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,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
|
+
|