StudyAgent / main.py
smlflg's picture
Initial public upload from Projekte/StudyAgent
c72a1e8 verified
Raw History Blame Contribute Delete
17.8 kB
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
load_dotenv() # Before agent import so MINIMAX_API_KEY is available
from fastapi import Depends, FastAPI, Form, Request # noqa: E402
from fastapi.responses import HTMLResponse # noqa: E402
from fastapi.staticfiles import StaticFiles # noqa: E402
from fastapi.templating import Jinja2Templates # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from agent import generate_micro_steps # noqa: E402
from database import create_all, get_db # noqa: E402
from models import Deadline, MicroStep, MicroStepStatus, StudySession, Subject # noqa: E402
SESSION_INACTIVITY_MINUTES = 30
def _utcnow() -> datetime:
"""Naive UTC datetime β€” matches SQLite's naive storage."""
return datetime.now(timezone.utc).replace(tzinfo=None)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_all()
yield
app = FastAPI(lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
def _get_current_session(db: Session):
"""Returns the most recent open session if within inactivity window."""
cutoff = _utcnow() - timedelta(minutes=SESSION_INACTIVITY_MINUTES)
return (
db.query(StudySession)
.filter(StudySession.ended_at == None, StudySession.started_at >= cutoff) # noqa: E711
.order_by(StudySession.started_at.desc())
.first()
)
def _get_last_ended_session(db: Session):
"""Returns the most recent completed session for the welcome-back banner."""
return (
db.query(StudySession)
.filter(StudySession.ended_at != None) # noqa: E711
.order_by(StudySession.ended_at.desc())
.first()
)
def _active_subjects(db: Session):
return db.query(Subject).filter(Subject.is_active == True, Subject.is_parked == False).all() # noqa: E712
def _upcoming_deadlines(db: Session, limit: int = 5):
# Include overdue deadlines from last 7 days so they show as "Ueberfaellig"
cutoff = _utcnow() - timedelta(days=7)
return (
db.query(Deadline)
.join(Subject)
.filter(Subject.is_parked == False, Deadline.due_date >= cutoff) # noqa: E712
.order_by(Deadline.due_date)
.limit(limit)
.all()
)
def _next_micro_step(db: Session):
return (
db.query(MicroStep)
.filter(MicroStep.status == MicroStepStatus.pending)
.join(Deadline)
.join(Subject)
.filter(Subject.is_parked == False) # noqa: E712
.order_by(Deadline.due_date, MicroStep.order_num)
.first()
)
def _expire_stale_sessions(db: Session):
"""Auto-close sessions older than the inactivity window."""
cutoff = _utcnow() - timedelta(minutes=SESSION_INACTIVITY_MINUTES)
stale = (
db.query(StudySession)
.filter(StudySession.ended_at == None, StudySession.started_at < cutoff) # noqa: E711
.all()
)
for s in stale:
s.ended_at = _utcnow()
if stale:
db.commit()
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db)):
_expire_stale_sessions(db)
# Auto-start a session if none is open; attach to the first active subject
open_session = _get_current_session(db)
if not open_session:
active_list = _active_subjects(db)
if active_list:
open_session = StudySession(subject_id=active_list[0].id)
db.add(open_session)
db.commit()
active_subjects = _active_subjects(db)
upcoming_deadlines = _upcoming_deadlines(db)
next_step = _next_micro_step(db)
last_session = _get_last_ended_session(db)
# Build per-deadline progress stats for dashboard mini-bars
deadline_stats = {}
for dl in upcoming_deadlines:
all_steps = db.query(MicroStep).filter(MicroStep.deadline_id == dl.id).all()
total = len(all_steps)
done = sum(1 for s in all_steps if s.status == MicroStepStatus.completed)
deadline_stats[dl.id] = {
"total": total,
"completed": done,
"pct": int(done / total * 100) if total else 0,
"panic": is_panic_mode(dl),
"overdue": is_overdue(dl),
}
return templates.TemplateResponse(
"index.html",
{
"request": request,
"active_subjects": active_subjects,
"upcoming_deadlines": upcoming_deadlines,
"next_step": next_step,
"last_session": last_session,
"deadline_stats": deadline_stats,
"now": _utcnow(),
},
)
@app.post("/subjects", response_class=HTMLResponse)
async def create_subject(
request: Request,
name: str = Form(...),
semester: str = Form(""),
db: Session = Depends(get_db),
):
active_count = len(_active_subjects(db))
if active_count >= 2:
return HTMLResponse(
'<div class="buddy-warning" id="wip-warning">'
"Hey, du hast schon 2 aktive Faecher. Parkiere erst eins, bevor du ein neues startest."
"</div>"
)
subject = Subject(name=name, semester=semester)
db.add(subject)
db.commit()
db.refresh(subject)
return templates.TemplateResponse(
"partials/subject_card.html",
{"request": request, "subject": subject},
)
@app.post("/deadlines", response_class=HTMLResponse)
async def create_deadline(
request: Request,
subject_id: int = Form(...),
title: str = Form(...),
description: str = Form(""),
due_date: str = Form(...),
db: Session = Depends(get_db),
):
due = datetime.fromisoformat(due_date)
deadline = Deadline(
subject_id=subject_id,
title=title,
description=description,
due_date=due,
)
db.add(deadline)
db.commit()
db.refresh(deadline)
# Auto-generate micro-steps
hours_until_due = (due - _utcnow()).total_seconds() / 3600
try:
steps_data = await generate_micro_steps(title, description, due, hours_until_due)
for i, s in enumerate(steps_data):
step = MicroStep(
deadline_id=deadline.id,
title=s["title"],
context=s.get("context", ""),
order_num=i,
duration_minutes=s.get("duration_minutes", 5),
)
db.add(step)
db.commit()
except Exception:
pass # Steps are a nice-to-have; deadline creation should never fail because of this
return templates.TemplateResponse(
"partials/deadline_item.html",
{"request": request, "deadline": deadline, "now": _utcnow()},
)
@app.post("/deadlines/{deadline_id}/generate-steps", response_class=HTMLResponse)
async def generate_steps_for_deadline(
deadline_id: int,
request: Request,
db: Session = Depends(get_db),
):
deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first()
if not deadline:
return HTMLResponse('<p class="buddy-warning">Deadline nicht gefunden.</p>')
hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600
steps_data = await generate_micro_steps(
deadline.title,
deadline.description or "",
deadline.due_date,
hours_until_due,
)
# Delete old pending steps for this deadline and recreate
db.query(MicroStep).filter(
MicroStep.deadline_id == deadline_id,
MicroStep.status == MicroStepStatus.pending,
).delete()
db.commit()
for i, s in enumerate(steps_data):
step = MicroStep(
deadline_id=deadline_id,
title=s["title"],
context=s.get("context", ""),
order_num=i,
duration_minutes=s.get("duration_minutes", 5),
)
db.add(step)
db.commit()
# Return all new step cards
new_steps = (
db.query(MicroStep)
.filter(MicroStep.deadline_id == deadline_id, MicroStep.status == MicroStepStatus.pending)
.order_by(MicroStep.order_num)
.all()
)
html_parts = []
for step in new_steps:
rendered = templates.TemplateResponse(
"partials/micro_step_card.html",
{"request": request, "step": step},
)
html_parts.append(rendered.body.decode())
return HTMLResponse("".join(html_parts))
@app.delete("/subjects/{subject_id}", response_class=HTMLResponse)
async def park_subject(subject_id: int, db: Session = Depends(get_db)):
subject = db.query(Subject).filter(Subject.id == subject_id).first()
if subject:
subject.is_parked = True
subject.is_active = False
db.commit()
return HTMLResponse("")
@app.post("/steps/{step_id}/complete", response_class=HTMLResponse)
async def complete_step(step_id: int, request: Request, db: Session = Depends(get_db)):
step = db.query(MicroStep).filter(MicroStep.id == step_id).first()
if step:
step.status = MicroStepStatus.completed
db.commit()
open_session = _get_current_session(db)
if open_session:
open_session.steps_completed += 1
db.commit()
# Return next pending step
next_step = _next_micro_step(db)
if next_step:
return templates.TemplateResponse(
"partials/micro_step_card.html",
{"request": request, "step": next_step},
)
return HTMLResponse('<p class="buddy-message">Super! Keine weiteren Steps gerade. Goennung verdient.</p>')
@app.post("/steps/{step_id}/skip", response_class=HTMLResponse)
async def skip_step(step_id: int, request: Request, db: Session = Depends(get_db)):
step = db.query(MicroStep).filter(MicroStep.id == step_id).first()
if step:
step.status = MicroStepStatus.skipped
db.commit()
next_step = _next_micro_step(db)
if next_step:
buddy_msg = '<p class="buddy-skip-msg">Kein Stress, machen wir anders.</p>'
card = templates.TemplateResponse(
"partials/micro_step_card.html",
{"request": request, "step": next_step},
)
return HTMLResponse(buddy_msg + card.body.decode())
return HTMLResponse('<p class="buddy-message">Alles verschoben fuer jetzt β€” kein Stress.</p>')
@app.get("/steps/next", response_class=HTMLResponse)
async def next_step_partial(request: Request, db: Session = Depends(get_db)):
step = _next_micro_step(db)
if step:
return templates.TemplateResponse(
"partials/micro_step_card.html",
{"request": request, "step": step},
)
return HTMLResponse('<p class="buddy-message">Keine offenen Steps. Gut gemacht.</p>')
# ── session endpoints ──────────────────────────────────────────────────────
@app.get("/session/current", response_class=HTMLResponse)
async def session_current(request: Request, db: Session = Depends(get_db)):
_expire_stale_sessions(db)
last_session = _get_last_ended_session(db)
next_step = _next_micro_step(db)
return templates.TemplateResponse(
"partials/session_banner.html",
{"request": request, "last_session": last_session, "next_step": next_step},
)
@app.get("/session/history", response_class=HTMLResponse)
async def session_history(request: Request, db: Session = Depends(get_db)):
sessions = (
db.query(StudySession)
.filter(StudySession.ended_at != None) # noqa: E711
.order_by(StudySession.ended_at.desc())
.limit(5)
.all()
)
return templates.TemplateResponse(
"partials/progress_journey.html",
{"request": request, "sessions": sessions},
)
@app.post("/session/start", response_class=HTMLResponse)
async def session_start(
request: Request,
subject_id: int = Form(...),
db: Session = Depends(get_db),
):
_expire_stale_sessions(db)
existing = _get_current_session(db)
if existing and existing.subject_id == subject_id:
return HTMLResponse("") # already open
session = StudySession(subject_id=subject_id)
db.add(session)
db.commit()
return HTMLResponse("")
@app.post("/session/end", response_class=HTMLResponse)
async def session_end(db: Session = Depends(get_db)):
open_session = _get_current_session(db)
if open_session:
open_session.ended_at = _utcnow()
db.commit()
return HTMLResponse("")
# ── Elastic Plan + Panic Mode ───────────────────────────────────────────────
def is_panic_mode(deadline: Deadline) -> bool:
"""True when fewer than 48 hours remain or deadline is overdue."""
return (deadline.due_date - _utcnow()).total_seconds() < 48 * 3600
def is_overdue(deadline: Deadline) -> bool:
"""True when the deadline is in the past."""
return deadline.due_date < _utcnow()
def _deadline_steps(db: Session, deadline_id: int):
"""All non-skipped steps ordered by position."""
return (
db.query(MicroStep)
.filter(
MicroStep.deadline_id == deadline_id,
MicroStep.status != MicroStepStatus.skipped,
)
.order_by(MicroStep.order_num)
.all()
)
def _should_auto_compress(deadline: Deadline, pending_count: int) -> bool:
"""True when pending steps take longer than remaining time (with 20% buffer).
Overdue deadlines with pending steps always trigger a replan.
"""
hours_left = (deadline.due_date - _utcnow()).total_seconds() / 3600
if hours_left <= 0:
return pending_count > 0 # overdue with pending steps β†’ always compress
return pending_count * 5 > hours_left * 60 * 0.8
@app.get("/deadlines/{deadline_id}/plan", response_class=HTMLResponse)
async def deadline_plan(deadline_id: int, request: Request, db: Session = Depends(get_db)):
deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first()
if not deadline:
return HTMLResponse('<p class="buddy-warning">Deadline nicht gefunden.</p>')
steps = _deadline_steps(db, deadline_id)
pending = [s for s in steps if s.status == MicroStepStatus.pending]
buddy_message = None
# Auto-compress when pending steps exceed available time
if pending and _should_auto_compress(deadline, len(pending)):
hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600
try:
new_steps_data = await generate_micro_steps(
deadline.title,
deadline.description or "",
deadline.due_date,
hours_until_due,
)
db.query(MicroStep).filter(
MicroStep.deadline_id == deadline_id,
MicroStep.status == MicroStepStatus.pending,
).delete(synchronize_session=False)
db.commit()
completed_count = db.query(MicroStep).filter(
MicroStep.deadline_id == deadline_id,
MicroStep.status == MicroStepStatus.completed,
).count()
for i, s in enumerate(new_steps_data):
db.add(MicroStep(
deadline_id=deadline_id,
title=s["title"],
context=s.get("context", ""),
order_num=completed_count + i,
duration_minutes=s.get("duration_minutes", 5),
))
db.commit()
steps = _deadline_steps(db, deadline_id)
buddy_message = "Ich hab den Plan etwas gestrafft β€” passt besser zur verbleibenden Zeit."
except Exception:
pass
return templates.TemplateResponse(
"partials/elastic_plan.html",
{
"request": request,
"deadline": deadline,
"steps": steps,
"buddy_message": buddy_message,
"now": _utcnow(),
},
)
@app.post("/deadlines/{deadline_id}/replan", response_class=HTMLResponse)
async def replan_deadline(deadline_id: int, request: Request, db: Session = Depends(get_db)):
deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first()
if not deadline:
return HTMLResponse('<p class="buddy-warning">Deadline nicht gefunden.</p>')
hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600
# Remove skipped + pending; keep completed
db.query(MicroStep).filter(
MicroStep.deadline_id == deadline_id,
MicroStep.status.in_([MicroStepStatus.pending, MicroStepStatus.skipped]),
).delete(synchronize_session=False)
db.commit()
new_steps_data = await generate_micro_steps(
deadline.title,
deadline.description or "",
deadline.due_date,
hours_until_due,
)
completed_count = db.query(MicroStep).filter(
MicroStep.deadline_id == deadline_id,
MicroStep.status == MicroStepStatus.completed,
).count()
for i, s in enumerate(new_steps_data):
db.add(MicroStep(
deadline_id=deadline_id,
title=s["title"],
context=s.get("context", ""),
order_num=completed_count + i,
duration_minutes=s.get("duration_minutes", 5),
))
db.commit()
steps = _deadline_steps(db, deadline_id)
return templates.TemplateResponse(
"partials/elastic_plan.html",
{
"request": request,
"deadline": deadline,
"steps": steps,
"buddy_message": "Plan angepasst! Hier ist dein neuer Fahrplan.",
"now": _utcnow(),
},
)