From cd47cb72d2a05e503c5eae8dee2111e2f1a4e81c Mon Sep 17 00:00:00 2001 From: Ryan Buchmayer Date: Thu, 11 Jun 2026 12:44:44 -0700 Subject: [PATCH 1/2] docs(articles): add VC memo agent scripts (guide lives in api-docs) Runnable code for the LangChain VC investment memo cookbook guide, published from ppl-ai/api-docs at /docs/cookbook/articles/langchain-vc-memo-agent/README. Scripts only; the article MDX intentionally lives in api-docs. --- .../langchain-vc-memo-agent/README.md | 13 ++ .../scripts/.env.example | 11 + .../scripts/memo/__init__.py | 0 .../scripts/memo/__main__.py | 5 + .../scripts/memo/compare.py | 48 +++++ .../scripts/memo/eval_dataset.py | 7 + .../scripts/memo/evaluators.py | 61 ++++++ .../scripts/memo/graph.py | 192 ++++++++++++++++++ .../scripts/memo/main.py | 23 +++ .../scripts/memo/profiles.py | 131 ++++++++++++ .../scripts/requirements.txt | 9 + 11 files changed, 500 insertions(+) create mode 100644 docs/articles/langchain-vc-memo-agent/README.md create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/.env.example create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/__init__.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/main.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/requirements.txt diff --git a/docs/articles/langchain-vc-memo-agent/README.md b/docs/articles/langchain-vc-memo-agent/README.md new file mode 100644 index 0000000..8e6d838 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/README.md @@ -0,0 +1,13 @@ +# VC Investment Memo Agent with LangGraph + +Runnable code for the cookbook guide at +https://docs.perplexity.ai/docs/cookbook/articles/langchain-vc-memo-agent/README + +```bash +cd scripts +pip install -r requirements.txt +export PPLX_API_KEY="pplx-..." +export LANGSMITH_API_KEY="ls__..." +export LANGSMITH_TRACING="true" +python -m memo --company "Anthropic" +``` diff --git a/docs/articles/langchain-vc-memo-agent/scripts/.env.example b/docs/articles/langchain-vc-memo-agent/scripts/.env.example new file mode 100644 index 0000000..b16789c --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/.env.example @@ -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=... diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/__init__.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py new file mode 100644 index 0000000..bcbfde6 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py @@ -0,0 +1,5 @@ +from .main import main + + +if __name__ == "__main__": + main() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py new file mode 100644 index 0000000..e30895a --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py @@ -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() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py new file mode 100644 index 0000000..bf5dfb5 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py @@ -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", +] diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py new file mode 100644 index 0000000..506aebb --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py @@ -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) diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py new file mode 100644 index 0000000..7233783 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated, Any, TypedDict + +from langchain_core.messages import AIMessage +from langchain_perplexity import ChatPerplexity +from langgraph.graph import END, START, StateGraph + + +def merge_research_output(left: dict[str, str], right: dict[str, str]) -> dict[str, str]: + """Each research node returns {"
": "..."}; 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 + + +SUBNODE_MODEL_NAME = "openai/gpt-5.5" +SYNTHESIZER_MODEL_NAME = "openai/gpt-5.5" + + +def _agent_model(model: str) -> ChatPerplexity: + """Build a ChatPerplexity client wired to the Responses API.""" + # The Responses (Agent) API ignores sampling params like temperature, so we omit it. + return ChatPerplexity(model=model, use_responses_api=True) + + +SUBNODE_MODEL = _agent_model(SUBNODE_MODEL_NAME) +SYNTHESIZER_MODEL = _agent_model(SYNTHESIZER_MODEL_NAME) + + +# Per-research-node tool specs. +TEAM_TOOLS = [{ + "type": "web_search", + "filters": {"search_recency_filter": "year"}, +}] + +PRODUCT_TOOLS = [{"type": "web_search"}] + +MARKET_TOOLS = [{"type": "web_search"}] + +FINANCIALS_TOOLS = [{"type": "finance_search"}, {"type": "web_search"}] + + +# Per-research-node max_steps caps the Perplexity Agent API's internal search loop. +RESEARCH_MAX_STEPS = { + "team": 2, "financials": 5, "product": 2, "market": 2, +} + + +RESEARCH_PROMPT = """You are a VC analyst writing the {section} section of the research output for {company}. + +{guidance} + +Return a markdown section, then end the document with a "### Citations" header \ +followed by a markdown list of: + + - — one-sentence evidence quoted from the source + +Cite only URLs that came back from your tool calls; never fabricate URLs. \ +Keep the section focused — 250-400 words is appropriate for the body.""" + + +GUIDANCE = { + "team": ( + "Search for the founders, CEO, and other named executives. Capture each " + "leader's prior roles and education. Prioritize the company's own About/Team " + "page and professional-network sources." + ), + "financials": ( + "If the company is public, use finance_search for revenue, margins, and analyst " + "estimates. If private, use web_search for funding rounds, valuation, and " + "disclosed revenue. Cross-check structured data against recent news." + ), + "product": ( + "Describe the company's flagship product, recent launches, and technical " + "differentiators. Cite the company's own product or engineering pages where " + "possible, plus tech-press coverage for context." + ), + "market": ( + "Map the competitive landscape, name direct competitors, and surface market " + "sizing. Your web_search is scoped to analyst and trade-press sources." + ), +} + + +def _run_research( + state: MemoState, + *, + section: str, + tools: list[dict[str, Any]], + max_steps: int, +) -> dict[str, dict[str, str]]: + """Run one research section with the given tools and return its output.""" + msg: AIMessage = SUBNODE_MODEL.invoke( + [ + {"role": "system", "content": RESEARCH_PROMPT.format( + section=section, company=state["company"], guidance=GUIDANCE[section], + )}, + {"role": "user", "content": f"Research the {section} of {state['company']}."}, + ], + tools=tools, + extra_body={"max_steps": max_steps}, + ) + return {"research_output": {section: msg.content}} + + +def team_node(state): + """Research the founders and leadership team.""" + return _run_research(state, section="team", + tools=TEAM_TOOLS, max_steps=RESEARCH_MAX_STEPS["team"]) + +def financials_node(state): + """Research revenue, funding, and financial metrics.""" + return _run_research(state, section="financials", + tools=FINANCIALS_TOOLS, max_steps=RESEARCH_MAX_STEPS["financials"]) + +def product_node(state): + """Research the product, launches, and technical differentiators.""" + return _run_research(state, section="product", + tools=PRODUCT_TOOLS, max_steps=RESEARCH_MAX_STEPS["product"]) + +def market_node(state): + """Research the competitive landscape and market sizing.""" + return _run_research(state, section="market", + tools=MARKET_TOOLS, max_steps=RESEARCH_MAX_STEPS["market"]) + + +SYNTH_PROMPT = """You are a senior VC partner writing the final memo for {company}. + +You may only cite evidence that appears in the research outputs below. You have no \ +tools; do not browse or fabricate sources. + +Produce a markdown memo with these seven sections, in order: + + 1. Snapshot — what the company is, founded, valuation, positioning (3-4 sentences) + 2. Team — founders, leadership, recent senior hires + 3. Financials — revenue, growth, funding history, comparables + 4. Product — what they sell, technology, distribution + 5. Market — TAM, direct competitors, category dynamics + 6. Risks — top 3-5 risks with brief reasoning + 7. Thesis — 1-2 paragraphs of analysis, ending with a single line: + "Recommendation: " + +Each section's H2 heading must be exactly `## ·
` \ +(e.g. `## 1 · Snapshot`), using a middle-dot separator — the evaluator depends \ +on this format. + +Each of sections 1-6 must end with a `### Citations` subsection listing the \ + pairs drawn from the research outputs. Section 7 (Thesis) does \ +not need its own citations. + +If a research output lacks evidence for a section, write "Insufficient evidence in \ +research outputs." in that section's body instead of guessing.""" + + +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: AIMessage = SYNTHESIZER_MODEL.invoke([ + {"role": "system", "content": SYNTH_PROMPT.format(company=state["company"])}, + {"role": "user", "content": ( + f"Company: {state['company']}\n" + f"As-of: {datetime.now(timezone.utc).isoformat(timespec='seconds')}\n\n" + f"Research outputs:\n\n{research_output_block}" + )}, + ]) + return {"memo": msg.content} + + +def build_graph(): + """Wire the four research nodes in parallel from START into the synthesizer, then END.""" + 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) + g.add_edge(section, "synthesizer") + + g.add_edge("synthesizer", END) + return g.compile() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/main.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/main.py new file mode 100644 index 0000000..ac76205 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/main.py @@ -0,0 +1,23 @@ +import argparse +import asyncio + +from .graph import build_graph + + +async def run_memo(company: str) -> str: + """Run the full memo agent for one company and return the final markdown memo.""" + graph = build_graph() + final = await graph.ainvoke({"company": company, "research_output": {}, "memo": ""}) + return final["memo"] + + +def main() -> None: + """CLI entrypoint: parse `--company` and print the generated memo.""" + parser = argparse.ArgumentParser(description="VC investment memo agent.") + parser.add_argument("--company", required=True) + args = parser.parse_args() + print(asyncio.run(run_memo(args.company))) + + +if __name__ == "__main__": + main() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py new file mode 100644 index 0000000..9780851 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from langchain_core.utils.function_calling import convert_to_openai_tool +from langchain_exa import ExaSearchResults +from langchain_parallel import ParallelSearchTool +from langchain_perplexity import ChatPerplexity, PerplexitySearchResults +from langgraph.graph import END, START, StateGraph + +from .graph import ( + GUIDANCE, RESEARCH_PROMPT, MemoState, SYNTH_PROMPT, +) + + +# Rich tool descriptions teach the LLM how to call each provider well -- +# small change, big impact on coverage. +TOOL_DESCRIPTIONS = { + "perplexity_search_results_json": ( + "Perplexity Search API: ranked web results with title, URL, snippet, and date. " + "Pass `query` as a concise search string and set " + "`search_recency_filter=\"year\"` for time-sensitive lookups." + ), + "parallel_web_search": ( + "Parallel Search API: pass `objective` as a one-sentence research goal plus " + "`search_queries` as 2-3 short keyword variants (3-6 words each)." + ), + "exa_search_results_json": ( + "Exa Search API: pass `query` as a natural-language string and set " + "`start_published_date` to ~12 months ago for time-sensitive lookups." + ), +} + + +@dataclass +class ProviderProfile: + name: str + build_graph: Callable[[], Any] + + +# Explicit parameter schemas keep each tool's surface small: the model only +# sees the filters this example actually uses, so its calls stay well-formed. +TOOL_PARAMETERS = { + "perplexity_search_results_json": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query."}, + "max_results": {"type": "integer", "description": "Maximum results to return."}, + "search_recency_filter": {"type": "string", "enum": ["day", "week", "month", "year"], + "description": "Restrict to a recent window."}, + }, + "required": ["query"], + }, +} + + +def _to_function_tool(tool): + """Convert a LangChain tool to an OpenAI function-tool spec, applying the overrides above.""" + spec = convert_to_openai_tool(tool) + fn = spec["function"] + fn["description"] = TOOL_DESCRIPTIONS.get(fn["name"], fn.get("description", "")) + if fn["name"] in TOOL_PARAMETERS: + fn["parameters"] = TOOL_PARAMETERS[fn["name"]] + return spec + + +def _research_node(section, sub_model, search_tool): + """Build a research node closure that loops tool calls until the model returns a final answer.""" + bound = sub_model.bind_tools([_to_function_tool(search_tool)]) + + def _research(state: MemoState) -> dict: + """Run the tool-calling loop for this section and return its research output.""" + history = [ + {"role": "system", "content": RESEARCH_PROMPT.format( + section=section, company=state["company"], guidance=GUIDANCE[section], + )}, + {"role": "user", "content": f"Research the {section} of {state['company']}."}, + ] + for _ in range(8): + msg = bound.invoke(history) + if not getattr(msg, "tool_calls", None): + return {"research_output": {section: msg.content}} + history.append(msg) + for tc in msg.tool_calls: + result = search_tool.invoke(tc["args"]) + history.append({"role": "tool", "tool_call_id": tc["id"], "content": str(result)}) + return {"research_output": {section: msg.content}} + + return _research + + +def _build_graph_for(sub_model, synth_model, search_tool): + """Build a StateGraph wired with the given models and a single search tool for all research nodes.""" + def _synth(state: MemoState) -> dict: + """Synthesize the final memo from all research outputs.""" + research_output_block = "\n\n".join( + f"## Research output: {name}\n\n{body}" + for name, body in sorted(state["research_output"].items()) + ) + msg = synth_model.invoke([ + {"role": "system", "content": SYNTH_PROMPT.format(company=state["company"])}, + {"role": "user", "content": f"Company: {state['company']}\n\nResearch outputs:\n\n{research_output_block}"}, + ]) + return {"memo": msg.content} + + g = StateGraph(MemoState) + g.add_node("synthesizer", _synth) + for section in ("team", "financials", "product", "market"): + g.add_node(section, _research_node(section, sub_model, search_tool)) + g.add_edge(START, section) + g.add_edge(section, "synthesizer") + g.add_edge("synthesizer", END) + return g.compile() + + +def _model() -> ChatPerplexity: + """Build the shared ChatPerplexity client used by every provider profile.""" + return ChatPerplexity(model="openai/gpt-5.5", use_responses_api=True) + + +def _build(search_tool): + """Shortcut: build a graph using the default model for both subnodes and synthesizer.""" + return _build_graph_for(sub_model=_model(), synth_model=_model(), search_tool=search_tool) + + +PROFILES = { + "perplexity": ProviderProfile("perplexity", lambda: _build(PerplexitySearchResults(max_results=8))), + "parallel": ProviderProfile("parallel", lambda: _build(ParallelSearchTool(max_results=8))), + "exa": ProviderProfile("exa", lambda: _build(ExaSearchResults(num_results=8))), +} diff --git a/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt new file mode 100644 index 0000000..97ce10f --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt @@ -0,0 +1,9 @@ +langchain-perplexity>=1.4.0 # bind_tools + Responses-API tool round-trip landed in langchain-ai/langchain#37934 +langgraph>=0.2.50 +langchain-core>=0.3 +langsmith>=0.2 +httpx>=0.27 + +# Provider comparison only +langchain-parallel>=0.4 +langchain-exa>=0.2 From 6067a2054091be35e72f49a16464c1e0773bc43b Mon Sep 17 00:00:00 2001 From: Ryan Buchmayer Date: Thu, 11 Jun 2026 14:43:27 -0700 Subject: [PATCH 2/2] docs(articles): note docs-page sync invariant at top of graph.py --- docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py index 7233783..c329b3e 100644 --- a/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py @@ -1,3 +1,7 @@ +# NOTE: The inline code blocks in the cookbook guide +# (docs/cookbook/articles/langchain-vc-memo-agent/README.mdx in ppl-ai/api-docs) +# are verbatim slices of this file. When editing either, update both in the +# same change. from __future__ import annotations from datetime import datetime, timezone