Pygmales commited on
Commit
3c5b98e
·
verified ·
1 Parent(s): 6dc2f9f

Sync from GitHub 7acd8038042b484e9650a6acfffda15038b5f1f3

Browse files
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 --full_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 >> logs/scrape.log 2>&1
113
- 0 2 * * 0 cd <repo> && ./venv/bin/python main.py --scrape --full_scrape >> 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 →
 
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 --full_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(full_scrape: bool = False) -> None:
18
  """
19
  Run the scraper to collect program data.
20
 
21
  Args:
22
- full_scrape: 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=full_scrape)
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", action="store_true",
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.full_scrape)
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
- def batch_import(self, data_rows: list, lang: str) -> list:
327
- """
328
- Perform a batch import of multiple objects into the current collection.
 
 
 
 
 
 
 
 
 
 
 
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
- Returns:
335
- list[dict]: List of failed imports with error details, if any.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- logger.error("No working collection selected!")
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
- def _import_batch(batch_rows: list[tuple[int, dict]]) -> None:
352
- vectors = self._embed_batch_vectors(batch_rows)
353
- with collection.batch.fixed_size(batch_size=batch_size, concurrent_requests=1) as batch:
354
- for (idx, data_row), vector in zip(batch_rows, vectors):
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(batch_rows) - 1,
380
- e,
381
  )
382
  sleep(1)
383
 
384
- self._last_query_time = perf_counter()
385
- logger.info(f"Batch import finished. Total errors: {len(import_errors)}")
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 import_errors
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 import WeaviateService
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
- Main pipeline class responsible for importing website and local documents
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 the import pipeline with optional callbacks for logging and deduplication.
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): A callback function for handling
35
- deduplication decisions. Defaults to a placeholder if not provided.
36
  """
37
  self._logging_callback = logging_callback or logging_callback_placeholder
38
- self._deduplication_callback = deduplication_callback or deduplication_callback_placeholder
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(self, scraper_chunks: dict[str, dict]) -> None:
47
- for lang, chunks in scraper_chunks.items():
48
- if not chunks: continue
49
-
50
- sources = list(set([chunk.get('source', '') for chunk in chunks]))
51
- self._service.delete_chunks(lang, property_filters={'source': sources})
52
- self._service.batch_import(data_rows=chunks, lang=lang)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- scraped_chunks = scraper.scrape_target(target_url)
67
- if not scraped_chunks:
68
  self._logging_callback(f"No importable chunks scraped from {target_url}.", 100)
69
- else:
70
- self._logging_callback(f"Importing scraped chunks from {target_url}...", 90)
71
 
72
- self.import_from_scraper(scraped_chunks)
73
- scraper.delete_temp_merged_chunks(target_url)
 
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
- scraped_chunks = scraper.scrape_target(url)
97
- if not scraped_chunks:
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(scraped_chunks)
 
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
- chunks = self._pipeline(paths, self._docprocessor, reset_collections)
126
 
127
  if reset_collections:
128
- self._logging_callback('Resetting database collections...', 60)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  self._service._reset_collections()
130
-
131
- self._logging_callback('Importing document chunks to database...', 90)
132
- for lang, ch in chunks.items():
133
- self._service.batch_import(data_rows=ch, lang=lang)
 
134
 
135
  self._import_urls_via_scraper(urls, scrape_all=True)
136
 
137
  self._logging_callback(
138
- f'Successfully imported {sum([len(ch) for ch in chunks.values()])} document chunks!',
139
  100
140
  )
141
 
142
 
143
- def _pipeline(
144
- self,
145
- sources: list[str],
146
- processor: ProcessorBase,
147
- reset_collections: bool,
148
- ) -> dict:
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 = processor.process(source)
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
- self._logging_callback(f'Storing chunks for {source}...', 100, result)
182
- unique_chunks[result.lang].extend(result.chunks)
183
 
184
- if all([len(chunks) == 0 for chunks in unique_chunks.values()]):
185
  self._logging_callback('No new data could be extracted from these sources!', 100)
186
- implogger.warning(f"File(s) provided for the insertion do not contain any unique information.")
187
 
188
- return unique_chunks
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
- If all chunks are duplicates, invokes the deduplication callback to decide whether
196
- to delete existing duplicates and reimport. Otherwise, returns only unique chunks.
197
-
198
- Args:
199
- result (ProcessingResult): The processing result containing document chunks.
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
- Returns:
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) -> list[ChunkMetadata]:
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 chunk_metadatas.get('final', chunk_metadatas['merged'])
 
 
 
 
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(temp_chunks, tagged_documents)
 
 
 
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
- ) -> set[list, list[dict]]:
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(self._path.SCRAPING_OUTPUT, 'url_timestamps', self._url_timestamps)
 
 
 
 
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 delete_temp_merged_chunks(self, target_url: str) -> None:
534
- temp_path = os.path.join(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  self._path.TEMP_CHUNKS_OUTPUT,
536
- self._get_temp_chunks_filename(target_url) + '.json'
537
  )
538
- if os.path.exists(temp_path):
539
- os.remove(temp_path)
540
- incupd_logger.info(f"Deleted temp merged chunks file '{temp_path}'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- response = call_with_exponential_backoff(fetch_head, args=(url, etag), delay=crawl_delay)
632
- if response['status'] == 'FAIL':
633
- logger.warning(f"Failed to fetch head for URL {url}: {response['last_error']}! Skipping...")
634
- return ScrapingResult(status=ScrapingStatus.REJECTED)
635
-
636
- fetch_result = response['result']
637
- validation = self._is_fetch_valid(url, visited_urls, fetch_result)
638
- if validation != ScrapingStatus.OK:
639
- return ScrapingResult(status=validation)
 
 
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.exception(f"Head fetch failed: {url}")
164
- raise e
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 set(scraper._url_timestamps) == {
 
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 [chunk.source_url for chunk in result['en']] == ['https://target.example/page-1']
 
 
 
 
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
- chunks = scraper.scrape_target(target_url)
25
- pipeline.import_from_scraper(chunks)
26
- scraper.delete_temp_merged_chunks(target_url)
 
 
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: