ltcai 1.3.0 → 1.4.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,579 @@
1
+ """Direct tool, upload, MCP, and knowledge utility routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import logging
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional
11
+
12
+ from fastapi import APIRouter, File, HTTPException, Request, UploadFile
13
+ from fastapi.responses import FileResponse
14
+ from pydantic import BaseModel
15
+
16
+ from latticeai.api.computer_use import create_computer_use_router
17
+ from latticeai.api.local_files import create_local_files_router
18
+ from latticeai.api.mcp import create_mcp_router
19
+ from latticeai.api.permissions import create_permissions_router
20
+ from latticeai.services.upload_service import process_uploaded_document
21
+ from latticeai.services.tool_dispatch import (
22
+ TOOL_GOVERNANCE,
23
+ TOOL_GOVERNANCE_DEFAULT as _TOOL_GOVERNANCE_DEFAULT,
24
+ check_tool_role as _check_tool_role,
25
+ get_tool_permission,
26
+ list_tool_permissions,
27
+ )
28
+ from p_reinforce import BRAIN_DIR
29
+ from tools import (
30
+ AGENT_ROOT,
31
+ ToolError,
32
+ build_project,
33
+ create_docx,
34
+ create_pdf,
35
+ create_pptx,
36
+ create_xlsx,
37
+ read_document,
38
+ deploy_project,
39
+ edit_file,
40
+ git_diff,
41
+ git_log,
42
+ git_show,
43
+ git_status,
44
+ grep,
45
+ inspect_html,
46
+ knowledge_save,
47
+ knowledge_search,
48
+ knowledge_tree,
49
+ list_dir,
50
+ network_status,
51
+ obsidian_save,
52
+ obsidian_search,
53
+ obsidian_tree,
54
+ preview_url,
55
+ read_file,
56
+ run_command,
57
+ search_files,
58
+ todo_read,
59
+ todo_write,
60
+ workspace_tree,
61
+ write_file,
62
+ )
63
+
64
+
65
+ class ToolPathRequest(BaseModel):
66
+ path: str = "."
67
+ approval_token: Optional[str] = None
68
+
69
+
70
+ class ToolWriteFileRequest(BaseModel):
71
+ path: str
72
+ content: str
73
+
74
+
75
+ class ToolRunCommandRequest(BaseModel):
76
+ command: str
77
+ cwd: Optional[str] = "."
78
+
79
+
80
+ class ToolScriptRequest(BaseModel):
81
+ cwd: Optional[str] = "."
82
+ script: str = "build"
83
+
84
+
85
+ class ToolSearchFilesRequest(BaseModel):
86
+ query: str
87
+ path: str = "."
88
+ max_results: int = 20
89
+
90
+
91
+ class ToolReadFileRequest(BaseModel):
92
+ path: str
93
+ offset: int = 0
94
+ limit: int = 0
95
+ line_numbers: bool = True
96
+
97
+
98
+ class ToolEditFileRequest(BaseModel):
99
+ path: str
100
+ old_string: str
101
+ new_string: str
102
+ replace_all: bool = False
103
+
104
+
105
+ class ToolGrepRequest(BaseModel):
106
+ pattern: str
107
+ path: str = "."
108
+ glob: Optional[str] = None
109
+ max_results: int = 50
110
+ case_insensitive: bool = False
111
+ context_lines: int = 0
112
+
113
+
114
+ class ToolTodoWriteRequest(BaseModel):
115
+ todos: List[Dict] = []
116
+
117
+
118
+ class ToolWorkspaceTreeRequest(BaseModel):
119
+ path: str = "."
120
+ max_depth: int = 3
121
+
122
+
123
+ class ToolClearHistoryRequest(BaseModel):
124
+ keep_last: int = 0
125
+
126
+
127
+ class ToolKnowledgeSaveRequest(BaseModel):
128
+ content: str
129
+ folder: str = "00_Raw"
130
+ title: Optional[str] = None
131
+
132
+
133
+ class ToolKnowledgeSearchRequest(BaseModel):
134
+ query: str
135
+ max_results: int = 5
136
+
137
+
138
+ class ToolDocxRequest(BaseModel):
139
+ title: str = ""
140
+ body: str = ""
141
+ filename: str = "document.docx"
142
+
143
+
144
+ class ToolXlsxRequest(BaseModel):
145
+ rows: List[List] = []
146
+ filename: str = "spreadsheet.xlsx"
147
+ sheet_name: str = "Sheet1"
148
+
149
+
150
+ class ToolPptxRequest(BaseModel):
151
+ title: str = ""
152
+ slides: List[Dict] = []
153
+ filename: str = "presentation.pptx"
154
+
155
+
156
+ class ToolPdfRequest(BaseModel):
157
+ title: str = ""
158
+ body: str = ""
159
+ filename: str = "document.pdf"
160
+
161
+
162
+ class ToolGitDiffRequest(BaseModel):
163
+ path: Optional[str] = None
164
+ cwd: Optional[str] = "."
165
+
166
+
167
+ class ToolGitLogRequest(BaseModel):
168
+ max_count: int = 5
169
+ cwd: Optional[str] = "."
170
+
171
+
172
+ class ToolGitShowRequest(BaseModel):
173
+ revision: str = "HEAD"
174
+ cwd: Optional[str] = "."
175
+
176
+
177
+ def create_tools_router(
178
+ *,
179
+ config,
180
+ data_dir: Path,
181
+ static_dir: Path,
182
+ model_router,
183
+ require_user,
184
+ require_admin,
185
+ get_current_user,
186
+ clear_history,
187
+ append_audit_event,
188
+ enforce_rate_limit,
189
+ bytes_match_extension,
190
+ classify_sensitive_message,
191
+ save_to_history,
192
+ enable_graph: bool,
193
+ knowledge_graph,
194
+ require_graph,
195
+ local_kg_watcher,
196
+ load_mcp_installs,
197
+ recommend_mcps,
198
+ install_mcp,
199
+ mcp_public_item,
200
+ ) -> APIRouter:
201
+ api_router = APIRouter()
202
+ CONFIG = config
203
+ DATA_DIR = data_dir
204
+ STATIC_DIR = static_dir
205
+ router = model_router
206
+ ENABLE_GRAPH = enable_graph
207
+ KNOWLEDGE_GRAPH = knowledge_graph
208
+ LOCAL_KG_WATCHER = local_kg_watcher
209
+ _require_graph = require_graph
210
+ _bytes_match_extension = bytes_match_extension
211
+ permissions_router, permission_gateway = create_permissions_router(
212
+ config=CONFIG,
213
+ data_dir=DATA_DIR,
214
+ require_user=require_user,
215
+ require_admin=require_admin,
216
+ get_current_user=get_current_user,
217
+ )
218
+
219
+ # ── Direct Tool API ───────────────────────────────────────────────────────────
220
+
221
+ def _tool_response(fn, *args):
222
+ try:
223
+ return {"status": "ok", "workspace": str(AGENT_ROOT), "result": fn(*args)}
224
+ except ToolError as exc:
225
+ raise HTTPException(status_code=400, detail=str(exc))
226
+
227
+
228
+ @api_router.post("/tools/list_dir")
229
+ async def tools_list_dir(req: ToolPathRequest, request: Request):
230
+ require_user(request)
231
+ return _tool_response(list_dir, req.path)
232
+
233
+
234
+ @api_router.post("/tools/workspace_tree")
235
+ async def tools_workspace_tree(req: ToolWorkspaceTreeRequest, request: Request):
236
+ require_user(request)
237
+ return _tool_response(workspace_tree, req.path, req.max_depth)
238
+
239
+
240
+ @api_router.post("/tools/read_file")
241
+ async def tools_read_file(req: ToolReadFileRequest, request: Request):
242
+ require_user(request)
243
+ try:
244
+ return {"status": "ok", "workspace": str(AGENT_ROOT),
245
+ "result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
246
+ except ToolError as exc:
247
+ raise HTTPException(status_code=400, detail=str(exc))
248
+
249
+
250
+ @api_router.post("/tools/write_file")
251
+ async def tools_write_file(req: ToolWriteFileRequest, request: Request):
252
+ require_user(request)
253
+ return _tool_response(write_file, req.path, req.content)
254
+
255
+
256
+ @api_router.post("/tools/edit_file")
257
+ async def tools_edit_file(req: ToolEditFileRequest, request: Request):
258
+ require_user(request)
259
+ try:
260
+ return {"status": "ok", "workspace": str(AGENT_ROOT),
261
+ "result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
262
+ except ToolError as exc:
263
+ raise HTTPException(status_code=400, detail=str(exc))
264
+
265
+
266
+ @api_router.post("/tools/search_files")
267
+ async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
268
+ require_user(request)
269
+ return _tool_response(search_files, req.query, req.path, req.max_results)
270
+
271
+
272
+ @api_router.post("/tools/grep")
273
+ async def tools_grep(req: ToolGrepRequest, request: Request):
274
+ require_user(request)
275
+ try:
276
+ return {"status": "ok", "workspace": str(AGENT_ROOT),
277
+ "result": grep(
278
+ req.pattern,
279
+ path=req.path,
280
+ glob=req.glob,
281
+ max_results=req.max_results,
282
+ case_insensitive=req.case_insensitive,
283
+ context_lines=req.context_lines,
284
+ )}
285
+ except ToolError as exc:
286
+ raise HTTPException(status_code=400, detail=str(exc))
287
+
288
+
289
+ @api_router.post("/tools/todo_read")
290
+ async def tools_todo_read(request: Request):
291
+ require_user(request)
292
+ return _tool_response(todo_read)
293
+
294
+
295
+ @api_router.post("/tools/todo_write")
296
+ async def tools_todo_write(req: ToolTodoWriteRequest, request: Request):
297
+ require_user(request)
298
+ return _tool_response(todo_write, req.todos)
299
+
300
+
301
+ @api_router.post("/tools/clear_history")
302
+ async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
303
+ current_user = require_user(request)
304
+ result = clear_history(req.keep_last)
305
+ append_audit_event(
306
+ "history_delete",
307
+ user_email=current_user,
308
+ source="tools",
309
+ keep_last=req.keep_last,
310
+ removed=result.get("removed", 0),
311
+ kept=result.get("kept", 0),
312
+ )
313
+ return result
314
+
315
+
316
+ @api_router.post("/tools/inspect_html")
317
+ async def tools_inspect_html(req: ToolPathRequest, request: Request):
318
+ require_user(request)
319
+ return _tool_response(inspect_html, req.path)
320
+
321
+
322
+ @api_router.post("/tools/preview_url")
323
+ async def tools_preview_url(req: ToolPathRequest, request: Request):
324
+ require_user(request)
325
+ return _tool_response(preview_url, req.path)
326
+
327
+
328
+ @api_router.post("/tools/create_docx")
329
+ async def tools_create_docx(req: ToolDocxRequest, request: Request):
330
+ require_user(request)
331
+ return _tool_response(create_docx, req.title, req.body, req.filename)
332
+
333
+
334
+ @api_router.post("/tools/create_xlsx")
335
+ async def tools_create_xlsx(req: ToolXlsxRequest, request: Request):
336
+ require_user(request)
337
+ return _tool_response(create_xlsx, req.rows, req.filename, req.sheet_name)
338
+
339
+
340
+ @api_router.post("/tools/create_pptx")
341
+ async def tools_create_pptx(req: ToolPptxRequest, request: Request):
342
+ require_user(request)
343
+ return _tool_response(create_pptx, req.title, req.slides, req.filename)
344
+
345
+
346
+ @api_router.post("/tools/create_pdf")
347
+ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
348
+ require_user(request)
349
+ return _tool_response(create_pdf, req.title, req.body, req.filename)
350
+
351
+
352
+ @api_router.post("/tools/read_document")
353
+ async def tools_read_document(req: ToolPathRequest, request: Request):
354
+ current_user = require_user(request)
355
+ if Path(req.path).expanduser().is_absolute():
356
+ permission_gateway.require_local_approval(
357
+ token=req.approval_token,
358
+ path=req.path,
359
+ action="read",
360
+ user_email=current_user,
361
+ )
362
+ return _tool_response(read_document, req.path)
363
+
364
+
365
+ @api_router.get("/tools/pdf_pages")
366
+ async def tools_pdf_pages(path: str, request: Request, approval_token: Optional[str] = None):
367
+ """Render PDF pages as base64 PNG images using pypdfium2 (Apache-2.0)."""
368
+ current_user = require_user(request)
369
+ permission_gateway.require_local_approval(
370
+ token=approval_token,
371
+ path=path,
372
+ action="read",
373
+ user_email=current_user,
374
+ )
375
+ target = Path(path).expanduser().resolve()
376
+ if not target.exists() or not target.is_file():
377
+ raise HTTPException(status_code=404, detail="File not found")
378
+ import pypdfium2 as pdfium
379
+ doc = None
380
+ try:
381
+ doc = pdfium.PdfDocument(str(target))
382
+ total = len(doc)
383
+ pages = []
384
+ for i in range(min(total, 20)): # 최대 20페이지
385
+ page = doc[i]
386
+ bitmap = page.render(scale=1.5)
387
+ pil_image = bitmap.to_pil()
388
+ buf = io.BytesIO()
389
+ pil_image.save(buf, format="PNG")
390
+ b64 = base64.b64encode(buf.getvalue()).decode()
391
+ pages.append({"page": i + 1, "b64": b64})
392
+ return {"total": total, "pages": pages}
393
+ except Exception as e:
394
+ raise HTTPException(status_code=500, detail=f"PDF 렌더링 실패: {e}")
395
+ finally:
396
+ if doc is not None:
397
+ try:
398
+ doc.close()
399
+ except Exception as e:
400
+ logging.warning("pypdfium2 doc close failed: %s", e)
401
+
402
+
403
+ @api_router.get("/tools/download")
404
+ async def tools_download(path: str, request: Request):
405
+ """Serve a generated file from agent workspace for download."""
406
+ require_user(request)
407
+ from urllib.parse import unquote
408
+ rel = unquote(path).lstrip("/")
409
+ target = (AGENT_ROOT / rel).resolve()
410
+ if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
411
+ raise HTTPException(status_code=403, detail="경로가 작업 공간 밖입니다.")
412
+ if not target.exists() or not target.is_file():
413
+ raise HTTPException(status_code=404, detail="파일이 없습니다.")
414
+ return FileResponse(
415
+ path=target,
416
+ filename=target.name,
417
+ media_type="application/octet-stream",
418
+ )
419
+
420
+
421
+ @api_router.post("/upload/document")
422
+ async def upload_document(request: Request, file: UploadFile = File(...)):
423
+ current_user = require_user(request)
424
+ return await process_uploaded_document(
425
+ request=request,
426
+ file=file,
427
+ current_user=current_user,
428
+ enable_graph=ENABLE_GRAPH,
429
+ knowledge_graph=KNOWLEDGE_GRAPH,
430
+ bytes_match_extension=_bytes_match_extension,
431
+ classify_sensitive_message=classify_sensitive_message,
432
+ append_audit_event=append_audit_event,
433
+ enforce_rate_limit=enforce_rate_limit,
434
+ )
435
+
436
+
437
+ api_router.include_router(permissions_router)
438
+ api_router.include_router(create_local_files_router(
439
+ require_user=require_user,
440
+ tool_response=_tool_response,
441
+ permission_gateway=permission_gateway,
442
+ knowledge_graph=KNOWLEDGE_GRAPH,
443
+ require_graph=_require_graph,
444
+ static_dir=STATIC_DIR,
445
+ local_kg_watcher=LOCAL_KG_WATCHER,
446
+ ))
447
+ api_router.include_router(create_computer_use_router(
448
+ model_router=router,
449
+ require_user=require_user,
450
+ tool_response=_tool_response,
451
+ save_to_history=save_to_history,
452
+ ))
453
+
454
+ @api_router.post("/tools/knowledge_save")
455
+ async def tools_knowledge_save(req: ToolKnowledgeSaveRequest, request: Request):
456
+ require_user(request)
457
+ return _tool_response(knowledge_save, req.content, req.folder, req.title)
458
+
459
+
460
+ @api_router.post("/tools/knowledge_search")
461
+ async def tools_knowledge_search(req: ToolKnowledgeSearchRequest, request: Request):
462
+ require_user(request)
463
+ return _tool_response(knowledge_search, req.query, req.max_results)
464
+
465
+
466
+ @api_router.get("/tools/knowledge_tree")
467
+ async def tools_knowledge_tree(request: Request):
468
+ require_user(request)
469
+ return _tool_response(knowledge_tree)
470
+
471
+
472
+ @api_router.post("/tools/obsidian_save")
473
+ async def tools_obsidian_save(req: ToolKnowledgeSaveRequest, request: Request):
474
+ require_user(request)
475
+ return _tool_response(obsidian_save, req.content, req.folder, req.title)
476
+
477
+
478
+ @api_router.post("/tools/obsidian_search")
479
+ async def tools_obsidian_search(req: ToolKnowledgeSearchRequest, request: Request):
480
+ require_user(request)
481
+ return _tool_response(obsidian_search, req.query, req.max_results)
482
+
483
+
484
+ @api_router.get("/tools/obsidian_tree")
485
+ async def tools_obsidian_tree(request: Request):
486
+ require_user(request)
487
+ return _tool_response(obsidian_tree)
488
+
489
+
490
+ @api_router.get("/obsidian/status")
491
+ async def obsidian_status(request: Request):
492
+ require_user(request)
493
+ return {
494
+ "status": "ok",
495
+ "vault_root": str(BRAIN_DIR),
496
+ "folders": [path.name for path in BRAIN_DIR.iterdir() if path.is_dir()] if BRAIN_DIR.exists() else [],
497
+ "ocr_engine": shutil.which("tesseract") or None,
498
+ }
499
+
500
+
501
+ @api_router.get("/tools/git_status")
502
+ async def tools_git_status(request: Request):
503
+ require_user(request)
504
+ return _tool_response(git_status)
505
+
506
+
507
+ @api_router.post("/tools/git_diff")
508
+ async def tools_git_diff(req: ToolGitDiffRequest, request: Request):
509
+ require_user(request)
510
+ return _tool_response(git_diff, req.path, req.cwd)
511
+
512
+
513
+ @api_router.post("/tools/git_log")
514
+ async def tools_git_log(req: ToolGitLogRequest, request: Request):
515
+ require_user(request)
516
+ return _tool_response(git_log, req.max_count, req.cwd)
517
+
518
+
519
+ @api_router.post("/tools/git_show")
520
+ async def tools_git_show(req: ToolGitShowRequest, request: Request):
521
+ require_user(request)
522
+ return _tool_response(git_show, req.revision, req.cwd)
523
+
524
+
525
+ @api_router.post("/tools/run_command")
526
+ async def tools_run_command(req: ToolRunCommandRequest, request: Request):
527
+ require_admin(request)
528
+ return _tool_response(run_command, req.command, req.cwd)
529
+
530
+
531
+ @api_router.get("/tools/network_status")
532
+ async def tools_network_status(request: Request):
533
+ require_user(request)
534
+ return _tool_response(network_status)
535
+
536
+
537
+ @api_router.post("/tools/build_project")
538
+ async def tools_build_project(req: ToolScriptRequest, request: Request):
539
+ require_admin(request)
540
+ return _tool_response(build_project, req.cwd, req.script)
541
+
542
+
543
+ @api_router.post("/tools/deploy_project")
544
+ async def tools_deploy_project(req: ToolScriptRequest, request: Request):
545
+ require_admin(request)
546
+ return _tool_response(deploy_project, req.cwd, req.script)
547
+
548
+
549
+ @api_router.get("/tools/permissions")
550
+ async def tools_permissions(request: Request):
551
+ """Compact tool permission view (tool / risk / requires_approval / network).
552
+
553
+ A simpler authorization-layer summary derived from TOOL_GOVERNANCE.
554
+ Use /mcp/tools for the full 7-dimensional governance object.
555
+ """
556
+ require_user(request)
557
+ return {"status": "ok", "permissions": list_tool_permissions()}
558
+
559
+
560
+ # ── MCP / skills / plugins router (latticeai.api.mcp, v1.3.0) ────────────────
561
+ api_router.include_router(create_mcp_router(
562
+ require_user=require_user,
563
+ require_admin=require_admin,
564
+ append_audit_event=append_audit_event,
565
+ load_mcp_installs=load_mcp_installs,
566
+ recommend_mcps=recommend_mcps,
567
+ install_mcp=install_mcp,
568
+ mcp_public_item=mcp_public_item,
569
+ get_tool_permission=get_tool_permission,
570
+ tool_governance=TOOL_GOVERNANCE,
571
+ tool_governance_default=_TOOL_GOVERNANCE_DEFAULT,
572
+ check_tool_role=_check_tool_role,
573
+ tool_response=_tool_response,
574
+ require_graph=_require_graph,
575
+ knowledge_graph=KNOWLEDGE_GRAPH,
576
+ data_dir=DATA_DIR,
577
+ ))
578
+
579
+ return api_router
@@ -18,7 +18,7 @@ from pathlib import Path
18
18
  from typing import Any, Callable, Dict, Iterable, List, Optional
19
19
 
20
20
 
21
- WORKSPACE_OS_VERSION = "1.3.0"
21
+ WORKSPACE_OS_VERSION = "1.4.0"
22
22
 
23
23
  # Workspace types separate single-user Personal workspaces from shared
24
24
  # Organization workspaces. Both keep the same local-first JSON store; the type