swarph-triage 0.1.0__tar.gz
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.
- swarph_triage-0.1.0/LICENSE +21 -0
- swarph_triage-0.1.0/PKG-INFO +129 -0
- swarph_triage-0.1.0/README.md +102 -0
- swarph_triage-0.1.0/pyproject.toml +47 -0
- swarph_triage-0.1.0/setup.cfg +4 -0
- swarph_triage-0.1.0/swarph_triage/__init__.py +28 -0
- swarph_triage-0.1.0/swarph_triage/_version.py +1 -0
- swarph_triage-0.1.0/swarph_triage/cli.py +116 -0
- swarph_triage-0.1.0/swarph_triage/config.py +63 -0
- swarph_triage-0.1.0/swarph_triage/fastapi.py +56 -0
- swarph_triage-0.1.0/swarph_triage/priority.py +133 -0
- swarph_triage-0.1.0/swarph_triage/queue.py +536 -0
- swarph_triage-0.1.0/swarph_triage/regression.py +82 -0
- swarph_triage-0.1.0/swarph_triage/schema.py +94 -0
- swarph_triage-0.1.0/swarph_triage/state_machine.py +43 -0
- swarph_triage-0.1.0/swarph_triage.egg-info/PKG-INFO +129 -0
- swarph_triage-0.1.0/swarph_triage.egg-info/SOURCES.txt +28 -0
- swarph_triage-0.1.0/swarph_triage.egg-info/dependency_links.txt +1 -0
- swarph_triage-0.1.0/swarph_triage.egg-info/entry_points.txt +2 -0
- swarph_triage-0.1.0/swarph_triage.egg-info/requires.txt +9 -0
- swarph_triage-0.1.0/swarph_triage.egg-info/top_level.txt +1 -0
- swarph_triage-0.1.0/tests/test_cli.py +105 -0
- swarph_triage-0.1.0/tests/test_edges.py +80 -0
- swarph_triage-0.1.0/tests/test_fastapi.py +89 -0
- swarph_triage-0.1.0/tests/test_ingest.py +173 -0
- swarph_triage-0.1.0/tests/test_maintenance.py +90 -0
- swarph_triage-0.1.0/tests/test_priority.py +171 -0
- swarph_triage-0.1.0/tests/test_reads.py +123 -0
- swarph_triage-0.1.0/tests/test_smoke.py +99 -0
- swarph_triage-0.1.0/tests/test_transition.py +124 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pierre Samson and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swarph-triage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generalizable ranked-queue triage primitive (fingerprint + priority + state machine + regression detector). Backend-agnostic via SQLAlchemy Core (sqlite + postgres).
|
|
5
|
+
Author: Pierre Samson, Claude
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/darw007d/swarph-triage
|
|
8
|
+
Project-URL: Issues, https://github.com/darw007d/swarph-triage/issues
|
|
9
|
+
Keywords: triage,queue,priority,state-machine,swarph
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
20
|
+
Provides-Extra: fastapi
|
|
21
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
22
|
+
Requires-Dist: sse-starlette>=2.0; extra == "fastapi"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# swarph-triage
|
|
29
|
+
|
|
30
|
+
> Generalizable ranked-queue triage primitive — fingerprint, priority, state machine, regression detector. Backend-agnostic via SQLAlchemy Core (sqlite + postgres).
|
|
31
|
+
|
|
32
|
+
**Status:** ✅ 0.1.0 — functional. Public API, priority formula, state machine, regression detector, CLI, and FastAPI router are implemented and tested (sqlite + postgres via SQLAlchemy Core).
|
|
33
|
+
|
|
34
|
+
Extracted from a production **error-triage** system (scan logs → fingerprint → prioritize → work the backlog down) and generalized: the same ranked-dedup-queue pattern fits any high-volume stream of observations that should collapse into prioritized, dispositionable rows.
|
|
35
|
+
|
|
36
|
+
## The pattern
|
|
37
|
+
|
|
38
|
+
A ranked queue where:
|
|
39
|
+
|
|
40
|
+
1. **Many concrete observations collapse to one logical row** via a `fingerprint_fn`. (Canonical case — error triage: 47 near-identical error log lines → 1 fingerprint with `count=47`. The pattern generalizes to any stream where many raw events map to one actionable unit.)
|
|
41
|
+
2. **Items rank by `severity × log(1+freq) × decay(age) × actionability`** — log on freq stops whales drowning fresh items, exp-decay on age makes hot items rise. All coefficients live in a calibration table (config-driven, not hardcoded).
|
|
42
|
+
3. **A small explicit state machine** (`new → triaged → approved → patched`, with branches to `wontfix` and `needs_review`) — every transition logged to `state_log`, no implicit "kinda done."
|
|
43
|
+
4. **A regression detector** — if a fingerprint with `status='patched'` gets a new occurrence within `regression_grace_hours`, resurrect to `new` with `regression=1`. Accepted dispositions don't silently mask returning problems.
|
|
44
|
+
5. **Cooldown semantics** — a `cooldown_until` timestamp on `let_cool` dispositions. The priority calc ramps back from zero as cooldown expires, so a deliberately-deferred item doesn't immediately re-surface.
|
|
45
|
+
|
|
46
|
+
## Public API surface (planned)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from swarph_triage import open as open_triage
|
|
50
|
+
|
|
51
|
+
q = open_triage(
|
|
52
|
+
"postgresql://user:pass@host:5433/mydb", # or sqlite:///path.db
|
|
53
|
+
config={"decay_half_life_hours": 72.0},
|
|
54
|
+
proposer_fn=my_proposer, # optional, domain-specific
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# ingest one observation
|
|
58
|
+
fp_id = q.ingest(
|
|
59
|
+
fingerprint="NullPointerError|auth.py|login",
|
|
60
|
+
severity="high",
|
|
61
|
+
actionability=0.7,
|
|
62
|
+
context={"module": "auth", "first_seen": "..."},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# disposition
|
|
66
|
+
q.transition(fp_id, to_status="approved", actor="oncall", note="fix queued")
|
|
67
|
+
|
|
68
|
+
# top-N for the UI
|
|
69
|
+
for row in q.list(limit=20):
|
|
70
|
+
print(row["fingerprint"], row["priority_score"], row["status"])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
CLI:
|
|
74
|
+
```
|
|
75
|
+
swarph-triage list
|
|
76
|
+
swarph-triage show <id>
|
|
77
|
+
swarph-triage approve <id>
|
|
78
|
+
swarph-triage wontfix <id> "reason"
|
|
79
|
+
swarph-triage stats
|
|
80
|
+
swarph-triage backlog # writes markdown snapshot
|
|
81
|
+
swarph-triage history <id>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
FastAPI routes (optional install: `pip install swarph-triage[fastapi]`):
|
|
85
|
+
```python
|
|
86
|
+
from fastapi import FastAPI
|
|
87
|
+
from swarph_triage.fastapi import build_router
|
|
88
|
+
|
|
89
|
+
app = FastAPI()
|
|
90
|
+
app.include_router(build_router(q), prefix="/triage")
|
|
91
|
+
# → /list, /stats, /show/{id}, /{id}/{approve|wontfix|escalate|reopen}, /events (SSE)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
Everything tunable lives in `swarph_triage.config.DEFAULT_CONFIG` — override per-consumer at `open()`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
DEFAULT_CONFIG = {
|
|
100
|
+
"decay_half_life_hours": 6.0, # 6h for hourly, 72h for daily
|
|
101
|
+
"severity_weights": {"critical": 1.0, "high": 0.7, "medium": 0.5, "low": 0.3},
|
|
102
|
+
"freq_curve": "log", # "log" | "linear" | "sqrt"
|
|
103
|
+
"freq_log_base": 10,
|
|
104
|
+
"actionability_floor": 0.1,
|
|
105
|
+
"regression_grace_hours": 24,
|
|
106
|
+
"cooldown_default_days": 14,
|
|
107
|
+
"priority_min": 0.0,
|
|
108
|
+
"priority_max": 100.0,
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Layout
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
swarph_triage/
|
|
116
|
+
__init__.py — public API surface
|
|
117
|
+
config.py — DEFAULT_CONFIG + load/merge helpers
|
|
118
|
+
schema.py — SQLAlchemy Core table definitions (3 tables, 6 indexes)
|
|
119
|
+
state_machine.py — Status enum + valid transition matrix + side effects
|
|
120
|
+
priority.py — score formula + decay + recompute_all
|
|
121
|
+
regression.py — patched-then-reappearance detector
|
|
122
|
+
queue.py — TriageQueue main class (ingest, transition, list, show)
|
|
123
|
+
cli.py — argparse-driven CLI
|
|
124
|
+
fastapi.py — APIRouter factory (optional extra)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT. Pierre Samson + Claude, co-authored — matching the `phawkes` / `fisherrao` / `tailcor` lineage.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# swarph-triage
|
|
2
|
+
|
|
3
|
+
> Generalizable ranked-queue triage primitive — fingerprint, priority, state machine, regression detector. Backend-agnostic via SQLAlchemy Core (sqlite + postgres).
|
|
4
|
+
|
|
5
|
+
**Status:** ✅ 0.1.0 — functional. Public API, priority formula, state machine, regression detector, CLI, and FastAPI router are implemented and tested (sqlite + postgres via SQLAlchemy Core).
|
|
6
|
+
|
|
7
|
+
Extracted from a production **error-triage** system (scan logs → fingerprint → prioritize → work the backlog down) and generalized: the same ranked-dedup-queue pattern fits any high-volume stream of observations that should collapse into prioritized, dispositionable rows.
|
|
8
|
+
|
|
9
|
+
## The pattern
|
|
10
|
+
|
|
11
|
+
A ranked queue where:
|
|
12
|
+
|
|
13
|
+
1. **Many concrete observations collapse to one logical row** via a `fingerprint_fn`. (Canonical case — error triage: 47 near-identical error log lines → 1 fingerprint with `count=47`. The pattern generalizes to any stream where many raw events map to one actionable unit.)
|
|
14
|
+
2. **Items rank by `severity × log(1+freq) × decay(age) × actionability`** — log on freq stops whales drowning fresh items, exp-decay on age makes hot items rise. All coefficients live in a calibration table (config-driven, not hardcoded).
|
|
15
|
+
3. **A small explicit state machine** (`new → triaged → approved → patched`, with branches to `wontfix` and `needs_review`) — every transition logged to `state_log`, no implicit "kinda done."
|
|
16
|
+
4. **A regression detector** — if a fingerprint with `status='patched'` gets a new occurrence within `regression_grace_hours`, resurrect to `new` with `regression=1`. Accepted dispositions don't silently mask returning problems.
|
|
17
|
+
5. **Cooldown semantics** — a `cooldown_until` timestamp on `let_cool` dispositions. The priority calc ramps back from zero as cooldown expires, so a deliberately-deferred item doesn't immediately re-surface.
|
|
18
|
+
|
|
19
|
+
## Public API surface (planned)
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from swarph_triage import open as open_triage
|
|
23
|
+
|
|
24
|
+
q = open_triage(
|
|
25
|
+
"postgresql://user:pass@host:5433/mydb", # or sqlite:///path.db
|
|
26
|
+
config={"decay_half_life_hours": 72.0},
|
|
27
|
+
proposer_fn=my_proposer, # optional, domain-specific
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ingest one observation
|
|
31
|
+
fp_id = q.ingest(
|
|
32
|
+
fingerprint="NullPointerError|auth.py|login",
|
|
33
|
+
severity="high",
|
|
34
|
+
actionability=0.7,
|
|
35
|
+
context={"module": "auth", "first_seen": "..."},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# disposition
|
|
39
|
+
q.transition(fp_id, to_status="approved", actor="oncall", note="fix queued")
|
|
40
|
+
|
|
41
|
+
# top-N for the UI
|
|
42
|
+
for row in q.list(limit=20):
|
|
43
|
+
print(row["fingerprint"], row["priority_score"], row["status"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
CLI:
|
|
47
|
+
```
|
|
48
|
+
swarph-triage list
|
|
49
|
+
swarph-triage show <id>
|
|
50
|
+
swarph-triage approve <id>
|
|
51
|
+
swarph-triage wontfix <id> "reason"
|
|
52
|
+
swarph-triage stats
|
|
53
|
+
swarph-triage backlog # writes markdown snapshot
|
|
54
|
+
swarph-triage history <id>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
FastAPI routes (optional install: `pip install swarph-triage[fastapi]`):
|
|
58
|
+
```python
|
|
59
|
+
from fastapi import FastAPI
|
|
60
|
+
from swarph_triage.fastapi import build_router
|
|
61
|
+
|
|
62
|
+
app = FastAPI()
|
|
63
|
+
app.include_router(build_router(q), prefix="/triage")
|
|
64
|
+
# → /list, /stats, /show/{id}, /{id}/{approve|wontfix|escalate|reopen}, /events (SSE)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Everything tunable lives in `swarph_triage.config.DEFAULT_CONFIG` — override per-consumer at `open()`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
DEFAULT_CONFIG = {
|
|
73
|
+
"decay_half_life_hours": 6.0, # 6h for hourly, 72h for daily
|
|
74
|
+
"severity_weights": {"critical": 1.0, "high": 0.7, "medium": 0.5, "low": 0.3},
|
|
75
|
+
"freq_curve": "log", # "log" | "linear" | "sqrt"
|
|
76
|
+
"freq_log_base": 10,
|
|
77
|
+
"actionability_floor": 0.1,
|
|
78
|
+
"regression_grace_hours": 24,
|
|
79
|
+
"cooldown_default_days": 14,
|
|
80
|
+
"priority_min": 0.0,
|
|
81
|
+
"priority_max": 100.0,
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Layout
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
swarph_triage/
|
|
89
|
+
__init__.py — public API surface
|
|
90
|
+
config.py — DEFAULT_CONFIG + load/merge helpers
|
|
91
|
+
schema.py — SQLAlchemy Core table definitions (3 tables, 6 indexes)
|
|
92
|
+
state_machine.py — Status enum + valid transition matrix + side effects
|
|
93
|
+
priority.py — score formula + decay + recompute_all
|
|
94
|
+
regression.py — patched-then-reappearance detector
|
|
95
|
+
queue.py — TriageQueue main class (ingest, transition, list, show)
|
|
96
|
+
cli.py — argparse-driven CLI
|
|
97
|
+
fastapi.py — APIRouter factory (optional extra)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT. Pierre Samson + Claude, co-authored — matching the `phawkes` / `fisherrao` / `tailcor` lineage.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "swarph-triage"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generalizable ranked-queue triage primitive (fingerprint + priority + state machine + regression detector). Backend-agnostic via SQLAlchemy Core (sqlite + postgres)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Pierre Samson" },
|
|
13
|
+
{ name = "Claude" },
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.9"
|
|
16
|
+
keywords = ["triage", "queue", "priority", "state-machine", "swarph"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Database",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
"Development Status :: 3 - Alpha",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"sqlalchemy>=2.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
fastapi = [
|
|
31
|
+
"fastapi>=0.110",
|
|
32
|
+
"sse-starlette>=2.0",
|
|
33
|
+
]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0",
|
|
36
|
+
"pytest-asyncio>=0.21",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
swarph-triage = "swarph_triage.cli:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/darw007d/swarph-triage"
|
|
44
|
+
Issues = "https://github.com/darw007d/swarph-triage/issues"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
include = ["swarph_triage*"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""swarph-triage — ranked-queue triage primitive.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from swarph_triage import open
|
|
6
|
+
q = open(db_url, config=..., proposer_fn=...)
|
|
7
|
+
q.ingest(fingerprint=..., severity=..., actionability=..., context=...)
|
|
8
|
+
q.transition(fp_id, to_status=..., actor=..., note=...)
|
|
9
|
+
q.list(limit=20)
|
|
10
|
+
q.show(fp_id)
|
|
11
|
+
|
|
12
|
+
See README.md for the full surface.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from swarph_triage._version import __version__
|
|
16
|
+
from swarph_triage.queue import TriageQueue, open # noqa: F401
|
|
17
|
+
from swarph_triage.state_machine import Status, VALID_TRANSITIONS # noqa: F401
|
|
18
|
+
from swarph_triage.config import DEFAULT_CONFIG, load_config # noqa: F401
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"__version__",
|
|
22
|
+
"open",
|
|
23
|
+
"TriageQueue",
|
|
24
|
+
"Status",
|
|
25
|
+
"VALID_TRANSITIONS",
|
|
26
|
+
"DEFAULT_CONFIG",
|
|
27
|
+
"load_config",
|
|
28
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""CLI — pure SQL, no LLM, instant.
|
|
2
|
+
|
|
3
|
+
Reads ``SWARPH_TRIAGE_DB_URL`` from env. Implementation lands with queue port.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="swarph-triage")
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--db-url",
|
|
17
|
+
default=os.environ.get("SWARPH_TRIAGE_DB_URL"),
|
|
18
|
+
help="Database URL (defaults to $SWARPH_TRIAGE_DB_URL).",
|
|
19
|
+
)
|
|
20
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
21
|
+
|
|
22
|
+
sub.add_parser("list", help="Show top of queue").add_argument(
|
|
23
|
+
"--status", help="Filter by status", default=None,
|
|
24
|
+
)
|
|
25
|
+
sub.add_parser("stats", help="Status + category breakdown")
|
|
26
|
+
p_show = sub.add_parser("show", help="Show one row + history")
|
|
27
|
+
p_show.add_argument("id", type=int)
|
|
28
|
+
for verb in ("approve", "wontfix", "escalate", "reopen"):
|
|
29
|
+
p = sub.add_parser(verb, help=f"{verb} a fingerprint")
|
|
30
|
+
p.add_argument("id", type=int)
|
|
31
|
+
p.add_argument("note", nargs="?", default="")
|
|
32
|
+
p_hist = sub.add_parser("history", help="Show state_log for one row")
|
|
33
|
+
p_hist.add_argument("id", type=int)
|
|
34
|
+
sub.add_parser("backlog", help="Print queue as markdown")
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
if not args.db_url:
|
|
38
|
+
print("ERROR: --db-url or $SWARPH_TRIAGE_DB_URL required", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
|
|
43
|
+
from swarph_triage import open as open_triage
|
|
44
|
+
|
|
45
|
+
q = open_triage(args.db_url)
|
|
46
|
+
|
|
47
|
+
# Verbs that map to a state transition / reopen.
|
|
48
|
+
_TRANSITION_VERBS = {
|
|
49
|
+
"approve": "approved",
|
|
50
|
+
"wontfix": "wontfix",
|
|
51
|
+
"escalate": "needs_review",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if args.cmd == "list":
|
|
55
|
+
rows = q.list(status=args.status)
|
|
56
|
+
if not rows:
|
|
57
|
+
print("(queue empty)")
|
|
58
|
+
for r in rows:
|
|
59
|
+
print(f"{r['id']:>5} {r['priority_score']:>7.2f} "
|
|
60
|
+
f"{r['status']:<12} {r['severity']:<8} {r['fingerprint']}")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
if args.cmd == "stats":
|
|
64
|
+
print(json.dumps(q.stats(), indent=2, default=str))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
if args.cmd == "show":
|
|
68
|
+
row = q.show(args.id)
|
|
69
|
+
if not row:
|
|
70
|
+
print(f"ERROR: no fingerprint id={args.id}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
print(json.dumps(row, indent=2, default=str))
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
if args.cmd in _TRANSITION_VERBS:
|
|
76
|
+
ok = q.transition(
|
|
77
|
+
args.id,
|
|
78
|
+
to_status=_TRANSITION_VERBS[args.cmd],
|
|
79
|
+
actor="cli",
|
|
80
|
+
note=args.note,
|
|
81
|
+
)
|
|
82
|
+
if not ok:
|
|
83
|
+
print(f"ERROR: {args.cmd} on id={args.id} rejected "
|
|
84
|
+
f"(invalid transition or missing row)", file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
print(f"{args.cmd}: id={args.id} -> {_TRANSITION_VERBS[args.cmd]}")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
if args.cmd == "reopen":
|
|
90
|
+
ok = q.reopen(args.id, actor="cli", note=args.note)
|
|
91
|
+
if not ok:
|
|
92
|
+
print(f"ERROR: reopen on id={args.id} rejected "
|
|
93
|
+
f"(not terminal or missing row)", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
print(f"reopen: id={args.id} -> new")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
if args.cmd == "history":
|
|
99
|
+
hist = q.history(args.id)
|
|
100
|
+
if not hist:
|
|
101
|
+
print("(no history)")
|
|
102
|
+
for h in hist:
|
|
103
|
+
print(f"{h['transitioned_at']} {h['from_status']} -> "
|
|
104
|
+
f"{h['to_status']} [{h['actor']}] {h['note'] or ''}")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
if args.cmd == "backlog":
|
|
108
|
+
print(q.backlog_md())
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
print(f"ERROR: unknown command {args.cmd}", file=sys.stderr)
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(main())
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Calibration table — every tunable lives here.
|
|
2
|
+
|
|
3
|
+
DSPy-style retunes / per-consumer overrides go in here, not in worker code.
|
|
4
|
+
Pass overrides via ``swarph_triage.open(..., config={...})``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
12
|
+
# ─── priority decay ───
|
|
13
|
+
# Used in priority.compute(): score *= exp(-hours_since_last_seen / half_life).
|
|
14
|
+
# Tune to ingest cadence: 6h for hourly cron, 48–72h for daily refresh.
|
|
15
|
+
"decay_half_life_hours": 6.0,
|
|
16
|
+
|
|
17
|
+
# ─── severity → weight ───
|
|
18
|
+
# Map your severity labels to multipliers. Unknown labels fall back to 0.5.
|
|
19
|
+
"severity_weights": {
|
|
20
|
+
"critical": 1.0,
|
|
21
|
+
"high": 0.7,
|
|
22
|
+
"medium": 0.5,
|
|
23
|
+
"low": 0.3,
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
# ─── frequency curve ───
|
|
27
|
+
# "log" → log_base(1 + freq). "linear" → freq. "sqrt" → sqrt(freq).
|
|
28
|
+
# log is the production-tested default; whales don't drown fresh items.
|
|
29
|
+
"freq_curve": "log",
|
|
30
|
+
"freq_log_base": 10,
|
|
31
|
+
|
|
32
|
+
# ─── actionability ───
|
|
33
|
+
# Floor on the multiplier so nothing is truly zero (small items still
|
|
34
|
+
# accumulate via the freq term and bubble up over time).
|
|
35
|
+
"actionability_floor": 0.1,
|
|
36
|
+
|
|
37
|
+
# ─── regression detector ───
|
|
38
|
+
# After a `patched_at` timestamp, a new occurrence within this window
|
|
39
|
+
# resurrects the row to `new` + sets regression=1. Default: 24h.
|
|
40
|
+
"regression_grace_hours": 24,
|
|
41
|
+
|
|
42
|
+
# ─── cooldown semantics ───
|
|
43
|
+
# When a row is sent to `let_cool`, cooldown_until = now + N days.
|
|
44
|
+
# Priority ramps DURING the cooldown window — 0 at cooldown-start, linearly
|
|
45
|
+
# back to full at expiry (not "after"); see priority.compute's cooldown ramp.
|
|
46
|
+
"cooldown_default_days": 14,
|
|
47
|
+
|
|
48
|
+
# ─── normalization ───
|
|
49
|
+
"priority_min": 0.0,
|
|
50
|
+
"priority_max": 100.0,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config(overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
55
|
+
"""Merge ``overrides`` shallow-on-top-of-DEFAULT_CONFIG.
|
|
56
|
+
|
|
57
|
+
Nested dicts (e.g. ``severity_weights``) are replaced wholesale, not merged.
|
|
58
|
+
If you need partial-merge for a nested dict, pass the full merged dict.
|
|
59
|
+
"""
|
|
60
|
+
cfg: dict[str, Any] = {**DEFAULT_CONFIG}
|
|
61
|
+
if overrides:
|
|
62
|
+
cfg.update(overrides)
|
|
63
|
+
return cfg
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""FastAPI APIRouter factory — JSON only, no HTML.
|
|
2
|
+
|
|
3
|
+
Templates / HTMX surface stays consumer-side; this module exposes the data
|
|
4
|
+
endpoints the consumer's UI binds against.
|
|
5
|
+
|
|
6
|
+
Optional install: ``pip install swarph-triage[fastapi]``
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_router(queue) -> Any:
|
|
15
|
+
"""Return a FastAPI ``APIRouter`` mountable into the consumer's app.
|
|
16
|
+
|
|
17
|
+
Routes:
|
|
18
|
+
|
|
19
|
+
GET /list?status=...&limit=...&offset=...
|
|
20
|
+
GET /stats
|
|
21
|
+
GET /show/{fp_id}
|
|
22
|
+
POST /transition/{fp_id} body: {to_status, actor, note?}
|
|
23
|
+
"""
|
|
24
|
+
from fastapi import APIRouter, Body
|
|
25
|
+
|
|
26
|
+
router = APIRouter()
|
|
27
|
+
|
|
28
|
+
@router.get("/list")
|
|
29
|
+
def list_rows(status: Optional[str] = None, limit: int = 50, offset: int = 0):
|
|
30
|
+
return queue.list(status=status, limit=limit, offset=offset)
|
|
31
|
+
|
|
32
|
+
@router.get("/stats")
|
|
33
|
+
def stats():
|
|
34
|
+
return queue.stats()
|
|
35
|
+
|
|
36
|
+
@router.get("/show/{fp_id}")
|
|
37
|
+
def show(fp_id: int):
|
|
38
|
+
return queue.show(fp_id)
|
|
39
|
+
|
|
40
|
+
@router.post("/transition/{fp_id}")
|
|
41
|
+
def transition(fp_id: int, body: dict = Body(...)):
|
|
42
|
+
from fastapi import HTTPException
|
|
43
|
+
# Auth is the consumer's responsibility (mount this router behind your
|
|
44
|
+
# own auth middleware). Validate the required field → 422, not a 500.
|
|
45
|
+
to_status = body.get("to_status")
|
|
46
|
+
if not to_status:
|
|
47
|
+
raise HTTPException(status_code=422, detail="to_status is required")
|
|
48
|
+
ok = queue.transition(
|
|
49
|
+
fp_id,
|
|
50
|
+
to_status=to_status,
|
|
51
|
+
actor=body.get("actor", "api"),
|
|
52
|
+
note=body.get("note", ""),
|
|
53
|
+
)
|
|
54
|
+
return {"ok": ok}
|
|
55
|
+
|
|
56
|
+
return router
|