feat: show the conversation evidence behind each learning trajectory
Reviewers were asked to include or exclude learning trajectories while only seeing metadata — credit class, confidence, status. The decisions and their redacted context turns were already captured and stored, but no endpoint returned them, so the evidence was unreachable from the UI. Add GET /api/learning/trajectories/:id and an expandable row on the learning page that fetches on demand. Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit and imageOutcomeAt. The learning loop added these to SentMessageRecord and they flow out of /api/messages, but the strict contract rejected them, breaking the dashboard. Enable generation with shadow mode off so eligible examples reach reply prompts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
687abab82b
commit
142378df33
25 files changed
+836
-28
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import express from 'express';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LearningTrajectoryDetailResponseSchema } from '../../packages/api-contracts/src/index.js';
|
||||
import { runMigrations } from '../../src/database/client.js';
|
||||
import { ConversationLearningRepository } from '../../src/database/conversation-learning-repository.js';
|
||||
import type { WebContext } from '../../src/web/context.js';
|
||||
import { createLearningRouter } from '../../src/web/routes/learning.js';
|
||||
|
||||
const ACCOUNT = '@t2_learner:reddit.com';
|
||||
const CONVERSATION = '!room:reddit.com';
|
||||
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||
|
||||
async function call(app: express.Express, path: string): Promise<{ status: number; json: any }> {
|
||||
const server = app.listen(0);
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`);
|
||||
const text = await response.text();
|
||||
return { status: response.status, json: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => (
|
||||
new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
)));
|
||||
});
|
||||
|
||||
describe('GET /api/learning/trajectories/:id', () => {
|
||||
let db: Database.Database;
|
||||
let repo: ConversationLearningRepository;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
db.prepare(`
|
||||
INSERT INTO reddit_accounts (
|
||||
account_id, reddit_username, matrix_user_id, status, is_active,
|
||||
cookies_json, matrix_credentials_json, created_at, updated_at
|
||||
) VALUES (?, 'learner', ?, 'verified', 1, '[]', '{}', 0, 0)
|
||||
`).run(ACCOUNT, ACCOUNT);
|
||||
repo = new ConversationLearningRepository(db, ACCOUNT);
|
||||
repo.upsertTrajectory({
|
||||
id: 'traj-1',
|
||||
conversationId: CONVERSATION,
|
||||
sentMessageId: null,
|
||||
subreddit: 'cats',
|
||||
sourceType: 'chat',
|
||||
policyMode: 'r4r-media',
|
||||
firstHumanEventId: '$human1',
|
||||
outcomeEventId: '$image1',
|
||||
outcomeType: 'image-received',
|
||||
creditClass: 'human-created-success',
|
||||
attributionConfidence: 'exact',
|
||||
status: 'eligible',
|
||||
maturityAt: 1_000,
|
||||
invalidatedAt: null,
|
||||
invalidationReason: null,
|
||||
now: 900,
|
||||
});
|
||||
repo.upsertDecision({
|
||||
id: 'dec-1',
|
||||
trajectoryId: 'traj-1',
|
||||
decisionEventId: '$human1',
|
||||
stage: 'media-invitation',
|
||||
context: [{
|
||||
eventId: '$in1', timestamp: 500, role: 'recipient', actor: 'recipient',
|
||||
kind: 'inbound-text', text: 'hey there',
|
||||
}],
|
||||
chosenResponse: 'no pressure, only if you want to',
|
||||
turnsUntilOutcome: 2,
|
||||
isDirectMediaInvitation: true,
|
||||
qualityStatus: 'eligible',
|
||||
now: 900,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
config: { learning: { captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1' } },
|
||||
services: {
|
||||
accountRuntimes: {
|
||||
list: () => [{ accountId: ACCOUNT }],
|
||||
get: () => ({ resources: { conversationLearningRepo: repo } }),
|
||||
},
|
||||
},
|
||||
} as unknown as WebContext;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', createLearningRouter(ctx));
|
||||
});
|
||||
|
||||
it('returns the redacted decisions behind a trajectory', async () => {
|
||||
const { status, json } = await call(app, '/api/learning/trajectories/traj-1');
|
||||
expect(status).toBe(200);
|
||||
const parsed = LearningTrajectoryDetailResponseSchema.parse(json);
|
||||
expect(parsed.trajectory.conversationId).toBe(CONVERSATION);
|
||||
expect(parsed.trajectory.creditClass).toBe('human-created-success');
|
||||
expect(parsed.decisions).toHaveLength(1);
|
||||
expect(parsed.decisions[0]?.chosenResponse).toBe('no pressure, only if you want to');
|
||||
expect(parsed.decisions[0]?.context[0]?.text).toBe('hey there');
|
||||
expect(parsed.decisions[0]?.isDirectMediaInvitation).toBe(true);
|
||||
});
|
||||
|
||||
it('does not nest decisions inside the trajectory object', async () => {
|
||||
const { json } = await call(app, '/api/learning/trajectories/traj-1');
|
||||
expect(json.trajectory.decisions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s for an unknown trajectory', async () => {
|
||||
const { status, json } = await call(app, '/api/learning/trajectories/missing');
|
||||
expect(status).toBe(404);
|
||||
expect(json.error).toBe('Learning trajectory not found');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { StatsResponseSchema } from '../../packages/api-contracts/src/index.js';
|
||||
import { MessagesResponseSchema, StatsResponseSchema } from '../../packages/api-contracts/src/index.js';
|
||||
import type { WebContext } from '../../src/web/context.js';
|
||||
import { createStatsRouter } from '../../src/web/routes/stats.js';
|
||||
|
||||
@@ -95,3 +95,44 @@ describe('stats route account status', () => {
|
||||
expect(stats.accounts[1]?.invitationCooldown).not.toHaveProperty('setAt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('messages route contract', () => {
|
||||
it('accepts the attribution fields the learning loop adds to sent messages', async () => {
|
||||
const context = {
|
||||
services: {},
|
||||
repos: {
|
||||
messages: {
|
||||
getRecent: () => [{
|
||||
id: 1,
|
||||
postId: 'p1',
|
||||
recipient: 'someone',
|
||||
subject: 'hey',
|
||||
message: 'hello there',
|
||||
sentAt: 1_700_000_000_000,
|
||||
gotResponse: true,
|
||||
gotImage: true,
|
||||
responseCheckedAt: 1_700_000_100_000,
|
||||
strategyId: null,
|
||||
chatRoomId: '!room:reddit.com',
|
||||
// Added by the conversation learning loop; must not break the dashboard.
|
||||
matrixEventId: '$opener1',
|
||||
imageOutcomeCredit: 'human-created-success',
|
||||
imageOutcomeAt: 1_700_000_200_000,
|
||||
}],
|
||||
},
|
||||
},
|
||||
activity: { recent: () => [], totalProcessed: () => 0 },
|
||||
getBotRunning: () => true,
|
||||
getBotMode: () => 'running',
|
||||
getMessagesThisHour: () => 0,
|
||||
} as unknown as WebContext;
|
||||
const app = express().use('/api', createStatsRouter(context));
|
||||
|
||||
const response = await request(app, '/api/messages');
|
||||
const messages = MessagesResponseSchema.parse(await response.json());
|
||||
|
||||
expect(messages[0]?.matrixEventId).toBe('$opener1');
|
||||
expect(messages[0]?.imageOutcomeCredit).toBe('human-created-success');
|
||||
expect(messages[0]?.imageOutcomeAt).toBe(1_700_000_200_000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user