gitcast 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. ai/__init__.py +0 -0
  2. ai/formatter.py +59 -0
  3. ai/generator.py +604 -0
  4. ai/prompts.py +197 -0
  5. ai/viral_patterns.py +75 -0
  6. api/__init__.py +0 -0
  7. api/analytics.py +48 -0
  8. api/auth.py +49 -0
  9. api/auth_middleware.py +129 -0
  10. api/auth_routes.py +117 -0
  11. api/monitoring.py +56 -0
  12. api/payload.py +253 -0
  13. api/ratelimit.py +9 -0
  14. api/routes.py +1565 -0
  15. api/server.py +162 -0
  16. api/validators.py +101 -0
  17. assets/__init__.py +1 -0
  18. assets/favicon-16x16.png +0 -0
  19. assets/favicon-32x32.png +0 -0
  20. assets/favicon-64x64.png +0 -0
  21. assets/favicon.ico +0 -0
  22. assets/icon.png +0 -0
  23. cli/.env.example +26 -0
  24. cli/__init__.py +1 -0
  25. cli/gitcast.py +79 -0
  26. config/__init__.py +0 -0
  27. config/settings.py +213 -0
  28. core/__init__.py +0 -0
  29. core/capture.py +258 -0
  30. core/codebase_reader.py +90 -0
  31. core/framing.py +86 -0
  32. core/hotkey.py +21 -0
  33. core/log_stream.py +50 -0
  34. core/ocr.py +173 -0
  35. core/screenshot_session.py +274 -0
  36. core/security.py +126 -0
  37. core/tray.py +54 -0
  38. gitcast-1.0.0.dist-info/LICENSE +21 -0
  39. gitcast-1.0.0.dist-info/METADATA +67 -0
  40. gitcast-1.0.0.dist-info/RECORD +61 -0
  41. gitcast-1.0.0.dist-info/WHEEL +5 -0
  42. gitcast-1.0.0.dist-info/entry_points.txt +2 -0
  43. gitcast-1.0.0.dist-info/top_level.txt +10 -0
  44. publisher/__init__.py +0 -0
  45. publisher/clipboard.py +44 -0
  46. publisher/twitter.py +100 -0
  47. storage/__init__.py +0 -0
  48. storage/cleanup.py +60 -0
  49. storage/engagement.py +114 -0
  50. storage/insights.py +203 -0
  51. storage/key_manager.py +45 -0
  52. storage/logger.py +208 -0
  53. storage/metrics.py +119 -0
  54. storage/sprint.py +40 -0
  55. storage/streak.py +0 -0
  56. storage/supabase_client.py +25 -0
  57. storage/tone_memory.py +139 -0
  58. ui/__init__.py +0 -0
  59. web/__init__.py +1 -0
  60. web/index.html +4994 -0
  61. web/landing.html +925 -0
