feat: AI assistant chat widget + session categories + anomaly detection

- Migration 0005: sessions.category, manual_entries.category, ai_flags table, assistant_messages table
- AiFlag + AssistantMessage ORM models added to models.py
- src/services/assistant.py: gap detection (>30min gaps, low active/wall ratio, uncategorized sessions, long days), Anthropic tool_use loop with 7 tools (get_daily_summary, get_sessions, get_project_stats, detect_anomalies, create_manual_entry, set_session_category, get_unresolved_flags)
- src/routers/assistant.py: POST /api/assistant/chat (SSE streaming), GET/DELETE /history, GET /flags, POST /flags/scan, PATCH /flags/:id/resolve, PATCH /sessions/:id/category
- APScheduler: hourly anomaly scan for all users, persists AiFlag records
- AssistantWidget.vue: floating bottom-right chat panel, streaming SSE rendering, quick-hint chips, tool activity indicators, red badge for unresolved flags, markdown rendering, clear history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-06 18:59:51 +01:00
parent ff52d502b8
commit 2533f4b046
9 changed files with 1305 additions and 1 deletions

View file

@ -0,0 +1,104 @@
"""Add session category and time_gaps view for accurate time accounting
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-06
"""
import sqlalchemy as sa
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
CATEGORIES = ("coding", "thinking", "deployment", "meeting", "review", "other")
def upgrade():
# Add category to sessions — defaults to NULL (uncategorized)
op.add_column(
"sessions",
sa.Column(
"category",
sa.String(20),
nullable=True,
comment="coding|thinking|deployment|meeting|review|other",
),
)
op.create_index("ix_sessions_category", "sessions", ["user_id", "category"])
# Add category to manual_entries as well
op.add_column(
"manual_entries",
sa.Column(
"category",
sa.String(20),
nullable=True,
server_default="other",
),
)
# ai_flags: anomalies/suggestions surfaced by the assistant
op.create_table(
"ai_flags",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("flag_date", sa.Date, nullable=False),
sa.Column(
"kind",
sa.String(30),
nullable=False,
comment="gap|long_session|uncategorized|missing_overhead",
),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column("entity_type", sa.String(20), nullable=True), # session|manual_entry|day
sa.Column("entity_id", sa.String(36), nullable=True),
sa.Column("resolved", sa.Boolean, nullable=False, server_default="false"),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
),
)
op.create_index("ix_ai_flags_user_date", "ai_flags", ["user_id", "flag_date"])
op.create_index("ix_ai_flags_resolved", "ai_flags", ["user_id", "resolved"])
# assistant_messages: persisted chat history per user
op.create_table(
"assistant_messages",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("role", sa.String(10), nullable=False), # user|assistant
sa.Column("content", sa.Text, nullable=False),
sa.Column("tool_calls", sa.JSON, nullable=True), # tool use metadata
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
),
)
op.create_index("ix_assistant_messages_user", "assistant_messages", ["user_id", "created_at"])
def downgrade():
op.drop_index("ix_assistant_messages_user", "assistant_messages")
op.drop_table("assistant_messages")
op.drop_index("ix_ai_flags_resolved", "ai_flags")
op.drop_index("ix_ai_flags_user_date", "ai_flags")
op.drop_table("ai_flags")
op.drop_column("manual_entries", "category")
op.drop_index("ix_sessions_category", "sessions")
op.drop_column("sessions", "category")

View file

@ -23,7 +23,7 @@ def _ensure_static_dir() -> None:
)
from src.middleware.logging import LoggingMiddleware
from src.routers import admin, auth, dashboard, events, ingest, keys, projects
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports, assistant
from src.services.scheduler import scheduler, setup_scheduler
BASE = settings.BASE_PATH
@ -92,6 +92,7 @@ for router in [
devops.router,
exports.router,
reports.router,
assistant.router,
]:
app.include_router(router)

View file

@ -97,6 +97,7 @@ class Session(Base):
files_changed: Mapped[list] = mapped_column(JSONB, default=list)
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(back_populates="sessions")
@ -176,6 +177,7 @@ class ManualEntry(Base):
title: Mapped[str] = mapped_column(String(500), nullable=False)
notes: Mapped[str] = mapped_column(Text, default="")
source: Mapped[str] = mapped_column(String(20), default="manual")
category: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project | None"] = relationship(foreign_keys=[project_id])
@ -268,3 +270,35 @@ class AuditLog(Base):
entity_id: Mapped[str] = mapped_column(String(64), default="")
payload_json: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class AiFlag(Base):
"""Anomaly/suggestion surfaced by the AI assistant."""
__tablename__ = "ai_flags"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
flag_date: Mapped[date] = mapped_column(Date, nullable=False)
kind: Mapped[str] = mapped_column(String(30), nullable=False) # gap|long_session|uncategorized|missing_overhead
description: Mapped[str] = mapped_column(Text, default="")
entity_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # session|manual_entry|day
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(foreign_keys=[user_id])
class AssistantMessage(Base):
"""Persisted chat history for the AI assistant per user."""
__tablename__ = "assistant_messages"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(10), nullable=False) # user|assistant
content: Mapped[str] = mapped_column(Text, nullable=False)
tool_calls: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(foreign_keys=[user_id])

