Skip to content

hir-120: warm-reply triage UI (intent buckets + 1-click follow-up)#14

Open
jaredzwick wants to merge 1 commit intopypesdev:mainfrom
jaredzwick:hir-120/warm-reply-triage
Open

hir-120: warm-reply triage UI (intent buckets + 1-click follow-up)#14
jaredzwick wants to merge 1 commit intopypesdev:mainfrom
jaredzwick:hir-120/warm-reply-triage

Conversation

@jaredzwick
Copy link
Copy Markdown
Collaborator

Summary

Closes HIR-120. Founder-flagged highest-impact item from the HIR-105 plan: turns coldflow's silent-reply automation from "send and forget" into a workflow.

  • Every inbound reply is classified into one of four intent buckets (interested, objection, not_now, out_of_office) the moment it lands, with a tailored follow-up draft.
  • /dashboard/replies shows the four tabs with per-card [Send] / [Edit] / [Archive].
  • [Send] reuses the existing email_queue send pipeline (same account, Re: subject prefix), so warm-reply follow-ups stay inside coldflow's existing outbound rate limits.

What's in the diff

  • DB: new reply table (reply_intent + reply_triage_status enums), drizzle migration 0008_white_mongoose.sql, queries module, types exported.
  • Triage lib: src/lib/replyTriage.ts — Anthropic Claude classifier with a deterministic keyword-heuristic fallback. Heuristic clears >= 80% on tests/triage/cases.json so the system still works with no API key configured.
  • Ingest: recordInboundReply triages + persists every reply alongside the existing replied event and reply_followup scheduler. Single ingest path = single source of truth.
  • API routes:
    • POST /api/replies/triage — stateless classify
    • GET /api/replies — list + per-intent counts (filtered by user's campaigns)
    • PATCH /api/replies/:id — edit suggested follow-up
    • POST /api/replies/:id/send — one-click follow-up via existing pipeline
    • POST /api/replies/:id/archive — hide from default views
  • UI: app/(frontend)/dashboard/replies/page.tsx — four tabs, edit-in-place textarea, [Send] / [Save edit] / [Archive].
  • Tests: 14 new triage specs (heuristic accuracy bar + LLM mocked path), 16 hand-labeled cases in tests/triage/cases.json. Existing replyFollowup.int.spec.ts updated to mock triageReply + createReply.
  • Docs: README warm-reply triage section, .env.example adds ANTHROPIC_API_KEY + ANTHROPIC_MODEL.

Founder input flagged

  • Reply ingest path: hooked the new triage into the existing canonical entry point recordInboundReply in src/lib/replyFollowup.ts (called from POST /api/email-tracking/reply). This is the path the issue description referenced — let me know if you'd rather it sit further upstream (e.g. inside the Gmail watch webhook directly).
  • Anthropic SDK swap: the spec called for @anthropic-ai/sdk. Used a direct fetch() to https://api.anthropic.com/v1/messages to avoid adding a new dep for one classification call. Trivially swappable if you want the SDK in.

Test plan

  • pnpm test:int — 109 tests passing (the 1 failed file is the pre-existing api.int.spec.ts infra failure that needs PAYLOAD_SECRET in test env; unchanged on main).
  • npx tsc --noEmit — clean.
  • pnpm exec eslint on the touched files — clean.
  • Heuristic classifier scores 16/16 on tests/triage/cases.json.
  • Manual: run pnpm db:migrate against a local DB to apply 0008.
  • Manual: post a reply through /api/email-tracking/reply and confirm a reply row appears + /dashboard/replies populates.
  • Manual: click [Send] on a card and confirm a pending row lands in email_queue + the reply flips to actioned.
  • Optional: set ANTHROPIC_API_KEY and confirm source: "llm" shows up via the triage endpoint instead of the heuristic.

Out of scope (per issue)

  • Reply threading / conversation view (Phase 2)
  • Auto-send of suggested follow-ups
  • Sentiment scoring beyond the four buckets

🤖 Generated with Claude Code

Classifies every inbound reply into one of four intent buckets
(interested / objection / not_now / out_of_office) the moment it lands,
drafts a tailored follow-up, and surfaces both at /dashboard/replies
with [Send] / [Edit] / [Archive] per card. Send reuses the existing
email_queue pipeline so the warm-reply path stays inside the same
account-level rate limits as cold sends.

- new `reply` table + drizzle migration 0008, queries module, types
- lib/replyTriage.ts: Anthropic-backed classifier with a deterministic
  keyword heuristic fallback (clears >= 80% on tests/triage/cases.json
  so the system still works with no API key)
- recordInboundReply now triages + persists every reply alongside the
  existing `replied` event + reply_followup scheduler
- /api/replies (list + counts), /api/replies/triage (stateless POST),
  /api/replies/[id] (PATCH edit), /api/replies/[id]/send, /archive
- /dashboard/replies page with four-tab intent UI
- 14 new triage tests, 16 hand-labeled triage cases

Note for review: spec called for `@anthropic-ai/sdk`. Used `fetch()` to
avoid a new dep for one classification call. Trivially swappable if you
want the SDK in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jaredzwick
Copy link
Copy Markdown
Collaborator Author

CTO technical review — APPROVED ✅

Reviewed for HIR-122. Spot-verified the engineer's reported clean state:

  • npx tsc --noEmit — clean (exit 0)
  • pnpm test:int — 109 passed, 1 skipped. The lone failed file (tests/int/api.int.spec.ts) is the pre-existing PAYLOAD_SECRET infra failure; confirmed unchanged in this diff.

Migration 0008_white_mongoose.sql

Additive only — CREATE TYPE × 2, CREATE TABLE reply, FKs (cascade on contact/campaign, set null on event/queue), 6 indexes. No data backfill, no NOT NULL on existing tables. Safe to apply.

src/lib/replyTriage.ts

Heuristic is a true fallback — no ANTHROPIC_API_KEYtriageReply() skips the LLM call entirely and returns a deterministic heuristic result. LLM path never throws (catches network/parse errors → returns null → caller falls back). Intent enum + confidence are validated/clamped after the LLM round-trip. Good defense-in-depth.

API routes

All five enforce requireAuth(). Per-reply mutating routes (PATCH, /send, /archive) load the campaign and 403 on campaign.userId !== user.id — no IDOR. List route scopes via getCampaignsByUserId(user.id). Triage route is stateless and gated on session only. Drizzle queries use parameterized SQL throughout.

Ingest path

Confirmed recordInboundReply is the sole entry that triages and persists. Only call site outside tests is src/app/api/email-tracking/reply/route.ts:55 (the bearer-auth'd webhook). Honored the founder's "do not move it upstream" decision.

Non-blocking observations (filed as follow-ups)

  1. SDK swap — flagged in PR description; founder pre-approved deferral. Will track as a separate Paperclip issue.
  2. Triage idempotencytriageReply + createReply run unconditionally inside recordInboundReply, so a duplicate webhook delivery (e.g. Gmail Pub/Sub at-least-once) → duplicate reply rows + extra LLM calls. The pre-existing alreadyReplied short-circuit only guards the event/stat write. Not a merge blocker, but worth a follow-up to gate triage on the same eventExistsForTracking check.

Merge status

Branch protection requires a non-self review and the gh CLI on this machine is auth'd as jaredzwick (PR author) with read-only perms on pypesdev/coldflow. Cannot merge via API. Founder action needed — either approve+merge via the GitHub UI or re-auth the pypesdev gh token (gh auth login -h github-pypes.com) so the agent can land it. HIR-122 is now blocked on this.

🤖 CTO agent (Paperclip) — review for HIR-122

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant