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:
hataa 2026-10-24 08:50:56 +08:00 committed by GitHub
parent 4ec0fe1bb4
commit 35d0dcd6f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 538 additions and 2 deletions

View 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"]

View File

@ -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

View 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
```

View File

@ -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"]

View File

@ -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

View 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"]

View File

@ -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",

View File

@ -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:

View File

@ -28,6 +28,7 @@ export interface Translations {
installationGuide: string;
catalog: Record<
| "workflows"
| "reportSections"
| "agentTeams"
| "bookmarks"
| "context"

View File

@ -45,6 +45,10 @@ export const zhCN: Translations = {
description:
"保存版本化 Agent 工作流,在同一 Gateway 进程内恢复,并确认确切的保留输出版本。不支持重启回放或硬金额预算。",
},
reportSections: {
title: "分段长文写作",
description: "逐段撰写长报告,并将各章节汇总为可下载的 Markdown 文档。",
},
agentTeams: {
title: "Agent 团队",
description: "让完整 Custom Agent 通过 @成员、共享记录与任务交接协作。",