ltcai 8.8.0 → 9.0.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 (111) hide show
  1. package/README.md +33 -37
  2. package/auto_setup.py +84 -70
  3. package/docs/CHANGELOG.md +113 -237
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  11. package/docs/TRUST_MODEL.md +5 -1
  12. package/docs/WHY_LATTICE.md +4 -3
  13. package/docs/architecture.md +4 -0
  14. package/docs/kg-schema.md +1 -1
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/archive.py +4 -9
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/embeddings.py +38 -2
  19. package/lattice_brain/graph/_kg_common.py +39 -496
  20. package/lattice_brain/graph/_kg_constants.py +243 -0
  21. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  22. package/lattice_brain/graph/discovery.py +0 -948
  23. package/lattice_brain/graph/discovery_index.py +972 -0
  24. package/lattice_brain/graph/json_utils.py +25 -0
  25. package/lattice_brain/graph/retrieval.py +66 -597
  26. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  27. package/lattice_brain/graph/retrieval_vector.py +460 -0
  28. package/lattice_brain/graph/runtime.py +16 -0
  29. package/lattice_brain/graph/store.py +6 -0
  30. package/lattice_brain/ingestion.py +68 -0
  31. package/lattice_brain/portability.py +1 -9
  32. package/lattice_brain/quality.py +98 -4
  33. package/lattice_brain/runtime/agent_runtime.py +166 -0
  34. package/lattice_brain/runtime/multi_agent.py +1 -1
  35. package/lattice_brain/utils.py +28 -0
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/chat.py +368 -418
  38. package/latticeai/api/chat_helpers.py +227 -0
  39. package/latticeai/api/computer_use.py +149 -31
  40. package/latticeai/api/marketplace.py +11 -0
  41. package/latticeai/api/mcp.py +3 -2
  42. package/latticeai/api/models.py +4 -1
  43. package/latticeai/api/permissions.py +72 -33
  44. package/latticeai/api/setup.py +17 -2
  45. package/latticeai/api/tools.py +105 -62
  46. package/latticeai/app_factory.py +101 -296
  47. package/latticeai/core/agent.py +25 -7
  48. package/latticeai/core/io_utils.py +37 -0
  49. package/latticeai/core/legacy_compatibility.py +1 -1
  50. package/latticeai/core/local_embeddings.py +2 -4
  51. package/latticeai/core/marketplace.py +33 -2
  52. package/latticeai/core/mcp_catalog.py +450 -0
  53. package/latticeai/core/mcp_registry.py +2 -441
  54. package/latticeai/core/sessions.py +11 -3
  55. package/latticeai/core/tool_registry.py +15 -4
  56. package/latticeai/core/users.py +4 -9
  57. package/latticeai/core/workspace_os.py +1 -1
  58. package/latticeai/core/workspace_os_utils.py +3 -17
  59. package/latticeai/integrations/telegram_bot.py +7 -2
  60. package/latticeai/models/model_providers.py +111 -0
  61. package/latticeai/models/router.py +58 -136
  62. package/latticeai/runtime/audit_runtime.py +27 -16
  63. package/latticeai/runtime/automation_runtime.py +9 -0
  64. package/latticeai/runtime/bootstrap.py +1 -1
  65. package/latticeai/runtime/history_runtime.py +163 -0
  66. package/latticeai/runtime/namespace_runtime.py +173 -0
  67. package/latticeai/runtime/network_config_runtime.py +56 -0
  68. package/latticeai/runtime/sso_config_runtime.py +128 -0
  69. package/latticeai/runtime/user_key_runtime.py +106 -0
  70. package/latticeai/services/app_context.py +1 -0
  71. package/latticeai/services/architecture_readiness.py +2 -2
  72. package/latticeai/services/memory_service.py +213 -0
  73. package/latticeai/services/model_engines.py +79 -12
  74. package/latticeai/services/model_runtime.py +24 -4
  75. package/latticeai/services/platform_runtime.py +9 -1
  76. package/latticeai/services/process_audit.py +208 -0
  77. package/latticeai/services/product_readiness.py +11 -11
  78. package/latticeai/services/review_queue.py +64 -11
  79. package/latticeai/services/run_executor.py +21 -0
  80. package/latticeai/services/search_service.py +106 -30
  81. package/latticeai/services/setup_detection.py +80 -0
  82. package/latticeai/services/tool_dispatch.py +66 -0
  83. package/latticeai/services/workspace_service.py +15 -0
  84. package/package.json +1 -1
  85. package/scripts/check_i18n_literals.mjs +20 -8
  86. package/scripts/i18n_literal_allowlist.json +34 -0
  87. package/scripts/lint_frontend.mjs +6 -2
  88. package/setup_wizard.py +196 -74
  89. package/src-tauri/Cargo.lock +1 -1
  90. package/src-tauri/Cargo.toml +1 -1
  91. package/src-tauri/tauri.conf.json +1 -1
  92. package/static/app/asset-manifest.json +11 -11
  93. package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
  94. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  95. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
  96. package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
  97. package/static/app/assets/System-K6krGCqn.js +1 -0
  98. package/static/app/assets/index-C4R3ws30.js +17 -0
  99. package/static/app/assets/index-ChSeOB02.css +2 -0
  100. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  101. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
  102. package/static/app/index.html +2 -2
  103. package/static/css/tokens.css +4 -2
  104. package/static/sw.js +1 -1
  105. package/tools/local_files.py +6 -0
  106. package/latticeai/runtime/sso_runtime.py +0 -52
  107. package/static/app/assets/Brain-I1OSzxJu.js +0 -321
  108. package/static/app/assets/System-D1Lkei3I.js +0 -1
  109. package/static/app/assets/index--P0ksosz.js +0 -17
  110. package/static/app/assets/index-DCh5AoXt.css +0 -2
  111. package/static/app/assets/primitives-BLqaKk5g.js +0 -1
