feat: improve RAG agent search configuration

This commit is contained in:
kelriclink committed 2026-07-04 22:58:48 +08:00
1 parent 5c35dfe463
commit ecbaf44425
11 files changed
+1487 -81

No files matched your search

+17
View File
@@ -11,6 +11,7 @@
- **主 Agent + 子 Agent 翻译 RAG**:字幕翻译前由主 Agent 扫描当前 block 和前文 summary,决定哪些术语需要研究;每个术语由独立子 Agent 维护 observation/evidence 上下文并选择工具。
- **PostgreSQL + pgvector 知识库**:术语、译法、来源、置信度和使用次数统一入库;支持向量检索、手动新增/删除条目、重建 embedding。
- **真正的工具调用循环**:子 Agent 可按需调用 `rag_lookup`、`wiki_search`、`search_web`、`fetch_url`、`finish`,而不是固定按 wiki -> search 的流水线机械执行。
- **轻量 Agent Runtime**:内部运行时统一管理 tool schema、LLM/tool/fetch 预算、状态迁移、错误类型和 trace 字段,方便后续扩展 Dictionary/Wiki/GitHub/Bilibili 等工具。
- **Wikipedia Tool + SearXNG Tool**:固定 English Wikipedia API 用于通用百科知识;自建 SearXNG 用于补充网络搜索;搜索摘要不足时可继续读取网页正文。
- **并发术语研究 Agent**:可在 `Settings -> Translate` 设置并发数和超时;主 Agent 等待子 Agent 返回结构化结果后再合并进当前 block 翻译。
- **保守入库策略**:summarize -> verify -> write_rag;只有 verifier 判断来源支撑、上下文一致且置信度足够时才正式入库,否则标记为 pending、context_only 或 skipped。
@@ -371,8 +372,10 @@ RAG 用于解决视频字幕翻译中的上下文和术语问题,例如游戏
- 自动正式入库要求 verifier 通过、上下文一致、有有效外部来源,并达到较高置信度;否则进入待审核或跳过。
- Wikipedia Tool 使用固定 English Wikipedia API,不需要额外配置 wiki 地址;它适合通用百科类专有名词,不替代垂直领域资料库。
- SearXNG Tool 使用 base URL 配置,系统自动拼接 `/search?q=...&format=json`,并过滤搜索引擎 About/Preferences 等内部页面。
- SearXNG 支持在 Web UI 配置 `categories`、`engines`、`fallback_engines`、`language`、`safesearch`、`time_range` 和 `pageno`;默认使用 `categories=general`、`language=all`、`safesearch=0`、`pageno=1`,默认搜索为空时 fallback 到 `bing,baidu`。
- 可在 `Settings -> Translate` 调整 Agent 并发数、Agent 超时、大模型失败重试次数。并发越高,术语补全越快,但外部 API、LLM 和搜索服务压力也越大。
- Dashboard 中可以点击主 Agent 查看下属子 Agent;点击子 Agent 可查看它自己的工具调用对话流。
- 每个 Agent trace 都会带 `event_id`、`span_id`、`status`、工具 schema、耗时、错误类型和预算计数,便于排查哪个节点失败或耗时过长。
子 Agent 返回主 Agent 的数据是结构化结果,而不是把整段 trace 直接塞回 prompt:
@@ -401,6 +404,20 @@ https://search.example.com
系统会自动请求 `/search?q=...&format=json`,不需要在配置里手写 `?q=`。
可选搜索参数在 `Settings -> Translate -> RAG Agent` 配置:
```text
categories=general
engines=
fallback_engines=bing,baidu
language=all
safesearch=0
time_range=
pageno=1
```
`engines` 为空时使用 SearXNG 实例默认引擎;常见值包括 `bing`、`baidu`、`brave`、`duckduckgo`、`wikipedia`、`arxiv`、`pubmed`、`github`、`stackoverflow`、`youtube` 等。最终可用引擎以你自己的 SearXNG `settings.yml` 为准。
Embedding 支持两种方式:
- OpenAI 兼容 embedding:填写 provider 支持的 embedding 模型名、base URL 和独立 API key。
+4
View File
@@ -14,8 +14,10 @@
- **PostgreSQL + pgvector 知识库**:保存术语、译法、领域、别名、解释、来源、置信度、状态和 embedding,翻译时按向量相似度召回并注入上下文。
- **主 Agent + 子 Agent 架构**:每个字幕 block 创建一个 `rag_master` 主 Agent;主 Agent 负责术语 gate、调度并发子 Agent、汇总结果;每个术语研究子 Agent 维护自己的 observation/evidence 上下文。
- **工具调用循环**:子 Agent 可按需调用 `rag_lookup`、`wiki_search`、`search_web`、`fetch_url`、`finish`。工具选择由 LLM 根据当前 observation 决定,程序只做超时、重试、fallback 和写库安全控制。
- **轻量 Agent Runtime**:借鉴 LangGraph/OpenAI Agents SDK/PydanticAI 一类框架的核心做法,但不引入重依赖;内部统一 tool schema、结构化输出校验、预算控制、状态迁移、错误分类和 trace 事件。
- **Wikipedia Tool**:固定使用 English Wikipedia API,适合百科型专有名词和通用背景知识;不用在配置里维护各种不通用的 wiki 地址。
- **SearXNG Tool**:可接入自建 SearXNG,配置填写 base URL,系统自动请求 `/search?q=...&format=json` 并过滤搜索引擎自身页面等无效结果。
- **SearXNG 参数配置**:翻译设置支持配置 `categories`、`engines`、`fallback_engines`、`language`、`safesearch`、`time_range` 和 `pageno`。不指定 `engines` 时使用实例默认引擎,默认搜索为空时使用 fallback 引擎再次尝试。
- **网页正文读取 Tool**:当搜索摘要不足时,Agent 可以打开候选链接抽取正文,再交给 LLM 总结证据。
- **Verifier 入库保护**:候选术语会经过 verifier 判断来源是否支撑、是否和字幕上下文一致、置信度是否足够;不满足条件时不会污染长期知识库。
- **并发术语研究**:翻译设置里可调整 RAG Agent 并发数和超时时间,让多个需要查证的术语同时研究;子 Agent 结束后返回结构化 `AgentResearchResult`,由主 Agent 合并回当前翻译上下文。
@@ -47,6 +49,8 @@
- `knowledge_cards`:文档型背景知识。
- `hits`:原始知识库命中,用于记录匹配和使用次数。
Agent trace 是规范化事件流,每步包含 `event_id`、`span_id`、`kind`、`action`、`status`、`duration_ms`、`error_type`、工具 schema、预算计数和输入输出摘要。Dashboard 以这些字段展示主 Agent/子 Agent 树和每步 JSON。
---
## 2. 总体拆分:三大模块 + 一个编排层(Orchestrator)
@@ -0,0 +1,228 @@
from __future__ import annotations
import json
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Generic, Literal, TypeVar
from pydantic import BaseModel, Field, ValidationError
AgentStepKind = Literal["agent", "llm", "tool", "policy", "retrieval", "error"]
class AgentBudget(BaseModel):
max_llm_calls: int = Field(default=12, ge=0, le=100)
max_tool_calls: int = Field(default=12, ge=0, le=100)
max_fetch_calls: int = Field(default=4, ge=0, le=50)
timeout_seconds: float = Field(default=120.0, ge=1.0, le=3600.0)
class AgentDecision(BaseModel):
action: Literal["rag_lookup", "wiki_search", "search_web", "fetch_url", "finish"]
query: str = ""
url: str = ""
reason: str = ""
final_answer_ready: bool = False
class SearchQueryPlan(BaseModel):
queries: list[str] = Field(default_factory=list)
class GlossaryCandidate(BaseModel):
term: str
translation: str
domain: str = ""
aliases: list[str] = Field(default_factory=list)
description: str = ""
sources: list[dict[str, Any]] = Field(default_factory=list)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class VerificationResult(BaseModel):
supported: bool = False
context_consistent: bool = False
should_write: bool = False
should_auto_approve: bool = False
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
reason: str = ""
failure_category: str = ""
class ToolSpec(BaseModel):
name: str
description: str = ""
input_schema: dict[str, Any] = Field(default_factory=dict)
output_schema: dict[str, Any] = Field(default_factory=dict)
timeout_seconds: float = 20.0
retry_count: int = 0
cost: dict[str, Any] = Field(default_factory=dict)
rate_limit: dict[str, Any] = Field(default_factory=dict)
guardrails: list[str] = Field(default_factory=list)
redact_fields: list[str] = Field(default_factory=list)
class ToolCallResult(BaseModel):
tool_name: str
input: dict[str, Any] = Field(default_factory=dict)
output: dict[str, Any] = Field(default_factory=dict)
ok: bool = True
duration_ms: int = 0
error_type: str = ""
error: str = ""
class AgentTraceEvent(BaseModel):
event_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
span_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
parent_span_id: str | None = None
kind: AgentStepKind = "agent"
action: str
agent_name: str = ""
tool_name: str = ""
status: str = "ok"
duration_ms: int | None = None
model: str | None = None
tokens: dict[str, Any] | None = None
cost: dict[str, Any] = Field(default_factory=dict)
input: dict[str, Any] | None = None
output: dict[str, Any] | None = None
error_type: str = ""
error: str = ""
metadata: dict[str, Any] = Field(default_factory=dict)
at: str = ""
def as_legacy_step(self) -> dict[str, Any]:
data = self.model_dump(exclude_none=True)
# Keep old Dashboard compatibility while exposing normalized trace fields.
data["kind"] = self.kind
data["action"] = self.action
if self.tool_name:
data["tool"] = self.tool_name
if self.input is not None:
data["input"] = self.input
if self.output is not None:
data["output"] = self.output
for key, value in self.metadata.items():
data.setdefault(key, value)
return data
class AgentState(BaseModel):
run_id: str | None = None
agent_name: str
status: str = "running"
node: str = "start"
term: str = ""
observations: list[dict[str, Any]] = Field(default_factory=list)
evidence: list[dict[str, Any]] = Field(default_factory=list)
tools_used: list[str] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
TIn = TypeVar("TIn", bound=BaseModel)
TOut = TypeVar("TOut", bound=BaseModel)
@dataclass(frozen=True)
class RegisteredTool(Generic[TIn, TOut]):
spec: ToolSpec
input_model: type[TIn]
output_model: type[TOut] | None = None
handler: Callable[[TIn], TOut | dict[str, Any]] | None = None
@dataclass
class ToolRegistry:
_tools: dict[str, RegisteredTool[Any, Any]] = field(default_factory=dict)
def register(self, tool: RegisteredTool[Any, Any]) -> None:
self._tools[tool.spec.name] = tool
def names(self) -> list[str]:
return sorted(self._tools)
def spec(self, name: str) -> ToolSpec:
return self._tools[name].spec
def specs(self) -> list[ToolSpec]:
return [tool.spec for tool in self._tools.values()]
class AgentBudgetExceeded(RuntimeError):
pass
@dataclass
class AgentRuntime:
agent_name: str
budget: AgentBudget
trace_recorder: Callable[[dict[str, Any]], None] | None = None
run_id: str | None = None
parent_span_id: str | None = None
started_monotonic: float = field(default_factory=time.monotonic)
llm_calls: int = 0
tool_calls: int = 0
fetch_calls: int = 0
def check_budget(self, *, tool_name: str = "", is_llm: bool = False) -> None:
if time.monotonic() - self.started_monotonic > self.budget.timeout_seconds:
raise AgentBudgetExceeded("agent timeout budget exceeded")
if is_llm and self.llm_calls >= self.budget.max_llm_calls:
raise AgentBudgetExceeded("agent LLM call budget exceeded")
if tool_name:
if self.tool_calls >= self.budget.max_tool_calls:
raise AgentBudgetExceeded("agent tool call budget exceeded")
if tool_name in {"fetch", "fetch_url"} and self.fetch_calls >= self.budget.max_fetch_calls:
raise AgentBudgetExceeded("agent fetch call budget exceeded")
def before_llm(self) -> None:
self.check_budget(is_llm=True)
self.llm_calls += 1
def before_tool(self, tool_name: str) -> None:
self.check_budget(tool_name=tool_name)
self.tool_calls += 1
if tool_name in {"fetch", "fetch_url"}:
self.fetch_calls += 1
def record(self, event: AgentTraceEvent) -> None:
if self.trace_recorder is None:
return
if not event.agent_name:
event.agent_name = self.agent_name
if self.parent_span_id and event.parent_span_id is None:
event.parent_span_id = self.parent_span_id
payload = event.as_legacy_step()
payload.setdefault("run_id", self.run_id)
payload.setdefault(
"budget",
{
"llm_calls": self.llm_calls,
"tool_calls": self.tool_calls,
"fetch_calls": self.fetch_calls,
"max_llm_calls": self.budget.max_llm_calls,
"max_tool_calls": self.budget.max_tool_calls,
"max_fetch_calls": self.budget.max_fetch_calls,
"timeout_seconds": self.budget.timeout_seconds,
},
)
self.trace_recorder(payload)
def validate_model(model: type[TOut], data: Any, *, fallback: TOut | None = None) -> TOut:
try:
if isinstance(data, model):
return data
return model.model_validate(data)
except ValidationError:
if fallback is not None:
return fallback
raise
def json_schema_for(model: type[BaseModel]) -> dict[str, Any]:
return json.loads(json.dumps(model.model_json_schema(), ensure_ascii=False))
@@ -353,6 +353,13 @@ def get_translate_settings_view(
rag_wiki_enabled=cfg["rag_wiki_enabled"],
rag_search_enabled=cfg["rag_search_enabled"],
rag_search_url=cfg["rag_search_url"],
rag_search_categories=cfg["rag_search_categories"],
rag_search_engines=cfg["rag_search_engines"],
rag_search_fallback_engines=cfg["rag_search_fallback_engines"],
rag_search_language=cfg["rag_search_language"],
rag_search_safesearch=cfg["rag_search_safesearch"],
rag_search_time_range=cfg["rag_search_time_range"],
rag_search_pageno=cfg["rag_search_pageno"],
rag_domain=cfg["rag_domain"],
rag_agent_parallelism=cfg["rag_agent_parallelism"],
rag_agent_timeout_seconds=cfg["rag_agent_timeout_seconds"],
@@ -395,6 +402,13 @@ def put_translate_settings_view(
rag_wiki_enabled=cfg["rag_wiki_enabled"],
rag_search_enabled=cfg["rag_search_enabled"],
rag_search_url=cfg["rag_search_url"],
rag_search_categories=cfg["rag_search_categories"],
rag_search_engines=cfg["rag_search_engines"],
rag_search_fallback_engines=cfg["rag_search_fallback_engines"],
rag_search_language=cfg["rag_search_language"],
rag_search_safesearch=cfg["rag_search_safesearch"],
rag_search_time_range=cfg["rag_search_time_range"],
rag_search_pageno=cfg["rag_search_pageno"],
rag_domain=cfg["rag_domain"],
rag_agent_parallelism=cfg["rag_agent_parallelism"],
rag_agent_timeout_seconds=cfg["rag_agent_timeout_seconds"],
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,126 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Generic, Iterable, TypeVar
THit = TypeVar("THit")
@dataclass(frozen=True)
class RetrievalStage:
name: str
query: str = ""
count: int = 0
duration_ms: int = 0
error_type: str = ""
error: str = ""
@property
def ok(self) -> bool:
return not self.error_type
def to_trace_step(self) -> dict[str, Any]:
step: dict[str, Any] = {
"kind": "retrieval",
"action": self.name,
"status": "ok" if self.ok else "failed",
"query": self.query,
"count": self.count,
"duration_ms": self.duration_ms,
}
if self.error_type:
step["error_type"] = self.error_type
if self.error:
step["error"] = self.error
return step
@dataclass
class RetrievalResult(Generic[THit]):
hits: list[THit] = field(default_factory=list)
stages: list[RetrievalStage] = field(default_factory=list)
def _duration_ms(started: float) -> int:
return max(0, int((time.perf_counter() - started) * 1000))
def merge_hits_by_id(items: Iterable[THit], *, id_getter: Callable[[THit], str]) -> list[THit]:
out: list[THit] = []
seen: set[str] = set()
for item in items:
key = id_getter(item)
if not key or key in seen:
continue
seen.add(key)
out.append(item)
return out
class RetrievalPipeline(Generic[THit]):
"""Small explicit retrieval pipeline inspired by RAG frameworks.
The pipeline owns stage timing, dedupe, and trace events. The actual database
operations stay injected so the subtitle service can keep its current storage
layer and avoid a heavy framework dependency.
"""
def __init__(
self,
*,
id_getter: Callable[[THit], str],
trace_recorder: Callable[[dict[str, Any]], None] | None = None,
error_handler: Callable[[Exception], None] | None = None,
) -> None:
self._id_getter = id_getter
self._trace_recorder = trace_recorder
self._error_handler = error_handler
self._hits: list[THit] = []
self._seen_ids: set[str] = set()
self.stages: list[RetrievalStage] = []
@property
def hits(self) -> list[THit]:
return list(self._hits)
def add_hits(self, hits: Iterable[THit]) -> int:
added = 0
for hit in hits:
key = self._id_getter(hit)
if not key or key in self._seen_ids:
continue
self._seen_ids.add(key)
self._hits.append(hit)
added += 1
return added
def run_stage(self, name: str, query: str, fn: Callable[[], list[THit]]) -> list[THit]:
started = time.perf_counter()
try:
hits = fn()
added = self.add_hits(hits)
stage = RetrievalStage(name=name, query=query, count=added, duration_ms=_duration_ms(started))
except Exception as e:
if self._error_handler is not None:
try:
self._error_handler(e)
except Exception:
pass
hits = []
stage = RetrievalStage(
name=name,
query=query,
count=0,
duration_ms=_duration_ms(started),
error_type=type(e).__name__,
error=str(e)[:500],
)
self.stages.append(stage)
if self._trace_recorder is not None:
self._trace_recorder(stage.to_trace_step())
return hits
def result(self) -> RetrievalResult[THit]:
return RetrievalResult(hits=self.hits, stages=list(self.stages))
@@ -219,6 +219,13 @@ class TranslateSettingsRead(BaseModel):
rag_wiki_enabled: bool = False
rag_search_enabled: bool = False
rag_search_url: str = ""
rag_search_categories: str = "general"
rag_search_engines: str = ""
rag_search_fallback_engines: str = "bing,baidu"
rag_search_language: str = "all"
rag_search_safesearch: int = 0
rag_search_time_range: str = ""
rag_search_pageno: int = 1
rag_domain: str = ""
rag_agent_parallelism: int = 1
rag_agent_timeout_seconds: float = 120.0
@@ -256,6 +263,13 @@ class TranslateSettingsUpdate(BaseModel):
rag_wiki_enabled: Optional[bool] = None
rag_search_enabled: Optional[bool] = None
rag_search_url: Optional[str] = None
rag_search_categories: Optional[str] = None
rag_search_engines: Optional[str] = None
rag_search_fallback_engines: Optional[str] = None
rag_search_language: Optional[str] = None
rag_search_safesearch: Optional[int] = Field(default=None, ge=0, le=2)
rag_search_time_range: Optional[str] = None
rag_search_pageno: Optional[int] = Field(default=None, ge=1, le=100)
rag_domain: Optional[str] = None
rag_agent_parallelism: Optional[int] = Field(default=None, ge=1, le=8)
rag_agent_timeout_seconds: Optional[float] = Field(default=None, ge=10.0, le=900.0)
@@ -11,6 +11,37 @@ from videoroll.utils.openai_compat import normalize_openai_base_url
TRANSLATE_SETTINGS_KEY = "subtitle.translate"
_SEARXNG_TIME_RANGES = {"", "day", "month", "year"}
def _clean_csv(value: Any, *, default: str = "", limit: int = 20) -> str:
raw_items = str(value or default or "").replace("\n", ",").split(",")
out: list[str] = []
seen: set[str] = set()
for item in raw_items:
clean = " ".join(str(item or "").strip().split())
if not clean:
continue
key = clean.lower()
if key in seen:
continue
seen.add(key)
out.append(clean[:80])
if len(out) >= limit:
break
return ",".join(out)
def _clean_search_language(value: Any, *, default: str = "all") -> str:
clean = str(value or default or "all").strip()
if not clean:
return "all"
return clean[:32]
def _clean_search_time_range(value: Any) -> str:
clean = str(value or "").strip().lower()
return clean if clean in _SEARXNG_TIME_RANGES else ""
def _get_row(db: Session) -> AppSetting:
@@ -89,6 +120,26 @@ def get_translate_settings(db: Session, defaults: SubtitleServiceSettings) -> di
"rag_wiki_enabled": bool(stored.get("rag_wiki_enabled") if "rag_wiki_enabled" in stored else False),
"rag_search_enabled": bool(stored.get("rag_search_enabled") if "rag_search_enabled" in stored else defaults.rag_search_enabled),
"rag_search_url": str(stored.get("rag_search_url") or defaults.rag_search_url),
"rag_search_categories": _clean_csv(stored.get("rag_search_categories"), default=defaults.rag_search_categories or "general"),
"rag_search_engines": _clean_csv(stored.get("rag_search_engines"), default=defaults.rag_search_engines or ""),
"rag_search_fallback_engines": _clean_csv(
stored.get("rag_search_fallback_engines"),
default=defaults.rag_search_fallback_engines or "bing,baidu",
),
"rag_search_language": _clean_search_language(stored.get("rag_search_language"), default=defaults.rag_search_language or "all"),
"rag_search_safesearch": max(
0,
min(
2,
int(
stored.get("rag_search_safesearch")
if stored.get("rag_search_safesearch") is not None
else defaults.rag_search_safesearch
),
),
),
"rag_search_time_range": _clean_search_time_range(stored.get("rag_search_time_range") or defaults.rag_search_time_range),
"rag_search_pageno": max(1, min(100, int(stored.get("rag_search_pageno") or defaults.rag_search_pageno or 1))),
"rag_domain": str(stored.get("rag_domain") or defaults.rag_domain),
"rag_agent_parallelism": int(stored.get("rag_agent_parallelism") or 1),
"rag_agent_timeout_seconds": float(stored.get("rag_agent_timeout_seconds") or 120.0),
@@ -120,6 +171,13 @@ def update_translate_settings(db: Session, defaults: SubtitleServiceSettings, up
"rag_wiki_enabled",
"rag_search_enabled",
"rag_search_url",
"rag_search_categories",
"rag_search_engines",
"rag_search_fallback_engines",
"rag_search_language",
"rag_search_safesearch",
"rag_search_time_range",
"rag_search_pageno",
"rag_domain",
"rag_agent_parallelism",
"rag_agent_timeout_seconds",
@@ -221,9 +279,33 @@ def update_translate_settings(db: Session, defaults: SubtitleServiceSettings, up
stored[bool_key] = bool(stored[bool_key])
provider = str(stored.get("rag_embedding_provider") or defaults.rag_embedding_provider).strip().lower()
stored["rag_embedding_provider"] = provider if provider in {"openai", "local"} else defaults.rag_embedding_provider
for str_key in ["rag_embedding_model", "rag_embedding_model_dir", "rag_embedding_device", "rag_search_url", "rag_domain"]:
for str_key in [
"rag_embedding_model",
"rag_embedding_model_dir",
"rag_embedding_device",
"rag_search_url",
"rag_search_language",
"rag_search_time_range",
"rag_domain",
]:
if str_key in stored and stored[str_key] is not None:
stored[str_key] = str(stored[str_key]).strip()
stored["rag_search_categories"] = _clean_csv(stored.get("rag_search_categories"), default=defaults.rag_search_categories or "general")
stored["rag_search_engines"] = _clean_csv(stored.get("rag_search_engines"), default=defaults.rag_search_engines or "")
stored["rag_search_fallback_engines"] = _clean_csv(
stored.get("rag_search_fallback_engines"),
default=defaults.rag_search_fallback_engines or "bing,baidu",
)
stored["rag_search_language"] = _clean_search_language(stored.get("rag_search_language"), default=defaults.rag_search_language or "all")
try:
stored["rag_search_safesearch"] = max(0, min(2, int(stored.get("rag_search_safesearch") or defaults.rag_search_safesearch or 0)))
except Exception:
stored["rag_search_safesearch"] = 0
stored["rag_search_time_range"] = _clean_search_time_range(stored.get("rag_search_time_range") or defaults.rag_search_time_range)
try:
stored["rag_search_pageno"] = max(1, min(100, int(stored.get("rag_search_pageno") or defaults.rag_search_pageno or 1)))
except Exception:
stored["rag_search_pageno"] = 1
if "base_url" in embedding_openai:
embedding_openai["base_url"] = normalize_openai_base_url(str(embedding_openai.get("base_url") or ""))
try:
+7
View File
@@ -96,6 +96,13 @@ class SubtitleServiceSettings(CommonSettings):
rag_auto_learn_terms: bool = Field(False, alias="SUBTITLE_RAG_AUTO_LEARN_TERMS")
rag_search_enabled: bool = Field(False, alias="SUBTITLE_RAG_SEARCH_ENABLED")
rag_search_url: str = Field("", alias="SUBTITLE_RAG_SEARCH_URL")
rag_search_categories: str = Field("general", alias="SUBTITLE_RAG_SEARCH_CATEGORIES")
rag_search_engines: str = Field("", alias="SUBTITLE_RAG_SEARCH_ENGINES")
rag_search_fallback_engines: str = Field("bing,baidu", alias="SUBTITLE_RAG_SEARCH_FALLBACK_ENGINES")
rag_search_language: str = Field("all", alias="SUBTITLE_RAG_SEARCH_LANGUAGE")
rag_search_safesearch: int = Field(0, alias="SUBTITLE_RAG_SEARCH_SAFESEARCH")
rag_search_time_range: str = Field("", alias="SUBTITLE_RAG_SEARCH_TIME_RANGE")
rag_search_pageno: int = Field(1, alias="SUBTITLE_RAG_SEARCH_PAGENO")
rag_domain: str = Field("", alias="SUBTITLE_RAG_DOMAIN")
# Orchestrator API (used by subtitle worker for auto pipelines).
+148
View File
@@ -34,6 +34,13 @@ type TranslateSettings = {
rag_wiki_enabled: boolean;
rag_search_enabled: boolean;
rag_search_url: string;
rag_search_categories: string;
rag_search_engines: string;
rag_search_fallback_engines: string;
rag_search_language: string;
rag_search_safesearch: number;
rag_search_time_range: string;
rag_search_pageno: number;
rag_domain: string;
rag_agent_parallelism: number;
rag_agent_timeout_seconds: number;
@@ -47,6 +54,51 @@ type EmbeddingModelInfo = {
type TranslateSettingsTab = "translation" | "rag" | "embedding" | "test";
const SEARXNG_CATEGORY_PRESETS = ["general", "it", "science", "news", "videos", "images", "files", "social media"];
const SEARXNG_ENGINE_PRESETS = [
"bing",
"baidu",
"brave",
"duckduckgo",
"google",
"startpage",
"qwant",
"mojeek",
"wikipedia",
"wikidata",
"arxiv",
"crossref",
"pubmed",
"semantic scholar",
"github",
"stackoverflow",
"reddit",
"youtube",
];
function csvItems(value: string) {
const seen = new Set<string>();
const out: string[] = [];
value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.forEach((item) => {
const key = item.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(item);
});
return out;
}
function toggleCsvItem(value: string, item: string) {
const clean = item.trim();
const current = csvItems(value);
const exists = current.some((x) => x.toLowerCase() === clean.toLowerCase());
return exists ? current.filter((x) => x.toLowerCase() !== clean.toLowerCase()).join(",") : [...current, clean].join(",");
}
function safeEmbeddingModelName(raw: string) {
const value = raw.trim().replace(/[\\/]/g, "--").replace(/[^A-Za-z0-9._-]/g, "-");
return value.slice(0, 96) || "embedding-model";
@@ -90,6 +142,13 @@ export default function SettingsTranslatePage() {
const [ragWikiEnabled, setRagWikiEnabled] = useState(false);
const [ragSearchEnabled, setRagSearchEnabled] = useState(false);
const [ragSearchUrl, setRagSearchUrl] = useState("");
const [ragSearchCategories, setRagSearchCategories] = useState("general");
const [ragSearchEngines, setRagSearchEngines] = useState("");
const [ragSearchFallbackEngines, setRagSearchFallbackEngines] = useState("bing,baidu");
const [ragSearchLanguage, setRagSearchLanguage] = useState("all");
const [ragSearchSafesearch, setRagSearchSafesearch] = useState(0);
const [ragSearchTimeRange, setRagSearchTimeRange] = useState("");
const [ragSearchPageno, setRagSearchPageno] = useState(1);
const [ragDomain, setRagDomain] = useState("");
const [ragAgentParallelism, setRagAgentParallelism] = useState(1);
const [ragAgentTimeoutSeconds, setRagAgentTimeoutSeconds] = useState(120);
@@ -140,6 +199,13 @@ export default function SettingsTranslatePage() {
setRagWikiEnabled(s.rag_wiki_enabled ?? false);
setRagSearchEnabled(s.rag_search_enabled);
setRagSearchUrl(s.rag_search_url);
setRagSearchCategories(s.rag_search_categories || "general");
setRagSearchEngines(s.rag_search_engines || "");
setRagSearchFallbackEngines(s.rag_search_fallback_engines || "bing,baidu");
setRagSearchLanguage(s.rag_search_language || "all");
setRagSearchSafesearch(s.rag_search_safesearch ?? 0);
setRagSearchTimeRange(s.rag_search_time_range || "");
setRagSearchPageno(s.rag_search_pageno ?? 1);
setRagDomain(s.rag_domain);
setRagAgentParallelism(s.rag_agent_parallelism ?? 1);
setRagAgentTimeoutSeconds(s.rag_agent_timeout_seconds ?? 120);
@@ -183,6 +249,13 @@ export default function SettingsTranslatePage() {
rag_wiki_enabled: ragWikiEnabled,
rag_search_enabled: ragSearchEnabled,
rag_search_url: ragSearchUrl,
rag_search_categories: ragSearchCategories,
rag_search_engines: ragSearchEngines,
rag_search_fallback_engines: ragSearchFallbackEngines,
rag_search_language: ragSearchLanguage,
rag_search_safesearch: ragSearchSafesearch,
rag_search_time_range: ragSearchTimeRange,
rag_search_pageno: ragSearchPageno,
rag_domain: ragDomain,
rag_agent_parallelism: ragAgentParallelism,
rag_agent_timeout_seconds: ragAgentTimeoutSeconds,
@@ -335,6 +408,7 @@ export default function SettingsTranslatePage() {
["summary", settings.default_enable_summary ? "true" : "false"],
["rag", settings.rag_enabled ? "enabled" : "disabled"],
["wiki", settings.rag_wiki_enabled ? "enabled" : "disabled"],
["search", settings.rag_search_enabled ? `${settings.rag_search_categories || "general"} / ${settings.rag_search_language || "all"}` : "disabled"],
["agents", `${settings.rag_agent_parallelism} / ${settings.rag_agent_timeout_seconds}s`],
["embedding", `${settings.rag_embedding_provider}:${settings.rag_embedding_model}`],
["embedding key", settings.rag_embedding_api_key_set ? "set" : "unset"],
@@ -551,6 +625,80 @@ export default function SettingsTranslatePage() {
<div className="mb-1 text-xs text-slate-600">SearXNG Base URL</div>
<input className="w-full rounded border px-3 py-2 text-sm" placeholder="https://search.linvk.com" value={ragSearchUrl} onChange={(e) => setRagSearchUrl(e.target.value)} />
</label>
<div className="block md:col-span-2">
<div className="mb-1 text-xs text-slate-600">SearXNG categories</div>
<input className="w-full rounded border px-3 py-2 text-sm" value={ragSearchCategories} onChange={(e) => setRagSearchCategories(e.target.value)} />
<div className="mt-2 flex flex-wrap gap-1.5">
{SEARXNG_CATEGORY_PRESETS.map((item) => {
const selected = csvItems(ragSearchCategories).some((x) => x.toLowerCase() === item.toLowerCase());
return (
<button
key={item}
type="button"
className={`rounded border px-2 py-1 text-xs ${selected ? "border-sky-400 bg-sky-50 text-sky-800" : "border-slate-200 text-slate-600 hover:bg-slate-50"}`}
onClick={() => setRagSearchCategories(toggleCsvItem(ragSearchCategories, item))}
>
{item}
</button>
);
})}
</div>
</div>
<div className="block md:col-span-2">
<div className="mb-1 text-xs text-slate-600">SearXNG engines</div>
<input className="w-full rounded border px-3 py-2 text-sm" placeholder="留空则使用实例默认引擎" value={ragSearchEngines} onChange={(e) => setRagSearchEngines(e.target.value)} />
<div className="mt-2 flex flex-wrap gap-1.5">
{SEARXNG_ENGINE_PRESETS.map((item) => {
const selected = csvItems(ragSearchEngines).some((x) => x.toLowerCase() === item.toLowerCase());
return (
<button
key={item}
type="button"
className={`rounded border px-2 py-1 text-xs ${selected ? "border-sky-400 bg-sky-50 text-sky-800" : "border-slate-200 text-slate-600 hover:bg-slate-50"}`}
onClick={() => setRagSearchEngines(toggleCsvItem(ragSearchEngines, item))}
>
{item}
</button>
);
})}
</div>
</div>
<label className="block md:col-span-2">
<div className="mb-1 text-xs text-slate-600">SearXNG fallback_engines</div>
<input className="w-full rounded border px-3 py-2 text-sm" value={ragSearchFallbackEngines} onChange={(e) => setRagSearchFallbackEngines(e.target.value)} />
</label>
<label className="block">
<div className="mb-1 text-xs text-slate-600">SearXNG language</div>
<select className="w-full rounded border px-3 py-2 text-sm" value={ragSearchLanguage} onChange={(e) => setRagSearchLanguage(e.target.value)}>
<option value="all">all</option>
<option value="zh-CN">zh-CN</option>
<option value="zh-TW">zh-TW</option>
<option value="en-US">en-US</option>
<option value="ja-JP">ja-JP</option>
<option value="ko-KR">ko-KR</option>
</select>
</label>
<label className="block">
<div className="mb-1 text-xs text-slate-600">SearXNG safesearch</div>
<select className="w-full rounded border px-3 py-2 text-sm" value={ragSearchSafesearch} onChange={(e) => setRagSearchSafesearch(parseInt(e.target.value || "0", 10))}>
<option value={0}>0</option>
<option value={1}>1</option>
<option value={2}>2</option>
</select>
</label>
<label className="block">
<div className="mb-1 text-xs text-slate-600">SearXNG time_range</div>
<select className="w-full rounded border px-3 py-2 text-sm" value={ragSearchTimeRange} onChange={(e) => setRagSearchTimeRange(e.target.value)}>
<option value="">不限</option>
<option value="day">day</option>
<option value="month">month</option>
<option value="year">year</option>
</select>
</label>
<label className="block">
<div className="mb-1 text-xs text-slate-600">SearXNG pageno</div>
<input type="number" min={1} max={100} className="w-full rounded border px-3 py-2 text-sm" value={ragSearchPageno} onChange={(e) => setRagSearchPageno(parseInt(e.target.value || "1", 10))} />
</label>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button disabled={busy} onClick={testEmbedding}>测试 Embedding</Button>
+102
View File
@@ -6,6 +6,8 @@ import httpx
from videoroll.ai.client import OpenAIChatConfig
from videoroll.ai.client import request_openai_embedding
from videoroll.apps.subtitle_service.agent_runtime import AgentBudget, AgentBudgetExceeded, AgentRuntime, AgentTraceEvent
from videoroll.apps.subtitle_service.retrieval import RetrievalPipeline
from videoroll.apps.subtitle_service.rag import (
_run_research_agents,
_fallback_search_queries,
@@ -56,6 +58,13 @@ def test_rag_settings_from_translate_settings_clamps_values() -> None:
"rag_wiki_enabled": True,
"rag_search_enabled": True,
"rag_search_url": "https://search.example",
"rag_search_categories": "general,it,general",
"rag_search_engines": "bing, baidu",
"rag_search_fallback_engines": "brave,duckduckgo",
"rag_search_language": "zh-CN",
"rag_search_safesearch": 2,
"rag_search_time_range": "month",
"rag_search_pageno": 3,
"rag_domain": "CS2",
"rag_agent_parallelism": 99,
"rag_agent_timeout_seconds": 9999,
@@ -74,11 +83,88 @@ def test_rag_settings_from_translate_settings_clamps_values() -> None:
assert cfg.wiki_enabled is True
assert cfg.search_enabled is True
assert cfg.search_url == "https://search.example"
assert cfg.search_categories == "general,it"
assert cfg.search_engines == "bing,baidu"
assert cfg.search_fallback_engines == "brave,duckduckgo"
assert cfg.search_language == "zh-CN"
assert cfg.search_safesearch == 2
assert cfg.search_time_range == "month"
assert cfg.search_pageno == 3
assert cfg.domain == "CS2"
assert cfg.agent_parallelism == 8
assert cfg.agent_timeout_seconds == 900.0
def test_agent_runtime_enforces_budget_and_records_normalized_steps() -> None:
steps: list[dict[str, object]] = []
runtime = AgentRuntime(
agent_name="rag_term_research",
run_id="run-1",
budget=AgentBudget(max_llm_calls=1, max_tool_calls=1, max_fetch_calls=0, timeout_seconds=30),
trace_recorder=steps.append,
)
runtime.before_llm()
runtime.record(AgentTraceEvent(kind="agent", action="started", output={"ok": True}))
try:
runtime.before_llm()
except AgentBudgetExceeded as e:
assert "LLM call budget" in str(e)
else:
raise AssertionError("expected LLM budget to stop the agent")
assert steps[0]["run_id"] == "run-1"
assert steps[0]["agent_name"] == "rag_term_research"
assert steps[0]["event_id"]
assert steps[0]["span_id"]
assert steps[0]["status"] == "ok"
assert steps[0]["budget"]["llm_calls"] == 1 # type: ignore[index]
def test_retrieval_pipeline_dedupes_records_and_rolls_back_on_error() -> None:
events: list[dict[str, object]] = []
errors: list[str] = []
pipeline = RetrievalPipeline[dict[str, str]](
id_getter=lambda item: item["id"],
trace_recorder=events.append,
error_handler=lambda e: errors.append(type(e).__name__),
)
first = pipeline.run_stage("exact", "AWP", lambda: [{"id": "1"}, {"id": "1"}, {"id": "2"}])
second = pipeline.run_stage("vector", "AWP", lambda: (_ for _ in ()).throw(RuntimeError("db aborted")))
assert first == [{"id": "1"}, {"id": "1"}, {"id": "2"}]
assert second == []
assert pipeline.hits == [{"id": "1"}, {"id": "2"}]
assert events[0]["action"] == "exact"
assert events[0]["count"] == 2
assert events[1]["status"] == "failed"
assert events[1]["error_type"] == "RuntimeError"
assert errors == ["RuntimeError"]
def test_research_tool_registry_exposes_enabled_tool_schemas() -> None:
from videoroll.apps.subtitle_service import rag as rag_module
cfg = rag_settings_from_translate_settings(
{
"rag_enabled": True,
"rag_wiki_enabled": True,
"rag_search_enabled": True,
}
)
specs = rag_module._ordered_tool_specs(rag_module._research_tool_registry(cfg))
names = [spec["name"] for spec in specs]
assert names == ["rag_lookup", "wiki_search", "search_web", "fetch_url", "finish"]
search_spec = next(spec for spec in specs if spec["name"] == "search_web")
assert "input_schema" in search_spec
assert "query" in search_spec["input_schema"]["properties"]
assert "filter_search_engine_internal_pages" in search_spec["guardrails"]
def test_build_knowledge_embedding_text_includes_term_fields() -> None:
text = build_knowledge_embedding_text(
item_type="term",
@@ -645,6 +731,22 @@ def test_search_url_accepts_base_url_and_legacy_search_url() -> None:
_search_url_with_params("https://search.example", query="truth table", json_format=True, extra_params={"engines": "bing,baidu"})
== "https://search.example/search?engines=bing%2Cbaidu&q=truth+table&format=json"
)
assert (
_search_url_with_params(
"https://search.example",
query="VGA 红色信号",
json_format=True,
extra_params={
"categories": "general,it",
"engines": "bing,baidu",
"language": "zh-CN",
"safesearch": "0",
"time_range": "month",
"pageno": "2",
},
)
== "https://search.example/search?categories=general%2Cit&engines=bing%2Cbaidu&language=zh-CN&safesearch=0&time_range=month&pageno=2&q=VGA+%E7%BA%A2%E8%89%B2%E4%BF%A1%E5%8F%B7&format=json"
)
def test_parse_search_json_accepts_searxng_content_results() -> None: