Tasks: Article Recommendation Signals & Weekly Summary Report
Feature: 014-article-recommendation-weekly-reportSpec: spec.md | Plan: plan.md | Data Model: data-model.md | API: contracts/api.md
Format: [ID] [P?] [Story] Description
- [P]: Can run in parallel (different files, no dependencies)
- [Story]: Which user story this task belongs to (US1–US5)
- Include exact file paths in descriptions
Phase 1: Setup (Shared Infrastructure)
Purpose: Add new dependencies and environment configuration needed across all user stories
- [X] T001 Add
boto3>=1.34andresend>=2.0topyproject.tomlcore dependencies group (google-genaialready present) - [X] T002 Add
R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET_NAME,R2_PUBLIC_URL,RESEND_API_KEY,RESEND_FROM_EMAIL,VIEW_COUNT_FLUSH_INTERVALto.env.examplewith comments - [X] T003 Add
jmespath>=1.0topyproject.tomlscraperdependency group (research.md §9c)
Phase 2: Foundational (Blocking Prerequisites — DB Migrations & ORM Models)
Purpose: Create database tables and ORM models that all user stories depend on
⚠️ CRITICAL: No user story work can begin until this phase is complete
- [X] T004 REWORK (2026-07-12) Edit Alembic migration
alembic/versions/23_article_recommendation_weekly_report.py(revises22_add_correlation_id_and_rag_providers, still unshipped — edit in place, do not add a follow-up revision): removecitation_countcolumn from thearticle_metricstable def + drop its old index; addmetric_definitionstable (id, metric_key, provider_name, priority, extractor_type, extractor_spec JSONB, enabled, label_i18n_key, format_hint, unit, timestamps; UNIQUE(metric_key, provider_name); partial index on metric_key WHERE enabled) with seed-dataINSERTforcitation_count/openalex(priority 1) andcitation_count/semantic_scholar(priority 2) per data-model.md; addarticle_metric_valuestable (id, article_id FK CASCADE, metric_key, value NUMERIC, last_flushed_at, timestamps; UNIQUE(article_id, metric_key); index on (metric_key, value DESC NULLS LAST); index on last_flushed_at WHERE NOT NULL); add two partial expression indexes onarticles:((metadata->>'doi'))and((metadata->>'arxiv_id')), bothWHERE ... IS NOT NULL; keepweekly_reports/user_topic_subscriptions/user_notification_settings/user_article_favorites/llm_providers.typeDDL unchanged; updatedowngrade()to match - [X] T005 [P] REWORK (2026-07-12) Edit ORM model
models/article_metrics.py: removecitation_countcolumn, keepid,article_idFK,view_countint default 0,last_flushed_at, UNIQUE on article_id - [X] T006 [P] Create ORM model
models/weekly_report.pywithWeeklyReportclass (UUID PK, topic_id FK → topics ON DELETE SET NULL, week_start_date, title, summary_text, cover_image_url, article_ids JSONB, article_count, status, error_message) - [X] T007 [P] Create ORM model
models/user_subscription.pywith three classes:UserTopicSubscription(user_id+topic_id UNIQUE),UserNotificationSettings(user_id UNIQUE, email_enabled, telegram_chat_id, telegram_enabled, locale String(10) default='en'),UserArticleFavorite(user_id+article_id UNIQUE) - [X] T008 [P] Fix
models/llm_provider.py: remove duplicatetypecolumn definition (lines 16–17 currently have two identicaltype =assignments); addCheckConstraint("type IN ('llm', 'embedding', 'multimodal')", name='ck_llm_provider_type')to__table_args__ - [X] T009 [P] Create ORM model
models/metric_definition.pywithMetricDefinitionclass per data-model.mdmetric_definitionstable (id, metric_key, provider_name, priority, extractor_type, extractor_spec JSONB, enabled, label_i18n_key, format_hint, unit, timestamps, UNIQUE(metric_key, provider_name)) - [X] T010 [P] Create ORM model
models/article_metric_value.pywithArticleMetricValueclass per data-model.mdarticle_metric_valuestable (id, article_id FK CASCADE, metric_key, value Numeric nullable, last_flushed_at, timestamps, UNIQUE(article_id, metric_key)) - [X] T011 [P] Create
shared/metric_definition.py::load_enabled_metric_definitions(session) -> List[Dict[str, Any]], mirroringshared/llm_provider.py::load_active_providers(returns metric_key/provider_name/priority/extractor_type/extractor_spec grouped forResilientMetricsServiceto consume),enabled=Truerows only, ordered by(metric_key, priority)
Checkpoint: Migration and ORM models complete — user story implementation can begin
Phase 3: User Story 1 — View Article Recommendation Signals (Priority: P1) 🎯 MVP
Goal: Show citation_count and view_count on article cards and detail dialog; track views via Redis with IP dedup
Independent Test: Visit /articles, open an article from OpenAlex or Semantic Scholar — citation count badge appears on card. Click into any article detail dialog — view count is visible and increments via Redis.
Implementation
- [X] T012 [P] [US1] REWORK (2026-07-12) Edit
src/modules/collection/domain/value_objects/scraped_article.py: replacecitation_count: Optional[int] = Nonefield withmetric_seeds: Dict[str, Any] = field(default_factory=dict) - [X] T013 [P] [US1] REWORK (2026-07-12) Edit
src/infrastructure/collection/scrapers/openalex_scraper.pyto populatemetric_seeds={"citation_count": e.citation_count}instead of setting the old dedicated field - [X] T014 [P] [US1] REWORK (2026-07-12) Edit
src/infrastructure/collection/scrapers/semantic_scholar_scraper.pyto populatemetric_seeds={"citation_count": e.citation_count}instead of setting the old dedicated field - [X] T015 [US1] REWORK (2026-07-12) Edit
src/modules/collection/domain/repositories/article_metrics_repository.py: changeupsert(self, article_id: UUID, citation_count: Optional[int]) -> Nonetoupsert(self, article_id: UUID, metrics: dict[str, Any]) -> None; editsrc/infrastructure/persistence/collection/article_metrics_repo_impl.py'sSqlAlchemyArticleMetricsRepositoryto write one row per key intoarticle_metric_valuesviaINSERT ... ON CONFLICT (article_id, metric_key) DO UPDATE SET value = EXCLUDED.value, last_flushed_at = now()instead of updating the oldcitation_countcolumn; editProcessScrapedArticleUseCase(src/modules/collection/application/use_cases/process_scraped_article.py) to forwardScrapedArticle.metric_seeds(filtered to knownmetric_definitions.metric_keyvalues) to the generalizedupsert(); depends on T012, T010, T011 - [X] T016 [P] [US1] Extend
backend/schemas/article.pyArticleOutandArticleDetailOutwithcitation_count: Optional[int] = Noneandview_count: int = 0— unaffected by 2026-07-12 rework: response field shape is unchanged, only the backend query sourcing it changes (see T017) - [X] T017 [US1] REWORK (2026-07-12) Edit
backend/services/article_service.pyget_articles_paginated: keepLEFT JOIN article_metricsforview_count; addLEFT JOIN article_metric_values ON article_metric_values.article_id = articles.id AND article_metric_values.metric_key = 'citation_count'forcitation_count(replaces the old direct column reference); depends on T016, T005, T010 - [X] T018 [US1] Add
POST /articles/{id}/viewendpoint tobackend/routers/articles.py: RedisINCR view:{article_id}with IP dedup keyviewed:{ip}:{article_id}(24h TTL viaEXPIRE); returns 204 regardless of duplicate - [X] T019 [US1] Add view count flush function to
backend/services/article_service.py(scanview:*Redis keys,GETDEL,UPDATE article_metrics SET view_count = view_count + :count); add admin endpointPOST /admin/articles/flush-view-countsinbackend/routers/articles.py; register background periodic flush on FastAPI startup usingVIEW_COUNT_FLUSH_INTERVALenv var (default 900s) — unaffected by 2026-07-12 rework: entirely separate from citation_count/metric_definitions per research.md §9b - [X] T020 [P] [US1] Extend
frontend/lib/api/articles.tswithrecordArticleView(id: string)fire-and-forget function (POST /articles/{id}/view); updateArticleOutTypeScript type withcitation_count: number | nullandview_count: number - [X] T021 [US1] Extend
frontend/components/features/articles/article-card.tsxto showcitation_countbadge (when > 0) andview_countbadge using the new fields from T020 - [X] T022 [US1] Extend
frontend/components/features/articles/article-detail-dialog.tsxto displaycitation_countandview_count, and callrecordArticleView(id)on dialog open; depends on T020
Recurring Metric Refresh (new, 2026-07-12 — extends US1: keeps citation_count fresh after the initial scrape)
- [X] T081 [P] [US1] Add
fetch_by_doi(doi: str) -> Optional[dict]tosrc/infrastructure/collection/clients/openalex_client.py, returning the raw parsed JSON dict (notOpenAlexEntry) for a single paper lookup;fetch_papers()unchanged - [X] T082 [P] [US1] Add
fetch_by_doi(doi: str) -> Optional[dict]tosrc/infrastructure/collection/clients/semantic_scholar_client.py, same contract as T081 - [X] T083 [P] [US1] Create
src/modules/collection/domain/services/metric_extractor.py—MetricExtractorABC withfetch(article_identifiers: dict[str, str]) -> Optional[dict]andextract(raw_response: dict, extractor_spec: dict) -> Optional[Any] - [X] T084 [P] [US1] Create
src/infrastructure/collection/metrics/json_path_extractor.py—JsonPathMetricExtractorimplementingMetricExtractor.extract()viajmespath.search(extractor_spec["path"], raw_response);fetch()delegates to aprovider_name-keyed dict of fetcher callables (T081, T082); depends on T081, T082, T083, T003 - [X] T085 [US1] Create
src/infrastructure/collection/metrics/resilient_metrics_service.py—ResilientMetricsService, built fromload_enabled_metric_definitions()(T011) grouped bymetric_keyordered bypriority;fetch_all(article_identifiers: dict) -> dict[str, Any]walks each metric_key's provider list in order, keeps first non-null result; depends on T011, T084 - [X] T086 [US1] Add
build_metrics_refresh_pipeline()tosrc/bootstrap.pywiringResilientMetricsService+ the generalizedArticleMetricsRepository; depends on T085, T015 - [X] T087 [US1] Create
src/entrypoints/cli/refresh_metrics.py: query articles (via thearticles.metadatadoi/arxiv_id expression indexes from T004) with a missing or stale (last_flushed_at < now() - interval '1 day')article_metric_valuesrow for any enabledmetric_key; resolve{"doi": ..., "arxiv_id": ...}per article fromArticle.metadata; callResilientMetricsService.fetch_all(); upsert results viaArticleMetricsRepository.upsert(); depends on T086 - [X] T088 Add Railway Cron Service entry for
refresh_metrics.pyinsrc/railway.toml(daily,0 3 * * *), reusingsrc/Dockerfile; depends on T087
Tests
- [X] T023 [P] [US1] REWORK (2026-07-12) Update unit test for
ProcessScrapedArticleUseCaseinsrc/tests/unit/test_process_scraped_article.pyto cover the generalizedupsert(article_id, metrics: dict)call (multiple metric_seeds keys, filtered against known metric_definitions) - [X] T024 [P] [US1] Write backend integration test for
POST /articles/{id}/viewendpoint and Redis IP dedup (second call within 24h does not increment) inbackend/tests/test_article_view_count.py - [X] T089 [P] [US1] Write unit test for
JsonPathMetricExtractor.extract()against fixture OpenAlex/Semantic Scholar JSON responses insrc/tests/unit/test_json_path_metric_extractor.py - [X] T090 [P] [US1] Write unit test for
ResilientMetricsService.fetch_all()fallback ordering (first provider fails/returns null → falls back to next by priority) insrc/tests/unit/test_resilient_metrics_service.py - [X] T091 [P] [US1] Write unit test for the generalized
SqlAlchemyArticleMetricsRepository.upsert()(multiple metric_key rows, ON CONFLICT update) insrc/tests/unit/test_article_metrics_repo.py - [X] T092 [P] [US1] Write unit test for
refresh_metrics.py's staleness query (missing row vs stalelast_flushed_atvs fresh row correctly included/excluded) insrc/tests/unit/test_refresh_metrics.py
Checkpoint: Citation count and view count visible on article cards and detail dialog; POST /articles/{id}/view increments Redis counter correctly; refresh_metrics.py keeps citation_count fresh independent of scrape time
Phase 4: User Story 2 — Sort Articles by Recommendation Signals (Priority: P2)
Goal: Add sort dropdown to filter bar; extend GET /articles to support citation_count and view_count sort
Independent Test: Open articles page, select "Sort by: Citation Count" from sort dropdown — articles reorder with highest citation count first. Change topic filter — topic filter is preserved alongside sort.
Implementation
- [X] T025 [US2] REWORK (2026-07-12) Edit
backend/services/article_service.pysort logic inget_articles_paginated:sort='view_count'still orders onarticle_metrics.view_count;sort='citation_count'now orders on thearticle_metric_values.valuecolumn from the T017 JOIN (filtered tometric_key='citation_count'), using the newidx_article_metric_values_metric_key_valueindex - [X] T026 [P] [US2] Extend
frontend/components/features/articles/filter-bar.tsxwith sort<Select>dropdown (Shadcn UI) on the right side; options: Scraped At (default), Published At, Citation Count, View Count, Source, Title; immediate apply on change (no draft state) - [X] T027 [US2] Update articles page/hook to read
sortandorderstate and pass as query params toGET /articlesviaapiFetch(); depends on T026
Tests
- [X] T028 [P] [US2] RE-VERIFY (2026-07-12) Re-run backend integration test for
GET /articles?sort=citation_count&order=descinbackend/tests/test_article_sort.pyagainst the reworked T025 query (fixture data now needs anarticle_metric_valuesrow instead of a flatarticle_metrics.citation_countcolumn); assertions on response ordering are unchanged - [X] T029 [P] [US2] Write frontend unit test for FilterBar sort dropdown rendering and
onChangeinfrontend/tests/unit/filter-bar.test.tsx
Checkpoint: Sort dropdown visible in filter bar; articles correctly reorder by citation_count and view_count without breaking topic filter
Phase 5: User Story 5 — Favorite an Article (Priority: P2)
Goal: Heart icon on article cards toggles favorites; Favorites filter restricts article list; backend favorites API
Independent Test: Log in, click heart icon on article card — icon fills, user_article_favorites DB row created. Enable Favorites filter — only favorited articles shown. Click filled heart again — icon empties, DB row deleted.
Implementation
- [X] T030 [P] [US5] Extend
backend/schemas/article.pyArticleOutwithis_favorited: bool = False - [X] T031 [US5] Extend
backend/services/article_service.pytoLEFT JOIN user_article_favoritesfor authenticated users to populateis_favorited; addfavorites_only: bool = Falsequery param that restricts results to user's favorited articles; depends on T030, T007 - [X] T032 [US5] Create
backend/routers/user.pywithGET /user/favorites(returns{"article_ids": [...]}list),POST /user/favorites/{article_id}(INSERT … ON CONFLICT DO NOTHING, 201 or 204),DELETE /user/favorites/{article_id}(204) — all withrequire_userauth - [X] T033 [US5] Register
backend/routers/user.pyrouter inbackend/main.py - [X] T034 [P] [US5] Add
addFavorite(articleId: string),removeFavorite(articleId: string),getFavorites()functions tofrontend/lib/api/user.ts - [X] T035 [US5] Extend
frontend/lib/api/articles.tsArticleOutTypeScript type withis_favorited: boolean; update article fetch function to accept and passfavorites_onlyquery param; depends on T030 - [X] T036 [US5] Extend
frontend/components/features/articles/article-card.tsxwith heart icon left of title: hover-visible when unfavorited, always-visible when favorited, hidden for unauthenticated guests; click callsaddFavorite/removeFavoriteand toggles local state; depends on T034, T035 - [X] T037 [US5] Extend
frontend/components/features/articles/filter-bar.tsxwith Favorites toggle button (visible for authenticated users only); activating passesfavorites_only=trueto articles API; depends on T034
Tests
- [X] T038 [P] [US5] Write backend integration test for
POST/DELETE/GET /user/favoritesendpoints including idempotency inbackend/tests/test_user_favorites.py - [X] T039 [P] [US5] Write backend integration test for
GET /articles?favorites_only=truerestricting results to authenticated user's favorited articles inbackend/tests/test_article_favorites_filter.py
Checkpoint: Heart icon toggles favorites; Favorites filter shows only user's articles; all favorites endpoints return correct responses
Phase 6: User Story 3 — Subscribe to Topic for Weekly Report (Priority: P3)
Goal: Build weekly report generation infrastructure (LLM, image gen, R2 storage, notifications) and subscription management API + settings UI
Independent Test: Visit settings page, subscribe to a topic — user_topic_subscriptions DB row created. Trigger weekly report generation via admin API — report generated, subscribed users receive email (if email_enabled) and Telegram (if telegram_chat_id set) notifications.
Implementation — DDD Domain Layer
- [X] T040 [P] [US3] Create
src/modules/intelligence/domain/entities/weekly_report.pywithWeeklyReportdataclass (id, topic_id, week_start_date, title, summary_text, cover_image_url, article_ids, article_count, status, error_message) - [X] T041 [P] [US3] Create
src/modules/intelligence/domain/repositories/weekly_report_repository.pyabstract repository interface (fetch top articles by topic/week using sort strategy from data-model.md, get/save WeeklyReport) - [X] T042 [P] [US3] Create
src/modules/intelligence/domain/services/image_generation_service.pyabstract service interface (generate_image(prompt: str) -> bytes) - [X] T043 [P] [US3] Create
src/modules/intelligence/domain/services/blob_storage_service.pyabstract interface (upload(data: bytes, key: str, content_type: str) -> strreturns public URL); use case injects this interface, notR2BlobStorageServicedirectly - [X] T044 [P] [US3] Create
src/modules/intelligence/domain/value_objects/article_summary_for_report.pyfrozen dataclass:title,summary,pain_points,insights,innovations(all Optional[str] from analyses table),tags: List[str](flat tag list),citation_count: Optional[int],view_count: int,published_at: Optional[datetime]; note: lives inintelligence/domain/value_objects/alongside existinganalysis_prompt.py— no cross-module import needed - [X] T045 [P] [US3] Create
src/modules/intelligence/domain/value_objects/weekly_report_prompt.pyextendingBasePrompt;render(topic_name: str, articles: List[ArticleSummaryForReport], week_start: date)fills template and returns JSON-requesting prompt for{"title": "...", "summary_text": "..."}output - [X] T046 [P] [US3] Create
src/modules/intelligence/domain/value_objects/image_generation_prompt.pyextendingBasePrompt;render(topic_name: str, top_tags: List[str], week_label: str)returns image generation prompt string (16:9 abstract art, futuristic data visualization aesthetic)
Implementation — Application Layer
- [X] T047 [US3] Create
src/modules/intelligence/application/use_cases/generate_weekly_report.pyorchestrating: (1) fetch top N articles via repo (COALESCE sort), (2) derivetop_tagsby frequency count, (3)WeeklyReportPrompt().render(...)→LLMService.analyze()→ parse JSON title+summary, (4)ImageGenerationPrompt().render(...)→ImageGenerationService.generate_image()→BlobStorageService.upload(), (5) persist WeeklyReport, (6) send email+Telegram to subscribed users; depends on T040–T046
Implementation — Infrastructure Layer
- [X] T048 [P] [US3] Create
src/infrastructure/intelligence/image/base_image_provider.pyabstract base class for image generation providers - [X] T049 [P] [US3] Create
src/infrastructure/intelligence/image/gemini_imagen_provider.pyimplementingImageGenerationServiceusinggoogle-genaiSDK; constructor acceptsmodel: str(read from the active multimodalLlmProviderDB record at runtime — never hardcoded) andapi_key: str(resolved fromapi_key_env); callsclient.models.generate_images(model=self._model, prompt=prompt, ...); depends on T042, T048 - [X] T050 [P] [US3] Create
src/infrastructure/storage/r2_blob_storage.pyR2BlobStorageServiceimplementingBlobStorageService(T043) usingboto3S3-compatible client withR2_*env vars; depends on T043 - [X] T051 [US3] REWORK (2026-07-12) Edit
src/infrastructure/intelligence/repositories/weekly_report_repo_impl.pyWeeklyReportRepoImpl's article-selection query: replace theam.citation_countreference with aLEFT JOIN article_metric_values ON ... AND metric_key = 'citation_count', orderingCOALESCE(amv.value, 0) DESC, am.view_count DESC, published_at DESC NULLS LASTper data-model.md;ArticleSummaryForReportfield name/type unchanged; depends on T041, T044, T006, T010
Implementation — Notifications
- [X] T052 [P] [US3] Create
src/infrastructure/notifications/weekly_report_email_notifier.pyWeeklyReportEmailNotifierusingresendPython SDK; queriesuser_notification_settingsfor subscribed users withemail_enabled=True; sends HTML email per user usingRESEND_API_KEYandRESEND_FROM_EMAIL; email layout matches homepage weekly report widget: full-widthcover_image_urlas header background image, semi-transparent white overlay box containing report title and summary text, CTA button "查看完整報告" / "View Full Report" linking to site root — text rendered in user'slocalefromuser_notification_settings - [X] T053 [P] [US3] Create
src/infrastructure/notifications/weekly_report_telegram_notifier.pyWeeklyReportTelegramNotifierreusing existing Telegram HTTP pattern; queriesuser_notification_settingsfor subscribed users withtelegram_enabled=Trueand non-nulltelegram_chat_id; sends per-user message
Implementation — Bootstrap & Entrypoint
- [X] T054 [US3] Add
build_weekly_pipeline()tosrc/bootstrap.pywiring:WeeklyReportRepoImpl,ResilientLLMService(reuse existing builder),GeminiImagenProvider,R2BlobStorageService,WeeklyReportEmailNotifier,WeeklyReportTelegramNotifier→GenerateWeeklyReportUseCase; depends on T047–T053 - [X] T055 [US3] Create
src/entrypoints/cli/weekly_main.pyCLI entrypoint: (1) on startup query DB for activetype='multimodal'provider — log error and exit(1) if none found; (2) callbuild_weekly_pipeline()and runGenerateWeeklyReportUseCasefor each active topic with articles in the past 7 days; accepts--topic-idand--week-startCLI args for manual triggers; depends on T054
Implementation — Backend Subscription & Notification Settings API
- [X] T056 [P] [US3] Add
GET /user/subscriptions,POST /user/subscriptions({"topic_id": "uuid"}),DELETE /user/subscriptions/{topic_id}endpoints tobackend/routers/user.py(require_user auth) - [X] T057 [P] [US3] Add
GET /user/notification-settings,PUT /user/notification-settingsendpoints tobackend/routers/user.py(require_user auth, upsert pattern onUserNotificationSettings)
Implementation — Frontend Settings UI
- [X] T058 [P] [US3] Extend
frontend/app/settings/page.tsxwith topic subscription section: list all topics, Subscribe/Unsubscribe buttons per topic, reads current subscriptions viafetchSubscriptions() - [X] T059 [P] [US3] Extend
frontend/app/settings/page.tsxwith notification settings form: email_enabled toggle, telegram_chat_id text input, telegram_enabled toggle, locale select (en/zh-TW); callsfetchNotificationSettings()on load andupdateNotificationSettings()on save - [X] T060 [P] [US3] Add
fetchSubscriptions(),subscribeToTopic(topicId),unsubscribeTopic(topicId),fetchNotificationSettings(),updateNotificationSettings(settings)tofrontend/lib/api/user.ts
Tests
- [X] T061 [P] [US3] Write unit test for
GenerateWeeklyReportUseCase(mock LLM service, image gen, blob storage, notifiers) insrc/tests/unit/test_generate_weekly_report.py - [X] T062 [P] [US3] Write unit test for
GeminiImagenProvider(mock google-genai client) insrc/tests/unit/test_gemini_imagen_provider.py - [X] T063 [P] [US3] Write unit test for
R2BlobStorageService(mock boto3 client, verify upload URL construction) insrc/tests/unit/test_r2_blob_storage.py - [X] T064 [P] [US3] Write backend integration test for
GET/POST/DELETE /user/subscriptionsandGET/PUT /user/notification-settingsinbackend/tests/test_user_subscriptions.py
Checkpoint: Weekly report generation runs end-to-end (may use mocked R2/Imagen); subscribed users receive notifications; settings page allows subscription and notification management
Phase 7: User Story 4 — View Weekly Summary Report on Homepage (Priority: P4)
Goal: Display latest weekly report above InlineQABar on homepage with cover image; dropdown to navigate past reports
Independent Test: Trigger weekly report generation via admin API, visit / — report appears above InlineQABar with cover image (or text-only if R2 unavailable). Open week dropdown — can navigate to past weekly reports.
Implementation — Backend
- [X] T065 [P] [US4] Create
backend/schemas/weekly_report.pywithWeeklyReportOutPydantic schema (id, topic_id, week_start_date, title, summary_text, cover_image_url, article_count, status, created_at) - [X] T066 [P] [US4] Create
backend/services/weekly_report_service.pywithget_weekly_reports(topic_id, limit, offset)andget_latest_weekly_report(topic_id)(returns most recentstatus='completed'report) functions - [X] T067 [US4] Create
backend/routers/weekly_reports.pywithGET /weekly-reports(paginated, public) andGET /weekly-reports/latest(single, public); depends on T065, T066 - [X] T068 [US4] Register
backend/routers/weekly_reports.pyinbackend/main.py; depends on T067
Implementation — Frontend
- [X] T069 [P] [US4] Create
frontend/lib/api/weekly-reports.tswithfetchLatestWeeklyReport(topicId: string)andfetchWeeklyReports(topicId: string, limit?: number, offset?: number)usingapiFetch() - [X] T070 [P] [US4] Create
frontend/components/features/weekly-report/weekly-report-skeleton.tsxloading skeleton component - [X] T071 [P] [US4]
Create— later removed;frontend/components/features/weekly-report/weekly-report-card.tsxdisplaying report title, summary_text (markdown rendered), and cover_image_url as CSS background-imageweekly-report-widget.tsxrenders this content inline and never adopted the component - [X] T072 [US4] Create
frontend/components/features/weekly-report/weekly-report-widget.tsxwith week navigation dropdown (Shadcn Select); fetches viafetchLatestWeeklyReport/fetchWeeklyReports; shows "No report for this week yet" placeholder when null; usesWeeklyReportSkeletonwhile loading; depends on T069, T070, T071 - [X] T073 [US4] Extend
frontend/app/page.tsxto render<WeeklyReportWidget topicId={currentTopicId} />above<InlineQABarWrapper>; depends on T072 - [X] T074 [P] [US4] Create
frontend/components/features/weekly-report/weekly-report-widget.stories.tsxStorybook story (required by Constitution §II) with no-report and with-report states - [X] T075 [P] [US4]
Create— later removed along with T071frontend/components/features/weekly-report/weekly-report-card.stories.tsxStorybook story (required by Constitution §II) with and without cover image
Tests
- [X] T076 [P] [US4] Write backend integration test for
GET /weekly-reportsandGET /weekly-reports/latestinbackend/tests/test_weekly_reports.py - [X] T077 [P] [US4] Write frontend unit test for
WeeklyReportWidget(empty state, loaded state) infrontend/tests/unit/weekly-report-widget.test.tsx
Checkpoint: Homepage displays weekly report widget above InlineQABar; empty state shows placeholder; week dropdown navigates past reports
Phase 8: Polish & Cross-Cutting Concerns
Purpose: E2E validation and final cross-cutting quality checks
- [X] T078 [P] Write E2E test for sort by citation_count reordering the article list in
frontend/tests/integration/sort-articles.spec.ts - [X] T079 [P] Write E2E test for weekly report widget display and week dropdown navigation in
frontend/tests/integration/weekly-report-widget.spec.ts - [X] T080 Run quickstart.md validation: applied migration 23 against a fresh throwaway DB (full upgrade chain from base, then downgrade -1, then re-upgrade — round-trip verified clean; fixed a pre-existing FK-ordering bug in downgrade() found in the process: weekly_reports was dropped before weekly_reports_translation, which FKs into it); verified
metric_definitionsseed rows present; manually ranuv run python -m src.entrypoints.cli.refresh_metricsagainst live OpenAlex/Semantic Scholar APIs and confirmed a realarticle_metric_valuesrow was written; fullsrc/tests/unit/(672 passed),backend/tests/unit (304 passed), andsrc/tests/integration/(50 passed) suites all green. (Note: corrected from the earlier draft — the weekly report entrypoint issrc.entrypoints.cli.weekly_report, notweekly_main; not exercised end-to-end here since it requires R2/multimodal provider credentials not configured in this dev environment, but its query change (T051) is covered by existing unit tests.)
Phase 9: User Story 6 — Paragraph-Level Article Citations in Weekly Report (Priority: P5, added 2026-07-12)
Goal: Weekly report summaries carry [N] inline citations resolvable to real articles (reusing the chat feature's citation UX); fixes the pre-existing WeeklyReport.article_ids bug (stored article titles instead of UUIDs) as a prerequisite. See spec.md FR-024–FR-029, plan.md Phase K.
Independent Test: Generate a weekly report for a topic with several articles. Open it on the homepage — sentences drawing on a specific article show a small numbered citation marker; clicking a marker opens that article's detail dialog. A report generated before this phase shipped still renders its summary as plain text with no markers.
Implementation
- [X] T093 [P] [US6] Add
article_id: UUIDfield toArticleSummaryForReportinsrc/modules/intelligence/domain/value_objects/article_summary_for_report.py. Extended same day (FR-035): replaced the old hardcodedcitation_count: Optional[int]field withmetrics: Dict[str, float](deployment-agnostic catalog metrics, metric_key → value) so the object isn't tied to one specific metric name. - [X] T094 [US6] Edit
WeeklyReportRepoImpl.fetch_top_articles()insrc/infrastructure/persistence/intelligence/weekly_report_repo_impl.pyto additionallySELECT Article.idand populateArticleSummaryForReport.article_id; depends on T093. Extended same day (FR-035): the top-N ranking query (still ordered bycitation_count DESC, view_count DESC, published_at DESC) no longer selectsArticleMetricValuedirectly — a second query fetches every non-NULLarticle_metric_valuesrow for the selected article ids and groups them per-article intoArticleSummaryForReport.metrics, instead of filtering tometric_key == "citation_count"only - [X] T095 [P] [US6] Edit
WeeklyReportPrompt.render()insrc/modules/intelligence/domain/value_objects/weekly_report_prompt.pyto render each article with a 1-indexed bracket number ([1],[2], ...) and add an instruction for the LLM to cite inline via[N]insummary_text, whereNis the article's list position (not an LLM-supplied ID). Extended same day (FR-035): each article's description also renders aMetrics:line (humanized metric key labels + view count, omitted when neither present) plus an instruction that metrics are one input among several, not a ranking to blindly follow — seeweekly_report_repo_impl.py's T094 update for the query-side change andtest_weekly_report_prompt.pyfor coverage - [X] T096 [US6] Fix the
article_idsbug inGenerateWeeklyReportUseCase(src/modules/intelligence/application/use_cases/generate_weekly_report.py, ~line 132): replacearticle_ids = [str(a.title) for a in articles]with[str(a.article_id) for a in articles], order preserved so index i ↔ citation[i+1]; depends on T093, T094, T095 - [X] T097 [P] [US6] CORRECTED PATH: Edit
WeeklyReportTranslationPromptinsrc/modules/intelligence/domain/value_objects/translation_prompt.py(not a separateweekly_report_translation_prompt.pyfile — it lives alongside the other translation prompt value objects) to instruct the LLM to preserve[N]citation markers verbatim (unchanged digits, count, and position) when translatingsummary_text - [X] T098 [US6] CORRECTED SCOPE: There is no standalone
TranslateWeeklyReportUseCase— translation is handled byGenerateWeeklyReportUseCase._translate_report()/_parse_translation_response()insrc/modules/intelligence/application/use_cases/generate_weekly_report.py. Added_extract_citation_numbers()and, after parsing the translated response, compare the set of[N]tokens against the originalreport.summary_text; on any mismatch, fall back to the original Englishsummary_textfor that language's translation row (title translation is kept regardless); depends on T097 - [X] T099 [P] [US6] Add
ArticleSourceOutschema (id: UUID,title: str,url: str,public_article_id: UUID) and asources: List[ArticleSourceOut] = []field onWeeklyReportOutinbackend/schemas/weekly_report.py - [X] T100 [US6] Edit
_resolve_sources()/_to_out()inbackend/routers/weekly_reports.py(now takesdb: Session) to resolvesourcesby looking upArticlerows forreport.article_idsin order; wraps each entry'sUUID(...)parse in try/except so pre-existing title-stringarticle_idsare skipped (old reports resolve to an emptysourceslist, no error); all three call sites (list_weekly_reports,get_latest_report,get_report_by_week) updated to passdb; depends on T099, T096 - [X] T101 [P] [US6] Extracted
parseInline,renderMarkdown, the source-chip list, and theArticleDetailDialog-open-on-click logic out offrontend/components/features/chat/AnswerDisplay.tsxinto a new shared componentfrontend/components/features/chat/cited-content.tsxexporting<CitedContent text sources showSourceList />; refactoredAnswerDisplay.tsxto render viaCitedContent(passesshowSourceList={!isLoading}to preserve the original streaming behavior where inline citations link immediately but the chip row only appears once streaming completes) - [X] T102 [P] [US6] Added
sources: ArticleSource[]field to theWeeklyReportinterface infrontend/lib/api/weekly-reports.ts, importingArticleSourcefrom@/components/features/chat/types(no import-cycle —types.tshas no imports). Also addedsources: []to the existing mock objects infrontend/tests/unit/weekly-report-widget.test.tsxandfrontend/stories/WeeklyReportWidget.stories.tsx - [X] T103 [US6] Edited
frontend/components/features/weekly-report/weekly-report-widget.tsxto renderselected.summary_textvia<CitedContent text={selected.summary_text} sources={selected.sources} />(wrapped in atext-sm text-neutral-700 leading-relaxeddiv for inherited typography) instead of the manualsplitParagraphs(selected.summary_text).map(p => <p>)block; removed the now-unusedsplitParagraphshelper; depends on T101, T102
Tests
- [X] T104 [P] [US6] Added unit tests asserting
WeeklyReportPrompt.render()produces a 1-indexed bracketed article list, the[N]citation instruction, and never leaks article UUIDs into the prompt, insrc/tests/unit/modules/intelligence/domain/test_weekly_report_prompt.py(new file, matching this repo's actualdomain/test_*.pyflat-file convention rather than avalue_objects/subfolder); depends on T095 - [X] T105 [P] [US6] Extended
src/tests/unit/modules/intelligence/application/test_generate_weekly_report.py: updated the_summary()helper to accept/generatearticle_id, and addedtest_execute_populates_article_ids_with_real_uuids_in_prompt_order— regression test for the title-string bug; depends on T096 - [X] T106 [P] [US6] CORRECTED PATH: Added
test_translate_report_falls_back_to_english_summary_when_citations_mismatchandtest_translate_report_keeps_translation_when_citations_matchto the sametest_generate_weekly_report.py(there is no separatetranslate_weekly_report.pyuse case to test — see T098 correction); depends on T098 - [X] T107 [P] [US6] CORRECTED PATH: Extended the existing
backend/tests/integration/test_weekly_reports.py(not a newbackend/tests/test_weekly_reports.py— that integration test already existed at this path) with_article()fixture helper plustest_get_latest_resolves_sources_for_valid_article_idsandtest_get_latest_returns_empty_sources_for_pre_existing_title_string_article_ids; also asserted"sources" in datain the existing schema-fields test; depends on T100 - [X] T108 [P] [US6] CORRECTED PATH: Created
frontend/tests/unit/rag/CitedContent.test.tsx(matching the existingrag/subfolder convention used byAnswerDisplay.test.tsx, not a flatcited-content.test.tsx) asserting[N]renders as a clickable marker only when withinsourcesrange, renders out-of-range/sourceless[N]as literal text, and thatshowSourceList={false}hides the chip row while still linkifying inline citations; depends on T101 - [X] T109 [P] [US6] NO CHANGE NEEDED:
frontend/tests/unit/rag/AnswerDisplay.test.tsxalready existed with comprehensive coverage of citation buttons, source chips, markdown, and thinking-block behavior: sinceAnswerDisplaynow delegates rendering toCitedContentwith an identical prop contract and output, this existing suite already serves as the no-regression test for the extraction — no new file created; depends on T101
Checkpoint: Weekly report summaries show clickable [N] citations resolving to the correct article; translations preserve or safely fall back on citation markers; pre-existing reports remain unaffected
Phase 10: User Story 7 — Pin This Week's Report into Chat (Priority: P6, added 2026-07-12)
Goal: A report-level pin control on the weekly report widget bulk-adds the report's cited articles into the shared pinned-article chat context; the homepage's inline chat bar (InlineQABarWrapper) gains the pinning wiring it currently lacks (only the separate floating chatbot has it today). No backend or chatbot-plugin changes — reuses the existing pinned_article_ids filtered-retrieval mechanism unchanged. See spec.md FR-030–FR-034, plan.md Phase L.
Independent Test: Open the homepage with a weekly report displayed. Click the report's pin control — the report's cited articles appear as pinned chips near the chat input. Ask a question — the request includes those article ids. Click the pin control again — the pinned chips clear.
Implementation
- [X] T110 [P] [US7] Extend
PinnedArticleContextValueinfrontend/lib/providers/pinned-article-provider.tsxwithpinArticles(articles: PinnedArticle[])(adds any not already present, no duplicates) andareAllPinned(ids: string[])(true only when every given id is currently pinned); existing per-article API (togglePinnedArticle,removePinnedArticle,clearPinnedArticles,isPinned) unchanged - [X] T111 [US7] Add a report-level pin control to
frontend/components/features/weekly-report/weekly-report-widget.tsx: a Sparkles-style button (mirrorsarticle-card.tsx's existing per-article pin button), shown only whenselected.sources.length > 0; toggling callspinArticles()withselected.sourcesmapped toPinnedArticlewhen not fully pinned, or removes each ofselected.sources's ids when fully pinned (per FR-031, FR-032); depends on T110 - [X] T112 [US7] Wire
usePinnedArticle()intofrontend/components/features/chat/InlineQABarWrapper.tsx: build theX-Pinned-Article-Idsheader frompinnedArticlesexactly asFloatingChatbotWrapper.tsxalready does; render a compact pinned-chip row aboveAgentInputshowing each pinned article's title with a per-chip remove action (per FR-033); depends on T110
Tests
- [X] T113 [P] [US7] Unit test
pinArticles()adds only articles not already present (no duplicates) andareAllPinned()returns true only when every given id is present, infrontend/tests/unit/pinned-article-provider.test.tsx; depends on T110 - [X] T114 [P] [US7] Unit test: the weekly report widget's pin control is absent when
sourcesis empty, pins all cited articles when none/some are pinned, and unpins all of them when all are already pinned, infrontend/tests/unit/weekly-report-widget.test.tsx; depends on T111 - [X] T115 [P] [US7] Unit test:
InlineQABarWrapperincludesX-Pinned-Article-Idsin the chat request headers when articles are pinned, and omits it when none are pinned, infrontend/tests/unit/rag/InlineQABarWrapper.test.tsx; depends on T112
Checkpoint: Weekly report's cited articles can be pinned into the homepage chat in one click; pinned state is visible and removable; chat requests carry the pinned article ids using the existing mechanism
Phase 11: User Story 8 & 9 — Generalized Metric Display + Admin Enable/Disable (Priority: P7, added 2026-07-12)
Goal: Any catalog metric automatically appears as a badge on article cards/detail dialog and as a sort option, driven by a new public display-metadata endpoint; administrators can toggle a metric's enabled state from a new admin page without touching its extraction/display configuration. See spec.md FR-036–FR-042, plan.md Phase M.
Independent Test: With two catalog metrics enabled, open the articles list — cards show a badge per metric with correct icon/label; sort dropdown offers both. As admin, disable one metric on /admin/metric-definitions — it disappears from cards and sort everywhere, without a deployment.
Implementation
- [X] T116 [US8][US9] CORRECTED (2026-07-12): Migration 23 is still unshipped to production, so
icon_namewas added by editingalembic/versions/23_article_recommendation_weekly_report.pyin place (nullableicon_name VARCHAR(50)column onmetric_definitions+ seedINSERTupdated with'quote'for the twocitation_countrows) — no new migration file, same rationale as the earlier citation_count/metric_definitions rework. Local Postgres (already stamped at revision 23) was brought in sync with a manualALTER TABLE ... ADD COLUMN+UPDATEinstead of a full downgrade/upgrade cycle, to avoid dropping unrelated local data. - [X] T117 [P] [US8][US9] Add
icon_name = Column(String(50), nullable=True)tomodels/metric_definition.py; depends on T116 - [X] T118 [P] [US8][US9] Create
backend/schemas/metric_definition.py:MetricDefinitionDisplayOut(metric_key,label_i18n_key,icon_name,format_hint,unit),MetricDefinitionAdminOut(+id,provider_name,priority,enabled),MetricDefinitionEnabledUpdate(enabled: boolonly) - [X] T119 [US8][US9] Create
backend/services/metric_definition_service.py:get_enabled_metric_display(db)(dedupe bymetric_key, ordered bypriority),get_all_metric_definitions(db),set_metric_definition_enabled(db, id, enabled)(updatesenabledonly, no other field accepted); depends on T117, T118 - [X] T120 [US8][US9] Create
backend/routers/metric_definitions.py:GET /metric-definitions(public),GET /admin/metric-definitions(require_admin),PATCH /admin/metric-definitions/{id}(require_admin); register inbackend/main.py; depends on T119 - [X] T121 [P] [US8] Edit
backend/schemas/article.py: replacecitation_count: Optional[int] = NoneonArticleOut/ArticleDetailOutwithmetrics: Dict[str, float] = {} - [X] T122 [US8] Edit
backend/services/article_service.py:build_article_out()populates the genericmetricsmap (fetch every non-NULLarticle_metric_valuesrow for the page's article ids, same two-query pattern asWeeklyReportRepoImpl.fetch_top_articles()); generalizeget_articles_paginated()'sif sort in ("citation_count", "view_count")branch so anysortvalue matching an enabledmetric_definitions.metric_keyuses the same outerjoin+nullslast ordering, keyed dynamically instead of hardcoded; depends on T121, T117 - [X] T123 [P] [US8][US9] Create
frontend/lib/api/metric-definitions.ts:fetchEnabledMetricDefinitions()(public),fetchAllMetricDefinitions()/updateMetricDefinitionEnabled(id, enabled)(admin, under/api/proxy/admin/metric-definitions) - [X] T124 [P] [US8] Update
frontend/lib/api/articles.ts'sArticle/ArticleDetailtypes:citation_count?: number | null→metrics: Record<string, number> - [X] T125 [P] [US8] Create
frontend/components/features/articles/metric-icons.tsexporting a whitelistedRecord<string, LucideIcon>+ default fallback icon (e.g.BarChart3) - [X] T126 [US8] Edit
article-card.tsx: replace the hardcodedcitation_count > 0 && <Quote>badge with a loop overObject.entries(article.metrics), resolving each metric's icon/label viafetchEnabledMetricDefinitions()(fetched once and shared across cards, not per-card); depends on T123, T124, T125 - [X] T127 [US8] Edit
article-detail-dialog.tsx: same generalization as T126; depends on T123, T124, T125 - [X] T128 [US8] Edit
sort-select.tsx: fetchfetchEnabledMetricDefinitions()once, append oneSORT_OPTIONSentry per returned metric after the fixed fields; depends on T123 - [X] T129 [US9] Create
frontend/app/admin/metric-definitions/page.tsx: fetchfetchAllMetricDefinitions(), render one card per row grouped bymetric_key(mirrorsadmin/llm-providers/page.tsx's card +Switchpattern), toggle callsupdateMetricDefinitionEnabled()optimistically with rollback on failure — no create/edit/delete/reorder controls; depends on T123 - [X] T130 [US9] Add the new tab to
frontend/app/settings/layout.tsx's admin nav list; addadmin.metricDefinitionsand related labels toen.json/zh-TW.json; depends on T129
Tests
- [X] T131 [P] [US8][US9] Backend test:
GET /metric-definitionsreturns onlyenabled=truerows, deduplicated bymetric_key, withoutprovider_name/extractor_specin the response; depends on T120 - [X] T132 [P] [US8][US9] Backend test:
GET /admin/metric-definitionsandPATCH /admin/metric-definitions/{id}both return 401/403 for a non-admin caller; depends on T120 - [X] T133 [P] [US8][US9] Backend test:
PATCH /admin/metric-definitions/{id}updates onlyenabled; other fields in the request body are never applied; depends on T120 - [X] T134 [P] [US8] Backend test:
GET /articlesreturns ametricsmap covering every catalog metric the article has a value for, and sorting by an enabled metric_key orders correctly with nulls-last regardless of direction; depends on T122 - [X] T135 [P] [US8] Frontend test:
article-card.tsxrenders one badge permetricsentry with the correct icon/label from a mockedfetchEnabledMetricDefinitions(), falling back to the default icon whenicon_nameis null; depends on T126 - [X] T136 [P] [US8] Frontend test:
sort-select.tsxincludes a dynamically-fetched metric option alongside the fixed fields; depends on T128 - [X] T137 [P] [US9] Frontend test:
admin/metric-definitions/page.tsxtoggles aSwitch, callsupdateMetricDefinitionEnabled(), and rolls back UI state if the call fails; depends on T129
Checkpoint: New catalog metrics require zero frontend code changes to appear on cards/detail/sort; admins can toggle metrics without a deployment; extraction/display config remains migration-only
Phase 12: Metric/Provider Table Split + arXiv Citation Coverage (Priority: P7, added 2026-07-12 same day, supersedes parts of Phase 11)
Goal: Admin page shows one row per metric_key with zero extraction-plumbing (provider/priority) leakage; arXiv-only articles (no DOI) can get citation_count refreshed via Semantic Scholar's arXiv-ID lookup, which OpenAlex's API doesn't support. See spec.md's follow-up Clarifications on US8/US9, plan.md Phase N.
Independent Test: As admin, open /admin/metric-definitions — exactly one row per metric_key, no provider/priority text anywhere, with an icon dropdown and enabled toggle. Seed an article with only an arxiv_id (no doi) in metadata, run refresh_metrics.py against a reachable Semantic Scholar API — citation_count gets populated where it previously would not have been.
Implementation
- [X] T138 [US8][US9] Edit
alembic/versions/23_article_recommendation_weekly_report.pyin place again (still unshipped): splitmetric_definitionsdown to metric-key-level only (metric_keyUNIQUE,label_i18n_key,format_hint,unit,icon_name,enabled); add newmetric_providerstable (metric_definition_idFK CASCADE,provider_name,priority,extractor_type,extractor_spec, UNIQUE(metric_definition_id, provider_name)); reseedcitation_countwith onemetric_definitionsrow + threemetric_providersrows (openalexprio 1,semantic_scholarprio 2,semantic_scholar_arxivprio 3); updatedowngrade()to dropmetric_providersbeforemetric_definitions. Sync local Postgres in place (hand-run equivalent DROP/CREATE/reseed, not a full downgrade/upgrade) - [X] T139 [P] [US8][US9] Rewrite
models/metric_definition.pyto the metric-key-only shape; createmodels/metric_provider.py; register both inmodels/__init__.py; depends on T138 - [X] T140 [US8][US9] Rewrite
shared/metric_definition.py::load_enabled_metric_definitions()to joinmetric_definitions+metric_providers(filteredenabled=True), keeping the same flat output dict shape soresilient_metrics_service.pyneeds no changes; depends on T139 - [X] T141 [P] [US8][US9] Add
SemanticScholarClient.fetch_by_arxiv_id(arxiv_id)(src/infrastructure/collection/clients/semantic_scholar_client.py), same shape asfetch_by_doi(), hitspaper/ARXIV:<id> - [X] T142 [US8][US9] Add
"semantic_scholar_arxiv"entry tobuild_provider_fetchers()(src/infrastructure/collection/metrics/resilient_metrics_service.py), callingfetch_by_arxiv_id()only whenids.get("arxiv_id"); no OpenAlex equivalent (its API has no arXiv-ID lookup); depends on T141 - [X] T143 [P] [US8][US9] Rewrite
backend/schemas/metric_definition.py:MetricDefinitionAdminOutdropsprovider_name/priority;MetricDefinitionAdminUpdate(renamed fromMetricDefinitionEnabledUpdate) acceptsenabledandicon_name, the latter validated against a module-levelICON_WHITELISTvia a Pydanticfield_validator - [X] T144 [US8][US9] Rewrite
backend/services/metric_definition_service.py:get_all_metric_definitions()becomes a plainmetric_definitionsquery;update_metric_definition(db, id, *, enabled, icon_name)(renamed fromset_metric_definition_enabled) sets whichever field(s) are provided; depends on T139, T143 - [X] T145 [US8][US9] Update
backend/routers/metric_definitions.py'sPATCH /admin/metric-definitions/{id}to useMetricDefinitionAdminUpdate/update_metric_definition; depends on T144 - [X] T146 [P] [US8][US9] Expand
frontend/components/features/articles/metric-icons.ts's whitelist from 8 to 20 icons (download,share-2,bookmark,heart,message-square,flame,trophy,hash,percent,clock,book-open,network); exportMETRIC_ICON_NAMES: string[] - [X] T147 [P] [US8][US9] Update
frontend/lib/api/metric-definitions.ts:MetricDefinitionAdmindropsprovider_name/priority; replaceupdateMetricDefinitionEnabled(id, enabled)withupdateMetricDefinition(id, { enabled?, icon_name? }) - [X] T148 [US8][US9] Rewrite
frontend/app/admin/metric-definitions/page.tsx: one row per metric_key with aSwitch(enabled) +NativeSelect(icon, options fromMETRIC_ICON_NAMES), both callingupdateMetricDefinition()optimistically with rollback on failure; no provider/priority displayed; depends on T146, T147 - [X] T149 [US8][US9] Update
CLAUDE.md: correct "LLM Provider Chain" section (noproviders.tomlfile exists; DB-driven viallm_providerstable since migration 16); add new "Metric Provider Chain" section documenting themetric_definitions/metric_providerssplit and contrasting it with the LLM chain; addMetricDefinition/MetricProviderto ORM Models; add missing router rows (llm_providers.py,metric_definitions.py,weekly_reports.py) to Backend Routers table
Tests
- [X] T150 [P] [US8][US9] Backend integration test:
GET /admin/metric-definitionsreturns exactly one row per metric_key even with multiplemetric_providersrows, and that row exposes neitherprovider_namenorpriority; depends on T145 - [X] T151 [P] [US8][US9] Backend integration test:
PATCH /admin/metric-definitions/{id}accepts a whitelistedicon_nameand persists it, rejects (422) a non-whitelisted one, and leaves the other field untouched when only one ofenabled/icon_nameis sent; depends on T145 - [X] T152 [P] [US8][US9] Backend integration test:
GET /articles?sort=<non-citation metric_key>still orders correctly post-split (regression guard for T138–T140); depends on T140 - [X] T153 [P] [US8][US9] Backend unit test:
SemanticScholarClient.fetch_by_arxiv_id()hitspaper/ARXIV:<id>, raisesSemanticScholarRateLimitedErroron 429, returnsNoneon other failures; depends on T141 - [X] T154 [P] [US8][US9] Backend unit test:
build_provider_fetchers()["semantic_scholar_arxiv"]only callsfetch_by_arxiv_id()whenarxiv_idis present;build_provider_fetchers()["openalex"]is never called with only anarxiv_id(regression guard for the original bug); depends on T142 - [X] T155 [P] [US8][US9] Frontend unit test: admin page renders one card per metric_key with no provider/priority text visible, even when the underlying fixture has multiple providers for the same metric_key; depends on T148
- [X] T156 [P] [US8][US9] Frontend unit test: changing the icon
NativeSelectcallsupdateMetricDefinition(id, { icon_name }); toggling theSwitchcalls it with{ enabled }; both roll back their respective field on failure; depends on T148
Checkpoint: Admin page fully decoupled from extraction plumbing; arXiv-only articles are no longer silently skipped by the citation refresh job
Phase 13: User Story 10 — Weekly Report Chat-Pin UX Refinements (Priority: P8, added 2026-07-14)
Goal: Replace one-pill-per-article pinning with one editable batch pill per report; support dragging a source pill into chat; move the pinned row below the chat input; collapse the source pill list by default; fix the stepper's date-picker drift. See spec.md FR-043–FR-051, plan.md Phase O, docs/superpowers/specs/2026-07-14-weekly-report-chat-pinning-design.md.
Independent Test: Click sparkles on a weekly report — one batch pill (not N article pills) appears below the chat input. Click its edit icon, uncheck an article — count decreases, pill disappears once all are unchecked. Expand a report's source pill list, drag one pill into the chat input — it pins as an individual pill. Resize a topic to have many weekly reports — the stepper's date picker stays put, with chevrons to jump to the newest/oldest week.
Implementation
- [ ] T157 [P] [US10] Extend
PinnedArticleContextValueinfrontend/lib/providers/pinned-article-provider.tsxwithpinnedGroups: PinnedGroup[]state ({ id, dateLabel, articles }) and three actions:pinGroup(group)(upsert by id, pin every article via existingpinArticles),toggleGroupArticle(groupId, articleId)(toggle via existingtogglePinnedArticle; auto-remove the group once its included count hits 0),removeGroup(groupId)(unpin every article in the group, delete it); existing per-article API unchanged - [ ] T158 [US10] Edit
handleTogglePinReportinfrontend/components/features/weekly-report/weekly-report-widget.tsxto callpinGroup({ id: selected.id, dateLabel, articles })/removeGroup(selected.id)instead of the rawpinArticles/loop, keeping the sameareAllPinned(ids)branch;dateLabeluses the same{ month: 'short', day: 'numeric' }format as the stepper's week dots (T163); depends on T157 - [ ] T159 [P] [US10] Add opt-in
draggableSources?: booleanprop (defaultfalse) tofrontend/components/features/chat/cited-content.tsx; when true, wrap each source-chip button with dnd-kit'suseDraggable({ id: 'source-' + src.id, data: { article: { id, title } } }); chat's existing usage (noDndContextancestor) is unaffected by the default - [ ] T160 [US10] Add local state
sourcesExpandedtoweekly-report-widget.tsx(reset tofalseon everyselected.idchange viauseEffect); turn the existingextraContentarticle-count paragraph into a disclosure button (▸/▾) toggling it; passshowSourceList={sourcesExpanded}anddraggableSourcestoCitedContent; depends on T159 - [ ] T161 [US10] Wrap
weekly-report-widget.tsx's returned JSX in dnd-kit's<DndContext onDragEnd={handleDragEnd}>;handleDragEndchecksevent.over?.id === 'chat-input-dropzone'and callspinArticles([event.active.data.current.article])when true; depends on T157, T159 - [ ] T162 [US10] Edit
frontend/components/features/chat/InlineQABarWrapper.tsx: move the pinned-pills block from above<AgentInput>to below it; render one pill perpinnedGroupsentry (🌟 {dateLabel} · {includedCount} 篇文章, live-computed fromgroup.articles.filter(a => isPinned(a.id)).length) before individually-pinned articles not covered by a group; edit icon opens a shadcnPopover(components/ui/popover.tsx) with a checkbox pergroup.articlesentry bound toisPinned, callingtoggleGroupArticle; remove icon callsremoveGroup; wrap the pinned-pills+input container withuseDroppable({ id: 'chat-input-dropzone' }), highlighting it whileisOver; depends on T157 - [ ] T163 [US10] Edit
frontend/components/features/weekly-report/weekly-report-stepper.tsx: replace theflex-1spacer div withoverflow-y-auto flex-1 min-h-0directly on the week-dotslistboxdiv (with aref), so the date picker stays pinned at the bottom of the column via normal flex-column order; add aResizeObserver(re-checked onreports.lengthchange) that showsChevronUp/ChevronDownbuttons above/below the listbox whenscrollHeight > clientHeight, each callingscrollTo({ top: 0 | scrollHeight, behavior: 'smooth' }); hidden when the list fits - [ ] T164 [P] [US10] Add
rag.weeklyGroupPill,rag.editGroupArticles,rag.groupArticlesPopoverTitlekeys tofrontend/lib/providers/locales/en.jsonandzh-TW.json; reuse existingrag.removeArticleReffor the batch pill's remove-icon aria-label
Tests
- [ ] T165 [P] [US10] Unit tests for
pinGroup/toggleGroupArticle(including auto-remove-at-zero-included)/removeGroupinfrontend/tests/unit/pinned-article-provider.test.tsx; depends on T157 - [ ] T166 [P] [US10] Unit test:
InlineQABarWrapperrenders a group pill with the correct live count, edit popover checkboxes reflectisPinned, and a simulated drop event on the dropzone pins the dragged article, infrontend/tests/unit/rag/InlineQABarWrapper.test.tsx; depends on T162 - [ ] T167 [P] [US10] Unit test:
weekly-report-widget.tsx's sparkles toggle still drivesareAllPinnedcorrectly through the new group actions; source pill list starts collapsed, expands on click, and resets to collapsed whenselected.idchanges, infrontend/tests/unit/weekly-report-widget.test.tsx; depends on T158, T160 - [ ] T168 [P] [US10] Unit test:
weekly-report-stepper.tsx's jump-to-top/bottom chevrons are absent when the list fits and present/functional when it overflows, infrontend/tests/unit/weekly-report-stepper.test.tsx; depends on T163 - [ ] T169 [P] [US10] Unit test:
cited-content.tsx's existing citation tests are unaffected by the newdraggableSourcesprop (defaultsfalse); a new test confirms drag attributes are present only when the prop is set, infrontend/tests/unit/rag/CitedContent.test.tsx; depends on T159
Checkpoint: Sparkles-pinning a weekly report produces one editable batch pill regardless of article count; source pills are draggable into chat when expanded; the pinned row sits below the chat input; the stepper's date picker no longer drifts with many weeks
Dependencies & Execution Order
Phase Dependencies
- Setup (Phase 1): No dependencies — start immediately
- Foundational (Phase 2): Depends on Phase 1; BLOCKS all user stories
- US1 (Phase 3): Depends on Phase 2 (needs
ArticleMetricsORM T005,MetricDefinition/ArticleMetricValueORMs T009/T010, shared loader T011) - US1 Recurring Metric Refresh (Phase 3, T081–T092): Depends on T015 (generalized
upsert()), T011; independent of the rest of Phase 3's implementation tasks — can be built in parallel once T015 lands - US2 (Phase 4): Depends on Phase 3 (needs
article_metrics/article_metric_valuesJOIN from T017) - US5 (Phase 5): Depends on Phase 2 (needs
UserArticleFavoriteORM T007); can start in parallel with Phase 3/4 - US3 (Phase 6): Depends on Phase 2 (needs
WeeklyReport,UserTopicSubscription,UserNotificationSettingsORMs T006, T007); T051 additionally depends on T010 (ArticleMetricValueORM); otherwise largely independent of Phase 3–5 - US4 (Phase 7): Depends on Phase 6 (weekly reports must be generable for meaningful testing)
- Polish (Phase 8): Depends on all user story phases
- US6 (Phase 9, added 2026-07-12): Depends on Phase 7 (extends the already-displayed weekly report widget with citations); independent of Phase 8 Polish
- US7 (Phase 10, added 2026-07-12): Depends on Phase 9 (needs
WeeklyReportOut.sourcesto have real article ids to pin); independent of Phase 8 Polish - US8/US9 (Phase 11, added 2026-07-12): Depends on Phase 2 (needs
MetricDefinition/ArticleMetricValueORMs) and Phase 3/US1 (extends the already-shippedcitation_countdisplay it generalizes); independent of Phases 6–10 (weekly report / pinning) - US8/US9 (Phase 12, added 2026-07-12 same day): Depends on Phase 11 completing first (splits the table Phase 11 just built); independent of Phases 6–10
- US10 (Phase 13, added 2026-07-14): Depends on Phase 10 (extends US7's pin control and reuses
InlineQABarWrapper's pinning wiring); independent of Phases 11–12 (metrics admin)
User Story Dependencies
| Story | Depends On | Notes |
|---|---|---|
| US1 (P1) | Phase 2 complete | No story-level dependencies. T081–T092 (recurring refresh) extend US1's "citation count visible" goal to "citation count stays fresh" |
| US2 (P2) | US1 T017 | Extends existing article_metrics/article_metric_values JOIN |
| US5 (P2) | Phase 2 complete | Shares filter-bar.tsx with US2; order: US2 → US5 |
| US3 (P3) | Phase 2 complete | New DDD bounded context, largely independent; T051 depends on T010 |
| US4 (P4) | US3 complete | Needs weekly reports to exist in DB |
| US6 (P5, added 2026-07-12) | US4 complete | Adds citation resolution/rendering on top of the existing weekly report pipeline and widget; also fixes the article_ids bug that predates US6 |
| US7 (P6, added 2026-07-12) | US6 complete | Bulk-pins US6's sources into the existing usePinnedArticle context; purely additive frontend wiring, no backend/RAG changes |
| US8/US9 (P7, added 2026-07-12) | US1 complete | Generalizes US1's citation_count-only display/sort into any enabled catalog metric, plus a narrow admin-toggle exception to FR-022; independent of US2–US7 |
| US10 (P8, added 2026-07-14) | US7 complete | Replaces US7's one-pill-per-article pinning with one editable batch pill per report, adds drag-and-drop from source pills, repositions the pinned row, collapses the source list, and fixes an unrelated stepper scroll bug; purely additive frontend wiring, no backend changes |
2026-07-12 Rework Note
This tasks.md was regenerated after a metrics-catalog redesign (see plan.md, research.md §9b–§9f). Tasks marked REWORK correspond to code that was already implemented (all originally [X]) against the superseded single-column article_metrics.citation_count design and must be edited to match the new metric_definitions/article_metric_values schema — they are unchecked again because the underlying code needs real changes, not because no work was ever done. Tasks marked RE-VERIFY have logic/assertions that are still valid but need to be re-run against the reworked query beneath them. New tasks (T003, T009–T011, T081–T092) are net-new capability (the recurring refresh job and its supporting abstractions) that did not exist under the original design. Everything else retains its original [X] and needs no changes.
Within Each User Story
- Domain entities before services/value objects before repositories
- Backend schemas before services before routers
- Backend implementation before frontend API client
- Frontend API client before frontend components
- Implementation before tests (no TDD per project preference)
Parallel Opportunities
Phase 2 (after T004 migration is written)
T005–T011 (ORM models + shared loader) can all run in parallel with each other.
Phase 3 (US1)
T012, T013, T014 (ScrapedArticle + scraper extensions) run in parallel. T016, T020 (backend schema + frontend type) run in parallel. T081, T082 (client fetch_by_doi methods) run in parallel; T083 (domain interface) can run alongside them. T023, T024, T089, T090, T091, T092 (tests) run in parallel after their respective implementation tasks land.
Phase 6 (US3)
T040–T046 (domain layer) run in parallel — entities, interfaces, value objects all have no inter-dependencies. T048, T049, T050, T052, T053 (infrastructure) run in parallel after T042/T043. T056, T057, T058, T059, T060 (backend API + frontend settings) run in parallel. T061, T062, T063, T064 (tests) run in parallel.
Phase 9 (US6, added 2026-07-12)
T093, T095, T097, T099, T101, T102 (independent value-object/schema/component edits) run in parallel. T104–T109 (tests) run in parallel once their respective implementation task lands.
Parallel Example: Phase 6 Domain Layer
# All domain tasks in parallel:
Task T040: Create weekly_report.py entity
Task T041: Create weekly_report_repository.py interface
Task T042: Create image_generation_service.py interface
Task T043: Create blob_storage_service.py interface
Task T044: Create article_summary_for_report.py DTO
Task T045: Create weekly_report_prompt.py value object
Task T046: Create image_generation_prompt.py value object
# Then T047 (use case) which depends on all of T040-T046Implementation Strategy
MVP First (User Story 1 Only)
- Complete Phase 1: Setup
- Complete Phase 2: Foundational migrations and ORM models
- Complete Phase 3: US1 — citation count display + view tracking
- STOP and VALIDATE: Scrape an OpenAlex article and verify citation count badge appears on card
- Deploy if ready
Incremental Delivery
- Phase 1 + Phase 2 → Foundation ready
- Phase 3 (US1) → Citation counts and view tracking live (MVP)
- Phase 4 (US2) → Sort by recommendation signals available
- Phase 5 (US5) → Favorites feature live
- Phase 6 (US3) → Weekly report generation + subscriptions + notifications live
- Phase 7 (US4) → Homepage weekly report widget live
- Phase 8 → E2E tests and final validation
- Phase 9 (US6, added 2026-07-12) → Weekly report citations live
Parallel Team Strategy
After Phase 2 completes:
- Developer A: US1 (Phase 3) → US2 (Phase 4)
- Developer B: US5 (Phase 5) in parallel
- Developer C: US3 (Phase 6) DDD infrastructure + entrypoint
- US4 (Phase 7) starts after US3 completes
Notes
[P]tasks = different files, no blocking dependencies — safe to run in parallel[Story]label maps task to specific user story for traceability- Favorites (
backend/routers/user.pycreated in Phase 5) and Subscriptions (endpoints added in Phase 6) share the same router file — Phase 5 creates it, Phase 6 extends it - Migration 23 is a single revision containing all new tables and the
llm_providers.typecolumn + CheckConstraint — runs viamake migrate - Constitution §II requires Storybook stories for all new feature components (T074, T075)
- Weekly runner entrypoint (T055) deploys as Railway Cron Service:
0 8 * * 1 WeeklyReportPrompt,ImageGenerationPrompt,ArticleSummaryForReport,WeeklyReportentity, and all domain interfaces live inintelligence/domain/— weekly report is NOT a separate bounded context; it is an application of LLM + image generation insideintelligenceBlobStorageServiceinterface (T043) ensures the use case (T047) depends only on domain abstractions, not on R2 directly — required for hexagonal architecture complianceWeeklyReportRepoImpllives insrc/infrastructure/intelligence/repositories/(new subdirectory alongside existingllm/,image/, etc.)user_notification_settings.localecontrols email wrapper language; supported values'en'and'zh-TW'match the app's existing i18n locales- Multimodal provider is NOT seeded in migration 23 — admin must add via LLM provider admin UI;
weekly_main.pyvalidates on startup metric_definitions(unlikellm_providers) IS seeded in migration 23 (T004) — it has no admin UI by design (FR-022), so declarative seeding in the migration is the only way it gets populated (research.md §9d)refresh_metrics.py(T087) deploys as its own Railway Cron Service (T088):0 3 * * *, independent ofweekly_main.py's0 8 * * 1and independent of the backend's view_count flush — three separate schedules/processes, deliberately not sharing a runner (see plan.md Complexity Tracking)ResilientMetricsService/MetricExtractor/JsonPathMetricExtractor(T083–T085) mirror the existingLLMService/ResilientLLMService/ProviderHandlerpattern insrc/infrastructure/intelligence/llm/— no new architectural convention introduced