139
src/routers/assistant.py Normal file
View file

@ -0,0 +1,139 @@
"""AI assistant router — streaming chat + flag management + session categorization."""
import logging
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import AiFlag, AssistantMessage, Session
from src.schemas import (
AiFlagOut,
AssistantChatIn,
AssistantMessageOut,
SessionCategoryIn,
)
from src.services.assistant import chat_stream, detect_day_anomalies, persist_flags
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
@router.post("/chat")
async def chat(
body: AssistantChatIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
) -> StreamingResponse:
"""Stream chat response as SSE. Each event is a JSON object on a `data:` line."""
return StreamingResponse(
chat_stream(user, body.message, db),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@router.get("/history", response_model=list[AssistantMessageOut])
async def get_history(
user: CurrentUser,
limit: int = 50,
db: AsyncSession = Depends(get_db),
) -> list[AssistantMessage]:
result = await db.execute(
select(AssistantMessage)
.where(AssistantMessage.user_id == user.id)
.order_by(AssistantMessage.created_at.desc())
.limit(limit)
)
return list(reversed(result.scalars().all()))
@router.delete("/history", status_code=204)
async def clear_history(
user: CurrentUser,
db: AsyncSession = Depends(get_db),
) -> Response:
result = await db.execute(
select(AssistantMessage).where(AssistantMessage.user_id == user.id)
)
for msg in result.scalars().all():
await db.delete(msg)
await db.commit()
return Response(status_code=204)
# ── Flags ─────────────────────────────────────────────────────────────────────
@router.get("/flags", response_model=list[AiFlagOut])
async def list_flags(
user: CurrentUser,
days_back: int = 14,
resolved: bool = False,
db: AsyncSession = Depends(get_db),
) -> list[AiFlag]:
from datetime import timedelta
since = datetime.now(timezone.utc).date() - timedelta(days=days_back)
q = (
select(AiFlag)
.where(
AiFlag.user_id == user.id,
AiFlag.flag_date >= since,
AiFlag.resolved == resolved,
)
.order_by(AiFlag.flag_date.desc(), AiFlag.created_at.desc())
)
result = await db.execute(q)
return result.scalars().all()
@router.post("/flags/scan", response_model=list[AiFlagOut])
async def scan_day(
check_date: date,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
) -> list[AiFlag]:
"""Run anomaly detection for a specific date and persist results."""
anomalies = await detect_day_anomalies(user, check_date, db)
flags = await persist_flags(user, check_date, anomalies, db)
await db.commit()
return flags
@router.patch("/flags/{flag_id}/resolve", response_model=AiFlagOut)
async def resolve_flag(
flag_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
) -> AiFlag:
flag = await db.get(AiFlag, flag_id)
if not flag or flag.user_id != user.id:
raise HTTPException(status_code=404, detail="Flag not found")
flag.resolved = True
flag.resolved_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(flag)
return flag
# ── Session categorization ────────────────────────────────────────────────────
@router.patch("/sessions/{session_id}/category", response_model=dict)
async def set_session_category(
session_id: str,
body: SessionCategoryIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
) -> dict:
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")
session.category = body.category
await db.commit()
return {"id": session.id, "category": session.category}

View file

@ -405,3 +405,36 @@ class AiReportOut(BaseModel):
class GenerateReportIn(BaseModel):
type: str = Field(pattern="^(daily|weekly)$")
date: date
# ── AI Assistant ──────────────────────────────────────────────────────────────
class AiFlagOut(BaseModel):
id: str
flag_date: date
kind: str
description: str
entity_type: str | None
entity_id: str | None
resolved: bool
resolved_at: datetime | None
created_at: datetime
model_config = {"from_attributes": True}
class AssistantMessageOut(BaseModel):
id: str
role: str
content: str
created_at: datetime
model_config = {"from_attributes": True}
class AssistantChatIn(BaseModel):
message: str = Field(min_length=1, max_length=4000)
class SessionCategoryIn(BaseModel):
category: str | None = Field(default=None, pattern="^(coding|thinking|deployment|meeting|review|other)?$")

534
src/services/assistant.py Normal file
View file

@ -0,0 +1,534 @@
"""AI assistant service: gap detection, anomaly analysis, tool-use chat."""
from __future__ import annotations
import json
import logging
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
from typing import Any, AsyncIterator
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.services.aggregator import _union_hours
log = logging.getLogger(__name__)
# ── 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 / 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": [],
},
},
]
# ── 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":
from src.models import ManualEntry
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
]
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.
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
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).
Respond concisely. Use Markdown for structure when helpful.
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 5 tool-use rounds
for _round in range(5):
response = await client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=system,
tools=TOOLS,
messages=messages,
)
# Collect text from this round
round_text = ""
has_tool_use = False
for block in response.content:
if block.type == "text":
round_text += block.text
full_response_text += block.text
# Stream text chunk
yield f"data: {json.dumps({'type': 'text', 'text': block.text})}\n\n"
elif block.type == "tool_use":
has_tool_use = True
tool_name = block.name
tool_input = block.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", extra={"tool": tool_name, "error": str(exc)})
result = {"error": str(exc)}
tool_calls_log.append({"tool": tool_name, "input": tool_input, "result": result})
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result})}\n\n"
# Add assistant tool_use + tool_result to messages for next round
messages.append({
"role": "assistant",
"content": [{"type": "tool_use", "id": block.id, "name": tool_name, "input": tool_input}],
})
messages.append({
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}],
})
if not has_tool_use:
# No more tool calls — done
break
if round_text:
# Between-round text already streamed, add to messages
messages.append({"role": "assistant", "content": round_text})
# 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", extra={"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"

