Pippinlitli commited on
Commit
1f883a3
·
1 Parent(s): 6b281e7

Add SQLite persistence — episodes survive Space restarts v1.1.0

Browse files

- mragent_server.py: all episodes now stored in SQLite (/data/mragent.db)
- /data is the HF Spaces persistent volume (survives restarts)
- Falls back to /app/data if /data is not mounted
- Embeddings stored as JSON in DB alongside episode metadata
- Full CRUD via /ingest, /query, /reconstruct, /stats, /recent
- Dockerfile: creates /data and /app/data directories
- Version bumped to 1.1.0

Files changed (2) hide show
  1. Dockerfile +3 -0
  2. mragent_server.py +159 -71
Dockerfile CHANGED
@@ -19,6 +19,9 @@ RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTr
19
 
20
  COPY mragent_server.py .
21
 
 
 
 
22
  EXPOSE 7860
23
 
24
  HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
 
19
 
20
  COPY mragent_server.py .
21
 
22
+ # /data is the HF Spaces persistent volume — create a fallback in case it's not mounted
23
+ RUN mkdir -p /data /app/data
24
+
25
  EXPOSE 7860
26
 
27
  HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
mragent_server.py CHANGED
@@ -1,13 +1,17 @@
1
  """
2
  evolva-mragent: MRAgent Memory Graph FastAPI Server
3
  Implements semantic memory storage and retrieval using sentence embeddings.
 
 
4
  Endpoints: /ingest, /query, /reconstruct, /stats, /recent, /health
5
  """
6
 
7
  import os
8
  import uuid
9
- import time
 
10
  import logging
 
11
  from datetime import datetime, timezone
12
  from typing import Optional, List, Dict, Any
13
 
@@ -18,10 +22,29 @@ from pydantic import BaseModel, Field
18
  logging.basicConfig(level=logging.INFO)
19
  logger = logging.getLogger("mragent")
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  app = FastAPI(
22
  title="evolva-mragent Memory Server",
23
- description="MRAgent semantic memory graph — stores and retrieves verified claim episodes",
24
- version="1.0.0",
 
 
 
25
  )
26
 
27
  app.add_middleware(
@@ -32,25 +55,53 @@ app.add_middleware(
32
  )
33
 
34
  # ---------------------------------------------------------------------------
35
- # In-memory store (persists for the lifetime of the container)
36
  # ---------------------------------------------------------------------------
37
- EPISODES: List[Dict[str, Any]] = []
38
- EMBEDDINGS: List[List[float]] = []
39
- EMBEDDING_MODEL_NAME = "all-MiniLM-L6-v2"
40
- _model = None # lazy-loaded
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  def get_model():
44
  global _model
45
  if _model is None:
46
- try:
47
- from sentence_transformers import SentenceTransformer
48
- logger.info("Loading sentence-transformers model …")
49
- _model = SentenceTransformer(EMBEDDING_MODEL_NAME)
50
- logger.info("Model loaded.")
51
- except Exception as e:
52
- logger.warning(f"sentence-transformers unavailable ({e}); using zero-vector fallback")
53
- _model = None
 
 
54
  return _model
55
 
56
 
@@ -59,8 +110,7 @@ def embed(text: str) -> Optional[List[float]]:
59
  if model is None:
60
  return None
61
  try:
62
- vec = model.encode([text])[0].tolist()
63
- return vec
64
  except Exception as e:
65
  logger.error(f"Embedding error: {e}")
66
  return None
@@ -99,7 +149,7 @@ class IngestResponse(BaseModel):
99
 
100
  class QueryRequest(BaseModel):
101
  claim: str = Field(..., description="The claim or query string to search for")
102
- top_k: int = Field(default=5, ge=1, le=50, description="Number of top episodes to return")
103
 
104
 
105
  class EpisodeResult(BaseModel):
@@ -135,6 +185,7 @@ class StatsResponse(BaseModel):
135
  verdict_counts: Dict[str, int]
136
  has_embedding: bool
137
  embedding_model: str
 
138
 
139
 
140
  class RecentEpisode(BaseModel):
@@ -157,6 +208,19 @@ class HealthResponse(BaseModel):
157
  embedding_model: str
158
  has_embedding: bool
159
  version: str
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
 
162
  # ---------------------------------------------------------------------------
@@ -169,19 +233,23 @@ def root():
169
  "message": "evolva-mragent Memory Server",
170
  "docs": "/docs",
171
  "health": "/health",
172
- "version": "1.0.0",
 
173
  }
