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.
- footnote_mcp/__init__.py +13 -0
- footnote_mcp/__main__.py +6 -0
- footnote_mcp/confidence.py +65 -0
- footnote_mcp/core.py +65 -0
- footnote_mcp/diagnostics.py +21 -0
- footnote_mcp/extract.py +216 -0
- footnote_mcp/fetch.py +114 -0
- footnote_mcp/pipeline.py +177 -0
- footnote_mcp/rerank.py +326 -0
- footnote_mcp/scraper.py +519 -0
- footnote_mcp/search.py +372 -0
- footnote_mcp/semantic.py +89 -0
- footnote_mcp/server.py +770 -0
- footnote_mcp/tools_browser.py +491 -0
- footnote_mcp/tools_data/__init__.py +47 -0
- footnote_mcp/tools_data/cache.py +51 -0
- footnote_mcp/tools_data/classify.py +144 -0
- footnote_mcp/tools_data/dates.py +116 -0
- footnote_mcp/tools_data/entailment.py +197 -0
- footnote_mcp/tools_data/files.py +331 -0
- footnote_mcp/tools_data/health.py +68 -0
- footnote_mcp/tools_data/sandbox.py +353 -0
- footnote_mcp/tools_extra.py +563 -0
- footnote_mcp/tools_search.py +182 -0
- footnote_mcp-1.0.0.dist-info/METADATA +286 -0
- footnote_mcp-1.0.0.dist-info/RECORD +30 -0
- footnote_mcp-1.0.0.dist-info/WHEEL +5 -0
- footnote_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- footnote_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- footnote_mcp-1.0.0.dist-info/top_level.txt +1 -0
footnote_mcp/__init__.py
ADDED
|
@@ -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()
|
footnote_mcp/__main__.py
ADDED
|
@@ -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()
|
footnote_mcp/extract.py
ADDED
|
@@ -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
|
footnote_mcp/pipeline.py
ADDED
|
@@ -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
|