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.
107 lines
4.7 KiB
Python
107 lines
4.7 KiB
Python
"""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]
|