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:
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user