ltcai 0.1.30 → 0.2.0

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.
@@ -0,0 +1,319 @@
1
+ """Local folder knowledge-source API and optional filesystem watcher."""
2
+
3
+ import logging
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Dict, Optional
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class LocalTreeRequest(BaseModel):
13
+ path: str
14
+ max_items: int = 200
15
+ approved: bool = False
16
+ approval_token: Optional[str] = None
17
+
18
+
19
+ class LocalKnowledgeAuditRequest(BaseModel):
20
+ path: str
21
+ include_ocr: bool = False
22
+ max_files: int = 50_000
23
+ approved: bool = False
24
+ approval_token: Optional[str] = None
25
+
26
+
27
+ class LocalKnowledgeIndexRequest(BaseModel):
28
+ path: str
29
+ include_ocr: bool = False
30
+ watch_enabled: bool = False
31
+ max_files: int = 5_000
32
+ consent: Dict[str, Any] = Field(default_factory=dict)
33
+ approved: bool = False
34
+ approval_token: Optional[str] = None
35
+
36
+
37
+ class LocalKnowledgeWatchRequest(BaseModel):
38
+ source_id: str
39
+
40
+
41
+ class _LocalWatchHandler:
42
+ def __init__(self, schedule_change: Callable[[], None]):
43
+ self._schedule_change = schedule_change
44
+
45
+ def on_any_event(self, event): # pragma: no cover - exercised by OS watcher
46
+ if getattr(event, "is_directory", False):
47
+ return
48
+ self._schedule_change()
49
+
50
+
51
+ class LocalKnowledgeWatcher:
52
+ """Debounced watchdog wrapper for approved local knowledge sources."""
53
+
54
+ def __init__(self, get_graph: Callable[[], Any], *, debounce_seconds: float = 5.0):
55
+ self._get_graph = get_graph
56
+ self._debounce_seconds = debounce_seconds
57
+ self._lock = threading.Lock()
58
+ self._watched: Dict[str, Dict[str, Any]] = {}
59
+ self._observer_cls = None
60
+ self._event_handler_base = None
61
+ self._import_error = ""
62
+ try:
63
+ from watchdog.events import FileSystemEventHandler
64
+ from watchdog.observers import Observer
65
+
66
+ self._observer_cls = Observer
67
+ self._event_handler_base = FileSystemEventHandler
68
+ except Exception as exc: # pragma: no cover - depends on optional dependency
69
+ self._import_error = str(exc)
70
+
71
+ @property
72
+ def available(self) -> bool:
73
+ return self._observer_cls is not None and self._event_handler_base is not None
74
+
75
+ def status(self) -> Dict[str, Any]:
76
+ with self._lock:
77
+ active = {
78
+ source_id: {
79
+ "root_path": item["source"].get("root_path"),
80
+ "last_event_at": item.get("last_event_at"),
81
+ "last_indexed_at": item.get("last_indexed_at"),
82
+ "last_error": item.get("last_error"),
83
+ }
84
+ for source_id, item in self._watched.items()
85
+ }
86
+ return {
87
+ "available": self.available,
88
+ "error": "" if self.available else self._import_error or "watchdog is not installed",
89
+ "debounce_seconds": self._debounce_seconds,
90
+ "active": active,
91
+ }
92
+
93
+ def restore_enabled_sources(self) -> Dict[str, Any]:
94
+ graph = self._get_graph()
95
+ if graph is None:
96
+ return {"restored": 0, "available": self.available}
97
+ restored = 0
98
+ try:
99
+ for source in graph.local_sources().get("sources", []):
100
+ if source.get("watch_enabled"):
101
+ result = self.start_source(source)
102
+ if result.get("watching"):
103
+ restored += 1
104
+ except Exception as exc:
105
+ logging.warning("local knowledge watcher restore failed: %s", exc)
106
+ return {"restored": restored, "available": self.available}
107
+
108
+ def start_source(self, source: Dict[str, Any]) -> Dict[str, Any]:
109
+ source_id = str(source.get("id") or "")
110
+ root_path = str(source.get("root_path") or "")
111
+ if not source_id or not root_path:
112
+ return {"watching": False, "error": "source_id and root_path are required"}
113
+ if not self.available:
114
+ return {"watching": False, "source_id": source_id, "error": self._import_error or "watchdog is not installed"}
115
+ root = Path(root_path).expanduser().resolve()
116
+ if not root.exists() or not root.is_dir():
117
+ return {"watching": False, "source_id": source_id, "error": "source folder is not available"}
118
+
119
+ self.stop_source(source_id)
120
+
121
+ class Handler(_LocalWatchHandler, self._event_handler_base): # type: ignore[misc, valid-type]
122
+ def __init__(handler_self):
123
+ self._event_handler_base.__init__(handler_self)
124
+ _LocalWatchHandler.__init__(handler_self, lambda: self._schedule(source_id))
125
+
126
+ observer = self._observer_cls()
127
+ try:
128
+ observer.schedule(Handler(), str(root), recursive=True)
129
+ observer.start()
130
+ except Exception as exc:
131
+ logging.warning("local knowledge watcher start failed for %s: %s", root, exc)
132
+ return {"watching": False, "source_id": source_id, "error": str(exc)}
133
+
134
+ with self._lock:
135
+ self._watched[source_id] = {
136
+ "observer": observer,
137
+ "timer": None,
138
+ "source": dict(source),
139
+ "last_event_at": None,
140
+ "last_indexed_at": None,
141
+ "last_error": None,
142
+ }
143
+ return {"watching": True, "source_id": source_id, "root_path": str(root)}
144
+
145
+ def stop_source(self, source_id: str) -> Dict[str, Any]:
146
+ with self._lock:
147
+ item = self._watched.pop(source_id, None)
148
+ if not item:
149
+ return {"stopped": False, "source_id": source_id}
150
+ timer = item.get("timer")
151
+ if timer:
152
+ timer.cancel()
153
+ observer = item.get("observer")
154
+ try:
155
+ observer.stop()
156
+ observer.join(timeout=3)
157
+ except Exception as exc:
158
+ logging.warning("local knowledge watcher stop failed for %s: %s", source_id, exc)
159
+ return {"stopped": True, "source_id": source_id}
160
+
161
+ def stop_all(self) -> None:
162
+ for source_id in list(self.status().get("active", {}).keys()):
163
+ self.stop_source(source_id)
164
+
165
+ def _schedule(self, source_id: str) -> None:
166
+ with self._lock:
167
+ item = self._watched.get(source_id)
168
+ if not item:
169
+ return
170
+ timer = item.get("timer")
171
+ if timer:
172
+ timer.cancel()
173
+ item["last_event_at"] = _now_seconds()
174
+ timer = threading.Timer(self._debounce_seconds, self._run_index, args=(source_id,))
175
+ timer.daemon = True
176
+ item["timer"] = timer
177
+ timer.start()
178
+
179
+ def _run_index(self, source_id: str) -> None:
180
+ with self._lock:
181
+ item = self._watched.get(source_id)
182
+ if not item:
183
+ return
184
+ source = dict(item["source"])
185
+ item["timer"] = None
186
+ graph = self._get_graph()
187
+ if graph is None:
188
+ return
189
+ consent = source.get("consent") or {}
190
+ try:
191
+ graph.index_local_folder(
192
+ Path(source["root_path"]),
193
+ include_ocr=bool(source.get("include_ocr")),
194
+ watch_enabled=True,
195
+ user_email=consent.get("approved_by"),
196
+ consent=consent,
197
+ )
198
+ with self._lock:
199
+ if source_id in self._watched:
200
+ self._watched[source_id]["last_indexed_at"] = _now_seconds()
201
+ self._watched[source_id]["last_error"] = None
202
+ except Exception as exc:
203
+ logging.warning("local knowledge watcher reindex failed for %s: %s", source_id, exc)
204
+ with self._lock:
205
+ if source_id in self._watched:
206
+ self._watched[source_id]["last_error"] = str(exc)
207
+
208
+
209
+ def _now_seconds() -> float:
210
+ import time
211
+
212
+ return time.time()
213
+
214
+
215
+ def create_local_knowledge_router(
216
+ *,
217
+ get_graph: Callable[[], Any],
218
+ require_graph: Callable[[], None],
219
+ require_user: Callable[[Request], str],
220
+ require_local_user: Callable[[Request], str],
221
+ local_permission_response: Callable[..., dict],
222
+ require_local_approval: Callable[..., None],
223
+ watcher: Optional[LocalKnowledgeWatcher] = None,
224
+ ) -> APIRouter:
225
+ router = APIRouter()
226
+
227
+ def graph():
228
+ require_graph()
229
+ return get_graph()
230
+
231
+ @router.get("/knowledge-graph/local/roots")
232
+ async def knowledge_graph_local_roots(request: Request):
233
+ require_user(request)
234
+ return graph().discover_local_roots()
235
+
236
+ @router.get("/knowledge-graph/local/sources")
237
+ async def knowledge_graph_local_sources(request: Request):
238
+ require_user(request)
239
+ payload = graph().local_sources()
240
+ watch_status = watcher.status() if watcher else {"available": False, "active": {}}
241
+ active = watch_status.get("active", {})
242
+ for source in payload.get("sources", []):
243
+ source["watch_active"] = source.get("id") in active
244
+ source["watch_status"] = active.get(source.get("id"))
245
+ payload["watch"] = watch_status
246
+ return payload
247
+
248
+ @router.get("/knowledge-graph/local/watch/status")
249
+ async def knowledge_graph_local_watch_status(request: Request):
250
+ require_user(request)
251
+ graph()
252
+ return watcher.status() if watcher else {"available": False, "active": {}, "error": "watcher unavailable"}
253
+
254
+ @router.post("/knowledge-graph/local/watch/stop")
255
+ async def knowledge_graph_local_watch_stop(req: LocalKnowledgeWatchRequest, request: Request):
256
+ require_user(request)
257
+ kg = graph()
258
+ try:
259
+ kg.set_local_source_watch(req.source_id, False)
260
+ except ValueError as exc:
261
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
262
+ result = watcher.stop_source(req.source_id) if watcher else {"stopped": False, "source_id": req.source_id}
263
+ return {"status": "ok", "watch": result}
264
+
265
+ @router.post("/knowledge-graph/local/tree")
266
+ async def knowledge_graph_local_tree(req: LocalTreeRequest, request: Request):
267
+ current_user = require_local_user(request)
268
+ kg = graph()
269
+ if not req.approved:
270
+ return local_permission_response(req.path, "list", current_user)
271
+ require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
272
+ try:
273
+ return kg.preview_local_tree(Path(req.path), max_items=req.max_items)
274
+ except ValueError as exc:
275
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
276
+
277
+ @router.post("/knowledge-graph/local/audit")
278
+ async def knowledge_graph_local_audit(req: LocalKnowledgeAuditRequest, request: Request):
279
+ current_user = require_local_user(request)
280
+ kg = graph()
281
+ if not req.approved:
282
+ return local_permission_response(req.path, "list", current_user)
283
+ require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
284
+ try:
285
+ return kg.audit_local_folder(Path(req.path), include_ocr=req.include_ocr, max_files=req.max_files)
286
+ except ValueError as exc:
287
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
288
+
289
+ @router.post("/knowledge-graph/local/index")
290
+ async def knowledge_graph_local_index(req: LocalKnowledgeIndexRequest, request: Request):
291
+ current_user = require_local_user(request)
292
+ kg = graph()
293
+ if not req.approved:
294
+ return local_permission_response(req.path, "read", current_user)
295
+ require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
296
+ try:
297
+ result = kg.index_local_folder(
298
+ Path(req.path),
299
+ include_ocr=req.include_ocr,
300
+ watch_enabled=req.watch_enabled,
301
+ user_email=current_user,
302
+ consent=req.consent or {},
303
+ max_files=req.max_files,
304
+ )
305
+ except ValueError as exc:
306
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
307
+
308
+ if watcher:
309
+ if req.watch_enabled:
310
+ source_payload = {
311
+ **result.get("source", {}),
312
+ "consent": {"approved_by": current_user, **(req.consent or {})},
313
+ }
314
+ result["watch"] = watcher.start_source(source_payload)
315
+ else:
316
+ result["watch"] = watcher.stop_source(result.get("source", {}).get("id", ""))
317
+ return result
318
+
319
+ return router
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.30",
3
+ "version": "0.2.0",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -18,7 +18,7 @@
18
18
  "start": "LTCAI",
