smlflg commited on
Commit
c72a1e8
Β·
verified Β·
1 Parent(s): 3b4d60f

Initial public upload from Projekte/StudyAgent

Browse files
.claude/settings.local.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(.venv/bin/python -m pytest tests/ -v 2>&1)"
5
+ ]
6
+ }
7
+ }
.codex/config.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Coding MCPs: project-local registration (2026-09-07).
2
+
3
+ [mcp_servers.serena]
4
+ command = "/home/smlflg/.local/share/coding-mcp/serena/.venv/bin/serena"
5
+ args = ["start-mcp-server", "--context=codex", "--project", "/home/smlflg/Projekte/StudyAgent", "--enable-web-dashboard", "false", "--open-web-dashboard", "false", "--enable-gui-log-window", "false"]
6
+ cwd = "/home/smlflg/Projekte/StudyAgent"
7
+ enabled = true
8
+ startup_timeout_sec = 120
9
+ tool_timeout_sec = 180
10
+
11
+ [mcp_servers.serena.env]
12
+ "SERENA_HOME" = "/home/smlflg/.local/share/coding-mcp/serena/state"
13
+
14
+ [mcp_servers.gitnexus]
15
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
16
+ args = ["/home/smlflg/.local/share/coding-mcp/gitnexus/node_modules/gitnexus/dist/cli/index.js", "mcp"]
17
+ cwd = "/home/smlflg/Projekte/StudyAgent"
18
+ enabled = true
19
+ startup_timeout_sec = 120
20
+ tool_timeout_sec = 180
21
+
22
+ [mcp_servers.gitnexus.env]
23
+ "GITNEXUS_HOME" = "/home/smlflg/.local/share/coding-mcp/gitnexus/runtime"
24
+
25
+ [mcp_servers.context7]
26
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
27
+ args = ["/home/smlflg/.local/share/coding-mcp/context7/node_modules/@upstash/context7-mcp/dist/index.js"]
28
+ cwd = "/home/smlflg/Projekte/StudyAgent"
29
+ enabled = true
30
+ startup_timeout_sec = 120
31
+ tool_timeout_sec = 180
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Database
19
+ study_agent.db
20
+ *.sqlite3
21
+ *.db
22
+
23
+ # Secrets
24
+ .env
25
+
26
+ # Frontend
27
+ node_modules/
28
+
29
+ # IDE
30
+ .idea/
31
+ .vscode/
32
+ *.swp
33
+ *.swo
34
+
35
+ # OS
36
+ .DS_Store
37
+ Thumbs.db
COACHING-EMPFEHLUNGEN.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Coaching-Empfehlungen fuer Samuel
2
+ *Stand: 05. Maerz 2026 β€” basierend auf MEMORY.md, 3 Projekt-Docs, PORTRAIT_SAMUEL.md, samuel-kontext.md*
3
+
4
+ ---
5
+
6
+ ## 1. Muster-Analyse
7
+
8
+ ### Was sich wiederholt
9
+
10
+ **Das Architektur-Dopamin-Muster.** Du baust Infrastruktur, die Infrastruktur steuert. Sidecar ueberwacht Claude Code. ClaudeCodePanel visualisiert Sidecar. Oktopus-Architektur orchestriert Agents. Jede Schicht ist elegant β€” und jede Schicht ist eine weitere Schicht zwischen dir und einem fertigen Produkt. Das ist kein Zufall. Architektur-Design gibt dir den Dopamin-Hit von "ich hab was geschafft", ohne dass du durch die muehsamen letzten 15% musst.
11
+
12
+ **Der Sprint-Sprawl-Zyklus.** Heute sind es 7 Projekte gleichzeitig. Am 4. Maerz waren es 22 Sidecar-Rules, 19 Skills, 7 Panel-Tabs, 4 Coaching-Hooks, ein Watcher v2.0 Plan, ein Digitaler Zwilling und Agent Teams. Die Session-Sprawl-Regel ("Keine neue Session ohne /recap + /learn") existiert β€” weil du sie brauchst. Und sie existiert als Regel β€” weil du sie brichst.
13
+
14
+ **Die 95%-Falle.** ClaudeCodePanel: "95%+ fertig, 4 Suggestions offen". ShortcutCounter: "85% fertig". Keeper: "ein Einzeiler fehlt seit Monaten". Das Pattern aus dem Portrait ist unveraendert: Du baust bis es funktioniert, dann springst du zum naechsten Dopamin-Geber. Die letzten 5% sind der haerteste Teil fuer ein ADHS-Gehirn β€” kein Novelty-Reward, nur Aufraeumen.
15
+
16
+ **Meta-Vibecoding.** Deine eigene Anti-Pattern-Regel #5 sagt es: "Basic code first, AI pipelines later." Und doch: Heute laeuft ein 5-Agent-Sprint mit Opus-Kopf und 3 Sonnet-Armen. Fuer GUIs, die noch kein Design-Doc haben. Die Werkzeuge sind faszinierend β€” aber sie sind nicht das Produkt.
17
+
18
+ ### Wo du den Fokus verlierst
19
+
20
+ An den **Uebergaengen**. Nicht mitten in der Arbeit β€” da bist du im Hyperfokus stark. Sondern in dem Moment, wo ein Projekt "genug laeuft" und das naechste lockt. Der Uebergang von ClaudeCodePanel (95%) zu Sidecar v2.0 zu Agent Teams zu GUI-Sprint β€” jeder Sprung fuehlt sich produktiv an. Keiner schliesst etwas ab.
21
+
22
+ ---
23
+
24
+ ## 2. ADHS-spezifische Empfehlungen
25
+
26
+ ### Hyperfokus nutzen statt bekaempfen
27
+
28
+ Dein Hyperfokus ist real und maechtig. Das ClaudeCodePanel (1321 Zeilen panel.py, 7 Module, Theme-System, Timer-Architektur) wurde in einer Welle gebaut. Der Sidecar-Daemon (1255 Zeilen, 22 Rules) ebenfalls. Das Problem ist nicht, dass du nicht fokussieren kannst β€” du kannst es zu gut. Aber nur auf Neues.
29
+
30
+ **Strategie: "Finish Sprints" statt "Build Sprints".**
31
+ - Reserviere Hyperfokus-Sessions explizit fuer *Abschluss*-Arbeit
32
+ - Trick: Definiere die letzten 5% als eigenstaendiges Mini-Projekt mit eigener Checkliste
33
+ - ShortcutCounter 85%β†’100% ist kein "weiterarbeiten" β€” es ist ein neues Projekt namens "ShortcutCounter Launch"
34
+ - Neues Framing = neues Dopamin
35
+
36
+ ### Dopamin-Management (Quick Wins als Momentum)
37
+
38
+ Dein Gehirn braucht schnelle Belohnungen. Das ist keine Schwaeche β€” es ist eine Designanforderung.
39
+
40
+ **Die 3-Minuten-Regel:** Jede Arbeitssession startet mit einer Aufgabe, die in unter 3 Minuten abschliessbar ist. Nicht "anfangen" β€” *abschliessen*. Ein Bug fixen. Einen Test schreiben. Eine Datei aufraeumen. Der erste Dopamin-Hit des Tages bestimmt den Rest.
41
+
42
+ **Sichtbare Fortschritts-Tracker:** Du hast bereits `sidecar-ctl status` und das ClaudeCodePanel. Nutze sie bewusst als Belohnungs-Feedback. Jedes gruene Haekchen ist Dopamin. Mach den Fortschritt sichtbar, nicht nur die offenen Punkte.
43
+
44
+ **"Done" feiern:** Dein CLAUDE.md sagt "Celebrate small wins". Tu das auch fuer dich selbst β€” nicht nur als Instruktion fuer Claude. Du hast heute ein funktionierendes Panel, einen Sidecar-Daemon mit 22 Rules, einen ShortcutCounter der laeuft, eine Voice Bridge, eine Analyse-Pipeline die ein 439-Zeilen-Portrait generiert hat. Das ist beeindruckend. Sag dir das.
45
+
46
+ ### Transition-Hilfen zwischen Projekten
47
+
48
+ Uebergaenge sind dein Feind. Jeder Kontextwechsel kostet ein ADHS-Gehirn ueberproportional viel.
49
+
50
+ **Die /feierabend-Regel durchsetzen:** Du hast den Command gebaut. Nutz ihn. Kein Projektwechsel ohne /recap des aktuellen Projekts. Das zwingt dein Gehirn, das aktuelle Projekt bewusst "abzulegen" statt es einfach fallenzulassen.
51
+
52
+ **Max 2 Projekte pro Tag:** Nicht 7. Zwei. Eins davon "Finish" (bestehend abschliessen), eins "Explore" (Neues). Mehr Kontextwechsel kosten mehr als sie bringen. Dein Sidecar hat 15-Sekunden-Cooldown fuer Warnungen β€” gib dir selbst einen 2-Stunden-Cooldown fuer Projektwechsel.
53
+
54
+ **Physische Marker:** Wechsel den Workspace in cosmic-term (oder oeffne ein neues kitty-Fenster) wenn du das Projekt wechselst. Visuelle Abgrenzung hilft dem Gehirn beim Kontextwechsel.
55
+
56
+ ---
57
+
58
+ ## 3. Tages-Struktur fuer die 7 Projekte
59
+
60
+ ### HEUTE fertig machen (realistisch, 2-3 Stunden)
61
+
62
+ 1. **ShortcutCounter 85% β†’ 100%** β€” Das ist am naechsten dran. Daemon laeuft, CLI laeuft, Storage laeuft. Was fehlt: systemd Service testen, config.toml an den richtigen XDG-Pfad, `pip install -e .` im Clean-Test. Maximal 90 Minuten.
63
+
64
+ 2. **ClaudeCodePanel: die 4 Suggestions abarbeiten** β€” `sorted()` vereinfachen, unbenutzten Parameter entfernen, Naming-Konvention, Theme-Farben. Jedes unter 10 Minuten. Zusammen 30-40 Minuten. Dann ist das Panel FERTIG-fertig.
65
+
66
+ ### Morgen (oder naechste fokussierte Session)
67
+
68
+ 3. **Sidecar Service-Pfade fixen** β€” Die .service Dateien zeigen noch auf den alten Pfad. Ein 5-Minuten-Fix, aber nicht heute β€” du hast genug Kontextwechsel.
69
+
70
+ 4. **GUI-Architektur Design-Doc** β€” Der Agent-Sprint laeuft bereits. Lass die Ergebnisse kommen, review morgen mit frischem Kopf.
71
+
72
+ ### Backlog (diese Woche nicht anfassen)
73
+
74
+ 5. **Sidecar v2.0 / Watcher** β€” Research done, Implementation pending. Bleibt pending. Der aktuelle Sidecar laeuft stabil.
75
+
76
+ 6. **Digitaler Zwilling** β€” Faszinierendes Konzept. Aber: kein Produkt. Backlog.
77
+
78
+ 7. **GUI-Sprint (Sidecar-GUI, Minimax-GUI, Florian-GUI)** β€” Warte auf das Design-Doc. Keine Implementation ohne Architektur.
79
+
80
+ ### Warum diese Reihenfolge?
81
+
82
+ ShortcutCounter und ClaudeCodePanel-Cleanup geben dir **zwei "Done"-Momente** heute. Das ist Dopamin-Kapital fuer morgen. Neue GUIs starten gibt dir null Done-Momente β€” nur mehr offene Baustellen.
83
+
84
+ ---
85
+
86
+ ## 4. Scope-Creep Warnsignale + Gegenmassnahmen
87
+
88
+ | Warnsignal | Was passiert | Gegenmassnahme |
89
+ |---|---|---|
90
+ | "Ich bau noch schnell..." | Scope waechst um 30% in 5 Minuten | CLAUDE.md-Regel: "Ich hab noch schnell X gemacht ist verboten". Gilt auch fuer DICH, nicht nur fuer Claude |
91
+ | "Dafuer brauch ich erstmal..." | Yak-Shaving: Tool X braucht Lib Y braucht Config Z | 2-Ebenen-Regel: Wenn die Abhaengigkeitskette laenger als 2 ist, STOP. Workaround suchen |
92
+ | Agent-Sprint fuer einen Einzeiler | 5-Agent-Overhead fuer Arbeit die solo schneller geht | Scaling-Leiter pruefen: Solo (80%) β†’ Paar (15%) β†’ Squad (4%). Die Zahlen stehen in deiner eigenen Architektur |
93
+ | "Das muesste man mal grundsaetzlich..." | Refactoring-Impuls statt Finishing | Frag: "Funktioniert es?" Ja β†’ nicht anfassen. Nein β†’ nur das Kaputte fixen |
94
+ | Dritte Session zum selben Thema ohne Ergebnis | Das Portrait-Pattern: Duplikat-Conversations, jede bei Null | /recap PFLICHT. Wenn du ein Thema zum dritten Mal oeffnest ohne vorherige Ergebnisse zu referenzieren, ist es ein Warnsignal |
95
+
96
+ ---
97
+
98
+ ## 5. Top-3 "Jetzt sofort" Aktionen (je unter 15 Minuten)
99
+
100
+ ### Aktion 1: ShortcutCounter β€” systemd Service pruefen (5 min)
101
+
102
+ ```bash
103
+ systemctl --user status shortcut-counter
104
+ # Laeuft? β†’ done. Laeuft nicht? β†’ Pfade in .service pruefen, starten.
105
+ ```
106
+
107
+ Ein gruenes "active (running)" ist dein erster Win heute.
108
+
109
+ ### Aktion 2: ClaudeCodePanel β€” den einfachsten Cleanup-Fix machen (10 min)
110
+
111
+ `log_viewer.py` β€” unbenutzten `sort_model` Parameter entfernen. Find, delete, test. Ein Commit. "Panel Cleanup 1/4 done." Momentum.
112
+
113
+ ### Aktion 3: Projekt-Status-Uebersicht schreiben (10 min)
114
+
115
+ Erstelle `~/Projekte/STATUS.md` mit genau diesem Format:
116
+
117
+ ```
118
+ | Projekt | Status | Naechster Schritt | Aufwand |
119
+ |---------|--------|-------------------|---------|
120
+ | ClaudeCodePanel | 95% | 4 Suggestions | 40 min |
121
+ | ShortcutCounter | 85% | systemd + clean test | 90 min |
122
+ | Sidecar | stabil | Service-Pfade | 5 min |
123
+ | ... | ... | ... | ... |
124
+ ```
125
+
126
+ Das ist dein externalisierter Arbeitsspeicher. Du brauchst das β€” dein Portrait sagt es, dein samuel-kontext.md sagt es, deine 232 Sessions sagen es. Eine Datei, die dir morgen frueh in 30 Sekunden sagt wo du stehst.
127
+
128
+ ---
129
+
130
+ ## Abschluss-Gedanke
131
+
132
+ Samuel, du hast in den letzten Wochen ein beeindruckendes Oekosystem gebaut. Panel, Sidecar, Voice Bridge, Agent Teams, Coaching Hooks, 22 Rules, 19 Skills β€” das ist echte Ingenieursarbeit. Die Architektur ist auf Industrieniveau. Das sagt nicht nur dein Portrait β€” das zeigen die Codebases.
133
+
134
+ Was jetzt zaehlt: **Zwei Projekte heute zu ENDE bringen.** Nicht anfangen. Nicht planen. Nicht orchestrieren. Zu Ende bringen.
135
+
136
+ "Mir mangelt es an Struktur, nicht an Intelligenz." β€” Stimmt. Und Struktur heisst heute: ShortcutCounter fertig, Panel-Cleanup fertig, Feierabend.
137
+
138
+ Laeuft.
COACHING_SAMUEL_2026-03-04.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Coaching-Analyse β€” 4. MΓ€rz 2026
2
+
3
+ ## Kern-Insights (aus Agent Team Sprint)
4
+
5
+ ### 5 Muster
6
+ 1. **"Architekt ohne Baustelle"** β€” Konzeptphase = Dopamin, Implementation = keins
7
+ 2. **"Duplikat-Sessions statt Iteration"** β€” 232 Sessions, wenig kumulative Ergebnisse
8
+ 3. **"Tooling als produktive Prokrastination"** β€” 22 Rules, 19 Anti-Patterns, 6 Gates > eigentliche Arbeit
9
+ 4. **"Druck als einziger Motor"** β€” Festivals funktionieren, Software nicht (keine Deadline)
10
+ 5. **"Execution outgesourct, VerstΓ€ndnis dΓΌnn"** β€” je besser Delegation, desto weniger eigenes Lernen
11
+
12
+ ### ADHS-informierte Empfehlungen
13
+ - **Dopamin-BrΓΌcke:** Sichtbares Feedback alle 25 Min (WINS.md nach jedem Commit)
14
+ - **Body Doubling:** Agent Team als virtueller Partner (30-Min Status-Checks)
15
+ - **KΓΌnstlicher Zeitdruck:** 90-Min-BlΓΆcke mit hartem Stop
16
+ - **Externe Verbindlichkeit:** Einen MENSCHEN einbinden, nicht KI
17
+ - **Angst benennen:** Die EINE Sache die du vermeidest = die Sache die das Projekt fertig macht
18
+
19
+ ### Tagesstruktur (ADHS-optimiert)
20
+ - 09:00-09:15 WINS.md lesen, 1 Ziel formulieren
21
+ - 09:15-10:45 Block 1 (90 Min Deep Work)
22
+ - 11:00-12:30 Block 2 (90 Min Deep Work)
23
+ - 13:30-14:00 Review + Commit
24
+ - Abends: Voice-Memo statt Session-Sprawl
25
+
26
+ ### Scope-Creep Warnsignale
27
+ - "Ich kΓΆnnte noch schnell..." β†’ STOP, in IDEAS.md
28
+ - Neues Konzept-Dokument statt Code β†’ Max 30 Zeilen Planung, dann Code
29
+ - "Ich optimiere erstmal den Workflow" β†’ 22 Rules reichen. STOP.
30
+ - Abends nach Joint β†’ Voice-Memo, morgens evaluieren
31
+
32
+ ### Ship or Kill
33
+ - **SHIP:** Sidecar v2.0, Subconsciousness MVP
34
+ - **MAINTAIN:** ClaudeCodePanel, Voicemode
35
+ - **KILL:** AgentSwarmDashboard, MyAIGame
36
+ - **PAUSE (4 Wochen):** ShortcutCounter
37
+
38
+ ### Der Satz
39
+ "Mir mangelt es an Γ„USSERER Struktur. Meine innere Struktur ist da β€” ich muss sie nur nach außen binden."
FOR_SMLFLG.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # StudyAgent β€” FOR_SMLFLG
2
+
3
+ ## Was ist das?
4
+ ADHS-freundlicher Study Buddy: Hilft Studenten mit ADHS durch ihr Semester.
5
+ Keine starren Plaene β€” Micro-Steps, Buddy-Ton, Dopamin beim Start statt beim Abschluss.
6
+
7
+ ## Warum
8
+ Samuel IS die Zielgruppe. 569 ChatGPT + 232 Claude Sessions dokumentieren das Problem.
9
+ Kern-Insight: Dopamin kommt beim Task-BEGINN, nicht beim Complete. Jeder Step = frischer Start.
10
+
11
+ ## Pain Points die das System loest
12
+ 1. Lernplaene die nie halten β€” elastischer Plan, kein Guilt
13
+ 2. WIP explodiert β€” max 2 Faecher gleichzeitig (hard enforcement in API)
14
+ 3. Zeitdruck als Feature β€” Panik-Modus bei <48h
15
+ 4. Routinedokumentation unmoeglich β€” Micro-Steps (je 5 Min)
16
+ 5. Session-Memory β€” nie wieder bei Null anfangen
17
+
18
+ ## Architektur & Tech-Stack
19
+
20
+ ```
21
+ StudyAgent/
22
+ β”œβ”€β”€ main.py # FastAPI app, alle Routes, htmx-Partials (~516 LOC)
23
+ β”œβ”€β”€ agent.py # MiniMax M2.5 API-Call, Fallback-Steps ohne Key
24
+ β”œβ”€β”€ models.py # SQLAlchemy Models (Subject, Deadline, MicroStep, StudySession)
25
+ β”œβ”€β”€ database.py # SQLite Setup, Session-Maker
26
+ β”œβ”€β”€ requirements.txt
27
+ β”œβ”€β”€ static/style.css # Warm palette, ADHS-friendly Design
28
+ β”œβ”€β”€ templates/
29
+ β”‚ β”œβ”€β”€ base.html
30
+ β”‚ β”œβ”€β”€ index.html
31
+ β”‚ └── partials/ # subject_card, deadline_item, micro_step_card,
32
+ β”‚ # elastic_plan, progress_journey, session_banner
33
+ └── study_agent.db # auto-created
34
+ ```
35
+
36
+ **Tech:** Python + FastAPI (async, lifespan) + SQLAlchemy + SQLite + htmx + Jinja2 + MiniMax M2.5 API
37
+
38
+ ## Starten / Nutzen
39
+
40
+ ```bash
41
+ cd ~/Projekte/StudyAgent
42
+
43
+ # Erste Einrichtung
44
+ pip install -r requirements.txt
45
+ export MINIMAX_API_KEY=your-key-here # optional β€” ohne Key laufen Fallback-Steps
46
+
47
+ # Starten
48
+ .venv/bin/uvicorn main:app --reload # oder: uvicorn main:app --reload
49
+ # open http://localhost:8000
50
+ ```
51
+
52
+ ## Was funktioniert (implementiert)
53
+
54
+ - **Dashboard** (`GET /`) β€” aktive Faecher, naechste Deadlines, naechster Step, Session-Banner, Panik-Indikatoren
55
+ - **Faecher CRUD** (`POST/DELETE /subjects`) β€” WIP-Limit 2 wird server-seitig enforced
56
+ - **Deadlines anlegen** (`POST /deadlines`) β€” auto-generiert sofort Micro-Steps via MiniMax M2.5
57
+ - **Steps abschliessen/ueberspringen** (`POST /steps/{id}/complete|skip`) β€” naechster Step wird inline geladen
58
+ - **Elastischer Plan** (`GET /deadlines/{id}/plan`) β€” auto-komprimiert wenn Steps die verbleibende Zeit uebersteigen
59
+ - **Replan** (`POST /deadlines/{id}/replan`) β€” loescht pending+skipped, generiert neu
60
+ - **Session-Tracking** β€” Sessions werden auto-gestartet, after 30 Min Inaktivitaet auto-geschlossen
61
+ - **Panik-Modus** (`is_panic_mode()`) β€” greift bei <48h, reduziert auf 3-4 wesentliche Steps
62
+ - **AI-Fallback** β€” ohne `MINIMAX_API_KEY` kommen sinnvolle Dummy-Steps (kein Absturz)
63
+
64
+ ## Was fehlt / bekannte Issues
65
+
66
+ - `.env` vorhanden (`MINIMAX_API_KEY`), in `.gitignore` geschuetzt. `.env.example` als Vorlage vorhanden.
67
+ - Browser-Test-Flow (Subject anlegen β†’ Dashboard β†’ Steps) noch nicht durchgespielt
68
+ - `fi_date`-aehnlicher Edge-Case: wenn `due_date` bereits vergangen ist, liefert `is_panic_mode()` `False` statt sinnvollen Zustand
69
+ - Kein Deployment-Setup (Fly.io geplant)
70
+ - Kein Auth (MVP-bewusste Entscheidung)
71
+
72
+ ## Entscheidungen & Lessons Learned
73
+
74
+ - **htmx statt React** β€” kein Build-Step, Partials direkt vom Server, funktioniert ohne JS-Framework
75
+ - **Buddy-Ton in Code** β€” "Kein Stress, machen wir anders." direkt in Responses, kein separates i18n
76
+ - **Dummy-Steps als Fallback** β€” API-Fehler oder fehlender Key darf nie das Feature brechen
77
+ - **WIP-Limit server-seitig** β€” nicht nur UI-Warnung, sondern echter `HTTPException`-Block im `POST /subjects`
78
+ - **Auto-compress elastisch** β€” 20% Buffer-Formel: wenn `pending * 5min > hours_left * 0.8 * 60` β†’ Replan
79
+
80
+ ## Next Steps (priorisiert)
81
+
82
+ 1. `MINIMAX_API_KEY` in `.env`-File konfigurieren und testen
83
+ 2. Browser-End-to-End-Test: Subject β†’ Deadline β†’ Steps β†’ Complete/Skip
84
+ 3. Edge-Case fixen: vergangene Deadlines richtig behandeln
85
+ 4. Sprint 3: Panik-Modus visuell sichtbar machen (Banner, roten Rahmen)
86
+ 5. Fly.io Deployment vorbereiten
PLAN.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADHS Study Agent β€” Projektplan
2
+
3
+ ## Kontext
4
+
5
+ Samuel will raus aus der Claude-Code-Optimierung und sein Orchestrierungs-Wissen auf ein echtes Problem anwenden: **Ein AI-Agent der Studenten mit ADHS durch ihr Semester navigiert.** Er IST die Zielgruppe β€” 569 ChatGPT-Conversations + 232 Claude-Sessions dokumentieren das Problem aus erster Hand.
6
+
7
+ **Ansatz:** Research First β€” aber Samuel selbst als User-Interview-Partner, nicht externe Recherche.
8
+
9
+ ---
10
+
11
+ ## Phase 1: User Research (Samuel als Zielgruppe)
12
+
13
+ Aus dem Portrait + Lernreise destillierte Pain Points:
14
+
15
+ 1. **"Lernplaene die nie halten"** β€” starre Plaene scheitern an ADHS. Der Plan muss sich anpassen, nicht der Mensch.
16
+ 2. **"10 von 150 Berichtsheften"** β€” Routinedokumentation ohne Feedback ist unmoeglich. Braucht Micro-Nudges.
17
+ 3. **"8 Stunden vor Pruefung"** β€” Zeitdruck ist der einzige funktionierende Motivator. Das System muss DAS nutzen, nicht bekaempfen.
18
+ 4. **"5 Uni-Bewerbungen = unueberwindbar"** β€” Formalia-Angst. Aufgaben muessen in Micro-Steps zerlegt werden.
19
+ 5. **Stress-Sweet-Spot Phase 2** β€” Es gibt ein schmales Fenster (eigene Stimme, produktiv). Davor zu formal, danach Panik.
20
+ 6. **Duplikat-Sessions** β€” Jede Session beginnt bei Null weil Gedaechtnis fuer eigene fruehere Gedanken unzuverlaessig ist.
21
+ 7. **Festivals im Pruefungsmonat** β€” Das System darf nicht moralisieren, sondern muss damit arbeiten.
22
+
23
+ **Interview-Ergebnis (5. Maerz 2026):**
24
+ Samuel waehlt ALLE 4 Pain Points + ergaenzt 2 kritische:
25
+ - **#5 Zu viele Dinge anfangen, nichts beenden** β€” Novelty Bias, WIP explodiert
26
+ - **#6 Dopamin kommt beim Task-BEGINN, nicht beim Complete** β€” DAS ist der Kern
27
+
28
+ β†’ Design-Konsequenz: Jeder Micro-Step muss sich wie ein NEUER Anfang anfuehlen (Dopamin beim Start), aber die Steps sind so klein dass Completion automatisch passiert. Nicht "mach die Aufgabe fertig" sondern "fang diesen 5-Min-Block an". WIP-Limit: max 2 aktive Faecher gleichzeitig.
29
+
30
+ **Tonalitaet:** Buddy (supportiv, kein Guilt-Tripping, ermutigend)
31
+
32
+ ---
33
+
34
+ ## Phase 2: Feature-Design (nach Interview)
35
+
36
+ ### Core Features (MVP)
37
+
38
+ **F1: Micro-Starts statt Mega-Tasks** (Pain Points #3, #6)
39
+ - Jede Aufgabe wird in 5-Min-Bloecke zerlegt
40
+ - Jeder Block ist ein NEUER Anfang mit eigenem Titel und Kontext
41
+ - "Oeffne Kapitel 3 und lies die erste Seite" β€” nicht "Lerne Kapitel 3"
42
+ - Dopamin-Trigger: jeder Block ist ein frischer Start (nicht "noch 47 uebrig")
43
+ - Visuell: Blocks als einzelne Karten, nicht als Checkliste
44
+
45
+ **F2: WIP-Limiter** (Pain Point #5)
46
+ - Max 2 Faecher gleichzeitig aktiv
47
+ - Wenn du ein drittes anfangen willst: Buddy sagt "Hey, du hast noch Mathe und OS offen. Willst du eins davon pausieren?"
48
+ - Kein Verbot β€” sanftes Bewusstmachen
49
+ - "Parking Lot" fuer Ideen die du spaeter anfangen willst
50
+
51
+ **F3: Elastischer Plan** (Pain Points #1, #2)
52
+ - Importiert Deadlines (manuell oder iCal)
53
+ - Plan passt sich an wenn du ihn ignorierst β€” kein Guilt, einfach neuer Plan
54
+ - Nutzt Zeitdruck als Feature: Plan wird automatisch komprimierter je naeher die Deadline
55
+ - "Verschieben" statt "Versagt" β€” der Buddy-Ton
56
+
57
+ **F4: Panik-Modus** (Pain Point #2)
58
+ - Aktiviert sich bei <48h vor Deadline
59
+ - Komprimiert auf das absolute Minimum
60
+ - Buddy wird zum Coach: "Du hast 12h. Hier sind die 5 wichtigsten Themen. Eins nach dem anderen."
61
+ - Kein Moralisieren, nur Klarheit
62
+
63
+ **F5: Session-Memory** (Pain Point #4)
64
+ - Merkt sich was du letzte Session gelernt/gemacht hast
65
+ - Zeigt beim Oeffnen: "Letzte Session: Du warst bei Kapitel 3, Seite 42. Weitermachen?"
66
+ - Kein "bei Null anfangen" mehr
67
+ - Fortschritt visuell als Karte/Map, nicht als Prozentzahl
68
+
69
+ ### Nice-to-have (v2)
70
+ - Stimmungs-Check-In (Phase 1-5 erkennen, Buddy passt Ton an)
71
+ - WhatsApp-Reminder (Nudges auf dem Handy)
72
+ - Lerngruppen-Matching (andere ADHS-Studenten)
73
+ - Pruefungsarchiv (was kam letztes Jahr dran)
74
+ - "Novelty Shuffle" β€” wenn ein Fach langweilig wird, schlaegt Buddy vor fuer 15 Min zu einem anderen zu wechseln (innerhalb WIP-Limit)
75
+
76
+ ---
77
+
78
+ ## Phase 3: Tech-Stack
79
+
80
+ **Empfehlung: Python + FastAPI + SQLite + htmx**
81
+
82
+ | Komponente | Wahl | Begruendung |
83
+ |---|---|---|
84
+ | Backend | FastAPI (Python) | Samuels staerkstes Setup, async, schnell |
85
+ | Datenbank | SQLite | Ueberwindet DB-Angst sanft β€” es ist nur eine Datei. Spaeter Postgres. |
86
+ | Frontend | htmx + Jinja2 Templates | Kein Framework lernen, trotzdem reaktive UI. Server-rendered. |
87
+ | AI Agent | Claude API (Haiku fuer Micro-Steps, Sonnet fuer Plaene) | Kostenoptimierung eingebaut β€” Samuels Staerke |
88
+ | Auth | Keine (MVP) β†’ spaeter Supabase | Nicht overengineeren am Start |
89
+ | Deployment | Fly.io oder Railway | Ein-Klick-Deploy, kostenlose Tier |
90
+
91
+ **Warum nicht Next.js/Svelte:** Samuel ist Python-First. Ein neues Frontend-Framework ist ein separates Lernprojekt und wuerde den Execution Gap vergroessern. htmx gibt 80% der Interaktivitaet fuer 10% der Komplexitaet.
92
+
93
+ ---
94
+
95
+ ## Phase 4: Implementierung
96
+
97
+ ### Sprint 1 (Woche 1-2): Skeleton
98
+ - [ ] Projekt-Setup: `~/Projekte/StudyAgent/`
99
+ - [ ] FastAPI + SQLite + htmx Grundgeruest
100
+ - [ ] Datenmodell: Semester, Faecher, Deadlines, Lerneinheiten
101
+ - [ ] Deadline-CRUD (manuell eintragen)
102
+ - [ ] FOR_SMLFLG.md anlegen
103
+
104
+ ### Sprint 2 (Woche 3-4): Agent-Logik
105
+ - [ ] Tagesplan-Generator (Claude Haiku)
106
+ - [ ] Micro-Step-Zerlegung
107
+ - [ ] Adaptiver Replan wenn Steps uebersprungen werden
108
+ - [ ] Einfache Session-Memory (letzte 3 Sessions)
109
+
110
+ ### Sprint 3 (Woche 5-6): Panik-Modus + Polish
111
+ - [ ] Panik-Modus bei <48h
112
+ - [ ] Visueller Fortschritt (Fortschrittsbalken, nicht Zahlen)
113
+ - [ ] Deployment auf Fly.io
114
+ - [ ] 1 Beta-Tester (Samuel selbst mit echten Pruefungen)
115
+
116
+ ---
117
+
118
+ ## Phase 5: Verifikation
119
+
120
+ - [ ] Laeuft lokal: `uvicorn main:app --reload`
121
+ - [ ] SQLite DB wird erstellt und befuellt
122
+ - [ ] Deadline eintragen β†’ Tagesplan generieren β†’ Steps sehen
123
+ - [ ] Plan ignorieren β†’ neuer Plan wird generiert (kein Guilt)
124
+ - [ ] Panik-Modus triggert bei Deadline < 48h
125
+ - [ ] Deployment funktioniert (oeffne URL im Browser)
126
+
127
+ ---
128
+
129
+ ## Deliverable
130
+
131
+ 1. [x] User-Interview mit Samuel β€” erledigt, 6 Pain Points + Buddy-Ton
132
+ 2. [ ] Plan permanent speichern: `~/Projekte/StudyAgent/PLAN.md`
133
+ 3. [ ] Visuelles HTML: Projekt-Overview als `~/.agent/diagrams/adhs-study-agent.html`
134
+ 4. [ ] Projekt-Setup starten (Sprint 1)
PLAN_LANGCHAIN_V2.md ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LangChain Lernpfad β€” StudyAgent V2 + Sidecar V7
2
+
3
+ ## Context
4
+
5
+ Samuel will LangChain lernen. Analyse ergab: Sidecar-Rewrite = falscher Ansatz (Event-Daemon β‰  Chatbot-Framework). ABER: Die agentische Komponente von Sidecar (2 Files: harness.py + minimax.py) passt perfekt zu LangChain. Zwei Projekte, ein Lernpfad:
6
+
7
+ 1. **StudyAgent V2** β€” LangChain von Null lernen (Tutorial-Stil, Schritt fuer Schritt)
8
+ 2. **Sidecar V7** β€” Gelerntes anwenden: agentischen Teil in LangChain umschreiben
9
+
10
+ ---
11
+
12
+ ## Teil 1: StudyAgent V2 β€” LangChain Live-Tutorial
13
+
14
+ ### Ist-Stand StudyAgent V1
15
+
16
+ ```
17
+ ~/Projekte/StudyAgent/ β€” 1.764 LOC, 17 Files
18
+ Tech: FastAPI + SQLite + htmx + Jinja2 + httpx
19
+ LLM: Claude Haiku (direkte API-Calls via httpx in agent.py)
20
+ Features: Micro-Steps, Panic Mode, Elastic Plan, WIP-Limit, Session Memory
21
+ Commit: 142dce7 (MVP, 5. Maerz)
22
+ Status: Laeuft, aber nie mit echten Daten getestet
23
+ ```
24
+
25
+ ### Lernziele (was Samuel nach V2 kann)
26
+
27
+ - [ ] LCEL Chains bauen (prompt | model | parser)
28
+ - [ ] Structured Output mit Pydantic-Parsern
29
+ - [ ] RAG Pipeline (PDF laden β†’ chunken β†’ embedden β†’ retrieven)
30
+ - [ ] LangGraph State Machines (Nodes, Edges, Conditional Routing)
31
+ - [ ] Conversational Memory (Window + Summary)
32
+ - [ ] Tool Use / Agents (LLM entscheidet welches Tool)
33
+ - [ ] LangSmith Tracing (Observability)
34
+ - [ ] Async LangChain in FastAPI (ainvoke, astream)
35
+
36
+ ### Schritt-fuer-Schritt Plan (7 Schritte, je ~90 Min)
37
+
38
+ #### Schritt 1: Erste Chain β€” agent.py ersetzen
39
+ **Lerninhalt:** ChatModel + PromptTemplate + OutputParser + LCEL
40
+ **Was passiert:**
41
+ - `pip install langchain-anthropic langchain-core python-dotenv`
42
+ - agent.py: httpx-Call β†’ LangChain Chain ersetzen
43
+ - Gleiche Funktionalitaet, aber jetzt LangChain-idiomatic
44
+
45
+ ```python
46
+ # VORHER (agent.py β€” raw httpx):
47
+ async with httpx.AsyncClient() as client:
48
+ response = await client.post("https://api.anthropic.com/v1/messages", ...)
49
+
50
+ # NACHHER (agent.py β€” LangChain):
51
+ from langchain_anthropic import ChatAnthropic
52
+ from langchain_core.prompts import ChatPromptTemplate
53
+ from langchain_core.output_parsers import JsonOutputParser
54
+
55
+ llm = ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0.7, max_tokens=1024)
56
+ prompt = ChatPromptTemplate.from_messages([("system", BUDDY_SYSTEM), ("user", "{task_context}")])
57
+ chain = prompt | llm | JsonOutputParser()
58
+
59
+ # Aufruf:
60
+ steps = await chain.ainvoke({"task_context": user_message})
61
+ ```
62
+
63
+ **Verifikation:** Server starten, Deadline erstellen, Micro-Steps erscheinen wie vorher.
64
+ **Dateien:** `agent.py` (rewrite), `requirements.txt` (+langchain-anthropic, +python-dotenv)
65
+
66
+ ---
67
+
68
+ #### Schritt 2: Structured Output β€” Pydantic statt JSON-Hoffen
69
+ **Lerninhalt:** PydanticOutputParser, Schema-Validierung, Auto-Retry
70
+ **Was passiert:**
71
+ - MicroStep-Schema als Pydantic Model definieren
72
+ - OutputParser validiert LLM-Antwort gegen Schema
73
+ - Fallback bei Parse-Error (retry mit Fehlermeldung)
74
+
75
+ ```python
76
+ from pydantic import BaseModel, Field
77
+ from langchain_core.output_parsers import PydanticOutputParser
78
+
79
+ class MicroStepOutput(BaseModel):
80
+ title: str = Field(description="Motivierender Titel, frischer Anfang")
81
+ context: str = Field(description="Was genau tun, wo anfangen")
82
+ duration_minutes: int = Field(default=5, description="Dauer in Minuten")
83
+
84
+ parser = PydanticOutputParser(pydantic_object=list[MicroStepOutput])
85
+ chain = prompt | llm | parser # Jetzt typsicher!
86
+ ```
87
+
88
+ **Verifikation:** Absichtlich kaputten Prompt senden β†’ Parser gibt saubere Fehlermeldung.
89
+ **Dateien:** `agent.py` (erweitern), `schemas.py` (neu, Pydantic Models)
90
+
91
+ ---
92
+
93
+ #### Schritt 3: Conversational Tutor β€” Memory + Chat
94
+ **Lerninhalt:** ConversationBufferWindowMemory, MessagesPlaceholder, Session-Management
95
+ **Was passiert:**
96
+ - Neuer `/chat` Endpoint: Student kann mit dem Buddy chatten
97
+ - Memory speichert letzte 5 Nachrichten pro Session
98
+ - Buddy kennt Kontext (Fach, aktuelle Steps, Fortschritt)
99
+
100
+ ```python
101
+ from langchain_core.prompts import MessagesPlaceholder
102
+ from langchain.memory import ConversationBufferWindowMemory
103
+
104
+ prompt = ChatPromptTemplate.from_messages([
105
+ ("system", TUTOR_SYSTEM),
106
+ MessagesPlaceholder(variable_name="chat_history"),
107
+ ("user", "{input}")
108
+ ])
109
+ memory = ConversationBufferWindowMemory(k=5, return_messages=True)
110
+ ```
111
+
112
+ **Verifikation:** Chat oeffnen, 3 Fragen stellen, Buddy erinnert sich an vorherige.
113
+ **Dateien:** `main.py` (+Chat-Routes), `templates/chat.html` (neu), `agent.py` (+Chat-Chain)
114
+
115
+ ---
116
+
117
+ #### Schritt 4: RAG β€” Vorlesungsskripte durchsuchbar machen
118
+ **Lerninhalt:** DocumentLoader, TextSplitter, Embeddings, ChromaDB, Retriever
119
+ **Was passiert:**
120
+ - PDF-Upload Endpoint (`/upload`)
121
+ - PDF β†’ Chunks β†’ Embeddings β†’ ChromaDB
122
+ - Chat-Chain bekommt relevante Chunks als Kontext
123
+ - Buddy antwortet NUR basierend auf Vorlesungsmaterial
124
+
125
+ ```python
126
+ from langchain_community.document_loaders import PyPDFLoader
127
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
128
+ from langchain_community.vectorstores import Chroma
129
+ from langchain_community.embeddings import OllamaEmbeddings
130
+
131
+ # Einmalig: Ollama lokal installieren + `ollama pull nomic-embed-text`
132
+ embeddings = OllamaEmbeddings(model="nomic-embed-text") # KOSTENLOS, lokal
133
+ vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
134
+ retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
135
+ ```
136
+
137
+ **Verifikation:** PDF hochladen, Frage stellen, Antwort zitiert aus dem Skript.
138
+ **Dateien:** `rag.py` (neu), `main.py` (+Upload-Route), `templates/upload.html` (neu)
139
+ **Dependencies:** `pip install pypdf chromadb langchain-community`
140
+ **Lokal:** `ollama pull nomic-embed-text` (Embeddings kostenlos, kein API-Key)
141
+
142
+ ---
143
+
144
+ #### Schritt 5: LangGraph β€” Lernflow als State Machine
145
+ **Lerninhalt:** StateGraph, Nodes, Edges, Conditional Routing, Zyklen
146
+ **Was passiert:**
147
+ - Quiz-Flow: Thema waehlen β†’ Fragen generieren β†’ Antwort bewerten β†’ Review/Weiter
148
+ - State Machine statt if/else Spaghetti
149
+ - Zyklen: bei falscher Antwort β†’ Erklaerung β†’ nochmal fragen
150
+
151
+ ```
152
+ START β†’ generate_quiz β†’ [warte auf Antwort]
153
+ β†’ evaluate_answer
154
+ β”œβ”€ richtig (>70%) β†’ next_topic β†’ END
155
+ └─ falsch (<70%) β†’ review_explanation β†’ generate_quiz (Zyklus!)
156
+ ```
157
+
158
+ ```python
159
+ from langgraph.graph import StateGraph, START, END
160
+
161
+ class QuizState(TypedDict):
162
+ topic: str
163
+ questions: list[dict]
164
+ score: int
165
+ feedback: str
166
+
167
+ workflow = StateGraph(QuizState)
168
+ workflow.add_node("generate_quiz", generate_quiz_node)
169
+ workflow.add_node("evaluate", evaluate_node)
170
+ workflow.add_node("review", review_node)
171
+ workflow.add_conditional_edges("evaluate", route_by_score,
172
+ {"pass": END, "fail": "review"})
173
+ workflow.add_edge("review", "generate_quiz") # Zyklus!
174
+ ```
175
+
176
+ **Verifikation:** Quiz starten, absichtlich falsch antworten β†’ Review β†’ neue Frage.
177
+ **Dateien:** `quiz_flow.py` (neu), `main.py` (+Quiz-Routes), `templates/quiz.html` (neu)
178
+ **Dependencies:** `pip install langgraph`
179
+
180
+ ---
181
+
182
+ #### Schritt 6: Tool Use β€” Buddy wird aktiv
183
+ **Lerninhalt:** @tool Decorator, Agent mit Tool-Auswahl, AgentExecutor
184
+ **Was passiert:**
185
+ - Buddy kann selbst Karteikarten erstellen (DB-Write)
186
+ - Buddy kann Lernfortschritt abfragen (DB-Read)
187
+ - Buddy kann im Vorlesungsskript suchen (RAG-Tool)
188
+ - LLM ENTSCHEIDET welches Tool es nutzt
189
+
190
+ ```python
191
+ from langchain_core.tools import tool
192
+
193
+ @tool
194
+ def create_flashcard(question: str, answer: str, subject: str) -> str:
195
+ """Erstellt eine Karteikarte fuer den Studenten."""
196
+ db.add(Flashcard(question=question, answer=answer, subject=subject))
197
+ return f"Karteikarte erstellt: {question}"
198
+
199
+ @tool
200
+ def search_notes(query: str) -> str:
201
+ """Durchsucht die hochgeladenen Vorlesungsskripte."""
202
+ docs = retriever.invoke(query)
203
+ return "\n".join(d.page_content[:200] for d in docs)
204
+
205
+ agent = create_react_agent(llm, [create_flashcard, search_notes, get_progress])
206
+ ```
207
+
208
+ **Verifikation:** "Erstell mir Karteikarten zu Kapitel 3" β†’ Buddy sucht im PDF, erstellt Karten.
209
+ **Dateien:** `tools.py` (neu), `agent.py` (Agent-Chain statt simple Chain)
210
+ **Dependencies:** `pip install langgraph` (create_react_agent lebt dort)
211
+
212
+ ---
213
+
214
+ #### Schritt 7: LangSmith β€” Observability + Kosten-Tracking
215
+ **Lerninhalt:** Tracing, Prompt-Iteration, Cost Monitoring, Eval
216
+ **Was passiert:**
217
+ - LangSmith Account erstellen (kostenloser Tier)
218
+ - Env-Vars setzen β†’ automatisches Tracing
219
+ - Jeden LLM-Call im Dashboard sehen (Prompts, Latenz, Kosten)
220
+ - Prompt-Vergleiche: welcher System-Prompt generiert bessere Steps?
221
+
222
+ ```bash
223
+ export LANGCHAIN_TRACING_V2="true"
224
+ export LANGCHAIN_API_KEY="ls__..."
225
+ export LANGCHAIN_PROJECT="StudyAgent-V2"
226
+ ```
227
+
228
+ **Verifikation:** 5 Interaktionen, dann LangSmith Dashboard β†’ alle Calls sichtbar.
229
+ **Dateien:** `.env` (Keys), `main.py` (lifespan: set env vars)
230
+
231
+ ---
232
+
233
+ ### Dependencies gesamt (nach Schritt 7)
234
+
235
+ ```txt
236
+ # Bestehend
237
+ fastapi, uvicorn[standard], sqlalchemy, jinja2, python-multipart, httpx
238
+
239
+ # Neu (LangChain)
240
+ langchain-anthropic # Claude Haiku Integration
241
+ langchain-core # Basis (Prompts, Parsers, Runnables)
242
+ langchain-community # ChromaDB, OllamaEmbeddings, PyPDFLoader
243
+ langgraph # State Machines + create_react_agent
244
+ chromadb # Vector Store (lokal, kein Server)
245
+ pypdf # PDF Loader
246
+ python-dotenv # .env Support
247
+
248
+ # Lokal (kein pip)
249
+ ollama # Lokale Embeddings (nomic-embed-text) β€” $0
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Teil 2: Sidecar V7 β€” Agentischer Teil in LangChain
255
+
256
+ ### Analyse: Was ist agentisch in V6?
257
+
258
+ ```
259
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
260
+ β”‚ Sidecar V6 β€” 7.684 LOC, 48 Python Files β”‚
261
+ β”‚ β”‚
262
+ β”‚ DETERMINISTIC (90%, bleibt): β”‚
263
+ β”‚ β”œβ”€ daemon.py β€” Socket Server, Event Routing β”‚
264
+ β”‚ β”œβ”€ coach.py β€” 36 Mechanismen (reine Regeln) οΏ½οΏ½
265
+ β”‚ β”œβ”€ mechanisms.py β€” Registry, Cooldowns, Priorities β”‚
266
+ β”‚ β”œβ”€ safety.py β€” PreToolUse Gates (Regex) β”‚
267
+ β”‚ β”œβ”€ context.py β€” Fast Path Context Builder β”‚
268
+ β”‚ β”œβ”€ knowledge.py β€” Keyword-RAG (15 Files) β”‚
269
+ β”‚ β”œβ”€ adaptation.py β€” Weighted Scoring (Mathe) β”‚
270
+ β”‚ β”œβ”€ learner.py β€” Spaced Repetition (Ebbinghaus) β”‚
271
+ β”‚ β”œβ”€ budget.py β€” Cost Tracking β”‚
272
+ β”‚ β”œβ”€ mood.py β€” Keyword-basierte Stimmung β”‚
273
+ β”‚ β”œβ”€ confidence.py β€” Scoring β”‚
274
+ β”‚ β”œβ”€ content_detectors.py β€” Text-Analyse β”‚
275
+ β”‚ β”œβ”€ outcomes.py β€” XP/Streaks β”‚
276
+ β”‚ β”œβ”€ phase_tracker.py β€” Phase Detection β”‚
277
+ β”‚ β”œβ”€ scorer.py β€” Importance Scoring β”‚
278
+ β”‚ β”œβ”€ adaptive_profile.py β€” Fear/Skill/Hours β”‚
279
+ β”‚ └─ twin_bridge.py β€” DB I/O β”‚
280
+ β”‚ β”‚
281
+ β”‚ AGENTIC (10%, wird LangChain): β”‚
282
+ β”‚ β”œβ”€ harness.py β€” Agent Loop + Tool Calling β”‚
283
+ β”‚ └─ minimax.py β€” LLM Wrapper + Prompt Builder β”‚
284
+ β”‚ β”‚
285
+ β”‚ TTS/STT (extern, bleibt): β”‚
286
+ β”‚ β”œβ”€ florian.py β€” Edge TTS (kein LLM) β”‚
287
+ β”‚ └─ stt.py β€” Whisper STT (kein LLM) β”‚
288
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
289
+ ```
290
+
291
+ ### Was aendert sich in V7
292
+
293
+ **NUR 2 Files werden ersetzt:**
294
+
295
+ | V6 File | V7 Ersatz | LangChain-Konzept |
296
+ |---------|-----------|-------------------|
297
+ | `harness.py` (Agent Loop) | `langchain_agent.py` | LangGraph StateGraph + @tool |
298
+ | `minimax.py` (LLM Wrapper) | `langchain_analyzer.py` | ChatModel + LCEL Chain |
299
+
300
+ **Alles andere bleibt identisch.** daemon.py ruft statt `minimax.analyze()` jetzt `langchain_analyzer.analyze()` β€” gleiche Signatur, gleiche Rueckgabe.
301
+
302
+ ### Migrations-Schritte (nach StudyAgent V2)
303
+
304
+ #### V7-Schritt 1: ChatMiniMax Wrapper
305
+ - MiniMax hat OpenAI-kompatible API β†’ `ChatOpenAI(openai_api_base=...)` oder Custom `BaseChatModel`
306
+ - Drop-in Replacement fuer den raw httpx-Call in harness.py
307
+
308
+ #### V7-Schritt 2: Slow Path als LCEL Chain
309
+ - System Prompt Builder bleibt Python (deterministic)
310
+ - LLM-Call wird `prompt | ChatMiniMax() | StrOutputParser()`
311
+ - `minimax.py` β†’ `langchain_analyzer.py`
312
+
313
+ #### V7-Schritt 3: Deep Judge als LangGraph Agent
314
+ - Die 4 Tools (read_file, list_dir, search_code, write_protocol) werden `@tool`
315
+ - Agent Loop wird `StateGraph` mit conditional edges
316
+ - Vorteil: LangSmith zeigt jeden Tool-Call + LLM-Reasoning
317
+
318
+ #### V7-Schritt 4: Optional β€” Knowledge Base als RAG-Tool
319
+ - Nur wenn Keyword-RAG nicht mehr reicht
320
+ - ChromaDB + lokale Embeddings (Ollama, $0)
321
+ - Als zusaetzliches Tool fuer den LangGraph Agent
322
+
323
+ #### V7-Schritt 5: LangSmith in systemd
324
+ - Env-Vars in `sidecar-v7.service` setzen
325
+ - Jeder Slow-Path-Call sichtbar im LangSmith Dashboard
326
+ - Budget-Tracking: LangSmith + eigenes budget.py parallel
327
+
328
+ ### Vorteile V7 gegenueber V6
329
+
330
+ | Dimension | V6 | V7 |
331
+ |-----------|----|----|
332
+ | Agent Loop | Custom (harness.py, 180 LOC) | LangGraph (standardisiert) |
333
+ | Tool Definition | Manuelles JSON-Schema | `@tool` Decorator (auto-Schema) |
334
+ | Provider Switch | Neuer httpx-Client schreiben | `ChatOpenAI` / `ChatAnthropic` swap |
335
+ | Observability | Eigenes Logging | LangSmith Tracing (gratis) |
336
+ | Debugging | print + Log-Files | LangSmith: jeden Schritt visuell |
337
+ | Community | Samuel allein | LangChain Ecosystem + Docs |
338
+ | CV-Wert | "Custom Python Daemon" | "LangChain + LangGraph in Production" |
339
+
340
+ ### Was NICHT aendert wird
341
+
342
+ - daemon.py (Socket Server) β€” bleibt asyncio
343
+ - coach.py (36 Mechanismen) β€” bleibt deterministisch
344
+ - safety.py (Gates) β€” bleibt Regex
345
+ - Fast Path β€” bleibt $0, <5ms
346
+ - Budget Tracking β€” bleibt eigenes System
347
+ - Florian TTS/STT β€” bleibt Edge TTS
348
+ - Twin Bridge β€” bleibt SQLite
349
+ - Adaptation/Learner β€” bleibt Mathe
350
+
351
+ ---
352
+
353
+ ## Reihenfolge
354
+
355
+ ```
356
+ Jetzt: StudyAgent V1 fertig testen (echte Daten!)
357
+ Dann: StudyAgent V2 Schritt 1-7 (LangChain lernen)
358
+ Danach: Sidecar V7 Schritt 1-5 (Gelerntes anwenden)
359
+
360
+ Zeitschaetzung:
361
+ StudyAgent V2: ~7 Sessions Γ  90 Min
362
+ Sidecar V7: ~5 Sessions Γ  90 Min
363
+ Gesamt: ~12 Sessions, ~18h
364
+ ```
365
+
366
+ ## Verifikation
367
+
368
+ ### StudyAgent V2
369
+ - Schritt 1: Server starten, Deadline erstellen β†’ Steps wie vorher
370
+ - Schritt 3: 5 Chat-Nachrichten, Buddy erinnert sich
371
+ - Schritt 4: PDF hochladen, Frage β†’ Antwort zitiert aus Skript
372
+ - Schritt 5: Quiz falsch beantworten β†’ Review-Loop
373
+ - Schritt 7: LangSmith Dashboard zeigt alle Calls
374
+
375
+ ### Sidecar V7
376
+ - Daemon starten, Hook-Events senden β†’ Fast Path unveraendert
377
+ - Slow Path: MiniMax-Call sichtbar in LangSmith
378
+ - Deep Judge: Tool-Calls (grep, read_file) in LangSmith nachvollziehbar
379
+ - Budget.py: Kosten stimmen weiterhin
380
+ - 490+ Tests: Alle gruen (Signatur-Kompatibilitaet)
PROJECT_HEALTH_REPORT.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Health Report β€” ~/Projekte/
2
+ **Datum:** 2026-03-06
3
+ **Methode:** 9 parallele Sonnet Explore-Agents, je 2 Projekte
4
+ **Kriterien:** Lauffaehigkeit, Code-Qualitaet, Security, Tests, Docs, Git-Hygiene, TODOs, Fertigstellungsgrad
5
+
6
+ ---
7
+
8
+ ## Portfolio-Uebersicht (Ranking nach Health Score)
9
+
10
+ | # | Projekt | Score | Status | LOC | Tests | Empfehlung |
11
+ |---|---------|-------|--------|-----|-------|------------|
12
+ | 1 | StudyAgent | 8/10 | MVP | 1.728 | 0 | Weitermachen |
13
+ | 2 | ClaudeCodePanel | 8/10 | Beta | 9.341 | 8 | **Ship it!** |
14
+ | 3 | ShortcutCounter | 8/10 | MVP | 977 | 12 | Shipped v0.1 |
15
+ | 4 | Sidecar-V5 | 8/10 | MVP | 1.144 | 0 | Weitermachen+Deploy |
16
+ | 5 | PromptGarage | 8/10 | MVP | 587 | 0 | Weitermachen |
17
+ | 6 | AgentIDE | 7.5/10 | MVP | 3.632 | 0 | Weitermachen |
18
+ | 7 | AgentArena | 7/10 | MVP | 3.533 | 0 | Weitermachen |
19
+ | 8 | FireTracker | 7/10 | MVP | 1.528 | 0 | Weitermachen |
20
+ | 9 | shared-gui | 7/10 | MVP | 203 | 0 | Weitermachen |
21
+ | 10 | MyAIGame | 6.5/10 | Alpha | 31.215 | 0 | Weitermachen |
22
+ | 11 | Sidecar | 6/10 | Beta | 55.460 | 0 | Cleanup+Tests |
23
+ | 12 | ProjectDashboard | 6/10 | MVP | 980 | 0 | Weitermachen |
24
+ | 13 | AgentSDK-HelloWorld | 6/10 | Alpha | 275 | 0 | **Parken (KEY LEAKED!)** |
25
+ | 14 | AgentSwarmDashboard | 6/10 | Alpha | 2.136 | 0 | Parken/Mergen |
26
+ | 15 | Subconsciousness | 4/10 | Alpha | 6.393 | 8 | Parken |
27
+ | 16 | Voicemode | 4/10 | Alpha | 219 | 0 | Parken |
28
+ | 17 | SearchAgent | 4/10 | Skeleton | 90 | 0 | Archivieren |
29
+ | 18 | BookmarkBrain | 2/10 | Skeleton | 997 | 0 | Parken |
30
+
31
+ ---
32
+
33
+ ## Gesamt-Statistiken
34
+
35
+ - **Total LOC:** ~119.448
36
+ - **Total Files:** ~300+
37
+ - **Projekte mit Tests:** 3 von 18 (17%)
38
+ - **Total Test Count:** 28 (nur ShortcutCounter:12, ClaudeCodePanel:8, Subconsciousness:8)
39
+ - **Projekte mit FOR_SMLFLG.md:** 5 von 18 (28%)
40
+ - **Projekte mit Git:** ~10 von 18 (56%)
41
+ - **Durchschnitt-Score:** 6.1/10
42
+ - **Median-Score:** 6.25/10
43
+
44
+ ---
45
+
46
+ ## ALARM: Sofort-Massnahmen
47
+
48
+ ### 1. API-Key Leak (AgentSDK-HelloWorld)
49
+ `.env` mit echtem API-Key (`sk-cp-...`) ist committed. **Key SOFORT invalidieren!**
50
+
51
+ ### 2. Path-Traversal (AgentSwarmDashboard)
52
+ `TEAMS_DIR / team_name` ohne Sanitization β€” kann `../../etc/passwd` traversieren.
53
+
54
+ ### 3. Subprocess Injection Risk (Voicemode)
55
+ `subprocess.run(cmd)` mit user-provided voice/text nicht escaped.
56
+
57
+ ---
58
+
59
+ ## Top 10 Quick Wins (ueber alle Projekte)
60
+
61
+ | # | Projekt | Quick Win | Aufwand | Impact |
62
+ |---|---------|-----------|---------|--------|
63
+ | 1 | AgentSDK-HelloWorld | API-Key revoken + .gitignore | 5 min | KRITISCH |
64
+ | 2 | FireTracker | 3 Tests fuer fire_math.py | 30 min | Hoch |
65
+ | 3 | StudyAgent | Timezone-Fix (utcnow β†’ now) | 5 min | Hoch |
66
+ | 4 | Sidecar-V5 | Git init + GitHub publish | 10 min | Hoch |
67
+ | 5 | PromptGarage | README.md erstellen | 15 min | Mittel |
68
+ | 6 | AgentIDE | Git init + FOR_SMLFLG.md | 30 min | Hoch |
69
+ | 7 | ClaudeCodePanel | Hardcoded Pfade β†’ config.ini | 60 min | Mittel |
70
+ | 8 | Sidecar | Git init + eval/ archivieren | 30 min | Hoch |
71
+ | 9 | AgentSwarmDashboard | Path-Traversal Fix | 10 min | KRITISCH |
72
+ | 10 | StudyAgent | Error logging statt silent pass | 10 min | Mittel |
73
+
74
+ ---
75
+
76
+ ## Empfehlung: Top 3 Projekte zuerst fertigmachen
77
+
78
+ ### 1. ClaudeCodePanel (8/10 β†’ 9/10)
79
+ - Bereits Beta, laeuft produktiv
80
+ - Fehlend: Tests fuer panel.py + monitor.py, Hardcoded Pfade
81
+ - **Aufwand bis Ship:** ~1 Tag
82
+
83
+ ### 2. StudyAgent (8/10 β†’ 9/10)
84
+ - ADHS-Psychologie ist echter Mehrwert, einzigartiges Projekt
85
+ - Fehlend: Tests, Timezone-Fix, Deployment auf Fly.io
86
+ - **Aufwand bis Ship:** ~1 Tag
87
+
88
+ ### 3. FireTracker (7/10 β†’ 8.5/10)
89
+ - Persoenlich nuetzlich (FIRE-Tracker)
90
+ - Fehlend: Tests fuer fire_math.py, FOR_SMLFLG.md, Input-Validierung
91
+ - **Aufwand bis Ship:** ~1-2 Tage
92
+
93
+ ---
94
+
95
+ ## Systemische Muster (Portfolio-weit)
96
+
97
+ ### Staerken
98
+ - **Architektur-Qualitaet ist hoch** β€” FastAPI+SQLite+htmx Stack ist konsistent
99
+ - **Dokumentation wo vorhanden ist gut** β€” FOR_SMLFLG.md Files sind detailliert
100
+ - **Sauberes Domain-Modelling** β€” Pydantic Models, klare Separation
101
+
102
+ ### Schwaechen
103
+ - **Tests fehlen fast ueberall** β€” 15 von 18 Projekten ohne Tests
104
+ - **Git-Hygiene mangelhaft** β€” ~8 Projekte ohne .git oder ohne Commits
105
+ - **Keine CI/CD** β€” kein einziges Projekt hat automatisierte Pipelines
106
+ - **Dependencies undokumentiert** β€” viele ohne requirements.txt/pyproject.toml
107
+
108
+ ---
109
+
110
+ ## Projekt-Kategorien
111
+
112
+ ### Ship-Ready (Score 8+)
113
+ ClaudeCodePanel, ShortcutCounter, StudyAgent, Sidecar-V5, PromptGarage
114
+
115
+ ### Weitermachen (Score 6-7.5)
116
+ AgentIDE, AgentArena, FireTracker, shared-gui, MyAIGame, Sidecar, ProjectDashboard
117
+
118
+ ### Parken (Score 4-6)
119
+ AgentSDK-HelloWorld, AgentSwarmDashboard, Subconsciousness, Voicemode
120
+
121
+ ### Archivieren (Score <4)
122
+ SearchAgent, BookmarkBrain
123
+
124
+ ---
125
+
126
+ ## Einzelberichte
127
+
128
+ Details zu jedem Projekt wurden von den Analyse-Agents generiert.
129
+ Vollstaendige Transkripte unter: /tmp/claude-1000/-home-smlflg/tasks/
README.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # StudyAgent
2
+
3
+ ADHS-friendly study buddy: helps students with ADHS get through their semester.
4
+ No rigid plans β€” micro-steps, buddy tone, dopamine at task START not completion.
5
+
6
+ ## Status
7
+
8
+ MVP shipped. Browser E2E test and past-deadline edge case pending.
9
+
10
+ ## Run
11
+
12
+ ```bash
13
+ cd ~/Projekte/StudyAgent
14
+ python3 -m venv .venv && source .venv/bin/activate
15
+ pip install -r requirements.txt
16
+
17
+ # Optional β€” without key, fallback steps run instead
18
+ cp .env.example .env
19
+ # edit .env: set MINIMAX_API_KEY
20
+
21
+ source .env # or use python-dotenv
22
+ uvicorn main:app --reload
23
+ # β†’ http://localhost:8000
24
+ ```
25
+
26
+ ## What works
27
+
28
+ - Dashboard β€” active subjects, next deadlines, next step, session banner, panic indicators
29
+ - Subjects CRUD β€” WIP limit of 2 enforced server-side
30
+ - Deadlines β€” auto-generates micro-steps via MiniMax M2.5 on creation
31
+ - Steps β€” complete/skip inline (htmx), next step loads automatically
32
+ - Elastic plan β€” auto-compresses when steps exceed remaining time
33
+ - Replan β€” deletes pending+skipped, generates fresh
34
+ - Session tracking β€” auto-start, auto-close after 30 min inactivity
35
+ - Panic mode β€” triggers at <48h, reduces to 3-4 essential steps
36
+ - AI fallback β€” works without MINIMAX_API_KEY (dummy steps)
37
+
38
+ ## Stack
39
+
40
+ FastAPI + SQLAlchemy + SQLite + htmx + Jinja2 + MiniMax M2.5 API
agent.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from datetime import datetime
4
+
5
+ import httpx
6
+
7
+ MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "")
8
+ MINIMAX_MODEL = "MiniMax-M2.5"
9
+ MINIMAX_ENDPOINT = "https://api.minimaxi.chat/v1/text/chatcompletion_v2"
10
+
11
+ SYSTEM_PROMPT = """Du bist ein ADHS-freundlicher Study Buddy. Deine Aufgabe: Zerlege Lernaufgaben in kleine, machbare 5-Minuten-Bloecke.
12
+
13
+ Regeln:
14
+ - Jeder Block hat einen eigenen Titel der sich wie ein FRISCHER ANFANG anfuehlt (z.B. "Oeffne die Seite und lies den ersten Absatz")
15
+ - NICHT: "Weiterlesen", "Aufgabe 3 von 10" β€” das toetet die Motivation
16
+ - Jeder Block hat einen kurzen Kontext-Satz: was genau tun, wo anfangen
17
+ - Buddy-Ton: supportiv, keine Vorwuerfe, kein Druck
18
+ - Im Panik-Modus (<48h): nur 3-5 Steps, nur das Absolut-Wichtigste, direkter Ton
19
+
20
+ Antworte NUR als JSON array (kein Markdown, kein Text drumrum):
21
+ [{"title": "...", "context": "...", "duration_minutes": 5}]"""
22
+
23
+
24
+ def _dummy_steps(title: str, panic: bool) -> list[dict]:
25
+ """Fallback steps when no API key is configured."""
26
+ if panic:
27
+ return [
28
+ {
29
+ "title": f"Oeffne deine Unterlagen zu '{title}'",
30
+ "context": "Lies nur die Ueberschriften und fett markierten Begriffe. Nicht mehr.",
31
+ "duration_minutes": 5,
32
+ },
33
+ {
34
+ "title": "Die wichtigsten 3 Punkte notieren",
35
+ "context": "Schreib auf ein leeres Blatt: Was muss ich auf jeden Fall wissen?",
36
+ "duration_minutes": 5,
37
+ },
38
+ {
39
+ "title": "Einen Durchgang β€” fertig",
40
+ "context": "Lies deine 3 Punkte laut vor. Das war's. Du bist bereit.",
41
+ "duration_minutes": 5,
42
+ },
43
+ ]
44
+ return [
45
+ {
46
+ "title": f"Starte mit '{title}' β€” nur die erste Seite",
47
+ "context": "Oeffne das Material und lies genau eine Seite. Kein Druck weiterzumachen.",
48
+ "duration_minutes": 5,
49
+ },
50
+ {
51
+ "title": "Notiere eine Frage die du hast",
52
+ "context": "Was ist noch unklar? Schreib es auf. Das hilft deinem Gehirn sich zu orientieren.",
53
+ "duration_minutes": 5,
54
+ },
55
+ {
56
+ "title": "Noch eine Seite β€” du bist schon drin",
57
+ "context": "Du hast den schwierigsten Teil hinter dir (das Anfangen). Lies weiter.",
58
+ "duration_minutes": 5,
59
+ },
60
+ {
61
+ "title": "Kurze Zusammenfassung in eigenen Worten",
62
+ "context": "Was hast du gerade gelesen? Ein Satz reicht. Kein perfekter Satz noetig.",
63
+ "duration_minutes": 5,
64
+ },
65
+ ]
66
+
67
+
68
+ async def generate_micro_steps(
69
+ deadline_title: str,
70
+ deadline_description: str,
71
+ due_date: datetime,
72
+ hours_until_due: float,
73
+ ) -> list[dict]:
74
+ panic = hours_until_due < 48
75
+
76
+ if not MINIMAX_API_KEY:
77
+ return _dummy_steps(deadline_title, panic)
78
+
79
+ if hours_until_due < 0:
80
+ mode_hint = "PANIK-MODUS: Deadline ist ueberschritten! Sofortmassnahmen. Maximal 4 Steps, nur das Absolut-Wichtigste."
81
+ elif panic:
82
+ mode_hint = f"PANIK-MODUS: Nur {int(hours_until_due)}h bis zur Deadline. Maximal 4 Steps, nur das Wichtigste."
83
+ else:
84
+ mode_hint = f"Normale Vorbereitung. Deadline in {int(hours_until_due)}h. Generiere 4-6 Steps."
85
+
86
+ user_message = (
87
+ f"Aufgabe: {deadline_title}\n"
88
+ f"Details: {deadline_description or 'keine weiteren Details'}\n"
89
+ f"Faellig: {due_date.strftime('%d.%m.%Y %H:%M')}\n"
90
+ f"{mode_hint}"
91
+ )
92
+
93
+ async with httpx.AsyncClient(timeout=60.0) as client:
94
+ response = await client.post(
95
+ MINIMAX_ENDPOINT,
96
+ headers={
97
+ "Authorization": f"Bearer {MINIMAX_API_KEY}",
98
+ "Content-Type": "application/json",
99
+ },
100
+ json={
101
+ "model": MINIMAX_MODEL,
102
+ "max_tokens": 1024,
103
+ "temperature": 0.3,
104
+ "messages": [
105
+ {"role": "system", "content": SYSTEM_PROMPT},
106
+ {"role": "user", "content": user_message},
107
+ ],
108
+ },
109
+ )
110
+ response.raise_for_status()
111
+ data = response.json()
112
+ msg = data["choices"][0]["message"]
113
+ raw = (msg.get("content") or "").strip()
114
+ if not raw:
115
+ # M2.5 reasoning fallback
116
+ reasoning = (msg.get("reasoning_content") or "").strip()
117
+ if reasoning:
118
+ raw = reasoning
119
+
120
+ # Strip markdown code fences if present
121
+ if raw.startswith("```"):
122
+ raw = raw.split("```")[1]
123
+ if raw.startswith("json"):
124
+ raw = raw[4:]
125
+ raw = raw.strip()
126
+
127
+ steps = json.loads(raw)
128
+ # Ensure required fields
129
+ for step in steps:
130
+ step.setdefault("duration_minutes", 5)
131
+ return steps
database.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.orm import declarative_base, sessionmaker
3
+
4
+ DATABASE_URL = "sqlite:///./study_agent.db"
5
+
6
+ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
7
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
8
+ Base = declarative_base()
9
+
10
+
11
+ def get_db():
12
+ db = SessionLocal()
13
+ try:
14
+ yield db
15
+ finally:
16
+ db.close()
17
+
18
+
19
+ def create_all():
20
+ from models import Subject, Deadline, MicroStep, StudySession # noqa: F401
21
+ Base.metadata.create_all(bind=engine)
main.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv() # Before agent import so MINIMAX_API_KEY is available
7
+
8
+ from fastapi import Depends, FastAPI, Form, Request # noqa: E402
9
+ from fastapi.responses import HTMLResponse # noqa: E402
10
+ from fastapi.staticfiles import StaticFiles # noqa: E402
11
+ from fastapi.templating import Jinja2Templates # noqa: E402
12
+ from sqlalchemy.orm import Session # noqa: E402
13
+
14
+ from agent import generate_micro_steps # noqa: E402
15
+ from database import create_all, get_db # noqa: E402
16
+ from models import Deadline, MicroStep, MicroStepStatus, StudySession, Subject # noqa: E402
17
+
18
+ SESSION_INACTIVITY_MINUTES = 30
19
+
20
+
21
+ def _utcnow() -> datetime:
22
+ """Naive UTC datetime β€” matches SQLite's naive storage."""
23
+ return datetime.now(timezone.utc).replace(tzinfo=None)
24
+
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(app: FastAPI):
28
+ create_all()
29
+ yield
30
+
31
+
32
+ app = FastAPI(lifespan=lifespan)
33
+ app.mount("/static", StaticFiles(directory="static"), name="static")
34
+ templates = Jinja2Templates(directory="templates")
35
+
36
+
37
+ def _get_current_session(db: Session):
38
+ """Returns the most recent open session if within inactivity window."""
39
+ cutoff = _utcnow() - timedelta(minutes=SESSION_INACTIVITY_MINUTES)
40
+ return (
41
+ db.query(StudySession)
42
+ .filter(StudySession.ended_at == None, StudySession.started_at >= cutoff) # noqa: E711
43
+ .order_by(StudySession.started_at.desc())
44
+ .first()
45
+ )
46
+
47
+
48
+ def _get_last_ended_session(db: Session):
49
+ """Returns the most recent completed session for the welcome-back banner."""
50
+ return (
51
+ db.query(StudySession)
52
+ .filter(StudySession.ended_at != None) # noqa: E711
53
+ .order_by(StudySession.ended_at.desc())
54
+ .first()
55
+ )
56
+
57
+
58
+ def _active_subjects(db: Session):
59
+ return db.query(Subject).filter(Subject.is_active == True, Subject.is_parked == False).all() # noqa: E712
60
+
61
+
62
+ def _upcoming_deadlines(db: Session, limit: int = 5):
63
+ # Include overdue deadlines from last 7 days so they show as "Ueberfaellig"
64
+ cutoff = _utcnow() - timedelta(days=7)
65
+ return (
66
+ db.query(Deadline)
67
+ .join(Subject)
68
+ .filter(Subject.is_parked == False, Deadline.due_date >= cutoff) # noqa: E712
69
+ .order_by(Deadline.due_date)
70
+ .limit(limit)
71
+ .all()
72
+ )
73
+
74
+
75
+ def _next_micro_step(db: Session):
76
+ return (
77
+ db.query(MicroStep)
78
+ .filter(MicroStep.status == MicroStepStatus.pending)
79
+ .join(Deadline)
80
+ .join(Subject)
81
+ .filter(Subject.is_parked == False) # noqa: E712
82
+ .order_by(Deadline.due_date, MicroStep.order_num)
83
+ .first()
84
+ )
85
+
86
+
87
+ def _expire_stale_sessions(db: Session):
88
+ """Auto-close sessions older than the inactivity window."""
89
+ cutoff = _utcnow() - timedelta(minutes=SESSION_INACTIVITY_MINUTES)
90
+ stale = (
91
+ db.query(StudySession)
92
+ .filter(StudySession.ended_at == None, StudySession.started_at < cutoff) # noqa: E711
93
+ .all()
94
+ )
95
+ for s in stale:
96
+ s.ended_at = _utcnow()
97
+ if stale:
98
+ db.commit()
99
+
100
+
101
+ @app.get("/", response_class=HTMLResponse)
102
+ async def dashboard(request: Request, db: Session = Depends(get_db)):
103
+ _expire_stale_sessions(db)
104
+
105
+ # Auto-start a session if none is open; attach to the first active subject
106
+ open_session = _get_current_session(db)
107
+ if not open_session:
108
+ active_list = _active_subjects(db)
109
+ if active_list:
110
+ open_session = StudySession(subject_id=active_list[0].id)
111
+ db.add(open_session)
112
+ db.commit()
113
+
114
+ active_subjects = _active_subjects(db)
115
+ upcoming_deadlines = _upcoming_deadlines(db)
116
+ next_step = _next_micro_step(db)
117
+ last_session = _get_last_ended_session(db)
118
+
119
+ # Build per-deadline progress stats for dashboard mini-bars
120
+ deadline_stats = {}
121
+ for dl in upcoming_deadlines:
122
+ all_steps = db.query(MicroStep).filter(MicroStep.deadline_id == dl.id).all()
123
+ total = len(all_steps)
124
+ done = sum(1 for s in all_steps if s.status == MicroStepStatus.completed)
125
+ deadline_stats[dl.id] = {
126
+ "total": total,
127
+ "completed": done,
128
+ "pct": int(done / total * 100) if total else 0,
129
+ "panic": is_panic_mode(dl),
130
+ "overdue": is_overdue(dl),
131
+ }
132
+
133
+ return templates.TemplateResponse(
134
+ "index.html",
135
+ {
136
+ "request": request,
137
+ "active_subjects": active_subjects,
138
+ "upcoming_deadlines": upcoming_deadlines,
139
+ "next_step": next_step,
140
+ "last_session": last_session,
141
+ "deadline_stats": deadline_stats,
142
+ "now": _utcnow(),
143
+ },
144
+ )
145
+
146
+
147
+ @app.post("/subjects", response_class=HTMLResponse)
148
+ async def create_subject(
149
+ request: Request,
150
+ name: str = Form(...),
151
+ semester: str = Form(""),
152
+ db: Session = Depends(get_db),
153
+ ):
154
+ active_count = len(_active_subjects(db))
155
+ if active_count >= 2:
156
+ return HTMLResponse(
157
+ '<div class="buddy-warning" id="wip-warning">'
158
+ "Hey, du hast schon 2 aktive Faecher. Parkiere erst eins, bevor du ein neues startest."
159
+ "</div>"
160
+ )
161
+ subject = Subject(name=name, semester=semester)
162
+ db.add(subject)
163
+ db.commit()
164
+ db.refresh(subject)
165
+ return templates.TemplateResponse(
166
+ "partials/subject_card.html",
167
+ {"request": request, "subject": subject},
168
+ )
169
+
170
+
171
+ @app.post("/deadlines", response_class=HTMLResponse)
172
+ async def create_deadline(
173
+ request: Request,
174
+ subject_id: int = Form(...),
175
+ title: str = Form(...),
176
+ description: str = Form(""),
177
+ due_date: str = Form(...),
178
+ db: Session = Depends(get_db),
179
+ ):
180
+ due = datetime.fromisoformat(due_date)
181
+ deadline = Deadline(
182
+ subject_id=subject_id,
183
+ title=title,
184
+ description=description,
185
+ due_date=due,
186
+ )
187
+ db.add(deadline)
188
+ db.commit()
189
+ db.refresh(deadline)
190
+
191
+ # Auto-generate micro-steps
192
+ hours_until_due = (due - _utcnow()).total_seconds() / 3600
193
+ try:
194
+ steps_data = await generate_micro_steps(title, description, due, hours_until_due)
195
+ for i, s in enumerate(steps_data):
196
+ step = MicroStep(
197
+ deadline_id=deadline.id,
198
+ title=s["title"],
199
+ context=s.get("context", ""),
200
+ order_num=i,
201
+ duration_minutes=s.get("duration_minutes", 5),
202
+ )
203
+ db.add(step)
204
+ db.commit()
205
+ except Exception:
206
+ pass # Steps are a nice-to-have; deadline creation should never fail because of this
207
+
208
+ return templates.TemplateResponse(
209
+ "partials/deadline_item.html",
210
+ {"request": request, "deadline": deadline, "now": _utcnow()},
211
+ )
212
+
213
+
214
+ @app.post("/deadlines/{deadline_id}/generate-steps", response_class=HTMLResponse)
215
+ async def generate_steps_for_deadline(
216
+ deadline_id: int,
217
+ request: Request,
218
+ db: Session = Depends(get_db),
219
+ ):
220
+ deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first()
221
+ if not deadline:
222
+ return HTMLResponse('<p class="buddy-warning">Deadline nicht gefunden.</p>')
223
+
224
+ hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600
225
+ steps_data = await generate_micro_steps(
226
+ deadline.title,
227
+ deadline.description or "",
228
+ deadline.due_date,
229
+ hours_until_due,
230
+ )
231
+
232
+ # Delete old pending steps for this deadline and recreate
233
+ db.query(MicroStep).filter(
234
+ MicroStep.deadline_id == deadline_id,
235
+ MicroStep.status == MicroStepStatus.pending,
236
+ ).delete()
237
+ db.commit()
238
+
239
+ for i, s in enumerate(steps_data):
240
+ step = MicroStep(
241
+ deadline_id=deadline_id,
242
+ title=s["title"],
243
+ context=s.get("context", ""),
244
+ order_num=i,
245
+ duration_minutes=s.get("duration_minutes", 5),
246
+ )
247
+ db.add(step)
248
+ db.commit()
249
+
250
+ # Return all new step cards
251
+ new_steps = (
252
+ db.query(MicroStep)
253
+ .filter(MicroStep.deadline_id == deadline_id, MicroStep.status == MicroStepStatus.pending)
254
+ .order_by(MicroStep.order_num)
255
+ .all()
256
+ )
257
+ html_parts = []
258
+ for step in new_steps:
259
+ rendered = templates.TemplateResponse(
260
+ "partials/micro_step_card.html",
261
+ {"request": request, "step": step},
262
+ )
263
+ html_parts.append(rendered.body.decode())
264
+ return HTMLResponse("".join(html_parts))
265
+
266
+
267
+ @app.delete("/subjects/{subject_id}", response_class=HTMLResponse)
268
+ async def park_subject(subject_id: int, db: Session = Depends(get_db)):
269
+ subject = db.query(Subject).filter(Subject.id == subject_id).first()
270
+ if subject:
271
+ subject.is_parked = True
272
+ subject.is_active = False
273
+ db.commit()
274
+ return HTMLResponse("")
275
+
276
+
277
+ @app.post("/steps/{step_id}/complete", response_class=HTMLResponse)
278
+ async def complete_step(step_id: int, request: Request, db: Session = Depends(get_db)):
279
+ step = db.query(MicroStep).filter(MicroStep.id == step_id).first()
280
+ if step:
281
+ step.status = MicroStepStatus.completed
282
+ db.commit()
283
+ open_session = _get_current_session(db)
284
+ if open_session:
285
+ open_session.steps_completed += 1
286
+ db.commit()
287
+ # Return next pending step
288
+ next_step = _next_micro_step(db)
289
+ if next_step:
290
+ return templates.TemplateResponse(
291
+ "partials/micro_step_card.html",
292
+ {"request": request, "step": next_step},
293
+ )
294
+ return HTMLResponse('<p class="buddy-message">Super! Keine weiteren Steps gerade. Goennung verdient.</p>')
295
+
296
+
297
+ @app.post("/steps/{step_id}/skip", response_class=HTMLResponse)
298
+ async def skip_step(step_id: int, request: Request, db: Session = Depends(get_db)):
299
+ step = db.query(MicroStep).filter(MicroStep.id == step_id).first()
300
+ if step:
301
+ step.status = MicroStepStatus.skipped
302
+ db.commit()
303
+ next_step = _next_micro_step(db)
304
+ if next_step:
305
+ buddy_msg = '<p class="buddy-skip-msg">Kein Stress, machen wir anders.</p>'
306
+ card = templates.TemplateResponse(
307
+ "partials/micro_step_card.html",
308
+ {"request": request, "step": next_step},
309
+ )
310
+ return HTMLResponse(buddy_msg + card.body.decode())
311
+ return HTMLResponse('<p class="buddy-message">Alles verschoben fuer jetzt β€” kein Stress.</p>')
312
+
313
+
314
+ @app.get("/steps/next", response_class=HTMLResponse)
315
+ async def next_step_partial(request: Request, db: Session = Depends(get_db)):
316
+ step = _next_micro_step(db)
317
+ if step:
318
+ return templates.TemplateResponse(
319
+ "partials/micro_step_card.html",
320
+ {"request": request, "step": step},
321
+ )
322
+ return HTMLResponse('<p class="buddy-message">Keine offenen Steps. Gut gemacht.</p>')
323
+
324
+
325
+ # ── session endpoints ──────────────────────────────────────────────────────
326
+
327
+ @app.get("/session/current", response_class=HTMLResponse)
328
+ async def session_current(request: Request, db: Session = Depends(get_db)):
329
+ _expire_stale_sessions(db)
330
+ last_session = _get_last_ended_session(db)
331
+ next_step = _next_micro_step(db)
332
+ return templates.TemplateResponse(
333
+ "partials/session_banner.html",
334
+ {"request": request, "last_session": last_session, "next_step": next_step},
335
+ )
336
+
337
+
338
+ @app.get("/session/history", response_class=HTMLResponse)
339
+ async def session_history(request: Request, db: Session = Depends(get_db)):
340
+ sessions = (
341
+ db.query(StudySession)
342
+ .filter(StudySession.ended_at != None) # noqa: E711
343
+ .order_by(StudySession.ended_at.desc())
344
+ .limit(5)
345
+ .all()
346
+ )
347
+ return templates.TemplateResponse(
348
+ "partials/progress_journey.html",
349
+ {"request": request, "sessions": sessions},
350
+ )
351
+
352
+
353
+ @app.post("/session/start", response_class=HTMLResponse)
354
+ async def session_start(
355
+ request: Request,
356
+ subject_id: int = Form(...),
357
+ db: Session = Depends(get_db),
358
+ ):
359
+ _expire_stale_sessions(db)
360
+ existing = _get_current_session(db)
361
+ if existing and existing.subject_id == subject_id:
362
+ return HTMLResponse("") # already open
363
+ session = StudySession(subject_id=subject_id)
364
+ db.add(session)
365
+ db.commit()
366
+ return HTMLResponse("")
367
+
368
+
369
+ @app.post("/session/end", response_class=HTMLResponse)
370
+ async def session_end(db: Session = Depends(get_db)):
371
+ open_session = _get_current_session(db)
372
+ if open_session:
373
+ open_session.ended_at = _utcnow()
374
+ db.commit()
375
+ return HTMLResponse("")
376
+
377
+
378
+ # ── Elastic Plan + Panic Mode ───────────────────────────────────────────────
379
+
380
+ def is_panic_mode(deadline: Deadline) -> bool:
381
+ """True when fewer than 48 hours remain or deadline is overdue."""
382
+ return (deadline.due_date - _utcnow()).total_seconds() < 48 * 3600
383
+
384
+
385
+ def is_overdue(deadline: Deadline) -> bool:
386
+ """True when the deadline is in the past."""
387
+ return deadline.due_date < _utcnow()
388
+
389
+
390
+ def _deadline_steps(db: Session, deadline_id: int):
391
+ """All non-skipped steps ordered by position."""
392
+ return (
393
+ db.query(MicroStep)
394
+ .filter(
395
+ MicroStep.deadline_id == deadline_id,
396
+ MicroStep.status != MicroStepStatus.skipped,
397
+ )
398
+ .order_by(MicroStep.order_num)
399
+ .all()
400
+ )
401
+
402
+
403
+ def _should_auto_compress(deadline: Deadline, pending_count: int) -> bool:
404
+ """True when pending steps take longer than remaining time (with 20% buffer).
405
+ Overdue deadlines with pending steps always trigger a replan.
406
+ """
407
+ hours_left = (deadline.due_date - _utcnow()).total_seconds() / 3600
408
+ if hours_left <= 0:
409
+ return pending_count > 0 # overdue with pending steps β†’ always compress
410
+ return pending_count * 5 > hours_left * 60 * 0.8
411
+
412
+
413
+ @app.get("/deadlines/{deadline_id}/plan", response_class=HTMLResponse)
414
+ async def deadline_plan(deadline_id: int, request: Request, db: Session = Depends(get_db)):
415
+ deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first()
416
+ if not deadline:
417
+ return HTMLResponse('<p class="buddy-warning">Deadline nicht gefunden.</p>')
418
+
419
+ steps = _deadline_steps(db, deadline_id)
420
+ pending = [s for s in steps if s.status == MicroStepStatus.pending]
421
+ buddy_message = None
422
+
423
+ # Auto-compress when pending steps exceed available time
424
+ if pending and _should_auto_compress(deadline, len(pending)):
425
+ hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600
426
+ try:
427
+ new_steps_data = await generate_micro_steps(
428
+ deadline.title,
429
+ deadline.description or "",
430
+ deadline.due_date,
431
+ hours_until_due,
432
+ )
433
+ db.query(MicroStep).filter(
434
+ MicroStep.deadline_id == deadline_id,
435
+ MicroStep.status == MicroStepStatus.pending,
436
+ ).delete(synchronize_session=False)
437
+ db.commit()
438
+ completed_count = db.query(MicroStep).filter(
439
+ MicroStep.deadline_id == deadline_id,
440
+ MicroStep.status == MicroStepStatus.completed,
441
+ ).count()
442
+ for i, s in enumerate(new_steps_data):
443
+ db.add(MicroStep(
444
+ deadline_id=deadline_id,
445
+ title=s["title"],
446
+ context=s.get("context", ""),
447
+ order_num=completed_count + i,
448
+ duration_minutes=s.get("duration_minutes", 5),
449
+ ))
450
+ db.commit()
451
+ steps = _deadline_steps(db, deadline_id)
452
+ buddy_message = "Ich hab den Plan etwas gestrafft β€” passt besser zur verbleibenden Zeit."
453
+ except Exception:
454
+ pass
455
+
456
+ return templates.TemplateResponse(
457
+ "partials/elastic_plan.html",
458
+ {
459
+ "request": request,
460
+ "deadline": deadline,
461
+ "steps": steps,
462
+ "buddy_message": buddy_message,
463
+ "now": _utcnow(),
464
+ },
465
+ )
466
+
467
+
468
+ @app.post("/deadlines/{deadline_id}/replan", response_class=HTMLResponse)
469
+ async def replan_deadline(deadline_id: int, request: Request, db: Session = Depends(get_db)):
470
+ deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first()
471
+ if not deadline:
472
+ return HTMLResponse('<p class="buddy-warning">Deadline nicht gefunden.</p>')
473
+
474
+ hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600
475
+
476
+ # Remove skipped + pending; keep completed
477
+ db.query(MicroStep).filter(
478
+ MicroStep.deadline_id == deadline_id,
479
+ MicroStep.status.in_([MicroStepStatus.pending, MicroStepStatus.skipped]),
480
+ ).delete(synchronize_session=False)
481
+ db.commit()
482
+
483
+ new_steps_data = await generate_micro_steps(
484
+ deadline.title,
485
+ deadline.description or "",
486
+ deadline.due_date,
487
+ hours_until_due,
488
+ )
489
+
490
+ completed_count = db.query(MicroStep).filter(
491
+ MicroStep.deadline_id == deadline_id,
492
+ MicroStep.status == MicroStepStatus.completed,
493
+ ).count()
494
+
495
+ for i, s in enumerate(new_steps_data):
496
+ db.add(MicroStep(
497
+ deadline_id=deadline_id,
498
+ title=s["title"],
499
+ context=s.get("context", ""),
500
+ order_num=completed_count + i,
501
+ duration_minutes=s.get("duration_minutes", 5),
502
+ ))
503
+ db.commit()
504
+
505
+ steps = _deadline_steps(db, deadline_id)
506
+ return templates.TemplateResponse(
507
+ "partials/elastic_plan.html",
508
+ {
509
+ "request": request,
510
+ "deadline": deadline,
511
+ "steps": steps,
512
+ "buddy_message": "Plan angepasst! Hier ist dein neuer Fahrplan.",
513
+ "now": _utcnow(),
514
+ },
515
+ )
models.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import enum
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text
5
+ from sqlalchemy.orm import relationship
6
+
7
+ from database import Base
8
+
9
+
10
+ class MicroStepStatus(enum.Enum):
11
+ pending = "pending"
12
+ in_progress = "in_progress"
13
+ completed = "completed"
14
+ skipped = "skipped"
15
+
16
+
17
+ class Subject(Base):
18
+ __tablename__ = "subjects"
19
+
20
+ id = Column(Integer, primary_key=True, index=True)
21
+ name = Column(String(200), nullable=False)
22
+ semester = Column(String(50))
23
+ is_active = Column(Boolean, default=True)
24
+ is_parked = Column(Boolean, default=False)
25
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
26
+
27
+ deadlines = relationship("Deadline", back_populates="subject", cascade="all, delete-orphan")
28
+ study_sessions = relationship("StudySession", back_populates="subject", cascade="all, delete-orphan")
29
+
30
+
31
+ class Deadline(Base):
32
+ __tablename__ = "deadlines"
33
+
34
+ id = Column(Integer, primary_key=True, index=True)
35
+ subject_id = Column(Integer, ForeignKey("subjects.id"), nullable=False)
36
+ title = Column(String(200), nullable=False)
37
+ description = Column(Text)
38
+ due_date = Column(DateTime, nullable=False)
39
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
40
+
41
+ subject = relationship("Subject", back_populates="deadlines")
42
+ micro_steps = relationship("MicroStep", back_populates="deadline", cascade="all, delete-orphan")
43
+
44
+
45
+ class MicroStep(Base):
46
+ __tablename__ = "micro_steps"
47
+
48
+ id = Column(Integer, primary_key=True, index=True)
49
+ deadline_id = Column(Integer, ForeignKey("deadlines.id"), nullable=False)
50
+ title = Column(String(300), nullable=False)
51
+ context = Column(Text) # what exactly to do β€” fresh-start framing
52
+ order_num = Column(Integer, default=0)
53
+ duration_minutes = Column(Integer, default=5)
54
+ status = Column(Enum(MicroStepStatus), default=MicroStepStatus.pending)
55
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
56
+
57
+ deadline = relationship("Deadline", back_populates="micro_steps")
58
+
59
+
60
+ class StudySession(Base):
61
+ __tablename__ = "study_sessions"
62
+
63
+ id = Column(Integer, primary_key=True, index=True)
64
+ subject_id = Column(Integer, ForeignKey("subjects.id"), nullable=False)
65
+ started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
66
+ ended_at = Column(DateTime)
67
+ steps_completed = Column(Integer, default=0)
68
+ notes = Column(Text)
69
+
70
+ subject = relationship("Subject", back_populates="study_sessions")
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.135.1
2
+ uvicorn[standard]==0.41.0
3
+ sqlalchemy==2.0.48
4
+ jinja2==3.1.6
5
+ python-multipart==0.0.22
6
+ httpx==0.28.1
7
+ python-dotenv==1.2.2
8
+ pytest==9.0.2
9
+ playwright
10
+ pytest-playwright
static/style.css ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CSS Variables β€” warm ADHS-friendly palette */
2
+ :root {
3
+ --bg: #faf7f5;
4
+ --bg-card: #ffffff;
5
+ --bg-card-hover: #f5f1ee;
6
+ --text-primary: #2c2420;
7
+ --text-secondary: #7a6a62;
8
+ --text-muted: #b0a09a;
9
+ --accent-warm: #c97d4e;
10
+ --accent-soft: #e8c4a0;
11
+ --accent-green: #6a9e7a;
12
+ --accent-amber: #d4a843;
13
+ --accent-panic: #c97d4e;
14
+ --border: #e8e0da;
15
+ --border-card: #ede6e0;
16
+ --radius: 12px;
17
+ --radius-sm: 8px;
18
+ --shadow: 0 2px 8px rgba(44, 36, 32, 0.06);
19
+ --shadow-card: 0 4px 16px rgba(44, 36, 32, 0.08);
20
+ --font-serif: 'Instrument Serif', Georgia, serif;
21
+ --font-mono: 'JetBrains Mono', 'Courier New', monospace;
22
+ }
23
+
24
+ /* Reset + Base */
25
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
26
+
27
+ body {
28
+ font-family: var(--font-mono);
29
+ background: var(--bg);
30
+ color: var(--text-primary);
31
+ font-size: 15px;
32
+ line-height: 1.6;
33
+ min-height: 100vh;
34
+ }
35
+
36
+ /* App Shell */
37
+ .app-shell {
38
+ max-width: 780px;
39
+ margin: 0 auto;
40
+ padding: 0 1.5rem 4rem;
41
+ }
42
+
43
+ /* Header */
44
+ .app-header {
45
+ padding: 2rem 0 1rem;
46
+ border-bottom: 1px solid var(--border);
47
+ margin-bottom: 1.5rem;
48
+ }
49
+ .header-inner { display: flex; align-items: baseline; gap: 1rem; }
50
+ .logo {
51
+ font-family: var(--font-serif);
52
+ font-size: 1.8rem;
53
+ color: var(--text-primary);
54
+ font-style: italic;
55
+ }
56
+ .header-sub {
57
+ font-size: 0.8rem;
58
+ color: var(--text-muted);
59
+ letter-spacing: 0.04em;
60
+ }
61
+
62
+ /* Buddy Area */
63
+ .buddy-area {
64
+ margin-bottom: 1.5rem;
65
+ }
66
+ .buddy-message {
67
+ background: linear-gradient(135deg, #fff8f0, #fef3e8);
68
+ border: 1px solid var(--accent-soft);
69
+ border-radius: var(--radius);
70
+ padding: 1rem 1.25rem;
71
+ font-size: 0.9rem;
72
+ color: var(--text-secondary);
73
+ line-height: 1.5;
74
+ }
75
+ .buddy-warning {
76
+ background: #fff8ec;
77
+ border: 1px solid var(--accent-amber);
78
+ border-radius: var(--radius-sm);
79
+ padding: 0.75rem 1rem;
80
+ font-size: 0.85rem;
81
+ color: #8a6010;
82
+ margin-top: 0.75rem;
83
+ }
84
+
85
+ /* Sections */
86
+ .section {
87
+ margin-bottom: 2.5rem;
88
+ }
89
+ .section-title {
90
+ font-family: var(--font-serif);
91
+ font-size: 1.3rem;
92
+ font-weight: normal;
93
+ color: var(--text-primary);
94
+ margin-bottom: 1rem;
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 0.75rem;
98
+ }
99
+ .wip-badge {
100
+ font-family: var(--font-mono);
101
+ font-size: 0.75rem;
102
+ background: var(--border);
103
+ color: var(--text-secondary);
104
+ padding: 0.15rem 0.5rem;
105
+ border-radius: 999px;
106
+ }
107
+
108
+ /* Subject Cards */
109
+ .subjects-grid {
110
+ display: grid;
111
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
112
+ gap: 1rem;
113
+ margin-bottom: 1rem;
114
+ }
115
+ .subject-card {
116
+ background: var(--bg-card);
117
+ border: 1px solid var(--border-card);
118
+ border-radius: var(--radius);
119
+ padding: 1.25rem;
120
+ box-shadow: var(--shadow);
121
+ display: flex;
122
+ justify-content: space-between;
123
+ align-items: flex-start;
124
+ transition: box-shadow 0.15s;
125
+ }
126
+ .subject-card:hover { box-shadow: var(--shadow-card); }
127
+ .subject-card-header { display: flex; flex-direction: column; gap: 0.25rem; }
128
+ .subject-name { font-weight: 500; font-size: 1rem; }
129
+ .subject-semester { font-size: 0.75rem; color: var(--text-muted); }
130
+
131
+ /* Micro-Step Card β€” THE key UI element */
132
+ .micro-step-card {
133
+ background: var(--bg-card);
134
+ border: 1.5px solid var(--accent-soft);
135
+ border-radius: var(--radius);
136
+ padding: 1.75rem;
137
+ box-shadow: var(--shadow-card);
138
+ position: relative;
139
+ max-width: 520px;
140
+ }
141
+ .step-fresh-tag {
142
+ display: inline-block;
143
+ font-size: 0.7rem;
144
+ letter-spacing: 0.08em;
145
+ text-transform: uppercase;
146
+ color: var(--accent-warm);
147
+ background: #fff4eb;
148
+ border: 1px solid var(--accent-soft);
149
+ border-radius: 999px;
150
+ padding: 0.2rem 0.75rem;
151
+ margin-bottom: 0.75rem;
152
+ }
153
+ .step-title {
154
+ font-family: var(--font-serif);
155
+ font-size: 1.4rem;
156
+ font-weight: normal;
157
+ font-style: italic;
158
+ color: var(--text-primary);
159
+ margin-bottom: 0.5rem;
160
+ line-height: 1.3;
161
+ }
162
+ .step-context {
163
+ font-size: 0.875rem;
164
+ color: var(--text-secondary);
165
+ margin-bottom: 1rem;
166
+ line-height: 1.6;
167
+ }
168
+ .step-meta {
169
+ display: flex;
170
+ gap: 1rem;
171
+ margin-bottom: 1.25rem;
172
+ font-size: 0.78rem;
173
+ color: var(--text-muted);
174
+ }
175
+ .step-duration::before { content: "⏱ "; }
176
+ .step-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
177
+
178
+ /* Deadline Items */
179
+ .deadlines-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
180
+ .deadline-item {
181
+ background: var(--bg-card);
182
+ border: 1px solid var(--border-card);
183
+ border-radius: var(--radius-sm);
184
+ padding: 0.875rem 1.1rem;
185
+ display: flex;
186
+ justify-content: space-between;
187
+ align-items: center;
188
+ gap: 1rem;
189
+ }
190
+ .deadline-panic {
191
+ border-color: var(--accent-panic);
192
+ background: #fff9f5;
193
+ }
194
+ .deadline-main { display: flex; flex-direction: column; gap: 0.15rem; }
195
+ .deadline-title { font-size: 0.9rem; font-weight: 500; }
196
+ .deadline-subject { font-size: 0.75rem; color: var(--text-muted); }
197
+ .deadline-date { font-size: 0.8rem; color: var(--text-secondary); }
198
+ .badge-panic {
199
+ font-size: 0.75rem;
200
+ background: var(--accent-panic);
201
+ color: white;
202
+ padding: 0.2rem 0.6rem;
203
+ border-radius: 999px;
204
+ }
205
+ .empty-hint { font-size: 0.85rem; color: var(--text-muted); font-style: italic; }
206
+
207
+ /* Forms */
208
+ .inline-form {
209
+ display: flex;
210
+ flex-wrap: wrap;
211
+ gap: 0.6rem;
212
+ align-items: flex-start;
213
+ margin-top: 1rem;
214
+ padding: 1rem;
215
+ background: var(--bg-card);
216
+ border: 1px dashed var(--border);
217
+ border-radius: var(--radius-sm);
218
+ }
219
+ .deadline-form { flex-direction: column; }
220
+ .input-field {
221
+ font-family: var(--font-mono);
222
+ font-size: 0.85rem;
223
+ padding: 0.55rem 0.85rem;
224
+ border: 1px solid var(--border);
225
+ border-radius: var(--radius-sm);
226
+ background: var(--bg);
227
+ color: var(--text-primary);
228
+ outline: none;
229
+ transition: border-color 0.15s;
230
+ flex: 1;
231
+ min-width: 160px;
232
+ }
233
+ .input-field:focus { border-color: var(--accent-warm); }
234
+ .input-small { max-width: 140px; flex: none; }
235
+ .input-textarea { min-height: 60px; resize: vertical; width: 100%; }
236
+
237
+ /* Buttons */
238
+ .btn {
239
+ font-family: var(--font-mono);
240
+ font-size: 0.85rem;
241
+ padding: 0.55rem 1.1rem;
242
+ border-radius: var(--radius-sm);
243
+ border: none;
244
+ cursor: pointer;
245
+ transition: opacity 0.15s, background 0.15s;
246
+ white-space: nowrap;
247
+ }
248
+ .btn:hover { opacity: 0.85; }
249
+ .btn-primary {
250
+ background: var(--accent-warm);
251
+ color: white;
252
+ }
253
+ .btn-secondary {
254
+ background: var(--accent-green);
255
+ color: white;
256
+ }
257
+ .btn-ghost {
258
+ background: transparent;
259
+ border: 1px solid var(--border);
260
+ color: var(--text-secondary);
261
+ }
262
+ .btn-small { font-size: 0.78rem; padding: 0.35rem 0.75rem; }
263
+
264
+ /* ── Deadline mini progress bar ─────────────────────────────────────────── */
265
+ .deadline-title-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
266
+ .badge-endspurt {
267
+ font-size: 0.7rem;
268
+ font-weight: 600;
269
+ letter-spacing: 0.04em;
270
+ background: var(--accent-amber);
271
+ color: #5a3a00;
272
+ padding: 0.15rem 0.55rem;
273
+ border-radius: 999px;
274
+ }
275
+ .deadline-date--panic { color: var(--accent-amber); font-weight: 600; }
276
+ .deadline-mini-progress {
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 0.5rem;
280
+ margin-top: 0.35rem;
281
+ }
282
+ .mini-progress-track {
283
+ flex: 1;
284
+ height: 4px;
285
+ background: var(--border);
286
+ border-radius: 999px;
287
+ overflow: hidden;
288
+ min-width: 60px;
289
+ }
290
+ .mini-progress-fill {
291
+ height: 100%;
292
+ background: var(--accent-green);
293
+ border-radius: 999px;
294
+ transition: width 0.3s ease;
295
+ }
296
+ .mini-progress-fill--panic { background: var(--accent-amber); }
297
+ .mini-progress-label { font-size: 0.72rem; color: var(--text-muted); white-space: nowrap; }
298
+
299
+ /* ── Elastic Plan ────────────────────────────────────────────────────────── */
300
+ .elastic-plan {
301
+ background: var(--bg-card);
302
+ border: 1px solid var(--border-card);
303
+ border-radius: var(--radius);
304
+ padding: 1.5rem;
305
+ box-shadow: var(--shadow-card);
306
+ margin-bottom: 1.5rem;
307
+ }
308
+ .elastic-plan--panic {
309
+ border-color: var(--accent-amber);
310
+ background: linear-gradient(135deg, #fffcf0, #fff8e0);
311
+ }
312
+ .plan-header { margin-bottom: 1.25rem; }
313
+ .plan-title-row {
314
+ display: flex;
315
+ align-items: baseline;
316
+ gap: 0.75rem;
317
+ flex-wrap: wrap;
318
+ margin-bottom: 0.5rem;
319
+ }
320
+ .plan-title {
321
+ font-family: var(--font-serif);
322
+ font-size: 1.4rem;
323
+ font-weight: normal;
324
+ font-style: italic;
325
+ }
326
+ .plan-subject { font-size: 0.8rem; color: var(--text-muted); }
327
+ .plan-time-bar { margin-bottom: 0.75rem; }
328
+ .time-badge {
329
+ display: inline-block;
330
+ font-size: 0.78rem;
331
+ background: var(--border);
332
+ color: var(--text-secondary);
333
+ padding: 0.2rem 0.65rem;
334
+ border-radius: 999px;
335
+ }
336
+ .time-badge--panic {
337
+ background: var(--accent-amber);
338
+ color: #5a3a00;
339
+ font-weight: 600;
340
+ }
341
+ .time-badge--soon { background: #f0f7ee; color: var(--accent-green); }
342
+ .plan-buddy {
343
+ font-size: 0.875rem;
344
+ color: var(--text-secondary);
345
+ padding: 0.75rem 1rem;
346
+ background: #faf7f5;
347
+ border-radius: var(--radius-sm);
348
+ border-left: 3px solid var(--accent-soft);
349
+ }
350
+ .plan-buddy--panic { border-left-color: var(--accent-amber); background: #fffbf0; }
351
+
352
+ /* Progress bar */
353
+ .plan-progress {
354
+ display: flex;
355
+ align-items: center;
356
+ gap: 0.75rem;
357
+ margin-bottom: 1.5rem;
358
+ }
359
+ .progress-track {
360
+ flex: 1;
361
+ height: 6px;
362
+ background: var(--border);
363
+ border-radius: 999px;
364
+ overflow: hidden;
365
+ }
366
+ .progress-fill {
367
+ height: 100%;
368
+ background: var(--accent-green);
369
+ border-radius: 999px;
370
+ transition: width 0.4s ease;
371
+ }
372
+ .progress-label { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }
373
+
374
+ /* Timeline */
375
+ .plan-timeline { display: flex; flex-direction: column; gap: 0; margin-bottom: 1.5rem; }
376
+ .timeline-step {
377
+ display: flex;
378
+ gap: 1rem;
379
+ position: relative;
380
+ padding-bottom: 1.25rem;
381
+ }
382
+ .timeline-step:last-child { padding-bottom: 0; }
383
+ .timeline-step::before {
384
+ content: '';
385
+ position: absolute;
386
+ left: 7px;
387
+ top: 16px;
388
+ bottom: 0;
389
+ width: 2px;
390
+ background: var(--border);
391
+ }
392
+ .timeline-step:last-child::before { display: none; }
393
+ .timeline-dot {
394
+ width: 16px;
395
+ height: 16px;
396
+ border-radius: 50%;
397
+ flex-shrink: 0;
398
+ margin-top: 2px;
399
+ position: relative;
400
+ z-index: 1;
401
+ }
402
+ .timeline-dot--done {
403
+ background: var(--accent-green);
404
+ opacity: 0.5;
405
+ }
406
+ .timeline-dot--active {
407
+ background: var(--accent-warm);
408
+ box-shadow: 0 0 0 3px rgba(201,125,78,0.15);
409
+ }
410
+ .timeline-dot--panic {
411
+ background: var(--accent-amber);
412
+ box-shadow: 0 0 0 3px rgba(212,168,67,0.2);
413
+ }
414
+ .timeline-content { flex: 1; }
415
+ .timeline-content--done { opacity: 0.5; }
416
+ .timeline-title {
417
+ display: block;
418
+ font-size: 0.9rem;
419
+ font-weight: 500;
420
+ margin-bottom: 0.2rem;
421
+ }
422
+ .timeline-context {
423
+ display: block;
424
+ font-size: 0.8rem;
425
+ color: var(--text-secondary);
426
+ margin-bottom: 0.25rem;
427
+ line-height: 1.5;
428
+ }
429
+ .timeline-duration { font-size: 0.72rem; color: var(--text-muted); }
430
+
431
+ /* Plan actions */
432
+ .plan-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
433
+
434
+ /* Buddy skip message */
435
+ .buddy-skip-msg {
436
+ font-size: 0.82rem;
437
+ color: var(--text-secondary);
438
+ font-style: italic;
439
+ margin-bottom: 0.75rem;
440
+ padding: 0.5rem 0.75rem;
441
+ background: #faf7f5;
442
+ border-radius: var(--radius-sm);
443
+ border-left: 2px solid var(--accent-soft);
444
+ }
templates/base.html ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}Study Buddy{% endblock %}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ <script src="https://unpkg.com/htmx.org@1.9.12"></script>
12
+ </head>
13
+ <body>
14
+ <div class="app-shell">
15
+ <header class="app-header">
16
+ <div class="header-inner">
17
+ <span class="logo">Study Buddy</span>
18
+ <span class="header-sub">Dein ADHS-Lernbegleiter</span>
19
+ </div>
20
+ </header>
21
+
22
+ <div id="buddy-area" class="buddy-area">
23
+ {% block buddy_message %}{% endblock %}
24
+ </div>
25
+
26
+ <main class="main-content">
27
+ {% block content %}{% endblock %}
28
+ </main>
29
+ </div>
30
+ </body>
31
+ </html>
templates/index.html ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Dashboard β€” Study Buddy{% endblock %}
4
+
5
+ {% block buddy_message %}
6
+ <div class="buddy-message">
7
+ {% if active_subjects %}
8
+ Hey! Du bist wieder da. {{ active_subjects|length }} Fach{% if active_subjects|length > 1 %}er{% endif %} aktiv.
9
+ {% if next_step %}
10
+ Dein naechster Schritt wartet schon.
11
+ {% else %}
12
+ Keine offenen Steps β€” vielleicht eine neue Deadline eintragen?
13
+ {% endif %}
14
+ {% else %}
15
+ Willkommen! Trag dein erstes Fach ein und leg los. Keine Hektik.
16
+ {% endif %}
17
+ </div>
18
+
19
+ <div hx-get="/session/current" hx-trigger="load" hx-swap="outerHTML"></div>
20
+ {% endblock %}
21
+
22
+ {% block content %}
23
+
24
+ <!-- WIP Warning placeholder -->
25
+ <div id="wip-warning"></div>
26
+
27
+ <!-- Active Subjects + Next Step -->
28
+ <section class="section">
29
+ <h2 class="section-title">Aktive Faecher <span class="wip-badge">{{ active_subjects|length }}/2</span></h2>
30
+
31
+ <div id="subjects-list" class="subjects-grid">
32
+ {% for subject in active_subjects %}
33
+ {% include "partials/subject_card.html" %}
34
+ {% endfor %}
35
+ </div>
36
+
37
+ <!-- Add Subject Form -->
38
+ {% if active_subjects|length < 2 %}
39
+ <form hx-post="/subjects"
40
+ hx-target="#subjects-list"
41
+ hx-swap="beforeend"
42
+ class="inline-form">
43
+ <input type="text" name="name" placeholder="Fach (z.B. Mathe, OS, BWL)" required class="input-field">
44
+ <input type="text" name="semester" placeholder="Semester (optional)" class="input-field input-small">
45
+ <button type="submit" class="btn btn-primary">+ Fach hinzufuegen</button>
46
+ </form>
47
+ {% else %}
48
+ <div class="buddy-warning">
49
+ WIP-Limit erreicht (2/2). Parkiere ein Fach um ein neues hinzuzufuegen.
50
+ </div>
51
+ {% endif %}
52
+ </section>
53
+
54
+ <!-- Next Micro-Step -->
55
+ {% if next_step %}
56
+ <section class="section">
57
+ <h2 class="section-title">Jetzt anfangen</h2>
58
+ <div id="current-step">
59
+ {% include "partials/micro_step_card.html" with context %}
60
+ </div>
61
+ </section>
62
+ {% endif %}
63
+
64
+ <!-- Plan Area (htmx target for elastic plan view) -->
65
+ <div id="plan-area"></div>
66
+
67
+ <!-- Upcoming Deadlines -->
68
+ <section class="section">
69
+ <h2 class="section-title">Naechste Deadlines</h2>
70
+
71
+ <div id="deadlines-list" class="deadlines-list">
72
+ {% for deadline in upcoming_deadlines %}
73
+ {% set dl = deadline %}
74
+ {% include "partials/deadline_item.html" %}
75
+ {% endfor %}
76
+ {% if not upcoming_deadlines %}
77
+ <p class="empty-hint">Noch keine Deadlines β€” trag eine ein bevor der Stress kommt.</p>
78
+ {% endif %}
79
+ </div>
80
+
81
+ <!-- Add Deadline Form -->
82
+ {% if active_subjects %}
83
+ <form hx-post="/deadlines"
84
+ hx-target="#deadlines-list"
85
+ hx-swap="beforeend"
86
+ class="inline-form deadline-form">
87
+ <select name="subject_id" required class="input-field">
88
+ <option value="">Fach waehlen...</option>
89
+ {% for subject in active_subjects %}
90
+ <option value="{{ subject.id }}">{{ subject.name }}</option>
91
+ {% endfor %}
92
+ </select>
93
+ <input type="text" name="title" placeholder="Aufgabe (z.B. Klausur, Abgabe)" required class="input-field">
94
+ <input type="datetime-local" name="due_date" required class="input-field">
95
+ <textarea name="description" placeholder="Details (optional)" class="input-field input-textarea"></textarea>
96
+ <button type="submit" class="btn btn-secondary">Deadline eintragen</button>
97
+ </form>
98
+ {% endif %}
99
+ </section>
100
+
101
+ <!-- Session History / Progress Journey -->
102
+ <section class="section">
103
+ <h2 class="section-title">Deine Lernreise</h2>
104
+ <div hx-get="/session/history" hx-trigger="load" hx-swap="innerHTML"></div>
105
+ </section>
106
+
107
+ {% endblock %}
templates/partials/deadline_item.html ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% set deadline = dl if dl is defined else deadline %}
2
+ {% set hours_left = ((deadline.due_date - now).total_seconds() / 3600) | int %}
3
+ {% set is_overdue = hours_left < 0 %}
4
+ {% set is_panic = hours_left < 48 %}
5
+ {% set stats = deadline_stats[deadline.id] if deadline_stats is defined and deadline.id in deadline_stats else none %}
6
+ <div class="deadline-item {% if is_overdue %}deadline-overdue{% elif is_panic %}deadline-panic{% endif %}" id="deadline-{{ deadline.id }}">
7
+ <div class="deadline-main">
8
+ <div class="deadline-title-row">
9
+ <span class="deadline-title">{{ deadline.title }}</span>
10
+ {% if is_overdue %}
11
+ <span class="deadline-badge badge-overdue">Ueberfaellig!</span>
12
+ {% elif is_panic %}
13
+ <span class="deadline-badge badge-endspurt">Endspurt!</span>
14
+ {% endif %}
15
+ </div>
16
+ <span class="deadline-subject">{{ deadline.subject.name }}</span>
17
+ {% if stats and stats.total > 0 %}
18
+ <div class="deadline-mini-progress">
19
+ <div class="mini-progress-track">
20
+ <div class="mini-progress-fill {% if is_panic %}mini-progress-fill--panic{% endif %}"
21
+ style="width: {{ stats.pct }}%"></div>
22
+ </div>
23
+ <span class="mini-progress-label">{{ stats.completed }}/{{ stats.total }}</span>
24
+ </div>
25
+ {% endif %}
26
+ </div>
27
+ <div class="deadline-meta">
28
+ {% if is_overdue %}
29
+ <span class="deadline-date deadline-date--overdue">Seit {{ (hours_left * -1) }}h ueberfaellig</span>
30
+ {% elif is_panic %}
31
+ <span class="deadline-date deadline-date--panic">{{ hours_left }}h</span>
32
+ {% else %}
33
+ <span class="deadline-date">{{ deadline.due_date.strftime('%d.%m. %H:%M') }}</span>
34
+ {% endif %}
35
+ <a href="/deadlines/{{ deadline.id }}/plan"
36
+ hx-get="/deadlines/{{ deadline.id }}/plan"
37
+ hx-target="#plan-area"
38
+ hx-swap="innerHTML"
39
+ class="btn btn-ghost btn-small">Plan</a>
40
+ </div>
41
+ </div>
templates/partials/elastic_plan.html ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% set hours_left = ((deadline.due_date - now).total_seconds() / 3600) %}
2
+ {% set overdue = hours_left < 0 %}
3
+ {% set panic = hours_left < 48 %}
4
+ {% set total = steps|length %}
5
+ {% set completed_count = steps | selectattr('status', 'equalto', 'completed') | list | length %}
6
+ {% set pending_steps = steps | selectattr('status', 'equalto', 'pending') | list %}
7
+ {% set pending_count = pending_steps | length %}
8
+ {% set progress_pct = ((completed_count / total * 100) | int) if total > 0 else 0 %}
9
+
10
+ <div class="elastic-plan {% if panic %}elastic-plan--panic{% endif %}" id="plan-{{ deadline.id }}">
11
+
12
+ <!-- Header -->
13
+ <div class="plan-header">
14
+ <div class="plan-title-row">
15
+ <h2 class="plan-title">{{ deadline.title }}</h2>
16
+ <span class="plan-subject">{{ deadline.subject.name }}</span>
17
+ </div>
18
+
19
+ <!-- Time bar -->
20
+ <div class="plan-time-bar">
21
+ {% if overdue %}
22
+ <span class="time-badge time-badge--overdue">Ueberfaellig! Seit {{ (hours_left * -1) | int }}h</span>
23
+ {% elif panic %}
24
+ <span class="time-badge time-badge--panic">Endspurt! {{ hours_left | int }}h verbleibend</span>
25
+ {% elif hours_left < 168 %}
26
+ <span class="time-badge time-badge--soon">{{ hours_left | int }}h verbleibend</span>
27
+ {% else %}
28
+ <span class="time-badge">{{ deadline.due_date.strftime('%d.%m.%Y') }}</span>
29
+ {% endif %}
30
+ </div>
31
+
32
+ <!-- Buddy message -->
33
+ <div class="plan-buddy {% if panic %}plan-buddy--panic{% endif %}">
34
+ {% if buddy_message %}
35
+ {{ buddy_message }}
36
+ {% elif overdue %}
37
+ Die Deadline ist vorbei, aber offene Steps sind noch da. Abarbeiten oder Deadline loeschen?
38
+ {% elif panic %}
39
+ Du hast noch {{ hours_left | int }}h. Hier sind die wichtigsten Schritte β€” eins nach dem anderen. Du schaffst das.
40
+ {% elif completed_count > 0 %}
41
+ Super, du bist schon drin! {{ completed_count }} von {{ total }} Steps erledigt.
42
+ {% else %}
43
+ Hier ist dein Plan. Fang einfach mit dem ersten Schritt an.
44
+ {% endif %}
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Progress bar -->
49
+ {% if total > 0 %}
50
+ <div class="plan-progress">
51
+ <div class="progress-track">
52
+ <div class="progress-fill" style="width: {{ progress_pct }}%"></div>
53
+ </div>
54
+ <span class="progress-label">{{ completed_count }}/{{ total }} erledigt</span>
55
+ </div>
56
+ {% endif %}
57
+
58
+ <!-- Timeline -->
59
+ <div class="plan-timeline">
60
+ {% for step in steps %}
61
+ {% if step.status.value == 'completed' %}
62
+ <div class="timeline-step timeline-step--done">
63
+ <div class="timeline-dot timeline-dot--done"></div>
64
+ <div class="timeline-content timeline-content--done">
65
+ <span class="timeline-title">{{ step.title }}</span>
66
+ <span class="timeline-duration">{{ step.duration_minutes }} Min</span>
67
+ </div>
68
+ </div>
69
+ {% elif step.status.value == 'pending' %}
70
+ <div class="timeline-step timeline-step--active">
71
+ <div class="timeline-dot timeline-dot--active {% if panic %}timeline-dot--panic{% endif %}"></div>
72
+ <div class="timeline-content">
73
+ <span class="timeline-title">{{ step.title }}</span>
74
+ {% if step.context %}
75
+ <span class="timeline-context">{{ step.context }}</span>
76
+ {% endif %}
77
+ <span class="timeline-duration">{{ step.duration_minutes }} Min</span>
78
+ </div>
79
+ </div>
80
+ {% endif %}
81
+ {# skipped steps are intentionally omitted β€” no "Versagt" #}
82
+ {% endfor %}
83
+
84
+ {% if not steps %}
85
+ <p class="empty-hint">Noch keine Steps. Lass den Buddy einen Plan generieren!</p>
86
+ {% endif %}
87
+ </div>
88
+
89
+ <!-- Actions -->
90
+ <div class="plan-actions">
91
+ <button hx-post="/deadlines/{{ deadline.id }}/replan"
92
+ hx-target="#plan-{{ deadline.id }}"
93
+ hx-swap="outerHTML"
94
+ class="btn btn-secondary btn-small">
95
+ Plan anpassen
96
+ </button>
97
+ <button hx-post="/deadlines/{{ deadline.id }}/generate-steps"
98
+ hx-target="#plan-{{ deadline.id }}"
99
+ hx-swap="outerHTML"
100
+ class="btn btn-ghost btn-small">
101
+ Neu generieren
102
+ </button>
103
+ </div>
104
+ </div>
templates/partials/micro_step_card.html ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% set step = step if step is defined else next_step %}
2
+ {% if step %}
3
+ <div class="micro-step-card" id="step-{{ step.id }}">
4
+ <div class="step-fresh-tag">Frischer Start</div>
5
+ <h3 class="step-title">{{ step.title }}</h3>
6
+ {% if step.context %}
7
+ <p class="step-context">{{ step.context }}</p>
8
+ {% endif %}
9
+ <div class="step-meta">
10
+ <span class="step-duration">{{ step.duration_minutes }} Min</span>
11
+ <span class="step-subject">{{ step.deadline.subject.name }}</span>
12
+ </div>
13
+ <div class="step-actions">
14
+ <button hx-post="/steps/{{ step.id }}/complete"
15
+ hx-target="#current-step"
16
+ hx-swap="innerHTML"
17
+ class="btn btn-primary">
18
+ Geschafft!
19
+ </button>
20
+ <button hx-post="/steps/{{ step.id }}/skip"
21
+ hx-target="#current-step"
22
+ hx-swap="innerHTML"
23
+ class="btn btn-ghost">
24
+ Ueberspringen
25
+ </button>
26
+ </div>
27
+ </div>
28
+ {% endif %}
templates/partials/progress_journey.html ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% if sessions %}
2
+ <div class="progress-journey">
3
+ <h3 class="journey-title">Deine letzten Sessions</h3>
4
+ <div class="journey-timeline">
5
+ {% for session in sessions %}
6
+ <div class="journey-entry">
7
+ <div class="journey-dots">
8
+ {% for i in range(session.steps_completed) %}
9
+ {% if loop.index <= 8 %}
10
+ <span class="dot dot-done" title="Step {{ loop.index }} erledigt"></span>
11
+ {% endif %}
12
+ {% endfor %}
13
+ {% if session.steps_completed > 8 %}
14
+ <span class="dot-overflow">+{{ session.steps_completed - 8 }}</span>
15
+ {% endif %}
16
+ {% if session.steps_completed == 0 %}
17
+ <span class="dot dot-empty"></span>
18
+ {% endif %}
19
+ </div>
20
+ <div class="journey-meta">
21
+ <span class="journey-subject">{{ session.subject.name if session.subject else "β€”" }}</span>
22
+ <span class="journey-date">{{ session.ended_at.strftime("%d.%m., %H:%M") if session.ended_at else "laufend" }}</span>
23
+ </div>
24
+ </div>
25
+ {% endfor %}
26
+ </div>
27
+ </div>
28
+ {% else %}
29
+ <p class="empty-hint">Noch keine abgeschlossenen Sessions β€” fang einfach an.</p>
30
+ {% endif %}
templates/partials/session_banner.html ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% if last_session and last_session.subject %}
2
+ <div class="session-banner" id="session-banner-card">
3
+ <div class="session-banner-inner">
4
+ <div class="session-banner-text">
5
+ <span class="session-banner-emoji">πŸ‘‹</span>
6
+ <div>
7
+ <strong>Schoen dass du wieder da bist!</strong>
8
+ <p class="session-banner-detail">
9
+ Letzte Session: <em>{{ last_session.subject.name }}</em>
10
+ {% if last_session.steps_completed > 0 %}
11
+ β€” {{ last_session.steps_completed }} Step{% if last_session.steps_completed != 1 %}s{% endif %} geschafft
12
+ {% endif %}
13
+ </p>
14
+ </div>
15
+ </div>
16
+ <div class="session-banner-actions">
17
+ {% if next_step %}
18
+ <button class="btn btn-primary btn-sm"
19
+ hx-get="/steps/next"
20
+ hx-target="#current-step"
21
+ hx-swap="innerHTML"
22
+ onclick="document.getElementById('session-banner-card').remove()">
23
+ Weitermachen
24
+ </button>
25
+ {% endif %}
26
+ <button class="btn btn-ghost btn-sm"
27
+ onclick="document.getElementById('session-banner-card').remove()">
28
+ Neu starten
29
+ </button>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ {% endif %}
templates/partials/subject_card.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="subject-card" id="subject-{{ subject.id }}">
2
+ <div class="subject-card-header">
3
+ <span class="subject-name">{{ subject.name }}</span>
4
+ {% if subject.semester %}
5
+ <span class="subject-semester">{{ subject.semester }}</span>
6
+ {% endif %}
7
+ </div>
8
+ <div class="subject-card-actions">
9
+ <button hx-delete="/subjects/{{ subject.id }}"
10
+ hx-target="#subject-{{ subject.id }}"
11
+ hx-swap="outerHTML"
12
+ hx-confirm="'{{ subject.name }}' in die Parkzone?"
13
+ class="btn btn-ghost btn-small">
14
+ Pausieren
15
+ </button>
16
+ </div>
17
+ </div>
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Playwright E2E test configuration for StudyAgent."""
2
+ import pytest
3
+
4
+ BASE_URL = "http://localhost:8000"
5
+
6
+
7
+ @pytest.fixture(scope="session")
8
+ def base_url():
9
+ return BASE_URL
10
+
11
+
12
+ @pytest.fixture(scope="session")
13
+ def browser_context_args(browser_context_args):
14
+ return {**browser_context_args, "base_url": BASE_URL}
tests/test_e2e.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Playwright E2E tests for StudyAgent.
3
+
4
+ Requires:
5
+ - Server running: cd ~/Projekte/StudyAgent && .venv/bin/uvicorn main:app --reload
6
+ - Browsers installed: playwright install chromium
7
+ """
8
+ import pytest
9
+
10
+ pw = pytest.importorskip("playwright")
11
+ from playwright.sync_api import Page, expect
12
+
13
+
14
+ def _add_subject(page: Page, name: str) -> None:
15
+ """Fill and submit the add-subject form via the visible input field."""
16
+ name_input = page.locator('input[name="name"]')
17
+ name_input.fill(name)
18
+ page.locator('button[type="submit"]:has-text("Fach hinzufuegen")').click()
19
+ # Wait for htmx to swap the new card into the DOM
20
+ page.wait_for_timeout(500)
21
+
22
+
23
+ def test_dashboard_loads(page: Page, base_url: str) -> None:
24
+ """Dashboard returns 200 and renders the subjects section heading."""
25
+ response = page.goto(base_url)
26
+ assert response is not None and response.status == 200
27
+
28
+ # The section heading is always present on the page
29
+ expect(page.locator("text=Aktive Faecher")).to_be_visible()
30
+
31
+
32
+ def test_add_subject_flow(page: Page, base_url: str) -> None:
33
+ """Adding a subject shows it on the page as a subject card."""
34
+ page.goto(base_url)
35
+
36
+ _add_subject(page, "Mathematik")
37
+
38
+ # The subject card renders the name inside .subject-name
39
+ expect(page.locator(".subject-name", has_text="Mathematik")).to_be_visible()
40
+
41
+
42
+ def test_wip_limit_enforced(page: Page, base_url: str) -> None:
43
+ """A third subject is rejected with the WIP-limit buddy warning."""
44
+ page.goto(base_url)
45
+
46
+ # Add first two subjects β€” the form is rendered while < 2 active subjects
47
+ _add_subject(page, "Fach 1")
48
+
49
+ # After first add, reload so the template re-evaluates the condition and
50
+ # renders the form for the second subject
51
+ page.reload()
52
+ _add_subject(page, "Fach 2")
53
+
54
+ # Now the form is hidden by the template (WIP=2/2). Post the third subject
55
+ # directly via the API to trigger the server-side rejection message.
56
+ page.evaluate(
57
+ """async () => {
58
+ const body = new URLSearchParams({ name: 'Fach 3', semester: '' });
59
+ const resp = await fetch('/subjects', {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
62
+ body: body.toString(),
63
+ });
64
+ const html = await resp.text();
65
+ document.getElementById('wip-warning').innerHTML = html;
66
+ }"""
67
+ )
68
+
69
+ # The rejection message contains "Parkiere"
70
+ expect(page.locator("#wip-warning")).to_contain_text("Parkiere")
tests/test_routes.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Basis-Tests fuer StudyAgent V1 β€” kritische Endpunkte."""
2
+
3
+ import os
4
+ import tempfile
5
+
6
+ import pytest
7
+ from fastapi.testclient import TestClient
8
+ from sqlalchemy import create_engine
9
+ from sqlalchemy.orm import sessionmaker
10
+
11
+ # Ensure no API key so we get deterministic fallback steps
12
+ os.environ.pop("MINIMAX_API_KEY", None)
13
+
14
+ from database import Base, get_db # noqa: E402
15
+ from main import app, _utcnow, is_panic_mode, is_overdue, _should_auto_compress # noqa: E402
16
+ from models import Deadline, MicroStep, MicroStepStatus, Subject # noqa: E402
17
+
18
+ from datetime import timedelta # noqa: E402
19
+
20
+
21
+ @pytest.fixture()
22
+ def db_session():
23
+ """In-memory SQLite DB per test."""
24
+ tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
25
+ tmp.close()
26
+ engine = create_engine(f"sqlite:///{tmp.name}", connect_args={"check_same_thread": False})
27
+ Base.metadata.create_all(bind=engine)
28
+ TestSession = sessionmaker(bind=engine)
29
+ session = TestSession()
30
+ try:
31
+ yield session
32
+ finally:
33
+ session.close()
34
+ os.unlink(tmp.name)
35
+
36
+
37
+ @pytest.fixture()
38
+ def client(db_session):
39
+ """TestClient with overridden DB dependency."""
40
+ def _override():
41
+ try:
42
+ yield db_session
43
+ finally:
44
+ pass
45
+ app.dependency_overrides[get_db] = _override
46
+ with TestClient(app) as c:
47
+ yield c
48
+ app.dependency_overrides.clear()
49
+
50
+
51
+ @pytest.fixture()
52
+ def seeded_db(db_session):
53
+ """DB with one subject, two deadlines (overdue + future), and steps."""
54
+ subj = Subject(name="Mathe", semester="3")
55
+ db_session.add(subj)
56
+ db_session.commit()
57
+ db_session.refresh(subj)
58
+
59
+ # Overdue deadline
60
+ dl_past = Deadline(
61
+ subject_id=subj.id,
62
+ title="Klausur Lineare Algebra",
63
+ due_date=_utcnow() - timedelta(hours=48),
64
+ )
65
+ # Future deadline (within 48h = panic)
66
+ dl_future = Deadline(
67
+ subject_id=subj.id,
68
+ title="Klausur Analysis",
69
+ due_date=_utcnow() + timedelta(hours=24),
70
+ )
71
+ db_session.add_all([dl_past, dl_future])
72
+ db_session.commit()
73
+ db_session.refresh(dl_past)
74
+ db_session.refresh(dl_future)
75
+
76
+ # Steps for both
77
+ for dl in [dl_past, dl_future]:
78
+ for i in range(3):
79
+ db_session.add(MicroStep(
80
+ deadline_id=dl.id,
81
+ title=f"Step {i+1} fuer {dl.title}",
82
+ order_num=i,
83
+ duration_minutes=5,
84
+ ))
85
+ db_session.commit()
86
+ return {"subject": subj, "dl_past": dl_past, "dl_future": dl_future}
87
+
88
+
89
+ # ── Dashboard ──
90
+
91
+ def test_dashboard_returns_200(client, seeded_db):
92
+ resp = client.get("/")
93
+ assert resp.status_code == 200
94
+ assert "Mathe" in resp.text
95
+
96
+
97
+ def test_dashboard_shows_overdue_badge(client, seeded_db):
98
+ resp = client.get("/")
99
+ assert "Ueberfaellig" in resp.text
100
+
101
+
102
+ # ── WIP-Limit ──
103
+
104
+ def test_wip_limit_allows_second_subject(client, seeded_db):
105
+ resp = client.post("/subjects", data={"name": "Physik", "semester": "2"})
106
+ assert resp.status_code == 200
107
+ assert "Physik" in resp.text
108
+
109
+
110
+ def test_wip_limit_rejects_third_subject(client, seeded_db):
111
+ client.post("/subjects", data={"name": "Physik", "semester": "2"})
112
+ resp = client.post("/subjects", data={"name": "Chemie", "semester": "1"})
113
+ assert resp.status_code == 200
114
+ assert "Parkiere" in resp.text
115
+ assert "Chemie" not in resp.text
116
+
117
+
118
+ # ── Step Transitions ──
119
+
120
+ def test_complete_step(client, seeded_db, db_session):
121
+ step = db_session.query(MicroStep).filter(MicroStep.status == MicroStepStatus.pending).first()
122
+ resp = client.post(f"/steps/{step.id}/complete")
123
+ assert resp.status_code == 200
124
+ db_session.refresh(step)
125
+ assert step.status == MicroStepStatus.completed
126
+
127
+
128
+ def test_skip_step(client, seeded_db, db_session):
129
+ step = db_session.query(MicroStep).filter(MicroStep.status == MicroStepStatus.pending).first()
130
+ resp = client.post(f"/steps/{step.id}/skip")
131
+ assert resp.status_code == 200
132
+ db_session.refresh(step)
133
+ assert step.status == MicroStepStatus.skipped
134
+ assert "Kein Stress" in resp.text
135
+
136
+
137
+ # ── Panic Mode / Overdue Detection ──
138
+
139
+ def test_is_panic_mode_future_close(seeded_db):
140
+ assert is_panic_mode(seeded_db["dl_future"]) is True # 24h < 48h
141
+
142
+
143
+ def test_is_panic_mode_overdue(seeded_db):
144
+ assert is_panic_mode(seeded_db["dl_past"]) is True # negative = still panic
145
+
146
+
147
+ def test_is_overdue(seeded_db):
148
+ assert is_overdue(seeded_db["dl_past"]) is True
149
+ assert is_overdue(seeded_db["dl_future"]) is False
150
+
151
+
152
+ # ── Auto-Compress ──
153
+
154
+ def test_auto_compress_overdue_with_pending_steps(seeded_db):
155
+ """Overdue deadline with pending steps must trigger compression."""
156
+ assert _should_auto_compress(seeded_db["dl_past"], pending_count=3) is True
157
+
158
+
159
+ def test_auto_compress_overdue_no_pending_steps(seeded_db):
160
+ """Overdue deadline with zero pending steps must NOT trigger compression."""
161
+ assert _should_auto_compress(seeded_db["dl_past"], pending_count=0) is False
162
+
163
+
164
+ # ── Plan View ──
165
+
166
+ def test_plan_view_overdue(client, seeded_db):
167
+ resp = client.get(f"/deadlines/{seeded_db['dl_past'].id}/plan")
168
+ assert resp.status_code == 200
169
+ assert "Ueberfaellig" in resp.text
170
+
171
+
172
+ def test_plan_view_future(client, seeded_db):
173
+ resp = client.get(f"/deadlines/{seeded_db['dl_future'].id}/plan")
174
+ assert resp.status_code == 200
175
+ assert "Endspurt" in resp.text
176
+
177
+
178
+ # ── Replan ──
179
+
180
+ def test_replan_removes_pending_keeps_completed(client, seeded_db, db_session):
181
+ """Replan deletes pending+skipped steps, keeps completed, adds new ones."""
182
+ dl = seeded_db["dl_future"]
183
+
184
+ # Complete one step, skip another β€” only 1 pending should remain
185
+ steps = db_session.query(MicroStep).filter(
186
+ MicroStep.deadline_id == dl.id,
187
+ ).order_by(MicroStep.order_num).all()
188
+ steps[0].status = MicroStepStatus.completed
189
+ steps[1].status = MicroStepStatus.skipped
190
+ db_session.commit()
191
+
192
+ resp = client.post(f"/deadlines/{dl.id}/replan")
193
+ assert resp.status_code == 200
194
+ assert "Plan angepasst" in resp.text
195
+
196
+ # Reload steps from DB
197
+ db_session.expire_all()
198
+ remaining = db_session.query(MicroStep).filter(
199
+ MicroStep.deadline_id == dl.id,
200
+ ).order_by(MicroStep.order_num).all()
201
+
202
+ # 1 completed survives + new fallback steps (3 panic or 4 normal)
203
+ completed = [s for s in remaining if s.status == MicroStepStatus.completed]
204
+ pending = [s for s in remaining if s.status == MicroStepStatus.pending]
205
+ assert len(completed) == 1
206
+ assert len(pending) >= 3 # fallback generates 3-4 steps
207
+
208
+ # New steps order_num starts after completed count
209
+ assert pending[0].order_num == 1 # 1 completed = offset 1
210
+
211
+
212
+ def test_replan_overdue_deadline(client, seeded_db, db_session):
213
+ """Replan works for overdue deadlines (negative hours_until_due)."""
214
+ dl = seeded_db["dl_past"] # 48h in the past
215
+ resp = client.post(f"/deadlines/{dl.id}/replan")
216
+ assert resp.status_code == 200
217
+ assert "Plan angepasst" in resp.text
218
+
219
+
220
+ def test_replan_nonexistent_deadline(client):
221
+ """Replan returns buddy warning for missing deadline."""
222
+ resp = client.post("/deadlines/99999/replan")
223
+ assert resp.status_code == 200
224
+ assert "nicht gefunden" in resp.text