fix(recommender): remove Todoist fallback on orchestrator failure; add snooze exclusion

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 13:28:32 +00:00
parent d4b40e2590
commit 59c493323f
5 changed files with 29 additions and 41 deletions

View File

@@ -40,11 +40,11 @@ export default function TipPage() {
} }
}, [state]); }, [state]);
const loadTip = useCallback(async () => { const loadTip = useCallback(async (recentTip?: string) => {
setVisible(false); setVisible(false);
setState('loading'); setState('loading');
try { try {
const rec = await getRecommendation(); const rec = await getRecommendation(recentTip);
if (!rec) { if (!rec) {
setState('empty'); setState('empty');
return; return;
@@ -62,10 +62,11 @@ export default function TipPage() {
const react = async (action: 'done' | 'dismiss' | 'snooze') => { const react = async (action: 'done' | 'dismiss' | 'snooze') => {
if (!tip) return; if (!tip) return;
const snoozedContent = action === 'snooze' ? tip.content : undefined;
setVisible(false); setVisible(false);
setState('done'); setState('done');
await sendFeedback(tip.id, { action }); await sendFeedback(tip.id, { action });
setTimeout(() => loadTip(), 700); setTimeout(() => loadTip(snoozedContent), 700);
}; };
const onPointerDown = () => { const onPointerDown = () => {

View File

@@ -23,9 +23,12 @@ export async function getSession() {
return apiFetch<{ user: { id: string; email: string; name?: string; image?: string } | null }>('/auth/session'); return apiFetch<{ user: { id: string; email: string; name?: string; image?: string } | null }>('/auth/session');
} }
export async function getRecommendation(): Promise<RecommendResponse | null> { export async function getRecommendation(recentTip?: string): Promise<RecommendResponse | null> {
try { try {
return await apiFetch<RecommendResponse>('/recommend', { method: 'POST' }); return await apiFetch<RecommendResponse>('/recommend', {
method: 'POST',
body: JSON.stringify(recentTip ? { recent_tip: recentTip } : {}),
});
} catch (e: any) { } catch (e: any) {
if (e.status === 204 || e.status === 422) return null; if (e.status === 204 || e.status === 422) return null;
throw e; throw e;

View File

@@ -233,6 +233,7 @@ class RecommendRequest(BaseModel):
hour_of_day: int = 12 hour_of_day: int = 12
day_of_week: int = 0 day_of_week: int = 0
science_destiny: int = 50 # 0=science (data-driven), 100=destiny (intuitive) 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): class TipResult(BaseModel):
@@ -430,6 +431,7 @@ async def recommend(req: RecommendRequest) -> RecommendResponse:
hour_of_day=req.hour_of_day, hour_of_day=req.hour_of_day,
day_of_week=req.day_of_week, day_of_week=req.day_of_week,
science_destiny=req.science_destiny, science_destiny=req.science_destiny,
recent_tip=req.recent_tip,
) )
_end_span(ctx_span, outputs={"message_count": len(messages)}) _end_span(ctx_span, outputs={"message_count": len(messages)})

View File

@@ -161,16 +161,21 @@ def build_orchestrator_messages(
hour_of_day: int, hour_of_day: int,
day_of_week: int, day_of_week: int,
science_destiny: int = 50, science_destiny: int = 50,
recent_tip: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Build the [system, user] message list for the orchestrator LLM call. """Build the [system, user] message list for the orchestrator LLM call.
agent_outputs: list of {agent_id, prompt_text} dicts. agent_outputs: list of {agent_id, prompt_text} dicts.
Falls back to raw task summary when agent_outputs is empty. 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) style_hint = _science_destiny_instruction(science_destiny)
system = _SYS_V4_ORCHESTRATOR + (f"\n\n{style_hint}" if style_hint else "") 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}", ""] 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: if agent_outputs:
lines.append("Context from analysis agents:") lines.append("Context from analysis agents:")
for s in agent_outputs: for s in agent_outputs:

View File

@@ -7,7 +7,7 @@ import { eq, and, desc } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { bus } from '../events/bus.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'; import { todoistSource, dueAgeDays } from '../signals/todoist.js';
export { dueAgeDays }; export { dueAgeDays };
import { googleHealthSource } from '../signals/google-health.js'; import { googleHealthSource } from '../signals/google-health.js';
@@ -26,32 +26,12 @@ export const _clearSignalCacheForTests = () => {
googleHealthSource.clearCache(); 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 // Orchestrator: fetch agent snippets + call ml/serving /recommend
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface OrchestratorResult { interface OrchestratorResult {
tip: TipCandidate; tip: Tip;
model: string | null; model: string | null;
agentIds: string[]; agentIds: string[];
} }
@@ -72,6 +52,7 @@ async function fetchOrchestratorTip(
hour: number, hour: number,
dayOfWeek: number, dayOfWeek: number,
traceparent?: string, traceparent?: string,
recentTip?: string,
): Promise<OrchestratorResult | null> { ): Promise<OrchestratorResult | null> {
const [allAgentRows, eligibleIds, scienceDestiny] = await Promise.all([ const [allAgentRows, eligibleIds, scienceDestiny] = await Promise.all([
getActiveAgentOutputs(userId), getActiveAgentOutputs(userId),
@@ -93,7 +74,7 @@ async function fetchOrchestratorTip(
const res = await fetch(`${config.ML_SERVING_URL}/recommend`, { const res = await fetch(`${config.ML_SERVING_URL}/recommend`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...(traceparent ? { traceparent } : {}) }, 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), signal: AbortSignal.timeout(15_000),
}); });
if (!res.ok) return null; if (!res.ok) return null;
@@ -110,7 +91,6 @@ async function fetchOrchestratorTip(
kind: 'advice' as const, kind: 'advice' as const,
rationale: data.tip.rationale, rationale: data.tip.rationale,
createdAt: now, createdAt: now,
features: { is_overdue: false, task_age_days: 0, priority: 1 },
}, },
model: data.model ?? null, model: data.model ?? null,
agentIds: agentOutputs.map((a) => a.agent_id), agentIds: agentOutputs.map((a) => a.agent_id),
@@ -127,6 +107,7 @@ async function fetchOrchestratorTip(
router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => { router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const hour = new Date().getHours(); const hour = new Date().getHours();
const dayOfWeek = new Date().getDay(); const dayOfWeek = new Date().getDay();
const { recent_tip: recentTip } = req.body as { recent_tip?: string };
const anyToken = await db const anyToken = await db
.select({ id: integrationTokens.id }) .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 signals = await aggregator.fetchAll(req.userId!);
const t0 = Date.now(); 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 latencyMs = Date.now() - t0;
const tip = orchestrated?.tip ?? randomPolicy(signals.map(signalToCandidate)); if (!orchestrated) {
if (!tip) {
res.status(204).end(); res.status(204).end();
return; return;
} }
const policy = orchestrated ? 'orchestrator' : 'random'; const tip = orchestrated.tip;
const policy = 'orchestrator';
const servedAt = new Date().toISOString(); const servedAt = new Date().toISOString();
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt }); 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, tipId: tip.id,
policy, policy,
mlScore: null, mlScore: null,
featuresJson: JSON.stringify( featuresJson: JSON.stringify({ agent_ids: orchestrated.agentIds, hour_of_day: hour, day_of_week: dayOfWeek }),
orchestrated candidateCount: 1,
? { 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,
latencyMs, latencyMs,
servedAt, servedAt,
promptVersion: orchestrated ? 'v4-orchestrator' : null, promptVersion: 'v4-orchestrator',
llmModel: orchestrated ? orchestrated.model : null, llmModel: orchestrated.model,
tipKind: tip.kind ?? null, tipKind: tip.kind ?? null,
}); });