Kevin Wu commited on
Commit
b3b5141
Β·
1 Parent(s): 5f5d646

put in API

Browse files
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
- # 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"]
 
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