ltcai 9.9.5 → 9.9.9
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 +41 -36
- package/docs/CHANGELOG.md +193 -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 +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +38 -8
- 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/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +119 -2
- package/latticeai/api/chat_contracts.py +9 -0
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +381 -43
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +5 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +188 -0
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +73 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +5 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -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 +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-VmP0kkeA.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-Ck1nqc-v.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmxfbqBp.js +0 -1
- package/static/app/assets/Brain-DK_zKCda.js +0 -321
- package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
- package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
- package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
- package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
- package/static/app/assets/Library-4As9VUvy.js +0 -1
- package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
- package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
- package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
- package/static/app/assets/System-DSR2zKWd.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-CEu0Wqjl.js +0 -10
- package/static/app/assets/input-CSi6OBJ9.js +0 -1
- package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
- package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
- package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
- package/static/app/assets/utils-BqoznzGP.js +0 -7
package/lattice_brain/context.py
CHANGED
|
@@ -116,12 +116,19 @@ class ContextAssembler:
|
|
|
116
116
|
|
|
117
117
|
Section priority (kept under budget in this order — semantic memories
|
|
118
118
|
first because they are cheap and durable; recency last):
|
|
119
|
-
1. memories
|
|
120
|
-
2.
|
|
121
|
-
3.
|
|
122
|
-
4.
|
|
119
|
+
1. memories — workspace semantic memories (preferences/decisions/…)
|
|
120
|
+
2. artifacts — files this conversation just produced (v9.9.6)
|
|
121
|
+
3. knowledge — hybrid search over the brain (the product's own engine)
|
|
122
|
+
4. notes — garden-note context
|
|
123
|
+
5. recent — the user's recent exchange
|
|
123
124
|
Every seam is optional; an absent seam contributes nothing (honest
|
|
124
125
|
absence), never a fabricated section.
|
|
126
|
+
|
|
127
|
+
The ``artifacts`` seam closes the re-search loop (review 2026-07-27 루프
|
|
128
|
+
§1): a file written moments ago may not be indexed yet — embedding and
|
|
129
|
+
graph writes are asynchronous — so asking "what did you just make?" could
|
|
130
|
+
miss it. The artifact ledger is a deterministic, high-priority section: it
|
|
131
|
+
does not depend on retrieval catching up.
|
|
125
132
|
"""
|
|
126
133
|
|
|
127
134
|
def __init__(
|
|
@@ -131,11 +138,13 @@ class ContextAssembler:
|
|
|
131
138
|
hybrid_search: Optional[Callable[..., Dict[str, Any]]] = None,
|
|
132
139
|
notes_context: Optional[Callable[..., str]] = None,
|
|
133
140
|
recent_chat: Optional[Callable[..., str]] = None,
|
|
141
|
+
recent_artifacts: Optional[Callable[..., List[Dict[str, Any]]]] = None,
|
|
134
142
|
) -> None:
|
|
135
143
|
self._memory_recall = memory_recall
|
|
136
144
|
self._hybrid_search = hybrid_search
|
|
137
145
|
self._notes_context = notes_context
|
|
138
146
|
self._recent_chat = recent_chat
|
|
147
|
+
self._recent_artifacts = recent_artifacts
|
|
139
148
|
|
|
140
149
|
def assemble(
|
|
141
150
|
self,
|
|
@@ -152,6 +161,8 @@ class ContextAssembler:
|
|
|
152
161
|
|
|
153
162
|
if self._memory_recall is not None:
|
|
154
163
|
sections.append(self._memories_section(query, user_email, workspace_id, memory_limit))
|
|
164
|
+
if self._recent_artifacts is not None:
|
|
165
|
+
sections.append(self._artifacts_section(user_email, conversation_id, workspace_id))
|
|
155
166
|
if self._hybrid_search is not None:
|
|
156
167
|
sections.append(self._knowledge_section(query, knowledge_limit, user_email, workspace_id))
|
|
157
168
|
if self._notes_context is not None:
|
|
@@ -182,6 +193,30 @@ class ContextAssembler:
|
|
|
182
193
|
],
|
|
183
194
|
)
|
|
184
195
|
|
|
196
|
+
def _artifacts_section(self, user_email, conversation_id, workspace_id) -> ContextSection:
|
|
197
|
+
"""Files this conversation produced, whether or not retrieval indexed them yet."""
|
|
198
|
+
try:
|
|
199
|
+
items = _call_keyword_seam(
|
|
200
|
+
self._recent_artifacts,
|
|
201
|
+
user_email=user_email,
|
|
202
|
+
conversation_id=conversation_id,
|
|
203
|
+
workspace_id=workspace_id,
|
|
204
|
+
) or []
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
logging.debug("context: recent artifacts failed: %s", exc)
|
|
207
|
+
items = []
|
|
208
|
+
rows = [item for item in items if isinstance(item, dict) and item.get("path")][:10]
|
|
209
|
+
lines = [
|
|
210
|
+
f"- {item['path']}" + (f" ({item['at']})" if item.get("at") else "")
|
|
211
|
+
for item in rows
|
|
212
|
+
]
|
|
213
|
+
return ContextSection(
|
|
214
|
+
name="Files created in this conversation",
|
|
215
|
+
content="\n".join(lines),
|
|
216
|
+
source="artifacts",
|
|
217
|
+
provenance=[{"path": item.get("path"), "run_id": item.get("run_id")} for item in rows],
|
|
218
|
+
)
|
|
219
|
+
|
|
185
220
|
def _knowledge_section(self, query, limit, user_email=None, workspace_id=None) -> ContextSection:
|
|
186
221
|
try:
|
|
187
222
|
hybrid = _call_context_seam(
|
|
@@ -105,7 +105,10 @@ _CODE_CHUNK_EXTENSIONS = {
|
|
|
105
105
|
".c", ".h", ".cpp", ".css", ".sh", ".sql", ".vue", ".svelte",
|
|
106
106
|
".json", ".yaml", ".yml", ".toml",
|
|
107
107
|
}
|
|
108
|
-
|
|
108
|
+
_PROSE_CHUNK_EXTENSIONS = {
|
|
109
|
+
".txt", ".pdf", ".docx", ".doc", ".rtf", ".odt", ".epub", ".html", ".htm",
|
|
110
|
+
}
|
|
111
|
+
_CHUNK_STRATEGIES = {"plain", "markdown", "code", "prose"}
|
|
109
112
|
# Markdown sections smaller than this merge forward into the next section so
|
|
110
113
|
# heading-dense documents don't shatter into confetti chunks.
|
|
111
114
|
_MARKDOWN_MIN_SECTION_CHARS = 200
|
|
@@ -120,9 +123,15 @@ def chunk_strategy_for(filename: Any, *, content_type: str = "") -> str:
|
|
|
120
123
|
"""Route a filename / path / URI (plus optional MIME hint) to a strategy.
|
|
121
124
|
|
|
122
125
|
Returns ``"markdown"`` for .md/.markdown, ``"code"`` for known source-code
|
|
123
|
-
extensions, ``"
|
|
124
|
-
(
|
|
125
|
-
|
|
126
|
+
extensions, ``"prose"`` for document formats whose text is running prose
|
|
127
|
+
(.txt/.pdf/.docx/.html/…), ``"plain"`` otherwise. Case-insensitive,
|
|
128
|
+
tolerant of URLs (query/fragment stripped) and ``Path`` objects; never
|
|
129
|
+
raises — any malformed input falls back to ``"plain"``.
|
|
130
|
+
|
|
131
|
+
Unknown/extension-less input stays ``"plain"`` on purpose: the plain
|
|
132
|
+
strategy is the byte-compatible legacy walk, and guessing prose for
|
|
133
|
+
something that might be a data dump would move chunk boundaries for no
|
|
134
|
+
retrieval gain.
|
|
126
135
|
"""
|
|
127
136
|
try:
|
|
128
137
|
name = str(filename or "").strip().lower()
|
|
@@ -135,8 +144,13 @@ def chunk_strategy_for(filename: Any, *, content_type: str = "") -> str:
|
|
|
135
144
|
return "markdown"
|
|
136
145
|
if ext in _CODE_CHUNK_EXTENSIONS:
|
|
137
146
|
return "code"
|
|
138
|
-
if
|
|
147
|
+
if ext in _PROSE_CHUNK_EXTENSIONS:
|
|
148
|
+
return "prose"
|
|
149
|
+
mime = str(content_type or "").strip().lower()
|
|
150
|
+
if "markdown" in mime:
|
|
139
151
|
return "markdown"
|
|
152
|
+
if mime.startswith("text/html") or mime.startswith("text/plain"):
|
|
153
|
+
return "prose"
|
|
140
154
|
except Exception:
|
|
141
155
|
pass
|
|
142
156
|
return "plain"
|
|
@@ -324,6 +338,72 @@ def _code_chunks(cleaned: str, size: int, overlap: int) -> List[Dict[str, Any]]:
|
|
|
324
338
|
return out
|
|
325
339
|
|
|
326
340
|
|
|
341
|
+
# ── Prose chunking (review 2026-07-27 P1 #4) ────────────────────────────────
|
|
342
|
+
# The plain walk cuts every ``size`` characters, which lands mid-sentence and
|
|
343
|
+
# — for Korean, where the verb carrying the meaning sits at the end — routinely
|
|
344
|
+
# splits a claim from its predicate. Retrieval then matches half a statement
|
|
345
|
+
# and the citation shows a fragment. The prose strategy keeps the same window
|
|
346
|
+
# budget but ends each chunk at the last sentence/paragraph boundary inside it.
|
|
347
|
+
|
|
348
|
+
# Strong: sentence-final punctuation (ASCII + CJK) with optional closing
|
|
349
|
+
# quotes/brackets, followed by whitespace; or a blank-line paragraph break.
|
|
350
|
+
_PROSE_STRONG_BOUNDARY_RE = re.compile(
|
|
351
|
+
r"(?:[.!?。!?…]+[\"'”’」』\)\]]*\s+|\n[ \t]*\n)"
|
|
352
|
+
)
|
|
353
|
+
# Weak: a single line break. Korean notes and bullet lists often carry no
|
|
354
|
+
# sentence punctuation at all; a line end is still a real boundary there.
|
|
355
|
+
_PROSE_WEAK_BOUNDARY_RE = re.compile(r"\n")
|
|
356
|
+
# Never emit a chunk shorter than this fraction of ``size`` just to hit a
|
|
357
|
+
# boundary — tiny chunks hurt recall more than a mid-sentence cut.
|
|
358
|
+
_PROSE_MIN_SPAN_RATIO = 0.5
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _last_boundary(cleaned: str, lo: int, hi: int) -> Optional[int]:
|
|
362
|
+
"""End offset of the last sentence/paragraph boundary in ``cleaned[lo:hi]``.
|
|
363
|
+
|
|
364
|
+
Strong boundaries win; a single line break is the fallback. Returns None
|
|
365
|
+
when the span holds neither, so the caller keeps the hard window cut.
|
|
366
|
+
"""
|
|
367
|
+
window = cleaned[lo:hi]
|
|
368
|
+
for pattern in (_PROSE_STRONG_BOUNDARY_RE, _PROSE_WEAK_BOUNDARY_RE):
|
|
369
|
+
last = None
|
|
370
|
+
for match in pattern.finditer(window):
|
|
371
|
+
last = match.end()
|
|
372
|
+
if last:
|
|
373
|
+
return lo + last
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _prose_chunks(cleaned: str, size: int, overlap: int) -> List[Dict[str, Any]]:
|
|
378
|
+
out: List[Dict[str, Any]] = []
|
|
379
|
+
total = len(cleaned)
|
|
380
|
+
min_span = max(1, int(size * _PROSE_MIN_SPAN_RATIO))
|
|
381
|
+
start = 0
|
|
382
|
+
while start < total:
|
|
383
|
+
hard_end = min(total, start + size)
|
|
384
|
+
end = hard_end
|
|
385
|
+
if hard_end < total:
|
|
386
|
+
boundary = _last_boundary(cleaned, start + min_span, hard_end)
|
|
387
|
+
if boundary is not None and boundary > start:
|
|
388
|
+
end = boundary
|
|
389
|
+
out.append(
|
|
390
|
+
{
|
|
391
|
+
"text": cleaned[start:end],
|
|
392
|
+
"meta": {
|
|
393
|
+
"strategy": "prose",
|
|
394
|
+
"start_char": start,
|
|
395
|
+
"heading_path": None,
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
if end >= total:
|
|
400
|
+
break
|
|
401
|
+
# Overlap carries the tail of the previous chunk into the next one so
|
|
402
|
+
# a claim split across a boundary is still retrievable from both.
|
|
403
|
+
start = max(start + 1, end - overlap)
|
|
404
|
+
return out
|
|
405
|
+
|
|
406
|
+
|
|
327
407
|
def typed_chunks(
|
|
328
408
|
text: str,
|
|
329
409
|
*,
|
|
@@ -356,6 +436,8 @@ def typed_chunks(
|
|
|
356
436
|
return _markdown_chunks(cleaned, size, overlap)
|
|
357
437
|
if label == "code":
|
|
358
438
|
return _code_chunks(cleaned, size, overlap)
|
|
439
|
+
if label == "prose":
|
|
440
|
+
return _prose_chunks(cleaned, size, overlap)
|
|
359
441
|
return _plain_windows(cleaned, size, overlap)
|
|
360
442
|
|
|
361
443
|
|
|
@@ -377,6 +459,35 @@ def typed_chunk_meta_fields(piece: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
377
459
|
return fields
|
|
378
460
|
|
|
379
461
|
|
|
462
|
+
def citation_locator(chunk_metadata: Any) -> str:
|
|
463
|
+
"""Human "where in the document" label for one chunk, or "".
|
|
464
|
+
|
|
465
|
+
Built only from provenance the chunk actually carries — a section heading
|
|
466
|
+
path and/or a page number. When neither is known the answer is the empty
|
|
467
|
+
string, so a citation never claims a location it cannot prove.
|
|
468
|
+
"""
|
|
469
|
+
if not isinstance(chunk_metadata, dict):
|
|
470
|
+
return ""
|
|
471
|
+
parts: List[str] = []
|
|
472
|
+
heading = str(chunk_metadata.get("heading_path") or "").strip()
|
|
473
|
+
if heading:
|
|
474
|
+
parts.append(heading)
|
|
475
|
+
def _page(key: str) -> int:
|
|
476
|
+
value = chunk_metadata.get(key)
|
|
477
|
+
try:
|
|
478
|
+
return int(value) if value is not None else 0
|
|
479
|
+
except (TypeError, ValueError):
|
|
480
|
+
return 0
|
|
481
|
+
|
|
482
|
+
page_number = _page("page")
|
|
483
|
+
if page_number > 0:
|
|
484
|
+
page_end = _page("page_end")
|
|
485
|
+
parts.append(
|
|
486
|
+
f"p.{page_number}–{page_end}" if page_end > page_number else f"p.{page_number}"
|
|
487
|
+
)
|
|
488
|
+
return " · ".join(parts)
|
|
489
|
+
|
|
490
|
+
|
|
380
491
|
def pdf_page_offsets(structure: Any) -> List[int]:
|
|
381
492
|
"""Start offset of each PDF page in the "\\n\\n"-joined page text.
|
|
382
493
|
|
|
@@ -538,13 +649,27 @@ def _llm_extract_triples(
|
|
|
538
649
|
triples = []
|
|
539
650
|
for item in parsed[:limit]:
|
|
540
651
|
if isinstance(item, dict) and "subject" in item and "object" in item:
|
|
652
|
+
relation = str(item.get("relation", "관련됨"))
|
|
653
|
+
evidence_text = str(item.get("evidence", ""))[:240]
|
|
654
|
+
confidence = float(item.get("confidence", 0.8))
|
|
655
|
+
# An LLM triple that names a real verb and cites the
|
|
656
|
+
# sentence it came from is semantic evidence; a bare
|
|
657
|
+
# "관련됨" with no quoted evidence is the model restating
|
|
658
|
+
# co-occurrence, and is weighted (and labelled) as such.
|
|
659
|
+
is_semantic = bool(evidence_text) and relation != "관련됨"
|
|
541
660
|
triples.append(
|
|
542
661
|
{
|
|
543
662
|
"subject": str(item["subject"]),
|
|
544
|
-
"relation":
|
|
663
|
+
"relation": relation,
|
|
545
664
|
"object": str(item["object"]),
|
|
546
|
-
"context":
|
|
547
|
-
"confidence":
|
|
665
|
+
"context": evidence_text,
|
|
666
|
+
"confidence": confidence,
|
|
667
|
+
"evidence": "verb" if is_semantic else "cooccurrence",
|
|
668
|
+
"weight": round(
|
|
669
|
+
(VERB_EDGE_WEIGHT if is_semantic else COOCCURRENCE_EDGE_WEIGHT)
|
|
670
|
+
* max(0.1, min(confidence, 1.0)),
|
|
671
|
+
4,
|
|
672
|
+
),
|
|
548
673
|
}
|
|
549
674
|
)
|
|
550
675
|
return triples if triples else None
|
|
@@ -820,13 +945,52 @@ EDGE_VERB = {
|
|
|
820
945
|
}
|
|
821
946
|
|
|
822
947
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
948
|
+
# Concepts in a list-like sentence ("A, B, C, D를 사용한다") sit together by
|
|
949
|
+
# enumeration, not by relation. Beyond this many concepts in one sentence, a
|
|
950
|
+
# verb-less pairing is enumeration noise and is dropped outright.
|
|
951
|
+
COOCCURRENCE_CONCEPT_LIMIT = 4
|
|
952
|
+
# Verb-backed relations carry the sentence's own evidence; co-occurrence
|
|
953
|
+
# relations carry only adjacency, so they enter the graph at a lower weight
|
|
954
|
+
# and are labelled as such.
|
|
955
|
+
VERB_EDGE_WEIGHT = 1.0
|
|
956
|
+
COOCCURRENCE_EDGE_WEIGHT = 0.35
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def infer_edge_relation(sentence: str) -> Dict[str, Any]:
|
|
960
|
+
"""Classify the relation between two concepts in one sentence.
|
|
961
|
+
|
|
962
|
+
Review 2026-07-27 P1 #6: the graph drifted toward co-occurrence because a
|
|
963
|
+
verb-less sentence still produced a "관련됨" edge indistinguishable from a
|
|
964
|
+
real semantic relation. The label alone cannot carry that difference, so
|
|
965
|
+
the evidence class rides with it::
|
|
966
|
+
|
|
967
|
+
{"relation": "사용함", "evidence": "verb", "weight": 1.0}
|
|
968
|
+
{"relation": "관련됨", "evidence": "cooccurrence", "weight": 0.35}
|
|
969
|
+
|
|
970
|
+
``evidence`` is what the graph, the curator, and the UI use to tell a
|
|
971
|
+
meaning edge from an adjacency edge — the honest distinction the previous
|
|
972
|
+
label-only output erased.
|
|
973
|
+
"""
|
|
974
|
+
s = str(sentence or "").lower()
|
|
826
975
|
for label, pattern in EDGE_VERB.items():
|
|
827
976
|
if re.search(pattern, s):
|
|
828
|
-
|
|
829
|
-
|
|
977
|
+
# "관련됨" is itself a weak, generic label: matching it by keyword
|
|
978
|
+
# ("관련", "related") is still verb evidence, but nothing stronger.
|
|
979
|
+
return {
|
|
980
|
+
"relation": label,
|
|
981
|
+
"evidence": "verb",
|
|
982
|
+
"weight": VERB_EDGE_WEIGHT,
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
"relation": "관련됨",
|
|
986
|
+
"evidence": "cooccurrence",
|
|
987
|
+
"weight": COOCCURRENCE_EDGE_WEIGHT,
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _infer_edge(sentence: str) -> str:
|
|
992
|
+
"""Back-compat wrapper: the verb label only (see :func:`infer_edge_relation`)."""
|
|
993
|
+
return infer_edge_relation(sentence)["relation"]
|
|
830
994
|
|
|
831
995
|
|
|
832
996
|
# Technical words that cannot be person names
|
|
@@ -940,7 +1104,16 @@ def _extract_triples_rules(
|
|
|
940
1104
|
if len(present) < 2:
|
|
941
1105
|
continue
|
|
942
1106
|
|
|
943
|
-
|
|
1107
|
+
relation = infer_edge_relation(sent)
|
|
1108
|
+
edge = relation["relation"]
|
|
1109
|
+
# Enumeration guard (review 2026-07-27 P1 #6): a verb-less sentence
|
|
1110
|
+
# listing many concepts is a list, not a set of relations. Verb-backed
|
|
1111
|
+
# sentences keep every pair — the verb is the evidence.
|
|
1112
|
+
if (
|
|
1113
|
+
relation["evidence"] == "cooccurrence"
|
|
1114
|
+
and len(present) > COOCCURRENCE_CONCEPT_LIMIT
|
|
1115
|
+
):
|
|
1116
|
+
continue
|
|
944
1117
|
|
|
945
1118
|
for i in range(len(present) - 1):
|
|
946
1119
|
subj, obj = present[i], present[i + 1]
|
|
@@ -955,6 +1128,8 @@ def _extract_triples_rules(
|
|
|
955
1128
|
"relation": edge, # verb form (동사)
|
|
956
1129
|
"object": obj,
|
|
957
1130
|
"context": sent[:240],
|
|
1131
|
+
"evidence": relation["evidence"],
|
|
1132
|
+
"weight": relation["weight"],
|
|
958
1133
|
}
|
|
959
1134
|
)
|
|
960
1135
|
if len(triples) >= limit:
|
|
@@ -548,6 +548,68 @@ def plan_concept_noise_reduction(
|
|
|
548
548
|
return {"remove": remove, "keep": keep}
|
|
549
549
|
|
|
550
550
|
|
|
551
|
+
def plan_relation_noise_reduction(
|
|
552
|
+
edges: Iterable[Dict[str, Any]],
|
|
553
|
+
*,
|
|
554
|
+
min_cooccurrence_weight: float = 0.3,
|
|
555
|
+
max_cooccurrence_degree: int = 12,
|
|
556
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
557
|
+
"""Separate meaning edges from adjacency edges (review 2026-07-27 P1 #6).
|
|
558
|
+
|
|
559
|
+
``edges``: ``[{"id", "from", "to", "type", "weight", "evidence", "degree"}]``
|
|
560
|
+
where ``evidence`` is the class recorded at ingest (``"verb"`` |
|
|
561
|
+
``"cooccurrence"``) and ``degree`` is how many co-occurrence edges the
|
|
562
|
+
source node already carries.
|
|
563
|
+
|
|
564
|
+
Rules (pure decision, dry-run friendly):
|
|
565
|
+
|
|
566
|
+
* verb-backed edges are **never** demoted — the sentence carried a real
|
|
567
|
+
relation;
|
|
568
|
+
* edges with no recorded evidence class are legacy rows: kept, and marked
|
|
569
|
+
``unknown_evidence`` rather than guessed at;
|
|
570
|
+
* co-occurrence edges below ``min_cooccurrence_weight`` are noise;
|
|
571
|
+
* a node whose co-occurrence degree exceeds ``max_cooccurrence_degree`` is
|
|
572
|
+
a hub built by adjacency, not meaning — its extra co-occurrence edges
|
|
573
|
+
are demoted.
|
|
574
|
+
|
|
575
|
+
Returns ``{"keep": [...], "demote": [...]}``. "Demote" is deliberate:
|
|
576
|
+
these edges are candidates for review, not silent deletion.
|
|
577
|
+
"""
|
|
578
|
+
keep: List[Dict[str, Any]] = []
|
|
579
|
+
demote: List[Dict[str, Any]] = []
|
|
580
|
+
for edge in edges:
|
|
581
|
+
entry = {
|
|
582
|
+
"id": str(edge.get("id") or ""),
|
|
583
|
+
"from": edge.get("from"),
|
|
584
|
+
"to": edge.get("to"),
|
|
585
|
+
"type": edge.get("type"),
|
|
586
|
+
"evidence": str(edge.get("evidence") or ""),
|
|
587
|
+
}
|
|
588
|
+
try:
|
|
589
|
+
entry["weight"] = float(edge.get("weight") or 0.0)
|
|
590
|
+
except (TypeError, ValueError):
|
|
591
|
+
entry["weight"] = 0.0
|
|
592
|
+
try:
|
|
593
|
+
degree = int(edge.get("degree") or 0)
|
|
594
|
+
except (TypeError, ValueError):
|
|
595
|
+
degree = 0
|
|
596
|
+
entry["degree"] = degree
|
|
597
|
+
if entry["evidence"] == "verb":
|
|
598
|
+
keep.append({**entry, "reason": "verb_evidence"})
|
|
599
|
+
continue
|
|
600
|
+
if not entry["evidence"]:
|
|
601
|
+
keep.append({**entry, "reason": "unknown_evidence"})
|
|
602
|
+
continue
|
|
603
|
+
if entry["weight"] < float(min_cooccurrence_weight):
|
|
604
|
+
demote.append({**entry, "reason": "weak_cooccurrence"})
|
|
605
|
+
continue
|
|
606
|
+
if degree > int(max_cooccurrence_degree):
|
|
607
|
+
demote.append({**entry, "reason": "cooccurrence_hub"})
|
|
608
|
+
continue
|
|
609
|
+
keep.append({**entry, "reason": "cooccurrence_within_budget"})
|
|
610
|
+
return {"keep": keep, "demote": demote}
|
|
611
|
+
|
|
612
|
+
|
|
551
613
|
# ── End-to-end helper ─────────────────────────────────────────────────────────
|
|
552
614
|
|
|
553
615
|
|
|
@@ -454,6 +454,82 @@ class KnowledgeGraphDiscoveryMixin:
|
|
|
454
454
|
source["file_status"] = counts.get(source["id"], {})
|
|
455
455
|
return {"sources": sources}
|
|
456
456
|
|
|
457
|
+
def local_source_health(self, *, error_samples: int = 3) -> Dict[str, Any]:
|
|
458
|
+
"""Per-folder memory state (v9.9.7).
|
|
459
|
+
|
|
460
|
+
``local_sources`` answers "which folders are connected"; a user asking
|
|
461
|
+
"is this folder actually in my Brain?" needs three more facts per
|
|
462
|
+
folder, and this is the single read that provides them:
|
|
463
|
+
|
|
464
|
+
* **coverage** — indexed / known files, so a half-indexed folder is
|
|
465
|
+
visible instead of looking connected;
|
|
466
|
+
* **failed / skipped counts** — what did not make it in;
|
|
467
|
+
* **recent error samples** — *why*, with the stored message.
|
|
468
|
+
|
|
469
|
+
Vector freshness is deliberately **not** claimed per folder: the vector
|
|
470
|
+
index is global, so a per-folder number would be invented. Callers pair
|
|
471
|
+
this with ``vector_freshness()`` and label it as global.
|
|
472
|
+
"""
|
|
473
|
+
try:
|
|
474
|
+
samples = max(0, min(int(error_samples), 20))
|
|
475
|
+
except (TypeError, ValueError):
|
|
476
|
+
samples = 3
|
|
477
|
+
payload = self.local_sources()
|
|
478
|
+
sources = payload.get("sources") or []
|
|
479
|
+
by_id = {str(source.get("id")): source for source in sources}
|
|
480
|
+
errors: Dict[str, List[Dict[str, Any]]] = {}
|
|
481
|
+
if by_id:
|
|
482
|
+
with self._connect() as conn:
|
|
483
|
+
for source_id in by_id:
|
|
484
|
+
rows = conn.execute(
|
|
485
|
+
"""
|
|
486
|
+
SELECT relative_path, status, error_message, last_scanned_at
|
|
487
|
+
FROM local_file_index
|
|
488
|
+
WHERE source_id=? AND error_message IS NOT NULL AND error_message<>''
|
|
489
|
+
ORDER BY last_scanned_at DESC
|
|
490
|
+
LIMIT ?
|
|
491
|
+
""",
|
|
492
|
+
(source_id, samples or 1),
|
|
493
|
+
).fetchall()
|
|
494
|
+
if samples:
|
|
495
|
+
errors[source_id] = [
|
|
496
|
+
{
|
|
497
|
+
"path": row["relative_path"],
|
|
498
|
+
"status": row["status"],
|
|
499
|
+
"detail": str(row["error_message"] or "")[:300],
|
|
500
|
+
"at": row["last_scanned_at"],
|
|
501
|
+
}
|
|
502
|
+
for row in rows
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
folders: List[Dict[str, Any]] = []
|
|
506
|
+
for source in sources:
|
|
507
|
+
counts = source.get("file_status") or {}
|
|
508
|
+
total = sum(int(value or 0) for value in counts.values())
|
|
509
|
+
indexed = int(counts.get("indexed") or 0)
|
|
510
|
+
failed = int(counts.get("failed") or 0) + int(counts.get("error") or 0)
|
|
511
|
+
skipped = int(counts.get("skipped") or 0)
|
|
512
|
+
folders.append({
|
|
513
|
+
"id": source.get("id"),
|
|
514
|
+
"label": source.get("label") or source.get("root_path"),
|
|
515
|
+
"root_path": source.get("root_path"),
|
|
516
|
+
"status": source.get("status"),
|
|
517
|
+
"watch_enabled": bool(source.get("watch_enabled")),
|
|
518
|
+
"last_scanned_at": source.get("last_scanned_at"),
|
|
519
|
+
"files": {
|
|
520
|
+
"total": total,
|
|
521
|
+
"indexed": indexed,
|
|
522
|
+
"failed": failed,
|
|
523
|
+
"skipped": skipped,
|
|
524
|
+
"pending": max(0, total - indexed - failed - skipped),
|
|
525
|
+
},
|
|
526
|
+
# None (not 0) when nothing is known yet — an empty folder must
|
|
527
|
+
# not read as "0% indexed".
|
|
528
|
+
"coverage": round(indexed / total, 4) if total else None,
|
|
529
|
+
"recent_errors": errors.get(str(source.get("id")), []),
|
|
530
|
+
})
|
|
531
|
+
return {"folders": folders, "count": len(folders)}
|
|
532
|
+
|
|
457
533
|
def set_local_source_watch(self, source_id: str, enabled: bool) -> Dict[str, Any]:
|
|
458
534
|
source_id = str(source_id or "").strip()
|
|
459
535
|
if not source_id:
|
|
@@ -19,6 +19,27 @@ def _scoped_hash_id(prefix: str, value: str, workspace_id: Optional[str]) -> str
|
|
|
19
19
|
return f"{prefix}:{_sha256_text(identity)[:24]}"
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _triple_edge_metadata(triple: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
"""Edge metadata that keeps the relation's evidence class visible.
|
|
24
|
+
|
|
25
|
+
Review 2026-07-27 P1 #6: a verb-backed relation and a bare co-occurrence
|
|
26
|
+
used to land in the graph identically. ``evidence`` travels with the edge
|
|
27
|
+
so readers (graph views, curator, promotion review) can tell a meaning
|
|
28
|
+
edge from an adjacency edge instead of guessing from the label.
|
|
29
|
+
"""
|
|
30
|
+
metadata: Dict[str, Any] = {"context": str(triple.get("context") or "")[:240]}
|
|
31
|
+
evidence = str(triple.get("evidence") or "")
|
|
32
|
+
if evidence:
|
|
33
|
+
metadata["evidence"] = evidence
|
|
34
|
+
confidence = triple.get("confidence")
|
|
35
|
+
if confidence is not None:
|
|
36
|
+
try:
|
|
37
|
+
metadata["confidence"] = round(float(confidence), 4)
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
pass
|
|
40
|
+
return metadata
|
|
41
|
+
|
|
42
|
+
|
|
22
43
|
class KnowledgeGraphIngestMixin:
|
|
23
44
|
def ingest_message(
|
|
24
45
|
self,
|
|
@@ -168,8 +189,8 @@ class KnowledgeGraphIngestMixin:
|
|
|
168
189
|
subj_id,
|
|
169
190
|
obj_id,
|
|
170
191
|
triple["relation"], # 동사형 레이블
|
|
171
|
-
weight=1.0,
|
|
172
|
-
metadata=
|
|
192
|
+
weight=float(triple.get("weight") or 1.0),
|
|
193
|
+
metadata=_triple_edge_metadata(triple),
|
|
173
194
|
)
|
|
174
195
|
|
|
175
196
|
# ── 7. Task / Decision 노드 (점: 명사) ────────────────────────────
|
|
@@ -342,9 +363,18 @@ class KnowledgeGraphIngestMixin:
|
|
|
342
363
|
chunk = piece["text"]
|
|
343
364
|
chunk_fields = typed_chunk_meta_fields(piece)
|
|
344
365
|
if page_offsets:
|
|
345
|
-
|
|
366
|
+
start_char = chunk_fields["start_char"]
|
|
367
|
+
page = page_for_offset(page_offsets, start_char)
|
|
346
368
|
if page is not None:
|
|
347
369
|
chunk_fields["page"] = page
|
|
370
|
+
# Sentence-aware chunks can straddle a page break, so
|
|
371
|
+
# the start page alone would under-describe them.
|
|
372
|
+
# `page_end` is recorded only when it really differs.
|
|
373
|
+
end_page = page_for_offset(
|
|
374
|
+
page_offsets, max(start_char, start_char + len(chunk) - 1)
|
|
375
|
+
)
|
|
376
|
+
if end_page is not None and end_page > page:
|
|
377
|
+
chunk_fields["page_end"] = end_page
|
|
348
378
|
chunk_id = f"chunk:{_sha256_text(f'{file_id}:{index}:{chunk}')[:24]}"
|
|
349
379
|
chunk_ids.append(chunk_id)
|
|
350
380
|
self._upsert_node(
|
|
@@ -394,8 +424,8 @@ class KnowledgeGraphIngestMixin:
|
|
|
394
424
|
subj_id,
|
|
395
425
|
obj_id,
|
|
396
426
|
triple["relation"],
|
|
397
|
-
weight=1.0,
|
|
398
|
-
metadata=
|
|
427
|
+
weight=float(triple.get("weight") or 1.0),
|
|
428
|
+
metadata=_triple_edge_metadata(triple),
|
|
399
429
|
)
|
|
400
430
|
|
|
401
431
|
# ── Task / Decision 노드 ──────────────────────────────────────────
|
|
@@ -735,8 +765,8 @@ class KnowledgeGraphIngestMixin:
|
|
|
735
765
|
subj_id,
|
|
736
766
|
obj_id,
|
|
737
767
|
triple["relation"],
|
|
738
|
-
weight=1.0,
|
|
739
|
-
metadata=
|
|
768
|
+
weight=float(triple.get("weight") or 1.0),
|
|
769
|
+
metadata=_triple_edge_metadata(triple),
|
|
740
770
|
)
|
|
741
771
|
# ── Task / Decision 노드 ────────────────────────────────────────
|
|
742
772
|
for item in _semantic_items(text):
|
|
@@ -657,6 +657,7 @@ class KnowledgeGraphVectorMixin:
|
|
|
657
657
|
n.type AS node_type, n.title AS node_title, n.summary AS node_summary,
|
|
658
658
|
n.metadata_json AS node_metadata, n.updated_at AS node_updated_at,
|
|
659
659
|
c.text AS chunk_text, c.source_node AS parent_node_id,
|
|
660
|
+
c.metadata_json AS chunk_metadata,
|
|
660
661
|
pn.type AS parent_type, pn.title AS parent_title,
|
|
661
662
|
pn.summary AS parent_summary, pn.metadata_json AS parent_metadata,
|
|
662
663
|
pn.updated_at AS parent_updated_at
|
|
@@ -690,6 +691,13 @@ class KnowledgeGraphVectorMixin:
|
|
|
690
691
|
)
|
|
691
692
|
parent_metadata = _safe_loads(row["parent_metadata"])
|
|
692
693
|
node_metadata = _safe_loads(row["node_metadata"])
|
|
694
|
+
# Citation precision (review 2026-07-27 P1 #4): a chunk hit used to
|
|
695
|
+
# cite only its parent document, so a 200-page PDF answered with
|
|
696
|
+
# "from report.pdf". The chunk's own provenance (section heading,
|
|
697
|
+
# page, offset) now rides along, and `locator` is the one-line
|
|
698
|
+
# human form — absent when the chunk carries no such metadata.
|
|
699
|
+
chunk_metadata = _safe_loads(row["chunk_metadata"]) if is_chunk else {}
|
|
700
|
+
locator = citation_locator(chunk_metadata)
|
|
693
701
|
scored.append(
|
|
694
702
|
{
|
|
695
703
|
"id": row["item_id"],
|
|
@@ -708,6 +716,8 @@ class KnowledgeGraphVectorMixin:
|
|
|
708
716
|
"vector": _safe_loads(row["vector_metadata"]),
|
|
709
717
|
"parent_node_id": row["parent_node_id"],
|
|
710
718
|
"parent_type": row["parent_type"],
|
|
719
|
+
**({"chunk": chunk_metadata} if chunk_metadata else {}),
|
|
720
|
+
**({"locator": locator} if locator else {}),
|
|
711
721
|
},
|
|
712
722
|
"updated_at": row["parent_updated_at"]
|
|
713
723
|
if is_chunk and row["parent_updated_at"]
|