Files
oO/ml/features/test_context.py
alvis ffdf70733f feat: M2 AI tips — LiteLLM gateway, context assembler, end-to-end generation pipeline
Issues closed: #86, #87, #88, #89, #90, #91, #79, #80, #82

infra:
- docker-compose `ai` profile: Ollama + LiteLLM services
- infra/litellm/litellm_config.yaml: tip-generator / embedder / judge aliases
- .env.example: LITELLM_URL, LITELLM_MASTER_KEY, OLLAMA_URL

ml/serving:
- POST /generate: calls LiteLLM tip-generator alias, returns TipCandidate[]
- JSON retry loop (2 retries with correction prompt on malformed response)
- _parse_llm_json strips markdown fences

ml/features:
- context.py: build_context() assembles user signals → PromptContext
  (sorts overdue/high-priority tasks first for LLM prompt quality)

shared-types:
- TipKind, TipSource, TipCandidate types
- Tip gains kind + rationale fields

services/api:
- recommender: 3-stage pipeline (assemble → score → serve)
  Stage 1: Todoist tasks + LLM candidates fetched in parallel
  Stage 2: egreedy bandit scores merged candidate pool
  Stage 3: serve + log with prompt_version, llm_model, tip_kind
- tip_scores: prompt_version, llm_model, tip_kind columns + migrations
- config: LITELLM_URL added
- integrations: surface token_status in /integrations response

tests:
- ml/serving/tests/test_generate.py: 13 tests (retry, 502/503, fence variants)
- ml/features/test_context.py: 9 tests (sorting, edge cases)
- services/api recommender.unit.test.ts: 16 pure-function tests (inferReward, dueAgeDays)
- services/api recommender.test.ts: 4 integration tests (tip_scores columns, LLM fallback)
- shared-types: TipCandidate, rationale, full TipFeedback action set

docs:
- ADR-0008: LiteLLM AI gateway decision
- overview.md: M2 pipeline description updated
- ml/README.md: serving + features roles updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:09:02 +00:00

65 lines
2.0 KiB
Python

"""Tests for ml/features/context.py"""
import pytest
import sys, os; sys.path.insert(0, os.path.dirname(__file__))
from context import build_context, TaskSignal, PromptContext
def test_empty_tasks():
ctx = build_context([], hour_of_day=9, day_of_week=1)
assert ctx.tasks == []
assert ctx.hour_of_day == 9
assert ctx.day_of_week == 1
def test_overdue_tasks_sorted_first():
tasks = [
TaskSignal(id="a", content="Normal task", priority=1, is_overdue=False),
TaskSignal(id="b", content="Overdue task", priority=2, is_overdue=True, task_age_days=3.0),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "b"
def test_high_priority_within_non_overdue():
tasks = [
TaskSignal(id="lo", content="Low prio", priority=1, is_overdue=False),
TaskSignal(id="hi", content="High prio", priority=4, is_overdue=False),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "hi"
def test_extra_fields_passed_through():
ctx = build_context([], extra={"mood": "focused"})
assert ctx.extra["mood"] == "focused"
def test_task_age_rounded():
tasks = [TaskSignal(id="x", content="Task", task_age_days=1.23456)]
ctx = build_context(tasks)
assert ctx.tasks[0]["task_age_days"] == 1.2
def test_overdue_sorted_by_priority():
tasks = [
TaskSignal(id="lo", content="Low", priority=1, is_overdue=True),
TaskSignal(id="hi", content="High", priority=4, is_overdue=True),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "hi"
def test_overdue_same_priority_sorted_by_age():
tasks = [
TaskSignal(id="new", content="New", priority=2, is_overdue=True, task_age_days=1.0),
TaskSignal(id="old", content="Old", priority=2, is_overdue=True, task_age_days=5.0),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "old"
def test_due_date_none_preserved():
tasks = [TaskSignal(id="x", content="No due", due_date=None)]
ctx = build_context(tasks)
assert ctx.tasks[0]["due_date"] is None