فهرست منبع

how agent 依赖数据从db导出、结果可视化

liuzhiheng 1 ماه پیش
والد
کامیت
7e660ce859
35فایلهای تغییر یافته به همراه8060 افزوده شده و 769 حذف شده
  1. 3 1
      .gitignore
  2. 33 0
      examples_how/db_utils/__init__.py
  3. 41 0
      examples_how/db_utils/errors.py
  4. 345 0
      examples_how/db_utils/mysql_client.py
  5. 732 0
      examples_how/db_utils/mysql_db.py
  6. 197 0
      examples_how/db_utils/mysql_manager.py
  7. 44 0
      examples_how/db_utils/types.py
  8. 318 0
      examples_how/overall_derivation/data_export_from_db/database_readme.md
  9. 272 0
      examples_how/overall_derivation/data_export_from_db/export_account_element_classification.py
  10. 268 0
      examples_how/overall_derivation/data_export_from_db/export_account_pattern.py
  11. 211 0
      examples_how/overall_derivation/data_export_from_db/export_post.py
  12. 226 0
      examples_how/overall_derivation/data_export_from_db/export_post_decode.py
  13. 105 0
      examples_how/overall_derivation/data_process/extract_post_topic.py
  14. 746 0
      examples_how/overall_derivation/data_process/how_tree_data_process.py
  15. 146 0
      examples_how/overall_derivation/data_process/pattern_data_process.py
  16. 35 11
      examples_how/overall_derivation/data_process/topic_summary.py
  17. 0 0
      examples_how/overall_derivation/data_process/topic_summary_prompt.md
  18. 32 14
      examples_how/overall_derivation/data_process/tree_lib_post_point_match.py
  19. 228 0
      examples_how/overall_derivation/data_process/tree_post_point_match.py
  20. 0 62
      examples_how/overall_derivation/extract_post_topic.py
  21. 215 26
      examples_how/overall_derivation/generate_visualize_data.py
  22. 1 1
      examples_how/overall_derivation/overall_derivation_agent_run.py
  23. 0 87
      examples_how/overall_derivation/pattern_data_process.py
  24. 0 245
      examples_how/overall_derivation/pattern_db_data_process.py
  25. 76 0
      examples_how/overall_derivation/prompt/judge_category_relation.md
  26. 7 7
      examples_how/overall_derivation/tools/find_pattern.py
  27. 5 5
      examples_how/overall_derivation/tools/find_tree_node.py
  28. 28 10
      examples_how/overall_derivation/tools/pattern_dimension_analyze.py
  29. 4 4
      examples_how/overall_derivation/tools/point_match.py
  30. 1 1
      examples_how/overall_derivation/tools/search_and_eval.py
  31. 0 139
      examples_how/overall_derivation/tree_data_process.py
  32. 0 142
      examples_how/overall_derivation/tree_lib_data_process.py
  33. 14 14
      examples_how/overall_derivation/utils/conditional_ratio_calc.py
  34. 3666 0
      examples_how/overall_derivation/visualize_paths.py
  35. 61 0
      mysql_test.py

+ 3 - 1
.gitignore

@@ -79,4 +79,6 @@ knowhub/milvus_data/
 
 # Vendor (non-submodule)
 vendor/browser-use/
-examples/
+examples/
+
+examples_how/overall_derivation/input

+ 33 - 0
examples_how/db_utils/__init__.py

@@ -0,0 +1,33 @@
+from .types import MySQLConfig, ExecResult, Params
+from .errors import (
+    MySQLBaseException,
+    MySQLConnectionError,
+    MySQLConfigError,
+    MySQLQueryError,
+    MySQLTransactionError,
+    MySQLPoolError,
+    MySQLValidationError,
+)
+from .mysql_client import MySQLClient
+from .mysql_manager import MySQLClientManager, get_global_manager
+from .mysql_db import mysql_db, get_mysql_db
+
+__all__ = [
+    "MySQLConfig",
+    "ExecResult",
+    "Params",
+    "MySQLBaseException",
+    "MySQLConnectionError",
+    "MySQLQueryError",
+    "MySQLConfigError",
+    "MySQLTransactionError",
+    "MySQLPoolError",
+    "MySQLValidationError",
+    "MySQLClient",
+    "MySQLClientManager",
+    "get_global_manager",
+    # high-level API (aligned with how_decode/utils/mysql)
+    "mysql_db",
+    "get_mysql_db",
+]
+

+ 41 - 0
examples_how/db_utils/errors.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+
+class MySQLBaseException(Exception):
+    """Base exception for this mysql helper library."""
+
+    def __init__(
+        self,
+        message: str,
+        error_code: str | None = None,
+        original_error: Exception | None = None,
+    ):
+        super().__init__(message)
+        self.message = message
+        self.error_code = error_code
+        self.original_error = original_error
+
+
+class MySQLConnectionError(MySQLBaseException):
+    """Raised when connecting to MySQL fails."""
+
+
+class MySQLConfigError(MySQLBaseException):
+    """Raised when configuration is invalid."""
+
+
+class MySQLQueryError(MySQLBaseException):
+    """Raised when executing a SQL statement fails."""
+
+
+class MySQLTransactionError(MySQLBaseException):
+    """Raised when a transaction fails."""
+
+
+class MySQLPoolError(MySQLBaseException):
+    """Raised when connection pool fails."""
+
+
+class MySQLValidationError(MySQLBaseException):
+    """Raised when input data validation fails."""
+

+ 345 - 0
examples_how/db_utils/mysql_client.py

@@ -0,0 +1,345 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple
+
+import pymysql
+from pymysql.cursors import DictCursor
+
+from .errors import MySQLConnectionError, MySQLQueryError
+from .types import ExecResult, MySQLConfig, Params
+
+try:
+    # Optional dependency for connection pooling.
+    from dbutils.pooled_db import PooledDB  # type: ignore
+
+    _HAS_DBUTILS_POOL = True
+except Exception:  # pragma: no cover
+    PooledDB = None  # type: ignore
+    _HAS_DBUTILS_POOL = False
+
+
+def _normalize_params(params: Params) -> Any:
+    if params is None:
+        return None
+    # pymysql supports Mapping (dict) and Sequence (tuple/list).
+    if isinstance(params, (list, tuple)):
+        return tuple(params)
+    return params
+
+
+class MySQLClient:
+    """
+    Synchronous MySQL client based on `pymysql`.
+
+    Main APIs:
+    - fetchone / fetchall / fetchmany for SELECT queries
+    - execute / executemany for INSERT/UPDATE/DELETE/DDL
+    - transaction() context manager for multi-statement transactions
+    """
+
+    def __init__(self, config: MySQLConfig):
+        self._config = config
+        self._pool = None
+
+        if self._config.use_pool and _HAS_DBUTILS_POOL:
+            conn_kwargs: Dict[str, Any] = dict(
+                host=self._config.host,
+                port=self._config.port,
+                user=self._config.user,
+                password=self._config.password,
+                database=self._config.database,
+                charset=self._config.charset,
+                connect_timeout=self._config.connect_timeout,
+                read_timeout=self._config.read_timeout,
+                write_timeout=self._config.write_timeout,
+                autocommit=self._config.autocommit,
+            )
+            self._pool = PooledDB(
+                creator=pymysql,
+                mincached=self._config.pool_mincached,
+                maxconnections=self._config.pool_maxconnections,
+                blocking=True,
+                **conn_kwargs,
+            )
+
+    @property
+    def config(self) -> MySQLConfig:
+        return self._config
+
+    def open_connection(self) -> pymysql.connections.Connection:
+        """
+        Open a raw pymysql connection.
+
+        Intended usage:
+        - transaction() keep the same connection for multiple operations
+        - other cases where you want manual connection lifecycle
+
+        Note:
+        - Caller must close the connection via `close_connection()`.
+        """
+        if self._pool is not None:
+            return self._pool.connection()
+        return self._connect()
+
+    def close_connection(self, connection: pymysql.connections.Connection) -> None:
+        """Close a previously opened connection (returns to pool when applicable)."""
+        try:
+            connection.close()
+        except Exception:
+            pass
+
+    def _connect(self) -> pymysql.connections.Connection:
+        try:
+            return pymysql.connect(
+                host=self._config.host,
+                port=self._config.port,
+                user=self._config.user,
+                password=self._config.password,
+                database=self._config.database,
+                charset=self._config.charset,
+                cursorclass=DictCursor,
+                connect_timeout=self._config.connect_timeout,
+                read_timeout=self._config.read_timeout,
+                write_timeout=self._config.write_timeout,
+                autocommit=self._config.autocommit,
+            )
+        except Exception as e:  # pragma: no cover
+            raise MySQLConnectionError(
+                f"MySQL connection failed (source={self._config.source}, host={self._config.host}, db={self._config.database}): {e}"
+            ) from e
+
+    @contextmanager
+    def _cursor(self) -> Iterator[Tuple[pymysql.connections.Connection, DictCursor]]:
+        """
+        Yield (connection, cursor) using DictCursor by default.
+        Uses pool connection if configured; otherwise creates a fresh connection.
+        """
+
+        if self._pool is not None:
+            # DBUtils pooled connection supports context manager.
+            conn = self._pool.connection()
+        else:
+            conn = self._connect()
+
+        try:
+            cursor = conn.cursor(DictCursor)
+            try:
+                yield conn, cursor
+            finally:
+                cursor.close()
+        finally:
+            try:
+                conn.close()
+            except Exception:
+                pass
+
+    def transaction(self):
+        """
+        Transaction context manager.
+
+        - Commits on success when autocommit is False.
+        - Rolls back on exception.
+        """
+
+        @contextmanager
+        def _tx():
+            if self._pool is not None:
+                conn = self._pool.connection()
+            else:
+                conn = self._connect()
+
+            # If autocommit=False, commit/rollback controls are meaningful.
+            tx_active = not self._config.autocommit
+            try:
+                cursor = conn.cursor(DictCursor)
+                try:
+                    yield cursor
+                finally:
+                    cursor.close()
+                if tx_active:
+                    conn.commit()
+            except Exception:
+                if tx_active:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                raise
+            finally:
+                try:
+                    conn.close()
+                except Exception:
+                    pass
+
+        return _tx()
+
+    def fetchone(self, sql: str, params: Params = None) -> Optional[Dict[str, Any]]:
+        with self._cursor() as (conn, cursor):
+            try:
+                cursor.execute(sql, _normalize_params(params))
+                return cursor.fetchone()
+            except Exception as e:
+                raise MySQLQueryError(f"fetchone failed: {e} | sql={sql}") from e
+
+    def fetchall(
+        self,
+        sql: str,
+        params: Params = None,
+        *,
+        max_rows: Optional[int] = None,
+    ) -> List[Dict[str, Any]]:
+        with self._cursor() as (conn, cursor):
+            try:
+                cursor.execute(sql, _normalize_params(params))
+                if max_rows is None:
+                    return list(cursor.fetchall())
+                # Cursor fetchall has no max; fallback to fetchmany.
+                out: List[Dict[str, Any]] = []
+                while True:
+                    batch = cursor.fetchmany(size=max_rows - len(out))
+                    if not batch:
+                        break
+                    out.extend(batch)
+                    if len(out) >= max_rows:
+                        break
+                return out
+            except Exception as e:
+                raise MySQLQueryError(f"fetchall failed: {e} | sql={sql}") from e
+
+    def fetchmany(
+        self,
+        sql: str,
+        params: Params = None,
+        *,
+        size: int = 100,
+    ) -> List[Dict[str, Any]]:
+        with self._cursor() as (conn, cursor):
+            try:
+                cursor.execute(sql, _normalize_params(params))
+                return list(cursor.fetchmany(size=size))
+            except Exception as e:
+                raise MySQLQueryError(f"fetchmany failed: {e} | sql={sql}") from e
+
+    def execute(
+        self,
+        sql: str,
+        params: Params = None,
+        *,
+        commit: Optional[bool] = None,
+        ignore_duplicate: bool = False,
+        ignore_deadlock: bool = False,
+    ) -> ExecResult:
+        """
+        Execute a write statement.
+
+        Args:
+            commit:
+                - None: follow config.autocommit (当 `autocommit=False` 时默认会 commit)
+                - True/False: force commit/rollback behavior
+            ignore_duplicate:
+                If True, silently ignore MySQL duplicate-key errors (1062).
+            ignore_deadlock:
+                If True, rollback and silently ignore deadlocks (1205).
+        """
+        # Commit semantics:
+        # - autocommit=True: no explicit commit is required/possible.
+        # - autocommit=False:
+        #     - commit is None: commit by default
+        #     - commit=True: force commit
+        #     - commit=False: skip commit
+        commit_enabled = (not self._config.autocommit) and (True if commit is None else bool(commit))
+
+        with self._cursor() as (conn, cursor):
+            try:
+                cursor.execute(sql, _normalize_params(params))
+                if commit_enabled:
+                    conn.commit()
+                return ExecResult(
+                    rowcount=int(cursor.rowcount or 0),
+                    lastrowid=getattr(cursor, "lastrowid", None),
+                )
+            except pymysql.err.IntegrityError as e:
+                if ignore_duplicate and getattr(e, "args", None) and e.args and e.args[0] == 1062:
+                    if not self._config.autocommit:
+                        conn.rollback()
+                    return ExecResult(rowcount=0, lastrowid=None)
+                raise MySQLQueryError(f"execute failed (IntegrityError): {e} | sql={sql}") from e
+            except pymysql.err.OperationalError as e:
+                if ignore_deadlock and getattr(e, "args", None) and e.args and e.args[0] == 1205:
+                    if not self._config.autocommit:
+                        conn.rollback()
+                    return ExecResult(rowcount=0, lastrowid=None)
+                raise MySQLQueryError(f"execute failed (OperationalError): {e} | sql={sql}") from e
+            except Exception as e:
+                if not self._config.autocommit:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                raise MySQLQueryError(f"execute failed: {e} | sql={sql}") from e
+
+    def executemany(
+        self,
+        sql: str,
+        params_seq: Sequence[Params],
+        *,
+        commit: Optional[bool] = None,
+        ignore_duplicate: bool = False,
+        ignore_deadlock: bool = False,
+    ) -> ExecResult:
+        """
+        Execute a statement against multiple parameter sets.
+
+        Note:
+            If ignore_duplicate/ignore_deadlock are enabled, we fall back to per-row execution
+            to emulate "ignore" semantics without terminating the whole batch.
+        """
+
+        commit_enabled = (not self._config.autocommit) and (True if commit is None else bool(commit))
+
+        if (ignore_duplicate or ignore_deadlock) and self._config.autocommit is False:
+            # Fallback: execute one-by-one so we can ignore specific errors.
+            total = 0
+            lastrowid: Optional[int] = None
+            for p in params_seq:
+                res = self.execute(
+                    sql,
+                    p,
+                    commit=commit,
+                    ignore_duplicate=ignore_duplicate,
+                    ignore_deadlock=ignore_deadlock,
+                )
+                total += res.rowcount
+                lastrowid = res.lastrowid
+            return ExecResult(rowcount=total, lastrowid=lastrowid)
+
+        with self._cursor() as (conn, cursor):
+            try:
+                cursor.executemany(sql, [_normalize_params(p) for p in params_seq])
+                if commit_enabled:
+                    conn.commit()
+                return ExecResult(
+                    rowcount=int(cursor.rowcount or 0),
+                    lastrowid=getattr(cursor, "lastrowid", None),
+                )
+            except pymysql.err.IntegrityError as e:
+                if ignore_duplicate and getattr(e, "args", None) and e.args and e.args[0] == 1062:
+                    if not self._config.autocommit:
+                        conn.rollback()
+                    return ExecResult(rowcount=0, lastrowid=None)
+                raise MySQLQueryError(f"executemany failed (IntegrityError): {e} | sql={sql}") from e
+            except pymysql.err.OperationalError as e:
+                if ignore_deadlock and getattr(e, "args", None) and e.args and e.args[0] == 1205:
+                    if not self._config.autocommit:
+                        conn.rollback()
+                    return ExecResult(rowcount=0, lastrowid=None)
+                raise MySQLQueryError(f"executemany failed (OperationalError): {e} | sql={sql}") from e
+            except Exception as e:
+                if not self._config.autocommit:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                raise MySQLQueryError(f"executemany failed: {e} | sql={sql}") from e
+

+ 732 - 0
examples_how/db_utils/mysql_db.py

@@ -0,0 +1,732 @@
+from __future__ import annotations
+
+import math
+from contextlib import contextmanager
+from contextvars import ContextVar
+from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple
+
+import pymysql
+from pymysql.cursors import DictCursor
+
+from .errors import (
+    MySQLConnectionError,
+    MySQLConfigError,
+    MySQLQueryError,
+    MySQLTransactionError,
+)
+from .mysql_manager import MySQLClientManager, get_global_manager
+from .mysql_client import MySQLClient
+
+_tx_connection_var: ContextVar[Optional[pymysql.connections.Connection]] = ContextVar(
+    "examples_how_db_utils_tx_connection", default=None
+)
+
+
+def _normalize_where_params(where_params: Any) -> Optional[Tuple[Any, ...]]:
+    if where_params is None:
+        return None
+    if isinstance(where_params, (list, tuple)):
+        return tuple(where_params)
+    if isinstance(where_params, dict):
+        return tuple(where_params.values())
+    # Fallback: keep as-is (pymysql supports sequences/tuples or mapping)
+    return (where_params,)
+
+
+class MySQLDB:
+    """
+    High-level MySQL API (CRUD + advanced queries + transaction).
+
+    Interface is aligned with `how_decode/utils/mysql/mysql_db` style.
+    """
+
+    def __init__(self, *, manager: MySQLClientManager, source: str = "default"):
+        self._manager = manager
+        self._source = source
+
+    @property
+    def source(self) -> str:
+        return self._source
+
+    def _client(self) -> MySQLClient:
+        return self._manager.get_client(self._source)
+
+    def _is_in_transaction(self) -> bool:
+        return _tx_connection_var.get() is not None
+
+    @contextmanager
+    def _get_connection_and_cursor(
+        self, connection: Optional[pymysql.connections.Connection] = None
+    ) -> Iterator[Tuple[pymysql.connections.Connection, DictCursor, bool]]:
+        """
+        Returns (connection, cursor, should_close_connection).
+
+        - If `connection` is provided: uses it and should_close_connection=False
+        - Else if transaction connection exists: uses it and should_close_connection=False
+        - Else opens a new connection: should_close_connection=True
+        """
+
+        client = self._client()
+        tx_conn = _tx_connection_var.get()
+
+        should_close = False
+        actual_conn: Optional[pymysql.connections.Connection] = None
+
+        if connection is not None:
+            actual_conn = connection
+        elif tx_conn is not None:
+            actual_conn = tx_conn
+        else:
+            actual_conn = client.open_connection()
+            should_close = True
+
+        cursor = actual_conn.cursor(DictCursor)
+        try:
+            yield actual_conn, cursor, should_close
+        finally:
+            try:
+                cursor.close()
+            except Exception:
+                pass
+            if should_close:
+                try:
+                    actual_conn.close()
+                except Exception:
+                    pass
+
+    @contextmanager
+    def transaction(self, isolation_level: Optional[str] = None):
+        """
+        Transaction context manager.
+
+        Important: when you call CRUD methods inside this context without passing `connection`,
+        they will automatically reuse the same transaction connection (via ContextVar).
+        """
+
+        client = self._client()
+        conn = None
+        token = None
+        try:
+            conn = client.open_connection()
+            # Ensure explicit transaction.
+            conn.autocommit(False)
+
+            if isolation_level:
+                conn.execute(
+                    f"SET SESSION TRANSACTION ISOLATION LEVEL {isolation_level}"
+                )
+
+            conn.begin()
+
+            token = _tx_connection_var.set(conn)
+            yield conn
+
+            conn.commit()
+        except Exception as e:
+            if conn is not None:
+                try:
+                    conn.rollback()
+                except Exception:
+                    pass
+            raise MySQLTransactionError(
+                message=f"transaction failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+        finally:
+            if token is not None:
+                _tx_connection_var.reset(token)
+            if conn is not None:
+                try:
+                    conn.close()
+                except Exception:
+                    pass
+
+    # -----------------------
+    # Basic CRUD
+    # -----------------------
+
+    def select(
+        self,
+        table: str,
+        columns: str = "*",
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        order_by: str = "",
+        limit: Optional[int] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> List[Dict[str, Any]]:
+        sql = f"SELECT {columns} FROM {table}"
+        if where:
+            sql += f" WHERE {where}"
+        if order_by:
+            sql += f" ORDER BY {order_by}"
+        if limit is not None:
+            sql += f" LIMIT {limit}"
+
+        params = _normalize_where_params(where_params)
+        try:
+            with self._get_connection_and_cursor(connection) as (conn, cursor, should_close):
+                cursor.execute(sql, params)
+                return list(cursor.fetchall())
+        except Exception as e:
+            raise MySQLQueryError(
+                message=f"select failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def select_one(
+        self,
+        table: str,
+        columns: str = "*",
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> Optional[Dict[str, Any]]:
+        sql = f"SELECT {columns} FROM {table}"
+        if where:
+            sql += f" WHERE {where}"
+        sql += " LIMIT 1"
+        params = _normalize_where_params(where_params)
+        try:
+            with self._get_connection_and_cursor(connection) as (_conn, cursor, _should_close):
+                cursor.execute(sql, params)
+                return cursor.fetchone()
+        except Exception as e:
+            raise MySQLQueryError(
+                message=f"select_one failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def insert(
+        self,
+        table: str,
+        data: Dict[str, Any],
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> int:
+        if not data:
+            raise ValueError("insert data must not be empty")
+
+        columns = list(data.keys())
+        placeholders = ", ".join(["%s"] * len(columns))
+        sql = f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders})"
+        params = tuple(data.values())
+
+        conn: Optional[pymysql.connections.Connection] = None
+        try:
+            with self._get_connection_and_cursor(connection) as (conn, cursor, should_close):
+                cursor.execute(sql, params)
+                # If not inside transaction, auto commit.
+                if not self._is_in_transaction() and should_close:
+                    conn.commit()
+                return int(getattr(cursor, "lastrowid", 0) or 0)
+        except Exception as e:
+            # If we opened a connection ourselves, rollback to be safe.
+            if conn is not None and connection is None and not self._is_in_transaction():
+                try:
+                    conn.rollback()
+                except Exception:
+                    pass
+            raise MySQLQueryError(
+                message=f"insert failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def insert_many(
+        self,
+        table: str,
+        data_list: List[Dict[str, Any]],
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> int:
+        if not data_list:
+            raise ValueError("insert_many data_list must not be empty")
+        columns = list(data_list[0].keys())
+        placeholders = ", ".join(["%s"] * len(columns))
+        sql = f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders})"
+        params_list = [tuple(d[col] for col in columns) for d in data_list]
+
+        conn: Optional[pymysql.connections.Connection] = None
+        try:
+            with self._get_connection_and_cursor(connection) as (conn, cursor, should_close):
+                cursor.executemany(sql, params_list)
+                if not self._is_in_transaction() and should_close:
+                    conn.commit()
+                return int(cursor.rowcount or 0)
+        except Exception as e:
+            if conn is not None and connection is None and not self._is_in_transaction():
+                try:
+                    conn.rollback()
+                except Exception:
+                    pass
+            raise MySQLQueryError(
+                message=f"insert_many failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def update(
+        self,
+        table: str,
+        data: Dict[str, Any],
+        where: str,
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> int:
+        if not data:
+            raise ValueError("update data must not be empty")
+        set_clause = ", ".join([f"{col}=%s" for col in data.keys()])
+        sql = f"UPDATE {table} SET {set_clause} WHERE {where}"
+
+        params: List[Any] = list(data.values())
+        wp = _normalize_where_params(where_params)
+        if wp is not None:
+            params.extend(list(wp))
+
+        conn: Optional[pymysql.connections.Connection] = None
+        try:
+            with self._get_connection_and_cursor(connection) as (conn, cursor, should_close):
+                cursor.execute(sql, tuple(params))
+                if not self._is_in_transaction() and should_close:
+                    conn.commit()
+                return int(cursor.rowcount or 0)
+        except Exception as e:
+            if conn is not None and connection is None and not self._is_in_transaction():
+                try:
+                    conn.rollback()
+                except Exception:
+                    pass
+            raise MySQLQueryError(
+                message=f"update failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def delete(
+        self,
+        table: str,
+        where: str,
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> int:
+        sql = f"DELETE FROM {table} WHERE {where}"
+        params = _normalize_where_params(where_params)
+        conn: Optional[pymysql.connections.Connection] = None
+        try:
+            with self._get_connection_and_cursor(connection) as (conn, cursor, should_close):
+                cursor.execute(sql, params)
+                if not self._is_in_transaction() and should_close:
+                    conn.commit()
+                return int(cursor.rowcount or 0)
+        except Exception as e:
+            if conn is not None and connection is None and not self._is_in_transaction():
+                try:
+                    conn.rollback()
+                except Exception:
+                    pass
+            raise MySQLQueryError(
+                message=f"delete failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def execute_many(
+        self,
+        sql: str,
+        params_list: List[Sequence[Any] | Mapping[str, Any]],
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> int:
+        params_seq = [_normalize_where_params(p) for p in params_list]
+        conn: Optional[pymysql.connections.Connection] = None
+        try:
+            with self._get_connection_and_cursor(connection) as (conn, cursor, should_close):
+                cursor.executemany(sql, params_seq)
+                if not self._is_in_transaction() and should_close:
+                    conn.commit()
+                return int(cursor.rowcount or 0)
+        except Exception as e:
+            if conn is not None and connection is None and not self._is_in_transaction():
+                try:
+                    conn.rollback()
+                except Exception:
+                    pass
+            raise MySQLQueryError(
+                message=f"execute_many failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    # -----------------------
+    # Query helpers
+    # -----------------------
+
+    def count(
+        self,
+        table: str,
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> int:
+        sql = f"SELECT COUNT(*) as count FROM {table}"
+        if where:
+            sql += f" WHERE {where}"
+        try:
+            params = _normalize_where_params(where_params)
+            with self._get_connection_and_cursor(connection) as (_conn, cursor, _should_close):
+                cursor.execute(sql, params)
+                r = cursor.fetchone()
+                if not r:
+                    return 0
+                return int(r.get("count") or 0)
+        except Exception as e:
+            raise MySQLQueryError(
+                message=f"count failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def exists(
+        self,
+        table: str,
+        where: str,
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> bool:
+        return self.count(
+            table, where=where, where_params=where_params, connection=connection
+        ) > 0
+
+    def paginate(
+        self,
+        table: str,
+        page: int = 1,
+        page_size: int = 20,
+        columns: str = "*",
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        order_by: str = "",
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> Dict[str, Any]:
+        if page < 1:
+            page = 1
+        if page_size < 1:
+            page_size = 20
+
+        total_count = self.count(
+            table, where=where, where_params=where_params, connection=connection
+        )
+        total_pages = math.ceil(total_count / page_size) if total_count > 0 else 1
+        offset = (page - 1) * page_size
+
+        sql = f"SELECT {columns} FROM {table}"
+        if where:
+            sql += f" WHERE {where}"
+        if order_by:
+            sql += f" ORDER BY {order_by}"
+        sql += f" LIMIT {page_size} OFFSET {offset}"
+
+        params = _normalize_where_params(where_params)
+        try:
+            with self._get_connection_and_cursor(connection) as (_conn, cursor, _should_close):
+                cursor.execute(sql, params)
+                data = list(cursor.fetchall())
+        except Exception as e:
+            raise MySQLQueryError(
+                message=f"paginate failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+        return {
+            "data": data,
+            "pagination": {
+                "current_page": page,
+                "page_size": page_size,
+                "total_count": total_count,
+                "total_pages": total_pages,
+                "has_prev": page > 1,
+                "has_next": page < total_pages,
+                "prev_page": page - 1 if page > 1 else None,
+                "next_page": page + 1 if page < total_pages else None,
+            },
+        }
+
+    def select_with_sort(
+        self,
+        table: str,
+        columns: str = "*",
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        sort_field: str = "id",
+        sort_order: str = "ASC",
+        limit: Optional[int] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> List[Dict[str, Any]]:
+        sort_order = (sort_order or "").upper()
+        if sort_order not in ["ASC", "DESC"]:
+            sort_order = "ASC"
+        order_by = f"{sort_field} {sort_order}"
+        return self.select(
+            table,
+            columns=columns,
+            where=where,
+            where_params=where_params,
+            order_by=order_by,
+            limit=limit,
+            connection=connection,
+        )
+
+    def select_with_multiple_sort(
+        self,
+        table: str,
+        columns: str = "*",
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        sort_fields: Optional[List[Tuple[str, str]]] = None,
+        limit: Optional[int] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> List[Dict[str, Any]]:
+        order_by = ""
+        if sort_fields:
+            parts: List[str] = []
+            for field, order in sort_fields:
+                order_u = (order or "").upper()
+                if order_u not in ["ASC", "DESC"]:
+                    order_u = "ASC"
+                parts.append(f"{field} {order_u}")
+            order_by = ", ".join(parts)
+
+        return self.select(
+            table,
+            columns=columns,
+            where=where,
+            where_params=where_params,
+            order_by=order_by,
+            limit=limit,
+            connection=connection,
+        )
+
+    def aggregate(
+        self,
+        table: str,
+        agg_functions: Dict[str, str],
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        group_by: str = "",
+        having: str = "",
+        having_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> List[Dict[str, Any]]:
+        if not agg_functions:
+            raise ValueError("agg_functions must not be empty")
+
+        select_parts: List[str] = []
+        if group_by:
+            select_parts.append(group_by)
+        for alias, func in agg_functions.items():
+            select_parts.append(f"{func} AS {alias}")
+
+        sql = f"SELECT {', '.join(select_parts)} FROM {table}"
+        if where:
+            sql += f" WHERE {where}"
+        if group_by:
+            sql += f" GROUP BY {group_by}"
+        if having:
+            sql += f" HAVING {having}"
+
+        params: List[Any] = []
+        wp = _normalize_where_params(where_params)
+        if wp is not None:
+            params.extend(list(wp))
+        hp = _normalize_where_params(having_params)
+        if hp is not None:
+            params.extend(list(hp))
+
+        try:
+            with self._get_connection_and_cursor(connection) as (_conn, cursor, _should_close):
+                cursor.execute(sql, tuple(params) if params else None)
+                return list(cursor.fetchall())
+        except Exception as e:
+            raise MySQLQueryError(
+                message=f"aggregate failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def sum(
+        self,
+        table: str,
+        column: str,
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> float:
+        rows = self.aggregate(
+            table=table,
+            agg_functions={"sum_result": f"SUM({column})"},
+            where=where,
+            where_params=where_params,
+            connection=connection,
+        )
+        v = rows[0].get("sum_result") if rows else None
+        return float(v) if v is not None else 0.0
+
+    def avg(
+        self,
+        table: str,
+        column: str,
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> float:
+        rows = self.aggregate(
+            table=table,
+            agg_functions={"avg_result": f"AVG({column})"},
+            where=where,
+            where_params=where_params,
+            connection=connection,
+        )
+        v = rows[0].get("avg_result") if rows else None
+        return float(v) if v is not None else 0.0
+
+    def max(
+        self,
+        table: str,
+        column: str,
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> Any:
+        rows = self.aggregate(
+            table=table,
+            agg_functions={"max_result": f"MAX({column})"},
+            where=where,
+            where_params=where_params,
+            connection=connection,
+        )
+        return rows[0].get("max_result") if rows and rows[0].get("max_result") is not None else None
+
+    def min(
+        self,
+        table: str,
+        column: str,
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> Any:
+        rows = self.aggregate(
+            table=table,
+            agg_functions={"min_result": f"MIN({column})"},
+            where=where,
+            where_params=where_params,
+            connection=connection,
+        )
+        return rows[0].get("min_result") if rows and rows[0].get("min_result") is not None else None
+
+    def group_count(
+        self,
+        table: str,
+        group_column: str,
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        order_by: str = "",
+        limit: Optional[int] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> List[Dict[str, Any]]:
+        sql = f"SELECT {group_column}, COUNT(*) as count FROM {table}"
+        if where:
+            sql += f" WHERE {where}"
+        sql += f" GROUP BY {group_column}"
+        if order_by:
+            sql += f" ORDER BY {order_by}"
+        else:
+            sql += " ORDER BY count DESC"
+        if limit is not None:
+            sql += f" LIMIT {limit}"
+
+        params = _normalize_where_params(where_params)
+        try:
+            with self._get_connection_and_cursor(connection) as (_conn, cursor, _should_close):
+                cursor.execute(sql, params)
+                return list(cursor.fetchall())
+        except Exception as e:
+            raise MySQLQueryError(
+                message=f"group_count failed (source={self._source}): {e}",
+                original_error=e,
+            ) from e
+
+    def search(
+        self,
+        table: str,
+        search_columns: List[str],
+        keyword: str,
+        columns: str = "*",
+        where: str = "",
+        where_params: Optional[Sequence[Any] | Mapping[str, Any]] = None,
+        order_by: str = "",
+        limit: Optional[int] = None,
+        connection: Optional[pymysql.connections.Connection] = None,
+    ) -> List[Dict[str, Any]]:
+        if not search_columns or not keyword:
+            return []
+
+        search_conditions: List[str] = []
+        search_params: List[Any] = []
+        for col in search_columns:
+            search_conditions.append(f"{col} LIKE %s")
+            search_params.append(f"%{keyword}%")
+
+        search_where = f"({' OR '.join(search_conditions)})"
+        final_where = search_where
+        final_params: List[Any] = list(search_params)
+
+        if where:
+            final_where = f"{search_where} AND ({where})"
+            wp = _normalize_where_params(where_params)
+            if wp is not None:
+                final_params.extend(list(wp))
+
+        return self.select(
+            table,
+            columns=columns,
+            where=final_where,
+            where_params=tuple(final_params),
+            order_by=order_by,
+            limit=limit,
+            connection=connection,
+        )
+
+    # -----------------------
+    # Functional transaction helpers (optional)
+    # -----------------------
+
+    def execute_in_transaction(
+        self,
+        func,
+        *args,
+        isolation_level: Optional[str] = None,
+        **kwargs,
+    ) -> Any:
+        with self.transaction(isolation_level=isolation_level) as conn:
+            return func(conn, *args, **kwargs)
+
+    def batch_operations(
+        self,
+        operations: list,
+        isolation_level: Optional[str] = None,
+    ) -> list:
+        results: list = []
+        with self.transaction(isolation_level=isolation_level) as conn:
+            for op in operations:
+                method_name, args, op_kwargs = op
+                op_kwargs = op_kwargs or {}
+                op_kwargs["connection"] = conn
+                method = getattr(self, method_name)
+                results.append(method(*args, **op_kwargs))
+        return results
+
+
+_GLOBAL_DB: Dict[str, MySQLDB] = {}
+
+
+def get_mysql_db(source: str = "default") -> MySQLDB:
+    if source not in _GLOBAL_DB:
+        mgr = get_global_manager()
+        _GLOBAL_DB[source] = MySQLDB(manager=mgr, source=source)
+    return _GLOBAL_DB[source]
+
+
+# For compatibility with how_decode/utils/mysql (global mysql_db)
+mysql_db = get_mysql_db("default")
+

+ 197 - 0
examples_how/db_utils/mysql_manager.py

@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import json
+import os
+from typing import Any, Dict, Mapping, Optional
+
+from dotenv import load_dotenv
+
+from .mysql_client import MySQLClient
+from .types import MySQLConfig
+
+
+class MySQLClientManager:
+    """
+    Manage multiple MySQLClient instances by "source" name.
+
+    This is designed for future multi-data-source requirements:
+    - Register configs for different sources (e.g. "default", "analytics", "crawler")
+    - Get a client by source whenever you need to query different DBs
+    """
+
+    def __init__(self, configs: Optional[Mapping[str, MySQLConfig]] = None):
+        self._configs: Dict[str, MySQLConfig] = {}
+        self._clients: Dict[str, MySQLClient] = {}
+
+        if configs:
+            for _, cfg in configs.items():
+                self.register_source(cfg)
+
+    def register_source(self, config: MySQLConfig) -> None:
+        source = config.source or "default"
+        self._configs[source] = config
+        # Drop existing instance to ensure updated config takes effect.
+        if source in self._clients:
+            del self._clients[source]
+
+    def get_client(self, source: str = "default") -> MySQLClient:
+        if source not in self._configs:
+            raise KeyError(f"MySQL source not registered: {source}")
+        if source not in self._clients:
+            self._clients[source] = MySQLClient(self._configs[source])
+        return self._clients[source]
+
+    @classmethod
+    def from_env(cls, *, prefix: str = "MYSQL_") -> "MySQLClientManager":
+        """
+        Build a manager from environment variables for a single "default" source.
+
+        Expected env vars (all optional except host/user/password/database if you want to connect):
+        - MYSQL_HOST
+        - MYSQL_PORT
+        - MYSQL_USER
+        - MYSQL_PASSWORD
+        - MYSQL_DATABASE
+        - MYSQL_CHARSET
+        """
+
+        def _get(name: str, default: str = "") -> str:
+            return os.getenv(f"{prefix}{name}", default)
+
+        host = _get("HOST", "127.0.0.1")
+        port_str = _get("PORT", "3306")
+        user = _get("USER", "")
+        password = _get("PASSWORD", "")
+        database = _get("DATABASE", "")
+        charset = _get("CHARSET", "utf8mb4")
+
+        try:
+            port = int(port_str)
+        except ValueError:
+            port = 3306
+
+        cfg = MySQLConfig(
+            source="default",
+            host=host,
+            port=port,
+            user=user,
+            password=password,
+            database=database,
+            charset=charset,
+        )
+        return cls(configs={"default": cfg})
+
+    @classmethod
+    def from_env_sources_info(
+        cls,
+        *,
+        env_var: str = "MYSQL_SOURCES_INFO",
+        dotenv_path: Optional[str] = None,
+        allow_fallback_single_source: bool = True,
+    ) -> "MySQLClientManager":
+        """
+        Build a manager from a single JSON env var `MYSQL_SOURCES_INFO`.
+
+        Expected JSON format:
+            {
+              "default": {"host": "...", "port": 3306, "user": "...", "password": "...", "database": "..."},
+              "crawler": {...}
+            }
+
+        Notes:
+        - If `password` is missing, it also accepts `passwd`.
+        - Unknown keys inside each source are ignored.
+        - If env var is missing and `allow_fallback_single_source=True`, it falls back to `from_env()`.
+        """
+
+        if dotenv_path is None:
+            load_dotenv()
+        else:
+            load_dotenv(dotenv_path)
+
+        raw = os.getenv(env_var, "").strip()
+        if not raw:
+            if allow_fallback_single_source:
+                return cls.from_env()
+            return cls()
+
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError as e:
+            raise ValueError(f"{env_var} is not valid JSON: {e}") from e
+
+        if not isinstance(parsed, dict):
+            raise ValueError(f"{env_var} must be a JSON object, got: {type(parsed).__name__}")
+
+        configs: Dict[str, MySQLConfig] = {}
+
+        for source_key, cfg in parsed.items():
+            if not isinstance(source_key, str) or not source_key:
+                continue
+            if not isinstance(cfg, dict):
+                continue
+
+            cfg_dict: Dict[str, Any] = dict(cfg)
+            # Accept synonyms
+            if "password" not in cfg_dict and "passwd" in cfg_dict:
+                cfg_dict["password"] = cfg_dict.get("passwd")
+
+            # Coerce port if present
+            if "port" in cfg_dict:
+                try:
+                    cfg_dict["port"] = int(cfg_dict["port"])
+                except Exception:
+                    cfg_dict["port"] = 3306
+
+            # Ensure source
+            cfg_dict["source"] = source_key
+
+            # Filter keys to MySQLConfig fields (ignore unknown keys for forward compatibility)
+            allowed = {
+                "source",
+                "host",
+                "port",
+                "user",
+                "password",
+                "database",
+                "charset",
+                "connect_timeout",
+                "read_timeout",
+                "write_timeout",
+                "autocommit",
+                "use_pool",
+                "pool_mincached",
+                "pool_maxconnections",
+            }
+            kwargs = {k: v for k, v in cfg_dict.items() if k in allowed}
+            configs[source_key] = MySQLConfig(**kwargs)
+
+        return cls(configs=configs)
+
+
+_GLOBAL_MANAGER = MySQLClientManager()
+_GLOBAL_MANAGER_INITIALIZED = False
+
+
+def get_global_manager() -> MySQLClientManager:
+    global _GLOBAL_MANAGER_INITIALIZED
+    if _GLOBAL_MANAGER_INITIALIZED:
+        return _GLOBAL_MANAGER
+    _GLOBAL_MANAGER_INITIALIZED = True
+
+    # If the user already registered sources, don't override.
+    if getattr(_GLOBAL_MANAGER, "_configs", None):
+        return _GLOBAL_MANAGER
+
+    # Try JSON multi-source init from env.
+    try:
+        mgr = MySQLClientManager.from_env_sources_info(allow_fallback_single_source=False)
+        # Copy configs into the singleton instance.
+        for source, cfg in getattr(mgr, "_configs", {}).items():
+            _GLOBAL_MANAGER.register_source(cfg)
+    except Exception:
+        # Keep the global manager empty if env is missing/invalid.
+        pass
+
+    return _GLOBAL_MANAGER
+

+ 44 - 0
examples_how/db_utils/types.py

@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Mapping, Sequence, Optional
+
+
+Params = Optional[Mapping[str, Any] | Sequence[Any]]
+
+
+@dataclass(frozen=True)
+class MySQLConfig:
+    """
+    MySQL connection configuration.
+
+    Notes:
+    - "autocommit": if False, write operations will commit by default.
+    - "use_pool": if True and DBUtils is available, a pooled connection is used.
+    """
+
+    source: str = "default"
+    host: str = "127.0.0.1"
+    port: int = 3306
+    user: str = ""
+    password: str = ""
+    database: str = ""
+    charset: str = "utf8mb4"
+
+    connect_timeout: int = 10
+    read_timeout: int = 30
+    write_timeout: int = 30
+
+    autocommit: bool = False
+    use_pool: bool = True
+
+    # Pool settings (only used when DBUtils is available).
+    pool_mincached: int = 2
+    pool_maxconnections: int = 10
+
+
+@dataclass(frozen=True)
+class ExecResult:
+    rowcount: int
+    lastrowid: Optional[int] = None
+

+ 318 - 0
examples_how/overall_derivation/data_export_from_db/database_readme.md

@@ -0,0 +1,318 @@
+# 数据库定义文档
+
+## open_aigc库
+存放帖子、帖子解构的选题、选题点、元素、元素的分类数据。
+
+### 表结构
+
+#### 帖子
+CREATE TABLE `post` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `post_id` varchar(100) NOT NULL COMMENT '帖子ID(平台原始ID)',
+  `platform` varchar(50) NOT NULL DEFAULT 'piaoquan' COMMENT '平台标识,如 piaoquan',
+  `platform_account_id` varchar(100) DEFAULT NULL COMMENT '平台作者ID',
+  `platform_account_name` varchar(200) DEFAULT NULL COMMENT '平台作者名称',
+  `publish_timestamp` bigint(20) DEFAULT NULL COMMENT '发布时间戳(ms)',
+  `title` varchar(500) DEFAULT NULL COMMENT '帖子标题',
+  `body_text` text COMMENT '正文内容',
+  `merge_leve1` varchar(100) DEFAULT NULL COMMENT '一级品类',
+  `merge_leve2` varchar(100) DEFAULT NULL COMMENT '二级品类',
+  `gmt_create` datetime DEFAULT NULL COMMENT '帖子创建时间',
+  `view_all` int(11) DEFAULT NULL COMMENT '总阅读量(20230601后)',
+  `share_all` int(11) DEFAULT NULL COMMENT '总分享量',
+  `return_all` int(11) DEFAULT NULL COMMENT '总回流量',
+  `view_recent` int(11) DEFAULT NULL COMMENT '近30天阅读量',
+  `share_recent` int(11) DEFAULT NULL COMMENT '近30天分享量',
+  `return_recent` int(11) DEFAULT NULL COMMENT '近30天回流量',
+  `like_count` int(11) DEFAULT '0' COMMENT '点赞数',
+  `comment_count` int(11) DEFAULT '0' COMMENT '评论数',
+  `collect_count` int(11) DEFAULT '0' COMMENT '收藏数',
+  `images` json DEFAULT NULL COMMENT '图片/视频URL列表',
+  `import_date` datetime NOT NULL COMMENT '导入日期',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `channel_content_id` (`post_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=9286 DEFAULT CHARSET=utf8mb4;
+
+#### 帖子选题
+CREATE TABLE `post_decode_result` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `post_id` varchar(100) NOT NULL COMMENT '关联帖子ID',
+  `topic` text COMMENT '最终选题结果',
+  `topic_type` varchar(255) DEFAULT NULL COMMENT '选题类型',
+  `core_attraction` text COMMENT '核心吸引力',
+  `main_purpose` text COMMENT '主要目的',
+  `confidence` varchar(50) DEFAULT NULL COMMENT '置信度',
+  `import_date` datetime NOT NULL COMMENT '导入日期',
+  PRIMARY KEY (`id`),
+  KEY `idx_post_decode_result_post_id` (`post_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=9286 DEFAULT CHARSET=utf8mb4;
+
+#### 帖子选题点
+CREATE TABLE `post_decode_topic_point` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `post_decode_result_id` bigint(20) NOT NULL COMMENT '关联解构结果ID',
+  `post_id` varchar(100) NOT NULL COMMENT '关联帖子ID',
+  `topic_point_type` enum('灵感点','目的点','关键点') NOT NULL COMMENT '选题点类型:灵感点/目的点/关键点',
+  `topic_point_result` varchar(500) NOT NULL COMMENT '选题点',
+  `topic_point_description` text COMMENT '选题点描述信息',
+  PRIMARY KEY (`id`),
+  KEY `idx_topic_point_post_decode_result_id` (`post_decode_result_id`),
+  KEY `idx_topic_point_post_id` (`post_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=70306 DEFAULT CHARSET=utf8mb4;
+
+#### 帖子选题点元素
+CREATE TABLE `post_decode_topic_point_element` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `post_decode_result_id` bigint(20) NOT NULL COMMENT '关联解构结果ID',
+  `post_id` varchar(100) NOT NULL COMMENT '关联帖子ID',
+  `topic_point_id` bigint(20) NOT NULL COMMENT '关联选题点ID',
+  `element_type` enum('实质','形式','意图') NOT NULL COMMENT '元素类型:实质/形式/意图',
+  `element_sub_type` varchar(100) DEFAULT NULL COMMENT '元素子类型(如:具体元素/具象概念/抽象概念/整体形式等)',
+  `element_name` varchar(500) NOT NULL COMMENT '元素名称',
+  `element_description` text COMMENT '元素说明',
+  `element_source` text COMMENT '元素来源',
+  `element_reason` text COMMENT '分类理由',
+  PRIMARY KEY (`id`),
+  KEY `idx_topic_point_element_topic_point_id` (`topic_point_id`),
+  KEY `idx_topic_point_element_post_id` (`post_id`),
+  KEY `idx_topic_point_element_type` (`element_type`)
+) ENGINE=InnoDB AUTO_INCREMENT=135125 DEFAULT CHARSET=utf8mb4;
+
+#### 帖子选题点元素分类完成状态追踪
+CREATE TABLE `post_classification_status` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `post_id` varchar(100) NOT NULL COMMENT '帖子ID',
+  `source_type` varchar(50) NOT NULL COMMENT '元素类型: 实质/形式/意图',
+  `total_elements` int(11) NOT NULL DEFAULT '0' COMMENT '该帖子该类型的元素总数',
+  `classified_elements` int(11) NOT NULL DEFAULT '0' COMMENT '已分类的元素数',
+  `is_completed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否全部分类完成',
+  `last_updated_execution_id` bigint(20) DEFAULT '0' COMMENT '最近更新的执行ID',
+  `updated_at` datetime NOT NULL COMMENT '最后更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uq_pcs_post_source` (`post_id`,`source_type`),
+  KEY `idx_pcs_source_completed` (`source_type`,`is_completed`),
+  KEY `idx_pcs_completed` (`is_completed`)
+) ENGINE=InnoDB AUTO_INCREMENT=42362 DEFAULT CHARSET=utf8mb4 COMMENT='帖子分类完成状态追踪表';
+
+#### 全局分类
+CREATE TABLE `global_category` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '行级主键,每次修改产生新行',
+  `stable_id` bigint(20) NOT NULL COMMENT '逻辑稳定ID,同一分类跨版本保持不变',
+  `name` varchar(500) NOT NULL COMMENT '分类名称',
+  `description` text COMMENT '分类说明',
+  `parent_stable_id` bigint(20) DEFAULT NULL COMMENT '父分类的stable_id,NULL=根节点',
+  `source_type` varchar(50) NOT NULL COMMENT '元素类型: 实质/形式/意图',
+  `category_nature` varchar(50) DEFAULT NULL COMMENT '分类性质: 内容/维度/领域',
+  `level` int(11) DEFAULT NULL COMMENT '层级深度',
+  `path` varchar(1000) DEFAULT NULL COMMENT '完整路径,如 /主体/角色类型/人物角色',
+  `created_at_execution_id` bigint(20) NOT NULL COMMENT '创建此行的执行ID',
+  `retired_at_execution_id` bigint(20) DEFAULT NULL COMMENT '废弃此行的执行ID,NULL=当前有效',
+  `create_reason` text COMMENT '创建/修改原因',
+  PRIMARY KEY (`id`),
+  KEY `idx_gc_stable_id` (`stable_id`),
+  KEY `idx_gc_parent_stable_id` (`parent_stable_id`),
+  KEY `idx_gc_source_type` (`source_type`),
+  KEY `idx_gc_retired` (`retired_at_execution_id`),
+  KEY `idx_gc_created_retired` (`created_at_execution_id`,`retired_at_execution_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=1050 DEFAULT CHARSET=utf8mb4;
+
+#### 全局元素
+CREATE TABLE `global_element` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `name` varchar(500) NOT NULL COMMENT '标准化元素名称',
+  `description` text COMMENT '元素描述',
+  `belong_category_stable_id` bigint(20) NOT NULL COMMENT '所属分类的stable_id',
+  `source_type` varchar(50) NOT NULL COMMENT '元素类型: 实质/形式/意图',
+  `element_sub_type` varchar(100) DEFAULT NULL COMMENT '具体元素/具象概念/抽象概念等',
+  `occurrence_count` int(11) DEFAULT NULL COMMENT '出现次数(跨帖子去重计数)',
+  `created_at_execution_id` bigint(20) NOT NULL COMMENT '创建此行的执行ID',
+  `retired_at_execution_id` bigint(20) DEFAULT NULL COMMENT '废弃此行的执行ID,NULL=当前有效',
+  PRIMARY KEY (`id`),
+  KEY `idx_ge_name` (`name`),
+  KEY `idx_ge_category_stable_id` (`belong_category_stable_id`),
+  KEY `idx_ge_source_type` (`source_type`),
+  KEY `idx_ge_retired` (`retired_at_execution_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=6071 DEFAULT CHARSET=utf8mb4;
+
+#### 元素和分类的映射
+CREATE TABLE `element_classification_mapping` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `post_decode_topic_point_element_id` bigint(20) NOT NULL COMMENT 'FK → PostDecodeTopicPointElement.id',
+  `post_id` varchar(100) DEFAULT NULL COMMENT '冗余帖子ID,便于查询',
+  `element_name` varchar(500) DEFAULT NULL COMMENT '原始元素名称',
+  `element_type` varchar(50) DEFAULT NULL COMMENT '实质/形式/意图',
+  `element_sub_type` varchar(100) DEFAULT NULL COMMENT '具体子类型',
+  `global_element_id` bigint(20) DEFAULT NULL COMMENT '→ GlobalElement.id',
+  `global_category_stable_id` bigint(20) DEFAULT NULL COMMENT '→ GlobalCategory.stable_id',
+  `classification_path` varchar(1000) DEFAULT NULL COMMENT '分类路径,如 /主体/角色类型/人物角色',
+  `classify_execution_id` bigint(20) NOT NULL COMMENT '哪次执行分类的',
+  `created_at` datetime NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_ecm_element_id` (`post_decode_topic_point_element_id`),
+  KEY `idx_ecm_execution_id` (`classify_execution_id`),
+  KEY `idx_ecm_post_id` (`post_id`),
+  KEY `idx_ecm_global_element_id` (`global_element_id`),
+  KEY `idx_ecm_category_stable_id` (`global_category_stable_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=43468 DEFAULT CHARSET=utf8mb4;
+
+#### 分类执行记录
+CREATE TABLE `classify_execution` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `execution_type` varchar(50) NOT NULL COMMENT '执行类型: classify/optimize/rollback',
+  `source_type` varchar(50) DEFAULT NULL COMMENT '元素类型: 实质/形式/意图',
+  `based_execution_id` bigint(20) DEFAULT NULL COMMENT '基于哪次执行(执行链),0=初始',
+  `status` varchar(50) NOT NULL COMMENT '状态: running/success/failed/rolled_back',
+  `batch_info` json DEFAULT NULL COMMENT '批次信息: {"batch_id": "...", "element_count": N, "unique_count": M}',
+  `model_name` varchar(100) DEFAULT NULL COMMENT '模型名称',
+  `trigger_context` text COMMENT '触发上下文',
+  `execution_summary` text COMMENT 'Agent执行总结',
+  `input_tokens` int(11) DEFAULT NULL COMMENT '输入Token数',
+  `output_tokens` int(11) DEFAULT NULL COMMENT '输出Token数',
+  `cost_usd` float DEFAULT NULL COMMENT '费用(USD)',
+  `start_time` datetime NOT NULL COMMENT '开始时间',
+  `end_time` datetime DEFAULT NULL COMMENT '结束时间',
+  `error_message` text COMMENT '错误信息',
+  PRIMARY KEY (`id`),
+  KEY `idx_classify_exec_based_id` (`based_execution_id`),
+  KEY `idx_classify_exec_status` (`status`),
+  KEY `idx_classify_exec_source_type` (`source_type`)
+) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=utf8mb4;
+
+#### 分类批次
+CREATE TABLE `classify_batch` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `batch_name` varchar(200) DEFAULT NULL COMMENT '批次名称,如 实质_batch_001',
+  `source_type` varchar(50) NOT NULL COMMENT '实质/形式/意图',
+  `total_element_count` int(11) DEFAULT NULL COMMENT '总元素数',
+  `unique_element_count` int(11) DEFAULT NULL COMMENT '去重后元素数',
+  `status` varchar(50) NOT NULL COMMENT '状态: pending/running/success/failed',
+  `classify_execution_id` bigint(20) DEFAULT NULL COMMENT '关联执行记录ID',
+  `created_at` datetime NOT NULL COMMENT '创建时间',
+  `completed_at` datetime DEFAULT NULL COMMENT '完成时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_cb_execution_id` (`classify_execution_id`),
+  KEY `idx_cb_source_type` (`source_type`),
+  KEY `idx_cb_status` (`status`)
+) ENGINE=InnoDB AUTO_INCREMENT=93 DEFAULT CHARSET=utf8mb4;
+
+#### 分类执行日志
+CREATE TABLE `classify_execution_log` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `classify_execution_id` bigint(20) NOT NULL COMMENT '关联的分类执行ID',
+  `log_content` longtext NOT NULL COMMENT '完整的执行日志内容',
+  `log_type` varchar(50) NOT NULL COMMENT '日志类型:classify/optimize',
+  `created_at` datetime NOT NULL COMMENT '日志保存时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `classify_execution_id` (`classify_execution_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=202 DEFAULT CHARSET=utf8mb4;
+
+
+## open_aigc_pattern库
+存放帖子解构的选题点元素pattern数据。
+
+### 表结构
+
+#### pattern执行记录
+CREATE TABLE `topic_pattern_execution` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `merge_leve2` varchar(100) DEFAULT NULL COMMENT '筛选条件-二级分类',
+  `platform` varchar(50) DEFAULT NULL COMMENT '筛选条件-平台',
+  `account_name` varchar(200) DEFAULT NULL COMMENT '筛选条件-账号名称',
+  `post_limit` int(11) NOT NULL COMMENT '帖子数量上限',
+  `min_absolute_support` int(11) NOT NULL COMMENT '最小绝对支持度阈值',
+  `classify_execution_id` bigint(20) DEFAULT NULL COMMENT 'FK-分类执行ID',
+  `mining_configs` json DEFAULT NULL COMMENT '挖掘配置JSON',
+  `post_count` int(11) DEFAULT NULL COMMENT '实际帖子数',
+  `itemset_count` int(11) DEFAULT NULL COMMENT '所有config的项集总数',
+  `status` varchar(50) NOT NULL COMMENT '执行状态: running/success/failed',
+  `error_message` text COMMENT '错误信息',
+  `start_time` datetime DEFAULT NULL COMMENT '开始时间',
+  `end_time` datetime DEFAULT NULL COMMENT '结束时间',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb4;
+
+#### pattern执行配置
+CREATE TABLE `topic_pattern_mining_config` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `execution_id` bigint(20) NOT NULL COMMENT 'FK-topic_pattern_execution.id',
+  `dimension_mode` varchar(50) NOT NULL COMMENT '维度模式: full/substance_form_only/point_type_only',
+  `target_depth` varchar(50) NOT NULL COMMENT '挖掘深度: max/all_levels',
+  `transaction_count` int(11) DEFAULT NULL COMMENT '事务帖子数',
+  `itemset_count` int(11) DEFAULT NULL COMMENT '该配置下的项集数',
+  PRIMARY KEY (`id`),
+  KEY `ix_topic_pattern_mining_config_execution_id` (`execution_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=78 DEFAULT CHARSET=utf8mb4;
+
+#### pattern集项
+每一行记录表示一个pattern
+
+CREATE TABLE `topic_pattern_itemset` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `execution_id` bigint(20) NOT NULL COMMENT 'FK-topic_pattern_execution.id',
+  `mining_config_id` bigint(20) NOT NULL COMMENT 'FK-topic_pattern_mining_config.id',
+  `combination_type` varchar(200) NOT NULL COMMENT '维度组合类型',
+  `item_count` int(11) NOT NULL COMMENT '项集中item数量',
+  `support` float NOT NULL COMMENT '相对支持度',
+  `absolute_support` int(11) NOT NULL COMMENT '绝对支持度-包含该项集的帖子数',
+  `dimensions` json DEFAULT NULL COMMENT '涉及的维度JSON数组',
+  `is_cross_point` tinyint(1) NOT NULL COMMENT '是否跨越多个选题点类型',
+  `matched_post_ids` json DEFAULT NULL COMMENT '匹配的帖子ID JSON数组',
+  PRIMARY KEY (`id`),
+  KEY `ix_topic_pattern_itemset_absolute_support` (`absolute_support`),
+  KEY `ix_topic_pattern_itemset_item_count` (`item_count`),
+  KEY `ix_topic_pattern_itemset_mining_config_id` (`mining_config_id`),
+  KEY `ix_topic_pattern_itemset_combination_type` (`combination_type`),
+  KEY `ix_topic_pattern_itemset_execution_id` (`execution_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=122811 DEFAULT CHARSET=utf8mb4;
+
+#### pattern中的元素
+pattern中的元素可能是 category_id 或者 element_name,二者二选一
+如果是 category_id,也可以通过 category_path 解析出分类名,category_path 格式:理念>事件>商业事件>创投活动,分类名为:创投活动
+
+CREATE TABLE `topic_pattern_itemset_item` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `itemset_id` bigint(20) NOT NULL COMMENT 'FK-topic_pattern_itemset.id',
+  `point_type` varchar(50) DEFAULT NULL COMMENT '选题点类型',
+  `dimension` varchar(50) DEFAULT NULL COMMENT '维度: 实质/形式/意图',
+  `category_id` bigint(20) DEFAULT NULL COMMENT 'FK-topic_pattern_category.id',
+  `category_path` varchar(1000) DEFAULT NULL COMMENT '分类路径',
+  `element_name` varchar(500) DEFAULT NULL COMMENT '元素名称-仅name层item有值',
+  PRIMARY KEY (`id`),
+  KEY `ix_topic_pattern_itemset_item_itemset_id` (`itemset_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=492344 DEFAULT CHARSET=utf8mb4;
+
+#### pattern中的分类表
+CREATE TABLE `topic_pattern_category` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `execution_id` bigint(20) NOT NULL COMMENT 'FK-topic_pattern_execution.id',
+  `source_stable_id` bigint(20) DEFAULT NULL COMMENT '原GlobalCategory.stable_id',
+  `source_type` varchar(50) NOT NULL COMMENT '维度类型: 实质/形式/意图',
+  `name` varchar(500) NOT NULL COMMENT '分类名称',
+  `description` text COMMENT '分类描述',
+  `category_nature` varchar(50) DEFAULT NULL COMMENT '分类性质: 内容/维度/领域',
+  `path` varchar(1000) DEFAULT NULL COMMENT '完整路径',
+  `level` int(11) DEFAULT NULL COMMENT '层级深度',
+  `parent_id` bigint(20) DEFAULT NULL COMMENT 'FK-本表id快照内父节点',
+  `parent_source_stable_id` bigint(20) DEFAULT NULL COMMENT '原parent_stable_id',
+  `element_count` int(11) DEFAULT NULL COMMENT '该分类下直属元素数',
+  PRIMARY KEY (`id`),
+  KEY `ix_topic_pattern_category_execution_id` (`execution_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=12572 DEFAULT CHARSET=utf8mb4;
+
+#### pattern中的分类包含的元素表
+CREATE TABLE `topic_pattern_element` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `execution_id` bigint(20) NOT NULL COMMENT 'FK-topic_pattern_execution.id',
+  `post_id` varchar(100) NOT NULL COMMENT '帖子ID',
+  `point_type` varchar(50) NOT NULL COMMENT '选题点类型: 灵感点/目的点/关键点',
+  `point_text` varchar(500) DEFAULT NULL COMMENT '选题点内容文本',
+  `element_type` varchar(50) NOT NULL COMMENT '元素维度: 实质/形式/意图',
+  `name` varchar(500) NOT NULL COMMENT '元素名称',
+  `description` text COMMENT '元素描述',
+  `category_id` bigint(20) DEFAULT NULL COMMENT 'FK-topic_pattern_category.id',
+  `category_path` varchar(1000) DEFAULT NULL COMMENT '冗余分类路径',
+  PRIMARY KEY (`id`),
+  KEY `ix_topic_pattern_element_category_id` (`category_id`),
+  KEY `ix_topic_pattern_element_post_id` (`post_id`),
+  KEY `ix_topic_pattern_element_execution_id` (`execution_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=106913 DEFAULT CHARSET=utf8mb4;

+ 272 - 0
examples_how/overall_derivation/data_export_from_db/export_account_element_classification.py

@@ -0,0 +1,272 @@
+from __future__ import annotations
+
+import json
+import sys
+from datetime import datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any, Optional
+
+from dotenv import load_dotenv
+
+# 让 `import db_utils.*` 可用:把 `examples_how/` 加入 sys.path
+_EXAMPLES_HOW_DIR = Path(__file__).resolve().parents[2]
+if str(_EXAMPLES_HOW_DIR) not in sys.path:
+    sys.path.insert(0, str(_EXAMPLES_HOW_DIR))
+
+from db_utils.mysql_db import get_mysql_db  # noqa: E402
+
+_SOURCE_TYPES = ("实质", "形式", "意图")
+_FILE_NAMES = {"实质": "实质_tree.json", "形式": "形式_tree.json", "意图": "意图_tree.json"}
+
+
+def _safe_int(v: Any) -> Optional[int]:
+    if v is None:
+        return None
+    if isinstance(v, Decimal):
+        return int(v)
+    try:
+        return int(v)
+    except (TypeError, ValueError):
+        return None
+
+
+def _fetch_classification_rows_from_ecm(account_name: str) -> list[dict[str, Any]]:
+    """
+    实质/形式:选题点元素在 element_classification_mapping 中的映射 + 全局元素 + 全局分类。
+    """
+    sql = """
+    SELECT
+      ecm.element_type AS element_type,
+      ecm.global_element_id AS global_element_id,
+      ecm.global_category_stable_id AS global_category_stable_id,
+      ecm.post_id AS post_id,
+      ge.name AS element_name,
+      ge.description AS element_description,
+      ge.occurrence_count AS occurrence_count,
+      gc.name AS category_name,
+      gc.path AS category_path
+    FROM element_classification_mapping ecm
+    INNER JOIN post p
+      ON p.post_id = ecm.post_id
+      AND p.platform_account_name = %s
+    INNER JOIN global_element ge
+      ON ge.id = ecm.global_element_id
+      AND ge.retired_at_execution_id IS NULL
+      AND ge.source_type = ecm.element_type
+    INNER JOIN global_category gc
+      ON gc.stable_id = ecm.global_category_stable_id
+      AND gc.retired_at_execution_id IS NULL
+      AND gc.source_type = ecm.element_type
+    WHERE ecm.global_element_id IS NOT NULL
+      AND ecm.global_category_stable_id IS NOT NULL
+      AND ecm.post_id IS NOT NULL
+      AND TRIM(ecm.post_id) <> ''
+      AND ecm.element_type IN ('实质', '形式')
+    """
+    client = get_mysql_db("default")._client()
+    return client.fetchall(sql, (account_name,))
+
+
+def _fetch_intent_rows_from_decode(account_name: str) -> list[dict[str, Any]]:
+    """
+    意图:不在 element_classification_mapping,也不进 global_element / global_category;
+    仅来自解构表中的原始元素名与说明。
+    """
+    sql = """
+    SELECT
+      '意图' AS element_type,
+      NULL AS global_element_id,
+      NULL AS global_category_stable_id,
+      pdte.post_id AS post_id,
+      pdte.element_name AS element_name,
+      pdte.element_description AS element_description,
+      NULL AS occurrence_count,
+      NULL AS category_name,
+      NULL AS category_path
+    FROM post_decode_topic_point_element pdte
+    INNER JOIN post p
+      ON p.post_id = pdte.post_id
+      AND p.platform_account_name = %s
+    WHERE pdte.element_type = '意图'
+      AND pdte.post_id IS NOT NULL
+      AND TRIM(pdte.post_id) <> ''
+    """
+    client = get_mysql_db("default")._client()
+    return client.fetchall(sql, (account_name,))
+
+
+def _fetch_classification_rows(account_name: str) -> list[dict[str, Any]]:
+    return _fetch_classification_rows_from_ecm(account_name) + _fetch_intent_rows_from_decode(
+        account_name
+    )
+
+
+def _fetch_account_platform(account_name: str) -> str:
+    row = get_mysql_db("default").select_one(
+        table="post",
+        columns="platform",
+        where="platform_account_name=%s",
+        where_params=(account_name,),
+    )
+    if row and row.get("platform"):
+        return str(row["platform"])
+    return "xiaohongshu"
+
+
+def _aggregate_by_type(
+    rows: list[dict[str, Any]],
+) -> dict[str, tuple[list[dict[str, Any]], set[str]]]:
+    """
+    按 element_type 聚合为 data 列表,并收集该维度下出现过的全部 post_id(用于 summary.total_posts)。
+
+    实质/形式:聚合键 (global_element_id, global_category_stable_id)。
+    意图:仅解构数据,聚合键为 element_name;category_path 为空字符串。
+    """
+    buckets: dict[tuple[str, int, int], dict[str, Any]] = {}
+    intent_by_name: dict[str, dict[str, Any]] = {}
+    posts_per_type: dict[str, set[str]] = {s: set() for s in _SOURCE_TYPES}
+
+    for r in rows:
+        stype = str(r.get("element_type") or "")
+        if stype not in _SOURCE_TYPES:
+            continue
+        pid = str(r.get("post_id") or "").strip()
+        if not pid:
+            continue
+
+        posts_per_type[stype].add(pid)
+
+        if stype == "意图":
+            ename = str(r.get("element_name") or "").strip()
+            if not ename:
+                continue
+            if ename not in intent_by_name:
+                occ = _safe_int(r.get("occurrence_count"))
+                intent_by_name[ename] = {
+                    "element_id": None,
+                    "element_name": ename,
+                    "element_description": str(r.get("element_description") or ""),
+                    "category_stable_id": None,
+                    "category_name": "",
+                    "category_path": "",
+                    "occurrence_count": occ if occ is not None else 0,
+                    "post_ids": set(),
+                }
+            intent_by_name[ename]["post_ids"].add(pid)
+            continue
+
+        ge_id = _safe_int(r.get("global_element_id"))
+        cs_id = _safe_int(r.get("global_category_stable_id"))
+        if ge_id is None or cs_id is None:
+            continue
+
+        key = (stype, ge_id, cs_id)
+        if key not in buckets:
+            occ = _safe_int(r.get("occurrence_count"))
+            buckets[key] = {
+                "element_id": ge_id,
+                "element_name": str(r.get("element_name") or ""),
+                "element_description": str(r.get("element_description") or ""),
+                "category_stable_id": cs_id,
+                "category_name": str(r.get("category_name") or ""),
+                "category_path": str(r.get("category_path") or ""),
+                "occurrence_count": occ if occ is not None else 0,
+                "post_ids": set(),
+            }
+        buckets[key]["post_ids"].add(pid)
+
+    out: dict[str, tuple[list[dict[str, Any]], set[str]]] = {}
+    for stype in _SOURCE_TYPES:
+        items: list[dict[str, Any]] = []
+        if stype == "意图":
+            for ename in sorted(intent_by_name.keys()):
+                meta = dict(intent_by_name[ename])
+                pids = sorted(meta.pop("post_ids"))
+                items.append(
+                    {
+                        **meta,
+                        "post_count": len(pids),
+                        "post_ids": pids,
+                    }
+                )
+        else:
+            keys_for_type = sorted(
+                [k for k in buckets.keys() if k[0] == stype],
+                key=lambda k: (k[1], k[2]),
+            )
+            for key in keys_for_type:
+                meta = buckets[key]
+                meta = dict(meta)
+                pids = sorted(meta.pop("post_ids"))
+                items.append(
+                    {
+                        **meta,
+                        "post_count": len(pids),
+                        "post_ids": pids,
+                    }
+                )
+        out[stype] = (items, posts_per_type[stype])
+
+    return out
+
+
+def export_account_element_classification(
+    *,
+    account_name: str,
+    overwrite: bool = True,
+) -> None:
+    base_dir = Path(__file__).resolve().parent
+    out_dir = base_dir.parent / "input" / account_name / "原始数据" / "tree"
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    rows = _fetch_classification_rows(account_name)
+    platform = _fetch_account_platform(account_name)
+    aggregated = _aggregate_by_type(rows)
+    export_time = datetime.now().isoformat()
+
+    for stype in _SOURCE_TYPES:
+        data, all_posts = aggregated[stype]
+        fname = _FILE_NAMES[stype]
+        out_path = out_dir / fname
+        if out_path.exists() and not overwrite:
+            print(f"已存在,跳过: {out_path}")
+            continue
+
+        payload = {
+            "success": True,
+            "data": data,
+            "summary": {
+                "total_elements": len(data),
+                "total_posts": len(all_posts),
+                "source_type": stype,
+                "account_name": account_name,
+                "platform": platform,
+                "export_time": export_time,
+            },
+        }
+        with open(out_path, "w", encoding="utf-8") as f:
+            json.dump(payload, f, ensure_ascii=False, indent=2)
+        print(f"已写入 {out_path}(元素 {len(data)} 条,帖子 {len(all_posts)} 篇)")
+
+
+def main(account_name) -> None:
+    load_dotenv()
+    # parser = argparse.ArgumentParser(
+    #     description="导出账号下帖子解构元素的分类数据(实质/形式/意图 三个 tree json)"
+    # )
+    # parser.add_argument("account_name", type=str, help="账号名称,如 阿里多多酱")
+    # parser.add_argument(
+    #     "--no-overwrite",
+    #     action="store_true",
+    #     help="若目标文件已存在则不覆盖(默认覆盖)",
+    # )
+    # args = parser.parse_args()
+
+    export_account_element_classification(
+        account_name=account_name,
+    )
+
+
+if __name__ == "__main__":
+    main(account_name="空间点阵设计研究室")

+ 268 - 0
examples_how/overall_derivation/data_export_from_db/export_account_pattern.py

@@ -0,0 +1,268 @@
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+from dotenv import load_dotenv
+
+# 让 `import db_utils.*` 可用:把 `examples_how/` 加入 sys.path
+_EXAMPLES_HOW_DIR = Path(__file__).resolve().parents[2]
+if str(_EXAMPLES_HOW_DIR) not in sys.path:
+    sys.path.insert(0, str(_EXAMPLES_HOW_DIR))
+
+from db_utils.mysql_db import get_mysql_db  # noqa: E402
+
+_PATTERN_SOURCE = "pattern"
+
+
+def _target_depth_to_key(target_depth: Any) -> str:
+    """
+    mining_config.target_depth -> 第二层 key,如 3 -> depth_3,max -> depth_max。
+    """
+    s = str(target_depth).strip() if target_depth is not None else ""
+    if not s:
+        return "depth_unknown"
+    if s.isdigit():
+        return f"depth_{int(s)}"
+    safe = re.sub(r"[^\w]+", "_", s).strip("_").lower()
+    return f"depth_{safe}" if safe else "depth_unknown"
+
+
+def _parse_matched_post_ids(raw: Any) -> list[str]:
+    if raw is None:
+        return []
+    if isinstance(raw, list):
+        return [str(x) for x in raw if x is not None and str(x).strip()]
+    if isinstance(raw, str):
+        raw = raw.strip()
+        if not raw:
+            return []
+        try:
+            parsed = json.loads(raw)
+            if isinstance(parsed, list):
+                return [str(x) for x in parsed if x is not None and str(x).strip()]
+        except json.JSONDecodeError:
+            pass
+        return [raw]
+    return [str(raw)]
+
+
+def _float_val(v: Any) -> float:
+    if v is None:
+        return 0.0
+    if isinstance(v, Decimal):
+        return float(v)
+    try:
+        return float(v)
+    except (TypeError, ValueError):
+        return 0.0
+
+
+def _int_val(v: Any) -> int:
+    if v is None:
+        return 0
+    if isinstance(v, Decimal):
+        return int(v)
+    try:
+        return int(v)
+    except (TypeError, ValueError):
+        return 0
+
+
+def _ensure_execution_account(
+    db: Any, execution_id: int, account_name: str
+) -> None:
+    row = db.select_one(
+        table="topic_pattern_execution",
+        columns="id,account_name",
+        where="id=%s",
+        where_params=(execution_id,),
+    )
+    if not row:
+        raise ValueError(f"未找到 topic_pattern_execution.id={execution_id}")
+    db_account = (row.get("account_name") or "").strip()
+    if db_account and db_account != account_name.strip():
+        raise ValueError(
+            f"execution_id={execution_id} 对应账号为 {db_account!r},与输入 {account_name!r} 不一致"
+        )
+
+
+def _load_categories_by_id(db: Any, execution_id: int) -> dict[int, dict[str, Any]]:
+    rows = db.select(
+        table="topic_pattern_category",
+        columns="*",
+        where="execution_id=%s",
+        where_params=(execution_id,),
+    )
+    out: dict[int, dict[str, Any]] = {}
+    for r in rows:
+        cid = r.get("id")
+        if cid is None:
+            continue
+        try:
+            out[int(cid)] = r
+        except (TypeError, ValueError):
+            continue
+    return out
+
+
+def _build_item_entry(
+    ri: dict[str, Any], cat_by_id: dict[int, dict[str, Any]]
+) -> dict[str, Any]:
+    cid_raw = ri.get("category_id")
+    dimension = str(ri.get("dimension") or "").strip()
+    cat_path_raw = ri.get("category_path")
+
+    if cid_raw is not None:
+        try:
+            cid = int(cid_raw)
+        except (TypeError, ValueError):
+            cid = None
+        cat = cat_by_id.get(cid) if cid is not None else None
+        name = str((cat or {}).get("name") or ri.get("element_name") or "")
+        path = ""
+        if cat_path_raw:
+            path = str(cat_path_raw)
+        elif cat:
+            path = str((cat or {}).get("path") or "")
+        if not dimension and cat:
+            dimension = str((cat or {}).get("source_type") or "")
+        typ = "分类"
+    else:
+        name = str(ri.get("element_name") or "")
+        path = str(cat_path_raw or "")
+        typ = "元素"
+
+    return {
+        "name": name,
+        "path": path,
+        "dimension": dimension,
+        "type": typ,
+    }
+
+
+def export_account_pattern(
+    *,
+    account_name: str,
+    execution_id: int,
+    overwrite: bool = True,
+) -> Path:
+    db = get_mysql_db(_PATTERN_SOURCE)
+    _ensure_execution_account(db, execution_id, account_name)
+
+    base_dir = Path(__file__).resolve().parent
+    out_dir = base_dir.parent / "input" / account_name / "原始数据" / "pattern"
+    out_dir.mkdir(parents=True, exist_ok=True)
+    out_path = out_dir / "pattern.json"
+
+    if out_path.exists() and not overwrite:
+        print(f"已存在,跳过: {out_path}")
+        return out_path
+
+    configs = db.select(
+        table="topic_pattern_mining_config",
+        columns="*",
+        where="execution_id=%s",
+        where_params=(execution_id,),
+        order_by="id ASC",
+    )
+    if not configs:
+        raise ValueError(
+            f"execution_id={execution_id} 下没有 topic_pattern_mining_config 记录"
+        )
+
+    cat_by_id = _load_categories_by_id(db, execution_id)
+
+    itemsets = db.select(
+        table="topic_pattern_itemset",
+        columns="*",
+        where="execution_id=%s",
+        where_params=(execution_id,),
+        order_by="id ASC",
+    )
+    itemset_ids = [int(x["id"]) for x in itemsets if x.get("id") is not None]
+    items_by_itemset: dict[int, list[dict[str, Any]]] = {i: [] for i in itemset_ids}
+
+    if itemset_ids:
+        placeholders = ",".join(["%s"] * len(itemset_ids))
+        sql_items = f"""
+        SELECT * FROM topic_pattern_itemset_item
+        WHERE itemset_id IN ({placeholders})
+        ORDER BY itemset_id ASC, id ASC
+        """
+        all_items = db._client().fetchall(sql_items, tuple(itemset_ids))
+        for ri in all_items:
+            iid = ri.get("itemset_id")
+            if iid is None:
+                continue
+            try:
+                items_by_itemset.setdefault(int(iid), []).append(ri)
+            except (TypeError, ValueError):
+                continue
+
+    config_by_id = {int(c["id"]): c for c in configs if c.get("id") is not None}
+
+    nested: dict[str, Any] = {}
+
+    for it in itemsets:
+        mcid = it.get("mining_config_id")
+        if mcid is None:
+            continue
+        try:
+            mcid_int = int(mcid)
+        except (TypeError, ValueError):
+            continue
+        cfg = config_by_id.get(mcid_int)
+        if not cfg:
+            continue
+
+        dm = str(cfg.get("dimension_mode") or "").strip()
+        if not dm:
+            continue
+        td_key = _target_depth_to_key(cfg.get("target_depth"))
+
+        matched = _parse_matched_post_ids(it.get("matched_post_ids"))
+        post_count = len(set(matched)) if matched else _int_val(it.get("absolute_support"))
+
+        item_objs = []
+        for ri in items_by_itemset.get(int(it["id"]), []):
+            item_objs.append(_build_item_entry(ri, cat_by_id))
+
+        pattern_obj = {
+            "id": str(it.get("id") or ""),
+            "combination_type": str(it.get("combination_type") or ""),
+            "support": _float_val(it.get("support")),
+            "absolute_support": _int_val(it.get("absolute_support")),
+            "length": _int_val(it.get("item_count")),
+            "post_count": post_count,
+            "matched_posts": matched,
+            "items": item_objs,
+        }
+
+        nested.setdefault(dm, {})
+        nested[dm].setdefault(td_key, [])
+        nested[dm][td_key].append(pattern_obj)
+
+    with open(out_path, "w", encoding="utf-8") as f:
+        json.dump(nested, f, ensure_ascii=False, indent=2)
+
+    print(f"已写入 {out_path}(execution_id={execution_id},itemset 条数 {len(itemsets)})")
+    return out_path
+
+
+def main(account_name, execution_id) -> None:
+    load_dotenv()
+
+    export_account_pattern(
+        account_name=account_name,
+        execution_id=execution_id,
+    )
+
+
+if __name__ == "__main__":
+    main(account_name="空间点阵设计研究室", execution_id=40)

+ 211 - 0
examples_how/overall_derivation/data_export_from_db/export_post.py

@@ -0,0 +1,211 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Optional
+
+from dotenv import load_dotenv
+
+# 让 `import db_utils.*` 可用:把 `examples_how/` 加入 sys.path
+_EXAMPLES_HOW_DIR = Path(__file__).resolve().parents[2]
+if str(_EXAMPLES_HOW_DIR) not in sys.path:
+    sys.path.insert(0, str(_EXAMPLES_HOW_DIR))
+
+from db_utils.mysql_db import mysql_db  # noqa: E402
+
+
+def _to_ms(v: Any) -> Optional[int]:
+    """把 datetime/int/str/float 转换为毫秒时间戳;返回 None 表示无法转换。"""
+    if v is None:
+        return None
+
+    if isinstance(v, datetime):
+        return int(v.timestamp() * 1000)
+
+    # pymysql 可能把 BIGINT/JSON 数字返回为 int/float,或把 datetime 返回为字符串
+    if isinstance(v, (int, float)):
+        n = float(v)
+        # 常见情况:ms (>= 1e12),s (>= 1e9)
+        if n >= 1e12:
+            return int(n)
+        if n >= 1e9:
+            return int(n * 1000)
+        return int(n)
+
+    if isinstance(v, str):
+        s = v.strip()
+        if not s:
+            return None
+        # 尝试数字
+        if s.isdigit():
+            return _to_ms(int(s))
+        # 尝试 datetime 字符串
+        for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
+            try:
+                dt = datetime.strptime(s, fmt)
+                return int(dt.timestamp() * 1000)
+            except ValueError:
+                continue
+        return None
+
+    return None
+
+
+def _format_datetime_ms_as_str(v: Any) -> Optional[str]:
+    ms = _to_ms(v)
+    if ms is None:
+        return None
+    dt = datetime.fromtimestamp(ms / 1000)
+    return dt.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _parse_images_field(images_field: Any) -> list[str]:
+    if images_field is None:
+        return []
+    if isinstance(images_field, list):
+        return [str(x) for x in images_field if x]
+    if isinstance(images_field, tuple):
+        return [str(x) for x in images_field if x]
+    if isinstance(images_field, str):
+        s = images_field.strip()
+        if not s:
+            return []
+        # 可能是 JSON 字符串
+        try:
+            parsed = json.loads(s)
+            if isinstance(parsed, list):
+                return [str(x) for x in parsed if x]
+            if isinstance(parsed, dict):
+                return [str(x) for x in parsed.values() if x]
+        except Exception:
+            # 兜底:把原字符串当成单个图片 url
+            return [s]
+        return []
+    # 兜底:不认识的类型,尽量转成字符串列表
+    return [str(images_field)]
+
+
+def _build_post_json(row: dict[str, Any]) -> dict[str, Any]:
+    post_id = str(row.get("post_id") or "")
+    publish_ts_ms = _to_ms(row.get("publish_timestamp"))
+
+    images = _parse_images_field(row.get("images"))
+    publish_date_str = _format_datetime_ms_as_str(
+        publish_ts_ms if publish_ts_ms is not None else row.get("gmt_create")
+    )
+
+    # DB 文档里没有 modify/update_timestamp,但你期望输出字段存在,因此用 import_date 兜底填充
+    import_date_ms = _to_ms(row.get("import_date"))
+
+    return {
+        "channel_content_id": post_id,
+        "link": f"https://www.xiaohongshu.com/explore/{post_id}" if post_id else "",
+        "xsec_token": "",
+        "comment_count": int(row.get("comment_count") or 0),
+        "images": images,
+        "like_count": int(row.get("like_count") or 0),
+        "body_text": str(row.get("body_text") or ""),
+        "title": str(row.get("title") or ""),
+        "collect_count": int(row.get("collect_count") or 0),
+        "channel_account_id": str(row.get("platform_account_id") or ""),
+        "channel_account_name": str(row.get("platform_account_name") or ""),
+        "publish_timestamp": publish_ts_ms,
+        "modify_timestamp": import_date_ms,
+        "update_timestamp": import_date_ms,
+        "publish_date": publish_date_str or "",
+        "content_type": "note",
+        "video": {},
+    }
+
+
+def export_posts_by_account(
+    *,
+    account_name: str,
+    page_size: int = 500,
+    limit: Optional[int] = None,
+    overwrite: bool = False,
+) -> None:
+    base_dir = Path(__file__).resolve().parent
+    out_dir = (
+        base_dir.parent
+        / "input"
+        / account_name
+        / "原始数据"
+        / "post_data"
+    )
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    columns = ",".join(
+        [
+            "id",
+            "post_id",
+            "platform_account_id",
+            "platform_account_name",
+            "publish_timestamp",
+            "title",
+            "body_text",
+            "like_count",
+            "comment_count",
+            "collect_count",
+            "images",
+            "gmt_create",
+            "import_date",
+        ]
+    )
+
+    # keyset 分页:避免 offset 大数据量性能问题
+    last_id = 0
+    exported = 0
+
+    while True:
+        where = "platform_account_name=%s AND id>%s"
+        where_params = (account_name, last_id)
+        rows = mysql_db.select(
+            table="post",
+            columns=columns,
+            where=where,
+            where_params=where_params,
+            order_by="id ASC",
+            limit=page_size,
+        )
+        if not rows:
+            break
+
+        for row in rows:
+            post_id = str(row.get("post_id") or "")
+            if not post_id:
+                continue
+
+            out_path = out_dir / f"{post_id}.json"
+            if out_path.exists() and not overwrite:
+                continue
+
+            data = _build_post_json(row)
+            with open(out_path, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False, indent=2)
+
+            exported += 1
+            if limit is not None and exported >= limit:
+                print(f"到达 limit={limit},提前停止。当前已写入 {exported} 个文件。")
+                return
+
+        last_id = int(rows[-1].get("id") or last_id)
+
+    print(f"导出完成:account_name={account_name},共写入 {exported} 个帖子文件。")
+
+
+def main(account_name) -> None:
+    load_dotenv()
+
+    export_posts_by_account(
+        account_name=account_name,
+        overwrite=True,
+    )
+
+
+if __name__ == "__main__":
+    main("空间点阵设计研究室")
+

+ 226 - 0
examples_how/overall_derivation/data_export_from_db/export_post_decode.py

@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from collections import defaultdict
+from typing import Any, Optional
+from dotenv import load_dotenv
+
+# 让 `import db_utils.*` 可用:把 `examples_how/` 加入 sys.path
+_EXAMPLES_HOW_DIR = Path(__file__).resolve().parents[2]
+if str(_EXAMPLES_HOW_DIR) not in sys.path:
+    sys.path.insert(0, str(_EXAMPLES_HOW_DIR))
+
+from db_utils.mysql_db import mysql_db  # noqa: E402
+
+
+def _empty_decode_json(post_id: str) -> dict[str, Any]:
+    return {
+        "帖子ID": post_id,
+        "最终选题结果": "",
+        "选题类型": "",
+        "核心吸引力": "",
+        "主要目的": "",
+        "置信度": "",
+        "灵感点": [],
+        "目的点": [],
+        "关键点": [],
+    }
+
+
+def _element_to_dict(row: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "元素名称": str(row.get("element_name") or ""),
+        "元素说明": str(row.get("element_description") or ""),
+        "元素来源": str(row.get("element_source") or ""),
+        "分类理由": str(row.get("element_reason") or ""),
+        "元素类型": str(row.get("element_type") or ""),
+        "元素子类型": str(row.get("element_sub_type") or ""),
+    }
+
+
+def _build_topic_point_item(
+    tp_row: dict[str, Any],
+    elements_by_tp: dict[int, list[dict[str, Any]]],
+) -> dict[str, Any]:
+    tp_id = int(tp_row.get("id") or 0)
+    elems = elements_by_tp.get(tp_id, [])
+    elems_sorted = sorted(elems, key=lambda r: int(r.get("id") or 0))
+    return {
+        "选题点": str(tp_row.get("topic_point_result") or ""),
+        "选题点描述": str(tp_row.get("topic_point_description") or ""),
+        "选题点元素": [_element_to_dict(e) for e in elems_sorted],
+    }
+
+
+def _iter_post_ids_for_account(account_name: str, *, page_size: int = 500) -> Any:
+    """按 keyset 分页产出 post_id。"""
+    last_id = 0
+    while True:
+        rows = mysql_db.select(
+            table="post",
+            columns="id,post_id",
+            where="platform_account_name=%s AND id>%s",
+            where_params=(account_name, last_id),
+            order_by="id ASC",
+            limit=page_size,
+        )
+        if not rows:
+            break
+        for row in rows:
+            pid = str(row.get("post_id") or "")
+            if pid:
+                yield pid
+        last_id = int(rows[-1].get("id") or last_id)
+
+
+def export_post_decode_for_account(
+    *,
+    account_name: str,
+    batch_post_ids: int = 200,
+    limit: Optional[int] = None,
+    overwrite: bool = False,
+) -> None:
+    base_dir = Path(__file__).resolve().parent
+    out_dir = (
+        base_dir.parent
+        / "input"
+        / account_name
+        / "原始数据"
+        / "解构内容"
+    )
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    exported = 0
+    batch: list[str] = []
+
+    def flush_batch(post_ids: list[str]) -> bool:
+        """写入一批帖子;若应停止(达到 limit)返回 True。"""
+        nonlocal exported
+        if not post_ids:
+            return False
+
+        # 同一帖子多条解构结果时,取 id 最大的一条
+        placeholders = ",".join(["%s"] * len(post_ids))
+        decode_rows = mysql_db.select(
+            table="post_decode_result",
+            columns="*",
+            where=f"post_id IN ({placeholders})",
+            where_params=tuple(post_ids),
+            order_by="id ASC",
+        )
+        decode_by_post: dict[str, dict[str, Any]] = {}
+        for r in decode_rows:
+            pid = str(r.get("post_id") or "")
+            if pid:
+                decode_by_post[pid] = r  # 同 post_id 后出现的 id 更大
+
+        result_ids = [
+            int(r["id"])
+            for r in decode_by_post.values()
+            if r.get("id") is not None
+        ]
+        topic_by_result: DefaultDict[int, list[dict[str, Any]]] = defaultdict(list)
+        if result_ids:
+            ph2 = ",".join(["%s"] * len(result_ids))
+            tps = mysql_db.select(
+                table="post_decode_topic_point",
+                columns="*",
+                where=f"post_decode_result_id IN ({ph2})",
+                where_params=tuple(result_ids),
+                order_by="id ASC",
+            )
+            for tp in tps:
+                rid = int(tp.get("post_decode_result_id") or 0)
+                topic_by_result[rid].append(tp)
+
+        tp_ids = []
+        for tlist in topic_by_result.values():
+            for tp in tlist:
+                if tp.get("id") is not None:
+                    tp_ids.append(int(tp["id"]))
+
+        elements_by_tp: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        if tp_ids:
+            ph3 = ",".join(["%s"] * len(tp_ids))
+            elems = mysql_db.select(
+                table="post_decode_topic_point_element",
+                columns="*",
+                where=f"topic_point_id IN ({ph3})",
+                where_params=tuple(tp_ids),
+                order_by="id ASC",
+            )
+            for e in elems:
+                tpid = int(e.get("topic_point_id") or 0)
+                elements_by_tp[tpid].append(e)
+
+        for post_id in post_ids:
+            out_path = out_dir / f"{post_id}.json"
+            if out_path.exists() and not overwrite:
+                continue
+
+            dec = decode_by_post.get(post_id)
+            if not dec:
+                data = _empty_decode_json(post_id)
+            else:
+                rid = int(dec.get("id") or 0)
+                data = {
+                    "帖子ID": post_id,
+                    "最终选题结果": str(dec.get("topic") or ""),
+                    "选题类型": str(dec.get("topic_type") or ""),
+                    "核心吸引力": str(dec.get("core_attraction") or ""),
+                    "主要目的": str(dec.get("main_purpose") or ""),
+                    "置信度": str(dec.get("confidence") or ""),
+                    "灵感点": [],
+                    "目的点": [],
+                    "关键点": [],
+                }
+                tps = topic_by_result.get(rid, [])
+                for tp in tps:
+                    ttype = str(tp.get("topic_point_type") or "")
+                    item = _build_topic_point_item(tp, elements_by_tp)
+                    if ttype == "灵感点":
+                        data["灵感点"].append(item)
+                    elif ttype == "目的点":
+                        data["目的点"].append(item)
+                    elif ttype == "关键点":
+                        data["关键点"].append(item)
+
+            with open(out_path, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False, indent=2)
+
+            exported += 1
+            if limit is not None and exported >= limit:
+                return True
+        return False
+
+    for post_id in _iter_post_ids_for_account(account_name):
+        batch.append(post_id)
+        if len(batch) >= batch_post_ids:
+            if flush_batch(batch):
+                print(f"到达 limit={limit},提前停止。已写入 {exported} 个文件。")
+                return
+            batch = []
+
+    if flush_batch(batch):
+        print(f"到达 limit={limit},提前停止。已写入 {exported} 个文件。")
+        return
+
+    print(
+        f"解构数据导出完成:account_name={account_name},共写入 {exported} 个 JSON 文件。"
+    )
+
+
+def main(account_name) -> None:
+    load_dotenv()
+
+    export_post_decode_for_account(
+        account_name=account_name,
+        overwrite=True,
+    )
+
+
+if __name__ == "__main__":
+    main("空间点阵设计研究室")

+ 105 - 0
examples_how/overall_derivation/data_process/extract_post_topic.py

@@ -0,0 +1,105 @@
+"""
+从帖子解构内容中提取选题相关词:
+- 新格式:灵感点/目的点/关键点 下每项「选题点元素」中的「元素名称」(不提取「选题点」字段本身)
+- 旧格式(兼容):分词结果 中每项的「词」
+去重后输出。
+"""
+import json
+from pathlib import Path
+
+
+def _append_from_deconstruct_item(item: dict, topics: list[str]) -> None:
+    if not isinstance(item, dict):
+        return
+    for el in item.get("选题点元素") or []:
+        if not isinstance(el, dict):
+            continue
+        name = el.get("元素名称")
+        if name and isinstance(name, str) and name.strip():
+            topics.append(name.strip())
+    for seg in item.get("分词结果") or []:
+        if not isinstance(seg, dict):
+            continue
+        word = seg.get("词")
+        if word and isinstance(word, str) and word.strip():
+            topics.append(word.strip())
+
+
+def extract_post_topic(account_name: str, post_id: str) -> list[str]:
+    """
+    从解构内容中提取选题点元素(元素名称)并去重;不提取「选题点」字段。
+
+    :param account_name: 账号名
+    :param post_id: 帖子ID
+    :return: 去重后的字符串列表
+    """
+    overall_derivation_dir = Path(__file__).resolve().parent.parent
+    input_path = (
+        overall_derivation_dir
+        / "input"
+        / account_name
+        / "原始数据"
+        / "解构内容"
+        / f"{post_id}.json"
+    )
+
+    with open(input_path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    topics: list[str] = []
+    for key in ("灵感点", "目的点", "关键点"):
+        for item in data.get(key, []) or []:
+            _append_from_deconstruct_item(item, topics)
+
+    # 去重且保持首次出现顺序
+    seen = set()
+    unique_topics: list[str] = []
+    for w in topics:
+        if w not in seen:
+            seen.add(w)
+            unique_topics.append(w)
+
+    return unique_topics
+
+
+def _load_post_id_list_from_exclude_note_ids(account_name: str) -> list[str]:
+    """从 input/{account_name}/原始数据/exclude_note_ids.json 读取帖子 ID 列表(字符串数组)。"""
+    overall_derivation_dir = Path(__file__).resolve().parent.parent
+    path = overall_derivation_dir / "input" / account_name / "原始数据" / "exclude_note_ids.json"
+    if not path.is_file():
+        raise FileNotFoundError(f"未找到帖子 ID 列表文件: {path}")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not isinstance(data, list):
+        raise ValueError(f"exclude_note_ids.json 应为字符串数组: {path}")
+    out: list[str] = []
+    for x in data:
+        if isinstance(x, str) and x.strip():
+            out.append(x.strip())
+    return out
+
+
+def main(account_name: str, post_id: str):
+    # parser = argparse.ArgumentParser(description="从解构内容中提取选题点")
+    # parser.add_argument("account_name", help="账号名")
+    # parser.add_argument("post_id", help="帖子ID")
+    # args = parser.parse_args()
+
+    topics = extract_post_topic(account_name, post_id)
+
+    overall_derivation_dir = Path(__file__).resolve().parent.parent
+    out_dir = overall_derivation_dir / "input" / account_name / "处理后数据" / "post_topic"
+    out_dir.mkdir(parents=True, exist_ok=True)
+    out_path = out_dir / f"{post_id}.json"
+
+    with open(out_path, "w", encoding="utf-8") as f:
+        json.dump(topics, f, ensure_ascii=False, indent=2)
+
+    print(f"已写入 {len(topics)} 个选题点元素到 {out_path}")
+
+
+if __name__ == "__main__":
+    account_name = "空间点阵设计研究室"
+    post_id_list = _load_post_id_list_from_exclude_note_ids(account_name)
+    for post_id in post_id_list:
+        main(account_name=account_name, post_id=post_id)

+ 746 - 0
examples_how/overall_derivation/data_process/how_tree_data_process.py

@@ -0,0 +1,746 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+How 人设树处理:从「原始数据/tree」与「原始数据/point_tree_weight」生成与
+「实质_point_tree_how.json」同结构的输出。
+
+可选:`原始数据/exclude_note_ids.json` 中的帖子 ID 不参与建树(权重聚合与带 post_ids 的扁平 data 行会过滤)。
+
+用法:
+  python how_tree_data_process.py --account_name 阿里多多酱
+
+依赖: 环境变量 GEMINI_API_KEY(用于子分类关系判断;缺失时采用保守默认「有交集」)
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+try:
+    import httpx
+except ImportError:
+    httpx = None  # type: ignore
+
+# -----------------------------------------------------------------------------
+# 路径
+# -----------------------------------------------------------------------------
+
+_SCRIPT_DIR = Path(__file__).resolve().parent
+_OVERALL_DIR = _SCRIPT_DIR.parent
+_DEFAULT_PROMPT = _OVERALL_DIR / "prompt" / "judge_category_relation.md"
+_DEFAULT_MODEL = "gemini-3-flash-preview"
+
+
+def _input_base(account_name: str) -> Path:
+    return _OVERALL_DIR / "input" / account_name / "原始数据"
+
+
+def _output_tree_dir(account_name: str) -> Path:
+    return _OVERALL_DIR / "input" / account_name / "处理后数据" / "tree"
+
+
+def _cache_dir(account_name: str) -> Path:
+    d = _output_tree_dir(account_name) / ".cache_relation" / account_name
+    d.mkdir(parents=True, exist_ok=True)
+    return d
+
+
+def load_exclude_note_ids(base: Path) -> set[str]:
+    """
+    读取「原始数据/exclude_note_ids.json」,格式为帖子 ID 字符串数组。
+    文件不存在或解析失败时返回空集合。
+    """
+    path = base / "exclude_note_ids.json"
+    if not path.exists():
+        return set()
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            raw = json.load(f)
+        if isinstance(raw, list):
+            return {str(x).strip() for x in raw if str(x).strip()}
+        if isinstance(raw, dict) and "ids" in raw:
+            v = raw["ids"]
+            if isinstance(v, list):
+                return {str(x).strip() for x in v if str(x).strip()}
+    except Exception as e:
+        print(f"警告: 读取排除帖子列表失败 {path}: {e}")
+    return set()
+
+
+def _row_has_posts_after_exclude(row: Dict[str, Any], exclude_ids: set[str]) -> bool:
+    """
+    若行带 post_ids 且去掉排除 ID 后仍非空,则参与建树;
+    若无 post_ids 或为空列表,则仍参与(仅结构、由权重侧决定帖子)。
+    """
+    if not exclude_ids:
+        return True
+    pids = row.get("post_ids")
+    if not isinstance(pids, list) or not pids:
+        return True
+    return any(pid not in exclude_ids for pid in pids if pid)
+
+
+def _filter_data_rows_for_exclude(
+    rows: List[Dict[str, Any]], exclude_ids: set[str]
+) -> List[Dict[str, Any]]:
+    if not exclude_ids:
+        return rows
+    return [r for r in rows if _row_has_posts_after_exclude(r, exclude_ids)]
+
+
+# -----------------------------------------------------------------------------
+# 扁平 tree JSON -> 分类树(直接元素 / 子分类)
+# -----------------------------------------------------------------------------
+
+
+def _find_or_create_classification(
+    level: List[Dict[str, Any]], name: str
+) -> Dict[str, Any]:
+    for node in level:
+        if node.get("分类名称") == name:
+            return node
+    node = {"分类名称": name, "直接元素": [], "子分类": []}
+    level.append(node)
+    return node
+
+
+def _merge_path_element(
+    classification_tree: List[Dict[str, Any]],
+    segments: List[str],
+    element: str,
+) -> None:
+    if not segments:
+        return
+    name = segments[0]
+    node = _find_or_create_classification(classification_tree, name)
+    if len(segments) == 1:
+        if element and element not in node["直接元素"]:
+            node["直接元素"].append(element)
+    else:
+        _merge_path_element(node["子分类"], segments[1:], element)
+
+
+def _rows_to_classification_tree(
+    rows: List[Dict[str, Any]],
+) -> Tuple[List[Dict[str, Any]], List[str]]:
+    """
+    返回 (分类树, root 下直接挂接的元素名列表)。
+    category_path 为空时(常见于「意图」维度),元素不进入子分类,直接挂在 root 下。
+    """
+    tree: List[Dict[str, Any]] = []
+    root_direct: List[str] = []
+    for row in rows:
+        raw_path = row.get("category_path") or ""
+        element = (row.get("element_name") or "").strip()
+        segments = [s for s in raw_path.strip("/").split("/") if s]
+        if not segments:
+            if element and element not in root_direct:
+                root_direct.append(element)
+            continue
+        _merge_path_element(tree, segments, element)
+    return tree, root_direct
+
+
+def _is_classification_style(nodes: Any) -> bool:
+    if not isinstance(nodes, list) or not nodes:
+        return False
+    n0 = nodes[0]
+    return isinstance(n0, dict) and (
+        "分类名称" in n0 or "直接元素" in n0 or "子分类" in n0
+    )
+
+
+def load_classification_tree_from_file(
+    tree_json_path: Path,
+    exclude_note_ids: Optional[set[str]] = None,
+) -> Optional[Tuple[List[Dict[str, Any]], List[str]]]:
+    """
+    支持:
+    1) 扁平 data[](category_path + element_name;path 空则元素进 root 直挂列表)
+    2) 与 generate_how_point_tree 相同的 classification_tree JSON
+
+    返回 (最终分类树, root 直挂元素名);解析失败返回 None。
+    """
+    with open(tree_json_path, "r", encoding="utf-8") as f:
+        tree_data = json.load(f)
+
+    if isinstance(tree_data, dict):
+        if "追加分类结果" in tree_data:
+            ft = tree_data["追加分类结果"].get("最终分类树")
+            if ft is not None:
+                return ft, []
+        if "分类结果" in tree_data:
+            ft = tree_data["分类结果"].get("最终分类树")
+            if ft is not None:
+                return ft, []
+        ft = tree_data.get("最终分类树")
+        if ft is not None and _is_classification_style(ft):
+            return ft, []
+        rows = tree_data.get("data")
+        if isinstance(rows, list):
+            if not rows:
+                return [], []
+            if isinstance(rows[0], dict) and (
+                "category_path" in rows[0] or "element_name" in rows[0]
+            ):
+                rows = _filter_data_rows_for_exclude(rows, exclude_note_ids or set())
+                return _rows_to_classification_tree(rows)
+    if isinstance(tree_data, list):
+        if not tree_data:
+            return [], []
+        if isinstance(tree_data[0], dict) and (
+            "category_path" in tree_data[0] or "element_name" in tree_data[0]
+        ):
+            rows = _filter_data_rows_for_exclude(tree_data, exclude_note_ids or set())
+            return _rows_to_classification_tree(rows)
+        if _is_classification_style(tree_data):
+            return tree_data, []
+
+    return None
+
+
+# -----------------------------------------------------------------------------
+# 权重(与 generate_how_point_tree.load_weight_scores 一致)
+# -----------------------------------------------------------------------------
+
+
+def load_weight_scores(
+    weight_file: Path,
+    exclude_note_ids: Optional[set[str]] = None,
+) -> Tuple[Dict[str, float], Dict[str, set], int]:
+    if not weight_file.exists():
+        print(f"警告: 权重分文件不存在: {weight_file}")
+        return {}, {}, 0
+    ex = exclude_note_ids or set()
+    try:
+        with open(weight_file, "r", encoding="utf-8") as f:
+            weight_data = json.load(f)
+        weight_map: Dict[str, float] = {}
+        post_ids_map: Dict[str, set] = {}
+        all_post_ids: set = set()
+        for item in weight_data:
+            word = item.get("词", "")
+            score = item.get("归一权重分", 0.0)
+            if word:
+                post_ids: set = set()
+                for detail in item.get("权重分详情", []):
+                    post_id = detail.get("帖子ID", "")
+                    if post_id and post_id not in ex:
+                        post_ids.add(post_id)
+                        all_post_ids.add(post_id)
+                post_ids_map[word] = post_ids
+                if not post_ids:
+                    weight_map[word] = 0.0
+                else:
+                    weight_map[word] = score
+        total_post_count = len(all_post_ids)
+        return weight_map, post_ids_map, total_post_count
+    except Exception as e:
+        print(f"加载权重分数据失败: {e}")
+        return {}, {}, 0
+
+
+# -----------------------------------------------------------------------------
+# 建树、概率、常量标记(与 generate_how_point_tree 一致)
+# -----------------------------------------------------------------------------
+
+
+def build_id_node(
+    element: str,
+    weight_map: Dict[str, float],
+    post_ids_map: Dict[str, set],
+) -> Dict[str, Any]:
+    post_ids = post_ids_map.get(element, set())
+    post_ids_list = list(post_ids)
+    return {
+        "_type": "ID",
+        "_persona_weight_score": round(weight_map.get(element, 0.0), 4),
+        "_post_count": len(post_ids_list),
+        "_post_ids": post_ids_list,
+    }
+
+
+def build_tree_node_from_classification(
+    classification_node: Dict,
+    weight_map: Dict[str, float],
+    post_ids_map: Dict[str, set],
+    dimension: str,
+) -> Dict[str, Any]:
+    direct_elements = classification_node.get("直接元素", [])
+    sub_classifications = classification_node.get("子分类", [])
+    children: Dict[str, Any] = {}
+
+    for element in direct_elements:
+        if element:
+            children[element] = build_id_node(element, weight_map, post_ids_map)
+
+    for sub_class in sub_classifications:
+        sub_node = build_tree_node_from_classification(
+            sub_class, weight_map, post_ids_map, dimension
+        )
+        sub_node_name = sub_class.get("分类名称", "")
+        if sub_node_name:
+            children[sub_node_name] = sub_node
+
+    def count_leaves_and_sum_scores(node: Dict) -> Tuple[int, float]:
+        if node.get("_type") == "ID":
+            return (1, node.get("_persona_weight_score", 0.0))
+        if node.get("_type") == "class":
+            leaf_count = 0
+            total_score = 0.0
+            for child in node.get("children", {}).values():
+                c, s = count_leaves_and_sum_scores(child)
+                leaf_count += c
+                total_score += s
+            return (leaf_count, total_score)
+        return (0, 0.0)
+
+    def collect_post_ids(node: Dict) -> set:
+        if node.get("_type") == "ID":
+            ids = node.get("_post_ids", [])
+            return set(ids) if isinstance(ids, list) else ids
+        if node.get("_type") == "class":
+            all_post_ids = set()
+            for child in node.get("children", {}).values():
+                all_post_ids |= collect_post_ids(child)
+            return all_post_ids
+        return set()
+
+    total_score = 0.0
+    for child in children.values():
+        _, score = count_leaves_and_sum_scores(child)
+        total_score += score
+
+    all_post_ids = set()
+    for child in children.values():
+        all_post_ids |= collect_post_ids(child)
+    total_post_count = len(all_post_ids)
+
+    result: Dict[str, Any] = {
+        "_type": "class",
+        "_persona_weight_score": round(total_score, 4),
+        "_post_count": total_post_count,
+        "_post_ids": list(all_post_ids),
+    }
+    if children:
+        result["children"] = children
+    return result
+
+
+def build_tree_from_classification(
+    classification_tree: List[Dict],
+    weight_map: Dict[str, float],
+    post_ids_map: Dict[str, set],
+    total_post_count: int,
+    dimension: str,
+    root_direct_elements: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    root_children: Dict[str, Any] = {}
+    for classification_node in classification_tree:
+        node = build_tree_node_from_classification(
+            classification_node, weight_map, post_ids_map, dimension
+        )
+        node_name = classification_node.get("分类名称", "")
+        if node_name:
+            root_children[node_name] = node
+
+    # 意图等:category_path 为空时元素直接作为 root 的子节点(_type=ID)
+    if root_direct_elements:
+        for element in root_direct_elements:
+            if not element or element in root_children:
+                continue
+            root_children[element] = build_id_node(element, weight_map, post_ids_map)
+
+    def collect_post_ids(node: Dict) -> set:
+        if node.get("_type") == "ID":
+            ids = node.get("_post_ids", [])
+            return set(ids) if isinstance(ids, list) else ids
+        if node.get("_type") == "class":
+            all_post_ids = set()
+            for child in node.get("children", {}).values():
+                all_post_ids |= collect_post_ids(child)
+            return all_post_ids
+        return set()
+
+    root_post_ids: set = set()
+    for child in root_children.values():
+        root_post_ids |= collect_post_ids(child)
+    root_post_count = len(root_post_ids)
+
+    root: Dict[str, Any] = {
+        "_type": "root",
+        "_post_count": root_post_count,
+        "_post_ids": list(root_post_ids),
+        "children": root_children,
+    }
+
+    def calculate_ratio(node: Dict[str, Any], parent_post_count: int | None = None) -> None:
+        node_type = node.get("_type")
+        post_count = node.get("_post_count", 0)
+        if node_type == "root":
+            pass
+        elif node_type in ("class", "ID"):
+            if total_post_count > 0:
+                node["_ratio"] = round(post_count / total_post_count, 4)
+            else:
+                node["_ratio"] = 0.0
+        children = node.get("children", {})
+        for child in children.values():
+            calculate_ratio(child, post_count)
+
+    calculate_ratio(root)
+    return root
+
+
+def collect_id_nodes(
+    node: Dict[str, Any],
+    node_path: List[str],
+    id_nodes: List[Tuple[Dict[str, Any], List[str]]],
+) -> None:
+    node_type = node.get("_type")
+    children = node.get("children", {})
+    if node_type == "ID":
+        id_nodes.append((node, node_path.copy()))
+    elif node_type in ("class", "root"):
+        for child_name, child_node in children.items():
+            collect_id_nodes(child_node, node_path + [child_name], id_nodes)
+
+
+def collect_class_nodes(
+    node: Dict[str, Any],
+    node_path: List[str],
+    class_nodes: List[Tuple[Dict[str, Any], List[str]]],
+    is_root: bool = False,
+) -> None:
+    node_type = node.get("_type")
+    children = node.get("children", {})
+    is_first_level = len(node_path) == 1
+    if node_type == "class":
+        if not is_root and not is_first_level:
+            class_nodes.append((node, node_path.copy()))
+        for child_name, child_node in children.items():
+            collect_class_nodes(child_node, node_path + [child_name], class_nodes, False)
+    elif node_type == "root":
+        for child_name, child_node in children.items():
+            collect_class_nodes(child_node, node_path + [child_name], class_nodes, False)
+
+
+def select_constant_nodes(
+    candidates: List[Tuple[Dict[str, Any], List[str]]],
+) -> set:
+    if not candidates:
+        return set()
+    max_score = 0.0
+    for node, _ in candidates:
+        score = node.get("_persona_weight_score", 0.0)
+        if score > max_score:
+            max_score = score
+    candidate_scores = []
+    for node, path in candidates:
+        score = node.get("_persona_weight_score", 0.0)
+        relative_score = (
+            score / max_score if max_score > 0 else (1.0 if len(candidates) == 1 else 0.0)
+        )
+        candidate_scores.append((node, path, relative_score, score))
+    qualified_candidates = [
+        (node, path, rel_score, score)
+        for node, path, rel_score, score in candidate_scores
+        if rel_score >= 0.5
+    ]
+    if len(qualified_candidates) > 8:
+        qualified_candidates.sort(key=lambda x: x[2], reverse=True)
+        constant_nodes = qualified_candidates[:8]
+    else:
+        constant_nodes = qualified_candidates.copy()
+    if len(constant_nodes) < 3:
+        filtered_candidates = [
+            (node, path, rel_score, score)
+            for node, path, rel_score, score in candidate_scores
+            if rel_score >= 0.2
+        ]
+        filtered_candidates.sort(key=lambda x: x[2], reverse=True)
+        constant_nodes = filtered_candidates[: min(3, len(filtered_candidates))]
+    return {tuple(path) for _, path, _, _ in constant_nodes}
+
+
+def mark_constant_nodes(tree: Dict[str, Any]) -> None:
+    id_nodes: List[Tuple[Dict[str, Any], List[str]]] = []
+    collect_id_nodes(tree, [], id_nodes)
+    class_nodes: List[Tuple[Dict[str, Any], List[str]]] = []
+    collect_class_nodes(tree, [], class_nodes, is_root=True)
+    id_constant_paths = select_constant_nodes(id_nodes)
+    class_constant_paths = select_constant_nodes(class_nodes)
+    constant_paths = id_constant_paths | class_constant_paths
+
+    def mark_node(node: Dict[str, Any], path: List[str], is_root: bool = False) -> None:
+        node_type = node.get("_type")
+        children = node.get("children", {})
+        is_first_level = len(path) == 1
+        if node_type == "ID":
+            node["_is_constant"] = tuple(path) in constant_paths
+        elif node_type == "class":
+            if not is_root and not is_first_level:
+                node["_is_constant"] = tuple(path) in constant_paths
+            for child_name, child_node in children.items():
+                mark_node(child_node, path + [child_name], False)
+        elif node_type == "root":
+            for child_name, child_node in children.items():
+                mark_node(child_node, path + [child_name], False)
+
+    mark_node(tree, [], True)
+
+
+def get_cache_key(parent_category: str, child_categories: List[str]) -> str:
+    sorted_categories = sorted(child_categories)
+    content = f"{parent_category}|||{','.join(sorted_categories)}"
+    return hashlib.md5(content.encode("utf-8")).hexdigest()
+
+
+def _try_parse_json_text(text: str) -> Dict[str, Any]:
+    text = text.strip()
+    text = re.sub(r"^```(?:json)?\s*", "", text)
+    text = re.sub(r"\s*```\s*$", "", text)
+    return json.loads(text)
+
+
+def _gemini_json_call(
+    system_prompt: str,
+    user_prompt: str,
+    model: str,
+) -> str:
+    if httpx is None:
+        raise RuntimeError("需要安装 httpx: pip install httpx")
+    api_key = os.getenv("GEMINI_API_KEY")
+    if not api_key:
+        raise ValueError("GEMINI_API_KEY 未设置")
+    base_url = os.getenv("GEMINI_API_BASE", "https://generativelanguage.googleapis.com/v1beta")
+    url = f"{base_url}/models/{model}:generateContent"
+    payload: Dict[str, Any] = {
+        "contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
+        "systemInstruction": {"parts": [{"text": system_prompt}]},
+        "generationConfig": {
+            "temperature": 0,
+            "maxOutputTokens": 4096,
+            "responseMimeType": "application/json",
+        },
+    }
+    with httpx.Client(timeout=120.0) as client:
+        r = client.post(url, params={"key": api_key}, json=payload)
+        r.raise_for_status()
+        data = r.json()
+    candidates = data.get("candidates") or []
+    if not candidates:
+        raise RuntimeError(f"Gemini 无候选输出: {data}")
+    parts = (candidates[0].get("content") or {}).get("parts") or []
+    text = "".join(p.get("text", "") for p in parts)
+    if not text.strip():
+        raise RuntimeError("Gemini 返回空文本")
+    return text
+
+
+def load_cached_relation(account_name: str, cache_key: str) -> Optional[Dict]:
+    cache_file = _cache_dir(account_name) / f"{cache_key}.json"
+    if cache_file.exists():
+        try:
+            with open(cache_file, "r", encoding="utf-8") as f:
+                return json.load(f)
+        except Exception as e:
+            print(f"读取缓存失败: {e}")
+    return None
+
+
+def save_cached_relation(account_name: str, cache_key: str, relation_data: Dict) -> None:
+    cache_file = _cache_dir(account_name) / f"{cache_key}.json"
+    try:
+        with open(cache_file, "w", encoding="utf-8") as f:
+            json.dump(relation_data, f, ensure_ascii=False, indent=2)
+    except Exception as e:
+        print(f"保存缓存失败: {e}")
+
+
+def judge_category_relation(
+    parent_category: str,
+    child_categories: List[str],
+    account_name: str,
+    prompt_path: Path,
+    model: str,
+) -> Dict[str, Any]:
+    cache_key = get_cache_key(parent_category, child_categories)
+    cached = load_cached_relation(account_name, cache_key)
+    if cached:
+        return cached
+    if not prompt_path.exists():
+        raise FileNotFoundError(f"Prompt 文件不存在: {prompt_path}")
+    prompt_template = prompt_path.read_text(encoding="utf-8")
+    system_prompt = (
+        prompt_template.replace("{parent_category}", parent_category).replace(
+            "{child_categories}", json.dumps(child_categories, ensure_ascii=False)
+        )
+    )
+    user_prompt = "请分析父分类和子分类列表的关系,判断它们是互斥还是有交集,并以JSON格式输出结果。"
+    try:
+        raw = _gemini_json_call(system_prompt, user_prompt, model=model)
+        result = _try_parse_json_text(raw)
+        save_cached_relation(account_name, cache_key, result)
+        return result
+    except Exception as e:
+        print(f"调用LLM判断分类关系失败: {e}")
+        result = {
+            "relation": "有交集",
+            "confidence": 0.5,
+            "reasoning": f"LLM调用失败,默认判断为有交集: {str(e)}",
+        }
+        save_cached_relation(account_name, cache_key, result)
+        return result
+
+
+def mark_local_constant_nodes(
+    tree: Dict[str, Any],
+    account_name: str,
+    prompt_path: Path,
+    model: str,
+) -> None:
+    def process_node(node: Dict[str, Any], path: List[str], is_root: bool = False) -> None:
+        node_type = node.get("_type")
+        children = node.get("children", {})
+        is_first_level = len(path) == 1
+        if node_type == "root":
+            for child_name, child_node in children.items():
+                process_node(child_node, path + [child_name], False)
+        elif node_type == "class":
+            ratio = node.get("_ratio", 0.0)
+            if (is_first_level or len(path) > 1) and ratio >= 0.5:
+                sub_class_nodes = [
+                    (name, cn)
+                    for name, cn in children.items()
+                    if cn.get("_type") == "class"
+                ]
+                if len(sub_class_nodes) >= 2:
+                    child_categories = [name for name, _ in sub_class_nodes]
+                    parent_category = path[-1] if path else "根分类"
+                    relation_result = judge_category_relation(
+                        parent_category, child_categories, account_name, prompt_path, model
+                    )
+                    relation = relation_result.get("relation", "有交集")
+                    node["_child_categories_relation"] = relation
+                    node["_child_categories_relation_detail"] = relation_result
+                    if relation == "互斥":
+                        for child_name, child_node in sub_class_nodes:
+                            child_node["_is_local_constant"] = True
+                    else:
+                        parent_post_count = node.get("_post_count", 0)
+                        if parent_post_count > 0:
+                            for child_name, child_node in sub_class_nodes:
+                                child_post_count = child_node.get("_post_count", 0)
+                                child_node["_is_local_constant"] = (
+                                    child_post_count / parent_post_count > 0.5
+                                )
+                        else:
+                            for child_name, child_node in sub_class_nodes:
+                                child_node["_is_local_constant"] = False
+            for child_name, child_node in children.items():
+                process_node(child_node, path + [child_name], False)
+
+    process_node(tree, [], True)
+
+
+def discover_dimensions(tree_dir: Path) -> List[str]:
+    dims: List[str] = []
+    if not tree_dir.is_dir():
+        return dims
+    for p in sorted(tree_dir.glob("*_tree.json")):
+        name = p.name
+        if name.endswith("_tree.json"):
+            dim = name[: -len("_tree.json")]
+            if dim:
+                dims.append(dim)
+    return dims
+
+
+def process_account(
+    account_name: str,
+    prompt_path: Optional[Path] = None,
+    model: Optional[str] = None,
+    dimensions: Optional[List[str]] = None,
+) -> None:
+    prompt_path = prompt_path or _DEFAULT_PROMPT
+    model = model or _DEFAULT_MODEL
+
+    base = _input_base(account_name)
+    tree_dir = base / "tree"
+    weight_dir = base / "point_tree_weight"
+    out_dir = _output_tree_dir(account_name)
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    dims = dimensions if dimensions is not None else discover_dimensions(tree_dir)
+    if not dims:
+        print(f"未在 {tree_dir} 找到 *_tree.json")
+        sys.exit(1)
+
+    exclude_note_ids = load_exclude_note_ids(base)
+
+    print(f"账号: {account_name}")
+    print(f"输出目录: {out_dir}")
+    print(f"维度: {dims}")
+    print(f"Gemini 模型: {model}")
+    print(f"排除帖子 ID 数: {len(exclude_note_ids)}")
+
+    for dimension in dims:
+        tree_file = tree_dir / f"{dimension}_tree.json"
+        weight_file = weight_dir / f"{dimension}_tree_weight_score.json"
+        if not tree_file.exists():
+            print(f"跳过维度 {dimension}:缺少 {tree_file}")
+            continue
+
+        weight_map, post_ids_map, total_post_count = load_weight_scores(
+            weight_file, exclude_note_ids=exclude_note_ids
+        )
+        if not weight_map:
+            print(f"跳过维度 {dimension}:无法加载权重分 {weight_file}")
+            continue
+
+        loaded = load_classification_tree_from_file(
+            tree_file, exclude_note_ids=exclude_note_ids
+        )
+        if loaded is None:
+            print(f"跳过维度 {dimension}:无法解析分类树 {tree_file}")
+            continue
+        classification_tree, root_direct_elements = loaded
+
+        print(
+            f"处理 {dimension}: 分类顶层 {len(classification_tree)} 类, "
+            f"root 直挂 {len(root_direct_elements)} 词, 权重词 {len(weight_map)}"
+        )
+        tree = build_tree_from_classification(
+            classification_tree,
+            weight_map,
+            post_ids_map,
+            total_post_count,
+            dimension,
+            root_direct_elements=root_direct_elements or None,
+        )
+        mark_constant_nodes(tree)
+        mark_local_constant_nodes(tree, account_name, prompt_path, model)
+
+        result = {dimension: tree}
+        out_file = out_dir / f"{dimension}_point_tree_how.json"
+        with open(out_file, "w", encoding="utf-8") as f:
+            json.dump(result, f, ensure_ascii=False, indent=2)
+        print(f"已写入 {out_file}")
+
+
+def main(account_name) -> None:
+    process_account(
+        account_name
+    )
+
+
+if __name__ == "__main__":
+    main(account_name="空间点阵设计研究室")

+ 146 - 0
examples_how/overall_derivation/data_process/pattern_data_process.py

@@ -0,0 +1,146 @@
+"""
+从原始 pattern.json 读取 full / substance_form_only / point_type_only,
+将三段的 depth_max 合并为 depth_max_concrete,三段的 depth_3 合并为 depth_4;
+各层内按 combination_type 分到 two_x / one_x / zero_x。
+输出格式对齐 processed_edge_data.json(type_key、items 中 point 恒为空字符串)。
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from typing import Any, Dict, List
+
+_OVR = Path(__file__).resolve().parent.parent
+if str(_OVR) not in sys.path:
+    sys.path.insert(0, str(_OVR))
+
+
+SECTION_KEYS = ("full", "substance_form_only", "point_type_only")
+BUCKET_KEYS = ("two_x", "one_x", "zero_x")
+
+
+def _load_json(path: Path) -> Any:
+    with open(path, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _normalize_item_row(row: Dict[str, Any]) -> Dict[str, Any]:
+    # 与创业邦 processed_edge_data 一致:point 恒为空,不保留 path
+    return {
+        "name": row.get("name") or "",
+        "point": "",
+        "dimension": row.get("dimension") or "",
+        "type": row.get("type") or "分类",
+    }
+
+
+def _normalize_pattern_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
+    combination_type = entry.get("combination_type") or ""
+    raw_id = entry.get("id")
+    if isinstance(raw_id, str) and raw_id.isdigit():
+        pid: Any = int(raw_id)
+    else:
+        pid = raw_id
+
+    items_in = entry.get("items") or []
+    items = [_normalize_item_row(x) for x in items_in if isinstance(x, dict)]
+
+    try:
+        support = float(entry.get("support", 0.0))
+    except (TypeError, ValueError):
+        support = 0.0
+
+    matched = entry.get("matched_posts")
+    if matched is None:
+        matched = []
+
+    return {
+        "id": pid,
+        "type_key": combination_type,
+        "support": support,
+        "absolute_support": entry.get("absolute_support"),
+        "length": entry.get("length"),
+        "post_count": entry.get("post_count"),
+        "matched_posts": matched,
+        "items": items,
+    }
+
+
+def _entries_to_buckets(entries: List[Any]) -> Dict[str, List[Dict[str, Any]]]:
+    out: Dict[str, List[Dict[str, Any]]] = {k: [] for k in BUCKET_KEYS}
+    for entry in entries:
+        if not isinstance(entry, dict):
+            continue
+        combination_type = entry.get("combination_type") or ""
+        bucket = _combination_type_bucket(combination_type)
+        out[bucket].append(_normalize_pattern_entry(entry))
+    return out
+
+def _combination_type_bucket(combination_type: str) -> str:
+    """
+    根据组合类型中的符号数量映射到 two_x / one_x / zero_x。
+
+    规则:
+    - 先统计组合类型中的 '×' 数量;
+    - 若没有 '×',则再根据 '+' 数量判断。
+    """
+    if not combination_type:
+        return "zero_x"
+
+    times_count = combination_type.count("×")
+    if times_count >= 2:
+        return "two_x"
+    if times_count == 1:
+        return "one_x"
+
+    # 没有 '×' 时,才按 '+' 数量判断
+    plus_count = combination_type.count("+")
+    if plus_count >= 2:
+        return "two_x"
+    if plus_count == 1:
+        return "one_x"
+    return "zero_x"
+
+def _collect_depth_list(raw: Dict[str, Any], depth_key: str) -> List[Any]:
+    merged: List[Any] = []
+    for sec_name in SECTION_KEYS:
+        sec = raw.get(sec_name)
+        if not isinstance(sec, dict):
+            continue
+        part = sec.get(depth_key)
+        if isinstance(part, list):
+            merged.extend(part)
+    return merged
+
+
+def process_pattern_for_account(account_name: str) -> Dict[str, Any]:
+    base = _OVR
+    in_path = base / "input" / account_name / "原始数据" / "pattern" / "pattern.json"
+    raw = _load_json(in_path)
+    if not isinstance(raw, dict):
+        raise ValueError(f"顶层应为对象: {in_path}")
+
+    depth_max_entries = _collect_depth_list(raw, "depth_max")
+    depth_3_entries = _collect_depth_list(raw, "depth_3")
+
+    return {
+        "depth_max_concrete": _entries_to_buckets(depth_max_entries),
+        "depth_4": _entries_to_buckets(depth_3_entries),
+    }
+
+
+def main(account_name: str) -> Path:
+    out_dir = _OVR / "input" / account_name / "处理后数据" / "pattern"
+    out_dir.mkdir(parents=True, exist_ok=True)
+    out_path = out_dir / "pattern.json"
+    data = process_pattern_for_account(account_name)
+    with open(out_path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+    print(f"[{account_name}] 已写入: {out_path}")
+    return out_path
+
+
+if __name__ == "__main__":
+    acc = sys.argv[1] if len(sys.argv) >= 2 else "空间点阵设计研究室"
+    main(acc)

+ 35 - 11
examples_how/overall_derivation/topic_summary.py → examples_how/overall_derivation/data_process/topic_summary.py

@@ -1,12 +1,11 @@
 #!/usr/bin/env python3
 """
 账号人设总结:
-1. 从 input/{account_name}/tree 目录下读取人设树 JSON 文件并合并
+1. 从 input/{account_name}/处理后数据/tree 目录下读取人设树 JSON 文件并合并
 2. 将合并后的 JSON 填充到 topic_summary_prompt.md 中的 {topic_point_tree}
-3. 调用大模型生成账号人设总结,写入 input/{account_name}/persona_data/persona_summary.json
+3. 调用大模型生成账号人设总结,写入 input/{account_name}/处理后数据/persona_data/persona_summary.json
 """
 
-import argparse
 import asyncio
 import json
 import logging
@@ -17,8 +16,8 @@ from typing import Any, Dict
 logger = logging.getLogger(__name__)
 
 
-# 确保可以导入 agent 内的 LLM 调用封装
-_project_root = Path(__file__).resolve().parent.parent.parent
+# 确保可以导入 agent 内的 LLM 调用封装(本文件在 data_process 下,多一层目录)
+_project_root = Path(__file__).resolve().parent.parent.parent.parent
 if str(_project_root) not in sys.path:
     sys.path.insert(0, str(_project_root))
 
@@ -31,8 +30,32 @@ except ImportError:  # pragma: no cover - 仅用于本地缺少依赖时的降
 # 复用与 search_and_eval 相同的模型,保证行为一致
 EVAL_LLM_MODEL = "google/gemini-3.1-pro-preview"
 
+# 脚本与 topic_summary_prompt.md 在 data_process;数据在 overall_derivation/input
 BASE_DIR = Path(__file__).resolve().parent
-INPUT_BASE = BASE_DIR / "input"
+OVERALL_DERIVATION_DIR = BASE_DIR.parent
+INPUT_BASE = OVERALL_DERIVATION_DIR / "input"
+
+# 人设树中不送入 LLM 的字段(递归删除)
+_TREE_STRIP_KEYS = frozenset(
+    {
+        "_post_ids",
+        "_child_categories_relation",
+        "_child_categories_relation_detail",
+    }
+)
+
+
+def _strip_tree_fields(obj: Any) -> Any:
+    """递归从树结构中移除 _TREE_STRIP_KEYS 中的键。"""
+    if isinstance(obj, dict):
+        return {
+            k: _strip_tree_fields(v)
+            for k, v in obj.items()
+            if k not in _TREE_STRIP_KEYS
+        }
+    if isinstance(obj, list):
+        return [_strip_tree_fields(x) for x in obj]
+    return obj
 
 
 def _extract_json_object(content: str) -> Dict[str, Any]:
@@ -59,13 +82,14 @@ def _extract_json_object(content: str) -> Dict[str, Any]:
 
 def _load_topic_point_tree(account_name: str) -> Dict[str, Any]:
     """
-    读取 input/{account_name}/tree 目录下的所有 JSON 文件,并合并成一个字典:
+    读取 input/{account_name}/处理后数据/tree 目录下的所有 JSON 文件,并合并成一个字典:
     {
       "<文件名去掉后缀>": <该文件对应的树 JSON>,
       ...
     }
+    每棵树加载后会去掉 _post_ids、_child_categories_relation、_child_categories_relation_detail。
     """
-    tree_dir = INPUT_BASE / account_name / "tree"
+    tree_dir = INPUT_BASE / account_name / "处理后数据" / "tree"
     if not tree_dir.is_dir():
         raise FileNotFoundError(f"人设树目录不存在: {tree_dir}")
 
@@ -80,7 +104,7 @@ def _load_topic_point_tree(account_name: str) -> Dict[str, Any]:
                 data = json.load(f)
             except json.JSONDecodeError as e:
                 raise ValueError(f"解析 JSON 文件失败: {path}") from e
-        merged[path.stem] = data
+        merged[path.stem] = _strip_tree_fields(data)
         logger.info("已加载人设树文件: %s", path.name)
 
     return merged
@@ -134,7 +158,7 @@ async def generate_topic_summary(account_name: str) -> Dict[str, Any]:
         raise RuntimeError(f"解析 LLM 返回内容失败: {e}") from e
 
     # 4. 写入 persona_summary.json
-    persona_dir = INPUT_BASE / account_name / "persona_data"
+    persona_dir = INPUT_BASE / account_name / "处理后数据" / "persona_data"
     persona_dir.mkdir(parents=True, exist_ok=True)
     persona_file = persona_dir / "persona_summary.json"
     with open(persona_file, "w", encoding="utf-8") as f:
@@ -165,5 +189,5 @@ def main(account_name) -> None:
 
 
 if __name__ == "__main__":
-    main(account_name="创业邦")
+    main(account_name="空间点阵设计研究室")
 

+ 0 - 0
examples_how/overall_derivation/topic_summary_prompt.md → examples_how/overall_derivation/data_process/topic_summary_prompt.md


+ 32 - 14
examples_how/overall_derivation/tree_lib_post_point_match.py → examples_how/overall_derivation/data_process/tree_lib_post_point_match.py

@@ -6,7 +6,7 @@
   python tree_lib_post_point_match.py 家有大志 68fb6a5c000000000302e5de
 
 模块方式:
-  python -m examples_how.overall_derivation.tree_lib_post_point_match 家有大志 68fb6a5c000000000302e5de
+  python -m examples_how.overall_derivation.data_process.tree_lib_post_point_match 家有大志 68fb6a5c000000000302e5de
 """
 
 from __future__ import annotations
@@ -19,8 +19,8 @@ import os
 import sys
 from typing import Any, Dict, List
 
-# 保证可 import agent(similarity_calc 依赖 openrouter)
-_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
+# 保证可 import agent(similarity_calc 依赖 openrouter);本文件在 data_process,多一层回到项目根
+_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".."))
 if _ROOT not in sys.path:
     sys.path.insert(0, _ROOT)
 
@@ -33,16 +33,33 @@ CLASS_NODE_BATCH_SIZE = 20
 MAX_CONCURRENT_BATCHES = 5
 
 
-def _derivation_dir() -> str:
-    return os.path.dirname(os.path.abspath(__file__))
+def _overall_derivation_dir() -> str:
+    """overall_derivation 根目录(input 的父目录)。"""
+    return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
 
 
 def _input_dir() -> str:
-    return os.path.join(_derivation_dir(), "input")
+    return os.path.join(_overall_derivation_dir(), "input")
+
+
+def _load_post_id_list_from_exclude_note_ids(account_name: str) -> List[str]:
+    """从 input/{account_name}/原始数据/exclude_note_ids.json 读取帖子 ID 列表(字符串数组)。"""
+    path = os.path.join(_input_dir(), account_name, "原始数据", "exclude_note_ids.json")
+    if not os.path.isfile(path):
+        raise FileNotFoundError(f"未找到帖子 ID 列表文件: {path}")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not isinstance(data, list):
+        raise ValueError(f"exclude_note_ids.json 应为字符串数组: {path}")
+    out: List[str] = []
+    for x in data:
+        if isinstance(x, str) and x.strip():
+            out.append(x.strip())
+    return out
 
 
 def load_post_topics(account_name: str, post_id: str) -> List[str]:
-    path = os.path.join(_input_dir(), account_name, "post_topic", f"{post_id}.json")
+    path = os.path.join(_input_dir(), account_name, "处理后数据", "post_topic", f"{post_id}.json")
     if not os.path.isfile(path):
         raise FileNotFoundError(f"未找到帖子选题点文件: {path}")
     with open(path, "r", encoding="utf-8") as f:
@@ -78,7 +95,7 @@ def _walk_class_nodes(
 
 
 def collect_class_nodes_from_tree_file(path: str) -> List[Dict[str, str]]:
-    """从单棵人设树 JSON 收集所有分类节点(顶层 key 为 dimension,如 实质/形式)。"""
+    """从单棵人设树 JSON 收集所有 _type=class 分类节点(顶层 key 为 dimension,如 实质/形式)。"""
     with open(path, "r", encoding="utf-8") as f:
         data = json.load(f)
     if not isinstance(data, dict):
@@ -167,11 +184,13 @@ def _merge_batch_into_by_topic(
         if i >= len(by_topic):
             break
         node = chunk_nodes[j]
+        # tree_post_point_match 传入的节点可带 tree_type=ID;本库仅 class 时无该键,视为分类
+        persona_type = "ID" if node.get("tree_type") == "ID" else "分类"
         by_topic[i].append(
             {
                 "name": node["name"],
                 "dimension": node["dimension"],
-                "type": "分类",
+                "type": persona_type,
                 "match_score": _round4(item["combined_score"]),
                 "embedding_score": _round4(item["embedding_score"]),
                 "llm_score": _round4(item["llm_score"]),
@@ -274,8 +293,7 @@ def main(account_name, post_id) -> None:
 
 
 if __name__ == "__main__":
-    # main(account_name="创业邦", post_id="69394a0b000000001f006ce6")
-    # main(account_name="创业邦", post_id="694a6caf000000001f00e112")
-    # main(account_name="创业邦", post_id="6944e9b5000000001e02472d")
-    # main(account_name="创业邦", post_id="69437b6d000000001e0381c9")
-    main(account_name="创业邦", post_id="69491c99000000001e02c765")
+    account_name = "空间点阵设计研究室"
+    post_id_list = _load_post_id_list_from_exclude_note_ids(account_name)
+    for post_id in post_id_list:
+        main(account_name, post_id)

+ 228 - 0
examples_how/overall_derivation/data_process/tree_post_point_match.py

@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+账号人设树节点(_type=class 与 _type=ID)与帖子选题点的语义相似度匹配。
+
+选题点:examples_how/overall_derivation/input/{account_name}/处理后数据/post_topic/{post_id}.json
+人设树:examples_how/overall_derivation/input/{account_name}/处理后数据/tree/*_point_tree_how.json
+
+用法:
+  python tree_post_point_match.py <account_name> <post_id>
+
+模块方式:
+  python -m examples_how.overall_derivation.data_process.tree_post_point_match <account_name> <post_id>
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import glob
+import json
+import logging
+import os
+import sys
+from typing import Any, Dict, List
+
+_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".."))
+if _ROOT not in sys.path:
+    sys.path.insert(0, _ROOT)
+
+from examples_how.overall_derivation.data_process.tree_lib_post_point_match import (  # noqa: E402
+    CLASS_NODE_BATCH_SIZE,
+    MAX_CONCURRENT_BATCHES,
+    _chunk_class_nodes,
+    _merge_batch_into_by_topic,
+    _similarity_one_batch,
+    load_post_topics,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _overall_derivation_dir() -> str:
+    """overall_derivation 根目录(input 的父目录)。"""
+    return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
+
+
+def _input_dir() -> str:
+    return os.path.join(_overall_derivation_dir(), "input")
+
+
+def _load_post_id_list_from_exclude_note_ids(account_name: str) -> List[str]:
+    """从 input/{account_name}/原始数据/exclude_note_ids.json 读取帖子 ID 列表(字符串数组)。"""
+    path = os.path.join(_input_dir(), account_name, "原始数据", "exclude_note_ids.json")
+    if not os.path.isfile(path):
+        raise FileNotFoundError(f"未找到帖子 ID 列表文件: {path}")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not isinstance(data, list):
+        raise ValueError(f"exclude_note_ids.json 应为字符串数组: {path}")
+    out: List[str] = []
+    for x in data:
+        if isinstance(x, str) and x.strip():
+            out.append(x.strip())
+    return out
+
+
+def _walk_class_and_id_nodes(
+    children: Any,
+    dimension: str,
+    out: List[Dict[str, str]],
+    seen: set[tuple[str, str]],
+) -> None:
+    """与 tree_lib 中仅 class 的遍历分离:账号树需同时收集 class 与 ID(如意图树 root 下直连 ID)。"""
+    if not isinstance(children, dict):
+        return
+    for name, node in children.items():
+        if not isinstance(node, dict):
+            continue
+        ntype = node.get("_type")
+        if ntype == "class" or ntype == "ID":
+            key = (dimension, name)
+            if key not in seen:
+                seen.add(key)
+                entry: Dict[str, str] = {"name": name, "dimension": dimension}
+                if ntype == "ID":
+                    entry["tree_type"] = "ID"
+                out.append(entry)
+        sub = node.get("children")
+        if isinstance(sub, dict):
+            _walk_class_and_id_nodes(sub, dimension, out, seen)
+
+
+def collect_match_nodes_from_tree_file(path: str) -> List[Dict[str, str]]:
+    """从单棵人设树 JSON 收集 _type=class 与 _type=ID(含任意深度、root 下直连 ID)。"""
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not isinstance(data, dict):
+        raise ValueError(f"树文件格式错误(应为对象): {path}")
+    out: List[Dict[str, str]] = []
+    seen: set[tuple[str, str]] = set()
+    for dimension, root in data.items():
+        if not isinstance(root, dict):
+            continue
+        ch = root.get("children")
+        _walk_class_and_id_nodes(ch, str(dimension), out, seen)
+    return out
+
+
+def load_account_tree_class_nodes(account_name: str) -> List[Dict[str, str]]:
+    """从账号「处理后数据/tree」下所有人设树 JSON 收集 class 与 ID 节点(顶层 key 为 dimension)。"""
+    tree_dir = os.path.join(_input_dir(), account_name, "处理后数据", "tree")
+    if not os.path.isdir(tree_dir):
+        raise FileNotFoundError(f"人设树目录不存在: {tree_dir}")
+    paths = sorted(glob.glob(os.path.join(tree_dir, "*_point_tree_how.json")))
+    if not paths:
+        raise FileNotFoundError(
+            f"未找到人设树文件(期望 *_point_tree_how.json): {tree_dir}"
+        )
+    merged: List[Dict[str, str]] = []
+    seen: set[tuple[str, str]] = set()
+    for p in paths:
+        for node in collect_match_nodes_from_tree_file(p):
+            key = (node["dimension"], node["name"])
+            if key not in seen:
+                seen.add(key)
+                merged.append(node)
+    return merged
+
+
+async def run_match(
+    account_name: str,
+    post_id: str,
+    *,
+    class_batch_size: int = CLASS_NODE_BATCH_SIZE,
+    max_concurrent_batches: int = MAX_CONCURRENT_BATCHES,
+) -> List[Dict[str, Any]]:
+    topics = load_post_topics(account_name, post_id)
+    class_nodes = load_account_tree_class_nodes(account_name)
+    if not topics:
+        raise ValueError("选题点列表为空")
+    if not class_nodes:
+        raise ValueError("人设树中未找到任何可匹配节点(_type=class 或 _type=ID)")
+
+    phrases_a = topics
+    chunks = _chunk_class_nodes(class_nodes, class_batch_size)
+    batch_total = len(chunks)
+    semaphore = asyncio.Semaphore(max(1, max_concurrent_batches))
+
+    logger.info(
+        "分类节点共 %d 个,分为 %d 批(每批最多 %d 个),最多 %d 批并发",
+        len(class_nodes),
+        batch_total,
+        class_batch_size,
+        max_concurrent_batches,
+    )
+
+    tasks = [
+        _similarity_one_batch(semaphore, phrases_a, chunk, bi + 1, batch_total)
+        for bi, chunk in enumerate(chunks)
+    ]
+    batch_results = await asyncio.gather(*tasks, return_exceptions=True)
+
+    by_topic: List[List[Dict[str, Any]]] = [[] for _ in range(len(phrases_a))]
+    for bi, res in enumerate(batch_results):
+        if isinstance(res, Exception):
+            logger.error(
+                "相似度批次协程异常(批次 %d/%d): %s",
+                bi + 1,
+                batch_total,
+                res,
+                exc_info=res,
+            )
+            continue
+        items, chunk_nodes = res
+        _merge_batch_into_by_topic(by_topic, items, chunk_nodes)
+
+    output: List[Dict[str, Any]] = []
+    for t, matches in zip(topics, by_topic):
+        matches_sorted = sorted(matches, key=lambda m: m["match_score"], reverse=True)
+        output.append({"name": t, "match_personas": matches_sorted})
+    return output
+
+
+def write_match_result(account_name: str, post_id: str, data: List[Dict[str, Any]]) -> str:
+    out_dir = os.path.join(_input_dir(), account_name, "处理后数据", "match_data")
+    os.makedirs(out_dir, exist_ok=True)
+    out_path = os.path.join(out_dir, f"{post_id}_匹配_all.json")
+    with open(out_path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+    return out_path
+
+
+async def main_async(
+    account_name: str,
+    post_id: str,
+    *,
+    class_batch_size: int = CLASS_NODE_BATCH_SIZE,
+    max_concurrent_batches: int = MAX_CONCURRENT_BATCHES,
+) -> str:
+    logger.info("account=%s post_id=%s", account_name, post_id)
+    result = await run_match(
+        account_name,
+        post_id,
+        class_batch_size=class_batch_size,
+        max_concurrent_batches=max_concurrent_batches,
+    )
+    path = write_match_result(account_name, post_id, result)
+    logger.info("已写入: %s(共 %d 个选题点)", path, len(result))
+    return path
+
+
+def main(account_name, post_id) -> None:
+    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
+    asyncio.run(
+        main_async(
+            account_name,
+            post_id,
+            class_batch_size=20,
+            max_concurrent_batches=10,
+        )
+    )
+
+
+if __name__ == "__main__":
+    account_name = "空间点阵设计研究室"
+    post_id_list = _load_post_id_list_from_exclude_note_ids(account_name)
+    for post_id in post_id_list:
+        main(account_name, post_id)

+ 0 - 62
examples_how/overall_derivation/extract_post_topic.py

@@ -1,62 +0,0 @@
-"""
-从帖子解构内容中提取选题点(灵感点/目的点/关键点 下 分词结果 中的 词),去重后输出。
-"""
-import json
-import argparse
-from pathlib import Path
-
-
-def extract_post_topic(account_name: str, post_id: str) -> list[str]:
-    """
-    从解构内容中提取选题点并去重。
-
-    :param account_name: 账号名
-    :param post_id: 帖子ID
-    :return: 去重后的选题点字符串列表
-    """
-    base = Path(__file__).resolve().parent
-    input_path = base / "input" / account_name / "原始数据" / "解构内容" / f"{post_id}.json"
-
-    with open(input_path, "r", encoding="utf-8") as f:
-        data = json.load(f)
-
-    topics: list[str] = []
-    for key in ("灵感点", "目的点", "关键点"):
-        for item in data.get(key, []):
-            for seg in item.get("分词结果", []):
-                word = seg.get("词")
-                if word and isinstance(word, str) and word.strip():
-                    topics.append(word.strip())
-
-    # 去重且保持首次出现顺序
-    seen = set()
-    unique_topics: list[str] = []
-    for w in topics:
-        if w not in seen:
-            seen.add(w)
-            unique_topics.append(w)
-
-    return unique_topics
-
-
-def main(account_name: str, post_id: str):
-    # parser = argparse.ArgumentParser(description="从解构内容中提取选题点")
-    # parser.add_argument("account_name", help="账号名")
-    # parser.add_argument("post_id", help="帖子ID")
-    # args = parser.parse_args()
-
-    topics = extract_post_topic(account_name, post_id)
-
-    base = Path(__file__).resolve().parent
-    out_dir = base / "input" / account_name / "post_topic"
-    out_dir.mkdir(parents=True, exist_ok=True)
-    out_path = out_dir / f"{post_id}.json"
-
-    with open(out_path, "w", encoding="utf-8") as f:
-        json.dump(topics, f, ensure_ascii=False, indent=2)
-
-    print(f"已写入 {len(topics)} 个选题点到 {out_path}")
-
-
-if __name__ == "__main__":
-    main(account_name="创业邦", post_id="69491c99000000001e02c765")

+ 215 - 26
examples_how/overall_derivation/generate_visualize_data.py

@@ -13,7 +13,134 @@ import argparse
 import json
 import re
 from pathlib import Path
-from typing import Any
+from typing import Any, Dict, List, Optional
+
+
+def _walk_tree_children_for_persona(
+    children: Any, persona_by_name: Dict[str, Dict[str, Any]]
+) -> None:
+    """递归遍历人设树 children,按节点名(与 input_tree_nodes 短名一致)登记 type / 常量标记。"""
+    if not isinstance(children, dict):
+        return
+    for name, node in children.items():
+        if not isinstance(node, dict):
+            continue
+        if name not in persona_by_name:
+            persona_by_name[name] = {
+                "name": name,
+                "type": node.get("_type"),
+                "is_constant": bool(node.get("_is_constant", False)),
+                "is_local_constant": bool(node.get("_is_local_constant", False)),
+            }
+        sub = node.get("children")
+        if isinstance(sub, dict):
+            _walk_tree_children_for_persona(sub, persona_by_name)
+
+
+def build_persona_by_name_from_tree_dir(tree_dir: Path) -> Dict[str, Dict[str, Any]]:
+    """
+    从 input/{account}/处理后数据/tree 下所有人设树 JSON(如 *_point_tree_how.json)构建 name -> 人设节点信息。
+    同名节点以首次出现为准,与 process_pipeline_tree_data.build_persona_by_name 用法一致。
+    """
+    persona_by_name: Dict[str, Dict[str, Any]] = {}
+    if not tree_dir.is_dir():
+        return persona_by_name
+    for path in sorted(tree_dir.glob("*_point_tree_how.json")):
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+        if not isinstance(data, dict):
+            continue
+        for _dim, root in data.items():
+            if not isinstance(root, dict):
+                continue
+            ch = root.get("children")
+            _walk_tree_children_for_persona(ch, persona_by_name)
+    return persona_by_name
+
+
+def _node_obj_for_used_tree(
+    name: str,
+    node: Optional[Dict[str, Any]],
+    persona: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+    """与 process_pipeline_tree_data._node_obj 一致:合并人设与 edge 上节点字段。"""
+    type_val = None
+    is_constant = False
+    is_local_constant = False
+    if persona is not None:
+        type_val = persona.get("type")
+        if "is_constant" in persona:
+            is_constant = bool(persona["is_constant"])
+        if "is_local_constant" in persona:
+            is_local_constant = bool(persona["is_local_constant"])
+    if node is not None:
+        t = node.get("type")
+        if t is not None and len(t) > 0:
+            type_val = t
+        if "is_constant" in node:
+            is_constant = bool(node["is_constant"])
+        if "is_local_constant" in node:
+            is_local_constant = bool(node["is_local_constant"])
+    return {
+        "name": name,
+        "type": type_val,
+        "is_constant": is_constant,
+        "is_local_constant": is_local_constant,
+    }
+
+
+def _dedup_node_objs(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    seen = set()
+    out = []
+    for n in nodes:
+        key = (n["name"], n.get("type"), n["is_constant"], n["is_local_constant"])
+        if key not in seen:
+            seen.add(key)
+            out.append(n)
+    return out
+
+
+def extract_used_tree_nodes_from_edge(
+    edge: Dict[str, Any],
+    persona_by_name: Dict[str, Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+    """与 process_pipeline_tree_data.extract_used_tree_nodes_from_edge 一致。"""
+    used: List[Dict[str, Any]] = []
+    for node in edge.get("input_tree_nodes") or []:
+        name = node.get("name")
+        if name is None or name == "":
+            continue
+        persona = persona_by_name.get(name)
+        used.append(_node_obj_for_used_tree(name, node, persona))
+    for pn in edge.get("input_pattern_nodes") or []:
+        for item in pn.get("match_items") or []:
+            if item is None or item == "":
+                continue
+            persona = persona_by_name.get(item)
+            used.append(_node_obj_for_used_tree(item, None, persona))
+    return _dedup_node_objs(used)
+
+
+def enrich_visualize_with_used_tree_nodes(
+    data: Dict[str, Any],
+    persona_by_name: Dict[str, Dict[str, Any]],
+) -> Dict[str, Any]:
+    """
+    为 edge_list 每条 edge 增加 used_tree_nodes,顶层增加 all_used_tree_nodes(与 process_pipeline 一致)。
+    """
+    edge_list = data.get("edge_list")
+    if not edge_list:
+        data["all_used_tree_nodes"] = []
+        return data
+
+    all_used: List[Dict[str, Any]] = []
+    for edge in edge_list:
+        used = extract_used_tree_nodes_from_edge(edge, persona_by_name)
+        edge["used_tree_nodes"] = used
+        all_used.extend(used)
+
+    data["all_used_tree_nodes"] = _dedup_node_objs(all_used)
+    return data
 
 
 def _collect_dimension_names(point_data: dict) -> dict[str, str]:
@@ -42,13 +169,43 @@ def _collect_dimension_names(point_data: dict) -> dict[str, str]:
 def parse_topic_points_from_deconstruct(deconstruct_path: Path) -> list[dict[str, Any]]:
     """
     从 input/{account_name}/解构内容/{post_id}.json 解析选题点列表。
-    选题点来自分词结果中的「词」,字段:name, point, dimension, root_source, root_sources_desc。
+    - 新格式(Agent):灵感点/目的点/关键点 下为「选题点」「选题点元素」(元素名称、元素类型)。
+    - 旧格式:「点」「分词结果」中的「词」等。
+    输出字段:name, point, dimension, root_source, root_sources_desc。
     """
     if not deconstruct_path.exists():
         raise FileNotFoundError(f"解构内容文件不存在: {deconstruct_path}")
     with open(deconstruct_path, "r", encoding="utf-8") as f:
         data = json.load(f)
 
+    result_agent: list[dict[str, Any]] = []
+    for point_type in ("灵感点", "目的点", "关键点"):
+        for point in data.get(point_type) or []:
+            if not isinstance(point, dict):
+                continue
+            root_source = (point.get("选题点") or point.get("点") or "").strip()
+            root_sources_desc = point.get("选题点描述") or point.get("点描述") or ""
+            for el in point.get("选题点元素") or []:
+                if not isinstance(el, dict):
+                    continue
+                name = (el.get("元素名称") or "").strip()
+                if not name:
+                    continue
+                et = el.get("元素类型") or "实质"
+                if et not in ("实质", "形式", "意图"):
+                    et = "实质"
+                result_agent.append(
+                    {
+                        "name": name,
+                        "point": point_type,
+                        "dimension": et,
+                        "root_source": root_source,
+                        "root_sources_desc": root_sources_desc,
+                    }
+                )
+    if result_agent:
+        return result_agent
+
     result = []
     for point_type in ("灵感点", "目的点", "关键点"):
         for point in data.get(point_type) or []:
@@ -291,17 +448,16 @@ def build_visualize_edges(
     生成 node_list(所有评估通过的帖子选题点)和 edge_list(只保留评估通过的推导路径)。
     - node_list:同一轮内节点不重复,重复时保留 matched_score 更高的;节点带 matched_score、is_fully_derived。
     - edge_list:边带 level(与 output 节点 level 一致);同一轮内 output 节点不重复;若前面轮次该节点匹配分更高则本轮不保留该节点。
-    评估数据支持 path_id(对应推导 derivation_results[].id)、item_id(output 中元素从 1 起的序号)、matched_score、is_fully_derived
+    评估数据支持 path_id(对应推导 derivation_results[].id)、derivation_output_point(与推导 output 中字符串对齐)、matched_score、is_fully_derived;不按 item_id 对齐
     """
     derivations = sorted(derivations, key=lambda d: d.get("round", 0))
     evals = sorted(evals, key=lambda e: e.get("round", 0))
 
     topic_by_name = {t["name"]: t for t in topic_points}
 
-    # 评估匹配:(round_num, path_id, item_id) -> (matched_post_point, matched_reason, matched_score, is_fully_derived)
-    # path_id = 推导中 derivation_results[].id,item_id = output 中元素从 1 起的序号
-    match_by_path_item: dict[tuple[int, int, int], tuple[str, str, float, bool]] = {}
-    match_by_round_output: dict[tuple[int, str], tuple[str, str, float, bool]] = {}  # 兼容无 path_id/item_id
+    # 评估匹配:(round_num, path_id, derivation_output_point) -> (matched_post_point, matched_reason, matched_score, is_fully_derived)
+    match_by_path_out: dict[tuple[int, int, str], tuple[str, str, float, bool]] = {}
+    match_by_round_output: dict[tuple[int, str], tuple[str, str, float, bool]] = {}  # 兼容无 path_id 的旧数据
     for round_idx, eval_data in enumerate(evals):
         round_num = eval_data.get("round", round_idx + 1)
         for er in eval_data.get("eval_results") or []:
@@ -323,10 +479,9 @@ def build_visualize_edges(
             is_fully = er.get("is_fully_derived", True)
             val = (mp, reason, score, bool(is_fully))
             path_id = er.get("path_id")
-            item_id = er.get("item_id")
-            if path_id is not None and item_id is not None:
+            if path_id is not None and out_point:
                 try:
-                    match_by_path_item[(round_num, int(path_id), int(item_id))] = val
+                    match_by_path_out[(round_num, int(path_id), out_point)] = val
                 except (TypeError, ValueError):
                     pass
             if out_point:
@@ -334,9 +489,12 @@ def build_visualize_edges(
                 if k not in match_by_round_output:
                     match_by_round_output[k] = val
 
-    def get_match(round_num: int, path_id: int | None, item_id: int | None, out_item: str) -> tuple[str, str, float, bool] | None:
-        if path_id is not None and item_id is not None:
-            v = match_by_path_item.get((round_num, path_id, item_id))
+    def get_match(round_num: int, path_id: int | None, out_item: str) -> tuple[str, str, float, bool] | None:
+        out_item = (out_item or "").strip()
+        if not out_item:
+            return None
+        if path_id is not None:
+            v = match_by_path_out.get((round_num, path_id, out_item))
             if v is not None:
                 return v
         return match_by_round_output.get((round_num, out_item))
@@ -349,9 +507,8 @@ def build_visualize_edges(
         for dr in derivation.get("derivation_results") or []:
             output_list = dr.get("output") or []
             path_id = dr.get("id")
-            for i, out_item in enumerate(output_list):
-                item_id = i + 1
-                v = get_match(round_num, path_id, item_id, out_item)
+            for out_item in output_list:
+                v = get_match(round_num, path_id, out_item)
                 if not v:
                     continue
                 mp, _reason, score, is_fully = v
@@ -370,9 +527,8 @@ def build_visualize_edges(
             output_list = dr.get("output") or []
             path_id = dr.get("id")
             matched: list[tuple[str, str, float, bool, str]] = []  # (mp, reason, score, is_fully, derivation_out)
-            for i, out_item in enumerate(output_list):
-                item_id = i + 1
-                v = get_match(round_num, path_id, item_id, out_item)
+            for out_item in output_list:
+                v = get_match(round_num, path_id, out_item)
                 if not v:
                     continue
                 mp, reason, score, is_fully = v
@@ -532,12 +688,25 @@ def generate_visualize_data(account_name: str, post_id: str, log_id: str, base_d
         json.dump(derivation_result, f, ensure_ascii=False, indent=4)
     print(f"已写入整体推导结果: {result_path}")
 
-    # 2.2 整体推导路径可视化
+    # 2.2 整体推导路径可视化(人设节点补全:used_tree_nodes / all_used_tree_nodes,数据来自处理后数据/tree 人设树)
     node_list, edge_list = build_visualize_edges(derivations, evals, topic_points)
+    tree_dir = base_dir / "input" / account_name / "处理后数据" / "tree"
+    persona_by_name = build_persona_by_name_from_tree_dir(tree_dir)
+    if persona_by_name:
+        print(
+            f"已加载人设树节点: {len(persona_by_name)} 个(目录: {tree_dir.name})"
+        )
+    else:
+        print(
+            f"警告: 未从人设树目录加载到节点(请确认存在 *_point_tree_how.json): {tree_dir}"
+        )
+    visualize_payload: Dict[str, Any] = {"node_list": node_list, "edge_list": edge_list}
+    enrich_visualize_with_used_tree_nodes(visualize_payload, persona_by_name)
+
     visualize_path = visualize_dir / f"{post_id}.json"
     visualize_dir.mkdir(parents=True, exist_ok=True)
     with open(visualize_path, "w", encoding="utf-8") as f:
-        json.dump({"node_list": node_list, "edge_list": edge_list}, f, ensure_ascii=False, indent=4)
+        json.dump(visualize_payload, f, ensure_ascii=False, indent=4)
     print(f"已写入整体推导路径可视化: {visualize_path}")
 
 
@@ -552,14 +721,34 @@ def main(account_name, post_id, log_id):
 
 
 if __name__ == "__main__":
-    account_name="创业邦"
-
+    from tools.pattern_dimension_analyze import main as pattern_dimension_analyze_main
+
+    # account_name="阿里多多酱"
+    # items = [
+    #     {"post_id": "6915dfc400000000070224d9", "log_id": "20260322135142"},
+    #     {"post_id":"69002ba70000000007008bcc","log_id":"20260322213934"},
+    # ]
+
+    # account_name="摸鱼阿希"
+    # items = [
+    #     {"post_id": "68ae91ce000000001d016b8b", "log_id": "20260322202416"},
+    #     {"post_id":"689c63ac000000001d015119","log_id":"20260322203119"},
+    # ]
+
+    # account_name = "每天心理学"
+    # items = [
+    #     {"post_id": "6949df27000000001d03e0e9", "log_id": "20260322205512"},
+    #     {"post_id": "6951c718000000001e0105b7", "log_id": "20260322211126"},
+    # ]
+
+    account_name = "空间点阵设计研究室"
     items = [
-        {"post_id":"69394a0b000000001f006ce6","log_id":"20260320153725"},
-        {"post_id":"694a6caf000000001f00e112","log_id":"20260320160445"},
-        # {"post_id":"6921937a000000001b0278d1","log_id":"20260319141843"}
+        {"post_id": "687ee6fc000000001c032bb1", "log_id": "20260322211748"},
+        {"post_id": "68843a4d000000001c037591", "log_id": "20260322213024"},
     ]
+
     for item in items:
         post_id = item["post_id"]
         log_id = item["log_id"]
         main(account_name, post_id, log_id)
+        pattern_dimension_analyze_main(account_name, post_id, log_id)

+ 1 - 1
examples_how/overall_derivation/overall_derivation_agent_run.py

@@ -550,4 +550,4 @@ async def main(account_name, post_id):
 if __name__ == "__main__":
     # anthropic/claude-sonnet-4.6
     # google/gemini-3-flash-preview
-    asyncio.run(main(account_name="创业邦", post_id="694a6caf000000001f00e112"))
+    asyncio.run(main(account_name="阿里多多酱", post_id="69002ba70000000007008bcc"))

+ 0 - 87
examples_how/overall_derivation/pattern_data_process.py

@@ -1,87 +0,0 @@
-#!/usr/bin/env python3
-"""
-pattern 数据精简:5 种 depth 合并为一份,按 items 去重;
-pattern 仅保留 s/l/i,i 为去重后字符串 "A+B+C",单行 JSON。
-"""
-import json
-from pathlib import Path
-
-INPUT_FILE = (
-    Path(__file__).resolve().parent / "input/家有大志/原始数据/pattern/processed_edge_data.json"
-)
-OUTPUT_DIR = Path(__file__).resolve().parent / "input/家有大志/pattern"
-OUTPUT_FILE = OUTPUT_DIR / "processed_edge_data.json"
-
-TOP_KEYS = [
-    "depth_max_with_name",
-    "depth_mixed",
-    "depth_max_concrete",
-    "depth2_medium",
-    "depth1_abstract",
-    "depth_max_minus_1",
-    "depth_max_minus_2",
-    "depth_3",
-    "depth_4",
-]
-SUB_KEYS = ["two_x", "one_x", "zero_x"]
-
-
-def slim_pattern(p):
-    """提取 name 列表,去重保序,返回 (support, length, items_key)。"""
-    names = [item["name"] for item in (p.get("items") or [])]
-    # 内部去重,保序
-    seen = set()
-    unique = []
-    for n in names:
-        if n not in seen:
-            seen.add(n)
-            unique.append(n)
-    support = round(float(p["support"]), 4)
-    length = p["length"]
-    return support, length, unique
-
-
-def to_short_entry(support, length, names):
-    """短格式:无 id,s/l/i,i 为 'A+B+C'。"""
-    return {"s": support, "l": length, "i": "+".join(names)}
-
-
-def merge_and_dedupe(patterns):
-    """按 items 的 name 集合去重(不区分顺序),留 support 最大;再按 s*l 降序;输出短格式。"""
-    key_to_best = {}
-    for p in patterns:
-        support, length, unique = slim_pattern(p)
-        key = tuple(sorted(unique))  # 同名字集合、顺序不同算同一条
-        if key not in key_to_best or support > key_to_best[key][0]:
-            key_to_best[key] = (support, length)
-    out = [to_short_entry(s, l, list(k)) for k, (s, l) in key_to_best.items() if s >= 0.1]
-    out.sort(key=lambda x: x["s"] * x["l"], reverse=True)
-    return out
-
-
-def main():
-    with open(INPUT_FILE, "r", encoding="utf-8") as f:
-        data = json.load(f)
-
-    all_patterns = []
-    for top in TOP_KEYS:
-        if top not in data:
-            continue
-        block = data[top]
-        for sub in SUB_KEYS:
-            all_patterns.extend(block.get(sub) or [])
-
-    result = merge_and_dedupe(all_patterns)
-    result.sort(key=lambda x: x["s"], reverse=True)
-    result = result[:500]
-
-    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
-    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
-        json.dump(result, f, ensure_ascii=False, indent=None, separators=(",", ":"))
-        f.write("\n")
-
-    print(f"已输出: {OUTPUT_FILE} (共 {len(result)} 条 pattern)")
-
-
-if __name__ == "__main__":
-    main()

+ 0 - 245
examples_how/overall_derivation/pattern_db_data_process.py

@@ -1,245 +0,0 @@
-import json
-import sys
-from pathlib import Path
-from typing import Any, Dict, List, DefaultDict
-from collections import defaultdict
-
-
-BASE_DIR = Path(__file__).resolve().parent
-
-
-def _load_json(path: Path) -> Any:
-    with open(path, "r", encoding="utf-8") as f:
-        return json.load(f)
-
-
-def _build_category_map(pattern_category_path: Path) -> Dict[int, Dict[str, Any]]:
-    """
-    根据 category_id 建索引,后面从 itemset_item 映射到分类名称等信息。
-    """
-    data = _load_json(pattern_category_path)
-    mapping: Dict[int, Dict[str, Any]] = {}
-    for row in data:
-        cid = row.get("id")
-        if cid is None:
-            continue
-        mapping[int(cid)] = row
-    return mapping
-
-
-def _build_items_by_itemset(pattern_itemset_item_path: Path) -> DefaultDict[int, List[Dict[str, Any]]]:
-    """
-    先把 itemset_item 根据 itemset_id 分组,便于后续快速拼装 pattern.items。
-    """
-    data = _load_json(pattern_itemset_item_path)
-    grouped: DefaultDict[int, List[Dict[str, Any]]] = defaultdict(list)
-    for row in data:
-        itemset_id = row.get("itemset_id")
-        if itemset_id is None:
-            continue
-        grouped[int(itemset_id)].append(row)
-    return grouped
-
-
-def _combination_type_bucket(combination_type: str) -> str:
-    """
-    根据组合类型中的符号数量映射到 two_x / one_x / zero_x。
-
-    规则:
-    - 先统计组合类型中的 '×' 数量;
-    - 若没有 '×',则再根据 '+' 数量判断。
-    """
-    if not combination_type:
-        return "zero_x"
-
-    times_count = combination_type.count("×")
-    if times_count >= 2:
-        return "two_x"
-    if times_count == 1:
-        return "one_x"
-
-    # 没有 '×' 时,才按 '+' 数量判断
-    plus_count = combination_type.count("+")
-    if plus_count >= 2:
-        return "two_x"
-    if plus_count == 1:
-        return "one_x"
-    return "zero_x"
-
-
-def _build_mining_config_id_to_depth_map(pattern_mining_config_path: Path) -> Dict[int, str]:
-    """
-    根据 pattern_mining_config.json 中的 target_depth 构建映射:
-    - target_depth = max -> depth_max_concrete
-    - target_depth = 3   -> depth_4
-    """
-    config_rows = _load_json(pattern_mining_config_path)
-    mapping: Dict[int, str] = {}
-    if not isinstance(config_rows, list):
-        return mapping
-
-    for row in config_rows:
-        if not isinstance(row, dict):
-            continue
-
-        cid = row.get("id")
-        target_depth = row.get("target_depth")
-        if cid is None or target_depth is None:
-            continue
-
-        try:
-            mining_config_id = int(cid)
-        except (TypeError, ValueError):
-            continue
-
-        target_str = str(target_depth).strip()
-        if target_str == "max":
-            mapping[mining_config_id] = "depth_max_concrete"
-        elif target_str == "3":
-            mapping[mining_config_id] = "depth_4"
-
-    return mapping
-
-
-def build_processed_edge_data(account_name: str) -> Dict[str, Any]:
-    """
-    读取小红书 pattern 原始数据,转换成 processed_edge_data.json 结构。
-
-    约定:
-    - target_depth = max → depth_max_concrete
-    - target_depth = 3   → depth_4
-    - two_x / one_x / zero_x 由 combination_type 中 '×' 的数量决定;没有 '×' 时,才用 '+' 的数量
-    - type_key = combination_type
-    """
-    # 以本文件为基准,定位到 account_name 原始 pattern_db 目录
-    pattern_db_root = BASE_DIR / "input" / account_name / "原始数据" / "pattern_db"
-    out_root = BASE_DIR / "input" / account_name / "原始数据" / "pattern"
-
-    pattern_itemset_path = pattern_db_root / "pattern_itemset.json"
-    pattern_itemset_item_path = pattern_db_root / "pattern_itemset_item.json"
-    pattern_category_path = pattern_db_root / "pattern_category.json"
-    pattern_mining_config_path = pattern_db_root / "pattern_mining_config.json"
-
-    mining_config_id_to_top_key = _build_mining_config_id_to_depth_map(pattern_mining_config_path)
-    if not mining_config_id_to_top_key:
-        # 这里不直接抛异常:方便调用方看日志/输出文件定位问题
-        print(f"未在 {pattern_mining_config_path} 中找到 target_depth 的映射记录")
-
-    itemsets: List[Dict[str, Any]] = _load_json(pattern_itemset_path)
-    items_by_itemset = _build_items_by_itemset(pattern_itemset_item_path)
-    category_map = _build_category_map(pattern_category_path)
-
-    # 初始化输出结构
-    output: Dict[str, Any] = {
-        "depth_max_with_name": {
-            "two_x": [],
-            "one_x": [],
-            "zero_x": [],
-        },
-        "depth_mixed": {
-            "two_x": [],
-            "one_x": [],
-            "zero_x": [],
-        },
-        "depth_max_concrete": {
-            "two_x": [],
-            "one_x": [],
-            "zero_x": [],
-        },
-        "depth_4": {
-            "two_x": [],
-            "one_x": [],
-            "zero_x": [],
-        },
-    }
-
-    for it in itemsets:
-        mining_config_id = it.get("mining_config_id")
-        if mining_config_id is None:
-            continue
-
-        top_key = mining_config_id_to_top_key.get(int(mining_config_id)) if str(mining_config_id).isdigit() else None
-        if not top_key:
-            continue
-
-        combination_type = it.get("combination_type") or ""
-        bucket = _combination_type_bucket(combination_type)
-
-        itemset_id = int(it["id"])
-        raw_items = items_by_itemset.get(itemset_id, [])
-
-        items: List[Dict[str, Any]] = []
-        for ri in raw_items:
-            cid = ri.get("category_id")
-            cat = category_map.get(int(cid)) if cid is not None else None
-            name = (cat or {}).get("name") or (ri.get("element_name") or "")
-            dimension = ri.get("dimension") or (cat or {}).get("source_type") or ""
-            items.append(
-                {
-                    "name": name,
-                    "point": "",           # 源数据未直接提供 point,这里留空
-                    "dimension": dimension,
-                    "type": "分类",
-                }
-            )
-
-        try:
-            support = float(it.get("support", 0.0))
-        except (TypeError, ValueError):
-            support = 0.0
-
-        matched_posts_raw = it.get("matched_post_ids") or "[]"
-        try:
-            matched_posts = json.loads(matched_posts_raw)
-        except Exception:
-            matched_posts = []
-
-        pattern_obj = {
-            "id": itemset_id,
-            "type_key": combination_type,
-            "support": support,
-            "absolute_support": it.get("absolute_support"),
-            "length": it.get("item_count"),
-            "post_count": it.get("absolute_support"),
-            "matched_posts": matched_posts,
-            "items": items,
-        }
-
-        output[top_key][bucket].append(pattern_obj)
-
-    # 输出路径与数据一起返回,方便脚本或外部调用使用
-    result = {
-        "data": output,
-        "output_root": str(out_root),
-    }
-    return result
-
-
-def build_processed_edge_data_for_xhs(account_name: str = "xiaohongshu") -> Dict[str, Any]:
-    """
-    兼容旧函数名:脚本历史上曾按 xiaohongshu 命名。
-    """
-    return build_processed_edge_data(account_name=account_name)
-
-
-def main(account_name: str) -> None:
-    """
-    脚本入口:生成 processed_edge_data.json 到
-    examples_how/overall_derivation/input/{account_name}/原始数据/pattern/ 目录下。
-    """
-    result = build_processed_edge_data(account_name=account_name)
-    data = result["data"]
-    out_root = Path(result["output_root"])
-    out_root.mkdir(parents=True, exist_ok=True)
-
-    out_path = out_root / "processed_edge_data.json"
-    with open(out_path, "w", encoding="utf-8") as f:
-        json.dump(data, f, ensure_ascii=False, indent=2)
-
-    print(f"[{account_name}] processed_edge_data.json 已生成:{out_path}")
-
-
-if __name__ == "__main__":
-    # account = sys.argv[1] if len(sys.argv) >= 2 else "xiaohongshu"
-    main(account_name="创业邦")
-

+ 76 - 0
examples_how/overall_derivation/prompt/judge_category_relation.md

@@ -0,0 +1,76 @@
+你是一个语义关系分析专家。你的任务是判断一组分类标签之间的语义关系是"互斥"还是"有交集"。
+
+## 定义
+
+- **互斥**:这些分类描述的是同一维度下的不同选项,一个内容主题通常只会属于其中一个分类。
+  - 例如:["Python教程", "Java教程", "Go教程"] → 互斥(都是编程语言教程,但语言不同,一篇教程只会讲一种语言)
+  - 例如:["美国旅行", "日本旅行", "泰国旅行"] → 互斥(都是旅行攻略,但目的地不同)
+
+- **有交集**:这些分类描述的维度有重叠,一个内容主题可能同时属于多个分类。
+  - 例如:["职场沟通", "职场晋升"] → 有交集(一篇讲"如何通过沟通获得晋升"的内容可同时属于两者)
+  - 例如:["护肤", "抗衰"] → 有交集(抗衰是护肤的一个目的,很多内容会重叠)
+
+## 输入
+
+父分类:{parent_category}
+子分类列表:{child_categories}
+
+## 任务
+
+1. 分析这些子分类在语义上的关系
+2. 判断它们是"互斥"还是"有交集"
+3. 给出判断依据
+
+## 输出格式
+```json
+{
+  "relation": "互斥" | "有交集",
+  "confidence": 0.0-1.0,
+  "reasoning": "判断依据的简要说明"
+}
+```
+
+## 注意事项
+
+- 从内容创作的角度思考:一篇帖子是否可能同时被归入多个子分类
+- 如果大部分子分类两两互斥,但有少数几对可能有交集,整体判断为"有交集"
+- 不确定时倾向于判断为"有交集"(更保守)
+
+
+## 调用示例
+
+示例1:
+**输入:**
+父分类:编程教程
+子分类列表:["Python教程", "Java教程", "Go教程"]
+
+**预期输出:**
+{
+  "relation": "互斥",
+  "confidence": 0.95,
+  "reasoning": "每篇教程通常只讲一种编程语言,不会同时教Python和Java。这些分类按语言划分,边界清晰。"
+}
+
+示例2:
+**输入:**
+父分类:生活方式
+子分类列表:["租房经验", "厨房好物", "通勤技巧"]
+
+**预期输出:**
+{
+  "relation": "互斥",
+  "confidence": 0.85,
+  "reasoning": "这三个分类分别聚焦居住、烹饪、出行三个不同的生活场景,一篇内容通常只会深入其中一个场景。"
+}
+
+示例3:
+**输入:**
+父分类:职场成长
+子分类列表:["向上管理", "跨部门协作", "个人品牌"]
+
+**预期输出:**
+{
+  "relation": "有交集",
+  "confidence": 0.75,
+  "reasoning": "向上管理和跨部门协作都涉及职场沟通,一篇讲如何通过跨部门协作获得领导认可的内容可能同时属于两者。个人品牌的建立也可能涉及向上管理的技巧。"
+}

+ 7 - 7
examples_how/overall_derivation/tools/find_pattern.py

@@ -2,7 +2,7 @@
 查找 Pattern Tool - 从 pattern 库中获取符合条件概率阈值的 pattern
 
 功能:
-- 账号:读取 input/{账号}/原始数据/pattern/processed_edge_data.json,条件概率基于账号人设树;
+- 账号:读取 input/{账号}/处理后数据/pattern/pattern.json,条件概率基于账号人设树;
   元素与帖子选题点匹配走账号 match_data / point_match,并支持人设树子节点、兄弟节点扩展。
 - 平台库:读取 input/xiaohongshu/pattern/processed_edge_data.json,条件概率基于 xiaohongshu/tree;
   元素匹配仅使用 input/xiaohongshu/match_data/{post_id}_匹配_all.json。
@@ -90,8 +90,8 @@ def _build_node_info(account_name: str) -> dict[str, dict]:
 
 
 def _pattern_file(account_name: str) -> Path:
-    """pattern 库文件:../input/{account_name}/原始数据/pattern/processed_edge_data.json"""
-    return _BASE_INPUT / account_name / "原始数据" / "pattern" / "processed_edge_data.json"
+    """pattern 库文件:../input/{account_name}/处理后数据/pattern/pattern.json"""
+    return _BASE_INPUT / account_name / "处理后数据" / "pattern" / "pattern.json"
 
 
 def _platform_pattern_file() -> Path:
@@ -597,7 +597,7 @@ async def find_pattern(
         items_platform: list[dict[str, Any]] = []
         if post_id:
             items_platform = get_platform_patterns_by_conditional_ratio(
-                derived_list, conditional_ratio_threshold / 10, top_n, post_id
+                derived_list, conditional_ratio_threshold / 5, top_n, post_id
             )
             _attach_platform_pattern_post_matches(items_platform, post_id, thr)
             items_platform = [
@@ -686,15 +686,15 @@ def main() -> None:
     """本地测试:用家有大志账号、已推导选题点,查询符合条件概率阈值的 pattern(含帖子匹配)。"""
     import asyncio
 
-    account_name = "创业邦"
-    post_id = "694a6caf000000001f00e112"
+    account_name = "阿里多多酱"
+    post_id = "6915dfc400000000070224d9"
     # 已推导选题点,每项:已推导的选题点 + 推导来源人设树节点
     # derived_items = [
     #     {"topic": "分享", "source_node": "分享"},
     #     {"topic": "植入方式", "source_node": "植入方式"},
     #     {"topic": "叙事结构", "source_node": "叙事结构"},
     # ]
-    derived_items = [{"topic":"叙事","source_node":"叙事"},{"source_node":"分享","topic":"分享"},{"source_node":"长图文","topic":"长图文"},{"topic":"深度报道","source_node":"深度报道"},{"topic":"深度报道","source_node":"深度解析/分析"},{"source_node":"学霸创业","topic":"学霸创业"},{"source_node":"港股IPO","topic":"港股IPO"},{"topic":"商业/行业资讯","source_node":"商业/行业资讯"},{"source_node":"统计数据","topic":"数据图表"},{"topic":"数据图表","source_node":"数据图表"},{"source_node":"辅助","topic":"辅助"},{"source_node":"人物/创业叙事","topic":"人物/创业叙事"},{"topic":"商业背景","source_node":"商业背景"},{"source_node":"创业样本","topic":"创业样本"},{"source_node":"地域","topic":"地域"},{"source_node":"叙事视角","topic":"叙事"},{"source_node":"叙事编排","topic":"叙事"},{"topic":"深度报道","source_node":"分析解读"},{"source_node":"叙事风格","topic":"叙事"},{"source_node":"量化数据","topic":"数据图表"},{"source_node":"拆解剖析","topic":"深度报道"},{"source_node":"综合统计","topic":"数据图表"},{"topic":"地域","source_node":"空间"},{"source_node":"地域标签","topic":"地域"}]
+    derived_items = derived_items = [{"topic":"推广","source_node":"推广"},{"topic":"视觉调性","source_node":"视觉调性"}]
     conditional_ratio_threshold = 0.2
     top_n = 2000
 

+ 5 - 5
examples_how/overall_derivation/tools/find_tree_node.py

@@ -114,8 +114,8 @@ def _descendant_names_under_tree_nodes(
 
 
 def _tree_dir(account_name: str) -> Path:
-    """人设树目录:../input/{account_name}/原始数据/tree/"""
-    return _BASE_INPUT / account_name / "原始数据" / "tree"
+    """人设树目录:../input/{account_name}/处理后数据/tree/"""
+    return _BASE_INPUT / account_name / "处理后数据" / "tree"
 
 
 def _load_trees(account_name: str) -> list[tuple[str, dict]]:
@@ -760,15 +760,15 @@ def main() -> None:
     """本地测试:用家有大志账号测常量节点与条件概率节点,有 agent 时再跑一遍 tool 接口。"""
     import asyncio
 
-    account_name = "创业邦"
-    post_id = "694a6caf000000001f00e112"
+    account_name = "阿里多多酱"
+    post_id = "6915dfc400000000070224d9"
     log_id = "20260320160445"
     round = 4
     # derived_items = [
     #     {"topic": "分享", "source_node": "分享"},
     #     {"topic": "叙事结构", "source_node": "叙事结构"},
     # ]
-    derived_items = [{"topic":"叙事","source_node":"叙事"},{"source_node":"分享","topic":"分享"},{"source_node":"长图文","topic":"长图文"},{"topic":"深度报道","source_node":"深度报道"},{"topic":"深度报道","source_node":"深度解析/分析"},{"source_node":"学霸创业","topic":"学霸创业"},{"source_node":"港股IPO","topic":"港股IPO"},{"topic":"商业/行业资讯","source_node":"商业/行业资讯"},{"source_node":"统计数据","topic":"数据图表"},{"topic":"数据图表","source_node":"数据图表"},{"source_node":"辅助","topic":"辅助"},{"source_node":"人物/创业叙事","topic":"人物/创业叙事"},{"topic":"商业背景","source_node":"商业背景"},{"source_node":"创业样本","topic":"创业样本"},{"source_node":"地域","topic":"地域"},{"source_node":"叙事视角","topic":"叙事"},{"source_node":"叙事编排","topic":"叙事"},{"topic":"深度报道","source_node":"分析解读"},{"source_node":"叙事风格","topic":"叙事"},{"source_node":"量化数据","topic":"数据图表"},{"source_node":"拆解剖析","topic":"深度报道"},{"source_node":"综合统计","topic":"数据图表"},{"topic":"地域","source_node":"空间"},{"source_node":"地域标签","topic":"地域"}]
+    derived_items = [{"topic":"推广","source_node":"推广"},{"topic":"视觉调性","source_node":"视觉调性"}]
     conditional_ratio_threshold = 0.2
     top_n = 2000
 

+ 28 - 10
examples_how/overall_derivation/tools/pattern_dimension_analyze.py

@@ -175,8 +175,8 @@ def _load_round_matched_points(
 # ---------------------------------------------------------------------------
 
 def _pattern_file(account_name: str) -> Path:
-    """pattern 库文件:../input/{account_name}/原始数据/pattern/processed_edge_data.json"""
-    return _BASE_INPUT / account_name / "原始数据" / "pattern" / "processed_edge_data.json"
+    """pattern 库文件:../input/{account_name}/处理后数据/pattern/pattern.json"""
+    return _BASE_INPUT / account_name / "处理后数据" / "pattern" / "pattern.json"
 
 
 def _load_raw_patterns(account_name: str) -> List[Dict[str, Any]]:
@@ -1008,25 +1008,43 @@ if __name__ == "__main__":
     run_round_pattern_test = False
     run_full_pattern_analyze = True
 
-    test_account_name = "创业邦"
-    test_post_id = "69185d49000000000d00f94e"
-    test_log_id = "20260318221136"
+
+    test_post_id = "6915dfc400000000070224d9"
+    test_log_id = "20260322134324"
     test_round = 1
 
+    # account_name = "阿里多多酱"
+    # items = [
+    #     {"post_id": "6915dfc400000000070224d9", "log_id": "20260322135142"},
+    #     {"post_id": "69002ba70000000007008bcc", "log_id": "20260322213934"},
+    # ]
+
+    # account_name = "摸鱼阿希"
+    # items = [
+    #     {"post_id": "68ae91ce000000001d016b8b", "log_id": "20260322202416"},
+    #     {"post_id": "689c63ac000000001d015119", "log_id": "20260322203119"},
+    # ]
+
+    # account_name = "每天心理学"
+    # items = [
+    #     {"post_id": "6949df27000000001d03e0e9", "log_id": "20260322205512"},
+    #     {"post_id": "6951c718000000001e0105b7", "log_id": "20260322211126"},
+    # ]
+
+    account_name = "空间点阵设计研究室"
     items = [
-        {"post_id": "69394a0b000000001f006ce6", "log_id": "20260320153725"},
-        {"post_id": "694a6caf000000001f00e112", "log_id": "20260320160445"},
-        # {"post_id": "6921937a000000001b0278d1", "log_id": "20260319141843"}
+        {"post_id": "687ee6fc000000001c032bb1", "log_id": "20260322211748"},
+        {"post_id": "68843a4d000000001c037591", "log_id": "20260322213024"},
     ]
 
     if run_round_pattern_test:
         main_round_pattern_dimension_analyze(
-            test_account_name,
+            account_name,
             test_post_id,
             test_log_id,
             test_round,
         )
     elif run_full_pattern_analyze:
         for item in items:
-            main(test_account_name, item["post_id"], item["log_id"])
+            main(account_name, item["post_id"], item["log_id"])
 

+ 4 - 4
examples_how/overall_derivation/tools/point_match.py

@@ -30,13 +30,13 @@ DEFAULT_MATCH_THRESHOLD = 0.6
 
 
 def _post_topic_file(account_name: str, post_id: str) -> Path:
-    """帖子选题点文件:../input/{account_name}/post_topic/{post_id}.json"""
-    return _BASE_INPUT / account_name / "post_topic" / f"{post_id}.json"
+    """帖子选题点文件:../input/{account_name}/处理后数据/post_topic/{post_id}.json"""
+    return _BASE_INPUT / account_name / "处理后数据" / "post_topic" / f"{post_id}.json"
 
 
 def _match_data_file(account_name: str, post_id: str) -> Path:
-    """帖子选题点与人设树节点匹配结果文件:../input/{account_name}/match_data/{post_id}_匹配_all.json"""
-    return _BASE_INPUT / account_name / "match_data" / f"{post_id}_匹配_all.json"
+    """帖子选题点与人设树节点匹配结果文件:../input/{account_name}/处理后数据/match_data/{post_id}_匹配_all.json"""
+    return _BASE_INPUT / account_name / "处理后数据" / "match_data" / f"{post_id}_匹配_all.json"
 
 
 def _load_match_data(

+ 1 - 1
examples_how/overall_derivation/tools/search_and_eval.py

@@ -63,7 +63,7 @@ def _load_match_and_extract_prompt() -> str:
 
 def _load_persona_text(account_name: str) -> str:
     """读取账号人设摘要,返回可读字符串;文件不存在时返回空人设提示"""
-    persona_file = _BASE_INPUT / account_name / "persona_data" / "persona_summary.json"
+    persona_file = _BASE_INPUT / account_name / "处理后数据" / "persona_data" / "persona_summary.json"
     if not persona_file.is_file():
         logger.warning("_load_persona_text: persona file not found: %s", persona_file)
         return f"账号:{account_name}(暂无人设数据)"

+ 0 - 139
examples_how/overall_derivation/tree_data_process.py

@@ -1,139 +0,0 @@
-#!/usr/bin/env python3
-"""
-人设树 JSON 精简处理:去掉指定字段,输出到目标目录。
-
-进一步精简建议(可选):
-  --minify      单行 JSON,去掉缩进与多余空白(体积可降约 40%+,推荐)
-  --round N     数值保留 N 位小数(默认 4,可再减小体积)
-  --short-keys  用短键名(t/n/w/r/c/ch),体积最小,但读取时需配合 KEY_MAP 还原
-"""
-import argparse
-import json
-from pathlib import Path
-
-# 需要移除的字段
-FIELDS_TO_REMOVE = {"_post_ids", "_child_categories_relation", "_child_categories_relation_detail"}
-
-# 短键名映射(仅当 --short-keys 时使用)
-KEY_MAP = {
-    "_type": "t",
-    "_post_count": "n",
-    "_persona_weight_score": "w",
-    "_ratio": "r",
-    "_is_constant": "c",
-    "_is_local_constant": "lc",
-    "children": "ch",
-}
-KEY_MAP_INV = {v: k for k, v in KEY_MAP.items()}
-
-INPUT_DIR = Path(__file__).resolve().parent / "input/创业邦/原始数据/tree"
-OUTPUT_DIR = Path(__file__).resolve().parent / "input/创业邦/tree"
-
-
-def strip_fields(obj):
-    """递归移除指定字段。"""
-    if isinstance(obj, dict):
-        for key in list(obj.keys()):
-            if key in FIELDS_TO_REMOVE:
-                del obj[key]
-            else:
-                strip_fields(obj[key])
-    elif isinstance(obj, list):
-        for item in obj:
-            strip_fields(item)
-    return obj
-
-
-def round_floats(obj, ndigits: int):
-    """递归将浮点数四舍五入到 ndigits 位。"""
-    if isinstance(obj, dict):
-        for k, v in obj.items():
-            obj[k] = round_floats(v, ndigits)
-    elif isinstance(obj, list):
-        for i, v in enumerate(obj):
-            obj[i] = round_floats(v, ndigits)
-    elif isinstance(obj, float):
-        return round(obj, ndigits)
-    return obj
-
-
-def abbreviate_keys(obj):
-    """递归将已知长键名替换为短键名(仅处理 KEY_MAP 中的键)。"""
-    if isinstance(obj, dict):
-        new_obj = {}
-        for k, v in obj.items():
-            new_key = KEY_MAP.get(k, k)
-            new_obj[new_key] = abbreviate_keys(v)
-        return new_obj
-    if isinstance(obj, list):
-        return [abbreviate_keys(x) for x in obj]
-    return obj
-
-
-def expand_keys(obj):
-    """递归将短键名还原为长键名(读取 --short-keys 输出的文件时使用)。"""
-    if isinstance(obj, dict):
-        new_obj = {}
-        for k, v in obj.items():
-            new_key = KEY_MAP_INV.get(k, k)
-            new_obj[new_key] = expand_keys(v)
-        return new_obj
-    if isinstance(obj, list):
-        return [expand_keys(x) for x in obj]
-    return obj
-
-
-def process_tree_json(
-    in_path: Path,
-    out_path: Path,
-    *,
-    minify: bool = False,
-    round_ndigits: int | None = None,
-    short_keys: bool = False,
-) -> None:
-    """读取一个树 JSON,精简后写入 out_path。"""
-    with open(in_path, "r", encoding="utf-8") as f:
-        data = json.load(f)
-    strip_fields(data)
-    if round_ndigits is not None:
-        round_floats(data, round_ndigits)
-    if short_keys:
-        data = abbreviate_keys(data)
-    out_path.parent.mkdir(parents=True, exist_ok=True)
-    with open(out_path, "w", encoding="utf-8") as f:
-        json.dump(
-            data,
-            f,
-            ensure_ascii=False,
-            indent=None if minify else 2,
-            separators=(",", ":") if minify else (", ", ": "),
-        )
-        if minify:
-            f.write("\n")
-
-
-def main():
-    # parser = argparse.ArgumentParser(description="人设树 JSON 精简")
-    # parser.add_argument("--minify", action="store_true", help="单行 JSON,减小体积")
-    # parser.add_argument("--round", type=int, default=None, metavar="N", help="数值保留 N 位小数")
-    # parser.add_argument("--short-keys", action="store_true", help="使用短键名(读取时需还原)")
-    # args = parser.parse_args()
-
-    INPUT_DIR.mkdir(parents=True, exist_ok=True)
-    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
-
-    for in_file in sorted(INPUT_DIR.glob("*.json")):
-        out_file = OUTPUT_DIR / in_file.name
-        process_tree_json(
-            in_file,
-            out_file,
-            minify=False,
-            round_ndigits=4,
-            short_keys=False,
-        )
-        size = out_file.stat().st_size
-        print(f"已处理: {in_file.name} -> {out_file} ({size:,} B)")
-
-
-if __name__ == "__main__":
-    main()

+ 0 - 142
examples_how/overall_derivation/tree_lib_data_process.py

@@ -1,142 +0,0 @@
-"""
-人设树库数据处理脚本
-
-将 xiaohongshu 原始数据树库(扁平列表格式)转换为嵌套树结构格式,
-与 家有大志/原始数据/tree/*.json 格式对齐,方便后续统一处理。
-
-输入:examples_how/overall_derivation/input/xiaohongshu/原始数据/tree/
-输出:examples_how/overall_derivation/input/xiaohongshu/tree/
-"""
-
-import json
-import os
-from pathlib import Path
-
-
-def parse_category_path(path: str) -> list[str]:
-    """将 '/表象/实体/生物/动物/宠物' 解析为路径段列表。"""
-    return [seg for seg in path.split("/") if seg]
-
-
-def build_tree_from_flat(elements: list, tree_name: str) -> dict:
-    """
-    从扁平元素列表构建嵌套树结构。
-
-    每个元素按 category_path 插入对应层级,元素本身作为叶节点(_type: "ID")。
-    分类节点(_type: "class")的 _post_ids 为其下所有叶节点 post_ids 的去重并集。
-    """
-    # 中间构建用的树,使用嵌套 dict,children 内均为同结构
-    root_children: dict = {}
-
-    for elem in elements:
-        path_segments = parse_category_path(elem["category_path"])
-        elem_name = elem["element_name"]
-        post_ids = elem.get("post_ids", [])
-
-        # 逐层创建或取已有的分类节点
-        current_children = root_children
-        for seg in path_segments:
-            if seg not in current_children:
-                current_children[seg] = {
-                    "_type": "class",
-                    "_post_ids_set": set(),
-                    "children": {},
-                }
-            current_children = current_children[seg]["children"]
-
-        # 叶节点:同名元素合并 post_ids
-        if elem_name not in current_children:
-            current_children[elem_name] = {
-                "_type": "ID",
-                "_post_ids_set": set(),
-            }
-        current_children[elem_name]["_post_ids_set"].update(post_ids)
-
-    # 自底向上传播 post_ids 并计算 _post_count
-    def propagate(children: dict) -> set:
-        """递归传播,返回当前层所有 post_ids 的并集。"""
-        all_ids: set = set()
-        for node in children.values():
-            if node["_type"] == "ID":
-                all_ids.update(node["_post_ids_set"])
-            else:
-                child_ids = propagate(node["children"])
-                node["_post_ids_set"].update(child_ids)
-                all_ids.update(node["_post_ids_set"])
-        return all_ids
-
-    root_ids = propagate(root_children)
-    root_post_count = len(root_ids)
-
-    # 递归序列化为目标格式,同时计算 _ratio
-    def serialize_class(name: str, node: dict) -> dict:
-        post_ids_list = sorted(node["_post_ids_set"])
-        post_count = len(post_ids_list)
-        result = {
-            "_type": node["_type"],
-            "_post_count": post_count,
-            "_post_ids": post_ids_list,
-        }
-        if node["_type"] == "class" and node.get("children"):
-            serialized_children = {}
-            for child_name, child_node in node["children"].items():
-                serialized_children[child_name] = serialize_class(child_name, child_node)
-            result["children"] = serialized_children
-        result["_ratio"] = (
-            round(post_count / root_post_count, 4) if root_post_count > 0 else 0.0
-        )
-        return result
-
-    # 构建根节点
-    root_ids_list = sorted(root_ids)
-    serialized_children = {}
-    for child_name, child_node in root_children.items():
-        serialized_children[child_name] = serialize_class(child_name, child_node)
-
-    root_node = {
-        "_type": "root",
-        "_post_count": root_post_count,
-        "_post_ids": root_ids_list,
-        "children": serialized_children,
-    }
-
-    return {tree_name: root_node}
-
-
-def process_file(input_path: str, output_path: str, tree_name: str) -> None:
-    with open(input_path, "r", encoding="utf-8") as f:
-        data = json.load(f)
-
-    elements = data["data"]
-    tree = build_tree_from_flat(elements, tree_name)
-
-    os.makedirs(os.path.dirname(output_path), exist_ok=True)
-    with open(output_path, "w", encoding="utf-8") as f:
-        json.dump(tree, f, ensure_ascii=False, indent=2)
-
-    root_post_count = tree[tree_name]["_post_count"]
-    print(f"[完成] {os.path.basename(input_path)} -> {output_path}")
-    print(f"       根节点帖子数: {root_post_count},元素总数: {len(elements)}")
-
-
-def main():
-    base_dir = Path(__file__).parent
-    input_dir = base_dir / "input/xiaohongshu/原始数据/tree"
-    output_dir = base_dir / "input/xiaohongshu/tree"
-
-    file_map = {
-        "实质_tree.json": "实质",
-        "形式_tree.json": "形式",
-    }
-
-    for filename, tree_name in file_map.items():
-        input_path = input_dir / filename
-        output_path = output_dir / f"{tree_name}_point_tree_how.json"
-        if not input_path.exists():
-            print(f"[跳过] 文件不存在: {input_path}")
-            continue
-        process_file(str(input_path), str(output_path), tree_name)
-
-
-if __name__ == "__main__":
-    main()

+ 14 - 14
examples_how/overall_derivation/utils/conditional_ratio_calc.py

@@ -20,10 +20,10 @@ DerivedItem = tuple[str, str]
 
 
 def _tree_dir(account_name: str, base_dir: Path | None = None) -> Path:
-    """人设树目录:../input/{account_name}/原始数据/tree/(相对本文件所在目录)。"""
+    """人设树目录:../input/{account_name}/处理后数据/tree/(相对本文件所在目录)。"""
     if base_dir is not None:
-        return base_dir / account_name / "原始数据" / "tree"
-    return Path(__file__).resolve().parent.parent / "input" / account_name / "原始数据" / "tree"
+        return base_dir / account_name / "处理后数据" / "tree"
+    return Path(__file__).resolve().parent.parent / "input" / account_name / "处理后数据" / "tree"
 
 
 def _load_trees(account_name: str, base_dir: Path | None = None) -> list[tuple[str, dict]]:
@@ -37,7 +37,7 @@ def _load_trees(account_name: str, base_dir: Path | None = None) -> list[tuple[s
 def _load_trees_from_directory(tree_dir: Path) -> list[tuple[str, dict]]:
     """
     从指定目录加载所有人设树 JSON(每文件取顶层第一个维度根,与按账号目录加载时行为一致)。
-    用于平台库等人设树路径非 input/{账号}/原始数据/tree/ 的场景。
+    用于平台库等人设树路径非 input/{账号}/处理后数据/tree/ 的场景。
     """
     if not tree_dir.is_dir():
         return []
@@ -295,25 +295,25 @@ def _test_with_user_example() -> None:
     人设树节点:恶作剧;pattern:分享+动物角色+创意表达 post_count=2。
     推导来源的 post_ids 在方法内部从人设树读取。
     """
-    account_name = "家有大志"
+    account_name = "阿里多多酱"
     # 已推导列表:(已推导的选题点, 推导来源人设树节点)
     derived_list: list[DerivedItem] = [
-        ("分享", "分享"),
-        ("叙事结构", "叙事结构"),
-        ("图片文字", "图片文字"),
-        ("补充说明式", "补充说明式"),
-        ("幽默化标题", "幽默化标题"),
-        ("标题", "标题"),
+        ("推广", "推广"),
+        ("视觉调性", "视觉调性"),
+        # ("图片文字", "图片文字"),
+        # ("补充说明式", "补充说明式"),
+        # ("幽默化标题", "幽默化标题"),
+        # ("标题", "标题"),
     ]
 
     # 1)人设树节点「恶作剧」的条件概率
-    r_node = calc_node_conditional_ratio(account_name, derived_list, "柴犬主角")
+    r_node = calc_node_conditional_ratio(account_name, derived_list, "观念")
     print(f"1) 人设树节点条件概率: {r_node}")
 
     # 2)pattern 分享+动物角色+创意表达 post_count=2 的条件概率
-    pattern = {"i": "分享+动物角色+创意表达", "post_count": 2, "s": 0.3}
+    pattern = {"i": "视觉调性+辞格意象+叙事编排", "post_count": 22, "s": 0.478261}
     r_pattern = calc_pattern_conditional_ratio(account_name, derived_list, pattern)
-    print(f"2) pattern 分享+动物角色+创意表达 (post_count=2) 条件概率: {r_pattern}")
+    print(f"2) pattern 条件概率: {r_pattern}")
 
 
 if __name__ == "__main__":

+ 3666 - 0
examples_how/overall_derivation/visualize_paths.py

@@ -0,0 +1,3666 @@
+import json
+import math
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional
+
+
+def _build_selection_points_from_decode(decode_data: Dict) -> List[Dict]:
+    """将 examples 解构内容 JSON 转为可视化「帖子选题表」所需的选题点行。"""
+    result: List[Dict] = []
+    point_types = ["灵感点", "目的点", "关键点"]
+    for point_type in point_types:
+        points = decode_data.get(point_type, [])
+        if not isinstance(points, list):
+            continue
+        for item in points:
+            if not isinstance(item, dict):
+                continue
+            title = item.get("选题点") or item.get("点") or ""
+            essence: List[str] = []
+            form: List[str] = []
+            intent: List[str] = []
+            for el in item.get("选题点元素") or []:
+                if not isinstance(el, dict):
+                    continue
+                name = el.get("元素名称")
+                if not name:
+                    continue
+                et = el.get("元素类型") or ""
+                if et == "实质":
+                    essence.append(name)
+                elif et == "形式":
+                    form.append(name)
+                elif et == "意图":
+                    intent.append(name)
+                else:
+                    essence.append(name)
+            result.append(
+                {
+                    "类型": point_type,
+                    "选题点": title,
+                    "实质": essence,
+                    "形式": form,
+                    "意图": intent,
+                }
+            )
+    return result
+
+
+def load_post_detail_for_visualization(account_name: str, post_id: str) -> Optional[Dict]:
+    """
+    从 Agent 示例目录读取原始帖子与解构内容,供「待解构帖子详情」弹窗与侧边栏使用。
+    - post_data: input/{account}/原始数据/post_data/{post_id}.json
+    - 解构: input/{account}/原始数据/解构内容/{post_id}.json
+    """
+    base = Path(__file__).resolve().parent
+    post_path = base / "input" / account_name / "原始数据" / "post_data" / f"{post_id}.json"
+    decode_path = base / "input" / account_name / "原始数据" / "解构内容" / f"{post_id}.json"
+    try:
+        with open(post_path, "r", encoding="utf-8") as f:
+            post_data = json.load(f)
+    except Exception:
+        return None
+    decode_data: Dict = {}
+    try:
+        with open(decode_path, "r", encoding="utf-8") as f:
+            decode_data = json.load(f)
+    except Exception:
+        pass
+    out = dict(post_data)
+    out["选题点"] = _build_selection_points_from_decode(decode_data) if decode_data else []
+    pid = out.get("channel_content_id") or decode_data.get("帖子ID")
+    if pid and not out.get("id"):
+        out["id"] = pid
+    return out
+
+def generate_all_in_one_visualization(
+    data_map: Dict[str, dict],
+    output_path: str,
+    account_name: str,
+    derivation_data: Dict[str, list] = None,
+    post_detail_map: Dict[str, dict] = None,
+    dimension_analyze_map: Dict[str, dict] = None,
+):
+    """
+    将所有帖子的数据整合到一个 HTML 中,支持动态切换
+    data_map: { "文件名": json_data, ... }
+    derivation_data: { "文件名": 推导结果列表, ... }
+    post_detail_map: { "文件名": 帖子详情(含选题点),来自 load_post_detail_for_visualization }
+    dimension_analyze_map: { post_id: 整体推导维度分析 JSON(含 rounds.derived_dims 等)}
+    """
+    # 提取第一个帖子的数据作为默认展示
+    first_key = list(data_map.keys())[0]
+    
+    # 将整个 data_map 转换为 JS 对象
+    json_data_js = json.dumps(data_map, ensure_ascii=False)
+    
+    # 将推导数据转换为 JS 对象
+    if derivation_data is None:
+        derivation_data = {}
+    derivation_data_js = json.dumps(derivation_data, ensure_ascii=False)
+    
+    # 将帖子详情数据转换为 JS 对象(供「待解构帖子」弹窗使用)
+    if post_detail_map is None:
+        post_detail_map = {}
+    post_detail_map_js = json.dumps(post_detail_map, ensure_ascii=False)
+
+    if dimension_analyze_map is None:
+        dimension_analyze_map = {}
+    dimension_analyze_data_js = json.dumps(dimension_analyze_map, ensure_ascii=False)
+
+    account_name_js = json.dumps(account_name, ensure_ascii=False)
+
+    html_content = rf'''<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>多源数据流可视化 - 完整全景版</title>
+    <style>
+        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+            background: #f8fafc;
+            overflow: hidden;
+            user-select: none;
+        }}
+
+        /* 顶部工具栏 */
+        #top-bar {{
+            position: fixed; top: 0; left: 0; right: 0; height: 60px;
+            background: white; border-bottom: 1px solid #e2e8f0;
+            display: flex; align-items: center; justify-content: space-between;
+            padding: 0 24px; z-index: 100;
+            box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+        }}
+        .controls {{ display: flex; gap: 16px; align-items: center; }}
+        .controls input {{
+            padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
+            font-size: 14px; width: 220px; transition: border 0.2s;
+        }}
+        .controls input:focus {{ border-color: #3b82f6; outline: none; }}
+        .controls select {{
+            padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
+            font-size: 14px; width: 200px; transition: border 0.2s;
+        }}
+        .controls select:focus {{ border-color: #3b82f6; outline: none; }}
+        .controls button {{
+            padding: 8px 16px; background: #3b82f6; color: white; border: none;
+            border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
+            transition: background 0.2s;
+        }}
+        .controls button:hover {{ background: #2563eb; }}
+
+        /* 画布区域 */
+        #app-container {{
+            position: fixed; top: 60px; left: 0; right: 0; bottom: 0;
+            overflow: hidden; cursor: grab; background: #f8fafc;
+            /* 移除 transition,让画布缩放瞬间完成 */
+            z-index: 1;
+            transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+        }}
+        #app-container.grabbing {{ cursor: grabbing; }}
+        /* 当侧边栏显示时,画布缩小并向右移动(宽度通过 JavaScript 动态设置) */
+        #app-container.sidebar-open {{
+            /* right 和 width 通过 JavaScript 动态设置 */
+        }}
+
+        #canvas {{
+            position: absolute;
+            transform-origin: 0 0;
+            transition: transform 0.1s linear;
+        }}
+        #canvas.animating {{ transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1); }}
+
+        /* 列标题 */
+        .column-header {{
+            position: absolute;
+            height: 36px; line-height: 36px;
+            font-size: 14px; font-weight: 600; color: #64748b;
+            background: #f1f5f9; border-radius: 18px;
+            text-align: center; padding: 0 20px;
+            z-index: 2; white-space: nowrap;
+            box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+        }}
+
+        /* 卡片样式(实线框颜色略深) */
+        .constant-card, .node-card {{
+            position: absolute; background: white;
+            border: 1px solid #64748b; border-radius: 10px;
+            padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+            cursor: pointer; transition: all 0.2s ease-out; z-index: 10;
+        }}
+        /* 当侧边栏打开时,提高节点卡片的 z-index */
+        #app-container.sidebar-open .constant-card,
+        #app-container.sidebar-open .node-card {{
+            z-index: 150;
+        }}
+        .constant-card {{ width: 280px; border-left: 5px solid #8b5cf6; }}
+        .node-card {{ width: 320px; }}
+
+        .constant-card:hover, .node-card:hover {{
+            transform: translateY(-2px);
+            box-shadow: 0 10px 20px -5px rgba(0,0,0,0.1);
+            border-color: #475569;
+        }}
+
+        /* 高亮样式(实线蓝框) */
+        .highlight {{
+            border: 2px solid #2563eb !important;
+            background: #eff6ff !important;
+            box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
+            z-index: 20;
+        }}
+        /* 虚线框节点高亮时保持虚线蓝框(用渐变虚线,去掉实线 border) */
+        .node-card.not-fully-derived.highlight {{
+            border: none !important;
+            background-color: #eff6ff !important;
+            box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
+        }}
+
+        /* 变暗样式 */
+        .dimmed {{ opacity: 0.1; filter: grayscale(100%); pointer-events: none; }}
+
+        /* 未完全推导的节点:虚线框(用渐变模拟较疏虚线,颜色略深) */
+        .node-card.not-fully-derived {{
+            border: none;
+            border-radius: 10px;
+            background-color: white;
+            background-image:
+                linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
+                linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #475569 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #475569 0 8px, transparent 8px 20px);
+            background-size: 28px 2px, 28px 2px, 2px 28px, 2px 28px;
+            background-position: left top, left bottom, left top, right top;
+            background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
+        }}
+        .node-card.not-fully-derived.highlight {{
+            background-image:
+                linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
+                linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px);
+        }}
+
+        .edge-path.dimmed {{
+            opacity: 0.05;
+            marker-end: none !important;
+        }}
+
+        .edge-label-text.dimmed {{ opacity: 0; }}
+        .edge-label-sub.dimmed {{ opacity: 0; }}
+        .connector-dot.dimmed {{ opacity: 0; }}
+
+        /* 内容排版 */
+        .node-header {{ font-weight: 700; font-size: 15px; margin-bottom: 12px; color: #0f172a; }}
+        .constant-name {{ font-weight: 700; font-size: 14px; color: #1e293b; margin-bottom: 6px; }}
+        .constant-value {{ font-size: 13px; color: #64748b; }}
+
+        .row {{ display: flex; margin-bottom: 6px; font-size: 12px; line-height: 1.5; align-items: baseline; }}
+        .key {{ color: #94a3b8; width: 50px; flex-shrink: 0; text-align: right; margin-right: 12px; white-space: nowrap; }}
+        .val {{ color: #334155; font-weight: 500; }}
+        .row-root-source .key {{ width: 80px; }}
+        .row-root-source .val {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }}
+        .row-post-topic {{ margin-top: 6px; }}
+        .row-post-topic .key {{ margin-right: 20px; }}
+
+        /* SVG */
+        .edge-layer {{ position: absolute; top: 0; left: 0; pointer-events: none; z-index: 1; }}
+        .edge-layer g {{ pointer-events: all; }}
+
+        .edge-path {{
+            fill: none; stroke: #cbd5e1; stroke-width: 1.5px;
+            stroke-linejoin: round; transition: stroke 0.3s, opacity 0.3s;
+            pointer-events: stroke; cursor: pointer;
+        }}
+        .edge-path.highlight {{ stroke: #2563eb; stroke-width: 2.5px; opacity: 1; }}
+
+        /* 主标签样式 */
+        .edge-label-text {{
+            font-size: 12px; fill: #475569; text-anchor: middle;
+            font-family: monospace; paint-order: stroke;
+            stroke: #f8fafc; stroke-width: 4px;
+            transition: opacity 0.3s;
+            pointer-events: all; cursor: pointer;
+        }}
+
+        /* 副标签样式(概率) */
+        .edge-label-sub {{
+            font-size: 10px; fill: #94a3b8; text-anchor: middle;
+            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+            paint-order: stroke; stroke: #f8fafc; stroke-width: 3px;
+            transition: opacity 0.3s;
+            pointer-events: all; cursor: pointer;
+        }}
+
+        .edge-label-text.highlight {{ fill: #2563eb; font-weight: 700; opacity: 1; }}
+        .edge-label-sub.highlight {{ fill: #2563eb; font-weight: 600; opacity: 1; }}
+
+        .connector-dot {{ fill: #cbd5e1; transition: fill 0.3s; pointer-events: all; cursor: pointer; }}
+        .connector-dot.highlight {{ fill: #2563eb; }}
+
+        /* 侧边栏 */
+        #sidebar {{
+            position: fixed; top: 60px; right: 0; width: 380px; min-width: 250px; max-width: 60vw;
+            height: calc(100vh - 60px);
+            background: white; border-left: 1px solid #e2e8f0; box-shadow: -4px 0 15px rgba(0,0,0,0.05);
+            transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+            z-index: 100; display: flex; flex-direction: column;
+        }}
+        #sidebar.active {{ transform: translateX(0); }}
+        
+        /* 侧边栏拉伸器 */
+        #sidebar-resizer {{
+            position: fixed; top: 60px; right: 0; width: 8px; height: calc(100vh - 60px);
+            background: #e0e0e0; cursor: col-resize; z-index: 101;
+            transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+            display: flex; align-items: center; justify-content: center;
+        }}
+        #sidebar-resizer.active {{ transform: translateX(0); }}
+        #sidebar-resizer:hover {{ background: #3b82f6; }}
+        #sidebar-resizer::before {{
+            content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
+            width: 2px; background: #999; transform: translateX(-50%);
+        }}
+        #sidebar-resizer:hover::before {{ background: #3b82f6; }}
+        body.resizing {{ user-select: none; }}
+        body.resizing #sidebar-resizer {{ background: #3b82f6; }}
+        .sidebar-header {{ padding: 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc; }}
+        .sidebar-content {{ padding: 20px; overflow-y: auto; flex: 1; }}
+        .detail-item {{ margin-bottom: 20px; }}
+        .detail-item label {{ display: block; font-size: 11px; font-weight: 600; color: #94a3b8; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }}
+        .detail-val {{ font-size: 14px; color: #1e293b; padding: 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; line-height: 1.6; white-space: pre-wrap; }}
+        .detail-empty {{ color: #999; font-style: italic; text-align: center; padding: 40px 20px; }}
+        .query-block-header {{ cursor: pointer; padding: 8px 0; user-select: none; }}
+        .query-block-header:hover {{ color: #3b82f6; }}
+        .query-block-body {{ margin-top: 8px; }}
+        .external-post-card {{ border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-top: 12px; background: #fafafa; }}
+        .root-detail-section {{ margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #eee; }}
+        .root-detail-section:last-child {{ border-bottom: none; }}
+        .root-detail-title {{ font-size: 18px; font-weight: bold; color: #333; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }}
+        .root-detail-title::before {{ content: ""; display: inline-block; width: 4px; height: 18px; background: #3b82f6; border-radius: 2px; }}
+        .post-title {{ font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #444; }}
+        .post-body {{ font-size: 14px; white-space: pre-wrap; color: #666; background: #f9f9f9; padding: 12px; border-radius: 6px; margin-bottom: 15px; }}
+        .post-stats {{ display: flex; gap: 20px; margin-bottom: 15px; font-size: 14px; color: #888; }}
+        .post-stats span {{ display: flex; align-items: center; gap: 4px; }}
+        .image-gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; margin-top: 10px; }}
+        .image-item {{ width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; cursor: pointer; transition: transform 0.2s; border: 1px solid #ddd; }}
+        .image-item:hover {{ transform: scale(1.05); border-color: #3b82f6; }}
+        .jump-link {{ display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f0f4f8; color: #3b82f6; text-decoration: none; border-radius: 8px; font-weight: bold; transition: background 0.2s; margin-bottom: 10px; }}
+        .jump-link:hover {{ background: #e1e9f0; color: #2563eb; }}
+        .jump-link::after {{ content: "→"; font-size: 18px; }}
+        
+        /* 推导进度区域 - 底部面板 */
+        #derivation-progress-section {{
+            position: fixed; bottom: 0; left: 0; right: 0;
+            height: 600px; max-height: 80vh; min-height: 200px;
+            background: white; border-top: 1px solid #e2e8f0; box-shadow: 0 -2px 15px rgba(0,0,0,0.05);
+            transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+            z-index: 99; display: flex; flex-direction: column;
+        }}
+        #derivation-resizer {{
+            position: absolute; top: 0; left: 0; right: 0; height: 6px;
+            cursor: row-resize; z-index: 100;
+            background: transparent;
+        }}
+        #derivation-resizer:hover, #derivation-resizer.active {{
+            background: #3b82f6;
+        }}
+        #derivation-progress-section.active {{ transform: translateY(0); }}
+        .derivation-progress-title {{
+            padding: 15px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc;
+            flex-shrink: 0;
+        }}
+        .derivation-progress-title span {{
+            font-weight: 700; color: #334155; font-size: 16px;
+        }}
+        .derivation-color-legend {{
+            display: flex; gap: 15px; align-items: center; margin-left: 20px; font-size: 12px;
+        }}
+        .derivation-color-legend-item {{
+            display: flex; align-items: center; gap: 6px;
+        }}
+        .derivation-color-legend-color {{
+            width: 16px; height: 16px; border-radius: 4px; border: 1px solid #d1d5db;
+        }}
+        .legend-black {{ background: #f3f4f6; border-color: #d1d5db; }}
+        .legend-yellow {{ background: #fef3c7; border-color: #fcd34d; }}
+        .legend-green {{ background: #d1fae5; border-color: #6ee7b7; }}
+        .derivation-progress-toggle {{
+            padding: 6px 12px; background: #3b82f6; color: white; border: none;
+            border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500;
+            transition: background 0.2s;
+        }}
+        .derivation-progress-toggle:hover {{ background: #2563eb; }}
+        #derivation-progress-content {{
+            padding: 20px; overflow-x: auto; overflow-y: auto; flex: 1;
+        }}
+        
+        /* 当推导进度面板打开时,调整画布底部边距 */
+        #app-container.derivation-open {{
+            bottom: 600px;
+            transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+        }}
+        
+        .derivation-empty {{
+            color: #999; font-style: italic; text-align: center; padding: 40px 20px;
+        }}
+        .derivation-timeline {{
+            display: flex; flex-direction: row; gap: 20px; align-items: flex-start;
+            min-width: max-content;
+        }}
+        .derivation-round-block {{
+            border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; background: #fafafa;
+            min-width: 350px; max-width: 450px; flex-shrink: 0;
+            display: flex; flex-direction: column;
+        }}
+        .derivation-round-title {{
+            font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #3b82f6;
+            flex-shrink: 0;
+        }}
+        .derivation-table {{
+            width: 100%; border-collapse: collapse; font-size: 11px;
+        }}
+        .derivation-table th {{
+            background: #f1f5f9; padding: 6px 4px; text-align: left; font-weight: 600; color: #475569; border: 1px solid #e2e8f0;
+            font-size: 10px;
+        }}
+        .derivation-table td {{
+            padding: 4px; border: 1px solid #e2e8f0; vertical-align: top;
+            font-size: 10px;
+        }}
+        .derivation-table .col-type {{ width: 50px; }}
+        .derivation-table .col-source {{ width: 100px; }}
+        .derivation-table .col-dim {{ width: auto; min-width: 80px; }}
+        .derivation-topic-item {{
+            display: inline-block; margin: 2px 4px 2px 0; padding: 2px 6px; border-radius: 4px; font-size: 11px;
+            cursor: pointer; transition: all 0.2s;
+        }}
+        /* 未点亮的词 - 黑色 */
+        .derivation-topic-underedived {{
+            background: #f3f4f6; color: #000000; border: 1px solid #d1d5db;
+        }}
+        .derivation-dimension-extra {{
+            margin-top: 12px; padding-top: 10px; border-top: 1px dashed #e2e8f0;
+            font-size: 11px; line-height: 1.5;
+        }}
+        .derivation-dim-line {{ margin-bottom: 6px; word-break: break-all; }}
+        .derivation-dim-label {{
+            display: inline-block; min-width: 72px; font-weight: 600; color: #64748b;
+        }}
+        .derivation-dim-val.dim-derived {{ color: #15803d; }}
+        .derivation-dim-val.dim-underived {{ color: #b45309; }}
+        .derivation-dim-line.dim-muted {{ color: #94a3b8; font-style: italic; }}
+        .btn-dimension-patterns {{
+            margin-top: 8px; padding: 5px 12px; font-size: 11px;
+            background: #6366f1; color: white; border: none; border-radius: 6px;
+            cursor: pointer; font-weight: 500;
+        }}
+        .btn-dimension-patterns:hover {{ background: #4f46e5; }}
+        #dimension-patterns-modal {{
+            display: none; position: fixed; inset: 0; z-index: 200;
+            background: rgba(15, 23, 42, 0.45); align-items: center; justify-content: center;
+            padding: 24px;
+        }}
+        #dimension-patterns-modal.active {{ display: flex; }}
+        .dimension-patterns-dialog {{
+            background: white; border-radius: 12px; max-width: 900px; width: 100%;
+            max-height: 80vh; display: flex; flex-direction: column;
+            box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
+        }}
+        .dimension-patterns-head {{
+            padding: 14px 18px; border-bottom: 1px solid #e2e8f0;
+            display: flex; justify-content: space-between; align-items: center;
+            flex-shrink: 0;
+        }}
+        .dimension-patterns-head span {{ font-weight: 700; color: #1e293b; font-size: 15px; }}
+        .dimension-patterns-close {{
+            padding: 6px 14px; background: #f1f5f9; border: none; border-radius: 6px;
+            cursor: pointer; font-size: 13px;
+        }}
+        .dimension-patterns-close:hover {{ background: #e2e8f0; }}
+        .dimension-patterns-body {{
+            padding: 16px 18px; overflow-y: auto; font-size: 12px; line-height: 1.6;
+        }}
+        .dimension-patterns-title {{ font-weight: 600; color: #475569; margin-bottom: 12px; }}
+        .pattern-line {{
+            padding: 8px 10px; margin-bottom: 6px; background: #f8fafc;
+            border-radius: 6px; border: 1px solid #e2e8f0; word-break: break-all;
+        }}
+        .pattern-plus {{ color: #94a3b8; font-weight: 600; margin: 0 4px; }}
+        .pattern-item-derived {{
+            color: #15803d; font-weight: 700; background: #dcfce7;
+            padding: 1px 4px; border-radius: 4px;
+        }}
+        /* 当前轮次点亮的点 - 黄色 */
+        .derivation-topic-new {{
+            background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; font-weight: 600;
+        }}
+        /* 未完全推导的选题点:虚线框 */
+        .derivation-topic-item.derivation-topic-not-fully-derived {{
+            border: 1px dashed #475569 !important;
+        }}
+        /* 之前已经点亮过的点 - 绿色 */
+        .derivation-topic-derived {{
+            background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7;
+        }}
+        /* 推导结果为空时,由解构内容回填的基准选题(不可点击定位) */
+        .derivation-topic-baseline {{
+            background: #e0e7ff; color: #312e81; border: 1px solid #a5b4fc; cursor: default;
+        }}
+        .derivation-topic-item.derivation-topic-baseline:hover {{
+            transform: none; box-shadow: none;
+        }}
+        .derivation-topic-item:hover {{
+            transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+        }}
+        .derivation-topic-search-icon {{ color: #2196F3; margin-left: 2px; }}
+        .derivation-topic-tool-icon {{ color: #ff9800; margin-left: 2px; }}
+
+        /* 待解构帖子数据 入口 */
+        .top-bar-left {{ display: flex; align-items: center; gap: 16px; }}
+        #btn-pending-decode-post {{
+            padding: 8px 16px; background: #8b5cf6; color: white; border: none;
+            border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
+            transition: background 0.2s;
+        }}
+        #btn-pending-decode-post:hover {{ background: #7c3aed; }}
+        .modal-overlay {{
+            position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+            background: rgba(0,0,0,0.4); z-index: 1000; display: none;
+            align-items: center; justify-content: center;
+        }}
+        .modal-overlay.active {{ display: flex; }}
+        .modal-box {{
+            background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15);
+            max-width: 480px; width: 90%; max-height: 85vh; overflow: hidden;
+            display: flex; flex-direction: column;
+        }}
+        .modal-box.post-detail-modal {{ max-width: 720px; }}
+        .modal-header {{
+            padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex;
+            justify-content: space-between; align-items: center; background: #f8fafc;
+        }}
+        .modal-header span {{ font-weight: 700; font-size: 16px; color: #334155; }}
+        .modal-close {{ background: none; border: none; font-size: 24px; cursor: pointer; color: #94a3b8; line-height: 1; }}
+        .modal-close:hover {{ color: #64748b; }}
+        .modal-body {{ padding: 20px; overflow-y: auto; flex: 1; }}
+
+        /* 图集大图查看(灯箱) */
+        #image-lightbox {{
+            position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+            background: rgba(0,0,0,0.9); z-index: 2000; display: none;
+            align-items: center; justify-content: center;
+        }}
+        #image-lightbox.active {{ display: flex; }}
+        #image-lightbox .lightbox-close {{
+            position: absolute; top: 16px; right: 20px;
+            background: none; border: none; color: #fff; font-size: 32px;
+            cursor: pointer; line-height: 1; opacity: 0.8;
+        }}
+        #image-lightbox .lightbox-close:hover {{ opacity: 1; }}
+        #image-lightbox .lightbox-prev,
+        #image-lightbox .lightbox-next {{
+            position: absolute; top: 50%; transform: translateY(-50%);
+            width: 48px; height: 48px; border: none; border-radius: 50%;
+            background: rgba(255,255,255,0.2); color: #fff; font-size: 24px;
+            cursor: pointer; display: flex; align-items: center; justify-content: center;
+            transition: background 0.2s;
+        }}
+        #image-lightbox .lightbox-prev:hover,
+        #image-lightbox .lightbox-next:hover {{ background: rgba(255,255,255,0.35); }}
+        #image-lightbox .lightbox-prev {{ left: 20px; }}
+        #image-lightbox .lightbox-next {{ right: 20px; }}
+        #image-lightbox .lightbox-img-wrap {{
+            max-width: 90vw; max-height: 85vh; display: flex; align-items: center; justify-content: center;
+        }}
+        #image-lightbox .lightbox-img-wrap img {{
+            max-width: 100%; max-height: 85vh; object-fit: contain;
+        }}
+        #image-lightbox .lightbox-counter {{
+            position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
+            color: rgba(255,255,255,0.9); font-size: 14px;
+        }}
+    </style>
+</head>
+<body>
+
+    <div id="top-bar">
+        <div class="top-bar-left">
+            <button type="button" id="btn-pending-decode-post">待解构帖子数据</button>
+            <h2 style="font-size:18px; color:#1e293b; font-weight:600;">多源数据流可视化 - 完整全景版</h2>
+        </div>
+        <div class="controls">
+            <select id="postSelector" onchange="switchPost(this.value)">
+                {"".join([f'<option value="{k}">{k}</option>' for k in data_map.keys()])}
+            </select>
+            <input type="text" id="search-input" placeholder="输入关键字 并回车定位..." />
+            <button onclick="resetView()">重置视图</button>
+            <button onclick="toggleDerivationProgress()">推导进度</button>
+        </div>
+    </div>
+
+    <div id="sidebar">
+        <div class="sidebar-header">
+            <div id="sidebar-title" style="font-weight:700; color:#334155;">节点详情</div>
+            <button onclick="closeSidebar()" style="background:none; border:none; font-size:24px; cursor:pointer; color:#94a3b8;">&times;</button>
+        </div>
+        <div class="sidebar-content" id="sidebar-content"></div>
+    </div>
+    <div id="sidebar-resizer"></div>
+
+    <div id="app-container">
+        <div id="canvas"></div>
+    </div>
+    
+    <div id="derivation-progress-section">
+        <div id="derivation-resizer"></div>
+        <div class="derivation-progress-title">
+            <div style="display: flex; align-items: center;">
+                <span>推导进度</span>
+                <div class="derivation-color-legend">
+                    <div class="derivation-color-legend-item">
+                        <div class="derivation-color-legend-color legend-black"></div>
+                        <span>未点亮</span>
+                    </div>
+                    <div class="derivation-color-legend-item">
+                        <div class="derivation-color-legend-color legend-yellow"></div>
+                        <span>当前轮次点亮</span>
+                    </div>
+                    <div class="derivation-color-legend-item">
+                        <div class="derivation-color-legend-color legend-green"></div>
+                        <span>之前已点亮</span>
+                    </div>
+                </div>
+            </div>
+            <button class="derivation-progress-toggle" onclick="toggleDerivationProgress()">收起</button>
+        </div>
+        <div id="derivation-progress-content"></div>
+    </div>
+
+    <!-- 待解构帖子详情弹窗 -->
+    <div id="post-detail-modal" class="modal-overlay">
+        <div class="modal-box post-detail-modal">
+            <div class="modal-header">
+                <span>待解构帖子详情</span>
+                <button type="button" class="modal-close" onclick="closePostDetailModal()">&times;</button>
+            </div>
+            <div class="modal-body" id="post-detail-modal-content"></div>
+        </div>
+    </div>
+
+    <!-- 图集大图灯箱 -->
+    <div id="image-lightbox">
+        <button type="button" class="lightbox-close" onclick="closeImageLightbox()">&times;</button>
+        <button type="button" class="lightbox-prev" onclick="lightboxPrev()">&#10094;</button>
+        <div class="lightbox-img-wrap">
+            <img id="lightbox-img" src="" alt="" />
+        </div>
+        <button type="button" class="lightbox-next" onclick="lightboxNext()">&#10095;</button>
+        <div class="lightbox-counter" id="lightbox-counter"></div>
+    </div>
+
+    <div id="dimension-patterns-modal" onclick="if(event.target===this) closeDimensionPatternsModal()">
+        <div class="dimension-patterns-dialog" onclick="event.stopPropagation()">
+            <div class="dimension-patterns-head">
+                <span id="dimension-patterns-modal-title">维度 patterns</span>
+                <button type="button" class="dimension-patterns-close" onclick="closeDimensionPatternsModal()">关闭</button>
+            </div>
+            <div class="dimension-patterns-body" id="dimension-patterns-modal-body"></div>
+        </div>
+    </div>
+
+    <script>
+        const allData = {json_data_js};
+        const derivationData = {derivation_data_js};
+        const dimensionAnalyzeData = {dimension_analyze_data_js};
+        const postDetailMap = {post_detail_map_js};
+        const accountName = {account_name_js};
+
+        const CONFIG = {{
+            cardWidth: 320,
+            constWidth: 280,
+            colSpacing: 900,
+            rowSpacing: 30,
+            paddingX: 80,
+            paddingY: 100,
+            busOffset: 450,
+            forkOffset: 40
+        }};
+
+        const canvas = document.getElementById('canvas');
+        let flatData = {{ nodesByLevel: {{}}, map: {{}} }};
+        let edgeGroups = {{}};
+        let currentPostKey = "{first_key}";
+
+        // 1. 数据解析 - 适配 node_list 和 edge_list 格式
+        function parseData(postKey) {{
+            flatData = {{ nodesByLevel: {{}}, map: {{}} }};
+            edgeGroups = {{}};
+            
+            const data = allData[postKey];
+            const nodesData = data.node_list || [];
+            const edgesData = data.edge_list || [];
+            const allUsedTreeNodes = data.all_used_tree_nodes || [];
+
+            // 创建节点映射
+            const nodeMap = {{}};
+            nodesData.forEach(node => {{
+                nodeMap[node.name] = node;
+            }});
+
+            // 处理人设/全局常量节点(放在 level -1,第一轮推导左侧)
+            // 注意:所有节点都需要展示,不受 is_constant 和 is_local_constant 字段影响
+            const constantLevel = -1;
+            if (!flatData.nodesByLevel[constantLevel]) flatData.nodesByLevel[constantLevel] = [];
+            
+            // 遍历所有节点,全部添加到列表中(不进行任何过滤,全部展示)
+            allUsedTreeNodes.forEach((constantNode, index) => {{
+                // 使用索引确保即使名称重复也能区分
+                const uniqueId = constantNode.name + '_const_' + index;
+                const item = {{
+                    id: uniqueId,
+                    name: constantNode.name,
+                    data: {{
+                        ...constantNode,
+                        type: constantNode.type || '',
+                        is_constant: constantNode.is_constant || false,
+                        is_local_constant: constantNode.is_local_constant || false
+                    }},
+                    type: 'node',
+                    level: constantLevel,
+                    sources: [],
+                    edgeName: '',
+                    edgeScore: 0
+                }};
+                
+                // 添加到数组和映射中(数组用于渲染,确保所有节点都显示)
+                flatData.nodesByLevel[constantLevel].push(item);
+                flatData.map[item.id] = item;
+                // 名称映射用于查找(如果有重复名称,最后一个会覆盖,但不影响数组中的显示)
+                flatData.map[item.name] = item;
+            }});
+
+            // 按 level 分组节点(同名节点可能出现在多轮,如「居家生活场景」level 1 与 level 2 各有一个)
+            nodesData.forEach(node => {{
+                const level = node.level || 0;
+                if (!flatData.nodesByLevel[level]) flatData.nodesByLevel[level] = [];
+                const uniqueId = node.name + '__L' + level;
+                const item = {{
+                    id: node.name,
+                    uid: uniqueId,
+                    name: node.name,
+                    data: node,
+                    type: 'node',
+                    level: level,
+                    sources: [],
+                    edgeName: '',
+                    edgeScore: 0
+                }};
+                flatData.nodesByLevel[level].push(item);
+                flatData.map[uniqueId] = item;
+                flatData.map[item.name] = item;
+            }});
+
+            // 处理边,建立连接关系
+            // 边对象有 level 字段,表示轮次;边只能连接同轮次的 output 输出节点
+            // output_nodes 为对象列表,每项有 name 字段表示输出节点名称
+            edgesData.forEach(edge => {{
+                const outputNodes = edge.output_nodes || [];
+                const inputPostNodes = edge.input_post_nodes || [];
+                const usedTreeNodes = edge.used_tree_nodes || edge.input_tree_nodes || [];
+
+                const edgeLevel = edge.level;
+
+                // 处理 input_post_nodes 作为输入节点(这些节点在推导过程中,不在 level -1)
+                const inputPostNames = inputPostNodes.map(n => n.name || n).filter(name => name);
+
+                // 处理 used_tree_nodes / input_tree_nodes,匹配到 all_used_tree_nodes 中的节点(这些节点在 level -1)
+                const usedTreeNames = [];
+                usedTreeNodes.forEach(usedNode => {{
+                    const usedName = usedNode.name || usedNode;
+                    // 在 all_used_tree_nodes 中查找匹配的节点(通过 name 匹配)
+                    const matchedNode = allUsedTreeNodes.find(n => n.name === usedName);
+                    if (matchedNode) {{
+                        usedTreeNames.push(usedName);
+                    }}
+                }});
+
+                // 合并所有输入节点名称(但需要区分来源)
+                // 保存输入节点的来源信息,用于后续查找正确的节点
+                const allInputNames = [];
+                const inputSourceMap = {{}}; // 记录每个输入节点的来源:'post' 或 'tree'
+                
+                inputPostNames.forEach(name => {{
+                    allInputNames.push(name);
+                    inputSourceMap[name] = 'post'; // 来自推导节点
+                }});
+                
+                usedTreeNames.forEach(name => {{
+                    allInputNames.push(name);
+                    inputSourceMap[name] = 'tree'; // 来自人设/全局常量节点(level -1)
+                }});
+
+                // 先收集所有有效的输出节点:仅同轮次(边只能连接 edge.level 对应的 output 节点)
+                // 同名节点可能出现在多轮,需按 name + level 查找
+                const validOutputItems = [];
+                outputNodes.forEach(outputNode => {{
+                    const outputName = (typeof outputNode === 'object' && outputNode !== null && outputNode.name != null) ? outputNode.name : outputNode;
+                    let outputItem = null;
+                    if (edgeLevel != null && flatData.nodesByLevel[edgeLevel]) {{
+                        outputItem = flatData.nodesByLevel[edgeLevel].find(n => n.name === outputName) || null;
+                    }}
+                    if (!outputItem) outputItem = flatData.map[outputName] || null;
+                    if (outputItem && (edgeLevel == null || outputItem.level === edgeLevel)) {{
+                        validOutputItems.push(outputItem);
+                    }}
+                }});
+
+                // 如果没有有效的输出节点,跳过
+                if (validOutputItems.length === 0) return;
+
+                // 为整个边创建一个边组(所有输出节点共享同一个边组)
+                const edgeName = edge.name || '';
+                const edgeScore = edge.score || 0;
+                // 收集输出节点名称用于生成唯一的 edgeKey
+                const outputNames = validOutputItems.map(item => item.name).sort();
+                // edgeKey 需要包含输入节点、输出节点和边名称,确保每条边都有唯一的 key
+                const inputKey = allInputNames.length > 0 
+                    ? allInputNames.slice().sort().join('|')
+                    : 'empty';
+                const outputKey = outputNames.join('|');
+                const edgeKey = inputKey + '||' + outputKey + '||' + edgeName;
+                
+                if (!edgeGroups[edgeKey]) {{
+                    edgeGroups[edgeKey] = {{
+                        key: edgeKey,
+                        targets: [],
+                        sources: allInputNames,
+                        sourceMap: inputSourceMap, // 保存输入节点的来源映射
+                        edgeName: edgeName,
+                        edgeScore: edgeScore,
+                        edgeData: edge  // 保存完整的边数据
+                    }};
+                }}
+
+                // 将所有输出节点添加到同一个边组,并设置相同的边信息
+                validOutputItems.forEach(outputItem => {{
+                    // 更新输出节点的边信息
+                    outputItem.sources = allInputNames;
+                    outputItem.edgeName = edgeName;
+                    outputItem.edgeScore = edgeScore;
+                    outputItem.edgeGroupKey = edgeKey;
+                    
+                    // 添加到边组
+                    edgeGroups[edgeKey].targets.push(outputItem);
+                }});
+            }});
+        }}
+
+        // 2. 布局计算(按 level 排序,但 x 用列索引排列,空缺的 level 不占位)
+        function calculateLayout() {{
+            const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
+            levels.forEach((level, colIndex) => {{
+                const nodes = flatData.nodesByLevel[level];
+                // level -1 放第一列;其余列按 colIndex 紧密排列,不因 level 空缺留白
+                const x = CONFIG.paddingX + colIndex * CONFIG.colSpacing;
+                let y = CONFIG.paddingY;
+
+                createHeader(level, x);
+
+                nodes.forEach(node => {{
+                    const h = estimateHeight(node);
+                    node.x = x;
+                    node.y = y;
+                    node.width = node.type === 'constant' ? CONFIG.constWidth : CONFIG.cardWidth;
+                    node.height = h;
+                    node.inputPoint = {{ x: node.x, y: node.y + h/2 }};
+                    node.outputPoint = {{ x: node.x + node.width, y: node.y + h/2 }};
+                    y += h + CONFIG.rowSpacing;
+                }});
+            }});
+        }}
+
+        function estimateHeight(node) {{
+            if (node.type === 'constant') return 80;
+            // level -1 的常量节点,只显示 name 和 type,固定高度
+            if (node.level === -1) {{
+                return 60 + 22; // name + type
+            }}
+            // 为了保证不同节点类型高度一致,这里统一按 point/dimension/root_source 的存在情况估算行数
+            let lines = 1; // node-header
+            if (node.data && node.data.point) lines++;
+            if (node.data && node.data.dimension) lines++;
+            if (node.data && node.data.root_source) lines++;
+            return 60 + lines * 22;
+        }}
+
+        function createHeader(level, x) {{
+            const existing = document.querySelector(`.column-header[data-level="${{level}}"]`);
+            if (existing) existing.remove();
+            
+            const el = document.createElement('div');
+            el.className = 'column-header';
+            el.dataset.level = level;
+            el.style.left = x + 'px';
+            el.style.top = '40px';
+            el.style.width = CONFIG.cardWidth + 'px';
+            
+            if (level === -1) {{
+                el.textContent = '人设/全局常量';
+            }} else {{
+                const nums = ['一','二','三','四','五','六','七','八','九','十'];
+                el.textContent = `第${{nums[level-1] || level}}轮推导`;
+            }}
+            canvas.appendChild(el);
+        }}
+
+        function renderNodes() {{
+            // 清空现有节点
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => el.remove());
+            const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
+            levels.forEach(level => {{
+                const nodes = flatData.nodesByLevel[level] || [];
+                nodes.forEach(node => {{
+                const el = document.createElement('div');
+                el.dataset.id = node.uid != null ? node.uid : node.id;
+                el.style.left = node.x + 'px';
+                el.style.top = node.y + 'px';
+                el.style.width = node.width + 'px';
+
+                if (node.type === 'constant') {{
+                    el.className = 'constant-card';
+                    el.style.height = (node.height || estimateHeight(node)) + 'px';
+                    el.innerHTML = `
+                        <div class="constant-name">${{node.name}}</div>
+                        <div class="constant-value">${{node.data.value || ''}}</div>
+                    `;
+                }} else if (node.level === -1) {{
+                    // level -1 的常量节点(人设/全局常量),只显示 name 和 type
+                    el.className = 'node-card';
+                    el.style.height = (node.height || estimateHeight(node)) + 'px';
+                    let html = `<div class="node-header">${{node.name}}</div>`;
+                    if (node.data.type) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.type}}</span></div>`;
+                    el.innerHTML = html;
+                }} else {{
+                    // node_list 节点:is_fully_derived=false 时用虚线框,名称显示 derivation_output_point,只显示帖子选题点
+                    el.className = 'node-card' + (node.data.is_fully_derived === false ? ' not-fully-derived' : '');
+                    el.style.height = (node.height || estimateHeight(node)) + 'px';
+                    const displayName = (node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
+                        ? node.data.derivation_output_point : node.name;
+                    let html = `<div class="node-header">${{displayName}}</div>`;
+                    if (node.data.is_fully_derived === false) {{
+                        // 未完全推导:只显示「帖子选题点」,值为原 node_list.name
+                        if (node.data.name != null && node.data.name !== '') html += `<div class="row row-post-topic"><span class="key">帖子选题点</span><span class="val">${{node.data.name}}</span></div>`;
+                    }} else {{
+                        // 常规节点:显示 类型(原关键点)、维度、所属选题点
+                        if (node.data.point) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.point}}</span></div>`;
+                        if (node.data.dimension) html += `<div class="row"><span class="key">维度</span><span class="val">${{node.data.dimension}}</span></div>`;
+                        if (node.data.root_source) html += `<div class="row row-root-source"><span class="key">所属选题点</span><span class="val">${{node.data.root_source}}</span></div>`;
+                    }}
+                    el.innerHTML = html;
+                }}
+                el.onclick = (e) => {{
+                    e.stopPropagation();
+                    // 保存当前选中的节点
+                    currentSelectedNode = node;
+                    currentSelectedEdgeGroup = null; // 清除边组选中状态
+                    // 不自动缩放,保持当前视图大小和位置
+                    // 立即高亮和显示侧边栏(无延迟)
+                    highlightDirectSources(node);
+                    const sidebarTitle = (node.data && node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
+                        ? `节点: ${{node.data.derivation_output_point}}` : `节点: ${{node.name}}`;
+                    showSidebar(node.data, sidebarTitle, node, 'node');
+                }};
+                canvas.appendChild(el);
+                node.el = el;
+                }});
+            }});
+        }}
+
+        // 3. 渲染连线 - 按组渲染
+        function renderEdges() {{
+            // 移除旧的 SVG
+            const oldSvg = document.querySelector('.edge-layer');
+            if (oldSvg) oldSvg.remove();
+
+            const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+            svg.classList.add('edge-layer');
+            svg.setAttribute('width', '10000');
+            svg.setAttribute('height', '8000');
+
+            // 定义箭头
+            const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
+            const createMarker = (id, color) => {{
+                const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
+                m.setAttribute('id', id);
+                m.setAttribute('markerWidth', '10'); m.setAttribute('markerHeight', '7');
+                m.setAttribute('refX', '9'); m.setAttribute('refY', '3.5');
+                m.setAttribute('orient', 'auto');
+                const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                p.setAttribute('d', 'M0,0 L0,7 L9,3.5 z');
+                p.setAttribute('fill', color);
+                m.appendChild(p);
+                return m;
+            }};
+            defs.appendChild(createMarker('arrow-head', '#cbd5e1'));
+            defs.appendChild(createMarker('arrow-head-highlight', '#2563eb'));
+            svg.appendChild(defs);
+
+            Object.values(edgeGroups).forEach(group => {{
+                const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+                g.dataset.edgeGroup = group.key;
+
+                const targets = group.targets;
+                const sourceNames = group.sources;
+                
+                // 如果没有目标节点,跳过
+                if (!targets.length) return;
+
+                const targetX = targets[0].inputPoint.x;
+                const busX = targetX - CONFIG.busOffset;
+                const forkX = targetX - CONFIG.forkOffset;
+
+                // 获取源节点(同名多轮时取低于目标层级的源)
+                const sourceNodes = [];
+                const sourceMap = group.sourceMap || {{}};
+                const minTargetLevel = targets.length ? Math.min(...targets.map(t => t.level)) : 0;
+                
+                sourceNames.forEach(name => {{
+                    const sourceType = sourceMap[name];
+                    let node = null;
+                    if (sourceType === 'tree') {{
+                        const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                        node = levelMinusOneNodes.find(n => n.name === name);
+                    }} else {{
+                        for (let l = minTargetLevel - 1; l >= 0; l--) {{
+                            const found = (flatData.nodesByLevel[l] || []).find(n => n.name === name);
+                            if (found) {{ node = found; break; }}
+                        }}
+                        if (!node) {{
+                            const candidate = flatData.map[name];
+                            if (candidate && candidate.level !== -1) node = candidate;
+                        }}
+                    }}
+                    if (node) sourceNodes.push(node);
+                }});
+
+                targets.sort((a,b) => a.y - b.y);
+                const tMinY = targets[0].inputPoint.y;
+                const tMaxY = targets[targets.length - 1].inputPoint.y;
+
+                // 核心连线的 Y 坐标(使用第一个目标节点的 Y 坐标,与参考文件保持一致)
+                const mainY = tMinY;
+
+                // 创建点击事件处理函数
+                const handleGroupClick = (e) => {{
+                    e.stopPropagation();
+                    handleEdgeClick(group);
+                }};
+
+                // 如果有源节点,渲染左侧部分
+                if (sourceNodes.length > 0) {{
+                    let sMinY = Infinity, sMaxY = -Infinity;
+                    sourceNodes.forEach(s => {{
+                        sMinY = Math.min(sMinY, s.outputPoint.y);
+                        sMaxY = Math.max(sMaxY, s.outputPoint.y);
+                    }});
+
+                    // A. 左侧源头馈线
+                    sourceNodes.forEach(s => {{
+                        const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        p.setAttribute('d', `M ${{s.outputPoint.x}} ${{s.outputPoint.y}} L ${{busX}} ${{s.outputPoint.y}}`);
+                        p.classList.add('edge-path', 'feeder');
+                        p.style.cursor = 'pointer';
+                        p.addEventListener('click', handleGroupClick);
+                        g.appendChild(p);
+                        const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+                        dot.setAttribute('cx', s.outputPoint.x); dot.setAttribute('cy', s.outputPoint.y);
+                        dot.setAttribute('r', 3); dot.classList.add('connector-dot');
+                        dot.style.cursor = 'pointer';
+                        dot.addEventListener('click', handleGroupClick);
+                        g.appendChild(dot);
+                    }});
+
+                    // B. 左侧主干
+                    if (sMinY !== sMaxY) {{
+                        const trunk = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        trunk.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{sMaxY}}`);
+                        trunk.classList.add('edge-path', 'trunk');
+                        trunk.style.cursor = 'pointer';
+                        trunk.addEventListener('click', handleGroupClick);
+                        g.appendChild(trunk);
+                    }}
+
+                    // C. 长连接线 (连接源头区域到主线高度)
+                    if (mainY < sMinY) {{
+                        const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        link.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{mainY}}`);
+                        link.classList.add('edge-path', 'trunk');
+                        link.style.cursor = 'pointer';
+                        link.addEventListener('click', handleGroupClick);
+                        g.appendChild(link);
+                    }} else if (mainY > sMaxY) {{
+                        const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        link.setAttribute('d', `M ${{busX}} ${{sMaxY}} L ${{busX}} ${{mainY}}`);
+                        link.classList.add('edge-path', 'trunk');
+                        link.style.cursor = 'pointer';
+                        link.addEventListener('click', handleGroupClick);
+                        g.appendChild(link);
+                    }}
+                }}
+
+                // 核心连线(无论是否有源节点都要渲染)
+                const mainLine = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                mainLine.setAttribute('d', `M ${{busX}} ${{mainY}} L ${{forkX}} ${{mainY}}`);
+                mainLine.classList.add('edge-path', 'main-flow');
+                mainLine.style.cursor = 'pointer';
+                mainLine.addEventListener('click', handleGroupClick);
+                g.appendChild(mainLine);
+
+                // D. 右侧分叉主干
+                if (targets.length > 1) {{
+                    const fork = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                    fork.setAttribute('d', `M ${{forkX}} ${{mainY}} L ${{forkX}} ${{tMaxY}}`);
+                    fork.classList.add('edge-path', 'trunk');
+                    fork.style.cursor = 'pointer';
+                    fork.addEventListener('click', handleGroupClick);
+                    g.appendChild(fork);
+                }}
+
+                // E. 目标接入线
+                targets.forEach(t => {{
+                    const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                    p.setAttribute('d', `M ${{forkX}} ${{t.inputPoint.y}} L ${{t.inputPoint.x}} ${{t.inputPoint.y}}`);
+                    p.classList.add('edge-path', 'entry');
+                    p.setAttribute('marker-end', 'url(#arrow-head)');
+                    p.style.cursor = 'pointer';
+                    p.addEventListener('click', handleGroupClick);
+                    g.appendChild(p);
+                    
+                    if (targets.length > 1) {{
+                        const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+                        dot.setAttribute('cx', forkX); dot.setAttribute('cy', t.inputPoint.y);
+                        dot.setAttribute('r', 2); dot.classList.add('connector-dot');
+                        dot.style.cursor = 'pointer';
+                        dot.addEventListener('click', handleGroupClick);
+                        g.appendChild(dot);
+                    }}
+                }});
+
+                // 连接点标记(只在有源节点时显示)
+                if (sourceNodes.length > 0) {{
+                    const busDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+                    busDot.setAttribute('cx', busX); busDot.setAttribute('cy', mainY);
+                    busDot.setAttribute('r', 3); busDot.classList.add('connector-dot');
+                    busDot.style.cursor = 'pointer';
+                    busDot.addEventListener('click', handleGroupClick);
+                    g.appendChild(busDot);
+                }}
+
+                // F. 文字标签
+                if (group.edgeName) {{
+                    const textX = (busX + forkX) / 2;
+                    const textY = mainY - 5;
+                    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+                    text.setAttribute('x', textX); text.setAttribute('y', textY);
+                    text.classList.add('edge-label-text');
+                    text.textContent = group.edgeName;
+                    text.style.cursor = 'pointer';
+                    text.addEventListener('click', handleGroupClick);
+                    g.appendChild(text);
+
+                    // 仅当边数据中有 score 字段时才在连线下方显示条件概率
+                    const hasScore = group.edgeData && group.edgeData.score !== undefined && group.edgeData.score !== null;
+                    if (hasScore) {{
+                        const subText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+                        subText.setAttribute('x', textX);
+                        subText.setAttribute('y', mainY + 14);
+                        subText.classList.add('edge-label-sub');
+                        let labelPrefix = "条件概率";
+                        if (group.edgeName && group.edgeName.startsWith("外部搜索")) {{
+                            labelPrefix = "搜索出现概率";
+                        }}
+                        subText.textContent = `${{labelPrefix}}:${{group.edgeScore}}`;
+                        subText.style.cursor = 'pointer';
+                        subText.addEventListener('click', handleGroupClick);
+                        g.appendChild(subText);
+                    }}
+                }}
+
+                svg.appendChild(g);
+            }});
+
+            canvas.insertBefore(svg, canvas.firstChild);
+        }}
+
+        // 处理边的点击事件
+        function handleEdgeClick(group) {{
+            // 保存当前选中的边组
+            currentSelectedEdgeGroup = group;
+            currentSelectedNode = null; // 清除节点选中状态
+            
+            // 获取边的详细信息
+            const edgeData = group.edgeData || {{}};
+            const edgeDetail = edgeData.detail || {{}};
+            const edgeName = group.edgeName || '';
+            const edgeScore = group.edgeScore || 0;
+            
+            // 获取目标节点名称(用于外部边和工具边的展示)
+            const targetNames = group.targets.map(t => t.name) || [];
+            const targetNodeName = targetNames.length > 0 ? targetNames[0] : '';
+            
+            // 构建边的完整数据对象
+            const fullEdgeData = {{
+                name: targetNodeName,  // 用于外部边和工具边的展示
+                edgeName: edgeName,
+                edgeScore: edgeScore,
+                sources: group.sources || [],
+                targets: targetNames,
+                type: edgeName.includes('外部搜索') || edgeName.includes('外部寻找') ? '外部边' : 
+                      edgeName.includes('工具') ? '工具边' : '普通边',
+                ...edgeData
+            }};
+            
+            // 高亮相关的节点和边
+            highlightEdgeGroup(group);
+            
+            // 显示边的详情(先打开侧边栏)
+            const sourceNames = group.sources.join('、');
+            const targetNamesStr = targetNames.join('、');
+            const title = `连线: ${{sourceNames}} → ${{targetNamesStr}}`;
+            showSidebar(edgeDetail, title, fullEdgeData, 'edge');
+        }}
+
+        // 计算边组相关节点的边界框并缩放显示
+        function fitEdgeGroupToView(group, useAnimation = true) {{
+            if (!group) return;
+
+            // 收集所有相关节点(源节点和目标节点)
+            const relatedNodes = new Set();
+            
+            // 添加所有源节点(需要根据来源区分查找)
+            const sourceMap = group.sourceMap || {{}};
+            group.sources.forEach(sName => {{
+                const sourceType = sourceMap[sName];
+                let sNode = null;
+                
+                if (sourceType === 'tree') {{
+                    // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
+                    const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                    sNode = levelMinusOneNodes.find(n => n.name === sName);
+                }} else {{
+                    // input_post_nodes:从推导节点中查找(排除 level -1)
+                    const candidate = flatData.map[sName];
+                    if (candidate && candidate.level !== -1) {{
+                        sNode = candidate;
+                    }} else {{
+                        // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
+                        for (let level in flatData.nodesByLevel) {{
+                            const levelNum = parseInt(level);
+                            if (levelNum !== -1) {{
+                                const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
+                                if (found) {{
+                                    sNode = found;
+                                    break;
+                                }}
+                            }}
+                        }}
+                    }}
+                }}
+                
+                if (sNode && sNode.x !== undefined) {{
+                    relatedNodes.add(sNode);
+                }}
+            }});
+            
+            // 添加所有目标节点
+            group.targets.forEach(t => {{
+                if (t && t.x !== undefined) {{
+                    relatedNodes.add(t);
+                }}
+            }});
+
+            // 如果没有任何节点,直接返回
+            if (relatedNodes.size === 0) return;
+
+            // 计算边界框(包括节点和连线可能延伸的区域)
+            let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+            relatedNodes.forEach(node => {{
+                if (node.x !== undefined && node.y !== undefined) {{
+                    // 节点本身的边界
+                    minX = Math.min(minX, node.x);
+                    minY = Math.min(minY, node.y);
+                    maxX = Math.max(maxX, node.x + (node.width || 0));
+                    maxY = Math.max(maxY, node.y + (node.height || 0));
+                    
+                    // 考虑连线可能延伸到左侧(busOffset)
+                    if (node.inputPoint) {{
+                        const leftExtend = node.inputPoint.x - CONFIG.busOffset;
+                        minX = Math.min(minX, leftExtend);
+                    }}
+                    if (node.outputPoint) {{
+                        const rightExtend = node.outputPoint.x + CONFIG.busOffset;
+                        maxX = Math.max(maxX, rightExtend);
+                    }}
+                }}
+            }});
+
+            // 如果边界框无效,直接返回
+            if (minX === Infinity) return;
+
+            // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
+            const padding = 40;
+            const contentWidth = maxX - minX + padding * 2;
+            const contentHeight = maxY - minY + padding * 2;
+            const contentCenterX = (minX + maxX) / 2;
+            const contentCenterY = (minY + maxY) / 2;
+
+            // 获取视口大小(考虑侧边栏是否打开)
+            const sidebar = document.getElementById('sidebar');
+            const isSidebarOpen = sidebar && sidebar.classList.contains('active');
+            // 当侧边栏打开时,画布宽度会缩小(减去侧边栏实际宽度)
+            const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
+            const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
+            const viewH = container.offsetHeight;
+
+            // 计算缩放比例,确保内容能完全显示
+            const scaleX = (viewW - padding * 2) / contentWidth;
+            const scaleY = (viewH - padding * 2) / contentHeight;
+            // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
+            scale = Math.min(scaleX, scaleY, 2.0);
+
+            // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
+            const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
+            translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
+            translateY = (viewH / 2) - (contentCenterY * scale);
+
+            // 根据参数决定是否使用动画
+            if (useAnimation) {{
+                canvas.classList.add('animating');
+                updateTransform();
+                setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+            }} else {{
+                // 移除动画类,确保瞬间完成
+                canvas.classList.remove('animating');
+                updateTransform();
+            }}
+        }}
+
+        // 高亮边组
+        function highlightEdgeGroup(group) {{
+            // Reset
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+            
+            // 高亮源节点(根据来源区分查找)
+            const sourceMap = group.sourceMap || {{}};
+            group.sources.forEach(sourceName => {{
+                const sourceType = sourceMap[sourceName];
+                let sourceNode = null;
+                
+                if (sourceType === 'tree') {{
+                    // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
+                    const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                    sourceNode = levelMinusOneNodes.find(n => n.name === sourceName);
+                }} else {{
+                    // input_post_nodes:从推导节点中查找(排除 level -1)
+                    const candidate = flatData.map[sourceName];
+                    if (candidate && candidate.level !== -1) {{
+                        sourceNode = candidate;
+                    }} else {{
+                        // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
+                        for (let level in flatData.nodesByLevel) {{
+                            const levelNum = parseInt(level);
+                            if (levelNum !== -1) {{
+                                const found = flatData.nodesByLevel[levelNum].find(n => n.name === sourceName);
+                                if (found) {{
+                                    sourceNode = found;
+                                    break;
+                                }}
+                            }}
+                        }}
+                    }}
+                }}
+                
+                if (sourceNode && sourceNode.el) {{
+                    sourceNode.el.classList.remove('dimmed');
+                    sourceNode.el.classList.add('highlight');
+                }}
+            }});
+            
+            // 高亮目标节点
+            group.targets.forEach(target => {{
+                if (target.el) {{
+                    target.el.classList.remove('dimmed');
+                    target.el.classList.add('highlight');
+                }}
+            }});
+            
+            // 高亮边组
+            const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
+            if (edgeGroupEl) {{
+                Array.from(edgeGroupEl.children).forEach(child => {{
+                    child.classList.remove('dimmed');
+                    child.classList.add('highlight');
+                    if (child.classList.contains('entry')) {{
+                        child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
+                    }}
+                }});
+            }}
+        }}
+
+        // 4. 交互:高亮组
+        function highlightDirectSources(targetNode) {{
+            // Reset
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+
+            let nodesToHighlight = [targetNode];
+
+            if (targetNode.edgeGroupKey) {{
+                const group = edgeGroups[targetNode.edgeGroupKey];
+                if (group) {{
+                    nodesToHighlight = group.targets;
+                    const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
+                    if (edgeGroupEl) {{
+                        Array.from(edgeGroupEl.children).forEach(child => {{
+                            child.classList.remove('dimmed');
+                            child.classList.add('highlight');
+                            if(child.classList.contains('entry')) {{
+                                child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
+                            }}
+                        }});
+                    }}
+                    // 高亮源节点(根据来源区分查找;同名多轮时取作为“源”的那一轮)
+                    const sourceMap = group.sourceMap || {{}};
+                    const minTargetLevel = group.targets.length ? Math.min(...group.targets.map(t => t.level)) : 0;
+                    group.sources.forEach(sName => {{
+                        const sourceType = sourceMap[sName];
+                        let sNode = null;
+                        
+                        if (sourceType === 'tree') {{
+                            const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                            sNode = levelMinusOneNodes.find(n => n.name === sName);
+                        }} else {{
+                            // input_post_nodes:取层级低于目标且同名的节点(从最接近目标的一轮开始找)
+                            for (let l = minTargetLevel - 1; l >= 0; l--) {{
+                                const found = (flatData.nodesByLevel[l] || []).find(n => n.name === sName);
+                                if (found) {{ sNode = found; break; }}
+                            }}
+                            if (!sNode) {{
+                                const candidate = flatData.map[sName];
+                                if (candidate && candidate.level !== -1) sNode = candidate;
+                            }}
+                        }}
+                        
+                        if (sNode && sNode.el) {{
+                            sNode.el.classList.remove('dimmed');
+                            sNode.el.classList.add('highlight');
+                        }}
+                    }});
+                }}
+            }}
+
+            nodesToHighlight.forEach(n => {{
+                if (n.el) {{
+                    n.el.classList.remove('dimmed');
+                    n.el.classList.add('highlight');
+                }}
+            }});
+        }}
+
+        // --- 视图控制 ---
+        let scale = 0.8, translateX = 50, translateY = 50;
+        let isDragging = false, startClientX, startClientY, startTranslateX, startTranslateY;
+        const DRAG_SENSITIVITY = 1.35; // 拖拽灵敏度,>1 更跟手
+        let currentSelectedNode = null; // 跟踪当前选中的节点
+        let currentSelectedEdgeGroup = null; // 跟踪当前选中的边组
+        const container = document.getElementById('app-container');
+
+        function updateTransform() {{
+            // 采用先缩放再平移的顺序,使平移量与缩放无关,便于以视图中心进行缩放和平移
+            canvas.style.transform = `scale(${{scale}}) translate(${{translateX}}px, ${{translateY}}px)`;
+        }}
+
+        // 计算相关节点的边界框并缩放显示(当前节点及其连线上的节点)
+        function fitRelatedNodesToView(targetNode, useAnimation = true) {{
+            if (!targetNode || targetNode.x === undefined) return;
+
+            // 收集所有相关节点
+            const relatedNodes = new Set();
+            relatedNodes.add(targetNode);
+
+            // 如果节点有边组,添加同组的其他目标节点
+            if (targetNode.edgeGroupKey) {{
+                const group = edgeGroups[targetNode.edgeGroupKey];
+                if (group) {{
+                    // 添加同组的所有目标节点
+                    group.targets.forEach(t => {{
+                        if (t && t.x !== undefined) relatedNodes.add(t);
+                    }});
+                    
+                    // 添加所有源节点(需要根据来源区分查找)
+                    const sourceMap = group.sourceMap || {{}};
+                    group.sources.forEach(sName => {{
+                        const sourceType = sourceMap[sName];
+                        let sNode = null;
+                        
+                        if (sourceType === 'tree') {{
+                            // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
+                            const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                            sNode = levelMinusOneNodes.find(n => n.name === sName);
+                        }} else {{
+                            // input_post_nodes:从推导节点中查找(排除 level -1)
+                            const candidate = flatData.map[sName];
+                            if (candidate && candidate.level !== -1) {{
+                                sNode = candidate;
+                            }} else {{
+                                // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
+                                for (let level in flatData.nodesByLevel) {{
+                                    const levelNum = parseInt(level);
+                                    if (levelNum !== -1) {{
+                                        const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
+                                        if (found) {{
+                                            sNode = found;
+                                            break;
+                                        }}
+                                    }}
+                                }}
+                            }}
+                        }}
+                        
+                        if (sNode && sNode.x !== undefined) {{
+                            relatedNodes.add(sNode);
+                        }}
+                    }});
+                }}
+            }}
+
+            const sidebar = document.getElementById('sidebar');
+            const isSidebarOpen = sidebar && sidebar.classList.contains('active');
+            const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
+            const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
+            const viewH = container.offsetHeight;
+
+            // 仅当节点没有连线(只有一个相关节点)时:只平移使节点居中,不改变缩放,避免视图被放大超出
+            if (relatedNodes.size === 1) {{
+                const nodeCenterX = targetNode.x + (targetNode.width || 0) / 2;
+                const nodeCenterY = targetNode.y + (targetNode.height || 0) / 2;
+                const offsetX = isSidebarOpen ? 0 : 0;
+                translateX = (viewW / 2) - (nodeCenterX * scale) + offsetX;
+                translateY = (viewH / 2) - (nodeCenterY * scale);
+                if (useAnimation) {{
+                    canvas.classList.add('animating');
+                    updateTransform();
+                    setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+                }} else {{
+                    canvas.classList.remove('animating');
+                    updateTransform();
+                }}
+                return;
+            }}
+
+            // 计算边界框(包括节点和连线可能延伸的区域)
+            let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+            relatedNodes.forEach(node => {{
+                if (node.x !== undefined && node.y !== undefined) {{
+                    // 节点本身的边界
+                    minX = Math.min(minX, node.x);
+                    minY = Math.min(minY, node.y);
+                    maxX = Math.max(maxX, node.x + (node.width || 0));
+                    maxY = Math.max(maxY, node.y + (node.height || 0));
+                    
+                    // 考虑连线可能延伸到左侧(busOffset)
+                    if (node.inputPoint) {{
+                        const leftExtend = node.inputPoint.x - CONFIG.busOffset;
+                        minX = Math.min(minX, leftExtend);
+                    }}
+                }}
+            }});
+
+            // 如果边界框无效,直接返回
+            if (minX === Infinity) return;
+
+            // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
+            const padding = 40;
+            const contentWidth = maxX - minX + padding * 2;
+            const contentHeight = maxY - minY + padding * 2;
+            const contentCenterX = (minX + maxX) / 2;
+            const contentCenterY = (minY + maxY) / 2;
+
+            // 计算缩放比例,确保内容能完全显示
+            const scaleX = (viewW - padding * 2) / contentWidth;
+            const scaleY = (viewH - padding * 2) / contentHeight;
+            // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
+            scale = Math.min(scaleX, scaleY, 2.0);
+
+            // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
+            const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
+            translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
+            translateY = (viewH / 2) - (contentCenterY * scale);
+
+            // 根据参数决定是否使用动画
+            if (useAnimation) {{
+                canvas.classList.add('animating');
+                updateTransform();
+                setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+            }} else {{
+                // 移除动画类,确保瞬间完成
+                canvas.classList.remove('animating');
+                updateTransform();
+            }}
+        }}
+
+        let hasDragged = false; // 标记是否发生了拖动
+        
+        container.addEventListener('click', e => {{
+            // 只有在没有拖动的情况下才重置视图
+            if (!hasDragged && (e.target.id === 'app-container' || e.target.id === 'canvas' || e.target.classList.contains('edge-layer'))) {{
+                resetView();
+            }}
+            // 处理完点击事件后重置标志
+            hasDragged = false;
+        }});
+
+        function resetView() {{
+            document.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
+            document.querySelectorAll('.dimmed').forEach(el => el.classList.remove('dimmed'));
+            document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+            document.getElementById('search-input').value = '';
+            currentSelectedNode = null; // 清除当前选中的节点
+            currentSelectedEdgeGroup = null; // 清除当前选中的边组
+            closeSidebar();
+        }}
+
+        const searchInput = document.getElementById('search-input');
+        searchInput.addEventListener('input', (e) => {{
+            const val = e.target.value.toLowerCase();
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
+                const name = el.innerText.toLowerCase();
+                if(!val) el.classList.remove('dimmed');
+                else if (name.includes(val)) el.classList.remove('dimmed');
+                else el.classList.add('dimmed');
+            }});
+        }});
+        searchInput.addEventListener('keydown', (e) => {{
+            if (e.key === 'Enter') {{
+                const val = searchInput.value.toLowerCase();
+                if (!val) return;
+                const match = Object.values(flatData.map).find(n => n.name.toLowerCase().includes(val));
+                if (match) focusOnNode(match);
+            }}
+        }});
+
+        function focusOnNode(node) {{
+            const nodeCenterX = node.x + node.width / 2;
+            const nodeCenterY = node.y + node.height / 2;
+            const viewW = container.offsetWidth;
+            const viewH = container.offsetHeight;
+            translateX = (viewW / 2) - (nodeCenterX * scale);
+            translateY = (viewH / 2) - (nodeCenterY * scale);
+            canvas.classList.add('animating');
+            updateTransform();
+            setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+            highlightDirectSources(node);
+        }}
+
+        container.addEventListener('mousedown', e => {{
+            if (e.target === container || e.target.id === 'canvas' || e.target.classList.contains('edge-layer')) {{
+                isDragging = true; 
+                hasDragged = false; // 重置拖动标志
+                startClientX = e.clientX;
+                startClientY = e.clientY;
+                startTranslateX = translateX;
+                startTranslateY = translateY;
+                container.classList.add('grabbing');
+            }}
+        }});
+        window.addEventListener('mousemove', e => {{
+            if (isDragging) {{
+                e.preventDefault(); 
+                translateX = startTranslateX + (e.clientX - startClientX) * DRAG_SENSITIVITY;
+                translateY = startTranslateY + (e.clientY - startClientY) * DRAG_SENSITIVITY;
+                updateTransform();
+                hasDragged = true; // 标记发生了拖动
+            }}
+        }});
+        window.addEventListener('mouseup', () => {{
+            isDragging = false; 
+            container.classList.remove('grabbing');
+        }});
+        container.addEventListener('wheel', e => {{
+            e.preventDefault();
+            
+            // 以当前视图中心为缩放中心,保证缩放时画面不会“往左上角跑”
+            const viewW = container.offsetWidth;
+            const viewH = container.offsetHeight;
+            const centerScreenX = viewW / 2;
+            const centerScreenY = viewH / 2;
+
+            // 当前视图中心对应的画布坐标(world coords)
+            const centerWorldX = (centerScreenX - translateX) / scale;
+            const centerWorldY = (centerScreenY - translateY) / scale;
+
+            // 更小的缩放步长,让滚轮缩放更平滑
+            const zoomStep = 0.05; // 每次滚轮约 5% 的缩放变化
+            const zoomFactor = e.deltaY > 0 ? (1 - zoomStep) : (1 + zoomStep);
+            const newScale = Math.max(0.1, Math.min(3, scale * zoomFactor));
+
+            // 根据新的缩放比例,调整平移量,使视图中心保持不动
+            translateX = centerScreenX - centerWorldX * newScale;
+            translateY = centerScreenY - centerWorldY * newScale;
+            scale = newScale;
+
+            updateTransform();
+        }}, {{ passive: false }});
+
+        function escapeHtml(text) {{
+            if (!text) return "";
+            const div = document.createElement("div");
+            div.textContent = text;
+            return div.innerHTML;
+        }}
+
+        function showSidebar(detail, title, fullData, sidebarType) {{
+            const container = document.getElementById('sidebar-content');
+            const sidebar = document.getElementById('sidebar');
+            const appContainer = document.getElementById('app-container');
+            const titleEl = document.getElementById('sidebar-title');
+            if (titleEl) titleEl.textContent = (sidebarType === 'edge') ? '边详情' : '节点详情';
+            container.innerHTML = '';
+
+            if (fullData && (fullData.id === "root" || fullData.name === "root")) {{
+                renderRootDetail(detail, container);
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 添加标题
+            const titleDiv = document.createElement('div');
+            titleDiv.className = 'detail-item';
+            const titleLabel = document.createElement('label');
+            titleLabel.textContent = title || '节点详情';
+            titleDiv.appendChild(titleLabel);
+            container.appendChild(titleDiv);
+
+            // 检查是否是外部边或工具边(通过 edgeName 判断)
+            const edgeName = fullData?.edgeName || '';
+            const isExternalEdge = edgeName && (edgeName.includes('外部搜索') || edgeName.includes('外部寻找'));
+            const isToolEdge = edgeName && edgeName.includes('工具');
+
+            // 外部边:特殊展示逻辑
+            if (isExternalEdge) {{
+                renderExternalEdgeDetail(detail, container, fullData?.name || '');
+                if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
+                    const scoreItem = document.createElement('div');
+                    scoreItem.className = 'detail-item';
+                    const scoreLabel = document.createElement('label');
+                    scoreLabel.textContent = 'Score:';
+                    scoreItem.appendChild(scoreLabel);
+                    const scoreVal = document.createElement('div');
+                    scoreVal.className = 'detail-val';
+                    scoreVal.textContent = fullData.edgeScore.toFixed(4);
+                    scoreItem.appendChild(scoreVal);
+                    container.appendChild(scoreItem);
+                }}
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 工具边:特殊展示逻辑
+            if (isToolEdge) {{
+                renderToolEdgeDetail(detail, container, fullData?.name || '');
+                if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
+                    const scoreItem = document.createElement('div');
+                    scoreItem.className = 'detail-item';
+                    const scoreLabel = document.createElement('label');
+                    scoreLabel.textContent = 'Score:';
+                    scoreItem.appendChild(scoreLabel);
+                    const scoreVal = document.createElement('div');
+                    scoreVal.className = 'detail-val';
+                    scoreVal.textContent = fullData.edgeScore.toFixed(4);
+                    scoreItem.appendChild(scoreVal);
+                    container.appendChild(scoreItem);
+                }}
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 过滤掉路径相关的字段
+            const pathFields = ['source', 'target', 'id', 'originalData', 'internal', 'external', 'internal_edge', 'external_edge', 'children', 'parent_edge', 'sources', 'edgeName', 'edgeScore', 'edgeGroupKey'];
+
+            // 过滤detail中的路径字段
+            const filteredDetail = {{}};
+            if (detail && typeof detail === "object") {{
+                Object.entries(detail).forEach(([key, value]) => {{
+                    if (!pathFields.includes(key)) {{
+                        filteredDetail[key] = value;
+                    }}
+                }});
+            }}
+
+            // 如果没有detail内容
+            if (!filteredDetail || Object.keys(filteredDetail).length === 0) {{
+                const emptyDiv = document.createElement('div');
+                emptyDiv.className = 'detail-empty';
+                emptyDiv.textContent = '暂无详情信息';
+                container.appendChild(emptyDiv);
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 显示detail内容
+            Object.entries(filteredDetail).forEach(([key, value]) => {{
+                // 跳过空值
+                if (value === null || value === undefined || value === "") return;
+
+                const item = document.createElement('div');
+                item.className = 'detail-item';
+
+                const label = document.createElement('label');
+                label.textContent = key + ':';
+                item.appendChild(label);
+
+                if (typeof value === "object" && value !== null && !Array.isArray(value)) {{
+                    // 对象结构,展示 KV 列表
+                    const subContainer = document.createElement('div');
+                    subContainer.className = 'detail-val';
+                    subContainer.style.paddingLeft = '15px';
+                    subContainer.style.borderLeft = '3px solid #eee';
+                    subContainer.style.marginTop = '10px';
+                    subContainer.style.fontSize = '14px';
+                    
+                    Object.entries(value).forEach(([subKey, subValue]) => {{
+                        if (subValue === null || subValue === undefined || subValue === "") return;
+                        const subItem = document.createElement('div');
+                        subItem.style.marginBottom = '8px';
+                        const subKeySpan = document.createElement('span');
+                        subKeySpan.style.color = '#666';
+                        subKeySpan.textContent = subKey + ': ';
+                        subItem.appendChild(subKeySpan);
+                        const subValSpan = document.createElement('span');
+                        subValSpan.textContent = typeof subValue === 'object' ? JSON.stringify(subValue) : subValue;
+                        subItem.appendChild(subValSpan);
+                        subContainer.appendChild(subItem);
+                    }});
+                    item.appendChild(subContainer);
+                }} else {{
+                    const valueContainer = document.createElement('div');
+                    valueContainer.className = 'detail-val';
+
+                    if (Array.isArray(value)) {{
+                        if (value.length === 0) return;
+                        
+                        // 检查数组元素是否为对象
+                        const isArrayOfObjects = value.length > 0 && typeof value[0] === 'object' && value[0] !== null;
+                        
+                        if (isArrayOfObjects) {{
+                            // 数组元素为对象时,使用表格展示
+                            const table = document.createElement('table');
+                            table.style.width = '100%';
+                            table.style.borderCollapse = 'collapse';
+                            table.style.fontSize = '13px';
+                            table.style.marginTop = '5px';
+
+                            // 统计所有列名(字段)
+                            const columnsSet = new Set();
+                            value.forEach(v => {{
+                                if (v && typeof v === 'object') {{
+                                    Object.keys(v).forEach(k => columnsSet.add(k));
+                                }}
+                            }});
+                            const columns = Array.from(columnsSet);
+
+                            // 表头
+                            const thead = document.createElement('thead');
+                            const headerRow = document.createElement('tr');
+                            const thIndex = document.createElement('th');
+                            thIndex.textContent = '#';
+                            thIndex.style.borderBottom = '1px solid #eee';
+                            thIndex.style.padding = '4px 6px';
+                            thIndex.style.textAlign = 'left';
+                            thIndex.style.color = '#888';
+                            headerRow.appendChild(thIndex);
+                            columns.forEach(col => {{
+                                const th = document.createElement('th');
+                                th.textContent = col;
+                                th.style.borderBottom = '1px solid #eee';
+                                th.style.padding = '4px 6px';
+                                th.style.textAlign = 'left';
+                                th.style.color = '#555';
+                                headerRow.appendChild(th);
+                            }});
+                            thead.appendChild(headerRow);
+                            table.appendChild(thead);
+
+                            // 表体
+                            const tbody = document.createElement('tbody');
+                            value.forEach((v, i) => {{
+                                const row = document.createElement('tr');
+                                row.style.borderBottom = '1px dashed #f0f0f0';
+                                const tdIndex = document.createElement('td');
+                                tdIndex.textContent = i + 1;
+                                tdIndex.style.padding = '4px 6px';
+                                tdIndex.style.color = '#999';
+                                row.appendChild(tdIndex);
+                                columns.forEach(col => {{
+                                    const cellVal = v && typeof v === 'object' ? v[col] : undefined;
+                                    const td = document.createElement('td');
+                                    td.textContent = cellVal === undefined || cellVal === null
+                                        ? ""
+                                        : (typeof cellVal === 'object' ? JSON.stringify(cellVal) : cellVal);
+                                    td.style.padding = '4px 6px';
+                                    td.style.color = '#666';
+                                    td.style.verticalAlign = 'top';
+                                    row.appendChild(td);
+                                }});
+                                tbody.appendChild(row);
+                            }});
+                            table.appendChild(tbody);
+                            valueContainer.appendChild(table);
+                        }} else {{
+                            // 普通数组:每个元素一行的单列表格
+                            const table = document.createElement('table');
+                            table.style.width = '100%';
+                            table.style.borderCollapse = 'collapse';
+                            table.style.fontSize = '13px';
+                            table.style.marginTop = '5px';
+
+                            const thead = document.createElement('thead');
+                            const headerRow = document.createElement('tr');
+                            const thIndex = document.createElement('th');
+                            thIndex.textContent = '#';
+                            thIndex.style.borderBottom = '1px solid #eee';
+                            thIndex.style.padding = '4px 6px';
+                            thIndex.style.textAlign = 'left';
+                            thIndex.style.color = '#888';
+                            headerRow.appendChild(thIndex);
+                            const thValue = document.createElement('th');
+                            thValue.textContent = 'value';
+                            thValue.style.borderBottom = '1px solid #eee';
+                            thValue.style.padding = '4px 6px';
+                            thValue.style.textAlign = 'left';
+                            thValue.style.color = '#555';
+                            headerRow.appendChild(thValue);
+                            thead.appendChild(headerRow);
+                            table.appendChild(thead);
+
+                            const tbody = document.createElement('tbody');
+                            value.forEach((v, i) => {{
+                                const row = document.createElement('tr');
+                                row.style.borderBottom = '1px dashed #f0f0f0';
+                                const tdIndex = document.createElement('td');
+                                tdIndex.textContent = i + 1;
+                                tdIndex.style.padding = '4px 6px';
+                                tdIndex.style.color = '#999';
+                                row.appendChild(tdIndex);
+                                const tdValue = document.createElement('td');
+                                tdValue.textContent = typeof v === 'object' ? JSON.stringify(v) : v;
+                                tdValue.style.padding = '4px 6px';
+                                tdValue.style.color = '#666';
+                                tdValue.style.verticalAlign = 'top';
+                                row.appendChild(tdValue);
+                                tbody.appendChild(row);
+                            }});
+                            table.appendChild(tbody);
+                            valueContainer.appendChild(table);
+                        }}
+                    }} else {{
+                        let displayValue = value;
+                        if (typeof value === "string" && value.length > 500) {{
+                            displayValue = value.substring(0, 500) + "...";
+                        }}
+                        valueContainer.textContent = displayValue;
+                    }}
+                    item.appendChild(valueContainer);
+                }}
+                container.appendChild(item);
+            }});
+
+            // 如果有score,也显示
+            if (fullData && fullData.score !== undefined && fullData.score !== null) {{
+                const scoreItem = document.createElement('div');
+                scoreItem.className = 'detail-item';
+                const scoreLabel = document.createElement('label');
+                scoreLabel.textContent = 'Score:';
+                scoreItem.appendChild(scoreLabel);
+                const scoreVal = document.createElement('div');
+                scoreVal.className = 'detail-val';
+                scoreVal.textContent = fullData.score.toFixed(4);
+                scoreItem.appendChild(scoreVal);
+                container.appendChild(scoreItem);
+            }}
+
+            sidebar.classList.add('active');
+            appContainer.classList.add('sidebar-open');
+            updateCanvasWidth();
+        }}
+        function renderExternalEdgeDetail(detail, container, targetNodeName) {{
+            if (!detail) return;
+
+            const targetName = targetNodeName || "";
+
+            // 1. 全局常量
+            const globalConstants = detail["全局常量"] || [];
+            if (Array.isArray(globalConstants) && globalConstants.length > 0) {{
+                const globalSection = document.createElement('div');
+                globalSection.className = 'detail-item';
+                const globalLabel = document.createElement('label');
+                globalLabel.textContent = '全局常量:';
+                globalSection.appendChild(globalLabel);
+                const globalValue = document.createElement('div');
+                globalValue.className = 'detail-val';
+                globalValue.style.marginTop = '8px';
+                globalValue.textContent = globalConstants.join('、');
+                globalSection.appendChild(globalValue);
+                container.appendChild(globalSection);
+            }}
+
+            // 2. 局部常量
+            const localConstants = detail["局部常量"] || [];
+            if (Array.isArray(localConstants) && localConstants.length > 0) {{
+                const localSection = document.createElement('div');
+                localSection.className = 'detail-item';
+                const localLabel = document.createElement('label');
+                localLabel.textContent = '局部常量:';
+                localSection.appendChild(localLabel);
+                const localValue = document.createElement('div');
+                localValue.className = 'detail-val';
+                localValue.style.marginTop = '8px';
+                localValue.textContent = localConstants.join('、');
+                localSection.appendChild(localValue);
+                container.appendChild(localSection);
+            }}
+
+            // 3. 匹配到的模式
+            const matchedPatterns = detail["匹配到的模式"] || [];
+            if (Array.isArray(matchedPatterns) && matchedPatterns.length > 0) {{
+                const patternSection = document.createElement('div');
+                patternSection.className = 'detail-item';
+                const patternLabel = document.createElement('label');
+                patternLabel.textContent = '匹配到的模式:';
+                patternSection.appendChild(patternLabel);
+                const patternValue = document.createElement('div');
+                patternValue.className = 'detail-val';
+                patternValue.style.marginTop = '8px';
+                container.appendChild(patternSection);
+                patternSection.appendChild(patternValue);
+
+                matchedPatterns.forEach((pattern, idx) => {{
+                    const patternBlock = document.createElement('div');
+                    patternBlock.style.marginBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
+                    patternBlock.style.paddingBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
+                    patternBlock.style.borderBottom = idx < matchedPatterns.length - 1 ? '1px solid #eee' : 'none';
+
+                    // 显示 id 和 support
+                    const patternHeader = document.createElement('div');
+                    patternHeader.style.fontWeight = 'bold';
+                    patternHeader.style.marginBottom = '6px';
+                    patternHeader.style.fontSize = '13px';
+                    patternHeader.textContent = '模式 ID: ' + (pattern.id || "") + ', Support: ' + (pattern.support !== undefined ? pattern.support.toFixed(4) : "");
+                    patternBlock.appendChild(patternHeader);
+
+                    // items 展示成表格
+                    const items = pattern.items || [];
+                    if (items.length > 0) {{
+                        const itemsTable = document.createElement('table');
+                        itemsTable.style.width = '100%';
+                        itemsTable.style.borderCollapse = 'collapse';
+                        itemsTable.style.fontSize = '13px';
+                        itemsTable.style.marginTop = '6px';
+                        itemsTable.style.marginBottom = '6px';
+
+                        const thead = document.createElement('thead');
+                        const headerRow = document.createElement('tr');
+                        ["name", "point", "dimension", "type"].forEach(col => {{
+                            const th = document.createElement('th');
+                            th.textContent = col;
+                            th.style.border = '1px solid #ddd';
+                            th.style.padding = '6px 8px';
+                            th.style.textAlign = 'left';
+                            th.style.background = '#f5f5f5';
+                            headerRow.appendChild(th);
+                        }});
+                        thead.appendChild(headerRow);
+                        itemsTable.appendChild(thead);
+
+                        const tbody = document.createElement('tbody');
+                        items.forEach(item => {{
+                            const tr = document.createElement('tr');
+                            ["name", "point", "dimension", "type"].forEach(col => {{
+                                const td = document.createElement('td');
+                                td.textContent = item[col] || "";
+                                td.style.border = '1px solid #ddd';
+                                td.style.padding = '6px 8px';
+                                tr.appendChild(td);
+                            }});
+                            tbody.appendChild(tr);
+                        }});
+                        itemsTable.appendChild(tbody);
+                        patternBlock.appendChild(itemsTable);
+                    }}
+
+                    // match_points
+                    const matchPoints = pattern.match_points || [];
+                    if (matchPoints.length > 0) {{
+                        const matchPointsDiv = document.createElement('div');
+                        matchPointsDiv.style.fontSize = '13px';
+                        matchPointsDiv.style.color = '#666';
+                        matchPointsDiv.style.marginTop = '4px';
+                        matchPointsDiv.textContent = '匹配点: ' + matchPoints.join('、');
+                        patternBlock.appendChild(matchPointsDiv);
+                    }}
+
+                    patternValue.appendChild(patternBlock);
+                }});
+            }}
+
+            // 4. 动态常量
+            const dynamicConstants = detail["动态常量"] || [];
+            if (Array.isArray(dynamicConstants) && dynamicConstants.length > 0) {{
+                const dynamicSection = document.createElement('div');
+                dynamicSection.className = 'detail-item';
+                const dynamicLabel = document.createElement('label');
+                dynamicLabel.textContent = '动态常量:';
+                dynamicSection.appendChild(dynamicLabel);
+                const dynamicValue = document.createElement('div');
+                dynamicValue.className = 'detail-val';
+                dynamicValue.style.marginTop = '8px';
+                container.appendChild(dynamicSection);
+                dynamicSection.appendChild(dynamicValue);
+
+                const dynamicTable = document.createElement('table');
+                dynamicTable.style.width = '100%';
+                dynamicTable.style.borderCollapse = 'collapse';
+                dynamicTable.style.fontSize = '13px';
+
+                const thead = document.createElement('thead');
+                const headerRow = document.createElement('tr');
+                const columns = ["point", "tree_parent_node", "match_score", "tree_child_node", "relative_ratio"];
+                columns.forEach(col => {{
+                    const th = document.createElement('th');
+                    th.textContent = col;
+                    th.style.border = '1px solid #ddd';
+                    th.style.padding = '6px 8px';
+                    th.style.textAlign = 'left';
+                    th.style.background = '#f5f5f5';
+                    headerRow.appendChild(th);
+                }});
+                thead.appendChild(headerRow);
+                dynamicTable.appendChild(thead);
+
+                // 计算每列的合并信息
+                const getValue = (dc, col) => {{
+                    if (col === "match_score") return dc.match_score !== undefined ? dc.match_score.toFixed(4) : "";
+                    if (col === "relative_ratio") return dc.relative_ratio !== undefined ? dc.relative_ratio.toFixed(4) : "";
+                    return dc[col] || "";
+                }};
+
+                const getRowspan = (colIdx, rowIdx) => {{
+                    const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
+                    let span = 1;
+                    for (let i = rowIdx + 1; i < dynamicConstants.length; i++) {{
+                        const nextValue = getValue(dynamicConstants[i], columns[colIdx]);
+                        if (nextValue === currentValue) {{
+                            span++;
+                        }} else {{
+                            break;
+                        }}
+                    }}
+                    return span;
+                }};
+
+                const shouldSkipCell = (colIdx, rowIdx) => {{
+                    if (rowIdx === 0) return false;
+                    const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
+                    const prevValue = getValue(dynamicConstants[rowIdx - 1], columns[colIdx]);
+                    return currentValue === prevValue;
+                }};
+
+                const tbody = document.createElement('tbody');
+                dynamicConstants.forEach((dc, rowIdx) => {{
+                    const tr = document.createElement('tr');
+                    columns.forEach((col, colIdx) => {{
+                        if (shouldSkipCell(colIdx, rowIdx)) {{
+                            // 跳过,因为会被上一行的 rowspan 覆盖
+                            return;
+                        }}
+                        const cellValue = getValue(dc, col);
+                        const span = getRowspan(colIdx, rowIdx);
+                        const td = document.createElement('td');
+                        td.textContent = cellValue;
+                        td.style.border = '1px solid #ddd';
+                        td.style.padding = '6px 8px';
+                        if (span > 1) {{
+                            td.setAttribute('rowspan', span);
+                        }}
+                        tr.appendChild(td);
+                    }});
+                    tbody.appendChild(tr);
+                }});
+                dynamicTable.appendChild(tbody);
+                dynamicValue.appendChild(dynamicTable);
+            }}
+
+            // 4.5. 推导成功的选题点
+            const successfulPointsRaw = detail["推导成功的选题点"] || [];
+            if (Array.isArray(successfulPointsRaw) && successfulPointsRaw.length > 0) {{
+                // 处理字符串数组或对象数组(对象需有 name 字段)
+                const successfulPoints = successfulPointsRaw.map(item => {{
+                    if (typeof item === "string") {{
+                        return item;
+                    }} else if (item && typeof item === "object" && item.name) {{
+                        return item.name;
+                    }}
+                    return "";
+                }}).filter(Boolean);
+                
+                if (successfulPoints.length > 0) {{
+                    const successfulSection = document.createElement('div');
+                    successfulSection.className = 'detail-item';
+                    const successfulLabel = document.createElement('label');
+                    successfulLabel.textContent = '推导成功的选题点:';
+                    successfulSection.appendChild(successfulLabel);
+                    const successfulValue = document.createElement('div');
+                    successfulValue.className = 'detail-val';
+                    successfulValue.style.marginTop = '8px';
+                    successfulValue.textContent = successfulPoints.join('、');
+                    successfulSection.appendChild(successfulValue);
+                    container.appendChild(successfulSection);
+                }}
+            }}
+
+            // 5. Query列表
+            const querySection = document.createElement('div');
+            querySection.className = 'detail-item';
+            const queryLabelRow = document.createElement('div');
+            queryLabelRow.style.display = 'flex';
+            queryLabelRow.style.justifyContent = 'space-between';
+            queryLabelRow.style.alignItems = 'center';
+            queryLabelRow.style.marginBottom = '8px';
+            const queryLabel = document.createElement('label');
+            queryLabel.style.margin = '0';
+            queryLabel.textContent = 'Query列表:';
+            queryLabelRow.appendChild(queryLabel);
+            querySection.appendChild(queryLabelRow);
+            const queryList = detail.query_list || [];
+            const queryStrs = queryList.map(q => q && q.query_str ? q.query_str : (typeof q === "string" ? q : "")).filter(Boolean);
+            const queryValueDiv = document.createElement('div');
+            queryValueDiv.className = 'detail-val';
+            queryValueDiv.style.marginTop = '4px';
+            queryValueDiv.style.whiteSpace = 'pre-wrap';
+            if (queryStrs.length) {{
+                queryValueDiv.textContent = queryStrs.join('\\n');
+            }} else {{
+                queryValueDiv.textContent = '暂无';
+            }}
+            querySection.appendChild(queryValueDiv);
+            container.appendChild(querySection);
+
+            // 6. 外部寻找结果 (match_result 为数组,按匹配率倒序显示)
+            const matchSection = document.createElement('div');
+            matchSection.className = 'detail-item';
+            const matchLabelRow = document.createElement('div');
+            matchLabelRow.style.display = 'flex';
+            matchLabelRow.style.justifyContent = 'space-between';
+            matchLabelRow.style.alignItems = 'center';
+            matchLabelRow.style.marginBottom = '8px';
+            const matchLabel = document.createElement('label');
+            matchLabel.style.margin = '0';
+            matchLabel.textContent = '外部寻找结果:';
+            matchLabelRow.appendChild(matchLabel);
+            matchSection.appendChild(matchLabelRow);
+            const matchResultArr = detail.match_result || [];
+
+            const matchWithStats = matchResultArr.map(mr => {{
+                const nodeList = mr.node_list || [];
+                const searchCount = nodeList.length;
+                const matchCount = nodeList.filter(n => n.eval_result && n.eval_result.匹配类型 === "完全匹配").length;
+                const matchRate = searchCount > 0 ? (matchCount / searchCount * 100) : 0;
+                return {{ ...mr, searchCount, matchCount, matchRate }};
+            }});
+            const sortedMatchResult = [...matchWithStats].sort((a, b) => b.matchRate - a.matchRate);
+
+            const matchValue = document.createElement('div');
+            matchValue.className = 'detail-val';
+            matchValue.style.marginTop = '8px';
+            matchSection.appendChild(matchValue);
+            container.appendChild(matchSection);
+
+            sortedMatchResult.forEach((matchResult, mrIdx) => {{
+                const nodeList = matchResult.node_list || [];
+                const sortedNodes = [...nodeList].sort((a, b) => {{
+                    const scoreA = a.eval_result && a.eval_result.综合得分 !== undefined ? a.eval_result.综合得分 : -1;
+                    const scoreB = b.eval_result && b.eval_result.综合得分 !== undefined ? b.eval_result.综合得分 : -1;
+                    return scoreB - scoreA;
+                }});
+
+                const queryBlock = document.createElement('div');
+                queryBlock.className = 'query-block';
+                queryBlock.style.marginBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
+                queryBlock.style.paddingBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
+                queryBlock.style.borderBottom = mrIdx < sortedMatchResult.length - 1 ? '1px solid #eee' : 'none';
+
+                const queryBody = document.createElement('div');
+                queryBody.className = 'query-block-body';
+
+                const headerDiv = document.createElement('div');
+                headerDiv.className = 'query-block-header';
+                headerDiv.style.fontWeight = 'bold';
+                headerDiv.style.fontSize = '14px';
+                headerDiv.style.cursor = 'pointer';
+                const toggleSpan = document.createElement('span');
+                toggleSpan.className = 'query-toggle';
+                toggleSpan.style.marginRight = '6px';
+                toggleSpan.style.display = 'inline-block';
+                toggleSpan.style.width = '16px';
+                toggleSpan.textContent = '▼';
+                headerDiv.appendChild(toggleSpan);
+                const querySpan = document.createElement('span');
+                querySpan.textContent = 'Query: ' + (matchResult.query_str || "暂无");
+                headerDiv.appendChild(querySpan);
+                const statsDiv = document.createElement('div');
+                statsDiv.style.fontSize = '13px';
+                statsDiv.style.color = '#666';
+                statsDiv.style.fontWeight = 'normal';
+                statsDiv.style.marginTop = '4px';
+                statsDiv.textContent = '搜索帖子数: ' + matchResult.searchCount + ',匹配帖子数: ' + matchResult.matchCount + ',匹配率: ' + matchResult.matchRate.toFixed(1) + '%';
+                headerDiv.appendChild(statsDiv);
+
+                let isExpanded = true;
+                headerDiv.addEventListener('click', function() {{
+                    isExpanded = !isExpanded;
+                    queryBody.style.display = isExpanded ? 'block' : 'none';
+                    toggleSpan.textContent = isExpanded ? '▼' : '▶';
+                }});
+
+                queryBlock.insertBefore(headerDiv, queryBlock.firstChild);
+                queryBlock.appendChild(queryBody);
+
+                sortedNodes.forEach((node, i) => {{
+                    const postCard = document.createElement('div');
+                    postCard.className = 'external-post-card';
+                    postCard.style.border = '1px solid #eee';
+                    postCard.style.borderRadius = '8px';
+                    postCard.style.padding = '12px';
+                    postCard.style.marginTop = '12px';
+                    postCard.style.background = '#fafafa';
+
+                    const titleDiv = document.createElement('div');
+                    titleDiv.style.fontWeight = 'bold';
+                    titleDiv.style.marginBottom = '6px';
+                    titleDiv.style.fontSize = '14px';
+                    titleDiv.textContent = (i + 1) + '. ' + (node.title || "无标题");
+                    postCard.appendChild(titleDiv);
+
+                    const bodyText = node.body_text || "";
+                    const bodyDiv = document.createElement('div');
+                    bodyDiv.style.fontSize = '13px';
+                    bodyDiv.style.color = '#666';
+                    bodyDiv.style.marginBottom = '8px';
+                    bodyDiv.style.whiteSpace = 'pre-wrap';
+                    bodyDiv.style.maxHeight = '100px';
+                    bodyDiv.style.overflowY = 'auto';
+                    bodyDiv.textContent = bodyText.length > 200 ? bodyText.substring(0, 200) + "..." : bodyText;
+                    postCard.appendChild(bodyDiv);
+
+                    const imgList = node.image_url_list || [];
+                    const urlList = imgList.map(img => (img && img.image_url) ? img.image_url : (typeof img === "string" ? img : "")).filter(Boolean);
+                    if (urlList.length > 0) {{
+                        const gallery = document.createElement('div');
+                        gallery.style.display = 'flex';
+                        gallery.style.flexWrap = 'wrap';
+                        gallery.style.gap = '6px';
+                        gallery.style.marginBottom = '8px';
+                        urlList.forEach((url, idx) => {{
+                            const thumb = document.createElement('div');
+                            thumb.style.display = 'block';
+                            thumb.style.cursor = 'pointer';
+                            const img = document.createElement('img');
+                            img.src = url;
+                            img.alt = '图' + (idx + 1);
+                            img.setAttribute('data-url', url);
+                            img.style.width = '60px';
+                            img.style.height = '60px';
+                            img.style.objectFit = 'cover';
+                            img.style.borderRadius = '4px';
+                            img.style.pointerEvents = 'none';
+                            thumb.appendChild(img);
+                            gallery.appendChild(thumb);
+                        }});
+                        postCard.appendChild(gallery);
+                    }}
+
+                    const evalResult = node.eval_result || {{}};
+                    if (evalResult && Object.keys(evalResult).length > 0) {{
+                        const evalDiv = document.createElement('div');
+                        evalDiv.style.marginTop = '8px';
+                        evalDiv.style.padding = '8px';
+                        evalDiv.style.background = '#fff';
+                        evalDiv.style.borderRadius = '4px';
+                        evalDiv.style.borderLeft = '3px solid #2196F3';
+                        const matchType = evalResult.匹配类型 || "无";
+                        const matchTypeColor = matchType === "完全匹配" ? "#5ba85f" : "inherit";
+                        const matchTypeDiv = document.createElement('div');
+                        matchTypeDiv.style.fontSize = '13px';
+                        matchTypeDiv.style.marginBottom = '4px';
+                        matchTypeDiv.innerHTML = '<strong>匹配类型:</strong> <strong style="color:' + matchTypeColor + '">' + escapeHtml(matchType) + '</strong>';
+                        evalDiv.appendChild(matchTypeDiv);
+                        const reasonDiv = document.createElement('div');
+                        reasonDiv.style.fontSize = '13px';
+                        reasonDiv.style.marginBottom = '4px';
+                        reasonDiv.innerHTML = '<strong>评分说明:</strong> ' + (evalResult.评分说明 || "无");
+                        evalDiv.appendChild(reasonDiv);
+                        const keyPoints = evalResult.关键匹配点;
+                        if (keyPoints && Array.isArray(keyPoints) && keyPoints.length > 0) {{
+                            const keyPointsLabel = document.createElement('div');
+                            keyPointsLabel.style.fontSize = '13px';
+                            keyPointsLabel.style.marginBottom = '4px';
+                            keyPointsLabel.style.fontWeight = 'bold';
+                            keyPointsLabel.textContent = '关键匹配点:';
+                            evalDiv.appendChild(keyPointsLabel);
+                            keyPoints.forEach(kp => {{
+                                const kpDiv = document.createElement('div');
+                                kpDiv.style.fontSize = '14px';
+                                kpDiv.style.fontWeight = 'bold';
+                                kpDiv.style.color = '#555';
+                                kpDiv.style.marginLeft = '12px';
+                                kpDiv.textContent = '• ' + kp;
+                                evalDiv.appendChild(kpDiv);
+                            }});
+                        }}
+                        postCard.appendChild(evalDiv);
+                    }}
+                    queryBody.appendChild(postCard);
+                }});
+
+                matchValue.appendChild(queryBlock);
+            }});
+        }}
+
+        function renderToolEdgeDetail(detail, container, targetNodeName) {{
+            if (!detail) return;
+
+            const toolDataList = detail.tool_data_list || [];
+            if (toolDataList.length === 0) {{
+                const emptyDiv = document.createElement('div');
+                emptyDiv.className = 'detail-empty';
+                emptyDiv.textContent = '暂无工具数据';
+                container.appendChild(emptyDiv);
+                return;
+            }}
+
+            // 添加列表标题
+            const listHeader = document.createElement('div');
+            listHeader.style.fontSize = '18px';
+            listHeader.style.fontWeight = 'bold';
+            listHeader.style.color = '#333';
+            listHeader.style.marginBottom = '15px';
+            listHeader.style.paddingBottom = '10px';
+            listHeader.style.borderBottom = '2px solid #2196F3';
+            listHeader.textContent = '工具数据列表 (共 ' + toolDataList.length + ' 项)';
+            container.appendChild(listHeader);
+
+            // 遍历每个工具数据
+            toolDataList.forEach((toolData, idx) => {{
+                const toolBlock = document.createElement('div');
+                toolBlock.className = 'tool-block';
+                toolBlock.style.marginBottom = idx < toolDataList.length - 1 ? '12px' : '0';
+                toolBlock.style.paddingBottom = idx < toolDataList.length - 1 ? '12px' : '0';
+                toolBlock.style.borderBottom = idx < toolDataList.length - 1 ? '1px solid #eee' : 'none';
+
+                const toolBody = document.createElement('div');
+                toolBody.className = 'tool-block-body';
+                toolBody.style.display = 'block';  // 默认展开
+
+                // 获取关键信息用于标题显示
+                const toolInfo = toolData.tool_info || {{}};
+                const toolName = toolInfo.name || "未知工具";
+                const eval = toolData.evaluation || {{}};
+                const matchLevel = eval.match_level || "未评估";
+                const matchLevelColor = matchLevel === "完全匹配" ? "#5ba85f" : 
+                                       matchLevel === "部分匹配" ? "#ff9800" : "#999";
+
+                // 添加可点击的标题栏(显示关键信息)
+                const headerDiv = document.createElement('div');
+                headerDiv.className = 'tool-block-header';
+                headerDiv.style.cursor = 'pointer';
+                headerDiv.style.userSelect = 'none';
+                headerDiv.style.fontWeight = 'bold';
+                headerDiv.style.fontSize = '14px';
+                headerDiv.style.padding = '10px 12px';
+                headerDiv.style.background = '#f5f5f5';
+                headerDiv.style.borderRadius = '6px';
+                headerDiv.style.borderLeft = '4px solid #2196F3';
+                headerDiv.style.marginBottom = '8px';
+                headerDiv.style.transition = 'background 0.2s';
+                headerDiv.addEventListener('mouseenter', function() {{
+                    this.style.background = '#e8f4f8';
+                }});
+                headerDiv.addEventListener('mouseleave', function() {{
+                    this.style.background = '#f5f5f5';
+                }});
+
+                const toggleSpan = document.createElement('span');
+                toggleSpan.className = 'tool-toggle';
+                toggleSpan.style.marginRight = '8px';
+                toggleSpan.style.display = 'inline-block';
+                toggleSpan.style.width = '16px';
+                toggleSpan.style.color = '#2196F3';
+                toggleSpan.textContent = '▼';
+                headerDiv.appendChild(toggleSpan);
+
+                const headerContent = document.createElement('span');
+                const toolNumSpan = document.createElement('span');
+                toolNumSpan.style.color = '#333';
+                toolNumSpan.textContent = '工具 ' + (idx + 1) + ' / ' + toolDataList.length + ': ';
+                headerContent.appendChild(toolNumSpan);
+                const toolNameSpan = document.createElement('span');
+                toolNameSpan.style.color = '#2196F3';
+                toolNameSpan.style.fontWeight = 'bold';
+                toolNameSpan.textContent = toolName;
+                headerContent.appendChild(toolNameSpan);
+                headerDiv.appendChild(headerContent);
+
+                // 添加关键信息摘要
+                const summaryDiv = document.createElement('div');
+                summaryDiv.style.fontSize = '13px';
+                summaryDiv.style.color = '#666';
+                summaryDiv.style.fontWeight = 'normal';
+                summaryDiv.style.marginTop = '6px';
+                summaryDiv.style.paddingLeft = '24px';
+
+                if (matchLevel && matchLevel !== "未评估") {{
+                    const matchLevelSpan = document.createElement('span');
+                    matchLevelSpan.style.marginRight = '15px';
+                    matchLevelSpan.innerHTML = '匹配级别: <strong style="color:' + matchLevelColor + '">' + escapeHtml(matchLevel) + '</strong>';
+                    summaryDiv.appendChild(matchLevelSpan);
+                }}
+                headerDiv.appendChild(summaryDiv);
+
+                headerDiv.addEventListener('click', function() {{
+                    const isExpanded = toolBody.style.display !== 'none';
+                    toolBody.style.display = isExpanded ? 'none' : 'block';
+                    toggleSpan.textContent = isExpanded ? '▶' : '▼';
+                }});
+
+                toolBlock.insertBefore(headerDiv, toolBlock.firstChild);
+                toolBlock.appendChild(toolBody);
+
+                const toolSection = document.createElement('div');
+                toolSection.className = 'detail-item';
+                toolSection.style.paddingTop = '10px';
+                toolBody.appendChild(toolSection);
+
+                // 1. 工具信息
+                if (Object.keys(toolInfo).length > 0) {{
+                    const toolInfoSection = document.createElement('div');
+                    toolInfoSection.style.marginBottom = '15px';
+                    const toolInfoTitle = document.createElement('div');
+                    toolInfoTitle.style.fontSize = '15px';
+                    toolInfoTitle.style.fontWeight = 'bold';
+                    toolInfoTitle.style.color = '#333';
+                    toolInfoTitle.style.marginBottom = '12px';
+                    toolInfoTitle.textContent = '工具信息';
+                    toolInfoSection.appendChild(toolInfoTitle);
+
+                    const toolInfoTable = document.createElement('table');
+                    toolInfoTable.style.width = '100%';
+                    toolInfoTable.style.borderCollapse = 'collapse';
+                    toolInfoTable.style.fontSize = '13px';
+                    toolInfoTable.style.background = '#fafafa';
+                    toolInfoTable.style.borderRadius = '6px';
+                    toolInfoTable.style.overflow = 'hidden';
+
+                    const toolInfoFields = [
+                        {{ key: "name", label: "工具名称" }},
+                        {{ key: "tool_description", label: "工具描述" }}
+                    ];
+
+                    toolInfoFields.forEach(field => {{
+                        const value = toolInfo[field.key];
+                        if (value !== undefined && value !== null && value !== "") {{
+                            const row = document.createElement('tr');
+                            const labelTd = document.createElement('td');
+                            labelTd.textContent = field.label + ':';
+                            labelTd.style.padding = '8px 12px';
+                            labelTd.style.fontWeight = 'bold';
+                            labelTd.style.color = '#555';
+                            labelTd.style.width = '120px';
+                            labelTd.style.verticalAlign = 'top';
+                            labelTd.style.background = '#f0f0f0';
+                            row.appendChild(labelTd);
+                            const valueTd = document.createElement('td');
+                            valueTd.textContent = String(value);
+                            valueTd.style.padding = '8px 12px';
+                            valueTd.style.color = '#666';
+                            valueTd.style.whiteSpace = 'pre-wrap';
+                            valueTd.style.wordBreak = 'break-word';
+                            row.appendChild(valueTd);
+                            toolInfoTable.appendChild(row);
+                        }}
+                    }});
+                    toolInfoSection.appendChild(toolInfoTable);
+                    toolSection.appendChild(toolInfoSection);
+                }}
+
+                // 2. 工具参数
+                if (toolData.params && toolData.params.prompt) {{
+                    const paramsSection = document.createElement('div');
+                    paramsSection.style.marginBottom = '15px';
+                    const paramsTitle = document.createElement('div');
+                    paramsTitle.style.fontSize = '15px';
+                    paramsTitle.style.fontWeight = 'bold';
+                    paramsTitle.style.color = '#333';
+                    paramsTitle.style.marginBottom = '8px';
+                    paramsTitle.textContent = '工具参数';
+                    paramsSection.appendChild(paramsTitle);
+
+                    const paramsDiv = document.createElement('div');
+                    paramsDiv.className = 'detail-val';
+                    paramsDiv.style.background = '#f0f7ff';
+                    paramsDiv.style.padding = '12px';
+                    paramsDiv.style.borderRadius = '6px';
+                    paramsDiv.style.borderLeft = '3px solid #2196F3';
+                    paramsDiv.style.whiteSpace = 'pre-wrap';
+                    paramsDiv.style.wordBreak = 'break-word';
+                    paramsDiv.style.fontSize = '14px';
+                    paramsDiv.style.lineHeight = '1.6';
+                    paramsDiv.style.color = '#333';
+                    paramsDiv.textContent = toolData.params.prompt;
+                    paramsSection.appendChild(paramsDiv);
+                    toolSection.appendChild(paramsSection);
+                }}
+
+                // 3. 工具内容
+                if (toolData.content) {{
+                    const contentSection = document.createElement('div');
+                    contentSection.style.marginBottom = '15px';
+                    const contentTitle = document.createElement('div');
+                    contentTitle.style.fontSize = '16px';
+                    contentTitle.style.fontWeight = 'bold';
+                    contentTitle.style.color = '#333';
+                    contentTitle.style.marginBottom = '8px';
+                    contentTitle.textContent = '工具返回内容';
+                    contentSection.appendChild(contentTitle);
+
+                    const contentDiv = document.createElement('div');
+                    contentDiv.className = 'detail-val';
+                    contentDiv.style.background = '#f9f9f9';
+                    contentDiv.style.padding = '12px';
+                    contentDiv.style.borderRadius = '6px';
+                    contentDiv.style.borderLeft = '3px solid #2196F3';
+                    contentDiv.style.whiteSpace = 'pre-wrap';
+                    contentDiv.style.wordBreak = 'break-word';
+                    contentDiv.style.fontSize = '14px';
+                    contentDiv.style.lineHeight = '1.6';
+                    contentDiv.style.color = '#333';
+                    contentDiv.style.maxHeight = '400px';
+                    contentDiv.style.overflowY = 'auto';
+                    contentDiv.textContent = toolData.content;
+                    contentSection.appendChild(contentDiv);
+                    toolSection.appendChild(contentSection);
+                }}
+
+                // 4. 评估结果
+                if (toolData.evaluation) {{
+                    const evalSection = document.createElement('div');
+                    const evalTitle = document.createElement('div');
+                    evalTitle.style.fontSize = '16px';
+                    evalTitle.style.fontWeight = 'bold';
+                    evalTitle.style.color = '#333';
+                    evalTitle.style.marginBottom = '8px';
+                    evalTitle.textContent = '评估结果';
+                    evalSection.appendChild(evalTitle);
+
+                    const evalDiv = document.createElement('div');
+                    evalDiv.style.background = '#fff';
+                    evalDiv.style.padding = '12px';
+                    evalDiv.style.borderRadius = '6px';
+                    evalDiv.style.borderLeft = '3px solid #2196F3';
+                    evalDiv.style.marginTop = '8px';
+
+                    const eval = toolData.evaluation;
+                    
+                    // 匹配级别
+                    if (eval.match_level) {{
+                        const matchLevelColor = eval.match_level === "完全匹配" ? "#5ba85f" : 
+                                               eval.match_level === "部分匹配" ? "#ff9800" : "#999";
+                        const matchLevelDiv = document.createElement('div');
+                        matchLevelDiv.style.fontSize = '14px';
+                        matchLevelDiv.style.marginBottom = '8px';
+                        matchLevelDiv.innerHTML = '<strong>匹配级别:</strong> <strong style="color:' + matchLevelColor + '">' + escapeHtml(eval.match_level) + '</strong>';
+                        evalDiv.appendChild(matchLevelDiv);
+                    }}
+
+                    // 核心主体
+                    if (eval.core_subject) {{
+                        const coreSubjectDiv = document.createElement('div');
+                        coreSubjectDiv.style.fontSize = '14px';
+                        coreSubjectDiv.style.marginBottom = '8px';
+                        coreSubjectDiv.innerHTML = '<strong>核心主体:</strong> ' + escapeHtml(eval.core_subject);
+                        evalDiv.appendChild(coreSubjectDiv);
+                    }}
+
+                    // 核心事件
+                    if (eval.core_event) {{
+                        const coreEventDiv = document.createElement('div');
+                        coreEventDiv.style.fontSize = '14px';
+                        coreEventDiv.style.marginBottom = '8px';
+                        coreEventDiv.innerHTML = '<strong>核心事件:</strong> ' + escapeHtml(eval.core_event);
+                        evalDiv.appendChild(coreEventDiv);
+                    }}
+
+                    // 原因说明
+                    if (eval.reason) {{
+                        const reasonDiv = document.createElement('div');
+                        reasonDiv.style.fontSize = '14px';
+                        reasonDiv.style.marginTop = '8px';
+                        reasonDiv.style.paddingTop = '8px';
+                        reasonDiv.style.borderTop = '1px solid #eee';
+                        reasonDiv.innerHTML = '<strong>原因说明:</strong> ' + escapeHtml(eval.reason);
+                        evalDiv.appendChild(reasonDiv);
+                    }}
+
+                    evalSection.appendChild(evalDiv);
+                    toolSection.appendChild(evalSection);
+                }}
+
+                // 工具统计信息
+                if (detail.tools_count !== undefined || detail.successful_tools_count !== undefined) {{
+                    const statsSection = document.createElement('div');
+                    statsSection.style.marginTop = '12px';
+                    statsSection.style.paddingTop = '12px';
+                    statsSection.style.borderTop = '1px solid #eee';
+                    statsSection.style.fontSize = '13px';
+                    statsSection.style.color = '#666';
+                    
+                    if (detail.tools_count !== undefined) {{
+                        const toolsCountSpan = document.createElement('span');
+                        toolsCountSpan.textContent = '工具总数: ' + detail.tools_count;
+                        toolsCountSpan.style.marginRight = '15px';
+                        statsSection.appendChild(toolsCountSpan);
+                    }}
+                    if (detail.successful_tools_count !== undefined) {{
+                        const successfulToolsCountSpan = document.createElement('span');
+                        successfulToolsCountSpan.textContent = '成功工具数: ' + detail.successful_tools_count;
+                        statsSection.appendChild(successfulToolsCountSpan);
+                    }}
+                    toolSection.appendChild(statsSection);
+                }}
+
+                container.appendChild(toolBlock);
+            }});
+        }}
+
+        function renderRootDetail(detail, container) {{
+            if (!detail) return;
+
+            // 1. 帖子详情
+            const postSection = document.createElement('div');
+            postSection.className = 'root-detail-section';
+            const postTitle = document.createElement('div');
+            postTitle.className = 'root-detail-title';
+            postTitle.textContent = '1. 帖子详情';
+            postSection.appendChild(postTitle);
+            
+            // 显示帖子 ID(尝试多个可能的字段名)
+            const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
+            if (postId) {{
+                const postIdDiv = document.createElement('div');
+                postIdDiv.style.marginBottom = '10px';
+                postIdDiv.style.fontSize = '14px';
+                postIdDiv.style.color = '#666';
+                const postIdLabel = document.createElement('span');
+                postIdLabel.style.fontWeight = 'bold';
+                postIdLabel.textContent = '帖子 ID: ';
+                postIdDiv.appendChild(postIdLabel);
+                const postIdValue = document.createElement('span');
+                postIdValue.textContent = postId;
+                postIdDiv.appendChild(postIdValue);
+                postSection.appendChild(postIdDiv);
+            }}
+            
+            if (detail.title) {{
+                const titleDiv = document.createElement('div');
+                titleDiv.className = 'post-title';
+                titleDiv.textContent = detail.title;
+                postSection.appendChild(titleDiv);
+            }}
+            
+            if (detail.body_text) {{
+                const bodyDiv = document.createElement('div');
+                bodyDiv.className = 'post-body';
+                bodyDiv.textContent = detail.body_text;
+                postSection.appendChild(bodyDiv);
+            }}
+
+            const stats = document.createElement('div');
+            stats.className = 'post-stats';
+            if (detail.like_count !== null && detail.like_count !== undefined) {{
+                const likeSpan = document.createElement('span');
+                likeSpan.textContent = `❤️ ${{detail.like_count}}`;
+                stats.appendChild(likeSpan);
+            }}
+            if (detail.collect_count !== null && detail.collect_count !== undefined) {{
+                const collectSpan = document.createElement('span');
+                collectSpan.textContent = `⭐ ${{detail.collect_count}}`;
+                stats.appendChild(collectSpan);
+            }}
+            postSection.appendChild(stats);
+
+            if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
+                const gallery = document.createElement('div');
+                gallery.className = 'image-gallery';
+                detail.images.forEach(imgUrl => {{
+                    const img = document.createElement('img');
+                    img.className = 'image-item';
+                    img.src = imgUrl;
+                    img.addEventListener('click', function() {{
+                        // 可以在这里添加图片查看功能
+                    }});
+                    gallery.appendChild(img);
+                }});
+                postSection.appendChild(gallery);
+            }}
+
+            container.appendChild(postSection);
+
+            // 2. 选题结果
+            const topicSection = document.createElement('div');
+            topicSection.className = 'root-detail-section';
+            const topicTitle = document.createElement('div');
+            topicTitle.className = 'root-detail-title';
+            topicTitle.textContent = '2. 选题结果';
+            topicSection.appendChild(topicTitle);
+            const topicLink = document.createElement('a');
+            topicLink.className = 'jump-link';
+            topicLink.href = `${{accountName}}_标签匹配可视化.html`;
+            topicLink.target = '_blank';
+            topicLink.textContent = '选题匹配结果';
+            topicSection.appendChild(topicLink);
+            container.appendChild(topicSection);
+            
+            // 3. 选题点拆解(选题点: list[dict])
+            const selectionPoints = detail["选题点"];
+            if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
+                const selectionSection = document.createElement('div');
+                selectionSection.className = 'root-detail-section';
+                const selectionTitle = document.createElement('div');
+                selectionTitle.className = 'root-detail-title';
+                selectionTitle.textContent = '3. 选题点拆解';
+                selectionSection.appendChild(selectionTitle);
+
+                const table = document.createElement('table');
+                table.style.width = '100%';
+                table.style.borderCollapse = 'collapse';
+                table.style.fontSize = '13px';
+                table.style.marginTop = '8px';
+
+                // 表头
+                const thead = document.createElement('thead');
+                const headerRow = document.createElement('tr');
+                const headerStyle = (th) => {{
+                    th.style.borderBottom = '1px solid #eee';
+                    th.style.padding = '6px 8px';
+                    th.style.textAlign = 'left';
+                    th.style.color = '#555';
+                    th.style.background = '#fafafa';
+                }};
+
+                const thType = document.createElement('th');
+                thType.textContent = '类型';
+                headerStyle(thType);
+                headerRow.appendChild(thType);
+                const thTopic = document.createElement('th');
+                thTopic.textContent = '选题点';
+                headerStyle(thTopic);
+                headerRow.appendChild(thTopic);
+                const thSubstantial = document.createElement('th');
+                thSubstantial.textContent = '实质';
+                headerStyle(thSubstantial);
+                headerRow.appendChild(thSubstantial);
+                const thForm = document.createElement('th');
+                thForm.textContent = '形式';
+                headerStyle(thForm);
+                headerRow.appendChild(thForm);
+                const thIntent = document.createElement('th');
+                thIntent.textContent = '意图';
+                headerStyle(thIntent);
+                headerRow.appendChild(thIntent);
+                thead.appendChild(headerRow);
+                table.appendChild(thead);
+
+                // 表体
+                const tbody = document.createElement('tbody');
+                selectionPoints.forEach((item, idx) => {{
+                    if (!item || typeof item !== "object") return;
+                    const row = document.createElement('tr');
+                    row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
+
+                    const rowCellStyle = (td) => {{
+                        td.style.padding = '6px 8px';
+                        td.style.verticalAlign = 'top';
+                        td.style.color = '#666';
+                    }};
+
+                    const toJoined = (v) => {{
+                        if (Array.isArray(v)) return v.join('、');
+                        if (v === null || v === undefined) return "";
+                        return String(v);
+                    }};
+
+                    const tdType = document.createElement('td');
+                    tdType.textContent = item["类型"] || "";
+                    rowCellStyle(tdType);
+                    row.appendChild(tdType);
+                    const tdTopic = document.createElement('td');
+                    tdTopic.textContent = item["选题点"] || "";
+                    rowCellStyle(tdTopic);
+                    row.appendChild(tdTopic);
+                    const tdSubstantial = document.createElement('td');
+                    tdSubstantial.textContent = toJoined(item["实质"]);
+                    rowCellStyle(tdSubstantial);
+                    row.appendChild(tdSubstantial);
+                    const tdForm = document.createElement('td');
+                    tdForm.textContent = toJoined(item["形式"]);
+                    rowCellStyle(tdForm);
+                    row.appendChild(tdForm);
+                    const tdIntent = document.createElement('td');
+                    tdIntent.textContent = toJoined(item["意图"]);
+                    rowCellStyle(tdIntent);
+                    row.appendChild(tdIntent);
+                    tbody.appendChild(row);
+                }});
+                table.appendChild(tbody);
+                selectionSection.appendChild(table);
+                container.appendChild(selectionSection);
+            }}
+        }}
+
+        // 更新画布宽度以适应侧边栏
+        function updateCanvasWidth() {{
+            const sidebar = document.getElementById('sidebar');
+            const appContainer = document.getElementById('app-container');
+            const sidebarResizer = document.getElementById('sidebar-resizer');
+            
+            if (sidebar.classList.contains('active')) {{
+                const sidebarWidth = sidebar.offsetWidth;
+                appContainer.style.right = sidebarWidth + 'px';
+                appContainer.style.width = `calc(100% - ${{sidebarWidth}}px)`;
+                sidebarResizer.style.right = sidebarWidth + 'px';
+                sidebarResizer.classList.add('active');
+            }} else {{
+                appContainer.style.right = '';
+                appContainer.style.width = '';
+                sidebarResizer.style.right = '';
+                sidebarResizer.classList.remove('active');
+            }}
+        }}
+
+        function closeSidebar() {{
+            const sidebar = document.getElementById('sidebar');
+            const appContainer = document.getElementById('app-container');
+            sidebar.classList.remove('active');
+            appContainer.classList.remove('sidebar-open');
+            updateCanvasWidth();
+        }}
+
+        // 侧边栏拖拽调整宽度
+        const sidebarResizer = document.getElementById('sidebar-resizer');
+        const sidebar = document.getElementById('sidebar');
+        let isSidebarResizing = false;
+        let sidebarStartX = 0;
+        let sidebarStartWidth = 0;
+
+        sidebarResizer.addEventListener('mousedown', function(e) {{
+            if (!sidebar.classList.contains('active')) return;
+            isSidebarResizing = true;
+            sidebarStartX = e.clientX;
+            sidebarStartWidth = sidebar.offsetWidth;
+            document.body.classList.add('resizing');
+            e.preventDefault();
+        }});
+
+        document.addEventListener('mousemove', function(e) {{
+            if (!isSidebarResizing) return;
+            
+            const deltaX = sidebarStartX - e.clientX; // 向左拖拽增加宽度
+            const newWidth = sidebarStartWidth + deltaX;
+            const minWidth = 250;
+            const maxWidth = window.innerWidth * 0.6;
+            
+            if (newWidth >= minWidth && newWidth <= maxWidth) {{
+                sidebar.style.width = newWidth + 'px';
+                updateCanvasWidth();
+            }}
+        }});
+
+        document.addEventListener('mouseup', function() {{
+            if (isSidebarResizing) {{
+                isSidebarResizing = false;
+                document.body.classList.remove('resizing');
+            }}
+        }});
+
+        function openPostDetailModal() {{
+            const detail = postDetailMap[currentPostKey] || null;
+            const container = document.getElementById('post-detail-modal-content');
+            container.innerHTML = '';
+            if (!detail) {{
+                const empty = document.createElement('div');
+                empty.className = 'derivation-empty';
+                empty.textContent = '暂无当前帖子的详情数据';
+                container.appendChild(empty);
+            }} else {{
+                renderPostDetailForModal(detail, container);
+            }}
+            document.getElementById('post-detail-modal').classList.add('active');
+        }}
+        function closePostDetailModal() {{
+            document.getElementById('post-detail-modal').classList.remove('active');
+        }}
+        function renderPostDetailForModal(detail, container) {{
+            if (!detail) return;
+            const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
+            if (postId) {{
+                const div = document.createElement('div');
+                div.style.marginBottom = '10px'; div.style.fontSize = '14px'; div.style.color = '#666';
+                div.innerHTML = '<span style="font-weight:bold;">帖子 ID: </span>' + escapeHtml(postId);
+                container.appendChild(div);
+            }}
+            if (detail.title) {{
+                const titleDiv = document.createElement('div');
+                titleDiv.className = 'post-title';
+                titleDiv.textContent = detail.title;
+                container.appendChild(titleDiv);
+            }}
+            if (detail.body_text) {{
+                const bodyDiv = document.createElement('div');
+                bodyDiv.className = 'post-body';
+                bodyDiv.textContent = detail.body_text;
+                container.appendChild(bodyDiv);
+            }}
+            const stats = document.createElement('div');
+            stats.className = 'post-stats';
+            if (detail.like_count != null) {{ const s = document.createElement('span'); s.textContent = '❤️ ' + detail.like_count; stats.appendChild(s); }}
+            if (detail.collect_count != null) {{ const s = document.createElement('span'); s.textContent = '⭐ ' + detail.collect_count; stats.appendChild(s); }}
+            if (stats.childNodes.length) container.appendChild(stats);
+            if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
+                const gallery = document.createElement('div');
+                gallery.className = 'image-gallery';
+                detail.images.forEach((imgUrl, idx) => {{
+                    const img = document.createElement('img');
+                    img.className = 'image-item';
+                    img.src = imgUrl;
+                    img.addEventListener('click', function() {{ openImageLightbox(detail.images, idx); }});
+                    gallery.appendChild(img);
+                }});
+                container.appendChild(gallery);
+            }}
+            const selectionPoints = detail["选题点"];
+            if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
+                const sectionTitle = document.createElement('div');
+                sectionTitle.className = 'root-detail-title';
+                sectionTitle.textContent = '帖子选题表';
+                sectionTitle.style.marginTop = '16px';
+                container.appendChild(sectionTitle);
+                const table = document.createElement('table');
+                table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '13px'; table.style.marginTop = '8px';
+                const thead = document.createElement('thead');
+                const headerRow = document.createElement('tr');
+                ['类型','选题点','实质','形式','意图'].forEach(txt => {{
+                    const th = document.createElement('th');
+                    th.textContent = txt;
+                    th.style.borderBottom = '1px solid #eee'; th.style.padding = '6px 8px'; th.style.textAlign = 'left'; th.style.background = '#fafafa';
+                    headerRow.appendChild(th);
+                }});
+                thead.appendChild(headerRow); table.appendChild(thead);
+                const tbody = document.createElement('tbody');
+                const toJoined = (v) => Array.isArray(v) ? v.join('、') : (v == null || v === undefined ? '' : String(v));
+                selectionPoints.forEach((item, idx) => {{
+                    if (!item || typeof item !== 'object') return;
+                    const row = document.createElement('tr');
+                    row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
+                    const cellStyle = td => {{ td.style.padding = '6px 8px'; td.style.verticalAlign = 'top'; td.style.color = '#666'; }};
+                    [item["类型"]||"", item["选题点"]||"", toJoined(item["实质"]), toJoined(item["形式"]), toJoined(item["意图"])].forEach(text => {{
+                        const td = document.createElement('td');
+                        td.textContent = text;
+                        cellStyle(td);
+                        row.appendChild(td);
+                    }});
+                    tbody.appendChild(row);
+                }});
+                table.appendChild(tbody);
+                container.appendChild(table);
+            }}
+        }}
+
+        document.getElementById('btn-pending-decode-post').addEventListener('click', openPostDetailModal);
+        document.getElementById('post-detail-modal').addEventListener('click', function(e) {{
+            if (e.target === this) closePostDetailModal();
+        }});
+
+        // --- 图集大图灯箱(左右切换)---
+        let currentLightboxImages = [];
+        let currentLightboxIndex = 0;
+
+        function openImageLightbox(images, index) {{
+            if (!images || !images.length) return;
+            currentLightboxImages = images;
+            currentLightboxIndex = (index >= 0 && index < images.length) ? index : 0;
+            updateLightboxImage();
+            document.getElementById('image-lightbox').classList.add('active');
+            document.addEventListener('keydown', lightboxKeydown);
+        }}
+        function closeImageLightbox() {{
+            document.getElementById('image-lightbox').classList.remove('active');
+            document.removeEventListener('keydown', lightboxKeydown);
+        }}
+        function updateLightboxImage() {{
+            const img = document.getElementById('lightbox-img');
+            const counter = document.getElementById('lightbox-counter');
+            if (!currentLightboxImages.length) return;
+            const idx = ((currentLightboxIndex % currentLightboxImages.length) + currentLightboxImages.length) % currentLightboxImages.length;
+            currentLightboxIndex = idx;
+            img.src = currentLightboxImages[idx];
+            counter.textContent = (idx + 1) + ' / ' + currentLightboxImages.length;
+        }}
+        function lightboxPrev() {{
+            if (!currentLightboxImages.length) return;
+            currentLightboxIndex = (currentLightboxIndex - 1 + currentLightboxImages.length) % currentLightboxImages.length;
+            updateLightboxImage();
+        }}
+        function lightboxNext() {{
+            if (!currentLightboxImages.length) return;
+            currentLightboxIndex = (currentLightboxIndex + 1) % currentLightboxImages.length;
+            updateLightboxImage();
+        }}
+        function lightboxKeydown(e) {{
+            if (e.key === 'Escape') {{ closeImageLightbox(); return; }}
+            if (e.key === 'ArrowLeft') {{ lightboxPrev(); e.preventDefault(); return; }}
+            if (e.key === 'ArrowRight') {{ lightboxNext(); e.preventDefault(); return; }}
+        }}
+        document.getElementById('image-lightbox').addEventListener('click', function(e) {{
+            if (e.target === this) closeImageLightbox();
+        }});
+        document.querySelector('#image-lightbox .lightbox-img-wrap').addEventListener('click', function(e) {{ e.stopPropagation(); }});
+
+        function switchPost(val) {{
+            currentPostKey = val;
+            parseData(val);
+            calculateLayout();
+            renderNodes();
+            renderEdges();
+            updateTransform();
+            resetView();
+            renderDerivationProgress(val);
+        }}
+
+        function closeDimensionPatternsModal() {{
+            const modal = document.getElementById('dimension-patterns-modal');
+            if (modal) modal.classList.remove('active');
+        }}
+        function showDimensionPatternsModal(postId, roundNum) {{
+            const modal = document.getElementById('dimension-patterns-modal');
+            const body = document.getElementById('dimension-patterns-modal-body');
+            const titleEl = document.getElementById('dimension-patterns-modal-title');
+            if (!modal || !body) return;
+            const doc = dimensionAnalyzeData[postId];
+            if (!doc || !doc.rounds) {{
+                if (titleEl) titleEl.textContent = '维度 patterns';
+                body.innerHTML = '<p style="color:#64748b;">暂无整体推导维度分析数据</p>';
+                modal.classList.add('active');
+                return;
+            }}
+            const r = doc.rounds.find(function(x) {{ return x.round === roundNum; }});
+            const label = (roundNum === 0) ? '选起点' : ('第' + roundNum + '轮');
+            if (titleEl) titleEl.textContent = '维度patterns · ' + label;
+            if (!r || !r.patterns || !r.patterns.length) {{
+                body.innerHTML = '<p style="color:#64748b;">该轮暂无 patterns 数据</p>';
+                modal.classList.add('active');
+                return;
+            }}
+            let parts = [];
+            parts.push('<div class="dimension-patterns-title">共 ' + r.patterns.length + ' 条 pattern(is_derived 已高亮)</div>');
+            r.patterns.forEach(function(pat) {{
+                const items = pat.items || [];
+                const segs = items.map(function(it) {{
+                    const nm = escapeHtml(it.name || '');
+                    return it.is_derived ? '<span class="pattern-item-derived">' + nm + '</span>' : nm;
+                }});
+                parts.push('<div class="pattern-line">' + segs.join('<span class="pattern-plus"> + </span>') + '</div>');
+            }});
+            body.innerHTML = parts.join('');
+            modal.classList.add('active');
+        }}
+        
+        // 渲染推导进度
+        function renderDerivationProgress(fileKey) {{
+            const container = document.getElementById('derivation-progress-content');
+            // 从文件名中提取文件ID(去掉.json扩展名)
+            const fileId = fileKey.replace(/\.json$/, '');
+            const rounds = derivationData[fileId] || derivationData[fileKey];
+            
+            if (!rounds || !Array.isArray(rounds) || rounds.length === 0) {{
+                container.innerHTML = '<div class="derivation-empty">暂无推导进度数据</div>';
+                return;
+            }}
+            
+            // 收集所有已推导成功的选题点名称(用于判断是否为之前已点亮)
+            const allDerivedNames = new Set();
+            rounds.forEach(round => {{
+                const derived = round.推导成功的选题点 || [];
+                derived.forEach(p => {{
+                    if (p.name) allDerivedNames.add(p.name);
+                }});
+            }});
+            
+            let html = '<div class="derivation-timeline">';
+            
+            for (let ri = 0; ri < rounds.length; ri++) {{
+                const round = rounds[ri];
+                // 推导结果数据中轮次从 1 开始(第一轮=1);轮次 0 表示选起点
+                const roundLabel = (round.轮次 === 0) ? "选起点" : ("第" + round.轮次 + "轮");
+                const derived = round.推导成功的选题点 || [];
+                const underived = round.未推导成功的选题点 || [];
+                
+                // 获取当前轮次新推导成功的选题点名称
+                const newInRoundRaw = round.本次新推导成功的选题点 || [];
+                const newInRoundNames = new Set(newInRoundRaw.map(p => p.name));
+                
+                // 如果是第一轮(轮次0),所有推导成功的都是新点亮的
+                if (ri === 0) {{
+                    derived.forEach(p => {{ if (p.name) newInRoundNames.add(p.name); }});
+                }} else if (newInRoundNames.size === 0) {{
+                    // 如果没有本次新推导成功的,则找出在当前轮次首次出现的
+                    const prevDerivedNames = new Set();
+                    for (let i = 0; i < ri; i++) {{
+                        const prevDerived = rounds[i].推导成功的选题点 || [];
+                        prevDerived.forEach(p => {{ if (p.name) prevDerivedNames.add(p.name); }});
+                    }}
+                    derived.forEach(p => {{
+                        if (p.name && !prevDerivedNames.has(p.name)) {{
+                            newInRoundNames.add(p.name);
+                        }}
+                    }});
+                }}
+                
+                // 收集所有root_source
+                const allRootSources = new Set();
+                derived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
+                underived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
+                
+                const pointsByRoot = {{}};
+                const dimDataByRoot = {{}};
+                
+                // 处理推导成功的选题点
+                derived.forEach(p => {{
+                    if (!p.root_source) return;
+                    if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
+                    if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
+                    const dim = p.dimension || "实质";
+                    // 判断颜色:当前轮次新点亮的为黄色,之前已点亮的为绿色
+                    const cls = newInRoundNames.has(p.name) ? "derivation-topic-new" : "derivation-topic-derived";
+                    dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: cls, derivation_type: p.derivation_type || "", is_fully_derived: p.is_fully_derived }});
+                }});
+                
+                // 处理未推导成功的选题点(黑色)
+                underived.forEach(p => {{
+                    if (!p.root_source) return;
+                    if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
+                    if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
+                    const dim = p.dimension || "实质";
+                    dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: "derivation-topic-underedived", derivation_type: p.derivation_type || "" }});
+                }});
+                
+                // 按point类型排序
+                const pointOrder = {{ "灵感点": 0, "目的点": 1, "关键点": 2 }};
+                let rootSourceList = Array.from(allRootSources).sort((a, b) => {{
+                    const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
+                    const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
+                    if (pa !== pb) return pa - pb;
+                    return (a || "").localeCompare(b || "");
+                }});
+                // 整体推导结果里若未写入选题点(或解析不到分词),用待解构帖子详情中的选题表回填,避免表格无行
+                if (rootSourceList.length === 0) {{
+                    const pd = postDetailMap[fileKey];
+                    const rows = (pd && Array.isArray(pd["选题点"])) ? pd["选题点"] : [];
+                    for (let ri = 0; ri < rows.length; ri++) {{
+                        const row = rows[ri];
+                        if (!row || typeof row !== "object") continue;
+                        const rs = String(row["选题点"] || "").trim();
+                        if (!rs) continue;
+                        const pt = row["类型"] || "";
+                        pointsByRoot[rs] = pt;
+                        if (!dimDataByRoot[rs]) dimDataByRoot[rs] = {{ 实质: [], 形式: [], 意图: [] }};
+                        ["实质", "形式", "意图"].forEach(function(dim) {{
+                            const arr = row[dim];
+                            const list = Array.isArray(arr) ? arr : [];
+                            list.forEach(function(nm) {{
+                                const s = (typeof nm === "string") ? nm.trim() : String(nm || "").trim();
+                                if (!s) return;
+                                dimDataByRoot[rs][dim].push({{
+                                    name: s,
+                                    cls: "derivation-topic-baseline",
+                                    derivation_type: "",
+                                    is_fully_derived: undefined
+                                }});
+                            }});
+                        }});
+                    }}
+                    rootSourceList = Object.keys(pointsByRoot).sort((a, b) => {{
+                        const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
+                        const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
+                        if (pa !== pb) return pa - pb;
+                        return (a || "").localeCompare(b || "");
+                    }});
+                }}
+                
+                html += '<div class="derivation-round-block">';
+                html += '<div class="derivation-round-title">' + escapeHtml(roundLabel) + '</div>';
+                html += '<table class="derivation-table"><thead><tr>';
+                html += '<th class="col-type">类型</th><th class="col-source">选题点</th>';
+                html += '<th class="col-dim">实质</th><th class="col-dim">形式</th><th class="col-dim">意图</th>';
+                html += '</tr></thead><tbody>';
+                
+                rootSourceList.forEach(rs => {{
+                    const point = pointsByRoot[rs] || "";
+                    const dimData = dimDataByRoot[rs] || {{ 实质: [], 形式: [], 意图: [] }};
+                    html += '<tr>';
+                    html += '<td class="col-type">' + escapeHtml(point) + '</td>';
+                    html += '<td class="col-source">' + escapeHtml(rs) + '</td>';
+                    for (const dim of ["实质", "形式", "意图"]) {{
+                        const items = (dimData[dim] || []).sort((a, b) => (a.name || "").localeCompare(b.name || ""));
+                        html += '<td class="col-dim">';
+                        items.forEach(it => {{
+                            const searchIcon = (it.derivation_type === "search") ? ' <span class="derivation-topic-search-icon" title="外部寻找">🔍</span>' : '';
+                            const toolIcon = (it.derivation_type === "tool") ? ' <span class="derivation-topic-tool-icon" title="工具调用">🔧</span>' : '';
+                            // 只有推导成功的选题点可以点击(黄色和绿色),未推导成功的(黑色)不可点击
+                            const isClickable = it.cls === "derivation-topic-derived" || it.cls === "derivation-topic-new";
+                            const dataAttr = isClickable ? ' data-topic-name="' + (it.name || "").replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '"' : '';
+                            const notFullyClass = (it.is_fully_derived === false) ? ' derivation-topic-not-fully-derived' : '';
+                            html += '<span class="derivation-topic-item ' + it.cls + notFullyClass + '"' + dataAttr + '>' + escapeHtml(it.name) + searchIcon + toolIcon + '</span>';
+                        }});
+                        if (items.length === 0) html += '<span style="color:#999;">-</span>';
+                        html += '</td>';
+                    }}
+                    html += '</tr>';
+                }});
+                if (rootSourceList.length === 0) {{
+                    html += '<tr><td colspan="5" style="color:#94a3b8;text-align:center;padding:12px;">暂无选题表数据(请检查整体推导结果与 input 解构内容)</td></tr>';
+                }}
+                html += '</tbody></table>';
+                const _dimDoc = (dimensionAnalyzeData && dimensionAnalyzeData[fileId]) ? dimensionAnalyzeData[fileId] : null;
+                const _dimRounds = (_dimDoc && _dimDoc.rounds) ? _dimDoc.rounds : [];
+                const _dimForRound = _dimRounds.find(function(dr) {{ return dr.round === round.轮次; }}) || null;
+                html += '<div class="derivation-dimension-extra">';
+                if (_dimForRound) {{
+                    const _dd = (_dimForRound.derived_dims || []).map(function(d) {{
+                        if (d && typeof d === 'object') {{
+                            const tn = d.tree_node_name || '';
+                            const dim = d.dimension || '';
+                            const mp = d.matched_point || '';
+                            let s = tn;
+                            if (dim) {{
+                                s += '->' + dim;
+                            }}
+                            if (mp) {{
+                                s += '(' + mp + ')';
+                            }}
+                            return escapeHtml(s);
+                        }}
+                        return escapeHtml(String(d));
+                    }}).join('、');
+                    const _ud = (_dimForRound.underived_dims || []).map(function(d) {{
+                        if (d && typeof d === 'object') {{
+                            const tn = d.tree_node_name || '';
+                            const dim = d.dimension || '';
+                            const mp = d.matched_point || '';
+                            let s = tn;
+                            if (dim) {{
+                                s += '->' + dim;
+                            }}
+                            if (mp) {{
+                                s += '(' + mp + ')';
+                            }}
+                            return escapeHtml(s);
+                        }}
+                        return escapeHtml(String(d));
+                    }}).join('、');
+                    html += '<div class="derivation-dim-line"><span class="derivation-dim-label">已推导维度</span> <span class="derivation-dim-val dim-derived">' + (_dd || '—') + '</span></div>';
+                    html += '<div class="derivation-dim-line"><span class="derivation-dim-label">未推导维度</span> <span class="derivation-dim-val dim-underived">' + (_ud || '—') + '</span></div>';
+                }} else {{
+                    html += '<div class="derivation-dim-line dim-muted">暂无与本轮对应的整体推导维度分析</div>';
+                }}
+                const _pidAttr = String(fileId).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
+                html += '<button type="button" class="btn-dimension-patterns" data-post-id="' + _pidAttr + '" data-round="' + String(round.轮次) + '">维度patterns</button>';
+                html += '</div>';
+                html += '</div>';
+            }}
+            html += '</div>';
+            container.innerHTML = html;
+            
+            container.querySelectorAll('.btn-dimension-patterns').forEach(function(el) {{
+                el.addEventListener('click', function() {{
+                    const pid = this.getAttribute('data-post-id');
+                    const rn = parseInt(this.getAttribute('data-round'), 10);
+                    showDimensionPatternsModal(pid, rn);
+                }});
+            }});
+            // 添加点击事件:点击已推导成功的选题点,在画布中定位(只关联node_list中的节点)
+            container.querySelectorAll('.derivation-topic-item[data-topic-name]').forEach(el => {{
+                el.addEventListener('click', function() {{
+                    const topicName = this.getAttribute('data-topic-name');
+                    if (topicName) {{
+                        focusOnNodeByName(topicName);
+                    }}
+                }});
+            }});
+        }}
+        
+        // 根据选题点名称定位节点(只关联node_list中的节点,不关联all_used_tree_nodes)
+        function focusOnNodeByName(topicName) {{
+            // 只在node_list中查找,排除level -1的节点(all_used_tree_nodes)
+            let node = null;
+            for (let level in flatData.nodesByLevel) {{
+                const levelNum = parseInt(level);
+                if (levelNum !== -1) {{  // 排除level -1的节点
+                    const found = flatData.nodesByLevel[levelNum].find(n => n.name === topicName);
+                    if (found) {{
+                        node = found;
+                        break;
+                    }}
+                }}
+            }}
+            
+            if (node) {{
+                focusOnNode(node);
+                highlightDirectSources(node);
+            }} else {{
+                // 如果在当前数据中找不到,尝试搜索
+                const searchInput = document.getElementById('search-input');
+                if (searchInput) {{
+                    searchInput.value = topicName;
+                    // 再次搜索,排除level -1
+                    for (let level in flatData.nodesByLevel) {{
+                        const levelNum = parseInt(level);
+                        if (levelNum !== -1) {{
+                            const match = flatData.nodesByLevel[levelNum].find(n => n.name.toLowerCase().includes(topicName.toLowerCase()));
+                            if (match) {{
+                                focusOnNode(match);
+                                highlightDirectSources(match);
+                                return;
+                            }}
+                        }}
+                    }}
+                }}
+            }}
+        }}
+        
+        // 切换推导进度显示
+        function toggleDerivationProgress() {{
+            const section = document.getElementById('derivation-progress-section');
+            const appContainer = document.getElementById('app-container');
+            const btn = document.querySelector('.derivation-progress-toggle');
+            if (section.classList.contains('active')) {{
+                section.classList.remove('active');
+                appContainer.classList.remove('derivation-open');
+                appContainer.style.bottom = '';
+                btn.textContent = '展开';
+            }} else {{
+                section.classList.add('active');
+                appContainer.classList.add('derivation-open');
+                // 设置画布底部边距为推导进度面板的高度
+                const sectionHeight = section.offsetHeight;
+                appContainer.style.bottom = sectionHeight + 'px';
+                btn.textContent = '收起';
+            }}
+        }}
+
+        // 推导进度面板高度拖拽调整
+        (function() {{
+            const resizer = document.getElementById('derivation-resizer');
+            const section = document.getElementById('derivation-progress-section');
+            const appContainer = document.getElementById('app-container');
+            let isResizing = false;
+            let startY = 0;
+            let startHeight = 0;
+
+            resizer.addEventListener('mousedown', function(e) {{
+                isResizing = true;
+                startY = e.clientY;
+                startHeight = section.offsetHeight;
+                resizer.classList.add('active');
+                document.body.classList.add('resizing');
+                section.style.transition = 'none';
+                appContainer.style.transition = 'none';
+                e.preventDefault();
+            }});
+
+            document.addEventListener('mousemove', function(e) {{
+                if (!isResizing) return;
+                const delta = startY - e.clientY;
+                const minH = 200;
+                const maxH = Math.floor(window.innerHeight * 0.8);
+                const newHeight = Math.min(maxH, Math.max(minH, startHeight + delta));
+                section.style.height = newHeight + 'px';
+                if (section.classList.contains('active')) {{
+                    appContainer.style.bottom = newHeight + 'px';
+                }}
+            }});
+
+            document.addEventListener('mouseup', function() {{
+                if (isResizing) {{
+                    isResizing = false;
+                    resizer.classList.remove('active');
+                    document.body.classList.remove('resizing');
+                    section.style.transition = '';
+                    appContainer.style.transition = '';
+                }}
+            }});
+        }})();
+
+        // 初始化
+        parseData(currentPostKey);
+        calculateLayout();
+        renderNodes();
+        renderEdges();
+        updateTransform();
+        renderDerivationProgress(currentPostKey);
+    </script>
+</body>
+</html>
+'''
+    with open(output_path, 'w', encoding='utf-8') as f:
+        f.write(html_content)
+    print(f"最终结果可视化已生成: {output_path}")
+
+def main(account_name) -> None:
+    name = account_name
+
+    base = Path(__file__).resolve().parent
+    output_base = base / "output" / name
+    data_dir = output_base / "整体推导路径可视化"
+
+    if not data_dir.exists():
+        print(f"错误: 找不到数据目录 {data_dir}")
+        return
+
+    json_files = sorted(f for f in os.listdir(data_dir) if f.endswith(".json"))
+    if not json_files:
+        print(f"在目录 {data_dir} 中未找到 .json 文件。")
+        return
+
+    data_map: Dict[str, dict] = {}
+    print("\n" + "=" * 50)
+    print(f"账号: {name}")
+    print(f"数据目录: {data_dir}")
+    print(f"正在读取 {len(json_files)} 个帖子数据...")
+
+    for filename in json_files:
+        json_path = data_dir / filename
+        try:
+            with open(json_path, "r", encoding="utf-8") as f:
+                data_map[filename] = json.load(f)
+            print(f"  -> 已读取: {filename}")
+        except Exception as e:
+            print(f"     [错误] 读取 {filename} 时出错: {e}")
+
+    if not data_map:
+        print("没有成功读取到任何数据。")
+        return
+
+    post_detail_map: Dict[str, dict] = {}
+    for filename in data_map.keys():
+        post_id = Path(filename).stem
+        try:
+            detail = load_post_detail_for_visualization(name, post_id)
+            if detail is not None:
+                post_detail_map[filename] = detail
+        except Exception as e:
+            print(f"     [警告] 加载帖子详情 {filename} 时出错: {e}")
+
+    derivation_dir = output_base / "整体推导结果"
+    derivation_data: Dict[str, list] = {}
+    if derivation_dir.exists():
+        print("\n正在读取推导进度数据...")
+        for json_file in derivation_dir.glob("*.json"):
+            try:
+                with open(json_file, "r", encoding="utf-8") as f:
+                    derivation_data[json_file.stem] = json.load(f)
+                print(f"  -> 已加载推导进度: {json_file.name}")
+            except Exception as e:
+                print(f"     [警告] 读取推导进度 {json_file.name} 时出错: {e}")
+    else:
+        print(f"     [提示] 推导结果目录不存在: {derivation_dir}")
+
+    dimension_analyze_dir = output_base / "整体推导维度分析"
+    dimension_analyze_map: Dict[str, dict] = {}
+    if dimension_analyze_dir.exists():
+        print("\n正在读取整体推导维度分析...")
+        suf = "_pattern_dimension_analyze"
+        for json_file in sorted(dimension_analyze_dir.glob(f"*{suf}.json")):
+            stem = json_file.stem
+            if not stem.endswith(suf):
+                continue
+            post_id_key = stem[: -len(suf)]
+            try:
+                with open(json_file, "r", encoding="utf-8") as f:
+                    dimension_analyze_map[post_id_key] = json.load(f)
+                print(f"  -> 已加载维度分析: {json_file.name}")
+            except Exception as e:
+                print(f"     [警告] 读取维度分析 {json_file.name} 时出错: {e}")
+    else:
+        print(f"     [提示] 整体推导维度分析目录不存在: {dimension_analyze_dir}")
+
+    output_base.mkdir(parents=True, exist_ok=True)
+    ts = datetime.now().strftime("%Y%m%d%H%M%S")
+    output_path = output_base / f"{name}_how推导可视化_{ts}.html"
+
+    generate_all_in_one_visualization(
+        data_map,
+        str(output_path),
+        name,
+        derivation_data=derivation_data,
+        post_detail_map=post_detail_map,
+        dimension_analyze_map=dimension_analyze_map,
+    )
+
+    print("\n" + "=" * 50)
+    print("处理完成!")
+    print(f"输出文件: {output_path}")
+    print("=" * 50 + "\n")
+
+
+if __name__ == "__main__":
+    main(account_name="空间点阵设计研究室")

+ 61 - 0
mysql_test.py

@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+from dotenv import find_dotenv, load_dotenv
+
+from examples_how.db_utils.errors import MySQLBaseException
+from examples_how.db_utils.mysql_db import MySQLDB
+from examples_how.db_utils.mysql_manager import MySQLClientManager
+
+
+def _print_rows_preview(title: str, rows: List[Dict[str, Any]], preview: int = 5) -> None:
+    print(f"\n=== {title} ===")
+    print(f"total: {len(rows)}")
+    for i, r in enumerate(rows[:preview], start=1):
+        print(f"[{i}] {r}")
+    if len(rows) > preview:
+        print(f"... (show first {preview} rows)")
+
+
+def main() -> None:
+    dotenv_path = find_dotenv()
+    if dotenv_path:
+        load_dotenv(dotenv_path)
+
+    # 从 .env 的 MYSQL_SOURCES_INFO 自动构建多 source 的 manager
+    manager = MySQLClientManager.from_env_sources_info(
+        env_var="MYSQL_SOURCES_INFO", dotenv_path=dotenv_path, allow_fallback_single_source=False
+    )
+
+    db_default = MySQLDB(manager=manager, source="default")
+    db_pattern = MySQLDB(manager=manager, source="pattern")
+
+    # 1) default: post 前 10 条
+    post_rows = db_default.select("post", columns="*", limit=10)
+    _print_rows_preview("default.post (first 10)", post_rows, preview=10)
+
+    # 2) pattern: topic_pattern_mining_config 全部
+    configs_rows = db_pattern.select("topic_pattern_mining_config", columns="*")
+    _print_rows_preview(
+        "pattern.topic_pattern_mining_config (all, preview)",
+        configs_rows,
+        preview=5,
+    )
+
+    # 3) pattern: topic_build_log 一条
+    build_log_row = db_pattern.select_one("topic_build_log", columns="*", where="")
+    print("\n=== pattern.topic_build_log (one) ===")
+    print(build_log_row)
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except MySQLBaseException as e:
+        print(f"MySQL 操作失败: {e.message}")
+        if getattr(e, "original_error", None):
+            print(f"original_error: {e.original_error}")
+    except Exception as e:
+        print(f"脚本运行失败: {e}")
+