174
 
175
 
176
  @app.get("/health", response_model=HealthResponse, tags=["system"])
177
  def health():
 
 
178
  model_loaded = get_model() is not None
179
  return HealthResponse(
180
  status="ok",
181
- episode_count=len(EPISODES),
182
  embedding_model=EMBEDDING_MODEL_NAME,
183
  has_embedding=model_loaded,
184
- version="1.0.0",
 
185
  )
186
 
187
 
@@ -190,24 +258,30 @@ def ingest(req: IngestRequest):
190
  try:
191
  ep_id = req.episode_id or str(uuid.uuid4())
192
  vec = embed(req.text)
193
- episode = {
194
- "episode_id": ep_id,
195
- "text": req.text,
196
- "origin": req.origin,
197
- "tags": req.tags or [],
198
- "citation": req.citation,
199
- "verdict": req.verdict,
200
- "ingested_at": datetime.now(timezone.utc).isoformat(),
201
- }
202
- EPISODES.append(episode)
203
- EMBEDDINGS.append(vec or [])
 
 
 
 
 
 
 
 
 
 
 
204
  logger.info(f"Ingested episode {ep_id} (has_embedding={vec is not None})")
205
- return IngestResponse(
206
- success=True,
207
- episode_id=ep_id,
208
- has_embedding=vec is not None,
209
- error=None,
210
- )
211
  except Exception as e:
212
  logger.error(f"Ingest error: {e}")
213
  return IngestResponse(success=False, episode_id=None, has_embedding=False, error=str(e))
@@ -216,33 +290,38 @@ def ingest(req: IngestRequest):
216
  @app.post("/query", response_model=QueryResponse, tags=["memory"])
217
  def query(req: QueryRequest):
218
  try:
219
- if not EPISODES:
 
 
 
 
 
220
  return QueryResponse(episodes=[], total_in_memory=0, error=None)
221
 
222
  q_vec = embed(req.claim)
223
- if q_vec and any(EMBEDDINGS):
224
  scored = []
225
- for ep, ev in zip(EPISODES, EMBEDDINGS):
 
226
  score = cosine_similarity(q_vec, ev) if ev else 0.0
227
- scored.append((score, ep))
228
  scored.sort(key=lambda x: x[0], reverse=True)
229
  top = scored[: req.top_k]
230
  else:
231
- # Fallback: return most recent episodes
232
- top = [(0.0, ep) for ep in EPISODES[-req.top_k:]]
233
 
234
  results = [
235
  EpisodeResult(
236
- episode_id=ep.get("episode_id"),
237
- text=ep.get("text"),
238
- origin=ep.get("origin"),
239
  score=round(score, 4),
240
- citation=ep.get("citation"),
241
- verdict=ep.get("verdict"),
242
  )
243
- for score, ep in top
244
  ]
245
- return QueryResponse(episodes=results, total_in_memory=len(EPISODES), error=None)
246
  except Exception as e:
247
  logger.error(f"Query error: {e}")
248
  raise HTTPException(status_code=500, detail=str(e))
@@ -274,38 +353,47 @@ def reconstruct(req: ReconstructRequest):
274
 
275
  @app.get("/stats", response_model=StatsResponse, tags=["memory"])
276
  def stats():
 
 
 
 
 
277
  verdict_counts: Dict[str, int] = {}
278
- for ep in EPISODES:
279
- v = ep.get("verdict") or "Unknown"
 
280
  verdict_counts[v] = verdict_counts.get(v, 0) + 1
 
 
 
 
281
 
