ritvikv03 Claude Sonnet 4.6 commited on
Commit
b65c098
Β·
1 Parent(s): 87fb121

feat: migrate vector store from ChromaDB to Astra DB + Astra Vectorize

Browse files

core/database.py β€” complete rewrite
- Remove all chromadb / sentence-transformers / ONNX runtime dependencies
- Integrate DataStax Astra DB via astrapy DataAPIClient
- Collection "pestel_signals" created with Astra Vectorize:
provider=nvidia, model=NV-Embed-QA (1024-dim, cosine)
No local model or external embedding API call β€” Astra embeds server-side
- Signal._to_astra_doc(): maps Signal β†’ Astra document (_id, $vectorize, fields)
- Signal._from_astra_doc(): reconstructs Signal from Astra document
(accepts both native lists and legacy JSON strings for entities/themes)
- SignalDB._get_or_create_collection(): idempotent bootstrap on first init
- SignalDB.insert(): find_one_and_replace(..., upsert=True) β€” safe re-ingestion
- SignalDB.search(): find(..., sort={"$vectorize": query}, include_similarity=True)
converts Astra similarity [0,1] β†’ distance convention (1.0 βˆ’ similarity)
to preserve all existing threshold logic in pipeline.py and graph_engine.py
- SignalDB.get_all(): limit=2_000, $vector excluded from projection
- SignalDB.count(): count_documents({}, upper_bound=10_000) β€” no full scan
- SignalDB.clear(): drop_collection then recreate with vectorize config
- Signal.to_metadata() / from_metadata() unchanged β€” graph_engine contract

requirements.txt
- Add astrapy>=1.5.0

.env.example
- Add ASTRA_DB_TOKEN documentation

core/logger.py
- Replace "chromadb" with "astrapy", "httpcore" in noise-suppressed loggers

app.py, core/pipeline.py, core/graph_engine.py, core/scheduler.py
- Update all "ChromaDB" user-facing strings and docstrings β†’ "Astra DB"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.env.example CHANGED
@@ -1,10 +1,18 @@
1
- # Anthropic API Key for Claude Code CLI Authentication
2
- # Get your API key from: https://console.anthropic.com/settings/keys
3
- # IMPORTANT: This will charge your Anthropic API account (pay-as-you-go rates)
4
- # Copy this file to .env and add your actual API key
5
 
6
- ANTHROPIC_API_KEY=sk-ant-api03-your-key-here
 
 
7
 
8
- # n8n Configuration (already set in docker-compose, can override here)
9
- # N8N_BASIC_AUTH_USER=admin
10
- # N8N_BASIC_AUTH_PASSWORD=fendt2026
 
 
 
 
 
 
 
 
1
+ # ── Fendt PESTEL-EL Sentinel β€” environment variables ─────────────────────────
2
+ # Copy this file to .env and fill in your actual values.
3
+ # NEVER commit .env to version control.
 
4
 
5
+ # ── HuggingFace (LLM scoring + graph edge identification) ─────────────────────
6
+ # Get your token from: https://huggingface.co/settings/tokens
7
+ HUGGINGFACEHUB_API_TOKEN=hf_your_token_here
8
 
9
+ # ── Astra DB (primary vector store for PESTEL signals) ────────────────────────
10
+ # Generate an Application Token in the Astra DB console:
11
+ # https://astra.datastax.com β†’ Database β†’ Connect β†’ Application Token
12
+ ASTRA_DB_TOKEN=AstraCS:your_token_here
13
+
14
+ # ── Optional: Firecrawl (HTML page scraping fallback) ─────────────────────────
15
+ # FIRECRAWL_API_KEY=fc-your_key_here
16
+
17
+ # ── Optional: log verbosity (DEBUG | INFO | WARNING | ERROR) ──────────────────
18
+ # LOG_LEVEL=INFO
app.py CHANGED
@@ -6,7 +6,7 @@ Architecture rules (CLAUDE.md):
6
  - Callbacks are pure functions. No global state mutation.
