ltcai 9.6.0 → 9.8.0

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 (73) hide show
  1. package/README.md +98 -306
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +91 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  5. package/docs/DEVELOPMENT.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/PERFORMANCE.md +106 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/kg_schema.py +11 -1
  12. package/knowledge_graph.py +12 -2
  13. package/knowledge_graph_api.py +11 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/proactive.py +583 -0
  16. package/lattice_brain/graph/retrieval.py +301 -8
  17. package/lattice_brain/graph/retrieval_vector.py +145 -0
  18. package/lattice_brain/ingestion.py +757 -16
  19. package/lattice_brain/quality.py +44 -9
  20. package/lattice_brain/runtime/multi_agent.py +38 -2
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/brain_intelligence.py +39 -2
  23. package/latticeai/api/change_proposals.py +32 -3
  24. package/latticeai/api/chat.py +17 -0
  25. package/latticeai/api/chat_helpers.py +48 -0
  26. package/latticeai/api/chat_stream.py +9 -1
  27. package/latticeai/api/local_files.py +120 -2
  28. package/latticeai/api/review_queue.py +66 -2
  29. package/latticeai/app_factory.py +3 -0
  30. package/latticeai/core/agent.py +193 -135
  31. package/latticeai/core/agent_eval.py +278 -4
  32. package/latticeai/core/legacy_compatibility.py +15 -1
  33. package/latticeai/core/marketplace.py +1 -1
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/runtime/router_registration.py +2 -0
  36. package/latticeai/services/architecture_readiness.py +1 -1
  37. package/latticeai/services/automation_intelligence.py +151 -14
  38. package/latticeai/services/brain_intelligence.py +169 -1
  39. package/latticeai/services/change_proposals.py +56 -4
  40. package/latticeai/services/product_readiness.py +1 -1
  41. package/latticeai/services/review_queue.py +43 -2
  42. package/llm_router.py +10 -1
  43. package/local_knowledge_api.py +10 -1
  44. package/ltcai_cli.py +11 -1
  45. package/mcp_registry.py +10 -1
  46. package/p_reinforce.py +10 -1
  47. package/package.json +1 -1
  48. package/scripts/brain_quality_eval.py +1 -1
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/profile_kg.py +360 -0
  51. package/setup_wizard.py +10 -1
  52. package/src-tauri/Cargo.lock +1 -1
  53. package/src-tauri/Cargo.toml +1 -1
  54. package/src-tauri/tauri.conf.json +1 -1
  55. package/static/app/asset-manifest.json +11 -11
  56. package/static/app/assets/{Act-BkOEmwBi.js → Act-Dd3z8AzF.js} +2 -1
  57. package/static/app/assets/Brain-BMkgdWnI.js +321 -0
  58. package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
  59. package/static/app/assets/Library-Yreq-KW5.js +1 -0
  60. package/static/app/assets/System-CXNmmtEo.js +1 -0
  61. package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
  62. package/static/app/assets/index-CndfILiF.js +18 -0
  63. package/static/app/assets/primitives-DxsIXb6G.js +1 -0
  64. package/static/app/assets/textarea-DH7ne8VI.js +1 -0
  65. package/static/app/index.html +2 -2
  66. package/static/sw.js +1 -1
  67. package/static/app/assets/Brain-C9ITUsQ_.js +0 -321
  68. package/static/app/assets/Capture-C-ppTeud.js +0 -1
  69. package/static/app/assets/Library-CGQbgWTu.js +0 -1
  70. package/static/app/assets/System-djmj0n2_.js +0 -1
  71. package/static/app/assets/index-AF0-4XVv.js +0 -18
  72. package/static/app/assets/primitives-jbb2qv4Q.js +0 -1
  73. package/static/app/assets/textarea-CFoo0OxJ.js +0 -1
@@ -22,6 +22,14 @@ automation suggestions:
22
22
 
23
23
  Every suggestion id is deterministic, so install calls are idempotent and the
24
24
  UI can safely re-request suggestions without duplicates.
