ltcai 0.1.4 → 0.1.9
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 +116 -0
- package/docs/OPERATIONS.md +149 -0
- package/knowledge_graph.py +815 -0
- package/ltcai_cli.py +45 -1
- package/package.json +15 -3
- package/requirements.txt +1 -0
- package/server.py +805 -44
- package/skills/SKILL_TEMPLATE.md +57 -0
- package/skills/code_review/SKILL.md +76 -0
- package/skills/data_analysis/SKILL.md +79 -0
- package/skills/file_edit/SKILL.md +68 -0
- package/skills/web_search/SKILL.md +74 -0
- package/static/account.html +14 -2
- package/static/admin.html +225 -6
- package/static/chat.html +644 -140
- package/static/graph.html +612 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/manifest.json +35 -0
- package/static/sw.js +51 -0
- package/telegram_bot.py +631 -217
- package/tests/__init__.py +0 -0
- package/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +94 -0
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/unit/__pycache__/test_security.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_security.py +125 -0
- package/tests/unit/test_tools.py +127 -0
- package/tools.py +169 -13
package/server.py
CHANGED
|
@@ -40,6 +40,7 @@ from pydantic import BaseModel
|
|
|
40
40
|
from PIL import Image
|
|
41
41
|
|
|
42
42
|
from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, parse_model_ref, mx, normalize_branding
|
|
43
|
+
from knowledge_graph import KnowledgeGraphStore
|
|
43
44
|
from p_reinforce import BRAIN_DIR, PReinforceGardener
|
|
44
45
|
from setup import get_recommendations, install_stream, open_url, scan_environment
|
|
45
46
|
from telegram_bot import broadcast_web_chat
|
|
@@ -165,6 +166,7 @@ IS_PUBLIC_MODE = APP_MODE == "public"
|
|
|
165
166
|
DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
|
|
166
167
|
DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
|
|
167
168
|
ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
|
|
169
|
+
ENABLE_GRAPH = env_bool("LATTICEAI_ENABLE_GRAPH", default=True)
|
|
168
170
|
AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
|
|
169
171
|
MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
|
|
170
172
|
ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
|
|
@@ -215,17 +217,25 @@ def verify_password(password: str, hashed: str) -> bool:
|
|
|
215
217
|
return False
|
|
216
218
|
|
|
217
219
|
def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict) -> bool:
|
|
218
|
-
"""평문 비밀번호를 투명하게 해시로 마이그레이션."""
|
|
220
|
+
"""평문 비밀번호를 투명하게 해시로 마이그레이션. 마이그레이션 발생 시 audit log 남김."""
|
|
219
221
|
if ":" in stored and len(stored) > 64:
|
|
220
222
|
return verify_password(plain, stored)
|
|
221
223
|
if plain == stored:
|
|
222
224
|
users[email]["password"] = hash_password(plain)
|
|
223
225
|
save_users(users)
|
|
226
|
+
try:
|
|
227
|
+
append_audit_event("password_migrated_from_plaintext", user_email=email)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logging.warning("audit log failed on password migration: %s", e)
|
|
230
|
+
logging.info("Migrated plaintext password to bcrypt hash for %s", email)
|
|
224
231
|
return True
|
|
225
232
|
return False
|
|
226
233
|
|
|
227
234
|
# ── Session store (file-backed, survives restarts) ────────────────────────────
|
|
228
|
-
|
|
235
|
+
# 24-hour TTL with sliding-window refresh — every authenticated request bumps
|
|
236
|
+
# created_at, so an active user stays logged in while idle sessions auto-expire.
|
|
237
|
+
_SESSION_TTL = 60 * 60 * 24 # 24 hours
|
|
238
|
+
_SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump (write amplification guard)
|
|
229
239
|
_sessions_lock = threading.Lock()
|
|
230
240
|
|
|
231
241
|
def _sessions_file() -> Path:
|
|
@@ -237,15 +247,15 @@ def _load_sessions() -> Dict[str, tuple]:
|
|
|
237
247
|
if f.exists():
|
|
238
248
|
raw = json.loads(f.read_text())
|
|
239
249
|
return {k: tuple(v) for k, v in raw.items()}
|
|
240
|
-
except Exception:
|
|
241
|
-
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logging.warning("_load_sessions failed (starting empty): %s", e)
|
|
242
252
|
return {}
|
|
243
253
|
|
|
244
254
|
def _persist_sessions(sessions: Dict[str, tuple]) -> None:
|
|
245
255
|
try:
|
|
246
256
|
_sessions_file().write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
|
|
247
|
-
except Exception:
|
|
248
|
-
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logging.warning("_persist_sessions failed: %s", e)
|
|
249
259
|
|
|
250
260
|
_sessions: Dict[str, tuple] = _load_sessions()
|
|
251
261
|
|
|
@@ -257,15 +267,21 @@ def create_session(email: str) -> str:
|
|
|
257
267
|
return token
|
|
258
268
|
|
|
259
269
|
def get_session_email(token: str) -> Optional[str]:
|
|
270
|
+
"""Return email for a valid session, sliding the expiry forward on activity."""
|
|
271
|
+
now = time.time()
|
|
260
272
|
with _sessions_lock:
|
|
261
273
|
entry = _sessions.get(token)
|
|
262
274
|
if entry is None:
|
|
263
275
|
return None
|
|
264
276
|
email, created_at = entry
|
|
265
|
-
if
|
|
277
|
+
if now - created_at > _SESSION_TTL:
|
|
266
278
|
_sessions.pop(token, None)
|
|
267
279
|
_persist_sessions(_sessions)
|
|
268
280
|
return None
|
|
281
|
+
# Sliding refresh: only update if the timestamp drifted enough to be worth a disk write
|
|
282
|
+
if now - created_at > _SESSION_REFRESH_THRESHOLD:
|
|
283
|
+
_sessions[token] = (email, now)
|
|
284
|
+
_persist_sessions(_sessions)
|
|
269
285
|
return email
|
|
270
286
|
|
|
271
287
|
def invalidate_session(token: str) -> None:
|
|
@@ -287,6 +303,12 @@ USERS_FILE = DATA_DIR / "users.json"
|
|
|
287
303
|
HISTORY_FILE = DATA_DIR / "chat_history.json"
|
|
288
304
|
VPC_FILE = DATA_DIR / "vpc_config.json"
|
|
289
305
|
MCP_FILE = DATA_DIR / "mcp_installs.json"
|
|
306
|
+
AUDIT_FILE = DATA_DIR / "audit_log.json"
|
|
307
|
+
KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
|
|
308
|
+
|
|
309
|
+
def _require_graph():
|
|
310
|
+
if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
|
|
311
|
+
raise HTTPException(status_code=404, detail="Data Graph is disabled. Set LATTICEAI_ENABLE_GRAPH=true in .env to enable.")
|
|
290
312
|
|
|
291
313
|
class UserRegister(BaseModel):
|
|
292
314
|
email: str
|
|
@@ -319,6 +341,17 @@ class McpRecommendRequest(BaseModel):
|
|
|
319
341
|
class McpInstallRequest(BaseModel):
|
|
320
342
|
mcp_id: str
|
|
321
343
|
|
|
344
|
+
class KnowledgeGraphIngestRequest(BaseModel):
|
|
345
|
+
type: str
|
|
346
|
+
content: str = ""
|
|
347
|
+
role: Optional[str] = None
|
|
348
|
+
title: Optional[str] = None
|
|
349
|
+
source: Optional[str] = None
|
|
350
|
+
conversation_id: Optional[str] = None
|
|
351
|
+
user_email: Optional[str] = None
|
|
352
|
+
user_nickname: Optional[str] = None
|
|
353
|
+
metadata: Optional[Dict] = None
|
|
354
|
+
|
|
322
355
|
DEFAULT_VPC_CONFIG = {
|
|
323
356
|
"provider": "AWS",
|
|
324
357
|
"region": "ap-northeast-2",
|
|
@@ -609,7 +642,8 @@ def load_vpc_config() -> Dict:
|
|
|
609
642
|
with open(VPC_FILE, "r", encoding="utf-8") as f:
|
|
610
643
|
stored = json.load(f)
|
|
611
644
|
return {**DEFAULT_VPC_CONFIG, **stored}
|
|
612
|
-
except Exception:
|
|
645
|
+
except Exception as e:
|
|
646
|
+
logging.warning("load_vpc_config failed (using defaults): %s", e)
|
|
613
647
|
return DEFAULT_VPC_CONFIG.copy()
|
|
614
648
|
|
|
615
649
|
def save_vpc_config(config: Dict):
|
|
@@ -626,7 +660,8 @@ def load_mcp_installs() -> Dict:
|
|
|
626
660
|
if "installed" not in data:
|
|
627
661
|
data["installed"] = {}
|
|
628
662
|
return data
|
|
629
|
-
except Exception:
|
|
663
|
+
except Exception as e:
|
|
664
|
+
logging.warning("load_mcp_installs failed: %s", e)
|
|
630
665
|
return {"installed": {}, "updated_at": None}
|
|
631
666
|
|
|
632
667
|
def save_mcp_installs(data: Dict):
|
|
@@ -727,6 +762,36 @@ def connector_info(mcp_id: str) -> Dict:
|
|
|
727
762
|
|
|
728
763
|
_history_lock = threading.Lock()
|
|
729
764
|
|
|
765
|
+
def get_audit_log() -> List[Dict]:
|
|
766
|
+
if not os.path.exists(AUDIT_FILE):
|
|
767
|
+
return []
|
|
768
|
+
try:
|
|
769
|
+
with open(AUDIT_FILE, "r", encoding="utf-8") as f:
|
|
770
|
+
data = json.load(f)
|
|
771
|
+
return data if isinstance(data, list) else []
|
|
772
|
+
except Exception as e:
|
|
773
|
+
logging.warning("get_audit_log failed: %s", e)
|
|
774
|
+
return []
|
|
775
|
+
|
|
776
|
+
def append_audit_event(event_type: str, **payload) -> None:
|
|
777
|
+
try:
|
|
778
|
+
event = {
|
|
779
|
+
"event_type": event_type,
|
|
780
|
+
"timestamp": datetime.now().isoformat(),
|
|
781
|
+
**payload,
|
|
782
|
+
}
|
|
783
|
+
with _history_lock:
|
|
784
|
+
events = get_audit_log()
|
|
785
|
+
events.append(event)
|
|
786
|
+
if len(events) > 5000:
|
|
787
|
+
events = events[-5000:]
|
|
788
|
+
tmp_path = str(AUDIT_FILE) + ".tmp"
|
|
789
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
790
|
+
json.dump(events, f, ensure_ascii=False, indent=2)
|
|
791
|
+
os.replace(tmp_path, AUDIT_FILE)
|
|
792
|
+
except Exception as e:
|
|
793
|
+
logging.warning("append_audit_event failed: %s", e)
|
|
794
|
+
|
|
730
795
|
def save_to_history(
|
|
731
796
|
role: str,
|
|
732
797
|
message: str,
|
|
@@ -748,6 +813,19 @@ def save_to_history(
|
|
|
748
813
|
item["source"] = source
|
|
749
814
|
if conversation_id:
|
|
750
815
|
item["conversation_id"] = conversation_id
|
|
816
|
+
sensitive = classify_sensitive_message(item, -1)
|
|
817
|
+
append_audit_event(
|
|
818
|
+
"chat_message",
|
|
819
|
+
role=role,
|
|
820
|
+
user_email=user_email,
|
|
821
|
+
user_nickname=user_nickname,
|
|
822
|
+
source=source,
|
|
823
|
+
conversation_id=conversation_id,
|
|
824
|
+
content_preview=sensitive.get("preview"),
|
|
825
|
+
content_chars=len(message or ""),
|
|
826
|
+
sensitivity=sensitive.get("sensitivity"),
|
|
827
|
+
sensitive_labels=sensitive.get("labels") or [],
|
|
828
|
+
)
|
|
751
829
|
with _history_lock:
|
|
752
830
|
history = []
|
|
753
831
|
if os.path.exists(HISTORY_FILE):
|
|
@@ -760,6 +838,19 @@ def save_to_history(
|
|
|
760
838
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
761
839
|
json.dump(history, f, ensure_ascii=False, indent=2)
|
|
762
840
|
os.replace(tmp_path, HISTORY_FILE)
|
|
841
|
+
try:
|
|
842
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
843
|
+
KNOWLEDGE_GRAPH.ingest_message(
|
|
844
|
+
role,
|
|
845
|
+
message,
|
|
846
|
+
user_email=user_email,
|
|
847
|
+
user_nickname=user_nickname,
|
|
848
|
+
source=source,
|
|
849
|
+
conversation_id=conversation_id,
|
|
850
|
+
raw=item,
|
|
851
|
+
)
|
|
852
|
+
except Exception as graph_error:
|
|
853
|
+
logging.warning("knowledge graph message ingest failed: %s", graph_error)
|
|
763
854
|
except Exception as e:
|
|
764
855
|
logging.warning("save_to_history failed: %s", e)
|
|
765
856
|
|
|
@@ -973,12 +1064,77 @@ def require_user(request: Request) -> str:
|
|
|
973
1064
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
974
1065
|
return email or ""
|
|
975
1066
|
|
|
1067
|
+
|
|
1068
|
+
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
|
1069
|
+
# Per-user token bucket. Disabled when LATTICEAI_RATE_LIMIT=0 (default: enabled).
|
|
1070
|
+
_RATE_LIMIT_ENABLED = os.getenv("LATTICEAI_RATE_LIMIT", "1") != "0"
|
|
1071
|
+
_rate_buckets: Dict[str, Dict[str, float]] = {}
|
|
1072
|
+
_rate_lock = threading.Lock()
|
|
1073
|
+
|
|
1074
|
+
# (capacity, refill_per_second) per endpoint family
|
|
1075
|
+
_RATE_LIMITS = {
|
|
1076
|
+
"chat": (30, 0.5), # 30 burst, 30/min sustained
|
|
1077
|
+
"agent": (10, 0.1), # 10 burst, 6/min sustained (agent is expensive)
|
|
1078
|
+
"upload": (20, 0.2), # 20 burst, 12/min sustained
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def enforce_rate_limit(email: str, bucket_key: str) -> None:
|
|
1083
|
+
"""Raise HTTP 429 if user exceeds the bucket. No-op when disabled or unauth'd."""
|
|
1084
|
+
if not _RATE_LIMIT_ENABLED or not email:
|
|
1085
|
+
return
|
|
1086
|
+
cap, refill = _RATE_LIMITS.get(bucket_key, (60, 1.0))
|
|
1087
|
+
key = f"{email}:{bucket_key}"
|
|
1088
|
+
now = time.time()
|
|
1089
|
+
with _rate_lock:
|
|
1090
|
+
bucket = _rate_buckets.get(key)
|
|
1091
|
+
if bucket is None:
|
|
1092
|
+
_rate_buckets[key] = {"tokens": cap - 1, "ts": now}
|
|
1093
|
+
return
|
|
1094
|
+
elapsed = now - bucket["ts"]
|
|
1095
|
+
bucket["tokens"] = min(cap, bucket["tokens"] + elapsed * refill)
|
|
1096
|
+
bucket["ts"] = now
|
|
1097
|
+
if bucket["tokens"] < 1:
|
|
1098
|
+
retry_after = max(1, int((1 - bucket["tokens"]) / refill))
|
|
1099
|
+
raise HTTPException(
|
|
1100
|
+
status_code=429,
|
|
1101
|
+
detail=f"Rate limit exceeded for {bucket_key}. Retry after {retry_after}s.",
|
|
1102
|
+
headers={"Retry-After": str(retry_after)},
|
|
1103
|
+
)
|
|
1104
|
+
bucket["tokens"] -= 1
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
# ── File magic-number validation ──────────────────────────────────────────────
|
|
1108
|
+
# Map of extension → list of byte-prefix signatures (any-match). Files without
|
|
1109
|
+
# distinctive magic (.txt, .md, .csv) skip the check.
|
|
1110
|
+
_FILE_MAGIC: Dict[str, List[bytes]] = {
|
|
1111
|
+
".pdf": [b"%PDF-"],
|
|
1112
|
+
".docx": [b"PK\x03\x04"],
|
|
1113
|
+
".xlsx": [b"PK\x03\x04"],
|
|
1114
|
+
".pptx": [b"PK\x03\x04"],
|
|
1115
|
+
".zip": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
|
1116
|
+
".png": [b"\x89PNG\r\n\x1a\n"],
|
|
1117
|
+
".jpg": [b"\xff\xd8\xff"],
|
|
1118
|
+
".jpeg": [b"\xff\xd8\xff"],
|
|
1119
|
+
".gif": [b"GIF87a", b"GIF89a"],
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _bytes_match_extension(data: bytes, ext: str) -> bool:
|
|
1124
|
+
"""Return True if the file bytes match the claimed extension (or extension has no magic)."""
|
|
1125
|
+
ext = (ext or "").lower()
|
|
1126
|
+
signatures = _FILE_MAGIC.get(ext)
|
|
1127
|
+
if not signatures:
|
|
1128
|
+
return True # text-like formats — no reliable magic
|
|
1129
|
+
head = data[:16]
|
|
1130
|
+
return any(head.startswith(sig) for sig in signatures)
|
|
1131
|
+
|
|
976
1132
|
def require_admin(request: Request) -> tuple[str, Dict]:
|
|
1133
|
+
users = load_users()
|
|
977
1134
|
token = _extract_bearer_token(request)
|
|
978
1135
|
if token:
|
|
979
1136
|
email = get_session_email(token)
|
|
980
1137
|
if email:
|
|
981
|
-
users = load_users()
|
|
982
1138
|
if get_user_role(email, users) == "admin":
|
|
983
1139
|
return email, users
|
|
984
1140
|
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
|
@@ -1151,6 +1307,136 @@ def build_sensitivity_report(history: List[Dict]) -> Dict:
|
|
|
1151
1307
|
"compliance_fields": compliant_items[-30:],
|
|
1152
1308
|
}
|
|
1153
1309
|
|
|
1310
|
+
AUDIT_DELETE_EVENTS = {"conversation_delete", "history_delete", "user_delete"}
|
|
1311
|
+
|
|
1312
|
+
def _audit_user_bucket(email: Optional[str], nickname: Optional[str] = None, users: Optional[Dict] = None) -> Dict:
|
|
1313
|
+
user = (users or {}).get(email or "", {})
|
|
1314
|
+
return {
|
|
1315
|
+
"email": email or "Unknown",
|
|
1316
|
+
"nickname": nickname or user.get("nickname") or user.get("name") or email or "Unknown",
|
|
1317
|
+
"role": get_user_role(email, users or {}) if email else "unknown",
|
|
1318
|
+
"disabled": bool(user.get("disabled")) if user else False,
|
|
1319
|
+
"user_messages": 0,
|
|
1320
|
+
"assistant_messages": 0,
|
|
1321
|
+
"document_uploads": 0,
|
|
1322
|
+
"clear_events": 0,
|
|
1323
|
+
"delete_events": 0,
|
|
1324
|
+
"sensitive_events": 0,
|
|
1325
|
+
"high_sensitive_events": 0,
|
|
1326
|
+
"total_content_chars": 0,
|
|
1327
|
+
"last_activity_at": None,
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
def _public_audit_event(event: Dict) -> Dict:
|
|
1331
|
+
allowed = {
|
|
1332
|
+
"event_type",
|
|
1333
|
+
"timestamp",
|
|
1334
|
+
"role",
|
|
1335
|
+
"user_email",
|
|
1336
|
+
"user_nickname",
|
|
1337
|
+
"source",
|
|
1338
|
+
"conversation_id",
|
|
1339
|
+
"command",
|
|
1340
|
+
"scope",
|
|
1341
|
+
"target_email",
|
|
1342
|
+
"filename",
|
|
1343
|
+
"mime_type",
|
|
1344
|
+
"ext",
|
|
1345
|
+
"bytes",
|
|
1346
|
+
"extracted_chars",
|
|
1347
|
+
"graph_node",
|
|
1348
|
+
"keep_last",
|
|
1349
|
+
"removed",
|
|
1350
|
+
"kept",
|
|
1351
|
+
"started_at",
|
|
1352
|
+
"sensitivity",
|
|
1353
|
+
"sensitive_labels",
|
|
1354
|
+
"content_preview",
|
|
1355
|
+
"content_chars",
|
|
1356
|
+
}
|
|
1357
|
+
return {key: event.get(key) for key in allowed if key in event}
|
|
1358
|
+
|
|
1359
|
+
def build_admin_audit_report(users: Dict) -> Dict:
|
|
1360
|
+
events = get_audit_log()
|
|
1361
|
+
per_user: Dict[str, Dict] = {}
|
|
1362
|
+
|
|
1363
|
+
def ensure_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
1364
|
+
key = email or nickname or "Unknown"
|
|
1365
|
+
if key not in per_user:
|
|
1366
|
+
per_user[key] = _audit_user_bucket(email, nickname, users)
|
|
1367
|
+
elif nickname and per_user[key].get("nickname") in {"Unknown", email, None}:
|
|
1368
|
+
per_user[key]["nickname"] = nickname
|
|
1369
|
+
return per_user[key]
|
|
1370
|
+
|
|
1371
|
+
for email, user in users.items():
|
|
1372
|
+
ensure_user(email, user.get("nickname") or user.get("name"))
|
|
1373
|
+
|
|
1374
|
+
summary = {
|
|
1375
|
+
"total_events": len(events),
|
|
1376
|
+
"chat_events": 0,
|
|
1377
|
+
"user_messages": 0,
|
|
1378
|
+
"assistant_messages": 0,
|
|
1379
|
+
"document_uploads": 0,
|
|
1380
|
+
"clear_events": 0,
|
|
1381
|
+
"delete_events": 0,
|
|
1382
|
+
"sensitive_events": 0,
|
|
1383
|
+
"high_sensitive_events": 0,
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
sensitive_events = []
|
|
1387
|
+
deletion_events = []
|
|
1388
|
+
for event in events:
|
|
1389
|
+
event_type = event.get("event_type")
|
|
1390
|
+
email = event.get("user_email")
|
|
1391
|
+
user = ensure_user(email, event.get("user_nickname"))
|
|
1392
|
+
timestamp = event.get("timestamp")
|
|
1393
|
+
if timestamp and (not user["last_activity_at"] or timestamp > user["last_activity_at"]):
|
|
1394
|
+
user["last_activity_at"] = timestamp
|
|
1395
|
+
|
|
1396
|
+
user["total_content_chars"] += int(event.get("content_chars") or event.get("extracted_chars") or 0)
|
|
1397
|
+
sensitivity = event.get("sensitivity") or "none"
|
|
1398
|
+
labels = event.get("sensitive_labels") or []
|
|
1399
|
+
is_sensitive = sensitivity != "none" or bool(labels)
|
|
1400
|
+
|
|
1401
|
+
if event_type == "chat_message":
|
|
1402
|
+
summary["chat_events"] += 1
|
|
1403
|
+
if event.get("role") == "user":
|
|
1404
|
+
summary["user_messages"] += 1
|
|
1405
|
+
user["user_messages"] += 1
|
|
1406
|
+
elif event.get("role") == "assistant":
|
|
1407
|
+
summary["assistant_messages"] += 1
|
|
1408
|
+
user["assistant_messages"] += 1
|
|
1409
|
+
elif event_type == "document_upload":
|
|
1410
|
+
summary["document_uploads"] += 1
|
|
1411
|
+
user["document_uploads"] += 1
|
|
1412
|
+
elif event_type == "clear_command":
|
|
1413
|
+
summary["clear_events"] += 1
|
|
1414
|
+
user["clear_events"] += 1
|
|
1415
|
+
elif event_type in AUDIT_DELETE_EVENTS:
|
|
1416
|
+
summary["delete_events"] += 1
|
|
1417
|
+
user["delete_events"] += 1
|
|
1418
|
+
deletion_events.append(_public_audit_event(event))
|
|
1419
|
+
|
|
1420
|
+
if is_sensitive:
|
|
1421
|
+
summary["sensitive_events"] += 1
|
|
1422
|
+
user["sensitive_events"] += 1
|
|
1423
|
+
sensitive_events.append(_public_audit_event(event))
|
|
1424
|
+
if sensitivity == "high":
|
|
1425
|
+
summary["high_sensitive_events"] += 1
|
|
1426
|
+
user["high_sensitive_events"] += 1
|
|
1427
|
+
|
|
1428
|
+
return {
|
|
1429
|
+
"summary": summary,
|
|
1430
|
+
"per_user": sorted(
|
|
1431
|
+
per_user.values(),
|
|
1432
|
+
key=lambda item: (item.get("last_activity_at") or "", item.get("user_messages", 0) + item.get("assistant_messages", 0)),
|
|
1433
|
+
reverse=True,
|
|
1434
|
+
),
|
|
1435
|
+
"recent_events": [_public_audit_event(event) for event in events[-80:]][::-1],
|
|
1436
|
+
"sensitive_events": sensitive_events[-80:][::-1],
|
|
1437
|
+
"deletion_events": deletion_events[-80:][::-1],
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1154
1440
|
router = LLMRouter()
|
|
1155
1441
|
gardener = PReinforceGardener()
|
|
1156
1442
|
|
|
@@ -1209,18 +1495,31 @@ async def unload_idle_models_loop() -> None:
|
|
|
1209
1495
|
except Exception as e:
|
|
1210
1496
|
logging.warning("Idle model unload failed: %s", e)
|
|
1211
1497
|
|
|
1498
|
+
def _spawn(coro, *, name: str):
|
|
1499
|
+
"""Fire-and-forget asyncio task that logs exceptions instead of swallowing them."""
|
|
1500
|
+
task = asyncio.create_task(coro, name=name)
|
|
1501
|
+
def _on_done(t: asyncio.Task) -> None:
|
|
1502
|
+
if t.cancelled():
|
|
1503
|
+
return
|
|
1504
|
+
exc = t.exception()
|
|
1505
|
+
if exc is not None:
|
|
1506
|
+
logging.warning("background task '%s' failed: %s", name, exc)
|
|
1507
|
+
task.add_done_callback(_on_done)
|
|
1508
|
+
return task
|
|
1509
|
+
|
|
1510
|
+
|
|
1212
1511
|
@asynccontextmanager
|
|
1213
1512
|
async def lifespan(app: FastAPI):
|
|
1214
1513
|
try:
|
|
1215
1514
|
print(f"🧭 Lattice AI mode: {APP_MODE}")
|
|
1216
1515
|
if ENABLE_TELEGRAM:
|
|
1217
1516
|
from telegram_bot import run_bot
|
|
1218
|
-
|
|
1517
|
+
_spawn(run_bot(), name="telegram_bot")
|
|
1219
1518
|
print("🚀 Telegram Bot Bridge activated!")
|
|
1220
1519
|
else:
|
|
1221
1520
|
print("⏭️ Telegram Bot Bridge disabled for this mode.")
|
|
1222
|
-
|
|
1223
|
-
|
|
1521
|
+
_spawn(unload_idle_models_loop(), name="unload_idle_models")
|
|
1522
|
+
_spawn(autoload_default_model(), name="autoload_default_model")
|
|
1224
1523
|
except Exception as e:
|
|
1225
1524
|
print(f"⚠️ Startup sequence failed: {e}")
|
|
1226
1525
|
try:
|
|
@@ -1239,11 +1538,16 @@ app.add_middleware(
|
|
|
1239
1538
|
allow_origins=CORS_ALLOWED_ORIGINS,
|
|
1240
1539
|
allow_methods=["*"],
|
|
1241
1540
|
allow_headers=["*"],
|
|
1541
|
+
allow_credentials=True,
|
|
1242
1542
|
)
|
|
1243
1543
|
|
|
1244
1544
|
# UI 파일이 담길 static 폴더 연결
|
|
1245
1545
|
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
1246
1546
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
1547
|
+
# PWA icons served at /icons/*
|
|
1548
|
+
_ICONS_DIR = STATIC_DIR / "icons"
|
|
1549
|
+
if _ICONS_DIR.exists():
|
|
1550
|
+
app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
|
|
1247
1551
|
ensure_agent_root()
|
|
1248
1552
|
app.mount("/agent-files", StaticFiles(directory=str(AGENT_ROOT)), name="agent-files")
|
|
1249
1553
|
|
|
@@ -1281,7 +1585,7 @@ async def login(req: UserLogin):
|
|
|
1281
1585
|
"is_admin": role == "admin",
|
|
1282
1586
|
"token": token,
|
|
1283
1587
|
})
|
|
1284
|
-
response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=
|
|
1588
|
+
response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
|
|
1285
1589
|
return response
|
|
1286
1590
|
|
|
1287
1591
|
@app.get("/auth/sso/config")
|
|
@@ -1468,6 +1772,17 @@ async def admin_sensitivity(request: Request):
|
|
|
1468
1772
|
require_admin(request)
|
|
1469
1773
|
return build_sensitivity_report(get_history())
|
|
1470
1774
|
|
|
1775
|
+
@app.get("/admin/audit")
|
|
1776
|
+
async def admin_audit(request: Request):
|
|
1777
|
+
_, users = require_admin(request)
|
|
1778
|
+
report = build_admin_audit_report(users)
|
|
1779
|
+
try:
|
|
1780
|
+
report["graph"] = KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
|
|
1781
|
+
except Exception as e:
|
|
1782
|
+
logging.warning("knowledge graph stats for audit failed: %s", e)
|
|
1783
|
+
report["graph"] = {"error": str(e)}
|
|
1784
|
+
return report
|
|
1785
|
+
|
|
1471
1786
|
@app.get("/vpc/status")
|
|
1472
1787
|
async def vpc_status(request: Request):
|
|
1473
1788
|
require_user(request)
|
|
@@ -1489,6 +1804,7 @@ async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
|
|
|
1489
1804
|
admin_email, users = require_admin(request)
|
|
1490
1805
|
if email not in users:
|
|
1491
1806
|
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
1807
|
+
before = public_user(email, users[email], users)
|
|
1492
1808
|
if req.role is not None:
|
|
1493
1809
|
if req.role not in {"admin", "user"}:
|
|
1494
1810
|
raise HTTPException(status_code=400, detail="role은 admin 또는 user만 가능합니다.")
|
|
@@ -1498,7 +1814,9 @@ async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
|
|
|
1498
1814
|
raise HTTPException(status_code=400, detail="자기 자신은 비활성화할 수 없습니다.")
|
|
1499
1815
|
users[email]["disabled"] = req.disabled
|
|
1500
1816
|
save_users(users)
|
|
1501
|
-
|
|
1817
|
+
after = public_user(email, users[email], users)
|
|
1818
|
+
append_audit_event("user_update", user_email=admin_email, target_email=email, before=before, after=after)
|
|
1819
|
+
return after
|
|
1502
1820
|
|
|
1503
1821
|
@app.delete("/admin/users/{email:path}")
|
|
1504
1822
|
async def admin_delete_user(email: str, request: Request):
|
|
@@ -1508,6 +1826,7 @@ async def admin_delete_user(email: str, request: Request):
|
|
|
1508
1826
|
if email not in users:
|
|
1509
1827
|
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
1510
1828
|
deleted = public_user(email, users[email], users)
|
|
1829
|
+
append_audit_event("user_delete", user_email=admin_email, target_email=email, deleted_user=deleted)
|
|
1511
1830
|
del users[email]
|
|
1512
1831
|
save_users(users)
|
|
1513
1832
|
return {"status": "ok", "deleted": deleted}
|
|
@@ -1515,7 +1834,7 @@ async def admin_delete_user(email: str, request: Request):
|
|
|
1515
1834
|
@app.get("/admin/invite-link")
|
|
1516
1835
|
async def admin_invite_link(request: Request):
|
|
1517
1836
|
require_admin(request)
|
|
1518
|
-
host = request.headers.get("host", f"localhost:{
|
|
1837
|
+
host = request.headers.get("host", f"localhost:{DEFAULT_PORT}")
|
|
1519
1838
|
scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
|
|
1520
1839
|
if INVITE_GATE_ENABLED:
|
|
1521
1840
|
url = f"{scheme}://{host}/?code={INVITE_CODE}"
|
|
@@ -1556,6 +1875,30 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
|
|
|
1556
1875
|
""", status_code=403)
|
|
1557
1876
|
|
|
1558
1877
|
|
|
1878
|
+
@app.get("/account")
|
|
1879
|
+
async def account_page():
|
|
1880
|
+
"""Direct login/register page route used by logout and manual navigation."""
|
|
1881
|
+
return FileResponse(STATIC_DIR / "account.html")
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
@app.get("/manifest.json")
|
|
1885
|
+
async def manifest():
|
|
1886
|
+
p = STATIC_DIR / "manifest.json"
|
|
1887
|
+
if not p.exists():
|
|
1888
|
+
raise HTTPException(status_code=404)
|
|
1889
|
+
return FileResponse(str(p), media_type="application/manifest+json")
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
@app.get("/sw.js")
|
|
1893
|
+
async def service_worker():
|
|
1894
|
+
p = STATIC_DIR / "sw.js"
|
|
1895
|
+
if not p.exists():
|
|
1896
|
+
raise HTTPException(status_code=404)
|
|
1897
|
+
resp = FileResponse(str(p), media_type="application/javascript")
|
|
1898
|
+
resp.headers["Service-Worker-Allowed"] = "/"
|
|
1899
|
+
return resp
|
|
1900
|
+
|
|
1901
|
+
|
|
1559
1902
|
@app.get("/chat")
|
|
1560
1903
|
async def chat_page(request: Request):
|
|
1561
1904
|
return FileResponse(STATIC_DIR / "chat.html")
|
|
@@ -1796,6 +2139,7 @@ ENGINE_MODEL_CATALOG = {
|
|
|
1796
2139
|
{"id": "mlx-community/gemma-4-e4b-4bit", "name": "Gemma 4 E4B Base", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
|
|
1797
2140
|
{"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
|
|
1798
2141
|
{"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
|
|
2142
|
+
{"id": "Jiunsong/supergemma4-26b-abliterated-multimodal-mlx-4bit", "name": "SuperGemma4 26B Abliterated Multimodal", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
|
|
1799
2143
|
{"id": "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit", "name": "Qwen 2.5 Coder 3B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "2.1GB", "pullable": True},
|
|
1800
2144
|
{"id": "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", "name": "Qwen 2.5 Coder 7B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "4.3GB", "pullable": True},
|
|
1801
2145
|
{"id": "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", "name": "Qwen 2.5 Coder 14B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "8.5GB", "pullable": True},
|
|
@@ -2045,6 +2389,7 @@ def runtime_features() -> Dict:
|
|
|
2045
2389
|
"port": DEFAULT_PORT,
|
|
2046
2390
|
"data_dir": str(DATA_DIR),
|
|
2047
2391
|
"telegram_enabled": ENABLE_TELEGRAM,
|
|
2392
|
+
"graph_enabled": ENABLE_GRAPH,
|
|
2048
2393
|
"autoload_models": AUTOLOAD_MODELS,
|
|
2049
2394
|
"model_idle_unload_seconds": MODEL_IDLE_UNLOAD_SECONDS,
|
|
2050
2395
|
"model_memory_policy": router.model_memory_policy(),
|
|
@@ -2098,11 +2443,28 @@ def install_engine(engine: str) -> Dict:
|
|
|
2098
2443
|
"installed": engine_installed(engine),
|
|
2099
2444
|
}
|
|
2100
2445
|
if engine == "ollama" and completed.returncode == 0 and shutil.which("ollama"):
|
|
2446
|
+
# Skip if already running to avoid orphan daemons.
|
|
2447
|
+
already_up = False
|
|
2101
2448
|
try:
|
|
2102
|
-
subprocess.
|
|
2103
|
-
|
|
2449
|
+
probe = subprocess.run(["ollama", "list"], capture_output=True, timeout=2, check=False)
|
|
2450
|
+
already_up = probe.returncode == 0
|
|
2104
2451
|
except Exception:
|
|
2105
|
-
|
|
2452
|
+
already_up = False
|
|
2453
|
+
if already_up:
|
|
2454
|
+
result["daemon_started"] = "already_running"
|
|
2455
|
+
else:
|
|
2456
|
+
try:
|
|
2457
|
+
# Detach so the daemon survives this request but doesn't become our zombie.
|
|
2458
|
+
subprocess.Popen(
|
|
2459
|
+
["ollama", "serve"],
|
|
2460
|
+
stdout=subprocess.DEVNULL,
|
|
2461
|
+
stderr=subprocess.DEVNULL,
|
|
2462
|
+
start_new_session=True,
|
|
2463
|
+
)
|
|
2464
|
+
result["daemon_started"] = True
|
|
2465
|
+
except Exception as e:
|
|
2466
|
+
logging.warning("ollama serve spawn failed: %s", e)
|
|
2467
|
+
result["daemon_started"] = False
|
|
2106
2468
|
return result
|
|
2107
2469
|
|
|
2108
2470
|
CLOUD_VERIFY_CACHE: Dict[str, Dict] = {}
|
|
@@ -2181,6 +2543,7 @@ async def health(request: Request):
|
|
|
2181
2543
|
|
|
2182
2544
|
|
|
2183
2545
|
@app.get("/mode")
|
|
2546
|
+
@app.get("/runtime_features")
|
|
2184
2547
|
async def mode():
|
|
2185
2548
|
return runtime_features()
|
|
2186
2549
|
|
|
@@ -2371,6 +2734,7 @@ async def unload_all_models(request: Request):
|
|
|
2371
2734
|
@app.post("/chat")
|
|
2372
2735
|
async def chat(req: ChatRequest, request: Request):
|
|
2373
2736
|
current_user = require_user(request)
|
|
2737
|
+
enforce_rate_limit(current_user, "chat")
|
|
2374
2738
|
img_len = len(req.image_data) if req.image_data else 0
|
|
2375
2739
|
print(
|
|
2376
2740
|
f"🧪 /chat request: stream={req.stream} image_data_len={img_len} "
|
|
@@ -2400,16 +2764,41 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
2400
2764
|
|
|
2401
2765
|
if is_clear_command(req.message):
|
|
2402
2766
|
command = req.message.strip().lower()
|
|
2767
|
+
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
2768
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
2769
|
+
try:
|
|
2770
|
+
KNOWLEDGE_GRAPH.ingest_event(
|
|
2771
|
+
"ClearEvent",
|
|
2772
|
+
f"{command} requested",
|
|
2773
|
+
user_email=effective_email,
|
|
2774
|
+
user_nickname=req.user_nickname,
|
|
2775
|
+
source=req.source or "web",
|
|
2776
|
+
conversation_id=req.conversation_id,
|
|
2777
|
+
metadata={"command": command, "scope": clear_scope},
|
|
2778
|
+
)
|
|
2779
|
+
except Exception as e:
|
|
2780
|
+
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
2403
2781
|
if command == "/clear_all":
|
|
2404
2782
|
result = clear_history(0)
|
|
2405
|
-
answer = f"
|
|
2783
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
|
|
2406
2784
|
else:
|
|
2407
2785
|
if req.conversation_id:
|
|
2408
2786
|
result = clear_conversation(req.conversation_id)
|
|
2409
|
-
answer = f"현재 대화방
|
|
2787
|
+
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
|
|
2410
2788
|
else:
|
|
2411
2789
|
result = clear_history(0)
|
|
2412
|
-
answer = f"
|
|
2790
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
|
|
2791
|
+
append_audit_event(
|
|
2792
|
+
"clear_command",
|
|
2793
|
+
user_email=effective_email,
|
|
2794
|
+
user_nickname=req.user_nickname,
|
|
2795
|
+
source=req.source or "web",
|
|
2796
|
+
conversation_id=req.conversation_id,
|
|
2797
|
+
command=command,
|
|
2798
|
+
scope=clear_scope,
|
|
2799
|
+
removed=result.get("removed", 0),
|
|
2800
|
+
kept=result.get("kept", 0),
|
|
2801
|
+
)
|
|
2413
2802
|
if req.stream:
|
|
2414
2803
|
return StreamingResponse(
|
|
2415
2804
|
single_text_stream(answer),
|
|
@@ -2454,11 +2843,33 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
2454
2843
|
except Exception as e:
|
|
2455
2844
|
logging.warning("Knowledge reinforcement skipped: %s", e)
|
|
2456
2845
|
|
|
2846
|
+
try:
|
|
2847
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
2848
|
+
graph_context = KNOWLEDGE_GRAPH.context_for_query(req.message)
|
|
2849
|
+
if graph_context:
|
|
2850
|
+
context += f"\n\n[KNOWLEDGE GRAPH]\n{graph_context}"
|
|
2851
|
+
print("🕸️ Context reinforced with knowledge graph.")
|
|
2852
|
+
except Exception as e:
|
|
2853
|
+
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
2854
|
+
|
|
2457
2855
|
if req.image_data:
|
|
2458
2856
|
screenshot_context = extract_screenshot_context(req.image_data)
|
|
2459
2857
|
if screenshot_context:
|
|
2460
2858
|
context += f"\n\n{screenshot_context}"
|
|
2461
2859
|
|
|
2860
|
+
# 메시지 안에 절대 경로나 ~/... 경로가 있으면 자동으로 파일 읽어서 컨텍스트 주입
|
|
2861
|
+
_file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
|
|
2862
|
+
for _m in _file_path_re.finditer(req.message or ""):
|
|
2863
|
+
_fpath = _m.group(1).strip()
|
|
2864
|
+
try:
|
|
2865
|
+
_result = local_read(_fpath)
|
|
2866
|
+
_fcontent = _result.get("content", "")
|
|
2867
|
+
if _fcontent:
|
|
2868
|
+
context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
|
|
2869
|
+
print(f"📂 Auto-injected file context: {_fpath}")
|
|
2870
|
+
except Exception:
|
|
2871
|
+
pass
|
|
2872
|
+
|
|
2462
2873
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
2463
2874
|
save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
2464
2875
|
if req.source != "telegram":
|
|
@@ -2521,14 +2932,31 @@ async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
|
2521
2932
|
@app.delete("/history/conversations/{conversation_id:path}")
|
|
2522
2933
|
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
2523
2934
|
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
2524
|
-
require_user(request)
|
|
2525
|
-
|
|
2935
|
+
email = require_user(request)
|
|
2936
|
+
result = clear_conversation(conversation_id, request.query_params.get("started_at"))
|
|
2937
|
+
append_audit_event(
|
|
2938
|
+
"conversation_delete",
|
|
2939
|
+
user_email=email,
|
|
2940
|
+
conversation_id=conversation_id,
|
|
2941
|
+
started_at=request.query_params.get("started_at"),
|
|
2942
|
+
removed=result.get("removed", 0),
|
|
2943
|
+
kept=result.get("kept", 0),
|
|
2944
|
+
)
|
|
2945
|
+
return result
|
|
2526
2946
|
|
|
2527
2947
|
|
|
2528
2948
|
@app.delete("/history")
|
|
2529
2949
|
async def delete_history(request: Request, keep_last: int = 0):
|
|
2530
|
-
require_user(request)
|
|
2531
|
-
|
|
2950
|
+
email = require_user(request)
|
|
2951
|
+
result = clear_history(keep_last)
|
|
2952
|
+
append_audit_event(
|
|
2953
|
+
"history_delete",
|
|
2954
|
+
user_email=email,
|
|
2955
|
+
keep_last=keep_last,
|
|
2956
|
+
removed=result.get("removed", 0),
|
|
2957
|
+
kept=result.get("kept", 0),
|
|
2958
|
+
)
|
|
2959
|
+
return result
|
|
2532
2960
|
|
|
2533
2961
|
@app.get("/history/search")
|
|
2534
2962
|
async def search_history(q: str, request: Request):
|
|
@@ -2548,6 +2976,85 @@ async def search_history(q: str, request: Request):
|
|
|
2548
2976
|
return {"results": list(grouped.values())[-30:], "query": q}
|
|
2549
2977
|
|
|
2550
2978
|
|
|
2979
|
+
@app.get("/graph")
|
|
2980
|
+
async def knowledge_graph_page(request: Request):
|
|
2981
|
+
"""Serve the interactive knowledge graph canvas UI."""
|
|
2982
|
+
_require_graph()
|
|
2983
|
+
require_user(request)
|
|
2984
|
+
return FileResponse(STATIC_DIR / "graph.html")
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
@app.get("/knowledge-graph")
|
|
2988
|
+
async def knowledge_graph_legacy_page(request: Request):
|
|
2989
|
+
"""Backward-compatible route for the graph page."""
|
|
2990
|
+
_require_graph()
|
|
2991
|
+
require_user(request)
|
|
2992
|
+
return FileResponse(STATIC_DIR / "graph.html")
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
@app.get("/knowledge-graph/stats")
|
|
2996
|
+
async def knowledge_graph_stats(request: Request):
|
|
2997
|
+
_require_graph()
|
|
2998
|
+
require_user(request)
|
|
2999
|
+
return KNOWLEDGE_GRAPH.stats()
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
@app.get("/knowledge-graph/graph")
|
|
3003
|
+
async def knowledge_graph_data(request: Request, limit: int = 300):
|
|
3004
|
+
_require_graph()
|
|
3005
|
+
require_user(request)
|
|
3006
|
+
return KNOWLEDGE_GRAPH.graph(limit)
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
@app.get("/knowledge-graph/search")
|
|
3010
|
+
async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
|
|
3011
|
+
_require_graph()
|
|
3012
|
+
require_user(request)
|
|
3013
|
+
if not q or not q.strip():
|
|
3014
|
+
return {"query": q, "matches": []}
|
|
3015
|
+
return KNOWLEDGE_GRAPH.search(q, limit)
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
@app.get("/knowledge-graph/context")
|
|
3019
|
+
async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
|
|
3020
|
+
_require_graph()
|
|
3021
|
+
require_user(request)
|
|
3022
|
+
return {"query": q, "context": KNOWLEDGE_GRAPH.context_for_query(q, limit)}
|
|
3023
|
+
|
|
3024
|
+
|
|
3025
|
+
@app.get("/knowledge-graph/neighbors/{node_id:path}")
|
|
3026
|
+
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
3027
|
+
_require_graph()
|
|
3028
|
+
require_user(request)
|
|
3029
|
+
if not node_id:
|
|
3030
|
+
raise HTTPException(status_code=400, detail="node_id required")
|
|
3031
|
+
return KNOWLEDGE_GRAPH.neighbors(node_id)
|
|
3032
|
+
|
|
3033
|
+
|
|
3034
|
+
@app.post("/knowledge-graph/ingest")
|
|
3035
|
+
async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
|
|
3036
|
+
_require_graph()
|
|
3037
|
+
current_user = require_user(request)
|
|
3038
|
+
event_type = (req.type or "").strip().lower()
|
|
3039
|
+
if event_type not in {"message", "ai_response", "note"}:
|
|
3040
|
+
raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
|
|
3041
|
+
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
3042
|
+
return KNOWLEDGE_GRAPH.ingest_message(
|
|
3043
|
+
role,
|
|
3044
|
+
req.content,
|
|
3045
|
+
user_email=req.user_email or current_user,
|
|
3046
|
+
user_nickname=req.user_nickname,
|
|
3047
|
+
source=req.source or "mcp",
|
|
3048
|
+
conversation_id=req.conversation_id,
|
|
3049
|
+
raw={
|
|
3050
|
+
"type": req.type,
|
|
3051
|
+
"title": req.title,
|
|
3052
|
+
"content": req.content,
|
|
3053
|
+
"metadata": req.metadata or {},
|
|
3054
|
+
},
|
|
3055
|
+
)
|
|
3056
|
+
|
|
3057
|
+
|
|
2551
3058
|
async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
|
|
2552
3059
|
full_response = ""
|
|
2553
3060
|
async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
|
|
@@ -2572,7 +3079,9 @@ async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = No
|
|
|
2572
3079
|
# ── Local Computer Agent ──────────────────────────────────────────────────────
|
|
2573
3080
|
|
|
2574
3081
|
AGENT_SYSTEM_PROMPT = """You are Lattice AI Agent, a local computer-use coding assistant.
|
|
2575
|
-
You
|
|
3082
|
+
You have full access to the local filesystem via local_list / local_read / local_write tools.
|
|
3083
|
+
Use read_file / write_file for paths inside the agent workspace (relative paths).
|
|
3084
|
+
Use local_read / local_write for any absolute path on the system (e.g. ~/Downloads, ~/Desktop).
|
|
2576
3085
|
|
|
2577
3086
|
Available actions:
|
|
2578
3087
|
- list_dir: {"action":"list_dir","args":{"path":"."}}
|
|
@@ -2587,6 +3096,7 @@ Available actions:
|
|
|
2587
3096
|
- create_xlsx: {"action":"create_xlsx","args":{"rows":[["A","B"],[1,2]],"filename":"spreadsheet.xlsx","sheet_name":"Sheet1"}}
|
|
2588
3097
|
- create_pptx: {"action":"create_pptx","args":{"title":"title","slides":[{"title":"Slide","bullets":["point"]}],"filename":"presentation.pptx"}}
|
|
2589
3098
|
- create_pdf: {"action":"create_pdf","args":{"title":"title","body":"paragraphs","filename":"document.pdf"}}
|
|
3099
|
+
- create_web_project: {"action":"create_web_project","args":{"path":"my_app","framework":"react","template":"vite"}} — scaffold a runnable web app project
|
|
2590
3100
|
- local_list: {"action":"local_list","args":{"path":"/Users/username/Downloads"}} — lists any local folder (UI will request user permission first)
|
|
2591
3101
|
- local_read: {"action":"local_read","args":{"path":"/Users/username/Documents/note.txt"}} — reads any local file (UI will request user permission first)
|
|
2592
3102
|
- local_write: {"action":"local_write","args":{"path":"/Users/username/Desktop/output.txt","content":"..."}} — writes any local file (UI will request user permission first)
|
|
@@ -2626,10 +3136,14 @@ Rules:
|
|
|
2626
3136
|
- Prefer simple, verifiable steps.
|
|
2627
3137
|
- Use inspect_html and preview_url for generated web UI.
|
|
2628
3138
|
- Use build_project when the user asks to build, compile, typecheck, or run a package build script.
|
|
2629
|
-
- Use deploy_project when the user asks to deploy, preview, or
|
|
3139
|
+
- Use deploy_project when the user asks to deploy, preview, release, or package installers (pkg/exe) and package.json defines that script (e.g. package, dist, make, build:pkg, build:exe).
|
|
3140
|
+
- If the user asks for app/service/web creation, prefer create_web_project first, then edit files with write_file/read_file and verify with build_project or run_command.
|
|
3141
|
+
- If the user asks for installer outputs (.pkg/.exe), set up packaging config (for example Electron/electron-builder or equivalent), create package scripts in package.json, then run deploy_project for installer scripts.
|
|
3142
|
+
- If .exe cannot be built on current OS/toolchain, still generate the full packaging config and scripts for Windows and report the exact missing prerequisite.
|
|
2630
3143
|
- Do not claim you cannot build or deploy. If a script, token, or platform config is missing, inspect the workspace and explain the exact missing piece.
|
|
2631
3144
|
- Use knowledge tools when the user asks to remember, search memory, or organize project context.
|
|
2632
3145
|
- Use run_command for local inspection, tests, and short development commands after files are written.
|
|
3146
|
+
- For data analysis tasks, read the provided files first (read_document/local_read), compute with run_command when needed, and return concrete findings plus output artifact paths when created.
|
|
2633
3147
|
- Use clear_history when the user asks to forget, clear, delete, reset, or speed up chat history.
|
|
2634
3148
|
- Git is read-only: status, diff, log, and show only. Never commit, push, pull, fetch, clone, reset, or checkout.
|
|
2635
3149
|
- If the user asks for something unsafe or outside the workspace, explain the limitation with final.
|
|
@@ -2637,13 +3151,73 @@ Rules:
|
|
|
2637
3151
|
"""
|
|
2638
3152
|
|
|
2639
3153
|
|
|
2640
|
-
_FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file"}
|
|
3154
|
+
_FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file", "create_web_project"}
|
|
3155
|
+
|
|
3156
|
+
# Harness risk level per tool action.
|
|
3157
|
+
# low — read-only, no side effects
|
|
3158
|
+
# medium — write/create files or knowledge entries
|
|
3159
|
+
# high — execute commands, control computer, write to arbitrary FS paths
|
|
3160
|
+
_TOOL_RISK: Dict[str, str] = {
|
|
3161
|
+
# read-only workspace tools
|
|
3162
|
+
"list_dir": "low", "workspace_tree": "low", "read_file": "low",
|
|
3163
|
+
"search_files": "low", "inspect_html": "low",
|
|
3164
|
+
# read-only local FS
|
|
3165
|
+
"local_list": "low", "local_read": "low",
|
|
3166
|
+
# read-only git
|
|
3167
|
+
"git_status": "low", "git_log": "low", "git_diff": "low", "git_show": "low",
|
|
3168
|
+
# read-only knowledge / computer
|
|
3169
|
+
"knowledge_search": "low", "knowledge_tree": "low",
|
|
3170
|
+
"obsidian_search": "low", "obsidian_tree": "low",
|
|
3171
|
+
"computer_screenshot": "low", "computer_status": "low",
|
|
3172
|
+
# write workspace
|
|
3173
|
+
"write_file": "medium", "create_web_project": "medium",
|
|
3174
|
+
"create_docx": "medium", "create_xlsx": "medium",
|
|
3175
|
+
"create_pptx": "medium", "create_pdf": "medium",
|
|
3176
|
+
# write knowledge
|
|
3177
|
+
"knowledge_save": "medium", "obsidian_save": "medium",
|
|
3178
|
+
# write local FS (arbitrary path — treated as medium; blocked from system roots below)
|
|
3179
|
+
"local_write": "medium",
|
|
3180
|
+
# preview
|
|
3181
|
+
"preview_url": "medium",
|
|
3182
|
+
# execute commands
|
|
3183
|
+
"run_command": "high",
|
|
3184
|
+
# computer control
|
|
3185
|
+
"computer_click": "high", "computer_type": "high", "computer_key": "high",
|
|
3186
|
+
"computer_scroll": "high", "computer_drag": "high", "computer_move": "high",
|
|
3187
|
+
"computer_open_app": "high", "computer_open_url": "high",
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
# Paths that local_write must never target (system-level protection)
|
|
3191
|
+
_LOCAL_WRITE_BLOCKED_PREFIXES = (
|
|
3192
|
+
"/etc/", "/usr/", "/bin/", "/sbin/", "/System/", "/private/etc/",
|
|
3193
|
+
"/Library/LaunchDaemons/", "/Library/LaunchAgents/",
|
|
3194
|
+
)
|
|
3195
|
+
|
|
3196
|
+
|
|
3197
|
+
def _agent_risk(action_name: str, args: dict) -> str:
|
|
3198
|
+
"""Return risk level for an action, upgrading local_write to 'high' for system paths."""
|
|
3199
|
+
risk = _TOOL_RISK.get(action_name, "medium")
|
|
3200
|
+
if action_name == "local_write":
|
|
3201
|
+
path = str(args.get("path", ""))
|
|
3202
|
+
if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
|
|
3203
|
+
risk = "high"
|
|
3204
|
+
return risk
|
|
3205
|
+
|
|
2641
3206
|
|
|
2642
3207
|
def _collect_created_files(transcript: list) -> list:
|
|
2643
3208
|
files = []
|
|
2644
3209
|
for step in transcript:
|
|
2645
3210
|
if step.get("action") in _FILE_CREATE_ACTIONS:
|
|
2646
3211
|
result = step.get("result", {})
|
|
3212
|
+
if isinstance(result.get("created_files"), list):
|
|
3213
|
+
for rel_path in result["created_files"]:
|
|
3214
|
+
files.append({
|
|
3215
|
+
"path": rel_path,
|
|
3216
|
+
"filename": Path(rel_path).name,
|
|
3217
|
+
"bytes": 0,
|
|
3218
|
+
"action": step["action"],
|
|
3219
|
+
})
|
|
3220
|
+
continue
|
|
2647
3221
|
path = result.get("path")
|
|
2648
3222
|
if path:
|
|
2649
3223
|
files.append({
|
|
@@ -2679,7 +3253,8 @@ def _extract_agent_action(raw: str) -> Dict:
|
|
|
2679
3253
|
@app.post("/agent")
|
|
2680
3254
|
async def agent(req: AgentRequest, request: Request):
|
|
2681
3255
|
"""Natural-language local agent loop for Telegram and future clients."""
|
|
2682
|
-
require_user(request)
|
|
3256
|
+
current_user = require_user(request)
|
|
3257
|
+
enforce_rate_limit(current_user, "agent")
|
|
2683
3258
|
if not router.current_model_id:
|
|
2684
3259
|
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
2685
3260
|
|
|
@@ -2710,10 +3285,17 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
2710
3285
|
action = _extract_agent_action(str(raw))
|
|
2711
3286
|
except ValueError as exc:
|
|
2712
3287
|
transcript.append({"step": step + 1, "action": "parse_error", "raw": str(raw), "error": str(exc)})
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
)
|
|
3288
|
+
message = "작업 계획을 안정적으로 해석하지 못해 자동 실행을 중단했습니다. 요청을 더 짧고 구체적으로 다시 시도해 주세요."
|
|
3289
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3290
|
+
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3291
|
+
created_files = _collect_created_files(transcript)
|
|
3292
|
+
return {
|
|
3293
|
+
"status": "ok",
|
|
3294
|
+
"response": message,
|
|
3295
|
+
"workspace": str(AGENT_ROOT),
|
|
3296
|
+
"steps": transcript,
|
|
3297
|
+
"created_files": created_files,
|
|
3298
|
+
}
|
|
2717
3299
|
|
|
2718
3300
|
name = action.get("action")
|
|
2719
3301
|
if name == "final":
|
|
@@ -2723,16 +3305,64 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
2723
3305
|
created_files = _collect_created_files(transcript)
|
|
2724
3306
|
return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
|
|
2725
3307
|
|
|
3308
|
+
# Prevent repeated file/project creation loops with identical action+args.
|
|
3309
|
+
last_step = transcript[-1] if transcript else None
|
|
3310
|
+
current_args = action.get("args") or {}
|
|
3311
|
+
if (
|
|
3312
|
+
name in _FILE_CREATE_ACTIONS
|
|
3313
|
+
and last_step
|
|
3314
|
+
and last_step.get("action") == name
|
|
3315
|
+
and (last_step.get("args") or {}) == current_args
|
|
3316
|
+
and "result" in last_step
|
|
3317
|
+
):
|
|
3318
|
+
message = "요청한 파일 생성을 이미 완료해서 반복 실행을 중단했습니다."
|
|
3319
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3320
|
+
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3321
|
+
created_files = _collect_created_files(transcript)
|
|
3322
|
+
return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
|
|
3323
|
+
|
|
2726
3324
|
if name == "clear_history":
|
|
2727
|
-
result = clear_history(
|
|
2728
|
-
|
|
3325
|
+
result = clear_history(current_args.get("keep_last", 0))
|
|
3326
|
+
append_audit_event(
|
|
3327
|
+
"history_delete",
|
|
3328
|
+
user_email=current_user,
|
|
3329
|
+
source=req.source or "agent",
|
|
3330
|
+
keep_last=current_args.get("keep_last", 0),
|
|
3331
|
+
removed=result.get("removed", 0),
|
|
3332
|
+
kept=result.get("kept", 0),
|
|
3333
|
+
)
|
|
3334
|
+
transcript.append({"step": step + 1, "action": name, "args": current_args, "result": result})
|
|
2729
3335
|
continue
|
|
2730
3336
|
|
|
3337
|
+
risk = _agent_risk(name, current_args)
|
|
3338
|
+
|
|
3339
|
+
# Block system-path local_write even if the LLM tries it
|
|
3340
|
+
if name == "local_write":
|
|
3341
|
+
path = str(current_args.get("path", ""))
|
|
3342
|
+
if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
|
|
3343
|
+
transcript.append({
|
|
3344
|
+
"step": step + 1, "action": name, "args": current_args,
|
|
3345
|
+
"risk": "high", "error": f"BLOCKED: writing to system path is not allowed: {path}",
|
|
3346
|
+
})
|
|
3347
|
+
append_audit_event(
|
|
3348
|
+
"agent_blocked", user_email=current_user, source=req.source or "agent",
|
|
3349
|
+
action=name, path=path, reason="system_path",
|
|
3350
|
+
)
|
|
3351
|
+
continue
|
|
3352
|
+
|
|
3353
|
+
# Audit medium/high actions before execution
|
|
3354
|
+
if risk in ("medium", "high"):
|
|
3355
|
+
append_audit_event(
|
|
3356
|
+
"agent_exec", user_email=current_user, source=req.source or "agent",
|
|
3357
|
+
step=step + 1, action=name, risk=risk,
|
|
3358
|
+
args={k: v for k, v in (current_args or {}).items() if k != "content"},
|
|
3359
|
+
)
|
|
3360
|
+
|
|
2731
3361
|
try:
|
|
2732
|
-
result = execute_tool(name,
|
|
2733
|
-
transcript.append({"step": step + 1, "action": name, "args":
|
|
3362
|
+
result = execute_tool(name, current_args)
|
|
3363
|
+
transcript.append({"step": step + 1, "action": name, "args": current_args, "risk": risk, "result": result})
|
|
2734
3364
|
except (ToolError, KeyError, TypeError) as exc:
|
|
2735
|
-
transcript.append({"step": step + 1, "action": name, "args":
|
|
3365
|
+
transcript.append({"step": step + 1, "action": name, "args": current_args, "risk": risk, "error": str(exc)})
|
|
2736
3366
|
|
|
2737
3367
|
summary_context = (
|
|
2738
3368
|
f"{AGENT_SYSTEM_PROMPT}\n\n"
|
|
@@ -2799,8 +3429,17 @@ async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
|
|
|
2799
3429
|
|
|
2800
3430
|
@app.post("/tools/clear_history")
|
|
2801
3431
|
async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
|
|
2802
|
-
require_user(request)
|
|
2803
|
-
|
|
3432
|
+
current_user = require_user(request)
|
|
3433
|
+
result = clear_history(req.keep_last)
|
|
3434
|
+
append_audit_event(
|
|
3435
|
+
"history_delete",
|
|
3436
|
+
user_email=current_user,
|
|
3437
|
+
source="tools",
|
|
3438
|
+
keep_last=req.keep_last,
|
|
3439
|
+
removed=result.get("removed", 0),
|
|
3440
|
+
kept=result.get("kept", 0),
|
|
3441
|
+
)
|
|
3442
|
+
return result
|
|
2804
3443
|
|
|
2805
3444
|
|
|
2806
3445
|
@app.post("/tools/inspect_html")
|
|
@@ -2839,6 +3478,43 @@ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
|
|
|
2839
3478
|
return _tool_response(create_pdf, req.title, req.body, req.filename)
|
|
2840
3479
|
|
|
2841
3480
|
|
|
3481
|
+
@app.post("/tools/read_document")
|
|
3482
|
+
async def tools_read_document(req: ToolPathRequest, request: Request):
|
|
3483
|
+
require_user(request)
|
|
3484
|
+
return _tool_response(read_document, req.path)
|
|
3485
|
+
|
|
3486
|
+
|
|
3487
|
+
@app.get("/tools/pdf_pages")
|
|
3488
|
+
async def tools_pdf_pages(path: str, request: Request):
|
|
3489
|
+
"""Render PDF pages as base64 PNG images using PyMuPDF."""
|
|
3490
|
+
require_user(request)
|
|
3491
|
+
target = Path(path).expanduser().resolve()
|
|
3492
|
+
if not target.exists() or not target.is_file():
|
|
3493
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
3494
|
+
import fitz # PyMuPDF
|
|
3495
|
+
doc = None
|
|
3496
|
+
try:
|
|
3497
|
+
doc = fitz.open(str(target))
|
|
3498
|
+
total = len(doc)
|
|
3499
|
+
pages = []
|
|
3500
|
+
for i, page in enumerate(doc):
|
|
3501
|
+
if i >= 20: # 최대 20페이지
|
|
3502
|
+
break
|
|
3503
|
+
mat = fitz.Matrix(1.5, 1.5)
|
|
3504
|
+
pix = page.get_pixmap(matrix=mat)
|
|
3505
|
+
b64 = base64.b64encode(pix.tobytes("png")).decode()
|
|
3506
|
+
pages.append({"page": i + 1, "b64": b64})
|
|
3507
|
+
return {"total": total, "pages": pages}
|
|
3508
|
+
except Exception as e:
|
|
3509
|
+
raise HTTPException(status_code=500, detail=f"PDF 렌더링 실패: {e}")
|
|
3510
|
+
finally:
|
|
3511
|
+
if doc is not None:
|
|
3512
|
+
try:
|
|
3513
|
+
doc.close()
|
|
3514
|
+
except Exception as e:
|
|
3515
|
+
logging.warning("fitz doc close failed: %s", e)
|
|
3516
|
+
|
|
3517
|
+
|
|
2842
3518
|
@app.get("/tools/download")
|
|
2843
3519
|
async def tools_download(path: str, request: Request):
|
|
2844
3520
|
"""Serve a generated file from agent workspace for download."""
|
|
@@ -2859,7 +3535,8 @@ async def tools_download(path: str, request: Request):
|
|
|
2859
3535
|
|
|
2860
3536
|
@app.post("/upload/document")
|
|
2861
3537
|
async def upload_document(request: Request, file: UploadFile = File(...)):
|
|
2862
|
-
require_user(request)
|
|
3538
|
+
current_user = require_user(request)
|
|
3539
|
+
enforce_rate_limit(current_user, "upload")
|
|
2863
3540
|
"""Upload a document and extract text (PDF, DOCX, XLSX, PPTX, TXT, MD, CSV)."""
|
|
2864
3541
|
suffix = Path(file.filename or "upload").suffix.lower()
|
|
2865
3542
|
allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
|
|
@@ -2868,11 +3545,55 @@ async def upload_document(request: Request, file: UploadFile = File(...)):
|
|
|
2868
3545
|
contents = await file.read()
|
|
2869
3546
|
if len(contents) > 10 * 1024 * 1024:
|
|
2870
3547
|
raise HTTPException(status_code=400, detail="파일이 너무 큽니다. 최대 10MB.")
|
|
3548
|
+
# MIME sniff — verify the bytes actually match the claimed extension (cheap header check)
|
|
3549
|
+
if not _bytes_match_extension(contents, suffix):
|
|
3550
|
+
raise HTTPException(status_code=400, detail=f"파일 내용이 확장자({suffix})와 일치하지 않습니다.")
|
|
2871
3551
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
|
2872
3552
|
tmp.write(contents)
|
|
2873
3553
|
tmp_path = tmp.name
|
|
2874
3554
|
try:
|
|
2875
3555
|
result = read_document(tmp_path)
|
|
3556
|
+
sensitive = classify_sensitive_message(
|
|
3557
|
+
{
|
|
3558
|
+
"role": "document",
|
|
3559
|
+
"content": result.get("content") or result.get("preview") or "",
|
|
3560
|
+
"user_email": current_user,
|
|
3561
|
+
"timestamp": datetime.now().isoformat(),
|
|
3562
|
+
},
|
|
3563
|
+
-1,
|
|
3564
|
+
)
|
|
3565
|
+
try:
|
|
3566
|
+
if not (ENABLE_GRAPH and KNOWLEDGE_GRAPH):
|
|
3567
|
+
raise RuntimeError("graph disabled")
|
|
3568
|
+
graph_result = KNOWLEDGE_GRAPH.ingest_document(
|
|
3569
|
+
Path(tmp_path),
|
|
3570
|
+
original_filename=file.filename,
|
|
3571
|
+
mime_type=file.content_type,
|
|
3572
|
+
uploader=current_user,
|
|
3573
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
3574
|
+
extracted=result,
|
|
3575
|
+
)
|
|
3576
|
+
result["knowledge_graph"] = {
|
|
3577
|
+
"node_id": graph_result["node_id"],
|
|
3578
|
+
"sha256": graph_result["sha256"],
|
|
3579
|
+
}
|
|
3580
|
+
except Exception as graph_error:
|
|
3581
|
+
logging.warning("knowledge graph document ingest failed: %s", graph_error)
|
|
3582
|
+
result["knowledge_graph"] = {"error": str(graph_error)}
|
|
3583
|
+
append_audit_event(
|
|
3584
|
+
"document_upload",
|
|
3585
|
+
user_email=current_user,
|
|
3586
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
3587
|
+
filename=file.filename,
|
|
3588
|
+
mime_type=file.content_type,
|
|
3589
|
+
ext=suffix,
|
|
3590
|
+
bytes=len(contents),
|
|
3591
|
+
extracted_chars=result.get("chars"),
|
|
3592
|
+
graph_node=(result.get("knowledge_graph") or {}).get("node_id"),
|
|
3593
|
+
content_preview=sensitive.get("preview"),
|
|
3594
|
+
sensitivity=sensitive.get("sensitivity"),
|
|
3595
|
+
sensitive_labels=sensitive.get("labels") or [],
|
|
3596
|
+
)
|
|
2876
3597
|
except ToolError as exc:
|
|
2877
3598
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
2878
3599
|
finally:
|
|
@@ -2916,6 +3637,16 @@ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
|
|
|
2916
3637
|
return _tool_response(local_read, req.path)
|
|
2917
3638
|
|
|
2918
3639
|
|
|
3640
|
+
@app.get("/local/serve")
|
|
3641
|
+
async def local_serve_file(path: str, request: Request):
|
|
3642
|
+
"""Serve a local file (images etc.) directly for browser preview."""
|
|
3643
|
+
require_user(request)
|
|
3644
|
+
target = Path(path).expanduser().resolve()
|
|
3645
|
+
if not target.exists() or not target.is_file():
|
|
3646
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
3647
|
+
return FileResponse(str(target))
|
|
3648
|
+
|
|
3649
|
+
|
|
2919
3650
|
@app.post("/local/write")
|
|
2920
3651
|
async def local_write_endpoint(req: LocalWriteRequest, request: Request):
|
|
2921
3652
|
require_user(request)
|
|
@@ -3311,6 +4042,10 @@ async def mcp_tools():
|
|
|
3311
4042
|
{"name": "knowledge_save", "description": "Save a note into the local knowledge garden."},
|
|
3312
4043
|
{"name": "knowledge_search", "description": "Search the local knowledge garden."},
|
|
3313
4044
|
{"name": "knowledge_tree", "description": "List local knowledge garden markdown files."},
|
|
4045
|
+
{"name": "knowledge_graph_ingest", "description": "Ingest a message, AI answer, or connector event into the SQLite knowledge graph."},
|
|
4046
|
+
{"name": "knowledge_graph_search", "description": "Search graph nodes, summaries, and JSON metadata."},
|
|
4047
|
+
{"name": "knowledge_graph_graph", "description": "Return Obsidian-style graph nodes and edges."},
|
|
4048
|
+
{"name": "knowledge_graph_context", "description": "Return compact graph-backed RAG context for a prompt."},
|
|
3314
4049
|
{"name": "obsidian_save", "description": "Save a note into the Obsidian-compatible memory vault."},
|
|
3315
4050
|
{"name": "obsidian_search", "description": "Search the Obsidian-compatible memory vault."},
|
|
3316
4051
|
{"name": "obsidian_tree", "description": "List Obsidian memory vault markdown files."},
|
|
@@ -3321,7 +4056,7 @@ async def mcp_tools():
|
|
|
3321
4056
|
{"name": "network_status", "description": "Get current local/private IP, public IP, hostname, and Wi-Fi info."},
|
|
3322
4057
|
{"name": "run_command", "description": "Run an allowlisted local command inside the workspace."},
|
|
3323
4058
|
{"name": "build_project", "description": "Run an allowlisted package.json build/compile/typecheck/test script."},
|
|
3324
|
-
{"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release script."},
|
|
4059
|
+
{"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release/package installer script (pkg/exe)."},
|
|
3325
4060
|
],
|
|
3326
4061
|
}
|
|
3327
4062
|
|
|
@@ -3353,7 +4088,33 @@ async def mcp_connector(mcp_id: str, request: Request):
|
|
|
3353
4088
|
|
|
3354
4089
|
@app.post("/mcp/call")
|
|
3355
4090
|
async def mcp_call(req: McpCallRequest, request: Request):
|
|
3356
|
-
require_user(request)
|
|
4091
|
+
current_user = require_user(request)
|
|
4092
|
+
args = req.args or {}
|
|
4093
|
+
if req.action == "knowledge_graph_ingest":
|
|
4094
|
+
_require_graph()
|
|
4095
|
+
return KNOWLEDGE_GRAPH.ingest_message(
|
|
4096
|
+
args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
|
|
4097
|
+
args.get("content") or "",
|
|
4098
|
+
user_email=args.get("user_email") or current_user,
|
|
4099
|
+
user_nickname=args.get("user_nickname"),
|
|
4100
|
+
source=args.get("source") or "mcp",
|
|
4101
|
+
conversation_id=args.get("conversation_id"),
|
|
4102
|
+
raw=args,
|
|
4103
|
+
)
|
|
4104
|
+
if req.action == "knowledge_graph_search":
|
|
4105
|
+
_require_graph()
|
|
4106
|
+
return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
|
|
4107
|
+
if req.action == "knowledge_graph_graph":
|
|
4108
|
+
_require_graph()
|
|
4109
|
+
return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
|
|
4110
|
+
if req.action == "knowledge_graph_context":
|
|
4111
|
+
_require_graph()
|
|
4112
|
+
return {
|
|
4113
|
+
"context": KNOWLEDGE_GRAPH.context_for_query(
|
|
4114
|
+
args.get("query") or args.get("q") or "",
|
|
4115
|
+
args.get("limit", 6),
|
|
4116
|
+
)
|
|
4117
|
+
}
|
|
3357
4118
|
return _tool_response(execute_tool, req.action, req.args or {})
|
|
3358
4119
|
|
|
3359
4120
|
|