feat(serving): replace MLflow run logging with native trace spans

Convert ml-serving from isolated MLflow runs to nested traces using
mlflow.start_span_no_context(). The recommend endpoint now emits a full
span tree: recommend (CHAIN) → build_context (TOOL), agent:* (AGENT) ×N,
llm_orchestrator (LLM). Compute and infer endpoints each emit a single span.

Supporting changes:
- mlflow-skinny>=3.1.0 added to requirements
- MLflow configured with --serve-artifacts + mlflow-artifacts:/ default root
  for cross-container artifact proxy (spans now persist from ml-serving)
- --allowed-hosts extended to include mlflow:5000 (SDK includes port in Host)
- science_destiny slider wired through prompts.py and recommend endpoint
- Config page exposes science/destiny slider (0=data-driven, 100=intuitive)
- Tip page shows rationale inline on tap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 08:26:05 +00:00
parent afacc34969
commit 161e654027
14 changed files with 419 additions and 141 deletions

View File

@@ -35,7 +35,7 @@ const AGENT_C = { ...MANIFEST_DEFAULTS, id: 'agent-c', required_consents: ['data
beforeAll(async () => {
await testDb.insert(users).values({
id: 'u1', email: 'u@test.com', name: null, image: null, role: 'user',
consentGiven: false, createdAt: NOW,
createdAt: NOW,
});
});

View File

@@ -213,7 +213,7 @@ describe('POST /recommend integration', () => {
});
// Intercept the /recommend body to inspect what agent_outputs were sent
const origFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
const origFetch = globalThis.fetch as unknown as (url: string, init?: RequestInit) => Promise<Response>;
const wrappedFetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
if (String(url).includes('/recommend') && init?.body) {
const body = JSON.parse(init.body as string);

View File

@@ -166,7 +166,7 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, tasks, profile, feedback_history: feedbackHistory, agent_prefs: agentPrefs }),
signal: AbortSignal.timeout(15_000),
signal: AbortSignal.timeout(60_000),
});
if (!mlResp.ok) {

View File

@@ -2,7 +2,7 @@ import { type Router as ExpressRouter, Router, Response } from 'express';
import { nanoid } from 'nanoid';
import { logger } from '../logger.js';
import { db } from '../db/index.js';
import { integrationTokens, tipFeedback, tipViews, tipScores } from '../db/schema.js';
import { integrationTokens, tipFeedback, tipViews, tipScores, userPreferences } from '../db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { config } from '../config.js';
@@ -52,6 +52,16 @@ interface OrchestratorResult {
agentIds: string[];
}
async function loadOrchestratorPref<T>(userId: string, key: string): Promise<T | undefined> {
const rows = await db
.select({ valueJson: userPreferences.valueJson })
.from(userPreferences)
.where(and(eq(userPreferences.userId, userId), eq(userPreferences.scope, 'orchestrator'), eq(userPreferences.key, key)))
.limit(1);
if (!rows.length) return undefined;
try { return JSON.parse(rows[0].valueJson) as T; } catch { return undefined; }
}
async function fetchOrchestratorTip(
userId: string,
signals: Signal[],
@@ -59,9 +69,10 @@ async function fetchOrchestratorTip(
dayOfWeek: number,
traceparent?: string,
): Promise<OrchestratorResult | null> {
const [allAgentRows, eligibleIds] = await Promise.all([
const [allAgentRows, eligibleIds, scienceDestiny] = await Promise.all([
getActiveAgentOutputs(userId),
getEligibleAgentIds(userId),
loadOrchestratorPref<number>(userId, 'science_destiny'),
]);
const agentOutputs = allAgentRows
.filter((r) => eligibleIds.has(r.agentId))
@@ -78,7 +89,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 }),
body: JSON.stringify({ user_id: userId, agent_outputs: agentOutputs, tasks, hour_of_day: hour, day_of_week: dayOfWeek, science_destiny: scienceDestiny ?? 50 }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return null;

View File

@@ -68,14 +68,13 @@ async function runCycle(agentIds: string[]): Promise<void> {
let failed = 0;
for (const userId of userIds) {
const results = await Promise.allSettled(
agentIds.map((agentId) => computeAndStore(userId, agentId)),
);
for (const r of results) {
if (r.status === 'fulfilled') ok++;
else {
for (const agentId of agentIds) {
try {
await computeAndStore(userId, agentId);
ok++;
} catch (err: any) {
failed++;
logger.error({ err: r.reason, userId }, 'agent-scheduler: compute error');
logger.error({ err, userId, agentId }, 'agent-scheduler: compute error');
}
}
}