From 50efd374d4118b520393574fd0d84d8a90d3b7a1 Mon Sep 17 00:00:00 2001 From: Matthew Charles Busel Date: Fri, 25 Sep 2026 15:15:05 -0400 Subject: [PATCH] Detect low-confidence spans from token logprobs; live mode; release workflow Adds the detector the repo name promises: --logprobs-file reads a Chat Completions response and flags words whose token probability is below --threshold, reporting the alternatives the model weighed. --live calls any OpenAI-compatible API using OPENAI_API_KEY. Bundles four real Llama 3.1 8B responses so the demo runs offline, adds --format json and --fail-on-flag, a release workflow for Linux/macOS/Windows binaries, MIT license metadata, and fixes TerminalRenderer printing to stdout as a side effect. --- .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 57 +++ CHANGELOG.md | 15 + CONTRIBUTING.md | 42 ++- Cargo.lock | 510 +++++++++++++++++++++++++- Cargo.toml | 15 +- README.md | 137 +++++-- examples/detect.rs | 26 ++ examples/logprobs/cuyp.json | 1 + examples/logprobs/eiffel.json | 1 + examples/logprobs/moonwalk.json | 1 + examples/logprobs/tour-de-france.json | 1 + src/detect.rs | 399 ++++++++++++++++++++ src/lib.rs | 3 + src/live.rs | 61 +++ src/main.rs | 190 ++++++++-- src/renderer.rs | 1 - 17 files changed, 1373 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 examples/detect.rs create mode 100644 examples/logprobs/cuyp.json create mode 100644 examples/logprobs/eiffel.json create mode 100644 examples/logprobs/moonwalk.json create mode 100644 examples/logprobs/tour-de-france.json create mode 100644 src/detect.rs create mode 100644 src/live.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fb217e..5adad75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,5 +16,11 @@ jobs: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - - run: cargo clippy -- -D warnings + - run: cargo clippy --all-targets -- -D warnings + - run: cargo clippy --all-targets --no-default-features -- -D warnings - run: cargo test + - name: Smoke test the offline detector on a bundled sample + run: | + cargo run -q -- --logprobs-file examples/logprobs/cuyp.json --threshold 0.6 --format json | tee report.json + grep -q '"text": " Dordrecht"' report.json + cargo run -q --example detect diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fcfdb11 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release +on: + push: + tags: [ "v*" ] +permissions: + contents: write +env: + CARGO_TERM_COLOR: always + BIN: llm-token-visualizer +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu } + - { os: macos-latest, target: aarch64-apple-darwin } + - { os: macos-latest, target: x86_64-apple-darwin } + - { os: windows-latest, target: x86_64-pc-windows-msvc } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - run: cargo build --release --locked --target ${{ matrix.target }} + - name: Package + shell: bash + run: | + name="$BIN-${GITHUB_REF_NAME}-${{ matrix.target }}" + mkdir -p "dist/$name" + if [ "${{ runner.os }}" = "Windows" ]; then exe="$BIN.exe"; else exe="$BIN"; fi + cp "target/${{ matrix.target }}/release/$exe" README.md LICENSE "dist/$name/" + cp -r examples/logprobs "dist/$name/samples" + cd dist + if [ "${{ runner.os }}" = "Windows" ]; then 7z a "$name.zip" "$name" >/dev/null; else tar czf "$name.tar.gz" "$name"; fi + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: dist/*.*z* + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Checksums + run: cd dist && sha256sum * > SHA256SUMS.txt + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..64d9200 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.2.0 (2026-09-25) + +- New: hallucination-risk detection from token logprobs. `--logprobs-file` reads a Chat Completions response (or its `logprobs` block, or a bare token array) and flags words below `--threshold` (default 0.5), with the alternatives the model considered. +- New: `--live ""` calls any OpenAI-compatible API (`OPENAI_API_KEY`, optional `OPENAI_BASE_URL`, `--model`, `--save`). Behind the default `live` feature. +- New: `--format json` report and `--fail-on-flag` (exit status 2) for scripts and CI. +- New: four real Llama 3.1 8B sample responses in `examples/logprobs/` and `cargo run --example detect`. +- Fix: `TerminalRenderer::render` no longer prints to stdout itself, so library callers of `visualize_tokens(.., "terminal", ..)` control output. The CLI prints the returned string. +- Release workflow builds Linux, macOS and Windows binaries on tag push. +- Repo: build output (`target/`) is no longer tracked; sketch directories renamed. + +## 0.1.0 + +- Token confidence visualizer: terminal, HTML and Markdown renderers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f5bd52..63ec631 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,22 +1,34 @@ -# Contributing to LLM-Hallucination-Detection-Script +# Contributing -Thanks for your interest. Contributions that improve detection accuracy, add new methods, extend provider integrations, or add test coverage are all welcome. +Thanks for helping. Small, focused PRs are easiest to review. -## What we want +## Setup -- **New detection methods** -- additional heuristics or ML-based approaches -- **Provider integrations** -- examples using Mistral, Gemini, local models -- **Rust MVP improvements** -- better streaming detection, new dashboard features -- **Bug fixes** -- correctness issues, edge cases, false positive reduction -- **Tests** -- more labeled examples, accuracy benchmarks +You need a stable Rust toolchain. -## How to contribute +```bash +git clone https://github.com/Mattbusel/LLM-Hallucination-Detection-Script +cd LLM-Hallucination-Detection-Script +cargo test +cargo run -- --logprobs-file examples/logprobs/cuyp.json --threshold 0.6 +``` -1. Fork and clone -2. Python: `python -m pytest tests/` must pass -3. Rust: `cargo test --all` must pass -4. Open a PR with a description of the change and why +## Before you open a PR -## Questions +CI runs these; run them locally first: -Open a [Discussion](https://github.com/Mattbusel/LLM-Hallucination-Detection-Script/discussions) for design questions or ideas before building. +```bash +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo clippy --all-targets --no-default-features -- -D warnings +cargo test +cargo run --example detect +``` + +## Good places to start + +- Detection logic lives in `src/detect.rs`, with unit tests at the bottom of the file. New heuristics should come with a test and, ideally, a real sample response in `examples/logprobs/`. +- Sample responses must be real API output (say which model and provider in the PR). Do not hand-edit logprobs. +- `rust_mvps/` is design sketches that are not built. Turning one into a working crate is welcome, but open an issue first. + +Issues labelled `good first issue` are sized for a first PR. diff --git a/Cargo.lock b/Cargo.lock index adc188c..56c29a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "0.6.21" @@ -58,12 +64,28 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -101,7 +123,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -126,6 +148,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -148,12 +190,49 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -188,12 +267,116 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -242,9 +425,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "llm-token-visualizer" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -253,6 +442,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror", + "ureq", ] [[package]] @@ -267,6 +457,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -279,6 +479,21 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -286,7 +501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -313,6 +528,20 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -326,6 +555,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "semver" version = "1.0.27" @@ -359,7 +623,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -375,12 +639,42 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -392,6 +686,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -399,7 +715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -422,7 +738,17 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] @@ -437,12 +763,58 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -495,12 +867,39 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -613,7 +1012,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -629,7 +1028,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -671,6 +1070,101 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 5e0d7fe..8f5a201 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,19 @@ [package] name = "llm-token-visualizer" -version = "0.1.0" +version = "0.2.0" edition = "2021" +description = "Flag low-confidence spans in LLM output from token logprobs, and render per-token confidence as terminal, HTML or Markdown" +license = "MIT" +repository = "https://github.com/Mattbusel/LLM-Hallucination-Detection-Script" +readme = "README.md" +keywords = ["llm", "hallucination", "logprobs", "confidence", "cli"] +categories = ["command-line-utilities", "text-processing"] + +[features] +default = ["live"] +# Live mode: call an OpenAI-compatible API. Disable with --no-default-features +# for an offline-only build without an HTTP client. +live = ["dep:ureq"] [dependencies] serde = { version = "1.0", features = ["derive"] } @@ -10,6 +22,7 @@ clap = { version = "4.0", features = ["derive"] } colored = "2.0" anyhow = "1.0" thiserror = "1.0" +ureq = { version = "2", optional = true } [dev-dependencies] tempfile = "3.0" diff --git a/README.md b/README.md index 5d71d70..ad93889 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,103 @@ -# LLM Hallucination Detector: token confidence visualizer +# LLM Hallucination Detector [![CI](https://github.com/Mattbusel/LLM-Hallucination-Detection-Script/actions/workflows/ci.yml/badge.svg)](https://github.com/Mattbusel/LLM-Hallucination-Detection-Script/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A Rust CLI and library that shows an LLM response token by token, colored by the model's confidence, with labeled spans for facts, uncertain claims and likely hallucinations. Output goes to the terminal, a standalone HTML report, or Markdown. +A Rust CLI and library that reads an LLM answer's token log probabilities and flags the words the model was unsure about, with the alternatives it was weighing at that point. It can also render per-token confidence as colored terminal output, a standalone HTML report, or Markdown. -Hallucinations tend to hide in fluent text. When you already have per-token confidence (for example from an API's logprobs) the fastest way to spot a shaky claim is to look at where confidence drops. This tool turns that JSON into something you can read at a glance and paste into a review, a bug report or a PR. +Hallucinations often sit where the model's confidence drops: a name, a date, a city it half-remembers. If your API returns `logprobs` (OpenAI and many OpenAI-compatible servers do), this tool shows you those spots in one command. -## What it does +```text +$ llm-token-visualizer --logprobs-file examples/logprobs/cuyp.json --threshold 0.6 +... +Aelbert Cuyp died in 1691 in Dordrecht, Netherlands. -- Renders every token with a five-band confidence color scale (very low < 0.3, low < 0.5, medium < 0.7, high < 0.9, very high). -- Highlights flagged spans (`fact`, `uncertain`, `hallucination`, or any label you choose) with a description. -- Three renderers behind one `Renderer` trait: `TerminalRenderer` (ANSI colors), `HtmlRenderer` (self-contained page), `MarkdownRenderer` (emoji legend, works in GitHub comments). -- Summary metrics: token count, min/max/average confidence, low-confidence and flagged token counts. -- Simple issue detection: very low confidence tokens and sudden confidence dips between neighbours. -- Built-in demo so you can see the output without any data. + [uncertain] 'Aelbert' (tokens 0-2): p=0.49 at "A"; model also considered "The" (0.43), "D" (0.08) + [uncertain] ' Dordrecht' (tokens 11-13): p=0.57 at "ord"; model also considered "üsseldorf" (0.39), "elf" (0.03) -It does not call a model and does not produce confidence scores itself. You bring the scores; it visualizes and summarizes them. +2 low-confidence span(s) at threshold 0.60 (17 tokens, mean p=0.88). +Check these claims before trusting the answer. +``` -## Quick start +That is a real response from Llama 3.1 8B Instruct. The answer happens to be right, but the model gave Düsseldorf a 39% chance, which is exactly the kind of claim to double-check. The "Aelbert" flag is the model choosing between starting with the name or with "The": low probability can be about phrasing, not facts. + +## Install + +Prebuilt binaries for Linux, macOS (Intel and Apple Silicon) and Windows are attached to each [GitHub Release](https://github.com/Mattbusel/LLM-Hallucination-Detection-Script/releases/latest). Download the archive for your platform, unpack it, and run `llm-token-visualizer` (the archive includes the sample responses under `samples/`). + +Or build from source with a Rust toolchain: -Requires a Rust toolchain. +```bash +cargo install --git https://github.com/Mattbusel/LLM-Hallucination-Detection-Script +``` + +## Quick start ```bash git clone https://github.com/Mattbusel/LLM-Hallucination-Detection-Script cd LLM-Hallucination-Detection-Script -# Built-in demo in the terminal +# Offline: analyze a bundled real response, no API key needed +cargo run -- --logprobs-file examples/logprobs/cuyp.json --threshold 0.6 + +# All four bundled samples, through the library API +cargo run --example detect + +# Machine-readable report +cargo run -- --logprobs-file examples/logprobs/cuyp.json --format json + +# Live: ask a model and analyze its answer (any OpenAI-compatible API) +export OPENAI_API_KEY=sk-... +cargo run -- --live "Who was the second person to walk on the Moon?" --save answer.json +``` + +### How detection works + +1. Each token's probability is `exp(logprob)`. +2. Tokens are grouped into words (a subword like `ord` in `Dordrecht` belongs to its word). +3. A word is flagged when any of its tokens with letters or digits has probability below `--threshold` (default `0.5`, meaning the model put more weight on other options than on the one it picked). Pure punctuation and whitespace never trigger a flag. +4. Neighbouring flagged words merge into one span. Each span reports its weakest token and the `top_logprobs` alternatives at that token. + +Raise the threshold to catch more (and noisier) spans, lower it to see only the shakiest ones. + +### Input + +`--logprobs-file` accepts a full Chat Completions response saved as JSON, just its `logprobs` object (`{"content": [...]}`), or a bare array of `{"token", "logprob", "top_logprobs"}` entries. Use `-` to read stdin. Request completions with `"logprobs": true` and, for alternatives, `"top_logprobs": 3` or more. Special tokens such as `<|eot_id|>` are ignored. + +### Live mode + +`--live ""` sends the prompt with temperature 0, `logprobs: true` and `top_logprobs: 3`, then analyzes the answer. + +| Variable | Default | Meaning | +|---|---|---| +| `OPENAI_API_KEY` | required | Bearer token for the API | +| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Any server with Chat Completions and logprobs, e.g. `https://router.huggingface.co/v1` | + +Flags: `--model` (default `gpt-4o-mini`), `--max-tokens` (default 200), and `--save ` to keep the raw response so you can re-run it offline with `--logprobs-file`. Anthropic's API does not return logprobs, so Claude models cannot be analyzed this way. + +The bundled samples were fetched through the Hugging Face router (`meta-llama/Llama-3.1-8B-Instruct:novita`). Build with `--no-default-features` for an offline-only binary without an HTTP client. + +### Use it in CI or scripts + +`--fail-on-flag` exits with status 2 when any span is flagged, so you can gate a pipeline on it or route flagged answers to review: + +```bash +llm-token-visualizer --logprobs-file answer.json --fail-on-flag --format json -o report.json +``` + +## What it cannot tell you + +Low token probability is a useful signal, not a fact checker. The bundled `moonwalk.json` sample shows the failure mode: the model answers "Pete Conrad was the second person to walk on the Moon" (it was Buzz Aldrin) with at least 72% probability on every token of the name, so the wrong name is not flagged; only phrasing words like "which" are. Models can be confidently wrong. Use this to decide where to look first, not to certify an answer. + +## Visualizing your own confidence scores + +If you already have per-token scores from another source, the visualizer mode renders them with a five-band color scale (very low < 0.3, low < 0.5, medium < 0.7, high < 0.9, very high) and labeled spans (`fact`, `uncertain`, `hallucination`, or any label): + +```bash +# Built-in demo (hand-written scores) cargo run -- --demo -# Your own data: a text file plus a confidence JSON file (samples included) +# A text file plus a confidence JSON file (samples included) cargo run -- --text-file sample_text.txt --confidence-file demo_data.json # HTML report @@ -38,9 +107,7 @@ cargo run -- --text-file sample_text.txt --confidence-file demo_data.json --form cargo run -- --text-file sample_text.txt --confidence-file demo_data.json --format markdown --verbose ``` -CLI flags: `--text` / `--text-file`, `--confidence` / `--confidence-file`, `--format terminal|html|markdown`, `--output `, `--verbose`, `--demo`. - -### Input format +Confidence file format: ```json { @@ -55,45 +122,57 @@ CLI flags: `--text` / `--text-file`, `--confidence` / `--confidence-file`, `--fo } ``` -`start` is inclusive and `end` is exclusive, both as token indices. `demo_data.json` is a full example that pairs with `sample_text.txt`. +`start` is inclusive and `end` is exclusive, both as token indices. ### Library use The crate is named `llm-token-visualizer`. ```rust -use llm_token_visualizer::{visualize_tokens, analyze_with_issues, quick_analyze, TokenAnalysis}; +use llm_token_visualizer::detect::{detect, parse_logprobs, to_token_analysis}; +use llm_token_visualizer::visualize_tokens; -let analysis: TokenAnalysis = serde_json::from_str(&json)?; -let html = visualize_tokens(&text, &analysis, "html", None)?; - -let (metrics, issues) = analyze_with_issues(&analysis); -println!("avg confidence {:.2}, {} issues", metrics.avg_confidence, issues.len()); +let tokens = parse_logprobs(&response_json)?; +let report = detect(&tokens, 0.5); +for span in &report.spans { + println!("{}: {}", span.text, span.describe()); +} -// Quick look with deterministic placeholder scores (no real confidence data) -let md = quick_analyze("Some text to try", "markdown")?; +// Render the same result as HTML +let analysis = to_token_analysis(&tokens, &report); +let html = visualize_tokens(&report.text, &analysis, "html", None)?; ``` +`analyze_with_issues` and `quick_analyze` are still available. + ## Repository layout ``` src/ main.rs CLI (clap) lib.rs public API: visualize_tokens, quick_analyze, analyze_with_issues + detect.rs logprob parsing and low-confidence span detection + live.rs OpenAI-compatible API client for --live (feature "live") data.rs TokenAnalysis, TokenInfo, TokenFlag, ConfidenceLevel renderer.rs Terminal, HTML and Markdown renderers utils.rs tokenizer for demos, metrics, issue detection -demo_data.json, sample_text.txt example input +examples/logprobs/ real Llama 3.1 8B responses with logprobs +examples/detect.rs library example over those samples +demo_data.json, sample_text.txt example input for the visualizer rust_mvps/ design sketches, see below real-time fact-checking DAG engine.cpp standalone C++ sketch, see below ``` ## Status and limitations -The Rust visualizer in `src/` is the working part of this repo: it builds, has unit tests, and CI runs fmt, clippy and tests on every push. +The Rust crate in `src/` is the working part of this repo: it builds, has unit tests, and CI runs fmt, clippy, tests and a detector smoke test on every push. The rest is exploratory and should be read as design notes, not shipped features: - `rust_mvps/` holds source sketches for a BERT-based detector (candle), multi-language phrase patterns, a streaming detector with a WebSocket server, and a web dashboard. They have no Cargo manifests and are not wired into the build. The neural detector expects model weights that are not published. - `real-time fact-checking DAG engine.cpp` is a single-file C++ sketch of a Boost Graph based fact graph. It is not part of any build here and needs Boost to compile. - Earlier versions of this README described a Python `hallucination_detector.py` module. That file is not in the repository, so its documentation has been removed. + +## License + +MIT, see [LICENSE](LICENSE). diff --git a/examples/detect.rs b/examples/detect.rs new file mode 100644 index 0000000..262b20d --- /dev/null +++ b/examples/detect.rs @@ -0,0 +1,26 @@ +//! Run the detector over every bundled sample response. +//! +//! cargo run --example detect +//! +//! The samples in examples/logprobs/ are real responses from +//! meta-llama/Llama-3.1-8B-Instruct (temperature 0, top_logprobs 3). + +use llm_token_visualizer::detect::{detect, parse_logprobs}; + +fn main() -> anyhow::Result<()> { + let threshold = 0.6; + for name in ["cuyp", "tour-de-france", "eiffel", "moonwalk"] { + let path = format!("examples/logprobs/{name}.json"); + let tokens = parse_logprobs(&std::fs::read_to_string(&path)?)?; + let report = detect(&tokens, threshold); + println!("{path}\n answer: {}", report.text.trim()); + if report.spans.is_empty() { + println!(" no spans below p={threshold}"); + } + for span in &report.spans { + println!(" flagged {:?}: {}", span.text.trim(), span.describe()); + } + println!(); + } + Ok(()) +} diff --git a/examples/logprobs/cuyp.json b/examples/logprobs/cuyp.json new file mode 100644 index 0000000..e162cf8 --- /dev/null +++ b/examples/logprobs/cuyp.json @@ -0,0 +1 @@ +{"id":"6f6cea2a43164b753b142f229f4966e6","object":"chat.completion","created":1790363150,"model":"meta-llama/llama-3.1-8b-instruct","choices":[{"index":0,"message":{"role":"assistant","content":"Aelbert Cuyp died in 1691 in Dordrecht, Netherlands."},"finish_reason":"stop","logprobs":{"content":[{"token":"A","logprob":-0.7139701247215271,"bytes":[65],"top_logprobs":[{"token":"A","logprob":-0.7139701247215271,"bytes":[65]},{"token":"The","logprob":-0.8389701247215271,"bytes":[84,104,101]},{"token":"D","logprob":-2.588970184326172,"bytes":[68]}]},{"token":"el","logprob":-0.000789715675637126,"bytes":[101,108],"top_logprobs":[{"token":"el","logprob":-0.000789715675637126,"bytes":[101,108]},{"token":" Dutch","logprob":-7.500789642333984,"bytes":[32,68,117,116,99,104]},{"token":"e","logprob":-9.875789642333984,"bytes":[101]}]},{"token":"bert","logprob":-2.3841860752327193e-7,"bytes":[98,101,114,116],"top_logprobs":[{"token":"bert","logprob":-2.3841860752327193e-7,"bytes":[98,101,114,116]},{"token":"ert","logprob":-15.875,"bytes":[101,114,116]},{"token":"BERT","logprob":-16.25,"bytes":[66,69,82,84]}]},{"token":" Cu","logprob":-0.007049074396491051,"bytes":[32,67,117],"top_logprobs":[{"token":" Cu","logprob":-0.007049074396491051,"bytes":[32,67,117]},{"token":" Jacobs","logprob":-5.132049083709717,"bytes":[32,74,97,99,111,98,115]},{"token":" Jacob","logprob":-7.882049083709717,"bytes":[32,74,97,99,111,98]}]},{"token":"yp","logprob":-0.00007593919872306287,"bytes":[121,112],"top_logprobs":[{"token":"yp","logprob":-0.00007593919872306287,"bytes":[121,112]},{"token":"yps","logprob":-9.750076293945312,"bytes":[121,112,115]},{"token":"yl","logprob":-11.125076293945312,"bytes":[121,108]}]},{"token":" died","logprob":-0.41965728998184204,"bytes":[32,100,105,101,100],"top_logprobs":[{"token":" died","logprob":-0.41965728998184204,"bytes":[32,100,105,101,100]},{"token":",","logprob":-1.1696572303771973,"bytes":[44]},{"token":" passed","logprob":-3.7946572303771973,"bytes":[32,112,97,115,115,101,100]}]},{"token":" in","logprob":-0.005920137278735638,"bytes":[32,105,110],"top_logprobs":[{"token":" in","logprob":-0.005920137278735638,"bytes":[32,105,110]},{"token":" on","logprob":-5.630919933319092,"bytes":[32,111,110]},{"token":" ","logprob":-6.505919933319092,"bytes":[32]}]},{"token":" ","logprob":-0.025677859783172607,"bytes":[32],"top_logprobs":[{"token":" ","logprob":-0.025677859783172607,"bytes":[32]},{"token":" D","logprob":-3.7756779193878174,"bytes":[32,68]},{"token":" the","logprob":-6.400677680969238,"bytes":[32,116,104,101]}]},{"token":"169","logprob":-0.011953886598348618,"bytes":[49,54,57],"top_logprobs":[{"token":"169","logprob":-0.011953886598348618,"bytes":[49,54,57]},{"token":"166","logprob":-4.511953830718994,"bytes":[49,54,54]},{"token":"179","logprob":-7.886953830718994,"bytes":[49,55,57]}]},{"token":"1","logprob":-0.0023607441689819098,"bytes":[49],"top_logprobs":[{"token":"1","logprob":-0.0023607441689819098,"bytes":[49]},{"token":"2","logprob":-6.127360820770264,"bytes":[50]},{"token":"0","logprob":-9.502360343933105,"bytes":[48]}]},{"token":" in","logprob":-0.09912583231925964,"bytes":[32,105,110],"top_logprobs":[{"token":" in","logprob":-0.09912583231925964,"bytes":[32,105,110]},{"token":",","logprob":-2.474125862121582,"bytes":[44]},{"token":" at","logprob":-5.474125862121582,"bytes":[32,97,116]}]},{"token":" D","logprob":-0.015349467284977436,"bytes":[32,68],"top_logprobs":[{"token":" D","logprob":-0.015349467284977436,"bytes":[32,68]},{"token":" Meg","logprob":-6.452849388122559,"bytes":[32,77,101,103]},{"token":" N","logprob":-6.952849388122559,"bytes":[32,78]}]},{"token":"ord","logprob":-0.5619649887084961,"bytes":[111,114,100],"top_logprobs":[{"token":"ord","logprob":-0.5619649887084961,"bytes":[111,114,100]},{"token":"üsseldorf","logprob":-0.9369649291038513,"bytes":[195,188,115,115,101,108,100,111,114,102]},{"token":"elf","logprob":-3.561964988708496,"bytes":[101,108,102]}]},{"token":"recht","logprob":-0.000378322642063722,"bytes":[114,101,99,104,116],"top_logprobs":[{"token":"recht","logprob":-0.000378322642063722,"bytes":[114,101,99,104,116]},{"token":"t","logprob":-8.500378608703613,"bytes":[116]},{"token":"trecht","logprob":-8.875378608703613,"bytes":[116,114,101,99,104,116]}]},{"token":",","logprob":-0.48486417531967163,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.48486417531967163,"bytes":[44]},{"token":".","logprob":-0.9848642349243164,"bytes":[46]},{"token":" city","logprob":-5.359864234924316,"bytes":[32,99,105,116,121]}]},{"token":" Netherlands","logprob":-0.22782719135284424,"bytes":[32,78,101,116,104,101,114,108,97,110,100,115],"top_logprobs":[{"token":" Netherlands","logprob":-0.22782719135284424,"bytes":[32,78,101,116,104,101,114,108,97,110,100,115]},{"token":" a","logprob":-2.227827310562134,"bytes":[32,97]},{"token":" the","logprob":-2.8528270721435547,"bytes":[32,116,104,101]}]},{"token":".","logprob":-0.015469801612198353,"bytes":[46],"top_logprobs":[{"token":".","logprob":-0.015469801612198353,"bytes":[46]},{"token":",","logprob":-4.640469551086426,"bytes":[44]},{"token":"<|eot_id|>","logprob":-5.640469551086426,"bytes":[60,124,101,111,116,95,105,100,124,62]}]},{"token":"<|eot_id|>","logprob":-0.00023147162573877722,"bytes":[60,124,101,111,116,95,105,100,124,62],"top_logprobs":[{"token":"<|eot_id|>","logprob":-0.00023147162573877722,"bytes":[60,124,101,111,116,95,105,100,124,62]},{"token":" ","logprob":-8.875231742858887,"bytes":[32]},{"token":" \n\n","logprob":-10.562731742858887,"bytes":[32,10,10]}]}]}}],"usage":{"prompt_tokens":56,"completion_tokens":18,"total_tokens":74,"prompt_tokens_details":null,"completion_tokens_details":null},"system_fingerprint":""} diff --git a/examples/logprobs/eiffel.json b/examples/logprobs/eiffel.json new file mode 100644 index 0000000..714cd5a --- /dev/null +++ b/examples/logprobs/eiffel.json @@ -0,0 +1 @@ +{"id":"26d5cd199b8ac9f9367108ce9435d513","object":"chat.completion","created":1790363138,"model":"meta-llama/llama-3.1-8b-instruct","choices":[{"index":0,"message":{"role":"assistant","content":"The Eiffel Tower was designed by Gustave Eiffel and his engineering company, Compagnie des Établissements Eiffel, and it opened on March 31, 1889, during the World's Fair in Paris, France."},"finish_reason":"stop","logprobs":{"content":[{"token":"The","logprob":-0.0115409130230546,"bytes":[84,104,101],"top_logprobs":[{"token":"The","logprob":-0.0115409130230546,"bytes":[84,104,101]},{"token":"G","logprob":-4.51154088973999,"bytes":[71]},{"token":"Built","logprob":-8.886541366577148,"bytes":[66,117,105,108,116]}]},{"token":" E","logprob":-0.0030821925029158592,"bytes":[32,69],"top_logprobs":[{"token":" E","logprob":-0.0030821925029158592,"bytes":[32,69]},{"token":" iconic","logprob":-5.878082275390625,"bytes":[32,105,99,111,110,105,99]},{"token":" famous","logprob":-9.628082275390625,"bytes":[32,102,97,109,111,117,115]}]},{"token":"iff","logprob":-2.3841860752327193e-7,"bytes":[105,102,102],"top_logprobs":[{"token":"iff","logprob":-2.3841860752327193e-7,"bytes":[105,102,102]},{"token":"IFF","logprob":-15.5,"bytes":[73,70,70]},{"token":"if","logprob":-17,"bytes":[105,102]}]},{"token":"el","logprob":0,"bytes":[101,108],"top_logprobs":[{"token":"el","logprob":0,"bytes":[101,108]},{"token":"elt","logprob":-17.625,"bytes":[101,108,116]},{"token":"ел","logprob":-18.125,"bytes":[208,181,208,187]}]},{"token":" Tower","logprob":-0.0000027418175250204513,"bytes":[32,84,111,119,101,114],"top_logprobs":[{"token":" Tower","logprob":-0.0000027418175250204513,"bytes":[32,84,111,119,101,114]},{"token":"Tower","logprob":-13.25000286102295,"bytes":[84,111,119,101,114]},{"token":" Iron","logprob":-14.50000286102295,"bytes":[32,73,114,111,110]}]},{"token":" was","logprob":-0.03372574225068092,"bytes":[32,119,97,115],"top_logprobs":[{"token":" was","logprob":-0.03372574225068092,"bytes":[32,119,97,115]},{"token":",","logprob":-3.4087257385253906,"bytes":[44]},{"token":" in","logprob":-9.90872573852539,"bytes":[32,105,110]}]},{"token":" designed","logprob":-0.0001554609916638583,"bytes":[32,100,101,115,105,103,110,101,100],"top_logprobs":[{"token":" designed","logprob":-0.0001554609916638583,"bytes":[32,100,101,115,105,103,110,101,100]},{"token":" primarily","logprob":-10.625155448913574,"bytes":[32,112,114,105,109,97,114,105,108,121]},{"token":" originally","logprob":-10.750155448913574,"bytes":[32,111,114,105,103,105,110,97,108,108,121]}]},{"token":" by","logprob":-0.0013344843173399568,"bytes":[32,98,121],"top_logprobs":[{"token":" by","logprob":-0.0013344843173399568,"bytes":[32,98,121]},{"token":" and","logprob":-6.6263346672058105,"bytes":[32,97,110,100]},{"token":" in","logprob":-12.501334190368652,"bytes":[32,105,110]}]},{"token":" Gust","logprob":-0.5622566938400269,"bytes":[32,71,117,115,116],"top_logprobs":[{"token":" Gust","logprob":-0.5622566938400269,"bytes":[32,71,117,115,116]},{"token":" French","logprob":-0.9372565746307373,"bytes":[32,70,114,101,110,99,104]},{"token":" the","logprob":-3.5622565746307373,"bytes":[32,116,104,101]}]},{"token":"ave","logprob":-0.0000295643403660506,"bytes":[97,118,101],"top_logprobs":[{"token":"ave","logprob":-0.0000295643403660506,"bytes":[97,118,101]},{"token":"av","logprob":-10.500029563903809,"bytes":[97,118]},{"token":"AVE","logprob":-13.250029563903809,"bytes":[65,86,69]}]},{"token":" E","logprob":-0.000005722062269342132,"bytes":[32,69],"top_logprobs":[{"token":" E","logprob":-0.000005722062269342132,"bytes":[32,69]},{"token":" Alexandre","logprob":-13.187505722045898,"bytes":[32,65,108,101,120,97,110,100,114,101]},{"token":"E","logprob":-13.375005722045898,"bytes":[69]}]},{"token":"iff","logprob":-0.00003826691317954101,"bytes":[105,102,102],"top_logprobs":[{"token":"iff","logprob":-0.00003826691317954101,"bytes":[105,102,102]},{"token":"IFF","logprob":-10.250038146972656,"bytes":[73,70,70]},{"token":"iffe","logprob":-13.125038146972656,"bytes":[105,102,102,101]}]},{"token":"el","logprob":0,"bytes":[101,108],"top_logprobs":[{"token":"el","logprob":0,"bytes":[101,108]},{"token":"EL","logprob":-19,"bytes":[69,76]},{"token":"els","logprob":-19.125,"bytes":[101,108,115]}]},{"token":" and","logprob":-0.1305483877658844,"bytes":[32,97,110,100],"top_logprobs":[{"token":" and","logprob":-0.1305483877658844,"bytes":[32,97,110,100]},{"token":",","logprob":-2.6305484771728516,"bytes":[44]},{"token":"'s","logprob":-3.0055482387542725,"bytes":[39,115]}]},{"token":" his","logprob":-0.03354192152619362,"bytes":[32,104,105,115],"top_logprobs":[{"token":" his","logprob":-0.03354192152619362,"bytes":[32,104,105,115]},{"token":" was","logprob":-5.346042156219482,"bytes":[32,119,97,115]},{"token":" opened","logprob":-5.596041679382324,"bytes":[32,111,112,101,110,101,100]}]},{"token":" engineering","logprob":-0.05293126404285431,"bytes":[32,101,110,103,105,110,101,101,114,105,110,103],"top_logprobs":[{"token":" engineering","logprob":-0.05293126404285431,"bytes":[32,101,110,103,105,110,101,101,114,105,110,103]},{"token":" company","logprob":-3.052931308746338,"bytes":[32,99,111,109,112,97,110,121]},{"token":" engineer","logprob":-5.802931308746338,"bytes":[32,101,110,103,105,110,101,101,114]}]},{"token":" company","logprob":-0.0011932813795283437,"bytes":[32,99,111,109,112,97,110,121],"top_logprobs":[{"token":" company","logprob":-0.0011932813795283437,"bytes":[32,99,111,109,112,97,110,121]},{"token":" firm","logprob":-6.751193523406982,"bytes":[32,102,105,114,109]},{"token":"company","logprob":-12.001193046569824,"bytes":[99,111,109,112,97,110,121]}]},{"token":",","logprob":-0.09029266983270645,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.09029266983270645,"bytes":[44]},{"token":" Comp","logprob":-2.9652926921844482,"bytes":[32,67,111,109,112]},{"token":" E","logprob":-3.8402926921844482,"bytes":[32,69]}]},{"token":" Comp","logprob":-0.6282027959823608,"bytes":[32,67,111,109,112],"top_logprobs":[{"token":" Comp","logprob":-0.6282027959823608,"bytes":[32,67,111,109,112]},{"token":" and","logprob":-1.2532027959823608,"bytes":[32,97,110,100]},{"token":" E","logprob":-2.1282029151916504,"bytes":[32,69]}]},{"token":"agn","logprob":-0.000044466054532676935,"bytes":[97,103,110],"top_logprobs":[{"token":"agn","logprob":-0.000044466054532676935,"bytes":[97,103,110]},{"token":"aign","logprob":-11.062544822692871,"bytes":[97,105,103,110]},{"token":"ang","logprob":-12.375044822692871,"bytes":[97,110,103]}]},{"token":"ie","logprob":-5.960466182841628e-7,"bytes":[105,101],"top_logprobs":[{"token":"ie","logprob":-5.960466182841628e-7,"bytes":[105,101]},{"token":"ied","logprob":-15.500000953674316,"bytes":[105,101,100]},{"token":"ies","logprob":-15.750000953674316,"bytes":[105,101,115]}]},{"token":" des","logprob":-0.0000069141628955549095,"bytes":[32,100,101,115],"top_logprobs":[{"token":" des","logprob":-0.0000069141628955549095,"bytes":[32,100,101,115]},{"token":" Des","logprob":-12.375006675720215,"bytes":[32,68,101,115]},{"token":"-des","logprob":-13.875006675720215,"bytes":[45,100,101,115]}]},{"token":" É","logprob":-0.013768431730568409,"bytes":[32,195,137],"top_logprobs":[{"token":" É","logprob":-0.013768431730568409,"bytes":[32,195,137]},{"token":" E","logprob":-4.388768672943115,"bytes":[32,69]},{"token":" Entre","logprob":-8.138768196105957,"bytes":[32,69,110,116,114,101]}]},{"token":"tabl","logprob":-0.022565273568034172,"bytes":[116,97,98,108],"top_logprobs":[{"token":"tabl","logprob":-0.022565273568034172,"bytes":[116,97,98,108]},{"token":"ta","logprob":-4.3975653648376465,"bytes":[116,97]},{"token":"tas","logprob":-5.7725653648376465,"bytes":[116,97,115]}]},{"token":"isse","logprob":-0.0004112970200367272,"bytes":[105,115,115,101],"top_logprobs":[{"token":"isse","logprob":-0.0004112970200367272,"bytes":[105,115,115,101]},{"token":"iss","logprob":-7.875411510467529,"bytes":[105,115,115]},{"token":"issement","logprob":-10.500411033630371,"bytes":[105,115,115,101,109,101,110,116]}]},{"token":"ments","logprob":-0.0000020265599687263602,"bytes":[109,101,110,116,115],"top_logprobs":[{"token":"ments","logprob":-0.0000020265599687263602,"bytes":[109,101,110,116,115]},{"token":"MENTS","logprob":-13.250001907348633,"bytes":[77,69,78,84,83]},{"token":"mts","logprob":-16.125001907348633,"bytes":[109,116,115]}]},{"token":" E","logprob":-0.00004303548121242784,"bytes":[32,69],"top_logprobs":[{"token":" E","logprob":-0.00004303548121242784,"bytes":[32,69]},{"token":" Gust","logprob":-11.000042915344238,"bytes":[32,71,117,115,116]},{"token":" Eug","logprob":-11.312542915344238,"bytes":[32,69,117,103]}]},{"token":"iff","logprob":-0.0008536643581464887,"bytes":[105,102,102],"top_logprobs":[{"token":"iff","logprob":-0.0008536643581464887,"bytes":[105,102,102]},{"token":"IFF","logprob":-7.125853538513184,"bytes":[73,70,70]},{"token":"ifu","logprob":-10.625853538513184,"bytes":[105,102,117]}]},{"token":"el","logprob":-0.0000023841887468734058,"bytes":[101,108],"top_logprobs":[{"token":"el","logprob":-0.0000023841887468734058,"bytes":[101,108]},{"token":"ell","logprob":-13.625001907348633,"bytes":[101,108,108]},{"token":"ель","logprob":-14.62500286102295,"bytes":[208,181,208,187,209,140]}]},{"token":",","logprob":-0.00030259895720519125,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.00030259895720519125,"bytes":[44]},{"token":" (","logprob":-8.7503023147583,"bytes":[32,40]},{"token":";","logprob":-9.2503023147583,"bytes":[59]}]},{"token":" and","logprob":-0.05744791030883789,"bytes":[32,97,110,100],"top_logprobs":[{"token":" and","logprob":-0.05744791030883789,"bytes":[32,97,110,100]},{"token":" which","logprob":-3.057447910308838,"bytes":[32,119,104,105,99,104]},{"token":" for","logprob":-5.807447910308838,"bytes":[32,102,111,114]}]},{"token":" it","logprob":-0.020101668313145638,"bytes":[32,105,116],"top_logprobs":[{"token":" it","logprob":-0.020101668313145638,"bytes":[32,105,116]},{"token":" opened","logprob":-4.145101547241211,"bytes":[32,111,112,101,110,101,100]},{"token":" was","logprob":-6.020101547241211,"bytes":[32,119,97,115]}]},{"token":" opened","logprob":-0.15201248228549957,"bytes":[32,111,112,101,110,101,100],"top_logprobs":[{"token":" opened","logprob":-0.15201248228549957,"bytes":[32,111,112,101,110,101,100]},{"token":" officially","logprob":-2.402012586593628,"bytes":[32,111,102,102,105,99,105,97,108,108,121]},{"token":" was","logprob":-3.277012348175049,"bytes":[32,119,97,115]}]},{"token":" on","logprob":-0.7110511660575867,"bytes":[32,111,110],"top_logprobs":[{"token":" on","logprob":-0.7110511660575867,"bytes":[32,111,110]},{"token":" to","logprob":-0.7110511660575867,"bytes":[32,116,111]},{"token":" in","logprob":-4.461050987243652,"bytes":[32,105,110]}]},{"token":" March","logprob":-0.015491655096411705,"bytes":[32,77,97,114,99,104],"top_logprobs":[{"token":" March","logprob":-0.015491655096411705,"bytes":[32,77,97,114,99,104]},{"token":" May","logprob":-4.265491485595703,"bytes":[32,77,97,121]},{"token":" April","logprob":-6.890491485595703,"bytes":[32,65,112,114,105,108]}]},{"token":" ","logprob":-1.1920930376163597e-7,"bytes":[32],"top_logprobs":[{"token":" ","logprob":-1.1920930376163597e-7,"bytes":[32]},{"token":",","logprob":-16.125,"bytes":[44]},{"token":" ","logprob":-17.125,"bytes":[194,160]}]},{"token":"31","logprob":-0.0005847733118571341,"bytes":[51,49],"top_logprobs":[{"token":"31","logprob":-0.0005847733118571341,"bytes":[51,49]},{"token":"15","logprob":-7.625584602355957,"bytes":[49,53]},{"token":"30","logprob":-9.625584602355957,"bytes":[51,48]}]},{"token":",","logprob":-0.000061871534853708,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.000061871534853708,"bytes":[44]},{"token":"st","logprob":-9.750061988830566,"bytes":[115,116]},{"token":" ","logprob":-13.000061988830566,"bytes":[32]}]},{"token":" ","logprob":-0.0000069141628955549095,"bytes":[32],"top_logprobs":[{"token":" ","logprob":-0.0000069141628955549095,"bytes":[32]},{"token":"188","logprob":-12.250006675720215,"bytes":[49,56,56]},{"token":"189","logprob":-14.125006675720215,"bytes":[49,56,57]}]},{"token":"188","logprob":-0.000009059946933120955,"bytes":[49,56,56],"top_logprobs":[{"token":"188","logprob":-0.000009059946933120955,"bytes":[49,56,56]},{"token":"189","logprob":-12.125009536743164,"bytes":[49,56,57]},{"token":"193","logprob":-13.125008583068848,"bytes":[49,57,51]}]},{"token":"9","logprob":-2.3841860752327193e-7,"bytes":[57],"top_logprobs":[{"token":"9","logprob":-2.3841860752327193e-7,"bytes":[57]},{"token":"7","logprob":-15.75,"bytes":[55]},{"token":"8","logprob":-16,"bytes":[56]}]},{"token":",","logprob":-0.43338823318481445,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.43338823318481445,"bytes":[44]},{"token":".","logprob":-1.058388113975525,"bytes":[46]},{"token":" for","logprob":-5.8083882331848145,"bytes":[32,102,111,114]}]},{"token":" during","logprob":-1.1376570463180542,"bytes":[32,100,117,114,105,110,103],"top_logprobs":[{"token":" during","logprob":-1.1376570463180542,"bytes":[32,100,117,114,105,110,103]},{"token":" for","logprob":-1.1376570463180542,"bytes":[32,102,111,114]},{"token":" at","logprob":-1.3876570463180542,"bytes":[32,97,116]}]},{"token":" the","logprob":-0.00005722209607483819,"bytes":[32,116,104,101],"top_logprobs":[{"token":" the","logprob":-0.00005722209607483819,"bytes":[32,116,104,101]},{"token":" World","logprob":-10.000057220458984,"bytes":[32,87,111,114,108,100]},{"token":" a","logprob":-11.625057220458984,"bytes":[32,97]}]},{"token":" World","logprob":-0.03828248381614685,"bytes":[32,87,111,114,108,100],"top_logprobs":[{"token":" World","logprob":-0.03828248381614685,"bytes":[32,87,111,114,108,100]},{"token":" Ex","logprob":-3.5382823944091797,"bytes":[32,69,120]},{"token":" ","logprob":-5.53828239440918,"bytes":[32]}]},{"token":"'s","logprob":-0.0001366231736028567,"bytes":[39,115],"top_logprobs":[{"token":"'s","logprob":-0.0001366231736028567,"bytes":[39,115]},{"token":" Ex","logprob":-9.625136375427246,"bytes":[32,69,120]},{"token":" Fair","logprob":-10.125136375427246,"bytes":[32,70,97,105,114]}]},{"token":" Fair","logprob":-0.0002673625713214278,"bytes":[32,70,97,105,114],"top_logprobs":[{"token":" Fair","logprob":-0.0002673625713214278,"bytes":[32,70,97,105,114]},{"token":" Colum","logprob":-8.625267028808594,"bytes":[32,67,111,108,117,109]},{"token":"Fair","logprob":-9.875267028808594,"bytes":[70,97,105,114]}]},{"token":" in","logprob":-0.018513169139623642,"bytes":[32,105,110],"top_logprobs":[{"token":" in","logprob":-0.018513169139623642,"bytes":[32,105,110]},{"token":" held","logprob":-4.643513202667236,"bytes":[32,104,101,108,100]},{"token":" (","logprob":-5.018513202667236,"bytes":[32,40]}]},{"token":" Paris","logprob":-0.00003910141094820574,"bytes":[32,80,97,114,105,115],"top_logprobs":[{"token":" Paris","logprob":-0.00003910141094820574,"bytes":[32,80,97,114,105,115]},{"token":"Paris","logprob":-10.500039100646973,"bytes":[80,97,114,105,115]},{"token":" France","logprob":-11.625039100646973,"bytes":[32,70,114,97,110,99,101]}]},{"token":",","logprob":-0.1805335432291031,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.1805335432291031,"bytes":[44]},{"token":".","logprob":-1.8055336475372314,"bytes":[46]},{"token":" to","logprob":-7.305533409118652,"bytes":[32,116,111]}]},{"token":" France","logprob":-0.004282212350517511,"bytes":[32,70,114,97,110,99,101],"top_logprobs":[{"token":" France","logprob":-0.004282212350517511,"bytes":[32,70,114,97,110,99,101]},{"token":" held","logprob":-6.629282474517822,"bytes":[32,104,101,108,100]},{"token":" which","logprob":-7.254282474517822,"bytes":[32,119,104,105,99,104]}]},{"token":".","logprob":-0.014381812885403633,"bytes":[46],"top_logprobs":[{"token":".","logprob":-0.014381812885403633,"bytes":[46]},{"token":",","logprob":-4.2643818855285645,"bytes":[44]},{"token":" to","logprob":-8.639381408691406,"bytes":[32,116,111]}]},{"token":"<|eot_id|>","logprob":-0.00004804249692824669,"bytes":[60,124,101,111,116,95,105,100,124,62],"top_logprobs":[{"token":"<|eot_id|>","logprob":-0.00004804249692824669,"bytes":[60,124,101,111,116,95,105,100,124,62]},{"token":" \n\n","logprob":-10.68754768371582,"bytes":[32,10,10]},{"token":" ","logprob":-11.56254768371582,"bytes":[32]}]}]}}],"usage":{"prompt_tokens":52,"completion_tokens":53,"total_tokens":105,"prompt_tokens_details":null,"completion_tokens_details":null},"system_fingerprint":""} diff --git a/examples/logprobs/moonwalk.json b/examples/logprobs/moonwalk.json new file mode 100644 index 0000000..f86f3df --- /dev/null +++ b/examples/logprobs/moonwalk.json @@ -0,0 +1 @@ +{"id":"a7442fd489e025f0d1bdef24b07f9128","object":"chat.completion","created":1790363149,"model":"meta-llama/llama-3.1-8b-instruct","choices":[{"index":0,"message":{"role":"assistant","content":"Pete Conrad was the second person to walk on the Moon, which occurred on November 19, 1969, during the Apollo 12 mission."},"finish_reason":"stop","logprobs":{"content":[{"token":"P","logprob":-0.33215734362602234,"bytes":[80],"top_logprobs":[{"token":"P","logprob":-0.33215734362602234,"bytes":[80]},{"token":"The","logprob":-1.9571573734283447,"bytes":[84,104,101]},{"token":"Charles","logprob":-2.9571573734283447,"bytes":[67,104,97,114,108,101,115]}]},{"token":"ete","logprob":-0.2779860198497772,"bytes":[101,116,101],"top_logprobs":[{"token":"ete","logprob":-0.2779860198497772,"bytes":[101,116,101]},{"token":"ilot","logprob":-2.0279860496520996,"bytes":[105,108,111,116]},{"token":"il","logprob":-2.5279860496520996,"bytes":[105,108]}]},{"token":" Conrad","logprob":-0.0011052037589251995,"bytes":[32,67,111,110,114,97,100],"top_logprobs":[{"token":" Conrad","logprob":-0.0011052037589251995,"bytes":[32,67,111,110,114,97,100]},{"token":" Richardson","logprob":-7.876105308532715,"bytes":[32,82,105,99,104,97,114,100,115,111,110]},{"token":" Davidson","logprob":-8.438605308532715,"bytes":[32,68,97,118,105,100,115,111,110]}]},{"token":" was","logprob":-0.51262366771698,"bytes":[32,119,97,115],"top_logprobs":[{"token":" was","logprob":-0.51262366771698,"bytes":[32,119,97,115]},{"token":",","logprob":-1.0126237869262695,"bytes":[44]},{"token":" became","logprob":-3.3876237869262695,"bytes":[32,98,101,99,97,109,101]}]},{"token":" the","logprob":-0.0012632835423573852,"bytes":[32,116,104,101],"top_logprobs":[{"token":" the","logprob":-0.0012632835423573852,"bytes":[32,116,104,101]},{"token":" not","logprob":-6.75126314163208,"bytes":[32,110,111,116]},{"token":" indeed","logprob":-10.126263618469238,"bytes":[32,105,110,100,101,101,100]}]},{"token":" second","logprob":-0.0012665062677115202,"bytes":[32,115,101,99,111,110,100],"top_logprobs":[{"token":" second","logprob":-0.0012665062677115202,"bytes":[32,115,101,99,111,110,100]},{"token":" third","logprob":-6.8762664794921875,"bytes":[32,116,104,105,114,100]},{"token":" ","logprob":-9.376266479492188,"bytes":[32]}]},{"token":" person","logprob":-0.000005960482212685747,"bytes":[32,112,101,114,115,111,110],"top_logprobs":[{"token":" person","logprob":-0.000005960482212685747,"bytes":[32,112,101,114,115,111,110]},{"token":" astronaut","logprob":-13.000005722045898,"bytes":[32,97,115,116,114,111,110,97,117,116]},{"token":" man","logprob":-13.500005722045898,"bytes":[32,109,97,110]}]},{"token":" to","logprob":-0.0002233635459560901,"bytes":[32,116,111],"top_logprobs":[{"token":" to","logprob":-0.0002233635459560901,"bytes":[32,116,111]},{"token":",","logprob":-8.625223159790039,"bytes":[44]},{"token":" and","logprob":-10.125223159790039,"bytes":[32,97,110,100]}]},{"token":" walk","logprob":-0.00000464917320641689,"bytes":[32,119,97,108,107],"top_logprobs":[{"token":" walk","logprob":-0.00000464917320641689,"bytes":[32,119,97,108,107]},{"token":" set","logprob":-13.500004768371582,"bytes":[32,115,101,116]},{"token":" walked","logprob":-13.750004768371582,"bytes":[32,119,97,108,107,101,100]}]},{"token":" on","logprob":-3.576279254957626e-7,"bytes":[32,111,110],"top_logprobs":[{"token":" on","logprob":-3.576279254957626e-7,"bytes":[32,111,110]},{"token":" the","logprob":-15.25,"bytes":[32,116,104,101]},{"token":" in","logprob":-17.125,"bytes":[32,105,110]}]},{"token":" the","logprob":-0.0000013113030945532955,"bytes":[32,116,104,101],"top_logprobs":[{"token":" the","logprob":-0.0000013113030945532955,"bytes":[32,116,104,101]},{"token":" Moon","logprob":-13.625000953674316,"bytes":[32,77,111,111,110]},{"token":" Earth","logprob":-17.125001907348633,"bytes":[32,69,97,114,116,104]}]},{"token":" Moon","logprob":-0.002811745274811983,"bytes":[32,77,111,111,110],"top_logprobs":[{"token":" Moon","logprob":-0.002811745274811983,"bytes":[32,77,111,111,110]},{"token":" moon","logprob":-5.877811908721924,"bytes":[32,109,111,111,110]},{"token":" lunar","logprob":-12.627811431884766,"bytes":[32,108,117,110,97,114]}]},{"token":",","logprob":-0.19133062660694122,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.19133062660694122,"bytes":[44]},{"token":" as","logprob":-2.941330671310425,"bytes":[32,97,115]},{"token":" and","logprob":-3.066330671310425,"bytes":[32,97,110,100]}]},{"token":" which","logprob":-1.9377403259277344,"bytes":[32,119,104,105,99,104],"top_logprobs":[{"token":" which","logprob":-1.9377403259277344,"bytes":[32,119,104,105,99,104]},{"token":" on","logprob":-2.0627403259277344,"bytes":[32,111,110]},{"token":" and","logprob":-2.0627403259277344,"bytes":[32,97,110,100]}]},{"token":" occurred","logprob":-0.45362186431884766,"bytes":[32,111,99,99,117,114,114,101,100],"top_logprobs":[{"token":" occurred","logprob":-0.45362186431884766,"bytes":[32,111,99,99,117,114,114,101,100]},{"token":" he","logprob":-1.3286218643188477,"bytes":[32,104,101]},{"token":" happened","logprob":-2.4536218643188477,"bytes":[32,104,97,112,112,101,110,101,100]}]},{"token":" on","logprob":-0.005385390017181635,"bytes":[32,111,110],"top_logprobs":[{"token":" on","logprob":-0.005385390017181635,"bytes":[32,111,110]},{"token":" during","logprob":-5.380385398864746,"bytes":[32,100,117,114,105,110,103]},{"token":" as","logprob":-8.630385398864746,"bytes":[32,97,115]}]},{"token":" November","logprob":-0.43523597717285156,"bytes":[32,78,111,118,101,109,98,101,114],"top_logprobs":[{"token":" November","logprob":-0.43523597717285156,"bytes":[32,78,111,118,101,109,98,101,114]},{"token":" July","logprob":-1.1852359771728516,"bytes":[32,74,117,108,121]},{"token":" February","logprob":-3.6852359771728516,"bytes":[32,70,101,98,114,117,97,114,121]}]},{"token":" ","logprob":-0.00001406679530191468,"bytes":[32],"top_logprobs":[{"token":" ","logprob":-0.00001406679530191468,"bytes":[32]},{"token":",","logprob":-11.375014305114746,"bytes":[44]},{"token":".","logprob":-14.500014305114746,"bytes":[46]}]},{"token":"19","logprob":-0.002863688860088587,"bytes":[49,57],"top_logprobs":[{"token":"19","logprob":-0.002863688860088587,"bytes":[49,57]},{"token":"20","logprob":-6.127863883972168,"bytes":[50,48]},{"token":"21","logprob":-8.127863883972168,"bytes":[50,49]}]},{"token":",","logprob":-0.006666540168225765,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.006666540168225765,"bytes":[44]},{"token":"-","logprob":-5.506666660308838,"bytes":[45]},{"token":"th","logprob":-6.381666660308838,"bytes":[116,104]}]},{"token":" ","logprob":-0.0004885197849944234,"bytes":[32],"top_logprobs":[{"token":" ","logprob":-0.0004885197849944234,"bytes":[32]},{"token":"196","logprob":-8.62548828125,"bytes":[49,57,54]},{"token":" during","logprob":-9.75048828125,"bytes":[32,100,117,114,105,110,103]}]},{"token":"196","logprob":-0.000013113108252582606,"bytes":[49,57,54],"top_logprobs":[{"token":"196","logprob":-0.000013113108252582606,"bytes":[49,57,54]},{"token":"199","logprob":-12.12501335144043,"bytes":[49,57,57]},{"token":"69","logprob":-13.50001335144043,"bytes":[54,57]}]},{"token":"9","logprob":-2.3841860752327193e-7,"bytes":[57],"top_logprobs":[{"token":"9","logprob":-2.3841860752327193e-7,"bytes":[57]},{"token":"7","logprob":-16.125,"bytes":[55]},{"token":"8","logprob":-16.375,"bytes":[56]}]},{"token":",","logprob":-0.4951174259185791,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.4951174259185791,"bytes":[44]},{"token":".","logprob":-0.9951174259185791,"bytes":[46]},{"token":" during","logprob":-4.245117664337158,"bytes":[32,100,117,114,105,110,103]}]},{"token":" during","logprob":-0.588891863822937,"bytes":[32,100,117,114,105,110,103],"top_logprobs":[{"token":" during","logprob":-0.588891863822937,"bytes":[32,100,117,114,105,110,103]},{"token":" as","logprob":-0.838891863822937,"bytes":[32,97,115]},{"token":" while","logprob":-5.463891983032227,"bytes":[32,119,104,105,108,101]}]},{"token":" the","logprob":-0.00154829618986696,"bytes":[32,116,104,101],"top_logprobs":[{"token":" the","logprob":-0.00154829618986696,"bytes":[32,116,104,101]},{"token":" Apollo","logprob":-6.8765482902526855,"bytes":[32,65,112,111,108,108,111]},{"token":" NASA","logprob":-7.8765482902526855,"bytes":[32,78,65,83,65]}]},{"token":" Apollo","logprob":-0.010657996870577335,"bytes":[32,65,112,111,108,108,111],"top_logprobs":[{"token":" Apollo","logprob":-0.010657996870577335,"bytes":[32,65,112,111,108,108,111]},{"token":" second","logprob":-5.760657787322998,"bytes":[32,115,101,99,111,110,100]},{"token":" mission","logprob":-5.885657787322998,"bytes":[32,109,105,115,115,105,111,110]}]},{"token":" ","logprob":-0.0000022649790025752736,"bytes":[32],"top_logprobs":[{"token":" ","logprob":-0.0000022649790025752736,"bytes":[32]},{"token":" missions","logprob":-14.375001907348633,"bytes":[32,109,105,115,115,105,111,110,115]},{"token":"-","logprob":-14.875001907348633,"bytes":[45]}]},{"token":"12","logprob":-0.000030637256713816896,"bytes":[49,50],"top_logprobs":[{"token":"12","logprob":-0.000030637256713816896,"bytes":[49,50]},{"token":"16","logprob":-11.375030517578125,"bytes":[49,54]},{"token":"11","logprob":-11.625030517578125,"bytes":[49,49]}]},{"token":" mission","logprob":-0.0009926117490977049,"bytes":[32,109,105,115,115,105,111,110],"top_logprobs":[{"token":" mission","logprob":-0.0009926117490977049,"bytes":[32,109,105,115,115,105,111,110]},{"token":" Mission","logprob":-8.000992774963379,"bytes":[32,77,105,115,115,105,111,110]},{"token":" lunar","logprob":-8.250992774963379,"bytes":[32,108,117,110,97,114]}]},{"token":".","logprob":-0.007830141112208366,"bytes":[46],"top_logprobs":[{"token":".","logprob":-0.007830141112208366,"bytes":[46]},{"token":",","logprob":-5.0078301429748535,"bytes":[44]},{"token":" as","logprob":-8.007830619812012,"bytes":[32,97,115]}]},{"token":"<|eot_id|>","logprob":-0.0005949120968580246,"bytes":[60,124,101,111,116,95,105,100,124,62],"top_logprobs":[{"token":"<|eot_id|>","logprob":-0.0005949120968580246,"bytes":[60,124,101,111,116,95,105,100,124,62]},{"token":" He","logprob":-8.438095092773438,"bytes":[32,72,101]},{"token":" ","logprob":-9.375595092773438,"bytes":[32]}]}]}}],"usage":{"prompt_tokens":54,"completion_tokens":32,"total_tokens":86,"prompt_tokens_details":null,"completion_tokens_details":null},"system_fingerprint":""} diff --git a/examples/logprobs/tour-de-france.json b/examples/logprobs/tour-de-france.json new file mode 100644 index 0000000..0380336 --- /dev/null +++ b/examples/logprobs/tour-de-france.json @@ -0,0 +1 @@ +{"id":"e6d6bb1ebf75a567466b1697b5fa9682","object":"chat.completion","created":1790363136,"model":"meta-llama/llama-3.1-8b-instruct","choices":[{"index":0,"message":{"role":"assistant","content":"Stephen Roche won the 1987 Tour de France, riding for the Carrera Jeans-Vagabond team."},"finish_reason":"stop","logprobs":{"content":[{"token":"Stephen","logprob":-0.7158687114715576,"bytes":[83,116,101,112,104,101,110],"top_logprobs":[{"token":"Stephen","logprob":-0.7158687114715576,"bytes":[83,116,101,112,104,101,110]},{"token":"The","logprob":-0.8408687114715576,"bytes":[84,104,101]},{"token":"Steven","logprob":-3.5908687114715576,"bytes":[83,116,101,118,101,110]}]},{"token":" Ro","logprob":-0.0005079605616629124,"bytes":[32,82,111],"top_logprobs":[{"token":" Ro","logprob":-0.0005079605616629124,"bytes":[32,82,111]},{"token":" Roth","logprob":-9.125508308410645,"bytes":[32,82,111,116,104]},{"token":"Ro","logprob":-9.250508308410645,"bytes":[82,111]}]},{"token":"che","logprob":-0.000011444157280493528,"bytes":[99,104,101],"top_logprobs":[{"token":"che","logprob":-0.000011444157280493528,"bytes":[99,104,101]},{"token":"ach","logprob":-11.875011444091797,"bytes":[97,99,104]},{"token":"ache","logprob":-13.125011444091797,"bytes":[97,99,104,101]}]},{"token":" won","logprob":-0.05352085828781128,"bytes":[32,119,111,110],"top_logprobs":[{"token":" won","logprob":-0.05352085828781128,"bytes":[32,119,111,110]},{"token":" from","logprob":-3.428520917892456,"bytes":[32,102,114,111,109]},{"token":" of","logprob":-4.678520679473877,"bytes":[32,111,102]}]},{"token":" the","logprob":-0.000012278632311790716,"bytes":[32,116,104,101],"top_logprobs":[{"token":" the","logprob":-0.000012278632311790716,"bytes":[32,116,104,101]},{"token":" ","logprob":-12.250012397766113,"bytes":[32]},{"token":" both","logprob":-13.000012397766113,"bytes":[32,98,111,116,104]}]},{"token":" ","logprob":-0.00009441821748623624,"bytes":[32],"top_logprobs":[{"token":" ","logprob":-0.00009441821748623624,"bytes":[32]},{"token":" Tour","logprob":-9.875094413757324,"bytes":[32,84,111,117,114]},{"token":" general","logprob":-10.375094413757324,"bytes":[32,103,101,110,101,114,97,108]}]},{"token":"198","logprob":-0.00014961884880904108,"bytes":[49,57,56],"top_logprobs":[{"token":"198","logprob":-0.00014961884880904108,"bytes":[49,57,56]},{"token":" ","logprob":-9.375149726867676,"bytes":[32]},{"token":"100","logprob":-11.500149726867676,"bytes":[49,48,48]}]},{"token":"7","logprob":-0.0000016689314179529902,"bytes":[55],"top_logprobs":[{"token":"7","logprob":-0.0000016689314179529902,"bytes":[55]},{"token":"6","logprob":-13.625001907348633,"bytes":[54]},{"token":"8","logprob":-15.250001907348633,"bytes":[56]}]},{"token":" Tour","logprob":-0.00022610597079619765,"bytes":[32,84,111,117,114],"top_logprobs":[{"token":" Tour","logprob":-0.00022610597079619765,"bytes":[32,84,111,117,114]},{"token":" edition","logprob":-8.750226020812988,"bytes":[32,101,100,105,116,105,111,110]},{"token":"Tour","logprob":-10.500226020812988,"bytes":[84,111,117,114]}]},{"token":" de","logprob":-0.000007152582838898525,"bytes":[32,100,101],"top_logprobs":[{"token":" de","logprob":-0.000007152582838898525,"bytes":[32,100,101]},{"token":" De","logprob":-11.875007629394531,"bytes":[32,68,101]},{"token":"-de","logprob":-16.50000762939453,"bytes":[45,100,101]}]},{"token":" France","logprob":-0.000011682578588079195,"bytes":[32,70,114,97,110,99,101],"top_logprobs":[{"token":" France","logprob":-0.000011682578588079195,"bytes":[32,70,114,97,110,99,101]},{"token":"France","logprob":-12.000011444091797,"bytes":[70,114,97,110,99,101]},{"token":" French","logprob":-12.250011444091797,"bytes":[32,70,114,101,110,99,104]}]},{"token":",","logprob":-0.4204120635986328,"bytes":[44],"top_logprobs":[{"token":",","logprob":-0.4204120635986328,"bytes":[44]},{"token":" for","logprob":-1.2954120635986328,"bytes":[32,102,111,114]},{"token":" while","logprob":-3.420412063598633,"bytes":[32,119,104,105,108,101]}]},{"token":" riding","logprob":-0.333596795797348,"bytes":[32,114,105,100,105,110,103],"top_logprobs":[{"token":" riding","logprob":-0.333596795797348,"bytes":[32,114,105,100,105,110,103]},{"token":" competing","logprob":-2.458596706390381,"bytes":[32,99,111,109,112,101,116,105,110,103]},{"token":" representing","logprob":-2.458596706390381,"bytes":[32,114,101,112,114,101,115,101,110,116,105,110,103]}]},{"token":" for","logprob":-0.005418709944933653,"bytes":[32,102,111,114],"top_logprobs":[{"token":" for","logprob":-0.005418709944933653,"bytes":[32,102,111,114]},{"token":" as","logprob":-5.25541877746582,"bytes":[32,97,115]},{"token":" in","logprob":-9.25541877746582,"bytes":[32,105,110]}]},{"token":" the","logprob":-0.0869462713599205,"bytes":[32,116,104,101],"top_logprobs":[{"token":" the","logprob":-0.0869462713599205,"bytes":[32,116,104,101]},{"token":" Carr","logprob":-3.0869462490081787,"bytes":[32,67,97,114,114]},{"token":" La","logprob":-3.9619462490081787,"bytes":[32,76,97]}]},{"token":" Carr","logprob":-0.028787098824977875,"bytes":[32,67,97,114,114],"top_logprobs":[{"token":" Carr","logprob":-0.028787098824977875,"bytes":[32,67,97,114,114]},{"token":" Lan","logprob":-5.591287136077881,"bytes":[32,76,97,110]},{"token":" Panasonic","logprob":-6.091287136077881,"bytes":[32,80,97,110,97,115,111,110,105,99]}]},{"token":"era","logprob":-0.0021613333374261856,"bytes":[101,114,97],"top_logprobs":[{"token":"era","logprob":-0.0021613333374261856,"bytes":[101,114,97]},{"token":"eras","logprob":-7.002161502838135,"bytes":[101,114,97,115]},{"token":"éra","logprob":-7.377161502838135,"bytes":[195,169,114,97]}]},{"token":" Jeans","logprob":-0.07600101083517075,"bytes":[32,74,101,97,110,115],"top_logprobs":[{"token":" Jeans","logprob":-0.07600101083517075,"bytes":[32,74,101,97,110,115]},{"token":" team","logprob":-3.3260011672973633,"bytes":[32,116,101,97,109]},{"token":"-V","logprob":-3.951000928878784,"bytes":[45,86]}]},{"token":"-V","logprob":-0.5577070713043213,"bytes":[45,86],"top_logprobs":[{"token":"-V","logprob":-0.5577070713043213,"bytes":[45,86]},{"token":"–","logprob":-1.5577069520950317,"bytes":[226,128,147]},{"token":" -","logprob":-1.9327070713043213,"bytes":[32,45]}]},{"token":"ag","logprob":-0.06768901646137238,"bytes":[97,103],"top_logprobs":[{"token":"ag","logprob":-0.06768901646137238,"bytes":[97,103]},{"token":"iag","logprob":-5.067688941955566,"bytes":[105,97,103]},{"token":"iv","logprob":-5.130188941955566,"bytes":[105,118]}]},{"token":"ab","logprob":-0.00400396017357707,"bytes":[97,98],"top_logprobs":[{"token":"ab","logprob":-0.00400396017357707,"bytes":[97,98]},{"token":"abl","logprob":-5.879004001617432,"bytes":[97,98,108]},{"token":"abo","logprob":-8.129003524780273,"bytes":[97,98,111]}]},{"token":"ond","logprob":-0.0007724168244749308,"bytes":[111,110,100],"top_logprobs":[{"token":"ond","logprob":-0.0007724168244749308,"bytes":[111,110,100]},{"token":"OND","logprob":-8.000772476196289,"bytes":[79,78,68]},{"token":"onds","logprob":-8.500772476196289,"bytes":[111,110,100,115]}]},{"token":" team","logprob":-0.017206452786922455,"bytes":[32,116,101,97,109],"top_logprobs":[{"token":" team","logprob":-0.017206452786922455,"bytes":[32,116,101,97,109]},{"token":" cycling","logprob":-5.26720666885376,"bytes":[32,99,121,99,108,105,110,103]},{"token":" and","logprob":-5.392206192016602,"bytes":[32,97,110,100]}]},{"token":".","logprob":-0.03662529215216637,"bytes":[46],"top_logprobs":[{"token":".","logprob":-0.03662529215216637,"bytes":[46]},{"token":",","logprob":-4.161625385284424,"bytes":[44]},{"token":" and","logprob":-5.411625385284424,"bytes":[32,97,110,100]}]},{"token":"<|eot_id|>","logprob":-0.00033706147223711014,"bytes":[60,124,101,111,116,95,105,100,124,62],"top_logprobs":[{"token":"<|eot_id|>","logprob":-0.00033706147223711014,"bytes":[60,124,101,111,116,95,105,100,124,62]},{"token":" ","logprob":-8.875336647033691,"bytes":[32]},{"token":" However","logprob":-9.750336647033691,"bytes":[32,72,111,119,101,118,101,114]}]}]}}],"usage":{"prompt_tokens":53,"completion_tokens":25,"total_tokens":78,"prompt_tokens_details":null,"completion_tokens_details":null},"system_fingerprint":""} diff --git a/src/detect.rs b/src/detect.rs new file mode 100644 index 0000000..33bd874 --- /dev/null +++ b/src/detect.rs @@ -0,0 +1,399 @@ +//! Hallucination-risk detection from token log probabilities. +//! +//! The input is the `logprobs` block that OpenAI-compatible Chat Completions +//! APIs return when you ask for `"logprobs": true`. Each token's probability +//! is `exp(logprob)`. Words containing a token whose probability is below the +//! threshold are flagged, and neighbouring flagged words are merged into one +//! span. Each span reports the weakest token and the alternatives the model +//! was weighing at that point. +//! +//! Low probability is a signal, not proof: a model can be confidently wrong, +//! and it can be unsure about phrasing while the facts are right. + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::data::{TokenAnalysis, TokenFlag, TokenInfo}; + +/// Default probability below which a token counts as low confidence. +pub const DEFAULT_THRESHOLD: f64 = 0.5; + +/// Label used for spans produced by [`detect`]. +pub const FLAG_LABEL: &str = "uncertain"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Alternative { + pub token: String, + pub logprob: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LogprobToken { + pub token: String, + pub logprob: f64, + #[serde(default)] + pub top_logprobs: Vec, +} + +impl LogprobToken { + pub fn prob(&self) -> f64 { + self.logprob.exp().clamp(0.0, 1.0) + } +} + +/// A run of low-confidence words. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Span { + /// First token index (inclusive). + pub start: usize, + /// Last token index (exclusive). + pub end: usize, + pub text: String, + /// The least likely token in the span and its probability. + pub weakest_token: String, + pub min_prob: f64, + /// What the model considered at the weakest token, most likely first. + /// Empty when the input has no `top_logprobs`. + pub alternatives: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Candidate { + pub token: String, + pub prob: f64, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Report { + pub threshold: f64, + pub text: String, + pub token_count: usize, + pub mean_prob: f64, + pub spans: Vec, +} + +impl Report { + pub fn flagged(&self) -> bool { + !self.spans.is_empty() + } +} + +/// Extract the token list from any of these shapes: +/// a full Chat Completions response (`choices[0].logprobs.content`), +/// a `logprobs` object (`{"content": [...]}`), or a bare token array. +pub fn parse_logprobs(json: &str) -> Result> { + let value: Value = serde_json::from_str(json).context("input is not valid JSON")?; + if let Some(err) = value.get("error") { + bail!("the API returned an error: {}", err); + } + let content = if value.is_array() { + &value + } else if let Some(c) = value.pointer("/choices/0/logprobs/content") { + c + } else if let Some(c) = value.get("content") { + c + } else { + bail!( + "no token logprobs found; expected choices[0].logprobs.content \ + (request the completion with \"logprobs\": true)" + ); + }; + if content.is_null() { + bail!("logprobs are null; request the completion with \"logprobs\": true"); + } + let mut tokens: Vec = + serde_json::from_value(content.clone()).context("could not read logprobs tokens")?; + // Drop end-of-turn and other special tokens (e.g. "<|eot_id|>") that some + // servers include in the logprobs but not in the message text. + tokens.retain(|t| !is_special_token(&t.token)); + if tokens.is_empty() { + bail!("logprobs contain no tokens"); + } + Ok(tokens) +} + +fn is_special_token(s: &str) -> bool { + s.starts_with("<|") && s.ends_with("|>") +} + +fn has_word_chars(s: &str) -> bool { + s.chars().any(|c| c.is_alphanumeric()) +} + +/// A token starts a new word if it begins with whitespace or punctuation, +/// or if the previous token ended in one (e.g. " " followed by "198"). +fn starts_word(tokens: &[LogprobToken], i: usize) -> bool { + if i == 0 { + return true; + } + let first = tokens[i].token.chars().next(); + let prev_last = tokens[i - 1].token.chars().last(); + let boundary = + |c: Option| c.is_none_or(|c| c.is_whitespace() || c.is_ascii_punctuation()); + boundary(first) || boundary(prev_last) +} + +/// Group token indices into words: returns (start, end) ranges. +fn words(tokens: &[LogprobToken]) -> Vec<(usize, usize)> { + let mut out: Vec<(usize, usize)> = Vec::new(); + for i in 0..tokens.len() { + if starts_word(tokens, i) || out.is_empty() { + out.push((i, i + 1)); + } else if let Some(last) = out.last_mut() { + last.1 = i + 1; + } + } + out +} + +/// Flag low-confidence spans. +pub fn detect(tokens: &[LogprobToken], threshold: f64) -> Report { + let text: String = tokens.iter().map(|t| t.token.as_str()).collect(); + let mean_prob = if tokens.is_empty() { + 0.0 + } else { + tokens.iter().map(|t| t.prob()).sum::() / tokens.len() as f64 + }; + + // A word is flagged when any of its tokens with letters or digits is + // below the threshold. Pure whitespace/punctuation never triggers a flag. + let flagged_words: Vec<(usize, usize)> = words(tokens) + .into_iter() + .filter(|&(s, e)| { + tokens[s..e] + .iter() + .any(|t| has_word_chars(&t.token) && t.prob() < threshold) + }) + .collect(); + + // Merge words that are adjacent, or separated only by whitespace tokens. + let mut ranges: Vec<(usize, usize)> = Vec::new(); + for (s, e) in flagged_words { + if let Some(last) = ranges.last_mut() { + if tokens[last.1..s].iter().all(|t| t.token.trim().is_empty()) { + last.1 = e; + continue; + } + } + ranges.push((s, e)); + } + + let spans = ranges + .into_iter() + .map(|(start, end)| { + let weakest = (start..end) + .filter(|&i| has_word_chars(&tokens[i].token)) + .min_by(|&a, &b| tokens[a].prob().total_cmp(&tokens[b].prob())) + .unwrap_or(start); + let w = &tokens[weakest]; + let alternatives = w + .top_logprobs + .iter() + .map(|a| Candidate { + token: a.token.clone(), + prob: a.logprob.exp(), + }) + .collect(); + Span { + start, + end, + text: tokens[start..end] + .iter() + .map(|t| t.token.as_str()) + .collect(), + weakest_token: w.token.clone(), + min_prob: w.prob(), + alternatives, + } + }) + .collect(); + + Report { + threshold, + text, + token_count: tokens.len(), + mean_prob, + spans, + } +} + +impl Span { + /// One-line human description, e.g. + /// `p=0.57 at "ord"; model also considered "üsseldorf" (0.39)`. + pub fn describe(&self) -> String { + let mut s = format!("p={:.2} at {:?}", self.min_prob, self.weakest_token); + let others: Vec = self + .alternatives + .iter() + .filter(|c| c.token != self.weakest_token) + .map(|c| format!("{:?} ({:.2})", c.token, c.prob)) + .collect(); + if !others.is_empty() { + s.push_str("; model also considered "); + s.push_str(&others.join(", ")); + } + s + } +} + +/// Convert tokens plus a report into the visualizer's input format, so the +/// terminal, HTML and Markdown renderers can display the result. +pub fn to_token_analysis(tokens: &[LogprobToken], report: &Report) -> TokenAnalysis { + TokenAnalysis { + tokens: tokens + .iter() + .map(|t| TokenInfo { + text: t.token.clone(), + confidence: t.prob(), + }) + .collect(), + flags: report + .spans + .iter() + .map(|s| TokenFlag { + start: s.start, + end: s.end, + flag: FLAG_LABEL.to_string(), + description: Some(s.describe()), + }) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tok(t: &str, p: f64) -> LogprobToken { + LogprobToken { + token: t.to_string(), + logprob: p.ln(), + top_logprobs: vec![], + } + } + + #[test] + fn parses_all_three_shapes() { + let arr = r#"[{"token":"Hi","logprob":-0.1}]"#; + let obj = r#"{"content":[{"token":"Hi","logprob":-0.1}]}"#; + let full = r#"{"choices":[{"logprobs":{"content":[{"token":"Hi","logprob":-0.1,"top_logprobs":[]}]}}]}"#; + for s in [arr, obj, full] { + let t = parse_logprobs(s).unwrap(); + assert_eq!(t.len(), 1); + assert_eq!(t[0].token, "Hi"); + } + } + + #[test] + fn special_tokens_are_dropped() { + let t = parse_logprobs( + r#"[{"token":"Hi","logprob":-0.1},{"token":"<|eot_id|>","logprob":-3.0}]"#, + ) + .unwrap(); + assert_eq!(t.len(), 1); + } + + #[test] + fn missing_or_null_logprobs_is_a_clear_error() { + let e = parse_logprobs(r#"{"choices":[{"logprobs":null}]}"#).unwrap_err(); + assert!(e.to_string().contains("logprobs")); + let e = parse_logprobs(r#"{"error":{"message":"bad key"}}"#).unwrap_err(); + assert!(e.to_string().contains("bad key")); + } + + #[test] + fn flags_whole_word_when_a_subword_token_is_low() { + let t = vec![ + tok("In", 0.9), + tok(" D", 0.95), + tok("ord", 0.3), + tok("recht", 1.0), + ]; + let r = detect(&t, 0.5); + assert_eq!(r.spans.len(), 1); + assert_eq!(r.spans[0].text, " Dordrecht"); + assert_eq!((r.spans[0].start, r.spans[0].end), (1, 4)); + assert_eq!(r.spans[0].weakest_token, "ord"); + } + + #[test] + fn punctuation_and_whitespace_never_trigger() { + let t = vec![ + tok("Yes", 0.9), + tok(",", 0.1), + tok(" ", 0.1), + tok("ok", 0.9), + ]; + assert!(!detect(&t, 0.5).flagged()); + } + + #[test] + fn adjacent_low_words_merge_into_one_span() { + let t = vec![ + tok("It", 0.99), + tok(" was", 0.2), + tok(" Pete", 0.3), + tok(".", 0.99), + tok(" Then", 0.1), + ]; + let r = detect(&t, 0.5); + assert_eq!(r.spans.len(), 2); + assert_eq!(r.spans[0].text, " was Pete"); + assert_eq!(r.spans[1].text, " Then"); + } + + #[test] + fn threshold_is_strict_less_than() { + let t = vec![tok("a", 0.5)]; + assert!(!detect(&t, 0.5).flagged()); + assert!(detect(&t, 0.51).flagged()); + } + + #[test] + fn digits_split_by_space_token_start_a_new_word() { + let t = vec![ + tok(" in", 0.99), + tok(" ", 0.99), + tok("169", 0.2), + tok("1", 0.99), + ]; + let r = detect(&t, 0.5); + assert_eq!(r.spans.len(), 1); + assert_eq!(r.spans[0].text, "1691"); + } + + #[test] + fn describe_lists_alternatives_but_not_the_chosen_token() { + let mut w = tok("ord", 0.57); + w.top_logprobs = vec![ + Alternative { + token: "ord".into(), + logprob: 0.57f64.ln(), + }, + Alternative { + token: "üsseldorf".into(), + logprob: 0.39f64.ln(), + }, + ]; + let r = detect(&[tok(" D", 0.99), w], 0.6); + let d = r.spans[0].describe(); + assert!(d.contains("p=0.57"), "{d}"); + assert!(d.contains("üsseldorf"), "{d}"); + assert_eq!(d.matches("\"ord\"").count(), 1, "{d}"); + } + + #[test] + fn bundled_sample_flags_the_birthplace() { + let json = include_str!("../examples/logprobs/cuyp.json"); + let tokens = parse_logprobs(json).unwrap(); + let r = detect(&tokens, 0.6); + assert_eq!( + r.text, + "Aelbert Cuyp died in 1691 in Dordrecht, Netherlands." + ); + assert!(r.spans.iter().any(|s| s.text == " Dordrecht")); + let a = to_token_analysis(&tokens, &r); + assert!(a.validate().is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs index cc8462b..a776794 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,7 @@ pub mod data; +pub mod detect; +#[cfg(feature = "live")] +pub mod live; pub mod renderer; pub mod utils; diff --git a/src/live.rs b/src/live.rs new file mode 100644 index 0000000..4fccd30 --- /dev/null +++ b/src/live.rs @@ -0,0 +1,61 @@ +//! Fetch a completion with token logprobs from an OpenAI-compatible API. +//! +//! Configuration comes from the environment: +//! - `OPENAI_API_KEY` (required) +//! - `OPENAI_BASE_URL` (optional, default `https://api.openai.com/v1`); any +//! server that implements Chat Completions with `logprobs` works, for +//! example Hugging Face's router at `https://router.huggingface.co/v1`. + +use anyhow::{Context, Result}; +use serde_json::json; + +pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +pub const DEFAULT_MODEL: &str = "gpt-4o-mini"; + +/// Build the request body. Temperature 0 so reruns are comparable. +pub fn request_body(prompt: &str, model: &str, max_tokens: u32) -> serde_json::Value { + json!({ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": max_tokens, + "temperature": 0, + "logprobs": true, + "top_logprobs": 3 + }) +} + +/// Send the prompt and return the raw JSON response text. +pub fn fetch(prompt: &str, model: &str, max_tokens: u32) -> Result { + let key = std::env::var("OPENAI_API_KEY") + .context("live mode needs OPENAI_API_KEY in the environment")?; + let base = std::env::var("OPENAI_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()); + let url = format!("{}/chat/completions", base.trim_end_matches('/')); + + let resp = ureq::post(&url) + .set("Authorization", &format!("Bearer {}", key)) + .set("Content-Type", "application/json") + .send_string(&request_body(prompt, model, max_tokens).to_string()); + + match resp { + Ok(r) => r.into_string().context("could not read the API response"), + Err(ureq::Error::Status(code, r)) => { + let body = r.into_string().unwrap_or_default(); + anyhow::bail!("{} returned HTTP {}: {}", url, code, body.trim()) + } + Err(e) => Err(anyhow::anyhow!("request to {} failed: {}", url, e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_asks_for_logprobs() { + let b = request_body("hi", "m", 50); + assert_eq!(b["logprobs"], true); + assert_eq!(b["top_logprobs"], 3); + assert_eq!(b["messages"][0]["content"], "hi"); + assert_eq!(b["model"], "m"); + } +} diff --git a/src/main.rs b/src/main.rs index bef9b02..a391707 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,42 +1,68 @@ -#![allow(dead_code)] -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; +use std::io::Read; use std::path::PathBuf; -mod data; -mod renderer; -mod utils; - -use data::{TokenAnalysis, VisualizationConfig}; -use renderer::{HtmlRenderer, MarkdownRenderer, Renderer, TerminalRenderer}; +use llm_token_visualizer::detect::{self, Report}; +use llm_token_visualizer::{ + HtmlRenderer, MarkdownRenderer, Renderer, TerminalRenderer, TokenAnalysis, VisualizationConfig, +}; #[derive(Parser)] -#[command(name = "llm-token-visualizer")] +#[command(name = "llm-token-visualizer", version)] #[command( - about = "Visualize LLM output token-by-token to detect hallucinations and confidence shifts" + about = "Flag low-confidence spans in LLM output from token logprobs, and visualize per-token confidence" )] struct Args { - /// LLM response text + /// Detect: path to a Chat Completions response (or its logprobs) saved as JSON; "-" reads stdin + #[arg(long, value_name = "PATH")] + logprobs_file: Option, + + /// Detect: send this prompt to an OpenAI-compatible API (needs OPENAI_API_KEY) and analyze the answer + #[arg(long, value_name = "PROMPT")] + live: Option, + + /// Model for --live + #[arg(long, default_value = "gpt-4o-mini")] + model: String, + + /// Max tokens for --live + #[arg(long, default_value_t = 200)] + max_tokens: u32, + + /// Save the raw --live response here so it can be re-analyzed offline with --logprobs-file + #[arg(long, value_name = "PATH")] + save: Option, + + /// Detect: flag words containing a token with probability below this (0-1) + #[arg(long, default_value_t = detect::DEFAULT_THRESHOLD)] + threshold: f64, + + /// Detect: exit with status 2 if any span is flagged (for CI and scripts) + #[arg(long)] + fail_on_flag: bool, + + /// Visualize: LLM response text #[arg(short, long)] text: Option, - /// Path to text file containing LLM response + /// Visualize: path to text file containing LLM response #[arg(long)] text_file: Option, - /// JSON string with token confidence data + /// Visualize: JSON string with token confidence data #[arg(short, long)] confidence: Option, - /// Path to JSON file with confidence data + /// Visualize: path to JSON file with confidence data #[arg(long)] confidence_file: Option, - /// Output format: terminal, html, markdown + /// Output format: terminal, html, markdown, or json (json: detect mode only) #[arg(short, long, default_value = "terminal")] format: String, - /// Output file path (for html/markdown formats) + /// Output file path (for html/markdown/json formats) #[arg(short, long)] output: Option, @@ -44,14 +70,32 @@ struct Args { #[arg(long)] verbose: bool, - /// Use demo data for testing + /// Use built-in demo data (hand-written scores, visualizer only) #[arg(long)] demo: bool, } -fn main() -> Result<()> { +fn main() { + match run() { + Ok(code) => std::process::exit(code), + Err(e) => { + eprintln!("error: {:#}", e); + std::process::exit(1); + } + } +} + +fn run() -> Result { let args = Args::parse(); + if !(0.0..=1.0).contains(&args.threshold) { + anyhow::bail!("--threshold must be between 0 and 1"); + } + + if args.logprobs_file.is_some() || args.live.is_some() { + return run_detect(&args); + } + let (text, token_analysis) = if args.demo { load_demo_data()? } else { @@ -60,39 +104,105 @@ fn main() -> Result<()> { (text, token_analysis) }; + render(&args, &text, &token_analysis)?; + Ok(0) +} + +fn render(args: &Args, text: &str, analysis: &TokenAnalysis) -> Result<()> { let config = VisualizationConfig { verbose: args.verbose, show_confidence_scores: true, show_flags: true, }; - match args.format.as_str() { - "terminal" => { - let renderer = TerminalRenderer::new(); - renderer.render(&text, &token_analysis, &config)?; + let output = match args.format.as_str() { + "terminal" => TerminalRenderer::new().render(text, analysis, &config)?, + "html" => HtmlRenderer::new().render(text, analysis, &config)?, + "markdown" => MarkdownRenderer::new().render(text, analysis, &config)?, + "json" => anyhow::bail!("--format json needs --logprobs-file or --live"), + _ => anyhow::bail!("Unsupported format: {}", args.format), + }; + write_output(args, &output) +} + +fn write_output(args: &Args, output: &str) -> Result<()> { + match (&args.output, args.format.as_str()) { + (Some(path), f) if f != "terminal" => std::fs::write(path, output) + .with_context(|| format!("could not write {}", path.display())), + _ => { + println!("{}", output); + Ok(()) } - "html" => { - let renderer = HtmlRenderer::new(); - let output = renderer.render(&text, &token_analysis, &config)?; - if let Some(path) = args.output { - std::fs::write(path, output)?; - } else { - println!("{}", output); - } + } +} + +fn run_detect(args: &Args) -> Result { + let json = if let Some(prompt) = &args.live { + let raw = fetch_live(prompt, args)?; + if let Some(path) = &args.save { + std::fs::write(path, &raw) + .with_context(|| format!("could not write {}", path.display()))?; } - "markdown" => { - let renderer = MarkdownRenderer::new(); - let output = renderer.render(&text, &token_analysis, &config)?; - if let Some(path) = args.output { - std::fs::write(path, output)?; - } else { - println!("{}", output); - } + raw + } else { + let path = args.logprobs_file.as_ref().expect("checked by caller"); + if path.as_os_str() == "-" { + let mut s = String::new(); + std::io::stdin().read_to_string(&mut s)?; + s + } else { + std::fs::read_to_string(path) + .with_context(|| format!("could not read {}", path.display()))? } - _ => anyhow::bail!("Unsupported format: {}", args.format), + }; + + let tokens = detect::parse_logprobs(&json)?; + let report = detect::detect(&tokens, args.threshold); + + if args.format == "json" { + write_output(args, &serde_json::to_string_pretty(&report)?)?; + } else { + let analysis = detect::to_token_analysis(&tokens, &report); + render(args, &report.text, &analysis)?; + if args.format == "terminal" { + println!("{}", verdict(&report)); + } + } + + Ok(if args.fail_on_flag && report.flagged() { + 2 + } else { + 0 + }) +} + +fn verdict(r: &Report) -> String { + if r.spans.is_empty() { + format!( + "No low-confidence spans (threshold {:.2}, {} tokens, mean p={:.2}).\n\ + This does not prove the answer is correct: models can be confidently wrong.", + r.threshold, r.token_count, r.mean_prob + ) + } else { + format!( + "{} low-confidence span(s) at threshold {:.2} ({} tokens, mean p={:.2}).\n\ + Check these claims before trusting the answer.", + r.spans.len(), + r.threshold, + r.token_count, + r.mean_prob + ) } +} + +#[cfg(feature = "live")] +fn fetch_live(prompt: &str, args: &Args) -> Result { + llm_token_visualizer::live::fetch(prompt, &args.model, args.max_tokens) +} - Ok(()) +#[cfg(not(feature = "live"))] +fn fetch_live(_prompt: &str, _args: &Args) -> Result { + anyhow::bail!("this build has no live mode; rebuild with the default \"live\" feature") } fn load_text(args: &Args) -> Result { diff --git a/src/renderer.rs b/src/renderer.rs index 292e7c8..5b21b8c 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -153,7 +153,6 @@ impl Renderer for TerminalRenderer { } } - println!("{}", output); Ok(output) } }