abstractgateway 0.1.0__py3-none-any.whl → 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.
- abstractgateway/__init__.py +1 -2
- abstractgateway/__main__.py +7 -0
- abstractgateway/app.py +4 -4
- abstractgateway/cli.py +568 -8
- abstractgateway/config.py +15 -5
- abstractgateway/embeddings_config.py +45 -0
- abstractgateway/host_metrics.py +274 -0
- abstractgateway/hosts/bundle_host.py +528 -55
- abstractgateway/hosts/visualflow_host.py +30 -3
- abstractgateway/integrations/__init__.py +2 -0
- abstractgateway/integrations/email_bridge.py +782 -0
- abstractgateway/integrations/telegram_bridge.py +534 -0
- abstractgateway/maintenance/__init__.py +5 -0
- abstractgateway/maintenance/action_tokens.py +100 -0
- abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
- abstractgateway/maintenance/backlog_parser.py +184 -0
- abstractgateway/maintenance/draft_generator.py +451 -0
- abstractgateway/maintenance/llm_assist.py +212 -0
- abstractgateway/maintenance/notifier.py +109 -0
- abstractgateway/maintenance/process_manager.py +1064 -0
- abstractgateway/maintenance/report_models.py +81 -0
- abstractgateway/maintenance/report_parser.py +219 -0
- abstractgateway/maintenance/text_similarity.py +123 -0
- abstractgateway/maintenance/triage.py +507 -0
- abstractgateway/maintenance/triage_queue.py +142 -0
- abstractgateway/migrate.py +155 -0
- abstractgateway/routes/__init__.py +2 -2
- abstractgateway/routes/gateway.py +10817 -179
- abstractgateway/routes/triage.py +118 -0
- abstractgateway/runner.py +689 -14
- abstractgateway/security/gateway_security.py +425 -110
- abstractgateway/service.py +213 -6
- abstractgateway/stores.py +64 -4
- abstractgateway/workflow_deprecations.py +225 -0
- abstractgateway-0.1.1.dist-info/METADATA +135 -0
- abstractgateway-0.1.1.dist-info/RECORD +40 -0
- abstractgateway-0.1.0.dist-info/METADATA +0 -101
- abstractgateway-0.1.0.dist-info/RECORD +0 -18
- {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
- {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _env(name: str, fallback: Optional[str] = None) -> Optional[str]:
|
|
12
|
+
v = os.getenv(name)
|
|
13
|
+
if v is not None and str(v).strip():
|
|
14
|
+
return str(v).strip()
|
|
15
|
+
if fallback:
|
|
16
|
+
v2 = os.getenv(fallback)
|
|
17
|
+
if v2 is not None and str(v2).strip():
|
|
18
|
+
return str(v2).strip()
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _json_from_text(text: str) -> Optional[Dict[str, Any]]:
|
|
23
|
+
raw = str(text or "").strip()
|
|
24
|
+
if not raw:
|
|
25
|
+
return None
|
|
26
|
+
# Best-effort: find the first JSON object in the output.
|
|
27
|
+
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
28
|
+
if not m:
|
|
29
|
+
return None
|
|
30
|
+
blob = m.group(0)
|
|
31
|
+
try:
|
|
32
|
+
obj = json.loads(blob)
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
return obj if isinstance(obj, dict) else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_llm_assist_config() -> Dict[str, Any]:
|
|
39
|
+
"""Load maintenance LLM config (best-effort).
|
|
40
|
+
|
|
41
|
+
Precedence:
|
|
42
|
+
1) env vars (override)
|
|
43
|
+
2) AbstractCore config system (if available)
|
|
44
|
+
|
|
45
|
+
This keeps triage configurable while remaining dependency-light when AbstractCore
|
|
46
|
+
isn't installed in a runner-only deployment.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
base_url = ""
|
|
50
|
+
model = ""
|
|
51
|
+
temperature = 0.2
|
|
52
|
+
timeout_s = 30.0
|
|
53
|
+
max_tokens = 800
|
|
54
|
+
use_llm = False
|
|
55
|
+
|
|
56
|
+
# AbstractCore config (best-effort).
|
|
57
|
+
try:
|
|
58
|
+
from abstractcore.config.manager import get_config_manager # type: ignore
|
|
59
|
+
|
|
60
|
+
cfg = get_config_manager().config
|
|
61
|
+
m = getattr(cfg, "maintenance", None)
|
|
62
|
+
if m is not None:
|
|
63
|
+
use_llm = bool(getattr(m, "triage_llm_enabled", False))
|
|
64
|
+
base_url = str(getattr(m, "triage_llm_base_url", "") or "")
|
|
65
|
+
model = str(getattr(m, "triage_llm_model", "") or "")
|
|
66
|
+
try:
|
|
67
|
+
temperature = float(getattr(m, "triage_llm_temperature", 0.2))
|
|
68
|
+
except Exception:
|
|
69
|
+
temperature = 0.2
|
|
70
|
+
try:
|
|
71
|
+
timeout_s = float(getattr(m, "triage_llm_timeout_s", 30.0))
|
|
72
|
+
except Exception:
|
|
73
|
+
timeout_s = 30.0
|
|
74
|
+
try:
|
|
75
|
+
max_tokens = int(getattr(m, "triage_llm_max_tokens", 800))
|
|
76
|
+
except Exception:
|
|
77
|
+
max_tokens = 800
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# Env overrides.
|
|
82
|
+
enabled = str(_env("ABSTRACT_TRIAGE_LLM", "ABSTRACTGATEWAY_TRIAGE_LLM") or "").strip().lower()
|
|
83
|
+
if enabled:
|
|
84
|
+
use_llm = enabled in {"1", "true", "yes", "on"}
|
|
85
|
+
|
|
86
|
+
base_url = _env("ABSTRACT_TRIAGE_LLM_BASE_URL", "ABSTRACTGATEWAY_TRIAGE_LLM_BASE_URL") or base_url
|
|
87
|
+
model = _env("ABSTRACT_TRIAGE_LLM_MODEL", "ABSTRACTGATEWAY_TRIAGE_LLM_MODEL") or model
|
|
88
|
+
api_key = _env("ABSTRACT_TRIAGE_LLM_API_KEY", "ABSTRACTGATEWAY_TRIAGE_LLM_API_KEY") or ""
|
|
89
|
+
|
|
90
|
+
temperature_raw = _env("ABSTRACT_TRIAGE_LLM_TEMPERATURE", "ABSTRACTGATEWAY_TRIAGE_LLM_TEMPERATURE")
|
|
91
|
+
if temperature_raw is not None:
|
|
92
|
+
try:
|
|
93
|
+
temperature = float(temperature_raw)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
timeout_raw = _env("ABSTRACT_TRIAGE_LLM_TIMEOUT_S", "ABSTRACTGATEWAY_TRIAGE_LLM_TIMEOUT_S")
|
|
98
|
+
if timeout_raw is not None:
|
|
99
|
+
try:
|
|
100
|
+
timeout_s = float(timeout_raw)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
max_tokens_raw = _env("ABSTRACT_TRIAGE_LLM_MAX_TOKENS", "ABSTRACTGATEWAY_TRIAGE_LLM_MAX_TOKENS")
|
|
105
|
+
if max_tokens_raw is not None:
|
|
106
|
+
try:
|
|
107
|
+
max_tokens = int(max_tokens_raw)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
"enabled": bool(use_llm),
|
|
113
|
+
"base_url": base_url,
|
|
114
|
+
"model": model,
|
|
115
|
+
"api_key": api_key,
|
|
116
|
+
"temperature": temperature,
|
|
117
|
+
"timeout_s": timeout_s,
|
|
118
|
+
"max_tokens": max_tokens,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def llm_assist(
|
|
123
|
+
*,
|
|
124
|
+
normalized_input: Dict[str, Any],
|
|
125
|
+
base_url: str,
|
|
126
|
+
model: str,
|
|
127
|
+
api_key: str = "",
|
|
128
|
+
temperature: float = 0.2,
|
|
129
|
+
timeout_s: float = 30.0,
|
|
130
|
+
max_tokens: int = 800,
|
|
131
|
+
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
132
|
+
if not str(base_url or "").strip():
|
|
133
|
+
return None, "LLM assist base_url is missing"
|
|
134
|
+
if not str(model or "").strip():
|
|
135
|
+
return None, "LLM assist model is missing"
|
|
136
|
+
|
|
137
|
+
url = str(base_url).rstrip("/")
|
|
138
|
+
# LMStudio commonly exposes `/v1`; accept both `/v1` and non-versioned roots.
|
|
139
|
+
if not url.endswith("/v1"):
|
|
140
|
+
url = url + "/v1"
|
|
141
|
+
endpoint = url + "/chat/completions"
|
|
142
|
+
|
|
143
|
+
# Prompt injection surface is reduced by feeding normalized JSON only.
|
|
144
|
+
system = (
|
|
145
|
+
"You are a maintenance assistant for an open source monorepo.\n"
|
|
146
|
+
"Given a normalized bug/feature report, propose a backlog item draft.\n"
|
|
147
|
+
"Output ONLY valid JSON with keys:\n"
|
|
148
|
+
'- "backlog_title" (string)\n'
|
|
149
|
+
'- "packages" (comma-separated string; prefer one)\n'
|
|
150
|
+
'- "acceptance_criteria" (markdown checklist string, each line starts with "- [ ]")\n'
|
|
151
|
+
'- "notes" (string, optional)\n'
|
|
152
|
+
"Do not include code fences.\n"
|
|
153
|
+
)
|
|
154
|
+
user = json.dumps(normalized_input, ensure_ascii=False, indent=2, sort_keys=True)
|
|
155
|
+
# Bound payload size to avoid accidental huge requests.
|
|
156
|
+
if len(user) > 25_000:
|
|
157
|
+
user = user[:25_000] + "\n…(truncated)…\n"
|
|
158
|
+
|
|
159
|
+
payload: Dict[str, Any] = {
|
|
160
|
+
"model": model,
|
|
161
|
+
"messages": [
|
|
162
|
+
{"role": "system", "content": system},
|
|
163
|
+
{"role": "user", "content": user},
|
|
164
|
+
],
|
|
165
|
+
"temperature": float(temperature),
|
|
166
|
+
"max_tokens": int(max_tokens),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
req = urllib.request.Request(
|
|
170
|
+
endpoint,
|
|
171
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
172
|
+
headers={
|
|
173
|
+
"content-type": "application/json",
|
|
174
|
+
**({"authorization": f"Bearer {api_key}"} if api_key else {}),
|
|
175
|
+
},
|
|
176
|
+
method="POST",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
with urllib.request.urlopen(req, timeout=float(timeout_s)) as resp:
|
|
181
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
182
|
+
except urllib.error.HTTPError as e:
|
|
183
|
+
try:
|
|
184
|
+
detail = e.read().decode("utf-8", errors="replace")
|
|
185
|
+
except Exception:
|
|
186
|
+
detail = str(e)
|
|
187
|
+
return None, f"HTTP error from LLM endpoint: {e.code} {detail}"
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return None, str(e)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
obj = json.loads(raw)
|
|
193
|
+
except Exception:
|
|
194
|
+
obj = None
|
|
195
|
+
if not isinstance(obj, dict):
|
|
196
|
+
return None, "Invalid LLM response (expected JSON object)"
|
|
197
|
+
|
|
198
|
+
# OpenAI-compatible response shape.
|
|
199
|
+
content = ""
|
|
200
|
+
try:
|
|
201
|
+
choices = obj.get("choices") or []
|
|
202
|
+
if isinstance(choices, list) and choices:
|
|
203
|
+
msg = choices[0].get("message") if isinstance(choices[0], dict) else None
|
|
204
|
+
if isinstance(msg, dict):
|
|
205
|
+
content = str(msg.get("content") or "")
|
|
206
|
+
except Exception:
|
|
207
|
+
content = ""
|
|
208
|
+
|
|
209
|
+
parsed = _json_from_text(content) if content else None
|
|
210
|
+
if parsed is None:
|
|
211
|
+
return None, "LLM did not return parseable JSON"
|
|
212
|
+
return parsed, None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _env(name: str, fallback: Optional[str] = None) -> Optional[str]:
|
|
8
|
+
v = os.getenv(name)
|
|
9
|
+
if v is not None and str(v).strip():
|
|
10
|
+
return str(v).strip()
|
|
11
|
+
if fallback:
|
|
12
|
+
v2 = os.getenv(fallback)
|
|
13
|
+
if v2 is not None and str(v2).strip():
|
|
14
|
+
return str(v2).strip()
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _telegram_chat_id() -> Optional[int]:
|
|
19
|
+
raw = (
|
|
20
|
+
_env("ABSTRACT_BACKLOG_TELEGRAM_CHAT_ID", "ABSTRACTGATEWAY_BACKLOG_TELEGRAM_CHAT_ID")
|
|
21
|
+
or _env("ABSTRACT_TRIAGE_TELEGRAM_CHAT_ID", "ABSTRACTGATEWAY_TRIAGE_TELEGRAM_CHAT_ID")
|
|
22
|
+
)
|
|
23
|
+
if not raw:
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
return int(str(raw).strip())
|
|
27
|
+
except Exception:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def send_telegram_notification(*, text: str) -> Tuple[bool, Optional[str]]:
|
|
32
|
+
chat_id = _telegram_chat_id()
|
|
33
|
+
if chat_id is None:
|
|
34
|
+
return False, "Missing/invalid TELEGRAM_CHAT_ID"
|
|
35
|
+
|
|
36
|
+
# Prefer the framework tool (supports TDLib Secret Chats when configured).
|
|
37
|
+
try:
|
|
38
|
+
from abstractcore.tools.telegram_tools import send_telegram_message # type: ignore
|
|
39
|
+
except Exception as e:
|
|
40
|
+
return False, f"Telegram tools unavailable: {e}"
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
out: Dict[str, Any] = send_telegram_message(chat_id=chat_id, text=str(text or ""))
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return False, str(e)
|
|
46
|
+
|
|
47
|
+
if isinstance(out, dict) and out.get("success") is True:
|
|
48
|
+
return True, None
|
|
49
|
+
err = out.get("error") if isinstance(out, dict) else None
|
|
50
|
+
return False, str(err or "Telegram send failed")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _email_recipients() -> List[str]:
|
|
54
|
+
raw = (
|
|
55
|
+
_env("ABSTRACT_BACKLOG_EMAIL_TO", "ABSTRACTGATEWAY_BACKLOG_EMAIL_TO")
|
|
56
|
+
or _env("ABSTRACT_TRIAGE_EMAIL_TO", "ABSTRACTGATEWAY_TRIAGE_EMAIL_TO")
|
|
57
|
+
or ""
|
|
58
|
+
)
|
|
59
|
+
parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
|
|
60
|
+
return parts
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def send_email_notification(*, subject: str, body_text: str) -> Tuple[bool, Optional[str]]:
|
|
64
|
+
to = _email_recipients()
|
|
65
|
+
if not to:
|
|
66
|
+
return False, "Missing EMAIL_TO recipients"
|
|
67
|
+
|
|
68
|
+
smtp_host = (
|
|
69
|
+
_env("ABSTRACT_BACKLOG_EMAIL_SMTP_HOST", "ABSTRACTGATEWAY_BACKLOG_EMAIL_SMTP_HOST")
|
|
70
|
+
or _env("ABSTRACT_TRIAGE_EMAIL_SMTP_HOST", "ABSTRACTGATEWAY_TRIAGE_EMAIL_SMTP_HOST")
|
|
71
|
+
or _env("ABSTRACT_EMAIL_SMTP_HOST")
|
|
72
|
+
or ""
|
|
73
|
+
)
|
|
74
|
+
username = (
|
|
75
|
+
_env("ABSTRACT_BACKLOG_EMAIL_USERNAME", "ABSTRACTGATEWAY_BACKLOG_EMAIL_USERNAME")
|
|
76
|
+
or _env("ABSTRACT_TRIAGE_EMAIL_USERNAME", "ABSTRACTGATEWAY_TRIAGE_EMAIL_USERNAME")
|
|
77
|
+
or _env("ABSTRACT_EMAIL_SMTP_USERNAME")
|
|
78
|
+
or ""
|
|
79
|
+
)
|
|
80
|
+
password_env_var = (
|
|
81
|
+
_env("ABSTRACT_BACKLOG_EMAIL_PASSWORD_ENV_VAR", "ABSTRACTGATEWAY_BACKLOG_EMAIL_PASSWORD_ENV_VAR")
|
|
82
|
+
or _env("ABSTRACT_TRIAGE_EMAIL_PASSWORD_ENV_VAR", "ABSTRACTGATEWAY_TRIAGE_EMAIL_PASSWORD_ENV_VAR")
|
|
83
|
+
or _env("ABSTRACT_EMAIL_SMTP_PASSWORD_ENV_VAR")
|
|
84
|
+
or ""
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Prefer the framework tool (secrets via env var indirection).
|
|
88
|
+
try:
|
|
89
|
+
from abstractcore.tools.comms_tools import send_email # type: ignore
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return False, f"Email tools unavailable: {e}"
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
out: Dict[str, Any] = send_email(
|
|
95
|
+
smtp_host=smtp_host or None,
|
|
96
|
+
username=username or None,
|
|
97
|
+
password_env_var=password_env_var or None,
|
|
98
|
+
from_email=_env("ABSTRACT_EMAIL_FROM"),
|
|
99
|
+
to=to,
|
|
100
|
+
subject=str(subject or ""),
|
|
101
|
+
body_text=str(body_text or ""),
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return False, str(e)
|
|
105
|
+
|
|
106
|
+
if isinstance(out, dict) and out.get("success") is True:
|
|
107
|
+
return True, None
|
|
108
|
+
err = out.get("error") if isinstance(out, dict) else None
|
|
109
|
+
return False, str(err or "Email send failed")
|