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
package/lattice_brain/quality.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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",
|
package/latticeai/__init__.py
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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(
|
|
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(
|
|
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"]
|
package/latticeai/api/chat.py
CHANGED
|
@@ -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__ = [
|
|
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(
|
|
113
|
-
|
|
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):
|
package/latticeai/app_factory.py
CHANGED
|
@@ -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,
|