flockmem 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. flockmem/__init__.py +5 -0
  2. flockmem/__main__.py +7 -0
  3. flockmem/api/redaction.py +58 -0
  4. flockmem/api/routes/chat.py +363 -0
  5. flockmem/api/routes/config_raw.py +78 -0
  6. flockmem/api/routes/conversation_meta.py +30 -0
  7. flockmem/api/routes/graph.py +58 -0
  8. flockmem/api/routes/health.py +10 -0
  9. flockmem/api/routes/ingest.py +239 -0
  10. flockmem/api/routes/memory.py +492 -0
  11. flockmem/api/routes/model_config.py +196 -0
  12. flockmem/api/routes/policy.py +63 -0
  13. flockmem/api/routes/status.py +19 -0
  14. flockmem/api/routes/ui.py +34 -0
  15. flockmem/api/security.py +50 -0
  16. flockmem/bootstrap/app_factory.py +239 -0
  17. flockmem/cli.py +83 -0
  18. flockmem/config/config_json.py +464 -0
  19. flockmem/config/openclaw_primary_sync.py +464 -0
  20. flockmem/config/profiles.py +91 -0
  21. flockmem/config/settings.py +395 -0
  22. flockmem/domain/policy.py +63 -0
  23. flockmem/domain/retrieval/__init__.py +41 -0
  24. flockmem/domain/retrieval/fusion.py +40 -0
  25. flockmem/infra/graph/kuzu_store.py +327 -0
  26. flockmem/infra/runtime_policy/__init__.py +1 -0
  27. flockmem/infra/runtime_policy/repository.py +57 -0
  28. flockmem/infra/sqlite/app_config_repository.py +41 -0
  29. flockmem/infra/sqlite/conversation_meta_repository.py +64 -0
  30. flockmem/infra/sqlite/db.py +43 -0
  31. flockmem/infra/sqlite/init_schema.py +522 -0
  32. flockmem/infra/sqlite/memory_repository.py +1964 -0
  33. flockmem/infra/sqlite/request_status_repository.py +79 -0
  34. flockmem/infra/vector/lancedb_store.py +592 -0
  35. flockmem/service/chat_model_rerank.py +137 -0
  36. flockmem/service/chat_responder.py +226 -0
  37. flockmem/service/embedding_factory.py +37 -0
  38. flockmem/service/event_log_extractor.py +74 -0
  39. flockmem/service/extractor.py +519 -0
  40. flockmem/service/extractor_factory.py +91 -0
  41. flockmem/service/foresight_extractor.py +251 -0
  42. flockmem/service/formation_enhancer.py +246 -0
  43. flockmem/service/graph_extractor.py +90 -0
  44. flockmem/service/http_auth.py +34 -0
  45. flockmem/service/local_embedding.py +65 -0
  46. flockmem/service/local_rerank.py +61 -0
  47. flockmem/service/memcell_extractor.py +98 -0
  48. flockmem/service/memory_service.py +4125 -0
  49. flockmem/service/openai_embedding.py +68 -0
  50. flockmem/service/openai_rerank.py +95 -0
  51. flockmem/service/policy_resolver.py +50 -0
  52. flockmem/service/query_rewriter.py +307 -0
  53. flockmem/service/rerank_factory.py +44 -0
  54. flockmem/service/retrieval_mode_selector.py +190 -0
  55. flockmem/service/retrieval_verifier.py +173 -0
  56. flockmem/service/semantic_consolidator.py +578 -0
  57. flockmem/testing/__init__.py +2 -0
  58. flockmem/testing/writable_tempdir.py +29 -0
  59. flockmem/ui/index.html +3250 -0
  60. flockmem-0.2.0.dist-info/METADATA +350 -0
  61. flockmem-0.2.0.dist-info/RECORD +65 -0
  62. flockmem-0.2.0.dist-info/WHEEL +5 -0
  63. flockmem-0.2.0.dist-info/entry_points.txt +3 -0
  64. flockmem-0.2.0.dist-info/licenses/LICENSE +21 -0
  65. flockmem-0.2.0.dist-info/top_level.txt +1 -0
