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
+834
-26
No files matched your search
@@ -1,13 +1,19 @@
|
||||
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 {
|
||||
useImportRedditSessionMutation,
|
||||
useLoginStatusQuery,
|
||||
useRedditAccountsQuery,
|
||||
useRemoveRedditAccountMutation,
|
||||
} from '../settings/settings-queries.js';
|
||||
import './accounts.css';
|
||||
|
||||
const CONFIRM_TIMEOUT_MS = 8_000;
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
@@ -85,6 +91,73 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u
|
||||
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 {
|
||||
if (!status) return 'Unknown';
|
||||
if (!status.ready) return 'Browser unavailable';
|
||||
@@ -211,21 +284,7 @@ export function AccountsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
{accounts.map((account) => (
|
||||
<div className={account.isRunning ? 'account-row is-active' : 'account-row'} 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>
|
||||
<AccountRow account={account} key={account.accountId} />
|
||||
))}
|
||||
{accountsQuery.isSuccess && accounts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||
.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-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. */
|
||||
.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';
|
||||
|
||||
async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> {
|
||||
@@ -12,6 +18,10 @@ export const learningApi = {
|
||||
getOverview(): Promise<LearningOverviewResponse> {
|
||||
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 }> {
|
||||
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, {
|
||||
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 { learningApi } from './learning-api.js';
|
||||
import './learning.css';
|
||||
|
||||
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() {
|
||||
const client = useQueryClient();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview });
|
||||
const review = useMutation({
|
||||
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">
|
||||
<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> : (
|
||||
<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' : ''}>
|
||||
<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>
|
||||
</tr>
|
||||
))}</tbody></table></div>
|
||||
</tr>,
|
||||
...(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>
|
||||
</main>
|
||||
|
||||
@@ -8,5 +8,24 @@
|
||||
.learning-table tr.human-led th:first-child { color: var(--accent); }
|
||||
.review-actions { display: flex; gap: .4rem; }
|
||||
.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: 600px) { .learning-metrics { grid-template-columns: 1fr; } }
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ErrorResponseSchema,
|
||||
LoginStatusResponseSchema,
|
||||
RedditAccountsResponseSchema,
|
||||
RedditAccountRemovalResponseSchema,
|
||||
RedditCookieImportRequestSchema,
|
||||
RedditCookieImportResponseSchema,
|
||||
type ConfigResponse,
|
||||
@@ -52,6 +53,16 @@ export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
|
||||
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> {
|
||||
const body = RedditCookieImportRequestSchema.parse(exported);
|
||||
const response = await parseResponse(
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 {
|
||||
fetchPushNotifications,
|
||||
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
@@ -65,8 +65,8 @@ conversations:
|
||||
|
||||
learning:
|
||||
captureEnabled: true # Build an auditable dataset without changing messages
|
||||
generationEnabled: false # Enable only after the candidate policy passes offline evaluation
|
||||
shadowMode: true # Record which examples would be used without injecting them
|
||||
generationEnabled: true # Retrieve eligible examples for reply generation
|
||||
shadowMode: false # Inject the retrieved examples into prompts (true = audit only)
|
||||
maturityHours: 12
|
||||
maxContextExamples: 3
|
||||
maxTurnsPerExample: 8
|
||||
|
||||
@@ -64,6 +64,7 @@ an administrator credential.
|
||||
| `GET` | `/api/stats` | Get bot statistics | 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/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 |
|
||||
| `GET` | `/api/activity` | Get recent activity | 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 |
|
||||
| `GET` | `/api/accounts` | List Reddit accounts | read | no |
|
||||
| `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/events/stream` | Stream typed application events | read | no |
|
||||
| `GET` | `/api/logs` | Get recent structured logs | read | no |
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -32,4 +32,28 @@ 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),
|
||||
});
|
||||
|
||||
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>;
|
||||
@@ -25,6 +25,10 @@ export const SentMessageSchema = z.strictObject({
|
||||
postImageAnalysis: z.string().optional(),
|
||||
postCreatedAt: z.number().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);
|
||||
|
||||
|
||||
@@ -88,6 +88,11 @@ export const RedditAccountActivationResponseSchema = z.union([
|
||||
z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }),
|
||||
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 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 AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>;
|
||||
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
|
||||
export type RedditAccountRemovalResponse = z.infer<typeof RedditAccountRemovalResponseSchema>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -134,6 +134,70 @@ describe('multi-account database ownership', () => {
|
||||
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', () => {
|
||||
addAccount('t2_alice', 'alice-bot', 100);
|
||||
addAccount('t2_bob', 'bob-bot', 110);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,54 @@ describe('Reddit session status', () => {
|
||||
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', () => {
|
||||
expect(
|
||||
classifyRedditSessionError(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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