diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5bb03d7 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# DealLens environment variables +# Copy to .env and fill in real values. Never commit .env to git. + +# --------------------------------------------------------------------------- +# Core +# --------------------------------------------------------------------------- +NODE_ENV=development + +# Set to 'true' to allow mock classes in non-production environments (staging). +# In NODE_ENV=production without USE_MOCKS=true, all mock classes will throw. +USE_MOCKS=true + +# Override the local mock storage root (default: /tmp/deallenz-mock-storage) +# MOCK_STORAGE_ROOT=/tmp/deallenz-mock-storage + +# --------------------------------------------------------------------------- +# LLM providers (PR#5 — leave blank to use MockModelRouter) +# --------------------------------------------------------------------------- +# ANTHROPIC_API_KEY=sk-ant-... +# OPENAI_API_KEY=sk-... + +# --------------------------------------------------------------------------- +# Supabase (PR#2 — leave blank to use MockStorageClient) +# --------------------------------------------------------------------------- +# SUPABASE_URL=https://your-project.supabase.co +# SUPABASE_ANON_KEY=eyJhbGci... +# SUPABASE_SERVICE_ROLE_KEY=eyJhbGci... # server-side only, never expose to browser + +# --------------------------------------------------------------------------- +# Data connectors (PR#5) +# --------------------------------------------------------------------------- +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# DROPBOX_APP_KEY= +# DROPBOX_APP_SECRET= +# NOTION_INTEGRATION_TOKEN= +# FIRECRAWL_API_KEY= +# TAVILY_API_KEY= diff --git a/api/ingest-link.ts b/api/ingest-link.ts new file mode 100644 index 0000000..b4d5b3f --- /dev/null +++ b/api/ingest-link.ts @@ -0,0 +1,213 @@ +/** + * POST /api/ingest-link — Path B: data-room link ingest + * + * Accepts { url, deal_id }, validates the request, classifies the source + * type, and enqueues an ingest job. + * + * Supported sources (this stub): + * • Google Drive folder drive.google.com/drive/folders/... + * • Google Drive file drive.google.com/file/d/... + * • Dropbox folder dropbox.com/sh/... or dropbox.com/scl/fo/... + * • Notion page notion.so/... + * • Generic HTTPS page any other https:// URL (single-page fetch) + * + * Real connectors (Google Drive API, Dropbox SDK, Notion API, Firecrawl) + * land in PR#5. This stub validates, classifies, and hands off to + * MockIngestQueue. + * + * Framework-agnostic: wrap with /api/adapters/vercel.ts or + * /api/adapters/cloudflare-worker.ts for deployment. + */ + +import { MockIngestQueue } from './ingest-queue.mock'; +import type { IngestJob } from './ingest-queue.mock'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface IngestLinkRequest { + url: string; + deal_id: string; +} + +export type SourceType = + | 'google_drive_folder' + | 'google_drive_file' + | 'dropbox_folder' + | 'notion_page' + | 'generic_webpage'; + +export interface IngestLinkResponse { + ok: boolean; + job_id?: string; + source_type?: SourceType; + error?: string; + detail?: string; +} + +// --------------------------------------------------------------------------- +// Source classification +// --------------------------------------------------------------------------- + +const SOURCE_PATTERNS: Array<{ pattern: RegExp; type: SourceType }> = [ + { + pattern: /drive\.google\.com\/drive\/folders\//i, + type: 'google_drive_folder', + }, + { + pattern: /drive\.google\.com\/file\/d\//i, + type: 'google_drive_file', + }, + { + // Covers both legacy /sh/ and newer /scl/fo/ sharing URLs + pattern: /dropbox\.com\/(sh|scl\/fo)\//i, + type: 'dropbox_folder', + }, + { + pattern: /notion\.so\//i, + type: 'notion_page', + }, +]; + +export function classifySource(url: string): SourceType { + for (const { pattern, type } of SOURCE_PATTERNS) { + if (pattern.test(url)) return type; + } + return 'generic_webpage'; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + error?: string; + detail?: string; +} + +export function validateIngestRequest(body: unknown): ValidationResult { + if (!body || typeof body !== 'object') { + return { + valid: false, + error: 'INVALID_BODY', + detail: 'Request body must be a JSON object.', + }; + } + + const { url, deal_id } = body as Record; + + if (!url || typeof url !== 'string') { + return { + valid: false, + error: 'MISSING_URL', + detail: '"url" is required and must be a string.', + }; + } + + if (!deal_id || typeof deal_id !== 'string') { + return { + valid: false, + error: 'MISSING_DEAL_ID', + detail: '"deal_id" is required and must be a string.', + }; + } + + // URL must parse + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { + valid: false, + error: 'INVALID_URL', + detail: `"${url}" is not a valid absolute URL.`, + }; + } + + // Must be HTTPS + if (parsed.protocol !== 'https:') { + return { + valid: false, + error: 'INSECURE_URL', + detail: 'Only HTTPS URLs are accepted. Data-room links must use a secure connection.', + }; + } + + // deal_id format: 3–64 lowercase alphanumeric + hyphens + if ( + deal_id.length < 3 || + deal_id.length > 64 || + !/^[a-z0-9-]+$/.test(deal_id) + ) { + return { + valid: false, + error: 'INVALID_DEAL_ID', + detail: + '"deal_id" must be 3–64 lowercase letters, numbers, or hyphens ' + + '(e.g. "acme-finance" or "pqc-bank").', + }; + } + + return { valid: true }; +} + +// --------------------------------------------------------------------------- +// Singleton queue (replaced by a real queue client in PR#5) +// --------------------------------------------------------------------------- + +/** + * Module-level singleton so the queue survives across handler invocations + * within the same process (useful for local dev + tests). + * In production this will be replaced by a real queue client. + */ +export const queue = new MockIngestQueue(); + +// --------------------------------------------------------------------------- +// Handler (framework-agnostic) +// --------------------------------------------------------------------------- + +/** + * Core handler logic. Call this from your framework adapter: + * + * // Vercel + * export default async (req, res) => { + * const result = await handleIngestLink(req.body); + * res.status(result.ok ? 200 : 400).json(result); + * }; + */ +export async function handleIngestLink( + body: unknown +): Promise { + // 1. Validate + const validation = validateIngestRequest(body); + if (!validation.valid) { + return { + ok: false, + error: validation.error, + detail: validation.detail, + }; + } + + const { url, deal_id } = body as IngestLinkRequest; + + // 2. Classify source + const source_type = classifySource(url); + + // 3. Build job + const job: IngestJob = { + id: `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + deal_id, + url, + source_type, + status: 'queued', + queued_at: new Date().toISOString(), + attempts: 0, + }; + + // 4. Enqueue (real queue in PR#5) + await queue.enqueue(job); + + return { ok: true, job_id: job.id, source_type }; +} diff --git a/api/ingest-queue.mock.ts b/api/ingest-queue.mock.ts new file mode 100644 index 0000000..fe1458e --- /dev/null +++ b/api/ingest-queue.mock.ts @@ -0,0 +1,160 @@ +/** + * MockIngestQueue + * In-memory queue for data-room ingest jobs. + * Jobs are processed with realistic artificial delay so the UI can show + * live status transitions: queued → processing → done | failed. + * + * ONLY usable when USE_MOCKS=true or NODE_ENV !== 'production'. + * Real queue (Supabase pgmq or Cloudflare Queues) lands in PR#5. + */ + +import type { SourceType } from './ingest-link'; + +if ( + typeof process !== 'undefined' && + process.env?.NODE_ENV === 'production' && + process.env?.USE_MOCKS !== 'true' +) { + throw new Error( + '[deallenz] MockIngestQueue must not be used in production. ' + + 'Set USE_MOCKS=true to override (only for staging).' + ); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type JobStatus = 'queued' | 'processing' | 'done' | 'failed'; + +export interface IngestJob { + id: string; + deal_id: string; + url: string; + source_type: SourceType; + status: JobStatus; + queued_at: string; // ISO-8601 + started_at?: string; + completed_at?: string; + error?: string; + attempts: number; + result?: IngestJobResult; +} + +export interface IngestJobResult { + files_found: number; + files_ingested: number; + pages_scraped: number; + /** + * Fields extracted from the ingested documents. + * In mock mode this always contains { _mock: true } to make the + * mock nature unmistakable. + */ + extracted_fields: Record; +} + +// --------------------------------------------------------------------------- +// MockIngestQueue +// --------------------------------------------------------------------------- + +export class MockIngestQueue { + private jobs: Map = new Map(); + private listeners: Map void>> = new Map(); + + /** Add a job to the queue and begin async processing. */ + async enqueue(job: IngestJob): Promise { + this.jobs.set(job.id, { ...job }); + void this.processAfterDelay(job.id); + } + + /** Retrieve a job by id. */ + async getJob(id: string): Promise { + return this.jobs.get(id); + } + + /** List all jobs for a given deal, newest first. */ + async listJobsForDeal(deal_id: string): Promise { + return Array.from(this.jobs.values()) + .filter(j => j.deal_id === deal_id) + .sort((a, b) => b.queued_at.localeCompare(a.queued_at)); + } + + /** + * Register a listener that fires whenever a specific job's status changes. + * Useful for streaming status to the browser via SSE or WebSocket. + */ + onJobUpdate(job_id: string, fn: (job: IngestJob) => void): void { + const existing = this.listeners.get(job_id) ?? []; + existing.push(fn); + this.listeners.set(job_id, existing); + } + + // ------------------------------------------------------------------------- + // Internal processing + // ------------------------------------------------------------------------- + + private processAfterDelay(id: string): Promise { + // Simulate 2–4 second queue wait + const delay = 2_000 + Math.random() * 2_000; + return new Promise(resolve => + setTimeout(() => this.processJob(id).then(resolve), delay) + ); + } + + private async processJob(id: string): Promise { + const job = this.jobs.get(id); + if (!job) return; + + // Transition: queued → processing + const processing: IngestJob = { + ...job, + status: 'processing', + started_at: new Date().toISOString(), + attempts: job.attempts + 1, + }; + this.jobs.set(id, processing); + this.notify(processing); + + // Simulate processing time (1–2 seconds per source type) + const processingTime: Record = { + google_drive_folder: 2_000, + google_drive_file: 1_000, + dropbox_folder: 1_800, + notion_page: 1_200, + generic_webpage: 800, + }; + await new Promise(r => + setTimeout(r, processingTime[job.source_type] ?? 1_500) + ); + + // Mock success result — clearly labeled + const result: IngestJobResult = { + files_found: 3, + files_ingested: 3, + pages_scraped: 12, + extracted_fields: { + _mock: true, + note: + 'Mock ingest result. Real connector not yet implemented (PR#5). ' + + 'No actual files were fetched.', + source_type: job.source_type, + url: job.url, + }, + }; + + // Transition: processing → done + const done: IngestJob = { + ...processing, + status: 'done', + completed_at: new Date().toISOString(), + result, + }; + this.jobs.set(id, done); + this.notify(done); + } + + private notify(job: IngestJob): void { + const fns = this.listeners.get(job.id) ?? []; + for (const fn of fns) fn(job); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..308a42e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,287 @@ +# DealLens Architecture + +> **Status:** PR#3 — Path B + Agent Swarm + MVI Router (mocks). Real LLM and storage adapters land in PR#5. + +--- + +## Overview + +DealLens enriches VC deal records through a **five-phase pipeline**: + +``` +Path A (file upload) ─┐ +Path B (link ingest) ─┤→ SwarmOrchestrator → CostLedger → deal.html memo +Chat intake ─┘ +``` + +Every component is behind an **interface** so mocks can substitute for real providers during development, and real adapters can be dropped in without touching the orchestrator or UI. + +--- + +## 1. Storage Abstraction (`lib/storage.ts`) + +### Interface + +```typescript +interface StorageClient { + putFile(dealId, data, name, mimeType): Promise; + getSignedUrl(key, expiresInSeconds?): Promise; + listDealFiles(dealId): Promise; +} +``` + +### Implementations + +| File | Status | Backend | +|---|---|---| +| `lib/storage.mock.ts` | ✅ PR#3 | Writes to `/tmp/deallenz-mock-storage/` | +| `lib/storage.supabase.ts` | 🔜 PR#2 | Supabase Storage | + +**Guard:** `MockStorageClient` throws at import time if `NODE_ENV === 'production'` AND `USE_MOCKS !== 'true'`. + +--- + +## 2. MVI Model Router (`lib/llm.ts`) + +### What is MVI? + +**Mixture of Verified Inferences** — a cost-aware routing strategy that assigns each LLM call to the cheapest model tier that is adequate for the task, with automatic downgrade if a per-call budget is set. + +### Tier Mapping + +| Tier | Anthropic | OpenAI | Default tasks | +|---|---|---|---| +| `cheap` | `claude-haiku-4-5` | `gpt-4o-mini` | `extract`, `classify` | +| `mid` | `claude-sonnet-4-5` | `gpt-4o` | `research`, `analyze`, `critique` | +| `deep` | `claude-opus-4-5` | `o1` | `write` | + +### Downgrade Logic + +``` +resolveTier(taskType, { cost_budget_usd, tokens_estimate }) + 1. Start at default tier for taskType (or tier_override) + 2. While estimated_cost > budget AND tier > cheap: + tier = tier - 1 (deep → mid → cheap) + 3. Return resolved tier +``` + +### Pricing (Anthropic, May 2025) + +| Tier | In ($/M) | Out ($/M) | +|---|---|---| +| cheap | $0.80 | $4.00 | +| mid | $3.00 | $15.00 | +| deep | $15.00 | $75.00 | + +### CostLedger JSON Shape + +Every deal gets a `cost_ledger` appended to its JSON record after swarm completes: + +```json +{ + "cost_ledger": { + "deal_id": "pqc-bank", + "entries": [ + { + "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "deal_id": "pqc-bank", + "agent": "researcher", + "task_type": "research", + "model": "claude-sonnet-4-5", + "tokens_in": 3500, + "tokens_out": 800, + "usd_cost": 0.0225, + "duration_ms": 1200, + "timestamp": "2025-05-10T14:32:00.000Z" + } + ], + "total_usd": 0.412, + "total_tokens_in": 18400, + "total_tokens_out": 5500 + } +} +``` + +The cost-transparency UI (PR#4) reads this shape from `deal.swarm_output.cost_ledger`. + +### Implementations + +| File | Status | +|---|---| +| `lib/llm.mock.ts` — `MockModelRouter` | ✅ PR#3 | +| `lib/llm.anthropic.ts` | 🔜 PR#5 | +| `lib/llm.openai.ts` | 🔜 PR#5 | + +--- + +## 3. Agent Swarm (`lib/swarm/`) + +### Orchestration Phases + +``` +┌─────────────────────────────────────────────────────────┐ +│ SwarmOrchestrator │ +│ │ +│ PLAN → define agent sequence │ +│ DISPATCH → ResearcherAgent ─┐ │ +│ AnalystAgent ─┼─ (parallel) │ +│ RiskAgent ─┘ │ +│ GATHER → collect all results │ +│ COMPOSE → WriterAgent (14 chapters, 3 per batch) │ +│ CRITIQUE → CriticAgent (14-point rubric) │ +│ FINALIZE → merge + emit OrchestratorSummary │ +└─────────────────────────────────────────────────────────┘ +``` + +### Agent Contracts + +| Agent | File | Task types | Output | +|---|---|---|---| +| **ResearcherAgent** | `agents/researcher.ts` | `research` | `ResearcherOutput` (market, competitors, macro, sources) | +| **AnalystAgent** | `agents/analyst.ts` | `classify`, `analyze` | `AnalystOutput` (CAC, LTV, payback, burn multiple, Rule of 40, TAM/SAM/SOM) | +| **RiskAgent** | `agents/risk.ts` | `analyze` | `RiskOutput` (risk register, red flags, overall severity) | +| **WriterAgent** | `agents/writer.ts` | `write` | `WriterOutput` (14 chapter drafts, word count) | +| **CriticAgent** | `agents/critic.ts` | `critique` | `CriticOutput` (rubric, pass rate, blocking failures, approved) | + +### Critic Rubric (14 items) + +| ID | Blocking | Check | +|---|---|---| +| R01 | ✅ | Every chapter ≥ 100 words | +| R02 | ✅ | No invented data without source | +| R03 | ✅ | No `[MOCK]`, lorem ipsum, or placeholders | +| R04 | ❌ | At least one cited real URL per chapter | +| R05 | ✅ | Verdict chapter has clear pass/fail recommendation | +| R06 | ❌ | Risk register covers ≥ 3 categories | +| R07 | ❌ | Unit economics shows metrics or explains missing inputs | +| R08 | ❌ | Team chapter addresses founder–market fit | +| R09 | ❌ | Competition chapter names real companies | +| R10 | ❌ | Macro chapter references real indicator or policy | +| R11 | ✅ | No contradictions between traction numbers | +| R12 | ❌ | Geo chapter addresses HQ jurisdiction risks | +| R13 | ❌ | Chapter numbers are sequential 01–14 | +| R14 | ✅ | No deferred-content placeholders | + +--- + +## 4. Path B — Link Ingest (`api/ingest-link.ts`) + +### Supported Sources + +| Source | Pattern | Connector status | +|---|---|---| +| Google Drive folder | `drive.google.com/drive/folders/` | 🔜 PR#5 (Drive API) | +| Google Drive file | `drive.google.com/file/d/` | 🔜 PR#5 | +| Dropbox folder | `dropbox.com/sh/` or `dropbox.com/scl/fo/` | 🔜 PR#5 (Dropbox SDK) | +| Notion page | `notion.so/` | 🔜 PR#5 (Notion API) | +| Generic HTTPS page | Any other `https://` URL | 🔜 PR#5 (Firecrawl) | + +### Request / Response + +``` +POST /api/ingest-link +Content-Type: application/json + +{ "url": "https://drive.google.com/drive/folders/...", "deal_id": "pqc-bank" } + +200 OK +{ "ok": true, "job_id": "job-1234-abc", "source_type": "google_drive_folder" } + +400 Bad Request +{ "ok": false, "error": "INSECURE_URL", "detail": "Only HTTPS URLs are accepted." } +``` + +### Job Lifecycle + +``` +queued → processing → done + ↘ failed (with error field) +``` + +Polled via `GET /api/ingest-link?job_id=` (adapter TBD in PR#5). + +### Queue + +| File | Status | +|---|---| +| `api/ingest-queue.mock.ts` — `MockIngestQueue` | ✅ PR#3 | +| Real queue (Supabase pgmq or Cloudflare Queues) | 🔜 PR#5 | + +--- + +## 5. Mock Guards + +All mock classes enforce a runtime guard: + +```typescript +if (process.env.NODE_ENV === 'production' && process.env.USE_MOCKS !== 'true') { + throw new Error('[deallenz] MockXxx must not be used in production.'); +} +``` + +This prevents accidental production deployment of stub code. +`USE_MOCKS=true` can be set for staging environments. + +--- + +## 6. Environment Variables + +See `.env.example` for the full list. Key variables: + +| Variable | Purpose | Required | +|---|---|---| +| `NODE_ENV` | `development` / `production` | Always | +| `USE_MOCKS` | `true` to allow mocks in production (staging only) | Optional | +| `MOCK_STORAGE_ROOT` | Override `/tmp/deallenz-mock-storage` path | Optional | +| `ANTHROPIC_API_KEY` | Real LLM calls (PR#5) | PR#5+ | +| `OPENAI_API_KEY` | OpenAI fallback (PR#5) | PR#5+ | +| `SUPABASE_URL` | Supabase project URL (PR#2) | PR#2+ | +| `SUPABASE_ANON_KEY` | Supabase anon key (PR#2) | PR#2+ | + +--- + +## 7. File Map + +``` +deallenz/ +├── api/ +│ ├── ingest-link.ts # Path B route handler (framework-agnostic) +│ └── ingest-queue.mock.ts # In-memory job queue (mock) +├── lib/ +│ ├── storage.ts # StorageClient interface +│ ├── storage.mock.ts # MockStorageClient (/tmp) +│ ├── llm.ts # ModelRouter interface + MVI routing + CostLedger +│ ├── llm.mock.ts # MockModelRouter (stub responses + realistic costs) +│ └── swarm/ +│ ├── orchestrator.ts # Plan/dispatch/gather/compose/critique/finalize +│ └── agents/ +│ ├── researcher.ts +│ ├── analyst.ts +│ ├── risk.ts +│ ├── writer.ts +│ └── critic.ts +├── docs/ +│ ├── ARCHITECTURE.md # This file +│ ├── DECISIONS.md +│ ├── EXPECTATIONS.md +│ └── Multi_Agent_VC_Platform_Scope.md +├── submit.html # Path A + Path B intake UI +├── deal.html # 14-chapter memo viewer +├── tsconfig.json +└── .env.example +``` + +--- + +## 8. Decision Log References + +| Decision | Impact | +|---|---| +| D-010 | MVI router resolves model per task type; Opus reserved for writing only | +| D-017 | Every chapter must cite a source; CriticAgent rubric R04 enforces this | +| D-018 | Macro data sources: FRED, World Bank, OFAC, BIS, USTR | +| D-019 | Unit economics canonical definitions (CAC, LTV, payback, burn multiple, NDR) | +| D-021 | StorageClient interface; real Supabase adapter in PR#2 | +| D-022 | MockModelRouter returns deterministic stubs; USE_MOCKS gate prevents prod use | +| D-023 | Path B: Google Drive + Dropbox first; email-forward deferred | +| D-024 | Critic rubric: 14 items, 6 blocking; approved=true only when all blocking pass | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d1a0806..055a851 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -13,7 +13,7 @@ Live log of significant decisions. Each decision is final until explicitly super | D-007 | 2026-05-04 | gstack + paperclip + autoresearch + feynman + evaluation | Reuse depth-first open work. | Locked | | D-008 | TBD | Vector DB: Firestore embeddings (v2) vs Pinecone (v3) | Cost vs latency. | Pending | | D-009 | TBD | Live `/deallens/` redesign vs parallel `/deallenz/` first | Risk vs speed. Currently shipping `/deallenz/` in parallel. | Pending | -| D-010 | TBD | LLM primary model | Quality vs cost benchmark to be run. | Pending | +| D-010 | 2026-05-10 | MVI router: Opus reserved for `write` tasks only | Opus is 5× the cost of Sonnet; long-form memo writing is the only task that justifies it. All other tasks route to Sonnet (mid) or Haiku (cheap). | Locked | | D-011 | TBD | Auth: Google-only vs email+Google | Friction vs reach. | Pending | | D-012 | TBD | Pricing tier launch with v3 or post-pilot | Revenue timing. | Pending | | D-013 | TBD | Custom domain `deallenz.com` | Brand vs cost. | Pending | @@ -24,6 +24,12 @@ Live log of significant decisions. Each decision is final until explicitly super | D-018 | 2026-05-04 | Macro/Geo data sources fixed | FRED, World Bank, OFAC, BIS, USTR — all open APIs. | Locked | | D-019 | 2026-05-04 | Unit-economics formulas locked | CAC, LTV, payback, burn multiple, NDR canonical definitions. | Locked | | D-020 | 2026-05-04 | No third-party brand names in product copy | Inspirations stay internal; product reads neutral. | Locked | +| D-021 | 2026-05-10 | StorageClient interface; real Supabase adapter deferred to PR#2 | Interface-first approach lets the swarm run in mock mode without a database. | Locked | +| D-022 | 2026-05-10 | MockModelRouter returns deterministic `[MOCK]`-prefixed stubs | Makes mock output unmistakable in UI and logs; guards prevent prod use. | Locked | +| D-023 | 2026-05-10 | Path B priority: Google Drive folder + Dropbox folder first; Notion, email-forward later | Drive and Dropbox cover 80%+ of VC data-room formats. Notion and email-forward are deferred to PR#5. | Locked | +| D-024 | 2026-05-10 | Critic rubric: 14 items, 6 blocking; `approved=true` only when all blocking items pass | Mirrors IC committee sign-off logic; non-blocking failures are advisory. | Locked | +| D-025 | 2026-05-10 | Path B UI shows honest error if backend not running | Static HTML can't run the ingest API; we explain the gap rather than faking success. | Locked | +| D-026 | 2026-05-10 | `tsconfig.json` added with strict mode + NodeNext module resolution | TypeScript strict mode catches interface mismatches early; NodeNext required for `node:` imports. | Locked | ## How to add a decision diff --git a/lib/llm.mock.ts b/lib/llm.mock.ts new file mode 100644 index 0000000..22abd9c --- /dev/null +++ b/lib/llm.mock.ts @@ -0,0 +1,150 @@ +/** + * MockModelRouter + * Returns deterministic stub responses and realistic-ish cost numbers so the + * cost-transparency UI can render without a real LLM connected. + * + * ONLY usable when USE_MOCKS=true or NODE_ENV !== 'production'. + * Stub responses are clearly labeled with [MOCK] prefix. + * + * Real adapters: lib/llm.anthropic.ts, lib/llm.openai.ts (PR#5) + */ + +import * as crypto from 'node:crypto'; +import type { + ModelRouter, + RouteParams, + LLMResponse, + CostLedger, + CostEntry, + TaskType, + ModelTier, +} from './llm'; +import { resolveTier, estimateUsd, TIER_MODELS } from './llm'; + +if ( + typeof process !== 'undefined' && + process.env?.NODE_ENV === 'production' && + process.env?.USE_MOCKS !== 'true' +) { + throw new Error( + '[deallenz] MockModelRouter must not be used in production. ' + + 'Set USE_MOCKS=true to override (only for staging).' + ); +} + +// --------------------------------------------------------------------------- +// Stub responses (deterministic, clearly labeled) +// --------------------------------------------------------------------------- + +/** Each stub clearly states it is mock output and what real integration is needed. */ +const STUB_RESPONSES: Record = { + extract: + '[MOCK] Extracted fields from document. ' + + 'Real extraction requires a connected LLM with PDF/text input.', + classify: + '[MOCK] Classification result: seed-stage fintech. ' + + 'Real classification requires a connected LLM.', + research: + '[MOCK] Research complete. 0 live sources found. ' + + 'Real web research requires Tavily or Firecrawl integration (PR#5).', + analyze: + '[MOCK] Financial analysis stub. ' + + 'CAC, LTV, payback, and burn multiple cannot be computed without real inputs.', + write: + '[MOCK] Memo chapter drafted. ' + + 'Real memo writing requires a connected LLM with full deal context (PR#5).', + critique: + '[MOCK] QA pass complete. 0 rubric items evaluated. ' + + 'Real critique requires a connected LLM (PR#5).', +}; + +// Realistic token counts per task type (to produce plausible cost numbers) +const STUB_TOKEN_COUNTS: Record = { + extract: [1_200, 400], + classify: [ 400, 100], + research: [3_500, 800], + analyze: [2_800, 700], + write: [5_000, 2_000], + critique: [4_000, 500], +}; + +// Simulated latency per tier (ms) +const TIER_LATENCY: Record = { + cheap: 400, + mid: 1_200, + deep: 3_500, +}; + +// --------------------------------------------------------------------------- +// MockModelRouter +// --------------------------------------------------------------------------- + +export class MockModelRouter implements ModelRouter { + private ledger: CostLedger; + + constructor(deal_id: string) { + this.ledger = { + deal_id, + entries: [], + total_usd: 0, + total_tokens_in: 0, + total_tokens_out: 0, + }; + } + + async route(params: RouteParams): Promise { + const tier: ModelTier = resolveTier(params.task_type, { + tier_override: params.tier_override, + cost_budget_usd: params.cost_budget_usd, + }); + + const model = TIER_MODELS[tier].anthropic; // default to Anthropic naming convention + const [tokensIn, tokensOut] = STUB_TOKEN_COUNTS[params.task_type]; + const usd_cost = estimateUsd(tier, tokensIn, tokensOut); + const latency = TIER_LATENCY[tier]; + + // Simulate async latency so the UI can show progress realistically + await new Promise(r => setTimeout(r, latency)); + + const entry: CostEntry = { + call_id: crypto.randomUUID(), + deal_id: params.deal_id, + agent: params.agent, + task_type: params.task_type, + model, + tokens_in: tokensIn, + tokens_out: tokensOut, + usd_cost, + duration_ms: latency, + timestamp: new Date().toISOString(), + }; + + // Accumulate into ledger + this.ledger.entries.push(entry); + this.ledger.total_usd += usd_cost; + this.ledger.total_tokens_in += tokensIn; + this.ledger.total_tokens_out += tokensOut; + + const content = STUB_RESPONSES[params.task_type]; + + return { content, cost_entry: entry }; + } + + getCostLedger(): CostLedger { + // Return a shallow copy so callers cannot mutate internal state + return { + ...this.ledger, + entries: [...this.ledger.entries], + }; + } + + resetLedger(deal_id: string): void { + this.ledger = { + deal_id, + entries: [], + total_usd: 0, + total_tokens_in: 0, + total_tokens_out: 0, + }; + } +} diff --git a/lib/llm.ts b/lib/llm.ts new file mode 100644 index 0000000..943ed47 --- /dev/null +++ b/lib/llm.ts @@ -0,0 +1,188 @@ +/** + * ModelRouter — MVI (Mixture of Verified Inferences) routing layer. + * + * Selects between cheap / mid / deep model tiers based on task type and + * an optional per-call cost budget. Tracks every LLM call in a CostLedger + * for the cost-transparency UI. + * + * Real adapters: lib/llm.anthropic.ts, lib/llm.openai.ts (PR#5) + * Mock: lib/llm.mock.ts + * + * MVI downgrade ladder: deep → mid → cheap + * (Upgrade is never automatic; use tier_override to go up intentionally.) + */ + +// --------------------------------------------------------------------------- +// Task taxonomy +// --------------------------------------------------------------------------- + +/** + * TaskType drives the default model tier selection. + * cheap → Haiku / GPT-4o-mini (fast, low cost, good for extraction/classification) + * mid → Sonnet / GPT-4o (best value for research + analysis) + * deep → Opus / o1 (reserved for long-form writing only) + */ +export type TaskType = + | 'extract' // Parse a PDF/doc and pull structured fields + | 'classify' // Short classification or routing decision + | 'research' // Multi-hop web + doc retrieval synthesis + | 'analyze' // Financial/market analysis with reasoning + | 'write' // Long-form memo chapter composition + | 'critique'; // QA rubric pass against a full draft + +export type ModelTier = 'cheap' | 'mid' | 'deep'; + +// --------------------------------------------------------------------------- +// Model catalogue (provider-agnostic symbolic names) +// --------------------------------------------------------------------------- + +/** Canonical model IDs per tier, per provider. Updated here when models change. */ +export const TIER_MODELS: Record = { + cheap: { anthropic: 'claude-haiku-4-5', openai: 'gpt-4o-mini' }, + mid: { anthropic: 'claude-sonnet-4-5', openai: 'gpt-4o' }, + deep: { anthropic: 'claude-opus-4-5', openai: 'o1' }, +}; + +/** Default tier assigned to each task type. */ +export const DEFAULT_TASK_TIERS: Record = { + extract: 'cheap', + classify: 'cheap', + research: 'mid', + analyze: 'mid', + write: 'deep', + critique: 'mid', +}; + +// --------------------------------------------------------------------------- +// Cost ledger +// --------------------------------------------------------------------------- + +export interface CostEntry { + call_id: string; // UUID + deal_id: string; + agent: string; // e.g. "researcher", "writer" + task_type: TaskType; + model: string; // Resolved model string e.g. "claude-haiku-4-5" + tokens_in: number; + tokens_out: number; + usd_cost: number; // Calculated at call-time from known pricing + duration_ms: number; + timestamp: string; // ISO-8601 +} + +export interface CostLedger { + deal_id: string; + entries: CostEntry[]; + total_usd: number; + total_tokens_in: number; + total_tokens_out: number; +} + +// --------------------------------------------------------------------------- +// Router interface +// --------------------------------------------------------------------------- + +export interface RouteParams { + deal_id: string; + agent: string; + task_type: TaskType; + /** Force a specific tier, overriding the default task-to-tier mapping. */ + tier_override?: ModelTier; + /** + * Maximum USD to spend on this single call. + * If the estimated cost at the chosen tier exceeds this budget, the router + * automatically downgrades to the next cheaper tier. + */ + cost_budget_usd?: number; + prompt: string; + system?: string; + max_tokens?: number; +} + +export interface LLMResponse { + content: string; + cost_entry: CostEntry; +} + +export interface ModelRouter { + /** + * Route the call to the appropriate model, execute it, record cost, and + * return the response with a cost_entry. + */ + route(params: RouteParams): Promise; + + /** Return the accumulated CostLedger for all calls routed through this instance. */ + getCostLedger(): CostLedger; + + /** Reset the ledger (e.g. between deals in tests). */ + resetLedger(deal_id: string): void; +} + +// --------------------------------------------------------------------------- +// MVI routing helpers (pure, model-agnostic) +// --------------------------------------------------------------------------- + +/** + * Resolve the appropriate model tier for a task, respecting budget constraints. + * + * Algorithm: + * 1. Start from tier_override if supplied, else DEFAULT_TASK_TIERS[taskType]. + * 2. If cost_budget_usd and tokens_estimate are both provided, estimate cost + * at the chosen tier. While cost > budget and tier > cheap, downgrade one step. + */ +export function resolveTier( + taskType: TaskType, + options: { + tier_override?: ModelTier; + cost_budget_usd?: number; + tokens_estimate?: number; + } = {} +): ModelTier { + let tier: ModelTier = options.tier_override ?? DEFAULT_TASK_TIERS[taskType]; + + if ( + options.cost_budget_usd !== undefined && + options.tokens_estimate !== undefined + ) { + const ladderDown = (t: ModelTier): ModelTier => + t === 'deep' ? 'mid' : 'cheap'; + + let estimated = estimateUsd( + tier, + options.tokens_estimate, + Math.round(options.tokens_estimate * 0.3) + ); + while (estimated > options.cost_budget_usd && tier !== 'cheap') { + tier = ladderDown(tier); + estimated = estimateUsd( + tier, + options.tokens_estimate, + Math.round(options.tokens_estimate * 0.3) + ); + } + } + + return tier; +} + +/** + * Estimate USD cost using Anthropic public pricing (May 2025). + * OpenAI adapter uses the same function with a pricing override. + * + * Haiku: $0.80 / $4.00 per million tokens (in / out) + * Sonnet: $3.00 / $15.00 per million tokens + * Opus: $15.00 / $75.00 per million tokens + */ +export function estimateUsd( + tier: ModelTier, + tokensIn: number, + tokensOut: number +): number { + const pricing: Record = { + cheap: [0.80, 4.00], + mid: [3.00, 15.00], + deep: [15.00, 75.00], + }; + const [inRate, outRate] = pricing[tier]; + return (tokensIn / 1_000_000) * inRate + (tokensOut / 1_000_000) * outRate; +} diff --git a/lib/storage.mock.ts b/lib/storage.mock.ts new file mode 100644 index 0000000..0122703 --- /dev/null +++ b/lib/storage.mock.ts @@ -0,0 +1,79 @@ +/** + * MockStorageClient + * Writes to /tmp/deallenz-mock-storage/{deal_id}/ and returns local file:// paths. + * + * ONLY usable when USE_MOCKS=true or NODE_ENV !== 'production'. + * Import guard enforced at module load time. + * + * Real Supabase implementation: lib/storage.supabase.ts (PR#2) + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import type { StorageClient, UploadedFile } from './storage'; + +if ( + typeof process !== 'undefined' && + process.env?.NODE_ENV === 'production' && + process.env?.USE_MOCKS !== 'true' +) { + throw new Error( + '[deallenz] MockStorageClient must not be used in production. ' + + 'Set USE_MOCKS=true to override (only for staging).' + ); +} + +const STORAGE_ROOT = + process.env.MOCK_STORAGE_ROOT ?? '/tmp/deallenz-mock-storage'; + +function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +export class MockStorageClient implements StorageClient { + /** In-memory index of uploads (keyed by deal_id). Survives only for process lifetime. */ + private index: Map = new Map(); + + async putFile( + dealId: string, + data: Buffer | Uint8Array, + name: string, + mimeType: string + ): Promise { + const dir = path.join(STORAGE_ROOT, dealId); + ensureDir(dir); + + const uuid = crypto.randomUUID(); + const safeName = name.replace(/[^a-z0-9._-]/gi, '_'); + const filename = `${uuid}-${safeName}`; + const fullPath = path.join(dir, filename); + + fs.writeFileSync(fullPath, data); + + const meta: UploadedFile = { + key: `deals/${dealId}/${filename}`, + name, + size: data.length, + mime_type: mimeType, + uploaded_at: new Date().toISOString(), + }; + + const existing = this.index.get(dealId) ?? []; + existing.unshift(meta); + this.index.set(dealId, existing); + + return meta; + } + + async getSignedUrl(key: string, _expiresInSeconds = 3600): Promise { + // Strip the "deals/" prefix to reconstruct the local path + const relativePath = key.replace(/^deals\//, ''); + const fullPath = path.join(STORAGE_ROOT, relativePath); + return `file://${fullPath}`; + } + + async listDealFiles(dealId: string): Promise { + return this.index.get(dealId) ?? []; + } +} diff --git a/lib/storage.ts b/lib/storage.ts new file mode 100644 index 0000000..4015e12 --- /dev/null +++ b/lib/storage.ts @@ -0,0 +1,49 @@ +/** + * StorageClient — abstract file-storage interface. + * + * Real Supabase Storage implementation lands in PR#2. + * Mock implementation: lib/storage.mock.ts + * + * Import guard: MockStorageClient throws if NODE_ENV === 'production' + * AND USE_MOCKS !== 'true'. + */ + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export interface UploadedFile { + /** Storage key, e.g. "deals/{deal_id}/{uuid}-{name}" */ + key: string; + /** Original file name as supplied by the caller */ + name: string; + size: number; + mime_type: string; + uploaded_at: string; // ISO-8601 +} + +// --------------------------------------------------------------------------- +// Interface +// --------------------------------------------------------------------------- + +export interface StorageClient { + /** + * Upload a file belonging to a deal. + * Returns metadata including the immutable storage key. + */ + putFile( + dealId: string, + data: Buffer | Uint8Array, + name: string, + mimeType: string + ): Promise; + + /** + * Return a pre-signed URL for a stored file. + * Defaults to 3600-second expiry. + */ + getSignedUrl(key: string, expiresInSeconds?: number): Promise; + + /** List all files attached to a deal, newest-first. */ + listDealFiles(dealId: string): Promise; +} diff --git a/lib/swarm/agents/analyst.ts b/lib/swarm/agents/analyst.ts new file mode 100644 index 0000000..dd30cc8 --- /dev/null +++ b/lib/swarm/agents/analyst.ts @@ -0,0 +1,164 @@ +/** + * AnalystAgent + * Financial modelling, unit economics, and market sizing. + * + * Where inputs are present in the deal record (ARR, MoM, NRR) metrics are + * computed deterministically without an LLM call. The LLM is used only for + * market sizing and for fields that require contextual reasoning. + * + * Real implementation requires deal financials as input. (PR#5) + */ + +import type { ModelRouter } from '../../llm'; +import type { SwarmContext, AgentResult } from '../orchestrator'; + +// --------------------------------------------------------------------------- +// Output type +// --------------------------------------------------------------------------- + +export interface AnalystOutput { + /** Customer Acquisition Cost in USD, or null if inputs are missing */ + cac_usd: number | null; + /** Lifetime Value in USD, or null if inputs are missing */ + ltv_usd: number | null; + /** LTV/CAC payback in months, or null */ + payback_months: number | null; + /** Burn Multiple = net burn / net new ARR, or null */ + burn_multiple: number | null; + /** Growth Rate + Margin (canonical Rule of 40), or null */ + rule_of_40: number | null; + /** Total Addressable Market in USD, or null */ + tam_usd: number | null; + /** Serviceable Addressable Market in USD, or null */ + sam_usd: number | null; + /** Serviceable Obtainable Market in USD, or null */ + som_usd: number | null; + /** Human-readable notes explaining null fields and methodology */ + notes: string; +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +export class AnalystAgent { + constructor(private router: ModelRouter) {} + + async run(ctx: SwarmContext): Promise> { + ctx.emit('analyst:start', { deal_id: ctx.deal_id }); + + // Cheap classification call: how complex is this deal? + const classifyCall = await this.router.route({ + deal_id: ctx.deal_id, + agent: 'analyst', + task_type: 'classify', + prompt: + `Deal: ${ctx.deal.name ?? ctx.deal_id}. ` + + `Stage: ${ctx.deal.stage ?? 'unknown'}. ` + + `ARR: ${ctx.deal.arr ?? 'unknown'}. ` + + `Classify analysis complexity: low | medium | high.`, + max_tokens: 64, + }); + + // Main analysis call + const analysisCall = await this.router.route({ + deal_id: ctx.deal_id, + agent: 'analyst', + task_type: 'analyze', + system: ANALYST_SYSTEM, + prompt: buildAnalysisPrompt(ctx), + max_tokens: 1024, + }); + + ctx.emit('analyst:done', { + deal_id: ctx.deal_id, + cost: analysisCall.cost_entry.usd_cost, + }); + + // Prefer deterministic computation over LLM output when inputs exist + const output = computeFromDeal(ctx) ?? parseOrMockOutput(analysisCall.content); + + return { + agent: 'analyst', + output, + cost_entries: [classifyCall.cost_entry, analysisCall.cost_entry], + }; + } +} + +// --------------------------------------------------------------------------- +// Deterministic computation (no LLM needed when inputs are available) +// --------------------------------------------------------------------------- + +/** + * Compute metrics directly from deal JSON fields. + * Returns null if data is insufficient (caller falls back to LLM output). + * + * Definitions per D-019: + * Rule of 40 = annualized_growth_pct + net_margin_pct + * (margin unknown → contribute 0; flagged in notes) + */ +function computeFromDeal(ctx: SwarmContext): AnalystOutput | null { + const { deal } = ctx; + if (!deal.arr || !deal.mom) return null; + + const annualGrowthPct = (Math.pow(1 + deal.mom / 100, 12) - 1) * 100; + const rule_of_40 = Math.round(annualGrowthPct); // margin = 0 (unknown) + + return { + cac_usd: null, + ltv_usd: null, + payback_months: null, + burn_multiple: null, + rule_of_40, + tam_usd: null, + sam_usd: null, + som_usd: null, + notes: + `Rule of 40 computed from MoM growth (${deal.mom}% → ${annualGrowthPct.toFixed(1)}% annualised); ` + + `net margin set to 0 (not provided). ` + + `CAC, LTV, payback, and burn multiple require monthly burn and new-ARR-per-month inputs — ` + + `please upload financials or provide these figures in the deal record.`, + }; +} + +// --------------------------------------------------------------------------- +// Prompt / system +// --------------------------------------------------------------------------- + +function buildAnalysisPrompt(ctx: SwarmContext): string { + const { deal } = ctx; + return [ + `Analyze deal: ${deal.name ?? ctx.deal_id}`, + `ARR: ${deal.arr ?? 'unknown'} | MoM growth: ${deal.mom ?? 'unknown'}% | NRR: ${deal.nrr ?? 'unknown'}%`, + `Logos: ${deal.logos ?? 'unknown'} | Ask: ${deal.ask ?? 'unknown'} | Pre-money: ${deal.premoney ?? 'unknown'}`, + '', + 'Compute: CAC, LTV, payback months, burn multiple, Rule of 40, TAM, SAM, SOM.', + 'Use canonical definitions from D-019.', + 'If any input is missing, set that field to null and explain in notes.', + 'Return JSON matching AnalystOutput schema.', + ].join('\n'); +} + +const ANALYST_SYSTEM = + `You are the DealLens financial analyst. Use canonical VC metric definitions (D-019). ` + + `Never invent numbers. If inputs are missing, return null for that field and explain clearly in notes.`; + +function parseOrMockOutput(content: string): AnalystOutput { + try { + const parsed = JSON.parse(content) as Partial; + if ('rule_of_40' in parsed) return parsed as AnalystOutput; + } catch { /* mock stub string */ } + + return { + cac_usd: null, + ltv_usd: null, + payback_months: null, + burn_multiple: null, + rule_of_40: null, + tam_usd: null, + sam_usd: null, + som_usd: null, + notes: content, + }; +} diff --git a/lib/swarm/agents/critic.ts b/lib/swarm/agents/critic.ts new file mode 100644 index 0000000..2d528ea --- /dev/null +++ b/lib/swarm/agents/critic.ts @@ -0,0 +1,152 @@ +/** + * CriticAgent + * QA pass against a 14-point rubric before the memo is released. + * + * All 14 rubric items must pass for `approved: true`. + * Any item that fails and is marked as blocking prevents approval. + * + * Real implementation requires a connected LLM. (PR#5) + */ + +import type { ModelRouter } from '../../llm'; +import type { SwarmContext, AgentResult } from '../orchestrator'; +import type { WriterOutput } from './writer'; + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +export interface RubricItem { + id: string; // e.g. "R01" + check: string; // Human-readable check description + passed: boolean; + note: string; // One-sentence explanation of pass/fail + blocking: boolean; // If true and failed, memo cannot be approved +} + +export interface CriticOutput { + rubric: RubricItem[]; + /** Fraction of rubric items that passed (0–1) */ + pass_rate: number; + /** IDs of rubric items that are blocking and failed */ + blocking_failures: string[]; + /** One-paragraph revision recommendation, or null if approved */ + recommended_revision: string | null; + /** True only when all blocking items pass */ + approved: boolean; +} + +// --------------------------------------------------------------------------- +// Rubric definition +// --------------------------------------------------------------------------- + +const RUBRIC: Array<{ id: string; check: string; blocking: boolean }> = [ + { id: 'R01', blocking: true, check: 'Every chapter has at least 100 words of substance' }, + { id: 'R02', blocking: true, check: 'No invented data (e.g. no "$X billion market" without a cited source)' }, + { id: 'R03', blocking: true, check: 'No lorem ipsum, "[MOCK]", or placeholder text in final output' }, + { id: 'R04', blocking: false, check: 'At least one cited source per chapter with a real HTTPS URL' }, + { id: 'R05', blocking: true, check: 'Verdict chapter includes a clear pass / pass-with-conditions / fail recommendation' }, + { id: 'R06', blocking: false, check: 'Risk register covers at least 3 distinct risk categories' }, + { id: 'R07', blocking: false, check: 'Unit economics section either shows computed metrics or explicitly states missing inputs' }, + { id: 'R08', blocking: false, check: 'Team chapter addresses founder–market fit' }, + { id: 'R09', blocking: false, check: 'Competition chapter names real companies, not "numerous competitors"' }, + { id: 'R10', blocking: false, check: 'Macro chapter references at least one real macro indicator or policy' }, + { id: 'R11', blocking: true, check: 'No contradictions between traction numbers across chapters' }, + { id: 'R12', blocking: false, check: 'Geopolitics chapter addresses HQ jurisdiction risks' }, + { id: 'R13', blocking: false, check: 'All chapter numbers are sequential (01–14)' }, + { id: 'R14', blocking: true, check: 'No "Awaiting agent enrichment" or equivalent deferred-content placeholders' }, +]; + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +export class CriticAgent { + constructor(private router: ModelRouter) {} + + async run( + ctx: SwarmContext, + draft: WriterOutput + ): Promise> { + ctx.emit('critic:start', { deal_id: ctx.deal_id }); + + const call = await this.router.route({ + deal_id: ctx.deal_id, + agent: 'critic', + task_type: 'critique', + system: CRITIC_SYSTEM, + prompt: buildCritiquePrompt(ctx, draft), + max_tokens: 1024, + }); + + ctx.emit('critic:done', { deal_id: ctx.deal_id }); + + return { + agent: 'critic', + output: parseOrMockOutput(call.content), + cost_entries: [call.cost_entry], + }; + } +} + +// --------------------------------------------------------------------------- +// Prompt / system +// --------------------------------------------------------------------------- + +function buildCritiquePrompt(ctx: SwarmContext, draft: WriterOutput): string { + const chapterSummaries = draft.chapters + .map( + c => + `Chapter ${c.id}: ${c.body.slice(0, 200)}…` + + ` [sources: ${c.sources.length}] [needs_review: ${c.needs_review}]` + ) + .join('\n'); + + return [ + `Evaluate the investment memo for "${ctx.deal.name ?? ctx.deal_id}" against this rubric:`, + '', + RUBRIC.map(r => `${r.id} [blocking=${r.blocking}]: ${r.check}`).join('\n'), + '', + 'Memo chapters:', + chapterSummaries, + '', + 'For each rubric item return: { id, check, passed: boolean, note: string, blocking: boolean }.', + 'Also return:', + ' blocking_failures: string[] (ids of blocking items that failed)', + ' recommended_revision: string | null (one paragraph, or null if approved)', + ' approved: boolean (true only when all blocking items pass)', + 'Return JSON matching CriticOutput schema.', + ].join('\n'); +} + +const CRITIC_SYSTEM = + `You are the DealLens QA critic. Apply the rubric strictly. ` + + `Do not approve memos with placeholder text, invented data, or missing critical sections. ` + + `Your job is to protect the IC committee from bad information.`; + +// --------------------------------------------------------------------------- +// Output parsing (graceful fallback for mock stub strings) +// --------------------------------------------------------------------------- + +function parseOrMockOutput(content: string): CriticOutput { + try { + const parsed = JSON.parse(content) as Partial; + if (Array.isArray(parsed.rubric)) return parsed as CriticOutput; + } catch { /* mock stub string */ } + + // In mock mode: all checks pending, not approved, with a clear explanation + const rubric: RubricItem[] = RUBRIC.map(r => ({ + ...r, + passed: false, + note: 'Mock mode — real critique requires a connected LLM (PR#5).', + })); + + return { + rubric, + pass_rate: 0, + blocking_failures: RUBRIC.filter(r => r.blocking).map(r => r.id), + recommended_revision: + 'Connect a real LLM provider (PR#5) to generate a genuine QA critique.', + approved: false, + }; +} diff --git a/lib/swarm/agents/researcher.ts b/lib/swarm/agents/researcher.ts new file mode 100644 index 0000000..5e51329 --- /dev/null +++ b/lib/swarm/agents/researcher.ts @@ -0,0 +1,103 @@ +/** + * ResearcherAgent + * Web + document retrieval for a deal record. + * + * Real implementation: Tavily / Firecrawl MCP tools (PR#5). + * In mock mode, ModelRouter returns stub strings; this agent wraps them + * into the typed ResearcherOutput shape. + */ + +import type { ModelRouter } from '../../llm'; +import type { SwarmContext, AgentResult } from '../orchestrator'; + +// --------------------------------------------------------------------------- +// Output type +// --------------------------------------------------------------------------- + +export interface ResearcherOutput { + market_summary: string; + competitors: Array<{ name: string; differentiation: string }>; + macro_signals: Array<{ signal: string; direction: 'tailwind' | 'headwind' }>; + sources: Array<{ title: string; url: string; snippet: string }>; +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +export class ResearcherAgent { + constructor(private router: ModelRouter) {} + + async run(ctx: SwarmContext): Promise> { + ctx.emit('researcher:start', { deal_id: ctx.deal_id }); + + const call = await this.router.route({ + deal_id: ctx.deal_id, + agent: 'researcher', + task_type: 'research', + system: RESEARCHER_SYSTEM, + prompt: buildResearchPrompt(ctx), + max_tokens: 2048, + }); + + ctx.emit('researcher:done', { + deal_id: ctx.deal_id, + cost: call.cost_entry.usd_cost, + }); + + return { + agent: 'researcher', + output: parseOrMockOutput(call.content), + cost_entries: [call.cost_entry], + }; + } +} + +// --------------------------------------------------------------------------- +// Prompt builders +// --------------------------------------------------------------------------- + +function buildResearchPrompt(ctx: SwarmContext): string { + return [ + `Deal: ${ctx.deal.name ?? ctx.deal_id}`, + `Sector: ${ctx.deal.sector ?? 'unknown'}`, + `Thesis: ${ctx.deal.thesis ?? 'not provided'}`, + `HQ: ${ctx.deal.hq ?? 'unknown'}`, + '', + 'Research tasks:', + '1. Summarize the total addressable market (TAM/SAM/SOM) with citations.', + '2. List the top 5 competitors with a one-line differentiation note each.', + '3. Identify 3 macro tailwinds and 2 macro headwinds with evidence.', + '4. Return ALL sources used: title, URL, one-sentence snippet.', + '', + 'Rules:', + '- Never invent company names, market sizes, or URLs.', + '- If you cannot find real data for a field, return an empty array for that field.', + '- Return valid JSON matching ResearcherOutput schema.', + ].join('\n'); +} + +const RESEARCHER_SYSTEM = + `You are the DealLens research agent. Synthesize market intelligence for VC deal memos. ` + + `Be factual and cite real sources. Never invent names, numbers, or URLs. ` + + `If data is unavailable, say so explicitly. Return structured JSON only.`; + +// --------------------------------------------------------------------------- +// Output parsing (graceful fallback for mock stub strings) +// --------------------------------------------------------------------------- + +function parseOrMockOutput(content: string): ResearcherOutput { + try { + const parsed = JSON.parse(content) as Partial; + if (typeof parsed.market_summary === 'string') { + return parsed as ResearcherOutput; + } + } catch { /* not JSON — must be mock stub string */ } + + return { + market_summary: content, + competitors: [], + macro_signals: [], + sources: [], + }; +} diff --git a/lib/swarm/agents/risk.ts b/lib/swarm/agents/risk.ts new file mode 100644 index 0000000..18ae09e --- /dev/null +++ b/lib/swarm/agents/risk.ts @@ -0,0 +1,118 @@ +/** + * RiskAgent + * Generates a structured risk register and red-flag summary. + * + * Real implementation requires a connected LLM. (PR#5) + * In mock mode, returns an empty register with a clear explanation. + */ + +import type { ModelRouter } from '../../llm'; +import type { SwarmContext, AgentResult } from '../orchestrator'; + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +export type RiskSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export type RiskCategory = + | 'market' + | 'execution' + | 'team' + | 'financial' + | 'regulatory' + | 'technology' + | 'geo'; + +export interface RiskItem { + id: string; // e.g. "RISK-01" + category: RiskCategory; + title: string; + description: string; // Max 2 sentences + severity: RiskSeverity; + mitigation: string | null; // One sentence, or null if none identified +} + +export interface RiskOutput { + risks: RiskItem[]; + red_flags: string[]; // Deal-breaker signals as plain strings + overall_severity: RiskSeverity; // Highest severity present + notes: string; +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +export class RiskAgent { + constructor(private router: ModelRouter) {} + + async run(ctx: SwarmContext): Promise> { + ctx.emit('risk:start', { deal_id: ctx.deal_id }); + + const call = await this.router.route({ + deal_id: ctx.deal_id, + agent: 'risk', + task_type: 'analyze', + system: RISK_SYSTEM, + prompt: buildRiskPrompt(ctx), + max_tokens: 1024, + }); + + ctx.emit('risk:done', { + deal_id: ctx.deal_id, + cost: call.cost_entry.usd_cost, + }); + + return { + agent: 'risk', + output: parseOrMockOutput(call.content), + cost_entries: [call.cost_entry], + }; + } +} + +// --------------------------------------------------------------------------- +// Prompt / system +// --------------------------------------------------------------------------- + +function buildRiskPrompt(ctx: SwarmContext): string { + const { deal } = ctx; + return [ + `Generate a risk register for: ${deal.name ?? ctx.deal_id}`, + `Stage: ${deal.stage ?? 'unknown'} | Sector: ${deal.sector ?? 'unknown'} | HQ: ${deal.hq ?? 'unknown'}`, + `Thesis: ${deal.thesis ?? 'not provided'}`, + '', + 'For each risk provide:', + ' - id (RISK-NN), category (market/execution/team/financial/regulatory/technology/geo)', + ' - title (< 10 words), description (max 2 sentences), severity (low/medium/high/critical)', + ' - mitigation (one sentence) or null', + '', + 'Also list red_flags as plain strings (deal-breaker signals).', + 'Set overall_severity to the highest risk severity present.', + 'Omit risk categories you have no data to assess — do not fabricate.', + 'Return JSON matching RiskOutput schema.', + ].join('\n'); +} + +const RISK_SYSTEM = + `You are the DealLens risk analyst. Surface real, deal-specific risks based on sector, stage, and geography. ` + + `Never use generic boilerplate. If you lack data to assess a risk category, omit it.`; + +// --------------------------------------------------------------------------- +// Output parsing +// --------------------------------------------------------------------------- + +function parseOrMockOutput(content: string): RiskOutput { + try { + const parsed = JSON.parse(content) as Partial; + if (Array.isArray(parsed.risks)) return parsed as RiskOutput; + } catch { /* mock stub string */ } + + return { + risks: [], + red_flags: [], + overall_severity: 'medium', + notes: content, + }; +} diff --git a/lib/swarm/agents/writer.ts b/lib/swarm/agents/writer.ts new file mode 100644 index 0000000..d5c04fc --- /dev/null +++ b/lib/swarm/agents/writer.ts @@ -0,0 +1,196 @@ +/** + * WriterAgent + * Composes all 14 chapters of the McKinsey-grade investment memo. + * + * Chapters are processed in parallel batches of 3 to stay within + * reasonable context window limits. + * + * Real implementation requires a connected LLM. (PR#5) + */ + +import type { ModelRouter } from '../../llm'; +import type { SwarmContext, AgentResult } from '../orchestrator'; +import type { ResearcherOutput } from './researcher'; +import type { AnalystOutput } from './analyst'; +import type { RiskOutput } from './risk'; + +// --------------------------------------------------------------------------- +// Chapter manifest (mirrors deal.html CH array and D-015) +// --------------------------------------------------------------------------- + +export const MEMO_CHAPTERS = [ + { id: 'customer', number: '01', act: 'WHY', title: 'The Customer' }, + { id: 'problem', number: '02', act: 'WHY', title: 'The Problem' }, + { id: 'solution', number: '03', act: 'WHY', title: 'The Solution' }, + { id: 'company', number: '04', act: 'WHAT', title: 'The Company' }, + { id: 'market', number: '05', act: 'WHAT', title: 'The Market (TAM/SAM/SOM)' }, + { id: 'comp', number: '06', act: 'WHAT', title: 'Competition' }, + { id: 'team', number: '07', act: 'WHO', title: 'The Team' }, + { id: 'traction', number: '08', act: 'HOW', title: 'Traction' }, + { id: 'moat', number: '09', act: 'HOW', title: 'Moat' }, + { id: 'macro', number: '10', act: 'SO-WHAT', title: 'Macro' }, + { id: 'micro', number: '11', act: 'SO-WHAT', title: 'Unit Economics' }, + { id: 'geo', number: '12', act: 'SO-WHAT', title: 'Geopolitics' }, + { id: 'risks', number: '13', act: 'SO-WHAT', title: 'Risks' }, + { id: 'verdict', number: '14', act: 'SO-WHAT', title: 'Verdict' }, +] as const; + +export type ChapterId = (typeof MEMO_CHAPTERS)[number]['id']; + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +export interface ChapterDraft { + id: ChapterId; + title: string; + /** Markdown prose, McKinsey-grade */ + body: string; + /** Cited facts/numbers extracted by the writer */ + data_points: string[]; + /** Source URLs cited in this chapter */ + sources: string[]; + /** True if the chapter needs human review before publication */ + needs_review: boolean; +} + +export interface WriterOutput { + chapters: ChapterDraft[]; + word_count: number; + notes: string; +} + +export interface WriterInputs { + research: ResearcherOutput; + analyst: AnalystOutput; + risk: RiskOutput; +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +const BATCH_SIZE = 3; // chapters per parallel batch + +export class WriterAgent { + constructor(private router: ModelRouter) {} + + async run(ctx: SwarmContext, inputs: WriterInputs): Promise> { + ctx.emit('writer:start', { + deal_id: ctx.deal_id, + chapters: MEMO_CHAPTERS.length, + }); + + const drafts: ChapterDraft[] = []; + + for (let i = 0; i < MEMO_CHAPTERS.length; i += BATCH_SIZE) { + const batch = MEMO_CHAPTERS.slice(i, i + BATCH_SIZE); + const results = await Promise.all( + batch.map(ch => this.writeChapter(ctx, ch, inputs)) + ); + drafts.push(...results); + ctx.emit('writer:progress', { + deal_id: ctx.deal_id, + completed: drafts.length, + total: MEMO_CHAPTERS.length, + }); + } + + const word_count = drafts.reduce( + (sum, ch) => sum + ch.body.split(/\s+/).filter(Boolean).length, + 0 + ); + + ctx.emit('writer:done', { deal_id: ctx.deal_id, word_count }); + + return { + agent: 'writer', + output: { chapters: drafts, word_count, notes: '' }, + cost_entries: [], // individual entries already recorded in router ledger + }; + } + + private async writeChapter( + ctx: SwarmContext, + chapter: (typeof MEMO_CHAPTERS)[number], + inputs: WriterInputs + ): Promise { + const call = await this.router.route({ + deal_id: ctx.deal_id, + agent: 'writer', + task_type: 'write', + system: WRITER_SYSTEM, + prompt: buildChapterPrompt(ctx, chapter, inputs), + max_tokens: 1500, + }); + + return parseChapterDraft(call.content, chapter.id, chapter.title); + } +} + +// --------------------------------------------------------------------------- +// Prompt / system +// --------------------------------------------------------------------------- + +function buildChapterPrompt( + ctx: SwarmContext, + chapter: (typeof MEMO_CHAPTERS)[number], + inputs: WriterInputs +): string { + return [ + `Write chapter ${chapter.number}: "${chapter.title}" for the ${ctx.deal.name ?? ctx.deal_id} investment memo.`, + `Narrative act: ${chapter.act}`, + '', + `Deal record (JSON): ${JSON.stringify(ctx.deal)}`, + '', + `Market research summary: ${inputs.research.market_summary}`, + `Financial analysis notes: ${inputs.analyst.notes}`, + `Risk overview: overall_severity=${inputs.risk.overall_severity}, ` + + `red_flags=[${inputs.risk.red_flags.join('; ')}]`, + '', + 'Requirements:', + '- McKinsey IC-quality prose, 200–400 words.', + '- Cite every factual claim with [source: URL].', + '- No invented data, no filler, no lorem ipsum.', + '- If data is missing for a claim, omit the claim or state the gap explicitly.', + '- Return JSON: { body: string, data_points: string[], sources: string[], needs_review: boolean }', + ].join('\n'); +} + +const WRITER_SYSTEM = + `You are the DealLens memo writer. Write investment memo chapters at McKinsey IC quality. ` + + `Be precise, direct, and evidence-backed. Every factual claim must cite a source. ` + + `Never invent data to fill gaps — acknowledge gaps honestly.`; + +// --------------------------------------------------------------------------- +// Output parsing +// --------------------------------------------------------------------------- + +function parseChapterDraft( + content: string, + id: ChapterId, + title: string +): ChapterDraft { + try { + const parsed = JSON.parse(content) as Partial; + if (typeof parsed.body === 'string') { + return { + id, + title, + body: parsed.body, + data_points: parsed.data_points ?? [], + sources: parsed.sources ?? [], + needs_review: parsed.needs_review ?? false, + }; + } + } catch { /* mock stub string */ } + + return { + id, + title, + body: content, + data_points: [], + sources: [], + needs_review: true, // always flag mock output for human review + }; +} diff --git a/lib/swarm/orchestrator.ts b/lib/swarm/orchestrator.ts new file mode 100644 index 0000000..3c4378b --- /dev/null +++ b/lib/swarm/orchestrator.ts @@ -0,0 +1,207 @@ +/** + * SwarmOrchestrator + * Hierarchical deal enrichment pipeline: + * plan → dispatch → gather → compose → critique → finalize + * + * Emits granular ProgressEvents throughout so the UI can show live status. + * Aggregates CostLedger across all agents and attaches it to the returned + * DealRecord, enabling the cost-transparency UI. + * + * Usage (mock mode): + * const router = new MockModelRouter(deal.id); + * const storage = new MockStorageClient(); + * const orch = new SwarmOrchestrator(router, storage); + * orch.onProgress((type, payload) => console.log(type, payload)); + * const enriched = await orch.run(deal); + */ + +import type { ModelRouter, CostLedger, CostEntry } from '../llm'; +import type { StorageClient } from '../storage'; +import type { ResearcherOutput } from './agents/researcher'; +import type { AnalystOutput } from './agents/analyst'; +import type { RiskOutput } from './agents/risk'; +import type { WriterOutput } from './agents/writer'; +import type { CriticOutput } from './agents/critic'; +import { ResearcherAgent } from './agents/researcher'; +import { AnalystAgent } from './agents/analyst'; +import { RiskAgent } from './agents/risk'; +import { WriterAgent } from './agents/writer'; +import { CriticAgent } from './agents/critic'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Minimal DealRecord shape. + * Superset of deals/pqc-bank.json; swarm output and cost ledger are injected. + */ +export interface DealRecord { + id: string; + name?: string; + stage?: string; + sector?: string; + hq?: string; + url?: string; + thesis?: string; + ask?: number; + premoney?: number; + lead?: string; + icp?: string; + jtbd?: string; + wtp?: string; + founders?: string[]; + arr?: number; + mom?: number; + logos?: number; + nrr?: number; + status?: string; + submitted_at?: string; + /** Populated by SwarmOrchestrator after a successful run */ + swarm_output?: SwarmOutput; + /** Aggregated cost ledger across all agent calls */ + cost_ledger?: CostLedger; +} + +export interface SwarmContext { + deal_id: string; + deal: DealRecord; + emit: ProgressEmitter; +} + +export type ProgressEmitter = ( + type: string, + payload: Record +) => void; + +export interface AgentResult { + agent: string; + output: T; + cost_entries: CostEntry[]; +} + +export interface SwarmOutput { + research: ResearcherOutput; + analyst: AnalystOutput; + risk: RiskOutput; + memo: WriterOutput; + critique: CriticOutput; +} + +export interface OrchestratorSummary { + deal_id: string; + approved: boolean; + pass_rate: number; + total_usd: number; + memo_word_count: number; + blocking_failures: string[]; + completed_at: string; +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +export class SwarmOrchestrator { + private progressListeners: Array< + (type: string, payload: Record) => void + > = []; + + constructor( + private router: ModelRouter, + private storage: StorageClient + ) {} + + /** + * Register a progress listener. + * Listeners receive (type: string, payload: object) on every emit. + */ + onProgress( + fn: (type: string, payload: Record) => void + ): void { + this.progressListeners.push(fn); + } + + /** + * Run the full enrichment pipeline for a deal. + * Returns the enriched DealRecord with swarm_output and cost_ledger attached. + */ + async run(deal: DealRecord): Promise { + const emit: ProgressEmitter = (type, payload) => { + for (const fn of this.progressListeners) fn(type, payload); + }; + + const ctx: SwarmContext = { deal_id: deal.id, deal, emit }; + + // ------------------------------------------------------------------ + // PHASE 1: PLAN + // ------------------------------------------------------------------ + const plan = ['researcher', 'analyst', 'risk', 'writer', 'critic']; + emit('plan', { deal_id: deal.id, plan }); + + // ------------------------------------------------------------------ + // PHASE 2: DISPATCH — run researcher, analyst, risk in parallel + // ------------------------------------------------------------------ + emit('dispatch', { deal_id: deal.id, agents: ['researcher', 'analyst', 'risk'] }); + + const [researchResult, analystResult, riskResult] = await Promise.all([ + new ResearcherAgent(this.router).run(ctx), + new AnalystAgent(this.router).run(ctx), + new RiskAgent(this.router).run(ctx), + ]); + + // ------------------------------------------------------------------ + // PHASE 3: GATHER + COMPOSE — writer uses all parallel results + // ------------------------------------------------------------------ + emit('compose:start', { deal_id: deal.id }); + + const writerResult = await new WriterAgent(this.router).run(ctx, { + research: researchResult.output, + analyst: analystResult.output, + risk: riskResult.output, + }); + + emit('compose:done', { deal_id: deal.id }); + + // ------------------------------------------------------------------ + // PHASE 4: CRITIQUE + // ------------------------------------------------------------------ + const criticResult = await new CriticAgent(this.router).run( + ctx, + writerResult.output + ); + + // ------------------------------------------------------------------ + // PHASE 5: FINALIZE — aggregate ledger + emit summary + // ------------------------------------------------------------------ + const ledger = this.router.getCostLedger(); + + const summary: OrchestratorSummary = { + deal_id: deal.id, + approved: criticResult.output.approved, + pass_rate: criticResult.output.pass_rate, + total_usd: ledger.total_usd, + memo_word_count: writerResult.output.word_count, + blocking_failures: criticResult.output.blocking_failures, + completed_at: new Date().toISOString(), + }; + + emit('finalize', { deal_id: deal.id, summary }); + + // ------------------------------------------------------------------ + // Return enriched deal record + // ------------------------------------------------------------------ + return { + ...deal, + status: criticResult.output.approved ? 'memo-ready' : 'needs-review', + swarm_output: { + research: researchResult.output, + analyst: analystResult.output, + risk: riskResult.output, + memo: writerResult.output, + critique: criticResult.output, + }, + cost_ledger: ledger, + }; + } +} diff --git a/submit.html b/submit.html index f8dbb6b..c908618 100644 --- a/submit.html +++ b/submit.html @@ -5,7 +5,7 @@ Submit a deal · DealLens