flintai-cli 1.0.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 (147) hide show
  1. flintai/__init__.py +0 -0
  2. flintai/cli/__init__.py +0 -0
  3. flintai/cli/__main__.py +3 -0
  4. flintai/cli/builtin_config.json +911 -0
  5. flintai/cli/console.py +116 -0
  6. flintai/cli/eval_cli.py +891 -0
  7. flintai/cli/init_cli.py +144 -0
  8. flintai/cli/main.py +262 -0
  9. flintai/cli/rich_observer.py +89 -0
  10. flintai/cli/runner.py +305 -0
  11. flintai/cli/scan_cli.py +238 -0
  12. flintai/cli/utils.py +37 -0
  13. flintai/cli/version.py +1 -0
  14. flintai/data/detector_prompts/llm01_adversarial.txt +55 -0
  15. flintai/data/detector_prompts/llm01_fixed.txt +8 -0
  16. flintai/data/detector_prompts/llm02_adversarial.txt +75 -0
  17. flintai/data/detector_prompts/llm02_fixed.txt +8 -0
  18. flintai/data/detector_prompts/llm05_adversarial.txt +63 -0
  19. flintai/data/detector_prompts/llm05_fixed.txt +16 -0
  20. flintai/data/detector_prompts/llm06_adversarial.txt +135 -0
  21. flintai/data/detector_prompts/llm06_fixed.txt +15 -0
  22. flintai/data/detector_prompts/llm07_adversarial.txt +185 -0
  23. flintai/data/detector_prompts/llm07_fixed.txt +14 -0
  24. flintai/data/detector_prompts/llm09_adversarial.txt +77 -0
  25. flintai/data/detector_prompts/llm09_fixed.txt +15 -0
  26. flintai/data/llm01_prompt_injection.csv +6708 -0
  27. flintai/data/llm02_sensitive_information.csv +1069 -0
  28. flintai/data/llm05_unsafe_output.csv +3882 -0
  29. flintai/data/llm06_excessive_agency.csv +778 -0
  30. flintai/data/llm07_system_prompt_leakage.csv +971 -0
  31. flintai/data/llm09_hallucination-goals.csv +32419 -0
  32. flintai/data/llm09_hallucination.csv +599 -0
  33. flintai/data/pii_leakage.csv +706 -0
  34. flintai/data/secret_leakage.csv +1380 -0
  35. flintai/eval/__init__.py +0 -0
  36. flintai/eval/common/converter_anthropic.py +151 -0
  37. flintai/eval/common/converter_genai.py +169 -0
  38. flintai/eval/common/converter_openai.py +165 -0
  39. flintai/eval/common/log.py +96 -0
  40. flintai/eval/common/reference.py +25 -0
  41. flintai/eval/common/schema.py +192 -0
  42. flintai/eval/common/utils.py +74 -0
  43. flintai/eval/core/__init__.py +0 -0
  44. flintai/eval/core/detectors/__init__.py +1 -0
  45. flintai/eval/core/detectors/detector.py +25 -0
  46. flintai/eval/core/detectors/detector_garak.py +80 -0
  47. flintai/eval/core/detectors/detector_model.py +76 -0
  48. flintai/eval/core/detectors/detector_pii.py +64 -0
  49. flintai/eval/core/detectors/detector_secret.py +82 -0
  50. flintai/eval/core/detectors/detector_topic_guard.py +70 -0
  51. flintai/eval/core/detectors/detector_toxicity.py +70 -0
  52. flintai/eval/core/eval/__init__.py +0 -0
  53. flintai/eval/core/eval/eval_creator.py +197 -0
  54. flintai/eval/core/eval/evaluation.py +100 -0
  55. flintai/eval/core/eval/evaluation_adversarial.py +523 -0
  56. flintai/eval/core/eval/evaluation_garak_module.py +52 -0
  57. flintai/eval/core/eval/evaluation_garak_probe.py +274 -0
  58. flintai/eval/core/eval/evaluation_message_list.py +44 -0
  59. flintai/eval/core/eval/evaluation_multi.py +207 -0
  60. flintai/eval/core/eval/evaluation_single.py +79 -0
  61. flintai/eval/core/eval/evaluation_single_prompt.py +58 -0
  62. flintai/eval/core/eval/evaluation_topic_guard.py +318 -0
  63. flintai/eval/core/eval/metric_conciseness.py +181 -0
  64. flintai/eval/core/eval/metric_factual_accuracy.py +294 -0
  65. flintai/eval/core/eval/metric_instruction_adherence.py +180 -0
  66. flintai/eval/core/eval/metric_tone.py +183 -0
  67. flintai/eval/core/eval/metric_toxicity.py +147 -0
  68. flintai/eval/core/eval/observer.py +136 -0
  69. flintai/eval/core/eval/probe_adversarial.py +14 -0
  70. flintai/eval/core/message/__init__.py +0 -0
  71. flintai/eval/core/message/message_collection.py +37 -0
  72. flintai/eval/core/message/message_collection_csv.py +46 -0
  73. flintai/eval/core/message/message_collection_garak.py +52 -0
  74. flintai/eval/core/message/message_collection_memory.py +36 -0
  75. flintai/eval/core/models/__init__.py +0 -0
  76. flintai/eval/core/models/generator_model.py +88 -0
  77. flintai/eval/core/models/model.py +111 -0
  78. flintai/eval/core/models/model_adk.py +158 -0
  79. flintai/eval/core/models/model_anthropic.py +51 -0
  80. flintai/eval/core/models/model_anthropic_agent.py +92 -0
  81. flintai/eval/core/models/model_gemini.py +107 -0
  82. flintai/eval/core/models/model_generic_http.py +117 -0
  83. flintai/eval/core/models/model_huggingface.py +61 -0
  84. flintai/eval/core/models/model_langserve.py +94 -0
  85. flintai/eval/core/models/model_litellm.py +38 -0
  86. flintai/eval/core/models/model_ollama.py +50 -0
  87. flintai/eval/core/models/model_openai.py +35 -0
  88. flintai/eval/core/models/model_openai_agent.py +80 -0
  89. flintai/eval/core/models/model_openai_compatible.py +57 -0
  90. flintai/eval/core/models/model_retry.py +134 -0
  91. flintai/eval/core/models/model_sync_wrapper.py +35 -0
  92. flintai/eval/db/__init__.py +0 -0
  93. flintai/eval/db/base/__init__.py +0 -0
  94. flintai/eval/db/base/detectors/__init__.py +1 -0
  95. flintai/eval/db/base/detectors/detector_helpers.py +78 -0
  96. flintai/eval/db/base/detectors/detector_repository.py +91 -0
  97. flintai/eval/db/base/detectors/detector_types.py +77 -0
  98. flintai/eval/db/base/eval/__init__.py +1 -0
  99. flintai/eval/db/base/eval/eval_helpers.py +227 -0
  100. flintai/eval/db/base/eval/eval_repository.py +115 -0
  101. flintai/eval/db/base/eval/eval_run.py +139 -0
  102. flintai/eval/db/base/eval/eval_types.py +129 -0
  103. flintai/eval/db/base/eval/model_eval_repository.py +78 -0
  104. flintai/eval/db/base/eval/model_eval_run_repository.py +70 -0
  105. flintai/eval/db/base/eval/model_eval_run_result_repository.py +40 -0
  106. flintai/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  107. flintai/eval/db/base/eval/model_eval_run_types.py +84 -0
  108. flintai/eval/db/base/eval/model_eval_types.py +71 -0
  109. flintai/eval/db/base/message/__init__.py +0 -0
  110. flintai/eval/db/base/message/message_collection_helpers.py +61 -0
  111. flintai/eval/db/base/message/message_collection_repository.py +92 -0
  112. flintai/eval/db/base/message/message_collection_types.py +89 -0
  113. flintai/eval/db/base/models/__init__.py +0 -0
  114. flintai/eval/db/base/models/model_helpers.py +172 -0
  115. flintai/eval/db/base/models/model_repository.py +95 -0
  116. flintai/eval/db/base/models/model_types.py +156 -0
  117. flintai/eval/db/json/__init__.py +0 -0
  118. flintai/eval/db/json/repository_json.py +603 -0
  119. flintai/scan/__init__.py +0 -0
  120. flintai/scan/agent_scanner.py +797 -0
  121. flintai/scan/config/agent_opengrep_rules.yaml +533 -0
  122. flintai/scan/config/agent_taxonomy.json +481 -0
  123. flintai/scan/config/agentic_cvss_mapping.yaml +136 -0
  124. flintai/scan/config/compliance_mappings.json +110 -0
  125. flintai/scan/config/triage_prompt.txt +335 -0
  126. flintai/scan/constants.py +15 -0
  127. flintai/scan/file_filter.py +156 -0
  128. flintai/scan/llm_provider.py +197 -0
  129. flintai/scan/opengrep_resolver.py +12 -0
  130. flintai/scan/reasoner.py +552 -0
  131. flintai/scan/schema.py +337 -0
  132. flintai/scan/scorer.py +371 -0
  133. flintai/scan/secret_anonymizer.py +72 -0
  134. flintai/scan/static_scanner.py +542 -0
  135. flintai/scan/taxonomy.py +75 -0
  136. flintai/scan/tool_dispatcher.py +754 -0
  137. flintai/scan/trace_logger.py +118 -0
  138. flintai/scan/trace_logger_file.py +143 -0
  139. flintai/scan/trace_logger_log.py +100 -0
  140. flintai/scan/triage.py +496 -0
  141. flintai/schema.py +58 -0
  142. flintai_cli-1.0.0.dist-info/METADATA +442 -0
  143. flintai_cli-1.0.0.dist-info/RECORD +147 -0
  144. flintai_cli-1.0.0.dist-info/WHEEL +5 -0
  145. flintai_cli-1.0.0.dist-info/entry_points.txt +2 -0
  146. flintai_cli-1.0.0.dist-info/licenses/LICENSE +210 -0
  147. flintai_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,144 @@
