bitcraft 0.1.0__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 (57) hide show
  1. bitcraft/__init__.py +3 -0
  2. bitcraft/api_client.py +146 -0
  3. bitcraft/app.py +217 -0
  4. bitcraft/assets/mascots/angry.png +0 -0
  5. bitcraft/assets/mascots/confused.png +0 -0
  6. bitcraft/assets/mascots/happy.png +0 -0
  7. bitcraft/assets/mascots/neutral.png +0 -0
  8. bitcraft/assets/mascots/panic.png +0 -0
  9. bitcraft/assets/mascots/shocked.png +0 -0
  10. bitcraft/assets/mascots/sleepy.png +0 -0
  11. bitcraft/assets/mascots/thinking.png +0 -0
  12. bitcraft/cli.py +132 -0
  13. bitcraft/helpers/__init__.py +1 -0
  14. bitcraft/helpers/drivers.py +37 -0
  15. bitcraft/helpers/format.py +43 -0
  16. bitcraft/helpers/severity.py +32 -0
  17. bitcraft/helpers/stub_chart.py +133 -0
  18. bitcraft/launcher.py +198 -0
  19. bitcraft/providers/__init__.py +1 -0
  20. bitcraft/providers/api_provider.py +400 -0
  21. bitcraft/providers/demo_provider.py +597 -0
  22. bitcraft/providers/factory.py +92 -0
  23. bitcraft/providers/models.py +298 -0
  24. bitcraft/providers/provider.py +82 -0
  25. bitcraft/screens/__init__.py +1 -0
  26. bitcraft/screens/alert_detail.py +188 -0
  27. bitcraft/screens/boot.py +254 -0
  28. bitcraft/screens/dashboard.py +304 -0
  29. bitcraft/screens/graph_explorer.py +169 -0
  30. bitcraft/screens/splash.py +47 -0
  31. bitcraft/screens/threat_detection.py +415 -0
  32. bitcraft/screens/wallet.py +182 -0
  33. bitcraft/store.py +100 -0
  34. bitcraft/theme.tcss +587 -0
  35. bitcraft/widgets/__init__.py +1 -0
  36. bitcraft/widgets/alert_detail_panel.py +18 -0
  37. bitcraft/widgets/alert_list.py +61 -0
  38. bitcraft/widgets/chart.py +298 -0
  39. bitcraft/widgets/chart_panel.py +349 -0
  40. bitcraft/widgets/community_table.py +34 -0
  41. bitcraft/widgets/filter_panel.py +71 -0
  42. bitcraft/widgets/graph_view.py +308 -0
  43. bitcraft/widgets/header_bar.py +86 -0
  44. bitcraft/widgets/kpi_summary.py +88 -0
  45. bitcraft/widgets/logo.py +115 -0
  46. bitcraft/widgets/mascot.py +264 -0
  47. bitcraft/widgets/panel_state.py +85 -0
  48. bitcraft/widgets/score_bar.py +27 -0
  49. bitcraft/widgets/severity_chip.py +38 -0
  50. bitcraft/widgets/stacked_bar.py +66 -0
  51. bitcraft-0.1.0.dist-info/METADATA +79 -0
  52. bitcraft-0.1.0.dist-info/RECORD +57 -0
  53. bitcraft-0.1.0.dist-info/WHEEL +5 -0
  54. bitcraft-0.1.0.dist-info/entry_points.txt +2 -0
  55. bitcraft-0.1.0.dist-info/licenses/LICENSE +202 -0
  56. bitcraft-0.1.0.dist-info/licenses/NOTICE +28 -0
  57. bitcraft-0.1.0.dist-info/top_level.txt +1 -0
