Download main.py from smlflg/StudyAgent: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/smlflg/StudyAgent/resolve/main/main.py
- Command line
-
hf download hf://smlflg/StudyAgent/main.py
-
curl -L -o main.py https://huggingface.co/smlflg/StudyAgent/resolve/main/main.py
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) | |
| 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() | |
| 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(), | |
| }, | |
| ) | |
| 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}, | |
| ) | |
| 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()}, | |
| ) | |
| 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)) | |
| 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("") | |
| 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>') | |
| 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>') | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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}, | |
| ) | |
| 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}, | |
| ) | |
| 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("") | |
| 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 | |
| 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(), | |
| }, | |
| ) | |
| 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(), | |
| }, | |
| ) | |