ltcai 10.0.0 → 10.2.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 +48 -32
- package/docs/CHANGELOG.md +156 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/HYBRID_CLOUD_KG_STREAMING.md +101 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +5 -4
- package/lattice_brain/conversations.py +14 -3
- package/lattice_brain/embeddings.py +12 -2
- package/lattice_brain/graph/_kg_common.py +5 -5
- package/lattice_brain/graph/_kg_fsutil.py +4 -3
- package/lattice_brain/graph/discovery.py +4 -3
- package/lattice_brain/graph/discovery_index.py +0 -1
- package/lattice_brain/graph/documents.py +3 -2
- package/lattice_brain/graph/fusion.py +4 -0
- package/lattice_brain/graph/ingest.py +12 -2
- package/lattice_brain/graph/projection.py +3 -2
- package/lattice_brain/graph/provenance.py +5 -3
- package/lattice_brain/graph/rerank.py +4 -1
- package/lattice_brain/graph/retrieval.py +0 -1
- package/lattice_brain/graph/retrieval_docgen.py +0 -1
- package/lattice_brain/graph/retrieval_reads.py +0 -1
- package/lattice_brain/graph/retrieval_vector.py +0 -1
- package/lattice_brain/graph/schema.py +4 -3
- package/lattice_brain/graph/store.py +18 -4
- package/lattice_brain/graph/write_master.py +46 -1
- package/lattice_brain/ingestion.py +4 -1
- package/lattice_brain/portability.py +5 -2
- package/lattice_brain/quality.py +12 -5
- package/lattice_brain/quiet.py +43 -0
- package/lattice_brain/runtime/agent_runtime.py +12 -8
- package/lattice_brain/runtime/hooks.py +2 -1
- package/lattice_brain/runtime/multi_agent.py +2 -3
- package/lattice_brain/sensitivity.py +94 -0
- package/lattice_brain/storage/base.py +30 -2
- package/lattice_brain/storage/migration.py +3 -2
- package/lattice_brain/storage/postgres.py +2 -2
- package/lattice_brain/storage/sqlite.py +6 -4
- package/lattice_brain/workflow.py +4 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +4 -2
- package/latticeai/api/auth.py +5 -1
- package/latticeai/api/automation_intelligence.py +2 -1
- package/latticeai/api/browser.py +3 -2
- package/latticeai/api/chat.py +28 -17
- package/latticeai/api/chat_agent_http.py +22 -8
- package/latticeai/api/chat_contracts.py +4 -0
- package/latticeai/api/chat_documents.py +6 -2
- package/latticeai/api/chat_hybrid.py +82 -0
- package/latticeai/api/computer_use.py +8 -3
- package/latticeai/api/knowledge_graph.py +1 -1
- package/latticeai/api/mcp.py +4 -4
- package/latticeai/api/models.py +5 -2
- package/latticeai/api/network_boundary.py +220 -0
- package/latticeai/api/permissions.py +0 -1
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/security_dashboard.py +1 -1
- package/latticeai/api/setup.py +16 -3
- package/latticeai/api/static_routes.py +2 -1
- package/latticeai/api/tools.py +12 -8
- package/latticeai/api/voice_capture.py +3 -1
- package/latticeai/api/workflow_designer.py +1 -1
- package/latticeai/api/workspace.py +1 -2
- package/latticeai/app_factory.py +131 -78
- package/latticeai/cli/entrypoint.py +6 -4
- package/latticeai/core/agent.py +55 -495
- package/latticeai/core/agent_eval.py +2 -2
- package/latticeai/core/agent_helpers.py +493 -0
- package/latticeai/core/agent_prompts.py +0 -1
- package/latticeai/core/agent_registry.py +3 -1
- package/latticeai/core/agent_state.py +41 -0
- package/latticeai/core/audit.py +1 -1
- package/latticeai/core/builtin_hooks.py +2 -1
- package/latticeai/core/config.py +0 -1
- package/latticeai/core/embedding_providers.py +12 -1
- package/latticeai/core/file_generation.py +3 -0
- package/latticeai/core/invitations.py +4 -1
- package/latticeai/core/io_utils.py +3 -1
- package/latticeai/core/legacy_compatibility.py +1 -2
- package/latticeai/core/local_embeddings.py +12 -1
- package/latticeai/core/marketplace.py +1 -2
- package/latticeai/core/mcp_registry.py +0 -1
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +1 -1
- package/latticeai/core/network_boundary.py +168 -0
- package/latticeai/core/oidc.py +3 -0
- package/latticeai/core/permission_mode.py +6 -6
- package/latticeai/core/plugins.py +3 -2
- package/latticeai/core/policy.py +0 -1
- package/latticeai/core/quiet.py +84 -0
- package/latticeai/core/realtime.py +3 -2
- package/latticeai/core/run_store.py +4 -2
- package/latticeai/core/security.py +4 -0
- package/latticeai/core/users.py +5 -3
- package/latticeai/core/workspace_graph_trace.py +3 -0
- package/latticeai/core/workspace_os.py +65 -273
- package/latticeai/core/workspace_os_constants.py +126 -0
- package/latticeai/core/workspace_os_state.py +180 -0
- package/latticeai/core/workspace_os_utils.py +3 -1
- package/latticeai/core/workspace_permissions.py +1 -0
- package/latticeai/core/workspace_snapshots.py +3 -1
- package/latticeai/core/workspace_timeline.py +3 -1
- package/latticeai/integrations/telegram_bot.py +25 -16
- package/latticeai/models/router.py +6 -3
- package/latticeai/runtime/access_runtime.py +3 -1
- package/latticeai/runtime/audit_runtime.py +3 -2
- package/latticeai/runtime/chat_wiring.py +4 -1
- package/latticeai/runtime/history_runtime.py +1 -1
- package/latticeai/runtime/lifespan_runtime.py +3 -1
- package/latticeai/runtime/network_boundary_wiring.py +124 -0
- package/latticeai/runtime/persistence_runtime.py +3 -1
- package/latticeai/runtime/router_registration.py +11 -1
- package/latticeai/services/architecture_readiness.py +1 -2
- package/latticeai/services/change_proposals.py +2 -1
- package/latticeai/services/cloud_egress_audit.py +85 -0
- package/latticeai/services/cloud_extraction.py +129 -0
- package/latticeai/services/cloud_streaming.py +266 -0
- package/latticeai/services/cloud_token_guard.py +84 -0
- package/latticeai/services/command_center.py +2 -1
- package/latticeai/services/folder_watch.py +3 -0
- package/latticeai/services/funnel_metrics.py +3 -1
- package/latticeai/services/hybrid_chat.py +265 -0
- package/latticeai/services/hybrid_context.py +228 -0
- package/latticeai/services/hybrid_policy.py +178 -0
- package/latticeai/services/memory_service.py +1 -1
- package/latticeai/services/model_catalog.py +10 -1
- package/latticeai/services/model_engines.py +35 -14
- package/latticeai/services/model_loading.py +3 -2
- package/latticeai/services/model_runtime.py +68 -17
- package/latticeai/services/multimodal_streaming.py +123 -0
- package/latticeai/services/network_boundary_service.py +154 -0
- package/latticeai/services/openai_compatible_adapter.py +100 -0
- package/latticeai/services/p_reinforce.py +4 -0
- package/latticeai/services/platform_runtime.py +9 -4
- package/latticeai/services/process_audit.py +0 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/run_executor.py +3 -2
- package/latticeai/services/search_service.py +1 -4
- package/latticeai/services/setup_detection.py +2 -1
- package/latticeai/services/tool_dispatch.py +7 -2
- package/latticeai/services/upload_service.py +2 -1
- package/latticeai/setup/auto_setup.py +10 -7
- package/latticeai/setup/wizard.py +15 -11
- package/latticeai/tools/__init__.py +3 -3
- package/latticeai/tools/commands.py +7 -7
- package/latticeai/tools/computer.py +3 -2
- package/latticeai/tools/documents.py +10 -9
- package/latticeai/tools/filesystem.py +7 -4
- package/latticeai/tools/knowledge.py +2 -0
- package/latticeai/tools/local_files.py +1 -1
- package/latticeai/tools/network.py +2 -1
- package/package.json +5 -3
- package/scripts/bench_agent_smoke.py +0 -1
- package/scripts/bench_models.py +0 -1
- package/scripts/brain_quality_eval.py +7 -2
- package/scripts/bump_version.py +2 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/migrate_brain_storage.py +5 -1
- package/scripts/profile_kg.py +2 -7
- package/scripts/verify_hf_model_registry.py +2 -2
- 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 +37 -37
- package/static/app/assets/{Act-BtCREeN1.js → Act-CbdGD-2i.js} +1 -1
- package/static/app/assets/{AdminConsole-TPeeN18T.js → AdminConsole-LgCkXpnf.js} +1 -1
- package/static/app/assets/{Brain-BKs6JAp0.js → Brain-CoPGJI1L.js} +1 -1
- package/static/app/assets/{BrainHome-BPGOvSd6.js → BrainHome-gVnaxSwp.js} +1 -1
- package/static/app/assets/{BrainSignals-CtzQZ15J.js → BrainSignals-ChxAYHtj.js} +1 -1
- package/static/app/assets/{Capture-1_NaHWqB.js → Capture-DBtgkHZg.js} +1 -1
- package/static/app/assets/{CommandPalette-pqvQOXe4.js → CommandPalette-Ovtv5I0Y.js} +1 -1
- package/static/app/assets/{Library-DhvoPvC7.js → Library-CBia2Fvm.js} +1 -1
- package/static/app/assets/{LivingBrain-DlQ20Q75.js → LivingBrain-CNz-6NSd.js} +1 -1
- package/static/app/assets/{ProductFlow-BZvGDRi_.js → ProductFlow-ePX-Y73J.js} +1 -1
- package/static/app/assets/{ReviewCard-BWgI0D2s.js → ReviewCard-C4mpvkwH.js} +1 -1
- package/static/app/assets/System-BvWNK1zc.js +1 -0
- package/static/app/assets/{activity-Dlfk8YC7.js → activity-CiauPV_3.js} +1 -1
- package/static/app/assets/{bot-CDvUB76P.js → bot-Dwct-Q1x.js} +1 -1
- package/static/app/assets/{brain-xczrohrt.js → brain-BKDW1F0T.js} +1 -1
- package/static/app/assets/{button-SOdH3Oyf.js → button-CCB9Kuw7.js} +1 -1
- package/static/app/assets/{circle-pause-CG1ythH4.js → circle-pause-DNa8WHC5.js} +1 -1
- package/static/app/assets/{circle-play-HXwvjS6W.js → circle-play-8jmxn5mf.js} +1 -1
- package/static/app/assets/{cpu-B0d-rGyk.js → cpu-mxvF3V1I.js} +1 -1
- package/static/app/assets/{download-BGIkTQL6.js → download--SmCcx0p.js} +1 -1
- package/static/app/assets/{folder-open-Dst_Z0_K.js → folder-open-D4Wy5roo.js} +1 -1
- package/static/app/assets/{hard-drive-D53MsWkV.js → hard-drive-BwQcSPlS.js} +1 -1
- package/static/app/assets/index-CQWdDU3z.css +2 -0
- package/static/app/assets/{index-C_IrlQMV.js → index-DDV2YZwM.js} +3 -3
- package/static/app/assets/{input-C5m0riF6.js → input-DFhSjmLS.js} +1 -1
- package/static/app/assets/{network-C5a-E5iS.js → network-Bts7VO94.js} +1 -1
- package/static/app/assets/{primitives-vNXYf58F.js → primitives-BuSMEJY8.js} +1 -1
- package/static/app/assets/search-DZzxhWaQ.js +1 -0
- package/static/app/assets/{shield-alert-Cc-WVXqN.js → shield-alert-BfTO6X_G.js} +1 -1
- package/static/app/assets/{textarea-BkZ0EqVO.js → textarea-BjctW1oh.js} +1 -1
- package/static/app/assets/{useFocusTrap-CTtKbAOU.js → useFocusTrap-CE43-LrA.js} +1 -1
- package/static/app/assets/{useQuery-Dx1fi4Wu.js → useQuery-Bv3ejLL5.js} +1 -1
- package/static/app/assets/{users-BFpQXtEF.js → users-CsNqLZAj.js} +1 -1
- package/static/app/assets/{utils-BA_lmW3J.js → utils-KFFdVG_t.js} +2 -2
- package/static/app/assets/workspace-wdCvdyPF.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/System-CSMdYLMy.js +0 -1
- package/static/app/assets/index-FxDusbr0.css +0 -2
- package/static/app/assets/search-DhbSgW6m.js +0 -1
- package/static/app/assets/workspace-DBPB0jkX.js +0 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Audit record for the one path where knowledge leaves the machine.
|
|
2
|
+
|
|
3
|
+
Mode *changes* were audited from 10.1.0; the send itself was not. For a product
|
|
4
|
+
whose central claim is that nothing leaves without consent, the absence of a
|
|
5
|
+
record on the only egress path meant the claim could not be checked after the
|
|
6
|
+
fact — not by the user, not by anyone reviewing an incident.
|
|
7
|
+
|
|
8
|
+
What is recorded is deliberately about *shape*, never content: which node ids
|
|
9
|
+
went, how many, the token estimate, the provider and model, the resolved mode,
|
|
10
|
+
and the scope. The compact payload text is not recorded — writing the outbound
|
|
11
|
+
knowledge into a second on-disk location to prove we were careful with it would
|
|
12
|
+
be its own leak.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_LOCK = threading.Lock()
|
|
24
|
+
_AUDIT: Optional[Callable[..., None]] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bind_egress_audit(audit: Optional[Callable[..., None]]) -> None:
|
|
28
|
+
"""Install the process-wide audit sink (called from runtime wiring)."""
|
|
29
|
+
global _AUDIT
|
|
30
|
+
with _LOCK:
|
|
31
|
+
_AUDIT = audit
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sink() -> Optional[Callable[..., None]]:
|
|
35
|
+
with _LOCK:
|
|
36
|
+
return _AUDIT
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record_cloud_egress(
|
|
40
|
+
*,
|
|
41
|
+
node_ids: List[str],
|
|
42
|
+
token_estimate: int,
|
|
43
|
+
mode: str,
|
|
44
|
+
provider: str,
|
|
45
|
+
model: Optional[str] = None,
|
|
46
|
+
user_email: Optional[str] = None,
|
|
47
|
+
workspace_id: Optional[str] = None,
|
|
48
|
+
outcome: str = "sent",
|
|
49
|
+
detail: Optional[str] = None,
|
|
50
|
+
) -> Dict[str, Any]:
|
|
51
|
+
"""Record one cloud send (or refusal) and return the event.
|
|
52
|
+
|
|
53
|
+
Returns the event even when no sink is bound so callers and tests can
|
|
54
|
+
assert on it. A failing audit sink never blocks or breaks the turn — but it
|
|
55
|
+
is logged at warning, because a silent audit failure is the same as no
|
|
56
|
+
audit at all.
|
|
57
|
+
"""
|
|
58
|
+
event: Dict[str, Any] = {
|
|
59
|
+
"event": "cloud_egress",
|
|
60
|
+
"outcome": outcome,
|
|
61
|
+
"mode": mode,
|
|
62
|
+
"provider": provider,
|
|
63
|
+
"model": model,
|
|
64
|
+
"node_ids": list(node_ids),
|
|
65
|
+
"node_count": len(node_ids),
|
|
66
|
+
"token_estimate": int(token_estimate),
|
|
67
|
+
"user_email": user_email,
|
|
68
|
+
"workspace_id": workspace_id,
|
|
69
|
+
}
|
|
70
|
+
if detail:
|
|
71
|
+
event["detail"] = detail
|
|
72
|
+
|
|
73
|
+
sink = _sink()
|
|
74
|
+
if sink is None:
|
|
75
|
+
# Not an error: unit tests and headless helpers run without wiring.
|
|
76
|
+
logger.debug("cloud egress (no audit sink bound): %s", event)
|
|
77
|
+
return event
|
|
78
|
+
try:
|
|
79
|
+
sink(**event)
|
|
80
|
+
except Exception: # noqa: BLE001
|
|
81
|
+
logger.warning("cloud egress audit sink failed; the send still happened", exc_info=True)
|
|
82
|
+
return event
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["bind_egress_audit", "record_cloud_egress"]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Richer candidate extraction from cloud answers (Phase 2).
|
|
2
|
+
|
|
3
|
+
Deterministic, dependency-free heuristics that turn a cloud answer into
|
|
4
|
+
candidate Concept / Decision / Task nodes. These are always staged with
|
|
5
|
+
provenance and never auto-committed in Phase 2.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from latticeai.services.cloud_streaming import (
|
|
14
|
+
CloudTurnResult,
|
|
15
|
+
KGExpansionPlan,
|
|
16
|
+
plan_kg_expansion,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_DECISION_PATTERNS = (
|
|
20
|
+
re.compile(r"(?im)^(?:decision|결정)\s*[::-]\s*(.+)$"),
|
|
21
|
+
re.compile(r"(?im)we (?:decided|agreed|chose) to (.+?)(?:\.|$)"),
|
|
22
|
+
re.compile(r"(?im)(?:결정(?:했|하)|합의)(?:습니다|다)?\s*[::]?\s*(.+)"),
|
|
23
|
+
)
|
|
24
|
+
_TASK_PATTERNS = (
|
|
25
|
+
re.compile(r"(?im)^(?:todo|task|할\s*일|다음)\s*[::-]\s*(.+)$"),
|
|
26
|
+
re.compile(r"(?im)^[-*]\s+\[(?: |x|X)\]\s*(.+)$"),
|
|
27
|
+
re.compile(r"(?im)^\d+[.)]\s+(.+)$"),
|
|
28
|
+
)
|
|
29
|
+
_CONCEPT_PATTERNS = (
|
|
30
|
+
re.compile(r"(?im)^(?:concept|개념|용어)\s*[::-]\s*(.+)$"),
|
|
31
|
+
re.compile(r"\*\*([^*]{2,80})\*\*"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean(text: str, limit: int = 200) -> str:
|
|
36
|
+
return re.sub(r"\s+", " ", str(text or "").strip())[:limit]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_candidates(answer: str, *, limit: int = 8) -> List[Dict[str, Any]]:
|
|
40
|
+
"""Return lightweight candidate nodes derived from the answer text."""
|
|
41
|
+
text = str(answer or "")
|
|
42
|
+
out: List[Dict[str, Any]] = []
|
|
43
|
+
seen: set[str] = set()
|
|
44
|
+
|
|
45
|
+
def add(node_type: str, title: str, summary: str = "") -> None:
|
|
46
|
+
title = _clean(title, 120)
|
|
47
|
+
if not title:
|
|
48
|
+
return
|
|
49
|
+
key = f"{node_type}:{title.lower()}"
|
|
50
|
+
if key in seen:
|
|
51
|
+
return
|
|
52
|
+
seen.add(key)
|
|
53
|
+
out.append(
|
|
54
|
+
{
|
|
55
|
+
"type": node_type,
|
|
56
|
+
"title": title,
|
|
57
|
+
"summary": _clean(summary or title, 400),
|
|
58
|
+
"metadata": {
|
|
59
|
+
"derived_from_cloud": True,
|
|
60
|
+
"extraction": "heuristic_v1",
|
|
61
|
+
"confidence": 0.55,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for pattern in _DECISION_PATTERNS:
|
|
67
|
+
for match in pattern.finditer(text):
|
|
68
|
+
add("Decision", match.group(1))
|
|
69
|
+
if len(out) >= limit:
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
for pattern in _TASK_PATTERNS:
|
|
73
|
+
for match in pattern.finditer(text):
|
|
74
|
+
add("Task", match.group(1))
|
|
75
|
+
if len(out) >= limit:
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
for pattern in _CONCEPT_PATTERNS:
|
|
79
|
+
for match in pattern.finditer(text):
|
|
80
|
+
add("Concept", match.group(1))
|
|
81
|
+
if len(out) >= limit:
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plan_kg_expansion_rich(result: CloudTurnResult) -> KGExpansionPlan:
|
|
88
|
+
"""Base conversation plan + heuristic Concept/Decision/Task candidates."""
|
|
89
|
+
plan = plan_kg_expansion(result)
|
|
90
|
+
candidates = extract_candidates(result.answer_text)
|
|
91
|
+
turn_id = plan.new_nodes[0]["id"] if plan.new_nodes else "cloud_turn:unknown"
|
|
92
|
+
|
|
93
|
+
for idx, cand in enumerate(candidates):
|
|
94
|
+
node_id = f"{turn_id}:cand:{idx}"
|
|
95
|
+
node = {
|
|
96
|
+
"id": node_id,
|
|
97
|
+
**cand,
|
|
98
|
+
}
|
|
99
|
+
plan.new_nodes.append(node)
|
|
100
|
+
plan.new_edges.append(
|
|
101
|
+
{
|
|
102
|
+
"from": turn_id,
|
|
103
|
+
"to": node_id,
|
|
104
|
+
"type": "implies",
|
|
105
|
+
"weight": 0.6,
|
|
106
|
+
"metadata": {"provenance": "cloud_extraction"},
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
for src in result.sent_node_ids:
|
|
110
|
+
plan.new_edges.append(
|
|
111
|
+
{
|
|
112
|
+
"from": node_id,
|
|
113
|
+
"to": src,
|
|
114
|
+
"type": "grounded_on",
|
|
115
|
+
"weight": 0.5,
|
|
116
|
+
"metadata": {"provenance": "cloud_extraction"},
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
plan.provenance = {
|
|
121
|
+
**plan.provenance,
|
|
122
|
+
"candidate_count": len(candidates),
|
|
123
|
+
"extraction": "heuristic_v1",
|
|
124
|
+
}
|
|
125
|
+
plan.auto_commit = False
|
|
126
|
+
return plan
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = ["extract_candidates", "plan_kg_expansion_rich"]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Cloud streaming bridge and Knowledge Graph expansion for hybrid turns.
|
|
2
|
+
|
|
3
|
+
When NetworkBoundaryMode is CLOUD_ALLOWED:
|
|
4
|
+
|
|
5
|
+
1. MinimalContext is sent to a cloud LLM.
|
|
6
|
+
2. The response is streamed back to the UI.
|
|
7
|
+
3. On completion the answer expands the local KG with provenance.
|
|
8
|
+
|
|
9
|
+
Phase 3: CloudResponseIngestor can enqueue into the Review Queue and honor
|
|
10
|
+
hybrid policy auto_commit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, AsyncIterator, Dict, List, Optional, Protocol
|
|
17
|
+
|
|
18
|
+
from latticeai.core.network_boundary import (
|
|
19
|
+
NetworkBoundaryMode,
|
|
20
|
+
normalize_network_mode,
|
|
21
|
+
)
|
|
22
|
+
from latticeai.services.hybrid_context import MinimalContext
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CloudTurnResult:
|
|
27
|
+
"""Completed cloud turn ready for local KG expansion."""
|
|
28
|
+
|
|
29
|
+
user_message: str
|
|
30
|
+
answer_text: str
|
|
31
|
+
sent_node_ids: List[str] = field(default_factory=list)
|
|
32
|
+
provider: str = ""
|
|
33
|
+
model: str = ""
|
|
34
|
+
usage: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
raw_events: List[Dict[str, Any]] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"user_message": self.user_message,
|
|
40
|
+
"answer_text": self.answer_text,
|
|
41
|
+
"sent_node_ids": list(self.sent_node_ids),
|
|
42
|
+
"provider": self.provider,
|
|
43
|
+
"model": self.model,
|
|
44
|
+
"usage": dict(self.usage),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CloudLLMAdapter(Protocol):
|
|
49
|
+
async def stream(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
system: str,
|
|
53
|
+
user: str,
|
|
54
|
+
context: str,
|
|
55
|
+
model: Optional[str] = None,
|
|
56
|
+
) -> AsyncIterator[str]:
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CloudStreamingBridge:
|
|
61
|
+
def __init__(self, adapter: Optional[CloudLLMAdapter] = None) -> None:
|
|
62
|
+
self._adapter = adapter
|
|
63
|
+
|
|
64
|
+
async def run_turn(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
user_message: str,
|
|
68
|
+
minimal: MinimalContext,
|
|
69
|
+
mode: NetworkBoundaryMode | str,
|
|
70
|
+
system_prompt: str = (
|
|
71
|
+
"You are assisting a user whose private Knowledge Graph lives on their machine. "
|
|
72
|
+
"Use only the provided context. If the context is insufficient, say so honestly."
|
|
73
|
+
),
|
|
74
|
+
model: Optional[str] = None,
|
|
75
|
+
) -> CloudTurnResult:
|
|
76
|
+
mode = normalize_network_mode(mode)
|
|
77
|
+
if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
|
|
78
|
+
raise PermissionError(
|
|
79
|
+
"CloudStreamingBridge refuses to call a cloud provider while "
|
|
80
|
+
f"NetworkBoundaryMode is {mode.value!r}"
|
|
81
|
+
)
|
|
82
|
+
if self._adapter is None:
|
|
83
|
+
answer = (
|
|
84
|
+
"[cloud adapter not configured] "
|
|
85
|
+
"This turn would have streamed a cloud response grounded on the "
|
|
86
|
+
f"{len(minimal.node_ids)} local node(s) that were selected."
|
|
87
|
+
)
|
|
88
|
+
return CloudTurnResult(
|
|
89
|
+
user_message=user_message,
|
|
90
|
+
answer_text=answer,
|
|
91
|
+
sent_node_ids=list(minimal.node_ids),
|
|
92
|
+
provider="none",
|
|
93
|
+
model=model or "",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
chunks: List[str] = []
|
|
97
|
+
async for piece in self._adapter.stream(
|
|
98
|
+
system=system_prompt,
|
|
99
|
+
user=user_message,
|
|
100
|
+
context=minimal.compact_text,
|
|
101
|
+
model=model,
|
|
102
|
+
):
|
|
103
|
+
chunks.append(piece)
|
|
104
|
+
answer = "".join(chunks)
|
|
105
|
+
return CloudTurnResult(
|
|
106
|
+
user_message=user_message,
|
|
107
|
+
answer_text=answer,
|
|
108
|
+
sent_node_ids=list(minimal.node_ids),
|
|
109
|
+
provider=getattr(self._adapter, "provider_name", "cloud"),
|
|
110
|
+
model=model or getattr(self._adapter, "default_model", ""),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class KGExpansionPlan:
|
|
116
|
+
conversation_title: str
|
|
117
|
+
new_nodes: List[Dict[str, Any]] = field(default_factory=list)
|
|
118
|
+
new_edges: List[Dict[str, Any]] = field(default_factory=list)
|
|
119
|
+
provenance: Dict[str, Any] = field(default_factory=dict)
|
|
120
|
+
auto_commit: bool = False
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
123
|
+
return {
|
|
124
|
+
"conversation_title": self.conversation_title,
|
|
125
|
+
"new_nodes": list(self.new_nodes),
|
|
126
|
+
"new_edges": list(self.new_edges),
|
|
127
|
+
"provenance": dict(self.provenance),
|
|
128
|
+
"auto_commit": self.auto_commit,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def plan_kg_expansion(result: CloudTurnResult) -> KGExpansionPlan:
|
|
133
|
+
turn_id = f"cloud_turn:{abs(hash((result.user_message, result.answer_text))) % (10**12)}"
|
|
134
|
+
conv_node = {
|
|
135
|
+
"id": turn_id,
|
|
136
|
+
"type": "Chat",
|
|
137
|
+
"title": (result.user_message or "Cloud turn")[:120],
|
|
138
|
+
"summary": (result.answer_text or "")[:800],
|
|
139
|
+
"metadata": {
|
|
140
|
+
"source": "cloud_llm",
|
|
141
|
+
"provider": result.provider,
|
|
142
|
+
"model": result.model,
|
|
143
|
+
"sent_node_ids": list(result.sent_node_ids),
|
|
144
|
+
"derived_from_cloud": True,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
edges: List[Dict[str, Any]] = []
|
|
148
|
+
for nid in result.sent_node_ids:
|
|
149
|
+
edges.append(
|
|
150
|
+
{
|
|
151
|
+
"from": turn_id,
|
|
152
|
+
"to": nid,
|
|
153
|
+
"type": "grounded_on",
|
|
154
|
+
"weight": 1.0,
|
|
155
|
+
"metadata": {"provenance": "cloud_turn"},
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return KGExpansionPlan(
|
|
160
|
+
conversation_title=conv_node["title"],
|
|
161
|
+
new_nodes=[conv_node],
|
|
162
|
+
new_edges=edges,
|
|
163
|
+
provenance={
|
|
164
|
+
"kind": "derived_from_cloud",
|
|
165
|
+
"sent_node_ids": list(result.sent_node_ids),
|
|
166
|
+
"provider": result.provider,
|
|
167
|
+
"model": result.model,
|
|
168
|
+
},
|
|
169
|
+
auto_commit=False,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class CloudResponseIngestor:
|
|
174
|
+
"""Applies a KGExpansionPlan to the local store and/or Review Queue.
|
|
175
|
+
|
|
176
|
+
Phase 3 behaviour:
|
|
177
|
+
|
|
178
|
+
* always stages a Review Queue item (change_proposal) when a review_queue
|
|
179
|
+
sink is bound — so the user can approve cloud-derived memory growth
|
|
180
|
+
* if ``plan.auto_commit`` and a store is bound, also attempts a direct write
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
store: Any = None,
|
|
186
|
+
*,
|
|
187
|
+
review_queue: Any = None,
|
|
188
|
+
user_email: Optional[str] = None,
|
|
189
|
+
workspace_id: Optional[str] = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
self._store = store
|
|
192
|
+
self._review_queue = review_queue
|
|
193
|
+
self._user_email = user_email
|
|
194
|
+
self._workspace_id = workspace_id
|
|
195
|
+
|
|
196
|
+
def ingest(self, plan: KGExpansionPlan) -> Dict[str, Any]:
|
|
197
|
+
result: Dict[str, Any] = {
|
|
198
|
+
"status": "staged",
|
|
199
|
+
"plan": plan.to_dict(),
|
|
200
|
+
"review_item_id": None,
|
|
201
|
+
"written_nodes": 0,
|
|
202
|
+
"written_edges": 0,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if self._review_queue is not None:
|
|
206
|
+
try:
|
|
207
|
+
item = self._review_queue.create(
|
|
208
|
+
title=f"Cloud KG expansion: {plan.conversation_title[:80]}",
|
|
209
|
+
summary=(
|
|
210
|
+
f"{len(plan.new_nodes)} node(s), {len(plan.new_edges)} edge(s) "
|
|
211
|
+
f"derived from cloud LLM (auto_commit={plan.auto_commit})"
|
|
212
|
+
),
|
|
213
|
+
source="change_proposal",
|
|
214
|
+
kind="kg_cloud_expansion",
|
|
215
|
+
payload={
|
|
216
|
+
"plan": plan.to_dict(),
|
|
217
|
+
"auto_commit": plan.auto_commit,
|
|
218
|
+
},
|
|
219
|
+
provenance={
|
|
220
|
+
**plan.provenance,
|
|
221
|
+
"source": "hybrid_cloud",
|
|
222
|
+
},
|
|
223
|
+
user_email=self._user_email,
|
|
224
|
+
workspace_id=self._workspace_id,
|
|
225
|
+
)
|
|
226
|
+
result["review_item_id"] = item.get("id")
|
|
227
|
+
result["status"] = "queued_for_review"
|
|
228
|
+
except Exception as exc: # noqa: BLE001
|
|
229
|
+
result["review_error"] = str(exc)
|
|
230
|
+
|
|
231
|
+
if plan.auto_commit and self._store is not None:
|
|
232
|
+
written_nodes = 0
|
|
233
|
+
written_edges = 0
|
|
234
|
+
try:
|
|
235
|
+
write_fn = getattr(self._store, "upsert_nodes", None) or getattr(
|
|
236
|
+
self._store, "ingest_nodes", None
|
|
237
|
+
)
|
|
238
|
+
if callable(write_fn):
|
|
239
|
+
write_fn(plan.new_nodes, plan.new_edges)
|
|
240
|
+
written_nodes = len(plan.new_nodes)
|
|
241
|
+
written_edges = len(plan.new_edges)
|
|
242
|
+
result["status"] = "accepted"
|
|
243
|
+
else:
|
|
244
|
+
# Soft accept: store present but no known write API.
|
|
245
|
+
result["status"] = result.get("status") or "accepted_soft"
|
|
246
|
+
written_nodes = len(plan.new_nodes)
|
|
247
|
+
written_edges = len(plan.new_edges)
|
|
248
|
+
except Exception as exc: # noqa: BLE001
|
|
249
|
+
result["write_error"] = str(exc)
|
|
250
|
+
result["written_nodes"] = written_nodes
|
|
251
|
+
result["written_edges"] = written_edges
|
|
252
|
+
|
|
253
|
+
if result["status"] == "staged" and self._store is None and self._review_queue is None:
|
|
254
|
+
result["reason"] = "no store or review_queue bound"
|
|
255
|
+
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
__all__ = [
|
|
260
|
+
"CloudTurnResult",
|
|
261
|
+
"CloudLLMAdapter",
|
|
262
|
+
"CloudStreamingBridge",
|
|
263
|
+
"KGExpansionPlan",
|
|
264
|
+
"plan_kg_expansion",
|
|
265
|
+
"CloudResponseIngestor",
|
|
266
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Token / cost guardrails for hybrid cloud turns (Phase 2).
|
|
2
|
+
|
|
3
|
+
Keeps cloud usage bounded:
|
|
4
|
+
|
|
5
|
+
* per-turn token estimate must stay under ``max_tokens_per_turn``
|
|
6
|
+
* optional session budget (in-memory, process-local) tracks cumulative usage
|
|
7
|
+
|
|
8
|
+
These are soft product guardrails, not hard billing meters. Real provider
|
|
9
|
+
usage is still authoritative when the cloud returns usage fields.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _env_int(name: str, default: int) -> int:
|
|
21
|
+
raw = os.environ.get(name, "").strip()
|
|
22
|
+
if not raw:
|
|
23
|
+
return default
|
|
24
|
+
try:
|
|
25
|
+
return max(0, int(raw))
|
|
26
|
+
except ValueError:
|
|
27
|
+
return default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TokenBudget:
|
|
32
|
+
max_tokens_per_turn: int = field(
|
|
33
|
+
default_factory=lambda: _env_int("LATTICEAI_CLOUD_MAX_TOKENS_PER_TURN", 2500)
|
|
34
|
+
)
|
|
35
|
+
max_tokens_per_session: int = field(
|
|
36
|
+
default_factory=lambda: _env_int("LATTICEAI_CLOUD_MAX_TOKENS_PER_SESSION", 50000)
|
|
37
|
+
)
|
|
38
|
+
session_used: int = 0
|
|
39
|
+
|
|
40
|
+
def check_turn(self, estimated_input_tokens: int) -> Optional[str]:
|
|
41
|
+
"""Return a refusal reason, or None if the turn is allowed."""
|
|
42
|
+
if estimated_input_tokens > self.max_tokens_per_turn:
|
|
43
|
+
return (
|
|
44
|
+
f"estimated input tokens {estimated_input_tokens} exceed "
|
|
45
|
+
f"per-turn limit {self.max_tokens_per_turn}"
|
|
46
|
+
)
|
|
47
|
+
if self.session_used + estimated_input_tokens > self.max_tokens_per_session:
|
|
48
|
+
return (
|
|
49
|
+
f"session budget would exceed {self.max_tokens_per_session} "
|
|
50
|
+
f"(used={self.session_used}, turn={estimated_input_tokens})"
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def record(self, tokens: int) -> None:
|
|
55
|
+
self.session_used += max(0, int(tokens or 0))
|
|
56
|
+
|
|
57
|
+
def snapshot(self) -> Dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"max_tokens_per_turn": self.max_tokens_per_turn,
|
|
60
|
+
"max_tokens_per_session": self.max_tokens_per_session,
|
|
61
|
+
"session_used": self.session_used,
|
|
62
|
+
"session_remaining": max(0, self.max_tokens_per_session - self.session_used),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_LOCK = threading.Lock()
|
|
67
|
+
_BUDGETS: Dict[str, TokenBudget] = {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def budget_for(scope_key: str) -> TokenBudget:
|
|
71
|
+
"""Process-local budget keyed by user|workspace."""
|
|
72
|
+
key = str(scope_key or "global")
|
|
73
|
+
with _LOCK:
|
|
74
|
+
if key not in _BUDGETS:
|
|
75
|
+
_BUDGETS[key] = TokenBudget()
|
|
76
|
+
return _BUDGETS[key]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def reset_budget(scope_key: str) -> None:
|
|
80
|
+
with _LOCK:
|
|
81
|
+
_BUDGETS.pop(str(scope_key or "global"), None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["TokenBudget", "budget_for", "reset_budget"]
|
|
@@ -26,7 +26,8 @@ import logging
|
|
|
26
26
|
from datetime import datetime, timedelta
|
|
27
27
|
from typing import Any, Dict, List, Optional
|
|
28
28
|
|
|
29
|
-
from latticeai.core.timeutil import local_now,
|
|
29
|
+
from latticeai.core.timeutil import local_now, parse_iso
|
|
30
|
+
from latticeai.core.timeutil import now_iso as _now
|
|
30
31
|
|
|
31
32
|
LOGGER = logging.getLogger(__name__)
|
|
32
33
|
|
|
@@ -39,6 +39,7 @@ from lattice_brain.ingestion import (
|
|
|
39
39
|
_load_latticeignore,
|
|
40
40
|
_matches_ignore,
|
|
41
41
|
)
|
|
42
|
+
from latticeai.core.quiet import quiet
|
|
42
43
|
from latticeai.core.timeutil import now_iso as _now_iso
|
|
43
44
|
|
|
44
45
|
WATCH_INTERVAL_ENV = "LATTICEAI_FOLDER_WATCH_INTERVAL"
|
|
@@ -339,6 +340,7 @@ class FolderWatchService:
|
|
|
339
340
|
try:
|
|
340
341
|
stat = path.stat()
|
|
341
342
|
except OSError:
|
|
343
|
+
quiet()
|
|
342
344
|
continue
|
|
343
345
|
if stat.st_size > DEFAULT_MAX_FILE_BYTES:
|
|
344
346
|
continue
|
|
@@ -390,6 +392,7 @@ class FolderWatchService:
|
|
|
390
392
|
try:
|
|
391
393
|
self.scan_once(watch_id)
|
|
392
394
|
except Exception: # noqa: BLE001 — one bad watch must not kill the poller
|
|
395
|
+
quiet()
|
|
393
396
|
continue
|
|
394
397
|
|
|
395
398
|
|
|
@@ -37,6 +37,8 @@ from datetime import datetime, timezone
|
|
|
37
37
|
from pathlib import Path
|
|
38
38
|
from typing import Any, Dict, Optional
|
|
39
39
|
|
|
40
|
+
from latticeai.core.quiet import quiet
|
|
41
|
+
|
|
40
42
|
LOGGER = logging.getLogger(__name__)
|
|
41
43
|
|
|
42
44
|
COUNTER_NAMES = (
|
|
@@ -124,7 +126,7 @@ class FunnelMetricsService:
|
|
|
124
126
|
try:
|
|
125
127
|
os.unlink(tmp_name)
|
|
126
128
|
except OSError:
|
|
127
|
-
|
|
129
|
+
quiet()
|
|
128
130
|
except Exception as exc: # noqa: BLE001 — metrics persistence is best-effort
|
|
129
131
|
LOGGER.warning("funnel metrics save failed: %s", exc)
|
|
130
132
|
|