282
- # Count unique tags as "key nodes", tag co-occurrences as "links"
283
- all_tags = [t for ep in EPISODES for t in ep.get("tags", [])]
284
  unique_tags = len(set(all_tags))
285
  link_count = max(0, len(all_tags) - unique_tags)
286
 
287
  return StatsResponse(
288
- episode_count=len(EPISODES),
289
  key_node_count=unique_tags,
290
  link_count=link_count,
291
  verdict_counts=verdict_counts,
292
  has_embedding=get_model() is not None,
293
  embedding_model=EMBEDDING_MODEL_NAME,
 
294
  )
295
 
296
 
297
  @app.get("/recent", response_model=RecentResponse, tags=["memory"])
298
  def recent():
299
- sorted_eps = sorted(
300
- EPISODES,
301
- key=lambda e: e.get("ingested_at", ""),
302
- reverse=True,
303
- )[:20]
 
304
 
305
  results = []
306
- for ep in sorted_eps:
307
- text = ep.get("text", "")
308
- # Try to extract claim from text like "VERDICT: X\nCLAIM: ..."
309
  claim_str = text
310
  if "\nCLAIM:" in text:
311
  claim_str = text.split("\nCLAIM:", 1)[1].strip()[:200]
@@ -314,12 +402,12 @@ def recent():
314
 
315
  results.append(
316
  RecentEpisode(
317
- episode_id=ep.get("episode_id"),
318
- verdict=ep.get("verdict"),
319
  claim=claim_str,
320
- origin=ep.get("origin"),
321
- ingested_at=ep.get("ingested_at"),
322
- citation=ep.get("citation"),
323
  )
324
  )
325
- return RecentResponse(episodes=results, total=len(EPISODES))
 
1
  """
2
  evolva-mragent: MRAgent Memory Graph FastAPI Server
3
  Implements semantic memory storage and retrieval using sentence embeddings.
4
+ Episodes are persisted to SQLite so they survive Space restarts.
5
+
6
  Endpoints: /ingest, /query, /reconstruct, /stats, /recent, /health
7
  """
8
 
9
  import os
10
  import uuid
11
+ import json
12
+ import sqlite3
13
  import logging
14
+ import threading
15
  from datetime import datetime, timezone
16
  from typing import Optional, List, Dict, Any
17
 
 
22
  logging.basicConfig(level=logging.INFO)
23
  logger = logging.getLogger("mragent")
24
 
25
+ # ---------------------------------------------------------------------------
26
+ # Configuration
27
+ # ---------------------------------------------------------------------------
28
+ # HF Spaces: /data is a persistent volume that survives restarts.
29
+ # Fall back to /app/data if /data is not available.
30
+ DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
31
+ os.makedirs(DATA_DIR, exist_ok=True)
32
+ DB_PATH = os.path.join(DATA_DIR, "mragent.db")
33
+
34
+ EMBEDDING_MODEL_NAME = "all-MiniLM-L6-v2"
35
+ _model = None
36
+ _model_lock = threading.Lock()
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # FastAPI app
40
+ # ---------------------------------------------------------------------------
41
  app = FastAPI(
42
  title="evolva-mragent Memory Server",
43
+ description=(
44
+ "MRAgent semantic memory graph — stores and retrieves verified claim episodes. "
45
+ "Episodes are persisted to SQLite and survive Space restarts."
46
+ ),
47
+ version="1.1.0",
48
  )
49
 
50
  app.add_middleware(
 
55
  )
56
 
57
  # ---------------------------------------------------------------------------
58
+ # SQLite helpers
59
  # ---------------------------------------------------------------------------
 
 
 
 
60
 
