Intake triage queue: bulk upload, auto-classify, and one-click project assignment

Four commits add a `/intake` queue where documents land before being assigned to projects. Each upload triggers an LLM classification in the background; the UI polls for results and lets users route each document to an existing project or spin up a new one. This is the "front door" that feeds the counterparty index.

intakecontract-review

32b1f25b is the load-bearing change. Migration 006_document_intake.sql adds seven intake_* columns to documents: role (buyer/seller/mutual), status (draft/execution/unknown), counterparty, parent_counterparty, lifecycle_hint, confidence, and analyzed_at. All nullable, so back-compat with existing rows is clean.

lib/intakeAnalysis.ts runs one LLM call per upload. The prompt returns a single JSON object with all seven fields in one shot - maxTokens 300, using first 12,000 characters of document text. The call is fire-and-forget; upload responses don't wait on it. A partial index on (user_id, intake_analyzed_at desc) WHERE project_id IS NULL keeps the intake queue query fast.

IntakeOverview.tsx (~318 LOC) is a polling list - 4-second interval while any document is mid-analysis. It shows role+status+lifecycle pills per document, fuzzy-matched project suggestions, and a "Create new project" button that pre-selects template by detected role.

230083fa refines the UX: the projects list swaps its CM/Chats columns for Type and Counterparty pills, and "Create new project" auto-names the project as "Counterparty - LIFECYCLE_HINT" (e.g., "Adobe Inc. - SOW"). A fall-through "Assign to..." dropdown appears when fuzzy matching finds no confident suggestion.

69aef399 promotes unassigned documents to first-class objects. POST /single-documents/bulk-assign accepts either {document_ids, project_id} or {document_ids, new_project: {...}}. The backend checks ownership and unassigned status for every ID before any mutation. Role filter chips with counts appear on the intake page. A sticky bulk-action bar lets users select multiple documents and create one project from them, using majority-vote on template and counterparty across the selection. The customer index (/projects/counterparties and the timeline endpoints) also folds standalone docs in.

b375d048 surfaces the standalone-doc count on each counterparty row in the index (+N standalone pill), adds a footer "view full timeline" link, and synthesises an "Unassigned" entry in CounterpartyTimelineView so /customers/Adobe Inc. renders even when no project for Adobe exists yet.

One thing to check before adopting: the 4-second polling cadence is fine at small scale but each document analysis is a billable LLM call. There's no UI signal for extraction failures - silent failures will appear as missing data.

So what Worth pulling alongside the counterparty work (post 521). These two tracks share the same `intake_role`/`intake_counterparty` columns - the multi-counterparty project derivation in `dd8e28e2` reads from intake data, so cherry-picking one without the other leaves things in a degraded state. Additive migration, no breaking changes to existing projects. Skip if you don't need bulk-upload triage or if per-document LLM cost is a concern at your upload volume.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

4 commits from Custos/legalos, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
32b1f25b Intake: bulk upload + auto-classification + assign-or-create flow Custos 2026-05-04 ↗ GitHub
commit body
Adds an /intake triage queue for bulk-uploaded contracts. Each upload is
classified by an LLM and routed to an existing project (counterparty
match) or a new one with a single click.

Schema:
- documents.intake_role / intake_status / intake_counterparty /
  intake_parent_counterparty / intake_lifecycle_hint / intake_confidence /
  intake_analyzed_at.

Backend:
- New lib/intakeAnalysis.ts: maybeAnalyzeIntake() runs one LLM call that
  determines role (buyer/seller/mutual), status (draft/execution/unknown),
  counterparty, parent entity, lifecycle position (original/amendment/
  renewal/addendum/sow/order_form/etc.), and self-reported confidence.
- Hooked into POST /single-documents and POST /projects/:id/documents,
  fire-and-forget alongside contract-fact extraction.
- GET /single-documents/intake returns pending docs + the user's projects
  for client-side counterparty matching.
- POST /single-documents/:id/assign accepts {project_id} OR
  {new_project: {name, template, counterparty, parent_counterparty}}.

Frontend:
- New /intake route with bulk upload button (multi-file).
- Per-doc row shows role + status + lifecycle pills, counterparty +
  parent + confidence, fuzzy-matched existing project suggestions
  (single click to assign), and a "Create new project" button that
  preselects the right template based on detected role.
- AppSidebar gains Intake entry above Projects.
- Polls every 4s while any doc is mid-analysis.
230083fa Projects + Intake UX: surface counterparty and template, fall-through assign Custos 2026-05-04 ↗ GitHub
commit body
Projects list now leads with what matters for contract management:
- Replaced CM / Chats columns with Type (vendor/customer/internal pill)
  and Counterparty (with parent badge if any).
- Files + Reviews kept; Created replaced with Updated (more useful).

Intake row improvements:
- "Create new project" now names the project as
  "Counterparty - LIFECYCLE_HINT" when both are known (e.g.
  "Adobe Inc. - SOW") so multiple contracts with one counterparty stay
  distinguishable.
- New "Assign to..." dropdown lists every existing project so you can
  pick a target even when fuzzy-match doesn't surface it.
69aef399 Standalone docs first-class: bulk assign, role filters, customer index Custos 2026-05-04 ↗ GitHub
commit body
Backend:
- POST /single-documents/bulk-assign accepts {document_ids, project_id}
  or {document_ids, new_project} and atomically reassigns the batch.
- GET /projects/counterparties now folds standalone documents into the
  customer index. Each group gains a standalone_count field; documents
  without an intake_role match the role filter via intake_role.
- GET /projects/counterparties/:name/timeline includes user-owned
  standalone docs whose intake_counterparty matches.

Frontend (/intake):
- Filter chips by role: All / Vendors / Customers / Internal-Other,
  with per-bucket counts.
- Per-row checkboxes plus a select-all checkbox in the header.
- Sticky bulk-action bar (visible when 1+ selected): pick an existing
  project from a dropdown and "Assign", or "Create new project from
  selection" which auto-derives template + counterparty by majority
  vote across the picked docs.
b375d048 Customer index: surface standalone-doc count + timeline entry Custos 2026-05-04 ↗ GitHub
commit body
- CustomersOverview shows '+N standalone' pill on each counterparty row
  when standalone docs exist, plus a footer link "view full timeline"
  in the expanded section.
- Page total now includes a "X standalone docs" tally.
- CounterpartyTimelineView synthesises an "Unassigned" timeline entry
  when standalone docs exist, so visiting /customers/Adobe Inc. with
  no projects but 2 unassigned PDFs actually renders the docs (was
  showing "no projects" empty state).
- Empty-state copy updated for the new world (don't require projects).

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-44.md from inside the repo you want the changes in.

⬇ Download capture-thread-44.md