1
+ """Subcommand for `flintai init`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import stat
9
+
10
+ from rich.prompt import Prompt
11
+
12
+ from flintai.cli.console import console, select
13
+ from flintai.cli.utils import get_flintai_config_path, get_flintai_env_path, get_flintai_dir
14
+
15
+ _PROVIDER_DEFAULTS: dict[str, dict[str, str | None]] = {
16
+ "gemini": {
17
+ "model": "gemini-3.5-flash",
18
+ "api_key_var": "GEMINI_API_KEY",
19
+ },
20
+ "openai": {
21
+ "model": "gpt-5.4",
22
+ "api_key_var": "OPENAI_API_KEY",
23
+ },
24
+ "anthropic": {
25
+ "model": "claude-sonnet-4-6",
26
+ "api_key_var": "ANTHROPIC_API_KEY",
27
+ },
28
+ "litellm": {
29
+ "model": "gpt-5.4",
30
+ "api_key_var": None,
31
+ },
32
+ }
33
+
34
+ _LITELLM_BACKENDS: dict[str, str] = {
35
+ "openai": "OPENAI_API_KEY",
36
+ "gemini": "GEMINI_API_KEY",
37
+ "anthropic": "ANTHROPIC_API_KEY",
38
+ }
39
+
40
+
41
+ def register(
42
+ subparsers: argparse._SubParsersAction,
43
+ ) -> None:
44
+ subparsers.add_parser(
45
+ "init",
46
+ help="Initialize flintai configuration",
47
+ )
48
+
49
+
50
+ def run_init() -> None:
51
+ flintai_dir = get_flintai_dir()
52
+
53
+ console.print("[bold cyan]Flint AI Setup[/bold cyan]")
54
+ console.print()
55
+
56
+ if flintai_dir.exists():
57
+ proceed = select(
58
+ "Existing configuration will be overwritten."
59
+ " Do you want to proceed?",
60
+ options=["yes", "no"],
61
+ default_index=0,
62
+ )
63
+ if proceed.lower() == "no":
64
+ console.print("[dim]Aborted.[/dim]")
65
+ return
66
+ console.print()
67
+
68
+ provider = select(
69
+ "Flint AI CLI needs an LLM for certain functionality. Select your model provider:",
70
+ options=["gemini", "openai", "anthropic", "litellm"],
71
+ default_index=0,
72
+ )
73
+ console.print()
74
+
75
+ defaults = _PROVIDER_DEFAULTS[provider]
76
+
77
+ if provider == "litellm":
78
+ model_name = Prompt.ask(
79
+ "LiteLLM model string (e.g. openai/gpt-5.4, gemini/gemini-2.5-flash)",
80
+ default="openai/gpt-5.4",
81
+ )
82
+ console.print()
83
+
84
+ backend = model_name.split("/")[0] if "/" in model_name else None
85
+ api_key_var = _LITELLM_BACKENDS.get(backend, None) if backend else None
86
+ if not api_key_var:
87
+ api_key_var = Prompt.ask("API key env var name")
88
+ console.print()
89
+
90
+ generator_model = f"litellm:{model_name}"
91
+ else:
92
+ model_name = Prompt.ask(
93
+ "Model name",
94
+ default=defaults["model"],
95
+ )
96
+ console.print()
97
+ api_key_var = defaults["api_key_var"]
98
+ generator_model = f"{provider}:{model_name}"
99
+
100
+ assert api_key_var is not None
101
+ api_key = Prompt.ask(
102
+ f"Enter your {api_key_var}",
103
+ password=True,
104
+ )
105
+ console.print()
106
+ env_lines = [
107
+ f"GENERATOR_MODEL={generator_model}",
108
+ f"{api_key_var}={api_key}",
109
+ "EXECUTOR_MAX_WORKERS=20"
110
+ ]
111
+
112
+ default_config = {
113
+ "models": [],
114
+ "evaluations": [],
115
+ "detectors": [],
116
+ "message_collections": [],
117
+ "model_evaluations": [],
118
+ }
119
+
120
+ old_umask = os.umask(0o077)
121
+ try:
122
+ flintai_dir.mkdir(parents=True, exist_ok=True)
123
+
124
+ env_path = get_flintai_env_path()
125
+ env_path.write_text("\n".join(env_lines) + "\n")
126
+ env_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
127
+
128
+ config_path = get_flintai_config_path()
129
+ config_path.write_text(
130
+ json.dumps(default_config, indent=2) + "\n",
131
+ )
132
+ finally:
133
+ os.umask(old_umask)
134
+
135
+ console.print(f"[green]Created {env_path}[/green]")
136
+ console.print(
137
+ f"[green]Created {config_path}[/green]",
138
+ )
139
+ console.print()
140
+ console.print(
141
+ "[bold green]Flint AI initialized"
142
+ " successfully![/bold green]",
143
+ )
144
+ console.print()
flintai/cli/main.py ADDED
@@ -0,0 +1,262 @@
1
+ """
2
+ CLI entry point for Flint AI CLI.
3
+
4
+ Usage:
5
+ python -m flintai.cli init
6
+ python -m flintai.cli eval models list
7
+ python -m flintai.cli eval run <model-evaluation-id>
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import sys
17
+ import time
18
+ import traceback
19
+ from datetime import datetime
20
+
21
+ from dotenv import load_dotenv
22
+ from rich.panel import Panel
23
+ from rich.text import Text
24
+
25
+ from flintai.cli import eval_cli, scan_cli, init_cli
26
+ from flintai.cli.console import CLI_WIDTH, console
27
+ from flintai.cli.version import VERSION
28
+ from flintai.cli.utils import is_ci
29
+ from flintai.eval.common.log import setup_file_logging
30
+ from flintai.eval.db.json.repository_json import JsonRepository
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _LOGO_LINES = [
35
+ "███████╗██╗ ██╗███╗ ██╗████████╗ █████╗ ██╗",
36
+ "██╔════╝██║ ██║████╗ ██║╚══██╔══╝ ██╔══██╗██║",
37
+ "█████╗ ██║ ██║██╔██╗ ██║ ██║ ███████║██║",
38
+ "██╔══╝ ██║ ██║██║╚██╗██║ ██║ ██╔══██║██║",
39
+ "██║ ███████╗██║██║ ╚████║ ██║ ██║ ██║██║",
40
+ "╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚═╝",
41
+ ]
42
+
43
+
44
+ _TIMESTAMP = datetime.now().strftime("%Y%m%dT%H%M%S")
45
+
46
+ _BUILTIN_CONFIG = os.path.join(
47
+ os.path.dirname(os.path.abspath(__file__)),
48
+ "builtin_config.json",
49
+ )
50
+
51
+
52
+
53
+ def _count(repo: JsonRepository) -> dict[str, int]:
54
+ return {
55
+ "models": len(repo.models.list()),
56
+ "evaluations": len(repo.evaluations.list()),
57
+ "detectors": len(repo.detectors.list()),
58
+ "collections": len(repo.message_collections.list()),
59
+ "assignments": len(repo.model_evaluations.list_all()),
60
+ }
61
+
62
+
63
+ def _fmt_count(total: int, builtin: int, user: int) -> str:
64
+ return f"{total} [dim]({builtin} builtin, {user} user)[/dim]"
65
+
66
+
67
+ def _print_logo() -> None:
68
+ console.print()
69
+ for line in _LOGO_LINES:
70
+ pad = max(0, (CLI_WIDTH - len(line)) // 2)
71
+ console.print(" " * pad + f"[bold cyan]{line}[/bold cyan]")
72
+ version_text = f"v{VERSION}"
73
+ pad = max(0, (CLI_WIDTH - len(version_text)) // 2)
74
+ console.print(" " * pad + f"[dim][bold cyan]{version_text}[/bold cyan][/dim]")
75
+ console.print()
76
+
77
+
78
+ def _print_config_info(
79
+ merged: JsonRepository,
80
+ builtin: JsonRepository,
81
+ user: JsonRepository,
82
+ config_path: str,
83
+ ) -> None:
84
+ m = _count(merged)
85
+ b = _count(builtin)
86
+ u = _count(user)
87
+
88
+ console.print(f"[dim]Config: {config_path}[/dim]")
89
+ console.print(f"[dim]Models: {_fmt_count(m['models'], b['models'], u['models'])}[/dim]")
90
+ console.print(f"[dim]Evaluations: {_fmt_count(m['evaluations'], b['evaluations'], u['evaluations'])}[/dim]")
91
+ console.print(f"[dim]Detectors: {_fmt_count(m['detectors'], b['detectors'], u['detectors'])}[/dim]")
92
+ console.print(f"[dim]Collections: {_fmt_count(m['collections'], b['collections'], u['collections'])}[/dim]")
93
+ console.print(f"[dim]Assignments: {_fmt_count(m['assignments'], b['assignments'], u['assignments'])}[/dim]")
94
+ console.print()
95
+
96
+
97
+ def _print_path(
98
+ path: str,
99
+ ) -> None:
100
+ console.print(f"[dim]Path: {path}[/dim]")
101
+ console.print()
102
+
103
+
104
+ def _print_shutdown(
105
+ elapsed: float,
106
+ output_path: str | None,
107
+ log_path: str | None,
108
+ ) -> None:
109
+ console.print()
110
+ parts = [f"[dim]Completed in [bold cyan]{elapsed:.1f}s[/bold cyan][/dim]"]
111
+ if output_path:
112
+ parts.append(f"[dim]Results: [bold cyan]{output_path}[/bold cyan][/dim]")
113
+ if log_path:
114
+ parts.append(f"[dim]Logs: [bold cyan]{log_path}[/bold cyan][/dim]")
115
+ console.print(" ".join(parts))
116
+ console.print()
117
+
118
+
119
+ def _dispatch(args: argparse.Namespace) -> str | None:
120
+ logger.info("Command: %s", " ".join(sys.argv))
121
+
122
+ if args.command == "eval":
123
+ return _dispatch_eval(args)
124
+ elif args.command == "scan":
125
+ return _dispatch_scan(args)
126
+ elif args.command == "init":
127
+ return _dispatch_init(args)
128
+ else:
129
+ console.print(f"[red]Unknown command: {args.command}[/red]")
130
+ sys.exit(1)
131
+
132
+
133
+ def _dispatch_init(args: argparse.Namespace) -> str | None:
134
+ init_cli.run_init()
135
+ return None
136
+
137
+
138
+ def _dispatch_scan(args: argparse.Namespace) -> None | None:
139
+ logger.info("flintai v%s | scan mode", VERSION)
140
+ _print_path(args.path)
141
+ return scan_cli.handle_scan(args)
142
+
143
+
144
+ def _dispatch_eval(args: argparse.Namespace) -> str | None:
145
+ """Run the requested command. Returns output file path if any."""
146
+ if args.command != "eval":
147
+ console.print(f"[red]Unknown command: {args.command}[/red]")
148
+ sys.exit(1)
149
+
150
+ config_path = getattr(args, "config", None) or str(
151
+ init_cli.get_flintai_config_path(),
152
+ )
153
+ builtin = JsonRepository(_BUILTIN_CONFIG)
154
+ user = JsonRepository(config_path)
155
+ store = user.merge(builtin)
156
+
157
+ _print_config_info(store, builtin, user, config_path)
158
+
159
+ m = _count(store)
160
+ logger.info(
161
+ "flintai v%s | models=%d, evaluations=%d, assignments=%d",
162
+ VERSION, m["models"], m["evaluations"], m["assignments"],
163
+ )
164
+
165
+ cmd = args.eval_cmd
166
+ if cmd == "models":
167
+ eval_cli.handle_models(args, store)
168
+ elif cmd == "evaluations":
169
+ eval_cli.handle_evaluations(args, store)
170
+ elif cmd == "model-evaluations":
171
+ eval_cli.handle_model_evaluations(
172
+ args, store, user,
173
+ )
174
+ elif cmd == "run":
175
+ return asyncio.run(eval_cli.handle_run(args, store))
176
+ else:
177
+ console.print(f"[red]Unknown eval subcommand: {cmd}[/red]")
178
+ sys.exit(1)
179
+ return None
180
+
181
+
182
+ def _print_error(error: BaseException) -> None:
183
+ error_type = type(error).__name__
184
+ error_msg = str(error) or "(no message)"
185
+
186
+ body = Text()
187
+ body.append(f"{error_type}: ", style="bold red")
188
+ body.append(error_msg)
189
+
190
+ panel = Panel(
191
+ body,
192
+ title="[bold red]Error[/bold red]",
193
+ border_style="red",
194
+ width=min(CLI_WIDTH, max(60, len(error_msg) + 20)),
195
+ padding=(1, 2),
196
+ )
197
+ console.print()
198
+ console.print(panel)
199
+
200
+
201
+ def main(argv: list[str] | None = None) -> None:
202
+ load_dotenv()
203
+
204
+ parser = argparse.ArgumentParser(
205
+ prog="flintai",
206
+ description="Flint AI CLI — AI Agent Evaluation Framework",
207
+ )
208
+ parser.add_argument(
209
+ "--version", action="version", version=f"%(prog)s {VERSION}",
210
+ )
211
+
212
+ subparsers = parser.add_subparsers(dest="command")
213
+ subparsers.required = True
214
+ eval_cli.register(subparsers)
215
+ scan_cli.register(subparsers)
216
+ init_cli.register(subparsers)
217
+
218
+ args = parser.parse_args(argv)
219
+
220
+ ci = is_ci()
221
+ flintai_env = init_cli.get_flintai_env_path()
222
+
223
+ if not flintai_env.exists() and args.command != "init":
224
+ if ci:
225
+ console.print(
226
+ "[dim]CI environment detected —"
227
+ " skipping interactive init.[/dim]",
228
+ )
229
+ else:
230
+ console.print(
231
+ "[yellow]First-time setup required."
232
+ " Running flintai init...[/yellow]",
233
+ )
234
+ console.print()
235
+ init_cli.run_init()
236
+
237
+ if flintai_env.exists():
238
+ load_dotenv(flintai_env)
239
+
240
+ log_path = getattr(args, "log", None) or f"flintai_{_TIMESTAMP}.log"
241
+ setup_file_logging(log_path)
242
+ _print_logo()
243
+
244
+ t0 = time.monotonic()
245
+ output_path: str | None = None
246
+ try:
247
+ output_path = _dispatch(args)
248
+ except KeyboardInterrupt:
249
+ logger.info("Interrupted by user")
250
+ console.print("\n[dim]Interrupted.[/dim]")
251
+ sys.exit(130)
252
+ except Exception as e:
253
+ logger.critical(
254
+ "Fatal error: %s: %s\n%s",
255
+ type(e).__name__, e, traceback.format_exc(),
256
+ )
257
+ _print_error(e)
258
+ elapsed = time.monotonic() - t0
259
+ _print_shutdown(elapsed, output_path, log_path)
260
+ sys.exit(1)
261
+ elapsed = time.monotonic() - t0
262
+ _print_shutdown(elapsed, output_path, log_path)
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.progress import (
4
+ BarColumn,
5
+ Progress,
6
+ ProgressColumn,
7
+ TextColumn,
8
+ TimeElapsedColumn,
9
+ TaskID,
10
+ )
11
+ from rich.spinner import Spinner
12
+ from rich.text import Text
13
+
14
+ from flintai.cli.console import console, score_style
15
+ from flintai.eval.core.eval.evaluation import Evaluation
16
+
17
+
18
+ class StatusColumn(ProgressColumn):
19
+ """Spinner while running, green check when done, red X on error."""
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+ self._spinner = Spinner("dots")
24
+
25
+ def render(self, task: object) -> Text:
26
+ if task.finished:
27
+ status = task.fields.get("final_status", "finished")
28
+ if status == "error":
29
+ return Text("✗", style="bold red")
30
+ return Text("✔", style="bold green")
31
+ return self._spinner.render(task.get_time())
32
+
33
+
34
+ class ProgressOrScoreColumn(ProgressColumn):
35
+ """Shows M/N while running, score percentage when finished."""
36
+
37
+ def render(self, task: object) -> Text:
38
+ if task.finished:
39
+ score = task.fields.get("score")
40
+ if score is not None:
41
+ pct = score * 100
42
+ return Text(f"{pct:.0f}%", style=score_style(score))
43
+ return Text("—", style="dim")
44
+ completed = int(task.completed)
45
+ if task.total is not None:
46
+ return Text(f"{completed}/{int(task.total)}")
47
+ return Text(f"{completed}/?")
48
+
49
+
50
+ def create_progress() -> Progress:
51
+ return Progress(
52
+ StatusColumn(),
53
+ TextColumn("{task.description}"),
54
+ BarColumn(),
55
+ ProgressOrScoreColumn(),
56
+ TextColumn("[dim]|[/dim]"),
57
+ TimeElapsedColumn(),
58
+ console=console,
59
+ )
60
+
61
+
62
+ def pad_description(name: str, width: int) -> str:
63
+ if len(name) > width:
64
+ return name[: width - 1] + "…"
65
+ return name.ljust(width)
66
+
67
+
68
+ class RichObserver:
69
+ """Evaluation observer that updates a task in a shared Progress."""
70
+
71
+ def __init__(self, progress: Progress, task_id: TaskID):
72
+ self._progress = progress
73
+ self._task_id = task_id
74
+
75
+ def __call__(self, evaluation: Evaluation) -> None:
76
+ summary = evaluation.get_summary()
77
+ done = summary.finished_evaluations + summary.error_evaluations
78
+ self._progress.update(self._task_id, completed=done)
79
+
80
+ def finish(self, evaluation: Evaluation) -> None:
81
+ summary = evaluation.get_summary()
82
+ total = summary.total_evaluations or 1
83
+ self._progress.update(
84
+ self._task_id,
85
+ completed=total,
86
+ total=total,
87
+ final_status=summary.status.value,
88
+ score=summary.score,
89
+ )