ltcai 9.9.3 → 9.9.5

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 (89) hide show
  1. package/README.md +39 -38
  2. package/docs/CHANGELOG.md +100 -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 +53 -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/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. package/static/app/assets/utils-uQYKXNeq.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.
@@ -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(
@@ -108,7 +108,11 @@ class KnowledgeGraphIngestMixin:
108
108
  )
109
109
 
110
110
  # ── 4. RAG chunks (검색용, 그래프에서 숨김) ──────────────────────
111
- for index, chunk in enumerate(_chunks(content)):
111
+ # Chat text is always the plain strategy: boundaries (and thus
112
+ # chunk ids) stay byte-identical to the legacy _chunks path.
113
+ for index, piece in enumerate(typed_chunks(content, strategy="plain")):
114
+ chunk = piece["text"]
115
+ chunk_fields = typed_chunk_meta_fields(piece)
112
116
  chunk_id = f"chunk:{_sha256_text(f'{node_id}:{index}:{chunk}')[:24]}"
113
117
  self._upsert_node(
114
118
  conn,
@@ -116,7 +120,7 @@ class KnowledgeGraphIngestMixin:
116
120
  "Chunk",
117
121
  f"chunk {index + 1}",
118
122
  summary=chunk[:500],
119
- metadata={"index": index, "source_node": node_id},
123
+ metadata={"index": index, "source_node": node_id, **chunk_fields},
120
124
  owner=user_email,
121
125
  workspace_id=workspace_id,
122
126
  )
@@ -125,7 +129,7 @@ class KnowledgeGraphIngestMixin:
125
129
  chunk_id=chunk_id,
126
130
  source_node=node_id,
127
131
  text=chunk,
128
- metadata={"index": index, "source_node": node_id},
132
+ metadata={"index": index, "source_node": node_id, **chunk_fields},
129
133
  )
130
134
  self._upsert_edge(conn, node_id, chunk_id, "포함함")
131
135
 
@@ -322,7 +326,25 @@ class KnowledgeGraphIngestMixin:
322
326
  self._upsert_edge(conn, conv_id, file_id, "언급함", weight=0.8)
323
327
 
324
328
  # ── RAG chunks (검색용, 그래프 비표시) ────────────────────────────
325
- for index, chunk in enumerate(_chunks(text)):
329
+ # Strategy from the filename (plain files keep legacy boundaries →
330
+ # identical chunk ids). For PDFs, per-page start offsets in the
331
+ # "\n\n"-joined page text let each chunk carry a 1-based "page" —
332
+ # only when the claimed extent matches the text we actually chunk
333
+ # (honest absence over wrong labels).
334
+ chunk_strategy = chunk_strategy_for(filename, content_type=mime_type or "")
335
+ page_offsets: List[int] = []
336
+ if ext == ".pdf":
337
+ page_offsets = pdf_page_offsets(doc_meta)
338
+ cleaned_len = len(str(text or "").strip())
339
+ if not page_offsets or page_offsets[-1] >= cleaned_len * 1.2:
340
+ page_offsets = []
341
+ for index, piece in enumerate(typed_chunks(text, strategy=chunk_strategy)):
342
+ chunk = piece["text"]
343
+ chunk_fields = typed_chunk_meta_fields(piece)
344
+ if page_offsets:
345
+ page = page_for_offset(page_offsets, chunk_fields["start_char"])
346
+ if page is not None:
347
+ chunk_fields["page"] = page
326
348
  chunk_id = f"chunk:{_sha256_text(f'{file_id}:{index}:{chunk}')[:24]}"
327
349
  chunk_ids.append(chunk_id)
328
350
  self._upsert_node(
@@ -331,7 +353,7 @@ class KnowledgeGraphIngestMixin:
331
353
  "Chunk",
332
354
  f"{filename} chunk {index + 1}",
333
355
  summary=chunk[:500],
334
- metadata={"index": index, "source_node": file_id, "workspace_id": workspace_id},
356
+ metadata={"index": index, "source_node": file_id, "workspace_id": workspace_id, **chunk_fields},
335
357
  owner=owner or uploader,
336
358
  workspace_id=workspace_id,
337
359
  )
@@ -340,7 +362,7 @@ class KnowledgeGraphIngestMixin:
340
362
  chunk_id=chunk_id,
341
363
  source_node=file_id,
342
364
  text=chunk,
343
- metadata={"index": index, "source_node": file_id},
365
+ metadata={"index": index, "source_node": file_id, **chunk_fields},
344
366
  )
345
367
  self._upsert_edge(conn, file_id, chunk_id, "포함함")
346
368
 
@@ -651,7 +673,19 @@ class KnowledgeGraphIngestMixin:
651
673
  )
652
674
  self._upsert_edge(conn, conv_id, content_id, "언급함", weight=0.8)
653
675
  # ── RAG 청크 ────────────────────────────────────────────────────
654
- for index, chunk in enumerate(_chunks(text)):
676
+ # Best available filename hint: source URI, then path/filename from
677
+ # caller metadata. Plain content keeps legacy chunk boundaries and
678
+ # therefore identical chunk ids.
679
+ chunk_strategy = chunk_strategy_for(
680
+ source_uri
681
+ or (metadata or {}).get("path")
682
+ or (metadata or {}).get("filename")
683
+ or "",
684
+ content_type=str((metadata or {}).get("mime_type") or ""),
685
+ )
686
+ for index, piece in enumerate(typed_chunks(text, strategy=chunk_strategy)):
687
+ chunk = piece["text"]
688
+ chunk_fields = typed_chunk_meta_fields(piece)
655
689
  chunk_id = f"chunk:{_sha256_text(f'{content_id}:{index}:{chunk}')[:24]}"
656
690
  chunk_ids.append(chunk_id)
657
691
  self._upsert_node(
@@ -660,7 +694,7 @@ class KnowledgeGraphIngestMixin:
660
694
  "Chunk",
661
695
  f"{title} chunk {index + 1}",
662
696
  summary=chunk[:500],
663
- metadata={"index": index, "source_node": content_id, "workspace_id": workspace_id},
697
+ metadata={"index": index, "source_node": content_id, "workspace_id": workspace_id, **chunk_fields},
664
698
  owner=owner,
665
699
  workspace_id=workspace_id,
666
700
  )
@@ -669,7 +703,7 @@ class KnowledgeGraphIngestMixin:
669
703
  chunk_id=chunk_id,
670
704
  source_node=content_id,
671
705
  text=chunk,
672
- metadata={"index": index, "source_node": content_id},
706
+ metadata={"index": index, "source_node": content_id, **chunk_fields},
673
707
  )
674
708
  self._upsert_edge(conn, content_id, chunk_id, "포함함")
675
709
  # ── Concept / Feature / Error / Code 노드 + 엣지 ────────────────