feat(search): app/search/vision.py — 两轮 VLM 帧观察模块
从 TRM4 core/tree/vision.py 迁移 observe_frame,关键变更: - VLM 调用走 VLMProvider.chat_with_images Protocol(images 传 Path) - OCR 调用走 OCRProvider.transcribe_frames 异步 Protocol - 遥测字段 session_id / parent_call_id 透传 - 帧文件存在性前置校验 12 个单元测试覆盖:两轮正常、仅提取、OCR 注入/失败降级/None、 VLM 提取失败、VLM 验证失败降级、帧缺失、stats 完整性、 分歧/弃权标记、遥测透传。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
"""视觉模型调用模块 -- 两轮 VLM 调用查看关键帧图像。
|
||||||
|
|
||||||
|
提取轮:带防幻觉 system prompt,提取原始视觉证据。
|
||||||
|
验证轮:把初稿全文喂回,逐条核实并给置信度。
|
||||||
|
|
||||||
|
从 TRM4 ``core/tree/vision.py`` 迁移,关键变更:
|
||||||
|
- VLM 调用走 ``VLMProvider.chat_with_images`` Protocol,images 传 Path 列表;
|
||||||
|
- OCR 调用走 ``OCRProvider.transcribe_frames`` 异步 Protocol;
|
||||||
|
- 遥测字段(session_id / parent_call_id)透传给 VLM 调用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.ports import OCRProvider
|
||||||
|
from core.protocols import VLMProvider
|
||||||
|
|
||||||
|
_OCR_PREFIX = (
|
||||||
|
"以下是 OCR 工具对这些帧的文字转录,仅供参考;"
|
||||||
|
"与你实际看到的不一致时,报告双读数并标注分歧:\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_prompt(prompts_dir: Path, filename: str) -> str:
|
||||||
|
"""从 prompts 目录加载 system prompt 文件。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
prompts_dir: prompt 文件所在目录。
|
||||||
|
filename: prompt 文件名。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
文件内容字符串。
|
||||||
|
"""
|
||||||
|
return (prompts_dir / filename).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def observe_frame(
|
||||||
|
vlm: VLMProvider,
|
||||||
|
frame_paths: list[Path],
|
||||||
|
question: str,
|
||||||
|
prompts_dir: Path,
|
||||||
|
*,
|
||||||
|
ocr: OCRProvider | None,
|
||||||
|
verify: bool,
|
||||||
|
stats_sink: Callable[[dict[str, int]], None] | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
parent_call_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""调用 VLM 查看帧图像:可选 OCR 事前并置 + 提取轮 + 可选验证轮。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
vlm: VLM 图文调用端口。
|
||||||
|
frame_paths: 帧文件路径列表。
|
||||||
|
question: 针对帧内容的视觉问题。
|
||||||
|
prompts_dir: prompt 文件目录。
|
||||||
|
ocr: 帧文字转录端口(None=不注入;返回空串视为无结果不注入)。
|
||||||
|
verify: 是否执行验证轮(False 时仅提取轮,输出无 [验证] 段)。
|
||||||
|
stats_sink: 统计回调(None 不收集);统计严禁写入输出文本。
|
||||||
|
session_id: 遥测会话 ID,透传给 VLM 调用。
|
||||||
|
parent_call_id: 遥测父调用 ID,透传给 VLM 调用。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
verify=True 为 ``"[视觉观察] {证据}\\n[验证] {核实结果}"``,
|
||||||
|
verify=False 为 ``"[视觉观察] {证据}"``,或错误信息。
|
||||||
|
|
||||||
|
关键实现细节:
|
||||||
|
OCR 文本作为额外文本并置于问题之前(事前并置——OCR 误读不进
|
||||||
|
工具输出故零 judge 口径风险);OCR 异常降级为不注入并计
|
||||||
|
ocr_failed(ocr 是外部注入依赖,任何异常都不得中断工具主流程,
|
||||||
|
故此处 except Exception 是刻意的降级边界)。sink 键:
|
||||||
|
ocr_injected / ocr_chars / ocr_failed / discrepancy(输出含"分歧"词面)/
|
||||||
|
abstain(含 [证据不存在])。
|
||||||
|
"""
|
||||||
|
stats: dict[str, int] = {
|
||||||
|
"ocr_injected": 0,
|
||||||
|
"ocr_chars": 0,
|
||||||
|
"ocr_failed": 0,
|
||||||
|
"discrepancy": 0,
|
||||||
|
"abstain": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _emit(output: str) -> str:
|
||||||
|
"""计算语义标记并回调 stats_sink。"""
|
||||||
|
stats["abstain"] = int("[证据不存在]" in output)
|
||||||
|
stats["discrepancy"] = int("分歧" in output)
|
||||||
|
if stats_sink is not None:
|
||||||
|
stats_sink(stats)
|
||||||
|
return output
|
||||||
|
|
||||||
|
# -- 帧文件存在性校验 --
|
||||||
|
for p in frame_paths:
|
||||||
|
if not p.exists():
|
||||||
|
return _emit(f"[VL错误] 帧文件不存在: {p}")
|
||||||
|
|
||||||
|
# -- OCR 转录(可选) --
|
||||||
|
ocr_text = ""
|
||||||
|
if ocr is not None:
|
||||||
|
try:
|
||||||
|
ocr_text = await ocr.transcribe_frames(frame_paths)
|
||||||
|
except Exception as e: # noqa: BLE001 — 刻意的降级边界
|
||||||
|
logger.warning("OCR 转录失败,降级不注入: {}", e)
|
||||||
|
stats["ocr_failed"] = 1
|
||||||
|
|
||||||
|
# -- 拼装提取轮 user 消息 --
|
||||||
|
user_parts: list[str] = []
|
||||||
|
if ocr_text:
|
||||||
|
stats["ocr_injected"] = 1
|
||||||
|
stats["ocr_chars"] = len(ocr_text)
|
||||||
|
user_parts.append(_OCR_PREFIX + ocr_text)
|
||||||
|
user_parts.append(question)
|
||||||
|
user_text = "\n".join(user_parts)
|
||||||
|
|
||||||
|
extract_messages = [
|
||||||
|
{"role": "system", "content": _load_prompt(prompts_dir, "observe_frame_extract.md")},
|
||||||
|
{"role": "user", "content": user_text},
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- 提取轮 --
|
||||||
|
try:
|
||||||
|
extract_response = await vlm.chat_with_images(
|
||||||
|
extract_messages,
|
||||||
|
images=frame_paths,
|
||||||
|
session_id=session_id,
|
||||||
|
parent_call_id=parent_call_id,
|
||||||
|
)
|
||||||
|
raw_evidence = extract_response.content
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return _emit(f"[VL错误] {e}")
|
||||||
|
|
||||||
|
if not verify:
|
||||||
|
return _emit(f"[视觉观察] {raw_evidence}")
|
||||||
|
|
||||||
|
# -- 验证轮 --
|
||||||
|
verify_text = (
|
||||||
|
f"问题: {question}\n\n"
|
||||||
|
f"以下是另一个模型基于这些图片生成的描述,请核实:\n{raw_evidence}"
|
||||||
|
)
|
||||||
|
verify_messages = [
|
||||||
|
{"role": "system", "content": _load_prompt(prompts_dir, "observe_frame_verify.md")},
|
||||||
|
{"role": "user", "content": verify_text},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
verify_response = await vlm.chat_with_images(
|
||||||
|
verify_messages,
|
||||||
|
images=frame_paths,
|
||||||
|
session_id=session_id,
|
||||||
|
parent_call_id=parent_call_id,
|
||||||
|
)
|
||||||
|
return _emit(f"[视觉观察] {raw_evidence}\n[验证] {verify_response.content}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("验证轮调用失败,跳过: {}", e)
|
||||||
|
return _emit(f"[视觉观察] {raw_evidence}\n[验证] 跳过(调用失败)")
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""observe_frame 单元测试。
|
||||||
|
|
||||||
|
FakeVLMProvider / FakeOCRProvider 实现 Protocol 最小集,
|
||||||
|
覆盖:两轮正常、verify=False 仅提取、OCR 注入、OCR 失败降级、
|
||||||
|
OCR 为 None、VLM 提取失败、VLM 验证失败降级、帧文件不存在、stats 键完整性。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.types import LLMResponse
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fake 实现
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DUMMY_RESPONSE_KWARGS = {
|
||||||
|
"thinking": "",
|
||||||
|
"model": "fake-vlm",
|
||||||
|
"provider": "fake",
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 20,
|
||||||
|
"latency_ms": 100,
|
||||||
|
"ttft_ms": None,
|
||||||
|
"max_inter_token_ms": None,
|
||||||
|
"cache_hit": False,
|
||||||
|
"call_id": "fake-call-id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeVLMProvider:
|
||||||
|
"""可编程的 VLM 假实现。
|
||||||
|
|
||||||
|
通过 responses 列表按序返回预设内容;raises 列表对应位置不为 None 时抛异常。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
responses: list[str] | None = None,
|
||||||
|
raises: list[Exception | None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._responses = responses or []
|
||||||
|
self._raises = raises or []
|
||||||
|
self._call_idx = 0
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def chat_with_images(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
images: list[str | Path],
|
||||||
|
*,
|
||||||
|
session_id: str | None = None,
|
||||||
|
parent_call_id: str | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
"""记录调用并按序返回预设响应或抛出异常。"""
|
||||||
|
idx = self._call_idx
|
||||||
|
self._call_idx += 1
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"messages": messages,
|
||||||
|
"images": images,
|
||||||
|
"session_id": session_id,
|
||||||
|
"parent_call_id": parent_call_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if idx < len(self._raises) and self._raises[idx] is not None:
|
||||||
|
raise self._raises[idx] # type: ignore[misc]
|
||||||
|
content = self._responses[idx] if idx < len(self._responses) else ""
|
||||||
|
return LLMResponse(content=content, **_DUMMY_RESPONSE_KWARGS)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOCRProvider:
|
||||||
|
"""可编程的 OCR 假实现。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
text: str = "",
|
||||||
|
raise_on_call: Exception | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._text = text
|
||||||
|
self._raise_on_call = raise_on_call
|
||||||
|
|
||||||
|
async def transcribe_frames(self, frame_paths: list[Path]) -> str:
|
||||||
|
"""返回预设文本或抛出异常。"""
|
||||||
|
if self._raise_on_call is not None:
|
||||||
|
raise self._raise_on_call
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def frame_files(tmp_path: Path) -> list[Path]:
|
||||||
|
"""创建两个最小 JPEG 占位帧文件。"""
|
||||||
|
frames: list[Path] = []
|
||||||
|
for i in range(2):
|
||||||
|
p = tmp_path / f"frame_{i}.jpg"
|
||||||
|
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 20)
|
||||||
|
frames.append(p)
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def prompts_dir(tmp_path: Path) -> Path:
|
||||||
|
"""创建 observe_frame_extract.md / observe_frame_verify.md 占位文件。"""
|
||||||
|
d = tmp_path / "prompts"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "observe_frame_extract.md").write_text("extract prompt", encoding="utf-8")
|
||||||
|
(d / "observe_frame_verify.md").write_text("verify prompt", encoding="utf-8")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 测试用例
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameNormal:
|
||||||
|
"""两轮正常执行(verify=True)。"""
|
||||||
|
|
||||||
|
def test_two_round_normal(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["raw evidence", "verified ok"])
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="what happened?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=True,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "[视觉观察] raw evidence\n[验证] verified ok"
|
||||||
|
assert len(vlm.calls) == 2
|
||||||
|
# 提取轮使用 extract prompt
|
||||||
|
assert vlm.calls[0]["messages"][0]["content"] == "extract prompt"
|
||||||
|
# 验证轮使用 verify prompt
|
||||||
|
assert vlm.calls[1]["messages"][0]["content"] == "verify prompt"
|
||||||
|
assert len(collected) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameExtractOnly:
|
||||||
|
"""verify=False 仅执行提取轮。"""
|
||||||
|
|
||||||
|
def test_extract_only(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["only extract"])
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "[视觉观察] only extract"
|
||||||
|
assert len(vlm.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameOCRInjection:
|
||||||
|
"""OCR 注入:文本非空时并置于问题前。"""
|
||||||
|
|
||||||
|
def test_ocr_injected(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["evidence with ocr"])
|
||||||
|
ocr = FakeOCRProvider(text="帧1: 你好世界")
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=ocr,
|
||||||
|
verify=False,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "[视觉观察]" in result
|
||||||
|
# 验证 OCR 文本被注入到 user message
|
||||||
|
user_msg = vlm.calls[0]["messages"][1]["content"]
|
||||||
|
assert "帧1: 你好世界" in user_msg
|
||||||
|
# stats 中 ocr_injected=1
|
||||||
|
assert collected[0]["ocr_injected"] == 1
|
||||||
|
assert collected[0]["ocr_chars"] == len("帧1: 你好世界")
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameOCRFailDegrades:
|
||||||
|
"""OCR 转录抛出异常时降级:不注入 OCR、ocr_failed=1。"""
|
||||||
|
|
||||||
|
def test_ocr_failure_degrades(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["evidence no ocr"])
|
||||||
|
ocr = FakeOCRProvider(raise_on_call=RuntimeError("OCR service down"))
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=ocr,
|
||||||
|
verify=False,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "[视觉观察] evidence no ocr"
|
||||||
|
assert collected[0]["ocr_failed"] == 1
|
||||||
|
assert collected[0]["ocr_injected"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameOCRNone:
|
||||||
|
"""ocr=None 时不执行转录。"""
|
||||||
|
|
||||||
|
def test_ocr_none(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["no ocr"])
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=False,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "[视觉观察] no ocr"
|
||||||
|
assert collected[0]["ocr_injected"] == 0
|
||||||
|
assert collected[0]["ocr_chars"] == 0
|
||||||
|
assert collected[0]["ocr_failed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameVLMExtractFailure:
|
||||||
|
"""VLM 提取轮失败 → 返回 [VL错误]。"""
|
||||||
|
|
||||||
|
def test_vlm_extract_failure(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(raises=[RuntimeError("VLM timeout")])
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=True,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.startswith("[VL错误]")
|
||||||
|
assert "VLM timeout" in result
|
||||||
|
assert len(collected) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameVLMVerifyFailureDegrades:
|
||||||
|
"""VLM 验证轮失败 → 降级:保留提取结果 + [验证] 跳过。"""
|
||||||
|
|
||||||
|
def test_vlm_verify_failure_degrades(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(
|
||||||
|
responses=["good evidence", ""],
|
||||||
|
raises=[None, RuntimeError("verify timeout")],
|
||||||
|
)
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=True,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "[视觉观察] good evidence" in result
|
||||||
|
assert "[验证] 跳过(调用失败)" in result
|
||||||
|
assert len(collected) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameFileMissing:
|
||||||
|
"""帧文件不存在 → 返回 [VL错误] 帧文件不存在。"""
|
||||||
|
|
||||||
|
def test_frame_file_not_found(self, prompts_dir: Path) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider()
|
||||||
|
missing = [Path("/nonexistent/frame_0.jpg")]
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=missing,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=True,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "[VL错误] 帧文件不存在" in result
|
||||||
|
assert len(collected) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameStatsKeys:
|
||||||
|
"""stats 包含全部五个预期键。"""
|
||||||
|
|
||||||
|
def test_stats_keys_complete(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["evidence"])
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=False,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_keys = {"ocr_injected", "ocr_chars", "ocr_failed", "discrepancy", "abstain"}
|
||||||
|
assert set(collected[0].keys()) == expected_keys
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameDiscrepancyAndAbstain:
|
||||||
|
"""VLM 返回含 '分歧' 或 '[证据不存在]' 时对应 stats 标记。"""
|
||||||
|
|
||||||
|
def test_discrepancy_flag(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["发现分歧:OCR 与画面不一致"])
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=False,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert collected[0]["discrepancy"] == 1
|
||||||
|
|
||||||
|
def test_abstain_flag(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["[证据不存在] 无法判断"])
|
||||||
|
collected: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=False,
|
||||||
|
stats_sink=collected.append,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert collected[0]["abstain"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestObserveFrameTelemetryPassthrough:
|
||||||
|
"""session_id 和 parent_call_id 透传到 VLM 调用。"""
|
||||||
|
|
||||||
|
def test_telemetry_passthrough(
|
||||||
|
self, frame_files: list[Path], prompts_dir: Path
|
||||||
|
) -> None:
|
||||||
|
from app.search.vision import observe_frame
|
||||||
|
|
||||||
|
vlm = FakeVLMProvider(responses=["evidence"])
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
observe_frame(
|
||||||
|
vlm=vlm,
|
||||||
|
frame_paths=frame_files,
|
||||||
|
question="q?",
|
||||||
|
prompts_dir=prompts_dir,
|
||||||
|
ocr=None,
|
||||||
|
verify=False,
|
||||||
|
session_id="sess-123",
|
||||||
|
parent_call_id="parent-456",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert vlm.calls[0]["session_id"] == "sess-123"
|
||||||
|
assert vlm.calls[0]["parent_call_id"] == "parent-456"
|
||||||
Reference in New Issue
Block a user