bitcraft/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """BitCraft terminal interface package. Built with Textual."""
2
+
3
+ __version__ = "0.1.0"
bitcraft/api_client.py ADDED
@@ -0,0 +1,146 @@
1
+ """HTTP client for the BitCraft FastAPI backend.
2
+
3
+ Thin transport only: JSON in and out. Parsing into dataclasses lives in
4
+ ApiProvider. All ML results are pre-computed; this client never triggers
5
+ scoring (plans/plan.md section 10.3).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Optional
11
+
12
+ import httpx
13
+
14
+ DEFAULT_BASE_URL = "http://localhost:8000"
15
+ DEFAULT_TIMEOUT_S = 1.5
16
+
17
+
18
+ class ApiClient:
19
+ """httpx wrapper around the plan section 10.1 endpoints."""
20
+
21
+ def __init__(
22
+ self,
23
+ base_url: str = DEFAULT_BASE_URL,
24
+ timeout_s: float = DEFAULT_TIMEOUT_S,
25
+ transport: Optional[httpx.BaseTransport] = None,
26
+ ) -> None:
27
+ self.base_url = base_url.rstrip("/")
28
+ self.timeout_s = timeout_s
29
+ self._client = httpx.Client(
30
+ base_url=self.base_url,
31
+ timeout=timeout_s,
32
+ transport=transport,
33
+ )
34
+
35
+ def close(self) -> None:
36
+ """Close the underlying httpx client."""
37
+ self._client.close()
38
+
39
+ def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
40
+ """GET JSON from path. Raises httpx.HTTPError on transport failure."""
41
+ response = self._client.get(path, params=params)
42
+ response.raise_for_status()
43
+ return response.json()
44
+
45
+ def health(self) -> dict:
46
+ """GET /health."""
47
+ return self.get("/health")
48
+
49
+ def get_alerts(self, params: Optional[dict[str, Any]] = None) -> Any:
50
+ """GET /alerts."""
51
+ return self.get("/alerts", params=params)
52
+
53
+ def get_alert_detail(self, tx_id: int) -> dict:
54
+ """GET /alerts/{tx_id}."""
55
+ return self.get(f"/alerts/{tx_id}")
56
+
57
+ def get_graph(self, tx_id: int, depth: int = 1) -> dict:
58
+ """GET /graph/{tx_id}?depth=."""
59
+ return self.get(f"/graph/{tx_id}", params={"depth": depth})
60
+
61
+ def get_community(self, community_id: int) -> dict:
62
+ """GET /communities/{community_id}."""
63
+ return self.get(f"/communities/{community_id}")
64
+
65
+ def get_communities(self, limit: int = 25, sort: str = "risk") -> list:
66
+ """GET /communities (top communities overview)."""
67
+ return self.get("/communities", params={"limit": limit, "sort": sort})
68
+
69
+ def get_threat_overview(self) -> dict:
70
+ """GET /threats/overview."""
71
+ return self.get("/threats/overview")
72
+
73
+ def get_entities(self, offset: int = 0, limit: int = 100) -> dict:
74
+ """GET /entities (ranked wallets)."""
75
+ return self.get("/entities", params={"offset": offset, "limit": limit})
76
+
77
+ def get_entity(self, entity_id: int) -> dict:
78
+ """GET /entities/{id}."""
79
+ return self.get(f"/entities/{entity_id}")
80
+
81
+ def get_entity_graph(self, entity_id: int) -> dict:
82
+ """GET /entities/{id}/graph."""
83
+ return self.get(f"/entities/{entity_id}/graph")
84
+
85
+ def get_stats_summary(self) -> dict:
86
+ """GET /stats/summary."""
87
+ return self.get("/stats/summary")
88
+
89
+ def get_pipeline_status(self) -> dict:
90
+ """GET /pipeline/status."""
91
+ return self.get("/pipeline/status")
92
+
93
+
94
+ # Module-level helpers kept for earlier call sites / tests.
95
+ def get_alerts(params: dict | None = None) -> list[dict]:
96
+ """GET /alerts via a one-shot client (prefer ApiClient in new code)."""
97
+ client = ApiClient()
98
+ try:
99
+ return client.get_alerts(params)
100
+ finally:
101
+ client.close()
102
+
103
+
104
+ def get_alert_detail(tx_id: int) -> dict:
105
+ """GET /alerts/{tx_id} via a one-shot client."""
106
+ client = ApiClient()
107
+ try:
108
+ return client.get_alert_detail(tx_id)
109
+ finally:
110
+ client.close()
111
+
112
+
113
+ def get_graph(tx_id: int, depth: int = 1) -> dict:
114
+ """GET /graph/{tx_id} via a one-shot client."""
115
+ client = ApiClient()
116
+ try:
117
+ return client.get_graph(tx_id, depth)
118
+ finally:
119
+ client.close()
120
+
121
+
122
+ def get_community(community_id: int) -> dict:
123
+ """GET /communities/{community_id} via a one-shot client."""
124
+ client = ApiClient()
125
+ try:
126
+ return client.get_community(community_id)
127
+ finally:
128
+ client.close()
129
+
130
+
131
+ def get_stats_summary() -> dict:
132
+ """GET /stats/summary via a one-shot client."""
133
+ client = ApiClient()
134
+ try:
135
+ return client.get_stats_summary()
136
+ finally:
137
+ client.close()
138
+
139
+
140
+ def get_pipeline_status() -> dict:
141
+ """GET /pipeline/status via a one-shot client."""
142
+ client = ApiClient()
143
+ try:
144
+ return client.get_pipeline_status()
145
+ finally:
146
+ client.close()
bitcraft/app.py ADDED
@@ -0,0 +1,217 @@
1
+ """BitCraft TUI entry point.
2
+
3
+ Terminal interface built with Textual. Reads pre-computed alert, graph,
4
+ and community data through a swappable DataProvider (demo or FastAPI).
5
+ No ML code runs here. See docs/tui_design.md and plans/plan.md section 11.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ from typing import Optional
13
+
14
+ from textual.app import App, ScreenStackError
15
+ from textual.binding import Binding
16
+ from textual.theme import Theme
17
+
18
+ from bitcraft.api_client import DEFAULT_BASE_URL
19
+ from bitcraft.providers.demo_provider import DemoProvider
20
+ from bitcraft.providers.factory import create_provider, resolve_source
21
+ from bitcraft.screens.boot import BootScreen
22
+ from bitcraft.screens.dashboard import DashboardScreen
23
+ from bitcraft.screens.graph_explorer import GraphExplorerScreen
24
+ from bitcraft.screens.splash import SplashScreen
25
+ from bitcraft.screens.threat_detection import ThreatDetectionScreen
26
+ from bitcraft.screens.wallet import WalletsScreen
27
+ from bitcraft.store import Store
28
+
29
+
30
+ # Gold everywhere Textual would otherwise paint its default blue: table
31
+ # cursors, scrollbars, focus borders, footer keys, checkboxes, selections.
32
+ BITCRAFT_THEME = Theme(
33
+ name="bitcraft",
34
+ primary="#d4af37",
35
+ secondary="#ffa700",
36
+ accent="#ffd84d",
37
+ warning="#ff8c42",
38
+ error="#b3261e",
39
+ success="#d4af37",
40
+ foreground="#e6e6e6",
41
+ background="#000000",
42
+ surface="#050505",
43
+ panel="#0d0b08",
44
+ dark=True,
45
+ variables={
46
+ "block-cursor-background": "#d4af37",
47
+ "block-cursor-foreground": "#000000",
48
+ "block-cursor-text-style": "bold",
49
+ "block-cursor-blurred-background": "#d4af37 45%",
50
+ "block-cursor-blurred-foreground": "#000000",
51
+ "block-hover-background": "#d4af37 12%",
52
+ "scrollbar": "#d4af37 55%",
53
+ "scrollbar-hover": "#e8c55a",
54
+ "scrollbar-active": "#ffd84d",
55
+ "scrollbar-background": "#0d0b08",
56
+ "scrollbar-background-hover": "#0d0b08",
57
+ "scrollbar-background-active": "#0d0b08",
58
+ "scrollbar-corner-color": "#0d0b08",
59
+ "footer-key-foreground": "#d4af37",
60
+ "footer-foreground": "#7a7a7a",
61
+ "footer-background": "#000000",
62
+ "footer-description-foreground": "#9a9080",
63
+ "input-cursor-background": "#d4af37",
64
+ "input-cursor-foreground": "#000000",
65
+ "input-selection-background": "#d4af37 35%",
66
+ "screen-selection-background": "#d4af37 35%",
67
+ "border": "#d4af37",
68
+ "border-blurred": "#3a3020",
69
+ "link-color": "#ffd84d",
70
+ },
71
+ )
72
+
73
+
74
+ class BitCraftApp(App):
75
+ """Root Textual application using modes for the main investigation screens.
76
+
77
+ Flow: splash -> boot -> dashboard. Modes: dashboard, threats, graph.
78
+ Detail screens push on top of the active mode.
79
+ """
80
+
81
+ CSS_PATH = "theme.tcss"
82
+ TITLE = "BitCraft"
83
+ MODES = {
84
+ "dashboard": DashboardScreen,
85
+ "graph": GraphExplorerScreen,
86
+ "threats": ThreatDetectionScreen,
87
+ "wallets": WalletsScreen,
88
+ }
89
+ DEFAULT_MODE = "dashboard"
90
+ BINDINGS = [
91
+ Binding("q", "quit", "Quit", priority=True),
92
+ Binding("d", "show_dashboard", "Dashboard", priority=True),
93
+ Binding("g", "show_graph", "Graph", priority=True),
94
+ Binding("t", "show_threats", "Threats", priority=True),
95
+ Binding("w", "show_wallets", "Wallets", priority=True),
96
+ Binding("escape", "go_home", "Home", priority=True),
97
+ ]
98
+
99
+ def __init__(self, store: Optional[Store] = None) -> None:
100
+ super().__init__()
101
+ if store is None:
102
+ store = Store(
103
+ provider=DemoProvider(simulate_latency=False),
104
+ is_demo=True,
105
+ boot_log=["demo default"],
106
+ fast_boot=True,
107
+ )
108
+ self.store = store
109
+ self.register_theme(BITCRAFT_THEME)
110
+ self.theme = "bitcraft"
111
+
112
+ def clear_selection(self) -> None:
113
+ """Ignore the startup window where the mode stack is still empty.
114
+
115
+ Textual mounts the DEFAULT_MODE screen before registering its stack,
116
+ and with eager tasks the dashboard's filter Inputs run their selection
117
+ watcher (which calls this) inside that window. Textual only catches
118
+ NoScreen here, so a real `app.run()` crashed with ScreenStackError.
119
+ """
120
+ try:
121
+ super().clear_selection()
122
+ except ScreenStackError:
123
+ pass
124
+
125
+ def on_mount(self) -> None:
126
+ """Open on the brand splash above the default dashboard mode."""
127
+ self.push_screen(SplashScreen())
128
+
129
+ def _pop_overlays(self) -> None:
130
+ """Pop pushed overlays until a mode screen is on top."""
131
+ mode_types = (DashboardScreen, GraphExplorerScreen, ThreatDetectionScreen, WalletsScreen)
132
+ while len(self.screen_stack) > 1 and not isinstance(self.screen, mode_types):
133
+ self.pop_screen()
134
+
135
+ def action_show_dashboard(self) -> None:
136
+ """Switch to dashboard mode (ignored on splash)."""
137
+ if isinstance(self.screen, (SplashScreen, BootScreen)):
138
+ return
139
+ self.switch_mode("dashboard")
140
+ self._pop_overlays()
141
+
142
+ def action_show_graph(self) -> None:
143
+ """Switch to graph mode."""
144
+ if isinstance(self.screen, (SplashScreen, BootScreen)):
145
+ return
146
+ self.switch_mode("graph")
147
+ self._pop_overlays()
148
+
149
+ def action_show_threats(self) -> None:
150
+ """Switch to threats mode."""
151
+ if isinstance(self.screen, (SplashScreen, BootScreen)):
152
+ return
153
+ self.switch_mode("threats")
154
+ self._pop_overlays()
155
+
156
+ def action_show_wallets(self) -> None:
157
+ """Switch to wallets mode."""
158
+ if isinstance(self.screen, (SplashScreen, BootScreen)):
159
+ return
160
+ self.switch_mode("wallets")
161
+ self._pop_overlays()
162
+
163
+ def action_go_home(self) -> None:
164
+ """Return to the ASCII-logo splash above the current mode."""
165
+ if isinstance(self.screen, SplashScreen):
166
+ return
167
+ if isinstance(self.screen, BootScreen):
168
+ return
169
+ while len(self.screen_stack) > 1:
170
+ self.pop_screen()
171
+ self.push_screen(SplashScreen())
172
+
173
+ def enter_from_splash(self) -> None:
174
+ """Dismiss splash and open the boot loader."""
175
+ if isinstance(self.screen, SplashScreen):
176
+ self.pop_screen()
177
+ self.push_screen(BootScreen())
178
+
179
+
180
+ def build_arg_parser() -> argparse.ArgumentParser:
181
+ """CLI flags for data source selection."""
182
+ parser = argparse.ArgumentParser(description="BitCraft terminal interface")
183
+ parser.add_argument(
184
+ "--demo",
185
+ action="store_true",
186
+ help="Force the deterministic demo provider",
187
+ )
188
+ parser.add_argument(
189
+ "--api",
190
+ action="store_true",
191
+ help="Force the FastAPI provider",
192
+ )
193
+ parser.add_argument(
194
+ "--api-url",
195
+ default=os.environ.get("BITCRAFT_API_URL", DEFAULT_BASE_URL),
196
+ help=f"Backend base URL (default {DEFAULT_BASE_URL})",
197
+ )
198
+ return parser
199
+
200
+
201
+ def main(argv: Optional[list[str]] = None) -> None:
202
+ """Run the BitCraft terminal interface."""
203
+ args = build_arg_parser().parse_args(argv)
204
+ source = resolve_source(cli_demo=args.demo, cli_api=args.api)
205
+ choice = create_provider(source=source, api_url=args.api_url)
206
+ store = Store(
207
+ provider=choice.provider,
208
+ boot_log=list(choice.boot_log),
209
+ is_demo=(choice.provider.source_label == "DEMO DATA"),
210
+ fast_boot=False,
211
+ )
212
+ app = BitCraftApp(store=store)
213
+ app.run()
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
bitcraft/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ """The `bitcraft` command.
2
+
3
+ bitcraft open BitCraft in a new, large window
4
+ bitcraft run [options] same, with options
5
+ bitcraft demo new window with built-in demo data
6
+ bitcraft here [options] run inside the current terminal
7
+ bitcraft status check the API and the last pipeline run
8
+ bitcraft version print the version
9
+
10
+ Options for run / here:
11
+ --demo | --api force a data source (default: auto)
12
+ --api-url URL backend to use (default http://localhost:8000)
13
+ --size COLSxROWS window size for run / demo (default 190x52)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import os
20
+ import sys
21
+
22
+ from bitcraft import __version__
23
+ from bitcraft.api_client import DEFAULT_BASE_URL
24
+ from bitcraft.launcher import DEFAULT_SIZE, Options, api_healthy, open_window, run_session
25
+
26
+ COMMANDS = ("run", "demo", "here", "status", "version")
27
+
28
+
29
+ def _size(text: str) -> tuple[int, int]:
30
+ try:
31
+ cols, rows = (int(v) for v in text.lower().split("x"))
32
+ except ValueError as exc:
33
+ raise argparse.ArgumentTypeError("size must look like 190x52") from exc
34
+ if cols < 100 or rows < 30:
35
+ raise argparse.ArgumentTypeError("BitCraft needs at least 100x30")
36
+ return cols, rows
37
+
38
+
39
+ def _source_args(p: argparse.ArgumentParser) -> None:
40
+ group = p.add_mutually_exclusive_group()
41
+ group.add_argument("--demo", action="store_true", help="built-in demo data, no API")
42
+ group.add_argument("--api", action="store_true", help="require the API (no demo fallback)")
43
+ p.add_argument(
44
+ "--api-url",
45
+ default=os.environ.get("BITCRAFT_API_URL", DEFAULT_BASE_URL),
46
+ help=f"backend URL (default {DEFAULT_BASE_URL})",
47
+ )
48
+
49
+
50
+ def build_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(
52
+ prog="bitcraft",
53
+ description="BitCraft: Bitcoin transaction intelligence in your terminal.",
54
+ epilog="Run `bitcraft` with no arguments to open it in a new window.",
55
+ )
56
+ parser.add_argument("-V", "--version", action="version", version=f"bitcraft {__version__}")
57
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
58
+
59
+ run = sub.add_parser("run", help="open in a new, large terminal window (default)")
60
+ _source_args(run)
61
+ run.add_argument("--size", type=_size, default=DEFAULT_SIZE, help="window size, e.g. 190x52")
62
+
63
+ demo = sub.add_parser("demo", help="open in a new window with demo data")
64
+ demo.add_argument("--size", type=_size, default=DEFAULT_SIZE, help="window size, e.g. 190x52")
65
+
66
+ here = sub.add_parser("here", help="run inside the current terminal")
67
+ _source_args(here)
68
+
69
+ status = sub.add_parser("status", help="check the API and the last pipeline run")
70
+ status.add_argument("--api-url", default=os.environ.get("BITCRAFT_API_URL", DEFAULT_BASE_URL))
71
+
72
+ sub.add_parser("version", help="print the version")
73
+ return parser
74
+
75
+
76
+ def _options(args: argparse.Namespace) -> Options:
77
+ source = "demo" if getattr(args, "demo", False) else ("api" if getattr(args, "api", False) else "auto")
78
+ return Options(
79
+ source=source,
80
+ api_url=getattr(args, "api_url", DEFAULT_BASE_URL),
81
+ size=getattr(args, "size", DEFAULT_SIZE),
82
+ )
83
+
84
+
85
+ def _launch(opts: Options) -> int:
86
+ try:
87
+ where = open_window(opts)
88
+ except Exception as exc: # noqa: BLE001 - no GUI terminal: run right here
89
+ print(f"Could not open a new window ({exc}); starting here.")
90
+ return run_session(opts)
91
+ cols, rows = opts.size
92
+ print(f"BitCraft is opening in a new {where} window ({cols}x{rows}).")
93
+ return 0
94
+
95
+
96
+ def _status(api_url: str) -> int:
97
+ print(f"bitcraft {__version__}")
98
+ if not api_healthy(api_url, timeout_s=1.5):
99
+ print(f"API {api_url} not reachable (BitCraft will use demo data)")
100
+ return 1
101
+ import httpx
102
+
103
+ stats = httpx.get(f"{api_url}/stats/summary", timeout=3).json()
104
+ pipeline = httpx.get(f"{api_url}/pipeline/status", timeout=3).json()
105
+ print(f"API {api_url} ok")
106
+ print(f"data {stats['total_transactions']:,} transactions, {stats['total_alerts']:,} alerts")
107
+ finished = pipeline.get("finished_at") or "-"
108
+ print(f"pipeline {pipeline.get('status', 'unknown')} (finished {finished})")
109
+ return 0
110
+
111
+
112
+ def main(argv: list[str] | None = None) -> int:
113
+ argv = list(sys.argv[1:] if argv is None else argv)
114
+ # Bare `bitcraft` (or `bitcraft --demo ...`) means `bitcraft run ...`.
115
+ if not argv or (argv[0] not in COMMANDS and argv[0] not in ("-h", "--help", "-V", "--version")):
116
+ argv.insert(0, "run")
117
+ args = build_parser().parse_args(argv)
118
+
119
+ if args.command == "version":
120
+ print(f"bitcraft {__version__}")
121
+ return 0
122
+ if args.command == "status":
123
+ return _status(args.api_url)
124
+ if args.command == "here":
125
+ return run_session(_options(args))
126
+ if args.command == "demo":
127
+ return _launch(Options(source="demo", size=args.size))
128
+ return _launch(_options(args))
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Pure, unit-testable TUI helpers."""
@@ -0,0 +1,37 @@
1
+ """Primary driver tags from weighted score contributions.
2
+
3
+ Weights mirrored from ml/config.yaml (plans/plan.md section 7).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Literal
9
+
10
+ from bitcraft.providers.demo_provider import (
11
+ ANOMALY_WEIGHT,
12
+ COMMUNITY_WEIGHT,
13
+ MODEL_WEIGHT,
14
+ NETWORK_WEIGHT,
15
+ )
16
+
17
+ DriverTag = Literal["MODEL", "ANOMALY", "COMMUNITY", "NETWORK"]
18
+
19
+
20
+ def weighted_parts(
21
+ anomaly: float, community: float, network: float, model: float | None = 0.0
22
+ ) -> dict[DriverTag, float]:
23
+ """Return weighted contribution of each composite component."""
24
+ return {
25
+ "MODEL": MODEL_WEIGHT * (model or 0.0),
26
+ "ANOMALY": ANOMALY_WEIGHT * anomaly,
27
+ "COMMUNITY": COMMUNITY_WEIGHT * community,
28
+ "NETWORK": NETWORK_WEIGHT * network,
29
+ }
30
+
31
+
32
+ def primary_driver(
33
+ anomaly: float, community: float, network: float, model: float | None = 0.0
34
+ ) -> DriverTag:
35
+ """Component with the largest weighted contribution."""
36
+ parts = weighted_parts(anomaly, community, network, model)
37
+ return max(parts, key=parts.get) # type: ignore[arg-type]
@@ -0,0 +1,43 @@
1
+ """Number, score bar, and glyph formatting helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def ascii_mode() -> bool:
9
+ """True when BITCRAFT_ASCII=1 forces plain ASCII glyphs."""
10
+ return os.environ.get("BITCRAFT_ASCII", "").strip() == "1"
11
+
12
+
13
+ def format_int(n: int) -> str:
14
+ """Format an integer with thousands separators."""
15
+ return f"{n:,}"
16
+
17
+
18
+ def format_pct(value: float, digits: int = 1) -> str:
19
+ """Format a percentage value."""
20
+ return f"{value:.{digits}f}%"
21
+
22
+
23
+ def score_bar(score: float, width: int = 10) -> str:
24
+ """Render a composite score as a filled bar like #######---."""
25
+ score = max(0.0, min(1.0, score))
26
+ filled = int(round(score * width))
27
+ fill = "#" if ascii_mode() else "#"
28
+ empty = "-"
29
+ return fill * filled + empty * (width - filled)
30
+
31
+
32
+ def network_cell(has_network: bool, signal: float) -> str:
33
+ """Render network signal: n/a when missing, else ~0.42 modeled."""
34
+ if not has_network:
35
+ return "n/a"
36
+ return f"~{signal:.2f}"
37
+
38
+
39
+ def modeled_mark(text: str) -> str:
40
+ """Prefix modeled values with the ~ glyph."""
41
+ if text.startswith("~"):
42
+ return text
43
+ return f"~{text}"
@@ -0,0 +1,32 @@
1
+ """Display-only severity tiers derived from composite_score.
2
+
3
+ These thresholds are not model outputs. They should eventually be tuned
4
+ against precision@k from ml/config.yaml (see plans/future.md).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from bitcraft.providers.models import SeverityTier
10
+
11
+ # Display thresholds only. Not used by the ML pipeline.
12
+ SEVERITY_THRESHOLDS: tuple[tuple[SeverityTier, float], ...] = (
13
+ ("critical", 0.80),
14
+ ("high", 0.60),
15
+ ("medium", 0.40),
16
+ ("low", 0.0),
17
+ )
18
+
19
+ SEVERITY_STYLE: dict[SeverityTier, str] = {
20
+ "critical": "alert-high",
21
+ "high": "alert-high",
22
+ "medium": "alert-medium",
23
+ "low": "alert-low",
24
+ }
25
+
26
+
27
+ def severity_for_score(composite_score: float) -> SeverityTier:
28
+ """Map a composite score to a display severity tier."""
29
+ for tier, floor in SEVERITY_THRESHOLDS:
30
+ if composite_score >= floor:
31
+ return tier
32
+ return "low"