ltcai 1.2.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,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