ltcai 8.2.0 → 8.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.
Files changed (46) hide show
  1. package/README.md +27 -19
  2. package/docs/CHANGELOG.md +59 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +43 -0
  4. package/docs/DEVELOPMENT.md +8 -8
  5. package/docs/LEGACY_COMPATIBILITY.md +22 -2
  6. package/docs/ONBOARDING.md +38 -0
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +5 -4
  9. package/docs/kg-schema.md +3 -3
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/ingest.py +40 -8
  12. package/lattice_brain/runtime/agent_runtime.py +22 -37
  13. package/lattice_brain/runtime/multi_agent.py +1 -1
  14. package/lattice_brain/workflow.py +26 -1
  15. package/latticeai/__init__.py +1 -1
  16. package/latticeai/api/chat.py +230 -51
  17. package/latticeai/api/knowledge_graph.py +33 -0
  18. package/latticeai/api/local_files.py +2 -0
  19. package/latticeai/api/tools.py +1 -0
  20. package/latticeai/api/workflow_designer.py +5 -4
  21. package/latticeai/core/legacy_compatibility.py +174 -0
  22. package/latticeai/core/marketplace.py +1 -1
  23. package/latticeai/core/workspace_os.py +1 -1
  24. package/latticeai/services/architecture_readiness.py +37 -3
  25. package/latticeai/services/model_catalog.py +6 -0
  26. package/latticeai/services/model_engines.py +12 -21
  27. package/latticeai/services/model_runtime.py +17 -23
  28. package/latticeai/services/product_readiness.py +45 -14
  29. package/llm_router.py +7 -28
  30. package/mcp_registry.py +7 -24
  31. package/package.json +1 -1
  32. package/scripts/pts-claudecode-discord-bridge.mjs +2 -1
  33. package/src-tauri/Cargo.lock +1 -1
  34. package/src-tauri/Cargo.toml +1 -1
  35. package/src-tauri/tauri.conf.json +1 -1
  36. package/static/app/asset-manifest.json +10 -10
  37. package/static/app/assets/{Act-D9jIknFd.js → Act-D5mo4tE4.js} +1 -1
  38. package/static/app/assets/{Brain-CFOtWbPN.js → Brain-BVWyQw8A.js} +1 -1
  39. package/static/app/assets/{Capture-Q4WYzwr5.js → Capture-C1R6GT0t.js} +1 -1
  40. package/static/app/assets/{Library-C5Q2yWee.js → Library-C2wIxpTs.js} +1 -1
  41. package/static/app/assets/{System-BLbjdr1_.js → System-DE5GRyQR.js} +1 -1
  42. package/static/app/assets/{index-BqammyNu.js → index-CoiuIFFP.js} +3 -3
  43. package/static/app/assets/{primitives-Br8uSfZ4.js → primitives-BdsUNXa6.js} +1 -1
  44. package/static/app/assets/{textarea-BnhNs1_X.js → textarea-e7qaj6Hm.js} +1 -1
  45. package/static/app/index.html +1 -1
  46. package/static/sw.js +1 -1
@@ -27,7 +27,7 @@ from dataclasses import dataclass, field
27
27
  from datetime import datetime
28
28
  from typing import Any, Callable, Dict, List, Optional
29
29
 
30
- from lattice_brain.runtime.contracts import workflow_run_contract
30
+ from lattice_brain.runtime.contracts import runtime_boundary_contract, workflow_run_contract
31
31
 
32
32
 
33
33
  WORKFLOW_ENGINE_VERSION = "2.2.0"
@@ -106,6 +106,11 @@ def normalize_definition(workflow: Dict[str, Any]) -> Dict[str, Any]:
106
106
  }
107
107
 
108
108
 
109
+ def legacy_steps_from_nodes(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
110
+ """Return the compact legacy ``steps`` projection for node workflows."""
111
+ return [{"action": node.get("type"), "node": node.get("id")} for node in nodes]
112
+
113
+
109
114
  def validate_definition(workflow: Dict[str, Any]) -> List[str]:
110
115
  """Return a list of validation errors ([] means valid)."""
111
116
  errors: List[str] = []
@@ -269,6 +274,26 @@ class WorkflowEngine:
269
274
  # against the workflow lifecycle actually executes.
270
275
  self.hooks = hooks
271
276
 
277
+ def boundary(self) -> Dict[str, Any]:
278
+ return runtime_boundary_contract(
279
+ name="WorkflowEngine",
280
+ runtime="workflow",
281
+ entrypoint="lattice_brain.workflow.WorkflowEngine",
282
+ surface="/workflows",
283
+ owns="typed workflow validation, execution, approval suspension, resume, export, and import",
284
+ )
285
+
286
+ def config(self) -> Dict[str, Any]:
287
+ return {
288
+ "version": WORKFLOW_ENGINE_VERSION,
289
+ "boundary": self.boundary(),
290
+ "node_types": list(NODE_TYPES),
291
+ "runner_families": dict(_RUNNER_FOR),
292
+ "max_steps": _MAX_STEPS,
293
+ "approval_suspend": True,
294
+ "legacy_steps_projection": "lattice_brain.workflow.legacy_steps_from_nodes",
295
+ }
296
+
272
297
  def run(self, workflow: Dict[str, Any], *, inputs: Optional[Dict[str, Any]] = None) -> WorkflowRun:
273
298
  definition = normalize_definition(workflow)
274
299
  errors = validate_definition(definition)
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "8.2.0"
3
+ __version__ = "8.4.0"
@@ -123,6 +123,90 @@ def is_current_url_request(text: str) -> bool:
123
123
  def is_clear_command(text: str) -> bool:
124
124
  return (text or "").strip().lower() in {"/clear", "/clear_all"}
125
125
 
126
+ # Path segments intentionally exclude spaces: allowing spaces let the target
127
+ # match swallow preceding words (e.g. "create a text file report.txt" resolved
128
+ # to the whole phrase as the path). Chat file targets are single tokens.
129
+ _FILE_TARGET_RE = re.compile(
130
+ r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
131
+ r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
132
+ re.IGNORECASE,
133
+ )
134
+
135
+
136
+ def is_file_action_request(text: str) -> bool:
137
+ """Return True for chat requests that should execute file tools, not prose.
138
+
139
+ The Brain chat composer posts to ``/chat``. Without this gate, explicit
140
+ side-effect requests such as "create hello.md" are handled as plain model
141
+ generation, which commonly produces a code block instead of a real file.
142
+ Keep the gate narrow so normal Q&A and "how do I create a file?" stay in
143
+ ordinary chat.
144
+ """
145
+ raw = (text or "").strip()
146
+ if not raw:
147
+ return False
148
+ lower = raw.lower()
149
+
150
+ if any(phrase in lower for phrase in (
151
+ "how to ", "how do i ", "방법", "어떻게", "예시", "sample", "example",
152
+ )) and not any(phrase in lower for phrase in (
153
+ "actually create", "real file", "실제로", "파일로", "저장해", "만들어",
154
+ )):
155
+ return False
156
+
157
+ has_target = bool(_FILE_TARGET_RE.search(raw))
158
+ has_file_word = any(word in lower for word in (
159
+ "file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
160
+ ))
161
+ has_action = any(word in lower for word in (
162
+ "create", "make", "write", "save", "generate", "edit", "update",
163
+ "만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
164
+ ))
165
+
166
+ if not has_action:
167
+ return False
168
+ if has_target and has_action:
169
+ return True
170
+ return has_file_word and has_action
171
+
172
+
173
+ def file_action_target(text: str) -> Optional[str]:
174
+ """Extract the first explicit workspace file target from a request."""
175
+ match = _FILE_TARGET_RE.search(text or "")
176
+ if not match:
177
+ return None
178
+ return match.group(0).strip().strip("`'\".,:;)]}")
179
+
180
+
181
+ def inline_file_action_content(text: str) -> Optional[str]:
182
+ """Extract short user-provided content for deterministic file writes."""
183
+ raw = (text or "").strip()
184
+ # Each pattern requires an explicit binder so ambiguous words like "text"
185
+ # ("create a text file report.txt") or a bare "with" ("report.md with a
186
+ # summary of X") do not get captured as literal file content.
187
+ patterns = [
188
+ r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
189
+ r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
190
+ r"(?:content|body)\s+(?:is|as)\s+(.+)$",
191
+ r"(?:with the content|with content|containing)\s+(.+)$",
192
+ ]
193
+ for pattern in patterns:
194
+ match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
195
+ if match:
196
+ content = match.group(1).strip()
197
+ return content.strip("`'\"")
198
+ return None
199
+
200
+
201
+ def strip_generated_file_content(text: str) -> str:
202
+ """Remove common chat wrappers when a model is asked for file content only."""
203
+ content = (text or "").strip()
204
+ fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
205
+ if fenced:
206
+ content = fenced.group(1).strip()
207
+ return content
208
+
209
+
126
210
  def format_network_status(info: Dict) -> str:
127
211
  lines = [
128
212
  f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
@@ -243,11 +327,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
243
327
  user_email=user_email,
244
328
  conversation_id=conversation_id,
245
329
  )
246
-
330
+
247
331
  def extract_screenshot_context(image_data: Optional[str]) -> str:
248
332
  if not image_data:
249
333
  return ""
250
-
334
+
251
335
  lines = ["[SCREENSHOT INGESTION]"]
252
336
  image_bytes = b""
253
337
  try:
@@ -258,18 +342,18 @@ def create_chat_router(context: AppContext) -> APIRouter:
258
342
  except Exception as e:
259
343
  lines.append(f"- image_decode_error: {e}")
260
344
  return "\n".join(lines)
261
-
345
+
262
346
  tesseract_path = shutil.which("tesseract")
263
347
  if not tesseract_path:
264
348
  lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
265
349
  return "\n".join(lines)
266
-
350
+
267
351
  temp_path = None
268
352
  try:
269
353
  with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
270
354
  temp.write(image_bytes)
271
355
  temp_path = temp.name
272
-
356
+
273
357
  ocr_text = ""
274
358
  for lang in ("kor+eng", "eng"):
275
359
  completed = subprocess.run(
@@ -283,7 +367,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
283
367
  ocr_text = completed.stdout.strip()
284
368
  lines.append(f"- ocr_language: {lang}")
285
369
  break
286
-
370
+
287
371
  if ocr_text:
288
372
  lines.append("- ocr_text:")
289
373
  lines.append(ocr_text[:4000])
@@ -297,7 +381,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
297
381
  Path(temp_path).unlink()
298
382
  except OSError:
299
383
  pass
300
-
384
+
301
385
  return "\n".join(lines)
302
386
 
303
387
  _AGENT_RUNTIME = context.chat_agent_runtime
@@ -329,7 +413,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
329
413
  "conversation_id": req.conversation_id,
330
414
  "workspace_id": workspace_scope_from_request(request),
331
415
  }
332
-
416
+
333
417
  if is_network_status_request(req.message):
334
418
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
335
419
  save_to_history("user", history_message, **history_meta, **history_user)
@@ -347,7 +431,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
347
431
  headers={"X-Model": "network_status"},
348
432
  )
349
433
  return JSONResponse(content={"response": answer})
350
-
434
+
351
435
  if is_clear_command(req.message):
352
436
  command = req.message.strip().lower()
353
437
  clear_scope = "all" if command == "/clear_all" else "conversation"
@@ -392,7 +476,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
392
476
  headers={"X-Model": "history"},
393
477
  )
394
478
  return JSONResponse(content={"response": answer})
395
-
479
+
396
480
  if is_current_url_request(req.message) and req.client_url:
397
481
  answer = f"현재 페이지 URL: {req.client_url}"
398
482
  save_to_history("user", req.message, **history_meta, **history_user)
@@ -406,7 +490,74 @@ def create_chat_router(context: AppContext) -> APIRouter:
406
490
  headers={"X-Model": "client_url"},
407
491
  )
408
492
  return JSONResponse(content={"response": answer})
409
-
493
+
494
+ if is_file_action_request(req.message):
495
+ target_path = file_action_target(req.message)
496
+ if target_path:
497
+ content = inline_file_action_content(req.message)
498
+ if content is None and router.current_model_id:
499
+ if req.model and req.model != router.current_model_id:
500
+ if req.model not in router.loaded_model_ids:
501
+ raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
502
+ router.switch_model(req.model)
503
+ generation_context = (
504
+ "Create the exact content for the requested file. "
505
+ "Return only the file bytes as plain text. "
506
+ "Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
507
+ f"Target path: {target_path}\n"
508
+ f"User request: {req.message}"
509
+ )
510
+ raw_content = await router.generate_as(
511
+ router.current_model_id,
512
+ message="Return only the requested file content.",
513
+ context=generation_context,
514
+ max_tokens=req.max_tokens,
515
+ temperature=req.temperature,
516
+ )
517
+ content = strip_generated_file_content(str(raw_content))
518
+ if content is None:
519
+ content = ""
520
+ try:
521
+ result = execute_tool("write_file", {"path": target_path, "content": content})
522
+ except ToolError as exc:
523
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
524
+ answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
525
+ created_files = [{
526
+ "path": result.get("path") or target_path,
527
+ "filename": Path(result.get("path") or target_path).name,
528
+ "bytes": result.get("bytes", 0),
529
+ "action": "write_file",
530
+ }]
531
+ notify_chat_message("user", req.message, req.source)
532
+ notify_chat_message("assistant", answer, req.source)
533
+ payload = {
534
+ "status": "ok",
535
+ "response": answer,
536
+ "workspace": str(AGENT_ROOT),
537
+ "steps": [{
538
+ "state": AgentState.EXECUTING.value,
539
+ "action": "write_file",
540
+ "args": {"path": target_path},
541
+ "result": result,
542
+ }],
543
+ "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
544
+ "final_state": AgentState.DONE.value,
545
+ "created_files": created_files,
546
+ "routed_to_agent": True,
547
+ "action_route": "direct_write_file",
548
+ }
549
+ if req.stream:
550
+ async def _stream_file_result():
551
+ yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
552
+ yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
553
+ yield "data: [DONE]\n\n"
554
+ return StreamingResponse(
555
+ _stream_file_result(),
556
+ media_type="text/event-stream",
557
+ headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
558
+ )
559
+ return JSONResponse(content=payload)
560
+
410
561
  if not router.current_model_id:
411
562
  detail = "No model loaded. Call /models/load first."
412
563
  if CONFIG.is_public:
@@ -420,12 +571,40 @@ def create_chat_router(context: AppContext) -> APIRouter:
420
571
  "action": "load_model",
421
572
  },
422
573
  )
423
-
574
+
424
575
  if req.model and req.model != router.current_model_id:
425
576
  if req.model not in router.loaded_model_ids:
426
577
  raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
427
578
  router.switch_model(req.model)
428
-
579
+
580
+ if is_file_action_request(req.message):
581
+ agent_req = AgentRequest(
582
+ message=req.message,
583
+ conversation_id=req.conversation_id,
584
+ source=req.source or "web",
585
+ max_steps=25,
586
+ temperature=min(req.temperature, 0.2),
587
+ user_email=effective_email,
588
+ user_nickname=req.user_nickname,
589
+ workspace_id=workspace_scope_from_request(request),
590
+ )
591
+ result = await agent(agent_req, request)
592
+ answer = str(result.get("response") or "파일 작업을 처리했습니다.")
593
+ notify_chat_message("user", req.message, req.source)
594
+ notify_chat_message("assistant", answer, req.source)
595
+ result["routed_to_agent"] = True
596
+ if req.stream:
597
+ async def _stream_agent_result():
598
+ yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
599
+ yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
600
+ yield "data: [DONE]\n\n"
601
+ return StreamingResponse(
602
+ _stream_agent_result(),
603
+ media_type="text/event-stream",
604
+ headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
605
+ )
606
+ return JSONResponse(content=result)
607
+
429
608
  lang = detect_language(req.message)
