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

@@ -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<OrchestratorResult | null> {
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,
});