Harden conversation replies and prioritize newest candidates

This commit is contained in:
jjbrunton committed 2026-08-09 18:16:57 +01:00
1 parent f98c5c732d
commit b8e4a1bb88
23 files changed
+598 -113

No files matched your search

+13 -5
View File
@@ -25,7 +25,7 @@ stateDiagram-v2
The discovery loop rotates through enabled subreddits and authenticated runtimes. A runtime fetches Reddit `/new` data using the process-shared `RedditRequestCoordinator`. Intake deduplicates post IDs and authors, excludes processed/contacted history, stores the full post snapshot, source type, weight, and timestamps, and logs exact intake counts. The discovery loop rotates through enabled subreddits and authenticated runtimes. A runtime fetches Reddit `/new` data using the process-shared `RedditRequestCoordinator`. Intake deduplicates post IDs and authors, excludes processed/contacted history, stores the full post snapshot, source type, weight, and timestamps, and logs exact intake counts.
The queue grows to a demand-based target rather than a fixed backlog. The target is the smaller of configured `reddit.maxReadyCandidates` and the work that authenticated running accounts can still send within the recent-post window, accounting for rolling hourly capacity and minimum message spacing. Intake admits only the current deficit, pauses at zero outbound capacity, and reports fetched posts left unevaluated because the funnel is full. Reddit request pacing, not a tight application poll, remains the primary request limiter. The queue grows to a demand-based target rather than a fixed backlog. The target is the smaller of configured `reddit.maxReadyCandidates` and the work that authenticated running accounts can still send within the recent-post window, accounting for rolling hourly capacity and minimum message spacing. Intake admits only the current deficit, newest Reddit posts first, pauses at zero outbound capacity, and reports fetched posts left unevaluated because the funnel is full. Reddit request pacing, not a tight application poll, remains the primary request limiter.
### 2. Screening and preparation ### 2. Screening and preparation
@@ -46,7 +46,7 @@ An account is eligible for new outreach only when its runtime is running, both i
Matrix cooldowns retain the operation that triggered them. A room-creation or invitation limit pauses new outreach but does not suppress replies in existing rooms; a limit returned while sending into an existing room pauses those sends as well. Matrix cooldowns retain the operation that triggered them. A room-creation or invitation limit pauses new outreach but does not suppress replies in existing rooms; a limit returned while sending into an existing room pauses those sends as well.
The dispatch worker leases a ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of: Screening, retry promotion, dispatch, and the operator's active-queue view are ordered by the Reddit post creation time, newest first; source priority is only a tie-breaker. The dispatch worker leases the newest ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of:
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent; - `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
- `terminal`: release the recipient reservation and reject the candidate; - `terminal`: release the recipient reservation and reject the candidate;
@@ -77,12 +77,18 @@ flowchart TD
STOP -- no --> GEN["Generate policy-specific reply"] STOP -- no --> GEN["Generate policy-specific reply"]
GEN --> SAFE{"Outbound checks pass?"} GEN --> SAFE{"Outbound checks pass?"}
SAFE -- no --> HANDOFF SAFE -- no --> HANDOFF
SAFE -- yes --> PERSIST["Claim event and persist intended state"] SAFE -- yes --> PERSIST["Persist intended reply claim"]
PERSIST --> SEND["Rate-limited Matrix send"] PERSIST --> WAIT["Wait for the account rate limiter"]
WAIT --> RECHECK{"Refresh Matrix; claimed event still latest and safe?"}
RECHECK -- no --> SUPERSEDE["Mark claim superseded; persist boundary/handoff if present"]
RECHECK -- yes --> SNAPSHOT["Persist intended conversation state"]
SNAPSHOT --> SEND["Matrix send"]
SEND --> MARK["Mark claim sent and record latency"] SEND --> MARK["Mark claim sent and record latency"]
``` ```
The inbound event key, intended reply, claim status, and conversation state make retries idempotent. If the process dies after Matrix accepted a reply, reconciliation finds the outbound event and marks the old claim sent instead of sending it again. The inbound event key, intended reply, claim status, and conversation state make retries idempotent. Immediately after any limiter wait, automation refreshes Matrix and revalidates the latest inbound event, boundaries, images, seller evidence, policy status, and the one-media-request limit. Newer state makes the old claim terminally `superseded`; it is never sent. If the process dies after Matrix accepted a reply, reconciliation finds the outbound event and marks the old claim sent instead of sending it again.
Legacy conversations may make at most one optional autonomous media request, including a request already present in the opener. The hybrid policy retains its single rapport-gated media ask. Deterministic, sequence-aware refusal checks run before generation and again at send time; generated media asks must pass the same non-explicit, non-coercive validator.
## Chat and media synchronization ## Chat and media synchronization
@@ -90,6 +96,8 @@ The inbound event key, intended reply, claim status, and conversation state make
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. Private media is served behind the same authorization boundary as the dashboard and APIs. `MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. Private media is served behind the same authorization boundary as the dashboard and APIs.
Conversation performance is derived from the complete attributed inbound transcript. Engagement cannot become a prompt-learning example while automation is active, and a later decline, boundary, or upsell removes an earlier example before future prompts load it.
## Dashboard data and events ## Dashboard data and events
Routes assemble snapshots from repositories and injected services. `LogBroadcaster`/the realtime event hub publishes typed events for bot state, sends, failures, conversations, replies, sync, sessions, rate limits, and logs. The React client validates incoming events, invalidates matching TanStack Query keys, and reconnects with replay support. Cached screens remain visible but stale during a disconnect. Routes assemble snapshots from repositories and injected services. `LogBroadcaster`/the realtime event hub publishes typed events for bot state, sends, failures, conversations, replies, sync, sessions, rate limits, and logs. The React client validates incoming events, invalidates matching TanStack Query keys, and reconnects with replay support. Cached screens remain visible but stale during a disconnect.
+5 -3
View File
@@ -21,7 +21,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
- `processed_posts`: account/post key, source metadata, analysis, generated opener, send marker, and terminal error/rejection reason. - `processed_posts`: account/post key, source metadata, analysis, generated opener, send marker, and terminal error/rejection reason.
- `sent_messages`: account-owned opener/reply attribution, strategy/experiment/model metadata, Matrix room, response/image flags, and source type. - `sent_messages`: account-owned opener/reply attribution, strategy/experiment/model metadata, Matrix room, response/image flags, and source type.
- `successful_messages`: examples derived from positive outcomes for prompt context. - `successful_messages`: mature positive examples for prompt context; terminal negative reclassification removes the linked example.
- `conversation_outcomes`: normalized outcome for a sent message (`engaged`, `declined`, `boundary`, `upsell`, or `unknown`). - `conversation_outcomes`: normalized outcome for a sent message (`engaged`, `declined`, `boundary`, `upsell`, or `unknown`).
- `activity_log`: operator-facing events with optional recipient/post context. - `activity_log`: operator-facing events with optional recipient/post context.
- `reddit_users`: global mapping between Reddit fullname/username and Matrix identity. - `reddit_users`: global mapping between Reddit fullname/username and Matrix identity.
@@ -32,7 +32,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
- `candidate_queue`: global candidate snapshot, source/priority, state, prepared payload, worker lease, retry timing, selected account, and rejection/error details. - `candidate_queue`: global candidate snapshot, source/priority, state, prepared payload, worker lease, retry timing, selected account, and rejection/error details.
- `delivery_outbox`: account-scoped prepared delivery and idempotency status retained for legacy/recovery paths. - `delivery_outbox`: account-scoped prepared delivery and idempotency status retained for legacy/recovery paths.
- `conversation_states`: account/conversation policy and derived state JSON. - `conversation_states`: account/conversation policy and derived state JSON.
- `conversation_automation_claims`: account/conversation/inbound-event claim with intended reply and pending/sent state. - `conversation_automation_claims`: account/conversation/inbound-event claim with intended reply and `pending`, `sent`, or terminal `superseded` state plus audit timestamps.
- `runtime_state`: small process controls such as whether bot work is running. - `runtime_state`: small process controls such as whether bot work is running.
### Configuration and telemetry ### Configuration and telemetry
@@ -71,10 +71,12 @@ Any new candidate state needs:
## Conversation reply claims ## Conversation reply claims
The unique identity is account + conversation + inbound message key. The claim persists the intended reply before the remote send. A pending claim is not blindly retried: Matrix history is checked first. This closes the process-crash window between remote acceptance and local completion. The unique identity is account + conversation + inbound message key. The claim persists the intended reply before the remote send. A pending claim is not blindly retried: Matrix history is checked first. After rate-limit waiting, Matrix is refreshed again; a newer event, boundary, received image, seller signal, operator-owned state, or repeated media request changes the claim to `superseded` and records `superseded_at`. This closes both the process-crash window between remote acceptance and local completion and the stale-send window while a reply is queued.
Changes to this flow must prove at-most-once observable replies across retry and restart tests. Changes to this flow must prove at-most-once observable replies across retry and restart tests.
Outcome rows are re-evaluated from the complete attributed recipient transcript. Negative outcomes take precedence over reciprocal activity. `successful_messages` rows are created only after the conversation becomes terminal/human-owned and are deleted by `sent_message_id` when later evidence changes the outcome.
## Configuration persistence ## Configuration persistence
On an empty database, `config.yaml` is parsed, environment-owned values are injected, the result is validated, and the application-owned subset is saved to `app_config`. Later starts load SQLite first. Dashboard changes update that record. On an empty database, `config.yaml` is parsed, environment-owned values are injected, the result is validated, and the application-owned subset is saved to `app_config`. Later starts load SQLite first. Dashboard changes update that record.
+5 -3
View File
@@ -19,7 +19,7 @@ The product has five connected goals:
- Candidate inventory follows near-term outbound capacity and the recent-post window; discovery pauses instead of accumulating an ever-older backlog when accounts cannot keep pace. - Candidate inventory follows near-term outbound capacity and the recent-post window; discovery pauses instead of accumulating an ever-older backlog when accounts cannot keep pace.
- Discovery is global while authentication, rate limits, conversations, and content remain account-owned. - Discovery is global while authentication, rate limits, conversations, and content remain account-owned.
- Recipient deduplication is case-insensitive, durable across restarts, and protected by a live reservation during sends. - Recipient deduplication is case-insensitive, durable across restarts, and protected by a live reservation during sends.
- Remote side effects are fail-closed: a send is recorded only after the chat service confirms it; interrupted reply claims reconcile against Matrix history before retrying. - Remote side effects are fail-closed: a send is recorded only after the chat service confirms it; interrupted reply claims reconcile against Matrix history before retrying. A queued reply refreshes Matrix immediately before sending and becomes an auditable superseded claim if a newer inbound event or terminal state appeared while it waited.
- Dry-run prepares and records decisions but never sends. - Dry-run prepares and records decisions but never sends.
- Operators can see funnel stages, account state, rate limits, failures, handoffs, and recent activity rather than inferring success from a process-health check. - Operators can see funnel stages, account state, rate limits, failures, handoffs, and recent activity rather than inferring success from a process-health check.
@@ -41,11 +41,11 @@ Each subreddit snapshots a policy into a conversation when automation first hand
| Mode | Meaning | | Mode | Meaning |
| --- | --- | | --- | --- |
| `manual` | Never auto-reply; surface a human handoff. | | `manual` | Never auto-reply; surface a human handoff. |
| `legacy` | A short, bounded media-request flow retained for existing behavior. | | `legacy` | At most one optional autonomous media request, counting a request already present in the opener, followed by human handoff. |
| `r4r` | Bounded rapport-style replies with autonomous safety checks. | | `r4r` | Bounded rapport-style replies with autonomous safety checks. |
| `r4r-media` | Rapport first, one bounded media ask, then a handoff/terminal boundary. | | `r4r-media` | Rapport first, one bounded media ask, then a handoff/terminal boundary. |
The snapshot matters: changing configuration later must not silently rewrite the rules for an active conversation. Automation stops for seller/upsell evidence, an image received, a detected boundary, unsafe content, an exhausted turn budget, or a manual policy. The snapshot matters: changing configuration later must not silently rewrite the rules for an active conversation. Automation stops for seller/upsell evidence, an image received, a detected boundary, unsafe content, an exhausted turn budget, a repeated media request, or a manual policy. Short refusals are interpreted in sequence: phrases such as “I’m good” become media boundaries only when they answer a media request.
## Success and analytics ## Success and analytics
@@ -56,6 +56,8 @@ Do not collapse all progress into “messages sent.” Useful states include:
- replies, declined/boundary/upsell outcomes, and images received; - replies, declined/boundary/upsell outcomes, and images received;
- model usage, fallback behavior, strategy/sample size, and account ownership. - model usage, fallback behavior, strategy/sample size, and account ownership.
Learning examples mature only after the conversation policy is no longer active. Full recipient history is reclassified on sync; a later decline or boundary overrides earlier reciprocal activity and removes any previously promoted example.
Fetched listing size is not an eligibility count. Logs and UI should quantify rejection and deferral reasons so a dry funnel can be diagnosed without guessing. Fetched listing size is not an eligibility count. Logs and UI should quantify rejection and deferral reasons so a dry funnel can be diagnosed without guessing.
## Operational authority ## Operational authority
+2 -1
View File
@@ -36,8 +36,9 @@ This is an ownership map, not an API dump. Read the owning implementation and te
| `services/chat/cache.ts` | Account-scoped cached conversations and sync state | SQLite is durable; do not share caches across accounts. | | `services/chat/cache.ts` | Account-scoped cached conversations and sync state | SQLite is durable; do not share caches across accounts. |
| `services/chat/matrix/username-resolver.ts` | Matrix/Reddit identity mapping | Preserve unresolved internal IDs without leaking them in operator text. | | `services/chat/matrix/username-resolver.ts` | Matrix/Reddit identity mapping | Preserve unresolved internal IDs without leaking them in operator text. |
| `services/chat/matrix/token-refresher.ts` | Explicit refresh-unavailable behavior | New credentials come from verified extension dumps, not token sniffing. | | `services/chat/matrix/token-refresher.ts` | Explicit refresh-unavailable behavior | New credentials come from verified extension dumps, not token sniffing. |
| `services/conversation-automation.ts` | Reply-first policy, claims, retries, handoffs, turn budgets | Snapshot policy; claim before send; reconcile pending claims; fail closed. | | `services/conversation-automation.ts` | Reply-first policy, claims, retries, send-time refresh, handoffs, turn budgets | Snapshot policy; claim before send; supersede stale queued work; reconcile pending claims; fail closed. |
| `services/conversation-state.ts` | Pure conversation parsing/state/safety helpers | Conversation text is untrusted data; keep prompt boundaries explicit. | | `services/conversation-state.ts` | Pure conversation parsing/state/safety helpers | Conversation text is untrusted data; keep prompt boundaries explicit. |
| `services/conversation-boundaries.ts` | Sequence-aware media requests, refusals, and full-stop detection | Short ambiguous replies become boundaries only in the matching request context. |
| `services/outbound-message-safety.ts` | Final no-proximity/no-meeting rule | This is defense in depth beyond prompts. | | `services/outbound-message-safety.ts` | Final no-proximity/no-meeting rule | This is defense in depth beyond prompts. |
| `services/conversation-outcome*.ts` | Response outcome classification/recording | Keep analytics attribution account/message scoped. | | `services/conversation-outcome*.ts` | Response outcome classification/recording | Keep analytics attribution account/message scoped. |
| `services/response-checker.ts` | Inbox/performance response checks and successful examples | Non-overlapping; do not confuse chat sync with outcome maturity. | | `services/response-checker.ts` | Inbox/performance response checks and successful examples | Non-overlapping; do not confuse chat sync with outcome maturity. |
+1
View File
@@ -298,6 +298,7 @@ export async function bootstrap(
redditUsersRepo, redditUsersRepo,
accountAuth, accountAuth,
conversationOutcomesRepo, conversationOutcomesRepo,
conversationStateRepo,
); );
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth); const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
+6 -12
View File
@@ -96,10 +96,11 @@ export class CandidateQueueRepository {
) VALUES (?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)
`); `);
const newestPostsFirst = [...posts].sort((left, right) => right.created_utc - left.created_utc);
this.db.transaction(() => { this.db.transaction(() => {
for (const [index, post] of posts.entries()) { for (const [index, post] of newestPostsFirst.entries()) {
if (result.discovered >= maxNewCandidates) { if (result.discovered >= maxNewCandidates) {
result.deferredAtCapacity += posts.length - index; result.deferredAtCapacity += newestPostsFirst.length - index;
break; break;
} }
if (existingPost.get(post.id)) { if (existingPost.get(post.id)) {
@@ -214,7 +215,7 @@ export class CandidateQueueRepository {
WHERE post_id IN ( WHERE post_id IN (
SELECT post_id FROM candidate_queue SELECT post_id FROM candidate_queue
WHERE status = 'retryable' AND available_at <= ? WHERE status = 'retryable' AND available_at <= ?
ORDER BY priority DESC, discovered_at ASC ORDER BY post_created_at DESC, priority DESC, discovered_at ASC
LIMIT ? LIMIT ?
) )
`).run(now, now, capacity).changes; `).run(now, now, capacity).changes;
@@ -302,15 +303,8 @@ export class CandidateQueueRepository {
SELECT * FROM candidate_queue SELECT * FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')}) WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
ORDER BY ORDER BY
CASE status
WHEN 'sending' THEN 1
WHEN 'screening' THEN 2
WHEN 'ready' THEN 3
WHEN 'retryable' THEN 4
ELSE 5
END,
priority DESC,
post_created_at DESC, post_created_at DESC,
priority DESC,
discovered_at ASC discovered_at ASC
LIMIT ? LIMIT ?
`).all(...ACTIVE_STATUSES, limit) as Array<Record<string, unknown>>; `).all(...ACTIVE_STATUSES, limit) as Array<Record<string, unknown>>;
@@ -332,7 +326,7 @@ export class CandidateQueueRepository {
const row = this.db.prepare(` const row = this.db.prepare(`
SELECT post_id FROM candidate_queue SELECT post_id FROM candidate_queue
WHERE status = ? AND available_at <= ? ${requirePrepared ? 'AND prepared_json IS NOT NULL' : ''} WHERE status = ? AND available_at <= ? ${requirePrepared ? 'AND prepared_json IS NOT NULL' : ''}
ORDER BY priority DESC, post_created_at DESC, discovered_at ASC ORDER BY post_created_at DESC, priority DESC, discovered_at ASC
LIMIT 1 LIMIT 1
`).get(from, now) as { post_id: string } | undefined; `).get(from, now) as { post_id: string } | undefined;
if (!row) return null; if (!row) return null;
+28
View File
@@ -336,6 +336,34 @@ export function runMigrations(db: Database.Database): void {
addColumnIfNotExists(db, 'conversation_automation_claims', 'last_attempt_at', 'INTEGER'); addColumnIfNotExists(db, 'conversation_automation_claims', 'last_attempt_at', 'INTEGER');
addColumnIfNotExists(db, 'conversation_automation_claims', 'attempt_count', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists(db, 'conversation_automation_claims', 'attempt_count', 'INTEGER NOT NULL DEFAULT 0');
}, },
() => db.exec(`
CREATE TABLE conversation_automation_claims_with_superseded (
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
incoming_key TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'sent', 'superseded')),
claimed_at INTEGER NOT NULL,
sent_at INTEGER,
superseded_at INTEGER,
reply_text TEXT,
incoming_at INTEGER,
last_attempt_at INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, conversation_id, incoming_key),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
INSERT INTO conversation_automation_claims_with_superseded (
account_id, conversation_id, incoming_key, status, claimed_at, sent_at,
reply_text, incoming_at, last_attempt_at, attempt_count
) SELECT
account_id, conversation_id, incoming_key, status, claimed_at, sent_at,
reply_text, incoming_at, last_attempt_at, attempt_count
FROM conversation_automation_claims;
DROP TABLE conversation_automation_claims;
ALTER TABLE conversation_automation_claims_with_superseded RENAME TO conversation_automation_claims;
CREATE INDEX idx_conversation_automation_claims_status
ON conversation_automation_claims(account_id, status, claimed_at);
`),
]; ];
let currentVersion = db.pragma('user_version', { simple: true }) as number; let currentVersion = db.pragma('user_version', { simple: true }) as number;
+23 -4
View File
@@ -1010,6 +1010,7 @@ export class SuccessfulMessagesRepository {
private getBySubredditStmt: Database.Statement; private getBySubredditStmt: Database.Statement;
private countStmt: Database.Statement; private countStmt: Database.Statement;
private existsByMessageStmt: Database.Statement; private existsByMessageStmt: Database.Statement;
private deleteBySentMessageIdStmt: Database.Statement;
constructor( constructor(
private db: Database.Database, private db: Database.Database,
@@ -1051,6 +1052,9 @@ export class SuccessfulMessagesRepository {
this.existsByMessageStmt = db.prepare(` this.existsByMessageStmt = db.prepare(`
SELECT 1 FROM successful_messages WHERE account_id = ? AND original_message = ? LIMIT 1 SELECT 1 FROM successful_messages WHERE account_id = ? AND original_message = ? LIMIT 1
`); `);
this.deleteBySentMessageIdStmt = db.prepare(`
DELETE FROM successful_messages WHERE account_id = ? AND sent_message_id = ?
`);
} }
insert(data: { insert(data: {
@@ -1077,6 +1081,10 @@ export class SuccessfulMessagesRepository {
return this.existsByMessageStmt.get(this.accountId, originalMessage) !== undefined; return this.existsByMessageStmt.get(this.accountId, originalMessage) !== undefined;
} }
deleteBySentMessageId(sentMessageId: number): boolean {
return this.deleteBySentMessageIdStmt.run(this.accountId, sentMessageId).changes > 0;
}
getRecent(limit: number = 5): SuccessfulMessageRecord[] { getRecent(limit: number = 5): SuccessfulMessageRecord[] {
const rows = this.getRecentStmt.all(this.accountId, limit) as Record<string, unknown>[]; const rows = this.getRecentStmt.all(this.accountId, limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row)); return rows.map((row) => this.mapRow(row));
@@ -1176,13 +1184,14 @@ export class ConversationStateRepository {
export interface ConversationAutomationClaimRecord { export interface ConversationAutomationClaimRecord {
conversationId: string; conversationId: string;
incomingKey: string; incomingKey: string;
status: 'pending' | 'sent'; status: 'pending' | 'sent' | 'superseded';
replyText: string | null; replyText: string | null;
incomingAt: number | null; incomingAt: number | null;
claimedAt: number; claimedAt: number;
lastAttemptAt: number | null; lastAttemptAt: number | null;
attemptCount: number; attemptCount: number;
sentAt: number | null; sentAt: number | null;
supersededAt: number | null;
} }
export class ConversationAutomationClaimRepository { export class ConversationAutomationClaimRepository {
@@ -1211,7 +1220,7 @@ export class ConversationAutomationClaimRepository {
getClaim(conversationId: string, incomingKey: string): ConversationAutomationClaimRecord | undefined { getClaim(conversationId: string, incomingKey: string): ConversationAutomationClaimRecord | undefined {
const row = this.db.prepare(` const row = this.db.prepare(`
SELECT conversation_id, incoming_key, status, reply_text, incoming_at, SELECT conversation_id, incoming_key, status, reply_text, incoming_at,
claimed_at, last_attempt_at, attempt_count, sent_at claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at
FROM conversation_automation_claims FROM conversation_automation_claims
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? WHERE account_id = ? AND conversation_id = ? AND incoming_key = ?
`).get(this.accountId, conversationId, incomingKey) as Record<string, unknown> | undefined; `).get(this.accountId, conversationId, incomingKey) as Record<string, unknown> | undefined;
@@ -1219,20 +1228,21 @@ export class ConversationAutomationClaimRepository {
return { return {
conversationId: row.conversation_id as string, conversationId: row.conversation_id as string,
incomingKey: row.incoming_key as string, incomingKey: row.incoming_key as string,
status: row.status as 'pending' | 'sent', status: row.status as 'pending' | 'sent' | 'superseded',
replyText: (row.reply_text as string | null) ?? null, replyText: (row.reply_text as string | null) ?? null,
incomingAt: (row.incoming_at as number | null) ?? null, incomingAt: (row.incoming_at as number | null) ?? null,
claimedAt: row.claimed_at as number, claimedAt: row.claimed_at as number,
lastAttemptAt: (row.last_attempt_at as number | null) ?? null, lastAttemptAt: (row.last_attempt_at as number | null) ?? null,
attemptCount: row.attempt_count as number, attemptCount: row.attempt_count as number,
sentAt: (row.sent_at as number | null) ?? null, sentAt: (row.sent_at as number | null) ?? null,
supersededAt: (row.superseded_at as number | null) ?? null,
}; };
} }
listPending(): ConversationAutomationClaimRecord[] { listPending(): ConversationAutomationClaimRecord[] {
const rows = this.db.prepare(` const rows = this.db.prepare(`
SELECT conversation_id, incoming_key, status, reply_text, incoming_at, SELECT conversation_id, incoming_key, status, reply_text, incoming_at,
claimed_at, last_attempt_at, attempt_count, sent_at claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at
FROM conversation_automation_claims FROM conversation_automation_claims
WHERE account_id = ? AND status = 'pending' WHERE account_id = ? AND status = 'pending'
ORDER BY claimed_at ASC ORDER BY claimed_at ASC
@@ -1247,6 +1257,7 @@ export class ConversationAutomationClaimRepository {
lastAttemptAt: (row.last_attempt_at as number | null) ?? null, lastAttemptAt: (row.last_attempt_at as number | null) ?? null,
attemptCount: row.attempt_count as number, attemptCount: row.attempt_count as number,
sentAt: (row.sent_at as number | null) ?? null, sentAt: (row.sent_at as number | null) ?? null,
supersededAt: (row.superseded_at as number | null) ?? null,
})); }));
} }
@@ -1272,6 +1283,14 @@ export class ConversationAutomationClaimRepository {
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? AND status = 'pending' WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? AND status = 'pending'
`).run(now, this.accountId, conversationId, incomingKey).changes === 1; `).run(now, this.accountId, conversationId, incomingKey).changes === 1;
} }
markSuperseded(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE conversation_automation_claims
SET status = 'superseded', superseded_at = ?
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? AND status = 'pending'
`).run(now, this.accountId, conversationId, incomingKey).changes === 1;
}
} }
/** Outcome labels are recorded independently from a raw inbox response. */ /** Outcome labels are recorded independently from a raw inbox response. */
+12 -1
View File
@@ -1,6 +1,7 @@
import type { Logger } from '../../utils/logger.js'; import type { Logger } from '../../utils/logger.js';
import type { import type {
ConversationOutcomesRepository, ConversationOutcomesRepository,
ConversationStateRepository,
MessagesRepository, MessagesRepository,
RedditUsersRepository, RedditUsersRepository,
SentMessageRecord, SentMessageRecord,
@@ -21,6 +22,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
export type { InvitationCooldown } from './types.js'; export type { InvitationCooldown } from './types.js';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js'; import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
import type { AccountAuthGate } from '../account-auth-context.js'; import type { AccountAuthGate } from '../account-auth-context.js';
import { containsMediaRequest } from '../conversation-boundaries.js';
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000; const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/'; const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
@@ -159,6 +161,7 @@ export class ChatService {
private redditUsersRepo?: RedditUsersRepository, private redditUsersRepo?: RedditUsersRepository,
private accountAuth?: AccountAuthGate, private accountAuth?: AccountAuthGate,
private outcomesRepo?: ConversationOutcomesRepository, private outcomesRepo?: ConversationOutcomesRepository,
private conversationStateRepo?: ConversationStateRepository,
) { ) {
this.cache = cache ?? new ChatCache(logger); this.cache = cache ?? new ChatCache(logger);
if (this.redditUsersRepo) { if (this.redditUsersRepo) {
@@ -1104,17 +1107,25 @@ export class ChatService {
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) { if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {
const firstReply = incomingMessages[0]; const firstReply = incomingMessages[0];
const latestReply = incomingMessages.at(-1);
const cachedSelfCount = conversationMessages.filter((message) => message.isSelf).length; const cachedSelfCount = conversationMessages.filter((message) => message.isSelf).length;
const selfCount = cachedSelfCount + (firstSelf ? 0 : 1); const selfCount = cachedSelfCount + (firstSelf ? 0 : 1);
const otherCount = incomingMessages.length; const otherCount = incomingMessages.length;
const wasMissingOutcome = this.outcomesRepo.get(performanceMessage.id) === undefined; const wasMissingOutcome = this.outcomesRepo.get(performanceMessage.id) === undefined;
const conversationState = this.conversationStateRepo?.get(chat.roomId);
try { try {
recordConversationReply( recordConversationReply(
performanceMessage, performanceMessage,
firstReply?.text.slice(0, 200) ?? '', latestReply?.text.slice(0, 200) ?? '',
{ {
messageCount: selfCount + otherCount, messageCount: selfCount + otherCount,
reciprocal: selfCount >= 2 && otherCount >= 2, reciprocal: selfCount >= 2 && otherCount >= 2,
responses: incomingMessages.map((message) => message.text),
learningEligible: conversationState !== undefined && conversationState.automation.status !== 'active',
receivedImage: hasReceivedImage,
mediaWasRequested: conversationState?.askedForMedia
?? conversationMessages.some((message) => message.isSelf && containsMediaRequest(message.text)),
authoritativeTranscript: true,
}, },
{ {
messages: this.messagesRepo, messages: this.messagesRepo,
+137 -36
View File
@@ -19,6 +19,7 @@ import {
remainingAutomationTurns, remainingAutomationTurns,
type ConversationState, type ConversationState,
} from './conversation-state.js'; } from './conversation-state.js';
import { containsMediaRequest } from './conversation-boundaries.js';
export type ConversationAutomationResultKind = export type ConversationAutomationResultKind =
| 'sent' | 'sent'
@@ -44,8 +45,14 @@ export interface ConversationAutomationClaims {
markAttempt?(conversationId: string, incomingKey: string): boolean; markAttempt?(conversationId: string, incomingKey: string): boolean;
releasePending?(conversationId: string, incomingKey: string): boolean; releasePending?(conversationId: string, incomingKey: string): boolean;
markSent(conversationId: string, incomingKey: string): boolean; markSent(conversationId: string, incomingKey: string): boolean;
markSuperseded?(conversationId: string, incomingKey: string): boolean;
} }
export type ConversationBeforeSendLoader = (
conversationId: string,
observed: ActiveChat,
) => Promise<ActiveChat | undefined>;
export interface ReplyPriorityStatus { export interface ReplyPriorityStatus {
processing: boolean; processing: boolean;
actionableBacklog: number; actionableBacklog: number;
@@ -86,6 +93,7 @@ export class ConversationAutomationService {
private readonly configuredSubreddits: () => readonly SubredditConfig[] = () => [], private readonly configuredSubreddits: () => readonly SubredditConfig[] = () => [],
private readonly now: () => number = Date.now, private readonly now: () => number = Date.now,
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS, private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
) {} ) {}
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> { async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
@@ -199,26 +207,21 @@ export class ConversationAutomationService {
); );
} }
if (this.now() - existingClaim.claimedAt < this.pendingReplyRetryDelayMs) return null; if (this.now() - existingClaim.claimedAt < this.pendingReplyRetryDelayMs) return null;
const retryPlan = replyPlan(state); const requestsMedia = containsMediaRequest(existingClaim.replyText);
const exhausted = this.advanceStateForReply( const isHybridMediaAsk = state.automation.policyMode === 'r4r-media' && requestsMedia;
state,
conversationId,
incomingKey,
existingClaim.replyText,
retryPlan.isLegacy,
retryPlan.isHybridMediaAsk,
);
return this.sendClaimedReply( return this.sendClaimedReply(
conversation, conversation,
state, state,
incomingKey, incomingKey,
existingClaim.replyText, existingClaim.replyText,
latest.timestamp, latest.timestamp,
exhausted, requestsMedia,
isHybridMediaAsk,
signal, signal,
true,
); );
} }
if (existingClaim?.status === 'sent') return null; if (existingClaim?.status === 'sent' || existingClaim?.status === 'superseded') return null;
if (state.automation.lastIncomingKey === incomingKey) return null; if (state.automation.lastIncomingKey === incomingKey) return null;
if (state.automation.status !== 'active') return null; if (state.automation.status !== 'active') return null;
@@ -269,6 +272,16 @@ export class ConversationAutomationService {
} }
const { isLegacy, isHybridMediaAsk } = replyPlan(state); const { isLegacy, isHybridMediaAsk } = replyPlan(state);
if (isLegacy && state.askedForMedia) {
return this.finish(
conversation,
conversationId,
state,
incomingKey,
'human-required',
'A media request has already been made; further autonomous requests are disabled.',
);
}
if (isHybridMediaAsk) { if (isHybridMediaAsk) {
state.automation.hybridPhase = 'media-ask'; state.automation.hybridPhase = 'media-ask';
this.states.save(conversationId, state); this.states.save(conversationId, state);
@@ -283,7 +296,9 @@ export class ConversationAutomationService {
: isHybridMediaAsk : isHybridMediaAsk
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state) ? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
: await this.ai.generateReply(mapped, conversation.username, state, intent); : await this.ai.generateReply(mapped, conversation.username, state, intent);
const safeReply = isHybridMediaAsk ? isSafeHybridMediaAsk(reply) : isLegacy || isSafeR4RReply(reply); const safeReply = isLegacy || isHybridMediaAsk
? isSafeHybridMediaAsk(reply)
: isSafeR4RReply(reply);
if (!safeReply) { if (!safeReply) {
return this.finish( return this.finish(
conversation, conversation,
@@ -291,7 +306,7 @@ export class ConversationAutomationService {
state, state,
incomingKey, incomingKey,
'human-required', 'human-required',
'The generated R4R reply failed autonomous safety checks.', 'The generated reply failed autonomous safety checks.',
); );
} }
if (this.dryRun) { if (this.dryRun) {
@@ -301,24 +316,16 @@ export class ConversationAutomationService {
if (!this.claims.tryClaim(conversationId, incomingKey, reply, latest.timestamp)) return null; if (!this.claims.tryClaim(conversationId, incomingKey, reply, latest.timestamp)) return null;
// Persist the full intended state before the remote send. If the process // The durable claim stores the intended body. Send-time validation then
// dies after Matrix accepts the message, both this state and the durable // snapshots the intended state immediately before the Matrix request.
// event claim make the retry fail closed instead of sending twice.
const exhausted = this.advanceStateForReply(
state,
conversationId,
incomingKey,
reply,
isLegacy,
isHybridMediaAsk,
);
return this.sendClaimedReply( return this.sendClaimedReply(
conversation, conversation,
state, state,
incomingKey, incomingKey,
reply, reply,
latest.timestamp, latest.timestamp,
exhausted, requestsMedia,
isHybridMediaAsk,
signal, signal,
); );
} }
@@ -360,6 +367,7 @@ export class ConversationAutomationService {
const claim = this.claims.getClaim?.(conversation.roomId, incomingKey); const claim = this.claims.getClaim?.(conversation.roomId, incomingKey);
if (claim?.status === 'pending') return true; if (claim?.status === 'pending') return true;
if (claim?.status === 'sent') return false; if (claim?.status === 'sent') return false;
if (claim?.status === 'superseded') return false;
// On the first scan after startup, retain Matrix unread as the conservative // On the first scan after startup, retain Matrix unread as the conservative
// bootstrap signal so a deployment cannot revive historical read replies. // bootstrap signal so a deployment cannot revive historical read replies.
// Later scans can recover fresh inbound events even if that flag cleared. // Later scans can recover fresh inbound events even if that flag cleared.
@@ -376,11 +384,11 @@ export class ConversationAutomationService {
conversationId: string, conversationId: string,
incomingKey: string, incomingKey: string,
reply: string, reply: string,
isLegacy: boolean, requestsMedia: boolean,
isHybridMediaAsk: boolean, isHybridMediaAsk: boolean,
): boolean { ): boolean {
if (state.automation.lastIncomingKey !== incomingKey) { if (state.automation.lastIncomingKey !== incomingKey) {
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true; if (requestsMedia) state.askedForMedia = true;
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask'; if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
state.automation.turnsUsed += 1; state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey; state.automation.lastIncomingKey = incomingKey;
@@ -405,33 +413,126 @@ export class ConversationAutomationService {
incomingKey: string, incomingKey: string,
reply: string, reply: string,
incomingAt: number, incomingAt: number,
exhausted: boolean, requestsMedia: boolean,
isHybridMediaAsk: boolean,
signal?: AbortSignal, signal?: AbortSignal,
resumePending = false,
): Promise<ConversationAutomationResult | null> { ): Promise<ConversationAutomationResult | null> {
this.claims.markAttempt?.(conversation.roomId!, incomingKey); const conversationId = conversation.roomId!;
const sent = await this.rateLimiter.throttle( const attempt = await this.rateLimiter.throttle(
() => this.chat.sendChatMessage(conversation.url!, reply), async () => {
(success) => success, const latestConversation = await this.loadConversationBeforeSend(conversationId, conversation);
const latest = latestConversation ? latestInboundMessage(latestConversation.messages ?? []) : null;
const latestKey = latest ? messageKey(latest) : null;
const freshState = latestConversation
? mergeConversationState(
this.states.get(conversationId),
buildConversationState((latestConversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
)
: this.states.get(conversationId) ?? state;
const persistedIntentCanResume = resumePending
&& freshState.automation.lastIncomingKey === incomingKey
&& freshState.automation.status === 'human-required';
if (!latestConversation || !latest || latestKey !== incomingKey
|| (freshState.automation.status !== 'active' && !persistedIntentCanResume)) {
this.claims.markSuperseded?.(conversationId, incomingKey);
const result = latestConversation && latest
? this.applyFreshHandoff(latestConversation, freshState, latest)
: null;
if (!result && latestConversation) this.states.save(conversationId, freshState);
this.logger.info(
`[automation] Superseded queued reply for u/${conversation.username}; a newer conversation state was observed before send`,
);
return { kind: 'superseded' as const, result, state: freshState };
}
if (freshState.boundaries.length > 0 || hasReceivedImage(latestConversation.messages ?? [])) {
this.claims.markSuperseded?.(conversationId, incomingKey);
const result = this.applyFreshHandoff(latestConversation, freshState, latest);
return { kind: 'superseded' as const, result, state: freshState };
}
if (requestsMedia && freshState.askedForMedia
&& !(resumePending && freshState.automation.lastIncomingKey === incomingKey)) {
this.claims.markSuperseded?.(conversationId, incomingKey);
const result = this.finish(
latestConversation,
conversationId,
freshState,
incomingKey,
'human-required',
'A media request has already been made; further autonomous requests are disabled.',
);
return { kind: 'superseded' as const, result, state: freshState };
}
const exhausted = this.advanceStateForReply(
freshState,
conversationId,
incomingKey,
reply,
requestsMedia,
isHybridMediaAsk,
);
this.claims.markAttempt?.(conversationId, incomingKey);
const sent = await this.chat.sendChatMessage(latestConversation.url!, reply);
return { kind: sent ? 'sent' as const : 'failed' as const, exhausted, state: freshState };
},
(result) => result.kind === 'sent',
signal, signal,
REPLY_SEND_PRIORITY, REPLY_SEND_PRIORITY,
); );
if (!sent) return null; if (attempt.kind === 'superseded') return attempt.result;
if (attempt.kind === 'failed') return null;
const sentAt = this.now(); const sentAt = this.now();
this.claims.markSent(conversation.roomId!, incomingKey); this.claims.markSent(conversation.roomId!, incomingKey);
this.lastReplySentAt = sentAt; this.lastReplySentAt = sentAt;
this.lastReplyLatencyMs = Math.max(0, sentAt - incomingAt); this.lastReplyLatencyMs = Math.max(0, sentAt - incomingAt);
this.logger.info( this.logger.info(
`[automation] Sent ${state.automation.policyMode} turn ${state.automation.turnsUsed}/${state.automation.maxAutomatedTurns} ` `[automation] Sent ${attempt.state.automation.policyMode} turn ${attempt.state.automation.turnsUsed}/${attempt.state.automation.maxAutomatedTurns} `
+ `to u/${conversation.username}; reply latency ${Math.round(this.lastReplyLatencyMs / 1_000)}s`, + `to u/${conversation.username}; reply latency ${Math.round(this.lastReplyLatencyMs / 1_000)}s`,
); );
return { return {
conversationId: conversation.roomId!, conversationId: conversation.roomId!,
username: conversation.username, username: conversation.username,
kind: exhausted ? 'human-required' : 'sent', kind: attempt.exhausted ? 'human-required' : 'sent',
remainingTurns: remainingAutomationTurns(state), remainingTurns: remainingAutomationTurns(attempt.state),
}; };
} }
private async loadConversationBeforeSend(conversationId: string, observed: ActiveChat): Promise<ActiveChat | undefined> {
if (this.beforeSendLoader) return this.beforeSendLoader(conversationId, observed);
await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT);
return (await this.chat.getCachedConversations()).find((item) => item.roomId === conversationId);
}
private applyFreshHandoff(
conversation: ActiveChat,
state: ConversationState,
latest: ChatMessage,
): ConversationAutomationResult | null {
if (state.boundaries.length > 0) {
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'boundary', state.boundaries.join('; '));
}
if (hasReceivedImage(conversation.messages ?? [])) {
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'image-received', 'The user shared an image.');
}
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
if (sellerSignal) {
state.sellerDetected = true;
return this.finish(
conversation,
conversation.roomId!,
state,
messageKey(latest),
'human-required',
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
);
}
return null;
}
private resolveConversationBehaviour(subreddit: string | undefined): SubredditConfig['conversation'] { private resolveConversationBehaviour(subreddit: string | undefined): SubredditConfig['conversation'] {
const configured = subreddit const configured = subreddit
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase()) ? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
+70
View File
@@ -0,0 +1,70 @@
export interface BoundaryMessage {
text: string;
isSelf: boolean;
}
const MEDIA_NOUN = String.raw`(?:pic|pics|picture|pictures|photo|photos|selfie|selfies|snap|snaps|image|images|video|videos|nude|nudes|content)`;
const MEDIA_VERB = String.raw`(?:send(?:ing)?|shar(?:e|ing)|show(?:ing)?|giv(?:e|ing)|drop(?:ping)?|swap(?:ping)?|trad(?:e|ing))`;
const EXPLICIT_MEDIA_REFUSAL_PATTERNS = [
new RegExp(String.raw`\b(?:no|not|never|won't|wouldn't|don't|do not|can't|cannot)\b[^.\n]{0,60}\b${MEDIA_NOUN}\b`, 'i'),
new RegExp(String.raw`\b(?:won't|wouldn't|don't|do not|not going to|never)\b[^.\n]{0,40}\b${MEDIA_VERB}\b`, 'i'),
new RegExp(String.raw`\b(?:zero|no) interest\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN}|anything|something)\b`, 'i'),
new RegExp(String.raw`\b(?:rather not|not comfortable)\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN})\b`, 'i'),
];
const CONTEXTUAL_MEDIA_REFUSAL_PATTERNS = [
/^(?:no|nope|nah|no thanks|not really|rather not|pass)(?:,? thanks)?[.!\s]*$/i,
/^(?:i(?:'m| am)|im|we(?:'re| are)|we're) good(?: thanks)?[.!\s]*$/i,
/\b(?:zero interest|not interested|no interest)\b/i,
/\b(?:not sending|won't send|wouldn't send|don't send|do not send)\b/i,
];
const FULL_STOP_PATTERNS = [
/\bstop\b/i,
/\bleave me alone\b/i,
/\b(?:do not|don't) message\b/i,
/\bback off\b/i,
/^(?:(?:i'm|i am|im)\s+)?not interested[.!\s]*$/i,
/\b(?:creepy|uncomfortable|weirdest (?:reddit )?approach)\b/i,
];
export function containsMediaRequest(text: string): boolean {
return new RegExp(String.raw`\b${MEDIA_VERB}\b[^.!?\n]{0,60}\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\b(?:can|could|would|will) you\b[^.!?\n]{0,60}\b(?:${MEDIA_VERB}\b[^.!?\n]{0,30})?\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\bgot any (?:more )?${MEDIA_NOUN}\b`, 'i').test(text);
}
export function isFullConversationStop(text: string): boolean {
return FULL_STOP_PATTERNS.some((pattern) => pattern.test(text));
}
export function isMediaRefusal(text: string, mediaWasRequested: boolean): boolean {
const normalized = text.replaceAll('’', "'");
if (EXPLICIT_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized))) return true;
return mediaWasRequested && CONTEXTUAL_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized.trim()));
}
/**
* Detect recipient limits in sequence so short phrases such as "I'm good"
* only become a media boundary when they directly follow a media request.
*/
export function detectConversationBoundaries(messages: readonly BoundaryMessage[]): string[] {
const boundaries: string[] = [];
let lastSelfRequestedMedia = false;
for (const message of messages) {
if (message.isSelf) {
lastSelfRequestedMedia = containsMediaRequest(message.text);
continue;
}
if (isMediaRefusal(message.text, lastSelfRequestedMedia)) {
boundaries.push('does not want to send photos or videos');
}
if (isFullConversationStop(message.text)) {
boundaries.push('does not want further sexual escalation');
}
}
return [...new Set(boundaries)];
}
@@ -29,7 +29,12 @@ export function recordConversationReply(
deps.outcomes.suppressRecipient(message.recipient, 'boundary'); deps.outcomes.suppressRecipient(message.recipient, 'boundary');
} }
if (outcome === 'engaged' && !deps.successful.existsByMessage(message.message)) { const terminalNegative = outcome === 'boundary' || outcome === 'declined' || outcome === 'upsell';
if (terminalNegative || (context.authoritativeTranscript && (outcome !== 'engaged' || !context.learningEligible))) {
deps.successful.deleteBySentMessageId(message.id);
}
if (outcome === 'engaged' && context.learningEligible && !deps.successful.existsByMessage(message.message)) {
deps.successful.insert({ deps.successful.insert({
sentMessageId: message.id, sentMessageId: message.id,
originalMessage: message.message, originalMessage: message.message,
@@ -41,6 +46,8 @@ export function recordConversationReply(
deps.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`); deps.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`);
} else if (outcome !== 'engaged') { } else if (outcome !== 'engaged') {
deps.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`); deps.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`);
} else if (!context.learningEligible) {
deps.logger.info(`Response from u/${message.recipient} is not terminal; deferred from learning examples.`);
} }
return outcome; return outcome;
} }
+22 -4
View File
@@ -1,3 +1,5 @@
import { isFullConversationStop, isMediaRefusal } from './conversation-boundaries.js';
export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const; export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const;
export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number]; export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number];
@@ -6,6 +8,16 @@ export interface OutcomeContext {
messageCount?: number; messageCount?: number;
/** True when both participants have sent at least two messages. */ /** True when both participants have sent at least two messages. */
reciprocal?: boolean; reciprocal?: boolean;
/** All recipient messages in the attributed conversation, oldest first. */
responses?: readonly string[];
/** True only after automation has reached a terminal/human-owned state. */
learningEligible?: boolean;
/** A received image is an explicit positive outcome once the flow is complete. */
receivedImage?: boolean;
/** Whether the conversation had already asked the recipient for media. */
mediaWasRequested?: boolean;
/** True when responses contains the complete attributed recipient transcript. */
authoritativeTranscript?: boolean;
} }
/** /**
@@ -16,10 +28,16 @@ export function classifyConversationOutcome(
reply: string, reply: string,
context: OutcomeContext = {}, context: OutcomeContext = {},
): ConversationOutcome { ): ConversationOutcome {
const text = reply.toLowerCase(); const responses = context.responses?.length ? context.responses : [reply];
const text = responses.join('\n').toLowerCase();
if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell'; if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell';
if (/\b(stop|leave me alone|do not message|don't message)\b/.test(text)) return 'boundary'; if (responses.some(isFullConversationStop)) return 'boundary';
if (/\b(no thanks|not interested|nope|don't send|do not send)\b/.test(text)) return 'declined'; if (responses.some((response) => isMediaRefusal(response, context.mediaWasRequested === true))
if (context.reciprocal && (context.messageCount ?? 0) >= 4 && text.trim().length > 0) return 'engaged'; || /\b(no thanks|not interested|nope|nah|rather not|zero interest)\b/.test(text)) return 'declined';
const explicitPositive = /\b(yes|yeah|yep|sure|absolutely|definitely|tell me more|sounds (?:good|fun)|i'd love to|would love to)\b/i.test(text);
if (context.learningEligible
&& (context.receivedImage || (context.reciprocal && (context.messageCount ?? 0) >= 4 && explicitPositive))) {
return 'engaged';
}
return 'unknown'; return 'unknown';
} }
+6 -17
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { isSafeOutboundMessage } from './outbound-message-safety.js'; import { isSafeOutboundMessage } from './outbound-message-safety.js';
import { containsMediaRequest, detectConversationBoundaries } from './conversation-boundaries.js';
export interface ConversationMessage { export interface ConversationMessage {
sender: string; sender: string;
@@ -38,7 +39,7 @@ export interface ConversationState {
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3; export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3; export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 2; export const CONVERSATION_POLICY_VERSION = 3;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual'; export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask'; export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
@@ -103,11 +104,6 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
.filter((message) => !message.isSelf) .filter((message) => !message.isSelf)
.map((message) => message.text) .map((message) => message.text)
.join('\n'); .join('\n');
const ownText = messages
.filter((message) => message.isSelf)
.map((message) => message.text)
.join('\n');
const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/); const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/);
const persona: ConversationPersona = { const persona: ConversationPersona = {
age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age, age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age,
@@ -115,20 +111,14 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus, relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus,
}; };
const boundaries: string[] = []; const boundaries = detectConversationBoundaries(messages);
if (/\b(no|not)\b[^.\n]{0,50}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b|\bdon't send\b[^.\n]{0,30}\b(pic|photo|video|nudes?)\b/i.test(theirText)) {
boundaries.push('does not want to send photos or videos');
}
if (/\bstop\b|\bleave me alone\b|\bnot interested\b/i.test(theirText)) {
boundaries.push('does not want further sexual escalation');
}
const topics = extractTopics(theirText); const topics = extractTopics(theirText);
return { return {
persona, persona,
boundaries, boundaries,
sellerDetected: false, sellerDetected: false,
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText), askedForMedia: messages.some((message) => message.isSelf && containsMediaRequest(message.text)),
topics, topics,
automation: createConversationAutomationState(), automation: createConversationAutomationState(),
}; };
@@ -214,14 +204,13 @@ export function isSafeConversationReply(message: string): boolean {
/** R4R automation may converse and flirt, but it must never solicit sexual media. */ /** R4R automation may converse and flirt, but it must never solicit sexual media. */
export function isSafeR4RReply(message: string): boolean { export function isSafeR4RReply(message: string): boolean {
if (!isSafeConversationReply(message)) return false; if (!isSafeConversationReply(message)) return false;
return !/\b(send|show|share|swap|trade|drop|give|dm)\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes|content)\b/i.test(message) return !containsMediaRequest(message);
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
} }
/** The hybrid policy gets one optional, non-explicit image request after rapport. */ /** The hybrid policy gets one optional, non-explicit image request after rapport. */
export function isSafeHybridMediaAsk(message: string): boolean { export function isSafeHybridMediaAsk(message: string): boolean {
if (!isSafeConversationReply(message)) return false; if (!isSafeConversationReply(message)) return false;
if (!/\b(pic|pics|photo|photos|selfie|selfies|video|videos)\b/i.test(message)) return false; if (!containsMediaRequest(message)) return false;
if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false; if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false;
return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message); return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message);
} }
@@ -181,7 +181,7 @@ async function buildRuntime(
const chatService = new ChatService( const chatService = new ChatService(
matrixClient, logger, successfulMessagesRepo, mediaService, matrixClient, logger, successfulMessagesRepo, mediaService,
new MatrixTokenRefresher(), usernameResolver, chatCache, new MatrixTokenRefresher(), usernameResolver, chatCache,
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, conversationStateRepo,
); );
await chatService.loadCache(); await chatService.loadCache();
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth); const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
+45 -8
View File
@@ -94,6 +94,42 @@ describe('CandidateQueueRepository', () => {
expect(queue.countActive()).toBe(2); expect(queue.countActive()).toBe(2);
}); });
it('admits, screens, and sends the newest posts before older higher-priority posts', () => {
queue.enqueue([
post('old-high-priority', 'alice', 100),
post('new-low-priority', 'bob', 300),
], 'image', 1, 1_000, 1);
expect(queue.listActive().map((candidate) => candidate.postId)).toEqual(['new-low-priority']);
queue.enqueue([post('old-high-priority', 'alice', 100)], 'image', 10, 1_001);
const newestScreening = queue.claimForScreening('screen-new', 100, 1_010)!;
expect(newestScreening.postId).toBe('new-low-priority');
queue.markReady(newestScreening.postId, 'screen-new', prepared(newestScreening.post), 1_011);
const olderScreening = queue.claimForScreening('screen-old', 100, 1_012)!;
queue.markReady(olderScreening.postId, 'screen-old', prepared(olderScreening.post), 1_013);
expect(queue.claimReady('account-a', 'send', 100, 1_014)?.postId).toBe('new-low-priority');
});
it('promotes the newest due delivery retry when ready capacity is limited', () => {
queue.enqueue([post('new-low-priority', 'bob', 300)], 'image', 1, 1_000);
queue.enqueue([post('old-high-priority', 'alice', 100)], 'image', 10, 1_001);
for (const worker of ['screen-new', 'screen-old']) {
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
queue.markReady(candidate.postId, worker, prepared(candidate.post), 1_011);
}
for (const [accountId, worker] of [['account-a', 'send-new'], ['account-b', 'send-old']] as const) {
const candidate = queue.claimReady(accountId, worker, 100, 1_012)!;
queue.deferDelivery(candidate.postId, worker, 'Matrix rate limited', 1_100, 1_013);
}
expect(queue.promoteDueRetries(1, 1_100)).toBe(1);
expect(queue.claimReady('account-a', 'retry', 100, 1_101)?.postId).toBe('new-low-priority');
});
it('excludes posts and recipients already handled anywhere', () => { it('excludes posts and recipients already handled anywhere', () => {
db.prepare(` db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at) INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
@@ -140,13 +176,13 @@ describe('CandidateQueueRepository', () => {
}); });
}); });
it('lists only active candidates in actionable stage order', () => { it('lists only active candidates in newest-post order across stages', () => {
queue.enqueue([ queue.enqueue([
post('waiting', 'alice'), post('waiting', 'alice', 100),
post('screening', 'bob'), post('screening', 'bob', 500),
post('ready', 'carol'), post('ready', 'carol', 400),
post('sending', 'dana'), post('sending', 'dana', 300),
post('rejected', 'erin'), post('rejected', 'erin', 200),
], 'image', 1, 1_000); ], 'image', 1, 1_000);
queue.claimForScreening('worker-1', 1_000, 1_010); queue.claimForScreening('worker-1', 1_000, 1_010);
const ready = queue.claimForScreening('worker-2', 1_000, 1_020)!; const ready = queue.claimForScreening('worker-2', 1_000, 1_020)!;
@@ -155,8 +191,9 @@ describe('CandidateQueueRepository', () => {
const rejected = queue.claimForScreening('worker-4', 1_000, 1_040)!; const rejected = queue.claimForScreening('worker-4', 1_000, 1_040)!;
queue.reject(rejected.postId, 'worker-4', 'Seller signals detected', 1_041); queue.reject(rejected.postId, 'worker-4', 'Seller signals detected', 1_041);
expect(queue.listActive().map((candidate) => candidate.status)).toEqual([ const active = queue.listActive();
'sending', 'screening', 'discovered', 'discovered', expect(active.map((candidate) => candidate.postId)).toEqual([
'screening', 'ready', 'rejected', 'waiting',
]); ]);
expect(queue.listActive().map((candidate) => candidate.postId)).not.toContain(rejected.postId); expect(queue.listActive().map((candidate) => candidate.postId)).not.toContain(rejected.postId);
expect(queue.getOldestActiveAt()).toBe(1_000); expect(queue.getOldestActiveAt()).toBe(1_000);
+15 -2
View File
@@ -2,7 +2,8 @@ import fs from 'fs/promises';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConversationOutcomesRepository } from '../src/database/repositories.js'; import { ConversationOutcomesRepository, ConversationStateRepository } from '../src/database/repositories.js';
import { buildConversationState } from '../src/services/conversation-state.js';
import { ChatCache } from '../src/services/chat/cache.js'; import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js'; import { MatrixClient } from '../src/services/chat/matrix/client.js';
import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js'; import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js';
@@ -46,6 +47,7 @@ describe('ChatService outcome ingestion', () => {
expect(fixture.outcomes.get(fixture.sentMessageId)?.outcome).toBe('unknown'); expect(fixture.outcomes.get(fixture.sentMessageId)?.outcome).toBe('unknown');
expect(fixture.repos.successfulMessagesRepo.count()).toBe(0); expect(fixture.repos.successfulMessagesRepo.count()).toBe(0);
fixture.markConversationComplete();
await fixture.service.refreshTopChats(); await fixture.service.refreshTopChats();
expect(fixture.outcomes.get(fixture.sentMessageId)?.outcome).toBe('engaged'); expect(fixture.outcomes.get(fixture.sentMessageId)?.outcome).toBe('engaged');
expect(fixture.repos.successfulMessagesRepo.count()).toBe(1); expect(fixture.repos.successfulMessagesRepo.count()).toBe(1);
@@ -67,6 +69,7 @@ describe('ChatService outcome ingestion', () => {
], ],
}); });
fixture.repos.messagesRepo.markGotResponse(fixture.sentMessageId); fixture.repos.messagesRepo.markGotResponse(fixture.sentMessageId);
fixture.markConversationComplete();
fixture.matrix.sync = vi.fn(async () => syncResponse('s2', [])) as typeof fixture.matrix.sync; fixture.matrix.sync = vi.fn(async () => syncResponse('s2', [])) as typeof fixture.matrix.sync;
await fixture.service.runStartupSync(); await fixture.service.runStartupSync();
@@ -94,6 +97,7 @@ async function createFixture(options: {
openDatabases.push(db); openDatabases.push(db);
const repos = createTestRepositories(db); const repos = createTestRepositories(db);
const outcomes = new ConversationOutcomesRepository(db); const outcomes = new ConversationOutcomesRepository(db);
const conversationStates = new ConversationStateRepository(db);
const now = Date.now(); const now = Date.now();
const roomId = '!alice:example.test'; const roomId = '!alice:example.test';
@@ -146,10 +150,19 @@ async function createFixture(options: {
undefined, undefined,
authenticatedAccountAuth, authenticatedAccountAuth,
outcomes, outcomes,
conversationStates,
); );
await service.loadCache(); await service.loadCache();
return { db, repos, outcomes, now, roomId, sentMessageId, matrix, service }; return {
db, repos, outcomes, now, roomId, sentMessageId, matrix, service,
markConversationComplete() {
const state = buildConversationState([]);
state.automation.status = 'human-required';
state.automation.handoffReason = 'Conversation complete.';
conversationStates.save(roomId, state);
},
};
} }
function syncResponse(nextBatch: string, events: ReturnType<typeof messageEvent>[]): MatrixSyncResponse { function syncResponse(nextBatch: string, events: ReturnType<typeof messageEvent>[]): MatrixSyncResponse {
+80 -10
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ConversationAutomationService } from '../src/services/conversation-automation.js'; import { ConversationAutomationService } from '../src/services/conversation-automation.js';
import type { ConversationState } from '../src/services/conversation-state.js'; import type { ConversationState } from '../src/services/conversation-state.js';
import type { ActiveChat } from '../src/services/chat.js';
function harness( function harness(
sellerKeywords: string[] = [], sellerKeywords: string[] = [],
@@ -11,6 +12,7 @@ function harness(
maxAutomatedTurns: number; maxAutomatedTurns: number;
rapportTurnsBeforeMediaAsk?: number; rapportTurnsBeforeMediaAsk?: number;
}, },
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
) { ) {
const saved = new Map<string, ConversationState>(); const saved = new Map<string, ConversationState>();
let lastSaved: ConversationState | undefined; let lastSaved: ConversationState | undefined;
@@ -43,6 +45,7 @@ function harness(
markAttempt: vi.fn(() => true), markAttempt: vi.fn(() => true),
releasePending: vi.fn(() => true), releasePending: vi.fn(() => true),
markSent: vi.fn(() => true), markSent: vi.fn(() => true),
markSuperseded: vi.fn(() => true),
}; };
const rateLimiter = { const rateLimiter = {
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()), throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
@@ -59,6 +62,7 @@ function harness(
}] : [], }] : [],
() => 1_000, () => 1_000,
0, 0,
beforeSendLoader ?? (async (_conversationId, observed) => observed),
); );
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved }; return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
} }
@@ -75,18 +79,17 @@ function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ original
} }
describe('conversation automation', () => { describe('conversation automation', () => {
it('sends once per inbound turn and flags human handoff after the third send', async () => { it('allows only one autonomous media request and then hands off', async () => {
const { service, chat, getSaved } = harness(); const { service, chat, getSaved } = harness();
expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2); expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2);
expect((await service.processConversation(incoming(2)))?.remainingTurns).toBe(1); const second = await service.processConversation(incoming(2));
const third = await service.processConversation(incoming(3));
expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); expect(second).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
expect(chat.sendChatMessage).toHaveBeenCalledTimes(3); expect(chat.sendChatMessage).toHaveBeenCalledOnce();
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' }); expect(getSaved()?.automation).toMatchObject({ turnsUsed: 1, status: 'human-required' });
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3); expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(1);
expect(await service.processConversation(incoming(3))).toBeNull(); expect(await service.processConversation(incoming(2))).toBeNull();
}); });
it('stops without sending when the user declines images', async () => { it('stops without sending when the user declines images', async () => {
@@ -98,6 +101,63 @@ describe('conversation automation', () => {
expect(getSaved()?.automation.status).toBe('boundary'); expect(getSaved()?.automation.status).toBe('boundary');
}); });
it('hands off immediately when the opener already made the conversation media request', async () => {
const fixture = harness();
const conversation = incoming(1, 'Sure');
conversation.messages[0].text = 'If you feel like it, send a pic where you are smiling.';
const result = await fixture.service.processConversation(conversation);
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled();
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.getSaved()?.automation.handoffReason).toContain('media request has already been made');
});
it('supersedes a queued reply when a refusal arrives during the rate-limit wait', async () => {
const tattooEvent = {
eventId: 'tattoo-event', sender: 'alice',
text: 'I edit out my tattoos for the sake of anonymity but that is the only filtering done.',
timestamp: 30, isSelf: false,
};
const observed: ActiveChat = {
username: 'JustProwling', roomName: 'JustProwling', roomId: 'room-alice', url: '/room/room-alice',
lastMessageTime: 30, lastMessageSnippet: tattooEvent.text, isFromUs: false, unread: true,
messages: [
{ eventId: 'opener', sender: 'me', text: 'Send a pic where you are smiling.', timestamp: 10, isSelf: true },
tattooEvent,
],
};
const latest: ActiveChat = {
...observed,
lastMessageTime: 40,
lastMessageSnippet: 'Zero interest in sending you anything lol',
messages: [
...observed.messages!,
{
eventId: 'refusal-event', sender: 'alice',
text: 'Zero interest in sending you anything lol', timestamp: 40, isSelf: false,
},
],
};
const fixture = harness([], [], 0, undefined, async () => latest);
fixture.claims.getClaim.mockReturnValue({
conversationId: 'room-alice', incomingKey: 'tattoo-event', status: 'pending',
replyText: 'If you ever feel like sharing a quick snap, I am all eyes.', incomingAt: 30,
claimedAt: 0, lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
} as never);
const result = await fixture.service.processConversation(observed);
expect(result?.kind).toBe('boundary');
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'tattoo-event');
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.getSaved()).toMatchObject({
boundaries: ['does not want to send photos or videos'],
automation: { status: 'boundary', lastIncomingKey: 'refusal-event' },
});
});
it('flags a potential upsell for human handoff without generating a reply', async () => { it('flags a potential upsell for human handoff without generating a reply', async () => {
const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']); const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']);
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example')); const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
@@ -323,7 +383,7 @@ describe('conversation automation', () => {
expect(ai.generateMediaAsk).not.toHaveBeenCalled(); expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2); expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
expect(getSaved()?.automation).toMatchObject({ expect(getSaved()?.automation).toMatchObject({
policyMode: 'r4r', policyVersion: 2, maxAutomatedTurns: 2, turnsUsed: 2, policyMode: 'r4r', policyVersion: 3, maxAutomatedTurns: 2, turnsUsed: 2,
}); });
expect(getSaved()?.askedForMedia).toBe(false); expect(getSaved()?.askedForMedia).toBe(false);
}); });
@@ -396,7 +456,7 @@ describe('conversation automation', () => {
expect(fixture.getSaved()?.askedForMedia).toBe(true); expect(fixture.getSaved()?.askedForMedia).toBe(true);
expect(fixture.getSaved()?.automation).toMatchObject({ expect(fixture.getSaved()?.automation).toMatchObject({
policyMode: 'r4r-media', policyMode: 'r4r-media',
policyVersion: 2, policyVersion: 3,
rapportTurnsBeforeMediaAsk: 2, rapportTurnsBeforeMediaAsk: 2,
hybridPhase: 'post-media-ask', hybridPhase: 'post-media-ask',
turnsUsed: 4, turnsUsed: 4,
@@ -445,4 +505,14 @@ describe('conversation automation', () => {
expect(result?.kind).toBe('human-required'); expect(result?.kind).toBe('human-required');
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(1); expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(1);
}); });
it('rejects a coercive legacy media request', async () => {
const fixture = harness();
fixture.ai.generateMediaAsk.mockResolvedValue('Prove me wrong and send a pic.');
const result = await fixture.service.processConversation(incoming(1));
expect(result?.kind).toBe('human-required');
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
});
}); });
+22 -2
View File
@@ -28,7 +28,11 @@ function message(): SentMessageRecord {
function dependencies() { function dependencies() {
return { return {
messages: { markGotResponse: vi.fn() } as unknown as MessagesRepository, messages: { markGotResponse: vi.fn() } as unknown as MessagesRepository,
successful: { insert: vi.fn(), existsByMessage: vi.fn(() => false) } as unknown as SuccessfulMessagesRepository, successful: {
insert: vi.fn(),
existsByMessage: vi.fn(() => false),
deleteBySentMessageId: vi.fn(() => false),
} as unknown as SuccessfulMessagesRepository,
outcomes: { record: vi.fn(), suppressRecipient: vi.fn() } as unknown as ConversationOutcomesRepository, outcomes: { record: vi.fn(), suppressRecipient: vi.fn() } as unknown as ConversationOutcomesRepository,
logger: createMockLogger(), logger: createMockLogger(),
}; };
@@ -44,7 +48,9 @@ describe('recordConversationReply', () => {
it('stores only reciprocal engaged conversations as learning examples', () => { it('stores only reciprocal engaged conversations as learning examples', () => {
const deps = dependencies(); const deps = dependencies();
expect(recordConversationReply(message(), 'Tell me more', { messageCount: 4, reciprocal: true }, deps)).toBe('engaged'); expect(recordConversationReply(message(), 'Tell me more', {
messageCount: 4, reciprocal: true, learningEligible: true,
}, deps)).toBe('engaged');
expect(deps.successful.insert).toHaveBeenCalledWith(expect.objectContaining({ sentMessageId: 1 })); expect(deps.successful.insert).toHaveBeenCalledWith(expect.objectContaining({ sentMessageId: 1 }));
}); });
@@ -53,5 +59,19 @@ describe('recordConversationReply', () => {
expect(recordConversationReply(message(), 'Stop messaging me', { messageCount: 2 }, deps)).toBe('boundary'); expect(recordConversationReply(message(), 'Stop messaging me', { messageCount: 2 }, deps)).toBe('boundary');
expect(deps.outcomes.suppressRecipient).toHaveBeenCalledWith('alice', 'boundary'); expect(deps.outcomes.suppressRecipient).toHaveBeenCalledWith('alice', 'boundary');
expect(deps.successful.insert).not.toHaveBeenCalled(); expect(deps.successful.insert).not.toHaveBeenCalled();
expect(deps.successful.deleteBySentMessageId).toHaveBeenCalledWith(1);
});
it('removes an earlier learning example when the full conversation later declines', () => {
const deps = dependencies();
expect(recordConversationReply(message(), "I'm good", {
messageCount: 8,
reciprocal: true,
learningEligible: true,
mediaWasRequested: true,
responses: ['Sure', 'Zero interest in sending you anything lol', "I'm good"],
}, deps)).toBe('declined');
expect(deps.successful.deleteBySentMessageId).toHaveBeenCalledWith(1);
expect(deps.successful.insert).not.toHaveBeenCalled();
}); });
}); });
+19
View File
@@ -15,6 +15,25 @@ describe('conversation outcome classification', () => {
expect(classifyConversationOutcome('That sounds fun, tell me more', { expect(classifyConversationOutcome('That sounds fun, tell me more', {
messageCount: 4, messageCount: 4,
reciprocal: true, reciprocal: true,
learningEligible: true,
})).toBe('engaged'); })).toBe('engaged');
}); });
it('lets a later refusal override earlier reciprocal engagement', () => {
expect(classifyConversationOutcome("I'm good", {
messageCount: 8,
reciprocal: true,
learningEligible: true,
mediaWasRequested: true,
responses: ['Sure, tell me more', 'Zero interest in sending you anything lol', "I'm good"],
})).toBe('declined');
});
it('does not promote a positive reciprocal thread while automation is active', () => {
expect(classifyConversationOutcome('Sure, tell me more', {
messageCount: 4,
reciprocal: true,
learningEligible: false,
})).toBe('unknown');
});
}); });
+23 -1
View File
@@ -50,6 +50,28 @@ describe('conversation state', () => {
}); });
}); });
it.each([
'Zero interest in sending you anything lol',
"I'm good",
'Rather not, thanks',
])('treats a contextual media refusal as a boundary: %s', (refusal) => {
const state = buildConversationState([
message('Would you be comfortable sharing a selfie?', true),
message(refusal),
]);
expect(state.boundaries).toContain('does not want to send photos or videos');
});
it('does not treat a conversational wellbeing answer as a media refusal', () => {
const state = buildConversationState([
message('How are you?', true),
message("I'm good"),
]);
expect(state.boundaries).toEqual([]);
});
it('allows three progressive image-sharing turns before human handoff', () => { it('allows three progressive image-sharing turns before human handoff', () => {
const state = buildConversationState([message('Hello', true), message('Hey')]); const state = buildConversationState([message('Hello', true), message('Hey')]);
expect(remainingAutomationTurns(state)).toBe(3); expect(remainingAutomationTurns(state)).toBe(3);
@@ -75,7 +97,7 @@ describe('conversation state', () => {
expect(state?.automation).toMatchObject({ turnsUsed: 0, status: 'active' }); expect(state?.automation).toMatchObject({ turnsUsed: 0, status: 'active' });
expect(state?.sellerDetected).toBe(false); expect(state?.sellerDetected).toBe(false);
expect(state?.automation).toMatchObject({ expect(state?.automation).toMatchObject({
policyMode: 'legacy', policyVersion: 2, maxAutomatedTurns: 3, policyMode: 'legacy', policyVersion: 3, maxAutomatedTurns: 3,
rapportTurnsBeforeMediaAsk: 3, hybridPhase: null, rapportTurnsBeforeMediaAsk: 3, hybridPhase: null,
}); });
}); });
+41 -1
View File
@@ -160,7 +160,7 @@ describe('Database Client', () => {
DROP TABLE conversation_automation_claims; DROP TABLE conversation_automation_claims;
DROP TABLE ai_usage; DROP TABLE ai_usage;
`); `);
db.pragma(`user_version = ${latestVersion - 5}`); db.pragma(`user_version = ${latestVersion - 6}`);
runMigrations(db); runMigrations(db);
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all()) expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
@@ -170,6 +170,46 @@ describe('Database Client', () => {
]); ]);
}); });
it('preserves pending reply claims when adding auditable superseded status', () => {
runMigrations(db);
const latestVersion = db.pragma('user_version', { simple: true }) as number;
db.exec(`
DROP TABLE conversation_automation_claims;
CREATE TABLE conversation_automation_claims (
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
incoming_key TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'sent')),
claimed_at INTEGER NOT NULL,
sent_at INTEGER,
reply_text TEXT,
incoming_at INTEGER,
last_attempt_at INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, conversation_id, incoming_key),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
INSERT INTO conversation_automation_claims (
account_id, conversation_id, incoming_key, status, claimed_at,
reply_text, incoming_at, last_attempt_at, attempt_count
) VALUES (
'__legacy_unassigned__', 'room', 'event', 'pending', 10,
'durable reply', 5, 11, 1
);
`);
db.pragma(`user_version = ${latestVersion - 1}`);
runMigrations(db);
expect(db.prepare(`
SELECT status, reply_text, incoming_at, last_attempt_at, attempt_count, superseded_at
FROM conversation_automation_claims
`).get()).toEqual({
status: 'pending', reply_text: 'durable reply', incoming_at: 5,
last_attempt_at: 11, attempt_count: 1, superseded_at: null,
});
});
it('processed_posts has correct schema', () => { it('processed_posts has correct schema', () => {
runMigrations(db); runMigrations(db);
const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[]; const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[];
+14 -1
View File
@@ -121,13 +121,26 @@ describe('account-bound domain repositories', () => {
expect(claims.getClaim('room', 'event')).toEqual({ expect(claims.getClaim('room', 'event')).toEqual({
conversationId: 'room', incomingKey: 'event', status: 'pending', conversationId: 'room', incomingKey: 'event', status: 'pending',
replyText: 'durable reply', incomingAt: 50, claimedAt: 60, replyText: 'durable reply', incomingAt: 50, claimedAt: 60,
lastAttemptAt: 70, attemptCount: 1, sentAt: null, lastAttemptAt: 70, attemptCount: 1, sentAt: null, supersededAt: null,
}); });
expect(claims.listPending()).toEqual([expect.objectContaining({ incomingKey: 'event' })]); expect(claims.listPending()).toEqual([expect.objectContaining({ incomingKey: 'event' })]);
expect(claims.releasePending('room', 'event')).toBe(true); expect(claims.releasePending('room', 'event')).toBe(true);
expect(claims.listPending()).toEqual([]); expect(claims.listPending()).toEqual([]);
}); });
it('keeps superseded reply claims as an auditable terminal record', () => {
const claims = new ConversationAutomationClaimRepository(db, ACCOUNT_A);
expect(claims.tryClaim('room', 'stale-event', 'stale reply', 50, 60)).toBe(true);
expect(claims.markSuperseded('room', 'stale-event', 80)).toBe(true);
expect(claims.getClaim('room', 'stale-event')).toMatchObject({
status: 'superseded',
supersededAt: 80,
sentAt: null,
});
expect(claims.listPending()).toEqual([]);
});
it('claims recipients globally and blocks usernames with existing chat history', () => { it('claims recipients globally and blocks usernames with existing chat history', () => {
db.prepare(` db.prepare(`
INSERT INTO reddit_conversations ( INSERT INTO reddit_conversations (