Spaces:
Running
Running
Sync from GitHub 7e4c3e8eb6f1afe4620f55cca1a22dfef2c1ee7c
Browse files- AUDIT_LATENCY_HALLUCINATIONS.md +1 -1
- README.md +13 -2
- TODO_TEAM.md +1 -1
- config.py +12 -11
- docs/deploy_readiness_checklist.md +1 -2
- docs/weaviate_database_setup.md +16 -5
- requirements.txt +2 -2
- src/apps/dbapp/config.py +9 -1
- src/config/configs.py +7 -5
- src/database/docker-compose.yml +0 -29
- src/database/embeddings.py +95 -0
- src/database/weavservice.py +133 -92
- src/notification/notification_center.py +8 -6
- src/pipeline/processors.py +52 -13
- src/rag/models.py +0 -18
- src/rag/prompts.py +9 -6
- src/scraping/scraper.py +11 -7
- src/scraping/url_normalizer.py +12 -4
- tests/conftest.py +4 -4
- tests/scraping/test_page_chunking.py +20 -9
- tests/test_master_transfer_integrations.py +162 -0
- tests/test_programme_positioning_real_agent.py +2 -5
- tests/test_reply_speed_real_agent.py +2 -5
AUDIT_LATENCY_HALLUCINATIONS.md
CHANGED
|
@@ -88,7 +88,7 @@ Offline (kein User wartet):
|
|
| 88 |
|
| 89 |
## 4. Offene Punkte (bewusst nicht gemacht)
|
| 90 |
|
| 91 |
-
- **Embeddings**
|
| 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 |
-
- [ ] **
|
| 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'
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 126 |
-
#
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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.
|
| 8 |
-
3.
|
| 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
|
|
|
|
| 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 |
-
#
|
| 20 |
-
|
| 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
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 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 = '
|
| 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 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 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 |
-
|
| 132 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
| 448 |
-
resp = collection.query.
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 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 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 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 |
-
|
| 27 |
-
|
| 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
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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 |
-
)
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 31 |
-
missing.append("
|
| 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.
|
| 32 |
-
missing.append("
|
| 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)}"
|