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
+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