@@ -0,0 +1,972 @@
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: F403,F405
4
+
5
+ from ._kg_common import * # noqa: F403,F401
6
+
7
+
8
+ class KnowledgeGraphLocalIndexMixin:
9
+ """Local file → graph indexing (text extraction, node/index upserts,
10
+ graph-node deletion, orphan cleanup, and the index_local_folder driver),
11
+ split out of discovery. Composed into KnowledgeGraphStore alongside
12
+ KnowledgeGraphDiscoveryMixin; both share the instance so these methods
13
+ still reach sibling discovery/write helpers through the class MRO.
14
+ """
15
+
16
+ def _extract_local_file_text(
17
+ self, path: Path, category: str, *, include_ocr: bool
18
+ ) -> Tuple[str, Dict[str, Any]]:
19
+ ext = path.suffix.lower()
20
+ meta: Dict[str, Any] = {"parser": _parser_type_for_category(category, ext)}
21
+ text = ""
22
+ if category in {"text", "code"} or ext == ".csv":
23
+ text = path.read_text(encoding="utf-8", errors="replace")
24
+ elif ext == ".pdf":
25
+ import pdfplumber
26
+
27
+ with pdfplumber.open(str(path)) as pdf:
28
+ meta["pages"] = len(pdf.pages)
29
+ text = "\n\n".join((page.extract_text() or "") for page in pdf.pages)
30
+ elif ext == ".docx":
31
+ from docx import Document
32
+
33
+ doc = Document(str(path))
34
+ paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
35
+ table_lines = []
36
+ for table in doc.tables:
37
+ for row in table.rows:
38
+ cells = [_clean_text(cell.text) for cell in row.cells]
39
+ if any(cells):
40
+ table_lines.append("\t".join(cells))
41
+ meta["paragraphs"] = len(paragraphs)
42
+ meta["tables"] = len(doc.tables)
43
+ meta["table_rows"] = len(table_lines)
44
+ text = "\n\n".join([*paragraphs, *table_lines])
45
+ elif ext == ".xlsx":
46
+ from openpyxl import load_workbook
47
+
48
+ wb = load_workbook(str(path), read_only=True, data_only=True)
49
+ rows_all = []
50
+ non_empty_rows = 0
51
+ non_empty_cells = 0
52
+ char_count = 0
53
+ for ws in wb.worksheets:
54
+ sheet_rows = []
55
+ for row in ws.iter_rows(values_only=True):
56
+ cells = [
57
+ str(cell).strip() if cell is not None else "" for cell in row
58
+ ]
59
+ if not any(cells):
60
+ continue
61
+ line = "\t".join(cells)
62
+ non_empty_rows += 1
63
+ non_empty_cells += sum(1 for cell in cells if cell)
64
+ sheet_rows.append(line)
65
+ char_count += len(line) + 1
66
+ if char_count > 200_000:
67
+ break
68
+ if sheet_rows:
69
+ rows_all.append(f"[Sheet: {ws.title}]")
70
+ rows_all.extend(sheet_rows)
71
+ if char_count > 200_000:
72
+ break
73
+ meta["sheets"] = len(wb.worksheets)
74
+ meta["rows"] = non_empty_rows
75
+ meta["cells"] = non_empty_cells
76
+ text = "\n".join(rows_all)
77
+ elif ext == ".pptx":
78
+ from pptx import Presentation
79
+
80
+ prs = Presentation(str(path))
81
+ slides_text = []
82
+ for index, slide in enumerate(prs.slides, 1):
83
+ parts = []
84
+ for shape in slide.shapes:
85
+ if getattr(shape, "has_text_frame", False):
86
+ slide_text = shape.text_frame.text.strip()
87
+ if slide_text:
88
+ parts.append(slide_text)
89
+ if parts:
90
+ slides_text.append(f"[Slide {index}]\n" + "\n".join(parts))
91
+ meta["slides"] = len(prs.slides)
92
+ meta["text_slides"] = len(slides_text)
93
+ text = "\n\n".join(slides_text)
94
+ elif category == "image":
95
+ from PIL import Image
96
+
97
+ with Image.open(str(path)) as image:
98
+ meta.update(
99
+ {
100
+ "width": image.width,
101
+ "height": image.height,
102
+ "format": image.format,
103
+ "mode": image.mode,
104
+ "ocr_enabled": bool(include_ocr),
105
+ }
106
+ )
107
+ if include_ocr:
108
+ try:
109
+ import pytesseract
110
+
111
+ text = pytesseract.image_to_string(image)
112
+ meta["ocr_chars"] = len(text)
113
+ except (
114
+ Exception
115
+ ) as exc: # pragma: no cover - depends on local OCR runtime
116
+ meta["ocr_error"] = str(exc)
117
+ text = ""
118
+ # Large candidate #2 slice: always attach vision stub describe for IMAGE node evidence
119
+ try:
120
+ from ..embeddings import get_vision_embedder
121
+ v = get_vision_embedder()
122
+ cap = v.describe(str(path), meta)
123
+ meta["vision_caption"] = cap
124
+ if not text:
125
+ text = cap # fallback text signal for retrieval
126
+ except Exception:
127
+ meta["vision_caption"] = meta.get("vision_caption") or f"image:{path}"
128
+ return text[:200_000], meta
129
+
130
+ def _ensure_local_hierarchy(
131
+ self,
132
+ conn: sqlite3.Connection,
133
+ *,
134
+ source_id: str,
135
+ root: Path,
136
+ file_path: Path,
137
+ os_type: str,
138
+ drive_id: str,
139
+ ) -> str:
140
+ computer_label = platform.node() or "내 컴퓨터"
141
+ computer_id = f"computer:{_slug(computer_label)}"
142
+ drive_node_id = f"drive:{_sha256_text(f'{os_type}:{drive_id}')[:24]}"
143
+ root_folder_id = f"folder:{_sha256_text(f'{source_id}:root')[:24]}"
144
+ self._upsert_node(
145
+ conn, computer_id, "Computer", computer_label, metadata={"os_type": os_type}
146
+ )
147
+ self._upsert_node(
148
+ conn,
149
+ drive_node_id,
150
+ "Drive",
151
+ drive_id,
152
+ metadata={"os_type": os_type, "drive_id": drive_id},
153
+ )
154
+ self._upsert_edge(
155
+ conn,
156
+ computer_id,
157
+ drive_node_id,
158
+ "포함함",
159
+ metadata={"source": "local_scan"},
160
+ )
161
+ self._upsert_node(
162
+ conn,
163
+ root_folder_id,
164
+ "Folder",
165
+ root.name or str(root),
166
+ summary=str(root),
167
+ metadata={"source_id": source_id, "path": str(root), "root": True},
168
+ )
169
+ self._upsert_edge(
170
+ conn,
171
+ drive_node_id,
172
+ root_folder_id,
173
+ "포함함",
174
+ metadata={"source": "local_scan"},
175
+ )
176
+
177
+ try:
178
+ relative_parent = file_path.parent.relative_to(root)
179
+ except ValueError:
180
+ relative_parent = Path()
181
+ parent_id = root_folder_id
182
+ current_path = root
183
+ for part in relative_parent.parts:
184
+ current_path = current_path / part
185
+ folder_id = (
186
+ f"folder:{_sha256_text(f'{source_id}:{current_path.as_posix()}')[:24]}"
187
+ )
188
+ self._upsert_node(
189
+ conn,
190
+ folder_id,
191
+ "Folder",
192
+ part,
193
+ summary=str(current_path),
194
+ metadata={
195
+ "source_id": source_id,
196
+ "path": str(current_path),
197
+ "root": False,
198
+ },
199
+ )
200
+ self._upsert_edge(
201
+ conn, parent_id, folder_id, "포함함", metadata={"source": "local_scan"}
202
+ )
203
+ parent_id = folder_id
204
+ return parent_id
205
+
206
+ def _upsert_local_file_index(
207
+ self,
208
+ conn: sqlite3.Connection,
209
+ *,
210
+ source_id: str,
211
+ root: Path,
212
+ file_path: Path,
213
+ stat: Optional[os.stat_result],
214
+ os_type: str,
215
+ drive_id: str,
216
+ status: str,
217
+ parser_type: str,
218
+ sha256: Optional[str] = None,
219
+ graph_node_id: Optional[str] = None,
220
+ error_message: Optional[str] = None,
221
+ metadata: Optional[Dict[str, Any]] = None,
222
+ ) -> str:
223
+ try:
224
+ relative_path = file_path.relative_to(root).as_posix()
225
+ except ValueError:
226
+ relative_path = file_path.name
227
+ index_id = f"local-index:{_sha256_text(f'{source_id}:{relative_path}')[:24]}"
228
+ now = _now()
229
+ size = stat.st_size if stat else None
230
+ modified_at = _safe_iso_from_stat_mtime(stat.st_mtime) if stat else ""
231
+ conn.execute(
232
+ """
233
+ INSERT INTO local_file_index(
234
+ id, source_id, os_type, drive_id, root_path, file_path, relative_path,
235
+ file_name, extension, size_bytes, modified_at, sha256, last_scanned_at,
236
+ last_indexed_at, parser_type, status, error_message, graph_node_id,
237
+ deleted, metadata_json
238
+ )
239
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
240
+ ON CONFLICT(source_id, relative_path) DO UPDATE SET
241
+ os_type=excluded.os_type,
242
+ drive_id=excluded.drive_id,
243
+ root_path=excluded.root_path,
244
+ file_path=excluded.file_path,
245
+ file_name=excluded.file_name,
246
+ extension=excluded.extension,
247
+ size_bytes=excluded.size_bytes,
248
+ modified_at=excluded.modified_at,
249
+ sha256=excluded.sha256,
250
+ last_scanned_at=excluded.last_scanned_at,
251
+ last_indexed_at=excluded.last_indexed_at,
252
+ parser_type=excluded.parser_type,
253
+ status=excluded.status,
254
+ error_message=excluded.error_message,
255
+ graph_node_id=excluded.graph_node_id,
256
+ deleted=excluded.deleted,
257
+ metadata_json=excluded.metadata_json
258
+ """,
259
+ (
260
+ index_id,
261
+ source_id,
262
+ os_type,
263
+ drive_id,
264
+ str(root),
265
+ str(file_path),
266
+ relative_path,
267
+ file_path.name,
268
+ file_path.suffix.lower(),
269
+ size,
270
+ modified_at,
271
+ sha256,
272
+ now,
273
+ now if status == "indexed" else None,
274
+ parser_type,
275
+ status,
276
+ error_message,
277
+ graph_node_id,
278
+ 0 if status != "deleted" else 1,
279
+ _json(metadata),
280
+ ),
281
+ )
282
+ return index_id
283
+
284
+ def _upsert_local_file_node(
285
+ self,
286
+ conn: sqlite3.Connection,
287
+ *,
288
+ source_id: str,
289
+ root: Path,
290
+ file_path: Path,
291
+ stat: os.stat_result,
292
+ os_type: str,
293
+ drive_id: str,
294
+ sha256: str,
295
+ category: str,
296
+ parser_type: str,
297
+ text: str,
298
+ parser_meta: Dict[str, Any],
299
+ ) -> str:
300
+ text = _clean_text(text)
301
+ if not text:
302
+ raise ValueError("텍스트 추출 결과가 비어 있습니다.")
303
+ try:
304
+ relative_path = file_path.relative_to(root).as_posix()
305
+ except ValueError:
306
+ relative_path = file_path.name
307
+ file_node_id = f"local-file:{_sha256_text(f'{source_id}:{relative_path}')[:24]}"
308
+ parent_folder_id = self._ensure_local_hierarchy(
309
+ conn,
310
+ source_id=source_id,
311
+ root=root,
312
+ file_path=file_path,
313
+ os_type=os_type,
314
+ drive_id=drive_id,
315
+ )
316
+ child_rows = conn.execute(
317
+ """
318
+ SELECT e.to_node AS id
319
+ FROM edges e
320
+ JOIN nodes n ON n.id=e.to_node
321
+ WHERE e.from_node=? AND n.type IN ('Chunk', 'ImageText', 'Section')
322
+ """,
323
+ (file_node_id,),
324
+ ).fetchall()
325
+ child_ids = [row["id"] for row in child_rows]
326
+ conn.execute("DELETE FROM chunks WHERE source_node=?", (file_node_id,))
327
+ if child_ids:
328
+ placeholders = ",".join("?" * len(child_ids))
329
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", child_ids)
330
+ self._v2_delete_nodes(conn, child_ids)
331
+ conn.execute("DELETE FROM edges WHERE from_node=?", (file_node_id,))
332
+ self._v2_delete_edges_from(conn, file_node_id)
333
+
334
+ metadata = {
335
+ "source": "local_folder",
336
+ "source_id": source_id,
337
+ "root_path": str(root),
338
+ "file_path": str(file_path),
339
+ "relative_path": relative_path,
340
+ "filename": file_path.name,
341
+ "ext": file_path.suffix.lower(),
342
+ "category": category,
343
+ "parser_type": parser_type,
344
+ "bytes": stat.st_size,
345
+ "modified_at": _safe_iso_from_stat_mtime(stat.st_mtime),
346
+ "sha256": sha256,
347
+ "parser": parser_meta,
348
+ }
349
+ self._upsert_node(
350
+ conn,
351
+ file_node_id,
352
+ _node_type_for_category(category),
353
+ file_path.name,
354
+ summary=text[:700],
355
+ metadata=metadata,
356
+ raw=metadata,
357
+ )
358
+ self._upsert_edge(
359
+ conn,
360
+ parent_folder_id,
361
+ file_node_id,
362
+ "포함함",
363
+ weight=1.0,
364
+ metadata={"source": "local_scan"},
365
+ )
366
+
367
+ target_for_concepts = text
368
+ if category == "image" and text:
369
+ image_text_id = f"imagetext:{_sha256_text(f'{file_node_id}:ocr')[:24]}"
370
+ self._upsert_node(
371
+ conn,
372
+ image_text_id,
373
+ "ImageText",
374
+ f"{file_path.name} OCR",
375
+ summary=_clean_text(text)[:700],
376
+ metadata={
377
+ "source_node": file_node_id,
378
+ "source_id": source_id,
379
+ "chars": len(text),
380
+ },
381
+ )
382
+ self._upsert_edge(
383
+ conn,
384
+ file_node_id,
385
+ image_text_id,
386
+ "포함함",
387
+ weight=0.8,
388
+ metadata={"source": "ocr"},
389
+ )
390
+
391
+ for index, chunk in enumerate(_chunks(text)):
392
+ chunk_id = f"chunk:{_sha256_text(f'{file_node_id}:{index}:{chunk}')[:24]}"
393
+ self._upsert_node(
394
+ conn,
395
+ chunk_id,
396
+ "Chunk",
397
+ f"{file_path.name} chunk {index + 1}",
398
+ summary=chunk[:500],
399
+ metadata={
400
+ "index": index,
401
+ "source_node": file_node_id,
402
+ "source_id": source_id,
403
+ },
404
+ )
405
+ self._upsert_chunk(
406
+ conn,
407
+ chunk_id=chunk_id,
408
+ source_node=file_node_id,
409
+ text=chunk,
410
+ metadata={
411
+ "index": index,
412
+ "source_node": file_node_id,
413
+ "source_id": source_id,
414
+ },
415
+ )
416
+ self._upsert_edge(
417
+ conn,
418
+ file_node_id,
419
+ chunk_id,
420
+ "포함함",
421
+ weight=0.7,
422
+ metadata={"source": "local_scan"},
423
+ )
424
+
425
+ concepts = _extract_concepts(target_for_concepts, limit=18)
426
+ concept_ids: Dict[str, str] = {}
427
+ for concept in concepts:
428
+ node_t = _classify_node_type(concept, target_for_concepts)
429
+ concept_id = f"{node_t.lower()}:{_slug(concept)}"
430
+ concept_ids[concept.lower()] = concept_id
431
+ self._upsert_node(
432
+ conn,
433
+ concept_id,
434
+ node_t,
435
+ concept,
436
+ metadata={
437
+ "auto_extracted": True,
438
+ "source": "local_folder",
439
+ "source_id": source_id,
440
+ },
441
+ )
442
+ self._upsert_edge(
443
+ conn,
444
+ file_node_id,
445
+ concept_id,
446
+ "언급함",
447
+ weight=0.75,
448
+ metadata={"source": "local_scan"},
449
+ )
450
+
451
+ for triple in _extract_triples(target_for_concepts, concepts, limit=20):
452
+ subj_id = concept_ids.get(triple["subject"].lower())
453
+ obj_id = concept_ids.get(triple["object"].lower())
454
+ if subj_id and obj_id and subj_id != obj_id:
455
+ self._upsert_edge(
456
+ conn,
457
+ subj_id,
458
+ obj_id,
459
+ triple["relation"],
460
+ weight=0.9,
461
+ metadata={
462
+ "context": triple.get("context", "")[:240],
463
+ "source_id": source_id,
464
+ },
465
+ )
466
+
467
+ for item in _semantic_items(target_for_concepts):
468
+ sem_type = item["type"]
469
+ sem_title = item["title"]
470
+ sem_id = f"{sem_type.lower()}:{_sha256_text(f'{file_node_id}:{sem_type}:{sem_title}')[:24]}"
471
+ self._upsert_node(
472
+ conn,
473
+ sem_id,
474
+ sem_type,
475
+ sem_title,
476
+ summary=item["summary"],
477
+ metadata={
478
+ "auto_extracted": True,
479
+ "source_node": file_node_id,
480
+ "filename": file_path.name,
481
+ },
482
+ raw=item,
483
+ )
484
+ self._upsert_edge(conn, file_node_id, sem_id, "포함함", weight=0.9)
485
+
486
+ return file_node_id
487
+
488
+ def _delete_local_file_graph(
489
+ self, conn: sqlite3.Connection, file_node_id: Optional[str]
490
+ ) -> None:
491
+ if not file_node_id:
492
+ return
493
+
494
+ file_row = conn.execute(
495
+ "SELECT metadata_json FROM nodes WHERE id=?",
496
+ (file_node_id,),
497
+ ).fetchone()
498
+ source_id = None
499
+ if file_row:
500
+ source_id = _safe_loads(file_row["metadata_json"]).get("source_id")
501
+
502
+ linked_rows = conn.execute(
503
+ """
504
+ SELECT n.id, n.type, n.metadata_json
505
+ FROM edges e
506
+ JOIN nodes n ON n.id=e.to_node
507
+ WHERE e.from_node=?
508
+ """,
509
+ (file_node_id,),
510
+ ).fetchall()
511
+ owned_ids: set = set()
512
+ auto_candidate_ids: set = set()
513
+ for row in linked_rows:
514
+ metadata = _safe_loads(row["metadata_json"])
515
+ if (
516
+ row["type"] in {"Chunk", "ImageText", "Section"}
517
+ or metadata.get("source_node") == file_node_id
518
+ ):
519
+ owned_ids.add(row["id"])
520
+ elif (
521
+ metadata.get("auto_extracted")
522
+ and metadata.get("source") == "local_folder"
523
+ ):
524
+ auto_candidate_ids.add(row["id"])
525
+
526
+ conn.execute("DELETE FROM chunks WHERE source_node=?", (file_node_id,))
527
+ conn.execute(
528
+ "DELETE FROM edges WHERE from_node=? OR to_node=?",
529
+ (file_node_id, file_node_id),
530
+ )
531
+ conn.execute("DELETE FROM nodes WHERE id=?", (file_node_id,))
532
+ self._v2_delete_nodes(conn, [file_node_id])
533
+
534
+ def delete_nodes(node_ids: set) -> None:
535
+ if not node_ids:
536
+ return
537
+ placeholders = ",".join("?" * len(node_ids))
538
+ params = list(node_ids)
539
+ conn.execute(
540
+ f"DELETE FROM chunks WHERE source_node IN ({placeholders})", params
541
+ )
542
+ conn.execute(
543
+ f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})",
544
+ params * 2,
545
+ )
546
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", params)
547
+ self._v2_delete_nodes(conn, params)
548
+
549
+ delete_nodes(owned_ids)
550
+
551
+ removable_auto_ids: set = set()
552
+ for node_id in auto_candidate_ids:
553
+ remaining_edges = conn.execute(
554
+ "SELECT from_node, to_node FROM edges WHERE from_node=? OR to_node=?",
555
+ (node_id, node_id),
556
+ ).fetchall()
557
+ if all(
558
+ (
559
+ row["from_node"] in auto_candidate_ids
560
+ and row["to_node"] in auto_candidate_ids
561
+ )
562
+ for row in remaining_edges
563
+ ):
564
+ removable_auto_ids.add(node_id)
565
+ delete_nodes(removable_auto_ids)
566
+ if source_id:
567
+ self._cleanup_local_graph_orphans(conn, str(source_id))
568
+
569
+ def _cleanup_local_graph_orphans(
570
+ self, conn: sqlite3.Connection, source_id: str
571
+ ) -> None:
572
+ while True:
573
+ folder_rows = conn.execute(
574
+ "SELECT id, metadata_json FROM nodes WHERE type='Folder'"
575
+ ).fetchall()
576
+ leaf_ids = []
577
+ for row in folder_rows:
578
+ metadata = _safe_loads(row["metadata_json"])
579
+ if metadata.get("source_id") != source_id:
580
+ continue
581
+ has_children = conn.execute(
582
+ "SELECT 1 FROM edges WHERE from_node=? LIMIT 1",
583
+ (row["id"],),
584
+ ).fetchone()
585
+ if not has_children:
586
+ leaf_ids.append(row["id"])
587
+ if not leaf_ids:
588
+ break
589
+ placeholders = ",".join("?" * len(leaf_ids))
590
+ conn.execute(
591
+ f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})",
592
+ leaf_ids * 2,
593
+ )
594
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", leaf_ids)
595
+ self._v2_delete_nodes(conn, leaf_ids)
596
+
597
+ for node_type in ("Drive", "Computer"):
598
+ rows = conn.execute(
599
+ "SELECT id FROM nodes WHERE type=?", (node_type,)
600
+ ).fetchall()
601
+ removable = []
602
+ for row in rows:
603
+ has_children = conn.execute(
604
+ "SELECT 1 FROM edges WHERE from_node=? LIMIT 1",
605
+ (row["id"],),
606
+ ).fetchone()
607
+ if not has_children:
608
+ removable.append(row["id"])
609
+ if removable:
610
+ placeholders = ",".join("?" * len(removable))
611
+ conn.execute(
612
+ f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})",
613
+ removable * 2,
614
+ )
615
+ conn.execute(
616
+ f"DELETE FROM nodes WHERE id IN ({placeholders})", removable
617
+ )
618
+ self._v2_delete_nodes(conn, removable)
619
+
620
+ def _local_file_index_has_extracted_text(self, row: sqlite3.Row) -> bool:
621
+ metadata = _safe_loads(row["metadata_json"])
622
+ parser = metadata.get("parser") if isinstance(metadata, dict) else {}
623
+ if not isinstance(parser, dict):
624
+ return False
625
+ try:
626
+ return int(parser.get("extracted_chars") or 0) > 0
627
+ except (TypeError, ValueError):
628
+ return False
629
+
630
+ def index_local_folder(
631
+ self,
632
+ path: Path,
633
+ *,
634
+ include_ocr: bool = False,
635
+ watch_enabled: bool = False,
636
+ user_email: Optional[str] = None,
637
+ consent: Optional[Dict[str, Any]] = None,
638
+ max_files: int = 5_000,
639
+ ) -> Dict[str, Any]:
640
+ """Read approved files from a local folder and connect them to Graph RAG."""
641
+ root = Path(path).expanduser().resolve()
642
+ if not root.exists():
643
+ raise ValueError(f"경로가 존재하지 않습니다: {path}")
644
+ if not root.is_dir():
645
+ raise ValueError(f"폴더가 아닙니다: {path}")
646
+
647
+ os_type = _current_os_type()
648
+ drive_id = _drive_id_for_path(root)
649
+ source_id = f"source:{_path_fingerprint(root)}"
650
+ now = _now()
651
+ max_files = max(1, min(int(max_files or 5_000), 50_000))
652
+ consent_payload = {
653
+ "approved_at": now,
654
+ "approved_by": user_email,
655
+ "knowledge_source": True,
656
+ "include_ocr": bool(include_ocr),
657
+ "watch_enabled": bool(watch_enabled),
658
+ "sensitive_files_default_excluded": True,
659
+ **(consent or {}),
660
+ }
661
+ counts: Counter = Counter()
662
+ seen_relative_paths: set = set()
663
+ indexed_nodes: List[str] = []
664
+ errors: List[Dict[str, str]] = []
665
+ limit_reached = False
666
+
667
+ with self._connect() as conn:
668
+ conn.execute(
669
+ """
670
+ INSERT INTO knowledge_sources(
671
+ id, root_path, os_type, drive_id, label, status, include_ocr,
672
+ watch_enabled, consent_json, created_at, updated_at, last_scanned_at
673
+ )
674
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
675
+ ON CONFLICT(id) DO UPDATE SET
676
+ root_path=excluded.root_path,
677
+ os_type=excluded.os_type,
678
+ drive_id=excluded.drive_id,
679
+ label=excluded.label,
680
+ status=excluded.status,
681
+ include_ocr=excluded.include_ocr,
682
+ watch_enabled=excluded.watch_enabled,
683
+ consent_json=excluded.consent_json,
684
+ updated_at=excluded.updated_at,
685
+ last_scanned_at=excluded.last_scanned_at
686
+ """,
687
+ (
688
+ source_id,
689
+ str(root),
690
+ os_type,
691
+ drive_id,
692
+ root.name or str(root),
693
+ "scanning",
694
+ 1 if include_ocr else 0,
695
+ 1 if watch_enabled else 0,
696
+ _json(consent_payload),
697
+ now,
698
+ now,
699
+ now,
700
+ ),
701
+ )
702
+
703
+ for entry in self._iter_local_scan_entries(root, max_files=max_files):
704
+ kind = entry["kind"]
705
+ file_path = entry["path"]
706
+ if kind == "limit_reached":
707
+ counts["limit_reached"] += 1
708
+ limit_reached = True
709
+ break
710
+ if kind in {"excluded_dir", "excluded"}:
711
+ counts["excluded"] += 1
712
+ continue
713
+ if kind in {"inaccessible_dir", "inaccessible_file"}:
714
+ counts["failed"] += 1
715
+ errors.append(
716
+ {
717
+ "path": str(file_path),
718
+ "error": entry.get("reason", "inaccessible"),
719
+ }
720
+ )
721
+ continue
722
+ if kind != "file":
723
+ continue
724
+
725
+ stat = entry["stat"]
726
+ try:
727
+ relative_path = file_path.relative_to(root).as_posix()
728
+ except ValueError:
729
+ relative_path = file_path.name
730
+ seen_relative_paths.add(relative_path)
731
+ modified_at = _safe_iso_from_stat_mtime(stat.st_mtime)
732
+ existing = conn.execute(
733
+ """
734
+ SELECT size_bytes, modified_at, sha256, graph_node_id, status, metadata_json
735
+ FROM local_file_index
736
+ WHERE source_id=? AND relative_path=?
737
+ """,
738
+ (source_id, relative_path),
739
+ ).fetchone()
740
+ decision = self._local_file_decision(file_path, root, stat)
741
+ parser_type = decision["parser_type"]
742
+ if not decision["indexable"]:
743
+ counts[decision["status"]] += 1
744
+ if existing and existing["graph_node_id"]:
745
+ self._delete_local_file_graph(conn, existing["graph_node_id"])
746
+ self._upsert_local_file_index(
747
+ conn,
748
+ source_id=source_id,
749
+ root=root,
750
+ file_path=file_path,
751
+ stat=stat,
752
+ os_type=os_type,
753
+ drive_id=drive_id,
754
+ status=decision["status"],
755
+ parser_type=parser_type,
756
+ metadata={
757
+ "reason": decision["reason"],
758
+ "category": decision["category"],
759
+ },
760
+ )
761
+ continue
762
+
763
+ if (
764
+ existing
765
+ and existing["status"] == "indexed"
766
+ and existing["graph_node_id"]
767
+ and self._local_file_index_has_extracted_text(existing)
768
+ and existing["size_bytes"] == stat.st_size
769
+ and existing["modified_at"] == modified_at
770
+ ):
771
+ counts["skipped_unchanged"] += 1
772
+ self._upsert_local_file_index(
773
+ conn,
774
+ source_id=source_id,
775
+ root=root,
776
+ file_path=file_path,
777
+ stat=stat,
778
+ os_type=os_type,
779
+ drive_id=drive_id,
780
+ status="indexed",
781
+ parser_type=parser_type,
782
+ sha256=existing["sha256"],
783
+ graph_node_id=existing["graph_node_id"],
784
+ metadata={
785
+ **_safe_loads(existing["metadata_json"]),
786
+ "category": decision["category"],
787
+ "unchanged": True,
788
+ },
789
+ )
790
+ continue
791
+
792
+ try:
793
+ data = file_path.read_bytes()
794
+ digest = _sha256_bytes(data)
795
+ except Exception as exc:
796
+ counts["failed"] += 1
797
+ errors.append({"path": str(file_path), "error": str(exc)})
798
+ if existing and existing["graph_node_id"]:
799
+ self._delete_local_file_graph(conn, existing["graph_node_id"])
800
+ self._upsert_local_file_index(
801
+ conn,
802
+ source_id=source_id,
803
+ root=root,
804
+ file_path=file_path,
805
+ stat=stat,
806
+ os_type=os_type,
807
+ drive_id=drive_id,
808
+ status="failed",
809
+ parser_type=parser_type,
810
+ error_message=str(exc),
811
+ metadata={"category": decision["category"]},
812
+ )
813
+ continue
814
+
815
+ if (
816
+ existing
817
+ and existing["sha256"] == digest
818
+ and existing["graph_node_id"]
819
+ and self._local_file_index_has_extracted_text(existing)
820
+ ):
821
+ counts["skipped_unchanged"] += 1
822
+ self._upsert_local_file_index(
823
+ conn,
824
+ source_id=source_id,
825
+ root=root,
826
+ file_path=file_path,
827
+ stat=stat,
828
+ os_type=os_type,
829
+ drive_id=drive_id,
830
+ status="indexed",
831
+ parser_type=parser_type,
832
+ sha256=digest,
833
+ graph_node_id=existing["graph_node_id"],
834
+ metadata={
835
+ **_safe_loads(existing["metadata_json"]),
836
+ "category": decision["category"],
837
+ "sha256_unchanged": True,
838
+ },
839
+ )
840
+ continue
841
+
842
+ try:
843
+ text, parser_meta = self._extract_local_file_text(
844
+ file_path,
845
+ decision["category"],
846
+ include_ocr=include_ocr,
847
+ )
848
+ text = _clean_text(text)
849
+ parser_meta = {**parser_meta, "extracted_chars": len(text)}
850
+ if not text:
851
+ counts["skipped_empty_text"] += 1
852
+ if existing and existing["graph_node_id"]:
853
+ self._delete_local_file_graph(
854
+ conn, existing["graph_node_id"]
855
+ )
856
+ self._upsert_local_file_index(
857
+ conn,
858
+ source_id=source_id,
859
+ root=root,
860
+ file_path=file_path,
861
+ stat=stat,
862
+ os_type=os_type,
863
+ drive_id=drive_id,
864
+ status="skipped_empty_text",
865
+ parser_type=parser_type,
866
+ sha256=digest,
867
+ error_message="텍스트 추출 결과가 비어 있습니다.",
868
+ metadata={
869
+ "category": decision["category"],
870
+ "parser": parser_meta,
871
+ },
872
+ )
873
+ continue
874
+ graph_node_id = self._upsert_local_file_node(
875
+ conn,
876
+ source_id=source_id,
877
+ root=root,
878
+ file_path=file_path,
879
+ stat=stat,
880
+ os_type=os_type,
881
+ drive_id=drive_id,
882
+ sha256=digest,
883
+ category=decision["category"],
884
+ parser_type=parser_type,
885
+ text=text,
886
+ parser_meta=parser_meta,
887
+ )
888
+ self._upsert_local_file_index(
889
+ conn,
890
+ source_id=source_id,
891
+ root=root,
892
+ file_path=file_path,
893
+ stat=stat,
894
+ os_type=os_type,
895
+ drive_id=drive_id,
896
+ status="indexed",
897
+ parser_type=parser_type,
898
+ sha256=digest,
899
+ graph_node_id=graph_node_id,
900
+ metadata={
901
+ "category": decision["category"],
902
+ "parser": parser_meta,
903
+ },
904
+ )
905
+ counts["indexed"] += 1
906
+ indexed_nodes.append(graph_node_id)
907
+ except Exception as exc:
908
+ counts["failed"] += 1
909
+ errors.append({"path": str(file_path), "error": str(exc)})
910
+ if existing and existing["graph_node_id"]:
911
+ self._delete_local_file_graph(conn, existing["graph_node_id"])
912
+ self._upsert_local_file_index(
913
+ conn,
914
+ source_id=source_id,
915
+ root=root,
916
+ file_path=file_path,
917
+ stat=stat,
918
+ os_type=os_type,
919
+ drive_id=drive_id,
920
+ status="failed",
921
+ parser_type=parser_type,
922
+ sha256=digest,
923
+ error_message=str(exc),
924
+ metadata={"category": decision["category"]},
925
+ )
926
+
927
+ if not limit_reached:
928
+ existing_rows = {
929
+ row["relative_path"]: row["graph_node_id"]
930
+ for row in conn.execute(
931
+ "SELECT relative_path, graph_node_id FROM local_file_index WHERE source_id=?",
932
+ (source_id,),
933
+ )
934
+ }
935
+ deleted_paths = set(existing_rows) - seen_relative_paths
936
+ for relative_path in deleted_paths:
937
+ self._delete_local_file_graph(
938
+ conn, existing_rows.get(relative_path)
939
+ )
940
+ conn.execute(
941
+ """
942
+ UPDATE local_file_index
943
+ SET status='deleted', deleted=1, last_scanned_at=?, error_message=NULL, graph_node_id=NULL
944
+ WHERE source_id=? AND relative_path=?
945
+ """,
946
+ (_now(), source_id, relative_path),
947
+ )
948
+ counts["deleted"] = len(deleted_paths)
949
+ conn.execute(
950
+ """
951
+ UPDATE knowledge_sources
952
+ SET status='active', updated_at=?, last_scanned_at=?
953
+ WHERE id=?
954
+ """,
955
+ (_now(), _now(), source_id),
956
+ )
957
+
958
+ return {
959
+ "status": "ok",
960
+ "source": {
961
+ "id": source_id,
962
+ "root_path": str(root),
963
+ "os_type": os_type,
964
+ "drive_id": drive_id,
965
+ "include_ocr": bool(include_ocr),
966
+ "watch_enabled": bool(watch_enabled),
967
+ },
968
+ "counts": dict(counts),
969
+ "indexed_nodes": indexed_nodes[:100],
970
+ "errors": errors[:50],
971
+ "notice": "Lattice AI는 사용자가 선택한 폴더만 AI 지식으로 변환합니다.",
972
+ }