ltcai 9.9.2 → 9.9.4

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 (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -92,6 +92,341 @@ def _chunks(text: str, size: int = 1200, overlap: int = 160) -> List[str]:
92
92
  return chunks
93
93
 
94
94
 
95
+ # ── Typed chunking (review 2026-07-25 §5.2 S2 — Wave 2.1 + 2.4) ──────────────
96
+ # ``_chunks`` above is a compatibility contract (chunk ids hash over the chunk
97
+ # text) and stays byte-for-byte untouched. ``typed_chunks`` layers strategy-
98
+ # aware boundaries plus per-chunk provenance (start_char / heading_path) on
99
+ # top; ``strategy="plain"`` reproduces the exact ``_chunks`` boundaries so
100
+ # unchanged plain content keeps identical chunk ids.
101
+
102
+ _MARKDOWN_CHUNK_EXTENSIONS = {".md", ".markdown"}
103
+ _CODE_CHUNK_EXTENSIONS = {
104
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java", ".rb",
105
+ ".c", ".h", ".cpp", ".css", ".sh", ".sql", ".vue", ".svelte",
106
+ ".json", ".yaml", ".yml", ".toml",
107
+ }
108
+ _CHUNK_STRATEGIES = {"plain", "markdown", "code"}
109
+ # Markdown sections smaller than this merge forward into the next section so
110
+ # heading-dense documents don't shatter into confetti chunks.
111
+ _MARKDOWN_MIN_SECTION_CHARS = 200
112
+ _MARKDOWN_HEADING_RE = re.compile(r"^(#{1,6}) (.*)$", re.MULTILINE)
113
+ _CODE_BOUNDARY_LINE_RE = re.compile(
114
+ r"^(?:def |class |function |export |const |public |private )", re.MULTILINE
115
+ )
116
+ _CODE_BLANK_RUN_RE = re.compile(r"\n\s*\n")
117
+
118
+
119
+ def chunk_strategy_for(filename: Any, *, content_type: str = "") -> str:
120
+ """Route a filename / path / URI (plus optional MIME hint) to a strategy.
121
+
122
+ Returns ``"markdown"`` for .md/.markdown, ``"code"`` for known source-code
123
+ extensions, ``"plain"`` otherwise. Case-insensitive, tolerant of URLs
124
+ (query/fragment stripped) and ``Path`` objects; never raises — any
125
+ malformed input falls back to ``"plain"``.
126
+ """
127
+ try:
128
+ name = str(filename or "").strip().lower()
129
+ for sep in ("?", "#"):
130
+ name = name.split(sep, 1)[0]
131
+ name = name.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
132
+ dot = name.rfind(".")
133
+ ext = name[dot:] if dot > 0 else ""
134
+ if ext in _MARKDOWN_CHUNK_EXTENSIONS:
135
+ return "markdown"
136
+ if ext in _CODE_CHUNK_EXTENSIONS:
137
+ return "code"
138
+ if "markdown" in str(content_type or "").strip().lower():
139
+ return "markdown"
140
+ except Exception:
141
+ pass
142
+ return "plain"
143
+
144
+
145
+ def _plain_windows(
146
+ cleaned: str,
147
+ size: int,
148
+ overlap: int,
149
+ *,
150
+ base_offset: int = 0,
151
+ strategy: str = "plain",
152
+ heading_path: Optional[str] = None,
153
+ ) -> List[Dict[str, Any]]:
154
+ """The exact ``_chunks`` walk with ``start_char`` tracked.
155
+
156
+ Boundaries and chunk texts are byte-identical to ``_chunks`` over the same
157
+ string — this is the plain-strategy compatibility guarantee.
158
+ """
159
+ out: List[Dict[str, Any]] = []
160
+ start = 0
161
+ total = len(cleaned)
162
+ while start < total:
163
+ end = min(total, start + size)
164
+ out.append(
165
+ {
166
+ "text": cleaned[start:end],
167
+ "meta": {
168
+ "strategy": strategy,
169
+ "start_char": base_offset + start,
170
+ "heading_path": heading_path,
171
+ },
172
+ }
173
+ )
174
+ if end >= total:
175
+ break
176
+ start = max(0, end - overlap)
177
+ return out
178
+
179
+
180
+ def _markdown_section_spans(cleaned: str) -> List[Tuple[int, int, Optional[str]]]:
181
+ """``(start, end, heading_path)`` spans split at ``^#{1,6} `` heading lines.
182
+
183
+ ``heading_path`` is the " > "-joined path of the enclosing headings
184
+ including the section's own heading (e.g. ``"Guide > Setup"``); the
185
+ preamble before the first heading carries ``None``. Spans are contiguous
186
+ raw slices of ``cleaned`` so every chunk text round-trips via start_char.
187
+ """
188
+ spans: List[Tuple[int, int, Optional[str]]] = []
189
+ stack: List[Tuple[int, str]] = []
190
+ prev_start = 0
191
+ prev_path: Optional[str] = None
192
+ for match in _MARKDOWN_HEADING_RE.finditer(cleaned):
193
+ offset = match.start()
194
+ if offset > prev_start:
195
+ spans.append((prev_start, offset, prev_path))
196
+ level = len(match.group(1))
197
+ while stack and stack[-1][0] >= level:
198
+ stack.pop()
199
+ stack.append((level, match.group(2).strip()))
200
+ prev_start = offset
201
+ prev_path = " > ".join(title for _, title in stack) or None
202
+ if len(cleaned) > prev_start:
203
+ spans.append((prev_start, len(cleaned), prev_path))
204
+ return spans
205
+
206
+
207
+ def _merge_small_sections(
208
+ spans: List[Tuple[int, int, Optional[str]]], min_chars: int
209
+ ) -> List[Tuple[int, int, Optional[str]]]:
210
+ """Merge sections under ``min_chars`` forward into the next section.
211
+
212
+ A merged section keeps the heading_path of its first constituent (the
213
+ path in effect at the chunk start). A trailing undersized section merges
214
+ backward into the previous emitted section when one exists.
215
+ """
216
+ merged: List[Tuple[int, int, Optional[str]]] = []
217
+ pending: Optional[Tuple[int, int, Optional[str]]] = None
218
+ for start, end, path in spans:
219
+ if pending is None:
220
+ pending = (start, end, path)
221
+ else:
222
+ pending = (pending[0], end, pending[2])
223
+ if pending[1] - pending[0] >= min_chars:
224
+ merged.append(pending)
225
+ pending = None
226
+ if pending is not None:
227
+ if merged and pending[1] - pending[0] < min_chars:
228
+ last = merged.pop()
229
+ merged.append((last[0], pending[1], last[2]))
230
+ else:
231
+ merged.append(pending)
232
+ return merged
233
+
234
+
235
+ def _markdown_chunks(cleaned: str, size: int, overlap: int) -> List[Dict[str, Any]]:
236
+ sections = _merge_small_sections(
237
+ _markdown_section_spans(cleaned), _MARKDOWN_MIN_SECTION_CHARS
238
+ )
239
+ out: List[Dict[str, Any]] = []
240
+ for start, end, path in sections:
241
+ body = cleaned[start:end]
242
+ if len(body) <= size:
243
+ out.append(
244
+ {
245
+ "text": body,
246
+ "meta": {
247
+ "strategy": "markdown",
248
+ "start_char": start,
249
+ "heading_path": path,
250
+ },
251
+ }
252
+ )
253
+ else:
254
+ out.extend(
255
+ _plain_windows(
256
+ body,
257
+ size,
258
+ overlap,
259
+ base_offset=start,
260
+ strategy="markdown",
261
+ heading_path=path,
262
+ )
263
+ )
264
+ return out
265
+
266
+
267
+ def _code_segment_spans(cleaned: str) -> List[Tuple[int, int]]:
268
+ """Contiguous top-level segments split at blank-line runs and decl lines."""
269
+ boundaries = {0, len(cleaned)}
270
+ for match in _CODE_BLANK_RUN_RE.finditer(cleaned):
271
+ boundaries.add(match.end())
272
+ for match in _CODE_BOUNDARY_LINE_RE.finditer(cleaned):
273
+ boundaries.add(match.start())
274
+ ordered = sorted(boundaries)
275
+ return [
276
+ (ordered[i], ordered[i + 1])
277
+ for i in range(len(ordered) - 1)
278
+ if ordered[i + 1] > ordered[i]
279
+ ]
280
+
281
+
282
+ def _code_chunks(cleaned: str, size: int, overlap: int) -> List[Dict[str, Any]]:
283
+ hard_limit = int(size * 1.5)
284
+ out: List[Dict[str, Any]] = []
285
+ pack: Optional[Tuple[int, int]] = None
286
+
287
+ def _emit(span: Tuple[int, int]) -> None:
288
+ out.append(
289
+ {
290
+ "text": cleaned[span[0] : span[1]],
291
+ "meta": {
292
+ "strategy": "code",
293
+ "start_char": span[0],
294
+ "heading_path": None,
295
+ },
296
+ }
297
+ )
298
+
299
+ for start, end in _code_segment_spans(cleaned):
300
+ if end - start > hard_limit:
301
+ # Monster segment: flush the pack, then window it like plain text.
302
+ if pack is not None:
303
+ _emit(pack)
304
+ pack = None
305
+ out.extend(
306
+ _plain_windows(
307
+ cleaned[start:end],
308
+ size,
309
+ overlap,
310
+ base_offset=start,
311
+ strategy="code",
312
+ )
313
+ )
314
+ continue
315
+ if pack is None:
316
+ pack = (start, end)
317
+ elif end - pack[0] <= size:
318
+ pack = (pack[0], end)
319
+ else:
320
+ _emit(pack)
321
+ pack = (start, end)
322
+ if pack is not None:
323
+ _emit(pack)
324
+ return out
325
+
326
+
327
+ def typed_chunks(
328
+ text: str,
329
+ *,
330
+ strategy: str = "plain",
331
+ size: int = 1200,
332
+ overlap: int = 160,
333
+ ) -> List[Dict[str, Any]]:
334
+ """Strategy-aware chunking with per-chunk provenance metadata.
335
+
336
+ Returns ``[{"text": str, "meta": {"strategy", "start_char", "heading_path"}}]``
337
+ where ``start_char`` is the offset in ``str(text or "").strip()`` (every
338
+ chunk text is an exact substring at that offset).
339
+
340
+ Contract: ``[c["text"] for c in typed_chunks(t)] == _chunks(t)`` for the
341
+ default plain strategy — unknown strategies also fall back to plain.
342
+ """
343
+ cleaned = str(text or "").strip()
344
+ if not cleaned:
345
+ return []
346
+ try:
347
+ size = max(1, int(size))
348
+ except Exception:
349
+ size = 1200
350
+ try:
351
+ overlap = min(max(0, int(overlap)), size - 1)
352
+ except Exception:
353
+ overlap = min(160, size - 1)
354
+ label = strategy if strategy in _CHUNK_STRATEGIES else "plain"
355
+ if label == "markdown":
356
+ return _markdown_chunks(cleaned, size, overlap)
357
+ if label == "code":
358
+ return _code_chunks(cleaned, size, overlap)
359
+ return _plain_windows(cleaned, size, overlap)
360
+
361
+
362
+ def typed_chunk_meta_fields(piece: Dict[str, Any]) -> Dict[str, Any]:
363
+ """Additive chunk-metadata fields for one ``typed_chunks`` piece.
364
+
365
+ Ingest call sites merge this into the existing ``{"index", "source_node"}``
366
+ chunk metadata; ``heading_path`` is only present when known — honest
367
+ absence over empty labels.
368
+ """
369
+ meta = piece.get("meta") or {}
370
+ fields: Dict[str, Any] = {
371
+ "strategy": str(meta.get("strategy") or "plain"),
372
+ "start_char": int(meta.get("start_char") or 0),
373
+ }
374
+ heading_path = meta.get("heading_path")
375
+ if heading_path:
376
+ fields["heading_path"] = str(heading_path)
377
+ return fields
378
+
379
+
380
+ def pdf_page_offsets(structure: Any) -> List[int]:
381
+ """Start offset of each PDF page in the "\\n\\n"-joined page text.
382
+
383
+ ``structure`` is the ``metadata["structure"]`` dict produced by
384
+ ``_pdf_structure`` (``pages`` = ``[{"chars": int, ...}, ...]``); pages were
385
+ joined with ``"\\n\\n"`` (see ``read_document``), so page k starts at
386
+ ``sum(chars[j] + 2 for j < k)``. Empty or malformed input returns ``[]``.
387
+ """
388
+ if not isinstance(structure, dict):
389
+ return []
390
+ pages = structure.get("pages")
391
+ if not isinstance(pages, list) or not pages:
392
+ return []
393
+ offsets: List[int] = []
394
+ cursor = 0
395
+ for page in pages:
396
+ if not isinstance(page, dict):
397
+ return []
398
+ chars = page.get("chars")
399
+ if isinstance(chars, bool) or not isinstance(chars, (int, float)) or chars < 0:
400
+ return []
401
+ offsets.append(cursor)
402
+ cursor += int(chars) + 2 # +2 for the "\n\n" page joiner
403
+ return offsets
404
+
405
+
406
+ def page_for_offset(page_offsets: List[int], offset: int) -> Optional[int]:
407
+ """1-based page number containing ``offset`` given page start offsets.
408
+
409
+ Returns ``None`` when ``page_offsets`` is empty or the offset precedes the
410
+ first page start (honest absence over a wrong label).
411
+ """
412
+ if not page_offsets:
413
+ return None
414
+ try:
415
+ target = int(offset)
416
+ except Exception:
417
+ return None
418
+ page = 0
419
+ for index, start in enumerate(page_offsets):
420
+ try:
421
+ if target >= int(start):
422
+ page = index + 1
423
+ else:
424
+ break
425
+ except Exception:
426
+ return None
427
+ return page if page >= 1 else None
428
+
429
+
95
430
  _LLM_EXTRACT_CONCEPT_PROMPT = """Extract the key concepts from the following text.
96
431
  Return ONLY a JSON array of objects, each with "concept" (string) and "importance" (float 0-1).
97
432
  Extract up to {limit} concepts. Focus on named entities, technical terms, and domain-specific nouns.
@@ -412,6 +412,142 @@ def curate_nodes(
412
412
  return enriched
413
413
 
414
414
 
415
+ # ── Noise reduction (backlog #10, review §7.2 D) ─────────────────────────────
416
+ # Relation-verb normalization dictionary (ko/en). Keys are canonical verbs;
417
+ # values are the free-string labels observed in the legacy edge table. The
418
+ # canonical labels stay aligned with the v2 EdgeType legacy mapping so this
419
+ # never fights the schema normalization.
420
+
421
+ RELATION_VERB_GROUPS: Dict[str, List[str]] = {
422
+ "created": [
423
+ "creates", "create", "만들다", "만든", "만들었다", "만듦", "만들어냄",
424
+ "생성함", "생성", "생성했다", "작성함", "작성", "작성했다",
425
+ ],
426
+ "mentions": ["mention", "언급함", "언급", "언급했다", "언급됨"],
427
+ "contains": ["contain", "포함함", "포함", "포함했다", "포함됨"],
428
+ "uses": ["use", "used", "사용함", "사용", "사용했다", "이용함", "이용"],
429
+ "related_to": ["related", "relates_to", "관련", "관련됨", "관련있음", "연관됨", "연관"],
430
+ "fixed": ["fixes", "fix", "수정함", "수정", "수정했다", "고침", "고쳤다"],
431
+ "decided": ["decides", "decide", "결정함", "결정", "결정했다"],
432
+ "uploaded": ["uploads", "upload", "업로드함", "업로드", "올림", "올렸다"],
433
+ }
434
+
435
+
436
+ def build_relation_verb_index(
437
+ groups: Optional[Dict[str, List[str]]] = None,
438
+ ) -> Dict[str, str]:
439
+ """{alias(lower) → canonical} — canonical labels map to themselves."""
440
+ groups = groups or RELATION_VERB_GROUPS
441
+ index: Dict[str, str] = {}
442
+ for canonical, aliases in groups.items():
443
+ canon = str(canonical).strip().lower()
444
+ index[canon] = canon
445
+ for alias in aliases:
446
+ index[str(alias).strip().lower()] = canon
447
+ return index
448
+
449
+
450
+ def normalize_relation_verb(
451
+ verb: str,
452
+ *,
453
+ index: Optional[Dict[str, str]] = None,
454
+ ) -> str:
455
+ """Map a free-string edge verb to its canonical form (identity if unknown).
456
+
457
+ Korean verbs are additionally tried with the trailing josa stripped so
458
+ "생성함을" style variants still normalize; unknown labels pass through
459
+ unchanged (lossless — this function is a rename map, not a filter).
460
+ """
461
+ raw = str(verb or "").strip()
462
+ if not raw:
463
+ return raw
464
+ index = index if index is not None else build_relation_verb_index()
465
+ low = raw.lower()
466
+ if low in index:
467
+ return index[low]
468
+ stripped = _strip_josa(low)
469
+ if stripped in index:
470
+ return index[stripped]
471
+ return raw
472
+
473
+
474
+ # v4 write-door enum labels are SCREAMING_SNAKE_CASE ASCII; those are already
475
+ # canonical schema taxonomy and must never be rewritten by the verb dictionary.
476
+ _V4_ENUM_LABEL_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
477
+
478
+
479
+ def plan_relation_normalization(
480
+ edge_types: Iterable[str],
481
+ *,
482
+ index: Optional[Dict[str, str]] = None,
483
+ ) -> Dict[str, str]:
484
+ """{observed_type → canonical} for every type the dictionary changes.
485
+
486
+ Skips v4-canonical enum labels (``MENTIONS``, ``INDEXED_FROM``, …): this
487
+ plan targets the free-string verbs of pre-v4 rows, not the schema enum.
488
+ """
489
+ index = index if index is not None else build_relation_verb_index()
490
+ plan: Dict[str, str] = {}
491
+ for edge_type in edge_types:
492
+ original = str(edge_type or "")
493
+ if _V4_ENUM_LABEL_RE.match(original):
494
+ continue
495
+ canonical = normalize_relation_verb(original, index=index)
496
+ if canonical and canonical != original:
497
+ plan[original] = canonical
498
+ return plan
499
+
500
+
501
+ def plan_concept_noise_reduction(
502
+ concepts: Iterable[Dict[str, Any]],
503
+ total_docs: int,
504
+ *,
505
+ max_df_ratio: float = 0.8,
506
+ min_doc_frequency: int = 1,
507
+ min_corpus_docs: int = 5,
508
+ ) -> Dict[str, List[Dict[str, Any]]]:
509
+ """Decide which heuristic concept nodes are graph noise.
510
+
511
+ ``concepts``: ``[{"id", "label", "df", "heuristic"}]`` where ``df`` is the
512
+ number of distinct content documents linking to the concept.
513
+
514
+ Rules (dry-run friendly — pure decision, no side effects):
515
+
516
+ * **never** flag ``heuristic=False`` nodes (explicit user-created nodes
517
+ are untouchable, whatever their stats);
518
+ * low IDF: with a corpus of at least ``min_corpus_docs`` documents, a
519
+ concept appearing in ``> max_df_ratio`` of them separates nothing —
520
+ remove;
521
+ * frequency floor: ``df < min_doc_frequency`` (orphaned/near-orphaned
522
+ auto concepts) — remove.
523
+ """
524
+ total = max(0, int(total_docs))
525
+ remove: List[Dict[str, Any]] = []
526
+ keep: List[Dict[str, Any]] = []
527
+ for concept in concepts:
528
+ entry = {
529
+ "id": str(concept.get("id") or ""),
530
+ "label": concept.get("label"),
531
+ "df": int(concept.get("df") or 0),
532
+ "heuristic": bool(concept.get("heuristic")),
533
+ }
534
+ if not entry["heuristic"]:
535
+ keep.append({**entry, "reason": "user_created_protected"})
536
+ continue
537
+ df = entry["df"]
538
+ if df < int(min_doc_frequency):
539
+ df_ratio = (df / total) if total else 0.0
540
+ remove.append({**entry, "df_ratio": round(df_ratio, 4), "reason": "below_frequency_floor"})
541
+ continue
542
+ if total >= int(min_corpus_docs):
543
+ df_ratio = df / total
544
+ if df_ratio > float(max_df_ratio):
545
+ remove.append({**entry, "df_ratio": round(df_ratio, 4), "reason": "low_idf_ubiquitous"})
546
+ continue
547
+ keep.append({**entry, "reason": "signal"})
548
+ return {"remove": remove, "keep": keep}
549
+
550
+
415
551
  # ── End-to-end helper ─────────────────────────────────────────────────────────
416
552
 
417
553
 
@@ -457,6 +593,11 @@ def auto_build_graph_overlay(
457
593
 
458
594
 
459
595
  __all__ = [
596
+ "RELATION_VERB_GROUPS",
597
+ "build_relation_verb_index",
598
+ "normalize_relation_verb",
599
+ "plan_relation_normalization",
600
+ "plan_concept_noise_reduction",
460
601
  "TopicCandidate",
461
602
  "PromotionDecision",
462
603
  "ThreadEdge",
@@ -474,7 +474,12 @@ class KnowledgeGraphLocalIndexMixin:
474
474
  metadata={"source": "ocr", "workspace_id": workspace_id},
475
475
  )
476
476
 
477
- for index, chunk in enumerate(_chunks(text)):
477
+ # Typed chunking by file extension (markdown/code/plain); plain files
478
+ # keep legacy _chunks boundaries and therefore identical chunk ids.
479
+ chunk_strategy = chunk_strategy_for(file_path.name)
480
+ for index, piece in enumerate(typed_chunks(text, strategy=chunk_strategy)):
481
+ chunk = piece["text"]
482
+ chunk_fields = typed_chunk_meta_fields(piece)
478
483
  chunk_id = f"chunk:{_sha256_text(f'{file_node_id}:{index}:{chunk}')[:24]}"
479
484
  self._upsert_node(
480
485
  conn,
@@ -487,6 +492,7 @@ class KnowledgeGraphLocalIndexMixin:
487
492
  "source_node": file_node_id,
488
493
  "source_id": source_id,
489
494
  "workspace_id": workspace_id,
495
+ **chunk_fields,
490
496
  },
491
497
  owner=user_email,
492
498
  workspace_id=workspace_id,
@@ -501,6 +507,7 @@ class KnowledgeGraphLocalIndexMixin:
501
507
  "source_node": file_node_id,
502
508
  "source_id": source_id,
503
509
  "workspace_id": workspace_id,
510
+ **chunk_fields,
504
511
  },
505
512
  )
506
513
  self._upsert_edge(
@@ -6,6 +6,117 @@ from ._kg_common import * # noqa: F403,F401
6
6
 
7
7
 
8
8
  class KnowledgeGraphDocumentsMixin:
9
+ def find_documents_by_uri_prefix(self, prefix: str) -> List[Dict[str, Any]]:
10
+ """Content nodes whose ``metadata.source_uri`` starts with ``prefix``.
11
+
12
+ Powers removable seeded corpora (e.g. the ``demo://`` First Value Loop
13
+ documents): callers stamp a URI scheme at ingest time and can later
14
+ enumerate exactly those nodes without guessing ids.
15
+ """
16
+ prefix = str(prefix or "").strip()
17
+ if not prefix:
18
+ return []
19
+ nt, _ = self._read_tables()
20
+ documents: List[Dict[str, Any]] = []
21
+ with self._connect() as conn:
22
+ rows = conn.execute(
23
+ f"""
24
+ SELECT id, type, title, metadata_json, created_at, updated_at
25
+ FROM {nt}
26
+ WHERE json_extract(metadata_json, '$.source_uri') LIKE ? || '%'
27
+ AND type NOT IN ('Source', 'Chunk')
28
+ ORDER BY created_at ASC, id ASC
29
+ """,
30
+ (prefix,),
31
+ ).fetchall()
32
+ for row in rows:
33
+ meta = _safe_loads(row["metadata_json"]) or {}
34
+ documents.append(
35
+ {
36
+ "id": row["id"],
37
+ "type": row["type"],
38
+ "title": row["title"],
39
+ "source_uri": meta.get("source_uri"),
40
+ "workspace_id": meta.get("workspace_id"),
41
+ "created_at": row["created_at"],
42
+ "updated_at": row["updated_at"],
43
+ }
44
+ )
45
+ return documents
46
+
47
+ def delete_document_tree(self, node_id: str) -> Dict[str, Any]:
48
+ """Delete an ingested content node plus everything it owns.
49
+
50
+ Removes the node, its retrieval chunks (``chunks`` table + ``Chunk``
51
+ nodes), auto-extracted Task/Decision nodes whose ``source_node`` is
52
+ this document, every touching edge, the vector rows, and — when it
53
+ becomes orphaned — the linked ``Source`` node. Shared Concept nodes
54
+ are intentionally left alone (they may be cited by other content).
55
+ Mirrored into the v2 projection via ``_v2_delete_nodes``.
56
+ """
57
+ node_id = str(node_id or "").strip()
58
+ if not node_id:
59
+ return {"status": "skipped", "removed_nodes": 0}
60
+ with self._connect() as conn:
61
+ if not conn.execute(
62
+ "SELECT 1 FROM nodes WHERE id=?", (node_id,)
63
+ ).fetchone():
64
+ return {"status": "not_found", "node_id": node_id, "removed_nodes": 0}
65
+
66
+ remove_ids = {node_id}
67
+ # Owned children: chunks + auto-extracted semantic nodes that
68
+ # explicitly point back at this document via metadata.source_node.
69
+ for row in conn.execute(
70
+ """
71
+ SELECT id FROM nodes
72
+ WHERE json_extract(metadata_json, '$.source_node') = ?
73
+ """,
74
+ (node_id,),
75
+ ):
76
+ remove_ids.add(row["id"])
77
+ # The Source node this content was indexed from (if it ends up
78
+ # referenced by nothing else after the delete, drop it too).
79
+ source_ids = [
80
+ row["to_node"]
81
+ for row in conn.execute(
82
+ "SELECT to_node FROM edges WHERE from_node=? AND type IN ('indexed_from', 'INDEXED_FROM')",
83
+ (node_id,),
84
+ )
85
+ ]
86
+
87
+ ids = list(remove_ids)
88
+ ph = ",".join("?" * len(ids))
89
+ conn.execute(f"DELETE FROM chunks WHERE source_node IN ({ph})", ids)
90
+ conn.execute(
91
+ f"DELETE FROM edges WHERE from_node IN ({ph}) OR to_node IN ({ph})",
92
+ ids * 2,
93
+ )
94
+ conn.execute(
95
+ f"DELETE FROM vector_embeddings WHERE item_id IN ({ph}) OR source_node IN ({ph})",
96
+ ids * 2,
97
+ )
98
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({ph})", ids)
99
+ self._v2_delete_nodes(conn, ids)
100
+
101
+ removed_sources = 0
102
+ for source_id in source_ids:
103
+ still_linked = conn.execute(
104
+ "SELECT 1 FROM edges WHERE from_node=? OR to_node=? LIMIT 1",
105
+ (source_id, source_id),
106
+ ).fetchone()
107
+ if still_linked:
108
+ continue
109
+ conn.execute("DELETE FROM vector_embeddings WHERE item_id=?", (source_id,))
110
+ conn.execute("DELETE FROM nodes WHERE id=?", (source_id,))
111
+ self._v2_delete_nodes(conn, [source_id])
112
+ removed_sources += 1
113
+
114
+ return {
115
+ "status": "ok",
116
+ "node_id": node_id,
117
+ "removed_nodes": len(remove_ids) + removed_sources,
118
+ }
119
+
9
120
  def _ingest_structure_nodes(
10
121
  self,
11
122
  conn: sqlite3.Connection,