ltcai 11.1.0 → 11.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 (289) hide show
  1. package/README.md +47 -54
  2. package/docs/CHANGELOG.md +94 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  8. package/docs/ONBOARDING.md +1 -1
  9. package/docs/OPERATIONS.md +6 -2
  10. package/docs/PERMISSION_MODE.md +1 -1
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/WHY_LATTICE.md +1 -1
  13. package/docs/architecture.md +6 -2
  14. package/docs/kg-schema.md +2 -2
  15. package/docs/v11.3.0_PLAN.md +202 -0
  16. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/gates.py +125 -0
  19. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  20. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  21. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  22. package/lattice_brain/graph/_kg_common/text.py +479 -0
  23. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  24. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  25. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  26. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  27. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  28. package/lattice_brain/graph/fusion.py +35 -4
  29. package/lattice_brain/graph/projection/__init__.py +42 -0
  30. package/lattice_brain/graph/projection/curation.py +500 -0
  31. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +81 -485
  32. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  33. package/lattice_brain/graph/retrieval/context.py +197 -0
  34. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  35. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  36. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  37. package/lattice_brain/graph/retrieval/signals.py +95 -0
  38. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  39. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  40. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  41. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  42. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  43. package/lattice_brain/graph/schema.py +9 -0
  44. package/lattice_brain/graph/store.py +9 -0
  45. package/lattice_brain/graph/vector_index/selector.py +32 -2
  46. package/lattice_brain/ingestion/__init__.py +130 -0
  47. package/lattice_brain/ingestion/_contract.py +90 -0
  48. package/lattice_brain/ingestion/constants.py +127 -0
  49. package/lattice_brain/ingestion/folder_scan.py +57 -0
  50. package/lattice_brain/ingestion/folders.py +258 -0
  51. package/lattice_brain/ingestion/hashing.py +26 -0
  52. package/lattice_brain/ingestion/jobs_api.py +107 -0
  53. package/lattice_brain/ingestion/models.py +80 -0
  54. package/lattice_brain/ingestion/pipeline.py +486 -0
  55. package/lattice_brain/ingestion/quality.py +209 -0
  56. package/lattice_brain/ingestion/routing.py +295 -0
  57. package/lattice_brain/multimodal/__init__.py +164 -0
  58. package/lattice_brain/multimodal/audio.py +77 -0
  59. package/lattice_brain/multimodal/common.py +118 -0
  60. package/lattice_brain/{multimodal.py → multimodal/images.py} +22 -262
  61. package/lattice_brain/multimodal/ports.py +169 -0
  62. package/lattice_brain/multimodal/video.py +410 -0
  63. package/lattice_brain/portability/__init__.py +90 -0
  64. package/lattice_brain/portability/_contract.py +42 -0
  65. package/lattice_brain/portability/backups.py +338 -0
  66. package/lattice_brain/portability/bundles.py +136 -0
  67. package/lattice_brain/portability/constants.py +93 -0
  68. package/lattice_brain/portability/fsops.py +138 -0
  69. package/lattice_brain/portability/service.py +41 -0
  70. package/lattice_brain/portability/sharing.py +714 -0
  71. package/lattice_brain/runtime/__init__.py +1 -1
  72. package/lattice_brain/runtime/multi_agent.py +1 -1
  73. package/lattice_brain/sealed_box.py +244 -0
  74. package/lattice_brain/synthesis.py +24 -1
  75. package/latticeai/__init__.py +1 -1
  76. package/latticeai/api/brain_intelligence.py +4 -0
  77. package/latticeai/api/chat.py +11 -0
  78. package/latticeai/api/chat_helpers.py +16 -3
  79. package/latticeai/api/chat_hybrid.py +32 -1
  80. package/latticeai/api/chronicle.py +63 -0
  81. package/latticeai/api/features.py +70 -0
  82. package/latticeai/api/local_files.py +102 -0
  83. package/latticeai/api/portability.py +39 -4
  84. package/latticeai/api/review_queue.py +126 -0
  85. package/latticeai/api/search.py +16 -2
  86. package/latticeai/core/agent/__init__.py +93 -0
  87. package/latticeai/core/agent/_contract.py +79 -0
  88. package/latticeai/core/agent/context.py +57 -0
  89. package/latticeai/core/agent/deps.py +125 -0
  90. package/latticeai/core/agent/execution.py +622 -0
  91. package/latticeai/core/agent/planning.py +145 -0
  92. package/latticeai/core/agent/recovery.py +157 -0
  93. package/latticeai/core/agent/runtime.py +210 -0
  94. package/latticeai/core/agent/verification.py +231 -0
  95. package/latticeai/core/config.py +4 -1
  96. package/latticeai/core/context_builder.py +6 -3
  97. package/latticeai/core/embedding_providers/__init__.py +151 -0
  98. package/latticeai/core/embedding_providers/base.py +199 -0
  99. package/latticeai/core/embedding_providers/captions.py +162 -0
  100. package/latticeai/core/embedding_providers/profiles.py +126 -0
  101. package/latticeai/core/embedding_providers/text.py +350 -0
  102. package/latticeai/core/embedding_providers/vision.py +352 -0
  103. package/latticeai/core/file_generation/__init__.py +115 -0
  104. package/latticeai/core/file_generation/bundles.py +76 -0
  105. package/latticeai/core/file_generation/extraction.py +154 -0
  106. package/latticeai/core/file_generation/inference.py +235 -0
  107. package/latticeai/core/file_generation/orchestration.py +152 -0
  108. package/latticeai/core/file_generation/prompting.py +117 -0
  109. package/latticeai/core/file_generation/repair.py +114 -0
  110. package/latticeai/core/file_generation/sanitize.py +61 -0
  111. package/latticeai/core/file_generation/validation.py +201 -0
  112. package/latticeai/core/legacy_compatibility.py +1 -1
  113. package/latticeai/core/marketplace.py +1 -1
  114. package/latticeai/core/messages.py +152 -0
  115. package/latticeai/core/model_compat.py +73 -2
  116. package/latticeai/core/workspace_os_constants.py +1 -1
  117. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  118. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  119. package/latticeai/integrations/telegram_bot/config.py +86 -0
  120. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  121. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  122. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  123. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  124. package/latticeai/models/model_providers.py +12 -4
  125. package/latticeai/models/router/__init__.py +88 -0
  126. package/latticeai/models/router/_contract.py +66 -0
  127. package/latticeai/models/router/branding.py +56 -0
  128. package/latticeai/models/router/catalog.py +69 -0
  129. package/latticeai/models/router/documents.py +199 -0
  130. package/latticeai/models/router/errors.py +37 -0
  131. package/latticeai/models/router/generation.py +258 -0
  132. package/latticeai/models/router/loading.py +291 -0
  133. package/latticeai/models/router/local_models.py +85 -0
  134. package/latticeai/models/router/registry.py +147 -0
  135. package/latticeai/runtime/build_phases/__init__.py +82 -0
  136. package/latticeai/runtime/build_phases/features.py +407 -0
  137. package/latticeai/runtime/build_phases/foundation.py +555 -0
  138. package/latticeai/runtime/build_phases/web.py +492 -0
  139. package/latticeai/runtime/chat_wiring.py +4 -0
  140. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  141. package/latticeai/runtime/router_registration.py +11 -0
  142. package/latticeai/runtime/runtime_context.py +1 -0
  143. package/latticeai/services/app_context.py +8 -0
  144. package/latticeai/services/architecture_readiness.py +48 -19
  145. package/latticeai/services/automation_intelligence.py +22 -2
  146. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  147. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  148. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  149. package/latticeai/services/brain_intelligence/constants.py +47 -0
  150. package/latticeai/services/brain_intelligence/digest.py +258 -0
  151. package/latticeai/services/brain_intelligence/health.py +331 -0
  152. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  153. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  154. package/latticeai/services/brain_intelligence/service.py +48 -0
  155. package/latticeai/services/chronicle.py +557 -0
  156. package/latticeai/services/command_center.py +10 -4
  157. package/latticeai/services/feature_toggles.py +502 -0
  158. package/latticeai/services/folder_watch.py +122 -1
  159. package/latticeai/services/hybrid_chat.py +56 -5
  160. package/latticeai/services/interop_bridges.py +978 -0
  161. package/latticeai/services/memory_service/__init__.py +52 -0
  162. package/latticeai/services/memory_service/_contract.py +100 -0
  163. package/latticeai/services/memory_service/brief.py +431 -0
  164. package/latticeai/services/memory_service/constants.py +57 -0
  165. package/latticeai/services/memory_service/maintenance.py +138 -0
  166. package/latticeai/services/memory_service/manager.py +186 -0
  167. package/latticeai/services/memory_service/proof.py +136 -0
  168. package/latticeai/services/memory_service/recall.py +225 -0
  169. package/latticeai/services/memory_service/service.py +48 -0
  170. package/latticeai/services/memory_service/stores.py +110 -0
  171. package/latticeai/services/model_capability_registry.py +434 -261
  172. package/latticeai/services/model_catalog.py +95 -61
  173. package/latticeai/services/model_recommendation.py +18 -11
  174. package/latticeai/services/model_runtime/__init__.py +322 -0
  175. package/latticeai/services/model_runtime/cloud.py +87 -0
  176. package/latticeai/services/model_runtime/download.py +282 -0
  177. package/latticeai/services/model_runtime/engines.py +341 -0
  178. package/latticeai/services/model_runtime/loading.py +178 -0
  179. package/latticeai/services/model_runtime/service.py +129 -0
  180. package/latticeai/services/model_runtime/state.py +131 -0
  181. package/latticeai/services/model_runtime/status.py +255 -0
  182. package/latticeai/services/multimodal_ports.py +26 -1
  183. package/latticeai/services/obsidian_bridge.py +16 -25
  184. package/latticeai/services/product_readiness.py +15 -7
  185. package/latticeai/services/search_service.py +149 -2
  186. package/latticeai/services/tool_dispatch.py +4 -0
  187. package/latticeai/setup/auto_setup.py +27 -30
  188. package/latticeai/setup/wizard/__init__.py +126 -0
  189. package/latticeai/setup/wizard/catalog.py +172 -0
  190. package/latticeai/setup/wizard/detect.py +323 -0
  191. package/latticeai/setup/wizard/install.py +348 -0
  192. package/latticeai/setup/wizard/paths.py +168 -0
  193. package/latticeai/setup/wizard/plans.py +74 -0
  194. package/latticeai/setup/wizard/recommend.py +320 -0
  195. package/package.json +6 -2
  196. package/scripts/bump_version.py +14 -0
  197. package/scripts/capture_release_evidence.mjs +33 -21
  198. package/scripts/check_current_release_docs.mjs +1 -1
  199. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  200. package/scripts/check_max_file_lines.mjs +102 -0
  201. package/scripts/check_release_evidence_bound.mjs +30 -15
  202. package/scripts/check_screenshot_pixel_delta.py +34 -4
  203. package/scripts/check_server_i18n.mjs +2 -0
  204. package/scripts/generate_rust_parity_fixtures.py +562 -0
  205. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  206. package/scripts/release_screen_claims.json +44 -2
  207. package/scripts/verify_hf_model_registry.py +253 -218
  208. package/src-tauri/Cargo.lock +361 -3
  209. package/src-tauri/Cargo.toml +6 -1
  210. package/src-tauri/src/backend.rs +349 -0
  211. package/src-tauri/src/folder.rs +33 -0
  212. package/src-tauri/src/main.rs +97 -399
  213. package/src-tauri/tauri.conf.json +1 -1
  214. package/static/app/asset-manifest.json +41 -37
  215. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  216. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  217. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-C1HBN0Wf.js} +2 -2
  218. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  219. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  220. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  221. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  222. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  223. package/static/app/assets/Library-DwO3yZST.js +1 -0
  224. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-Jn1GK0-S.js} +1 -1
  225. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  226. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  227. package/static/app/assets/System-DW8F-2xL.js +1 -0
  228. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  229. package/static/app/assets/{bot-4BvN07ux.js → bot-IM_E_Y12.js} +1 -1
  230. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  231. package/static/app/assets/{button-CDjtnAoU.js → button-COwyqfHM.js} +1 -1
  232. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  233. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-DEM4A1Y5.js} +1 -1
  234. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-C9djDuLd.js} +1 -1
  235. package/static/app/assets/{cpu-BIlWInHf.js → cpu-DFdo1gw-.js} +1 -1
  236. package/static/app/assets/{download-BtjXfL3z.js → download-SnJL6oqk.js} +1 -1
  237. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-CqZeDkjE.js} +1 -1
  238. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-j1jJXYYf.js} +1 -1
  239. package/static/app/assets/{index-vtEfYvQY.css → index-BLPb5lmE.css} +1 -1
  240. package/static/app/assets/index-_u5iUHDr.js +10 -0
  241. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  242. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  243. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-BsyLxtao.js} +1 -1
  244. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  245. package/static/app/assets/search-BybIWPNd.js +1 -0
  246. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-CVtZ_ewX.js} +1 -1
  247. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-CBi2GNWM.js} +1 -1
  248. package/static/app/assets/{textarea-nEVIweKY.js → textarea-DNMpB5ih.js} +1 -1
  249. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-C83t3GXF.js} +1 -1
  250. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  251. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-Dcp1OChy.js} +1 -1
  252. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  253. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  254. package/static/app/index.html +4 -4
  255. package/static/sw.js +1 -1
  256. package/lattice_brain/graph/_kg_common.py +0 -1331
  257. package/lattice_brain/graph/discovery_index.py +0 -1141
  258. package/lattice_brain/graph/retrieval.py +0 -1120
  259. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  260. package/lattice_brain/ingestion.py +0 -1377
  261. package/lattice_brain/portability.py +0 -1210
  262. package/latticeai/core/agent.py +0 -1412
  263. package/latticeai/core/embedding_providers.py +0 -1196
  264. package/latticeai/core/file_generation.py +0 -1047
  265. package/latticeai/integrations/telegram_bot.py +0 -1390
  266. package/latticeai/models/router.py +0 -1007
  267. package/latticeai/runtime/build_phases.py +0 -1422
  268. package/latticeai/services/brain_intelligence.py +0 -967
  269. package/latticeai/services/memory_service.py +0 -1177
  270. package/latticeai/services/model_runtime.py +0 -1281
  271. package/latticeai/setup/wizard.py +0 -1277
  272. package/static/app/assets/Act-D0HWqtn0.js +0 -1
  273. package/static/app/assets/AdminConsole-D-QDW-A4.js +0 -1
  274. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  275. package/static/app/assets/BrainSignals-2dHQNkns.js +0 -1
  276. package/static/app/assets/Capture-CT8v1StE.js +0 -1
  277. package/static/app/assets/CommandPalette-DoLXC2KH.js +0 -1
  278. package/static/app/assets/Library-DDoxFE5c.js +0 -1
  279. package/static/app/assets/ProductFlow-DOYf7JIs.js +0 -1
  280. package/static/app/assets/ReviewCard-COQsqidK.js +0 -3
  281. package/static/app/assets/System-BRllvYXd.js +0 -1
  282. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  283. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  284. package/static/app/assets/index-0AvoEBzJ.js +0 -10
  285. package/static/app/assets/input-B_5ZJ9oy.js +0 -1
  286. package/static/app/assets/primitives-CVwew78r.js +0 -1
  287. package/static/app/assets/search-DkhnOKZt.js +0 -1
  288. package/static/app/assets/utils-DcDMoZIe.js +0 -4
  289. package/static/app/assets/workspace-LtRRSKTf.js +0 -1
