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
@@ -142,6 +142,46 @@ class RerankerInterface:
142
142
  # -----------------------------
143
143
  # 3. Memory Candidate Extraction / Scoring / Dedupe / Merge / Conflict / Retention
144
144
  # -----------------------------
145
+
146
+ # Public content-signature helpers (v9.6.x graph-layer proactive seam).
147
+ # Extracted from MemoryQualityManager so the graph layer
148
+ # (lattice_brain.graph.proactive) and future ingestion gating can reuse the
149
+ # exact same dedupe semantics without instantiating the manager. Behaviour is
150
+ # byte-for-byte identical to the pre-existing private logic.
151
+
152
+ _SIGNATURE_STOPWORDS = {
153
+ "a", "an", "and", "for", "i", "is", "it", "mode", "the", "to",
154
+ "user", "users", "does", "do", "not", "like", "likes", "prefer",
155
+ "prefers", "want", "wants",
156
+ }
157
+
158
+
159
+ def dedupe_key(content: str) -> str:
160
+ """Stable near-exact signature for a piece of content.
161
+
162
+ sha256 prefix over the normalized text head plus a coarse length bucket —
163
+ the same key ``MemoryQualityManager.dedupe`` uses to collapse duplicates.
164
+ Two texts with the same key are treated as exact/near-exact duplicates.
165
+ """
166
+ text = str(content or "")
167
+ norm = " ".join(text.lower().split())[:200]
168
+ return hashlib.sha256((norm + f"|{len(text)//50}").encode()).hexdigest()[:16]
169
+
170
+
171
+ def content_signature(content: str) -> set:
172
+ """Token-set signature (stopword-filtered) for overlap/jaccard comparison.
173
+
174
+ Mirrors ``MemoryQualityManager._content_signature`` — kept public so
175
+ graph-layer duplicate/contradiction detection shares one definition.
176
+ """
177
+ tokens = set(re.findall(r"\w+", str(content or "").lower()))
178
+ return {
179
+ token
180
+ for token in tokens
181
+ if len(token) > 2 and token not in _SIGNATURE_STOPWORDS
182
+ }
183
+
184
+
145
185
  @dataclass
146
186
  class MemoryCandidate:
147
187
  id: str
@@ -193,8 +233,7 @@ class MemoryQualityManager:
193
233
  kept = []
194
234
  seen = set()
195
235
  for c in cands:
196
- norm = " ".join(c.content.lower().split())[:200]
197
- h = hashlib.sha256((norm + f"|{len(c.content)//50}").encode()).hexdigest()[:16]
236
+ h = dedupe_key(c.content)
198
237
  if h not in seen:
199
238
  seen.add(h)
200
239
  kept.append(c)
@@ -247,13 +286,7 @@ class MemoryQualityManager:
247
286
  return any(pattern in lowered for pattern in self._POSITIVE_PATTERNS)
248
287
 
249
288
  def _content_signature(self, content: str) -> set[str]:
250
- stopwords = {
251
- "a", "an", "and", "for", "i", "is", "it", "mode", "the", "to",
252
- "user", "users", "does", "do", "not", "like", "likes", "prefer",
253
- "prefers", "want", "wants",
254
- }
255
- tokens = set(re.findall(r"\w+", content.lower()))
256
- return {token for token in tokens if len(token) > 2 and token not in stopwords}
289
+ return content_signature(content)
257
290
 
258
291
  # --- Large candidate #4 slice: proactive / temporal contradiction detection ---
