ltcai 9.9.3 → 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.
- package/README.md +43 -38
- package/docs/CHANGELOG.md +65 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +47 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +221 -37
- package/lattice_brain/graph/retrieval.py +61 -12
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +175 -6
- package/latticeai/api/chat_intents.py +37 -18
- package/latticeai/api/chat_stream.py +76 -2
- package/latticeai/api/knowledge_graph.py +35 -1
- package/latticeai/core/agent.py +264 -9
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +130 -5
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/command_center.py +90 -1
- package/latticeai/services/folder_watch.py +5 -0
- package/latticeai/services/funnel_metrics.py +9 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +43 -13
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +29 -28
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmdruVKV.js +0 -1
- package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
- package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 노드 + 엣지 ────────────────
|