Pygmales commited on
Commit
31c360c
·
verified ·
1 Parent(s): 7515e40

Sync from GitHub 7e4c3e8eb6f1afe4620f55cca1a22dfef2c1ee7c

Browse files
AUDIT_LATENCY_HALLUCINATIONS.md CHANGED
@@ -88,7 +88,7 @@ Offline (kein User wartet):
88
 
89
  ## 4. Offene Punkte (bewusst nicht gemacht)
90
 
91
- - **Embeddings** laufen weiter über die HF Inference API (`text2vec_huggingface`). Wechsel auf OpenAI-Embeddings oder lokales Modell erfordert Neu-Erstellung der Weaviate-Collection + Re-Import — lohnt sich zusammen mit Re-Chunking (200 → 512–1024 Tokens). Der BM25-Fallback loggt jetzt sichtbar.
92
  - **Merge-Strategie:** lokale Arbeit liegt auf `master`, Remote-Hauptbranch ist `main` — vor dem Push klären (Rename oder PR).
93
  - Cron läuft auf dem Entwicklungs-Mac nur, wenn er wach ist — auf dem Produktivserver einrichten.
94
  - `scripts/remove_legacy_code.py` ist ein verbrauchtes Einweg-Skript und kann gelöscht werden.
 
88
 
89
  ## 4. Offene Punkte (bewusst nicht gemacht)
90
 
91
+ - **Embeddings:** Die Migration auf app-seitig erzeugte OpenRouter-Embeddings (`openai/text-embedding-3-small`) erfordert weiterhin Neu-Erstellung der Weaviate-Collection + Re-Import. Der BM25-Fallback loggt sichtbar, falls Embedding oder Vektor-Hybrid-Query fehlschlägt.
92
  - **Merge-Strategie:** lokale Arbeit liegt auf `master`, Remote-Hauptbranch ist `main` — vor dem Push klären (Rename oder PR).
93
  - Cron läuft auf dem Entwicklungs-Mac nur, wenn er wach ist — auf dem Produktivserver einrichten.
94
  - `scripts/remove_legacy_code.py` ist ein verbrauchtes Einweg-Skript und kann gelöscht werden.
README.md CHANGED
@@ -65,9 +65,9 @@ Following variables are required for every mode to run:
65
 
66
  ```bash
67
  OPENAI_API_KEY=...
 
68
  WEAVIATE_API_KEY=...
69
  WEAVIATE_CLUSTER_URL=...
70
- HUGGING_FACE_API_KEY=...
71
  ```
72
 
73
  Optional but commonly useful:
@@ -79,7 +79,6 @@ LANGSMITH_PROJECT=...
79
  LANGSMITH_ENDPOINT=https://api.smith.langchain.com
80
 
81
  GROQ_API_KEY=...
82
- OPEN_ROUTER_API_KEY=...
83
 
84
  REDIS_CLOUD_HOST=...
85
  REDIS_CLOUD_PORT=...
@@ -171,6 +170,18 @@ python main.py --clear-cache
171
  python main.py --dbapp
172
  ```
173
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  Cache mode can be selected explicitly:
175
 
176
  ```bash
 
65
 
66
  ```bash
67
  OPENAI_API_KEY=...
68
+ OPEN_ROUTER_API_KEY=...
69
  WEAVIATE_API_KEY=...
70
  WEAVIATE_CLUSTER_URL=...
 
71
  ```
72
 
73
  Optional but commonly useful:
 
79
  LANGSMITH_ENDPOINT=https://api.smith.langchain.com
80
 
81
  GROQ_API_KEY=...
 
82
 
83
  REDIS_CLOUD_HOST=...
84
  REDIS_CLOUD_PORT=...
 
170
  python main.py --dbapp
171
  ```
172
 
173
+ Embedding model changes require a Weaviate collection rebuild and re-import:
174
+
175
+ ```bash
176
+ python main.py --weaviate redo
177
+ python main.py --scrape
178
+ # plus python main.py --imports ... for any local source files you maintain
179
+ ```
180
+
181
+ The default cloud embedding path uses OpenRouter `openai/text-embedding-3-small`
182
+ and stores app-generated vectors in Weaviate. The existing scraper restoration
183
+ flow is unchanged.
184
+
185
  Cache mode can be selected explicitly:
186
 
187
  ```bash
TODO_TEAM.md CHANGED
@@ -20,7 +20,7 @@ Status: 2026-06-11 · Base: branch `master` · Context: `AUDIT_LATENCY_HALLUCINA
20
 
21
  ## Backlog (prioritised)
22
 
23
- - [ ] **Move embeddings off the HF Inference API** (OpenAI `text-embedding-3-small` or local) + **re-chunking** 200 512–1024 tokens; requires rebuilding the Weaviate collection + re-import. Trigger: frequent BM25 fallbacks or weak long-tail answers
24
  - [ ] **LLM eval as CI gate** (GitHub Action with `RUN_LLM_EVAL=1` on PRs against `main`; secret for the API key budget)
25
  - [ ] **Rework or remove the cache** — currently exact query match per session (hit rate ~0); either normalised/semantic keys or delete the code
26
  - [ ] **Simplify booking logic further** — replace the remaining keyword heuristics in `_query_lead` (explicit booking intent, preference follow-up) with pure structured-output flags
 
20
 
21
  ## Backlog (prioritised)
22
 
23
+ - [ ] **Rebuild retrieval collections after embedding migration** current code uses OpenRouter `openai/text-embedding-3-small` with app-side vectors and 512-token chunks; run `python main.py --weaviate redo`, then the existing scrape/import jobs, and monitor BM25 fallback warnings
24
  - [ ] **LLM eval as CI gate** (GitHub Action with `RUN_LLM_EVAL=1` on PRs against `main`; secret for the API key budget)
25
  - [ ] **Rework or remove the cache** — currently exact query match per session (hit rate ~0); either normalised/semantic keys or delete the code
26
  - [ ] **Simplify booking logic further** — replace the remaining keyword heuristics in `_query_lead` (explicit booking intent, preference follow-up) with pure structured-output flags
config.py CHANGED
@@ -36,7 +36,6 @@ MAX_CONVERSATION_TURNS = 20
36
  # Keep the master branch's latency-oriented defaults.
37
  MAIN_AGENT_MODEL = ('openai', 'gpt-4.1')
38
  FALLBACK_MODELS = [('openai', 'gpt-5-mini')]
39
- SUBAGENT_MODEL = ('openai', 'gpt-5-mini')
40
  LANGUAGE_DETECTION_MODEL = ('openai', 'gpt-4o-mini')
41
  CONFIDENCE_SCORING_MODEL = ('openai', 'gpt-4o-mini')
42
  SUMMARIZATION_MODEL = ('openai', 'gpt-4.1')
@@ -50,17 +49,12 @@ OPENAI_MODEL = 'gpt-4.1'
50
 
51
  # ==================================== Weaviate Database Configuration ======================================
52
 
53
- # A boolean; either True or False.
54
- # Defines whether the database is set as a local instance (via Docker container),
55
- # or as a cloud service. More information on https://docs.weaviate.io/weaviate.
56
- WEAVIATE_IS_LOCAL = False
57
-
58
  # A string. Defines the name of the colletions stored in the database.
59
  # For each available language a new collection will be created
60
  # with set name <WEAVIATE_COLLECTION_BASENAME>_<LANGUAGE>.
61
  WEAVIATE_COLLECTION_BASENAME = 'hsg_rag_content'
62
 
63
- # A string; either 'manual', 'filesystem' (local instance), 's3' (AWS).
64
  # Defines the service for storing the database backups.
65
  # More information on https://docs.weaviate.io/deploy/configuration/backups.
66
  WEAVIATE_BACKUP_METHOD = 'manual'
@@ -93,7 +87,9 @@ WEAVIATE_INSERT_TIMEOUT = 600
93
  WEAVIATE_KEEP_WARM_ENABLED = True
94
 
95
  # An integer. Defines how often the keep-warm loop may query Weaviate while idle (in seconds).
96
- WEAVIATE_KEEP_WARM_INTERVAL = 30
 
 
97
 
98
  # An integer. Defines when an idle Weaviate client is considered stale enough to
99
  # reconnect proactively (in seconds).
@@ -122,9 +118,14 @@ CACHE_LOCAL_PORT = 6379
122
 
123
  # ===================================== Data Processing Configuration =======================================
124
 
125
- # A string representing the name of an embeding model for embedding generation.
126
- # The parameter MAX_TOKENS must match this model's maximum token amount.
127
- EMBEDDING_MODEL = 'sentence-transformers/multi-qa-mpnet-base-dot-v1'
 
 
 
 
 
128
 
129
  # A float in range from 0 to 1. Sets the threshold for english language in the language detector.
130
  # If the language detection certanty is lower than the threshold, the English language will be returned.
 
36
  # Keep the master branch's latency-oriented defaults.
37
  MAIN_AGENT_MODEL = ('openai', 'gpt-4.1')
38
  FALLBACK_MODELS = [('openai', 'gpt-5-mini')]
 
39
  LANGUAGE_DETECTION_MODEL = ('openai', 'gpt-4o-mini')
40
  CONFIDENCE_SCORING_MODEL = ('openai', 'gpt-4o-mini')
41
  SUMMARIZATION_MODEL = ('openai', 'gpt-4.1')
 
49
 
50
  # ==================================== Weaviate Database Configuration ======================================
51
 
 
 
 
 
 
52
  # A string. Defines the name of the colletions stored in the database.
53
  # For each available language a new collection will be created
54
  # with set name <WEAVIATE_COLLECTION_BASENAME>_<LANGUAGE>.
55
  WEAVIATE_COLLECTION_BASENAME = 'hsg_rag_content'
56
 
57
+ # A string; either 'manual', 'filesystem', 's3' (AWS).
58
  # Defines the service for storing the database backups.
59
  # More information on https://docs.weaviate.io/deploy/configuration/backups.
60
  WEAVIATE_BACKUP_METHOD = 'manual'
 
87
  WEAVIATE_KEEP_WARM_ENABLED = True
88
 
89
  # An integer. Defines how often the keep-warm loop may query Weaviate while idle (in seconds).
90
+ # 180s keeps the vectorizer warm enough between turns while cutting idle
91
+ # hybrid queries (and HF API usage) to a sixth of the previous 30s setting.
92
+ WEAVIATE_KEEP_WARM_INTERVAL = 180
93
 
94
  # An integer. Defines when an idle Weaviate client is considered stale enough to
95
  # reconnect proactively (in seconds).
 
118
 
119
  # ===================================== Data Processing Configuration =======================================
120
 
121
+ # Embeddings are generated by the application through OpenRouter and stored as
122
+ # self-provided vectors in Weaviate. Rebuild the collection after changing any
123
+ # embedding provider/model/vector dimension setting.
124
+ EMBEDDING_MODEL = 'openai/text-embedding-3-small'
125
+ EMBEDDING_BASE_URL = 'https://openrouter.ai/api/v1'
126
+ EMBEDDING_DIMENSIONS = 1536
127
+ EMBEDDING_BATCH_SIZE = 32
128
+ EMBEDDING_VECTOR_NAME = 'hsg_rag_embeddings'
129
 
130
  # A float in range from 0 to 1. Sets the threshold for english language in the language detector.
131
  # If the language detection certanty is lower than the threshold, the English language will be returned.
docs/deploy_readiness_checklist.md CHANGED
@@ -38,8 +38,7 @@ This checklist reflects the GitHub `main` state at commit `c0462c1b7c5074af682ec
38
  - DNS for `bot.hsg.ch` points to the target server
