Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ dependencies = [
"requests>=2.28.0",
"aiohttp>=3.8.0",
"beautifulsoup4>=4.11.0",
"cryptography>=41.0.0",
"lxml>=4.9.0",
"pydantic>=2.0",
"loguru>=0.7.0",
"openai>=1.0.0",
"PyYAML>=6.0",
"SQLAlchemy>=2.0.0",
"alembic>=1.13.0",
"keyring>=25.0.0",
]

[project.optional-dependencies]
Expand Down
10 changes: 10 additions & 0 deletions src/paperbot/api/routes/model_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
import time
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException
Expand Down Expand Up @@ -66,10 +67,13 @@ class EndpointTestRequest(BaseModel):

class EndpointTestResponse(BaseModel):
ok: bool

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

EndpointTestResponse 模型同时包含了 ok: boolsuccess: bool 两个字段。这两个字段似乎是多余的,因为在成功的情况下它们都被设置为 True。为了简化数据模型并提高清晰度,我建议移除 ok 字段。您也需要在 test_model_endpoint 函数中移除对它的使用。

success: bool
endpoint_id: int
provider: Dict[str, Any]
api_key_present: bool
latency_ms: int
message: str
error: Optional[str] = None


class EndpointCapabilitiesResponse(BaseModel):
Expand Down Expand Up @@ -169,6 +173,7 @@ def test_model_endpoint(endpoint_id: int, req: EndpointTestRequest):
os.getenv(api_key_env)
)

t0 = time.monotonic()
try:
cfg = _build_model_config(endpoint)
router = ModelRouter(RouterConfig(models={"__test__": cfg}, fallback_model="__test__"))
Expand All @@ -187,16 +192,21 @@ def test_model_endpoint(endpoint_id: int, req: EndpointTestRequest):
)
message = f"Remote check success: {(pong or '').strip()[:80]}"

latency_ms = int((time.monotonic() - t0) * 1000)
return EndpointTestResponse(
ok=True,
success=True,
endpoint_id=endpoint_id,
provider={
"provider_name": info.provider_name,
"model_name": info.model_name,
"api_base": info.api_base,
},
api_key_present=api_key_present,
latency_ms=latency_ms,
message=message,
error=None,
)
except Exception as exc:
latency_ms = int((time.monotonic() - t0) * 1000)

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable latency_ms is not used.

Suggested change
latency_ms = int((time.monotonic() - t0) * 1000)

Copilot uses AI. Check for mistakes.
raise HTTPException(status_code=400, detail=f"test failed: {exc}") from exc
16 changes: 5 additions & 11 deletions src/paperbot/api/routes/paperscool.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ def _count_report_claims_and_evidence(report: Dict[str, Any]) -> tuple[int, int]
claims += 1
if item.get("url") or item.get("pdf_url") or item.get("external_url"):
evidences += 1
judge = item.get("judge")
if isinstance(judge, dict):
eq = judge.get("evidence_quotes")
if isinstance(eq, list) and eq:
evidences += len(eq)
return claims, evidences


Expand All @@ -114,17 +119,6 @@ async def _run_topic_search(
show_per_branch: int,
min_score: float,
) -> Dict[str, Any]:
if callable(PapersCoolTopicSearchWorkflow):
workflow = PapersCoolTopicSearchWorkflow()
return workflow.run(
queries=queries,
sources=sources,
branches=branches,
top_k_per_query=top_k_per_query,
show_per_branch=show_per_branch,
min_score=min_score,
)