259
292
  def detect_temporal_contradictions(self, memories: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -516,6 +549,8 @@ class LatticeBrainQuality:
516
549
  __all__ = [
517
550
  "BM25Scorer",
518
551
  "ContextGuardrails",
552
+ "content_signature",
553
+ "dedupe_key",
519
554
  "EmbeddingFallbackLabeller",
520
555
  "EmbeddingLabel",
521
556
  "GraphEdgeQuality",
@@ -10,6 +10,33 @@ the operational objects first-class: handoffs, context packets, review/retry
10
10
  history, replayable timeline events, and explicit planning records. The default
11
11
  runner is still deterministic and LLM-free so tests, local demos, and Community
12
12
  installations can exercise the full Planner -> Executor -> Reviewer loop.
13
+
14
+ Consistency with the single-agent harness (latticeai.core.agent)
15
+ ----------------------------------------------------------------
16
+ Both runtimes expose the shared ``agent-run-contract/v1`` envelope
17
+ (:mod:`.contracts`), and every terminal status this orchestrator emits
18
+ (``ok`` / ``retried_ok`` / ``failed``) is a member of
19
+ ``statuses.RUN_TERMINAL_STATUSES``. Three differences are intentional design,
20
+ not drift:
21
+
22
+ * **Tool dispatch** — this orchestrator is pure and never executes tools
23
+ itself. Tool work reachable from a multi-agent step flows through the
24
+ *injected* ``workflow_runner`` / ``plugin_runner`` seams (wired in
25
+ ``latticeai.services.platform_runtime``), and those seams route every call
26
+ through the same shared ``hooks.dispatch_tool`` pre_tool/post_tool lifecycle
27
+ the single-agent loop uses.
28
+ * **Change governance** — the single-agent loop stages mutations of existing
29
+ content as review proposals (``AgentDeps.change_governor``); the injected
30
+ workflow tool node instead pauses non-auto-approve tools into
31
+ ``awaiting_approval`` (``ApprovalRequired``). Different mechanisms, same
32
+ fail-closed outcome: no unapproved mutation executes from either runtime.
33
+ * **Tracing** — the single-agent loop records a ``LoopTrace`` event stream;
34
+ this runtime records replayable ``timeline`` events. Both surface uniformly
35
+ as the contract's ``timeline``.
36
+
37
+ Run-level ``pre_run`` / ``post_run`` hooks fire in
38
+ ``lattice_brain.runtime.agent_runtime.AgentRuntime``, which wraps this
39
+ orchestrator for the product ``/agents`` surface.
13
40
  """
14
41
 
15
42
  from __future__ import annotations
@@ -21,7 +48,7 @@ from .contracts import multi_agent_contract
21
48
  from ..utils import now_iso as _now
22
49
 
23
50
 
24
- MULTI_AGENT_VERSION = "9.6.0"
51
+ MULTI_AGENT_VERSION = "9.8.0"
25
52
 
26
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
27
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -742,7 +769,16 @@ class MultiAgentOrchestrator:
742
769
  # let downstream roles review stale or missing output and could
743
770
  # incorrectly convert a failed run into an approved one.
744
771
  if str(role_result.get("status") or "").lower() == "error":
745
- reason = str(role_result.get("error") or f"{role} role failed")
772
+ # Both role-error result shapes are honored: a raised exception
773
+ # (``_run_role``) carries ``error``; an llm_role_runner failure
774
+ # carries ``reason`` (with the raw model output preserved in
775
+ # ``ctx.review``). Either way the terminal timeline event names
776
+ # the real cause instead of a generic placeholder.
777
+ reason = str(
778
+ role_result.get("error")
779
+ or role_result.get("reason")
780
+ or f"{role} role failed"
781
+ )
746
782
  existing_review = dict(ctx.review or {})
747
783
  ctx.review = existing_review or {
748
784
  "outcome": "reject",
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.6.0"
3
+ __version__ = "9.8.0"
@@ -18,6 +18,15 @@ from latticeai.services.brain_intelligence import BrainIntelligenceService
18
18
 
19
19
  class ConsolidateRequest(BaseModel):
20
20
  apply: bool = False
21
+ # v9.6.x additive alias: dry_run=true means apply=false. When provided it
22
+ # takes precedence over ``apply`` (explicit intent wins); omitted keeps the
23
+ # v9.3.0 contract unchanged. Default behaviour is always a dry run.
24
+ dry_run: Optional[bool] = None
25
+
26
+ def effective_apply(self) -> bool:
27
+ if self.dry_run is not None:
28
+ return not self.dry_run
29
+ return self.apply
21
30
 
22
31
 
23
32
  def create_brain_intelligence_router(
@@ -48,11 +57,39 @@ def create_brain_intelligence_router(
48
57
  scope = gate_read(request)
49
58
  return service.contradictions(user_email=user, workspace_id=scope)
50
59
 
60
+ @router.get("/api/brain/vector-freshness")
61
+ async def brain_vector_freshness(request: Request):
62
+ """Vector index freshness summary (read-only, never raises).
63
+
64
+ Fixed contract consumed by the frontend:
65
+ ``{"status": "ready"|"pending"|"unavailable", "pending_items": int,
66
+ "total_items": int, "detail": str}``.
67
+ """
68
+ user = require_user(request)
69
+ scope = gate_read(request)
70
+ return service.vector_freshness(user_email=user, workspace_id=scope)
71
+
72
+ @router.get("/api/brain/duplicates")
73
+ async def brain_duplicates(request: Request):
74
+ """Graph-layer duplicate node candidates (read-only)."""
75
+ user = require_user(request)
76
+ scope = gate_read(request)
77
+ return service.graph_duplicates(user_email=user, workspace_id=scope)
78
+
79
+ @router.get("/api/brain/quality-report")
80
+ async def brain_quality_report(request: Request):
81
+ """Combined graph quality report: duplicates, contradictions, stale
82
+ nodes, edge quality (read-only)."""
83
+ user = require_user(request)
84
+ scope = gate_read(request)
85
+ return service.quality_report(user_email=user, workspace_id=scope)
86
+
51
87
  @router.post("/api/brain/consolidate")
52
88
  async def brain_consolidate(req: ConsolidateRequest, request: Request):
53
89
  user = require_user(request)
54
- scope = gate_write(request) if req.apply else gate_read(request)
55
- result = service.consolidate(apply=req.apply, user_email=user, workspace_id=scope)
90
+ apply = req.effective_apply()
91
+ scope = gate_write(request) if apply else gate_read(request)
92
+ result = service.consolidate(apply=apply, user_email=user, workspace_id=scope)
56
93
  append_audit_event(
57
94
  "brain_consolidate",
58
95
  user_email=user,
@@ -13,10 +13,17 @@ from __future__ import annotations
13
13
  from typing import Callable, Optional
14
14
 
15
15
  from fastapi import APIRouter, HTTPException, Request
16
+ from pydantic import BaseModel
16
17
 
17
18
  from latticeai.services.change_proposals import ChangeProposalService
18
19
 
19
20
 
21
+ class RejectProposalRequest(BaseModel):
22
+ """Optional rejection context — kept in the item's provenance."""
23
+
24
+ reason: str = ""
25
+
26
+
20
27
  def create_change_proposals_router(
21
28
  *,
22
29
  service: ChangeProposalService,
@@ -32,6 +39,23 @@ def create_change_proposals_router(
32
39
  scope = gate_read(request)
33
40
  return service.pending(user_email=user, workspace_id=scope)
34
41
 
42
+ # NOTE: declared before /api/proposals/{item_id} so "counts" never
43
+ # resolves as an item id.
44
+ @router.get("/api/proposals/counts")
45
+ async def proposal_counts(request: Request):
46
+ user = require_user(request)
47
+ scope = gate_read(request)
48
+ return service.counts(user_email=user, workspace_id=scope)
49
+
50
+ @router.get("/api/proposals/{item_id}")
51
+ async def get_proposal(item_id: str, request: Request):
52
+ require_user(request)
53
+ scope = gate_read(request)
54
+ try:
55
+ return service.get_proposal(item_id, workspace_id=scope)
56
+ except (KeyError, FileNotFoundError) as exc:
57
+ raise HTTPException(status_code=404, detail=str(exc))
58
+
35
59
  @router.post("/api/proposals/{item_id}/approve")
36
60
  async def approve_proposal(item_id: str, request: Request):
37
61
  user = require_user(request)
@@ -46,15 +70,20 @@ def create_change_proposals_router(
46
70
  raise HTTPException(status_code=400, detail=str(exc))
47
71
 
48
72
  @router.post("/api/proposals/{item_id}/reject")
49
- async def reject_proposal(item_id: str, request: Request):
73
+ async def reject_proposal(
74
+ item_id: str, request: Request, req: Optional[RejectProposalRequest] = None
75
+ ):
50
76
  user = require_user(request)
51
77
  scope = gate_write(request)
52
78
  try:
53
- return service.reject(item_id, user_email=user, workspace_id=scope)
79
+ return service.reject(
80
+ item_id, user_email=user, workspace_id=scope,
81
+ reason=(req.reason if req else ""),
82
+ )
54
83
  except (KeyError, FileNotFoundError) as exc:
55
84
  raise HTTPException(status_code=404, detail=str(exc))
56
85
 
57
86
  return router
58
87
 
59
88
 
60
- __all__ = ["create_change_proposals_router"]
89
+ __all__ = ["create_change_proposals_router", "RejectProposalRequest"]
@@ -28,6 +28,7 @@ from latticeai.api.chat_documents import (
28
28
  )
29
29
  from latticeai.api.chat_helpers import (
30
30
  _LANG_HINT,
31
+ build_context_quality,
31
32
  build_recent_chat_context,
32
33
  detect_language,
33
34
  file_action_target,
@@ -64,6 +65,7 @@ __all__ = [
64
65
  "AgentResumeRequest",
65
66
  "ChatRequest",
66
67
  "create_chat_router",
68
+ "build_context_quality",
67
69
  "build_recent_chat_context",
68
70
  "pair_user_history",
69
71
  "detect_language",
@@ -358,6 +360,19 @@ def create_chat_router(context: AppContext) -> APIRouter:
358
360
  if context_trace is not None and isinstance(trace_seed, dict):
359
361
  trace_seed["context_assembly"] = context_trace
360
362
 
363
+ # v9.8.0 honest RAG signal: how well the graph grounded this answer.
364
+ # Rides the same channel as sources/evidence (the answer trace) and is
365
+ # additionally exposed top-level on both response shapes below.
366
+ context_quality = build_context_quality(
367
+ req.message,
368
+ knowledge_graph=context.knowledge_graph
369
+ if (context.enable_graph and context.knowledge_graph)
370
+ else None,
371
+ allowed_workspaces={workspace_id} if workspace_id else None,
372
+ )
373
+ if isinstance(trace_seed, dict):
374
+ trace_seed["context_quality"] = context_quality
375
+
361
376
  history_message = (
362
377
  f"{req.message}\n[Image attached]" if req.image_data else req.message
363
378
  )
@@ -407,6 +422,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
407
422
  history_meta=history_meta,
408
423
  model_id=selected_model_id,
409
424
  workspace_id=workspace_id,
425
+ context_quality=context_quality,
410
426
  ),
411
427
  media_type="text/event-stream",
412
428
  headers={"X-Model": selected_model_id},
@@ -462,6 +478,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
462
478
  "response": response_text,
463
479
  "trace_id": trace_record["id"],
464
480
  "trace": trace_record,
481
+ "context_quality": context_quality,
465
482
  }
466
483
  )
467
484
 
@@ -197,6 +197,54 @@ def format_network_status(info: Dict) -> str:
197
197
  lines.extend(["", note])
198
198
  return "\n".join(lines)
199
199
 
200
+ def build_context_quality(
201
+ query: str,
202
+ *,
203
+ knowledge_graph=None,
204
+ allowed_workspaces=None,
205
+ limit: int = 6,
206
+ ) -> Dict[str, object]:
207
+ """Honest RAG context-quality signal for chat responses (v9.8.0, additive).
208
+
209
+ Returns ``{"mode": "hybrid"|"lexical_only"|"none", "nodes": int,
210
+ "limited": bool, "reason": str|None}``. ``limited`` is true when the
211
+ graph produced 0–1 matches, when vector retrieval fell back to
212
+ lexical-only, or when retrieval failed entirely — so the frontend can
213
+ tell the user the answer is weakly grounded instead of implying rich
214
+ graph context. Never raises.
215
+ """
216
+ from lattice_brain.graph.retrieval import context_quality_signal
217
+
218
+ query = str(query or "").strip()
219
+ if knowledge_graph is None or not query:
220
+ return context_quality_signal(
221
+ "none", 0, reason="지식 그래프 컨텍스트를 사용하지 않았습니다"
222
+ )
223
+ scope_kwargs = (
224
+ {"allowed_workspaces": allowed_workspaces}
225
+ if allowed_workspaces is not None
226
+ else {}
227
+ )
228
+ hybrid_fn = getattr(knowledge_graph, "hybrid_search", None)
229
+ if callable(hybrid_fn):
230
+ try:
231
+ result = hybrid_fn(query, top_k=limit, **scope_kwargs) or {}
232
+ except Exception: # noqa: BLE001 — signal building must never fail chat
233
+ return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
234
+ matches = result.get("matches") or []
235
+ return context_quality_signal(
236
+ str(result.get("mode") or "hybrid"), len(matches)
237
+ )
238
+ # Lexical-only stores without the hybrid mixin.
239
+ try:
240
+ matches = (
241
+ knowledge_graph.search(query, limit=limit, **scope_kwargs) or {}
242
+ ).get("matches") or []
243
+ except Exception: # noqa: BLE001 — signal building must never fail chat
244
+ return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
245
+ return context_quality_signal("lexical_only", len(matches))
246
+
247
+
200
248
  def workspace_scope_from_request(request: Request) -> Optional[str]:
201
249
  header = request.headers.get("X-Workspace-Id")
202
250
  if header and header.strip():
@@ -52,8 +52,14 @@ async def stream_chat(
52
52
  history_meta: Optional[Dict[str, Any]] = None,
53
53
  model_id: Optional[str] = None,
54
54
  workspace_id: Optional[str] = None,
55
+ context_quality: Optional[Dict[str, Any]] = None,
55
56
  ) -> AsyncIterator[str]:
56
- """Stream model chunks and persist exactly one finalized answer."""
57
+ """Stream model chunks and persist exactly one finalized answer.
58
+
59
+ ``context_quality`` (v9.8.0, additive) is echoed on the final trailer
60
+ event alongside the answer trace so streaming clients receive the same
61
+ honest RAG signal as the non-streaming JSON response.
62
+ """
57
63
 
58
64
  full_response = ""
59
65
  stream_error: Optional[str] = None
@@ -106,6 +112,8 @@ async def stream_chat(
106
112
  trailer: Dict[str, Any] = {"chunk": "", "model": model_id}
107
113
  if trace_record:
108
114
  trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
115
+ if context_quality is not None:
116
+ trailer["context_quality"] = context_quality
109
117
  if stream_error:
110
118
  trailer["error"] = stream_error
111
119
  yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
@@ -10,7 +10,7 @@ from datetime import datetime
10
10
  from pathlib import Path
11
11
  from typing import Optional
12
12
 
13
- from fastapi import APIRouter, HTTPException, Request
13
+ from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
14
14
  from fastapi.responses import FileResponse
15
15
  from pydantic import BaseModel
16
16
 
@@ -37,6 +37,18 @@ class LocalWriteRequest(BaseModel):
37
37
  approval_token: Optional[str] = None
38
38
 
39
39
 
40
+ class FolderIngestRequest(BaseModel):
41
+ path: str
42
+ recursive: bool = True
43
+ background: bool = False
44
+ workspace_id: Optional[str] = None
45
+ # Local filesystem reads follow the standard approval dance (same as
46
+ # /local/read and /knowledge-graph/local/index): the first call returns a
47
+ # permission_required payload with an approval token.
48
+ approved: bool = False
49
+ approval_token: Optional[str] = None
50
+
51
+
40
52
  def create_local_files_router(
41
53
  *,
42
54
  require_user,
@@ -194,6 +206,107 @@ def create_local_files_router(
194
206
  raise HTTPException(status_code=404, detail="File not found")
195
207
  return FileResponse(str(target))
196
208
 
209
+ # ── v9.8.0 ingestion jobs API (frozen paths — consumed by the frontend) ───
210
+ def _require_pipeline():
211
+ if ingestion_pipeline is None or not ingestion_pipeline.available():
212
+ raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
213
+
214
+ def _ingestion_write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
215
+ header = request.headers.get("X-Workspace-Id")
216
+ header = header.strip() if header and header.strip() else None
217
+ supplied = [value for value in (body_workspace, header) if value]
218
+ if len(set(supplied)) > 1:
219
+ raise HTTPException(status_code=403, detail="Workspace selectors must match.")
220
+ requested = supplied[0] if supplied else None
221
+ if workspace_service is None:
222
+ return requested
223
+ try:
224
+ return workspace_service.resolve_write_scope(requested, user or None)
225
+ except PermissionError as exc:
226
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
227
+
228
+ @router.get("/api/ingestion/jobs")
229
+ async def ingestion_jobs(request: Request, limit: int = 20):
230
+ """Recent background ingestion jobs (newest first)."""
231
+ require_user(request)
232
+ _require_pipeline()
233
+ limit = max(1, min(int(limit or 20), 100))
234
+ return {"jobs": ingestion_pipeline.list_background_jobs(limit=limit)}
235
+
236
+ @router.get("/api/ingestion/jobs/{job_id}")
237
+ async def ingestion_job_detail(job_id: str, request: Request):
238
+ """One job with its progress counters and (capped) error records."""
239
+ require_user(request)
240
+ _require_pipeline()
241
+ job = ingestion_pipeline.get_background_job(job_id)
242
+ if job is None:
243
+ raise HTTPException(status_code=404, detail="ingestion job not found")
244
+ return job.as_dict()
245
+
246
+ @router.post("/api/ingestion/jobs/{job_id}/resume")
247
+ async def ingestion_job_resume(job_id: str, request: Request, background_tasks: BackgroundTasks):
248
+ """Resume an interrupted/partial/failed job from its remaining items."""
249
+ user = require_user(request)
250
+ _require_pipeline()
251
+ job = ingestion_pipeline.get_background_job(job_id)
252
+ if job is None:
253
+ raise HTTPException(status_code=404, detail="ingestion job not found")
254
+ if job.status == "running":
255
+ return {"status": "already_running", "job_id": job_id, "job": job.as_dict()}
256
+ remaining = len(job.remaining_indices())
257
+ if remaining == 0 and job.status == "completed":
258
+ return {"status": "nothing_to_resume", "job_id": job_id, "job": job.as_dict()}
259
+ background_tasks.add_task(
260
+ ingestion_pipeline.resume_background_job, job_id, user_email=user or None,
261
+ )
262
+ return {
263
+ "status": "resuming",
264
+ "job_id": job_id,
265
+ "remaining": remaining,
266
+ "job": job.as_dict(),
267
+ }
268
+
269
+ @router.post("/api/ingestion/folder")
270
+ async def ingestion_folder(req: FolderIngestRequest, request: Request, background_tasks: BackgroundTasks):
271
+ """Ingest a local folder through the unified pipeline.
272
+
273
+ Reads local disk, so it follows the same approval dance as
274
+ ``/local/read`` and ``/knowledge-graph/local/index``: without
275
+ ``approved`` + ``approval_token`` the response is a
276
+ ``permission_required`` payload. ``background=true`` schedules a job
277
+ (summary includes ``job_id``) and executes it after the response.
278
+ """
279
+ current_user = permission_gateway.require_local_user(request)
280
+ _require_pipeline()
281
+ workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
282
+ path = (req.path or "").strip()
283
+ if not path:
284
+ raise HTTPException(status_code=400, detail="path is required.")
285
+ if not req.approved:
286
+ return permission_gateway.local_permission_response(path, "read", current_user)
287
+ permission_gateway.require_local_approval(
288
+ token=req.approval_token,
289
+ path=path,
290
+ action="read",
291
+ user_email=current_user,
292
+ )
293
+ summary = ingestion_pipeline.ingest_folder(
294
+ path,
295
+ recursive=req.recursive,
296
+ background=req.background,
297
+ owner=current_user or None,
298
+ workspace_id=workspace_id,
299
+ user_email=current_user or None,
300
+ )
301
+ job_id = summary.get("job_id")
302
+ if req.background and job_id:
303
+ # Execute after the response is sent; progress is visible via
304
+ # GET /api/ingestion/jobs/{job_id}.
305
+ background_tasks.add_task(
306
+ ingestion_pipeline.run_background_job, job_id, user_email=current_user or None,
307
+ )
308
+ return summary
309
+
197
310
  @router.post("/local/write")
198
311
  async def local_write_endpoint(req: LocalWriteRequest, request: Request):
199
312
  current_user = permission_gateway.require_local_user(request)
@@ -238,4 +351,9 @@ def create_local_files_router(
238
351
  return router
239
352
 
240
353
 
241
- __all__ = ["LocalAccessRequest", "LocalWriteRequest", "create_local_files_router"]
354
+ __all__ = [
355
+ "FolderIngestRequest",
356
+ "LocalAccessRequest",
357
+ "LocalWriteRequest",
358
+ "create_local_files_router",
359
+ ]
@@ -56,6 +56,18 @@ class SnoozeRequest(BaseModel):
56
56
  until: str
57
57
 
58
58
 
59
+ class DismissRequest(BaseModel):
60
+ """Optional dismissal context (e.g. why a change proposal was rejected)."""
61
+
62
+ reason: str = ""
63
+
64
+
65
+ class ReviewCounts(BaseModel):
66
+ pending: int = 0
67
+ snoozed: int = 0
68
+ pending_by_source: Dict[str, int] = Field(default_factory=dict)
69
+
70
+
59
71
  def create_review_queue_router(
60
72
  *,
61
73
  service: ReviewQueueService,
@@ -64,7 +76,13 @@ def create_review_queue_router(
64
76
  gate_write: Callable[[Request], Optional[str]],
65
77
  run_review_item: Callable[..., Any],
66
78
  append_audit_event: Callable[..., None],
79
+ change_proposals: Any = None,
67
80
  ) -> APIRouter:
81
+ """``change_proposals`` (optional) closes the governance loop: approving a
82
+ ``change_proposal`` item from the Review Center applies the staged content
83
+ via :class:`~latticeai.services.change_proposals.ChangeProposalService`
84
+ instead of merely flipping the status — the same single application path
85
+ the /api/proposals surface uses."""
68
86
  router = APIRouter()
69
87
 
70
88
  @router.get("/automation/reviews", response_model=ReviewItemList)
@@ -95,6 +113,14 @@ def create_review_queue_router(
95
113
  append_audit_event("review_item_created", user_email=user, item_id=item["id"])
96
114
  return item
97
115
 
116
+ # NOTE: declared before /automation/reviews/{item_id} so "counts" never
117
+ # resolves as an item id.
118
+ @router.get("/automation/reviews/counts", response_model=ReviewCounts)
119
+ async def review_counts(request: Request):
120
+ user = require_user(request)
121
+ scope = gate_read(request)
122
+ return service.counts(workspace_id=scope, user_email=user)
123
+
98
124
  @router.get("/automation/reviews/{item_id}", response_model=ReviewItem)
99
125
  async def get_item(item_id: str, request: Request):
100
126
  require_user(request)
@@ -106,11 +132,49 @@ def create_review_queue_router(
106
132
 
107
133
  @router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
108
134
  async def approve_item(item_id: str, request: Request):
135
+ user = require_user(request)
136
+ scope = gate_write(request)
137
+ # change_proposal items must apply the staged content on approve —
138
+ # otherwise the item flips to "approved" while nothing hits disk.
139
+ if change_proposals is not None:
140
+ try:
141
+ stored = service.get(item_id, workspace_id=scope)
142
+ except FileNotFoundError as exc:
143
+ raise HTTPException(status_code=404, detail="review item not found") from exc
144
+ if stored.get("source") == "change_proposal":
145
+ if stored.get("effective_status") not in ("pending", "snoozed"):
146
+ raise HTTPException(
147
+ status_code=409,
148
+ detail=f"cannot 'approve' a review item in status {stored.get('status')!r}",
149
+ )
150
+ try:
151
+ applied = change_proposals.approve_and_apply(
152
+ item_id, user_email=user, workspace_id=scope
153
+ )
154
+ except (KeyError, FileNotFoundError) as exc:
155
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
156
+ except ValueError as exc:
157
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
158
+ append_audit_event("review_item_approve", user_email=user, item_id=item_id)
159
+ return applied["item"]
109
160
  return _act(request, item_id, "approve")
110
161
 
111
162
  @router.post("/automation/reviews/{item_id}/dismiss", response_model=ReviewItem)
112
- async def dismiss_item(item_id: str, request: Request):
113
- return _act(request, item_id, "dismiss")
163
+ async def dismiss_item(
164
+ item_id: str, request: Request, req: Optional[DismissRequest] = None
165
+ ):
166
+ user = require_user(request)
167
+ scope = gate_write(request)
168
+ try:
169
+ item = service.dismiss(
170
+ item_id, workspace_id=scope, reason=(req.reason if req else None)
171
+ )
172
+ except FileNotFoundError as exc:
173
+ raise HTTPException(status_code=404, detail="review item not found") from exc
174
+ except InvalidReviewTransition as exc:
175
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
176
+ append_audit_event("review_item_dismiss", user_email=user, item_id=item_id)
177
+ return item
114
178
 
115
179
  @router.post("/automation/reviews/{item_id}/snooze", response_model=ReviewItem)
116
180
  async def snooze_item(item_id: str, req: SnoozeRequest, request: Request):
@@ -1194,6 +1194,9 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1194
1194
  gate_write=PLATFORM.gate_write,
1195
1195
  run_review_item=run_review_item,
1196
1196
  append_audit_event=append_audit_event,
1197
+ # Approving a change_proposal from the Review Center applies the
1198
+ # staged content through the same service the agent governor uses.
1199
+ change_proposals=CHANGE_PROPOSALS,
1197
1200
  create_browser_router=create_browser_router,
1198
1201
  ingestion_pipeline=INGESTION_PIPELINE,
1199
1202
  workspace_service=WORKSPACE_SERVICE,