Initial public upload from Projekte/StudyAgent
Browse files- .claude/settings.local.json +7 -0
- .codex/config.toml +31 -0
- .gitignore +37 -0
- COACHING-EMPFEHLUNGEN.md +138 -0
- COACHING_SAMUEL_2026-03-04.md +39 -0
- FOR_SMLFLG.md +86 -0
- PLAN.md +134 -0
- PLAN_LANGCHAIN_V2.md +380 -0
- PROJECT_HEALTH_REPORT.md +129 -0
- README.md +40 -0
- agent.py +131 -0
- database.py +21 -0
- main.py +515 -0
- models.py +70 -0
- requirements.txt +10 -0
- static/style.css +444 -0
- templates/base.html +31 -0
- templates/index.html +107 -0
- templates/partials/deadline_item.html +41 -0
- templates/partials/elastic_plan.html +104 -0
- templates/partials/micro_step_card.html +28 -0
- templates/partials/progress_journey.html +30 -0
- templates/partials/session_banner.html +33 -0
- templates/partials/subject_card.html +17 -0
- tests/__init__.py +0 -0
- tests/conftest.py +14 -0
- tests/test_e2e.py +70 -0
- tests/test_routes.py +224 -0
.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
|