61
+ def get_db() -> sqlite3.Connection:
62
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
63
+ conn.row_factory = sqlite3.Row
64
+ return conn
65
+
66
+
67
+ def init_db():
68
+ with get_db() as conn:
69
+ conn.execute("""
70
+ CREATE TABLE IF NOT EXISTS episodes (
71
+ episode_id TEXT PRIMARY KEY,
72
+ text TEXT NOT NULL,
73
+ origin TEXT,
74
+ tags TEXT, -- JSON array stored as string
75
+ citation TEXT,
76
+ verdict TEXT,
77
+ embedding TEXT, -- JSON float array stored as string
78
+ ingested_at TEXT NOT NULL
79
+ )
80
+ """)
81
+ conn.execute("""
82
+ CREATE INDEX IF NOT EXISTS idx_ingested_at ON episodes(ingested_at DESC)
83
+ """)
84
+ conn.commit()
85
+ logger.info(f"SQLite DB initialised at {DB_PATH}")
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Embedding helpers
90
+ # ---------------------------------------------------------------------------
91
 
92
  def get_model():
93
  global _model
94
  if _model is None:
95
+ with _model_lock:
96
+ if _model is None:
97
+ try:
98
+ from sentence_transformers import SentenceTransformer
99
+ logger.info("Loading sentence-transformers model …")
100
+ _model = SentenceTransformer(EMBEDDING_MODEL_NAME)
101
+ logger.info("Model loaded.")
102
+ except Exception as e:
103
+ logger.warning(f"sentence-transformers unavailable ({e}); using zero-vector fallback")
104
+ _model = None
105
  return _model
106
 
107
 
 
110
  if model is None:
111
  return None
112
  try:
113
+ return model.encode([text])[0].tolist()
 
114
  except Exception as e:
115
  logger.error(f"Embedding error: {e}")
116
  return None
 
149
 
150
  class QueryRequest(BaseModel):
151
  claim: str = Field(..., description="The claim or query string to search for")
152
+ top_k: int = Field(default=5, ge=1, le=50)
153
 
154
 
155
  class EpisodeResult(BaseModel):
 
185
  verdict_counts: Dict[str, int]
186
  has_embedding: bool
187
  embedding_model: str
188
+ db_path: str
189
 
190
 
191
  class RecentEpisode(BaseModel):
 
208
  embedding_model: str
209
  has_embedding: bool
210
  version: str
211
+ db_path: str
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Startup
216
+ # ---------------------------------------------------------------------------
217
+
218
+ @app.on_event("startup")
219
+ def startup():
220
+ init_db()
221
+ # Warm up the embedding model in the background
222
+ import threading
223
+ threading.Thread(target=get_model, daemon=True).start()
224
 
225
 
226
  # ---------------------------------------------------------------------------
 
233
  "message": "evolva-mragent Memory Server",
234
  "docs": "/docs",
235
  "health": "/health",
236
+ "version": "1.1.0",
237
+ "persistence": "SQLite",
238
  }
239
 
240
 
241
  @app.get("/health", response_model=HealthResponse, tags=["system"])
242
  def health():
243
+ with get_db() as conn:
244
+ count = conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
245
  model_loaded = get_model() is not None
246
  return HealthResponse(
247
  status="ok",
248
+ episode_count=count,
249
  embedding_model=EMBEDDING_MODEL_NAME,
250
  has_embedding=model_loaded,
251
+ version="1.1.0",
252
+ db_path=DB_PATH,
253
  )
254
 
255
 
 
258
  try:
259
  ep_id = req.episode_id or str(uuid.uuid4())
260
  vec = embed(req.text)
261
+ now = datetime.now(timezone.utc).isoformat()
262
+
263
+ with get_db() as conn:
264
+ conn.execute(
265
+ """
266
+ INSERT OR REPLACE INTO episodes
267
+ (episode_id, text, origin, tags, citation, verdict, embedding, ingested_at)
268
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
269
+ """,
270
+ (
271
+ ep_id,
272
+ req.text,
273
+ req.origin,
274
+ json.dumps(req.tags or []),
275
+ req.citation,
276
+ req.verdict,
277
+ json.dumps(vec) if vec else None,
278
+ now,
279
+ ),
280
+ )
281
+ conn.commit()
282
+
283
  logger.info(f"Ingested episode {ep_id} (has_embedding={vec is not None})")
