"""Synthesis / Lead-Engine (F-13). Generates four kinds of research **leads** over the RAG substrate: | Stage | Kind | Grounded? | Output dir | |-------|---------------|-----------|--------------------------| | 2 | connection | yes | ``leads/grounded/`` | | 3 | gap | yes | ``leads/grounded/`` | | 4 | improvement | yes | ``leads/grounded/`` | | 5 | conjecture | no — by definition unverifiable | ``leads/conjectures/`` | Hard invariant (F-13 HARD) -------------------------- **Conjectures are NEVER written to ``wiki/``.** They are quarantined to ``leads/conjectures/`` with mandatory ``status="unverified"`` plus a ``suggested_validation`` field describing the spike that would falsify them. The wiki layer (F-12) stays grounded-only. Conjectures live in their own sandbox so that human review can promote them after a spike succeeds. Grounding discipline -------------------- * For ``connection`` / ``gap`` / ``improvement`` leads we run the same content-5-gram grounding check as F-12 (re-used directly from :mod:`codex.wiki`). Every claim in the lead body that fails the check is marked with the ⚠ prefix and the lead is recorded as having ungrounded claims. Leads whose grounded-ratio is below the floor are dropped (returned as "ungrounded" rather than "grounded"). * For ``conjecture`` leads grounding by definition cannot apply (the claim does not exist in the corpus). Provenance is still mandatory: it points at the chunks that *seeded* the conjecture so a reviewer can trace the inspiration. Graceful degradation -------------------- * Missing ``lib_path`` → :func:`find_improvements` returns ``[]``. * DB unreachable → caller sees the psycopg error; no silent failures. * LLM unreachable (httpx.ConnectError / any exception) → the affected generator returns ``[]`` (matches the F-12 LLM degradation rule). """ from __future__ import annotations import json import logging import re from dataclasses import asdict, dataclass, field from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal, Protocol from codex.config import get_settings from codex.wiki import ( Claim, LLMClient, OllamaClient, _parse_claims, _retrieve_chunks, _run_grounding_guard, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Datamodel # --------------------------------------------------------------------------- LeadKind = Literal["connection", "gap", "improvement", "conjecture"] LeadStatus = Literal["unverified", "spiking", "go", "no-go"] @dataclass class Provenance: """Pointer back to a single source location for a lead. ``locator`` follows the same conventions as F-12 wiki claims: ``"chunk 16"``, ``"page 9"``, ``"eq.(9)"``, or — for ``improvement`` leads — a code locator like ``"src/cli.cpp:42"``. """ bibkey: str locator: str @dataclass class Lead: """A single research lead emitted by the synthesis engine. See module docstring for the four-stage taxonomy and the grounded-vs-conjecture invariant. """ id: str # "L-0001" kind: LeadKind title: str body: str provenance: list[Provenance] # mandatory — even for conjectures confidence: float # 0..1 status: LeadStatus = "unverified" # For conjectures: the falsification spike. Empty string for grounded leads. suggested_validation: str = "" # Derived: claim-level grounding decisions (empty for conjectures). claims: list[Claim] = field(default_factory=list) def __post_init__(self) -> None: if not self.provenance: raise ValueError( f"Lead '{self.id}' must carry at least one Provenance entry. " "For gap leads with no chunks, use a topic-marker Provenance." ) # --------------------------------------------------------------------------- # Protocols # --------------------------------------------------------------------------- class _ConnLike(Protocol): """Subset of ``psycopg.Connection`` used by this module (helps tests).""" def execute(self, sql: str, params: dict[str, Any] | None = ...) -> Any: ... # --------------------------------------------------------------------------- # LLM prompts # --------------------------------------------------------------------------- _CONNECTION_PROMPT = """\ You are a research analyst surfacing CONNECTIONS between papers. A connection is a non-trivial overlap (shared definitions, shared techniques, shared theorems, or one paper extending/generalising another). It is a DESCRIPTIVE statement about the corpus — not a conjecture. Use ONLY the source chunks below. For every factual claim, cite it inline in the format [BibKey #locator]. Example: "Both papers use the Lobachevsky function as the building block of volume formulas. [Springborn2008 #chunk 16] [BobenkoPinkallSpringborn2015 #chunk 3]" Do NOT invent facts. Do NOT speculate. Two short paragraphs maximum. SOURCE CHUNKS: {chunks_block} Now describe a single non-trivial connection between these papers: """ _GAP_PROMPT = """\ You are a research analyst identifying GAPS in a corpus. A gap is an ABSENCE: a topic, technique, or comparison that the corpus fails to cover, even though adjacent material exists. Make the absence concrete by pointing at where the corpus DOES talk about adjacent things. Use ONLY the source chunks below. For every factual claim about what IS in the corpus, cite it inline in the format [BibKey #locator]. The absence itself does not need a citation; the surrounding context does. Do NOT invent facts. Two short paragraphs maximum. SOURCE CHUNKS: {chunks_block} Now describe a single concrete gap in the corpus's coverage of "{topic}": """ _IMPROVEMENT_PROMPT = """\ You are a research analyst inspecting C++ code annotated with @cite tags. For the symbol below, a literature paper is cited. Read the chunks and suggest at most one CONCRETE improvement to the code that the literature would support. The improvement must be a DESCRIPTIVE statement of what the paper says, not a conjecture. Use ONLY the source chunks. Cite each claim inline as [BibKey #locator]. Symbol: {symbol} Cited bibkey: {bibkey} File:locator: {file_locator} SOURCE CHUNKS: {chunks_block} Now state a single grounded improvement (or "No improvement suggested." if the chunks do not support any): """ _CONJECTURE_PROMPT = """\ You are a creative researcher proposing CONJECTURES — novel hypotheses that EXTEND but DO NOT contradict the corpus. A conjecture is a NEW idea not yet present in the literature. By definition it cannot be grounded in the chunks. Your output MUST be honest about this: do not pretend it is a result already in the papers. You MUST produce: 1. A one-sentence title (the conjecture itself). 2. A short body (2-4 sentences) explaining the intuition. 3. A "Validation:" line stating ONE concrete spike — a small experiment, counter-example search, or proof attempt — that would FALSIFY the conjecture if it is wrong. Without a falsification path the conjecture is rejected. Use the chunks below ONLY as inspiration. Cite seed material as [BibKey #locator] in the body so a reviewer can trace where the idea came from. SOURCE CHUNKS: {chunks_block} Now propose one conjecture in the format: Title: Body: <2-4 sentences with [BibKey #locator] seed citations> Validation: """ # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _format_chunks(chunks: list[dict[str, Any]]) -> str: """Render chunks for the LLM prompt — same format as F-12.""" lines: list[str] = [] for chunk in chunks: bibkey = chunk.get("bibkey") or chunk["paper_id"] ord_val = chunk.get("ord", "?") lines.append(f"[{bibkey} #chunk {ord_val}]\n{chunk['content'].strip()}\n") return "\n---\n".join(lines) def _mark_ungrounded(body: str, claims: list[Claim]) -> str: """Prefix each ungrounded claim's last sentence with ⚠ in *body*.""" for claim in claims: if claim.grounded: continue escaped = re.escape(claim.text.strip()) body = re.sub(rf"(? float: """Fraction of *claims* that passed the grounding check (0.0 if empty).""" if not claims: return 0.0 n_grounded = sum(1 for c in claims if c.grounded) return n_grounded / len(claims) _KIND_PREFIX: dict[LeadKind, str] = { "connection": "C", "gap": "G", "improvement": "I", "conjecture": "X", } def _make_lead_id(seq: int, kind: LeadKind) -> str: """Format a per-kind sequential lead id like ``L-C-0001``. Each synthesis stage numbers its leads from 1, so without a kind prefix a connection, a gap, and an improvement all produce ``L-0001`` and clobber one another in the shared ``grounded/`` directory (audit C-11). The kind prefix namespaces the ids so they stay distinct. """ return f"L-{_KIND_PREFIX[kind]}-{seq:04d}" def _safe_llm_generate(llm: LLMClient, prompt: str, model: str) -> str: """Call the LLM, return ``""`` on any exception (mirrors F-12 behaviour).""" try: return llm.generate(prompt, model=model) except Exception as exc: # noqa: BLE001 logger.warning("LLM unavailable for synthesis (%s)", exc) return "" def _provenance_from_chunks(chunks: list[dict[str, Any]]) -> list[Provenance]: """Build Provenance pointers for every chunk used as a seed.""" out: list[Provenance] = [] for chunk in chunks: bibkey = str(chunk.get("bibkey") or chunk.get("paper_id") or "") ord_val = chunk.get("ord", "?") out.append(Provenance(bibkey=bibkey, locator=f"chunk {ord_val}")) return out def _finalise_grounded_lead( *, seq: int, kind: LeadKind, title: str, body: str, chunks: list[dict[str, Any]], min_grounded_ratio: float, ) -> Lead | None: """Parse claims, run grounding guard, mark ⚠, return ``None`` if too sparse. Returns the populated :class:`Lead` or ``None`` if no parseable claims were found or the grounded ratio is below ``min_grounded_ratio``. """ claims = _parse_claims(body) if not claims: # No citations at all → cannot be a grounded lead. return None claims = _run_grounding_guard(claims, chunks) ratio = _grounded_ratio(claims) if ratio < min_grounded_ratio: return None marked = _mark_ungrounded(body, claims) return Lead( id=_make_lead_id(seq, kind), kind=kind, title=title, body=marked, provenance=_provenance_from_chunks(chunks), confidence=ratio, status="unverified", suggested_validation="", claims=claims, ) # --------------------------------------------------------------------------- # Stage 2 — connections # --------------------------------------------------------------------------- def find_connections( *, db_conn: _ConnLike | None = None, top_k: int, llm: LLMClient, ) -> list[Lead]: """Surface grounded connection leads across the corpus. Strategy: cluster the corpus by shared retrieval — for each paper pair that shows up together in the top-K of a common probe query we ask the LLM to articulate the connection. The probe queries are the bibkey/title pairs of the existing papers (cheap, no external config needed). Parameters ---------- db_conn: Unused right now (kept for the agreed signature so callers can inject a transactional connection later). top_k: Number of chunks retrieved per probe. llm: Injectable LLM client. Errors fall back to ``[]``. """ settings = get_settings() seq = 1 leads: list[Lead] = [] # Probe queries: use the discovered titles of already-ingested papers. # Without DB we can't get titles, so we fall back to a generic probe. titles = _list_paper_titles(db_conn) if not titles: return leads seen_pairs: set[tuple[str, str]] = set() for bibkey, title in titles: chunks = _retrieve_chunks([title], top_k=top_k) if not chunks: continue # Find chunks from a *different* bibkey — that's the connection signal. other_bibkeys = { str(c.get("bibkey") or "") for c in chunks if c.get("bibkey") and c.get("bibkey") != bibkey } for other in sorted(other_bibkeys): pair = tuple(sorted([bibkey, other])) if pair in seen_pairs: continue seen_pairs.add(pair) # type: ignore[arg-type] pair_chunks = [c for c in chunks if c.get("bibkey") in {bibkey, other}] prompt = _CONNECTION_PROMPT.format(chunks_block=_format_chunks(pair_chunks)) raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model) if not raw.strip(): continue lead = _finalise_grounded_lead( seq=seq, kind="connection", title=f"Connection: {bibkey} ⇄ {other}", body=raw.strip(), chunks=pair_chunks, min_grounded_ratio=settings.synthesis_min_grounded_ratio, ) if lead is None: continue leads.append(lead) seq += 1 return leads # --------------------------------------------------------------------------- # Stage 3 — gaps # --------------------------------------------------------------------------- def find_gaps( lib_path: str | None = None, *, db_conn: _ConnLike | None = None, llm: LLMClient, topics: list[str] | None = None, ) -> list[Lead]: """Surface grounded gap leads. Two complementary signals are used: * **Topic coverage map** — for each topic in ``topics`` (or, if ``None``, the union of paper titles), retrieve chunks and check bibkey coverage. A topic covered by < ``synthesis_gap_min_coverage`` bibkeys is reported as a gap candidate and the LLM is asked to articulate it. * **Code-vs-corpus gap** — when ``lib_path`` is given, any @cite bibkey scanned from code that is NOT present in the corpus is flagged. This catches the "cited in code, never ingested" case. """ settings = get_settings() seq = 1 leads: list[Lead] = [] probe_topics: list[str] = list(topics or []) if not probe_topics: probe_topics = [title for _, title in _list_paper_titles(db_conn)] known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)} # --- coverage-map gaps -------------------------------------------- for topic in probe_topics: chunks = _retrieve_chunks([topic], top_k=settings.synthesis_top_k) if not chunks: # No coverage at all → record the gap directly (LLM not strictly needed). leads.append( Lead( id=_make_lead_id(seq, "gap"), kind="gap", title=f"Gap: corpus has no material on '{topic}'", body=( f"The retrieval layer returned no chunks for topic '{topic}'.\n" "No grounded statement is possible; this is the absence itself." ), provenance=[Provenance(bibkey="(none)", locator=f"topic:{topic}")], confidence=1.0, status="unverified", suggested_validation=( "Ingest a paper covering this topic, re-run synthesis: the gap " "should disappear." ), ) ) seq += 1 continue bibkeys_for_topic = {str(c.get("bibkey") or "") for c in chunks if c.get("bibkey")} if len(bibkeys_for_topic) >= settings.synthesis_gap_min_coverage: # Topic is well covered — not a gap. continue prompt = _GAP_PROMPT.format(topic=topic, chunks_block=_format_chunks(chunks)) raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model) if not raw.strip(): continue lead = _finalise_grounded_lead( seq=seq, kind="gap", title=f"Gap: '{topic}' covered by only {len(bibkeys_for_topic)} bibkey(s)", body=raw.strip(), chunks=chunks, min_grounded_ratio=settings.synthesis_min_grounded_ratio, ) if lead is None: continue leads.append(lead) seq += 1 # --- code-vs-corpus gaps ------------------------------------------ if lib_path: from codex.provenance import scan_cite_tags try: hits = scan_cite_tags(lib_path) except FileNotFoundError: hits = [] cited_bibkeys = {h["bibkey"] for h in hits} missing = cited_bibkeys - known_bibkeys for bibkey in sorted(missing): leads.append( Lead( id=_make_lead_id(seq, "gap"), kind="gap", title=f"Gap: code cites '{bibkey}' but corpus does not contain it", body=( f"The bibkey `{bibkey}` is referenced by @cite tags in `{lib_path}`\n" "but no paper with this bibkey is ingested into the corpus.\n" "This is a concrete absence." ), provenance=[Provenance(bibkey=bibkey, locator="(not ingested)")], confidence=1.0, status="unverified", suggested_validation=( f"Ingest the paper for `{bibkey}` via `codex ingest`. The gap " "should disappear on the next synthesis run." ), ) ) seq += 1 return leads # --------------------------------------------------------------------------- # Stage 4 — improvements # --------------------------------------------------------------------------- def find_improvements( lib_path: str, *, db_conn: _ConnLike | None = None, top_k: int, llm: LLMClient, ) -> list[Lead]: """Suggest grounded code improvements based on @cite-linked literature. Walks ``lib_path`` for ``@cite `` markers, retrieves the relevant chunks for each cited bibkey, and asks the LLM to propose one concrete improvement supported by the paper. Improvements failing the grounding check are dropped. """ if not lib_path: return [] from codex.provenance import scan_cite_tags settings = get_settings() seq = 1 leads: list[Lead] = [] try: hits = scan_cite_tags(lib_path) except FileNotFoundError: return leads known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)} # Group hits by (symbol, bibkey) so we ask the LLM at most once per pair. by_key: dict[tuple[str, str], list[dict[str, str]]] = {} for hit in hits: if hit["bibkey"] not in known_bibkeys: continue # handled by find_gaps by_key.setdefault((hit["symbol"] or "(unknown)", hit["bibkey"]), []).append(hit) for (symbol, bibkey), group in by_key.items(): chunks = _retrieve_chunks([symbol, bibkey], top_k=top_k) # Restrict to chunks from the cited bibkey when possible same_bib = [c for c in chunks if c.get("bibkey") == bibkey] or chunks if not same_bib: continue sample = group[0] file_locator = f"{sample['file']}:{sample['line']}" prompt = _IMPROVEMENT_PROMPT.format( symbol=symbol, bibkey=bibkey, file_locator=file_locator, chunks_block=_format_chunks(same_bib), ) raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model) if not raw.strip() or "no improvement suggested" in raw.lower(): continue lead = _finalise_grounded_lead( seq=seq, kind="improvement", title=f"Improvement: {symbol} ({file_locator}) via {bibkey}", body=raw.strip(), chunks=same_bib, min_grounded_ratio=settings.synthesis_min_grounded_ratio, ) if lead is None: continue # Attach the code locator to the provenance list as the first entry lead.provenance.insert(0, Provenance(bibkey=bibkey, locator=file_locator)) leads.append(lead) seq += 1 return leads # --------------------------------------------------------------------------- # Stage 5 — conjectures # --------------------------------------------------------------------------- _CONJECTURE_RE = re.compile( r"Title:\s*(?P.+?)\n+" r"Body:\s*(?P<body>.+?)\n+" r"Validation:\s*(?P<validation>.+)", re.DOTALL | re.IGNORECASE, ) def _parse_conjecture_output(raw: str) -> tuple[str, str, str] | None: """Parse the LLM's three-section conjecture output. Returns ``(title, body, validation)`` or ``None`` if any section is missing or empty. """ match = _CONJECTURE_RE.search(raw) if not match: return None title = match.group("title").strip() body = match.group("body").strip() validation = match.group("validation").strip() if not title or not body or not validation: return None return title, body, validation def propose_conjectures( *, db_conn: _ConnLike | None = None, top_k: int, llm: LLMClient, seeds: list[str] | None = None, ) -> list[Lead]: """Generate stage-5 conjecture leads. Each conjecture is the LLM's own hypothesis seeded by a small retrieval slice. The output is by definition ungrounded, so: * ``status`` is hard-set to ``"unverified"``. * ``suggested_validation`` is mandatory; conjectures without a falsification path are dropped. * ``provenance`` records the seed chunks so a reviewer can trace inspiration. Conjectures are written to ``leads/conjectures/`` only — never to ``wiki/``. """ settings = get_settings() seq = 1 leads: list[Lead] = [] probe_seeds: list[str] = list(seeds or []) if not probe_seeds: probe_seeds = [title for _, title in _list_paper_titles(db_conn)] if not probe_seeds: return leads for seed in probe_seeds: chunks = _retrieve_chunks([seed], top_k=top_k) if not chunks: continue prompt = _CONJECTURE_PROMPT.format(chunks_block=_format_chunks(chunks)) raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model) if not raw.strip(): continue parsed = _parse_conjecture_output(raw) if parsed is None: # No falsification path → reject (hard invariant). continue title, body, validation = parsed provenance = _provenance_from_chunks(chunks) if not provenance: continue leads.append( Lead( id=_make_lead_id(seq, "conjecture"), kind="conjecture", title=title, body=body, provenance=provenance, confidence=0.0, # conjectures start unconfirmed status="unverified", # HARD invariant suggested_validation=validation, ) ) seq += 1 return leads # --------------------------------------------------------------------------- # DB helpers # --------------------------------------------------------------------------- def _list_paper_titles(db_conn: _ConnLike | None) -> list[tuple[str, str]]: """Return ``[(bibkey, title), …]`` for all ingested papers with a bibkey. Uses the injected connection when provided, otherwise opens a fresh one. Returns ``[]`` if the database is unreachable or has no rows. """ sql = "SELECT bibkey, title FROM papers WHERE bibkey IS NOT NULL" try: if db_conn is not None: rows = db_conn.execute(sql).fetchall() else: from codex.db import get_conn with get_conn() as conn: rows = conn.execute(sql).fetchall() except Exception as exc: # noqa: BLE001 logger.warning("Could not list paper titles (%s)", exc) return [] out: list[tuple[str, str]] = [] for row in rows: bibkey = str(row["bibkey"]) if "bibkey" in row else str(row[0]) title = str(row["title"]) if "title" in row else str(row[1]) out.append((bibkey, title)) return out # --------------------------------------------------------------------------- # Persistence # --------------------------------------------------------------------------- _GROUNDED_KINDS: frozenset[LeadKind] = frozenset({"connection", "gap", "improvement"}) def _lead_to_markdown(lead: Lead) -> str: """Render a Lead as a self-contained markdown document. Front-matter is a fenced JSON block so the file is human-readable and machine-parseable. """ meta = { "id": lead.id, "kind": lead.kind, "status": lead.status, "confidence": round(lead.confidence, 3), "provenance": [asdict(p) for p in lead.provenance], "suggested_validation": lead.suggested_validation, "compiled_at": datetime.now(UTC).isoformat(), } parts = [ f"# {lead.id} — {lead.title}", "", "```json", json.dumps(meta, indent=2, sort_keys=True), "```", "", lead.body.rstrip(), "", ] if lead.kind == "conjecture": parts.extend( [ "## Status", "", "**UNVERIFIED CONJECTURE — not grounded in the corpus.**", "Promotion to the wiki requires the validation spike below to succeed.", "", "## Suggested validation", "", lead.suggested_validation, "", ] ) return "\n".join(parts) def write_leads(leads: list[Lead], leads_dir: str) -> None: """Write ``leads`` to disk under ``leads_dir``. * Grounded leads → ``leads_dir/grounded/<id>.md`` * Conjectures → ``leads_dir/conjectures/<id>.md`` (HARD invariant) The function ALSO maintains an append-only ``leads_dir/INDEX.md``: a previously-recorded id is kept on subsequent runs even if it is overwritten with a newer body. The two sections (grounded / conjectures) are visibly separated. Notes ----- * The function asserts the kind→directory routing — a conjecture ending up under ``grounded/`` would be an invariant violation and is raised as :class:`RuntimeError`. """ root = Path(leads_dir) grounded_dir = root / "grounded" conj_dir = root / "conjectures" grounded_dir.mkdir(parents=True, exist_ok=True) conj_dir.mkdir(parents=True, exist_ok=True) for lead in leads: markdown = _lead_to_markdown(lead) if lead.kind == "conjecture": target_dir = conj_dir elif lead.kind in _GROUNDED_KINDS: target_dir = grounded_dir else: # pragma: no cover — defensive raise RuntimeError(f"Unknown lead kind: {lead.kind!r}") # HARD invariant: conjectures NEVER land in grounded/ or wiki/. if lead.kind == "conjecture" and target_dir != conj_dir: raise RuntimeError( f"INVARIANT VIOLATION: conjecture {lead.id} would be written " f"to {target_dir} instead of {conj_dir}" ) path = target_dir / f"{lead.id}.md" path.write_text(markdown, encoding="utf-8") # Update INDEX.md _update_index(root) def _update_index(root: Path) -> None: """Re-build ``root/INDEX.md`` from the on-disk files (full rebuild, not append-only).""" grounded_dir = root / "grounded" conj_dir = root / "conjectures" grounded_files = sorted(grounded_dir.glob("*.md")) if grounded_dir.exists() else [] conj_files = sorted(conj_dir.glob("*.md")) if conj_dir.exists() else [] ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") lines = [ "# Lead Index", "", f"_Last updated {ts} by `codex synthesis`_", "", "## Grounded leads (connection / gap / improvement)", "", ] if grounded_files: for f in grounded_files: title = _extract_title(f) or f.stem lines.append(f"- `{f.stem}` — {title}") else: lines.append("_None._") lines.extend(["", "## Conjectures (UNVERIFIED — quarantined here, never in wiki/)", ""]) if conj_files: for f in conj_files: title = _extract_title(f) or f.stem lines.append(f"- `{f.stem}` — {title}") else: lines.append("_None._") lines.append("") (root / "INDEX.md").write_text("\n".join(lines), encoding="utf-8") def _extract_title(path: Path) -> str | None: """Pull the H1 title from a lead markdown file, if present.""" try: first = path.read_text(encoding="utf-8").splitlines()[0] except (OSError, IndexError): return None if first.startswith("# "): return first[2:].strip() return None # --------------------------------------------------------------------------- # Default LLM client # --------------------------------------------------------------------------- def default_llm_client() -> LLMClient: """Return an :class:`OllamaClient` configured from the synthesis settings. Falls back to ``ollama_base_url`` when ``synthesis_llm_url`` is unset. """ settings = get_settings() url = settings.synthesis_llm_url or settings.ollama_base_url return OllamaClient(url)