39
  - Caddy is installed and configured with `deploy/Caddyfile`
40
  - Port `7860` is reachable internally on the host
41
- - Weaviate is available:
42
- - local or cloud
43
  - Redis is available:
44
  - local or cloud
45
  - Outbound network access exists for required model downloads, or models are pre-cached
 
38
  - DNS for `bot.hsg.ch` points to the target server
39
  - Caddy is installed and configured with `deploy/Caddyfile`
40
  - Port `7860` is reachable internally on the host
41
+ - Weaviate Cloud is available
 
42
  - Redis is available:
43
  - local or cloud
44
  - Outbound network access exists for required model downloads, or models are pre-cached
docs/weaviate_database_setup.md CHANGED
@@ -1,12 +1,13 @@
1
  # Weaviate Database Setup
2
 
3
- This project uses a local instance of a Weaviate vector database to store vector embeddings. The chosen embedding model is Sentence Transformers. The model is integrated in the embedding insertion pipeline and will be installed alongside the database.
 
 
4
 
5
  ## Installation steps
6
  1. Create a new python virtual environment using `python -m venv venv`, activate the environment via `source venv/bin/activate`, install the needed requirements from the `requirements.txt` file if you haven't done it already.
7
- 2. Follow the installation guide to install [Docker Desktop](https://docs.docker.com/desktop/) on your device.
8
- 3. Navigate to `src/database` and locate the `docker-compose.yml` file. Inside this directory call the command `docker compose up -d` to install and setup the database and embedding model containers. Wait for installation to finish gracefully.
9
- 4. With the python environment activated, call the collection creation script from the `weaviate.py` located in the same directory using `python wvt_service.py --create_collections`. Inspect the logs to check whether the creation of the collections was successfull.
10
 
11
  If you've managed to setup the database and create the collections, the installation process is finished and the database is accessible from the other parts of the program.
12
 
@@ -20,6 +21,16 @@ To manage the state of the database directly, multiple useful scripts were devel
20
  - `-cb` or `--create_backup`: creates a backup of the current state of the database.
21
  - `-rb` pr `--restore_backup`: restores the state of the database from the provided backup\_id.
22
 
 
 
 
 
 
 
 
 
 
 
23
  ## Data properties
24
  Embeddings are stored in the corresponding language collection with a set of properties that define chunk metadata:
25
 
@@ -32,4 +43,4 @@ Embeddings are stored in the corresponding language collection with a set of pro
32
 
33
 
34
  ## WeaviateService
35
- The WeaviateService class manages the connection and the interaction with the local database.
 
1
  # Weaviate Database Setup
2
 
3
+ This project uses Weaviate Cloud to store retrieval chunks and vectors. The
4
+ application generates embeddings through OpenRouter
5
+ `openai/text-embedding-3-small` and stores them as self-provided vectors.
6
 
7
  ## Installation steps
8
  1. Create a new python virtual environment using `python -m venv venv`, activate the environment via `source venv/bin/activate`, install the needed requirements from the `requirements.txt` file if you haven't done it already.
9
+ 2. Configure `WEAVIATE_CLUSTER_URL`, `WEAVIATE_API_KEY`, and `OPEN_ROUTER_API_KEY`.
10
+ 3. With the python environment activated, initialize the collections with `python main.py --weaviate init`. Inspect the logs to check whether collection creation was successful.
 
11
 
12
  If you've managed to setup the database and create the collections, the installation process is finished and the database is accessible from the other parts of the program.
13
 
 
21
  - `-cb` or `--create_backup`: creates a backup of the current state of the database.
22
  - `-rb` pr `--restore_backup`: restores the state of the database from the provided backup\_id.
23
 
24
+ When changing embedding model, tokenizer, or vector dimensions, rebuild the collections and re-import content:
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
32
+ part of the knowledge base.
33
+
34
  ## Data properties
35
  Embeddings are stored in the corresponding language collection with a set of properties that define chunk metadata:
36
 
 
43
 
44
 
45
  ## WeaviateService
46
+ The WeaviateService class manages the connection and interaction with Weaviate Cloud.
requirements.txt CHANGED
@@ -16,8 +16,8 @@ colorama>=0.4.6
16
  # Language detection
17
  langdetect>=1.0.9
18
 
19
- # Transformers for tokenization
20
- transformers>=4.34.0
21
 
22
  # Web applications
23
  gradio==6.14.0
 
16
  # Language detection
17
  langdetect>=1.0.9
18
 
19
+ # Tokenization
20
+ tiktoken>=0.12.0
21
 
22
  # Web applications
23
  gradio==6.14.0
src/apps/dbapp/config.py CHANGED
@@ -16,6 +16,14 @@ def _dump_schema(schema):
16
  yaml.safe_dump(schema, f)
17
 
18
 
 
 
 
 
 
 
 
 
19
  class SchemaConfigurationFrame(CustomFrameBase):
20
  def __init__(self, parent, service: WeaviateService) -> None:
21
  super().__init__(parent, service)
@@ -71,7 +79,7 @@ class SchemaConfigurationFrame(CustomFrameBase):
71
  'data_type': prop['dataType'][0],
72
  'filterable': prop['indexFilterable'],
73
  'searchable': prop['indexSearchable'],
74
- 'skip_vectorization': prop['moduleConfig']['text2vec-huggingface']['skip'],
75
  }
76
  schema_data[prop['name']] = data_property
77
 
 
16
  yaml.safe_dump(schema, f)
17
 
18
 
19
+ def _skip_vectorization_from_module_config(prop: dict) -> bool:
20
+ module_config = prop.get('moduleConfig', {}) or {}
21
+ for module_data in module_config.values():
22
+ if isinstance(module_data, dict) and 'skip' in module_data:
23
+ return module_data['skip']
24
+ return False
25
+
26
+
27
  class SchemaConfigurationFrame(CustomFrameBase):
28
  def __init__(self, parent, service: WeaviateService) -> None:
29
  super().__init__(parent, service)
 
79
  'data_type': prop['dataType'][0],
80
  'filterable': prop['indexFilterable'],
81
  'searchable': prop['indexSearchable'],
82
+ 'skip_vectorization': _skip_vectorization_from_module_config(prop),
83
  }
84
  schema_data[prop['name']] = data_property
85
 
src/config/configs.py CHANGED
@@ -77,7 +77,12 @@ class ConversationStateConfig(ConfigBase):
77
 
78
  class ProcessingConfig(ConfigBase):
79
  LANG_AMBIGUITY_THRESHOLD: float = _get('LANG_AMBIGUITY_THRESHOLD')
80
- EMBEDDING_MODEL: float = _get('EMBEDDING_MODEL')
 
 
 
 
 
81
  MAX_TOKENS: int = _get('MAX_TOKENS')
82
  CHUNK_OVERLAP: int = _get('CHUNK_OVERLAP')
83
 
@@ -118,7 +123,6 @@ class CacheConfig(ConfigBase):
118
 
119
 
120
  class WeaviateConfig(ConfigBase):
121
- LOCAL_DATABASE: bool = _get('WEAVIATE_IS_LOCAL')
122
  WEAVIATE_COLLECTION_BASENAME: str = _get('WEAVIATE_COLLECTION_BASENAME')
123
 
124
  BACKUP_METHODS: list[str] = ['manual', 'filesystem', 's3']
@@ -130,13 +134,12 @@ class WeaviateConfig(ConfigBase):
130
 
131
  CLUSTER_URL: str = _get('WEAVIATE_CLUSTER_URL')
132
  WEAVIATE_API_KEY: str = _get('WEAVIATE_API_KEY')
133
- HUGGING_FACE_API_KEY: str = _get('HUGGING_FACE_API_KEY')
134
 
135
  INIT_TIMEOUT: int = _get('WEAVIATE_INIT_TIMEOUT', 90)
136
  QUERY_TIMEOUT: int = _get('WEAVIATE_QUERY_TIMEOUT', 60)
137
  INSERT_TIMEOUT: int = _get('WEAVIATE_INSERT_TIMEOUT', 600)
138
  KEEP_WARM_ENABLED: bool = _get_bool('WEAVIATE_KEEP_WARM_ENABLED', True)
139
- KEEP_WARM_INTERVAL: int = _get('WEAVIATE_KEEP_WARM_INTERVAL', 30, type_=int)
140
  CLIENT_IDLE_TIMEOUT: int = _get('WEAVIATE_CLIENT_IDLE_TIMEOUT', 25 * 60, type_=int)