25
+
26
+ v9.8.0 quality layer (additive): every suggestion carries a deterministic
27
+ ``confidence`` score plus its ``confidence_factors`` evidence (repeat count,
28
+ distinct phrasings, intent match, related Brain nodes / indexed files);
29
+ suggestions below a minimum confidence are suppressed, duplicate suggestions
30
+ targeting an already-suggested or already-installed starter recipe are
31
+ deduplicated, and the response reports the suppression counters under
32
+ ``quality``.
25
33
  """
26
34
 
27
35
  from __future__ import annotations
@@ -40,6 +48,50 @@ _MIN_PATTERN_COUNT = 2
40
48
  _MAX_HISTORY = 4000
41
49
  _SIGNATURE_SIMILARITY = 0.6
42
50
 
51
+ # Suggestion quality gates (v9.8.0, additive). Suggestions below the minimum
52
+ # confidence are suppressed (insufficient evidence); suggestions between the
53
+ # minimum and the low-confidence threshold are shown but flagged so the UI
54
+ # can render them less prominently.
55
+ _MIN_SUGGESTION_CONFIDENCE = 0.35
56
+ _LOW_CONFIDENCE_THRESHOLD = 0.5
57
+ _KG_GROUNDING_LIMIT = 5
58
+
59
+
60
+ def _question_confidence(
61
+ count: int,
62
+ examples: List[str],
63
+ recipe_id: Optional[str],
64
+ kg_related: Optional[int],
65
+ ) -> tuple:
66
+ """Deterministic confidence for a recurring-question suggestion.
67
+
68
+ Evidence factors: how often the question repeats, how many distinct
69
+ phrasings exist, whether it maps onto a known starter-recipe intent, and
70
+ (when the graph is available) how many Brain nodes relate to it.
71
+ """
72
+ score = 0.3 + 0.5 * min(1.0, (int(count) - 1) / 4)
73
+ score += min(0.15, 0.05 * len(examples or []))
74
+ if recipe_id:
75
+ score += 0.15
76
+ if kg_related:
77
+ score += min(0.2, 0.05 * int(kg_related))
78
+ factors = {
79
+ "repeat_count": int(count),
80
+ "distinct_examples": len(examples or []),
81
+ "intent_match": bool(recipe_id),
82
+ "kg_related_nodes": kg_related,
83
+ }
84
+ return round(min(1.0, score), 2), factors
85
+
86
+
87
+ def _source_confidence(indexed: int, watch_enabled: bool) -> tuple:
88
+ """Deterministic confidence for a knowledge-source digest suggestion."""
89
+ score = 0.25 + 0.6 * min(1.0, int(indexed) / 25)
90
+ if watch_enabled:
91
+ score += 0.1
92
+ factors = {"indexed_files": int(indexed), "watch_enabled": bool(watch_enabled)}
93
+ return round(min(1.0, score), 2), factors
94
+
43
95
  _QUESTION_HINT_RE = re.compile(
44
96
  r"(\?|어때|뭐야|뭐가|뭘까|알려줘|보여줘|정리해|요약해|정리 좀|요약 좀|해줘"
45
97
  r"|what|how|why|when|where|status|summar|remind|list|show me|tell me)",
@@ -223,38 +275,101 @@ class AutomationIntelligenceService:
223
275
  LOGGER.exception("automation intelligence source read failed")
224
276
  return []
225
277
 
278
+ def _kg_related_count(
279
+ self, text: str, *, workspace_id: Optional[str]
280
+ ) -> Optional[int]:
281
+ """Count Brain nodes related to a recurring question (grounding evidence).
282
+
283
+ Returns ``None`` when the graph is unavailable so confidence scoring can
284
+ distinguish "grounding impossible" from "grounded with zero hits".
285
+ Scoping mirrors the conversation-history read: an explicit workspace is
286
+ strict, no workspace falls back to the unscoped/legacy view.
287
+ """
288
+ if not self._enable_graph or not hasattr(self._kg, "search"):
289
+ return None
290
+ try:
291
+ report = self._kg.search(
292
+ str(text or "")[:200],
293
+ limit=_KG_GROUNDING_LIMIT,
294
+ allowed_workspaces={workspace_id} if workspace_id is not None else None,
295
+ include_legacy_global=workspace_id is None,
296
+ )
297
+ except Exception:
298
+ LOGGER.exception("automation intelligence KG grounding failed")
299
+ return None
300
+ if not isinstance(report, dict):
301
+ return None
302
+ return len(report.get("matches") or [])
303
+
226
304
  # ── suggestions ──────────────────────────────────────────────────────
227
305
 
228
- def _installed_suggestion_ids(self, *, workspace_id: Optional[str]) -> Dict[str, Dict[str, Any]]:
229
- installed: Dict[str, Dict[str, Any]] = {}
306
+ def _installed_workflows(
307
+ self, *, workspace_id: Optional[str]
308
+ ) -> tuple:
309
+ """Map installed automation workflows by suggestion id and recipe id.
310
+
311
+ The recipe map lets a recurring-question suggestion that targets an
312
+ already-installed starter recipe surface as *installed* instead of
313
+ re-suggesting the same automation — the install API is idempotent on
314
+ exactly this provenance, so the read side must agree.
315
+ """
316
+ by_suggestion: Dict[str, Dict[str, Any]] = {}
317
+ by_recipe: Dict[str, Dict[str, Any]] = {}
230
318
  if self._store is None:
231
- return installed
319
+ return by_suggestion, by_recipe
232
320
  try:
233
321
  workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
234
322
  except Exception:
235
323
  LOGGER.exception("automation intelligence workflow read failed")
236
- return installed
324
+ return by_suggestion, by_recipe
237
325
  for workflow in workflows:
238
326
  metadata = (workflow or {}).get("metadata") or {}
239
- suggestion_id = metadata.get("suggestion_id")
240
- if metadata.get("created_from") == "automation_suggestion" and suggestion_id:
241
- installed[str(suggestion_id)] = workflow
242
- return installed
327
+ created_from = metadata.get("created_from")
328
+ if created_from == "automation_suggestion" and metadata.get("suggestion_id"):
329
+ by_suggestion[str(metadata["suggestion_id"])] = workflow
330
+ elif created_from == "brain_automation_recipe" and metadata.get("recipe_id"):
331
+ by_recipe[str(metadata["recipe_id"])] = workflow
332
+ return by_suggestion, by_recipe
243
333
 
244
334
  def suggestions(
245
335
  self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
246
336
  ) -> Dict[str, Any]:
247
337
  pattern_report = self.question_patterns(user_email=user_email, workspace_id=workspace_id)
248
- installed = self._installed_suggestion_ids(workspace_id=workspace_id)
338
+ by_suggestion, by_recipe = self._installed_workflows(workspace_id=workspace_id)
249
339
 
250
340
  items: List[Dict[str, Any]] = []
341
+ suppressed_low_confidence = 0
342
+ suppressed_duplicates = 0
343
+ seen_recipe_ids: set = set()
344
+
345
+ # Patterns arrive sorted by (count, last_asked) desc, so the first
346
+ # suggestion per recipe is the strongest — later ones targeting the
347
+ # same recipe would install the identical workflow and are suppressed.
251
348
  for pattern in pattern_report["patterns"]:
349
+ recipe_id = pattern["recipe_id"]
350
+ if recipe_id and recipe_id in seen_recipe_ids:
351
+ suppressed_duplicates += 1
352
+ continue
353
+ kg_related = self._kg_related_count(
354
+ pattern["representative"], workspace_id=workspace_id
355
+ )
356
+ confidence, factors = _question_confidence(
357
+ pattern["count"], pattern["examples"], recipe_id, kg_related
358
+ )
359
+ if confidence < _MIN_SUGGESTION_CONFIDENCE:
360
+ suppressed_low_confidence += 1
361
+ continue
362
+ if recipe_id:
363
+ seen_recipe_ids.add(recipe_id)
252
364
  suggestion_id = _stable_id("sug-q", pattern["id"])
365
+ workflow = by_suggestion.get(suggestion_id) or (
366
+ by_recipe.get(str(recipe_id)) if recipe_id else None
367
+ )
253
368
  items.append({
254
369
  "id": suggestion_id,
255
370
  "kind": "recurring_question",
256
371
  "intent": pattern["intent"],
257
- "recipe_id": pattern["recipe_id"],
372
+ "recipe_id": recipe_id,
258
373
  "title": pattern["representative"],
259
374
  "reason": {
260
375
  "type": "repeated_question",
@@ -263,8 +378,11 @@ class AutomationIntelligenceService:
263
378
  "examples": pattern["examples"],
264
379
  },
265
380
  "cadence": "daily",
266
- "installed": suggestion_id in installed,
267
- "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
381
+ "confidence": confidence,
382
+ "confidence_factors": factors,
383
+ "low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
384
+ "installed": workflow is not None,
385
+ "workflow_id": (workflow or {}).get("id"),
268
386
  })
269
387
 
270
388
  for source in self._knowledge_sources():
@@ -272,6 +390,14 @@ class AutomationIntelligenceService:
272
390
  indexed = sum(int(v or 0) for v in file_status.values())
273
391
  if indexed <= 0:
274
392
  continue
393
+ confidence, factors = _source_confidence(
394
+ indexed, bool(source.get("watch_enabled"))
395
+ )
396
+ if confidence < _MIN_SUGGESTION_CONFIDENCE:
397
+ # A barely-indexed folder is not enough evidence for a digest
398
+ # automation yet — do not suggest.
399
+ suppressed_low_confidence += 1
400
+ continue
275
401
  suggestion_id = _stable_id("sug-src", str(source.get("id") or source.get("root_path") or ""))
276
402
  items.append({
277
403
  "id": suggestion_id,
@@ -286,13 +412,22 @@ class AutomationIntelligenceService:
286
412
  "watch_enabled": bool(source.get("watch_enabled")),
287
413
  },
288
414
  "cadence": "when new knowledge arrives",
289
- "installed": suggestion_id in installed,
290
- "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
415
+ "confidence": confidence,
416
+ "confidence_factors": factors,
417
+ "low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
418
+ "installed": suggestion_id in by_suggestion,
419
+ "workflow_id": (by_suggestion.get(suggestion_id) or {}).get("id"),
291
420
  })
292
421
 
293
422
  return {
294
423
  "suggestions": items,
295
424
  "questions_scanned": pattern_report["questions_scanned"],
425
+ "quality": {
426
+ "min_confidence": _MIN_SUGGESTION_CONFIDENCE,
427
+ "low_confidence_threshold": _LOW_CONFIDENCE_THRESHOLD,
428
+ "suppressed_low_confidence": suppressed_low_confidence,
429
+ "suppressed_duplicates": suppressed_duplicates,
430
+ },
296
431
  "consent": {
297
432
  "default_state": "draft_disabled",
298
433
  "local_only": True,
@@ -403,6 +538,7 @@ class AutomationIntelligenceService:
403
538
  "suggestion_kind": kind,
404
539
  "suggestion_title": title,
405
540
  "suggestion_reason": suggestion.get("reason"),
541
+ "suggestion_confidence": suggestion.get("confidence"),
406
542
  "automation_state": "enabled" if enabled else "draft_disabled",
407
543
  "local_only": True,
408
544
  "external_actions": False,
@@ -442,6 +578,7 @@ class AutomationIntelligenceService:
442
578
  "suggestions": suggestion_report["suggestions"],
443
579
  "questions_scanned": suggestion_report["questions_scanned"],
444
580
  "installed": installed,
581
+ "quality": suggestion_report["quality"],
445
582
  "consent": suggestion_report["consent"],
446
583
  "generated_at": _now(),
447
584
  }
@@ -65,6 +65,23 @@ class BrainIntelligenceService:
65
65
  self._enable_graph = bool(enable_graph and knowledge_graph is not None)
66
66
  self._memory_quality = MemoryQualityManager()
67
67
  self._edge_quality = GraphEdgeQualityManager()
68
+ self._proactive_brain: Any = None
69
+
70
+ def _proactive(self) -> Any:
71
+ """Lazy graph-layer ProactiveBrain over the injected store (or None)."""
72
+ if not self._enable_graph:
73
+ return None
74
+ if self._proactive_brain is None:
75
+ try:
76
+ from lattice_brain.graph.proactive import ProactiveBrain
77
+
78
+ self._proactive_brain = ProactiveBrain(
79
+ self._kg, sample_limit=_GRAPH_SAMPLE_LIMIT
80
+ )
81
+ except Exception:
82
+ LOGGER.exception("proactive brain initialization failed")
83
+ return None
84
+ return self._proactive_brain
68
85
 
69
86
  # ── shared graph sampling ─────────────────────────────────────────────
70
87
 
@@ -240,6 +257,78 @@ class BrainIntelligenceService:
240
257
  "generated_at": _now(),
241
258
  }
242
259
 
260
+ # ── vector freshness (v9.8.0) ────────────────────────────────────────
261
+
262
+ def vector_freshness(
263
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
264
+ ) -> Dict[str, Any]:
265
+ """Fixed-contract vector index freshness for ``/api/brain/vector-freshness``.
266
+
267
+ Always returns ``{"status": "ready"|"pending"|"unavailable",
268
+ "pending_items": int, "total_items": int, "detail": str}`` and never
269
+ raises. The vector index is store-global (not workspace-partitioned);
270
+ scope arguments are accepted for router symmetry but do not narrow
271
+ the report.
272
+ """
273
+
274
+ def _unavailable(detail: str) -> Dict[str, Any]:
275
+ return {
276
+ "status": "unavailable",
277
+ "pending_items": 0,
278
+ "total_items": 0,
279
+ "detail": detail,
280
+ }
281
+
282
+ if not self._enable_graph or self._kg is None:
283
+ return _unavailable("knowledge graph is disabled; no vector index is configured")
284
+
285
+ freshness_fn = getattr(self._kg, "vector_freshness", None)
286
+ if callable(freshness_fn):
287
+ try:
288
+ raw = freshness_fn() or {}
289
+ except Exception as exc:
290
+ LOGGER.exception("vector freshness read failed")
291
+ return _unavailable(f"vector freshness read failed: {exc}")
292
+ status = str(raw.get("status") or "unavailable")
293
+ if status == "needs_reindex":
294
+ status = "pending"
295
+ if status not in {"ready", "pending", "unavailable"}:
296
+ status = "unavailable"
297
+ return {
298
+ "status": status,
299
+ "pending_items": int(raw.get("pending_items") or 0),
300
+ "total_items": int(raw.get("total_items") or 0),
301
+ "detail": str(raw.get("detail") or ""),
302
+ }
303
+
304
+ # Older/lighter stores: summarize index_status directly.
305
+ status_fn = getattr(self._kg, "index_status", None)
306
+ if callable(status_fn):
307
+ try:
308
+ raw = status_fn() or {}
309
+ except Exception as exc:
310
+ LOGGER.exception("vector index status read failed")
311
+ return _unavailable(f"vector index status unavailable: {exc}")
312
+ pending = int(raw.get("pending_items") or 0)
313
+ total = int(raw.get("source_items") or 0)
314
+ if pending > 0:
315
+ return {
316
+ "status": "pending",
317
+ "pending_items": pending,
318
+ "total_items": total,
319
+ "detail": (
320
+ f"{pending} of {total} items are missing or stale in the vector index"
321
+ ),
322
+ }
323
+ return {
324
+ "status": "ready",
325
+ "pending_items": 0,
326
+ "total_items": total,
327
+ "detail": "vector index is up to date",
328
+ }
329
+
330
+ return _unavailable("this knowledge store does not expose a vector index")
331
+
243
332
  # ── insights digest ──────────────────────────────────────────────────
244
333
 
245
334
  def insights(
@@ -302,6 +391,56 @@ class BrainIntelligenceService:
302
391
  "generated_at": _now(),
303
392
  }
304
393
 
394
+ # ── graph-layer proactive quality (v9.6.x) ───────────────────────────
395
+
396
+ def graph_duplicates(
397
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
398
+ ) -> Dict[str, Any]:
399
+ """Duplicate graph nodes (exact groups + near pairs) — read only."""
400
+ proactive = self._proactive()
401
+ if proactive is None:
402
+ return {
403
+ "available": False,
404
+ "exact_groups": [],
405
+ "near_pairs": [],
406
+ "exact_duplicate_nodes": 0,
407
+ "nodes_scanned": 0,
408
+ "generated_at": _now(),
409
+ }
410
+ try:
411
+ result = dict(proactive.find_duplicates(workspace_id=workspace_id))
412
+ except Exception as exc:
413
+ LOGGER.exception("graph duplicates scan failed")
414
+ return {
415
+ "available": False,
416
+ "error": str(exc),
417
+ "exact_groups": [],
418
+ "near_pairs": [],
419
+ "exact_duplicate_nodes": 0,
420
+ "nodes_scanned": 0,
421
+ "generated_at": _now(),
422
+ }
423
+ result["available"] = True
424
+ result["generated_at"] = _now()
425
+ return result
426
+
427
+ def quality_report(
428
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
429
+ ) -> Dict[str, Any]:
430
+ """Combined graph quality report: duplicates, contradictions, stale
431
+ nodes, edge quality — one workspace-scoped graph sample."""
432
+ proactive = self._proactive()
433
+ if proactive is None:
434
+ return {"available": False, "generated_at": _now()}
435
+ try:
436
+ result = dict(proactive.quality_report(workspace_id=workspace_id))
437
+ except Exception as exc:
438
+ LOGGER.exception("graph quality report failed")
439
+ return {"available": False, "error": str(exc), "generated_at": _now()}
440
+ result["available"] = True
441
+ result["generated_at"] = _now()
442
+ return result
443
+
305
444
  # ── contradictions ───────────────────────────────────────────────────
306
445
 
307
446
  def contradictions(
@@ -365,7 +504,21 @@ class BrainIntelligenceService:
365
504
  "signal": "contradicts_edge",
366
505
  })
367
506
 
368
- items = conflicts + temporal_items + edge_items
507
+ # v9.6.x additive: graph-layer node-content contradictions (proactive
508
+ # detector over node title/summary), on top of the memory + edge scans.
509
+ graph_pair_items: List[Dict[str, Any]] = []
510
+ proactive = self._proactive()
511
+ if proactive is not None:
512
+ try:
513
+ graph_result = proactive.detect_contradictions(workspace_id=workspace_id)
514
+ graph_pair_items = [
515
+ {"kind": "graph_node_pair", **pair}
516
+ for pair in graph_result.get("node_pairs") or []
517
+ ]
518
+ except Exception:
519
+ LOGGER.exception("graph contradiction scan failed")
520
+
521
+ items = conflicts + temporal_items + edge_items + graph_pair_items
369
522
  return {
370
523
  "items": items,
371
524
  "count": len(items),
@@ -373,6 +526,7 @@ class BrainIntelligenceService:
373
526
  "memory_pairs": len(conflicts),
374
527
  "temporal": len(temporal_items),
375
528
  "graph_edges": len(edge_items),
529
+ "graph_node_pairs": len(graph_pair_items),
376
530
  },
377
531
  "memories_scanned": len(memory_rows),
378
532
  "generated_at": _now(),
@@ -422,6 +576,19 @@ class BrainIntelligenceService:
422
576
  except Exception:
423
577
  LOGGER.exception("consolidation prune failed")
424
578
 
579
+ # v9.6.x additive: graph-layer node merge plan. Always dry-run from
580
+ # this service — graph content changes stay proposal-first; the plan
581
+ # is surfaced so a governed apply path can adopt it later.
582
+ graph_consolidation: Optional[Dict[str, Any]] = None
583
+ proactive = self._proactive()
584
+ if proactive is not None:
585
+ try:
586
+ graph_consolidation = proactive.consolidate_duplicates(
587
+ workspace_id=workspace_id, dry_run=True
588
+ )
589
+ except Exception:
590
+ LOGGER.exception("graph consolidation plan failed")
591
+
425
592
  return {
426
593
  "mode": "applied" if apply else "dry_run",
427
594
  "memories_scanned": len(memory_rows),
@@ -432,6 +599,7 @@ class BrainIntelligenceService:
432
599
  # mutates graph content directly.
433
600
  "duplicate_edges": duplicate_edge_ids[:50],
434
601
  "duplicate_edge_count": len(duplicate_edge_ids),
602
+ "graph_consolidation": graph_consolidation,
435
603
  "generated_at": _now(),
436
604
  }
437
605
 
@@ -71,6 +71,7 @@ class ChangeProposalService:
71
71
  policy: Optional[Dict[str, Any]] = None,
72
72
  user_email: Optional[str] = None,
73
73
  workspace_id: Optional[str] = None,
74
+ conversation_id: Optional[str] = None,
74
75
  ) -> Optional[Dict[str, Any]]:
75
76
  """Governor port for the agent loop.