430
609
  context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
431
610
  # v4 Context System: one budgeted, provenance-carrying assembly
@@ -462,12 +641,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
462
641
  print("📝 Document generation context retrieved from knowledge graph.")
463
642
  except Exception as e:
464
643
  logging.warning("Knowledge graph reinforcement skipped: %s", e)
465
-
644
+
466
645
  if req.image_data:
467
646
  screenshot_context = extract_screenshot_context(req.image_data)
468
647
  if screenshot_context:
469
648
  context += f"\n\n{screenshot_context}"
470
-
649
+
471
650
  if CONFIG.auto_read_chat_paths:
472
651
  _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
473
652
  requested_paths = [_m.group(1).strip() for _m in _file_path_re.finditer(req.message or "")]
@@ -487,7 +666,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
487
666
  "Attach the file, upload it, or use an approved local-file tool flow."
488
667
  ),
489
668
  )
490
-
669
+
491
670
  trace_seed = CHAT_SERVICE.build_graph_trace(
492
671
  req.message,
493
672
  KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
@@ -497,11 +676,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
497
676
  # Persisted with the answer trace: 'why is this in my context?'
498
677
  # is answerable from the stored record (UI surface lands in T9b).
499
678
  trace_seed["context_assembly"] = context_trace
500
-
679
+
501
680
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
502
681
  save_to_history("user", history_message, **history_meta, **history_user)
503
682
  notify_chat_message("user", req.message, req.source)
504
-
683
+
505
684
  if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
506
685
  conv_key = req.conversation_id or "default"
507
686
  session = _doc_gen_sessions.get(conv_key)
@@ -512,7 +691,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
512
691
  system_prompt = session.get_system_prompt(graph_md)
513
692
  sources = (doc_gen_context_result or {}).get("sources", [])
514
693
  footnote = format_sources_footnote(sources)
515
-
694
+
516
695
  if req.stream:
517
696
  async def _stream_doc_gen():
518
697
  collected = []
@@ -563,7 +742,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
563
742
  )
564
743
  notify_chat_message("assistant", str(result), req.source)
565
744
  return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
566
-
745
+
567
746
  if req.stream:
568
747
  recent_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
569
748
  stream_context = context
@@ -593,9 +772,9 @@ def create_chat_router(context: AppContext) -> APIRouter:
593
772
  else:
594
773
  history_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
595
774
  full_context = f"{history_context}\n{context}" if context else history_context
596
-
775
+
597
776
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
598
-
777
+
599
778
  save_to_history("assistant", str(result), **history_meta, **history_user)
600
779
  trace_record = CHAT_SERVICE.record_trace(
601
780
  question=req.message,
@@ -605,22 +784,22 @@ def create_chat_router(context: AppContext) -> APIRouter:
605
784
  trace=trace_seed,
606
785
  )
607
786
  notify_chat_message("assistant", str(result), req.source)
608
-
787
+
609
788
  return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
610
-
789
+
611
790
 
612
791
  @api_router.get("/history")
613
792
  async def fetch_history(request: Request):
