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.
- package/README.md +44 -39
- package/docs/CHANGELOG.md +109 -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/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- 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/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- 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/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- 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 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.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-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- 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-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- 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
|
-
|
|
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,
|