Research + Web Research
Files:src/cofounder_agent/services/research_service.pysrc/cofounder_agent/services/web_research.py
src/cofounder_agent/tests/unit/services/test_research_service.pysrc/cofounder_agent/tests/unit/services/test_web_research.py
Documented as a pair becauseResearchServicecallsWebResearcherandMultiModelQAcallsWebResearcherdirectly for fact-check. CLAUDE.md groups them under one row in the load-bearing services table.
What it does
ResearchService.build_context(topic) assembles a single
prompt-ready string that the writer prepends to the draft prompt so
generated content cites real sources instead of fabricated ones. It
fans out to three sources, in this priority order:
- Known reference database — a curated
{keyword: [{title, url}, ...]}map of official documentation links (FastAPI, PostgreSQL, Docker, etc.). Defaults are tech-oriented; operators in non-tech niches override the entire map viaapp_settings.known_references_json. - Internal links — published posts on the operator’s own site that share keywords with the topic, so the writer can build internal linking.
- Web search — DuckDuckGo via
WebResearcher.search_simple(), free and key-less.
WebResearcher is the lower-level web layer. It exposes two modes:
search() does DuckDuckGo + concurrent HTTP fetch + BeautifulSoup
text extraction (used for “give me real content”); search_simple()
returns just titles/URLs/snippets (used by ResearchService and the
QA fact-check gate, which only need the URL list).
The pair replaces an older Serper-based (0 DuckDuckGo. Intentionally graceful — if DDG rate-limits,
times out, or returns nothing, every entry-point logs a warning and
returns an empty list/string rather than crashing the pipeline.
Public API
research_service.py
ResearchService(pool=None, settings_service=None)— constructor.poolis an asyncpg pool (used only for the internal-links lookup);Nonedisables that source.await rs.build_context(topic, category="technology") -> str— formatted prompt block. Header sections includeVERIFIED REFERENCE LINKS,EXISTING POSTS ON OUR SITE,RECENT WEB SOURCES, and aCITATION GUIDANCEfooter telling the writer how to use the links.categoryis reserved (no behavior yet).get_known_references() -> dict[str, list[dict[str, str]]]— module function that returns the active reference map. Readsknown_references_jsonfrom app_settings; falls back to_DEFAULT_KNOWN_REFERENCESif unset, malformed, or shape-invalid. Entries are normalized to lowercase keys with{title, url}shape.KNOWN_REFERENCES— backward-compat module alias to the DEFAULT map. New callers should useget_known_references().await research_topic(query, max_sources=None) -> str— module-level shim used by the TWO_PASS writer mode (modules/content/atoms/two_pass_writer.py) to fill[EXTERNAL_NEEDED]markers without needing a DB pool. WrapsResearchService(pool=None).build_context(query). Returns"[research stub for: <query>]"on failure so the writer keeps moving.
web_research.py
WebResearcher()— constructor (no args, no state).await wr.search(query, num_results=5) -> list[dict]— full pipeline: DuckDuckGo search, then concurrent HTTP fetch + content extraction for each result. Each dict hastitle,url,snippet,content.await wr.search_simple(query, num_results=5) -> list[dict]— search only (no content fetch). Faster; used when the caller only needs URLs and snippets.wr.format_for_prompt(results, max_chars=3000) -> str— render a result list as a markdown-formatted prompt block withWEB RESEARCH (current sources — cite these, do not fabricate URLs):header. Truncates atmax_charsto control prompt budget.
Configuration
All fromapp_settings via site_config.
research_service.py
known_references_json(default empty — falls back to built-in_DEFAULT_KNOWN_REFERENCES). JSON object mapping lowercase keyword to a list of{title, url}entries. Customize per niche (cooking, gardening, legal) so the writer cites authoritative sources for that field. Malformed JSON logs a warning and falls back to defaults.writer_rag_research_topic_max_sources(default2) — advisory cap used by theresearch_topic()shim. Currently logged but not enforced; the underlyingbuild_contextcaps internally (5 web results, 8 references). Plumbing a true cap requires refactoringbuild_context(out of scope for the migration that introduced it).
web_research.py
All read at call time through _web_research_int(key, default) so live
app_settings changes apply without a restart (#198):
web_research_max_content_chars(default2000) — per-page text extraction cap.web_research_fetch_timeout_seconds(default10) — per-URL HTTP timeout.web_research_max_concurrent(default3) — fetch parallelism semaphore forsearch().web_research_search_timeout_seconds(default20) — hard cap on the DuckDuckGo call itself;asyncio.wait_forenforces this so a hung DDG request can’t stall the pipeline.
Dependencies
- Reads from:
poststable (status = ‘published’) for internal-link candidates via the ILIKE-by-word-overlap query.- DuckDuckGo (via the
ddgspackage). - Arbitrary HTTP origins via
httpx.AsyncClient(User-AgentMozilla/5.0 (compatible; ContentResearcher/1.0)). services.site_configfor the tunables above.
- Writes to: nothing. Both services are read-only / pure.
- External APIs: DuckDuckGo (no key) + outbound HTTP fetches.
- Sister-service callers (non-exhaustive):
modules.content.stages.generate_content— primary writer pipeline (moved fromservices/stages/in the 2026-06-04 content-module migration).modules.content.atoms.two_pass_writer— TWO_PASS revise loop via theresearch_topic()shim (moved fromservices/writer_rag_modes/).modules.content.multi_model_qa— the web fact-check gate constructs its ownWebResearcher()directly.services.title_generation— title generator searches for competing titles in the niche.services.topic_sources.web_search— topic discovery tap.
Failure modes
- DuckDuckGo rate-limited / network down —
WebResearcher._ddg_searchcatches the exception, logs[RESEARCH] DuckDuckGo search failed: ...at warning level, returns[]. Callers then return an empty section;build_contextstill returns whatever other sections it could assemble. - DuckDuckGo hangs —
asyncio.wait_foraborts afterweb_research_search_timeout_seconds. Logged as[RESEARCH] DuckDuckGo search timed out after Ns. ddgspackage missing — thefrom ddgs import DDGSraisesImportError, which_ddg_searchcatches, logs at warning level, and returns[].- HTTP fetch fails or returns non-200 —
_extract_contentreturns empty string. The caller still keeps the title/URL/snippet from the search step. - Internal-links DB query fails — caught, logged at debug,
returns
[]. Pool=None short-circuits to[]without touching the DB at all. research_topic()shim — any exception — returns"[research stub for: <query>]"so the TWO_PASS writer can keep going; the downstream validator will still flag a missing citation.- Malformed
known_references_json—get_known_references()logs a warning and falls back to the built-in defaults. No crash propagation.
Common ops
- Bring your own niche references (replace tech defaults):
- Tighten DDG timeout when it’s hanging:
poindexter settings set web_research_search_timeout_seconds 10 - Test research output for a topic without running the pipeline:
- Spot-check what DDG returns — same shape, faster:
WebResearcher().search_simple("topic", num_results=3). - Audit research-quality of recent generations — research output
isn’t persisted; rerun
build_context()against the post’s topic to see what the writer was given. (Persisting research output is TBD — needs operator confirmation if this becomes a recurring need.)
See also
docs/architecture/services/multi_model_qa.md— how the web fact-check gate usesWebResearcherfor adversarial QA.docs/architecture/anti-hallucination.md— research is the FIRST line of defense (real citations beat downstream link-verification).docs/architecture/content-pipeline.md— pipeline ordering.feedback_no_paid_apis(operator design note) — why DuckDuckGo replaced Serper.