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
api/routes.py ADDED
@@ -0,0 +1,1565 @@
1
+ import asyncio
2
+ import time
3
+ from datetime import datetime, timedelta
4
+ from fastapi import APIRouter, HTTPException, Depends, Request
5
+ from fastapi.responses import StreamingResponse
6
+ from pydantic import BaseModel
7
+ from typing import Optional, List
8
+ from dotenv import dotenv_values, load_dotenv, set_key, unset_key
9
+ from ai.generator import generate_posts, generate_sprint_summary, _ai_call
10
+ from ai.prompts import load_prompt_definitions, PROMPTS_FILE, article_prompt, article_refinement_prompt, get_prompt
11
+ from ai.formatter import split_into_thread
12
+ from ai.viral_patterns import get_all_patterns
13
+ from api.payload import validate_payload
14
+ from api.auth_middleware import LOCAL_USER_ID, get_current_user
15
+ from storage.logger import decline_post, get_unverified_posts, load_posts, log_post, verify_post
16
+ from storage.metrics import get_metrics, save_metrics
17
+ from storage.insights import calculate_insights
18
+ from storage.tone_memory import save_rating
19
+ from storage.key_manager import encrypt_key, mask_key
20
+ from storage.supabase_client import get_client
21
+ from core.codebase_reader import summarise_for_prompt
22
+ from config.settings import (
23
+ DEFAULTS,
24
+ get_twitter_plan,
25
+ load_settings,
26
+ set_twitter_plan,
27
+ save_settings,
28
+ validate_api_keys,
29
+ CURRENT_DRAFT,
30
+ SPRINT_LOG,
31
+ API_KEY_ENV_MAP,
32
+ BASE_DIR,
33
+ WAITLIST_FILE,
34
+ reload_api_keys,
35
+ )
36
+ from core.log_stream import get_logs_after, get_latest_log_id, stream_log
37
+ from api.analytics import track
38
+ from api.ratelimit import limiter
39
+ from api.validators import (
40
+ check_prompt_injection,
41
+ sanitize_path,
42
+ sanitize_text,
43
+ validate_api_key as validate_provider_api_key,
44
+ validate_email,
45
+ )
46
+ import json
47
+ import os
48
+
49
+ router = APIRouter()
50
+
51
+
52
+ # ── Request / Response models ─────────────────────────────────────────────────
53
+
54
+ class GenerateRequest(BaseModel):
55
+ raw_thought: str
56
+ ocr_text: Optional[str] = ""
57
+ git_diff: Optional[str] = ""
58
+ git_diff_available: Optional[bool] = False
59
+ narrative: Optional[str] = ""
60
+ use_vision_fallback: Optional[bool] = False
61
+ screenshot_b64: Optional[str] = None
62
+ screenshot_path: Optional[str] = ""
63
+ user_message: Optional[str] = ""
64
+ format_keys: Optional[List[str]] = None
65
+ ocr_confidence: Optional[float] = 0.0
66
+ working_dir: Optional[str] = ""
67
+ timestamp: Optional[str] = ""
68
+
69
+
70
+ class PostVariation(BaseModel):
71
+ format_key: str
72
+ label: str
73
+ content: str
74
+ char_count: int
75
+ success: bool
76
+ error: Optional[str] = None
77
+
78
+
79
+ class GenerateResponse(BaseModel):
80
+ success: bool
81
+ variations: List[PostVariation]
82
+ warnings: List[str]
83
+ timestamp: str
84
+
85
+
86
+ class SprintRequest(BaseModel):
87
+ entries: List[dict]
88
+ narrative: Optional[str] = ""
89
+
90
+
91
+ class RateRequest(BaseModel):
92
+ post_text: str
93
+ format_key: str
94
+ rating: int
95
+ timestamp: Optional[str] = ""
96
+
97
+
98
+ class PromptUpdate(BaseModel):
99
+ format_key: str
100
+ label: str
101
+ description: str
102
+ system_prompt: str
103
+
104
+
105
+ class PromptDelete(BaseModel):
106
+ format_key: str
107
+
108
+
109
+ class RecommendRequest(BaseModel):
110
+ filename: str
111
+
112
+
113
+ class PlanUpdate(BaseModel):
114
+ plan: str
115
+
116
+
117
+ class KeysUpdate(BaseModel):
118
+ twitter_api_key: Optional[str] = None
119
+ twitter_api_secret: Optional[str] = None
120
+ twitter_access_token: Optional[str] = None
121
+ twitter_access_secret: Optional[str] = None
122
+ twitter_bearer_token: Optional[str] = None
123
+
124
+
125
+ class ProviderKeyUpdate(BaseModel):
126
+ provider: str
127
+ key: str
128
+
129
+
130
+ class ProviderKeyRemove(BaseModel):
131
+ provider: str
132
+
133
+
134
+ class ChatRequest(BaseModel):
135
+ message: str
136
+ format_key: str
137
+
138
+
139
+ class CliTriggerRequest(BaseModel):
140
+ thought: str
141
+
142
+
143
+ class CaptureTriggerRequest(BaseModel):
144
+ delay: Optional[int] = 5
145
+
146
+
147
+ class ArticleGenerateRequest(BaseModel):
148
+ include_codebase: Optional[bool] = False
149
+ repo_path: Optional[str] = "."
150
+
151
+
152
+ class ArticleRefineRequest(BaseModel):
153
+ current_article: str
154
+ instruction: str
155
+
156
+
157
+ class ThreadSplitRequest(BaseModel):
158
+ post_text: str
159
+
160
+
161
+ class SettingsUpdate(BaseModel):
162
+ project_narrative: Optional[str] = None
163
+ sprint_mode: Optional[bool] = None
164
+ tone_memory_enabled: Optional[bool] = None
165
+ ocr_confidence_threshold: Optional[int] = None
166
+ screenshot_retention_hours: Optional[int] = None
167
+ onboarding_complete: Optional[bool] = None
168
+ preferred_providers: Optional[dict] = None
169
+ viral_patterns_enabled: Optional[bool] = None
170
+
171
+
172
+ class PublishRequest(BaseModel):
173
+ post_text: str
174
+ screenshot_path: Optional[str] = None
175
+ format_key: Optional[str] = "deep_tech"
176
+
177
+
178
+ class WaitlistRequest(BaseModel):
179
+ email: str
180
+
181
+
182
+ class PostVerifyRequest(BaseModel):
183
+ post_id: str
184
+ post_url: Optional[str] = ""
185
+
186
+
187
+ class PostDeclineRequest(BaseModel):
188
+ post_id: str
189
+
190
+
191
+ class MetricsSaveRequest(BaseModel):
192
+ post_id: str
193
+ impressions: int
194
+ likes: int
195
+ comments: int
196
+ reposts: int
197
+ hashtags: Optional[List[str]] = []
198
+ days_after_post: int
199
+ platform: Optional[str] = ""
200
+
201
+
202
+ # ── Routes ────────────────────────────────────────────────────────────────────
203
+
204
+ _INSIGHTS_CACHE = {"timestamp": 0.0, "data": None}
205
+
206
+
207
+ def invalidate_insights_cache() -> None:
208
+ _INSIGHTS_CACHE["timestamp"] = 0.0
209
+ _INSIGHTS_CACHE["data"] = None
210
+
211
+
212
+ # ── Local Storage Helpers (Fallback) ──────────────────────────────────────────
213
+
214
+ def load_local_posts() -> list:
215
+ from config.settings import POST_LOG
216
+ if not POST_LOG.exists() or POST_LOG.stat().st_size == 0:
217
+ return []
218
+ try:
219
+ with open(POST_LOG, "r", encoding="utf-8") as f:
220
+ data = json.load(f)
221
+ if isinstance(data, list):
222
+ from storage.logger import _normalize_entry
223
+ return [_normalize_entry(entry) for entry in data]
224
+ return []
225
+ except Exception:
226
+ try:
227
+ posts = []
228
+ with open(POST_LOG, "r", encoding="utf-8") as f:
229
+ for line in f:
230
+ if line.strip():
231
+ posts.append(json.loads(line))
232
+ from storage.logger import _normalize_entry
233
+ return [_normalize_entry(entry) for entry in posts]
234
+ except Exception:
235
+ return []
236
+
237
+
238
+ def save_local_posts(posts: list) -> None:
239
+ from config.settings import POST_LOG
240
+ try:
241
+ POST_LOG.parent.mkdir(parents=True, exist_ok=True)
242
+ with open(POST_LOG, "w", encoding="utf-8") as f:
243
+ json.dump(posts, f, indent=4)
244
+ except Exception as e:
245
+ stream_log("Storage", "ERROR", f"Failed to save local posts: {e}")
246
+
247
+
248
+ async def safe_get_posts() -> list:
249
+ try:
250
+ from storage.supabase_client import get_client
251
+ client = get_client()
252
+ result = client.table("posts").select("*").order("timestamp", desc=True).execute()
253
+ if result.data:
254
+ from storage.logger import _normalize_entry
255
+ return [_normalize_entry(entry) for entry in result.data]
256
+ return []
257
+ except Exception as e:
258
+ stream_log("Storage", "WARN", f"Supabase get_posts failed: {e}. Falling back to local JSON.")
259
+ return load_local_posts()
260
+
261
+
262
+ async def safe_save_post(post_data: dict) -> bool:
263
+ try:
264
+ from storage.supabase_client import get_client
265
+ client = get_client()
266
+ payload = {
267
+ "post_text": post_data.get("post_text"),
268
+ "format_key": post_data.get("format_key", "deep_tech"),
269
+ "tweet_url": post_data.get("tweet_url", "") or post_data.get("post_url", ""),
270
+ "tweet_id": post_data.get("tweet_id", ""),
271
+ "provider_used": post_data.get("provider_used", ""),
272
+ "platform": post_data.get("platform", "twitter"),
273
+ "user_id": post_data.get("user_id"),
274
+ }
275
+ if "timestamp" in post_data and post_data["timestamp"]:
276
+ payload["timestamp"] = post_data["timestamp"]
277
+ client.table("posts").insert(payload).execute()
278
+ return True
279
+ except Exception as e:
280
+ stream_log("Storage", "WARN", f"Supabase save_post failed: {e}. Falling back to local JSON.")
281
+ posts = load_local_posts()
282
+ from uuid import uuid4
283
+ new_entry = {
284
+ "id": post_data.get("id") or str(uuid4()),
285
+ "post_text": post_data.get("post_text"),
286
+ "format_key": post_data.get("format_key", "deep_tech"),
287
+ "screenshot_path": post_data.get("screenshot_path", ""),
288
+ "tweet_url": post_data.get("tweet_url", "") or post_data.get("post_url", ""),
289
+ "tweet_id": post_data.get("tweet_id", ""),
290
+ "fallback": post_data.get("fallback", False),
291
+ "timestamp": post_data.get("timestamp") or datetime.now().isoformat(),
292
+ "user_id": post_data.get("user_id", LOCAL_USER_ID),
293
+ "posted_verified": post_data.get("posted_verified", False),
294
+ "declined": post_data.get("declined", False) or post_data.get("posted_declined", False),
295
+ "verified_at": post_data.get("verified_at", ""),
296
+ "metrics_saved": post_data.get("metrics_saved", False),
297
+ "metrics_saved_at": post_data.get("metrics_saved_at", ""),
298
+ "impressions": post_data.get("impressions", 0),
299
+ "likes": post_data.get("likes", 0),
300
+ "comments": post_data.get("comments", 0),
301
+ "reposts": post_data.get("reposts", 0),
302
+ "hashtags": post_data.get("hashtags", []),
303
+ "platform": post_data.get("platform", "twitter"),
304
+ "days_after_post": post_data.get("days_after_post", 0),
305
+ }
306
+ posts.append(new_entry)
307
+ save_local_posts(posts)
308
+ return True
309
+
310
+
311
+ async def safe_update_post(post_id: str, updates: dict) -> bool:
312
+ try:
313
+ from storage.supabase_client import get_client
314
+ client = get_client()
315
+ result = client.table("posts").update(updates).eq("id", post_id).execute()
316
+ if result.data:
317
+ return True
318
+ raise Exception("Post not found in Supabase")
319
+ except Exception as e:
320
+ stream_log("Storage", "WARN", f"Supabase update post failed: {e}. Updating local JSON.")
321
+ posts = load_local_posts()
322
+ found = False
323
+ for post in posts:
324
+ if post.get("id") == post_id:
325
+ for k, v in updates.items():
326
+ if k == "declined":
327
+ post["declined"] = v
328
+ post["posted_declined"] = v
329
+ elif k == "posted_verified":
330
+ post["posted_verified"] = v
331
+ post["posted_declined"] = False
332
+ post["declined"] = False
333
+ else:
334
+ post[k] = v
335
+ found = True
336
+ break
337
+ if found:
338
+ save_local_posts(posts)
339
+ return True
340
+ return False
341
+
342
+
343
+ async def safe_get_unverified() -> list:
344
+ posts = await safe_get_posts()
345
+ unverified = [
346
+ post for post in posts
347
+ if not post.get("posted_verified") and not post.get("posted_declined") and not post.get("declined")
348
+ ]
349
+ return sorted(unverified, key=lambda item: item.get("timestamp", ""), reverse=True)
350
+
351
+
352
+ async def safe_save_metrics(post_id: str, metrics: dict) -> dict:
353
+ payload = {
354
+ "impressions": metrics["impressions"],
355
+ "likes": metrics["likes"],
356
+ "comments": metrics["comments"],
357
+ "reposts": metrics["reposts"],
358
+ "hashtags": metrics["hashtags"],
359
+ "platform": metrics["platform"] or "twitter",
360
+ "days_after_post": metrics["days_after_post"],
361
+ "metrics_saved": True,
362
+ "metrics_saved_at": datetime.now().isoformat(),
363
+ }
364
+ try:
365
+ from storage.supabase_client import get_client
366
+ client = get_client()
367
+ result = client.table("posts").update(payload).eq("id", post_id).execute()
368
+ if result.data:
369
+ return {"success": True}
370
+ raise Exception("Post not found in Supabase")
371
+ except Exception as e:
372
+ stream_log("Storage", "WARN", f"Supabase save metrics failed: {e}. Saving to local JSON.")
373
+ posts = load_local_posts()
374
+ found = False
375
+ for post in posts:
376
+ if post.get("id") == post_id:
377
+ for k, v in payload.items():
378
+ post[k] = v
379
+ found = True
380
+ break
381
+ if found:
382
+ save_local_posts(posts)
383
+ from config.settings import METRICS_LOG
384
+ try:
385
+ metrics_log = []
386
+ if METRICS_LOG.exists() and METRICS_LOG.stat().st_size > 0:
387
+ with open(METRICS_LOG, "r", encoding="utf-8") as f:
388
+ metrics_log = json.load(f)
389
+ existing = False
390
+ for m in metrics_log:
391
+ if m.get("post_id") == post_id:
392
+ m.update(payload)
393
+ existing = True
394
+ break
395
+ if not existing:
396
+ metrics_log.append({
397
+ "post_id": post_id,
398
+ **payload
399
+ })
400
+ with open(METRICS_LOG, "w", encoding="utf-8") as f:
401
+ json.dump(metrics_log, f, indent=4)
402
+ except Exception as e_log:
403
+ stream_log("Storage", "ERROR", f"Failed to save metrics_log: {e_log}")
404
+ return {"success": True}
405
+ return {"success": False, "error": "post not found"}
406
+
407
+
408
+ KEY_GUIDE = {
409
+ "groq": {
410
+ "name": "Groq",
411
+ "url": "https://console.groq.com",
412
+ "free_tier": "12k TPM free",
413
+ "best_for": "quick_win, struggle, linkedin",
414
+ "required": True,
415
+ },
416
+ "gemini": {
417
+ "name": "Gemini",
418
+ "url": "https://aistudio.google.com",
419
+ "free_tier": "1M tokens/day",
420
+ "best_for": "vision fallback, low OCR screenshots",
421
+ "required": False,
422
+ },
423
+ "kimi": {
424
+ "name": "Kimi",
425
+ "url": "https://platform.moonshot.ai",
426
+ "free_tier": "free trial credits",
427
+ "best_for": "article, sprint_summary",
428
+ "required": False,
429
+ },
430
+ "cerebras": {
431
+ "name": "Cerebras",
432
+ "url": "https://cloud.cerebras.ai",
433
+ "free_tier": "free inference tier",
434
+ "best_for": "fallback generation",
435
+ "required": False,
436
+ },
437
+ "openrouter": {
438
+ "name": "OpenRouter",
439
+ "url": "https://openrouter.ai",
440
+ "free_tier": "free models available",
441
+ "best_for": "final fallback via qwen/qwen3-coder:free",
442
+ "required": False,
443
+ },
444
+ }
445
+
446
+
447
+ def _env_path():
448
+ path = BASE_DIR / ".env"
449
+ path.touch(exist_ok=True)
450
+ return path
451
+
452
+
453
+ def _normalize_provider(provider: str) -> str:
454
+ normalized = provider.strip().lower()
455
+ if normalized not in API_KEY_ENV_MAP:
456
+ raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}")
457
+ return normalized
458
+
459
+
460
+ def _is_local_user(user_id: str) -> bool:
461
+ return user_id == LOCAL_USER_ID
462
+
463
+
464
+ def _reload_key_runtime() -> None:
465
+ load_dotenv(_env_path(), override=True)
466
+ reload_api_keys()
467
+ try:
468
+ from ai.generator import refresh_provider_keys
469
+ refresh_provider_keys()
470
+ except Exception as e:
471
+ stream_log("Keys", "WARN", f"provider runtime refresh failed: {e}")
472
+
473
+
474
+ def _settings_for_user(user_id: str) -> dict:
475
+ def local_settings() -> dict:
476
+ settings = load_settings()
477
+ return {**DEFAULTS, **settings, "user_id": user_id}
478
+
479
+ if _is_local_user(user_id):
480
+ return local_settings()
481
+
482
+ try:
483
+ client = get_client()
484
+ except RuntimeError as exc:
485
+ if "SUPABASE_URL and SUPABASE_SERVICE_KEY" not in str(exc):
486
+ raise
487
+ stream_log("Settings", "WARN", "Supabase not configured; using local settings")
488
+ return local_settings()
489
+
490
+ try:
491
+ response = (
492
+ client
493
+ .table("user_settings")
494
+ .select("*")
495
+ .eq("user_id", user_id)
496
+ .execute()
497
+ )
498
+ if response.data:
499
+ return response.data[0]
500
+ created = client.table("user_settings").insert({"user_id": user_id}).execute()
501
+ return created.data[0] if created.data else {"user_id": user_id}
502
+ except Exception as exc:
503
+ stream_log("Settings", "WARN", f"Supabase settings unavailable; using local settings: {exc}")
504
+ return local_settings()
505
+
506
+
507
+ def _update_settings_for_user(user_id: str, values: dict) -> dict:
508
+ def save_local_settings(payload: dict) -> dict:
509
+ settings = {**load_settings(), **payload}
510
+ settings.pop("updated_at", None)
511
+ save_settings(settings)
512
+ return {**DEFAULTS, **settings, "user_id": user_id}
513
+
514
+ payload = {key: value for key, value in values.items() if value is not None}
515
+ if not payload:
516
+ return _settings_for_user(user_id)
517
+ payload["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
518
+ if _is_local_user(user_id):
519
+ return save_local_settings(payload)
520
+
521
+ try:
522
+ client = get_client()
523
+ except RuntimeError as exc:
524
+ if "SUPABASE_URL and SUPABASE_SERVICE_KEY" not in str(exc):
525
+ raise
526
+ stream_log("Settings", "WARN", "Supabase not configured; saving local settings")
527
+ return save_local_settings(payload)
528
+
529
+ try:
530
+ response = (
531
+ client
532
+ .table("user_settings")
533
+ .upsert({"user_id": user_id, **payload}, on_conflict="user_id")
534
+ .execute()
535
+ )
536
+ return response.data[0] if response.data else _settings_for_user(user_id)
537
+ except Exception as exc:
538
+ stream_log("Settings", "WARN", f"Supabase settings unavailable; saving local settings: {exc}")
539
+ return save_local_settings(payload)
540
+
541
+
542
+ @router.get("/keys/status")
543
+ def get_ai_keys_status():
544
+ user_id = LOCAL_USER_ID
545
+ if _is_local_user(user_id):
546
+ env_values = dotenv_values(_env_path())
547
+ return {
548
+ provider: {
549
+ "configured": bool((env_values.get(env_name) or os.getenv(env_name) or "").strip()),
550
+ "key_preview": "",
551
+ "last_used_at": None,
552
+ }
553
+ for provider, env_name in API_KEY_ENV_MAP.items()
554
+ }
555
+
556
+ response = (
557
+ get_client()
558
+ .table("api_keys")
559
+ .select("provider,key_preview,last_used_at,created_at")
560
+ .eq("user_id", user_id)
561
+ .execute()
562
+ )
563
+ saved = {row["provider"]: row for row in response.data or []}
564
+ return {
565
+ provider: {
566
+ "configured": provider in saved,
567
+ "key_preview": saved.get(provider, {}).get("key_preview", ""),
568
+ "last_used_at": saved.get(provider, {}).get("last_used_at"),
569
+ }
570
+ for provider in API_KEY_ENV_MAP
571
+ }
572
+
573
+
574
+ @router.post("/keys/update")
575
+ @limiter.limit("5/minute")
576
+ def update_ai_key(request: Request, body: ProviderKeyUpdate, user_id: str = Depends(get_current_user)):
577
+ provider = _normalize_provider(sanitize_text(body.provider))
578
+ key = (body.key or "").strip()
579
+ if not validate_provider_api_key(key, provider):
580
+ raise HTTPException(status_code=400, detail=f"Invalid {provider} API key format")
581
+
582
+ try:
583
+ if _is_local_user(user_id):
584
+ set_key(str(_env_path()), API_KEY_ENV_MAP[provider], key)
585
+ _reload_key_runtime()
586
+ stream_log("Keys", "OK", f"{provider} key saved locally")
587
+ track("api_key_added", {"provider": provider, "storage": "local"})
588
+ return {"success": True, "provider": provider, "key_preview": mask_key(key)}
589
+
590
+ get_client().table("api_keys").upsert(
591
+ {
592
+ "user_id": user_id,
593
+ "provider": provider,
594
+ "encrypted_key": encrypt_key(key),
595
+ "key_preview": mask_key(key),
596
+ },
597
+ on_conflict="user_id,provider",
598
+ ).execute()
599
+ _reload_key_runtime()
600
+ stream_log("Keys", "OK", f"{provider} key updated")
601
+ track("api_key_added", {"provider": provider})
602
+ return {"success": True, "provider": provider, "key_preview": mask_key(key)}
603
+ except Exception as e:
604
+ stream_log("Keys", "ERROR", f"{provider} key update failed: {e}")
605
+ return {"success": False, "error": str(e)}
606
+
607
+
608
+ @router.delete("/keys/remove")
609
+ def remove_ai_key(request: ProviderKeyRemove, user_id: str = Depends(get_current_user)):
610
+ provider = _normalize_provider(sanitize_text(request.provider))
611
+ try:
612
+ if _is_local_user(user_id):
613
+ unset_key(str(_env_path()), API_KEY_ENV_MAP[provider])
614
+ _reload_key_runtime()
615
+ stream_log("Keys", "OK", f"{provider} key removed locally")
616
+ return {"success": True}
617
+
618
+ get_client().table("api_keys").delete().eq("user_id", user_id).eq("provider", provider).execute()
619
+ _reload_key_runtime()
620
+ stream_log("Keys", "OK", f"{provider} key removed")
621
+ return {"success": True}
622
+ except Exception as e:
623
+ stream_log("Keys", "ERROR", f"{provider} key removal failed: {e}")
624
+ return {"success": False, "error": str(e)}
625
+
626
+
627
+ @router.get("/keys/guide")
628
+ def get_keys_guide():
629
+ return KEY_GUIDE
630
+
631
+
632
+ @router.get("/logs/stream")
633
+ async def stream_logs():
634
+ async def event_generator():
635
+ last_id = max(0, get_latest_log_id() - 50)
636
+ while True:
637
+ entries = get_logs_after(last_id)
638
+ for entry in entries:
639
+ last_id = max(last_id, entry["id"])
640
+ payload = {key: value for key, value in entry.items() if key != "id"}
641
+ yield f"data: {json.dumps(payload)}\n\n"
642
+ await asyncio.sleep(1)
643
+
644
+ return StreamingResponse(
645
+ event_generator(),
646
+ media_type="text/event-stream",
647
+ headers={
648
+ "Cache-Control": "no-cache",
649
+ "Connection": "keep-alive",
650
+ },
651
+ )
652
+
653
+
654
+ @router.post("/waitlist")
655
+ def add_to_waitlist(request: WaitlistRequest):
656
+ """Adds an email to the public waitlist."""
657
+ email = sanitize_text(request.email).lower()
658
+ if not validate_email(email):
659
+ raise HTTPException(status_code=400, detail="Invalid email format")
660
+
661
+ try:
662
+ get_client().table("waitlist").upsert({"email": email, "source": "landing"}, on_conflict="email").execute()
663
+ return {"success": True, "message": "you're on the list"}
664
+ except Exception as e:
665
+ raise HTTPException(status_code=500, detail=f"Failed to join waitlist: {e}")
666
+
667
+
668
+ @router.patch("/settings")
669
+ @router.post("/settings")
670
+ def update_settings(update: SettingsUpdate):
671
+ """Updates user project settings."""
672
+ user_id = LOCAL_USER_ID
673
+ values = update.dict(exclude_unset=True)
674
+ if "project_narrative" in values and values["project_narrative"] is not None:
675
+ values["project_narrative"] = sanitize_text(values["project_narrative"])
676
+ updated = _update_settings_for_user(user_id, values)
677
+ return {"success": True, "settings": updated}
678
+
679
+
680
+ @router.post("/publish")
681
+ @limiter.limit("10/minute")
682
+ async def publish(request: Request, body: PublishRequest):
683
+ """Publishes a post to X (Twitter)."""
684
+ user_id = LOCAL_USER_ID
685
+ from publisher.twitter import publish_post
686
+ try:
687
+ post_text = sanitize_text(body.post_text)
688
+ screenshot_path = sanitize_path(body.screenshot_path) if body.screenshot_path else None
689
+ result = await asyncio.to_thread(publish_post, post_text, screenshot_path)
690
+ if result.get("success") or result.get("fallback"):
691
+ await safe_save_post({
692
+ "post_text": post_text,
693
+ "format_key": sanitize_text(body.format_key or "deep_tech"),
694
+ "screenshot_path": body.screenshot_path or "",
695
+ "tweet_url": result.get("tweet_url", ""),
696
+ "tweet_id": result.get("tweet_id", ""),
697
+ "fallback": bool(result.get("fallback")),
698
+ "user_id": user_id,
699
+ })
700
+ return result
701
+ except Exception as e:
702
+ raise HTTPException(status_code=500, detail=f"Publishing failed: {e}")
703
+
704
+
705
+ @router.delete("/screenshot/{filename}")
706
+ def delete_screenshot(filename: str):
707
+ """Securely deletes a screenshot from disk."""
708
+ from core.security import delete_capture
709
+ from config.settings import STORAGE_DIR
710
+ path = sanitize_path(str(STORAGE_DIR / "screenshots" / sanitize_text(filename)))
711
+ delete_capture(str(path))
712
+ return {"success": True}
713
+
714
+
715
+ @router.get("/screenshot/{filename}/framed")
716
+ async def get_framed_screenshot(filename: str):
717
+ """Applies a macOS-style frame to a screenshot and returns it."""
718
+ from PIL import Image, ImageDraw, ImageFilter
719
+ from config.settings import STORAGE_DIR
720
+ from fastapi.responses import Response
721
+ import io
722
+
723
+ path = STORAGE_DIR / "screenshots" / filename
724
+ if not path.exists():
725
+ raise HTTPException(status_code=404, detail="Screenshot not found")
726
+
727
+ if filename.endswith("_framed.png"):
728
+ from fastapi.responses import FileResponse
729
+ return FileResponse(path, media_type="image/png", filename=filename)
730
+
731
+ try:
732
+ img = Image.open(path).convert("RGBA")
733
+
734
+ padding = 60
735
+ bar_height = 36
736
+ corner_radius = 12
737
+
738
+ bg_color = (26, 26, 26, 255)
739
+
740
+ window_w = img.width
741
+ window_h = img.height + bar_height
742
+
743
+ bg_w = window_w + (padding * 2)
744
+ bg_h = window_h + (padding * 2)
745
+ bg = Image.new('RGBA', (bg_w, bg_h), bg_color)
746
+
747
+ shadow_padding = 20
748
+ shadow = Image.new('RGBA', (window_w + shadow_padding*2, window_h + shadow_padding*2), (0,0,0,0))
749
+ shadow_draw = ImageDraw.Draw(shadow)
750
+ shadow_draw.rounded_rectangle(
751
+ [shadow_padding, shadow_padding, shadow_padding + window_w, shadow_padding + window_h],
752
+ radius=corner_radius,
753
+ fill=(0, 0, 0, 100)
754
+ )
755
+ shadow = shadow.filter(ImageFilter.GaussianBlur(15))
756
+
757
+ bg.paste(shadow, (padding - shadow_padding, padding - shadow_padding), shadow)
758
+
759
+ window = Image.new('RGBA', (window_w, window_h), (0, 0, 0, 0))
760
+ draw = ImageDraw.Draw(window)
761
+ draw.rounded_rectangle([0, 0, window_w, window_h], radius=corner_radius, fill=(255, 255, 255, 255))
762
+
763
+ draw.rounded_rectangle([0, 0, window_w, bar_height], radius=corner_radius, fill=(240, 240, 240, 255))
764
+ draw.rectangle([0, bar_height//2, window_w, bar_height], fill=(240, 240, 240, 255))
765
+
766
+ dot_radius = 6
767
+ dot_y = bar_height // 2
768
+ draw.ellipse([20-dot_radius, dot_y-dot_radius, 20+dot_radius, dot_y+dot_radius], fill=(255, 95, 87, 255))
769
+ draw.ellipse([42-dot_radius, dot_y-dot_radius, 42+dot_radius, dot_y+dot_radius], fill=(254, 188, 46, 255))
770
+ draw.ellipse([64-dot_radius, dot_y-dot_radius, 64+dot_radius, dot_y+dot_radius], fill=(40, 200, 64, 255))
771
+
772
+ window.paste(img, (0, bar_height), img)
773
+
774
+ bg.paste(window, (padding, padding), window)
775
+
776
+ img_byte_arr = io.BytesIO()
777
+ bg.convert("RGB").save(img_byte_arr, format='PNG')
778
+ return Response(content=img_byte_arr.getvalue(), media_type="image/png")
779
+ except Exception as e:
780
+ raise HTTPException(status_code=500, detail=f"Framing failed: {e}")
781
+
782
+
783
+ @router.post("/generate", response_model=GenerateResponse)
784
+ @limiter.limit("10/minute")
785
+ async def generate(request: Request, body: GenerateRequest):
786
+ user_id = LOCAL_USER_ID
787
+ payload = body.dict()
788
+ for key, value in list(payload.items()):
789
+ if isinstance(value, str):
790
+ payload[key] = sanitize_text(value)
791
+ injection = check_prompt_injection(body.raw_thought)
792
+ if not injection["safe"]:
793
+ payload["raw_thought"] = injection.get("sanitized", "")
794
+ payload["user_message"] = sanitize_text(payload.get("user_message", ""))
795
+
796
+ if not payload.get("format_keys"):
797
+ definitions = load_prompt_definitions()
798
+ payload["format_keys"] = list(definitions.keys())
799
+
800
+ is_valid, warnings = validate_payload(payload)
801
+ if not is_valid:
802
+ raise HTTPException(
803
+ status_code=400,
804
+ detail="Payload has no raw thought — cannot generate posts."
805
+ )
806
+
807
+ try:
808
+ results = await generate_posts(payload, user_id=user_id)
809
+ except Exception as e:
810
+ raise HTTPException(status_code=500, detail=str(e))
811
+
812
+ definitions = load_prompt_definitions()
813
+ variations = []
814
+
815
+ # Handle the helper error structure returned from generate_posts if all failed
816
+ if "error" in results and len(results) == 1:
817
+ error_msg = results["error"]
818
+ for format_key in payload["format_keys"]:
819
+ variations.append(PostVariation(
820
+ format_key=format_key,
821
+ label=definitions.get(format_key, {}).get("label", format_key),
822
+ content="",
823
+ char_count=0,
824
+ success=False,
825
+ error=error_msg,
826
+ ))
827
+ else:
828
+ for format_key, content in results.items():
829
+ is_error = content.startswith("[Error]")
830
+ variations.append(PostVariation(
831
+ format_key=format_key,
832
+ label=definitions.get(format_key, {}).get("label", format_key),
833
+ content=content if not is_error else "",
834
+ char_count=len(content) if not is_error else 0,
835
+ success=not is_error,
836
+ error=content if is_error else None,
837
+ ))
838
+
839
+ return GenerateResponse(
840
+ success=True,
841
+ variations=variations,
842
+ warnings=warnings,
843
+ timestamp=payload.get("timestamp", ""),
844
+ )
845
+
846
+
847
+ @router.get("/history")
848
+ async def get_history():
849
+ """Returns the full history of published posts."""
850
+ return {"history": await safe_get_posts()}
851
+
852
+
853
+ @router.get("/draft")
854
+ def get_current_draft():
855
+ """Returns the latest captured draft from disk."""
856
+ if not CURRENT_DRAFT.exists():
857
+ return {"status": "empty"}
858
+
859
+ try:
860
+ with open(CURRENT_DRAFT, "r", encoding="utf-8") as f:
861
+ return json.load(f)
862
+ except Exception as e:
863
+ raise HTTPException(status_code=500, detail=f"Error reading draft: {e}")
864
+
865
+
866
+ @router.post("/chat")
867
+ @limiter.limit("20/minute")
868
+ async def chat_refine(request: Request, body: ChatRequest):
869
+ """Refines a post variation using AI chat."""
870
+ user_id = LOCAL_USER_ID
871
+ if not CURRENT_DRAFT.exists():
872
+ raise HTTPException(status_code=400, detail="No active draft to refine.")
873
+
874
+ try:
875
+ with open(CURRENT_DRAFT, "r", encoding="utf-8") as f:
876
+ draft = json.load(f)
877
+ except Exception as e:
878
+ raise HTTPException(status_code=500, detail=f"Error reading draft: {e}")
879
+
880
+ message = sanitize_text(body.message)
881
+ injection = check_prompt_injection(message)
882
+ if not injection["safe"]:
883
+ message = injection.get("sanitized", "")
884
+ format_key = sanitize_text(body.format_key)
885
+ current_text = draft["variations"].get(format_key, "")
886
+
887
+ try:
888
+ platform_prompt = get_prompt(format_key)
889
+ except Exception as e:
890
+ platform_prompt = f"Format/Platform key: {format_key}"
891
+
892
+ refinement_system_prompt = (
893
+ "You are a social media manager helping a developer refine a post.\n"
894
+ f"The post format/platform is: '{format_key}'.\n"
895
+ "Adhere to the platform rules and guidelines below:\n"
896
+ "--- START PLATFORM RULES ---\n"
897
+ f"{platform_prompt}\n"
898
+ "--- END PLATFORM RULES ---\n\n"
899
+ "The user will provide instructions on how to change the existing draft. "
900
+ "Adhere strictly to the platform rules above while applying the user's changes."
901
+ )
902
+
903
+ refinement_user_message = (
904
+ f"Original Context:\n{draft['payload']['user_message']}\n\n"
905
+ f"Current Draft for '{format_key}':\n{current_text}\n\n"
906
+ f"User Instruction: {message}\n\n"
907
+ "Output ONLY the revised post text. No preamble."
908
+ )
909
+
910
+ try:
911
+ new_text = await _ai_call(
912
+ format_key,
913
+ refinement_system_prompt,
914
+ refinement_user_message,
915
+ user_id=user_id,
916
+ )
917
+
918
+ draft["variations"][format_key] = new_text
919
+ with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
920
+ json.dump(draft, f, indent=4)
921
+
922
+ return {"success": True, "new_text": new_text}
923
+ except Exception as e:
924
+ raise HTTPException(status_code=500, detail=f"AI refinement failed: {e}")
925
+
926
+
927
+ @router.post("/cli/trigger")
928
+ async def cli_trigger(request: CliTriggerRequest):
929
+ """Triggers a capture workflow with a specific thought from the CLI."""
930
+ user_id = LOCAL_USER_ID
931
+ from core.capture import run_capture
932
+ from core.ocr import run_ocr
933
+ from api.payload import build_payload
934
+ from ai.generator import generate_posts
935
+
936
+ try:
937
+ capture = await asyncio.to_thread(run_capture)
938
+ ocr = await asyncio.to_thread(run_ocr, capture["screenshot"]["path"])
939
+
940
+ payload = build_payload(
941
+ raw_thought=sanitize_text(request.thought),
942
+ ocr_result=ocr,
943
+ capture_result=capture,
944
+ )
945
+
946
+ payload["use_vision_fallback"] = False
947
+ payload["screenshot_b64"] = None
948
+
949
+ variations = await generate_posts(payload, user_id=user_id)
950
+
951
+ draft_data = {
952
+ "payload": payload,
953
+ "variations": variations,
954
+ "timestamp": payload.get("timestamp", ""),
955
+ "status": "ready"
956
+ }
957
+ with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
958
+ json.dump(draft_data, f, indent=4)
959
+
960
+ return {"success": True}
961
+ except Exception as e:
962
+ raise HTTPException(status_code=500, detail=str(e))
963
+
964
+
965
+ @router.post("/capture/trigger")
966
+ async def ui_trigger_capture(request: CaptureTriggerRequest):
967
+ """Triggers a capture workflow from the UI."""
968
+ user_id = LOCAL_USER_ID
969
+ from core.capture import run_capture
970
+ from core.ocr import run_ocr
971
+ from api.payload import build_payload
972
+ from ai.generator import generate_posts
973
+
974
+ stream_log("API", "INFO", f"ui_trigger_capture triggered with delay={request.delay}s")
975
+ try:
976
+ stream_log("API", "INFO", f"Step 1/4: Running screenshot capture (delay={request.delay}s)...")
977
+ capture = await asyncio.to_thread(run_capture, delay=request.delay)
978
+ screenshot_path = capture.get("screenshot", {}).get("path", "unknown")
979
+ stream_log("API", "INFO", f"Step 1/4: Screenshot captured successfully. Path: {screenshot_path}")
980
+
981
+ stream_log("API", "INFO", f"Step 2/4: Running OCR on screenshot ({screenshot_path})...")
982
+ ocr = await asyncio.to_thread(run_ocr, screenshot_path)
983
+ detected_text = ocr.get("text", "")
984
+ stream_log("API", "INFO", f"Step 2/4: OCR completed. Text length: {len(detected_text)} characters.")
985
+
986
+ stream_log("API", "INFO", "Step 3/4: Building payload for AI generation...")
987
+ payload = build_payload(
988
+ raw_thought="",
989
+ ocr_result=ocr,
990
+ capture_result=capture,
991
+ )
992
+
993
+ payload["use_vision_fallback"] = False
994
+ payload["screenshot_b64"] = None
995
+
996
+ stream_log("API", "INFO", "Step 4/4: Requesting AI generation from providers...")
997
+ variations = await generate_posts(payload, user_id=user_id)
998
+ stream_log("API", "INFO", "Step 4/4: AI generation returned.")
999
+
1000
+ draft_data = {
1001
+ "payload": payload,
1002
+ "variations": variations,
1003
+ "timestamp": payload.get("timestamp", ""),
1004
+ "status": "ready"
1005
+ }
1006
+ with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
1007
+ json.dump(draft_data, f, indent=4)
1008
+
1009
+ errors = {
1010
+ key: value
1011
+ for key, value in variations.items()
1012
+ if isinstance(value, str) and value.startswith("[Error]")
1013
+ }
1014
+ return {
1015
+ "success": len(errors) < len(variations),
1016
+ "timestamp": draft_data["timestamp"],
1017
+ "errors": errors,
1018
+ "error": "AI generation failed for all formats" if errors and len(errors) == len(variations) else "",
1019
+ }
1020
+ except Exception as e:
1021
+ stream_log("API", "ERROR", f"ui_trigger_capture failed: {e}")
1022
+ raise HTTPException(status_code=500, detail=str(e))
1023
+
1024
+
1025
+ @router.get("/screenshots")
1026
+ def list_screenshots():
1027
+ """Lists all screenshots available in the storage directory."""
1028
+ from config.settings import STORAGE_DIR
1029
+ path = STORAGE_DIR / "screenshots"
1030
+ if not path.exists():
1031
+ return {"screenshots": []}
1032
+
1033
+ files = []
1034
+ for f in os.listdir(path):
1035
+ if f.endswith(".png") or f.endswith(".jpg"):
1036
+ files.append({
1037
+ "filename": f,
1038
+ "path": f"storage/data/screenshots/{f}",
1039
+ "timestamp": os.path.getmtime(path / f)
1040
+ })
1041
+
1042
+ files.sort(key=lambda x: x["timestamp"], reverse=True)
1043
+ return {"screenshots": files}
1044
+
1045
+
1046
+ @router.post("/screenshot/recommend")
1047
+ async def recommend_screenshot(request: RecommendRequest):
1048
+ """Uses AI to recommend where to post a specific screenshot."""
1049
+ user_id = LOCAL_USER_ID
1050
+ from config.settings import STORAGE_DIR
1051
+ path = STORAGE_DIR / "screenshots" / sanitize_text(request.filename)
1052
+ if not path.exists():
1053
+ raise HTTPException(status_code=404, detail="Screenshot not found")
1054
+
1055
+ system_prompt = (
1056
+ "You are a social media strategist for developers. "
1057
+ "Analyze the provided context and recommend which platform (X, LinkedIn, or a Technical Article) "
1058
+ "this screenshot is best suited for and WHY. "
1059
+ "Keep it brief: 2-3 sentences max."
1060
+ )
1061
+
1062
+ ocr_context = "A screenshot of code or a development tool."
1063
+ if CURRENT_DRAFT.exists():
1064
+ with open(CURRENT_DRAFT, "r") as f:
1065
+ draft = json.load(f)
1066
+ for s in draft.get("payload", {}).get("screenshots", []):
1067
+ if s["path"].endswith(request.filename):
1068
+ ocr_context = f"OCR Context: {s.get('ocr_text', 'No OCR available')}"
1069
+ break
1070
+
1071
+ try:
1072
+ recommendation = await _ai_call("deep_tech", system_prompt, f"Context: {ocr_context}", user_id=user_id)
1073
+ return {"success": True, "recommendation": recommendation}
1074
+ except Exception as e:
1075
+ raise HTTPException(status_code=500, detail=f"Recommendation failed: {e}")
1076
+
1077
+
1078
+ @router.delete("/prompts/{format_key}")
1079
+ def delete_prompt(format_key: str):
1080
+ """Deletes a prompt definition."""
1081
+ definitions = load_prompt_definitions()
1082
+ clean_key = sanitize_text(format_key)
1083
+ if clean_key in definitions:
1084
+ del definitions[clean_key]
1085
+ try:
1086
+ with open(PROMPTS_FILE, "w", encoding="utf-8") as f:
1087
+ json.dump(definitions, f, indent=4)
1088
+ return {"success": True}
1089
+ except Exception as e:
1090
+ raise HTTPException(status_code=500, detail=str(e))
1091
+ else:
1092
+ raise HTTPException(status_code=404, detail="Prompt not found")
1093
+
1094
+
1095
+ @router.get("/prompts")
1096
+ def get_prompts():
1097
+ """Returns all customizable prompt definitions."""
1098
+ return load_prompt_definitions()
1099
+
1100
+
1101
+ @router.put("/prompts")
1102
+ def update_prompt(update: PromptUpdate):
1103
+ """Updates a single prompt definition."""
1104
+ definitions = load_prompt_definitions()
1105
+ definitions[sanitize_text(update.format_key)] = {
1106
+ "label": sanitize_text(update.label),
1107
+ "description": sanitize_text(update.description),
1108
+ "system_prompt": sanitize_text(update.system_prompt)
1109
+ }
1110
+
1111
+ try:
1112
+ with open(PROMPTS_FILE, "w", encoding="utf-8") as f:
1113
+ json.dump(definitions, f, indent=4)
1114
+ return {"success": True}
1115
+ except Exception as e:
1116
+ raise HTTPException(status_code=500, detail=str(e))
1117
+
1118
+
1119
+ @router.post("/rate")
1120
+ def rate_post(request: RateRequest):
1121
+ """Stores a rating for a historical post."""
1122
+ save_rating(
1123
+ post_text=sanitize_text(request.post_text),
1124
+ format_key=sanitize_text(request.format_key),
1125
+ rating=request.rating,
1126
+ timestamp=request.timestamp
1127
+ )
1128
+ return {"success": True}
1129
+
1130
+
1131
+ @router.get("/formats")
1132
+ def get_formats():
1133
+ """Returns available post format keys and their display labels."""
1134
+ definitions = load_prompt_definitions()
1135
+ return {"formats": {k: v["label"] for k, v in definitions.items()}}
1136
+
1137
+
1138
+ @router.get("/settings/plan")
1139
+ def get_plan():
1140
+ user_id = LOCAL_USER_ID
1141
+ settings = _settings_for_user(user_id)
1142
+ preferred = settings.get("preferred_providers") or {}
1143
+ return {"plan": preferred.get("twitter_plan", get_twitter_plan())}
1144
+
1145
+
1146
+ @router.put("/settings/plan")
1147
+ def update_plan(update: PlanUpdate):
1148
+ user_id = LOCAL_USER_ID
1149
+ plan = sanitize_text(update.plan).lower()
1150
+ settings = _settings_for_user(user_id)
1151
+ preferred = settings.get("preferred_providers") or {}
1152
+ preferred["twitter_plan"] = plan
1153
+ _update_settings_for_user(user_id, {"preferred_providers": preferred})
1154
+ return {"success": True}
1155
+
1156
+
1157
+ @router.get("/settings")
1158
+ def get_all_settings():
1159
+ user_id = LOCAL_USER_ID
1160
+ return _settings_for_user(user_id)
1161
+
1162
+
1163
+ @router.post("/settings/sprint/toggle")
1164
+ def sprint_toggle():
1165
+ user_id = LOCAL_USER_ID
1166
+ settings = _settings_for_user(user_id)
1167
+ new_state = not bool(settings.get("sprint_mode"))
1168
+ _update_settings_for_user(user_id, {"sprint_mode": new_state})
1169
+ return {"success": True, "sprint_mode": new_state}
1170
+
1171
+
1172
+ @router.get("/settings/keys")
1173
+ def get_keys_status():
1174
+ user_id = LOCAL_USER_ID
1175
+ return get_ai_keys_status()
1176
+
1177
+
1178
+ @router.get("/diagnose")
1179
+ async def diagnose_connectivity():
1180
+ """Diagnoses network and authentication status of all configured AI providers."""
1181
+ user_id = LOCAL_USER_ID
1182
+ import ai.generator
1183
+ import httpx
1184
+
1185
+ ai.generator.refresh_provider_keys(user_id)
1186
+
1187
+ results = {}
1188
+
1189
+ async with httpx.AsyncClient(timeout=10) as client:
1190
+ for provider_name, config in ai.generator.PROVIDERS.items():
1191
+ if not config.get("api_key"):
1192
+ results[provider_name] = {
1193
+ "configured": False,
1194
+ "status": "missing_key",
1195
+ "error": "No API key configured"
1196
+ }
1197
+ continue
1198
+
1199
+ try:
1200
+ test_prompt = "respond with 'ok'"
1201
+ await ai.generator._call_provider(
1202
+ provider_name=provider_name,
1203
+ system_prompt="you are a connectivity tester",
1204
+ user_message=test_prompt,
1205
+ retries=0,
1206
+ client=client
1207
+ )
1208
+ results[provider_name] = {
1209
+ "configured": True,
1210
+ "status": "success",
1211
+ "error": ""
1212
+ }
1213
+ except Exception as e:
1214
+ results[provider_name] = {
1215
+ "configured": True,
1216
+ "status": "error",
1217
+ "error": str(e)
1218
+ }
1219
+
1220
+ if not ai.generator.GEMINI_API_KEY:
1221
+ results["gemini"] = {
1222
+ "configured": False,
1223
+ "status": "missing_key",
1224
+ "error": "No API key configured"
1225
+ }
1226
+ else:
1227
+ try:
1228
+ await ai.generator._gemini_text_call(
1229
+ system_prompt="you are a connectivity tester",
1230
+ user_message="respond with 'ok'",
1231
+ client=client
1232
+ )
1233
+ results["gemini"] = {
1234
+ "configured": True,
1235
+ "status": "success",
1236
+ "error": ""
1237
+ }
1238
+ except Exception as e:
1239
+ results["gemini"] = {
1240
+ "configured": True,
1241
+ "status": "error",
1242
+ "error": str(e)
1243
+ }
1244
+
1245
+ return {"results": results}
1246
+
1247
+
1248
+ @router.post("/article/generate")
1249
+ @limiter.limit("5/minute")
1250
+ async def generate_article(request: Request, body: ArticleGenerateRequest):
1251
+ """Generates a full technical article from sprint context."""
1252
+ user_id = LOCAL_USER_ID
1253
+ if not CURRENT_DRAFT.exists():
1254
+ raise HTTPException(status_code=400, detail="No active draft to generate article from.")
1255
+
1256
+ try:
1257
+ with open(CURRENT_DRAFT, "r", encoding="utf-8") as f:
1258
+ draft = json.load(f)
1259
+ except Exception as e:
1260
+ raise HTTPException(status_code=500, detail=f"Error reading draft: {e}")
1261
+
1262
+ codebase_summary = ""
1263
+ if body.include_codebase:
1264
+ codebase_summary = await asyncio.to_thread(summarise_for_prompt, sanitize_text(body.repo_path))
1265
+
1266
+ sys_prompt = article_prompt(codebase_summary)
1267
+
1268
+ sprint_context = ""
1269
+ if SPRINT_LOG.exists():
1270
+ with open(SPRINT_LOG, "r", encoding="utf-8") as f:
1271
+ sprint_context = f.read()
1272
+
1273
+ user_msg = (
1274
+ f"Raw Thoughts: {draft['payload'].get('user_message', '')}\n\n"
1275
+ f"OCR Context: {draft['payload'].get('ocr_text', '')}\n\n"
1276
+ f"Git Diff: {draft['payload'].get('git_diff', '')}\n\n"
1277
+ f"Sprint Context: {sprint_context}"
1278
+ )
1279
+
1280
+ try:
1281
+ article = await _ai_call("article", sys_prompt, user_msg, user_id=user_id)
1282
+ return {"success": True, "article": article}
1283
+ except Exception as e:
1284
+ raise HTTPException(status_code=500, detail=f"Article generation failed: {e}")
1285
+
1286
+
1287
+ @router.post("/article/refine")
1288
+ async def refine_article(request: ArticleRefineRequest):
1289
+ """Refines an article draft based on user instructions."""
1290
+ user_id = LOCAL_USER_ID
1291
+ current_article = sanitize_text(request.current_article)
1292
+ instruction = sanitize_text(request.instruction)
1293
+ injection = check_prompt_injection(instruction)
1294
+ if not injection["safe"]:
1295
+ instruction = injection.get("sanitized", "")
1296
+ sys_prompt = article_refinement_prompt(current_article, instruction)
1297
+ try:
1298
+ article = await _ai_call("article", sys_prompt, "Refine the article.", user_id=user_id)
1299
+ return {"success": True, "article": article}
1300
+ except Exception as e:
1301
+ raise HTTPException(status_code=500, detail=f"Article refinement failed: {e}")
1302
+
1303
+
1304
+ @router.get("/patterns")
1305
+ def get_patterns():
1306
+ """Returns all available viral patterns."""
1307
+ return get_all_patterns()
1308
+
1309
+
1310
+ @router.post("/thread/split")
1311
+ def thread_split(request: ThreadSplitRequest):
1312
+ """Splits a long post into a numbered thread."""
1313
+ tweets = split_into_thread(sanitize_text(request.post_text))
1314
+ return {"success": True, "tweets": tweets}
1315
+
1316
+
1317
+ @router.post("/posts/verify")
1318
+ async def verify_logged_post(request: PostVerifyRequest):
1319
+ user_id = LOCAL_USER_ID
1320
+ post_id = sanitize_text(request.post_id)
1321
+ post_url = sanitize_text(request.post_url or "")
1322
+ result = await safe_update_post(post_id, {
1323
+ "posted_verified": True,
1324
+ "declined": False,
1325
+ "verified_at": datetime.now().isoformat(),
1326
+ "tweet_url": post_url,
1327
+ })
1328
+ if not result:
1329
+ raise HTTPException(status_code=404, detail="post not found")
1330
+ track("post_verified", {"has_url": bool(post_url)})
1331
+ return {"success": True}
1332
+
1333
+
1334
+ @router.post("/posts/decline")
1335
+ async def decline_logged_post(request: PostDeclineRequest):
1336
+ user_id = LOCAL_USER_ID
1337
+ result = await safe_update_post(sanitize_text(request.post_id), {"declined": True})
1338
+ if not result:
1339
+ raise HTTPException(status_code=404, detail="post not found")
1340
+ return {"success": True}
1341
+
1342
+
1343
+ @router.get("/posts/unverified")
1344
+ async def unverified_posts():
1345
+ return {"posts": await safe_get_unverified()}
1346
+
1347
+
1348
+ @router.post("/metrics/save")
1349
+ async def save_post_metrics(request: MetricsSaveRequest):
1350
+ user_id = LOCAL_USER_ID
1351
+ values = {
1352
+ "impressions": request.impressions,
1353
+ "likes": request.likes,
1354
+ "comments": request.comments,
1355
+ "reposts": request.reposts,
1356
+ "days_after_post": request.days_after_post,
1357
+ }
1358
+ if any(value < 0 for value in values.values()):
1359
+ raise HTTPException(status_code=400, detail="Metric values must be non-negative")
1360
+ if request.days_after_post < 1 or request.days_after_post > 30:
1361
+ raise HTTPException(status_code=400, detail="days_after_post must be between 1 and 30")
1362
+
1363
+ hashtags = [sanitize_text(tag) for tag in (request.hashtags or []) if sanitize_text(tag)]
1364
+ if len(hashtags) > 10:
1365
+ raise HTTPException(status_code=400, detail="hashtags cannot exceed 10 items")
1366
+
1367
+ result = await safe_save_metrics(
1368
+ sanitize_text(request.post_id),
1369
+ {
1370
+ **values,
1371
+ "hashtags": hashtags,
1372
+ "platform": sanitize_text(request.platform or ""),
1373
+ }
1374
+ )
1375
+ invalidate_insights_cache()
1376
+ track("metrics_saved", {"days_after": request.days_after_post})
1377
+ return result
1378
+
1379
+
1380
+ @router.get("/metrics/{post_id}")
1381
+ async def get_post_metrics(post_id: str):
1382
+ try:
1383
+ from storage.supabase_client import get_client
1384
+ client = get_client()
1385
+ response = (
1386
+ client.table("posts")
1387
+ .select("id,impressions,likes,comments,reposts,hashtags,platform,metrics_saved_at,days_after_post")
1388
+ .eq("id", post_id)
1389
+ .eq("metrics_saved", True)
1390
+ .execute()
1391
+ )
1392
+ if response.data:
1393
+ row = response.data[0]
1394
+ return {
1395
+ "post_id": row["id"],
1396
+ "impressions": row.get("impressions") or 0,
1397
+ "likes": row.get("likes") or 0,
1398
+ "comments": row.get("comments") or 0,
1399
+ "reposts": row.get("reposts") or 0,
1400
+ "hashtags": row.get("hashtags") or [],
1401
+ "platform": row.get("platform") or "",
1402
+ "measured_at": row.get("metrics_saved_at") or "",
1403
+ "days_after_post": row.get("days_after_post") or 0,
1404
+ }
1405
+ raise Exception("Metrics not found in Supabase")
1406
+ except Exception:
1407
+ posts = load_local_posts()
1408
+ for post in posts:
1409
+ if post.get("id") == post_id and post.get("metrics_saved"):
1410
+ return {
1411
+ "post_id": post_id,
1412
+ "impressions": post.get("impressions") or 0,
1413
+ "likes": post.get("likes") or 0,
1414
+ "comments": post.get("comments") or 0,
1415
+ "reposts": post.get("reposts") or 0,
1416
+ "hashtags": post.get("hashtags") or [],
1417
+ "platform": post.get("platform") or "",
1418
+ "measured_at": post.get("metrics_saved_at") or "",
1419
+ "days_after_post": post.get("days_after_post") or 0,
1420
+ }
1421
+ return {}
1422
+
1423
+
1424
+ @router.get("/insights")
1425
+ async def get_insights():
1426
+ now = time.time()
1427
+ cache_key = f"data:{LOCAL_USER_ID}"
1428
+ if _INSIGHTS_CACHE.get(cache_key) is not None and now - _INSIGHTS_CACHE["timestamp"] < 3600:
1429
+ return _INSIGHTS_CACHE[cache_key]
1430
+
1431
+ posts = await safe_get_posts()
1432
+
1433
+ from storage.insights import _row_from_metric, _best_group, _avg
1434
+ from collections import defaultdict
1435
+
1436
+ rows = []
1437
+ for post in posts:
1438
+ post_id = post.get("id") or post.get("timestamp")
1439
+ metrics = post.get("metrics") or {
1440
+ "impressions": post.get("impressions") or 0,
1441
+ "likes": post.get("likes") or 0,
1442
+ "comments": post.get("comments") or 0,
1443
+ "reposts": post.get("reposts") or 0,
1444
+ "hashtags": post.get("hashtags") or [],
1445
+ "platform": post.get("platform") or "",
1446
+ "measured_at": post.get("metrics_saved_at") or "",
1447
+ "days_after_post": post.get("days_after_post") or 0,
1448
+ }
1449
+ if not post.get("metrics_saved") and not post.get("metrics"):
1450
+ continue
1451
+ rows.append(_row_from_metric(post_id, metrics, post))
1452
+
1453
+ if len(rows) < 5:
1454
+ data = {"insufficient_data": True, "posts_needed": 5 - len(rows), "posts_with_metrics": len(rows)}
1455
+ else:
1456
+ cutoff = datetime.now() - timedelta(days=30)
1457
+ recent = [row for row in rows if row["dt"] and row["dt"] >= cutoff] or rows
1458
+
1459
+ def calculate_streak_local(posts_list: list) -> dict:
1460
+ active_posts = [p for p in posts_list if not p.get("posted_declined") and not p.get("declined")]
1461
+ if not active_posts:
1462
+ return {"current_streak": 0, "best_streak": 0, "total_posts": 0, "last_post_date": ""}
1463
+ post_dates = set()
1464
+ for entry in active_posts:
1465
+ try:
1466
+ ts = entry.get("timestamp")
1467
+ if ts:
1468
+ post_dates.add(datetime.fromisoformat(str(ts).replace("Z", "+00:00")).date())
1469
+ except Exception:
1470
+ continue
1471
+ if not post_dates:
1472
+ return {"current_streak": 0, "best_streak": 0, "total_posts": len(active_posts), "last_post_date": ""}
1473
+ sorted_dates = sorted(post_dates, reverse=True)
1474
+ last_post_date = sorted_dates[0]
1475
+ today = datetime.now().date()
1476
+ if last_post_date < today - timedelta(days=1):
1477
+ return {"current_streak": 0, "best_streak": 0, "total_posts": len(active_posts), "last_post_date": str(last_post_date)}
1478
+
1479
+ streak_val = 1
1480
+ for i in range(1, len(sorted_dates)):
1481
+ if sorted_dates[i] == sorted_dates[i - 1] - timedelta(days=1):
1482
+ streak_val += 1
1483
+ else:
1484
+ break
1485
+ best_streak = 1
1486
+ running = 1
1487
+ for i in range(1, len(sorted_dates)):
1488
+ if sorted_dates[i] == sorted_dates[i - 1] - timedelta(days=1):
1489
+ running += 1
1490
+ else:
1491
+ best_streak = max(best_streak, running)
1492
+ running = 1
1493
+ best_streak = max(best_streak, running)
1494
+ return {"current_streak": streak_val, "best_streak": best_streak, "total_posts": len(active_posts), "last_post_date": str(last_post_date)}
1495
+
1496
+ streak = calculate_streak_local(posts)
1497
+
1498
+ total_impressions = sum(row["impressions"] for row in recent)
1499
+ best_format = _best_group(rows, "format_key")
1500
+ best_day = _best_group(rows, "day")
1501
+ best_time = _best_group(rows, "time_window")
1502
+ top = max(rows, key=lambda row: row["impressions"])
1503
+ overall_avg = _avg([row["impressions"] for row in rows])
1504
+
1505
+ patterns = []
1506
+ by_format = defaultdict(list)
1507
+ by_chars = defaultdict(list)
1508
+ by_day = defaultdict(list)
1509
+ tags = defaultdict(list)
1510
+ for row in rows:
1511
+ by_format[row["format_key"]].append(row["impressions"])
1512
+ by_chars[row["char_bucket"]].append(row["impressions"])
1513
+ by_day[row["day"]].append(row["impressions"])
1514
+ for tag in row["hashtags"]:
1515
+ tags[tag].append(row["impressions"])
1516
+
1517
+ if len(by_format) >= 2:
1518
+ ordered = sorted(by_format.items(), key=lambda item: _avg(item[1]), reverse=True)
1519
+ high, low = ordered[0], ordered[-1]
1520
+ if _avg(low[1]) > 0:
1521
+ patterns.append({
1522
+ "pattern": "format",
1523
+ "description": f"{high[0].upper()} format gets {round(_avg(high[1]) / _avg(low[1]), 1)}x more impressions than {low[0].upper()}",
1524
+ "impact": "high",
1525
+ })
1526
+ if by_chars:
1527
+ bucket, values = max(by_chars.items(), key=lambda item: _avg(item[1]))
1528
+ lift = round(((_avg(values) - overall_avg) / overall_avg) * 100, 0) if overall_avg else 0
1529
+ patterns.append({"pattern": "length", "description": f"posts in the {bucket} char bucket get {lift}% more engagement", "impact": "medium"})
1530
+ if by_day:
1531
+ day, values = max(by_day.items(), key=lambda item: _avg(item[1]))
1532
+ multiple = round(_avg(values) / overall_avg, 1) if overall_avg else 0
1533
+ patterns.append({"pattern": "day", "description": f"{day} posts get {multiple}x your average", "impact": "medium"})
1534
+
1535
+ hashtag_performance = [
1536
+ {"hashtag": tag, "avg_impressions": round(_avg(values), 1), "uses": len(values)}
1537
+ for tag, values in sorted(tags.items(), key=lambda item: _avg(item[1]), reverse=True)
1538
+ ]
1539
+ for tag in hashtag_performance[:3]:
1540
+ patterns.append({
1541
+ "pattern": "hashtag",
1542
+ "description": f"{tag['hashtag']} adds avg {int(tag['avg_impressions'])} impressions",
1543
+ "impact": "medium",
1544
+ })
1545
+
1546
+ data = {
1547
+ "overview": {
1548
+ "total_posts": len(recent),
1549
+ "total_verified": len([post for post in posts if post.get("posted_verified")]),
1550
+ "total_impressions": total_impressions,
1551
+ "avg_engagement_rate": round(_avg([row["engagement_rate"] for row in recent]), 2),
1552
+ "posting_streak": streak.get("current_streak", 0),
1553
+ "best_streak": streak.get("best_streak", streak.get("current_streak", 0)),
1554
+ },
1555
+ "best_format": {"format_key": best_format.get("name", ""), "avg_impressions": best_format.get("avg", 0.0), "sample_size": best_format.get("sample_size", 0)},
1556
+ "best_day": {"day": best_day.get("name", ""), "avg_impressions": best_day.get("avg", 0.0)},
1557
+ "best_time": {"window": best_time.get("name", ""), "avg_impressions": best_time.get("avg", 0.0)},
1558
+ "top_post": {key: top[key] for key in ["post_text", "impressions", "likes", "format_key", "timestamp"]},
1559
+ "viral_patterns": patterns[:8],
1560
+ "hashtag_performance": hashtag_performance[:20],
1561
+ }
1562
+
1563
+ _INSIGHTS_CACHE["timestamp"] = now
1564
+ _INSIGHTS_CACHE[cache_key] = data
1565
+ return data