614
793
  """웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
615
794
  require_user(request)
616
795
  return get_history()
617
-
796
+
618
797
  @api_router.get("/history/conversations")
619
798
  async def fetch_history_conversations(request: Request):
620
799
  """저장된 히스토리를 대화 단위로 묶어 반환합니다."""
621
800
  require_user(request)
622
801
  return group_history_conversations()
623
-
802
+
624
803
  @api_router.get("/history/conversations/{conversation_id:path}")
625
804
  async def fetch_history_conversation(conversation_id: str, request: Request):
626
805
  """선택한 대화의 메시지를 반환합니다."""
@@ -629,7 +808,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
629
808
  if not messages:
630
809
  raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
631
810
  return {"id": conversation_id, "messages": messages}
632
-
811
+
633
812
 
634
813
  @api_router.delete("/history/conversations/{conversation_id:path}")
635
814
  async def delete_history_conversation(conversation_id: str, request: Request):
@@ -645,7 +824,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
645
824
  kept=result.get("kept", 0),
646
825
  )
647
826
  return result
648
-
827
+
649
828
 
650
829
  @api_router.delete("/history")
651
830
  async def delete_history(request: Request, keep_last: int = 0):
@@ -659,7 +838,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
659
838
  kept=result.get("kept", 0),
660
839
  )
661
840
  return result
662
-
841
+
663
842
  @api_router.get("/history/search")
664
843
  async def search_history(q: str, request: Request):
665
844
  """키워드로 채팅 히스토리를 검색합니다."""
@@ -676,7 +855,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
676
855
  grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
677
856
  grouped[cid]["messages"].append(item)
678
857
  return {"results": list(grouped.values())[-30:], "query": q}
679
-
858
+
680
859
  async def _stream_chat(
681
860
  req: ChatRequest,
682
861
  context: str = "",
@@ -696,7 +875,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
696
875
  clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
697
876
  except Exception:
698
877
  pass
699
-
878
+
700
879
  full_response += str(clean_chunk)
701
880
  yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
702
881
  history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
@@ -724,7 +903,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
724
903
  schema_path = skill_dir / "schema.json"
725
904
  if not schema_path.exists():
726
905
  raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
727
-
906
+
728
907
  schema = json.loads(schema_path.read_text(encoding="utf-8"))
729
908
  eval_cases = schema.get("evals", [])
730
909
  if req.case_id:
@@ -732,7 +911,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
732
911
  if not eval_cases:
733
912
  return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
734
913
  "message": "No eval cases defined in schema.json"}
735
-
914
+
736
915
  action_name = schema.get("action", req.skill)
737
916
  results = []
738
917
  for case in eval_cases:
@@ -754,19 +933,19 @@ def create_chat_router(context: AppContext) -> APIRouter:
754
933
  results.append({"id": case_id, "description": case.get("description", ""),
755
934
  "passed": False, "error": str(exc),
756
935
  "pass_criteria": case.get("pass_criteria", "")})
757
-
936
+
758
937
  n_passed = sum(1 for r in results if r.get("passed") is True)
759
938
  return {
760
939
  "skill": req.skill, "action": action_name,
761
940
  "total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
762
941
  "results": results,
763
942
  }
764
-
943
+
765
944
 
766
945
  @api_router.post("/agent")
767
946
  async def agent(req: AgentRequest, request: Request):
768
947
  """Natural-language local agent.
769
-
948
+
770
949
  State machine:
771
950
  IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
772
951
  ↓ ↓
@@ -779,22 +958,22 @@ def create_chat_router(context: AppContext) -> APIRouter:
779
958
  req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
780
959
  if not router.current_model_id:
781
960
  raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
782
-
961
+
783
962
  ensure_agent_root()
784
963
  lang = detect_language(req.message)
785
964
  lang_hint = _LANG_HINT[lang]
786
965
  max_steps = max(1, min(req.max_steps, 50))
787
966
  max_retry = 3
788
-
967
+
789
968
  ctx = AgentRunContext()
790
969
  ctx.executing_model = req.executing_model
791
970
  ctx.reviewing_model = req.reviewing_model
792
-
971
+
793
972
  # PLANNING phase
794
973
  ctx.state = AgentState.PLANNING
795
974
  ctx.state_history.append(ctx.state.value)
796
975
  await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
797
-
976
+
798
977
  # Human-in-the-loop: pause after planning, return plan to UI
799
978
  if req.human_in_loop:
800
979
  context_id = secrets.token_urlsafe(16)
@@ -810,11 +989,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
810
989
  "executing_model": req.executing_model or router.current_model_id,
811
990
  "reviewing_model": req.reviewing_model or router.current_model_id,
812
991
  }
813
-
992
+
814
993
  # Auto-approve and run to completion (default behaviour)
815
994
  _AGENT_RUNTIME.approve(ctx, current_user)
