pebaryan commited on
Commit
3183da7
·
1 Parent(s): 0ae9d9f

Improve open source project readiness

Browse files
.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 **Google Scholar** by default (`src/qalmsw/retrieval/scholar.py` via `scholarly`). **Scraping-based**; rate-limits and CAPTCHAs are expected under sustained use. Keep it for personal/interactive runs; fall back to Semantic Scholar or arXiv when CI-scale reliability matters. Backend switching is done at runtime via `qalmsw.retrieval.set_backend()`.
79
- - No SARIF/JSON report formats yet only `report/text.py`. The `Finding` pydantic model is the serialization seam for future support.
 
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
- Automated QA for scientific LaTeX writing, powered by a local LLM (llama.cpp server).
4
- Catches the artefacts that get you a 1-year arXiv ban - before you submit.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ones that get you banned. qalmsw catches all of them.
 
28
 
29
  ## Quick start
30
 
31
  ```bash
32
- pip install -e '.[dev]'
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
+ [![CI](https://github.com/pebaryan/qalmsw/actions/workflows/ci.yml/badge.svg)](https://github.com/pebaryan/qalmsw/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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: :func:`qalmsw.retrieval.search_by_title`.
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, search_by_title
34
 
35
- _EXTRACT_SYSTEM_PROMPT = """You are extracting citation-backed claims from a paragraph of a scientific paper.
 
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 = search_by_title,
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 = f"Cite keys present: {', '.join(sorted(set(cite_keys)))}\n\nParagraph:\n{para.text}"
 
 
 
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 [_finding(para, Severity.info, f"Unknown cite key '{cite_key}' in claim: {claim_text}")]
 
 
 
 
 
 
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}' ({entry.title!r}) — claim unverified.",
 
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
- f"Claim: {claim_text}\n\nCited paper title: {abstract_result.title}\n\nAbstract: {abstract_result.abstract}",
 
 
 
 
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 render_findings, render_findings_json
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 True if any errors found."""
133
  doc = Document.load(file)
134
- console.print(f"[dim]{len(doc.paragraphs)} paragraph(s) parsed[/]")
 
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
- console.print(
147
- f"[dim]{len(inline)} bib entries from inline "
148
- f"\\begin{{thebibliography}}[/]"
149
- )
150
- else:
 
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
- else:
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 LLMClient, LlamaCppClient
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 f in findings:
24
- by_severity[f.severity.value] = by_severity.get(f.severity.value, 0) + 1
 
25
 
26
- payload: dict[str, Any] = {
27
- "file": str(file),
28
- "total": len(findings),
29
  "by_severity": by_severity,
30
- "findings": [_finding_dict(f) for f in findings],
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`` so existing imports
18
- like ``from qalmsw.retrieval import search_by_title`` pick up the change.
 
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
- findings = ClaimsChecker(llm, [_entry("foo", "Big LLMs")], search=_stub_search("Big LLMs")).check(
48
- _doc([para])
 
 
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
- findings = ClaimsChecker(llm, [_entry("foo")], search=_stub_search("A Paper")).check(_doc([para]))
 
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
+ ]