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.
This commit is contained in:
eezd committed 2026-10-31 22:08:59 +08:00
1 parent ae35d3d608
commit 80d266dba7
44 files changed
+2682 -574

No files matched your search

+5 -3
View File
@@ -65,7 +65,7 @@ async def add_version(session: AsyncSession, skill: SkillNode, content: str, sou
return version
async def create_node(session: AsyncSession, parent_id: int | None, kind: str, name: str, priority: int = 0) -> SkillNode:
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("名称不能为空,也不能包含 /")
@@ -73,11 +73,13 @@ async def create_node(session: AsyncSession, parent_id: int | None, kind: str, n
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)
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
@@ -96,7 +98,7 @@ async def published_skills(session: AsyncSession) -> list[tuple[NodeInfo, str]]:
return True
node = by_id[node.parent_id]
chosen = [i for i in tree if i.node.kind == "skill" and i.node.published_version_id is not None and active(i.node)]
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())