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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Tip, TipFeedback, RecommendResponse } from '../index.js';
|
||||
import type { Tip, TipFeedback, TipCandidate, RecommendResponse } from '../index.js';
|
||||
|
||||
describe('Tip type contract', () => {
|
||||
it('accepts a valid Tip object', () => {
|
||||
@@ -7,6 +7,7 @@ describe('Tip type contract', () => {
|
||||
id: 'todoist:123',
|
||||
content: 'Finish the report',
|
||||
source: 'todoist',
|
||||
kind: 'task',
|
||||
sourceId: '123',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -18,6 +19,7 @@ describe('Tip type contract', () => {
|
||||
id: 'advice:abc',
|
||||
content: 'Take a break',
|
||||
source: 'advice',
|
||||
kind: 'advice',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
expect(tip.sourceId).toBeUndefined();
|
||||
@@ -25,16 +27,45 @@ describe('Tip type contract', () => {
|
||||
|
||||
it('RecommendResponse wraps a Tip', () => {
|
||||
const res: RecommendResponse = {
|
||||
tip: { id: 'x', content: 'Do it', source: 'todoist', createdAt: '' },
|
||||
tip: { id: 'x', content: 'Do it', source: 'todoist', kind: 'task', createdAt: '' },
|
||||
};
|
||||
expect(res.tip.id).toBe('x');
|
||||
});
|
||||
|
||||
it('TipFeedback allows valid actions', () => {
|
||||
const actions: TipFeedback['action'][] = ['done', 'dismiss', 'snooze'];
|
||||
it('TipFeedback allows all valid actions including helpful/not_helpful', () => {
|
||||
const actions: TipFeedback['action'][] = ['done', 'dismiss', 'snooze', 'helpful', 'not_helpful'];
|
||||
for (const action of actions) {
|
||||
const fb: TipFeedback = { action };
|
||||
expect(fb.action).toBe(action);
|
||||
}
|
||||
});
|
||||
|
||||
it('Tip accepts optional rationale', () => {
|
||||
const tip: Tip = {
|
||||
id: 'llm:tip-1',
|
||||
content: 'Block 30 min for deep work.',
|
||||
source: 'llm',
|
||||
kind: 'advice',
|
||||
rationale: 'Your calendar is clear until noon.',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
expect(tip.rationale).toBeDefined();
|
||||
});
|
||||
|
||||
it('Tip rationale is optional', () => {
|
||||
const tip: Tip = { id: 'x', content: 'Do it', source: 'todoist', kind: 'task', createdAt: '' };
|
||||
expect(tip.rationale).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TipCandidate includes features', () => {
|
||||
const c: TipCandidate = {
|
||||
id: 'todoist:1',
|
||||
content: 'Finish report',
|
||||
source: 'todoist',
|
||||
kind: 'task',
|
||||
createdAt: '',
|
||||
features: { is_overdue: true, task_age_days: 2, priority: 4 },
|
||||
};
|
||||
expect(c.features.is_overdue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
/** Category of a tip — drives icon, CTA copy, and reward inference */
|
||||
export type TipKind = 'task' | 'advice' | 'insight' | 'reminder';
|
||||
|
||||
/** Where the tip content originated */
|
||||
export type TipSource = 'todoist' | 'llm' | 'advice';
|
||||
|
||||
/** A single recommendation surfaced to the user */
|
||||
export interface Tip {
|
||||
id: string;
|
||||
content: string;
|
||||
source: 'todoist' | 'advice';
|
||||
source: TipSource;
|
||||
kind: TipKind;
|
||||
sourceId?: string;
|
||||
createdAt: string; // ISO 8601
|
||||
rationale?: string; // LLM-generated "why now" shown on long-press
|
||||
createdAt: string; // ISO 8601
|
||||
}
|
||||
|
||||
/**
|
||||
* A scored tip candidate flowing through the bandit pipeline.
|
||||
* Extends Tip with features needed for scoring.
|
||||
*/
|
||||
export interface TipCandidate extends Tip {
|
||||
features: {
|
||||
is_overdue: boolean;
|
||||
task_age_days: number;
|
||||
priority: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /recommend response */
|
||||
|
||||
Reference in New Issue
Block a user