1import json
2import logging
3from typing import Any, Optional, List
4
5import requests
6from apify import Actor
7from requests.adapters import HTTPAdapter
8from urllib3.util.retry import Retry
9
10WORKER_URL = "https://gemini-proxy-worker.anass-seb3.workers.dev"
11
12
13MODEL_CANDIDATES: List[str] = [
14 "gemini-3-pro-preview",
15 "gemini-1.5-flash-8b",
16 "gemini-1.5-pro",
17 "gemini-1.0-pro",
18]
19
20REQUEST_TIMEOUT_SEC = 100
21MAX_OUTPUT_TOKENS = 8192
22
23
24def get_session() -> requests.Session:
25 s = requests.Session()
26 retry = Retry(
27 total=3,
28 backoff_factor=1.2,
29 status_forcelist=[429, 500, 502, 503, 504],
30 allowed_methods=["POST"],
31 )
32 adapter = HTTPAdapter(max_retries=retry)
33 s.mount("https://", adapter)
34 s.mount("http://", adapter)
35 return s
36
37
38session = get_session()
39
40
41def extract_json_content(raw: str) -> Optional[str]:
42 raw = raw.strip()
43
44
45 try:
46 json.loads(raw)
47 return raw
48 except:
49 pass
50
51
52 if "```json" in raw:
53 start = raw.find("```json") + 7
54 end = raw.find("```", start)
55 if end > start:
56 return raw[start:end].strip()
57
58
59 start_idx = raw.rfind("{")
60 end_idx = raw.rfind("}") + 1
61 if start_idx >= 0 and end_idx > start_idx:
62 candidate = raw[start_idx:end_idx]
63 try:
64 json.loads(candidate)
65 return candidate
66 except:
67 pass
68
69 return None
70
71
72def call_gemini(prompt: str) -> Optional[dict]:
73 for model in MODEL_CANDIDATES:
74 Actor.log.info(f"Trying model: {model}")
75
76 url = f"{WORKER_URL}/v1beta/models/{model}:generateContent"
77 payload = {
78 "contents": [{"parts": [{"text": prompt}]}],
79 "generationConfig": {
80 "responseMimeType": "application/json",
81 "temperature": 0.2,
82 "maxOutputTokens": MAX_OUTPUT_TOKENS,
83 },
84 }
85
86 try:
87 resp = session.post(url, json=payload, timeout=REQUEST_TIMEOUT_SEC)
88 except Exception as e:
89 Actor.log.warning(f"Request error ({model}): {e}")
90 continue
91
92 if resp.status_code != 200:
93 Actor.log.warning(f"{model} → HTTP {resp.status_code}")
94 continue
95
96 try:
97 data = resp.json()
98 text = (
99 data.get("candidates", [{}])[0]
100 .get("content", {})
101 .get("parts", [{}])[0]
102 .get("text", "")
103 )
104 except Exception:
105 Actor.log.warning(f"Cannot extract text from {model}")
106 continue
107
108 if not text.strip():
109 continue
110
111
112 preview = text[:450].replace("\n", " ").strip()
113 Actor.log.info(f"Raw output ({model}): {preview}...")
114
115 json_str = extract_json_content(text)
116 if not json_str:
117 continue
118
119 try:
120 parsed = json.loads(json_str)
121 if isinstance(parsed, dict):
122 return parsed
123 except Exception as e:
124 Actor.log.warning(f"JSON parse failed ({model}): {e}")
125
126 Actor.log.error("All model attempts failed")
127 return None
128
129
130async def main() -> None:
131 async with Actor:
132 inp = await Actor.get_input() or {}
133
134 goal = str(inp.get("goal") or "").strip()
135 level = str(inp.get("currentLevel") or "Beginner").strip()
136 minutes = int(inp.get("timePerDayMinutes") or 30)
137 weeks = int(inp.get("durationWeeks") or 6)
138 constraints = str(inp.get("constraints") or "").strip()
139
140 if not goal:
141 Actor.log.error("Goal is required")
142 await Actor.fail()
143 return
144
145 schema_example = {
146 "milestones": [{"week": 1, "milestone": "string"}],
147 "tasks": [
148 {
149 "week": 1,
150 "day": "Monday",
151 "task": "string – clear & actionable",
152 "deliverable": "string or null",
153 }
154 ],
155 }
156
157 prompt = f"""You are an experienced learning coach and curriculum designer.
158
159Create a realistic, progressive learning plan.
160
161Rules:
162- Return ONLY valid JSON — no explanations, no markdown
163- Use keys "milestones" and "tasks"
164- "milestones" → array of important checkpoints (one per few weeks)
165- "tasks" → detailed daily tasks
166- Days: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
167- Keep tasks realistic for {minutes} min/day
168- Adapt difficulty to "{level}" level
169- Respect constraints
170
171JSON example structure:
172{json.dumps(schema_example, indent=2)}
173
174INPUT:
175Goal: {goal}
176Current level: {level}
177Daily time: {minutes} minutes
178Total duration: {weeks} weeks
179Additional constraints / preferences:
180{constraints if constraints else "None"}
181
182Generate the plan now.
183""".strip()
184
185 result = call_gemini(prompt)
186
187 if not result or not isinstance(result, dict):
188 Actor.log.error("Model did not return valid structured output")
189 await Actor.fail()
190 return
191
192
193 tasks = result.get("tasks", [])
194 if not isinstance(tasks, list):
195 Actor.log.error("'tasks' is not a list")
196 await Actor.fail()
197 return
198
199 pushed = 0
200
201 for item in tasks:
202 if not isinstance(item, dict):
203 continue
204
205 row = {
206 "week": int(item.get("week") or 0),
207 "day": str(item.get("day", "")).strip(),
208 "task": str(item.get("task", "")).strip(),
209 "deliverable": str(item.get("deliverable", "")).strip() or None,
210 }
211
212 if row["week"] < 1 or not row["task"]:
213 continue
214
215 await Actor.push_data(row)
216 pushed += 1
217
218 if pushed == 0:
219 Actor.log.warning("No valid tasks could be extracted")
220 await Actor.fail()
221 return
222
223 Actor.log.info(f"Successfully pushed {pushed} learning tasks")
224
225
226if __name__ == "__main__":
227 import asyncio
228 asyncio.run(main())