19
19
  "dev": "python3 ltcai_cli.py --reload",
20
20
  "build:python": "python3 -m build",
21
- "check:python": "python3 -m py_compile ltcai_cli.py server.py knowledge_graph.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
21
+ "check:python": "python3 -m py_compile ltcai_cli.py server.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
22
22
  "test": "python3 -m pytest tests/ -v",
23
23
  "test:unit": "python3 -m pytest tests/unit/ -v",
24
24
  "test:integration": "python3 -m pytest tests/integration/ -v",
@@ -46,12 +46,15 @@
46
46
  "server.py",
47
47
  "kg_schema.py",
48
48
  "knowledge_graph.py",
49
+ "knowledge_graph_api.py",
50
+ "local_knowledge_api.py",
49
51
  "llm_router.py",
50
52
  "p_reinforce.py",
51
53
  "telegram_bot.py",
52
54
  "tools.py",
53
55
  "codex_telegram_bot.py",
54
56
  "mcp_registry.py",
57
+ "latticeai/",
55
58
  "skills/",
56
59
  "static/account.html",
57
60
  "static/chat.html",
package/requirements.txt CHANGED
@@ -11,4 +11,5 @@ python-multipart
11
11
  keyring
12
12
  authlib
13
13
  pdfplumber
14
- pymupdf
14
+ pypdfium2
15
+ watchdog