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.
- ai/__init__.py +0 -0
- ai/formatter.py +59 -0
- ai/generator.py +604 -0
- ai/prompts.py +197 -0
- ai/viral_patterns.py +75 -0
- api/__init__.py +0 -0
- api/analytics.py +48 -0
- api/auth.py +49 -0
- api/auth_middleware.py +129 -0
- api/auth_routes.py +117 -0
- api/monitoring.py +56 -0
- api/payload.py +253 -0
- api/ratelimit.py +9 -0
- api/routes.py +1565 -0
- api/server.py +162 -0
- api/validators.py +101 -0
- assets/__init__.py +1 -0
- assets/favicon-16x16.png +0 -0
- assets/favicon-32x32.png +0 -0
- assets/favicon-64x64.png +0 -0
- assets/favicon.ico +0 -0
- assets/icon.png +0 -0
- cli/.env.example +26 -0
- cli/__init__.py +1 -0
- cli/gitcast.py +79 -0
- config/__init__.py +0 -0
- config/settings.py +213 -0
- core/__init__.py +0 -0
- core/capture.py +258 -0
- core/codebase_reader.py +90 -0
- core/framing.py +86 -0
- core/hotkey.py +21 -0
- core/log_stream.py +50 -0
- core/ocr.py +173 -0
- core/screenshot_session.py +274 -0
- core/security.py +126 -0
- core/tray.py +54 -0
- gitcast-1.0.0.dist-info/LICENSE +21 -0
- gitcast-1.0.0.dist-info/METADATA +67 -0
- gitcast-1.0.0.dist-info/RECORD +61 -0
- gitcast-1.0.0.dist-info/WHEEL +5 -0
- gitcast-1.0.0.dist-info/entry_points.txt +2 -0
- gitcast-1.0.0.dist-info/top_level.txt +10 -0
- publisher/__init__.py +0 -0
- publisher/clipboard.py +44 -0
- publisher/twitter.py +100 -0
- storage/__init__.py +0 -0
- storage/cleanup.py +60 -0
- storage/engagement.py +114 -0
- storage/insights.py +203 -0
- storage/key_manager.py +45 -0
- storage/logger.py +208 -0
- storage/metrics.py +119 -0
- storage/sprint.py +40 -0
- storage/streak.py +0 -0
- storage/supabase_client.py +25 -0
- storage/tone_memory.py +139 -0
- ui/__init__.py +0 -0
- web/__init__.py +1 -0
- web/index.html +4994 -0
- 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
|