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.
- package/README.md +27 -19
- package/docs/CHANGELOG.md +55 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +786 -0
- package/latticeai/api/computer_use.py +294 -0
- package/latticeai/api/deps.py +15 -0
- package/latticeai/api/garden.py +34 -0
- package/latticeai/api/local_files.py +125 -0
- package/latticeai/api/permissions.py +331 -0
- package/latticeai/api/setup.py +158 -0
- package/latticeai/api/static_routes.py +166 -0
- package/latticeai/api/tools.py +579 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +223 -4301
- package/latticeai/services/app_context.py +27 -0
- package/latticeai/services/model_runtime.py +1973 -0
- package/latticeai/services/tool_dispatch.py +135 -0
- package/latticeai/services/upload_service.py +99 -0
- package/package.json +3 -3
- package/skills/SKILL_TEMPLATE.md +1 -1
- package/skills/code_review/SKILL.md +1 -1
- package/skills/data_analysis/SKILL.md +1 -1
- package/skills/file_edit/SKILL.md +1 -1
- package/skills/summarize_document/SKILL.md +1 -1
- package/skills/web_search/SKILL.md +1 -1
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"""Chat, history, and local agent API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import AsyncIterator, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
20
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
from PIL import Image
|
|
23
|
+
|
|
24
|
+
from latticeai.core.agent import AgentRunContext, AgentState
|
|
25
|
+
from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
|
|
26
|
+
from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
|
|
27
|
+
from latticeai.services.chat_service import ChatService
|
|
28
|
+
from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
|
|
29
|
+
from telegram_bot import broadcast_web_chat
|
|
30
|
+
from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, local_read, network_status
|
|
31
|
+
|
|
32
|
+
class ChatRequest(BaseModel):
|
|
33
|
+
message: str
|
|
34
|
+
conversation_id: Optional[str] = None
|
|
35
|
+
client_url: Optional[str] = None
|
|
36
|
+
model: Optional[str] = None
|
|
37
|
+
max_tokens: int = 2048
|
|
38
|
+
temperature: float = 0.2
|
|
39
|
+
stream: bool = True
|
|
40
|
+
context: Optional[str] = None
|
|
41
|
+
source: Optional[str] = None
|
|
42
|
+
user_email: Optional[str] = None
|
|
43
|
+
user_nickname: Optional[str] = None
|
|
44
|
+
image_data: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentRequest(BaseModel):
|
|
48
|
+
message: str
|
|
49
|
+
conversation_id: Optional[str] = None
|
|
50
|
+
source: Optional[str] = None
|
|
51
|
+
max_steps: int = 25
|
|
52
|
+
temperature: float = 0.1
|
|
53
|
+
user_email: Optional[str] = None
|
|
54
|
+
user_nickname: Optional[str] = None
|
|
55
|
+
planning_model: Optional[str] = None
|
|
56
|
+
executing_model: Optional[str] = None
|
|
57
|
+
reviewing_model: Optional[str] = None
|
|
58
|
+
human_in_loop: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AgentResumeRequest(BaseModel):
|
|
62
|
+
context_id: str
|
|
63
|
+
approved: bool = True
|
|
64
|
+
modified_plan: Optional[dict] = None
|
|
65
|
+
executing_model: Optional[str] = None
|
|
66
|
+
reviewing_model: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AgentEvalRequest(BaseModel):
|
|
70
|
+
skill: str
|
|
71
|
+
case_id: Optional[str] = None
|
|
72
|
+
|
|
73
|
+
def detect_language(text: str) -> str:
|
|
74
|
+
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
75
|
+
total = max(len(text), 1)
|
|
76
|
+
ko = sum(1 for c in text if '가' <= c <= '힣')
|
|
77
|
+
if ko / total > 0.05:
|
|
78
|
+
return "ko"
|
|
79
|
+
return "en"
|
|
80
|
+
|
|
81
|
+
_LANG_HINT = {
|
|
82
|
+
"ko": "Respond in Korean (한국어로 답변하세요).",
|
|
83
|
+
"en": "Respond in English.",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def is_network_status_request(text: str) -> bool:
|
|
87
|
+
"""사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
|
|
88
|
+
t = (text or "").lower()
|
|
89
|
+
has_ip = bool(re.search(r"((?<![a-z0-9])ip(?![a-z0-9])|아이피|ip\s*주소|아이피\s*주소|ipconfig|ifconfig|네트워크)", t))
|
|
90
|
+
asks_current = any(word in t for word in ["내", "현재", "지금", "local", "로컬", "주소", "address", "뭐", "알려", "확인", "상태"])
|
|
91
|
+
return has_ip and asks_current
|
|
92
|
+
|
|
93
|
+
def is_current_url_request(text: str) -> bool:
|
|
94
|
+
t = (text or "").lower()
|
|
95
|
+
has_url = any(word in t for word in ["url", "주소", "링크", "address"])
|
|
96
|
+
asks_current = any(word in t for word in ["현재", "지금", "여기", "접속", "페이지", "브라우저", "알려", "뭐"])
|
|
97
|
+
return has_url and asks_current
|
|
98
|
+
|
|
99
|
+
def is_clear_command(text: str) -> bool:
|
|
100
|
+
return (text or "").strip().lower() in {"/clear", "/clear_all"}
|
|
101
|
+
|
|
102
|
+
def format_network_status(info: Dict) -> str:
|
|
103
|
+
lines = [
|
|
104
|
+
f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
|
|
105
|
+
f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
|
|
106
|
+
f"호스트명: {info.get('hostname') or '확인 안 됨'}",
|
|
107
|
+
]
|
|
108
|
+
local_ips = info.get("local_ips") or {}
|
|
109
|
+
if local_ips:
|
|
110
|
+
lines.extend(["", "인터페이스:"])
|
|
111
|
+
lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
|
|
112
|
+
note = info.get("note")
|
|
113
|
+
if note:
|
|
114
|
+
lines.extend(["", note])
|
|
115
|
+
return "\n".join(lines)
|
|
116
|
+
|
|
117
|
+
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
118
|
+
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
119
|
+
yield "data: [DONE]\n\n"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_chat_router(
|
|
123
|
+
*,
|
|
124
|
+
config,
|
|
125
|
+
model_router,
|
|
126
|
+
chat_service: ChatService,
|
|
127
|
+
workspace_store,
|
|
128
|
+
workspace_graph,
|
|
129
|
+
gardener,
|
|
130
|
+
require_user,
|
|
131
|
+
enforce_rate_limit,
|
|
132
|
+
get_history_user,
|
|
133
|
+
save_to_history,
|
|
134
|
+
append_audit_event,
|
|
135
|
+
clear_history,
|
|
136
|
+
clear_conversation,
|
|
137
|
+
get_history,
|
|
138
|
+
group_history_conversations,
|
|
139
|
+
get_conversation_messages,
|
|
140
|
+
conversation_title,
|
|
141
|
+
load_users,
|
|
142
|
+
get_user_role,
|
|
143
|
+
enable_graph: bool,
|
|
144
|
+
knowledge_graph,
|
|
145
|
+
public_model: str,
|
|
146
|
+
base_dir: Path,
|
|
147
|
+
) -> APIRouter:
|
|
148
|
+
api_router = APIRouter()
|
|
149
|
+
router = model_router
|
|
150
|
+
CONFIG = config
|
|
151
|
+
CHAT_SERVICE = chat_service
|
|
152
|
+
WORKSPACE_OS = workspace_store
|
|
153
|
+
ENABLE_GRAPH = enable_graph
|
|
154
|
+
KNOWLEDGE_GRAPH = knowledge_graph
|
|
155
|
+
PUBLIC_MODEL = public_model
|
|
156
|
+
BASE_DIR = base_dir
|
|
157
|
+
_doc_gen_sessions: dict = {}
|
|
158
|
+
_pending_agents: dict[str, tuple] = {}
|
|
159
|
+
_pending_agents_lock = threading.Lock()
|
|
160
|
+
|
|
161
|
+
def build_recent_chat_context(
|
|
162
|
+
limit: int = 10,
|
|
163
|
+
include_image_missing_replies: bool = True,
|
|
164
|
+
user_email: Optional[str] = None,
|
|
165
|
+
conversation_id: Optional[str] = None,
|
|
166
|
+
) -> str:
|
|
167
|
+
history = get_history()
|
|
168
|
+
if conversation_id:
|
|
169
|
+
history = [item for item in history if item.get("conversation_id") == conversation_id]
|
|
170
|
+
if user_email:
|
|
171
|
+
history = [item for item in history if item.get("user_email") == user_email or item.get("role") == "assistant"]
|
|
172
|
+
history = history[-limit:]
|
|
173
|
+
lines = []
|
|
174
|
+
for item in history:
|
|
175
|
+
role = item.get("role", "user")
|
|
176
|
+
content = item.get("content", "")
|
|
177
|
+
if not include_image_missing_replies and role == "assistant":
|
|
178
|
+
if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
|
|
179
|
+
continue
|
|
180
|
+
source = item.get("source")
|
|
181
|
+
label = role
|
|
182
|
+
if source:
|
|
183
|
+
label = f"{role} ({source})"
|
|
184
|
+
lines.append(f"{label}: {content}")
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
187
|
+
def extract_screenshot_context(image_data: Optional[str]) -> str:
|
|
188
|
+
if not image_data:
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
lines = ["[SCREENSHOT INGESTION]"]
|
|
192
|
+
image_bytes = b""
|
|
193
|
+
try:
|
|
194
|
+
image_bytes = base64.b64decode(image_data)
|
|
195
|
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
196
|
+
lines.append(f"- image_size: {image.width}x{image.height}")
|
|
197
|
+
lines.append(f"- image_mode: {image.mode}")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
lines.append(f"- image_decode_error: {e}")
|
|
200
|
+
return "\n".join(lines)
|
|
201
|
+
|
|
202
|
+
tesseract_path = shutil.which("tesseract")
|
|
203
|
+
if not tesseract_path:
|
|
204
|
+
lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
|
|
205
|
+
return "\n".join(lines)
|
|
206
|
+
|
|
207
|
+
temp_path = None
|
|
208
|
+
try:
|
|
209
|
+
with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
|
|
210
|
+
temp.write(image_bytes)
|
|
211
|
+
temp_path = temp.name
|
|
212
|
+
|
|
213
|
+
ocr_text = ""
|
|
214
|
+
for lang in ("kor+eng", "eng"):
|
|
215
|
+
completed = subprocess.run(
|
|
216
|
+
[tesseract_path, temp_path, "stdout", "-l", lang, "--psm", "6"],
|
|
217
|
+
capture_output=True,
|
|
218
|
+
text=True,
|
|
219
|
+
timeout=20,
|
|
220
|
+
check=False,
|
|
221
|
+
)
|
|
222
|
+
if completed.returncode == 0 and completed.stdout.strip():
|
|
223
|
+
ocr_text = completed.stdout.strip()
|
|
224
|
+
lines.append(f"- ocr_language: {lang}")
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
if ocr_text:
|
|
228
|
+
lines.append("- ocr_text:")
|
|
229
|
+
lines.append(ocr_text[:4000])
|
|
230
|
+
else:
|
|
231
|
+
lines.append("- ocr: no text extracted.")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
lines.append(f"- ocr_error: {e}")
|
|
234
|
+
finally:
|
|
235
|
+
if temp_path:
|
|
236
|
+
try:
|
|
237
|
+
Path(temp_path).unlink()
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
return "\n".join(lines)
|
|
242
|
+
|
|
243
|
+
_AGENT_RUNTIME = build_agent_runtime(
|
|
244
|
+
model_router=router,
|
|
245
|
+
execute_tool=execute_tool,
|
|
246
|
+
recent_chat_context=build_recent_chat_context,
|
|
247
|
+
clear_history=clear_history,
|
|
248
|
+
knowledge_save=knowledge_save,
|
|
249
|
+
audit=append_audit_event,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@api_router.post("/chat")
|
|
253
|
+
async def chat(req: ChatRequest, request: Request):
|
|
254
|
+
current_user = require_user(request)
|
|
255
|
+
enforce_rate_limit(current_user, "chat")
|
|
256
|
+
img_len = len(req.image_data) if req.image_data else 0
|
|
257
|
+
print(
|
|
258
|
+
f"🧪 /chat request: stream={req.stream} image_data_len={img_len} "
|
|
259
|
+
f"message_len={len(req.message or '')}"
|
|
260
|
+
)
|
|
261
|
+
effective_email = req.user_email or current_user or None
|
|
262
|
+
history_user = get_history_user(effective_email, req.user_nickname)
|
|
263
|
+
|
|
264
|
+
if is_network_status_request(req.message):
|
|
265
|
+
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
266
|
+
save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
267
|
+
try:
|
|
268
|
+
answer = format_network_status(network_status())
|
|
269
|
+
except ToolError as exc:
|
|
270
|
+
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
271
|
+
save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
272
|
+
if req.source != "telegram":
|
|
273
|
+
asyncio.create_task(broadcast_web_chat("user", req.message))
|
|
274
|
+
asyncio.create_task(broadcast_web_chat("assistant", answer))
|
|
275
|
+
if req.stream:
|
|
276
|
+
return StreamingResponse(
|
|
277
|
+
single_text_stream(answer),
|
|
278
|
+
media_type="text/event-stream",
|
|
279
|
+
headers={"X-Model": "network_status"},
|
|
280
|
+
)
|
|
281
|
+
return JSONResponse(content={"response": answer})
|
|
282
|
+
|
|
283
|
+
if is_clear_command(req.message):
|
|
284
|
+
command = req.message.strip().lower()
|
|
285
|
+
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
286
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
287
|
+
try:
|
|
288
|
+
KNOWLEDGE_GRAPH.ingest_event(
|
|
289
|
+
"ClearEvent",
|
|
290
|
+
f"{command} requested",
|
|
291
|
+
user_email=effective_email,
|
|
292
|
+
user_nickname=req.user_nickname,
|
|
293
|
+
source=req.source or "web",
|
|
294
|
+
conversation_id=req.conversation_id,
|
|
295
|
+
metadata={"command": command, "scope": clear_scope},
|
|
296
|
+
)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
299
|
+
if command == "/clear_all":
|
|
300
|
+
result = clear_history(0)
|
|
301
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
302
|
+
else:
|
|
303
|
+
if req.conversation_id:
|
|
304
|
+
result = clear_conversation(req.conversation_id)
|
|
305
|
+
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
306
|
+
else:
|
|
307
|
+
result = clear_history(0)
|
|
308
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
309
|
+
append_audit_event(
|
|
310
|
+
"clear_command",
|
|
311
|
+
user_email=effective_email,
|
|
312
|
+
user_nickname=req.user_nickname,
|
|
313
|
+
source=req.source or "web",
|
|
314
|
+
conversation_id=req.conversation_id,
|
|
315
|
+
command=command,
|
|
316
|
+
scope=clear_scope,
|
|
317
|
+
removed=result.get("removed", 0),
|
|
318
|
+
kept=result.get("kept", 0),
|
|
319
|
+
)
|
|
320
|
+
if req.stream:
|
|
321
|
+
return StreamingResponse(
|
|
322
|
+
single_text_stream(answer),
|
|
323
|
+
media_type="text/event-stream",
|
|
324
|
+
headers={"X-Model": "history"},
|
|
325
|
+
)
|
|
326
|
+
return JSONResponse(content={"response": answer})
|
|
327
|
+
|
|
328
|
+
if is_current_url_request(req.message) and req.client_url:
|
|
329
|
+
answer = f"현재 페이지 URL: {req.client_url}"
|
|
330
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
331
|
+
save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
332
|
+
if req.source != "telegram":
|
|
333
|
+
asyncio.create_task(broadcast_web_chat("user", req.message))
|
|
334
|
+
asyncio.create_task(broadcast_web_chat("assistant", answer))
|
|
335
|
+
if req.stream:
|
|
336
|
+
return StreamingResponse(
|
|
337
|
+
single_text_stream(answer),
|
|
338
|
+
media_type="text/event-stream",
|
|
339
|
+
headers={"X-Model": "client_url"},
|
|
340
|
+
)
|
|
341
|
+
return JSONResponse(content={"response": answer})
|
|
342
|
+
|
|
343
|
+
if not router.current_model_id:
|
|
344
|
+
detail = "No model loaded. Call /models/load first."
|
|
345
|
+
if IS_PUBLIC_MODE:
|
|
346
|
+
detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
|
|
347
|
+
raise HTTPException(status_code=400, detail=detail)
|
|
348
|
+
|
|
349
|
+
if req.model and req.model != router.current_model_id:
|
|
350
|
+
if req.model not in router.loaded_model_ids:
|
|
351
|
+
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
|
|
352
|
+
router.switch_model(req.model)
|
|
353
|
+
|
|
354
|
+
lang = detect_language(req.message)
|
|
355
|
+
context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
|
|
356
|
+
try:
|
|
357
|
+
knowledge_context = gardener.get_relevant_context(req.message)
|
|
358
|
+
if knowledge_context:
|
|
359
|
+
context += f"\n\n[LOCAL KNOWLEDGE BASE]\n{knowledge_context}"
|
|
360
|
+
print(f"📖 Context reinforced with local knowledge.")
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logging.warning("Knowledge reinforcement skipped: %s", e)
|
|
363
|
+
|
|
364
|
+
is_doc_gen = detect_document_intent(req.message)
|
|
365
|
+
doc_gen_context_result = None
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
369
|
+
if is_doc_gen:
|
|
370
|
+
doc_gen_context_result = retrieve_context_for_generation(
|
|
371
|
+
KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
|
|
372
|
+
)
|
|
373
|
+
graph_md = doc_gen_context_result.get("context_markdown", "")
|
|
374
|
+
if graph_md:
|
|
375
|
+
context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
|
|
376
|
+
print("📝 Document generation context retrieved from knowledge graph.")
|
|
377
|
+
else:
|
|
378
|
+
graph_context = KNOWLEDGE_GRAPH.context_for_query(req.message)
|
|
379
|
+
if graph_context:
|
|
380
|
+
context += f"\n\n[KNOWLEDGE GRAPH]\n{graph_context}"
|
|
381
|
+
print("🕸️ Context reinforced with knowledge graph.")
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
384
|
+
|
|
385
|
+
if req.image_data:
|
|
386
|
+
screenshot_context = extract_screenshot_context(req.image_data)
|
|
387
|
+
if screenshot_context:
|
|
388
|
+
context += f"\n\n{screenshot_context}"
|
|
389
|
+
|
|
390
|
+
if CONFIG.auto_read_chat_paths:
|
|
391
|
+
_file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
|
|
392
|
+
for _m in _file_path_re.finditer(req.message or ""):
|
|
393
|
+
_fpath = _m.group(1).strip()
|
|
394
|
+
try:
|
|
395
|
+
_result = local_read(_fpath)
|
|
396
|
+
_fcontent = _result.get("content", "")
|
|
397
|
+
if _fcontent:
|
|
398
|
+
context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
|
|
399
|
+
print(f"📂 Auto-injected file context: {_fpath}")
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
trace_seed = CHAT_SERVICE.build_graph_trace(
|
|
404
|
+
req.message,
|
|
405
|
+
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
406
|
+
context,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
410
|
+
save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
411
|
+
if req.source != "telegram":
|
|
412
|
+
asyncio.create_task(broadcast_web_chat("user", req.message))
|
|
413
|
+
|
|
414
|
+
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
415
|
+
conv_key = req.conversation_id or "default"
|
|
416
|
+
session = _doc_gen_sessions.get(conv_key)
|
|
417
|
+
if session is None:
|
|
418
|
+
session = DocumentGenerationSession()
|
|
419
|
+
_doc_gen_sessions[conv_key] = session
|
|
420
|
+
graph_md = (doc_gen_context_result or {}).get("context_markdown", "")
|
|
421
|
+
system_prompt = session.get_system_prompt(graph_md)
|
|
422
|
+
sources = (doc_gen_context_result or {}).get("sources", [])
|
|
423
|
+
footnote = format_sources_footnote(sources)
|
|
424
|
+
|
|
425
|
+
if req.stream:
|
|
426
|
+
async def _stream_doc_gen():
|
|
427
|
+
collected = []
|
|
428
|
+
async for chunk in router.stream_generate_document(
|
|
429
|
+
req.message, system_prompt,
|
|
430
|
+
max_tokens=req.max_tokens or 8192,
|
|
431
|
+
temperature=req.temperature or 0.3,
|
|
432
|
+
):
|
|
433
|
+
collected.append(chunk)
|
|
434
|
+
yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
|
|
435
|
+
full_text = "".join(collected)
|
|
436
|
+
if footnote:
|
|
437
|
+
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
438
|
+
full_text += footnote
|
|
439
|
+
session.update(graph_md, full_text, req.conversation_id)
|
|
440
|
+
save_to_history("assistant", full_text, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
441
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
442
|
+
question=req.message,
|
|
443
|
+
response=full_text,
|
|
444
|
+
conversation_id=req.conversation_id,
|
|
445
|
+
user_email=effective_email,
|
|
446
|
+
trace=trace_seed,
|
|
447
|
+
)
|
|
448
|
+
if req.source != "telegram":
|
|
449
|
+
asyncio.create_task(broadcast_web_chat("assistant", full_text))
|
|
450
|
+
yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
451
|
+
yield "data: [DONE]\n\n"
|
|
452
|
+
return StreamingResponse(
|
|
453
|
+
_stream_doc_gen(),
|
|
454
|
+
media_type="text/event-stream",
|
|
455
|
+
headers={"X-Model": router.current_model_id, "X-Doc-Gen": "true"},
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
result = await router.generate_document(
|
|
459
|
+
req.message, system_prompt,
|
|
460
|
+
max_tokens=req.max_tokens or 8192,
|
|
461
|
+
temperature=req.temperature or 0.3,
|
|
462
|
+
)
|
|
463
|
+
if footnote:
|
|
464
|
+
result += footnote
|
|
465
|
+
session.update(graph_md, result, req.conversation_id)
|
|
466
|
+
save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
467
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
468
|
+
question=req.message,
|
|
469
|
+
response=str(result),
|
|
470
|
+
conversation_id=req.conversation_id,
|
|
471
|
+
user_email=effective_email,
|
|
472
|
+
trace=trace_seed,
|
|
473
|
+
)
|
|
474
|
+
if req.source != "telegram":
|
|
475
|
+
asyncio.create_task(broadcast_web_chat("assistant", str(result)))
|
|
476
|
+
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
477
|
+
|
|
478
|
+
if req.stream:
|
|
479
|
+
recent_context = build_recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
|
|
480
|
+
stream_context = context
|
|
481
|
+
if recent_context:
|
|
482
|
+
stream_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip()
|
|
483
|
+
return StreamingResponse(
|
|
484
|
+
_stream_chat(req, stream_context, req.image_data, trace_seed=trace_seed, effective_email=effective_email),
|
|
485
|
+
media_type="text/event-stream",
|
|
486
|
+
headers={"X-Model": router.current_model_id},
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
if req.image_data:
|
|
490
|
+
recent_context = build_recent_chat_context(
|
|
491
|
+
limit=6,
|
|
492
|
+
include_image_missing_replies=False,
|
|
493
|
+
user_email=effective_email,
|
|
494
|
+
conversation_id=req.conversation_id,
|
|
495
|
+
)
|
|
496
|
+
full_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip() if recent_context else context
|
|
497
|
+
else:
|
|
498
|
+
history_context = build_recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
|
|
499
|
+
full_context = f"{history_context}\n{context}" if context else history_context
|
|
500
|
+
|
|
501
|
+
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
502
|
+
|
|
503
|
+
save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
504
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
505
|
+
question=req.message,
|
|
506
|
+
response=str(result),
|
|
507
|
+
conversation_id=req.conversation_id,
|
|
508
|
+
user_email=effective_email,
|
|
509
|
+
trace=trace_seed,
|
|
510
|
+
)
|
|
511
|
+
if req.source != "telegram":
|
|
512
|
+
asyncio.create_task(broadcast_web_chat("assistant", str(result)))
|
|
513
|
+
|
|
514
|
+
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@api_router.get("/history")
|
|
518
|
+
async def fetch_history(request: Request):
|
|
519
|
+
"""웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
|
|
520
|
+
require_user(request)
|
|
521
|
+
return get_history()
|
|
522
|
+
|
|
523
|
+
@api_router.get("/history/conversations")
|
|
524
|
+
async def fetch_history_conversations(request: Request):
|
|
525
|
+
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
526
|
+
require_user(request)
|
|
527
|
+
return group_history_conversations()
|
|
528
|
+
|
|
529
|
+
@api_router.get("/history/conversations/{conversation_id:path}")
|
|
530
|
+
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
531
|
+
"""선택한 대화의 메시지를 반환합니다."""
|
|
532
|
+
require_user(request)
|
|
533
|
+
messages = get_conversation_messages(conversation_id)
|
|
534
|
+
if not messages:
|
|
535
|
+
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
536
|
+
return {"id": conversation_id, "messages": messages}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@api_router.delete("/history/conversations/{conversation_id:path}")
|
|
540
|
+
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
541
|
+
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
542
|
+
email = require_user(request)
|
|
543
|
+
result = clear_conversation(conversation_id, request.query_params.get("started_at"))
|
|
544
|
+
append_audit_event(
|
|
545
|
+
"conversation_delete",
|
|
546
|
+
user_email=email,
|
|
547
|
+
conversation_id=conversation_id,
|
|
548
|
+
started_at=request.query_params.get("started_at"),
|
|
549
|
+
removed=result.get("removed", 0),
|
|
550
|
+
kept=result.get("kept", 0),
|
|
551
|
+
)
|
|
552
|
+
return result
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@api_router.delete("/history")
|
|
556
|
+
async def delete_history(request: Request, keep_last: int = 0):
|
|
557
|
+
email = require_user(request)
|
|
558
|
+
result = clear_history(keep_last)
|
|
559
|
+
append_audit_event(
|
|
560
|
+
"history_delete",
|
|
561
|
+
user_email=email,
|
|
562
|
+
keep_last=keep_last,
|
|
563
|
+
removed=result.get("removed", 0),
|
|
564
|
+
kept=result.get("kept", 0),
|
|
565
|
+
)
|
|
566
|
+
return result
|
|
567
|
+
|
|
568
|
+
@api_router.get("/history/search")
|
|
569
|
+
async def search_history(q: str, request: Request):
|
|
570
|
+
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
571
|
+
require_user(request)
|
|
572
|
+
if not q or not q.strip():
|
|
573
|
+
return {"results": [], "query": q}
|
|
574
|
+
q_lower = q.strip().lower()
|
|
575
|
+
history = get_history()
|
|
576
|
+
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
577
|
+
grouped: Dict[str, Dict] = {}
|
|
578
|
+
for item in matches:
|
|
579
|
+
cid = item.get("conversation_id") or "legacy"
|
|
580
|
+
if cid not in grouped:
|
|
581
|
+
grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
|
|
582
|
+
grouped[cid]["messages"].append(item)
|
|
583
|
+
return {"results": list(grouped.values())[-30:], "query": q}
|
|
584
|
+
|
|
585
|
+
async def _stream_chat(
|
|
586
|
+
req: ChatRequest,
|
|
587
|
+
context: str = "",
|
|
588
|
+
image_data: str = None,
|
|
589
|
+
*,
|
|
590
|
+
trace_seed: Optional[Dict] = None,
|
|
591
|
+
effective_email: Optional[str] = None,
|
|
592
|
+
) -> AsyncIterator[str]:
|
|
593
|
+
full_response = ""
|
|
594
|
+
async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
|
|
595
|
+
clean_chunk = chunk
|
|
596
|
+
if hasattr(chunk, "text"):
|
|
597
|
+
clean_chunk = chunk.text
|
|
598
|
+
elif isinstance(chunk, str) and "text='" in chunk:
|
|
599
|
+
try:
|
|
600
|
+
clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
full_response += str(clean_chunk)
|
|
605
|
+
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
606
|
+
history_user = get_history_user(req.user_email, req.user_nickname)
|
|
607
|
+
save_to_history("assistant", full_response, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
608
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
609
|
+
question=req.message,
|
|
610
|
+
response=full_response,
|
|
611
|
+
conversation_id=req.conversation_id,
|
|
612
|
+
user_email=effective_email or req.user_email,
|
|
613
|
+
trace=trace_seed or CHAT_SERVICE.build_graph_trace(
|
|
614
|
+
req.message,
|
|
615
|
+
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
616
|
+
context,
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
if req.source != "telegram":
|
|
620
|
+
asyncio.create_task(broadcast_web_chat("assistant", full_response))
|
|
621
|
+
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
622
|
+
yield "data: [DONE]\n\n"
|
|
623
|
+
|
|
624
|
+
@api_router.post("/agent/eval")
|
|
625
|
+
async def agent_eval(req: AgentEvalRequest, request: Request):
|
|
626
|
+
"""Run a skill's eval cases from schema.json and return pass/fail per case."""
|
|
627
|
+
require_user(request)
|
|
628
|
+
skill_dir = BASE_DIR / "skills" / req.skill
|
|
629
|
+
schema_path = skill_dir / "schema.json"
|
|
630
|
+
if not schema_path.exists():
|
|
631
|
+
raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
|
|
632
|
+
|
|
633
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
634
|
+
eval_cases = schema.get("evals", [])
|
|
635
|
+
if req.case_id:
|
|
636
|
+
eval_cases = [c for c in eval_cases if c.get("id") == req.case_id]
|
|
637
|
+
if not eval_cases:
|
|
638
|
+
return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
|
|
639
|
+
"message": "No eval cases defined in schema.json"}
|
|
640
|
+
|
|
641
|
+
action_name = schema.get("action", req.skill)
|
|
642
|
+
results = []
|
|
643
|
+
for case in eval_cases:
|
|
644
|
+
case_id = case.get("id", "?")
|
|
645
|
+
try:
|
|
646
|
+
result = execute_tool(action_name, case.get("input", {}))
|
|
647
|
+
criteria = case.get("pass_criteria", "")
|
|
648
|
+
if "success == true" in criteria:
|
|
649
|
+
passed = result.get("success") is True
|
|
650
|
+
elif "success == false" in criteria:
|
|
651
|
+
passed = result.get("success") is False
|
|
652
|
+
else:
|
|
653
|
+
passed = True # manual review required
|
|
654
|
+
results.append({"id": case_id, "description": case.get("description", ""),
|
|
655
|
+
"passed": passed, "result": result, "pass_criteria": criteria})
|
|
656
|
+
except Exception as exc:
|
|
657
|
+
results.append({"id": case_id, "description": case.get("description", ""),
|
|
658
|
+
"passed": False, "error": str(exc),
|
|
659
|
+
"pass_criteria": case.get("pass_criteria", "")})
|
|
660
|
+
|
|
661
|
+
n_passed = sum(1 for r in results if r.get("passed") is True)
|
|
662
|
+
return {
|
|
663
|
+
"skill": req.skill, "action": action_name,
|
|
664
|
+
"total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
|
|
665
|
+
"results": results,
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@api_router.post("/agent")
|
|
670
|
+
async def agent(req: AgentRequest, request: Request):
|
|
671
|
+
"""Natural-language local agent.
|
|
672
|
+
|
|
673
|
+
State machine:
|
|
674
|
+
IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
|
|
675
|
+
↓ ↓
|
|
676
|
+
FAILED DONE | EXECUTING(retry) | ROLLBACK
|
|
677
|
+
↓
|
|
678
|
+
FAILED
|
|
679
|
+
"""
|
|
680
|
+
current_user = require_user(request)
|
|
681
|
+
enforce_rate_limit(current_user, "agent")
|
|
682
|
+
if not router.current_model_id:
|
|
683
|
+
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
684
|
+
|
|
685
|
+
ensure_agent_root()
|
|
686
|
+
lang = detect_language(req.message)
|
|
687
|
+
lang_hint = _LANG_HINT[lang]
|
|
688
|
+
max_steps = max(1, min(req.max_steps, 50))
|
|
689
|
+
max_retry = 3
|
|
690
|
+
|
|
691
|
+
ctx = AgentRunContext()
|
|
692
|
+
ctx.executing_model = req.executing_model
|
|
693
|
+
ctx.reviewing_model = req.reviewing_model
|
|
694
|
+
|
|
695
|
+
# PLANNING phase
|
|
696
|
+
ctx.state = AgentState.PLANNING
|
|
697
|
+
ctx.state_history.append(ctx.state.value)
|
|
698
|
+
await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
|
|
699
|
+
|
|
700
|
+
# Human-in-the-loop: pause after planning, return plan to UI
|
|
701
|
+
if req.human_in_loop:
|
|
702
|
+
context_id = secrets.token_urlsafe(16)
|
|
703
|
+
with _pending_agents_lock:
|
|
704
|
+
_pending_agents[context_id] = (ctx, req, lang_hint, current_user)
|
|
705
|
+
return {
|
|
706
|
+
"status": "waiting_approval",
|
|
707
|
+
"context_id": context_id,
|
|
708
|
+
"plan": ctx.plan,
|
|
709
|
+
"steps": ctx.transcript,
|
|
710
|
+
"state_history": ctx.state_history,
|
|
711
|
+
"planning_model": req.planning_model or router.current_model_id,
|
|
712
|
+
"executing_model": req.executing_model or router.current_model_id,
|
|
713
|
+
"reviewing_model": req.reviewing_model or router.current_model_id,
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
# Auto-approve and run to completion (default behaviour)
|
|
717
|
+
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
718
|
+
return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
async def _agent_finish(
|
|
722
|
+
ctx: AgentRunContext, req: AgentRequest, lang_hint: str,
|
|
723
|
+
current_user: str, max_steps: int, max_retry: int,
|
|
724
|
+
) -> dict:
|
|
725
|
+
"""HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
|
|
726
|
+
await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
727
|
+
asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
|
|
728
|
+
|
|
729
|
+
message = ctx.final_message or "작업을 완료했습니다."
|
|
730
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
731
|
+
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
732
|
+
try:
|
|
733
|
+
WORKSPACE_OS.record_agent_run(
|
|
734
|
+
agent_id="agent:executor",
|
|
735
|
+
status="ok" if ctx.state == AgentState.DONE else "failed",
|
|
736
|
+
input_text=req.message,
|
|
737
|
+
output_text=message,
|
|
738
|
+
user_email=current_user or None,
|
|
739
|
+
timeline=ctx.transcript,
|
|
740
|
+
relationships=["agent:planner", "agent:reviewer"],
|
|
741
|
+
graph=_workspace_graph(),
|
|
742
|
+
)
|
|
743
|
+
except Exception as exc:
|
|
744
|
+
logging.warning("workspace agent run record failed: %s", exc)
|
|
745
|
+
created_files = collect_created_files(ctx.transcript)
|
|
746
|
+
return {
|
|
747
|
+
"status": "ok" if ctx.state == AgentState.DONE else "failed",
|
|
748
|
+
"response": message,
|
|
749
|
+
"workspace": str(AGENT_ROOT),
|
|
750
|
+
"steps": ctx.transcript,
|
|
751
|
+
"state_history": ctx.state_history,
|
|
752
|
+
"final_state": ctx.state.value,
|
|
753
|
+
"created_files": created_files,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@api_router.post("/agent/resume")
|
|
758
|
+
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
759
|
+
"""Resume a paused agent after human approval of the plan."""
|
|
760
|
+
current_user = require_user(request)
|
|
761
|
+
|
|
762
|
+
with _pending_agents_lock:
|
|
763
|
+
entry = _pending_agents.pop(req.context_id, None)
|
|
764
|
+
if not entry:
|
|
765
|
+
raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
|
|
766
|
+
|
|
767
|
+
ctx, orig_req, lang_hint, _orig_user = entry
|
|
768
|
+
|
|
769
|
+
if not req.approved:
|
|
770
|
+
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
771
|
+
|
|
772
|
+
if req.modified_plan:
|
|
773
|
+
ctx.plan = req.modified_plan
|
|
774
|
+
ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
|
|
775
|
+
|
|
776
|
+
# Apply model overrides from resume request (takes priority over original request)
|
|
777
|
+
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
778
|
+
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
779
|
+
|
|
780
|
+
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
781
|
+
|
|
782
|
+
max_steps = max(1, min(orig_req.max_steps, 50))
|
|
783
|
+
max_retry = 3
|
|
784
|
+
return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
|
|
785
|
+
|
|
786
|
+
return api_router
|