76
77
 
@@ -102,6 +103,15 @@ class ChangeProposalService:
102
103
  reason=verdict["reason"],
103
104
  user_email=user_email,
104
105
  workspace_id=workspace_id,
106
+ context={
107
+ # Full provenance for the Review Center: which tool asked,
108
+ # how the governor classified it, and the policy risk.
109
+ "tool": name,
110
+ "change_class": verdict.get("change_class"),
111
+ "risk": (policy or {}).get("risk"),
112
+ "conversation_id": conversation_id,
113
+ "source_detail": "agent change governor",
114
+ },
105
115
  )
106
116
  except Exception:
107
117
  LOGGER.exception("change proposal staging failed")
@@ -152,10 +162,15 @@ class ChangeProposalService:
152
162
  reason: str = "",
153
163
  user_email: Optional[str] = None,
154
164
  workspace_id: Optional[str] = None,
165
+ context: Optional[Dict[str, Any]] = None,
155
166
  ) -> Dict[str, Any]:
156
167
  before = self._read_before(path)
157
168
  diff = _unified_diff(before, new_content, path)
158
169
  tier = "small" if len(diff) <= _SMALL_TIER_DIFF_LINES else "large"
170
+ provenance: Dict[str, Any] = {"proposed_by": proposed_by, "reason": reason}
171
+ for key, value in (context or {}).items():
172
+ if value is not None and key not in provenance:
173
+ provenance[key] = value
159
174
  item = self._review_queue.create(
160
175
  title=f"파일 수정 제안: {path}",
161
176
  summary=reason or "기존 파일을 변경하는 작업이라 검토 후 적용됩니다.",
@@ -169,7 +184,7 @@ class ChangeProposalService:
169
184
  "before_bytes": len(before.encode("utf-8")),
170
185
  "after_bytes": len(new_content.encode("utf-8")),
171
186
  },
