Spaces:
Running
Running
pebaryan commited on
Commit ·
3183da7
1
Parent(s): 0ae9d9f
Improve open source project readiness
Browse files- .github/ISSUE_TEMPLATE/bug_report.md +30 -0
- .github/ISSUE_TEMPLATE/feature_request.md +23 -0
- .github/pull_request_template.md +12 -0
- .github/workflows/ci.yml +38 -0
- CHANGELOG.md +13 -0
- CLAUDE.md +2 -2
- CODE_OF_CONDUCT.md +25 -0
- CONTRIBUTING.md +45 -0
- LICENSE +21 -0
- README.md +30 -5
- ROADMAP.md +51 -0
- SECURITY.md +29 -0
- pyproject.toml +21 -0
- src/qalmsw/checkers/claims.py +25 -9
- src/qalmsw/cli.py +24 -19
- src/qalmsw/llm/__init__.py +1 -1
- src/qalmsw/report/__init__.py +2 -2
- src/qalmsw/report/json.py +35 -6
- src/qalmsw/retrieval/__init__.py +3 -2
- tests/test_claims.py +28 -3
- tests/test_cli.py +58 -0
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug report
|
| 3 |
+
about: Report a reproducible qalmsw problem
|
| 4 |
+
title: ""
|
| 5 |
+
labels: bug
|
| 6 |
+
assignees: ""
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## What happened?
|
| 10 |
+
|
| 11 |
+
Describe the unexpected behavior.
|
| 12 |
+
|
| 13 |
+
## Reproduction
|
| 14 |
+
|
| 15 |
+
1. qalmsw version or commit:
|
| 16 |
+
2. Python version:
|
| 17 |
+
3. Command:
|
| 18 |
+
4. Minimal `.tex` / `.bib` input:
|
| 19 |
+
|
| 20 |
+
```tex
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## Expected behavior
|
| 25 |
+
|
| 26 |
+
Describe what should have happened.
|
| 27 |
+
|
| 28 |
+
## Output
|
| 29 |
+
|
| 30 |
+
Paste the relevant terminal output or JSON report.
|
.github/ISSUE_TEMPLATE/feature_request.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature request
|
| 3 |
+
about: Propose a checker, output format, backend, or workflow improvement
|
| 4 |
+
title: ""
|
| 5 |
+
labels: enhancement
|
| 6 |
+
assignees: ""
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Use case
|
| 10 |
+
|
| 11 |
+
Who needs this, and what paper-writing or review workflow would it improve?
|
| 12 |
+
|
| 13 |
+
## Proposed behavior
|
| 14 |
+
|
| 15 |
+
Describe the command, checker, report field, or integration you want.
|
| 16 |
+
|
| 17 |
+
## Alternatives
|
| 18 |
+
|
| 19 |
+
Describe any current workaround or related tool.
|
| 20 |
+
|
| 21 |
+
## Implementation notes
|
| 22 |
+
|
| 23 |
+
Share links, examples, or constraints that would help a maintainer scope the work.
|
.github/pull_request_template.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Summary
|
| 2 |
+
|
| 3 |
+
-
|
| 4 |
+
|
| 5 |
+
## Testing
|
| 6 |
+
|
| 7 |
+
- [ ] `ruff check .`
|
| 8 |
+
- [ ] `pytest -q`
|
| 9 |
+
|
| 10 |
+
## Notes
|
| 11 |
+
|
| 12 |
+
Mention any behavior changes, network calls, or LLM assumptions reviewers should verify.
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: read
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
test:
|
| 14 |
+
name: Python ${{ matrix.python-version }}
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
strategy:
|
| 17 |
+
fail-fast: false
|
| 18 |
+
matrix:
|
| 19 |
+
python-version: ["3.11", "3.12"]
|
| 20 |
+
|
| 21 |
+
steps:
|
| 22 |
+
- name: Check out repository
|
| 23 |
+
uses: actions/checkout@v4
|
| 24 |
+
|
| 25 |
+
- name: Set up Python
|
| 26 |
+
uses: actions/setup-python@v5
|
| 27 |
+
with:
|
| 28 |
+
python-version: ${{ matrix.python-version }}
|
| 29 |
+
cache: pip
|
| 30 |
+
|
| 31 |
+
- name: Install package
|
| 32 |
+
run: python -m pip install -U pip && python -m pip install -e ".[dev]"
|
| 33 |
+
|
| 34 |
+
- name: Lint
|
| 35 |
+
run: ruff check .
|
| 36 |
+
|
| 37 |
+
- name: Test
|
| 38 |
+
run: pytest -q
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable project changes should be recorded here before a release is tagged.
|
| 4 |
+
|
| 5 |
+
## Unreleased
|
| 6 |
+
|
| 7 |
+
- Added CI configuration for linting and tests on supported Python versions.
|
| 8 |
+
- Added open-source project files: license, contributing guide, security policy,
|
| 9 |
+
code of conduct, issue templates, and pull request template.
|
| 10 |
+
- Improved JSON output so CI consumers receive one parseable document for single
|
| 11 |
+
or multi-file checks.
|
| 12 |
+
- Fixed claims-checker retrieval backend selection so runtime backend switches
|
| 13 |
+
affect new `ClaimsChecker` instances.
|
CLAUDE.md
CHANGED
|
@@ -75,5 +75,5 @@ When adding a checker: drop a file into `src/qalmsw/checkers/`, register it in `
|
|
| 75 |
### What's intentionally *not* here
|
| 76 |
|
| 77 |
- No LLM-assisted citation verification (does this citation actually support this claim?). That's the `claims` checker's territory.
|
| 78 |
-
- Retrieval uses **
|
| 79 |
-
-
|
|
|
|
| 75 |
### What's intentionally *not* here
|
| 76 |
|
| 77 |
- No LLM-assisted citation verification (does this citation actually support this claim?). That's the `claims` checker's territory.
|
| 78 |
+
- Retrieval uses **Semantic Scholar** by default (`src/qalmsw/retrieval/semantic_scholar.py`). Google Scholar remains available as an opt-in scraping backend and may hit rate limits or CAPTCHAs. Backend switching is done at runtime via `qalmsw.retrieval.set_backend()`.
|
| 79 |
+
- JSON report output exists in `report/json.py`; SARIF is still future work. The `Finding` pydantic model is the serialization seam for future output formats.
|
CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Code of Conduct
|
| 2 |
+
|
| 3 |
+
qalmsw should be a practical, respectful place to improve tooling for scientific
|
| 4 |
+
writing quality.
|
| 5 |
+
|
| 6 |
+
## Expected behavior
|
| 7 |
+
|
| 8 |
+
- Be direct and constructive when discussing bugs, limitations, and research claims.
|
| 9 |
+
- Focus criticism on code, data, documentation, and reproducible behavior.
|
| 10 |
+
- Respect differences in experience level, institution, geography, language, and
|
| 11 |
+
research area.
|
| 12 |
+
- Assume public issues and pull requests are archived and searchable.
|
| 13 |
+
|
| 14 |
+
## Unacceptable behavior
|
| 15 |
+
|
| 16 |
+
- Harassment, threats, or personal attacks.
|
| 17 |
+
- Dismissive comments about a contributor's identity, background, or affiliation.
|
| 18 |
+
- Publishing private information without permission.
|
| 19 |
+
- Repeated off-topic or bad-faith disruption.
|
| 20 |
+
|
| 21 |
+
## Enforcement
|
| 22 |
+
|
| 23 |
+
Maintainers may edit, hide, or remove comments; close issues; reject pull requests;
|
| 24 |
+
or block participants when necessary to keep the project usable. Report conduct
|
| 25 |
+
concerns through the maintainer contact listed on the GitHub repository profile.
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to qalmsw
|
| 2 |
+
|
| 3 |
+
qalmsw is a Python CLI for checking scientific LaTeX manuscripts before submission.
|
| 4 |
+
Contributions are welcome when they keep the tool reliable for local, privacy-preserving
|
| 5 |
+
review workflows.
|
| 6 |
+
|
| 7 |
+
## Development setup
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
pip install -e ".[dev]"
|
| 11 |
+
ruff check .
|
| 12 |
+
pytest -q
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
Tests use fake LLM clients and should not require a running llama.cpp server or live
|
| 16 |
+
network access. If a test needs paper metadata, stub the retrieval function.
|
| 17 |
+
|
| 18 |
+
## Project constraints
|
| 19 |
+
|
| 20 |
+
- The default LLM backend is a local llama.cpp server using an OpenAI-compatible API.
|
| 21 |
+
- Do not require hosted LLM providers for core checks.
|
| 22 |
+
- Deterministic checks should run before LLM-backed checks and should work in CI.
|
| 23 |
+
- Checkers should return `Finding` objects instead of printing directly.
|
| 24 |
+
- Do not ask the LLM for line numbers. Ask for excerpts and locate them in source.
|
| 25 |
+
- Keep output stable enough for CI and editor integrations.
|
| 26 |
+
|
| 27 |
+
## Adding a checker
|
| 28 |
+
|
| 29 |
+
1. Add the checker in `src/qalmsw/checkers/`.
|
| 30 |
+
2. Register it in `src/qalmsw/checkers/__init__.py`.
|
| 31 |
+
3. Wire it into `src/qalmsw/cli.py`.
|
| 32 |
+
4. Add focused tests under `tests/` with a fake LLM or stubbed retrieval backend.
|
| 33 |
+
5. Document the checker in `README.md`.
|
| 34 |
+
|
| 35 |
+
## Pull requests
|
| 36 |
+
|
| 37 |
+
Before opening a PR, run:
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
ruff check .
|
| 41 |
+
pytest -q
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
For user-visible behavior changes, include a short example command and explain the
|
| 45 |
+
expected report output.
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 qalmsw contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,7 +1,22 @@
|
|
| 1 |
# qalmsw
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
## What it catches
|
| 7 |
|
|
@@ -24,12 +39,13 @@ Code of Conduct penalises:
|
|
| 24 |
| **Substantive reviewer concerns** | `reviewer` checker - per-section LLM critique |
|
| 25 |
| **Unsupported claims** *(opt-in)* | `claims` checker - checks each \cite-backed claim against the cited paper's abstract |
|
| 26 |
|
| 27 |
-
The first three rows are the
|
|
|
|
| 28 |
|
| 29 |
## Quick start
|
| 30 |
|
| 31 |
```bash
|
| 32 |
-
pip install -e
|
| 33 |
|
| 34 |
# Start llama.cpp server separately, e.g.
|
| 35 |
# ./llama-server -m model.gguf -c 8192 --port 8080
|
|
@@ -40,7 +56,7 @@ qalmsw check --skip-math --skip-grammar --skip-reviewer paper.tex
|
|
| 40 |
qalmsw check --skip-grammar path/to/paper.tex # reviewer + citations + artifacts + references
|
| 41 |
qalmsw check -j 4 path/to/paper.tex # fan out 4 parallel LLM calls
|
| 42 |
qalmsw check --bib refs.bib path/to/paper.tex # override .bib auto-discovery
|
| 43 |
-
qalmsw check --json paper.tex # JSON output for CI
|
| 44 |
qalmsw check ch1.tex ch2.tex ch3.tex # batch mode: check multiple files
|
| 45 |
qalmsw check "src/**/*.tex" # glob expansion
|
| 46 |
|
|
@@ -121,3 +137,12 @@ report.render_findings # rich-formatted terminal output (or --json for CI)
|
|
| 121 |
Deterministic checkers (artifacts, figures, images, citations) run first and always.
|
| 122 |
LLM checkers (grammar, math, reviewer, claims) run only when a server is available.
|
| 123 |
Network checkers (references, claims) make live API calls.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# qalmsw
|
| 2 |
|
| 3 |
+
[](https://github.com/pebaryan/qalmsw/actions/workflows/ci.yml)
|
| 4 |
+
[](LICENSE)
|
| 5 |
+
|
| 6 |
+
Local-first automated QA for scientific LaTeX writing, powered by a local LLM
|
| 7 |
+
through a llama.cpp OpenAI-compatible server.
|
| 8 |
+
|
| 9 |
+
qalmsw catches high-risk artifacts before submission: LLM meta-comments,
|
| 10 |
+
placeholder data, hallucinated references, missing figures, citation mistakes,
|
| 11 |
+
and claim/reference mismatches. Deterministic checks run without an LLM, while
|
| 12 |
+
grammar, math, reviewer, and claims checks are opt-in parts of a local review
|
| 13 |
+
workflow.
|
| 14 |
+
|
| 15 |
+
## Status
|
| 16 |
+
|
| 17 |
+
Pre-1.0 and actively maintained. The package has a Python CLI, focused tests,
|
| 18 |
+
JSON output for CI integrations, and a local-first architecture. See
|
| 19 |
+
[ROADMAP.md](ROADMAP.md) for the maintainer plan and Codex/API credit use case.
|
| 20 |
|
| 21 |
## What it catches
|
| 22 |
|
|
|
|
| 39 |
| **Substantive reviewer concerns** | `reviewer` checker - per-section LLM critique |
|
| 40 |
| **Unsupported claims** *(opt-in)* | `claims` checker - checks each \cite-backed claim against the cited paper's abstract |
|
| 41 |
|
| 42 |
+
The first three rows are the highest-risk categories. qalmsw catches all of them
|
| 43 |
+
with deterministic checks, so they can run in CI without LLM cost.
|
| 44 |
|
| 45 |
## Quick start
|
| 46 |
|
| 47 |
```bash
|
| 48 |
+
pip install -e ".[dev]"
|
| 49 |
|
| 50 |
# Start llama.cpp server separately, e.g.
|
| 51 |
# ./llama-server -m model.gguf -c 8192 --port 8080
|
|
|
|
| 56 |
qalmsw check --skip-grammar path/to/paper.tex # reviewer + citations + artifacts + references
|
| 57 |
qalmsw check -j 4 path/to/paper.tex # fan out 4 parallel LLM calls
|
| 58 |
qalmsw check --bib refs.bib path/to/paper.tex # override .bib auto-discovery
|
| 59 |
+
qalmsw check --json paper.tex # parseable JSON output for CI
|
| 60 |
qalmsw check ch1.tex ch2.tex ch3.tex # batch mode: check multiple files
|
| 61 |
qalmsw check "src/**/*.tex" # glob expansion
|
| 62 |
|
|
|
|
| 137 |
Deterministic checkers (artifacts, figures, images, citations) run first and always.
|
| 138 |
LLM checkers (grammar, math, reviewer, claims) run only when a server is available.
|
| 139 |
Network checkers (references, claims) make live API calls.
|
| 140 |
+
|
| 141 |
+
## Open Source
|
| 142 |
+
|
| 143 |
+
- License: [MIT](LICENSE)
|
| 144 |
+
- Contributing guide: [CONTRIBUTING.md](CONTRIBUTING.md)
|
| 145 |
+
- Security policy: [SECURITY.md](SECURITY.md)
|
| 146 |
+
- Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
| 147 |
+
- Roadmap and funding plan: [ROADMAP.md](ROADMAP.md)
|
| 148 |
+
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
ROADMAP.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Roadmap
|
| 2 |
+
|
| 3 |
+
qalmsw is focused on pre-submission QA for scientific LaTeX manuscripts. The
|
| 4 |
+
near-term goal is to make the deterministic checks strong enough for CI, and the
|
| 5 |
+
LLM-backed checks useful enough for private local review before authors submit.
|
| 6 |
+
|
| 7 |
+
## Current priorities
|
| 8 |
+
|
| 9 |
+
- Keep deterministic artifact, citation, image, and figure/table checks fast and
|
| 10 |
+
suitable for CI.
|
| 11 |
+
- Improve JSON output and add SARIF output so editors, GitHub Actions, and review
|
| 12 |
+
tools can annotate exact manuscript lines.
|
| 13 |
+
- Expand reference verification beyond arXiv and DOI checks with better fallbacks
|
| 14 |
+
for Semantic Scholar, Crossref, and OpenAlex.
|
| 15 |
+
- Build a small public regression corpus of synthetic LaTeX manuscripts that cover
|
| 16 |
+
hallucinated references, placeholder content, missing figures, and unsupported
|
| 17 |
+
claims.
|
| 18 |
+
- Publish reproducible releases with changelogs and pinned test results.
|
| 19 |
+
|
| 20 |
+
## Codex/API Credit Plan
|
| 21 |
+
|
| 22 |
+
OpenAI's Codex open source programs ask how API credits would support project work.
|
| 23 |
+
For qalmsw, credits would be used for bounded maintainer automation and evaluation,
|
| 24 |
+
not for sending users' manuscripts to hosted services by default.
|
| 25 |
+
|
| 26 |
+
Planned uses:
|
| 27 |
+
|
| 28 |
+
- Generate and review checker test cases from synthetic LaTeX fixtures.
|
| 29 |
+
- Compare checker output across models and prompts before releases.
|
| 30 |
+
- Prototype SARIF/GitHub Actions annotations for manuscript review workflows.
|
| 31 |
+
- Triage issues and draft pull-request reviews for community contributions.
|
| 32 |
+
- Build release notes and migration notes from merged changes.
|
| 33 |
+
|
| 34 |
+
User-facing checks should continue to support local llama.cpp by default. Hosted API
|
| 35 |
+
usage belongs in maintainer workflows, opt-in experiments, or clearly documented
|
| 36 |
+
integrations.
|
| 37 |
+
|
| 38 |
+
## Funding Application Draft
|
| 39 |
+
|
| 40 |
+
Short project description:
|
| 41 |
+
|
| 42 |
+
> qalmsw is an open-source Python CLI that checks scientific LaTeX manuscripts for
|
| 43 |
+
> LLM artifacts, placeholder text, hallucinated references, missing figures, citation
|
| 44 |
+
> problems, and unsupported claims before submission.
|
| 45 |
+
|
| 46 |
+
How credits would be used:
|
| 47 |
+
|
| 48 |
+
> API credits would fund maintainer automation: synthetic LaTeX regression fixtures,
|
| 49 |
+
> checker evaluation, issue/PR triage, release-note drafting, and CI annotation
|
| 50 |
+
> prototypes. The user CLI remains local-first with llama.cpp; hosted API use is for
|
| 51 |
+
> project maintenance and opt-in integrations.
|
SECURITY.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## Supported versions
|
| 4 |
+
|
| 5 |
+
qalmsw is pre-1.0. Security fixes are applied to the `main` branch until formal
|
| 6 |
+
releases are published.
|
| 7 |
+
|
| 8 |
+
## Reporting a vulnerability
|
| 9 |
+
|
| 10 |
+
Please do not open a public issue for a vulnerability.
|
| 11 |
+
|
| 12 |
+
Report security-sensitive problems by emailing the maintainer listed on the GitHub
|
| 13 |
+
repository profile, or by opening a private GitHub security advisory if that is
|
| 14 |
+
enabled for the repository.
|
| 15 |
+
|
| 16 |
+
Useful details include:
|
| 17 |
+
|
| 18 |
+
- A minimal input file or command that triggers the issue.
|
| 19 |
+
- Whether the issue requires network retrieval or an LLM server.
|
| 20 |
+
- Any file paths, URLs, or environment variables involved.
|
| 21 |
+
|
| 22 |
+
## Security model
|
| 23 |
+
|
| 24 |
+
qalmsw reads LaTeX and BibTeX files supplied by the user and may call local LLM and
|
| 25 |
+
metadata retrieval services. It should not execute LaTeX, shell commands from input,
|
| 26 |
+
or arbitrary code embedded in a manuscript.
|
| 27 |
+
|
| 28 |
+
The default LLM endpoint is a local llama.cpp server. Users who configure another
|
| 29 |
+
OpenAI-compatible endpoint are responsible for that provider's data handling terms.
|
pyproject.toml
CHANGED
|
@@ -7,7 +7,20 @@ name = "qalmsw"
|
|
| 7 |
version = "0.0.1"
|
| 8 |
description = "Automated QA for scientific LaTeX writing using local LLMs"
|
| 9 |
readme = "README.md"
|
|
|
|
| 10 |
requires-python = ">=3.11"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
dependencies = [
|
| 12 |
"pylatexenc>=2.10",
|
| 13 |
"openai>=1.40",
|
|
@@ -28,6 +41,11 @@ dev = [
|
|
| 28 |
[project.scripts]
|
| 29 |
qalmsw = "qalmsw.cli:app"
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
[tool.hatch.build.targets.wheel]
|
| 32 |
packages = ["src/qalmsw"]
|
| 33 |
|
|
@@ -38,6 +56,9 @@ target-version = "py311"
|
|
| 38 |
[tool.ruff.lint]
|
| 39 |
select = ["E", "F", "I", "UP", "B", "SIM"]
|
| 40 |
|
|
|
|
|
|
|
|
|
|
| 41 |
[tool.pytest.ini_options]
|
| 42 |
testpaths = ["tests", "research/tests"]
|
| 43 |
pythonpath = ["."]
|
|
|
|
| 7 |
version = "0.0.1"
|
| 8 |
description = "Automated QA for scientific LaTeX writing using local LLMs"
|
| 9 |
readme = "README.md"
|
| 10 |
+
license = { file = "LICENSE" }
|
| 11 |
requires-python = ">=3.11"
|
| 12 |
+
keywords = ["latex", "llm", "scientific-writing", "citations", "quality-assurance"]
|
| 13 |
+
classifiers = [
|
| 14 |
+
"Development Status :: 3 - Alpha",
|
| 15 |
+
"Environment :: Console",
|
| 16 |
+
"Intended Audience :: Science/Research",
|
| 17 |
+
"License :: OSI Approved :: MIT License",
|
| 18 |
+
"Programming Language :: Python :: 3",
|
| 19 |
+
"Programming Language :: Python :: 3.11",
|
| 20 |
+
"Programming Language :: Python :: 3.12",
|
| 21 |
+
"Topic :: Scientific/Engineering",
|
| 22 |
+
"Topic :: Text Processing :: Markup :: LaTeX",
|
| 23 |
+
]
|
| 24 |
dependencies = [
|
| 25 |
"pylatexenc>=2.10",
|
| 26 |
"openai>=1.40",
|
|
|
|
| 41 |
[project.scripts]
|
| 42 |
qalmsw = "qalmsw.cli:app"
|
| 43 |
|
| 44 |
+
[project.urls]
|
| 45 |
+
Homepage = "https://github.com/pebaryan/qalmsw"
|
| 46 |
+
Repository = "https://github.com/pebaryan/qalmsw"
|
| 47 |
+
Issues = "https://github.com/pebaryan/qalmsw/issues"
|
| 48 |
+
|
| 49 |
[tool.hatch.build.targets.wheel]
|
| 50 |
packages = ["src/qalmsw"]
|
| 51 |
|
|
|
|
| 56 |
[tool.ruff.lint]
|
| 57 |
select = ["E", "F", "I", "UP", "B", "SIM"]
|
| 58 |
|
| 59 |
+
[tool.ruff.lint.per-file-ignores]
|
| 60 |
+
"src/qalmsw/cli.py" = ["B008"]
|
| 61 |
+
|
| 62 |
[tool.pytest.ini_options]
|
| 63 |
testpaths = ["tests", "research/tests"]
|
| 64 |
pythonpath = ["."]
|
src/qalmsw/checkers/claims.py
CHANGED
|
@@ -14,7 +14,7 @@ Pipeline for each paragraph that contains a ``\\cite{}``:
|
|
| 14 |
|
| 15 |
Design notes:
|
| 16 |
- Scholar is injected as a ``Callable[[str], ScholarResult | None]`` so tests can stub
|
| 17 |
-
it without hitting the network. Default:
|
| 18 |
- Scholar lookups are per-run-cached by bib key. Duplicate ``\\cite`` of the same paper
|
| 19 |
costs one network call, not N.
|
| 20 |
- We never raise on Scholar failures — they become ``info`` findings so the rest of
|
|
@@ -25,14 +25,16 @@ from __future__ import annotations
|
|
| 25 |
from collections.abc import Callable
|
| 26 |
from typing import Any
|
| 27 |
|
|
|
|
| 28 |
from qalmsw.bib import BibEntry
|
| 29 |
from qalmsw.checkers.base import Finding, Severity
|
| 30 |
from qalmsw.document import Document
|
| 31 |
from qalmsw.llm import LLMClient
|
| 32 |
from qalmsw.parse import Paragraph, scan_citations
|
| 33 |
-
from qalmsw.retrieval import ScholarResult
|
| 34 |
|
| 35 |
-
_EXTRACT_SYSTEM_PROMPT = """You are extracting citation-backed claims from a paragraph
|
|
|
|
| 36 |
|
| 37 |
A "claim" is a factual or empirical assertion that is explicitly attributed to one or \
|
| 38 |
more citations. Do NOT include statements that don't carry a citation.
|
|
@@ -84,11 +86,11 @@ class ClaimsChecker:
|
|
| 84 |
self,
|
| 85 |
llm: LLMClient,
|
| 86 |
bib_entries: list[BibEntry],
|
| 87 |
-
search: SearchFn =
|
| 88 |
) -> None:
|
| 89 |
self._llm = llm
|
| 90 |
self._bib_by_key = {e.key: e for e in bib_entries}
|
| 91 |
-
self._search = search
|
| 92 |
self._abstract_cache: dict[str, ScholarResult | None] = {}
|
| 93 |
|
| 94 |
def check(self, doc: Document) -> list[Finding]:
|
|
@@ -103,7 +105,10 @@ class ClaimsChecker:
|
|
| 103 |
return findings
|
| 104 |
|
| 105 |
def _extract_claims(self, para: Paragraph, cite_keys: list[str]) -> list[dict[str, Any]]:
|
| 106 |
-
prompt =
|
|
|
|
|
|
|
|
|
|
| 107 |
result = self._llm.complete_json(_EXTRACT_SYSTEM_PROMPT, prompt)
|
| 108 |
raw_claims = result.get("claims", [])
|
| 109 |
return [c for c in raw_claims if isinstance(c, dict) and c.get("claim")]
|
|
@@ -119,7 +124,13 @@ class ClaimsChecker:
|
|
| 119 |
def _verify_single(self, para: Paragraph, claim_text: str, cite_key: str) -> list[Finding]:
|
| 120 |
entry = self._bib_by_key.get(cite_key)
|
| 121 |
if entry is None:
|
| 122 |
-
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
if not entry.title:
|
| 124 |
return [
|
| 125 |
_finding(
|
|
@@ -134,7 +145,8 @@ class ClaimsChecker:
|
|
| 134 |
_finding(
|
| 135 |
para,
|
| 136 |
Severity.info,
|
| 137 |
-
f"Could not retrieve abstract for '{cite_key}'
|
|
|
|
| 138 |
)
|
| 139 |
]
|
| 140 |
if not abstract_result.abstract:
|
|
@@ -147,7 +159,11 @@ class ClaimsChecker:
|
|
| 147 |
]
|
| 148 |
verdict_raw = self._llm.complete_json(
|
| 149 |
_JUDGE_SYSTEM_PROMPT,
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
)
|
| 152 |
verdict = str(verdict_raw.get("verdict", "unclear")).strip().lower()
|
| 153 |
rationale = str(verdict_raw.get("rationale", "")).strip()
|
|
|
|
| 14 |
|
| 15 |
Design notes:
|
| 16 |
- Scholar is injected as a ``Callable[[str], ScholarResult | None]`` so tests can stub
|
| 17 |
+
it without hitting the network. Default: the active ``qalmsw.retrieval`` backend.
|
| 18 |
- Scholar lookups are per-run-cached by bib key. Duplicate ``\\cite`` of the same paper
|
| 19 |
costs one network call, not N.
|
| 20 |
- We never raise on Scholar failures — they become ``info`` findings so the rest of
|
|
|
|
| 25 |
from collections.abc import Callable
|
| 26 |
from typing import Any
|
| 27 |
|
| 28 |
+
import qalmsw.retrieval as retrieval
|
| 29 |
from qalmsw.bib import BibEntry
|
| 30 |
from qalmsw.checkers.base import Finding, Severity
|
| 31 |
from qalmsw.document import Document
|
| 32 |
from qalmsw.llm import LLMClient
|
| 33 |
from qalmsw.parse import Paragraph, scan_citations
|
| 34 |
+
from qalmsw.retrieval import ScholarResult
|
| 35 |
|
| 36 |
+
_EXTRACT_SYSTEM_PROMPT = """You are extracting citation-backed claims from a paragraph
|
| 37 |
+
of a scientific paper.
|
| 38 |
|
| 39 |
A "claim" is a factual or empirical assertion that is explicitly attributed to one or \
|
| 40 |
more citations. Do NOT include statements that don't carry a citation.
|
|
|
|
| 86 |
self,
|
| 87 |
llm: LLMClient,
|
| 88 |
bib_entries: list[BibEntry],
|
| 89 |
+
search: SearchFn | None = None,
|
| 90 |
) -> None:
|
| 91 |
self._llm = llm
|
| 92 |
self._bib_by_key = {e.key: e for e in bib_entries}
|
| 93 |
+
self._search = search or retrieval.search_by_title
|
| 94 |
self._abstract_cache: dict[str, ScholarResult | None] = {}
|
| 95 |
|
| 96 |
def check(self, doc: Document) -> list[Finding]:
|
|
|
|
| 105 |
return findings
|
| 106 |
|
| 107 |
def _extract_claims(self, para: Paragraph, cite_keys: list[str]) -> list[dict[str, Any]]:
|
| 108 |
+
prompt = (
|
| 109 |
+
f"Cite keys present: {', '.join(sorted(set(cite_keys)))}\n\n"
|
| 110 |
+
f"Paragraph:\n{para.text}"
|
| 111 |
+
)
|
| 112 |
result = self._llm.complete_json(_EXTRACT_SYSTEM_PROMPT, prompt)
|
| 113 |
raw_claims = result.get("claims", [])
|
| 114 |
return [c for c in raw_claims if isinstance(c, dict) and c.get("claim")]
|
|
|
|
| 124 |
def _verify_single(self, para: Paragraph, claim_text: str, cite_key: str) -> list[Finding]:
|
| 125 |
entry = self._bib_by_key.get(cite_key)
|
| 126 |
if entry is None:
|
| 127 |
+
return [
|
| 128 |
+
_finding(
|
| 129 |
+
para,
|
| 130 |
+
Severity.info,
|
| 131 |
+
f"Unknown cite key '{cite_key}' in claim: {claim_text}",
|
| 132 |
+
)
|
| 133 |
+
]
|
| 134 |
if not entry.title:
|
| 135 |
return [
|
| 136 |
_finding(
|
|
|
|
| 145 |
_finding(
|
| 146 |
para,
|
| 147 |
Severity.info,
|
| 148 |
+
f"Could not retrieve abstract for '{cite_key}' "
|
| 149 |
+
f"({entry.title!r}) — claim unverified.",
|
| 150 |
)
|
| 151 |
]
|
| 152 |
if not abstract_result.abstract:
|
|
|
|
| 159 |
]
|
| 160 |
verdict_raw = self._llm.complete_json(
|
| 161 |
_JUDGE_SYSTEM_PROMPT,
|
| 162 |
+
(
|
| 163 |
+
f"Claim: {claim_text}\n\n"
|
| 164 |
+
f"Cited paper title: {abstract_result.title}\n\n"
|
| 165 |
+
f"Abstract: {abstract_result.abstract}"
|
| 166 |
+
),
|
| 167 |
)
|
| 168 |
verdict = str(verdict_raw.get("verdict", "unclear")).strip().lower()
|
| 169 |
rationale = str(verdict_raw.get("rationale", "")).strip()
|
src/qalmsw/cli.py
CHANGED
|
@@ -25,7 +25,7 @@ from qalmsw.checkers import (
|
|
| 25 |
from qalmsw.document import Document
|
| 26 |
from qalmsw.llm import LlamaCppClient
|
| 27 |
from qalmsw.parse import scan_bib_resources
|
| 28 |
-
from qalmsw.report import
|
| 29 |
from qalmsw.retrieval import search_by_title, set_backend
|
| 30 |
|
| 31 |
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
@@ -88,10 +88,11 @@ def check(
|
|
| 88 |
# Select retrieval backend for claims checker
|
| 89 |
set_backend(retrieval)
|
| 90 |
|
| 91 |
-
if len(resolved) > 1:
|
| 92 |
console.print(f"[dim]{len(resolved)} file(s) to check[/]")
|
| 93 |
|
| 94 |
any_errors = False
|
|
|
|
| 95 |
for file in resolved:
|
| 96 |
if not json_output:
|
| 97 |
if len(resolved) > 1:
|
|
@@ -99,7 +100,7 @@ def check(
|
|
| 99 |
else:
|
| 100 |
console.print(f"[bold]{file}[/]")
|
| 101 |
|
| 102 |
-
has_errors = _check_single(
|
| 103 |
file,
|
| 104 |
bib,
|
| 105 |
skip_grammar,
|
|
@@ -113,7 +114,11 @@ def check(
|
|
| 113 |
)
|
| 114 |
if has_errors:
|
| 115 |
any_errors = True
|
|
|
|
|
|
|
| 116 |
|
|
|
|
|
|
|
| 117 |
raise typer.Exit(code=1 if any_errors else 0)
|
| 118 |
|
| 119 |
|
|
@@ -128,14 +133,15 @@ def _check_single(
|
|
| 128 |
base_url: str | None,
|
| 129 |
model: str | None,
|
| 130 |
json_output: bool,
|
| 131 |
-
) -> bool:
|
| 132 |
-
"""Run checks on a single file. Returns
|
| 133 |
doc = Document.load(file)
|
| 134 |
-
|
|
|
|
| 135 |
|
| 136 |
-
bib_paths = list(bib) if bib else _discover_bib_files(doc)
|
| 137 |
bib_entries = _load_bib_entries(bib_paths)
|
| 138 |
-
if bib_paths:
|
| 139 |
console.print(f"[dim]{len(bib_entries)} bib entries from {len(bib_paths)} file(s)[/]")
|
| 140 |
if not bib_entries:
|
| 141 |
inline = extract_inline_bibitems(
|
|
@@ -143,11 +149,12 @@ def _check_single(
|
|
| 143 |
)
|
| 144 |
if inline:
|
| 145 |
bib_entries = inline
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
| 151 |
console.print(
|
| 152 |
"[yellow]warning[/]: no bib entries found (no --bib, no \\bibliography{}, "
|
| 153 |
"no inline \\begin{thebibliography}); citation checks will be limited."
|
|
@@ -176,12 +183,10 @@ def _check_single(
|
|
| 176 |
for c in checkers:
|
| 177 |
findings.extend(c.check(doc))
|
| 178 |
|
| 179 |
-
if json_output:
|
| 180 |
-
console.print(render_findings_json(file, findings))
|
| 181 |
-
else:
|
| 182 |
render_findings(console, file, findings)
|
| 183 |
|
| 184 |
-
return any(f.severity.value == "error" for f in findings)
|
| 185 |
|
| 186 |
|
| 187 |
@app.command()
|
|
@@ -220,7 +225,7 @@ def _resolve_files(paths: list[Path]) -> list[Path]:
|
|
| 220 |
return resolved
|
| 221 |
|
| 222 |
|
| 223 |
-
def _discover_bib_files(doc: Document) -> list[Path]:
|
| 224 |
names = scan_bib_resources(doc.source)
|
| 225 |
base_dir = doc.path.parent
|
| 226 |
resolved: list[Path] = []
|
|
@@ -228,7 +233,7 @@ def _discover_bib_files(doc: Document) -> list[Path]:
|
|
| 228 |
candidate = base_dir / (name if name.endswith(".bib") else f"{name}.bib")
|
| 229 |
if candidate.exists():
|
| 230 |
resolved.append(candidate)
|
| 231 |
-
|
| 232 |
console.print(f"[yellow]warning[/]: referenced bib file not found: {candidate}")
|
| 233 |
return resolved
|
| 234 |
|
|
|
|
| 25 |
from qalmsw.document import Document
|
| 26 |
from qalmsw.llm import LlamaCppClient
|
| 27 |
from qalmsw.parse import scan_bib_resources
|
| 28 |
+
from qalmsw.report import render_batch_findings_json, render_findings
|
| 29 |
from qalmsw.retrieval import search_by_title, set_backend
|
| 30 |
|
| 31 |
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
|
|
| 88 |
# Select retrieval backend for claims checker
|
| 89 |
set_backend(retrieval)
|
| 90 |
|
| 91 |
+
if len(resolved) > 1 and not json_output:
|
| 92 |
console.print(f"[dim]{len(resolved)} file(s) to check[/]")
|
| 93 |
|
| 94 |
any_errors = False
|
| 95 |
+
json_results: list[tuple[Path, list[Finding]]] = []
|
| 96 |
for file in resolved:
|
| 97 |
if not json_output:
|
| 98 |
if len(resolved) > 1:
|
|
|
|
| 100 |
else:
|
| 101 |
console.print(f"[bold]{file}[/]")
|
| 102 |
|
| 103 |
+
has_errors, findings = _check_single(
|
| 104 |
file,
|
| 105 |
bib,
|
| 106 |
skip_grammar,
|
|
|
|
| 114 |
)
|
| 115 |
if has_errors:
|
| 116 |
any_errors = True
|
| 117 |
+
if json_output:
|
| 118 |
+
json_results.append((file, findings))
|
| 119 |
|
| 120 |
+
if json_output:
|
| 121 |
+
console.file.write(render_batch_findings_json(json_results) + "\n")
|
| 122 |
raise typer.Exit(code=1 if any_errors else 0)
|
| 123 |
|
| 124 |
|
|
|
|
| 133 |
base_url: str | None,
|
| 134 |
model: str | None,
|
| 135 |
json_output: bool,
|
| 136 |
+
) -> tuple[bool, list[Finding]]:
|
| 137 |
+
"""Run checks on a single file. Returns error state plus all findings."""
|
| 138 |
doc = Document.load(file)
|
| 139 |
+
if not json_output:
|
| 140 |
+
console.print(f"[dim]{len(doc.paragraphs)} paragraph(s) parsed[/]")
|
| 141 |
|
| 142 |
+
bib_paths = list(bib) if bib else _discover_bib_files(doc, quiet=json_output)
|
| 143 |
bib_entries = _load_bib_entries(bib_paths)
|
| 144 |
+
if bib_paths and not json_output:
|
| 145 |
console.print(f"[dim]{len(bib_entries)} bib entries from {len(bib_paths)} file(s)[/]")
|
| 146 |
if not bib_entries:
|
| 147 |
inline = extract_inline_bibitems(
|
|
|
|
| 149 |
)
|
| 150 |
if inline:
|
| 151 |
bib_entries = inline
|
| 152 |
+
if not json_output:
|
| 153 |
+
console.print(
|
| 154 |
+
f"[dim]{len(inline)} bib entries from inline "
|
| 155 |
+
f"\\begin{{thebibliography}}[/]"
|
| 156 |
+
)
|
| 157 |
+
elif not json_output:
|
| 158 |
console.print(
|
| 159 |
"[yellow]warning[/]: no bib entries found (no --bib, no \\bibliography{}, "
|
| 160 |
"no inline \\begin{thebibliography}); citation checks will be limited."
|
|
|
|
| 183 |
for c in checkers:
|
| 184 |
findings.extend(c.check(doc))
|
| 185 |
|
| 186 |
+
if not json_output:
|
|
|
|
|
|
|
| 187 |
render_findings(console, file, findings)
|
| 188 |
|
| 189 |
+
return any(f.severity.value == "error" for f in findings), findings
|
| 190 |
|
| 191 |
|
| 192 |
@app.command()
|
|
|
|
| 225 |
return resolved
|
| 226 |
|
| 227 |
|
| 228 |
+
def _discover_bib_files(doc: Document, quiet: bool = False) -> list[Path]:
|
| 229 |
names = scan_bib_resources(doc.source)
|
| 230 |
base_dir = doc.path.parent
|
| 231 |
resolved: list[Path] = []
|
|
|
|
| 233 |
candidate = base_dir / (name if name.endswith(".bib") else f"{name}.bib")
|
| 234 |
if candidate.exists():
|
| 235 |
resolved.append(candidate)
|
| 236 |
+
elif not quiet:
|
| 237 |
console.print(f"[yellow]warning[/]: referenced bib file not found: {candidate}")
|
| 238 |
return resolved
|
| 239 |
|
src/qalmsw/llm/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
-
from qalmsw.llm.client import
|
| 2 |
|
| 3 |
__all__ = ["LLMClient", "LlamaCppClient"]
|
|
|
|
| 1 |
+
from qalmsw.llm.client import LlamaCppClient, LLMClient
|
| 2 |
|
| 3 |
__all__ = ["LLMClient", "LlamaCppClient"]
|
src/qalmsw/report/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from qalmsw.report.json import render_findings_json
|
| 2 |
from qalmsw.report.text import render_findings
|
| 3 |
|
| 4 |
-
__all__ = ["render_findings", "render_findings_json"]
|
|
|
|
| 1 |
+
from qalmsw.report.json import render_batch_findings_json, render_findings_json
|
| 2 |
from qalmsw.report.text import render_findings
|
| 3 |
|
| 4 |
+
__all__ = ["render_batch_findings_json", "render_findings", "render_findings_json"]
|
src/qalmsw/report/json.py
CHANGED
|
@@ -8,6 +8,20 @@ from typing import Any
|
|
| 8 |
from qalmsw.checkers import Finding
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def render_findings_json(file: Path, findings: list[Finding]) -> str:
|
| 12 |
"""Return findings as a JSON string, one object with file info and results list.
|
| 13 |
|
|
@@ -19,15 +33,30 @@ def render_findings_json(file: Path, findings: list[Finding]) -> str:
|
|
| 19 |
"findings": [ ... Finding.dict() ... ]
|
| 20 |
}
|
| 21 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
by_severity: dict[str, int] = {}
|
| 23 |
-
for
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
-
payload
|
| 27 |
-
"
|
| 28 |
-
"total":
|
| 29 |
"by_severity": by_severity,
|
| 30 |
-
"
|
| 31 |
}
|
| 32 |
return json.dumps(payload, indent=2, default=str)
|
| 33 |
|
|
|
|
| 8 |
from qalmsw.checkers import Finding
|
| 9 |
|
| 10 |
|
| 11 |
+
def findings_payload(file: Path, findings: list[Finding]) -> dict[str, Any]:
|
| 12 |
+
"""Return the serializable JSON payload for one checked file."""
|
| 13 |
+
by_severity: dict[str, int] = {}
|
| 14 |
+
for f in findings:
|
| 15 |
+
by_severity[f.severity.value] = by_severity.get(f.severity.value, 0) + 1
|
| 16 |
+
|
| 17 |
+
return {
|
| 18 |
+
"file": str(file),
|
| 19 |
+
"total": len(findings),
|
| 20 |
+
"by_severity": by_severity,
|
| 21 |
+
"findings": [_finding_dict(f) for f in findings],
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
def render_findings_json(file: Path, findings: list[Finding]) -> str:
|
| 26 |
"""Return findings as a JSON string, one object with file info and results list.
|
| 27 |
|
|
|
|
| 33 |
"findings": [ ... Finding.dict() ... ]
|
| 34 |
}
|
| 35 |
"""
|
| 36 |
+
return json.dumps(findings_payload(file, findings), indent=2, default=str)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def render_batch_findings_json(results: list[tuple[Path, list[Finding]]]) -> str:
|
| 40 |
+
"""Return parseable JSON for one or more checked files.
|
| 41 |
+
|
| 42 |
+
Single-file output preserves the original object shape. Multi-file output wraps
|
| 43 |
+
per-file payloads in a top-level summary object.
|
| 44 |
+
"""
|
| 45 |
+
if len(results) == 1:
|
| 46 |
+
file, findings = results[0]
|
| 47 |
+
return render_findings_json(file, findings)
|
| 48 |
+
|
| 49 |
+
files = [findings_payload(file, findings) for file, findings in results]
|
| 50 |
by_severity: dict[str, int] = {}
|
| 51 |
+
for file_payload in files:
|
| 52 |
+
for severity, count in file_payload["by_severity"].items():
|
| 53 |
+
by_severity[severity] = by_severity.get(severity, 0) + count
|
| 54 |
|
| 55 |
+
payload = {
|
| 56 |
+
"total_files": len(results),
|
| 57 |
+
"total": sum(file_payload["total"] for file_payload in files),
|
| 58 |
"by_severity": by_severity,
|
| 59 |
+
"files": files,
|
| 60 |
}
|
| 61 |
return json.dumps(payload, indent=2, default=str)
|
| 62 |
|
src/qalmsw/retrieval/__init__.py
CHANGED
|
@@ -14,8 +14,9 @@ __all__ = ["ScholarResult", "search_by_title", "set_backend"]
|
|
| 14 |
def set_backend(name: str) -> None:
|
| 15 |
"""Switch the active retrieval backend at runtime.
|
| 16 |
|
| 17 |
-
This patches the module-level ``search_by_title``
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
Parameters
|
| 21 |
----------
|
|
|
|
| 14 |
def set_backend(name: str) -> None:
|
| 15 |
"""Switch the active retrieval backend at runtime.
|
| 16 |
|
| 17 |
+
This patches the module-level ``search_by_title``. Code that needs runtime
|
| 18 |
+
switching should resolve ``qalmsw.retrieval.search_by_title`` after calling
|
| 19 |
+
this function instead of keeping an earlier direct function import.
|
| 20 |
|
| 21 |
Parameters
|
| 22 |
----------
|
tests/test_claims.py
CHANGED
|
@@ -44,9 +44,12 @@ def test_supports_verdict_produces_no_finding():
|
|
| 44 |
extract=[{"claims": [{"claim": "LLMs are powerful", "cite_keys": ["foo"]}]}],
|
| 45 |
judge=[{"verdict": "supports", "rationale": "abstract confirms"}],
|
| 46 |
)
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
| 49 |
)
|
|
|
|
| 50 |
assert findings == []
|
| 51 |
|
| 52 |
|
|
@@ -56,7 +59,8 @@ def test_contradicts_verdict_produces_error_finding():
|
|
| 56 |
extract=[{"claims": [{"claim": "X always works", "cite_keys": ["foo"]}]}],
|
| 57 |
judge=[{"verdict": "contradicts", "rationale": "abstract says otherwise"}],
|
| 58 |
)
|
| 59 |
-
|
|
|
|
| 60 |
assert len(findings) == 1
|
| 61 |
f = findings[0]
|
| 62 |
assert f.checker == "claims"
|
|
@@ -152,3 +156,24 @@ def test_abstract_is_cached_per_bib_key():
|
|
| 152 |
|
| 153 |
ClaimsChecker(llm, [_entry("foo")], search=counting_search).check(_doc([para1, para2]))
|
| 154 |
assert call_count["n"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
extract=[{"claims": [{"claim": "LLMs are powerful", "cite_keys": ["foo"]}]}],
|
| 45 |
judge=[{"verdict": "supports", "rationale": "abstract confirms"}],
|
| 46 |
)
|
| 47 |
+
checker = ClaimsChecker(
|
| 48 |
+
llm,
|
| 49 |
+
[_entry("foo", "Big LLMs")],
|
| 50 |
+
search=_stub_search("Big LLMs"),
|
| 51 |
)
|
| 52 |
+
findings = checker.check(_doc([para]))
|
| 53 |
assert findings == []
|
| 54 |
|
| 55 |
|
|
|
|
| 59 |
extract=[{"claims": [{"claim": "X always works", "cite_keys": ["foo"]}]}],
|
| 60 |
judge=[{"verdict": "contradicts", "rationale": "abstract says otherwise"}],
|
| 61 |
)
|
| 62 |
+
checker = ClaimsChecker(llm, [_entry("foo")], search=_stub_search("A Paper"))
|
| 63 |
+
findings = checker.check(_doc([para]))
|
| 64 |
assert len(findings) == 1
|
| 65 |
f = findings[0]
|
| 66 |
assert f.checker == "claims"
|
|
|
|
| 156 |
|
| 157 |
ClaimsChecker(llm, [_entry("foo")], search=counting_search).check(_doc([para1, para2]))
|
| 158 |
assert call_count["n"] == 1
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_default_search_uses_active_retrieval_backend(monkeypatch):
|
| 162 |
+
"""ClaimsChecker should pick up runtime backend switches by default."""
|
| 163 |
+
import qalmsw.retrieval as retrieval
|
| 164 |
+
|
| 165 |
+
para = Paragraph(text="LLMs are useful \\cite{foo}.", start_line=1, end_line=1)
|
| 166 |
+
llm = ScriptedLLM(
|
| 167 |
+
extract=[{"claims": [{"claim": "LLMs are useful", "cite_keys": ["foo"]}]}],
|
| 168 |
+
judge=[{"verdict": "supports", "rationale": "abstract confirms"}],
|
| 169 |
+
)
|
| 170 |
+
calls: list[str] = []
|
| 171 |
+
|
| 172 |
+
def active_search(title: str) -> ScholarResult | None:
|
| 173 |
+
calls.append(title)
|
| 174 |
+
return ScholarResult(title=title, authors=[], year=None, abstract="Useful.", url=None)
|
| 175 |
+
|
| 176 |
+
monkeypatch.setattr(retrieval, "search_by_title", active_search)
|
| 177 |
+
ClaimsChecker(llm, [_entry("foo", "Active Backend")]).check(_doc([para]))
|
| 178 |
+
|
| 179 |
+
assert calls == ["Active Backend"]
|
tests/test_cli.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
| 1 |
"""Tests for CLI batch mode and retrieval backend switching."""
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
|
|
|
|
|
|
|
|
|
|
| 4 |
from qalmsw.retrieval import search_by_title, set_backend
|
| 5 |
from qalmsw.retrieval.scholar import search_by_title as gs_search
|
| 6 |
from qalmsw.retrieval.semantic_scholar import search_by_title as ss_search
|
| 7 |
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def test_default_backend_is_semantic_scholar():
|
| 10 |
"""Module-level search_by_title should point to Semantic Scholar by default."""
|
|
@@ -66,3 +72,55 @@ def test_resolve_files_no_match(tmp_path: Path):
|
|
| 66 |
from qalmsw.cli import _resolve_files
|
| 67 |
resolved = _resolve_files([tmp_path / "nonexistent*.tex"])
|
| 68 |
assert resolved == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Tests for CLI batch mode and retrieval backend switching."""
|
| 2 |
+
import json
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
from typer.testing import CliRunner
|
| 6 |
+
|
| 7 |
+
from qalmsw.cli import app
|
| 8 |
from qalmsw.retrieval import search_by_title, set_backend
|
| 9 |
from qalmsw.retrieval.scholar import search_by_title as gs_search
|
| 10 |
from qalmsw.retrieval.semantic_scholar import search_by_title as ss_search
|
| 11 |
|
| 12 |
+
runner = CliRunner()
|
| 13 |
+
|
| 14 |
|
| 15 |
def test_default_backend_is_semantic_scholar():
|
| 16 |
"""Module-level search_by_title should point to Semantic Scholar by default."""
|
|
|
|
| 72 |
from qalmsw.cli import _resolve_files
|
| 73 |
resolved = _resolve_files([tmp_path / "nonexistent*.tex"])
|
| 74 |
assert resolved == []
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_json_output_is_parseable_without_status_lines(tmp_path: Path):
|
| 78 |
+
"""--json should emit only JSON so CI tools can parse stdout."""
|
| 79 |
+
tex = tmp_path / "paper.tex"
|
| 80 |
+
tex.write_text(r"\documentclass{article}\begin{document}Plain text.\end{document}")
|
| 81 |
+
|
| 82 |
+
result = runner.invoke(
|
| 83 |
+
app,
|
| 84 |
+
[
|
| 85 |
+
"check",
|
| 86 |
+
"--skip-grammar",
|
| 87 |
+
"--skip-math",
|
| 88 |
+
"--skip-reviewer",
|
| 89 |
+
"--json",
|
| 90 |
+
str(tex),
|
| 91 |
+
],
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
assert result.exit_code == 0
|
| 95 |
+
payload = json.loads(result.stdout)
|
| 96 |
+
assert payload["file"] == str(tex)
|
| 97 |
+
assert payload["findings"] == []
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_json_output_batches_multiple_files(tmp_path: Path):
|
| 101 |
+
"""Multi-file --json should still be one parseable JSON document."""
|
| 102 |
+
tex1 = tmp_path / "a.tex"
|
| 103 |
+
tex2 = tmp_path / "b.tex"
|
| 104 |
+
tex1.write_text(r"\documentclass{article}\begin{document}A.\end{document}")
|
| 105 |
+
tex2.write_text(r"\documentclass{article}\begin{document}B.\end{document}")
|
| 106 |
+
|
| 107 |
+
result = runner.invoke(
|
| 108 |
+
app,
|
| 109 |
+
[
|
| 110 |
+
"check",
|
| 111 |
+
"--skip-grammar",
|
| 112 |
+
"--skip-math",
|
| 113 |
+
"--skip-reviewer",
|
| 114 |
+
"--json",
|
| 115 |
+
str(tex1),
|
| 116 |
+
str(tex2),
|
| 117 |
+
],
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
assert result.exit_code == 0
|
| 121 |
+
payload = json.loads(result.stdout)
|
| 122 |
+
assert payload["total_files"] == 2
|
| 123 |
+
assert [file_payload["file"] for file_payload in payload["files"]] == [
|
| 124 |
+
str(tex1),
|
| 125 |
+
str(tex2),
|
| 126 |
+
]
|