Spaces:
Sleeping
Sleeping
Kevin Wu
commited on
Commit
Β·
b3b5141
1
Parent(s):
5f5d646
put in API
Browse files- Dockerfile +23 -10
- app.py +0 -0
- main.py +1 -0
- requirements.txt +6 -1
- reviewer/__init__.py +5 -0
- reviewer/api.py +36 -0
- reviewer/cli.py +86 -0
- reviewer/logger.py +37 -0
- reviewer/reviewer.py +363 -0
- reviewer/utils.py +64 -0
Dockerfile
CHANGED
@@ -1,17 +1,30 @@
|
|
1 |
-
FROM python:3.9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
-
# install system-wide (root)
|
4 |
WORKDIR /app
|
5 |
COPY requirements.txt .
|
6 |
RUN pip install --no-cache-dir -r requirements.txt
|
7 |
|
8 |
-
#
|
9 |
-
|
10 |
-
USER user
|
11 |
-
ENV PYTHONUNBUFFERED=1 \
|
12 |
-
PATH="/home/user/.local/bin:$PATH"
|
13 |
-
|
14 |
-
# copy the source code
|
15 |
-
COPY --chown=user . /app
|
16 |
|
|
|
|
|
17 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
1 |
+
# FROM python:3.9
|
2 |
+
|
3 |
+
# # install system-wide (root)
|
4 |
+
# WORKDIR /app
|
5 |
+
# COPY requirements.txt .
|
6 |
+
# RUN pip install --no-cache-dir -r requirements.txt
|
7 |
+
|
8 |
+
# # now drop privs
|
9 |
+
# RUN useradd -m -u 1000 user
|
10 |
+
# USER user
|
11 |
+
# ENV PYTHONUNBUFFERED=1 \
|
12 |
+
# PATH="/home/user/.local/bin:$PATH"
|
13 |
+
|
14 |
+
# # copy the source code
|
15 |
+
# COPY --chown=user . /app
|
16 |
+
|
17 |
+
# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
18 |
+
|
19 |
+
FROM python:3.11-slim
|
20 |
|
|
|
21 |
WORKDIR /app
|
22 |
COPY requirements.txt .
|
23 |
RUN pip install --no-cache-dir -r requirements.txt
|
24 |
|
25 |
+
# copy source *after* deps to use layer caching
|
26 |
+
COPY . .
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
|
28 |
+
# expose any port (HF maps it); 7860 is conventional
|
29 |
+
EXPOSE 7860
|
30 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app.py
DELETED
File without changes
|
main.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
from fastapi import FastAPI
|
|
|
2 |
|
3 |
app = FastAPI()
|
4 |
|
|
|
1 |
from fastapi import FastAPI
|
2 |
+
from ai_reviewer.api import app
|
3 |
|
4 |
app = FastAPI()
|
5 |
|
requirements.txt
CHANGED
@@ -1,2 +1,7 @@
|
|
1 |
fastapi
|
2 |
-
uvicorn[standard]
|
|
|
|
|
|
|
|
|
|
|
|
1 |
fastapi
|
2 |
+
uvicorn[standard]
|
3 |
+
python-dotenv
|
4 |
+
openai
|
5 |
+
anthropic
|
6 |
+
pydantic
|
7 |
+
requests
|
reviewer/__init__.py
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tmlr_reviewer package
|
2 |
+
|
3 |
+
from .reviewer import PDFReviewer
|
4 |
+
|
5 |
+
__all__ = ["PDFReviewer"]
|
reviewer/api.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from __future__ import annotations
|
2 |
+
|
3 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
4 |
+
from fastapi.responses import JSONResponse
|
5 |
+
|
6 |
+
from pathlib import Path
|
7 |
+
import tempfile
|
8 |
+
|
9 |
+
from .reviewer import PDFReviewer
|
10 |
+
|
11 |
+
app = FastAPI(title="TMLR Reviewer PDF API", description="Generate reviews for uploaded PDFs")
|
12 |
+
|
13 |
+
reviewer = PDFReviewer()
|
14 |
+
|
15 |
+
|
16 |
+
@app.post("/review")
|
17 |
+
async def review_pdf(file: UploadFile = File(...)):
|
18 |
+
"""Upload a PDF file and return the structured review as JSON."""
|
19 |
+
if file.content_type != "application/pdf":
|
20 |
+
raise HTTPException(status_code=400, detail="Only PDF files are supported")
|
21 |
+
|
22 |
+
# Save to temporary location
|
23 |
+
suffix = Path(file.filename).suffix or ".pdf"
|
24 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
25 |
+
content = await file.read()
|
26 |
+
tmp.write(content)
|
27 |
+
tmp_path = Path(tmp.name)
|
28 |
+
|
29 |
+
try:
|
30 |
+
review_result = reviewer.review_pdf(tmp_path)
|
31 |
+
except Exception as exc:
|
32 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
33 |
+
finally:
|
34 |
+
tmp_path.unlink(missing_ok=True)
|
35 |
+
|
36 |
+
return JSONResponse(content=review_result)
|
reviewer/cli.py
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import argparse
|
2 |
+
import pathlib
|
3 |
+
import uvicorn
|
4 |
+
import gradio as gr
|
5 |
+
|
6 |
+
from .reviewer import PDFReviewer
|
7 |
+
from .api import app as fastapi_app
|
8 |
+
|
9 |
+
|
10 |
+
def _cli_review(args):
|
11 |
+
reviewer = PDFReviewer(debug=args.debug)
|
12 |
+
result = reviewer.review_pdf(args.pdf)
|
13 |
+
for key, value in result.items():
|
14 |
+
print(f"\n{key.upper()}:\n{value}\n")
|
15 |
+
|
16 |
+
|
17 |
+
def _cli_demo(args):
|
18 |
+
reviewer = PDFReviewer(debug=args.debug)
|
19 |
+
|
20 |
+
def gradio_interface(file, progress=gr.Progress()):
|
21 |
+
result = reviewer.review_pdf(file.name)
|
22 |
+
return (
|
23 |
+
result["contributions"],
|
24 |
+
result["strengths"],
|
25 |
+
result["weaknesses"],
|
26 |
+
result["requested_changes"],
|
27 |
+
result["impact_concerns"],
|
28 |
+
result["claims_and_evidence"],
|
29 |
+
result["audience_interest"],
|
30 |
+
)
|
31 |
+
|
32 |
+
iface = gr.Interface(
|
33 |
+
fn=gradio_interface,
|
34 |
+
inputs=gr.File(label="Upload PDF"),
|
35 |
+
outputs=[
|
36 |
+
gr.Textbox(label="Contributions"),
|
37 |
+
gr.Textbox(label="Strengths"),
|
38 |
+
gr.Textbox(label="Weaknesses"),
|
39 |
+
gr.Textbox(label="Requested Changes"),
|
40 |
+
gr.Textbox(label="Impact Concerns"),
|
41 |
+
gr.Textbox(label="Claims and Evidence"),
|
42 |
+
gr.Textbox(label="Audience Interest"),
|
43 |
+
],
|
44 |
+
title="PDF Review Parser",
|
45 |
+
description="Upload a PDF to parse and review its content.",
|
46 |
+
)
|
47 |
+
iface.launch(share=True)
|
48 |
+
|
49 |
+
|
50 |
+
def _cli_api(args):
|
51 |
+
uvicorn.run(fastapi_app, host=args.host, port=args.port, reload=args.reload)
|
52 |
+
|
53 |
+
|
54 |
+
def build_parser() -> argparse.ArgumentParser:
|
55 |
+
parser = argparse.ArgumentParser(description="TMLR PDF Reviewer")
|
56 |
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
57 |
+
|
58 |
+
# Review command
|
59 |
+
p_review = subparsers.add_parser("review", help="Process a PDF and output to console")
|
60 |
+
p_review.add_argument("pdf", type=pathlib.Path, help="Path to the PDF file")
|
61 |
+
p_review.add_argument("--debug", action="store_true", help="Enable debug mode (uses gpt-4o and drops to pdb on errors)")
|
62 |
+
p_review.set_defaults(func=_cli_review)
|
63 |
+
|
64 |
+
# Demo command
|
65 |
+
p_demo = subparsers.add_parser("demo", help="Launch Gradio demo")
|
66 |
+
p_demo.add_argument("--debug", action="store_true", help="Enable debug mode (uses gpt-4o and drops to pdb on errors)")
|
67 |
+
p_demo.set_defaults(func=_cli_demo)
|
68 |
+
|
69 |
+
# API command
|
70 |
+
p_api = subparsers.add_parser("api", help="Run FastAPI server")
|
71 |
+
p_api.add_argument("--host", default="0.0.0.0")
|
72 |
+
p_api.add_argument("--port", type=int, default=8000)
|
73 |
+
p_api.add_argument("--reload", action="store_true")
|
74 |
+
p_api.set_defaults(func=_cli_api)
|
75 |
+
|
76 |
+
return parser
|
77 |
+
|
78 |
+
|
79 |
+
def main():
|
80 |
+
parser = build_parser()
|
81 |
+
args = parser.parse_args()
|
82 |
+
args.func(args)
|
83 |
+
|
84 |
+
|
85 |
+
if __name__ == "__main__":
|
86 |
+
main()
|
reviewer/logger.py
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from pathlib import Path
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
__all__ = ["get_review_logger"]
|
6 |
+
|
7 |
+
|
8 |
+
LOG_DIR = Path("logs")
|
9 |
+
LOG_DIR.mkdir(exist_ok=True)
|
10 |
+
|
11 |
+
|
12 |
+
def get_review_logger(review_id: str | None = None) -> logging.Logger:
|
13 |
+
"""Return a configured logger for a single review run.
|
14 |
+
|
15 |
+
A new ``FileHandler`` is attached whose filename is based on *review_id* or a
|
16 |
+
timestamp if none is given.
|
17 |
+
"""
|
18 |
+
name = f"tmlr_reviewer.{review_id or datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
|
19 |
+
logger = logging.getLogger(name)
|
20 |
+
if logger.handlers:
|
21 |
+
# already configured
|
22 |
+
return logger
|
23 |
+
|
24 |
+
logger.setLevel(logging.INFO)
|
25 |
+
|
26 |
+
# Console output
|
27 |
+
stream_handler = logging.StreamHandler()
|
28 |
+
stream_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
29 |
+
logger.addHandler(stream_handler)
|
30 |
+
|
31 |
+
# File output
|
32 |
+
log_file = LOG_DIR / f"{name}.log"
|
33 |
+
file_handler = logging.FileHandler(log_file)
|
34 |
+
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
35 |
+
logger.addHandler(file_handler)
|
36 |
+
|
37 |
+
return logger
|
reviewer/reviewer.py
ADDED
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from __future__ import annotations
|
2 |
+
|
3 |
+
import os
|
4 |
+
from pathlib import Path
|
5 |
+
from typing import Dict, List, Literal, Tuple
|
6 |
+
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
from openai import OpenAI
|
9 |
+
import anthropic
|
10 |
+
import requests
|
11 |
+
import base64
|
12 |
+
|
13 |
+
from pydantic import BaseModel
|
14 |
+
|
15 |
+
from .logger import get_review_logger
|
16 |
+
from .utils import extract_all_tags
|
17 |
+
|
18 |
+
load_dotenv()
|
19 |
+
|
20 |
+
# ---------------------------------------------------------------------------
|
21 |
+
# Pydantic models
|
22 |
+
# ---------------------------------------------------------------------------
|
23 |
+
|
24 |
+
class Point(BaseModel):
|
25 |
+
content: str
|
26 |
+
importance: Literal["critical", "minor"]
|
27 |
+
|
28 |
+
|
29 |
+
class Review(BaseModel):
|
30 |
+
contributions: str
|
31 |
+
strengths: List[Point]
|
32 |
+
weaknesses: List[Point]
|
33 |
+
requested_changes: List[Point]
|
34 |
+
impact_concerns: str
|
35 |
+
claims_and_evidence: str
|
36 |
+
audience_interest: str
|
37 |
+
|
38 |
+
|
39 |
+
IMPORTANCE_MAPPING = {"critical": 2, "minor": 1}
|
40 |
+
|
41 |
+
# ---------------------------------------------------------------------------
|
42 |
+
# Reviewer Class
|
43 |
+
# ---------------------------------------------------------------------------
|
44 |
+
|
45 |
+
|
46 |
+
class PDFReviewer:
|
47 |
+
"""Encapsulates the full PDF review life-cycle.
|
48 |
+
|
49 |
+
Parameters
|
50 |
+
----------
|
51 |
+
openai_key:
|
52 |
+
OAuth key for the OpenAI client. Falls back to ``OPENAI_API_KEY`` env var.
|
53 |
+
anthropic_key:
|
54 |
+
Key for Anthropic Claude API. Falls back to ``ANTHROPIC_API_KEY`` env var.
|
55 |
+
cache_dir:
|
56 |
+
Where temporary PDFs are stored.
|
57 |
+
"""
|
58 |
+
|
59 |
+
def __init__(
|
60 |
+
self,
|
61 |
+
*,
|
62 |
+
openai_key: str | None = None,
|
63 |
+
anthropic_key: str | None = None,
|
64 |
+
cache_dir: str | Path = "cache",
|
65 |
+
debug: bool = False,
|
66 |
+
) -> None:
|
67 |
+
self.openai_key = openai_key or os.getenv("OPENAI_API_KEY")
|
68 |
+
self.anthropic_key = anthropic_key or os.getenv("ANTHROPIC_API_KEY")
|
69 |
+
|
70 |
+
if not self.openai_key:
|
71 |
+
raise EnvironmentError("Missing OPENAI_API_KEY env var or parameter")
|
72 |
+
|
73 |
+
if not self.anthropic_key:
|
74 |
+
raise EnvironmentError("Missing ANTHROPIC_API_KEY env var or parameter")
|
75 |
+
|
76 |
+
self.client = OpenAI(api_key=self.openai_key)
|
77 |
+
self.claude_client = anthropic.Anthropic(api_key=self.anthropic_key)
|
78 |
+
|
79 |
+
self.cache_dir = Path(cache_dir)
|
80 |
+
self.cache_dir.mkdir(exist_ok=True)
|
81 |
+
|
82 |
+
self.debug = debug
|
83 |
+
|
84 |
+
self.logger = get_review_logger()
|
85 |
+
|
86 |
+
# Lazy import prompts to avoid circular dependency during tests
|
87 |
+
import importlib
|
88 |
+
|
89 |
+
self.PROMPTS = importlib.import_module("prompts")
|
90 |
+
|
91 |
+
# ---------------------------------------------------------------------
|
92 |
+
# Public high-level API
|
93 |
+
# ---------------------------------------------------------------------
|
94 |
+
|
95 |
+
def review_pdf(self, pdf_path: str | Path) -> Dict[str, str]:
|
96 |
+
"""Main entry-point: review *pdf_path* and return parsed results."""
|
97 |
+
pdf_path = Path(pdf_path)
|
98 |
+
self.logger.info("Starting review for %s", pdf_path.name)
|
99 |
+
file_uploaded = self._step("upload_pdf", self._upload_pdf, pdf_path)
|
100 |
+
self.logger.info("PDF uploaded, id=%s", file_uploaded.id)
|
101 |
+
|
102 |
+
literature_report = self._step("literature_search", self._literature_search, file_uploaded)
|
103 |
+
self.logger.info("Literature search complete")
|
104 |
+
|
105 |
+
raw_review = self._step("generate_initial_review", self._generate_initial_review, file_uploaded, literature_report)
|
106 |
+
self.logger.info("Initial review generated")
|
107 |
+
|
108 |
+
# Optional defense / revision stage
|
109 |
+
defended_review = self._step("defend_review", self._defend_review, file_uploaded, raw_review)
|
110 |
+
|
111 |
+
parsed_review = self._step("parse_final", self._parse_final, defended_review)
|
112 |
+
self.logger.info("Review parsed")
|
113 |
+
|
114 |
+
return parsed_review
|
115 |
+
|
116 |
+
# ------------------------------------------------------------------
|
117 |
+
# Internal helpers (prefixed with _)
|
118 |
+
# ------------------------------------------------------------------
|
119 |
+
|
120 |
+
def _upload_pdf(self, pdf_path: Path):
|
121 |
+
"""Upload *pdf_path* to OpenAI and return the file object."""
|
122 |
+
with open(pdf_path, "rb") as pdf_file:
|
123 |
+
return self.client.files.create(file=pdf_file, purpose="user_data")
|
124 |
+
|
125 |
+
def _literature_search(self, file):
|
126 |
+
"""Run literature search tool call."""
|
127 |
+
model_name = "gpt-4o" if self.debug else "gpt-4.1"
|
128 |
+
resp = self.client.responses.create(
|
129 |
+
model=model_name,
|
130 |
+
input=[
|
131 |
+
{
|
132 |
+
"role": "user",
|
133 |
+
"content": [
|
134 |
+
{"type": "input_file", "file_id": file.id},
|
135 |
+
{"type": "input_text", "text": self.PROMPTS.literature_search},
|
136 |
+
],
|
137 |
+
}
|
138 |
+
],
|
139 |
+
tools=[{"type": "web_search"}],
|
140 |
+
)
|
141 |
+
return resp.output_text
|
142 |
+
|
143 |
+
def _generate_initial_review(self, file, literature_report: str):
|
144 |
+
"""Query GPT model with combined prompts to get initial review."""
|
145 |
+
prompt = self.PROMPTS.review_prompt.format(
|
146 |
+
literature_search_report=literature_report,
|
147 |
+
acceptance_criteria=self.PROMPTS.acceptance_criteria,
|
148 |
+
review_format=self.PROMPTS.review_format,
|
149 |
+
)
|
150 |
+
model_name = "gpt-4o" if self.debug else "o4-mini"
|
151 |
+
resp = self.client.responses.create(
|
152 |
+
model=model_name,
|
153 |
+
input=[
|
154 |
+
{
|
155 |
+
"role": "user",
|
156 |
+
"content": [
|
157 |
+
{"type": "input_file", "file_id": file.id},
|
158 |
+
{"type": "input_text", "text": prompt},
|
159 |
+
],
|
160 |
+
}
|
161 |
+
],
|
162 |
+
)
|
163 |
+
return resp.output_text
|
164 |
+
|
165 |
+
# ------------------------------------------------------------------
|
166 |
+
# Static/utility parsing helpers
|
167 |
+
# ------------------------------------------------------------------
|
168 |
+
|
169 |
+
def _parse_final(self, parsed: Dict, *, max_strengths: int = 3, max_weaknesses: int = 5, max_requested_changes: int = 5) -> Dict[str, str]:
|
170 |
+
"""Convert model structured response into simplified text blobs."""
|
171 |
+
self.logger.debug("Parsing final review json -> human readable")
|
172 |
+
if isinstance(parsed, str):
|
173 |
+
# attempt to parse via Pydantic
|
174 |
+
try:
|
175 |
+
parsed = Review.model_validate_json(parsed).model_dump()
|
176 |
+
except Exception:
|
177 |
+
self.logger.warning("parse_final received string that could not be parsed by Review model. Returning as-is text under 'contributions'.")
|
178 |
+
return {"contributions": parsed}
|
179 |
+
|
180 |
+
new_parsed: Dict[str, str] = {}
|
181 |
+
new_parsed["contributions"] = parsed["contributions"]
|
182 |
+
new_parsed["claims_and_evidence"] = parsed["claims_and_evidence"]
|
183 |
+
new_parsed["audience_interest"] = parsed["audience_interest"]
|
184 |
+
new_parsed["impact_concerns"] = parsed["impact_concerns"]
|
185 |
+
|
186 |
+
new_parsed["strengths"] = "\n".join(
|
187 |
+
[f"- {point['content']}" for point in parsed["strengths"][:max_strengths]]
|
188 |
+
)
|
189 |
+
new_parsed["weaknesses"] = "\n".join(
|
190 |
+
[f"- {point['content']}" for point in parsed["weaknesses"][:max_weaknesses]]
|
191 |
+
)
|
192 |
+
request_changes_sorted = sorted(
|
193 |
+
parsed["requested_changes"],
|
194 |
+
key=lambda x: IMPORTANCE_MAPPING[x["importance"]],
|
195 |
+
reverse=True,
|
196 |
+
)
|
197 |
+
new_parsed["requested_changes"] = "\n".join(
|
198 |
+
[f"- {point['content']}" for point in request_changes_sorted[:max_requested_changes]]
|
199 |
+
)
|
200 |
+
return new_parsed
|
201 |
+
|
202 |
+
# ------------------------------------------------------------------
|
203 |
+
# Optional β could integrate unit tests style checks here
|
204 |
+
# ------------------------------------------------------------------
|
205 |
+
|
206 |
+
def _run_unit_tests(self, pdf_path: Path, review: Dict[str, str]) -> Tuple[bool, str | None]:
|
207 |
+
"""Run post-hoc sanity tests powered by Claude prompts."""
|
208 |
+
test_prompt = self.PROMPTS.unit_test_prompt.format(review=review)
|
209 |
+
response = self._ask_claude(test_prompt, pdf_path)
|
210 |
+
results = extract_all_tags(response)
|
211 |
+
for test_name in [
|
212 |
+
"reviewing_process_references",
|
213 |
+
"inappropriate_language",
|
214 |
+
"llm_generated_review",
|
215 |
+
"hallucinations",
|
216 |
+
"formatting_and_style",
|
217 |
+
]:
|
218 |
+
self.logger.info("Unit test %s: %s", test_name, results.get(test_name))
|
219 |
+
if results.get(test_name) == "FAIL":
|
220 |
+
return False, test_name
|
221 |
+
return True, None
|
222 |
+
|
223 |
+
# ------------------------------------------------------------------
|
224 |
+
# Claude wrapper
|
225 |
+
# ------------------------------------------------------------------
|
226 |
+
|
227 |
+
def _ask_claude(
|
228 |
+
self,
|
229 |
+
query: str,
|
230 |
+
pdf_path: str | Path | None = None,
|
231 |
+
*,
|
232 |
+
max_tokens: int = 8000,
|
233 |
+
model: str = "claude-3-5-sonnet-20241022",
|
234 |
+
) -> str:
|
235 |
+
content = query
|
236 |
+
betas: List[str] = []
|
237 |
+
|
238 |
+
# Attach PDF for context if provided
|
239 |
+
if pdf_path is not None:
|
240 |
+
if str(pdf_path).startswith(("http://", "https://")):
|
241 |
+
binary_data = requests.get(str(pdf_path)).content
|
242 |
+
else:
|
243 |
+
with open(pdf_path, "rb") as fp:
|
244 |
+
binary_data = fp.read()
|
245 |
+
pdf_data = base64.standard_b64encode(binary_data).decode()
|
246 |
+
content = [
|
247 |
+
{
|
248 |
+
"type": "document",
|
249 |
+
"source": {
|
250 |
+
"type": "base64",
|
251 |
+
"media_type": "application/pdf",
|
252 |
+
"data": pdf_data,
|
253 |
+
},
|
254 |
+
},
|
255 |
+
{"type": "text", "text": query},
|
256 |
+
]
|
257 |
+
betas.append("pdfs-2024-09-25")
|
258 |
+
|
259 |
+
kwargs = {
|
260 |
+
"model": model,
|
261 |
+
"max_tokens": max_tokens,
|
262 |
+
"messages": [{"role": "user", "content": content}],
|
263 |
+
}
|
264 |
+
if betas:
|
265 |
+
kwargs["betas"] = betas
|
266 |
+
|
267 |
+
message = self.claude_client.beta.messages.create(**kwargs) # type: ignore[arg-type]
|
268 |
+
return message.content[0].text
|
269 |
+
|
270 |
+
# ------------------------------------------------------------------
|
271 |
+
# Public utility methods
|
272 |
+
# ------------------------------------------------------------------
|
273 |
+
|
274 |
+
def get_prompts(self):
|
275 |
+
"""Return the prompts module for inspection."""
|
276 |
+
return self.PROMPTS
|
277 |
+
|
278 |
+
def get_logger(self):
|
279 |
+
"""Return the logger for inspection."""
|
280 |
+
return self.logger
|
281 |
+
|
282 |
+
# ------------------------------------------------------------------
|
283 |
+
# _step helper (defined at end to avoid cluttering core logic)
|
284 |
+
# ------------------------------------------------------------------
|
285 |
+
|
286 |
+
def _step(self, name: str, fn, *args, **kwargs):
|
287 |
+
"""Execute *fn* and, if an exception occurs, trigger pdb in debug mode."""
|
288 |
+
try:
|
289 |
+
self.logger.info("Starting step: %s", name)
|
290 |
+
result = fn(*args, **kwargs)
|
291 |
+
self.logger.info("Completed step: %s", name)
|
292 |
+
return result
|
293 |
+
except Exception:
|
294 |
+
self.logger.exception("Step %s failed", name)
|
295 |
+
if self.debug:
|
296 |
+
import pdb, traceback
|
297 |
+
traceback.print_exc()
|
298 |
+
pdb.post_mortem()
|
299 |
+
raise
|
300 |
+
|
301 |
+
# ------------------------------------------------------------------
|
302 |
+
# Defense / revision helpers
|
303 |
+
# ------------------------------------------------------------------
|
304 |
+
|
305 |
+
def _run_query_on_file(self, file, prompt: str, *, model_name: str):
|
306 |
+
"""Thin wrapper around OpenAI responses.create used by several steps."""
|
307 |
+
return self.client.responses.create(
|
308 |
+
model=model_name,
|
309 |
+
input=[
|
310 |
+
{
|
311 |
+
"role": "user",
|
312 |
+
"content": [
|
313 |
+
{"type": "input_file", "file_id": file.id},
|
314 |
+
{"type": "input_text", "text": prompt},
|
315 |
+
],
|
316 |
+
}
|
317 |
+
],
|
318 |
+
).output_text
|
319 |
+
|
320 |
+
def _defend_review(self, file, review: str):
|
321 |
+
"""Run defense β revision β human-style polishing as in legacy workflow."""
|
322 |
+
model_name = "gpt-4o" if self.debug else "o3"
|
323 |
+
|
324 |
+
defense = self._run_query_on_file(
|
325 |
+
file,
|
326 |
+
self.PROMPTS.defend_prompt.format(combined_review=review),
|
327 |
+
model_name=model_name,
|
328 |
+
)
|
329 |
+
|
330 |
+
revision_prompt = self.PROMPTS.revise_prompt.format(
|
331 |
+
review_format=self.PROMPTS.review_format.format(
|
332 |
+
acceptance_criteria=self.PROMPTS.acceptance_criteria,
|
333 |
+
review_format=self.PROMPTS.review_format,
|
334 |
+
),
|
335 |
+
combined_review=review,
|
336 |
+
defended_paper=defense,
|
337 |
+
)
|
338 |
+
revision = self._run_query_on_file(file, revision_prompt, model_name=model_name)
|
339 |
+
|
340 |
+
humanised = self._run_query_on_file(
|
341 |
+
file,
|
342 |
+
self.PROMPTS.human_style.format(review=revision),
|
343 |
+
model_name=model_name,
|
344 |
+
)
|
345 |
+
|
346 |
+
# Finally, convert to structured Review JSON using formatting prompt
|
347 |
+
formatted = self._format_review(humanised, model_name=model_name)
|
348 |
+
|
349 |
+
return formatted
|
350 |
+
|
351 |
+
def _format_review(self, review_text: str, *, model_name: str):
|
352 |
+
"""Use OpenAI function calling to map *review_text* β Review model dict."""
|
353 |
+
chat_completion = self.client.beta.chat.completions.parse(
|
354 |
+
messages=[
|
355 |
+
{
|
356 |
+
"role": "user",
|
357 |
+
"content": self.PROMPTS.formatting_prompt.format(review=review_text),
|
358 |
+
}
|
359 |
+
],
|
360 |
+
model=model_name,
|
361 |
+
response_format=Review,
|
362 |
+
)
|
363 |
+
return chat_completion.choices[0].message.parsed.model_dump()
|
reviewer/utils.py
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Utility helper functions for tmlr_reviewer package."""
|
2 |
+
|
3 |
+
import re
|
4 |
+
from typing import Dict
|
5 |
+
|
6 |
+
__all__ = [
|
7 |
+
"extract_all_tags",
|
8 |
+
"extract_output_tags",
|
9 |
+
"parse_json_from_text",
|
10 |
+
]
|
11 |
+
|
12 |
+
|
13 |
+
def extract_all_tags(text: str) -> Dict[str, str]:
|
14 |
+
"""Extracts content between any XML-style tags from a string.
|
15 |
+
|
16 |
+
Args:
|
17 |
+
text: Input string that may contain tags
|
18 |
+
|
19 |
+
Returns:
|
20 |
+
Mapping of tag name -> tag content with surrounding whitespace stripped.
|
21 |
+
"""
|
22 |
+
pattern = r"<(\w+)>(.*?)</\1>"
|
23 |
+
matches = re.findall(pattern, text, re.DOTALL)
|
24 |
+
return {tag: content.strip() for tag, content in matches}
|
25 |
+
|
26 |
+
|
27 |
+
def extract_output_tags(text: str) -> str:
|
28 |
+
"""Extract content between <output></output> tags from *text* if present.
|
29 |
+
|
30 |
+
Args:
|
31 |
+
text: Arbitrary string that may contain output tags.
|
32 |
+
|
33 |
+
Returns:
|
34 |
+
The content inside the first pair of <output> tags if found otherwise the original text.
|
35 |
+
"""
|
36 |
+
pattern = r"<output>(.*?)</output>"
|
37 |
+
match = re.search(pattern, text, re.DOTALL)
|
38 |
+
return match.group(1).strip() if match else text
|
39 |
+
|
40 |
+
|
41 |
+
def parse_json_from_text(text: str):
|
42 |
+
"""Attempt to parse the *first* JSON object found in *text*.
|
43 |
+
|
44 |
+
Returns the parsed Python object if successful, otherwise ``None``.
|
45 |
+
"""
|
46 |
+
import json, re
|
47 |
+
|
48 |
+
# 1) Direct attempt
|
49 |
+
try:
|
50 |
+
return json.loads(text)
|
51 |
+
except Exception:
|
52 |
+
pass
|
53 |
+
|
54 |
+
# 2) Look for JSON object inside text
|
55 |
+
json_pattern = re.compile(r"\{[\s\S]*?\}") # non-greedy to first closing brace
|
56 |
+
match = json_pattern.search(text)
|
57 |
+
if match:
|
58 |
+
candidate = match.group(0)
|
59 |
+
try:
|
60 |
+
return json.loads(candidate)
|
61 |
+
except Exception:
|
62 |
+
pass
|
63 |
+
|
64 |
+
return None
|