footnote-mcp 1.0.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.
@@ -0,0 +1,13 @@
1
+ """footnote-mcp: an MCP server for source-grounded web research.
2
+
3
+ The runtime lives as submodules of this package (``server``, ``core``,
4
+ ``search``, ...). ``cli`` is the console-script entry point; the import of the
5
+ heavy ``server`` module is deferred into the call so ``import footnote_mcp`` stays
6
+ cheap.
7
+ """
8
+
9
+
10
+ def cli():
11
+ from .server import cli as _cli
12
+
13
+ _cli()
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m footnote_mcp`` to launch the server."""
2
+
3
+ from . import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+
6
+ def calculate_confidence(
7
+ chunk,
8
+ avg_relevance,
9
+ query=None,
10
+ *,
11
+ has_factual_data,
12
+ ):
13
+ """
14
+ Calculate confidence score for a chunk based on relevance, freshness,
15
+ factual density, source position, and text substance.
16
+
17
+ The source-domain whitelist was intentionally removed to avoid
18
+ hardcoded trust assumptions in library code.
19
+ """
20
+ score = 0
21
+ now = datetime.now()
22
+
23
+ rel = chunk["relevance"]
24
+ if rel > 0.7:
25
+ score += 3
26
+ elif rel > 0.4:
27
+ score += 2
28
+ elif rel > 0.2:
29
+ score += 1
30
+
31
+ if avg_relevance > 0 and rel > avg_relevance * 1.3:
32
+ score += 1
33
+
34
+ if chunk.get("pub_date"):
35
+ pub_date = chunk["pub_date"]
36
+ age_hours = (now - pub_date).total_seconds() / 3600
37
+
38
+ if age_hours < 24:
39
+ score += 3
40
+ elif age_hours < 24 * 7:
41
+ score += 2
42
+ elif age_hours < 24 * 30:
43
+ score += 1
44
+ else:
45
+ score += 1
46
+
47
+ if has_factual_data(chunk["text"]):
48
+ score += 2
49
+
50
+ if chunk["source_idx"] < 3:
51
+ score += 1
52
+ elif chunk["source_idx"] < 6:
53
+ score += 0.5
54
+
55
+ word_count = len(chunk["text"].split())
56
+ if word_count > 100:
57
+ score += 1
58
+ elif word_count > 50:
59
+ score += 0.5
60
+
61
+ if score >= 7:
62
+ return "HIGH"
63
+ if score >= 4:
64
+ return "MEDIUM"
65
+ return "LOW"
footnote_mcp/core.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ Internal runtime settings and shared optional dependency state.
3
+
4
+ Implementation code lives in focused modules: search, fetch, extract,
5
+ rerank, and pipeline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Optional dependencies
11
+ try:
12
+ import trafilatura # noqa: F401
13
+
14
+ HAS_TRAFILATURA = True
15
+ except ImportError:
16
+ HAS_TRAFILATURA = False
17
+
18
+ try:
19
+ from sentence_transformers import CrossEncoder, SentenceTransformer
20
+
21
+ HAS_EMBEDDINGS = True
22
+ HAS_CROSS_ENCODER = True
23
+ except ImportError:
24
+ SentenceTransformer = None
25
+ CrossEncoder = None
26
+ HAS_EMBEDDINGS = False
27
+ HAS_CROSS_ENCODER = False
28
+
29
+
30
+ # Configurable runtime knobs
31
+ NUM_PER_ENGINE = 15
32
+ TOP_N_FETCH = 15
33
+ MAX_CONTENT_CHARS = 12000
34
+ CHUNK_SIZE = 600
35
+ CHUNK_OVERLAP = 100
36
+ TOP_CHUNKS_PER_PAGE = 3
37
+ TOTAL_CONTEXT_CHUNKS = 15
38
+ FETCH_WORKERS = 10
39
+ FETCH_TIMEOUT = 12
40
+
41
+ IMPERSONATE = [
42
+ "chrome110",
43
+ "chrome116",
44
+ "chrome120",
45
+ "chrome123",
46
+ "chrome124",
47
+ "safari15_5",
48
+ "safari17_0",
49
+ ]
50
+
51
+ __all__ = [
52
+ "HAS_TRAFILATURA",
53
+ "HAS_EMBEDDINGS",
54
+ "HAS_CROSS_ENCODER",
55
+ "NUM_PER_ENGINE",
56
+ "TOP_N_FETCH",
57
+ "MAX_CONTENT_CHARS",
58
+ "CHUNK_SIZE",
59
+ "CHUNK_OVERLAP",
60
+ "TOP_CHUNKS_PER_PAGE",
61
+ "TOTAL_CONTEXT_CHUNKS",
62
+ "FETCH_WORKERS",
63
+ "FETCH_TIMEOUT",
64
+ "IMPERSONATE",
65
+ ]
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+
8
+ def get_logger() -> logging.Logger:
9
+ logger = logging.getLogger("footnote")
10
+ if not logger.handlers:
11
+ handler = logging.StreamHandler(sys.stderr)
12
+ handler.setFormatter(logging.Formatter("[%(name)s] %(levelname)s: %(message)s"))
13
+ logger.addHandler(handler)
14
+ logger.propagate = False
15
+
16
+ level_name = os.environ.get("FOOTNOTE_LOG_LEVEL", "WARNING").upper()
17
+ logger.setLevel(getattr(logging, level_name, logging.WARNING))
18
+ return logger
19
+
20
+
21
+ log = get_logger()
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from collections import Counter
6
+
7
+ from bs4 import BeautifulSoup
8
+
9
+ from .diagnostics import log
10
+
11
+
12
+ def _fallback_extract(html):
13
+ # ponytail: raw get_text(). trafilatura covers 95%+ of pages, this is the last resort.
14
+ # ceiling: naive get_text() chunking. upgrade: readability-lxml when fallback rate exceeds 10% of fetches.
15
+ return BeautifulSoup(html, "html.parser").get_text("\n", strip=True)
16
+
17
+
18
+ def _extract_publish_date(html):
19
+ from datetime import datetime
20
+
21
+ soup = BeautifulSoup(html, "html.parser")
22
+ date_str = None
23
+
24
+ meta_selectors = [
25
+ ("meta", {"property": "article:published_time"}),
26
+ ("meta", {"name": "publication_date"}),
27
+ ("meta", {"name": "publishdate"}),
28
+ ("meta", {"property": "og:published_time"}),
29
+ ("meta", {"name": "date"}),
30
+ ("meta", {"itemprop": "datePublished"}),
31
+ ("time", {"datetime": True}),
32
+ ]
33
+
34
+ for tag, attrs in meta_selectors:
35
+ elem = soup.find(tag, attrs)
36
+ if elem:
37
+ date_str = elem.get("content") or elem.get("datetime")
38
+ if date_str:
39
+ break
40
+
41
+ if not date_str:
42
+ for script in soup.find_all("script", {"type": "application/ld+json"}):
43
+ try:
44
+ data = json.loads(script.string)
45
+ if isinstance(data, dict):
46
+ date_str = data.get("datePublished") or data.get("dateCreated")
47
+ if date_str:
48
+ break
49
+ except Exception:
50
+ pass
51
+
52
+ if date_str:
53
+ try:
54
+ if "T" in date_str or "-" in date_str:
55
+ date_str = date_str.split("+")[0].split("Z")[0]
56
+ return datetime.fromisoformat(date_str.replace("T", " ")[:19])
57
+ except Exception:
58
+ pass
59
+
60
+ return None
61
+
62
+
63
+ def extract_content(html, url=None):
64
+ from . import core
65
+
66
+ if core.HAS_TRAFILATURA:
67
+ text = core.trafilatura.extract(
68
+ html,
69
+ url=url,
70
+ include_comments=False,
71
+ include_tables=True,
72
+ no_fallback=False,
73
+ favor_recall=True,
74
+ output_format="markdown",
75
+ )
76
+ if text and len(text) > 100:
77
+ return text
78
+
79
+ return _fallback_extract(html)
80
+
81
+
82
+ def chunk_text(text, chunk_size=None, overlap=None, lang="en"):
83
+ from . import core
84
+
85
+ if chunk_size is None:
86
+ chunk_size = core.CHUNK_SIZE
87
+ if overlap is None:
88
+ overlap = core.CHUNK_OVERLAP
89
+ if not text:
90
+ return []
91
+
92
+ paragraphs = re.split(r"\n{2,}", text.strip())
93
+ chunks = []
94
+ current = ""
95
+
96
+ for para in paragraphs:
97
+ para = para.strip()
98
+ if not para:
99
+ continue
100
+
101
+ if current and len(current) + len(para) + 2 > chunk_size:
102
+ chunks.append(current.strip())
103
+ if overlap > 0 and len(current) > overlap:
104
+ current = current[-overlap:] + "\n\n" + para
105
+ else:
106
+ current = para
107
+ else:
108
+ current = current + "\n\n" + para if current else para
109
+
110
+ while len(current) > chunk_size * 1.5:
111
+ split_pos = chunk_size
112
+ for delim in [". ", "! ", "? ", ".\n", ";\n", "\n"]:
113
+ pos = current.rfind(delim, 0, chunk_size + 50)
114
+ if pos > chunk_size * 0.3:
115
+ split_pos = pos + len(delim)
116
+ break
117
+
118
+ chunk_part = current[:split_pos].strip()
119
+ if chunk_part:
120
+ chunks.append(chunk_part)
121
+
122
+ remainder = current[split_pos:].strip()
123
+ if overlap > 0 and len(chunk_part) > overlap:
124
+ current = chunk_part[-overlap:] + " " + remainder
125
+ else:
126
+ current = remainder
127
+
128
+ if current.strip():
129
+ chunks.append(current.strip())
130
+
131
+ return [chunk for chunk in chunks if len(chunk) > 40]
132
+
133
+
134
+ def _is_incomplete_chunk(text):
135
+ """Only reject clear mid-sentence fragments."""
136
+ if not text or len(text) < 30:
137
+ return True
138
+
139
+ text_stripped = text.strip()
140
+ # starts mid-sentence: lowercase letter
141
+ if len(text_stripped) < 100 and text_stripped[0].islower():
142
+ return True
143
+ # starts with punctuation (continuation)
144
+ if text_stripped[0] in ".,-—–…":
145
+ return True
146
+ # trailing dash = cut off mid-word
147
+ if text_stripped.endswith("-") or text_stripped.endswith("—"):
148
+ return True
149
+ return False
150
+
151
+
152
+
153
+ def _is_low_quality_chunk(text):
154
+ """One-pass quality check. ponytail: merged _remove_garbage_lines patterns."""
155
+ if not text or len(text) < 30:
156
+ return True
157
+
158
+ words = text.split()
159
+ if not words:
160
+ return True
161
+ if len(words) / len(text) < 0.08:
162
+ return True
163
+
164
+ text_lower = text.lower()
165
+ # merged garbage + boilerplate patterns
166
+ garbage = [
167
+ "подписаться", "подпис", "subscribe", "sign up", "telegram", "whatsapp",
168
+ "вконтакте", "следите за нами", "follow us", "поделиться",
169
+ "share on", "tweet", "facebook", "twitter", "комментар", "comment",
170
+ "оставьте отзыв", "читайте также", "read also", "related articles",
171
+ "рекомендуем", "recommended", "похожие статьи", "subscribe now",
172
+ "email updates", "daily digest",
173
+ "cookie policy", "privacy policy", "terms of service", "all rights reserved", "© 20",
174
+ "copyright ©", "sign up for", "follow us on",
175
+ "share this article", "advertisement", "sponsored content", "click here to",
176
+ "read more »", "loading...", "please wait", "javascript is disabled",
177
+ "enable javascript", "accept cookies", "we use cookies",
178
+ "in your inbox", "sign up for our", "get the latest",
179
+ ]
180
+ if sum(1 for p in garbage if p in text_lower) >= 2:
181
+ return True
182
+
183
+ word_counts = Counter(w.lower() for w in words if len(w) > 3)
184
+ if word_counts and max(word_counts.values()) > len(words) * 0.3:
185
+ return True
186
+
187
+ long_words = [w for w in words if len(w) > 4]
188
+ if len(long_words) / len(words) < 0.2:
189
+ return True
190
+ return False
191
+
192
+
193
+ def is_content_page(text, query=None, lang="en", min_avg_sentence_len=25, min_sentences=3):
194
+ """Return True when extracted text has enough sentence or line-level substance."""
195
+ if not text or len(text) < 100:
196
+ return False
197
+
198
+ sentences = [s.strip() for s in re.split(r"[.!?]\s+", text) if len(s.strip()) > 20]
199
+ lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 25]
200
+ if len(sentences) < min_sentences and len(lines) < min_sentences:
201
+ return False
202
+ all_parts = sentences + lines
203
+ avg_len = sum(len(s) for s in all_parts) / len(all_parts)
204
+ return avg_len >= min_avg_sentence_len or len(all_parts) >= 8 # enough distinct items = real content
205
+
206
+
207
+ def filter_low_quality_chunks(chunks):
208
+ filtered = []
209
+ for chunk in chunks:
210
+ if not _is_incomplete_chunk(chunk) and not _is_low_quality_chunk(chunk):
211
+ filtered.append(chunk)
212
+
213
+ removed = len(chunks) - len(filtered)
214
+ if removed:
215
+ log.info("[FILTER] Removed %s low-quality/incomplete chunks", removed)
216
+ return filtered
footnote_mcp/fetch.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+
7
+ from curl_cffi import requests as http
8
+
9
+ from .diagnostics import log
10
+
11
+
12
+ def _imp():
13
+ from . import core
14
+
15
+ return random.choice(core.IMPERSONATE)
16
+
17
+
18
+ def _headers(lang="en"):
19
+ return {
20
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
21
+ "Accept-Language": f"{lang},en-US;q=0.7,en;q=0.3",
22
+ "Accept-Encoding": "gzip, deflate, br",
23
+ "DNT": "1",
24
+ "Connection": "keep-alive",
25
+ "Upgrade-Insecure-Requests": "1",
26
+ "Sec-Fetch-Dest": "document",
27
+ "Sec-Fetch-Mode": "navigate",
28
+ "Sec-Fetch-Site": "none",
29
+ "Sec-Fetch-User": "?1",
30
+ }
31
+
32
+
33
+ def _get(url, lang="en", cookies=None, max_retries=2, timeout=15, extra_headers=None, proxies=None):
34
+ """Single HTTP GET with TLS impersonation and retries.
35
+
36
+ ``extra_headers`` (optional) are merged on top of the default browser headers,
37
+ which lets callers attach auth tokens or custom headers for gated pages.
38
+ ``proxies`` (optional) routes the request through a proxy, e.g.
39
+ ``{"http": "http://host:port", "https": "http://host:port"}``.
40
+ """
41
+ headers = _headers(lang)
42
+ if extra_headers:
43
+ headers.update({str(k): str(v) for k, v in extra_headers.items()})
44
+ last_err = None
45
+ for attempt in range(max_retries + 1):
46
+ try:
47
+ return http.get(
48
+ url,
49
+ headers=headers,
50
+ cookies=cookies,
51
+ impersonate=_imp(),
52
+ timeout=timeout,
53
+ allow_redirects=True,
54
+ proxies=proxies,
55
+ )
56
+ except Exception as exc:
57
+ last_err = exc
58
+ if attempt < max_retries:
59
+ time.sleep(random.uniform(0.5, 1.5))
60
+ raise last_err
61
+
62
+
63
+ def fetch_page(url, lang="en"):
64
+ """Fetch a single page → return (url, raw_html, publish_date, error)."""
65
+ from . import core
66
+ from .extract import _extract_publish_date
67
+
68
+ try:
69
+ resp = _get(url, lang, timeout=core.FETCH_TIMEOUT, max_retries=1)
70
+ if resp.status_code == 200:
71
+ pub_date = _extract_publish_date(resp.text)
72
+ return (url, resp.text, pub_date, None)
73
+ return (url, None, None, f"HTTP {resp.status_code}")
74
+ except Exception as exc:
75
+ return (url, None, None, str(exc))
76
+
77
+
78
+ def fetch_pages_parallel(urls, query=None, lang="en"):
79
+ """Fetch multiple pages in parallel and return extracted text by URL."""
80
+ from . import core
81
+ from .extract import extract_content, is_content_page
82
+
83
+ results = {}
84
+
85
+ with ThreadPoolExecutor(max_workers=core.FETCH_WORKERS) as pool:
86
+ futures = {pool.submit(fetch_page, url, lang): url for url in urls}
87
+
88
+ for future in as_completed(futures):
89
+ url = futures[future]
90
+ try:
91
+ fetched_url, html, pub_date, err = future.result()
92
+ if err:
93
+ log.info("[FETCH] failed %s... - %s", url[:60], err)
94
+ continue
95
+
96
+ try:
97
+ text = extract_content(html, url=fetched_url)
98
+ if text and len(text) > 50:
99
+ if not is_content_page(text, query=query, lang=lang):
100
+ log.info("[FETCH] rejected %s... - low-quality content", url[:60])
101
+ continue
102
+
103
+ text = text[:core.MAX_CONTENT_CHARS]
104
+ results[fetched_url] = {"text": text, "pub_date": pub_date}
105
+ date_str = pub_date.strftime("%Y-%m-%d") if pub_date else "unknown"
106
+ log.info("[FETCH] ok %s... - %s chars, date=%s", url[:60], len(text), date_str)
107
+ else:
108
+ log.info("[FETCH] rejected %s... - empty after extraction", url[:60])
109
+ except Exception as extract_err:
110
+ log.warning("[FETCH] extraction failed %s... - %s", url[:60], extract_err)
111
+ except Exception as exc:
112
+ log.warning("[FETCH] failed %s... - %s", url[:60], exc)
113
+
114
+ return results
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from .diagnostics import log
4
+
5
+
6
+ def search_extract_rerank(query, num_fetch=None, lang="en", debug=False):
7
+ """
8
+ Full pipeline:
9
+ 1. Search Bing+DDG → merge
10
+ 2. Take top N results
11
+ 3. Parallel fetch + extraction
12
+ 4. Chunk extracted text
13
+ 5. Rerank chunks vs query
14
+ 6. Return ranked context chunks with metadata
15
+ """
16
+ from . import core
17
+ from .extract import chunk_text, filter_low_quality_chunks
18
+ from .fetch import fetch_pages_parallel
19
+ from .rerank import filter_results_by_relevance, rerank_chunks
20
+ from .search import search
21
+
22
+ if num_fetch is None:
23
+ num_fetch = core.TOP_N_FETCH
24
+
25
+ log.info("QUERY: %s", query)
26
+
27
+ all_results = search(query, num=20, lang=lang, debug=debug)
28
+ if not all_results:
29
+ return [], [], set()
30
+
31
+ threshold = 0.25 if lang == "ru" else 0.30
32
+ log.info("[PIPELINE] Pre-filtering %s results by semantic relevance (threshold=%s)", len(all_results), threshold)
33
+ all_results = filter_results_by_relevance(query, all_results, threshold=threshold, lang=lang)
34
+
35
+ if not all_results:
36
+ log.info("[PIPELINE] No relevant results after pre-filtering")
37
+ return [], [], set()
38
+
39
+ top_results = all_results[:num_fetch]
40
+ log.info("[PIPELINE] Top %s results to fetch:", len(top_results))
41
+ for i, result in enumerate(top_results, 1):
42
+ engines = ", ".join(result["engines"])
43
+ log.info(" %s. [%s] (score=%s) %s", i, engines, result["score"], result["title"][:60])
44
+ log.info(" %s", result["url"][:80])
45
+
46
+ log.info("[PIPELINE] Fetching %s pages in parallel", len(top_results))
47
+ urls = [result["url"] for result in top_results]
48
+ fetched = fetch_pages_parallel(urls, query=query, lang=lang)
49
+
50
+ if not fetched:
51
+ log.info("[PIPELINE] No pages fetched successfully; using snippets only")
52
+ chunks_with_meta = []
53
+ snippet_urls = set()
54
+ for i, result in enumerate(top_results):
55
+ if result["snippet"]:
56
+ chunks_with_meta.append(
57
+ {
58
+ "text": result["snippet"],
59
+ "source_idx": i,
60
+ "source_url": result["url"],
61
+ "source_title": result["title"],
62
+ "chunk_idx": 0,
63
+ }
64
+ )
65
+ snippet_urls.add(result["url"])
66
+ ranked = rerank_chunks(query, chunks_with_meta, top_k=12, lang=lang)
67
+ return ranked, top_results, snippet_urls
68
+
69
+ log.info("[PIPELINE] Chunking %s pages", len(fetched))
70
+ all_chunks = []
71
+
72
+ for i, result in enumerate(top_results):
73
+ page_data = fetched.get(result["url"])
74
+ if not page_data:
75
+ if result["snippet"]:
76
+ all_chunks.append(
77
+ {
78
+ "text": result["snippet"],
79
+ "source_idx": i,
80
+ "source_url": result["url"],
81
+ "source_title": result["title"],
82
+ "chunk_idx": 0,
83
+ "pub_date": None,
84
+ }
85
+ )
86
+ continue
87
+
88
+ text = page_data["text"]
89
+ pub_date = page_data.get("pub_date")
90
+ chunks = filter_low_quality_chunks(chunk_text(text, lang=lang))
91
+ log.info(" Source %s: %s chunks from %s", i + 1, len(chunks), result["title"][:50])
92
+
93
+ for ci, chunk in enumerate(chunks):
94
+ all_chunks.append(
95
+ {
96
+ "text": chunk,
97
+ "source_idx": i,
98
+ "source_url": result["url"],
99
+ "source_title": result["title"],
100
+ "chunk_idx": ci,
101
+ "pub_date": pub_date,
102
+ }
103
+ )
104
+
105
+ log.info(" Total chunks: %s", len(all_chunks))
106
+
107
+ ranked = rerank_chunks(query, all_chunks, top_k=12, lang=lang)
108
+ log.info(" Selected top %s chunks:", len(ranked))
109
+ for chunk in ranked:
110
+ if core.HAS_EMBEDDINGS:
111
+ log.info(
112
+ " src=%s chunk=%s rel=%.3f (bm25=%.3f sem=%.3f) - %s...",
113
+ chunk["source_idx"] + 1,
114
+ chunk["chunk_idx"],
115
+ chunk["relevance"],
116
+ chunk["bm25"],
117
+ chunk["semantic"],
118
+ chunk["text"][:50],
119
+ )
120
+ else:
121
+ log.info(
122
+ " src=%s chunk=%s rel=%.4f - %s...",
123
+ chunk["source_idx"] + 1,
124
+ chunk["chunk_idx"],
125
+ chunk["relevance"],
126
+ chunk["text"][:60],
127
+ )
128
+
129
+ fetched_urls = set(fetched.keys()) if fetched else set()
130
+ return ranked, top_results, fetched_urls
131
+
132
+
133
+ def build_llm_context(ranked_chunks, search_results, fetched_urls=None, renumber_sources=True):
134
+ if not ranked_chunks:
135
+ return "No relevant content found.", {}, {}
136
+
137
+ by_source = {}
138
+ skipped_ghost_sources = set()
139
+ for chunk in ranked_chunks:
140
+ idx = chunk["source_idx"]
141
+ url = chunk["source_url"]
142
+ if fetched_urls is not None and url not in fetched_urls:
143
+ skipped_ghost_sources.add((idx, chunk["source_title"], url))
144
+ continue
145
+ if idx not in by_source:
146
+ by_source[idx] = {"title": chunk["source_title"], "url": url, "chunks": []}
147
+ by_source[idx]["chunks"].append(chunk)
148
+
149
+ if skipped_ghost_sources:
150
+ log.info("[CONTEXT] Filtered out %s sources with failed fetch:", len(skipped_ghost_sources))
151
+ for idx, title, url in sorted(skipped_ghost_sources):
152
+ log.info(" [%s] %s... - %s", idx + 1, title[:60], url[:60])
153
+
154
+ filtered_sources = {}
155
+ for idx, source_data in by_source.items():
156
+ if source_data["chunks"]:
157
+ filtered_sources[idx] = source_data
158
+
159
+ by_source = filtered_sources
160
+ if not by_source:
161
+ return "No relevant sources found.", {}, {}
162
+
163
+ if renumber_sources:
164
+ source_mapping = {old_idx: new_idx + 1 for new_idx, old_idx in enumerate(sorted(by_source.keys()))}
165
+ else:
166
+ source_mapping = {idx: idx + 1 for idx in by_source.keys()}
167
+
168
+ parts = []
169
+ for old_idx in sorted(by_source.keys()):
170
+ src = by_source[old_idx]
171
+ src_num = source_mapping[old_idx]
172
+ parts.append(f"[{src_num}] {src['title']}")
173
+ for chunk in src["chunks"]:
174
+ parts.append(chunk["text"].replace("**", ""))
175
+ parts.append("")
176
+
177
+ return "\n".join(parts), source_mapping, by_source