Files
AlphaPulse/backend/src/alphapulse/api/skills.py
T
eezd 5db3ead883 Add AI chat with skills management and model provider settings
- Model providers for OpenAI and Anthropic message formats (e.g. via
  Sub2API), with API keys encrypted at rest and never returned
- Skills tree with markdown editing, priorities, drafts, publishing and
  rollback; only published skills enter the chat prompt
- Chat page with tool calls over collected news and Binance USD-M
  klines, a persisted CONTEXT.md, reset, and stop-generation support
- Chat may only create skill drafts and update CONTEXT.md; publishing,
  deleting, moving and priority changes stay with the user
2026-10-14 13:11:45 +08:00

203 lines
8.0 KiB
Python

"""Skills 管理:树、节点、版本、发布与回退"""
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai.skills import SkillError, add_version, create_node, load_tree
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.models import SkillNode, SkillVersion
router = APIRouter(prefix="/api/skills", tags=["skills"], dependencies=[Depends(require_admin)])
class NodeOut(BaseModel):
id: int
parent_id: int | None
kind: Literal["folder", "skill"]
name: str
path: str
sort_order: int
priority: int
enabled: bool
published_version_no: int | None
latest_version_no: int | None
has_draft: bool
updated_at: datetime
class NodeIn(BaseModel):
parent_id: int | None = None
kind: Literal["folder", "skill"]
name: Annotated[str, Field(min_length=1, max_length=128)]
priority: int = 0
# 新建 skill 时的初始内容,保存为 v1 草稿(未发布);为空则不建版本
content: str | None = None
class NodePatch(BaseModel):
name: Annotated[str | None, Field(min_length=1, max_length=128)] = None
priority: int | None = None
enabled: bool | None = None
class MoveIn(BaseModel):
# null 表示移到根目录
parent_id: int | None
# 在新父节点下的位置(从 0 开始)
index: Annotated[int, Field(ge=0)]
class VersionOut(BaseModel):
id: int
version_no: int
content: str
source: Literal["user", "chat"]
note: str
created_at: datetime
class VersionIn(BaseModel):
content: str
note: str = ""
class PublishIn(BaseModel):
version_id: int
async def get_node(session: AsyncSession, node_id: int, kind: str | None = None) -> SkillNode:
node = await session.get(SkillNode, node_id)
if node is None or (kind and node.kind != kind):
raise HTTPException(status.HTTP_404_NOT_FOUND, "节点不存在")
return node
async def node_out(session: AsyncSession, node_id: int) -> NodeOut:
info = next(i for i in await load_tree(session) if i.node.id == node_id)
return NodeOut(**{**info.node.__dict__, "path": info.path, "published_version_no": info.published_version_no,
"latest_version_no": info.latest_version_no, "has_draft": info.has_draft})
def bad_request(e: SkillError) -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
@router.get("/tree")
async def tree(session: Annotated[AsyncSession, Depends(get_session)]) -> list[NodeOut]:
"""扁平列表,前端按 parent_id 组装成树"""
return [
NodeOut(**{**i.node.__dict__, "path": i.path, "published_version_no": i.published_version_no,
"latest_version_no": i.latest_version_no, "has_draft": i.has_draft})
for i in await load_tree(session)
]
@router.post("/nodes", status_code=status.HTTP_201_CREATED)
async def create(body: NodeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
try:
node = await create_node(session, body.parent_id, body.kind, body.name, body.priority)
# 没有初始内容时不建空版本,第一次保存即 v1
if body.kind == "skill" and body.content:
await add_version(session, node, body.content, "user", "新建")
except SkillError as e:
raise bad_request(e) from None
await session.commit()
return await node_out(session, node.id)
@router.patch("/nodes/{node_id}")
async def patch(node_id: int, body: NodePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
node = await get_node(session, node_id)
if body.name is not None:
name = body.name.strip()
if "/" in name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "名称不能包含 /")
duplicate = await session.scalar(select(SkillNode.id).where(
SkillNode.parent_id.is_not_distinct_from(node.parent_id), SkillNode.name == name, SkillNode.id != node_id
))
if duplicate:
raise HTTPException(status.HTTP_409_CONFLICT, f"同一文件夹下已存在:{name}")
node.name = name
if body.priority is not None:
node.priority = body.priority
if body.enabled is not None:
node.enabled = body.enabled
await session.commit()
return await node_out(session, node_id)
@router.post("/nodes/{node_id}/move")
async def move(node_id: int, body: MoveIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
node = await get_node(session, node_id)
if body.parent_id is not None:
parent = await get_node(session, body.parent_id)
if parent.kind != "folder":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只能移动到文件夹中")
# 不能移到自己或自己的子孙下面
cursor: SkillNode | None = parent
while cursor is not None:
if cursor.id == node_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "不能移动到自身或其子文件夹中")
cursor = await session.get(SkillNode, cursor.parent_id) if cursor.parent_id else None
duplicate = await session.scalar(select(SkillNode.id).where(
SkillNode.parent_id.is_not_distinct_from(body.parent_id), SkillNode.name == node.name, SkillNode.id != node_id
))
if duplicate:
raise HTTPException(status.HTTP_409_CONFLICT, f"目标文件夹下已存在同名节点:{node.name}")
siblings = (await session.scalars(
select(SkillNode).where(SkillNode.parent_id.is_not_distinct_from(body.parent_id), SkillNode.id != node_id)
.order_by(SkillNode.sort_order, SkillNode.id)
)).all()
ordered = list(siblings)
ordered.insert(min(body.index, len(ordered)), node)
node.parent_id = body.parent_id
for i, sibling in enumerate(ordered):
sibling.sort_order = i
await session.commit()
@router.delete("/nodes/{node_id}")
async def delete(node_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
node = await get_node(session, node_id)
if node.kind == "folder" and await session.scalar(select(func.count()).where(SkillNode.parent_id == node_id)):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "文件夹不为空,请先移走或删除其中的节点")
# 先解除对版本的引用,版本随节点级联删除
node.published_version_id = None
await session.flush()
await session.delete(node)
await session.commit()
@router.get("/{skill_id}/versions")
async def versions(skill_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> list[VersionOut]:
await get_node(session, skill_id, "skill")
rows = await session.scalars(select(SkillVersion).where(SkillVersion.skill_id == skill_id).order_by(SkillVersion.version_no.desc()))
return [VersionOut.model_validate(v, from_attributes=True) for v in rows]
@router.post("/{skill_id}/versions", status_code=status.HTTP_201_CREATED)
async def save_draft(skill_id: int, body: VersionIn, session: Annotated[AsyncSession, Depends(get_session)]) -> VersionOut:
skill = await get_node(session, skill_id, "skill")
version = await add_version(session, skill, body.content, "user", body.note)
await session.commit()
await session.refresh(version)
return VersionOut.model_validate(version, from_attributes=True)
@router.post("/{skill_id}/publish")
async def publish(skill_id: int, body: PublishIn, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
"""发布指定版本;发布旧版本即回退"""
skill = await get_node(session, skill_id, "skill")
version = await session.get(SkillVersion, body.version_id)
if version is None or version.skill_id != skill_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
skill.published_version_id = version.id
await session.commit()
return await node_out(session, skill_id)