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.
- package/README.md +98 -306
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +91 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/PERFORMANCE.md +106 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/kg_schema.py +11 -1
- package/knowledge_graph.py +12 -2
- package/knowledge_graph_api.py +11 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +583 -0
- package/lattice_brain/graph/retrieval.py +301 -8
- package/lattice_brain/graph/retrieval_vector.py +145 -0
- package/lattice_brain/ingestion.py +757 -16
- package/lattice_brain/quality.py +44 -9
- package/lattice_brain/runtime/multi_agent.py +38 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +39 -2
- package/latticeai/api/change_proposals.py +32 -3
- package/latticeai/api/chat.py +17 -0
- package/latticeai/api/chat_helpers.py +48 -0
- package/latticeai/api/chat_stream.py +9 -1
- package/latticeai/api/local_files.py +120 -2
- package/latticeai/api/review_queue.py +66 -2
- package/latticeai/app_factory.py +3 -0
- package/latticeai/core/agent.py +193 -135
- package/latticeai/core/agent_eval.py +278 -4
- package/latticeai/core/legacy_compatibility.py +15 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/router_registration.py +2 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +151 -14
- package/latticeai/services/brain_intelligence.py +169 -1
- package/latticeai/services/change_proposals.py +56 -4
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +43 -2
- package/llm_router.py +10 -1
- package/local_knowledge_api.py +10 -1
- package/ltcai_cli.py +11 -1
- package/mcp_registry.py +10 -1
- package/p_reinforce.py +10 -1
- package/package.json +1 -1
- package/scripts/brain_quality_eval.py +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/profile_kg.py +360 -0
- package/setup_wizard.py +10 -1
- 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 +11 -11
- package/static/app/assets/{Act-BkOEmwBi.js → Act-Dd3z8AzF.js} +2 -1
- package/static/app/assets/Brain-BMkgdWnI.js +321 -0
- package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
- package/static/app/assets/Library-Yreq-KW5.js +1 -0
- package/static/app/assets/System-CXNmmtEo.js +1 -0
- package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
- package/static/app/assets/index-CndfILiF.js +18 -0
- package/static/app/assets/primitives-DxsIXb6G.js +1 -0
- package/static/app/assets/textarea-DH7ne8VI.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Brain-C9ITUsQ_.js +0 -321
- package/static/app/assets/Capture-C-ppTeud.js +0 -1
- package/static/app/assets/Library-CGQbgWTu.js +0 -1
- package/static/app/assets/System-djmj0n2_.js +0 -1
- package/static/app/assets/index-AF0-4XVv.js +0 -18
- package/static/app/assets/primitives-jbb2qv4Q.js +0 -1
- 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
|
|
229
|
-
|
|
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
|
|
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
|
|
324
|
+
return by_suggestion, by_recipe
|
|
237
325
|
for workflow in workflows:
|
|
238
326
|
metadata = (workflow or {}).get("metadata") or {}
|
|
239
|
-
|
|
240
|
-
if
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
"
|
|
267
|
-
"
|
|
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
|
-
"
|
|
290
|
-
"
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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"]
|
|
@@ -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(
|
|
145
|
-
|
|
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
|
-
|
|
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
|
package/local_knowledge_api.py
CHANGED
|
@@ -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
|
-
|
|
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
|