View file

@ -37,6 +37,29 @@ async def _weekly_report_job() -> None:
log.info("weekly_reports.completed")
async def _anomaly_scan_job() -> None:
"""Scan yesterday + today for all users and persist AI flags."""
from datetime import date, timedelta, datetime, timezone
from sqlalchemy import select
from src.database import AsyncSessionLocal
from src.models import User
from src.services.assistant import detect_day_anomalies, persist_flags
today = datetime.now(timezone.utc).date()
yesterday = today - timedelta(days=1)
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
for user in result.scalars().all():
for d in (yesterday, today):
anomalies = await detect_day_anomalies(user, d, db)
await persist_flags(user, d, anomalies, db)
await db.commit()
log.info("anomaly_scan.completed")
def setup_scheduler() -> None:
if settings.ADO_PAT:
scheduler.add_job(
@ -64,3 +87,12 @@ def setup_scheduler() -> None:
id="weekly_report",
replace_existing=True,
)
# Scan for time-tracking anomalies every hour
scheduler.add_job(
_anomaly_scan_job,
"interval",
hours=1,
id="anomaly_scan",
replace_existing=True,
)

View file

@ -1,9 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Toaster } from 'vue-sonner'
import { useAuthStore } from '@/stores/auth'
import AssistantWidget from '@/components/assistant/AssistantWidget.vue'
const authStore = useAuthStore()
const showAssistant = computed(() => authStore.isAuthenticated)
</script>
<template>
<RouterView />
<AssistantWidget v-if="showAssistant" />
<Toaster
position="top-right"
:toast-options="{

View file

