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 ADDED
@@ -0,0 +1,6 @@
1
+ """FastSLR - Universal deterministic triage for Systematic Literature Reviews."""
2
+
3
+ from .core.constants import VERSION
4
+
5
+ __version__ = VERSION
6
+ __all__ = ["__version__"]
fastslr/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running FastSLR as ``python -m fastslr``."""
2
+
3
+ from fastslr.app.cli import main
4
+
5
+ main()
@@ -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()