284
+ return IngestResponse(success=True, episode_id=ep_id, has_embedding=vec is not None, error=None)
 
 
 
 
 
285
  except Exception as e:
286
  logger.error(f"Ingest error: {e}")
287
  return IngestResponse(success=False, episode_id=None, has_embedding=False, error=str(e))
 
290
  @app.post("/query", response_model=QueryResponse, tags=["memory"])
291
  def query(req: QueryRequest):
292
  try:
293
+ with get_db() as conn:
294
+ rows = conn.execute(
295
+ "SELECT episode_id, text, origin, citation, verdict, embedding FROM episodes"
296
+ ).fetchall()
297
+
298
+ if not rows:
299
  return QueryResponse(episodes=[], total_in_memory=0, error=None)
300
 
301
  q_vec = embed(req.claim)
302
+ if q_vec:
303
  scored = []
304
+ for row in rows:
305
+ ev = json.loads(row["embedding"]) if row["embedding"] else []
306
  score = cosine_similarity(q_vec, ev) if ev else 0.0
307
+ scored.append((score, row))
308
  scored.sort(key=lambda x: x[0], reverse=True)
309
  top = scored[: req.top_k]
310
  else:
311
+ top = [(0.0, row) for row in rows[-req.top_k:]]
 
312
 
313
  results = [
314
  EpisodeResult(
315
+ episode_id=row["episode_id"],
316
+ text=row["text"],
317
+ origin=row["origin"],
318
  score=round(score, 4),
319
+ citation=row["citation"],
320
+ verdict=row["verdict"],
321
  )
322
+ for score, row in top
323
  ]
324
+ return QueryResponse(episodes=results, total_in_memory=len(rows), error=None)
325
  except Exception as e:
326
  logger.error(f"Query error: {e}")
327
  raise HTTPException(status_code=500, detail=str(e))
 
353
 
354
  @app.get("/stats", response_model=StatsResponse, tags=["memory"])
355
  def stats():
356
+ with get_db() as conn:
357
+ rows = conn.execute(
358
+ "SELECT verdict, tags FROM episodes"
359
+ ).fetchall()
360
+
361
  verdict_counts: Dict[str, int] = {}
362
+ all_tags = []
363
+ for row in rows:
364
+ v = row["verdict"] or "Unknown"
365
  verdict_counts[v] = verdict_counts.get(v, 0) + 1
366
+ try:
367
+ all_tags.extend(json.loads(row["tags"] or "[]"))
368
+ except Exception:
369
+ pass
370
 
 
 
371
  unique_tags = len(set(all_tags))
372
  link_count = max(0, len(all_tags) - unique_tags)
373
 
374
  return StatsResponse(
375
+ episode_count=len(rows),
376
  key_node_count=unique_tags,
377
  link_count=link_count,
378
  verdict_counts=verdict_counts,
379
  has_embedding=get_model() is not None,
380
  embedding_model=EMBEDDING_MODEL_NAME,
381
+ db_path=DB_PATH,
382
  )
383
 
384
 
385
  @app.get("/recent", response_model=RecentResponse, tags=["memory"])
386
  def recent():
387
+ with get_db() as conn:
388
+ total = conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
389
+ rows = conn.execute(
390
+ "SELECT episode_id, verdict, text, origin, ingested_at, citation "
391
+ "FROM episodes ORDER BY ingested_at DESC LIMIT 20"
392
+ ).fetchall()
393
 
394
  results = []
395
+ for row in rows:
396
+ text = row["text"] or ""
 
397
  claim_str = text
398
  if "\nCLAIM:" in text:
399
  claim_str = text.split("\nCLAIM:", 1)[1].strip()[:200]
 
402
 
403
  results.append(
404
  RecentEpisode(
405
+ episode_id=row["episode_id"],
406
+ verdict=row["verdict"],
407
  claim=claim_str,
408
+ origin=row["origin"],
409
+ ingested_at=row["ingested_at"],
410
+ citation=row["citation"],
411
  )
412
  )
413
+ return RecentResponse(episodes=results, total=total)