chore: prepare yanting monorepo handoff

This commit is contained in:
2026-06-03 10:39:03 +09:00
commit fde51468c6
106 changed files with 8171 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Local/private agent overlays
AGENTS.local.md
CURRENT_STATUS.md
docs.jimme.local/
docs.*.local/
# Secrets and local env
.env
*.env.local
# Python
report-notebooklm-api/.venv/
report-notebooklm-api/.pytest_cache/
report-notebooklm-api/.mypy_cache/
report-notebooklm-api/**/*.pyc
report-notebooklm-api/**/__pycache__/
report-notebooklm-api/*.db
report-notebooklm-api/*.egg-info/
# Flutter / Dart
report-notebooklm-app/.dart_tool/
report-notebooklm-app/.flutter-plugins-dependencies
report-notebooklm-app/.pub-cache/
report-notebooklm-app/.pub/
report-notebooklm-app/build/
report-notebooklm-app/coverage/
# Android local/generated
report-notebooklm-app/android/.gradle/
report-notebooklm-app/android/local.properties
report-notebooklm-app/android/app/debug/
report-notebooklm-app/android/app/profile/
report-notebooklm-app/android/app/release/
report-notebooklm-app/**/*.apk
report-notebooklm-app/**/*.jks
report-notebooklm-app/**/*.keystore
report-notebooklm-app/android/key.properties
# IDE / OS noise
.DS_Store
**/.DS_Store
.idea/
*.iml
.vscode/
# Build artifacts
build/
dist/
coverage/
+126
View File
@@ -0,0 +1,126 @@
# AGENTS.md - Yanting Engineering Repo
> Public agent instructions for this repository. This file is safe to commit.
> Local agents may read ignored `AGENTS.local.md`, but the repository must not depend on it.
> Last updated: 2026-06-03.
## Project
This repository contains the Phase 1 implementation and engineering handoff for `研听 / report-notebooklm`.
`研听` is a Chinese research-report interpretation app. It turns global institutional research reports into structured Chinese reading and listening experiences. The product is an interpretation and annotation service, not investment advice.
Technical identifiers:
- Code/API/internal name: `report-notebooklm`
- Short prefix: `rnb`
- API prefix: `/api/report-notebooklm/v1`
- Database schema name: `report_notebooklm`
- User-facing display name: `研听`
Do not use the user-facing display name in code identifiers, database schema names, Redis keys, object-storage paths, or package names.
## Repository Layout
This is intended to be a single Gitea repository.
| Path | Purpose |
|---|---|
| `README.md` | Human-facing repository entry point. |
| `docs/` | Repo-level public handoff, decisions, and development history. |
| `report-notebooklm-api/` | FastAPI backend, database models, migrations, seed importer, API docs. |
| `report-notebooklm-app/` | Flutter app, Android/web scaffolds, App docs. |
| `docs.jimme.local/` | Ignored local-only notes, not required by the team. |
| `AGENTS.local.md` | Ignored local agent overlay. |
## Public vs Local Documentation
Public, committed documentation must be portable:
- Use repository-relative paths.
- Use environment variables and placeholders for credentials.
- Describe product decisions in team-readable language.
- Distinguish implemented behavior from planned/spec behavior.
Do not commit local-only material:
- Local absolute paths.
- Personal machine setup.
- private agent workflow.
- raw session logs.
- local screenshots, APKs, caches, virtualenvs, build outputs.
- credentials or local service passwords.
Use `docs.jimme.local/` for ignored local notes and raw process references. Durable team-facing conclusions should be distilled into public `docs/`.
## Product and Compliance Constraints
- Public responses expose only reviewed display artifacts, not raw NotebookLM artifacts.
- Public responses expose `cache_version`; `display_version` and module `version` are internal.
- Do not expose raw artifact payloads, local file paths, NotebookLM notebook/source/conversation IDs, account identifiers, or private object-storage paths.
- Phase 1 has no report-interpretation download feature.
- Phase 1 does not include comments, UGC, paid unlocks, membership, task walls, points, trading signals, or investment recommendations.
- NotebookLM-native/source-driven artifacts are the content source. Do not use local LLM rewriting to invent publishable report content.
- Gray broker sources and generated media require compliance/operations review before public release.
## Backend
Read first:
- `report-notebooklm-api/README.md`
- `report-notebooklm-api/docs/HANDOFF.md`
- `report-notebooklm-api/docs/API_AND_DATA.md`
- `report-notebooklm-api/docs/CONTENT_PIPELINE.md`
- `report-notebooklm-api/docs/RUNBOOK.md`
Verify:
```bash
cd report-notebooklm-api
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
alembic upgrade head
python scripts/import_seed_content.py
pytest -q
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
The backend requires `.env` settings for real MySQL/Redis environments. Use `.env.example` as the template. Do not commit `.env`.
## App
Read first:
- `report-notebooklm-app/README.md`
- `report-notebooklm-app/docs/HANDOFF.md`
- `report-notebooklm-app/docs/API_CONTRACT_NOTES.md`
- `report-notebooklm-app/docs/APP_RUNBOOK.md`
Verify:
```bash
cd report-notebooklm-app
flutter analyze
flutter test
flutter build web --dart-define=RNB_API_BASE=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
The App intentionally has no built-in live API default. Always pass `RNB_API_BASE`.
## Decision Records
Long-lived decisions belong in `docs/DECISIONS.md`.
Development timeline and major implementation changes belong in `docs/DEVELOPMENT_HISTORY.md`.
Raw session logs, temporary planning transcripts, or local-only evidence pointers belong in ignored `docs.jimme.local/`.
## Git Rules
- Target remote: `https://gitea.neuronlabs.art/third-party-project/yanting.git`.
- Commit one monorepo, not nested repositories.
- Before the final monorepo push, remove or archive nested `.git/` directories under subprojects so source files are committed as normal directories.
- Keep `.env`, build artifacts, caches, APKs, local status files, and local agent overlays ignored.
- Use English commit messages with prefixes such as `feat:`, `fix:`, `docs:`, and `chore:`.
+139
View File
@@ -0,0 +1,139 @@
# Yanting / report-notebooklm
`研听` is a Phase 1 app and backend for turning global institutional research reports into structured Chinese reading and listening experiences.
This repository is prepared as a single Gitea handoff repository for product and engineering teams.
## What Is In This Repository
| Area | Path | Description |
|---|---|---|
| Backend API | `report-notebooklm-api/` | FastAPI service, MySQL models, Alembic migration, seed importer, public read API. |
| Flutter App | `report-notebooklm-app/` | Flutter client with five main tabs, report detail modules, Android/web scaffolds. |
| Repo docs | `docs/` | Project-level overview, decisions, development history, and handoff guidance. |
| Backend docs | `report-notebooklm-api/docs/` | API/data/content-pipeline/runbook details. |
| App docs | `report-notebooklm-app/docs/` | App runbook, project map, and API consumption notes. |
## Product Snapshot
`研听` helps Chinese users understand global institutional research reports across macro, precious metals, commodities, energy, central banks, and cross-asset topics.
Phase 1 focuses on:
- 推荐: curated/latest report interpretations.
- 研报: report list and basic filtering.
- 机构: institution list and institution detail.
- 听单: reports that have audio.
- 我的: guest/login state and shallow personal-state entries.
Phase 1 explicitly does not include comments, UGC, paid unlocks, membership, ads, trading signals, investment advice, or report-interpretation downloads.
## Read First
For human readers:
1. `docs/PROJECT_OVERVIEW.md`
2. `docs/DECISIONS.md`
3. `docs/DEVELOPMENT_HISTORY.md`
4. `report-notebooklm-api/docs/HANDOFF.md`
5. `report-notebooklm-app/docs/HANDOFF.md`
For agents:
1. `AGENTS.md`
2. `docs/DECISIONS.md`
3. Target subsystem README and runbook.
## Current Implementation Status
Backend implemented:
- FastAPI app under `/api/report-notebooklm/v1`.
- SQLAlchemy model layer for Phase 1 tables.
- Alembic initial migration.
- Seed import script.
- Public read endpoints for health, feed, reports, report modules, institutions, and listen list.
- Tests for seed and public API behavior.
App implemented:
- Five bottom tabs: 推荐, 研报, 机构, 听单, 我的.
- API-backed list/detail views using `RNB_API_BASE`.
- Report detail module renderer registry.
- Local placeholders for login, favorites, outbound confirmation, and playback progress.
- Android and web build scaffolds.
Not production-ready yet:
- Auth and personal state.
- Real audio stream signing.
- Outbound event writing.
- Internal content management API.
- Production object storage and cache invalidation.
- Production API domain, release signing, final app icon, and store metadata.
## Backend Quick Start
```bash
cd report-notebooklm-api
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
# edit .env for your MySQL and Redis
alembic upgrade head
python scripts/import_seed_content.py
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
Smoke checks:
```bash
API_BASE_URL=http://<api-host>:<port>/api/report-notebooklm/v1
curl "$API_BASE_URL/health"
curl "$API_BASE_URL/feed/recommended"
curl "$API_BASE_URL/reports/rep_ssga_gold"
```
## App Quick Start
```bash
cd report-notebooklm-app
flutter analyze
flutter test
flutter run -d chrome --dart-define=RNB_API_BASE=<api-base-url>
```
Android emulator:
```bash
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## Verification
Backend:
```bash
cd report-notebooklm-api
source .venv/bin/activate
pytest -q
```
App:
```bash
cd report-notebooklm-app
flutter analyze
flutter test
flutter build web --dart-define=RNB_API_BASE=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## Documentation Boundary
This repository contains a code handoff snapshot. It does not replace the product source of truth.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
Local-only notes, private paths, raw session pointers, and personal agent workflow belong in ignored `docs.jimme.local/` and `AGENTS.local.md`.
+44
View File
@@ -0,0 +1,44 @@
# Decision Record
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Product Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-02 | Phase 1 scope is a Chinese global institutional report interpretation app, not a pure audio app. | Five main tabs remain 推荐 / 研报 / 机构 / 听单 / 我的. |
| 2026-06-02 | Phase 1 has no commercialization. | No ads, paid unlock, membership, task wall, or points. |
| 2026-06-02 | Phase 1 does not open comments, UGC, or user-generated report interpretation. | App should not show community or publishing entry points. |
| 2026-06-02 | Guest users can browse public content and fully listen to at least one item. | Login should not block first listening experience. |
| 2026-06-03 | Product display name is `研听`; technical identifiers stay `report-notebooklm` / `rnb`. | Code identifiers, database schema, Redis keys, object-storage paths, and API prefixes remain brand-neutral. |
| 2026-06-03 | Phase 1 has no report-interpretation download feature. | No top-level download icon, detail download button, profile download record, download API, or offline audio package. |
## API and Data Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-03 | Public responses expose only `cache_version`. | `display_version`, module `version`, and nested cache version objects are internal. |
| 2026-06-03 | Heavy modules use a skeleton plus lazy full-module flow. | Detail returns previews; full content uses `/reports/{report_id}/modules/{module_id}` or a content reference. |
| 2026-06-03 | FAQ, Study Guide, and Glossary are represented as `study_guide`. | Legacy `faq` should map to `study_guide`; no separate public `faq` type. |
| 2026-06-03 | Public published content may use direct content references; restricted sources need short-lived backend signed URLs. | Backend keeps module endpoint and should add signed URL behavior for restricted content. |
| 2026-06-03 | Gray broker sources may be full-text audio-ized, but need compliance/operations review before production. | Seed and production rules can allow audio, but release must remain reviewed. |
## Content Pipeline Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-02 | NotebookLM is treated as a source-driven research engine. | Use native artifacts and targeted queries; do not invent unsupported copy. |
| 2026-06-02 | Raw artifacts stay internal. | App consumes reviewed display artifacts only. |
| 2026-06-02 | P0 text artifacts publish first; media and enrichment are async. | Audio, infographic, research discovery, and mind map must not block text publishability. |
| 2026-06-02 | Vision can be used as source/reference experience but not as a production runtime dependency. | Production data must not depend on local Vision runtime, local paths, or local account state. |
## Repository and Handoff Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-03 | Gitea target is a single repository. | `report-notebooklm-api/` and `report-notebooklm-app/` should be ordinary subdirectories in one repo. |
| 2026-06-03 | Public docs must be portable. | No local absolute paths or private machine setup in committed docs. |
| 2026-06-03 | Local-only agent and status material goes into ignored files. | Use `AGENTS.local.md` and `docs.jimme.local/`. |
| 2026-06-03 | Long-lived decisions are public; raw sessions are local. | Distill decisions into this file; keep session pointers in ignored local docs. |
+85
View File
@@ -0,0 +1,85 @@
# Development History
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## 2026-06-02 - Product Scope Freeze
- Product scope was corrected away from the old "Wall Street listening" / pure-audio framing.
- Phase 1 was frozen around a Chinese research-report interpretation app.
- Main tabs were fixed as 推荐 / 研报 / 机构 / 听单 / 我的.
- Non-goals were made explicit: no commercialization, comments, UGC, trading advice, professional terminal, or local Vision runtime dependency.
- Vision was kept as reference/source experience, not production runtime.
## 2026-06-02 - Development Plan and Review
- Phase 1 technical baseline was selected:
- Flutter App.
- FastAPI backend.
- MySQL 8.
- Redis with `rnb:` namespace.
- Object storage for raw artifacts, heavy modules, audio, and images.
- Existing cloud/server deployment model.
- External launch dependencies were identified:
- SMS template/signature.
- WeChat Open Platform.
- Apple login if required.
- AI-generated-content labeling.
- compliance review for source and media policies.
- The plan passed independent review with changes requested around launch blockers and implementation details.
## 2026-06-03 - Backend Scaffold
- FastAPI service created under `report-notebooklm-api/`.
- SQLAlchemy model layer created for Phase 1 tables.
- Alembic initial migration added.
- Seed importer added with institutions, reports, display artifacts, display modules, audio assets, users, favorites, and playback progress.
- Public read routes implemented:
- `/health`
- `/feed/recommended`
- `/reports`
- `/reports/{id}`
- `/reports/{id}/modules/{module_id}`
- `/institutions`
- `/institutions/{id}`
- `/listen`
- Tests added for seed counts, public API shape, hidden/review module boundaries, gray-source behavior, and listen list behavior.
## 2026-06-03 - App Scaffold
- Flutter app shell created under `report-notebooklm-app/`.
- Five tabs implemented.
- API client added with explicit `RNB_API_BASE`.
- Feature folders created for feed, reports, institutions, listen, profile, detail, and shared widgets.
- Detail module renderer registry added.
- Local placeholders added for blocked behaviors:
- login
- favorite
- outbound confirmation
- playback progress
- real audio stream
- Android platform scaffold added.
## 2026-06-03 - Handoff Preparation
- Backend and App documentation added.
- Public docs were distilled from product documents without copying the full product-doc tree.
- Local-only paths and raw session details were separated from public docs.
- Root README and public AGENTS instructions were introduced for the single-repo Gitea handoff.
## Current Verification Snapshot
Validated during handoff preparation:
- Backend editable install with dev dependencies.
- Backend migration.
- Backend seed import.
- Backend tests.
- Backend smoke checks for health, feed, and report detail.
- App analyze.
- App widget test.
- App web build.
- App debug APK build.
Build artifacts are transient and are not committed.
+44
View File
@@ -0,0 +1,44 @@
# Project Overview
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Purpose
`研听` is a Chinese app for understanding global institutional research reports. It converts difficult English research reports into reviewed Chinese reading and listening experiences.
The product is a research-report interpretation and annotation service. It does not provide investment advice.
## Technical Shape
| Layer | Technology | Path |
|---|---|---|
| App | Flutter | `report-notebooklm-app/` |
| API | FastAPI | `report-notebooklm-api/` |
| Database | MySQL 8 | configured by `RNB_DATABASE_URL` |
| Cache | Redis | configured by `RNB_REDIS_URL` |
| Storage | Object storage | planned for raw artifacts, modules, audio, images |
## Phase 1 Surfaces
- 推荐: latest and curated report interpretations.
- 研报: all published report interpretations with basic filters.
- 机构: institution list, institution detail, and recent reports.
- 听单: audio-backed reports.
- 我的: guest/login state and shallow personal-state entries.
## Key Engineering Principle
The app consumes reviewed display artifacts through the API. Raw NotebookLM artifacts are internal evidence and must not be exposed publicly.
NotebookLM-native content may be cleaned, mapped, reviewed, and assembled deterministically. It must not be silently replaced by local LLM rewriting.
## Repository Documentation
- `README.md`: human entry point.
- `AGENTS.md`: public agent instructions.
- `docs/DECISIONS.md`: durable decisions.
- `docs/DEVELOPMENT_HISTORY.md`: major change history.
- `report-notebooklm-api/docs/`: backend, data, API, and content pipeline details.
- `report-notebooklm-app/docs/`: App runbook and API consumption notes.
+3
View File
@@ -0,0 +1,3 @@
RNB_DATABASE_URL=mysql+asyncmy://<db-user>:<db-pass>@<db-host>:<db-port>/report_notebooklm
RNB_REDIS_URL=redis://<host>:<port>/0
RNB_REDIS_KEY_PREFIX=rnb:
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
*.egg-info/
*.pyc
*.db
.env
.DS_Store
build/
*.apk
+56
View File
@@ -0,0 +1,56 @@
# report-notebooklm-api Notes
This file keeps short engineering notes for this repository. The durable handoff is in `docs/`.
## 2026-06-03 Phase 1 Scaffold
- Started the Phase 1 backend scaffold from `phase1-build-brief.md`.
- Technical identifiers use `report-notebooklm` / `rnb`; user-facing product name is `研听`.
- Implemented FastAPI config, database, cache helper, routers, SQLAlchemy models, Alembic migration, seed importer, and public read API.
- Public API prefix is `/api/report-notebooklm/v1`.
- Implemented public routes:
- `/health`
- `/feed/recommended`
- `/reports`
- `/reports/{id}`
- `/reports/{id}/modules/{module_id}`
- `/institutions`
- `/institutions/{id}`
- `/listen`
- Seed importer covers institutions, reports, display artifacts, display modules, audio assets, users, favorites, and playback progress.
- Heavy modules store preview/full content in a JSON envelope. Public detail responses expose previews for `card_plus_page`; the module endpoint exposes full content.
- Review-only modules do not appear in public responses.
- Public responses expose `cache_version`; `display_version` and module `version` remain internal.
## Verification Snapshot
- Backend tests: `pytest -q` passed.
- Local API smoke checks passed for `/health`, `/feed/recommended`, and `/reports/rep_ssga_gold`.
- Companion App analyze/test/build checks passed when using a Flutter SDK compatible with Dart 3.12.1.
- Android debug validation was completed during local handoff. Build artifacts and screenshots are transient and should not be committed.
## Resolved Product Decisions
- Public responses expose only `cache_version`.
- Heavy module access keeps both `content_ref` and `GET /reports/{id}/modules/{module_id}` available.
- Public published content may use direct content references; restricted sources should use backend short-lived signed URLs.
- FAQ, Study Guide, and Glossary are represented as a single `study_guide` module type.
- `faq` stays deprecated; legacy seed `faq` should map to `study_guide`.
- Gray-source full-text audio is allowed by product decision but still needs operations/compliance review before production release.
- App prototype feedback decisions from 2026-06-03 are durable in mall-docs `docs/2026-06-03-app-prototype-feedback-decisions.md`.
- Seed/display module order is: 报告概览 / 报告摘要 / 听研报 / 报告要点 / 报告中的关键数据 / 观点差异 / 局限与疑问 / 时间线 / 术语与问答 / 结构梳理 / 延伸阅读 / 报告来源.
- Do not seed a separate `institution` display module for public Detail. Publisher information belongs inside the source/compliance surface rendered as `报告来源`.
- The real BIS sample should be the top report, but public UI copy must not expose internal labels such as NotebookLM sample, query artifact, or artifact mapping.
- `basic_info` and `executive_overview` must not repeat the same text: overview is factual scope/metadata; summary is a few-sentence report-level description.
- All public modules returned for Detail should expose `has_detail_page=True`; tests assert this to prevent accidental regression.
## Remaining Backend Gaps
- Auth routes.
- Personal-state routes.
- Audio stream signed URL route.
- Outbound events route.
- Internal management routes.
- Production object storage integration.
- Production cache invalidation and pagination.
- Deployment environment configuration.
+62
View File
@@ -0,0 +1,62 @@
# report-notebooklm-api
FastAPI service for the report-notebooklm Phase 1 public read surface.
This directory is the main engineering handoff entry for API, data model, seed import, and the NotebookLM-backed content pipeline. The companion Flutter app lives in `../report-notebooklm-app/` in the same monorepo.
## Read First
- [docs/HANDOFF.md](docs/HANDOFF.md): current progress, solved issues, open issues, and handoff order.
- [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md): product and Phase 1 scope snapshot.
- [docs/API_AND_DATA.md](docs/API_AND_DATA.md): data tables, endpoints, implemented vs planned API.
- [docs/CONTENT_PIPELINE.md](docs/CONTENT_PIPELINE.md): report source and NotebookLM artifact flow.
- [docs/RUNBOOK.md](docs/RUNBOOK.md): local setup, seed import, smoke checks, and deployment checks.
- [docs/ROADMAP_AND_OPEN_ISSUES.md](docs/ROADMAP_AND_OPEN_ISSUES.md): next engineering work.
- [docs/SOURCE_INDEX.md](docs/SOURCE_INDEX.md): source document names used for this handoff snapshot.
## Product Boundary
This repo contains code and an engineering handoff snapshot. It is not the product source of truth.
Product SSOT: mall-docs report-notebooklm docs. Snapshot date: 2026-06-03.
Use `report-notebooklm` and `rnb` for technical identifiers. The user-facing product name is `研听`.
## Local Quick Start
Create a `.env` file with the backend services available to your environment:
```bash
RNB_DATABASE_URL=mysql+asyncmy://<db-user>:<db-pass>@<db-host>:<db-port>/report_notebooklm
RNB_REDIS_URL=redis://<host>:<port>/0
RNB_REDIS_KEY_PREFIX=rnb:
```
Then run:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
alembic upgrade head
python scripts/import_seed_content.py
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
API prefix: `/api/report-notebooklm/v1`
## Verify
```bash
source .venv/bin/activate
pytest -q
```
Recommended smoke checks after the service starts:
```bash
API_BASE_URL=http://<api-host>:<port>/api/report-notebooklm/v1
curl "$API_BASE_URL/health"
curl "$API_BASE_URL/feed/recommended"
curl "$API_BASE_URL/reports/rep_ssga_gold"
```
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = migrations
prepend_sys_path = .
path_separator = os
# Runtime value is injected from RNB_DATABASE_URL in migrations/env.py.
sqlalchemy.url = sqlite+aiosqlite:///unused_alembic_config.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
+15
View File
@@ -0,0 +1,15 @@
from redis.asyncio import Redis
from app.config import get_settings
settings = get_settings()
def prefixed_key(key: str) -> str:
return f"{settings.redis_key_prefix}{key}"
def get_redis() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True)
+18
View File
@@ -0,0 +1,18 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "report-notebooklm-api"
api_prefix: str = "/api/report-notebooklm/v1"
database_url: str
redis_url: str
redis_key_prefix: str = "rnb:"
model_config = SettingsConfigDict(env_prefix="RNB_", env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
+22
View File
@@ -0,0 +1,22 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.routers import health, institutions, listen, reports
settings = get_settings()
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["*"],
)
app.include_router(health.router, prefix=settings.api_prefix)
app.include_router(reports.router, prefix=settings.api_prefix)
app.include_router(institutions.router, prefix=settings.api_prefix)
app.include_router(listen.router, prefix=settings.api_prefix)
@@ -0,0 +1,32 @@
from app.models.entities import (
AudioAsset,
DisplayArtifact,
DisplayModule,
Favorite,
Institution,
OutboundEvent,
PlaybackProgress,
RawArtifact,
ReadingHistory,
RelatedNews,
Report,
SavedListen,
User,
)
__all__ = [
"AudioAsset",
"DisplayArtifact",
"DisplayModule",
"Favorite",
"Institution",
"OutboundEvent",
"PlaybackProgress",
"RawArtifact",
"ReadingHistory",
"RelatedNews",
"Report",
"SavedListen",
"User",
]
@@ -0,0 +1,302 @@
from __future__ import annotations
import datetime as dt
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.mysql import MEDIUMTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def utcnow() -> dt.datetime:
return dt.datetime.now(dt.UTC).replace(tzinfo=None)
MediumText = Text().with_variant(MEDIUMTEXT, "mysql")
class Institution(Base):
__tablename__ = "institutions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
institution_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
name_cn: Mapped[str] = mapped_column(String(255), nullable=False)
name_en: Mapped[str | None] = mapped_column(String(255))
institution_type: Mapped[str] = mapped_column(String(32), nullable=False)
source_tier: Mapped[str] = mapped_column(String(16), nullable=False)
website_url: Mapped[str | None] = mapped_column(String(512))
covered_topics: Mapped[str | None] = mapped_column(Text)
intro_cn: Mapped[str | None] = mapped_column(Text)
credibility_note: Mapped[str | None] = mapped_column(Text)
report_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
latest_report_id: Mapped[str | None] = mapped_column(String(64))
latest_report_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
reports: Mapped[list[Report]] = relationship(back_populates="institution")
__table_args__ = (Index("ix_institutions_status_latest", "status", "latest_report_at"),)
class Report(Base):
__tablename__ = "reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
report_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_type: Mapped[str] = mapped_column(String(16), nullable=False, default="single")
title_cn: Mapped[str] = mapped_column(String(512), nullable=False)
subtitle_cn: Mapped[str | None] = mapped_column(String(512))
original_title: Mapped[str | None] = mapped_column(String(512))
one_liner: Mapped[str | None] = mapped_column(String(512))
institution_id: Mapped[str] = mapped_column(String(64), ForeignKey("institutions.institution_id"), nullable=False)
co_institution_ids: Mapped[str | None] = mapped_column(Text)
source_tier: Mapped[str] = mapped_column(String(32), nullable=False)
source_url: Mapped[str | None] = mapped_column(String(512))
source_note: Mapped[str] = mapped_column(Text, nullable=False)
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
interpreted_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
released_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
topics: Mapped[str | None] = mapped_column(Text)
language: Mapped[str] = mapped_column(String(8), nullable=False, default="en")
has_audio: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
display_status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
display_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cache_version: Mapped[str] = mapped_column(String(128), nullable=False)
risk_disclaimer: Mapped[str | None] = mapped_column(Text)
interpretation_label: Mapped[str | None] = mapped_column(String(64), default="研报解读")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
institution: Mapped[Institution] = relationship(back_populates="reports")
__table_args__ = (
Index("ix_reports_status_released", "display_status", "released_at"),
Index("ix_reports_institution_released", "institution_id", "released_at"),
Index("ix_reports_audio_released", "has_audio", "released_at"),
)
class RawArtifact(Base):
__tablename__ = "raw_artifacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
raw_artifact_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False, default="notebooklm")
artifact_type: Mapped[str] = mapped_column(String(64), nullable=False)
conversation_id: Mapped[str | None] = mapped_column(String(128))
source_id: Mapped[str | None] = mapped_column(String(128))
notebook_id: Mapped[str | None] = mapped_column(String(128))
source_language: Mapped[str | None] = mapped_column(String(8))
payload_format: Mapped[str] = mapped_column(String(16), nullable=False)
payload_ref: Mapped[str | None] = mapped_column(String(512))
sha256: Mapped[str | None] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
error: Mapped[str | None] = mapped_column(Text)
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
generated_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
ingested_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
is_publish_blocking: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
requires_human_review: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
quality_flags: Mapped[str | None] = mapped_column(Text)
retention_status: Mapped[str] = mapped_column(String(32), nullable=False, default="retained")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (
Index("ix_raw_report_type", "report_id", "artifact_type"),
Index("ix_raw_report_status", "report_id", "status"),
Index("ix_raw_retention", "retention_status"),
)
class DisplayArtifact(Base):
__tablename__ = "display_artifacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
display_artifact_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
display_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
title_cn: Mapped[str] = mapped_column(String(512), nullable=False)
summary_cn: Mapped[str | None] = mapped_column(Text)
source_label: Mapped[str | None] = mapped_column(String(255))
interpretation_label: Mapped[str | None] = mapped_column(String(64), default="研报解读")
ai_generated_label: Mapped[str | None] = mapped_column(String(128))
synthesis_type: Mapped[str | None] = mapped_column(String(16))
source_disclosure_text: Mapped[str | None] = mapped_column(Text)
review_status: Mapped[str] = mapped_column(String(16), nullable=False, default="review")
reviewed_by: Mapped[str | None] = mapped_column(String(128))
reviewed_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (Index("ix_display_artifacts_report_status_version", "report_id", "review_status", "display_version"),)
class DisplayModule(Base):
__tablename__ = "display_modules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
module_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
display_artifact_id: Mapped[str] = mapped_column(String(64), ForeignKey("display_artifacts.display_artifact_id"), nullable=False)
type: Mapped[str] = mapped_column(String(32), nullable=False)
title_cn: Mapped[str | None] = mapped_column(String(255))
content_format: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str | None] = mapped_column(MediumText)
content_ref: Mapped[str | None] = mapped_column(String(512))
content_etag: Mapped[str | None] = mapped_column(String(64))
source_raw_artifact_ids: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="missing")
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (
Index("ix_display_modules_report_status_sort", "report_id", "status", "sort_order"),
Index("ix_display_modules_artifact_status", "display_artifact_id", "status"),
)
class AudioAsset(Base):
__tablename__ = "audio_assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
audio_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
source_raw_artifact_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("raw_artifacts.raw_artifact_id"))
title_cn: Mapped[str] = mapped_column(String(512), nullable=False)
duration_sec: Mapped[int | None] = mapped_column(Integer)
oss_key: Mapped[str | None] = mapped_column(String(512))
waveform_ref: Mapped[str | None] = mapped_column(String(512))
chapters: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="missing")
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (Index("ix_audio_report_status", "report_id", "status"),)
class RelatedNews(Base):
__tablename__ = "related_news"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
related_news_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
title: Mapped[str] = mapped_column(String(512), nullable=False)
source_name: Mapped[str | None] = mapped_column(String(255))
source_url: Mapped[str | None] = mapped_column(String(512))
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
language: Mapped[str | None] = mapped_column(String(8))
summary_cn: Mapped[str | None] = mapped_column(Text)
match_method: Mapped[str] = mapped_column(String(32), nullable=False, default="manual_curated")
match_keywords: Mapped[str | None] = mapped_column(Text)
match_confidence: Mapped[str | None] = mapped_column(String(8))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="candidate")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (Index("ix_related_news_report_status", "report_id", "status"),)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
phone_hash: Mapped[str | None] = mapped_column(String(128), unique=True)
wechat_openid: Mapped[str | None] = mapped_column(String(128), unique=True)
apple_user_id: Mapped[str | None] = mapped_column(String(256), unique=True)
display_name: Mapped[str | None] = mapped_column(String(128))
avatar_url: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
last_login_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
class Favorite(Base):
__tablename__ = "favorites"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
favorite_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
__table_args__ = (UniqueConstraint("user_id", "report_id", name="uq_favorites_user_report"), Index("ix_favorites_user_report", "user_id", "report_id"))
class ReadingHistory(Base):
__tablename__ = "reading_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
history_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
event_type: Mapped[str] = mapped_column(String(32), nullable=False, default="view_detail")
last_position: Mapped[str | None] = mapped_column(Text)
last_seen_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (Index("ix_reading_history_user_seen", "user_id", "last_seen_at"), Index("ix_reading_history_user_report", "user_id", "report_id"))
class SavedListen(Base):
__tablename__ = "saved_listens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
saved_listen_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
audio_id: Mapped[str] = mapped_column(String(64), ForeignKey("audio_assets.audio_id"), nullable=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
__table_args__ = (UniqueConstraint("user_id", "audio_id", name="uq_saved_listens_user_audio"),)
class PlaybackProgress(Base):
__tablename__ = "playback_progress"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
progress_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
audio_id: Mapped[str] = mapped_column(String(64), ForeignKey("audio_assets.audio_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
position_sec: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
duration_sec: Mapped[int | None] = mapped_column(Integer)
completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (UniqueConstraint("user_id", "audio_id", name="uq_playback_user_audio"), Index("ix_playback_user_audio", "user_id", "audio_id"))
class OutboundEvent(Base):
__tablename__ = "outbound_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
outbound_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
click_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
tracking_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("users.user_id"))
device_id: Mapped[str | None] = mapped_column(String(128))
report_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("reports.report_id"))
institution_id: Mapped[str | None] = mapped_column(String(64))
scene: Mapped[str | None] = mapped_column(String(64))
ref: Mapped[str | None] = mapped_column(String(128))
target: Mapped[str | None] = mapped_column(String(32))
source_page: Mapped[str | None] = mapped_column(String(32))
placement: Mapped[str | None] = mapped_column(String(64))
campaign_id: Mapped[str | None] = mapped_column(String(64))
target_app: Mapped[str | None] = mapped_column(String(64))
commodity_tag: Mapped[str | None] = mapped_column(String(64))
hook_type: Mapped[str | None] = mapped_column(String(64))
user_state: Mapped[str | None] = mapped_column(String(16))
ts: Mapped[int | None] = mapped_column(BigInteger)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (Index("ix_outbound_tracking", "tracking_id"), Index("ix_outbound_report_created", "report_id", "created_at"))
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,9 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.services.catalog import CatalogService
router = APIRouter()
@router.get("/institutions")
async def institutions(
topic: str | None = None,
source_tier: str | None = None,
page_size: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_session),
) -> dict:
return await CatalogService(session).institutions(topic=topic, source_tier=source_tier, page_size=page_size)
@router.get("/institutions/{institution_id}")
async def institution_detail(institution_id: str, session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).institution_detail(institution_id)
@@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.services.catalog import CatalogService
router = APIRouter()
@router.get("/listen")
async def listen(page_size: int = Query(20, ge=1, le=50), session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).listen_items(page_size=page_size)
@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.services.catalog import CatalogService
router = APIRouter()
@router.get("/feed/recommended")
async def recommended_feed(
topic: str | None = None,
page_size: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_session),
) -> dict:
return await CatalogService(session).report_cards(topic=topic, page_size=page_size)
@router.get("/reports")
async def reports(
topic: str | None = None,
institution_id: str | None = None,
has_audio: bool | None = None,
source_tier: str | None = None,
q: str | None = None,
page_size: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_session),
) -> dict:
return await CatalogService(session).report_cards(
topic=topic,
institution_id=institution_id,
has_audio=has_audio,
source_tier=source_tier,
q=q,
page_size=page_size,
)
@router.get("/reports/{report_id}")
async def report_detail(report_id: str, session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).report_detail(report_id)
@router.get("/reports/{report_id}/modules/{module_id}")
async def module_detail(report_id: str, module_id: str, session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).module_detail(report_id, module_id)
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,272 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import HTTPException
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AudioAsset, DisplayModule, Institution, RelatedNews, Report
MODULE_META: dict[str, dict[str, Any]] = {
"basic_info": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"executive_overview": {"layer": "p0", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"core_insights": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"key_data": {"layer": "p0", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"source_compliance": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"differentiated_view": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"weaknesses": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"timeline": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"study_guide": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"related_sources": {"layer": "p1", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
"structure_graph": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"infographic": {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
"audio": {"layer": "p2", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"research_discovery": {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
"institution": {"layer": "p0", "render_mode": "inline", "has_detail_page": False, "is_publish_blocking": False, "requires_human_review": False},
}
def loads_json(value: str | None, default: Any) -> Any:
if not value:
return default
return json.loads(value)
def iso(value: Any) -> str | None:
return value.isoformat() if value else None
def institution_public(inst: Institution, *, detail: bool = False) -> dict[str, Any]:
data = {
"institution_id": inst.institution_id,
"name_cn": inst.name_cn,
"name_en": inst.name_en,
"institution_type": inst.institution_type,
"source_tier": inst.source_tier,
"website_url": inst.website_url,
"covered_topics": loads_json(inst.covered_topics, []),
"report_count": inst.report_count,
"latest_report_at": iso(inst.latest_report_at),
"credibility_note": inst.credibility_note,
}
if detail:
data["intro_cn"] = inst.intro_cn
return data
def institution_card(inst: Institution) -> dict[str, Any]:
return {
"institution_id": inst.institution_id,
"name_cn": inst.name_cn,
"name_en": inst.name_en,
"source_tier": inst.source_tier,
}
def report_card(report: Report, inst: Institution) -> dict[str, Any]:
return {
"report_id": report.report_id,
"title_cn": report.title_cn,
"subtitle_cn": report.subtitle_cn or "",
"one_liner": report.one_liner,
"institution": institution_card(inst),
"topics": loads_json(report.topics, []),
"released_at": iso(report.released_at),
"has_audio": report.has_audio,
"interpretation_label": report.interpretation_label,
"source_tier": report.source_tier,
"cache_version": report.cache_version,
}
def module_payload(module: DisplayModule) -> dict[str, Any]:
meta = MODULE_META.get(module.type, {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False})
envelope = loads_json(module.content, {})
render_mode = meta["render_mode"]
content = None
preview = None
if render_mode == "inline":
content = envelope.get("content", envelope)
else:
preview = envelope.get("preview", {})
return {
"module_id": module.module_id,
"type": module.type,
"layer": meta["layer"],
"render_mode": render_mode,
"has_detail_page": meta["has_detail_page"],
"is_publish_blocking": meta["is_publish_blocking"],
"requires_human_review": meta["requires_human_review"],
"sort_order": module.sort_order,
"title_cn": module.title_cn,
"content": content,
"preview": preview,
"content_ref": module.content_ref,
"content_etag": module.content_etag,
}
class CatalogService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def _published_report_query(self) -> Select[tuple[Report, Institution]]:
return (
select(Report, Institution)
.join(Institution, Report.institution_id == Institution.institution_id)
.where(Report.display_status == "published", Institution.status == "active")
.order_by(Report.released_at.desc(), Report.report_id)
)
async def report_cards(
self,
*,
topic: str | None = None,
institution_id: str | None = None,
has_audio: bool | None = None,
source_tier: str | None = None,
q: str | None = None,
page_size: int = 20,
) -> dict[str, Any]:
stmt = await self._published_report_query()
if topic:
stmt = stmt.where(Report.topics.like(f"%{topic}%"))
if institution_id:
stmt = stmt.where(Report.institution_id == institution_id)
if has_audio is not None:
stmt = stmt.where(Report.has_audio == has_audio)
if source_tier:
stmt = stmt.where(Report.source_tier == source_tier)
if q:
stmt = stmt.where(Report.title_cn.like(f"%{q}%"))
stmt = stmt.limit(min(max(page_size, 1), 50))
rows = (await self.session.execute(stmt)).all()
return {
"items": [report_card(report, inst) for report, inst in rows],
"page": {"next_cursor": None, "has_more": False},
"cache_version": "feed:recommended:seed:v1",
}
async def report_detail(self, report_id: str) -> dict[str, Any]:
row = (
await self.session.execute(
select(Report, Institution)
.join(Institution, Report.institution_id == Institution.institution_id)
.where(Report.report_id == report_id, Report.display_status == "published", Institution.status == "active")
)
).one_or_none()
if row is None:
raise HTTPException(status_code=404, detail={"error": {"code": "REPORT_NOT_FOUND", "message": "报告不存在或未发布。"}})
report, inst = row
modules = (
await self.session.execute(
select(DisplayModule)
.where(DisplayModule.report_id == report_id, DisplayModule.status == "published")
.order_by(DisplayModule.sort_order)
)
).scalars().all()
return {
"report_id": report.report_id,
"title_cn": report.title_cn,
"subtitle_cn": report.subtitle_cn or "",
"original_title": report.original_title,
"one_liner": report.one_liner,
"institution": institution_public(inst, detail=True),
"source": {
"source_url": report.source_url,
"source_note": report.source_note,
"source_tier": report.source_tier,
"published_at": iso(report.published_at),
},
"topics": loads_json(report.topics, []),
"has_audio": report.has_audio,
"interpretation_label": report.interpretation_label,
"risk_disclaimer": report.risk_disclaimer,
"released_at": iso(report.released_at),
"cache_version": report.cache_version,
"modules": [module_payload(module) for module in modules],
}
async def module_detail(self, report_id: str, module_id: str) -> dict[str, Any]:
report = (
await self.session.execute(select(Report).where(Report.report_id == report_id, Report.display_status == "published"))
).scalar_one_or_none()
if report is None:
raise HTTPException(status_code=404, detail={"error": {"code": "REPORT_NOT_FOUND", "message": "报告不存在或未发布。"}})
module = (
await self.session.execute(
select(DisplayModule).where(DisplayModule.report_id == report_id, DisplayModule.module_id == module_id, DisplayModule.status == "published")
)
).scalar_one_or_none()
if module is None:
raise HTTPException(status_code=404, detail={"error": {"code": "MODULE_HIDDEN", "message": "模块隐藏或不可见。"}})
envelope = loads_json(module.content, {})
content = envelope.get("full") or envelope.get("content") or envelope
return {
"module_id": module.module_id,
"type": module.type,
"title_cn": module.title_cn,
"content": content,
"content_etag": module.content_etag,
"cache_version": report.cache_version,
}
async def institutions(self, *, topic: str | None = None, source_tier: str | None = None, page_size: int = 20) -> dict[str, Any]:
stmt = select(Institution).where(Institution.status == "active").order_by(Institution.source_tier, Institution.name_cn).limit(min(max(page_size, 1), 50))
if topic:
stmt = stmt.where(Institution.covered_topics.like(f"%{topic}%"))
if source_tier:
stmt = stmt.where(Institution.source_tier == source_tier)
rows = (await self.session.execute(stmt)).scalars().all()
return {"items": [institution_public(inst) for inst in rows], "page": {"next_cursor": None, "has_more": False}}
async def institution_detail(self, institution_id: str) -> dict[str, Any]:
inst = (await self.session.execute(select(Institution).where(Institution.institution_id == institution_id, Institution.status == "active"))).scalar_one_or_none()
if inst is None:
raise HTTPException(status_code=404, detail={"error": {"code": "INSTITUTION_NOT_FOUND", "message": "机构不存在。"}})
reports = await self.report_cards(institution_id=institution_id, page_size=5)
detail = institution_public(inst, detail=True)
detail["latest_report"] = reports["items"][0] if reports["items"] else None
detail["recent_reports"] = reports["items"]
return detail
async def listen_items(self, *, page_size: int = 20) -> dict[str, Any]:
stmt = (
select(AudioAsset, Report, Institution)
.join(Report, AudioAsset.report_id == Report.report_id)
.join(Institution, Report.institution_id == Institution.institution_id)
.where(AudioAsset.status == "published", Report.display_status == "published")
.order_by(Report.released_at.desc(), AudioAsset.audio_id)
.limit(min(max(page_size, 1), 50))
)
rows = (await self.session.execute(stmt)).all()
items = [
{
"audio_id": audio.audio_id,
"title_cn": audio.title_cn,
"duration_sec": audio.duration_sec,
"report_id": report.report_id,
"report_title_cn": report.title_cn,
"institution": institution_card(inst),
"released_at": iso(report.released_at),
"cache_version": report.cache_version,
}
for audio, report, inst in rows
]
return {"items": items, "page": {"next_cursor": None, "has_more": False}, "cache_version": "listen:seed:v1"}
async def seed_counts(self) -> dict[str, int]:
models = {
"institutions": Institution,
"reports": Report,
"audio_assets": AudioAsset,
"display_modules": DisplayModule,
"related_news": RelatedNews,
}
counts = {}
for name, model in models.items():
counts[name] = await self.session.scalar(select(func.count()).select_from(model)) or 0
return counts
+185
View File
@@ -0,0 +1,185 @@
# API and Data Handoff
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Current Implementation Status
Implemented in this repository:
- FastAPI app under `/api/report-notebooklm/v1`.
- SQLAlchemy model layer for the Phase 1 table set.
- Alembic initial migration.
- Seed import script with institutions, reports, modules, audio assets, users, favorites, and playback-progress fixtures.
- Public read endpoints for health, feeds, reports, modules, institutions, and listen list.
- Tests covering seed counts, public response shape, module visibility, gray-source handling, and listen behavior.
Not implemented yet:
- Auth APIs.
- Personal state APIs.
- Audio stream signing endpoint.
- Outbound events endpoint.
- Internal management APIs.
- Real Redis cache invalidation policy.
- Real object-storage signed URL policy.
- Production pagination/cursor behavior beyond seed-scale responses.
## Data Tables
| Table | Purpose | Current model |
|---|---|---|
| `institutions` | Institution profile, source tier, website, topics, credibility notes. | Implemented |
| `reports` | Report master record, source, topics, publication state, cache version. | Implemented |
| `raw_artifacts` | NotebookLM artifact metadata and object-storage references. | Implemented as metadata only |
| `display_artifacts` | Reviewed display version metadata for App consumption. | Implemented |
| `display_modules` | Detail-page modules, sort order, visibility, content or content reference. | Implemented |
| `audio_assets` | Audio metadata and object-storage key. | Implemented |
| `related_news` | Related-source candidates and reviewed related items. | Implemented |
| `users` | User account records. | Implemented as seed model, no auth routes |
| `favorites` | User report favorites. | Implemented as seed model, no API routes |
| `reading_history` | User reading/history events. | Implemented as model, no API routes |
| `saved_listens` | User saved-listen records. | Implemented as model, no API routes |
| `playback_progress` | Playback progress sync records. | Implemented as seed model, no API routes |
| `outbound_events` | External attribution events. | Implemented as model, no API route |
## Public API Implemented
Prefix: `/api/report-notebooklm/v1`
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/health` | Service health. |
| `GET` | `/feed/recommended` | Published report cards for recommendation feed. |
| `GET` | `/reports` | Published report cards with basic filters. |
| `GET` | `/reports/{report_id}` | Report detail skeleton and published modules. |
| `GET` | `/reports/{report_id}/modules/{module_id}` | Full content for a visible module. |
| `GET` | `/institutions` | Active institution list. |
| `GET` | `/institutions/{institution_id}` | Institution detail with latest/recent reports. |
| `GET` | `/listen` | Published audio-backed report list. |
Current filters:
- `/reports`: `topic`, `institution_id`, `has_audio`, `source_tier`, `q`, `page_size`.
- `/institutions`: `topic`, `source_tier`, `page_size`.
- `/feed/recommended` and `/listen`: `page_size`.
Current pagination is seed-scale. Responses return `next_cursor: null` and `has_more: false`.
## Planned Public API
The Phase 1 contract also expects:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/audio/{audio_id}/stream` | Return short-lived playable URL. |
| `POST` | `/outbound/events` | Persist external attribution click event. |
Audio stream must not return a permanent object-storage URL. The planned behavior is backend-signed short-lived playback URL with no download URL.
## Planned Auth and Personal State API
Auth:
- `POST /auth/phone/start`
- `POST /auth/phone/verify`
- `POST /auth/wechat`
- `POST /auth/apple`
Personal state:
- `GET /me`
- `GET /me/favorites`
- `POST /me/favorites`
- `DELETE /me/favorites/{report_id}`
- `GET /me/history`
- `POST /me/history`
- `GET /me/listens/saved`
- `POST /me/listens/saved`
- `DELETE /me/listens/saved/{audio_id}`
- `POST /me/playback-progress`
- `GET /me/playback-progress/{audio_id}`
These endpoints are contract-level requirements but are not implemented in this scaffold.
## Planned Internal API
Internal APIs should require service token and network allowlist. They must never be exposed to the App.
- `POST /internal/reports`
- `POST /internal/reports/{report_id}/raw-artifacts`
- `GET /internal/reports/{report_id}/raw-artifacts`
- `POST /internal/reports/{report_id}/display-artifacts`
- `PATCH /internal/modules/{module_id}`
- `POST /internal/reports/{report_id}/publish`
- `POST /internal/reports/{report_id}/hide`
- `POST /internal/related-news/candidates`
Publishing should update report display status, update `has_audio`, bump `cache_version`, and clear related cache keys.
## Public vs Internal Fields
Public responses may expose:
- Report identity, title, subtitle, one-liner, topics, institution card, release time, source tier, interpretation label, `has_audio`, and `cache_version`.
- Detail source note, source URL where allowed, risk disclaimer, and published display modules.
- Module metadata needed by the client: `module_id`, `type`, `layer`, `render_mode`, `has_detail_page`, `is_publish_blocking`, `requires_human_review`, `sort_order`, `title_cn`, `content`, `preview`, `content_ref`, `content_etag`.
Public responses must not expose:
- Raw artifact payload.
- Object-storage private paths for raw artifacts.
- NotebookLM notebook IDs, source IDs, conversation IDs, or local account information.
- Local filesystem paths.
- `display_version` or `module.version`.
- User phone hash, WeChat OpenID, Apple user ID, or auth internals.
The public cache contract is a single `cache_version` string. `display_version` and module `version` are server-internal fields only.
## Seed Data
The seed importer currently creates:
- 18 institutions.
- 27 reports, including one NotebookLM sample report and multiple boundary cases.
- 15 audio assets.
- More than 120 display modules.
- Test users, favorites, and playback progress.
Seed boundary cases intentionally cover:
- Reports with audio and reports without audio.
- Hidden/unpublished report behavior.
- Gray broker source with restricted source URL behavior.
- Published modules vs review-only modules.
- `study_guide` module replacing legacy `faq`.
- Heavy modules using `card_plus_page` preview plus full-module endpoint.
Do not treat seed content as production content. It exists to exercise app/API behavior and edge cases.
## Detail Module Model
The detail page uses a skeleton plus module model:
- Inline modules include small `content` directly in the detail response.
- Heavy modules use `render_mode=card_plus_page`, return `preview` in detail, and load full content from `/reports/{report_id}/modules/{module_id}`.
- Unknown future module types should not break the App; they should fall back to hidden or generic rendering.
Core module types:
- `basic_info`
- `executive_overview`
- `core_insights`
- `key_data`
- `source_compliance`
- `institution`
- `differentiated_view`
- `weaknesses`
- `timeline`
- `study_guide`
- `structure_graph`
- `related_sources`
- `infographic`
- `audio`
- `research_discovery`
@@ -0,0 +1,142 @@
# Content Pipeline Handoff
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Content Principle
Use NotebookLM as a source-driven research engine, not as a generic rewriting model.
The pipeline may orchestrate, clean, validate, map, and review NotebookLM-native artifacts. It must not silently replace missing NotebookLM artifacts with locally rewritten publishable content.
## Source Inputs
Phase 1 content is based on public or authorized institutional research reports. Priority source categories:
- Official public sources.
- Authorized partner sources.
- Gray broker public sources, with stricter review and source display handling.
Vision source lists, tiering, and historical source-health experience may be used as reference material. Production data must not depend on a local Vision runtime, local path, local cache, or local account state.
## NotebookLM Workflow
Recommended report run order:
1. Inspect the source PDF: title, institution, date, page count, size, and report type.
2. Create or reuse one notebook for one report source unless a multi-report synthesis is explicitly planned.
3. Upload the report source.
4. Generate the P0 text package:
- source description
- native Briefing Doc
- native Blog Post
- data table
- query dimensions
- query key data
- query divergence
- query weaknesses
5. Generate useful P1 artifacts:
- query timeline
- query related sources
- Study Guide
- mind map, if download succeeds
6. Generate P2 artifacts asynchronously:
- infographic candidate
- audio brief
- research discovery
7. Persist every artifact status in a manifest.
8. Deterministically assemble display modules from reviewed artifacts.
9. Run human review before publishing.
## Artifact Types
The Phase 1 schema supports these NotebookLM artifact types:
| Artifact type | Purpose | Publish blocking | Human review |
|---|---|---:|---:|
| `source_summary` | Source-level summary. | No | No |
| `notebook_summary` | Notebook-level summary. | No | No |
| `native_briefing_doc` | Native briefing document. | Yes | No |
| `native_blog_post` | Native blog post. | Yes | No |
| `native_study_guide` | FAQ, study guide, glossary. | No | No |
| `data_table` | Structured table data. | Yes | No |
| `mind_map` | Mind map or graph source. | No | No |
| `query_dimensions` | Analysis dimensions. | Yes | No |
| `query_key_data` | Key data points. | Yes | No |
| `query_divergence` | Views that diverge from consensus. | No | No |
| `query_weaknesses` | Weaknesses and open questions. | No | No |
| `query_timeline` | Timeline and turning points. | No | No |
| `query_related_sources` | Related source candidates. | No | Yes |
| `research_discovery` | Enrichment queue. | No | Yes |
| `infographic` | Candidate public image. | No | Yes |
| `audio_brief` | Listening preview or audio source. | No | No |
Artifact records should keep status, object reference, format, size, hash, generated time, error, and review flags. Raw payloads should stay in object storage and remain internal.
## Module Mapping
| Product module | Primary artifact sources | Notes |
|---|---|---|
| `basic_info` | Source metadata and source summary. | P0, inline. |
| `executive_overview` | Briefing Doc and Blog Post. | P0, heavy card plus page. |
| `core_insights` | Briefing Doc and query dimensions. | P0, inline with optional detail page. |
| `key_data` | Data table and query key data. | P0, heavy card plus page. |
| `source_compliance` | Source metadata and review notes. | P0, inline, must include disclaimer. |
| `institution` | Institution record. | P0, inline. |
| `differentiated_view` | Query divergence. | P1, optional. |
| `weaknesses` | Query weaknesses. | P1, optional, avoid investment-advice wording. |
| `timeline` | Query timeline. | P1, optional. |
| `study_guide` | Native Study Guide. | P1, optional, replaces legacy `faq`. |
| `structure_graph` | Mind map or deterministic fallback. | P1, optional. |
| `related_sources` | Related-source query and review queue. | P1, review required before display. |
| `infographic` | Infographic candidate. | P2, review required before display. |
| `audio` | Audio brief or reviewed audio asset. | P2, not required for text publish. |
| `research_discovery` | Research discovery queue. | P2, internal or reviewed only. |
## Publish Gates
Blocking before public release:
- Source upload succeeded and is traceable.
- Required P0 text artifacts exist and have usable content.
- `basic_info`, `executive_overview`, `core_insights`, `key_data`, and `source_compliance` are present unless a product decision allows a partial report.
- Display artifact is reviewed and approved.
- Source attribution and risk disclaimer are present.
- No raw artifact payload, local path, private notebook ID, or account information appears in public responses.
Non-blocking:
- Mind map.
- Study guide.
- Timeline.
- Related-source candidates.
- Research discovery.
- Infographic.
- Audio.
If optional artifacts fail, record the failure and continue without inventing fallback public copy. Deterministic fallback is allowed for structure graph from already available artifacts.
## Cadence Notes
NotebookLM operations should be conservative by default:
- One active NotebookLM operation per account.
- Text artifacts first.
- Media artifacts after text success.
- Heavy media should not block publishable text.
- On transient failure, retry once; if an optional artifact fails again, mark it failed and continue.
The seed importer is not a production runner. A production runner should persist manifests after every operation and support resumable review/import.
## Human Review
Review is mandatory for:
- Gray broker sources.
- Related-source candidate display.
- Infographic or generated media.
- Any content where citations/page labels are ambiguous.
- Any copy that could be interpreted as investment advice.
Do not display raw NotebookLM page labels until they are normalized against verifiable source pages or sections.
+84
View File
@@ -0,0 +1,84 @@
# Backend Handoff
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Current State
The backend is a runnable Phase 1 scaffold for the public read surface. It is not production-ready yet.
Implemented:
- FastAPI app and API prefix.
- SQLAlchemy models for the Phase 1 table set.
- Alembic initial migration.
- Seed import script.
- Public read API for feed, reports, module detail, institutions, and listen list.
- Tests for current seed behavior and public response boundaries.
Not implemented:
- Authentication.
- User personal-state routes.
- Audio stream signing.
- Outbound attribution route.
- Internal management routes.
- Production object storage integration.
- Real Redis cache invalidation.
- Production deployment config.
## Repository Map
| Path | Purpose |
|---|---|
| `app/main.py` | FastAPI app, CORS, router registration. |
| `app/config.py` | Environment-driven settings. |
| `app/db.py` | Async SQLAlchemy engine and session dependency. |
| `app/cache.py` | Redis client helper and key prefixing. |
| `app/models/entities.py` | SQLAlchemy table models. |
| `app/routers/` | HTTP route handlers. |
| `app/services/catalog.py` | Public catalog response assembly. |
| `migrations/` | Alembic environment and migration files. |
| `scripts/import_seed_content.py` | Seed data importer and module fixture builder. |
| `tests/test_public_api.py` | Current API and seed behavior tests. |
| `docs/` | Engineering handoff documentation. |
## Solved Decisions
- Technical identifiers stay `report-notebooklm` / `rnb`; display name is `研听`.
- Public API responses expose `cache_version`, not `display_version` or module `version`.
- `study_guide` replaces legacy `faq`.
- Heavy modules use preview cards plus full-module endpoint.
- Raw artifacts stay internal; App consumes reviewed display artifacts only.
- Gray broker sources may be audio-ized only after the latest product decision and compliance review.
- Phase 1 has no interpretation-content download feature.
## Known Gaps
- `GET /audio/{audio_id}/stream` needs signed playback URL behavior.
- Auth and personal state APIs need implementation.
- `POST /outbound/events` needs implementation and validation for `click_id` / `tracking_id`.
- Internal publish/hide/import management endpoints need implementation.
- Cursor pagination and cache invalidation are seed-scale placeholders.
- Object storage policy needs a production decision for public vs signed module content.
- Release/deploy settings need staging and production environment values.
- Compliance must re-review gray-source audio and generated media rules before launch.
## Suggested Handoff Order
1. Read `docs/PROJECT_BRIEF.md`.
2. Read `docs/API_AND_DATA.md`.
3. Run the backend locally with seed data using `docs/RUNBOOK.md`.
4. Run `pytest -q` and smoke the three core public endpoints.
5. Pair with `report-notebooklm-app/` and verify `RNB_API_BASE` points to this service.
6. Choose the next work item from `docs/ROADMAP_AND_OPEN_ISSUES.md`.
## Definition of Done for Next Backend Work
- New API behavior has tests.
- Public responses do not expose internal/raw fields.
- Migrations include downgrade.
- New config is environment-driven.
- Seed data remains useful for App development.
- Documentation is updated when contract behavior changes.
@@ -0,0 +1,71 @@
# Project Brief Snapshot
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Product
`研听` is a Chinese research-report interpretation app for users who want to understand global institutional research with lower language and time barriers. It turns hard-to-read English research reports into structured Chinese reading and listening experiences.
Technical identifiers remain `report-notebooklm` and `rnb`. Do not use the product display name in code identifiers, database schema names, Redis keys, object storage paths, or API prefixes.
## Phase 1 Goals
- Validate whether Chinese users will repeatedly consume global institutional research-report interpretations.
- Ship a complete first app experience for discovery, reading, listening, saving, and returning to reports.
- Establish a minimum loop from report sources to selection, NotebookLM-assisted interpretation, review, storage, API distribution, and app display.
- Keep source attribution and compliance clear: this is report interpretation and annotation, not investment advice.
- Keep the commercial app independent from any local-only Vision runtime.
## Target Users
- General Chinese users interested in macro, precious metals, commodities, energy, central banks, and cross-asset research.
- Light professional users who want overseas institutional views and original-source traceability, without trading advice.
- Commuting or fragmented-time users who want reports transformed into listenable content.
Non-target users: professional terminal users, real-time trading-signal users, UGC/community users, and users expecting original investment recommendations.
## Main Tabs
| Tab | Phase 1 scope | Explicitly out of scope |
|---|---|---|
| 推荐 | Latest and curated report interpretations. | Ads, hard trading CTAs, real-time news flashes. |
| 研报 | All published report interpretations with basic filters. | Advanced investment terminal search. |
| 机构 | Institution list and institution report entry points. | Commercial institution ranking or onboarding backend. |
| 听单 | Reports that have audio form. | User-created podcasts, downloads, offline packages. |
| 我的 | Guest/login state, favorites, history, saved listening entry points. | Comments, UGC, paid membership, points. |
## Phase 1 Must Do
- Public browsing for recommended reports, report list, institutions, and listen list.
- Report detail pages with title, institution, publication/release data, source type, topics, summary, structured modules, source/compliance information, and favorite entry.
- Guest users can browse public content and fully listen to at least one episode.
- Logged-in users can synchronize favorites, reading history, saved listens, and playback progress.
- Published app responses must expose only reviewed display artifacts, not raw NotebookLM artifacts.
- Every report detail must preserve source attribution and risk disclaimer wording.
## Phase 1 Must Not Do
- No commercialization: no ads, paid unlock, membership, task wall, or points.
- No comments, community, UGC, or user-generated report interpretations.
- No investment advice, trading signals, buy/sell points, return promises, or portfolio recommendations.
- No original financial news, real-time reporting, or commentary positioned as original market views.
- No in-product downloads for interpretation content, audio packages, or PDFs.
- No long-term production dependency on a local Vision runtime, local SQLite, local scripts, local paths, or local account state.
- No App or server-side LLM rewriting of NotebookLM-native content into unsupported original copy.
## Compliance Boundary
- Positioning: research-report interpretation and annotation service.
- Content: Chinese interpretation of public or authorized institutional reports.
- Detail pages, agreements, and store metadata must state that content is not investment advice.
- Each item must show institution, source, publication time, and interpretation/source labels.
- Gray broker sources require special handling and human review before public release.
- Phase 1 does not open user content surfaces.
## Vision Decoupling
Vision source experience can be reused as reference material: source lists, source tiers, source-health lessons, NotebookLM experience, and prior pitfalls.
The app must not depend on local Vision runtime state in production. Any short-term Vision consumption must be read-only transition input, must not write back to Vision, and must not leak local file paths into production data.
@@ -0,0 +1,57 @@
# Roadmap and Open Issues
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## P0 Before Production Handoff
- Add environment examples and production-safe defaults for all deploy-time settings.
- Decide staging and production API domains.
- Implement `GET /audio/{audio_id}/stream` with short-lived signed playback URL.
- Implement auth start/verify flow and token handling.
- Implement `/me` personal-state APIs for favorites, history, saved listens, and playback progress.
- Implement `POST /outbound/events` with required `click_id` and `tracking_id`.
- Implement production cursor pagination.
- Implement cache invalidation on publish/hide/module/audio changes.
- Add smoke scripts for health, feed, detail, listen, audio stream, favorite, and outbound event.
## P1 Content and Admin
- Implement internal APIs for report import, raw artifacts, display artifacts, module patching, publish, hide, and related-source candidates.
- Implement production content importer from a manifest-based NotebookLM runner.
- Add validation for module JSON schemas.
- Add object storage integration for raw payloads, heavy module content, audio, images, and source references.
- Add publish blocking validation for P0 modules.
- Add gray-source review flags and operational reporting.
## P1 App/API Contract
- Align App with real auth state and return-to-action behavior.
- Add playable audio stream integration once backend stream endpoint exists.
- Replace local playback placeholders with API-backed progress.
- Add real outbound event write before external navigation.
- Decide whether heavy P1 modules stay as separate pages or merge into one deep-dive page.
## P2 Production Operations
- Add structured logs and request IDs.
- Add application metrics for feed/detail/listen/audio/outbound.
- Add backup and restore runbook for database and content objects.
- Add staging seed or reviewed staging content set.
- Add CI checks for lint, tests, migrations, and public response snapshots.
## Product and Compliance Open Issues
- Re-review gray-source audio policy before public release.
- Define AI-generated-content labeling requirements in App detail and store metadata.
- Define infographic watermark, QA, and factual-check process.
- Define source citation display rules after citation/page-label normalization.
- Confirm login channels and external approvals: phone SMS, WeChat, Apple.
- Confirm store listing wording and risk disclaimers.
## Gitea Handoff Blockers
- Use the single Gitea remote for the monorepo.
- Decide whether the initial push goes directly to `main` or to a review branch.
- Confirm the team has access to the product SSOT or accepts the code-repo snapshot as the development handoff.
+112
View File
@@ -0,0 +1,112 @@
# Backend Runbook
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Requirements
- Python 3.12 or compatible with the configured project dependencies.
- MySQL 8 for local/staging/prod-like runs.
- Redis 7 for cache-compatible local/staging/prod-like runs.
- A shell environment that can create a Python virtual environment.
SQLite is used by the automated tests through `RNB_DATABASE_URL`; production-like local runs should use MySQL.
## Environment Variables
Create `.env` in the repository root:
```bash
RNB_DATABASE_URL=mysql+asyncmy://<db-user>:<db-pass>@<db-host>:<db-port>/report_notebooklm
RNB_REDIS_URL=redis://<redis-host>:<redis-port>/0
RNB_REDIS_KEY_PREFIX=rnb:
```
Do not commit `.env`.
## Install
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
## Migrate and Seed
```bash
source .venv/bin/activate
alembic upgrade head
python scripts/import_seed_content.py
```
Seed import is destructive for seed tables. Use it only in local or disposable test data environments unless a production-safe importer is written.
## Run API
```bash
source .venv/bin/activate
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
API prefix:
```text
/api/report-notebooklm/v1
```
## Smoke Checks
```bash
API_BASE_URL=http://<api-host>:<port>/api/report-notebooklm/v1
curl "$API_BASE_URL/health"
curl "$API_BASE_URL/feed/recommended"
curl "$API_BASE_URL/reports/rep_ssga_gold"
```
Expected:
- Health returns `{"status":"ok"}`.
- Feed returns non-empty `items`.
- Report detail returns modules and does not include `display_version`.
## Test
```bash
source .venv/bin/activate
pytest -q
```
## App Integration
Start this backend first, then run the App with:
```bash
flutter run -d chrome --dart-define=RNB_API_BASE=<api-base-url>
```
For Android emulator, use an API base URL reachable from that emulator:
```bash
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
Only use cleartext HTTP for local debug builds. Release builds must use HTTPS.
## Deployment Checks
Before staging or production:
- Use environment variables for all database, Redis, object storage, auth, and signing settings.
- Configure HTTPS at the gateway.
- Confirm migrations can run forward and downgrade in staging.
- Import reviewed content, not raw/unreviewed NotebookLM artifacts.
- Smoke `/health`, `/feed/recommended`, report detail, audio stream, favorites, and outbound event once those APIs exist.
- Confirm public responses do not expose local paths, raw payloads, notebook IDs, source IDs, conversation IDs, or secrets.
## Operational Notes
- Redis keys must use the `rnb:` prefix or a compatible namespace.
- Object storage keys should use `rnb/raw/`, `rnb/modules/`, `rnb/audio/`, and `rnb/images/` style prefixes.
- Long NotebookLM operations should live in a resumable runner, not inside HTTP request handlers.
@@ -0,0 +1,35 @@
# Source Index
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Snapshot Sources
The handoff documents in this repository were distilled from these logical product-document sources:
| Logical source | Used for |
|---|---|
| `phase1-scope.md` | Product positioning, target users, tabs, Phase 1 scope, non-goals, compliance boundary, Vision decoupling. |
| `phase1-build-brief.md` | Data tables, endpoint list, display module model, artifact enum, seed mapping, open questions. |
| `phase1-development-plan.md` | Technology choices, architecture, Redis/object-storage strategy, phases, deployment assumptions, external dependencies. |
| `data-model-api-contract-v0.1.md` | API/data object intent and response boundaries. |
| `user-flows.md` | Guest vs logged-in behavior, shallow interaction expectations, no-download clarification. |
| `app-prd-v0.1.md` | App-side behavior and page-level expectations. |
| `vision-research-sources.md` | Source-reference context and Vision decoupling principle. |
## Drift Rule
Do not treat this repository snapshot as the product SSOT. When product requirements change:
1. Update the product SSOT first.
2. Update this code-repo snapshot only for information needed by engineers.
3. Bump the snapshot date or add a short changelog entry.
## What Was Not Copied
- Historical drafts.
- Full experiment reports.
- Local-only evidence paths.
- Private local notes.
- Raw NotebookLM notebook IDs, source IDs, conversation IDs, account identifiers, or payloads.
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import get_settings
from app.db import Base
from app.models import * # noqa: F401,F403
config = context.config
config.set_main_option("sqlalchemy.url", get_settings().database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=get_settings().database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
@@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,26 @@
"""phase1 initial tables
Revision ID: 202606030100
Revises:
Create Date: 2026-06-03 01:00:00
"""
from alembic import op
from app.db import Base
from app.models import * # noqa: F401,F403
revision = "202606030100"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade() -> None:
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "report-notebooklm-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn",
"sqlalchemy",
"greenlet",
"alembic",
"pydantic",
"pydantic-settings",
"asyncmy",
"redis",
]
[project.optional-dependencies]
dev = [
"aiosqlite",
"httpx",
"pytest",
"pytest-asyncio",
]
[tool.setuptools.packages.find]
include = ["app*", "scripts*"]
exclude = ["migrations*", "tests*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
@@ -0,0 +1 @@
@@ -0,0 +1,649 @@
from __future__ import annotations
import asyncio
import csv
import datetime as dt
import hashlib
import re
import json
import sys
from typing import Any
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Base, SessionLocal, engine
from app.models import (
AudioAsset,
DisplayArtifact,
DisplayModule,
Favorite,
Institution,
OutboundEvent,
PlaybackProgress,
RawArtifact,
ReadingHistory,
RelatedNews,
Report,
SavedListen,
User,
)
def j(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def d(value: str) -> dt.datetime:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
def etag(value: Any) -> str:
return hashlib.sha256(j(value).encode("utf-8")).hexdigest()[:16]
REAL_SAMPLE_REPORT_ID = "rep_bis_notebooklm_sample"
REAL_SAMPLE_ROOT = (
Path.home()
/ "Projects/team-project/mall-docs/products/type3-orbit/report-notebooklm/docs.jimme.local/report-notebooklm/notebooklm-capability-bis-2026-06-02"
)
REAL_SAMPLE_ARTIFACTS = REAL_SAMPLE_ROOT / "artifacts"
def read_real_sample(name: str) -> str:
path = REAL_SAMPLE_ARTIFACTS / name
if not path.exists():
return ""
return path.read_text(encoding="utf-8-sig")
def clean_markdown_text(value: str) -> str:
text = re.sub(r"\[\d+(?:[-, ]+\d+)*\]", "", value)
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
text = text.replace("`", "")
return re.sub(r"\s+", " ", text).strip()
def markdown_sections(markdown: str, *, min_level: int = 2, limit: int = 8) -> list[dict[str, str]]:
sections: list[dict[str, str]] = []
current_heading = ""
current_lines: list[str] = []
heading_re = re.compile(r"^(#{%d,4})\s+(.+)$" % min_level)
for raw_line in markdown.splitlines():
line = raw_line.strip()
if line == "## Citations":
break
match = heading_re.match(line)
if match:
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
current_heading = clean_markdown_text(match.group(2))
current_lines = []
continue
if current_heading and line and not line.startswith("---") and not line.startswith("|"):
current_lines.append(line)
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
return sections[:limit]
def numbered_sections(markdown: str, *, limit: int = 8) -> list[dict[str, str]]:
sections: list[dict[str, str]] = []
pattern = re.compile(r"^(?:###\s*)?\d+\.\s+\**(.+?)\**$")
current_heading = ""
current_lines: list[str] = []
for raw_line in markdown.splitlines():
line = raw_line.strip()
if line == "## Citations":
break
match = pattern.match(line)
if match:
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
current_heading = clean_markdown_text(match.group(1))
current_lines = []
continue
if current_heading and line and not line.startswith("#") and not line.startswith("---"):
current_lines.append(line)
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
return sections[:limit]
def split_heading_body(section: dict[str, str]) -> tuple[str, str]:
body = section["body"]
parts = re.split(r"研报观点与证据:|证据:|影响:", body, maxsplit=1)
if len(parts) == 2:
return clean_markdown_text(parts[0]), clean_markdown_text(parts[1])
return "", body
def key_data_rows() -> list[dict[str, str]]:
csv_text = read_real_sample("data-table.csv")
rows: list[dict[str, str]] = []
if csv_text:
for row in csv.DictReader(csv_text.splitlines()):
rows.append(
{
"metric": row.get("数据点/指标名称", ""),
"value": row.get("定量数值或趋势", ""),
"unit": "",
"importance": row.get("风险/修订指示", ""),
"judgment": row.get("相关行业或资产类别", ""),
}
)
if rows:
return rows
return [
{"metric": "M7 市值占比", "value": "近 35%", "unit": "", "importance": "提示指数集中度风险", "judgment": "美国大型科技股"},
{"metric": "SRT 覆盖贷款", "value": "约 8000 亿欧元", "unit": "", "importance": "提示隐藏信贷风险规模", "judgment": "银行业 / 非银机构"},
]
def sample_artifact_types() -> list[str]:
return [
"describe-source",
"native_briefing_doc",
"native_blog_post",
"native_study_guide",
"data_table",
"query_dimensions",
"query_key_data",
"query_divergence",
"query_weaknesses",
"query_timeline",
"query_related_sources",
"audio_brief",
]
MODULE_TITLES = {
"basic_info": "报告概览",
"executive_overview": "报告摘要",
"audio": "听研报",
"core_insights": "报告要点",
"key_data": "报告中的关键数据",
"differentiated_view": "观点差异",
"weaknesses": "局限与疑问",
"timeline": "时间线",
"study_guide": "术语与问答",
"structure_graph": "结构梳理",
"related_sources": "延伸阅读",
"source_compliance": "报告来源",
}
MODULE_DISPLAY_ORDER = {module_type: index for index, module_type in enumerate(MODULE_TITLES)}
def real_sample_module_envelope(module_type: str, report_id: str, title: str, institution_name: str) -> dict[str, Any]:
briefing = read_real_sample("briefing-doc.md")
blog = read_real_sample("blog-post.md")
study = read_real_sample("study-guide.md")
dimensions = read_real_sample("query-dimensions.md")
key_data = read_real_sample("query-key-data.md")
divergence = read_real_sample("query-divergence.md")
weaknesses = read_real_sample("query-weaknesses.md")
timeline = read_real_sample("query-timeline.md")
related_sources = read_real_sample("query-related-sources.md")
briefing_sections = markdown_sections(briefing, min_level=2, limit=7)
blog_sections = markdown_sections(blog, min_level=3, limit=7)
dimension_sections = numbered_sections(dimensions, limit=5)
key_data_sections = numbered_sections(key_data, limit=12)
divergence_sections = numbered_sections(divergence, limit=5)
weakness_sections = numbered_sections(weaknesses, limit=5)
timeline_sections = numbered_sections(timeline, limit=10)
related_sections = numbered_sections(related_sources, limit=8)
study_faq = numbered_sections(study, limit=5)
key_rows = key_data_rows()
core_points = [
{"kind": "view", "text": "市场表面平静,但底层已经从美国大型科技股向欧洲、日本、新兴市场、价值股和小盘股重新轮动。"},
{"kind": "number", "text": "M7 在标普 500 指数中的市值占比接近 35%,单一板块波动正在显著影响指数风险。"},
{"kind": "risk", "text": "AI 基础设施融资从现金流叙事转向债务和表外融资,私人信贷、保险公司和银行授信之间的关联增强。"},
{"kind": "risk", "text": "白银在 2026 年 1 月先涨超 50%、后单日跌近 30%,暴露了杠杆 ETF 再平衡和保证金触发平仓的放大效应。"},
]
base = {
"basic_info": {
"content": {
"report_id": report_id,
"title_cn": title,
"summary_cn": "BIS 2026 年 3 月季度评论,回顾 2025 年 11 月 29 日至 2026 年 3 月 5 日的全球金融市场变化,覆盖市场轮动、AI 融资、私募信贷、贵金属和新兴市场政策反应。",
"topics": ["宏观金融", "金融稳定", "AI 融资", "非银风险"],
"interpretation_label": "研报解读",
}
},
"executive_overview": {
"preview": {
"preview_summary": "报告认为,本轮变化不是单一资产回调,而是高估值科技股、AI 基础设施融资、贵金属杠杆交易和非银信用链条共同推动的市场重新校准。它的核心价值在于把看似分散的市场波动,放回金融稳定和跨市场传导的框架里理解。",
"section_count": len(briefing_sections) + len(blog_sections),
"key_quote_snippet": "全球金融市场在表面的平静下经历了深刻的流向切换与重新校准。",
"highlights": ["资金从美国大型科技股转向欧洲、日本和新兴市场", "AI 基础设施融资开始暴露信用风险", "贵金属波动显示杠杆交易的放大效应"],
},
"full": {
"intro_cn": "这份报告把 2025 年底到 2026 年初的市场变化概括为一次跨资产重新校准:美国大型科技股降温,资金转向欧洲、日本和新兴市场;AI 融资从高成长叙事进入债务和表外风险阶段;贵金属和非银金融机构的波动说明杠杆与流动性仍是金融稳定的关键变量。",
"sections": briefing_sections + blog_sections[:4],
"source_artifacts": ["native_briefing_doc", "native_blog_post"],
},
},
"core_insights": {
"content": {"points": core_points},
"full": {
"points": core_points,
"dimensions": [{"dimension": item["heading"], "summary": item["body"]} for item in dimension_sections],
},
},
"key_data": {
"preview": {
"preview_headline": "8 个真实关键数据点",
"highlights": [f"{row['metric']}{row['value']}" for row in key_rows[:3]],
"row_count": len(key_rows),
},
"full": {
"rows": key_rows,
"source_artifacts": ["data_table", "query_key_data"],
"supporting_notes": [{"heading": item["heading"], "body": item["body"]} for item in key_data_sections[:6]],
},
},
"source_compliance": {
"content": {
"source_url": "https://www.bis.org/publ/qtrpdf/r_qt2603.htm",
"source_note": "原文为 BIS Quarterly Review, March 2026 的公开研报;本页仅提供中文解读,不提供解读内容下载。",
"copyright_cn": "原文版权归发布机构所有;本页为基于公开研报整理的中文阅读辅助。",
"disclaimer": "本内容仅供研报阅读参考,不构成投资建议。",
"ai_generated_label": "AI 辅助生成",
}
},
"differentiated_view": {
"preview": {
"preview_headline": "5 处与常见叙事的分歧",
"highlights": [item["heading"] for item in divergence_sections[:3]],
"divergence_count": len(divergence_sections),
},
"full": {
"divergences": [
{
"topic": item["heading"],
"consensus_view": split_heading_body(item)[0] or "常规叙事没有充分覆盖该维度。",
"report_position": split_heading_body(item)[1],
}
for item in divergence_sections
]
},
},
"weaknesses": {
"preview": {
"preview_headline": "5 个论证弱点与反方向证据",
"highlights": [item["heading"] for item in weakness_sections[:3]],
"item_count": len(weakness_sections),
"disclaimer_brief": "只做论证质量分析,不做投资建议。",
},
"full": {
"disclaimer_cn": "以下仅分析研报论证质量,不构成投资建议。",
"verification_notes": ["以上问题需要结合后续市场数据、原文脚注和反方向证据继续验证。"],
"items": [
{
"topic": item["heading"],
"weakness": item["body"],
"counter_evidence": "需要结合后续数据、原文脚注与反方向证据继续验证。",
}
for item in weakness_sections
],
},
},
"timeline": {
"preview": {
"preview_headline": "10 个关键事件节点",
"date_range": "1990s-2026",
"highlights": [item["heading"] for item in timeline_sections[:3]],
"event_count": len(timeline_sections),
},
"full": {
"events": [
{
"date": item["heading"],
"period_type": "report_timeline",
"event": item["heading"],
"impact": item["body"],
}
for item in timeline_sections
]
},
},
"study_guide": {
"preview": {
"preview_headline": "术语与问答",
"faq_count": len(study_faq),
"glossary_count": 8,
"sample_question": study_faq[0]["heading"] if study_faq else "为什么要读这份 BIS 季报?",
"highlights": ["核心概念摘要", "简答练习题", "重要术语表"],
},
"full": {
"intro_cn": "这一部分整理了阅读本篇研报时容易遇到的概念、问题和术语。",
"faq_items": [{"question": item["heading"], "answer": item["body"]} for item in study_faq],
"glossary": [
{"term": "M7", "definition": "主导美国股市的七大科技巨头。"},
{"term": "SRT", "definition": "合成风险转移,银行通过衍生品或担保转移部分信用风险。"},
{"term": "BISTRO", "definition": "BIS Time-series Regression Oracle,宏观时间序列预测工具。"},
{"term": "NBFI", "definition": "非银行金融机构。"},
{"term": "Shadow Borrowing", "definition": "经济实质类似债务、但主要存在于资产负债表外的融资安排。"},
{"term": "BDCs", "definition": "业务发展公司,是私募信贷市场的公开交易窗口之一。"},
{"term": "Carry Trade", "definition": "借入低息货币、投资高息资产的套利交易。"},
{"term": "Margin-triggered Liquidations", "definition": "保证金要求上升触发的被迫平仓。"},
],
},
},
"structure_graph": {
"preview": {
"preview_headline": "结构梳理",
"root": "BIS 季报:分析框架",
"top_nodes": [item["heading"] for item in dimension_sections[:5]],
"fallback_derived": True,
},
"full": {
"root": "BIS 季报:分析框架",
"nodes": [
{
"label": item["heading"],
"children": [phrase.strip("") for phrase in re.split(r"[。;;]", item["body"])[:3] if phrase.strip()],
}
for item in dimension_sections
],
"fallback_derived": True,
"source_artifacts": ["query_dimensions"],
},
},
"related_sources": {
"content": {
"items": [
{"title": item["heading"], "source_name": "延伸资料", "summary_cn": item["body"]}
for item in related_sections[:3]
],
"review_note": "延伸来源仅作为候选队列,正式展示前需要人工审核。",
},
"full": {
"items": [
{"title": item["heading"], "source_name": "延伸资料", "summary_cn": item["body"]}
for item in related_sections
],
"review_note": "延伸来源仅作为候选队列,正式展示前需要人工审核。",
},
},
"audio": {
"content": {
"audio_id": "aud_bis_notebooklm_sample",
"title_cn": "BIS 季度评论",
"duration_sec": 75,
"chapters": [],
}
},
}
return base[module_type]
INSTITUTIONS = [
("inst_wgc", "世界黄金协会", "World Gold Council", "industry_org", "tier_1", "https://www.gold.org/", ["贵金属", "央行"]),
("inst_imf", "国际货币基金组织", "International Monetary Fund", "international_org", "tier_1", "https://www.imf.org/", ["宏观金融", "外汇"]),
("inst_world_bank", "世界银行", "World Bank", "international_org", "tier_1", "https://www.worldbank.org/", ["大宗商品", "发展经济"]),
("inst_iea", "国际能源署", "International Energy Agency", "international_org", "tier_1", "https://www.iea.org/", ["能源", "原油"]),
("inst_eia", "美国能源信息署", "U.S. Energy Information Administration", "official", "tier_1", "https://www.eia.gov/", ["能源", "原油"]),
("inst_usgs", "美国地质调查局", "U.S. Geological Survey", "official", "tier_1", "https://www.usgs.gov/", ["矿产", "贵金属"]),
("inst_ecb", "欧洲央行", "European Central Bank", "official", "tier_1", "https://www.ecb.europa.eu/", ["货币政策", "欧元区"]),
("inst_bis", "国际清算银行", "Bank for International Settlements", "international_org", "tier_1", "https://www.bis.org/", ["宏观金融", "金融稳定"]),
("inst_fed", "美联储", "Federal Reserve", "official", "tier_1", "https://www.federalreserve.gov/", ["货币政策", "美元"]),
("inst_opec", "欧佩克", "OPEC", "international_org", "tier_1", "https://www.opec.org/", ["能源", "原油"]),
("inst_ssga", "道富环球投资管理", "State Street Global Advisors", "asset_manager", "tier_2", "https://www.ssga.com/", ["贵金属", "跨资产"]),
("inst_wisdomtree", "WisdomTree", "WisdomTree", "asset_manager", "tier_2", "https://www.wisdomtree.com/", ["大宗商品", "资产配置"]),
("inst_ing", "ING 银行研究", "ING Think", "bank_research", "tier_2", "https://think.ing.com/", ["贵金属", "外汇"]),
("inst_silver_institute", "白银协会", "The Silver Institute", "industry_org", "tier_2", "https://silverinstitute.org/", ["白银", "矿产"]),
("inst_goldman", "高盛研究", "Goldman Sachs Research", "bank_research", "tier_3", "https://www.goldmansachs.com/", ["大宗商品", "宏观"]),
("inst_jpm", "摩根大通研究", "J.P. Morgan Research", "bank_research", "tier_3", "https://www.jpmorgan.com/", ["大宗商品", "宏观"]),
("inst_invesco", "景顺", "Invesco", "asset_manager", "tier_3", "https://www.invesco.com/", ["ETF", "资产配置"]),
("inst_pas", "泛美白银", "Pan American Silver", "partner", "tier_3", "https://www.panamericansilver.com/", ["白银", "矿业"]),
]
BASE_REPORTS = [
(REAL_SAMPLE_REPORT_ID, "BIS 季度评论:全球金融市场重新校准", "inst_bis", "official_public", True, ["宏观金融", "金融稳定", "AI 融资", "非银风险"], "2026-06-02T00:00:00Z"),
("rep_ssga_gold", "黄金月报:金价新高之后,谁在继续买?", "inst_ssga", "authorized_partner", True, ["贵金属", "跨资产"], "2026-05-22T00:00:00Z"),
("rep_wb_pinksheet", "世界银行大宗商品价格表:金属分化继续", "inst_world_bank", "official_public", True, ["大宗商品", "金属"], "2026-05-20T00:00:00Z"),
("rep_iea_omr", "IEA 原油市场月报:库存与需求再平衡", "inst_iea", "official_public", True, ["能源", "原油"], "2026-05-18T00:00:00Z"),
("rep_ing_gold", "ING 黄金观点:实际利率回摆的压力测试", "inst_ing", "authorized_partner", False, ["贵金属", "外汇"], "2026-05-16T00:00:00Z"),
("rep_wisdomtree_outlook", "WisdomTree 商品展望:配置窗口与回撤风险", "inst_wisdomtree", "authorized_partner", False, ["大宗商品", "资产配置"], "2026-05-14T00:00:00Z"),
("rep_usgs_minerals", "USGS 矿产摘要:关键金属供给约束", "inst_usgs", "official_public", True, ["矿产", "贵金属"], "2026-05-12T00:00:00Z"),
("rep_pas_silver", "白银矿业更新:供给扰动与成本曲线", "inst_pas", "broker_public_gray", False, ["白银", "矿业"], "2026-05-10T00:00:00Z"),
("rep_eia_steo", "EIA 短期能源展望:油气价格情景", "inst_eia", "official_public", True, ["能源", "原油"], "2026-05-08T00:00:00Z"),
]
LIGHT_REPORTS = [
("rep_imf_weo", "IMF 世界经济展望:增长分化与政策空间", "inst_imf", "official_public", True, ["宏观金融"], "2026-05-06T00:00:00Z"),
("rep_bis_quarterly", "BIS 季报:市场重新校准", "inst_bis", "official_public", True, ["宏观金融", "金融稳定"], "2026-05-04T00:00:00Z"),
("rep_fed_fsr", "美联储金融稳定报告:杠杆与流动性", "inst_fed", "official_public", True, ["金融稳定"], "2026-05-02T00:00:00Z"),
("rep_ecb_bulletin", "欧洲央行经济公报:通胀路径更新", "inst_ecb", "official_public", True, ["货币政策"], "2026-04-30T00:00:00Z"),
("rep_opec_momr", "OPEC 月报:供需缺口与配额纪律", "inst_opec", "official_public", True, ["能源", "原油"], "2026-04-28T00:00:00Z"),
("rep_wgc_trends", "世界黄金协会:黄金需求趋势", "inst_wgc", "official_public", True, ["贵金属", "央行"], "2026-04-26T00:00:00Z"),
("rep_silver_survey", "白银协会:白银供需调查", "inst_silver_institute", "official_public", True, ["白银"], "2026-04-24T00:00:00Z"),
("rep_gs_commodity", "高盛商品观点:再通胀交易复盘", "inst_goldman", "broker_public_gray", False, ["大宗商品"], "2026-04-22T00:00:00Z"),
("rep_jpm_flows", "摩根大通资金流:商品 ETF 与风险偏好", "inst_jpm", "authorized_partner", False, ["跨资产"], "2026-04-20T00:00:00Z"),
("rep_invesco_etf", "景顺 ETF 观察:黄金与能源配置", "inst_invesco", "authorized_partner", False, ["ETF", "贵金属"], "2026-04-18T00:00:00Z"),
("rep_world_bank_macro", "世界银行宏观更新:贸易与大宗商品", "inst_world_bank", "official_public", True, ["宏观金融", "大宗商品"], "2026-04-16T00:00:00Z"),
("rep_iea_gas", "IEA 天然气市场报告:需求弹性", "inst_iea", "official_public", True, ["能源"], "2026-04-14T00:00:00Z"),
("rep_eia_inventory", "EIA 库存周报解读:裂解价差与需求", "inst_eia", "official_public", False, ["能源"], "2026-04-12T00:00:00Z"),
("rep_usgs_copper", "USGS 铜矿供给:项目延迟与品位下降", "inst_usgs", "official_public", False, ["矿产"], "2026-04-10T00:00:00Z"),
("rep_ing_fx", "ING 外汇周报:美元路径与黄金敏感性", "inst_ing", "authorized_partner", False, ["外汇", "贵金属"], "2026-04-08T00:00:00Z"),
("rep_wisdomtree_gold", "WisdomTree 黄金配置:避险与实际利率", "inst_wisdomtree", "authorized_partner", False, ["贵金属"], "2026-04-06T00:00:00Z"),
("rep_ecb_stability", "欧洲央行稳定评估:非银金融风险", "inst_ecb", "official_public", False, ["金融稳定"], "2026-04-04T00:00:00Z"),
("rep_bis_ai_credit", "BIS 专题:AI 融资与信用风险", "inst_bis", "official_public", False, ["金融稳定", "AI"], "2026-04-02T00:00:00Z"),
]
def module_envelope(module_type: str, report_id: str, title: str, institution_name: str, *, fallback: bool = False) -> dict[str, Any]:
base = {
"basic_info": {"content": {"report_id": report_id, "title_cn": title, "summary_cn": f"{title} 的基础信息,包含发布机构、发布时间、主题标签和来源层级。", "topics": ["贵金属"], "interpretation_label": "研报解读"}},
"executive_overview": {
"preview": {"preview_summary": f"{title} 的结构化摘要,聚焦核心结论、数据线索与风险边界。", "section_count": 3, "key_quote_snippet": "公开研报显示关键变量正在重新定价。"},
"full": {"intro_cn": f"{title} 的执行摘要。", "sections": [{"heading": "核心结论", "body": "报告把需求、价格和风险拆成可读结构。"}, {"heading": "数据线索", "body": "关键指标用于判断趋势是否可持续。"}, {"heading": "风险边界", "body": "外部冲击和估值回摆仍可能改变短期路径。"}], "source_artifacts": ["native_briefing_doc", "native_blog_post"]},
},
"core_insights": {"content": {"points": [{"kind": "view", "text": "核心变量从情绪驱动转向结构驱动。"}, {"kind": "number", "text": "多项关键指标出现同步变化。"}, {"kind": "risk", "text": "若宏观假设反转,短期波动会放大。"}]}, "full": {"dimensions": [{"dimension": "需求结构", "summary": "机构、ETF 与产业需求变化共同影响价格。"}, {"dimension": "风险路径", "summary": "利率、美元和地缘冲击是主要风险因子。"}]}},
"key_data": {"preview": {"preview_headline": "10 个关键数据点", "highlights": ["央行购金保持韧性", "ETF 资金重新流入", "库存周期出现分化"], "row_count": 10}, "full": {"rows": [{"metric": "样本指标", "value": "10", "unit": "", "importance": "用于验证关键数据模块渲染", "judgment": "方向性信号清晰"}], "source_artifacts": ["data_table", "query_key_data"]}},
"source_compliance": {"content": {"source_url": None if report_id == "rep_pas_silver" else "https://example.org/public-report", "source_note": "灰度来源仅展示来源说明,不提供原文链接。" if report_id == "rep_pas_silver" else "原文来源于机构公开研究页。", "copyright_cn": "内容基于机构公开研报的中文结构化解读。", "disclaimer": "本内容不构成投资建议。", "ai_generated_label": "AI 辅助生成"}},
"differentiated_view": {"preview": {"preview_headline": "3 处与共识的关键分歧", "highlights": ["结构性买盘强于短期情绪", "库存周期解释部分价格韧性"], "divergence_count": 3}, "full": {"divergences": [{"topic": "买盘结构", "consensus_view": "价格主要由短期情绪驱动。", "report_position": "报告强调更稳定的结构性买盘。"}]}},
"weaknesses": {"preview": {"preview_headline": "3 处质疑点与开放问题", "highlights": ["样本窗口偏短", "反方向证据仍需跟踪"], "item_count": 3, "disclaimer_brief": "AI 辅助论证质量分析"}, "full": {"disclaimer_cn": "仅供学习参考,不构成投资建议。", "verification_notes": ["这些开放问题需要结合后续数据、原文脚注和反方向证据继续验证。"], "items": [{"topic": "样本窗口", "weakness": "短周期数据可能放大结论。", "counter_evidence": "后续数据可能修正方向。"}]}},
"timeline": {"preview": {"preview_headline": "5 个关键事件节点", "date_range": "2025-2026", "highlights": ["2026:价格重新定价", "2025:资金结构切换"], "event_count": 5}, "full": {"events": [{"date": "2026-05", "period_type": "review_period", "event": "报告发布", "impact": "为市场判断提供公开依据。"}]}},
"study_guide": {"preview": {"preview_headline": "学习指南", "faq_count": 3, "glossary_count": 5, "sample_question": "这份报告适合谁读?"}, "full": {"intro_cn": "学习指南帮助读者理解术语和关键问题。", "faq_items": [{"question": "这份报告适合谁读?", "answer": "适合关注宏观、商品和资产配置的读者。"}], "glossary": [{"term": "source_tier", "definition": "来源可信层级。"}]}},
"structure_graph": {"preview": {"preview_headline": "研报结构图", "root": f"{title}:分析框架", "top_nodes": ["需求", "价格", "风险"], "fallback_derived": fallback}, "full": {"root": f"{title}:分析框架", "nodes": [{"label": "需求", "children": ["机构", "产业", "投资"]}, {"label": "价格", "children": ["利率", "美元", "库存"]}], "fallback_derived": fallback, "source_artifacts": ["query_dimensions"] if fallback else ["mind_map"]}},
"audio": {"content": {"audio_id": f"aud_{report_id.removeprefix('rep_')}", "title_cn": f"{title} 音频摘要", "duration_sec": 180, "chapters": []}},
}
return base[module_type]
def rich_module_types(report_id: str) -> list[str]:
by_report = {
REAL_SAMPLE_REPORT_ID: [
"basic_info",
"executive_overview",
"core_insights",
"key_data",
"source_compliance",
"institution",
"differentiated_view",
"weaknesses",
"timeline",
"study_guide",
"structure_graph",
"related_sources",
"audio",
],
"rep_ssga_gold": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "differentiated_view", "weaknesses", "timeline", "study_guide", "structure_graph", "audio"],
"rep_wb_pinksheet": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "timeline", "study_guide", "audio"],
"rep_iea_omr": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "study_guide", "structure_graph", "audio"],
"rep_ing_gold": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution"],
"rep_wisdomtree_outlook": ["basic_info", "executive_overview", "core_insights", "source_compliance", "institution", "timeline"],
"rep_usgs_minerals": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "timeline", "structure_graph", "audio"],
"rep_pas_silver": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution"],
"rep_eia_steo": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "study_guide", "audio"],
}
return by_report.get(report_id, ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution"])
async def reset(session: AsyncSession) -> None:
for model in [
OutboundEvent,
PlaybackProgress,
SavedListen,
ReadingHistory,
Favorite,
User,
RelatedNews,
AudioAsset,
DisplayModule,
DisplayArtifact,
RawArtifact,
Report,
Institution,
]:
await session.execute(delete(model))
await session.commit()
async def import_seed(session: AsyncSession) -> None:
await reset(session)
inst_lookup: dict[str, str] = {}
for inst_id, name_cn, name_en, inst_type, tier, url, topics in INSTITUTIONS:
inst_lookup[inst_id] = name_cn
session.add(Institution(institution_id=inst_id, name_cn=name_cn, name_en=name_en, institution_type=inst_type, source_tier=tier, website_url=url, covered_topics=j(topics), intro_cn=f"{name_cn} 的公开研究和数据用于 Phase 1 seed 展示。", credibility_note=f"{name_cn}{tier} 来源。", status="active"))
await session.flush()
all_reports = BASE_REPORTS + LIGHT_REPORTS
audio_report_ids = {report_id for report_id, *_rest, has_audio, _topics, _date in all_reports if has_audio}
for idx, (report_id, title, inst_id, source_tier, has_audio, topics, released) in enumerate(all_reports, start=1):
display_status = "draft" if report_id == "rep_wisdomtree_outlook" else "published"
source_url = None if source_tier == "broker_public_gray" else "https://example.org/public-report"
source_note = "灰度公开来源,仅保留来源说明,不做默认音频化。" if source_tier == "broker_public_gray" else "原文来源于机构公开研究页。"
if report_id == REAL_SAMPLE_REPORT_ID:
source_url = "https://www.bis.org/publ/qtrpdf/r_qt2603.htm"
source_note = "原文为 BIS Quarterly Review, March 2026 的公开研报。"
session.add(
Report(
report_id=report_id,
report_type="single",
title_cn=title,
subtitle_cn="",
original_title="BIS Quarterly Review, March 2026" if report_id == REAL_SAMPLE_REPORT_ID else f"{title} original",
one_liner="2025 年底至 2026 年初,全球金融市场在表面平静下出现资金流向切换,AI 融资、贵金属杠杆和非银风险成为主要线索。" if report_id == REAL_SAMPLE_REPORT_ID else f"{title} 的一分钟结构化摘要。",
institution_id=inst_id,
source_tier=source_tier,
source_url=source_url,
source_note=source_note,
published_at=d(released),
interpreted_at=d(released),
released_at=d(released),
topics=j(topics),
language="en",
has_audio=has_audio,
display_status=display_status,
display_version=1,
cache_version=f"{report_id}:v1",
risk_disclaimer="本内容为公开研报的结构化解读,不构成投资建议。",
interpretation_label="研报解读",
)
)
await session.flush()
da_id = f"da_{report_id.removeprefix('rep_')}_v1"
session.add(DisplayArtifact(display_artifact_id=da_id, report_id=report_id, display_version=1, title_cn=title, summary_cn=f"{title} seed display artifact", source_label=inst_lookup[inst_id], interpretation_label="研报解读", ai_generated_label="AI 辅助生成", synthesis_type="mixed" if has_audio else "text", source_disclosure_text=source_note, review_status="published", published_at=d(released)))
await session.flush()
artifact_types = sample_artifact_types() if report_id == REAL_SAMPLE_REPORT_ID else ["native_briefing_doc", "native_blog_post", "native_study_guide", "data_table", "query_dimensions", "query_key_data"]
for artifact_type in artifact_types:
session.add(RawArtifact(raw_artifact_id=f"raw_{report_id.removeprefix('rep_')}_{artifact_type}", report_id=report_id, artifact_type=artifact_type, payload_format="markdown" if artifact_type != "data_table" else "csv", status="ok", is_publish_blocking=artifact_type in {"native_briefing_doc", "native_blog_post", "data_table", "query_dimensions", "query_key_data"}, retention_status="retained", ingested_at=d(released)))
if report_id == "rep_iea_omr":
session.add(RawArtifact(raw_artifact_id="raw_iea_omr_mind_map", report_id=report_id, artifact_type="mind_map", payload_format="json", status="failed", error="Download failed for mind_map", is_publish_blocking=False, retention_status="retained", ingested_at=d(released)))
module_types = [
value
for value in sorted(
rich_module_types(report_id),
key=lambda value: MODULE_DISPLAY_ORDER.get(value, len(MODULE_DISPLAY_ORDER)),
)
if value != "institution"
]
for order, module_type in enumerate(module_types):
if report_id == REAL_SAMPLE_REPORT_ID:
payload = real_sample_module_envelope(module_type, report_id, title, inst_lookup[inst_id])
else:
payload = module_envelope(module_type, report_id, title, inst_lookup[inst_id], fallback=(report_id == "rep_iea_omr" and module_type == "structure_graph"))
module_id = f"mod_{report_id.removeprefix('rep_')}_{module_type}"
content_ref = f"rnb/modules/{module_id}.json" if "full" in payload else None
session.add(
DisplayModule(
module_id=module_id,
report_id=report_id,
display_artifact_id=da_id,
type=module_type,
title_cn=MODULE_TITLES.get(module_type, module_type),
content_format="json",
content=j(payload),
content_ref=content_ref,
content_etag=etag(payload),
source_raw_artifact_ids=j([]),
status="published" if display_status == "published" else "review",
sort_order=order,
version=1,
)
)
if has_audio and report_id in audio_report_ids:
audio_id = f"aud_{report_id.removeprefix('rep_')}"
session.add(AudioAsset(audio_id=audio_id, report_id=report_id, title_cn=f"{title} 音频摘要", duration_sec=180 + idx, oss_key=f"rnb/audio/{audio_id}.m4a", chapters=j([]), status="published" if display_status == "published" else "review", published_at=d(released)))
if idx <= 15:
session.add(RelatedNews(related_news_id=f"news_{idx:03d}", report_id=report_id, title=f"{title} 延伸阅读", source_name="公开财经资讯", source_url="https://example.org/news", published_at=d(released), language="zh", summary_cn="整理自公开财经资讯的延伸阅读。", match_method="manual_curated", match_keywords=j(topics), match_confidence="medium", status="published"))
await session.flush()
for inst_id in inst_lookup:
count = await session.scalar(select(Report).where(Report.institution_id == inst_id).count()) if False else None
reports = (await session.execute(select(Report).where(Report.institution_id == inst_id, Report.display_status == "published").order_by(Report.released_at.desc()))).scalars().all()
inst = (await session.execute(select(Institution).where(Institution.institution_id == inst_id))).scalar_one()
inst.report_count = len(reports)
if reports:
inst.latest_report_id = reports[0].report_id
inst.latest_report_at = reports[0].released_at
users = [
User(user_id="user_alpha", phone_hash="hash_alpha", display_name="Alpha", status="active"),
User(user_id="user_history", phone_hash="hash_history", display_name="History", status="active"),
User(user_id="user_guest_placeholder", display_name="Guest Placeholder", status="disabled"),
]
session.add_all(users)
await session.flush()
for idx, report_id in enumerate(["rep_ssga_gold", "rep_wb_pinksheet", "rep_iea_omr", "rep_usgs_minerals", "rep_eia_steo"], start=1):
session.add(Favorite(favorite_id=f"fav_{idx:03d}", user_id="user_alpha", report_id=report_id, status="active"))
for idx, report_id in enumerate(["rep_ssga_gold", "rep_wb_pinksheet", "rep_iea_omr"], start=1):
audio_id = f"aud_{report_id.removeprefix('rep_')}"
session.add(PlaybackProgress(progress_id=f"prog_{idx:03d}", user_id="user_alpha", audio_id=audio_id, report_id=report_id, position_sec=idx * 30, duration_sec=180 + idx, completed=False))
await session.commit()
async def main() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await import_seed(session)
print("seed import complete")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,117 @@
from __future__ import annotations
import os
os.environ["RNB_DATABASE_URL"] = "sqlite+aiosqlite:///./test_seed.db"
os.environ["RNB_REDIS_URL"] = "redis://test-redis.invalid/0"
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db import Base, SessionLocal, engine
from app.main import app
from app.models import AudioAsset, DisplayModule, Institution, Report
from scripts.import_seed_content import import_seed
PREFIX = "/api/report-notebooklm/v1"
@pytest.fixture(autouse=True)
async def seeded_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await import_seed(session)
yield
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def test_seed_counts_match_phase1_shape():
async with SessionLocal() as session:
assert len((await session.execute(select(Institution))).scalars().all()) == 18
assert len((await session.execute(select(Report))).scalars().all()) == 27
assert len((await session.execute(select(AudioAsset))).scalars().all()) == 15
assert len((await session.execute(select(DisplayModule))).scalars().all()) >= 120
async def test_health_and_recommended_feed(client: AsyncClient):
health = await client.get(f"{PREFIX}/health")
assert health.status_code == 200
assert health.json() == {"status": "ok"}
feed = await client.get(f"{PREFIX}/feed/recommended")
assert feed.status_code == 200
body = feed.json()
assert body["items"]
assert body["items"][0]["report_id"] == "rep_bis_notebooklm_sample"
assert "display_version" not in body["items"][0]
assert body["items"][0]["cache_version"].startswith("rep_")
async def test_report_detail_hides_internal_fields_and_review_modules(client: AsyncClient):
response = await client.get(f"{PREFIX}/reports/rep_ssga_gold")
assert response.status_code == 200
body = response.json()
assert body["report_id"] == "rep_ssga_gold"
assert "display_version" not in body
module_types = [module["type"] for module in body["modules"]]
assert "study_guide" in module_types
assert "institution" not in module_types
assert "faq" not in module_types
assert "infographic" not in module_types
assert all(module["has_detail_page"] for module in body["modules"])
assert module_types[-1] == "source_compliance"
key_data = next(module for module in body["modules"] if module["type"] == "key_data")
assert key_data["render_mode"] == "card_plus_page"
assert key_data["content"] is None
assert key_data["preview"]["row_count"] == 10
assert key_data["content_ref"].startswith("rnb/modules/")
async def test_module_endpoint_returns_full_content(client: AsyncClient):
detail = (await client.get(f"{PREFIX}/reports/rep_ssga_gold")).json()
key_data = next(module for module in detail["modules"] if module["type"] == "key_data")
response = await client.get(f"{PREFIX}/reports/rep_ssga_gold/modules/{key_data['module_id']}")
assert response.status_code == 200
body = response.json()
assert body["module_id"] == key_data["module_id"]
assert "rows" in body["content"]
assert body["cache_version"] == "rep_ssga_gold:v1"
async def test_boundary_reports(client: AsyncClient):
listen = (await client.get(f"{PREFIX}/listen")).json()
listen_report_ids = {item["report_id"] for item in listen["items"]}
assert "rep_ing_gold" not in listen_report_ids
assert "rep_pas_silver" not in listen_report_ids
hidden = await client.get(f"{PREFIX}/reports/rep_wisdomtree_outlook")
assert hidden.status_code == 404
gray = (await client.get(f"{PREFIX}/reports/rep_pas_silver")).json()
compliance = next(module for module in gray["modules"] if module["type"] == "source_compliance")
assert compliance["content"]["source_url"] is None
assert "灰度" in compliance["content"]["source_note"]
async def test_institutions_and_listen(client: AsyncClient):
institutions = await client.get(f"{PREFIX}/institutions")
assert institutions.status_code == 200
assert len(institutions.json()["items"]) == 18
inst = await client.get(f"{PREFIX}/institutions/inst_ssga")
assert inst.status_code == 200
assert inst.json()["latest_report"]["report_id"] == "rep_ssga_gold"
listen = await client.get(f"{PREFIX}/listen")
assert listen.status_code == 200
assert listen.json()["items"][0]["audio_id"].startswith("aud_")
+47
View File
@@ -0,0 +1,47 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
*.apk
build/verification/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "924134a44c189315be2148659913dda1671cbe99"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: android
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+28
View File
@@ -0,0 +1,28 @@
# report-notebooklm-app NOTES
## 2026-06-03
- App prototype pass moved the Flutter client from a single `lib/main.dart` shell into feature/domain folders:
- `lib/theme/`: Wise-style tokens and `ThemeData`.
- `lib/data/`: typed API data source and models for reports, institutions, display modules, audio, and module detail.
- `lib/features/`: feed, reports, institutions, listen, profile, and detail pages.
- `lib/features/detail/modules/`: renderer registry and module detail routing.
- `lib/widgets/`: reusable cards, badges/chips, buttons, states, sheets, and mini/player UI.
- User-visible product name is `研听`; code/package/API identifiers remain `report-notebooklm` / `rnb`.
- Current UI uses real backend seed/API responses. Local UI mock is limited to blocked Phase 1 behaviors: login, save/favorite state, outbound confirmation, and audio progress without a real stream.
- Product decision: Phase 1 has no report-interpretation download feature. Do not show a top-level download icon, Detail download button, profile download record, download API, audio offline package, or download login trigger. Original reports are accessed only through source/outbound links from source/compliance surfaces.
- 2026-06-03 prototype feedback pass:
- Detail module cards are clickable as a whole and keep a `查看详情` affordance.
- Module detail pages should use vertical `subtitle + content` flows, not left/right comparison layouts.
- Report source, publisher parameters, copyright note, and disclaimer render in one `报告来源` card; do not re-add a separate `发布机构` card or bottom `来源与合规` card.
- `局限与疑问` shows the "需要继续验证" reminder once at the bottom, not after every paragraph.
- Custom overscroll feedback must move content over whitespace and rebound smoothly; do not stretch text or relayout cards during pull.
- If report detail does not open in an emulator or device, first verify that the backend is running and that `RNB_API_BASE` is reachable from that runtime. Host loopback, emulator, and physical-device networking are different environments.
- Known remaining gaps:
- Real audio stream: needs backend `/audio/{audio_id}/stream` or equivalent playable URL in `/listen`.
- Auth and personal state sync: favorites, history, saved listens, and playback progress remain local UI placeholders.
- Outbound events: confirmation sheet exists, but `POST /outbound/events` is not implemented.
- Production API domain: app still requires explicit `--dart-define=RNB_API_BASE=...`.
- Release signing: not configured beyond Flutter scaffold defaults.
- App icon/final brand visual: not finalized.
- Backend pagination/cache/OSS signed URL: list pagination is still seed-scale; Redis/signed URL policies remain backend work.
+77
View File
@@ -0,0 +1,77 @@
# report-notebooklm-app
Flutter client for the report-notebooklm Phase 1 app shell.
The backend API lives in `../report-notebooklm-api/` in the same monorepo. API/data/content-pipeline details are documented there; this directory focuses on App handoff, UI state, build commands, and integration notes.
## Read First
- [docs/HANDOFF.md](docs/HANDOFF.md): current App state, implemented screens, placeholders, and next work.
- [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md): product and Phase 1 scope snapshot.
- [docs/APP_RUNBOOK.md](docs/APP_RUNBOOK.md): Flutter version, local run, web build, Android debug build, and verification.
- [docs/API_CONTRACT_NOTES.md](docs/API_CONTRACT_NOTES.md): endpoints and fields consumed by the App.
- [docs/PROJECT_MAP.md](docs/PROJECT_MAP.md): source tree map.
## Product Boundary
This repo contains App code and an engineering handoff snapshot. It is not the product source of truth.
Product SSOT: mall-docs report-notebooklm docs. Snapshot date: 2026-06-03.
Use `report-notebooklm` and `rnb` for technical identifiers. The user-facing product name is `研听`.
## Requirements
- Flutter 3.44.1 / Dart 3.12.1 or compatible newer versions.
- A running backend that serves `/api/report-notebooklm/v1`.
- For Android builds: Android SDK, accepted licenses, and an emulator or device.
## API Base URL
The App intentionally has no built-in live API default. Pass the backend base URL explicitly:
```bash
flutter run -d chrome --dart-define=RNB_API_BASE=<api-base-url>
```
Android emulator:
```bash
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
Same-network Android device:
```bash
flutter run -d <device-id> --dart-define=RNB_API_BASE=http://<host-lan-ip>:<port>/api/report-notebooklm/v1
```
Only use cleartext HTTP for debug builds. Release builds must use HTTPS.
## Verify
```bash
flutter analyze
flutter test
flutter build web --dart-define=RNB_API_BASE=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## Current App Scope
Implemented:
- Five bottom tabs: 推荐, 研报, 机构, 听单, 我的.
- API-backed feed, report list, institution list, listen list, institution detail, and report detail.
- Module renderer registry for inline and card-plus-page modules.
- Product display name `研听`.
- Local UI placeholders for login, favorite, outbound confirmation, and playback progress.
Not implemented yet:
- Real auth.
- Real favorite/history/saved-listen sync.
- Real playable audio stream.
- Real outbound event write.
- Production API domain.
- Release signing, final icon, and final store metadata.
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+11
View File
@@ -0,0 +1,11 @@
/.gradle
/captures/
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.report_notebooklm_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.report_notebooklm_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application android:usesCleartextTraffic="true"/>
</manifest>
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="研听"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.report_notebooklm_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
Binary file not shown.
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
@@ -0,0 +1,158 @@
# App API Contract Notes
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
For full API/data details, read `../report-notebooklm-api/docs/API_AND_DATA.md` from the monorepo root.
## Configuration
The App reads the API base URL from:
```text
RNB_API_BASE
```
If `RNB_API_BASE` is missing, live API requests throw an error. This is intentional to avoid silently pointing production builds at a debug backend.
## Endpoints Currently Consumed
| Method | Path | App use |
|---|---|---|
| `GET` | `/feed/recommended` | Recommended feed cards. |
| `GET` | `/reports` | Report list. |
| `GET` | `/reports/{report_id}` | Report detail skeleton and modules. |
| `GET` | `/reports/{report_id}/modules/{module_id}` | Module detail page content. |
| `GET` | `/institutions` | Institution list. |
| `GET` | `/institutions/{institution_id}` | Institution detail and recent reports. |
| `GET` | `/listen` | Listen list and mini-player entry. |
## Response Fields Currently Used
Report cards:
- `report_id`
- `title_cn`
- `subtitle_cn`
- `one_liner`
- `institution`
- `topics`
- `released_at`
- `has_audio`
- `interpretation_label`
- `source_tier`
- `cache_version`
Report detail:
- `report_id`
- `title_cn`
- `subtitle_cn`
- `original_title`
- `one_liner`
- `institution`
- `source`
- `topics`
- `has_audio`
- `interpretation_label`
- `risk_disclaimer`
- `released_at`
- `cache_version`
- `modules`
Display modules:
- `module_id`
- `type`
- `layer`
- `render_mode`
- `has_detail_page`
- `is_publish_blocking`
- `requires_human_review`
- `sort_order`
- `title_cn`
- `content`
- `preview`
- `content_ref`
- `content_etag`
Listen items:
- `audio_id`
- `title_cn`
- `duration_sec`
- `report_id`
- `report_title_cn`
- `institution`
- `released_at`
- `cache_version`
Institution:
- `institution_id`
- `name_cn`
- `name_en`
- `institution_type`
- `source_tier`
- `website_url`
- `covered_topics`
- `report_count`
- `latest_report_at`
- `credibility_note`
- `intro_cn`
- `latest_report`
- `recent_reports`
## Module Rendering Behavior
- `render_mode=inline`: App renders `content` on the report detail page.
- `render_mode=card_plus_page`: App renders `preview` on the report detail page and opens module detail for full content.
- Unknown module `type` should not crash the App; it should fall back to a generic or hidden renderer.
Current renderer coverage includes:
- `basic_info`
- `executive_overview`
- `core_insights`
- `key_data`
- `source_compliance`
- `audio`
- `institution`
- `timeline`
- `study_guide`
- `structure_graph`
- `related_sources`
- `differentiated_view`
- `weaknesses`
## Endpoints Not Yet Consumed
- `GET /audio/{audio_id}/stream`
- Auth endpoints.
- `/me` personal-state endpoints.
- `POST /outbound/events`
The UI currently uses placeholders for these blocked flows:
- Login prompt.
- Favorite action.
- Browse/history sync.
- Saved listen.
- Playback progress.
- Outbound confirmation.
- Real audio playback.
## Public Field Boundary
The App must not rely on or display:
- `display_version`
- module `version`
- raw artifact payload
- raw NotebookLM IDs
- private object-storage references
- local paths
- auth internals
The App should use `cache_version` as the public cache/version signal.
+81
View File
@@ -0,0 +1,81 @@
# App Runbook
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Requirements
- Flutter 3.44.1 / Dart 3.12.1 or compatible newer versions.
- A backend API serving `/api/report-notebooklm/v1`.
- Android SDK and accepted licenses for Android builds.
- Chrome for web debug.
Check tool versions:
```bash
flutter --version
dart --version
flutter doctor -v
```
If dependency solving reports that Dart is older than required, switch to a Flutter SDK that includes Dart 3.12.1 or newer.
## API Base URL
The App requires an explicit API base URL:
```bash
--dart-define=RNB_API_BASE=<api-base-url>
```
Examples:
```bash
RNB_API_BASE=http://<debug-api-host>:<port>/api/report-notebooklm/v1
RNB_API_BASE=https://<api-domain>/api/report-notebooklm/v1
```
Use cleartext HTTP only for local debug builds. Release builds must use HTTPS.
## Web Run
```bash
flutter run -d chrome --dart-define=RNB_API_BASE=<api-base-url>
```
## Android Emulator Run
Start the backend on the host, then:
```bash
flutter devices
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## Same-Network Android Device
Start the backend on a host/port reachable from the device, then:
```bash
flutter run -d <device-id> --dart-define=RNB_API_BASE=http://<host-lan-ip>:<port>/api/report-notebooklm/v1
```
Use this only for debug. Do not hardcode LAN IPs in source code.
## Verify
```bash
flutter analyze
flutter test
flutter build web --dart-define=RNB_API_BASE=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## Release Notes
- Release builds must not allow cleartext traffic.
- Production API base must be passed explicitly.
- Release signing is not finalized in this scaffold.
- Final app icon and store metadata are not finalized.
- Do not commit build outputs, APKs, screenshots, `.dart_tool/`, or local IDE caches.
+79
View File
@@ -0,0 +1,79 @@
# App Handoff
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Current State
The App is a runnable Phase 1 Flutter shell connected to the backend public seed API. It is not production-ready yet.
Implemented:
- Five bottom tabs: 推荐, 研报, 机构, 听单, 我的.
- API client configured by `RNB_API_BASE`.
- API-backed recommended feed, report list, institution list, institution detail, listen list, report detail, and module detail.
- Report detail module renderer registry.
- Inline module rendering and card-plus-page module preview/detail flow.
- Local mini-player UI and player controls.
- Login, favorite, outbound, and playback placeholders that make blocked Phase 1 flows visible without pretending they are complete.
- Android platform scaffold and debug/release build compatibility.
Not implemented:
- Auth and real login state.
- Server-synced favorites, reading history, saved listens, and playback progress.
- Real audio stream from `/audio/{audio_id}/stream`.
- Outbound event write before external navigation.
- Production API domain.
- Release signing.
- Final app icon, final brand visuals, privacy copy, and store metadata.
## Important Product Decisions Reflected in App
- User-facing product name is `研听`.
- Technical identifiers stay `report-notebooklm` / `rnb`.
- Phase 1 has no download feature for report interpretation content, audio packages, or PDFs.
- Source access is through source/compliance or outbound surfaces, not in-product downloads.
- Guest users can browse public content and listen; login is only required for synchronized personal state.
- App must not call NotebookLM or any LLM to generate report content.
## Source Tree Map
| Path | Purpose |
|---|---|
| `lib/main.dart` | App entry point and API data source construction. |
| `lib/app.dart` | Material app and theme hookup. |
| `lib/data/api/` | Backend API client. |
| `lib/data/models/` | Typed response models. |
| `lib/features/shell_page.dart` | Bottom-tab shell and mini-player state. |
| `lib/features/feed/` | Recommended feed. |
| `lib/features/reports/` | Report list. |
| `lib/features/institutions/` | Institution list and detail. |
| `lib/features/listen/` | Listen list. |
| `lib/features/profile/` | Guest/login placeholder and personal-state entry points. |
| `lib/features/detail/` | Report detail. |
| `lib/features/detail/modules/` | Module renderer registry and module detail page. |
| `lib/widgets/` | Reusable UI components. |
| `lib/theme/` | Design tokens and Flutter theme. |
| `android/` | Android platform scaffold. |
| `test/` | Widget tests. |
## Suggested Handoff Order
1. Read `docs/PROJECT_BRIEF.md`.
2. Run the backend seed API from `report-notebooklm-api/`.
3. Read `docs/API_CONTRACT_NOTES.md`.
4. Run `flutter analyze` and `flutter test`.
5. Run the App against the backend with `RNB_API_BASE`.
6. Choose the next App/API integration item from the open gaps.
## Next App Work
- Wire real auth once backend auth exists.
- Replace favorite/history/saved-listen placeholders with API-backed state.
- Use backend audio stream endpoint for actual playback URL.
- Persist playback progress for logged-in users.
- Write outbound event before external navigation.
- Add production API config and release signing.
- Finalize app icon, privacy wording, store copy, and risk-disclaimer surfaces.
@@ -0,0 +1,65 @@
# Project Brief Snapshot
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Product
`研听` is a Chinese research-report interpretation app for users who want to understand global institutional research with lower language and time barriers. It turns hard-to-read English research reports into structured Chinese reading and listening experiences.
Technical identifiers remain `report-notebooklm` and `rnb`. Do not use the product display name in code identifiers, database schema names, Redis keys, object storage paths, or API prefixes.
## Phase 1 Goals
- Validate whether Chinese users will repeatedly consume global institutional research-report interpretations.
- Ship a complete first app experience for discovery, reading, listening, saving, and returning to reports.
- Establish a minimum loop from report sources to selection, NotebookLM-assisted interpretation, review, storage, API distribution, and app display.
- Keep source attribution and compliance clear: this is report interpretation and annotation, not investment advice.
- Keep the commercial app independent from any local-only Vision runtime.
## Target Users
- General Chinese users interested in macro, precious metals, commodities, energy, central banks, and cross-asset research.
- Light professional users who want overseas institutional views and original-source traceability, without trading advice.
- Commuting or fragmented-time users who want reports transformed into listenable content.
Non-target users: professional terminal users, real-time trading-signal users, UGC/community users, and users expecting original investment recommendations.
## Main Tabs
| Tab | Phase 1 scope | Explicitly out of scope |
|---|---|---|
| 推荐 | Latest and curated report interpretations. | Ads, hard trading CTAs, real-time news flashes. |
| 研报 | All published report interpretations with basic filters. | Advanced investment terminal search. |
| 机构 | Institution list and institution report entry points. | Commercial institution ranking or onboarding backend. |
| 听单 | Reports that have audio form. | User-created podcasts, downloads, offline packages. |
| 我的 | Guest/login state, favorites, history, saved listening entry points. | Comments, UGC, paid membership, points. |
## Phase 1 Must Do
- Public browsing for recommended reports, report list, institutions, and listen list.
- Report detail pages with title, institution, publication/release data, source type, topics, summary, structured modules, source/compliance information, and favorite entry.
- Guest users can browse public content and fully listen to at least one episode.
- Logged-in users can synchronize favorites, reading history, saved listens, and playback progress.
- Published app responses must expose only reviewed display artifacts, not raw NotebookLM artifacts.
- Every report detail must preserve source attribution and risk disclaimer wording.
## Phase 1 Must Not Do
- No commercialization: no ads, paid unlock, membership, task wall, or points.
- No comments, community, UGC, or user-generated report interpretations.
- No investment advice, trading signals, buy/sell points, return promises, or portfolio recommendations.
- No original financial news, real-time reporting, or commentary positioned as original market views.
- No in-product downloads for interpretation content, audio packages, or PDFs.
- No long-term production dependency on a local Vision runtime, local path, local cache, or local account state.
- No App or server-side LLM rewriting of NotebookLM-native content into unsupported original copy.
## Compliance Boundary
- Positioning: research-report interpretation and annotation service.
- Content: Chinese interpretation of public or authorized institutional reports.
- Detail pages, agreements, and store metadata must state that content is not investment advice.
- Each item must show institution, source, publication time, and interpretation/source labels.
- Gray broker sources require special handling and human review before public release.
- Phase 1 does not open user content surfaces.
+75
View File
@@ -0,0 +1,75 @@
# App Project Map
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Entry Points
| Path | Purpose |
|---|---|
| `lib/main.dart` | Creates `RnbApiDataSource` and starts the App. |
| `lib/app.dart` | Defines `MaterialApp`, app title, theme, and shell page. |
| `lib/features/shell_page.dart` | Owns bottom navigation and mini-player state. |
## Data Layer
| Path | Purpose |
|---|---|
| `lib/data/api/report_data_source.dart` | API client interface and HTTP implementation. |
| `lib/data/models/models.dart` | Data models, JSON parsing helpers, and formatting helpers. |
The data source currently performs GET requests only. Mutating APIs for auth, favorites, playback progress, and outbound events are not implemented.
## Feature Pages
| Path | Purpose |
|---|---|
| `lib/features/feed/feed_page.dart` | Recommended feed. |
| `lib/features/reports/reports_page.dart` | Report list and simple filters. |
| `lib/features/institutions/institutions_page.dart` | Institution list. |
| `lib/features/institutions/institution_detail_page.dart` | Institution detail and recent reports. |
| `lib/features/listen/listen_page.dart` | Listen list. |
| `lib/features/profile/profile_page.dart` | Guest/login placeholder and personal-state entries. |
| `lib/features/detail/report_detail_page.dart` | Report detail shell and source/compliance display. |
| `lib/features/detail/modules/renderer_registry.dart` | Module card rendering and module detail page rendering. |
| `lib/features/shared/report_card_widget.dart` | Shared report card. |
## Shared UI
| Path | Purpose |
|---|---|
| `lib/widgets/app_buttons.dart` | Buttons. |
| `lib/widgets/app_card.dart` | Cards. |
| `lib/widgets/badges.dart` | Badges and chips. |
| `lib/widgets/mini_player.dart` | Mini-player and placeholder player UI. |
| `lib/widgets/sheets.dart` | Login/outbound sheets and toasts. |
| `lib/widgets/states.dart` | Loading, skeleton, empty, and error states. |
| `lib/theme/app_theme.dart` | Flutter `ThemeData`. |
| `lib/theme/wise_tokens.dart` | Color, spacing, radius, motion, and shadow tokens. |
## Platform
| Path | Purpose |
|---|---|
| `android/` | Android scaffold. |
| `android/app/src/debug/AndroidManifest.xml` | Debug-only cleartext allowance. |
| `android/app/src/main/AndroidManifest.xml` | Main manifest; release should remain HTTPS-only. |
| `web/` | Flutter web scaffold. |
## Tests
| Path | Purpose |
|---|---|
| `test/widget_test.dart` | Smoke widget test with fake data source. |
## Generated or Local Files
Do not commit:
- `build/`
- `.dart_tool/`
- APKs
- screenshots
- local IDE caches
- local environment files
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'data/api/report_data_source.dart';
import 'features/shell_page.dart';
import 'theme/app_theme.dart';
class MyApp extends StatelessWidget {
const MyApp({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '研听',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
scrollBehavior: const WhitespaceStretchScrollBehavior(),
home: ShellPage(dataSource: dataSource),
);
}
}
class WhitespaceStretchScrollBehavior extends MaterialScrollBehavior {
const WhitespaceStretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return _WhitespaceStretchIndicator(child: child);
}
}
class _WhitespaceStretchIndicator extends StatefulWidget {
const _WhitespaceStretchIndicator({required this.child});
final Widget child;
@override
State<_WhitespaceStretchIndicator> createState() =>
_WhitespaceStretchIndicatorState();
}
class _WhitespaceStretchIndicatorState
extends State<_WhitespaceStretchIndicator>
with SingleTickerProviderStateMixin {
static const double _maxStretch = 64;
static const double _dragResistance = 0.38;
late final AnimationController _offsetController =
AnimationController.unbounded(vsync: this)..addListener(_onTick);
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: ClipRect(
child: Transform.translate(
offset: Offset(0, _offsetController.value),
child: widget.child,
),
),
);
}
@override
void dispose() {
_offsetController.dispose();
super.dispose();
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification is OverscrollNotification) {
final overscroll = notification.overscroll;
final atTop =
notification.metrics.pixels <= notification.metrics.minScrollExtent;
final atBottom =
notification.metrics.pixels >= notification.metrics.maxScrollExtent;
if (atTop && overscroll < 0) {
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
0,
_maxStretch,
),
);
} else if (atBottom && overscroll > 0) {
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
-_maxStretch,
0,
),
);
}
}
if (notification is ScrollUpdateNotification &&
notification.dragDetails == null) {
_releaseOffset();
}
if (notification is ScrollEndNotification) {
_releaseOffset();
}
return false;
}
void _setOffset(num next) {
if (next == _offsetController.value) {
return;
}
_offsetController.stop();
_offsetController.value = next.toDouble();
}
void _releaseOffset() {
if (_offsetController.value == 0) {
return;
}
_offsetController.animateTo(
0,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
void _onTick() {
if (mounted) {
setState(() {});
}
}
}
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/models.dart';
abstract class ReportDataSource {
Future<List<ReportCardModel>> recommended();
Future<List<ReportCardModel>> reports();
Future<List<Institution>> institutions();
Future<Institution> institutionDetail(String institutionId);
Future<List<AudioItem>> listen();
Future<ReportDetail> reportDetail(String reportId);
Future<ModuleDetail> moduleDetail(String reportId, String moduleId);
}
class RnbApiDataSource implements ReportDataSource {
RnbApiDataSource({
http.Client? client,
this.baseUrl = const String.fromEnvironment('RNB_API_BASE'),
}) : client = client ?? http.Client();
final http.Client client;
final String baseUrl;
Future<JsonMap> _get(String path) async {
if (baseUrl.isEmpty) {
throw StateError('RNB_API_BASE is required for live API requests.');
}
final response = await client.get(Uri.parse('$baseUrl$path'));
if (response.statusCode != 200) {
throw StateError('Request failed: ${response.statusCode}');
}
return jsonDecode(utf8.decode(response.bodyBytes)) as JsonMap;
}
List<JsonMap> _items(JsonMap body) => asMapList(body['items']);
@override
Future<List<ReportCardModel>> recommended() async {
final body = await _get('/feed/recommended');
return _items(body).map(ReportCardModel.fromJson).toList();
}
@override
Future<List<ReportCardModel>> reports() async {
final body = await _get('/reports');
return _items(body).map(ReportCardModel.fromJson).toList();
}
@override
Future<List<Institution>> institutions() async {
final body = await _get('/institutions');
return _items(body).map(Institution.fromJson).toList();
}
@override
Future<Institution> institutionDetail(String institutionId) async {
return Institution.fromJson(await _get('/institutions/$institutionId'));
}
@override
Future<List<AudioItem>> listen() async {
final body = await _get('/listen');
return _items(body).map(AudioItem.fromJson).toList();
}
@override
Future<ReportDetail> reportDetail(String reportId) async {
return ReportDetail.fromJson(await _get('/reports/$reportId'));
}
@override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId'));
}
}
@@ -0,0 +1,313 @@
typedef JsonMap = Map<String, dynamic>;
String asString(Object? value, [String fallback = '']) =>
value == null ? fallback : value.toString();
int asInt(Object? value, [int fallback = 0]) {
if (value is int) return value;
if (value is num) return value.round();
return int.tryParse(asString(value)) ?? fallback;
}
bool asBool(Object? value) => value == true;
List<String> asStringList(Object? value) {
if (value is List) return value.map((item) => item.toString()).toList();
return const [];
}
JsonMap asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.map((key, val) => MapEntry(key.toString(), val));
return const {};
}
List<JsonMap> asMapList(Object? value) {
if (value is List) return value.map(asMap).where((item) => item.isNotEmpty).toList();
return const [];
}
String formatDate(String? iso) {
if (iso == null || iso.isEmpty) return '';
final parsed = DateTime.tryParse(iso);
if (parsed == null) return iso;
return '${parsed.year}${parsed.month}${parsed.day}';
}
String formatDuration(int seconds) {
final safe = seconds < 0 ? 0 : seconds;
final minutes = safe ~/ 60;
final secs = safe % 60;
return '$minutes:${secs.toString().padLeft(2, '0')}';
}
class Institution {
const Institution({
required this.id,
required this.nameCn,
this.nameEn = '',
this.institutionType = '',
this.sourceTier = '',
this.websiteUrl = '',
this.coveredTopics = const [],
this.reportCount = 0,
this.latestReportAt,
this.credibilityNote = '',
this.introCn = '',
this.recentReports = const [],
});
final String id;
final String nameCn;
final String nameEn;
final String institutionType;
final String sourceTier;
final String websiteUrl;
final List<String> coveredTopics;
final int reportCount;
final String? latestReportAt;
final String credibilityNote;
final String introCn;
final List<ReportCardModel> recentReports;
factory Institution.fromJson(JsonMap json) {
return Institution(
id: asString(json['institution_id']),
nameCn: asString(json['name_cn']),
nameEn: asString(json['name_en']),
institutionType: asString(json['institution_type']),
sourceTier: asString(json['source_tier']),
websiteUrl: asString(json['website_url']),
coveredTopics: asStringList(json['covered_topics']),
reportCount: asInt(json['report_count']),
latestReportAt: json['latest_report_at']?.toString(),
credibilityNote: asString(json['credibility_note']),
introCn: asString(json['intro_cn']),
recentReports: asMapList(json['recent_reports'])
.map(ReportCardModel.fromJson)
.toList(),
);
}
}
class ReportCardModel {
const ReportCardModel({
required this.id,
required this.titleCn,
required this.institution,
this.subtitleCn = '',
this.oneLiner = '',
this.topics = const [],
this.releasedAt,
this.hasAudio = false,
this.interpretationLabel = '研报解读',
this.sourceTier = '',
this.cacheVersion = '',
});
final String id;
final String titleCn;
final String subtitleCn;
final String oneLiner;
final Institution institution;
final List<String> topics;
final String? releasedAt;
final bool hasAudio;
final String interpretationLabel;
final String sourceTier;
final String cacheVersion;
factory ReportCardModel.fromJson(JsonMap json) {
final institution = Institution.fromJson(asMap(json['institution']));
return ReportCardModel(
id: asString(json['report_id']),
titleCn: asString(json['title_cn']),
subtitleCn: asString(json['subtitle_cn']),
oneLiner: asString(json['one_liner']),
institution: institution,
topics: asStringList(json['topics']),
releasedAt: json['released_at']?.toString(),
hasAudio: asBool(json['has_audio']),
interpretationLabel: asString(json['interpretation_label'], '研报解读'),
sourceTier: asString(json['source_tier']),
cacheVersion: asString(json['cache_version']),
);
}
}
class AudioItem {
const AudioItem({
required this.audioId,
required this.reportId,
required this.titleCn,
required this.reportTitleCn,
required this.durationSec,
required this.institution,
this.releasedAt,
this.cacheVersion = '',
});
final String audioId;
final String reportId;
final String titleCn;
final String reportTitleCn;
final int durationSec;
final Institution institution;
final String? releasedAt;
final String cacheVersion;
factory AudioItem.fromJson(JsonMap json) {
return AudioItem(
audioId: asString(json['audio_id']),
reportId: asString(json['report_id']),
titleCn: asString(json['title_cn']),
reportTitleCn: asString(json['report_title_cn'], asString(json['title_cn'])),
durationSec: asInt(json['duration_sec']),
institution: Institution.fromJson(asMap(json['institution'])),
releasedAt: json['released_at']?.toString(),
cacheVersion: asString(json['cache_version']),
);
}
}
class DisplayModule {
const DisplayModule({
required this.id,
required this.type,
required this.titleCn,
required this.renderMode,
this.layer = '',
this.hasDetailPage = false,
this.sortOrder = 0,
this.content = const {},
this.preview = const {},
this.contentRef = '',
this.contentEtag = '',
});
final String id;
final String type;
final String titleCn;
final String layer;
final String renderMode;
final bool hasDetailPage;
final int sortOrder;
final JsonMap content;
final JsonMap preview;
final String contentRef;
final String contentEtag;
factory DisplayModule.fromJson(JsonMap json) {
return DisplayModule(
id: asString(json['module_id']),
type: asString(json['type']),
titleCn: asString(json['title_cn'], asString(json['type'])),
layer: asString(json['layer']),
renderMode: asString(json['render_mode'], 'inline'),
hasDetailPage: asBool(json['has_detail_page']),
sortOrder: asInt(json['sort_order']),
content: asMap(json['content']),
preview: asMap(json['preview']),
contentRef: asString(json['content_ref']),
contentEtag: asString(json['content_etag']),
);
}
}
class ReportDetail {
const ReportDetail({
required this.id,
required this.titleCn,
required this.institution,
this.subtitleCn = '',
this.originalTitle = '',
this.oneLiner = '',
this.source = const {},
this.topics = const [],
this.hasAudio = false,
this.interpretationLabel = '研报解读',
this.riskDisclaimer = '',
this.releasedAt,
this.cacheVersion = '',
this.modules = const [],
});
final String id;
final String titleCn;
final String subtitleCn;
final String originalTitle;
final String oneLiner;
final Institution institution;
final JsonMap source;
final List<String> topics;
final bool hasAudio;
final String interpretationLabel;
final String riskDisclaimer;
final String? releasedAt;
final String cacheVersion;
final List<DisplayModule> modules;
factory ReportDetail.fromJson(JsonMap json) {
return ReportDetail(
id: asString(json['report_id']),
titleCn: asString(json['title_cn']),
subtitleCn: asString(json['subtitle_cn']),
originalTitle: asString(json['original_title']),
oneLiner: asString(json['one_liner']),
institution: Institution.fromJson(asMap(json['institution'])),
source: asMap(json['source']),
topics: asStringList(json['topics']),
hasAudio: asBool(json['has_audio']),
interpretationLabel: asString(json['interpretation_label'], '研报解读'),
riskDisclaimer: asString(json['risk_disclaimer']),
releasedAt: json['released_at']?.toString(),
cacheVersion: asString(json['cache_version']),
modules: asMapList(json['modules']).map(DisplayModule.fromJson).toList(),
);
}
ReportCardModel asCard() {
return ReportCardModel(
id: id,
titleCn: titleCn,
subtitleCn: subtitleCn,
oneLiner: oneLiner,
institution: institution,
topics: topics,
releasedAt: releasedAt,
hasAudio: hasAudio,
interpretationLabel: interpretationLabel,
sourceTier: asString(source['source_tier']),
cacheVersion: cacheVersion,
);
}
}
class ModuleDetail {
const ModuleDetail({
required this.id,
required this.type,
required this.titleCn,
this.content = const {},
this.contentEtag = '',
this.cacheVersion = '',
});
final String id;
final String type;
final String titleCn;
final JsonMap content;
final String contentEtag;
final String cacheVersion;
factory ModuleDetail.fromJson(JsonMap json) {
return ModuleDetail(
id: asString(json['module_id']),
type: asString(json['type']),
titleCn: asString(json['title_cn'], asString(json['type'])),
content: asMap(json['content']),
contentEtag: asString(json['content_etag']),
cacheVersion: asString(json['cache_version']),
);
}
}
@@ -0,0 +1,993 @@
import 'package:flutter/material.dart';
import '../../../data/api/report_data_source.dart';
import '../../../data/models/models.dart';
import '../../../theme/wise_tokens.dart';
import '../../../widgets/app_card.dart';
import '../../../widgets/badges.dart';
import '../../../widgets/mini_player.dart';
typedef StartModuleAudio =
void Function(
String audioId,
String reportId,
String title,
int durationSec,
);
class ModuleRendererRegistry {
const ModuleRendererRegistry();
Widget card({
required BuildContext context,
required DisplayModule module,
required ReportDetail report,
required ReportDataSource dataSource,
required PlayerStateModel player,
StartModuleAudio? onStartAudio,
VoidCallback? onToggleAudio,
void Function(int delta)? onSeekAudio,
VoidCallback? onSpeed,
}) {
final openDetail = module.hasDetailPage
? () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ModuleDetailPage(
reportId: report.id,
module: module,
report: report,
dataSource: dataSource,
registry: this,
),
),
)
: null;
return AppCard(
onTap: openDetail,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ModuleHeader(module: module),
const SizedBox(height: WiseSpacing.x4),
_contentFor(
context,
type: module.type,
payload: module.renderMode == 'inline'
? module.content
: module.preview,
report: report,
player: player,
onStartAudio: onStartAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
compact: module.renderMode != 'inline',
),
if (module.hasDetailPage) ...[
const SizedBox(height: WiseSpacing.x4),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: openDetail,
icon: const Icon(Icons.open_in_new),
label: const Text('查看详情'),
),
),
],
],
),
);
}
Widget page(
BuildContext context,
String type,
JsonMap payload, {
ReportDetail? report,
}) {
return _contentFor(
context,
type: type,
payload: payload,
report: report,
player: const PlayerStateModel(),
compact: false,
);
}
Widget _contentFor(
BuildContext context, {
required String type,
required JsonMap payload,
required PlayerStateModel player,
ReportDetail? report,
StartModuleAudio? onStartAudio,
VoidCallback? onToggleAudio,
void Function(int delta)? onSeekAudio,
VoidCallback? onSpeed,
bool compact = false,
}) {
return switch (type) {
'basic_info' => _BasicInfo(payload: payload, report: report),
'core_insights' => _CoreInsights(payload: payload),
'source_compliance' => _SourceCompliance(
payload: payload,
report: report,
),
'audio' => _AudioModule(
payload: payload,
report: report,
player: player,
onStartAudio: onStartAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
'institution' => _InstitutionModule(payload: payload, report: report),
'executive_overview' => _SectionsModule(
payload: payload,
compact: compact,
),
'key_data' => _KeyDataModule(payload: payload, compact: compact),
'timeline' => _TimelineModule(payload: payload, compact: compact),
'study_guide' => _StudyGuideModule(payload: payload, compact: compact),
'structure_graph' => _StructureGraphModule(
payload: payload,
compact: compact,
),
'related_sources' => _RelatedSourcesModule(
payload: payload,
compact: compact,
),
'differentiated_view' => _DifferentiatedViewModule(
payload: payload,
compact: compact,
),
'weaknesses' => _WeaknessesModule(payload: payload, compact: compact),
'infographic' => _FallbackModule(type: '信息图', payload: payload),
'research_discovery' => _FallbackModule(type: '延伸研究', payload: payload),
_ => _FallbackModule(type: type, payload: payload),
};
}
}
class ModuleDetailPage extends StatefulWidget {
const ModuleDetailPage({
required this.reportId,
required this.module,
required this.report,
required this.dataSource,
required this.registry,
super.key,
});
final String reportId;
final DisplayModule module;
final ReportDetail report;
final ReportDataSource dataSource;
final ModuleRendererRegistry registry;
@override
State<ModuleDetailPage> createState() => _ModuleDetailPageState();
}
class _ModuleDetailPageState extends State<ModuleDetailPage> {
late Future<ModuleDetail> future = widget.dataSource.moduleDetail(
widget.reportId,
widget.module.id,
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.module.titleCn)),
body: FutureBuilder<ModuleDetail>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
),
);
}
final detail = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
child: widget.registry.page(
context,
detail.type,
detail.content,
report: widget.report,
),
),
const SizedBox(height: WiseSpacing.x3),
Text(
'缓存版本 ${detail.cacheVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
},
),
);
}
}
class _ModuleHeader extends StatelessWidget {
const _ModuleHeader({required this.module});
final DisplayModule module;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
module.titleCn,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (module.layer.isNotEmpty)
AppBadge(text: module.layer.toUpperCase(), kind: BadgeKind.brand),
],
);
}
}
class _BasicInfo extends StatelessWidget {
const _BasicInfo({required this.payload, required this.report});
final JsonMap payload;
final ReportDetail? report;
@override
Widget build(BuildContext context) {
final topics = asStringList(payload['topics']).isEmpty
? report?.topics ?? const []
: asStringList(payload['topics']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(
payload['summary_cn'],
asString(payload['scope_cn'], report?.oneLiner ?? ''),
),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x2),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
for (final topic in topics) AppBadge(text: topic),
if (report?.releasedAt != null)
AppBadge(text: formatDate(report!.releasedAt)),
],
),
],
);
}
}
class _CoreInsights extends StatelessWidget {
const _CoreInsights({required this.payload});
final JsonMap payload;
@override
Widget build(BuildContext context) {
final points = asMapList(payload['points']);
if (points.isEmpty) return _TextLines(payload: payload);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final point in points)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppBadge(
text: _kindLabel(asString(point['kind'])),
kind: _kindBadge(asString(point['kind'])),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(point['text']),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
);
}
}
class _SourceCompliance extends StatelessWidget {
const _SourceCompliance({required this.payload, required this.report});
final JsonMap payload;
final ReportDetail? report;
@override
Widget build(BuildContext context) {
final institution = report?.institution;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['source_note']).isNotEmpty)
Text(
asString(payload['source_note']),
style: Theme.of(context).textTheme.bodyMedium,
),
if (institution != null) ...[
const SizedBox(height: WiseSpacing.x4),
Text('发布机构', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2),
_InfoLine(label: '机构名称', value: institution.nameCn),
if (institution.nameEn.isNotEmpty)
_InfoLine(label: '英文名称', value: institution.nameEn),
if (institution.institutionType.isNotEmpty)
_InfoLine(
label: '机构类型',
value: _institutionTypeLabel(institution.institutionType),
),
if (institution.sourceTier.isNotEmpty)
_InfoLine(label: '来源层级', value: institution.sourceTier),
if (institution.reportCount > 0)
_InfoLine(label: '收录报告', value: '${institution.reportCount}'),
if (institution.coveredTopics.isNotEmpty)
_InfoLine(
label: '覆盖主题',
value: institution.coveredTopics.join(''),
),
if (institution.websiteUrl.isNotEmpty)
_InfoLine(label: '官网', value: institution.websiteUrl),
if (institution.introCn.isNotEmpty)
_InfoLine(label: '说明', value: institution.introCn),
],
if (asString(payload['copyright_cn']).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x4),
Text(
asString(payload['copyright_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: WiseSpacing.x3),
DecoratedBox(
decoration: BoxDecoration(
color: const Color(0x109A6500),
borderRadius: BorderRadius.circular(WiseRadius.sm),
),
child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3),
child: Text(
asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: WiseColors.warning),
),
),
),
],
);
}
}
class _InfoLine extends StatelessWidget {
const _InfoLine({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
),
const SizedBox(height: WiseSpacing.x1),
Text(value, style: Theme.of(context).textTheme.bodyMedium),
],
),
);
}
}
class _AudioModule extends StatelessWidget {
const _AudioModule({
required this.payload,
required this.report,
required this.player,
this.onStartAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
});
final JsonMap payload;
final ReportDetail? report;
final PlayerStateModel player;
final StartModuleAudio? onStartAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context) {
final title = asString(payload['title_cn'], report?.titleCn ?? '音频解读');
final audioId = asString(
payload['audio_id'],
'local_${report?.id ?? title.hashCode}',
);
final duration = asInt(payload['duration_sec'], 180);
return PlayerCard(
title: title,
durationSec: duration,
player: player,
onStart: () =>
onStartAudio?.call(audioId, report?.id ?? '', title, duration),
onToggle: onToggleAudio ?? () {},
onSeek: onSeekAudio ?? (_) {},
onSpeed: onSpeed ?? () {},
);
}
}
class _InstitutionModule extends StatelessWidget {
const _InstitutionModule({required this.payload, required this.report});
final JsonMap payload;
final ReportDetail? report;
@override
Widget build(BuildContext context) {
final name = asString(payload['name_cn'], report?.institution.nameCn ?? '');
return Row(
children: [
const Icon(Icons.account_balance_outlined, color: WiseColors.primary),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Text(name, style: Theme.of(context).textTheme.bodyMedium),
),
Text(
'${asInt(payload['report_count'], report?.institution.reportCount ?? 0)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
class _SectionsModule extends StatelessWidget {
const _SectionsModule({required this.payload, required this.compact});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
final summary = asString(
payload['preview_summary'],
asString(payload['intro_cn']),
);
final sections = asMapList(payload['sections']);
if (compact) return _Preview(payload: payload);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (summary.isNotEmpty)
Text(summary, style: Theme.of(context).textTheme.bodyMedium),
for (final section in sections) ...[
const SizedBox(height: WiseSpacing.x3),
Text(
asString(section['heading']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(section['body']),
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
);
}
}
class _KeyDataModule extends StatelessWidget {
const _KeyDataModule({required this.payload, required this.compact});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) return _Preview(payload: payload);
final rows = asMapList(payload['rows']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final row in rows)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(row['metric']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
_valueWithUnit(row),
style: Theme.of(context).textTheme.bodyLarge,
),
if (asString(
row['judgment'],
asString(row['importance']),
).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x1),
Text(
asString(row['judgment'], asString(row['importance'])),
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (asString(row['importance']).isNotEmpty &&
asString(row['importance']) !=
asString(row['judgment'])) ...[
const SizedBox(height: WiseSpacing.x1),
Text(
asString(row['importance']),
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
],
);
}
}
class _TimelineModule extends StatelessWidget {
const _TimelineModule({required this.payload, required this.compact});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) return _Preview(payload: payload);
final events = asMapList(payload['events']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final event in events)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(event['date']).isNotEmpty)
Text(
asString(event['date']),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(event['event']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(event['impact']),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
);
}
}
class _StudyGuideModule extends StatelessWidget {
const _StudyGuideModule({required this.payload, required this.compact});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) return _Preview(payload: payload);
final faqs = asMapList(payload['faq_items']);
final glossary = asMapList(payload['glossary']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['intro_cn']).isNotEmpty)
Text(
asString(payload['intro_cn']),
style: Theme.of(context).textTheme.bodyMedium,
),
for (final item in faqs)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(asString(item['question'])),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
asString(item['answer']),
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
if (glossary.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
for (final item in glossary)
AppBadge(
text:
'${asString(item['term'])}: ${asString(item['definition'])}',
),
],
),
],
],
);
}
}
class _StructureGraphModule extends StatelessWidget {
const _StructureGraphModule({required this.payload, required this.compact});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) return _Preview(payload: payload);
final nodes = asMapList(payload['nodes']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(payload['root']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x3),
for (final node in nodes)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(node['label']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
for (final child in asStringList(node['children']))
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1),
child: Text(
child,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
],
);
}
}
class _RelatedSourcesModule extends StatelessWidget {
const _RelatedSourcesModule({required this.payload, required this.compact});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
final items = asMapList(payload['items'] ?? payload['sources']);
if (items.isEmpty) return _Preview(payload: payload);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final item in items)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(item['title']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['summary_cn'], asString(item['source_name'])),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
);
}
}
class _DifferentiatedViewModule extends StatelessWidget {
const _DifferentiatedViewModule({
required this.payload,
required this.compact,
});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) return _Preview(payload: payload);
final items = asMapList(payload['divergences']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final item in items)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(item['topic']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x2),
if (asString(item['consensus_view']).isNotEmpty) ...[
Text(
'常见观点',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['consensus_view']),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x2),
],
if (asString(item['report_position']).isNotEmpty) ...[
Text(
'报告观点',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['report_position']),
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
],
);
}
}
class _WeaknessesModule extends StatelessWidget {
const _WeaknessesModule({required this.payload, required this.compact});
final JsonMap payload;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) return _Preview(payload: payload);
final items = asMapList(payload['items']);
final verificationNotes = asStringList(payload['verification_notes']);
final counterEvidence = {
for (final item in items)
if (asString(item['counter_evidence']).isNotEmpty)
asString(item['counter_evidence']),
}.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['disclaimer_cn']).isNotEmpty)
Text(
asString(payload['disclaimer_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
for (final item in items)
Padding(
padding: const EdgeInsets.only(
top: WiseSpacing.x3,
bottom: WiseSpacing.x2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(item['topic']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['weakness']),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
DecoratedBox(
decoration: BoxDecoration(
color: const Color(0x109A6500),
borderRadius: BorderRadius.circular(WiseRadius.sm),
),
child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'需要继续验证',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.warning),
),
const SizedBox(height: WiseSpacing.x1),
for (final note
in verificationNotes.isNotEmpty
? verificationNotes
: counterEvidence)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1),
child: Text(
note,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
),
],
],
);
}
}
class _Preview extends StatelessWidget {
const _Preview({required this.payload});
final JsonMap payload;
@override
Widget build(BuildContext context) {
final headline = asString(
payload['preview_headline'],
asString(payload['preview_summary'], asString(payload['root'])),
);
final highlights = asStringList(payload['highlights']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (headline.isNotEmpty)
Text(headline, style: Theme.of(context).textTheme.bodyMedium),
for (final item in highlights.take(3))
Padding(
padding: const EdgeInsets.only(top: WiseSpacing.x1),
child: Text(
'$item',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
);
}
}
class _TextLines extends StatelessWidget {
const _TextLines({required this.payload});
final JsonMap payload;
@override
Widget build(BuildContext context) {
final values = payload.entries
.where(
(entry) => entry.value != null && entry.value.toString().isNotEmpty,
)
.map((entry) => '${entry.key}: ${entry.value}')
.take(5)
.join('\n');
return Text(
values.isEmpty ? '该模块暂无可展示内容。' : values,
style: Theme.of(context).textTheme.bodyMedium,
);
}
}
class _FallbackModule extends StatelessWidget {
const _FallbackModule({required this.type, required this.payload});
final String type;
final JsonMap payload;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppBadge(text: '未知模块:$type', kind: BadgeKind.warning),
const SizedBox(height: WiseSpacing.x2),
_Preview(payload: payload),
],
);
}
}
String _kindLabel(String kind) => switch (kind) {
'view' => '观点',
'number' => '数字',
'risk' => '风险',
_ => '要点',
};
BadgeKind _kindBadge(String kind) => switch (kind) {
'risk' => BadgeKind.warning,
'number' => BadgeKind.audio,
_ => BadgeKind.brand,
};
String _valueWithUnit(JsonMap row) {
final value = asString(row['value']);
final unit = asString(row['unit']);
if (unit.isEmpty) return value;
return '$value $unit';
}
String _institutionTypeLabel(String value) => switch (value) {
'international_org' => '国际组织',
'official' => '官方机构',
'industry_org' => '行业组织',
'asset_manager' => '资管机构',
'bank_research' => '银行研究',
'partner' => '合作机构',
_ => value,
};
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import 'modules/renderer_registry.dart';
class ReportDetailPage extends StatefulWidget {
const ReportDetailPage({
required this.reportId,
required this.dataSource,
this.player = const PlayerStateModel(),
this.onStartAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final String reportId;
final ReportDataSource dataSource;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
State<ReportDetailPage> createState() => _ReportDetailPageState();
}
class _ReportDetailPageState extends State<ReportDetailPage> {
static const registry = ModuleRendererRegistry();
late Future<ReportDetail> future = widget.dataSource.reportDetail(
widget.reportId,
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('研报详情')),
body: FutureBuilder<ReportDetail>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingState();
}
if (snapshot.hasError) {
return ErrorState(
message: snapshot.error.toString(),
onRetry: () => setState(
() => future = widget.dataSource.reportDetail(widget.reportId),
),
);
}
final detail = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppBadge(
text: detail.interpretationLabel,
kind: BadgeKind.brand,
),
if (detail.hasAudio)
const AppBadge(
text: '音频',
icon: Icons.graphic_eq,
kind: BadgeKind.audio,
),
AppBadge(
text: asString(detail.source['source_tier']),
icon: Icons.verified_outlined,
kind: BadgeKind.tier,
),
],
),
const SizedBox(height: WiseSpacing.x3),
Text(
detail.titleCn,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
),
if (detail.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
Text(
detail.oneLiner,
style: Theme.of(context).textTheme.bodyMedium,
),
],
const SizedBox(height: WiseSpacing.x3),
Text(
'${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(height: WiseSpacing.x4),
_ActionBar(detail: detail),
const SizedBox(height: WiseSpacing.x4),
_Toc(modules: detail.modules),
const SizedBox(height: WiseSpacing.x4),
for (final module in detail.modules) ...[
registry.card(
context: context,
module: module,
report: detail,
dataSource: widget.dataSource,
player: widget.player,
onStartAudio: widget.onStartAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
),
const SizedBox(height: WiseSpacing.x4),
],
],
);
},
),
);
}
}
class _ActionBar extends StatelessWidget {
const _ActionBar({required this.detail});
final ReportDetail detail;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: AppButton(
label: '收藏',
icon: Icons.favorite_border,
kind: AppButtonKind.ghost,
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'),
),
),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: AppButton(
label: '原文',
icon: Icons.open_in_new,
kind: AppButtonKind.ghost,
onPressed: () => showOutboundSheet(context, title: detail.titleCn),
),
),
],
);
}
}
class _Toc extends StatelessWidget {
const _Toc({required this.modules});
final List<DisplayModule> modules;
@override
Widget build(BuildContext context) {
if (modules.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final module in modules)
Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2),
child: AppBadge(text: module.titleCn, kind: BadgeKind.brand),
),
],
),
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class FeedPage extends StatefulWidget {
const FeedPage({
required this.dataSource,
required this.onPlay,
this.player = const PlayerStateModel(),
this.onStartModuleAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
State<FeedPage> createState() => _FeedPageState();
}
class _FeedPageState extends State<FeedPage> {
String topic = '全部';
late Future<List<ReportCardModel>> future = widget.dataSource.recommended();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ReportCardModel>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState();
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.recommended()));
final items = snapshot.data ?? const [];
final topics = ['全部', ...{for (final item in items) ...item.topics}];
final visible = topic == '全部' ? items : items.where((item) => item.topics.contains(topic)).toList();
if (items.isEmpty) return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容');
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final t in topics)
Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2),
child: AppChip(label: t, selected: t == topic, onTap: () => setState(() => topic = t)),
),
],
),
),
const SizedBox(height: WiseSpacing.x3),
if (visible.isEmpty)
EmptyState(title: '暂无可推荐的研报解读', message: '换个主题,或去研报页看看全部内容', icon: Icons.filter_alt_off)
else ...[
ReportCardWidget(
report: visible.first,
hero: true,
onTap: () => openReportDetail(
context,
widget.dataSource,
visible.first,
player: widget.player,
onStartAudio: widget.onStartModuleAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
),
onPlayTap: () => playFromReport(widget.onPlay, visible.first),
),
const SizedBox(height: WiseSpacing.x5),
Text('最新解读', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3),
for (final report in visible.skip(1)) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(
context,
widget.dataSource,
report,
player: widget.player,
onStartAudio: widget.onStartModuleAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
),
onPlayTap: () => playFromReport(widget.onPlay, report),
),
const SizedBox(height: WiseSpacing.x3),
],
],
],
);
},
);
}
void playFromReport(void Function(AudioItem item) onPlay, ReportCardModel report) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class InstitutionDetailPage extends StatefulWidget {
const InstitutionDetailPage({required this.institutionId, required this.dataSource, super.key});
final String institutionId;
final ReportDataSource dataSource;
@override
State<InstitutionDetailPage> createState() => _InstitutionDetailPageState();
}
class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
late Future<Institution> future = widget.dataSource.institutionDetail(widget.institutionId);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('机构主页')),
body: FutureBuilder<Institution>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState();
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutionDetail(widget.institutionId)));
final item = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall),
if (item.nameEn.isNotEmpty) Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppBadge(text: item.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier),
AppBadge(text: '${item.reportCount} 份研报', kind: BadgeKind.brand),
for (final topic in item.coveredTopics) AppBadge(text: topic),
],
),
],
),
),
const SizedBox(height: WiseSpacing.x3),
if (item.introCn.isNotEmpty)
AppCard(child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium)),
const SizedBox(height: WiseSpacing.x3),
if (item.credibilityNote.isNotEmpty)
AppCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.verified_user_outlined, color: WiseColors.positive),
const SizedBox(width: WiseSpacing.x2),
Expanded(child: Text(item.credibilityNote, style: Theme.of(context).textTheme.bodyMedium)),
],
),
),
const SizedBox(height: WiseSpacing.x5),
Text('最新研报', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3),
if (item.recentReports.isEmpty)
const EmptyState(title: '机构暂无研报', message: '稍后再试', icon: Icons.article_outlined)
else
for (final report in item.recentReports) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(context, widget.dataSource, report),
),
const SizedBox(height: WiseSpacing.x3),
],
AppButton(
label: '了解相关服务',
icon: Icons.open_in_new,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () => showOutboundSheet(context, title: item.nameCn),
),
],
);
},
),
);
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/states.dart';
class InstitutionsPage extends StatefulWidget {
const InstitutionsPage({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
State<InstitutionsPage> createState() => _InstitutionsPageState();
}
class _InstitutionsPageState extends State<InstitutionsPage> {
late Future<List<Institution>> future = widget.dataSource.institutions();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Institution>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState();
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutions()));
final items = [...snapshot.data ?? const <Institution>[]]..sort((a, b) => b.reportCount.compareTo(a.reportCount));
if (items.isEmpty) return const EmptyState(title: '暂无机构信息', message: '稍后再试', icon: Icons.account_balance_outlined);
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x3),
for (final item in items) ...[
InstitutionCard(
institution: item,
onTap: () => openInstitutionDetail(context, widget.dataSource, item.id),
),
const SizedBox(height: WiseSpacing.x3),
],
],
);
},
);
}
}
class InstitutionCard extends StatelessWidget {
const InstitutionCard({required this.institution, required this.onTap, super.key});
final Institution institution;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final initials = institution.nameCn.isEmpty ? '' : institution.nameCn.characters.take(2).toString();
return AppCard(
onTap: onTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 25,
backgroundColor: WiseColors.secondary200,
foregroundColor: WiseColors.primary,
child: Text(initials, style: const TextStyle(fontWeight: FontWeight.w800)),
),
const SizedBox(width: WiseSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(institution.nameCn, style: Theme.of(context).textTheme.titleMedium),
if (institution.nameEn.isNotEmpty)
Text(institution.nameEn, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: WiseSpacing.x2),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
if (institution.institutionType.isNotEmpty) AppBadge(text: institution.institutionType),
for (final topic in institution.coveredTopics.take(3)) AppBadge(text: topic, kind: BadgeKind.brand),
],
),
],
),
),
const SizedBox(width: WiseSpacing.x2),
Column(
children: [
Text('${institution.reportCount}', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: WiseColors.primary)),
Text('份研报', style: Theme.of(context).textTheme.bodySmall),
],
),
],
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/states.dart';
class ListenPage extends StatefulWidget {
const ListenPage({required this.dataSource, required this.onPlay, super.key});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
@override
State<ListenPage> createState() => _ListenPageState();
}
class _ListenPageState extends State<ListenPage> {
late Future<List<AudioItem>> future = widget.dataSource.listen();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<AudioItem>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在加载听单');
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.listen()));
final items = snapshot.data ?? const [];
if (items.isEmpty) return const EmptyState(title: '暂无音频研报', message: '先去研报页看看图文解读', icon: Icons.headphones_outlined);
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text('游客可完整收听;真实音频流待后端接入。', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x4),
for (final item in items) ...[
AppCard(
onTap: () => widget.onPlay(item),
child: Row(
children: [
IconButton.filled(
onPressed: () => widget.onPlay(item),
icon: const Icon(Icons.play_arrow),
style: IconButton.styleFrom(backgroundColor: WiseColors.primary, foregroundColor: Colors.white),
),
const SizedBox(width: WiseSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.reportTitleCn, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium),
Text('${item.institution.nameCn} · ${formatDuration(item.durationSec)}', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(value: 0, minHeight: 4, color: WiseColors.accent, backgroundColor: WiseColors.border),
],
),
),
],
),
),
const SizedBox(height: WiseSpacing.x3),
],
],
);
},
);
}
}
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('游客', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: WiseSpacing.x2),
Text('浏览、阅读和完整收听不需要登录。收藏、历史同步和保存听单等待 auth 接口接入。', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '登录后保存个人状态',
icon: Icons.login,
onPressed: () => showLoginSheet(context),
),
],
),
),
const SizedBox(height: WiseSpacing.x4),
_ProfileRow(icon: Icons.favorite_border, title: '收藏研报', subtitle: '登录后同步收藏', onTap: () => showLoginSheet(context, reason: '登录后保存到你的收藏')),
_ProfileRow(icon: Icons.history, title: '浏览历史', subtitle: '本地历史占位,服务端同步待接入', onTap: () => showAppToast(context, '历史同步接口待接入')),
_ProfileRow(icon: Icons.playlist_add_check, title: '保存听单', subtitle: '登录后保存到你的听单', onTap: () => showLoginSheet(context, reason: '登录后保存到你的听单')),
_ProfileRow(icon: Icons.open_in_new, title: '了解研值相关服务', subtitle: '外跳前提示风险边界', onTap: () => showOutboundSheet(context, title: '研值相关服务')),
],
);
}
}
class _ProfileRow extends StatelessWidget {
const _ProfileRow({required this.icon, required this.title, required this.subtitle, required this.onTap});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3),
child: AppCard(
onTap: onTap,
child: Row(
children: [
Icon(icon, color: WiseColors.primary),
const SizedBox(width: WiseSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
const Icon(Icons.chevron_right, color: WiseColors.textTertiary),
],
),
),
);
}
}
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class ReportsPage extends StatefulWidget {
const ReportsPage({
required this.dataSource,
required this.onPlay,
this.player = const PlayerStateModel(),
this.onStartModuleAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
State<ReportsPage> createState() => _ReportsPageState();
}
class _ReportsPageState extends State<ReportsPage> {
late Future<List<ReportCardModel>> future = widget.dataSource.reports();
String query = '';
String topic = '';
bool hasAudio = false;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ReportCardModel>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在搜索研报');
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.reports()));
final items = applyFilters(snapshot.data ?? const []);
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
TextField(
decoration: InputDecoration(
hintText: '搜索标题、机构或主题',
prefixIcon: const Icon(Icons.search),
suffixIcon: query.isEmpty ? null : IconButton(onPressed: () => setState(() => query = ''), icon: const Icon(Icons.close)),
filled: true,
fillColor: WiseColors.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(WiseRadius.pill), borderSide: BorderSide.none),
),
onChanged: (value) => setState(() => query = value.trim()),
),
const SizedBox(height: WiseSpacing.x3),
Row(
children: [
AppButton(label: '筛选', icon: Icons.tune, kind: AppButtonKind.ghost, onPressed: openFilterSheet),
const SizedBox(width: WiseSpacing.x2),
AppChip(label: '有音频', selected: hasAudio, onTap: () => setState(() => hasAudio = !hasAudio)),
],
),
const SizedBox(height: WiseSpacing.x3),
Text('${items.length} 篇研报解读${query.isNotEmpty || topic.isNotEmpty || hasAudio ? '(已筛选)' : ''}', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: WiseSpacing.x3),
if (items.isEmpty)
EmptyState(
title: query.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: query.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选',
onAction: () => setState(() {
query = '';
topic = '';
hasAudio = false;
}),
)
else
for (final report in items) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(
context,
widget.dataSource,
report,
player: widget.player,
onStartAudio: widget.onStartModuleAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
),
onPlayTap: () => widget.onPlay(AudioItem(audioId: 'local_${report.id}', reportId: report.id, titleCn: report.titleCn, reportTitleCn: report.titleCn, durationSec: 180, institution: report.institution)),
),
const SizedBox(height: WiseSpacing.x3),
],
],
);
},
);
}
List<ReportCardModel> applyFilters(List<ReportCardModel> items) {
return items.where((item) {
final hay = '${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'.toLowerCase();
if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false;
if (topic.isNotEmpty && !item.topics.contains(topic)) return false;
if (hasAudio && !item.hasAudio) return false;
return true;
}).toList();
}
void openFilterSheet() {
widget.dataSource.reports().then((items) {
if (!mounted) return;
final topics = {for (final item in items) ...item.topics}.toList();
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg))),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('筛选研报', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppChip(label: '全部主题', selected: topic.isEmpty, onTap: () => selectTopic('')),
for (final t in topics) AppChip(label: t, selected: topic == t, onTap: () => selectTopic(t)),
],
),
const SizedBox(height: WiseSpacing.x4),
AppButton(label: '完成', expand: true, onPressed: () => Navigator.pop(context)),
],
),
),
);
});
}
void selectTopic(String value) {
setState(() => topic = value);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import '../../data/models/models.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
class ReportCardWidget extends StatelessWidget {
const ReportCardWidget({
required this.report,
required this.onTap,
this.hero = false,
this.onInstitutionTap,
this.onPlayTap,
super.key,
});
final ReportCardModel report;
final VoidCallback onTap;
final bool hero;
final VoidCallback? onInstitutionTap;
final VoidCallback? onPlayTap;
@override
Widget build(BuildContext context) {
final child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand),
if (report.hasAudio)
const AppBadge(text: '音频', icon: Icons.graphic_eq, kind: BadgeKind.audio),
if (report.sourceTier.isNotEmpty)
AppBadge(text: report.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier),
for (final topic in report.topics.take(3)) AppBadge(text: topic),
],
),
const SizedBox(height: WiseSpacing.x3),
Text(
report.titleCn,
maxLines: hero ? 3 : 2,
overflow: TextOverflow.ellipsis,
style: hero
? Theme.of(context).textTheme.titleLarge
: Theme.of(context).textTheme.titleMedium,
),
if (report.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
Text(
report.oneLiner,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
const SizedBox(height: WiseSpacing.x3),
Row(
children: [
Expanded(
child: InkWell(
onTap: onInstitutionTap,
child: Text(
'${report.institution.nameCn}${report.releasedAt == null ? '' : ' · ${formatDate(report.releasedAt)}'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
if (report.hasAudio)
TextButton.icon(
onPressed: onPlayTap,
icon: const Icon(Icons.play_circle_outline, size: 18),
label: const Text('听研报'),
),
],
),
],
);
return hero
? HeroReportCard(onTap: onTap, child: child)
: AppCard(onTap: onTap, child: child);
}
}
@@ -0,0 +1,152 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../data/api/report_data_source.dart';
import '../data/models/models.dart';
import '../theme/wise_tokens.dart';
import '../widgets/mini_player.dart';
import 'feed/feed_page.dart';
import 'institutions/institutions_page.dart';
import 'listen/listen_page.dart';
import 'profile/profile_page.dart';
import 'reports/reports_page.dart';
class ShellPage extends StatefulWidget {
const ShellPage({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
State<ShellPage> createState() => _ShellPageState();
}
class _ShellPageState extends State<ShellPage> {
int index = 0;
PlayerStateModel player = const PlayerStateModel();
Timer? timer;
@override
void dispose() {
timer?.cancel();
super.dispose();
}
void startAudio(AudioItem item) {
timer?.cancel();
setState(() {
player = PlayerStateModel(
audioId: item.audioId,
reportId: item.reportId,
title: item.titleCn,
durationSec: item.durationSec,
playing: true,
speed: player.speed,
);
});
timer = Timer.periodic(const Duration(seconds: 1), (_) => tick());
}
void startModuleAudio(String audioId, String reportId, String title, int durationSec) {
startAudio(
AudioItem(
audioId: audioId,
reportId: reportId,
titleCn: title,
reportTitleCn: title,
durationSec: durationSec,
institution: const Institution(id: '', nameCn: ''),
),
);
}
void tick() {
if (!player.playing) return;
final next = player.positionSec + player.speed.round().clamp(1, 2);
setState(() {
player = player.copyWith(
positionSec: next >= player.durationSec ? player.durationSec : next,
playing: next < player.durationSec,
);
});
}
void toggleAudio() {
if (!player.hasAudio) return;
setState(() => player = player.copyWith(playing: !player.playing));
}
void seekAudio(int delta) {
if (!player.hasAudio) return;
setState(() {
player = player.copyWith(
positionSec: (player.positionSec + delta).clamp(0, player.durationSec),
);
});
}
void cycleSpeed() {
const speeds = [1.0, 1.25, 1.5, 2.0];
final current = speeds.indexOf(player.speed);
setState(() => player = player.copyWith(speed: speeds[(current + 1) % speeds.length]));
}
@override
Widget build(BuildContext context) {
final pages = [
FeedPage(
dataSource: widget.dataSource,
onPlay: startAudio,
player: player,
onStartModuleAudio: startModuleAudio,
onToggleAudio: toggleAudio,
onSeekAudio: seekAudio,
onSpeed: cycleSpeed,
),
ReportsPage(
dataSource: widget.dataSource,
onPlay: startAudio,
player: player,
onStartModuleAudio: startModuleAudio,
onToggleAudio: toggleAudio,
onSeekAudio: seekAudio,
onSpeed: cycleSpeed,
),
InstitutionsPage(dataSource: widget.dataSource),
ListenPage(dataSource: widget.dataSource, onPlay: startAudio),
ProfilePage(dataSource: widget.dataSource),
];
return Scaffold(
appBar: AppBar(
title: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('研听'),
Text(
'全球机构研报中文解读',
style: TextStyle(fontSize: 12, color: WiseColors.textSecondary, fontWeight: FontWeight.w500),
),
],
),
),
body: pages[index],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
MiniPlayer(player: player, onToggle: toggleAudio),
NavigationBar(
selectedIndex: index,
onDestinationSelected: (value) => setState(() => index = value),
destinations: const [
NavigationDestination(icon: Icon(Icons.auto_awesome_outlined), selectedIcon: Icon(Icons.auto_awesome), label: '推荐'),
NavigationDestination(icon: Icon(Icons.article_outlined), selectedIcon: Icon(Icons.article), label: '研报'),
NavigationDestination(icon: Icon(Icons.account_balance_outlined), selectedIcon: Icon(Icons.account_balance), label: '机构'),
NavigationDestination(icon: Icon(Icons.headphones_outlined), selectedIcon: Icon(Icons.headphones), label: '听单'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'),
],
),
],
),
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'app.dart';
import 'data/api/report_data_source.dart';
export 'app.dart';
export 'data/api/report_data_source.dart';
export 'data/models/models.dart';
void main() {
runApp(MyApp(dataSource: RnbApiDataSource()));
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../data/api/report_data_source.dart';
import '../data/models/models.dart';
import '../features/detail/report_detail_page.dart';
import '../features/institutions/institution_detail_page.dart';
import '../widgets/mini_player.dart';
void openReportDetail(
BuildContext context,
ReportDataSource dataSource,
ReportCardModel report, {
PlayerStateModel player = const PlayerStateModel(),
void Function(String audioId, String reportId, String title, int durationSec)? onStartAudio,
VoidCallback? onToggleAudio,
void Function(int delta)? onSeekAudio,
VoidCallback? onSpeed,
}) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReportDetailPage(
reportId: report.id,
dataSource: dataSource,
player: player,
onStartAudio: onStartAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
),
);
}
void openInstitutionDetail(
BuildContext context,
ReportDataSource dataSource,
String institutionId,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => InstitutionDetailPage(
institutionId: institutionId,
dataSource: dataSource,
),
),
);
}
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'wise_tokens.dart';
ThemeData buildAppTheme() {
final scheme = ColorScheme.fromSeed(
seedColor: WiseColors.primary,
primary: WiseColors.primary,
secondary: WiseColors.secondary,
tertiary: WiseColors.accent,
surface: WiseColors.surface,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: 'Inter',
scaffoldBackgroundColor: WiseColors.canvas,
appBarTheme: const AppBarTheme(
backgroundColor: WiseColors.canvas,
foregroundColor: WiseColors.primary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: WiseColors.primary,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
cardTheme: const CardThemeData(
color: WiseColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(WiseRadius.md)),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: WiseColors.surface,
indicatorColor: WiseColors.secondary200,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
color: states.contains(WidgetState.selected)
? WiseColors.primary
: WiseColors.textTertiary,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
color: states.contains(WidgetState.selected)
? WiseColors.primary
: WiseColors.textTertiary,
),
),
),
textTheme: const TextTheme(
headlineSmall: TextStyle(
color: WiseColors.ink,
fontSize: 26,
height: 1.18,
fontWeight: FontWeight.w800,
),
titleLarge: TextStyle(
color: WiseColors.ink,
fontSize: 21,
height: 1.22,
fontWeight: FontWeight.w800,
),
titleMedium: TextStyle(
color: WiseColors.ink,
fontSize: 17,
height: 1.25,
fontWeight: FontWeight.w800,
),
bodyLarge: TextStyle(
color: WiseColors.ink,
fontSize: 16,
height: 1.55,
),
bodyMedium: TextStyle(
color: WiseColors.ink700,
fontSize: 14,
height: 1.5,
),
bodySmall: TextStyle(
color: WiseColors.textSecondary,
fontSize: 12,
height: 1.45,
),
labelSmall: TextStyle(
color: WiseColors.textSecondary,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
);
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
final class WiseColors {
static const primary = Color(0xFF163300);
static const primarySoft = Color(0xFF1F4708);
static const secondary = Color(0xFF9FE870);
static const secondary200 = Color(0xFFE2F6D5);
static const accent = Color(0xFF00A2DD);
static const canvas = Color(0xFFF4F6F3);
static const ink = Color(0xFF0E0F0C);
static const ink700 = Color(0xFF454745);
static const textSecondary = Color(0xFF5D7079);
static const textTertiary = Color(0xFF768E9C);
static const surface = Colors.white;
static const border = Color(0x1A000000);
static const positive = Color(0xFF008026);
static const warning = Color(0xFF9A6500);
static const negative = Color(0xFFCF2929);
}
final class WiseSpacing {
static const x1 = 4.0;
static const x2 = 8.0;
static const x3 = 12.0;
static const x4 = 16.0;
static const x5 = 20.0;
static const x6 = 24.0;
static const x8 = 32.0;
static const x10 = 40.0;
}
final class WiseRadius {
static const sm = 10.0;
static const md = 16.0;
static const lg = 24.0;
static const pill = 999.0;
}
final class WiseMotion {
static const short = Duration(milliseconds: 200);
static const base = Duration(milliseconds: 350);
static const curve = Cubic(0.8, 0.05, 0.2, 0.95);
}
final class WiseShadows {
static const card = [
BoxShadow(
color: Color(0x14000000),
blurRadius: 20,
offset: Offset(0, 6),
),
];
static const elevated = [
BoxShadow(
color: Color(0x24000000),
blurRadius: 32,
offset: Offset(0, 10),
),
];
}
const wiseFontStack = [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
'Arial',
'sans-serif',
];
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
class AppButton extends StatelessWidget {
const AppButton({
required this.label,
required this.onPressed,
this.icon,
this.kind = AppButtonKind.primary,
this.expand = false,
super.key,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final AppButtonKind kind;
final bool expand;
@override
Widget build(BuildContext context) {
final colors = switch (kind) {
AppButtonKind.primary => (WiseColors.secondary, WiseColors.primary),
AppButtonKind.dark => (WiseColors.primary, Colors.white),
AppButtonKind.accent => (WiseColors.accent, Colors.white),
AppButtonKind.ghost => (WiseColors.surface, WiseColors.primary),
};
final child = FilledButton.icon(
onPressed: onPressed,
icon: icon == null ? const SizedBox.shrink() : Icon(icon, size: 18),
label: Text(label),
style: FilledButton.styleFrom(
backgroundColor: colors.$1,
foregroundColor: colors.$2,
disabledBackgroundColor: WiseColors.border,
disabledForegroundColor: WiseColors.textTertiary,
minimumSize: Size(expand ? double.infinity : 0, 44),
shape: const StadiumBorder(),
),
);
return expand ? SizedBox(width: double.infinity, child: child) : child;
}
}
enum AppButtonKind { primary, dark, accent, ghost }
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
class AppCard extends StatelessWidget {
const AppCard({
required this.child,
this.onTap,
this.padding = const EdgeInsets.all(WiseSpacing.x4),
this.color = WiseColors.surface,
super.key,
});
final Widget child;
final VoidCallback? onTap;
final EdgeInsetsGeometry padding;
final Color color;
@override
Widget build(BuildContext context) {
final content = DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(WiseRadius.md),
boxShadow: WiseShadows.card,
),
child: Padding(padding: padding, child: child),
);
if (onTap == null) return content;
return InkWell(
borderRadius: BorderRadius.circular(WiseRadius.md),
onTap: onTap,
child: content,
);
}
}
class HeroReportCard extends StatelessWidget {
const HeroReportCard({required this.child, this.onTap, super.key});
final Widget child;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return AppCard(
onTap: onTap,
color: WiseColors.secondary200,
padding: const EdgeInsets.all(WiseSpacing.x5),
child: child,
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
class AppBadge extends StatelessWidget {
const AppBadge({
required this.text,
this.icon,
this.kind = BadgeKind.neutral,
super.key,
});
final String text;
final IconData? icon;
final BadgeKind kind;
@override
Widget build(BuildContext context) {
final colors = switch (kind) {
BadgeKind.brand => (WiseColors.secondary200, WiseColors.primarySoft),
BadgeKind.audio => (const Color(0x1F00A2DD), WiseColors.accent),
BadgeKind.tier => (const Color(0x1A008026), WiseColors.positive),
BadgeKind.warning => (const Color(0x209A6500), WiseColors.warning),
BadgeKind.neutral => (const Color(0x1286A7BD), WiseColors.textSecondary),
};
return DecoratedBox(
decoration: BoxDecoration(
color: colors.$1,
borderRadius: BorderRadius.circular(WiseRadius.pill),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: colors.$2),
const SizedBox(width: 4),
],
Text(
text,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colors.$2),
),
],
),
),
);
}
}
enum BadgeKind { brand, audio, tier, warning, neutral }
class AppChip extends StatelessWidget {
const AppChip({
required this.label,
this.selected = false,
this.onTap,
super.key,
});
final String label;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ActionChip(
onPressed: onTap,
label: Text(label),
labelStyle: TextStyle(
color: selected ? Colors.white : WiseColors.textSecondary,
fontWeight: FontWeight.w700,
),
backgroundColor: selected ? WiseColors.primary : WiseColors.surface,
side: const BorderSide(color: WiseColors.border),
shape: const StadiumBorder(),
);
}
}
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import '../data/models/models.dart';
import '../theme/wise_tokens.dart';
import 'app_card.dart';
class PlayerStateModel {
const PlayerStateModel({
this.audioId = '',
this.reportId = '',
this.title = '',
this.durationSec = 0,
this.positionSec = 0,
this.playing = false,
this.speed = 1.0,
});
final String audioId;
final String reportId;
final String title;
final int durationSec;
final int positionSec;
final bool playing;
final double speed;
bool get hasAudio => audioId.isNotEmpty;
PlayerStateModel copyWith({
String? audioId,
String? reportId,
String? title,
int? durationSec,
int? positionSec,
bool? playing,
double? speed,
}) {
return PlayerStateModel(
audioId: audioId ?? this.audioId,
reportId: reportId ?? this.reportId,
title: title ?? this.title,
durationSec: durationSec ?? this.durationSec,
positionSec: positionSec ?? this.positionSec,
playing: playing ?? this.playing,
speed: speed ?? this.speed,
);
}
}
class MiniPlayer extends StatelessWidget {
const MiniPlayer({
required this.player,
required this.onToggle,
super.key,
});
final PlayerStateModel player;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
if (!player.hasAudio) return const SizedBox.shrink();
final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: AppCard(
padding: const EdgeInsets.all(12),
color: WiseColors.primary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton.filled(
onPressed: onToggle,
icon: Icon(player.playing ? Icons.pause : Icons.play_arrow),
style: IconButton.styleFrom(
backgroundColor: WiseColors.secondary,
foregroundColor: WiseColors.primary,
),
),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
player.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800),
),
Text(
'${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x',
style: const TextStyle(color: Color(0xCCFFFFFF), fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
minHeight: 4,
backgroundColor: const Color(0x33FFFFFF),
color: WiseColors.secondary,
),
],
),
),
);
}
}
class PlayerCard extends StatelessWidget {
const PlayerCard({
required this.title,
required this.durationSec,
required this.player,
required this.onStart,
required this.onToggle,
required this.onSeek,
required this.onSpeed,
super.key,
});
final String title;
final int durationSec;
final PlayerStateModel player;
final VoidCallback onStart;
final VoidCallback onToggle;
final void Function(int delta) onSeek;
final VoidCallback onSpeed;
@override
Widget build(BuildContext context) {
final active = player.hasAudio && player.title == title;
final position = active ? player.positionSec : 0;
final ratio = durationSec == 0 ? 0.0 : position / durationSec;
return AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('音频解读', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2),
Text(title, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
minHeight: 6,
backgroundColor: Colors.white,
color: WiseColors.accent,
),
const SizedBox(height: WiseSpacing.x2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall),
Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: WiseSpacing.x3),
Row(
children: [
IconButton.outlined(onPressed: () => onSeek(-15), icon: const Icon(Icons.replay_10)),
IconButton.filled(
onPressed: active ? onToggle : onStart,
icon: Icon(active && player.playing ? Icons.pause : Icons.play_arrow),
style: IconButton.styleFrom(
backgroundColor: WiseColors.primary,
foregroundColor: Colors.white,
),
),
IconButton.outlined(onPressed: () => onSeek(15), icon: const Icon(Icons.forward_10)),
const Spacer(),
TextButton(onPressed: onSpeed, child: Text('${player.speed.toStringAsFixed(1)}x')),
],
),
Text(
'真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'states.dart';
Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保存当前动作'}) {
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
backgroundColor: WiseColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('登录研听', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(reason, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '使用手机号继续',
icon: Icons.phone_iphone,
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '登录接口待接入,已保留当前页面');
},
),
const SizedBox(height: WiseSpacing.x2),
AppButton(
label: '微信 / Apple 登录占位',
icon: Icons.account_circle_outlined,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '真实 auth 待后端接入');
},
),
],
),
),
);
}
Future<void> showOutboundSheet(BuildContext context, {required String title}) {
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
backgroundColor: WiseColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('即将打开外部服务', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(
'$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '确认并记录占位事件',
icon: Icons.open_in_new,
kind: AppButtonKind.accent,
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '外跳事件接口待接入');
},
),
],
),
),
);
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'app_card.dart';
class LoadingState extends StatelessWidget {
const LoadingState({this.label = '正在加载研报解读', super.key});
final String label;
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(WiseSpacing.x4),
itemCount: 4,
separatorBuilder: (_, _) => const SizedBox(height: WiseSpacing.x3),
itemBuilder: (context, index) => const SkeletonCard(),
);
}
}
class SkeletonCard extends StatelessWidget {
const SkeletonCard({super.key});
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonLine(width: 96),
SizedBox(height: WiseSpacing.x3),
SkeletonLine(width: double.infinity, height: 18),
SizedBox(height: WiseSpacing.x2),
SkeletonLine(width: 240),
SizedBox(height: WiseSpacing.x3),
SkeletonLine(width: 160),
],
),
);
}
}
class SkeletonLine extends StatelessWidget {
const SkeletonLine({required this.width, this.height = 12, super.key});
final double width;
final double height;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: WiseColors.border,
borderRadius: BorderRadius.circular(WiseRadius.pill),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({
required this.title,
required this.message,
this.icon = Icons.search_off,
this.actionLabel,
this.onAction,
super.key,
});
final String title;
final String message;
final IconData icon;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 42, color: WiseColors.primary),
const SizedBox(height: WiseSpacing.x3),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
if (actionLabel != null) ...[
const SizedBox(height: WiseSpacing.x4),
AppButton(label: actionLabel!, onPressed: onAction, kind: AppButtonKind.ghost),
],
],
),
),
);
}
}
class ErrorState extends StatelessWidget {
const ErrorState({required this.message, this.onRetry, super.key});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return EmptyState(
icon: Icons.cloud_off_outlined,
title: '内容暂时加载失败',
message: message,
actionLabel: onRetry == null ? null : '重试',
onAction: onRetry,
);
}
}
void showAppToast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
backgroundColor: WiseColors.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(WiseRadius.md)),
),
);
}
+245
View File
@@ -0,0 +1,245 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.12.1 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+90
View File
@@ -0,0 +1,90 @@
name: report_notebooklm_app
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.12.1
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
http: ^1.6.0
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:report_notebooklm_app/main.dart';
void main() {
testWidgets('renders shell tabs and report detail modules', (tester) async {
await tester.pumpWidget(MyApp(dataSource: FakeDataSource()));
await tester.pumpAndSettle();
expect(find.text('推荐'), findsWidgets);
expect(find.text('研报'), findsWidgets);
expect(find.text('机构'), findsWidgets);
expect(find.text('听单'), findsWidgets);
expect(find.text('我的'), findsWidgets);
expect(find.text('黄金月报:金价新高之后,谁在继续买?'), findsOneWidget);
await tester.tap(find.text('黄金月报:金价新高之后,谁在继续买?'));
await tester.pumpAndSettle();
expect(find.text('研报详情'), findsOneWidget);
expect(find.text('报告要点'), findsWidgets);
expect(find.text('报告中的关键数据'), findsWidgets);
expect(find.text('查看详情'), findsWidgets);
await tester.tap(find.text('报告摘要').last);
await tester.pumpAndSettle();
expect(find.text('报告摘要'), findsOneWidget);
expect(find.text('需求结构'), findsOneWidget);
});
}
class FakeDataSource implements ReportDataSource {
final institution = const Institution(
id: 'inst_ssga',
nameCn: '道富环球投资管理',
nameEn: 'State Street Global Advisors',
sourceTier: 'tier_2',
coveredTopics: ['贵金属'],
reportCount: 1,
);
late final report = ReportCardModel(
id: 'rep_ssga_gold',
titleCn: '黄金月报:金价新高之后,谁在继续买?',
oneLiner: '央行购金转向结构性,ETF 重新净流入。',
institution: institution,
topics: const ['贵金属', '跨资产'],
releasedAt: '2026-05-22T00:00:00',
hasAudio: true,
interpretationLabel: '研报解读',
sourceTier: 'authorized_partner',
cacheVersion: 'rep_ssga_gold:v1',
);
@override
Future<List<ReportCardModel>> recommended() async => [report];
@override
Future<List<ReportCardModel>> reports() async => [report];
@override
Future<List<Institution>> institutions() async => [institution];
@override
Future<Institution> institutionDetail(String institutionId) async {
return Institution(
id: institution.id,
nameCn: institution.nameCn,
nameEn: institution.nameEn,
sourceTier: institution.sourceTier,
coveredTopics: institution.coveredTopics,
reportCount: 1,
introCn: '道富环球投资管理的公开研究用于演示。',
credibilityNote: 'tier_2 来源。',
recentReports: [report],
);
}
@override
Future<List<AudioItem>> listen() async => [
AudioItem(
audioId: 'aud_ssga_gold',
titleCn: '金价新高之后,谁在继续买?',
reportTitleCn: report.titleCn,
durationSec: 180,
reportId: report.id,
institution: institution,
),
];
@override
Future<ReportDetail> reportDetail(String reportId) async => ReportDetail(
id: report.id,
titleCn: report.titleCn,
oneLiner: report.oneLiner,
institution: institution,
source: const {
'source_tier': 'authorized_partner',
'source_note': '原文来源于机构公开研究页。',
},
topics: report.topics,
hasAudio: true,
releasedAt: report.releasedAt,
cacheVersion: report.cacheVersion,
modules: const [
DisplayModule(
id: 'mod_ssga_gold_executive_overview',
type: 'executive_overview',
layer: 'p0',
renderMode: 'card_plus_page',
hasDetailPage: true,
sortOrder: 1,
titleCn: '报告摘要',
preview: {'preview_summary': '本期月报拆解黄金买盘。', 'section_count': 3},
),
DisplayModule(
id: 'mod_ssga_gold_core_insights',
type: 'core_insights',
layer: 'p0',
renderMode: 'inline',
hasDetailPage: true,
sortOrder: 2,
titleCn: '报告要点',
content: {
'points': [
{'kind': 'view', 'text': '央行购金从机会性买入转向结构性配置。'},
],
},
),
DisplayModule(
id: 'mod_ssga_gold_key_data',
type: 'key_data',
layer: 'p0',
renderMode: 'card_plus_page',
hasDetailPage: true,
sortOrder: 3,
titleCn: '报告中的关键数据',
preview: {
'preview_headline': '10 个关键数据点',
'highlights': ['ETF 连续净流入'],
},
),
],
);
@override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return ModuleDetail(
id: moduleId,
type: 'executive_overview',
titleCn: '报告摘要',
content: const {
'sections': [
{'heading': '需求结构', 'body': '央行与 ETF 买盘提供支撑。'},
],
},
contentEtag: 'etag',
cacheVersion: 'rep_ssga_gold:v1',
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Some files were not shown because too many files have changed in this diff Show More