172
- provenance={"proposed_by": proposed_by, "reason": reason},
187
+ provenance=provenance,
173
188
  user_email=user_email,
174
189
  workspace_id=workspace_id,
175
190
  )
@@ -232,11 +247,36 @@ class ChangeProposalService:
232
247
  },
233
248
  }
234
249
 
250
+ def counts(
251
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
252
+ ) -> Dict[str, Any]:
253
+ """Pending proposal count for the Review Center badge."""
254
+ listed = self._review_queue.list(
255
+ workspace_id=workspace_id, user_email=user_email,
256
+ status="pending", source="change_proposal",
257
+ )
258
+ return {"pending": len(listed.get("items") or [])}
259
+
260
+ def get_proposal(
261
+ self, item_id: str, *, workspace_id: Optional[str] = None
262
+ ) -> Dict[str, Any]:
263
+ """Full proposal detail (diff + staged content) for the preview UI.
264
+
265
+ Raises ``KeyError`` when the id exists but is not a change proposal,
266
+ so the API surface cannot leak arbitrary review items.
267
+ """
268
+ item = self._review_queue.get(item_id, workspace_id=workspace_id)
269
+ if item.get("source") != "change_proposal":
270
+ raise KeyError(f"not a change proposal: {item_id}")
271
+ return item
272
+
235
273
  def approve_and_apply(
236
274
  self, item_id: str, *, user_email: Optional[str] = None,
237
275
  workspace_id: Optional[str] = None,
238
276
  ) -> Dict[str, Any]:
239
277
  item = self._review_queue.get(item_id, workspace_id=workspace_id)
278
+ if item.get("source") != "change_proposal":
279
+ raise KeyError(f"not a change proposal: {item_id}")
240
280
  payload = item.get("payload") or {}
241
281
  kind = item.get("kind")
242
282
  path = str(payload.get("path") or "")
@@ -258,13 +298,25 @@ class ChangeProposalService:
258
298
 
259
299
  def reject(
260
300
  self, item_id: str, *, user_email: Optional[str] = None,
261
- workspace_id: Optional[str] = None,
301
+ workspace_id: Optional[str] = None, reason: str = "",
262
302
  ) -> Dict[str, Any]:
263
- dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
303
+ self.get_proposal(item_id, workspace_id=workspace_id) # source guard
304
+ reason = str(reason or "").strip()[:500]
305
+ if reason:
306
+ try:
307
+ dismissed = self._review_queue.dismiss(
308
+ item_id, workspace_id=workspace_id, reason=reason
309
+ )
310
+ except TypeError:
311
+ # Older/fake queues without reason support still dismiss.
312
+ dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
313
+ else:
314
+ dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
264
315
  self._audit(
265
316
  "change_proposal_rejected", user_email=user_email, proposal_id=item_id,
317
+ reason=reason or None,
266
318
  )
