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,81 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Literal, Optional
6
+
7
+
8
+ ReportType = Literal["bug", "feature"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ReportHeader:
13
+ title: str
14
+ created_at: str = ""
15
+ report_id: str = ""
16
+ session_id: str = ""
17
+ session_memory_run_id: str = ""
18
+ active_run_id: str = ""
19
+ workflow_id: str = ""
20
+ client: str = ""
21
+ client_version: str = ""
22
+ provider: str = ""
23
+ model: str = ""
24
+ template: str = ""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ReportRecord:
29
+ report_type: ReportType
30
+ path: Path
31
+ header: ReportHeader
32
+ description: str
33
+ sections: Dict[str, str] = field(default_factory=dict)
34
+ context: Dict[str, Any] = field(default_factory=dict)
35
+
36
+ def to_similarity_text(self) -> str:
37
+ bits: List[str] = []
38
+ if self.header.title:
39
+ bits.append(self.header.title)
40
+ if self.description:
41
+ bits.append(self.description)
42
+ # Include a small amount of environment context for disambiguation.
43
+ env_bits: List[str] = []
44
+ if self.header.client:
45
+ env_bits.append(f"client={self.header.client}")
46
+ if self.header.workflow_id:
47
+ env_bits.append(f"workflow={self.header.workflow_id}")
48
+ if self.header.template:
49
+ env_bits.append(f"template={self.header.template}")
50
+ if env_bits:
51
+ bits.append(" ".join(env_bits))
52
+ return "\n\n".join([b for b in bits if b]).strip()
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SimilarityCandidate:
57
+ kind: Literal["report", "backlog_planned", "backlog_completed"]
58
+ ref: str
59
+ score: float
60
+ title: str = ""
61
+
62
+
63
+ @dataclass
64
+ class TriageDecision:
65
+ decision_id: str
66
+ report_type: ReportType
67
+ report_relpath: str
68
+ status: Literal["pending", "approved", "deferred", "rejected"] = "pending"
69
+ created_at: str = ""
70
+ updated_at: str = ""
71
+ defer_until: str = ""
72
+
73
+ missing_fields: List[str] = field(default_factory=list)
74
+ duplicates: List[SimilarityCandidate] = field(default_factory=list)
75
+
76
+ # When we write a draft file, store its relative path (repo-root relative).
77
+ draft_relpath: str = ""
78
+
79
+ # Optional LLM suggestions (best-effort; informational only).
80
+ llm_suggestion: Dict[str, Any] = field(default_factory=dict)
81
+
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional, Tuple
7
+
8
+ from .report_models import ReportHeader, ReportRecord, ReportType
9
+
10
+
11
+ _TITLE_RE = re.compile(r"^#\s+(?P<kind>Bug|Feature)\s*:\s*(?P<title>.+?)\s*$", re.IGNORECASE)
12
+ _BLOCKQUOTE_KV_RE = re.compile(r"^\s*>\s*(?P<key>[^:]+?)\s*:\s*(?P<value>.*?)\s*$")
13
+ _H2_RE = re.compile(r"^##\s+(?P<name>.+?)\s*$")
14
+ _FENCE_JSON_RE = re.compile(r"```json\s*(?P<body>.*?)\s*```", re.IGNORECASE | re.DOTALL)
15
+
16
+
17
+ def _infer_report_type_from_path(path: Path) -> Optional[ReportType]:
18
+ parts = {p.lower() for p in path.parts}
19
+ if "bug_reports" in parts:
20
+ return "bug"
21
+ if "feature_requests" in parts:
22
+ return "feature"
23
+ return None
24
+
25
+
26
+ def _parse_title_and_type(text: str, *, path: Path) -> Tuple[ReportType, str]:
27
+ for raw in text.splitlines():
28
+ line = raw.strip()
29
+ if not line.startswith("#"):
30
+ continue
31
+ m = _TITLE_RE.match(line)
32
+ if m:
33
+ kind = (m.group("kind") or "").strip().lower()
34
+ title = (m.group("title") or "").strip()
35
+ if len(title) > 120:
36
+ title = title[:120].rstrip()
37
+ return ("bug" if kind == "bug" else "feature", title)
38
+ break
39
+ inferred = _infer_report_type_from_path(path) or "bug"
40
+ # Best-effort: use first non-empty line as title.
41
+ for raw in text.splitlines():
42
+ s = raw.strip().lstrip("#").strip()
43
+ if s:
44
+ return inferred, s[:120]
45
+ return inferred, "Report"
46
+
47
+
48
+ def _parse_blockquote_headers(text: str) -> Dict[str, str]:
49
+ out: Dict[str, str] = {}
50
+ for raw in text.splitlines():
51
+ m = _BLOCKQUOTE_KV_RE.match(raw)
52
+ if not m:
53
+ continue
54
+ key = str(m.group("key") or "").strip().lower()
55
+ val = str(m.group("value") or "").strip()
56
+ if key:
57
+ out[key] = val
58
+ return out
59
+
60
+
61
+ def _parse_sections(text: str) -> Dict[str, str]:
62
+ sections: Dict[str, list[str]] = {}
63
+ current: Optional[str] = None
64
+ for raw in text.splitlines():
65
+ m = _H2_RE.match(raw.strip())
66
+ if m:
67
+ current = str(m.group("name") or "").strip()
68
+ if current:
69
+ sections.setdefault(current, [])
70
+ continue
71
+ if current:
72
+ sections[current].append(raw.rstrip("\n"))
73
+ # Preserve leading whitespace (many templates store user-provided fields as
74
+ # Markdown literals via indentation). Only strip surrounding newlines.
75
+ return {k: "\n".join(v).strip("\n") for k, v in sections.items()}
76
+
77
+
78
+ def _extract_context_json(text: str) -> Dict[str, Any]:
79
+ m = _FENCE_JSON_RE.search(text)
80
+ if not m:
81
+ return {}
82
+ body = str(m.group("body") or "").strip()
83
+ if not body:
84
+ return {}
85
+ try:
86
+ obj = json.loads(body)
87
+ except Exception:
88
+ return {}
89
+ return obj if isinstance(obj, dict) else {}
90
+
91
+
92
+ def _first_nonempty_section(sections: Dict[str, str], *names: str) -> str:
93
+ for name in names:
94
+ content = sections.get(name)
95
+ if isinstance(content, str) and content.strip():
96
+ # Preserve indentation (see `_parse_sections`).
97
+ return content.strip("\n")
98
+ return ""
99
+
100
+
101
+ def _dedent_markdown_literal(text: str) -> str:
102
+ """Remove a single Markdown-literal indentation level (4 spaces).
103
+
104
+ Gateway report templates store user-provided descriptions as indented code
105
+ blocks to avoid accidental Markdown/HTML rendering. Downstream consumers
106
+ (triage + proposed backlog drafts) want the raw user text.
107
+ """
108
+
109
+ s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
110
+ if not s:
111
+ return ""
112
+ lines = s.split("\n")
113
+ out: list[str] = []
114
+ for ln in lines:
115
+ if ln.startswith(" "):
116
+ out.append(ln[4:])
117
+ elif ln.startswith("\t"):
118
+ out.append(ln[1:])
119
+ else:
120
+ out.append(ln)
121
+ return "\n".join(out).rstrip("\n")
122
+
123
+
124
+ def parse_report_file(path: Path) -> ReportRecord:
125
+ text = path.read_text(encoding="utf-8", errors="replace")
126
+ report_type, title = _parse_title_and_type(text, path=path)
127
+
128
+ headers = _parse_blockquote_headers(text)
129
+
130
+ def _h(*keys: str) -> str:
131
+ for k in keys:
132
+ v = headers.get(k.lower())
133
+ if isinstance(v, str) and v.strip():
134
+ return v.strip()
135
+ return ""
136
+
137
+ created_at = _h("created")
138
+ report_id = _h("bug id", "feature id", "report id")
139
+ session_id = _h("session id")
140
+ session_memory_run_id = _h("session memory run id", "session_memory_run_id")
141
+ active_run_id = _h("relevant run id", "active run id", "run id")
142
+ workflow_id = _h("workflow id")
143
+
144
+ sections = _parse_sections(text)
145
+
146
+ description = _first_nonempty_section(
147
+ sections,
148
+ "User Description",
149
+ "User Request",
150
+ "Description",
151
+ )
152
+ description = _dedent_markdown_literal(description)
153
+
154
+ # Environment info (best-effort; present in templates but may be missing/edited).
155
+ env_section = sections.get("Environment") or ""
156
+ client = ""
157
+ client_version = ""
158
+ provider = ""
159
+ model = ""
160
+ template = ""
161
+ if env_section:
162
+ # Parse "- Key: value" bullets.
163
+ for raw in env_section.splitlines():
164
+ line = raw.strip()
165
+ if not line.startswith("-"):
166
+ continue
167
+ kv = line.lstrip("-").strip()
168
+ if ":" not in kv:
169
+ continue
170
+ k, v = kv.split(":", 1)
171
+ key = k.strip().lower()
172
+ val = v.strip()
173
+ if not val:
174
+ continue
175
+ if key == "client":
176
+ client = val
177
+ elif key == "client version":
178
+ client_version = val
179
+ elif key in {"provider/model", "provider"}:
180
+ provider = val
181
+ elif key == "model":
182
+ model = val
183
+ elif key in {"agent template", "template"}:
184
+ template = val
185
+ elif key == "provider/model":
186
+ # "Provider/model: provider / model"
187
+ if "/" in val:
188
+ pass
189
+
190
+ # Special-case provider/model format: "- Provider/model: X / Y"
191
+ m = re.search(r"^\s*-\s*Provider/model\s*:\s*(.+?)\s*/\s*(.+?)\s*$", env_section, re.IGNORECASE | re.MULTILINE)
192
+ if m:
193
+ provider = (m.group(1) or "").strip()
194
+ model = (m.group(2) or "").strip()
195
+
196
+ header = ReportHeader(
197
+ title=title,
198
+ created_at=created_at,
199
+ report_id=report_id,
200
+ session_id=session_id,
201
+ session_memory_run_id=session_memory_run_id,
202
+ active_run_id=active_run_id,
203
+ workflow_id=workflow_id,
204
+ client=client,
205
+ client_version=client_version,
206
+ provider=provider,
207
+ model=model,
208
+ template=template,
209
+ )
210
+ context = _extract_context_json(text)
211
+
212
+ return ReportRecord(
213
+ report_type=report_type,
214
+ path=path,
215
+ header=header,
216
+ description=description,
217
+ sections=sections,
218
+ context=context,
219
+ )
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ from typing import Dict, Iterable, List, Set, Tuple
6
+
7
+
8
+ _TOKEN_RE = re.compile(r"[a-z0-9]+", re.IGNORECASE)
9
+
10
+ _STOPWORDS: Set[str] = {
11
+ "a",
12
+ "an",
13
+ "and",
14
+ "are",
15
+ "as",
16
+ "at",
17
+ "be",
18
+ "but",
19
+ "by",
20
+ "for",
21
+ "from",
22
+ "has",
23
+ "have",
24
+ "i",
25
+ "in",
26
+ "into",
27
+ "is",
28
+ "it",
29
+ "of",
30
+ "on",
31
+ "or",
32
+ "s",
33
+ "that",
34
+ "the",
35
+ "their",
36
+ "this",
37
+ "to",
38
+ "was",
39
+ "were",
40
+ "with",
41
+ "without",
42
+ "you",
43
+ "your",
44
+ }
45
+
46
+
47
+ def tokenize(text: str) -> List[str]:
48
+ raw = str(text or "").lower()
49
+ tokens = [t for t in _TOKEN_RE.findall(raw) if len(t) >= 2]
50
+ return [t for t in tokens if t not in _STOPWORDS]
51
+
52
+
53
+ def token_set(text: str) -> Set[str]:
54
+ return set(tokenize(text))
55
+
56
+
57
+ def jaccard(a: Iterable[str], b: Iterable[str]) -> float:
58
+ sa = set(a)
59
+ sb = set(b)
60
+ if not sa and not sb:
61
+ return 1.0
62
+ if not sa or not sb:
63
+ return 0.0
64
+ inter = sa.intersection(sb)
65
+ union = sa.union(sb)
66
+ return float(len(inter)) / float(len(union)) if union else 0.0
67
+
68
+
69
+ def cosine_counts(counts_a: Dict[str, int], counts_b: Dict[str, int]) -> float:
70
+ if not counts_a and not counts_b:
71
+ return 1.0
72
+ if not counts_a or not counts_b:
73
+ return 0.0
74
+ dot = 0.0
75
+ na = 0.0
76
+ nb = 0.0
77
+ for k, va in counts_a.items():
78
+ na += float(va) * float(va)
79
+ vb = counts_b.get(k)
80
+ if vb:
81
+ dot += float(va) * float(vb)
82
+ for vb in counts_b.values():
83
+ nb += float(vb) * float(vb)
84
+ denom = math.sqrt(na) * math.sqrt(nb)
85
+ if denom <= 0:
86
+ return 0.0
87
+ return float(dot) / float(denom)
88
+
89
+
90
+ def token_counts(text: str) -> Dict[str, int]:
91
+ counts: Dict[str, int] = {}
92
+ for t in tokenize(text):
93
+ counts[t] = counts.get(t, 0) + 1
94
+ return counts
95
+
96
+
97
+ def similarity(text_a: str, text_b: str) -> float:
98
+ """Deterministic similarity score in [0,1]."""
99
+ # Blend set overlap (good for short titles) and cosine counts (good for longer text).
100
+ sa = token_set(text_a)
101
+ sb = token_set(text_b)
102
+ j = jaccard(sa, sb)
103
+ ca = token_counts(text_a)
104
+ cb = token_counts(text_b)
105
+ c = cosine_counts(ca, cb)
106
+ return 0.55 * j + 0.45 * c
107
+
108
+
109
+ def top_k_similar(
110
+ *,
111
+ query_text: str,
112
+ candidates: List[Tuple[str, str]],
113
+ k: int = 5,
114
+ min_score: float = 0.25,
115
+ ) -> List[Tuple[str, float]]:
116
+ scored: List[Tuple[str, float]] = []
117
+ for ref, cand_text in candidates:
118
+ s = similarity(query_text, cand_text)
119
+ if s >= float(min_score):
120
+ scored.append((ref, s))
121
+ scored.sort(key=lambda x: x[1], reverse=True)
122
+ return scored[: max(0, int(k))]
123
+