141
 
142
 
@@ -174,7 +177,6 @@ class LLMConfig(ConfigBase):
174
 
175
  MAIN_AGENT_MODEL: tuple[str, str] = _get('MAIN_AGENT_MODEL', ('openai', 'gpt-4.1'))
176
  FALLBACK_MODELS: list[tuple[str, str]] = _get('FALLBACK_MODELS', [('openai', 'gpt-5-mini')])
177
- SUBAGENT_MODEL: tuple[str, str] = _get('SUBAGENT_MODEL', ('openai', 'gpt-5-mini'))
178
  LANGUAGE_DETECTION_MODEL: tuple[str, str] = _get('LANGUAGE_DETECTION_MODEL', ('openai', 'gpt-4o-mini'))
179
  CONFIDENCE_SCORING_MODEL: tuple[str, str] = _get('CONFIDENCE_SCORING_MODEL', ('openai', 'gpt-4o-mini'))
180
  SUMMARIZATION_MODEL: tuple[str, str] = _get('SUMMARIZATION_MODEL', ('openai', 'gpt-4.1'))
 
77
 
78
  class ProcessingConfig(ConfigBase):
79
  LANG_AMBIGUITY_THRESHOLD: float = _get('LANG_AMBIGUITY_THRESHOLD')
80
+ EMBEDDING_MODEL: str = _get('EMBEDDING_MODEL', 'openai/text-embedding-3-small')
81
+ EMBEDDING_BASE_URL: str = _get('EMBEDDING_BASE_URL', 'https://openrouter.ai/api/v1')
82
+ EMBEDDING_API_KEY: str = _get('EMBEDDING_API_KEY') or _get('OPEN_ROUTER_API_KEY')
83
+ EMBEDDING_DIMENSIONS: int = _get('EMBEDDING_DIMENSIONS', 1536, type_=int)
84
+ EMBEDDING_BATCH_SIZE: int = _get('EMBEDDING_BATCH_SIZE', 32, type_=int)
85
+ EMBEDDING_VECTOR_NAME: str = _get('EMBEDDING_VECTOR_NAME', 'hsg_rag_embeddings')
86
  MAX_TOKENS: int = _get('MAX_TOKENS')
87
  CHUNK_OVERLAP: int = _get('CHUNK_OVERLAP')
88
 
 
123
 
124
 
125
  class WeaviateConfig(ConfigBase):
 
126
  WEAVIATE_COLLECTION_BASENAME: str = _get('WEAVIATE_COLLECTION_BASENAME')
127
 
128
  BACKUP_METHODS: list[str] = ['manual', 'filesystem', 's3']
 
134
 
135
  CLUSTER_URL: str = _get('WEAVIATE_CLUSTER_URL')
136
  WEAVIATE_API_KEY: str = _get('WEAVIATE_API_KEY')
 
137
 
138
  INIT_TIMEOUT: int = _get('WEAVIATE_INIT_TIMEOUT', 90)
139
  QUERY_TIMEOUT: int = _get('WEAVIATE_QUERY_TIMEOUT', 60)
140
  INSERT_TIMEOUT: int = _get('WEAVIATE_INSERT_TIMEOUT', 600)
141
  KEEP_WARM_ENABLED: bool = _get_bool('WEAVIATE_KEEP_WARM_ENABLED', True)
142
+ KEEP_WARM_INTERVAL: int = _get('WEAVIATE_KEEP_WARM_INTERVAL', 180, type_=int)
143
  CLIENT_IDLE_TIMEOUT: int = _get('WEAVIATE_CLIENT_IDLE_TIMEOUT', 25 * 60, type_=int)
144
 
145
 
 
177
 
178
  MAIN_AGENT_MODEL: tuple[str, str] = _get('MAIN_AGENT_MODEL', ('openai', 'gpt-4.1'))
179
  FALLBACK_MODELS: list[tuple[str, str]] = _get('FALLBACK_MODELS', [('openai', 'gpt-5-mini')])
 
180
  LANGUAGE_DETECTION_MODEL: tuple[str, str] = _get('LANGUAGE_DETECTION_MODEL', ('openai', 'gpt-4o-mini'))
181
  CONFIDENCE_SCORING_MODEL: tuple[str, str] = _get('CONFIDENCE_SCORING_MODEL', ('openai', 'gpt-4o-mini'))
182
  SUMMARIZATION_MODEL: tuple[str, str] = _get('SUMMARIZATION_MODEL', ('openai', 'gpt-4.1'))
src/database/docker-compose.yml DELETED
@@ -1,29 +0,0 @@
1
- version: '3.4'
2
-
3
- services:
4
- weaviate:
5
- image: semitechnologies/weaviate:1.33.0
6
- restart: on-failure:0
7
- ports:
8
- - "8080:8080"
9
- - "50051:50051"
10
- environment:
11
- QUERY_DEFAULTS_LIMIT: 25
12
- AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
13
- PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
14
- ENABLE_API_BASED_MODULES: 'true'
15
- ENABLE_MODULES: 'text2vec-transformers'
16
- TRANSFORMERS_INFERENCE_API: 'http://t2v-transformers:8080'
17
- CLUSTER_HOSTNAME: 'node1'
18
- volumes:
19
- - weaviate_data:/var/lib/weaviate
20
-
21
- t2v-transformers:
22
- image: semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
23
- restart: on-failure:0
24
- ports:
25
- - "8081:8080"
26
-
27
- volumes:
28
- weaviate_data:
29
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/database/embeddings.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from time import sleep
4
+ from typing import Iterable
5
+
6
+ from ..config import config
7
+
8
+ MAX_EMBEDDING_ATTEMPTS = 3
9
+
10
+
11
+ class EmbeddingError(RuntimeError):
12
+ pass
13
+
14
+
15
+ class OpenRouterEmbeddingClient:
16
+ def __init__(self) -> None:
17
+ self._client = None
18
+
19
+ @property
20
+ def client(self):
21
+ if self._client is None:
22
+ if not config.processing.EMBEDDING_API_KEY:
23
+ raise EmbeddingError("OPEN_ROUTER_API_KEY is not configured for embeddings")
24
+
25
+ from openai import OpenAI
26
+
27
+ self._client = OpenAI(
28
+ api_key=config.processing.EMBEDDING_API_KEY,
29
+ base_url=config.processing.EMBEDDING_BASE_URL,
30
+ )
31
+
32
+ return self._client
33
+
34
+ def embed_query(self, text: str) -> list[float]:
35
+ return self.embed_documents([text])[0]
36
+
37
+ def embed_documents(self, texts: Iterable[str]) -> list[list[float]]:
38
+ clean_texts = [(text or " ").strip() or " " for text in texts]
39
+ if not clean_texts:
40
+ return []
41
+
42
+ last_error: Exception | None = None
43
+ for attempt in range(1, MAX_EMBEDDING_ATTEMPTS + 1):
44
+ try:
45
+ response = self.client.embeddings.create(
46
+ model=config.processing.EMBEDDING_MODEL,
47
+ input=clean_texts,
48
+ )
49
+ embeddings = self._extract_embeddings(response)
50
+ self._validate_embeddings(embeddings, expected_count=len(clean_texts))
51
+ return embeddings
52
+ except Exception as exc:
53
+ last_error = exc
54
+ if attempt == MAX_EMBEDDING_ATTEMPTS or not self._is_retryable(exc):
55
+ break
56
+ sleep(min(2 ** (attempt - 1), 8))
57
+
58
+ raise EmbeddingError(f"Failed to generate OpenRouter embeddings: {last_error}") from last_error
59
+
60
+ @staticmethod
61
+ def _extract_embeddings(response) -> list[list[float]]:
62
+ data = list(getattr(response, "data", []) or [])
63
+ data.sort(key=lambda item: getattr(item, "index", 0))
64
+ return [list(getattr(item, "embedding", []) or []) for item in data]
65
+
66
+ def _validate_embeddings(self, embeddings: list[list[float]], expected_count: int) -> None:
67
+ if len(embeddings) != expected_count:
68
+ raise EmbeddingError(
69
+ f"Embedding response count mismatch: expected {expected_count}, got {len(embeddings)}"
70
+ )
71
+
72
+ for idx, embedding in enumerate(embeddings):
73
+ if len(embedding) != config.processing.EMBEDDING_DIMENSIONS:
74
+ raise EmbeddingError(
75
+ f"Embedding {idx} has dimension {len(embedding)}; "
76
+ f"expected {config.processing.EMBEDDING_DIMENSIONS}"
77
+ )
78
+
79
+ @staticmethod
80
+ def _is_retryable(error: Exception) -> bool:
81
+ status_code = getattr(error, "status_code", None)
82
+ if status_code in {408, 409, 425, 429, 500, 502, 503, 504}:
83
+ return True
84
+
85
+ text = str(error).lower()
86
+ return any(
87
+ signal in text
88
+ for signal in [
89
+ "rate limit",
90
+ "timeout",
91
+ "temporarily unavailable",
92
+ "connection",
93
+ "server error",
94
+ ]
95
+ )
src/database/weavservice.py CHANGED
@@ -13,6 +13,7 @@ from weaviate.config import AdditionalConfig
13
 
14
  from ..utils.logging import get_logger
15
  from ..config import config
 
16
 
17
  logger = get_logger("weaviate_service")
18
 
@@ -54,7 +55,7 @@ class WeaviateService:
54
  if hasattr(self, '_initialized'):
55
  return
56
 
57
- self._connection_type = 'local' if config.weaviate.LOCAL_DATABASE else 'cloud'
58
  self._client = None
59
  self._client_lock = RLock()
60
 
@@ -66,6 +67,7 @@ class WeaviateService:
66
  self._keep_warm_interval = max(1, config.weaviate.KEEP_WARM_INTERVAL)
67
  self._keep_warm_stop = Event()
68
  self._keep_warm_thread = None
 
69
  self._initialized = True
70
 
