diff --git a/docs/design/research-track-context-model.md b/docs/design/research-track-context-model.md new file mode 100644 index 00000000..ffae8a07 --- /dev/null +++ b/docs/design/research-track-context-model.md @@ -0,0 +1,105 @@ +# Research Track Context Model + +## Purpose + +PaperBot now treats `ResearchTrack` as the stable aggregate root for research workspace state. +The goal is to stop rebuilding track state ad hoc in route handlers and frontend pages. + +This document describes the current target shape after the `#325` refactor stack: + +- `#327` introduced an application-layer track context read model +- `#328` exposed a consolidated track context endpoint +- `#329` migrated `ResearchPageNew` to that endpoint and made context builds send explicit `track_id` +- `#331` wrapped track-scoped memory access behind a dedicated service + +## Stable Track Snapshot + +Use the consolidated track snapshot when a surface needs to answer: + +- what track is active +- which tasks and milestones matter right now +- how much track-scoped memory exists +- what effective feedback already shaped the track +- what saved-paper preview and eval summary should be shown + +Current API surface: + +- `GET /api/research/tracks/{track_id}/context` + +Current backend entry point: + +- `paperbot.application.services.research_track_context_service.ResearchTrackContextService` + +Snapshot fields: + +- `track` +- `tasks` +- `milestones` +- `memory` +- `feedback` +- `saved_papers` +- `eval_summary` + +This snapshot is intentionally **stable**. Query-dependent recommendation work still belongs to +`POST /api/research/context`. + +## Track Memory Ownership + +Track memory mutations and reads should not manually assemble: + +- `scope_type="track"` +- `scope_id=str(track_id)` +- active-track fallback logic +- affected-track recompute logic + +Current backend entry point: + +- `paperbot.application.services.track_memory_service.TrackMemoryService` + +Use it for: + +- inbox reads +- clear-track operations +- bulk moderate / bulk move flows +- scope resolution for track-bound memory mutations + +This keeps route code focused on HTTP concerns, metrics, and response shaping. + +## Contributor Guidance + +### Backend + +When adding a new research-track surface: + +1. Start from `ResearchTrackContextService` if the need is a stable snapshot. +2. Start from `TrackMemoryService` if the need is a track-scoped memory read or mutation. +3. Add or widen an application-layer port before reaching into infrastructure from a new service. +4. Prefer reusing existing store projections such as: + - `list_effective_paper_feedback` + - `list_saved_papers` + - `summarize_eval` +5. Do not add new route-local stitching if the same aggregation could live in a service. + +### Web + +When building track-aware pages: + +1. Fetch the consolidated track snapshot from `/api/research/tracks/{track_id}/context`. +2. Treat `track_id` as explicit request state, not hidden server-side activation state. +3. Mutations such as track activation, memory clear, and feedback writes should invalidate or refetch the snapshot. +4. Keep query-time recommendations separate from the stable snapshot; use `POST /api/research/context` for that path. + +## Test Guidance + +Relevant coverage added in this refactor stack: + +- `tests/unit/test_research_track_context_service.py` +- `tests/integration/test_research_track_context_routes.py` +- `tests/unit/test_research_context_route_explicit_track.py` +- `tests/unit/test_track_memory_service.py` +- `tests/integration/test_research_track_memory_routes.py` + +Isolation note: + +- tests that exercise memory metrics must reset route-level collector/store singletons before creating the client +- route modules in this codebase cache stores aggressively; tests should explicitly clear those globals when switching DB URLs diff --git a/src/paperbot/api/routes/research.py b/src/paperbot/api/routes/research.py index a692805d..b49e12af 100644 --- a/src/paperbot/api/routes/research.py +++ b/src/paperbot/api/routes/research.py @@ -12,6 +12,15 @@ from pydantic import BaseModel, Field from sqlalchemy.exc import IntegrityError +from paperbot.application.services.research_track_context_service import ( + ResearchTrackContextService, + TrackContextSnapshot, +) +from paperbot.application.services.track_memory_service import ( + TrackMemoryScopeError, + TrackMemoryService, + TrackMemoryValidationError, +) from paperbot.context_engine import ContextEngine, ContextEngineConfig from paperbot.context_engine.track_router import TrackRouter from paperbot.domain.paper_identity import normalize_arxiv_id, normalize_doi @@ -65,6 +74,20 @@ def _get_track_router() -> TrackRouter: return _track_router +def _build_track_context_service() -> ResearchTrackContextService: + return ResearchTrackContextService( + track_reader=_get_research_store(), + memory_store=_get_memory_store(), + ) + + +def _build_track_memory_service() -> TrackMemoryService: + return TrackMemoryService( + track_reader=_get_research_store(), + memory_store=_get_memory_store(), + ) + + def _schedule_obsidian_export( background_tasks: BackgroundTasks, *, @@ -314,6 +337,52 @@ class TrackResponse(BaseModel): track: Dict[str, Any] +class TrackContextMemorySummaryResponse(BaseModel): + total_items: int + approved_items: int + pending_items: int + top_tags: List[str] + latest_memory_at: Optional[str] = None + + +class TrackContextFeedbackSummaryResponse(BaseModel): + total_items: int + actions: Dict[str, int] + latest_feedback_at: Optional[str] = None + recent_items: List[Dict[str, Any]] + + +class TrackContextSavedPapersResponse(BaseModel): + total_items: int + latest_saved_at: Optional[str] = None + recent_items: List[Dict[str, Any]] + + +class TrackContextResponse(BaseModel): + user_id: str + track_id: int + track: Dict[str, Any] + tasks: List[Dict[str, Any]] + milestones: List[Dict[str, Any]] + memory: TrackContextMemorySummaryResponse + feedback: TrackContextFeedbackSummaryResponse + saved_papers: TrackContextSavedPapersResponse + eval_summary: Dict[str, Any] + + +def _serialize_track_context_response( + *, + user_id: str, + snapshot: TrackContextSnapshot, +) -> TrackContextResponse: + track_id = int(snapshot.track.get("id") or 0) + return TrackContextResponse( + user_id=user_id, + track_id=track_id, + **snapshot.to_dict(), + ) + + @router.post("/research/tracks", response_model=TrackResponse) def create_track(req: TrackCreateRequest, background_tasks: BackgroundTasks): track = _get_research_store().create_track( @@ -458,6 +527,17 @@ def get_active_track(user_id: str = "default"): return TrackResponse(track=track) +@router.get("/research/tracks/{track_id}/context", response_model=TrackContextResponse) +def get_track_context(track_id: int, user_id: str = "default"): + snapshot = _build_track_context_service().get_track_context( + user_id=user_id, + track_id=track_id, + ) + if snapshot is None: + raise HTTPException(status_code=404, detail="Track not found") + return _serialize_track_context_response(user_id=user_id, snapshot=snapshot) + + @router.patch("/research/tracks/{track_id}", response_model=TrackResponse) def update_track( track_id: int, @@ -567,14 +647,11 @@ class MemoryItemResponse(BaseModel): def _resolve_track_scope_id( user_id: str, scope_type: str, scope_id: Optional[str] ) -> Optional[str]: - if scope_type != "track": - return scope_id - if scope_id: - return scope_id - active = _get_research_store().get_active_track(user_id=user_id) - if not active: - return None - return str(active["id"]) + return _build_track_memory_service().resolve_scope_id( + user_id=user_id, + scope_type=scope_type, + scope_id=scope_id, + ) @router.post("/research/memory/items", response_model=MemoryItemResponse) @@ -582,7 +659,10 @@ def create_memory_item(req: MemoryItemCreateRequest, background_tasks: Backgroun scope_type = (req.scope_type or "global").strip() or "global" scope_id = _resolve_track_scope_id(req.user_id, scope_type, req.scope_id) if scope_type == "track" and not scope_id: - raise HTTPException(status_code=400, detail="scope_id missing and no active track") + raise HTTPException( + status_code=400, + detail="track scope requires an existing track or an active track", + ) cand = MemoryCandidate( kind=req.kind, # type: ignore[arg-type] @@ -639,21 +719,14 @@ def list_memory_inbox( track_id: Optional[int] = None, limit: int = Query(100, ge=1, le=500), ): - if track_id is None: - active = _get_research_store().get_active_track(user_id=user_id) - if not active: - raise HTTPException(status_code=404, detail="No active track for user") - track_id = int(active["id"]) - - items = _get_memory_store().list_memories( - user_id=user_id, - limit=limit, - scope_type="track", - scope_id=str(track_id), - status="pending", - include_deleted=False, - include_pending=True, - ) + try: + items = _build_track_memory_service().list_inbox( + user_id=user_id, + track_id=track_id, + limit=limit, + ) + except TrackMemoryScopeError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc return MemoryItemListResponse(user_id=user_id, items=items) @@ -679,7 +752,10 @@ def suggest_memories(req: MemorySuggestRequest, background_tasks: BackgroundTask scope_type = (req.scope_type or "global").strip() or "global" scope_id = _resolve_track_scope_id(req.user_id, scope_type, req.scope_id) if scope_type == "track" and not scope_id: - raise HTTPException(status_code=400, detail="scope_id missing and no active track") + raise HTTPException( + status_code=400, + detail="track scope requires an existing track or an active track", + ) msgs = [NormalizedMessage(role="user", content=req.text)] extracted = extract_memories( @@ -752,21 +828,18 @@ class BulkModerateResponse(BaseModel): @router.post("/research/memory/bulk_moderate", response_model=BulkModerateResponse) def bulk_moderate(req: BulkModerateRequest, background_tasks: BackgroundTasks): - # Get items before update to check their confidence for P0 metrics - items_before = _get_memory_store().get_items_by_ids(user_id=req.user_id, item_ids=req.item_ids) - - updated = _get_memory_store().bulk_update_items( + result = _build_track_memory_service().bulk_moderate( user_id=req.user_id, item_ids=req.item_ids, status=req.status, - actor_id="user", ) - affected_tracks = [ - int(i.get("scope_id") or 0) - for i in updated - if i.get("scope_type") == "track" and i.get("scope_id") - ] - _schedule_embedding_precompute(background_tasks, user_id=req.user_id, track_ids=affected_tracks) + items_before = result.items_before + updated = result.updated_items + _schedule_embedding_precompute( + background_tasks, + user_id=req.user_id, + track_ids=result.affected_track_ids, + ) # P0 Hook: Record false positive rate when user rejects high-confidence items # A rejection of an auto-approved (confidence >= 0.60) item is a false positive @@ -805,24 +878,22 @@ class BulkMoveResponse(BaseModel): @router.post("/research/memory/bulk_move", response_model=BulkMoveResponse) def bulk_move(req: BulkMoveRequest, background_tasks: BackgroundTasks): - scope_type = (req.scope_type or "global").strip() or "global" - scope_id = _resolve_track_scope_id(req.user_id, scope_type, req.scope_id) - if scope_type == "track" and not scope_id: - raise HTTPException(status_code=400, detail="scope_id missing and no active track") - updated = _get_memory_store().bulk_update_items( + try: + result = _build_track_memory_service().bulk_move( + user_id=req.user_id, + item_ids=req.item_ids, + scope_type=req.scope_type, + scope_id=req.scope_id, + ) + except TrackMemoryValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + _schedule_embedding_precompute( + background_tasks, user_id=req.user_id, - item_ids=req.item_ids, - scope_type=scope_type, - scope_id=scope_id, - actor_id="user", + track_ids=result.affected_track_ids, ) - affected_tracks = [ - int(i.get("scope_id") or 0) - for i in updated - if i.get("scope_type") == "track" and i.get("scope_id") - ] - _schedule_embedding_precompute(background_tasks, user_id=req.user_id, track_ids=affected_tracks) - return BulkMoveResponse(user_id=req.user_id, updated=updated) + return BulkMoveResponse(user_id=req.user_id, updated=result.updated_items) class MemoryFeedbackRequest(BaseModel): @@ -911,45 +982,28 @@ def clear_track_memory( ): if not confirm: raise HTTPException(status_code=400, detail="confirm=true required") - deleted = _get_memory_store().soft_delete_by_scope( - user_id=user_id, - scope_type="track", - scope_id=str(track_id), - actor_id="user", - reason="clear_track_memory", - ) + try: + result = _build_track_memory_service().clear_track_memory( + user_id=user_id, + track_id=track_id, + ) + except TrackMemoryScopeError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + deleted = result.deleted_count _schedule_embedding_precompute(background_tasks, user_id=user_id, track_ids=[track_id]) # P0 Hook: Verify deletion compliance - deleted items should not be retrievable if deleted > 0: - # Try to retrieve items from the cleared scope (should return empty) - retrieved_after_delete = _get_memory_store().list_memories( - user_id=user_id, - scope_type="track", - scope_id=str(track_id), - include_deleted=False, - include_pending=True, - limit=100, - ) - # Also try searching - search_results = _get_memory_store().search_memories( - user_id=user_id, - query="*", # broad query - scope_type="track", - scope_id=str(track_id), - limit=100, - ) - retrieved_count = len(retrieved_after_delete) + len(search_results) - collector = _get_metric_collector() collector.record_deletion_compliance( - deleted_retrieved_count=retrieved_count, + deleted_retrieved_count=result.retrieved_after_delete_count, deleted_total_count=deleted, evaluator_id=f"user:{user_id}", detail={ "track_id": track_id, "deleted_count": deleted, - "retrieved_after_delete": retrieved_count, + "retrieved_after_delete": result.retrieved_after_delete_count, "action": "clear_track_memory", }, ) diff --git a/src/paperbot/application/ports/__init__.py b/src/paperbot/application/ports/__init__.py index 260f071d..e8439a26 100644 --- a/src/paperbot/application/ports/__init__.py +++ b/src/paperbot/application/ports/__init__.py @@ -1,19 +1,27 @@ """Application ports (interfaces) used by the application layer.""" from .event_log_port import EventLogPort +from .feedback_port import FeedbackPort from .harvester_port import HarvesterPort +from .memory_port import MemoryPort +from .research_track_read_port import ResearchTrackReadPort from .source_collector import ( NullSourceCollector, SourceCollector, SourceCollectRequest, SourceCollectResult, ) +from .track_memory_store_port import TrackMemoryStorePort __all__ = [ "EventLogPort", + "FeedbackPort", "HarvesterPort", + "MemoryPort", + "ResearchTrackReadPort", "SourceCollector", "SourceCollectRequest", "SourceCollectResult", + "TrackMemoryStorePort", "NullSourceCollector", ] diff --git a/src/paperbot/application/ports/research_track_read_port.py b/src/paperbot/application/ports/research_track_read_port.py new file mode 100644 index 00000000..462d8a18 --- /dev/null +++ b/src/paperbot/application/ports/research_track_read_port.py @@ -0,0 +1,59 @@ +"""Read-only protocol for research track context aggregation.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + + +@runtime_checkable +class ResearchTrackReadPort(Protocol): + """Track-scoped read operations needed by track context services.""" + + def get_track(self, *, user_id: str, track_id: int) -> Optional[Dict[str, Any]]: ... + + def get_active_track(self, *, user_id: str) -> Optional[Dict[str, Any]]: ... + + def list_tasks( + self, + *, + user_id: str, + track_id: int, + status: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: ... + + def list_milestones( + self, + *, + user_id: str, + track_id: int, + status: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: ... + + def list_effective_paper_feedback( + self, + *, + user_id: str, + track_id: int, + limit: int = 200, + ) -> List[Dict[str, Any]]: ... + + def list_saved_papers( + self, + *, + user_id: str, + track_id: Optional[int] = None, + collection_id: Optional[int] = None, + limit: int = 200, + sort_by: str = "saved_at", + ) -> List[Dict[str, Any]]: ... + + def summarize_eval( + self, + *, + user_id: str, + track_id: Optional[int] = None, + days: int = 30, + limit: int = 2000, + ) -> Dict[str, Any]: ... diff --git a/src/paperbot/application/ports/track_memory_store_port.py b/src/paperbot/application/ports/track_memory_store_port.py new file mode 100644 index 00000000..77037db0 --- /dev/null +++ b/src/paperbot/application/ports/track_memory_store_port.py @@ -0,0 +1,68 @@ +"""Protocol for track-scoped memory operations used by the application layer.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + + +@runtime_checkable +class TrackMemoryStorePort(Protocol): + """Subset of memory-store operations needed for track memory orchestration.""" + + def list_memories( + self, + *, + user_id: str, + limit: int = 100, + kind: Optional[str] = None, + workspace_id: Optional[str] = None, + scope_type: Optional[str] = None, + scope_id: Optional[str] = None, + include_pending: bool = False, + include_deleted: bool = False, + status: Optional[str] = None, + ) -> List[Dict[str, Any]]: ... + + def get_items_by_ids( + self, + *, + user_id: str, + item_ids: List[int], + ) -> List[Dict[str, Any]]: ... + + def search_memories( + self, + *, + user_id: str, + query: str, + limit: int = 8, + workspace_id: Optional[str] = None, + scope_type: Optional[str] = None, + scope_id: Optional[str] = None, + min_score: float = 0.0, + candidate_multiplier: int = 4, + mmr_enabled: bool = False, + mmr_lambda: float = 0.7, + half_life_days: float = 30.0, + ) -> List[Dict[str, Any]]: ... + + def soft_delete_by_scope( + self, + *, + user_id: str, + scope_type: str, + scope_id: Optional[str], + actor_id: str = "system", + reason: str = "", + ) -> int: ... + + def bulk_update_items( + self, + *, + user_id: str, + item_ids: List[int], + actor_id: str = "system", + status: Optional[str] = None, + scope_type: Optional[str] = None, + scope_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: ... diff --git a/src/paperbot/application/services/__init__.py b/src/paperbot/application/services/__init__.py index b3eac724..11af585b 100644 --- a/src/paperbot/application/services/__init__.py +++ b/src/paperbot/application/services/__init__.py @@ -2,6 +2,8 @@ from paperbot.application.services.p2c import ExtractionOrchestrator from paperbot.application.services.paper_deduplicator import PaperDeduplicator from paperbot.application.services.query_rewriter import QueryRewriter +from paperbot.application.services.research_track_context_service import ResearchTrackContextService +from paperbot.application.services.track_memory_service import TrackMemoryService from paperbot.application.services.venue_recommender import VenueRecommender __all__ = [ @@ -10,5 +12,7 @@ "PaperDeduplicator", "ExtractionOrchestrator", "QueryRewriter", + "ResearchTrackContextService", + "TrackMemoryService", "VenueRecommender", ] diff --git a/src/paperbot/application/services/research_track_context_service.py b/src/paperbot/application/services/research_track_context_service.py new file mode 100644 index 00000000..7559fdd4 --- /dev/null +++ b/src/paperbot/application/services/research_track_context_service.py @@ -0,0 +1,257 @@ +"""Track-centric read model for research workspace surfaces.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + +from paperbot.application.ports.memory_port import MemoryPort +from paperbot.application.ports.research_track_read_port import ResearchTrackReadPort + + +@dataclass(frozen=True) +class TrackContextQuery: + """Read limits for a track context snapshot.""" + + task_limit: int = 5 + milestone_limit: int = 3 + feedback_limit: int = 25 + feedback_scan_limit: int = 200 + saved_preview_limit: int = 5 + saved_scan_limit: int = 200 + memory_scan_limit: int = 500 + top_tag_limit: int = 5 + eval_days: int = 30 + + +@dataclass(frozen=True) +class TrackMemoryStats: + """Basic memory summary for a research track.""" + + total_items: int + approved_items: int + pending_items: int + top_tags: List[str] = field(default_factory=list) + latest_memory_at: Optional[str] = None + + +@dataclass(frozen=True) +class TrackFeedbackSummary: + """Effective feedback summary for a research track.""" + + total_items: int + actions: Dict[str, int] = field(default_factory=dict) + latest_feedback_at: Optional[str] = None + recent_items: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class TrackSavedPaperSummary: + """Saved-paper summary for a research track.""" + + total_items: int + latest_saved_at: Optional[str] = None + recent_items: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class TrackContextSnapshot: + """Track-centric aggregate used by API routes and web clients.""" + + track: Dict[str, Any] + tasks: List[Dict[str, Any]] + milestones: List[Dict[str, Any]] + memory: TrackMemoryStats + feedback: TrackFeedbackSummary + saved_papers: TrackSavedPaperSummary + eval_summary: Dict[str, Any] + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +class ResearchTrackContextService: + """Compose a track-scoped read model from existing stores.""" + + def __init__( + self, + *, + track_reader: ResearchTrackReadPort, + memory_store: MemoryPort, + ) -> None: + self._track_reader = track_reader + self._memory_store = memory_store + + def get_track_context( + self, + *, + user_id: str, + track_id: Optional[int] = None, + query: Optional[TrackContextQuery] = None, + ) -> Optional[TrackContextSnapshot]: + limits = query or TrackContextQuery() + track = self._resolve_track(user_id=user_id, track_id=track_id) + if track is None: + return None + + resolved_track_id = int(track.get("id") or 0) + if resolved_track_id <= 0: + return None + + tasks = self._track_reader.list_tasks( + user_id=user_id, + track_id=resolved_track_id, + limit=limits.task_limit, + ) + milestones = self._track_reader.list_milestones( + user_id=user_id, + track_id=resolved_track_id, + limit=limits.milestone_limit, + ) + memory = self._build_memory_stats( + user_id=user_id, + track_id=resolved_track_id, + query=limits, + ) + feedback = self._build_feedback_summary( + user_id=user_id, + track_id=resolved_track_id, + query=limits, + ) + saved_papers = self._build_saved_paper_summary( + user_id=user_id, + track_id=resolved_track_id, + query=limits, + ) + eval_summary = self._track_reader.summarize_eval( + user_id=user_id, + track_id=resolved_track_id, + days=limits.eval_days, + ) + + return TrackContextSnapshot( + track=track, + tasks=tasks, + milestones=milestones, + memory=memory, + feedback=feedback, + saved_papers=saved_papers, + eval_summary=eval_summary, + ) + + def _resolve_track(self, *, user_id: str, track_id: Optional[int]) -> Optional[Dict[str, Any]]: + if track_id is None: + return self._track_reader.get_active_track(user_id=user_id) + return self._track_reader.get_track(user_id=user_id, track_id=int(track_id)) + + def _build_memory_stats( + self, + *, + user_id: str, + track_id: int, + query: TrackContextQuery, + ) -> TrackMemoryStats: + scope_id = str(track_id) + approved_items = self._memory_store.list_memories( + user_id=user_id, + limit=query.memory_scan_limit, + scope_type="track", + scope_id=scope_id, + status="approved", + include_pending=True, + include_deleted=False, + ) + pending_items = self._memory_store.list_memories( + user_id=user_id, + limit=query.memory_scan_limit, + scope_type="track", + scope_id=scope_id, + status="pending", + include_pending=True, + include_deleted=False, + ) + all_items = approved_items + pending_items + tag_counts: Counter[str] = Counter() + latest_memory_at: Optional[str] = None + for item in all_items: + for raw_tag in item.get("tags") or []: + tag = str(raw_tag).strip() + if tag: + tag_counts[tag] += 1 + latest_memory_at = self._pick_latest_timestamp( + latest_memory_at, + str(item.get("updated_at") or item.get("created_at") or "") or None, + ) + return TrackMemoryStats( + total_items=len(all_items), + approved_items=len(approved_items), + pending_items=len(pending_items), + top_tags=[tag for tag, _ in tag_counts.most_common(query.top_tag_limit)], + latest_memory_at=latest_memory_at, + ) + + def _build_feedback_summary( + self, + *, + user_id: str, + track_id: int, + query: TrackContextQuery, + ) -> TrackFeedbackSummary: + rows = self._track_reader.list_effective_paper_feedback( + user_id=user_id, + track_id=track_id, + limit=max(query.feedback_limit, query.feedback_scan_limit), + ) + actions = Counter() + latest_feedback_at: Optional[str] = None + for row in rows: + action = str(row.get("action") or "").strip() + if action: + actions[action] += 1 + latest_feedback_at = self._pick_latest_timestamp( + latest_feedback_at, + str(row.get("ts") or "") or None, + ) + return TrackFeedbackSummary( + total_items=len(rows), + actions=dict(actions), + latest_feedback_at=latest_feedback_at, + recent_items=rows[: query.feedback_limit], + ) + + def _build_saved_paper_summary( + self, + *, + user_id: str, + track_id: int, + query: TrackContextQuery, + ) -> TrackSavedPaperSummary: + rows = self._track_reader.list_saved_papers( + user_id=user_id, + track_id=track_id, + limit=max(query.saved_preview_limit, query.saved_scan_limit), + sort_by="saved_at", + ) + latest_saved_at = None + for row in rows: + latest_saved_at = self._pick_latest_timestamp( + latest_saved_at, + str(row.get("saved_at") or "") or None, + ) + return TrackSavedPaperSummary( + total_items=len(rows), + latest_saved_at=latest_saved_at, + recent_items=rows[: query.saved_preview_limit], + ) + + @staticmethod + def _pick_latest_timestamp( + current_value: Optional[str], + candidate_value: Optional[str], + ) -> Optional[str]: + if not candidate_value: + return current_value + if not current_value or candidate_value > current_value: + return candidate_value + return current_value diff --git a/src/paperbot/application/services/track_memory_service.py b/src/paperbot/application/services/track_memory_service.py new file mode 100644 index 00000000..02380812 --- /dev/null +++ b/src/paperbot/application/services/track_memory_service.py @@ -0,0 +1,216 @@ +"""Track-scoped memory facade used by research routes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from paperbot.application.ports.research_track_read_port import ResearchTrackReadPort +from paperbot.application.ports.track_memory_store_port import TrackMemoryStorePort + + +class TrackMemoryScopeError(LookupError): + """Raised when a required track scope cannot be resolved.""" + + +class TrackMemoryValidationError(ValueError): + """Raised when a track memory operation receives invalid input.""" + + +@dataclass(frozen=True) +class TrackMemoryScope: + track_id: int + scope_id: str + + +@dataclass(frozen=True) +class TrackMemoryClearResult: + track_id: int + deleted_count: int + retrieved_after_delete_count: int + + +@dataclass(frozen=True) +class TrackMemoryBulkResult: + items_before: List[Dict[str, Any]] + updated_items: List[Dict[str, Any]] + affected_track_ids: List[int] + + +class TrackMemoryService: + """Own track-scoped memory resolution and mutations.""" + + def __init__( + self, + *, + track_reader: ResearchTrackReadPort, + memory_store: TrackMemoryStorePort, + ) -> None: + self._track_reader = track_reader + self._memory_store = memory_store + + def resolve_scope_id( + self, + *, + user_id: str, + scope_type: str, + scope_id: Optional[str], + ) -> Optional[str]: + normalized_scope_type = (scope_type or "global").strip() or "global" + if normalized_scope_type != "track": + return scope_id + if scope_id: + try: + track_id = int(scope_id) + except (TypeError, ValueError): + return None + track = self._track_reader.get_track(user_id=user_id, track_id=track_id) + return str(track_id) if track is not None else None + active = self._track_reader.get_active_track(user_id=user_id) + if not active: + return None + track_id = int(active.get("id") or 0) + return str(track_id) if track_id > 0 else None + + def list_inbox( + self, + *, + user_id: str, + track_id: Optional[int] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + scope = self.require_track_scope(user_id=user_id, track_id=track_id) + return self._memory_store.list_memories( + user_id=user_id, + limit=limit, + scope_type="track", + scope_id=scope.scope_id, + status="pending", + include_deleted=False, + include_pending=True, + ) + + def clear_track_memory( + self, + *, + user_id: str, + track_id: int, + actor_id: str = "user", + reason: str = "clear_track_memory", + verification_limit: int = 100, + ) -> TrackMemoryClearResult: + scope = self.require_track_scope(user_id=user_id, track_id=track_id) + deleted_count = self._memory_store.soft_delete_by_scope( + user_id=user_id, + scope_type="track", + scope_id=scope.scope_id, + actor_id=actor_id, + reason=reason, + ) + if deleted_count <= 0: + return TrackMemoryClearResult( + track_id=scope.track_id, + deleted_count=0, + retrieved_after_delete_count=0, + ) + remaining_items = self._memory_store.list_memories( + user_id=user_id, + scope_type="track", + scope_id=scope.scope_id, + include_deleted=False, + include_pending=True, + limit=verification_limit, + ) + search_results = self._memory_store.search_memories( + user_id=user_id, + query="*", + scope_type="track", + scope_id=scope.scope_id, + limit=verification_limit, + ) + return TrackMemoryClearResult( + track_id=scope.track_id, + deleted_count=deleted_count, + retrieved_after_delete_count=len(remaining_items) + len(search_results), + ) + + def bulk_moderate( + self, + *, + user_id: str, + item_ids: List[int], + status: str, + actor_id: str = "user", + ) -> TrackMemoryBulkResult: + items_before = self._memory_store.get_items_by_ids(user_id=user_id, item_ids=item_ids) + updated_items = self._memory_store.bulk_update_items( + user_id=user_id, + item_ids=item_ids, + status=status, + actor_id=actor_id, + ) + return TrackMemoryBulkResult( + items_before=items_before, + updated_items=updated_items, + affected_track_ids=self._extract_track_ids(updated_items), + ) + + def bulk_move( + self, + *, + user_id: str, + item_ids: List[int], + scope_type: str, + scope_id: Optional[str], + actor_id: str = "user", + ) -> TrackMemoryBulkResult: + normalized_scope_type = (scope_type or "global").strip() or "global" + resolved_scope_id = self.resolve_scope_id( + user_id=user_id, + scope_type=normalized_scope_type, + scope_id=scope_id, + ) + if normalized_scope_type == "track" and not resolved_scope_id: + raise TrackMemoryValidationError( + "track scope requires an existing track or an active track" + ) + updated_items = self._memory_store.bulk_update_items( + user_id=user_id, + item_ids=item_ids, + scope_type=normalized_scope_type, + scope_id=resolved_scope_id, + actor_id=actor_id, + ) + return TrackMemoryBulkResult( + items_before=[], + updated_items=updated_items, + affected_track_ids=self._extract_track_ids(updated_items), + ) + + def require_track_scope( + self, + *, + user_id: str, + track_id: Optional[int] = None, + ) -> TrackMemoryScope: + if track_id is None: + track = self._track_reader.get_active_track(user_id=user_id) + if not track: + raise TrackMemoryScopeError("No active track for user") + else: + track = self._track_reader.get_track(user_id=user_id, track_id=int(track_id)) + if not track: + raise TrackMemoryScopeError("Track not found") + resolved_track_id = int(track.get("id") or 0) + if resolved_track_id <= 0: + raise TrackMemoryScopeError("Track not found") + return TrackMemoryScope(track_id=resolved_track_id, scope_id=str(resolved_track_id)) + + @staticmethod + def _extract_track_ids(items: List[Dict[str, Any]]) -> List[int]: + track_ids = { + int(item.get("scope_id") or 0) + for item in items + if item.get("scope_type") == "track" and item.get("scope_id") + } + return sorted(track_id for track_id in track_ids if track_id > 0) diff --git a/tests/integration/test_research_track_context_routes.py b/tests/integration/test_research_track_context_routes.py new file mode 100644 index 00000000..7d059c43 --- /dev/null +++ b/tests/integration/test_research_track_context_routes.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from paperbot.api import main as api_main +from paperbot.api.routes import research as research_route +from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore +from paperbot.infrastructure.stores.paper_store import SqlAlchemyPaperStore +from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore +from paperbot.memory.schema import MemoryCandidate + + +def _prepare_context_route_db(tmp_path: Path): + db_path = tmp_path / "track-context.db" + db_url = f"sqlite:///{db_path}" + + paper_store = SqlAlchemyPaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url, auto_create_schema=True) + memory_store = SqlAlchemyMemoryStore(db_url=db_url) + + track = research_store.create_track( + user_id="u-context", + name="Agentic Retrieval", + description="Focus on retrieval pipelines.", + keywords=["rag", "retrieval"], + activate=True, + ) + track_id = int(track["id"]) + + research_store.add_task( + user_id="u-context", + track_id=track_id, + title="Validate reranker", + status="todo", + priority=5, + ) + research_store.add_milestone( + user_id="u-context", + track_id=track_id, + name="Freeze eval set", + status="doing", + notes="Keep it small and stable.", + ) + + paper = paper_store.upsert_paper( + paper={ + "title": "Context-Routed Retrieval", + "abstract": "Track context for agentic retrieval.", + "url": "https://example.com/context-routed-retrieval", + } + ) + research_store.add_paper_feedback( + user_id="u-context", + track_id=track_id, + paper_id=str(paper["id"]), + action="save", + metadata={"title": "Context-Routed Retrieval"}, + ) + research_store.add_paper_feedback( + user_id="u-context", + track_id=track_id, + paper_id=str(paper["id"]), + action="like", + metadata={"title": "Context-Routed Retrieval"}, + ) + + memory_store.add_memories( + user_id="u-context", + memories=[ + MemoryCandidate( + kind="fact", + content="RAG baselines should keep retrieval latency below 200ms.", + confidence=0.9, + tags=["retrieval", "latency"], + scope_type="track", + scope_id=str(track_id), + status="approved", + ), + MemoryCandidate( + kind="note", + content="Compare reranking against OpenAlex-only recall.", + confidence=0.5, + tags=["retrieval", "openalex"], + scope_type="track", + scope_id=str(track_id), + status="pending", + ), + ], + ) + + return research_store, memory_store, track_id + + +def test_track_context_route_returns_consolidated_snapshot(tmp_path, monkeypatch): + research_store, memory_store, track_id = _prepare_context_route_db(tmp_path) + monkeypatch.setattr(research_route, "_research_store", research_store) + monkeypatch.setattr(research_route, "_memory_store", memory_store) + + with TestClient(api_main.app) as client: + response = client.get( + f"/api/research/tracks/{track_id}/context", + params={"user_id": "u-context"}, + ) + + assert response.status_code == 200 + payload = response.json() + + assert payload["track_id"] == track_id + assert payload["track"]["name"] == "Agentic Retrieval" + assert [task["title"] for task in payload["tasks"]] == ["Validate reranker"] + assert [milestone["name"] for milestone in payload["milestones"]] == ["Freeze eval set"] + assert payload["memory"]["approved_items"] == 1 + assert payload["memory"]["pending_items"] == 1 + assert payload["memory"]["total_items"] == 2 + assert "retrieval" in payload["memory"]["top_tags"] + assert payload["feedback"]["actions"]["save"] == 1 + assert payload["feedback"]["actions"]["like"] == 1 + assert payload["saved_papers"]["total_items"] == 1 + assert payload["saved_papers"]["recent_items"][0]["paper"]["title"] == "Context-Routed Retrieval" + assert "feedback_coverage" in payload["eval_summary"] + + +def test_track_context_route_returns_404_for_missing_or_inaccessible_track(tmp_path, monkeypatch): + research_store, memory_store, track_id = _prepare_context_route_db(tmp_path) + monkeypatch.setattr(research_route, "_research_store", research_store) + monkeypatch.setattr(research_route, "_memory_store", memory_store) + + with TestClient(api_main.app) as client: + missing = client.get( + "/api/research/tracks/999999/context", + params={"user_id": "u-context"}, + ) + wrong_user = client.get( + f"/api/research/tracks/{track_id}/context", + params={"user_id": "other-user"}, + ) + + assert missing.status_code == 404 + assert wrong_user.status_code == 404 diff --git a/tests/integration/test_research_track_memory_routes.py b/tests/integration/test_research_track_memory_routes.py new file mode 100644 index 00000000..75dd9b95 --- /dev/null +++ b/tests/integration/test_research_track_memory_routes.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from paperbot.api.main import app +from paperbot.api.routes import research as research_route +from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore +from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore +from paperbot.memory.schema import MemoryCandidate + + +def _prepare_memory_routes(tmp_path): + db_url = f"sqlite:///{tmp_path / 'track-memory.db'}" + research_store = SqlAlchemyResearchStore(db_url=db_url, auto_create_schema=True) + memory_store = SqlAlchemyMemoryStore(db_url=db_url) + + active_track = research_store.create_track(user_id="u-memory", name="Active Track", activate=True) + other_track = research_store.create_track(user_id="u-memory", name="Other Track", activate=False) + + memory_store.add_memories( + user_id="u-memory", + memories=[ + MemoryCandidate( + kind="note", + content="Pending memory for active track", + confidence=0.4, + tags=["active"], + scope_type="track", + scope_id=str(active_track["id"]), + status="pending", + ), + MemoryCandidate( + kind="note", + content="Pending memory for other track", + confidence=0.4, + tags=["other"], + scope_type="track", + scope_id=str(other_track["id"]), + status="pending", + ), + ], + ) + + return research_store, memory_store, int(active_track["id"]) + + +def test_track_memory_inbox_uses_active_track_scope(tmp_path, monkeypatch): + research_store, memory_store, active_track_id = _prepare_memory_routes(tmp_path) + monkeypatch.setattr(research_route, "_research_store", research_store) + monkeypatch.setattr(research_route, "_memory_store", memory_store) + + with TestClient(app) as client: + response = client.get("/api/research/memory/inbox", params={"user_id": "u-memory"}) + + assert response.status_code == 200 + payload = response.json() + assert len(payload["items"]) == 1 + assert payload["items"][0]["scope_id"] == str(active_track_id) + assert payload["items"][0]["content"] == "Pending memory for active track" + + +def test_track_memory_inbox_returns_404_without_active_track(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path / 'track-memory-empty.db'}" + research_store = SqlAlchemyResearchStore(db_url=db_url, auto_create_schema=True) + memory_store = SqlAlchemyMemoryStore(db_url=db_url) + monkeypatch.setattr(research_route, "_research_store", research_store) + monkeypatch.setattr(research_route, "_memory_store", memory_store) + + with TestClient(app) as client: + response = client.get("/api/research/memory/inbox", params={"user_id": "missing-user"}) + + assert response.status_code == 404 + assert response.json()["detail"] == "No active track for user" + + +def test_clear_track_memory_returns_404_for_missing_or_inaccessible_track(tmp_path, monkeypatch): + research_store, memory_store, active_track_id = _prepare_memory_routes(tmp_path) + monkeypatch.setattr(research_route, "_research_store", research_store) + monkeypatch.setattr(research_route, "_memory_store", memory_store) + + with TestClient(app) as client: + missing = client.post( + "/api/research/tracks/99999/memory/clear", + params={"user_id": "u-memory", "confirm": True}, + ) + wrong_user = client.post( + f"/api/research/tracks/{active_track_id}/memory/clear", + params={"user_id": "other-user", "confirm": True}, + ) + + assert missing.status_code == 404 + assert wrong_user.status_code == 404 diff --git a/tests/integration/test_scope_and_acceptance_criteria_hooks.py b/tests/integration/test_scope_and_acceptance_criteria_hooks.py index aba6f09d..5f6ffadc 100644 --- a/tests/integration/test_scope_and_acceptance_criteria_hooks.py +++ b/tests/integration/test_scope_and_acceptance_criteria_hooks.py @@ -19,6 +19,18 @@ def test_client(tmp_path, monkeypatch): monkeypatch.setenv("PAPERBOT_DB_URL", f"sqlite:///{tmp_path / 'test.db'}") from paperbot.api import main as api_main + from paperbot.api.routes import memory as memory_module + from paperbot.api.routes import research as research_module + from paperbot.memory.eval.collector import MemoryMetricCollector + + memory_module._metric_collector = MemoryMetricCollector() + monkeypatch.setattr(research_module, "_metric_collector", None) + monkeypatch.setattr(research_module, "_research_store", None) + monkeypatch.setattr(research_module, "_memory_store", None) + monkeypatch.setattr(research_module, "_track_router", None) + monkeypatch.setattr(research_module, "_workflow_metric_store", None) + monkeypatch.setattr(research_module, "_paper_store", None) + monkeypatch.setattr(research_module, "_paper_search_service", None) with TestClient(api_main.app) as client: yield client diff --git a/tests/unit/test_research_context_route_explicit_track.py b/tests/unit/test_research_context_route_explicit_track.py new file mode 100644 index 00000000..7869a2f6 --- /dev/null +++ b/tests/unit/test_research_context_route_explicit_track.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from paperbot.api import main as api_main +from paperbot.api.routes import research as research_route + + +class _FakeWorkflowMetricStore: + def __init__(self) -> None: + self.track_ids: list[int | None] = [] + + def record_metric(self, *, track_id=None, **kwargs) -> None: + self.track_ids.append(track_id) + + +def test_context_route_uses_explicit_track_id_without_activation(monkeypatch): + captured: dict[str, object] = {} + metric_store = _FakeWorkflowMetricStore() + + class _FakeContextEngine: + def __init__(self, **kwargs) -> None: + captured["init_kwargs"] = kwargs + + async def build_context_pack( + self, + *, + user_id: str, + query: str, + track_id: int | None = None, + include_cross_track: bool = False, + ): + captured["user_id"] = user_id + captured["query"] = query + captured["track_id"] = track_id + captured["include_cross_track"] = include_cross_track + return { + "routing": {"track_id": track_id}, + "paper_recommendations": [], + "paper_recommendation_reasons": {}, + } + + async def close(self) -> None: + return None + + monkeypatch.setattr(research_route, "_workflow_metric_store", metric_store) + monkeypatch.setattr(research_route, "ContextEngine", _FakeContextEngine) + + with TestClient(api_main.app) as client: + response = client.post( + "/api/research/context", + json={ + "user_id": "u-explicit", + "query": "agentic retrieval", + "track_id": 42, + "paper_limit": 0, + "offline": True, + "include_cross_track": False, + }, + ) + + assert response.status_code == 200 + assert captured["track_id"] == 42 + assert captured["user_id"] == "u-explicit" + assert response.json()["context_pack"]["routing"]["track_id"] == 42 + assert metric_store.track_ids[-1] == 42 diff --git a/tests/unit/test_research_track_context_service.py b/tests/unit/test_research_track_context_service.py new file mode 100644 index 00000000..05fe0773 --- /dev/null +++ b/tests/unit/test_research_track_context_service.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from paperbot.application.services.research_track_context_service import ( + ResearchTrackContextService, + TrackContextQuery, +) + + +class _FakeTrackReader: + def __init__(self) -> None: + self._track = { + "id": 7, + "user_id": "u1", + "name": "Agentic Retrieval", + "description": "Focus on retrieval pipelines.", + "keywords": ["rag", "retrieval"], + "is_active": True, + } + self.last_eval_days: Optional[int] = None + + def get_track(self, *, user_id: str, track_id: int) -> Optional[Dict[str, Any]]: + if user_id == "u1" and track_id == 7: + return dict(self._track) + return None + + def get_active_track(self, *, user_id: str) -> Optional[Dict[str, Any]]: + if user_id == "u1": + return dict(self._track) + return None + + def list_tasks( + self, + *, + user_id: str, + track_id: int, + status: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + return [ + {"id": 1, "track_id": track_id, "title": "Evaluate reranker", "status": "todo"}, + {"id": 2, "track_id": track_id, "title": "Compare OpenAlex", "status": "doing"}, + ][:limit] + + def list_milestones( + self, + *, + user_id: str, + track_id: int, + status: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + return [ + { + "id": 11, + "track_id": track_id, + "name": "Collect benchmark set", + "status": "todo", + "updated_at": "2026-03-10T09:00:00+00:00", + } + ][:limit] + + def list_effective_paper_feedback( + self, + *, + user_id: str, + track_id: int, + limit: int = 200, + ) -> List[Dict[str, Any]]: + return [ + {"id": 91, "track_id": track_id, "paper_id": "p1", "action": "save", "ts": "2026-03-11T08:30:00+00:00"}, + {"id": 92, "track_id": track_id, "paper_id": "p2", "action": "like", "ts": "2026-03-09T12:00:00+00:00"}, + {"id": 93, "track_id": track_id, "paper_id": "p3", "action": "save", "ts": "2026-03-08T12:00:00+00:00"}, + ][:limit] + + def list_saved_papers( + self, + *, + user_id: str, + track_id: Optional[int] = None, + collection_id: Optional[int] = None, + limit: int = 200, + sort_by: str = "saved_at", + ) -> List[Dict[str, Any]]: + return [ + {"paper": {"id": 101, "title": "RAG from First Principles"}, "saved_at": "2026-03-11T08:30:00+00:00"}, + {"paper": {"id": 102, "title": "Context Routers"}, "saved_at": "2026-03-07T08:30:00+00:00"}, + ][:limit] + + def summarize_eval( + self, + *, + user_id: str, + track_id: Optional[int] = None, + days: int = 30, + limit: int = 2000, + ) -> Dict[str, Any]: + self.last_eval_days = days + return {"total_runs": 4, "feedback_coverage": 0.75} + + +class _FakeMemoryStore: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def list_memories( + self, + *, + user_id: str, + limit: int = 100, + kind: Optional[str] = None, + workspace_id: Optional[str] = None, + scope_type: Optional[str] = None, + scope_id: Optional[str] = None, + include_pending: bool = False, + include_deleted: bool = False, + status: Optional[str] = None, + ) -> List[Dict[str, Any]]: + self.calls.append( + { + "user_id": user_id, + "limit": limit, + "scope_type": scope_type, + "scope_id": scope_id, + "status": status, + "include_pending": include_pending, + "include_deleted": include_deleted, + } + ) + if status == "approved": + return [ + { + "id": 1, + "scope_type": "track", + "scope_id": scope_id, + "status": "approved", + "tags": ["retrieval", "benchmark"], + "updated_at": "2026-03-10T12:00:00+00:00", + }, + { + "id": 2, + "scope_type": "track", + "scope_id": scope_id, + "status": "approved", + "tags": ["retrieval"], + "updated_at": "2026-03-11T15:00:00+00:00", + }, + ] + if status == "pending": + return [ + { + "id": 3, + "scope_type": "track", + "scope_id": scope_id, + "status": "pending", + "tags": ["followup"], + "created_at": "2026-03-11T09:00:00+00:00", + } + ] + return [] + + +def test_get_track_context_aggregates_track_scoped_reads() -> None: + track_reader = _FakeTrackReader() + memory_store = _FakeMemoryStore() + service = ResearchTrackContextService(track_reader=track_reader, memory_store=memory_store) + + snapshot = service.get_track_context( + user_id="u1", + query=TrackContextQuery( + task_limit=2, + milestone_limit=1, + feedback_limit=2, + saved_preview_limit=1, + eval_days=14, + ), + ) + + assert snapshot is not None + payload = snapshot.to_dict() + + assert payload["track"]["id"] == 7 + assert [task["title"] for task in payload["tasks"]] == [ + "Evaluate reranker", + "Compare OpenAlex", + ] + assert [item["action"] for item in payload["feedback"]["recent_items"]] == ["save", "like"] + assert payload["feedback"]["actions"] == {"save": 2, "like": 1} + assert payload["memory"] == { + "total_items": 3, + "approved_items": 2, + "pending_items": 1, + "top_tags": ["retrieval", "benchmark", "followup"], + "latest_memory_at": "2026-03-11T15:00:00+00:00", + } + assert payload["saved_papers"]["total_items"] == 2 + assert len(payload["saved_papers"]["recent_items"]) == 1 + assert payload["eval_summary"]["feedback_coverage"] == 0.75 + assert track_reader.last_eval_days == 14 + assert memory_store.calls == [ + { + "user_id": "u1", + "limit": 500, + "scope_type": "track", + "scope_id": "7", + "status": "approved", + "include_pending": True, + "include_deleted": False, + }, + { + "user_id": "u1", + "limit": 500, + "scope_type": "track", + "scope_id": "7", + "status": "pending", + "include_pending": True, + "include_deleted": False, + }, + ] + + +def test_get_track_context_returns_none_when_track_is_missing() -> None: + service = ResearchTrackContextService( + track_reader=_FakeTrackReader(), + memory_store=_FakeMemoryStore(), + ) + + snapshot = service.get_track_context(user_id="missing-user", track_id=999) + + assert snapshot is None diff --git a/tests/unit/test_track_memory_service.py b/tests/unit/test_track_memory_service.py new file mode 100644 index 00000000..413612fd --- /dev/null +++ b/tests/unit/test_track_memory_service.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import pytest + +from paperbot.application.services.track_memory_service import ( + TrackMemoryScopeError, + TrackMemoryService, + TrackMemoryValidationError, +) + + +class _FakeTrackReader: + def __init__(self) -> None: + self.track = {"id": 7, "name": "Track 7", "is_active": True} + + def get_track(self, *, user_id: str, track_id: int) -> Optional[Dict[str, Any]]: + if user_id == "u1" and track_id == 7: + return dict(self.track) + return None + + def get_active_track(self, *, user_id: str) -> Optional[Dict[str, Any]]: + if user_id == "u1": + return dict(self.track) + return None + + +class _FakeMemoryStore: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def list_memories(self, **kwargs) -> List[Dict[str, Any]]: + self.calls.append({"method": "list_memories", **kwargs}) + return [{"id": 1, "scope_type": "track", "scope_id": "7", "status": "pending"}] + + def get_items_by_ids(self, **kwargs) -> List[Dict[str, Any]]: + self.calls.append({"method": "get_items_by_ids", **kwargs}) + return [{"id": 1, "scope_type": "track", "scope_id": "7", "confidence": 0.9}] + + def search_memories(self, **kwargs) -> List[Dict[str, Any]]: + self.calls.append({"method": "search_memories", **kwargs}) + return [] + + def soft_delete_by_scope(self, **kwargs) -> int: + self.calls.append({"method": "soft_delete_by_scope", **kwargs}) + return 1 + + def bulk_update_items(self, **kwargs) -> List[Dict[str, Any]]: + self.calls.append({"method": "bulk_update_items", **kwargs}) + return [{"id": 2, "scope_type": "track", "scope_id": "7", "status": kwargs.get("status")}] + + +def test_list_inbox_uses_active_track_scope() -> None: + memory_store = _FakeMemoryStore() + service = TrackMemoryService( + track_reader=_FakeTrackReader(), + memory_store=memory_store, + ) + + items = service.list_inbox(user_id="u1") + + assert items == [{"id": 1, "scope_type": "track", "scope_id": "7", "status": "pending"}] + assert memory_store.calls[-1] == { + "method": "list_memories", + "user_id": "u1", + "limit": 100, + "scope_type": "track", + "scope_id": "7", + "status": "pending", + "include_deleted": False, + "include_pending": True, + } + + +def test_clear_track_memory_reports_post_delete_retrieval_count() -> None: + memory_store = _FakeMemoryStore() + service = TrackMemoryService( + track_reader=_FakeTrackReader(), + memory_store=memory_store, + ) + + result = service.clear_track_memory(user_id="u1", track_id=7) + + assert result.track_id == 7 + assert result.deleted_count == 1 + assert result.retrieved_after_delete_count == 1 + + +def test_bulk_move_requires_resolvable_track_scope() -> None: + service = TrackMemoryService( + track_reader=_FakeTrackReader(), + memory_store=_FakeMemoryStore(), + ) + + with pytest.raises(TrackMemoryValidationError): + service.bulk_move( + user_id="missing", + item_ids=[1, 2], + scope_type="track", + scope_id=None, + ) + + +def test_require_track_scope_rejects_missing_track() -> None: + service = TrackMemoryService( + track_reader=_FakeTrackReader(), + memory_store=_FakeMemoryStore(), + ) + + with pytest.raises(TrackMemoryScopeError): + service.require_track_scope(user_id="u1", track_id=99) diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index bd44510f..fdd3567e 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -39,8 +39,10 @@ import { MemoryTab } from "./MemoryTab" import { CreateTrackModal } from "./CreateTrackModal" import { EditTrackModal } from "./EditTrackModal" import { ManageTracksModal } from "./ManageTracksModal" +import { ResearchTrackContextPanel } from "./ResearchTrackContextPanel" import type { Track } from "./TrackSelector" import type { Paper } from "./PaperCard" +import type { ResearchTrackContextResponse } from "@/lib/types" type ContextPack = { context_run_id?: number | null @@ -69,6 +71,8 @@ export default function ResearchPageNew() { // Track state const [tracks, setTracks] = useState([]) const [activeTrackId, setActiveTrackId] = useState(null) + const [trackContext, setTrackContext] = useState(null) + const [trackContextLoading, setTrackContextLoading] = useState(false) // All available sources const ALL_SOURCES = ["semantic_scholar", "arxiv", "openalex", "papers_cool", "hf_daily"] @@ -102,8 +106,13 @@ export default function ResearchPageNew() { // Derived state const activeTrack = useMemo( - () => tracks.find((t) => t.id === activeTrackId) || null, - [tracks, activeTrackId] + () => { + if (trackContext?.track && trackContext.track.id === activeTrackId) { + return trackContext.track as Track + } + return tracks.find((t) => t.id === activeTrackId) || null + }, + [trackContext, tracks, activeTrackId] ) const papers = contextPack?.paper_recommendations || [] @@ -131,6 +140,51 @@ export default function ResearchPageNew() { setQuery(routeQuery) }, [routeQuery, query, hasSearched]) + async function refreshTrackContext(trackId: number): Promise { + const data = await fetchJson( + `/api/research/tracks/${trackId}/context?user_id=${encodeURIComponent(userId)}` + ) + setTrackContext(data) + } + + useEffect(() => { + let cancelled = false + + async function loadTrackContext(trackId: number) { + setTrackContextLoading(true) + try { + const data = await fetchJson( + `/api/research/tracks/${trackId}/context?user_id=${encodeURIComponent(userId)}` + ) + if (!cancelled) { + setTrackContext(data) + } + } catch (e) { + if (!cancelled) { + setTrackContext(null) + setError(getErrorMessage(e)) + } + } finally { + if (!cancelled) { + setTrackContextLoading(false) + } + } + } + + if (!activeTrackId) { + setTrackContext(null) + setTrackContextLoading(false) + return () => { + cancelled = true + } + } + + loadTrackContext(activeTrackId).catch(() => {}) + return () => { + cancelled = true + } + }, [activeTrackId, userId]) + async function refreshTracks(): Promise { const data = await fetchJson<{ tracks: Track[] }>( `/api/research/tracks?user_id=${encodeURIComponent(userId)}` @@ -183,6 +237,7 @@ export default function ResearchPageNew() { const body = { user_id: userId, query, + track_id: activeTrackId ?? undefined, paper_limit: 10, memory_limit: 8, sources: searchSources, @@ -320,6 +375,9 @@ export default function ResearchPageNew() { headers: { "Content-Type": "application/json" }, }) await refreshTracks() + if (trackId === activeTrackId) { + await refreshTrackContext(trackId) + } return true } catch (e) { const message = getErrorMessage(e) @@ -353,6 +411,9 @@ export default function ResearchPageNew() { headers: { "Content-Type": "application/json" }, } ) + if (trackToClear === activeTrackId) { + await refreshTrackContext(trackToClear) + } setConfirmClearOpen(false) setTrackToClear(null) } catch (e) { @@ -567,6 +628,15 @@ export default function ResearchPageNew() { /> + {trackContext ? ( +
+ setMemoryOpen(true)} + /> +
+ ) : null} + {/* Track Pills - only show before search */} {!hasSearched && tracks.length > 0 && (
@@ -602,6 +672,13 @@ export default function ResearchPageNew() { Track: {activeTrack?.name || "Global"} + {trackContextLoading ? ( + Track snapshot: loading + ) : trackContext ? ( + + Pending memory: {trackContext.memory.pending_items} + + ) : null} Mode: {anchorPersonalized ? "Personalized" : "Global"} diff --git a/web/src/components/research/ResearchTrackContextPanel.test.tsx b/web/src/components/research/ResearchTrackContextPanel.test.tsx new file mode 100644 index 00000000..c1a6dac1 --- /dev/null +++ b/web/src/components/research/ResearchTrackContextPanel.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import { renderToStaticMarkup } from "react-dom/server" + +import type { ResearchTrackContextResponse } from "@/lib/types" + +import { ResearchTrackContextPanel, buildStatItems } from "./ResearchTrackContextPanel" + +const TRACK_CONTEXT_FIXTURE: ResearchTrackContextResponse = { + user_id: "default", + track_id: 7, + track: { + id: 7, + name: "Agentic Retrieval", + description: "Keep retrieval quality and latency balanced.", + keywords: ["rag", "retrieval", "reranking"], + is_active: true, + }, + tasks: [ + { id: 1, track_id: 7, title: "Validate reranker", status: "todo" }, + { id: 2, track_id: 7, title: "Compare OpenAlex recall", status: "doing" }, + ], + milestones: [ + { id: 9, track_id: 7, name: "Freeze benchmark slice", status: "doing" }, + ], + memory: { + total_items: 6, + approved_items: 4, + pending_items: 2, + top_tags: ["retrieval", "latency"], + latest_memory_at: "2026-03-12T08:00:00+00:00", + }, + feedback: { + total_items: 5, + actions: { save: 3, like: 2 }, + latest_feedback_at: "2026-03-12T08:30:00+00:00", + recent_items: [], + }, + saved_papers: { + total_items: 3, + latest_saved_at: "2026-03-12T08:40:00+00:00", + recent_items: [], + }, + eval_summary: { + feedback_coverage: 0.75, + }, +} + +describe("ResearchTrackContextPanel", () => { + it("builds compact stat cards from the track snapshot", () => { + const stats = buildStatItems(TRACK_CONTEXT_FIXTURE) + + expect(stats).toHaveLength(3) + expect(stats.map((item) => ({ label: item.label, value: item.value }))).toEqual([ + { label: "Pending Memory", value: "2" }, + { label: "Saved Papers", value: "3" }, + { label: "Feedback", value: "5" }, + ]) + }) + + it("renders the consolidated track snapshot details", () => { + const html = renderToStaticMarkup( + undefined} + /> + ) + + expect(html).toContain("Track Snapshot") + expect(html).toContain("Agentic Retrieval") + expect(html).toContain("Pending Memory") + expect(html).toContain("Validate reranker") + expect(html).toContain("Freeze benchmark slice") + expect(html).toContain("save: 3") + expect(html).toContain("75%") + }) +}) diff --git a/web/src/components/research/ResearchTrackContextPanel.tsx b/web/src/components/research/ResearchTrackContextPanel.tsx new file mode 100644 index 00000000..51841a61 --- /dev/null +++ b/web/src/components/research/ResearchTrackContextPanel.tsx @@ -0,0 +1,213 @@ +"use client" + +import { BookMarked, BrainCircuit, CheckSquare2, Clock3, Database, Tags } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" +import type { + ResearchTrackContextResponse, + ResearchTrackSummary, +} from "@/lib/types" + +type StatItem = { + label: string + value: string + icon: typeof Database +} + +export function buildStatItems(context: ResearchTrackContextResponse): StatItem[] { + return [ + { + label: "Pending Memory", + value: String(context.memory.pending_items), + icon: Clock3, + }, + { + label: "Saved Papers", + value: String(context.saved_papers.total_items), + icon: BookMarked, + }, + { + label: "Feedback", + value: String(context.feedback.total_items), + icon: BrainCircuit, + }, + ] +} + +function getTrackDescription(track: ResearchTrackSummary): string { + const description = String(track.description || "").trim() + if (description) { + return description + } + const keywords = track.keywords || [] + if (keywords.length > 0) { + return `Focused on ${keywords.slice(0, 4).join(", ")}.` + } + return "Use the track snapshot to keep context, memory, and saved papers aligned." +} + +interface ResearchTrackContextPanelProps { + context: ResearchTrackContextResponse + onOpenMemory: () => void +} + +export function ResearchTrackContextPanel({ + context, + onOpenMemory, +}: ResearchTrackContextPanelProps) { + const statItems = buildStatItems(context) + const track = context.track + const keywords = track.keywords || [] + const memoryTags = context.memory.top_tags || [] + + return ( + + +
+
+
+ Track Snapshot + {track.is_active ? Active Track : null} +
+
+ {track.name} +

{getTrackDescription(track)}

+
+
+ +
+
+ +
+ {statItems.map((item) => { + const Icon = item.icon + return ( +
+
+ + {item.label} +
+
{item.value}
+
+ ) + })} +
+ + {keywords.length > 0 ? ( +
+ + + Track Keywords + + {keywords.slice(0, 6).map((keyword) => ( + + {keyword} + + ))} +
+ ) : null} + + + +
+
+
+ +

Recent Track Work

+
+
+
+

Tasks

+
+ {context.tasks.length > 0 ? ( + context.tasks.slice(0, 3).map((task) => ( +
+
{task.title}
+
+ Status {task.status || "todo"} +
+
+ )) + ) : ( +

No tasks yet for this track.

+ )} +
+
+ +
+

Milestones

+
+ {context.milestones.length > 0 ? ( + context.milestones.slice(0, 3).map((milestone) => ( +
+
{milestone.name}
+
+ Status {milestone.status || "todo"} +
+
+ )) + ) : ( +

No milestones yet for this track.

+ )} +
+
+
+
+ +
+

Memory & Feedback Shape

+
+
+
Approved Memory
+
{context.memory.approved_items}
+
+
+
Feedback Coverage
+
+ {Math.round(Number(context.eval_summary.feedback_coverage || 0) * 100)}% +
+
+
+
+
Top Memory Tags
+
+ {memoryTags.length > 0 ? ( + memoryTags.map((tag) => ( + + {tag} + + )) + ) : ( + No track-scoped memory tags yet. + )} +
+
+
+
Effective Feedback Mix
+
+ {Object.entries(context.feedback.actions).length > 0 ? ( + Object.entries(context.feedback.actions).map(([action, count]) => ( + + {action}: {count} + + )) + ) : ( + No effective feedback recorded yet. + )} +
+
+
+
+
+
+ ) +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b31b1208..76b7612b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -281,3 +281,83 @@ export interface AnchorPreviewItem { anchor_level?: string user_action?: "follow" | "ignore" | null } + +export interface ResearchTrackContextTask { + id: number + track_id: number + title: string + status?: string + priority?: number + paper_id?: string | null + paper_url?: string | null + metadata?: Record + created_at?: string | null + updated_at?: string | null + done_at?: string | null +} + +export interface ResearchTrackContextMilestone { + id: number + track_id: number + name: string + status?: string + notes?: string + due_at?: string | null + created_at?: string | null + updated_at?: string | null +} + +export interface ResearchTrackContextMemorySummary { + total_items: number + approved_items: number + pending_items: number + top_tags: string[] + latest_memory_at?: string | null +} + +export interface ResearchTrackContextFeedbackItem { + id: number + track_id: number + paper_id: string + action: string + ts?: string | null + metadata?: Record +} + +export interface ResearchTrackContextFeedbackSummary { + total_items: number + actions: Record + latest_feedback_at?: string | null + recent_items: ResearchTrackContextFeedbackItem[] +} + +export interface ResearchTrackSavedPaperPreview { + paper?: TrackFeedPaper & { + authors?: string[] + abstract?: string | null + url?: string | null + } + saved_at?: string | null + latest_judge?: { + overall?: number | null + recommendation?: string | null + } | null +} + +export interface ResearchTrackContextSavedPapersSummary { + total_items: number + latest_saved_at?: string | null + recent_items: ResearchTrackSavedPaperPreview[] +} + +export interface ResearchTrackContextResponse { + user_id: string + track_id: number + track: ResearchTrackSummary + tasks: ResearchTrackContextTask[] + milestones: ResearchTrackContextMilestone[] + memory: ResearchTrackContextMemorySummary + feedback: ResearchTrackContextFeedbackSummary + saved_papers: ResearchTrackContextSavedPapersSummary + eval_summary: Record +}