初始化
This commit is contained in:
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()
|
||||
Reference in new issue
Block a user