816
995
  return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
817
-
996
+
818
997
 
819
998
  async def _agent_finish(
820
999
  ctx: AgentRunContext, req: AgentRequest, lang_hint: str,
@@ -823,7 +1002,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
823
1002
  """HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
824
1003
  await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
825
1004
  asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
826
-
1005
+
827
1006
  message = ctx.final_message or "작업을 완료했습니다."
828
1007
  save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
829
1008
  save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
@@ -850,33 +1029,33 @@ def create_chat_router(context: AppContext) -> APIRouter:
850
1029
  "final_state": ctx.state.value,
851
1030
  "created_files": created_files,
852
1031
  }
853
-
1032
+
854
1033
 
855
1034
  @api_router.post("/agent/resume")
856
1035
  async def agent_resume(req: AgentResumeRequest, request: Request):
857
1036
  """Resume a paused agent after human approval of the plan."""
858
1037
  current_user = require_user(request)
859
-
1038
+
860
1039
  with _pending_agents_lock:
861
1040
  entry = _pending_agents.pop(req.context_id, None)
862
1041
  if not entry:
863
1042
  raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
864
-
1043
+
865
1044
  ctx, orig_req, lang_hint, _orig_user = entry
866
-
1045
+
867
1046
  if not req.approved:
868
1047
  return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
869
-
1048
+
870
1049
  if req.modified_plan:
871
1050
  ctx.plan = req.modified_plan
872
1051
  ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
873
-
1052
+
874
1053
  # Apply model overrides from resume request (takes priority over original request)
875
1054
  ctx.executing_model = req.executing_model or ctx.executing_model
876
1055
  ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
877
-
1056
+
878
1057
  _AGENT_RUNTIME.approve(ctx, current_user)
879
-
1058
+
880
1059
  max_steps = max(1, min(orig_req.max_steps, 50))
881
1060
  max_retry = 3
882
1061
  return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
@@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Request
13
13
  from pydantic import BaseModel
14
14
 
15
15
  from latticeai.api.ui_redirects import app_redirect
16
+ from lattice_brain.ingestion import IngestionItem
16
17
 
17
18
 
18
19
  class KnowledgeGraphIngestRequest(BaseModel):
@@ -61,6 +62,7 @@ def create_knowledge_graph_router(
61
62
  require_user: Callable[[Request], str],
62
63
  static_dir: Path,
63
64
  allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
65
+ ingestion_pipeline: Any = None,
64
66
  ) -> APIRouter:
65
67
  router = APIRouter()
66
68
 
@@ -191,6 +193,37 @@ def create_knowledge_graph_router(
191
193
  if event_type not in {"message", "ai_response", "note"}:
192
194
  raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
193
195
  role = req.role or ("assistant" if event_type == "ai_response" else "user")
196
+ if ingestion_pipeline is not None:
197
+ source_type = "chat_message" if event_type in {"message", "ai_response"} else "note"
198
+ result = ingestion_pipeline.ingest(
199
+ IngestionItem(
200
+ source_type=source_type,
201
+ title=req.title,
202
+ text=req.content,
203
+ source_uri=req.source,
204
+ owner=req.user_email or current_user,
205
+ workspace_id=workspace_id,
206
+ conversation_id=req.conversation_id,
207
+ metadata={
208
+ "type": req.type,
209
+ "role": role,
210
+ "source": req.source or "mcp",
211
+ "user_nickname": req.user_nickname,
212
+ "raw": {
213
+ "type": req.type,
214
+ "title": req.title,
215
+ "content": req.content,
216
+ "workspace_id": workspace_id,
217
+ "metadata": req.metadata or {},
218
+ },
219
+ **(req.metadata or {}),
220
+ },
221
+ ),
222
+ user_email=req.user_email or current_user,
223
+ )
224
+ if result.status != "ok":
225
+ raise HTTPException(status_code=500, detail=result.detail or result.status)
226
+ return result.as_dict()
194
227
  return kg.ingest_message(
195
228
  role,
196
229
  req.content,