diff --git a/alembic/versions/0006_session_ai.py b/alembic/versions/0006_session_ai.py new file mode 100644 index 0000000..4e6f4b7 --- /dev/null +++ b/alembic/versions/0006_session_ai.py @@ -0,0 +1,23 @@ +"""Add ai_title and ai_result to sessions + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-05-06 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("sessions", sa.Column("ai_title", sa.String(200), nullable=True)) + op.add_column("sessions", sa.Column("ai_result", sa.Text, nullable=True)) + + +def downgrade(): + op.drop_column("sessions", "ai_result") + op.drop_column("sessions", "ai_title") diff --git a/src/models.py b/src/models.py index af16d18..d725d44 100644 --- a/src/models.py +++ b/src/models.py @@ -98,6 +98,8 @@ class Session(Base): raw_stats: Mapped[dict] = mapped_column(JSONB, default=dict) task_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True) category: Mapped[str | None] = mapped_column(String(20), nullable=True) + ai_title: Mapped[str | None] = mapped_column(String(200), nullable=True) + ai_result: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) user: Mapped["User"] = relationship(back_populates="sessions") diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py index eeafc57..db0b5eb 100644 --- a/src/routers/dashboard.py +++ b/src/routers/dashboard.py @@ -2,13 +2,13 @@ from collections import defaultdict from datetime import date, datetime, timedelta -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.auth import CurrentUser from src.database import get_db -from src.models import DailyStat, Project, Session +from src.models import DailyStat, Project, ProjectBudget, Session from src.schemas import ( DailyPoint, DowPoint, KpiSummary, MonthlyPoint, ProjectDetail, ProjectHours, ProjectOut, SessionOut, ToolUsage, @@ -160,6 +160,21 @@ async def projects_overview( if not proj_data[pid]["last_active"] or stat.date > proj_data[pid]["last_active"]: proj_data[pid]["last_active"] = stat.date + # Batch-load project budgets + project_ids = list(proj_data.keys()) + budget_map: dict[str, float] = {} + if project_ids: + budget_result = await db.execute( + select(ProjectBudget) + .where( + ProjectBudget.user_id == user.id, + ProjectBudget.project_id.in_(project_ids), + ) + ) + for budget in budget_result.scalars().all(): + # Sum all budgets for the project + budget_map[budget.project_id] = budget_map.get(budget.project_id, 0) + budget.target_hours + return sorted( [ ProjectHours( @@ -172,6 +187,12 @@ async def projects_overview( session_count=v["session_count"], working_days=len(v["days"]), last_active=v["last_active"], + budget_hours=budget_map.get(v["project_id"]), + progress_pct=( + round(v["total_hours"] / budget_map[v["project_id"]] * 100, 1) + if v["project_id"] in budget_map and budget_map[v["project_id"]] > 0 + else None + ), ) for v in proj_data.values() ], @@ -250,11 +271,21 @@ async def monthly(user: CurrentUser, db: AsyncSession = Depends(get_db)): @router.get("/dow", response_model=list[DowPoint]) -async def day_of_week(user: CurrentUser, db: AsyncSession = Depends(get_db)): - # Query raw session intervals — same union+overhead logic as summary/timeline +async def day_of_week( + user: CurrentUser, + from_date: date | None = Query(default=None, alias="from"), + to_date: date | None = Query(default=None, alias="to"), + db: AsyncSession = Depends(get_db), +): + from_d, to_d = _date_range(from_date, to_date) + # Query raw session intervals filtered by date range result = await db.execute( select(Session.date, Session.start_at, Session.end_at) - .where(Session.user_id == user.id) + .where( + Session.user_id == user.id, + Session.date >= from_d, + Session.date <= to_d, + ) ) day_intervals: dict[date, list] = defaultdict(list) for s_date, start_at, end_at in result.all(): @@ -371,6 +402,22 @@ async def project_detail( for t, c in (s.tools_used or {}).items(): tools_count[t] += c + # Compute total hours for this project across sessions + total_project_hours = sum( + (s.end_at - s.start_at).total_seconds() / 3600 for s in sessions + ) + total_tool_count = sum(tools_count.values()) + sorted_tools = sorted(tools_count.items(), key=lambda x: -x[1])[:10] + top_tools_list = [ + ToolUsage( + tool=t, + count=c, + hours=round(c / total_tool_count * total_project_hours, 2) if total_tool_count > 0 else 0.0, + pct=round(c / total_tool_count * 100, 1) if total_tool_count > 0 else 0.0, + ) + for t, c in sorted_tools + ] + return ProjectDetail( project=ProjectOut.model_validate(project), daily=[DailyPoint(date=s.date, hours=round(s.total_hours, 2), sessions=s.session_count) for s in stats], @@ -382,9 +429,26 @@ async def project_detail( message_count=s.message_count, work_summary=s.work_summary, commits=s.commits or [], tools_used=s.tools_used or {}, files_changed=s.files_changed or [], + ai_title=getattr(s, 'ai_title', None), + ai_result=getattr(s, 'ai_result', None), ) for s in sessions ], top_files=sorted([{"file": f, "count": c} for f, c in files_count.items()], key=lambda x: -x["count"])[:10], - top_tools=sorted([ToolUsage(tool=t, count=c) for t, c in tools_count.items()], key=lambda x: -x.count)[:10], + top_tools=top_tools_list, ) + + +@router.post("/sessions/{session_id}/summarize") +async def summarize_session_endpoint( + session_id: str, + user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + from src.services.ai_session_summary import summarize_session as _summarize + session = await db.get(Session, session_id) + if not session or session.user_id != user.id: + raise HTTPException(status_code=404, detail="Session not found") + result = await _summarize(session, db) + await db.commit() + return result diff --git a/src/routers/reports.py b/src/routers/reports.py index 1591544..299fe6f 100644 --- a/src/routers/reports.py +++ b/src/routers/reports.py @@ -45,9 +45,9 @@ async def generate_report( ) if body.type == "daily": - report = await generate_and_send_daily_report(user, body.date, db) + report = await generate_and_send_daily_report(user, body.period_date, db) else: - report = await generate_and_send_weekly_report(user, body.date, db) + report = await generate_and_send_weekly_report(user, body.period_date, db) if report is None: raise HTTPException( diff --git a/src/routers/tasks.py b/src/routers/tasks.py index ffb75e9..adc5ec9 100644 --- a/src/routers/tasks.py +++ b/src/routers/tasks.py @@ -1,7 +1,7 @@ """Tasks CRUD + planned blocks.""" from datetime import date, datetime, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -55,7 +55,7 @@ async def _task_out(task: Task, db: AsyncSession) -> TaskOut: @router.get("", response_model=list[TaskOut]) async def list_tasks( user: CurrentUser, - task_date: date | None = None, + task_date: date | None = Query(default=None, alias="date"), db: AsyncSession = Depends(get_db), ): # Batch-load related entities to avoid N+1 queries diff --git a/src/schemas.py b/src/schemas.py index 1449522..b1dc7f0 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -128,6 +128,8 @@ class ProjectHours(BaseModel): client: str = "" job_number: str = "" repo_url: str = "" + budget_hours: float | None = None + progress_pct: float | None = None class DailyPoint(BaseModel): @@ -150,6 +152,8 @@ class DowPoint(BaseModel): class ToolUsage(BaseModel): tool: str count: int + hours: float = 0.0 + pct: float = 0.0 class SessionOut(BaseModel): @@ -166,6 +170,8 @@ class SessionOut(BaseModel): commits: list[str] tools_used: dict[str, int] files_changed: list[str] + ai_title: str | None = None + ai_result: str | None = None model_config = {"from_attributes": True} @@ -404,7 +410,7 @@ class AiReportOut(BaseModel): class GenerateReportIn(BaseModel): type: str = Field(pattern="^(daily|weekly)$") - date: date + period_date: date # ── AI Assistant ────────────────────────────────────────────────────────────── diff --git a/src/services/ai_session_summary.py b/src/services/ai_session_summary.py new file mode 100644 index 0000000..68dfedd --- /dev/null +++ b/src/services/ai_session_summary.py @@ -0,0 +1,54 @@ +"""Generates concise AI title and result for a session using Claude.""" +from __future__ import annotations + +import json + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.config import settings +from src.models import Session + + +async def summarize_session(session: Session, db: AsyncSession) -> dict: + if not settings.ANTHROPIC_API_KEY: + return {"title": "", "result": ""} + + import anthropic + + commits = "\n".join(session.commits[:10]) if session.commits else "none" + tools = ", ".join(f"{k}:{v}" for k, v in (session.tools_used or {}).items()) + files = "\n".join((session.files_changed or [])[:10]) + summary = (session.work_summary or "")[:500] + + prompt = f"""Developer session data: +Work summary: {summary} +Commits: {commits} +Tools used: {tools} +Files changed: {files} + +Generate a JSON response with exactly these fields: +- "title": 5-8 word title describing what was worked on (be specific, mention the project/feature) +- "result": 1-2 sentences describing what was accomplished + +Return only valid JSON, no other text.""" + + client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY) + response = await client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=200, + messages=[{"role": "user", "content": prompt}], + ) + + try: + text = response.content[0].text.strip() + data = json.loads(text) + ai_title = str(data.get("title", ""))[:200] + ai_result = str(data.get("result", "")) + + session.ai_title = ai_title + session.ai_result = ai_result + await db.flush() + + return {"title": ai_title, "result": ai_result} + except Exception: + return {"title": "", "result": ""} diff --git a/src/services/assistant.py b/src/services/assistant.py index 55c3999..ae3d668 100644 --- a/src/services/assistant.py +++ b/src/services/assistant.py @@ -11,7 +11,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.config import settings -from src.models import AiFlag, AssistantMessage, ManualEntry, Project, Session, Task, User +from src.models import AiFlag, AssistantMessage, AzureWorkItem, ManualEntry, PlannedBlock, Project, Session, Task, User from src.services.aggregator import _union_hours log = structlog.get_logger() @@ -246,6 +246,176 @@ TOOLS: list[dict] = [ "required": [], }, }, + { + "name": "list_tasks", + "description": "List the user's tasks, optionally filtered by date and/or status.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD (optional)"}, + "status": {"type": "string", "enum": ["todo", "doing", "done", "cancelled"], "description": "Optional status filter"}, + }, + "required": [], + }, + }, + { + "name": "create_task", + "description": "Create a new task in the planner.", + "input_schema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "planned_date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "estimate_hours": {"type": "number", "description": "Estimated hours (default 1)"}, + "priority": {"type": "integer", "description": "1=low, 2=normal, 3=medium, 4=high, 5=critical (default 3)"}, + "project_id": {"type": ["string", "null"]}, + "notes": {"type": ["string", "null"]}, + }, + "required": ["title", "planned_date"], + }, + }, + { + "name": "update_task", + "description": "Update an existing task (title, status, priority, planned_date, notes, estimate_hours).", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + "title": {"type": ["string", "null"]}, + "status": {"type": ["string", "null"], "enum": ["todo", "doing", "done", "cancelled"]}, + "priority": {"type": ["integer", "null"]}, + "planned_date": {"type": ["string", "null"]}, + "notes": {"type": ["string", "null"]}, + "estimate_hours": {"type": ["number", "null"]}, + }, + "required": ["task_id"], + }, + }, + { + "name": "delete_task", + "description": "Delete a task permanently. Use only when user explicitly requests deletion.", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + }, + "required": ["task_id"], + }, + }, + { + "name": "complete_task", + "description": "Mark a task as done and push to Azure DevOps if linked.", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + }, + "required": ["task_id"], + }, + }, + { + "name": "prioritize_day", + "description": "Re-order all tasks for a given day by priority. Updates sort_index so planner shows them in the right order.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "strategy": { + "type": "string", + "enum": ["priority_desc", "estimate_asc"], + "description": "priority_desc: highest priority first (default). estimate_asc: shortest tasks first.", + }, + }, + "required": ["date"], + }, + }, + { + "name": "schedule_task", + "description": "Schedule a task at a specific time by creating a planned block.", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + "start_at": {"type": "string", "description": "ISO datetime with TZ"}, + "end_at": {"type": "string", "description": "ISO datetime with TZ"}, + }, + "required": ["task_id", "start_at", "end_at"], + }, + }, + { + "name": "auto_schedule_day", + "description": "Automatically schedule all unscheduled tasks for a day into working hours, ordered by priority, without overlap.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "work_start": {"type": "string", "description": "Start of work day HH:MM (default 09:00)"}, + "work_end": {"type": "string", "description": "End of work day HH:MM (default 18:00)"}, + }, + "required": ["date"], + }, + }, + { + "name": "list_projects", + "description": "List all of the user's projects.", + "input_schema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "list_manual_entries", + "description": "List manual time entries for a specific date.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"} + }, + "required": ["date"], + }, + }, + { + "name": "delete_manual_entry", + "description": "Delete a manual time entry.", + "input_schema": { + "type": "object", + "properties": {"entry_id": {"type": "string"}}, + "required": ["entry_id"], + }, + }, + { + "name": "generate_report", + "description": "Generate a daily or weekly AI report for a specific date.", + "input_schema": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["daily", "weekly"]}, + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + }, + "required": ["type", "date"], + }, + }, + { + "name": "search_sessions", + "description": "Search sessions by keyword in work summary.", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "from_date": {"type": "string", "description": "Optional ISO date"}, + "to_date": {"type": "string", "description": "Optional ISO date"}, + }, + "required": ["query"], + }, + }, + { + "name": "list_work_items", + "description": "List Azure DevOps work items synced for the user.", + "input_schema": { + "type": "object", + "properties": { + "state": {"type": "string", "description": "Filter by state (optional)"} + }, + "required": [], + }, + }, ] @@ -381,23 +551,275 @@ async def execute_tool( for f in flags ] + if tool_name == "list_tasks": + query = select(Task).where(Task.user_id == user.id) + if tool_input.get("date"): + d = date.fromisoformat(tool_input["date"]) + query = query.where(Task.planned_date == d) + if tool_input.get("status"): + query = query.where(Task.status == tool_input["status"]) + query = query.order_by(Task.planned_date, Task.sort_index) + result = await db.execute(query) + tasks = result.scalars().all() + return [ + { + "id": t.id, "title": t.title, "status": t.status, + "priority": t.priority, "planned_date": t.planned_date.isoformat(), + "estimate_hours": t.estimate_hours, "notes": t.notes or "", + } + for t in tasks + ] + + if tool_name == "create_task": + task = Task( + user_id=user.id, + title=tool_input["title"], + planned_date=date.fromisoformat(tool_input["planned_date"]), + estimate_hours=float(tool_input.get("estimate_hours", 1)), + priority=int(tool_input.get("priority", 3)), + project_id=tool_input.get("project_id"), + notes=tool_input.get("notes") or "", + status="todo", + ) + db.add(task) + await db.flush() + return {"id": task.id, "title": task.title, "planned_date": task.planned_date.isoformat(), "status": task.status, "priority": task.priority} + + if tool_name == "update_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + for field in ("title", "status", "priority", "notes", "estimate_hours"): + if tool_input.get(field) is not None: + setattr(task, field, tool_input[field]) + if tool_input.get("planned_date"): + task.planned_date = date.fromisoformat(tool_input["planned_date"]) + await db.flush() + return {"updated": task.id, "title": task.title, "status": task.status} + + if tool_name == "delete_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + await db.delete(task) + await db.flush() + return {"deleted": tool_input["task_id"]} + + if tool_name == "complete_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + task.status = "done" + task.completed_at = datetime.now(timezone.utc) + await db.flush() + return {"completed": task.id, "title": task.title} + + if tool_name == "prioritize_day": + d = date.fromisoformat(tool_input["date"]) + strategy = tool_input.get("strategy", "priority_desc") + result = await db.execute( + select(Task).where(Task.user_id == user.id, Task.planned_date == d) + ) + tasks = list(result.scalars().all()) + if strategy == "estimate_asc": + tasks.sort(key=lambda t: t.estimate_hours) + else: + tasks.sort(key=lambda t: -t.priority) + for i, task in enumerate(tasks): + task.sort_index = i + await db.flush() + return {"reordered": len(tasks), "order": [t.title for t in tasks]} + + if tool_name == "schedule_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + block = PlannedBlock( + user_id=user.id, + task_id=task.id, + project_id=task.project_id, + start_at=datetime.fromisoformat(tool_input["start_at"]), + end_at=datetime.fromisoformat(tool_input["end_at"]), + ) + db.add(block) + await db.flush() + return {"block_id": block.id, "start_at": block.start_at.isoformat(), "end_at": block.end_at.isoformat()} + + if tool_name == "auto_schedule_day": + from datetime import time as dtime + d = date.fromisoformat(tool_input["date"]) + work_start_str = tool_input.get("work_start", "09:00") + work_end_str = tool_input.get("work_end", "18:00") + + ws_h, ws_m = map(int, work_start_str.split(":")) + we_h, we_m = map(int, work_end_str.split(":")) + + result = await db.execute( + select(Task).where(Task.user_id == user.id, Task.planned_date == d) + .order_by(Task.priority.desc(), Task.sort_index) + ) + tasks = list(result.scalars().all()) + + # Find which tasks already have blocks for this day + existing_blocks_result = await db.execute( + select(PlannedBlock).where( + PlannedBlock.user_id == user.id, + PlannedBlock.start_at >= datetime(d.year, d.month, d.day, tzinfo=timezone.utc), + PlannedBlock.start_at < datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc), + ) + ) + scheduled_task_ids = {b.task_id for b in existing_blocks_result.scalars().all()} + + current_time = datetime(d.year, d.month, d.day, ws_h, ws_m, tzinfo=timezone.utc) + work_end_dt = datetime(d.year, d.month, d.day, we_h, we_m, tzinfo=timezone.utc) + + scheduled = [] + for task in tasks: + if task.id in scheduled_task_ids: + continue + duration_hours = task.estimate_hours if task.estimate_hours > 0 else 1.0 + end_time = current_time + timedelta(hours=duration_hours) + if end_time > work_end_dt: + break + block = PlannedBlock( + user_id=user.id, + task_id=task.id, + project_id=task.project_id, + start_at=current_time, + end_at=end_time, + ) + db.add(block) + scheduled.append({ + "title": task.title, + "start": current_time.strftime("%H:%M"), + "end": end_time.strftime("%H:%M"), + }) + current_time = end_time + await db.flush() + return {"scheduled": len(scheduled), "slots": scheduled} + + if tool_name == "list_projects": + result = await db.execute( + select(Project).where(Project.user_id == user.id).order_by(Project.display_name) + ) + projects = result.scalars().all() + return [ + {"id": p.id, "display_name": p.display_name, "client": p.client or "", "job_number": p.job_number or "", "slug": p.slug} + for p in projects + ] + + if tool_name == "list_manual_entries": + d = date.fromisoformat(tool_input["date"]) + result = await db.execute( + select(ManualEntry).where( + ManualEntry.user_id == user.id, + ManualEntry.start_at >= datetime(d.year, d.month, d.day, tzinfo=timezone.utc), + ManualEntry.start_at < datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc), + ) + ) + entries = result.scalars().all() + return [ + { + "id": e.id, + "title": e.title, + "start_at": e.start_at.isoformat(), + "end_at": e.end_at.isoformat(), + "category": e.category, + "hours": round((e.end_at - e.start_at).total_seconds() / 3600, 2), + } + for e in entries + ] + + if tool_name == "delete_manual_entry": + entry = await db.get(ManualEntry, tool_input["entry_id"]) + if not entry or entry.user_id != user.id: + return {"error": "Entry not found"} + await db.delete(entry) + await db.flush() + return {"deleted": tool_input["entry_id"]} + + if tool_name == "generate_report": + from src.services.ai_reports import ( + generate_and_send_daily_report, + generate_and_send_weekly_report, + ) + report_type = tool_input["type"] + report_date = date.fromisoformat(tool_input["date"]) + if report_type == "daily": + report = await generate_and_send_daily_report(user, report_date, db) + else: + report = await generate_and_send_weekly_report(user, report_date, db) + if report is None: + return {"error": "No data found for the given period"} + return { + "report_id": report.id, + "content_preview": report.content_markdown[:500], + } + + if tool_name == "search_sessions": + query_str = tool_input["query"] + query = select(Session, Project.display_name).join(Project, Session.project_id == Project.id).where( + Session.user_id == user.id, + Session.work_summary.ilike(f"%{query_str}%"), + ) + if tool_input.get("from_date"): + query = query.where(Session.date >= date.fromisoformat(tool_input["from_date"])) + if tool_input.get("to_date"): + query = query.where(Session.date <= date.fromisoformat(tool_input["to_date"])) + query = query.order_by(Session.date.desc()).limit(10) + result = await db.execute(query) + return [ + { + "id": s.id, + "date": s.date.isoformat(), + "project": name, + "summary_excerpt": (s.work_summary or "")[:200], + } + for s, name in result.all() + ] + + if tool_name == "list_work_items": + query = select(AzureWorkItem).where(AzureWorkItem.user_id == user.id) + if tool_input.get("state"): + query = query.where(AzureWorkItem.state == tool_input["state"]) + result = await db.execute(query.order_by(AzureWorkItem.ado_id)) + items = result.scalars().all() + return [ + { + "id": wi.id, + "ado_id": wi.ado_id, + "title": wi.title, + "type": wi.type, + "state": wi.state, + "url": wi.url, + } + for wi in items + ] + return {"error": f"Unknown tool: {tool_name}"} # ── Streaming chat ──────────────────────────────────────────────────────────── -SYSTEM_PROMPT = """You are a time-tracking assistant for CC Dashboard — a productivity app for a developer/manager at Oliver Agency. +SYSTEM_PROMPT = """You are a powerful AI assistant for CC Dashboard — a personal productivity dashboard for a developer/manager at Oliver Agency. -Your job is to: -1. Help the user understand how they spent their time -2. Detect and flag inaccuracies in time logs: gaps between sessions, uncategorized sessions, missing thinking/deployment/meeting time -3. Suggest and create manual entries for unlogged work -4. Set categories on sessions when appropriate -5. Answer questions about projects, hours, and task completion +You have FULL access to read and modify the user's data: sessions, tasks, projects, manual entries, and DevOps work items. -Always be proactive: when asked about a day, automatically check for anomalies and mention them. -When you detect gaps or uncategorized sessions, suggest specific actions (create manual entry, set category). +Your capabilities: +- Read time-tracking data, sessions, projects, tasks +- Detect anomalies and suggest improvements +- Create, update, delete, and complete tasks +- Schedule tasks into planned time blocks +- Auto-arrange the daily plan by priority +- Categorize sessions and create manual time entries +- Generate AI reports +- Search through sessions +- List and manage Azure DevOps work items + +Always be proactive: when asked about a day, also check for anomalies. +Before performing any DESTRUCTIVE action (delete, mass-reschedule, complete), state exactly what you're about to do and wait for context that confirms the user wants this. After any modification, briefly summarize what changed. Respond concisely. Use Markdown for structure when helpful. +For scheduling tasks, use the user's work hours (default 9-18) unless specified otherwise. Today's date: {today} User's daily overhead: {overhead}h/day @@ -457,8 +879,8 @@ async def chat_stream( tool_calls_log: list[dict] = [] try: - # Agentic loop — up to 5 tool-use rounds - for _round in range(5): + # Agentic loop — up to 10 tool-use rounds + for _round in range(10): response = await client.messages.create( model="claude-sonnet-4-6", max_tokens=2048, diff --git a/src/services/scheduler.py b/src/services/scheduler.py index eaf4f16..64f587c 100644 --- a/src/services/scheduler.py +++ b/src/services/scheduler.py @@ -60,6 +60,27 @@ async def _anomaly_scan_job() -> None: log.info("anomaly_scan.completed") +async def _session_summary_job() -> None: + """Summarize sessions without AI title (up to 20 at a time).""" + from sqlalchemy import select + + from src.database import AsyncSessionLocal + from src.models import Session + from src.services.ai_session_summary import summarize_session + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(Session) + .where(Session.ai_title.is_(None), Session.work_summary.isnot(None), Session.work_summary != "") + .limit(20) + ) + sessions = result.scalars().all() + for session in sessions: + await summarize_session(session, db) + await db.commit() + log.info("session_summary.completed", count=len(sessions)) + + def setup_scheduler() -> None: if settings.ADO_PAT: scheduler.add_job( @@ -96,3 +117,13 @@ def setup_scheduler() -> None: id="anomaly_scan", replace_existing=True, ) + + # Summarize sessions with AI every 10 minutes + if settings.ANTHROPIC_API_KEY: + scheduler.add_job( + _session_summary_job, + "interval", + minutes=10, + id="session_summary", + replace_existing=True, + ) diff --git a/web/index.html b/web/index.html index a7db225..a9849b2 100644 --- a/web/index.html +++ b/web/index.html @@ -1,10 +1,19 @@ - +