feat: 会商链条启停与深度研究报告结构,并完善问答展示与 8BF 返回。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
be4851555d
commit
0ce060693b
42 files changed
+5320
-3247
No files matched your search
+19
-16
@@ -7,7 +7,7 @@ import threading
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
@@ -82,7 +82,7 @@ class CommonQuestion(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
class AgentConfig(BaseModel):
|
||||
"""Configuration for a custom agent."""
|
||||
|
||||
id: str | None = None
|
||||
@@ -108,20 +108,23 @@ class AgentConfig(BaseModel):
|
||||
# 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
|
||||
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
|
||||
# ``app/gateway/roundtable_seat_skills.py``。
|
||||
seat_skill_directive: bool | None = None
|
||||
# DeerFlow mapping ids this agent may offer in its new-conversation
|
||||
# knowledge selector. WeKnora ids and credentials never enter agent files.
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None
|
||||
|
||||
@field_validator("llmwiki_knowledge_base_ids")
|
||||
@classmethod
|
||||
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
|
||||
if len(normalized) > 100:
|
||||
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
|
||||
return normalized
|
||||
seat_skill_directive: bool | None = None
|
||||
# 岗位职责:会商智能体配置面板展示用描述。Display/UX metadata only —
|
||||
# the runtime never reads this. None/empty = not set.
|
||||
job_responsibilities: str | None = None
|
||||
# DeerFlow mapping ids this agent may offer in its new-conversation
|
||||
# knowledge selector. WeKnora ids and credentials never enter agent files.
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None
|
||||
|
||||
@field_validator("llmwiki_knowledge_base_ids")
|
||||
@classmethod
|
||||
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
|
||||
if len(normalized) > 100:
|
||||
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
|
||||
return normalized
|
||||
|
||||
|
||||
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""Add enabled column to roundtable_chains.
|
||||
|
||||
Per-chain 启停 switch: True = 可在选链弹窗 / 公共入口里选用;False = 停用(管理列表
|
||||
仍可见,但不可选入研讨)。Default True → existing chains stay enabled. BOOLEAN NOT
|
||||
NULL DEFAULT 1, matching ``RoundtableChainRow.enabled``.
|
||||
|
||||
``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
|
||||
sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
|
||||
alembic graph consistent with the rest of the chain columns and documents the change.
|
||||
|
||||
Revision ID: 20260819_02
|
||||
Revises: 20260819_01
|
||||
Create Date: 2026-08-19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260819_02"
|
||||
down_revision = "20260819_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "enabled"):
|
||||
op.add_column(
|
||||
"roundtable_chains",
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_chains", "enabled"):
|
||||
op.drop_column("roundtable_chains", "enabled")
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Deep-research report-structure persistence (报告结构 / 工作流配置)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.report_structures.base import ReportStructureStore
|
||||
from deerflow.persistence.report_structures.memory import MemoryReportStructureStore
|
||||
from deerflow.persistence.report_structures.model import ReportStructureRow
|
||||
from deerflow.persistence.report_structures.sql import ReportStructureRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryReportStructureStore",
|
||||
"ReportStructureRepository",
|
||||
"ReportStructureRow",
|
||||
"ReportStructureStore",
|
||||
"make_report_structure_store",
|
||||
]
|
||||
|
||||
|
||||
def make_report_structure_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryReportStructureStore()
|
||||
return ReportStructureRepository(session_factory)
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"""Abstract report-structure persistence API (报告结构 / 工作流配置).
|
||||
|
||||
Global / shared store: no ``user_id`` scoping — the deep-research 工作流配置
|
||||
dialog shows the same cards to every account. ``created_by`` is a display-only
|
||||
audit field (创建用户名). Rows are managed per-item (create / update /
|
||||
toggle-enabled / delete); ``ensure_defaults`` lazily seeds the two built-in
|
||||
outlines the first time the table is empty so a fresh deployment renders the
|
||||
same 通用分析结构 / 递进式论证结构 cards the frontend used to hardcode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportStructureStore(ABC):
|
||||
@abstractmethod
|
||||
async def list_structures(self) -> list[dict[str, Any]]:
|
||||
"""Return every stored structure, most recently modified first."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Insert ``{id, title, name, content, created_by}`` (enabled forced True)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update any of ``title`` / ``name`` / ``content``. None when missing."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
|
||||
"""Flip 启用/停用 (also bumps updated_at). None when missing."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_structure(self, structure_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def count(self) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
async def ensure_defaults(self) -> None:
|
||||
"""Seed the built-in outlines when the table has never held a row.
|
||||
|
||||
Concurrent first reads can race; seed ids are fixed so a losing insert
|
||||
fails on the primary key and is simply skipped.
|
||||
"""
|
||||
if await self.count() > 0:
|
||||
return
|
||||
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
|
||||
await self.create_structure(dict(row))
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
"""深度研究「报告结构」默认种子(新机器部署开箱即有的内置结构库)。
|
||||
|
||||
前两套(通用分析结构 / 递进式论证结构)逐字提取自前端
|
||||
``frontend-web/src/pages/deep-research/ReportConfigCard.tsx`` 的
|
||||
``DEFAULT_REPORT_OUTLINE`` / ``PROGRESSIVE_REPORT_OUTLINE``;其余三套为本仓库
|
||||
补充的常用研究结构(快速简报 / 事件分析 / 对象调查)。
|
||||
表为空(全新部署、从未写过数据)时 ``ensure_defaults`` 会把全部内置结构插入
|
||||
``report_structures``;已有数据的库不会追加。若调优了某套结构,请同步更新
|
||||
本文件,否则新机器补建出来的还是旧版。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
GENERAL_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
|
||||
|
||||
# 一、研究对象与事实基础
|
||||
## 1.1 概念、范围与背景
|
||||
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
|
||||
## 1.2 已有事实与证据
|
||||
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
|
||||
## 1.3 当前状态与主要特征
|
||||
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
|
||||
|
||||
# 二、问题形成与作用机制
|
||||
## 2.1 直接原因与触发条件
|
||||
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
|
||||
## 2.2 深层影响因素
|
||||
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
|
||||
## 2.3 因果关系与相互作用
|
||||
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
|
||||
|
||||
# 三、影响、问题与不同观点
|
||||
## 3.1 对研究对象的直接影响
|
||||
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
|
||||
## 3.2 对相关主体与外部环境的影响
|
||||
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
|
||||
## 3.3 主要争议与解释差异
|
||||
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
|
||||
|
||||
# 四、比较、趋势与不确定性
|
||||
## 4.1 可比案例或横向比较
|
||||
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
|
||||
## 4.2 发展趋势与关键变量
|
||||
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
|
||||
## 4.3 风险情景与不确定性
|
||||
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
|
||||
|
||||
# 五、结论、建议与后续关注
|
||||
## 5.1 核心结论
|
||||
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
|
||||
## 5.2 可行建议或应对方向
|
||||
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
|
||||
## 5.3 资料局限与后续关注
|
||||
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。"""
|
||||
|
||||
PROGRESSIVE_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
|
||||
|
||||
# 一、事实基线:问题是什么
|
||||
## 1.1 概念界定与研究范围
|
||||
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
|
||||
## 1.2 关键事实、数据与证据
|
||||
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
|
||||
## 1.3 基线判断与待解问题
|
||||
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
|
||||
|
||||
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
|
||||
|
||||
# 二、形成机制:为什么会这样
|
||||
## 2.1 直接原因与触发条件
|
||||
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
|
||||
## 2.2 深层条件与关键约束
|
||||
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
|
||||
## 2.3 因果链条与关键变量
|
||||
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
|
||||
|
||||
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
|
||||
|
||||
# 三、结果与影响:会带来什么
|
||||
## 3.1 直接结果与短期影响
|
||||
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
|
||||
## 3.2 传导效应与中长期影响
|
||||
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
|
||||
## 3.3 权衡、分歧与边界
|
||||
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
|
||||
|
||||
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
|
||||
|
||||
# 四、趋势与应对:接下来怎么办
|
||||
## 4.1 未来走势与观察指标
|
||||
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
|
||||
## 4.2 可选方案与适用条件
|
||||
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
|
||||
## 4.3 风险情景与验证重点
|
||||
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
|
||||
|
||||
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
|
||||
|
||||
# 五、综合结论与后续关注
|
||||
## 5.1 核心结论
|
||||
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
|
||||
## 5.2 局限与后续研究
|
||||
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。"""
|
||||
|
||||
BRIEF_OUTLINE = """# 报告标题
|
||||
|
||||
# 核心结论
|
||||
用 3–5 条要点直接给出最重要的结论与判断,每条一两句话并可回溯到后文依据;先说结论,再列支撑。
|
||||
|
||||
# 关键事实
|
||||
## 事实清单
|
||||
按重要性列出已核验的关键事实、数据与出处;不确定的信息单独注明置信度。
|
||||
## 时间与背景
|
||||
用最短的篇幅交代理解结论所需的事件顺序与背景,不展开细节。
|
||||
|
||||
# 风险与不确定性
|
||||
列出可能推翻或削弱上述结论的因素、证据缺口和正在变化中的变量。
|
||||
|
||||
# 建议行动
|
||||
按优先级给出可执行的建议或下一步关注点,说明适用前提与预期作用;材料不足时明确说明证据尚不支持。
|
||||
|
||||
# 参考资料
|
||||
仅列出本简报实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:全文控制在较短篇幅(建议 800–1500 字),信息密度优先;不写套话与背景铺陈,所有论断要么有来源支撑,要么明确标注为分析判断。"""
|
||||
|
||||
|
||||
EVENT_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明事件的性质、发生时间地点、当前状态与本报告要回答的核心问题。
|
||||
|
||||
# 一、事件概述
|
||||
## 1.1 事件是什么
|
||||
客观描述发生了什么:主体、行为、规模、直接结果;只写已核验事实。
|
||||
## 1.2 各方说法
|
||||
并列当事方、官方与媒体的不同表述,注明各自的信息来源与立场。
|
||||
|
||||
# 二、时间线还原
|
||||
## 2.1 起因与前情
|
||||
梳理事件发生的背景、前置条件与诱发节点。
|
||||
## 2.2 关键节点
|
||||
按时间顺序还原事件演进的关键节点,每个节点注明时间与来源;存在争议的时间点单独标注。
|
||||
## 2.3 当前状态
|
||||
说明事件目前的进展、处置情况与未决事项。
|
||||
|
||||
# 三、原因与责任分析
|
||||
## 3.1 直接原因
|
||||
分析触发事件的直接因素与机制。
|
||||
## 3.2 深层原因
|
||||
从制度、管理、技术、环境等维度分析更深层的促成条件。
|
||||
## 3.3 责任与归因
|
||||
基于事实分析各方责任,区分已认定的结论与仍在调查的部分。
|
||||
|
||||
# 四、影响与反应
|
||||
## 4.1 直接影响
|
||||
评估事件对当事方与相关群体已造成的影响。
|
||||
## 4.2 各方反应与后续动作
|
||||
梳理官方处置、当事方回应、舆论反应与衍生事件。
|
||||
## 4.3 潜在延伸影响
|
||||
分析可能的中长期影响与传导路径,注明判断依据。
|
||||
|
||||
# 五、结论与后续关注
|
||||
## 5.1 核心结论
|
||||
收束事件性质、原因与影响的核心判断。
|
||||
## 5.2 待验证问题与观察点
|
||||
列出尚未证实的关键问题与值得持续跟踪的信号。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:严格区分“已证实事实—各方声称—分析推断”三个层次并分别标注;时间线每个节点须有来源;对争议点呈现多方说法,不单方采信。"""
|
||||
|
||||
PROFILE_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
说明调查对象的界定范围(个人、组织或产品)、研究目的与资料边界(公开信息的时间范围与覆盖面)。
|
||||
|
||||
# 一、基本情况
|
||||
## 1.1 基本信息与背景
|
||||
梳理对象的公认基本信息与来历,注明信息时效。
|
||||
## 1.2 发展沿革
|
||||
按时间梳理关键成长节点、转折与现状。
|
||||
|
||||
# 二、能力与表现
|
||||
## 2.1 核心能力或竞争力
|
||||
分析对象的核心能力、资源禀赋或产品力,给出可观察的证据。
|
||||
## 2.2 代表性成果与表现
|
||||
列举代表性成果、业绩或事件表现,并评估其含金量与可持续性。
|
||||
## 2.3 短板与争议
|
||||
并列材料中的短板、批评与争议点,说明各方依据。
|
||||
|
||||
# 三、关系网络与生态
|
||||
## 3.1 关联方与合作关系
|
||||
梳理重要的合作关系、关联方与利益结构。
|
||||
## 3.2 立场与倾向
|
||||
基于言行记录分析对象的立场、风格与决策倾向。
|
||||
|
||||
# 四、风险评估
|
||||
## 4.1 已知风险
|
||||
汇总已暴露的风险点、负面记录与可靠性问题。
|
||||
## 4.2 潜在风险与信息缺口
|
||||
指出资料不足导致的盲区与需要进一步核实的问题。
|
||||
|
||||
# 五、综合评估
|
||||
## 5.1 画像结论
|
||||
用几句话给出对象的综合画像与可信度评估。
|
||||
## 5.2 交往或合作建议
|
||||
给出与对象打交道的注意事项与建议,说明前提。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:只依据公开可核验信息,猜测与推断必须明确标注;负面信息须注明来源与时间,区分“事实—评价—传闻”;信息不足时如实说明,不为了完整而编造。"""
|
||||
|
||||
|
||||
# 固定 id:并发首查时重复插入会撞主键而被吞掉,不会产生重复行。
|
||||
DEFAULT_REPORT_STRUCTURE_ROWS: list[dict] = [
|
||||
{
|
||||
"id": "seed-report-outline-general",
|
||||
"title": "通用分析结构",
|
||||
"name": "通用分析结构",
|
||||
"content": GENERAL_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-progressive",
|
||||
"title": "递进式论证结构",
|
||||
"name": "递进式论证结构",
|
||||
"content": PROGRESSIVE_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-brief",
|
||||
"title": "快速简报结构",
|
||||
"name": "快速简报结构",
|
||||
"content": BRIEF_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-event",
|
||||
"title": "事件分析结构",
|
||||
"name": "事件分析结构",
|
||||
"content": EVENT_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-profile",
|
||||
"title": "对象调查结构",
|
||||
"name": "对象调查结构",
|
||||
"content": PROFILE_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
]
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""In-memory report-structure store (auth-less tests / no-DB fallback)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.report_structures.base import ReportStructureStore
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _sort_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return sorted(rows, key=lambda r: (r.get("updated_at") or "", r.get("created_at") or ""), reverse=True)
|
||||
|
||||
|
||||
class MemoryReportStructureStore(ReportStructureStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_structures(self) -> list[dict[str, Any]]:
|
||||
return _sort_rows(list(self._rows.values()))
|
||||
|
||||
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
|
||||
row = self._rows.get(structure_id)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = {
|
||||
"id": str(data["id"]),
|
||||
"title": str(data.get("title") or ""),
|
||||
"name": str(data.get("name") or ""),
|
||||
"content": str(data.get("content") or ""),
|
||||
"enabled": True,
|
||||
"created_by": data.get("created_by"),
|
||||
"created_at": _now_iso(),
|
||||
"updated_at": _now_iso(),
|
||||
}
|
||||
self._rows[row["id"]] = row
|
||||
return dict(row)
|
||||
|
||||
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._rows.get(structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("title", "name", "content"):
|
||||
if key in data:
|
||||
row[key] = str(data[key])
|
||||
row["updated_at"] = _now_iso()
|
||||
return dict(row)
|
||||
|
||||
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
|
||||
row = self._rows.get(structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
row["enabled"] = bool(enabled)
|
||||
row["updated_at"] = _now_iso()
|
||||
return dict(row)
|
||||
|
||||
async def delete_structure(self, structure_id: str) -> bool:
|
||||
return self._rows.pop(structure_id, None) is not None
|
||||
|
||||
async def count(self) -> int:
|
||||
return len(self._rows)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""ORM model for deep-research report structures (报告结构 / 工作流配置).
|
||||
|
||||
Global / shared — every account sees the same structure cards in the deep
|
||||
research workbench's 工作流配置 dialog. Each row is one reusable report
|
||||
structure the user can pick to fill ``custom_outline``:
|
||||
|
||||
- ``title`` — 标题, the card's primary label and the list's 标题筛选 target.
|
||||
- ``name`` — 名称, a secondary display label.
|
||||
- ``content`` — 接口内容, the Markdown outline text applied to the research
|
||||
config's ``custom_outline`` when the card is selected.
|
||||
- ``enabled`` — 启用/停用; creation always defaults to True (the caller cannot
|
||||
choose it) and a dedicated toggle flips it later.
|
||||
- ``created_by`` — 创建用户名 (audit-only display field).
|
||||
|
||||
Fresh deployments get the two built-in outlines (通用分析结构 / 递进式论证结构)
|
||||
seeded by ``ensure_defaults`` — see ``defaults.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class ReportStructureRow(Base):
|
||||
__tablename__ = "report_structures"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(256), default="", nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# ``server_default`` 必须显式声明:否则在已存在该表但缺列的旧库上,启动期
|
||||
# ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列(见
|
||||
# engine.py),该列永远补不上 → 查询报 Unknown column → 500。
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
|
||||
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""SQLAlchemy-backed report-structure store (global / shared)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.report_structures.base import ReportStructureStore
|
||||
from deerflow.persistence.report_structures.model import ReportStructureRow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_dict(row: ReportStructureRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class ReportStructureRepository(ReportStructureStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_structures(self) -> list[dict[str, Any]]:
|
||||
stmt = select(ReportStructureRow).order_by(
|
||||
ReportStructureRow.updated_at.desc(), ReportStructureRow.created_at.asc()
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ReportStructureRow, structure_id)
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = ReportStructureRow(
|
||||
id=str(data["id"]),
|
||||
title=str(data.get("title") or ""),
|
||||
name=str(data.get("name") or ""),
|
||||
content=str(data.get("content") or ""),
|
||||
# 创建永远默认启用(前端不提供该项填写)。
|
||||
enabled=True,
|
||||
created_by=data.get("created_by"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ReportStructureRow, structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("title", "name", "content"):
|
||||
if key in data:
|
||||
setattr(row, key, str(data[key]))
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ReportStructureRow, structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.enabled = bool(enabled)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_structure(self, structure_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(delete(ReportStructureRow).where(ReportStructureRow.id == structure_id))
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self._sf() as session:
|
||||
return int(await session.scalar(select(func.count()).select_from(ReportStructureRow)) or 0)
|
||||
|
||||
async def ensure_defaults(self) -> None:
|
||||
"""Seed when empty; a concurrent first-read loses on the fixed PK silently."""
|
||||
if await self.count() > 0:
|
||||
return
|
||||
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
|
||||
|
||||
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
|
||||
try:
|
||||
await self.create_structure(dict(row))
|
||||
except IntegrityError:
|
||||
logger.debug("report-structure default '%s' already inserted by a concurrent request", row["id"])
|
||||
+6
@@ -63,6 +63,12 @@ class RoundtableChainRow(Base):
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0", index=True
|
||||
)
|
||||
# 启停开关:True = 启用(可在选链弹窗 / 公共入口里选用);False = 停用(管理列表仍可见,
|
||||
# 但不可选入研讨)。默认启用,存量链条补列后也为 True。``server_default`` 必须显式声明,
|
||||
# 否则既有表上启动期 ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列。
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="1"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
+6
@@ -62,6 +62,8 @@ class RoundtableChainRepository:
|
||||
"gather_first": bool(getattr(row, "gather_first", False)),
|
||||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||||
"is_public": bool(getattr(row, "is_public", False)),
|
||||
# 启停状态:缺省 True(存量行 / 补列前视为启用)。
|
||||
"enabled": bool(getattr(row, "enabled", True)),
|
||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
|
||||
"owner_id": row.user_id,
|
||||
@@ -122,6 +124,7 @@ class RoundtableChainRepository:
|
||||
seat_mode=(data.get("seat_mode") or None),
|
||||
gather_first=bool(data.get("gather_first") or False),
|
||||
is_public=bool(data.get("is_public") or False),
|
||||
enabled=True if data.get("enabled") is None else bool(data.get("enabled")),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -145,6 +148,7 @@ class RoundtableChainRepository:
|
||||
seat_mode=_UNSET,
|
||||
gather_first=_UNSET,
|
||||
is_public=_UNSET,
|
||||
enabled=_UNSET,
|
||||
is_admin: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
@@ -170,6 +174,8 @@ class RoundtableChainRepository:
|
||||
row.gather_first = bool(gather_first)
|
||||
if is_public is not _UNSET and is_public is not None:
|
||||
row.is_public = bool(is_public)
|
||||
if enabled is not _UNSET and enabled is not None:
|
||||
row.enabled = bool(enabled)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
Reference in new issue
Block a user