完全跑通1.0版本
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql+asyncpg://ai_news:PrDTEr6tGcyWX6G2@chenwuzhu.cn:5432/ai_news
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
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
|
||||
@@ -0,0 +1,55 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Import all models so Alembic can detect schema changes
|
||||
from app.models.news import Base # noqa: E402
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> 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()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-05-26
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"news_sources",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("url", sa.String(500), nullable=False),
|
||||
sa.Column("source_type", sa.String(20), nullable=False, server_default="rss"),
|
||||
sa.Column("language", sa.String(5), nullable=False, server_default="zh"),
|
||||
sa.Column("category", sa.String(50), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"raw_news",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("source_id", sa.Integer(), sa.ForeignKey("news_sources.id"), nullable=True),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("url", sa.String(1000), nullable=False, unique=True),
|
||||
sa.Column("raw_content", sa.Text(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("crawled_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"processed_news",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("raw_news_id", sa.Integer(), sa.ForeignKey("raw_news.id"), nullable=False),
|
||||
sa.Column("title_zh", sa.String(500), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("opinion", sa.Text(), nullable=True),
|
||||
sa.Column("keywords", postgresql.ARRAY(sa.String()), nullable=True),
|
||||
sa.Column("importance_score", sa.Float(), nullable=False, server_default="5.0"),
|
||||
sa.Column("importance_reason", sa.Text(), nullable=True),
|
||||
sa.Column("category", sa.String(50), nullable=False, server_default="行业动态"),
|
||||
sa.Column("is_featured", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("featured_rank", sa.Integer(), nullable=True),
|
||||
sa.Column("source_name", sa.String(200), nullable=True),
|
||||
sa.Column("source_url", sa.String(1000), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("processed_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"llm_config",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("provider", sa.String(50), nullable=False),
|
||||
sa.Column("api_key", sa.String(500), nullable=False),
|
||||
sa.Column("base_url", sa.String(500), nullable=False),
|
||||
sa.Column("model_name", sa.String(200), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"system_logs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("event_type", sa.String(50), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("level", sa.String(20), nullable=False, server_default="INFO"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_index("ix_raw_news_status", "raw_news", ["status"])
|
||||
op.create_index("ix_processed_news_processed_at", "processed_news", ["processed_at"])
|
||||
op.create_index("ix_processed_news_is_featured", "processed_news", ["is_featured"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("system_logs")
|
||||
op.drop_table("llm_config")
|
||||
op.drop_table("processed_news")
|
||||
op.drop_table("raw_news")
|
||||
op.drop_table("news_sources")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
-15
@@ -1,17 +1,16 @@
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func, distinct
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.news import ProcessedNews, RawNews
|
||||
from ..models.news import ProcessedNews
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(n: ProcessedNews) -> dict:
|
||||
raw = n.raw_news
|
||||
return {
|
||||
"id": n.id,
|
||||
"title_zh": n.title_zh,
|
||||
@@ -23,8 +22,8 @@ def _serialize(n: ProcessedNews) -> dict:
|
||||
"category": n.category,
|
||||
"is_featured": n.is_featured,
|
||||
"featured_rank": n.featured_rank,
|
||||
"source_name": n.source_name or (raw.source.name if raw and raw.source else ""),
|
||||
"source_url": n.source_url or (raw.url if raw else ""),
|
||||
"source_name": n.source_name or "",
|
||||
"source_url": n.source_url or "",
|
||||
"published_at": n.published_at.isoformat() if n.published_at else None,
|
||||
"processed_at": n.processed_at.isoformat() if n.processed_at else None,
|
||||
}
|
||||
@@ -38,7 +37,6 @@ async def get_featured(
|
||||
target = date.fromisoformat(news_date) if news_date else date.today()
|
||||
stmt = (
|
||||
select(ProcessedNews)
|
||||
.join(ProcessedNews.raw_news)
|
||||
.where(ProcessedNews.is_featured == True)
|
||||
.where(func.date(ProcessedNews.processed_at) == target)
|
||||
.order_by(ProcessedNews.featured_rank)
|
||||
@@ -57,11 +55,7 @@ async def get_news(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target = date.fromisoformat(news_date) if news_date else date.today()
|
||||
stmt = (
|
||||
select(ProcessedNews)
|
||||
.join(ProcessedNews.raw_news)
|
||||
.where(func.date(ProcessedNews.processed_at) == target)
|
||||
)
|
||||
stmt = select(ProcessedNews).where(func.date(ProcessedNews.processed_at) == target)
|
||||
if category:
|
||||
stmt = stmt.where(ProcessedNews.category == category)
|
||||
|
||||
@@ -87,10 +81,9 @@ async def get_dates(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.get("/{news_id}")
|
||||
async def get_news_detail(news_id: int, db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(ProcessedNews).join(ProcessedNews.raw_news).where(ProcessedNews.id == news_id)
|
||||
stmt = select(ProcessedNews).where(ProcessedNews.id == news_id)
|
||||
result = await db.execute(stmt)
|
||||
news = result.scalar_one_or_none()
|
||||
if not news:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return _serialize(news)
|
||||
|
||||
@@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+asyncpg://pharma:pharma123@localhost/pharma_news"
|
||||
database_url: str = "postgresql+asyncpg://ai_news:PrDTEr6tGcyWX6G2@chenwuzhu.cn:5432/ai_news"
|
||||
admin_token: str = "change-me-admin-token"
|
||||
|
||||
initial_llm_provider: str = "deepseek"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -60,14 +60,15 @@ async def fetch_rss(url: str, max_items: int = 30) -> list[dict]:
|
||||
return items
|
||||
|
||||
|
||||
# 默认新闻源(管理页可增删)
|
||||
# 默认新闻源(管理页可增删)— URLs 经过验证可用
|
||||
DEFAULT_SOURCES = [
|
||||
# 中文
|
||||
{"name": "国家药监局", "url": "https://www.nmpa.gov.cn/rss/yaopinxinxi.xml", "language": "zh", "category": "药品监管"},
|
||||
{"name": "丁香园", "url": "https://www.dxy.cn/bbs/feed.xml", "language": "zh", "category": "临床研究"},
|
||||
{"name": "医学界", "url": "https://www.yxj.org.cn/rss.xml", "language": "zh", "category": "行业动态"},
|
||||
{"name": "中国新闻网·健康", "url": "https://www.chinanews.com.cn/rss/health.xml", "language": "zh", "category": "行业动态"},
|
||||
# 英文
|
||||
{"name": "STAT News", "url": "https://www.statnews.com/feed/", "language": "en", "category": "临床研究"},
|
||||
{"name": "FiercePharma", "url": "https://www.fiercepharma.com/rss/xml", "language": "en", "category": "行业动态"},
|
||||
{"name": "FDA News", "url": "https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/fda-news-feed/rss.xml", "language": "en", "category": "药品监管"},
|
||||
{"name": "FierceBiotech", "url": "https://www.fiercebiotech.com/rss/xml", "language": "en", "category": "临床研究"},
|
||||
{"name": "FDA MedWatch", "url": "https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/medwatch/rss.xml", "language": "en", "category": "药品监管"},
|
||||
{"name": "FDA Press Releases", "url": "https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/press-releases/rss.xml", "language": "en", "category": "药品监管"},
|
||||
{"name": "Nature Medicine", "url": "https://www.nature.com/nm.rss", "language": "en", "category": "临床研究"},
|
||||
]
|
||||
|
||||
+21
-1
@@ -6,18 +6,38 @@ from .database import create_tables, AsyncSessionLocal
|
||||
from .scheduler import start_scheduler, shutdown_scheduler
|
||||
from .api import news, admin
|
||||
from .config import settings
|
||||
from .models.news import LLMConfig
|
||||
from .models.news import LLMConfig, NewsSource
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await create_tables()
|
||||
await seed_initial_llm_config()
|
||||
await seed_default_sources()
|
||||
start_scheduler()
|
||||
yield
|
||||
shutdown_scheduler()
|
||||
|
||||
|
||||
async def seed_default_sources():
|
||||
"""Insert default news sources on first run if the table is empty."""
|
||||
from .crawler.rss_fetcher import DEFAULT_SOURCES
|
||||
from sqlalchemy import select
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(NewsSource).limit(1))
|
||||
if result.scalar_one_or_none():
|
||||
return
|
||||
for src in DEFAULT_SOURCES:
|
||||
db.add(NewsSource(
|
||||
name=src["name"],
|
||||
url=src["url"],
|
||||
source_type="rss",
|
||||
language=src["language"],
|
||||
category=src["category"],
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def seed_initial_llm_config():
|
||||
"""Insert default LLM config on first run if none exists."""
|
||||
from sqlalchemy import select
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user