Files
AlphaPulse/backend/src/alphapulse/ai/skills.py
T
eezd 80d266dba7 Add three-stage cross-domain news research and reading briefings
Group original news into tagged fact packages, review each package with evidence and read-only research, and build complete 72-hour reading lists with independent model selections.

Add paused RSS/Atom subscriptions, news-scoped Skills, versioned analysis records, and locked deployment support for SearXNG. Preserve original news and keep live migration and legacy AI cleanup separately authorized.

Validate with 604 backend tests, 49 frontend tests, 19 CI control tests, lint, type checks, production build, and disposable PostgreSQL migration and three-stage smoke checks.
2026-10-31 22:08:59 +08:00

107 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""skills 树与版本的共用逻辑,供 skills 接口和 Chat 工具使用"""
from dataclasses import dataclass
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.models import SkillNode, SkillVersion
class SkillError(Exception):
"""可直接展示给用户(或模型)的业务错误"""
@dataclass
class NodeInfo:
node: SkillNode
path: str
published_version_no: int | None
latest_version_no: int | None
@property
def has_draft(self) -> bool:
# 存在比已发布版本更新的版本
return self.latest_version_no is not None and self.latest_version_no != self.published_version_no
async def load_tree(session: AsyncSession) -> list[NodeInfo]:
"""所有节点及其路径和版本状态,按父节点、sort_order 排序"""
nodes = (await session.scalars(select(SkillNode).order_by(SkillNode.parent_id.nulls_first(), SkillNode.sort_order, SkillNode.id))).all()
latest = dict((await session.execute(select(SkillVersion.skill_id, func.max(SkillVersion.version_no)).group_by(SkillVersion.skill_id))).all())
published = dict((await session.execute(
select(SkillNode.id, SkillVersion.version_no).join(SkillVersion, SkillVersion.id == SkillNode.published_version_id)
)).all())
by_id = {n.id: n for n in nodes}
def path(node: SkillNode) -> str:
parts = [node.name]
while node.parent_id is not None:
node = by_id[node.parent_id]
parts.append(node.name)
return "/".join(reversed(parts))
return [NodeInfo(n, path(n), published.get(n.id), latest.get(n.id)) for n in nodes]
async def find_by_path(session: AsyncSession, path: str, kind: str | None = None) -> SkillNode:
path = path.strip("/")
for info in await load_tree(session):
if info.path == path and (kind is None or info.node.kind == kind):
return info.node
raise SkillError(f"找不到{'文件夹' if kind == 'folder' else 'skill'}:{path}")
async def add_version(session: AsyncSession, skill: SkillNode, content: str, source: str, note: str) -> SkillVersion:
"""追加一个新版本(不发布);调用方负责 commit"""
if skill.kind != "skill":
raise SkillError("文件夹没有内容版本")
# 锁住 skill 行,避免并发保存得到相同的版本号
await session.execute(select(SkillNode.id).where(SkillNode.id == skill.id).with_for_update())
current = await session.scalar(select(func.max(SkillVersion.version_no)).where(SkillVersion.skill_id == skill.id))
version = SkillVersion(skill_id=skill.id, version_no=(current or 0) + 1, content=content, source=source, note=note)
session.add(version)
await session.flush()
return version
async def create_node(session: AsyncSession, parent_id: int | None, kind: str, name: str, priority: int = 0, scope: str = "shared") -> SkillNode:
name = name.strip()
if not name or "/" in name:
raise SkillError("名称不能为空,也不能包含 /")
if parent_id is not None:
parent = await session.get(SkillNode, parent_id)
if parent is None or parent.kind != "folder":
raise SkillError("只能在文件夹下新建")
if parent_id is not None:
scope = parent.scope
same_parent = SkillNode.parent_id.is_not_distinct_from(parent_id)
if await session.scalar(select(SkillNode.id).where(same_parent, SkillNode.name == name)):
raise SkillError(f"同一文件夹下已存在:{name}")
last = await session.scalar(select(func.max(SkillNode.sort_order)).where(same_parent))
node = SkillNode(parent_id=parent_id, kind=kind, name=name, priority=priority, sort_order=(last or 0) + 1, enabled=True, scope=scope)
session.add(node)
await session.flush()
return node
async def published_skills(session: AsyncSession) -> list[tuple[NodeInfo, str]]:
"""进入 prompt 的 skills:已发布、已启用、所有上级文件夹也未停用;按优先级从高到低"""
tree = await load_tree(session)
by_id = {i.node.id: i.node for i in tree}
def active(node: SkillNode) -> bool:
while True:
if not node.enabled:
return False
if node.parent_id is None:
return True
node = by_id[node.parent_id]
chosen = [i for i in tree if i.node.kind == "skill" and i.node.scope == "shared" and i.node.published_version_id is not None and active(i.node)]
contents = dict((await session.execute(
select(SkillVersion.id, SkillVersion.content).where(SkillVersion.id.in_([i.node.published_version_id for i in chosen]))
)).all())
chosen.sort(key=lambda i: (-i.node.priority, i.path))
return [(i, contents[i.node.published_version_id]) for i in chosen]