flockmem/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.0"
flockmem/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from main import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ _SENSITIVE_FRAGMENTS = ("api_key", "token", "password", "secret")
6
+ REDACTED_LITERAL = "[REDACTED]"
7
+
8
+
9
+ def _is_sensitive_key(key: str) -> bool:
10
+ lowered = str(key or "").strip().lower()
11
+ return any(fragment in lowered for fragment in _SENSITIVE_FRAGMENTS)
12
+
13
+
14
+ def _mask_secret(value: Any) -> str:
15
+ raw = str(value or "").strip()
16
+ if not raw:
17
+ return ""
18
+ return REDACTED_LITERAL
19
+
20
+
21
+ def redact_sensitive(data: Any) -> Any:
22
+ if isinstance(data, dict):
23
+ out: dict[str, Any] = {}
24
+ for key, value in data.items():
25
+ if _is_sensitive_key(key):
26
+ out[str(key)] = _mask_secret(value)
27
+ continue
28
+ out[str(key)] = redact_sensitive(value)
29
+ return out
30
+ if isinstance(data, list):
31
+ return [redact_sensitive(item) for item in data]
32
+ return data
33
+
34
+
35
+ def is_redacted_literal(value: Any) -> bool:
36
+ return str(value or "").strip() == REDACTED_LITERAL
37
+
38
+
39
+ def restore_redacted(candidate: Any, baseline: Any) -> Any:
40
+ if isinstance(candidate, dict):
41
+ baseline_map = baseline if isinstance(baseline, dict) else {}
42
+ out: dict[str, Any] = {}
43
+ for key, value in candidate.items():
44
+ key_text = str(key)
45
+ base_value = baseline_map.get(key_text)
46
+ if _is_sensitive_key(key_text) and is_redacted_literal(value):
47
+ out[key_text] = base_value
48
+ continue
49
+ out[key_text] = restore_redacted(value, base_value)
50
+ return out
51
+ if isinstance(candidate, list):
52
+ baseline_list = baseline if isinstance(baseline, list) else []
53
+ out_list: list[Any] = []
54
+ for idx, item in enumerate(candidate):
55
+ base_item = baseline_list[idx] if idx < len(baseline_list) else None
56
+ out_list.append(restore_redacted(item, base_item))
57
+ return out_list
58
+ return candidate
@@ -0,0 +1,363 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ from datetime import datetime
6
+ from functools import partial
7
+ from typing import Any
8
+
9
+ import anyio
10
+ from fastapi import APIRouter, HTTPException, Request
11
+ from pydantic import BaseModel, Field
12
+
13
+ from flockmem.service.policy_resolver import ResolveInput
14
+ from flockmem.service.memory_service import ChatTurnInput
15
+
16
+ router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
17
+
18
+
19
+ class ChatRequest(BaseModel):
20
+ query: str = Field(min_length=1, max_length=8000)
21
+ user_id: str | None = None
22
+ group_id: str | None = None
23
+ top_k: int = Field(default=5, ge=1, le=30)
24
+ provider: str | None = None
25
+ conversation_id: str | None = None
26
+
27
+
28
+ _GREETING_PATTERNS = (
29
+ r"^\s*(hi|hello|hey|yo|hola|你好|您好|嗨|哈喽|在吗|在不在|早上好|下午好|晚上好)[!!,.。~\s]*$",
30
+ )
31
+ _MEMORY_HINT_TERMS = (
32
+ "记得",
33
+ "还记得",
34
+ "回忆",
35
+ "之前",
36
+ "上次",
37
+ "以前",
38
+ "历史",
39
+ "profile",
40
+ "memory",
41
+ "remember",
42
+ "before",
43
+ "previous",
44
+ "past",
45
+ "history",
46
+ "名字",
47
+ "姓名",
48
+ "我叫",
49
+ "叫什么",
50
+ "name",
51
+ )
52
+ _TOKEN_RE = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]{1,4}")
53
+ _IDENTITY_PATTERNS = (
54
+ r"我叫.*什么",
55
+ r"我的名字",
56
+ r"我是谁",
57
+ r"who am i",
58
+ r"what(?:'s| is) my name",
59
+ )
60
+ _AGE_QUERY_TERMS = ("几岁", "多大", "年龄", "age", "old")
61
+ _AGE_SUBJECT_TERMS = (
62
+ "我",
63
+ "我的",
64
+ "儿子",
65
+ "女儿",
66
+ "孩子",
67
+ "宝宝",
68
+ "son",
69
+ "daughter",
70
+ "child",
71
+ "kid",
72
+ )
73
+ _MIN_IMPORTANCE = 0.45
74
+ _MIN_RELEVANCE = 0.12
75
+ _MIN_COMBINED_SCORE = 0.36
76
+ _MAX_MEMORY_CONTEXT = 4
77
+
78
+
79
+ def _is_smalltalk(query: str) -> bool:
80
+ q = query.strip().lower()
81
+ if not q:
82
+ return True
83
+ if len(q) <= 6 and any(re.match(p, q, flags=re.IGNORECASE) for p in _GREETING_PATTERNS):
84
+ return True
85
+ return False
86
+
87
+
88
+ def _has_memory_hint(query: str) -> bool:
89
+ q = query.lower()
90
+ if any(term in q for term in _MEMORY_HINT_TERMS):
91
+ return True
92
+ return _is_identity_query(q) or _is_age_query(q)
93
+
94
+
95
+ def _is_identity_query(query: str) -> bool:
96
+ q = query.strip().lower()
97
+ if not q:
98
+ return False
99
+ return any(re.search(pattern, q, flags=re.IGNORECASE) for pattern in _IDENTITY_PATTERNS)
100
+
101
+
102
+ def _is_age_query(query: str) -> bool:
103
+ q = query.strip().lower()
104
+ if not q:
105
+ return False
106
+ return any(term in q for term in _AGE_QUERY_TERMS) and any(
107
+ term in q for term in _AGE_SUBJECT_TERMS
108
+ )
109
+
110
+
111
+ def _query_tokens(query: str) -> set[str]:
112
+ q = query.lower()
113
+ tokens = {tok for tok in _TOKEN_RE.findall(q) if tok.strip()}
114
+ chars = re.findall(r"[\u4e00-\u9fff]", q)
115
+ for i in range(len(chars) - 1):
116
+ tokens.add(chars[i] + chars[i + 1])
117
+ if chars:
118
+ tokens.add("".join(chars))
119
+ stop_words = {"我", "你", "他", "她", "它", "吗", "呢", "啊", "呀", "的", "了"}
120
+ return {tok for tok in tokens if tok and tok not in stop_words}
121
+
122
+
123
+ def _row_text(row: dict[str, Any]) -> str:
124
+ return " ".join(
125
+ [
126
+ str(row.get("summary") or ""),
127
+ str(row.get("episode") or ""),
128
+ str(row.get("subject") or ""),
129
+ str(row.get("atomic_fact_text") or ""),
130
+ ]
131
+ ).lower()
132
+
133
+
134
+ def _lexical_relevance(query: str, row: dict[str, Any]) -> float:
135
+ text = _row_text(row)
136
+ if not text:
137
+ return 0.0
138
+ q = query.lower().strip()
139
+ if q and q in text:
140
+ return 1.0
141
+ tokens = _query_tokens(query)
142
+ if not tokens:
143
+ return 0.0
144
+ hits = sum(1 for token in tokens if token in text)
145
+ return float(hits) / float(max(1, len(tokens)))
146
+
147
+
148
+ def _identity_relevance(row: dict[str, Any]) -> float:
149
+ text = _row_text(row)
150
+ if not text:
151
+ return 0.0
152
+ indicators = ("我叫", "名字", "姓名", "name_is", "name")
153
+ if any(token in text for token in indicators):
154
+ return 1.0
155
+ return 0.0
156
+
157
+
158
+ def _age_relevance(row: dict[str, Any]) -> float:
159
+ text = _row_text(row)
160
+ if not text:
161
+ return 0.0
162
+ has_age = bool(re.search(r"(\d{1,3}\s*岁|age_is|年龄)", text))
163
+ has_family = any(
164
+ token in text
165
+ for token in ("儿子", "女儿", "孩子", "has_son", "has_daughter", "has_child")
166
+ )
167
+ if has_age and has_family:
168
+ return 1.0
169
+ if has_age:
170
+ return 0.75
171
+ if has_family:
172
+ return 0.55
173
+ return 0.0
174
+
175
+
176
+ def _importance_score(row: dict[str, Any]) -> float:
177
+ try:
178
+ return float(row.get("importance_score", 0.0))
179
+ except Exception:
180
+ return 0.0
181
+
182
+
183
+ def _filter_memories_for_prompt(query: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
184
+ if not rows:
185
+ return []
186
+ has_hint = _has_memory_hint(query)
187
+ is_identity = _is_identity_query(query)
188
+ is_age = _is_age_query(query)
189
+ if _is_smalltalk(query) and not has_hint:
190
+ return []
191
+ if is_identity or is_age:
192
+ min_importance = 0.05
193
+ min_relevance = 0.0
194
+ min_combined = 0.05
195
+ else:
196
+ min_importance = 0.30 if has_hint else _MIN_IMPORTANCE
197
+ min_relevance = 0.05 if has_hint else _MIN_RELEVANCE
198
+ min_combined = 0.24 if has_hint else _MIN_COMBINED_SCORE
199
+
200
+ picked: list[tuple[float, dict[str, Any]]] = []
201
+ for row in rows:
202
+ imp = _importance_score(row)
203
+ rel = _lexical_relevance(query, row)
204
+ if is_identity:
205
+ rel = max(rel, _identity_relevance(row))
206
+ if is_age:
207
+ rel = max(rel, _age_relevance(row))
208
+ combined = imp * 0.65 + rel * 0.35
209
+ if imp < min_importance and rel < min_relevance:
210
+ continue
211
+ if combined < min_combined:
212
+ continue
213
+ picked.append((combined, row))
214
+ picked.sort(
215
+ key=lambda item: (
216
+ item[0],
217
+ _importance_score(item[1]),
218
+ float(item[1].get("timestamp", 0.0)),
219
+ ),
220
+ reverse=True,
221
+ )
222
+ if picked:
223
+ return [row for _, row in picked[:_MAX_MEMORY_CONTEXT]]
224
+ if has_hint:
225
+ # For explicit memory-intent queries, provide a small fallback context.
226
+ by_importance = sorted(
227
+ rows,
228
+ key=lambda row: (
229
+ _identity_relevance(row),
230
+ _age_relevance(row),
231
+ _importance_score(row),
232
+ ),
233
+ reverse=True,
234
+ )
235
+ return by_importance[: min(2, _MAX_MEMORY_CONTEXT)]
236
+ return []
237
+
238
+
239
+ @router.post("/simple")
240
+ async def simple_chat(payload: ChatRequest, request: Request) -> dict:
241
+ settings = request.app.state.settings
242
+ resolver = request.app.state.policy_resolver
243
+ memory_service = request.app.state.memory_service
244
+ chat_responder = request.app.state.chat_responder
245
+ model_config = request.app.state.runtime_model_config
246
+
247
+ effective = resolver.resolve(
248
+ ResolveInput(default_profile=settings.retrieval_profile, tenant_id="default")
249
+ )
250
+ raw_memories: list[dict[str, Any]] = []
251
+ if not _is_smalltalk(payload.query) or _has_memory_hint(payload.query):
252
+ raw_memories = memory_service.search(
253
+ policy=effective,
254
+ query=payload.query,
255
+ user_id=payload.user_id,
256
+ group_id=payload.group_id,
257
+ top_k=payload.top_k,
258
+ )
259
+ live_segment_memories: list[dict[str, Any]] = []
260
+ if payload.conversation_id:
261
+ live_segment_memories = memory_service.retrieve_live_segment_context(
262
+ conversation_id=str(payload.conversation_id),
263
+ query=payload.query,
264
+ limit=min(2, payload.top_k),
265
+ )
266
+ if live_segment_memories:
267
+ dedup: set[str] = set()
268
+ merged: list[dict[str, Any]] = []
269
+ for row in [*live_segment_memories, *raw_memories]:
270
+ key = str(row.get("id") or row.get("event_id") or row.get("summary") or row.get("episode"))
271
+ if not key or key in dedup:
272
+ continue
273
+ dedup.add(key)
274
+ merged.append(row)
275
+ raw_memories = merged
276
+ memories = _filter_memories_for_prompt(payload.query, raw_memories)
277
+ provider_options = (
278
+ model_config.get("chat_provider_options")
279
+ if isinstance(model_config.get("chat_provider_options"), dict)
280
+ else {}
281
+ )
282
+ default_provider = (
283
+ str(model_config.get("chat_provider") or "openai").strip() or "openai"
284
+ )
285
+ active_provider = str(payload.provider or default_provider).strip() or default_provider
286
+ if provider_options and active_provider not in provider_options:
287
+ if default_provider in provider_options:
288
+ active_provider = default_provider
289
+ else:
290
+ active_provider = next(iter(provider_options.keys()), "openai")
291
+ try:
292
+ response = chat_responder.respond(
293
+ query=payload.query,
294
+ memories=memories,
295
+ system_time=datetime.now().astimezone(),
296
+ provider=active_provider,
297
+ provider_options=provider_options,
298
+ model=str(model_config.get("chat_model", "")),
299
+ )
300
+ except ValueError as exc:
301
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
302
+ except RuntimeError as exc:
303
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
304
+ auto_memory_saved = False
305
+ auto_memory_error: str | None = None
306
+ boundary_detected = False
307
+ user_id = (payload.user_id or "").strip() or "anonymous"
308
+ group_id = (payload.group_id or "").strip() or f"default:{user_id}"
309
+ conversation_id = (
310
+ str(payload.conversation_id or "").strip() or f"session-{user_id}-{group_id}"
311
+ )
312
+ try:
313
+ segment_result = await anyio.to_thread.run_sync(
314
+ partial(
315
+ memory_service.append_chat_turn,
316
+ payload=ChatTurnInput(
317
+ conversation_id=conversation_id,
318
+ user_id=user_id,
319
+ group_id=group_id,
320
+ user_text=payload.query,
321
+ assistant_text=str(response.get("answer", "")),
322
+ timestamp=int(time.time()),
323
+ ),
324
+ policy=effective,
325
+ )
326
+ )
327
+ auto_memory_saved = bool(segment_result.get("memory_saved"))
328
+ auto_memory_error = segment_result.get("memory_error")
329
+ boundary_detected = bool(segment_result.get("boundary_detected"))
330
+ except Exception as exc: # best effort: chat should not fail because of memory persistence
331
+ auto_memory_error = str(exc)
332
+
333
+ title = payload.query.strip().replace("\n", " ")[:80] or "New Chat"
334
+ try:
335
+ request.app.state.conversation_meta_repo.upsert(
336
+ user_id=user_id,
337
+ group_id=group_id,
338
+ title=title,
339
+ conversation_id=conversation_id,
340
+ )
341
+ except Exception:
342
+ pass
343
+ return {
344
+ "status": "ok",
345
+ "result": {
346
+ "answer": response["answer"],
347
+ "citations": response["citations"],
348
+ "provider": response.get("provider", active_provider),
349
+ "model": response["model"],
350
+ "effective_policy": effective.to_dict(),
351
+ "memory_filter": {
352
+ "retrieved_count": len(raw_memories),
353
+ "used_count": len(memories),
354
+ "smalltalk_bypass": _is_smalltalk(payload.query) and not _has_memory_hint(payload.query),
355
+ "live_segment_count": len(live_segment_memories),
356
+ },
357
+ "memory_saved": auto_memory_saved,
358
+ "memory_error": auto_memory_error,
359
+ "boundary_detected": boundary_detected,
360
+ "conversation_id": conversation_id,
361
+ },
362
+ }
363
+
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Depends, Request
6
+ from pydantic import BaseModel
7
+
8
+ from flockmem.api.redaction import redact_sensitive, restore_redacted
9
+ from flockmem.api.security import require_admin_access
10
+ from flockmem.service.extractor_factory import build_memory_extractor
11
+
12
+ router = APIRouter(
13
+ prefix="/api/v1/config",
14
+ tags=["config"],
15
+ dependencies=[Depends(require_admin_access)],
16
+ )
17
+
18
+
19
+ class RawConfigPatch(BaseModel):
20
+ config: dict[str, Any]
21
+
22
+
23
+ @router.get("/raw")
24
+ async def get_raw_config(request: Request) -> dict[str, Any]:
25
+ config_repo = request.app.state.config_repo
26
+ payload = config_repo.get_raw_config(request.app.state.settings)
27
+ return {
28
+ "status": "ok",
29
+ "result": {
30
+ "config": redact_sensitive(payload),
31
+ "path": str(config_repo.config_path),
32
+ },
33
+ }
34
+
35
+
36
+ @router.put("/raw")
37
+ async def put_raw_config(request: Request, payload: RawConfigPatch) -> dict[str, Any]:
38
+ config_repo = request.app.state.config_repo
39
+ old_payload = config_repo.get_raw_config(request.app.state.settings)
40
+ merged_payload = restore_redacted(payload.config, old_payload)
41
+ updated_payload = config_repo.replace_raw_config(
42
+ bootstrap_settings=request.app.state.settings,
43
+ payload=merged_payload,
44
+ )
45
+ runtime_model_config = config_repo.get_runtime_model_config(request.app.state.settings)
46
+ request.app.state.runtime_model_config.clear()
47
+ request.app.state.runtime_model_config.update(runtime_model_config)
48
+ request.app.state.chat_responder.base_url = str(
49
+ runtime_model_config.get("chat_base_url", "")
50
+ )
51
+ request.app.state.chat_responder.api_key = str(
52
+ runtime_model_config.get("chat_api_key", "")
53
+ )
54
+ request.app.state.chat_responder.model = str(
55
+ runtime_model_config.get("chat_model", "")
56
+ )
57
+ request.app.state.chat_responder.provider = str(
58
+ runtime_model_config.get("chat_provider", "openai")
59
+ )
60
+ request.app.state.memory_service.extractor = build_memory_extractor(
61
+ settings=request.app.state.settings,
62
+ runtime_model_config=request.app.state.runtime_model_config,
63
+ )
64
+ old_settings = old_payload.get("settings") if isinstance(old_payload, dict) else {}
65
+ new_settings = (
66
+ updated_payload.get("settings") if isinstance(updated_payload, dict) else {}
67
+ )
68
+ restart_required = old_settings != new_settings
69
+ return {
70
+ "status": "ok",
71
+ "result": {
72
+ "saved": True,
73
+ "restart_required": bool(restart_required),
74
+ "path": str(config_repo.config_path),
75
+ "config": redact_sensitive(updated_payload),
76
+ },
77
+ }
78
+
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Request
4
+ from pydantic import BaseModel, Field
5
+
6
+ router = APIRouter(prefix="/api/v1/conversation-meta", tags=["conversation-meta"])
7
+
8
+
9
+ class ConversationMetaPatch(BaseModel):
10
+ user_id: str = Field(min_length=1, max_length=128)
11
+ group_id: str | None = None
12
+ title: str = Field(min_length=1, max_length=200)
13
+ conversation_id: str | None = None
14
+
15
+
16
+ @router.get("")
17
+ async def list_conversations(request: Request, user_id: str, group_id: str | None = None) -> dict:
18
+ rows = request.app.state.conversation_meta_repo.list_by_user(user_id=user_id, group_id=group_id)
19
+ return {"status": "ok", "result": {"items": rows, "total_count": len(rows)}}
20
+
21
+
22
+ @router.put("")
23
+ async def upsert_conversation(request: Request, payload: ConversationMetaPatch) -> dict:
24
+ row = request.app.state.conversation_meta_repo.upsert(
25
+ user_id=payload.user_id,
26
+ group_id=payload.group_id,
27
+ title=payload.title,
28
+ conversation_id=payload.conversation_id,
29
+ )
30
+ return {"status": "ok", "result": row}
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import partial
4
+
5
+ import anyio
6
+ from fastapi import APIRouter, HTTPException, Request
7
+
8
+ router = APIRouter(prefix="/api/v1/graph", tags=["graph"])
9
+
10
+
11
+ @router.get("/search")
12
+ async def search_graph(
13
+ request: Request,
14
+ query: str,
15
+ user_id: str | None = None,
16
+ group_id: str | None = None,
17
+ limit: int = 10,
18
+ ) -> dict:
19
+ q = query.strip()
20
+ if not q:
21
+ raise HTTPException(status_code=400, detail="query must not be blank")
22
+ if limit < 1 or limit > 100:
23
+ raise HTTPException(status_code=400, detail="limit out of range")
24
+ rows = await anyio.to_thread.run_sync(
25
+ partial(
26
+ request.app.state.memory_service.search_graph,
27
+ query=q,
28
+ user_id=user_id,
29
+ group_id=group_id,
30
+ limit=limit,
31
+ )
32
+ )
33
+ return {"status": "ok", "result": {"items": rows, "total_count": len(rows)}}
34
+
35
+
36
+ @router.get("/neighbors")
37
+ async def graph_neighbors(
38
+ request: Request,
39
+ entity: str,
40
+ user_id: str | None = None,
41
+ group_id: str | None = None,
42
+ limit: int = 20,
43
+ ) -> dict:
44
+ name = entity.strip()
45
+ if not name:
46
+ raise HTTPException(status_code=400, detail="entity must not be blank")
47
+ if limit < 1 or limit > 200:
48
+ raise HTTPException(status_code=400, detail="limit out of range")
49
+ rows = await anyio.to_thread.run_sync(
50
+ partial(
51
+ request.app.state.memory_service.graph_neighbors,
52
+ entity_name=name,
53
+ user_id=user_id,
54
+ group_id=group_id,
55
+ limit=limit,
56
+ )
57
+ )
58
+ return {"status": "ok", "result": {"items": rows, "total_count": len(rows)}}
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ router = APIRouter(tags=["health"])
6
+
7
+
8
+ @router.get("/health")
9
+ async def health() -> dict[str, str]:
10
+ return {"status": "ok"}