ltcai 0.1.29 → 0.1.31
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.
- package/README.md +54 -24
- package/auto_setup.py +279 -55
- package/docs/CHANGELOG.md +52 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/knowledge_graph.py +1338 -3
- package/knowledge_graph_api.py +112 -0
- package/llm_router.py +15 -9
- package/local_knowledge_api.py +319 -0
- package/mcp_registry.py +791 -0
- package/package.json +5 -2
- package/requirements.txt +2 -0
- package/server.py +209 -965
- package/static/graph.html +7 -2
- package/static/lattice-reference.css +220 -0
- package/static/scripts/graph.js +305 -4
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Knowledge graph page and API routes."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Callable, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
7
|
+
from fastapi.responses import FileResponse
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KnowledgeGraphIngestRequest(BaseModel):
|
|
12
|
+
type: str
|
|
13
|
+
content: str = ""
|
|
14
|
+
role: Optional[str] = None
|
|
15
|
+
title: Optional[str] = None
|
|
16
|
+
source: Optional[str] = None
|
|
17
|
+
conversation_id: Optional[str] = None
|
|
18
|
+
user_email: Optional[str] = None
|
|
19
|
+
user_nickname: Optional[str] = None
|
|
20
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_knowledge_graph_router(
|
|
24
|
+
*,
|
|
25
|
+
get_graph: Callable[[], Any],
|
|
26
|
+
require_graph: Callable[[], None],
|
|
27
|
+
require_user: Callable[[Request], str],
|
|
28
|
+
static_dir: Path,
|
|
29
|
+
) -> APIRouter:
|
|
30
|
+
router = APIRouter()
|
|
31
|
+
|
|
32
|
+
def graph():
|
|
33
|
+
require_graph()
|
|
34
|
+
return get_graph()
|
|
35
|
+
|
|
36
|
+
@router.get("/graph")
|
|
37
|
+
async def knowledge_graph_page(request: Request):
|
|
38
|
+
"""Serve the interactive knowledge graph canvas UI."""
|
|
39
|
+
graph()
|
|
40
|
+
require_user(request)
|
|
41
|
+
return FileResponse(static_dir / "graph.html")
|
|
42
|
+
|
|
43
|
+
@router.get("/knowledge-graph")
|
|
44
|
+
async def knowledge_graph_legacy_page(request: Request):
|
|
45
|
+
"""Backward-compatible route for the graph page."""
|
|
46
|
+
graph()
|
|
47
|
+
require_user(request)
|
|
48
|
+
return FileResponse(static_dir / "graph.html")
|
|
49
|
+
|
|
50
|
+
@router.get("/knowledge-graph/stats")
|
|
51
|
+
async def knowledge_graph_stats(request: Request):
|
|
52
|
+
require_user(request)
|
|
53
|
+
return graph().stats()
|
|
54
|
+
|
|
55
|
+
@router.get("/knowledge-graph/schema")
|
|
56
|
+
async def knowledge_graph_schema(request: Request):
|
|
57
|
+
require_user(request)
|
|
58
|
+
stats = graph().stats()
|
|
59
|
+
return {
|
|
60
|
+
"legacy_schema_version": stats.get("schema_version"),
|
|
61
|
+
"v2_schema_available": stats.get("v2_schema_available"),
|
|
62
|
+
"v2": stats.get("v2"),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@router.get("/knowledge-graph/graph")
|
|
66
|
+
async def knowledge_graph_data(request: Request, limit: int = 300):
|
|
67
|
+
require_user(request)
|
|
68
|
+
return graph().graph(limit)
|
|
69
|
+
|
|
70
|
+
@router.get("/knowledge-graph/search")
|
|
71
|
+
async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
|
|
72
|
+
require_user(request)
|
|
73
|
+
if not q or not q.strip():
|
|
74
|
+
return {"query": q, "matches": []}
|
|
75
|
+
return graph().search(q, limit)
|
|
76
|
+
|
|
77
|
+
@router.get("/knowledge-graph/context")
|
|
78
|
+
async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
|
|
79
|
+
require_user(request)
|
|
80
|
+
return {"query": q, "context": graph().context_for_query(q, limit)}
|
|
81
|
+
|
|
82
|
+
@router.get("/knowledge-graph/neighbors/{node_id:path}")
|
|
83
|
+
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
84
|
+
require_user(request)
|
|
85
|
+
if not node_id:
|
|
86
|
+
raise HTTPException(status_code=400, detail="node_id required")
|
|
87
|
+
return graph().neighbors(node_id)
|
|
88
|
+
|
|
89
|
+
@router.post("/knowledge-graph/ingest")
|
|
90
|
+
async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
|
|
91
|
+
current_user = require_user(request)
|
|
92
|
+
kg = graph()
|
|
93
|
+
event_type = (req.type or "").strip().lower()
|
|
94
|
+
if event_type not in {"message", "ai_response", "note"}:
|
|
95
|
+
raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
|
|
96
|
+
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
97
|
+
return kg.ingest_message(
|
|
98
|
+
role,
|
|
99
|
+
req.content,
|
|
100
|
+
user_email=req.user_email or current_user,
|
|
101
|
+
user_nickname=req.user_nickname,
|
|
102
|
+
source=req.source or "mcp",
|
|
103
|
+
conversation_id=req.conversation_id,
|
|
104
|
+
raw={
|
|
105
|
+
"type": req.type,
|
|
106
|
+
"title": req.title,
|
|
107
|
+
"content": req.content,
|
|
108
|
+
"metadata": req.metadata or {},
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return router
|
package/llm_router.py
CHANGED
|
@@ -100,7 +100,7 @@ OPENAI_COMPATIBLE_PROVIDERS = {
|
|
|
100
100
|
"env_key": "VLLM_API_KEY",
|
|
101
101
|
"base_url_env": "VLLM_BASE_URL",
|
|
102
102
|
"base_url": "http://localhost:8000/v1",
|
|
103
|
-
"default_model": "
|
|
103
|
+
"default_model": "meta-llama/Llama-3.1-8B-Instruct",
|
|
104
104
|
"api_key_fallback": "vllm",
|
|
105
105
|
},
|
|
106
106
|
"lmstudio": {
|
|
@@ -121,29 +121,35 @@ OPENAI_COMPATIBLE_PROVIDERS = {
|
|
|
121
121
|
|
|
122
122
|
PROVIDER_MODEL_CATALOG = {
|
|
123
123
|
"openai": [
|
|
124
|
+
{"id": "gpt-5.5", "name": "GPT-5.5", "family": "GPT"},
|
|
125
|
+
{"id": "gpt-5.4", "name": "GPT-5.4", "family": "GPT"},
|
|
126
|
+
{"id": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "family": "GPT"},
|
|
127
|
+
{"id": "gpt-5.4-nano", "name": "GPT-5.4 Nano", "family": "GPT"},
|
|
124
128
|
{"id": "gpt-4o-mini", "name": "GPT-4o Mini", "family": "GPT"},
|
|
125
129
|
{"id": "gpt-4o", "name": "GPT-4o", "family": "GPT"},
|
|
126
130
|
{"id": "gpt-4.1-mini", "name": "GPT-4.1 Mini", "family": "GPT"},
|
|
127
131
|
{"id": "gpt-4.1", "name": "GPT-4.1", "family": "GPT"},
|
|
128
132
|
],
|
|
129
133
|
"openrouter": [
|
|
134
|
+
{"id": "openai/gpt-5.5", "name": "GPT-5.5 via OpenRouter", "family": "GPT"},
|
|
130
135
|
{"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini via OpenRouter", "family": "GPT"},
|
|
131
|
-
{"id": "anthropic/claude-
|
|
132
|
-
{"id": "anthropic/claude-
|
|
136
|
+
{"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
|
|
137
|
+
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
|
|
138
|
+
{"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
|
|
139
|
+
{"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
|
|
140
|
+
{"id": "qwen/qwen3-coder", "name": "Qwen3 Coder via OpenRouter", "family": "Qwen"},
|
|
133
141
|
{"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
|
|
134
142
|
{"id": "meta-llama/llama-3.3-70b-instruct", "name": "Llama 3.3 70B via OpenRouter", "family": "Llama"},
|
|
135
|
-
{"id": "
|
|
136
|
-
{"id": "google/gemini-2.0-flash-exp", "name": "Gemini 2 Flash via OpenRouter", "family": "Gemini"},
|
|
143
|
+
{"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
|
|
137
144
|
],
|
|
138
145
|
"groq": [
|
|
146
|
+
{"id": "qwen/qwen3-32b", "name": "Qwen3 32B", "family": "Qwen"},
|
|
139
147
|
{"id": "llama-3.1-8b-instant", "name": "Llama 3.1 8B Instant", "family": "Llama"},
|
|
140
148
|
{"id": "llama-3.3-70b-versatile", "name": "Llama 3.3 70B Versatile", "family": "Llama"},
|
|
141
|
-
{"id": "qwen-qwq-32b", "name": "Qwen QwQ 32B", "family": "Qwen"},
|
|
142
149
|
],
|
|
143
150
|
"together": [
|
|
151
|
+
{"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
|
|
144
152
|
{"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "name": "Llama 3.3 70B Turbo", "family": "Llama"},
|
|
145
|
-
{"id": "Qwen/Qwen2.5-72B-Instruct-Turbo", "name": "Qwen 2.5 72B Turbo", "family": "Qwen"},
|
|
146
|
-
{"id": "deepseek-ai/DeepSeek-R1", "name": "DeepSeek R1", "family": "DeepSeek"},
|
|
147
153
|
{"id": "mistralai/Mixtral-8x22B-Instruct-v0.1", "name": "Mixtral 8x22B", "family": "Mistral"},
|
|
148
154
|
],
|
|
149
155
|
"xai": [
|
|
@@ -174,7 +180,7 @@ def parse_model_ref(model_id: str) -> tuple[str, str]:
|
|
|
174
180
|
return "local_mlx", model_id.split(":", 1)[1]
|
|
175
181
|
return "local_mlx", model_id
|
|
176
182
|
|
|
177
|
-
HF_MODELS_ROOT = Path.home() / ".
|
|
183
|
+
HF_MODELS_ROOT = Path.home() / ".ltcai" / "hf-models"
|
|
178
184
|
|
|
179
185
|
def hf_model_dir(repo_id: str) -> Path:
|
|
180
186
|
return HF_MODELS_ROOT / repo_id.replace("/", "__")
|
|
@@ -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
|