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