diff --git a/examples/custom-parser-plugin/README.md b/examples/custom-parser-plugin/README.md new file mode 100644 index 000000000..c2e20e08c --- /dev/null +++ b/examples/custom-parser-plugin/README.md @@ -0,0 +1,159 @@ +# Custom Parser Plugin Example + +这个目录演示如何为 OpenViking 编写和注册自定义 parser。 + +当前包含两个示例: + +- `custom_txt_parser.py` + 演示如何接管 `.txt` / `.text` 文件,并在解析前做自定义预处理。 +- `custom_jsonl_parser.py` + 演示如何接管 `.jsonl` 文件,把每一行的 `title` / `content` 记录转换成 Markdown,再复用内置 `MarkdownParser`。 + +## 目录结构 + +```text +examples/custom-parser-plugin/ +├── README.md +├── custom_jsonl_parser.py +├── custom_txt_parser.py +├── example-dir/ +│ ├── new_supported_jsonl.jsonl +│ └── pure_text_file.txt +└── ov.conf +``` + +## JSONL 示例说明 + +`custom_jsonl_parser.py` 只支持示例格式,不追求通用 JSONL 兼容性。 + +输入文件格式: + +```jsonl +{"title": "test title1", "content": "test content1"} +{"title": "test title2", "content": "test content2"} +``` + +对应示例文件: + +- `example-dir/new_supported_jsonl.jsonl` + +解析逻辑很简单: + +1. 逐行读取 JSONL +2. 每一行必须是 JSON object +3. 每一行必须包含 `title` 和 `content` +4. 转换成下面这种 Markdown + +```md +# test title1 + +test content1 + +# test title2 + +test content2 +``` + +5. 把转换后的 Markdown 交给 `MarkdownParser` + +这样做的好处是示例代码足够短,同时又能直接复用 OpenViking 现有的 Markdown 解析能力。 + +## `MyCustomJsonlParser` 核心代码 + +```python +class MyCustomJsonlParser(BaseParser): + @property + def supported_extensions(self) -> List[str]: + return [".jsonl"] + + async def parse_content( + self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs + ) -> ParseResult: + markdown_content = self._jsonl_to_markdown(content) + result = await self._md_parser.parse_content( + markdown_content, + source_path=source_path, + instruction=instruction, + **kwargs, + ) + result.source_format = "jsonl" + result.parser_name = "MyCustomJsonlParser" + return result +``` + +## 配置方式 + +OpenViking 支持在 `ov.conf` 中注册自定义 parser: + +```json +{ + "custom_parsers": { + "my-jsonl-parser": { + "class": "your_package.custom_jsonl_parser.MyCustomJsonlParser", + "extensions": [".jsonl"] + } + } +} +``` + +`class` 必须指向一个可以被 Python `import` 的类路径。 + +## 重要说明 + +当前示例目录名是 `custom-parser-plugin`,目录名里有 `-`。这意味着: + +- `examples.custom-parser-plugin.custom_jsonl_parser.MyCustomJsonlParser` +- `examples.custom-parser-plugin.custom_txt_parser.MyCustomTxtParser` + +这两种写法都不能作为真实的 Python import 路径直接使用。 + +如果你想把这里的示例真正注册到 `ov.conf` 中运行,应该把 parser 文件放到一个可导入的模块路径下,例如: + +```text +my_parsers/ +├── __init__.py +├── custom_jsonl_parser.py +└── custom_txt_parser.py +``` + +然后在配置中写: + +```json +{ + "custom_parsers": { + "my-jsonl-parser": { + "class": "my_parsers.custom_jsonl_parser.MyCustomJsonlParser", + "extensions": [".jsonl"] + }, + "my-txt-parser": { + "class": "my_parsers.custom_txt_parser.MyCustomTxtParser", + "extensions": [".txt", ".text"], + "kwargs": { + "plugin_name": "my-txt-parser", + "version": "1.0" + } + } + } +} +``` + +## 本地验证建议 + +如果你只是想快速理解这个 JSONL 示例,先看这两个文件即可: + +- `custom_jsonl_parser.py` +- `example-dir/new_supported_jsonl.jsonl` + +如果你要在自己的项目中使用: + +1. 把 parser 文件移动到可导入的 Python package +2. 修改 `ov.conf` 里的 `class` +3. 为目标扩展名写入 `custom_parsers` +4. 启动 OpenViking server,确认自定义 parser 已覆盖对应扩展名 + +## 相关文件 + +- `custom_jsonl_parser.py` +- `custom_txt_parser.py` +- `ov.conf` +- `example-dir/new_supported_jsonl.jsonl` diff --git a/examples/custom-parser-plugin/custom_jsonl_parser.py b/examples/custom-parser-plugin/custom_jsonl_parser.py new file mode 100644 index 000000000..42f4518a8 --- /dev/null +++ b/examples/custom-parser-plugin/custom_jsonl_parser.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import List, Optional, Union + +from openviking.parse.base import ParseResult +from openviking.parse.parsers.base_parser import BaseParser +from openviking.parse.parsers.markdown import MarkdownParser +from openviking_cli.utils.config.parser_config import ParserConfig +from openviking_cli.utils.logger import get_logger + +logger = get_logger(__name__) + + +class MyCustomJsonlParser(BaseParser): + """Example JSONL parser for records with `title` and `content` fields.""" + + def __init__( + self, + config: Optional[ParserConfig] = None, + ): + self.config = config or ParserConfig() + self._md_parser = MarkdownParser(config=self.config) + + logger.info("MyCustomJsonlParser initialized") + + @property + def supported_extensions(self) -> List[str]: + return [".jsonl"] + + async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult: + path = Path(source) + if path.exists(): + content = self._read_file(path) + return await self.parse_content( + content, + source_path=str(path), + instruction=instruction, + **kwargs, + ) + + return await self.parse_content(str(source), instruction=instruction, **kwargs) + + async def parse_content( + self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs + ) -> ParseResult: + markdown_content = self._jsonl_to_markdown(content) + result = await self._md_parser.parse_content( + markdown_content, + source_path=source_path, + instruction=instruction, + **kwargs, + ) + result.source_format = "jsonl" + result.parser_name = "MyCustomJsonlParser" + return result + + def _jsonl_to_markdown(self, content: str) -> str: + sections = [] + for line_number, raw_line in enumerate(content.splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON on line {line_number}") from exc + + if not isinstance(record, dict): + raise ValueError(f"Line {line_number} must be a JSON object") + if "title" not in record or "content" not in record: + raise ValueError(f"Line {line_number} must contain 'title' and 'content'") + + sections.append(f"# {record['title']}\n\n{record['content']}") + + return "\n\n".join(sections) + + +__all__ = ["MyCustomJsonlParser"] diff --git a/examples/custom-parser-plugin/custom_txt_parser.py b/examples/custom-parser-plugin/custom_txt_parser.py new file mode 100644 index 000000000..bc6e49bd5 --- /dev/null +++ b/examples/custom-parser-plugin/custom_txt_parser.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional, Union + +from openviking.parse.base import ParseResult +from openviking.parse.parsers.base_parser import BaseParser +from openviking.parse.parsers.markdown import MarkdownParser +from openviking_cli.utils.config.parser_config import ParserConfig +from openviking_cli.utils.logger import get_logger + +logger = get_logger(__name__) + + +class MyCustomTxtParser(BaseParser): + def __init__( + self, + config: Optional[ParserConfig] = None, + plugin_name: str = "txt parser", + version: str = "0.0.1", + ): + self.plugin_name = plugin_name + self.version = version + + self._md_parser = MarkdownParser(config=config) + self.config = config or ParserConfig() + logger.critical(f"MyCustomTxtParser initialized: {self.plugin_name} {self.version}") + + @property + def supported_extensions(self) -> List[str]: + return [".txt"] + + async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult: + logger.critical(f"custom parse txt: {source}, AND ONLY PARSE THE FIRST 500 CHARACTERS") + + path = Path(source) + if path.exists(): + content = self._read_file(path)[:500] + "..." + return await self.parse_content( + content, + source_path=str(path), + instruction=instruction, + **kwargs, + ) + + return await self.parse_content(str(source), instruction=instruction, **kwargs) + + async def parse_content( + self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs + ) -> ParseResult: + result = await self._md_parser.parse_content( + content, source_path=source_path, instruction=instruction, **kwargs + ) + result.source_format = "txt" + result.parser_name = "MyCustomTxtParser" + return result + + +__all__ = ["MyCustomTxtParser"] diff --git a/examples/custom-parser-plugin/example-dir/new_supported_jsonl.jsonl b/examples/custom-parser-plugin/example-dir/new_supported_jsonl.jsonl new file mode 100644 index 000000000..0da0b2771 --- /dev/null +++ b/examples/custom-parser-plugin/example-dir/new_supported_jsonl.jsonl @@ -0,0 +1,4 @@ +{"title": "test title1", "content": "test content1"} +{"title": "test title2", "content": "test content2"} +{"title": "test title3", "content": "test content3"} +{"title": "test title4", "content": "test content4"} \ No newline at end of file diff --git a/examples/custom-parser-plugin/example-dir/pure_text_file.txt b/examples/custom-parser-plugin/example-dir/pure_text_file.txt new file mode 100644 index 000000000..c64168e64 --- /dev/null +++ b/examples/custom-parser-plugin/example-dir/pure_text_file.txt @@ -0,0 +1,711 @@ +
+ + + OpenViking + + + +### OpenViking:AI 智能体的上下文数据库 + +[English](README.md) / 中文 / [日本語](README_JA.md) + +官网 · GitHub · 问题反馈 · 文档 + +[![][release-shield]][release-link] +[![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-shield-link] +[![][github-contributors-shield]][github-contributors-link] +[![][license-shield]][license-shield-link] +[![][last-commit-shield]][last-commit-shield-link] + + +👋 加入我们的社区 + +📱 飞书群 · 微信群 · Discord · X + +
+ +--- + +## 概述 + +### 智能体开发面临的挑战 + +在 AI 时代,数据丰富,但高质量的上下文却难以获得。在构建 AI 智能体时,开发者经常面临以下挑战: + +- **上下文碎片化**:记忆存储在代码中,资源在向量数据库中,技能分散在各处,难以统一管理。 +- **上下文需求激增**:智能体的长运行任务在每次执行时都会产生上下文。简单的截断或压缩会导致信息丢失。 +- **检索效果不佳**:传统 RAG 使用扁平化存储,缺乏全局视图,难以理解信息的完整上下文。 +- **上下文不可观察**:传统 RAG 的隐式检索链像黑盒,出错时难以调试。 +- **记忆迭代有限**:当前记忆只是用户交互的记录,缺乏智能体相关的任务记忆。 + +### OpenViking 解决方案 + +**OpenViking** 是专为 AI 智能体设计的开源**上下文数据库**。 + +我们的目标是为智能体定义一个极简的上下文交互范式,让开发者完全告别上下文管理的烦恼。OpenViking 抛弃了传统 RAG 的碎片化向量存储模型,创新性地采用 **"文件系统范式"** 来统一组织智能体所需的记忆、资源和技能。 + +使用 OpenViking,开发者可以像管理本地文件一样构建智能体的大脑: + +- **文件系统管理范式** → **解决碎片化**:基于文件系统范式统一管理记忆、资源和技能。 +- **分层上下文加载** → **降低 Token 消耗**:L0/L1/L2 三层结构,按需加载,显著节省成本。 +- **目录递归检索** → **提升检索效果**:支持原生文件系统检索方式,结合目录定位和语义搜索,实现递归精准的上下文获取。 +- **可视化检索轨迹** → **可观察上下文**:支持目录检索轨迹可视化,让用户清晰观察问题根源,指导检索逻辑优化。 +- **自动会话管理** → **上下文自迭代**:自动压缩对话中的内容、资源引用、工具调用等,提取长期记忆,让智能体越用越聪明。 + +--- + +## 快速开始 + +### 前置条件 + +在开始使用 OpenViking 之前,请确保您的环境满足以下要求: + +- **Python 版本**:3.10 或更高版本 +- **Go 版本**:1.22 或更高(从源码构建 AGFS 组件需要) +- **C++ 编译器**:GCC 9+ 或 Clang 11+(构建核心扩展需要,必须支持 C++17) +- **操作系统**:Linux、macOS、Windows +- **网络连接**:需要稳定的网络连接(用于下载依赖和访问模型服务) + +### 1. 安装 + +#### Python 包 + +```bash +pip install openviking --upgrade --force-reinstall +``` + +#### Rust CLI(可选) + +```bash +curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/main/crates/ov_cli/install.sh | bash +``` + +或从源码构建: + +```bash +cargo install --git https://github.com/volcengine/OpenViking ov_cli +``` + +### 2. 模型准备 + +OpenViking 需要以下模型能力: +- **VLM 模型**:用于图像和内容理解 +- **Embedding 模型**:用于向量化和语义检索 + +#### 支持的 VLM 提供商 + +OpenViking 支持三种 VLM 提供商: + +| 提供商 | 描述 | 获取 API Key | +|----------|-------------|-------------| +| `volcengine` | 火山引擎豆包模型 | [Volcengine 控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_content=OpenViking&utm_medium=devrel&utm_source=OWO&utm_term=OpenViking) | +| `openai` | OpenAI 官方 API | [OpenAI 平台](https://platform.openai.com) | +| `azure` | Azure OpenAI 服务 | [Azure OpenAI 服务](https://portal.azure.com) | +| `litellm` | 统一调用多种第三方模型 (Anthropic, DeepSeek, Gemini, vLLM, Ollama 等) | 参见 [LiteLLM 提供商](https://docs.litellm.ai/docs/providers) | + +> 💡 **提示**: +> - `litellm` 支持通过统一接口调用多种模型,model 字段需遵循 [LiteLLM 格式规范](https://docs.litellm.ai/docs/providers) +> - 系统自动检测常见模型(如 `claude-*`, `deepseek-*`, `gemini-*`, `hosted_vllm/*`, `ollama/*` 等),其他模型需按 LiteLLM 格式填写完整前缀 + +#### 提供商特定说明 + +
+Volcengine (豆包) + +Volcengine 支持模型名称和端点 ID。为简单起见,建议使用模型名称: + +```json +{ + "vlm": { + "provider": "volcengine", + "model": "doubao-seed-2-0-pro-260215", + "api_key": "your-api-key", + "api_base": "https://ark.cn-beijing.volces.com/api/v3" + } +} +``` + +您也可以使用端点 ID(可在 [Volcengine ARK 控制台](https://console.volcengine.com/ark) 中找到): + +```json +{ + "vlm": { + "provider": "volcengine", + "model": "ep-20241220174930-xxxxx", + "api_key": "your-api-key", + "api_base": "https://ark.cn-beijing.volces.com/api/v3" + } +} +``` + +
+ +
+OpenAI + +使用 OpenAI 的官方 API: + +```json +{ + "vlm": { + "provider": "openai", + "model": "gpt-4o", + "api_key": "your-api-key", + "api_base": "https://api.openai.com/v1" + } +} +``` + +您也可以使用自定义的 OpenAI 兼容端点: + +```json +{ + "vlm": { + "provider": "openai", + "model": "gpt-4o", + "api_key": "your-api-key", + "api_base": "https://your-custom-endpoint.com/v1" + } +} +``` + +
+ +
+Azure OpenAI + +使用 Azure OpenAI 服务。`model` 字段需要填写 Azure 上的**部署名称(deployment name)**,而非模型官方名字: + +```json +{ + "vlm": { + "provider": "azure", + "model": "your-deployment-name", + "api_key": "your-azure-api-key", + "api_base": "https://your-resource-name.openai.azure.com", + "api_version": "2025-01-01-preview" + } +} +``` + +> 💡 **提示**: +> - `api_base` 填写你的 Azure OpenAI 资源端点,支持 `*.openai.azure.com` 和 `*.cognitiveservices.azure.com` 两种格式 +> - `api_version` 可选,默认值为 `2025-01-01-preview` +> - `model` 必须与 Azure Portal 中创建的部署名称一致 + +
+ +
+LiteLLM (Anthropic, DeepSeek, Gemini, Qwen, vLLM, Ollama 等) + +LiteLLM 提供对各种模型的统一访问。`model` 字段应遵循 LiteLLM 的命名约定。以下以 Claude 和 Qwen 为例: + +**Anthropic:** + +```json +{ + "vlm": { + "provider": "litellm", + "model": "claude-3-5-sonnet-20240620", + "api_key": "your-anthropic-api-key" + } +} +``` + +**Qwen (DashScope):** + +```json +{ + "vlm": { + "provider": "litellm", + "model": "dashscope/qwen-turbo", + "api_key": "your-dashscope-api-key", + "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1" + } +} +``` + +> 💡 **Qwen 提示**: +> - **中国/北京** 区域,使用 `api_base`:`https://dashscope.aliyuncs.com/compatible-mode/v1` +> - **国际** 区域,使用 `api_base`:`https://dashscope-intl.aliyuncs.com/compatible-mode/v1` + +**常见模型格式:** + +| 提供商 | 模型示例 | 说明 | +|----------|---------------|-------| +| Anthropic | `claude-3-5-sonnet-20240620` | 自动检测,使用 `ANTHROPIC_API_KEY` | +| DeepSeek | `deepseek-chat` | 自动检测,使用 `DEEPSEEK_API_KEY` | +| Gemini | `gemini-pro` | 自动检测,使用 `GEMINI_API_KEY` | +| Qwen | `dashscope/qwen-turbo` | 根据区域设置 `api_base`(见上方说明) | +| OpenRouter | `openrouter/openai/gpt-4o` | 需要完整前缀 | +| vLLM | `hosted_vllm/llama-3.1-8b` | 设置 `api_base` 为 vLLM 服务器 | +| Ollama | `ollama/llama3.1` | 设置 `api_base` 为 Ollama 服务器 | + +**本地模型 (vLLM / Ollama):** + +```bash + +# 启动 Ollama +ollama serve +``` + +```json +// Ollama +{ + "vlm": { + "provider": "litellm", + "model": "ollama/llama3.1", + "api_base": "http://localhost:11434" + } +} +``` + +完整的模型支持,请参见 [LiteLLM 提供商文档](https://docs.litellm.ai/docs/providers)。 + +
+ +### 3. 环境配置 + +#### 服务器配置模板 + +创建配置文件 `~/.openviking/ov.conf`,复制前请删除注释: + +```json +{ + "storage": { + "workspace": "/home/your-name/openviking_workspace" + }, + "log": { + "level": "INFO", + "output": "stdout" // 日志输出:"stdout" 或 "file" + }, + "embedding": { + "dense": { + "api_base" : "", // API 端点地址 + "api_key" : "", // 模型服务 API Key + "provider" : "", // 提供商类型:"volcengine"、"openai"、"azure" 等 + "api_version": "2025-01-01-preview", // (仅 azure)API 版本,可选,默认 "2025-01-01-preview" + "dimension": 1024, // 向量维度 + "model" : "" // Embedding 模型名称或 Azure 部署名(如 doubao-embedding-vision-250615 或 text-embedding-3-large) + }, + "max_concurrent": 10 // 最大并发 embedding 请求(默认:10) + }, + "vlm": { + "api_base" : "", // API 端点地址 + "api_key" : "", // 模型服务 API Key + "provider" : "", // 提供商类型 (volcengine, openai, azure, litellm 等) + "api_version": "2025-01-01-preview", // (仅 azure)API 版本,可选,默认 "2025-01-01-preview" + "model" : "", // VLM 模型名称或 Azure 部署名(如 doubao-seed-2-0-pro-260215 或 gpt-4-vision-preview) + "max_concurrent": 100 // 语义处理的最大并发 LLM 调用(默认:100) + } +} +``` + +> **注意**:对于 embedding 模型,目前支持 `volcengine`(豆包)、`openai`、`azure`、`jina` 等提供商。对于 VLM 模型,我们支持 `volcengine`、`openai`、`azure` 和 `litellm` 提供商。`litellm` 提供商支持各种模型,包括 Anthropic (Claude)、DeepSeek、Gemini、Moonshot、Zhipu、DashScope、MiniMax、vLLM、Ollama 等。 + +#### 服务器配置示例 + +👇 展开查看您的模型服务的配置示例: + +
+示例 1:使用 Volcengine(豆包模型) + +```json +{ + "storage": { + "workspace": "/home/your-name/openviking_workspace" + }, + "log": { + "level": "INFO", + "output": "stdout" // 日志输出:"stdout" 或 "file" + }, + "embedding": { + "dense": { + "api_base" : "https://ark.cn-beijing.volces.com/api/v3", + "api_key" : "your-volcengine-api-key", + "provider" : "volcengine", + "dimension": 1024, + "model" : "doubao-embedding-vision-250615" + }, + "max_concurrent": 10 + }, + "vlm": { + "api_base" : "https://ark.cn-beijing.volces.com/api/v3", + "api_key" : "your-volcengine-api-key", + "provider" : "volcengine", + "model" : "doubao-seed-2-0-pro-260215", + "max_concurrent": 100 + } +} +``` + +
+ +
+示例 2:使用 OpenAI 模型 + +```json +{ + "storage": { + "workspace": "/home/your-name/openviking_workspace" + }, + "log": { + "level": "INFO", + "output": "stdout" // 日志输出:"stdout" 或 "file" + }, + "embedding": { + "dense": { + "api_base" : "https://api.openai.com/v1", + "api_key" : "your-openai-api-key", + "provider" : "openai", + "dimension": 3072, + "model" : "text-embedding-3-large" + }, + "max_concurrent": 10 + }, + "vlm": { + "api_base" : "https://api.openai.com/v1", + "api_key" : "your-openai-api-key", + "provider" : "openai", + "model" : "gpt-4-vision-preview", + "max_concurrent": 100 + } +} +``` + +
+ +
+示例 3:使用 Azure OpenAI 模型 + +```json +{ + "storage": { + "workspace": "/home/your-name/openviking_workspace" + }, + "log": { + "level": "INFO", + "output": "stdout" + }, + "embedding": { + "dense": { + "api_base" : "https://your-resource-name.openai.azure.com", + "api_key" : "your-azure-api-key", + "provider" : "azure", + "api_version": "2025-01-01-preview", + "dimension": 1024, + "model" : "text-embedding-3-large" + }, + "max_concurrent": 10 + }, + "vlm": { + "api_base" : "https://your-resource-name.openai.azure.com", + "api_key" : "your-azure-api-key", + "provider" : "azure", + "api_version": "2025-01-01-preview", + "model" : "gpt-4o", + "max_concurrent": 100 + } +} +``` + +> 💡 **提示**: +> - `model` 必须填写 Azure Portal 中创建的**部署名称**,而非模型官方名字 +> - `api_base` 支持 `*.openai.azure.com` 和 `*.cognitiveservices.azure.com` 两种端点格式 +> - Embedding 和 VLM 可以使用不同的 Azure 资源和 API Key + +
+ +#### 设置服务器配置环境变量 + +创建配置文件后,设置环境变量指向它(Linux/macOS): + +```bash +export OPENVIKING_CONFIG_FILE=~/.openviking/ov.conf # 默认值 +``` + +在 Windows 上,使用以下任一方式: + +PowerShell: + +```powershell +$env:OPENVIKING_CONFIG_FILE = "$HOME/.openviking/ov.conf" +``` + +命令提示符 (cmd.exe): + +```bat +set "OPENVIKING_CONFIG_FILE=%USERPROFILE%\.openviking\ov.conf" +``` + +> 💡 **提示**:您也可以将配置文件放在其他位置,只需在环境变量中指定正确路径。 + +#### CLI/客户端配置示例 + +👇 展开查看您的 CLI/客户端的配置示例: + +示例:用于访问本地服务器的 ovcli.conf + +```json +{ + "url": "http://localhost:1933", + "timeout": 60.0, + "output": "table" +} +``` + +创建配置文件后,设置环境变量指向它(Linux/macOS): + +```bash +export OPENVIKING_CLI_CONFIG_FILE=~/.openviking/ovcli.conf # 默认值 +``` + +在 Windows 上,使用以下任一方式: + +PowerShell: + +```powershell +$env:OPENVIKING_CLI_CONFIG_FILE = "$HOME/.openviking/ovcli.conf" +``` + +命令提示符 (cmd.exe): + +```bat +set "OPENVIKING_CLI_CONFIG_FILE=%USERPROFILE%\.openviking\ovcli.conf" +``` + +### 4. 运行您的第一个示例 + +> 📝 **前置条件**:确保您已完成上一步的配置(ov.conf 和 ovcli.conf)。 + +现在让我们运行一个完整的示例,体验 OpenViking 的核心功能。 + +#### 启动服务器 + +```bash +openviking-server +``` + +或者您可以在后台运行 + +```bash +nohup openviking-server > /data/log/openviking.log 2>&1 & +``` + +#### 运行 CLI + +```bash +ov status +ov add-resource https://github.com/volcengine/OpenViking # --wait +ov ls viking://resources/ +ov tree viking://resources/volcengine -L 2 +# 如果没有使用 --wait,等待一段时间以进行语义处理 +ov find "what is openviking" +ov grep "openviking" --uri viking://resources/volcengine/OpenViking/docs/zh +``` + +恭喜!您已成功运行 OpenViking 🎉 + +### VikingBot 快速开始 + +VikingBot 是构建在 OpenViking 之上的 AI 智能体框架。以下是快速开始指南: + +```bash +# 选项 1:从 PyPI 安装 VikingBot(推荐大多数用户使用) +pip install "openviking[bot]" + +# 选项 2:从源码安装 VikingBot(用于开发) +uv pip install -e ".[bot]" + +# 启动 OpenViking 服务器(同时启动 Bot) +openviking-server --with-bot + +# 在另一个终端启动交互式聊天 +ov chat +``` + +--- + +## 服务器部署详情 + +对于生产环境,我们建议将 OpenViking 作为独立的 HTTP 服务运行,为您的 AI 智能体提供持久、高性能的上下文支持。 + +🚀 **在云端部署 OpenViking**: +为确保最佳的存储性能和数据安全,我们建议在 **火山引擎弹性计算服务 (ECS)** 上使用 **veLinux** 操作系统进行部署。我们准备了详细的分步指南,帮助您快速上手。 + +👉 **[查看:服务器部署与 ECS 设置指南](./docs/zh/getting-started/03-quickstart-server.md)** + + +## OpenClaw 上下文插件详情 + +* 测试集:基于 LoCoMo10(https://github.com/snap-research/locomo) 的长程对话进行效果测试(去除无真值的 category5 后,共 1540 条 case) +* 实验组:因用户在使用 OpenViking 时可能不关闭 OpenClaw 原生记忆,所以增加是否开关原生记忆的实验组 +* OpenViking 版本:0.1.18 +* 模型:seed-2.0-code +* 评测脚本:https://github.com/ZaynJarvis/openclaw-eval/tree/main + +| 实验组 | 任务完成率 | 成本:输入 token (总计) | +|----------|------------------|------------------| +| OpenClaw(memory-core) | 35.65% | 24,611,530 | +| OpenClaw + LanceDB (-memory-core) | 44.55% | 51,574,530 | +| OpenClaw + OpenViking Plugin (-memory-core) | 52.08% | 4,264,396 | +| OpenClaw + OpenViking Plugin (+memory-core) | 51.23% | 2,099,622 | + +* 实验结论: +结合 OpenViking 后,若仍开启原生记忆,效果在原 OpenClaw 上提升 43%,输入 token 成本降低 91%;在 LanceDB 上效果提升 15%,输入 token 降低 96%。若关闭原生记忆,效果在原 OpenClaw 上提升 49%,输入 token 成本降低 83%;在 LanceDB 上效果提升 17%,输入 token 降低 92%。 + +👉 **[查看:OpenClaw 上下文插件](examples/openclaw-plugin/README_CN.md)** + +👉 **[查看:OpenCode 记忆插件示例](examples/opencode-memory-plugin/README_CN.md)** + +## VikingBot 部署详情 + +OpenViking 有一个类似 nanobot 的机器人用于交互工作,现已可用。 + +👉 **[查看:使用 VikingBot 部署服务器](bot/README_CN.md)** + +-- + +## 核心概念 + +运行第一个示例后,让我们深入了解 OpenViking 的设计理念。这五个核心概念与前面提到的解决方案一一对应,共同构建了一个完整的上下文管理系统: + +### 1. 文件系统管理范式 → 解决碎片化 + +我们不再将上下文视为扁平的文本切片,而是将它们统一到一个抽象的虚拟文件系统中。无论是记忆、资源还是能力,都映射到 `viking://` 协议下的虚拟目录中,每个都有唯一的 URI。 + +这种范式赋予智能体前所未有的上下文操作能力,使它们能够像开发者一样,通过 `ls` 和 `find` 等标准命令精确、确定地定位、浏览和操作信息。这将上下文管理从模糊的语义匹配转变为直观、可追踪的"文件操作"。了解更多:[Viking URI](./docs/zh/concepts/04-viking-uri.md) | [上下文类型](./docs/zh/concepts/02-context-types.md) + +``` +viking:// +├── resources/ # 资源:项目文档、代码库、网页等 +│ ├── my_project/ +│ │ ├── docs/ +│ │ │ ├── api/ +│ │ │ └── tutorials/ +│ │ └── src/ +│ └── ... +├── user/ # 用户:个人偏好、习惯等 +│ └── memories/ +│ ├── preferences/ +│ │ ├── writing_style +│ │ └── coding_habits +│ └── ... +└── agent/ # 智能体:技能、指令、任务记忆等 + ├── skills/ + │ ├── search_code + │ ├── analyze_data + │ └── ... + ├── memories/ + └── instructions/ +``` + +### 2. 分层上下文加载 → 降低 Token 消耗 + +一次性将大量上下文塞入提示不仅昂贵,而且容易超出模型窗口并引入噪声。OpenViking 在写入时自动将上下文处理为三个级别: +- **L0 (摘要)**:一句话摘要,用于快速检索和识别。 +- **L1 (概览)**:包含核心信息和使用场景,用于智能体在规划阶段的决策。 +- **L2 (详情)**:完整的原始数据,供智能体在绝对必要时深度阅读。 + +了解更多:[上下文分层](./docs/zh/concepts/03-context-layers.md) + +``` +viking://resources/my_project/ +├── .abstract # L0 层:摘要(~100 tokens)- 快速相关性检查 +├── .overview # L1 层:概览(~2k tokens)- 理解结构和关键点 +├── docs/ +│ ├── .abstract # 每个目录都有对应的 L0/L1 层 +│ ├── .overview +│ ├── api/ +│ │ ├── .abstract +│ │ ├── .overview +│ │ ├── auth.md # L2 层:完整内容 - 按需加载 +│ │ └── endpoints.md +│ └── ... +└── src/ + └── ... +``` + +### 3. 目录递归检索 → 提升检索效果 + +单一向量检索难以应对复杂的查询意图。OpenViking 设计了创新的**目录递归检索策略**,深度集成多种检索方法: + +1. **意图分析**:通过意图分析生成多个检索条件。 +2. **初始定位**:使用向量检索快速定位初始切片所在的高分目录。 +3. **精细探索**:在该目录内进行二次检索,并将高分结果更新到候选集。 +4. **递归深入**:如果存在子目录,则逐层递归重复二次检索步骤。 +5. **结果聚合**:最终获取最相关的上下文返回。 + +这种"先锁定高分目录,再精细化内容探索"的策略不仅找到语义最佳匹配的片段,还能理解信息所在的完整上下文,从而提高检索的全局性和准确性。了解更多:[检索机制](./docs/zh/concepts/07-retrieval.md) + +### 4. 可视化检索轨迹 → 可观察上下文 + +OpenViking 的组织采用分层虚拟文件系统结构。所有上下文以统一格式集成,每个条目对应一个唯一的 URI(如 `viking://` 路径),打破了传统的扁平黑盒管理模式,具有清晰易懂的层次结构。 + +检索过程采用目录递归策略。每次检索的目录浏览和文件定位轨迹被完整保留,让用户能够清晰观察问题的根源,指导检索逻辑的优化。了解更多:[检索机制](./docs/zh/concepts/07-retrieval.md) + +### 5. 自动会话管理 → 上下文自迭代 + +OpenViking 内置了记忆自迭代循环。在每个会话结束时,开发者可以主动触发记忆提取机制。系统将异步分析任务执行结果和用户反馈,并自动更新到用户和智能体记忆目录。 + +- **用户记忆更新**:更新与用户偏好相关的记忆,使智能体响应更好地适应用户需求。 +- **智能体经验积累**:从任务执行经验中提取操作技巧和工具使用经验等核心内容,辅助后续任务的高效决策。 + +这使得智能体能够通过与世界的交互"越用越聪明",实现自我进化。了解更多:[会话管理](./docs/zh/concepts/08-session.md) + +--- + +## 深入阅读 + +### 文档 + +更多详情,请访问我们的[完整文档](./docs/zh/)。 + +### 社区与团队 + +更多详情,请参见:**[关于我们](./docs/zh/about/01-about-us.md)** + +### 加入社区 + +OpenViking 仍处于早期阶段,有许多改进和探索的空间。我们真诚邀请每一位对 AI 智能体技术充满热情的开发者: + +- 为我们点亮一颗珍贵的 **Star**,给我们前进的动力。 +- 访问我们的[**官网**](https://www.openviking.ai)了解我们传达的理念,并通过[**文档**](https://www.openviking.ai/docs)在您的项目中使用它。感受它带来的变化,并给我们最真实的体验反馈。 +- 加入我们的社区,分享您的见解,帮助回答他人的问题,共同创造开放互助的技术氛围: + - 📱 **飞书群**:扫码加入 → [查看二维码](./docs/en/about/01-about-us.md#lark-group) + - 💬 **微信群**:扫码添加助手 → [查看二维码](./docs/en/about/01-about-us.md#wechat-group) + - 🎮 **Discord**:[加入 Discord 服务器](https://discord.com/invite/eHvx8E9XF3) + - 🐦 **X (Twitter)**:[关注我们](https://x.com/openvikingai) +- 成为**贡献者**,无论是提交错误修复还是贡献新功能,您的每一行代码都将是 OpenViking 成长的重要基石。 + +让我们共同努力,定义和构建 AI 智能体上下文管理的未来。旅程已经开始,期待您的参与! + +### Star 趋势 + +[![Star History Chart](https://api.star-history.com/svg?repos=volcengine/OpenViking&type=timeline&legend=top-left)](https://www.star-history.com/#volcengine/OpenViking&type=timeline&legend=top-left) + +## 许可证 + +OpenViking 项目不同组件采用不同的开源协议: + +- **主项目**: AGPLv3 - 详情请参见 [LICENSE](./LICENSE) 文件 +- **crates/ov_cli**: Apache 2.0 - 详情请参见 [LICENSE](./crates/ov_cli/LICENSE) 文件 +- **examples**: Apache 2.0 - 详情请参见 [LICENSE](./examples/LICENSE) 文件 +- **third_party**: 保留各三方项目的原有协议 + + + + +[release-shield]: https://img.shields.io/github/v/release/volcengine/OpenViking?color=369eff&labelColor=black&logo=github&style=flat-square +[release-link]: https://github.com/volcengine/OpenViking/releases +[license-shield]: https://img.shields.io/badge/license-AGPLv3-white?labelColor=black&style=flat-square +[license-shield-link]: https://github.com/volcengine/OpenViking/blob/main/LICENSE +[last-commit-shield]: https://img.shields.io/github/last-commit/volcengine/OpenViking?color=c4f042&labelColor=black&style=flat-square +[last-commit-shield-link]: https://github.com/volcengine/OpenViking/commcommits/main +[github-stars-shield]: https://img.shields.io/github/stars/volcengine/OpenViking?labelColor&style=flat-square&color=ffcb47 +[github-stars-link]: https://github.com/volcengine/OpenViking +[github-issues-shield]: https://img.shields.io/github/issues/volcengine/OpenViking?labelColor=black&style=flat-square&color=ff80eb +[github-issues-shield-link]: https://github.com/volcengine/OpenViking/issues +[github-contributors-shield]: https://img.shields.io/github/contributors/volcengine/OpenViking?color=c4f042&labelColor=black&style=flat-square +[github-contributors-link]: https://github.com/volcengine/OpenViking/graphs/contributors diff --git a/openviking/parse/__init__.py b/openviking/parse/__init__.py index b266a1dae..7e31957d0 100644 --- a/openviking/parse/__init__.py +++ b/openviking/parse/__init__.py @@ -5,6 +5,10 @@ from openviking.parse.base import NodeType, ParseResult, ResourceNode, create_parse_result from openviking.parse.converter import DocumentConverter from openviking.parse.custom import CallbackParserWrapper, CustomParserProtocol, CustomParserWrapper +from openviking.parse.custom_loader import ( + ConfiguredParserWrapper, + register_configured_custom_parsers, +) from openviking.parse.directory_scan import ( CLASS_PROCESSABLE, CLASS_UNSUPPORTED, @@ -40,10 +44,12 @@ "CustomParserProtocol", "CustomParserWrapper", "CallbackParserWrapper", + "ConfiguredParserWrapper", # Registry "ParserRegistry", "get_registry", "parse", + "register_configured_custom_parsers", # Tree builder "TreeBuilder", "BuildingTree", diff --git a/openviking/parse/custom_loader.py b/openviking/parse/custom_loader.py new file mode 100644 index 000000000..caf82cdc3 --- /dev/null +++ b/openviking/parse/custom_loader.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Helpers for loading and registering custom parsers from ``ov.conf``.""" + +import importlib +import json +import logging +from pathlib import Path +from typing import Optional + +from openviking.parse.parsers.base_parser import BaseParser +from openviking.parse.registry import ParserRegistry, get_registry +from openviking_cli.utils.config.open_viking_config import OpenVikingConfig, get_openviking_config + +logger = logging.getLogger(__name__) + +_REGISTRATION_KEY_ATTR = "_custom_parser_registration_key" +_REGISTERED_NAMES_ATTR = "_custom_parser_registered_names" + + +class ConfiguredParserWrapper(BaseParser): + """Delegate parser behavior while overriding supported extensions.""" + + def __init__(self, parser: BaseParser, extensions: list[str]): + self.parser = parser + self._extensions = list(extensions) + + @property + def supported_extensions(self) -> list[str]: + return list(self._extensions) + + async def parse(self, source, instruction: str = "", **kwargs): + return await self.parser.parse(source, instruction=instruction, **kwargs) + + async def parse_content( + self, content, source_path: Optional[str] = None, instruction="", **kwargs + ): + return await self.parser.parse_content( + content, + source_path=source_path, + instruction=instruction, + **kwargs, + ) + + def can_parse(self, path): + return Path(path).suffix.lower() in self._extensions + + def __getattr__(self, name: str): + return getattr(self.parser, name) + + +def _load_parser_class(class_path: str) -> type[BaseParser]: + try: + module_name, class_name = class_path.rsplit(".", 1) + except ValueError as exc: + raise ImportError(f"Invalid custom parser class path: {class_path}") from exc + + module = importlib.import_module(module_name) + try: + parser_class = getattr(module, class_name) + except AttributeError as exc: + raise ImportError(f"Could not import custom parser class '{class_path}'") from exc + if not isinstance(parser_class, type) or not issubclass(parser_class, BaseParser): + raise TypeError(f"Custom parser class '{class_path}' must inherit from BaseParser") + return parser_class + + +def build_custom_parser_registration_key(config: OpenVikingConfig) -> str: + payload = { + name: parser_config.model_dump(by_alias=True) + for name, parser_config in config.custom_parsers.items() + } + return json.dumps(payload, sort_keys=True) + + +def register_configured_custom_parsers( + *, + registry: Optional[ParserRegistry] = None, + config: Optional[OpenVikingConfig] = None, +) -> ParserRegistry: + """Register configured custom parsers onto the target registry.""" + + resolved_registry = registry or get_registry() + resolved_config = config or get_openviking_config() + registration_key = build_custom_parser_registration_key(resolved_config) + + if getattr(resolved_registry, _REGISTRATION_KEY_ATTR, None) == registration_key: + return resolved_registry + + for name in getattr(resolved_registry, _REGISTERED_NAMES_ATTR, ()): + resolved_registry.unregister(name) + + registered_names: list[str] = [] + for name, parser_config in resolved_config.custom_parsers.items(): + parser_class = _load_parser_class(parser_config.class_path) + try: + parser = parser_class(**parser_config.kwargs) + except Exception as exc: + raise RuntimeError( + f"Failed to instantiate custom parser '{name}' " + f"from '{parser_config.class_path}': {exc}" + ) from exc + + wrapped = ConfiguredParserWrapper(parser=parser, extensions=parser_config.extensions) + resolved_registry.register(name, wrapped) + registered_names.append(name) + logger.info( + "Registered custom parser '%s' from %s for %s", + name, + parser_config.class_path, + parser_config.extensions, + ) + + setattr(resolved_registry, _REGISTERED_NAMES_ATTR, tuple(registered_names)) + setattr(resolved_registry, _REGISTRATION_KEY_ATTR, registration_key) + return resolved_registry diff --git a/openviking/service/core.py b/openviking/service/core.py index e61483fe5..14c9456ac 100644 --- a/openviking/service/core.py +++ b/openviking/service/core.py @@ -250,6 +250,10 @@ async def initialize(self) -> None: config = get_openviking_config() + from openviking.parse.custom_loader import register_configured_custom_parsers + + register_configured_custom_parsers(config=config) + # Initialize encryption module full_config = config.to_dict() self._encryptor = await bootstrap_encryption(full_config) diff --git a/openviking_cli/utils/config/__init__.py b/openviking_cli/utils/config/__init__.py index fcec617cc..05e437a01 100644 --- a/openviking_cli/utils/config/__init__.py +++ b/openviking_cli/utils/config/__init__.py @@ -15,6 +15,7 @@ OPENVIKING_PROMPT_TEMPLATES_DIR_ENV, SYSTEM_CONFIG_DIR, ) +from .custom_parser_config import CustomParserConfig from .embedding_config import EmbeddingConfig from .log_config import LogConfig from .open_viking_config import ( @@ -53,6 +54,7 @@ "DEFAULT_OV_CONF", "DEFAULT_OVCLI_CONF", "EmbeddingConfig", + "CustomParserConfig", "LogConfig", "OPENVIKING_CLI_CONFIG_ENV", "OPENVIKING_CONFIG_ENV", diff --git a/openviking_cli/utils/config/custom_parser_config.py b/openviking_cli/utils/config/custom_parser_config.py new file mode 100644 index 000000000..165198668 --- /dev/null +++ b/openviking_cli/utils/config/custom_parser_config.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Configuration models for custom parser registration.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + + +class CustomParserConfig(BaseModel): + """Config for a single custom parser entry in ``ov.conf``.""" + + class_path: str = Field(alias="class") + extensions: list[str] + kwargs: dict[str, Any] = Field(default_factory=dict) + + model_config = {"extra": "forbid", "populate_by_name": True} + + @field_validator("class_path") + @classmethod + def _validate_class_path(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("class must not be empty") + if "." not in value: + raise ValueError("class must be a fully qualified import path") + return value + + @field_validator("extensions") + @classmethod + def _normalize_extensions(cls, value: list[str]) -> list[str]: + if not value: + raise ValueError("extensions must not be empty") + + normalized: list[str] = [] + seen: set[str] = set() + for item in value: + if not isinstance(item, str): + raise ValueError("extensions must contain strings") + ext = item.strip().lower() + if not ext: + raise ValueError("extensions must not contain empty values") + if not ext.startswith("."): + ext = f".{ext}" + if ext not in seen: + normalized.append(ext) + seen.add(ext) + return normalized diff --git a/openviking_cli/utils/config/open_viking_config.py b/openviking_cli/utils/config/open_viking_config.py index 9273a1c72..64a5bfff9 100644 --- a/openviking_cli/utils/config/open_viking_config.py +++ b/openviking_cli/utils/config/open_viking_config.py @@ -18,6 +18,7 @@ OPENVIKING_CONFIG_ENV, SYSTEM_CONFIG_DIR, ) +from .custom_parser_config import CustomParserConfig from .embedding_config import EmbeddingConfig from .encryption_config import EncryptionConfig from .telemetry_config import TelemetryConfig @@ -160,6 +161,11 @@ class OpenVikingConfig(BaseModel): description="Prompt template configuration", ) + custom_parsers: Dict[str, CustomParserConfig] = Field( + default_factory=dict, + description="Custom parser registrations loaded at startup", + ) + model_config = {"arbitrary_types_allowed": True, "extra": "forbid"} @classmethod @@ -219,6 +225,7 @@ def from_dict(cls, config: Dict[str, Any]) -> "OpenVikingConfig": memory_config_data = config_copy.pop("memory") instance = cls(**config_copy) + cls._validate_custom_parsers(instance.custom_parsers) # Apply log configuration if log_config_data is not None: @@ -256,7 +263,20 @@ def from_dict(cls, config: Dict[str, Any]) -> "OpenVikingConfig": def to_dict(self) -> Dict[str, Any]: """Convert configuration to dictionary.""" - return self.model_dump() + return self.model_dump(by_alias=True) + + @staticmethod + def _validate_custom_parsers(custom_parsers: Dict[str, CustomParserConfig]) -> None: + extension_map: Dict[str, str] = {} + for parser_name, parser_config in custom_parsers.items(): + for ext in parser_config.extensions: + existing = extension_map.get(ext) + if existing is not None: + raise ValueError( + f"custom_parsers contains duplicate extension '{ext}': " + f"'{existing}' and '{parser_name}'" + ) + extension_map[ext] = parser_name class OpenVikingConfigSingleton: diff --git a/tests/parse/test_custom_jsonl_parser_example.py b/tests/parse/test_custom_jsonl_parser_example.py new file mode 100644 index 000000000..898ef508d --- /dev/null +++ b/tests/parse/test_custom_jsonl_parser_example.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 + +import importlib.util +from pathlib import Path + +import pytest + +from openviking.parse.base import NodeType, ParseResult, ResourceNode + + +def _load_custom_jsonl_parser_module(): + module_path = ( + Path(__file__).resolve().parents[2] + / "examples" + / "custom-parser-plugin" + / "custom_jsonl_parser.py" + ) + spec = importlib.util.spec_from_file_location("examples.custom_jsonl_parser", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +@pytest.mark.asyncio +async def test_custom_jsonl_parser_converts_title_content_records_to_markdown(tmp_path): + module = _load_custom_jsonl_parser_module() + parser = module.MyCustomJsonlParser() + + source = tmp_path / "records.jsonl" + source.write_text( + '{"title": "test title1", "content": "test content1"}\n' + '{"title": "test title2", "content": "test content2"}\n', + encoding="utf-8", + ) + + captured = {} + + async def fake_parse_content(content, source_path=None, instruction="", **kwargs): + captured["content"] = content + captured["source_path"] = source_path + captured["instruction"] = instruction + return ParseResult(root=ResourceNode(type=NodeType.ROOT, title="records")) + + parser._md_parser.parse_content = fake_parse_content + + result = await parser.parse(source, instruction="keep-order") + + assert captured["content"] == ( + "# test title1\n\ntest content1\n\n# test title2\n\ntest content2" + ) + assert captured["source_path"] == str(source) + assert captured["instruction"] == "keep-order" + assert result.source_format == "jsonl" + assert result.parser_name == "MyCustomJsonlParser" diff --git a/tests/parse/test_custom_parser_registration.py b/tests/parse/test_custom_parser_registration.py new file mode 100644 index 000000000..9dd9f7b95 --- /dev/null +++ b/tests/parse/test_custom_parser_registration.py @@ -0,0 +1,119 @@ +import json + +import pytest + +from openviking.parse.parsers.word import WordParser +from openviking.parse.registry import ParserRegistry +from openviking_cli.utils.config.open_viking_config import OpenVikingConfig + + +def _custom_parser_config( + class_path: str = "tests.utils.custom_parser_samples.SampleDocxParser", + *, + extensions: list[str] | None = None, + kwargs: dict | None = None, +) -> OpenVikingConfig: + return OpenVikingConfig.from_dict( + { + "embedding": { + "dense": { + "provider": "openai", + "api_key": "test-key", + "model": "text-embedding-3-small", + } + }, + "custom_parsers": { + "my-docx-parser": { + "class": class_path, + "extensions": extensions or [".docx"], + "kwargs": kwargs or {"plugin-name": "my-docx-parser", "version": 1.0}, + } + }, + } + ) + + +def test_register_configured_custom_parsers_overrides_builtin_extension(): + from openviking.parse.custom_loader import register_configured_custom_parsers + + registry = ParserRegistry(register_optional=False) + before = registry.get_parser_for_file("report.docx") + assert isinstance(before, WordParser) + + config = _custom_parser_config() + + register_configured_custom_parsers(registry=registry, config=config) + + parser = registry.get_parser_for_file("report.docx") + assert type(parser).__name__ == "ConfiguredParserWrapper" + assert type(parser.parser).__name__ == "SampleDocxParser" + assert parser.supported_extensions == [".docx"] + assert parser.parser.kwargs == {"plugin-name": "my-docx-parser", "version": 1.0} + + +def test_register_configured_custom_parsers_is_idempotent_for_same_config(): + from openviking.parse.custom_loader import register_configured_custom_parsers + + registry = ParserRegistry(register_optional=False) + config = _custom_parser_config() + + register_configured_custom_parsers(registry=registry, config=config) + first = registry.get_parser_for_file("report.docx") + register_configured_custom_parsers(registry=registry, config=config) + second = registry.get_parser_for_file("report.docx") + + assert first is second + + +def test_register_configured_custom_parsers_rejects_non_base_parser_class(): + from openviking.parse.custom_loader import register_configured_custom_parsers + + registry = ParserRegistry(register_optional=False) + config = _custom_parser_config("tests.utils.custom_parser_samples.NotAParser") + + with pytest.raises(TypeError, match="BaseParser"): + register_configured_custom_parsers(registry=registry, config=config) + + +def test_register_configured_custom_parsers_rejects_missing_import(): + from openviking.parse.custom_loader import register_configured_custom_parsers + + registry = ParserRegistry(register_optional=False) + config = _custom_parser_config("tests.utils.custom_parser_samples.MissingParser") + + with pytest.raises(ImportError, match="MissingParser"): + register_configured_custom_parsers(registry=registry, config=config) + + +def test_register_configured_custom_parsers_reloads_when_config_changes(): + from openviking.parse.custom_loader import register_configured_custom_parsers + + registry = ParserRegistry(register_optional=False) + config = _custom_parser_config(kwargs={"plugin-name": "first"}) + updated = _custom_parser_config(kwargs={"plugin-name": "second"}, extensions=[".docx", ".doc"]) + + register_configured_custom_parsers(registry=registry, config=config) + first = registry.get_parser_for_file("report.docx") + + register_configured_custom_parsers(registry=registry, config=updated) + second = registry.get_parser_for_file("report.docx") + + assert second is not first + assert second.supported_extensions == [".docx", ".doc"] + assert second.parser.kwargs == {"plugin-name": "second"} + + +def test_register_configured_custom_parsers_requires_initialized_config(): + from openviking.parse.custom_loader import build_custom_parser_registration_key + + config = _custom_parser_config() + + key = build_custom_parser_registration_key(config) + + assert json.loads(key) == { + "my-docx-parser": { + "class": "tests.utils.custom_parser_samples.SampleDocxParser", + "extensions": [".docx"], + "kwargs": {"plugin-name": "my-docx-parser", "version": 1.0}, + } + } diff --git a/tests/service/test_custom_parser_startup.py b/tests/service/test_custom_parser_startup.py new file mode 100644 index 000000000..8723f1837 --- /dev/null +++ b/tests/service/test_custom_parser_startup.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace + +import pytest + +from openviking.service.core import OpenVikingService + + +@pytest.mark.asyncio +async def test_initialize_registers_custom_parsers_before_other_startup(monkeypatch, tmp_path): + service = OpenVikingService.__new__(OpenVikingService) + service._initialized = False + service._config = SimpleNamespace( + storage=SimpleNamespace(workspace=str(tmp_path)), + embedding=SimpleNamespace(max_concurrent=1), + vlm=SimpleNamespace(max_concurrent=1), + ) + service._vikingdb_manager = object() + service._embedder = object() + + sentinel = RuntimeError("custom parser registration failed") + + monkeypatch.setattr( + "openviking.utils.process_lock.acquire_data_dir_lock", + lambda _workspace: None, + ) + monkeypatch.setattr( + "openviking.service.core.get_openviking_config", + lambda: SimpleNamespace(), + ) + monkeypatch.setattr( + "openviking.parse.custom_loader.register_configured_custom_parsers", + lambda **_kwargs: (_ for _ in ()).throw(sentinel), + ) + + with pytest.raises(RuntimeError, match="custom parser registration failed"): + await OpenVikingService.initialize(service) diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 8f7e1bc18..7f603e74a 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -180,3 +180,103 @@ def test_openviking_config_singleton_preserves_value_error_for_bad_config(tmp_pa with pytest.raises(ValueError, match="server"): OpenVikingConfigSingleton.initialize(config_path=str(config_path)) OpenVikingConfigSingleton.reset_instance() + + +def test_openviking_config_accepts_custom_parsers(monkeypatch): + monkeypatch.setenv("OPENVIKING_CONFIG_FILE", "/tmp/codex-no-config.json") + + from openviking_cli.utils.config.open_viking_config import ( + OpenVikingConfig, + OpenVikingConfigSingleton, + ) + + config = OpenVikingConfig.from_dict( + { + "embedding": { + "dense": { + "provider": "openai", + "api_key": "test-key", + "model": "text-embedding-3-small", + } + }, + "custom_parsers": { + "my-docx-parser": { + "class": "tests.utils.custom_parser_samples.SampleDocxParser", + "extensions": [".DOCX", "doc"], + "kwargs": {"plugin-name": "my-docx-parser", "version": 1.0}, + } + }, + } + ) + + parser = config.custom_parsers["my-docx-parser"] + assert parser.class_path == "tests.utils.custom_parser_samples.SampleDocxParser" + assert parser.extensions == [".docx", ".doc"] + assert parser.kwargs == {"plugin-name": "my-docx-parser", "version": 1.0} + assert config.to_dict()["custom_parsers"]["my-docx-parser"]["class"] == parser.class_path + + OpenVikingConfigSingleton.reset_instance() + + +def test_openviking_config_rejects_duplicate_custom_parser_extensions(monkeypatch): + monkeypatch.setenv("OPENVIKING_CONFIG_FILE", "/tmp/codex-no-config.json") + + from openviking_cli.utils.config.open_viking_config import ( + OpenVikingConfig, + OpenVikingConfigSingleton, + ) + + with pytest.raises(ValueError, match=r"custom_parsers.*\.docx"): + OpenVikingConfig.from_dict( + { + "embedding": { + "dense": { + "provider": "openai", + "api_key": "test-key", + "model": "text-embedding-3-small", + } + }, + "custom_parsers": { + "first": { + "class": "tests.utils.custom_parser_samples.SampleDocxParser", + "extensions": [".docx"], + }, + "second": { + "class": "tests.utils.custom_parser_samples.SampleDocxParser", + "extensions": [".DOCX"], + }, + }, + } + ) + + OpenVikingConfigSingleton.reset_instance() + + +def test_openviking_config_rejects_invalid_custom_parser_extensions(monkeypatch): + monkeypatch.setenv("OPENVIKING_CONFIG_FILE", "/tmp/codex-no-config.json") + + from openviking_cli.utils.config.open_viking_config import ( + OpenVikingConfig, + OpenVikingConfigSingleton, + ) + + with pytest.raises(ValueError, match=r"custom_parsers\.my-docx-parser\.extensions"): + OpenVikingConfig.from_dict( + { + "embedding": { + "dense": { + "provider": "openai", + "api_key": "test-key", + "model": "text-embedding-3-small", + } + }, + "custom_parsers": { + "my-docx-parser": { + "class": "tests.utils.custom_parser_samples.SampleDocxParser", + "extensions": [], + } + }, + } + ) + + OpenVikingConfigSingleton.reset_instance() diff --git a/tests/utils/custom_parser_samples.py b/tests/utils/custom_parser_samples.py new file mode 100644 index 000000000..2788ed87c --- /dev/null +++ b/tests/utils/custom_parser_samples.py @@ -0,0 +1,27 @@ +from pathlib import Path +from typing import List, Optional, Union + +from openviking.parse.base import ParseResult +from openviking.parse.parsers.base_parser import BaseParser + + +class SampleDocxParser(BaseParser): + def __init__(self, **kwargs): + self.kwargs = dict(kwargs) + + @property + def supported_extensions(self) -> List[str]: + return [".docx"] + + async def parse(self, source: Union[str, Path], instruction: str = "", **kwargs) -> ParseResult: + raise NotImplementedError + + async def parse_content( + self, content: str, source_path: Optional[str] = None, instruction: str = "", **kwargs + ) -> ParseResult: + raise NotImplementedError + + +class NotAParser: + def __init__(self, **kwargs): + self.kwargs = dict(kwargs)