"""Authenticated API for the two-stage research workspace.""" from datetime import UTC, datetime from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import AwareDatetime, Field from sqlalchemy import select, update, func, or_, text from sqlalchemy.dialects.postgresql import insert from alphapulse.core.db import get_session from sqlalchemy.ext.asyncio import AsyncSession from alphapulse.core.auth import require_admin from alphapulse.api.response import success from alphapulse.models import NewsItem from alphapulse.news_research.models import ( ResearchSettings, Package, Attempt, Event, Membership, Version, Reference, BatchEvent, ) from alphapulse.news_research import service, batches, vocabulary as vocab from alphapulse.news_research.models import VocabularyVersion from alphapulse.news_research.contracts import ( INDEX_ASSIGN_SKILL, SkillFormatError, RESEARCH_SKILL, Strict, IndexResult, Assignment, ResearchResult, ) router = APIRouter( prefix="/api/risk", tags=["news-research"], dependencies=[Depends(require_admin)] ) DB = Annotated[AsyncSession, Depends(get_session)] @router.get("/tag-vocabulary") async def vocabulary(session: DB): return success(await vocab.snapshot(session)) @router.put("/tag-vocabulary") async def save_vocabulary(body: vocab.VocabularyInput, session: DB): await session.execute(text("SELECT pg_advisory_xact_lock(20261103, 3)")) current = await vocab.snapshot(session) if current["revision"] != body.base_revision: raise HTTPException(409, "词库已更新,请重新读取后保存") row = VocabularyVersion(revision=current["revision"] + 1, entries=[e.model_dump() for e in body.entries]) session.add(row) await session.commit() return success({"revision": row.revision, "entries": row.entries}) async def version_references(session, version_id): result = [] for ref in await session.scalars(select(Reference).where(Reference.version_id == version_id)): target = await session.get(Version, ref.target_version_id) current = await session.get(Event, target.event_id) result.append({"event_id": target.event_id, "title": target.output["title"], "version_id": target.id, "role": ref.role, "reason": ref.reason, "changed": target.revision != current.revision or current.state != "done"}) return result @router.get("/events/{event_id}/versions/{version_id}") async def event_version(event_id: int, version_id: int, session: DB): row = await session.get(Version, version_id) if row is None or row.event_id != event_id or row.state != "done": raise HTTPException(404, "事件成功版本不存在") return success({"id": row.id, "event_id": row.event_id, "revision": row.revision, "state": row.state, "created_at": row.created_at, "result": row.output, "packages": row.input["packages"], "articles": row.input["articles"], "evidence": row.evidence, "usage": row.usage, "references": await version_references(session, row.id)}) class ModelConfig(Strict): provider_id: int = Field(gt=0) model: str = Field(min_length=1, max_length=128) reasoning_effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None skill_id: int = Field(gt=0) class SettingsInput(Strict): index: ModelConfig research: ModelConfig concurrency: int = Field(default=3, ge=1, le=16) async def settings_row(session): row = await session.get(ResearchSettings, 1) if row is None: await session.execute( insert(ResearchSettings) .values( id=1, config={}, enabled=False, start_at=datetime(2026, 10, 31, 16, tzinfo=UTC), ) .on_conflict_do_nothing(index_elements=["id"]) ) await session.commit() row = await session.get(ResearchSettings, 1) return row @router.get("/settings") async def settings(session: DB): row = await settings_row(session) return success( {"config": row.config, "enabled": row.enabled, "start_at": row.start_at, "batch": await batches.progress(session)} ) @router.get("/coverage") async def coverage(session: DB, start_at: AwareDatetime | None = None): row = await settings_row(session) start = (start_at if start_at is not None else row.start_at).astimezone(UTC) if start > datetime.now(UTC): raise HTTPException(422, "回补起始时间不能晚于当前时间") return success(await batches.coverage(session, start)) @router.put("/settings") async def save(body: SettingsInput, session: DB): await settings_row(session) await session.execute( update(ResearchSettings) .where(ResearchSettings.id == 1) .values( config=body.model_dump(), enabled=False, control_revision=ResearchSettings.control_revision + 1, ) ) await session.commit() return success(None) @router.post("/pause") async def pause(session: DB): await settings_row(session) await session.execute( update(ResearchSettings) .where(ResearchSettings.id == 1) .values(enabled=False, control_revision=ResearchSettings.control_revision + 1) ) await session.commit() return success(None) class EnableInput(Strict): start_at: AwareDatetime @router.post("/enable") async def enable(session: DB, body: EnableInput | None = None): row = await settings_row(session) start_at = (body.start_at if body is not None else row.start_at).astimezone(UTC) if start_at > datetime.now(UTC): raise HTTPException(422, "回补起始时间不能晚于当前时间") if not await session.scalar(text("SELECT pg_try_advisory_xact_lock(20261103, 2)")): raise HTTPException(409, "模型验证已在进行中") if row.enabled: raise HTTPException(409, "请先暂停两阶段处理") running = await session.scalar( select(func.count()) .select_from(Package) .where(Package.state.in_(["index_processing", "assign_processing"])) ) running += await session.scalar( select(func.count()).select_from(Event).where(Event.state == "processing") ) if running: raise HTTPException(409, "请等待已接收任务完成后再验证") from alphapulse.news_research.model import complete from alphapulse.news_research.pipeline import validate_event fixture = { "id": 1, "title": "虚构示例:公司维护通知", "summary": "", "content": "本条为虚构测试。示例公司宣布计划维护,具体时间未知。", } original_config = row.config control_revision = row.control_revision try: configs = {task: await service.model_config(session, original_config, task) for task in ("index", "assign", "research")} provider, skill, _ = configs["index"] indexed, _ = await complete( provider, skill, {"task": "index", "article": fixture}, IndexResult, ) service.validate_keywords(indexed, fixture) provider, skill, _ = configs["assign"] assigned, _ = await complete( provider, skill, { "task": "assign", "article": fixture, "keywords": indexed.model_dump()["keywords"], "events": [], }, Assignment, ) service.validate_assignment(assigned, fixture, set(), indexed.model_dump()["keywords"]) provider, skill, _ = configs["research"] researched, _ = await complete( provider, skill, { "id": 1, "event": {"id": 1, "title": fixture["title"]}, "articles": [fixture], "packages": [{ "id": 1, "news_id": 1, "scope_quote": assigned.targets[0].quote, "reason": assigned.targets[0].reason, "keywords": [k for k in indexed.model_dump()["keywords"] if k["canonical"] in assigned.targets[0].keyword_canonicals], "keyword_canonicals": assigned.targets[0].keyword_canonicals, "scope_selection": "assignment", "other_scopes": [ {"title": t.title, "scope_quote": t.quote, "reason": t.reason} for t in assigned.targets[1:] ], }], "package_ids": [1], "previous": None, }, ResearchResult, ) validate_event(researched, {1: fixture}) service.validate_timeline(researched, {1: 1}) if ( researched.id != 1 or researched.references or any(set(p.package_ids) != {1} for p in researched.timeline_points) ): raise ValueError("测试返回未知对象") except SkillFormatError as error: raise HTTPException(422, str(error)) from error except Exception as error: raise HTTPException(502, f"两阶段验证失败:{error}") from error row = await session.get( ResearchSettings, 1, populate_existing=True, with_for_update=True ) if row.control_revision != control_revision or row.config != original_config: raise HTTPException(409, "验证期间已暂停或设置变化,请重新验证") report = await batches.coverage(session, start_at) row.start_at = start_at row.enabled = True row.control_revision += 1 await session.commit() return success(report) @router.post("/skill-templates") async def templates(session: DB): from alphapulse.ai.skills import create_node, add_version from alphapulse.models import SkillNode, SkillVersion ids = {} for role, name, content in [ ("index", "新闻关键词索引与事件归属", INDEX_ASSIGN_SKILL), ("research", "新闻事件整体研究", RESEARCH_SKILL), ]: node = await session.scalar( select(SkillNode) .where(SkillNode.parent_id.is_(None), SkillNode.name == name) .with_for_update() ) if node is None: node = await create_node(session, None, "skill", name, scope="news") if node.scope != "news" or node.kind != "skill": raise HTTPException(409, "默认Skill名称已被其他用途使用") latest = await session.scalar( select(SkillVersion) .where(SkillVersion.skill_id == node.id) .order_by(SkillVersion.version_no.desc()) .limit(1) ) if latest is None or latest.content != content: await add_version(session, node, content, "user", "两阶段新闻研究草稿") ids[role] = node.id await session.commit() return success(ids) class EnrollInput(Strict): news_ids: list[int] = Field(min_length=1, max_length=500) @router.post("/packages") async def enroll(body: EnrollInput, session: DB): ids = set(body.news_ids) if await session.scalar( select(func.count()).select_from(NewsItem).where(NewsItem.id.in_(ids)) ) != len(ids): raise HTTPException(404, "新闻不存在") await service.enroll(session, ids) return success(None) async def package_out(session, package, news): current = package.content_hash == news.content_hash memberships = list( await session.execute( select(Membership, Event) .join(Event, Event.id == Membership.event_id) .where(Membership.package_id == package.id) ) ) return { "id": package.id, "news_id": news.id, "title": news.title, "url": news.url, "published_at": news.published_at, "state": package.state if current else "index_pending", "error": package.error if current else None, "keywords": package.keywords if current else [], "content_hash": package.content_hash, "events": [ {"id": e.id, "title": e.title, "reason": m.reason, "quote": m.evidence} for m, e in memberships if current ], } @router.get("/packages") async def packages(session: DB, q: str = "", page: int = Query(1, ge=1)): query = select(Package, NewsItem).join(NewsItem, NewsItem.id == Package.news_id) if q: condition, _ = service.package_matching([q], await vocab.snapshot(session)) query = query.where(condition) rows = list( await session.execute( query.order_by(Package.id.desc()).offset((page - 1) * 20).limit(20) ) ) return success( { "items": [await package_out(session, p, n) for p, n in rows], "total": await session.scalar( select(func.count()).select_from(query.subquery()) ), } ) @router.get("/news/{news_id}/packages") async def news_package( news_id: int, session: DB, revision_no: int | None = Query(None, ge=1) ): news = await session.get(NewsItem, news_id) if news is None: raise HTTPException(404, "新闻不存在") if revision_no is not None and revision_no != news.revision_no: return success([]) package = await session.scalar(select(Package).where(Package.news_id == news_id)) return success( [] if package is None or package.content_hash != news.content_hash else [await package_out(session, package, news)] ) @router.get("/packages/{identifier}") async def package(identifier: int, session: DB): row = await session.get(Package, identifier) if row is None: raise HTTPException(404, "新闻包不存在") news = await session.get(NewsItem, row.news_id) attempts = list( await session.scalars( select(Attempt) .where(Attempt.package_id == identifier) .order_by(Attempt.id.desc()) ) ) return success( { **await package_out(session, row, news), "article": service.article(news), "attempts": [ { "id": a.id, "stage": a.stage, "state": a.state, "error": a.error, "output": a.output, "usage": a.usage, } for a in attempts ], } ) @router.post("/packages/{identifier}/retry") async def retry_package(identifier: int, session: DB): row = await session.get(Package, identifier, with_for_update=True) if row is None: raise HTTPException(404, "新闻包不存在") if row.state not in ("index_failed", "assign_failed", "done"): raise HTTPException(409, "新闻包正在处理") row.state = "index_pending" if not row.keywords else "assign_pending" row.error = None await session.commit() return success(None) @router.get("/events") async def events(session: DB, q: str = "", page: int = Query(1, ge=1)): query = select(Event).where(Event.state != "retired") if q: query = query.where( or_(*(Event.title.icontains(t, autoescape=True) | Event.tags.contains([t]) for t in vocab.expand([q], await vocab.snapshot(session)))) ) rows = list( await session.scalars( query.order_by(Event.updated_at.desc(), Event.id) .offset((page - 1) * 20) .limit(20) ) ) return success( { "items": [ { "id": e.id, "title": e.title, "state": e.state, "revision": e.revision, "error": e.error, "tags": e.tags, "result": e.result, "updated_at": e.updated_at, } for e in rows ], "total": await session.scalar( select(func.count()).select_from(query.subquery()) ), } ) @router.get("/events/{identifier}") async def event(identifier: int, session: DB): row = await session.get(Event, identifier) if row is None: raise HTTPException(404, "事件不存在") members = list( await session.execute( select(Package, NewsItem) .join(NewsItem, NewsItem.id == Package.news_id) .join(Membership, Membership.package_id == Package.id) .where(Membership.event_id == identifier) ) ) versions = list( await session.scalars( select(Version) .where(Version.event_id == identifier) .order_by(Version.id.desc()) ) ) latest = next((v for v in versions if v.state == "done"), None) references = await version_references(session, latest.id) if latest else [] return success( { "id": row.id, "title": row.title, "state": row.state, "result": row.result, "error": row.error, "revision": row.revision, "packages": [await package_out(session, p, n) for p, n in members], "references": references, "versions": [ { "id": v.id, "revision": v.revision, "state": v.state, "result": v.output, "error": v.error, "usage": v.usage, "evidence": v.evidence, "created_at": v.created_at, } for v in versions ], } ) @router.post("/events/{identifier}/retry") async def retry_event(identifier: int, session: DB): row = await session.get(Event, identifier, with_for_update=True) if row is None: raise HTTPException(404, "事件不存在") if row.state == "processing": raise HTTPException(409, "事件正在处理") deferred = None if row.state == "pending": deferred = await session.scalar(select(BatchEvent.batch_id).where( BatchEvent.event_id == row.id, BatchEvent.revision == row.revision, BatchEvent.state == "deferred").limit(1)) if row.state == "done" or deferred is not None: # Escape a deferred revision only on explicit retry; ordinary retries reuse evidence. service.dirty(row, True) else: row.state = "pending" row.due_at = datetime.now(UTC) row.updated_at = row.due_at row.error = None await session.commit() return success(None)