chromerag 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chromerag/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """ChromeRAG — Ingest-time elimination of site template noise for enterprise web RAG.
2
+
3
+ Core contract: HTML string in → RAG-ready Markdown out.
4
+ Fetching/crawling is intentionally out of scope (use your crawler / poc harness).
5
+ """
6
+
7
+ from chromerag.batch import learn_then_extract
8
+ from chromerag.config import ContentPriority, PageType, PipelineConfig, Strictness
9
+ from chromerag.extractor import ChromeRAG
10
+ from chromerag.input_quality import InputQualityReport, assess_input_html
11
+ from chromerag.models import ExtractResult
12
+ from chromerag.site_chrome import (
13
+ SiteChromeModel,
14
+ load_chrome_models,
15
+ mine_site_chrome,
16
+ save_chrome_models,
17
+ )
18
+
19
+ __all__ = [
20
+ "ChromeRAG",
21
+ "ExtractResult",
22
+ "PipelineConfig",
23
+ "ContentPriority",
24
+ "Strictness",
25
+ "PageType",
26
+ "SiteChromeModel",
27
+ "mine_site_chrome",
28
+ "save_chrome_models",
29
+ "load_chrome_models",
30
+ "learn_then_extract",
31
+ "assess_input_html",
32
+ "InputQualityReport",
33
+ ]
34
+ __version__ = "0.1.0"
35
+ __paper__ = (
36
+ "ChromeRAG: Ingest-Time Elimination of Site Template Noise for Enterprise Web RAG"
37
+ )
chromerag/batch.py ADDED
@@ -0,0 +1,111 @@
1
+ """Learn-then-extract batch helpers.
2
+
3
+ Critical order when STCE is enabled:
4
+ 1. Mine chrome patterns across the batch (or load a saved model)
5
+ 2. Only then extract each page with the learned model
6
+
7
+ Never extract-first then learn later — early chunks would keep template noise.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Iterable
14
+
15
+ from chromerag.config import PipelineConfig
16
+ from chromerag.extractor import ChromeRAG
17
+ from chromerag.models import ExtractResult
18
+ from chromerag.site_chrome import (
19
+ SiteChromeModel,
20
+ mine_site_chrome,
21
+ select_model_for_url,
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class BatchPage:
27
+ html: str
28
+ url: str | None = None
29
+ page_id: str | None = None
30
+
31
+
32
+ @dataclass
33
+ class BatchResult:
34
+ page_id: str | None
35
+ url: str | None
36
+ result: ExtractResult
37
+ chrome_group: str | None
38
+ stce_applied: bool
39
+
40
+
41
+ def learn_chrome(
42
+ pages: Iterable[BatchPage | tuple[str | None, str]],
43
+ *,
44
+ min_pages: int | None = None,
45
+ frequency: float | None = None,
46
+ max_block_chars: int | None = None,
47
+ require_chrome_features: bool = True,
48
+ path_prefix_depth: int = 1,
49
+ config: PipelineConfig | None = None,
50
+ ) -> dict[str, SiteChromeModel]:
51
+ """Mine site chrome from a multi-page corpus. Does not extract."""
52
+ cfg = config or PipelineConfig()
53
+ pairs: list[tuple[str | None, str]] = []
54
+ for p in pages:
55
+ if isinstance(p, BatchPage):
56
+ pairs.append((p.url, p.html))
57
+ else:
58
+ pairs.append((p[0], p[1]))
59
+ return mine_site_chrome(
60
+ pairs,
61
+ min_pages=min_pages if min_pages is not None else cfg.stce_min_pages,
62
+ frequency=frequency if frequency is not None else cfg.stce_frequency,
63
+ max_block_chars=max_block_chars
64
+ if max_block_chars is not None
65
+ else cfg.stce_max_block_chars,
66
+ require_chrome_features=require_chrome_features,
67
+ path_prefix_depth=path_prefix_depth,
68
+ )
69
+
70
+
71
+ def learn_then_extract(
72
+ pages: Iterable[BatchPage],
73
+ *,
74
+ config: PipelineConfig | None = None,
75
+ chrome_models: dict[str, SiteChromeModel] | None = None,
76
+ learn: bool = True,
77
+ path_prefix_depth: int = 1,
78
+ ) -> tuple[list[BatchResult], dict[str, SiteChromeModel]]:
79
+ """
80
+ If learn=True and chrome_models is None: mine from `pages` first, then extract.
81
+ If chrome_models provided: use them (skip mining).
82
+ If learn=False and no models: extract with heuristics/DVDF only (STCE off).
83
+ """
84
+ page_list = list(pages)
85
+ cfg = config or PipelineConfig()
86
+
87
+ models: dict[str, SiteChromeModel] = {}
88
+ if chrome_models is not None:
89
+ models = chrome_models
90
+ elif learn and cfg.enable_stce:
91
+ models = learn_chrome(page_list, config=cfg, path_prefix_depth=path_prefix_depth)
92
+
93
+ results: list[BatchResult] = []
94
+ for page in page_list:
95
+ model = (
96
+ select_model_for_url(models, page.url, path_prefix_depth=path_prefix_depth)
97
+ if models and cfg.enable_stce
98
+ else None
99
+ )
100
+ extractor = ChromeRAG(config=cfg, site_chrome=model)
101
+ result = extractor.extract(page.html, url=page.url)
102
+ results.append(
103
+ BatchResult(
104
+ page_id=page.page_id,
105
+ url=page.url,
106
+ result=result,
107
+ chrome_group=model.group_key if model else None,
108
+ stce_applied=bool(model and model.signatures),
109
+ )
110
+ )
111
+ return results, models
chromerag/cli.py ADDED
@@ -0,0 +1,296 @@
1
+ """ChromeRAG CLI — HTML → RAG Markdown (fetch is not a core concern).
2
+
3
+ Subcommands:
4
+ extract — single HTML file → markdown
5
+ learn — mine site-chrome model from a directory of HTML fixtures
6
+ batch — learn-then-extract (or extract with a saved chrome model)
7
+
8
+ URL fetching is intentionally omitted from the default path. Use `poc.fetch_pages`
9
+ or your own crawler, then point ChromeRAG at the saved HTML.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from chromerag.batch import BatchPage, learn_chrome, learn_then_extract
20
+ from chromerag.config import ContentPriority, PipelineConfig, Strictness
21
+ from chromerag.extractor import ChromeRAG
22
+ from chromerag.site_chrome import load_chrome_models, save_chrome_models
23
+
24
+
25
+ def _build_config(args: argparse.Namespace) -> PipelineConfig:
26
+ overrides = dict(
27
+ enable_dvdf=not getattr(args, "no_dvdf", False),
28
+ enable_schema=not getattr(args, "no_schema", False),
29
+ enable_tables=not getattr(args, "no_tables", False),
30
+ enable_stce=not getattr(args, "no_stce", False),
31
+ inject_heading_paths=getattr(args, "heading_paths", False),
32
+ )
33
+ if getattr(args, "priority", None):
34
+ return PipelineConfig.from_priority(args.priority, **overrides)
35
+ if getattr(args, "strictness", None):
36
+ return PipelineConfig.from_strictness(args.strictness, **overrides)
37
+ return PipelineConfig.from_priority(ContentPriority.BALANCED, **overrides)
38
+
39
+
40
+ def _add_shared_flags(p: argparse.ArgumentParser) -> None:
41
+ p.add_argument(
42
+ "--priority",
43
+ choices=[c.value for c in ContentPriority],
44
+ default=None,
45
+ help="precision | coverage | balanced (default)",
46
+ )
47
+ p.add_argument(
48
+ "--strictness",
49
+ choices=[s.value for s in Strictness],
50
+ default=None,
51
+ help="Legacy alias for priority",
52
+ )
53
+ p.add_argument("--no-dvdf", action="store_true")
54
+ p.add_argument("--no-schema", action="store_true")
55
+ p.add_argument("--no-tables", action="store_true")
56
+ p.add_argument("--no-stce", action="store_true")
57
+ p.add_argument("--heading-paths", action="store_true")
58
+
59
+
60
+ def _load_fixture_dir(raw_dir: Path) -> list[BatchPage]:
61
+ pages: list[BatchPage] = []
62
+ for meta_path in sorted(raw_dir.glob("*.meta.json")):
63
+ if meta_path.name.startswith("_"):
64
+ continue
65
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
66
+ html_path = raw_dir / f"{meta['id']}.html"
67
+ if not html_path.exists():
68
+ continue
69
+ pages.append(
70
+ BatchPage(
71
+ html=html_path.read_text(encoding="utf-8", errors="ignore"),
72
+ url=meta.get("url"),
73
+ page_id=meta["id"],
74
+ )
75
+ )
76
+ if not pages:
77
+ # Fallback: bare *.html without meta
78
+ for html_path in sorted(raw_dir.glob("*.html")):
79
+ pages.append(
80
+ BatchPage(
81
+ html=html_path.read_text(encoding="utf-8", errors="ignore"),
82
+ url=None,
83
+ page_id=html_path.stem,
84
+ )
85
+ )
86
+ return pages
87
+
88
+
89
+ def cmd_extract(args: argparse.Namespace) -> int:
90
+ path = Path(args.html)
91
+ if not path.exists():
92
+ sys.stderr.write(
93
+ f"File not found: {path}\n"
94
+ "ChromeRAG extracts HTML files only. Fetch pages with your crawler "
95
+ "or: python -m poc.fetch_pages\n"
96
+ )
97
+ return 2
98
+ html = path.read_text(encoding="utf-8", errors="ignore")
99
+ cfg = _build_config(args)
100
+ model = None
101
+ if args.chrome_model and not args.no_stce:
102
+ models = load_chrome_models(args.chrome_model)
103
+ from chromerag.site_chrome import select_model_for_url
104
+
105
+ model = select_model_for_url(models, args.url)
106
+ extractor = ChromeRAG(config=cfg, site_chrome=model)
107
+ result = extractor.extract(html, url=args.url)
108
+
109
+ for warning in result.warnings:
110
+ sys.stderr.write(f"WARNING: {warning}\n")
111
+
112
+ if args.output:
113
+ Path(args.output).write_text(result.markdown, encoding="utf-8")
114
+ else:
115
+ sys.stdout.write(result.markdown)
116
+
117
+ if args.json_meta:
118
+ meta = {
119
+ "url": result.url,
120
+ "tokens_estimate": result.tokens_estimate,
121
+ "n_blocks_in": result.n_blocks_in,
122
+ "n_blocks_kept": result.n_blocks_kept,
123
+ "n_tables": result.n_tables,
124
+ "front_matter": result.front_matter,
125
+ "warnings": result.warnings,
126
+ "input_quality": result.input_quality,
127
+ "diagnostics": result.diagnostics,
128
+ }
129
+ sys.stderr.write(json.dumps(meta, indent=2) + "\n")
130
+ # Non-zero only on hard failures; thin JS shells warn but still exit 0 so
131
+ # pipelines can log and decide. Use --fail-on-thin to hard-fail.
132
+ if getattr(args, "fail_on_thin", False) and result.input_quality.get("is_thin"):
133
+ return 3
134
+ return 0
135
+
136
+
137
+ def cmd_learn(args: argparse.Namespace) -> int:
138
+ pages = _load_fixture_dir(Path(args.html_dir))
139
+ if len(pages) < 2:
140
+ sys.stderr.write("Need at least 2 HTML pages to learn chrome patterns.\n")
141
+ return 2
142
+ cfg = _build_config(args)
143
+ models = learn_chrome(
144
+ pages,
145
+ config=cfg,
146
+ min_pages=args.min_pages,
147
+ path_prefix_depth=args.path_prefix_depth,
148
+ )
149
+ out = Path(args.output)
150
+ save_chrome_models(models, out)
151
+ active = {k: len(m.signatures) for k, m in models.items() if m.signatures}
152
+ sys.stderr.write(
153
+ json.dumps(
154
+ {
155
+ "pages": len(pages),
156
+ "groups": {k: m.n_pages for k, m in models.items()},
157
+ "chrome_signatures": active,
158
+ "wrote": str(out),
159
+ },
160
+ indent=2,
161
+ )
162
+ + "\n"
163
+ )
164
+ return 0
165
+
166
+
167
+ def cmd_batch(args: argparse.Namespace) -> int:
168
+ """Learn-then-extract (default) or extract with a pre-learned chrome model."""
169
+ pages = _load_fixture_dir(Path(args.html_dir))
170
+ if not pages:
171
+ sys.stderr.write(f"No HTML fixtures in {args.html_dir}\n")
172
+ return 2
173
+ cfg = _build_config(args)
174
+ out_dir = Path(args.output_dir)
175
+ out_dir.mkdir(parents=True, exist_ok=True)
176
+
177
+ chrome_models = None
178
+ learn = True
179
+ if args.chrome_model:
180
+ chrome_models = load_chrome_models(args.chrome_model)
181
+ learn = False
182
+ if args.no_learn:
183
+ learn = False
184
+
185
+ results, models = learn_then_extract(
186
+ pages,
187
+ config=cfg,
188
+ chrome_models=chrome_models,
189
+ learn=learn,
190
+ path_prefix_depth=args.path_prefix_depth,
191
+ )
192
+
193
+ if args.save_chrome:
194
+ save_chrome_models(models, args.save_chrome)
195
+
196
+ summary = []
197
+ for br in results:
198
+ pid = br.page_id or "page"
199
+ page_out = out_dir / pid
200
+ page_out.mkdir(parents=True, exist_ok=True)
201
+ (page_out / "chromerag.md").write_text(br.result.markdown, encoding="utf-8")
202
+ # UI compat alias
203
+ (page_out / "smart_rag_extractor.md").write_text(br.result.markdown, encoding="utf-8")
204
+ iq = br.result.input_quality or {}
205
+ row = {
206
+ "id": pid,
207
+ "url": br.url,
208
+ "tokens": br.result.tokens_estimate,
209
+ "stce_applied": br.stce_applied,
210
+ "chrome_group": br.chrome_group,
211
+ "stce_removed": br.result.diagnostics.get("stce_removed", 0),
212
+ "priority": br.result.diagnostics.get("content_priority"),
213
+ "json_ld": br.result.diagnostics.get("schema_sources", {}).get("json_ld_count", 0),
214
+ "input_thin": bool(iq.get("is_thin")),
215
+ "likely_js_shell": bool(iq.get("likely_js_shell")),
216
+ "warnings": list(br.result.warnings or []),
217
+ }
218
+ summary.append(row)
219
+ flag = " THIN" if row["input_thin"] else ""
220
+ if row["likely_js_shell"]:
221
+ flag = " JS_SHELL"
222
+ print(
223
+ f"{pid:28s} tok={row['tokens']:6d} stce={row['stce_applied']} "
224
+ f"rm={row['stce_removed']} jsonld={row['json_ld']}{flag}"
225
+ )
226
+ for warning in br.result.warnings or []:
227
+ sys.stderr.write(f"WARNING [{pid}]: {warning}\n")
228
+
229
+ (out_dir / "batch_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
230
+ return 0
231
+
232
+
233
+ def main(argv: list[str] | None = None) -> int:
234
+ parser = argparse.ArgumentParser(
235
+ prog="chromerag",
236
+ description=(
237
+ "ChromeRAG: ingest-time elimination of site template noise "
238
+ "for enterprise web RAG. Pass HTML files — do not use as a crawler."
239
+ ),
240
+ )
241
+ sub = parser.add_subparsers(dest="command", required=True)
242
+
243
+ p_ex = sub.add_parser("extract", help="Extract one HTML file to Markdown")
244
+ p_ex.add_argument("html", help="Path to .html file")
245
+ p_ex.add_argument("-o", "--output", help="Write markdown to path")
246
+ p_ex.add_argument("--url", help="Canonical URL (for page-type + STCE group)")
247
+ p_ex.add_argument("--chrome-model", help="Path to learned chrome JSON from `chromerag learn`")
248
+ p_ex.add_argument("--json-meta", action="store_true")
249
+ p_ex.add_argument(
250
+ "--fail-on-thin",
251
+ action="store_true",
252
+ help="Exit 3 when input HTML looks thin / like a JS shell (still prints WARNINGs by default)",
253
+ )
254
+ _add_shared_flags(p_ex)
255
+ p_ex.set_defaults(func=cmd_extract)
256
+
257
+ p_learn = sub.add_parser(
258
+ "learn",
259
+ help="Mine site-chrome patterns from a directory of HTML (run BEFORE batch extract)",
260
+ )
261
+ p_learn.add_argument("html_dir", help="Directory with *.html (+ optional *.meta.json)")
262
+ p_learn.add_argument("-o", "--output", required=True, help="Write chrome model JSON here")
263
+ p_learn.add_argument("--min-pages", type=int, default=3)
264
+ p_learn.add_argument("--path-prefix-depth", type=int, default=1)
265
+ _add_shared_flags(p_learn)
266
+ p_learn.set_defaults(func=cmd_learn)
267
+
268
+ p_batch = sub.add_parser(
269
+ "batch",
270
+ help="Learn-then-extract a directory (or extract with --chrome-model / --no-learn)",
271
+ )
272
+ p_batch.add_argument("html_dir", help="Directory with HTML fixtures")
273
+ p_batch.add_argument("-o", "--output-dir", required=True, help="Output directory")
274
+ p_batch.add_argument(
275
+ "--chrome-model",
276
+ help="Use a previously learned chrome model (skips mining)",
277
+ )
278
+ p_batch.add_argument(
279
+ "--save-chrome",
280
+ help="Also write the mined chrome model to this path",
281
+ )
282
+ p_batch.add_argument(
283
+ "--no-learn",
284
+ action="store_true",
285
+ help="Skip STCE mining; heuristics + DVDF only",
286
+ )
287
+ p_batch.add_argument("--path-prefix-depth", type=int, default=1)
288
+ _add_shared_flags(p_batch)
289
+ p_batch.set_defaults(func=cmd_batch)
290
+
291
+ args = parser.parse_args(argv)
292
+ return int(args.func(args))
293
+
294
+
295
+ if __name__ == "__main__":
296
+ raise SystemExit(main())
chromerag/config.py ADDED
@@ -0,0 +1,181 @@
1
+ """Pipeline configuration — manageable strictness knobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+ from chromerag.density import DensityConfig
9
+
10
+
11
+ class ContentPriority(str, Enum):
12
+ """User-facing tradeoff: clean vectors vs keeping more relevant text.
13
+
14
+ PRECISION — Prefer correctness of extracted text. Aggressively drop chrome;
15
+ may lose some relevant chunks (lower content recall).
16
+ COVERAGE — Prefer not losing relevant chunks. Tolerates more residual noise
17
+ (higher content recall, higher noise retention).
18
+ BALANCED — Default enterprise RAG compromise.
19
+ """
20
+
21
+ PRECISION = "precision"
22
+ COVERAGE = "coverage"
23
+ BALANCED = "balanced"
24
+
25
+
26
+ class Strictness(str, Enum):
27
+ """Internal aggressiveness presets (mapped from ContentPriority)."""
28
+
29
+ LENIENT = "lenient" # Prefer recall — keep more text
30
+ BALANCED = "balanced" # Default for enterprise RAG
31
+ AGGRESSIVE = "aggressive" # Prefer precision — more chrome gone
32
+
33
+
34
+ class PageType(str, Enum):
35
+ DOCS = "docs"
36
+ PRICING = "pricing"
37
+ MARKETING = "marketing"
38
+ HUB = "hub"
39
+ UNKNOWN = "unknown"
40
+
41
+
42
+ _PRIORITY_TO_STRICTNESS = {
43
+ ContentPriority.PRECISION: Strictness.AGGRESSIVE,
44
+ ContentPriority.COVERAGE: Strictness.LENIENT,
45
+ ContentPriority.BALANCED: Strictness.BALANCED,
46
+ }
47
+
48
+
49
+ def priority_to_strictness(priority: ContentPriority | str) -> Strictness:
50
+ if isinstance(priority, str):
51
+ priority = ContentPriority(priority)
52
+ return _PRIORITY_TO_STRICTNESS[priority]
53
+
54
+
55
+ @dataclass
56
+ class PipelineConfig:
57
+ """All tunables in one place for CLI / UI / experiments."""
58
+
59
+ strictness: Strictness = Strictness.BALANCED
60
+ content_priority: ContentPriority = ContentPriority.BALANCED
61
+ page_type: PageType = PageType.UNKNOWN
62
+
63
+ enable_schema: bool = True
64
+ enable_tables: bool = True
65
+ enable_dvdf: bool = True
66
+ enable_stce: bool = True # applied only when a SiteChromeModel is provided
67
+ inject_heading_paths: bool = False
68
+
69
+ # Density
70
+ min_chars: int = 40
71
+ max_link_density: float = 0.55
72
+ min_text_density: float = 0.12
73
+
74
+ # Chrome / size guards
75
+ max_noise_block_chars: int = 4000 # do not strip wrappers larger than this via class heuristics
76
+ max_rejected_drop_chars: int = 400 # density/DVDF rejected nodes dropped only if shorter
77
+
78
+ # DVDF
79
+ dvdf_threshold: float = 0.42
80
+
81
+ # STCE (site-template chrome elimination)
82
+ stce_min_pages: int = 5
83
+ stce_frequency: float = 0.80 # appear in ≥80% of site pages
84
+ stce_max_block_chars: int = 1500 # never STCE-drop long shared content blocks
85
+ stce_require_chrome_features: bool = True # frequency alone is not enough
86
+
87
+ def density_config(self) -> DensityConfig:
88
+ return DensityConfig(
89
+ min_chars=self.min_chars,
90
+ max_link_density=self.max_link_density,
91
+ min_text_density=self.min_text_density,
92
+ prefer_main=True,
93
+ )
94
+
95
+ @classmethod
96
+ def from_priority(
97
+ cls,
98
+ priority: ContentPriority | str = ContentPriority.BALANCED,
99
+ page_type: PageType | str = PageType.UNKNOWN,
100
+ **overrides: object,
101
+ ) -> PipelineConfig:
102
+ """Build config from the user-facing precision/coverage flag."""
103
+ if isinstance(priority, str):
104
+ priority = ContentPriority(priority)
105
+ cfg = cls.from_strictness(
106
+ priority_to_strictness(priority),
107
+ page_type,
108
+ **overrides,
109
+ )
110
+ cfg.content_priority = priority
111
+ return cfg
112
+
113
+ @classmethod
114
+ def from_strictness(
115
+ cls,
116
+ strictness: Strictness | str = Strictness.BALANCED,
117
+ page_type: PageType | str = PageType.UNKNOWN,
118
+ **overrides: object,
119
+ ) -> PipelineConfig:
120
+ if isinstance(strictness, str):
121
+ strictness = Strictness(strictness)
122
+ if isinstance(page_type, str):
123
+ page_type = PageType(page_type)
124
+
125
+ # Keep content_priority aligned with strictness when callers use from_strictness
126
+ priority = ContentPriority.BALANCED
127
+ if strictness == Strictness.AGGRESSIVE:
128
+ priority = ContentPriority.PRECISION
129
+ elif strictness == Strictness.LENIENT:
130
+ priority = ContentPriority.COVERAGE
131
+
132
+ base = cls(strictness=strictness, content_priority=priority, page_type=page_type)
133
+
134
+ if strictness == Strictness.LENIENT:
135
+ base.max_link_density = 0.70
136
+ base.min_text_density = 0.08
137
+ base.min_chars = 25
138
+ base.dvdf_threshold = 0.55
139
+ base.max_rejected_drop_chars = 250
140
+ base.stce_frequency = 0.85
141
+ base.enable_dvdf = True
142
+ elif strictness == Strictness.AGGRESSIVE:
143
+ base.max_link_density = 0.40
144
+ base.min_text_density = 0.18
145
+ base.min_chars = 50
146
+ base.dvdf_threshold = 0.32
147
+ base.max_rejected_drop_chars = 600
148
+ base.stce_frequency = 0.55
149
+ base.stce_max_block_chars = 3500
150
+ else: # BALANCED
151
+ pass
152
+
153
+ # Page-type nudges (on top of strictness)
154
+ if page_type == PageType.MARKETING:
155
+ base.max_link_density = min(base.max_link_density, 0.50)
156
+ base.dvdf_threshold = min(base.dvdf_threshold, 0.38)
157
+ elif page_type == PageType.DOCS:
158
+ base.max_link_density = max(base.max_link_density, 0.60) # docs side nav is linky but keep body
159
+ base.enable_dvdf = True
160
+ elif page_type == PageType.PRICING:
161
+ base.enable_tables = True
162
+ base.max_rejected_drop_chars = min(base.max_rejected_drop_chars, 300)
163
+
164
+ for key, value in overrides.items():
165
+ if not hasattr(base, key):
166
+ raise ValueError(f"Unknown PipelineConfig field: {key}")
167
+ setattr(base, key, value)
168
+ return base
169
+
170
+
171
+ def infer_page_type(url: str | None, html: str = "") -> PageType:
172
+ blob = f"{url or ''} {html[:2000]}".lower()
173
+ if any(k in blob for k in ("/pricing", "pricing", "on-demand", "price-list")):
174
+ return PageType.PRICING
175
+ if any(k in blob for k in ("/docs", "documentation", "userguide", "developer", "/learn/")):
176
+ return PageType.DOCS
177
+ if any(k in blob for k in ("/products", "/product/", "solutions", "platform")):
178
+ return PageType.MARKETING
179
+ if any(k in blob for k in ("/blog", "resources", "customers")):
180
+ return PageType.HUB
181
+ return PageType.UNKNOWN