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
51 changes: 51 additions & 0 deletions alembic/versions/0027_global_paper_feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Allow global paper feedback without an active track.

Revision ID: 0027_global_paper_feedback
Revises: 0026_embedding_endpoint_settings
Create Date: 2026-03-13
"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa


revision = "0027_global_paper_feedback"
down_revision = "0026_embedding_endpoint_settings"
branch_labels = None
depends_on = None


def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if "paper_feedback" not in inspector.get_table_names():
return

columns = {column["name"]: column for column in inspector.get_columns("paper_feedback")}
track_column = columns.get("track_id")
if not track_column or bool(track_column.get("nullable")):
return

with op.batch_alter_table("paper_feedback") as batch_op:
batch_op.alter_column("track_id", existing_type=sa.Integer(), nullable=True)


def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if "paper_feedback" not in inspector.get_table_names():
return

columns = {column["name"]: column for column in inspector.get_columns("paper_feedback")}
track_column = columns.get("track_id")
if not track_column or not bool(track_column.get("nullable")):
return

null_count = conn.execute(sa.text("SELECT COUNT(*) FROM paper_feedback WHERE track_id IS NULL")).scalar()
if null_count:
raise RuntimeError("Cannot downgrade: global paper_feedback rows with NULL track_id exist")

with op.batch_alter_table("paper_feedback") as batch_op:
batch_op.alter_column("track_id", existing_type=sa.Integer(), nullable=False)
52 changes: 31 additions & 21 deletions src/paperbot/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,10 @@
# src/paperbot/agents/__init__.py
"""
PaperBot Agent 模块。

提供各类 AI Agent 实现:
- BaseAgent: Agent 基类
- ResearchAgent: 论文研究 Agent
- CodeAnalysisAgent: 代码分析 Agent
- QualityAgent: 质量评估 Agent
- DocumentationAgent: 文档生成 Agent
- ConferenceResearchAgent: 会议论文抓取 Agent
- ReviewerAgent: 论文评审 Agent
- VerificationAgent: 声明验证 Agent
"""PaperBot agent exports.

Keep imports lazy so routes that only need a lightweight scholar agent do not
pull in optional report/PDF dependencies during package initialization.
"""

from .base import BaseAgent
from .research.agent import ResearchAgent
from .code_analysis.agent import CodeAnalysisAgent
from .quality.agent import QualityAgent
from .documentation.agent import DocumentationAgent
from .conference.agent import ConferenceResearchAgent
from .review.agent import ReviewerAgent
from .verification.agent import VerificationAgent
from importlib import import_module

__all__ = [
"BaseAgent",
Expand All @@ -32,3 +16,29 @@
"ReviewerAgent",
"VerificationAgent",
]

_LAZY_EXPORTS = {
"BaseAgent": (".base", "BaseAgent"),
"ResearchAgent": (".research.agent", "ResearchAgent"),
"CodeAnalysisAgent": (".code_analysis.agent", "CodeAnalysisAgent"),
"QualityAgent": (".quality.agent", "QualityAgent"),
"DocumentationAgent": (".documentation.agent", "DocumentationAgent"),
"ConferenceResearchAgent": (".conference.agent", "ConferenceResearchAgent"),
"ReviewerAgent": (".review.agent", "ReviewerAgent"),
"VerificationAgent": (".verification.agent", "VerificationAgent"),
}


def __getattr__(name: str):
try:
module_name, attr_name = _LAZY_EXPORTS[name]
except KeyError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc

value = getattr(import_module(module_name, __name__), attr_name)
globals()[name] = value
return value


def __dir__() -> list[str]:
return sorted(set(globals()) | set(__all__))
14 changes: 14 additions & 0 deletions src/paperbot/api/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,17 @@ def get_user_id(
if user is None:
return "default"
return str(user.id)


def get_required_user_id(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer),
) -> str:
"""Like get_user_id but rejects unauthenticated requests even when AUTH_OPTIONAL=true.

