Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [main]
branches: [main, develop]
pull_request:
branches: [main]
branches: [main, develop]
workflow_dispatch:

env:
Expand Down
24 changes: 14 additions & 10 deletions backend/app/api/v1/entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from sqlalchemy import select

from app.api.deps import DbSession, CurrentUser
from app.models.entry import EntryStatus
from app.models.entry import EntryStatus, TaskStatus
from app.models.user_config import UserConfig
from app.schemas.entry import (
EntryResponse,
Expand Down Expand Up @@ -47,15 +47,15 @@ def entry_to_response(entry) -> EntryResponse:
is_read=entry.is_read,
marked_at=entry.marked_at,
ai_summary=entry.ai_summary,
ai_content_type=entry.ai_content_type,
ai_processed_at=entry.ai_processed_at,
task_interpret_status=getattr(entry, 'task_interpret_status', None),
task_translation_status=getattr(entry, 'task_translation_status', None),
user_notes=entry.user_notes,
exported_to_zotero=entry.exported_to_zotero,
fetched_at=entry.fetched_at,
created_at=entry.created_at,
translated_abstract=getattr(entry, 'translated_abstract', None),
brief_summary=getattr(entry, 'brief_summary', None),
translation_status=getattr(entry, 'translation_status', None),
rss_source_name=source_name,
)

Expand All @@ -69,6 +69,8 @@ async def list_entries(
category: Optional[str] = None,
period: Optional[Literal["today", "past"]] = None,
is_read: Optional[bool] = None,
task_translation_status: Optional[str] = None,
task_interpret_status: Optional[str] = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
):
Expand All @@ -81,6 +83,8 @@ async def list_entries(
category=category,
period=period,
is_read=is_read,
task_translation_status=task_translation_status,
task_interpret_status=task_interpret_status,
skip=skip,
limit=page_size,
)
Expand Down Expand Up @@ -220,11 +224,11 @@ async def update_entry_status(
# 检查是否需要解读:未解读或解读失败都需要重新解读
needs_interpretation = (
not entry.ai_summary or
entry.ai_content_type == "error" or
entry.ai_content_type is None
entry.task_interpret_status == TaskStatus.FAILED.value or
entry.task_interpret_status is None
)
# 排除正在解读中的
if needs_interpretation and entry.ai_content_type != "interpreting":
if needs_interpretation and entry.task_interpret_status != TaskStatus.RUNNING.value:
from app.tasks.fetch_rss import interpret_arxiv_entry
asyncio.create_task(interpret_arxiv_entry(entry.id))

Expand Down Expand Up @@ -267,10 +271,10 @@ async def batch_update_status(
if is_arxiv_entry(entry):
needs_interpretation = (
not entry.ai_summary or
entry.ai_content_type == "error" or
entry.ai_content_type is None
entry.task_interpret_status == TaskStatus.FAILED.value or
entry.task_interpret_status is None
)
if needs_interpretation and entry.ai_content_type != "interpreting":
if needs_interpretation and entry.task_interpret_status != TaskStatus.RUNNING.value:
asyncio.create_task(interpret_arxiv_entry(entry.id))

return EntryBatchResponse(updated_count=updated_count)
Expand Down Expand Up @@ -341,7 +345,7 @@ async def reinterpret_entry(

# 重置解读状态
entry.ai_summary = None
entry.ai_content_type = None
entry.task_interpret_status = None
entry.ai_processed_at = None
await db.commit()

Expand Down
5 changes: 4 additions & 1 deletion backend/app/api/v1/share.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,15 @@ async def get_share(share_code: str):
is_read=entry.is_read,
marked_at=entry.marked_at,
ai_summary=entry.ai_summary,
ai_content_type=entry.ai_content_type,
ai_processed_at=entry.ai_processed_at,
task_interpret_status=entry.task_interpret_status,
task_translation_status=entry.task_translation_status,
user_notes=entry.user_notes,
exported_to_zotero=entry.exported_to_zotero,
fetched_at=entry.fetched_at,
created_at=entry.created_at,
translated_abstract=entry.translated_abstract,
brief_summary=entry.brief_summary,
rss_source_name=entry.rss_source_name or (entry.rss_source.name if entry.rss_source else None),
)
)
Expand Down
48 changes: 48 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ async def run_migrations() -> None:
("user_configs", "ai_models_translation", "TEXT", "TEXT"),
("user_configs", "ai_models_interpret", "TEXT", "TEXT"),
("user_configs", "ai_models", "TEXT", "TEXT"),
# 统一任务状态新列
("entries", "task_translation_status", "VARCHAR(20)", "VARCHAR(20)"),
("entries", "task_interpret_status", "VARCHAR(50)", "VARCHAR(50)"),
]

async with engine.begin() as conn:
Expand Down Expand Up @@ -119,6 +122,9 @@ async def run_migrations() -> None:
# 运行任务结构迁移(flat list -> {model_ids, enabled})
await migrate_tasks_to_abstract_format()

# 迁移旧的任务状态列到新的统一列
await migrate_to_task_status_columns()


