mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
feat(plugins): add sectioned long-form report writing example (#7371)
* feat(plugins): add sectioned long-form report writing example A standalone example extension whose save_section model tool stores a 万字-scale report section by section (SQLite, per-user and per-thread) and assembles every section into one markdown document on a final section_index=999 call. The assembled document is returned as a tool artifact with only a short status line re-entering the model context, so the full report never consumes the context budget. Assembly drops a section's leading heading when it restates the section title (models habitually open each section with '# <title>' while the assembler already emits '## <title>'), which otherwise doubles every heading in the document. The outline section passes through unwrapped and keeps its own heading as the document title. * fix(frontend): register report sections in extension catalog * docs(report-sections): correct the test command path --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
4ec0fe1bb4
commit
35d0dcd6f2
198
backend/tests/test_report_sections_extension.py
Normal file
198
backend/tests/test_report_sections_extension.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Sectioned long-form writing plugin through the real plugin tool path."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from deerflow_extension_api.auth import ExtensionPrincipal
|
||||
from deerflow_extension_api.plugins import ToolContext
|
||||
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
from deerflow.extensions.plugin_tools import build_plugin_tools
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-report-sections"
|
||||
|
||||
OUTLINE = "# 2026 年新能源汽车行业研究报告\n\n本报告分五章。"
|
||||
SECTION_WITH_TITLE = "# 一、行业概述与研究方法\n\n行业概述正文。"
|
||||
SECTION_HASH_SUFFIX = "### 二、市场格局与竞争态势 ###\n\n竞争态势正文。"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def syspath(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(EXAMPLE))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load(syspath, tmp_path):
|
||||
def _load(**config):
|
||||
loaded, diagnostics = load_extensions(
|
||||
[
|
||||
ExtensionSpec(
|
||||
use="deerflow_extension_report_sections:install",
|
||||
config={"enabled": True, "storage_path": str(tmp_path / "sections.sqlite"), **config},
|
||||
)
|
||||
]
|
||||
)
|
||||
assert not diagnostics, diagnostics
|
||||
return loaded
|
||||
|
||||
return _load
|
||||
|
||||
|
||||
def handler(loaded):
|
||||
((_, plugin),) = loaded.plugins
|
||||
(tool,) = plugin.tools
|
||||
assert tool.name == "save_section"
|
||||
assert tool.response_format == "content_and_artifact"
|
||||
return tool.handler
|
||||
|
||||
|
||||
def context(user="alice", thread="thread-1"):
|
||||
return ToolContext(ExtensionPrincipal(user), {"enabled": True}, thread)
|
||||
|
||||
|
||||
def save(handle, section_index, title, content, user="alice", thread="thread-1"):
|
||||
return asyncio.run(handle({"section_index": section_index, "title": title, "content": content}, context(user, thread)))
|
||||
|
||||
|
||||
def assemble(handle, user="alice", thread="thread-1"):
|
||||
return asyncio.run(handle({"section_index": 999, "title": "__assemble__", "content": ""}, context(user, thread)))
|
||||
|
||||
|
||||
def seed(handle):
|
||||
save(handle, 0, "2026 年新能源汽车行业研究报告", OUTLINE)
|
||||
save(handle, 1, "一、行业概述与研究方法", SECTION_WITH_TITLE)
|
||||
save(handle, 2, "二、市场格局与竞争态势", SECTION_HASH_SUFFIX)
|
||||
|
||||
|
||||
def test_registration_exposes_one_assembling_tool(load):
|
||||
handle = handler(load())
|
||||
status = save(handle, 1, "一、章节", "正文内容。")
|
||||
assert status.content.startswith("OK section 1")
|
||||
assert status.artifact is None
|
||||
|
||||
|
||||
def test_assembly_strips_duplicate_headings_and_keeps_unique_ones(load):
|
||||
handle = handler(load())
|
||||
seed(handle)
|
||||
result = assemble(handle)
|
||||
assert result.content.startswith("OK assembled 3 sections")
|
||||
document = result.artifact["document"]
|
||||
# The assembler's own heading is the only occurrence for each section.
|
||||
assert document.count("一、行业概述与研究方法") == 1
|
||||
assert document.count("二、市场格局与竞争态势") == 1
|
||||
assert "### 二、市场格局与竞争态势 ###" not in document
|
||||
assert document.count("## 一、行业概述与研究方法") == 1
|
||||
# The outline passes through unwrapped and keeps its own opening heading.
|
||||
assert document.startswith("# 2026 年新能源汽车行业研究报告")
|
||||
assert result.artifact["filename"].startswith("report-")
|
||||
assert result.artifact["sections"] == 3
|
||||
|
||||
|
||||
def test_strip_duplicate_title_heading_variants(syspath):
|
||||
from deerflow_extension_report_sections.sections import strip_duplicate_title_heading
|
||||
|
||||
assert strip_duplicate_title_heading("# 摘要\n正文", "摘要") == "正文"
|
||||
assert strip_duplicate_title_heading("### 摘要 ###\n正文", " 摘要 ") == "正文"
|
||||
assert strip_duplicate_title_heading("正文没有标题", "摘要") == "正文没有标题"
|
||||
# A heading that does not restate the title is content, not a duplicate.
|
||||
assert strip_duplicate_title_heading("# 其他标题\n正文", "摘要") == "# 其他标题\n正文"
|
||||
|
||||
|
||||
def test_assemble_document_counts_stripped_bodies(syspath):
|
||||
from deerflow_extension_report_sections.sections import assemble_document
|
||||
|
||||
rows = [
|
||||
{"section_index": 0, "title": "大纲", "content": "# 大纲\n引言。"},
|
||||
{"section_index": 1, "title": "一、背景", "content": "# 一、背景\n背景正文。"},
|
||||
]
|
||||
document, total_chars = assemble_document(rows)
|
||||
# The outline keeps its own heading (it becomes the document title);
|
||||
# wrapped sections drop their duplicate opening heading.
|
||||
assert total_chars == len("# 大纲\n引言。") + len("背景正文。")
|
||||
assert document.startswith("# 大纲")
|
||||
assert "\n\n## 一、背景\n\n背景正文。" in document
|
||||
|
||||
|
||||
def test_sections_are_scoped_per_user_and_thread(load):
|
||||
handle = handler(load())
|
||||
seed(handle)
|
||||
for user, thread in (("bob", "thread-1"), ("alice", "thread-2")):
|
||||
empty = assemble(handle, user=user, thread=thread)
|
||||
assert empty.content.startswith("ERROR: no sections saved yet")
|
||||
|
||||
|
||||
def test_empty_section_content_is_refused(load):
|
||||
handle = handler(load())
|
||||
refused = save(handle, 1, "一、章节", " \n")
|
||||
assert refused.content.startswith("ERROR: content is empty")
|
||||
|
||||
|
||||
def test_missing_thread_context_is_refused(load):
|
||||
handle = handler(load())
|
||||
no_thread = ToolContext(ExtensionPrincipal("alice"), {"enabled": True}, None)
|
||||
refused = asyncio.run(handle({"section_index": 1, "title": "一", "content": "正文"}, no_thread))
|
||||
assert refused.content.startswith("ERROR: no user or thread context")
|
||||
|
||||
|
||||
def test_resaving_a_section_replaces_it(load):
|
||||
handle = handler(load())
|
||||
save(handle, 1, "一、章节", "第一版正文。")
|
||||
save(handle, 1, "一、章节", "第二版正文。")
|
||||
result = assemble(handle)
|
||||
assert result.artifact["sections"] == 1
|
||||
assert "第一版" not in result.artifact["document"]
|
||||
assert "第二版正文。" in result.artifact["document"]
|
||||
|
||||
|
||||
def test_oversized_assembly_is_refused_but_sections_remain(load):
|
||||
handle = handler(load())
|
||||
body = "字" * 20_000
|
||||
for index in range(1, 5):
|
||||
save(handle, index, f"第{index}章", body)
|
||||
refused = assemble(handle)
|
||||
assert "over the" in refused.content
|
||||
assert refused.artifact is None
|
||||
# The stored sections are intact for a replacement save.
|
||||
trimmed = save(handle, 1, "第1章", "短正文。")
|
||||
assert trimmed.content.startswith("OK section 1")
|
||||
|
||||
|
||||
def test_disabled_contribution_builds_no_tool(load):
|
||||
loaded = load(enabled=False)
|
||||
tools = build_plugin_tools(loaded, groups=("extensions",))
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_real_tool_dispatch_delivers_document_as_artifact(load):
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
(tool,) = build_plugin_tools(load(), groups=("extensions",))
|
||||
graph = StateGraph(MessagesState)
|
||||
graph.add_node("tools", ToolNode([tool]))
|
||||
graph.add_edge(START, "tools")
|
||||
graph.add_edge("tools", END)
|
||||
compiled = graph.compile()
|
||||
|
||||
def dispatch(section_index, title, content):
|
||||
return asyncio.run(
|
||||
compiled.ainvoke(
|
||||
{"messages": [AIMessage(content="", tool_calls=[{"id": "c", "name": tool.name, "args": {"section_index": section_index, "title": title, "content": content}}])]},
|
||||
context={"user_id": "alice", "thread_id": "thread-1"},
|
||||
)
|
||||
)["messages"][-1]
|
||||
|
||||
saved = dispatch(1, "一、章节", "正文。")
|
||||
assert saved.status != "error"
|
||||
assert saved.content.startswith("OK section 1")
|
||||
assert getattr(saved, "artifact", None) is None
|
||||
|
||||
assembled = dispatch(999, "__assemble__", "")
|
||||
assert assembled.status != "error"
|
||||
assert assembled.content.startswith("OK assembled 1 sections")
|
||||
artifact = assembled.artifact
|
||||
assert artifact["filename"].startswith("report-")
|
||||
assert artifact["document"].endswith("\n")
|
||||
assert "## 一、章节" in artifact["document"]
|
||||
@ -10,8 +10,9 @@ independent Python package using the public `deerflow-extension-api` contract.
|
||||
| [Agent teams](deerflow-extension-agent-teams/README.md) | Full Agent orchestration. Use this after the basic contribution and lifecycle patterns are familiar. |
|
||||
|
||||
Additional examples cover [text-list classification](deerflow-extension-jev-classify/README.md),
|
||||
[context pruning](deerflow-extension-jev-context/README.md), and
|
||||
[advisory screening of fetched content](deerflow-extension-jev-screening/README.md).
|
||||
[context pruning](deerflow-extension-jev-context/README.md),
|
||||
[advisory screening of fetched content](deerflow-extension-jev-screening/README.md), and
|
||||
[sectioned long-form report writing](deerflow-extension-report-sections/README.md).
|
||||
|
||||
## Edit, test, check, then install
|
||||
|
||||
|
||||
65
examples/deerflow-extension-report-sections/README.md
Normal file
65
examples/deerflow-extension-report-sections/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Report sections — sectioned long-form writing plugin example
|
||||
|
||||
For the shared edit → test → packaging check → install workflow, see the
|
||||
[existing-example guide](../README.md).
|
||||
|
||||
A compact example of a **stateful model tool**: the agent writes a 万字-scale
|
||||
long report section by section with `save_section`, and a final
|
||||
`section_index=999` call assembles every stored section into one markdown
|
||||
document. The assembled report is returned as a tool artifact (visible in the
|
||||
UI) with only a short status summary re-entering the model context, so the
|
||||
full document never consumes the agent's context budget.
|
||||
|
||||
Assembly drops a section's leading heading when it restates the section title
|
||||
(models habitually open each section with `# <title>` while the assembler
|
||||
already emits `## <title>`), which otherwise produces doubled headings across
|
||||
the whole report. Sections are scoped per user and per thread in the plugin's
|
||||
own SQLite file — no host database or host-internal imports.
|
||||
|
||||
## Deployment
|
||||
|
||||
Install this directory using the existing trusted Python extension manager.
|
||||
From `backend/`:
|
||||
|
||||
```sh
|
||||
uv run deerflow extensions install ../examples/deerflow-extension-report-sections --yes
|
||||
```
|
||||
|
||||
Then set the deployment-owned `config` on the registered plugin entry before
|
||||
restarting Gateway. Example configuration:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- use: deerflow_extension_report_sections:install
|
||||
enabled: true
|
||||
config:
|
||||
enabled: true
|
||||
storage_path: /var/lib/deerflow/report-sections.sqlite
|
||||
```
|
||||
|
||||
The outer `enabled` controls package loading; `config.enabled` controls the
|
||||
tool contribution. Mount a persistent writable directory for `storage_path`.
|
||||
The file stores user section data, not deployment settings.
|
||||
|
||||
## Tool contract
|
||||
|
||||
`save_section(section_index, title, content)`:
|
||||
|
||||
- `section_index=0` — store the outline (passed through unwrapped on assembly).
|
||||
- `1..N` — store one chapter (keep each call within roughly 1200–2000
|
||||
characters to stay inside the per-completion output budget; hard cap 24000).
|
||||
- `999` with `title="__assemble__"` — assemble all stored sections into
|
||||
`report-<topic>.md`, delivered as an artifact. Assemblies over 60 KiB are
|
||||
refused with a clear status; the sections remain stored.
|
||||
|
||||
The tool result envelope is capped at 64 KiB by the host; this example keeps
|
||||
the model-visible content to one status line and carries the document in the
|
||||
artifact.
|
||||
|
||||
## Tests
|
||||
|
||||
From the DeerFlow checkout root with the backend environment prepared:
|
||||
|
||||
```sh
|
||||
uv run --project backend --no-sync python -m pytest backend/tests/test_report_sections_extension.py -q
|
||||
```
|
||||
@ -0,0 +1,120 @@
|
||||
"""Sectioned long-form writing plugin. No imports from DeerFlow host internals."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from deerflow_extension_api import ModelTool, PluginContribution, PluginToolResult, extension
|
||||
|
||||
from .sections import (
|
||||
ASSEMBLE_INDEX,
|
||||
ASSEMBLE_TITLE,
|
||||
MAX_CONTENT_CHARS,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MAX_TITLE_CHARS,
|
||||
SectionStore,
|
||||
)
|
||||
|
||||
TOOL_DESCRIPTION = (
|
||||
"Persist one section of a long report, then assemble all sections into the final document. "
|
||||
"Call with section_index=0 first to store the outline, then 1..N in order for chapters "
|
||||
"(keep each section within roughly 1200-2000 characters), and finally section_index=999 "
|
||||
"with title '__assemble__' to assemble every stored section into one markdown report, "
|
||||
"returned as a downloadable artifact with a short status summary. "
|
||||
"Sections are scoped to the current user and thread."
|
||||
)
|
||||
|
||||
INPUT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"section_index": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": ASSEMBLE_INDEX,
|
||||
"description": "0 = outline, 1..N = chapters in order, 999 = assemble.",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": MAX_TITLE_CHARS,
|
||||
"description": "Section title; use '__assemble__' with index 999.",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"maxLength": MAX_CONTENT_CHARS,
|
||||
"description": "Full markdown content of this section; ignored when assembling.",
|
||||
},
|
||||
},
|
||||
"required": ["section_index", "title", "content"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _error(message: str) -> PluginToolResult:
|
||||
return PluginToolResult(content=f"ERROR: {message}", artifact=None)
|
||||
|
||||
|
||||
@extension(api="0.2.8", name="report-sections")
|
||||
def install(registry, config):
|
||||
enabled = config.get("enabled", False)
|
||||
if type(enabled) is not bool or not isinstance(config.get("storage_path"), str):
|
||||
raise ValueError("Configure boolean enabled and an absolute storage_path at deployment")
|
||||
store = SectionStore(config["storage_path"])
|
||||
|
||||
async def save_section(payload, context):
|
||||
section_index = payload["section_index"]
|
||||
title = payload["title"].strip()
|
||||
owner = context.principal.user_id
|
||||
thread_id = context.thread_id
|
||||
if not owner or not thread_id:
|
||||
return _error("no user or thread context; cannot save section")
|
||||
|
||||
if section_index == ASSEMBLE_INDEX or title == ASSEMBLE_TITLE:
|
||||
assembled = await asyncio.to_thread(store.assemble, owner, thread_id)
|
||||
if assembled is None:
|
||||
return _error("no sections saved yet; nothing to assemble")
|
||||
filename, document, count, total_chars = assembled
|
||||
if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
|
||||
return _error(f"assembled report is {len(document.encode('utf-8'))} bytes, over the {MAX_DOCUMENT_BYTES}-byte tool-result cap; sections remain stored — assemble fewer sections per thread")
|
||||
# content is model-visible and stays short; the artifact carries the
|
||||
# full document to the UI without re-entering the model context.
|
||||
return PluginToolResult(
|
||||
content=(f"OK assembled {count} sections, {total_chars} chars into {filename}. Give the user a brief completion summary now (section list and total length)."),
|
||||
artifact={
|
||||
"filename": filename,
|
||||
"sections": count,
|
||||
"chars": total_chars,
|
||||
"document": document,
|
||||
},
|
||||
)
|
||||
|
||||
content = payload["content"]
|
||||
if not content.strip():
|
||||
return _error("content is empty")
|
||||
await asyncio.to_thread(store.save, owner, thread_id, int(section_index), title, content)
|
||||
return PluginToolResult(
|
||||
content=f"OK section {section_index} '{title}' saved ({len(content)} chars). Continue with the next section.",
|
||||
artifact=None,
|
||||
)
|
||||
|
||||
contribution = PluginContribution(
|
||||
namespace="community.report-sections",
|
||||
title="分段长文写作 / Sectioned long-form writing",
|
||||
description=(
|
||||
"把万字级长报告分节保存,最后拼装成完整交付文档;拼装时自动去掉与节标题重复的正文标题。"
|
||||
" / Store a long report section by section and assemble the final document,"
|
||||
" dropping content headings that duplicate the assembled section titles."
|
||||
),
|
||||
enabled=enabled,
|
||||
tools=(
|
||||
ModelTool(
|
||||
"save_section",
|
||||
TOOL_DESCRIPTION,
|
||||
INPUT_SCHEMA,
|
||||
save_section,
|
||||
response_format="content_and_artifact",
|
||||
),
|
||||
),
|
||||
)
|
||||
if registry.plugin(contribution) is not True:
|
||||
raise RuntimeError("This example requires the full-stack plugin host contract")
|
||||
|
||||
|
||||
__all__ = ["install"]
|
||||
@ -0,0 +1,120 @@
|
||||
"""Section storage and report assembly. No imports from DeerFlow host internals."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
ASSEMBLE_INDEX = 999
|
||||
ASSEMBLE_TITLE = "__assemble__"
|
||||
|
||||
MAX_TITLE_CHARS = 200
|
||||
MAX_CONTENT_CHARS = 24_000
|
||||
# The host caps every plugin tool envelope at 64 KiB; leave room for the
|
||||
# status text and JSON escaping before refusing an oversized assembly.
|
||||
MAX_DOCUMENT_BYTES = 60_000
|
||||
|
||||
_DDL = """CREATE TABLE IF NOT EXISTS report_sections (
|
||||
owner TEXT NOT NULL,
|
||||
thread_id TEXT NOT NULL,
|
||||
section_index INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (owner, thread_id, section_index)
|
||||
)"""
|
||||
|
||||
|
||||
def _norm_heading(text: str) -> str:
|
||||
"""Whitespace- and marker-insensitive form for heading comparison."""
|
||||
return re.sub(r"[\s#]+", "", text)
|
||||
|
||||
|
||||
def strip_duplicate_title_heading(content: str, title: str) -> str:
|
||||
"""Drop a leading heading that restates the section title.
|
||||
|
||||
Models tend to open each stored section with ``# <title>``; the assembler
|
||||
already emits ``## <title>`` for every non-outline section, so keeping
|
||||
both produced doubled headings throughout assembled reports.
|
||||
"""
|
||||
stripped = content.lstrip()
|
||||
first_line, _, rest = stripped.partition("\n")
|
||||
match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", first_line)
|
||||
if match and _norm_heading(match.group(1)) == _norm_heading(title):
|
||||
return rest.lstrip("\n")
|
||||
return stripped
|
||||
|
||||
|
||||
def assemble_document(rows: list[sqlite3.Row]) -> tuple[str, int]:
|
||||
"""Build the final markdown document and report its body size.
|
||||
|
||||
``section_index`` 0 is the outline: it passes through unwrapped, so its own
|
||||
leading heading is the document title and must be kept. Every other
|
||||
section is preceded by one ``## <title>`` heading, and the section's own
|
||||
duplicate opening heading is removed first.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
total_chars = 0
|
||||
for row in rows:
|
||||
title = row["title"].strip()
|
||||
if row["section_index"] == 0:
|
||||
body = row["content"].lstrip()
|
||||
else:
|
||||
body = strip_duplicate_title_heading(row["content"], title)
|
||||
total_chars += len(body)
|
||||
if row["section_index"] == 0:
|
||||
parts.append(body)
|
||||
else:
|
||||
parts.append(f"\n\n## {title}\n\n{body}")
|
||||
return "".join(parts).strip() + "\n", total_chars
|
||||
|
||||
|
||||
def _slug(text: str, limit: int = 40) -> str:
|
||||
slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", text.strip())[:limit].strip("-")
|
||||
return slug or "report"
|
||||
|
||||
|
||||
class SectionStore:
|
||||
"""Plugin-owned per-user section data in a deployment-local SQLite file."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = Path(path)
|
||||
if not self.path.is_absolute():
|
||||
raise ValueError("storage_path must be an absolute deployment-owned path")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(self.connect()) as db, db:
|
||||
db.execute(_DDL)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.path, timeout=10)
|
||||
db.row_factory = sqlite3.Row
|
||||
return db
|
||||
|
||||
def save(self, owner: str, thread_id: str, section_index: int, title: str, content: str) -> None:
|
||||
with closing(self.connect()) as db, db:
|
||||
db.execute(
|
||||
"INSERT INTO report_sections (owner, thread_id, section_index, title, content)"
|
||||
" VALUES (?, ?, ?, ?, ?)"
|
||||
" ON CONFLICT (owner, thread_id, section_index)"
|
||||
" DO UPDATE SET title=excluded.title, content=excluded.content,"
|
||||
" created_at=CURRENT_TIMESTAMP",
|
||||
(owner, thread_id, section_index, title, content),
|
||||
)
|
||||
|
||||
def load_sections(self, owner: str, thread_id: str) -> list[sqlite3.Row]:
|
||||
with closing(self.connect()) as db:
|
||||
return db.execute(
|
||||
"SELECT section_index, title, content FROM report_sections WHERE owner=? AND thread_id=? AND section_index<>? ORDER BY section_index",
|
||||
(owner, thread_id, ASSEMBLE_INDEX),
|
||||
).fetchall()
|
||||
|
||||
def assemble(self, owner: str, thread_id: str) -> tuple[str, str, int, int] | None:
|
||||
"""Return ``(filename, document, section_count, total_chars)`` or None when empty."""
|
||||
rows = self.load_sections(owner, thread_id)
|
||||
if not rows:
|
||||
return None
|
||||
document, total_chars = assemble_document(rows)
|
||||
first = rows[0]
|
||||
topic = _slug(first["title"]) if first["section_index"] == 0 else "report"
|
||||
filename = f"report-{topic}.md"
|
||||
return filename, document, len(rows), total_chars
|
||||
17
examples/deerflow-extension-report-sections/pyproject.toml
Normal file
17
examples/deerflow-extension-report-sections/pyproject.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "deerflow-extension-report-sections"
|
||||
version = "0.1.0"
|
||||
description = "Sectioned long-form report writing with assembled artifact delivery for DeerFlow"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
dependencies = ["deerflow-extension-api>=0.2.8,<0.3"]
|
||||
|
||||
[project.entry-points."deerflow.extensions"]
|
||||
report-sections = "deerflow_extension_report_sections:install"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["deerflow_extension_report_sections"]
|
||||
@ -35,6 +35,11 @@ export const extensionCatalog = [
|
||||
package: "deerflow-extension-workflows",
|
||||
namespace: "community.workflows",
|
||||
},
|
||||
{
|
||||
key: "reportSections",
|
||||
package: "deerflow-extension-report-sections",
|
||||
namespace: "community.report-sections",
|
||||
},
|
||||
{
|
||||
key: "retrieval",
|
||||
package: "deerflow-extension-retrieval",
|
||||
|
||||
@ -45,6 +45,11 @@ export const enUS: Translations = {
|
||||
description:
|
||||
"Save versioned Agent workflows, resume within the same Gateway process and review exact retained output versions. No restart replay or hard monetary budget.",
|
||||
},
|
||||
reportSections: {
|
||||
title: "Sectioned long-form writing",
|
||||
description:
|
||||
"Write long reports section by section and assemble them into a downloadable Markdown document.",
|
||||
},
|
||||
agentTeams: {
|
||||
title: "Agent teams",
|
||||
description:
|
||||
|
||||
@ -28,6 +28,7 @@ export interface Translations {
|
||||
installationGuide: string;
|
||||
catalog: Record<
|
||||
| "workflows"
|
||||
| "reportSections"
|
||||
| "agentTeams"
|
||||
| "bookmarks"
|
||||
| "context"
|
||||
|
||||
@ -45,6 +45,10 @@ export const zhCN: Translations = {
|
||||
description:
|
||||
"保存版本化 Agent 工作流,在同一 Gateway 进程内恢复,并确认确切的保留输出版本。不支持重启回放或硬金额预算。",
|
||||
},
|
||||
reportSections: {
|
||||
title: "分段长文写作",
|
||||
description: "逐段撰写长报告,并将各章节汇总为可下载的 Markdown 文档。",
|
||||
},
|
||||
agentTeams: {
|
||||
title: "Agent 团队",
|
||||
description: "让完整 Custom Agent 通过 @成员、共享记录与任务交接协作。",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user