Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
142378df33 |
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) => (
|
||||
<tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}>
|
||||
<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>
|
||||
<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>,
|
||||
...(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