初始化

This commit is contained in:
gozeros committed 2026-09-07 18:24:55 +08:00
1 parent 34474bd5fc
commit b638b541d0
3008 files changed
+837699

No files matched your search

@@ -0,0 +1,71 @@
"""Audit ORM index/unique-key byte sizes against MySQL InnoDB prefix limits.
MySQL with the legacy 767-byte InnoDB index-prefix limit (5.6, or 5.7 with
COMPACT row format) rejects any index whose key exceeds 767 bytes — and
utf8mb4 costs 4 bytes per char, so an indexed VARCHAR key may total at most
191 chars. Modern servers (DYNAMIC row format) allow 3072 bytes = 768 chars.
Run: PYTHONPATH=. .venv/Scripts/python scripts/audit_index_key_lengths.py
"""
from __future__ import annotations
import sys
from sqlalchemy import String, UniqueConstraint
import deerflow.persistence.models # noqa: F401 (registers all ORM models)
from deerflow.persistence.base import Base
LIMIT_OLD = 767
LIMIT_NEW = 3072
BYTES_PER_CHAR = 4 # utf8mb4
def _col_key_bytes(col) -> int | None:
t = col.type
# unwrap TypeDecorator
impl = getattr(t, "impl", None)
if impl is not None:
t = impl
if isinstance(t, String):
return (t.length or 255) * BYTES_PER_CHAR
return 8 # ints/datetimes/floats — small fixed size, close enough
def _key_bytes(col, mysql_length) -> int:
full = _col_key_bytes(col) or 0
if mysql_length is None:
return full
if isinstance(mysql_length, dict):
prefix = mysql_length.get(col.name)
else:
prefix = mysql_length
if prefix is None:
return full
return min(full, int(prefix) * BYTES_PER_CHAR)
def main() -> int:
bad = 0
for table in Base.metadata.sorted_tables:
keys: list[tuple[str, str, list, object]] = []
for idx in table.indexes:
keys.append(("INDEX", idx.name, list(idx.columns), idx.dialect_options["mysql"].get("length")))
for cons in table.constraints:
if isinstance(cons, UniqueConstraint):
keys.append(("UNIQUE", cons.name or "?", list(cons.columns), None))
for kind, name, cols, mysql_length in keys:
total = sum(_key_bytes(c, mysql_length) for c in cols)
if total > LIMIT_OLD:
bad += 1
tag = "EXCEEDS 3072 (fails on ALL MySQL)" if total > LIMIT_NEW else "exceeds 767 (fails on legacy MySQL)"
detail = ", ".join(f"{c.name}({getattr(getattr(c.type, 'impl', c.type), 'length', '?')})" for c in cols)
print(f"{table.name}: {kind} {name} = {total} bytes [{detail}] -- {tag}")
if not bad:
print("OK: every index key fits within 767 bytes (utf8mb4 worst case)")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,182 @@
"""一次性清洗:修正历史知识笔记里遗留的 `## Related` 结构链接与 `### Sources`。
背景
====
早期沉淀的每条笔记都会在 `## Related` 段自动塞入“结构性” wikilink
(`[[index]]` / `[[_meta/taxonomy]]` / `[[projects/<project>/…]]` / 项目 hub),
并在 `## Related` 下嵌套一个 `### Sources` 子段。现在:
- 真·相关链接只用裸标题 `[[Title]]`(由 service 层按共享标签注入);
- 来源统一由结构化的「来源」区块(`note.sources`)展示,不再写进正文。
但历史笔记的 `content_md`(数据库 + vault 文件)里仍焊死着这些遗留内容,
导致详情页出现噪声链接、以及 sources/来源 重复。本脚本把它们清理掉。
清洗规则(保守,只删明确匹配项)
================================
对每条笔记的正文:
1. 删除 `## Related` 段内的 `### Sources` 子段(标题 + 其下条目);
2. 删除 `## Related` 段内的“结构性”链接条目 —— 即 wikilink 目标包含 `/`、
或等于 `index` / `taxonomy` 之类的脚手架页(真·相关链接是裸标题,予以保留);
3. 若清理后 `## Related` 段已无任何内容条目,则连同 `## Related` 标题一并删除;
4. 折叠多余空行。
裸标题的 `[[Title]]` 相关链接、其它正文内容一律保留(无数据丢失)。
用法
====
::
# 预览(不写盘),打印每条将要发生的改动
PYTHONPATH=. python scripts/cleanup_knowledge_related.py --dry-run
# 执行(写回数据库 + 重写 vault 文件)
PYTHONPATH=. python scripts/cleanup_knowledge_related.py
# 只处理单条
PYTHONPATH=. python scripts/cleanup_knowledge_related.py --note-id kb_xxx
脚本幂等:已经干净的笔记会被跳过。写回走 `KnowledgeService.update_note`,
所以每次改动都会落一份版本快照(可在「编辑历史」回滚)。
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import re
import sys
logger = logging.getLogger("cleanup_knowledge_related")
_RELATED_HEADER_RE = re.compile(r"^##\s+(Related|相关知识|相关文档|相关)\s*$", re.IGNORECASE)
_SOURCES_HEADER_RE = re.compile(r"^###\s+(Sources|来源)\s*$", re.IGNORECASE)
_H12_RE = re.compile(r"^#{1,2}\s+")
_H123_RE = re.compile(r"^#{1,3}\s+")
_BULLET_LINK_RE = re.compile(r"^[-*]\s*\[\[([^\]\n|]+)(?:\|[^\]\n]+)?\]\]\s*$")
def _is_structural_target(target: str) -> bool:
"""A `## Related` wikilink that points at vault scaffolding (not a real note).
Real related cross-links are bare note titles (`[[标题]]`) with no slashes.
The legacy structural links were full vault paths / meta pages.
"""
t = (target or "").strip().strip("/").lower()
if not t:
return True
if "/" in t: # projects/<p>/…, _meta/taxonomy, … — all structural
return True
return t in {"index", "taxonomy", "hot", "log", "_meta"}
def clean_note_body(md: str) -> str:
"""Strip the legacy `### Sources` subsection + structural `## Related` links.
Pure function (no I/O) so it can be unit-tested. Returns the cleaned body;
identical to the input when there is nothing to clean.
"""
lines = md.split("\n")
n = len(lines)
out: list[str] = []
i = 0
in_fence = False
while i < n:
line = lines[i]
if re.match(r"^\s*(```|~~~)", line):
in_fence = not in_fence
out.append(line)
i += 1
continue
if in_fence or not _RELATED_HEADER_RE.match(line.strip()):
out.append(line)
i += 1
continue
# --- inside a `## Related` section -------------------------------
header = line
j = i + 1
kept: list[str] = [] # surviving content lines of this section
while j < n:
cur = lines[j]
stripped = cur.strip()
if _H12_RE.match(cur): # next h1/h2 ends the Related section
break
if _SOURCES_HEADER_RE.match(stripped):
# Drop the `### Sources` header and everything until the next header.
j += 1
while j < n and not _H123_RE.match(lines[j]):
j += 1
continue
m = _BULLET_LINK_RE.match(stripped)
if m and _is_structural_target(m.group(1)):
j += 1 # drop the structural link bullet
continue
kept.append(cur)
j += 1
meaningful = [ln for ln in kept if ln.strip()]
if meaningful:
out.append(header)
out.extend(kept)
# else: drop the now-empty `## Related` section entirely.
i = j
cleaned = "\n".join(out)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).rstrip() + "\n"
return cleaned
async def _run(dry_run: bool, only_note_id: str | None) -> int:
from deerflow.config import get_app_config
from deerflow.knowledge import make_knowledge_service
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
config = get_app_config()
await init_engine_from_config(config.database)
sf = get_session_factory()
if sf is None:
logger.error("No database session factory — is the database backend configured?")
return 1
service = make_knowledge_service(sf, config.knowledge)
if service is None:
logger.error("Knowledge base disabled (knowledge.enabled=false) or memory backend; nothing to do.")
return 1
notes = await service.repo.list_all(include_archived=True)
if only_note_id:
notes = [n for n in notes if n.get("id") == only_note_id]
scanned = changed = 0
for note in notes:
scanned += 1
body = note.get("content_md") or ""
cleaned = clean_note_body(body)
if cleaned == body:
continue
changed += 1
nid = note.get("id")
title = note.get("title") or nid
if dry_run:
logger.info("[dry-run] would clean %s — %s (%d → %d chars)", nid, title, len(body), len(cleaned))
else:
await service.update_note(nid, content_md=cleaned, updated_by="cleanup_script")
logger.info("cleaned %s — %s", nid, title)
await close_engine()
logger.info("Done. scanned=%d, %s=%d", scanned, "would-change" if dry_run else "changed", changed)
return 0
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Clean legacy `## Related` structural links + `### Sources` from knowledge notes.")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing.")
parser.add_argument("--note-id", default=None, help="Only process this note id.")
args = parser.parse_args()
return asyncio.run(_run(args.dry_run, args.note_id))
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,264 @@
"""Drop the stale business tables from the SQLite file after the MySQL switch.
Context
-------
In SQLite mode the LangGraph checkpointer **and** the application share one
file: ``.deer-flow/data/deerflow.db``. After ``database.backend`` is switched
to ``mysql`` (and ``migrate_business_sqlite_to_mysql.py`` has copied the data
over), the business tables in ``deerflow.db`` are dead weight — the app now
reads them from MySQL — while the LangGraph checkpoint tables in the *same*
file are still actively used (``checkpointer.type`` stays ``sqlite``).
This script drops only the stale business tables and ``VACUUM``s to reclaim
the freed pages.
Safety design
-------------
* **Explicit DROP allowlist** — only the known business tables are dropped.
LangGraph tables (``checkpoints`` / ``writes`` / ``store`` /
``store_migrations``) and any unrecognised table are left untouched.
* **MySQL pre-check** — before dropping, it connects to MySQL and verifies
every business table exists there. A missing table aborts the run (the
migration looks incomplete). Skippable only via an explicit flag.
* **Dry-run by default** — nothing is changed unless ``--apply`` is passed.
* **Automatic backup** — ``--apply`` first copies ``deerflow.db`` to a
timestamped ``.bak`` file (disable with ``--no-backup``).
IMPORTANT: stop the backend first. ``DROP TABLE`` / ``VACUUM`` need an
exclusive lock on the SQLite file; with the server running they will fail
with "database is locked" (a safe failure — nothing is changed).
Usage
-----
# from backend/ , with the backend's interpreter (has the MySQL driver)
PYTHONPATH=. uv run python scripts/cleanup_sqlite_after_mysql_migration.py # dry-run
PYTHONPATH=. uv run python scripts/cleanup_sqlite_after_mysql_migration.py --apply # do it
"""
from __future__ import annotations
import argparse
import asyncio
import os
import re
import shutil
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
# Business tables migrated to MySQL — identical to BUSINESS_TABLES in
# migrate_business_sqlite_to_mysql.py. These are the DROP allowlist.
BUSINESS_TABLES: list[str] = [
"users",
"threads_meta",
"runs",
"run_events",
"feedback",
"agents",
"skills",
"scheduled_tasks",
"scheduled_task_subscriptions",
"scheduled_task_runs",
"scheduled_task_delivery_profiles",
"scheduler_threads",
"llm_call_metrics",
"notifications",
"recommended_questions",
"tags",
"tag_assignments",
"thread_shares",
]
# Newer business tables that may exist depending on deployment age. Dropped if
# present; absence is fine. They are NOT checked against MySQL (a fresh MySQL
# install creates them via create_all / Alembic, but an older SQLite file may
# never have had them).
OPTIONAL_BUSINESS_TABLES: list[str] = [
"ai_writing_sessions",
"article_types",
"tool_call_metrics",
]
# SQLite-side Alembic version tracker. After the MySQL switch the SQLite file
# only serves the checkpointer, which does not use Alembic — so this row is
# stale. Dropped, but never MySQL-checked.
ALEMBIC_TABLE = "alembic_version"
# LangGraph infrastructure tables — these stay on SQLite and must NEVER be
# dropped. The checkpointer/store keep using them.
KEEP_TABLES: set[str] = {"checkpoints", "writes", "store", "store_migrations"}
def _repo_root_from_script() -> Path:
# scripts/ -> backend/ -> offline-backend-20260512/
return Path(__file__).resolve().parents[2]
def _load_mysql_url(explicit: str | None) -> str | None:
if explicit:
return explicit
env = os.environ.get("MYSQL_DATABASE_URL")
if env:
return env
# Fall back to the .env next to the backend directory.
for candidate in (
_repo_root_from_script() / ".env",
Path(__file__).resolve().parents[1] / ".env",
):
if candidate.is_file():
for line in candidate.read_text(encoding="utf-8").splitlines():
if line.startswith("MYSQL_DATABASE_URL="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return None
def _normalize_async_url(url: str) -> str:
"""Force the URL onto the asyncmy async driver SQLAlchemy understands."""
if url.startswith(("mysql+asyncmy://", "mysql+aiomysql://")):
return url
return re.sub(r"^mysql(\+\w+)?://", "mysql+asyncmy://", url)
def _default_sqlite_path() -> Path:
return _repo_root_from_script() / "backend" / ".deer-flow" / "data" / "deerflow.db"
async def _mysql_existing_tables(url: str) -> set[str]:
"""Return the set of table names present in the MySQL business database."""
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import create_async_engine
async_url = _normalize_async_url(url)
db_name = make_url(async_url).database
engine = create_async_engine(async_url)
try:
async with engine.connect() as conn:
rows = await conn.execute(
text("SELECT table_name FROM information_schema.tables WHERE table_schema = :s"),
{"s": db_name},
)
return {r[0] for r in rows}
finally:
await engine.dispose()
def _sqlite_tables(con: sqlite3.Connection) -> list[str]:
return [r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
def _row_count(con: sqlite3.Connection, table: str) -> int:
return con.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--sqlite", default=str(_default_sqlite_path()), help="Path to deerflow.db")
parser.add_argument("--mysql-url", default=None, help="MySQL URL (default: $MYSQL_DATABASE_URL or .env)")
parser.add_argument("--apply", action="store_true", help="Actually drop tables + VACUUM (default: dry-run)")
parser.add_argument("--no-backup", action="store_true", help="Skip the pre-apply backup copy")
parser.add_argument("--skip-mysql-check", action="store_true", help="Do NOT verify tables exist in MySQL (unsafe)")
parser.add_argument("--drop-alembic", action="store_true", help="Also drop the stale SQLite alembic_version table")
args = parser.parse_args()
sqlite_path = Path(args.sqlite).resolve()
if not sqlite_path.is_file():
print(f"ERROR: SQLite file not found: {sqlite_path}")
return 1
size_before = sqlite_path.stat().st_size
print(f"SQLite file : {sqlite_path}")
print(f"Size : {size_before / 1024 / 1024:.1f} MB")
print(f"Mode : {'APPLY' if args.apply else 'DRY-RUN'}\n")
con = sqlite3.connect(sqlite_path)
try:
present = set(_sqlite_tables(con))
drop_business = [t for t in BUSINESS_TABLES if t in present]
drop_optional = [t for t in OPTIONAL_BUSINESS_TABLES if t in present]
to_drop = drop_business + drop_optional
if args.drop_alembic and ALEMBIC_TABLE in present:
to_drop.append(ALEMBIC_TABLE)
keep = sorted(present & KEEP_TABLES)
known = set(BUSINESS_TABLES) | set(OPTIONAL_BUSINESS_TABLES) | KEEP_TABLES | {ALEMBIC_TABLE}
unknown = sorted(present - known)
missing_business = [t for t in BUSINESS_TABLES if t not in present]
if missing_business:
print(f"NOTE: business tables already absent from SQLite: {', '.join(missing_business)}\n")
print("KEEP (LangGraph checkpointer / store - never dropped):")
for t in keep:
print(f" + {t:<34}{_row_count(con, t):>10} rows")
if unknown:
print("\nUNKNOWN (not recognised - left untouched, review manually):")
for t in unknown:
print(f" ? {t:<34}{_row_count(con, t):>10} rows")
print("\nDROP (stale business tables):")
for t in to_drop:
print(f" - {t:<34}{_row_count(con, t):>10} rows")
if not to_drop:
print(" (nothing to drop - already clean)")
return 0
# --- MySQL safety pre-check -------------------------------------
if args.skip_mysql_check:
print("\nWARNING: --skip-mysql-check set - NOT verifying the MySQL side.")
else:
mysql_url = _load_mysql_url(args.mysql_url)
if not mysql_url:
print("\nERROR: no MySQL URL ($MYSQL_DATABASE_URL / .env / --mysql-url).")
print(" Pass --skip-mysql-check to bypass (only if you are certain).")
return 1
print("\nVerifying business tables exist in MySQL ...")
try:
mysql_tables = asyncio.run(_mysql_existing_tables(mysql_url))
except Exception as exc: # noqa: BLE001
print(f"ERROR: could not query MySQL: {exc}")
print(" Run this on the backend host (MySQL access is host-restricted),")
print(" or pass --skip-mysql-check to bypass (only if you are certain).")
return 1
missing_in_mysql = [t for t in drop_business if t not in mysql_tables]
if missing_in_mysql:
print(f"ABORT: these business tables are MISSING in MySQL: {', '.join(missing_in_mysql)}")
print(" The migration looks incomplete - not dropping anything.")
return 1
print(f"OK - all {len(drop_business)} business tables present in MySQL.")
if not args.apply:
print("\nDry-run only. Re-run with --apply to drop the tables and VACUUM.")
return 0
# --- apply ------------------------------------------------------
if not args.no_backup:
backup = sqlite_path.with_name(
f"{sqlite_path.name}.bak-{datetime.now():%Y%m%d-%H%M%S}"
)
print(f"\nBacking up -> {backup}")
shutil.copy2(sqlite_path, backup)
print("Dropping tables ...")
for t in to_drop:
con.execute(f'DROP TABLE IF EXISTS "{t}"')
con.commit()
print("VACUUM (reclaiming space) ...")
con.isolation_level = None # VACUUM cannot run inside a transaction
con.execute("VACUUM")
finally:
con.close()
size_after = sqlite_path.stat().st_size
print(
f"\nDone. Size {size_before / 1024 / 1024:.1f} MB "
f"-> {size_after / 1024 / 1024:.1f} MB "
f"(reclaimed {(size_before - size_after) / 1024 / 1024:.1f} MB)"
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,114 @@
"""一次性巡检:把最新圆桌草稿里「后台作业 run」的关键字段 dump 到文件,
用于验证维度3 存储 + 报告 HTML + 席位 md 文件是否都对齐了。
用法(在 backend 目录):
PYTHONPATH=. uv run python scripts/inspect_latest_roundtable_run.py
输出写到 scripts/_roundtable_run_report.txt(UTF-8),直接打开看,避免 PowerShell GBK 乱码。
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
DB = Path(".deer-flow/data/deerflow.db")
OUT = Path("scripts/_roundtable_run_report.txt")
def _loads(v):
if isinstance(v, (bytes, bytearray)):
v = v.decode("utf-8", "replace")
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return v
return v
def main() -> None:
con = sqlite3.connect(str(DB))
con.row_factory = sqlite3.Row
lines: list[str] = []
# 最新一条有 step2.runs 的草稿
rows = con.execute(
"SELECT id, step2, step3 FROM roundtable_drafts ORDER BY updated_at DESC LIMIT 5"
).fetchall()
draft = None
top_step3 = None
for r in rows:
s2 = _loads(r["step2"]) or {}
if isinstance(s2, dict) and s2.get("runs"):
draft = (r["id"], s2)
top_step3 = _loads(r["step3"])
break
if not draft:
lines.append("没找到带 step2.runs 的草稿。")
OUT.write_text("\n".join(lines), encoding="utf-8")
print(f"written: {OUT}")
return
draft_id, s2 = draft
runs = s2.get("runs") or []
job_run = next((x for x in runs if str(x.get("id", "")).startswith("job-")), runs[-1])
lines.append(f"draft_id = {draft_id}")
lines.append(f"run.id = {job_run.get('id')} jobStatus = {job_run.get('jobStatus')} hasConsensus = {job_run.get('hasConsensus')}")
lines.append("")
# ── 维度3:threadIds ──
lines.append("【threadIds】(应为真实 map,不是 null)")
lines.append(json.dumps(job_run.get("threadIds"), ensure_ascii=False, indent=2))
lines.append("")
# ── 维度3:各对话的 steps / agentThreadId / time ──
lines.append("【step2RoundtableDialogues 摘要】")
for d in job_run.get("step2RoundtableDialogues") or []:
steps = d.get("steps") or []
step_keys = [s.get("key") for s in steps]
lines.append(
f"- [{d.get('sender')}] time={d.get('time')!r} thread={d.get('agentThreadId')!r} "
f"steps={step_keys} content_len={len(d.get('content') or '')}"
)
lines.append("")
# ── 席位 md 文件:seatStubMessages 里有没有 write_file ──
lines.append("【seatStubMessages】(席位写的文件 → 文件卡片数据源)")
stubs = job_run.get("seatStubMessages") or {}
if not stubs:
lines.append(" (空 —— 席位没有写文件 / 没采集到)")
for tid, msgs in stubs.items():
for m in msgs:
for tc in m.get("tool_calls") or []:
args = tc.get("args") or {}
lines.append(f" thread={tid} {tc.get('name')} path={args.get('path')!r} content_len={len(str(args.get('content') or ''))}")
lines.append("")
# ── 报告 HTML(P0)──
lines.append("【step3 报告】")
step3 = job_run.get("step3") or {}
html = step3.get("html") or ""
lines.append(f" html_len = {len(html)}")
lines.append(f" 含 <html 标签 = {'<html' in html.lower()} (True=真 HTML / False=纯文字回退=P0未解决)")
lines.append(f" summary = {(step3.get('summary') or '')[:120]!r}")
lines.append("")
# ── 顶层 draft.step3(结果绘制聊天记录靠它重建报告气泡)──
lines.append("【顶层 draft.step3】(前端 hydrateStep3 用它重建报告聊天记录气泡)")
if not top_step3:
lines.append(" ❌ 空 —— 报告聊天记录加载后会丢失(本次修复点:应非空)")
else:
th = (top_step3.get("html") or "") if isinstance(top_step3, dict) else ""
lines.append(f" ✅ 已存 html_len={len(th)} summary={(top_step3.get('summary') or '')[:80]!r}" if isinstance(top_step3, dict) else f" {top_step3!r}")
OUT.write_text("\n".join(lines), encoding="utf-8")
print(f"written: {OUT.resolve()}")
if __name__ == "__main__":
main()
@@ -0,0 +1,433 @@
"""Migrate DeerFlow business tables from SQLite to MySQL.
This intentionally excludes LangGraph checkpoint tables (``checkpoints`` and
``writes``). Keep those on SQLite unless/until a MySQL checkpointer is added.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sqlite3
import sys
from collections.abc import Iterable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import sqlalchemy as sa
from sqlalchemy import MetaData, text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import create_async_engine
BUSINESS_TABLES = [
"users",
"threads_meta",
"runs",
"run_events",
"feedback",
"agents",
"skills",
"scheduled_tasks",
"scheduled_task_subscriptions",
"scheduled_task_runs",
"scheduled_task_delivery_profiles",
"scheduler_threads",
"llm_call_metrics",
"notifications",
"recommended_questions",
"tags",
"tag_assignments",
"thread_shares",
]
JSON_COLUMNS = {
("threads_meta", "metadata_json"),
("runs", "metadata_json"),
("runs", "kwargs_json"),
("run_events", "event_metadata"),
("scheduled_tasks", "execution_context_json"),
("scheduled_task_runs", "result_json"),
("notifications", "payload"),
}
def _json_serializer(obj: object) -> str:
return json.dumps(obj, ensure_ascii=False)
def _add_vendor_path(vendor_dir: str | None) -> None:
candidates: list[Path] = []
if vendor_dir:
candidates.append(Path(vendor_dir))
if env_home := os.getenv("DEER_FLOW_HOME"):
candidates.append(Path(env_home) / "python-vendor")
candidates.append(Path(".deer-flow/python-vendor"))
for candidate in candidates:
resolved = candidate.resolve()
if resolved.exists():
value = str(resolved)
if value not in sys.path:
sys.path.insert(0, value)
def _normalize_mysql_url(raw: str) -> str:
if raw.startswith("mysql://"):
return raw.replace("mysql://", "mysql+asyncmy://", 1)
return raw
def _quote_sqlite_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def _sqlite_tables(conn: sqlite3.Connection) -> set[str]:
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
return {str(row[0]) for row in rows}
def _sqlite_columns(conn: sqlite3.Connection, table_name: str) -> list[str]:
rows = conn.execute(f"PRAGMA table_info({_quote_sqlite_identifier(table_name)})").fetchall()
return [str(row[1]) for row in rows]
def _sqlite_count(conn: sqlite3.Connection, table_name: str) -> int:
return int(conn.execute(f"SELECT COUNT(*) FROM {_quote_sqlite_identifier(table_name)}").fetchone()[0])
def _parse_datetime(value: Any) -> Any:
if value is None or isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=timezone.utc).replace(tzinfo=None)
if not isinstance(value, str):
return value
raw = value.strip()
if not raw:
return None
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
parsed = datetime.fromisoformat(raw)
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _parse_json(value: Any) -> Any:
if value is None or isinstance(value, (dict, list, int, float, bool)):
return value
if isinstance(value, bytes):
value = value.decode("utf-8")
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
return json.loads(raw)
return value
def _convert_value(table_name: str, column: sa.Column, value: Any) -> Any:
if value is None:
return None
if (table_name, column.name) in JSON_COLUMNS or isinstance(column.type, sa.JSON):
parsed = _parse_json(value)
if not isinstance(column.type, sa.JSON):
return json.dumps(parsed, ensure_ascii=False)
return parsed
if isinstance(column.type, sa.DateTime):
return _parse_datetime(value)
if isinstance(column.type, sa.Boolean):
return bool(value)
return value
def _build_rows(
*,
table_name: str,
table: sa.Table,
rows: Iterable[sqlite3.Row],
columns: list[str],
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for row in rows:
item: dict[str, Any] = {}
for column_name in columns:
column = table.c[column_name]
item[column_name] = _convert_value(table_name, column, row[column_name])
result.append(item)
return result
async def _create_database_if_needed(mysql_url: str) -> None:
parsed = make_url(mysql_url)
db_name = parsed.database
if not db_name:
raise ValueError("MySQL URL must include a database name")
maintenance = parsed.set(database="mysql")
engine = create_async_engine(
maintenance.render_as_string(hide_password=False),
isolation_level="AUTOCOMMIT",
pool_pre_ping=True,
)
quoted = "`" + db_name.replace("`", "``") + "`"
try:
async with engine.connect() as conn:
await conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {quoted} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"))
finally:
await engine.dispose()
async def _create_schema(mysql_url: str) -> None:
from deerflow.persistence.base import Base
import deerflow.persistence.models # noqa: F401
engine = create_async_engine(mysql_url, pool_pre_ping=True, json_serializer=_json_serializer)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
finally:
await engine.dispose()
def _quote_mysql_identifier(name: str) -> str:
return "`" + name.replace("`", "``") + "`"
async def _drop_empty_target_schema(mysql_url: str, table_names: list[str]) -> None:
"""Drop known target tables only when all existing known tables are empty.
This is intentionally conservative. It lets a failed first attempt clean up
half-created MySQL tables before re-running with fixed schema definitions,
while refusing to touch a database that already contains migrated rows.
"""
engine = create_async_engine(mysql_url, pool_pre_ping=True)
try:
async with engine.begin() as conn:
result = await conn.execute(
text(
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE()"
)
)
existing = {str(row[0]) for row in result}
candidates = [name for name in table_names if name in existing]
if not candidates:
return
non_empty: list[str] = []
for table_name in candidates:
count = int(
(
await conn.execute(
text(f"SELECT COUNT(*) FROM {_quote_mysql_identifier(table_name)}")
)
).scalar_one()
)
if count > 0:
non_empty.append(table_name)
if non_empty:
joined = ", ".join(non_empty)
print(f"Target MySQL has non-empty tables; skip empty-schema cleanup: {joined}")
return
await conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
try:
for table_name in reversed(candidates):
await conn.execute(text(f"DROP TABLE {_quote_mysql_identifier(table_name)}"))
print(f"dropped empty target table: {table_name}")
finally:
await conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
finally:
await engine.dispose()
def _run_alembic(mysql_url: str) -> None:
from alembic import command
from alembic.config import Config
import deerflow.persistence as persistence_pkg
migrations_dir = str(Path(persistence_pkg.__file__).parent / "migrations")
cfg = Config()
cfg.set_main_option("script_location", migrations_dir)
cfg.set_main_option("sqlalchemy.url", mysql_url.replace("%", "%%"))
command.upgrade(cfg, "head")
async def _reflect_target(mysql_url: str) -> tuple[sa.ext.asyncio.AsyncEngine, MetaData]:
engine = create_async_engine(
mysql_url,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
metadata = MetaData()
async with engine.begin() as conn:
await conn.run_sync(metadata.reflect)
return engine, metadata
async def _target_count(conn, table: sa.Table) -> int:
return int((await conn.execute(sa.select(sa.func.count()).select_from(table))).scalar_one())
async def _ensure_target_empty(engine, metadata: MetaData, table_names: list[str]) -> None:
non_empty: list[str] = []
async with engine.connect() as conn:
for table_name in table_names:
table = metadata.tables.get(table_name)
if table is not None and await _target_count(conn, table) > 0:
non_empty.append(table_name)
if non_empty:
joined = ", ".join(non_empty)
raise RuntimeError(f"Target MySQL tables are not empty: {joined}. Re-run with --truncate-target if this is intentional.")
async def _truncate_target(engine, metadata: MetaData, table_names: list[str]) -> None:
async with engine.begin() as conn:
await conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
try:
for table_name in reversed(table_names):
table = metadata.tables.get(table_name)
if table is not None:
await conn.execute(table.delete())
finally:
await conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
async def _copy_table(
*,
source: sqlite3.Connection,
engine,
metadata: MetaData,
table_name: str,
batch_size: int,
) -> tuple[int, int]:
table = metadata.tables.get(table_name)
if table is None:
print(f"skip {table_name}: not present in MySQL schema")
return 0, 0
source_columns = _sqlite_columns(source, table_name)
columns = [name for name in source_columns if name in table.c]
if not columns:
print(f"skip {table_name}: no shared columns")
return 0, 0
selected = ", ".join(_quote_sqlite_identifier(name) for name in columns)
query = f"SELECT {selected} FROM {_quote_sqlite_identifier(table_name)}"
cursor = source.execute(query)
copied = 0
async with engine.begin() as conn:
while True:
batch = cursor.fetchmany(batch_size)
if not batch:
break
payload = _build_rows(table_name=table_name, table=table, rows=batch, columns=columns)
if payload:
await conn.execute(table.insert(), payload)
copied += len(payload)
expected = _sqlite_count(source, table_name)
return expected, copied
async def _verify_counts(source: sqlite3.Connection, engine, metadata: MetaData, table_names: list[str]) -> None:
failures: list[str] = []
async with engine.connect() as conn:
for table_name in table_names:
table = metadata.tables.get(table_name)
if table is None:
continue
source_count = _sqlite_count(source, table_name)
target_count = await _target_count(conn, table)
if source_count != target_count:
failures.append(f"{table_name}: sqlite={source_count}, mysql={target_count}")
if failures:
raise RuntimeError("Row count verification failed:\n " + "\n ".join(failures))
async def _migrate_data(args: argparse.Namespace, mysql_url: str) -> None:
source_path = Path(args.sqlite).resolve()
if not source_path.is_file():
raise FileNotFoundError(f"SQLite database not found: {source_path}")
source = sqlite3.connect(str(source_path))
source.row_factory = sqlite3.Row
try:
source_tables = _sqlite_tables(source)
table_names = [name for name in BUSINESS_TABLES if name in source_tables]
missing = [name for name in BUSINESS_TABLES if name not in source_tables]
if missing:
print("SQLite tables not present, skipped: " + ", ".join(missing))
engine, metadata = await _reflect_target(mysql_url)
try:
if args.truncate_target:
await _truncate_target(engine, metadata, table_names)
else:
await _ensure_target_empty(engine, metadata, table_names)
for table_name in table_names:
expected, copied = await _copy_table(
source=source,
engine=engine,
metadata=metadata,
table_name=table_name,
batch_size=args.batch_size,
)
print(f"{table_name}: copied {copied}/{expected}")
await _verify_counts(source, engine, metadata, table_names)
finally:
await engine.dispose()
finally:
source.close()
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Migrate DeerFlow business tables from SQLite to MySQL")
parser.add_argument("--sqlite", required=True, help="Source SQLite deerflow.db path")
parser.add_argument("--mysql-url", default=os.getenv("MYSQL_DATABASE_URL", ""), help="Target MySQL SQLAlchemy URL")
parser.add_argument("--vendor-dir", default="", help="Optional Python vendor directory containing asyncmy")
parser.add_argument("--batch-size", type=int, default=1000)
parser.add_argument("--create-database", action="store_true", help="Create the target MySQL database if it does not exist")
parser.add_argument("--drop-empty-target-schema", action="store_true", help="Drop known target tables first, but only when all are empty")
parser.add_argument("--skip-schema", action="store_true", help="Do not create/update target schema before copying")
parser.add_argument("--truncate-target", action="store_true", help="Delete existing target rows before copying")
return parser.parse_args()
def main() -> None:
args = _parse_args()
_add_vendor_path(args.vendor_dir)
mysql_url = _normalize_mysql_url(args.mysql_url.strip())
if not mysql_url:
raise SystemExit("Missing --mysql-url or MYSQL_DATABASE_URL")
if args.create_database:
asyncio.run(_create_database_if_needed(mysql_url))
if not args.skip_schema:
if args.drop_empty_target_schema:
asyncio.run(_drop_empty_target_schema(mysql_url, BUSINESS_TABLES + ["alembic_version"]))
asyncio.run(_create_schema(mysql_url))
_run_alembic(mysql_url)
asyncio.run(_migrate_data(args, mysql_url))
print("Business table migration completed successfully.")
if __name__ == "__main__":
main()
@@ -0,0 +1,259 @@
"""一次性迁移:把旧版 memory.json 转换为 Memory V2 的 USER.md / MEMORY.md。
用法
====
::
# dry-run,只打印将要做什么,不写盘
PYTHONPATH=. python scripts/migrate_memory_to_v2.py --dry-run
# 迁移单个用户
PYTHONPATH=. python scripts/migrate_memory_to_v2.py --user-id default
# 迁移所有用户 (.deer-flow/users/*/memory.json 及 per-agent)
PYTHONPATH=. python scripts/migrate_memory_to_v2.py --all
转换规则 (详见 docs/MEMORY_V2_DESIGN_ZH.md §13)
================================================
- ``user.{workContext,personalContext,topOfMind}.summary`` -> USER.md 条目
- ``history.{recentMonths,earlierContext,longTermBackground}.summary`` -> USER.md 条目
(设计上 history 属于 Hindsight user bank,但 P1 期无 Hindsight,先落到 USER.md 保数据)
- ``facts[]`` category=preference -> USER.md;其余 (knowledge/context/behavior/goal) -> MEMORY.md
- per-agent memory.json (``users/{uid}/agents/{agent}/memory.json``) 的 facts 落到该 agent 的 MEMORY.md
迁移完成后,原 ``memory.json`` 重命名为 ``memory.json.bak`` 供回滚。
脚本幂等:已迁移 (存在 .bak) 的文件会被跳过。
Hindsight 灌库 (P2)
===================
本脚本只迁移本地文件。把这些记忆灌进 Hindsight bank 的步骤在 P2 期实现。
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Any
from deerflow.agents.memory.providers.builtin import ENTRY_DELIMITER
from deerflow.config.paths import Paths, get_paths
logger = logging.getLogger(__name__)
# 落到 USER.md 的 fact category;其余都落到 MEMORY.md。
_USER_FACT_CATEGORIES: frozenset[str] = frozenset({"preference", "personal_info", "identity"})
# user / history 各 summary 字段及其在 USER.md 里的前缀。
_CONTEXT_SECTIONS: dict[str, dict[str, str]] = {
"user": {
"workContext": "[工作背景]",
"personalContext": "[个人背景]",
"topOfMind": "[当前关注]",
},
"history": {
"recentMonths": "[近期历史]",
"earlierContext": "[较早历史]",
"longTermBackground": "[长期背景]",
},
}
def _load_memory_json(path: Path) -> dict[str, Any] | None:
"""读取一个旧版 memory.json;不存在或解析失败返回 ``None``。"""
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError) as exc:
logger.warning("读取 %s 失败:%s", path, exc)
return None
def _extract_entries(data: dict[str, Any]) -> tuple[list[str], list[str]]:
"""把一份旧版 memory.json 拆成 (USER.md 条目, MEMORY.md 条目)。"""
user_entries: list[str] = []
memory_entries: list[str] = []
# user.* 与 history.* 的 summary
for section_key, fields in _CONTEXT_SECTIONS.items():
section = data.get(section_key) or {}
if not isinstance(section, dict):
continue
for field, prefix in fields.items():
node = section.get(field) or {}
summary = (node.get("summary") if isinstance(node, dict) else "") or ""
summary = summary.strip()
if summary:
user_entries.append(f"{prefix} {summary}")
# facts[] 按 category 路由
for fact in data.get("facts", []) or []:
if not isinstance(fact, dict):
continue
content = (fact.get("content") or "").strip()
if not content:
continue
category = (fact.get("category") or "context").strip().lower()
if category in _USER_FACT_CATEGORIES:
user_entries.append(content)
else:
memory_entries.append(content)
# 去重,保序
return list(dict.fromkeys(user_entries)), list(dict.fromkeys(memory_entries))
def _append_md_file(path: Path, new_entries: list[str], *, dry_run: bool) -> int:
"""把条目追加进一个 V2 markdown 文件 (USER.md / MEMORY.md),返回实际新增数。
已存在的条目跳过。dry-run 时不写盘。
"""
if not new_entries:
return 0
existing: list[str] = []
if path.exists():
raw = path.read_text(encoding="utf-8")
existing = [e.strip() for e in raw.split(ENTRY_DELIMITER) if e.strip()]
merged = list(existing)
added = 0
for entry in new_entries:
if entry not in merged:
merged.append(entry)
added += 1
if added and not dry_run:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(ENTRY_DELIMITER.join(merged), encoding="utf-8")
return added
def _migrate_one(
memory_json: Path,
user_md: Path,
memory_md: Path,
*,
dry_run: bool,
) -> dict[str, Any]:
"""迁移一份 memory.json。返回一条迁移报告。"""
report: dict[str, Any] = {"source": str(memory_json), "action": "", "user_added": 0, "memory_added": 0}
bak = memory_json.with_suffix(memory_json.suffix + ".bak")
if bak.exists():
report["action"] = "skipped (已存在 .bak,视为已迁移)"
return report
data = _load_memory_json(memory_json)
if data is None:
report["action"] = "skipped (文件不存在或无法解析)"
return report
user_entries, memory_entries = _extract_entries(data)
report["user_added"] = _append_md_file(user_md, user_entries, dry_run=dry_run)
report["memory_added"] = _append_md_file(memory_md, memory_entries, dry_run=dry_run)
if not dry_run:
memory_json.rename(bak)
report["action"] = f"migrated -> {user_md.name}/{memory_md.name};原文件改名 {bak.name}"
else:
report["action"] = f"would migrate -> {user_md.name}/{memory_md.name}"
return report
def _discover_user_ids(paths: Paths) -> list[str]:
"""枚举 .deer-flow/users/ 下的所有 user_id。"""
users_root = paths.base_dir / "users"
if not users_root.is_dir():
return []
return sorted(d.name for d in users_root.iterdir() if d.is_dir())
def migrate_user(paths: Paths, user_id: str, *, dry_run: bool) -> list[dict[str, Any]]:
"""迁移单个用户的全局 memory.json 与所有 per-agent memory.json。"""
reports: list[dict[str, Any]] = []
# 全局 memory.json -> USER.md + default agent 的 MEMORY.md
global_json = paths.user_memory_file(user_id)
reports.append(
_migrate_one(
global_json,
paths.user_profile_md_file(user_id),
paths.user_agent_memory_md_file(user_id, "default"),
dry_run=dry_run,
)
)
# per-agent memory.json -> USER.md (preference facts) + 该 agent 的 MEMORY.md
agents_root = paths.user_dir(user_id) / "agents"
if agents_root.is_dir():
for agent_dir in sorted(agents_root.iterdir()):
if not agent_dir.is_dir():
continue
agent_json = agent_dir / "memory.json"
if not agent_json.exists():
continue
reports.append(
_migrate_one(
agent_json,
paths.user_profile_md_file(user_id),
paths.user_agent_memory_md_file(user_id, agent_dir.name),
dry_run=dry_run,
)
)
return reports
def main(argv: list[str] | None = None) -> int:
"""命令行入口。"""
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="把旧版 memory.json 迁移为 Memory V2 的 USER.md / MEMORY.md")
parser.add_argument("--dry-run", action="store_true", help="只打印将要做什么,不写盘")
parser.add_argument("--user-id", help="只迁移指定用户")
parser.add_argument("--all", action="store_true", help="迁移所有用户")
parser.add_argument("--base-dir", help="覆盖 .deer-flow 根目录 (默认从 DEER_FLOW_HOME 解析)")
parser.add_argument(
"--skip-hindsight",
action="store_true",
help="只迁移本地文件,不灌 Hindsight (P1 期本就如此,该选项为 P2 预留)",
)
args = parser.parse_args(argv)
if not args.all and not args.user_id:
parser.error("必须指定 --all 或 --user-id")
paths = Paths(base_dir=args.base_dir) if args.base_dir else get_paths()
if args.all:
user_ids = _discover_user_ids(paths)
if not user_ids:
logger.info("未发现任何用户目录,无需迁移。")
return 0
else:
user_ids = [args.user_id]
all_reports: list[dict[str, Any]] = []
for user_id in user_ids:
logger.info("=== 用户 %s ===", user_id)
for report in migrate_user(paths, user_id, dry_run=args.dry_run):
all_reports.append(report)
logger.info(
" %s | %s | USER +%d, MEMORY +%d",
report["action"],
report["source"],
report["user_added"],
report["memory_added"],
)
total_user = sum(r["user_added"] for r in all_reports)
total_memory = sum(r["memory_added"] for r in all_reports)
mode = "DRY-RUN(未写盘)" if args.dry_run else "已完成"
logger.info("迁移%s:USER.md 共新增 %d 条,MEMORY.md 共新增 %d 条。", mode, total_user, total_memory)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,127 @@
"""一次性迁移:把已有的 taskId 会商记录从个人草稿表搬进独立的 task 存储。
背景
====
早期 taskId 深链会商记录混存在按用户分权的 ``roundtable_drafts`` 表里(带一个
``task_id`` 列 + 放开归属校验)。现在 taskId 聊天记录有了独立、不分权的存储
``roundtable_task_drafts``(只按 ``task_id`` 区分,跨用户共享)。本脚本把历史数据搬过去:
1. ``roundtable_drafts`` 中 ``task_id`` 非空的每一行 → 在 ``roundtable_task_drafts``
建同 id 的记录(``created_by`` = 原 ``user_id``,审计用);
2. 这些草稿对应的 ``roundtable_jobs`` 行(``draft_id`` 匹配且 ``task_id`` 为空)补上
``task_id``,使其转为「task 作业」(按 task 共享);
3. 从 ``roundtable_drafts`` 删除已迁走的行,使个人历史不再混入 task 记录。
幂等:目标表已存在同 id 记录则跳过;可重复运行。alembic 自动升级在运行时是关闭的
(见 ``persistence/engine.py``),所以这步数据搬迁用本脚本手动执行。
用法(在 backend 目录)
======================
::
# 预览(不写盘)
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py --dry-run
# 执行
PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("migrate_roundtable_task_drafts")
async def _run(dry_run: bool) -> int:
from sqlalchemy import select, update
from deerflow.config import get_app_config
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.roundtable_drafts.model import RoundtableDraftRow
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
config = get_app_config()
await init_engine_from_config(config.database)
sf = get_session_factory()
moved = 0
skipped = 0
jobs_tagged = 0
try:
async with sf() as session:
rows = (
await session.execute(
select(RoundtableDraftRow).where(RoundtableDraftRow.task_id.is_not(None))
)
).scalars().all()
logger.info("发现 %d 条带 task_id 的个人草稿待迁移。", len(rows))
for row in rows:
# 幂等:目标已存在同 id → 跳过(仍尝试给作业补 task_id + 删源行)。
exists = await session.get(RoundtableTaskDraftRow, row.id)
if exists is None:
if not dry_run:
session.add(
RoundtableTaskDraftRow(
id=row.id,
task_id=row.task_id,
created_by=row.user_id,
title=row.title or "",
furthest_step=row.furthest_step or 1,
step1=row.step1,
step2=row.step2,
step3=row.step3,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
moved += 1
logger.info(" 迁移 draft %s (task_id=%s)", row.id, row.task_id)
else:
skipped += 1
logger.info(" 跳过 draft %s (目标已存在)", row.id)
# 关联后台作业补 task_id(未标记的才动)。
if not dry_run:
res = await session.execute(
update(RoundtableJobRow)
.where(RoundtableJobRow.draft_id == row.id)
.where(RoundtableJobRow.task_id.is_(None))
.values(task_id=row.task_id)
)
jobs_tagged += int(res.rowcount or 0)
# 删除源行(task 记录不再留在个人表)。
if not dry_run:
await session.delete(row)
if not dry_run:
await session.commit()
logger.info(
"完成%s:迁移 %d 条,跳过 %d 条,关联作业打标 %d 条。",
"(dry-run,未写盘)" if dry_run else "",
moved,
skipped,
jobs_tagged,
)
return 0
finally:
await close_engine()
def main() -> int:
parser = argparse.ArgumentParser(description="迁移 taskId 会商记录到独立 task 存储")
parser.add_argument("--dry-run", action="store_true", help="只预览,不写盘")
args = parser.parse_args()
return asyncio.run(_run(args.dry_run))
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,159 @@
"""One-time migration: move legacy thread dirs and memory into per-user layout.
Usage:
PYTHONPATH=. python scripts/migrate_user_isolation.py [--dry-run]
The script is idempotent — re-running it after a successful migration is a no-op.
"""
import argparse
import logging
import shutil
from deerflow.config.paths import Paths, get_paths
logger = logging.getLogger(__name__)
def migrate_thread_dirs(
paths: Paths,
thread_owner_map: dict[str, str],
*,
dry_run: bool = False,
) -> list[dict]:
"""Move legacy thread directories into per-user layout.
Args:
paths: Paths instance.
thread_owner_map: Mapping of thread_id -> user_id from threads_meta table.
dry_run: If True, only log what would happen.
Returns:
List of migration report entries.
"""
report: list[dict] = []
legacy_threads = paths.base_dir / "threads"
if not legacy_threads.exists():
logger.info("No legacy threads directory found — nothing to migrate.")
return report
for thread_dir in sorted(legacy_threads.iterdir()):
if not thread_dir.is_dir():
continue
thread_id = thread_dir.name
user_id = thread_owner_map.get(thread_id, "default")
dest = paths.base_dir / "users" / user_id / "threads" / thread_id
entry = {"thread_id": thread_id, "user_id": user_id, "action": ""}
if dest.exists():
conflicts_dir = paths.base_dir / "migration-conflicts" / thread_id
entry["action"] = f"conflict -> {conflicts_dir}"
if not dry_run:
conflicts_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(thread_dir), str(conflicts_dir))
logger.warning("Conflict for thread %s: moved to %s", thread_id, conflicts_dir)
else:
entry["action"] = f"moved -> {dest}"
if not dry_run:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(thread_dir), str(dest))
logger.info("Migrated thread %s -> user %s", thread_id, user_id)
report.append(entry)
# Clean up empty legacy threads dir
if not dry_run and legacy_threads.exists() and not any(legacy_threads.iterdir()):
legacy_threads.rmdir()
return report
def migrate_memory(
paths: Paths,
user_id: str = "default",
*,
dry_run: bool = False,
) -> None:
"""Move legacy global memory.json into per-user layout.
Args:
paths: Paths instance.
user_id: Target user to receive the legacy memory.
dry_run: If True, only log.
"""
legacy_mem = paths.base_dir / "memory.json"
if not legacy_mem.exists():
logger.info("No legacy memory.json found — nothing to migrate.")
return
dest = paths.user_memory_file(user_id)
if dest.exists():
legacy_backup = paths.base_dir / "memory.legacy.json"
logger.warning("Destination %s exists; renaming legacy to %s", dest, legacy_backup)
if not dry_run:
legacy_mem.rename(legacy_backup)
return
logger.info("Migrating memory.json -> %s", dest)
if not dry_run:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(legacy_mem), str(dest))
def _build_owner_map_from_db(paths: Paths) -> dict[str, str]:
"""Query threads_meta table for thread_id -> user_id mapping.
Uses raw sqlite3 to avoid async dependencies.
"""
import sqlite3
db_path = paths.base_dir / "deer-flow.db"
if not db_path.exists():
logger.info("No database found at %s — using empty owner map.", db_path)
return {}
conn = sqlite3.connect(str(db_path))
try:
cursor = conn.execute("SELECT thread_id, user_id FROM threads_meta WHERE user_id IS NOT NULL")
return {row[0]: row[1] for row in cursor.fetchall()}
except sqlite3.OperationalError as e:
logger.warning("Failed to query threads_meta: %s", e)
return {}
finally:
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Migrate DeerFlow data to per-user layout")
parser.add_argument("--dry-run", action="store_true", help="Log actions without making changes")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
paths = get_paths()
logger.info("Base directory: %s", paths.base_dir)
logger.info("Dry run: %s", args.dry_run)
owner_map = _build_owner_map_from_db(paths)
logger.info("Found %d thread ownership records in DB", len(owner_map))
report = migrate_thread_dirs(paths, owner_map, dry_run=args.dry_run)
migrate_memory(paths, user_id="default", dry_run=args.dry_run)
if report:
logger.info("Migration report:")
for entry in report:
logger.info(" thread=%s user=%s action=%s", entry["thread_id"], entry["user_id"], entry["action"])
else:
logger.info("No threads to migrate.")
unowned = [e for e in report if e["user_id"] == "default"]
if unowned:
logger.warning("%d thread(s) had no owner and were assigned to 'default':", len(unowned))
for e in unowned:
logger.warning(" %s", e["thread_id"])
if __name__ == "__main__":
main()
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Offline runtime smoke test for the LocalSandbox Office toolchain."""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
def require_command(name: str) -> str:
path = shutil.which(name)
if path is None:
raise RuntimeError(f"missing command: {name}")
return path
def assert_contains(text: str, expected: str, label: str) -> None:
normalized = text.replace("\\_", "_")
if expected not in normalized:
raise RuntimeError(f"{label} conversion lost test text: {text[:300]!r}")
def main() -> int:
from docx import Document
from markitdown import MarkItDown
from openpyxl import Workbook
from pptx import Presentation
soffice = require_command("soffice")
require_command("pandoc")
require_command("pdftotext")
require_command("file")
subprocess.run(
[soffice, "--headless", "--version"],
check=True,
capture_output=True,
text=True,
timeout=30,
)
with tempfile.TemporaryDirectory(prefix="deerflow-office-selfcheck-") as temporary:
root = Path(temporary)
word_path = root / "word-test.docx"
document = Document()
document.add_heading("Word 自检", level=1)
document.add_paragraph("DEERFLOW_WORD_OK")
document.save(word_path)
excel_path = root / "excel-test.xlsx"
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "自检"
worksheet.append(["状态", "DEERFLOW_EXCEL_OK"])
workbook.save(excel_path)
ppt_path = root / "ppt-test.pptx"
presentation = Presentation()
slide = presentation.slides.add_slide(presentation.slide_layouts[5])
slide.shapes.title.text = "DEERFLOW_PPT_OK"
presentation.save(ppt_path)
converter = MarkItDown()
assert_contains(converter.convert(word_path).text_content, "DEERFLOW_WORD_OK", "Word")
assert_contains(converter.convert(excel_path).text_content, "DEERFLOW_EXCEL_OK", "Excel")
assert_contains(converter.convert(ppt_path).text_content, "DEERFLOW_PPT_OK", "PowerPoint")
print("OFFICE_RUNTIME_SELF_CHECK=PASS")
print("支持:DOC/DOCX、XLS/XLSX、PPT/PPTX、ODT/ODS/ODP、PDF")
print("组件:MarkItDown、python-docx、openpyxl、python-pptx、LibreOffice、Pandoc、中文字体")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,179 @@
"""AI 写作 assistant 冒烟脚本:验证 ai_writing graph 注册到 LangGraph Server 后,
interrupt/Command(resume=...)/state 持久化/custom stream 全链路是否跑通。
阶段 0 用名为 ai_writing_probe;阶段 1 起改为正式 assistant 名 ai_writing,本脚本随之切换。
前置条件:
1. langgraph.json 已注册 ai_writing(见 ai-writing-优化.md 阶段 1)。
2. 后端已通过 `make dev` 启动,Gateway + LangGraph Server 监听 8001。
3. config.yaml 配好至少一个可用 model,researcher 用到的 search_provider 也已配置
—— 否则 researcher 节点会失败(这是业务依赖,不是迁移问题,仍然算「跑通」)。
运行:
cd offline-backend-20260512/backend
PYTHONPATH=. uv run python scripts/probe_ai_writing_assistant.py
通过条件(与 ai-writing-优化.md §阶段 0 §通过条件 对齐):
- assistants 列表里能看到 ai_writing_probe
- 跑到 pause_material_confirm 能拿到 interrupt 事件
- Command(resume={"action": "confirm", ...}) 能成功续跑
- 重建客户端后 client.threads.get_state(thread_id) 仍能拿到上一阶段 state
"""
from __future__ import annotations
import asyncio
import os
import sys
from typing import Any
try:
from langgraph_sdk import get_client
except ImportError:
print("ERROR: langgraph-sdk 未安装。run: uv pip install langgraph-sdk", file=sys.stderr)
sys.exit(1)
ASSISTANT_ID = "ai_writing"
DEFAULT_URL = os.environ.get("DEERFLOW_LANGGRAPH_URL", "http://localhost:8001/api/langgraph")
def _banner(text: str) -> None:
print(f"\n{'=' * 8} {text} {'=' * 8}")
def _summarize_event(event: Any) -> str:
"""裁剪日志,避免一屏全是大段 markdown / 素材列表。"""
s = repr(event)
return s if len(s) <= 240 else s[:240] + "…"
async def _stream_until_interrupt(
client, thread_id: str, *, input_payload: dict | None = None, command: dict | None = None
) -> dict | None:
"""跑一次 runs.stream,捕获第一个 interrupt(含 pause payload)后返回,否则返回 None。
收到 interrupt 时把 payload 一并打出来,便于人工核对 4 个 pause 点的 schema。
"""
interrupt_payload: dict | None = None
kwargs: dict[str, Any] = {
"stream_mode": ["values", "messages-tuple", "custom", "updates"],
}
if input_payload is not None:
kwargs["input"] = input_payload
if command is not None:
kwargs["command"] = command
async for chunk in client.runs.stream(thread_id, ASSISTANT_ID, **kwargs):
# langgraph-sdk 把不同 mode 的事件混在同一 stream 里,按 chunk.event 分类。
event_name = getattr(chunk, "event", None) or (
chunk.get("event") if isinstance(chunk, dict) else None
)
data = getattr(chunk, "data", None) or (
chunk.get("data") if isinstance(chunk, dict) else None
)
print(f"[stream] event={event_name} data={_summarize_event(data)}")
# SDK 在遇到 interrupt() 时会发一个特殊事件 —— 不同版本字段名不同,做下兼容。
if event_name == "updates" and isinstance(data, dict):
for _node, node_update in data.items():
if isinstance(node_update, dict) and "__interrupt__" in node_update:
interrupt_payload = node_update["__interrupt__"]
if event_name in {"interrupt", "__interrupt__"}:
interrupt_payload = data
break
return interrupt_payload
async def main() -> int:
print(f"LangGraph URL = {DEFAULT_URL}")
client = get_client(url=DEFAULT_URL)
# ── Step 1: 确认 assistant 已注册 ──────────────────────────────────────────
_banner("Step 1: 列 assistant,确认 probe 已注册")
assistants = await client.assistants.search()
names = [a.get("name") or a.get("graph_id") for a in assistants]
print(f"已注册 assistants: {names}")
if not any(ASSISTANT_ID in (n or "") for n in names):
print(f"FAIL: 未找到 {ASSISTANT_ID},请检查 langgraph.json 注册是否生效。")
return 1
# ── Step 2: 创建 thread 并触发第一次 run ──────────────────────────────────
_banner("Step 2: 创建 thread + 启动写作(应停在 pause_material)")
thread = await client.threads.create()
thread_id = thread["thread_id"]
print(f"thread_id = {thread_id}")
start_input = {
"user_intent": "写一篇关于 LangGraph 多 worker 部署最佳实践的分析报道,目标读者是后端工程师,约 800 字",
"article_type": "analysis",
"target_audience": "后端工程师",
"word_count_target": 800,
"keyword_count": 4,
"writing_mode": "loose",
"max_revisions": 2,
"status": "researching",
"drafts": [],
"progress_events": [],
}
payload = await _stream_until_interrupt(client, thread_id, input_payload=start_input)
if not payload:
print("FAIL: 未收到 interrupt 事件 —— pause_material 可能没停住,或 researcher 抛错。")
return 2
print(f"\nPAUSE-1 payload = {_summarize_event(payload)}")
# ── Step 3: 确认素材,应停在 pause_outline ────────────────────────────────
_banner("Step 3: Command(resume) 确认素材(应停在 pause_outline)")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "confirm"}}, # 不指定 approvedMaterialIds → 默认全部通过
)
if not payload:
print("FAIL: 未停在 pause_outline。")
return 3
print(f"\nPAUSE-2 payload = {_summarize_event(payload)}")
# ── Step 4: 确认大纲,应停在 pause_draft 或 pause_section_help ───────────
_banner("Step 4: Command(resume) 确认大纲(应停在 pause_draft 或 pause_section_help)")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "confirm"}},
)
if not payload:
print("FAIL: 未停在 pause_draft / pause_section_help。")
return 4
print(f"\nPAUSE-3 payload = {_summarize_event(payload)}")
# ── Step 5: 定稿 ─────────────────────────────────────────────────────────
_banner("Step 5: Command(resume) finalize 草稿(应跑到 END)")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "finalize"}},
)
if payload:
print(f"NOTE: 收到额外 interrupt(可能编辑审核打回) payload = {_summarize_event(payload)}")
print("尝试强制定稿…")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "force_finalize", "overrideVerdict": True}},
)
if payload:
print("WARN: 强制定稿后仍有 interrupt,graph 路由可能有意外分支。")
# ── Step 6: 验证 state 持久化 ────────────────────────────────────────────
_banner("Step 6: 重新建客户端 client.threads.get_state(thread_id) 应能拿到最终 state")
client2 = get_client(url=DEFAULT_URL)
state = await client2.threads.get_state(thread_id)
values = state.get("values") or {}
final_status = values.get("status")
has_draft = bool(values.get("current_draft"))
print(f"final status = {final_status}, has_draft = {has_draft}, "
f"next nodes = {state.get('next')}")
if final_status not in {"done", "writing_draft", "revising"}:
print("WARN: 最终 status 不是 done,请人工确认上面流程是否正常。")
print("\nPROBE OK —— 阶段 0 验证通过的核心条件已成立(interrupt + resume + state 持久化)。")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
@@ -0,0 +1,110 @@
"""后台挂起「分层 DAG」实测脚本:带一个并行 stage 的业务链。
POST 启动一个 dag 作业(stage1 两席位并行 → stage2 → stage3),轮询打印每帧的
dispatchChain 节点状态,直观看到:①stage1 两席位**同时 active**(并行);②stage2/3
在前一 stage 全 done 后才 active(串行);③orchestrationPlan 在响应里回得来(持久化生效)。
用法:PYTHONPATH=. .venv/Scripts/python scripts/probe_roundtable_dag_job.py
环境:GATEWAY_URL 默认 http://127.0.0.1:8001;AUTH_COOKIE 可选。
"""
from __future__ import annotations
import asyncio
import os
import time
import httpx
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8001").rstrip("/")
AUTH_COOKIE = os.getenv("AUTH_COOKIE", "")
AUTH_BEARER = os.getenv("AUTH_BEARER", "")
# 4 个已 seed 的圆桌席位。stage1 = 风险审查 ‖ 方案设计(并行),stage2 = 执行规划,stage3 = 总结。
AGENTS = [
{"agentId": "roundtable-risk-review", "name": "风险审查"},
{"agentId": "roundtable-solution-design", "name": "方案设计"},
{"agentId": "roundtable-execution-plan", "name": "执行规划"},
{"agentId": "roundtable-summary", "name": "方案总结"},
]
PLAN = {
"mode": "dag",
"stages": [
{"id": "s0", "agentIds": ["roundtable-risk-review", "roundtable-solution-design"], "goal": "并行评估风险并产出初步方案"},
{"id": "s1", "agentIds": ["roundtable-execution-plan"], "goal": "据风险与方案制定执行规划"},
{"id": "s2", "agentIds": ["roundtable-summary"]},
],
"finalSynthesis": True,
"coordinatorPrompt": "先并行评估、再规划执行、最后总结,逐阶段承接。",
}
INTENT = "为一家中型制造企业设计一套数字化转型的整体方案"
TERMINAL = {"done", "error", "cancelled"}
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
if AUTH_COOKIE:
h["Cookie"] = AUTH_COOKIE
if AUTH_BEARER:
h["Authorization"] = f"Bearer {AUTH_BEARER}"
return h
def _chain_str(nodes: list[dict]) -> str:
sym = {"pending": ".", "active": ">RUN", "done": "DONE"}
return " ".join(f"{n['name']}={sym.get(n['state'], '?')}" for n in nodes)
async def main() -> None:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
print(f"[probe] POST {GATEWAY_URL}/api/roundtable-jobs mode=dag (stage1 并行)")
resp = await client.post(
f"{GATEWAY_URL}/api/roundtable-jobs",
headers=_headers(),
json={"intent": INTENT, "agents": AGENTS, "orchestrationMode": "dag", "orchestrationPlan": PLAN},
)
if resp.status_code != 201:
print(f"[probe] start failed: HTTP {resp.status_code}: {resp.text[:500]}")
return
job = resp.json()
job_id = job["id"]
# 验证 plan 在响应里回得来(持久化 + _to_response 生效)
plan_back = job.get("orchestrationPlan")
print(f"[probe] started {job_id} orchestrationMode={job.get('orchestrationMode')} "
f"orchestrationPlan stages={len((plan_back or {}).get('stages') or [])}")
last = ""
t0 = time.perf_counter()
saw_parallel = False
while True:
await asyncio.sleep(1.0)
r = await client.get(f"{GATEWAY_URL}/api/roundtable-jobs/{job_id}", headers=_headers())
if r.status_code != 200:
print(f"[probe] get failed: HTTP {r.status_code}: {r.text[:300]}")
return
j = r.json()
nodes = j.get("dispatchChain") or []
active_seats = [n["name"] for n in nodes if n["state"] == "active"]
if len(active_seats) >= 2:
saw_parallel = True
line = f"status={j['status']} phase={j['phase']} consensus={j['consensusPercentage']}% [{_chain_str(nodes)}]"
if line != last:
print(f"[probe] +{time.perf_counter() - t0:5.1f}s {line}")
last = line
if j["status"] in TERMINAL:
print(f"\n[probe] terminal: {j['status']}")
if j["status"] == "error":
print(f"[probe] error detail: {j.get('error')}")
# 对话顺序证明 stage 串行
seats = [d for d in (j.get("dialogues") or []) if d["role"] == "seat"]
print(f"[probe] 席位交付顺序: {[d['agentId'] for d in seats]}")
leaders = [d for d in (j.get("dialogues") or []) if d["role"] == "leader"]
print(f"[probe] 总控发言轮数: {len(leaders)}(3 stage 派活 + 1 收口 = 4)")
print(f"[probe] 是否观察到 stage 内并行(≥2 席位同时 active): {'是 ✅' if saw_parallel else '否 ❌'}")
sample = next((d["content"] for d in seats if d.get("content")), "")
print(f"[probe] 席位交付样例: {sample[:80]}")
return
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,100 @@
"""圆桌后台作业真网关 —— headless 验证脚本(Phase 3b)。
用法(在能起完整后端栈 + 配好真模型的环境):
1) 用真网关启动后端:
ROUNDTABLE_JOB_GATEWAY=inprocess make gateway
(或 docker 里给 gateway 容器加该环境变量)
2) 跑本脚本,它会:POST 启动一个作业 → 轮询 GET 打印进度 → 跑到 done 打印报告长度。
PYTHONPATH=. uv run python scripts/probe_roundtable_job.py
环境变量:
GATEWAY_URL 默认 http://127.0.0.1:8001
AUTH_COOKIE 若后端开启鉴权,传 "session=xxx" 之类的 Cookie 头
PROBE_INTENT 自定义任务意图
PROBE_MODE recommend(默认)| chain
说明:鉴权关闭的 dev 模式无需 AUTH_COOKIE。真网关会进程内驱动 roundtable-coordinator /
各席位 / roundtable-report 真模型,耗时取决于模型,可能数分钟。
"""
from __future__ import annotations
import asyncio
import os
import time
import httpx
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8001").rstrip("/")
AUTH_COOKIE = os.getenv("AUTH_COOKIE", "")
INTENT = os.getenv("PROBE_INTENT", "为一家中型制造企业设计一套数字化转型的整体方案")
MODE = os.getenv("PROBE_MODE", "recommend")
# 用 3 个内置圆桌席位(确保可见、已 seed)。
AGENTS = [
{"agentId": "roundtable-intelligence", "name": "情报收集"},
{"agentId": "roundtable-solution-design", "name": "方案设计"},
{"agentId": "roundtable-summary", "name": "总结陈述"},
]
TERMINAL = {"done", "error", "cancelled"}
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
if AUTH_COOKIE:
h["Cookie"] = AUTH_COOKIE
return h
async def main() -> None:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
print(f"[probe] POST {GATEWAY_URL}/api/roundtable-jobs mode={MODE}")
resp = await client.post(
f"{GATEWAY_URL}/api/roundtable-jobs",
headers=_headers(),
json={"intent": INTENT, "agents": AGENTS, "orchestrationMode": MODE},
)
if resp.status_code != 201:
print(f"[probe] start failed: HTTP {resp.status_code}: {resp.text[:500]}")
return
job = resp.json()
job_id = job["id"]
print(f"[probe] started job {job_id}")
last = ""
t0 = time.perf_counter()
while True:
await asyncio.sleep(2.0)
r = await client.get(f"{GATEWAY_URL}/api/roundtable-jobs/{job_id}", headers=_headers())
if r.status_code != 200:
print(f"[probe] get failed: HTTP {r.status_code}: {r.text[:300]}")
return
j = r.json()
line = (
f"status={j['status']} phase={j['phase']} cycle={j['cycle']} "
f"active={j.get('activeAgentId')} consensus={j['consensusPercentage']}%"
)
if line != last:
print(f"[probe] +{time.perf_counter() - t0:5.0f}s {line}")
last = line
if j["status"] in TERMINAL:
print(f"[probe] terminal: {j['status']}")
if j["status"] == "error":
print(f"[probe] error detail: {j.get('error')}")
# 读草稿 step3 验证报告已落库
if j.get("draftId"):
dr = await client.get(
f"{GATEWAY_URL}/api/roundtable-drafts/{j['draftId']}", headers=_headers()
)
if dr.status_code == 200:
step3 = dr.json().get("step3")
html_len = len((step3 or {}).get("html") or "")
print(f"[probe] draft.step3 html length = {html_len}")
return
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,233 @@
-- ============================================================================
-- 给【已存在的表】补列(ADD COLUMN)—— MySQL 手动执行版
-- ============================================================================
--
-- 背景:
-- 本项目的 alembic 自动迁移是【关闭】的(engine.py 中
-- `# await _run_alembic_upgrade(url)` 被注释)。`Base.metadata.create_all`
-- 只会建【新表】,对【已存在的表】既不会补建索引、也不会补加新列。
-- 因此当我们在某个 ORM model 上新增了一列时,【全新部署】会随建表一并带上,
-- 但【已有数据的 MySQL 部署】上这一列并不会自动出现,代码读写该列会报错。
--
-- 约定(重要):
-- 今后只要有代码给某张【已存在的表】新增字段(在 ORM model 上加列),就必须
-- 把对应的 `ADD COLUMN` 语句以下面的【幂等】写法补进本文件,并在迁移目录里
-- 保留对应的 alembic 版本文件,二者保持一致。运维在升级老库时整文件执行本脚本
-- 即可补齐所有缺失的列。
--
-- 用法:
-- 1) 先选中目标库(与 database.mysql_url 里的库名一致):
-- USE your_deerflow_db;
-- 2) 直接整文件执行即可。本脚本是【幂等】的:已存在的列会跳过,不会报错,
-- 可重复执行。
--
-- 注意:
-- * 列定义必须与 ORM model / alembic migration 完全一致(类型、长度、可空性)。
-- * 新增列一律建议 NULL 或带 DEFAULT,避免在大表上回填导致长时间锁表。
-- * MySQL 8.0 在线 DDL 默认 ALGORITHM=INPLACE 一般不阻塞写入,但仍建议低峰执行。
-- ============================================================================
-- ---- 通用幂等封装:仅当列不存在时才新增 --------------------------------------
DELIMITER //
DROP PROCEDURE IF EXISTS _add_column_if_missing //
CREATE PROCEDURE _add_column_if_missing(
IN p_table VARCHAR(64),
IN p_column VARCHAR(64),
IN p_definition VARCHAR(255) -- 列定义,如 'VARCHAR(128) NULL DEFAULT NULL'
)
BEGIN
DECLARE v_exists INT DEFAULT 0;
SELECT COUNT(*) INTO v_exists
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table
AND column_name = p_column;
IF v_exists = 0 THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT CONCAT('ADDED ', p_table, '.', p_column) AS result;
ELSE
SELECT CONCAT('SKIPPED ', p_table, '.', p_column, ' (already exists)') AS result;
END IF;
END //
DELIMITER ;
-- ---- skills ------------------------------------------------------------------
-- name_zh: 技能的中文显示名(用户配置或 LLM 生成),对应 migration 20260609_01。
-- ORM: SkillRow.name_zh = String(128), nullable=True。
CALL _add_column_if_missing('skills', 'name_zh', 'VARCHAR(128) NULL DEFAULT NULL');
-- detail: 技能的长文介绍,对应 migration 20260518_01。
-- ORM: SkillRow.detail = Text, nullable=True。
CALL _add_column_if_missing('skills', 'detail', 'TEXT NULL DEFAULT NULL');
-- square_id: 技能所属的发布广场(发布广场功能),对应 migration 20260611_01。
-- ORM: SkillRow.square_id = String(64), nullable=False, default=''。
CALL _add_column_if_missing('skills', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
-- always_on: 常驻(免压缩) —— 开启技能压缩后仍全文注入系统提示词,对应 migration 20260616_03。
-- ORM: SkillRow.always_on = Boolean, nullable=False, default=False。
CALL _add_column_if_missing('skills', 'always_on', 'TINYINT(1) NOT NULL DEFAULT 0');
-- ---- agents ------------------------------------------------------------------
-- square_id: 智能体所属的发布广场(发布广场功能),对应 migration 20260611_01。
-- ORM: AgentRow.square_id = String(64), nullable=False, default=''。
CALL _add_column_if_missing('agents', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
-- ---- roundtable_drafts -------------------------------------------------------
-- task_id: 外部任务 id(无界嵌入抽屉传入),对应 migration 20260611_01。非空时草稿
-- 按 task_id 共享、不再按 user_id 分权。ORM: RoundtableDraftRow.task_id =
-- String(128), nullable=True, index=True。
CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
-- orchestration_plan: dag 模式分层编排计划 JSON(stages/阶段目标/总控提示词/finalSynthesis),
-- 对应 migration 20260612_01。驱动后端 _run_dag + 前端大弹窗分层流程图。
-- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。
CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL');
-- seat_mode: 业务链条「默认席位执行模式」(flash/thinking/pro/ultra),对应 migration 20260615_01。
-- 选入研讨时弹窗默认用它(仍可临时改)。ORM: RoundtableChainRow.seat_mode = String(16), nullable。
CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL DEFAULT NULL');
-- gather_first: 业务链条「研讨前并行取数」开关,对应 migration 20260620_02。True=选入研讨时研讨前先
-- 跑一轮全席位并行取数(只调技能、不研讨),产出作为共享资料注入研讨轮。逐条 opt-in。
-- ORM: RoundtableChainRow.gather_first = Boolean, NOT NULL, server_default '0'。
CALL _add_column_if_missing('roundtable_chains', 'gather_first', 'TINYINT(1) NOT NULL DEFAULT 0');
-- enabled: 业务链条启停开关,对应 migration 20260819_02。True=启用(可选入研讨);
-- False=停用(管理列表仍可见)。默认启用,存量行补列后也为 True。
-- ORM: RoundtableChainRow.enabled = Boolean, NOT NULL, server_default '1'。
CALL _add_column_if_missing('roundtable_chains', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1');
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
-- 以下是给【已存在的表】补的列:
-- users.position_id: 用户所属岗位(1:1)。ORM: UserRow.position_id = String(64) NULL。
CALL _add_column_if_missing('users', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
-- agent_favorites: origin 区分用户自收藏 vs 岗位授予;position_id 记授予岗位。
CALL _add_column_if_missing('agent_favorites', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
CALL _add_column_if_missing('agent_favorites', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
-- scheduled_task_subscriptions: 同上。
CALL _add_column_if_missing('scheduled_task_subscriptions', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
-- knowledge_notes.folder: 用户自建目录(斜杠分隔路径),驱动产品目录树;NULL=未归类。
-- (knowledge_folders / knowledge_extract_templates 为【新表】,由 create_all 自动建,无需在此补列。)
CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFAULT NULL');
-- ---- taskId 深链会商独立存储,对应 migration 20260618_01 ----------------------
-- roundtable_task_drafts 是【新表】(taskId 聊天记录,只按 task_id 不分权),由 create_all
-- 自动建,无需补列。以下给【已存在的】roundtable_jobs 补 task_id 列:
-- task_id: 非空 = task 作业(按 task 共享、不按 user 分权,报告写回 roundtable_task_drafts)。
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
-- ---- position_roundtable_sessions: opt-in task sharing, migration 20260808_01 ----
-- Existing records remain personal (`task_scoped = 0`). New records become
-- shared only when the caller explicitly sends a non-empty route taskId.
CALL _add_column_if_missing('position_roundtable_sessions', 'task_scoped', 'TINYINT(1) NOT NULL DEFAULT 0');
-- ---- 按钮管理「外部链接」追加登录参数,对应 migration 20260625_01 ----------------
-- login_params_json: url 外链追加的登录参数列表 JSON([{key,source,custom_value},...]),
-- 由前端运行时从 userInfo 解析后拼到地址尾部;NULL/空 = 不追加(旧行为)。
-- ORM: TaskButtonRow.login_params_json = PortableJSON()(MySQL 落 TEXT)。
CALL _add_column_if_missing('task_buttons', 'login_params_json', 'TEXT NULL DEFAULT NULL');
-- ---- users.approved: 登录白名单放行标记(黑名单模式),对应 migration 20260625_03 ----
-- 系统设置 login_whitelist.enabled 打开后,仅被管理员显式「取消放行」(approved=0) 的
-- 用户被拦;新账号(含从未登录过的用户)默认 1(已放行),admin 永远放行。DEFAULT 1 让
-- 存量用户(执行本脚本补列时)一并置为已放行,避免首次打开开关把老用户锁死。
-- ORM: UserRow.approved = Boolean, NOT NULL, default True, server_default '1'。
CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1');
-- ---- users.login_password: 账号级「登录口令」(明文),对应 migration 20260625_04 ----
-- 管理员在白名单管理页给个别账号设置的明文登录口令。非空 → 该账号用户名直登
-- (/api/v1/auth/login/username) 必须带匹配的 ?password=(优先于全局共享口令门);
-- NULL/空 → 走原有共享口令门/免口令逻辑。明文存储以便随时复制完整登录链接。
-- ORM: UserRow.login_password = String(255), nullable。
CALL _add_column_if_missing('users', 'login_password', 'VARCHAR(255) NULL DEFAULT NULL');
-- ---- compact_menu_overrides.menu_type: 简介模式菜单分套(A/B),对应 migration 20260819_01 ----
-- 两套网页部署各自一份左侧简介菜单。存量行视为类型 A;类型 B 的 node_id 带 B:: 前缀,
-- 以免在仍以 node_id 为单列主键的旧库上撞主键。
-- ORM: CompactMenuOverrideRow.menu_type = String(32), NOT NULL, default A, server_default A。
CALL _add_column_if_missing('compact_menu_overrides', 'menu_type', "VARCHAR(32) NOT NULL DEFAULT 'A'");
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _add_column_if_missing;
-- ---- roundtable_drafts.task_id 索引(幂等) -----------------------------------
SET @idx_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'roundtable_drafts'
AND index_name = 'ix_roundtable_drafts_task_id'
);
SET @idx_ddl = IF(@idx_exists = 0,
'CREATE INDEX `ix_roundtable_drafts_task_id` ON `roundtable_drafts` (`task_id`)',
'SELECT ''SKIPPED ix_roundtable_drafts_task_id (already exists)'' AS result');
PREPARE stmt FROM @idx_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---- knowledge_notes.folder 索引(幂等) --------------------------------------
SET @idx_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'knowledge_notes'
AND index_name = 'ix_knowledge_notes_folder'
);
SET @idx_ddl = IF(@idx_exists = 0,
'CREATE INDEX `ix_knowledge_notes_folder` ON `knowledge_notes` (`folder`(191))',
'SELECT ''SKIPPED ix_knowledge_notes_folder (already exists)'' AS result');
PREPARE stmt FROM @idx_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---- roundtable_jobs.task_id 索引(幂等) -------------------------------------
SET @idx_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'roundtable_jobs'
AND index_name = 'ix_roundtable_jobs_task_id'
);
SET @idx_ddl = IF(@idx_exists = 0,
'CREATE INDEX `ix_roundtable_jobs_task_id` ON `roundtable_jobs` (`task_id`)',
'SELECT ''SKIPPED ix_roundtable_jobs_task_id (already exists)'' AS result');
PREPARE stmt FROM @idx_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---- task_buttons ------------------------------------------------------------
-- 任务工作区跳转按钮的「追加登录态」选项,对应 migration 20260625_02。url 按钮可在外链
-- 后追加 token(token 登录的上游 token)+ username(登录用户名),供外部系统识别当前用户。
-- ORM: TaskButtonRow.append_auth = Boolean NOT NULL default False;
-- auth_token_param / auth_name_param = String(64), nullable, 读时归一化为 token/username。
CALL _add_column_if_missing('task_buttons', 'append_auth', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL _add_column_if_missing('task_buttons', 'auth_token_param', "VARCHAR(64) NULL DEFAULT 'token'");
CALL _add_column_if_missing('task_buttons', 'auth_name_param', "VARCHAR(64) NULL DEFAULT 'username'");
-- ---- report_structures.retrieval_directions ---------------------------------
-- 报告结构可选「检索方向」JSON 列表。空 / NULL = 沿用原收集员拆词检索;
-- 非空则素材收集按指定方向检索。ORM: PortableJSON() 在 MySQL 落 TEXT。
CALL _add_column_if_missing('report_structures', 'retrieval_directions', 'TEXT NULL');
-- ---- report_structures.retrieval_skills -------------------------------------
-- 报告结构可选「检索来源」JSON 列表(技能名)。空 / NULL = 沿用原收集员工具;
-- 非空则素材收集调用指定技能。ORM: PortableJSON() 在 MySQL 落 TEXT。
CALL _add_column_if_missing('report_structures', 'retrieval_skills', 'TEXT NULL');
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id', 'always_on'))
OR (table_name = 'agents' AND column_name IN ('square_id'))
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type')
OR (table_name = 'roundtable_chains' AND column_name = 'enabled')
OR (table_name = 'report_structures' AND column_name IN ('retrieval_directions', 'retrieval_skills')))
ORDER BY table_name, column_name;
@@ -0,0 +1,102 @@
-- ============================================================================
-- 排行榜(admin leaderboard)统计所需的复合索引 —— MySQL 手动执行版
-- ============================================================================
--
-- 背景:
-- 这些索引声明在各 ORM model 的 __table_args__ 里,但 Base.metadata.create_all
-- 对【已存在的表】不会补建索引。本项目虽会在启动时运行 Alembic,但大表
-- 补索引刻意不放进启动迁移,避免在线建索引拖长或阻塞服务启动。
-- 因此在【已有数据的 MySQL 部署】上需要低峰期执行本脚本,否则排行榜的
-- 单日聚合查询会退化为全表扫描,拖慢并占用连接。
--
-- 用法:
-- 1) 先选中目标库(与 database.mysql_url 里的库名一致):
-- USE your_deerflow_db;
-- 2) 直接整文件执行即可。本脚本是【幂等】的:已存在的索引会跳过,不会报错,
-- 可重复执行。
--
-- 注意:
-- * 大表上建索引可能耗时;MySQL 8.0 在线 DDL 默认 ALGORITHM=INPLACE 一般不阻塞
-- 写入,但仍建议在低峰期执行,或先在备库/预发验证。
-- * 涉及列均为 String(64) / String(20) 等短列,utf8mb4 下复合索引长度远低于
-- 3072 字节上限,无需前缀索引。
-- * 索引名与 migration 20260608_01 保持一致,日后若启用 alembic 也不会重复创建。
-- ============================================================================
-- ---- 通用幂等封装:仅当索引不存在时才创建 --------------------------------------
DELIMITER //
DROP PROCEDURE IF EXISTS _create_index_if_missing //
CREATE PROCEDURE _create_index_if_missing(
IN p_table VARCHAR(64),
IN p_index VARCHAR(64),
IN p_columns VARCHAR(255)
)
BEGIN
DECLARE v_exists INT DEFAULT 0;
SELECT COUNT(*) INTO v_exists
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = p_table
AND index_name = p_index;
IF v_exists = 0 THEN
SET @ddl = CONCAT('CREATE INDEX `', p_index, '` ON `', p_table, '` (', p_columns, ')');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT CONCAT('CREATED ', p_table, '.', p_index) AS result;
ELSE
SELECT CONCAT('SKIPPED ', p_table, '.', p_index, ' (already exists)') AS result;
END IF;
END //
DELIMITER ;
-- ---- runs ---------------------------------------------------------------------
CALL _create_index_if_missing('runs', 'ix_runs_created_user_status', '`created_at`, `user_id`, `status`');
CALL _create_index_if_missing('runs', 'ix_runs_user_created', '`user_id`, `created_at`');
CALL _create_index_if_missing('runs', 'ix_runs_thread_created', '`thread_id`, `created_at`');
CALL _create_index_if_missing('runs', 'ix_runs_created_assistant_user', '`created_at`, `assistant_id`, `user_id`');
CALL _create_index_if_missing('runs', 'ix_runs_created_model_user', '`created_at`, `model_name`, `user_id`');
-- ---- tool_call_metrics -------------------------------------------------------
CALL _create_index_if_missing('tool_call_metrics', 'ix_tool_call_metrics_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('tool_call_metrics', 'ix_tool_call_metrics_run_created', '`run_id`, `created_at`');
CALL _create_index_if_missing('tool_call_metrics', 'ix_tool_call_metrics_created_skill_user', '`created_at`, `skill_name`, `user_id`');
CALL _create_index_if_missing('tool_call_metrics', 'ix_tool_call_metrics_created_tool_user', '`created_at`, `tool_name`, `user_id`');
-- ---- llm_call_metrics --------------------------------------------------------
CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_run_created', '`run_id`, `created_at`');
CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_created_model_user', '`created_at`, `model_name`, `user_id`');
-- ---- threads_meta ------------------------------------------------------------
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_user_created', '`user_id`, `created_at`');
-- ---- scheduled_task_runs -----------------------------------------------------
-- NOT EXISTS anti-join used when the leaderboard excludes scheduler runs.
CALL _create_index_if_missing('scheduled_task_runs', 'ix_scheduled_task_runs_agent_run_id', '`agent_run_id`');
-- ---- position_roundtable_sessions --------------------------------------------
-- Explicit taskId history is loaded by taskId and newest update time.
CALL _create_index_if_missing('position_roundtable_sessions', 'ix_position_roundtable_sessions_task_updated', '`external_task_id`, `updated_at`');
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _create_index_if_missing;
-- ---- 执行后自检:确认排行榜索引都已存在 ----------------------------------------
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN (
'ix_runs_created_user_status', 'ix_runs_user_created', 'ix_runs_thread_created',
'ix_runs_created_assistant_user', 'ix_runs_created_model_user',
'ix_tool_call_metrics_created_user', 'ix_tool_call_metrics_run_created',
'ix_tool_call_metrics_created_skill_user', 'ix_tool_call_metrics_created_tool_user',
'ix_llm_call_metrics_created_user', 'ix_llm_call_metrics_run_created',
'ix_llm_call_metrics_created_model_user',
'ix_threads_meta_created_user', 'ix_threads_meta_user_created',
'ix_scheduled_task_runs_agent_run_id'
)
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
@@ -0,0 +1,47 @@
"""Patch config.yaml for the MySQL business DB + SQLite checkpointer split."""
from __future__ import annotations
import argparse
from pathlib import Path
import yaml
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Switch DeerFlow config.yaml to MySQL business storage")
parser.add_argument("--config", required=True, help="Path to config.yaml")
parser.add_argument("--mysql-url-env", default="MYSQL_DATABASE_URL", help="Environment variable referenced by database.mysql_url")
parser.add_argument("--checkpointer-sqlite", required=True, help="SQLite deerflow.db path used by LangGraph checkpointer")
parser.add_argument("--pool-size", type=int, default=10)
return parser.parse_args()
def main() -> None:
args = _parse_args()
config_path = Path(args.config)
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"Expected a mapping in {config_path}")
database = dict(data.get("database") or {})
database["backend"] = "mysql"
database["mysql_url"] = f"${args.mysql_url_env}"
database.setdefault("pool_size", args.pool_size)
database.setdefault("echo_sql", False)
data["database"] = database
data["checkpointer"] = {
"type": "sqlite",
"connection_string": args.checkpointer_sqlite,
}
config_path.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
print(f"Updated {config_path}: database.backend=mysql, checkpointer.type=sqlite")
if __name__ == "__main__":
main()