feat: improve RAG agent search configuration
This commit is contained in:
1 parent
5c35dfe463
commit
ecbaf44425
11 files changed
+1487
-81
No files matched your search
@@ -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。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user