diff --git a/.gitignore b/.gitignore index 5ef6a520..5d912e24 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # dependencies /node_modules +/.idea +/.claude /.pnp .pnp.* .yarn/* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..b58b603f --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/greenroom-starter.iml b/.idea/greenroom-starter.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/.idea/greenroom-starter.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..639900d1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..b10df14c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/api/clarifications/[id]/route.ts b/app/api/clarifications/[id]/route.ts new file mode 100644 index 00000000..0b751e7e --- /dev/null +++ b/app/api/clarifications/[id]/route.ts @@ -0,0 +1,64 @@ +/** + * PATCH /api/clarifications/[id] + * + * Updates a clarification's status. The booker calls this when they dismiss + * or resolve a flag raised by the AI analysis. + * + * Body: { status: "dismissed" | "resolved", dismissal_reason?: string } + * Returns: the updated clarification row. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { dealClarifications } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + let body: { status?: string; dismissal_reason?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { status, dismissal_reason } = body; + + if (status !== "dismissed" && status !== "resolved") { + return NextResponse.json( + { error: 'status must be "dismissed" or "resolved"' }, + { status: 400 }, + ); + } + + // Verify the clarification exists + const [existing] = await db + .select() + .from(dealClarifications) + .where(eq(dealClarifications.id, id)); + + if (!existing) { + return NextResponse.json( + { error: "Clarification not found" }, + { status: 404 }, + ); + } + + const now = Date.now(); + + const [updated] = await db + .update(dealClarifications) + .set({ + status, + dismissalReason: dismissal_reason ?? null, + resolvedAt: now, + }) + .where(eq(dealClarifications.id, id)) + .returning(); + + return NextResponse.json(updated); +} diff --git a/app/api/deals/[id]/analyze/route.ts b/app/api/deals/[id]/analyze/route.ts new file mode 100644 index 00000000..141e15f9 --- /dev/null +++ b/app/api/deals/[id]/analyze/route.ts @@ -0,0 +1,247 @@ +/** + * POST /api/deals/[id]/analyze + * + * Reads the deal's prose notes + structured fields, calls the Anthropic API + * to extract terms and surface ambiguities/conflicts, then persists the result. + * + * [id] is the deal_id (e.g. "deal_show_0007"), not the show_id. + * + * Transaction semantics: + * - Deletes all "open" clarifications for this deal (preserves dismissed/resolved) + * - Inserts fresh clarifications from the model response + * - Updates deal.last_analyzed_at and deal.extraction_confidence + */ + +import { NextRequest, NextResponse } from "next/server"; +import Anthropic from "@anthropic-ai/sdk"; +import { db } from "@/db"; +import { deals, dealClarifications } from "@/db/schema"; +import { eq, and, inArray } from "drizzle-orm"; +import { getDealWithClarifications } from "@/lib/queries"; + +const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, +}); + +const SYSTEM_PROMPT = `You are a deal-extraction assistant for Greenroom, software for independent music venues. Your job: read a deal description (prose notes) and surface anything that could cause a settlement dispute. The booker will review your output and accept or dismiss each flag — a flag is a query, not a verdict. + +YOUR THREE JOBS: +1. EXTRACT structured terms from the prose. +2. COMPARE your extraction to the existing structured fields provided. Where they materially disagree, raise a flag with flag_type="conflict". +3. FLAG AMBIGUITIES — places where prose admits multiple readings, or references external info (emails, deal memos, phone calls) not provided. Use flag_type="ambiguity" or "missing_reference". + +DEAL TYPES: +- "flat": fixed guarantee, no upside +- "vs": guarantee versus % of net, whichever greater +- "percentage_of_net": % of net, no guarantee +- "percentage_of_gross": % of gross +- "door_deal": split of door +- "walkout": artist takes incremental gross above a breakeven (often combined with vs) +- "complex": multi-tier or unusual + +CONVENTIONS — apply these BEFORE flagging: +- "85/15 split" or "85/15 on net": first number is artist share by convention. Don't flag unless prose explicitly contradicts this. +- Hospitality cap is a sub-cap within the expense cap unless prose says otherwise. Don't flag the placement. +- "vs" deals: artist takes whichever is HIGHER. Don't flag the direction. +- The \`percentage\` field stores values as decimal fractions (e.g., 0.85 = 85%, 0.9 = 90%). This is the Greenroom storage convention. Treat a decimal in the structured field as equivalent to its percentage in the prose — do not flag this as ambiguous. + +SEVERITY RUBRIC: +- HIGH: would change settlement by >$200, OR there is an explicit conflict between prose and a structured field, OR the term references external info that materially affects payout. +- MEDIUM: real but lower-dollar ambiguity, or a vague term that needs definition (e.g., "breakeven" without a basis). +- LOW: minor wording, or a term with strong convention but worth confirming. + +WHY THIS MATTERS: +Last March a deal said "expenses capped at $2,500, marketing recoup of $900 against gross." Venue read the recoup as separate from cap, agent read it as inside. $720 dispute. Patterns to catch: recoups with unclear placement, bonus thresholds with unclear metric (gross? tickets? net?), splits without explicit artist share, references to external docs, renegotiations where the structured fields might be stale. + +OUTPUT — respond with valid JSON only, no preamble: + +{ + "extracted": { + "deal_type": "flat" | "vs" | "percentage_of_net" | "percentage_of_gross" | "door_deal" | "walkout" | "complex", + "guarantee_amount": number | null, + "percentage": number | null, + "percentage_basis": "gross" | "net_after_expenses" | null, + "expense_cap": number | null, + "hospitality_cap": number | null, + "bonuses": [{"description": string, "threshold": number | null, "threshold_metric": "gross"|"tickets"|"net"|null, "amount": number | null}], + "walkout": {"enabled": boolean, "threshold": number | null, "threshold_basis": string | null, "artist_share": number | null} | null + }, + "flags": [ + { + "flag_type": "conflict" | "ambiguity" | "missing_reference", + "severity": "high" | "medium" | "low", + "field": string, + "issue": string, + "extracted_value": string | null, + "structured_value": string | null, + "interpretation_a": string | null, + "interpretation_b": string | null, + "financial_impact": string, + "recommended_clarification": string + } + ], + "external_references": [string], + "extraction_confidence": number +}`; + +/** Strip markdown code fences if the model wraps its JSON in them. */ +function extractJson(text: string): string { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenced) return fenced[1].trim(); + return text.trim(); +} + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: dealId } = await params; + + // 1. Fetch the deal + const [deal] = await db + .select() + .from(deals) + .where(eq(deals.id, dealId)); + + if (!deal) { + return NextResponse.json({ error: "Deal not found" }, { status: 404 }); + } + + if (!deal.dealNotesFreetext) { + return NextResponse.json( + { error: "Deal has no prose notes to analyze" }, + { status: 422 }, + ); + } + + // 2. Build the structured fields snapshot (everything except the prose) + const structuredFields = { + deal_type: deal.dealType, + guarantee_amount: deal.guaranteeAmount, + percentage: deal.percentage, + percentage_basis: deal.percentageBasis, + expense_cap: deal.expenseCap, + hospitality_cap: deal.hospitalityCap, + bonuses_json: deal.bonusesJson ? JSON.parse(deal.bonusesJson) : null, + }; + + const userMessage = `Existing structured fields in the database: +${JSON.stringify(structuredFields, null, 2)} + +Deal prose notes: +${deal.dealNotesFreetext}`; + + // 3. Call Anthropic + let rawContent: string; + try { + const response = await anthropic.messages.create({ + model: "claude-sonnet-4-6", + max_tokens: 4096, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: userMessage }], + }); + + const block = response.content[0]; + if (block.type !== "text") { + throw new Error("Unexpected response type from model"); + } + rawContent = block.text; + } catch (err) { + console.error("[analyze] Anthropic call failed:", err); + return NextResponse.json( + { error: "AI analysis failed", detail: String(err) }, + { status: 502 }, + ); + } + + // 4. Parse the JSON response defensively + let parsed: { + extracted: Record; + flags: Array<{ + flag_type: "conflict" | "ambiguity" | "missing_reference"; + severity: "high" | "medium" | "low"; + field?: string; + issue: string; + extracted_value?: string | null; + structured_value?: string | null; + interpretation_a?: string | null; + interpretation_b?: string | null; + financial_impact?: string; + recommended_clarification?: string; + }>; + external_references: string[]; + extraction_confidence: number; + }; + + try { + parsed = JSON.parse(extractJson(rawContent)); + } catch { + console.error("[analyze] JSON parse failed. Raw:", rawContent.slice(0, 500)); + return NextResponse.json( + { error: "Model returned malformed JSON", raw: rawContent.slice(0, 500) }, + { status: 502 }, + ); + } + + // 5. Persist in a transaction + const now = Date.now(); + + await db.transaction(async (tx) => { + // Delete only the open clarifications — preserve dismissed/resolved for audit + const openRows = await tx + .select({ id: dealClarifications.id }) + .from(dealClarifications) + .where( + and( + eq(dealClarifications.dealId, dealId), + eq(dealClarifications.status, "open"), + ), + ); + + if (openRows.length > 0) { + await tx.delete(dealClarifications).where( + inArray( + dealClarifications.id, + openRows.map((r) => r.id), + ), + ); + } + + // Insert new clarifications + if (parsed.flags.length > 0) { + const rows = parsed.flags.map((flag, i) => ({ + id: `clr_${dealId}_${now}_${i}`, + dealId, + flagType: flag.flag_type, + severity: flag.severity, + field: flag.field ?? null, + issue: flag.issue, + extractedValue: flag.extracted_value ?? null, + structuredValue: flag.structured_value ?? null, + interpretationA: flag.interpretation_a ?? null, + interpretationB: flag.interpretation_b ?? null, + financialImpact: flag.financial_impact ?? null, + recommendedClarification: flag.recommended_clarification ?? null, + status: "open" as const, + dismissalReason: null, + createdAt: now, + resolvedAt: null, + })); + + await tx.insert(dealClarifications).values(rows); + } + + // Update deal metadata + await tx + .update(deals) + .set({ + lastAnalyzedAt: now, + extractionConfidence: parsed.extraction_confidence ?? null, + }) + .where(eq(deals.id, dealId)); + }); + + // 6. Return the fresh deal + clarifications + const result = await getDealWithClarifications(deal.showId); + return NextResponse.json(result); +} diff --git a/app/shows/[id]/page.tsx b/app/shows/[id]/page.tsx index c67e8b91..2380014f 100644 --- a/app/shows/[id]/page.tsx +++ b/app/shows/[id]/page.tsx @@ -7,7 +7,7 @@ import { Clock, TrendingUp, } from "lucide-react"; -import { getShowById } from "@/lib/queries"; +import { getShowById, getDealWithClarifications } from "@/lib/queries"; import { Card, CardContent, @@ -26,6 +26,7 @@ import { relativeShowDate, } from "@/lib/format"; import type { Bonus } from "@/db/schema"; +import { DealConfidenceCard } from "@/components/deal-confidence-card"; const COMP_LABELS: Record = { artist_gl: "Artist guest list", @@ -43,7 +44,10 @@ export default async function ShowDetailPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const data = await getShowById(id); + const [data, dealConfidence] = await Promise.all([ + getShowById(id), + getDealWithClarifications(id), + ]); if (!data) notFound(); const { @@ -296,6 +300,18 @@ export default async function ShowDetailPage({ + {/* Deal Confidence — AI analysis of prose vs structured fields */} + {dealConfidence && ( + + )} + {/* Box office */} diff --git a/components/deal-confidence-card.tsx b/components/deal-confidence-card.tsx new file mode 100644 index 00000000..a5da51e4 --- /dev/null +++ b/components/deal-confidence-card.tsx @@ -0,0 +1,639 @@ +"use client"; + +import { useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertTriangle, + AlertCircle, + Info, + Check, + X, + Sparkles, + ChevronDown, + ChevronUp, + RotateCw, + Copy, + CheckCheck, +} from "lucide-react"; + +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Tooltip } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { Deal, DealClarification } from "@/db/schema"; + +type Props = { + dealId: string; + initialDeal: Deal; + initialClarifications: DealClarification[]; + artistName?: string; + agentName?: string; + agentEmail?: string; +}; + +const severityConfig = { + high: { + Icon: AlertTriangle, + iconClass: "text-rose-500", + pill: "bg-rose-50 text-rose-800 ring-rose-200/80", + border: "border-l-rose-400", + bg: "bg-rose-50/30", + }, + medium: { + Icon: AlertCircle, + iconClass: "text-amber-500", + pill: "bg-amber-50 text-amber-800 ring-amber-200/80", + border: "border-l-amber-400", + bg: "bg-amber-50/20", + }, + low: { + Icon: Info, + iconClass: "text-stone-400", + pill: "bg-stone-50 text-stone-700 ring-stone-200/80", + border: "border-l-stone-300", + bg: "bg-stone-50/20", + }, +} as const; + +const flagTypeLabel: Record = { + conflict: "Conflict", + ambiguity: "Ambiguity", + missing_reference: "Missing ref", +}; + +function confidenceStyle(c: number) { + if (c >= 0.8) return "bg-brand-50 text-brand-800 ring-brand-200/80"; + if (c >= 0.6) return "bg-amber-50 text-amber-800 ring-amber-200/80"; + return "bg-rose-50 text-rose-800 ring-rose-200/80"; +} + +export function DealConfidenceCard({ + dealId, + initialDeal, + initialClarifications, + artistName, + agentName, + agentEmail, +}: Props) { + const [deal, setDeal] = useState(initialDeal); + const [clarifications, setClarifications] = useState(initialClarifications); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [actionState, setActionState] = useState<{ + id: string; + type: "dismiss" | "resolve"; + } | null>(null); + const [reasonText, setReasonText] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const [copiedId, setCopiedId] = useState(null); + + const openFlags = clarifications.filter((c) => c.status === "open"); + const closedFlags = clarifications.filter((c) => c.status !== "open"); + const highCount = openFlags.filter((c) => c.severity === "high").length; + const mediumCount = openFlags.filter((c) => c.severity === "medium").length; + const lowCount = openFlags.filter((c) => c.severity === "low").length; + + const isNeverAnalyzed = !deal.lastAnalyzedAt; + const cardAccent = !isNeverAnalyzed && highCount > 0 ? "rose" : undefined; + + // ── Handlers ────────────────────────────────────────────────────────────── + + async function runAnalysis() { + setLoading(true); + try { + const res = await fetch(`/api/deals/${dealId}/analyze`, { + method: "POST", + }); + if (!res.ok) throw new Error("Analysis failed"); + const data = await res.json(); + setDeal(data.deal); + setClarifications(data.clarifications); + } catch (err) { + console.error("[DealConfidenceCard] analyze failed:", err); + } finally { + setLoading(false); + } + } + + function toggleAction(id: string, type: "dismiss" | "resolve") { + if (actionState?.id === id && actionState.type === type) { + setActionState(null); + setReasonText(""); + } else { + setActionState({ id, type }); + setReasonText(""); + } + } + + async function confirmAction(flagId: string) { + if (!actionState) return; + const status = + actionState.type === "dismiss" ? "dismissed" : "resolved"; + + const before = clarifications; + setClarifications((prev) => + prev.map((c) => + c.id === flagId + ? { + ...c, + status, + dismissalReason: reasonText || null, + resolvedAt: Date.now(), + } + : c, + ), + ); + setActionState(null); + setReasonText(""); + + try { + const res = await fetch(`/api/clarifications/${flagId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status, + dismissal_reason: reasonText || undefined, + }), + }); + if (!res.ok) throw new Error("Failed"); + } catch { + setClarifications(before); + } + } + + async function copyEmailDraft(flag: DealClarification) { + const subjectField = flag.field + ? `${flag.field} clarification` + : "deal terms clarification"; + const lines = [ + `Subject: Quick question before settlement — ${artistName ?? "show"} · ${subjectField}`, + "", + `Hi ${agentName ?? "[Agent name]"},`, + "", + "I wanted to flag a quick question before we run settlement:", + "", + flag.issue, + "", + flag.recommendedClarification ?? + "Could you confirm the correct terms so we can settle accurately?", + "", + agentEmail ? `(Sending to: ${agentEmail})` : "", + "", + "Thanks,", + "Mariana", + "The Crescent", + ] + .filter((l) => l !== undefined) + .join("\n"); + + try { + await navigator.clipboard.writeText(lines.trim()); + setCopiedId(flag.id); + setTimeout(() => setCopiedId(null), 2000); + } catch { + // Clipboard unavailable — silently skip + } + } + + // ── Flag card ────────────────────────────────────────────────────────────── + + function renderFlagCard(flag: DealClarification, isClosed = false) { + const sev = severityConfig[flag.severity]; + const { Icon } = sev; + const isExpanded = expandedId === flag.id; + const isActioning = actionState?.id === flag.id; + const isCopied = copiedId === flag.id; + const isConflict = flag.flagType === "conflict"; + + return ( +
+ {/* Body */} +
+ {/* Header row: severity pill · flag type · field */} +
+ {!isClosed && ( + + )} + + {flag.severity.toUpperCase()} + + + {flagTypeLabel[flag.flagType]} + + {flag.field && ( + <> + · + + {flag.field} + + + )} + {isClosed && ( + + {flag.status} + {flag.dismissalReason ? ` · ${flag.dismissalReason}` : ""} + + )} +
+ + {/* Issue */} +

