CLI: threatlens
Inspect what the extraction engine makes of one report, without touching a database. src/threatlens/cli.py
Purpose
The threatlens command runs a single CTI report through the
extraction engine and shows the result. It prints a short summary to the
terminal and, with -o, writes the full result as JSON. The JSON
holds every regex candidate with its offsets and context excerpt, the
verifier's verdict, reason and evidence quote for it, the document metadata
and, with the LLM engine, the report's scope assessment.
It exists to make the extraction stage visible on its own: to see why a value was confirmed or rejected, to compare the two verification engines on the same report, or to check a change to the extractor before ingesting anything. The JSON it writes is also the input format of the quality checker's own command line, so the next stage can be inspected the same way (see Chaining into the quality checker).
threatlens-ingest or the dashboard's Ingest page. Both run
dashboard/ingest_worker.py, which is also what the
evaluation runs use.
| Command | Does | Writes |
|---|---|---|
threatlens | Extract and verify one report | Terminal summary; JSON file with -o |
threatlens-ingest | Extract, quality checks, persist, rescore | The SQLite database given by --db |
threatlens init | Setup wizard: asks for every setting, writes it, creates the database (below) | threatlens.conf (previous kept as .bak); the database |
threatlens-calibrate | Benchmark the Ollama server and suggest LLM settings (below) | threatlens.conf: [ollama], [models] verifier, [llm], [calibration] |
threatlens-dashboard | The web UI (Dashboard) | The active database; threatlens.conf from its Configuration page |
python -m threatlens.db.migrate [DB] | Create or upgrade a database's schema with the migrations shipped in the package (Database) | The SQLite file DB (default: threatlens.conf's database in db/) |
How it works
-
Parse arguments
cli.py:59, cli.py:188main()builds the parser withbuild_parser()and callsrun(args). Expected failures (OSError,UnicodeError,ValueError,RuntimeError,OllamaError) are caught and printed aserror: …on stderr with exit code 1, rather than a traceback. -
Check the source file
cli.py:121run()stops straight away withsource does not exist: …(exit 1) if the path is not a file. -
Choose the engine
--engine pythoncallsioc_extract_python.analyse_document()directly; no settings are loaded and no network is used.For the default
llmengine, every LLM setting comes fromthreatlens.conf(or the file named by--config): the Ollama URL, the models and the[llm]performance tuning, with built-in defaults for anything missing.--ignore-calibrationswaps in the built-in[llm]tuning only. A bad value stops the run witherror: ….It then runs the setup check and prints a
cli.py:126–137warning: …line on stderr for each step not yet done (no file, unedited template, not calibrated; the calibration warning is dropped with--ignore-calibration). Warnings never stop the run. -
Extract and verify
ioc_extract_llm_fs.analyse_document()reads the report (PDF via PyMuPDF, or plain text), extracts regex candidates and metadata, profiles the document once with the profiler model, then sends the candidates to the verifier model in batches. The python engine shares the same reading and candidate extraction and differs only in the verifier. See the Extraction Engine page for what happens inside.Both engines return the same
cli.py:138IoCExtractionResultobject, held in memory. -
Print the summary
Four lines: metadata (source, publish date, author count), candidate count with engine and model, verdict counts, and, for the llm engine, the primary scope with its tactical/operational/strategic flags.
cli.py:152–173 -
Write JSON (optional)
With
cli.py:175–183-o, the result'sto_dict()is written as indented UTF-8 JSON, creating parent directories as needed.--accepted-onlytrims theindicatorslist here.
Using it
Prerequisites
- The project installed into a virtual environment (
pip install -e .from a checkout, or a plainpip installof the package), which provides thethreatlens,threatlens-ingest,threatlens-calibrateandthreatlens-dashboardcommands.python -m threatlens.cliworks too. - For the
llmengine, a reachable Ollama server with the verifier model pulled. Thepythonengine needs neither. - Settings are read from
threatlens.conf, wherever the layout puts it (the repo root in a checkout, the per-user config folder when pip-installed, orTHREATLENS_CONF), whatever the current directory. Runthreatlens-calibrateonce to tune its[llm]section for your Ollama server.
Synopsis
threatlens SOURCE [--engine {llm,python}] [--config FILE]
[--ignore-calibration] [--accepted-only] [-o FILE] [-v|-vv|-vvv]
Arguments
| Argument | Default | Effect |
|---|---|---|
SOURCE | required | The report to analyse: .pdf, or text (.txt, .md, .log, .csv, .json, or no extension). Any other extension fails with Unsupported input type. |
--engine llm|python | llm | llm: Ollama-hosted model verifies candidates and the document gets a scope profile. python: local heuristics only, no LLM or network. |
--config FILE | The usual threatlens.conf | llm engine only. A threatlens.conf-format file to read settings from instead. A file named here must exist. |
--ignore-calibration | off | llm engine only. Use the built-in [llm] tuning instead of the file's. Its Ollama URL and models still apply. |
--accepted-only | off | The JSON indicators list keeps only confirmed_ioc and context_entity candidates, dropping rejected and uncertain. The summary counts are not affected. |
-o, --output FILE | none | Write the full result as JSON to FILE. Without it, only the summary is printed. |
-v, -vv, -vvv | quiet | Progress detail on stderr. -v: stages, scope and LLM batch progress. -vv: each candidate as it is verified. -vvv: raw Ollama request/response debug. |
-h, --help | Print usage and exit. | |
--version | Print the installed version (e.g. threatlens 0.1.0) and exit. Every threatlens* command has it. |
Where the LLM settings come from
One file, threatlens.conf; built-in defaults (ThreatLensConfig / LLMSettings in config.py) fill anything it leaves out:
| Section | Supplies | Built-in default |
|---|---|---|
[ollama] | Ollama URL | http://127.0.0.1:11434 |
[models] | Verifier and profiler models | qwen3.5:4b; profiler = verifier |
[llm] | Performance tuning: batch_size, timeout, num_ctx, num_predict, context_chars, max_retries | 5, 600s, 4096, 384, 300, 3 |
threatlens-calibrate benchmarks the server and writes [ollama], the verifier model, [llm] and a [calibration] record of what it measured, keeping every other section. The dashboard's Configuration page edits the URL and models and keeps [llm].
The summary line model=… shows which verifier model was actually used.
This replaces the earlier separate llm_calibration.conf: its
[llm] keys now live in threatlens.conf, and its
model and URL are the [models] verifier and
[ollama] url values, so there is one file and one lookup.
--calibration-config became --config.
Setting up: threatlens init
pip cannot ask questions while it installs, so this is the first command
to run afterwards (also threatlens-init). It asks for each
setting, with the current threatlens.conf values as the
defaults when the file exists, so re-running it to change one thing is
quick:
- Data folder for databases and reports (default by layout); checked for write access.
- LLM engine? Answering no skips the Ollama questions; the python engine needs none.
- Ollama URL, tested live. If unreachable: retry, keep it anyway, or skip LLM setup.
- Verifier and profiler models, chosen from the models the server has pulled (or typed). The default is the configured model, else
qwen3.5:9bif pulled, else the first one listed. - Calibrate now? Offered when the chosen model is available; runs
threatlens-calibrate's benchmark at the end. - Database file name, and whether to create or upgrade it now.
- Dashboard address: this machine only (
127.0.0.1, default) or the local network (0.0.0.0, after a warning, since the dashboard has no login), and the port. - Quality weights, only if asked; re-asked until they sum to 1.00.
A summary is shown and nothing is written until it is confirmed. Then the
wizard keeps the previous file as threatlens.conf.bak, writes
the new one with config.save_config() (as the dashboard and
calibration do; [llm] and [calibration] are kept
unless it calibrates), creates or upgrades the database with the shipped
migrations, fills its reference tables only if they are empty (so it is
safe on a database in use), calibrates if chosen, and lists anything still
to do. A file that cannot be read can be replaced from the defaults, with
the old one kept.
threatlens init
threatlens init --non-interactive --ollama-url http://host:11434 --verifier qwen3.5:9b
threatlens init --non-interactive --data-dir /srv/threatlens --host 0.0.0.0 --port 8080 --no-database
--non-interactive asks nothing: it starts from the current
file (or the defaults), applies --data-dir,
--ollama-url, --verifier, --profiler,
--database, --host, --port, and creates
the database unless --no-database; --calibrate
also benchmarks. Without it, threatlens init refuses to run
when input is not a terminal.
First run: threatlens (llm engine),
threatlens-ingest and threatlens-dashboard offer to
run the wizard when there is no threatlens.conf and they are
running in a terminal. Answering no continues on the built-in defaults.
Jobs started by the dashboard, scripts and pipes are never prompted, and
THREATLENS_NO_SETUP=1 turns the offer off.
Setup check
config.setup_status() reports how far
threatlens.conf is from set up. threatlens (llm
engine), threatlens-ingest and the dashboard's Ingest worker
([WARN] log lines), threatlens-dashboard at
startup (SETUP: lines) and the dashboard's Configuration and
Ingest pages all warn from it. Nothing is blocked; built-in defaults fill
the gaps meanwhile.
| State | Detected by | Cleared by |
|---|---|---|
| Missing | No file at the configured path | threatlens init, threatlens-calibrate --save or saving from the Configuration page (each creates it); in a checkout also cp threatlens.conf.example threatlens.conf |
| Unedited template | The file still carries the template's marker comment (# threatlens: unedited template) and the template's values | threatlens init, changing any value, deleting the marker line, or any save from the Configuration page or calibration (none of them write the marker) |
| Not calibrated | No [calibration] record and [llm] still at the built-in values | threatlens-calibrate, or tuning [llm] by hand. Only reported where the llm engine is used. |
| Invalid | The file cannot be parsed or fails validation | Fixing the value named in the message |
threatlens.conf.example is generated from
config.render(TEMPLATE, template=True), and a test fails if the
two drift apart.
Where files live
threatlens/paths.py resolves every location, so the same
commands work from a repo checkout and from a pip install. The layout is
detected automatically:
Checkout (incl. pip install -e .) | pip install (Linux; see below for others) | |
|---|---|---|
threatlens.conf | repo root | ~/.config/threatlens/threatlens.conf |
| Databases | db/ | ~/.local/share/threatlens/db/ |
| Report PDFs (uploads, downloads, ingest) | docs/database/CTI/ | ~/.local/share/threatlens/reports/ |
On Windows, config and data share one folder,
%LOCALAPPDATA%\threatlens (e.g.
C:\Users\you\AppData\Local\threatlens, holding
threatlens.conf, db\ and reports\); on
macOS both are ~/Library/Application Support/threatlens. Overrides, highest
first: THREATLENS_CONF for the config file;
THREATLENS_HOME, then [paths] data_dir in
threatlens.conf, for the data folder (which then holds
db/ and reports/). Report paths stored in the
database are relative to the data folder, so a checkout's existing
docs/database/CTI/… rows still resolve. The dashboard's
Configuration page shows the folders in use.
Calibrating the LLM settings: threatlens-calibrate
Also python -m threatlens.extractors.calibration. It warms
the model, times structured verification requests at several batch sizes,
picks the smallest batch within 8% of the best throughput, labels the
hardware (cpu-minimal … gpu-high) and suggests
[llm] values. Saved, it writes [ollama] url,
[models] verifier, [llm] and
[calibration] into threatlens.conf and keeps every
other section. An existing file that cannot be read is reported before any
benchmarking and is never overwritten.
| Option | Default | Effect |
|---|---|---|
--model | [models] verifier | Model to benchmark |
--ollama-url / --ollama-ip + --ollama-port | [ollama] url | Server to benchmark (port default 11434) |
--config FILE | The usual threatlens.conf | File to read and update |
--batch-sizes | 1,3,5,8 | Batch sizes to try |
--repetitions / --attempts | 1 / 3 | Timed runs per size; attempts per run |
--num-ctx / --num-predict / --timeout | 4096 / 2048 / 300 | Benchmark request settings |
--save / --no-save | prompt when interactive | Save without asking, or never save |
threatlens-calibrate # re-tune the configured model and server
threatlens-calibrate --model qwen3.5:9b --ollama-url http://host:11434 --save
For a setup script, calibrate() and
save_calibration() in extractors/calibration.py do
the same without the command line. pip cannot run calibration while
installing (it never prompts and needs a running Ollama), so it is a step
after installation.
Examples
# Summary only, default llm engine
threatlens docs/database/CTI/report.pdf
# No LLM available: heuristic verifier
threatlens report.pdf --engine python
# Full result as JSON, with batch progress
threatlens report.pdf -o out/report.llm.json -v
# Only accepted candidates in the JSON
threatlens report.pdf -o out/report.accepted.json --accepted-only
# One-off settings file, or built-in [llm] tuning
threatlens report.pdf --config ~/threatlens.low_cpu.conf
threatlens report.pdf --ignore-calibration
Console output
From one of the evaluation corpus reports with the python engine:
$ threatlens docs/database/CTI/fdf53fac0af9cced76179fb1b9df0ce009952e36.pdf --engine python -o py.json
metadata: Skia/PDF m90, published 2022-02-21, 0 author(s)
extract: 3 candidates from fdf53fac0af9cced76179fb1b9df0ce009952e36.pdf (python engine, model=python-heuristics)
verify: 3 confirmed, 0 context, 0 rejected
output: py.json
The llm engine adds a fourth line, for example
scope: tactical (tactical=True, operational=False, strategic=False).
The verify line does not count uncertain verdicts;
the JSON summary does.
JSON output
| Key | Contents |
|---|---|
metadata | filename, title, authors, cti_source, source_url, publish_date, file_sha256, and extraction_method (which heuristic found each field) |
verification_model | The verifier model, or python-heuristics |
scope | llm engine: primary_scope, reason, structure_summary and the three has_*_content flags. null for the python engine. |
summary | Counts: candidates, confirmed, context, rejected, uncertain |
indicators | One entry per candidate (see below) |
accepted_by_category | Accepted candidates grouped by network, host, email, behaviour, intelligence |
One indicators entry, abridged:
{
"id": "ba27359faf1a32066466",
"category": "network",
"indicator_type": "url",
"value": "blog.alyac.co.kr/4501",
"normalized_value": "blog.alyac.co.kr/4501",
"start": 119,
"end": 140,
"context": "… customer center! blog.alyac.co.kr/4501 Detailed content body …",
"method": "regex:schemeless-url",
"attributes": { "was_defanged": false, "document_section_hint": "…" },
"verification": {
"candidate_id": "ba27359faf1a32066466",
"status": "confirmed_ioc",
"confidence": 0.76,
"reason": "Candidate appears in malicious CTI context but lacks stronger structural evidence.",
"evidence": "nearby_label=ESRC | …",
"corrected_category": "network",
"corrected_indicator_type": "url"
},
"accepted": true
}
start/end are character offsets into the extracted
text, and context is the window either side of the match that the
verifier saw. verification.confidence is confidence in the
verdict, not in maliciousness. accepted is true for
confirmed_ioc and context_entity.
Chaining into the quality checker
The quality checker reads these JSON files directly, so the QA stage can be inspected without a database too:
threatlens report.pdf -o llm.json
threatlens report.pdf --engine python -o python.json
python -m threatlens.quality.quality_checker llm.json --show all
python -m threatlens.quality.quality_checker llm.json --compare python.json
--compare enables the cross-verifier agreement grading. Both
files must come from the same report.
Exit codes and errors
| Exit | When |
|---|---|
0 | Extraction finished (and the JSON was written, if asked for) |
1 | Source file missing; unsupported file type; unreadable or invalid threatlens.conf, or a --config file that does not exist; Ollama unreachable or still failing after retries; PDF support (PyMuPDF) missing |
2 | Invalid arguments (argparse) |