267
- return {"item": dismissed, "applied": False}
319
+ return {"item": dismissed, "applied": False, "reason": reason}
268
320
 
269
321
 
270
322
  __all__ = ["ChangeProposalService"]
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.6.0"
21
+ PRODUCT_VERSION_TARGET = "9.8.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -141,8 +141,49 @@ class ReviewQueueService:
141
141
  updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
142
142
  return self._view(updated)
143
143
 
144
- def dismiss(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
145
- return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
144
+ def dismiss(
145
+ self, item_id: str, *, workspace_id: Optional[str] = None,
146
+ reason: Optional[str] = None,
147
+ ) -> Dict[str, Any]:
148
+ """Dismiss an item; an optional ``reason`` is kept in provenance.
149
+
150
+ The reason matters most for ``change_proposal`` items — the review
151
+ timeline doubles as the change audit log, so "why was this rejected"
152
+ should survive next to the staged diff.
153
+ """
154
+ if not reason:
155
+ return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
156
+ item = self._store.get_review_item(item_id, workspace_id=workspace_id)
157
+ self._guard("dismiss", item)
158
+ provenance = dict(item.get("provenance") or {})
159
+ provenance["dismiss_reason"] = str(reason)[:500]
160
+ patch: Dict[str, Any] = {"status": "dismissed", "provenance": provenance}
161
+ if item.get("snoozed_until") is not None:
162
+ patch["snoozed_until"] = None
163
+ updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
164
+ return self._view(updated)
165
+
166
+ def counts(
167
+ self, *, workspace_id: Optional[str] = None, user_email: Optional[str] = None,
168
+ ) -> Dict[str, Any]:
169
+ """Badge-friendly effective-status counts (pending includes expired snoozes)."""
170
+ items = [
171
+ self._view(it)
172
+ for it in self._store.list_review_items(
173
+ workspace_id=workspace_id, user_email=user_email,
174
+ )
175
+ ]
176
+ pending = [it for it in items if it["effective_status"] == "pending"]
177
+ snoozed = [it for it in items if it["effective_status"] == "snoozed"]
178
+ by_source: Dict[str, int] = {}
179
+ for it in pending:
180
+ source = str(it.get("source") or "workflow_run")
181
+ by_source[source] = by_source.get(source, 0) + 1
182
+ return {
183
+ "pending": len(pending),
184
+ "snoozed": len(snoozed),
185
+ "pending_by_source": by_source,
186
+ }
146
187
 
147
188
  def snooze(
148
189
  self, item_id: str, *, until: str, workspace_id: Optional[str] = None,
package/llm_router.py CHANGED
@@ -5,7 +5,16 @@ monkeypatching keep working through the old import path.
5
5
  """
6
6
 
7
7
  import sys
8
+ import warnings
8
9
 
9
- import latticeai.models.router as _impl
10
+ warnings.warn(
11
+ "Importing 'llm_router' from the repository root is deprecated; "
12
+ "use 'import latticeai.models.router' instead. "
13
+ "The root shim will be removed in a future major release.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+
18
+ import latticeai.models.router as _impl # noqa: E402
10
19
 
11
20
  sys.modules[__name__] = _impl
@@ -1,7 +1,16 @@
1
1
  """Compatibility shim for :mod:`latticeai.services.local_knowledge`."""
2
2
 
3
3
  import sys
4
+ import warnings
4
5
 
5
- from latticeai.services import local_knowledge as _impl
6
+ warnings.warn(
7
+ "Importing 'local_knowledge_api' from the repository root is deprecated; "
8
+ "use 'from latticeai.services import local_knowledge' instead. "
9
+ "The root shim will be removed in a future major release.",
10
+ DeprecationWarning,
11
+ stacklevel=2,
12
+ )
13
+
14
+ from latticeai.services import local_knowledge as _impl # noqa: E402
6
15
 
7
16
  sys.modules[__name__] = _impl