ai/__init__.py ADDED
File without changes
ai/formatter.py ADDED
@@ -0,0 +1,59 @@
1
+ import re
2
+
3
+ # [Formatter] module for post cleaning and thread splitting
4
+
5
+ def split_into_thread(post_text: str, max_chars: int = 270) -> list[str]:
6
+ """
7
+ Splits text into multiple tweets at sentence boundaries.
8
+ Numbers each tweet: 1/ 2/ 3/
9
+ """
10
+ if len(post_text) <= 280:
11
+ return [post_text]
12
+
13
+ # Split into sentences (basic regex split)
14
+ sentences = re.split(r'(?<=[.!?])\s+', post_text.strip())
15
+
16
+ tweets = []
17
+ current_tweet = ""
18
+ tweet_number = 1
19
+
20
+ for sentence in sentences:
21
+ # Check if adding this sentence exceeds limit (considering "N/ " prefix)
22
+ prefix = f"{tweet_number}/ "
23
+ potential_content = current_tweet + (" " if current_tweet else "") + sentence
24
+
25
+ if len(prefix + potential_content) <= max_chars:
26
+ current_tweet = potential_content
27
+ else:
28
+ if current_tweet:
29
+ tweets.append(f"{tweet_number}/ {current_tweet}")
30
+ tweet_number += 1
31
+
32
+ # If a single sentence is too long, we must split it by words
33
+ if len(f"{tweet_number}/ {sentence}") > max_chars:
34
+ words = sentence.split()
35
+ sub_tweet = ""
36
+ for word in words:
37
+ if len(f"{tweet_number}/ {sub_tweet} {word}") <= max_chars:
38
+ sub_tweet = f"{sub_tweet} {word}".strip()
39
+ else:
40
+ tweets.append(f"{tweet_number}/ {sub_tweet}...")
41
+ tweet_number += 1
42
+ sub_tweet = word
43
+ current_tweet = sub_tweet
44
+ else:
45
+ current_tweet = sentence
46
+
47
+ if current_tweet:
48
+ tweets.append(f"{tweet_number}/ {current_tweet}")
49
+
50
+ return tweets
51
+
52
+ if __name__ == "__main__":
53
+ print("=== THREAD SPLITTER TEST ===")
54
+ long_post = "This is a very long post that should be split into multiple tweets. It contains many sentences. Each sentence is relatively short but together they exceed the limit of a single tweet. We want to see how the formatter handles this situation by splitting at sentence boundaries and numbering the resulting tweets correctly."
55
+
56
+ # Force small limit for testing
57
+ result = split_into_thread(long_post, max_chars=100)
58
+ for t in result:
59
+ print(f"[{len(t)} chars] {t}")
ai/generator.py ADDED
@@ -0,0 +1,604 @@
1
+ import asyncio
2
+ import httpx
3
+ import uuid
4
+ from datetime import datetime
5
+ from ai.prompts import get_all_prompts, sprint_summary_prompt
6
+ from core.log_stream import stream_log
7
+ from api.analytics import track
8
+ from config.settings import (
9
+ GROQ_API_KEY,
10
+ GEMINI_API_KEY,
11
+ MOONSHOT_API_KEY,
12
+ CEREBRAS_API_KEY,
13
+ OPENROUTER_API_KEY,
14
+ GROQ_MODEL,
15
+ MOONSHOT_MODEL,
16
+ GEMINI_MODEL,
17
+ CEREBRAS_MODEL,
18
+ OPENROUTER_MODEL,
19
+ )
20
+
21
+ # ── Constants ─────────────────────────────────────────────────────────────────
22
+
23
+ PROVIDERS = {
24
+ "groq": {
25
+ "base_url": "https://api.groq.com/openai/v1",
26
+ "api_key": GROQ_API_KEY,
27
+ "model": GROQ_MODEL,
28
+ "tasks": ["quick_win", "struggle", "linkedin", "deep_tech", "pr_generator"]
29
+ },
30
+ "kimi": {
31
+ "base_url": "https://api.moonshot.cn/v1",
32
+ "api_key": MOONSHOT_API_KEY,
33
+ "model": MOONSHOT_MODEL,
34
+ "tasks": ["article", "sprint_summary"]
35
+ },
36
+ "cerebras": {
37
+ "base_url": "https://api.cerebras.ai/v1",
38
+ "api_key": CEREBRAS_API_KEY,
39
+ "model": CEREBRAS_MODEL,
40
+ "tasks": [] # fallback only
41
+ },
42
+ "openrouter": {
43
+ "base_url": "https://openrouter.ai/api/v1",
44
+ "api_key": OPENROUTER_API_KEY,
45
+ "model": OPENROUTER_MODEL,
46
+ "tasks": [] # fallback only
47
+ }
48
+ }
49
+
50
+ GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent"
51
+ MAX_TOKENS = 4096
52
+ TIMEOUT = 15
53
+
54
+ _last_call_meta = {"provider_used": "", "used_fallback": False}
55
+
56
+
57
+ class ProviderUnavailable(Exception):
58
+ def __init__(self, provider: str, message: str, status_code: int | None = None):
59
+ super().__init__(message)
60
+ self.provider = provider
61
+ self.status_code = status_code
62
+
63
+
64
+ def _is_uuid(value: str) -> bool:
65
+ try:
66
+ uuid.UUID(str(value))
67
+ return True
68
+ except (TypeError, ValueError):
69
+ return False
70
+
71
+
72
+ def _load_user_provider_keys(user_id: str) -> dict:
73
+ from storage.key_manager import decrypt_key
74
+ from storage.supabase_client import get_client
75
+
76
+ if not user_id or not _is_uuid(user_id):
77
+ return {}
78
+
79
+ response = (
80
+ get_client()
81
+ .table("api_keys")
82
+ .select("provider,encrypted_key")
83
+ .eq("user_id", user_id)
84
+ .execute()
85
+ )
86
+ keys = {}
87
+ for row in response.data or []:
88
+ provider = row.get("provider")
89
+ encrypted_key = row.get("encrypted_key")
90
+ if not provider or not encrypted_key:
91
+ continue
92
+ try:
93
+ keys[provider] = decrypt_key(encrypted_key)
94
+ (
95
+ get_client()
96
+ .table("api_keys")
97
+ .update({"last_used_at": datetime.now().isoformat()})
98
+ .eq("user_id", user_id)
99
+ .eq("provider", provider)
100
+ .execute()
101
+ )
102
+ except Exception as e:
103
+ stream_log("Keys", "WARN", f"{provider} key decrypt failed: {e}")
104
+ return keys
105
+
106
+
107
+ def refresh_provider_keys(user_id: str = "") -> None:
108
+ from config import settings
109
+
110
+ settings.reload_api_keys()
111
+ user_keys = _load_user_provider_keys(user_id) if user_id else {}
112
+ PROVIDERS["groq"]["api_key"] = settings.GROQ_API_KEY
113
+ PROVIDERS["kimi"]["api_key"] = settings.MOONSHOT_API_KEY
114
+ PROVIDERS["cerebras"]["api_key"] = settings.CEREBRAS_API_KEY
115
+ PROVIDERS["openrouter"]["api_key"] = settings.OPENROUTER_API_KEY
116
+ PROVIDERS["groq"]["model"] = settings.GROQ_MODEL
117
+ PROVIDERS["kimi"]["model"] = settings.MOONSHOT_MODEL
118
+ PROVIDERS["cerebras"]["model"] = settings.CEREBRAS_MODEL
119
+ PROVIDERS["openrouter"]["model"] = settings.OPENROUTER_MODEL
120
+
121
+ global GEMINI_API_KEY
122
+ global GEMINI_URL
123
+ GEMINI_API_KEY = settings.GEMINI_API_KEY
124
+ GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{settings.GEMINI_MODEL}:generateContent"
125
+ for provider, api_key in user_keys.items():
126
+ if provider == "gemini":
127
+ GEMINI_API_KEY = api_key
128
+ elif provider in PROVIDERS:
129
+ PROVIDERS[provider]["api_key"] = api_key
130
+
131
+
132
+ # ── Main generator ────────────────────────────────────────────────────────────
133
+
134
+ async def generate_posts(payload: dict, user_id: str = "") -> dict:
135
+ async def _generate_all():
136
+ refresh_provider_keys(user_id)
137
+ prompts = get_all_prompts()
138
+ format_keys = payload.get("format_keys", list(prompts.keys()))
139
+ user_message = payload.get("user_message", "")
140
+ use_vision = payload.get("use_vision_fallback", False)
141
+
142
+ screenshots = payload.get("screenshots", [])
143
+ if not screenshots and payload.get("screenshot_path"):
144
+ screenshots = [{"path": payload["screenshot_path"]}]
145
+
146
+ selected_prompts = {k: v for k, v in prompts.items() if k in format_keys}
147
+
148
+ output = {}
149
+ unavailable = {}
150
+
151
+ async def run_one(format_key, system_prompt, client):
152
+ started = asyncio.get_event_loop().time()
153
+ active_user_message = user_message
154
+ if format_key == "pr_generator":
155
+ if "## Screen context" in active_user_message:
156
+ parts = active_user_message.split("## Screen context")
157
+ prefix = parts[0].strip()
158
+ suffix = parts[1].strip()
159
+ if "## " in suffix:
160
+ next_idx = suffix.find("## ")
161
+ active_user_message = prefix + "\n\n" + suffix[next_idx:].strip()
162
+ else:
163
+ active_user_message = prefix
164
+
165
+ local_meta = {"provider_used": "", "used_fallback": False}
166
+ try:
167
+ if use_vision and screenshots and format_key != "pr_generator":
168
+ stream_log("ROUTER", "ROUTER", f"{format_key} -> gemini vision fallback")
169
+ stream_log("GENERATOR", "INFO", f"generating {format_key} via gemini...")
170
+ t0 = asyncio.get_event_loop().time()
171
+ try:
172
+ result = await _gemini_vision_call(
173
+ system_prompt=system_prompt,
174
+ user_message=active_user_message,
175
+ screenshots=screenshots,
176
+ client=client,
177
+ )
178
+ elapsed = asyncio.get_event_loop().time() - t0
179
+ stream_log("GENERATOR", "OK", f"{format_key} complete ({elapsed:.1f}s)")
180
+ local_meta.update({"provider_used": "gemini", "used_fallback": True})
181
+ except Exception as e:
182
+ print(f"[Generator] {format_key} failed on gemini: {e}")
183
+ stream_log("GENERATOR", "WARN", f"{format_key} failed on gemini — trying fallback")
184
+ raise e
185
+ else:
186
+ result = await _ai_call(
187
+ format_key,
188
+ system_prompt,
189
+ active_user_message,
190
+ user_id=user_id,
191
+ unavailable=unavailable,
192
+ client=client,
193
+ meta=local_meta,
194
+ )
195
+
196
+ output[format_key] = result
197
+ elapsed = asyncio.get_event_loop().time() - started
198
+ track("post_generated", {
199
+ "format_keys": [format_key],
200
+ "provider_used": local_meta.get("provider_used", ""),
201
+ "latency_seconds": round(elapsed, 1),
202
+ "used_fallback": bool(local_meta.get("used_fallback", False)),
203
+ })
204
+ except Exception as e:
205
+ stream_log("Generator", "ERROR", f"{format_key} failed: {e}")
206
+ output[format_key] = f"[Error] {str(e)}"
207
+
208
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
209
+ tasks = [
210
+ run_one(format_key, system_prompt, client)
211
+ for format_key, system_prompt in selected_prompts.items()
212
+ ]
213
+ await asyncio.gather(*tasks)
214
+
215
+ # Check if all format generation failed
216
+ all_failed = True
217
+ for format_key in format_keys:
218
+ val = output.get(format_key)
219
+ if val and not val.startswith("[Error]"):
220
+ all_failed = False
221
+ break
222
+
223
+ if all_failed:
224
+ return {"error": "all providers failed — check your API keys"}
225
+
226
+ return output
227
+
228
+ try:
229
+ # Add a 30 second total timeout across all calls
230
+ return await asyncio.wait_for(_generate_all(), timeout=30.0)
231
+ except asyncio.TimeoutError:
232
+ stream_log("Generator", "ERROR", "Generation timed out after 30 seconds")
233
+ return {"error": "all providers failed — check your API keys"}
234
+ except Exception as e:
235
+ stream_log("Generator", "ERROR", f"Generation failed: {e}")
236
+ return {"error": "all providers failed — check your API keys"}
237
+
238
+
239
+ # ── Sprint generator ──────────────────────────────────────────────────────────
240
+
241
+ async def generate_sprint_summary(entries: list, narrative: str = "", user_id: str = "") -> str:
242
+ """
243
+ Synthesizes multiple sprint captures into a single cohesive thread.
244
+ """
245
+ from api.payload import build_sprint_payload
246
+
247
+ stream_log("Generator", "AI", f"synthesizing {len(entries)} captures into sprint thread")
248
+
249
+ refresh_provider_keys(user_id)
250
+ payload = build_sprint_payload(entries)
251
+ system_prompt = sprint_summary_prompt(len(entries))
252
+
253
+ try:
254
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
255
+ return await _ai_call("sprint_summary", system_prompt, payload["user_message"], user_id=user_id, client=client)
256
+ except Exception as e:
257
+ stream_log("Generator", "ERROR", f"sprint synthesis failed: {e}")
258
+ return f"[Error] {str(e)}"
259
+
260
+
261
+ # ── Provider Routing & Fallback ────────────────────────────────────────────────
262
+
263
+ async def _ai_call(
264
+ format_key: str,
265
+ system_prompt: str,
266
+ user_message: str,
267
+ user_id: str = "",
268
+ unavailable: dict[str, str] | None = None,
269
+ client: httpx.AsyncClient = None,
270
+ meta: dict | None = None,
271
+ ) -> str:
272
+ """
273
+ Identifies primary provider for a task and falls back through available providers.
274
+ """
275
+ unavailable = unavailable if unavailable is not None else {}
276
+
277
+ # 1. Identify primary provider
278
+ primary = "groq" # default
279
+ for name, config in PROVIDERS.items():
280
+ if format_key in config["tasks"]:
281
+ primary = name
282
+ break
283
+
284
+ # 2. Build the fallback chain
285
+ # Standard order: Primary -> Groq -> Cerebras -> Kimi -> OpenRouter -> Gemini
286
+ chain = [primary]
287
+ for fallback in ["groq", "cerebras", "kimi", "openrouter"]:
288
+ if fallback not in chain:
289
+ chain.append(fallback)
290
+
291
+ # 3. Walk the chain
292
+ last_error = None
293
+ stream_log("ROUTER", "ROUTER", f"{format_key} -> {primary}")
294
+
295
+ async def run_chain(active_client: httpx.AsyncClient):
296
+ nonlocal last_error
297
+ for provider_name in chain:
298
+ if provider_name in unavailable:
299
+ stream_log("ROUTER", "WARN", f"{provider_name} skipped: {unavailable[provider_name]}")
300
+ last_error = unavailable[provider_name]
301
+ continue
302
+
303
+ stream_log("GENERATOR", "INFO", f"generating {format_key} via {provider_name}...")
304
+ t0 = asyncio.get_event_loop().time()
305
+ try:
306
+ if provider_name != primary:
307
+ stream_log("ROUTER", "ROUTER", f"{format_key} -> {provider_name} fallback")
308
+ result = await _call_provider(provider_name, system_prompt, user_message, client=active_client)
309
+ _last_call_meta.update({
310
+ "provider_used": provider_name,
311
+ "used_fallback": provider_name != primary,
312
+ })
313
+ if meta is not None:
314
+ meta.update({
315
+ "provider_used": provider_name,
316
+ "used_fallback": provider_name != primary,
317
+ })
318
+ elapsed = asyncio.get_event_loop().time() - t0
319
+ stream_log("GENERATOR", "OK", f"{format_key} complete ({elapsed:.1f}s)")
320
+ return result
321
+ except ProviderUnavailable as e:
322
+ last_error = str(e)
323
+ unavailable[e.provider] = str(e)
324
+ print(f"[Generator] {format_key} failed on {provider_name}: {e}")
325
+ stream_log("GENERATOR", "WARN", f"{format_key} failed on {provider_name} — trying fallback")
326
+ continue
327
+ except Exception as e:
328
+ last_error = str(e)
329
+ unavailable[provider_name] = str(e)
330
+ print(f"[Generator] {format_key} failed on {provider_name}: {e}")
331
+ stream_log("GENERATOR", "WARN", f"{format_key} failed on {provider_name} — trying fallback")
332
+ continue
333
+
334
+ # 4. Final attempt with Gemini
335
+ if GEMINI_API_KEY and "gemini" not in unavailable:
336
+ stream_log("GENERATOR", "INFO", f"generating {format_key} via gemini...")
337
+ t0 = asyncio.get_event_loop().time()
338
+ try:
339
+ if "gemini" != primary:
340
+ stream_log("ROUTER", "ROUTER", f"{format_key} -> gemini fallback")
341
+ result = await _gemini_text_call(system_prompt, user_message, client=active_client)
342
+ _last_call_meta.update({"provider_used": "gemini", "used_fallback": True})
343
+ if meta is not None:
344
+ meta.update({"provider_used": "gemini", "used_fallback": True})
345
+ elapsed = asyncio.get_event_loop().time() - t0
346
+ stream_log("GENERATOR", "OK", f"{format_key} complete ({elapsed:.1f}s)")
347
+ return result
348
+ except Exception as e:
349
+ last_error = f"Gemini also failed: {e}"
350
+ unavailable["gemini"] = last_error
351
+ print(f"[Generator] {format_key} failed on gemini: {e}")
352
+ stream_log("GENERATOR", "WARN", f"{format_key} failed on gemini — trying fallback")
353
+ elif "gemini" in unavailable:
354
+ last_error = unavailable["gemini"]
355
+
356
+ raise Exception(f"AI chain exhausted. Last error: {last_error}")
357
+
358
+ if client:
359
+ return await run_chain(client)
360
+ else:
361
+ async with httpx.AsyncClient(timeout=TIMEOUT) as local_client:
362
+ return await run_chain(local_client)
363
+
364
+
365
+ async def _call_provider(
366
+ provider_name: str,
367
+ system_prompt: str,
368
+ user_message: str,
369
+ retries: int = 1,
370
+ client: httpx.AsyncClient = None,
371
+ ) -> str:
372
+ """
373
+ Single unified function for calling any OpenAI-compatible provider.
374
+ """
375
+ config = PROVIDERS.get(provider_name)
376
+ if not config or not config["api_key"]:
377
+ raise ValueError(f"Provider {provider_name} not configured or missing API key.")
378
+
379
+ url = f"{config['base_url'].rstrip('/')}/chat/completions"
380
+ headers = {
381
+ "Authorization": f"Bearer {config['api_key']}",
382
+ "Content-Type": "application/json",
383
+ }
384
+
385
+ body = {
386
+ "model": config["model"],
387
+ "max_tokens": MAX_TOKENS,
388
+ "temperature": 0.8,
389
+ "messages": [
390
+ {"role": "system", "content": system_prompt},
391
+ {"role": "user", "content": user_message},
392
+ ],
393
+ }
394
+
395
+ is_local_client = client is None
396
+ active_client = client if client is not None else httpx.AsyncClient(timeout=TIMEOUT)
397
+ try:
398
+ for attempt in range(retries + 1):
399
+ try:
400
+ stream_log(provider_name, "AI", f"Calling {config['model']} (timeout={TIMEOUT}s, attempt {attempt + 1}/{retries + 1})")
401
+ response = await active_client.post(url, headers=headers, json=body, timeout=TIMEOUT)
402
+
403
+ if response.status_code == 429:
404
+ if attempt < retries:
405
+ wait = 2 + (attempt * 2)
406
+ stream_log(provider_name, "WARN", f"Rate limited (429). Retrying in {wait}s...")
407
+ await asyncio.sleep(wait)
408
+ continue
409
+ raise ProviderUnavailable(provider_name, "Rate limit exceeded (429)", 429)
410
+
411
+ if response.status_code in {402, 404}:
412
+ raise ProviderUnavailable(
413
+ provider_name,
414
+ f"API Error {response.status_code}: {response.text[:180]}",
415
+ response.status_code,
416
+ )
417
+
418
+ if response.status_code != 200:
419
+ raise Exception(f"API Error {response.status_code}: {response.text[:180]}")
420
+
421
+ data = response.json()
422
+ content = data["choices"][0]["message"]["content"].strip()
423
+ stream_log(provider_name, "OK", "Provider call complete successfully.")
424
+ return content
425
+
426
+ except httpx.TimeoutException as te:
427
+ stream_log(provider_name, "ERROR", f"Request to {provider_name} timed out after {TIMEOUT}s: {te}")
428
+ if attempt == retries:
429
+ raise ProviderUnavailable(provider_name, f"Request to {provider_name} timed out after {TIMEOUT}s", 408)
430
+ await asyncio.sleep(1)
431
+ except Exception as e:
432
+ stream_log(provider_name, "ERROR", f"Request to {provider_name} failed: {e}")
433
+ if attempt == retries:
434
+ raise e
435
+ await asyncio.sleep(1)
436
+ finally:
437
+ if is_local_client:
438
+ await active_client.aclose()
439
+
440
+ raise Exception(f"Provider {provider_name} failed after retries.")
441
+
442
+
443
+ # Legacy aliases for backward compatibility with routes
444
+ async def _groq_call(system_prompt: str, user_message: str, retries: int = 2) -> str:
445
+ return await _call_provider("groq", system_prompt, user_message, retries=retries)
446
+
447
+
448
+ # ── Gemini text call ──────────────────────────────────────────────────────────
449
+
450
+ async def _gemini_text_call(system_prompt: str, user_message: str, client: httpx.AsyncClient = None) -> str:
451
+ """
452
+ Fallback text generation using Gemini 1.5 Flash.
453
+ """
454
+ if not GEMINI_API_KEY:
455
+ raise ValueError("GEMINI_API_KEY is not set in .env")
456
+
457
+ url = GEMINI_URL
458
+ headers = {
459
+ "Content-Type": "application/json",
460
+ "x-goog-api-key": GEMINI_API_KEY
461
+ }
462
+
463
+ body = {
464
+ "contents": [
465
+ {
466
+ "parts": [
467
+ {"text": f"System Instruction: {system_prompt}\n\nUser Message: {user_message}"}
468
+ ]
469
+ }
470
+ ],
471
+ "generationConfig": {
472
+ "maxOutputTokens": MAX_TOKENS,
473
+ "temperature": 0.7,
474
+ },
475
+ }
476
+
477
+ is_local_client = client is None
478
+ active_client = client if client is not None else httpx.AsyncClient(timeout=TIMEOUT)
479
+ try:
480
+ stream_log("Gemini", "AI", f"Calling Gemini text fallback API (timeout={TIMEOUT}s)")
481
+ response = await active_client.post(url, headers=headers, json=body, timeout=TIMEOUT)
482
+ if response.status_code != 200:
483
+ detail = response.text[:240]
484
+ stream_log("Gemini", "ERROR", f"Text call failed with {response.status_code}: {detail}")
485
+ if response.status_code in {400, 401, 403, 404, 429}:
486
+ raise ProviderUnavailable("gemini", f"Gemini API Error {response.status_code}: {detail}", response.status_code)
487
+ raise Exception(f"Gemini API Error {response.status_code}: {detail}")
488
+
489
+ data = response.json()
490
+ stream_log("Gemini", "OK", "Gemini text fallback complete successfully.")
491
+ return data["candidates"][0]["content"]["parts"][0]["text"].strip()
492
+ except httpx.TimeoutException as te:
493
+ stream_log("Gemini", "ERROR", f"Gemini text fallback call timed out after {TIMEOUT}s: {te}")
494
+ raise te
495
+ except Exception as e:
496
+ stream_log("Gemini", "ERROR", f"Gemini text fallback failed: {e}")
497
+ raise e
498
+ finally:
499
+ if is_local_client:
500
+ await active_client.aclose()
501
+
502
+
503
+ # ── Gemini vision call ────────────────────────────────────────────────────────
504
+
505
+ async def _gemini_vision_call(
506
+ system_prompt: str,
507
+ user_message: str,
508
+ screenshots: list,
509
+ client: httpx.AsyncClient = None,
510
+ ) -> str:
511
+ """
512
+ Calls Gemini 1.5 Flash with multiple screenshots as vision inputs.
513
+ """
514
+ if not GEMINI_API_KEY:
515
+ raise ValueError("GEMINI_API_KEY is not set in .env")
516
+
517
+ url = f"{GEMINI_URL}?key={GEMINI_API_KEY}"
518
+ headers = {"Content-Type": "application/json"}
519
+
520
+ parts = [{"text": f"{system_prompt}\n\n{user_message}"}]
521
+
522
+ # Add each screenshot
523
+ for s in screenshots:
524
+ b64 = _encode_image(s["path"])
525
+ if b64:
526
+ parts.append({
527
+ "inline_data": {
528
+ "mime_type": "image/png",
529
+ "data": b64,
530
+ }
531
+ })
532
+
533
+ body = {
534
+ "contents": [{"parts": parts}],
535
+ "generationConfig": {
536
+ "maxOutputTokens": MAX_TOKENS,
537
+ "temperature": 0.85,
538
+ },
539
+ }
540
+
541
+ is_local_client = client is None
542
+ active_client = client if client is not None else httpx.AsyncClient(timeout=TIMEOUT)
543
+ try:
544
+ stream_log("Gemini", "AI", f"Calling Gemini vision with {len(parts) - 1} screenshot(s) (timeout={TIMEOUT}s)")
545
+ response = await active_client.post(url, headers=headers, json=body, timeout=TIMEOUT)
546
+ if response.status_code != 200:
547
+ raise Exception(f"Gemini Vision Error {response.status_code}: {response.text}")
548
+
549
+ data = response.json()
550
+ stream_log("Gemini", "OK", "Gemini vision call complete successfully.")
551
+ return data["candidates"][0]["content"]["parts"][0]["text"].strip()
552
+ except httpx.TimeoutException as te:
553
+ stream_log("Gemini", "ERROR", f"Gemini vision call timed out after {TIMEOUT}s: {te}")
554
+ raise te
555
+ except (KeyError, IndexError):
556
+ raise Exception("Gemini response format unexpected")
557
+ except Exception as e:
558
+ stream_log("Gemini", "ERROR", f"Gemini vision call failed: {e}")
559
+ raise e
560
+ finally:
561
+ if is_local_client:
562
+ await active_client.aclose()
563
+
564
+
565
+ def _encode_image(image_path: str) -> str:
566
+ import base64
567
+ try:
568
+ with open(image_path, "rb") as f:
569
+ return base64.b64encode(f.read()).decode("utf-8")
570
+ except Exception:
571
+ return ""
572
+
573
+
574
+ # ── Test ──────────────────────────────────────────────────────────────────────
575
+
576
+ if __name__ == "__main__":
577
+ import asyncio
578
+ from core.capture import run_capture
579
+ from core.ocr import run_ocr
580
+ from api.payload import build_payload
581
+ from config.settings import set_project_narrative
582
+
583
+ set_project_narrative("an AI-powered build-in-public automation tool for developers")
584
+
585
+ async def test():
586
+ print("[Generator] Running multi-provider routing test...")
587
+ capture = run_capture()
588
+ ocr = run_ocr(capture["screenshot"]["path"])
589
+ payload = build_payload(
590
+ raw_thought="testing the new multi-provider fallback chain with Cerebras and OpenRouter",
591
+ )
592
+
593
+ payload["use_vision_fallback"] = False
594
+ payload["screenshot_b64"] = None
595
+
596
+ print("[Generator] Firing routed AI calls...")
597
+ results = await generate_posts(payload)
598
+
599
+ print("\n=== GENERATED POSTS ===")
600
+ for format_key, content in results.items():
601
+ print(f"\n--- {format_key.upper()} ---")
602
+ print(content)
603
+
604
+ asyncio.run(test())