return await run_unified_topic_search(
queries=queries,
sources=sources,
Expand Down
175 changes: 175 additions & 0 deletions src/paperbot/api/routes/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Any, Dict, List, Optional, Tuple

from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError

Expand Down Expand Up @@ -1119,6 +1120,13 @@ class WorkflowMetricsResponse(BaseModel):
summary: Dict[str, Any]


class EvidenceCoverageResponse(BaseModel):
coverage_rate: float
total_claims: int
total_with_evidence: int
trend: List[Dict[str, Any]]


@router.get("/research/metrics/workflows", response_model=WorkflowMetricsResponse)
def get_workflow_metrics_summary(
days: int = Query(7, ge=1, le=90),
Expand All @@ -1133,6 +1141,37 @@ def get_workflow_metrics_summary(
return WorkflowMetricsResponse(summary=summary)


@router.get("/research/metrics/evidence-coverage", response_model=EvidenceCoverageResponse)
def get_evidence_coverage(
days: int = Query(7, ge=1, le=90),
workflow: Optional[str] = None,
track_id: Optional[int] = None,
):
summary = _get_workflow_metric_store().summarize(
days=days,
workflow=workflow,
track_id=track_id,
)
totals = summary.get("totals", {})
total_claims = int(totals.get("claim_count") or 0)
total_with_evidence = int(totals.get("evidence_count") or 0)
coverage_rate = float(totals.get("coverage_rate") or 0.0)

trend = []
for day_bucket in summary.get("by_day", []):
trend.append({
"date": day_bucket.get("date", ""),
"rate": float(day_bucket.get("coverage_rate") or 0.0),
})

return EvidenceCoverageResponse(
coverage_rate=coverage_rate,
total_claims=total_claims,
total_with_evidence=total_with_evidence,
trend=trend,
)


@router.post("/research/context", response_model=ContextResponse)
async def build_context(req: ContextRequest):
set_trace_id() # Initialize trace_id for this request
Expand Down Expand Up @@ -1608,3 +1647,139 @@ async def scholar_trends(req: ScholarTrendsRequest):
recent_papers=recent_papers[:10],
trend_summary=trend_summary,
)


# ---------------------------------------------------------------------------
# Paper export (BibTeX / RIS / Markdown)
# ---------------------------------------------------------------------------

def _make_citation_key(authors: List[str], year: Optional[int]) -> str:
"""first_author_lastname + year, e.g. 'smith2025'."""
lastname = "unknown"
if authors:
parts = authors[0].strip().split()
if parts:
lastname = re.sub(r"[^a-zA-Z]", "", parts[-1]).lower() or "unknown"
return f"{lastname}{year or 'nd'}"


def _dedup_citation_keys(keys: List[str]) -> List[str]:
"""Append a/b/c suffixes when keys collide."""
seen: Dict[str, int] = {}
result: List[str] = []
for k in keys:
count = seen.get(k, 0)
seen[k] = count + 1
result.append(k if count == 0 else f"{k}{chr(ord('a') + count)}")

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_dedup_citation_keys says it appends a/b/c suffixes, but the implementation appends b for the first collision (because count starts at 1 for the first duplicate). Either change the algorithm to start with a on the first collision (more standard for BibTeX) or update the docstring/tests to match the intended suffix scheme.

Suggested change
result.append(k if count == 0 else f"{k}{chr(ord('a') + count)}")
result.append(k if count == 0 else f"{k}{chr(ord('a') + count - 1)}")

Copilot uses AI. Check for mistakes.
return result
Comment on lines +1666 to +1674

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

当前用于通过附加字符后缀(chr(ord('a') + count))来去重引用键的实现,在处理超过25个相同基本键的冲突时存在问题。如果 count 达到26或更高,它将开始附加非字母字符(例如 {, |),这可能导致无效的BibTeX键。虽然这是一个边缘情况,但更稳健的做法是处理它。例如,在'z'之后切换到数字后缀,或者使用类似Excel列的 a, b, ..., z, aa, ab, ... 风格的后缀。以下建议实现了后者,它能优雅地处理任意数量的冲突。此建议还将后缀序列更改为从 'a' 开始,这更为常规。

Suggested change
def _dedup_citation_keys(keys: List[str]) -> List[str]:
"""Append a/b/c suffixes when keys collide."""
seen: Dict[str, int] = {}
result: List[str] = []
for k in keys:
count = seen.get(k, 0)
seen[k] = count + 1
result.append(k if count == 0 else f"{k}{chr(ord('a') + count)}")
return result
def _dedup_citation_keys(keys: List[str]) -> List[str]:
"""Append a/b/c suffixes when keys collide."""
seen: Dict[str, int] = {}
result: List[str] = []
for k in keys:
count = seen.get(k, 0)
seen[k] = count + 1
if count == 0:
result.append(k)
continue
# Generate suffixes like a, b, ..., z, aa, ab, ...
suffix = ""
temp_count = count
while temp_count > 0:
temp_count, remainder = divmod(temp_count - 1, 26)
suffix = chr(ord('a') + remainder) + suffix
result.append(f"{k}{suffix}")
return result



def _escape_bibtex(value: str) -> str:
return value.replace("{", "\\{").replace("}", "\\}")


def _paper_to_bibtex(paper: Dict[str, Any], key: str) -> str:
entry_type = "article" if paper.get("doi") else "misc"
lines = [f"@{entry_type}{{{key},"]
lines.append(f" title = {{{_escape_bibtex(paper.get('title') or '')}}},")
authors = paper.get("authors") or []
if authors:
lines.append(f" author = {{{_escape_bibtex(' and '.join(authors))}}},")
if paper.get("year"):
lines.append(f" year = {{{paper['year']}}},")
if paper.get("venue"):
field = "journal" if paper.get("doi") else "booktitle"
lines.append(f" {field} = {{{_escape_bibtex(paper['venue'])}}},")
if paper.get("doi"):
lines.append(f" doi = {{{paper['doi']}}},")
if paper.get("url"):
lines.append(f" url = {{{paper['url']}}},")
if paper.get("arxiv_id"):
lines.append(f" eprint = {{{paper['arxiv_id']}}},")
lines.append(" archiveprefix = {arXiv},")
lines.append("}")
return "\n".join(lines)


def _paper_to_ris(paper: Dict[str, Any]) -> str:
venue = (paper.get("venue") or "").lower()
is_conf = any(kw in venue for kw in ("conf", "proc", "sympos", "workshop"))
lines = [f"TY - {'CONF' if is_conf else 'JOUR'}"]
lines.append(f"TI - {paper.get('title') or ''}")
for author in paper.get("authors") or []:
lines.append(f"AU - {author}")
if paper.get("year"):
lines.append(f"PY - {paper['year']}")
if paper.get("venue"):
lines.append(f"JO - {paper['venue']}")
if paper.get("doi"):
lines.append(f"DO - {paper['doi']}")
if paper.get("url"):
lines.append(f"UR - {paper['url']}")
if paper.get("arxiv_id"):
lines.append(f"AN - {paper['arxiv_id']}")
lines.append("ER - ")
return "\n".join(lines)


def _paper_to_markdown(paper: Dict[str, Any]) -> str:
authors = ", ".join(paper.get("authors") or ["Unknown"])
title = paper.get("title") or "Untitled"
year = paper.get("year") or "n.d."
venue = paper.get("venue") or ""
parts = [f"- **{title}**", f" {authors} ({year})"]
if venue:
parts.append(f" *{venue}*")
links: List[str] = []
if paper.get("url"):
links.append(f"[URL]({paper['url']})")
if paper.get("doi"):
links.append(f"[DOI](https://doi.org/{paper['doi']})")
if paper.get("arxiv_id"):
links.append(f"[arXiv](https://arxiv.org/abs/{paper['arxiv_id']})")
if links:
parts.append(f" {' | '.join(links)}")
return "\n".join(parts)


@router.get("/research/papers/export")
def export_papers(
user_id: str = "default",
track_id: Optional[int] = None,
format: str = Query("bibtex", pattern="^(bibtex|ris|markdown)$"),
):
items = _research_store.list_saved_papers(
user_id=user_id, track_id=track_id, limit=1000
)
papers = [item["paper"] for item in items if item.get("paper")]

if not papers:
raise HTTPException(status_code=404, detail="No saved papers found")

if format == "bibtex":
raw_keys = [_make_citation_key(p.get("authors") or [], p.get("year")) for p in papers]
keys = _dedup_citation_keys(raw_keys)
body = "\n\n".join(_paper_to_bibtex(p, k) for p, k in zip(papers, keys))
return Response(
content=body,
media_type="application/x-bibtex",
headers={"Content-Disposition": "attachment; filename=papers.bib"},
)

if format == "ris":
body = "\n\n".join(_paper_to_ris(p) for p in papers)
return Response(
content=body,
media_type="application/x-research-info-systems",
headers={"Content-Disposition": "attachment; filename=papers.ris"},
)

# markdown
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d")
header = f"---\nexported: {now_iso}\ncount: {len(papers)}\n---\n\n# Saved Papers\n"
body = header + "\n\n".join(_paper_to_markdown(p) for p in papers)
return Response(
content=body,
media_type="text/markdown",
headers={"Content-Disposition": "attachment; filename=papers.md"},
)
5 changes: 4 additions & 1 deletion src/paperbot/application/workflows/analysis/judge_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ def build_paper_judge_user_prompt(*, query: str, paper: Dict[str, Any], rubric:
f" {dims_json},\n"
' "overall": <weighted float 1.0-5.0>,\n'
' "one_line_summary": "<one sentence takeaway>",\n'
' "recommendation": "<must_read|worth_reading|skim|skip>"\n'
' "recommendation": "<must_read|worth_reading|skim|skip>",\n'
' "evidence_quotes": [\n'
' {"text": "<exact quote from abstract/paper>", "source_url": "<url if available>", "page_hint": "<section or page>"}\n'
" ]\n"
"}\n"
)

Expand Down
28 changes: 28 additions & 0 deletions src/paperbot/application/workflows/analysis/paper_judge.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ class PaperJudgment:
recommendation: str
judge_model: str = ""
judge_cost_tier: int = 0
evidence_quotes: List[Dict[str, str]] = None # type: ignore[assignment]

def __post_init__(self) -> None:
if self.evidence_quotes is None:
self.evidence_quotes = []

def to_dict(self) -> Dict[str, Any]:
return {
Expand All @@ -52,6 +57,7 @@ def to_dict(self) -> Dict[str, Any]:
"recommendation": self.recommendation,
"judge_model": self.judge_model,
"judge_cost_tier": int(self.judge_cost_tier),
"evidence_quotes": list(self.evidence_quotes),
}


Expand Down Expand Up @@ -108,6 +114,7 @@ def pick_recommendation(values: Sequence[str]) -> str:
payload["overall"] = self._weighted_overall(dim_medians)
payload["one_line_summary"] = judgments[0].one_line_summary
payload["recommendation"] = pick_recommendation([j.recommendation for j in judgments])
payload["evidence_quotes"] = judgments[0].evidence_quotes

provider_info = {
"model_name": judgments[0].judge_model,
Expand Down Expand Up @@ -191,6 +198,8 @@ def _to_judgment(self, *, payload: Dict[str, Any], provider_info: Dict[str, Any]
if not one_line_summary:
one_line_summary = self._fallback_summary(dims)

evidence_quotes = self._parse_evidence_quotes(payload.get("evidence_quotes"))

return PaperJudgment(
relevance=dims["relevance"],
novelty=dims["novelty"],
Expand All @@ -202,8 +211,27 @@ def _to_judgment(self, *, payload: Dict[str, Any], provider_info: Dict[str, Any]
recommendation=recommendation,
judge_model=str(provider_info.get("model_name") or ""),
judge_cost_tier=int(provider_info.get("cost_tier") or 0),
evidence_quotes=evidence_quotes,
)

@staticmethod
def _parse_evidence_quotes(raw: Any) -> List[Dict[str, str]]:
if not isinstance(raw, list):
return []
result: List[Dict[str, str]] = []
for item in raw:
if not isinstance(item, dict):
continue
text = str(item.get("text") or "").strip()
if not text:
continue
result.append({
"text": text,
"source_url": str(item.get("source_url") or ""),
"page_hint": str(item.get("page_hint") or ""),
})
return result

def _weighted_overall(self, scores: Dict[str, int]) -> float:
weights = self._rubric.weights()
total = 0.0
Expand Down
Loading
Loading