eraz3r commited on
Commit
239347e
Β·
verified Β·
1 Parent(s): f298994

Upload 3 files

Browse files
Files changed (3) hide show
  1. core/ai_engine.py +418 -0
  2. core/database.py +386 -0
  3. core/styles.py +190 -0
core/ai_engine.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/ai_engine.py
3
+ All Groq API calls β€” prompts taken verbatim from the tested Colab notebook.
4
+ Covers: task parsing, scheduling, journaling Q&A, context synthesis.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import os
10
+ from datetime import datetime, date
11
+ from copy import deepcopy
12
+
13
+ from groq import Groq
14
+
15
+ GROQ_MODEL = "llama-3.3-70b-versatile"
16
+ _client: Groq = None
17
+
18
+
19
+ def init_groq(api_key: str = None):
20
+ global _client
21
+ key = api_key or os.environ.get("GROQ_API_KEY", "")
22
+ if not key:
23
+ raise ValueError(
24
+ "GROQ_API_KEY is not set. "
25
+ "Add it in HuggingFace Space β†’ Settings β†’ Repository secrets."
26
+ )
27
+ _client = Groq(api_key=key)
28
+
29
+
30
+ def _groq() -> Groq:
31
+ if _client is None:
32
+ init_groq()
33
+ return _client
34
+
35
+
36
+ # ── Shared util ───────────────────────────────────────────────────────────────
37
+
38
+ def safe_json_parse(text: str):
39
+ """Parse JSON, stripping markdown fences if present. Returns None on failure."""
40
+ try:
41
+ return json.loads(text)
42
+ except json.JSONDecodeError:
43
+ cleaned = re.sub(r'^```(?:json)?\s*|\s*```$', '', text, flags=re.MULTILINE).strip()
44
+ try:
45
+ return json.loads(cleaned)
46
+ except json.JSONDecodeError:
47
+ m = re.search(r'\{[\s\S]*\}', cleaned)
48
+ if m:
49
+ try:
50
+ return json.loads(m.group())
51
+ except Exception:
52
+ pass
53
+ return None
54
+
55
+
56
+ # ── Module 1: Task Capture ────────────────────────────────────────────────────
57
+
58
+ TASK_CAPTURE_PROMPT = """You are a task classification assistant for a productivity app called The Second Brain.
59
+
60
+ Your job is to take a user's raw task description and return a structured JSON object.
61
+
62
+ Classify the task across these dimensions:
63
+
64
+ 1. title (string): A clean, concise, action-oriented task title. Fix grammar.
65
+
66
+ 2. life_area (string): Choose ONE from: Work, Health, Learning, Finance, Personal, Family, Other
67
+ - Work: job, meetings, deadlines, clients, projects
68
+ - Health: exercise, medical, diet, mental health
69
+ - Learning: courses, books, skills, studying
70
+ - Finance: bills, payments, budgeting, taxes, investments
71
+ - Personal: hobbies, errands, home maintenance
72
+ - Family: tasks involving family members
73
+ - Other: does not fit any category
74
+
75
+ 3. urgency (string): Choose ONE from:
76
+ - Habit: recurring or routine task
77
+ - Urgent: hard deadline or time pressure
78
+ - Not Urgent: no specific deadline
79
+
80
+ 4. importance (string): Choose ONE from:
81
+ - Move the Needle: very high impact
82
+ - Important: meaningful, should be done
83
+ - Not Important: low real impact
84
+
85
+ 5. state_of_mind (string): Choose ONE from:
86
+ - Quick: 5-10 mins, very low focus
87
+ - Easy: 10-20 mins, low focus
88
+ - Flow: deep concentration needed
89
+ - Personal: life admin, little goal impact
90
+
91
+ 6. time_estimate (integer): Realistic minutes to complete.
92
+
93
+ 7. clarifications_needed (array of strings):
94
+ If NOT confident about a dimension, add a short specific question.
95
+ If everything is clear, return []
96
+
97
+ STRICT RULES:
98
+ - Return ONLY valid JSON. No markdown, no explanation.
99
+ - Never guess if uncertain β€” ask a clarification question instead.
100
+ - Always return all 7 fields.
101
+
102
+ Example: {"title": "Finish project proposal", "life_area": "Work", "urgency": "Urgent", "importance": "Move the Needle", "state_of_mind": "Flow", "time_estimate": 90, "clarifications_needed": []}"""
103
+
104
+
105
+ def parse_task_with_groq(raw_text: str, user_context: dict = None,
106
+ user_goals: list = None, life_areas: list = None) -> dict:
107
+ """Parse raw task text into structured dimensions using Groq."""
108
+ # Build context hint from AI memory + goals
109
+ context_hint = ""
110
+ if user_context and user_context.get("learned_patterns", {}).get("notes"):
111
+ notes = user_context["learned_patterns"]["notes"]
112
+ context_hint += f"\n\nUser context notes (use to inform classification): {'; '.join(notes[-3:])}"
113
+ if user_goals:
114
+ context_hint += f"\nUser goals: {'; '.join(user_goals[:5])}"
115
+ if life_areas:
116
+ context_hint += f"\nUser's life areas: {', '.join(life_areas)}"
117
+
118
+ response = _groq().chat.completions.create(
119
+ model=GROQ_MODEL,
120
+ messages=[
121
+ {"role": "system", "content": TASK_CAPTURE_PROMPT + context_hint},
122
+ {"role": "user", "content": f"Parse this task: {raw_text}"}
123
+ ],
124
+ max_tokens=512,
125
+ temperature=0.1,
126
+ )
127
+
128
+ result = safe_json_parse(response.choices[0].message.content.strip())
129
+ if result is None:
130
+ result = {
131
+ "title": raw_text,
132
+ "life_area": None, "urgency": None, "importance": None,
133
+ "state_of_mind": None, "time_estimate": None,
134
+ "clarifications_needed": [
135
+ "Could you give more details about this task?",
136
+ "Which area of your life does this belong to?",
137
+ "Is this urgent or flexible?"
138
+ ]
139
+ }
140
+ return result
141
+
142
+
143
+ # ── Module 2: Scheduling ──────────────────────────────────────────────────────
144
+
145
+ SCHEDULING_SYSTEM_PROMPT = """You are an intelligent daily scheduler for a productivity app called The Second Brain.
146
+
147
+ You receive a USER CONTEXT (preferences + learned patterns), a TASK LIST, and a SCHEDULING PROMPT.
148
+ Return a time-blocked schedule as a JSON object.
149
+
150
+ SCHEDULING RULES:
151
+ - Respect wake_time and sleep_time from context
152
+ - Place Flow tasks during the user's peak focus time
153
+ - If avg_task_overrun_pct > 0, add buffer proportionally to time estimates
154
+ - If flow_batch_capable is true, group Flow tasks; otherwise space them out
155
+ - Place Quick and Easy tasks around transitions and low-energy windows
156
+ - Place Personal/Habit tasks at day boundaries (start or end of day)
157
+ - Urgent tasks are scheduled before Not Urgent ones
158
+ - Move the Needle tasks get the best time slots
159
+ - Add 5-10 min breaks between tasks
160
+ - Respect any fixed commitments mentioned in the scheduling prompt
161
+ - Do NOT schedule past sleep_time
162
+ - If tasks won't realistically fit, put them in deferred_tasks
163
+ - If context is minimal (new user), use sensible defaults
164
+
165
+ RETURN FORMAT (JSON only, no markdown):
166
+ {
167
+ "schedule_date": "YYYY-MM-DD",
168
+ "scheduled_tasks": [
169
+ {
170
+ "task_id": "(id from input or index)",
171
+ "title": "...",
172
+ "life_area": "...",
173
+ "start_time": "HH:MM",
174
+ "end_time": "HH:MM",
175
+ "duration_minutes": 60,
176
+ "state_of_mind": "...",
177
+ "scheduling_reason": "1-sentence explanation"
178
+ }
179
+ ],
180
+ "deferred_tasks": [{"task_id": "...", "title": "...", "reason": "..."}],
181
+ "day_summary": "2-3 sentences on day structure and reasoning",
182
+ "warnings": ["any concerns e.g. day overloaded"]
183
+ }"""
184
+
185
+
186
+ def generate_schedule(context: dict, tasks: list, scheduling_prompt: str,
187
+ goals: list = None, schedule_date: str = None) -> dict:
188
+ if not schedule_date:
189
+ schedule_date = str(date.today())
190
+
191
+ goals_section = ""
192
+ if goals:
193
+ goals_section = "\nUSER GOALS:\n" + "\n".join(f"- {g}" for g in goals)
194
+
195
+ user_message = f"""Schedule Date: {schedule_date}
196
+
197
+ USER CONTEXT:
198
+ {json.dumps(context, indent=2)}
199
+ {goals_section}
200
+ TASKS TO SCHEDULE ({len(tasks)} tasks):
201
+ {json.dumps(tasks, indent=2)}
202
+
203
+ USER SCHEDULING PROMPT:
204
+ {scheduling_prompt}
205
+
206
+ Generate the optimal schedule."""
207
+
208
+ response = _groq().chat.completions.create(
209
+ model=GROQ_MODEL,
210
+ messages=[
211
+ {"role": "system", "content": SCHEDULING_SYSTEM_PROMPT},
212
+ {"role": "user", "content": user_message}
213
+ ],
214
+ max_tokens=2048,
215
+ temperature=0.2,
216
+ )
217
+
218
+ result = safe_json_parse(response.choices[0].message.content.strip())
219
+ if result is None:
220
+ result = {
221
+ "error": "Could not parse schedule response.",
222
+ "raw": response.choices[0].message.content
223
+ }
224
+
225
+ result["schedule_date"] = schedule_date
226
+ result["generated_at"] = datetime.now().isoformat()
227
+ return result
228
+
229
+
230
+ # ── Module 3: Journaling ──────────────────────────────────────────────────────
231
+
232
+ JOURNAL_QUESTION_PROMPT = """You are a reflective journaling coach for a productivity app called The Second Brain.
233
+
234
+ You receive the user's context, today's schedule with completion status, and the conversation so far.
235
+ Your job: decide what targeted question to ask NEXT.
236
+
237
+ FOCUS AREAS (cover what's most relevant, don't ask all):
238
+ - Tasks not completed β€” why? wrong time? too tired? overestimated?
239
+ - Tasks that took much longer than estimated
240
+ - Energy levels β€” when were they sharp vs drained?
241
+ - Whether Flow tasks were placed well or hard to start
242
+ - Whether the day felt balanced or overloaded
243
+ - Patterns the user noticed about themselves
244
+
245
+ RULES:
246
+ - Ask ONE question at a time. Short and specific.
247
+ - Build on previous answers β€” don't repeat covered ground.
248
+ - After 4-6 good exchanges, signal completion.
249
+ - Keep tone warm and efficient β€” 2-minute check-in, not therapy.
250
+
251
+ RETURN FORMAT (JSON only):
252
+ {"question": "Your next question", "question_focus": "what aspect this targets", "session_complete": false}
253
+ OR when done:
254
+ {"question": null, "question_focus": null, "session_complete": true}"""
255
+
256
+
257
+ SYNTHESIS_PROMPT = """You are a pattern recognition engine for a productivity app called The Second Brain.
258
+
259
+ You have a completed journaling conversation. Extract learnings and return the UPDATED user context JSON.
260
+
261
+ UPDATE these fields in learned_patterns based on conversation evidence:
262
+ - productive_times: when user felt sharp/focused
263
+ - low_energy_times: when they felt drained or skipped tasks
264
+ - avg_task_overrun_pct: recalculate from actual vs estimated times mentioned
265
+ - flow_batch_capable: update if user gave clear evidence
266
+ - best_life_areas_morning: what they completed well before noon
267
+ - common_skipped_task_types: patterns in what gets consistently skipped
268
+ - notes: append 1-2 new insight notes (keep existing ones)
269
+
270
+ ALWAYS UPDATE:
271
+ - scheduling_feedback.total_days_scheduled: +1
272
+ - scheduling_feedback.avg_completion_rate: rolling average
273
+ - scheduling_feedback.last_7_day_completion_rates: append today, keep last 7
274
+ - history_summary: append brief today summary, keep last 14
275
+ - last_updated: now
276
+ - version: +1
277
+
278
+ RULES:
279
+ - Return ONLY the complete updated context JSON. Nothing else.
280
+ - Never remove existing patterns β€” only update or append.
281
+ - Be conservative β€” only update if there is clear evidence in the conversation."""
282
+
283
+
284
+ def build_opening_question(context: dict, tasks_today: list) -> dict:
285
+ """Generate the first journal question based on task completion at a glance."""
286
+ total = len(tasks_today)
287
+ completed = sum(1 for t in tasks_today if t.get("completed", False))
288
+ incomplete = [t for t in tasks_today if not t.get("completed", False)]
289
+
290
+ if total == 0:
291
+ q = "It looks like you didn't have any tasks scheduled today β€” was that intentional or did things go sideways?"
292
+ elif completed == 0:
293
+ q = f"None of today's {total} tasks got marked complete β€” was the day unexpectedly derailed, or did the plan just not fit how your day went?"
294
+ elif completed == total:
295
+ q = f"You completed all {total} tasks today β€” great day! Did the schedule feel natural, or were you pushing through?"
296
+ elif len(incomplete) == 1:
297
+ q = f'You got almost everything done β€” the one task left was "{incomplete[0]["title"]}". What got in the way?'
298
+ else:
299
+ rate = round(completed / total * 100)
300
+ titles = ", ".join(f'"{t["title"]}"' for t in incomplete[:2])
301
+ q = f"You completed {completed}/{total} tasks ({rate}%). Tasks like {titles} didn't get done β€” was that time, energy, or something else?"
302
+
303
+ return {"question": q, "question_focus": "completion_overview", "session_complete": False}
304
+
305
+
306
+ def get_next_journal_question(context: dict, tasks_today: list,
307
+ conversation_history: list) -> dict:
308
+ user_message = f"""USER CONTEXT:
309
+ {json.dumps(context, indent=2)}
310
+
311
+ TODAY'S SCHEDULE (with completion):
312
+ {json.dumps(tasks_today, indent=2)}
313
+
314
+ CONVERSATION SO FAR ({len(conversation_history)} exchanges):
315
+ {json.dumps(conversation_history, indent=2)}
316
+
317
+ What should I ask next? Return session_complete: true if enough has been covered."""
318
+
319
+ response = _groq().chat.completions.create(
320
+ model=GROQ_MODEL,
321
+ messages=[
322
+ {"role": "system", "content": JOURNAL_QUESTION_PROMPT},
323
+ {"role": "user", "content": user_message}
324
+ ],
325
+ max_tokens=256,
326
+ temperature=0.3,
327
+ )
328
+
329
+ result = safe_json_parse(response.choices[0].message.content.strip())
330
+ if result is None:
331
+ result = {
332
+ "question": response.choices[0].message.content.strip(),
333
+ "question_focus": "general",
334
+ "session_complete": False
335
+ }
336
+ return result
337
+
338
+
339
+ def synthesize_journal(context: dict, tasks_today: list,
340
+ conversation_history: list) -> dict:
341
+ """Synthesize conversation into updated context. Fallback to manual stats update if LLM fails."""
342
+ total = len(tasks_today)
343
+ completed = sum(1 for t in tasks_today if t.get("completed", False))
344
+ completion_rate = round(completed / total, 2) if total > 0 else 0.0
345
+
346
+ user_message = f"""USER CONTEXT (current):
347
+ {json.dumps(context, indent=2)}
348
+
349
+ TODAY'S SCHEDULE + COMPLETION:
350
+ {json.dumps(tasks_today, indent=2)}
351
+
352
+ Today's completion rate: {completion_rate} ({completed}/{total})
353
+
354
+ FULL JOURNALING CONVERSATION:
355
+ {json.dumps(conversation_history, indent=2)}
356
+
357
+ Return the complete updated context JSON."""
358
+
359
+ response = _groq().chat.completions.create(
360
+ model=GROQ_MODEL,
361
+ messages=[
362
+ {"role": "system", "content": SYNTHESIS_PROMPT},
363
+ {"role": "user", "content": user_message}
364
+ ],
365
+ max_tokens=2048,
366
+ temperature=0.1,
367
+ )
368
+
369
+ updated = safe_json_parse(response.choices[0].message.content.strip())
370
+
371
+ if updated is None:
372
+ # Fallback: update stats manually if synthesis fails
373
+ updated = deepcopy(context)
374
+ updated["last_updated"] = datetime.now().isoformat()
375
+ updated["version"] = context.get("version", 1) + 1
376
+ sf = updated.setdefault("scheduling_feedback", {})
377
+ sf["total_days_scheduled"] = sf.get("total_days_scheduled", 0) + 1
378
+ rates = sf.get("last_7_day_completion_rates", [])
379
+ rates.append(completion_rate)
380
+ sf["last_7_day_completion_rates"] = rates[-7:]
381
+ sf["avg_completion_rate"] = round(sum(rates) / len(rates), 2)
382
+
383
+ return updated
384
+
385
+
386
+ # ── Context helpers ───────────────────────────────────────────────────────────
387
+
388
+ def create_blank_context(user_id, preferences: dict = None) -> dict:
389
+ prefs = preferences or {}
390
+ return {
391
+ "user_id": user_id,
392
+ "created_at": datetime.now().isoformat(),
393
+ "last_updated": datetime.now().isoformat(),
394
+ "version": 1,
395
+ "preferences": {
396
+ "wake_time": prefs.get("wake_time", "08:00"),
397
+ "sleep_time": prefs.get("sleep_time", "23:00"),
398
+ "focus_peak": prefs.get("focus_peak", "Morning"),
399
+ "break_duration_minutes": 10,
400
+ "max_flow_block_minutes": 90,
401
+ },
402
+ "learned_patterns": {
403
+ "productive_times": [],
404
+ "low_energy_times": [],
405
+ "avg_task_overrun_pct": 0,
406
+ "flow_batch_capable": None,
407
+ "best_life_areas_morning": [],
408
+ "habit_completion_rate": {},
409
+ "common_skipped_task_types": [],
410
+ "notes": []
411
+ },
412
+ "history_summary": [],
413
+ "scheduling_feedback": {
414
+ "total_days_scheduled": 0,
415
+ "avg_completion_rate": 0.0,
416
+ "last_7_day_completion_rates": []
417
+ }
418
+ }
core/database.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/database.py
3
+ SQLite persistence for Second Brain.
4
+ Tables: users, life_areas, goals, tasks, user_context (AI memory)
5
+ """
6
+
7
+ import sqlite3
8
+ import json
9
+ import bcrypt
10
+ from datetime import datetime, date
11
+ from typing import Optional
12
+
13
+ DB_PATH = "second_brain.db"
14
+
15
+
16
+ # ── Connection ────────────────────────────────────────────────────────────────
17
+
18
+ def get_db() -> sqlite3.Connection:
19
+ conn = sqlite3.connect(DB_PATH)
20
+ conn.row_factory = sqlite3.Row
21
+ conn.execute("PRAGMA foreign_keys = ON")
22
+ return conn
23
+
24
+
25
+ def init_db():
26
+ """Create all tables on first run."""
27
+ conn = get_db()
28
+ c = conn.cursor()
29
+
30
+ c.execute("""
31
+ CREATE TABLE IF NOT EXISTS users (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ username TEXT UNIQUE NOT NULL,
34
+ password_hash TEXT NOT NULL,
35
+ created_at TEXT DEFAULT (datetime('now'))
36
+ )
37
+ """)
38
+
39
+ c.execute("""
40
+ CREATE TABLE IF NOT EXISTS life_areas (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ user_id INTEGER NOT NULL,
43
+ name TEXT NOT NULL,
44
+ color TEXT DEFAULT '#6366f1',
45
+ created_at TEXT DEFAULT (datetime('now')),
46
+ FOREIGN KEY (user_id) REFERENCES users(id)
47
+ )
48
+ """)
49
+
50
+ c.execute("""
51
+ CREATE TABLE IF NOT EXISTS user_goals (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ user_id INTEGER NOT NULL,
54
+ goal_text TEXT NOT NULL,
55
+ created_at TEXT DEFAULT (datetime('now')),
56
+ FOREIGN KEY (user_id) REFERENCES users(id)
57
+ )
58
+ """)
59
+
60
+ c.execute("""
61
+ CREATE TABLE IF NOT EXISTS tasks (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ user_id INTEGER NOT NULL,
64
+ title TEXT NOT NULL,
65
+ life_area TEXT DEFAULT '',
66
+ urgency TEXT DEFAULT 'Not Urgent',
67
+ importance TEXT DEFAULT 'Important',
68
+ state_of_mind TEXT DEFAULT 'Easy',
69
+ time_estimate INTEGER DEFAULT 30,
70
+ scheduled_date TEXT DEFAULT (date('now')),
71
+ is_completed INTEGER DEFAULT 0,
72
+ actual_duration INTEGER,
73
+ is_habit INTEGER DEFAULT 0,
74
+ habit_interval TEXT DEFAULT '',
75
+ raw_input TEXT DEFAULT '',
76
+ created_at TEXT DEFAULT (datetime('now')),
77
+ FOREIGN KEY (user_id) REFERENCES users(id)
78
+ )
79
+ """)
80
+
81
+ # AI-learned context stored as a JSON blob per user
82
+ c.execute("""
83
+ CREATE TABLE IF NOT EXISTS user_context (
84
+ user_id INTEGER PRIMARY KEY,
85
+ context TEXT NOT NULL,
86
+ updated_at TEXT DEFAULT (datetime('now')),
87
+ FOREIGN KEY (user_id) REFERENCES users(id)
88
+ )
89
+ """)
90
+
91
+ conn.commit()
92
+ conn.close()
93
+
94
+
95
+ # ── Auth ──────────────────────────────────────────────────────────────────────
96
+
97
+ def register_user(username: str, password: str) -> tuple:
98
+ """Returns (user_id, message). user_id is None on failure."""
99
+ username = username.strip().lower()
100
+ if not username or not password:
101
+ return None, "Username and password cannot be empty."
102
+ if len(password) < 6:
103
+ return None, "Password must be at least 6 characters."
104
+ conn = get_db()
105
+ try:
106
+ pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
107
+ conn.execute(
108
+ "INSERT INTO users (username, password_hash) VALUES (?, ?)",
109
+ (username, pw_hash)
110
+ )
111
+ conn.commit()
112
+ row = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
113
+ return row["id"], "Account created!"
114
+ except sqlite3.IntegrityError:
115
+ return None, "Username already taken."
116
+ finally:
117
+ conn.close()
118
+
119
+
120
+ def login_user(username: str, password: str) -> tuple:
121
+ """Returns (user_id, message). user_id is None on failure."""
122
+ username = username.strip().lower()
123
+ if not username or not password:
124
+ return None, "Please enter your credentials."
125
+ conn = get_db()
126
+ row = conn.execute(
127
+ "SELECT id, password_hash FROM users WHERE username = ?", (username,)
128
+ ).fetchone()
129
+ conn.close()
130
+ if not row:
131
+ return None, "Username not found."
132
+ if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
133
+ return None, "Incorrect password."
134
+ return row["id"], f"Welcome back, {username}!"
135
+
136
+
137
+ def get_username(user_id: int) -> str:
138
+ conn = get_db()
139
+ row = conn.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()
140
+ conn.close()
141
+ return row["username"].capitalize() if row else "User"
142
+
143
+
144
+ # ── Life Areas ────────────────────────────────────────────────────────────────
145
+
146
+ DEFAULT_AREAS = [
147
+ ("Work", "#4F8EF7"),
148
+ ("Health", "#4CAF87"),
149
+ ("Finance", "#F7A84F"),
150
+ ("Learning", "#A855F7"),
151
+ ("Personal", "#EC4899"),
152
+ ("Family", "#F59E0B"),
153
+ ]
154
+
155
+
156
+ def create_default_life_areas(user_id: int):
157
+ conn = get_db()
158
+ for name, color in DEFAULT_AREAS:
159
+ conn.execute(
160
+ "INSERT INTO life_areas (user_id, name, color) VALUES (?, ?, ?)",
161
+ (user_id, name, color)
162
+ )
163
+ conn.commit()
164
+ conn.close()
165
+
166
+
167
+ def get_life_areas(user_id: int) -> list:
168
+ conn = get_db()
169
+ rows = conn.execute(
170
+ "SELECT id, name, color FROM life_areas WHERE user_id = ? ORDER BY id",
171
+ (user_id,)
172
+ ).fetchall()
173
+ conn.close()
174
+ return [dict(r) for r in rows]
175
+
176
+
177
+ def get_life_area_names(user_id: int) -> list:
178
+ return [a["name"] for a in get_life_areas(user_id)]
179
+
180
+
181
+ def add_life_area(user_id: int, name: str, color: str = "#6366f1") -> tuple:
182
+ name = name.strip()
183
+ if not name:
184
+ return False, "Name cannot be empty."
185
+ conn = get_db()
186
+ exists = conn.execute(
187
+ "SELECT id FROM life_areas WHERE user_id = ? AND LOWER(name) = LOWER(?)",
188
+ (user_id, name)
189
+ ).fetchone()
190
+ if exists:
191
+ conn.close()
192
+ return False, f'"{name}" already exists.'
193
+ conn.execute(
194
+ "INSERT INTO life_areas (user_id, name, color) VALUES (?, ?, ?)",
195
+ (user_id, name, color)
196
+ )
197
+ conn.commit()
198
+ conn.close()
199
+ return True, f'"{name}" added.'
200
+
201
+
202
+ def delete_life_area(user_id: int, name: str) -> tuple:
203
+ conn = get_db()
204
+ conn.execute(
205
+ "DELETE FROM life_areas WHERE user_id = ? AND name = ?", (user_id, name)
206
+ )
207
+ conn.commit()
208
+ conn.close()
209
+ return True, f'"{name}" removed.'
210
+
211
+
212
+ # ── Goals ─────────────────────────────────────────────────────────────────────
213
+
214
+ def save_goals(user_id: int, goals_text: str):
215
+ conn = get_db()
216
+ conn.execute("DELETE FROM user_goals WHERE user_id = ?", (user_id,))
217
+ for line in goals_text.strip().splitlines():
218
+ line = line.strip("β€’- ").strip()
219
+ if line:
220
+ conn.execute(
221
+ "INSERT INTO user_goals (user_id, goal_text) VALUES (?, ?)",
222
+ (user_id, line)
223
+ )
224
+ conn.commit()
225
+ conn.close()
226
+
227
+
228
+ def get_goals(user_id: int) -> list:
229
+ conn = get_db()
230
+ rows = conn.execute(
231
+ "SELECT goal_text FROM user_goals WHERE user_id = ? ORDER BY id",
232
+ (user_id,)
233
+ ).fetchall()
234
+ conn.close()
235
+ return [r["goal_text"] for r in rows]
236
+
237
+
238
+ # ── Tasks ─────────────────────────────────────────────────────────────────────
239
+
240
+ def save_task(user_id: int, task: dict, scheduled_date: str = None) -> int:
241
+ conn = get_db()
242
+ cursor = conn.execute("""
243
+ INSERT INTO tasks
244
+ (user_id, title, life_area, urgency, importance, state_of_mind,
245
+ time_estimate, scheduled_date, raw_input, is_habit, habit_interval)
246
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
247
+ """, (
248
+ user_id,
249
+ task.get("title", "Untitled"),
250
+ task.get("life_area", ""),
251
+ task.get("urgency", "Not Urgent"),
252
+ task.get("importance", "Important"),
253
+ task.get("state_of_mind", "Easy"),
254
+ int(task.get("time_estimate") or 30),
255
+ scheduled_date or str(date.today()),
256
+ task.get("raw_input", ""),
257
+ 1 if task.get("is_habit") else 0,
258
+ task.get("habit_interval", ""),
259
+ ))
260
+ task_id = cursor.lastrowid
261
+ conn.commit()
262
+ conn.close()
263
+ return task_id
264
+
265
+
266
+ def get_tasks(user_id: int, filter_area: str = "All", only_today: bool = False,
267
+ include_completed: bool = True) -> list:
268
+ conn = get_db()
269
+ q = "SELECT * FROM tasks WHERE user_id = ?"
270
+ params = [user_id]
271
+ if filter_area and filter_area != "All":
272
+ q += " AND life_area = ?"
273
+ params.append(filter_area)
274
+ if only_today:
275
+ q += " AND scheduled_date = ?"
276
+ params.append(str(date.today()))
277
+ if not include_completed:
278
+ q += " AND is_completed = 0"
279
+ q += " ORDER BY is_completed ASC, created_at DESC"
280
+ rows = conn.execute(q, params).fetchall()
281
+ conn.close()
282
+ return [dict(r) for r in rows]
283
+
284
+
285
+ def toggle_task_complete(task_id: int, user_id: int, actual_duration: int = None):
286
+ conn = get_db()
287
+ task = conn.execute(
288
+ "SELECT is_completed FROM tasks WHERE id = ? AND user_id = ?", (task_id, user_id)
289
+ ).fetchone()
290
+ if task:
291
+ new_status = 1 - task["is_completed"]
292
+ if actual_duration and new_status == 1:
293
+ conn.execute(
294
+ "UPDATE tasks SET is_completed = ?, actual_duration = ? WHERE id = ? AND user_id = ?",
295
+ (new_status, actual_duration, task_id, user_id)
296
+ )
297
+ else:
298
+ conn.execute(
299
+ "UPDATE tasks SET is_completed = ? WHERE id = ? AND user_id = ?",
300
+ (new_status, task_id, user_id)
301
+ )
302
+ conn.commit()
303
+ conn.close()
304
+
305
+
306
+ def delete_task(task_id: int, user_id: int):
307
+ conn = get_db()
308
+ conn.execute("DELETE FROM tasks WHERE id = ? AND user_id = ?", (task_id, user_id))
309
+ conn.commit()
310
+ conn.close()
311
+
312
+
313
+ def get_today_stats(user_id: int) -> dict:
314
+ tasks = get_tasks(user_id, only_today=True)
315
+ total = len(tasks)
316
+ done = sum(1 for t in tasks if t["is_completed"])
317
+ return {"total": total, "done": done, "remaining": total - done}
318
+
319
+
320
+ # ── Habit recurrence ──────────────────────────────────────────────────────────
321
+
322
+ def spawn_due_habits(user_id: int):
323
+ """
324
+ Check all habit tasks. If a habit's scheduled_date < today and
325
+ it's not already scheduled for today, create a fresh copy for today.
326
+ Called on login / tab load.
327
+ """
328
+ today = str(date.today())
329
+ conn = get_db()
330
+ habits = conn.execute(
331
+ "SELECT * FROM tasks WHERE user_id = ? AND is_habit = 1",
332
+ (user_id,)
333
+ ).fetchall()
334
+
335
+ for h in habits:
336
+ # Check if already exists today
337
+ existing = conn.execute(
338
+ "SELECT id FROM tasks WHERE user_id = ? AND title = ? AND is_habit = 1 AND scheduled_date = ?",
339
+ (user_id, h["title"], today)
340
+ ).fetchone()
341
+ if existing:
342
+ continue
343
+ # Only spawn if original was scheduled before today
344
+ if h["scheduled_date"] and h["scheduled_date"] >= today:
345
+ continue
346
+ conn.execute("""
347
+ INSERT INTO tasks (user_id, title, life_area, urgency, importance,
348
+ state_of_mind, time_estimate, scheduled_date, is_habit,
349
+ habit_interval, raw_input)
350
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
351
+ """, (
352
+ user_id, h["title"], h["life_area"], "Habit",
353
+ h["importance"], h["state_of_mind"], h["time_estimate"],
354
+ today, h["habit_interval"], h["raw_input"]
355
+ ))
356
+ conn.commit()
357
+ conn.close()
358
+
359
+
360
+ # ── AI Context ────────────────────────────────────────────────────────────────
361
+
362
+ def load_user_context(user_id: int) -> Optional[dict]:
363
+ conn = get_db()
364
+ row = conn.execute(
365
+ "SELECT context FROM user_context WHERE user_id = ?", (user_id,)
366
+ ).fetchone()
367
+ conn.close()
368
+ if row:
369
+ try:
370
+ return json.loads(row["context"])
371
+ except Exception:
372
+ return None
373
+ return None
374
+
375
+
376
+ def save_user_context(user_id: int, context: dict):
377
+ conn = get_db()
378
+ conn.execute("""
379
+ INSERT INTO user_context (user_id, context, updated_at)
380
+ VALUES (?, ?, datetime('now'))
381
+ ON CONFLICT(user_id) DO UPDATE SET
382
+ context = excluded.context,
383
+ updated_at = excluded.updated_at
384
+ """, (user_id, json.dumps(context)))
385
+ conn.commit()
386
+ conn.close()
core/styles.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/styles.py
3
+ All CSS for the Second Brain Gradio app.
4
+ """
5
+
6
+ CSS = """
7
+ @import url('https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
8
+
9
+ /* ── Reset & base ──────────────────────────────────────── */
10
+ *, *::before, *::after { box-sizing: border-box; }
11
+
12
+ body, .gradio-container {
13
+ font-family: 'Sora', system-ui, sans-serif !important;
14
+ background: #060a12 !important;
15
+ color: #cbd5e1 !important;
16
+ }
17
+ .gradio-container { max-width: 1200px !important; margin: 0 auto !important; }
18
+ .main { padding: 16px !important; }
19
+
20
+ /* ── Auth card ─────────────────────────────────────────── */
21
+ #auth-card {
22
+ max-width: 420px;
23
+ margin: 48px auto 0;
24
+ background: #0d1117;
25
+ border: 1px solid #1e293b;
26
+ border-radius: 20px;
27
+ padding: 40px 36px 32px;
28
+ box-shadow: 0 0 80px rgba(99,102,241,.1);
29
+ }
30
+ #brand-logo {
31
+ font-size: 28px; font-weight: 700; text-align: center;
32
+ background: linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%);
33
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
34
+ margin-bottom: 4px;
35
+ }
36
+ #brand-sub { text-align: center; color: #334155; font-size: 13px; margin-bottom: 28px; }
37
+
38
+ /* ── Top header ────────────────────────────────────────── */
39
+ #top-header {
40
+ background: #0d1117;
41
+ border: 1px solid #1e293b;
42
+ border-radius: 14px;
43
+ padding: 14px 22px;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ margin-bottom: 18px;
48
+ }
49
+ #top-header-brand {
50
+ font-size: 18px; font-weight: 700;
51
+ background: linear-gradient(135deg, #a78bfa, #60a5fa);
52
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
53
+ }
54
+ #top-header-user { font-size: 12px; color: #334155; }
55
+
56
+ /* ── Stat cards ────────────────────────────────────────── */
57
+ .stat-card {
58
+ background: #0d1117;
59
+ border: 1px solid #1e293b;
60
+ border-radius: 14px;
61
+ padding: 18px 12px;
62
+ text-align: center;
63
+ min-height: 90px;
64
+ }
65
+ .stat-num { font-size: 36px; font-weight: 700; line-height: 1; }
66
+ .stat-label { font-size: 10px; color: #334155; margin-top: 6px;
67
+ text-transform: uppercase; letter-spacing: .8px; }
68
+
69
+ /* ── Panels ────────────────────────────────────────────── */
70
+ .panel {
71
+ background: #0d1117;
72
+ border: 1px solid #1e293b;
73
+ border-radius: 14px;
74
+ padding: 22px 20px;
75
+ margin-top: 14px;
76
+ }
77
+ .sec-label {
78
+ font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
79
+ color: #334155; font-weight: 600; margin-bottom: 16px;
80
+ }
81
+
82
+ /* ── Buttons ───────────────────────────────────────────── */
83
+ .btn-primary button {
84
+ background: linear-gradient(135deg,#6366f1,#8b5cf6) !important;
85
+ color: #fff !important; font-weight: 600 !important;
86
+ border: none !important; border-radius: 10px !important;
87
+ font-family: 'Sora', sans-serif !important;
88
+ transition: opacity .15s !important;
89
+ }
90
+ .btn-primary button:hover { opacity: .85 !important; }
91
+
92
+ .btn-accent button {
93
+ background: linear-gradient(135deg,#0ea5e9,#6366f1) !important;
94
+ color: #fff !important; font-weight: 600 !important;
95
+ border: none !important; border-radius: 10px !important;
96
+ font-family: 'Sora', sans-serif !important;
97
+ }
98
+ .btn-secondary button {
99
+ background: #161b27 !important; color: #64748b !important;
100
+ border: 1px solid #1e293b !important; border-radius: 10px !important;
101
+ font-family: 'Sora', sans-serif !important;
102
+ }
103
+ .btn-success button {
104
+ background: #052e16 !important; color: #4ade80 !important;
105
+ border: 1px solid #14532d !important; border-radius: 10px !important;
106
+ font-family: 'Sora', sans-serif !important;
107
+ }
108
+ .btn-danger button {
109
+ background: #1c0606 !important; color: #f87171 !important;
110
+ border: 1px solid #450a0a !important; border-radius: 10px !important;
111
+ font-family: 'Sora', sans-serif !important;
112
+ }
113
+
114
+ /* ── Inputs ────────────────────────────────────────────── */
115
+ input, textarea, select {
116
+ background: #060a12 !important;
117
+ border: 1px solid #1e293b !important;
118
+ border-radius: 10px !important;
119
+ color: #e2e8f0 !important;
120
+ font-family: 'Sora', sans-serif !important;
121
+ }
122
+ input:focus, textarea:focus {
123
+ border-color: #6366f1 !important;
124
+ box-shadow: 0 0 0 2px rgba(99,102,241,.18) !important;
125
+ }
126
+ label { color: #475569 !important; font-size: 12px !important; font-weight: 500 !important; }
127
+
128
+ /* ── Tabs ──────────────────────────────────────────────── */
129
+ .tab-nav button {
130
+ color: #334155 !important; font-family: 'Sora', sans-serif !important;
131
+ font-weight: 500 !important; background: transparent !important;
132
+ border: none !important; border-bottom: 2px solid transparent !important;
133
+ padding: 10px 18px !important; font-size: 13px !important;
134
+ }
135
+ .tab-nav button.selected {
136
+ color: #a78bfa !important;
137
+ border-bottom: 2px solid #a78bfa !important;
138
+ }
139
+
140
+ /* ── Dataframe ─────────────────────────────────────────── */
141
+ .dataframe thead th {
142
+ background: #0d1117 !important; color: #334155 !important;
143
+ font-size: 10px !important; text-transform: uppercase !important;
144
+ letter-spacing: .6px !important; padding: 10px 12px !important;
145
+ font-family: 'Sora', sans-serif !important; border: none !important;
146
+ }
147
+ .dataframe tbody td {
148
+ background: #060a12 !important; color: #94a3b8 !important;
149
+ font-family: 'JetBrains Mono', monospace !important;
150
+ font-size: 12px !important; padding: 9px 12px !important;
151
+ border-bottom: 1px solid #0d1117 !important;
152
+ }
153
+ .dataframe tbody tr:hover td { background: #0d1117 !important; }
154
+
155
+ /* ── Chatbot ───────────────────────────────────────────── */
156
+ .chatbot {
157
+ background: #060a12 !important;
158
+ border: 1px solid #1e293b !important;
159
+ border-radius: 14px !important;
160
+ }
161
+
162
+ /* ── Schedule cards ────────────────────────────────────── */
163
+ .sched-card {
164
+ background: #0d1117;
165
+ border: 1px solid #1e293b;
166
+ border-left: 3px solid #6366f1;
167
+ border-radius: 10px;
168
+ padding: 12px 16px;
169
+ margin-bottom: 8px;
170
+ }
171
+ .sched-time { font-size: 11px; font-family: 'JetBrains Mono',monospace; color: #6366f1; font-weight: 600; }
172
+ .sched-title { font-size: 14px; font-weight: 600; color: #f1f5f9; margin: 2px 0 4px; }
173
+ .sched-meta { font-size: 11px; color: #334155; }
174
+ .sched-why { font-size: 11px; color: #475569; font-style: italic; margin-top: 3px; }
175
+
176
+ /* ── Life area chips ───────────────────────────────────── */
177
+ .chip {
178
+ display: inline-block; padding: 4px 12px; border-radius: 20px;
179
+ font-size: 12px; font-weight: 600; margin: 3px;
180
+ }
181
+
182
+ /* ── Status messages ───────────────────────────────────── */
183
+ .ok { color: #4ade80; font-size: 13px; }
184
+ .err { color: #f87171; font-size: 13px; }
185
+ .inf { color: #60a5fa; font-size: 13px; }
186
+
187
+ /* ── Misc ──────────────────────────────────────────────── */
188
+ hr { border-color: #1e293b !important; margin: 16px 0 !important; }
189
+ .hidden { display: none !important; }
190
+ """