ltcai 3.5.0 → 4.0.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 +73 -35
- package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
- package/docs/CHANGELOG.md +32 -0
- package/docs/HANDOVER_v3.6.0.md +46 -0
- package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/architecture.md +13 -12
- package/docs/kg-schema.md +102 -53
- package/docs/privacy.md +18 -2
- package/docs/security-model.md +17 -0
- package/kg_schema.py +139 -10
- package/knowledge_graph.py +874 -26
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +7 -1
- package/latticeai/api/auth.py +27 -4
- package/latticeai/api/browser.py +217 -0
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/knowledge_graph.py +146 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/portability.py +93 -0
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +2 -4
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/workflow_designer.py +46 -0
- package/latticeai/api/workspace.py +71 -49
- package/latticeai/app_factory.py +1710 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +37 -7
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +58 -10
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1504
- package/latticeai/services/agent_runtime.py +1 -0
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +318 -0
- package/latticeai/services/kg_portability.py +207 -0
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +31 -0
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +11 -8
- package/scripts/build_vsix.mjs +72 -0
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +82 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/account.html +2 -4
- package/static/admin.html +3 -5
- package/static/chat.html +3 -6
- package/static/graph.html +2 -4
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +20 -19
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.d086489d.js → app.356e6452.js} +1 -1
- package/static/v3/js/core/{api.12b568ad.js → api.7a308b89.js} +39 -1
- package/static/v3/js/core/api.js +38 -0
- package/static/v3/js/core/{routes.d214b399.js → routes.7222343d.js} +22 -22
- package/static/v3/js/core/routes.js +22 -22
- package/static/v3/js/core/{shell.d05266f5.js → shell.a1657f20.js} +4 -4
- package/static/v3/js/core/shell.js +1 -1
- package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
- package/static/v3/js/core/store.js +1 -1
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/knowledge-graph.5e40cbeb.js +509 -0
- package/static/v3/js/views/knowledge-graph.js +326 -54
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/static/workspace.html +2 -2
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
- package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Trigger system (T7d) — workflows fire beyond 'manual'.
|
|
2
|
+
|
|
3
|
+
Two real trigger types:
|
|
4
|
+
|
|
5
|
+
* **interval** — a supervised scheduler loop fires the workflow every
|
|
6
|
+
``interval_seconds``. Firings missed while the server was down are
|
|
7
|
+
SKIPPED with a recorded skip event (design-review amendment: no silent
|
|
8
|
+
gaps, no thundering catch-up).
|
|
9
|
+
* **brain_event** — the killer Digital Brain feature: "when new knowledge
|
|
10
|
+
enters the brain, run this workflow". Wired through the existing hooks
|
|
11
|
+
bus (the ingestion pipeline fires ``post_tool`` on ``kg_ingest.<source>``);
|
|
12
|
+
an optional ``source_type`` filter narrows it.
|
|
13
|
+
|
|
14
|
+
Trigger-fired runs carry provenance: their inputs include ``__trigger__``
|
|
15
|
+
describing what fired them, persisted in the run record like any other
|
|
16
|
+
input.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
DEFAULT_TICK_SECONDS = 5.0
|
|
29
|
+
MIN_INTERVAL_SECONDS = 60
|
|
30
|
+
|
|
31
|
+
TRIGGER_HOOK_NAME = "brain-event-triggers"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TriggerService:
|
|
35
|
+
"""Scans workflow definitions for non-manual triggers and fires them."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
store: Any,
|
|
41
|
+
run_workflow: Callable[[str, Dict[str, Any]], Dict[str, Any]],
|
|
42
|
+
data_dir: Path,
|
|
43
|
+
clock: Callable[[], float] = time.time,
|
|
44
|
+
tick_seconds: float = DEFAULT_TICK_SECONDS,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._store = store
|
|
47
|
+
self._run_workflow = run_workflow
|
|
48
|
+
self._state_file = Path(data_dir) / "triggers_state.json"
|
|
49
|
+
self._clock = clock
|
|
50
|
+
self._tick = float(tick_seconds)
|
|
51
|
+
self._stop_event = threading.Event()
|
|
52
|
+
self._thread: Optional[threading.Thread] = None
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
# ── durable state ──────────────────────────────────────────────────────
|
|
56
|
+
def _load_state(self) -> Dict[str, Any]:
|
|
57
|
+
if not self._state_file.exists():
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(self._state_file.read_text(encoding="utf-8"))
|
|
61
|
+
except Exception:
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
def _save_state(self, state: Dict[str, Any]) -> None:
|
|
65
|
+
self._state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
tmp = self._state_file.with_suffix(".tmp")
|
|
67
|
+
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
68
|
+
tmp.replace(self._state_file)
|
|
69
|
+
|
|
70
|
+
def _record_event(self, state: Dict[str, Any], workflow_id: str, event: Dict[str, Any]) -> None:
|
|
71
|
+
entry = state.setdefault(workflow_id, {})
|
|
72
|
+
events = entry.setdefault("events", [])
|
|
73
|
+
events.append({**event, "at": self._clock()})
|
|
74
|
+
entry["events"] = events[-50:]
|
|
75
|
+
|
|
76
|
+
# ── definition scanning ────────────────────────────────────────────────
|
|
77
|
+
def _triggered_workflows(self) -> List[Dict[str, Any]]:
|
|
78
|
+
found = []
|
|
79
|
+
try:
|
|
80
|
+
workflows = list(self._store.load_state().get("workflows") or [])
|
|
81
|
+
except Exception:
|
|
82
|
+
return []
|
|
83
|
+
for wf in workflows:
|
|
84
|
+
for node in wf.get("nodes") or []:
|
|
85
|
+
if node.get("type") != "trigger":
|
|
86
|
+
continue
|
|
87
|
+
cfg = node.get("config") or {}
|
|
88
|
+
kind = str(cfg.get("trigger") or "manual")
|
|
89
|
+
if kind in ("interval", "brain_event"):
|
|
90
|
+
found.append({"workflow": wf, "node": node, "kind": kind, "config": cfg})
|
|
91
|
+
return found
|
|
92
|
+
|
|
93
|
+
def describe(self) -> Dict[str, Any]:
|
|
94
|
+
"""Honest status surface: what is armed, when it last fired/skipped."""
|
|
95
|
+
state = self._load_state()
|
|
96
|
+
armed = []
|
|
97
|
+
for item in self._triggered_workflows():
|
|
98
|
+
wf_id = item["workflow"].get("id")
|
|
99
|
+
armed.append({
|
|
100
|
+
"workflow_id": wf_id,
|
|
101
|
+
"name": item["workflow"].get("name"),
|
|
102
|
+
"kind": item["kind"],
|
|
103
|
+
"config": {k: v for k, v in item["config"].items() if k != "trigger"},
|
|
104
|
+
"last_fired_at": (state.get(wf_id) or {}).get("last_fired_at"),
|
|
105
|
+
"recent_events": (state.get(wf_id) or {}).get("events", [])[-5:],
|
|
106
|
+
})
|
|
107
|
+
return {
|
|
108
|
+
"running": bool(self._thread and self._thread.is_alive()),
|
|
109
|
+
"tick_seconds": self._tick,
|
|
110
|
+
"armed": armed,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# ── interval scheduling ────────────────────────────────────────────────
|
|
114
|
+
def reconcile_missed(self) -> int:
|
|
115
|
+
"""Startup pass: record (never replay) firings missed while down."""
|
|
116
|
+
now = self._clock()
|
|
117
|
+
skipped = 0
|
|
118
|
+
with self._lock:
|
|
119
|
+
state = self._load_state()
|
|
120
|
+
for item in self._triggered_workflows():
|
|
121
|
+
if item["kind"] != "interval":
|
|
122
|
+
continue
|
|
123
|
+
wf_id = item["workflow"].get("id")
|
|
124
|
+
interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
|
|
125
|
+
entry = state.setdefault(wf_id, {})
|
|
126
|
+
last = entry.get("last_fired_at")
|
|
127
|
+
if last is not None and now - float(last) > interval:
|
|
128
|
+
missed = int((now - float(last)) // interval)
|
|
129
|
+
self._record_event(state, wf_id, {
|
|
130
|
+
"type": "skipped",
|
|
131
|
+
"reason": f"{missed} interval firing(s) missed while the server was down",
|
|
132
|
+
})
|
|
133
|
+
skipped += missed
|
|
134
|
+
# Reset the cadence from now — no catch-up storm.
|
|
135
|
+
entry["last_fired_at"] = now if last is not None else entry.get("last_fired_at")
|
|
136
|
+
self._save_state(state)
|
|
137
|
+
return skipped
|
|
138
|
+
|
|
139
|
+
def tick_intervals(self) -> int:
|
|
140
|
+
"""One scheduler pass; returns how many workflows fired."""
|
|
141
|
+
now = self._clock()
|
|
142
|
+
fired = 0
|
|
143
|
+
with self._lock:
|
|
144
|
+
state = self._load_state()
|
|
145
|
+
for item in self._triggered_workflows():
|
|
146
|
+
if item["kind"] != "interval":
|
|
147
|
+
continue
|
|
148
|
+
wf_id = item["workflow"].get("id")
|
|
149
|
+
interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
|
|
150
|
+
entry = state.setdefault(wf_id, {})
|
|
151
|
+
last = entry.get("last_fired_at")
|
|
152
|
+
if last is None:
|
|
153
|
+
# First sighting arms the schedule; it fires one interval later.
|
|
154
|
+
entry["last_fired_at"] = now
|
|
155
|
+
continue
|
|
156
|
+
if now - float(last) < interval:
|
|
157
|
+
continue
|
|
158
|
+
entry["last_fired_at"] = now
|
|
159
|
+
self._record_event(state, wf_id, {"type": "fired", "trigger": "interval"})
|
|
160
|
+
fired += 1
|
|
161
|
+
self._fire(wf_id, {
|
|
162
|
+
"type": "interval",
|
|
163
|
+
"interval_seconds": interval,
|
|
164
|
+
"fired_at": now,
|
|
165
|
+
})
|
|
166
|
+
self._save_state(state)
|
|
167
|
+
return fired
|
|
168
|
+
|
|
169
|
+
# ── brain events ───────────────────────────────────────────────────────
|
|
170
|
+
def on_brain_event(self, event: str, payload: Optional[Dict[str, Any]] = None) -> int:
|
|
171
|
+
"""Fire workflows whose brain_event trigger matches this ingestion."""
|
|
172
|
+
payload = payload or {}
|
|
173
|
+
source_type = str(payload.get("source_type") or event.split(".", 1)[-1] or "")
|
|
174
|
+
fired = 0
|
|
175
|
+
with self._lock:
|
|
176
|
+
state = self._load_state()
|
|
177
|
+
for item in self._triggered_workflows():
|
|
178
|
+
if item["kind"] != "brain_event":
|
|
179
|
+
continue
|
|
180
|
+
wanted = str(item["config"].get("source_type") or "").strip()
|
|
181
|
+
if wanted and wanted != source_type:
|
|
182
|
+
continue
|
|
183
|
+
wf_id = item["workflow"].get("id")
|
|
184
|
+
state.setdefault(wf_id, {})["last_fired_at"] = self._clock()
|
|
185
|
+
self._record_event(state, wf_id, {
|
|
186
|
+
"type": "fired", "trigger": "brain_event", "source_type": source_type,
|
|
187
|
+
})
|
|
188
|
+
fired += 1
|
|
189
|
+
self._fire(wf_id, {
|
|
190
|
+
"type": "brain_event",
|
|
191
|
+
"event": event,
|
|
192
|
+
"source_type": source_type,
|
|
193
|
+
"node_id": payload.get("node_id"),
|
|
194
|
+
})
|
|
195
|
+
self._save_state(state)
|
|
196
|
+
return fired
|
|
197
|
+
|
|
198
|
+
def hook_runner(self):
|
|
199
|
+
"""A post_tool hook runner: ingestion events fan into triggers."""
|
|
200
|
+
def runner(context):
|
|
201
|
+
event = str(getattr(context, "event", "") or "")
|
|
202
|
+
if not event.startswith("kg_ingest."):
|
|
203
|
+
return {"status": "ok", "output": "not an ingestion event"}
|
|
204
|
+
payload = context.payload if isinstance(context.payload, dict) else {}
|
|
205
|
+
fired = self.on_brain_event(event, payload)
|
|
206
|
+
return {"status": "ok", "output": f"fired {fired} workflow trigger(s)"}
|
|
207
|
+
return runner
|
|
208
|
+
|
|
209
|
+
# ── execution + lifecycle ──────────────────────────────────────────────
|
|
210
|
+
def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
|
|
211
|
+
def _run():
|
|
212
|
+
try:
|
|
213
|
+
self._run_workflow(workflow_id, {"__trigger__": trigger_info})
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
logging.warning("trigger run failed for %s: %s", workflow_id, exc)
|
|
216
|
+
|
|
217
|
+
threading.Thread(target=_run, name=f"trigger-{workflow_id}", daemon=True).start()
|
|
218
|
+
|
|
219
|
+
def start(self) -> None:
|
|
220
|
+
if self._thread and self._thread.is_alive():
|
|
221
|
+
return
|
|
222
|
+
self.reconcile_missed()
|
|
223
|
+
self._stop_event.clear()
|
|
224
|
+
|
|
225
|
+
def _loop():
|
|
226
|
+
while not self._stop_event.wait(self._tick):
|
|
227
|
+
try:
|
|
228
|
+
self.tick_intervals()
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
logging.warning("trigger scheduler tick failed: %s", exc)
|
|
231
|
+
|
|
232
|
+
self._thread = threading.Thread(target=_loop, name="trigger-scheduler", daemon=True)
|
|
233
|
+
self._thread.start()
|
|
234
|
+
|
|
235
|
+
def stop(self) -> None:
|
|
236
|
+
self._stop_event.set()
|
|
237
|
+
if self._thread:
|
|
238
|
+
self._thread.join(timeout=2)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
__all__ = ["TriggerService", "TRIGGER_HOOK_NAME", "MIN_INTERVAL_SECONDS"]
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from fastapi import HTTPException, Request, UploadFile
|
|
11
11
|
|
|
12
|
+
from latticeai.services.ingestion import IngestionItem
|
|
12
13
|
from tools import ToolError, read_document
|
|
13
14
|
|
|
14
15
|
|
|
@@ -19,6 +20,7 @@ async def process_uploaded_document(
|
|
|
19
20
|
current_user: str,
|
|
20
21
|
enable_graph: bool,
|
|
21
22
|
knowledge_graph,
|
|
23
|
+
ingestion_pipeline=None,
|
|
22
24
|
bytes_match_extension,
|
|
23
25
|
classify_sensitive_message,
|
|
24
26
|
append_audit_event,
|
|
@@ -71,18 +73,41 @@ async def process_uploaded_document(
|
|
|
71
73
|
try:
|
|
72
74
|
if not (enable_graph and knowledge_graph):
|
|
73
75
|
raise RuntimeError("graph disabled")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
if ingestion_pipeline is not None:
|
|
77
|
+
# v4: uploads enter the brain through the unified ingestion
|
|
78
|
+
# pipeline (provenance + kg_ingest hook lifecycle).
|
|
79
|
+
ingest = ingestion_pipeline.ingest(
|
|
80
|
+
IngestionItem(
|
|
81
|
+
source_type="upload",
|
|
82
|
+
title=file.filename,
|
|
83
|
+
path=tmp_path,
|
|
84
|
+
mime_type=file.content_type,
|
|
85
|
+
owner=current_user,
|
|
86
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
87
|
+
metadata={"extracted": result},
|
|
88
|
+
),
|
|
89
|
+
user_email=current_user,
|
|
90
|
+
)
|
|
91
|
+
if ingest.status != "ok":
|
|
92
|
+
raise RuntimeError(ingest.detail or f"ingestion {ingest.status}")
|
|
93
|
+
result["knowledge_graph"] = {
|
|
94
|
+
"node_id": ingest.node_id,
|
|
95
|
+
"sha256": ingest.content_hash,
|
|
96
|
+
"provenance_id": ingest.provenance_id,
|
|
97
|
+
}
|
|
98
|
+
else:
|
|
99
|
+
graph_result = knowledge_graph.ingest_document(
|
|
100
|
+
Path(tmp_path),
|
|
101
|
+
original_filename=file.filename,
|
|
102
|
+
mime_type=file.content_type,
|
|
103
|
+
uploader=current_user,
|
|
104
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
105
|
+
extracted=result,
|
|
106
|
+
)
|
|
107
|
+
result["knowledge_graph"] = {
|
|
108
|
+
"node_id": graph_result["node_id"],
|
|
109
|
+
"sha256": graph_result["sha256"],
|
|
110
|
+
}
|
|
86
111
|
except Exception as graph_error:
|
|
87
112
|
logging.warning("knowledge graph document ingest failed: %s", graph_error)
|
|
88
113
|
result["knowledge_graph"] = {"error": str(graph_error)}
|
|
@@ -72,6 +72,37 @@ class WorkspaceService:
|
|
|
72
72
|
def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
73
73
|
return self.store.has_permission(workspace_id, user_id, "write")
|
|
74
74
|
|
|
75
|
+
# ── record-level authorization (by-id access must not bypass gating) ──
|
|
76
|
+
|
|
77
|
+
def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
|
|
78
|
+
"""Authorize reading a record against ITS OWN workspace.
|
|
79
|
+
|
|
80
|
+
Records predating workspace scoping carry no workspace_id and remain
|
|
81
|
+
readable (legacy-global compatibility); a scoped record requires read
|
|
82
|
+
permission on its workspace regardless of any caller-supplied header.
|
|
83
|
+
"""
|
|
84
|
+
workspace_id = (record or {}).get("workspace_id")
|
|
85
|
+
if workspace_id:
|
|
86
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
87
|
+
|
|
88
|
+
def authorize_memory_delete(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
|
|
89
|
+
"""Delete requires owning the memory or write access to its workspace.
|
|
90
|
+
|
|
91
|
+
Ownerless records with no workspace keep their pre-v4 behaviour
|
|
92
|
+
(deletable by any authenticated local user).
|
|
93
|
+
"""
|
|
94
|
+
owner = (record or {}).get("user_email")
|
|
95
|
+
workspace_id = (record or {}).get("workspace_id")
|
|
96
|
+
if owner and owner == user_id:
|
|
97
|
+
return
|
|
98
|
+
if workspace_id:
|
|
99
|
+
self._ensure_permission(workspace_id, user_id, "write")
|
|
100
|
+
return
|
|
101
|
+
if owner and owner != user_id:
|
|
102
|
+
raise PermissionError(
|
|
103
|
+
f"'{user_id or 'anonymous'}' is not the owner of memory '{record.get('id')}'"
|
|
104
|
+
)
|
|
105
|
+
|
|
75
106
|
# ── workspace registry / summary ─────────────────────────────────────
|
|
76
107
|
|
|
77
108
|
def summary(self, user_id: Optional[str]) -> Dict[str, Any]:
|