async def migrate_ai_configs() -> None:
"""
Expand Down Expand Up @@ -385,6 +391,48 @@ async def migrate_tasks_to_abstract_format() -> None:
print(f"Migration: migrated {migrated_count} user(s) to abstract task format")


async def migrate_to_task_status_columns() -> None:
"""
将旧的 translation_status 和 ai_content_type 列数据迁移到新的统一任务状态列

旧列 → 新列:
translation_status → task_translation_status (translating → running,其余不变)
ai_content_type → task_interpret_status (interpreting → running, arxiv_interpretation → completed,
error → failed, no_html → skipped)

迁移是幂等的:只更新新列为 NULL 且旧列不为 NULL 的行
"""
from sqlalchemy import text

async with async_session_maker() as db:
# 迁移 translation_status → task_translation_status
result = await db.execute(text("""
UPDATE entries SET task_translation_status = CASE
WHEN translation_status = 'translating' THEN 'running'
ELSE translation_status
END
WHERE task_translation_status IS NULL AND translation_status IS NOT NULL
"""))
if result.rowcount > 0:
print(f"Migration: migrated {result.rowcount} entries translation_status -> task_translation_status")

# 迁移 ai_content_type → task_interpret_status
result = await db.execute(text("""
UPDATE entries SET task_interpret_status = CASE
WHEN ai_content_type = 'interpreting' THEN 'running'
WHEN ai_content_type = 'arxiv_interpretation' THEN 'completed'
WHEN ai_content_type = 'error' THEN 'failed'
WHEN ai_content_type = 'no_html' THEN 'skipped'
ELSE ai_content_type
END
WHERE task_interpret_status IS NULL AND ai_content_type IS NOT NULL
"""))
if result.rowcount > 0:
print(f"Migration: migrated {result.rowcount} entries ai_content_type -> task_interpret_status")

await db.commit()


async def close_db() -> None:
"""关闭数据库连接"""
await engine.dispose()
8 changes: 4 additions & 4 deletions backend/app/integrations/zotero.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def create_item(self, entry: Entry, collection_name: Optional[str] = None) -> Op
"""
try:
# 根据 AI 内容类型和链接选择 Zotero item type
item_type = self._get_zotero_item_type(entry.ai_content_type, entry.link)
item_type = self._get_zotero_item_type(entry.task_interpret_status, entry.link)
template = self.client.item_template(item_type)

# 通用字段
Expand Down Expand Up @@ -184,7 +184,7 @@ def create_item(self, entry: Entry, collection_name: Optional[str] = None) -> Op
extra_parts = []

# ArXiv 论文:优先使用深度解读,其次是翻译摘要
if entry.ai_content_type == "arxiv_interpretation" and entry.ai_summary:
if entry.task_interpret_status == "completed" and entry.ai_summary:
extra_parts.append(f"【AI 深度解读】\n{entry.ai_summary}")
elif entry.translated_abstract:
# 有翻译摘要
Expand All @@ -200,8 +200,8 @@ def create_item(self, entry: Entry, collection_name: Optional[str] = None) -> Op

# 添加标签
tags = []
if entry.ai_content_type:
tags.append({"tag": entry.ai_content_type})
if entry.task_interpret_status:
tags.append({"tag": entry.task_interpret_status})
if entry.rss_source:
tags.append({"tag": entry.rss_source.name})
tags.append({"tag": "Focus"})
Expand Down
34 changes: 23 additions & 11 deletions backend/app/models/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,17 @@ class EntryStatus(str, Enum):
ALL = "all" # 全部状态(不过滤)


class TranslationStatus(str, Enum):
"""ArXiv 翻译状态"""
PENDING = "pending" # 待翻译
TRANSLATING = "translating" # 翻译中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 翻译失败
class TaskStatus(str, Enum):
"""统一任务处理状态"""
PENDING = "pending" # 待处理
RUNNING = "running" # 处理中(原 translating / interpreting)
COMPLETED = "completed" # 已完成(原 completed / arxiv_interpretation)
FAILED = "failed" # 处理失败(原 failed / error)
SKIPPED = "skipped" # 跳过(原 no_html,无法处理)


# 向后兼容别名
TranslationStatus = TaskStatus


class Entry(Base):
Expand Down Expand Up @@ -63,13 +68,19 @@ class Entry(Base):

# AI 生成内容
ai_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ai_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
ai_processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)

# ArXiv 翻译
# 旧列(已弃用,由迁移脚本复制到新列后不再使用)
ai_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
translation_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True)

# 统一任务状态(新列,取代 ai_content_type 和 translation_status)
task_interpret_status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # 解读任务状态
task_translation_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # 翻译任务状态

# ArXiv 翻译结果
translated_abstract: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 翻译后的摘要
brief_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 简要总结(帮助快速判断)
translation_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True) # 翻译状态

