ltcai 3.4.0 → 3.5.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.
- package/README.md +175 -225
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +42 -0
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/chat.py +6 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/local_files.py +76 -10
- package/latticeai/api/tools.py +35 -35
- package/latticeai/core/agent.py +13 -2
- package/latticeai/core/builtin_hooks.py +106 -0
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/hooks.py +76 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/oidc.py +205 -0
- package/latticeai/core/security.py +59 -5
- package/latticeai/core/workflow_engine.py +3 -3
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +22 -34
- package/latticeai/services/platform_runtime.py +18 -6
- package/latticeai/services/tool_dispatch.py +2 -0
- package/latticeai/services/upload_service.py +24 -4
- package/local_knowledge_api.py +27 -1
- package/package.json +3 -3
- package/requirements.txt +1 -0
- package/scripts/check_python.py +87 -0
- package/static/css/reference/account.css +1 -1
- package/static/css/reference/admin.css +1 -1
- package/static/css/reference/base.css +8 -5
- package/static/css/reference/chat.css +8 -8
- package/static/css/reference/graph.css +2 -2
- package/static/css/responsive.css +2 -2
- package/static/v3/asset-manifest.json +9 -9
- package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
- package/static/v3/css/lattice.shell.css +2 -1
- package/static/v3/js/{app.c4acfdd8.js → app.d086489d.js} +1 -1
- package/static/v3/js/core/{components.35f02e4c.js → components.f25b3b93.js} +1 -1
- package/static/v3/js/core/components.js +1 -1
- package/static/v3/js/core/{shell.80a6ad82.js → shell.d05266f5.js} +1 -1
- package/static/v3/js/views/{hooks.13845954.js → hooks.37895880.js} +12 -7
- package/static/v3/js/views/hooks.js +12 -7
- package/static/v3/js/views/{my-computer.c3ef5283.js → my-computer.d9d9ae1c.js} +7 -4
- package/static/v3/js/views/my-computer.js +7 -4
- package/static/workspace.css +1 -1
- package/tools/__init__.py +276 -0
- package/tools/commands.py +188 -0
- package/tools/computer.py +185 -0
- package/tools/documents.py +243 -0
- package/tools/filesystem.py +560 -0
- package/tools/knowledge.py +97 -0
- package/tools/local_files.py +69 -0
- package/tools/network.py +66 -0
- package/tools.py +0 -1525
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Computer-use tools: screenshots and native desktop control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from tools import ToolError
|
|
13
|
+
|
|
14
|
+
_PLATFORM = platform.system()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Computer Use ──────────────────────────────────────────────────────────────
|
|
18
|
+
_CU_AVAILABLE = False
|
|
19
|
+
_pyautogui = None
|
|
20
|
+
|
|
21
|
+
def _init_computer_use():
|
|
22
|
+
global _CU_AVAILABLE, _pyautogui
|
|
23
|
+
try:
|
|
24
|
+
import pyautogui as _pag
|
|
25
|
+
_pag.FAILSAFE = True # 마우스를 좌상단 코너로 이동하면 중단
|
|
26
|
+
_pag.PAUSE = 0.25
|
|
27
|
+
_pyautogui = _pag
|
|
28
|
+
_CU_AVAILABLE = True
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
_init_computer_use()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def computer_screenshot() -> Dict[str, Any]:
|
|
36
|
+
"""현재 화면 전체를 캡처하여 base64 PNG로 반환합니다."""
|
|
37
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file:
|
|
38
|
+
tmp = tmp_file.name
|
|
39
|
+
try:
|
|
40
|
+
if _PLATFORM == "Darwin":
|
|
41
|
+
r = subprocess.run(
|
|
42
|
+
["screencapture", "-x", "-t", "png", tmp],
|
|
43
|
+
capture_output=True, timeout=10, check=False,
|
|
44
|
+
)
|
|
45
|
+
if r.returncode != 0:
|
|
46
|
+
raise ToolError(f"screencapture 실패: {r.stderr.decode()}")
|
|
47
|
+
elif _CU_AVAILABLE:
|
|
48
|
+
# Windows / Linux: use pyautogui screenshot
|
|
49
|
+
screenshot = _pyautogui.screenshot()
|
|
50
|
+
screenshot.save(tmp)
|
|
51
|
+
else:
|
|
52
|
+
raise ToolError("스크린샷 불가: macOS 전용 screencapture 또는 pyautogui 필요")
|
|
53
|
+
with open(tmp, "rb") as f:
|
|
54
|
+
b64 = base64.b64encode(f.read()).decode()
|
|
55
|
+
size = os.path.getsize(tmp)
|
|
56
|
+
w, h = (_pyautogui.size() if _CU_AVAILABLE else (0, 0))
|
|
57
|
+
return {
|
|
58
|
+
"screenshot_b64": b64,
|
|
59
|
+
"format": "png",
|
|
60
|
+
"bytes": size,
|
|
61
|
+
"screen_width": int(w),
|
|
62
|
+
"screen_height": int(h),
|
|
63
|
+
}
|
|
64
|
+
finally:
|
|
65
|
+
try:
|
|
66
|
+
if os.path.exists(tmp):
|
|
67
|
+
os.unlink(tmp)
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def computer_open_app(app: str = "Google Chrome") -> Dict[str, Any]:
|
|
73
|
+
"""앱을 실행하거나 앞으로 가져옵니다 (macOS/Windows/Linux)."""
|
|
74
|
+
app = str(app or "Google Chrome").strip()
|
|
75
|
+
if not app:
|
|
76
|
+
raise ToolError("앱 이름이 필요합니다.")
|
|
77
|
+
if _PLATFORM == "Darwin":
|
|
78
|
+
cmd = ["open", "-a", app]
|
|
79
|
+
elif _PLATFORM == "Windows":
|
|
80
|
+
cmd = ["cmd", "/c", "start", "", app]
|
|
81
|
+
else:
|
|
82
|
+
cmd = ["xdg-open", app]
|
|
83
|
+
r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
84
|
+
if r.returncode != 0:
|
|
85
|
+
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
86
|
+
raise ToolError(f"앱 열기 실패: {err or app}")
|
|
87
|
+
return {"action": "open_app", "app": app}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def computer_open_url(url: str, app: str = "Google Chrome") -> Dict[str, Any]:
|
|
91
|
+
"""URL을 브라우저로 엽니다 (macOS/Windows/Linux)."""
|
|
92
|
+
url = str(url or "").strip()
|
|
93
|
+
app = str(app or "").strip()
|
|
94
|
+
if not url:
|
|
95
|
+
raise ToolError("URL이 필요합니다.")
|
|
96
|
+
if "://" not in url and not url.startswith(("localhost", "127.0.0.1")):
|
|
97
|
+
url = "https://" + url
|
|
98
|
+
if _PLATFORM == "Darwin":
|
|
99
|
+
cmd = ["open", "-a", app, url] if app else ["open", url]
|
|
100
|
+
elif _PLATFORM == "Windows":
|
|
101
|
+
cmd = ["cmd", "/c", "start", "", url]
|
|
102
|
+
else:
|
|
103
|
+
cmd = ["xdg-open", url]
|
|
104
|
+
r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
105
|
+
if r.returncode != 0:
|
|
106
|
+
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
107
|
+
raise ToolError(f"URL 열기 실패: {err or url}")
|
|
108
|
+
return {"action": "open_url", "app": app or "default", "url": url}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def computer_click(x: int, y: int, button: str = "left", double: bool = False) -> Dict[str, Any]:
|
|
112
|
+
"""화면 좌표 (x, y)를 클릭합니다."""
|
|
113
|
+
if not _CU_AVAILABLE:
|
|
114
|
+
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
115
|
+
x, y = int(x), int(y)
|
|
116
|
+
if double:
|
|
117
|
+
_pyautogui.doubleClick(x, y)
|
|
118
|
+
elif button == "right":
|
|
119
|
+
_pyautogui.rightClick(x, y)
|
|
120
|
+
elif button == "middle":
|
|
121
|
+
_pyautogui.middleClick(x, y)
|
|
122
|
+
else:
|
|
123
|
+
_pyautogui.click(x, y)
|
|
124
|
+
return {"action": "click", "x": x, "y": y, "button": button, "double": double}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def computer_type(text: str, interval: float = 0.04) -> Dict[str, Any]:
|
|
128
|
+
"""현재 포커스된 위치에 텍스트를 입력합니다."""
|
|
129
|
+
if not _CU_AVAILABLE:
|
|
130
|
+
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
131
|
+
_pyautogui.write(str(text), interval=float(interval))
|
|
132
|
+
return {"action": "type", "text": (text[:60] + "...") if len(text) > 60 else text, "chars": len(text)}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def computer_key(key: str) -> Dict[str, Any]:
|
|
136
|
+
"""키보드 키를 누릅니다. 예: 'return', 'escape', 'command+c', 'tab'"""
|
|
137
|
+
if not _CU_AVAILABLE:
|
|
138
|
+
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
139
|
+
key = str(key)
|
|
140
|
+
if "+" in key:
|
|
141
|
+
_pyautogui.hotkey(*key.split("+"))
|
|
142
|
+
else:
|
|
143
|
+
_pyautogui.press(key)
|
|
144
|
+
return {"action": "key", "key": key}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def computer_scroll(x: int, y: int, direction: str = "down", clicks: int = 3) -> Dict[str, Any]:
|
|
148
|
+
"""화면 좌표에서 스크롤합니다."""
|
|
149
|
+
if not _CU_AVAILABLE:
|
|
150
|
+
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
151
|
+
x, y, clicks = int(x), int(y), int(clicks)
|
|
152
|
+
_pyautogui.moveTo(x, y)
|
|
153
|
+
amount = -clicks if direction == "down" else clicks
|
|
154
|
+
_pyautogui.scroll(amount)
|
|
155
|
+
return {"action": "scroll", "x": x, "y": y, "direction": direction, "clicks": clicks}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def computer_move(x: int, y: int) -> Dict[str, Any]:
|
|
159
|
+
"""마우스를 좌표로 이동합니다 (클릭 없음)."""
|
|
160
|
+
if not _CU_AVAILABLE:
|
|
161
|
+
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
162
|
+
_pyautogui.moveTo(int(x), int(y), duration=0.2)
|
|
163
|
+
return {"action": "move", "x": int(x), "y": int(y)}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def computer_drag(x1: int, y1: int, x2: int, y2: int) -> Dict[str, Any]:
|
|
167
|
+
"""(x1,y1)에서 (x2,y2)로 드래그합니다."""
|
|
168
|
+
if not _CU_AVAILABLE:
|
|
169
|
+
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
170
|
+
_pyautogui.moveTo(int(x1), int(y1))
|
|
171
|
+
_pyautogui.dragTo(int(x2), int(y2), duration=0.35, button="left")
|
|
172
|
+
return {"action": "drag", "from": [int(x1), int(y1)], "to": [int(x2), int(y2)]}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def computer_status() -> Dict[str, Any]:
|
|
176
|
+
"""Computer Use 기능 사용 가능 여부를 확인합니다."""
|
|
177
|
+
if not _CU_AVAILABLE:
|
|
178
|
+
return {"available": False, "reason": "pyautogui not installed"}
|
|
179
|
+
w, h = _pyautogui.size()
|
|
180
|
+
return {
|
|
181
|
+
"available": True,
|
|
182
|
+
"screen_size": {"width": int(w), "height": int(h)},
|
|
183
|
+
"failsafe": _pyautogui.FAILSAFE,
|
|
184
|
+
"note": "macOS Accessibility 권한이 필요합니다 (시스템 설정 > 개인 정보 보호 > 손쉬운 사용)",
|
|
185
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Document generation (docx/xlsx/pptx/pdf) and extraction (read_document)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from tools import (
|
|
10
|
+
ToolError,
|
|
11
|
+
_resolve_path,
|
|
12
|
+
_relative,
|
|
13
|
+
DOCUMENT_OUTPUT_DIR,
|
|
14
|
+
PRESENTATION_OUTPUT_DIR,
|
|
15
|
+
SPREADSHEET_OUTPUT_DIR,
|
|
16
|
+
PDF_OUTPUT_DIR,
|
|
17
|
+
DOCUMENT_MAX_READ_BYTES,
|
|
18
|
+
_CJK_FONT_CANDIDATES,
|
|
19
|
+
_SUPPORTED_READ_EXTENSIONS,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safe_filename(name: str, suffix: str) -> str:
|
|
24
|
+
base = Path(name or f"artifact{suffix}").name
|
|
25
|
+
if not base.lower().endswith(suffix):
|
|
26
|
+
base += suffix
|
|
27
|
+
safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".", " ") else "_" for ch in base).strip()
|
|
28
|
+
return safe or f"artifact{suffix}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _body_to_str(body) -> str:
|
|
32
|
+
if isinstance(body, list):
|
|
33
|
+
return "\n\n".join(str(item) for item in body)
|
|
34
|
+
return str(body or "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_docx(title: str, body, filename: str = "document.docx") -> Dict[str, Any]:
|
|
38
|
+
try:
|
|
39
|
+
from docx import Document
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
raise ToolError("python-docx is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
42
|
+
|
|
43
|
+
output_dir = _resolve_path(DOCUMENT_OUTPUT_DIR)
|
|
44
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
target = output_dir / _safe_filename(filename, ".docx")
|
|
46
|
+
|
|
47
|
+
document = Document()
|
|
48
|
+
if title:
|
|
49
|
+
document.add_heading(str(title), level=1)
|
|
50
|
+
for block in _body_to_str(body).split("\n\n"):
|
|
51
|
+
text = block.strip()
|
|
52
|
+
if text:
|
|
53
|
+
document.add_paragraph(text)
|
|
54
|
+
document.save(target)
|
|
55
|
+
return {"path": _relative(target), "bytes": target.stat().st_size}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_xlsx(rows: List[List[Any]], filename: str = "spreadsheet.xlsx", sheet_name: str = "Sheet1") -> Dict[str, Any]:
|
|
59
|
+
try:
|
|
60
|
+
from openpyxl import Workbook
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise ToolError("openpyxl is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
63
|
+
|
|
64
|
+
if not isinstance(rows, list) or not all(isinstance(row, list) for row in rows):
|
|
65
|
+
raise ToolError("Rows must be a list of lists.")
|
|
66
|
+
|
|
67
|
+
output_dir = _resolve_path(SPREADSHEET_OUTPUT_DIR)
|
|
68
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
target = output_dir / _safe_filename(filename, ".xlsx")
|
|
70
|
+
|
|
71
|
+
workbook = Workbook()
|
|
72
|
+
sheet = workbook.active
|
|
73
|
+
sheet.title = (sheet_name or "Sheet1")[:31]
|
|
74
|
+
for row in rows:
|
|
75
|
+
sheet.append(row)
|
|
76
|
+
workbook.save(target)
|
|
77
|
+
return {"path": _relative(target), "rows": len(rows), "bytes": target.stat().st_size}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_pptx(title: str, slides: List[Dict[str, Any]], filename: str = "presentation.pptx") -> Dict[str, Any]:
|
|
81
|
+
try:
|
|
82
|
+
from pptx import Presentation
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
raise ToolError("python-pptx is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
85
|
+
|
|
86
|
+
output_dir = _resolve_path(PRESENTATION_OUTPUT_DIR)
|
|
87
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
target = output_dir / _safe_filename(filename, ".pptx")
|
|
89
|
+
|
|
90
|
+
presentation = Presentation()
|
|
91
|
+
first_layout = presentation.slide_layouts[0]
|
|
92
|
+
first = presentation.slides.add_slide(first_layout)
|
|
93
|
+
first.shapes.title.text = title or "Presentation"
|
|
94
|
+
first.placeholders[1].text = ""
|
|
95
|
+
|
|
96
|
+
content_layout = presentation.slide_layouts[1]
|
|
97
|
+
for slide_data in slides or []:
|
|
98
|
+
slide = presentation.slides.add_slide(content_layout)
|
|
99
|
+
slide.shapes.title.text = str(slide_data.get("title") or "Slide")
|
|
100
|
+
body = slide.placeholders[1].text_frame
|
|
101
|
+
body.clear()
|
|
102
|
+
bullets = slide_data.get("bullets") or []
|
|
103
|
+
if isinstance(bullets, str):
|
|
104
|
+
bullets = [bullets]
|
|
105
|
+
for index, bullet in enumerate(bullets):
|
|
106
|
+
paragraph = body.paragraphs[0] if index == 0 else body.add_paragraph()
|
|
107
|
+
paragraph.text = str(bullet)
|
|
108
|
+
paragraph.level = 0
|
|
109
|
+
|
|
110
|
+
presentation.save(target)
|
|
111
|
+
return {"path": _relative(target), "slides": len(presentation.slides), "bytes": target.stat().st_size}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_pdf(title: str, body, filename: str = "document.pdf") -> Dict[str, Any]:
|
|
117
|
+
try:
|
|
118
|
+
from reportlab.lib.pagesizes import A4
|
|
119
|
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
120
|
+
from reportlab.lib.units import mm
|
|
121
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
|
122
|
+
from reportlab.pdfbase import pdfmetrics
|
|
123
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise ToolError("reportlab is not installed. Run `pip install reportlab`.") from exc
|
|
126
|
+
|
|
127
|
+
output_dir = _resolve_path(PDF_OUTPUT_DIR)
|
|
128
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
target = output_dir / _safe_filename(filename, ".pdf")
|
|
130
|
+
|
|
131
|
+
# CJK 폰트 등록
|
|
132
|
+
font_name = "Helvetica"
|
|
133
|
+
for font_path in _CJK_FONT_CANDIDATES:
|
|
134
|
+
if Path(font_path).exists():
|
|
135
|
+
try:
|
|
136
|
+
pdfmetrics.registerFont(TTFont("KoreanFont", font_path))
|
|
137
|
+
font_name = "KoreanFont"
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
styles = getSampleStyleSheet()
|
|
143
|
+
title_style = ParagraphStyle("Title", fontName=font_name, fontSize=18, spaceAfter=8, leading=24)
|
|
144
|
+
body_style = ParagraphStyle("Body", fontName=font_name, fontSize=11, spaceAfter=6, leading=16)
|
|
145
|
+
|
|
146
|
+
story = []
|
|
147
|
+
if title:
|
|
148
|
+
story.append(Paragraph(str(title), title_style))
|
|
149
|
+
story.append(Spacer(1, 4 * mm))
|
|
150
|
+
|
|
151
|
+
for block in _body_to_str(body).split("\n\n"):
|
|
152
|
+
text = block.strip()
|
|
153
|
+
if text:
|
|
154
|
+
safe_text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
155
|
+
story.append(Paragraph(safe_text, body_style))
|
|
156
|
+
story.append(Spacer(1, 2 * mm))
|
|
157
|
+
|
|
158
|
+
doc = SimpleDocTemplate(str(target), pagesize=A4,
|
|
159
|
+
leftMargin=20*mm, rightMargin=20*mm,
|
|
160
|
+
topMargin=20*mm, bottomMargin=20*mm)
|
|
161
|
+
doc.build(story)
|
|
162
|
+
return {"path": _relative(target), "bytes": target.stat().st_size}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_document(path: str) -> Dict[str, Any]:
|
|
166
|
+
"""Extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV files."""
|
|
167
|
+
target = Path(path).expanduser().resolve()
|
|
168
|
+
if not target.exists():
|
|
169
|
+
raise ToolError(f"파일이 없습니다: {path}")
|
|
170
|
+
if not target.is_file():
|
|
171
|
+
raise ToolError(f"파일이 아닙니다: {path}")
|
|
172
|
+
if target.stat().st_size > DOCUMENT_MAX_READ_BYTES:
|
|
173
|
+
raise ToolError(f"파일이 너무 큽니다 ({target.stat().st_size:,} bytes).")
|
|
174
|
+
|
|
175
|
+
ext = target.suffix.lower()
|
|
176
|
+
if ext not in _SUPPORTED_READ_EXTENSIONS:
|
|
177
|
+
raise ToolError(f"지원하지 않는 형식입니다: {ext}. 지원: {', '.join(_SUPPORTED_READ_EXTENSIONS)}")
|
|
178
|
+
|
|
179
|
+
text = ""
|
|
180
|
+
meta: Dict[str, Any] = {"path": str(target), "ext": ext}
|
|
181
|
+
|
|
182
|
+
if ext == ".pdf":
|
|
183
|
+
try:
|
|
184
|
+
import pdfplumber
|
|
185
|
+
with pdfplumber.open(str(target)) as pdf:
|
|
186
|
+
meta["pages"] = len(pdf.pages)
|
|
187
|
+
text = "\n\n".join(
|
|
188
|
+
(p.extract_text() or "") for p in pdf.pages
|
|
189
|
+
).strip()
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
raise ToolError(f"PDF 읽기 실패: {exc}") from exc
|
|
192
|
+
|
|
193
|
+
elif ext == ".docx":
|
|
194
|
+
try:
|
|
195
|
+
from docx import Document
|
|
196
|
+
doc = Document(str(target))
|
|
197
|
+
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
|
198
|
+
text = "\n\n".join(paragraphs)
|
|
199
|
+
meta["paragraphs"] = len(paragraphs)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
raise ToolError(f"DOCX 읽기 실패: {exc}") from exc
|
|
202
|
+
|
|
203
|
+
elif ext == ".xlsx":
|
|
204
|
+
try:
|
|
205
|
+
from openpyxl import load_workbook
|
|
206
|
+
wb = load_workbook(str(target), data_only=True)
|
|
207
|
+
rows_all = []
|
|
208
|
+
for ws in wb.worksheets:
|
|
209
|
+
rows_all.append(f"[Sheet: {ws.title}]")
|
|
210
|
+
for row in ws.iter_rows(values_only=True):
|
|
211
|
+
cells = [str(c) if c is not None else "" for c in row]
|
|
212
|
+
rows_all.append("\t".join(cells))
|
|
213
|
+
text = "\n".join(rows_all)
|
|
214
|
+
meta["sheets"] = len(wb.worksheets)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
raise ToolError(f"XLSX 읽기 실패: {exc}") from exc
|
|
217
|
+
|
|
218
|
+
elif ext == ".pptx":
|
|
219
|
+
try:
|
|
220
|
+
from pptx import Presentation
|
|
221
|
+
prs = Presentation(str(target))
|
|
222
|
+
slides_text = []
|
|
223
|
+
for i, slide in enumerate(prs.slides, 1):
|
|
224
|
+
parts = []
|
|
225
|
+
for shape in slide.shapes:
|
|
226
|
+
if shape.has_text_frame:
|
|
227
|
+
parts.append(shape.text_frame.text)
|
|
228
|
+
slides_text.append(f"[Slide {i}]\n" + "\n".join(parts))
|
|
229
|
+
text = "\n\n".join(slides_text)
|
|
230
|
+
meta["slides"] = len(prs.slides)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
raise ToolError(f"PPTX 읽기 실패: {exc}") from exc
|
|
233
|
+
|
|
234
|
+
elif ext in {".txt", ".md", ".csv"}:
|
|
235
|
+
try:
|
|
236
|
+
text = target.read_text(encoding="utf-8", errors="replace")
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
raise ToolError(f"파일 읽기 실패: {exc}") from exc
|
|
239
|
+
|
|
240
|
+
meta["chars"] = len(text)
|
|
241
|
+
meta["preview"] = text[:500]
|
|
242
|
+
meta["content"] = text[:50_000] # 50K char cap for context
|
|
243
|
+
return meta
|