@@ -0,0 +1,495 @@
1
+ """One local file → its rows and its graph nodes.
2
+
3
+ The computer/drive/folder hierarchy, the ``local_file_index`` row, and the
4
+ file node with its concepts and edges. ``_local_scoped_slug`` lives here
5
+ because both hierarchy and node upsert call it. Moved verbatim out of
6
+ ``discovery_index.py`` (v11.3.0 decomposition).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ # ruff: noqa: F403,F405
14
+ from .._kg_common import * # noqa: F403,F401
15
+
16
+ # The cross-mixin surface (`_connect`, `_upsert_node`, …) is declared in
17
+ # `_kg_contract.KnowledgeGraphCore`. It is a typing-only base: at runtime this
18
+ # is `object`, so the MRO of `KnowledgeGraphStore` is unchanged.
19
+ if TYPE_CHECKING:
20
+ from .._kg_contract import KnowledgeGraphCore as _Core
21
+ else:
22
+ _Core = object
23
+
24
+
25
+ def _local_scoped_slug(prefix: str, value: str, workspace_id: Optional[str]) -> str:
26
+ """Preserve legacy IDs while isolating newly workspace-scoped nodes."""
27
+ slug = _slug(value)
28
+ if not workspace_id:
29
+ return f"{prefix}:{slug}"
30
+ scope = _sha256_text(str(workspace_id))[:12]
31
+ return f"{prefix}:{scope}:{slug}"
32
+
33
+
34
+ class _LocalUpsertMixin(_Core):
35
+ """Hierarchy + file node/index upserts. Composed into the public mixin."""
36
+
37
+ def _ensure_local_hierarchy(
38
+ self,
39
+ conn: sqlite3.Connection,
40
+ *,
41
+ source_id: str,
42
+ root: Path,
43
+ file_path: Path,
44
+ os_type: str,
45
+ drive_id: str,
46
+ user_email: Optional[str] = None,
47
+ workspace_id: Optional[str] = None,
48
+ ) -> str:
49
+ computer_label = platform.node() or "내 컴퓨터"
50
+ computer_id = _local_scoped_slug("computer", computer_label, workspace_id)
51
+ drive_identity = f"{workspace_id}|{os_type}:{drive_id}" if workspace_id else f"{os_type}:{drive_id}"
52
+ drive_node_id = f"drive:{_sha256_text(drive_identity)[:24]}"
53
+ root_folder_id = f"folder:{_sha256_text(f'{source_id}:root')[:24]}"
54
+ self._upsert_node(
55
+ conn,
56
+ computer_id,
57
+ "Computer",
58
+ computer_label,
59
+ metadata={"os_type": os_type, "workspace_id": workspace_id},
60
+ owner=user_email,
61
+ workspace_id=workspace_id,
62
+ )
63
+ self._upsert_node(
64
+ conn,
65
+ drive_node_id,
66
+ "Drive",
67
+ drive_id,
68
+ metadata={"os_type": os_type, "drive_id": drive_id, "workspace_id": workspace_id},
69
+ owner=user_email,
70
+ workspace_id=workspace_id,
71
+ )
72
+ stale_parents = conn.execute(
73
+ """
74
+ SELECT e.from_node
75
+ FROM edges e
76
+ JOIN nodes n ON n.id=e.from_node
77
+ WHERE e.to_node=? AND n.type='Drive' AND e.from_node<>?
78
+ """,
79
+ (root_folder_id, drive_node_id),
80
+ ).fetchall()
81
+ for row in stale_parents:
82
+ conn.execute(
83
+ "DELETE FROM edges WHERE from_node=? AND to_node=?",
84
+ (row["from_node"], root_folder_id),
85
+ )
86
+ conn.execute(
87
+ "DELETE FROM edges_v2 WHERE source=? AND target=?",
88
+ (row["from_node"], root_folder_id),
89
+ )
90
+ self._upsert_edge(
91
+ conn,
92
+ computer_id,
93
+ drive_node_id,
94
+ "포함함",
95
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
96
+ )
97
+ self._upsert_node(
98
+ conn,
99
+ root_folder_id,
100
+ "Folder",
101
+ root.name or str(root),
102
+ summary=str(root),
103
+ metadata={"source_id": source_id, "path": str(root), "root": True, "workspace_id": workspace_id},
104
+ owner=user_email,
105
+ workspace_id=workspace_id,
106
+ )
107
+ self._upsert_edge(
108
+ conn,
109
+ drive_node_id,
110
+ root_folder_id,
111
+ "포함함",
112
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
113
+ )
114
+
115
+ try:
116
+ relative_parent = file_path.parent.relative_to(root)
117
+ except ValueError:
118
+ relative_parent = Path()
119
+ parent_id = root_folder_id
120
+ current_path = root
121
+ for part in relative_parent.parts:
122
+ current_path = current_path / part
123
+ folder_id = (
124
+ f"folder:{_sha256_text(f'{source_id}:{current_path.as_posix()}')[:24]}"
125
+ )
126
+ self._upsert_node(
127
+ conn,
128
+ folder_id,
129
+ "Folder",
130
+ part,
131
+ summary=str(current_path),
132
+ metadata={
133
+ "source_id": source_id,
134
+ "path": str(current_path),
135
+ "root": False,
136
+ "workspace_id": workspace_id,
137
+ },
138
+ owner=user_email,
139
+ workspace_id=workspace_id,
140
+ )
141
+ self._upsert_edge(
142
+ conn,
143
+ parent_id,
144
+ folder_id,
145
+ "포함함",
146
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
147
+ )
148
+ parent_id = folder_id
149
+ return parent_id
150
+
151
+ def _upsert_local_file_index(
152
+ self,
153
+ conn: sqlite3.Connection,
154
+ *,
155
+ source_id: str,
156
+ root: Path,
157
+ file_path: Path,
158
+ stat: Optional[os.stat_result],
159
+ os_type: str,
160
+ drive_id: str,
161
+ status: str,
162
+ parser_type: str,
163
+ sha256: Optional[str] = None,
164
+ graph_node_id: Optional[str] = None,
165
+ error_message: Optional[str] = None,
166
+ metadata: Optional[Dict[str, Any]] = None,
167
+ ) -> str:
168
+ try:
169
+ relative_path = file_path.relative_to(root).as_posix()
170
+ except ValueError:
171
+ relative_path = file_path.name
172
+ index_id = f"local-index:{_sha256_text(f'{source_id}:{relative_path}')[:24]}"
173
+ now = _now()
174
+ size = stat.st_size if stat else None
175
+ modified_at = _safe_iso_from_stat_mtime(stat.st_mtime) if stat else ""
176
+ conn.execute(
177
+ """
178
+ INSERT INTO local_file_index(
179
+ id, source_id, os_type, drive_id, root_path, file_path, relative_path,
180
+ file_name, extension, size_bytes, modified_at, sha256, last_scanned_at,
181
+ last_indexed_at, parser_type, status, error_message, graph_node_id,
182
+ deleted, metadata_json
183
+ )
184
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
185
+ ON CONFLICT(source_id, relative_path) DO UPDATE SET
186
+ os_type=excluded.os_type,
187
+ drive_id=excluded.drive_id,
188
+ root_path=excluded.root_path,
189
+ file_path=excluded.file_path,
190
+ file_name=excluded.file_name,
191
+ extension=excluded.extension,
192
+ size_bytes=excluded.size_bytes,
193
+ modified_at=excluded.modified_at,
194
+ sha256=excluded.sha256,
195
+ last_scanned_at=excluded.last_scanned_at,
196
+ last_indexed_at=excluded.last_indexed_at,
197
+ parser_type=excluded.parser_type,
198
+ status=excluded.status,
199
+ error_message=excluded.error_message,
200
+ graph_node_id=excluded.graph_node_id,
201
+ deleted=excluded.deleted,
202
+ metadata_json=excluded.metadata_json
203
+ """,
204
+ (
205
+ index_id,
206
+ source_id,
207
+ os_type,
208
+ drive_id,
209
+ str(root),
210
+ str(file_path),
211
+ relative_path,
212
+ file_path.name,
213
+ file_path.suffix.lower(),
214
+ size,
215
+ modified_at,
216
+ sha256,
217
+ now,
218
+ now if status == "indexed" else None,
219
+ parser_type,
220
+ status,
221
+ error_message,
222
+ graph_node_id,
223
+ 0 if status != "deleted" else 1,
224
+ _json(metadata),
225
+ ),
226
+ )
227
+ return index_id
228
+
229
+ def _upsert_local_file_node(
230
+ self,
231
+ conn: sqlite3.Connection,
232
+ *,
233
+ source_id: str,
234
+ root: Path,
235
+ file_path: Path,
236
+ stat: os.stat_result,
237
+ os_type: str,
238
+ drive_id: str,
239
+ sha256: str,
240
+ category: str,
241
+ parser_type: str,
242
+ text: str,
243
+ parser_meta: Dict[str, Any],
244
+ user_email: Optional[str] = None,
245
+ workspace_id: Optional[str] = None,
246
+ ) -> str:
247
+ text = _clean_text(text)
248
+ if not text:
249
+ raise ValueError("텍스트 추출 결과가 비어 있습니다.")
250
+ try:
251
+ relative_path = file_path.relative_to(root).as_posix()
252
+ except ValueError:
253
+ relative_path = file_path.name
254
+ file_node_id = f"local-file:{_sha256_text(f'{source_id}:{relative_path}')[:24]}"
255
+ parent_folder_id = self._ensure_local_hierarchy(
256
+ conn,
257
+ source_id=source_id,
258
+ root=root,
259
+ file_path=file_path,
260
+ os_type=os_type,
261
+ drive_id=drive_id,
262
+ user_email=user_email,
263
+ workspace_id=workspace_id,
264
+ )
265
+ linked_rows = conn.execute(
266
+ """
267
+ SELECT e.to_node AS id, n.type, n.metadata_json
268
+ FROM edges e
269
+ JOIN nodes n ON n.id=e.to_node
270
+ WHERE e.from_node=?
271
+ """,
272
+ (file_node_id,),
273
+ ).fetchall()
274
+ child_ids = []
275
+ auto_candidate_ids = set()
276
+ for row in linked_rows:
277
+ linked_metadata = _safe_loads(row["metadata_json"])
278
+ if row["type"] in {"Chunk", "ImageText", "Section"} or linked_metadata.get("source_node") == file_node_id:
279
+ child_ids.append(row["id"])
280
+ elif linked_metadata.get("auto_extracted") and linked_metadata.get("source") == "local_folder":
281
+ auto_candidate_ids.add(row["id"])
282
+ conn.execute("DELETE FROM chunks WHERE source_node=?", (file_node_id,))
283
+ if child_ids:
284
+ placeholders = ",".join("?" * len(child_ids))
285
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", child_ids)
286
+ self._v2_delete_nodes(conn, child_ids)
287
+ conn.execute("DELETE FROM edges WHERE from_node=?", (file_node_id,))
288
+ self._v2_delete_edges_from(conn, file_node_id)
289
+ removable_auto_ids = set()
290
+ for node_id in auto_candidate_ids:
291
+ remaining_edges = conn.execute(
292
+ "SELECT from_node, to_node FROM edges WHERE from_node=? OR to_node=?",
293
+ (node_id, node_id),
294
+ ).fetchall()
295
+ if all(
296
+ row["from_node"] in auto_candidate_ids
297
+ and row["to_node"] in auto_candidate_ids
298
+ for row in remaining_edges
299
+ ):
300
+ removable_auto_ids.add(node_id)
301
+ if removable_auto_ids:
302
+ placeholders = ",".join("?" * len(removable_auto_ids))
303
+ params = list(removable_auto_ids)
304
+ conn.execute(
305
+ f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})",
306
+ params * 2,
307
+ )
308
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", params)
309
+ self._v2_delete_nodes(conn, params)
310
+
311
+ metadata = {
312
+ "source": "local_folder",
313
+ "source_id": source_id,
314
+ "root_path": str(root),
315
+ "file_path": str(file_path),
316
+ "relative_path": relative_path,
317
+ "filename": file_path.name,
318
+ "ext": file_path.suffix.lower(),
319
+ "category": category,
320
+ "parser_type": parser_type,
321
+ "bytes": stat.st_size,
322
+ "modified_at": _safe_iso_from_stat_mtime(stat.st_mtime),
323
+ "sha256": sha256,
324
+ "parser": parser_meta,
325
+ "workspace_id": workspace_id,
326
+ }
327
+ self._upsert_node(
328
+ conn,
329
+ file_node_id,
330
+ _node_type_for_category(category),
331
+ file_path.name,
332
+ summary=text[:700],
333
+ metadata=metadata,
334
+ raw=metadata,
335
+ owner=user_email,
336
+ workspace_id=workspace_id,
337
+ )
338
+ self._upsert_edge(
339
+ conn,
340
+ parent_folder_id,
341
+ file_node_id,
342
+ "포함함",
343
+ weight=1.0,
344
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
345
+ )
346
+ self._cleanup_local_graph_orphans(conn, source_id)
347
+
348
+ target_for_concepts = text
349
+ if category == "image" and text:
350
+ image_text_id = f"imagetext:{_sha256_text(f'{file_node_id}:ocr')[:24]}"
351
+ self._upsert_node(
352
+ conn,
353
+ image_text_id,
354
+ "ImageText",
355
+ f"{file_path.name} OCR",
356
+ summary=_clean_text(text)[:700],
357
+ metadata={
358
+ "source_node": file_node_id,
359
+ "source_id": source_id,
360
+ "chars": len(text),
361
+ "workspace_id": workspace_id,
362
+ },
363
+ owner=user_email,
364
+ workspace_id=workspace_id,
365
+ )
366
+ self._upsert_edge(
367
+ conn,
368
+ file_node_id,
369
+ image_text_id,
370
+ "포함함",
371
+ weight=0.8,
372
+ metadata={"source": "ocr", "workspace_id": workspace_id},
373
+ )
374
+
375
+ # Typed chunking by file extension (markdown/code/plain); plain files
376
+ # keep legacy _chunks boundaries and therefore identical chunk ids.
377
+ chunk_strategy = chunk_strategy_for(file_path.name)
378
+ for index, piece in enumerate(typed_chunks(text, strategy=chunk_strategy)):
379
+ chunk = piece["text"]
380
+ chunk_fields = typed_chunk_meta_fields(piece)
381
+ chunk_id = f"chunk:{_sha256_text(f'{file_node_id}:{index}:{chunk}')[:24]}"
382
+ self._upsert_node(
383
+ conn,
384
+ chunk_id,
385
+ "Chunk",
386
+ f"{file_path.name} chunk {index + 1}",
387
+ summary=chunk[:500],
388
+ metadata={
389
+ "index": index,
390
+ "source_node": file_node_id,
391
+ "source_id": source_id,
392
+ "workspace_id": workspace_id,
393
+ **chunk_fields,
394
+ },
395
+ owner=user_email,
396
+ workspace_id=workspace_id,
397
+ )
398
+ self._upsert_chunk(
399
+ conn,
400
+ chunk_id=chunk_id,
401
+ source_node=file_node_id,
402
+ text=chunk,
403
+ metadata={
404
+ "index": index,
405
+ "source_node": file_node_id,
406
+ "source_id": source_id,
407
+ "workspace_id": workspace_id,
408
+ **chunk_fields,
409
+ },
410
+ )
411
+ self._upsert_edge(
412
+ conn,
413
+ file_node_id,
414
+ chunk_id,
415
+ "포함함",
416
+ weight=0.7,
417
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
418
+ )
419
+
420
+ concepts = _extract_concepts(target_for_concepts, limit=18)
421
+ concept_ids: Dict[str, str] = {}
422
+ for concept in concepts:
423
+ node_t = _classify_node_type(concept, target_for_concepts)
424
+ concept_id = _local_scoped_slug(node_t.lower(), concept, workspace_id)
425
+ concept_ids[concept.lower()] = concept_id
426
+ self._upsert_node(
427
+ conn,
428
+ concept_id,
429
+ node_t,
430
+ concept,
431
+ metadata={
432
+ "auto_extracted": True,
433
+ "source": "local_folder",
434
+ "source_id": source_id,
435
+ "workspace_id": workspace_id,
436
+ },
437
+ owner=user_email,
438
+ workspace_id=workspace_id,
439
+ )
440
+ self._upsert_edge(
441
+ conn,
442
+ file_node_id,
443
+ concept_id,
444
+ "언급함",
445
+ weight=0.75,
446
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
447
+ )
448
+
449
+ for triple in _extract_triples(target_for_concepts, concepts, limit=20):
450
+ subj_id = concept_ids.get(triple["subject"].lower())
451
+ obj_id = concept_ids.get(triple["object"].lower())
452
+ if subj_id and obj_id and subj_id != obj_id:
453
+ self._upsert_edge(
454
+ conn,
455
+ subj_id,
456
+ obj_id,
457
+ triple["relation"],
458
+ weight=0.9,
459
+ metadata={
460
+ "context": triple.get("context", "")[:240],
461
+ "source_id": source_id,
462
+ "workspace_id": workspace_id,
463
+ },
464
+ )
465
+
466
+ for item in _semantic_items(target_for_concepts):
467
+ sem_type = item["type"]
468
+ sem_title = item["title"]
469
+ sem_id = f"{sem_type.lower()}:{_sha256_text(f'{file_node_id}:{sem_type}:{sem_title}')[:24]}"
470
+ self._upsert_node(
471
+ conn,
472
+ sem_id,
473
+ sem_type,
474
+ sem_title,
475
+ summary=item["summary"],
476
+ metadata={
477
+ "auto_extracted": True,
478
+ "source_node": file_node_id,
479
+ "filename": file_path.name,
480
+ "workspace_id": workspace_id,
481
+ },
482
+ raw=item,
483
+ owner=user_email,
484
+ workspace_id=workspace_id,
485
+ )
486
+ self._upsert_edge(
487
+ conn,
488
+ file_node_id,
489
+ sem_id,
490
+ "포함함",
491
+ weight=0.9,
492
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
493
+ )
494
+
495
+ return file_node_id
@@ -30,6 +30,7 @@ import os
30
30
  import re
31
31
  from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
32
32
 
33
+ from ..gates import FeatureGate
33
34
  from ..quiet import quiet
34
35
 
35
36
  QUERY_CLASSES = ("fact", "code", "person", "recency")
@@ -50,8 +51,20 @@ FUSION_WEIGHTS_ENV = "LATTICEAI_FUSION_WEIGHTS"
50
51
  # get shipped. Turn it on with LATTICEAI_FUSION_STRATEGY=rrf (all classes) or
51
52
  # a JSON object like {"code": "rrf"} (per class).
52
53
  FUSION_STRATEGY_ENV = "LATTICEAI_FUSION_STRATEGY"
54
+ #: The one-switch form of the per-class table above, for the settings panel:
55
+ #: "combine by rank instead of score", everywhere. It is applied as the *base*
56
+ #: of the table, so the per-class ``LATTICEAI_FUSION_STRATEGY`` config — the
57
+ #: more specific statement — still wins over it, and an install that touched
58
+ #: neither is byte-identical to what it was.
59
+ FUSION_RRF_ENV = "LATTICEAI_FUSION_RRF"
53
60
  FUSION_STRATEGIES = ("alpha", "rrf")
54
61
  DEFAULT_FUSION_STRATEGY: Dict[str, str] = dict.fromkeys(QUERY_CLASSES, "alpha")
62
+ FUSION_RRF_GATE = FeatureGate(
63
+ FUSION_RRF_ENV,
64
+ default=False,
65
+ name="fusion_rrf",
66
+ detail="Search channels are combined by rank rather than by score.",
67
+ )
55
68
  #: The smoothing constant from the original RRF paper (Cormack et al., 2009).
56
69
  #: Larger k flattens the curve, so rank 1 wins by less.
57
70
  DEFAULT_RRF_K = 60
@@ -195,8 +208,16 @@ def _env_strategy_overrides() -> Dict[str, str]:
195
208
  def fusion_strategy_table(
196
209
  overrides: Optional[Mapping[str, str]] = None,
197
210
  ) -> Dict[str, str]:
198
- """Full per-class strategy table: defaults ← env override ← caller."""
211
+ """Full per-class table: defaults ← simple switch ← env override ← caller.
212
+
213
+ The simple switch (:data:`FUSION_RRF_GATE`, the settings panel's "combine by
214
+ rank") sits *under* the per-class env config on purpose: a person who wrote
215
+ ``{"code": "alpha"}`` said something specific, and a single global flag must
216
+ not overrule it.
217
+ """
199
218
  table = dict(DEFAULT_FUSION_STRATEGY)
219
+ if FUSION_RRF_GATE.enabled():
220
+ table = dict.fromkeys(QUERY_CLASSES, "rrf")
200
221
  for source in (_env_strategy_overrides(), overrides or {}):
201
222
  for cls, value in source.items():
202
223
  if cls in table and str(value).lower() in FUSION_STRATEGIES:
@@ -243,6 +264,14 @@ def rrf_fuse(
243
264
  # failure mode is dilution: an unbounded expansion turns a precise answer into
244
265
  # a tour of the graph.
245
266
  GRAPH_EXPANSION_ENV = "LATTICEAI_GRAPH_EXPANSION"
267
+ #: Resolved when asked so the settings panel can move it without a restart; the
268
+ #: env parsing is the same set of words the hand-written check used.
269
+ GRAPH_EXPANSION_GATE = FeatureGate(
270
+ GRAPH_EXPANSION_ENV,
271
+ default=False,
272
+ name="graph_expansion",
273
+ detail="Memories one link away from a hit are offered as extra candidates.",
274
+ )
246
275
  DEFAULT_EXPANSION_SEEDS = 3
247
276
  DEFAULT_EXPANSION_CAP = 5
248
277
  #: Expanded candidates inherit a damped share of their seed's score: they are
@@ -251,9 +280,8 @@ EXPANSION_DECAY = 0.5
251
280
 
252
281
 
253
282
  def graph_expansion_enabled() -> bool:
254
- """True when ``LATTICEAI_GRAPH_EXPANSION`` opts in (default: off)."""
255
- raw = os.getenv(GRAPH_EXPANSION_ENV, "").strip().lower()
256
- return raw in {"1", "true", "yes", "on"}
283
+ """True when the expansion gate opts in (default: off)."""
284
+ return GRAPH_EXPANSION_GATE.enabled()
257
285
 
258
286
 
259
287
  def expand_with_neighbors(
@@ -348,10 +376,13 @@ __all__ = [
348
376
  "DEFAULT_FUSION_WEIGHTS",
349
377
  "DEFAULT_RRF_K",
350
378
  "EXPANSION_DECAY",
379
+ "FUSION_RRF_ENV",
380
+ "FUSION_RRF_GATE",
351
381
  "FUSION_STRATEGIES",
352
382
  "FUSION_STRATEGY_ENV",
353
383
  "FUSION_WEIGHTS_ENV",
354
384
  "GRAPH_EXPANSION_ENV",
385
+ "GRAPH_EXPANSION_GATE",
355
386
  "QUERY_CLASSES",
356
387
  "classify_query",
357
388
  "expand_with_neighbors",
@@ -0,0 +1,42 @@
1
+ """Legacy ↔ v2 projection, the keyword index, and curation over the result.
2
+
3
+ Split into two cohesive submodules in v11.3.0 (no behaviour change):
4
+ ``v2_schema`` (the derived projection, its migration, and the FTS index) and
5
+ ``curation`` (topic promotion, the review queue, noise reduction).
6
+ :class:`KnowledgeGraphProjectionMixin` composes both under its original name, so
7
+ ``KnowledgeGraphStore``'s MRO and every method resolution are what they were,
8
+ and every name the single module exposed is re-exported here.
9
+ """
10
+
11
+ # The single module had no ``__all__``: its surface was "every module global",
12
+ # star-imported ``_kg_common`` vocabulary included. That is reproduced verbatim
13
+ # below, so the waiver the star import needs applies to this file too.
14
+ # ruff: noqa: F403,F405
15
+ from __future__ import annotations
16
+
17
+ from .._kg_common import * # noqa: F401
18
+
19
+ # Module-level names of the single file, re-exported with the redundant-alias
20
+ # form so each is marked deliberate rather than a leftover import.
21
+ #
22
+ # Stubbing note: rebinding one of these *here* changes only this module's name.
23
+ # ``curate`` reads ``_promotion_review_default`` as ``curation``'s own global,
24
+ # so a test standing in for it patches ``…graph.projection.curation``.
25
+ from .curation import _LAST_NOISE_CURATE_KEY as _LAST_NOISE_CURATE_KEY
26
+ from .curation import _PENDING_PROMOTIONS_CAP as _PENDING_PROMOTIONS_CAP
27
+ from .curation import _PENDING_PROMOTIONS_KEY as _PENDING_PROMOTIONS_KEY
28
+ from .curation import _PROMOTION_REVIEW_ENV as _PROMOTION_REVIEW_ENV
29
+ from .curation import KnowledgeGraphCurationMixin
30
+ from .curation import _promotion_review_default as _promotion_review_default
31
+ from .v2_schema import KnowledgeGraphV2SchemaMixin
32
+
33
+
34
+ class KnowledgeGraphProjectionMixin(
35
+ KnowledgeGraphV2SchemaMixin, KnowledgeGraphCurationMixin
36
+ ):
37
+ """Every projection and curation method, under the name the store mixes in.
38
+
39
+ Composition only: the two mixins own disjoint halves of what used to be one
40
+ class body, and this subclass adds nothing. ``KnowledgeGraphStore`` keeps
41
+ listing exactly this name among its bases.
42
+ """