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