fastslr 3.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.
- fastslr/__init__.py +6 -0
- fastslr/__main__.py +5 -0
- fastslr/app/__init__.py +1 -0
- fastslr/app/cli.py +593 -0
- fastslr/app/controller.py +805 -0
- fastslr/app/profiles.py +128 -0
- fastslr/app/tui.py +1035 -0
- fastslr/core/__init__.py +17 -0
- fastslr/core/config.py +551 -0
- fastslr/core/constants.py +64 -0
- fastslr/core/coverage.py +252 -0
- fastslr/core/default_config.json +64 -0
- fastslr/core/engine.py +467 -0
- fastslr/core/io.py +724 -0
- fastslr/core/models.py +115 -0
- fastslr/core/normalization.py +208 -0
- fastslr/core/patterns.py +336 -0
- fastslr/core/presets.py +123 -0
- fastslr/core/scoring.py +492 -0
- fastslr/i18n/__init__.py +160 -0
- fastslr/i18n/locales/en.json +73 -0
- fastslr/i18n/locales/es.json +73 -0
- fastslr/i18n/locales/pt_BR.json +73 -0
- fastslr/py.typed +0 -0
- fastslr-3.0.0.dist-info/METADATA +707 -0
- fastslr-3.0.0.dist-info/RECORD +30 -0
- fastslr-3.0.0.dist-info/WHEEL +5 -0
- fastslr-3.0.0.dist-info/entry_points.txt +2 -0
- fastslr-3.0.0.dist-info/licenses/LICENSE +21 -0
- fastslr-3.0.0.dist-info/top_level.txt +1 -0
fastslr/__init__.py
ADDED
fastslr/__main__.py
ADDED
fastslr/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastSLR application layer — CLI, TUI, and orchestration."""
|
fastslr/app/cli.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""FastSLR CLI — batch mode entry point.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
fastslr doctor --input <input> --config <config.json> --terms <terms.xlsx>
|
|
5
|
+
fastslr run <input> --config <config.json> --terms <terms.xlsx>
|
|
6
|
+
fastslr preview <input> --config <config.json> --sample 50
|
|
7
|
+
fastslr new-project
|
|
8
|
+
fastslr coverage <input> --config <config.json>
|
|
9
|
+
fastslr diff <result1> <result2>
|
|
10
|
+
fastslr export <result> --output <dir>
|
|
11
|
+
fastslr profile save <name> --config <config.json>
|
|
12
|
+
fastslr profile load <name>
|
|
13
|
+
fastslr profile list
|
|
14
|
+
fastslr version
|
|
15
|
+
fastslr tui
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
|
|
29
|
+
from ..i18n import _ as t
|
|
30
|
+
from ..i18n import set_locale
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="fastslr",
|
|
34
|
+
help="FastSLR — Universal deterministic triage for Systematic Literature Reviews.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
rich_markup_mode="rich",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
profile_app = typer.Typer(help="Manage configuration profiles.", no_args_is_help=True)
|
|
40
|
+
app.add_typer(profile_app, name="profile")
|
|
41
|
+
|
|
42
|
+
console = Console()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _setup_lang(lang: str | None) -> None:
|
|
49
|
+
"""Configure locale from --lang flag if provided."""
|
|
50
|
+
if lang:
|
|
51
|
+
set_locale(lang)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _require_input(input_file: Path) -> None:
|
|
55
|
+
"""Abort with a friendly message if the input articles file is missing."""
|
|
56
|
+
if not input_file.exists():
|
|
57
|
+
console.print(f"[red]{t('file_not_found', path=input_file)}[/red]")
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _require_config(config: Path) -> None:
|
|
62
|
+
"""Abort with a friendly message if the config file is missing."""
|
|
63
|
+
if not config.exists():
|
|
64
|
+
console.print(f"[red]{t('config_not_found', path=config)}[/red]")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _require_terms(terms: Path | None) -> None:
|
|
69
|
+
"""Abort with a friendly message if a provided terms file is missing.
|
|
70
|
+
|
|
71
|
+
``terms`` is optional, so ``None`` is accepted silently; only a non-existent
|
|
72
|
+
path triggers the error.
|
|
73
|
+
"""
|
|
74
|
+
if terms is not None and not terms.exists():
|
|
75
|
+
console.print(f"[red]{t('file_not_found', path=terms)}[/red]")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _rich_progress_callback(progress: Progress, task_id: object):
|
|
80
|
+
"""Return a callback that updates a rich progress bar."""
|
|
81
|
+
|
|
82
|
+
def _on_progress(current: int, total: int) -> None:
|
|
83
|
+
progress.update(task_id, completed=current, total=total) # type: ignore[arg-type]
|
|
84
|
+
|
|
85
|
+
return _on_progress
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _print_stats(stats: dict) -> None:
|
|
89
|
+
"""Print triage statistics as a rich table."""
|
|
90
|
+
table = Table(title=t("table_triage_results"), show_header=True)
|
|
91
|
+
table.add_column(t("table_metric"), style="cyan")
|
|
92
|
+
table.add_column(t("table_value"), style="bold")
|
|
93
|
+
|
|
94
|
+
table.add_row(
|
|
95
|
+
t("table_total_articles"),
|
|
96
|
+
str(stats.get("total_articles", 0)),
|
|
97
|
+
)
|
|
98
|
+
table.add_row(
|
|
99
|
+
t("table_processing_time"),
|
|
100
|
+
t("time_unit", value=stats.get("processing_time", 0)),
|
|
101
|
+
)
|
|
102
|
+
table.add_row(
|
|
103
|
+
t("table_speed"),
|
|
104
|
+
t("speed_unit", value=stats.get("articles_per_second", 0)),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
dist = stats.get("decision_distribution", {})
|
|
108
|
+
for decision, count in sorted(dist.items()):
|
|
109
|
+
style = "green" if "APPROVED" in decision else "yellow" if "FLAGGED" in decision else "red"
|
|
110
|
+
table.add_row(f" {decision}", f"[{style}]{count}[/{style}]")
|
|
111
|
+
|
|
112
|
+
if stats.get("error_count", 0) > 0:
|
|
113
|
+
table.add_row(
|
|
114
|
+
t("table_errors"),
|
|
115
|
+
f"[red]{stats['error_count']}[/red]",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
console.print(table)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _print_setup_inspection(inspection) -> None:
|
|
122
|
+
"""Print a setup inspection returned by the controller."""
|
|
123
|
+
if inspection.messages:
|
|
124
|
+
console.print(f"\n[bold]{t('doctor_quick_start')}[/bold]")
|
|
125
|
+
for msg in inspection.messages:
|
|
126
|
+
console.print(f" - {msg}")
|
|
127
|
+
|
|
128
|
+
if inspection.errors:
|
|
129
|
+
console.print(f"\n[red bold]{t('doctor_setup_errors')}[/red bold]")
|
|
130
|
+
for msg in inspection.errors:
|
|
131
|
+
console.print(f" [red]- {msg}[/red]")
|
|
132
|
+
|
|
133
|
+
if inspection.warnings:
|
|
134
|
+
console.print(f"\n[yellow bold]{t('doctor_setup_warnings')}[/yellow bold]")
|
|
135
|
+
for msg in inspection.warnings:
|
|
136
|
+
console.print(f" [yellow]- {msg}[/yellow]")
|
|
137
|
+
|
|
138
|
+
if inspection.input_rows is not None:
|
|
139
|
+
console.print(f"\n[bold]{t('doctor_input_articles')}[/bold]: {inspection.input_rows}")
|
|
140
|
+
if inspection.input_columns:
|
|
141
|
+
console.print(t("doctor_columns") + ": " + ", ".join(inspection.input_columns[:20]))
|
|
142
|
+
|
|
143
|
+
if inspection.field_mapping:
|
|
144
|
+
table = Table(title=t("doctor_field_mapping"), show_header=True)
|
|
145
|
+
table.add_column(t("doctor_field"), style="cyan")
|
|
146
|
+
table.add_column(t("doctor_column"))
|
|
147
|
+
for field, column in inspection.field_mapping.items():
|
|
148
|
+
table.add_row(field, column or f"[yellow]{t('doctor_not_found')}[/yellow]")
|
|
149
|
+
console.print(table)
|
|
150
|
+
|
|
151
|
+
if inspection.domain_blocks:
|
|
152
|
+
console.print(
|
|
153
|
+
f"\n[bold]{t('doctor_domain_blocks')}[/bold]: " + ", ".join(inspection.domain_blocks)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if inspection.terms_count is not None:
|
|
157
|
+
console.print(f"[bold]{t('doctor_valid_terms')}[/bold]: {inspection.terms_count}")
|
|
158
|
+
|
|
159
|
+
if inspection.output_dir is not None:
|
|
160
|
+
console.print(f"[bold]{t('doctor_output_directory')}[/bold]: {inspection.output_dir}")
|
|
161
|
+
|
|
162
|
+
if inspection.run_command:
|
|
163
|
+
console.print(f"\n[bold green]{t('doctor_run_command')}[/bold green]")
|
|
164
|
+
console.print(f" {inspection.run_command}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── Commands ─────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def version(
|
|
172
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Show FastSLR version."""
|
|
175
|
+
_setup_lang(lang)
|
|
176
|
+
from . import controller
|
|
177
|
+
|
|
178
|
+
console.print(t("version_info", version=controller.get_version()))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def doctor(
|
|
183
|
+
input_file: Path | None = typer.Option(
|
|
184
|
+
None, "--input", "-i", help="Path to articles CSV/XLSX file."
|
|
185
|
+
),
|
|
186
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Path to config.json."),
|
|
187
|
+
terms: Path | None = typer.Option(None, "--terms", "-t", help="Path to terms XLSX/CSV."),
|
|
188
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output directory."),
|
|
189
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Check setup files and show the exact run command."""
|
|
192
|
+
_setup_lang(lang)
|
|
193
|
+
from . import controller
|
|
194
|
+
|
|
195
|
+
inspection = controller.inspect_run_setup(
|
|
196
|
+
input_path=input_file,
|
|
197
|
+
config_path=config,
|
|
198
|
+
terms_path=terms,
|
|
199
|
+
output_dir=output,
|
|
200
|
+
)
|
|
201
|
+
_print_setup_inspection(inspection)
|
|
202
|
+
|
|
203
|
+
if not inspection.ok:
|
|
204
|
+
raise typer.Exit(1)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command()
|
|
208
|
+
def run(
|
|
209
|
+
input_file: Path = typer.Argument(..., help="Path to articles CSV/XLSX file."),
|
|
210
|
+
config: Path = typer.Option(..., "--config", "-c", help="Path to config.json."),
|
|
211
|
+
terms: Path | None = typer.Option(None, "--terms", "-t", help="Path to terms XLSX/CSV."),
|
|
212
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output directory."),
|
|
213
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
214
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress output."),
|
|
215
|
+
yes: bool = typer.Option(
|
|
216
|
+
False, "--yes", "-y", help="Proceed past configuration warnings without prompting."
|
|
217
|
+
),
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Run triage on an articles file."""
|
|
220
|
+
_setup_lang(lang)
|
|
221
|
+
from . import controller
|
|
222
|
+
|
|
223
|
+
_require_input(input_file)
|
|
224
|
+
_require_config(config)
|
|
225
|
+
_require_terms(terms)
|
|
226
|
+
|
|
227
|
+
# Validate config first
|
|
228
|
+
prepared = controller._prepare_config(config, terms)
|
|
229
|
+
issues = controller.validate_config(prepared)
|
|
230
|
+
errors = [i for i in issues if i.level == "error"]
|
|
231
|
+
if errors:
|
|
232
|
+
for issue in errors:
|
|
233
|
+
console.print(f"[red]{t('config_error', message=issue.message)}[/red]")
|
|
234
|
+
raise typer.Exit(1)
|
|
235
|
+
|
|
236
|
+
warnings = [i for i in issues if i.level == "warning"]
|
|
237
|
+
if warnings and not quiet:
|
|
238
|
+
console.print(f"\n[yellow bold]{t('warnings_found', count=len(warnings))}[/yellow bold]")
|
|
239
|
+
for i, issue in enumerate(warnings, 1):
|
|
240
|
+
console.print(f" [yellow]{i}. {issue.message}[/yellow]")
|
|
241
|
+
console.print()
|
|
242
|
+
# Only prompt when running interactively. In a pipe/CI context (no TTY)
|
|
243
|
+
# or with --yes there is nobody to answer [y/N]; aborting there would
|
|
244
|
+
# silently fail the run, so proceed by default instead.
|
|
245
|
+
interactive = sys.stdin.isatty()
|
|
246
|
+
if interactive and not yes:
|
|
247
|
+
if not typer.confirm(t("continue_with_warnings"), default=False):
|
|
248
|
+
raise typer.Exit(0)
|
|
249
|
+
else:
|
|
250
|
+
console.print(f"[dim]{t('proceeding_with_warnings')}[/dim]")
|
|
251
|
+
console.print()
|
|
252
|
+
|
|
253
|
+
with Progress(
|
|
254
|
+
SpinnerColumn(),
|
|
255
|
+
TextColumn("[progress.description]{task.description}"),
|
|
256
|
+
BarColumn(),
|
|
257
|
+
TaskProgressColumn(),
|
|
258
|
+
console=console,
|
|
259
|
+
disable=quiet,
|
|
260
|
+
) as progress:
|
|
261
|
+
task_id = progress.add_task("Triage", total=None)
|
|
262
|
+
callback = _rich_progress_callback(progress, task_id)
|
|
263
|
+
|
|
264
|
+
result = controller.run_triage(
|
|
265
|
+
input_path=input_file,
|
|
266
|
+
config_path=config,
|
|
267
|
+
terms_path=terms,
|
|
268
|
+
output_dir=output,
|
|
269
|
+
on_progress=callback,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if not quiet:
|
|
273
|
+
_print_stats(result.stats)
|
|
274
|
+
console.print(f"\n[green]{t('triage_complete', path=result.output_dir)}[/green]")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def preview(
|
|
279
|
+
input_file: Path = typer.Argument(..., help="Path to articles CSV/XLSX file."),
|
|
280
|
+
config: Path = typer.Option(..., "--config", "-c", help="Path to config.json."),
|
|
281
|
+
terms: Path | None = typer.Option(None, "--terms", "-t", help="Path to terms XLSX/CSV."),
|
|
282
|
+
sample: int = typer.Option(50, "--sample", "-s", help="Number of articles to sample."),
|
|
283
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Preview triage results on a sample of articles."""
|
|
286
|
+
_setup_lang(lang)
|
|
287
|
+
from . import controller
|
|
288
|
+
|
|
289
|
+
if sample < 1:
|
|
290
|
+
console.print(f"[red]{t('sample_must_be_positive', value=sample)}[/red]")
|
|
291
|
+
raise typer.Exit(1)
|
|
292
|
+
|
|
293
|
+
_require_input(input_file)
|
|
294
|
+
_require_config(config)
|
|
295
|
+
_require_terms(terms)
|
|
296
|
+
|
|
297
|
+
result = controller.preview_triage(
|
|
298
|
+
input_path=input_file,
|
|
299
|
+
config_path=config,
|
|
300
|
+
terms_path=terms,
|
|
301
|
+
sample_size=sample,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
_print_stats(result.stats)
|
|
305
|
+
console.print(f"\n[dim]{t('preview_note', count=result.sample_size)}[/dim]")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.command()
|
|
309
|
+
def coverage(
|
|
310
|
+
input_file: Path = typer.Argument(..., help="Path to articles CSV/XLSX file."),
|
|
311
|
+
config: Path = typer.Option(..., "--config", "-c", help="Path to config.json."),
|
|
312
|
+
terms: Path | None = typer.Option(None, "--terms", "-t", help="Path to terms XLSX/CSV."),
|
|
313
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Export coverage CSV."),
|
|
314
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Analyze term coverage across all articles."""
|
|
317
|
+
_setup_lang(lang)
|
|
318
|
+
from . import controller
|
|
319
|
+
|
|
320
|
+
_require_input(input_file)
|
|
321
|
+
_require_config(config)
|
|
322
|
+
_require_terms(terms)
|
|
323
|
+
|
|
324
|
+
report = controller.analyze_coverage(
|
|
325
|
+
input_path=input_file,
|
|
326
|
+
config_path=config,
|
|
327
|
+
terms_path=terms,
|
|
328
|
+
output_path=output,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
console.print(controller.format_coverage(report))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.command()
|
|
335
|
+
def diff(
|
|
336
|
+
result_a: Path = typer.Argument(..., help="First result file (CSV/XLSX)."),
|
|
337
|
+
result_b: Path = typer.Argument(..., help="Second result file (CSV/XLSX)."),
|
|
338
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Compare two triage result files."""
|
|
341
|
+
_setup_lang(lang)
|
|
342
|
+
from . import controller
|
|
343
|
+
|
|
344
|
+
for result_file in (result_a, result_b):
|
|
345
|
+
if not result_file.exists():
|
|
346
|
+
console.print(f"[red]{t('file_not_found', path=result_file)}[/red]")
|
|
347
|
+
raise typer.Exit(1)
|
|
348
|
+
|
|
349
|
+
report = controller.diff_results(result_a, result_b)
|
|
350
|
+
|
|
351
|
+
table = Table(title=t("table_diff_title"), show_header=True)
|
|
352
|
+
table.add_column(t("table_article_id"), style="cyan")
|
|
353
|
+
table.add_column(t("table_old_decision"), style="red")
|
|
354
|
+
table.add_column(t("table_new_decision"), style="green")
|
|
355
|
+
|
|
356
|
+
for entry in report.changed[:50]:
|
|
357
|
+
table.add_row(entry.article_id, entry.old_decision, entry.new_decision)
|
|
358
|
+
|
|
359
|
+
console.print(table)
|
|
360
|
+
|
|
361
|
+
if len(report.changed) > 50:
|
|
362
|
+
console.print(f"[dim]{t('diff_more_changes', count=len(report.changed) - 50)}[/dim]")
|
|
363
|
+
|
|
364
|
+
console.print(f"\n{t('diff_total_changes', count=len(report.changed))}")
|
|
365
|
+
console.print(t("diff_article_counts", a=report.total_a, b=report.total_b))
|
|
366
|
+
|
|
367
|
+
if report.summary:
|
|
368
|
+
console.print(f"\n{t('diff_transitions')}")
|
|
369
|
+
for transition, count in sorted(report.summary.items()):
|
|
370
|
+
console.print(f" {transition}: {count}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.command(name="new-project")
|
|
374
|
+
def new_project(
|
|
375
|
+
name: str = typer.Argument(..., help="Project name."),
|
|
376
|
+
blocks: str = typer.Option(
|
|
377
|
+
...,
|
|
378
|
+
"--blocks",
|
|
379
|
+
"-b",
|
|
380
|
+
help="Comma-separated block names (e.g., 'CTX,TECH,SCM').",
|
|
381
|
+
),
|
|
382
|
+
preset: str = typer.Option(
|
|
383
|
+
"standard",
|
|
384
|
+
"--preset",
|
|
385
|
+
"-p",
|
|
386
|
+
help="Level preset: binary, simple, standard.",
|
|
387
|
+
),
|
|
388
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output directory."),
|
|
389
|
+
force: bool = typer.Option(
|
|
390
|
+
False,
|
|
391
|
+
"--force",
|
|
392
|
+
"-f",
|
|
393
|
+
help="Overwrite an existing project directory instead of refusing.",
|
|
394
|
+
),
|
|
395
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Create a new triage project with config and terms template."""
|
|
398
|
+
_setup_lang(lang)
|
|
399
|
+
from . import controller
|
|
400
|
+
|
|
401
|
+
block_list = [
|
|
402
|
+
{"name": b.strip().upper(), "label": b.strip()} for b in blocks.split(",") if b.strip()
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
if not block_list:
|
|
406
|
+
console.print(f"[red]{t('blocks_required')}[/red]")
|
|
407
|
+
raise typer.Exit(1)
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
project_dir = controller.create_project(
|
|
411
|
+
name=name,
|
|
412
|
+
blocks=block_list,
|
|
413
|
+
preset=preset,
|
|
414
|
+
output_dir=output,
|
|
415
|
+
force=force,
|
|
416
|
+
)
|
|
417
|
+
except FileExistsError as exc:
|
|
418
|
+
console.print(f"[red]{t('project_exists', message=exc)}[/red]")
|
|
419
|
+
raise typer.Exit(1) from exc
|
|
420
|
+
|
|
421
|
+
console.print(f"[green]{t('project_created', path=project_dir)}[/green]")
|
|
422
|
+
console.print(t("project_config_hint"))
|
|
423
|
+
console.print(t("project_terms_hint"))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.command(name="export")
|
|
427
|
+
def export_cmd(
|
|
428
|
+
result_file: Path = typer.Argument(..., help="Path to triage results file."),
|
|
429
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output directory."),
|
|
430
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Config file to include."),
|
|
431
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Export an academic package (ZIP) from triage results."""
|
|
434
|
+
_setup_lang(lang)
|
|
435
|
+
from . import controller
|
|
436
|
+
|
|
437
|
+
if not result_file.exists():
|
|
438
|
+
console.print(f"[red]{t('file_not_found', path=result_file)}[/red]")
|
|
439
|
+
raise typer.Exit(1)
|
|
440
|
+
|
|
441
|
+
out_dir = output or result_file.parent
|
|
442
|
+
zip_path = controller.export_academic_package(
|
|
443
|
+
result_path=result_file,
|
|
444
|
+
output_dir=out_dir,
|
|
445
|
+
config_path=config,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
console.print(f"[green]{t('academic_exported', path=zip_path)}[/green]")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@app.command(name="terms")
|
|
452
|
+
def terms_cmd(
|
|
453
|
+
config: Path = typer.Option(..., "--config", "-c", help="Path to config.json."),
|
|
454
|
+
terms: Path | None = typer.Option(None, "--terms", "-t", help="Path to terms XLSX/CSV."),
|
|
455
|
+
block: str | None = typer.Option(None, "--block", "-b", help="Filter by block name."),
|
|
456
|
+
kind: str | None = typer.Option(None, "--kind", "-k", help="Filter by kind (pos/anti/flag)."),
|
|
457
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Browse configured terms."""
|
|
460
|
+
_setup_lang(lang)
|
|
461
|
+
from . import controller
|
|
462
|
+
|
|
463
|
+
_require_config(config)
|
|
464
|
+
_require_terms(terms)
|
|
465
|
+
|
|
466
|
+
view = controller.browse_terms(
|
|
467
|
+
config_path=config,
|
|
468
|
+
terms_path=terms,
|
|
469
|
+
block_filter=block,
|
|
470
|
+
kind_filter=kind,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
table = Table(title=t("table_terms_title", count=view.total), show_header=True)
|
|
474
|
+
table.add_column(t("table_block"), style="cyan")
|
|
475
|
+
table.add_column(t("table_kind"), style="magenta")
|
|
476
|
+
table.add_column(t("table_term"))
|
|
477
|
+
table.add_column(t("table_level"), justify="center")
|
|
478
|
+
table.add_column(t("table_scope"))
|
|
479
|
+
|
|
480
|
+
for entry in view.terms[:200]:
|
|
481
|
+
kind_style = (
|
|
482
|
+
"green" if entry["kind"] == "pos" else "red" if entry["kind"] == "anti" else "yellow"
|
|
483
|
+
)
|
|
484
|
+
table.add_row(
|
|
485
|
+
entry["block"],
|
|
486
|
+
f"[{kind_style}]{entry['kind']}[/{kind_style}]",
|
|
487
|
+
entry["term"],
|
|
488
|
+
str(entry["level"] or ""),
|
|
489
|
+
entry["scope"],
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if view.total > 200:
|
|
493
|
+
console.print(f"[dim]{t('terms_truncated', shown=200, total=view.total)}[/dim]")
|
|
494
|
+
|
|
495
|
+
console.print(table)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@app.command()
|
|
499
|
+
def tui() -> None:
|
|
500
|
+
"""Launch the interactive TUI."""
|
|
501
|
+
from .tui import main as tui_main
|
|
502
|
+
|
|
503
|
+
tui_main()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ── Profile subcommands ──────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@profile_app.command("save")
|
|
510
|
+
def profile_save(
|
|
511
|
+
name: str = typer.Argument(..., help="Profile name."),
|
|
512
|
+
config: Path = typer.Option(..., "--config", "-c", help="Config file to save as profile."),
|
|
513
|
+
description: str = typer.Option("", "--desc", "-d", help="Profile description."),
|
|
514
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
515
|
+
) -> None:
|
|
516
|
+
"""Save a configuration as a named profile."""
|
|
517
|
+
_setup_lang(lang)
|
|
518
|
+
from . import controller
|
|
519
|
+
|
|
520
|
+
_require_config(config)
|
|
521
|
+
|
|
522
|
+
path = controller.save_profile_config(name, config, description)
|
|
523
|
+
console.print(f"[green]{t('profile_saved', name=name, path=path)}[/green]")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@profile_app.command("load")
|
|
527
|
+
def profile_load(
|
|
528
|
+
name: str = typer.Argument(..., help="Profile name."),
|
|
529
|
+
output: Path = typer.Option("config.json", "--output", "-o", help="Output config file path."),
|
|
530
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
531
|
+
) -> None:
|
|
532
|
+
"""Load a named profile to a config file."""
|
|
533
|
+
_setup_lang(lang)
|
|
534
|
+
import json
|
|
535
|
+
|
|
536
|
+
from . import profiles
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
cfg = profiles.load_profile(name)
|
|
540
|
+
except FileNotFoundError:
|
|
541
|
+
console.print(f"[red]{t('file_not_found', path=name)}[/red]")
|
|
542
|
+
raise typer.Exit(1)
|
|
543
|
+
|
|
544
|
+
output.write_text(
|
|
545
|
+
json.dumps(cfg, indent=2, ensure_ascii=False),
|
|
546
|
+
encoding="utf-8",
|
|
547
|
+
)
|
|
548
|
+
console.print(f"[green]{t('profile_loaded', name=name, path=output)}[/green]")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@profile_app.command("list")
|
|
552
|
+
def profile_list(
|
|
553
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Interface language."),
|
|
554
|
+
) -> None:
|
|
555
|
+
"""List all saved profiles."""
|
|
556
|
+
_setup_lang(lang)
|
|
557
|
+
from . import profiles
|
|
558
|
+
|
|
559
|
+
all_profiles = profiles.list_profiles()
|
|
560
|
+
|
|
561
|
+
if not all_profiles:
|
|
562
|
+
console.print(f"[dim]{t('no_profiles')}[/dim]")
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
table = Table(title=t("table_profiles_title"), show_header=True)
|
|
566
|
+
table.add_column(t("table_name"), style="cyan")
|
|
567
|
+
table.add_column(t("table_description"))
|
|
568
|
+
table.add_column(t("table_path"), style="dim")
|
|
569
|
+
|
|
570
|
+
for p in all_profiles:
|
|
571
|
+
table.add_row(p.name, p.description, str(p.path))
|
|
572
|
+
|
|
573
|
+
console.print(table)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def main() -> None:
|
|
577
|
+
"""Entry point that wraps the Typer app with a friendly error handler.
|
|
578
|
+
|
|
579
|
+
Commands raise plain exceptions (``FileNotFoundError``, ``ValueError``,
|
|
580
|
+
``json.JSONDecodeError``) deep in the core engine. Without this wrapper they
|
|
581
|
+
surface as raw tracebacks. Here we translate the common ones into a localized
|
|
582
|
+
one-line message and exit with status 1, while letting ``typer.Exit`` /
|
|
583
|
+
``SystemExit`` (and ``KeyboardInterrupt``) propagate untouched.
|
|
584
|
+
"""
|
|
585
|
+
try:
|
|
586
|
+
app()
|
|
587
|
+
except (FileNotFoundError, json.JSONDecodeError, ValueError) as exc:
|
|
588
|
+
console.print(f"[red]{t('error_generic', message=exc)}[/red]")
|
|
589
|
+
sys.exit(1)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
if __name__ == "__main__":
|
|
593
|
+
main()
|