chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.) - Add claude hooks (pre-commit-guard, post-edit-quality) - Add research templates (experiment plan, research brief, etc.) - Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa) - Add TRM4 reference implementation as algorithm fidelity baseline - Add research-wiki content (plans, index, graph, query_pack) - Update .gitignore to exclude .graphify_version runtime state
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Video-Tree-TRM 核心包
|
||||
=====================
|
||||
结合 TRM 多层对话探索能力与 PageIndex 树状检索能力的新型 Video RAG。
|
||||
"""
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.embeddings import EmbeddingModel
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.text_tree_builder import TextTreeBuilder
|
||||
from video_tree_trm.tree_index import (
|
||||
IndexMeta,
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"EmbeddingModel",
|
||||
"LLMClient",
|
||||
"TextTreeBuilder",
|
||||
"IndexMeta",
|
||||
"L1Node",
|
||||
"L2Node",
|
||||
"L3Node",
|
||||
"TreeIndex",
|
||||
]
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
配置管理模块
|
||||
============
|
||||
定义所有超参数的 dataclass 类型(无默认值)+ 多源加载。
|
||||
|
||||
三层优先级: CLI args > .env > YAML,统一归口到 Config dataclass。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
cfg = Config.load("config/default.yaml", cli_args={"retriever.num_heads": "8"})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子配置 Dataclass(全部无默认值,YAML 必须写全)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeConfig:
|
||||
"""树索引构建参数。
|
||||
|
||||
属性:
|
||||
max_paragraphs_per_l2: 每个 L2 节点包含的最大段落数(文本模式)。
|
||||
l1_segment_duration: L1 段时长,秒(视频模式)。
|
||||
l2_clip_duration: L2 clip 时长,秒(视频模式)。
|
||||
l3_fps: L3 帧提取频率(视频模式)。
|
||||
l2_representative_frames: L2 VLM 描述用的代表帧数。
|
||||
cache_dir: TreeIndex 缓存目录。
|
||||
"""
|
||||
|
||||
max_paragraphs_per_l2: int
|
||||
l1_segment_duration: float
|
||||
l2_clip_duration: float
|
||||
l3_fps: float
|
||||
l2_representative_frames: int
|
||||
cache_dir: str
|
||||
concurrency: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbedConfig:
|
||||
"""嵌入模型参数。
|
||||
|
||||
属性:
|
||||
backend: 嵌入后端类型,"local"(sentence-transformers)或 "remote"(OpenAI 兼容 API)。
|
||||
model_name: 本地模式为 HuggingFace 模型名,远程模式为 API 模型名。
|
||||
embed_dim: 嵌入维度 D。
|
||||
device: 推理设备,"cuda" 或 "cpu"(仅本地模式使用)。
|
||||
api_key: 远程模式 API 密钥,从 .env 加载。本地模式为空串。
|
||||
api_url: 远程模式 API 端点。本地模式为空串。
|
||||
"""
|
||||
|
||||
backend: str
|
||||
model_name: str
|
||||
embed_dim: int
|
||||
device: str
|
||||
api_key: str
|
||||
api_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""大语言模型参数。
|
||||
|
||||
属性:
|
||||
backend: 后端类型,"qwen" | "openai" | "ollama"。
|
||||
api_key: API 密钥,从 .env 加载。
|
||||
model: 模型名称。
|
||||
api_url: API 端点 URL。
|
||||
max_tokens: 最大生成 token 数。
|
||||
temperature: 采样温度。
|
||||
"""
|
||||
|
||||
backend: str
|
||||
api_key: str
|
||||
model: str
|
||||
api_url: str
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLMConfig:
|
||||
"""视觉语言模型参数。
|
||||
|
||||
属性:
|
||||
backend: 后端类型,"qwen" | "openai" | "ollama"。
|
||||
api_key: API 密钥,从 .env 加载。
|
||||
model: 模型名称。
|
||||
api_url: API 端点 URL。
|
||||
max_tokens: 最大生成 token 数。
|
||||
temperature: 采样温度。
|
||||
"""
|
||||
|
||||
backend: str
|
||||
api_key: str
|
||||
model: str
|
||||
api_url: str
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetrieverConfig:
|
||||
"""TRM 检索器参数。
|
||||
|
||||
属性:
|
||||
embed_dim: 嵌入维度,须与 EmbedConfig.embed_dim 一致。
|
||||
num_heads: Cross-Attention 头数。
|
||||
L_layers: ReasoningModule 层数。
|
||||
L_cycles: 每级推理迭代次数。
|
||||
max_rounds: ACT 最大遍历轮次。
|
||||
ffn_expansion: SwiGLU 扩展比。
|
||||
checkpoint: 训练好的模型权重路径,推理时必填。
|
||||
"""
|
||||
|
||||
embed_dim: int
|
||||
num_heads: int
|
||||
L_layers: int
|
||||
L_cycles: int
|
||||
max_rounds: int
|
||||
ffn_expansion: float
|
||||
checkpoint: Optional[str]
|
||||
# 多路径检索配置 (Top-k)
|
||||
k_l1: int = 1
|
||||
k_l2: int = 1
|
||||
k_l3: int = 1
|
||||
max_paths: int = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainConfig:
|
||||
"""训练参数。
|
||||
|
||||
属性:
|
||||
lr: 学习率。
|
||||
weight_decay: 权重衰减。
|
||||
batch_size: 批大小。
|
||||
max_epochs_phase1: Phase 1 导航训练轮数。
|
||||
max_epochs_phase2: Phase 2 ACT 训练轮数。
|
||||
nav_loss_weight: 导航损失权重。
|
||||
act_loss_weight: ACT 损失权重。
|
||||
act_lambda_step: ACT 步数惩罚系数。
|
||||
act_gamma: ACT 折扣因子。
|
||||
eval_interval: 每 N epoch 评估一次。
|
||||
save_dir: 模型权重保存目录。
|
||||
dataset: 数据集名称,"longbench" | "narrativeqa" | "videomme"。
|
||||
dataset_path: 数据集路径。
|
||||
"""
|
||||
|
||||
lr: float
|
||||
weight_decay: float
|
||||
batch_size: int
|
||||
max_epochs_phase1: int
|
||||
max_epochs_phase2: int
|
||||
nav_loss_weight: float
|
||||
act_loss_weight: float
|
||||
margin_loss_weight: float
|
||||
act_lambda_step: float
|
||||
act_gamma: float
|
||||
eval_interval: int
|
||||
save_dir: str
|
||||
dataset: str
|
||||
dataset_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HierRetrieverConfig:
|
||||
"""Hierarchical Cross-Encoder 检索器参数。
|
||||
|
||||
属性:
|
||||
backbone_model: 预训练语言模型名称。
|
||||
num_heads: Cross-Attention 头数。
|
||||
hidden_dim: 隐藏层维度。
|
||||
dropout: Dropout 概率。
|
||||
use_query_dep_weights: 是否使用 Query-dependent 层级权重。
|
||||
level_weight_type: 层级权重类型,"fixed" | "query_dependent" | "hybrid"。
|
||||
|
||||
# Stage 1 参数
|
||||
stage1_epochs: Stage 1 训练轮数。
|
||||
stage1_lr: Stage 1 学习率。
|
||||
stage1_batch_size: Stage 1 批大小。
|
||||
stage1_num_negatives: Stage 1 每样本的负例数量。
|
||||
stage1_temperature: Stage 1 温度参数。
|
||||
|
||||
# Stage 2 参数
|
||||
stage2_epochs: Stage 2 训练轮数。
|
||||
stage2_lr: Stage 2 学习率。
|
||||
stage2_batch_size: Stage 2 批大小。
|
||||
stage2_num_negatives: Stage 2 每样本的负例数量。
|
||||
stage2_temperature: Stage 2 温度参数。
|
||||
stage2_hard_neg_update_freq: Stage 2 硬负例更新频率。
|
||||
stage2_hier_loss_weight: Stage 2 层级一致性损失权重。
|
||||
|
||||
# Stage 3 参数
|
||||
stage3_enabled: 是否启用 Stage 3。
|
||||
stage3_epochs: Stage 3 训练轮数。
|
||||
stage3_lr: Stage 3 学习率。
|
||||
|
||||
# 推理参数
|
||||
coarse_top_k: 粗排候选数量。
|
||||
fine_top_k: 精排返回数量。
|
||||
use_bm25: 是否使用 BM25 辅助粗排。
|
||||
"""
|
||||
|
||||
# 模型参数
|
||||
backbone_model: str
|
||||
num_heads: int
|
||||
hidden_dim: int
|
||||
dropout: float
|
||||
use_query_dep_weights: bool
|
||||
level_weight_type: str
|
||||
|
||||
# Stage 1
|
||||
stage1_epochs: int
|
||||
stage1_lr: float
|
||||
stage1_batch_size: int
|
||||
stage1_num_negatives: int
|
||||
stage1_temperature: float
|
||||
|
||||
# Stage 2
|
||||
stage2_epochs: int
|
||||
stage2_lr: float
|
||||
stage2_batch_size: int
|
||||
stage2_num_negatives: int
|
||||
stage2_temperature: float
|
||||
stage2_hard_neg_update_freq: int
|
||||
stage2_hier_loss_weight: float
|
||||
|
||||
# Stage 3
|
||||
stage3_enabled: bool
|
||||
stage3_epochs: int
|
||||
stage3_lr: float
|
||||
|
||||
# 推理
|
||||
coarse_top_k: int
|
||||
fine_top_k: int
|
||||
use_bm25: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SECTION_TO_CLASS: dict[str, type] = {
|
||||
"tree": TreeConfig,
|
||||
"embed": EmbedConfig,
|
||||
"llm": LLMConfig,
|
||||
"vlm": VLMConfig,
|
||||
"retriever": RetrieverConfig,
|
||||
"train": TrainConfig,
|
||||
}
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict) -> dict:
|
||||
"""递归合并字典,override 优先覆盖 base。
|
||||
|
||||
参数:
|
||||
base: 基础字典。
|
||||
override: 覆盖字典。
|
||||
|
||||
返回:
|
||||
合并后的新字典。
|
||||
"""
|
||||
merged = base.copy()
|
||||
for key, value in override.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _apply_dotpath(d: dict, key: str, value: Any) -> None:
|
||||
"""通过点路径设置嵌套字典的值。
|
||||
|
||||
支持 "retriever.num_heads" 风格的路径,自动拆分并逐级写入。
|
||||
|
||||
参数:
|
||||
d: 目标字典。
|
||||
key: 点分隔的路径,如 "retriever.num_heads"。
|
||||
value: 要设置的值。
|
||||
"""
|
||||
parts = key.split(".")
|
||||
current = d
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
|
||||
|
||||
def _coerce_value(raw: str, target_type: type) -> Any:
|
||||
"""将 CLI 字符串值转换为目标类型。
|
||||
|
||||
参数:
|
||||
raw: 原始字符串值。
|
||||
target_type: 目标 Python 类型。
|
||||
|
||||
返回:
|
||||
转换后的值。
|
||||
"""
|
||||
if target_type is bool:
|
||||
return raw.lower() in ("true", "1", "yes")
|
||||
if target_type is type(None):
|
||||
return None if raw.lower() in ("none", "null", "") else raw
|
||||
return target_type(raw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 顶层配置
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""全局配置容器。
|
||||
|
||||
统一管理所有子模块配置,通过 ``Config.load()`` 加载。
|
||||
|
||||
属性:
|
||||
tree: 树索引构建参数。
|
||||
embed: 嵌入模型参数。
|
||||
llm: 大语言模型参数。
|
||||
vlm: 视觉语言模型参数。
|
||||
retriever: TRM 检索器参数。
|
||||
train: 训练参数。
|
||||
hier_retriever: Hierarchical Cross-Encoder 检索器参数。
|
||||
"""
|
||||
|
||||
tree: TreeConfig
|
||||
embed: EmbedConfig
|
||||
llm: LLMConfig
|
||||
vlm: VLMConfig
|
||||
retriever: RetrieverConfig
|
||||
train: TrainConfig
|
||||
hier_retriever: Optional[HierRetrieverConfig] = None
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
yaml_path: str,
|
||||
cli_args: Optional[dict[str, str]] = None,
|
||||
env_path: Optional[str] = None,
|
||||
) -> "Config":
|
||||
"""三层合并加载配置。
|
||||
|
||||
优先级: CLI args > .env > YAML。
|
||||
|
||||
参数:
|
||||
yaml_path: YAML 配置文件路径。
|
||||
cli_args: CLI 覆盖参数,键为点路径(如 "retriever.num_heads"),值为字符串。
|
||||
env_path: .env 文件路径,默认为项目根目录的 .env。
|
||||
|
||||
返回:
|
||||
完整的 Config 实例。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: YAML 文件不存在。
|
||||
TypeError: YAML 中缺少必需字段。
|
||||
"""
|
||||
# Phase 1: 读取 YAML
|
||||
yaml_file = Path(yaml_path)
|
||||
if not yaml_file.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {yaml_path}")
|
||||
|
||||
with open(yaml_file, encoding="utf-8") as f:
|
||||
base_dict: dict = yaml.safe_load(f)
|
||||
|
||||
# Phase 2: 读取 .env 覆盖敏感字段
|
||||
if env_path is None:
|
||||
env_file = Path(yaml_path).parent.parent / ".env"
|
||||
else:
|
||||
env_file = Path(env_path)
|
||||
|
||||
if env_file.exists():
|
||||
env_vars = dotenv_values(str(env_file))
|
||||
env_overrides: dict[str, dict[str, str]] = {}
|
||||
# .env 变量名 → (配置节, 字段名) 的映射
|
||||
_ENV_MAP: dict[str, tuple[str, str]] = {
|
||||
"LLM_API_KEY": ("llm", "api_key"),
|
||||
"LLM_MODEL": ("llm", "model"),
|
||||
"LLM_API_URL": ("llm", "api_url"),
|
||||
"VLM_API_KEY": ("vlm", "api_key"),
|
||||
"VLM_MODEL": ("vlm", "model"),
|
||||
"VLM_API_URL": ("vlm", "api_url"),
|
||||
"EMBED_BACKEND": ("embed", "backend"),
|
||||
"EMBED_MODEL": ("embed", "model_name"),
|
||||
"EMBED_API_KEY": ("embed", "api_key"),
|
||||
"EMBED_API_URL": ("embed", "api_url"),
|
||||
}
|
||||
for env_name, (section, field) in _ENV_MAP.items():
|
||||
if env_vars.get(env_name):
|
||||
env_overrides.setdefault(section, {})[field] = env_vars[env_name]
|
||||
base_dict = _deep_merge(base_dict, env_overrides)
|
||||
|
||||
# Phase 3: CLI args 覆盖
|
||||
if cli_args:
|
||||
for dotpath, value in cli_args.items():
|
||||
_apply_dotpath(base_dict, dotpath, value)
|
||||
|
||||
# Phase 4: 构造 dataclass(缺字段自动抛 TypeError)
|
||||
sections = {}
|
||||
for section_name, dc_class in _SECTION_TO_CLASS.items():
|
||||
section_data = base_dict.get(section_name, {})
|
||||
if not isinstance(section_data, dict):
|
||||
# 对于 Optional 的 section,跳过
|
||||
if section_name == "hier_retriever":
|
||||
continue
|
||||
raise TypeError(
|
||||
f"配置节 '{section_name}' 必须是字典,实际为 {type(section_data)}"
|
||||
)
|
||||
sections[section_name] = dc_class(**section_data)
|
||||
|
||||
config = cls(**sections)
|
||||
|
||||
# 校验: embed_dim 一致性
|
||||
ensure(
|
||||
config.embed.embed_dim == config.retriever.embed_dim,
|
||||
f"embed.embed_dim ({config.embed.embed_dim}) 与 "
|
||||
f"retriever.embed_dim ({config.retriever.embed_dim}) 不一致",
|
||||
)
|
||||
|
||||
log_msg("INFO", "配置加载完成", yaml=yaml_path)
|
||||
return config
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
嵌入服务模块
|
||||
============
|
||||
封装文本嵌入器,支持本地 sentence-transformers 和远程 OpenAI 兼容 API 两种后端。
|
||||
提供统一的 ``embed()`` / ``embed_tensor()`` 接口,冻结不训练。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.embeddings import EmbeddingModel
|
||||
from video_tree_trm.config import Config
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
model = EmbeddingModel(cfg.embed)
|
||||
vecs = model.embed(["你好世界"]) # ndarray [1, D]
|
||||
tens = model.embed_tensor(["你好"]) # Tensor [1, D]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from numpy import ndarray
|
||||
from torch import Tensor
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
from video_tree_trm.config import EmbedConfig
|
||||
|
||||
|
||||
class EmbeddingModel:
|
||||
"""文本嵌入器封装(冻结),支持本地和远程双后端。
|
||||
|
||||
本地模式: 使用 sentence-transformers 加载 HuggingFace 模型,本地推理。
|
||||
远程模式: 调用 OpenAI 兼容 API(如 GPUStack 上的 qwen3-embedding)。
|
||||
|
||||
属性:
|
||||
dim: 嵌入维度 D。
|
||||
"""
|
||||
|
||||
def __init__(self, config: EmbedConfig) -> None:
|
||||
"""初始化嵌入模型。
|
||||
|
||||
参数:
|
||||
config: 嵌入配置,包含 backend、model_name、embed_dim 等。
|
||||
|
||||
异常:
|
||||
ValueError: backend 不是 "local" 或 "remote"。
|
||||
ValueError: 远程模式缺少 api_key 或 api_url。
|
||||
"""
|
||||
ensure(
|
||||
config.backend in ("local", "remote"),
|
||||
f"embed.backend 必须为 'local' 或 'remote',实际为 '{config.backend}'",
|
||||
)
|
||||
self._backend = config.backend
|
||||
self._dim = config.embed_dim
|
||||
|
||||
if self._backend == "local":
|
||||
self._init_local(config)
|
||||
else:
|
||||
self._init_remote(config)
|
||||
|
||||
log_msg(
|
||||
"INFO", "嵌入模型初始化完成", backend=self._backend, model=config.model_name
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 初始化
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_local(self, config: EmbedConfig) -> None:
|
||||
"""初始化本地 sentence-transformers 模型。
|
||||
|
||||
参数:
|
||||
config: 嵌入配置。
|
||||
"""
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
self._model = SentenceTransformer(config.model_name, device=config.device)
|
||||
self._model.eval()
|
||||
# 冻结所有参数
|
||||
for param in self._model.parameters():
|
||||
param.requires_grad = False
|
||||
|
||||
actual_dim = self._model.get_sentence_embedding_dimension()
|
||||
ensure(
|
||||
actual_dim == self._dim,
|
||||
f"模型实际维度 ({actual_dim}) 与配置 embed_dim ({self._dim}) 不一致",
|
||||
)
|
||||
|
||||
def _init_remote(self, config: EmbedConfig) -> None:
|
||||
"""初始化远程 OpenAI 兼容 API 客户端。
|
||||
|
||||
参数:
|
||||
config: 嵌入配置。
|
||||
"""
|
||||
ensure(bool(config.api_key), "远程模式必须提供 embed.api_key")
|
||||
ensure(bool(config.api_url), "远程模式必须提供 embed.api_url")
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
self._client = OpenAI(base_url=config.api_url, api_key=config.api_key)
|
||||
self._model_name = config.model_name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def dim(self) -> int:
|
||||
"""嵌入维度 D。"""
|
||||
return self._dim
|
||||
|
||||
def embed(self, texts: Union[str, List[str]]) -> ndarray:
|
||||
"""文本 → 嵌入向量(L2 归一化)。
|
||||
|
||||
参数:
|
||||
texts: 单条文本或文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] ndarray,每行 L2 范数为 1.0。单条文本时 N=1。
|
||||
"""
|
||||
if isinstance(texts, str):
|
||||
texts = [texts]
|
||||
|
||||
if self._backend == "local":
|
||||
return self._embed_local(texts)
|
||||
return self._embed_remote(texts)
|
||||
|
||||
def embed_tensor(self, texts: Union[str, List[str]]) -> Tensor:
|
||||
"""文本 → 嵌入 Tensor(L2 归一化)。
|
||||
|
||||
参数:
|
||||
texts: 单条文本或文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] torch.Tensor(float32)。
|
||||
"""
|
||||
arr = self.embed(texts)
|
||||
return torch.from_numpy(arr).float()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 后端实现
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _embed_local(self, texts: List[str]) -> ndarray:
|
||||
"""本地 sentence-transformers 推理。
|
||||
|
||||
参数:
|
||||
texts: 文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] ndarray,L2 归一化。
|
||||
"""
|
||||
with torch.no_grad():
|
||||
embeddings = self._model.encode(
|
||||
texts,
|
||||
normalize_embeddings=True,
|
||||
convert_to_numpy=True,
|
||||
)
|
||||
# sentence-transformers encode 返回 ndarray [N, D]
|
||||
if embeddings.ndim == 1:
|
||||
embeddings = embeddings.reshape(1, -1)
|
||||
return embeddings
|
||||
|
||||
def _embed_remote(self, texts: List[str]) -> ndarray:
|
||||
"""远程 OpenAI 兼容 API 调用。
|
||||
|
||||
参数:
|
||||
texts: 文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] ndarray,L2 归一化。
|
||||
"""
|
||||
response = self._client.embeddings.create(
|
||||
model=self._model_name,
|
||||
input=texts,
|
||||
)
|
||||
# 按 index 排序,确保顺序一致
|
||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||
embeddings = np.array(
|
||||
[item.embedding for item in sorted_data], dtype=np.float32
|
||||
)
|
||||
|
||||
# L2 归一化
|
||||
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||
norms = np.maximum(norms, 1e-12) # 避免除零
|
||||
embeddings = embeddings / norms
|
||||
|
||||
return embeddings
|
||||
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
LLM/VLM 客户端模块
|
||||
==================
|
||||
统一封装 LLM(纯文本)和 VLM(多模态)API 调用。
|
||||
|
||||
仅支持 OpenAI-compatible 接口,通过配置 api_url + model 适配不同服务商
|
||||
(如 Qwen DashScope、OpenAI、本地推理服务等)。
|
||||
|
||||
提供同步版(chat / chat_with_images)和异步版(chat_async / chat_with_images_async)。
|
||||
异步版基于 openai.AsyncOpenAI,适配 asyncio 事件循环,零线程阻塞。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.config import Config
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
vlm = LLMClient(cfg.vlm)
|
||||
|
||||
# 同步
|
||||
answer = vlm.chat_with_images("图中有什么?", images=["frame.jpg"])
|
||||
|
||||
# 异步
|
||||
import asyncio
|
||||
answer = asyncio.run(vlm.chat_with_images_async("图中有什么?", images=["frame.jpg"]))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
|
||||
from utils.logger_system import log_exception, log_msg
|
||||
from video_tree_trm.config import LLMConfig, VLMConfig
|
||||
|
||||
# 502/503 时的重试参数
|
||||
_RETRY_STATUS_CODES = {502, 503}
|
||||
_MAX_RETRIES = 20 # 最多重试次数(约等待 20+ 分钟)
|
||||
_RETRY_BASE_WAIT = 60 # 首次等待 60 秒
|
||||
_RETRY_MAX_WAIT = 300 # 单次等待上限 5 分钟
|
||||
|
||||
|
||||
def _call_with_retry(fn, label: str):
|
||||
"""对 fn() 调用执行指数退避重试(重试 502/503 及超时)。
|
||||
|
||||
参数:
|
||||
fn: 无参调用的函数,返回 API response。
|
||||
label: 日志标识(如方法名)。
|
||||
|
||||
返回:
|
||||
fn() 的返回值。
|
||||
|
||||
异常:
|
||||
openai.OpenAIError: 超过最大重试次数或非可重试错误时抛出。
|
||||
"""
|
||||
wait = _RETRY_BASE_WAIT
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
return fn()
|
||||
except openai.APITimeoutError:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 请求超时,等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
except openai.InternalServerError as exc:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status not in _RETRY_STATUS_CODES:
|
||||
raise
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 遇到 {status},等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
|
||||
|
||||
|
||||
async def _async_call_with_retry(coro_fn, label: str):
|
||||
"""异步版指数退避重试,适配 asyncio 事件循环。
|
||||
|
||||
参数:
|
||||
coro_fn: 无参调用的协程工厂函数(每次调用返回新协程)。
|
||||
label: 日志标识(如方法名)。
|
||||
|
||||
返回:
|
||||
coro_fn() 的返回值。
|
||||
|
||||
实现细节:
|
||||
使用 await asyncio.sleep() 替代 time.sleep(),不阻塞事件循环。
|
||||
每次重试需重新调用 coro_fn() 构造新协程(协程不可复用)。
|
||||
"""
|
||||
wait = _RETRY_BASE_WAIT
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
return await coro_fn()
|
||||
except openai.APITimeoutError:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 请求超时,等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
except openai.InternalServerError as exc:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status not in _RETRY_STATUS_CODES:
|
||||
raise
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 遇到 {status},等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""OpenAI-compatible LLM/VLM 统一客户端。
|
||||
|
||||
同时提供同步接口(chat / chat_with_images)和异步接口(chat_async / chat_with_images_async)。
|
||||
异步接口使用独立的 AsyncOpenAI 实例,零线程阻塞,与 asyncio.Semaphore 配合实现真并发。
|
||||
|
||||
属性:
|
||||
_config: LLMConfig 或 VLMConfig 配置对象。
|
||||
_client: openai.OpenAI 同步客户端。
|
||||
_async_client: openai.AsyncOpenAI 异步客户端。
|
||||
_extra_body: 关闭 Qwen3 thinking 模式的额外参数。
|
||||
"""
|
||||
|
||||
def __init__(self, config: Union[LLMConfig, VLMConfig]) -> None:
|
||||
"""初始化 LLM/VLM 客户端(同步 + 异步双客户端)。
|
||||
|
||||
参数:
|
||||
config: LLMConfig 或 VLMConfig,包含 api_key、api_url、model 等参数。
|
||||
|
||||
异常:
|
||||
ValueError: api_key 或 api_url 为空时抛出。
|
||||
"""
|
||||
if not config.api_key:
|
||||
raise ValueError(
|
||||
"LLMClient 初始化失败: config.api_key 不能为空,请在 .env 中设置"
|
||||
)
|
||||
if not config.api_url:
|
||||
raise ValueError(
|
||||
"LLMClient 初始化失败: config.api_url 不能为空,请在 config/default.yaml 中设置"
|
||||
)
|
||||
|
||||
self._config = config
|
||||
# 同步客户端(向后兼容)
|
||||
self._client = openai.OpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.api_url,
|
||||
http_client=httpx.Client(proxy=None),
|
||||
)
|
||||
# 异步客户端(asyncio 场景,零阻塞)
|
||||
self._async_client = openai.AsyncOpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.api_url,
|
||||
http_client=httpx.AsyncClient(proxy=None),
|
||||
)
|
||||
# 关闭 Qwen3 thinking 模式(vLLM 正确格式)
|
||||
self._extra_body: Dict = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
log_msg(
|
||||
"INFO", "LLMClient 初始化完成", model=config.model, api_url=config.api_url
|
||||
)
|
||||
|
||||
# ── 同步接口(向后兼容)─────────────────────────────────────────────────
|
||||
|
||||
def chat(self, prompt: str, max_tokens: Optional[int] = None) -> str:
|
||||
"""纯文本单轮对话(同步)。
|
||||
|
||||
参数:
|
||||
prompt: 用户输入文本。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
"""
|
||||
messages = self._build_messages(prompt)
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = _call_with_retry(
|
||||
lambda: self._client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat 调用失败", exc)
|
||||
raise
|
||||
|
||||
def chat_with_images(
|
||||
self,
|
||||
prompt: str,
|
||||
images: List[str],
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> str:
|
||||
"""多模态单轮对话(VLM,同步)。
|
||||
|
||||
参数:
|
||||
prompt: 文本指令。
|
||||
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
"""
|
||||
encoded = [self._encode_image(img) for img in images]
|
||||
messages = self._build_messages(prompt, images=encoded)
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = _call_with_retry(
|
||||
lambda: self._client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat_with_images",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat_with_images 调用失败", exc)
|
||||
raise
|
||||
|
||||
def batch_chat(
|
||||
self,
|
||||
prompts: List[str],
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
"""批量纯文本并发对话,保序返回(同步)。
|
||||
|
||||
参数:
|
||||
prompts: 文本输入列表。
|
||||
max_tokens: 最大生成 token 数。
|
||||
|
||||
返回:
|
||||
与 prompts 等长的生成文本列表,顺序与输入对应。
|
||||
"""
|
||||
results: List[str] = [""] * len(prompts)
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
future_to_idx = {
|
||||
executor.submit(self.chat, prompt, max_tokens): idx
|
||||
for idx, prompt in enumerate(prompts)
|
||||
}
|
||||
for future in as_completed(future_to_idx):
|
||||
idx = future_to_idx[future]
|
||||
results[idx] = future.result()
|
||||
return results
|
||||
|
||||
# ── 异步接口(asyncio 事件循环,零阻塞)──────────────────────────────────
|
||||
|
||||
async def chat_async(self, prompt: str, max_tokens: Optional[int] = None) -> str:
|
||||
"""纯文本单轮对话(异步,零线程阻塞)。
|
||||
|
||||
参数:
|
||||
prompt: 用户输入文本。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
|
||||
实现细节:
|
||||
使用 AsyncOpenAI 客户端,await 期间事件循环可处理其他协程,
|
||||
配合 asyncio.Semaphore 实现受控并发。
|
||||
"""
|
||||
messages = self._build_messages(prompt)
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = await _async_call_with_retry(
|
||||
lambda: self._async_client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat_async",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat_async 调用失败", exc)
|
||||
raise
|
||||
|
||||
async def chat_with_images_async(
|
||||
self,
|
||||
prompt: str,
|
||||
images: List[str],
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> str:
|
||||
"""多模态单轮对话(VLM,异步,零线程阻塞)。
|
||||
|
||||
参数:
|
||||
prompt: 文本指令。
|
||||
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
|
||||
实现细节:
|
||||
图像编码(磁盘读取 + base64)在默认线程池执行器中并行执行,
|
||||
避免阻塞事件循环;VLM API 调用通过 AsyncOpenAI 零阻塞。
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# 并行编码所有图像(I/O 密集,交给线程池)
|
||||
encoded: List[str] = await asyncio.gather(
|
||||
*[loop.run_in_executor(None, self._encode_image, img) for img in images]
|
||||
)
|
||||
messages = self._build_messages(prompt, images=list(encoded))
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = await _async_call_with_retry(
|
||||
lambda: self._async_client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat_with_images_async",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat_with_images_async 调用失败", exc)
|
||||
raise
|
||||
|
||||
# ── 私有辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_thinking(content: str) -> str:
|
||||
"""剥离 Qwen3 thinking 模式生成的 <think>...</think> 块。
|
||||
|
||||
参数:
|
||||
content: VLM/LLM 原始返回文本(可能含 <think> 块)。
|
||||
|
||||
返回:
|
||||
去除 think 块后的纯净文本。
|
||||
|
||||
实现细节:
|
||||
当 API 参数无法完全禁用 thinking 时作为兜底保障。
|
||||
<think> 块可能跨多行,使用 DOTALL 模式匹配。
|
||||
"""
|
||||
cleaned = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
|
||||
def _encode_image(self, path_or_b64: str) -> str:
|
||||
"""将图像转换为 data URI 格式的 base64 字符串。
|
||||
|
||||
参数:
|
||||
path_or_b64: 本地文件路径,或已是 "data:image/...;base64,..." 格式的字符串。
|
||||
|
||||
返回:
|
||||
"data:image/jpeg;base64,<base64数据>" 格式字符串。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 指定路径文件不存在时抛出。
|
||||
"""
|
||||
if "base64," in path_or_b64:
|
||||
return path_or_b64
|
||||
|
||||
if not os.path.exists(path_or_b64):
|
||||
raise FileNotFoundError(f"图像文件不存在: {path_or_b64}")
|
||||
|
||||
with open(path_or_b64, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
b64_data = base64.b64encode(raw).decode("utf-8")
|
||||
ext = os.path.splitext(path_or_b64)[1].lower()
|
||||
mime = "image/png" if ext == ".png" else "image/jpeg"
|
||||
return f"data:{mime};base64,{b64_data}"
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
prompt: str,
|
||||
images: Optional[List[str]] = None,
|
||||
) -> List[Dict]:
|
||||
"""拼装 OpenAI-compatible 消息结构。
|
||||
|
||||
参数:
|
||||
prompt: 文本指令。
|
||||
images: 已编码的 base64 data URI 列表(可为 None)。
|
||||
|
||||
返回:
|
||||
OpenAI messages 格式的列表。
|
||||
|
||||
实现细节:
|
||||
- 无图像:content 为纯字符串。
|
||||
- 有图像:content 为列表,图像在前,文本在后。
|
||||
"""
|
||||
if not images:
|
||||
return [{"role": "user", "content": prompt}]
|
||||
|
||||
content: List[Dict] = [
|
||||
{"type": "image_url", "image_url": {"url": img}} for img in images
|
||||
]
|
||||
content.append({"type": "text", "text": prompt})
|
||||
return [{"role": "user", "content": content}]
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
端到端推理管线
|
||||
==============
|
||||
串联 预处理 → 检索 → 生成 的完整推理流程。
|
||||
提供 ``build_index()`` 和 ``query()`` 两个高层接口。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.pipeline import Pipeline
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
pipeline = Pipeline(cfg)
|
||||
|
||||
# 构建(或从缓存加载)树索引
|
||||
tree = pipeline.build_index("data/my_doc.txt", modality="text")
|
||||
|
||||
# 问答
|
||||
answer = pipeline.query("文档的主要结论是什么?", tree)
|
||||
print(answer)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
from video_tree_trm.answer_generator import AnswerGenerator
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.embeddings import EmbeddingModel
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.recursive_retriever import RecursiveRetriever
|
||||
from video_tree_trm.text_tree_builder import TextTreeBuilder
|
||||
from video_tree_trm.tree_index import TreeIndex
|
||||
from video_tree_trm.video_tree_builder import VideoTreeBuilder
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""端到端推理管线(预处理 → 检索 → 生成)。
|
||||
|
||||
将所有子模块按配置串联,对外暴露两个接口:
|
||||
- ``build_index()``: 从原始文件构建 TreeIndex,支持磁盘缓存。
|
||||
- ``query()``: 对已有 TreeIndex 执行问答,返回生成答案字符串。
|
||||
|
||||
属性:
|
||||
config: 全局配置对象。
|
||||
embed_model: 文本嵌入模型(冻结)。
|
||||
llm: 文本大语言模型客户端。
|
||||
vlm: 视觉语言模型客户端。
|
||||
retriever: TRM 递归检索器(eval 模式)。
|
||||
generator: 答案生成器。
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
"""初始化端到端推理管线。
|
||||
|
||||
参数:
|
||||
config: 通过 ``Config.load()`` 加载的全局配置对象。
|
||||
|
||||
实现细节:
|
||||
- 若 ``config.retriever.checkpoint`` 非 None,加载预训练权重。
|
||||
- 检索器始终切换到 eval 模式(关闭 Dropout 等训练行为)。
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# Phase 1: 初始化各子模块(embed_model 懒加载,仅 query/embed 时触发)
|
||||
self._embed_model: Optional[EmbeddingModel] = None
|
||||
self.llm = LLMClient(config.llm)
|
||||
self.vlm = LLMClient(config.vlm)
|
||||
self.retriever = RecursiveRetriever(config.retriever)
|
||||
|
||||
# Phase 2: 可选加载检查点
|
||||
if config.retriever.checkpoint:
|
||||
ensure(
|
||||
os.path.isfile(config.retriever.checkpoint),
|
||||
f"检查点文件不存在: {config.retriever.checkpoint}",
|
||||
)
|
||||
state_dict = torch.load(config.retriever.checkpoint, map_location="cpu")
|
||||
self.retriever.load_state_dict(state_dict)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"检索器权重已加载",
|
||||
checkpoint=config.retriever.checkpoint,
|
||||
)
|
||||
|
||||
self.retriever.eval()
|
||||
self.generator = AnswerGenerator(self.llm, self.vlm)
|
||||
|
||||
log_msg(
|
||||
"INFO",
|
||||
"Pipeline 初始化完成",
|
||||
modality_embed=config.embed.model_name,
|
||||
has_checkpoint=bool(config.retriever.checkpoint),
|
||||
)
|
||||
|
||||
@property
|
||||
def embed_model(self) -> EmbeddingModel:
|
||||
"""懒加载 EmbeddingModel,仅在首次访问时初始化(index 阶段不触发)。"""
|
||||
if self._embed_model is None:
|
||||
log_msg("INFO", "懒加载 EmbeddingModel", model=self.config.embed.model_name)
|
||||
self._embed_model = EmbeddingModel(self.config.embed)
|
||||
return self._embed_model
|
||||
|
||||
def build_index(self, source_path: str, modality: str) -> TreeIndex:
|
||||
"""构建并缓存 TreeIndex(JSON 格式,含 embedding)。
|
||||
|
||||
参数:
|
||||
source_path: 原始文件路径(文本文件或视频文件)。
|
||||
modality: 模态类型,"text" 或 "video"。
|
||||
|
||||
返回:
|
||||
构建完成的 TreeIndex 对象(已 embed)。
|
||||
|
||||
实现细节:
|
||||
- 缓存路径: ``{cache_dir}/{stem}_{modality}.json``。
|
||||
- 缓存命中时直接反序列化返回(自动恢复 embedding 若有)。
|
||||
- 缓存未命中时调用 VLM 生成描述文本,执行 embedding,保存为 JSON。
|
||||
"""
|
||||
ensure(
|
||||
modality in ("text", "video"),
|
||||
f"modality 须为 'text' 或 'video',实际={modality}",
|
||||
)
|
||||
|
||||
# Phase 1: 缓存路径计算
|
||||
stem = Path(source_path).stem
|
||||
cache_dir = Path(self.config.tree.cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_path = str(cache_dir / f"{stem}_{modality}.json")
|
||||
|
||||
if os.path.isfile(cache_path):
|
||||
log_msg("INFO", "缓存命中,直接加载 TreeIndex", cache_path=cache_path)
|
||||
tree = TreeIndex.load_json(cache_path)
|
||||
# 若缓存中已有 embedding,直接返回;否则按需 embed
|
||||
if tree.is_embedded:
|
||||
return tree
|
||||
log_msg("INFO", "缓存中无 embedding,开始执行 embed_all")
|
||||
self._embed_tree(tree, cache_path=cache_path)
|
||||
return tree
|
||||
|
||||
# Phase 2: 构建树索引(纯 VLM 文字描述)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"缓存未命中,开始构建 TreeIndex",
|
||||
source_path=source_path,
|
||||
modality=modality,
|
||||
)
|
||||
|
||||
if modality == "text":
|
||||
with open(source_path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
builder = TextTreeBuilder(self.llm, self.config.tree)
|
||||
tree = builder.build(text, source_path)
|
||||
else:
|
||||
builder = VideoTreeBuilder(self.vlm, self.config.tree)
|
||||
tree = builder.build(source_path)
|
||||
|
||||
# Phase 3: 执行 embedding 并保存(含 embedding)
|
||||
self._embed_tree(tree, cache_path=cache_path)
|
||||
|
||||
return tree
|
||||
|
||||
def _embed_tree(self, tree: TreeIndex, cache_path: Optional[str] = None) -> None:
|
||||
"""对树的所有节点执行 embedding,可选回写缓存。
|
||||
|
||||
参数:
|
||||
tree: 待 embed 的 TreeIndex(embedding=None 的节点)。
|
||||
cache_path: 若非 None,embed 完成后回写到此路径(JSON 格式,含 embedding)。
|
||||
|
||||
实现细节:
|
||||
调用 TreeIndex.embed_all,传入 EmbeddingModel.embed 作为 embed_fn。
|
||||
embed_all 内部按 L2 分组批量处理 L3,减少 API 调用次数。
|
||||
若 cache_path 非 None,保存时 include_embedding=True。
|
||||
"""
|
||||
log_msg("INFO", "开始对树执行 embedding")
|
||||
tree.embed_all(
|
||||
embed_fn=self.embed_model.embed,
|
||||
model_name=self.config.embed.model_name,
|
||||
embed_dim=self.embed_model.dim,
|
||||
)
|
||||
if cache_path is not None:
|
||||
tree.save_json(cache_path, include_embedding=True)
|
||||
log_msg("INFO", "embed_all 完成,缓存已更新(含 embedding)", cache_path=cache_path)
|
||||
else:
|
||||
log_msg("INFO", "embed_all 完成(仅内存,未写磁盘)")
|
||||
|
||||
def _load_or_build_video_tree(self, video_path: str) -> TreeIndex:
|
||||
"""根据视频路径优先从缓存加载 TreeIndex,若无缓存则在线构建。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 youtube_id。
|
||||
|
||||
返回:
|
||||
加载或构建完成的 TreeIndex 对象。
|
||||
"""
|
||||
# 如果传入的是 youtube_id,尝试拼凑路径
|
||||
if not os.path.isfile(video_path):
|
||||
video_path_full = os.path.join("data/videomme/videos", f"{video_path}.mp4")
|
||||
if os.path.isfile(video_path_full):
|
||||
video_path = video_path_full
|
||||
|
||||
return self.build_index(video_path, modality="video")
|
||||
|
||||
def query(
|
||||
self,
|
||||
question: str,
|
||||
tree: TreeIndex | str,
|
||||
modality: Optional[str] = None,
|
||||
cache_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""执行端到端问答。
|
||||
|
||||
参数:
|
||||
question: 用户查询字符串。
|
||||
tree: TreeIndex 对象,或树 JSON 路径,或视频路径。
|
||||
modality: 当 tree 为字符串且无法自动推断时,指定模态 ("text" 或 "video")。
|
||||
cache_path: 若非 None,embed 完成后回写到此路径。
|
||||
|
||||
返回:
|
||||
生成的答案字符串。
|
||||
"""
|
||||
# Phase 0: 处理输入,确保得到 TreeIndex 对象
|
||||
if isinstance(tree, str):
|
||||
if tree.endswith(".json"):
|
||||
log_msg("INFO", "直接从 JSON 路径加载 TreeIndex", path=tree)
|
||||
tree_obj = TreeIndex.load_json(tree)
|
||||
# 若 cache_path 未指定,使用 tree 的 JSON 路径
|
||||
if cache_path is None:
|
||||
cache_path = tree
|
||||
elif modality == "video" or tree.endswith(".mp4"):
|
||||
log_msg("INFO", "根据视频路径获取 TreeIndex", path=tree)
|
||||
tree_obj = self._load_or_build_video_tree(tree)
|
||||
else:
|
||||
# 默认为文本
|
||||
log_msg("INFO", "根据文本路径获取 TreeIndex", path=tree)
|
||||
tree_obj = self.build_index(tree, modality="text")
|
||||
else:
|
||||
tree_obj = tree
|
||||
|
||||
# Phase 1: 确保树已 embed
|
||||
if not tree_obj.is_embedded:
|
||||
log_msg("INFO", "树尚未 embed,触发 embed_all 并回写缓存", cache_path=cache_path)
|
||||
self._embed_tree(tree_obj, cache_path=cache_path)
|
||||
|
||||
# Phase 2: 嵌入查询
|
||||
q: torch.Tensor = self.embed_model.embed_tensor(question) # [1, D]
|
||||
|
||||
# Phase 3: 递归检索
|
||||
with torch.no_grad():
|
||||
result = self.retriever(q, tree_obj)
|
||||
|
||||
log_msg(
|
||||
"INFO",
|
||||
"检索完成",
|
||||
num_rounds=result["num_rounds"],
|
||||
num_paths=len(result["paths"]),
|
||||
question=question[:50],
|
||||
)
|
||||
|
||||
# Phase 4: 生成答案
|
||||
return self.generator.generate(
|
||||
question, result["paths"], tree_obj, frame_hits=result.get("frame_hits")
|
||||
)
|
||||
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
文本树构建模块
|
||||
==============
|
||||
将长文本通过 L2 轴心策略转化为三层 TreeIndex。
|
||||
|
||||
构建策略::
|
||||
|
||||
Step 1: _segment_text — 结构切分,确定 L1/L2 边界
|
||||
Step 2: L2 先行 — 从原始内容独立生成 L2 摘要(batch_chat 并发)
|
||||
Step 3: L3 向下 — 原始段落文本直接作为 L3,无需二次生成
|
||||
Step 4: L1 向上 — 聚合 L2 描述,生成 L1 粗粒度摘要
|
||||
Step 5: 组装 TreeIndex
|
||||
|
||||
L2 轴心策略解决了循环依赖:
|
||||
- L2 描述不依赖 L3,从原始段落直接生成
|
||||
- L3 直接使用原始段落文本,不调用 LLM
|
||||
- L1 聚合 L2 描述,保证完整覆盖
|
||||
|
||||
使用方式::
|
||||
|
||||
builder = TextTreeBuilder(embed_model, llm_client, config.tree)
|
||||
index = builder.build(text, source_path="docs/my_doc.txt")
|
||||
index.save("cache/my_doc.pkl")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.logger_system import ensure, log_json, log_msg
|
||||
from video_tree_trm.config import TreeConfig
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.tree_index import (
|
||||
IndexMeta,
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_L2_PROMPT = (
|
||||
"用1-2句话描述以下段落的核心内容,与同级小节形成区分:\n\n{text}"
|
||||
)
|
||||
|
||||
_L1_PROMPT = (
|
||||
"用2-3句话总结以下小节的核心内容:\n\n{l2_descriptions}"
|
||||
)
|
||||
|
||||
_SEG_PROMPT = (
|
||||
"将以下文本分成若干语义段落,每段为完整语义单元。\n"
|
||||
"只返回 JSON 数组,格式: [\"段落1\", \"段落2\", ...],不要其他内容。\n"
|
||||
"文本:\n\n{text}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _chunk(lst: List[str], size: int) -> List[List[str]]:
|
||||
"""将列表等长分块(固定步长,无重叠)。
|
||||
|
||||
参数:
|
||||
lst: 待分块的列表。
|
||||
size: 每块的最大长度。
|
||||
|
||||
返回:
|
||||
分块后的列表,每个元素为一个子列表。
|
||||
"""
|
||||
return [lst[i : i + size] for i in range(0, len(lst), size)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TextTreeBuilder:
|
||||
"""文本模态树构建器。
|
||||
|
||||
将长文本通过 L2 轴心策略(先构建 L2,再向下扩展 L3,向上聚合 L1)
|
||||
转化为三层 TreeIndex。节点 embedding 均为 None(由 Pipeline.embed_all 延迟填充)。
|
||||
|
||||
属性:
|
||||
llm: LLM 客户端。
|
||||
config: 树构建配置。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLMClient,
|
||||
config: TreeConfig,
|
||||
) -> None:
|
||||
"""初始化文本树构建器。
|
||||
|
||||
参数:
|
||||
llm: 已初始化的 LLM 客户端(LLMClient)。
|
||||
config: 树构建配置(TreeConfig),关键字段 max_paragraphs_per_l2。
|
||||
|
||||
实现细节:
|
||||
构建器不持有 EmbeddingModel,所有 embedding 延迟到检索阶段由 Pipeline 统一计算。
|
||||
"""
|
||||
self.llm = llm
|
||||
self.config = config
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build(self, text: str, source_path: str) -> TreeIndex:
|
||||
"""将长文本构建为三层 TreeIndex。
|
||||
|
||||
参数:
|
||||
text: 输入长文本(UTF-8 字符串)。
|
||||
source_path: 原始文件路径,写入 IndexMeta。
|
||||
|
||||
返回:
|
||||
三层 TreeIndex 对象。
|
||||
|
||||
实现细节:
|
||||
1. _segment_text 切分文本 → List[List[str]](外层=L1,内层=L2段落组)
|
||||
2. 将所有 L2 段落组的 prompt 批量送入 llm.batch_chat(),并发获取摘要
|
||||
3. 逐层组装 L3→L2→L1 节点
|
||||
4. 构建 TreeIndex 并写入日志
|
||||
"""
|
||||
ensure(bool(text.strip()), "输入文本不能为空")
|
||||
log_msg("INFO", "开始构建文本树索引", source_path=source_path)
|
||||
|
||||
# Phase 1: 结构切分
|
||||
sections = self._segment_text(text)
|
||||
ensure(len(sections) > 0, "文本切分结果为空")
|
||||
log_msg(
|
||||
"INFO",
|
||||
"文本切分完成",
|
||||
l1_count=len(sections),
|
||||
l2_groups=[len(s) for s in sections],
|
||||
)
|
||||
|
||||
# Phase 2: 收集所有 L2 段落组,批量生成摘要(L2 先行)
|
||||
all_groups: List[Tuple[int, int, List[str]]] = []
|
||||
for i, section_paragraphs in enumerate(sections):
|
||||
for j, group in enumerate(
|
||||
_chunk(section_paragraphs, self.config.max_paragraphs_per_l2)
|
||||
):
|
||||
all_groups.append((i, j, group))
|
||||
|
||||
l2_prompts = [
|
||||
_L2_PROMPT.format(text="\n\n".join(group))
|
||||
for _, _, group in all_groups
|
||||
]
|
||||
l2_descs = self.llm.batch_chat(l2_prompts)
|
||||
log_msg("INFO", "L2 摘要生成完成", total_l2=len(l2_descs))
|
||||
|
||||
# Phase 3-4: 按 L1 组装三层节点
|
||||
# 构建索引映射:(i, j) → 在 all_groups / l2_descs 中的位置
|
||||
group_index: dict = {
|
||||
(i, j): idx for idx, (i, j, _) in enumerate(all_groups)
|
||||
}
|
||||
|
||||
l1_nodes: List[L1Node] = []
|
||||
for i, section_paragraphs in enumerate(sections):
|
||||
groups = _chunk(section_paragraphs, self.config.max_paragraphs_per_l2)
|
||||
l2_nodes: List[L2Node] = []
|
||||
|
||||
for j, group in enumerate(groups):
|
||||
idx = group_index[(i, j)]
|
||||
desc = l2_descs[idx]
|
||||
l3_nodes = self._build_l3_from_paragraphs(group, i, j)
|
||||
l2_node = L2Node(
|
||||
id=f"l1_{i}_l2_{j}",
|
||||
description=desc,
|
||||
embedding=None,
|
||||
time_range=None,
|
||||
children=l3_nodes,
|
||||
)
|
||||
l2_nodes.append(l2_node)
|
||||
|
||||
l1_node = self._build_l1(l2_nodes, f"l1_{i}")
|
||||
l1_nodes.append(l1_node)
|
||||
|
||||
# Phase 5: 组装 TreeIndex(embedding 延迟到 Pipeline.embed_all,此处为 None)
|
||||
metadata = IndexMeta(
|
||||
source_path=source_path,
|
||||
modality="text",
|
||||
created_at=datetime.now().isoformat(),
|
||||
)
|
||||
index = TreeIndex(metadata=metadata, roots=l1_nodes)
|
||||
|
||||
total_l2 = sum(len(r.children) for r in l1_nodes)
|
||||
total_l3 = sum(
|
||||
len(l2.children) for r in l1_nodes for l2 in r.children
|
||||
)
|
||||
log_json(
|
||||
"text_tree_build",
|
||||
{
|
||||
"source_path": source_path,
|
||||
"l1_count": len(l1_nodes),
|
||||
"l2_count": total_l2,
|
||||
"l3_count": total_l3,
|
||||
"embedded": False,
|
||||
},
|
||||
)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"文本树索引构建完成",
|
||||
l1=len(l1_nodes),
|
||||
l2=total_l2,
|
||||
l3=total_l3,
|
||||
)
|
||||
return index
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:切分策略
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _segment_text(self, text: str) -> List[List[str]]:
|
||||
"""结构切分长文本为 L1/L2 层次。
|
||||
|
||||
参数:
|
||||
text: 输入文本。
|
||||
|
||||
返回:
|
||||
sections[i] = [paragraph_1, paragraph_2, ...]
|
||||
外层列表 = L1 段(章节),内层列表 = L2 单元(段落组内段落)。
|
||||
|
||||
策略:
|
||||
有 Markdown 标题 → 正则解析 #/## 边界
|
||||
无 Markdown 标题 → LLM 单次调用语义分段
|
||||
"""
|
||||
if self._detect_toc(text):
|
||||
log_msg("INFO", "检测到 Markdown 标题,使用正则切分")
|
||||
return self._segment_with_regex(text)
|
||||
else:
|
||||
log_msg("INFO", "未检测到 Markdown 标题,使用 LLM 语义分段")
|
||||
return self._segment_with_llm(text)
|
||||
|
||||
def _detect_toc(self, text: str) -> bool:
|
||||
"""检测文本是否包含 Markdown 标题(有 ToC 结构)。
|
||||
|
||||
参数:
|
||||
text: 输入文本。
|
||||
|
||||
返回:
|
||||
True 表示有 # 或 ## 开头的标题行,False 表示无。
|
||||
"""
|
||||
return bool(re.search(r"^#{1,2}\s+\S", text, re.MULTILINE))
|
||||
|
||||
def _segment_with_regex(self, text: str) -> List[List[str]]:
|
||||
"""通过正则解析 Markdown 标题边界进行结构切分。
|
||||
|
||||
参数:
|
||||
text: 含 Markdown 标题的文本。
|
||||
|
||||
返回:
|
||||
List[List[str]],外层=L1章节,内层=该章节下的段落列表。
|
||||
若二级标题下段落数超过 max_paragraphs_per_l2,则进一步等长分块。
|
||||
|
||||
实现细节:
|
||||
- # 标题 → L1 边界
|
||||
- ## 标题 → L2 边界
|
||||
- ### 及以下标题视为段落内容,收集到最近 L2 段落组
|
||||
- 空段落过滤掉
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
|
||||
sections: List[List[str]] = [] # 外层=L1
|
||||
current_section: List[str] = [] # 当前 L1 下的段落(扁平)
|
||||
current_para_lines: List[str] = [] # 积累段落文本行
|
||||
|
||||
def _flush_para() -> None:
|
||||
"""将当前积累的行合并为一个段落加入 current_section。"""
|
||||
para = "\n".join(current_para_lines).strip()
|
||||
if para:
|
||||
current_section.append(para)
|
||||
current_para_lines.clear()
|
||||
|
||||
def _flush_section() -> None:
|
||||
"""将当前 section 保存,重置。"""
|
||||
_flush_para()
|
||||
if current_section:
|
||||
sections.append(list(current_section))
|
||||
current_section.clear()
|
||||
|
||||
for line in lines:
|
||||
h1_match = re.match(r"^#\s+(.+)", line)
|
||||
h2_match = re.match(r"^##\s+(.+)", line)
|
||||
|
||||
if h1_match:
|
||||
# L1 边界:保存当前 section
|
||||
_flush_section()
|
||||
# 将 H1 标题本身作为第一段落(可选:也可忽略标题行)
|
||||
title = h1_match.group(1).strip()
|
||||
if title:
|
||||
current_section.append(title)
|
||||
elif h2_match:
|
||||
# L2 边界:只冲刷当前段落,不切换 section
|
||||
_flush_para()
|
||||
title = h2_match.group(1).strip()
|
||||
if title:
|
||||
current_section.append(title)
|
||||
else:
|
||||
# 普通内容行(含 ###、####、正文段落)
|
||||
if line.strip() == "":
|
||||
# 空行触发段落分隔
|
||||
_flush_para()
|
||||
else:
|
||||
current_para_lines.append(line)
|
||||
|
||||
_flush_section()
|
||||
|
||||
# 若没有产生任何 section(如文本只有一个 L1),保底处理
|
||||
if not sections:
|
||||
sections = [self._collect_paragraphs(text)]
|
||||
|
||||
# 对超出 max_paragraphs_per_l2 的段落组不做处理(由 build() 负责分块)
|
||||
return sections
|
||||
|
||||
def _segment_with_llm(self, text: str) -> List[List[str]]:
|
||||
"""通过 LLM 单次调用语义分段无结构文本。
|
||||
|
||||
参数:
|
||||
text: 无 Markdown 标题的纯文本。
|
||||
|
||||
返回:
|
||||
List[List[str]],只有一个外层元素(整篇视为单个 L1)。
|
||||
内层为 LLM 返回的语义段落列表。
|
||||
|
||||
异常:
|
||||
ValueError: LLM 返回的内容无法解析为 JSON 数组。
|
||||
"""
|
||||
prompt = _SEG_PROMPT.format(text=text)
|
||||
raw = self.llm.chat(prompt)
|
||||
|
||||
# 尝试从返回结果中提取 JSON 数组
|
||||
raw = raw.strip()
|
||||
# 提取可能被 markdown 代码块包裹的 JSON
|
||||
code_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, re.DOTALL)
|
||||
if code_match:
|
||||
raw = code_match.group(1)
|
||||
|
||||
ensure(
|
||||
raw.startswith("["),
|
||||
f"LLM 语义分段返回格式错误,期望 JSON 数组,实际: {raw[:100]}",
|
||||
)
|
||||
|
||||
try:
|
||||
paragraphs: List[str] = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"LLM 语义分段 JSON 解析失败: {e}\n原始输出: {raw}") from e
|
||||
|
||||
ensure(isinstance(paragraphs, list), "LLM 返回值不是列表")
|
||||
ensure(len(paragraphs) > 0, "LLM 语义分段返回空列表")
|
||||
|
||||
# 过滤空段落
|
||||
paragraphs = [p.strip() for p in paragraphs if p.strip()]
|
||||
log_msg("INFO", "LLM 语义分段完成", paragraph_count=len(paragraphs))
|
||||
|
||||
return [paragraphs] # 整篇视为单个 L1
|
||||
|
||||
def _collect_paragraphs(self, text: str) -> List[str]:
|
||||
"""按双换行符切分段落(保底策略)。
|
||||
|
||||
参数:
|
||||
text: 输入文本。
|
||||
|
||||
返回:
|
||||
非空段落列表。
|
||||
"""
|
||||
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||
return paras if paras else [text.strip()]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:节点构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_l2(self, paragraphs: List[str], l2_id: str) -> L2Node:
|
||||
"""将段落组构建为 L2 节点(含 LLM 摘要和嵌入)。
|
||||
|
||||
参数:
|
||||
paragraphs: 该 L2 节点下的段落文本列表。
|
||||
l2_id: 节点 ID。
|
||||
|
||||
返回:
|
||||
L2Node(children 为空,由调用方填充)。
|
||||
"""
|
||||
ensure(len(paragraphs) > 0, f"L2 节点 {l2_id} 的段落列表为空")
|
||||
prompt = _L2_PROMPT.format(text="\n\n".join(paragraphs))
|
||||
description = self.llm.chat(prompt)
|
||||
return L2Node(
|
||||
id=l2_id,
|
||||
description=description,
|
||||
embedding=None,
|
||||
time_range=None,
|
||||
)
|
||||
|
||||
def _build_l3_from_paragraphs(
|
||||
self,
|
||||
paragraphs: List[str],
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
) -> List[L3Node]:
|
||||
"""将段落列表批量构建为 L3 节点(原始文本直接复用,不调用 LLM)。
|
||||
|
||||
参数:
|
||||
paragraphs: 段落文本列表。
|
||||
l1_i: 父 L1 索引(用于生成 ID)。
|
||||
l2_j: 父 L2 索引(用于生成 ID)。
|
||||
|
||||
返回:
|
||||
L3Node 列表,description == raw_content == 原始段落文本。
|
||||
|
||||
实现细节:
|
||||
使用 embed.embed(paragraphs) 批量嵌入,一次调用获取全部向量。
|
||||
"""
|
||||
ensure(len(paragraphs) > 0, f"L3 段落列表为空 (l1={l1_i}, l2={l2_j})")
|
||||
nodes: List[L3Node] = []
|
||||
for k, para in enumerate(paragraphs):
|
||||
nodes.append(
|
||||
L3Node(
|
||||
id=f"l1_{l1_i}_l2_{l2_j}_l3_{k}",
|
||||
description=para,
|
||||
embedding=None,
|
||||
raw_content=para,
|
||||
frame_path=None,
|
||||
timestamp=None,
|
||||
)
|
||||
)
|
||||
return nodes
|
||||
|
||||
def _build_l1(self, l2_children: List[L2Node], l1_id: str) -> L1Node:
|
||||
"""聚合 L2 描述,构建 L1 节点(含 LLM 摘要和嵌入)。
|
||||
|
||||
参数:
|
||||
l2_children: 该 L1 节点下的所有 L2 节点。
|
||||
l1_id: 节点 ID。
|
||||
|
||||
返回:
|
||||
L1Node(children 已由调用方赋值,或在此赋值)。
|
||||
|
||||
实现细节:
|
||||
将所有 L2 描述拼接,用序号标注后送入 LLM 生成 2-3 句摘要。
|
||||
"""
|
||||
ensure(len(l2_children) > 0, f"L1 节点 {l1_id} 没有 L2 子节点")
|
||||
l2_descriptions = "\n".join(
|
||||
f"{idx + 1}. {node.description}"
|
||||
for idx, node in enumerate(l2_children)
|
||||
)
|
||||
prompt = _L1_PROMPT.format(l2_descriptions=l2_descriptions)
|
||||
summary = self.llm.chat(prompt)
|
||||
return L1Node(
|
||||
id=l1_id,
|
||||
summary=summary,
|
||||
embedding=None,
|
||||
time_range=None,
|
||||
children=l2_children,
|
||||
)
|
||||
@@ -0,0 +1,642 @@
|
||||
"""
|
||||
三层树索引核心数据结构
|
||||
======================
|
||||
定义 Video-Tree-TRM 的三层树状索引结构,是所有后续模块
|
||||
(builder、retriever、losses、pipeline)的基础依赖。
|
||||
|
||||
数据结构层次::
|
||||
|
||||
TreeIndex
|
||||
└─ List[L1Node] 全局叙事节点
|
||||
└─ List[L2Node] 片段级语义节点
|
||||
└─ List[L3Node] 帧/细节级节点
|
||||
|
||||
与参考项目 (Tree-TRM/video_pyramid.py) 的关键区别:
|
||||
- 统一嵌入空间:所有 embedding 均来自 text_embed(),无跨模态问题
|
||||
- 序列化方式:pickle 整体序列化(而非 JSON + NPY 分文件存储)
|
||||
- L3 全文本化:无需 VisualProjectionLayer
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import pickle
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding 序列化辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _embed_to_str(arr: Optional[np.ndarray]) -> Optional[str]:
|
||||
"""float32 ndarray → base64 字符串(用于 JSON 序列化)。
|
||||
|
||||
参数:
|
||||
arr: float32 数组,形状任意。
|
||||
|
||||
返回:
|
||||
base64 编码字符串,或 None(输入为 None 时)。
|
||||
"""
|
||||
if arr is None:
|
||||
return None
|
||||
return base64.b64encode(arr.astype(np.float32).tobytes()).decode()
|
||||
|
||||
|
||||
def _embed_from_str(s: Optional[str]) -> Optional[np.ndarray]:
|
||||
"""base64 字符串 → float32 ndarray(用于 JSON 反序列化)。
|
||||
|
||||
参数:
|
||||
s: base64 编码字符串。
|
||||
|
||||
返回:
|
||||
float32 数组,或 None(输入为 None/空时)。
|
||||
"""
|
||||
if s is None or s == "":
|
||||
return None
|
||||
return np.frombuffer(base64.b64decode(s), dtype=np.float32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 元数据
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexMeta:
|
||||
"""树索引元数据。
|
||||
|
||||
Attributes:
|
||||
source_path: 原始数据路径(视频文件或文本文件)。
|
||||
modality: 数据模态,"text" 或 "video"。
|
||||
embed_model: 嵌入模型名称(建树时为 None,embed_all 后填充)。
|
||||
embed_dim: 嵌入向量维度(建树时为 None,embed_all 后填充)。
|
||||
created_at: 创建时间(ISO 格式字符串)。
|
||||
"""
|
||||
|
||||
source_path: str
|
||||
modality: str
|
||||
embed_model: Optional[str] = None
|
||||
embed_dim: Optional[int] = None
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 节点数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class L3Node:
|
||||
"""L3 帧/细节级节点(叶子层)。
|
||||
|
||||
代表最细粒度的语义单元,对应一个具体的描述片段。
|
||||
|
||||
Attributes:
|
||||
id: 节点唯一标识。
|
||||
description: 文本描述。
|
||||
embedding: 文本嵌入向量,形状 [D],float32。
|
||||
raw_content: 原始文本内容(可选)。
|
||||
frame_path: 关联的帧图像路径(可选,仅视频模态)。
|
||||
timestamp: 对应的时间戳(秒,可选)。
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str
|
||||
embedding: Optional[np.ndarray] = None # [D],build 时为 None,embed_all 后填充
|
||||
raw_content: Optional[str] = None
|
||||
frame_path: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class L2Node:
|
||||
"""L2 片段级语义节点(中间层)。
|
||||
|
||||
连接 L1 宏观叙事与 L3 细节描述。
|
||||
|
||||
Attributes:
|
||||
id: 节点唯一标识。
|
||||
description: 片段文本描述。
|
||||
embedding: 文本嵌入向量,形状 [D],float32。
|
||||
time_range: 时间范围 (start, end)(秒,可选)。
|
||||
children: 所属的 L3 子节点列表。
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str
|
||||
embedding: Optional[np.ndarray] = None # [D],build 时为 None,embed_all 后填充
|
||||
time_range: Optional[Tuple[float, float]] = None
|
||||
children: List[L3Node] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class L1Node:
|
||||
"""L1 全局叙事节点(根层)。
|
||||
|
||||
代表最粗粒度的语义单元,包含宏观事件摘要。
|
||||
|
||||
Attributes:
|
||||
id: 节点唯一标识。
|
||||
summary: 高层叙事摘要。
|
||||
embedding: 文本嵌入向量,形状 [D],float32。
|
||||
time_range: 时间范围 (start, end)(秒,可选)。
|
||||
children: 所属的 L2 子节点列表。
|
||||
"""
|
||||
|
||||
id: str
|
||||
summary: str
|
||||
embedding: Optional[np.ndarray] = None # [D],build 时为 None,embed_all 后填充
|
||||
time_range: Optional[Tuple[float, float]] = None
|
||||
children: List[L2Node] = field(default_factory=list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON 辅助方法(单个 L1 段的轻量序列化)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_dict(self, include_embedding: bool = False) -> Dict[str, Any]:
|
||||
"""将当前 L1 节点(及其全部 L2/L3 子树)序列化为纯 dict。
|
||||
|
||||
参数:
|
||||
include_embedding: 若 True,将 embedding 向量序列化为 base64 字符串。
|
||||
|
||||
返回:
|
||||
包含 id/summary/time_range/children 的字典,可选包含 embedding。
|
||||
"""
|
||||
|
||||
def l3_to_dict(n: L3Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"timestamp": n.timestamp,
|
||||
"frame_path": n.frame_path,
|
||||
"raw_content": n.raw_content,
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
def l2_to_dict(n: L2Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"time_range": list(n.time_range) if n.time_range else None,
|
||||
"children": [l3_to_dict(c) for c in n.children],
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
d = {
|
||||
"id": self.id,
|
||||
"summary": self.summary,
|
||||
"time_range": list(self.time_range) if self.time_range else None,
|
||||
"children": [l2_to_dict(c) for c in self.children],
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(self.embedding)
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: Dict[str, Any]) -> "L1Node":
|
||||
"""从 dict 反序列化单个 L1 节点(支持 embedding 恢复)。
|
||||
|
||||
参数:
|
||||
d: to_dict() 输出的字典,可包含 embedding 字段。
|
||||
|
||||
返回:
|
||||
L1Node 实例(embedding 自动从 base64 恢复,若无则为 None)。
|
||||
"""
|
||||
l2_nodes: List[L2Node] = []
|
||||
for l2d in d.get("children", []):
|
||||
l3_nodes: List[L3Node] = []
|
||||
for l3d in l2d.get("children", []):
|
||||
l3_nodes.append(
|
||||
L3Node(
|
||||
id=l3d["id"],
|
||||
description=l3d["description"],
|
||||
embedding=_embed_from_str(l3d.get("embedding")),
|
||||
timestamp=l3d.get("timestamp"),
|
||||
frame_path=l3d.get("frame_path"),
|
||||
raw_content=l3d.get("raw_content"),
|
||||
)
|
||||
)
|
||||
tr2 = l2d.get("time_range")
|
||||
l2_nodes.append(
|
||||
L2Node(
|
||||
id=l2d["id"],
|
||||
description=l2d["description"],
|
||||
embedding=_embed_from_str(l2d.get("embedding")),
|
||||
time_range=tuple(tr2) if tr2 else None,
|
||||
children=l3_nodes,
|
||||
)
|
||||
)
|
||||
tr1 = d.get("time_range")
|
||||
return L1Node(
|
||||
id=d["id"],
|
||||
summary=d["summary"],
|
||||
embedding=_embed_from_str(d.get("embedding")),
|
||||
time_range=tuple(tr1) if tr1 else None,
|
||||
children=l2_nodes,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 树索引容器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeIndex:
|
||||
"""三层树索引容器。
|
||||
|
||||
组织和管理三层节点结构,提供嵌入矩阵提取、节点访问、
|
||||
以及 pickle 序列化/反序列化接口。
|
||||
|
||||
典型工作流::
|
||||
|
||||
# 1. 构建索引
|
||||
index = TreeIndex(metadata=meta, roots=[l1_node_1, l1_node_2])
|
||||
|
||||
# 2. 提取嵌入矩阵(用于 Tree-TRM 检索)
|
||||
M_L1 = index.l1_embeddings() # [N1, D]
|
||||
M_L2 = index.l2_embeddings_of(l1_idx=0) # [N2, D]
|
||||
M_L3 = index.l3_embeddings_of(0, 1) # [N3, D]
|
||||
|
||||
# 3. 序列化
|
||||
index.save("cache/my_index.pkl")
|
||||
loaded = TreeIndex.load("cache/my_index.pkl")
|
||||
|
||||
Attributes:
|
||||
metadata: 索引元数据。
|
||||
roots: L1 节点列表。
|
||||
"""
|
||||
|
||||
metadata: IndexMeta
|
||||
roots: List[L1Node] = field(default_factory=list)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 嵌入矩阵提取
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 懒加载嵌入支持
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def is_embedded(self) -> bool:
|
||||
"""检查所有节点是否已填充嵌入向量。
|
||||
|
||||
返回:
|
||||
True 表示所有 L1/L2/L3 节点的 embedding 均非 None;False 表示尚未 embed。
|
||||
"""
|
||||
for l1 in self.roots:
|
||||
if l1.embedding is None:
|
||||
return False
|
||||
for l2 in l1.children:
|
||||
if l2.embedding is None:
|
||||
return False
|
||||
for l3 in l2.children:
|
||||
if l3.embedding is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def embed_all(
|
||||
self,
|
||||
embed_fn: Callable[[Union[str, List[str]]], np.ndarray],
|
||||
model_name: str,
|
||||
embed_dim: int,
|
||||
) -> None:
|
||||
"""对所有节点批量执行 embedding,更新 metadata。
|
||||
|
||||
建树阶段不调用此方法(embedding=None)。
|
||||
首次检索前由 Pipeline 调用,结果缓存在节点上。
|
||||
|
||||
参数:
|
||||
embed_fn: EmbeddingModel.embed 方法,接受 str 或 List[str],返回 [N, D] ndarray。
|
||||
model_name: 嵌入模型名称,写入 metadata。
|
||||
embed_dim: 嵌入维度,写入 metadata。
|
||||
|
||||
实现细节:
|
||||
- L3 节点按 L2 分组批量 embed(一次调用),减少 API 开销。
|
||||
- L1/L2 各单独 embed(数量少,不值得合并)。
|
||||
- 仅对 embedding 为 None 的节点执行(支持增量更新)。
|
||||
"""
|
||||
ensure(len(self.roots) > 0, "embed_all: 树为空,无节点可 embed")
|
||||
for l1 in self.roots:
|
||||
if l1.embedding is None:
|
||||
l1.embedding = embed_fn(l1.summary)[0].astype(np.float32)
|
||||
for l2 in l1.children:
|
||||
if l2.embedding is None:
|
||||
l2.embedding = embed_fn(l2.description)[0].astype(np.float32)
|
||||
# L3 批量 embed
|
||||
need_embed = [l3 for l3 in l2.children if l3.embedding is None]
|
||||
if need_embed:
|
||||
texts = [l3.description for l3 in need_embed]
|
||||
embs = embed_fn(texts).astype(np.float32) # [N, D]
|
||||
for l3, emb in zip(need_embed, embs):
|
||||
l3.embedding = emb
|
||||
self.metadata.embed_model = model_name
|
||||
self.metadata.embed_dim = embed_dim
|
||||
log_msg("INFO", "embed_all 完成", model=model_name, embed_dim=embed_dim)
|
||||
|
||||
def l1_embeddings(self) -> np.ndarray:
|
||||
"""返回所有 L1 节点的嵌入矩阵。
|
||||
|
||||
返回:
|
||||
形状 [N1, D] 的 float32 矩阵。空树返回 [0, D]。
|
||||
|
||||
异常:
|
||||
RuntimeError: 节点 embedding 尚未计算(请先调用 embed_all)。
|
||||
"""
|
||||
ensure(self.is_embedded, "L1 embedding 尚未计算,请先调用 tree.embed_all()")
|
||||
if not self.roots:
|
||||
return np.zeros((0, self.metadata.embed_dim), dtype=np.float32)
|
||||
return np.stack([r.embedding for r in self.roots], axis=0).astype(np.float32)
|
||||
|
||||
def l2_embeddings_of(self, l1_idx: int) -> np.ndarray:
|
||||
"""返回指定 L1 节点下所有 L2 子节点的嵌入矩阵。
|
||||
|
||||
参数:
|
||||
l1_idx: L1 节点索引。
|
||||
|
||||
返回:
|
||||
形状 [N2, D] 的 float32 矩阵。
|
||||
|
||||
异常:
|
||||
IndexError: l1_idx 越界。
|
||||
RuntimeError: embedding 尚未计算。
|
||||
"""
|
||||
ensure(self.is_embedded, "L2 embedding 尚未计算,请先调用 tree.embed_all()")
|
||||
if not (0 <= l1_idx < len(self.roots)):
|
||||
raise IndexError(f"l1_idx={l1_idx} 越界,L1 节点数={len(self.roots)}")
|
||||
children = self.roots[l1_idx].children
|
||||
if not children:
|
||||
return np.zeros((0, self.metadata.embed_dim), dtype=np.float32)
|
||||
return np.stack([c.embedding for c in children], axis=0).astype(np.float32)
|
||||
|
||||
def l3_embeddings_of(self, l1_idx: int, l2_idx: int) -> np.ndarray:
|
||||
"""返回指定 L2 节点下所有 L3 子节点的嵌入矩阵。
|
||||
|
||||
参数:
|
||||
l1_idx: L1 节点索引。
|
||||
l2_idx: L2 节点索引(相对于 L1)。
|
||||
|
||||
返回:
|
||||
形状 [N3, D] 的 float32 矩阵。
|
||||
|
||||
异常:
|
||||
IndexError: 索引越界。
|
||||
RuntimeError: embedding 尚未计算。
|
||||
"""
|
||||
ensure(self.is_embedded, "L3 embedding 尚未计算,请先调用 tree.embed_all()")
|
||||
if not (0 <= l1_idx < len(self.roots)):
|
||||
raise IndexError(f"l1_idx={l1_idx} 越界,L1 节点数={len(self.roots)}")
|
||||
l2_children = self.roots[l1_idx].children
|
||||
if not (0 <= l2_idx < len(l2_children)):
|
||||
raise IndexError(f"l2_idx={l2_idx} 越界,L2 节点数={len(l2_children)}")
|
||||
l3_children = l2_children[l2_idx].children
|
||||
if not l3_children:
|
||||
return np.zeros((0, self.metadata.embed_dim), dtype=np.float32)
|
||||
return np.stack([c.embedding for c in l3_children], axis=0).astype(np.float32)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 节点访问
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_node(self, l1: int, l2: int, l3: int) -> L3Node:
|
||||
"""按三级路径索引获取 L3 节点。
|
||||
|
||||
参数:
|
||||
l1: L1 节点索引。
|
||||
l2: L2 节点索引。
|
||||
l3: L3 节点索引。
|
||||
|
||||
返回:
|
||||
目标 L3Node。
|
||||
|
||||
异常:
|
||||
IndexError: 任意层级索引越界。
|
||||
"""
|
||||
if l1 < 0 or l1 >= len(self.roots):
|
||||
raise IndexError(f"l1={l1} 越界,L1 节点数={len(self.roots)}")
|
||||
l2_children = self.roots[l1].children
|
||||
if l2 < 0 or l2 >= len(l2_children):
|
||||
raise IndexError(f"l2={l2} 越界,L2 节点数={len(l2_children)}")
|
||||
l3_children = l2_children[l2].children
|
||||
if l3 < 0 or l3 >= len(l3_children):
|
||||
raise IndexError(f"l3={l3} 越界,L3 节点数={len(l3_children)}")
|
||||
return l3_children[l3]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# JSON 序列化(主格式,无 embedding)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def to_dict(self, include_embedding: bool = False) -> Dict[str, Any]:
|
||||
"""将树索引序列化为纯 Python dict。
|
||||
|
||||
参数:
|
||||
include_embedding: 若 True,将所有节点的 embedding 向量序列化为 base64。
|
||||
|
||||
返回:
|
||||
可直接 json.dump 的字典,结构为 {metadata, roots[...]}。
|
||||
当 include_embedding=True 时,每个节点包含 embedding 字段。
|
||||
"""
|
||||
|
||||
def l3_to_dict(n: L3Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"timestamp": n.timestamp,
|
||||
"frame_path": n.frame_path,
|
||||
"raw_content": n.raw_content,
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
def l2_to_dict(n: L2Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"time_range": list(n.time_range) if n.time_range else None,
|
||||
"children": [l3_to_dict(c) for c in n.children],
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
metadata_dict = {
|
||||
"source_path": self.metadata.source_path,
|
||||
"modality": self.metadata.modality,
|
||||
"created_at": self.metadata.created_at,
|
||||
}
|
||||
if include_embedding:
|
||||
metadata_dict["embed_model"] = self.metadata.embed_model
|
||||
metadata_dict["embed_dim"] = self.metadata.embed_dim
|
||||
|
||||
return {
|
||||
"metadata": metadata_dict,
|
||||
"roots": [r.to_dict(include_embedding=include_embedding) for r in self.roots],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "TreeIndex":
|
||||
"""从 dict 反序列化为 TreeIndex(支持 embedding 恢复)。
|
||||
|
||||
参数:
|
||||
d: to_dict() 的输出或等价结构,可包含 embedding 字段。
|
||||
|
||||
返回:
|
||||
TreeIndex 实例(若 JSON 中有 embedding 字段,自动反序列化填充)。
|
||||
"""
|
||||
meta = IndexMeta(
|
||||
source_path=d["metadata"]["source_path"],
|
||||
modality=d["metadata"]["modality"],
|
||||
embed_model=d["metadata"].get("embed_model"),
|
||||
embed_dim=d["metadata"].get("embed_dim"),
|
||||
created_at=d["metadata"].get("created_at", datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
roots: List[L1Node] = []
|
||||
for r in d["roots"]:
|
||||
l2_nodes: List[L2Node] = []
|
||||
for l2d in r.get("children", []):
|
||||
l3_nodes: List[L3Node] = []
|
||||
for l3d in l2d.get("children", []):
|
||||
l3_nodes.append(L3Node(
|
||||
id=l3d["id"],
|
||||
description=l3d["description"],
|
||||
embedding=_embed_from_str(l3d.get("embedding")),
|
||||
timestamp=l3d.get("timestamp"),
|
||||
frame_path=l3d.get("frame_path"),
|
||||
raw_content=l3d.get("raw_content"),
|
||||
))
|
||||
tr2 = l2d.get("time_range")
|
||||
l2_nodes.append(L2Node(
|
||||
id=l2d["id"],
|
||||
description=l2d["description"],
|
||||
embedding=_embed_from_str(l2d.get("embedding")),
|
||||
time_range=tuple(tr2) if tr2 else None,
|
||||
children=l3_nodes,
|
||||
))
|
||||
tr1 = r.get("time_range")
|
||||
roots.append(L1Node(
|
||||
id=r["id"],
|
||||
summary=r["summary"],
|
||||
embedding=_embed_from_str(r.get("embedding")),
|
||||
time_range=tuple(tr1) if tr1 else None,
|
||||
children=l2_nodes,
|
||||
))
|
||||
|
||||
return cls(metadata=meta, roots=roots)
|
||||
|
||||
def save_json(self, path: str, include_embedding: bool = False) -> None:
|
||||
"""将树索引以 JSON 格式保存到磁盘。
|
||||
|
||||
参数:
|
||||
path: 保存文件路径(推荐 .json 后缀)。
|
||||
include_embedding: 若 True,将所有节点的 embedding 向量保存到 JSON。
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(include_embedding=include_embedding), f, ensure_ascii=False, indent=2)
|
||||
log_msg(
|
||||
"INFO",
|
||||
f"树索引(JSON)已保存至 {path}",
|
||||
n_l1=len(self.roots),
|
||||
include_embedding=include_embedding,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_json(cls, path: str) -> "TreeIndex":
|
||||
"""从 JSON 文件加载树索引(自动检测并恢复 embedding)。
|
||||
|
||||
参数:
|
||||
path: JSON 文件路径。
|
||||
|
||||
返回:
|
||||
TreeIndex 实例。若 JSON 中包含 embedding 字段,自动反序列化填充;
|
||||
否则 embedding=None(向后兼容旧格式)。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 文件不存在。
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
obj = cls.from_dict(d)
|
||||
log_msg(
|
||||
"INFO",
|
||||
f"树索引(JSON)已从 {path} 加载",
|
||||
n_l1=len(obj.roots),
|
||||
is_embedded=obj.is_embedded,
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def save_l1_json(path: str, l1_node: L1Node) -> None:
|
||||
"""将单个 L1 节点(及其子树)以 JSON 形式保存到磁盘。
|
||||
|
||||
参数:
|
||||
path: 目标文件路径。
|
||||
l1_node: 待序列化的 L1 节点。
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(l1_node.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
log_msg("INFO", "L1 中间结果已保存", path=path, l1_id=l1_node.id)
|
||||
|
||||
|
||||
def load_l1_json(path: str) -> L1Node:
|
||||
"""从 JSON 文件加载单个 L1 节点(embedding=None)。"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
node = L1Node.from_dict(data)
|
||||
log_msg("INFO", "L1 中间结果已加载", path=path, l1_id=node.id)
|
||||
return node
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 序列化(pickle,保留供向后兼容)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
"""将整个树索引序列化到磁盘(pickle 格式)。
|
||||
|
||||
参数:
|
||||
path: 保存文件路径(推荐 .pkl 后缀)。
|
||||
"""
|
||||
with open(path, "wb") as f:
|
||||
pickle.dump(self, f, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
log_msg("INFO", f"树索引已保存至 {path}", n_l1=len(self.roots))
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> "TreeIndex":
|
||||
"""从磁盘加载树索引。
|
||||
|
||||
.. warning::
|
||||
pickle 反序列化可执行任意代码,切勿加载不受信任的文件。
|
||||
如需安全替代方案,请考虑 JSON + NPY 分文件存储。
|
||||
|
||||
参数:
|
||||
path: pickle 文件路径。
|
||||
|
||||
返回:
|
||||
TreeIndex 实例。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 文件不存在。
|
||||
TypeError: 文件内容不是 TreeIndex 实例。
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
obj = pickle.load(f) # noqa: S301
|
||||
if not isinstance(obj, cls):
|
||||
msg = f"文件内容不是 TreeIndex 实例: {type(obj)}"
|
||||
log_msg("ERROR", msg, path=path)
|
||||
raise TypeError(msg)
|
||||
log_msg("INFO", f"树索引已从 {path} 加载", n_l1=len(obj.roots))
|
||||
return obj
|
||||
@@ -0,0 +1,993 @@
|
||||
"""
|
||||
视频树构建模块
|
||||
==============
|
||||
将长视频通过 L2 轴心策略 + VLM 帧描述转化为三层 TreeIndex。
|
||||
|
||||
构建策略::
|
||||
|
||||
Step 1: _segment_video — 固定步长切分,确定 L1 时间边界
|
||||
Step 2: L2 先行 — 每个 L2 clip 均匀 seek l2_representative_frames 帧(稀疏),VLM 生成片段描述(1-2句)
|
||||
Step 3: L3 向下 — 注入 L2 上下文,VLM 批量帧描述(每帧1-2句)
|
||||
Step 4: L1 向上 — 聚合 L2 描述,LLM 生成 L1 摘要(2-3句)
|
||||
Step 5: 组装 TreeIndex
|
||||
|
||||
并发模型(异步版)::
|
||||
|
||||
build() → asyncio.run(_build_async())
|
||||
_build_async():
|
||||
asyncio.Semaphore(concurrency=16) 控制最大 VLM 并发数
|
||||
Phase 1: asyncio.gather(所有L2任务) — 16路同时 VLM
|
||||
Phase 2: asyncio.gather(所有L3任务) — 每个L3任务内的12批次同时并发
|
||||
Phase 3: asyncio.gather(各L1摘要) — L1收齐后并发
|
||||
ffmpeg 提帧在 ThreadPoolExecutor(max_workers=8) 中并行执行
|
||||
|
||||
L2 轴心策略解决了循环依赖:
|
||||
- L2 描述不依赖 L3,从代表帧直接生成
|
||||
- L3 注入 L2 上下文后逐帧描述
|
||||
- L1 聚合 L2 描述,保证完整覆盖
|
||||
|
||||
帧持久化:
|
||||
- 帧图像保存到 {cache_dir}/frames/{video_stem}/,长期有效
|
||||
- 已提取的帧自动跳过(缓存复用)
|
||||
|
||||
使用方式::
|
||||
|
||||
builder = VideoTreeBuilder(vlm_client, config.tree)
|
||||
index = builder.build("path/to/video.mp4") # 同步壳,内部 asyncio.run()
|
||||
index.save("cache/my_video.pkl")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
|
||||
from utils.logger_system import ensure, log_json, log_msg
|
||||
from video_tree_trm.config import TreeConfig
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.tree_index import (
|
||||
IndexMeta,
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
load_l1_json,
|
||||
save_l1_json,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_L2_VIDEO_PROMPT = "用1-2句话描述以下视频片段的核心内容,与同级片段形成区分。"
|
||||
|
||||
_L3_VIDEO_PROMPT = (
|
||||
'该片段的整体内容: "{l2_description}"\n'
|
||||
"以下是该片段中连续的 {n} 帧画面。\n"
|
||||
"对每帧用一到两句话描述其具体画面内容。\n"
|
||||
"重点关注: 动作、物体变化、文字信息、人物表情。\n"
|
||||
"不要重复片段整体描述,聚焦每帧的区分性信息。\n"
|
||||
'只返回 JSON 数组,格式: ["帧1描述", "帧2描述", ...],不要其他内容。'
|
||||
)
|
||||
|
||||
_L1_VIDEO_PROMPT = (
|
||||
"以下是一个视频段落中各片段的描述:\n{l2_texts}\n"
|
||||
"用2-3句话总结该段落的整体内容,涵盖所有片段的主题。"
|
||||
)
|
||||
|
||||
# 每次 VLM 调用携带的最大帧数:5 帧 payload 小、JSON 解析成功率高
|
||||
_L3_BATCH_SIZE = 5
|
||||
|
||||
_L3_SINGLE_PROMPT = (
|
||||
'该片段的整体内容: "{l2_description}"\n'
|
||||
"用一到两句话描述这帧画面的具体内容。"
|
||||
"重点关注: 动作、物体变化、文字信息、人物表情。"
|
||||
)
|
||||
|
||||
# ffmpeg 并发提帧的线程池大小(CPU 密集型,避免过度并发)
|
||||
_FFMPEG_MAX_WORKERS = 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VideoTreeBuilder:
|
||||
"""视频模态树构建器(asyncio 真并发版)。
|
||||
|
||||
将长视频通过 L2 轴心策略(先构建 L2,再向下扩展 L3,向上聚合 L1)
|
||||
转化为三层 TreeIndex。
|
||||
|
||||
并发架构:
|
||||
build() 为同步壳,内部调用 asyncio.run(_build_async())。
|
||||
_build_async() 使用 asyncio.Semaphore(concurrency) 控制并发 VLM 数量。
|
||||
所有 VLM 调用通过 LLMClient 的异步接口(AsyncOpenAI)发起,零线程阻塞。
|
||||
ffmpeg 提帧在独立 ThreadPoolExecutor 中并行,不阻塞事件循环。
|
||||
|
||||
属性:
|
||||
vlm: VLM/LLM 客户端(用于图文和纯文本调用)。
|
||||
config: 树构建配置。
|
||||
_ffmpeg_pool: ffmpeg 专用线程池(max_workers=_FFMPEG_MAX_WORKERS)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vlm: LLMClient,
|
||||
config: TreeConfig,
|
||||
) -> None:
|
||||
"""初始化视频树构建器。
|
||||
|
||||
参数:
|
||||
vlm: 已初始化的 VLM/LLM 客户端(LLMClient),需支持异步接口。
|
||||
config: 树构建配置(TreeConfig),关键字段:
|
||||
l1_segment_duration, l2_clip_duration, l3_fps,
|
||||
l2_representative_frames, cache_dir, concurrency。
|
||||
|
||||
实现细节:
|
||||
ffmpeg 线程池在构建器级别创建,所有异步协程共用,
|
||||
避免每次提帧都重建线程池的开销。
|
||||
"""
|
||||
self.vlm = vlm
|
||||
self.config = config
|
||||
self._ffmpeg_pool = ThreadPoolExecutor(max_workers=_FFMPEG_MAX_WORKERS)
|
||||
# 进度与中间结果目录均挂在 cache_dir 下,避免散落其它位置
|
||||
self._cache_root = Path(self.config.cache_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# URL 流式辅助方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_url(path_or_url: str) -> bool:
|
||||
"""判断输入是否为网络 URL(而非本地路径)。
|
||||
|
||||
参数:
|
||||
path_or_url: 文件路径或 URL 字符串。
|
||||
|
||||
返回:
|
||||
True 表示 URL,False 表示本地路径。
|
||||
"""
|
||||
return path_or_url.startswith(("http://", "https://"))
|
||||
|
||||
@staticmethod
|
||||
def _source_stem(video_path: str) -> str:
|
||||
"""从视频路径或 YouTube URL 中提取短标识符,用于帧缓存目录命名。
|
||||
|
||||
参数:
|
||||
video_path: 本地文件路径或 YouTube 视频页面 URL。
|
||||
|
||||
返回:
|
||||
短字符串标识符(本地文件取 stem,YouTube URL 取 v= 后的视频 ID)。
|
||||
"""
|
||||
if "youtube.com/watch" in video_path or "youtu.be/" in video_path:
|
||||
match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{8,15})", video_path)
|
||||
if match:
|
||||
return match.group(1)
|
||||
stem = Path(video_path).stem
|
||||
return stem[:64] if len(stem) > 64 else stem
|
||||
|
||||
@staticmethod
|
||||
def _resolve_stream(url: str) -> str:
|
||||
"""通过 yt-dlp 获取 YouTube 视频的 CDN 直链,供 cv2.VideoCapture 直接使用。
|
||||
|
||||
参数:
|
||||
url: YouTube 视频页面 URL。
|
||||
|
||||
返回:
|
||||
CDN HTTPS 直链(ffmpeg/OpenCV 可直接流式读取)。
|
||||
"""
|
||||
log_msg("INFO", "获取 YouTube CDN 直链", url=url)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"yt-dlp",
|
||||
"-g",
|
||||
"--format",
|
||||
"best[ext=mp4][height<=720]/best[ext=mp4]/best",
|
||||
url,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
ensure(result.returncode == 0, f"yt-dlp 获取直链失败: {result.stderr.strip()}")
|
||||
stream_url = result.stdout.strip().splitlines()[0]
|
||||
ensure(stream_url.startswith("http"), f"yt-dlp 返回非 URL: {stream_url[:100]}")
|
||||
log_msg("INFO", "CDN 直链获取成功", stream_url=stream_url[:80])
|
||||
return stream_url
|
||||
|
||||
@staticmethod
|
||||
def _get_video_duration(url: str) -> float:
|
||||
"""通过 yt-dlp --dump-json 获取视频时长(秒)。
|
||||
|
||||
参数:
|
||||
url: YouTube 视频页面 URL。
|
||||
|
||||
返回:
|
||||
视频总时长(秒,浮点数)。
|
||||
"""
|
||||
log_msg("INFO", "获取视频时长元数据", url=url)
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "--dump-json", "--no-playlist", url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
ensure(result.returncode == 0, f"yt-dlp 元数据获取失败: {result.stderr.strip()}")
|
||||
meta = json.loads(result.stdout)
|
||||
duration = float(meta.get("duration", 0))
|
||||
ensure(duration > 0, f"视频时长读取异常: {duration}")
|
||||
log_msg("INFO", "视频时长确认", duration_sec=round(duration, 1))
|
||||
return duration
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build(self, video_path: str) -> TreeIndex:
|
||||
"""将长视频构建为三层 TreeIndex(同步壳,内部 asyncio.run 驱动)。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径(.mp4/.avi/.mkv 等)或 YouTube URL。
|
||||
|
||||
返回:
|
||||
三层 TreeIndex 对象。
|
||||
|
||||
实现细节:
|
||||
同步壳设计保持与 build_trees_batch.py 的接口兼容性。
|
||||
每次调用 asyncio.run() 创建独立事件循环,多线程安全(各线程独立循环)。
|
||||
"""
|
||||
return asyncio.run(self._build_async(video_path))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 核心异步构建逻辑
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _build_async(self, video_path: str) -> TreeIndex:
|
||||
"""异步构建三层 TreeIndex(真并发核心,L2→L3 链式触发)。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 YouTube URL。
|
||||
|
||||
返回:
|
||||
三层 TreeIndex 对象。
|
||||
|
||||
实现细节:
|
||||
并发架构:每个 L1 段内启动一组"L2→L3 链式协程",
|
||||
L2 完成后立即触发 L3(不等待其他 L2),L3 完成后触发 L1 摘要。
|
||||
各 L1 段独立并发,彼此不阻塞。
|
||||
Semaphore(concurrency=16) 全局限制同时在途 VLM 调用数量。
|
||||
|
||||
关键调用链(每个 L2 clip 独立)::
|
||||
_build_segment(i) → asyncio.gather(
|
||||
_chain(i,0): _build_l2_video_async → _build_l3_task_async
|
||||
_chain(i,1): _build_l2_video_async → _build_l3_task_async
|
||||
...
|
||||
) → _build_l1_video_async(i)
|
||||
"""
|
||||
# Phase 0: URL vs 本地文件处理
|
||||
if self._is_url(video_path):
|
||||
stream_url = self._resolve_stream(video_path)
|
||||
duration_hint: Optional[float] = self._get_video_duration(video_path)
|
||||
log_msg("INFO", "开始构建视频树索引(URL 流式模式)", source_url=video_path)
|
||||
else:
|
||||
ensure(os.path.isfile(video_path), f"视频文件不存在: {video_path}")
|
||||
stream_url = video_path
|
||||
duration_hint = None
|
||||
log_msg("INFO", "开始构建视频树索引", video_path=video_path)
|
||||
|
||||
source_id = self._source_stem(video_path)
|
||||
|
||||
# Phase 1: 时间切分(同步,仅一次)
|
||||
l1_ranges = self._segment_video(stream_url, duration_hint=duration_hint)
|
||||
ensure(len(l1_ranges) > 0, "视频时间切分结果为空")
|
||||
log_msg("INFO", "视频切分完成", l1_count=len(l1_ranges))
|
||||
|
||||
total_l1 = len(l1_ranges)
|
||||
|
||||
# Phase 1.1: 读取已有进度(支持断点续跑)
|
||||
finished_l1_ids: set[int] = set()
|
||||
progress = self._load_progress(source_id)
|
||||
if progress is not None and progress.get("total_l1") == total_l1:
|
||||
finished_l1_ids = set(progress.get("finished_l1_ids", []))
|
||||
if finished_l1_ids:
|
||||
log_msg(
|
||||
"INFO",
|
||||
"检测到中间进度,启用断点续跑",
|
||||
stem=source_id,
|
||||
finished_l1=list(sorted(finished_l1_ids)),
|
||||
)
|
||||
else:
|
||||
# 进度不存在或形状不匹配时,从零开始,旧进度视为无效
|
||||
if progress is not None:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
"进度文件与当前 L1 段数不一致,忽略旧进度",
|
||||
stem=source_id,
|
||||
recorded_total_l1=progress.get("total_l1"),
|
||||
current_total_l1=total_l1,
|
||||
)
|
||||
|
||||
# 创建 VLM 并发控制信号量(每视频独立,限制同时在途 VLM 请求数)
|
||||
vlm_sem = asyncio.Semaphore(self.config.concurrency)
|
||||
|
||||
# Phase 2-5: 按 L1 段并发,段内 L2→L3 链式触发,L3 收齐后触发 L1
|
||||
async def _build_segment(i: int, l1_range: Tuple[float, float]) -> L1Node:
|
||||
"""单个 L1 段的完整构建:L2+L3 并发链式 → L1 摘要。
|
||||
|
||||
参数:
|
||||
i: L1 段索引。
|
||||
l1_range: L1 时间区间 (start, end)。
|
||||
|
||||
返回:
|
||||
完整的 L1Node(含所有 L2 和 L3 子节点)。
|
||||
|
||||
实现细节:
|
||||
段内所有 L2 clip 同时启动(asyncio.gather),
|
||||
每个 clip 的 L2 VLM 完成后立即触发 L3,不等待其他 clip 的 L2。
|
||||
所有 clip 的 L2+L3 完成后,触发 L1 文本摘要。
|
||||
"""
|
||||
clips = self._get_l2_clips(l1_range)
|
||||
|
||||
async def _chain(j: int, clip_range: Tuple[float, float]) -> Tuple[int, L2Node]:
|
||||
"""L2→L3 链:L2 完成立即触发 L3,返回 (j, 含children的L2Node)。"""
|
||||
l2_id = f"l1_{i}_l2_{j}"
|
||||
l2_node = await self._build_l2_video_async(
|
||||
stream_url, clip_range, l2_id, source_id, vlm_sem
|
||||
)
|
||||
log_msg("INFO", "L2 VLM 完成,已触发 L3 任务", l2_id=l2_id)
|
||||
|
||||
completed_l2 = await self._build_l3_task_async(
|
||||
stream_url, l2_node, clip_range, source_id, i, j, vlm_sem
|
||||
)
|
||||
log_msg(
|
||||
"INFO", "L3 完成",
|
||||
l2_id=l2_id,
|
||||
l3_count=len(completed_l2.children),
|
||||
)
|
||||
return (j, completed_l2)
|
||||
|
||||
# 所有 clip 同时启动(不等 L2 全部结束再开 L3)
|
||||
pairs = await asyncio.gather(*[_chain(j, clip) for j, clip in enumerate(clips)])
|
||||
ordered_l2 = [p[1] for p in sorted(pairs, key=lambda x: x[0])]
|
||||
|
||||
log_msg("INFO", "L1 触发", l1_id=f"l1_{i}")
|
||||
l1_node = await self._build_l1_video_async(
|
||||
ordered_l2, f"l1_{i}", l1_range, vlm_sem
|
||||
)
|
||||
log_msg(
|
||||
"INFO", "L1 节点构建完成",
|
||||
l1_id=f"l1_{i}",
|
||||
l2_count=len(ordered_l2),
|
||||
)
|
||||
return l1_node
|
||||
|
||||
total_clips = sum(len(self._get_l2_clips(r)) for r in l1_ranges)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"开始并发构建(L2→L3链式,L1段间并发,支持断点续跑)",
|
||||
total_l2=total_clips,
|
||||
concurrency=self.config.concurrency,
|
||||
)
|
||||
|
||||
# Phase 2: 并发构建尚未完成的 L1 段(段内 L2+L3 链式并发)
|
||||
tasks: List[asyncio.Task[L1Node]] = []
|
||||
task_indices: List[int] = []
|
||||
for i, r in enumerate(l1_ranges):
|
||||
# 已完成且中间 JSON 存在 → 直接复用
|
||||
if i in finished_l1_ids and self._has_l1_intermediate(source_id, i):
|
||||
continue
|
||||
tasks.append(asyncio.create_task(_build_segment(i, r)))
|
||||
task_indices.append(i)
|
||||
|
||||
new_l1_nodes: Dict[int, L1Node] = {}
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks)
|
||||
for idx, node in zip(task_indices, results):
|
||||
# 每完成一个 L1 段就写入中间 JSON,并刷新进度文件
|
||||
self._save_l1_intermediate(source_id, node, idx)
|
||||
finished_l1_ids.add(idx)
|
||||
new_l1_nodes[idx] = node
|
||||
self._save_progress(source_id, total_l1, finished_l1_ids)
|
||||
|
||||
# Phase 3: 汇总所有 L1 段(中间 + 新生成)
|
||||
l1_nodes: List[L1Node] = []
|
||||
for i in range(total_l1):
|
||||
if i in new_l1_nodes:
|
||||
l1_nodes.append(new_l1_nodes[i])
|
||||
continue
|
||||
node = self._load_l1_intermediate(source_id, i)
|
||||
ensure(node is not None, f"L1 段 {i} 缺失中间结果,无法恢复")
|
||||
l1_nodes.append(node)
|
||||
|
||||
# Phase 6: 组装 TreeIndex
|
||||
metadata = IndexMeta(
|
||||
source_path=video_path,
|
||||
modality="video",
|
||||
created_at=datetime.now().isoformat(),
|
||||
)
|
||||
index = TreeIndex(metadata=metadata, roots=l1_nodes)
|
||||
|
||||
total_l2_count = sum(len(r.children) for r in l1_nodes)
|
||||
total_l3_count = sum(len(l2.children) for r in l1_nodes for l2 in r.children)
|
||||
log_json(
|
||||
"video_tree_build",
|
||||
{
|
||||
"source_path": video_path,
|
||||
"l1_count": len(l1_nodes),
|
||||
"l2_count": total_l2_count,
|
||||
"l3_count": total_l3_count,
|
||||
"embedded": False,
|
||||
},
|
||||
)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"视频树索引构建完成",
|
||||
l1=len(l1_nodes),
|
||||
l2=total_l2_count,
|
||||
l3=total_l3_count,
|
||||
)
|
||||
# 最终 JSON 写入成功后,清理由断点机制生成的中间文件
|
||||
self._cleanup_intermediate_and_progress(source_id)
|
||||
return index
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:时间切分(同步,仅执行一次)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _segment_video(
|
||||
self,
|
||||
video_path: str,
|
||||
duration_hint: Optional[float] = None,
|
||||
) -> List[Tuple[float, float]]:
|
||||
"""读取视频总时长,按固定步长切分为 L1 时间区间列表。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
duration_hint: 已知视频时长(秒)。传入时跳过 cv2 读取。
|
||||
|
||||
返回:
|
||||
L1 时间区间列表,每项为 (start_sec, end_sec)。
|
||||
"""
|
||||
if duration_hint is not None:
|
||||
total_duration = duration_hint
|
||||
else:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
ensure(cap.isOpened(), f"无法打开视频文件: {video_path}")
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
total_frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
cap.release()
|
||||
ensure(fps > 0, f"视频 FPS 读取异常: {fps}")
|
||||
ensure(total_frames > 0, f"视频总帧数读取异常: {total_frames}")
|
||||
total_duration = total_frames / fps
|
||||
|
||||
step = self.config.l1_segment_duration
|
||||
ranges: List[Tuple[float, float]] = []
|
||||
start = 0.0
|
||||
while start < total_duration:
|
||||
end = min(start + step, total_duration)
|
||||
ranges.append((start, end))
|
||||
start = end
|
||||
|
||||
log_msg(
|
||||
"INFO",
|
||||
"L1 时间切分",
|
||||
total_duration=round(total_duration, 2),
|
||||
l1_count=len(ranges),
|
||||
)
|
||||
return ranges
|
||||
|
||||
def _get_l2_clips(self, l1_range: Tuple[float, float]) -> List[Tuple[float, float]]:
|
||||
"""将 L1 时间区间等分为 L2 clips。
|
||||
|
||||
参数:
|
||||
l1_range: L1 时间区间 (start, end),单位秒。
|
||||
|
||||
返回:
|
||||
L2 clip 时间区间列表。
|
||||
"""
|
||||
start, end = l1_range
|
||||
step = self.config.l2_clip_duration
|
||||
clips: List[Tuple[float, float]] = []
|
||||
t = start
|
||||
while t < end:
|
||||
clip_end = min(t + step, end)
|
||||
clips.append((t, clip_end))
|
||||
t = clip_end
|
||||
return clips
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:帧提取(ffmpeg subprocess,在线程池执行)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ffmpeg_extract_frame(self, video_path: str, ts: float, out_path: str) -> bool:
|
||||
"""用 ffmpeg subprocess 提取单帧图像,兼容 AV1/H.264 等所有编码格式。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径(本地 MP4 或 CDN URL)。
|
||||
ts: 目标时间戳(秒)。
|
||||
out_path: 输出 JPEG 文件路径。
|
||||
|
||||
返回:
|
||||
True 表示提取成功,False 表示失败。
|
||||
"""
|
||||
cmd = [
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-ss", f"{ts:.3f}",
|
||||
"-i", video_path,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "2",
|
||||
"-y", out_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
return result.returncode == 0 and os.path.isfile(out_path)
|
||||
|
||||
async def _extract_frames_async(
|
||||
self,
|
||||
video_path: str,
|
||||
time_range: Tuple[float, float],
|
||||
fps: float,
|
||||
source_id: Optional[str] = None,
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""异步并发提取时间范围内的帧,保存到 cache 目录。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
time_range: 提取时间区间 (start_sec, end_sec)。
|
||||
fps: 提取帧率(帧/秒)。
|
||||
source_id: 帧缓存目录名。
|
||||
|
||||
返回:
|
||||
[(frame_path, timestamp_sec), ...],按时间顺序排列。
|
||||
|
||||
实现细节:
|
||||
所有 ffmpeg 提取任务通过 run_in_executor(self._ffmpeg_pool, ...) 并发执行,
|
||||
已缓存的帧直接跳过(无需调用 ffmpeg)。
|
||||
ffmpeg 线程池 max_workers=_FFMPEG_MAX_WORKERS 防止过度并发占满 CPU。
|
||||
"""
|
||||
video_stem = source_id if source_id is not None else self._source_stem(video_path)
|
||||
frame_dir = Path(self.config.cache_dir) / "frames" / video_stem
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
start_sec, end_sec = time_range
|
||||
step = 1.0 / fps
|
||||
|
||||
timestamps: List[float] = []
|
||||
t = start_sec
|
||||
while t < end_sec:
|
||||
timestamps.append(t)
|
||||
t += step
|
||||
|
||||
if not timestamps:
|
||||
log_msg("WARNING", "帧提取时间区间内无有效时间戳", time_range=time_range, fps=fps)
|
||||
return []
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _extract_one(ts: float) -> Optional[Tuple[str, float]]:
|
||||
"""提取单帧:缓存命中直接返回,否则在线程池中调用 ffmpeg。"""
|
||||
frame_name = f"{start_sec:.1f}_{ts:.3f}.jpg"
|
||||
frame_path = str(frame_dir / frame_name)
|
||||
|
||||
if os.path.isfile(frame_path):
|
||||
return (frame_path, ts)
|
||||
|
||||
success = await loop.run_in_executor(
|
||||
self._ffmpeg_pool,
|
||||
self._ffmpeg_extract_frame, video_path, ts, frame_path,
|
||||
)
|
||||
if not success:
|
||||
log_msg("WARNING", "帧读取失败,跳过", timestamp=ts, video_path=video_path)
|
||||
return None
|
||||
return (frame_path, ts)
|
||||
|
||||
# 并发提取所有帧(受 ffmpeg 线程池限制,不会无限并发)
|
||||
results = await asyncio.gather(*[_extract_one(ts) for ts in timestamps])
|
||||
return [r for r in results if r is not None]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:L1 中间结果与进度管理(断点续跑)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _intermediate_dir(self, stem: str) -> Path:
|
||||
"""获取某视频的中间结果目录路径。"""
|
||||
return self._cache_root / "intermediate" / stem
|
||||
|
||||
def _progress_path(self, stem: str) -> Path:
|
||||
"""获取某视频的进度文件路径。"""
|
||||
return self._cache_root / "progress" / f"{stem}.json"
|
||||
|
||||
def _has_l1_intermediate(self, stem: str, l1_idx: int) -> bool:
|
||||
"""检查某 L1 段的中间 JSON 是否存在。"""
|
||||
path = self._intermediate_dir(stem) / f"l1_{l1_idx}.json"
|
||||
return path.is_file()
|
||||
|
||||
def _save_l1_intermediate(self, stem: str, l1_node: L1Node, l1_idx: int) -> None:
|
||||
"""将单个 L1 段的中间结果保存到 JSON 文件。"""
|
||||
dir_path = self._intermediate_dir(stem)
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
out_path = dir_path / f"l1_{l1_idx}.json"
|
||||
save_l1_json(str(out_path), l1_node)
|
||||
|
||||
def _load_l1_intermediate(self, stem: str, l1_idx: int) -> Optional[L1Node]:
|
||||
"""从中间 JSON 加载单个 L1 段,若不存在则返回 None。"""
|
||||
path = self._intermediate_dir(stem) / f"l1_{l1_idx}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
return load_l1_json(str(path))
|
||||
|
||||
def _load_progress(self, stem: str) -> Optional[Dict[str, object]]:
|
||||
"""加载某视频的进度文件(若不存在则返回 None)。"""
|
||||
path = self._progress_path(stem)
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
data: Dict[str, object] = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
log_msg("WARNING", "进度文件 JSON 解析失败,忽略", path=str(path))
|
||||
return None
|
||||
return data
|
||||
|
||||
def _save_progress(self, stem: str, total_l1: int, finished_l1_ids: set[int]) -> None:
|
||||
"""将最新进度写回磁盘,支持断点续跑。"""
|
||||
path = self._progress_path(stem)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"video_id": stem,
|
||||
"total_l1": total_l1,
|
||||
"finished_l1_ids": sorted(finished_l1_ids),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
if not path.is_file():
|
||||
payload["created_at"] = payload["updated_at"]
|
||||
else:
|
||||
# 尝试保留旧 created_at
|
||||
old = self._load_progress(stem)
|
||||
if old and isinstance(old.get("created_at"), str):
|
||||
payload["created_at"] = old["created_at"]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"进度文件已更新",
|
||||
path=str(path),
|
||||
total_l1=total_l1,
|
||||
finished_l1=list(sorted(finished_l1_ids)),
|
||||
)
|
||||
|
||||
def _cleanup_intermediate_and_progress(self, stem: str) -> None:
|
||||
"""在最终 JSON 写入成功后清理中间结果与进度文件。"""
|
||||
# 清理 progress
|
||||
progress_path = self._progress_path(stem)
|
||||
if progress_path.is_file():
|
||||
try:
|
||||
progress_path.unlink()
|
||||
except OSError:
|
||||
log_msg("WARNING", "删除进度文件失败", path=str(progress_path))
|
||||
|
||||
# 清理 intermediate 目录
|
||||
inter_dir = self._intermediate_dir(stem)
|
||||
if inter_dir.is_dir():
|
||||
for child in inter_dir.glob("l1_*.json"):
|
||||
try:
|
||||
child.unlink()
|
||||
except OSError:
|
||||
log_msg("WARNING", "删除 L1 中间 JSON 失败", path=str(child))
|
||||
try:
|
||||
# 目录可能仍有其它调试文件,忽略删除异常
|
||||
inter_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:异步节点构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _build_l2_video_async(
|
||||
self,
|
||||
video_path: str,
|
||||
clip_range: Tuple[float, float],
|
||||
l2_id: str,
|
||||
source_id: Optional[str],
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> L2Node:
|
||||
"""异步构建 L2 视频节点(代表帧 VLM 描述)。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
clip_range: L2 clip 时间区间 (start, end),单位秒。
|
||||
l2_id: 节点 ID。
|
||||
source_id: 帧缓存目录名。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
L2Node(children 为空,由后续 L3 阶段填充)。
|
||||
|
||||
实现细节:
|
||||
均匀采样 l2_representative_frames 帧,并行 ffmpeg 提取,
|
||||
async with vlm_sem 限制 VLM 并发量。
|
||||
"""
|
||||
start_sec, end_sec = clip_range
|
||||
n_rep = self.config.l2_representative_frames
|
||||
|
||||
if n_rep == 1:
|
||||
timestamps = [(start_sec + end_sec) / 2.0]
|
||||
else:
|
||||
step = (end_sec - start_sec) / (n_rep - 1)
|
||||
timestamps = [start_sec + i * step for i in range(n_rep)]
|
||||
|
||||
video_stem = source_id if source_id is not None else self._source_stem(video_path)
|
||||
frame_dir = Path(self.config.cache_dir) / "frames" / video_stem
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _extract_rep(ts: float) -> Optional[str]:
|
||||
frame_name = f"l2_{ts:.3f}.jpg"
|
||||
frame_path = str(frame_dir / frame_name)
|
||||
if os.path.isfile(frame_path):
|
||||
return frame_path
|
||||
success = await loop.run_in_executor(
|
||||
self._ffmpeg_pool,
|
||||
self._ffmpeg_extract_frame, video_path, ts, frame_path,
|
||||
)
|
||||
if not success:
|
||||
log_msg("WARNING", "L2 代表帧读取失败,跳过", timestamp=ts)
|
||||
return None
|
||||
return frame_path
|
||||
|
||||
# 并发提取所有代表帧
|
||||
rep_results = await asyncio.gather(*[_extract_rep(ts) for ts in timestamps])
|
||||
rep_frames = [p for p in rep_results if p is not None]
|
||||
ensure(len(rep_frames) > 0, f"L2 节点 {l2_id} 代表帧提取结果为空")
|
||||
|
||||
# VLM 调用受信号量保护
|
||||
async with vlm_sem:
|
||||
description = await self.vlm.chat_with_images_async(
|
||||
_L2_VIDEO_PROMPT, images=rep_frames
|
||||
)
|
||||
|
||||
return L2Node(
|
||||
id=l2_id,
|
||||
description=description,
|
||||
embedding=None,
|
||||
time_range=clip_range,
|
||||
)
|
||||
|
||||
async def _build_l3_task_async(
|
||||
self,
|
||||
video_path: str,
|
||||
l2_node: L2Node,
|
||||
clip_range: Tuple[float, float],
|
||||
source_id: str,
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> L2Node:
|
||||
"""异步 L3 任务:并发提帧 + 批次级并发 VLM 帧描述。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
l2_node: 已构建的 L2 节点。
|
||||
clip_range: L2 clip 时间区间。
|
||||
source_id: 帧缓存目录名。
|
||||
l1_i: 父 L1 索引。
|
||||
l2_j: 父 L2 索引。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
已填充 children 的 L2Node。
|
||||
|
||||
实现细节:
|
||||
提帧阶段完全并行(受 ffmpeg 线程池限制);
|
||||
VLM 调用阶段:12个批次同时提交(asyncio.gather),受信号量限流。
|
||||
"""
|
||||
all_frames = await self._extract_frames_async(
|
||||
video_path, clip_range, self.config.l3_fps, source_id=source_id
|
||||
)
|
||||
l3_nodes = await self._build_l3_video_async(
|
||||
all_frames, l2_node.description, l1_i, l2_j, vlm_sem
|
||||
)
|
||||
l2_node.children = l3_nodes
|
||||
return l2_node
|
||||
|
||||
async def _build_l3_video_async(
|
||||
self,
|
||||
frames: List[Tuple[str, float]],
|
||||
l2_description: str,
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> List[L3Node]:
|
||||
"""异步批次级并发构建 L3 节点(核心加速点)。
|
||||
|
||||
参数:
|
||||
frames: [(frame_path, timestamp), ...]。
|
||||
l2_description: L2 节点描述,注入 prompt 上下文。
|
||||
l1_i: 父 L1 索引(用于节点 ID 生成)。
|
||||
l2_j: 父 L2 索引(用于节点 ID 生成)。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
L3Node 列表,每项对应一帧。
|
||||
|
||||
实现细节:
|
||||
将全部帧按 _L3_BATCH_SIZE 分批,所有批次同时提交(asyncio.gather),
|
||||
每批通过 vlm_sem 限流,实现批次级真并发。
|
||||
对比旧版串行:12批 × 6s = 72s → 现在 ~6s(受信号量限流取最慢批次)。
|
||||
"""
|
||||
ensure(len(frames) > 0, f"L3 帧列表为空 (l1={l1_i}, l2={l2_j})")
|
||||
|
||||
# Phase 1: 构建所有批次的协程(同时提交,asyncio.gather 并发执行)
|
||||
batches: List[List[Tuple[str, float]]] = []
|
||||
for batch_start in range(0, len(frames), _L3_BATCH_SIZE):
|
||||
batches.append(frames[batch_start : batch_start + _L3_BATCH_SIZE])
|
||||
|
||||
batch_results: List[List[str]] = list(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
self._call_vlm_batch_async(
|
||||
batch, l2_description, l1_i, l2_j, vlm_sem
|
||||
)
|
||||
for batch in batches
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Phase 2: 展平所有批次描述,构建 L3 节点
|
||||
all_descriptions: List[str] = [
|
||||
desc for batch_descs in batch_results for desc in batch_descs
|
||||
]
|
||||
|
||||
nodes: List[L3Node] = []
|
||||
for k, (desc, (frame_path, ts)) in enumerate(zip(all_descriptions, frames)):
|
||||
nodes.append(
|
||||
L3Node(
|
||||
id=f"l1_{l1_i}_l2_{l2_j}_l3_{k}",
|
||||
description=desc,
|
||||
embedding=None,
|
||||
raw_content=None,
|
||||
frame_path=frame_path,
|
||||
timestamp=ts,
|
||||
)
|
||||
)
|
||||
return nodes
|
||||
|
||||
async def _call_vlm_batch_async(
|
||||
self,
|
||||
batch: List[Tuple[str, float]],
|
||||
l2_description: str,
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> List[str]:
|
||||
"""异步单批次 VLM 调用(≤ _L3_BATCH_SIZE 帧),解析失败时逐帧 fallback。
|
||||
|
||||
参数:
|
||||
batch: 本批帧列表 [(frame_path, ts), ...]。
|
||||
l2_description: L2 描述,用于 prompt 和 fallback prompt。
|
||||
l1_i: 父 L1 索引(日志用)。
|
||||
l2_j: 父 L2 索引(日志用)。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
与 batch 等长的描述文本列表。
|
||||
|
||||
实现细节:
|
||||
async with vlm_sem 确保并发量不超过 config.concurrency。
|
||||
fallback 时逐帧并发(asyncio.gather),同样受信号量保护。
|
||||
"""
|
||||
batch_paths = [fp for fp, _ in batch]
|
||||
n = len(batch_paths)
|
||||
prompt = _L3_VIDEO_PROMPT.format(l2_description=l2_description, n=n)
|
||||
|
||||
# Phase 1: 尝试批量调用
|
||||
try:
|
||||
async with vlm_sem:
|
||||
raw = await self.vlm.chat_with_images_async(prompt, images=batch_paths)
|
||||
descriptions = self._parse_json_descriptions(raw, n)
|
||||
if descriptions is not None:
|
||||
return descriptions
|
||||
log_msg(
|
||||
"WARNING",
|
||||
"L3 小批量 VLM JSON 解析失败,对本批逐帧 fallback",
|
||||
l1=l1_i,
|
||||
l2=l2_j,
|
||||
batch_n=n,
|
||||
raw_preview=raw[:100],
|
||||
)
|
||||
except Exception as exc:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"L3 小批量 VLM 调用异常,对本批逐帧 fallback: {exc}",
|
||||
l1=l1_i,
|
||||
l2=l2_j,
|
||||
batch_n=n,
|
||||
)
|
||||
|
||||
# Phase 2: 逐帧 fallback(并发,受信号量保护)
|
||||
single_prompt = _L3_SINGLE_PROMPT.format(l2_description=l2_description)
|
||||
|
||||
async def _single_frame(fp: str) -> str:
|
||||
async with vlm_sem:
|
||||
return await self.vlm.chat_with_images_async(single_prompt, images=[fp])
|
||||
|
||||
return list(await asyncio.gather(*[_single_frame(fp) for fp in batch_paths]))
|
||||
|
||||
async def _build_l1_video_async(
|
||||
self,
|
||||
l2_children: List[L2Node],
|
||||
l1_id: str,
|
||||
l1_range: Tuple[float, float],
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> L1Node:
|
||||
"""异步构建 L1 节点(LLM 文本摘要)。
|
||||
|
||||
参数:
|
||||
l2_children: 该 L1 节点下的所有 L2 节点。
|
||||
l1_id: 节点 ID。
|
||||
l1_range: L1 时间区间 (start, end),单位秒。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
L1Node(children 已赋值)。
|
||||
"""
|
||||
ensure(len(l2_children) > 0, f"L1 节点 {l1_id} 没有 L2 子节点")
|
||||
l2_texts = "\n".join(f"- {node.description}" for node in l2_children)
|
||||
prompt = _L1_VIDEO_PROMPT.format(l2_texts=l2_texts)
|
||||
|
||||
async with vlm_sem:
|
||||
summary = await self.vlm.chat_async(prompt)
|
||||
|
||||
log_msg("INFO", "L1 触发", l1_id=l1_id)
|
||||
return L1Node(
|
||||
id=l1_id,
|
||||
summary=summary,
|
||||
embedding=None,
|
||||
time_range=l1_range,
|
||||
children=l2_children,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:JSON 解析(同步,纯 CPU)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_json_descriptions(
|
||||
self, raw: str, expected_n: int
|
||||
) -> Optional[List[str]]:
|
||||
"""从 VLM 输出中解析 JSON 描述数组。
|
||||
|
||||
参数:
|
||||
raw: VLM 原始返回字符串。
|
||||
expected_n: 期望的描述条数。
|
||||
|
||||
返回:
|
||||
成功解析且长度匹配时返回 List[str],否则返回 None。
|
||||
"""
|
||||
raw = raw.strip()
|
||||
code_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, re.DOTALL)
|
||||
if code_match:
|
||||
raw = code_match.group(1)
|
||||
|
||||
if not raw.startswith("["):
|
||||
return None
|
||||
|
||||
try:
|
||||
items: List[str] = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(items, list) or len(items) != expected_n:
|
||||
return None
|
||||
|
||||
return [str(item).strip() for item in items]
|
||||
Reference in New Issue
Block a user