chore: prepare yanting monorepo handoff
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user