71
  # Initialize the client for the first time
@@ -114,24 +116,18 @@ class WeaviateService:
114
  last_exception: Exception = None
115
  while retries < 3:
116
  try:
117
- if config.weaviate.LOCAL_DATABASE:
118
- self._client = wvt.connect_to_local()
119
- else:
120
- self._client = wvt.connect_to_weaviate_cloud(
121
- cluster_url=config.weaviate.CLUSTER_URL,
122
- auth_credentials=config.weaviate.WEAVIATE_API_KEY,
123
- additional_config=AdditionalConfig(
124
- timeout=Timeout(
125
- init=config.weaviate.INIT_TIMEOUT,
126
- query=config.weaviate.QUERY_TIMEOUT,
127
- insert=config.weaviate.INSERT_TIMEOUT,
128
- ),
129
- skip_init_checks=False,
130
  ),
131
- headers={
132
- "X-HuggingFace-Api-Key": config.weaviate.HUGGING_FACE_API_KEY,
133
- },
134
- )
135
 
136
  self._warm_up_client(self._client)
137
  break
@@ -176,19 +172,39 @@ class WeaviateService:
176
 
177
 
178
  def _keep_warm_loop(self) -> None:
179
- while not self._keep_warm_stop.wait(self._keep_warm_interval):
 
 
 
 
 
 
 
180
  try:
181
- self._keep_warm_once()
 
 
 
 
 
182
  except Exception as e:
 
183
  logger.warning(f"Weaviate keep-warm tick failed (non-critical): {e}")
 
 
 
 
 
184
 
185
 
186
- def _keep_warm_once(self) -> bool:
187
  """
188
  Run one scheduled keep-warm tick when the client has been idle long enough.
189
 
190
  Returns:
191
- bool: True when a warm-up query was attempted, False when skipped.
 
 
192
  """
193
  client = self._client
194
  if client is None:
@@ -196,14 +212,13 @@ class WeaviateService:
196
 
197
  time_since_query = perf_counter() - self._last_query_time
198
  if time_since_query < self._keep_warm_interval:
199
- return False
200
 
201
  logger.debug(
202
  "Running scheduled Weaviate keep-warm after %3.2f seconds idle",
203
  time_since_query,
204
  )
205
- self._warm_up_client(client)
206
- return True
207
 
208
 
209
  def stop_keep_warm(self) -> None:
@@ -215,9 +230,59 @@ class WeaviateService:
215
  self._keep_warm_thread = None
216
 
217
 
218
- def _warm_up_client(self, client: wvt.WeaviateClient) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  """
220
  Quickly query the client to decrease the response latency of future calls.
 
 
 
221
  """
222
  logger.debug("Running warm-up query to initialize server and vectorizer...")
223
  try:
@@ -225,18 +290,16 @@ class WeaviateService:
225
  with self._client_lock:
226
  if not client.collections.exists(collection_name):
227
  logger.warning("Warm-up skipped because collection %s does not exist", collection_name)
228
- return
229
 
230
  collection = client.collections.get(collection_name)
231
- collection.query.hybrid(
232
- query="HSG",
233
- limit=1,
234
- return_metadata=MetadataQuery.full(),
235
- )
236
  self._last_query_time = perf_counter()
237
- logger.debug("Warm-up finished - server and vectorizer are ready!")
 
238
  except Exception as warmup_err:
239
  logger.warning(f"Warm-up query failed (non-critical): {warmup_err}")
 
240
 
241
 
242
  def _select_collection(self, lang: str) -> tuple[Collection, str]:
@@ -282,14 +345,15 @@ class WeaviateService:
282
  import_errors = []
283
  logger.info(f"Batch importing {len(data_rows)} rows into {collection_name}")
284
 
285
- batch_size = 10
286
  max_attempts = 2
287
 
288
  def _import_batch(batch_rows: list[tuple[int, dict]]) -> None:
 
289
  with collection.batch.fixed_size(batch_size=batch_size, concurrent_requests=1) as batch:
290
- for idx, data_row in batch_rows:
291
  try:
292
- batch.add_object(properties=data_row)
293
  except Exception as e:
294
  import_errors.append({'index': idx, 'chunk_id': data_row['chunk_id'], 'error': str(e)})
295
 
@@ -397,9 +461,7 @@ class WeaviateService:
397
 
398
  def ping(self, lang: str) -> dict:
399
  try:
400
- collection, _ = self._select_collection(lang)
401
- with self._client_lock:
402
- collection.query.hybrid("health check query")
403
  return { 'status': 'OK' }
404
  except Exception as e:
405
  return { 'status': 'ERROR', 'error': e }
@@ -443,28 +505,30 @@ class WeaviateService:
443
  logger.info(f"Querying collection {collection_name}")
444
  query_start_time = perf_counter()
445
 
 
 
 
 
 
 
 
 
 
 
446
  with self._client_lock:
447
- try:
448
- resp = collection.query.hybrid(
449
- query=query,
450
- filters=filters,
451
- limit=limit,
452
- return_metadata=MetadataQuery.full()
453
- )
454
- except Exception as hybrid_err:
455
- if not self._should_fallback_to_bm25(hybrid_err):
456
- raise hybrid_err
457
- logger.warning(
458
- "Hybrid query failed during remote vectorization. "
459
- "Falling back to BM25 keyword retrieval: %s",
460
- hybrid_err,
461
- )
462
- resp = collection.query.bm25(
463
- query=query,
464
- filters=filters,
465
- limit=limit,
466
- return_metadata=MetadataQuery.full()
467
  )
 
 
 
 
 
 
468
  elapsed = perf_counter() - query_start_time
469
  self._last_query_time = perf_counter()
470
  logger.info(f"Querying retrieved {len(resp.objects)} objects in {elapsed:3.2f} seconds")
@@ -479,15 +543,6 @@ class WeaviateService:
479
  raise e
480
  else: # Probably not a server issue
481
  raise e
482
-
483
- @staticmethod
484
- def _should_fallback_to_bm25(error: Exception) -> bool:
485
- error_text = str(error).lower()
486
- return (
487
- "remote client vectorize" in error_text
488
- or "vectorize" in error_text and "401" in error_text
489
- or "invalid username or password" in error_text
490
- )
491
 
492
 
493
  def _load_properties(self) -> list[Property]:
@@ -539,6 +594,10 @@ class WeaviateService:
539
  return final_properties
540
 
541
 
 
 
 
 
542
  def _create_collections(self):
543
  """
544
  Create and initialize language-specific collections.
@@ -550,14 +609,7 @@ class WeaviateService:
550
  client = self._init_client()
551
  logger.info('Attempting collections creation...')
552
 
553
- vector_config = (
554
- Configure.Vectors.text2vec_transformers() if config.weaviate.LOCAL_DATABASE
555
- else Configure.Vectors.text2vec_huggingface(
556
- name='hsg_rag_embeddings',
557
- source_properties=['body'],
558
- model=config.processing.EMBEDDING_MODEL,
559
- )
560
- )
561
 
562
  successful_creations = 0
563
 
@@ -849,29 +901,18 @@ class WeaviateService:
849
  with self._client_lock:
850
  metainfo = client.get_meta()
851
 
852
- # Format module information
853
  modules = metainfo.get('modules', {})
854
  modules_list = list(modules.keys()) if isinstance(modules, dict) else modules
855
  modules_str = ', '.join(str(m) for m in modules_list) if modules_list else 'None'
856
 
857
- # Truncate long module strings for logging
858
  if len(modules_str) > 50:
859
  modules_str = modules_str[:47] + '...'
860
 
861
- # Log connection details
862
- if config.weaviate.LOCAL_DATABASE:
863
- logger.info(
864
- f"Database metadata: "
865
- f"HOSTNAME={metainfo.get('hostname', 'unknown')}, "
866
- f"VERSION={metainfo.get('version', 'unknown')}, "
867
- f"MODULES={modules_str}"
868
- )
869
- else:
870
- logger.info(
871
- f"Database metadata: "
872
- f"VERSION={metainfo.get('version', 'unknown')}, "
873
- f"MODULES={modules_str}"
874
- )
875
 
876
  except Exception as e:
877
  logger.warning(f"Could not retrieve database metadata: {e}")
 
13
 
14
  from ..utils.logging import get_logger
15
  from ..config import config
16
+ from .embeddings import EmbeddingError, OpenRouterEmbeddingClient
17
 
18
  logger = get_logger("weaviate_service")
19
 
 
55
  if hasattr(self, '_initialized'):
56
  return
57
 
58
+ self._connection_type = 'cloud'
59
  self._client = None
60
  self._client_lock = RLock()
61
 
 
67
  self._keep_warm_interval = max(1, config.weaviate.KEEP_WARM_INTERVAL)
68
  self._keep_warm_stop = Event()
69
  self._keep_warm_thread = None
70
+ self._embedding_client = OpenRouterEmbeddingClient()
71
  self._initialized = True
72
 
73
  # Initialize the client for the first time
 
116
  last_exception: Exception = None
117
  while retries < 3:
118
  try:
119
+ self._client = wvt.connect_to_weaviate_cloud(
120
+ cluster_url=config.weaviate.CLUSTER_URL,
121
+ auth_credentials=config.weaviate.WEAVIATE_API_KEY,
122
+ additional_config=AdditionalConfig(
123
+ timeout=Timeout(
124
+ init=config.weaviate.INIT_TIMEOUT,
125
+ query=config.weaviate.QUERY_TIMEOUT,
126
+ insert=config.weaviate.INSERT_TIMEOUT,
 
 
 
 
 
127
  ),
128
+ skip_init_checks=False,
129
+ ),
130
+ )
 
131
 
132
  self._warm_up_client(self._client)
133
  break
 
172
 
173
 
174
  def _keep_warm_loop(self) -> None:
175
+ # Exponential backoff on consecutive failures: a broken embedding key
176
+ # would otherwise be hammered every interval, spamming logs and quota.
177
+ consecutive_failures = 0
178
+ max_wait = 30 * 60
179
+ while True:
180
+ wait = min(self._keep_warm_interval * (2 ** consecutive_failures), max_wait)
181
+ if self._keep_warm_stop.wait(wait):
182
+ return
183
  try:
184
+ result = self._keep_warm_once()
185
+ if result is True:
186
+ consecutive_failures = 0
187
+ elif result is False:
188
+ consecutive_failures += 1
189
+ # result None: skipped (recent real query) — leave backoff as is
190
  except Exception as e:
191
+ consecutive_failures += 1
192
  logger.warning(f"Weaviate keep-warm tick failed (non-critical): {e}")
193
+ if consecutive_failures == 3:
194
+ logger.warning(
195
+ "Keep-warm failed 3 times in a row - backing off. If the error is a "
196
+ "vector query or embedding 401, check OPEN_ROUTER_API_KEY."
197
+ )
198
 
199
 
200
+ def _keep_warm_once(self) -> bool | None:
201
  """
