| import asyncio |
| import hashlib |
| import json |
| import os |
| import re |
| from contextlib import asynccontextmanager |
| from pathlib import Path |
|
|
| from dotenv import load_dotenv |
| from fastapi import FastAPI, HTTPException, UploadFile |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import FileResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel |
| from sse_starlette.sse import EventSourceResponse |
|
|
| from .annotate import (CompletionCache, ProviderError, build_prompt, cache_key, |
| stream_completion) |
| from .bootstrap import ensure_corpus |
| from .equations import anchor_equations, extract_equations |
| from .figures import extract_figures |
| from .jobs import STAGES, Job, JobRegistry, emit |
| from .parse import parse_pdf, parse_text |
| from .references import parse_references |
| from .retrieve import Corpus, embed_batch, retrieve_for_paragraphs |
| from .schemas import Paragraph |
|
|
| load_dotenv(Path(__file__).resolve().parent.parent / ".env") |
|
|
| |
| |
| |
|
|
| _DEFAULT_DATA_DIR = Path(__file__).resolve().parent.parent / "data" |
| |
| _REPO_ROOT = Path(__file__).resolve().parent.parent.parent |
|
|
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| if os.environ.get("ASTROPARSE_SKIP_CORPUS") != "1": |
| corpus_dir = Path( |
| os.environ.get("ASTROPARSE_CORPUS_DIR", str(_DEFAULT_DATA_DIR / "corpus")) |
| ) |
| faiss_path = Path( |
| os.environ.get("ASTROPARSE_FAISS_PATH", str(_DEFAULT_DATA_DIR / "astroparse_fp16.faiss")) |
| ) |
| beacon_repo = os.environ.get("BEACON_CORPUS_REPO", "kiyer/beacon_corpus") |
| await asyncio.to_thread(ensure_corpus, corpus_dir, faiss_path, beacon_repo) |
| await asyncio.to_thread(get_corpus) |
| yield |
|
|
|
|
| app = FastAPI(lifespan=lifespan) |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["http://localhost:5173"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| registry = JobRegistry() |
| cache = CompletionCache( |
| os.environ.get( |
| "ASTROPARSE_CACHE_PATH", |
| str(Path(__file__).resolve().parent.parent / "annotations_cache.sqlite"), |
| ) |
| ) |
| _corpus: Corpus | None = None |
|
|
| |
| _DEFAULT_FIGURES_DIR = Path(__file__).resolve().parent.parent / "data" / "figures" |
|
|
| |
| _HASH_RE = re.compile(r"^[0-9a-f]{64}$") |
| _FIGID_RE = re.compile(r"^(f|eq)\d+$") |
|
|
|
|
| def get_corpus() -> Corpus: |
| """Lazy singleton; tests monkeypatch this function.""" |
| global _corpus |
| if _corpus is None: |
| _corpus = Corpus.load() |
| return _corpus |
|
|
|
|
| def get_figures_dir() -> Path: |
| """Return the figures base directory, from env or default.""" |
| return Path(os.environ.get("ASTROPARSE_FIGURES_DIR", str(_DEFAULT_FIGURES_DIR))) |
|
|
|
|
| _TEXT_SUFFIXES = {".txt", ".md", ".markdown"} |
|
|
|
|
| def _is_text_upload(file_name: str, content_type: str) -> bool: |
| """Return True when the upload is plaintext/markdown (not a PDF).""" |
| suffix = Path(file_name).suffix.lower() |
| return suffix in _TEXT_SUFFIXES or content_type.startswith("text/") |
|
|
|
|
| async def run_pipeline(job: Job, pdf_bytes: bytes, file_name: str, content_type: str = ""): |
| try: |
| async def stage(i, status, log=None): |
| await emit(job, "stage", {"index": i, "name": STAGES[i], "status": status, "log": log}) |
|
|
| is_text = _is_text_upload(file_name, content_type) |
|
|
| |
| await stage(0, "active") |
| if is_text: |
| paper, raw_refs = await asyncio.to_thread(parse_text, pdf_bytes, file_name) |
| await stage(0, "done", "plaintext manuscript — no leaves to cut") |
| else: |
| paper, raw_refs = await asyncio.to_thread(parse_pdf, pdf_bytes, file_name) |
| await stage(0, "done", f"{paper.pages} leaves read") |
|
|
| |
| references_out: list[dict] = [] |
| cite_index: dict[str, list[str]] = {} |
|
|
| |
| await stage(1, "active") |
| await stage(1, "done", "headers and footers set aside") |
|
|
| |
| await stage(2, "active") |
| await stage(2, "done", f"{len(paper.paragraphs)} paragraphs ruled") |
|
|
| |
| pdf_hash = hashlib.sha256(pdf_bytes).hexdigest() |
|
|
| if is_text: |
| |
| figures = [] |
| fig_embs = [] |
| equations_out = [] |
| else: |
| |
| figures_dir = get_figures_dir() / pdf_hash |
| figures_dir.mkdir(parents=True, exist_ok=True) |
| figures = await asyncio.to_thread(extract_figures, pdf_bytes, figures_dir) |
| eq_raw = await asyncio.to_thread(extract_equations, pdf_bytes, figures_dir) |
| equations_with_anchor = anchor_equations(eq_raw, paper.paragraphs) |
| equations_out = [ |
| {"id": e["id"], "page": e["page"], "tag": e["tag"], |
| "afterPara": e["afterPara"], "hasImage": e["hasImage"]} |
| for e in equations_with_anchor |
| ] |
|
|
| |
| await stage(3, "active") |
| key = os.environ.get("OPENAI_API_KEY", "") |
|
|
| |
| para_texts = [p.text for p in paper.paragraphs] |
| if is_text: |
| all_texts = para_texts |
| else: |
| fig_captions = [f.caption for f in figures] |
| all_texts = para_texts + fig_captions |
|
|
| all_embs = await asyncio.to_thread(embed_batch, all_texts, key) |
|
|
| |
| para_embs = all_embs[:len(para_texts)] |
| if not is_text: |
| fig_embs = all_embs[len(para_texts):] |
|
|
| await stage(3, "done", f"{len(all_embs)} passages committed to memory") |
|
|
| |
| await stage(4, "active") |
| corpus = await asyncio.to_thread(get_corpus) |
| await stage(4, "done", "lexicon ready") |
|
|
| |
| parsed_refs, cite_index = parse_references( |
| raw_refs, corpus.arxiv_ids, corpus.bibcodes |
| ) |
| references_out = [ |
| { |
| "id": r.id, "raw": r.raw, "short": r.short, "year": r.year, |
| "bibcode": r.bibcode, "arxiv": r.arxiv, "corpusMatch": r.corpus_match, |
| } |
| for r in parsed_refs |
| ] |
|
|
| |
| await stage(5, "active") |
| lit_papers, lit_by_para = await asyncio.to_thread( |
| retrieve_for_paragraphs, corpus, paper.paragraphs, para_embs |
| ) |
|
|
| if is_text: |
| lit_by_fig = {} |
| else: |
| |
| fig_pseudo_paras = [ |
| Paragraph(id=fig.id, section=fig.label, text=fig.caption) |
| for fig in figures |
| ] |
| if fig_pseudo_paras: |
| fig_lit_papers, lit_by_fig = await asyncio.to_thread( |
| retrieve_for_paragraphs, corpus, fig_pseudo_paras, fig_embs |
| ) |
| |
| for k, v in fig_lit_papers.items(): |
| if k not in lit_papers: |
| lit_papers[k] = v |
| else: |
| lit_by_fig = {} |
|
|
| await stage(5, "done", f"{len(lit_papers)} works retrieved from the library") |
|
|
| |
| await stage(6, "active") |
| if is_text: |
| await stage(6, "done", "0 figures set in plates") |
| else: |
| await stage(6, "done", f"{len(figures)} figures set in plates") |
|
|
| await emit(job, "result", { |
| "paper": paper.model_dump(), |
| "litPapers": {k: v.model_dump() for k, v in lit_papers.items()}, |
| "litByPara": lit_by_para, |
| "pdfHash": pdf_hash, |
| "figures": [f.model_dump() for f in figures], |
| "litByFig": lit_by_fig, |
| "equations": equations_out, |
| "references": references_out, |
| "citeIndex": cite_index, |
| }) |
| except Exception as e: |
| await emit(job, "error", {"message": str(e)[:300]}) |
|
|
|
|
| @app.post("/api/parse", status_code=202) |
| async def parse_endpoint(file: UploadFile): |
| pdf_bytes = await file.read() |
| job = registry.create() |
| asyncio.create_task(run_pipeline( |
| job, pdf_bytes, |
| file.filename or "manuscript.pdf", |
| file.content_type or "", |
| )) |
| return {"jobId": job.id} |
|
|
|
|
| @app.get("/api/jobs/{job_id}/events") |
| async def job_events(job_id: str): |
| job = registry.get(job_id) |
| if job is None: |
| raise HTTPException(404, "unknown job") |
|
|
| async def gen(): |
| while True: |
| item = await job.queue.get() |
| if item is None: |
| break |
| yield {"event": item["event"], "data": json.dumps(item["data"])} |
|
|
| return EventSourceResponse(gen()) |
|
|
|
|
| @app.get("/api/figures/{pdf_hash}/{fig_id}.png") |
| async def serve_figure(pdf_hash: str, fig_id: str): |
| """Serve a figure PNG; validates path params to prevent traversal.""" |
| if not _HASH_RE.match(pdf_hash) or not _FIGID_RE.match(fig_id): |
| raise HTTPException(404, "not found") |
| png_path = get_figures_dir() / pdf_hash / f"{fig_id}.png" |
| if not png_path.is_file(): |
| raise HTTPException(404, "not found") |
| return FileResponse(str(png_path), media_type="image/png") |
|
|
|
|
| class PaperContext(BaseModel): |
| title: str = "" |
| sectionOutline: list[str] = [] |
| opening: str = "" |
| prevParagraph: str = "" |
| nextParagraph: str = "" |
|
|
|
|
| class AnnotateRequest(BaseModel): |
| paragraphId: str |
| paragraph: str |
| section: str |
| mode: str |
| lit: list[dict] = [] |
| provider: str |
| model: str |
| key: str |
| paperContext: PaperContext | None = None |
|
|
|
|
| @app.post("/api/annotate") |
| async def annotate(req: AnnotateRequest): |
| async def gen(): |
| if not req.key: |
| yield { |
| "event": "error", |
| "data": json.dumps({"message": "no API key set — open the annotator settings"}), |
| } |
| return |
| ctx = req.paperContext.model_dump() if req.paperContext else None |
| ck = cache_key(req.paragraph, req.mode, [l["id"] for l in req.lit], req.model, |
| paper_context=ctx) |
| hit = cache.get(ck) |
| if hit is not None: |
| yield {"event": "done", "data": json.dumps({"text": hit, "cached": True})} |
| return |
| prompt = build_prompt(req.paragraph, req.section, req.mode, req.lit, |
| paper_context=ctx) |
| full = [] |
| try: |
| async for chunk in stream_completion(req.provider, req.model, prompt, req.key): |
| full.append(chunk) |
| yield {"event": "token", "data": json.dumps({"text": chunk})} |
| except ProviderError as e: |
| yield {"event": "error", "data": json.dumps({"message": str(e)})} |
| return |
| except Exception as e: |
| |
| |
| yield {"event": "error", |
| "data": json.dumps({"message": f"could not reach {req.provider} ({type(e).__name__})"})} |
| return |
| text = "".join(full).strip() |
| cache.put(ck, text) |
| yield {"event": "done", "data": json.dumps({"text": text, "cached": False})} |
|
|
| return EventSourceResponse(gen()) |
|
|
|
|
| |
| |
| |
|
|
| _static_dir = Path( |
| os.environ.get("BEACON_STATIC_DIR", str(_REPO_ROOT / "frontend" / "dist")) |
| ) |
| if _static_dir.is_dir(): |
| app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="spa") |
|
|