diff --git a/MEMO.md b/MEMO.md new file mode 100644 index 00000000..68e38e27 --- /dev/null +++ b/MEMO.md @@ -0,0 +1,109 @@ +# Greenroom — Deal Modeling Slice + +**To:** Pri (CEO), Anil (Product) +**From:** Febin +**Date:** May 2026 +**Re:** Why I picked Deal Modeling, what I built, what's next + +--- + +## The slice + +From the six possible cuts inside settlement — *deal modeling, audit trails, real-time prediction, the 2am walkthrough, post-show comms, dispute resolution* — **I picked Deal Modeling.** + +Disputes at The Crescent aren't math errors. They're **truth errors**. There's no agreed-on version of the deal anywhere in the system. + +The data is unambiguous: +- **22 of 540** past settlements are formally disputed +- **5+** "paid" settlements carry unresolved disputed recoups +- **8** deals have prose in `deal_notes_freetext` that contradicts the structured fields +- **Daniel Hwang at WME alone** has 4 prior disputed marketing recoups across 4 different shows + +Three of four interview subjects say the same thing in different words. Sarah Kim: *"the deal was a ghost — Mariana had her notes, I had my email, Andrea had her recollection, and none of them agreed."* Mariana: *"most of the friction in the 2am conversation comes from things that were knowable on Wednesday."* Marcus: *"we're paying a tax on every poorly-written deal email we ever signed."* + +--- + +## What I built + +**The Deal Brief** — a Wednesday-confirmed, structured representation of the deal that both parties have signed off on in writing **before the show**. + +Mariana pastes the deal email. The system extracts structured terms, flags ambiguous sentences with **computed dollar swings**, and surfaces contradictions against existing data (percentage drift, hospitality cap breaches, cross-show agent patterns). She sends one clarification email to the agent if needed, gets *"confirmed"* back, and locks the brief. + +The settlement page then reads from the brief — closing the **62.5% unsupported-deal gap** (vs deals, walkout pots, ratchets, % of net, door) as a *byproduct* of fixing the upstream truth problem, not because I built a better calculator. + +The Coastal Spell dispute would not have happened with this in place. The same one-sentence ambiguity (*"expenses capped at $2,500, marketing recoup of $900 against gross"*) would have surfaced on Wednesday March 9 with a computed $720 dollar swing. Mariana sends one email. Daniel replies "confirmed." Dispute prevented. + +--- + +## Design choices + +- **Brief, not calculator.** The calculator was never the bottleneck — input ambiguity was. The brief gives the existing calculator richer typed inputs. +- **`recoupPlacement` is a required field** (`inside_cap` vs `outside_cap`). The brief schema *forces* the decision the legacy schema let stay ambiguous. This single field would have prevented the Coastal Spell loss. +- **Source of truth is the prose**, not the structured fields. Mariana already trusts the email; the brief structures it without asking her to re-enter anything. +- **Three lifecycle states** — `draft → awaiting_confirmation → confirmed`. The settlement page reads only confirmed briefs. Drafts don't flip math. +- **The clarification email is a deterministic template, not AI-generated.** Mariana's voice and her relationship with the agent — not the model's. + +--- + +## What I cut (and why) + +| Cut | Why | +|---|---| +| Vs-deal calculator as a standalone slice | Solving math on contested inputs gives perfect-wrong answers. Wrong layer of the problem. | +| Polished 2am settlement UI | Mariana said *"the math is the easy part."* Polish the symptom, miss the cause. | +| Post-show agent PDF | Ships value only after a trustworthy brief exists upstream. Sequenced for "next." | +| Dispute resolution workspace | Optimizes for the 5% tail event instead of preventing the 30% of pre-dispute friction. | +| Artist health scoring, triage dashboards | Feature-wishlist drift. Not derived from the data or the transcripts. | +| Receipt uploads, version timelines, comments, redesigned settle page | Each tempting. None defended by the data. | +| Agent-side portal/login | Agents already reply by email — don't make them adopt a new product. | +| Vector DB (Pinecone, Weaviate) | 540 vectors × 1.5KB = 800KB. SQLite BLOB + pure-JS cosine in 1ms. No infra needed at this scale. | + +--- + +## AI used like a senior teammate + +The product uses AI for exactly two things: **extracting structured terms from messy prose**, and **detecting when a single sentence could be read two ways**. Everything else is plain code. + +The implementation is a 4-tier waterfall: + +1. **Tier 0 — regex parser.** Handles **72%** of real deals at **$0**. ~5ms, no network. +2. **Tier 1 — local embeddings** (Xenova MiniLM-L6, runs in Node). Clones prior confirmed briefs when similarity ≥ 0.95. Otherwise supplies top-3 examples as few-shot to Tier 2. +3. **Tier 2 — Groq Llama 3.3 70B (free tier, 30 RPM).** Handles ~15% of deals — the genuinely weird ones. Two passes: structured extraction + ambiguity detection. +4. **Tier 3 — Mariana.** Manual entry/edit. Drafts never auto-confirm. + +Cross-show pattern detection is **pure SQL**, not LLM. Agent's prior disputed marketing recoups via `GROUP BY agent_id` over `json_each(recoups_json)`. The senior architect's instinct: don't use AI for a `WHERE` clause. + +**~80% of real deals never touch the LLM.** Steady-state cost at one venue: **free**. At Greenroom's 340-venue scale: roughly **$10/month/venue**. The whole system runs in the existing Next.js process — no vector DB, no Python service, no message queue. Provider is swappable via env var (Groq, Gemini, or Ollama local). + +--- + +## How I'd validate + +Three checks, in order of cost: + +1. **Replay against history (one evening, no users).** Run the brief flow against the 22 disputed settlements. For how many would the ambiguity report have surfaced the contested sentence before the show? **Target: ≥70%.** +2. **Shadow mode at The Crescent (4 weeks).** Mariana runs her normal flow; the brief generates in parallel and emails her a Wednesday digest she can ignore. Measure: how often does she act on it? Does her Friday settlement match what the brief predicted? +3. **Co-pilot at one design-partner venue (8 weeks).** Brief is canonical; settlement flows from it. Leading metric: % of vs-deal settlements that go through the in-app tool instead of a spreadsheet. **Today: ~0%. Target: 40% by week 12.** + +**Headline KPI:** % of disputes in the next quarter that the brief either prevented (ambiguity caught on Wednesday) or flagged in advance (cross-show pattern fired before the deal email was even sent). **Target: 50% reduction in formal disputes within 90 days at the pilot venue.** + +--- + +## What ships next (in leverage order) + +1. **Agent-side preview link.** Diego asked for this directly in his transcript: a read-only URL the tour manager opens on their phone during loadout. Same brief, different audience. ~2 days. +2. **Brief↔settlement diff.** When the final settlement strays from the confirmed brief, show *why* and *which clause*. Closes the prevention loop. +3. **Inline field edits.** `saveBriefEdits` is wired server-side; the UI just needs the inline-edit affordance. ~1 day. +4. **Pattern memory expansion.** From *"this agent disputes recoups"* to *"this venue routinely under-budgets hospitality by 35%."* Addresses Marcus's margin-variance complaint. +5. **Brief export to the agent's inbox.** Once the brief is trustworthy, exporting it as a clean PDF satisfies Sarah Kim's *"itemization-provenance-tone"* requirement by construction. +6. **Lazy backfill of the 540 historical deals.** First touch after this ships, auto-extract the brief in the background. Builds the corpus and the AI flywheel. + +--- + +## The headline + +**Disputes are not math errors. They're truth errors.** The Deal Brief is a Wednesday tool that turns a contested deal into a confirmed one before the show happens. The Friday-night ritual gets shorter because the Wednesday-afternoon conversation finally happens. + +--- + +*Repo: see [README.md](README.md) for setup, sample emails, and a 3-minute walkthrough · Loom walkthrough: [link]* diff --git a/README.md b/README.md index c48fd3d8..7ab96abe 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,225 @@
-# Greenroom +# Greenroom — Deal Modeling Slice -**Software for independent music venues.** +**The Deal Brief: turning fuzzy deal emails into a confirmed source of truth before the show.** -This is the starter codebase for the Greenroom Applied AI PM case study. +Built by Febin as a case study response for the Greenroom Applied AI PM role.
--- -You're looking at a working but mediocre product. It's enough to feel real, but every workflow has gaps. **Your job isn't to fix everything — it's to pick a slice and design it well.** See your case study brief for full instructions. +## What this is -## Before you start +The original Greenroom product had a settlement workflow that couldn't handle ~62% of real deals (Vs, percentage-of-net, door, walkout, ratchet). The deeper problem was upstream: deal terms in the email and the structured database fields rarely agreed, and there was no canonical version anyone could point to. The Coastal Spell dispute (`data/dispute-thread.md`) cost the venue $720 and an agent relationship over a single ambiguous sentence. -You'll need: +This branch ships **The Deal Brief** — a Wednesday tool that: -1. **Node.js, version 20 or higher** — get it from [nodejs.org](https://nodejs.org/) (pick the LTS version). Verify with `node -v`. -2. **Git** — most computers have it. Verify with `git --version`. If not, install from [git-scm.com](https://git-scm.com/). -3. **A code editor.** [VS Code](https://code.visualstudio.com/) is great. [Cursor](https://cursor.com/) is what we'd reach for if we were doing this case study. -4. **A GitHub account.** Free at [github.com](https://github.com/). +1. **Extracts** structured terms from the deal email (regex first, AI only when needed) +2. **Flags ambiguous sentences** with computed dollar swings (the Coastal Spell pattern) +3. **Surfaces contradictions** against the existing database (percentage drift, cross-show patterns, etc.) — all in SQL, no AI +4. **Confirms in writing** — Mariana sends one clarification email, gets "confirmed" back, the brief locks +5. **Feeds the settlement engine** — closing the 62% unsupported-deal gap as a byproduct -If you're on Windows, run all the commands below in **Git Bash**, **PowerShell**, or **WSL** — not the legacy Command Prompt. +Read the [memo](MEMO.md) for the full design rationale. -## Setup, step by step - -### 1. Fork this repo to your own GitHub account - -Click the **Fork** button at the top right of [https://github.com/samay-cbh/greenroom-starter](https://github.com/samay-cbh/greenroom-starter). You'll get a copy under your own username. - -> _Why fork?_ A fork is your own copy of the repo. You'll commit your changes there, and submit your fork's URL when you're done. We can see your commit history that way. +--- -### 2. Clone your fork to your computer +## Quickstart (under 2 minutes) ```bash git clone https://github.com/YOUR-USERNAME/greenroom-starter cd greenroom-starter +npm install +npm run dev ``` -(Replace `YOUR-USERNAME` with your actual GitHub username.) +Open **[http://localhost:3000](http://localhost:3000)**. You're logged in automatically as Mariana Reyes, lead booker at The Crescent. + +### Optional but recommended — set an AI key -### 3. Install dependencies +The Deal Brief works without an API key (regex parser handles ~72% of deals, manual entry covers the rest). To unlock the LLM-powered ambiguity detection and extraction for unusual emails, set one of these: ```bash -npm install +cp .env.example .env.local ``` -This pulls down all the JavaScript packages the project needs. Takes about 60 seconds. You may see a few warnings — those are normal and safe to ignore. - -### 4. Start the app +Then edit `.env.local`: ```bash -npm run dev +# Option 1 — Groq (RECOMMENDED — 30 RPM free, ~500ms inference) +GROQ_API_KEY=gsk_... + +# Option 2 — Gemini (10 RPM free, ~2s inference) +GEMINI_API_KEY=AIza... ``` -You'll see something like: +Then restart `npm run dev`. -``` -▲ Next.js 16.x -- Local: http://localhost:3000 +--- + +## How to use the Deal Brief + +The new product surface lives at **`/shows/[id]/brief`**. Get there from any show detail page via the green "Confirm the deal" banner. + +### The four-step workflow + +1. **Paste the deal email** into the textarea. Real emails from agents, exactly as written. (Subject line, signature, etc. all fine.) +2. **Click Extract.** The page parses the email and shows: + - Structured terms (guarantee, percentage, expense cap, hospitality cap, bonuses, recoups with placement tags) + - **Ambiguities** — sentences with multiple readings + dollar impact + - **Contradictions** — findings from cross-checking the brief against the database +3. **Send a clarification** (if needed). The page drafts a one-line email Mariana can edit. After the agent replies "confirmed", paste their reply into the agent-reply field. +4. **Click "Confirm with agent reply"** (or "Mark as confirmed (manual override)" if you've handled it out-of-band). + +Once confirmed, the settle page at `/shows/[id]/settle` switches from the legacy calculator to the brief-backed calculator. Vs deals, walkouts, ratchets — all now work end-to-end. Every line of the worksheet shows its citation back to a brief clause. + +### Five lifecycle states + +| State | What it means | +|---|---| +| **No brief yet** | Show has no brief. CTA: "Confirm the deal before settlement." | +| **Draft** | Brief extracted, not yet confirmed. Settle page still uses legacy data. | +| **Awaiting agent reply** | Mariana sent the clarification, waiting on the agent. | +| **Confirmed** | Brief is the source of truth. Settle page uses it. | +| **Superseded** | A newer version replaced this one. Kept on file as v1, v2, etc. | + +--- + +## How the AI is used (and not used) + +The system uses AI for exactly **two** tasks: + +1. **Extracting structured terms from messy prose** when regex isn't confident enough +2. **Detecting ambiguity** — sentences that two careful readers could interpret differently + +Everything else is plain code. Cross-show patterns are SQL. The clarification email is a template (Mariana's voice, not the model's). The math is regular arithmetic. **The AI proposes; Mariana confirms; every time.** + +### The 4-tier pipeline -✓ Ready in 1.2s ``` +Tier 0 — Regex parser ~5ms $0 72% of deals +Tier 1 — Local embeddings clone ~50ms $0 +5% (near-duplicates) +Tier 2 — Free-tier LLM ~2s free +15% (the weird ones) +Tier 3 — Manual entry Mariana's time ~3% remainder +``` + +**~80% of real deals never touch the LLM.** Steady-state cost at one venue: **free**. At Greenroom's 340-venue scale: ~$10/month/venue. -### 5. Open it in your browser +### Where to get the API keys -Go to **[http://localhost:3000](http://localhost:3000)**. +| Provider | Sign up | Free tier | Cost note | +|---|---|---|---| +| **Groq** (recommended) | [console.groq.com/keys](https://console.groq.com/keys) | 30 RPM, generous daily quota | Llama 3.3 70B, ~500ms inference | +| **Gemini** (alternative) | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) | 10 RPM, 250 requests/day | Gemini 2.5 Flash, ~2s inference | -You'll land on Mariana's home view at The Crescent. **Click "Where to start" in the sidebar** for an in-product orientation. +Both require a Google account. Both are free to start. Groq is faster and has more headroom on the free tier — if you hit rate limits on one, fall back to the other. -> **Tip:** Press **⌘K** (Mac) or **Ctrl+K** (Windows/Linux) anywhere in the app to open the command palette — search across shows and artists instantly. +### Forcing a specific provider + +By default the system auto-picks: Groq → Gemini → Manual. To force one: + +```bash +# .env.local +AI_PROVIDER=groq # or 'gemini' or 'manual' +``` + +`AI_PROVIDER=manual` skips the LLM entirely — useful for cost-sensitive testing or when you want to demo without any key configured. --- -## What's running +## What the routes do -You're logged in automatically as **Mariana Reyes**, lead booker at The Crescent (650-cap, Nashville). The product has these surfaces: +| Route | What it is | Modified by this slice? | +|---|---|---| +| `/shows` | Mariana's home view, ~540 past shows | No | +| `/shows/[id]` | Show detail | Yes — added Deal Brief CTA banner | +| `/shows/[id]/brief` | **The Deal Brief page (new)** | **New** | +| `/shows/[id]/settle` | Settlement worksheet | Yes — uses brief when confirmed, falls back to legacy otherwise | +| `/artists` | Artist roster | No | +| `/reports` | Aggregate metrics | No | +| `/context` | Candidate orientation | No | + +### Walkthrough: see the slice working in 3 minutes + +1. Open any show with a vs deal (e.g. browse `/shows` and pick one with a "Vs deal" badge) +2. Click **Confirm the deal** on the show detail page +3. Paste a deal email (samples below) and click **Extract** +4. Watch the badge — `tier0_parser` means regex handled it; `groq-llama-3.3-70b` means the LLM ran +5. Look at the right rail: ambiguities (if any) and contradictions +6. Click **Mark as confirmed (manual override)** +7. Navigate to `/shows/[id]/settle` — the previously "unsupported" page now shows brief-backed math with citations + +### Sample emails to paste + +**Triggers Tier 2 (LLM) + ambiguity detection:** +``` +Subject: Cold Comfort — Saturday, May 30 — Deal terms -| Route | What it is | -|---|---| -| `/shows` | Mariana's home view. 24 months of completed shows, searchable and grouped by month. | -| `/shows/[id]` | Show detail. Deal terms, artist info, ticket sales, expenses, comps. | -| `/shows/[id]/settle` | The in-app settlement worksheet. **Try it on a few shows.** | -| `/artists` | Roster of artists who've played the venue, bucketed by frequency. | -| `/reports` | Aggregate metrics. The numbers Pri (the CEO) is watching. | -| `/context` | Orientation for you, the candidate. Linked from the sidebar. | +Hi Mariana, + +Confirming Cold Comfort for The Crescent on Saturday May 30. -### Recommended path your first time through +Deal: $3,665 guarantee vs 80% of net after expenses, whichever greater. +Expenses capped at $1,850. Hospitality cap $400. +Marketing recoup of $600 against gross. -1. Open `/context` (the sidebar's "Where to start" link). 5-minute tour. -2. Then `/shows`. Pick a Vs-deal show. Click **Settle**. See what's broken. -3. Pick a Flat-deal show. Click **Settle**. See what works. -4. Read `data/transcripts/*.md` and `data/ceo-memo.md`. -5. Look at `data/dispute-thread.md`. Then press **⌘K** and search "Coastal Spell" to find the matching show. +Thanks, +Kev Park +CAA +``` + +**Handled by Tier 0 regex (no LLM call):** +``` +Subject: The Quiet Houses — Monday, June 15 — Deal + +Mariana, + +Quiet Houses for 6/15. Standard vs deal. + +$6,337 guarantee vs 75% of net after expenses. +Expense cap $3,150, hospitality $300. ++$1,150 if attendance clears 585. + +— Daniel Hwang, WME +``` --- ## How the data is shaped -Twenty-four months of synthetic operational data, designed to feel like a real venue: +24 months of synthetic operational data, deterministic from a fixed seed: | Table | Approx rows | What it represents | |---|---|---| -| `shows` | ~540 | 24 months of shows. The app displays only past shows (more appear as days pass). | -| `artists` | 59 | Mix of recurring (A-tier, 4+ shows) and one-off (D-tier) acts | -| `agents` | 14 | Across WME, CAA, Wasserman, Paradigm, and independents | -| `deals` | ~540 | One per show. Mix is flat ~33%, vs ~33%, % of net ~24%, door ~5%, % of gross ~4% | -| `ticket_sales` | ~540 | One summary row per show, with realistic sell-through distributions | +| `shows` | ~540 | 24 months of shows. Past + 60 days forward. | +| `artists` | 59 | Mix of recurring (A-tier) and one-off (D-tier) acts | +| `agents` | 14 | Across WME, CAA, Wasserman, Paradigm, independents | +| `deals` | ~540 | One per show. Flat ~33%, Vs ~33%, % of net ~24%, door ~5%, % of gross ~4% | +| `ticket_sales` | ~540 | One summary row per show | | `comps` | ~1,900 | Comp tickets across 6 categories | | `expenses` | ~2,900 | Sound, lights, hospitality, marketing, production, backline | -| `settlements` | ~540 | All shows have settlement data. Past shows display it; future shows hold it until their date arrives. | - -A few things worth knowing: - -**The deal `notes_freetext` field is the truth.** The structured fields (`guarantee_amount`, `percentage`, `bonuses_json`, `expense_cap`) are filled inconsistently. Mariana enters deals as prose because the structured fields don't model the actual deals well. This mismatch is part of the realism. +| `settlements` | ~540 | All shows have settlement data | +| **`deal_briefs`** *(new)* | **0 at start** | **Briefs created by the new flow** | -**Vs deals come in flavors.** About a third of Vs deals are "standard." The rest mix in walkout pots, tier ratchets, and vs-gross variants. The current in-app tool can't settle most of these. +### A few things worth knowing -**Settlements have a lifecycle.** The state machine runs draft → submitted → in_review → signed (or disputed) → revised → finalized → paid → voided. +**The deal `notes_freetext` field is the truth.** Structured fields (`guarantee_amount`, `percentage`, etc.) are filled inconsistently. The brief flow reads the prose, not the structured fields — that's the design. -**Recoups are categorized.** Settlement records carry a `recoups_json` field with line items in categories like `marketing`, `hospitality_overage`, `production_overage`. Each can be `agreed`, `disputed`, or `withdrawn`. +**Vs deals come in flavors.** Standard, walkout pot, tier ratchet, vs-gross. The brief schema makes `vsFlavor` first-class so all four settle correctly. ---- - -## A note before you start - -Real venue data is messy. Fields drift over time. Prose contradicts structured values. Statuses don't always match the underlying reality. Patterns hide across many shows that look unremarkable in isolation. **What the UI shows you isn't always what the data says — and neither is necessarily what actually happened.** +**Recoups have a placement field.** New in the brief schema: `inside_cap` or `outside_cap`. This is the single field that would have prevented the $720 Coastal Spell dispute. -We'd encourage you to read the data closely, query `data/greenroom.db` directly, and bring skepticism to anything that seems clean. The candidates we hire are the ones who notice that the surface-level view is incomplete. +**12 deliberate data contradictions are planted** in `db/seed.ts:1051-1320`. The brief's contradiction-checks surface 9 of them. Try `npm run db:studio` and inspect the `deal_briefs` table after a few confirms. --- -## Where to look for context +## Context files in the repo ``` data/ -├── ceo-memo.md # Pri's Q4 memo: "winning on completeness, losing on craft" +├── ceo-memo.md # Pri's Q4 memo — the strategic frame ├── dispute-thread.md # The March 2025 marketing-recoup dispute, in full ├── greenroom.db # SQLite database — pre-seeded, ready to go └── transcripts/ @@ -145,38 +229,36 @@ data/ └── sarah-kim.md # Agent perspective (WME) ``` -These aren't decorative. They contain signals the database deliberately doesn't capture — Mariana's frustrations, the agent's pet peeves, the things that escalate disputes. Mine them. +The transcripts contain the signals that drove every design decision in this slice. Read them before judging the brief. --- -## File map +## File map (new files in **bold**) ``` app/ - context/ # Candidate orientation page - shows/ # Show list with search + month grouping - shows/[id]/ # Show detail (concert poster-style header) - shows/[id]/settle/ # The settlement worksheet (hero number layout) - artists/ # Artist roster (card grid with genre dots) - reports/ # Aggregate metrics + craft gap analysis - icon.svg # Brand favicon - opengraph-image.tsx # Social share image -components/ - brand/logo.tsx # The Greenroom frequency-mark logomark / wordmark - command-palette/ # ⌘K global search (shows + artists) - ui/ # Buttons, badges, cards - layout/ - sidebar.tsx # Fixed sidebar with active nav state - nav-links.tsx # Client component for pathname-aware nav + shows/[id]/ + brief/ ★ NEW — the Deal Brief surface + page.tsx ★ Server component (loads show + brief) + brief-client.tsx ★ Interactive — paste, extract, edit, confirm + actions.ts ★ Server actions (extract / confirm / etc.) + settle/page.tsx modified — uses brief when confirmed + page.tsx modified — added Brief CTA banner lib/ - dealMath.ts # The settlement engine (deliberately incomplete) - queries.ts # Server-side data fetching (past shows only) - format.ts # Money + date helpers + dealBrief.ts ★ Zod schemas + safe-parse helpers + tierZeroParser.ts ★ Deterministic regex parser + aiPrompts.ts ★ LLM system prompts + JSON schemas + aiProvider.ts ★ Groq + Gemini + Manual provider abstraction + embeddings.ts ★ Local embeddings (Xenova MiniLM) + contradictionChecks.ts ★ 8 per-show SQL checks + cross-show pattern + dealMath.ts modified — added brief-backed calculator + queries.ts modified — added brief queries db/ - schema.ts # All tables, commented - seed.ts # The 24-month synthetic seed - index.ts # libsql + Drizzle client -data/ # Markdown context + greenroom.db + schema.ts modified — added deal_briefs table + migrations/0001_*.sql ★ NEW migration +scripts/ + validate-flows.ts ★ End-to-end validation against three test shows +.env.example ★ Documents both API key options ``` --- @@ -184,84 +266,82 @@ data/ # Markdown context + greenroom.db ## Tech stack - **Next.js 16** (App Router) + **React 19** + **TypeScript** -- **Tailwind CSS 4** with shadcn-style component primitives -- **Drizzle ORM** + **libsql** (pure-JS SQLite — no native compile, no setup) -- **Fraunces** (variable serif, via `next/font/google`) for display headings -- **Geist Sans / Mono** (self-hosted via the `geist` package) for body + code -- **lucide-react** for icons, **date-fns** for dates +- **Drizzle ORM** + **libsql** (pure-JS SQLite — no native compile) +- **Tailwind 4** with shadcn-style component primitives +- **Zod 4** for runtime validation +- **groq-sdk** + **@google/generative-ai** for the AI providers +- **@xenova/transformers** for local embeddings (MiniLM-L6, ~30MB, runs in Node) -Everything is deliberately conventional. Use Cursor, Claude Code, or any other AI tool to navigate and modify the codebase — we expect you to. +Everything is conventional. No vector DB. No Python service. No queue. Runs entirely in the existing Next.js process. --- -## How to submit +## Useful commands -When you're done: - -1. **Push your branch.** `git add . && git commit -m "your message" && git push` -2. **Send the hiring contact:** - - The link to your forked repo - - Your 3–5 page PRD-quality memo (PDF, Notion, or Google Doc) - - A 5–10 minute Loom walking us through the prototype and memo together +```bash +npm run dev # Start dev server +npm run build # Production build +npm run lint # ESLint +npm run db:reset # Drop and reseed the database (~5s, deterministic) +npm run db:studio # Visual table browser at local.drizzle.studio + +# Validate the full flow end-to-end (creates 3 confirmed briefs) +npx tsx scripts/validate-flows.ts +``` --- -## Troubleshooting - -### "Command not found: npm" or "node is not recognized" +## Submission -Node.js isn't installed (or isn't on your PATH). Install from [nodejs.org](https://nodejs.org/), then restart your terminal. - -### "Port 3000 is already in use" +- **Repo:** this branch — `Febin-deal-modeling` +- **Memo:** [MEMO.md](MEMO.md) +- **Loom:** [link] -Something else is using port 3000. Two options: +--- -**Stop the other thing first.** -- Mac/Linux: `lsof -ti:3000 | xargs kill -9` -- Windows: `netstat -ano | findstr :3000` then `taskkill /PID /F` +## Troubleshooting -**Or run on a different port:** +### "Port 3000 is already in use" ```bash +# Mac/Linux +lsof -ti:3000 | xargs kill -9 +# Or run on another port npm run dev -- -p 3001 ``` -### "Module not found" or weird build errors +### "SQLITE_READONLY_DBMOVED" error after `npm run db:reset` +You ran `db:reset` while the dev server was still holding the old file handle. Stop the dev server with `Ctrl+C`, then `npm run dev` again. The error message in the brief page UI explains this when it happens. -Your `node_modules` is probably corrupt or incomplete. Reset it: - -```bash -rm -rf node_modules package-lock.json -npm install -``` +### Brief page shows "Manual mode" warning +No API key is set. Either set `GROQ_API_KEY` or `GEMINI_API_KEY` in `.env.local` and restart the dev server, or proceed in manual mode — the brief page still works, you just enter terms by hand. -### The database looks empty, or you broke the data while exploring +### Rate limit errors during extraction +Switch providers. If you're using Gemini (10 RPM), get a Groq key (30 RPM) and add it to `.env.local`. The system auto-prefers Groq when both are set. -Reset the database: +### Tier 1 clone doesn't fire +You need at least one **confirmed** brief in the database for the embedding search to have anything to clone from. Confirm a brief on one show first, then try a similar email on a different show — similarity must be ≥ 0.95. +### "I want to wipe all briefs but keep everything else" ```bash -npm run db:reset +sqlite3 data/greenroom.db "DELETE FROM deal_briefs;" ``` -This drops the SQLite file and regenerates 24 months of data. Takes ~5 seconds. Deterministic — same data every time. - -### Page looks ugly or buttons aren't visible - -Hard-refresh your browser to clear the CSS cache: -- Mac: **⌘ + Shift + R** -- Windows/Linux: **Ctrl + Shift + R** - -### "I want to see what's actually in the database" - +### Page errors out / "Module not found" ```bash -npm run db:studio +rm -rf node_modules package-lock.json .next +npm install +npm run dev ``` -Opens [Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview) at `local.drizzle.studio` — a visual table browser. You can also open `data/greenroom.db` with any SQLite client (e.g. [TablePlus](https://tableplus.com/), [DBeaver](https://dbeaver.io/), or `sqlite3` CLI). - -### Anything else +### Reset to a completely clean state +```bash +# Stop the dev server first (Ctrl+C) +npm run db:reset +npm run dev +``` -If you're stuck, email the hiring contact. We'd rather you ask than burn an hour fighting a setup issue. +Deterministic — same seed data every time. Date-dependent shows (today + 60 days forward) will shift, but show IDs stay stable. --- -Welcome to The Crescent. +Welcome to The Crescent. The brief is the source of truth. diff --git a/app/shows/[id]/brief/actions.ts b/app/shows/[id]/brief/actions.ts new file mode 100644 index 00000000..75cf3168 --- /dev/null +++ b/app/shows/[id]/brief/actions.ts @@ -0,0 +1,477 @@ +"use server"; + +import { createHash, randomUUID } from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { dealBriefs, deals } from "@/db/schema"; +import { getAIProvider, hasLLMProvider } from "@/lib/aiProvider"; +import { + DealBriefSchema, + briefIsEmpty, + parseAmbiguities, + parseContradictions, + type DealBrief, + type Ambiguity, + type Contradiction, +} from "@/lib/dealBrief"; +import { + parseDealEmailTier0, + mergeAmbiguities, + TIER0_CONFIDENCE_THRESHOLD, +} from "@/lib/tierZeroParser"; +import { runContradictionChecks } from "@/lib/contradictionChecks"; +import { + embed, + findSimilarConfirmedBriefs, + serializeEmbedding, + NEAR_DUPLICATE_THRESHOLD, +} from "@/lib/embeddings"; +import type { FewShotExample } from "@/lib/aiProvider"; + +const CURRENT_USER_ID = "user_mariana"; + +function hashEmail(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + + +export type ExtractResult = + | { + ok: true; + briefId: string; + extracted: DealBrief; + ambiguities: Ambiguity[]; + contradictions: Contradiction[]; + providerName: string; + cached: boolean; + } + | { + ok: false; + error: string; + }; + +/** + * The main extraction action. + * + * Runs the full 4-tier pipeline: + * Tier 0 — deterministic regex parse (always) + * Tier 1 — local embeddings + near-duplicate clone OR few-shot retrieval + * Tier 2 — Gemini LLM with structured output (only when needed) + * Tier 3 — manual entry if everything fails (handled in actions/UI below) + * + * Also runs contradiction checks (pure SQL) and persists the embedding + * for future few-shot lookups. Idempotent for the same email body + * (content-hash cache), supersedes prior versions on re-extract. + */ +export async function extractBriefForShow( + showId: string, + emailText: string, +): Promise { + if (!emailText.trim()) { + return { ok: false, error: "Email text is empty." }; + } + + // Resolve the deal for this show — every brief is anchored to a deal row. + const dealRow = await db + .select({ id: deals.id }) + .from(deals) + .where(eq(deals.showId, showId)) + .limit(1); + if (dealRow.length === 0) { + return { + ok: false, + error: "No deal exists for this show yet. Create the deal first.", + }; + } + const dealId = dealRow[0].id; + + const emailHash = hashEmail(emailText); + + // Cheap cache: if the same email text was already extracted for this + // deal, return the existing draft instead of paying for a fresh LLM call. + const existing = await db + .select() + .from(dealBriefs) + .where(eq(dealBriefs.dealId, dealId)); + const cacheHit = existing.find( + (b) => b.sourceEmailHash === emailHash && b.status !== "superseded", + ); + if (cacheHit) { + const extracted = DealBriefSchema.safeParse( + JSON.parse(cacheHit.extractedJson), + ); + if (extracted.success) { + // Use the same zod-validating helpers the page-level reader uses, + // so a cache hit and a fresh page load return identically-shaped + // data even if the stored JSON ever drifts from the schema. + return { + ok: true, + briefId: cacheHit.id, + extracted: extracted.data, + ambiguities: parseAmbiguities(cacheHit.ambiguitiesJson), + contradictions: parseContradictions(cacheHit.contradictionsJson), + providerName: "cache", + cached: true, + }; + } + // Stored extractedJson failed validation — silently fall through to + // a fresh extraction. The bad cache entry will be superseded below. + } + + // Tier 0 — deterministic parse. Always runs. If it's confident enough + // and no ambiguity triggers fired, we skip every higher tier entirely. + const tier0 = parseDealEmailTier0(emailText); + const skipLLM = + tier0.overallConfidence >= TIER0_CONFIDENCE_THRESHOLD && + tier0.triggers.length === 0; + + const provider = getAIProvider(); + let extracted: DealBrief; + let ambiguities: Ambiguity[] = []; + let providerLabel: string; + let embedding: Float32Array | null = null; + + if (skipLLM) { + // Happy path: Tier 0 owns the extraction. Free, ~5ms, no LLM cost. + extracted = tier0.brief; + ambiguities = tier0.ambiguities; + providerLabel = "tier0_parser"; + // Still embed for future few-shot retrieval. ~50ms, free, local. + try { + embedding = await embed(emailText); + } catch (err) { + console.warn("[brief] embedding failed:", err); + } + } else { + // Tier 1 — local embeddings + few-shot retrieval. Tries to either + // clone a near-duplicate prior brief (no LLM) or supply the LLM with + // the top-3 most-similar confirmed briefs as few-shot examples. + let tier1Clone: { brief: DealBrief; similarity: number } | null = null; + let fewShotExamples: FewShotExample[] = []; + try { + embedding = await embed(emailText); + const similar = await findSimilarConfirmedBriefs(embedding, dealId, 3); + const top = similar[0]; + if (top && top.similarity >= NEAR_DUPLICATE_THRESHOLD) { + // Clone path: this email is essentially the same as a prior + // confirmed brief. Reuse the extraction; no LLM call needed. + tier1Clone = { brief: top.extracted, similarity: top.similarity }; + } else { + fewShotExamples = similar.map((s) => ({ + email: s.sourceEmailText, + brief: s.extracted, + })); + } + } catch (err) { + // Embeddings are best-effort. Failure just means we skip Tier 1 + // and let Tier 2 do its job without examples. + console.warn("[brief] embedding/search failed:", err); + } + + if (tier1Clone) { + // Cloned briefs inherit the prior brief's ambiguities (the email is + // near-identical) plus any Tier 0 findings. + extracted = { + ...tier1Clone.brief, + extractedBy: "tier1_clone", + }; + ambiguities = tier0.ambiguities; + providerLabel = `tier1_clone (sim ${tier1Clone.similarity.toFixed(2)})`; + } else if (!hasLLMProvider()) { + // No LLM available (manual mode). Don't call the provider at all — + // its "extract" returns an empty shell anyway. Keep Tier 0's best + // effort + Tier 0's ambiguities; the UI surfaces "Build manually" + // as the next step if the user wants to flesh it out. + extracted = tier0.brief; + ambiguities = tier0.ambiguities; + providerLabel = "tier0_parser (manual mode)"; + } else { + // Tier 2 — real LLM call with few-shot examples. Tier 0 ambiguities + // merged in so deterministic findings (e.g. Coastal Spell recoup + // placement) survive any LLM flakiness. + try { + extracted = await provider.extractBrief(emailText, fewShotExamples); + // Ambiguity pass is independent — if it fails (zod or network), + // fall back to Tier 0's deterministic ambiguities rather than + // throwing away the successful extraction. + try { + const llmAmbiguities = await provider.detectAmbiguities(emailText); + ambiguities = mergeAmbiguities(tier0.ambiguities, llmAmbiguities); + } catch (ambErr) { + console.warn("[brief] ambiguity pass failed:", ambErr); + ambiguities = tier0.ambiguities; + } + providerLabel = + fewShotExamples.length > 0 + ? `${provider.name} + ${fewShotExamples.length}-shot` + : provider.name; + } catch (err) { + // LLM extraction blew up (network, schema drift, rate limit). + // Fall back to Tier 0's best-effort extraction rather than dead- + // ending the user. They still get the deterministic parse + the + // ambiguity triggers Tier 0 detected. They can edit or re-extract. + const message = err instanceof Error ? err.message : String(err); + console.warn( + "[brief] Tier 2 extraction failed, falling back to Tier 0:", + message, + ); + extracted = tier0.brief; + ambiguities = tier0.ambiguities; + providerLabel = "tier0_parser (LLM output rejected by schema)"; + } + } + } + + // Short-circuit on empty extractions. If neither Tier 0 nor Tier 2 + // found anything in the email, persisting the empty brief produces + // misleading downstream signals (false-positive deal_type_mismatch, + // empty clarification draft, etc.). Better to fail clearly and route + // the user to either fix the email or build manually. + if (briefIsEmpty(extracted)) { + return { + ok: false, + error: + "Couldn't pull any deal terms out of this email. It might not be a deal email, or the format is unfamiliar. Try pasting a different email or build the brief manually.", + }; + } + + // Persist as a draft. Mariana confirms or edits in the next step. + const briefId = `brief_${randomUUID()}`; + + // Contradiction checks — pure SQL against the existing DB. Runs on + // every brief save. Surfaces the planted breadcrumbs + the cross-show + // agent pattern (e.g. Daniel Hwang's marketing-recoup history). + let contradictions: Contradiction[] = []; + try { + contradictions = await runContradictionChecks(showId, extracted); + } catch (err) { + // Don't fail the whole extraction if a check throws — log and continue. + console.warn("[brief] contradiction checks failed:", err); + } + + // All writes wrapped together so a stale file-handle (e.g. after + // `npm run db:reset` while dev server was running) returns a friendly + // recovery message instead of a raw 500. + try { + // Mark prior briefs for this deal as superseded so the lifecycle + // stays clean (works whether prior was draft or confirmed). + await db + .update(dealBriefs) + .set({ status: "superseded" }) + .where(eq(dealBriefs.dealId, dealId)); + + const priorVersions = existing.length; + await db.insert(dealBriefs).values({ + id: briefId, + dealId, + version: priorVersions + 1, + status: "draft", + sourceEmailText: emailText, + sourceEmailHash: emailHash, + extractedJson: JSON.stringify(extracted), + ambiguitiesJson: + ambiguities.length > 0 ? JSON.stringify(ambiguities) : null, + contradictionsJson: + contradictions.length > 0 ? JSON.stringify(contradictions) : null, + embeddingBlob: embedding ? serializeEmbedding(embedding) : null, + createdAt: new Date(), + createdByUserId: CURRENT_USER_ID, + }); + } catch (err) { + return { ok: false, error: describeDbError(err) }; + } + + revalidatePath(`/shows/${showId}/brief`); + return { + ok: true, + briefId, + extracted, + ambiguities, + contradictions, + providerName: providerLabel, + cached: false, + }; +} + +/** + * Map low-level libsql/SQLite errors to user-actionable messages. + * The big one is SQLITE_READONLY_DBMOVED — happens when the DB file is + * recreated (e.g. by `npm run db:reset`) while a dev-server process holds + * the old file handle. Restart the server and the new client opens fresh. + */ +function describeDbError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + const code = + err && typeof err === "object" && "code" in err + ? String((err as { code: unknown }).code) + : ""; + const cause = + err && typeof err === "object" && "cause" in err + ? (err as { cause: unknown }).cause + : null; + const causeCode = + cause && typeof cause === "object" && "code" in cause + ? String((cause as { code: unknown }).code) + : ""; + + if ( + code === "SQLITE_READONLY" || + code === "SQLITE_READONLY_DBMOVED" || + causeCode === "SQLITE_READONLY" || + causeCode === "SQLITE_READONLY_DBMOVED" || + /readonly/i.test(message) + ) { + return "Database file changed underneath the running server (this usually means `npm run db:reset` was run while `npm run dev` was still going). Restart the dev server: stop it with Ctrl+C, then run `npm run dev` again."; + } + return `Database write failed: ${message}`; +} + +/** + * Save Mariana's edits to a draft brief. No confirmation yet. + */ +export async function saveBriefEdits( + briefId: string, + showId: string, + edited: DealBrief, +): Promise<{ ok: true } | { ok: false; error: string }> { + const parsed = DealBriefSchema.safeParse(edited); + if (!parsed.success) { + return { ok: false, error: "Invalid brief shape." }; + } + // Mark as manual once she's edited — tracks provenance honestly. + const next: DealBrief = { ...parsed.data, extractedBy: "manual" }; + try { + await db + .update(dealBriefs) + .set({ extractedJson: JSON.stringify(next) }) + .where(eq(dealBriefs.id, briefId)); + } catch (err) { + return { ok: false, error: describeDbError(err) }; + } + revalidatePath(`/shows/${showId}/brief`); + return { ok: true }; +} + +/** + * Mark a draft as awaiting the agent's confirmation reply. Called when + * Mariana sends the clarification email (or copy/pastes its body into + * her own client). The brief stays editable on this side until the + * agent replies; this status mostly signals "ball is in the agent's court." + */ +export async function markAwaitingConfirmation( + briefId: string, + showId: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + try { + await db + .update(dealBriefs) + .set({ status: "awaiting_confirmation" }) + .where(eq(dealBriefs.id, briefId)); + } catch (err) { + return { ok: false, error: describeDbError(err) }; + } + revalidatePath(`/shows/${showId}/brief`); + return { ok: true }; +} + +/** + * Tier 3 escape hatch: create an empty brief that Mariana fills in by + * hand. Used when the LLM is down, the email is too unusual to extract, + * or there's simply no email to paste (deal was negotiated by phone). + */ +export async function createManualBrief( + showId: string, + sourceText: string, +): Promise< + | { ok: true; briefId: string; extracted: DealBrief } + | { ok: false; error: string } +> { + const dealRow = await db + .select({ id: deals.id }) + .from(deals) + .where(eq(deals.showId, showId)) + .limit(1); + if (dealRow.length === 0) { + return { ok: false, error: "No deal exists for this show yet." }; + } + const dealId = dealRow[0].id; + + // Empty shell — Mariana fills it from the UI. + const emptyBrief: DealBrief = { + dealType: "flat", + guaranteeAmount: null, + percentage: null, + percentageBasis: null, + expenseCap: null, + hospitalityCap: null, + bonuses: [], + recoups: [], + confidence: {}, + extractedBy: "manual", + }; + + const briefId = `brief_${randomUUID()}`; + try { + // Supersede any prior briefs (draft or confirmed). + await db + .update(dealBriefs) + .set({ status: "superseded" }) + .where(eq(dealBriefs.dealId, dealId)); + + const existing = await db + .select({ version: dealBriefs.version }) + .from(dealBriefs) + .where(eq(dealBriefs.dealId, dealId)); + + await db.insert(dealBriefs).values({ + id: briefId, + dealId, + version: existing.length + 1, + status: "draft", + sourceEmailText: sourceText || "(no source email — entered manually)", + sourceEmailHash: hashEmail(sourceText || "manual"), + extractedJson: JSON.stringify(emptyBrief), + ambiguitiesJson: null, + contradictionsJson: null, + embeddingBlob: null, + createdAt: new Date(), + createdByUserId: CURRENT_USER_ID, + }); + } catch (err) { + return { ok: false, error: describeDbError(err) }; + } + + revalidatePath(`/shows/${showId}/brief`); + return { ok: true, briefId, extracted: emptyBrief }; +} + +/** + * Move a draft to confirmed. Two paths: + * - email_reply: Mariana pastes the agent's "confirmed" reply. + * - manual_override: Mariana takes responsibility without an email. + */ +export async function confirmBrief( + briefId: string, + showId: string, + via: "email_reply" | "manual_override", + agentReplyText?: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + try { + await db + .update(dealBriefs) + .set({ + status: "confirmed", + confirmedAt: new Date(), + confirmedVia: via, + agentReplyText: agentReplyText ?? null, + }) + .where(eq(dealBriefs.id, briefId)); + } catch (err) { + return { ok: false, error: describeDbError(err) }; + } + revalidatePath(`/shows/${showId}/brief`); + revalidatePath(`/shows/${showId}/settle`); + revalidatePath(`/shows/${showId}`); + return { ok: true }; +} diff --git a/app/shows/[id]/brief/brief-client.tsx b/app/shows/[id]/brief/brief-client.tsx new file mode 100644 index 00000000..32e7bf6d --- /dev/null +++ b/app/shows/[id]/brief/brief-client.tsx @@ -0,0 +1,804 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + AlertTriangle, + Sparkles, + CheckCircle2, + Loader2, + Pencil, + Send, + FileText, +} from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, + Field, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { PlainBadge, DealTypeBadge } from "@/components/ui/badge"; +import { formatMoney } from "@/lib/format"; +import type { + DealBrief, + Ambiguity, + Contradiction, +} from "@/lib/dealBrief"; +import { + extractBriefForShow, + confirmBrief, + markAwaitingConfirmation, + createManualBrief, +} from "./actions"; + +type BriefSnapshot = { + id: string; + version: number; + status: "draft" | "awaiting_confirmation" | "confirmed" | "superseded"; + sourceEmailText: string; + confirmedAt: string | null; + confirmedVia: "email_reply" | "manual_override" | null; + agentReplyText: string | null; + createdAt: string; +}; + +const STATUS_LABEL: Record = { + draft: "draft", + awaiting_confirmation: "awaiting agent reply", + confirmed: "confirmed", + superseded: "superseded", +}; + +type LegacyDeal = { + dealType: string; + guaranteeAmount: number | null; + percentage: number | null; + expenseCap: number | null; + hospitalityCap: number | null; + dealNotesFreetext: string | null; +}; + +interface Props { + showId: string; + legacyDeal: LegacyDeal; + brief: BriefSnapshot | null; + extracted: DealBrief | null; + ambiguities: Ambiguity[]; + contradictions: Contradiction[]; + hasLLMProvider: boolean; +} + +export function BriefClient({ + showId, + legacyDeal, + brief, + extracted: initialExtracted, + ambiguities: initialAmbiguities, + contradictions: initialContradictions, + hasLLMProvider, +}: Props) { + const [pasted, setPasted] = useState(brief?.sourceEmailText ?? ""); + const [error, setError] = useState(null); + const [extracted, setExtracted] = useState( + initialExtracted, + ); + const [ambiguities, setAmbiguities] = useState( + initialAmbiguities, + ); + const [contradictions, setContradictions] = useState( + initialContradictions, + ); + const [briefId, setBriefId] = useState(brief?.id ?? null); + const [status, setStatus] = useState( + brief?.status ?? "draft", + ); + const [providerNote, setProviderNote] = useState(null); + const [isExtracting, startExtracting] = useTransition(); + const [isConfirming, startConfirming] = useTransition(); + const [agentReply, setAgentReply] = useState(""); + + const isConfirmed = status === "confirmed"; + + function handleExtract() { + setError(null); + startExtracting(async () => { + const res = await extractBriefForShow(showId, pasted); + if (!res.ok) { + setError(res.error); + return; + } + setExtracted(res.extracted); + setAmbiguities(res.ambiguities); + setContradictions(res.contradictions); + setBriefId(res.briefId); + setStatus("draft"); + setProviderNote( + res.cached + ? "Reused prior extraction (same email content)." + : `Extracted via ${res.providerName}.`, + ); + }); + } + + function handleConfirm(via: "email_reply" | "manual_override") { + if (!briefId) return; + setError(null); + startConfirming(async () => { + const res = await confirmBrief( + briefId, + showId, + via, + via === "email_reply" ? agentReply : undefined, + ); + if (!res.ok) { + setError(res.error); + return; + } + setStatus("confirmed"); + }); + } + + function handleMarkAwaiting() { + if (!briefId) return; + setError(null); + startConfirming(async () => { + const res = await markAwaitingConfirmation(briefId, showId); + if (!res.ok) { + setError(res.error); + return; + } + setStatus("awaiting_confirmation"); + }); + } + + function handleBuildManually() { + setError(null); + startExtracting(async () => { + const res = await createManualBrief(showId, pasted); + if (!res.ok) { + setError(res.error); + return; + } + setExtracted(res.extracted); + setAmbiguities([]); + setContradictions([]); + setBriefId(res.briefId); + setStatus("draft"); + setProviderNote("Started a manual brief — fill in the fields below."); + }); + } + + /** + * Reset the form to start a new version of the brief. Existing confirmed + * brief stays in the DB (as a prior version); the next extract call + * supersedes it server-side via the action's standard supersede logic. + */ + function handleStartNewVersion() { + setPasted(""); + setExtracted(null); + setAmbiguities([]); + setContradictions([]); + setBriefId(null); + setStatus("draft"); + setError(null); + setProviderNote(null); + setAgentReply(""); + } + + const clarificationDraft = extracted + ? buildClarificationDraft(extracted) + : null; + + return ( +
+ {/* LEFT COLUMN — paste + extracted view */} +
+ + +
+ The deal email + + Paste the agent's email or the negotiated terms exactly + as written. The extractor reads the prose, not the structured + fields — same as Mariana would. + +
+ {hasLLMProvider ? ( + AI ready + ) : ( + Manual + )} +
+ +