202
  Run one scheduled keep-warm tick when the client has been idle long enough.
203
 
204
  Returns:
205
+ True when the warm-up query succeeded, False when it was attempted
206
+ but failed (drives the backoff), None when skipped because a real
207
+ query happened recently.
208
  """
209
  client = self._client
210
  if client is None:
 
212
 
213
  time_since_query = perf_counter() - self._last_query_time
214
  if time_since_query < self._keep_warm_interval:
215
+ return None
216
 
217
  logger.debug(
218
  "Running scheduled Weaviate keep-warm after %3.2f seconds idle",
219
  time_since_query,
220
  )
221
+ return self._warm_up_client(client)
 
222
 
223
 
224
  def stop_keep_warm(self) -> None:
 
230
  self._keep_warm_thread = None
231
 
232
 
233
+ @staticmethod
234
+ def _embedding_vector_name() -> str:
235
+ return config.processing.EMBEDDING_VECTOR_NAME
236
+
237
+
238
+ def _embed_query(self, query: str) -> list[float]:
239
+ return self._embedding_client.embed_query(query)
240
+
241
+
242
+ def _embed_batch_vectors(self, batch_rows: list[tuple[int, dict]]) -> list[dict]:
243
+ embeddings = self._embedding_client.embed_documents(
244
+ data_row.get("body", "") for _, data_row in batch_rows
245
+ )
246
+ vector_name = self._embedding_vector_name()
247
+ return [{vector_name: embedding} for embedding in embeddings]
248
+
249
+
250
+ def _hybrid_query_kwargs(
251
+ self,
252
+ query: str,
253
+ filters=None,
254
+ limit: int = 5,
255
+ query_vector: list[float] | None = None,
256
+ ) -> dict:
257
+ kwargs = {
258
+ "query": query,
259
+ "filters": filters,
260
+ "limit": limit,
261
+ "return_metadata": MetadataQuery.full(),
262
+ }
263
+
264
+ kwargs["vector"] = query_vector if query_vector is not None else self._embed_query(query)
265
+ kwargs["target_vector"] = self._embedding_vector_name()
266
+
267
+ return kwargs
268
+
269
+
270
+ @staticmethod
271
+ def _bm25_query_kwargs(query: str, filters=None, limit: int = 5) -> dict:
272
+ return {
273
+ "query": query,
274
+ "filters": filters,
275
+ "limit": limit,
276
+ "return_metadata": MetadataQuery.full(),
277
+ }
278
+
279
+
280
+ def _warm_up_client(self, client: wvt.WeaviateClient) -> bool:
281
  """
282
  Quickly query the client to decrease the response latency of future calls.
283
+
284
+ Returns:
285
+ bool: True when the warm-up query succeeded.
286
  """
287
  logger.debug("Running warm-up query to initialize server and vectorizer...")
288
  try:
 
290
  with self._client_lock:
291
  if not client.collections.exists(collection_name):
292
  logger.warning("Warm-up skipped because collection %s does not exist", collection_name)
293
+ return False
294
 
295
  collection = client.collections.get(collection_name)
296
+ collection.query.hybrid(**self._hybrid_query_kwargs(query="HSG", limit=1))
 
 
 
 
297
  self._last_query_time = perf_counter()
298
+ logger.debug("Warm-up finished - server and embeddings are ready!")
299
+ return True
300
  except Exception as warmup_err:
301
  logger.warning(f"Warm-up query failed (non-critical): {warmup_err}")
302
+ return False
303
 
304
 
305
  def _select_collection(self, lang: str) -> tuple[Collection, str]:
 
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
 
 
461
 
462
  def ping(self, lang: str) -> dict:
463
  try:
464
+ self.query("health check query", lang=lang, limit=1)
 
 
465
  return { 'status': 'OK' }
466
  except Exception as e:
467
  return { 'status': 'ERROR', 'error': e }
 
505
  logger.info(f"Querying collection {collection_name}")
506
  query_start_time = perf_counter()
507
 
508
+ try:
509
+ query_vector = self._embed_query(query)
510
+ except EmbeddingError as embed_err:
511
+ query_vector = None
512
+ logger.warning(
513
+ "OpenRouter embedding query failed. "
514
+ "Falling back to BM25 keyword retrieval: %s",
515
+ embed_err,
516
+ )
517
+
518
  with self._client_lock:
519
+ if query_vector is None:
520
+ resp = collection.query.bm25(**self._bm25_query_kwargs(query, filters, limit))
521
+ else:
522
+ try:
523
+ resp = collection.query.hybrid(
524
+ **self._hybrid_query_kwargs(query, filters, limit, query_vector=query_vector)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  )
526
+ except Exception as hybrid_err:
527
+ logger.warning(
528
+ "Hybrid vector query failed. Falling back to BM25 keyword retrieval: %s",
529
+ hybrid_err,
530
+ )
531
+ resp = collection.query.bm25(**self._bm25_query_kwargs(query, filters, limit))
532
  elapsed = perf_counter() - query_start_time
533
  self._last_query_time = perf_counter()
534
  logger.info(f"Querying retrieved {len(resp.objects)} objects in {elapsed:3.2f} seconds")
 
543
  raise e
544
  else: # Probably not a server issue
545
  raise e
 
 
 
 
 
 
 
 
 
546
 
547
 
548
  def _load_properties(self) -> list[Property]:
 
594
  return final_properties
595
 
596
 
597
+ def _vector_config(self):
598
+ return Configure.Vectors.self_provided(name=self._embedding_vector_name())
599
+
600
+
601
  def _create_collections(self):
602
  """
603
  Create and initialize language-specific collections.
 
609
  client = self._init_client()
610
  logger.info('Attempting collections creation...')
611
 
612
+ vector_config = self._vector_config()
 
 
 
 
 
 
 
613
 
614
  successful_creations = 0
615
 
 
901
  with self._client_lock:
902
  metainfo = client.get_meta()
903
 
 
904
  modules = metainfo.get('modules', {})
905
  modules_list = list(modules.keys()) if isinstance(modules, dict) else modules
906
  modules_str = ', '.join(str(m) for m in modules_list) if modules_list else 'None'
907
 
 
908
  if len(modules_str) > 50:
909
  modules_str = modules_str[:47] + '...'
910
 
911
+ logger.info(
912
+ f"Database metadata: "
913
+ f"VERSION={metainfo.get('version', 'unknown')}, "
914
+ f"MODULES={modules_str}"
915
+ )
 
 
 
 
 
 
 
 
 
916
 
917
  except Exception as e:
918
  logger.warning(f"Could not retrieve database metadata: {e}")
src/notification/notification_center.py CHANGED
@@ -22,9 +22,9 @@ class EmailNotifier:
22
  self.smtp_use_tls = NC.SMTP_USE_TLS
23
  self.from_email = NC.FROM_EMAIL
24
  self.to_emails = self._parse_recipients(NC.TO_EMAIL)
25
-
26
- if self.enabled:
27
- self._validate()
28
 
29
  @staticmethod
30
  def _parse_recipients(value: str | None) -> list[str]:
@@ -58,6 +58,8 @@ class EmailNotifier:
58
  if not self.enabled:
59
  return
60
 
 
 
61
  if isinstance(attachments, str):
62
  attachments = [attachments]
63
 
@@ -95,9 +97,7 @@ class SlackNotifier:
95
  def __init__(self):
96
  self.enabled = NC.ENABLE_SLACK_ALERTS
97
  self.webhook_url = NC.SLACK_WEBHOOK_URL
98
-
99
- if self.enabled:
100
- self._validate()
101
 
102
  def _validate(self) -> None:
103
  if not self.webhook_url:
@@ -107,6 +107,8 @@ class SlackNotifier:
107
  if not self.enabled:
108
  return
109
 
 
 
110
  text = f"*{subject}*\n{body}"
111
 