# 用户笔记
user_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
Expand Down Expand Up @@ -103,8 +114,9 @@ class Entry(Base):
Index("ix_entry_source_name", "rss_source_name"), # 源名称索引,用于筛选孤立条目
# Focus 页面优化:按状态、源、显示顺序查询
Index("ix_entry_unread_source_order", "status", "rss_source_id", "display_order"),
# AI 解读状态查询
Index("ix_entry_ai_status", "ai_content_type", "ai_processed_at"),
# AI 解读状态查询(新列)
Index("ix_entry_task_interpret_status", "task_interpret_status", "ai_processed_at"),
Index("ix_entry_task_translation_status", "task_translation_status"),
# 唯一约束:同一源下同一内容只能有一条
# 注意:当 rss_source_id 为 NULL 时(源已删除),此约束不生效
# SQLite 和 PostgreSQL 都将 NULL 视为不相等,所以孤立条目不受约束影响
Expand Down
6 changes: 3 additions & 3 deletions backend/app/schemas/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ class EntryResponse(BaseModel):
is_read: bool
marked_at: Optional[datetime]
ai_summary: Optional[str]
ai_content_type: Optional[str]
ai_processed_at: Optional[datetime]
task_interpret_status: Optional[str] = None # 统一任务状态:pending, running, completed, failed, skipped
task_translation_status: Optional[str] = None # 统一任务状态:pending, running, completed, failed
user_notes: Optional[str]
exported_to_zotero: bool
fetched_at: datetime
created_at: datetime

# ArXiv 翻译
# ArXiv 翻译结果
translated_abstract: Optional[str] = None
brief_summary: Optional[str] = None # 简要总结(帮助快速判断)
translation_status: Optional[str] = None # pending, translating, completed, failed

# 关联信息(即使源被删除,仍保留源名称)
rss_source_name: Optional[str] = None
Expand Down
6 changes: 3 additions & 3 deletions backend/app/services/ai_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def __init__(self, primary: AIModelConfig, fallbacks: Optional[List[AIModelConfi
"""
self.models = [primary] + (fallbacks or [])
self.current_index = 0
model_names = [f"{m.name}({m.model})" for m in self.models]
model_names = [f"{m.name}({m.model}) via {m.provider}" for m in self.models]
logger.info(f"AIModelExecutor initialized with models: {' → '.join(model_names)}")

def _create_client(self, model_config: AIModelConfig) -> AsyncOpenAI:
Expand Down Expand Up @@ -241,7 +241,7 @@ async def execute(
for i, model_config in enumerate(self.models):
try:
client = self._create_client(model_config)
logger.info(f"[{task_name}] Using model: {model_config.name} ({model_config.model})")
logger.info(f"[{task_name}] Using model: {model_config.name} ({model_config.model}) via {model_config.provider}")
result = await task_func(client, model_config.model)
return result
except Exception as e:
Expand All @@ -252,7 +252,7 @@ async def execute(
# 检查是否应该切换模型
if self._should_switch(e) and i < len(self.models) - 1:
next_model = self.models[i + 1]
logger.info(f"[{task_name}] Switching from '{model_config.name}' to '{next_model.name}'")
logger.info(f"[{task_name}] Switching from '{model_config.name}' ({model_config.provider}) to '{next_model.name}' ({next_model.provider})")
continue

# 不应切换或已是最后一个模型,直接抛出
Expand Down
21 changes: 18 additions & 3 deletions backend/app/services/entry_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.models.entry import Entry, EntryStatus
from app.models.entry import Entry, EntryStatus, TaskStatus
from app.models.rss import RssSource
from app.utils.logger import logger
from app.utils.datetime_utils import get_today_start_utc
Expand Down Expand Up @@ -40,6 +40,8 @@ async def get_entries(
exclude_untranslated_arxiv: bool = False,
user_id: Optional[int] = None,
only_subscribed: bool = False,
task_translation_status: Optional[str] = None,
task_interpret_status: Optional[str] = None,
) -> Tuple[List[Entry], int]:
"""Get entry list.

Expand All @@ -48,7 +50,6 @@ async def get_entries(
user_id: User ID for filtering subscribed sources.
only_subscribed: If True, only return entries from user's subscribed sources.
"""
from app.models.entry import TranslationStatus
from app.models.subscription import UserRssSubscription

query = select(Entry).options(selectinload(Entry.rss_source))
Expand Down Expand Up @@ -86,10 +87,24 @@ async def get_entries(
query = query.where(
or_(
~Entry.link.contains('arxiv.org'),
Entry.translation_status == TranslationStatus.COMPLETED.value,
Entry.task_translation_status == TaskStatus.COMPLETED.value,
)
)

# Filter by task translation status
if task_translation_status:
if task_translation_status == "none":
query = query.where(Entry.task_translation_status.is_(None))
else:
query = query.where(Entry.task_translation_status == task_translation_status)

# Filter by task interpretation status
if task_interpret_status:
if task_interpret_status == "none":
query = query.where(Entry.task_interpret_status.is_(None))
else:
query = query.where(Entry.task_interpret_status == task_interpret_status)

count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar() or 0

Expand Down
Loading