Skip to content
Open
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
38 changes: 38 additions & 0 deletions docs/EXA-PODCAST-SEARCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Search podcast transcripts with Exa

Use the existing `exa.web.search` tool. Exa's [podcast library](https://exa.ai/library/podcast)
is part of its web search index, and `includeDomains` limits a search to that library.

```bash
treg call exa.web.search --method POST --data '{
"query": "podcast episodes where founders discuss pricing mistakes",
"includeDomains": ["exa.ai/library/podcast"],
"numResults": 2,
"contents": {"text": true}
}'
```

`contents.text` requests transcript text in each result. It may include speaker labels and
timestamps. A content limit such as `contents.text.maxCharacters` can truncate the returned text.
Leave out `includeDomains` to search the wider web and podcast index together.

## Response shape

This illustrative excerpt uses an [episode and transcript visible on Exa's public site](https://exa.ai/library/podcast/tpr3b0f359y/episode/s20kmckm3v9).
It shows the episode URL and timestamped text an agent can use; it is not a saved response from the
live Treg smoke test.

```json
{
"results": [
{
"url": "https://exa.ai/library/podcast/tpr3b0f359y/episode/s20kmckm3v9",
"title": "Carla Lalli Says Food Is A Feeling, from Expense Account",
"text": "Yuri Lisordo 0:00\nHi, this is Yuri Lisordo, the managing producer..."
}
]
}
```

Podcast searches use ordinary Exa search and content pricing. The amount can vary with the
request; use the actual charge reported by Treg for the call.
16 changes: 11 additions & 5 deletions src/treg/catalog/exa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,27 @@ endpoints:
method: POST
path: /search
name: "Search the web"
summary: "The search endpoint lets you intelligently search the web and extract contents from the results."
summary: "Search web pages and podcast episodes or transcripts, including guest appearances and podcast mentions; retrieve result content when needed."
input:
bodyType: json
body:
query: {type: string, required: true, note: "natural-language or keyword query; Exa's neural index understands meaning, so phrase it as a description of the page you want", example: "open source vector database written in rust"}
numResults: {type: integer, required: false, note: "1-100, default 10. The first 10 are covered by the base price; each result beyond 10 adds $0.001", example: 5}
type: {type: string, required: false, note: "auto (default) | fast | instant | lite | deep-lite | deep | deep-reasoning. auto/fast bill the base; instant/lite bill $0.004, see exa.web.search.lite; use exa.web.search.deep for the deep modes", example: "auto"}
category: {type: string, required: false, note: "focus on one sub-index: company | people | news | publication | personal site | financial report. The people/company/news/publication categories have their own curated routes below"}
includeDomains: {type: "array[string]", required: false, note: "only these hosts or host paths"}
includeDomains: {type: "array[string]", required: false, note: "only these hosts or host paths; use [\"exa.ai/library/podcast\"] for podcast-only results", example: ["exa.ai/library/podcast"]}
excludeDomains: {type: "array[string]", required: false}
startPublishedDate: {type: string, required: false, note: "ISO 8601; only pages published after this"}
endPublishedDate: {type: string, required: false, note: "ISO 8601"}
userLocation: {type: string, required: false, note: "two-letter ISO country code to localise results, e.g. US"}
contents: {type: object, required: false, note: "attach page content to each result: {text: true|{maxCharacters}, highlights: true|{query, maxCharacters}, summary: {query}}. Observed 2026-08-27: highlights on a search were not billed on top of the base; text/summary follow the $1/1k-page contents rate"}
note: "results carry url, title, publishedDate, author, score and any requested contents. Keep numResults at 10 or below to stay at the base price."
contents: {type: object, required: false, note: "attach page content to each result: {text: true|{maxCharacters}, highlights: true|{query, maxCharacters}, summary: {query}}. For podcast results, text retrieves transcript content that may include speaker labels and timestamps; maxCharacters can limit or truncate it. Observed 2026-08-27: highlights on a search were not billed on top of the base; text/summary follow the $1/1k-page contents rate", example: {text: true}}
note: >-
Results carry url, title, publishedDate, author, score and any requested contents.
Podcast transcript recipe: send this native Exa JSON body through exa.web.search:
{"query":"podcast episodes where founders discuss pricing mistakes","includeDomains":["exa.ai/library/podcast"],"numResults":2,"contents":{"text":true}}.
includeDomains restricts results to Exa's podcast library; contents.text requests transcript text.
Podcast searches use ordinary Exa search and content pricing, and Treg reports the actual charge for each call.
See https://exa.ai/library/podcast.
test_request:
body: {query: "open source vector database written in rust", numResults: 3}
verified: '2026-08-27'
Expand All @@ -63,7 +69,7 @@ endpoints:
source_url: https://exa.ai/pricing
checked: '2026-08-27'
confidence: verified
note: "$0.007 per call for up to 10 results, plus $0.001 per result beyond 10 (OBSERVED 2026-08-27: numResults 10 → costDollars.total 0.007; numResults 20 → 0.016, i.e. 19 returned = base + 9 riders). type fast billed the same 0.007; type instant bills the lite base, 0.004 (OBSERVED 2026-11-02). Platform billing settles the exact costDollars.total the response reports, so the rider is charged as Exa charges it."
note: "$0.007 base for up to 10 results, plus $0.001 per result beyond 10 and ordinary content charges when requested (OBSERVED 2026-08-27: numResults 10 → costDollars.total 0.007; numResults 20 → 0.016, i.e. 19 returned = base + 9 riders). Type fast billed the $0.007 base; type instant bills the lite base, $0.004 (OBSERVED 2026-11-02). Podcast search has no separate surcharge. Treg settles the exact costDollars.total Exa reports, so transcript requests need not cost the same amount."
docs_url: https://docs.exa.ai/reference/search

- id: exa.web.search.lite
Expand Down
31 changes: 31 additions & 0 deletions tests/test_catalog_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from httpx import AsyncClient

from treg import oauth_providers as P
from treg.domain.catalog import find_recall
from treg.domain.catalog import store as cs
from treg.domain.catalog.schema import CatalogSchemaError

Expand Down Expand Up @@ -384,6 +385,36 @@ async def test_search_finds_the_job_across_providers_best_first(clients: AsyncCl
assert any(h.startswith(f"treg catalog get {rows[0]['id']}") or h.startswith(f"treg catalog get {first['id']}") for h in body["hints"])


async def test_exa_podcast_transcripts_are_discoverable_with_a_native_request(clients: AsyncClient):
cat = cs.load()
index = find_recall.index(cat)
for query in ("Exa podcast search", "search podcast transcripts", "Exa web search"):
found = (await clients.get("/catalog/search", params={"q": query, "limit": 8})).json()
ids = [row["id"] for row in found["results"]]
assert "exa.web.search" in ids[:3], query
if query == "Exa web search":
assert "exa.web.search" in ids[:2], query
assert any(c.unit.id == "exa.web.search" for c in find_recall.recall(query, index, cat.aliases)), query

detail = (await clients.get("/catalog/endpoints/exa.web.search")).json()
endpoint = detail["endpoint"]
assert endpoint["method"] == "POST" and endpoint["path"] == "/search"
body = endpoint["input"]["body"]
assert body["includeDomains"]["example"] == ["exa.ai/library/podcast"]
assert body["contents"]["example"] == {"text": True}
note = endpoint["input"]["note"]
recipe = json.JSONDecoder().raw_decode(note[note.index('{"query":'):])[0]
assert recipe == {
"query": "podcast episodes where founders discuss pricing mistakes",
"includeDomains": ["exa.ai/library/podcast"],
"numResults": 2,
"contents": {"text": True},
}
assert "speaker labels and timestamps" in body["contents"]["note"]
assert "actual charge" in note and "need not cost the same" in endpoint["cost"]["note"]
assert "open source vector database written in rust" in detail["call_template"]


async def test_search_requires_every_token_to_match(clients: AsyncClient):
"""AND, not OR — a second word is a refinement, so it must be able to shrink the result set."""
broad = (await clients.get("/catalog/search", params={"q": "tiktok", "limit": 100})).json()
Expand Down
12 changes: 12 additions & 0 deletions tests/test_catalog_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ async def _rows():
return logs, misses


async def test_podcast_queries_surface_exa_as_a_strong_agent_search_result(clients, monkeypatch):
_v2(monkeypatch)
seen = []
monkeypatch.setattr(judge_infra, "judge", _fake_v2({"exa.web.search": 0.92}, seen, plat=("web", 0.9)))
for query in ("Exa podcast search", "search podcast transcripts"):
out = await _search(query)
assert out["verdict"] == "strong", query
assert out["results"][0]["endpoint_id"] == "exa.web.search", query
assert any(c["id"] == "exa.web.search" and "podcast" in c["summary"] and "transcripts" in c["summary"]
for c in seen[-1][1]), query


# ---------------------------------------------------------------- the page, as a pure function

def _found(verdict, groups, reason=""):
Expand Down
Loading