Implementation Plan: Article Recommendation Signals & Weekly Summary Report
Branch: 014-article-recommendation-weekly-report | Date: 2026-06-26 | Spec: spec.md
Input: Feature specification from /specs/014-article-recommendation-weekly-report/spec.md
Summary
Add article recommendation signals — an extensible, maintainer-curated catalog of academic metrics (citation count now, impact factor/h-index later) refreshed daily via a dedicated cron job, plus view count via Redis — to the existing scrape-analyze pipeline, expose them in the frontend with sort support, and build a weekly LLM-generated summary report system with cover image generation (Gemini Imagen → Cloudflare R2), per-user topic subscriptions, and multi-channel notifications (in-app, email via Resend, Telegram per-user).
2026-07-12 revision: The original single hardcoded citation_count column (populated only at scrape time, never refreshed) is replaced with a normalized metric_definitions (catalog) + article_metric_values (per-article values) design, plus a new recurring refresh_metrics.py cron job independent of the view_count Redis-flush path. See research.md §9b–§9f and data-model.md for the full design. Only the article-metrics slice of this plan changes; weekly reports, subscriptions, notifications, favorites, and the multimodal LLM provider are unaffected.
2026-07-12 addition — User Story 6 (paragraph-level citations, FR-024–FR-029): Weekly report summaries gain inline [N] citation markers pointing to the specific articles a claim draws on, reusing the chat feature's existing citation UX. This also fixes a pre-existing bug where WeeklyReport.article_ids was populated with article title strings instead of real UUIDs, making citation resolution (and any future per-article linkage) impossible. See Phase K below; no new bounded context or service.
2026-07-12 addition — User Story 7 (pin report into chat, FR-030–FR-034): The weekly report widget gains a report-level pin control that bulk-adds the report's cited articles (Feature 1's sources) into the existing shared usePinnedArticle context. The homepage's InlineQABarWrapper — which currently has zero pinning support, unlike the separate FloatingChatbotWrapper — gains the same X-Pinned-Article-Ids header forwarding and a visible pinned-chip row. No backend or chatbot-plugin RAG changes: pinned-article retrieval is already implemented as a filtered vector search keyed by article id (ChatService._fetch_pinned_chunks()), and this feature reuses it unchanged. See Phase L below.
2026-07-12 addition — User Story 8 & 9 (generalized metric display + admin enable/disable, FR-036–FR-042): The article card, detail dialog, and sort control still hardcode a single citation_count field end-to-end even though the metric catalog itself was already generalized in the original rework — this closes that gap so any catalog metric automatically appears in all three UI surfaces with zero further code changes, plus lets administrators toggle which metrics are active (a narrow, explicit amendment to FR-022 — see FR-041). Follows the existing llm_providers admin-catalog pattern (backend/routers/llm_providers.py, frontend/app/admin/llm-providers/page.tsx) for the new admin surface. See Phase M below.
2026-07-14 addition — User Story 10 (weekly report chat-pin UX refinements, FR-043–FR-051): Phase L's report-level pin control turned out to dump one pill per cited article into InlineQABarWrapper, which doesn't scale past a couple of sources. Redesigns pinning as one editable "batch" pill per report (new frontend-only PinnedGroup state on PinnedArticleProvider), adds drag-and-drop from the widget's source pills into the chat input (@dnd-kit/core, already a dependency, used elsewhere via useDraggable/useDroppable), moves the pinned-pills row below the chat input, collapses the source pill list by default, and fixes an unrelated stepper bug where the date picker drifts once a topic has many weekly reports. Purely additive frontend work — no backend, migration, or RAG changes. See Phase O below.
Technical Context
Language/Version: Python 3.11 (backend/scraper), TypeScript/React 19 (frontend)
Primary Dependencies:
- Existing: FastAPI, SQLAlchemy 2, Alembic, redis-py, structlog, google-generativeai, NextAuth v4, Shadcn/UI, Tailwind CSS v4
- New:
boto3(Cloudflare R2 via S3-compatible API),resend(email notifications),google-genai(Imagen 3),jmespath(declarative metric-value extraction — see research.md §9c)
Storage: PostgreSQL 15 + pgvector (existing), Redis (existing, already in docker-compose), Cloudflare R2 (new — blob storage for weekly report cover images)
Testing: pytest (unit + integration), Vitest + Playwright (frontend)
Target Platform: Railway (CD), Docker Compose (local dev)
Project Type: Web service (FastAPI backend + Next.js frontend + scraper service)
Performance Goals: View count increment <10ms (Redis write); article list sort <500ms p95 (SQL JOIN on indexed columns); weekly report generation <5 min per topic (LLM + image gen)
Constraints:
- Must follow hexagonal DDD architecture (Constitution §I)
- Redis already deployed; no new infrastructure beyond R2
- All tests must run inside Docker (Constitution §III)
- Image generation deferred gracefully if R2/Imagen unavailable (cover_image_url = null)
- Metric extraction MUST NOT execute arbitrary stored code (FR-023) — declarative JMESPath or a fixed in-code registry only
- Metric catalog (
metric_definitions) MUST NOT be editable via any runtime/admin API (FR-022) — migration-only
Scale/Scope:
- ~1,000 articles per topic per week (existing scrape volume)
- 1 Alembic migration (23) covering all new tables + model changes
- 7 new DB tables (
article_metrics,metric_definitions,article_metric_values,weekly_reports,user_topic_subscriptions,user_notification_settings,user_article_favorites), 2 expression indexes onarticles.metadata, 1 modified model (llm_providers) - 1 new scraper module (
weekly_report), 2 new entrypoints (weekly_main.py,refresh_metrics.py) - ~8 new backend endpoints, ~5 new frontend components (no new frontend surface from the metrics-catalog rework itself — same
citation_countfield shape, different backend sourcing)
Constitution Check
GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.
| Principle | Status | Notes |
|---|---|---|
| §I DDD — hexagonal architecture | ✅ PASS | Weekly report artifacts added inside existing intelligence bounded context — no new top-level module. ArticleMetrics domain signals integrated into collection module via ScrapedArticle value object extension. New MetricExtractor domain interface + ResilientMetricsService/JsonPathMetricExtractor infrastructure impl also live in collection (domain/infrastructure split maintained), mirroring the existing LLMService/ResilientLLMService pattern rather than inventing a new convention. |
| §II Atomic Frontend — component hierarchy | ✅ PASS | WeeklyReportWidget goes in components/features/weekly-report/. All Storybook stories required. Sort control added to existing filter-bar.tsx (no new common component). |
| §III Test Discipline — mandatory tests | ✅ PASS | Test tasks included for all layers: unit tests for weekly_report use case and image service, integration tests for new endpoints, E2E for sort and weekly report widget. |
| §IV Docker-first — service architecture | ✅ PASS | Weekly runner uses existing Docker service (app). New Railway Cron Service uses same image. boto3 and resend added to pyproject.toml. |
| §V CI-only deployment | ✅ PASS | New Alembic migrations (18–21) auto-run via existing CI migrate job on push to master. |
| §VI Observability | ✅ PASS | Weekly report runner uses structlog. New backend endpoints emit OTel spans. Image upload includes structured logging. |
| §VIII UML conventions | ✅ PASS | New weekly_report module follows src/modules/weekly_report/ structure. Events end in Event. Handler exposes handle(). |
| §IX FastAPI microservice structure | ✅ PASS | No new microservices. Backend router additions follow existing backend/routers/ pattern. |
Post-design re-check: ✅ All gates pass. Weekly report is placed inside the intelligence bounded context (LLM + image generation is its core purpose). Cross-context data access (reading Article/Analysis/Tag entities) is via a read-only WeeklyReportRepository interface in intelligence/domain/repositories/ — implementations query the DB directly without importing collection domain types. Metric extraction (MetricExtractor, ResilientMetricsService) stays inside collection (it operates on Article/ScrapedArticle, not a separate concern) and follows the same domain-interface/infrastructure-impl split already established by LLMService/ResilientLLMService — no new architectural pattern introduced.
Re-check for 2026-07-12 citation addition: ✅ All gates still pass. §I DDD — citation logic is entirely prompt/value-object/use-case changes inside the existing intelligence module; no new domain artifact type. §II Atomic Frontend — the new CitedContent component is extracted from existing components/features/chat/AnswerDisplay.tsx into the same chat/ feature directory (not a new top-level feature), consumed by both chat/ and weekly-report/; this is a reuse-first refactor, exactly what §II's "Reuse first" rule asks for. §III Test Discipline — new unit tests required for the prompt's citation instruction, the article_ids UUID fix, the translation citation-preservation fallback, and the sources resolution/skip-invalid-UUID logic; frontend unit test for the extracted CitedContent component. No new service, no new migration beyond what's already covered by weekly_reports.article_ids (column type unchanged — still JSONB, only the values stored change).
Re-check for 2026-07-12 pin-into-chat addition (US7): ✅ All gates still pass. §I DDD — no src/ changes at all; this is entirely a frontend wiring feature reusing an existing backend contract (pinned_article_ids) unchanged. §II Atomic Frontend — reuses the existing usePinnedArticle provider and PinnedArticle type rather than inventing a parallel pinning mechanism (reuse-first); the weekly report's pin control follows the same icon/toggle pattern as article-card.tsx's existing Sparkles pin button. §III Test Discipline — unit tests required for the provider's new bulk pin/unpin helper, the widget's pin-control toggle state, and InlineQABarWrapper's header forwarding. No new service, no new migration, no chatbot-plugin changes.
Re-check for 2026-07-12 metric generalization addition (US8/US9): ✅ All gates still pass. §I DDD — no src/ changes; article_metric_values/metric_definitions already live in the collection-adjacent shared schema from the original 014 migration, this only adds one column (icon_name) and generalizes existing backend query/schema code, no new bounded context. §II Atomic Frontend — new admin page (frontend/app/admin/metric-definitions/page.tsx) explicitly follows the existing llm-providers admin page's conventions (card list, Switch toggle, require_admin-gated, Storybook story required); article-card.tsx/article-detail-dialog.tsx/sort-select.tsx are extended, not replaced. §III Test Discipline — unit + integration tests required for the generalized sort logic, the new public/admin endpoints, the enabled-toggle permission boundary (non-admin denied), and the frontend badge-list rendering. §IX FastAPI Microservice Structure — new router follows existing backend/routers/ + backend/services/ split, mirroring llm_providers.py.
Complexity Tracking
| Aspect | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
Separate article_metrics table | Different scrapers provide different signals; keeps articles hot-path clean | Adding nullable columns to articles would widen a critical table and make sort queries require COALESCE everywhere |
| Redis view count with dedup | High-throughput write-path for view tracking | Direct PostgreSQL UPDATE on every view would serialize under concurrent load |
New weekly_report bounded context | Weekly report has its own lifecycle (pending/completed/failed), distinct from article collection | Placing in collection module would violate single-responsibility; weekly reports are not scraped articles |
| Cloudflare R2 (external blob storage) | Railway has no native S3; weekly report images are 1-4MB blobs unsuitable for PostgreSQL | Base64 in PostgreSQL is not production-appropriate for binary assets |
| 3-channel notification (in-app + email + Telegram) | User explicitly requested all three | In-app only would miss users who don't visit; email alone misses Telegram preference |
Separate metric_definitions + article_metric_values tables instead of a citation_count column or a metrics JSONB blob | New academic signals (impact factor, h-index, etc.) will be added over time; a hardcoded column per metric doesn't scale (every addition = migration + scraper code change), and a JSONB blob can't be indexed per-key for sort/ranking queries | A metrics JSONB column on article_metrics was considered and rejected — un-indexable per key without generated columns, and mixes backend-owned usage signals with src-owned academic signals in one table (see research.md §9b) |
metric_definitions is migration-only, no admin/dashboard UI (FR-022) | Letting deployment admins define arbitrary provider-response field mappings was evaluated and rejected as a self-service dashboard feature — the UX cost (surfacing each provider's response shape, building a mapping editor) outweighed the low frequency of "add a new metric" as an operation | A full self-service dashboard (admin defines new metrics + extraction rules at runtime) was the alternative; rejected for UX cost and because extraction-as-stored-code (the only way to make it fully generic) is a code-execution risk (FR-023) |
refresh_metrics.py as a separate cron job/entrypoint, not reusing weekly_main.py or the view_count flush | Citation refresh has different data sources (external academic APIs) and cadence (daily) than both the weekly report (weekly, LLM-driven) and view_count (Redis, near-real-time); forcing them to share a runner would couple unrelated failure domains | Extending weekly_main.py to also refresh metrics was considered — rejected because a citation-fetch failure would then also risk blocking/delaying report generation, and the schedules (daily vs weekly) don't naturally coincide |
Position-based [N] citations (LLM cites list position) instead of LLM-supplied article IDs | Avoids fabricated/hallucinated references — the LLM only ever sees a plain number, never an identifier it could get wrong; the use case is the sole source of truth mapping N → real UUID, matching the same pattern already proven in the chat feature | Asking the LLM to echo back an article ID or title per citation was considered — rejected because LLM output would need validation/repair logic for malformed or hallucinated IDs, adding failure modes with no benefit over position-based reference |
Extracting CitedContent out of AnswerDisplay.tsx into a shared component rather than duplicating the parsing logic in weekly-report-widget.tsx | Two independent implementations of [N] parsing + citation-chip rendering would drift out of sync over time (e.g. one gets a bugfix, the other doesn't); §II's reuse-first rule applies directly | Copy-pasting parseInline/renderMarkdown into the weekly report widget was considered — rejected as the kind of duplication the constitution explicitly asks reviewers to avoid |
Reusing the existing usePinnedArticle context + X-Pinned-Article-Ids header mechanism for US7 instead of a report-specific pinning path | The floating chatbot already proves this mechanism works end-to-end (frontend state → header → backend forwarding → filtered RAG retrieval); building a second pinning path for "report articles" specifically would duplicate all of that for no behavioral difference | A dedicated pinned_report_id concept (pin the whole report as one unit, resolved server-side) was considered — rejected because it would require a new backend contract and a new chatbot-plugin code path when the existing per-article-id mechanism already does exactly what's needed once fed the report's article ids |
| No forced re-ingestion / readiness check for unindexed cited articles before pinning (US7) | Pinning an unindexed article already degrades gracefully today (empty retrieval, no error); adding a synchronous "wait for ingestion" or "warn the user" step would add latency/complexity for a case that's already rare in practice (indexing happens earlier in the pipeline than weekly report generation) and not required by any acceptance scenario | A pre-flight check that calls the ingestion pipeline for any unindexed cited article before allowing pin was considered — rejected as scope creep beyond what US7's acceptance scenarios ask for; revisit only if real usage shows this matters |
Narrow amendment to FR-022 (admin may toggle enabled only, not define metrics) instead of leaving it fully migration-only or fully opening the catalog to admin editing | The original FR-022 rejection was specifically about admins defining arbitrary extraction/field mappings — a real RCE/UX-cost concern (FR-023) that doesn't apply to flipping a boolean on an already-vetted entry; leaving enabled migration-only forces a deployment for a config change comparable in kind to toggling an llm_providers.is_active flag, which is already admin-dashboard-editable today | Two alternatives considered: (a) keep FR-022 fully migration-only, including enabled — rejected as an inconsistent double standard next to the existing llm_providers admin UI; (b) open the whole catalog row (incl. extraction config) to admin editing — rejected, reintroduces exactly the risk/UX-cost FR-022/FR-023 were written to avoid |
icon_name stored on metric_definitions (duplicated across a metric_key's provider rows) rather than a new metric_key-level table | Superseded same day (Phase N) — see the two rows below. Follows the exact convention label_i18n_key/format_hint/unit already use on this same per-provider-row table — introducing a second table just for display fields would be a bigger schema change for a problem the codebase already accepted the tradeoff for | Splitting into a metric_key-level table (display fields) + a metric_key + provider-level table (extraction fields) was considered — rejected as disproportionate: it would require migrating three already-shipped columns, not just adding one, for a duplication risk that's already maintainer-review-mitigated in practice |
metric_definitions/metric_providers split (Phase N, reverses the row above) | Once icon_name/enabled became admin-editable (not just enabled), the "duplication risk is maintainer-review-mitigated" argument stopped applying — an admin editing icon_name on one provider row would silently desync it from the metric_key's other provider rows, a bug the previous design invited. Splitting so metric_definitions is genuinely one row per metric_key removes the possibility of desync entirely, and lets the admin page/API stop exposing provider/priority at all | Keeping one table and just writing icon_name/enabled to every provider row on every admin edit was considered — rejected as accidental complexity (every write becomes a multi-row UPDATE ... WHERE metric_key = ...) papering over what should just be a foreign key |
metric_providers stays a DB table rather than moving to a plain Python constant, even though it's never admin-edited | Discussed directly and kept as-is: a maintainer adding a metric_key sourced from an already-registered provider (its fetcher already exists in build_provider_fetchers()) can do it via a migration INSERT alone, no .py file to touch — narrower benefit than it first appears (a genuinely new external API always needs a new fetcher function regardless), but real for the common case | Moving metric_providers to a Python dict/list constant (mirroring CODE_EXTRACTOR_REGISTRY's existing pattern in the same file) was proposed and left as a live option for a future simplification pass — not done now because the migration-only cost, for a single maintainer, was judged roughly a wash against the join/FK overhead of keeping it in DB; revisit if the extra table ever feels like more trouble than it's worth |
| Icon picker constrained to a fixed ~20-name whitelist rather than lucide-react's full icon catalog | Confirmed against lucide-react's own docs: its DynamicIcon/dynamicIconImports mechanism for load-by-name icon pickers is explicitly not recommended by the maintainers ("imports all icons during the build"); a small statically-imported whitelist has zero extra bundle cost since the icons are already imported, and matches the existing maintainer-curates-the-option-set governance already used for the rest of the catalog | DynamicIcon from lucide-react/dynamic was evaluated for a "browse all ~1500 icons" admin picker — rejected per lucide's own guidance, and because a handful of curated options each maintainer already imports when adding a metric is enough for actual recommendation-signal icons (citations, views, awards, etc.) |
semantic_scholar_arxiv as a distinct metric_providers row rather than making the existing semantic_scholar fetcher accept either identifier | Keeps priority meaningful as an explicit, inspectable fallback order (DOI via OpenAlex → DOI via Semantic Scholar → arXiv ID via Semantic Scholar) rather than hiding a secondary lookup strategy inside one fetcher function; also mirrors that openalex's fetcher deliberately has no arXiv equivalent (OpenAlex's API doesn't support it) — the catalog should reflect real per-provider capability, not paper over it | Making build_provider_fetchers()["semantic_scholar"] internally try DOI-then-arXiv in one callable was considered — rejected because it would silently conflate two different lookup strategies under one priority value, losing the ability to reason about (or independently disable) either |
Project Structure
Documentation (this feature)
specs/014-article-recommendation-weekly-report/
├── plan.md # This file
├── research.md # Phase 0 research decisions
├── data-model.md # Entity definitions and SQL schemas
├── quickstart.md # Dev setup and manual trigger guide
├── contracts/
│ └── api.md # REST endpoint contracts
└── tasks.md # Phase 2 output (speckit-tasks command)Source Code (repository root)
# Backend (FastAPI)
backend/
├── routers/
│ ├── articles.py # extend: citation_count/view_count in ArticleOut, POST /articles/{id}/view, sort by new fields; 2026-07-12: sort generalized to any enabled metric_key (US8)
│ ├── weekly_reports.py # new: GET /weekly-reports, GET /weekly-reports/latest; 2026-07-12: _to_out() resolves `sources` from article_ids
│ ├── metric_definitions.py # 2026-07-12 new (US8/US9): GET /metric-definitions (public, enabled+deduped display metadata), GET /admin/metric-definitions (admin, all rows), PATCH /admin/metric-definitions/{id} (admin, enabled only)
│ └── user.py # new: GET|PUT /user/notification-settings, GET|POST|DELETE /user/subscriptions/{topic_id}
├── schemas/
│ ├── article.py # extend ArticleOut + ArticleDetailOut with citation_count, view_count; 2026-07-12: citation_count → metrics: Dict[str, float] (US8)
│ ├── weekly_report.py # new: WeeklyReportOut; 2026-07-12: + ArticleSourceOut, WeeklyReportOut.sources
│ └── metric_definition.py # 2026-07-12 new (US8/US9): MetricDefinitionDisplayOut (public); Phase N same day: MetricDefinitionAdminOut drops provider_name/priority, MetricDefinitionAdminUpdate (enabled + icon_name, ICON_WHITELIST-validated) replaces MetricDefinitionEnabledUpdate
└── services/
├── article_service.py # extend: JOIN article_metrics (view_count) + article_metric_values (citation_count, filtered metric_key='citation_count') for sort + output; view count flush logic unchanged; 2026-07-12: build_article_out() emits generic metrics map, get_articles_paginated() sort generalized (US8)
├── weekly_report_service.py # new: get_weekly_reports, get_latest_weekly_report
└── metric_definition_service.py # 2026-07-12 new (US8/US9): get_enabled_metric_display, get_all_metric_definitions; Phase N same day: update_metric_definition(enabled, icon_name) replaces set_metric_definition_enabled
# Scraper service (DDD)
src/
├── modules/
│ ├── collection/
│ │ └── domain/
│ │ ├── value_objects/scraped_article.py # revise: citation_count field → metric_seeds: Dict[str, Any]
│ │ ├── repositories/article_metrics_repository.py # revise: upsert(article_id, citation_count) → upsert(article_id, metrics: dict)
│ │ └── services/metric_extractor.py # new: MetricExtractor domain interface (fetch/extract)
│ └── intelligence/ # weekly report lives here, not a separate bounded context
│ ├── domain/
│ │ ├── entities/
│ │ │ └── weekly_report.py # new
│ │ ├── repositories/
│ │ │ └── weekly_report_repository.py # new: interface
│ │ ├── services/
│ │ │ ├── image_generation_service.py # new: interface
│ │ │ └── blob_storage_service.py # new: interface (R2 impl in infrastructure)
│ │ └── value_objects/
│ │ ├── article_summary_for_report.py # new: per-article prompt input DTO; 2026-07-12: + article_id field
│ │ ├── weekly_report_prompt.py # new: extends BasePrompt; 2026-07-12: numbered article list + [N] citation instruction
│ │ └── image_generation_prompt.py # new: extends BasePrompt
│ │ # WeeklyReportTranslationPrompt lives in translation_prompt.py (alongside ArticleTranslationPrompt etc., pre-existing file) — 2026-07-12: + instruction to preserve [N] markers verbatim
│ └── application/
│ └── use_cases/
│ └── generate_weekly_report.py # new; 2026-07-12: fix article_ids bug (was titles, now real UUIDs, citation-order-aligned); _translate_report() validates translated [N] markers match original, falls back to English summary_text on mismatch
├── infrastructure/
│ ├── collection/
│ │ ├── scrapers/
│ │ │ ├── openalex_scraper.py # extend: populate metric_seeds={"citation_count": ...} on ScrapedArticle
│ │ │ └── semantic_scholar_scraper.py # extend: same, metric_seeds
│ │ ├── clients/
│ │ │ ├── openalex_client.py # new method: fetch_by_doi(doi) -> Optional[dict] (raw JSON, for refresh job)
│ │ │ └── semantic_scholar_client.py # new method: fetch_by_doi(doi) -> Optional[dict]; Phase N same day: + fetch_by_arxiv_id(arxiv_id) -> Optional[dict]
│ │ └── metrics/ # new subpackage
│ │ ├── json_path_extractor.py # new: JsonPathMetricExtractor (jmespath-based, generic)
│ │ └── resilient_metrics_service.py # new: ResilientMetricsService (mirrors ResilientLLMService); Phase N same day: build_provider_fetchers() + "semantic_scholar_arxiv" entry
│ ├── persistence/
│ │ └── collection/
│ │ └── article_metrics_repo_impl.py # revise: upsert() writes N rows to article_metric_values instead of 1 column
│ ├── intelligence/
│ │ ├── image/
│ │ │ ├── base_image_provider.py # new
│ │ │ └── gemini_imagen_provider.py # new
│ │ └── repositories/
│ │ └── weekly_report_repo_impl.py # new; 2026-07-12: fetch_top_articles() additionally SELECTs Article.id → ArticleSummaryForReport.article_id
│ └── storage/
│ └── r2_blob_storage.py # new
└── entrypoints/
└── cli/
├── weekly_main.py # new: weekly runner entrypoint (validates multimodal provider on startup)
└── refresh_metrics.py # new: daily metric-refresh runner — queries stale article_metric_values, runs ResilientMetricsService, upserts
# Shared (importable by both src/ and backend/, no src. prefix — see shared/llm_provider.py for the established pattern)
shared/
└── metric_definition.py # new: load_enabled_metric_definitions(session) -> List[Dict[str, Any]], mirrors load_active_providers()
# ORM Models (shared)
models/
├── article_metrics.py # revise: remove citation_count column, keep view_count only
├── metric_definition.py # new; 2026-07-12: + icon_name column (US8/US9); Phase N same day: rewritten to metric-key-only shape (provider_name/priority/extractor_type/extractor_spec moved out)
├── metric_provider.py # Phase N new (2026-07-12, same day): provider_name, priority, extractor_type, extractor_spec, FK metric_definition_id
├── article_metric_value.py # new
├── weekly_report.py # new
└── user_subscription.py # new: UserTopicSubscription + UserNotificationSettings + UserArticleFavorite
# Alembic migrations
alembic/versions/
└── 23_article_recommendation_weekly_report.py # all new tables (incl. metric_definitions + article_metric_values + seed data + articles.metadata expression indexes) + llm_provider type column; 2026-07-12 (US8/US9): edited in place to add metric_definitions.icon_name + seed values; Phase N same day: edited in place again — metric_definitions split into metric_definitions (metric-key-level) + new metric_providers table, seed restructured with 3 providers incl. semantic_scholar_arxiv — still no follow-up revision, still unshipped
# Frontend
frontend/
├── app/
│ ├── page.tsx # extend: add WeeklyReportWidget above InlineQABarWrapper
│ └── admin/
│ └── metric-definitions/
│ └── page.tsx # 2026-07-12 new (US9): list all metric_definitions rows grouped by metric_key, enabled Switch toggle per row, require_admin-gated — mirrors admin/llm-providers/page.tsx's card-list + Switch conventions (read-only otherwise, no create/edit/delete/reorder)
├── components/
│ └── features/
│ ├── articles/
│ │ ├── article-card.tsx # extend: heart icon (left of title), citation_count badge, view_count, fire view event; 2026-07-12: citation-only badge → generic loop over article.metrics using fetched display metadata, default icon fallback (US8)
│ │ ├── article-detail-dialog.tsx # extend: citation_count + view_count display; 2026-07-12: same generalization as article-card.tsx (US8)
│ │ ├── sort-select.tsx # 2026-07-12: SORT_OPTIONS fixed fields unchanged; dynamically appends one option per enabled catalog metric fetched from GET /metric-definitions (US8)
│ │ └── filter-bar.tsx # extend: sort dropdown on right + Favorites toggle
│ ├── chat/
│ │ ├── cited-content.tsx # 2026-07-12 new: <CitedContent text sources /> extracted from AnswerDisplay.tsx (parseInline, renderMarkdown, source-chip list, ArticleDetailDialog-open-on-click)
│ │ ├── AnswerDisplay.tsx # 2026-07-12: refactored to render via CitedContent instead of inline logic
│ │ └── InlineQABarWrapper.tsx # 2026-07-12 (US7): + usePinnedArticle() wiring, X-Pinned-Article-Ids header, pinned-chip row (mirrors FloatingChatbotWrapper.tsx's existing pattern)
│ └── weekly-report/ # new feature directory
│ ├── weekly-report-widget.tsx # 2026-07-12: render selected.summary_text via <CitedContent text sources={selected.sources} /> instead of manual splitParagraphs; + report-level pin control (US7)
│ ├── weekly-report-skeleton.tsx
│ └── weekly-report-widget.stories.tsx # required by Constitution §II
└── lib/
├── providers/
│ └── pinned-article-provider.tsx # 2026-07-12 (US7): + bulk pin/unpin helper for "pin all of this report's articles"
└── api/
├── articles.ts # extend: recordArticleView(), update types (citation_count, view_count, is_favorited); 2026-07-12: citation_count → metrics: Record<string, number> (US8)
├── weekly-reports.ts # new; 2026-07-12: WeeklyReport type + sources: ArticleSource[]
├── metric-definitions.ts # 2026-07-12 new (US8/US9): fetchEnabledMetricDefinitions() (public), fetchAllMetricDefinitions()/updateMetricDefinitionEnabled() (admin)
└── user.ts # new or extend: subscriptions, notification settings, favorites (addFavorite, removeFavorite, getFavorites)frontend/app/settings/layout.tsx's admin tab list (2026-07-12, US9): add a /admin/metric-definitions → admin.metricDefinitions entry alongside the existing five tabs.
Structure Decision: Web application (Option 2). Feature touches all three service layers: src/ (scraper/DDD), backend/ (FastAPI), and frontend/ (Next.js). Weekly report generation is an application of LLM + image generation and belongs inside the existing intelligence bounded context — no new top-level module is created.
Implementation Phases
Phase A: Data Foundation (Migrations + Models)
- Create single Alembic migration
23_article_recommendation_weekly_report.py— all new tables (includingmetric_definitionswith seed data,article_metric_values, twoarticles.metadataexpression indexes) +typecolumn onllm_providers - Create ORM models:
article_metrics.py(view_count only),metric_definition.py,article_metric_value.py,weekly_report.py,user_subscription.py - Extend
LlmProvidermodel to addCheckConstraintfortype IN ('llm', 'embedding', 'multimodal')and fix duplicatetypecolumn definition - Create
shared/metric_definition.py::load_enabled_metric_definitions(session), mirroringshared/llm_provider.py::load_active_providers
Phase B: Article Metrics Collection (opportunistic seed path)
- Revise
ScrapedArticlevalue object:citation_countfield →metric_seeds: Dict[str, Any] - Revise
openalex_scraper.pyandsemantic_scholar_scraper.pyto populatemetric_seeds={"citation_count": ...} - Revise
ArticleMetricsRepository.upsert()signature toupsert(article_id, metrics: dict[str, Any]);SqlAlchemyArticleMetricsRepositorywrites toarticle_metric_values(INSERT ... ON CONFLICT (article_id, metric_key) DO UPDATE) - Revise
ProcessScrapedArticleUseCaseto forwardmetric_seeds(filtered to knownmetric_definitions.metric_keyvalues) to the generalizedupsert() - Extend backend
ArticleOutschema andget_articles_paginatedto JOINarticle_metrics(view_count) +article_metric_values(citation_count viametric_key='citation_count'filter) - Add
citation_countandview_countto sort options inGET /articles(sort now joinsarticle_metric_values, not a flat column)
Phase B2: Recurring Metric Refresh (new)
- Add
fetch_by_doi()(raw-JSON-returning) toOpenAlexClientandSemanticScholarClient— new methods,fetch_papers()unchanged - Create
MetricExtractordomain interface (src/modules/collection/domain/services/metric_extractor.py) - Create
JsonPathMetricExtractor(src/infrastructure/collection/metrics/json_path_extractor.py) usingjmespath - Create
ResilientMetricsService(src/infrastructure/collection/metrics/resilient_metrics_service.py), built at bootstrap fromload_enabled_metric_definitions()— priority-ordered fallback permetric_key, mirrorsResilientLLMService - Wire
build_metrics_refresh_pipeline()insrc/bootstrap.py - Create
src/entrypoints/cli/refresh_metrics.py: query articles with a missing or stale (last_flushed_at < now() - interval '1 day') row for each enabledmetric_key(via thearticles.metadataDOI/arxiv_id expression indexes), callResilientMetricsService.fetch_all(), upsert results - Add Railway Cron Service entry for
refresh_metrics.pyinsrc/railway.toml(daily), reusingsrc/Dockerfile - Update
WeeklyReportRepoImpl's article-selection query andArticleSummaryForReportsourcing to joinarticle_metric_valuesinstead of the oldam.citation_countcolumn
Phase C: View Count Tracking
- Add
POST /articles/{id}/viewbackend endpoint (Redis INCR with IP dedup) - Add admin
POST /admin/articles/flush-view-countsto trigger DB sync - Add background flush task (periodic, configurable interval via env var)
- Frontend: fire
recordArticleView(id)whenArticleDetailDialogopens
Phase D: Frontend Metrics Display + Sort
- Extend
ArticleCardwith citation_count badge and view_count badge - Extend
ArticleDetailDialogwith citation_count and view_count - Extend
FilterBarwith sort dropdown (right side, immediate apply, no draft state) - Update
useArticleshook / articles page to pass sort params to API
Phase E: Weekly Report Infrastructure
- Add weekly report domain artifacts inside existing
intelligencemodule:WeeklyReportentity,WeeklyReportRepositoryinterface,ImageGenerationServiceinterface,BlobStorageServiceinterface,ArticleSummaryForReportvalue object,WeeklyReportPrompt,ImageGenerationPrompt - Create
GeminiImagenProviderimplementingImageGenerationService(src/infrastructure/intelligence/image/) - Create
R2BlobStorageService(src/infrastructure/storage/) - Create
WeeklyReportRepoImpl(src/infrastructure/intelligence/repositories/) - Create
GenerateWeeklyReportUseCase(src/modules/intelligence/application/use_cases/generate_weekly_report.py) - Wire in
src/bootstrap.pyvia newbuild_weekly_pipeline()function - Create
src/entrypoints/cli/weekly_main.py— on startup queries DB for activetype='multimodal'provider; exits with clear error if none found
Phase F: Notification Pipeline
- Extend
user_notification_settingsquery to identify subscribed users per topic - Create
WeeklyReportEmailNotifier(uses Resend SDK) - Create
WeeklyReportTelegramNotifier(parameterized chat_id, reuse request pattern) - Integrate notifications into
GenerateWeeklyReportUseCasepost-generation - Add
providers.tomlentry for Imagen provider
Phase G: Backend API for Reports + Subscriptions
- Create
backend/routers/weekly_reports.py(GET /weekly-reports,GET /weekly-reports/latest) - Create
backend/routers/user.py(subscription + notification settings endpoints) - Create
backend/schemas/weekly_report.py - Register new routers in
backend/main.py
Phase H: Frontend Weekly Report Widget + Homepage
- Create
WeeklyReportWidget,WeeklyReportSkeletoncomponents - Create Storybook stories for both (Constitution §II requirement)
- Update
app/page.tsxto showWeeklyReportWidgetaboveInlineQABarWrapper - Create
frontend/lib/api/weekly-reports.ts
Phase I: Settings UI (Subscriptions + Notification Preferences)
- Add subscription management UI to existing settings page
- Add notification settings form (email toggle, Telegram chat_id input)
- Connect to new API endpoints
Phase J: Tests
- Unit tests:
WeeklyReportUseCase,GeminiImagenProvider,R2BlobStorageService, view count flush - Unit tests:
JsonPathMetricExtractor(jmespath evaluation against fixture responses),ResilientMetricsServicefallback ordering, generalizedArticleMetricsRepository.upsert(),refresh_metrics.pystaleness query - Backend integration tests: new endpoints (weekly reports, subscriptions, view count),
GET /articlessort/citation_count join againstarticle_metric_values - Frontend unit tests:
WeeklyReportWidget, sort inFilterBar - E2E: sort articles by citation_count, weekly report widget display
Phase K: Weekly Report Citations (2026-07-12, User Story 6, FR-024–FR-029)
Goal: Weekly report summaries carry [N] inline citations resolvable to real articles; fixes the article_ids title-string bug as a prerequisite.
- Fix
ArticleSummaryForReport(src/modules/intelligence/domain/value_objects/article_summary_for_report.py): addarticle_id: UUIDfield - Fix
WeeklyReportRepoImpl.fetch_top_articles()(src/infrastructure/persistence/intelligence/weekly_report_repo_impl.py): additionallySELECT Article.id, populatearticle_idon eachArticleSummaryForReport - Update
WeeklyReportPrompt.render()(src/modules/intelligence/domain/value_objects/weekly_report_prompt.py): render articles as a 1-indexed bracketed list; add instruction to cite inline via[N]insummary_text, whereNis list position (not an LLM-supplied ID) - Fix
GenerateWeeklyReportUseCase(src/modules/intelligence/application/use_cases/generate_weekly_report.py, ~line 132):article_ids = [str(a.title) for a in articles]→[str(a.article_id) for a in articles], order preserved - Update
WeeklyReportTranslationPrompt(src/modules/intelligence/domain/value_objects/translation_prompt.py— lives alongside the other translation prompt value objects, not a standalone file): add instruction to preserve[N]markers verbatim during translation - Update
GenerateWeeklyReportUseCase._translate_report()(there is no standaloneTranslateWeeklyReportUseCase— translation is a private method on the same use case): after translatingsummary_text, compare the set of[N]tokens against the original; on mismatch, store the original Englishsummary_textfor that language's row instead of the translated one - Add
ArticleSourceOutschema andsources: List[ArticleSourceOut] = []field toWeeklyReportOut(backend/schemas/weekly_report.py) - Update
_to_out()(backend/routers/weekly_reports.py): resolvesourcesby looking upArticlerows forreport.article_idsin order; wrap each entry'sUUID(...)parse in try/except, skipping unparseable (pre-existing title-string) entries so old reports resolve to an emptysourceslist - Extract
CitedContentcomponent (frontend/components/features/chat/cited-content.tsx) fromAnswerDisplay.tsx'sparseInline/renderMarkdown/source-chip-list/ArticleDetailDialog-open logic; refactorAnswerDisplay.tsxto use it - Add
sources: ArticleSource[]to theWeeklyReporttype (frontend/lib/api/weekly-reports.ts) - Update
weekly-report-widget.tsxto renderselected.summary_textvia<CitedContent text={selected.summary_text} sources={selected.sources} />instead of the manualsplitParagraphs(...).map(p => <p>)block
Tests (Phase K)
- Unit test:
WeeklyReportPrompt.render()produces a numbered article list and the citation instruction - Unit test:
GenerateWeeklyReportUseCasepopulatesarticle_idswith real UUIDs in prompt order (regression test for the title-string bug) - Unit test:
TranslateWeeklyReportUseCasefalls back to the Englishsummary_textwhen translated[N]markers don't match the original - Backend integration test:
GET /weekly-reports/latest(or equivalent) resolvessourcescorrectly for a report with valid UUIDarticle_ids, and returns an emptysourceslist (no error) for a report with pre-existing title-stringarticle_ids - Frontend unit test:
CitedContentrenders[N]as a clickable marker only whenNis withinsourcesrange, and renders out-of-range/malformed markers as literal text - Frontend unit test:
AnswerDisplaystill renders identically after theCitedContentextraction (no behavior regression in chat)
Phase L: Pin Weekly Report into Chat (2026-07-12, User Story 7, FR-030–FR-034)
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 gains the pinning wiring it currently lacks. No backend changes — reuses the existing pinned_article_ids → filtered-retrieval mechanism unchanged.
- Extend
PinnedArticleContextValue(frontend/lib/providers/pinned-article-provider.tsx) withpinArticles(articles: PinnedArticle[])(adds any not already present) andareAllPinned(ids: string[])(helper for the widget's toggle state); keep existing per-article API (togglePinnedArticle,removePinnedArticle,clearPinnedArticles,isPinned) unchanged - 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(selected.sources.map(...))when not fully pinned, or removes each ofselected.sources' ids when fully pinned - Wire
usePinnedArticle()intofrontend/components/features/chat/InlineQABarWrapper.tsx: build theX-Pinned-Article-Idsheader frompinnedArticlesexactly asFloatingChatbotWrapper.tsxalready does; render a compact pinned-chip row above theAgentInputshowing each pinned article's title with a per-chip remove action
Tests (Phase L)
- Unit test:
pinArticles()adds only the articles not already present (no duplicates);areAllPinned()returns true only when every given id is present - Frontend unit test: the weekly report widget's pin control is hidden when
sourcesis empty, pins all cited articles when none/some are pinned, and unpins all of them when all are already pinned - Frontend unit test:
InlineQABarWrapperincludesX-Pinned-Article-Idsin the chat request headers when articles are pinned, and omits it when none are pinned
Phase M: Generalized Metric Display + Admin Enable/Disable (2026-07-12, User Story 8 & 9, FR-036–FR-042)
Superseded same day by Phase N below — steps 1, 3–5, 8, 13 here describe a single metric_definitions table (provider_name/priority alongside display config, only enabled admin-editable) that was replaced before Phase M finished being reviewed. Kept for history; see Phase N for what actually shipped.
Goal: Any catalog metric (not just citation_count) 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 or display configuration.
- Edit migration
23_article_recommendation_weekly_report.pyin place (still unshipped to production as of 2026-07-12 — same rationale as the earlier citation_count/metric_definitions rework, do not add a follow-up revision): add nullableicon_name VARCHAR(50)column to themetric_definitionscreate_table(); addicon_nameto the seedINSERTfor the existingcitation_countrows (openalex/semantic_scholar, e.g.'quote') - Add
icon_name = Column(String(50), nullable=True)tomodels/metric_definition.py - Create
backend/schemas/metric_definition.py:MetricDefinitionDisplayOut(metric_key,label_i18n_key,icon_name,format_hint,unit— public shape, no provider/extraction fields),MetricDefinitionAdminOut(addsid,provider_name,priority,enabled),MetricDefinitionEnabledUpdate(enabled: bool— the only admin-editable field) - Create
backend/services/metric_definition_service.py:get_enabled_metric_display(db)(queryenabled=True, dedupe bymetric_keyordered bypriority, returnMetricDefinitionDisplayOutlist),get_all_metric_definitions(db)(all rows, for the admin page),set_metric_definition_enabled(db, id, enabled)(updates one row by id,enabledonly — no other field accepted) - Create
backend/routers/metric_definitions.py:GET /metric-definitions(public, callsget_enabled_metric_display),GET /admin/metric-definitions(require_admin, callsget_all_metric_definitions),PATCH /admin/metric-definitions/{id}(require_admin, callsset_metric_definition_enabled); register inbackend/main.py - Edit
backend/schemas/article.py: replacecitation_count: Optional[int] = NoneonArticleOut/ArticleDetailOutwithmetrics: Dict[str, float] = {} - Edit
backend/services/article_service.py::build_article_out()andget_articles_paginated(): fetch every non-NULLarticle_metric_valuesrow for the page's article ids (same two-query pattern asWeeklyReportRepoImpl.fetch_top_articles()from Phase K) and populateArticleOut.metrics; generalize theif sort in ("citation_count", "view_count")branch so anysortvalue matching an enabledmetric_definitions.metric_keyuses the same outerjoin+nullslast ordering pattern, keyed by thatsortvalue instead of a hardcoded string - Create
frontend/lib/api/metric-definitions.ts:fetchEnabledMetricDefinitions()(public),fetchAllMetricDefinitions()/updateMetricDefinitionEnabled(id, enabled)(admin, under/api/proxy/admin/metric-definitions) - Update
frontend/lib/api/articles.ts'sArticle/ArticleDetailtypes:citation_count?: number | null→metrics: Record<string, number> - Create a small icon lookup, e.g.
frontend/components/features/articles/metric-icons.tsexportingRecord<string, LucideIcon>(whitelisted names only) + a default fallback icon (e.g.BarChart3) - Edit
article-card.tsxandarticle-detail-dialog.tsx: replace the hardcodedcitation_count > 0 && <Quote>badge with a loop overObject.entries(article.metrics), resolving eachmetric_key's icon/label via a fetchedfetchEnabledMetricDefinitions()list (cached at a level shared by all cards on the page, e.g. a small hook or the existing articles page-level fetch, not one fetch per card) - Edit
sort-select.tsx: fetchfetchEnabledMetricDefinitions()once, append oneSORT_OPTIONSentry per returned metric (value: metric_key,labelKey: label_i18n_key) after the fixed fields - Create
frontend/app/admin/metric-definitions/page.tsx: fetchfetchAllMetricDefinitions(), render one card per row grouped bymetric_key(mirroringadmin/llm-providers/page.tsx'sAccordionSection+ card pattern), each with aSwitchbound toenabledcallingupdateMetricDefinitionEnabled()optimistically with rollback on failure — no create/edit/delete/reorder controls - Add the new tab to
frontend/app/settings/layout.tsx's admin nav list; addadmin.metricDefinitions(and any other new labels) toen.json/zh-TW.json
Tests (Phase M)
- Backend unit/integration test:
GET /metric-definitionsreturns onlyenabled=truerows, deduplicated bymetric_key, withoutprovider_name/extractor_specin the response - Backend integration test:
GET /admin/metric-definitionsandPATCH /admin/metric-definitions/{id}both return 401/403 for a non-admin caller (FR-042's access boundary, US9 acceptance scenario 4) - Backend integration test:
PATCH /admin/metric-definitions/{id}updates onlyenabled; a request body containing other fields (e.g.extractor_spec) either is ignored or rejected, never applied - Backend integration test:
GET /articlesreturns ametricsmap with entries for every catalog metric the article has a value for (not just citation_count), and sorting by an enabled metric_key orders correctly with nulls-last regardless of direction - Frontend unit test:
article-card.tsxrenders one badge permetricsentry with the correct icon/label from a mockedfetchEnabledMetricDefinitions(), and falls back to the default icon when a metric'sicon_nameis null - Frontend unit test:
sort-select.tsxincludes a dynamically-fetched metric option alongside the fixed fields - Frontend unit test:
admin/metric-definitions/page.tsxtoggles aSwitch, callsupdateMetricDefinitionEnabled(), and rolls back the UI state if the call fails
Phase N: Metric/Provider Table Split + arXiv Citation Coverage (2026-07-12, same day, supersedes Phase M's steps 1/3–5/8/13)
Why: Two issues surfaced discussing Phase M before it was considered done. (1) The admin page ended up showing provider_name/priority per row because metric_definitions conflated admin-facing display config with maintainer-only extraction config in one (metric_key, provider_name)-keyed table — admins should only ever see/edit one row per metric_key (enabled + icon), never provider/priority. (2) refresh_metrics.py's two provider fetchers (openalex, semantic_scholar) both only ever used ids["doi"], silently ignoring ids["arxiv_id"] even though the stale-articles query and articles.metadata expression indexes already support arXiv-only articles — meaning arXiv preprints with no DOI got zero citation refresh. Root-caused during discussion: article.source (how an article was scraped) is unrelated to which external database can supply its citation count — that's determined by which identifiers (DOI/arXiv ID) the article carries, not by scrape provenance; a same-underlying-paper-scraped-from-multiple-sources dedup gap was also identified but is out of scope here (tracked separately).
Goal: Admin page shows one row per metric_key with zero extraction-plumbing leakage; arXiv-only articles can get citation_count refreshed via Semantic Scholar (the only one of the two providers whose API accepts an arXiv ID).
- Edit migration
23_article_recommendation_weekly_report.pyin place again (still unshipped):metric_definitionsbecomes metric_key-level only — dropprovider_name/priority/extractor_type/extractor_spec, keepmetric_key(nowUNIQUEby itself),label_i18n_key,format_hint,unit,icon_name,enabled. Newmetric_providerstable:metric_definition_id(FK →metric_definitions.id,ON DELETE CASCADE),provider_name,priority,extractor_type,extractor_spec;UNIQUE(metric_definition_id, provider_name). Seed: onemetric_definitionsrow forcitation_count, threemetric_providersrows —openalex(priority 1, DOI),semantic_scholar(priority 2, DOI),semantic_scholar_arxiv(priority 3, arXiv ID — new).downgrade()dropsmetric_providersbeforemetric_definitions(FK). Local Postgres brought in sync via a hand-run equivalentDROP TABLE/CREATE TABLE/re-seed (not a full alembic downgrade/upgrade, to avoid touching unrelated local data) — same in-place-edit rationale as every other touch of migration 23 this feature. - Rewrite
models/metric_definition.pyto the new metric-key-only shape; addmodels/metric_provider.py(new); register both inmodels/__init__.py - Rewrite
shared/metric_definition.py::load_enabled_metric_definitions()toJOIN metric_definitions(filteredenabled=True) withmetric_providers, still returning the same flat{metric_key, provider_name, priority, extractor_type, extractor_spec}dict shape —resilient_metrics_service.pyneeds zero changes as a result - Add
SemanticScholarClient.fetch_by_arxiv_id(arxiv_id)(src/infrastructure/collection/clients/semantic_scholar_client.py) — same shape asfetch_by_doi(), hitspaper/ARXIV:<id>(confirmed against Semantic Scholar's public API docs); add a"semantic_scholar_arxiv"entry tobuild_provider_fetchers()(resilient_metrics_service.py) calling it only whenids.get("arxiv_id"). OpenAlex gets no equivalent — confirmed against OpenAlex's docs that single-item lookup only accepts DOI/PMID/PMCID/MAG ID, no arXiv ID - Rewrite
backend/schemas/metric_definition.py:MetricDefinitionDisplayOutunchanged (public shape was already metric-key-level);MetricDefinitionAdminOutdropsprovider_name/priority;MetricDefinitionAdminUpdate(renamed fromMetricDefinitionEnabledUpdate) acceptsenabled: Optional[bool]ANDicon_name: Optional[str], the latter validated by a Pydanticfield_validatoragainstICON_WHITELIST(module-level constant, kept in sync with the frontend whitelist — see step 8) - Rewrite
backend/services/metric_definition_service.py:get_all_metric_definitions()now a plainmetric_definitionsquery (no join needed for the admin list);update_metric_definition(db, id, *, enabled, icon_name)(renamed fromset_metric_definition_enabled) sets whichever of the two fields is provided - Update
backend/routers/metric_definitions.py:PATCH /admin/metric-definitions/{id}now usesMetricDefinitionAdminUpdate/update_metric_definition - Expand
frontend/components/features/articles/metric-icons.ts's whitelist from 8 to 20 icons (adddownload,share-2,bookmark,heart,message-square,flame,trophy,hash,percent,clock,book-open,network— broader coverage of plausible future article/recommendation metrics); exportMETRIC_ICON_NAMES: string[]for the admin icon picker - Update
frontend/lib/api/metric-definitions.ts:MetricDefinitionAdmindropsprovider_name/priority;updateMetricDefinitionEnabled(id, enabled)replaced byupdateMetricDefinition(id, {enabled?, icon_name?}) - Rewrite
frontend/app/admin/metric-definitions/page.tsx: one row per metric_key (not per provider) —Switchforenabled+ aNativeSelect(@/components/ui/native-select, not a full lucide "complete catalog" dynamic-icon picker — deliberately rejected, see Complexity Tracking) populated fromMETRIC_ICON_NAMESforicon_name; both callupdateMetricDefinition()optimistically with rollback on failure - Update
CLAUDE.md: correct the "LLM Provider Chain" section (providers.tomldoes not exist in this repo — confirmed by search; provider config has been DB-driven via thellm_providerstable since migration 16,shared/llm_provider.py::load_active_providers()et al.); add a new "Metric Provider Chain" section documenting themetric_definitions/metric_providerssplit and contrasting it with the LLM chain (interchangeable providers + rate-limit fallback vs. non-interchangeable providers + identifier/coverage-driven fallback); addMetricDefinition/MetricProviderto the ORM Models bullet list; addllm_providers.py,metric_definitions.py,weekly_reports.pyrows to the Backend Routers table (pre-existing gaps noticed in passing, not a full audit)
Tests (Phase N)
- Backend integration test:
GET /metric-definitionspublic shape unchanged (still metric-key-level, no provider/priority — this was already true, verify it stays true post-split) - Backend integration test:
GET /admin/metric-definitionsreturns exactly one row per metric_key even when it has multiplemetric_providersrows, and that row exposes neitherprovider_namenorpriority - Backend integration test:
PATCH /admin/metric-definitions/{id}acceptsicon_namefrom the whitelist and persists it; rejects (422) anicon_nameoutside the whitelist; a body with onlyenabledleavesicon_nameuntouched and vice versa - Backend unit test:
SemanticScholarClient.fetch_by_arxiv_id()hitspaper/ARXIV:<id>, returns the raw JSON, raisesSemanticScholarRateLimitedErroron 429, returnsNoneon other failures - Backend unit test:
build_provider_fetchers()["semantic_scholar_arxiv"]callsfetch_by_arxiv_id()only whenarxiv_idis present, never when onlydoiis given;build_provider_fetchers()["openalex"]is never called with only anarxiv_id(regression guard — this was the original bug) - Frontend unit test: admin page renders one card per metric_key with no provider/priority text visible anywhere, even when the underlying fixture has multiple providers for the same metric_key
- Frontend unit test: changing the icon
NativeSelectcallsupdateMetricDefinition(id, { icon_name }); toggling theSwitchcalls it with{ enabled }; both roll back on failure
Phase O: Weekly Report Chat-Pin UX — Group Pills, Drag & Drop, Collapsible Sources, Stepper Fix (2026-07-14, User Story 10, FR-043–FR-051)
Why: Phase L's report-level pin control adds one pill per cited article to the chat input — fine for a handful of sources, but it floods InlineQABarWrapper once a report cites more than a couple of articles. Separately, the widget's source-citation pill row (Phase K) always renders every source at once, and the stepper's date picker (Phase H) has no scroll bound on its week-dots list, so it drifts or gets clipped once a topic accumulates many weekly reports. All four fixes touch the same widget family and were scoped together. Full design rationale in docs/superpowers/specs/2026-07-14-weekly-report-chat-pinning-design.md.
Goal: One compact, editable "batch" pill per weekly report replaces one-pill-per-article; source pills support drag-and-drop into chat; the pinned-pills row moves below the chat input; the source pill list is collapsed by default; the stepper's date picker stays fixed with jump-to-top/bottom controls when the week list overflows.
- Extend
PinnedArticleContextValue(frontend/lib/providers/pinned-article-provider.tsx) with a parallelpinnedGroups: PinnedGroup[]state ({ id, dateLabel, articles }, id = weekly report id) and three new actions:pinGroup(group)(upserts the group by id, pins every one of its articles via the existing additivepinArticles),toggleGroupArticle(groupId, articleId)(flips one article via the existingtogglePinnedArticle; auto-removes the group frompinnedGroupsonce its included count hits 0),removeGroup(groupId)(unpins every article in the group and deletes it). Existing per-article API unchanged. - Edit
weekly-report-widget.tsx'shandleTogglePinReport: sameareAllPinned(ids)branch as today, but callpinGroup({ id: selected.id, dateLabel, articles })/removeGroup(selected.id)instead of the rawpinArticles/loop;dateLabelreuses the stepper's{ month: 'short', day: 'numeric' }format (step 7) for visual consistency - Add an 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 (outside anyDndContext) is unaffected by the default - Add local state
sourcesExpandedtoweekly-report-widget.tsx(reset tofalseon everyselected.idchange); turn the existingextraContentarticle-count paragraph into a disclosure button (▸/▾) toggling it; passshowSourceList={sourcesExpanded}anddraggableSourcestoCitedContent - Wrap
weekly-report-widget.tsx's returned JSX in dnd-kit's<DndContext onDragEnd={handleDragEnd}>(it's already the common ancestor of the draggable source pills and{children}=InlineQABarWrapper);handleDragEndchecksevent.over?.id === 'chat-input-dropzone'and, if so, callspinArticles([event.active.data.current.article])(additive single-pin, no-op if already pinned) - Edit
InlineQABarWrapper.tsx: move the pinned-pills block from above<AgentInput>to below it; render one pill perpinnedGroupsentry (🌟 {dateLabel} · {includedCount} 篇文章with edit + remove icons) before any individually-pinned articles not covered by a group (existing rendering, unchanged); edit icon opens a shadcnPopover(components/ui/popover.tsx) with a checkbox pergroup.articlesentry bound toisPinned(article.id), callingtoggleGroupArticle; remove icon callsremoveGroup; wrap the pinned-pills+input container withuseDroppable({ id: 'chat-input-dropzone' }), highlighting it whileisOver - Edit
weekly-report-stepper.tsx: replace the separateflex-1spacer div withoverflow-y-auto flex-1 min-h-0directly on the week-dotslistboxdiv (with aref), so it scrolls internally instead of pushing/clipping the date picker, which now stays pinned at the bottom of the column via normal flex-column order (no spacer needed). Track overflow viaResizeObserver(re-checked onreports.lengthchange); whenscrollHeight > clientHeight, renderChevronUp/ChevronDownbuttons above/below the listbox thatscrollTo({ top: 0 | scrollHeight, behavior: 'smooth' }); hidden when the list fits - Add new i18n keys under
rag.*infrontend/lib/providers/locales/en.json/zh-TW.json:weeklyGroupPill("{date} · {count} articles"/"{date}·{count}篇文章"),editGroupArticles,groupArticlesPopoverTitle; reuse the existingrag.removeArticleRefkey for the batch pill's remove-icon aria-label
Tests (Phase O)
- Unit tests for
pinGroup/toggleGroupArticle(including the auto-remove-at-zero-included behavior)/removeGroupinfrontend/tests/unit/pinned-article-provider.test.tsx InlineQABarWrapperunit test: renders a group pill with the correct live count; the edit popover's checkboxes reflectisPinned; a simulated drop event on the dropzone pins the dragged articleweekly-report-widget.tsxunit test: sparkles toggle still drivesareAllPinnedcorrectly through the new group actions; source pill list starts collapsed and expands on click; resets to collapsed whenselected.idchangesweekly-report-stepper.tsxunit test: jump-to-top/bottom chevrons absent when the list fits, present and functional (scroll-to-top/bottom) when it overflowscited-content.tsxunit test: existing citation tests unaffected by the newdraggableSourcesprop (defaultsfalse); a new test confirms drag attributes are present only when the prop is set
Environment Variables Summary
New variables to add to .env.example:
# Cloudflare R2
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_PUBLIC_URL=
# Email (Resend)
RESEND_API_KEY=
RESEND_FROM_EMAIL=
# Optional: separate API key for Imagen (defaults to GEMINI_API_KEY)
IMAGEN_API_KEY=
# View count flush interval (seconds, default 900 = 15 min)
VIEW_COUNT_FLUSH_INTERVAL=900Dependencies to Add
# pyproject.toml (core group)
boto3 = ">=1.34"
resend = ">=2.0"
# google-genai (for Imagen 3) — check if google-generativeai already covers this
# If using newer google-genai package:
# google-genai = ">=0.8"
# pyproject.toml (scraper group) — declarative metric extraction (research.md §9c)
jmespath = ">=1.0"