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>
64 lines
1.6 KiB
Python
64 lines
1.6 KiB
Python
"""
|
||
Context assembler — converts raw user signals into a PromptContext for LLM tip generation.
|
||
|
||
Usage:
|
||
from ml.features.context import build_context
|
||
ctx = build_context(tasks, hour_of_day=9, day_of_week=2)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
from dataclasses import dataclass, field
|
||
|
||
|
||
@dataclass
|
||
class TaskSignal:
|
||
id: str
|
||
content: str
|
||
priority: int = 1 # 1–4 (Todoist scale)
|
||
is_overdue: bool = False
|
||
task_age_days: float = 0.0
|
||
due_date: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class PromptContext:
|
||
tasks: list[dict] = field(default_factory=list)
|
||
hour_of_day: int = 12
|
||
day_of_week: int = 0
|
||
extra: dict = field(default_factory=dict)
|
||
|
||
|
||
def build_context(
|
||
tasks: list[TaskSignal],
|
||
hour_of_day: int = 12,
|
||
day_of_week: int = 0,
|
||
extra: dict | None = None,
|
||
) -> PromptContext:
|
||
"""
|
||
Assemble user signals into a PromptContext.
|
||
|
||
Signals are sorted so overdue + high-priority tasks appear first,
|
||
giving the LLM the most actionable context at the top of the prompt.
|
||
"""
|
||
sorted_tasks = sorted(
|
||
tasks,
|
||
key=lambda t: (not t.is_overdue, -t.priority, -t.task_age_days),
|
||
)
|
||
task_dicts = [
|
||
{
|
||
"id": t.id,
|
||
"content": t.content,
|
||
"priority": t.priority,
|
||
"is_overdue": t.is_overdue,
|
||
"task_age_days": round(t.task_age_days, 1),
|
||
"due_date": t.due_date,
|
||
}
|
||
for t in sorted_tasks
|
||
]
|
||
return PromptContext(
|
||
tasks=task_dicts,
|
||
hour_of_day=hour_of_day,
|
||
day_of_week=day_of_week,
|
||
extra=extra or {},
|
||
)
|