memuron 0.1.1__py3-none-any.whl
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.
- memuron/__init__.py +3 -0
- memuron/actions/__init__.py +12 -0
- memuron/actions/context.py +63 -0
- memuron/actions/helpers.py +88 -0
- memuron/actions/memory.py +340 -0
- memuron/actions/memory_write.py +290 -0
- memuron/actions/nodes.py +340 -0
- memuron/actions/registry.py +5 -0
- memuron/actions/runtime.py +37 -0
- memuron/actions/spaces_documents.py +720 -0
- memuron/actions/sync.py +155 -0
- memuron/application/__init__.py +1 -0
- memuron/application/api.py +206 -0
- memuron/application/app.py +103 -0
- memuron/application/capabilities.py +82 -0
- memuron/application/cli.py +35 -0
- memuron/application/config.py +176 -0
- memuron/application/mcp.py +44 -0
- memuron/application/mcp_oauth.py +290 -0
- memuron/application/registry.py +52 -0
- memuron/context.py +532 -0
- memuron/documents/__init__.py +1 -0
- memuron/documents/link_guardian.py +192 -0
- memuron/documents/linking.py +292 -0
- memuron/documents/parser.py +1152 -0
- memuron/documents/storage.py +151 -0
- memuron/documents/url_ingest.py +375 -0
- memuron/domain/__init__.py +1 -0
- memuron/domain/decoders.py +1 -0
- memuron/domain/encoders.py +185 -0
- memuron/domain/lifecycles.py +8 -0
- memuron/domain/limits.py +6 -0
- memuron/domain/representations.py +56 -0
- memuron/domain/schemas.py +581 -0
- memuron/domain/scope_filter.py +104 -0
- memuron/graphfs/__init__.py +1 -0
- memuron/graphfs/manual.py +635 -0
- memuron/graphfs/projection.py +578 -0
- memuron/graphfs/query.py +1782 -0
- memuron/graphfs/read_model.py +574 -0
- memuron/ingest/__init__.py +1 -0
- memuron/ingest/guardian.py +213 -0
- memuron/ingest/jobs.py +424 -0
- memuron/ingest/prompts.py +147 -0
- memuron/memory/__init__.py +1 -0
- memuron/memory/engine.py +35 -0
- memuron/memory/projections.py +452 -0
- memuron/memory/recipes.py +3247 -0
- memuron/persistence/__init__.py +1 -0
- memuron/persistence/db_pool.py +57 -0
- memuron/persistence/identity_store.py +918 -0
- memuron/persistence/store_helpers.py +16 -0
- memuron/search/__init__.py +1 -0
- memuron/search/fulltext.py +110 -0
- memuron/search/hybrid.py +284 -0
- memuron/search/pgvector.py +252 -0
- memuron/security/__init__.py +1 -0
- memuron/security/auth.py +143 -0
- memuron/security/auth_provider.py +119 -0
- memuron/security/authorization.py +53 -0
- memuron/security/clerk_scopes.py +94 -0
- memuron/security/clerk_webhooks.py +61 -0
- memuron/security/jwt_tokens.py +53 -0
- memuron/security/passwords.py +38 -0
- memuron/security/tenant.py +58 -0
- memuron/spaces/__init__.py +1 -0
- memuron/spaces/model.py +35 -0
- memuron/spaces/service.py +155 -0
- memuron/sync/__init__.py +25 -0
- memuron/sync/folder.py +828 -0
- memuron-0.1.1.dist-info/METADATA +242 -0
- memuron-0.1.1.dist-info/RECORD +74 -0
- memuron-0.1.1.dist-info/WHEEL +4 -0
- memuron-0.1.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""OpenRouter Guardian for memory ingest writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from memuron.application.config import settings
|
|
14
|
+
from memuron.ingest.prompts import MEMORY_WRITE_GUARDIAN_PROMPT, GuardianWritePlan
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GuardianError(RuntimeError):
|
|
22
|
+
"""Raised when the Guardian LLM call fails after retries."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgnoGuardian:
|
|
26
|
+
"""Mandatory LLM Guardian: one atomic write plan per ingest."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
api_key: str | None = None,
|
|
32
|
+
model_id: str | None = None,
|
|
33
|
+
max_retries: int | None = None,
|
|
34
|
+
max_tokens: int | None = None,
|
|
35
|
+
timeout_seconds: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.api_key = api_key or settings.openrouter_api_key
|
|
38
|
+
self.model_id = model_id or settings.guardian_model
|
|
39
|
+
self.max_retries = max_retries if max_retries is not None else settings.guardian_retries
|
|
40
|
+
self.max_tokens = max_tokens if max_tokens is not None else settings.guardian_max_tokens
|
|
41
|
+
self.timeout_seconds = (
|
|
42
|
+
timeout_seconds
|
|
43
|
+
if timeout_seconds is not None
|
|
44
|
+
else settings.guardian_timeout_seconds
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _parse_write_plan_content(content: Any) -> GuardianWritePlan:
|
|
49
|
+
if isinstance(content, GuardianWritePlan):
|
|
50
|
+
return content
|
|
51
|
+
if isinstance(content, dict):
|
|
52
|
+
return GuardianWritePlan.model_validate(content)
|
|
53
|
+
if isinstance(content, str):
|
|
54
|
+
text = content.strip()
|
|
55
|
+
if not text:
|
|
56
|
+
raise GuardianError("Guardian returned empty content")
|
|
57
|
+
if text.startswith("```"):
|
|
58
|
+
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
59
|
+
text = re.sub(r"\s*```$", "", text)
|
|
60
|
+
payload = json.loads(text)
|
|
61
|
+
if not isinstance(payload, dict):
|
|
62
|
+
raise GuardianError(
|
|
63
|
+
f"Guardian JSON payload must be an object, got {type(payload)!r}"
|
|
64
|
+
)
|
|
65
|
+
return GuardianWritePlan.model_validate(payload)
|
|
66
|
+
raise GuardianError(f"Unexpected Guardian content type: {type(content)!r}")
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _format_candidates(candidates: list[tuple[str, float, dict[str, Any]]]) -> str:
|
|
70
|
+
if not candidates:
|
|
71
|
+
return "(no candidates)"
|
|
72
|
+
lines: list[str] = []
|
|
73
|
+
for index, (memory_id, score, memory) in enumerate(candidates, start=1):
|
|
74
|
+
content = str(memory.get("content", ""))[:400]
|
|
75
|
+
scope = memory.get("scope") or []
|
|
76
|
+
node_type = memory.get("node_type", "text")
|
|
77
|
+
lines.append(
|
|
78
|
+
f"\n--- Candidate {index} ---\n"
|
|
79
|
+
f"ID: {memory_id}\n"
|
|
80
|
+
f"Node type: {node_type}\n"
|
|
81
|
+
f"Similarity Score: {score:.3f}\n"
|
|
82
|
+
f"Content: {content}\n"
|
|
83
|
+
f"Scope: {scope}\n"
|
|
84
|
+
)
|
|
85
|
+
return "".join(lines)
|
|
86
|
+
|
|
87
|
+
def _build_prompt(
|
|
88
|
+
self,
|
|
89
|
+
new_content: str,
|
|
90
|
+
candidates: list[tuple[str, float, dict[str, Any]]],
|
|
91
|
+
*,
|
|
92
|
+
space_context: dict[str, str] | None = None,
|
|
93
|
+
) -> str:
|
|
94
|
+
from memuron.ingest.prompts import SPACE_GUARDIAN_SECTION
|
|
95
|
+
|
|
96
|
+
prompt = MEMORY_WRITE_GUARDIAN_PROMPT.format(
|
|
97
|
+
new_content=new_content,
|
|
98
|
+
candidates_text=self._format_candidates(candidates),
|
|
99
|
+
)
|
|
100
|
+
if space_context:
|
|
101
|
+
prompt = (
|
|
102
|
+
prompt
|
|
103
|
+
+ "\n"
|
|
104
|
+
+ SPACE_GUARDIAN_SECTION.format(
|
|
105
|
+
active_space_name=space_context["active_space_name"],
|
|
106
|
+
active_space_token=space_context["active_space_token"],
|
|
107
|
+
active_space_prompt=space_context["active_space_prompt"],
|
|
108
|
+
other_spaces_summary=space_context["other_spaces_summary"],
|
|
109
|
+
registered_tokens=space_context["registered_tokens"],
|
|
110
|
+
all_spaces_detail=space_context.get("all_spaces_detail", "(none)"),
|
|
111
|
+
space_mode=space_context.get("space_mode", "assist"),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return prompt
|
|
115
|
+
|
|
116
|
+
def _call_guardian(self, prompt: str) -> GuardianWritePlan:
|
|
117
|
+
if not self.api_key:
|
|
118
|
+
raise GuardianError("OPENROUTER_API_KEY is required for Guardian writes")
|
|
119
|
+
payload = {
|
|
120
|
+
"model": self.model_id,
|
|
121
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
122
|
+
"response_format": {"type": "json_object"},
|
|
123
|
+
"max_tokens": self.max_tokens,
|
|
124
|
+
}
|
|
125
|
+
try:
|
|
126
|
+
response = requests.post(
|
|
127
|
+
OPENROUTER_CHAT_URL,
|
|
128
|
+
headers={
|
|
129
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
},
|
|
132
|
+
json=payload,
|
|
133
|
+
timeout=self.timeout_seconds,
|
|
134
|
+
)
|
|
135
|
+
except requests.RequestException as exc:
|
|
136
|
+
raise GuardianError(f"Guardian request failed: {exc}") from exc
|
|
137
|
+
if response.status_code >= 400:
|
|
138
|
+
raise GuardianError(
|
|
139
|
+
f"Guardian HTTP {response.status_code}: {response.text[:500]}"
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
body = response.json()
|
|
143
|
+
choice = body["choices"][0]
|
|
144
|
+
message = choice["message"]
|
|
145
|
+
raw_content = message.get("content")
|
|
146
|
+
finish_reason = choice.get("finish_reason")
|
|
147
|
+
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
|
148
|
+
raise GuardianError(f"Guardian response parse error: {exc}") from exc
|
|
149
|
+
if finish_reason == "length":
|
|
150
|
+
raise GuardianError(
|
|
151
|
+
"Guardian response truncated at max_tokens="
|
|
152
|
+
f"{self.max_tokens}; increase MEMURON_GUARDIAN_MAX_TOKENS"
|
|
153
|
+
)
|
|
154
|
+
if not isinstance(raw_content, str) or not raw_content.strip():
|
|
155
|
+
raise GuardianError("Guardian returned empty content")
|
|
156
|
+
try:
|
|
157
|
+
return self._parse_write_plan_content(raw_content)
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise GuardianError(f"Guardian response JSON parse error: {exc}") from exc
|
|
160
|
+
|
|
161
|
+
async def _call_guardian_async(
|
|
162
|
+
self,
|
|
163
|
+
new_content: str,
|
|
164
|
+
candidates: list[tuple[str, float, dict[str, Any]]],
|
|
165
|
+
*,
|
|
166
|
+
space_context: dict[str, str] | None = None,
|
|
167
|
+
) -> GuardianWritePlan:
|
|
168
|
+
prompt = self._build_prompt(
|
|
169
|
+
new_content,
|
|
170
|
+
candidates,
|
|
171
|
+
space_context=space_context,
|
|
172
|
+
)
|
|
173
|
+
return await asyncio.to_thread(self._call_guardian, prompt)
|
|
174
|
+
|
|
175
|
+
async def plan_write(
|
|
176
|
+
self,
|
|
177
|
+
new_content: str,
|
|
178
|
+
candidates: list[tuple[str, float, dict[str, Any]]],
|
|
179
|
+
*,
|
|
180
|
+
space_context: dict[str, str] | None = None,
|
|
181
|
+
) -> GuardianWritePlan:
|
|
182
|
+
last_error: Exception | None = None
|
|
183
|
+
for attempt in range(1, self.max_retries + 1):
|
|
184
|
+
try:
|
|
185
|
+
plan = await self._call_guardian_async(
|
|
186
|
+
new_content,
|
|
187
|
+
candidates,
|
|
188
|
+
space_context=space_context,
|
|
189
|
+
)
|
|
190
|
+
action = plan.action
|
|
191
|
+
if action == "update":
|
|
192
|
+
if not plan.target_memory_id:
|
|
193
|
+
raise GuardianError("Guardian update plan missing target_memory_id")
|
|
194
|
+
else:
|
|
195
|
+
plan = plan.model_copy(update={"target_memory_id": None})
|
|
196
|
+
return plan
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
last_error = exc
|
|
199
|
+
logger.warning(
|
|
200
|
+
"Guardian attempt %s/%s failed: %s",
|
|
201
|
+
attempt,
|
|
202
|
+
self.max_retries,
|
|
203
|
+
exc,
|
|
204
|
+
)
|
|
205
|
+
if attempt < self.max_retries:
|
|
206
|
+
await asyncio.sleep(2 ** (attempt - 1))
|
|
207
|
+
raise GuardianError(
|
|
208
|
+
f"Guardian failed after {self.max_retries} attempts: {last_error}"
|
|
209
|
+
) from last_error
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def create_guardian() -> AgnoGuardian:
|
|
213
|
+
return AgnoGuardian()
|
memuron/ingest/jobs.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Durable async ingest job queue backed by PostgreSQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from typing import Any, Iterator
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
from psycopg.rows import dict_row
|
|
16
|
+
|
|
17
|
+
from memuron.application.config import settings
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
JOB_TABLE = "memuron_ingest_jobs"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _utcnow() -> datetime:
|
|
25
|
+
return datetime.now(UTC).replace(tzinfo=None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _serialize_job(row: dict[str, Any]) -> dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"id": str(row["id"]),
|
|
31
|
+
"status": row["status"],
|
|
32
|
+
"payload_json": row["payload_json"],
|
|
33
|
+
"result_json": row.get("result_json"),
|
|
34
|
+
"error_json": row.get("error_json"),
|
|
35
|
+
"attempt_count": int(row["attempt_count"]),
|
|
36
|
+
"max_attempts": int(row["max_attempts"]),
|
|
37
|
+
"retryable": bool(row["retryable"]),
|
|
38
|
+
"available_at": row["available_at"].isoformat() if row["available_at"] else None,
|
|
39
|
+
"locked_at": row["locked_at"].isoformat() if row.get("locked_at") else None,
|
|
40
|
+
"lease_expires_at": row["lease_expires_at"].isoformat()
|
|
41
|
+
if row.get("lease_expires_at")
|
|
42
|
+
else None,
|
|
43
|
+
"started_at": row["started_at"].isoformat() if row.get("started_at") else None,
|
|
44
|
+
"completed_at": row["completed_at"].isoformat() if row.get("completed_at") else None,
|
|
45
|
+
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
46
|
+
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class IngestJobStore:
|
|
51
|
+
def __init__(self, database_url: str) -> None:
|
|
52
|
+
if not database_url:
|
|
53
|
+
raise ValueError("ARTHA_DATABASE_URL is required for ingest jobs")
|
|
54
|
+
self.database_url = database_url
|
|
55
|
+
self._init_schema()
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def _connect(self) -> Iterator[psycopg.Connection[Any]]:
|
|
59
|
+
conn = psycopg.connect(self.database_url, row_factory=dict_row)
|
|
60
|
+
try:
|
|
61
|
+
yield conn
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
def _init_schema(self) -> None:
|
|
66
|
+
with self._connect() as conn:
|
|
67
|
+
conn.execute(
|
|
68
|
+
f"""
|
|
69
|
+
CREATE TABLE IF NOT EXISTS {JOB_TABLE} (
|
|
70
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
71
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
72
|
+
payload_json JSONB NOT NULL,
|
|
73
|
+
result_json JSONB,
|
|
74
|
+
error_json JSONB,
|
|
75
|
+
attempt_count INT NOT NULL DEFAULT 0,
|
|
76
|
+
max_attempts INT NOT NULL DEFAULT 3,
|
|
77
|
+
retryable BOOLEAN NOT NULL DEFAULT TRUE,
|
|
78
|
+
available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
79
|
+
locked_at TIMESTAMPTZ,
|
|
80
|
+
lease_expires_at TIMESTAMPTZ,
|
|
81
|
+
started_at TIMESTAMPTZ,
|
|
82
|
+
completed_at TIMESTAMPTZ,
|
|
83
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
84
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
85
|
+
)
|
|
86
|
+
"""
|
|
87
|
+
)
|
|
88
|
+
conn.execute(
|
|
89
|
+
f"""
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_memuron_ingest_jobs_claim
|
|
91
|
+
ON {JOB_TABLE} (status, available_at, lease_expires_at)
|
|
92
|
+
"""
|
|
93
|
+
)
|
|
94
|
+
conn.commit()
|
|
95
|
+
|
|
96
|
+
def create_job(self, payload: dict[str, Any], *, max_attempts: int | None = None) -> dict[str, Any]:
|
|
97
|
+
job_id = str(uuid4())
|
|
98
|
+
now = _utcnow()
|
|
99
|
+
with self._connect() as conn:
|
|
100
|
+
row = conn.execute(
|
|
101
|
+
f"""
|
|
102
|
+
INSERT INTO {JOB_TABLE} (
|
|
103
|
+
id, status, payload_json, max_attempts, available_at, created_at, updated_at
|
|
104
|
+
) VALUES (%s, 'queued', %s::jsonb, %s, %s, %s, %s)
|
|
105
|
+
RETURNING *
|
|
106
|
+
""",
|
|
107
|
+
(
|
|
108
|
+
job_id,
|
|
109
|
+
json.dumps(payload),
|
|
110
|
+
max_attempts or settings.ingest_job_max_attempts,
|
|
111
|
+
now,
|
|
112
|
+
now,
|
|
113
|
+
now,
|
|
114
|
+
),
|
|
115
|
+
).fetchone()
|
|
116
|
+
conn.commit()
|
|
117
|
+
return _serialize_job(row)
|
|
118
|
+
|
|
119
|
+
def get_job(self, job_id: str) -> dict[str, Any] | None:
|
|
120
|
+
with self._connect() as conn:
|
|
121
|
+
row = conn.execute(
|
|
122
|
+
f"SELECT * FROM {JOB_TABLE} WHERE id = %s",
|
|
123
|
+
(job_id,),
|
|
124
|
+
).fetchone()
|
|
125
|
+
return _serialize_job(row) if row else None
|
|
126
|
+
|
|
127
|
+
def claim_next_job(self, lease_seconds: int) -> dict[str, Any] | None:
|
|
128
|
+
now = _utcnow()
|
|
129
|
+
with self._connect() as conn:
|
|
130
|
+
row = conn.execute(
|
|
131
|
+
f"""
|
|
132
|
+
SELECT * FROM {JOB_TABLE}
|
|
133
|
+
WHERE (
|
|
134
|
+
status = 'queued' AND available_at <= %s
|
|
135
|
+
) OR (
|
|
136
|
+
status = 'processing'
|
|
137
|
+
AND lease_expires_at IS NOT NULL
|
|
138
|
+
AND lease_expires_at <= %s
|
|
139
|
+
)
|
|
140
|
+
ORDER BY created_at ASC
|
|
141
|
+
FOR UPDATE SKIP LOCKED
|
|
142
|
+
LIMIT 1
|
|
143
|
+
""",
|
|
144
|
+
(now, now),
|
|
145
|
+
).fetchone()
|
|
146
|
+
if not row:
|
|
147
|
+
conn.rollback()
|
|
148
|
+
return None
|
|
149
|
+
lease_expires = now + timedelta(seconds=lease_seconds)
|
|
150
|
+
updated = conn.execute(
|
|
151
|
+
f"""
|
|
152
|
+
UPDATE {JOB_TABLE}
|
|
153
|
+
SET status = 'processing',
|
|
154
|
+
attempt_count = attempt_count + 1,
|
|
155
|
+
locked_at = %s,
|
|
156
|
+
lease_expires_at = %s,
|
|
157
|
+
started_at = COALESCE(started_at, %s),
|
|
158
|
+
updated_at = %s
|
|
159
|
+
WHERE id = %s
|
|
160
|
+
RETURNING *
|
|
161
|
+
""",
|
|
162
|
+
(now, lease_expires, now, now, row["id"]),
|
|
163
|
+
).fetchone()
|
|
164
|
+
conn.commit()
|
|
165
|
+
return _serialize_job(updated) if updated else None
|
|
166
|
+
|
|
167
|
+
def complete_job(self, job_id: str, result_json: dict[str, Any]) -> dict[str, Any]:
|
|
168
|
+
now = _utcnow()
|
|
169
|
+
with self._connect() as conn:
|
|
170
|
+
row = conn.execute(
|
|
171
|
+
f"""
|
|
172
|
+
UPDATE {JOB_TABLE}
|
|
173
|
+
SET status = 'completed',
|
|
174
|
+
result_json = %s::jsonb,
|
|
175
|
+
error_json = NULL,
|
|
176
|
+
completed_at = %s,
|
|
177
|
+
locked_at = NULL,
|
|
178
|
+
lease_expires_at = NULL,
|
|
179
|
+
updated_at = %s
|
|
180
|
+
WHERE id = %s
|
|
181
|
+
RETURNING *
|
|
182
|
+
""",
|
|
183
|
+
(json.dumps(result_json), now, now, job_id),
|
|
184
|
+
).fetchone()
|
|
185
|
+
conn.commit()
|
|
186
|
+
if row is None:
|
|
187
|
+
raise KeyError(f"Ingest job not found: {job_id}")
|
|
188
|
+
return _serialize_job(row)
|
|
189
|
+
|
|
190
|
+
def fail_job(
|
|
191
|
+
self,
|
|
192
|
+
job_id: str,
|
|
193
|
+
error_json: dict[str, Any],
|
|
194
|
+
*,
|
|
195
|
+
retryable: bool = True,
|
|
196
|
+
backoff_seconds: int | None = None,
|
|
197
|
+
) -> dict[str, Any]:
|
|
198
|
+
now = _utcnow()
|
|
199
|
+
with self._connect() as conn:
|
|
200
|
+
row = conn.execute(
|
|
201
|
+
f"SELECT * FROM {JOB_TABLE} WHERE id = %s",
|
|
202
|
+
(job_id,),
|
|
203
|
+
).fetchone()
|
|
204
|
+
if row is None:
|
|
205
|
+
raise KeyError(f"Ingest job not found: {job_id}")
|
|
206
|
+
should_retry = retryable and int(row["attempt_count"]) < int(row["max_attempts"])
|
|
207
|
+
if should_retry:
|
|
208
|
+
delay = backoff_seconds if backoff_seconds is not None else 2 ** max(
|
|
209
|
+
int(row["attempt_count"]) - 1, 0
|
|
210
|
+
)
|
|
211
|
+
status = "queued"
|
|
212
|
+
available_at = now + timedelta(seconds=delay)
|
|
213
|
+
completed_at = None
|
|
214
|
+
else:
|
|
215
|
+
status = "failed"
|
|
216
|
+
available_at = row["available_at"]
|
|
217
|
+
completed_at = now
|
|
218
|
+
updated = conn.execute(
|
|
219
|
+
f"""
|
|
220
|
+
UPDATE {JOB_TABLE}
|
|
221
|
+
SET status = %s,
|
|
222
|
+
error_json = %s::jsonb,
|
|
223
|
+
retryable = %s,
|
|
224
|
+
available_at = %s,
|
|
225
|
+
locked_at = NULL,
|
|
226
|
+
lease_expires_at = NULL,
|
|
227
|
+
completed_at = %s,
|
|
228
|
+
updated_at = %s
|
|
229
|
+
WHERE id = %s
|
|
230
|
+
RETURNING *
|
|
231
|
+
""",
|
|
232
|
+
(
|
|
233
|
+
status,
|
|
234
|
+
json.dumps(error_json),
|
|
235
|
+
retryable,
|
|
236
|
+
available_at,
|
|
237
|
+
completed_at,
|
|
238
|
+
now,
|
|
239
|
+
job_id,
|
|
240
|
+
),
|
|
241
|
+
).fetchone()
|
|
242
|
+
conn.commit()
|
|
243
|
+
return _serialize_job(updated)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_job_payload(
|
|
247
|
+
content: str,
|
|
248
|
+
scope: list[str] | None,
|
|
249
|
+
*,
|
|
250
|
+
metadata: dict[str, Any] | None = None,
|
|
251
|
+
event_metadata: dict[str, Any] | None = None,
|
|
252
|
+
space_context: dict[str, str] | None = None,
|
|
253
|
+
candidate_scope: list[str] | None = None,
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
payload: dict[str, Any] = {
|
|
256
|
+
"content": content,
|
|
257
|
+
"scope": scope or [],
|
|
258
|
+
"metadata": metadata or {},
|
|
259
|
+
"event_metadata": event_metadata or {},
|
|
260
|
+
"candidate_scope": candidate_scope or [],
|
|
261
|
+
}
|
|
262
|
+
if space_context:
|
|
263
|
+
payload["space_context"] = space_context
|
|
264
|
+
return payload
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _payload_space_context(payload: dict[str, Any]) -> dict[str, str] | None:
|
|
268
|
+
space_context = payload.get("space_context")
|
|
269
|
+
if not isinstance(space_context, dict):
|
|
270
|
+
return None
|
|
271
|
+
if not space_context.get("active_space_token"):
|
|
272
|
+
return None
|
|
273
|
+
return space_context
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _run_ingest_job(job: dict[str, Any], engine: Any, guardian: Any) -> None:
|
|
277
|
+
from memuron.ingest.guardian import GuardianError
|
|
278
|
+
from memuron.memory.recipes import ingest_memory, update_memory
|
|
279
|
+
from memuron.spaces.service import apply_guardian_space_scope
|
|
280
|
+
from memuron.security.tenant import normalize_tenant_scope
|
|
281
|
+
|
|
282
|
+
store = job["_store"] # type: ignore[index]
|
|
283
|
+
payload = job["payload_json"] or {}
|
|
284
|
+
event_metadata = payload.get("event_metadata") or {}
|
|
285
|
+
space_context = _payload_space_context(payload)
|
|
286
|
+
if space_context is None:
|
|
287
|
+
backup = event_metadata.get("space_context")
|
|
288
|
+
if isinstance(backup, dict):
|
|
289
|
+
space_context = _payload_space_context({"space_context": backup})
|
|
290
|
+
tenant_id = event_metadata.get("tenant_id")
|
|
291
|
+
if not tenant_id:
|
|
292
|
+
for token in payload.get("scope") or []:
|
|
293
|
+
if str(token).startswith("org:"):
|
|
294
|
+
tenant_id = str(token)[4:]
|
|
295
|
+
break
|
|
296
|
+
try:
|
|
297
|
+
result = await ingest_memory(
|
|
298
|
+
engine,
|
|
299
|
+
guardian,
|
|
300
|
+
content=payload["content"],
|
|
301
|
+
scope=payload.get("scope"),
|
|
302
|
+
metadata=payload.get("metadata") or None,
|
|
303
|
+
event_metadata=event_metadata,
|
|
304
|
+
space_context=space_context,
|
|
305
|
+
candidate_scope=payload.get("candidate_scope") or None,
|
|
306
|
+
)
|
|
307
|
+
if result.get("memory_id"):
|
|
308
|
+
memory = result.get("memory") or {}
|
|
309
|
+
current_scope = list(memory.get("scope") or [])
|
|
310
|
+
fixed_scope = normalize_tenant_scope(
|
|
311
|
+
current_scope,
|
|
312
|
+
str(tenant_id) if tenant_id else None,
|
|
313
|
+
)
|
|
314
|
+
if space_context:
|
|
315
|
+
fixed_scope = apply_guardian_space_scope(
|
|
316
|
+
fixed_scope,
|
|
317
|
+
space_context=space_context,
|
|
318
|
+
)
|
|
319
|
+
if fixed_scope != current_scope:
|
|
320
|
+
patched = update_memory(
|
|
321
|
+
engine,
|
|
322
|
+
str(result["memory_id"]),
|
|
323
|
+
scope=fixed_scope,
|
|
324
|
+
event_metadata=event_metadata,
|
|
325
|
+
)
|
|
326
|
+
result["memory"] = patched
|
|
327
|
+
await asyncio.to_thread(store.complete_job, job["id"], result)
|
|
328
|
+
except GuardianError as exc:
|
|
329
|
+
await asyncio.to_thread(
|
|
330
|
+
store.fail_job,
|
|
331
|
+
job["id"],
|
|
332
|
+
{"code": "GUARDIAN_FAILED", "message": str(exc), "retryable": True},
|
|
333
|
+
retryable=True,
|
|
334
|
+
)
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
logger.exception("Ingest job %s failed", job["id"])
|
|
337
|
+
await asyncio.to_thread(
|
|
338
|
+
store.fail_job,
|
|
339
|
+
job["id"],
|
|
340
|
+
{"code": "INGEST_JOB_FAILED", "message": str(exc), "retryable": True},
|
|
341
|
+
retryable=True,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def _ingest_worker_loop(
|
|
346
|
+
worker_name: str,
|
|
347
|
+
stop_event: asyncio.Event,
|
|
348
|
+
*,
|
|
349
|
+
engine: Any,
|
|
350
|
+
guardian: Any,
|
|
351
|
+
job_store: IngestJobStore,
|
|
352
|
+
) -> None:
|
|
353
|
+
while not stop_event.is_set():
|
|
354
|
+
try:
|
|
355
|
+
job = await asyncio.to_thread(
|
|
356
|
+
job_store.claim_next_job,
|
|
357
|
+
settings.ingest_job_lease_seconds,
|
|
358
|
+
)
|
|
359
|
+
if not job:
|
|
360
|
+
try:
|
|
361
|
+
await asyncio.wait_for(
|
|
362
|
+
stop_event.wait(),
|
|
363
|
+
timeout=settings.ingest_job_poll_interval_seconds,
|
|
364
|
+
)
|
|
365
|
+
except TimeoutError:
|
|
366
|
+
pass
|
|
367
|
+
continue
|
|
368
|
+
logger.info("Worker %s processing ingest job %s", worker_name, job["id"])
|
|
369
|
+
job["_store"] = job_store
|
|
370
|
+
await _run_ingest_job(job, engine, guardian)
|
|
371
|
+
except asyncio.CancelledError:
|
|
372
|
+
raise
|
|
373
|
+
except Exception:
|
|
374
|
+
logger.exception("Worker %s hit an unexpected ingest loop error", worker_name)
|
|
375
|
+
await asyncio.sleep(settings.ingest_job_poll_interval_seconds)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def start_ingest_workers(app: Any, engine: Any, guardian: Any) -> None:
|
|
379
|
+
worker_count = int(
|
|
380
|
+
os.environ.get("MEMURON_INGEST_WORKER_COUNT", str(settings.ingest_worker_count))
|
|
381
|
+
)
|
|
382
|
+
if worker_count <= 0:
|
|
383
|
+
app.state.ingest_worker_tasks = []
|
|
384
|
+
app.state.ingest_worker_stop_event = None
|
|
385
|
+
return
|
|
386
|
+
if not settings.database_url.startswith("postgresql"):
|
|
387
|
+
logger.info("Skipping ingest workers because database is not PostgreSQL")
|
|
388
|
+
app.state.ingest_worker_tasks = []
|
|
389
|
+
app.state.ingest_worker_stop_event = None
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
job_store = getattr(app.state, "ingest_job_store", None)
|
|
393
|
+
if job_store is None:
|
|
394
|
+
job_store = IngestJobStore(settings.database_url)
|
|
395
|
+
app.state.ingest_job_store = job_store
|
|
396
|
+
stop_event = asyncio.Event()
|
|
397
|
+
tasks = [
|
|
398
|
+
asyncio.create_task(
|
|
399
|
+
_ingest_worker_loop(
|
|
400
|
+
f"ingest-{index + 1}",
|
|
401
|
+
stop_event,
|
|
402
|
+
engine=engine,
|
|
403
|
+
guardian=guardian,
|
|
404
|
+
job_store=job_store,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
for index in range(worker_count)
|
|
408
|
+
]
|
|
409
|
+
app.state.ingest_job_store = job_store
|
|
410
|
+
app.state.ingest_worker_stop_event = stop_event
|
|
411
|
+
app.state.ingest_worker_tasks = tasks
|
|
412
|
+
logger.info("Started %s ingest worker(s)", len(tasks))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def stop_ingest_workers(app: Any) -> None:
|
|
416
|
+
stop_event = getattr(app.state, "ingest_worker_stop_event", None)
|
|
417
|
+
tasks = getattr(app.state, "ingest_worker_tasks", [])
|
|
418
|
+
if stop_event:
|
|
419
|
+
stop_event.set()
|
|
420
|
+
if tasks:
|
|
421
|
+
for task in tasks:
|
|
422
|
+
task.cancel()
|
|
423
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
424
|
+
logger.info("Stopped %s ingest worker(s)", len(tasks))
|