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.
Files changed (40) hide show
  1. abstractgateway/__init__.py +1 -2
  2. abstractgateway/__main__.py +7 -0
  3. abstractgateway/app.py +4 -4
  4. abstractgateway/cli.py +568 -8
  5. abstractgateway/config.py +15 -5
  6. abstractgateway/embeddings_config.py +45 -0
  7. abstractgateway/host_metrics.py +274 -0
  8. abstractgateway/hosts/bundle_host.py +528 -55
  9. abstractgateway/hosts/visualflow_host.py +30 -3
  10. abstractgateway/integrations/__init__.py +2 -0
  11. abstractgateway/integrations/email_bridge.py +782 -0
  12. abstractgateway/integrations/telegram_bridge.py +534 -0
  13. abstractgateway/maintenance/__init__.py +5 -0
  14. abstractgateway/maintenance/action_tokens.py +100 -0
  15. abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
  16. abstractgateway/maintenance/backlog_parser.py +184 -0
  17. abstractgateway/maintenance/draft_generator.py +451 -0
  18. abstractgateway/maintenance/llm_assist.py +212 -0
  19. abstractgateway/maintenance/notifier.py +109 -0
  20. abstractgateway/maintenance/process_manager.py +1064 -0
  21. abstractgateway/maintenance/report_models.py +81 -0
  22. abstractgateway/maintenance/report_parser.py +219 -0
  23. abstractgateway/maintenance/text_similarity.py +123 -0
  24. abstractgateway/maintenance/triage.py +507 -0
  25. abstractgateway/maintenance/triage_queue.py +142 -0
  26. abstractgateway/migrate.py +155 -0
  27. abstractgateway/routes/__init__.py +2 -2
  28. abstractgateway/routes/gateway.py +10817 -179
  29. abstractgateway/routes/triage.py +118 -0
  30. abstractgateway/runner.py +689 -14
  31. abstractgateway/security/gateway_security.py +425 -110
  32. abstractgateway/service.py +213 -6
  33. abstractgateway/stores.py +64 -4
  34. abstractgateway/workflow_deprecations.py +225 -0
  35. abstractgateway-0.1.1.dist-info/METADATA +135 -0
  36. abstractgateway-0.1.1.dist-info/RECORD +40 -0
  37. abstractgateway-0.1.0.dist-info/METADATA +0 -101
  38. abstractgateway-0.1.0.dist-info/RECORD +0 -18
  39. {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
  40. {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")