From 59c493323fe7096a0f1e73e9ed55bc32881b6d03 Mon Sep 17 00:00:00 2001 From: alvis Date: Tue, 12 May 2026 13:28:32 +0000 Subject: [PATCH] fix(recommender): remove Todoist fallback on orchestrator failure; add snooze exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When fetchOrchestratorTip returned null (LiteLLM timeout, bad JSON, etc.) the recommender silently fell back to randomPolicy, serving a raw Todoist task with no rationale — explaining both reported symptoms. - Remove randomPolicy/signalToCandidate; return 204 when orchestrator fails so the UI shows "All clear" instead of a confusing Todoist task - Pass recent_tip through the stack (frontend → POST /recommend → fetchOrchestratorTip → ml/serving RecommendRequest → build_orchestrator_messages) so after snooze the LLM is instructed not to repeat the snoozed content Fixes #122 Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/tip/page.tsx | 7 ++-- apps/web/src/lib/api.ts | 7 ++-- ml/serving/main.py | 2 ++ ml/serving/prompts.py | 5 +++ services/api/src/routes/recommender.ts | 49 +++++++------------------- 5 files changed, 29 insertions(+), 41 deletions(-) diff --git a/apps/web/src/app/tip/page.tsx b/apps/web/src/app/tip/page.tsx index 3c71555..6f6c0f7 100644 --- a/apps/web/src/app/tip/page.tsx +++ b/apps/web/src/app/tip/page.tsx @@ -40,11 +40,11 @@ export default function TipPage() { } }, [state]); - const loadTip = useCallback(async () => { + const loadTip = useCallback(async (recentTip?: string) => { setVisible(false); setState('loading'); try { - const rec = await getRecommendation(); + const rec = await getRecommendation(recentTip); if (!rec) { setState('empty'); return; @@ -62,10 +62,11 @@ export default function TipPage() { const react = async (action: 'done' | 'dismiss' | 'snooze') => { if (!tip) return; + const snoozedContent = action === 'snooze' ? tip.content : undefined; setVisible(false); setState('done'); await sendFeedback(tip.id, { action }); - setTimeout(() => loadTip(), 700); + setTimeout(() => loadTip(snoozedContent), 700); }; const onPointerDown = () => { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index e270809..f69db3d 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -23,9 +23,12 @@ export async function getSession() { return apiFetch<{ user: { id: string; email: string; name?: string; image?: string } | null }>('/auth/session'); } -export async function getRecommendation(): Promise { +export async function getRecommendation(recentTip?: string): Promise { try { - return await apiFetch('/recommend', { method: 'POST' }); + return await apiFetch('/recommend', { + method: 'POST', + body: JSON.stringify(recentTip ? { recent_tip: recentTip } : {}), + }); } catch (e: any) { if (e.status === 204 || e.status === 422) return null; throw e; diff --git a/ml/serving/main.py b/ml/serving/main.py index 4e68ad1..4013d8c 100644 --- a/ml/serving/main.py +++ b/ml/serving/main.py @@ -233,6 +233,7 @@ class RecommendRequest(BaseModel): hour_of_day: int = 12 day_of_week: int = 0 science_destiny: int = 50 # 0=science (data-driven), 100=destiny (intuitive) + recent_tip: Optional[str] = None # content of last snoozed tip; LLM avoids repeating it class TipResult(BaseModel): @@ -430,6 +431,7 @@ async def recommend(req: RecommendRequest) -> RecommendResponse: hour_of_day=req.hour_of_day, day_of_week=req.day_of_week, science_destiny=req.science_destiny, + recent_tip=req.recent_tip, ) _end_span(ctx_span, outputs={"message_count": len(messages)}) diff --git a/ml/serving/prompts.py b/ml/serving/prompts.py index 3342c04..98ee870 100644 --- a/ml/serving/prompts.py +++ b/ml/serving/prompts.py @@ -161,16 +161,21 @@ def build_orchestrator_messages( hour_of_day: int, day_of_week: int, science_destiny: int = 50, + recent_tip: str | None = None, ) -> list[dict]: """Build the [system, user] message list for the orchestrator LLM call. agent_outputs: list of {agent_id, prompt_text} dicts. Falls back to raw task summary when agent_outputs is empty. + recent_tip: content of a tip the user just snoozed — generate something different. """ style_hint = _science_destiny_instruction(science_destiny) system = _SYS_V4_ORCHESTRATOR + (f"\n\n{style_hint}" if style_hint else "") lines = [f"Current time: {hour_of_day:02d}:00, day_of_week={day_of_week}", ""] + if recent_tip: + lines.append(f"The user snoozed this tip (do NOT repeat it or anything similar): \"{recent_tip}\"") + lines.append("") if agent_outputs: lines.append("Context from analysis agents:") for s in agent_outputs: diff --git a/services/api/src/routes/recommender.ts b/services/api/src/routes/recommender.ts index 0626bc1..8b1bb89 100644 --- a/services/api/src/routes/recommender.ts +++ b/services/api/src/routes/recommender.ts @@ -7,7 +7,7 @@ import { eq, and, desc } from 'drizzle-orm'; import { requireAuth, AuthenticatedRequest } from '../middleware/session.js'; import { config } from '../config.js'; import { bus } from '../events/bus.js'; -import type { TipCandidate, Signal } from '@oo/shared-types'; +import type { Tip, Signal } from '@oo/shared-types'; import { todoistSource, dueAgeDays } from '../signals/todoist.js'; export { dueAgeDays }; import { googleHealthSource } from '../signals/google-health.js'; @@ -26,32 +26,12 @@ export const _clearSignalCacheForTests = () => { googleHealthSource.clearCache(); }; -// --------------------------------------------------------------------------- -// Signal → TipCandidate conversion -// --------------------------------------------------------------------------- -function signalToCandidate(signal: Signal): TipCandidate { - return { - id: signal.id, - content: signal.content, - source: signal.source as TipCandidate['source'], - kind: signal.kind as TipCandidate['kind'], - sourceId: (signal.metadata.todoistId as string | undefined) ?? undefined, - createdAt: signal.timestamp, - features: signal.features, - }; -} - -function randomPolicy(candidates: TipCandidate[]): TipCandidate | null { - if (!candidates.length) return null; - return candidates[Math.floor(Math.random() * candidates.length)]; -} - // --------------------------------------------------------------------------- // Orchestrator: fetch agent snippets + call ml/serving /recommend // --------------------------------------------------------------------------- interface OrchestratorResult { - tip: TipCandidate; + tip: Tip; model: string | null; agentIds: string[]; } @@ -72,6 +52,7 @@ async function fetchOrchestratorTip( hour: number, dayOfWeek: number, traceparent?: string, + recentTip?: string, ): Promise { const [allAgentRows, eligibleIds, scienceDestiny] = await Promise.all([ getActiveAgentOutputs(userId), @@ -93,7 +74,7 @@ async function fetchOrchestratorTip( const res = await fetch(`${config.ML_SERVING_URL}/recommend`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(traceparent ? { traceparent } : {}) }, - body: JSON.stringify({ user_id: userId, agent_outputs: agentOutputs, tasks, hour_of_day: hour, day_of_week: dayOfWeek, science_destiny: scienceDestiny ?? 50 }), + body: JSON.stringify({ user_id: userId, agent_outputs: agentOutputs, tasks, hour_of_day: hour, day_of_week: dayOfWeek, science_destiny: scienceDestiny ?? 50, recent_tip: recentTip ?? null }), signal: AbortSignal.timeout(15_000), }); if (!res.ok) return null; @@ -110,7 +91,6 @@ async function fetchOrchestratorTip( kind: 'advice' as const, rationale: data.tip.rationale, createdAt: now, - features: { is_overdue: false, task_age_days: 0, priority: 1 }, }, model: data.model ?? null, agentIds: agentOutputs.map((a) => a.agent_id), @@ -127,6 +107,7 @@ async function fetchOrchestratorTip( router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => { const hour = new Date().getHours(); const dayOfWeek = new Date().getDay(); + const { recent_tip: recentTip } = req.body as { recent_tip?: string }; const anyToken = await db .select({ id: integrationTokens.id }) @@ -142,16 +123,16 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re const signals = await aggregator.fetchAll(req.userId!); const t0 = Date.now(); - const orchestrated = await fetchOrchestratorTip(req.userId!, signals, hour, dayOfWeek, req.traceparent); + const orchestrated = await fetchOrchestratorTip(req.userId!, signals, hour, dayOfWeek, req.traceparent, recentTip); const latencyMs = Date.now() - t0; - const tip = orchestrated?.tip ?? randomPolicy(signals.map(signalToCandidate)); - if (!tip) { + if (!orchestrated) { res.status(204).end(); return; } - const policy = orchestrated ? 'orchestrator' : 'random'; + const tip = orchestrated.tip; + const policy = 'orchestrator'; const servedAt = new Date().toISOString(); await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt }); @@ -162,16 +143,12 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re tipId: tip.id, policy, mlScore: null, - featuresJson: JSON.stringify( - orchestrated - ? { agent_ids: orchestrated.agentIds, hour_of_day: hour, day_of_week: dayOfWeek } - : { ...tip.features, hour_of_day: hour, day_of_week: dayOfWeek }, - ), - candidateCount: orchestrated ? 1 : signals.length, + featuresJson: JSON.stringify({ agent_ids: orchestrated.agentIds, hour_of_day: hour, day_of_week: dayOfWeek }), + candidateCount: 1, latencyMs, servedAt, - promptVersion: orchestrated ? 'v4-orchestrator' : null, - llmModel: orchestrated ? orchestrated.model : null, + promptVersion: 'v4-orchestrator', + llmModel: orchestrated.model, tipKind: tip.kind ?? null, });