7
  - All Plotly colors use rgba() β€” never 8-char hex (#rrggbbaa).
8
  - All chart builders tested in _preflight() before Dash starts.
9
- - ChromaDB access only via SignalDB. Never call chroma directly.
10
  - Styling via CSS className. Inline style only for dynamic values.
11
 
12
  Sponsor requirements implemented:
@@ -580,7 +580,7 @@ def _tab_overview() -> html.Div:
580
  html.Div("KEY METRICS", className="section-label"),
581
  dbc.Row([
582
  dbc.Col(_metric("Total Signals", str(total) if total else "β€”",
583
- "in ChromaDB", "cyan"), md=3),
584
  dbc.Col(_metric("Critical", str(stats["critical"]) if total else "β€”",
585
  "score β‰₯ 0.75", "red"), md=3),
586
  dbc.Col(_metric("High", str(stats["high"]) if total else "β€”",
@@ -683,7 +683,7 @@ def _tab_feed() -> html.Div:
683
  dbc.Row([
684
  dbc.Col([
685
  html.Div(
686
- f"{len(signals)} signal(s) Β· sorted newest first Β· live from ChromaDB",
687
  style={"fontSize": "11px", "color": "#3d4f62", "marginBottom": "16px"},
688
  ),
689
  html.Div(
@@ -725,7 +725,7 @@ def _tab_chatbot(history: list[dict] | None = None) -> html.Div:
725
 
726
  welcome = _chat_bubble(
727
  f"Fendt Intelligence Assistant\n\n"
728
- f"{db_count} signal(s) indexed in ChromaDB. HuggingFace: {hf_status}\n\n"
729
  f"Ask about macro-level strategic decisions, regulatory timelines, "
730
  f"competitive positioning, or supply chain risks across the EU agricultural market.",
731
  role="assistant",
@@ -1183,7 +1183,7 @@ def _run_lens_search(topic: str | None, custom: str | None = None) -> html.Div:
1183
  if total_signals == 0:
1184
  return html.Div([
1185
  html.Div("β—‹", className="empty-state-icon"),
1186
- html.Div("No signals in ChromaDB", className="empty-state-title"),
1187
  html.Div(
1188
  'Click "Run Scout Now" in the sidebar to ingest intelligence. '
1189
  "The Intelligence Lens will populate after the first scout cycle completes.",
@@ -1214,7 +1214,7 @@ def _run_lens_search(topic: str | None, custom: str | None = None) -> html.Div:
1214
 
1215
 
1216
  def _tab_lens() -> html.Div:
1217
- """Strategic Intelligence Lens β€” semantic deep-dive via ChromaDB search."""
1218
  initial_results = _run_lens_search(_LENS_PRESETS[0])
1219
 
1220
  return html.Div([
@@ -1245,7 +1245,7 @@ def _tab_lens() -> html.Div:
1245
  dbc.Col(html.Div([
1246
  html.Div("HOW IT WORKS", className="section-label"),
1247
  html.P(
1248
- "ChromaDB semantic search surfaces the most relevant signals for any "
1249
  "macro-trend query. Results are ranked by cosine similarity using the "
1250
  "all-MiniLM-L6-v2 embedding model.",
1251
  style={"fontSize": "10.5px", "color": "#c4d0dc", "lineHeight": "1.7"},
@@ -1307,7 +1307,7 @@ def _llm_chat(question: str, context_signals: list[Signal]) -> str:
1307
  f" Content: {s.content}\n"
1308
  f" Source: {s.source_url}"
1309
  for i, s in enumerate(context_signals, 1)
1310
- ) if context_signals else "No matching signals found in ChromaDB."
1311
  )
1312
  try:
1313
  from huggingface_hub import InferenceClient
@@ -1551,7 +1551,7 @@ def update_sidebar(_i: int, _n: int):
1551
 
1552
  html.Div([
1553
  html.Div("SERVICES", className="sb-section-label"),
1554
- _dot("ChromaDB", db_kind),
1555
  _dot("HuggingFace API", gem_kind),
1556
  _dot("Scheduler", sched_kind),
1557
  _dot("Scout", scout_kind),
@@ -1783,11 +1783,11 @@ if __name__ == "__main__":
1783
 
1784
  _scheduler_engine.start()
1785
  stats = _db_stats()
1786
- log.info("App starting β€” ChromaDB: %d signals, HuggingFace: %s",
1787
  stats["total"], "OK" if _HF_OK else "NO KEY")
1788
 
1789
  print(f"\n Fendt Sentinel Β· http://localhost:8050")
1790
- print(f" ChromaDB : {stats['total']} signal(s)")
1791
  print(f" HuggingFace: {'OK' if _HF_OK else 'no API key β€” set HUGGINGFACEHUB_API_TOKEN'}")
1792
  print(f" Scheduler: active (6-hour scout cycle)")
1793
  print(f" Auto-refresh: 30 seconds\n")
 
6
  - Callbacks are pure functions. No global state mutation.
7
  - All Plotly colors use rgba() β€” never 8-char hex (#rrggbbaa).
8
  - All chart builders tested in _preflight() before Dash starts.
9
+ - Astra DB access only via SignalDB. Never call astrapy directly.
10
  - Styling via CSS className. Inline style only for dynamic values.
11
 
12
  Sponsor requirements implemented:
 
580
  html.Div("KEY METRICS", className="section-label"),
581
  dbc.Row([
582
  dbc.Col(_metric("Total Signals", str(total) if total else "β€”",
583
+ "in Astra DB", "cyan"), md=3),
584
  dbc.Col(_metric("Critical", str(stats["critical"]) if total else "β€”",
585
  "score β‰₯ 0.75", "red"), md=3),
586
  dbc.Col(_metric("High", str(stats["high"]) if total else "β€”",
 
683
  dbc.Row([
684
  dbc.Col([
685
  html.Div(
686
+ f"{len(signals)} signal(s) Β· sorted newest first Β· live from Astra DB",
687
  style={"fontSize": "11px", "color": "#3d4f62", "marginBottom": "16px"},
688
  ),
689
  html.Div(
 
725
 
726
  welcome = _chat_bubble(
727
  f"Fendt Intelligence Assistant\n\n"
728
+ f"{db_count} signal(s) indexed in Astra DB. HuggingFace: {hf_status}\n\n"
729
  f"Ask about macro-level strategic decisions, regulatory timelines, "
730
  f"competitive positioning, or supply chain risks across the EU agricultural market.",
731
  role="assistant",
 
1183
  if total_signals == 0:
1184
  return html.Div([
1185
  html.Div("β—‹", className="empty-state-icon"),
1186
+ html.Div("No signals in Astra DB", className="empty-state-title"),
1187
  html.Div(
1188
  'Click "Run Scout Now" in the sidebar to ingest intelligence. '
1189
  "The Intelligence Lens will populate after the first scout cycle completes.",
 
1214
 
1215
 
1216
  def _tab_lens() -> html.Div:
1217
+ """Strategic Intelligence Lens β€” semantic deep-dive via Astra DB."""
1218
  initial_results = _run_lens_search(_LENS_PRESETS[0])
1219
 
1220
  return html.Div([
 
1245
  dbc.Col(html.Div([
1246
  html.Div("HOW IT WORKS", className="section-label"),
1247
  html.P(
1248
+ "Astra DB semantic search surfaces the most relevant signals for any "
1249
  "macro-trend query. Results are ranked by cosine similarity using the "
1250
  "all-MiniLM-L6-v2 embedding model.",
1251
  style={"fontSize": "10.5px", "color": "#c4d0dc", "lineHeight": "1.7"},
 
1307
  f" Content: {s.content}\n"
1308
  f" Source: {s.source_url}"
1309
  for i, s in enumerate(context_signals, 1)
1310
+ ) if context_signals else "No matching signals found in Astra DB."
1311
  )
1312
  try:
1313
  from huggingface_hub import InferenceClient
 
1551
 
1552
  html.Div([
1553
  html.Div("SERVICES", className="sb-section-label"),
1554
+ _dot("Astra DB", db_kind),
1555
  _dot("HuggingFace API", gem_kind),
1556
  _dot("Scheduler", sched_kind),
1557
  _dot("Scout", scout_kind),
 
1783
 
1784
  _scheduler_engine.start()
1785
  stats = _db_stats()
1786
+ log.info("App starting β€” Astra DB: %d signals, HuggingFace: %s",
1787
  stats["total"], "OK" if _HF_OK else "NO KEY")
1788
 
1789
  print(f"\n Fendt Sentinel Β· http://localhost:8050")
1790
+ print(f" Astra DB : {stats['total']} signal(s)")
1791
  print(f" HuggingFace: {'OK' if _HF_OK else 'no API key β€” set HUGGINGFACEHUB_API_TOKEN'}")
1792
  print(f" Scheduler: active (6-hour scout cycle)")
1793
  print(f" Auto-refresh: 30 seconds\n")
core/database.py CHANGED
@@ -1,41 +1,53 @@
1
  """
2
- core/database.py β€” Fendt PESTEL-EL Sentinel: Vector Signal Store
3
- =================================================================
4
- Persistent ChromaDB collection backed by Pydantic-validated Signal models.
 
 
 
5
 
6
  Architecture
7
  ------------
8
- SignalDB β€” thin faΓ§ade: insert / query / delete
9
- Signal β€” canonical Pydantic v2 data model
10
  PESTELDimension β€” strict enum prevents tag drift
11
- _build_document() β€” deterministic text used for embedding
12
- _to_signal() β€” raw Chroma hit β†’ validated Signal
13
-
14
- Embedding
15
- ---------
16
- Uses ChromaDB's default embedding function (sentence-transformers
17
- "all-MiniLM-L6-v2" via chroma's bundled `chromadb.utils.embedding_functions`).
18
- No external API key required.
 
 
 
 
 
19
  """
20
 
21
  from __future__ import annotations
22
 
23
  import json
 
24
  import uuid
25
  from datetime import datetime, timezone
26
  from enum import Enum
27
- from pathlib import Path
28
  from typing import Optional
29
 
30
- import chromadb
31
- from chromadb.utils import embedding_functions
 
32
  from pydantic import BaseModel, Field, field_validator, model_validator
33
 
34
  # ─── Constants ────────────────────────────────────────────────────────────────
35
 
36
- _DB_DIR = Path(__file__).parent.parent / "data" / "chroma_db"
37
  _COLLECTION_NAME = "pestel_signals"
38
- _EMBED_MODEL = "all-MiniLM-L6-v2" # fast, runs fully offline via ONNX
 
 
 
39
 
40
 
41
  # ─── Enums ────────────────────────────────────────────────────────────────────
@@ -103,20 +115,20 @@ class Signal(BaseModel):
103
 
104
  @model_validator(mode="after")
105
  def title_not_in_content(self) -> Signal:
106
- # Soft sanity: content should be richer than just the title
107
  if self.content.strip() == self.title.strip():
108
  raise ValueError("content must differ from title")
109
  return self
110
 
111
- # ── serialisation helpers ─────────────────────────────────────────────────
 
112
 
113
  def to_metadata(self) -> dict:
114
- """Flat dict safe for ChromaDB metadata (strings/ints/floats/bools only)."""
115
  return {
116
  "id": self.id,
117
  "title": self.title,
118
  "pestel_dimension": self.pestel_dimension.value,
119
- "content": self.content, # stored for lossless reconstruction
120
  "source_url": self.source_url,
121
  "impact_score": self.impact_score,
122
  "novelty_score": self.novelty_score,
@@ -129,7 +141,7 @@ class Signal(BaseModel):
129
 
130
  @classmethod
131
  def from_metadata(cls, metadata: dict) -> Signal:
132
- """Reconstruct a Signal from a ChromaDB metadata dict."""
133
  return cls(
134
  id=metadata["id"],
135
  title=metadata["title"],
@@ -144,15 +156,66 @@ class Signal(BaseModel):
144
  themes=json.loads(metadata.get("themes", "[]")),
145
  )
146
 
 
147
 
148
- # ─── Embedding helper ─────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  def _build_document(signal: Signal) -> str:
151
  """
152
- Deterministic text fed to the embedding model.
153
 
154
- Concatenates the semantically richest fields so that similarity search
155
- works on both titles and full content.
156
  """
157
  parts = [
158
  f"[{signal.pestel_dimension.value}]",
@@ -170,7 +233,12 @@ def _build_document(signal: Signal) -> str:
170
 
171
  class SignalDB:
172
  """
173
- Thin faΓ§ade over a persistent ChromaDB collection.
 
 
 
 
 
174
 
175
  Usage
176
  -----
@@ -179,44 +247,65 @@ class SignalDB:
179
  >>> results = db.search("EU tractor subsidies", n_results=5)
180
  """
181
 
182
- def __init__(self, db_dir: Optional[Path] = None):
183
- _DB_DIR.mkdir(parents=True, exist_ok=True)
184
- path = str(db_dir or _DB_DIR)
185
-
186
- self._client = chromadb.PersistentClient(path=path)
187
- # DefaultEmbeddingFunction uses ONNX (all-MiniLM-L6-v2) β€”
188
- # no PyTorch / GPU required; fully offline after first download.
189
- self._ef = embedding_functions.DefaultEmbeddingFunction()
190
- self._col = self._client.get_or_create_collection(
191
- name=_COLLECTION_NAME,
192
- embedding_function=self._ef,
193
- metadata={"hnsw:space": "cosine"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  )
195
 
196
  # ── write ──────────────────────────────────────────────────────────────────
197
 
198
  def insert(self, signal: Signal) -> str:
199
  """
200
- Insert or update a Signal (upsert by signal.id).
 
 
 
201
 
202
  Returns the signal id.
203
  """
204
- self._col.upsert(
205
- ids=[signal.id],
206
- documents=[_build_document(signal)],
207
- metadatas=[signal.to_metadata()],
 
208
  )
209
  return signal.id
210
 
211
  def insert_many(self, signals: list[Signal]) -> list[str]:
212
- """Batch upsert. Returns list of ids."""
 
 
 
 
 
213
  if not signals:
214
  return []
215
- self._col.upsert(
216
- ids=[s.id for s in signals],
217
- documents=[_build_document(s) for s in signals],
218
- metadatas=[s.to_metadata() for s in signals],
219
- )
220
  return [s.id for s in signals]
221
 
222
  # ── read ───────────────────────────────────────────────────────────────────
@@ -228,7 +317,10 @@ class SignalDB:
228
  dimension_filter: Optional[PESTELDimension] = None,
229
  ) -> list[tuple[Signal, float]]:
230
  """
231
- Semantic similarity search.
 
 
 
232
 
233
  Parameters
234
  ----------
@@ -239,52 +331,58 @@ class SignalDB:
239
  Returns
240
  -------
241
  List of (Signal, distance) tuples, sorted by distance ascending
242
- (lower = more similar).
 
243
  """
244
- where = (
245
- {"pestel_dimension": dimension_filter.value}
246
- if dimension_filter
247
- else None
248
- )
249
- kwargs: dict = dict(
250
- query_texts=[query],
251
- n_results=min(n_results, self._col.count() or 1),
252
- include=["documents", "metadatas", "distances"],
 
253
  )
254
- if where:
255
- kwargs["where"] = where
256
-
257
- raw = self._col.query(**kwargs)
258
 
259
  results: list[tuple[Signal, float]] = []
260
- for doc, meta, dist in zip(
261
- raw["documents"][0],
262
- raw["metadatas"][0],
263
- raw["distances"][0],
264
- ):
265
- sig = Signal.from_metadata(meta)
266
- results.append((sig, round(dist, 4)))
267
  return results
268
 
269
  def get_by_id(self, signal_id: str) -> Optional[Signal]:
270
  """Exact fetch by UUID."""
271
- raw = self._col.get(
272
- ids=[signal_id],
273
- include=["documents", "metadatas"],
274
  )
275
- if not raw["ids"]:
276
  return None
277
- return Signal.from_metadata(raw["metadatas"][0])
278
 
279
  def get_all(self) -> list[Signal]:
280
- """Return every signal in the collection."""
281
- raw = self._col.get(include=["documents", "metadatas"])
282
- return [Signal.from_metadata(m) for m in raw["metadatas"]]
 
 
 
 
 
 
 
 
 
283
 
284
  # ── stats ──────────────────────────────────────────────────────────────────
285
 
286
  def count(self) -> int:
287
- return self._col.count()
 
288
 
289
  def stats(self) -> dict:
290
  all_signals = self.get_all()
@@ -293,21 +391,18 @@ class SignalDB:
293
  by_dim[s.pestel_dimension.value] = by_dim.get(s.pestel_dimension.value, 0) + 1
294
  return {
295
  "total_signals": self.count(),
296
- "by_dimension": by_dim,
297
- "collection": _COLLECTION_NAME,
298
- "db_path": str(_DB_DIR),
 
299
  }
300
 
301
  # ── delete ─────────────────────────────────────────────────────────────────
302
 
303
  def delete(self, signal_id: str) -> None:
304
- self._col.delete(ids=[signal_id])
305
 
306
  def clear(self) -> None:
307
- """Wipe the collection. Use with caution."""
308
- self._client.delete_collection(_COLLECTION_NAME)
309
- self._col = self._client.get_or_create_collection(
310
- name=_COLLECTION_NAME,
311
- embedding_function=self._ef,
312
- metadata={"hnsw:space": "cosine"},
313
- )
 
1
  """
2
+ core/database.py β€” Fendt PESTEL-EL Sentinel: Astra DB Vector Store
3
+ ===================================================================
4
+ Production-grade serverless vector store backed by DataStax Astra DB.
5
+ Embeddings are generated automatically via Astra Vectorize (Nvidia
6
+ NV-Embed-QA, 1024 dims, cosine similarity) β€” no local GPU or API key
7
+ overhead required at insert/query time.
8
 
9
  Architecture
10
  ------------
11
+ SignalDB β€” thin faΓ§ade: insert / upsert / query / delete
12
+ Signal β€” canonical Pydantic v2 data model (unchanged)
13
  PESTELDimension β€” strict enum prevents tag drift
14
+ _build_document() β€” deterministic text sent to Astra Vectorize
15
+ Signal._to_astra_doc() β€” Signal β†’ Astra document dict
16
+ Signal._from_astra_doc() β€” Astra document dict β†’ Signal
17
+
18
+ Similarity convention
19
+ ---------------------
20
+ Astra returns `$similarity` in [0.0, 1.0] (higher = more similar).
21
+ All existing call sites expect ChromaDB-style distance (lower = more
22
+ similar). SignalDB.search() converts: distance = 1.0 βˆ’ similarity.
23
+
24
+ Environment
25
+ -----------
26
+ ASTRA_DB_TOKEN β€” Application token from Astra DB console (required)
27
  """
28
 
29
  from __future__ import annotations
30
 
31
  import json
32
+ import os
33
  import uuid
34
  from datetime import datetime, timezone
35
  from enum import Enum
 
36
  from typing import Optional
37
 
38
+ from astrapy import DataAPIClient
39
+ from astrapy.constants import VectorMetric
40
+ from astrapy.info import CollectionVectorServiceOptions
41
  from pydantic import BaseModel, Field, field_validator, model_validator
42
 
43
  # ─── Constants ────────────────────────────────────────────────────────────────
44
 
45
+ _ASTRA_ENDPOINT = "https://8debd070-7481-4ab4-bedd-3c06e680be00-us-east-2.apps.astra.datastax.com"
46
  _COLLECTION_NAME = "pestel_signals"
47
+
48
+ # Astra Vectorize provider / model (no separate API key needed)
49
+ _VECTORIZE_PROVIDER = "nvidia"
50
+ _VECTORIZE_MODEL = "NV-Embed-QA"
51
 
52
 
53
  # ─── Enums ────────────────────────────────────────────────────────────────────
 
115
 
116
  @model_validator(mode="after")
117
  def title_not_in_content(self) -> Signal:
 
118
  if self.content.strip() == self.title.strip():
119
  raise ValueError("content must differ from title")
120
  return self
121
 
122
+ # ── graph-engine serialisation (JSON-safe flat dict) ─────────────────────
123
+ # Used by core/graph_engine.py LangGraph state β€” must stay stable.
124
 
125
  def to_metadata(self) -> dict:
126
+ """Flat dict safe for LangGraph state (strings/ints/floats/bools only)."""
127
  return {
128
  "id": self.id,
129
  "title": self.title,
130
  "pestel_dimension": self.pestel_dimension.value,
131
+ "content": self.content,
132
  "source_url": self.source_url,
133
  "impact_score": self.impact_score,
134
  "novelty_score": self.novelty_score,
 
141
 
142
  @classmethod
143
  def from_metadata(cls, metadata: dict) -> Signal:
144
+ """Reconstruct a Signal from a to_metadata() dict (graph engine use)."""
145
  return cls(
146
  id=metadata["id"],
147
  title=metadata["title"],
 
156
  themes=json.loads(metadata.get("themes", "[]")),
157
  )
158
 
159
+ # ��─ Astra DB document serialisation ──────────────────────────────────────
160
 
161
+ def _to_astra_doc(self) -> dict:
162
+ """
163
+ Convert to an Astra DB document.
164
+
165
+ The ``$vectorize`` field is passed to Astra Vectorize (Nvidia
166
+ NV-Embed-QA) which generates the embedding server-side.
167
+ Entities and themes are stored as native lists (Astra supports
168
+ rich types; JSON-stringification is not required here).
169
+ """
170
+ return {
171
+ "_id": self.id,
172
+ "$vectorize": _build_document(self),
173
+ "title": self.title,
174
+ "pestel_dimension": self.pestel_dimension.value,
175
+ "content": self.content,
176
+ "source_url": self.source_url,
177
+ "impact_score": self.impact_score,
178
+ "novelty_score": self.novelty_score,
179
+ "velocity_score": self.velocity_score,
180
+ "disruption_score": self.disruption_score,
181
+ "date_ingested": self.date_ingested.isoformat(),
182
+ "entities": self.entities,
183
+ "themes": self.themes,
184
+ }
185
+
186
+ @classmethod
187
+ def _from_astra_doc(cls, doc: dict) -> Signal:
188
+ """Reconstruct a Signal from an Astra DB document."""
189
+ entities = doc.get("entities") or []
190
+ themes = doc.get("themes") or []
191
+ # Accept both native lists and legacy JSON strings (migration safety)
192
+ if isinstance(entities, str):
193
+ entities = json.loads(entities)
194
+ if isinstance(themes, str):
195
+ themes = json.loads(themes)
196
+ return cls(
197
+ id=doc["_id"],
198
+ title=doc["title"],
199
+ pestel_dimension=PESTELDimension(doc["pestel_dimension"]),
200
+ content=doc["content"],
201
+ source_url=doc["source_url"],
202
+ impact_score=float(doc["impact_score"]),
203
+ novelty_score=float(doc["novelty_score"]),
204
+ velocity_score=float(doc["velocity_score"]),
205
+ date_ingested=datetime.fromisoformat(doc["date_ingested"]),
206
+ entities=entities,
207
+ themes=themes,
208
+ )
209
+
210
+
211
+ # ─── Vectorize document builder ───────────────────────────────────────────────
212
 
213
  def _build_document(signal: Signal) -> str:
214
  """
215
+ Deterministic text sent to Astra Vectorize (Nvidia NV-Embed-QA).
216
 
217
+ Concatenates the semantically richest fields so similarity search
218
+ works across titles, content, entities, and themes.
219
  """
220
  parts = [
221
  f"[{signal.pestel_dimension.value}]",
 
233
 
234
  class SignalDB:
235
  """
236
+ Thin faΓ§ade over an Astra DB collection with Astra Vectorize.
237
+
238
+ The collection is lazily created on first instantiation if it does
239
+ not yet exist. Embeddings are generated automatically by the Nvidia
240
+ NV-Embed-QA model hosted inside Astra β€” no local model or external
241
+ embedding API call is made by this class.
242
 
243
  Usage
244
  -----
 
247
  >>> results = db.search("EU tractor subsidies", n_results=5)
248
  """
249
 
250
+ def __init__(self) -> None:
251
+ token = os.getenv("ASTRA_DB_TOKEN", "")
252
+ if not token:
253
+ raise RuntimeError(
254
+ "ASTRA_DB_TOKEN is not set. "
255
+ "Add it to your .env file and restart the app."
256
+ )
257
+
258
+ client = DataAPIClient(token=token)
259
+ self._db = client.get_database(_ASTRA_ENDPOINT)
260
+ self._col = self._get_or_create_collection()
261
+
262
+ # ── collection bootstrap ───────────────────────────────────────────────────
263
+
264
+ def _get_or_create_collection(self):
265
+ """Return the existing collection or create it with Astra Vectorize."""
266
+ existing_names = {c.name for c in self._db.list_collections()}
267
+ if _COLLECTION_NAME in existing_names:
268
+ return self._db.get_collection(_COLLECTION_NAME)
269
+
270
+ return self._db.create_collection(
271
+ _COLLECTION_NAME,
272
+ metric=VectorMetric.COSINE,
273
+ service=CollectionVectorServiceOptions(
274
+ provider=_VECTORIZE_PROVIDER,
275
+ model_name=_VECTORIZE_MODEL,
276
+ ),
277
  )
278
 
279
  # ── write ──────────────────────────────────────────────────────────────────
280
 
281
  def insert(self, signal: Signal) -> str:
282
  """
283
+ Upsert a Signal by its UUID.
284
+
285
+ Astra Vectorize auto-embeds the ``$vectorize`` field using
286
+ Nvidia NV-Embed-QA. No local embedding computation occurs.
287
 
288
  Returns the signal id.
289
  """
290
+ doc = signal._to_astra_doc()
291
+ self._col.find_one_and_replace(
292
+ {"_id": signal.id},
293
+ doc,
294
+ upsert=True,
295
  )
296
  return signal.id
297
 
298
  def insert_many(self, signals: list[Signal]) -> list[str]:
299
+ """
300
+ Batch upsert. Each signal is individually replaced/inserted so
301
+ Astra Vectorize can embed each ``$vectorize`` field.
302
+
303
+ Returns list of ids.
304
+ """
305
  if not signals:
306
  return []
307
+ for s in signals:
308
+ self.insert(s)
 
 
 
309
  return [s.id for s in signals]
310
 
311
  # ── read ───────────────────────────────────────────────────────────────────
 
317
  dimension_filter: Optional[PESTELDimension] = None,
318
  ) -> list[tuple[Signal, float]]:
319
  """
320
+ Semantic similarity search powered by Astra Vectorize.
321
+
322
+ The query string is embedded server-side using Nvidia NV-Embed-QA;
323
+ the top-k most similar signals are returned.
324
 
325
  Parameters
326
  ----------
 
331
  Returns
332
  -------
333
  List of (Signal, distance) tuples, sorted by distance ascending
334
+ (lower = more similar). Distance = 1.0 βˆ’ Astra similarity score,
335
+ preserving the ChromaDB convention used throughout the codebase.
336
  """
337
+ filter_: dict = {}
338
+ if dimension_filter:
339
+ filter_["pestel_dimension"] = dimension_filter.value
340
+
341
+ cursor = self._col.find(
342
+ filter_,
343
+ sort={"$vectorize": query},
344
+ limit=n_results,
345
+ include_similarity=True,
346
+ projection={"$vector": False}, # omit raw embedding bytes
347
  )
 
 
 
 
348
 
349
  results: list[tuple[Signal, float]] = []
350
+ for doc in cursor:
351
+ similarity = float(doc.pop("$similarity", 0.0))
352
+ distance = round(1.0 - similarity, 4)
353
+ sig = Signal._from_astra_doc(doc)
354
+ results.append((sig, distance))
 
 
355
  return results
356
 
357
  def get_by_id(self, signal_id: str) -> Optional[Signal]:
358
  """Exact fetch by UUID."""
359
+ doc = self._col.find_one(
360
+ {"_id": signal_id},
361
+ projection={"$vector": False},
362
  )
363
+ if doc is None:
364
  return None
365
+ return Signal._from_astra_doc(doc)
366
 
367
  def get_all(self) -> list[Signal]:
368
+ """
369
+ Return every signal in the collection (up to 2 000).
370
+
371
+ For dashboard and export use. Signals are returned in arbitrary
372
+ order; callers are responsible for sorting.
373
+ """
374
+ cursor = self._col.find(
375
+ {},
376
+ projection={"$vector": False},
377
+ limit=2_000,
378
+ )
379
+ return [Signal._from_astra_doc(doc) for doc in cursor]
380
 
381
  # ── stats ──────────────────────────────────────────────────────────────────
382
 
383
  def count(self) -> int:
384
+ """Fast estimated document count (no full scan)."""
385
+ return self._col.count_documents({}, upper_bound=10_000)
386
 
387
  def stats(self) -> dict:
388
  all_signals = self.get_all()
 
391
  by_dim[s.pestel_dimension.value] = by_dim.get(s.pestel_dimension.value, 0) + 1
392
  return {
393
  "total_signals": self.count(),
394
+ "by_dimension": by_dim,
395
+ "collection": _COLLECTION_NAME,
396
+ "endpoint": _ASTRA_ENDPOINT,
397
+ "vectorize": f"{_VECTORIZE_PROVIDER}/{_VECTORIZE_MODEL}",
398
  }
399
 
400
  # ── delete ─────────────────────────────────────────────────────────────────
401
 
402
  def delete(self, signal_id: str) -> None:
403
+ self._col.delete_one({"_id": signal_id})
404
 
405
  def clear(self) -> None:
406
+ """Drop and recreate the collection. Use with caution."""
407
+ self._db.drop_collection(_COLLECTION_NAME)
408
+ self._col = self._get_or_create_collection()
 
 
 
 
core/graph_engine.py CHANGED
@@ -7,7 +7,7 @@ Flow
7
  ----
8
  Signal
9
  β†’ [Node 1] receive_signal : validates & wraps input state
10
- β†’ [Node 2] rag_query : fetches top-2 semantically related signals from ChromaDB
11
  β†’ [Node 3] identify_edges : asks HuggingFace model to name relationships
12
  β†’ [Node 4] update_graph : appends nodes/edges to data/graph.json
13
 
 
7
  ----
8
  Signal
9
  β†’ [Node 1] receive_signal : validates & wraps input state
10
+ β†’ [Node 2] rag_query : fetches top-2 semantically related signals from Astra DB
11
  β†’ [Node 3] identify_edges : asks HuggingFace model to name relationships
12
  β†’ [Node 4] update_graph : appends nodes/edges to data/graph.json
13
 
core/logger.py CHANGED
@@ -6,7 +6,7 @@ Usage:
6
  log = get_logger(__name__)
7
  log.info("Pipeline started")
8
  log.warning("Gemini quota low")
9
- log.error("ChromaDB unreachable: %s", exc)
10
 
11
  All log records go to:
12
  - logs/agent.log (rotating, 5 MB Γ— 3 backups, always)
@@ -64,7 +64,8 @@ def _configure_root() -> None:
64
 
65
  # Silence noisy third-party loggers
66
  for noisy in ("werkzeug", "dash", "dash.dash", "httpx",
67
- "urllib3", "chromadb", "apscheduler.executors"):
 
68
  logging.getLogger(noisy).setLevel(logging.WARNING)
69
 
70
 
 
6
  log = get_logger(__name__)
7
  log.info("Pipeline started")
8
  log.warning("Gemini quota low")
9
+ log.error("Astra DB unreachable: %s", exc)
10
 
11
  All log records go to:
12
  - logs/agent.log (rotating, 5 MB Γ— 3 backups, always)
 
64
 
65
  # Silence noisy third-party loggers
66
  for noisy in ("werkzeug", "dash", "dash.dash", "httpx",
67
+ "urllib3", "astrapy", "httpcore",
68
+ "apscheduler.executors"):
69
  logging.getLogger(noisy).setLevel(logging.WARNING)
70
 
71
 
core/pipeline.py CHANGED
@@ -2,7 +2,7 @@
2
  core/pipeline.py β€” Fendt PESTEL-EL Sentinel: Scoring Pipeline
3
  ==============================================================
4
  Takes raw text (news article, report snippet, any string) and
5
- returns a fully-validated Signal ready for ChromaDB insertion.
6
 
7
  Flow
8
  ----
@@ -13,7 +13,7 @@ Flow
13
  Public API
14
  ----------
15
  score_text(text) β†’ Signal
16
- score_and_save(text, db) β†’ Signal (also persists to ChromaDB)
17
  """
18
 
19
  from __future__ import annotations
@@ -183,7 +183,7 @@ _DEDUP_THRESHOLD = 0.08 # cosine distance; lower = more similar. Tune here.
183
 
184
  def _is_duplicate(text: str, db: SignalDB) -> bool:
185
  """
186
- Return True if ChromaDB already contains a semantically near-identical document.
187
 
188
  Uses cosine distance from ChromaDB (range 0–2, lower = more similar).
189
  A threshold of 0.08 catches rephrased duplicates of the same article
@@ -301,7 +301,7 @@ def score_and_save(
301
  text: str, db: Optional[SignalDB] = None
302
  ) -> Optional[tuple[Signal, LLMScoreResponse]]:
303
  """
304
- Score text and persist to ChromaDB.
305
 
306
  Returns None if the text is a near-duplicate of an existing signal.
307
  Returns (Signal, LLMScoreResponse) otherwise.
 
2
  core/pipeline.py β€” Fendt PESTEL-EL Sentinel: Scoring Pipeline
3
  ==============================================================
4
  Takes raw text (news article, report snippet, any string) and
5
+ returns a fully-validated Signal ready for Astra DB insertion.
6
 
7
  Flow
8
  ----
 
13
  Public API
14
  ----------
15
  score_text(text) β†’ Signal
16
+ score_and_save(text, db) β†’ Signal (also persists to Astra DB)
17
  """
18
 
19
  from __future__ import annotations
 
183
 
184
  def _is_duplicate(text: str, db: SignalDB) -> bool:
185
  """
186
+ Return True if Astra DB already contains a semantically near-identical document.
187
 
188
  Uses cosine distance from ChromaDB (range 0–2, lower = more similar).
189
  A threshold of 0.08 catches rephrased duplicates of the same article
 
301
  text: str, db: Optional[SignalDB] = None
302
  ) -> Optional[tuple[Signal, LLMScoreResponse]]:
303
  """
304
+ Score text and persist to Astra DB.
305
 
306
  Returns None if the text is a near-duplicate of an existing signal.
307
  Returns (Signal, LLMScoreResponse) otherwise.
core/scheduler.py CHANGED
@@ -9,7 +9,7 @@ Architecture
9
  β†’ every 6 hours: _run_scout_cycle()
10
  β†’ scrape each PESTEL_SOURCE
11
  β†’ score each article via Gemini
12
- β†’ save to ChromaDB
13
  β†’ every 30 seconds: _heartbeat()
14
  β†’ update HEALTH dict (alive, last_run, counts)
15
 
@@ -69,7 +69,7 @@ HEALTH: dict = {
69
  def _run_scout_cycle() -> None:
70
  """
71
  One full intelligence cycle:
72
- scrape all sources β†’ score via Gemini β†’ save to ChromaDB.
73
  Errors per source are caught; they never abort the full cycle.
74
  """
75
  from core.scraper import scrape_source
 
9
  β†’ every 6 hours: _run_scout_cycle()
10
  β†’ scrape each PESTEL_SOURCE
11
  β†’ score each article via Gemini
12
+ β†’ save to Astra DB
13
  β†’ every 30 seconds: _heartbeat()
14
  β†’ update HEALTH dict (alive, last_run, counts)
15
 
 
69
  def _run_scout_cycle() -> None:
70
  """
71
  One full intelligence cycle:
72
+ scrape all sources β†’ score via Gemini β†’ save to Astra DB.
73
  Errors per source are caught; they never abort the full cycle.
74
  """
75
  from core.scraper import scrape_source
requirements.txt CHANGED
@@ -82,6 +82,8 @@ zipp==3.23.0
82
  # ── Added for Tab 6 export formats ──────────────────────────────────────────
83
  openpyxl>=3.1.0
84
  python-docx>=0.8.11
 
 
85
  # ── AI / LLM stack (HuggingFace + LangChain) ────────────────────────────────
86
  langchain>=0.3.0,<0.4
87
  langchain-huggingface>=0.1.0,<0.2
 
82
  # ── Added for Tab 6 export formats ──────────────────────────────────────────
83
  openpyxl>=3.1.0
84
  python-docx>=0.8.11
85
+ # ── Vector store (Astra DB + Astra Vectorize) ────────────────────────────────
86
+ astrapy>=1.5.0
87
  # ── AI / LLM stack (HuggingFace + LangChain) ────────────────────────────────
88
  langchain>=0.3.0,<0.4
89
  langchain-huggingface>=0.1.0,<0.2