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.
Files changed (132) hide show
  1. package/README.md +41 -36
  2. package/docs/CHANGELOG.md +193 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +38 -8
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +5 -3
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -29
  78. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  79. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  80. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  81. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  82. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  83. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  84. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  85. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  86. package/static/app/assets/Library-BVtrSt12.js +1 -0
  87. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  88. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  89. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  90. package/static/app/assets/System-B4nQDy8a.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-DtKkLfXj.js} +1 -1
  92. package/static/app/assets/brain-wVV63_1z.js +1 -0
  93. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
  97. package/static/app/assets/download-EJS5_O0f.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-BFnwi1Lu.js} +1 -1
  100. package/static/app/assets/index-BFtDYI6l.js +10 -0
  101. package/static/app/assets/index-BY9bpxyx.css +2 -0
  102. package/static/app/assets/input-DEz5UjBz.js +1 -0
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-zKa3jAwl.js} +1 -1
  104. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  105. package/static/app/assets/search-ku6-_6wY.js +1 -0
  106. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  107. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-CcVlXlBd.js} +1 -1
  110. package/static/app/assets/users-C0cFMj6o.js +1 -0
  111. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  112. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
  123. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  124. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  125. package/static/app/assets/System-DSR2zKWd.js +0 -1
  126. package/static/app/assets/index-7FAfYm4v.css +0 -2
  127. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  128. package/static/app/assets/input-CSi6OBJ9.js +0 -1
  129. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  130. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  131. package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
  132. package/static/app/assets/utils-BqoznzGP.js +0 -7
@@ -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 — workspace semantic memories (preferences/decisions/…)
120
- 2. knowledge hybrid search over the brain (the product's own engine)
121
- 3. notes garden-note context
122
- 4. recent the user's recent exchange
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
- _CHUNK_STRATEGIES = {"plain", "markdown", "code"}
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, ``"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
+ 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 "markdown" in str(content_type or "").strip().lower():
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": str(item.get("relation", "관련됨")),
663
+ "relation": relation,
545
664
  "object": str(item["object"]),
546
- "context": str(item.get("evidence", ""))[:240],
547
- "confidence": float(item.get("confidence", 0.8)),
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
- def _infer_edge(sentence: str) -> str:
824
- """Return the best-matching verb-form edge label for a sentence."""
825
- s = sentence.lower()
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
- return label
829
- return "관련됨"
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
- edge = _infer_edge(sent)
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={"context": triple.get("context", "")[:240]},
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
- page = page_for_offset(page_offsets, chunk_fields["start_char"])
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={"context": triple.get("context", "")[:240]},
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={"context": triple.get("context", "")[:240]},
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"]