Spaces:
Running
Running
Sync from GitHub 7acd8038042b484e9650a6acfffda15038b5f1f3
Browse files- DEPLOYMENT_CHECKLIST.md +3 -3
- README.md +3 -3
- docs/deploy_readiness_checklist.md +1 -1
- docs/weaviate_database_setup.md +1 -1
- main.py +5 -7
- requirements.txt +1 -1
- src/database/weavservice.py +267 -47
- src/pipeline/pipeline.py +96 -102
- src/scraping/scraper.py +118 -25
- src/scraping/types.py +17 -0
- src/scraping/utils.py +2 -2
- tests/scraping/test_scraping.py +63 -1
- tests/scraping/test_scraping_resume.py +43 -4
- tests/test_master_transfer_integrations.py +3 -2
- tests/test_safe_import_reconciliation.py +369 -0
- tools/scraping.py +5 -3
DEPLOYMENT_CHECKLIST.md
CHANGED
|
@@ -70,7 +70,7 @@ Cron auf demselben Host:
|
|
| 70 |
- [x] EU-Cluster bereitgestellt, `.env`: `WEAVIATE_CLUSTER_URL` + `WEAVIATE_API_KEY` gesetzt
|
| 71 |
- [x] `python main.py --weaviate checkhealth` → Connection ✓ OK
|
| 72 |
- [x] `python main.py --weaviate init` → Collections `hsg_rag_content_de`/`_en` angelegt
|
| 73 |
-
- [ ] **Datenimport abgeschlossen:** `python main.py --scrape
|
| 74 |
(läuft; danach Objekt-Counts in beiden Collections plausibel prüfen — EN/embax nicht unterrepräsentiert)
|
| 75 |
- [ ] `python main.py --weaviate checkhealth` → beide Collections ✓ OK
|
| 76 |
- [ ] Stichprobe: Query "Was macht die HSG besonders?" liefert echte Chunks (keine `QUERY_EXCEPTION_MESSAGE`)
|
|
@@ -109,8 +109,8 @@ Cron auf demselben Host:
|
|
| 109 |
```
|
| 110 |
- [ ] **Scraping-Refresh** — als Cron (robuster als Dauerprozess):
|
| 111 |
```
|
| 112 |
-
0 3 * * 1-6 cd <repo> && ./venv/bin/python main.py --scrape
|
| 113 |
-
0 2 * * 0 cd <repo> && ./venv/bin/python main.py --scrape
|
| 114 |
```
|
| 115 |
(Alternative: `python tools/scraping.py --init_sched` als systemd-Service — APScheduler intern.)
|
| 116 |
- [ ] **Alert-Chain einmal testen:** Preis in `data/database/programme_facts.json` ändern →
|
|
|
|
| 70 |
- [x] EU-Cluster bereitgestellt, `.env`: `WEAVIATE_CLUSTER_URL` + `WEAVIATE_API_KEY` gesetzt
|
| 71 |
- [x] `python main.py --weaviate checkhealth` → Connection ✓ OK
|
| 72 |
- [x] `python main.py --weaviate init` → Collections `hsg_rag_content_de`/`_en` angelegt
|
| 73 |
+
- [ ] **Datenimport abgeschlossen:** `python main.py --scrape full`
|
| 74 |
(läuft; danach Objekt-Counts in beiden Collections plausibel prüfen — EN/embax nicht unterrepräsentiert)
|
| 75 |
- [ ] `python main.py --weaviate checkhealth` → beide Collections ✓ OK
|
| 76 |
- [ ] Stichprobe: Query "Was macht die HSG besonders?" liefert echte Chunks (keine `QUERY_EXCEPTION_MESSAGE`)
|
|
|
|
| 109 |
```
|
| 110 |
- [ ] **Scraping-Refresh** — als Cron (robuster als Dauerprozess):
|
| 111 |
```
|
| 112 |
+
0 3 * * 1-6 cd <repo> && ./venv/bin/python main.py --scrape simple >> logs/scrape.log 2>&1
|
| 113 |
+
0 2 * * 0 cd <repo> && ./venv/bin/python main.py --scrape full >> logs/scrape.log 2>&1
|
| 114 |
```
|
| 115 |
(Alternative: `python tools/scraping.py --init_sched` als systemd-Service — APScheduler intern.)
|
| 116 |
- [ ] **Alert-Chain einmal testen:** Preis in `data/database/programme_facts.json` ändern →
|
README.md
CHANGED
|
@@ -154,8 +154,8 @@ python main.py --help
|
|
| 154 |
Useful operational commands:
|
| 155 |
|
| 156 |
```bash
|
| 157 |
-
python main.py --scrape
|
| 158 |
-
python main.py --scrape
|
| 159 |
python main.py --imports path/to/file1 path/to/file2
|
| 160 |
python main.py --weaviate checkhealth
|
| 161 |
python main.py --weaviate init
|
|
@@ -167,7 +167,7 @@ Embedding model changes require a Weaviate collection rebuild and re-import:
|
|
| 167 |
|
| 168 |
```bash
|
| 169 |
python main.py --weaviate redo
|
| 170 |
-
python main.py --scrape
|
| 171 |
# plus python main.py --imports ... for any local source files you maintain
|
| 172 |
```
|
| 173 |
|
|
|
|
| 154 |
Useful operational commands:
|
| 155 |
|
| 156 |
```bash
|
| 157 |
+
python main.py --scrape simple
|
| 158 |
+
python main.py --scrape full
|
| 159 |
python main.py --imports path/to/file1 path/to/file2
|
| 160 |
python main.py --weaviate checkhealth
|
| 161 |
python main.py --weaviate init
|
|
|
|
| 167 |
|
| 168 |
```bash
|
| 169 |
python main.py --weaviate redo
|
| 170 |
+
python main.py --scrape full
|
| 171 |
# plus python main.py --imports ... for any local source files you maintain
|
| 172 |
```
|
| 173 |
|
docs/deploy_readiness_checklist.md
CHANGED
|
@@ -75,7 +75,7 @@ This checklist reflects the GitHub `main` state at commit `c0462c1b7c5074af682ec
|
|
| 75 |
- Verify admissions handover path
|
| 76 |
- Verify booking widget visibility
|
| 77 |
- If imports are part of rollout:
|
| 78 |
-
- verify `--scrape`
|
| 79 |
- verify `--imports`
|
| 80 |
- If admin operations are required:
|
| 81 |
- verify the DB app
|
|
|
|
| 75 |
- Verify admissions handover path
|
| 76 |
- Verify booking widget visibility
|
| 77 |
- If imports are part of rollout:
|
| 78 |
+
- verify `--scrape simple` and `--scrape full`
|
| 79 |
- verify `--imports`
|
| 80 |
- If admin operations are required:
|
| 81 |
- verify the DB app
|
docs/weaviate_database_setup.md
CHANGED
|
@@ -25,7 +25,7 @@ When changing embedding model, tokenizer, or vector dimensions, rebuild the coll
|
|
| 25 |
|
| 26 |
```bash
|
| 27 |
python main.py --weaviate redo
|
| 28 |
-
python main.py --scrape
|
| 29 |
```
|
| 30 |
|
| 31 |
Run `python main.py --imports ...` afterward for any local documents that are
|
|
|
|
| 25 |
|
| 26 |
```bash
|
| 27 |
python main.py --weaviate redo
|
| 28 |
+
python main.py --scrape full
|
| 29 |
```
|
| 30 |
|
| 31 |
Run `python main.py --imports ...` afterward for any local documents that are
|
main.py
CHANGED
|
@@ -14,18 +14,18 @@ def logging_startup():
|
|
| 14 |
return get_logger('main_module')
|
| 15 |
|
| 16 |
|
| 17 |
-
def run_scraper(
|
| 18 |
"""
|
| 19 |
Run the scraper to collect program data.
|
| 20 |
|
| 21 |
Args:
|
| 22 |
-
|
| 23 |
"""
|
| 24 |
from src.pipeline.pipeline import ImportPipeline
|
| 25 |
logger = logging_startup()
|
| 26 |
|
| 27 |
logger.info("Running scraper...")
|
| 28 |
-
ImportPipeline().scrape_website(scrape_all=
|
| 29 |
logger.info("Scraping completed.")
|
| 30 |
|
| 31 |
|
|
@@ -89,10 +89,8 @@ def parse_args():
|
|
| 89 |
parser = argparse.ArgumentParser(description="University of St. Gallen Executive Education RAG Chatbot")
|
| 90 |
|
| 91 |
# Add arguments
|
| 92 |
-
parser.add_argument("--scrape",
|
| 93 |
help="Scrapes the data from the HSG website and imports it into the database")
|
| 94 |
-
parser.add_argument("--full_scrape", action="store_true",
|
| 95 |
-
help="When used with --scrape, ignores timestamps and scrapes all pages")
|
| 96 |
parser.add_argument("--imports", nargs="+", help="Runs the data importing pipeline for the provided files")
|
| 97 |
|
| 98 |
parser.add_argument("--weaviate", type=str, choices=['init', 'delete', 'redo', 'checkhealth', 'backup', 'restore'],
|
|
@@ -118,7 +116,7 @@ def main():
|
|
| 118 |
|
| 119 |
# Run the specified components
|
| 120 |
if args.scrape:
|
| 121 |
-
run_scraper(args.
|
| 122 |
|
| 123 |
if args.imports:
|
| 124 |
run_importer(args.imports)
|
|
|
|
| 14 |
return get_logger('main_module')
|
| 15 |
|
| 16 |
|
| 17 |
+
def run_scraper(scrape_type: str) -> None:
|
| 18 |
"""
|
| 19 |
Run the scraper to collect program data.
|
| 20 |
|
| 21 |
Args:
|
| 22 |
+
scrape_type: Whether to ignore timestamps and scrape all pages.
|
| 23 |
"""
|
| 24 |
from src.pipeline.pipeline import ImportPipeline
|
| 25 |
logger = logging_startup()
|
| 26 |
|
| 27 |
logger.info("Running scraper...")
|
| 28 |
+
ImportPipeline().scrape_website(scrape_all=True if scrape_type == 'full' else False)
|
| 29 |
logger.info("Scraping completed.")
|
| 30 |
|
| 31 |
|
|
|
|
| 89 |
parser = argparse.ArgumentParser(description="University of St. Gallen Executive Education RAG Chatbot")
|
| 90 |
|
| 91 |
# Add arguments
|
| 92 |
+
parser.add_argument("--scrape", type=str, choices=['simple', 'full'],
|
| 93 |
help="Scrapes the data from the HSG website and imports it into the database")
|
|
|
|
|
|
|
| 94 |
parser.add_argument("--imports", nargs="+", help="Runs the data importing pipeline for the provided files")
|
| 95 |
|
| 96 |
parser.add_argument("--weaviate", type=str, choices=['init', 'delete', 'redo', 'checkhealth', 'backup', 'restore'],
|
|
|
|
| 116 |
|
| 117 |
# Run the specified components
|
| 118 |
if args.scrape:
|
| 119 |
+
run_scraper(args.scrape)
|
| 120 |
|
| 121 |
if args.imports:
|
| 122 |
run_importer(args.imports)
|
requirements.txt
CHANGED
|
@@ -30,7 +30,7 @@ beautifulsoup4>=4.14.3
|
|
| 30 |
fake-useragent>=1.5.1
|
| 31 |
|
| 32 |
# Weaviate Vector DB
|
| 33 |
-
weaviate-client>=4.16.9
|
| 34 |
PyYAML>=6.0
|
| 35 |
|
| 36 |
# Scheduling
|
|
|
|
| 30 |
fake-useragent>=1.5.1
|
| 31 |
|
| 32 |
# Weaviate Vector DB
|
| 33 |
+
weaviate-client>=4.16.9,<5
|
| 34 |
PyYAML>=6.0
|
| 35 |
|
| 36 |
# Scheduling
|
src/database/weavservice.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
from functools import reduce
|
| 2 |
import weaviate as wvt
|
| 3 |
-
import datetime, os
|
|
|
|
| 4 |
from threading import Event, Lock, RLock, Thread
|
|
|
|
| 5 |
|
| 6 |
from time import perf_counter, sleep
|
| 7 |
from weaviate.classes.config import Configure, Property, DataType
|
|
@@ -10,6 +12,7 @@ from weaviate.collections.collection import Collection
|
|
| 10 |
from weaviate.classes.init import AdditionalConfig, Timeout
|
| 11 |
from weaviate.classes.query import Filter
|
| 12 |
from weaviate.config import AdditionalConfig
|
|
|
|
| 13 |
|
| 14 |
from ..utils.logging import get_logger
|
| 15 |
from ..config import config
|
|
@@ -19,6 +22,7 @@ logger = get_logger("weaviate_service")
|
|
| 19 |
|
| 20 |
_get_collection_name = lambda lang: f'{config.weaviate.WEAVIATE_COLLECTION_BASENAME}_{lang}'
|
| 21 |
_collection_names = [_get_collection_name(lang) for lang in config.get('AVAILABLE_LANGUAGES')]
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _default_properties() -> list[Property]:
|
|
@@ -32,6 +36,26 @@ def _default_properties() -> list[Property]:
|
|
| 32 |
]
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
class WeaviateService:
|
| 36 |
"""
|
| 37 |
Provides an interface for interacting with the Weaviate vector database.
|
|
@@ -323,74 +347,270 @@ class WeaviateService:
|
|
| 323 |
return client.collections.use(collection_name), collection_name
|
| 324 |
|
| 325 |
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
-
Args:
|
| 331 |
-
data_rows (list): List of dictionaries representing the data rows to import.
|
| 332 |
-
lang (str, optional): Language collection to use. If not provided, uses the current one.
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
-
Raises:
|
| 338 |
-
If no active collection is available or a connection error was catched.
|
| 339 |
-
"""
|
| 340 |
collection, collection_name = self._select_collection(lang)
|
| 341 |
if collection is None:
|
| 342 |
-
|
| 343 |
-
return []
|
| 344 |
-
|
| 345 |
-
import_errors = []
|
| 346 |
-
logger.info(f"Batch importing {len(data_rows)} rows into {collection_name}")
|
| 347 |
|
|
|
|
| 348 |
batch_size = max(1, config.processing.EMBEDDING_BATCH_SIZE)
|
| 349 |
max_attempts = 2
|
| 350 |
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
try:
|
| 356 |
-
batch.add_object(properties=data_row, vector=vector)
|
| 357 |
-
except Exception as e:
|
| 358 |
-
import_errors.append({'index': idx, 'chunk_id': data_row['chunk_id'], 'error': str(e)})
|
| 359 |
-
|
| 360 |
-
if idx % 20 == 0 and idx > 0:
|
| 361 |
-
if batch.number_errors > 0:
|
| 362 |
-
logger.info(f"Failed imports at index {idx}: {batch.number_errors}")
|
| 363 |
-
|
| 364 |
-
try:
|
| 365 |
-
with self._client_lock:
|
| 366 |
-
for start_idx in range(0, len(data_rows), batch_size):
|
| 367 |
-
batch_rows = list(enumerate(data_rows[start_idx:start_idx + batch_size], start=start_idx))
|
| 368 |
-
for attempt in range(1, max_attempts + 1):
|
| 369 |
-
try:
|
| 370 |
-
_import_batch(batch_rows)
|
| 371 |
-
break
|
| 372 |
-
except Exception as e:
|
| 373 |
-
if attempt == max_attempts:
|
| 374 |
-
raise e
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
logger.warning(
|
| 377 |
"Batch import failed for rows %s-%s; retrying once: %s",
|
| 378 |
start_idx,
|
| 379 |
-
start_idx + len(
|
| 380 |
-
|
| 381 |
)
|
| 382 |
sleep(1)
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
except Exception as e:
|
| 388 |
if 'connection' in str(e).lower():
|
| 389 |
logger.error(f"Connection error during batch import: {e}")
|
| 390 |
self._client = None
|
| 391 |
raise e
|
| 392 |
|
| 393 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
|
| 396 |
@staticmethod
|
|
|
|
| 1 |
from functools import reduce
|
| 2 |
import weaviate as wvt
|
| 3 |
+
import datetime, json, os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
from threading import Event, Lock, RLock, Thread
|
| 6 |
+
from typing import Any
|
| 7 |
|
| 8 |
from time import perf_counter, sleep
|
| 9 |
from weaviate.classes.config import Configure, Property, DataType
|
|
|
|
| 12 |
from weaviate.classes.init import AdditionalConfig, Timeout
|
| 13 |
from weaviate.classes.query import Filter
|
| 14 |
from weaviate.config import AdditionalConfig
|
| 15 |
+
from weaviate.util import generate_uuid5
|
| 16 |
|
| 17 |
from ..utils.logging import get_logger
|
| 18 |
from ..config import config
|
|
|
|
| 22 |
|
| 23 |
_get_collection_name = lambda lang: f'{config.weaviate.WEAVIATE_COLLECTION_BASENAME}_{lang}'
|
| 24 |
_collection_names = [_get_collection_name(lang) for lang in config.get('AVAILABLE_LANGUAGES')]
|
| 25 |
+
_IMPORT_ID_VERSION = 1
|
| 26 |
|
| 27 |
|
| 28 |
def _default_properties() -> list[Property]:
|
|
|
|
| 36 |
]
|
| 37 |
|
| 38 |
|
| 39 |
+
class ImportBatchError(RuntimeError):
|
| 40 |
+
"""Raised when Weaviate rejects any object in a strict batch import."""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class PreparedImportRow:
|
| 45 |
+
uuid: str
|
| 46 |
+
properties: dict[str, Any]
|
| 47 |
+
vector: dict[str, list[float]]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class SourceReconciliationSummary:
|
| 52 |
+
source: str
|
| 53 |
+
expected: int = 0
|
| 54 |
+
retained: int = 0
|
| 55 |
+
inserted: int = 0
|
| 56 |
+
deleted: int = 0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
class WeaviateService:
|
| 60 |
"""
|
| 61 |
Provides an interface for interacting with the Weaviate vector database.
|
|
|
|
| 347 |
return client.collections.use(collection_name), collection_name
|
| 348 |
|
| 349 |
|
| 350 |
+
@staticmethod
|
| 351 |
+
def _deterministic_object_uuid(data_row: dict[str, Any], lang: str) -> str:
|
| 352 |
+
identity = {
|
| 353 |
+
"version": _IMPORT_ID_VERSION,
|
| 354 |
+
"lang": lang,
|
| 355 |
+
"source": data_row.get("source", ""),
|
| 356 |
+
"document_id": data_row.get("document_id", ""),
|
| 357 |
+
"chunk_id": data_row.get("chunk_id", ""),
|
| 358 |
+
"programs": sorted(data_row.get("programs") or []),
|
| 359 |
+
}
|
| 360 |
+
return generate_uuid5(
|
| 361 |
+
json.dumps(identity, sort_keys=True, separators=(",", ":")),
|
| 362 |
+
namespace="hsg-rag-import",
|
| 363 |
+
)
|
| 364 |
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
+
def prepare_batch_import(
|
| 367 |
+
self,
|
| 368 |
+
data_rows: list[dict[str, Any]],
|
| 369 |
+
lang: str,
|
| 370 |
+
) -> list[PreparedImportRow]:
|
| 371 |
+
"""Generate all embeddings and deterministic UUIDs without writing to Weaviate."""
|
| 372 |
+
prepared = []
|
| 373 |
+
batch_size = max(1, config.processing.EMBEDDING_BATCH_SIZE)
|
| 374 |
+
|
| 375 |
+
for start_idx in range(0, len(data_rows), batch_size):
|
| 376 |
+
batch_rows = list(
|
| 377 |
+
enumerate(
|
| 378 |
+
data_rows[start_idx:start_idx + batch_size],
|
| 379 |
+
start=start_idx,
|
| 380 |
+
)
|
| 381 |
+
)
|
| 382 |
+
vectors = self._embed_batch_vectors(batch_rows)
|
| 383 |
+
prepared.extend(
|
| 384 |
+
PreparedImportRow(
|
| 385 |
+
uuid=self._deterministic_object_uuid(data_row, lang),
|
| 386 |
+
properties=data_row,
|
| 387 |
+
vector=vector,
|
| 388 |
+
)
|
| 389 |
+
for (_, data_row), vector in zip(batch_rows, vectors)
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
return prepared
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _write_prepared_rows(
|
| 396 |
+
self,
|
| 397 |
+
prepared_rows: list[PreparedImportRow],
|
| 398 |
+
lang: str,
|
| 399 |
+
) -> None:
|
| 400 |
+
if not prepared_rows:
|
| 401 |
+
return
|
| 402 |
|
|
|
|
|
|
|
|
|
|
| 403 |
collection, collection_name = self._select_collection(lang)
|
| 404 |
if collection is None:
|
| 405 |
+
raise RuntimeError(f"No working collection selected for language '{lang}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
+
logger.info(f"Batch importing {len(prepared_rows)} rows into {collection_name}")
|
| 408 |
batch_size = max(1, config.processing.EMBEDDING_BATCH_SIZE)
|
| 409 |
max_attempts = 2
|
| 410 |
|
| 411 |
+
with self._client_lock:
|
| 412 |
+
for start_idx in range(0, len(prepared_rows), batch_size):
|
| 413 |
+
rows = prepared_rows[start_idx:start_idx + batch_size]
|
| 414 |
+
last_error = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
| 416 |
+
for attempt in range(1, max_attempts + 1):
|
| 417 |
+
try:
|
| 418 |
+
failed_before = list(
|
| 419 |
+
getattr(collection.batch, "failed_objects", [])
|
| 420 |
+
)
|
| 421 |
+
with collection.batch.fixed_size(
|
| 422 |
+
batch_size=batch_size,
|
| 423 |
+
concurrent_requests=1,
|
| 424 |
+
) as batch:
|
| 425 |
+
for row in rows:
|
| 426 |
+
batch.add_object(
|
| 427 |
+
uuid=row.uuid,
|
| 428 |
+
properties=row.properties,
|
| 429 |
+
vector=row.vector,
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
failed_after = list(
|
| 433 |
+
getattr(collection.batch, "failed_objects", [])
|
| 434 |
+
)
|
| 435 |
+
new_failures = (
|
| 436 |
+
failed_after[len(failed_before):]
|
| 437 |
+
if len(failed_after) >= len(failed_before)
|
| 438 |
+
else failed_after
|
| 439 |
+
)
|
| 440 |
+
error_count = max(
|
| 441 |
+
getattr(batch, "number_errors", 0),
|
| 442 |
+
len(new_failures),
|
| 443 |
+
)
|
| 444 |
+
if error_count:
|
| 445 |
+
details = "; ".join(str(error) for error in new_failures[:3])
|
| 446 |
+
raise ImportBatchError(
|
| 447 |
+
f"Weaviate rejected {error_count} object(s)"
|
| 448 |
+
+ (f": {details}" if details else "")
|
| 449 |
+
)
|
| 450 |
+
last_error = None
|
| 451 |
+
break
|
| 452 |
+
except Exception as error:
|
| 453 |
+
last_error = error
|
| 454 |
+
if attempt < max_attempts:
|
| 455 |
logger.warning(
|
| 456 |
"Batch import failed for rows %s-%s; retrying once: %s",
|
| 457 |
start_idx,
|
| 458 |
+
start_idx + len(rows) - 1,
|
| 459 |
+
error,
|
| 460 |
)
|
| 461 |
sleep(1)
|
| 462 |
|
| 463 |
+
if last_error is not None:
|
| 464 |
+
raise ImportBatchError(
|
| 465 |
+
f"Failed importing rows {start_idx}-{start_idx + len(rows) - 1} "
|
| 466 |
+
f"into {collection_name}: {last_error}"
|
| 467 |
+
) from last_error
|
| 468 |
+
|
| 469 |
+
self._last_query_time = perf_counter()
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def batch_import(self, data_rows: list, lang: str) -> list:
|
| 473 |
+
"""
|
| 474 |
+
Perform a strict, deterministic batch import into the current collection.
|
| 475 |
+
|
| 476 |
+
Args:
|
| 477 |
+
data_rows (list): List of dictionaries representing the data rows to import.
|
| 478 |
+
lang (str, optional): Language collection to use. If not provided, uses the current one.
|
| 479 |
+
|
| 480 |
+
Returns:
|
| 481 |
+
An empty list on success, retained for compatibility.
|
| 482 |
+
|
| 483 |
+
Raises:
|
| 484 |
+
ImportBatchError: If any synchronous or asynchronous object import fails.
|
| 485 |
+
"""
|
| 486 |
+
try:
|
| 487 |
+
prepared_rows = self.prepare_batch_import(data_rows, lang)
|
| 488 |
+
self._write_prepared_rows(prepared_rows, lang)
|
| 489 |
+
logger.info("Batch import finished successfully")
|
| 490 |
except Exception as e:
|
| 491 |
if 'connection' in str(e).lower():
|
| 492 |
logger.error(f"Connection error during batch import: {e}")
|
| 493 |
self._client = None
|
| 494 |
raise e
|
| 495 |
|
| 496 |
+
return []
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _collect_source_objects(
|
| 500 |
+
self,
|
| 501 |
+
lang: str,
|
| 502 |
+
source: str,
|
| 503 |
+
) -> dict[str, dict[str, Any]]:
|
| 504 |
+
collection, _ = self._select_collection(lang)
|
| 505 |
+
if collection is None:
|
| 506 |
+
raise RuntimeError(f"No working collection selected for language '{lang}'")
|
| 507 |
+
|
| 508 |
+
objects = {}
|
| 509 |
+
offset = 0
|
| 510 |
+
page_size = 100
|
| 511 |
+
filters = Filter.by_property("source").equal(source)
|
| 512 |
+
|
| 513 |
+
while True:
|
| 514 |
+
with self._client_lock:
|
| 515 |
+
response = collection.query.fetch_objects(
|
| 516 |
+
limit=page_size,
|
| 517 |
+
offset=offset,
|
| 518 |
+
filters=filters,
|
| 519 |
+
return_properties=["source", "chunk_id", "document_id", "programs"],
|
| 520 |
+
)
|
| 521 |
+
page = response.objects
|
| 522 |
+
for obj in page:
|
| 523 |
+
objects[str(obj.uuid)] = obj.properties or {}
|
| 524 |
+
|
| 525 |
+
if len(page) < page_size:
|
| 526 |
+
break
|
| 527 |
+
offset += page_size
|
| 528 |
+
|
| 529 |
+
return objects
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def source_object_count(self, source: str) -> int:
|
| 533 |
+
return sum(
|
| 534 |
+
len(self._collect_source_objects(lang, source))
|
| 535 |
+
for lang in config.get('AVAILABLE_LANGUAGES')
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def reconcile_source(
|
| 540 |
+
self,
|
| 541 |
+
source: str,
|
| 542 |
+
rows_by_language: dict[str, list[dict[str, Any]]],
|
| 543 |
+
) -> SourceReconciliationSummary:
|
| 544 |
+
"""Insert and verify a complete source replacement before deleting old objects."""
|
| 545 |
+
languages = config.get('AVAILABLE_LANGUAGES')
|
| 546 |
+
prepared_by_language = {
|
| 547 |
+
lang: self.prepare_batch_import(rows_by_language.get(lang, []), lang)
|
| 548 |
+
for lang in languages
|
| 549 |
+
}
|
| 550 |
+
expected_ids = {
|
| 551 |
+
lang: {row.uuid for row in prepared_by_language[lang]}
|
| 552 |
+
for lang in languages
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
existing_before = {
|
| 556 |
+
lang: self._collect_source_objects(lang, source)
|
| 557 |
+
for lang in languages
|
| 558 |
+
}
|
| 559 |
+
summary = SourceReconciliationSummary(
|
| 560 |
+
source=source,
|
| 561 |
+
expected=sum(len(ids) for ids in expected_ids.values()),
|
| 562 |
+
retained=sum(
|
| 563 |
+
len(expected_ids[lang] & set(existing_before[lang]))
|
| 564 |
+
for lang in languages
|
| 565 |
+
),
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
for lang in languages:
|
| 569 |
+
missing_rows = [
|
| 570 |
+
row for row in prepared_by_language[lang]
|
| 571 |
+
if row.uuid not in existing_before[lang]
|
| 572 |
+
]
|
| 573 |
+
self._write_prepared_rows(missing_rows, lang)
|
| 574 |
+
summary.inserted += len(missing_rows)
|
| 575 |
+
|
| 576 |
+
verified = {
|
| 577 |
+
lang: self._collect_source_objects(lang, source)
|
| 578 |
+
for lang in languages
|
| 579 |
+
}
|
| 580 |
+
for lang in languages:
|
| 581 |
+
missing_ids = expected_ids[lang] - set(verified[lang])
|
| 582 |
+
if missing_ids:
|
| 583 |
+
raise ImportBatchError(
|
| 584 |
+
f"Verification failed for source '{source}' in language '{lang}': "
|
| 585 |
+
f"{len(missing_ids)} expected object(s) are missing"
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
for lang in languages:
|
| 589 |
+
obsolete_ids = set(verified[lang]) - expected_ids[lang]
|
| 590 |
+
if not obsolete_ids:
|
| 591 |
+
continue
|
| 592 |
+
|
| 593 |
+
collection, collection_name = self._select_collection(lang)
|
| 594 |
+
if collection is None:
|
| 595 |
+
raise RuntimeError(f"No working collection selected for language '{lang}'")
|
| 596 |
+
|
| 597 |
+
for object_uuid in obsolete_ids:
|
| 598 |
+
if not collection.data.delete_by_id(object_uuid):
|
| 599 |
+
raise RuntimeError(
|
| 600 |
+
f"Failed deleting obsolete object {object_uuid} from {collection_name}"
|
| 601 |
+
)
|
| 602 |
+
summary.deleted += 1
|
| 603 |
+
|
| 604 |
+
self._last_query_time = perf_counter()
|
| 605 |
+
logger.info(
|
| 606 |
+
"Reconciled source %s: expected=%s retained=%s inserted=%s deleted=%s",
|
| 607 |
+
source,
|
| 608 |
+
summary.expected,
|
| 609 |
+
summary.retained,
|
| 610 |
+
summary.inserted,
|
| 611 |
+
summary.deleted,
|
| 612 |
+
)
|
| 613 |
+
return summary
|
| 614 |
|
| 615 |
|
| 616 |
@staticmethod
|
src/pipeline/pipeline.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
from .utils import *
|
| 2 |
from .processors import *
|
| 3 |
from ..scraping.scraper import Scraper
|
|
|
|
| 4 |
|
| 5 |
-
from ..database.weavservice
|
| 6 |
from ..utils.logging import get_logger
|
| 7 |
from ..utils.tools import call_with_exponential_backoff
|
| 8 |
from ..config import config
|
|
@@ -13,8 +14,7 @@ implogger = get_logger("import_pipeline")
|
|
| 13 |
|
| 14 |
class ImportPipeline:
|
| 15 |
"""
|
| 16 |
-
|
| 17 |
-
into the database with deduplication and language-based organization.
|
| 18 |
"""
|
| 19 |
|
| 20 |
def __init__(
|
|
@@ -23,33 +23,43 @@ class ImportPipeline:
|
|
| 23 |
deduplication_callback = None,
|
| 24 |
) -> None:
|
| 25 |
"""
|
| 26 |
-
Initialize
|
| 27 |
-
|
| 28 |
-
This sets up the processors for websites and documents and recieves existing chunk IDs
|
| 29 |
-
from the database for deduplication purposes.
|
| 30 |
|
| 31 |
Args:
|
| 32 |
logging_callback (callable, optional): A callback function for logging progress.
|
| 33 |
Defaults to a placeholder if not provided.
|
| 34 |
-
deduplication_callback (callable, optional):
|
| 35 |
-
|
| 36 |
"""
|
| 37 |
self._logging_callback = logging_callback or logging_callback_placeholder
|
| 38 |
-
self._deduplication_callback = deduplication_callback
|
| 39 |
self._docprocessor = DocumentProcessor()
|
| 40 |
self._service = WeaviateService()
|
| 41 |
-
self._ids = self._service._collect_chunk_ids()
|
| 42 |
|
| 43 |
implogger.info('Import pipeline initialization finished!')
|
| 44 |
|
| 45 |
|
| 46 |
-
def import_from_scraper(
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def scrape_website(self, target_urls: list[str] | None = None, scrape_all: bool = False) -> None:
|
|
@@ -63,14 +73,14 @@ class ImportPipeline:
|
|
| 63 |
scraper = Scraper(scrape_all=scrape_all)
|
| 64 |
for target_url in target_urls:
|
| 65 |
self._logging_callback(f"Scraping target {target_url}...", 0)
|
| 66 |
-
|
| 67 |
-
if not
|
| 68 |
self._logging_callback(f"No importable chunks scraped from {target_url}.", 100)
|
| 69 |
-
|
| 70 |
-
self._logging_callback(f"Importing scraped chunks from {target_url}...", 90)
|
| 71 |
|
| 72 |
-
self.
|
| 73 |
-
|
|
|
|
| 74 |
self._logging_callback(f"Finished scraping import for {target_url}.", 100)
|
| 75 |
except Exception as e:
|
| 76 |
implogger.error(f"Scraping task was interrupted: {e}")
|
|
@@ -93,13 +103,14 @@ class ImportPipeline:
|
|
| 93 |
scraper = Scraper(scrape_all=scrape_all)
|
| 94 |
for url in urls:
|
| 95 |
self._logging_callback(f"Scraping URL {url}...", 0)
|
| 96 |
-
|
| 97 |
-
if not
|
| 98 |
self._logging_callback(f"Failed to scrape URL {url}!", 100, failed=True)
|
| 99 |
continue
|
| 100 |
|
| 101 |
self._logging_callback(f"Importing scraped chunks from {url}...", 90)
|
| 102 |
-
self.import_from_scraper(
|
|
|
|
| 103 |
self._logging_callback(f"Stored scraped chunks for {url}.", 100)
|
| 104 |
|
| 105 |
|
|
@@ -122,102 +133,85 @@ class ImportPipeline:
|
|
| 122 |
reset_collections (bool, optional): If True, reset the database collections before importing.
|
| 123 |
Defaults to False.
|
| 124 |
"""
|
| 125 |
-
|
| 126 |
|
| 127 |
if reset_collections:
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
self._service._reset_collections()
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
| 134 |
|
| 135 |
self._import_urls_via_scraper(urls, scrape_all=True)
|
| 136 |
|
| 137 |
self._logging_callback(
|
| 138 |
-
f'Successfully imported {sum(
|
| 139 |
100
|
| 140 |
)
|
| 141 |
|
| 142 |
|
| 143 |
-
def
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
Internal pipeline to process a list of sources using a given processor.
|
| 151 |
-
|
| 152 |
-
Handles processing, deduplication (if not resetting), and organizes unique chunks by language.
|
| 153 |
-
If no new unique data is found, logs a warning and returns empty chunks.
|
| 154 |
-
|
| 155 |
-
Args:
|
| 156 |
-
sources (list[str]): List of sources (paths or URLs) to process.
|
| 157 |
-
processor (ProcessorBase): The processor instance to use for handling sources.
|
| 158 |
-
reset_collections (bool): If True, skip deduplication.
|
| 159 |
-
|
| 160 |
-
Returns:
|
| 161 |
-
dict: A dictionary mapping languages to lists of unique chunk dictionaries.
|
| 162 |
-
"""
|
| 163 |
-
unique_chunks = {lang: [] for lang in config.get('AVAILABLE_LANGUAGES')}
|
| 164 |
-
|
| 165 |
-
sources = [s for s in (sources or []) if s != ""]
|
| 166 |
-
if not sources:
|
| 167 |
-
return unique_chunks
|
| 168 |
-
|
| 169 |
-
for source in sources:
|
| 170 |
self._logging_callback(f'Starting pipeline for {source}...', 0)
|
| 171 |
-
result =
|
| 172 |
|
| 173 |
if not result.chunks:
|
| 174 |
implogger.error(f"Failed to process {source}!")
|
| 175 |
self._logging_callback(f"Failed to process {source}!", 100, result, failed=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
continue
|
| 177 |
-
|
| 178 |
-
if not reset_collections:
|
| 179 |
-
self._deduplicate(result)
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
|
| 184 |
-
if
|
| 185 |
self._logging_callback('No new data could be extracted from these sources!', 100)
|
| 186 |
-
implogger.warning(
|
| 187 |
|
| 188 |
-
return
|
| 189 |
-
|
| 190 |
|
| 191 |
-
def _deduplicate(self, result: ProcessingResult) -> ProcessingResult:
|
| 192 |
-
"""
|
| 193 |
-
Remove duplicate chunks based on chunks that are already stored in the database.
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
-
|
| 202 |
-
list[dict]: List of unique chunk dictionaries (or all if reimporting duplicates).
|
| 203 |
-
"""
|
| 204 |
-
self._logging_callback('Performing deduplication...', 80)
|
| 205 |
-
unique_chunks = []
|
| 206 |
-
duplicate_ids = []
|
| 207 |
-
for chunk in result.chunks:
|
| 208 |
-
chunk_id = chunk['chunk_id']
|
| 209 |
-
if chunk_id in self._ids:
|
| 210 |
-
duplicate_ids.append(chunk_id)
|
| 211 |
-
else:
|
| 212 |
-
unique_chunks.append(chunk)
|
| 213 |
-
|
| 214 |
-
implogger.info(f"Found {len(duplicate_ids)} already existing IDs in {len(result.chunks)} collected chunks")
|
| 215 |
-
if duplicate_ids:
|
| 216 |
-
implogger.info(f"Duplicates found! Calling deduplication callback...")
|
| 217 |
-
if self._deduplication_callback(result.source, len(duplicate_ids)):
|
| 218 |
-
implogger.info('Duplicated chunks will be reimported as new...')
|
| 219 |
-
self._service._delete_by_id(duplicate_ids)
|
| 220 |
-
return result
|
| 221 |
-
|
| 222 |
-
result.chunks = unique_chunks
|
| 223 |
-
return result
|
|
|
|
| 1 |
from .utils import *
|
| 2 |
from .processors import *
|
| 3 |
from ..scraping.scraper import Scraper
|
| 4 |
+
from ..scraping.types import ScrapeManifest
|
| 5 |
|
| 6 |
+
from ..database.weavservice import SourceReconciliationSummary, WeaviateService
|
| 7 |
from ..utils.logging import get_logger
|
| 8 |
from ..utils.tools import call_with_exponential_backoff
|
| 9 |
from ..config import config
|
|
|
|
| 14 |
|
| 15 |
class ImportPipeline:
|
| 16 |
"""
|
| 17 |
+
Import website and local documents with source-level safe replacement.
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
def __init__(
|
|
|
|
| 23 |
deduplication_callback = None,
|
| 24 |
) -> None:
|
| 25 |
"""
|
| 26 |
+
Initialize processors and optional UI callbacks.
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
Args:
|
| 29 |
logging_callback (callable, optional): A callback function for logging progress.
|
| 30 |
Defaults to a placeholder if not provided.
|
| 31 |
+
deduplication_callback (callable, optional): For existing local sources,
|
| 32 |
+
returns True to replace the source or False to append without cleanup.
|
| 33 |
"""
|
| 34 |
self._logging_callback = logging_callback or logging_callback_placeholder
|
| 35 |
+
self._deduplication_callback = deduplication_callback
|
| 36 |
self._docprocessor = DocumentProcessor()
|
| 37 |
self._service = WeaviateService()
|
|
|
|
| 38 |
|
| 39 |
implogger.info('Import pipeline initialization finished!')
|
| 40 |
|
| 41 |
|
| 42 |
+
def import_from_scraper(
|
| 43 |
+
self,
|
| 44 |
+
manifest: ScrapeManifest,
|
| 45 |
+
) -> list[SourceReconciliationSummary]:
|
| 46 |
+
rows_by_source = {
|
| 47 |
+
source: {
|
| 48 |
+
lang: []
|
| 49 |
+
for lang in config.get('AVAILABLE_LANGUAGES')
|
| 50 |
+
}
|
| 51 |
+
for source in manifest.processed_sources
|
| 52 |
+
}
|
| 53 |
+
for lang, chunks in manifest.chunks_by_language.items():
|
| 54 |
+
for chunk in chunks:
|
| 55 |
+
source = chunk.get('source', '')
|
| 56 |
+
if source in rows_by_source:
|
| 57 |
+
rows_by_source[source].setdefault(lang, []).append(chunk)
|
| 58 |
+
|
| 59 |
+
return [
|
| 60 |
+
self._service.reconcile_source(source, rows_by_source[source])
|
| 61 |
+
for source in manifest.processed_sources
|
| 62 |
+
]
|
| 63 |
|
| 64 |
|
| 65 |
def scrape_website(self, target_urls: list[str] | None = None, scrape_all: bool = False) -> None:
|
|
|
|
| 73 |
scraper = Scraper(scrape_all=scrape_all)
|
| 74 |
for target_url in target_urls:
|
| 75 |
self._logging_callback(f"Scraping target {target_url}...", 0)
|
| 76 |
+
manifest = scraper.scrape_target(target_url)
|
| 77 |
+
if not manifest:
|
| 78 |
self._logging_callback(f"No importable chunks scraped from {target_url}.", 100)
|
| 79 |
+
continue
|
|
|
|
| 80 |
|
| 81 |
+
self._logging_callback(f"Importing scraped chunks from {target_url}...", 90)
|
| 82 |
+
self.import_from_scraper(manifest)
|
| 83 |
+
scraper.commit_scrape(manifest)
|
| 84 |
self._logging_callback(f"Finished scraping import for {target_url}.", 100)
|
| 85 |
except Exception as e:
|
| 86 |
implogger.error(f"Scraping task was interrupted: {e}")
|
|
|
|
| 103 |
scraper = Scraper(scrape_all=scrape_all)
|
| 104 |
for url in urls:
|
| 105 |
self._logging_callback(f"Scraping URL {url}...", 0)
|
| 106 |
+
manifest = scraper.scrape_target(url)
|
| 107 |
+
if not manifest:
|
| 108 |
self._logging_callback(f"Failed to scrape URL {url}!", 100, failed=True)
|
| 109 |
continue
|
| 110 |
|
| 111 |
self._logging_callback(f"Importing scraped chunks from {url}...", 90)
|
| 112 |
+
self.import_from_scraper(manifest)
|
| 113 |
+
scraper.commit_scrape(manifest)
|
| 114 |
self._logging_callback(f"Stored scraped chunks for {url}.", 100)
|
| 115 |
|
| 116 |
|
|
|
|
| 133 |
reset_collections (bool, optional): If True, reset the database collections before importing.
|
| 134 |
Defaults to False.
|
| 135 |
"""
|
| 136 |
+
results = self._process_documents(paths, fail_fast=reset_collections)
|
| 137 |
|
| 138 |
if reset_collections:
|
| 139 |
+
chunks_by_language = {
|
| 140 |
+
lang: []
|
| 141 |
+
for lang in config.get('AVAILABLE_LANGUAGES')
|
| 142 |
+
}
|
| 143 |
+
for result in results:
|
| 144 |
+
chunks_by_language[result.lang].extend(result.chunks)
|
| 145 |
+
|
| 146 |
+
prepared_by_language = {
|
| 147 |
+
lang: self._service.prepare_batch_import(chunks, lang)
|
| 148 |
+
for lang, chunks in chunks_by_language.items()
|
| 149 |
+
}
|
| 150 |
+
self._logging_callback(
|
| 151 |
+
'Resetting database collections (destructive operation)...',
|
| 152 |
+
60,
|
| 153 |
+
)
|
| 154 |
self._service._reset_collections()
|
| 155 |
+
for lang, prepared in prepared_by_language.items():
|
| 156 |
+
self._service._write_prepared_rows(prepared, lang)
|
| 157 |
+
else:
|
| 158 |
+
for result in results:
|
| 159 |
+
self._import_document_result(result)
|
| 160 |
|
| 161 |
self._import_urls_via_scraper(urls, scrape_all=True)
|
| 162 |
|
| 163 |
self._logging_callback(
|
| 164 |
+
f'Successfully imported {sum(len(result.chunks) for result in results)} document chunks!',
|
| 165 |
100
|
| 166 |
)
|
| 167 |
|
| 168 |
|
| 169 |
+
def _process_documents(
|
| 170 |
+
self,
|
| 171 |
+
sources: list[str] | None,
|
| 172 |
+
fail_fast: bool = False,
|
| 173 |
+
) -> list[ProcessingResult]:
|
| 174 |
+
results = []
|
| 175 |
+
for source in [s for s in (sources or []) if s]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
self._logging_callback(f'Starting pipeline for {source}...', 0)
|
| 177 |
+
result = self._docprocessor.process(source)
|
| 178 |
|
| 179 |
if not result.chunks:
|
| 180 |
implogger.error(f"Failed to process {source}!")
|
| 181 |
self._logging_callback(f"Failed to process {source}!", 100, result, failed=True)
|
| 182 |
+
if fail_fast:
|
| 183 |
+
raise RuntimeError(
|
| 184 |
+
f"Aborting destructive collection reset because '{source}' "
|
| 185 |
+
"did not produce importable chunks"
|
| 186 |
+
)
|
| 187 |
continue
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
results.append(result)
|
| 190 |
+
self._logging_callback(f'Prepared chunks for {source}.', 70, result)
|
| 191 |
|
| 192 |
+
if sources and not results:
|
| 193 |
self._logging_callback('No new data could be extracted from these sources!', 100)
|
| 194 |
+
implogger.warning("Provided files did not contain importable information.")
|
| 195 |
|
| 196 |
+
return results
|
|
|
|
| 197 |
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
def _import_document_result(
|
| 200 |
+
self,
|
| 201 |
+
result: ProcessingResult,
|
| 202 |
+
) -> None:
|
| 203 |
+
existing_count = self._service.source_object_count(result.source)
|
| 204 |
+
should_replace = True
|
| 205 |
+
if existing_count and self._deduplication_callback:
|
| 206 |
+
should_replace = self._deduplication_callback(result.source, existing_count)
|
| 207 |
+
|
| 208 |
+
if existing_count and should_replace:
|
| 209 |
+
rows_by_language = {
|
| 210 |
+
lang: []
|
| 211 |
+
for lang in config.get('AVAILABLE_LANGUAGES')
|
| 212 |
+
}
|
| 213 |
+
rows_by_language[result.lang] = result.chunks
|
| 214 |
+
self._service.reconcile_source(result.source, rows_by_language)
|
| 215 |
+
return
|
| 216 |
|
| 217 |
+
self._service.batch_import(result.chunks, result.lang)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/scraping/scraper.py
CHANGED
|
@@ -69,7 +69,7 @@ class Scraper:
|
|
| 69 |
os.makedirs(self._path.EXTRACTED_TEXT_OUTPUT, exist_ok=True)
|
| 70 |
|
| 71 |
|
| 72 |
-
def scrape_target(self, target_url: str) ->
|
| 73 |
if getattr(self, "_content_cleaner", None) is None or hasattr(self, "_scrape_all"):
|
| 74 |
self._content_cleaner = ContentCleaner(getattr(self, "_scrape_all", True))
|
| 75 |
|
|
@@ -77,7 +77,7 @@ class Scraper:
|
|
| 77 |
analyzed_domain = self._analyze_domain(target_url)
|
| 78 |
if not analyzed_domain:
|
| 79 |
logger.error(f"Failed to scrape target URL {target_url}")
|
| 80 |
-
return
|
| 81 |
|
| 82 |
sitemap_urls = analyzed_domain.urls
|
| 83 |
self._save_results(self._path.URLS_OUTPUT, 'sitemap_urls', sitemap_urls, target_url)
|
|
@@ -106,10 +106,14 @@ class Scraper:
|
|
| 106 |
# Step 4: Load temp chunks first so resume works even when there are no new documents.
|
| 107 |
temp_filename = self._get_temp_chunks_filename(target_url)
|
| 108 |
temp_merged_chunks = self._load_data(self._path.TEMP_CHUNKS_OUTPUT, temp_filename)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
if not documents and not temp_merged_chunks:
|
| 111 |
logger.info(f"No new content was scraped from the target URL {target_url}")
|
| 112 |
-
return
|
| 113 |
|
| 114 |
tagged_documents = []
|
| 115 |
# Step 5: Analyze the converted URLs
|
|
@@ -136,7 +140,11 @@ class Scraper:
|
|
| 136 |
|
| 137 |
logger.info(f"Scraping finished for target URL '{target_url}'")
|
| 138 |
|
| 139 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
def _analyze_domain(self, target_url: str) -> DomainAnalysisReport | None:
|
|
@@ -351,13 +359,17 @@ class Scraper:
|
|
| 351 |
|
| 352 |
raw_chunks = []
|
| 353 |
deleted_chunks = []
|
| 354 |
-
merged_chunks, final_chunks = self._read_temp_chunks(
|
|
|
|
|
|
|
|
|
|
| 355 |
# Temp snapshots must only carry (a) URLs newly chunked in this session
|
| 356 |
# (added by _store_temp_chunks) and (b) URLs whose restore failed and
|
| 357 |
# which must survive for the next session. Successfully restored URLs
|
| 358 |
# are finalized now — keeping them in temp would re-restore them on the
|
| 359 |
# next resume and duplicate their chunks.
|
| 360 |
self._active_temp_chunks = dict(getattr(self, '_unrestorable_temp_chunks', {}))
|
|
|
|
| 361 |
|
| 362 |
program_counter = self._build_program_counter_from_merged_chunks(merged_chunks)
|
| 363 |
|
|
@@ -368,6 +380,7 @@ class Scraper:
|
|
| 368 |
program = entry.tags.program
|
| 369 |
language = entry.tags.language
|
| 370 |
url = document.name
|
|
|
|
| 371 |
url_filename = self._normalizer.url_to_filename(url)
|
| 372 |
|
| 373 |
program_counter[program] += 1
|
|
@@ -426,6 +439,7 @@ class Scraper:
|
|
| 426 |
'merged': merged_chunks,
|
| 427 |
'deleted': deleted_chunks,
|
| 428 |
'final': final_chunks,
|
|
|
|
| 429 |
}
|
| 430 |
|
| 431 |
|
|
@@ -433,7 +447,7 @@ class Scraper:
|
|
| 433 |
self,
|
| 434 |
temp_chunks: dict[str, list[ChunkMetadata]],
|
| 435 |
tagged_documents: list[TaggedDocument]
|
| 436 |
-
) ->
|
| 437 |
loaded_temp_chunks = temp_chunks.copy()
|
| 438 |
prepared_temp_chunks = {lang: [] for lang in config.get('AVAILABLE_LANGUAGES', ['en', 'de'])}
|
| 439 |
|
|
@@ -443,6 +457,7 @@ class Scraper:
|
|
| 443 |
del loaded_temp_chunks[url]
|
| 444 |
|
| 445 |
restored_temp_chunks = []
|
|
|
|
| 446 |
# URLs whose chunks could not be finalized; they must stay in the temp
|
| 447 |
# store so the next session can re-scrape them (read in _collect_chunks).
|
| 448 |
self._unrestorable_temp_chunks: dict[str, list[ChunkMetadata]] = {}
|
|
@@ -465,24 +480,32 @@ class Scraper:
|
|
| 465 |
prepared_temp_chunks[lang].extend(prepared_chunks[lang])
|
| 466 |
|
| 467 |
restored_temp_chunks.extend(chunks)
|
|
|
|
| 468 |
incupd_logger.info(f"Restored {len(chunks)} chunks for URL {url} from temp")
|
| 469 |
|
| 470 |
-
return restored_temp_chunks, prepared_temp_chunks
|
| 471 |
|
| 472 |
|
| 473 |
def _store_temp_chunks(self, target_url: str, url: str, chunks: list[ChunkMetadata]) -> None:
|
| 474 |
-
self._url_timestamps[url] = self._url_temp_timestamps[url]
|
| 475 |
-
|
| 476 |
temp_chunks = getattr(self, '_active_temp_chunks', {}).copy()
|
| 477 |
temp_chunks[url] = chunks
|
| 478 |
self._active_temp_chunks = temp_chunks
|
| 479 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
self._save_results(
|
| 481 |
self._path.TEMP_CHUNKS_OUTPUT,
|
| 482 |
self._get_temp_chunks_filename(target_url),
|
| 483 |
_TempChunkSnapshot(temp_chunks),
|
| 484 |
)
|
| 485 |
-
self._save_results(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
incupd_logger.info(f"Stored {len(chunks)} chunks in temp for URL {url}")
|
| 488 |
|
|
@@ -530,14 +553,70 @@ class Scraper:
|
|
| 530 |
return self._normalizer.url_to_filename(target_url) + '_merged_chunks'
|
| 531 |
|
| 532 |
|
| 533 |
-
def
|
| 534 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
self._path.TEMP_CHUNKS_OUTPUT,
|
| 536 |
-
self._get_temp_chunks_filename(target_url) + '.json'
|
| 537 |
)
|
| 538 |
-
if os.path.exists(
|
| 539 |
-
os.remove(
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
|
| 542 |
|
| 543 |
def _get_etag(self, url: str) -> str | None:
|
|
@@ -628,15 +707,17 @@ class Scraper:
|
|
| 628 |
logger.info(f"Fetching head for URL '{url}'...")
|
| 629 |
|
| 630 |
etag = self._get_etag(url)
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
|
|
|
|
|
|
| 640 |
|
| 641 |
response = call_with_exponential_backoff(fetch_url, args=(url, etag), delay=crawl_delay)
|
| 642 |
if response['status'] == 'FAIL':
|
|
@@ -714,6 +795,12 @@ class Scraper:
|
|
| 714 |
for url, ts in results.items():
|
| 715 |
results_dict[url] = dataclass_to_dict(ts)
|
| 716 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
case 'url_priorities':
|
| 718 |
for prio, urls in results.items():
|
| 719 |
prev = set(results_dict.get(prio, []))
|
|
@@ -769,6 +856,12 @@ class Scraper:
|
|
| 769 |
incupd_logger.debug(f"Loaded {len(loaded_data)} temp merged chunks")
|
| 770 |
return loaded_data
|
| 771 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
case _:
|
| 773 |
incupd_logger.info(f"Loaded data '{filename}'")
|
| 774 |
return loaded_data
|
|
|
|
| 69 |
os.makedirs(self._path.EXTRACTED_TEXT_OUTPUT, exist_ok=True)
|
| 70 |
|
| 71 |
|
| 72 |
+
def scrape_target(self, target_url: str) -> ScrapeManifest:
|
| 73 |
if getattr(self, "_content_cleaner", None) is None or hasattr(self, "_scrape_all"):
|
| 74 |
self._content_cleaner = ContentCleaner(getattr(self, "_scrape_all", True))
|
| 75 |
|
|
|
|
| 77 |
analyzed_domain = self._analyze_domain(target_url)
|
| 78 |
if not analyzed_domain:
|
| 79 |
logger.error(f"Failed to scrape target URL {target_url}")
|
| 80 |
+
return ScrapeManifest.empty(target_url)
|
| 81 |
|
| 82 |
sitemap_urls = analyzed_domain.urls
|
| 83 |
self._save_results(self._path.URLS_OUTPUT, 'sitemap_urls', sitemap_urls, target_url)
|
|
|
|
| 106 |
# Step 4: Load temp chunks first so resume works even when there are no new documents.
|
| 107 |
temp_filename = self._get_temp_chunks_filename(target_url)
|
| 108 |
temp_merged_chunks = self._load_data(self._path.TEMP_CHUNKS_OUTPUT, temp_filename)
|
| 109 |
+
self._pending_timestamps = self._load_data(
|
| 110 |
+
self._path.TEMP_CHUNKS_OUTPUT,
|
| 111 |
+
self._get_temp_timestamps_filename(target_url),
|
| 112 |
+
)
|
| 113 |
|
| 114 |
if not documents and not temp_merged_chunks:
|
| 115 |
logger.info(f"No new content was scraped from the target URL {target_url}")
|
| 116 |
+
return ScrapeManifest.empty(target_url)
|
| 117 |
|
| 118 |
tagged_documents = []
|
| 119 |
# Step 5: Analyze the converted URLs
|
|
|
|
| 140 |
|
| 141 |
logger.info(f"Scraping finished for target URL '{target_url}'")
|
| 142 |
|
| 143 |
+
return ScrapeManifest(
|
| 144 |
+
target_url=target_url,
|
| 145 |
+
chunks_by_language=chunk_metadatas.get('final', {}),
|
| 146 |
+
processed_sources=sorted(chunk_metadatas.get('processed_sources', [])),
|
| 147 |
+
)
|
| 148 |
|
| 149 |
|
| 150 |
def _analyze_domain(self, target_url: str) -> DomainAnalysisReport | None:
|
|
|
|
| 359 |
|
| 360 |
raw_chunks = []
|
| 361 |
deleted_chunks = []
|
| 362 |
+
merged_chunks, final_chunks, restored_sources = self._read_temp_chunks(
|
| 363 |
+
temp_chunks,
|
| 364 |
+
tagged_documents,
|
| 365 |
+
)
|
| 366 |
# Temp snapshots must only carry (a) URLs newly chunked in this session
|
| 367 |
# (added by _store_temp_chunks) and (b) URLs whose restore failed and
|
| 368 |
# which must survive for the next session. Successfully restored URLs
|
| 369 |
# are finalized now — keeping them in temp would re-restore them on the
|
| 370 |
# next resume and duplicate their chunks.
|
| 371 |
self._active_temp_chunks = dict(getattr(self, '_unrestorable_temp_chunks', {}))
|
| 372 |
+
processed_sources = set(restored_sources)
|
| 373 |
|
| 374 |
program_counter = self._build_program_counter_from_merged_chunks(merged_chunks)
|
| 375 |
|
|
|
|
| 380 |
program = entry.tags.program
|
| 381 |
language = entry.tags.language
|
| 382 |
url = document.name
|
| 383 |
+
processed_sources.add(url)
|
| 384 |
url_filename = self._normalizer.url_to_filename(url)
|
| 385 |
|
| 386 |
program_counter[program] += 1
|
|
|
|
| 439 |
'merged': merged_chunks,
|
| 440 |
'deleted': deleted_chunks,
|
| 441 |
'final': final_chunks,
|
| 442 |
+
'processed_sources': processed_sources,
|
| 443 |
}
|
| 444 |
|
| 445 |
|
|
|
|
| 447 |
self,
|
| 448 |
temp_chunks: dict[str, list[ChunkMetadata]],
|
| 449 |
tagged_documents: list[TaggedDocument]
|
| 450 |
+
) -> tuple[list, dict[str, list[dict]], set[str]]:
|
| 451 |
loaded_temp_chunks = temp_chunks.copy()
|
| 452 |
prepared_temp_chunks = {lang: [] for lang in config.get('AVAILABLE_LANGUAGES', ['en', 'de'])}
|
| 453 |
|
|
|
|
| 457 |
del loaded_temp_chunks[url]
|
| 458 |
|
| 459 |
restored_temp_chunks = []
|
| 460 |
+
restored_sources = set()
|
| 461 |
# URLs whose chunks could not be finalized; they must stay in the temp
|
| 462 |
# store so the next session can re-scrape them (read in _collect_chunks).
|
| 463 |
self._unrestorable_temp_chunks: dict[str, list[ChunkMetadata]] = {}
|
|
|
|
| 480 |
prepared_temp_chunks[lang].extend(prepared_chunks[lang])
|
| 481 |
|
| 482 |
restored_temp_chunks.extend(chunks)
|
| 483 |
+
restored_sources.add(url)
|
| 484 |
incupd_logger.info(f"Restored {len(chunks)} chunks for URL {url} from temp")
|
| 485 |
|
| 486 |
+
return restored_temp_chunks, prepared_temp_chunks, restored_sources
|
| 487 |
|
| 488 |
|
| 489 |
def _store_temp_chunks(self, target_url: str, url: str, chunks: list[ChunkMetadata]) -> None:
|
|
|
|
|
|
|
| 490 |
temp_chunks = getattr(self, '_active_temp_chunks', {}).copy()
|
| 491 |
temp_chunks[url] = chunks
|
| 492 |
self._active_temp_chunks = temp_chunks
|
| 493 |
|
| 494 |
+
pending_timestamps = getattr(self, '_pending_timestamps', {}).copy()
|
| 495 |
+
if url in self._url_temp_timestamps:
|
| 496 |
+
pending_timestamps[url] = self._url_temp_timestamps[url]
|
| 497 |
+
self._pending_timestamps = pending_timestamps
|
| 498 |
+
|
| 499 |
self._save_results(
|
| 500 |
self._path.TEMP_CHUNKS_OUTPUT,
|
| 501 |
self._get_temp_chunks_filename(target_url),
|
| 502 |
_TempChunkSnapshot(temp_chunks),
|
| 503 |
)
|
| 504 |
+
self._save_results(
|
| 505 |
+
self._path.TEMP_CHUNKS_OUTPUT,
|
| 506 |
+
self._get_temp_timestamps_filename(target_url),
|
| 507 |
+
pending_timestamps,
|
| 508 |
+
)
|
| 509 |
|
| 510 |
incupd_logger.info(f"Stored {len(chunks)} chunks in temp for URL {url}")
|
| 511 |
|
|
|
|
| 553 |
return self._normalizer.url_to_filename(target_url) + '_merged_chunks'
|
| 554 |
|
| 555 |
|
| 556 |
+
def _get_temp_timestamps_filename(self, target_url: str) -> str:
|
| 557 |
+
return self._normalizer.url_to_filename(target_url) + '_pending_timestamps'
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def commit_scrape(self, manifest: ScrapeManifest) -> None:
|
| 561 |
+
"""Commit scraper checkpoints after all database sources are reconciled."""
|
| 562 |
+
processed_sources = set(manifest.processed_sources)
|
| 563 |
+
pending_timestamps = getattr(self, '_pending_timestamps', {})
|
| 564 |
+
for url in processed_sources:
|
| 565 |
+
timestamp = pending_timestamps.get(url)
|
| 566 |
+
if timestamp:
|
| 567 |
+
self._url_timestamps[url] = timestamp
|
| 568 |
+
|
| 569 |
+
self._save_results(self._path.SCRAPING_OUTPUT, 'url_timestamps', self._url_timestamps)
|
| 570 |
+
|
| 571 |
+
remaining_chunks = {
|
| 572 |
+
url: chunks
|
| 573 |
+
for url, chunks in getattr(self, '_active_temp_chunks', {}).items()
|
| 574 |
+
if url not in processed_sources
|
| 575 |
+
}
|
| 576 |
+
chunks_path = os.path.join(
|
| 577 |
self._path.TEMP_CHUNKS_OUTPUT,
|
| 578 |
+
self._get_temp_chunks_filename(manifest.target_url) + '.json',
|
| 579 |
)
|
| 580 |
+
if os.path.exists(chunks_path):
|
| 581 |
+
os.remove(chunks_path)
|
| 582 |
+
if remaining_chunks:
|
| 583 |
+
self._save_results(
|
| 584 |
+
self._path.TEMP_CHUNKS_OUTPUT,
|
| 585 |
+
self._get_temp_chunks_filename(manifest.target_url),
|
| 586 |
+
_TempChunkSnapshot(remaining_chunks),
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
remaining_timestamps = {
|
| 590 |
+
url: timestamp
|
| 591 |
+
for url, timestamp in pending_timestamps.items()
|
| 592 |
+
if url not in processed_sources
|
| 593 |
+
}
|
| 594 |
+
timestamps_path = os.path.join(
|
| 595 |
+
self._path.TEMP_CHUNKS_OUTPUT,
|
| 596 |
+
self._get_temp_timestamps_filename(manifest.target_url) + '.json',
|
| 597 |
+
)
|
| 598 |
+
if remaining_timestamps:
|
| 599 |
+
self._save_results(
|
| 600 |
+
self._path.TEMP_CHUNKS_OUTPUT,
|
| 601 |
+
self._get_temp_timestamps_filename(manifest.target_url),
|
| 602 |
+
remaining_timestamps,
|
| 603 |
+
)
|
| 604 |
+
elif os.path.exists(timestamps_path):
|
| 605 |
+
os.remove(timestamps_path)
|
| 606 |
+
|
| 607 |
+
self._active_temp_chunks = remaining_chunks
|
| 608 |
+
self._pending_timestamps = remaining_timestamps
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def delete_temp_merged_chunks(self, target_url: str) -> None:
|
| 612 |
+
for filename in (
|
| 613 |
+
self._get_temp_chunks_filename(target_url),
|
| 614 |
+
self._get_temp_timestamps_filename(target_url),
|
| 615 |
+
):
|
| 616 |
+
temp_path = os.path.join(self._path.TEMP_CHUNKS_OUTPUT, filename + '.json')
|
| 617 |
+
if os.path.exists(temp_path):
|
| 618 |
+
os.remove(temp_path)
|
| 619 |
+
incupd_logger.info(f"Deleted scraper temp file '{temp_path}'")
|
| 620 |
|
| 621 |
|
| 622 |
def _get_etag(self, url: str) -> str | None:
|
|
|
|
| 707 |
logger.info(f"Fetching head for URL '{url}'...")
|
| 708 |
|
| 709 |
etag = self._get_etag(url)
|
| 710 |
+
try:
|
| 711 |
+
fetch_result = fetch_head(url, etag)
|
| 712 |
+
except Exception as head_error:
|
| 713 |
+
logger.warning(
|
| 714 |
+
f"HEAD request failed for URL {url}: {head_error}. "
|
| 715 |
+
"Falling back to a GET request..."
|
| 716 |
+
)
|
| 717 |
+
else:
|
| 718 |
+
validation = self._is_fetch_valid(url, visited_urls, fetch_result)
|
| 719 |
+
if validation != ScrapingStatus.OK:
|
| 720 |
+
return ScrapingResult(status=validation)
|
| 721 |
|
| 722 |
response = call_with_exponential_backoff(fetch_url, args=(url, etag), delay=crawl_delay)
|
| 723 |
if response['status'] == 'FAIL':
|
|
|
|
| 795 |
for url, ts in results.items():
|
| 796 |
results_dict[url] = dataclass_to_dict(ts)
|
| 797 |
|
| 798 |
+
case _ if filename.endswith('_pending_timestamps'):
|
| 799 |
+
results_dict = {
|
| 800 |
+
url: dataclass_to_dict(timestamp)
|
| 801 |
+
for url, timestamp in results.items()
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
case 'url_priorities':
|
| 805 |
for prio, urls in results.items():
|
| 806 |
prev = set(results_dict.get(prio, []))
|
|
|
|
| 856 |
incupd_logger.debug(f"Loaded {len(loaded_data)} temp merged chunks")
|
| 857 |
return loaded_data
|
| 858 |
|
| 859 |
+
case _ if filename.endswith('_pending_timestamps'):
|
| 860 |
+
for url, timestamp in loaded_data.items():
|
| 861 |
+
loaded_data[url] = dict_to_dataclass(timestamp, UrlTimestamps)
|
| 862 |
+
incupd_logger.debug(f"Loaded {len(loaded_data)} pending timestamps")
|
| 863 |
+
return loaded_data
|
| 864 |
+
|
| 865 |
case _:
|
| 866 |
incupd_logger.info(f"Loaded data '{filename}'")
|
| 867 |
return loaded_data
|
src/scraping/types.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from dataclasses import asdict, dataclass, is_dataclass
|
| 2 |
from datetime import datetime
|
| 3 |
from enum import Enum
|
|
|
|
| 4 |
|
| 5 |
from docling_core.types.doc.document import DoclingDocument
|
| 6 |
|
|
@@ -99,6 +100,22 @@ class DocumentAnalysisReport:
|
|
| 99 |
tagged_documents: list[TaggedDocument]
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def dataclass_to_dict(obj) -> dict:
|
| 103 |
if not is_dataclass(obj): return obj
|
| 104 |
return asdict(obj, dict_factory=lambda items: {
|
|
|
|
| 1 |
from dataclasses import asdict, dataclass, is_dataclass
|
| 2 |
from datetime import datetime
|
| 3 |
from enum import Enum
|
| 4 |
+
from typing import Any
|
| 5 |
|
| 6 |
from docling_core.types.doc.document import DoclingDocument
|
| 7 |
|
|
|
|
| 100 |
tagged_documents: list[TaggedDocument]
|
| 101 |
|
| 102 |
|
| 103 |
+
@dataclass
|
| 104 |
+
class ScrapeManifest:
|
| 105 |
+
"""Prepared database rows and authoritative sources from one scrape target."""
|
| 106 |
+
|
| 107 |
+
target_url: str
|
| 108 |
+
chunks_by_language: dict[str, list[dict[str, Any]]]
|
| 109 |
+
processed_sources: list[str]
|
| 110 |
+
|
| 111 |
+
def __bool__(self) -> bool:
|
| 112 |
+
return bool(self.processed_sources)
|
| 113 |
+
|
| 114 |
+
@classmethod
|
| 115 |
+
def empty(cls, target_url: str) -> "ScrapeManifest":
|
| 116 |
+
return cls(target_url=target_url, chunks_by_language={}, processed_sources=[])
|
| 117 |
+
|
| 118 |
+
|
| 119 |
def dataclass_to_dict(obj) -> dict:
|
| 120 |
if not is_dataclass(obj): return obj
|
| 121 |
return asdict(obj, dict_factory=lambda items: {
|
src/scraping/utils.py
CHANGED
|
@@ -160,8 +160,8 @@ def fetch_head(url: str, etag: str | None = None) -> FetchResult:
|
|
| 160 |
etag = response.headers.get('ETag')
|
| 161 |
)
|
| 162 |
except Exception as e:
|
| 163 |
-
logger.
|
| 164 |
-
raise
|
| 165 |
|
| 166 |
|
| 167 |
def fetch_url(url: str, etag: str | None = None) -> dict:
|
|
|
|
| 160 |
etag = response.headers.get('ETag')
|
| 161 |
)
|
| 162 |
except Exception as e:
|
| 163 |
+
logger.warning(f"Head fetch failed for '{url}': {e}")
|
| 164 |
+
raise
|
| 165 |
|
| 166 |
|
| 167 |
def fetch_url(url: str, etag: str | None = None) -> dict:
|
tests/scraping/test_scraping.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
import pytest, os
|
| 2 |
from datetime import datetime, timedelta
|
|
|
|
| 3 |
|
| 4 |
-
from src.scraping.types import UrlTimestamps
|
| 5 |
from src.scraping.url_normalizer import UrlNormalizer
|
| 6 |
from src.scraping.scraper import Scraper
|
| 7 |
from src.utils.logging import init_logging
|
|
@@ -71,6 +72,67 @@ class TestScrapingErrors:
|
|
| 71 |
for url in scraper._url_timestamps:
|
| 72 |
assert scraper._is_url_prioritized(url) == expected[url]
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
if __name__ == "__main__":
|
| 76 |
pytest.main([__file__, '-v'])
|
|
|
|
| 1 |
import pytest, os
|
| 2 |
from datetime import datetime, timedelta
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
|
| 5 |
+
from src.scraping.types import FetchResult, ScrapingStatus, UrlTimestamps
|
| 6 |
from src.scraping.url_normalizer import UrlNormalizer
|
| 7 |
from src.scraping.scraper import Scraper
|
| 8 |
from src.utils.logging import init_logging
|
|
|
|
| 72 |
for url in scraper._url_timestamps:
|
| 73 |
assert scraper._is_url_prioritized(url) == expected[url]
|
| 74 |
|
| 75 |
+
def test_head_timeout_falls_back_to_get(self, tmp_path, monkeypatch):
|
| 76 |
+
url = "https://embax.ch/contact/"
|
| 77 |
+
get_calls = []
|
| 78 |
+
scraper = Scraper.__new__(Scraper)
|
| 79 |
+
scraper._scrape_all = True
|
| 80 |
+
scraper._url_timestamps = {}
|
| 81 |
+
scraper._url_priorities = {}
|
| 82 |
+
scraper._normalizer = SimpleNamespace(
|
| 83 |
+
is_url_blacklisted=lambda _: False,
|
| 84 |
+
url_to_filename=lambda _: "embax-contact",
|
| 85 |
+
)
|
| 86 |
+
scraper._content_cleaner = SimpleNamespace(
|
| 87 |
+
clean_mobile_content=lambda html: html,
|
| 88 |
+
extract_urls=lambda document: [],
|
| 89 |
+
collect_repetitive_content=lambda document: None,
|
| 90 |
+
)
|
| 91 |
+
scraper._processor = SimpleNamespace(
|
| 92 |
+
process=lambda final_url, html: SimpleNamespace(name=final_url),
|
| 93 |
+
convert_to_txt=lambda document: "contact page",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def fail_head(*_):
|
| 97 |
+
raise TimeoutError("HEAD timed out")
|
| 98 |
+
|
| 99 |
+
def fetch_get(request_url, etag):
|
| 100 |
+
get_calls.append((request_url, etag))
|
| 101 |
+
return FetchResult(
|
| 102 |
+
final_url=request_url,
|
| 103 |
+
last_modified=None,
|
| 104 |
+
etag=None,
|
| 105 |
+
text="<html><body>Contact</body></html>",
|
| 106 |
+
page_hash="hash",
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
def no_wait_backoff(func, args=(), **_):
|
| 110 |
+
return {
|
| 111 |
+
"result": func(*args),
|
| 112 |
+
"retries": 0,
|
| 113 |
+
"last_error": None,
|
| 114 |
+
"status": "OK",
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
monkeypatch.setattr("src.scraping.scraper.fetch_head", fail_head)
|
| 118 |
+
monkeypatch.setattr("src.scraping.scraper.fetch_url", fetch_get)
|
| 119 |
+
monkeypatch.setattr(
|
| 120 |
+
"src.scraping.scraper.call_with_exponential_backoff",
|
| 121 |
+
no_wait_backoff,
|
| 122 |
+
)
|
| 123 |
+
monkeypatch.setattr(config.paths, "RAW_HTML_OUTPUT", str(tmp_path))
|
| 124 |
+
monkeypatch.setattr(config.paths, "RAW_TEXT_OUTPUT", str(tmp_path))
|
| 125 |
+
|
| 126 |
+
result = scraper._scrape_page(
|
| 127 |
+
url=url,
|
| 128 |
+
crawl_delay=0,
|
| 129 |
+
visited_urls=set(),
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
assert result.status == ScrapingStatus.OK
|
| 133 |
+
assert result.final_url == url
|
| 134 |
+
assert get_calls == [(url, None)]
|
| 135 |
+
|
| 136 |
|
| 137 |
if __name__ == "__main__":
|
| 138 |
pytest.main([__file__, '-v'])
|
tests/scraping/test_scraping_resume.py
CHANGED
|
@@ -5,7 +5,7 @@ from types import SimpleNamespace
|
|
| 5 |
import pytest
|
| 6 |
|
| 7 |
from src.scraping.scraper import Scraper
|
| 8 |
-
from src.scraping.types import ChunkMetadata, UrlTimestamps
|
| 9 |
|
| 10 |
|
| 11 |
class DummyNormalizer:
|
|
@@ -188,7 +188,8 @@ class TestTempBehaviorComplete:
|
|
| 188 |
'https://target.example/page-1',
|
| 189 |
'https://target.example/page-2',
|
| 190 |
]
|
| 191 |
-
assert
|
|
|
|
| 192 |
'https://target.example/page-1',
|
| 193 |
'https://target.example/page-2',
|
| 194 |
}
|
|
@@ -274,13 +275,18 @@ class TestTempBehaviorComplete:
|
|
| 274 |
'merged': merged,
|
| 275 |
'deleted': [],
|
| 276 |
'final': {'en': merged, 'de': []},
|
|
|
|
| 277 |
}
|
| 278 |
|
| 279 |
scraper._collect_chunks = fake_collect_chunks
|
| 280 |
|
| 281 |
result = scraper.scrape_target(target_url)
|
| 282 |
|
| 283 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
assert len(collect_calls) == 1
|
| 285 |
assert collect_calls[0]['tagged_documents'] == []
|
| 286 |
assert collect_calls[0]['existing_merged_chunks'] == {'https://target.example/page-1': [existing_chunk]}
|
|
@@ -315,7 +321,40 @@ class TestTempBehaviorComplete:
|
|
| 315 |
|
| 316 |
result = scraper.scrape_target(target_url)
|
| 317 |
|
| 318 |
-
assert result
|
|
|
|
| 319 |
saved_filenames = [call['filename'] for call in scraper._save_calls]
|
| 320 |
assert 'merged_chunk_metadata' not in saved_filenames
|
| 321 |
assert scraper._content_cleaner.perform_calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import pytest
|
| 6 |
|
| 7 |
from src.scraping.scraper import Scraper
|
| 8 |
+
from src.scraping.types import ChunkMetadata, ScrapeManifest, UrlTimestamps
|
| 9 |
|
| 10 |
|
| 11 |
class DummyNormalizer:
|
|
|
|
| 188 |
'https://target.example/page-1',
|
| 189 |
'https://target.example/page-2',
|
| 190 |
]
|
| 191 |
+
assert scraper._url_timestamps == {}
|
| 192 |
+
assert set(scraper._pending_timestamps) == {
|
| 193 |
'https://target.example/page-1',
|
| 194 |
'https://target.example/page-2',
|
| 195 |
}
|
|
|
|
| 275 |
'merged': merged,
|
| 276 |
'deleted': [],
|
| 277 |
'final': {'en': merged, 'de': []},
|
| 278 |
+
'processed_sources': {'https://target.example/page-1'},
|
| 279 |
}
|
| 280 |
|
| 281 |
scraper._collect_chunks = fake_collect_chunks
|
| 282 |
|
| 283 |
result = scraper.scrape_target(target_url)
|
| 284 |
|
| 285 |
+
assert isinstance(result, ScrapeManifest)
|
| 286 |
+
assert [chunk.source_url for chunk in result.chunks_by_language['en']] == [
|
| 287 |
+
'https://target.example/page-1'
|
| 288 |
+
]
|
| 289 |
+
assert result.processed_sources == ['https://target.example/page-1']
|
| 290 |
assert len(collect_calls) == 1
|
| 291 |
assert collect_calls[0]['tagged_documents'] == []
|
| 292 |
assert collect_calls[0]['existing_merged_chunks'] == {'https://target.example/page-1': [existing_chunk]}
|
|
|
|
| 321 |
|
| 322 |
result = scraper.scrape_target(target_url)
|
| 323 |
|
| 324 |
+
assert isinstance(result, ScrapeManifest)
|
| 325 |
+
assert not result
|
| 326 |
saved_filenames = [call['filename'] for call in scraper._save_calls]
|
| 327 |
assert 'merged_chunk_metadata' not in saved_filenames
|
| 328 |
assert scraper._content_cleaner.perform_calls == []
|
| 329 |
+
|
| 330 |
+
def test_commit_scrape_persists_timestamps_and_removes_temp_files(self, scraper):
|
| 331 |
+
target_url = 'https://target.example/'
|
| 332 |
+
source_url = 'https://target.example/page-1'
|
| 333 |
+
timestamp = _make_timestamp()
|
| 334 |
+
scraper._pending_timestamps = {source_url: timestamp}
|
| 335 |
+
|
| 336 |
+
for filename in (
|
| 337 |
+
scraper._get_temp_chunks_filename(target_url),
|
| 338 |
+
scraper._get_temp_timestamps_filename(target_url),
|
| 339 |
+
):
|
| 340 |
+
path = os.path.join(scraper._path.TEMP_CHUNKS_OUTPUT, filename + '.json')
|
| 341 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 342 |
+
f.write('{}')
|
| 343 |
+
|
| 344 |
+
scraper.commit_scrape(ScrapeManifest(
|
| 345 |
+
target_url=target_url,
|
| 346 |
+
chunks_by_language={'en': [], 'de': []},
|
| 347 |
+
processed_sources=[source_url],
|
| 348 |
+
))
|
| 349 |
+
|
| 350 |
+
assert scraper._url_timestamps[source_url] == timestamp
|
| 351 |
+
assert any(
|
| 352 |
+
call['filename'] == 'url_timestamps'
|
| 353 |
+
for call in scraper._save_calls
|
| 354 |
+
)
|
| 355 |
+
for filename in (
|
| 356 |
+
scraper._get_temp_chunks_filename(target_url),
|
| 357 |
+
scraper._get_temp_timestamps_filename(target_url),
|
| 358 |
+
):
|
| 359 |
+
path = os.path.join(scraper._path.TEMP_CHUNKS_OUTPUT, filename + '.json')
|
| 360 |
+
assert not os.path.exists(path)
|
tests/test_master_transfer_integrations.py
CHANGED
|
@@ -168,8 +168,8 @@ class FakeBatchContext:
|
|
| 168 |
def __exit__(self, exc_type, exc, tb):
|
| 169 |
return False
|
| 170 |
|
| 171 |
-
def add_object(self, properties, vector=None):
|
| 172 |
-
self.added.append({"properties": properties, "vector": vector})
|
| 173 |
|
| 174 |
|
| 175 |
class FakeBatchFactory:
|
|
@@ -199,6 +199,7 @@ def test_batch_import_embeds_rows_and_writes_named_vectors(monkeypatch):
|
|
| 199 |
assert errors == []
|
| 200 |
assert service._embedding_client.document_inputs == [["First chunk"]]
|
| 201 |
assert batch_context.added[0]["vector"] == {"test_vectors": [0.7, 0.8, 0.9]}
|
|
|
|
| 202 |
|
| 203 |
|
| 204 |
def test_query_embeds_once_and_passes_vector_to_hybrid(monkeypatch):
|
|
|
|
| 168 |
def __exit__(self, exc_type, exc, tb):
|
| 169 |
return False
|
| 170 |
|
| 171 |
+
def add_object(self, properties, vector=None, uuid=None):
|
| 172 |
+
self.added.append({"properties": properties, "vector": vector, "uuid": uuid})
|
| 173 |
|
| 174 |
|
| 175 |
class FakeBatchFactory:
|
|
|
|
| 199 |
assert errors == []
|
| 200 |
assert service._embedding_client.document_inputs == [["First chunk"]]
|
| 201 |
assert batch_context.added[0]["vector"] == {"test_vectors": [0.7, 0.8, 0.9]}
|
| 202 |
+
assert batch_context.added[0]["uuid"]
|
| 203 |
|
| 204 |
|
| 205 |
def test_query_embeds_once_and_passes_vector_to_hybrid(monkeypatch):
|
tests/test_safe_import_reconciliation.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from types import SimpleNamespace
|
| 2 |
+
from threading import RLock
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from src.database.weavservice import (
|
| 7 |
+
ImportBatchError,
|
| 8 |
+
PreparedImportRow,
|
| 9 |
+
WeaviateService,
|
| 10 |
+
)
|
| 11 |
+
from src.pipeline.pipeline import ImportPipeline
|
| 12 |
+
from src.pipeline.utils import ProcessingResult
|
| 13 |
+
from src.scraping.types import ScrapeManifest
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class FakeData:
|
| 17 |
+
def __init__(self, state, lang):
|
| 18 |
+
self.state = state
|
| 19 |
+
self.lang = lang
|
| 20 |
+
|
| 21 |
+
def delete_by_id(self, object_uuid):
|
| 22 |
+
self.state[self.lang].pop(str(object_uuid), None)
|
| 23 |
+
return True
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _reconciliation_service(initial_state):
|
| 27 |
+
service = object.__new__(WeaviateService)
|
| 28 |
+
service._last_query_time = 0
|
| 29 |
+
state = {
|
| 30 |
+
lang: dict(objects)
|
| 31 |
+
for lang, objects in initial_state.items()
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def prepare(rows, lang):
|
| 35 |
+
return [
|
| 36 |
+
PreparedImportRow(
|
| 37 |
+
uuid=row["uuid"],
|
| 38 |
+
properties=row,
|
| 39 |
+
vector={"test": [1.0]},
|
| 40 |
+
)
|
| 41 |
+
for row in rows
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
def collect(lang, source):
|
| 45 |
+
return {
|
| 46 |
+
object_uuid: properties
|
| 47 |
+
for object_uuid, properties in state[lang].items()
|
| 48 |
+
if properties.get("source") == source
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
def write(rows, lang):
|
| 52 |
+
for row in rows:
|
| 53 |
+
state[lang][row.uuid] = row.properties
|
| 54 |
+
|
| 55 |
+
service.prepare_batch_import = prepare
|
| 56 |
+
service._collect_source_objects = collect
|
| 57 |
+
service._write_prepared_rows = write
|
| 58 |
+
service._select_collection = lambda lang: (
|
| 59 |
+
SimpleNamespace(data=FakeData(state, lang)),
|
| 60 |
+
f"collection_{lang}",
|
| 61 |
+
)
|
| 62 |
+
return service, state
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_collect_source_objects_uses_offset_with_filters():
|
| 66 |
+
source = "https://example.test/page"
|
| 67 |
+
pages = [
|
| 68 |
+
[
|
| 69 |
+
SimpleNamespace(
|
| 70 |
+
uuid=f"uuid-{index}",
|
| 71 |
+
properties={"source": source, "chunk_id": str(index)},
|
| 72 |
+
)
|
| 73 |
+
for index in range(start, min(start + 100, 205))
|
| 74 |
+
]
|
| 75 |
+
for start in (0, 100, 200)
|
| 76 |
+
]
|
| 77 |
+
calls = []
|
| 78 |
+
|
| 79 |
+
def fetch_objects(**kwargs):
|
| 80 |
+
calls.append(kwargs)
|
| 81 |
+
return SimpleNamespace(objects=pages[kwargs["offset"] // 100])
|
| 82 |
+
|
| 83 |
+
service = object.__new__(WeaviateService)
|
| 84 |
+
service._client_lock = RLock()
|
| 85 |
+
service._select_collection = lambda lang: (
|
| 86 |
+
SimpleNamespace(query=SimpleNamespace(fetch_objects=fetch_objects)),
|
| 87 |
+
"test_collection",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
objects = service._collect_source_objects("en", source)
|
| 91 |
+
|
| 92 |
+
assert len(objects) == 205
|
| 93 |
+
assert [call["offset"] for call in calls] == [0, 100, 200]
|
| 94 |
+
assert all("after" not in call for call in calls)
|
| 95 |
+
assert all(call["filters"] is not None for call in calls)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_reconcile_migrates_legacy_uuid_and_language():
|
| 99 |
+
source = "https://example.test/page"
|
| 100 |
+
service, state = _reconciliation_service({
|
| 101 |
+
"en": {"legacy-random": {"source": source}},
|
| 102 |
+
"de": {},
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
summary = service.reconcile_source(
|
| 106 |
+
source,
|
| 107 |
+
{
|
| 108 |
+
"en": [],
|
| 109 |
+
"de": [{"uuid": "deterministic-new", "source": source}],
|
| 110 |
+
},
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
assert state["en"] == {}
|
| 114 |
+
assert set(state["de"]) == {"deterministic-new"}
|
| 115 |
+
assert summary.inserted == 1
|
| 116 |
+
assert summary.deleted == 1
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_reconcile_zero_chunks_removes_old_source():
|
| 120 |
+
source = "https://example.test/removed"
|
| 121 |
+
service, state = _reconciliation_service({
|
| 122 |
+
"en": {"old": {"source": source}},
|
| 123 |
+
"de": {},
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
service.reconcile_source(source, {"en": [], "de": []})
|
| 127 |
+
|
| 128 |
+
assert state == {"en": {}, "de": {}}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_reconcile_insert_failure_preserves_old_objects():
|
| 132 |
+
source = "https://example.test/page"
|
| 133 |
+
service, state = _reconciliation_service({
|
| 134 |
+
"en": {"old": {"source": source}},
|
| 135 |
+
"de": {},
|
| 136 |
+
})
|
| 137 |
+
service._write_prepared_rows = lambda rows, lang: (
|
| 138 |
+
(_ for _ in ()).throw(ImportBatchError("embedding or insert failed"))
|
| 139 |
+
if rows else None
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
with pytest.raises(ImportBatchError):
|
| 143 |
+
service.reconcile_source(
|
| 144 |
+
source,
|
| 145 |
+
{"en": [{"uuid": "new", "source": source}], "de": []},
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
assert set(state["en"]) == {"old"}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_reconcile_verification_failure_prevents_cleanup():
|
| 152 |
+
source = "https://example.test/page"
|
| 153 |
+
service, state = _reconciliation_service({
|
| 154 |
+
"en": {"old": {"source": source}},
|
| 155 |
+
"de": {},
|
| 156 |
+
})
|
| 157 |
+
service._write_prepared_rows = lambda rows, lang: None
|
| 158 |
+
|
| 159 |
+
with pytest.raises(ImportBatchError, match="Verification failed"):
|
| 160 |
+
service.reconcile_source(
|
| 161 |
+
source,
|
| 162 |
+
{"en": [{"uuid": "missing", "source": source}], "de": []},
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
assert set(state["en"]) == {"old"}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_reconcile_retries_after_partial_insert_without_duplicates():
|
| 169 |
+
source = "https://example.test/page"
|
| 170 |
+
service, state = _reconciliation_service({
|
| 171 |
+
"en": {"old": {"source": source}},
|
| 172 |
+
"de": {},
|
| 173 |
+
})
|
| 174 |
+
normal_write = service._write_prepared_rows
|
| 175 |
+
failed_once = False
|
| 176 |
+
|
| 177 |
+
def partial_write(rows, lang):
|
| 178 |
+
nonlocal failed_once
|
| 179 |
+
if rows and not failed_once:
|
| 180 |
+
failed_once = True
|
| 181 |
+
normal_write(rows[:1], lang)
|
| 182 |
+
raise ImportBatchError("connection dropped after a partial insert")
|
| 183 |
+
normal_write(rows, lang)
|
| 184 |
+
|
| 185 |
+
service._write_prepared_rows = partial_write
|
| 186 |
+
rows = {
|
| 187 |
+
"en": [
|
| 188 |
+
{"uuid": "new-1", "source": source},
|
| 189 |
+
{"uuid": "new-2", "source": source},
|
| 190 |
+
],
|
| 191 |
+
"de": [],
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
with pytest.raises(ImportBatchError):
|
| 195 |
+
service.reconcile_source(source, rows)
|
| 196 |
+
|
| 197 |
+
summary = service.reconcile_source(source, rows)
|
| 198 |
+
|
| 199 |
+
assert set(state["en"]) == {"new-1", "new-2"}
|
| 200 |
+
assert summary.inserted == 1
|
| 201 |
+
assert summary.retained == 1
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_reconcile_retries_after_cleanup_failure():
|
| 205 |
+
source = "https://example.test/page"
|
| 206 |
+
service, state = _reconciliation_service({
|
| 207 |
+
"en": {"old": {"source": source}},
|
| 208 |
+
"de": {},
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
class FailOnceData(FakeData):
|
| 212 |
+
failed = False
|
| 213 |
+
|
| 214 |
+
def delete_by_id(self, object_uuid):
|
| 215 |
+
if not self.failed:
|
| 216 |
+
self.failed = True
|
| 217 |
+
raise RuntimeError("delete connection dropped")
|
| 218 |
+
return super().delete_by_id(object_uuid)
|
| 219 |
+
|
| 220 |
+
failing_data = FailOnceData(state, "en")
|
| 221 |
+
service._select_collection = lambda lang: (
|
| 222 |
+
SimpleNamespace(data=failing_data if lang == "en" else FakeData(state, lang)),
|
| 223 |
+
f"collection_{lang}",
|
| 224 |
+
)
|
| 225 |
+
rows = {"en": [{"uuid": "new", "source": source}], "de": []}
|
| 226 |
+
|
| 227 |
+
with pytest.raises(RuntimeError, match="delete connection dropped"):
|
| 228 |
+
service.reconcile_source(source, rows)
|
| 229 |
+
|
| 230 |
+
assert set(state["en"]) == {"old", "new"}
|
| 231 |
+
|
| 232 |
+
summary = service.reconcile_source(source, rows)
|
| 233 |
+
|
| 234 |
+
assert set(state["en"]) == {"new"}
|
| 235 |
+
assert summary.retained == 1
|
| 236 |
+
assert summary.inserted == 0
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
class AlwaysFailBatchContext:
|
| 240 |
+
number_errors = 1
|
| 241 |
+
|
| 242 |
+
def __enter__(self):
|
| 243 |
+
return self
|
| 244 |
+
|
| 245 |
+
def __exit__(self, exc_type, exc, tb):
|
| 246 |
+
return False
|
| 247 |
+
|
| 248 |
+
def add_object(self, **kwargs):
|
| 249 |
+
pass
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class AlwaysFailBatch:
|
| 253 |
+
def __init__(self):
|
| 254 |
+
self.failed_objects = []
|
| 255 |
+
|
| 256 |
+
def fixed_size(self, **kwargs):
|
| 257 |
+
return AlwaysFailBatchContext()
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class FailedObjectBatchContext:
|
| 261 |
+
number_errors = 0
|
| 262 |
+
|
| 263 |
+
def __init__(self, owner):
|
| 264 |
+
self.owner = owner
|
| 265 |
+
|
| 266 |
+
def __enter__(self):
|
| 267 |
+
return self
|
| 268 |
+
|
| 269 |
+
def __exit__(self, exc_type, exc, tb):
|
| 270 |
+
self.owner.failed_objects.append("server rejected object")
|
| 271 |
+
return False
|
| 272 |
+
|
| 273 |
+
def add_object(self, **kwargs):
|
| 274 |
+
pass
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class FailedObjectBatch:
|
| 278 |
+
def __init__(self):
|
| 279 |
+
self.failed_objects = []
|
| 280 |
+
|
| 281 |
+
def fixed_size(self, **kwargs):
|
| 282 |
+
return FailedObjectBatchContext(self)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def test_strict_batch_raises_on_asynchronous_errors(monkeypatch):
|
| 286 |
+
service = object.__new__(WeaviateService)
|
| 287 |
+
service._client_lock = RLock()
|
| 288 |
+
service._last_query_time = 0
|
| 289 |
+
service._select_collection = lambda lang: (
|
| 290 |
+
SimpleNamespace(batch=AlwaysFailBatch()),
|
| 291 |
+
"test_collection",
|
| 292 |
+
)
|
| 293 |
+
monkeypatch.setattr("src.database.weavservice.sleep", lambda _: None)
|
| 294 |
+
|
| 295 |
+
with pytest.raises(ImportBatchError):
|
| 296 |
+
service._write_prepared_rows(
|
| 297 |
+
[
|
| 298 |
+
PreparedImportRow(
|
| 299 |
+
uuid="68cda09a-5466-5f23-a595-3e9575af4102",
|
| 300 |
+
properties={"source": "source", "chunk_id": "chunk"},
|
| 301 |
+
vector={"test": [1.0]},
|
| 302 |
+
)
|
| 303 |
+
],
|
| 304 |
+
"en",
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def test_strict_batch_checks_failed_objects(monkeypatch):
|
| 309 |
+
service = object.__new__(WeaviateService)
|
| 310 |
+
service._client_lock = RLock()
|
| 311 |
+
service._last_query_time = 0
|
| 312 |
+
service._select_collection = lambda lang: (
|
| 313 |
+
SimpleNamespace(batch=FailedObjectBatch()),
|
| 314 |
+
"test_collection",
|
| 315 |
+
)
|
| 316 |
+
monkeypatch.setattr("src.database.weavservice.sleep", lambda _: None)
|
| 317 |
+
|
| 318 |
+
with pytest.raises(ImportBatchError, match="server rejected object"):
|
| 319 |
+
service._write_prepared_rows(
|
| 320 |
+
[
|
| 321 |
+
PreparedImportRow(
|
| 322 |
+
uuid="68cda09a-5466-5f23-a595-3e9575af4102",
|
| 323 |
+
properties={"source": "source", "chunk_id": "chunk"},
|
| 324 |
+
vector={"test": [1.0]},
|
| 325 |
+
)
|
| 326 |
+
],
|
| 327 |
+
"en",
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def test_scraper_manifest_reconciles_empty_languages():
|
| 332 |
+
pipeline = object.__new__(ImportPipeline)
|
| 333 |
+
calls = []
|
| 334 |
+
pipeline._service = SimpleNamespace(
|
| 335 |
+
reconcile_source=lambda source, rows: calls.append((source, rows))
|
| 336 |
+
)
|
| 337 |
+
manifest = ScrapeManifest(
|
| 338 |
+
target_url="https://example.test",
|
| 339 |
+
chunks_by_language={"de": []},
|
| 340 |
+
processed_sources=["https://example.test/empty"],
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
pipeline.import_from_scraper(manifest)
|
| 344 |
+
|
| 345 |
+
source, rows = calls[0]
|
| 346 |
+
assert source == "https://example.test/empty"
|
| 347 |
+
assert rows == {"en": [], "de": []}
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def test_cli_local_import_replaces_existing_source():
|
| 351 |
+
pipeline = object.__new__(ImportPipeline)
|
| 352 |
+
calls = []
|
| 353 |
+
pipeline._deduplication_callback = None
|
| 354 |
+
pipeline._service = SimpleNamespace(
|
| 355 |
+
source_object_count=lambda source: 2,
|
| 356 |
+
reconcile_source=lambda source, rows: calls.append(("replace", source, rows)),
|
| 357 |
+
batch_import=lambda rows, lang: calls.append(("append", lang, rows)),
|
| 358 |
+
)
|
| 359 |
+
result = ProcessingResult(
|
| 360 |
+
chunks=[{"source": "document.pdf", "chunk_id": "new"}],
|
| 361 |
+
source="document.pdf",
|
| 362 |
+
lang="en",
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
pipeline._import_document_result(result)
|
| 366 |
+
|
| 367 |
+
assert calls[0][0] == "replace"
|
| 368 |
+
assert calls[0][2]["en"] == result.chunks
|
| 369 |
+
assert calls[0][2]["de"] == []
|
tools/scraping.py
CHANGED
|
@@ -21,9 +21,11 @@ def scraping_task(full_scrape: bool):
|
|
| 21 |
pipeline = ImportPipeline()
|
| 22 |
|
| 23 |
for target_url in config.scraping.TARGET_URLS:
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
|
| 28 |
logger.info("Scraper task finished gracefully")
|
| 29 |
except Exception as e:
|
|
|
|
| 21 |
pipeline = ImportPipeline()
|
| 22 |
|
| 23 |
for target_url in config.scraping.TARGET_URLS:
|
| 24 |
+
manifest = scraper.scrape_target(target_url)
|
| 25 |
+
if not manifest:
|
| 26 |
+
continue
|
| 27 |
+
pipeline.import_from_scraper(manifest)
|
| 28 |
+
scraper.commit_scrape(manifest)
|
| 29 |
|
| 30 |
logger.info("Scraper task finished gracefully")
|
| 31 |
except Exception as e:
|