feat(api): orchestrator cutover — replace bandit with multi-agent pipeline (ADR-0013 step 6)
POST /recommend now calls ml/serving /recommend with pre-computed agent snippets + task context instead of /generate + /score/egreedy/v2. Falls back to a random signal candidate when ml/serving is unavailable. Removes: remotePolicy, fetchLlmCandidates, sendRewardWithRetry, candidateCache, pickPromptVersion. Feedback handler keeps inferReward + tipFeedback writes for observability; reward delivery to the bandit is gone. tipScores.policy is now 'orchestrator'; promptVersion is 'v4-orchestrator'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
* inside beforeAll (same pattern as admin.test.ts) to avoid TDZ issues.
|
||||
* Uses http.request (not fetch) as the test client so that globalThis.fetch
|
||||
* mocking doesn't interfere with the test runner itself.
|
||||
*
|
||||
* The orchestrator path (ADR-0013): signals fetched for task context/fallback,
|
||||
* then ml/serving /recommend called. agent_outputs table is empty in tests so
|
||||
* the orchestrator always uses the raw-task fallback path.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
@@ -48,7 +52,7 @@ describe('POST /recommend integration', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let savedFetch: typeof globalThis.fetch;
|
||||
let clearCache: () => void;
|
||||
let clearSignalCache: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.insert(users).values({
|
||||
@@ -58,11 +62,12 @@ describe('POST /recommend integration', () => {
|
||||
await testDb.insert(integrationTokens).values({
|
||||
id: 'tok-1', userId: 'user-1', provider: 'todoist',
|
||||
accessToken: 'fake-token', connectedAt: new Date().toISOString(),
|
||||
tokenStatus: 'active',
|
||||
});
|
||||
|
||||
const mod = await import('../recommender.js');
|
||||
const { recommenderRouter } = mod;
|
||||
clearCache = (mod as any)._clearCandidateCacheForTests;
|
||||
clearSignalCache = (mod as any)._clearSignalCacheForTests;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', recommenderRouter);
|
||||
@@ -74,19 +79,22 @@ describe('POST /recommend integration', () => {
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = savedFetch;
|
||||
clearCache?.();
|
||||
clearSignalCache?.();
|
||||
});
|
||||
|
||||
it('returns 204 when Todoist + LLM both return empty', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ results: [] }),
|
||||
} as any);
|
||||
it('returns 204 when Todoist is empty and orchestrator fails', async () => {
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (String(url).includes('todoist.com')) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) } as any);
|
||||
}
|
||||
// /recommend fails → orchestrator returns null, random fallback also empty → 204
|
||||
return Promise.resolve({ ok: false, status: 503 } as any);
|
||||
});
|
||||
const { status } = await post(`${baseUrl}/api/recommend`);
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
|
||||
it('serves todoist tip and writes correct tip_scores columns', async () => {
|
||||
it('serves orchestrator tip and writes correct tip_scores columns', async () => {
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (String(url).includes('todoist.com')) {
|
||||
return Promise.resolve({
|
||||
@@ -96,55 +104,16 @@ describe('POST /recommend integration', () => {
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
if (String(url).includes('/generate')) {
|
||||
return Promise.resolve({ ok: false, status: 503, json: async () => ({}) } as any);
|
||||
}
|
||||
if (String(url).includes('/score')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ tip_id: 'todoist:task-1', score: 0.8 }),
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as any);
|
||||
});
|
||||
|
||||
const { status, body } = await post(`${baseUrl}/api/recommend`);
|
||||
expect(status).toBe(200);
|
||||
expect(body.tip.source).toBe('todoist');
|
||||
expect(body.tip.kind).toBe('task');
|
||||
|
||||
const rows = await testDb.select().from(tipScores);
|
||||
const row = rows[rows.length - 1];
|
||||
expect(row.tipKind).toBe('task');
|
||||
expect(row.promptVersion).toBeNull();
|
||||
expect(row.llmModel).toBeNull();
|
||||
});
|
||||
|
||||
it('writes prompt_version + llm_model when LLM tip is served', async () => {
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (String(url).includes('todoist.com')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ results: [] }),
|
||||
} as any);
|
||||
}
|
||||
if (String(url).includes('/generate')) {
|
||||
if (String(url).includes('/recommend')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({
|
||||
candidates: [{ id: 'adv-1', content: 'Take a break.', rationale: 'You deserve it.' }],
|
||||
tip: { id: 'adv-1', content: 'Take a break.', rationale: 'You deserve it.' },
|
||||
model: 'tip-generator',
|
||||
prompt_version: 'v1',
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
if (String(url).includes('/score')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ tip_id: 'llm:adv-1', score: 0.9 }),
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as any);
|
||||
return Promise.resolve({ ok: false, status: 500 } as any);
|
||||
});
|
||||
|
||||
const { status, body } = await post(`${baseUrl}/api/recommend`);
|
||||
@@ -155,12 +124,14 @@ describe('POST /recommend integration', () => {
|
||||
|
||||
const rows = await testDb.select().from(tipScores);
|
||||
const row = rows[rows.length - 1];
|
||||
expect(row.promptVersion).toBe('v1');
|
||||
expect(row.policy).toBe('orchestrator');
|
||||
expect(row.promptVersion).toBe('v4-orchestrator');
|
||||
expect(row.llmModel).toBe('tip-generator');
|
||||
expect(row.mlScore).toBeNull();
|
||||
expect(row.tipKind).toBe('advice');
|
||||
});
|
||||
|
||||
it('falls back to todoist tip when /generate returns non-200', async () => {
|
||||
it('falls back to random signal tip when orchestrator fails', async () => {
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (String(url).includes('todoist.com')) {
|
||||
return Promise.resolve({
|
||||
@@ -170,22 +141,18 @@ describe('POST /recommend integration', () => {
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
if (String(url).includes('/generate')) {
|
||||
return Promise.resolve({ ok: false, status: 502, json: async () => ({}) } as any);
|
||||
}
|
||||
if (String(url).includes('/score')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ tip_id: 'todoist:fallback-1', score: 0.5 }),
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as any);
|
||||
// /recommend fails → falls back to random signal candidate
|
||||
return Promise.resolve({ ok: false, status: 502 } as any);
|
||||
});
|
||||
|
||||
const { status, body } = await post(`${baseUrl}/api/recommend`);
|
||||
expect([200, 204]).toContain(status);
|
||||
if (status === 200) {
|
||||
expect(body.tip.source).toBe('todoist');
|
||||
}
|
||||
expect(status).toBe(200);
|
||||
expect(body.tip.source).toBe('todoist');
|
||||
|
||||
const rows = await testDb.select().from(tipScores);
|
||||
const row = rows[rows.length - 1];
|
||||
expect(row.policy).toBe('random');
|
||||
expect(row.promptVersion).toBeNull();
|
||||
expect(row.llmModel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user