Use this for any endpoint that writes or reads user-scoped data, so that
anonymous callers cannot touch the shared "default" namespace.
"""
user = _resolve_user(credentials)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
return str(user.id)
8 changes: 6 additions & 2 deletions src/paperbot/api/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

from typing import List, Optional

from fastapi import APIRouter
from fastapi import APIRouter, Depends

from paperbot.api.auth.dependencies import get_required_user_id
from pydantic import BaseModel, Field, field_validator

from ...application.collaboration.message_schema import new_trace_id
Expand Down Expand Up @@ -128,11 +130,13 @@ async def chat_stream(request: ChatRequest, *, trace_id: str):


@router.post("/chat")
async def chat(request: ChatRequest):
async def chat(request: ChatRequest, user_id: str = Depends(get_required_user_id)):
"""
Chat with PaperBot AI and stream response.

Returns Server-Sent Events with streaming text.
"""
trace_id = new_trace_id()
# Override any client-provided user_id with authenticated user
request.user_id = user_id
return sse_response(chat_stream(request, trace_id=trace_id), workflow="chat", trace_id=trace_id)
Comment on lines +133 to 142

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add route tests for the new auth dependency path.

This changed endpoint behavior (dependency-required user identity and payload override), but no corresponding chat-route test updates are included in the provided changes.

Please add tests for: unauthenticated request rejection, authenticated request success, and precedence of dependency-injected user_id over any body-provided value.

As per coding guidelines {src,web/src}/**/*.{py,ts,tsx}: “If behavior changes, add or update tests”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/api/routes/chat.py` around lines 133 - 142, Add route tests for
the updated chat endpoint (function chat) to cover the new auth dependency path:
1) unauthenticated rejection — call the chat route without satisfying
get_required_user_id and assert it returns the appropriate 401/unauthorized
response; 2) authenticated success — mock or override get_required_user_id to
return a test user_id, call chat and assert the endpoint returns a successful
SSE/stream response (or expected status) and that chat_stream is invoked; 3)
dependency precedence — call chat with an authenticated get_required_user_id
value but include a different user_id in the request body and assert that the
dependency-injected user_id (from get_required_user_id) is used (e.g., by
inspecting arguments to chat_stream or final behavior), ensuring request.user_id
is overridden; place tests alongside existing route tests and use the same test
client/fixtures and mocking approach used in other route tests.

17 changes: 11 additions & 6 deletions src/paperbot/api/routes/gen_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel

from paperbot.application.collaboration.message_schema import new_run_id, new_trace_id
from paperbot.core.abstractions import AgentRunContext
from paperbot.api.auth.dependencies import get_required_user_id

from ..streaming import StreamEvent, sse_response

router = APIRouter()


class GenCodeRequest(BaseModel):
user_id: str = "default"
title: str
abstract: str
method_section: Optional[str] = None
Expand All @@ -28,7 +28,7 @@ class GenCodeRequest(BaseModel):


async def gen_code_stream(
request: GenCodeRequest, *, event_log=None, run_id: str = "", trace_id: str = ""
request: GenCodeRequest, *, user_id: str, event_log=None, run_id: str = "", trace_id: str = ""
):
"""Stream code generation progress"""
Comment on lines 30 to 33

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

gen_code_stream now takes the authenticated user_id separately, but GenCodeRequest still exposes a user_id field in the request body (and it is no longer used). This can confuse clients and invites incorrect assumptions; remove user_id from the request model (or mark it excluded) to make the API contract explicit.

Copilot uses AI. Check for mistakes.
try:
Expand Down Expand Up @@ -94,7 +94,7 @@ async def gen_code_stream(
result = await agent.reproduce_from_paper(
paper_context,
output_dir=output_dir,
user_id=request.user_id,
user_id=user_id,
event_log=event_log,
run_id=run_id,
trace_id=trace_id,
Expand Down Expand Up @@ -181,7 +181,11 @@ async def gen_code_stream(


@router.post("/gen-code")
async def generate_code(request: GenCodeRequest, http_request: Request):
async def generate_code(
request: GenCodeRequest,
http_request: Request,
user_id: str = Depends(get_required_user_id),
):
"""
Generate code from paper and stream progress.

