Spaces:
Running
Running
| """Gradio frontend for Hugging Face Spaces.""" | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import shutil | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Any | |
| import gradio as gr | |
| from qalmsw.bib import BibEntry, extract_inline_bibitems, parse_bib_file | |
| from qalmsw.checkers import ( | |
| ArtifactChecker, | |
| Checker, | |
| CitationChecker, | |
| ClaimsChecker, | |
| FigureTableChecker, | |
| Finding, | |
| GrammarChecker, | |
| ImageChecker, | |
| MathChecker, | |
| ReferenceChecker, | |
| ReviewerChecker, | |
| ) | |
| from qalmsw.document import Document | |
| from qalmsw.llm import LlamaCppClient | |
| from qalmsw.report.json import findings_payload | |
| from qalmsw.retrieval import set_backend | |
| _GRAPHICS_RE = re.compile(r"\\includegraphics(?:\s*\[[^\]]*\])?\s*\{([^}]+)\}") | |
| _SAMPLE = r"""\documentclass{article} | |
| \begin{document} | |
| \section{Results} | |
| As an AI language model, I cannot verify these numbers. | |
| Prior work established the result \cite{missingref}. | |
| \begin{figure} | |
| \includegraphics{figures/result.png} | |
| \caption{Insert caption here} | |
| \label{fig:result} | |
| \end{figure} | |
| \end{document} | |
| """ | |
| _THEME = gr.themes.Soft(primary_hue="teal", neutral_hue="slate") | |
| _CSS = """ | |
| .status-pill { | |
| border: 1px solid #d6d3d1; | |
| border-radius: 8px; | |
| padding: 6px 10px; | |
| display: inline-block; | |
| background: #fafaf9; | |
| } | |
| """ | |
| def check_manuscript( | |
| tex_file: str | None, | |
| tex_source: str | None, | |
| bib_files: list[str] | None, | |
| bib_source: str | None, | |
| project_files: list[str] | None, | |
| verify_references: bool, | |
| run_grammar: bool, | |
| run_math: bool, | |
| run_reviewer: bool, | |
| run_claims: bool, | |
| retrieval_backend: str, | |
| concurrency: int, | |
| llm_base_url: str | None, | |
| llm_model: str | None, | |
| llm_api_key: str | None, | |
| ) -> tuple[str, list[list[Any]], dict[str, Any]]: | |
| """Run qalmsw checks from uploaded or pasted manuscript content.""" | |
| with tempfile.TemporaryDirectory(prefix="qalmsw-space-") as tmp: | |
| tmp_dir = Path(tmp) | |
| main_path, source = _prepare_main_tex(tmp_dir, tex_file, tex_source) | |
| _copy_project_files(tmp_dir, project_files or [], source) | |
| bib_paths = _prepare_bib_files(tmp_dir, bib_files or [], bib_source) | |
| doc = Document.load(main_path) | |
| bib_entries = _load_bib_entries(bib_paths) | |
| notes: list[str] = [f"Parsed {len(doc.paragraphs)} paragraph(s)."] | |
| if not bib_entries: | |
| inline_entries = extract_inline_bibitems( | |
| doc.source, | |
| line_map=doc.line_map, | |
| default_file=doc.path, | |
| ) | |
| if inline_entries: | |
| bib_entries = inline_entries | |
| notes.append(f"Loaded {len(inline_entries)} inline bibliography item(s).") | |
| else: | |
| notes.append("No bibliography entries found; citation checks are limited.") | |
| elif bib_paths: | |
| notes.append(f"Loaded {len(bib_entries)} bibliography entry/entries.") | |
| set_backend(retrieval_backend) | |
| checkers = _build_checkers( | |
| bib_entries=bib_entries, | |
| verify_references=verify_references, | |
| run_grammar=run_grammar, | |
| run_math=run_math, | |
| run_reviewer=run_reviewer, | |
| run_claims=run_claims, | |
| concurrency=concurrency, | |
| llm_base_url=llm_base_url, | |
| llm_model=llm_model, | |
| llm_api_key=llm_api_key, | |
| notes=notes, | |
| ) | |
| findings: list[Finding] = [] | |
| for checker in checkers: | |
| findings.extend(checker.check(doc)) | |
| payload = findings_payload(main_path, findings) | |
| return _summary(payload, notes), _finding_rows(findings), payload | |
| def _prepare_main_tex( | |
| tmp_dir: Path, | |
| tex_file: str | None, | |
| tex_source: str | None, | |
| ) -> tuple[Path, str]: | |
| source = (tex_source or "").strip() | |
| if source: | |
| path = tmp_dir / "main.tex" | |
| path.write_text(source + "\n", encoding="utf-8") | |
| return path, source | |
| if not tex_file: | |
| raise gr.Error("Upload a .tex file or paste LaTeX source.") | |
| upload = Path(tex_file) | |
| path = tmp_dir / _safe_name(upload.name, fallback="main.tex") | |
| shutil.copyfile(upload, path) | |
| return path, path.read_text(encoding="utf-8", errors="replace") | |
| def _prepare_bib_files(tmp_dir: Path, bib_files: list[str], bib_source: str | None) -> list[Path]: | |
| paths: list[Path] = [] | |
| if (bib_source or "").strip(): | |
| path = tmp_dir / "refs.bib" | |
| path.write_text((bib_source or "").strip() + "\n", encoding="utf-8") | |
| paths.append(path) | |
| for upload in bib_files: | |
| source = Path(upload) | |
| target = tmp_dir / _safe_name(source.name, fallback="refs.bib") | |
| shutil.copyfile(source, target) | |
| paths.append(target) | |
| return paths | |
| def _copy_project_files(tmp_dir: Path, uploads: list[str], tex_source: str) -> None: | |
| graphics_paths = {Path(match.group(1).strip()) for match in _GRAPHICS_RE.finditer(tex_source)} | |
| for upload in uploads: | |
| source = Path(upload) | |
| safe_name = _safe_name(source.name, fallback="upload") | |
| target = tmp_dir / safe_name | |
| shutil.copyfile(source, target) | |
| for graphics_path in graphics_paths: | |
| if graphics_path.name == safe_name and not graphics_path.is_absolute(): | |
| nested_target = tmp_dir / graphics_path | |
| nested_target.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copyfile(source, nested_target) | |
| def _load_bib_entries(paths: list[Path]) -> list[BibEntry]: | |
| entries: list[BibEntry] = [] | |
| for path in paths: | |
| entries.extend(parse_bib_file(path)) | |
| return entries | |
| def _build_checkers( | |
| bib_entries: list[BibEntry], | |
| verify_references: bool, | |
| run_grammar: bool, | |
| run_math: bool, | |
| run_reviewer: bool, | |
| run_claims: bool, | |
| concurrency: int, | |
| llm_base_url: str | None, | |
| llm_model: str | None, | |
| llm_api_key: str | None, | |
| notes: list[str], | |
| ) -> list[Checker]: | |
| checkers: list[Checker] = [ | |
| ArtifactChecker(), | |
| FigureTableChecker(), | |
| ImageChecker(), | |
| CitationChecker(bib_entries), | |
| ] | |
| if verify_references and bib_entries: | |
| checkers.append(ReferenceChecker(bib_entries)) | |
| wants_llm = run_grammar or run_math or run_reviewer or run_claims | |
| base_url = _clean(llm_base_url) or os.environ.get("QALMSW_BASE_URL") | |
| model = _clean(llm_model) or os.environ.get("QALMSW_MODEL") or "local-model" | |
| api_key = _clean(llm_api_key) or os.environ.get("QALMSW_API_KEY") | |
| if wants_llm and not base_url: | |
| notes.append("LLM checks skipped because no backend URL was provided.") | |
| return checkers | |
| if wants_llm: | |
| notes.append(f"LLM checks using {base_url} with model {model}.") | |
| llm = LlamaCppClient(base_url=base_url, model=model, api_key=api_key) | |
| if run_grammar: | |
| checkers.append(GrammarChecker(llm, concurrency=concurrency)) | |
| if run_math: | |
| checkers.append(MathChecker(llm, concurrency=concurrency)) | |
| if run_reviewer: | |
| checkers.append(ReviewerChecker(llm, concurrency=concurrency)) | |
| if run_claims: | |
| checkers.append(ClaimsChecker(llm, bib_entries)) | |
| return checkers | |
| def _summary(payload: dict[str, Any], notes: list[str]) -> str: | |
| by_severity = payload["by_severity"] | |
| parts = [ | |
| f"Total: {payload['total']}", | |
| f"Errors: {by_severity.get('error', 0)}", | |
| f"Warnings: {by_severity.get('warning', 0)}", | |
| f"Info: {by_severity.get('info', 0)}", | |
| ] | |
| note_text = "\n".join(f"- {note}" for note in notes) | |
| return "**" + " | ".join(parts) + "**\n\n" + note_text | |
| def _finding_rows(findings: list[Finding]) -> list[list[Any]]: | |
| rows: list[list[Any]] = [] | |
| for finding in findings: | |
| rows.append( | |
| [ | |
| finding.severity.value, | |
| finding.checker, | |
| finding.file or "", | |
| finding.line, | |
| finding.message, | |
| finding.suggestion or "", | |
| ] | |
| ) | |
| return rows | |
| def _safe_name(name: str, fallback: str) -> str: | |
| candidate = Path(name).name.strip() | |
| if not candidate or candidate in {".", ".."}: | |
| return fallback | |
| return candidate | |
| def _clean(value: str | None) -> str | None: | |
| if value is None: | |
| return None | |
| stripped = value.strip() | |
| return stripped or None | |
| def _llm_status() -> str: | |
| if os.environ.get("QALMSW_BASE_URL"): | |
| return "✅ LLM backend configured — all checkers available" | |
| return "⚠️ Deterministic checks only — enable [LLM] checkers by configuring the backend below" | |
| with gr.Blocks(title="qalmsw", theme=_THEME, css=_CSS) as demo: | |
| gr.Markdown("# qalmsw") | |
| gr.Markdown(f"<span class='status-pill'>{_llm_status()}</span>") | |
| with gr.Row(): | |
| with gr.Column(scale=5, min_width=360): | |
| tex_file = gr.File(label="Main .tex file", file_types=[".tex"], type="filepath") | |
| with gr.Accordion("LaTeX source", open=False): | |
| tex_source = gr.Code(label="LaTeX source", value=_SAMPLE, lines=18) | |
| bib_files = gr.File( | |
| label=".bib files", | |
| file_types=[".bib"], | |
| file_count="multiple", | |
| type="filepath", | |
| ) | |
| with gr.Accordion("BibTeX source", open=False): | |
| bib_source = gr.Code(label="BibTeX source", lines=8) | |
| project_files = gr.File( | |
| label="Project files", | |
| file_count="multiple", | |
| type="filepath", | |
| ) | |
| with gr.Column(scale=4, min_width=320): | |
| with gr.Group(): | |
| gr.Markdown("**Always runs:** artifacts, citations, figures, images") | |
| gr.Markdown("### Optional checks") | |
| verify_references = gr.Checkbox(label="Verify arXiv IDs and DOIs", value=False) | |
| run_grammar = gr.Checkbox(label="Grammar [LLM]", value=False) | |
| run_math = gr.Checkbox(label="Math consistency [LLM]", value=False) | |
| run_reviewer = gr.Checkbox(label="Reviewer critique [LLM]", value=False) | |
| run_claims = gr.Checkbox(label="Claim support [LLM]", value=False) | |
| retrieval_backend = gr.Dropdown( | |
| label="Retrieval backend", | |
| choices=["semantic-scholar", "google-scholar"], | |
| value="semantic-scholar", | |
| ) | |
| concurrency = gr.Slider( | |
| label="Concurrency", | |
| minimum=1, | |
| maximum=8, | |
| step=1, | |
| value=1, | |
| ) | |
| with gr.Accordion("LLM backend config", open=False): | |
| gr.Markdown("Set `QALMSW_BASE_URL` and `QALMSW_MODEL` as Space secrets to enable [LLM] checkers.") | |
| llm_base_url = gr.Textbox( | |
| label="Base URL", | |
| placeholder="https://api.openai.com/v1", | |
| ) | |
| llm_model = gr.Textbox( | |
| label="Model", | |
| placeholder="provider-model-id or local-model", | |
| ) | |
| llm_api_key = gr.Textbox( | |
| label="API key", | |
| type="password", | |
| placeholder="Optional; leave blank to use Space secret", | |
| ) | |
| run = gr.Button("Run checks", variant="primary") | |
| gr.Markdown( | |
| "Upload a .tex file (or paste source) and optional .bib files, " | |
| "then click **Run checks**. Always-on checks run automatically; " | |
| "toggle optional checks above. Results appear below.", | |
| ) | |
| summary = gr.Markdown() | |
| findings_table = gr.DataFrame( | |
| headers=["Severity", "Checker", "File", "Line", "Message", "Suggestion"], | |
| datatype=["str", "str", "str", "number", "str", "str"], | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| json_output = gr.JSON(label="JSON") | |
| tex_file.change( | |
| lambda path: Path(path).read_text(encoding="utf-8", errors="replace") if path else _SAMPLE, | |
| inputs=[tex_file], | |
| outputs=[tex_source], | |
| ) | |
| bib_files.change( | |
| lambda paths: "\n\n".join(Path(p).read_text(encoding="utf-8", errors="replace") for p in paths) if paths else "", | |
| inputs=[bib_files], | |
| outputs=[bib_source], | |
| ) | |
| run.click( | |
| check_manuscript, | |
| inputs=[ | |
| tex_file, | |
| tex_source, | |
| bib_files, | |
| bib_source, | |
| project_files, | |
| verify_references, | |
| run_grammar, | |
| run_math, | |
| run_reviewer, | |
| run_claims, | |
| retrieval_backend, | |
| concurrency, | |
| llm_base_url, | |
| llm_model, | |
| llm_api_key, | |
| ], | |
| outputs=[summary, findings_table, json_output], | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue().launch() | |