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
@@ -43,6 +43,20 @@ type AccountRow = {
|
||||
last_imported_at: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tables whose foreign key to `reddit_accounts` predates cascading deletes,
|
||||
* ordered children first so each delete leaves no dangling composite reference.
|
||||
*/
|
||||
const ACCOUNT_OWNED_TABLES = [
|
||||
'conversation_outcomes',
|
||||
'successful_messages',
|
||||
'delivery_outbox',
|
||||
'sent_messages',
|
||||
'conversation_states',
|
||||
'activity_log',
|
||||
'processed_posts',
|
||||
] as const;
|
||||
|
||||
export class RedditAccountsRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
@@ -128,6 +142,23 @@ export class RedditAccountsRepository {
|
||||
`).run(active ? 1 : 0, status, now, accountId).changes === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the account and everything it owns. Account-scoped tables added
|
||||
* after the ownership migration cascade on their own, but the tables that
|
||||
* migration created reference `reddit_accounts` with no delete action, so
|
||||
* their rows are cleared explicitly — children before parents — or SQLite
|
||||
* would refuse the delete.
|
||||
*/
|
||||
delete(accountId: string): boolean {
|
||||
requireText(accountId, 'accountId');
|
||||
return this.db.transaction((id: string) => {
|
||||
for (const table of ACCOUNT_OWNED_TABLES) {
|
||||
this.db.prepare(`DELETE FROM ${table} WHERE account_id = ?`).run(id);
|
||||
}
|
||||
return this.db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(id).changes === 1;
|
||||
})(accountId);
|
||||
}
|
||||
|
||||
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE reddit_accounts
|
||||
|
||||
@@ -90,6 +90,22 @@ export class RedditAccountsService {
|
||||
return this.importSession(cookies, credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the account's loop and drop it, along with the content it owns, from
|
||||
* the database. Downloaded media files stay on disk: the media library can
|
||||
* hand an image to another account, so the files are not ours alone to bin.
|
||||
*/
|
||||
async remove(accountId: string): Promise<RedditAccountSummary> {
|
||||
const account = this.get(accountId);
|
||||
if (!account) throw new Error('Reddit account not found');
|
||||
await this.runtimes?.stop(accountId);
|
||||
if (!this.accounts.delete(accountId)) throw new Error('Reddit account not found');
|
||||
if (this.auth.getSnapshot().accountId === accountId) {
|
||||
this.auth.invalidate(`Reddit account u/${account.redditUsername} was removed`);
|
||||
}
|
||||
return { ...account, isRunning: false, status: 'login-required' };
|
||||
}
|
||||
|
||||
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
|
||||
try {
|
||||
const verified = await this.importer.import(cookies, credentials);
|
||||
|
||||
+10
-1
@@ -20,7 +20,12 @@ import {
|
||||
GrowthSubredditCreateRequestSchema,
|
||||
GrowthSubredditUpdateRequestSchema,
|
||||
} from './growth-contracts.js';
|
||||
import { LearningOverviewResponseSchema, LearningReviewRequestSchema, LearningReviewResponseSchema } from './learning-contracts.js';
|
||||
import {
|
||||
LearningOverviewResponseSchema,
|
||||
LearningReviewRequestSchema,
|
||||
LearningReviewResponseSchema,
|
||||
LearningTrajectoryDetailResponseSchema,
|
||||
} from './learning-contracts.js';
|
||||
|
||||
export type ApiAccess = 'read' | 'control' | 'admin';
|
||||
export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
@@ -84,6 +89,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
operation('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']),
|
||||
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']),
|
||||
operation('getLearningOverview', 'GET', '/api/learning', 'Inspect attributed learning trajectories and credit', ['Learning'], { responseSchema: LearningOverviewResponseSchema }),
|
||||
operation('getLearningTrajectory', 'GET', '/api/learning/trajectories/{id}', 'Inspect the redacted decisions behind a learning trajectory', ['Learning'], { responseSchema: LearningTrajectoryDetailResponseSchema }),
|
||||
operation('reviewLearningTrajectory', 'PATCH', '/api/learning/trajectories/{id}', 'Include or exclude a learning trajectory', ['Learning'], { requestSchema: LearningReviewRequestSchema, responseSchema: LearningReviewResponseSchema }),
|
||||
operation('getActivity', 'GET', '/api/activity', 'Get recent activity', ['Dashboard']),
|
||||
operation('getPostsByAuthor', 'GET', '/api/posts/by-author/{username}', 'Get processed posts by author', ['Dashboard']),
|
||||
@@ -229,6 +235,9 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
operation('importSession', 'POST', '/api/login/session-import', 'Import Reddit and Matrix credentials', ['Accounts']),
|
||||
operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']),
|
||||
operation('activateAccount', 'POST', '/api/accounts/{accountId}/activate', 'Activate a Reddit account', ['Accounts']),
|
||||
operation('removeAccount', 'DELETE', '/api/accounts/{accountId}', 'Remove a Reddit account and the content it owns', ['Accounts'], {
|
||||
access: 'admin',
|
||||
}),
|
||||
operation('streamLogs', 'GET', '/api/logs/stream', 'Stream legacy log events', ['Events'], { streaming: true }),
|
||||
operation('streamEvents', 'GET', '/api/events/stream', 'Stream typed application events', ['Events'], { streaming: true }),
|
||||
operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']),
|
||||
|
||||
@@ -26,3 +26,23 @@ export const LearningOverviewResponseSchema = z.strictObject({
|
||||
});
|
||||
export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) });
|
||||
export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) });
|
||||
export const LearningStageSchema = z.enum([
|
||||
'rapport', 'answer', 'flirt', 'disclosure', 'media-invitation', 'handoff', 'unknown',
|
||||
]);
|
||||
export const LearningDecisionStatusSchema = z.enum(['eligible', 'excluded', 'invalidated', 'needs-review']);
|
||||
export const LearningTurnSchema = z.strictObject({
|
||||
eventId: z.string(), timestamp: z.number(), role: z.enum(['recipient', 'operator']),
|
||||
actor: z.enum(['recipient', 'ai', 'human']),
|
||||
kind: z.enum(['inbound-text', 'opener', 'automation-reply', 'manual-text', 'library-reciprocal']),
|
||||
text: z.string(),
|
||||
});
|
||||
export const LearningDecisionSchema = z.strictObject({
|
||||
id: z.string(), trajectoryId: z.string(), accountId: z.string(), decisionEventId: z.string(),
|
||||
stage: LearningStageSchema, context: z.array(LearningTurnSchema), chosenResponse: z.string(),
|
||||
turnsUntilOutcome: z.number().int(), isDirectMediaInvitation: z.boolean(),
|
||||
qualityStatus: LearningDecisionStatusSchema, createdAt: z.number(), updatedAt: z.number(),
|
||||
});
|
||||
export const LearningTrajectoryDetailResponseSchema = z.strictObject({
|
||||
trajectory: LearningTrajectorySchema,
|
||||
decisions: z.array(LearningDecisionSchema),
|
||||
});
|
||||
@@ -35,6 +35,17 @@ export function createLearningRouter(ctx: WebContext): Router {
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/learning/trajectories/:id', (req, res) => {
|
||||
for (const runtime of runtimes()) {
|
||||
const detail = runtime.resources.conversationLearningRepo.getTrajectory(req.params.id);
|
||||
if (!detail) continue;
|
||||
const { decisions, ...trajectory } = detail;
|
||||
res.json({ trajectory, decisions });
|
||||
return;
|
||||
}
|
||||
res.status(404).json({ error: 'Learning trajectory not found' });
|
||||
});
|
||||
|
||||
router.patch('/learning/trajectories/:id', (req, res) => {
|
||||
const parsed = LearningReviewRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -158,5 +158,27 @@ export function createLoginRouter(ctx: WebContext): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/accounts/:accountId', async (req, res) => {
|
||||
const accounts = ctx.services.redditAccounts;
|
||||
if (!accounts) {
|
||||
res.status(503).json({ ok: false, error: 'Reddit account service not initialised' });
|
||||
return;
|
||||
}
|
||||
const accountId = decodeURIComponent(req.params.accountId);
|
||||
try {
|
||||
const account = await accounts.remove(accountId);
|
||||
ctx.logger.info(`Removed Reddit account u/${account.redditUsername} and the content it owned`);
|
||||
res.json({ ok: true, account });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not remove Reddit account';
|
||||
if (message === 'Reddit account not found') {
|
||||
res.status(404).json({ ok: false, error: message });
|
||||
return;
|
||||
}
|
||||
ctx.logger.error('Failed to remove Reddit account:', error);
|
||||
res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in new issue
Block a user