112
  response = requests.post(
 
22
  self.smtp_use_tls = NC.SMTP_USE_TLS
23
  self.from_email = NC.FROM_EMAIL
24
  self.to_emails = self._parse_recipients(NC.TO_EMAIL)
25
+ # Validation happens at send time, not at construction: constructing a
26
+ # NotificationCenter (e.g. inside Scraper.__init__) must not require a
27
+ # complete SMTP configuration on machines that never send alerts.
28
 
29
  @staticmethod
30
  def _parse_recipients(value: str | None) -> list[str]:
 
58
  if not self.enabled:
59
  return
60
 
61
+ self._validate()
62
+
63
  if isinstance(attachments, str):
64
  attachments = [attachments]
65
 
 
97
  def __init__(self):
98
  self.enabled = NC.ENABLE_SLACK_ALERTS
99
  self.webhook_url = NC.SLACK_WEBHOOK_URL
100
+ # Validated at send time (see EmailNotifier note above).
 
 
101
 
102
  def _validate(self) -> None:
103
  if not self.webhook_url:
 
107
  if not self.enabled:
108
  return
109
 
110
+ self._validate()
111
+
112
  text = f"*{subject}*\n{body}"
113
 
114
  response = requests.post(
src/pipeline/processors.py CHANGED
@@ -2,9 +2,11 @@ from collections import defaultdict
2
  import os, re
3
 
4
  from pathlib import Path
5
- from transformers import AutoTokenizer
6
 
7
- from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
 
 
8
  from docling.datamodel.pipeline_options import PdfPipelineOptions, LayoutOptions
9
  from docling_core.transforms.serializer.markdown import MarkdownDocSerializer
10
  from docling.document_converter import DocumentConverter, PdfFormatOption, InputFormat
@@ -20,6 +22,41 @@ from ..config import config
20
  weblogger = get_logger("website_processor")
21
  datalogger = get_logger("data_processor")
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  class ProcessorBase:
24
  def __init__(self) -> None:
25
  """
@@ -50,20 +87,22 @@ class ProcessorBase:
50
  InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
51
  },
52
  )
53
- tokenizer = AutoTokenizer.from_pretrained(config.processing.EMBEDDING_MODEL)
54
- self._chunker = HybridChunker(
55
- tokenizer=HuggingFaceTokenizer(
56
- tokenizer=tokenizer,
57
- max_tokens=config.processing.MAX_TOKENS
58
- ),
59
- serializer_provider=EnhansedSerializerProvider(),
60
- max_tokens=config.processing.MAX_TOKENS,
61
- merge_peers=True
62
- )
63
  self.strategies_processor = StrategiesProcessor()
64
  self._logging_callback = config.dbapp['logging_callback'] or logging_callback_placeholder
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
67
  def process(self):
68
  """
69
  Abstract method to be implemented by subclasses for processing sources.
@@ -240,7 +279,7 @@ class ProcessorBase:
240
 
241
  tokens = tokenizer.encode(document_content)
242
  chunk_size = self._chunker.max_tokens
243
- overlap = 50
244
 
245
  collected_chunks = []
246
  for i in range(0, len(tokens), chunk_size-overlap):
 
2
  import os, re
3
 
4
  from pathlib import Path
5
+ from typing import Any
6
 
7
+ import tiktoken
8
+ from pydantic import ConfigDict
9
+ from docling_core.transforms.chunker.tokenizer.base import BaseTokenizer
10
  from docling.datamodel.pipeline_options import PdfPipelineOptions, LayoutOptions
11
  from docling_core.transforms.serializer.markdown import MarkdownDocSerializer
12
  from docling.document_converter import DocumentConverter, PdfFormatOption, InputFormat
 
22
  weblogger = get_logger("website_processor")
23
  datalogger = get_logger("data_processor")
24
 
25
+
26
+ class TiktokenTokenizer(BaseTokenizer):
27
+ model_config = ConfigDict(arbitrary_types_allowed=True)
28
+
29
+ encoding: Any
30
+ max_tokens: int
31
+
32
+ def count_tokens(self, text: str) -> int:
33
+ return len(self.encode(text))
34
+
35
+ def get_max_tokens(self) -> int:
36
+ return self.max_tokens
37
+
38
+ def get_tokenizer(self) -> Any:
39
+ return self.encoding
40
+
41
+ def encode(self, text: str) -> list[int]:
42
+ return self.encoding.encode(text, disallowed_special=())
43
+
44
+ def decode(self, tokens: list[int], *_, **__) -> str:
45
+ return self.encoding.decode(tokens)
46
+
47
+
48
+ def _embedding_tokenizer_model() -> str:
49
+ return config.processing.EMBEDDING_MODEL.split('/', 1)[-1]
50
+
51
+
52
+ def _embedding_tokenizer() -> TiktokenTokenizer:
53
+ try:
54
+ encoding = tiktoken.encoding_for_model(_embedding_tokenizer_model())
55
+ except KeyError:
56
+ encoding = tiktoken.get_encoding('cl100k_base')
57
+ return TiktokenTokenizer(encoding=encoding, max_tokens=config.processing.MAX_TOKENS)
58
+
59
+
60
  class ProcessorBase:
61
  def __init__(self) -> None:
62
  """
 
87
  InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
88
  },
89
  )
90
+ self._chunker_instance: HybridChunker | None = None
 
 
 
 
 
 
 
 
 
91
  self.strategies_processor = StrategiesProcessor()
92
  self._logging_callback = config.dbapp['logging_callback'] or logging_callback_placeholder
93
 
94
 
95
+ @property
96
+ def _chunker(self) -> HybridChunker:
97
+ if self._chunker_instance is None:
98
+ self._chunker_instance = HybridChunker(
99
+ tokenizer=_embedding_tokenizer(),
100
+ serializer_provider=EnhansedSerializerProvider(),
101
+ max_tokens=config.processing.MAX_TOKENS,
102
+ merge_peers=True
103
+ )
104
+ return self._chunker_instance
105
+
106
  def process(self):
107
  """
108
  Abstract method to be implemented by subclasses for processing sources.
 
279
 
280
  tokens = tokenizer.encode(document_content)
281
  chunk_size = self._chunker.max_tokens
282
+ overlap = min(config.processing.CHUNK_OVERLAP, max(0, chunk_size - 1))
283
 
284
  collected_chunks = []
285
  for i in range(0, len(tokens), chunk_size-overlap):
src/rag/models.py CHANGED
@@ -7,7 +7,6 @@ logger = get_logger("model_config")
7
 
8
  class ModelConfigurator:
9
  _main_model_instance: BaseChatModel = None
10
- _subagent_model_instance: BaseChatModel = None
11
  _fallback_models_instances: list[BaseChatModel] = None
12
  _summarization_model_instance: BaseChatModel = None
13
  _confidence_scoring_model_instance: BaseChatModel = None
@@ -66,23 +65,6 @@ class ModelConfigurator:
66
  raise e
67
 
68
 
69
- @classmethod
70
- def get_subagent_model(cls) -> BaseChatModel:
71
- if cls._subagent_model_instance:
72
- return cls._subagent_model_instance
73
- provider, model = config.llm.SUBAGENT_MODEL
74
- try:
75
- cls._subagent_model_instance = cls._initialize_model(
76
- provider=provider,
77
- model=model,
78
- role="main",
79
- )
80
- logger.info(f"Initialized subagent model '{provider}:{model}'")
81
- return cls._subagent_model_instance
82
- except Exception as e:
83
- logger.error(f"Failed to initialize subagent model '{provider}:{model}': {e}")
84
- raise e
85
-
86
  @classmethod
87
  def get_main_agent_model(cls) -> BaseChatModel:
88
  """Initialize the language model based on config."""
 
7
 
8
  class ModelConfigurator:
9
  _main_model_instance: BaseChatModel = None
 
10
  _fallback_models_instances: list[BaseChatModel] = None
11
  _summarization_model_instance: BaseChatModel = None
12
  _confidence_scoring_model_instance: BaseChatModel = None
 
65
  raise e
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  @classmethod
69
  def get_main_agent_model(cls) -> BaseChatModel:
70
  """Initialize the language model based on config."""
src/rag/prompts.py CHANGED
@@ -226,13 +226,16 @@ GENERAL:
226
 
227
  agent_key = agent.lower().replace(" ", "")
228
 
229
- # Verified programme facts (auto-generated from official sources).
230
- # Hallucination fix: gives the model an authoritative in-prompt source
231
- # for volatile core facts instead of regex-extraction from chunks.
232
- facts_block = VerifiedFacts.render_prompt_block(language=language)
233
-
234
  # 2. Configure Lead Agent
235
  if agent_key == 'lead':
 
 
 
 
 
 
 
 
236
  return cls._LEAD_SYSTEM_PROMPT.format(
237
  university_name=university_name,
238
  tool_routing=cls._RETRIEVE_CONTEXT_TOOL_ROUTING,
@@ -248,7 +251,7 @@ GENERAL:
248
  selected_language=selected_language,
249
  university_name=university_name,
250
  program_name=agent.upper()
251
- ) + facts_block
252
  else:
253
  # Fallback
254
  return cls._BASE_PROGRAM_PROMPT.format(
 
226
 
227
  agent_key = agent.lower().replace(" ", "")
228
 
 
 
 
 
 
229
  # 2. Configure Lead Agent
230
  if agent_key == 'lead':
231
+ # Verified programme facts (auto-generated from official sources).
232
+ # Hallucination fix: gives the model an authoritative in-prompt
233
+ # source for volatile core facts instead of regex-extraction from
234
+ # chunks. ONLY the lead prompt gets this block — the (currently
235
+ # unused) programme-agent prompts keep their retrieval-first
236
+ # invariant: no volatile facts embedded in the prompt text
237
+ # (guarded by tests/test_pricing_prompts.py).
238
+ facts_block = VerifiedFacts.render_prompt_block(language=language)
239
  return cls._LEAD_SYSTEM_PROMPT.format(
240
  university_name=university_name,
241
  tool_routing=cls._RETRIEVE_CONTEXT_TOOL_ROUTING,
 
251
  selected_language=selected_language,
252
  university_name=university_name,
253
  program_name=agent.upper()
254
+ )
255
  else:
256
  # Fallback
257
  return cls._BASE_PROGRAM_PROMPT.format(
src/scraping/scraper.py CHANGED
@@ -351,13 +351,13 @@ class Scraper:
351
 
352
  raw_chunks = []
353
  deleted_chunks = []
354
- new_urls = {entry.document.name for entry in tagged_documents}
355
- self._active_temp_chunks = {
356
- url: chunks
357
- for url, chunks in (temp_chunks or {}).items()
358
- if url not in new_urls
359
- }
360
  merged_chunks, final_chunks = self._read_temp_chunks(temp_chunks, tagged_documents)
 
 
 
 
 
 
361
 
362
  program_counter = self._build_program_counter_from_merged_chunks(merged_chunks)
363
 
@@ -443,14 +443,18 @@ class Scraper:
443
  del loaded_temp_chunks[url]
444
 
445
  restored_temp_chunks = []
 
 
 
446
  for url, chunks in loaded_temp_chunks.items():
447
  url_filename = self._normalizer.url_to_filename(url)
448
  extracted_text_path = os.path.join(self._path.EXTRACTED_TEXT_OUTPUT, url_filename + '.txt')
449
  if not os.path.exists(extracted_text_path):
450
  incupd_logger.warning(f"Cannot restore chunks for URL {url}: Failed to locate previously extracted contents!")
451
  incupd_logger.warning(f"This URL will has to be rescraped in the next session")
 
452
  restored_temp_chunks.extend(chunks)
453
- continue
454
 
455
  with open(extracted_text_path, 'r') as f:
456
  url_text = f.read()
 
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
 
 
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]] = {}
449
  for url, chunks in loaded_temp_chunks.items():
450
  url_filename = self._normalizer.url_to_filename(url)
451
  extracted_text_path = os.path.join(self._path.EXTRACTED_TEXT_OUTPUT, url_filename + '.txt')
452
  if not os.path.exists(extracted_text_path):
453
  incupd_logger.warning(f"Cannot restore chunks for URL {url}: Failed to locate previously extracted contents!")
454
  incupd_logger.warning(f"This URL will has to be rescraped in the next session")
455
+ self._unrestorable_temp_chunks[url] = chunks
456
  restored_temp_chunks.extend(chunks)
457
+ continue
458
 
459
  with open(extracted_text_path, 'r') as f:
460
  url_text = f.read()
src/scraping/url_normalizer.py CHANGED
@@ -6,13 +6,21 @@ class UrlNormalizer:
6
  @staticmethod
7
  def is_url_blacklisted(url: str) -> bool:
8
  url_lower = url.lower()
9
- path = url_lower.split('://', 1)[-1].split('/', 1)[-1]
10
-
11
  for forbidden in PAGE_BLACKLIST:
12
  if forbidden in path:
13
  return True
14
-
15
- return len(path) > 35
 
 
 
 
 
 
 
 
16
 
17
 
18
  @staticmethod
 
6
  @staticmethod
7
  def is_url_blacklisted(url: str) -> bool:
8
  url_lower = url.lower()
9
+ path = url_lower.split('://', 1)[-1].split('/', 1)[-1]
10
+
11
  for forbidden in PAGE_BLACKLIST:
12
  if forbidden in path:
13
  return True
14
+
15
+ # Guard against junk/tracking URLs without dropping legitimate content
16
+ # pages. A plain length cap (previously: len(path) > 35) silently
17
+ # skipped real pages such as /admissions/ready-to-relearn-the-future/
18
+ # (39 chars). Use structural signals instead:
19
+ if '?' in path or '&' in path or '=' in path:
20
+ return True # query strings: filters, tracking, form states
21
+ if path.count('/') > 4:
22
+ return True # deeper than any real content page on the targets
23
+ return len(path) > 100 # extreme guard for runaway slugs
24
 
25
 
26
  @staticmethod
tests/conftest.py CHANGED
@@ -7,10 +7,10 @@ from pathlib import Path
7
  TEST_DEPENDENCIES = {
8
  "tests/consent/test_agent_chain_session.py": {"langchain_core", "langchain", "langsmith"},
9
  "tests/consent/test_consent_logger.py": {"colorama"},
10
- "tests/scraping/test_happy_path.py": {"colorama", "docling", "docling_core", "usp", "fake_useragent"},
11
- "tests/scraping/test_page_chunking.py": {"colorama", "docling", "docling_core"},
12
- "tests/scraping/test_scraping.py": {"colorama", "docling_core", "usp", "fake_useragent"},
13
- "tests/scraping/test_scraping_resume.py": {"colorama", "docling_core", "usp", "fake_useragent"},
14
  "tests/scraping/test_utils.py": {"fake_useragent"},
15
  "tests/test_cache.py": {"langchain"},
16
  "tests/test_chatbot_improvements.py": {"langchain_core", "langchain", "langsmith"},
 
7
  TEST_DEPENDENCIES = {
8
  "tests/consent/test_agent_chain_session.py": {"langchain_core", "langchain", "langsmith"},
9
  "tests/consent/test_consent_logger.py": {"colorama"},
10
+ "tests/scraping/test_happy_path.py": {"colorama", "docling", "docling_core", "usp", "fake_useragent", "tiktoken"},
11
+ "tests/scraping/test_page_chunking.py": {"colorama", "docling", "docling_core", "tiktoken"},
12
+ "tests/scraping/test_scraping.py": {"colorama", "docling_core", "usp", "fake_useragent", "tiktoken"},
13
+ "tests/scraping/test_scraping_resume.py": {"colorama", "docling_core", "usp", "fake_useragent", "tiktoken"},
14
  "tests/scraping/test_utils.py": {"fake_useragent"},
15
  "tests/test_cache.py": {"langchain"},
16
  "tests/test_chatbot_improvements.py": {"langchain_core", "langchain", "langsmith"},
tests/scraping/test_page_chunking.py CHANGED
@@ -9,20 +9,31 @@ class TestPageChunking:
9
 
10
  def test_chunking_pipeline(self):
11
  init_logging()
12
-
13
- processor = HTMLProcessor()
14
- cleaner = ContentCleaner(full_scraping=True)
15
- raw_texts = []
16
- documents = []
17
-
18
  html_path = config.paths.RAW_HTML_OUTPUT
19
- for raw_html_file_path in [
20
  os.path.join(html_path, 'embax-ch.html'),
21
  os.path.join(html_path, 'embax-ch_admissions_student-profile.html'),
22
  # Tests for tables and lists
23
- os.path.join(html_path, 'embax-ch_admissions_deadlines-fees.html'),
24
  os.path.join(html_path, 'embax-ch_events.html')
25
- ]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  raw_html = open(raw_html_file_path, 'r', encoding='utf-8').read()
27
  cleaned_html = cleaner.clean_mobile_content(raw_html)
28
  document = processor.process(url='https://embax.ch', html_content=cleaned_html)
 
9
 
10
  def test_chunking_pipeline(self):
11
  init_logging()
12
+
 
 
 
 
 
13
  html_path = config.paths.RAW_HTML_OUTPUT
14
+ required_fixtures = [
15
  os.path.join(html_path, 'embax-ch.html'),
16
  os.path.join(html_path, 'embax-ch_admissions_student-profile.html'),
17
  # Tests for tables and lists
18
+ os.path.join(html_path, 'embax-ch_admissions_deadlines-fees.html'),
19
  os.path.join(html_path, 'embax-ch_events.html')
20
+ ]
21
+ missing = [path for path in required_fixtures if not os.path.exists(path)]
22
+ if missing:
23
+ # Integration test over local scrape artifacts (data/* is not in
24
+ # git). Skip instead of failing on machines without a prior scrape.
25
+ pytest.skip(
26
+ "Requires scraped raw HTML in data/raw_html "
27
+ f"(missing: {', '.join(os.path.basename(p) for p in missing)}). "
28
+ "Run a scrape of embax.ch first."
29
+ )
30
+
31
+ processor = HTMLProcessor()
32
+ cleaner = ContentCleaner(full_scraping=True)
33
+ raw_texts = []
34
+ documents = []
35
+
36
+ for raw_html_file_path in required_fixtures:
37
  raw_html = open(raw_html_file_path, 'r', encoding='utf-8').read()
38
  cleaned_html = cleaner.clean_mobile_content(raw_html)
39
  document = processor.process(url='https://embax.ch', html_content=cleaned_html)
tests/test_master_transfer_integrations.py CHANGED
@@ -1,6 +1,10 @@
1
  from threading import RLock
2
  from types import SimpleNamespace
3
 
 
 
 
 
4
  from src.database.weavservice import WeaviateService
5
  from src.rag.agent_chain import ExecutiveAgentChain
6
  from src.rag.models import ModelConfigurator
@@ -38,11 +42,16 @@ def test_retrieve_context_filters_embax_with_canonical_programme_id():
38
  class FakeQuery:
39
  def __init__(self):
40
  self.hybrid_calls = []
 
41
 
42
  def hybrid(self, **kwargs):
43
  self.hybrid_calls.append(kwargs)
44
  return SimpleNamespace(objects=[])
45
 
 
 
 
 
46
 
47
  class FakeCollections:
48
  def __init__(self, collection):
@@ -57,6 +66,26 @@ class FakeCollections:
57
  return self.collection
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def test_weaviate_keep_warm_once_runs_hybrid_warmup():
61
  collection = SimpleNamespace(query=FakeQuery())
62
  client = SimpleNamespace(collections=FakeCollections(collection))
@@ -65,10 +94,143 @@ def test_weaviate_keep_warm_once_runs_hybrid_warmup():
65
  service._client_lock = RLock()
66
  service._last_query_time = 0
67
  service._keep_warm_interval = 1
 
68
 
69
  assert service._keep_warm_once() is True
70
  assert collection.query.hybrid_calls[0]["query"] == "HSG"
71
  assert collection.query.hybrid_calls[0]["limit"] == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  def test_model_config_keeps_master_defaults_and_budgets(monkeypatch):
 
1
  from threading import RLock
2
  from types import SimpleNamespace
3
 
4
+ import pytest
5
+
6
+ from src.database.embeddings import EmbeddingError
7
+ from src.database import weavservice
8
  from src.database.weavservice import WeaviateService
9
  from src.rag.agent_chain import ExecutiveAgentChain
10
  from src.rag.models import ModelConfigurator
 
42
  class FakeQuery:
43
  def __init__(self):
44
  self.hybrid_calls = []
45
+ self.bm25_calls = []
46
 
47
  def hybrid(self, **kwargs):
48
  self.hybrid_calls.append(kwargs)
49
  return SimpleNamespace(objects=[])
50
 
51
+ def bm25(self, **kwargs):
52
+ self.bm25_calls.append(kwargs)
53
+ return SimpleNamespace(objects=[])
54
+
55
 
56
  class FakeCollections:
57
  def __init__(self, collection):
 
66
  return self.collection
67
 
68
 
69
+ class FakeEmbeddingClient:
70
+ def __init__(self, vector=None, fail=False):
71
+ self.vector = vector or [0.1, 0.2, 0.3]
72
+ self.fail = fail
73
+ self.document_inputs = []
74
+ self.query_inputs = []
75
+
76
+ def embed_documents(self, texts):
77
+ self.document_inputs.append(list(texts))
78
+ if self.fail:
79
+ raise EmbeddingError("embedding service unavailable")
80
+ return [self.vector for _ in self.document_inputs[-1]]
81
+
82
+ def embed_query(self, text):
83
+ self.query_inputs.append(text)
84
+ if self.fail:
85
+ raise EmbeddingError("embedding service unavailable")
86
+ return self.vector
87
+
88
+
89
  def test_weaviate_keep_warm_once_runs_hybrid_warmup():
90
  collection = SimpleNamespace(query=FakeQuery())
91
  client = SimpleNamespace(collections=FakeCollections(collection))
 
94
  service._client_lock = RLock()
95
  service._last_query_time = 0
96
  service._keep_warm_interval = 1
97
+ service._embedding_client = FakeEmbeddingClient(vector=[0.4, 0.5, 0.6])
98
 
99
  assert service._keep_warm_once() is True
100
  assert collection.query.hybrid_calls[0]["query"] == "HSG"
101
  assert collection.query.hybrid_calls[0]["limit"] == 1
102
+ assert collection.query.hybrid_calls[0]["vector"] == [0.4, 0.5, 0.6]
103
+ assert collection.query.hybrid_calls[0]["target_vector"] == config.processing.EMBEDDING_VECTOR_NAME
104
+
105
+
106
+ def test_embedding_config_defaults_to_openrouter_small_model():
107
+ assert config.processing.EMBEDDING_MODEL == "openai/text-embedding-3-small"
108
+ assert config.processing.EMBEDDING_BASE_URL == "https://openrouter.ai/api/v1"
109
+ assert config.processing.EMBEDDING_DIMENSIONS == 1536
110
+ assert config.processing.EMBEDDING_BATCH_SIZE == 32
111
+ assert config.processing.MAX_TOKENS == 512
112
+
113
+
114
+ def test_processor_uses_embedding_model_tokenizer(monkeypatch):
115
+ processors = pytest.importorskip("src.pipeline.processors")
116
+ calls = []
117
+
118
+ class FakeEncoding:
119
+ def encode(self, text, **kwargs):
120
+ return [1, 2]
121
+
122
+ def decode(self, tokens):
123
+ return "decoded"
124
+
125
+ class FakeHybridChunker:
126
+ def __init__(self, **kwargs):
127
+ self.kwargs = kwargs
128
+
129
+ monkeypatch.setattr(
130
+ processors.tiktoken,
131
+ "encoding_for_model",
132
+ lambda model: calls.append(model) or FakeEncoding(),
133
+ )
134
+ monkeypatch.setattr(processors, "HybridChunker", FakeHybridChunker)
135
+ monkeypatch.setattr(processors, "EnhansedSerializerProvider", lambda: object())
136
+
137
+ processor = object.__new__(processors.ProcessorBase)
138
+ processor._chunker_instance = None
139
+
140
+ chunker = processors.ProcessorBase._chunker.fget(processor)
141
+
142
+ assert calls == ["text-embedding-3-small"]
143
+ assert chunker.kwargs["max_tokens"] == config.processing.MAX_TOKENS
144
+ assert chunker.kwargs["tokenizer"].count_tokens("test") == 2
145
+
146
+
147
+ def test_weaviate_vector_config_uses_self_provided_for_openrouter(monkeypatch):
148
+ monkeypatch.setattr(config.processing, "EMBEDDING_VECTOR_NAME", "test_vectors")
149
+ monkeypatch.setattr(
150
+ weavservice.Configure.Vectors,
151
+ "self_provided",
152
+ lambda name: ("self_provided", name),
153
+ )
154
+
155
+ service = object.__new__(WeaviateService)
156
+
157
+ assert service._vector_config() == ("self_provided", "test_vectors")
158
+
159
+
160
+ class FakeBatchContext:
161
+ def __init__(self):
162
+ self.added = []
163
+ self.number_errors = 0
164
+
165
+ def __enter__(self):
166
+ return self
167
+
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:
176
+ def __init__(self, context):
177
+ self.context = context
178
+
179
+ def fixed_size(self, **kwargs):
180
+ self.kwargs = kwargs
181
+ return self.context
182
+
183
+
184
+ def test_batch_import_embeds_rows_and_writes_named_vectors(monkeypatch):
185
+ monkeypatch.setattr(config.processing, "EMBEDDING_VECTOR_NAME", "test_vectors")
186
+ batch_context = FakeBatchContext()
187
+ collection = SimpleNamespace(batch=FakeBatchFactory(batch_context))
188
+ service = object.__new__(WeaviateService)
189
+ service._client_lock = RLock()
190
+ service._last_query_time = 0
191
+ service._embedding_client = FakeEmbeddingClient(vector=[0.7, 0.8, 0.9])
192
+ service._select_collection = lambda lang: (collection, "test_collection")
193
+
194
+ errors = service.batch_import(
195
+ data_rows=[{"chunk_id": "c1", "body": "First chunk"}],
196
+ lang="en",
197
+ )
198
+
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):
205
+ monkeypatch.setattr(config.processing, "EMBEDDING_VECTOR_NAME", "test_vectors")
206
+ collection = SimpleNamespace(query=FakeQuery())
207
+ service = object.__new__(WeaviateService)
208
+ service._client_lock = RLock()
209
+ service._last_query_time = 0
210
+ service._embedding_client = FakeEmbeddingClient(vector=[0.2, 0.3, 0.4])
211
+ service._select_collection = lambda lang: (collection, "test_collection")
212
+
213
+ service.query(query="admissions", lang="en", limit=3)
214
+
215
+ assert service._embedding_client.query_inputs == ["admissions"]
216
+ assert collection.query.hybrid_calls[0]["vector"] == [0.2, 0.3, 0.4]
217
+ assert collection.query.hybrid_calls[0]["target_vector"] == "test_vectors"
218
+ assert collection.query.hybrid_calls[0]["limit"] == 3
219
+
220
+
221
+ def test_query_falls_back_to_bm25_when_embedding_fails(monkeypatch):
222
+ collection = SimpleNamespace(query=FakeQuery())
223
+ service = object.__new__(WeaviateService)
224
+ service._client_lock = RLock()
225
+ service._last_query_time = 0
226
+ service._embedding_client = FakeEmbeddingClient(fail=True)
227
+ service._select_collection = lambda lang: (collection, "test_collection")
228
+
229
+ service.query(query="admissions", lang="en", limit=3)
230
+
231
+ assert collection.query.hybrid_calls == []
232
+ assert collection.query.bm25_calls[0]["query"] == "admissions"
233
+ assert collection.query.bm25_calls[0]["limit"] == 3
234
 
235
 
236
  def test_model_config_keeps_master_defaults_and_budgets(monkeypatch):
tests/test_programme_positioning_real_agent.py CHANGED
@@ -19,16 +19,13 @@ def _has_real_agent_prerequisites() -> tuple[bool, str]:
19
  if not llm_api_key:
20
  return False, "No LLM API key configured for the real agent positioning test."
21
 
22
- if config.weaviate.LOCAL_DATABASE:
23
- return True, ""
24
-
25
  missing = []
26
  if not config.weaviate.CLUSTER_URL:
27
  missing.append("WEAVIATE_CLUSTER_URL")
28
  if not config.weaviate.WEAVIATE_API_KEY:
29
  missing.append("WEAVIATE_API_KEY")
30
- if not config.weaviate.HUGGING_FACE_API_KEY:
31
- missing.append("HUGGING_FACE_API_KEY")
32
 
33
  if missing:
34
  return False, f"Missing Weaviate configuration for real agent positioning test: {', '.join(missing)}"
 
19
  if not llm_api_key:
20
  return False, "No LLM API key configured for the real agent positioning test."
21
 
 
 
 
22
  missing = []
23
  if not config.weaviate.CLUSTER_URL:
24
  missing.append("WEAVIATE_CLUSTER_URL")
25
  if not config.weaviate.WEAVIATE_API_KEY:
26
  missing.append("WEAVIATE_API_KEY")
27
+ if not config.processing.EMBEDDING_API_KEY:
28
+ missing.append("OPEN_ROUTER_API_KEY")
29
 
30
  if missing:
31
  return False, f"Missing Weaviate configuration for real agent positioning test: {', '.join(missing)}"
tests/test_reply_speed_real_agent.py CHANGED
@@ -20,16 +20,13 @@ def _has_real_agent_prerequisites() -> tuple[bool, str]:
20
  if not llm_api_key:
21
  return False, "No LLM API key configured for the real agent test."
22
 
23
- if config.weaviate.LOCAL_DATABASE:
24
- return True, ""
25
-
26
  missing = []
27
  if not config.weaviate.CLUSTER_URL:
28
  missing.append("WEAVIATE_CLUSTER_URL")
29
  if not config.weaviate.WEAVIATE_API_KEY:
30
  missing.append("WEAVIATE_API_KEY")
31
- if not config.weaviate.HUGGING_FACE_API_KEY:
32
- missing.append("HUGGING_FACE_API_KEY")
33
 
34
  if missing:
35
  return False, f"Missing Weaviate configuration for real agent test: {', '.join(missing)}"
 
20
  if not llm_api_key:
21
  return False, "No LLM API key configured for the real agent test."
22
 
 
 
 
23
  missing = []
24
  if not config.weaviate.CLUSTER_URL:
25
  missing.append("WEAVIATE_CLUSTER_URL")
26
  if not config.weaviate.WEAVIATE_API_KEY:
27
  missing.append("WEAVIATE_API_KEY")
28
+ if not config.processing.EMBEDDING_API_KEY:
29
+ missing.append("OPEN_ROUTER_API_KEY")
30
 
31
  if missing:
32
  return False, f"Missing Weaviate configuration for real agent test: {', '.join(missing)}"