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:
Jamie BruntonandClaude Opus 5 committed 2026-08-12 08:23:22 +01:00
1 parent 687abab82b
commit 142378df33
25 files changed
+834 -26

No files matched your search

@@ -1,13 +1,19 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { AccountEstablishmentSummary } from '../../../../../packages/api-contracts/src/index.js'; import type {
AccountEstablishmentSummary,
RedditAccountSummary,
} from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { import {
useImportRedditSessionMutation, useImportRedditSessionMutation,
useLoginStatusQuery, useLoginStatusQuery,
useRedditAccountsQuery, useRedditAccountsQuery,
useRemoveRedditAccountMutation,
} from '../settings/settings-queries.js'; } from '../settings/settings-queries.js';
import './accounts.css'; import './accounts.css';
const CONFIRM_TIMEOUT_MS = 8_000;
function errorMessage(error: Error | null): string { function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.'; return error?.message ?? 'Something went wrong.';
} }
@@ -85,6 +91,73 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u
return <span className="pill success">Established</span>; return <span className="pill success">Established</span>;
} }
/**
* Removal is destructive — the account's messages, activity and sync state go
* with it — so the button arms a confirmation that disarms itself if ignored.
*/
function AccountRow({ account }: { account: RedditAccountSummary }) {
const removeMutation = useRemoveRedditAccountMutation();
const [confirmRemove, setConfirmRemove] = useState(false);
useEffect(() => {
if (!confirmRemove) return;
const timer = globalThis.setTimeout(() => setConfirmRemove(false), CONFIRM_TIMEOUT_MS);
return () => globalThis.clearTimeout(timer);
}, [confirmRemove]);
return (
<div className={account.isRunning ? 'account-row is-active' : 'account-row'}>
<div>
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
</div>
<div className="account-row-pills">
{establishmentPill(account.establishment)}
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
{account.isRunning ? 'Running' : 'Paused'}
</span>
</div>
<div className="account-row-actions">
{confirmRemove ? (
<>
<span className="account-remove-warning">
Removes u/{account.redditUsername}, its stored session and all of its messages,
conversations and activity. This cannot be undone.
</span>
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
Cancel
</button>
<button
aria-busy={removeMutation.isPending || undefined}
className="button danger"
disabled={removeMutation.isPending}
onClick={() => removeMutation.mutate(account.accountId)}
type="button"
>
{removeMutation.isPending ? 'Removing…' : 'Confirm removal'}
</button>
</>
) : (
<button
aria-label={`Remove u/${account.redditUsername}`}
className="button secondary"
onClick={() => setConfirmRemove(true)}
type="button"
>
Remove
</button>
)}
</div>
{removeMutation.isError ? (
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
) : null}
{account.establishment && account.establishment.state !== 'unknown' ? (
<EstablishmentDetail establishment={account.establishment} />
) : null}
</div>
);
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string { function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
if (!status) return 'Unknown'; if (!status) return 'Unknown';
if (!status.ready) return 'Browser unavailable'; if (!status.ready) return 'Browser unavailable';
@@ -211,21 +284,7 @@ export function AccountsPage() {
</div> </div>
) : null} ) : null}
{accounts.map((account) => ( {accounts.map((account) => (
<div className={account.isRunning ? 'account-row is-active' : 'account-row'} key={account.accountId}> <AccountRow account={account} key={account.accountId} />
<div>
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
</div>
<div className="account-row-pills">
{establishmentPill(account.establishment)}
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
{account.isRunning ? 'Running' : 'Paused'}
</span>
</div>
{account.establishment && account.establishment.state !== 'unknown' ? (
<EstablishmentDetail establishment={account.establishment} />
) : null}
</div>
))} ))}
{accountsQuery.isSuccess && accounts.length === 0 ? ( {accountsQuery.isSuccess && accounts.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
@@ -9,6 +9,8 @@
.account-row span { color: var(--muted); font-size: .82rem; } .account-row span { color: var(--muted); font-size: .82rem; }
.account-row .pill { font-size: .7rem; } .account-row .pill { font-size: .7rem; }
.account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; } .account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.account-row > .account-row-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .4rem; margin-left: auto; }
.account-row .account-remove-warning { flex-basis: 100%; text-align: right; color: var(--danger-text, var(--muted)); font-size: .78rem; }
/* Establishment evidence sits on its own row beneath the account summary. */ /* Establishment evidence sits on its own row beneath the account summary. */
.account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); } .account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); }
@@ -1,4 +1,10 @@
import { LearningOverviewResponseSchema, LearningReviewResponseSchema, type LearningOverviewResponse } from '../../../../../packages/api-contracts/src/index.js'; import {
LearningOverviewResponseSchema,
LearningReviewResponseSchema,
LearningTrajectoryDetailResponseSchema,
type LearningOverviewResponse,
type LearningTrajectoryDetailResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js'; import { createDashboardRequest } from '../../api/request.js';
async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> { async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> {
@@ -12,6 +18,10 @@ export const learningApi = {
getOverview(): Promise<LearningOverviewResponse> { getOverview(): Promise<LearningOverviewResponse> {
return request('/api/learning').then((response) => parse(response, LearningOverviewResponseSchema)); return request('/api/learning').then((response) => parse(response, LearningOverviewResponseSchema));
}, },
getTrajectory(id: string): Promise<LearningTrajectoryDetailResponse> {
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`)
.then((response) => parse(response, LearningTrajectoryDetailResponseSchema));
},
review(id: string, status: 'eligible' | 'excluded'): Promise<{ success: true }> { review(id: string, status: 'eligible' | 'excluded'): Promise<{ success: true }> {
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, { return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }), method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }),
@@ -1,11 +1,57 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { learningApi } from './learning-api.js'; import { learningApi } from './learning-api.js';
import './learning.css'; import './learning.css';
const humanCredits = new Set(['human-created-success', 'human-improved-success', 'human-completed-ai-trajectory']); const humanCredits = new Set(['human-created-success', 'human-improved-success', 'human-completed-ai-trajectory']);
function TrajectoryDetail({ id }: { id: string }) {
const detail = useQuery({ queryKey: ['learning', 'trajectory', id], queryFn: () => learningApi.getTrajectory(id) });
if (detail.isPending) return <div className="panel-loading" role="status"><span className="spinner" />Loading decisions…</div>;
if (detail.isError) return <div className="error-panel" role="alert">{detail.error.message}</div>;
const { trajectory, decisions } = detail.data;
return (
<div className="trajectory-detail">
<dl className="trajectory-facts">
<div><dt>Conversation</dt><dd>{trajectory.conversationId}</dd></div>
<div><dt>Policy mode</dt><dd>{trajectory.policyMode ?? 'Unknown'}</dd></div>
<div><dt>Outcome</dt><dd>{trajectory.outcomeType.replaceAll('-', ' ')}</dd></div>
<div><dt>Matures</dt><dd>{new Date(trajectory.maturityAt).toLocaleString()}</dd></div>
{trajectory.invalidationReason ? <div><dt>Invalidated</dt><dd>{trajectory.invalidationReason}</dd></div> : null}
</dl>
{decisions.length === 0 ? <p className="feature-empty">No manual decisions were captured for this trajectory.</p> : (
<ol className="decision-list">
{decisions.map((decision) => (
<li key={decision.id} className="decision-card">
<div className="decision-heading">
<span className="decision-stage">{decision.stage.replaceAll('-', ' ')}</span>
{decision.isDirectMediaInvitation ? <span className="decision-flag">Direct media ask</span> : null}
<span className={`trajectory-status status-${decision.qualityStatus}`}>{decision.qualityStatus}</span>
<span className="decision-turns">{decision.turnsUntilOutcome} turn(s) to outcome</span>
</div>
{decision.context.length > 0 ? (
<div className="decision-context">
{decision.context.map((turn) => (
<p key={turn.eventId} className={`turn turn-${turn.role}`}>
<span className="turn-actor">{turn.role === 'recipient' ? 'Them' : turn.actor === 'human' ? 'You' : 'AI'}</span>
<span className="turn-text">{turn.text}</span>
</p>
))}
</div>
) : null}
<p className="decision-chosen"><span className="turn-actor">Your message</span><span className="turn-text">{decision.chosenResponse}</span></p>
</li>
))}
</ol>
)}
<p className="decision-note">Identifiers are redacted before storage. Text shown here is what generation would receive.</p>
</div>
);
}
export function LearningPage() { export function LearningPage() {
const client = useQueryClient(); const client = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null);
const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview }); const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview });
const review = useMutation({ const review = useMutation({
mutationFn: ({ id, status }: { id: string; status: 'eligible' | 'excluded' }) => learningApi.review(id, status), mutationFn: ({ id, status }: { id: string; status: 'eligible' | 'excluded' }) => learningApi.review(id, status),
@@ -27,11 +73,16 @@ export function LearningPage() {
<section className="panel learning-panel" aria-labelledby="trajectory-heading"> <section className="panel learning-panel" aria-labelledby="trajectory-heading">
<div className="panel-heading"><div><span className="eyebrow">Policy {overview.data.policyVersion}</span><h2 id="trajectory-heading">Attributed trajectories</h2></div><span className="sample-size">n = {summary.total}</span></div> <div className="panel-heading"><div><span className="eyebrow">Policy {overview.data.policyVersion}</span><h2 id="trajectory-heading">Attributed trajectories</h2></div><span className="sample-size">n = {summary.total}</span></div>
{trajectories.length === 0 ? <p className="feature-empty">No received-image trajectories have been extracted yet. A full chat sync will backfill authoritative histories.</p> : ( {trajectories.length === 0 ? <p className="feature-empty">No received-image trajectories have been extracted yet. A full chat sync will backfill authoritative histories.</p> : (
<div className="table-scroll"><table className="performance-table learning-table"><thead><tr><th>Credit</th><th>Community</th><th>Confidence</th><th>Status</th><th>Updated</th><th>Review</th></tr></thead><tbody>{trajectories.map((item) => ( <div className="table-scroll"><table className="performance-table learning-table"><thead><tr><th><span className="sr-only">Expand</span></th><th>Credit</th><th>Community</th><th>Confidence</th><th>Status</th><th>Updated</th><th>Review</th></tr></thead><tbody>{trajectories.flatMap((item) => {
const isOpen = expanded === item.id;
return [
<tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}> <tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}>
<td><button type="button" className="button secondary expand-toggle" aria-expanded={isOpen} aria-controls={`detail-${item.id}`} onClick={() => setExpanded(isOpen ? null : item.id)}>{isOpen ? '▾' : '▸'}<span className="sr-only"> Show conversation evidence</span></button></td>
<th scope="row">{item.creditClass.replaceAll('-', ' ')}</th><td>{item.subreddit ? `r/${item.subreddit}` : 'Unknown'}</td><td>{item.attributionConfidence}</td><td><span className={`trajectory-status status-${item.status}`}>{item.status}</span></td><td>{new Date(item.updatedAt).toLocaleString()}</td><td><div className="review-actions"><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'eligible' })}>Include</button><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'excluded' })}>Exclude</button></div></td> <th scope="row">{item.creditClass.replaceAll('-', ' ')}</th><td>{item.subreddit ? `r/${item.subreddit}` : 'Unknown'}</td><td>{item.attributionConfidence}</td><td><span className={`trajectory-status status-${item.status}`}>{item.status}</span></td><td>{new Date(item.updatedAt).toLocaleString()}</td><td><div className="review-actions"><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'eligible' })}>Include</button><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'excluded' })}>Exclude</button></div></td>
</tr> </tr>,
))}</tbody></table></div> ...(isOpen ? [<tr key={`${item.id}-detail`} id={`detail-${item.id}`} className="detail-row"><td colSpan={7}><TrajectoryDetail id={item.id} /></td></tr>] : []),
];
})}</tbody></table></div>
)} )}
</section> </section>
</main> </main>
@@ -8,5 +8,24 @@
.learning-table tr.human-led th:first-child { color: var(--accent); } .learning-table tr.human-led th:first-child { color: var(--accent); }
.review-actions { display: flex; gap: .4rem; } .review-actions { display: flex; gap: .4rem; }
.review-actions .button { padding: .4rem .6rem; } .review-actions .button { padding: .4rem .6rem; }
.expand-toggle { padding: .3rem .5rem; line-height: 1; }
.detail-row > td { background: var(--surface-strong); padding: .85rem 1rem; }
.trajectory-detail { display: grid; gap: .85rem; }
.trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; }
.trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
.trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; }
.decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
.decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; }
.decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; }
.decision-stage { color: var(--accent); font-size: .78rem; text-transform: capitalize; }
.decision-flag { border-radius: 999px; background: var(--surface); color: var(--warning); font-size: .72rem; padding: .2rem .5rem; }
.decision-turns { color: var(--muted); font-size: .72rem; margin-left: auto; }
.decision-context { display: grid; gap: .3rem; }
.turn { display: grid; gap: .1rem; margin: 0; }
.turn-actor { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
.turn-text { font-size: .85rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.turn-recipient .turn-text { color: var(--muted); }
.decision-chosen { border-left: 2px solid var(--accent); display: grid; gap: .1rem; margin: 0; padding-left: .6rem; }
.decision-note { color: var(--muted); font-size: .72rem; margin: 0; }
@media (max-width: 900px) { .learning-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 900px) { .learning-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } } @media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } }
@@ -6,6 +6,7 @@ import {
ErrorResponseSchema, ErrorResponseSchema,
LoginStatusResponseSchema, LoginStatusResponseSchema,
RedditAccountsResponseSchema, RedditAccountsResponseSchema,
RedditAccountRemovalResponseSchema,
RedditCookieImportRequestSchema, RedditCookieImportRequestSchema,
RedditCookieImportResponseSchema, RedditCookieImportResponseSchema,
type ConfigResponse, type ConfigResponse,
@@ -52,6 +53,16 @@ export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema)); return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema));
} }
export async function removeRedditAccount(accountId: string): Promise<RedditAccountSummary> {
const response = await request(`/api/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
// A refused removal carries its reason in the same envelope as a successful
// one, so parse the body before falling back to the bare status code.
const parsed = RedditAccountRemovalResponseSchema.safeParse(await response.json() as unknown);
if (!parsed.success) throw new Error(`Request failed (${response.status})`);
if (!parsed.data.ok) throw new Error(parsed.data.error);
return parsed.data.account;
}
export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> { export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> {
const body = RedditCookieImportRequestSchema.parse(exported); const body = RedditCookieImportRequestSchema.parse(exported);
const response = await parseResponse( const response = await parseResponse(
@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js'; import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js'; import {
fetchLoginStatus,
fetchRedditAccounts,
fetchSettings,
importRedditSession,
removeRedditAccount,
saveSettings,
} from './settings-api.js';
import { import {
fetchPushNotifications, fetchPushNotifications,
savePushNotifications, savePushNotifications,
@@ -75,3 +82,20 @@ export function useImportRedditSessionMutation() {
}, },
}); });
} }
export function useRemoveRedditAccountMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: removeRedditAccount,
onSuccess() {
invalidateAccountContent(queryClient);
// Removal takes the account's outreach history with it, so every
// account-derived view on the dashboard is now stale.
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'growth'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
void queryClient.invalidateQueries({ queryKey: ['performance'] });
void queryClient.invalidateQueries({ queryKey: ['outreach'] });
void queryClient.invalidateQueries({ queryKey: ['attention'] });
},
});
}
+2 -2
View File
@@ -65,8 +65,8 @@ conversations:
learning: learning:
captureEnabled: true # Build an auditable dataset without changing messages captureEnabled: true # Build an auditable dataset without changing messages
generationEnabled: false # Enable only after the candidate policy passes offline evaluation generationEnabled: true # Retrieve eligible examples for reply generation
shadowMode: true # Record which examples would be used without injecting them shadowMode: false # Inject the retrieved examples into prompts (true = audit only)
maturityHours: 12 maturityHours: 12
maxContextExamples: 3 maxContextExamples: 3
maxTurnsPerExample: 8 maxTurnsPerExample: 8
+2
View File
@@ -64,6 +64,7 @@ an administrator credential.
| `GET` | `/api/stats` | Get bot statistics | read | no | | `GET` | `/api/stats` | Get bot statistics | read | no |
| `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | read | no | | `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | read | no |
| `GET` | `/api/learning` | Inspect attributed learning trajectories and credit | read | no | | `GET` | `/api/learning` | Inspect attributed learning trajectories and credit | read | no |
| `GET` | `/api/learning/trajectories/{id}` | Inspect the redacted decisions behind a learning trajectory | read | no |
| `PATCH` | `/api/learning/trajectories/{id}` | Include or exclude a learning trajectory | admin | yes | | `PATCH` | `/api/learning/trajectories/{id}` | Include or exclude a learning trajectory | admin | yes |
| `GET` | `/api/activity` | Get recent activity | read | no | | `GET` | `/api/activity` | Get recent activity | read | no |
| `GET` | `/api/posts/by-author/{username}` | Get processed posts by author | read | no | | `GET` | `/api/posts/by-author/{username}` | Get processed posts by author | read | no |
@@ -127,6 +128,7 @@ an administrator credential.
| `POST` | `/api/login/session-import` | Import Reddit and Matrix credentials | admin | yes | | `POST` | `/api/login/session-import` | Import Reddit and Matrix credentials | admin | yes |
| `GET` | `/api/accounts` | List Reddit accounts | read | no | | `GET` | `/api/accounts` | List Reddit accounts | read | no |
| `POST` | `/api/accounts/{accountId}/activate` | Activate a Reddit account | admin | yes | | `POST` | `/api/accounts/{accountId}/activate` | Activate a Reddit account | admin | yes |
| `DELETE` | `/api/accounts/{accountId}` | Remove a Reddit account and the content it owns | admin | yes |
| `GET` | `/api/logs/stream` | Stream legacy log events | read | no | | `GET` | `/api/logs/stream` | Stream legacy log events | read | no |
| `GET` | `/api/events/stream` | Stream typed application events | read | no | | `GET` | `/api/events/stream` | Stream typed application events | read | no |
| `GET` | `/api/logs` | Get recent structured logs | read | no | | `GET` | `/api/logs` | Get recent structured logs | read | no |
+1 -1
View File
@@ -50,7 +50,7 @@ The repository/provider/evaluation interfaces are the framework seams. A hosted
1. Deploy with capture enabled, generation disabled, and shadow mode enabled. 1. Deploy with capture enabled, generation disabled, and shadow mode enabled.
2. Run a full chat sync to backfill authoritative histories. 2. Run a full chat sync to backfill authoritative histories.
3. Review inferred and fingerprint-attributed trajectories in `/learning`. 3. Review inferred and fingerprint-attributed trajectories in `/learning`. Expanding a row calls `GET /api/learning/trajectories/{id}` and shows the redacted context turns and chosen response behind each decision, so include/exclude is a judgement on visible evidence rather than on metadata alone.
4. Let examples mature and run the offline evaluation against a backup/non-production copy. 4. Let examples mature and run the offline evaluation against a backup/non-production copy.
5. Compare human, autonomous, and recipient-initiated cohorts; do not blend them into the opener strategy metric. 5. Compare human, autonomous, and recipient-initiated cohorts; do not blend them into the opener strategy metric.
6. Enable generation while retaining shadow mode to exercise selection/auditing. 6. Enable generation while retaining shadow mode to exercise selection/auditing.
+24
View File
@@ -32,4 +32,28 @@ export const LearningOverviewResponseSchema = z.strictObject({
export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) }); export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) });
export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) }); 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),
});
export type LearningOverviewResponse = z.infer<typeof LearningOverviewResponseSchema>; export type LearningOverviewResponse = z.infer<typeof LearningOverviewResponseSchema>;
export type LearningTrajectoryDetailResponse = z.infer<typeof LearningTrajectoryDetailResponseSchema>;
export type LearningDecision = z.infer<typeof LearningDecisionSchema>;
export type LearningTurn = z.infer<typeof LearningTurnSchema>;
+4
View File
@@ -25,6 +25,10 @@ export const SentMessageSchema = z.strictObject({
postImageAnalysis: z.string().optional(), postImageAnalysis: z.string().optional(),
postCreatedAt: z.number().optional(), postCreatedAt: z.number().optional(),
owner: RedditAccountOwnerSchema.optional(), owner: RedditAccountOwnerSchema.optional(),
// Attribution/learning fields added by the conversation learning loop.
matrixEventId: z.string().nullable().optional(),
imageOutcomeCredit: z.string().nullable().optional(),
imageOutcomeAt: z.number().nullable().optional(),
}); });
export const MessagesResponseSchema = z.array(SentMessageSchema); export const MessagesResponseSchema = z.array(SentMessageSchema);
+6
View File
@@ -88,6 +88,11 @@ export const RedditAccountActivationResponseSchema = z.union([
z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }), z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }),
z.strictObject({ ok: z.literal(false), error: z.string() }), z.strictObject({ ok: z.literal(false), error: z.string() }),
]); ]);
/** The removed account is echoed back so the caller can name what it deleted. */
export const RedditAccountRemovalResponseSchema = z.union([
z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }),
z.strictObject({ ok: z.literal(false), error: z.string() }),
]);
export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>; export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>; export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
@@ -96,3 +101,4 @@ export type RedditAccountSummary = z.infer<typeof RedditAccountSummarySchema>;
export type AccountEstablishmentState = z.infer<typeof AccountEstablishmentStateSchema>; export type AccountEstablishmentState = z.infer<typeof AccountEstablishmentStateSchema>;
export type AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>; export type AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>;
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>; export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
export type RedditAccountRemovalResponse = z.infer<typeof RedditAccountRemovalResponseSchema>;
+31
View File
@@ -43,6 +43,20 @@ type AccountRow = {
last_imported_at: number | null; 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 { export class RedditAccountsRepository {
constructor(private readonly db: Database.Database) {} constructor(private readonly db: Database.Database) {}
@@ -128,6 +142,23 @@ export class RedditAccountsRepository {
`).run(active ? 1 : 0, status, now, accountId).changes === 1; `).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 { markLoginRequired(accountId: string, now: number = Date.now()): boolean {
return this.db.prepare(` return this.db.prepare(`
UPDATE reddit_accounts UPDATE reddit_accounts
+16
View File
@@ -90,6 +90,22 @@ export class RedditAccountsService {
return this.importSession(cookies, credentials); 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> { async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
try { try {
const verified = await this.importer.import(cookies, credentials); const verified = await this.importer.import(cookies, credentials);
+10 -1
View File
@@ -20,7 +20,12 @@ import {
GrowthSubredditCreateRequestSchema, GrowthSubredditCreateRequestSchema,
GrowthSubredditUpdateRequestSchema, GrowthSubredditUpdateRequestSchema,
} from './growth-contracts.js'; } 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 ApiAccess = 'read' | 'control' | 'admin';
export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 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('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']),
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']), 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('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('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('getActivity', 'GET', '/api/activity', 'Get recent activity', ['Dashboard']),
operation('getPostsByAuthor', 'GET', '/api/posts/by-author/{username}', 'Get processed posts by author', ['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('importSession', 'POST', '/api/login/session-import', 'Import Reddit and Matrix credentials', ['Accounts']),
operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']), operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']),
operation('activateAccount', 'POST', '/api/accounts/{accountId}/activate', 'Activate a Reddit account', ['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('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('streamEvents', 'GET', '/api/events/stream', 'Stream typed application events', ['Events'], { streaming: true }),
operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']), operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']),
+20
View File
@@ -26,3 +26,23 @@ export const LearningOverviewResponseSchema = z.strictObject({
}); });
export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) }); export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) });
export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) }); 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),
});
+11
View File
@@ -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) => { router.patch('/learning/trajectories/:id', (req, res) => {
const parsed = LearningReviewRequestSchema.safeParse(req.body); const parsed = LearningReviewRequestSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
+22
View File
@@ -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; return router;
} }
+64
View File
@@ -134,6 +134,70 @@ describe('multi-account database ownership', () => {
expect(db.pragma('foreign_key_check')).toEqual([]); expect(db.pragma('foreign_key_check')).toEqual([]);
}); });
it('removes an account with everything it owns and leaves other accounts intact', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
for (const accountId of ['t2_alice', 't2_bob']) {
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES (?, 'post-one', 'test', 'recipient', 'title', 1)
`).run(accountId);
db.prepare(`
INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at)
VALUES (?, 'post-one', 'recipient', 'subject', 'message', 2)
`).run(accountId);
db.prepare(`
INSERT INTO successful_messages (account_id, original_message, created_at)
VALUES (?, 'message', 3)
`).run(accountId);
db.prepare(`
INSERT INTO delivery_outbox (
account_id, post_id, recipient, subject, message, idempotency_key,
status, created_at, updated_at
) VALUES (?, 'post-one', 'recipient', 'subject', 'message', 'key', 'prepared', 1, 1)
`).run(accountId);
db.prepare(`
INSERT INTO activity_log (account_id, timestamp, type, message) VALUES (?, 4, 'sent', 'activity')
`).run(accountId);
db.prepare(`
INSERT INTO conversation_states (account_id, conversation_id, state_json, updated_at)
VALUES (?, 'room', '{}', 5)
`).run(accountId);
content.upsertConversation({
accountId, conversationId: 'room', username: 'recipient', roomName: 'Recipient',
payloadJson: '{}', updatedAt: 6,
});
content.saveSyncState({
accountId, nextBatch: 'cursor', lastFullSyncAt: 6, lastDeltaSyncAt: 6,
cooldownJson: null, updatedAt: 6,
});
}
expect(accounts.delete('t2_alice')).toBe(true);
expect(accounts.get('t2_alice')).toBeNull();
expect(accounts.listPublic().map((account) => account.accountId)).toContain('t2_bob');
for (const table of [
'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox',
'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state',
]) {
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object })
.toEqual({ table, count: 0 });
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_bob') as object })
.toEqual({ table, count: 1 });
}
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it('reports an unknown account as not removed and rejects a blank id', () => {
addAccount('t2_alice', 'alice-bot', 100);
expect(accounts.delete('t2_missing')).toBe(false);
expect(() => accounts.delete(' ')).toThrow(/accountId/);
expect(accounts.get('t2_alice')).not.toBeNull();
});
it('scopes legacy account-produced table uniqueness by account', () => { it('scopes legacy account-produced table uniqueness by account', () => {
addAccount('t2_alice', 'alice-bot', 100); addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110); addAccount('t2_bob', 'bob-bot', 110);
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js';
const TRAJECTORY_ID = 'traj-1';
let detailCalls: string[] = [];
function summary() {
return {
total: 1, provisional: 0, eligible: 1, excluded: 0, invalidated: 0, needsReview: 0,
humanCreated: 1, humanImproved: 0, humanCompletedAi: 0, autonomous: 0,
recipientInitiated: 0, uncertain: 0, exact: 1, fingerprint: 0, inferred: 0,
};
}
function trajectory() {
return {
id: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com', conversationId: '!room:reddit.com',
sentMessageId: 7, subreddit: 'cats', sourceType: 'chat' as const, policyMode: 'r4r-media' as const,
firstHumanEventId: '$human1', outcomeEventId: '$image1', outcomeType: 'image-received' as const,
creditClass: 'human-created-success' as const, attributionConfidence: 'exact' as const,
status: 'eligible' as const, maturityAt: 1_700_000_100_000, invalidatedAt: null,
invalidationReason: null, reviewedAt: null, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
};
}
function decision() {
return {
id: 'dec-1', trajectoryId: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com',
decisionEventId: '$human1', stage: 'media-invitation' as const,
context: [{
eventId: '$in1', timestamp: 1_700_000_010_000, role: 'recipient' as const,
actor: 'recipient' as const, kind: 'inbound-text' as const, text: 'hey how was your weekend',
}],
chosenResponse: 'no pressure, only if you want to',
turnsUntilOutcome: 2, isDirectMediaInvitation: true, qualityStatus: 'eligible' as const,
createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
};
}
const server = setupServer(
http.get('/api/learning', () => HttpResponse.json({
summary: summary(), trajectories: [trajectory()],
captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1',
})),
http.get('/api/learning/trajectories/:id', ({ params }) => {
detailCalls.push(String(params.id));
return HttpResponse.json({ trajectory: trajectory(), decisions: [decision()] });
}),
);
function renderLearning() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<LearningPage />
</QueryClientProvider>,
);
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
cleanup();
});
afterAll(() => server.close());
beforeEach(() => {
detailCalls = [];
});
describe('learning page trajectory detail', () => {
it('does not fetch decisions until a row is expanded', async () => {
renderLearning();
await screen.findByText(/human created success/i);
expect(detailCalls).toEqual([]);
});
it('reveals the redacted conversation evidence when expanded', async () => {
renderLearning();
await screen.findByText(/human created success/i);
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
expect(await screen.findByText('no pressure, only if you want to')).toBeInTheDocument();
expect(screen.getByText('hey how was your weekend')).toBeInTheDocument();
expect(screen.getByText('!room:reddit.com')).toBeInTheDocument();
expect(screen.getByText(/direct media ask/i)).toBeInTheDocument();
await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID]));
});
it('collapses the evidence again on a second click', async () => {
renderLearning();
await screen.findByText(/human created success/i);
const toggle = screen.getByRole('button', { name: /show conversation evidence/i });
await userEvent.click(toggle);
await screen.findByText('no pressure, only if you want to');
await userEvent.click(toggle);
await waitFor(() => {
expect(screen.queryByText('no pressure, only if you want to')).not.toBeInTheDocument();
});
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
});
+48
View File
@@ -149,6 +149,54 @@ describe('Reddit session status', () => {
expect((await request(app, '/login/start', { method: 'POST' })).status).toBe(404); expect((await request(app, '/login/start', { method: 'POST' })).status).toBe(404);
}); });
it('removes an account and reports the account it deleted', async () => {
const remove = vi.fn().mockResolvedValue({ accountId: 't2_alice', redditUsername: 'alice-bot' });
const app = express();
app.use(createLoginRouter({
services: { redditAccounts: { remove } },
logger: { error: () => undefined, info: () => undefined },
} as never));
const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
ok: true,
account: { accountId: 't2_alice', redditUsername: 'alice-bot' },
});
expect(remove).toHaveBeenCalledWith('t2_alice');
});
it('answers 404 when the account to remove does not exist', async () => {
const app = express();
app.use(createLoginRouter({
services: {
redditAccounts: { remove: async () => { throw new Error('Reddit account not found'); } },
},
logger: { error: () => undefined, info: () => undefined },
} as never));
const response = await request(app, '/accounts/t2_missing', { method: 'DELETE' });
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ ok: false, error: 'Reddit account not found' });
});
it('reports a failed removal as a server error rather than a silent success', async () => {
const app = express();
app.use(createLoginRouter({
services: {
redditAccounts: { remove: async () => { throw new Error('database is locked'); } },
},
logger: { error: () => undefined, info: () => undefined },
} as never));
const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' });
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ ok: false, error: 'database is locked' });
});
it('classifies Reddit navigation failures as unreachable', () => { it('classifies Reddit navigation failures as unreachable', () => {
expect( expect(
classifyRedditSessionError( classifyRedditSessionError(
+104
View File
@@ -0,0 +1,104 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RedditAccountsRepository } from '../src/database/account-repositories.js';
import { runMigrations } from '../src/database/client.js';
import { AccountAuthContext } from '../src/services/account-auth-context.js';
import { RedditAccountsService } from '../src/services/reddit-accounts.js';
const silentLogger = {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined,
} as never;
describe('removing a Reddit account', () => {
let db: Database.Database;
let accounts: RedditAccountsRepository;
let auth: AccountAuthContext;
let stopped: string[];
let service: RedditAccountsService;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
accounts = new RedditAccountsRepository(db);
auth = new AccountAuthContext(silentLogger);
stopped = [];
const runtimes = {
get: () => null,
stop: async (accountId: string) => { stopped.push(accountId); },
};
service = new RedditAccountsService(
accounts,
{ import: async () => { throw new Error('not used'); } } as never,
auth,
runtimes as never,
);
accounts.upsertVerified({
accountId: 't2_alice',
redditUsername: 'alice-bot',
matrixUserId: '@t2_alice:reddit.com',
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: 'secret' }]),
matrixCredentialsJson: JSON.stringify({ accessToken: 'matrix-secret' }),
now: 100,
});
});
afterEach(() => db.close());
it('stops the runtime before dropping the account and its stored credentials', async () => {
const removed = await service.remove('t2_alice');
expect(stopped).toEqual(['t2_alice']);
expect(removed).toMatchObject({
accountId: 't2_alice',
redditUsername: 'alice-bot',
isRunning: false,
status: 'login-required',
});
expect(accounts.get('t2_alice')).toBeNull();
expect(service.list()).toEqual([]);
});
it('drops the authenticated session when the removed account held it', async () => {
auth.markAuthenticated({
accountId: 't2_alice',
redditUsername: 'alice-bot',
matrixUserId: '@t2_alice:reddit.com',
});
expect(auth.getSnapshot().accountId).toBe('t2_alice');
await service.remove('t2_alice');
expect(auth.getSnapshot()).toMatchObject({ status: 'login-required', accountId: null });
});
it('leaves another account\'s session alone', async () => {
accounts.upsertVerified({
accountId: 't2_bob',
redditUsername: 'bob-bot',
matrixUserId: '@t2_bob:reddit.com',
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: 'bob-secret' }]),
matrixCredentialsJson: JSON.stringify({ accessToken: 'bob-matrix-secret' }),
now: 110,
});
auth.markAuthenticated({
accountId: 't2_bob',
redditUsername: 'bob-bot',
matrixUserId: '@t2_bob:reddit.com',
});
await service.remove('t2_alice');
expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: 't2_bob' });
expect(service.list().map((account) => account.accountId)).toEqual(['t2_bob']);
});
it('refuses to remove an account it does not know, without stopping anything', async () => {
await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found');
expect(stopped).toEqual([]);
expect(accounts.get('t2_alice')).not.toBeNull();
});
});
+118
View File
@@ -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');
});
});
+42 -1
View File
@@ -1,7 +1,7 @@
import express from 'express'; import express from 'express';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest'; 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 type { WebContext } from '../../src/web/context.js';
import { createStatsRouter } from '../../src/web/routes/stats.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'); 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);
});
});