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]);
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 = () => {

View File

@@ -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<RecommendResponse | null> {
export async function getRecommendation(recentTip?: string): Promise<RecommendResponse | null> {
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) {
if (e.status === 204 || e.status === 422) return null;
throw e;

View File

@@ -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)})

View File

@@ -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:

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,
});