ltcai 9.6.0 → 9.7.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 +52 -42
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +54 -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 +211 -7
- package/lattice_brain/graph/retrieval_vector.py +102 -0
- package/lattice_brain/ingestion.py +360 -4
- 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 +27 -2
- package/latticeai/api/change_proposals.py +32 -3
- 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 +155 -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/brain_intelligence.py +97 -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 +10 -10
- package/static/app/assets/{Act-BkOEmwBi.js → Act-B6c39ays.js} +2 -1
- package/static/app/assets/{Brain-C9ITUsQ_.js → Brain-D7Qg4k6M.js} +1 -1
- package/static/app/assets/{Capture-C-ppTeud.js → Capture-VF_di68r.js} +1 -1
- package/static/app/assets/{Library-CGQbgWTu.js → Library-D_Gis2PA.js} +1 -1
- package/static/app/assets/{System-djmj0n2_.js → System-C5s5H2ov.js} +1 -1
- package/static/app/assets/{index-AF0-4XVv.js → index-DJC_2oub.js} +3 -3
- package/static/app/assets/{primitives-jbb2qv4Q.js → primitives-DL4Nip8C.js} +1 -1
- package/static/app/assets/{textarea-CFoo0OxJ.js → textarea-woZfCXHy.js} +1 -1
- package/static/app/index.html +1 -1
- package/static/sw.js +1 -1
|
@@ -34,6 +34,7 @@ from latticeai.core.agent import (
|
|
|
34
34
|
AgentState,
|
|
35
35
|
SingleAgentRuntime,
|
|
36
36
|
)
|
|
37
|
+
from latticeai.tools import ToolError
|
|
37
38
|
|
|
38
39
|
_AUTO_POLICY = {
|
|
39
40
|
"auto_approve": True, "risk": "low", "shell": False, "network": False,
|
|
@@ -43,6 +44,43 @@ _DESTRUCTIVE_POLICY = {
|
|
|
43
44
|
"auto_approve": False, "risk": "destructive", "shell": True, "network": False,
|
|
44
45
|
"destructive": True, "sandbox": False, "rollback": "none",
|
|
45
46
|
}
|
|
47
|
+
# Mirrors production write-tool governance when the change governor is wired:
|
|
48
|
+
# not auto-approved, so only the governor's additive/proposed verdicts (never
|
|
49
|
+
# the model) decide whether a write runs or is staged for review.
|
|
50
|
+
_GOVERNED_WRITE_POLICY = {
|
|
51
|
+
"auto_approve": False, "risk": "write", "shell": False, "network": False,
|
|
52
|
+
"destructive": False, "sandbox": "workspace", "rollback": "git",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _EvalChangeGovernor:
|
|
57
|
+
"""Deterministic stand-in for ChangeProposalService's governor port.
|
|
58
|
+
|
|
59
|
+
Mirrors the wire contract of
|
|
60
|
+
:meth:`latticeai.services.change_proposals.ChangeProposalService.review`:
|
|
61
|
+
``None`` falls through, additive writes get ``allow_additive``, and
|
|
62
|
+
mutations of "existing" paths come back ``proposed`` with a proposal id —
|
|
63
|
+
exactly what the agent loop routes into the review queue.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
governed_tools = frozenset({"write_file", "edit_file"})
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.proposals: List[Dict[str, Any]] = []
|
|
70
|
+
|
|
71
|
+
def review(self, name, args, *, policy=None, user_email=None, workspace_id=None, conversation_id=None):
|
|
72
|
+
if name not in self.governed_tools:
|
|
73
|
+
return None
|
|
74
|
+
path = str(args.get("path") or "")
|
|
75
|
+
if path.startswith("existing"):
|
|
76
|
+
proposal = {"id": f"eval-proposal-{len(self.proposals) + 1}", "path": path}
|
|
77
|
+
self.proposals.append(proposal)
|
|
78
|
+
return {
|
|
79
|
+
"decision": "proposed",
|
|
80
|
+
"classification": {"change_class": "mutation"},
|
|
81
|
+
"proposal": proposal,
|
|
82
|
+
}
|
|
83
|
+
return {"decision": "allow_additive", "classification": {"change_class": "additive"}}
|
|
46
84
|
|
|
47
85
|
|
|
48
86
|
@dataclass
|
|
@@ -56,6 +94,11 @@ class Scenario:
|
|
|
56
94
|
expect_min: Dict[str, int] = field(default_factory=dict)
|
|
57
95
|
expect_exact: Dict[str, Any] = field(default_factory=dict)
|
|
58
96
|
expect_tool_outcomes: Dict[str, int] = field(default_factory=dict)
|
|
97
|
+
# Exact ordered sequence of tool names that actually executed (successful
|
|
98
|
+
# execute_tool calls only — proposed/blocked/failed calls never run).
|
|
99
|
+
expect_tool_calls: List[str] = field(default_factory=list)
|
|
100
|
+
# Wire a change governor so governed tools follow the proposal path.
|
|
101
|
+
use_governor: bool = False
|
|
59
102
|
max_steps: int = 8
|
|
60
103
|
|
|
61
104
|
|
|
@@ -69,7 +112,12 @@ class _Req:
|
|
|
69
112
|
self.message = message
|
|
70
113
|
|
|
71
114
|
|
|
72
|
-
def _build_deps(
|
|
115
|
+
def _build_deps(
|
|
116
|
+
replies: List[str],
|
|
117
|
+
tool_log: List[Dict[str, Any]],
|
|
118
|
+
*,
|
|
119
|
+
governor: Any = None,
|
|
120
|
+
) -> AgentDeps:
|
|
73
121
|
queue = list(replies)
|
|
74
122
|
|
|
75
123
|
async def generate_as(model_id, message, context, max_tokens, temperature):
|
|
@@ -83,14 +131,21 @@ def _build_deps(replies: List[str], tool_log: List[Dict[str, Any]]) -> AgentDeps
|
|
|
83
131
|
return '{"action": "noop"}'
|
|
84
132
|
|
|
85
133
|
def execute_tool(name: str, args: dict) -> dict:
|
|
134
|
+
# File-producing tools fail like the real dispatcher when the model
|
|
135
|
+
# forgets the target path — scenarios use this to exercise recovery.
|
|
136
|
+
if name in ("write_file", "generate_file") and not args.get("path"):
|
|
137
|
+
raise ToolError(f"{name} requires args.path")
|
|
86
138
|
tool_log.append({"name": name, "args": args})
|
|
87
139
|
return {"ok": True, "path": args.get("path", "")}
|
|
88
140
|
|
|
89
141
|
def policy_for(name: str, args: dict) -> dict:
|
|
90
142
|
if name == "delete_everything":
|
|
91
143
|
return dict(_DESTRUCTIVE_POLICY)
|
|
144
|
+
if governor is not None and name in ("write_file", "edit_file"):
|
|
145
|
+
return dict(_GOVERNED_WRITE_POLICY)
|
|
92
146
|
return dict(_AUTO_POLICY)
|
|
93
147
|
|
|
148
|
+
write_policy = dict(_GOVERNED_WRITE_POLICY) if governor is not None else dict(_AUTO_POLICY)
|
|
94
149
|
return AgentDeps(
|
|
95
150
|
generate_as=generate_as,
|
|
96
151
|
generate=generate,
|
|
@@ -99,17 +154,19 @@ def _build_deps(replies: List[str], tool_log: List[Dict[str, Any]]) -> AgentDeps
|
|
|
99
154
|
risk_level=lambda p: p["risk"],
|
|
100
155
|
check_role=lambda name, user: None,
|
|
101
156
|
tool_governance={
|
|
102
|
-
"write_file":
|
|
157
|
+
"write_file": write_policy,
|
|
103
158
|
"read_file": dict(_AUTO_POLICY),
|
|
159
|
+
"generate_file": dict(_AUTO_POLICY),
|
|
104
160
|
"delete_everything": dict(_DESTRUCTIVE_POLICY),
|
|
105
161
|
},
|
|
106
|
-
file_create_actions=frozenset({"write_file"}),
|
|
162
|
+
file_create_actions=frozenset({"write_file", "generate_file"}),
|
|
107
163
|
recent_chat_context=lambda **kw: "",
|
|
108
164
|
clear_history=lambda keep: {"ok": True},
|
|
109
165
|
knowledge_save=lambda *a, **kw: None,
|
|
110
166
|
audit=lambda *a, **kw: None,
|
|
111
167
|
planner_prompt="plan", executor_prompt="exec", critic_prompt="critic",
|
|
112
168
|
memory_updater_prompt="mem", agent_root=Path("/tmp/agent-eval"),
|
|
169
|
+
change_governor=governor,
|
|
113
170
|
)
|
|
114
171
|
|
|
115
172
|
|
|
@@ -193,12 +250,96 @@ def default_scenarios() -> List[Scenario]:
|
|
|
193
250
|
expect_min={"parse_errors": 3},
|
|
194
251
|
expect_exact={"tool_outcomes": {}},
|
|
195
252
|
),
|
|
253
|
+
# ── file generation ─────────────────────────────────────────────
|
|
254
|
+
Scenario(
|
|
255
|
+
name="file-generation-happy-path",
|
|
256
|
+
replies=[
|
|
257
|
+
'{"action": "plan", "goal": "generate landing page", '
|
|
258
|
+
'"steps": [{"action": "generate_file"}]}',
|
|
259
|
+
# Small local models wrap the payload in reasoning + fences;
|
|
260
|
+
# the loop must still extract one clean tool call.
|
|
261
|
+
"<think>layout first, then hero copy</think>\n"
|
|
262
|
+
'```json\n{"thoughts": "produce the full document", '
|
|
263
|
+
'"action": "generate_file", "args": {"path": "site/index.html", '
|
|
264
|
+
'"content": "<!DOCTYPE html><html><body>hello</body></html>"}}\n```',
|
|
265
|
+
_FINAL,
|
|
266
|
+
_PASS,
|
|
267
|
+
],
|
|
268
|
+
expect_exact={"parse_errors": 0},
|
|
269
|
+
expect_tool_outcomes={"ok": 1},
|
|
270
|
+
expect_tool_calls=["generate_file"],
|
|
271
|
+
),
|
|
272
|
+
Scenario(
|
|
273
|
+
name="file-generation-bad-args-recovers",
|
|
274
|
+
replies=[
|
|
275
|
+
'{"action": "plan", "goal": "generate report", '
|
|
276
|
+
'"steps": [{"action": "generate_file"}]}',
|
|
277
|
+
# Malformed tool call: the model forgot the target path, the
|
|
278
|
+
# tool port fails like the real dispatcher would…
|
|
279
|
+
'{"thoughts": "write it", "action": "generate_file", '
|
|
280
|
+
'"args": {"content": "<html></html>"}}',
|
|
281
|
+
# …and the next turn recovers with a complete call.
|
|
282
|
+
'{"thoughts": "add the missing path", "action": "generate_file", '
|
|
283
|
+
'"args": {"path": "reports/q3.html", "content": "<html></html>"}}',
|
|
284
|
+
_FINAL,
|
|
285
|
+
_PASS,
|
|
286
|
+
],
|
|
287
|
+
expect_tool_outcomes={"error": 1, "ok": 1},
|
|
288
|
+
expect_tool_calls=["generate_file"],
|
|
289
|
+
),
|
|
290
|
+
# ── multi-step workflow ─────────────────────────────────────────
|
|
291
|
+
Scenario(
|
|
292
|
+
name="multi-step-workflow-chain",
|
|
293
|
+
replies=[
|
|
294
|
+
'{"action": "plan", "goal": "read spec, generate report, save summary", '
|
|
295
|
+
'"steps": [{"action": "read_file"}, {"action": "generate_file"}, '
|
|
296
|
+
'{"action": "write_file"}]}',
|
|
297
|
+
'{"thoughts": "step 1: read the source spec", '
|
|
298
|
+
'"action": "read_file", "args": {"path": "spec.md"}}',
|
|
299
|
+
'{"thoughts": "step 2: the spec asks for an HTML report", '
|
|
300
|
+
'"action": "generate_file", "args": {"path": "report.html", '
|
|
301
|
+
'"content": "<html><body>report</body></html>"}}',
|
|
302
|
+
'{"thoughts": "step 3: persist a short summary next to it", '
|
|
303
|
+
'"action": "write_file", "args": {"path": "summary.txt", '
|
|
304
|
+
'"content": "report generated"}}',
|
|
305
|
+
_FINAL,
|
|
306
|
+
_PASS,
|
|
307
|
+
],
|
|
308
|
+
expect_exact={"parse_errors": 0},
|
|
309
|
+
expect_min={"llm_calls": 6},
|
|
310
|
+
expect_tool_outcomes={"ok": 3},
|
|
311
|
+
expect_tool_calls=["read_file", "generate_file", "write_file"],
|
|
312
|
+
),
|
|
313
|
+
# ── governed-tool proposal path ─────────────────────────────────
|
|
314
|
+
Scenario(
|
|
315
|
+
name="governed-write-proposal-path",
|
|
316
|
+
use_governor=True,
|
|
317
|
+
replies=[
|
|
318
|
+
# write_file is NOT auto-approved here, but it is governed —
|
|
319
|
+
# approve() must not hard-block the plan (core invariant:
|
|
320
|
+
# governed tools are excluded from the non-auto set).
|
|
321
|
+
'{"action": "plan", "goal": "update existing page, add new note", '
|
|
322
|
+
'"steps": [{"action": "write_file"}]}',
|
|
323
|
+
# Mutation of existing content → staged as proposal, not written.
|
|
324
|
+
'{"thoughts": "rewrite the existing page", "action": "write_file", '
|
|
325
|
+
'"args": {"path": "existing/site.html", "content": "<new>"}}',
|
|
326
|
+
# Additive create → governor allows it to run immediately.
|
|
327
|
+
'{"thoughts": "add a fresh note", "action": "write_file", '
|
|
328
|
+
'"args": {"path": "fresh/new-note.md", "content": "hello"}}',
|
|
329
|
+
_FINAL,
|
|
330
|
+
_PASS,
|
|
331
|
+
],
|
|
332
|
+
expect_exact={"parse_errors": 0},
|
|
333
|
+
expect_tool_outcomes={"proposed": 1, "ok": 1},
|
|
334
|
+
expect_tool_calls=["write_file"],
|
|
335
|
+
),
|
|
196
336
|
]
|
|
197
337
|
|
|
198
338
|
|
|
199
339
|
async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
|
|
200
340
|
tool_log: List[Dict[str, Any]] = []
|
|
201
|
-
|
|
341
|
+
governor = _EvalChangeGovernor() if scenario.use_governor else None
|
|
342
|
+
deps = _build_deps(scenario.replies, tool_log, governor=governor)
|
|
202
343
|
runtime = SingleAgentRuntime(deps)
|
|
203
344
|
ctx = AgentRunContext()
|
|
204
345
|
ctx.state = AgentState.PLANNING
|
|
@@ -226,6 +367,14 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
|
|
|
226
367
|
failures.append(
|
|
227
368
|
f"tool_outcomes[{outcome}]={summary['tool_outcomes'].get(outcome, 0)} != {count}"
|
|
228
369
|
)
|
|
370
|
+
executed = [call["name"] for call in tool_log]
|
|
371
|
+
if scenario.expect_tool_calls and executed != scenario.expect_tool_calls:
|
|
372
|
+
failures.append(f"tool_calls={executed} != {scenario.expect_tool_calls}")
|
|
373
|
+
if governor is not None and summary["tool_outcomes"].get("proposed", 0) != len(governor.proposals):
|
|
374
|
+
failures.append(
|
|
375
|
+
f"governor proposals={len(governor.proposals)} != "
|
|
376
|
+
f"traced proposed={summary['tool_outcomes'].get('proposed', 0)}"
|
|
377
|
+
)
|
|
229
378
|
return {
|
|
230
379
|
"name": scenario.name,
|
|
231
380
|
"ok": not failures,
|
|
@@ -233,6 +382,8 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
|
|
|
233
382
|
"final_state": ctx.state.value,
|
|
234
383
|
"summary": summary,
|
|
235
384
|
"tool_calls": len(tool_log),
|
|
385
|
+
"executed_tools": executed,
|
|
386
|
+
"proposals": len(governor.proposals) if governor is not None else 0,
|
|
236
387
|
}
|
|
237
388
|
|
|
238
389
|
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Any, Dict, List
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
LEGACY_COMPATIBILITY_VERSION = "9.
|
|
17
|
+
LEGACY_COMPATIBILITY_VERSION = "9.7.0"
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
@dataclass(frozen=True)
|
|
@@ -82,6 +82,20 @@ LEGACY_SHIMS: List[LegacyShim] = [
|
|
|
82
82
|
reason="Old gardener scripts referenced the root reinforcement helper.",
|
|
83
83
|
removal_phase="major-release-after-8.x",
|
|
84
84
|
),
|
|
85
|
+
LegacyShim(
|
|
86
|
+
path="mcp_registry.py",
|
|
87
|
+
owner="latticeai.core.mcp_registry",
|
|
88
|
+
replacement="import latticeai.core.mcp_registry",
|
|
89
|
+
reason="Older integrations imported the MCP registry from the repo root.",
|
|
90
|
+
removal_phase="major-release-after-9.x",
|
|
91
|
+
),
|
|
92
|
+
LegacyShim(
|
|
93
|
+
path="llm_router.py",
|
|
94
|
+
owner="latticeai.models.router",
|
|
95
|
+
replacement="import latticeai.models.router",
|
|
96
|
+
reason="Historical scripts and tests imported the LLM router from the repo root.",
|
|
97
|
+
removal_phase="major-release-after-9.x",
|
|
98
|
+
),
|
|
85
99
|
LegacyShim(
|
|
86
100
|
path="server.py",
|
|
87
101
|
owner="latticeai.server_app",
|
|
@@ -49,7 +49,7 @@ __all__ = [
|
|
|
49
49
|
"remove_skill_directory",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
WORKSPACE_OS_VERSION = "9.
|
|
52
|
+
WORKSPACE_OS_VERSION = "9.7.0"
|
|
53
53
|
|
|
54
54
|
# Workspace types separate single-user Personal workspaces from shared
|
|
55
55
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -604,6 +604,7 @@ def register_review_and_brain_tail_routers(
|
|
|
604
604
|
gardener: Any,
|
|
605
605
|
create_setup_router: Any,
|
|
606
606
|
model_router: Any,
|
|
607
|
+
change_proposals: Any = None,
|
|
607
608
|
) -> Any:
|
|
608
609
|
"""Register the final review/browser/brain tail routes in legacy order."""
|
|
609
610
|
|
|
@@ -616,6 +617,7 @@ def register_review_and_brain_tail_routers(
|
|
|
616
617
|
gate_write=gate_write,
|
|
617
618
|
run_review_item=run_review_item,
|
|
618
619
|
append_audit_event=append_audit_event,
|
|
620
|
+
change_proposals=change_proposals,
|
|
619
621
|
),
|
|
620
622
|
create_browser_router(
|
|
621
623
|
pipeline=ingestion_pipeline,
|
|
@@ -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
|
|
|
@@ -302,6 +319,56 @@ class BrainIntelligenceService:
|
|
|
302
319
|
"generated_at": _now(),
|
|
303
320
|
}
|
|
304
321
|
|
|
322
|
+
# ── graph-layer proactive quality (v9.6.x) ───────────────────────────
|
|
323
|
+
|
|
324
|
+
def graph_duplicates(
|
|
325
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
326
|
+
) -> Dict[str, Any]:
|
|
327
|
+
"""Duplicate graph nodes (exact groups + near pairs) — read only."""
|
|
328
|
+
proactive = self._proactive()
|
|
329
|
+
if proactive is None:
|
|
330
|
+
return {
|
|
331
|
+
"available": False,
|
|
332
|
+
"exact_groups": [],
|
|
333
|
+
"near_pairs": [],
|
|
334
|
+
"exact_duplicate_nodes": 0,
|
|
335
|
+
"nodes_scanned": 0,
|
|
336
|
+
"generated_at": _now(),
|
|
337
|
+
}
|
|
338
|
+
try:
|
|
339
|
+
result = dict(proactive.find_duplicates(workspace_id=workspace_id))
|
|
340
|
+
except Exception as exc:
|
|
341
|
+
LOGGER.exception("graph duplicates scan failed")
|
|
342
|
+
return {
|
|
343
|
+
"available": False,
|
|
344
|
+
"error": str(exc),
|
|
345
|
+
"exact_groups": [],
|
|
346
|
+
"near_pairs": [],
|
|
347
|
+
"exact_duplicate_nodes": 0,
|
|
348
|
+
"nodes_scanned": 0,
|
|
349
|
+
"generated_at": _now(),
|
|
350
|
+
}
|
|
351
|
+
result["available"] = True
|
|
352
|
+
result["generated_at"] = _now()
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
def quality_report(
|
|
356
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
357
|
+
) -> Dict[str, Any]:
|
|
358
|
+
"""Combined graph quality report: duplicates, contradictions, stale
|
|
359
|
+
nodes, edge quality — one workspace-scoped graph sample."""
|
|
360
|
+
proactive = self._proactive()
|
|
361
|
+
if proactive is None:
|
|
362
|
+
return {"available": False, "generated_at": _now()}
|
|
363
|
+
try:
|
|
364
|
+
result = dict(proactive.quality_report(workspace_id=workspace_id))
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
LOGGER.exception("graph quality report failed")
|
|
367
|
+
return {"available": False, "error": str(exc), "generated_at": _now()}
|
|
368
|
+
result["available"] = True
|
|
369
|
+
result["generated_at"] = _now()
|
|
370
|
+
return result
|
|
371
|
+
|
|
305
372
|
# ── contradictions ───────────────────────────────────────────────────
|
|
306
373
|
|
|
307
374
|
def contradictions(
|
|
@@ -365,7 +432,21 @@ class BrainIntelligenceService:
|
|
|
365
432
|
"signal": "contradicts_edge",
|
|
366
433
|
})
|
|
367
434
|
|
|
368
|
-
|
|
435
|
+
# v9.6.x additive: graph-layer node-content contradictions (proactive
|
|
436
|
+
# detector over node title/summary), on top of the memory + edge scans.
|
|
437
|
+
graph_pair_items: List[Dict[str, Any]] = []
|
|
438
|
+
proactive = self._proactive()
|
|
439
|
+
if proactive is not None:
|
|
440
|
+
try:
|
|
441
|
+
graph_result = proactive.detect_contradictions(workspace_id=workspace_id)
|
|
442
|
+
graph_pair_items = [
|
|
443
|
+
{"kind": "graph_node_pair", **pair}
|
|
444
|
+
for pair in graph_result.get("node_pairs") or []
|
|
445
|
+
]
|
|
446
|
+
except Exception:
|
|
447
|
+
LOGGER.exception("graph contradiction scan failed")
|
|
448
|
+
|
|
449
|
+
items = conflicts + temporal_items + edge_items + graph_pair_items
|
|
369
450
|
return {
|
|
370
451
|
"items": items,
|
|
371
452
|
"count": len(items),
|
|
@@ -373,6 +454,7 @@ class BrainIntelligenceService:
|
|
|
373
454
|
"memory_pairs": len(conflicts),
|
|
374
455
|
"temporal": len(temporal_items),
|
|
375
456
|
"graph_edges": len(edge_items),
|
|
457
|
+
"graph_node_pairs": len(graph_pair_items),
|
|
376
458
|
},
|
|
377
459
|
"memories_scanned": len(memory_rows),
|
|
378
460
|
"generated_at": _now(),
|
|
@@ -422,6 +504,19 @@ class BrainIntelligenceService:
|
|
|
422
504
|
except Exception:
|
|
423
505
|
LOGGER.exception("consolidation prune failed")
|
|
424
506
|
|
|
507
|
+
# v9.6.x additive: graph-layer node merge plan. Always dry-run from
|
|
508
|
+
# this service — graph content changes stay proposal-first; the plan
|
|
509
|
+
# is surfaced so a governed apply path can adopt it later.
|
|
510
|
+
graph_consolidation: Optional[Dict[str, Any]] = None
|
|
511
|
+
proactive = self._proactive()
|
|
512
|
+
if proactive is not None:
|
|
513
|
+
try:
|
|
514
|
+
graph_consolidation = proactive.consolidate_duplicates(
|
|
515
|
+
workspace_id=workspace_id, dry_run=True
|
|
516
|
+
)
|
|
517
|
+
except Exception:
|
|
518
|
+
LOGGER.exception("graph consolidation plan failed")
|
|
519
|
+
|
|
425
520
|
return {
|
|
426
521
|
"mode": "applied" if apply else "dry_run",
|
|
427
522
|
"memories_scanned": len(memory_rows),
|
|
@@ -432,6 +527,7 @@ class BrainIntelligenceService:
|
|
|
432
527
|
# mutates graph content directly.
|
|
433
528
|
"duplicate_edges": duplicate_edge_ids[:50],
|
|
434
529
|
"duplicate_edge_count": len(duplicate_edge_ids),
|
|
530
|
+
"graph_consolidation": graph_consolidation,
|
|
435
531
|
"generated_at": _now(),
|
|
436
532
|
}
|
|
437
533
|
|
|
@@ -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
|