Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions docs/articles/langchain-vc-memo-agent/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
---
title: VC Investment Memo Agent with LangGraph
description: Build an auditable, citation-grounded VC research agent with LangGraph and the Perplexity Agent API, and pick the best search provider with a LangSmith eval harness.
sidebar_position: 3
keywords: [langgraph, langchain, langsmith, agent api, investment memo, evaluation]
---

# Building an Auditable VC Investment Memo Agent with LangGraph and the Perplexity Agent API

## Overview

This guide builds an agent that takes a company name and returns a citation-grounded VC investment memo with seven sections: Snapshot, Team, Financials, Product, Market, Risks, and a Thesis ending in a one-line recommendation. **Every claim is traced back to a primary source.**

It runs on the [Perplexity Agent API](https://docs.perplexity.ai/docs/agent-api/tools) and its built-in `web_search` and `finance_search` tools, orchestrated with [LangGraph](https://langchain-ai.github.io/langgraph/), and evaluated in [LangSmith](https://docs.langchain.com/langsmith/home). The whole build runs in about ninety seconds for roughly $0.40 per memo.

The design lesson generalizes beyond finance: **separating search from synthesis is a structural reliability fix for a research agent.** Four research nodes fan out in parallel, each calling the Agent API with its own tools. A final synthesizer node has no tools and can only cite evidence the research nodes already gathered, so the memo cannot invent a source.

![Company name flows into START, fans out to four parallel research nodes (team, financials, product, market), which all feed a single synthesizer node that produces the memo at END.](../../static/img/langchain-vc-memo-architecture.png)

## Features

- **Parallel research fan-out.** Four focused research nodes (team, financials, product, market) run concurrently, each with its own tools and search budget.
- **Tool-less synthesizer.** The final memo is composed only from upstream evidence, a structural guard against fabricated citations.
- **Built-in Agent API tools.** `web_search` and `finance_search` work out of the box; no client-side search plumbing for the core agent.
- **Auditable in LangSmith.** Every node's tool calls and outputs are captured, so any claim traces back to the search result that produced it.
- **Provider eval harness.** A LangSmith comparison that scores search providers on primary-source rate, financial-concept coverage, latency, and cost.

## Prerequisites

- Python 3.10+
- A [Perplexity API key](https://docs.perplexity.ai/docs/admin/api-key-management) (`PPLX_API_KEY`)
- A [LangSmith API key](https://docs.smith.langchain.com/) for tracing and evaluation
- (provider comparison only) [Parallel](https://platform.parallel.ai/) and [Exa](https://dashboard.exa.ai/) API keys

## Installation

```bash
pip install -r scripts/requirements.txt
```

```bash
# ChatPerplexity reads PPLX_API_KEY.
export PPLX_API_KEY="pplx-..."
export LANGSMITH_API_KEY="ls__..."
export LANGSMITH_TRACING="true" # capture every node's tool calls end-to-end
```

## How it works

### Graph state

Each research node reads `company` from the shared state and writes its findings into `research_output`; a reducer merges the parallel writes.

```python
def merge_research_output(left: dict[str, str], right: dict[str, str]) -> dict[str, str]:
"""Each research node returns {"<section>": "..."}; merge into one dict."""
return {**(left or {}), **(right or {})}


class MemoState(TypedDict):
company: str
research_output: Annotated[dict[str, str], merge_research_output]
memo: str
```

### Models and tools

The Agent API exposes Perplexity's built-in tools directly. The financials node adds `finance_search`; the rest use `web_search`. `max_steps` caps each node's internal search loop.

```python
def _agent_model(model: str) -> ChatPerplexity:
# The Responses (Agent) API ignores sampling params like temperature, so we omit it.
return ChatPerplexity(model=model, use_responses_api=True)

TEAM_TOOLS = [{"type": "web_search", "filters": {"search_recency_filter": "year"}}]
FINANCIALS_TOOLS = [{"type": "finance_search"}, {"type": "web_search"}]
PRODUCT_TOOLS = MARKET_TOOLS = [{"type": "web_search"}]
```

### The synthesizer

The synthesizer has no tools. It composes all seven memo sections from the four nodes' research outputs, so every cited claim is grounded in research one of the nodes actually did. Sections 1–6 each end with a `### Citations` list pairing every source URL with the evidence it supports. The Thesis is the one analysis-only section, with no citations.

```python
def synthesizer_node(state: MemoState) -> dict[str, str]:
"""Combine all research outputs into the final memo. No tools attached."""
research_output_block = "\n\n".join(
f"## Research output: {name}\n\n{body}"
for name, body in sorted(state["research_output"].items())
)
msg = SYNTHESIZER_MODEL.invoke([
{"role": "system", "content": SYNTH_PROMPT.format(company=state["company"])},
{"role": "user", "content": f"Company: {state['company']}\n\n{research_output_block}"},
])
return {"memo": msg.content}
```

### Wiring the graph

Four research nodes fan out from `START` in parallel and converge on the synthesizer. The wiring is short:

```python
def build_graph():
g = StateGraph(MemoState)
g.add_node("team", team_node)
g.add_node("financials", financials_node)
g.add_node("product", product_node)
g.add_node("market", market_node)
g.add_node("synthesizer", synthesizer_node)

for section in ("team", "financials", "product", "market"):
g.add_edge(START, section) # fan out from START, in parallel
g.add_edge(section, "synthesizer") # ...and back into the synthesizer

g.add_edge("synthesizer", END)
return g.compile()
```

### Running it

```bash
python -m memo --company "Anthropic"
```

The full agent lives in `scripts/memo/` (`graph.py`, `main.py`).

## Choosing a search provider

Which search provider should back the agent? `scripts/memo/profiles.py` runs the same graph with three swappable client-side search tools (`PerplexitySearchResults`, `ParallelSearchTool`, `ExaSearchResults`), and `scripts/memo/compare.py` scores them in LangSmith so the same metrics apply to each.

Two custom evaluators score memo quality, alongside LangSmith's built-in latency and cost:

- `primary_source_rate`: share of citations from primary sources (IR pages, SEC, official press) rather than aggregators.
- `financial_concept_coverage`: whether the Financials section covers valuation, revenue, funding, and operating metrics.

```bash
python -m memo.compare
```

### Results

Scored across ten public and private companies on `openai/gpt-5.5`:

| Metric | Perplexity | Parallel | Exa |
| --- | --- | --- | --- |
| Primary-source rate | **1.00** | 0.82 | 0.85 |
| Financial-concept coverage | **0.70** | 0.70 | 0.50 |
| Latency p50 (s/memo) | **91** | 192 | 143 |
| Cost (USD/memo) | **$0.38** | $0.60 | $0.67 |

Perplexity posted a perfect primary-source rate, the fastest memos, the lowest cost per run, and tied for the best financial-concept coverage on this run. Re-score the providers on your own dataset to see how they compare for your use case.

## Directory structure

```
scripts/
├── requirements.txt
├── .env.example
└── memo/
├── graph.py # typed state, parallel research nodes, tool-less synthesizer, build_graph()
├── main.py # CLI entrypoint (python -m memo --company "...")
├── profiles.py # the three swappable provider profiles
├── evaluators.py # LangSmith evaluators
├── eval_dataset.py # companies used as eval inputs
└── compare.py # runs the LangSmith comparison
```

## Links

- [Perplexity Agent API tools](https://docs.perplexity.ai/docs/agent-api/tools)
- [Finance Search](https://docs.perplexity.ai/docs/agent-api/finance-search)
- [LangChain Perplexity provider](https://docs.langchain.com/oss/python/integrations/providers/perplexity)
- [LangGraph](https://langchain-ai.github.io/langgraph/)
- [LangSmith evaluation](https://docs.langchain.com/langsmith/evaluation)

## Limitations

- **Know where it falls short.** The agent is only as strong as the primary sources it can find: solid for well-documented companies, shakier for thinly-covered private startups where little has been published.
- **The provider comparison uses web search only.** It swaps web-search backends, so the Perplexity profile there does not use `finance_search`; financial-concept coverage for private companies can vary run to run.
- **The section template is just a convention.** The seven sections and the PASS / TRACK / ADVANCE / LEAD scale are the format we picked; swap in whatever your team uses.
11 changes: 11 additions & 0 deletions docs/articles/langchain-vc-memo-agent/scripts/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# ChatPerplexity reads PPLX_API_KEY.
PPLX_API_KEY=pplx-...

# LangSmith — tracing is on from the start so every node's tool calls and
# token usage are captured end-to-end.
LANGSMITH_API_KEY=ls__...
LANGSMITH_TRACING=true

# Provider comparison only
PARALLEL_API_KEY=...
EXA_API_KEY=...
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .main import main


if __name__ == "__main__":
main()
48 changes: 48 additions & 0 deletions docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import asyncio
from langsmith import Client
from langsmith.evaluation import evaluate

from .eval_dataset import EVAL_COMPANIES
from .evaluators import primary_source_rate, financial_concept_coverage
from .profiles import PROFILES, ProviderProfile

DATASET_NAME = "vc-memo-eval-v2"
client = Client()


def upload_dataset() -> None:
"""Create the LangSmith eval dataset from EVAL_COMPANIES if it doesn't already exist."""
if any(d.name == DATASET_NAME for d in client.list_datasets()):
return
dataset = client.create_dataset(DATASET_NAME, description="VC memo evaluation")
for company in EVAL_COMPANIES:
client.create_example(
inputs={"company": company},
outputs={},
dataset_id=dataset.id,
)


async def _run_one(company: str, profile: ProviderProfile) -> dict:
"""Run the memo graph for one company under the given provider profile."""
graph = profile.build_graph()
final = await graph.ainvoke({"company": company, "research_output": {}, "memo": ""})
return {"memo_md": final["memo"]}


def main() -> None:
"""Upload the dataset and run a LangSmith evaluation for each provider profile."""
upload_dataset()
for name in ("perplexity", "parallel", "exa"):
profile = PROFILES[name]
evaluate(
lambda inputs, profile=profile: asyncio.run(_run_one(inputs["company"], profile)),
data=DATASET_NAME,
evaluators=[primary_source_rate, financial_concept_coverage],
experiment_prefix=f"memo-{name}",
max_concurrency=2,
)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# A mix of public and private companies across sectors. The evaluators below
# are search-quality metrics — no per-company ground truth required, so extend
# this with your own targets freely.
EVAL_COMPANIES = [
"Anduril", "Arm Holdings", "Cohere", "CrowdStrike", "Klaviyo",
"Mistral AI", "Palantir", "Perplexity", "Reddit", "xAI",
]
61 changes: 61 additions & 0 deletions docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import re
from urllib.parse import urlparse
from langsmith.evaluation import EvaluationResult, run_evaluator

URL_RE = re.compile(r"https?://\S+")

PRIMARY_HOST_RE = re.compile(
r"(^|\.)(investors?|ir|investorrelations?|press|news|newsroom|press-?releases?|media)\.",
re.IGNORECASE,
)
PRIMARY_DOMAINS = {"sec.gov", "edgar.sec.gov", "businesswire.com",
"prnewswire.com", "globenewswire.com"}
AGGREGATOR_DOMAINS = {"en.wikipedia.org", "crunchbase.com", "pitchbook.com",
"simplywall.st", "stockanalysis.com", "finance.yahoo.com",
"reddit.com", "medium.com", "macrotrends.net"}


def _classify(url: str, company: str) -> str:
"""Classify a cited URL as primary, aggregator, or neutral source."""
host = urlparse(url).netloc.lower()
if host in PRIMARY_DOMAINS or PRIMARY_HOST_RE.search(host):
return "primary"
co_words = [w.lower() for w in company.split() if len(w) > 3]
if any(w in host for w in co_words):
return "primary" # Company's own domain counts as primary
if host in AGGREGATOR_DOMAINS:
return "aggregator"
return "neutral"


@run_evaluator
def primary_source_rate(run, example) -> EvaluationResult:
"""Share of citations from primary sources (IR pages, SEC, official press)
rather than aggregators (Wikipedia, Crunchbase). Neutral domains are
excluded from the ratio."""
memo = run.outputs["memo_md"]
company = example.inputs["company"]
urls = [u.rstrip(".,)") for u in URL_RE.findall(memo)]
primary = sum(1 for u in urls if _classify(u, company) == "primary")
aggregator = sum(1 for u in urls if _classify(u, company) == "aggregator")
denom = primary + aggregator
score = primary / denom if denom else None
return EvaluationResult(key="primary_source_rate", score=score)


@run_evaluator
def financial_concept_coverage(run, example) -> EvaluationResult:
"""Of four financial concepts (valuation, revenue/ARR, funding, operating
metrics), how many appear in the Financials section?"""
memo = run.outputs["memo_md"]
m = re.search(r"##.*Financials.*?\n(.*?)(?=\n##\s|\Z)", memo, re.DOTALL | re.IGNORECASE)
if not m:
return EvaluationResult(key="financial_concept_coverage", score=0.0)
body = m.group(1).lower()
hits = sum([
bool(re.search(r"valuation|valued at|market cap|post-money|pre-money", body)),
bool(re.search(r"revenue|arr |annual recurring|run.?rate", body)),
bool(re.search(r"raised|series [a-h]|funding round|total funding", body)),
bool(re.search(r"gross margin|operating margin|cash position|growth|customers|headcount|employees", body)),
])
return EvaluationResult(key="financial_concept_coverage", score=hits / 4)
Loading
Loading