From 7840c59fb9f2dc5ee62a85bf23b2a0e9237a0652 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 15 May 2026 08:55:37 +0200 Subject: [PATCH] feat: add AI-powered time entry suggestions in timer and manual entry forms Introduce GET /api/ai/suggest with deterministic AISuggestionService results and optional LLM enrichment (?rich=true), plus reusable suggestion chips, notes ghost autocomplete, and a manual-entry Autofill popover. All UI hides when AI is disabled; LLM failures degrade to deterministic suggestions only. --- README.md | 8 + app/routes/api.py | 155 ++++++++++++ app/static/js/ai_autocomplete.js | 230 ++++++++++++++++++ app/templates/base.html | 9 + app/templates/components/ai_suggestions.html | 233 ++++++++++++++++++ app/templates/main/dashboard.html | 54 +++++ app/templates/timer/manual_entry.html | 238 ++++++++++++++++++- docs/api/API_VERSIONING.md | 11 + 8 files changed, 934 insertions(+), 4 deletions(-) create mode 100644 app/static/js/ai_autocomplete.js create mode 100644 app/templates/components/ai_suggestions.html diff --git a/README.md b/README.md index 734813e6..57ebd6e3 100644 --- a/README.md +++ b/README.md @@ -722,6 +722,14 @@ The AI helper is exposed as: - Session web UI JSON: `POST /api/ai/chat` (same-origin, login required) - REST API v1: `POST /api/v1/ai/chat` (API token required, scopes `read:ai`/`write:ai`) +**Time entry suggestions** (when `AI_ENABLED=true`): + +- `GET /api/ai/suggest` — deterministic suggestions from recent patterns and active tasks; optional `?q=` filters by notes/project name; `?rich=true` merges LLM suggestions when configured. AI failures return deterministic results only. +- **Start Timer** modal: horizontal suggestion chips above the project field (refresh for LLM-enhanced suggestions). +- **Log Time** form: “✦ Autofill” popover and ghost-text autocomplete on the notes field (`app/static/js/ai_autocomplete.js`). + +All suggestion UI is hidden when AI is disabled (`ai_enabled` in template context). + ### Bundled Ollama service (Docker Compose, opt-in) The root `docker-compose.yml` defines a CPU-only **`ollama`** service and a one-shot **`ollama-init`** sidecar that pulls `AI_MODEL` (default `llama3.1`, ~4.7 GB) into the `ollama_data` volume. These services use the Compose **`ai`** profile so they **do not start by default**. diff --git a/app/routes/api.py b/app/routes/api.py index 0f44d365..9126d0a4 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,5 +1,6 @@ import json import os +import re import uuid from datetime import datetime, time, timedelta @@ -24,6 +25,7 @@ from app.models import ( User, ) from app.models.time_entry import local_now +from app.services.ai_suggestion_service import AISuggestionService from app.services.global_search_service import run_global_search from app.services.llm_service import AIServiceError, LLMService from app.services.time_tracking_service import TimeTrackingService @@ -100,6 +102,159 @@ def ai_confirm_action(): return _ai_error_response(exc) +def _ai_confidence_label(value): + """Map a numeric confidence (0..1) to a coarse label used by the suggestion UI.""" + try: + score = float(value) + except (TypeError, ValueError): + return "low" + if score >= 0.7: + return "high" + if score >= 0.4: + return "medium" + return "low" + + +def _ai_safe_int(value): + try: + return int(value) if value not in (None, "") else None + except (TypeError, ValueError): + return None + + +def _ai_parse_suggestion_array(content): + """Best-effort extraction of a JSON array from an LLM reply that may include prose.""" + if not isinstance(content, str) or not content.strip(): + return [] + fenced = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", content, re.DOTALL) + candidate = fenced.group(1) if fenced else None + if candidate is None: + match = re.search(r"\[.*\]", content, re.DOTALL) + candidate = match.group(0) if match else None + if candidate is None: + return [] + try: + data = json.loads(candidate) + except (ValueError, TypeError): + return [] + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict)] + + +@api_bp.route("/api/ai/suggest") +@login_required +@deprecated_session_api("/api/v1/ai/suggest") +def ai_suggest(): + """Return AI-powered time entry suggestions (deterministic, optionally LLM-enhanced).""" + q = (request.args.get("q") or "").strip() + rich = (request.args.get("rich") or "").strip().lower() in {"1", "true", "yes", "on"} + + suggestions = [] + + try: + deterministic = AISuggestionService().get_time_entry_suggestions(current_user.id, limit=5) + except Exception as exc: # noqa: BLE001 - never let suggestion errors 500 the request + current_app.logger.warning("AI deterministic suggestions failed: %s", exc) + deterministic = [] + + pairs = {(s.get("project_id"), s.get("task_id")) for s in deterministic if s.get("project_id")} + last_entry_by_pair = {} + for project_id, task_id in pairs: + query = TimeEntry.query.filter( + TimeEntry.user_id == current_user.id, + TimeEntry.project_id == project_id, + TimeEntry.end_time.isnot(None), + ) + if task_id is None: + query = query.filter(TimeEntry.task_id.is_(None)) + else: + query = query.filter(TimeEntry.task_id == task_id) + entry = query.order_by(TimeEntry.start_time.desc()).first() + if entry is not None: + last_entry_by_pair[(project_id, task_id)] = entry + + for item in deterministic: + pid = item.get("project_id") + tid = item.get("task_id") + last = last_entry_by_pair.get((pid, tid)) + suggestions.append( + { + "project_id": pid, + "project_name": item.get("project_name"), + "task_id": tid, + "task_name": item.get("task_name"), + "notes": (last.notes if last and last.notes else "") or "", + "tags": (last.tags if last and last.tags else "") or "", + "billable": bool(last.billable) if last is not None else True, + "confidence": _ai_confidence_label(item.get("confidence")), + "source": "deterministic", + } + ) + + if rich: + try: + llm = LLMService() + if llm.is_enabled(): + prompt = ( + "Based on the user's recent time entries and assigned tasks, suggest the top 3 most likely " + "next time entries. For each, return project_id, task_id (if any), notes, tags, billable. " + "Return ONLY a JSON array, no prose." + ) + result = llm.chat(current_user, prompt) + ai_items = _ai_parse_suggestion_array(result.get("reply") or "") + for item in ai_items: + pid = _ai_safe_int(item.get("project_id")) + if pid is None or not user_can_access_project(current_user, pid): + continue + project = Project.query.get(pid) + if project is None: + continue + tid = _ai_safe_int(item.get("task_id")) + task = Task.query.get(tid) if tid else None + if task is not None and task.project_id != pid: + task = None + tid = None + suggestions.append( + { + "project_id": pid, + "project_name": project.name, + "task_id": tid, + "task_name": task.name if task else None, + "notes": (str(item.get("notes") or ""))[:1000], + "tags": (str(item.get("tags") or ""))[:500], + "billable": bool(item.get("billable", True)), + "confidence": "medium", + "source": "ai", + } + ) + except AIServiceError as exc: + current_app.logger.info("AI rich suggestions unavailable: %s", exc.message) + except Exception as exc: # noqa: BLE001 - never fail the request because of AI errors + current_app.logger.warning("AI rich suggestions failed: %s", exc) + + seen = set() + unique = [] + for item in suggestions: + key = (item.get("project_id"), item.get("task_id")) + if key in seen: + continue + seen.add(key) + unique.append(item) + + if q: + ql = q.lower() + + def _matches(item): + project_name = (item.get("project_name") or "").lower() + notes = (item.get("notes") or "").lower() + return ql in project_name or ql in notes + + unique = [item for item in unique if _matches(item)] + + return jsonify({"ok": True, "suggestions": unique[:5]}) + + def _effective_user_for_version_api(): """Session user, or API token user (Bearer / X-API-Key). Used for version check routes.""" if getattr(current_user, "is_authenticated", False): diff --git a/app/static/js/ai_autocomplete.js b/app/static/js/ai_autocomplete.js new file mode 100644 index 00000000..e7818416 --- /dev/null +++ b/app/static/js/ai_autocomplete.js @@ -0,0 +1,230 @@ +/* + * AI ghost-text autocomplete for time-entry notes fields. + * + * Binds to any or +
@@ -1105,4 +1131,208 @@ document.addEventListener('DOMContentLoaded', function() { document.head.appendChild(scriptEl); }); + +{% if ai_enabled %} + +{% endif %} {% endblock %} diff --git a/docs/api/API_VERSIONING.md b/docs/api/API_VERSIONING.md index 55f8dc42..da4fa1ff 100644 --- a/docs/api/API_VERSIONING.md +++ b/docs/api/API_VERSIONING.md @@ -31,6 +31,17 @@ These **`/api/*`** routes have v1 successors. They remain for the web UI but may - `GET /api/projects`, `GET /api/projects//tasks`, `GET /api/tasks` → `/api/v1/projects`, `/api/v1/tasks` - `GET /api/activities` → `GET /api/v1/activities` (v1 is a simpler list; legacy adds filters/pagination) +### Session AI routes (web UI; partial v1 migration) + +| Session route | Notes | v1 successor (planned) | +|---------------|--------|-------------------------| +| `GET /api/ai/context-preview` | Compact context for AI helper | `GET /api/v1/ai/context-preview` | +| `POST /api/ai/chat` | AI helper chat | `POST /api/v1/ai/chat` | +| `POST /api/ai/actions/confirm` | Confirm proposed AI actions | `POST /api/v1/ai/actions/confirm` | +| `GET /api/ai/suggest` | Time entry suggestions (`?q=`, `?rich=true`) | `GET /api/v1/ai/suggest` (deprecation header points here) | + +`GET /api/ai/suggest` always returns deterministic suggestions from `AISuggestionService`; optional `rich=true` adds LLM suggestions when the helper is enabled. Failures in the LLM path do not fail the request. + ### Internal / UI-only (no v1 equivalent yet) Examples: `GET /api/notifications`, dashboard stats (`/api/dashboard/*`, `/api/stats*`, `/api/reports/week-comparison`), editor uploads, smart notifications dismiss, many calendar helpers. Treat as **internal** to the web app unless documented otherwise.