@ -0,0 +1,420 @@
<template>
<!-- Floating toggle button -->
<button
v-if="!isOpen"
class="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-amber-400 text-gray-900 shadow-lg hover:bg-amber-300 transition-all duration-200 hover:scale-105 active:scale-95"
title="AI Assistant"
@click="open"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
<!-- Unresolved flag badge -->
<span
v-if="flagCount > 0"
class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white text-xs font-bold"
>{{ flagCount > 9 ? '9+' : flagCount }}</span>
</button>
<!-- Chat panel -->
<Transition name="slide-up">
<div
v-if="isOpen"
class="fixed bottom-6 right-6 z-50 flex flex-col w-[380px] max-h-[600px] rounded-2xl bg-gray-900 border border-gray-700 shadow-2xl overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-700 bg-gray-800 flex-shrink-0">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-900" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
</div>
<div>
<p class="text-sm font-semibold text-white">Time Analyst</p>
<p class="text-xs text-gray-400">AI assistant</p>
</div>
</div>
<div class="flex items-center gap-1">
<!-- Flag count chip -->
<button
v-if="flagCount > 0"
class="flex items-center gap-1 rounded-full bg-red-900/40 px-2 py-0.5 text-xs text-red-400 hover:bg-red-900/60 transition-colors"
title="View anomalies"
@click="loadFlagsMessage"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{{ flagCount }} issue{{ flagCount > 1 ? 's' : '' }}
</button>
<button class="p-1.5 text-gray-400 hover:text-white transition-colors" title="Clear history" @click="clearHistory">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<button class="p-1.5 text-gray-400 hover:text-white transition-colors" @click="isOpen = false">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Messages -->
<div ref="messagesEl" class="flex-1 overflow-y-auto p-3 space-y-3 min-h-0">
<!-- Empty state -->
<div v-if="messages.length === 0 && !streaming" class="flex flex-col items-center justify-center h-full py-8 text-center">
<div class="h-12 w-12 rounded-full bg-amber-400/10 flex items-center justify-center mb-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
</div>
<p class="text-sm font-medium text-gray-300">Time Analyst</p>
<p class="text-xs text-gray-500 mt-1">Ask me about your hours, gaps, or missing time entries.</p>
<div class="mt-4 flex flex-wrap justify-center gap-2">
<button
v-for="hint in quickHints"
:key="hint"
class="rounded-full border border-gray-600 px-3 py-1.5 text-xs text-gray-400 hover:border-amber-400 hover:text-amber-400 transition-colors"
@click="sendQuick(hint)"
>{{ hint }}</button>
</div>
</div>
<!-- Message list -->
<template v-for="msg in messages" :key="msg.id">
<!-- User message -->
<div v-if="msg.role === 'user'" class="flex justify-end">
<div class="max-w-[80%] rounded-2xl rounded-tr-sm bg-amber-400 px-3 py-2">
<p class="text-sm text-gray-900">{{ msg.content }}</p>
</div>
</div>
<!-- Assistant message -->
<div v-else class="flex justify-start">
<div class="max-w-[90%] rounded-2xl rounded-tl-sm bg-gray-800 border border-gray-700 px-3 py-2">
<div
class="text-sm text-gray-200 prose prose-sm prose-invert max-w-none"
v-html="renderMarkdown(msg.content)"
/>
</div>
</div>
</template>
<!-- Streaming assistant message -->
<div v-if="streaming || streamingText" class="flex justify-start">
<div class="max-w-[90%] rounded-2xl rounded-tl-sm bg-gray-800 border border-gray-700 px-3 py-2">
<!-- Tool activity indicators -->
<div v-if="activeTools.length > 0" class="mb-2 space-y-1">
<div
v-for="tool in activeTools"
:key="tool"
class="flex items-center gap-1.5 text-xs text-amber-400"
>
<svg class="h-3 w-3 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{{ toolLabel(tool) }}
</div>
</div>
<div
v-if="streamingText"
class="text-sm text-gray-200 prose prose-sm prose-invert max-w-none"
v-html="renderMarkdown(streamingText)"
/>
<div v-else class="flex items-center gap-1">
<span class="h-1.5 w-1.5 rounded-full bg-gray-500 animate-bounce" style="animation-delay: 0ms"/>
<span class="h-1.5 w-1.5 rounded-full bg-gray-500 animate-bounce" style="animation-delay: 150ms"/>
<span class="h-1.5 w-1.5 rounded-full bg-gray-500 animate-bounce" style="animation-delay: 300ms"/>
</div>
</div>
</div>
</div>
<!-- Input area -->
<div class="flex-shrink-0 border-t border-gray-700 bg-gray-800 p-3">
<div class="flex items-end gap-2">
<textarea
ref="inputEl"
v-model="inputText"
rows="1"
placeholder="Ask about your time..."
class="flex-1 resize-none rounded-xl bg-gray-700 border border-gray-600 px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-amber-400 transition-colors max-h-24 overflow-y-auto"
:disabled="streaming"
@keydown.enter.exact.prevent="send"
@keydown.enter.shift.exact="() => {}"
@input="autoResize"
/>
<button
:disabled="!inputText.trim() || streaming"
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-amber-400 text-gray-900 transition-all hover:bg-amber-300 disabled:opacity-40 disabled:cursor-not-allowed"
@click="send"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
</svg>
</button>
</div>
<p class="mt-1.5 text-center text-xs text-gray-600">Enter to send · Shift+Enter for newline</p>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
created_at: string
}
const authStore = useAuthStore()
const isOpen = ref(false)
const messages = ref<Message[]>([])
const inputText = ref('')
const streaming = ref(false)
const streamingText = ref('')
const activeTools = ref<string[]>([])
const flagCount = ref(0)
const messagesEl = ref<HTMLElement>()
const inputEl = ref<HTMLTextAreaElement>()
const quickHints = [
'Check today for gaps',
'How many hours this week?',
'Any uncategorized sessions?',
'Summarize yesterday',
]
function toolLabel(tool: string): string {
const labels: Record<string, string> = {
get_daily_summary: 'Loading daily summary…',
get_sessions: 'Fetching sessions…',
get_project_stats: 'Calculating project hours…',
detect_anomalies: 'Scanning for gaps…',
create_manual_entry: 'Creating manual entry…',
set_session_category: 'Updating category…',
get_unresolved_flags: 'Loading flags…',
}
return labels[tool] ?? `Running ${tool}`
}
function renderMarkdown(text: string): string {
// Simple markdown to HTML: bold, code, headers, lists, line breaks
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/### (.+)/g, '<h3 class="text-sm font-semibold text-white mt-2 mb-1">$1</h3>')
.replace(/## (.+)/g, '<h3 class="text-sm font-semibold text-white mt-2 mb-1">$1</h3>')
.replace(/# (.+)/g, '<h3 class="font-semibold text-white mt-2 mb-1">$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white">$1</strong>')
.replace(/`(.+?)`/g, '<code class="rounded bg-gray-700 px-1 py-0.5 text-amber-400 text-xs font-mono">$1</code>')
.replace(/^- (.+)/gm, '<li class="ml-3 list-disc">$1</li>')
.replace(/^(\d+)\. (.+)/gm, '<li class="ml-3 list-decimal">$2</li>')
.replace(/<\/li>\n<li/g, '</li><li')
.replace(/\n\n/g, '</p><p class="mt-1">')
.replace(/\n/g, '<br/>')
}
async function loadHistory(): Promise<void> {
try {
const res = await fetch('/cc-dashboard/api/assistant/history?limit=30', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
messages.value = await res.json()
} catch {
// ignore
}
}
async function loadFlagCount(): Promise<void> {
try {
const res = await fetch('/cc-dashboard/api/assistant/flags?days_back=7&resolved=false', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const flags = await res.json()
flagCount.value = flags.length
} catch {
// ignore
}
}
async function clearHistory(): Promise<void> {
await fetch('/cc-dashboard/api/assistant/history', {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.token}` },
})
messages.value = []
}
function loadFlagsMessage(): void {
sendQuick('Show me all unresolved time-tracking issues from the last 7 days')
}
function sendQuick(text: string): void {
inputText.value = text
send()
}
async function send(): Promise<void> {
const text = inputText.value.trim()
if (!text || streaming.value) return
inputText.value = ''
resetTextareaHeight()
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: text,
created_at: new Date().toISOString(),
}
messages.value.push(userMsg)
scrollBottom()
streaming.value = true
streamingText.value = ''
activeTools.value = []
try {
const res = await fetch('/cc-dashboard/api/assistant/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify({ message: text }),
})
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6).trim()
if (payload === '[DONE]') continue
try {
const event = JSON.parse(payload)
if (event.type === 'text') {
streamingText.value += event.text
scrollBottom()
} else if (event.type === 'tool_start') {
if (!activeTools.value.includes(event.tool)) {
activeTools.value.push(event.tool)
}
} else if (event.type === 'tool_result') {
activeTools.value = activeTools.value.filter(t => t !== event.tool)
} else if (event.type === 'error') {
streamingText.value = event.text
}
} catch {
// non-JSON line, ignore
}
}
}
// Finalise streaming message
if (streamingText.value) {
messages.value.push({
id: crypto.randomUUID(),
role: 'assistant',
content: streamingText.value,
created_at: new Date().toISOString(),
})
}
// Refresh flag count after AI might have found issues
await loadFlagCount()
} catch (err) {
messages.value.push({
id: crypto.randomUUID(),
role: 'assistant',
content: 'Failed to get response. Please try again.',
created_at: new Date().toISOString(),
})
} finally {
streaming.value = false
streamingText.value = ''
activeTools.value = []
scrollBottom()
}
}
function scrollBottom(): void {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
})
}
function autoResize(e: Event): void {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}
function resetTextareaHeight(): void {
if (inputEl.value) {
inputEl.value.style.height = 'auto'
}
}
async function open(): Promise<void> {
isOpen.value = true
await loadHistory()
nextTick(() => inputEl.value?.focus())
scrollBottom()
}
onMounted(() => {
loadFlagCount()
// Refresh flag count every 5 minutes
setInterval(loadFlagCount, 5 * 60 * 1000)
})
watch(isOpen, (val) => {
if (val) loadFlagCount()
})
</script>
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.prose :deep(li) {
margin: 0.125rem 0;
}
</style>