+ {flag.issue} +

+ + {/* Conflict comparison strip */} + {isConflict && !isClosed && (flag.structuredValue || flag.extractedValue) && ( +
+
+
+ Structured field +
+
+ {flag.structuredValue ?? "—"} +
+
+
+
+ Prose says +
+
+ {flag.extractedValue ?? "—"} +
+
+
+ )} + + {/* Expanded details */} + {isExpanded && !isClosed && ( +
+ {(flag.interpretationA || flag.interpretationB) && ( +
+
+ Two readings +
+
+ {flag.interpretationA && ( +
+
+ Reading A +
+

+ {flag.interpretationA} +

+
+ )} + {flag.interpretationB && ( +
+
+ Reading B +
+

+ {flag.interpretationB} +

+
+ )} +
+
+ )} + + {flag.financialImpact && ( +
+
+ Financial impact +
+
+ {flag.financialImpact} +
+
+ )} + + {flag.recommendedClarification && ( +
+
+ Recommended action +
+
+ {flag.recommendedClarification} +
+
+ )} + +
+ +
+
+ )} + + {/* Inline action form */} + {isActioning && !isClosed && ( +
+
+ {actionState!.type === "dismiss" + ? "Why dismiss? (optional — helps the system learn your conventions)" + : "How was this resolved? e.g. 'Confirmed with agent via email'"} +
+