Expand All @@ -190,8 +194,9 @@ async def generate_code(request: GenCodeRequest, http_request: Request):
event_log = getattr(http_request.app.state, "event_log", None)
run_id = new_run_id()
trace_id = new_trace_id()

return sse_response(
gen_code_stream(request, event_log=event_log, run_id=run_id, trace_id=trace_id),
gen_code_stream(request, user_id=user_id, event_log=event_log, run_id=run_id, trace_id=trace_id),
workflow="gen_code",
run_id=run_id,
trace_id=trace_id,
Expand Down
23 changes: 10 additions & 13 deletions src/paperbot/api/routes/harvest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field

from paperbot.api.streaming import StreamEvent, sse_response
Expand All @@ -23,6 +23,7 @@
HarvestPipeline,
HarvestProgress,
)
from paperbot.api.auth.dependencies import get_required_user_id
from paperbot.infrastructure.stores.paper_store import PaperStore, paper_to_dict
from paperbot.utils.logging_config import LogFiles, Logger, clear_trace_id, set_trace_id

Expand Down Expand Up @@ -324,12 +325,9 @@ class LibraryResponse(BaseModel):
offset: int


# TODO(auth): user_id is accepted from client without authentication.
# This is intentional for the MVP single-user setup. For multi-user production,
# user_id should come from an authenticated session or JWT token.
@router.get("/papers/library", response_model=LibraryResponse)
def get_user_library(
user_id: str = Query("default", description="User ID"),
user_id: str = Depends(get_required_user_id),
track_id: Optional[int] = Query(None, description="Filter by track"),
actions: Optional[str] = Query(None, description="Filter by actions (comma-separated)"),
Comment on lines 328 to 332

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

These library endpoints use Depends(get_user_id), which can fall back to the shared "default" namespace when AUTH_OPTIONAL=true (see get_user_id). For user-scoped reads, this should use get_required_user_id to prevent anonymous access to another user's data / the shared namespace.

Copilot uses AI. Check for mistakes.
sort_by: str = Query("saved_at", description="Sort field"),
Expand Down Expand Up @@ -392,13 +390,15 @@ def get_paper(paper_id: int):
class SavePaperRequest(BaseModel):
"""Request to save paper to library."""

# TODO(auth): user_id from client without auth - intentional for MVP single-user setup
user_id: str = Field("default", description="User ID")
track_id: Optional[int] = Field(None, description="Associated track ID")


@router.post("/papers/{paper_id}/save")
def save_paper_to_library(paper_id: int, request: SavePaperRequest):
def save_paper_to_library(
paper_id: int,
request: SavePaperRequest,
user_id: str = Depends(get_required_user_id),
):
Comment on lines 396 to +401

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

save_paper_to_library writes user-scoped data but uses Depends(get_user_id), which may resolve to "default" under AUTH_OPTIONAL=true. To avoid anonymous callers mutating the shared namespace, switch this to get_required_user_id.

Copilot uses AI. Check for mistakes.
"""
Save a paper to user's library.

Expand All @@ -413,7 +413,7 @@ def save_paper_to_library(paper_id: int, request: SavePaperRequest):
# Use research store to record feedback
research_store = _get_research_store()
feedback = research_store.record_paper_feedback(
user_id=request.user_id,
user_id=user_id,
paper_id=str(paper_id),
action="save",
track_id=request.track_id,
Expand All @@ -422,13 +422,10 @@ def save_paper_to_library(paper_id: int, request: SavePaperRequest):
return {"success": True, "feedback": feedback}


# TODO(auth): user_id accepted from query string without authentication.
# Intentional for MVP single-user setup. For multi-user production, user_id
# should come from authenticated session/JWT, not query parameters.
@router.delete("/papers/{paper_id}/save")
def remove_paper_from_library(
paper_id: int,
user_id: str = Query("default", description="User ID"),
user_id: str = Depends(get_required_user_id),
):
Comment on lines 425 to 429

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

remove_paper_from_library deletes user-scoped data but uses Depends(get_user_id), which can fall back to the shared "default" namespace when AUTH_OPTIONAL=true. This should use get_required_user_id so unauthenticated callers cannot modify shared state.

Copilot uses AI. Check for mistakes.
"""Remove a paper from user's library."""
store = _get_paper_store()
Expand Down
35 changes: 12 additions & 23 deletions src/paperbot/api/routes/intelligence.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@
import re
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel, Field

from paperbot.infrastructure.services.intelligence_radar_service import IntelligenceRadarService
from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore
from paperbot.api.auth.dependencies import get_required_user_id

router = APIRouter()
_service: Optional[IntelligenceRadarService] = None
_research_store = SqlAlchemyResearchStore()
_DEFAULT_USER_ID = "default"

_SIGNAL_STOPWORDS = {
"about",
"across",
Expand Down Expand Up @@ -97,20 +96,11 @@ class IntelligenceFeedResponse(BaseModel):
subreddits: List[str] = Field(default_factory=list)


def _resolve_feed_user_id(requested_user_id: Optional[str]) -> str:
user_id = str(requested_user_id or _DEFAULT_USER_ID).strip() or _DEFAULT_USER_ID
if user_id != _DEFAULT_USER_ID:
raise HTTPException(
status_code=403,
detail="cross-user intelligence access requires authenticated user context",
)
return _DEFAULT_USER_ID


@router.get("/intelligence/feed", response_model=IntelligenceFeedResponse)
def get_intelligence_feed(
background_tasks: BackgroundTasks,
user_id: str = Query("default"),
user_id: str = Depends(get_required_user_id),
limit: int = Query(6, ge=1, le=20),
refresh: bool = Query(False),
source: Optional[str] = Query(None),
Expand All @@ -123,42 +113,41 @@ def get_intelligence_feed(
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
track_id: Optional[int] = Query(None, ge=1),
):
resolved_user_id = _resolve_feed_user_id(user_id)
service = _get_service()
refresh_scheduled = False

if refresh:
service.refresh(user_id=resolved_user_id)
elif service.needs_refresh(user_id=resolved_user_id):
cached = service.list_feed(user_id=resolved_user_id, limit=1)
service.refresh(user_id=user_id)
elif service.needs_refresh(user_id=user_id):
cached = service.list_feed(user_id=user_id, limit=1)
if cached:
background_tasks.add_task(service.refresh, user_id=resolved_user_id)
background_tasks.add_task(service.refresh, user_id=user_id)
refresh_scheduled = True
else:
service.refresh(user_id=resolved_user_id)
service.refresh(user_id=user_id)

rows = service.list_feed(
user_id=resolved_user_id,
user_id=user_id,
limit=max(int(limit) * 10, 50),
source=source,
keyword=keyword,
repo=repo,
sort_by=sort_by,
sort_order=sort_order,
)
annotated_rows = [_annotate_intelligence_row(user_id=resolved_user_id, row=row) for row in rows]
annotated_rows = [_annotate_intelligence_row(user_id=user_id, row=row) for row in rows]
if track_id:
annotated_rows = [
row
for row in annotated_rows
if any(int(track.get("track_id") or 0) == int(track_id) for track in row.get("matched_tracks") or [])
]

profile = service.build_profile(user_id=resolved_user_id)
profile = service.build_profile(user_id=user_id)

return IntelligenceFeedResponse(
items=[_to_response_item(row) for row in annotated_rows[: max(1, int(limit))]],
refreshed_at=service.latest_refresh(user_id=resolved_user_id),
refreshed_at=service.latest_refresh(user_id=user_id),
refresh_scheduled=refresh_scheduled,
keywords=profile.keywords,
watch_repos=profile.watch_repos,
Expand Down
Loading
Loading