Phase 1 — Contract bugs:
- Add progress_pct/budget_hours to ProjectHours schema and compute from ProjectBudget
- Add hours/pct to ToolUsage schema, compute in project_detail endpoint
- Fix ProjectDetailView to use correct nested data shape (data.project.*, data.sessions, etc.)
- Fix GenerateReportIn: rename field date → period_date; update reports router
- Fix tasks list date filter: use Query(alias='date') instead of positional arg
- Fix AzureIntegration/AzureWorkItem types: org→organization, id type, ado_id, sync_enabled
- Fix devops API payload and SettingsView to use organization field
- Fix TaskForm ADO work item selector to use wi.ado_id for display, wi.id for value
- Add light theme CSS variables in :root, keep dark in .dark class
- Remove hardcoded class='dark' from HTML, add theme persistence script
- TopBar: persist dark/light to localStorage on toggle
- DashboardView: switch monthly() → timeline() endpoint for charts
- DOW endpoint: add from/to date range filtering
Phase 2 — Planner:
- Add projects API endpoint and Pinia store
- Add project picker to TaskForm
- Fix ADO work item display (#ado_id not #id)
Phase 3 — Calendar:
- getWeekDays() accepts weekLength 5|7 parameter
- Calendar store: add weekLength ref, setWeekLength(), update fetchCurrentView range
- CalendarToolbar: add 5d/7d toggle buttons; fix dateLabel to use days[days.length-1]
- CalendarView: clicking session block navigates to project-detail/:id/:date
- project-detail route: add optional :date? param; ProjectDetailView filters by date
- DnD resize: send start_at alongside end_at (PlannedBlockIn requires both fields)
Phase 4 — AI session summaries:
- Add ai_title/ai_result columns to Session model
- Alembic migration 0006 for new columns
- New ai_session_summary service using Claude Haiku
- Session summarize endpoint: POST /api/dashboard/sessions/{id}/summarize
- Scheduler job: summarize sessions without ai_title every 10 minutes
- SessionOut schema: add ai_title/ai_result fields
- ProjectDetailView: show ai_title as primary, ai_result as subtitle; sparkle button to generate
Phase 5 — Expanded AI assistant:
- Add 14 new tools: list/create/update/delete/complete tasks, prioritize_day,
schedule_task, auto_schedule_day, list_projects, list/delete manual entries,
generate_report, search_sessions, list_work_items
- Import PlannedBlock and AzureWorkItem in assistant service
- Update SYSTEM_PROMPT to describe full agent capabilities
- Agentic loop: 5 → 10 rounds max
- AssistantWidget: add tool labels for all new tools, update quick hints
New files: DevopsView.vue, projects store/API, ai_session_summary.py, migration 0006
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
948 lines
37 KiB
Python
948 lines
37 KiB
Python
"""AI assistant service: gap detection, anomaly analysis, tool-use chat."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from collections import defaultdict
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any, AsyncIterator
|
||
|
||
import structlog
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.config import settings
|
||
from src.models import AiFlag, AssistantMessage, AzureWorkItem, ManualEntry, PlannedBlock, Project, Session, Task, User
|
||
from src.services.aggregator import _union_hours
|
||
|
||
log = structlog.get_logger()
|
||
|
||
# ── Gap detection constants ───────────────────────────────────────────────────
|
||
GAP_THRESHOLD_MINUTES = 30 # gaps between sessions longer than this may be unlogged work
|
||
LONG_SESSION_HOURS = 8 # single session wall-clock > this is suspicious
|
||
LONG_DAY_HOURS = 14 # total day wall-clock > this is suspicious
|
||
RATIO_THRESHOLD = 0.4 # active_hours / wall_clock < 40% → likely thinking time
|
||
|
||
|
||
# ── Gap / anomaly detection ───────────────────────────────────────────────────
|
||
|
||
async def detect_day_anomalies(
|
||
user: User,
|
||
check_date: date,
|
||
db: AsyncSession,
|
||
) -> list[dict[str, Any]]:
|
||
"""Analyse a single day and return list of anomaly dicts (not persisted)."""
|
||
sessions_result = await db.execute(
|
||
select(Session, Project.display_name)
|
||
.join(Project, Session.project_id == Project.id)
|
||
.where(Session.user_id == user.id, Session.date == check_date)
|
||
.order_by(Session.start_at)
|
||
)
|
||
rows = sessions_result.all()
|
||
|
||
anomalies: list[dict[str, Any]] = []
|
||
|
||
if not rows:
|
||
return anomalies
|
||
|
||
intervals = [(s.start_at, s.end_at) for s, _ in rows]
|
||
total_wall = _union_hours(intervals)
|
||
|
||
# Suspiciously long day
|
||
if total_wall > LONG_DAY_HOURS:
|
||
anomalies.append({
|
||
"kind": "long_session",
|
||
"description": f"Day total {total_wall:.1f}h exceeds {LONG_DAY_HOURS}h — verify all entries",
|
||
"entity_type": "day",
|
||
"entity_id": check_date.isoformat(),
|
||
})
|
||
|
||
# Per-session checks
|
||
for s, display_name in rows:
|
||
wall = (s.end_at - s.start_at).total_seconds() / 3600
|
||
|
||
if wall > LONG_SESSION_HOURS:
|
||
anomalies.append({
|
||
"kind": "long_session",
|
||
"description": (
|
||
f"Session on {display_name} lasted {wall:.1f}h "
|
||
f"({s.start_at.strftime('%H:%M')}–{s.end_at.strftime('%H:%M')}). "
|
||
"Consider splitting into coding + thinking/deployment entries."
|
||
),
|
||
"entity_type": "session",
|
||
"entity_id": s.id,
|
||
})
|
||
|
||
if wall > 0 and (s.active_hours or 0) / wall < RATIO_THRESHOLD:
|
||
anomalies.append({
|
||
"kind": "uncategorized",
|
||
"description": (
|
||
f"Session on {display_name}: active_hours ({s.active_hours:.1f}h) is only "
|
||
f"{s.active_hours / wall * 100:.0f}% of wall-clock ({wall:.1f}h). "
|
||
"The difference might be thinking/review time — add a manual entry."
|
||
),
|
||
"entity_type": "session",
|
||
"entity_id": s.id,
|
||
})
|
||
|
||
if not s.category:
|
||
anomalies.append({
|
||
"kind": "uncategorized",
|
||
"description": f"Session on {display_name} ({s.start_at.strftime('%H:%M')}–{s.end_at.strftime('%H:%M')}) has no category.",
|
||
"entity_type": "session",
|
||
"entity_id": s.id,
|
||
})
|
||
|
||
# Gaps between consecutive sessions
|
||
sorted_intervals = sorted(intervals, key=lambda x: x[0])
|
||
for i in range(1, len(sorted_intervals)):
|
||
prev_end = sorted_intervals[i - 1][1]
|
||
curr_start = sorted_intervals[i][0]
|
||
gap_min = (curr_start - prev_end).total_seconds() / 60
|
||
if gap_min >= GAP_THRESHOLD_MINUTES:
|
||
anomalies.append({
|
||
"kind": "gap",
|
||
"description": (
|
||
f"Gap of {gap_min:.0f} min between sessions "
|
||
f"({prev_end.strftime('%H:%M')}–{curr_start.strftime('%H:%M')}). "
|
||
"Was this a meeting, call, or thinking time? Consider adding a manual entry."
|
||
),
|
||
"entity_type": "day",
|
||
"entity_id": check_date.isoformat(),
|
||
})
|
||
|
||
return anomalies
|
||
|
||
|
||
async def persist_flags(
|
||
user: User,
|
||
check_date: date,
|
||
anomalies: list[dict[str, Any]],
|
||
db: AsyncSession,
|
||
) -> list[AiFlag]:
|
||
"""Upsert-like: clear old unresolved flags for the date then re-insert."""
|
||
existing = await db.execute(
|
||
select(AiFlag).where(
|
||
AiFlag.user_id == user.id,
|
||
AiFlag.flag_date == check_date,
|
||
AiFlag.resolved == False, # noqa: E712
|
||
)
|
||
)
|
||
for flag in existing.scalars().all():
|
||
await db.delete(flag)
|
||
|
||
flags = []
|
||
for a in anomalies:
|
||
flag = AiFlag(
|
||
user_id=user.id,
|
||
flag_date=check_date,
|
||
kind=a["kind"],
|
||
description=a["description"],
|
||
entity_type=a.get("entity_type"),
|
||
entity_id=a.get("entity_id"),
|
||
)
|
||
db.add(flag)
|
||
flags.append(flag)
|
||
|
||
await db.flush()
|
||
return flags
|
||
|
||
|
||
# ── Anthropic tool definitions ────────────────────────────────────────────────
|
||
|
||
TOOLS: list[dict] = [
|
||
{
|
||
"name": "get_daily_summary",
|
||
"description": "Get total hours, session count, projects worked, and tasks for a specific date.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"date": {"type": "string", "description": "ISO date YYYY-MM-DD"}
|
||
},
|
||
"required": ["date"],
|
||
},
|
||
},
|
||
{
|
||
"name": "get_sessions",
|
||
"description": "Get raw session details for a date range including start/end times, projects, summaries, and categories.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"from_date": {"type": "string", "description": "Start date YYYY-MM-DD"},
|
||
"to_date": {"type": "string", "description": "End date YYYY-MM-DD"},
|
||
},
|
||
"required": ["from_date", "to_date"],
|
||
},
|
||
},
|
||
{
|
||
"name": "get_project_stats",
|
||
"description": "Get total hours logged per project for a date range.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"from_date": {"type": "string", "description": "Start date YYYY-MM-DD"},
|
||
"to_date": {"type": "string", "description": "End date YYYY-MM-DD"},
|
||
},
|
||
"required": ["from_date", "to_date"],
|
||
},
|
||
},
|
||
{
|
||
"name": "detect_anomalies",
|
||
"description": (
|
||
"Analyse a day for time-tracking anomalies: large gaps between sessions, "
|
||
"sessions without categories, unusually long sessions, low active/wall-clock ratio."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"date": {"type": "string", "description": "ISO date YYYY-MM-DD"}
|
||
},
|
||
"required": ["date"],
|
||
},
|
||
},
|
||
{
|
||
"name": "create_manual_entry",
|
||
"description": (
|
||
"Create a manual time entry (e.g. for a meeting, deployment, thinking session). "
|
||
"project_id is optional — pass null if unknown."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {"type": "string"},
|
||
"start_at": {"type": "string", "description": "ISO datetime with TZ, e.g. 2026-05-06T14:00:00+00:00"},
|
||
"end_at": {"type": "string", "description": "ISO datetime with TZ"},
|
||
"category": {
|
||
"type": "string",
|
||
"enum": ["coding", "thinking", "deployment", "meeting", "review", "other"],
|
||
},
|
||
"project_id": {"type": ["string", "null"]},
|
||
},
|
||
"required": ["title", "start_at", "end_at", "category"],
|
||
},
|
||
},
|
||
{
|
||
"name": "set_session_category",
|
||
"description": "Set the category of a specific session.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"session_id": {"type": "string"},
|
||
"category": {
|
||
"type": "string",
|
||
"enum": ["coding", "thinking", "deployment", "meeting", "review", "other"],
|
||
},
|
||
},
|
||
"required": ["session_id", "category"],
|
||
},
|
||
},
|
||
{
|
||
"name": "get_unresolved_flags",
|
||
"description": "Get all unresolved AI-detected anomaly flags for the user.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"days_back": {"type": "integer", "description": "How many days back to look (default 7)"}
|
||
},
|
||
"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": [],
|
||
},
|
||
},
|
||
]
|
||
|
||
|
||
# ── Tool execution ────────────────────────────────────────────────────────────
|
||
|
||
async def execute_tool(
|
||
tool_name: str,
|
||
tool_input: dict,
|
||
user: User,
|
||
db: AsyncSession,
|
||
) -> Any:
|
||
"""Execute an assistant tool call and return a JSON-serialisable result."""
|
||
today = datetime.now(timezone.utc).date()
|
||
|
||
if tool_name == "get_daily_summary":
|
||
d = date.fromisoformat(tool_input["date"])
|
||
sessions_result = await db.execute(
|
||
select(Session, Project.display_name)
|
||
.join(Project, Session.project_id == Project.id)
|
||
.where(Session.user_id == user.id, Session.date == d)
|
||
.order_by(Session.start_at)
|
||
)
|
||
rows = sessions_result.all()
|
||
intervals = [(s.start_at, s.end_at) for s, _ in rows]
|
||
total = _union_hours(intervals) + (user.daily_overhead_hours if intervals else 0)
|
||
tasks_result = await db.execute(
|
||
select(Task).where(Task.user_id == user.id, Task.planned_date == d)
|
||
)
|
||
tasks = tasks_result.scalars().all()
|
||
projects: dict[str, float] = {}
|
||
for s, name in rows:
|
||
h = (s.end_at - s.start_at).total_seconds() / 3600
|
||
projects[name] = projects.get(name, 0) + h
|
||
return {
|
||
"date": d.isoformat(),
|
||
"total_hours": round(total, 2),
|
||
"session_count": len(rows),
|
||
"projects": {k: round(v, 2) for k, v in projects.items()},
|
||
"tasks_done": sum(1 for t in tasks if t.status == "done"),
|
||
"tasks_total": len(tasks),
|
||
}
|
||
|
||
if tool_name == "get_sessions":
|
||
fd = date.fromisoformat(tool_input["from_date"])
|
||
td = date.fromisoformat(tool_input["to_date"])
|
||
result = await db.execute(
|
||
select(Session, Project.display_name, Project.job_number)
|
||
.join(Project, Session.project_id == Project.id)
|
||
.where(Session.user_id == user.id, Session.date >= fd, Session.date <= td)
|
||
.order_by(Session.start_at)
|
||
)
|
||
return [
|
||
{
|
||
"id": s.id,
|
||
"date": s.date.isoformat(),
|
||
"project": name,
|
||
"job_number": job_number,
|
||
"start": s.start_at.isoformat(),
|
||
"end": s.end_at.isoformat(),
|
||
"wall_clock_h": round((s.end_at - s.start_at).total_seconds() / 3600, 2),
|
||
"active_h": round(s.active_hours, 2),
|
||
"category": s.category,
|
||
"summary": s.work_summary[:200] if s.work_summary else "",
|
||
}
|
||
for s, name, job_number in result.all()
|
||
]
|
||
|
||
if tool_name == "get_project_stats":
|
||
fd = date.fromisoformat(tool_input["from_date"])
|
||
td = date.fromisoformat(tool_input["to_date"])
|
||
result = await db.execute(
|
||
select(Session, Project.display_name, Project.job_number)
|
||
.join(Project, Session.project_id == Project.id)
|
||
.where(Session.user_id == user.id, Session.date >= fd, Session.date <= td)
|
||
)
|
||
by_project: dict[str, list] = defaultdict(list)
|
||
for s, name, job_number in result.all():
|
||
label = f"{job_number} {name}".strip() if job_number else name
|
||
by_project[label].append((s.start_at, s.end_at))
|
||
return {
|
||
label: round(sum((e - st).total_seconds() / 3600 for st, e in intervals), 2)
|
||
for label, intervals in sorted(by_project.items(), key=lambda x: -sum((e - s).total_seconds() for s, e in x[1]))
|
||
}
|
||
|
||
if tool_name == "detect_anomalies":
|
||
d = date.fromisoformat(tool_input["date"])
|
||
anomalies = await detect_day_anomalies(user, d, db)
|
||
return {"date": d.isoformat(), "anomalies": anomalies, "count": len(anomalies)}
|
||
|
||
if tool_name == "create_manual_entry":
|
||
entry = ManualEntry(
|
||
user_id=user.id,
|
||
project_id=tool_input.get("project_id"),
|
||
start_at=datetime.fromisoformat(tool_input["start_at"]),
|
||
end_at=datetime.fromisoformat(tool_input["end_at"]),
|
||
title=tool_input["title"],
|
||
category=tool_input.get("category"),
|
||
source="assistant",
|
||
)
|
||
db.add(entry)
|
||
await db.flush()
|
||
hours = (entry.end_at - entry.start_at).total_seconds() / 3600
|
||
return {"created": entry.id, "hours": round(hours, 2), "title": entry.title}
|
||
|
||
if tool_name == "set_session_category":
|
||
session = await db.get(Session, tool_input["session_id"])
|
||
if not session or session.user_id != user.id:
|
||
return {"error": "Session not found"}
|
||
session.category = tool_input["category"]
|
||
await db.flush()
|
||
return {"updated": session.id, "category": session.category}
|
||
|
||
if tool_name == "get_unresolved_flags":
|
||
days_back = int(tool_input.get("days_back", 7))
|
||
since = today - timedelta(days=days_back)
|
||
result = await db.execute(
|
||
select(AiFlag).where(
|
||
AiFlag.user_id == user.id,
|
||
AiFlag.flag_date >= since,
|
||
AiFlag.resolved == False, # noqa: E712
|
||
).order_by(AiFlag.flag_date.desc())
|
||
)
|
||
flags = result.scalars().all()
|
||
return [
|
||
{
|
||
"id": f.id,
|
||
"date": f.flag_date.isoformat(),
|
||
"kind": f.kind,
|
||
"description": f.description,
|
||
"entity_type": f.entity_type,
|
||
"entity_id": f.entity_id,
|
||
}
|
||
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 powerful AI assistant for CC Dashboard — a personal productivity dashboard for a developer/manager at Oliver Agency.
|
||
|
||
You have FULL access to read and modify the user's data: sessions, tasks, projects, manual entries, and DevOps work items.
|
||
|
||
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
|
||
"""
|
||
|
||
|
||
async def chat_stream(
|
||
user: User,
|
||
user_message: str,
|
||
db: AsyncSession,
|
||
) -> AsyncIterator[str]:
|
||
"""
|
||
Stream SSE events for the chat response.
|
||
Yields strings formatted as SSE data lines.
|
||
Uses Anthropic tool_use loop with up to 5 rounds.
|
||
Falls back to a static error message if no API key.
|
||
"""
|
||
if not settings.ANTHROPIC_API_KEY:
|
||
yield f"data: {json.dumps({'type': 'text', 'text': 'Anthropic API key not configured.'})}\n\n"
|
||
yield "data: [DONE]\n\n"
|
||
return
|
||
|
||
import anthropic
|
||
|
||
# Persist user message
|
||
user_msg_rec = AssistantMessage(
|
||
user_id=user.id,
|
||
role="user",
|
||
content=user_message,
|
||
)
|
||
db.add(user_msg_rec)
|
||
await db.flush()
|
||
|
||
# Load recent history (last 20 turns to stay within context)
|
||
history_result = await db.execute(
|
||
select(AssistantMessage)
|
||
.where(AssistantMessage.user_id == user.id)
|
||
.order_by(AssistantMessage.created_at.desc())
|
||
.limit(20)
|
||
)
|
||
history = list(reversed(history_result.scalars().all()))
|
||
|
||
messages: list[dict] = []
|
||
for m in history:
|
||
if m.tool_calls:
|
||
# assistant message with tool_use
|
||
messages.append({"role": "assistant", "content": json.loads(m.tool_calls) if isinstance(m.tool_calls, str) else m.tool_calls})
|
||
else:
|
||
messages.append({"role": m.role, "content": m.content})
|
||
|
||
today = datetime.now(timezone.utc).date()
|
||
client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||
|
||
system = SYSTEM_PROMPT.format(today=today.isoformat(), overhead=user.daily_overhead_hours)
|
||
|
||
full_response_text = ""
|
||
tool_calls_log: list[dict] = []
|
||
|
||
try:
|
||
# 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,
|
||
system=system,
|
||
tools=TOOLS,
|
||
messages=messages,
|
||
)
|
||
|
||
# Collect text from this round; build proper assistant message for next round
|
||
round_text = ""
|
||
tool_results: list[dict] = [] # tool_result blocks for the user turn
|
||
assistant_content: list[dict] = [] # full content for the assistant turn
|
||
|
||
for block in response.content:
|
||
if block.type == "text":
|
||
round_text += block.text
|
||
full_response_text += block.text
|
||
assistant_content.append({"type": "text", "text": block.text})
|
||
yield f"data: {json.dumps({'type': 'text', 'text': block.text})}\n\n"
|
||
|
||
elif block.type == "tool_use":
|
||
tool_name = block.name
|
||
tool_input = block.input
|
||
assistant_content.append({"type": "tool_use", "id": block.id, "name": tool_name, "input": tool_input})
|
||
|
||
yield f"data: {json.dumps({'type': 'tool_start', 'tool': tool_name})}\n\n"
|
||
|
||
try:
|
||
result = await execute_tool(tool_name, tool_input, user, db)
|
||
await db.flush()
|
||
except Exception as exc:
|
||
log.warning("assistant.tool_error", tool=tool_name, error=str(exc))
|
||
result = {"error": str(exc)}
|
||
|
||
tool_calls_log.append({"tool": tool_name, "input": tool_input, "result": result})
|
||
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)})
|
||
|
||
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result})}\n\n"
|
||
|
||
if not tool_results:
|
||
# No tool calls this round — conversation is done
|
||
break
|
||
|
||
# Add the full assistant turn (text + all tool_use blocks) as one message
|
||
messages.append({"role": "assistant", "content": assistant_content})
|
||
# Collect all tool_results into one user turn (Anthropic requirement)
|
||
messages.append({"role": "user", "content": tool_results})
|
||
|
||
# Persist assistant response
|
||
if full_response_text or tool_calls_log:
|
||
asst_msg = AssistantMessage(
|
||
user_id=user.id,
|
||
role="assistant",
|
||
content=full_response_text,
|
||
tool_calls=tool_calls_log if tool_calls_log else None,
|
||
)
|
||
db.add(asst_msg)
|
||
await db.commit()
|
||
|
||
except Exception as exc:
|
||
await db.rollback()
|
||
log.error("assistant.chat_error", user_id=user.id, error=str(exc))
|
||
yield f"data: {json.dumps({'type': 'error', 'text': 'Assistant error — please try again.'})}\n\n"
|
||
finally:
|
||
yield "data: [DONE]\n\n"
|