File size: 8,949 Bytes
056cefb
 
 
 
 
3039836
056cefb
 
 
 
 
3039836
 
056cefb
 
 
 
37a21b0
 
 
3039836
 
 
 
37a21b0
 
 
 
 
 
 
3039836
37a21b0
056cefb
 
3039836
 
056cefb
 
3039836
056cefb
 
 
 
 
 
3039836
056cefb
 
 
 
 
 
37a21b0
 
056cefb
 
 
 
37a21b0
056cefb
 
 
 
 
3039836
056cefb
 
 
37a21b0
 
056cefb
 
 
 
 
 
 
 
 
 
 
 
37a21b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
056cefb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3039836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
056cefb
 
 
 
 
 
 
 
 
 
 
 
 
3039836
 
056cefb
3039836
056cefb
3039836
 
056cefb
 
 
 
 
 
 
 
3039836
 
056cefb
 
3039836
 
 
 
 
056cefb
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""Shared helpers: LLM completion, chunking, formatting, validation."""

from __future__ import annotations

import re
import threading
import time
from typing import TYPE_CHECKING

from . import config

_meter_lock = threading.Lock()

if TYPE_CHECKING:  # avoid importing heavy modules at runtime just for typing
    from .tools import Paper


def _record_usage(meter: dict | None, in_tok: int, out_tok: int) -> None:
    if meter is None:
        return
    with _meter_lock:  # called from parallel reader threads
        meter["llm_calls"] = meter.get("llm_calls", 0) + 1
        meter["in_tok"] = meter.get("in_tok", 0) + (in_tok or 0)
        meter["out_tok"] = meter.get("out_tok", 0) + (out_tok or 0)


def complete(
    prompt: str,
    system: str | None = None,
    max_tokens: int = 4096,
    meter: dict | None = None,
    model: str | None = None,
) -> str:
    """Send a single prompt to the configured provider and return the text.

    Retries once on transient failure, then re-raises. ``model`` overrides the
    configured model for this call; ``meter`` accumulates token usage.
    """
    client = config.get_llm_client()
    model = model or config.MODEL
    last_err: Exception | None = None

    for attempt in range(2):
        try:
            if config.PROVIDER == "anthropic":
                kwargs = {
                    "model": model,
                    "max_tokens": max_tokens,
                    "messages": [{"role": "user", "content": prompt}],
                }
                if system:
                    kwargs["system"] = system
                resp = client.messages.create(**kwargs)
                u = getattr(resp, "usage", None)
                _record_usage(meter, getattr(u, "input_tokens", 0), getattr(u, "output_tokens", 0))
                return "".join(
                    block.text for block in resp.content if block.type == "text"
                ).strip()

            # openai / gemini (OpenAI-compatible)
            messages = []
            if system:
                messages.append({"role": "system", "content": system})
            messages.append({"role": "user", "content": prompt})
            resp = client.chat.completions.create(
                model=model,
                max_tokens=max_tokens,
                messages=messages,
            )
            u = getattr(resp, "usage", None)
            _record_usage(meter, getattr(u, "prompt_tokens", 0), getattr(u, "completion_tokens", 0))
            return (resp.choices[0].message.content or "").strip()
        except Exception as err:  # noqa: BLE001 - retry-once then surface
            last_err = err
            if attempt == 0:
                time.sleep(1.5)
                continue
            raise

    # Unreachable, but keeps type checkers happy.
    raise RuntimeError(f"LLM completion failed: {last_err}")


# Approx USD per 1M tokens (input, output). Used only for a rough estimate.
_PRICING = {
    "gemini-2.5-flash": (0.30, 2.50),
    "gemini-2.5-pro": (1.25, 10.0),
    "gpt-4o": (2.50, 10.0),
    "claude-sonnet": (3.0, 15.0),
    "claude-opus": (15.0, 75.0),
}


def cost_report(meter: dict, model: str | None = None) -> dict:
    """Turn an accumulated meter into a {calls, in_tok, out_tok, est_cost_usd} dict."""
    model = (model or config.MODEL).lower()
    rate = next((v for k, v in _PRICING.items() if k in model), (0.50, 1.50))
    in_tok = meter.get("in_tok", 0)
    out_tok = meter.get("out_tok", 0)
    est = (in_tok / 1_000_000) * rate[0] + (out_tok / 1_000_000) * rate[1]
    return {
        "llm_calls": meter.get("llm_calls", 0),
        "in_tok": in_tok,
        "out_tok": out_tok,
        "est_cost_usd": round(est, 4),
    }


def chunk_text(text: str, size: int = 3000) -> list[str]:
    """Split text into ~``size``-char chunks, preferring paragraph breaks.

    Paragraphs (split on blank lines) are packed greedily so that section
    coherence is preserved where possible. A single oversized paragraph is
    hard-split.
    """
    paragraphs = re.split(r"\n\s*\n", text)
    chunks: list[str] = []
    current = ""

    for para in paragraphs:
        para = para.strip()
        if not para:
            continue
        if len(para) > size:
            if current:
                chunks.append(current)
                current = ""
            for i in range(0, len(para), size):
                chunks.append(para[i : i + size])
            continue
        if len(current) + len(para) + 2 > size:
            if current:
                chunks.append(current)
            current = para
        else:
            current = f"{current}\n\n{para}" if current else para

    if current:
        chunks.append(current)
    return chunks


def sanitize_filename(text: str, max_len: int = 80) -> str:
    """Turn an arbitrary topic string into a safe filename stem."""
    slug = re.sub(r"[^\w\s-]", "", text).strip().lower()
    slug = re.sub(r"[\s_-]+", "-", slug)
    return (slug[:max_len] or "review").strip("-")


def format_papers_for_synthesis(papers: list["Paper"]) -> str:
    """Render papers into a compact block for the synthesis prompt."""
    blocks: list[str] = []
    for p in papers:
        authors = ", ".join(p.authors[:3]) + (" et al." if len(p.authors) > 3 else "")
        parts = [f"### {p.title} ({p.year})", f"Authors: {authors or 'Unknown'}"]
        if p.claims:
            parts.append("Claims:\n" + "\n".join(f"  - {c}" for c in p.claims))
        if p.methods:
            parts.append("Methods:\n" + "\n".join(f"  - {m}" for m in p.methods))
        if p.results:
            parts.append("Results:\n" + "\n".join(f"  - {r}" for r in p.results))
        if not (p.claims or p.methods or p.results) and p.abstract:
            parts.append(f"Abstract: {p.abstract[:500]}")
        blocks.append("\n".join(parts))
    return "\n\n".join(blocks)


def paper_url(p: "Paper") -> str:
    """Best human-facing URL for a paper."""
    if p.arxiv_id:
        return f"https://arxiv.org/abs/{p.arxiv_id}"
    if p.source == "arxiv":
        return f"https://arxiv.org/abs/{p.id}"
    return p.pdf_url or ""


def paper_meta(p: "Paper") -> dict:
    """Serializable metadata for the UI bibliography."""
    authors = ", ".join(p.authors[:3]) + (" et al." if len(p.authors) > 3 else "")
    return {
        "title": p.title,
        "authors": authors or "Unknown",
        "year": p.year,
        "url": paper_url(p),
        "source": p.source,
        "citation_count": p.citation_count,
        "read_from": p.read_from,
    }


def references_markdown(papers: list["Paper"]) -> str:
    """A numbered, linked '## References' section for provenance."""
    if not papers:
        return ""
    lines = ["", "## References", ""]
    for i, p in enumerate(papers, 1):
        m = paper_meta(p)
        link = f"[{p.title}]({m['url']})" if m["url"] else p.title
        cites = f" · {p.citation_count} citations" if p.citation_count else ""
        abstract_note = " · abstract only" if p.read_from == "abstract" else ""
        lines.append(f"{i}. {link} — {m['authors']} ({p.year}){cites}{abstract_note}")
    return "\n".join(lines)


def validate_review(review: str, papers: list["Paper"]) -> dict:
    """Sanity-check a generated review.

    Confirms section headers exist and that ``[Author, Year]`` citations
    reference an author surname that appears in the paper set. Returns
    ``{"valid": bool, "issues": [str, ...]}``.
    """
    issues: list[str] = []

    headers = re.findall(r"^#{1,3}\s+\S", review, flags=re.MULTILINE)
    if len(headers) < 2:
        issues.append("Review has fewer than 2 section headers.")

    # Known author surnames (last token of each author name), lowercased, and
    # the set of paper years — used to spot citations to papers not in the set.
    known: set[str] = set()
    known_years: set[str] = set()
    for p in papers:
        if p.year:
            known_years.add(str(p.year))
        for author in p.authors:
            surname = author.strip().split()[-1].lower() if author.strip() else ""
            if surname:
                known.add(surname)

    citations = re.findall(r"\[([^\]]+?),\s*(\d{4})\]", review)
    if not citations:
        issues.append("No [Author, Year] citations found.")
    hallucinated = 0
    for author_part, year in citations:
        token = re.split(r"\s+|&|,", author_part.strip())[0].lower()
        token = token.replace("et", "").strip()
        bad_author = bool(token) and bool(known) and token not in known
        bad_year = bool(known_years) and year not in known_years
        if bad_author or bad_year:
            hallucinated += 1
            issues.append(f"Citation may not match any paper: [{author_part}, {year}]")

    # De-duplicate while preserving order.
    seen: set[str] = set()
    unique_issues = [i for i in issues if not (i in seen or seen.add(i))]
    return {"valid": not unique_issues, "issues": unique_issues}