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
214 changes: 134 additions & 80 deletions src/paperbot/api/routes/research.py

Large diffs are not rendered by default.

62 changes: 59 additions & 3 deletions src/paperbot/application/services/anchor_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,58 @@ def _safe_json_obj(text: str) -> dict[str, Any]:
return {}


_FEEDBACK_ACTION_ALIASES = {
"not_relevant": "dislike",
"not-relevant": "dislike",
"not related": "dislike",
}
_FEEDBACK_GROUP_BY_ACTION = {
"save": "save_state",
"unsave": "save_state",
"like": "preference_state",
"unlike": "preference_state",
"dislike": "preference_state",
"undislike": "preference_state",
"skip": "preference_state",
"cite": "cite_state",
}
_FEEDBACK_EFFECTIVE_ACTIONS = {
"save": "save",
"unsave": None,
"like": "like",
"unlike": None,
"dislike": "dislike",
"undislike": None,
"skip": "skip",
"cite": "cite",
}


def _normalize_feedback_action(value: str) -> str:
normalized = (value or "").strip().lower().replace(" ", "_")
return _FEEDBACK_ACTION_ALIASES.get(normalized, normalized)


def _collapse_effective_feedback_actions(rows: list[PaperFeedbackModel]) -> list[str]:
effective_actions: list[str] = []
seen: set[tuple[int, str]] = set()
for row in rows:
paper_id = int(row.canonical_paper_id or row.paper_ref_id or 0)
if paper_id <= 0:
continue
normalized_action = _normalize_feedback_action(str(row.action or ""))
group = _FEEDBACK_GROUP_BY_ACTION.get(normalized_action, normalized_action)
state_key = (paper_id, group)
if state_key in seen:
continue
seen.add(state_key)

effective_action = _FEEDBACK_EFFECTIVE_ACTIONS.get(normalized_action, normalized_action)
if effective_action:
effective_actions.append(effective_action)
return effective_actions

Comment on lines +72 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The constants (_FEEDBACK_ACTION_ALIASES, etc.) and helper functions (_normalize_feedback_action, _collapse_effective_feedback_actions) for handling feedback actions are duplicated from src/paperbot/infrastructure/stores/research_store.py. This duplication can lead to inconsistencies if the logic is updated in one place but not the other. To improve maintainability, this logic should be centralized. Since SqlAlchemyResearchStore is the owner of the feedback data, it's the ideal place for this logic. Consider removing the duplicated code from this file and importing the necessary functions/constants from research_store.py.


class AnchorService:
"""Discover anchor authors with intrinsic + relevance + network scoring."""

Expand Down Expand Up @@ -158,6 +210,7 @@ def discover(
PaperFeedbackModel.paper_ref_id.in_(paper_ids),
)
)
.order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id))
)
.scalars()
.all()
Expand All @@ -180,11 +233,14 @@ def discover(
"dislike": -1.0,
"skip": -0.3,
}
effective_feedback_actions = _collapse_effective_feedback_actions(feedback_rows)
raw_feedback = 0.0
for row in feedback_rows:
raw_feedback += action_weights.get((row.action or "").lower(), 0.0)
for action in effective_feedback_actions:
raw_feedback += action_weights.get(action, 0.0)
feedback_signal = (
(math.tanh(raw_feedback / 4.0) + 1.0) / 2.0 if feedback_rows else 0.0
(math.tanh(raw_feedback / 4.0) + 1.0) / 2.0
if effective_feedback_actions
else 0.0
)

relevance_score = keyword_match_rate
Expand Down
30 changes: 19 additions & 11 deletions src/paperbot/context_engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ def __init__(
config=self.config.track_router,
)
self._layer0_cache: Dict[str, Dict[str, Any]] = {} # keyed by user_id
self._layer0_cache_ts: Dict[str, float] = {} # keyed by user_id
self._layer0_cache_ts: Dict[str, float] = {} # keyed by user_id
self._layer0_ttl: float = 300.0 # 5 minutes

def _attach_latest_judge(self, papers: List[Dict[str, Any]]) -> None:
Expand Down Expand Up @@ -591,9 +591,7 @@ def _load_layer0_profile(self, user_id: str) -> List[Dict[str, Any]]:
self._layer0_cache_ts[user_id] = now
return prefs

def _load_layer1_track(
self, user_id: str, track: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
def _load_layer1_track(self, user_id: str, track: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Layer 1: track context — goals, keywords, tasks, milestones (~500 tokens)."""
tasks: List[Dict[str, Any]] = []
milestones: List[Dict[str, Any]] = []
Expand Down Expand Up @@ -771,9 +769,7 @@ def _total_tokens() -> int:

return prefs, task_list, milestone_list, relevant, cross_track, paper, layers, trimmed

def _load_layer3_paper(
self, user_id: str, paper_id: Optional[str]
) -> List[Dict[str, Any]]:
def _load_layer3_paper(self, user_id: str, paper_id: Optional[str]) -> List[Dict[str, Any]]:
"""Layer 3: paper-scoped memories (on-demand, only when paper_id given)."""
if not paper_id:
return []
Expand Down Expand Up @@ -917,11 +913,23 @@ async def build_context_pack(

if self.config.personalized and routed_track:
try:
feedback_rows = self.research_store.list_paper_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
list_effective_feedback = getattr(
self.research_store,
"list_effective_paper_feedback",
None,
)
if callable(list_effective_feedback):
feedback_rows = list_effective_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
)
else:
feedback_rows = self.research_store.list_paper_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
)
Comment on lines +916 to +932

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

The use of getattr to dynamically check for the existence of list_effective_paper_feedback is a bit fragile and not the most idiomatic Python. Using hasattr is generally preferred for this type of check as it's more explicit about its intent. This would make the code clearer and slightly more robust.

Suggested change
list_effective_feedback = getattr(
self.research_store,
"list_effective_paper_feedback",
None,
)
if callable(list_effective_feedback):
feedback_rows = list_effective_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
)
else:
feedback_rows = self.research_store.list_paper_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
)
if hasattr(self.research_store, "list_effective_paper_feedback"):
feedback_rows = self.research_store.list_effective_paper_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
)
else:
feedback_rows = self.research_store.list_paper_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
)

default_rrf_weights = getattr(
self.search_service,
"DEFAULT_SOURCE_WEIGHTS",
Expand Down
Loading
Loading