From ccf51270b05dba93b2d7dd99d126772c40ac1486 Mon Sep 17 00:00:00 2001 From: ItayUliel Date: Wed, 22 Jul 2026 20:02:38 +0300 Subject: [PATCH 1/2] feat: make max_pages optional (omit for no page limit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A source had to specify max_pages. Make it optional across both services: omitting it (or passing null) means "no page limit" — crawl all in-scope pages, still bounded by same-host + include/exclude prefix scoping. - config.SourceConfig / retrieval.ProposedSourceConfig: max_pages is now `int | None = Field(default=None, gt=0)` — optional, and positive only when provided. The DB column was already NULLABLE; NULL now means unlimited (schema comment updated). - crawler.crawl(): when max_pages is None, use sys.maxsize internally as the cap (page_cap) so the existing sitemap/BFS/llms bound checks and slicing need no special-casing. - propose_doc_source MCP tool + retrieval.propose_source: max_pages optional (default None); docstring updated (omit for no limit; positive if given). - admin: form field no longer required (blank = no limit); dropped the None -> 100 fallbacks in _record_to_config and sources_repo._row_to_record so an unset limit stays unlimited instead of silently becoming 100; index view shows "unlimited". Tests: omitting max_pages is accepted and yields None (config + ProposedSource); a None-limit sitemap crawl fetches all in-scope pages; propose with max_pages=None writes NULL; zero/negative still rejected. Updated the two tests that asserted max_pages was required. --- db/init/02_sources_config.sql | 12 +++++------- ingestion/app/admin.py | 8 ++++---- ingestion/app/config.py | 4 ++-- ingestion/app/crawler.py | 19 ++++++++++++------ ingestion/app/sources_repo.py | 2 +- ingestion/app/templates/admin/form.html | 4 ++-- ingestion/app/templates/admin/index.html | 2 +- ingestion/tests/test_config.py | 19 +++++++++++++++++- ingestion/tests/test_crawler.py | 25 ++++++++++++++++++++++++ ingestion/tests/test_sources_repo.py | 9 ++++----- mcp-server/app/retrieval.py | 4 ++-- mcp-server/app/server.py | 10 ++++++---- mcp-server/tests/test_server.py | 18 +++++++++++++++++ 13 files changed, 101 insertions(+), 35 deletions(-) diff --git a/db/init/02_sources_config.sql b/db/init/02_sources_config.sql index 3b19d0d..5cf005c 100644 --- a/db/init/02_sources_config.sql +++ b/db/init/02_sources_config.sql @@ -25,13 +25,11 @@ -- fresh-volume schema looks like" and "what the live db was migrated to" — -- exactly the bug class this file exists to avoid. -- --- max_pages is intentionally left NULLABLE here: the pydantic SourceConfig --- model (ingestion/app/config.py) is what enforces `max_pages` is required --- and > 0 on every write path (sync/propose). Enforcing NOT NULL at the SQL --- layer would break the 9 existing rows that predate this column with no --- backfill value to give them. This split (permissive schema + strict --- application-layer validation) is deliberate — do not "fix" it by adding a --- NOT NULL constraint here. +-- max_pages is NULLABLE and NULL is meaningful: it means "no page limit" +-- (crawl all in-scope pages). The pydantic SourceConfig model +-- (ingestion/app/config.py) makes `max_pages` OPTIONAL and, when provided, +-- enforces it is > 0 on every write path (sync/propose). Do not add a +-- NOT NULL constraint here — a NULL is a valid, intentional "unlimited". ALTER TABLE doc_sources ADD COLUMN IF NOT EXISTS sitemap TEXT, diff --git a/ingestion/app/admin.py b/ingestion/app/admin.py index c1401a7..1e764ec 100644 --- a/ingestion/app/admin.py +++ b/ingestion/app/admin.py @@ -519,7 +519,7 @@ def _build_source_config( sitemap=sitemap.strip() or None, include_prefixes=_split_prefixes(include_prefixes), exclude_prefixes=_split_prefixes(exclude_prefixes), - max_pages=max_pages.strip(), + max_pages=(max_pages.strip() or None), language=language.strip() or "english", rate_limit_rps=rate_limit_rps.strip(), llms_txt=(llms_txt.strip() or "auto"), @@ -543,7 +543,7 @@ def _record_to_config(record: SourceRecord) -> SourceConfig: sitemap=record.sitemap, include_prefixes=record.include_prefixes, exclude_prefixes=record.exclude_prefixes, - max_pages=record.max_pages if (record.max_pages is not None and record.max_pages > 0) else 100, + max_pages=record.max_pages, language=record.language or "english", rate_limit_rps=record.rate_limit_rps if (record.rate_limit_rps is not None and record.rate_limit_rps > 0) else 1.0, llms_txt=record.llms_txt or "auto", @@ -637,7 +637,7 @@ def create_source_submit( sitemap: str = Form(default=""), include_prefixes: str = Form(default=""), exclude_prefixes: str = Form(default=""), - max_pages: str = Form(...), + max_pages: str = Form(default=""), language: str = Form(default="english"), rate_limit_rps: str = Form(default="1.0"), llms_txt: str = Form(default="auto"), @@ -720,7 +720,7 @@ def update_source_submit( sitemap: str = Form(default=""), include_prefixes: str = Form(default=""), exclude_prefixes: str = Form(default=""), - max_pages: str = Form(...), + max_pages: str = Form(default=""), language: str = Form(default="english"), rate_limit_rps: str = Form(default="1.0"), llms_txt: str = Form(default="auto"), diff --git a/ingestion/app/config.py b/ingestion/app/config.py index 801c101..5da2cc5 100644 --- a/ingestion/app/config.py +++ b/ingestion/app/config.py @@ -8,7 +8,7 @@ sitemap: https://fastapi.tiangolo.com/sitemap.xml # optional include_prefixes: ["/tutorial/", "/reference/"] # optional allowlist exclude_prefixes: ["/blog/", "/release-notes/"] # optional denylist (wins) - max_pages: 500 # required + max_pages: 500 # optional — omit for no page limit (crawl all in-scope pages) language: english # optional, default english rate_limit_rps: 1.0 # optional, default 1.0 @@ -99,7 +99,7 @@ class SourceConfig(BaseModel): sitemap: HttpUrl | None = None include_prefixes: list[str] = Field(default_factory=list) exclude_prefixes: list[str] = Field(default_factory=list) - max_pages: int = Field(gt=0) + max_pages: int | None = Field(default=None, gt=0) language: str = "english" rate_limit_rps: float = Field(default=1.0, gt=0) llms_txt: Literal["auto", "off", "only"] = "auto" diff --git a/ingestion/app/crawler.py b/ingestion/app/crawler.py index 038a701..16008b2 100644 --- a/ingestion/app/crawler.py +++ b/ingestion/app/crawler.py @@ -15,6 +15,7 @@ from __future__ import annotations import ipaddress +import sys import time import urllib.robotparser import warnings @@ -397,7 +398,8 @@ def crawl( client: httpx.Client | None = None, conditional: dict[str, tuple[str | None, str | None]] | None = None, ) -> Iterator[dict]: - """Discover and fetch up to `source.max_pages` pages for `source`. + """Discover and fetch up to `source.max_pages` pages for `source` (or ALL + in-scope pages when `source.max_pages` is None — no page limit). YIELDS `{"url": str, "html": str | None, "fetch_ok": bool}` dicts one at a time as pages are visited, rather than materializing the whole crawl in @@ -462,6 +464,11 @@ def crawl( rp = load_robots(client, base_url) limiter = RateLimiter(source.rate_limit_rps) + # `max_pages` is optional: None means "no page limit". Internally we use + # a very large int so the existing cap comparisons/slicing need no + # special-casing — same-host + prefix scoping still bounds the crawl. + page_cap = source.max_pages if source.max_pages is not None else sys.maxsize + pages_fetched = 0 visited: set[str] = set() @@ -525,7 +532,7 @@ def crawl( sections = llms_txt.split_llms_full(llms_text, index_url) llms_count = 0 for section in sections: - if llms_count >= source.max_pages: + if llms_count >= page_cap: break yield { "url": section["url"], @@ -560,7 +567,7 @@ def crawl( continue if u not in filtered: filtered.append(u) - candidate_urls = filtered[: source.max_pages] + candidate_urls = filtered[:page_cap] log.info("llms_index_candidates", count=len(candidate_urls)) elif source.sitemap and not _same_host(str(source.sitemap), base_url): # Defence in depth: SourceConfig already rejects an off-host @@ -572,7 +579,7 @@ def crawl( candidate_urls = discover_sitemap_urls( client, str(source.sitemap), - source.max_pages, + page_cap, limiter, log, base_url, @@ -672,7 +679,7 @@ def _visit(url: str) -> dict | None: if candidate_urls is not None: for url in candidate_urls: - if pages_fetched >= source.max_pages: + if pages_fetched >= page_cap: break url = _strip_fragment(url) if url in visited: @@ -685,7 +692,7 @@ def _visit(url: str) -> dict | None: yield item else: queue = [base_url] - while queue and pages_fetched < source.max_pages: + while queue and pages_fetched < page_cap: url = _strip_fragment(queue.pop(0)) if url in visited: continue diff --git a/ingestion/app/sources_repo.py b/ingestion/app/sources_repo.py index 28fd91e..bdb5b2a 100644 --- a/ingestion/app/sources_repo.py +++ b/ingestion/app/sources_repo.py @@ -189,7 +189,7 @@ def _row_to_record(row: tuple) -> SourceRecord: sitemap=sitemap, include_prefixes=list(include_prefixes) if include_prefixes is not None else [], exclude_prefixes=list(exclude_prefixes) if exclude_prefixes is not None else [], - max_pages=max_pages if (max_pages is not None and max_pages > 0) else 100, + max_pages=max_pages, # None means "no page limit" (optional) language=language or "english", rate_limit_rps=rate_limit_rps if (rate_limit_rps is not None and rate_limit_rps > 0) else 1.0, llms_txt=llms_txt if llms_txt else "auto", diff --git a/ingestion/app/templates/admin/form.html b/ingestion/app/templates/admin/form.html index 4af3d71..cdbb3d3 100644 --- a/ingestion/app/templates/admin/form.html +++ b/ingestion/app/templates/admin/form.html @@ -35,8 +35,8 @@

{% if record %}Edit {{ record.name }}{% else %}Add a source{% endif %}

- - + +