ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# 由 create_sample.py 离线生成的样例文件
|
||||
test_files/
|
||||
|
||||
# Python 缓存与环境
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -0,0 +1,307 @@
|
||||
# Multimodal Agent — Three Extraction Paradigms / 多模态 Agent——三种抽取范式对比
|
||||
|
||||
> Companion material for *AI Agents in Depth*, Chapter 4 — **Experiment 4-2**: native multimodal vs extract-to-text vs tool-based analysis.
|
||||
> 配套《深入理解 AI Agent》第 4 章 **实验 4-2**:原生多模态 vs 先抽文本 vs 工具化分析。
|
||||
|
||||
← [Chapter 4 index / 返回第 4 章目录](../README.md)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Features — three extraction modes
|
||||
|
||||
1. **Native Multimodality**: model built-in multimodal
|
||||
- Gemini 2.5 Pro: PDF, image, audio
|
||||
- GPT-5/GPT-4o: images (OpenAI multimodal format)
|
||||
- Doubao 1.6: images
|
||||
|
||||
2. **Extract to Text**: convert first, then reason
|
||||
- PDF OCR (Gemini or GPT-5)
|
||||
- Image captions (GPT-5 or Doubao 1.6)
|
||||
- Audio: Whisper or Gemini
|
||||
|
||||
3. **Multimodal analysis tools**: add-on for follow-ups
|
||||
- Image / audio / PDF analysis tools
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
MultimodalAgent
|
||||
├── Configuration (config.py)
|
||||
├── Agent Core (agent.py) — messages, history, modes, streaming
|
||||
└── Multimodal Tools — image, audio, PDF analysis
|
||||
```
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# From the repository root: use the shared Chapter 4 environment
|
||||
uv sync --locked --python 3.12 --extra ch3
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch3]"
|
||||
|
||||
cd chapter4/multimodal-agent
|
||||
|
||||
# Exact legacy parity path, including python-magic file sniffing:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env
|
||||
# Edit .env with API keys
|
||||
export $(cat .env | xargs) # optional on Unix
|
||||
```
|
||||
|
||||
### Quick offline start (no API key)
|
||||
|
||||
Generate a chart-bearing sample so Experiment 4-2 is measurable—**exact quarterly figures live only in the chart bars**, not surrounding text:
|
||||
|
||||
```bash
|
||||
python create_sample.py # or: python demo.py --generate-sample
|
||||
# → test_files/sample_chart.png, test_files/sample_report.pdf
|
||||
```
|
||||
|
||||
Then compare three paradigms (needs vision API key):
|
||||
|
||||
```bash
|
||||
python demo.py \
|
||||
--file test_files/sample_chart.png \
|
||||
--query "Which quarter had the highest revenue, and what was the exact value?" \
|
||||
--model gpt-5.6-luna
|
||||
```
|
||||
|
||||
Chinese `--help` on `demo.py` / `main.py` / `create_sample.py`.
|
||||
|
||||
### Usage
|
||||
|
||||
#### Interactive
|
||||
|
||||
```bash
|
||||
python main.py --interactive
|
||||
```
|
||||
|
||||
Commands: `/file <path>`, `/mode <native|extract_to_text>`, `/model <name>`, `/tools <on|off>`, `/history`, `/clear`, `/quit`.
|
||||
|
||||
#### Single file
|
||||
|
||||
```bash
|
||||
python main.py --file document.pdf --query "What is the main topic?"
|
||||
python main.py --mode extract_to_text --file image.jpg --query "Describe this image"
|
||||
python main.py --tools --mode extract_to_text --file audio.mp3 --query "What's the content?"
|
||||
```
|
||||
|
||||
#### Programmatic
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent import MultimodalAgent, MultimodalContent
|
||||
from config import ExtractionMode
|
||||
|
||||
async def example():
|
||||
agent = MultimodalAgent(
|
||||
model="gemini-3.5-flash",
|
||||
mode=ExtractionMode.NATIVE,
|
||||
enable_tools=True
|
||||
)
|
||||
content = MultimodalContent(type="pdf", path="document.pdf")
|
||||
result = await agent.process_multimodal_content(content, "Summarize this document")
|
||||
print(result)
|
||||
async for chunk in agent.chat("Tell me more about the key points", stream=True):
|
||||
print(chunk, end="", flush=True)
|
||||
|
||||
asyncio.run(example())
|
||||
```
|
||||
|
||||
### Demo comparison
|
||||
|
||||
```bash
|
||||
python demo.py --file document.pdf --query "What are the key findings?" --model gpt-5.6-luna
|
||||
python demo.py document.pdf "What are the key findings?" # positional still works
|
||||
python demo.py --file test_files/sample_chart.png \
|
||||
--query "Which quarter had the highest revenue?" \
|
||||
--model gpt-5.6-luna --skip-model-comparison --output result.txt
|
||||
```
|
||||
|
||||
Runs: (1) native (2) extract-to-text (3) extract + tools (4) cross-model unless skipped.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--file` / positional | Image / PDF / audio |
|
||||
| `--query` / positional | Question |
|
||||
| `--model` | Default `gemini-3.5-flash` |
|
||||
| `--skip-model-comparison` | Only three-paradigm compare |
|
||||
| `--generate-sample` | Offline sample then exit |
|
||||
| `--output`, `-o` | Transcript file |
|
||||
|
||||
### Mode comparison
|
||||
|
||||
| Mode | Advantages | Disadvantages | Best for |
|
||||
|------|------------|---------------|----------|
|
||||
| **Native** | Full context; better vision | Limited models; more tokens | Mixed complex docs |
|
||||
| **Extract to Text** | Any text model; cacheable | Loses visual context | Text-heavy / cost |
|
||||
| **With Tools** | Follow-ups; selective depth | More API calls | Interactive Q&A |
|
||||
|
||||
### Supported files / models
|
||||
|
||||
- PDF (best native Gemini), images (JPEG/PNG/GIF/BMP/WebP), audio (MP3/WAV/M4A/FLAC/AAC/OGG)
|
||||
- Size limits: PDF/images 20MB, audio 25MB
|
||||
|
||||
| Model | Native PDF | Native Image | Native Audio | Extract | Tools |
|
||||
|-------|------------|--------------|--------------|---------|-------|
|
||||
| Gemini 2.5 Pro | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| GPT-5/GPT-4o | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
| Doubao 1.6 | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
|
||||
### API keys
|
||||
|
||||
- `GOOGLE_API_KEY` or `GEMINI_API_KEY` — PDF/audio native
|
||||
- `OPENAI_API_KEY` — GPT + Whisper
|
||||
- `DOUBAO_API_KEY` or `ARK_API_KEY`
|
||||
|
||||
### Testing / best practices
|
||||
|
||||
```bash
|
||||
python test_multimodal.py
|
||||
```
|
||||
|
||||
Prefer native when vision/audio fidelity matters; extract-to-text for cost/cache; tools for multi-turn. Validate files and keys; handle rate limits.
|
||||
|
||||
### License
|
||||
|
||||
MIT License — educational project.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 功能——三种抽取模式
|
||||
|
||||
1. **原生多模态**:直接用模型内置能力(Gemini PDF/图/音频;GPT/豆包图像等)
|
||||
2. **先抽文本再推理**:PDF OCR、图像描述、Whisper/Gemini 转写
|
||||
3. **多模态分析工具**:跟进问题的图像 / 音频 / PDF 工具
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
MultimodalAgent
|
||||
├── Configuration (config.py)
|
||||
├── Agent Core (agent.py)
|
||||
└── Multimodal Tools
|
||||
```
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 在仓库根目录使用统一的第 4 章环境
|
||||
uv sync --locked --python 3.12 --extra ch3
|
||||
|
||||
# 切换目录前先激活环境:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell:.venv\Scripts\Activate.ps1
|
||||
# Windows cmd:.venv\Scripts\activate.bat
|
||||
|
||||
# 未安装 uv 时可用 pip 兜底:
|
||||
# python -m pip install -e ".[ch3]"
|
||||
|
||||
cd chapter4/multimodal-agent
|
||||
|
||||
# 精确复现旧版单项目环境,含 python-magic 文件类型检测:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env
|
||||
# 编辑 API Key
|
||||
export $(cat .env | xargs) # Unix 可选
|
||||
```
|
||||
|
||||
### 离线快速开始(无需 API Key)
|
||||
|
||||
生成带图表的样例报告——**精确季度数字只在柱状图里**,方便测三种范式取舍:
|
||||
|
||||
```bash
|
||||
python create_sample.py
|
||||
```
|
||||
|
||||
再对比三种范式(需视觉 API Key):
|
||||
|
||||
```bash
|
||||
python demo.py \
|
||||
--file test_files/sample_chart.png \
|
||||
--query "Which quarter had the highest revenue, and what was the exact value?" \
|
||||
--model gpt-5.6-luna
|
||||
```
|
||||
|
||||
各 CLI 均有中文 `--help`。
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
python main.py --interactive
|
||||
# /file /mode /model /tools /history /clear /quit
|
||||
|
||||
python main.py --file document.pdf --query "What is the main topic?"
|
||||
python main.py --mode extract_to_text --file image.jpg --query "Describe this image"
|
||||
python main.py --tools --mode extract_to_text --file audio.mp3 --query "What's the content?"
|
||||
```
|
||||
|
||||
程序化用法见 English 节 `asyncio` 示例。
|
||||
|
||||
### 对比演示
|
||||
|
||||
```bash
|
||||
python demo.py --file document.pdf --query "What are the key findings?" --model gpt-5.6-luna
|
||||
python demo.py --file test_files/sample_chart.png \
|
||||
--query "Which quarter had the highest revenue?" \
|
||||
--model gpt-5.6-luna --skip-model-comparison --output result.txt
|
||||
```
|
||||
|
||||
| 标志 | 说明 |
|
||||
|------|------|
|
||||
| `--file` | 多模态文件 |
|
||||
| `--query` | 问题 |
|
||||
| `--model` | 默认 `gemini-3.5-flash` |
|
||||
| `--skip-model-comparison` | 只做三范式对比 |
|
||||
| `--generate-sample` | 离线生成样例后退出 |
|
||||
| `--output`, `-o` | 保存完整记录 |
|
||||
|
||||
### 模式对比
|
||||
|
||||
| 模式 | 优势 | 劣势 | 适用 |
|
||||
|------|------|------|------|
|
||||
| **原生** | 上下文与视觉完整 | 模型支持有限、token 多 | 复杂混排文档 |
|
||||
| **抽文本** | 通用、可缓存 | 丢视觉细节 | 文本向 / 控成本 |
|
||||
| **工具** | 可追问、按需深挖 | 多次 API | 交互式问答 |
|
||||
|
||||
### 文件与模型能力
|
||||
|
||||
支持 PDF / 常见图像 / 常见音频;大小限制 PDF/图 20MB、音频 25MB。能力矩阵与 English 表相同。
|
||||
|
||||
### API Key
|
||||
|
||||
- `GOOGLE_API_KEY` 或 `GEMINI_API_KEY`
|
||||
- `OPENAI_API_KEY`
|
||||
- `DOUBAO_API_KEY` 或 `ARK_API_KEY`
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
python test_multimodal.py
|
||||
```
|
||||
|
||||
### 许可
|
||||
|
||||
MIT — 教学项目。
|
||||
|
||||
---
|
||||
|
||||
## Notes / 说明
|
||||
|
||||
### OpenRouter 通用回退 / Universal OpenRouter fallback
|
||||
|
||||
Chat / vision can route via OpenRouter when `OPENROUTER_API_KEY` is set and primary keys are missing. **Audio transcription (Whisper) and native-PDF extraction still need direct OpenAI/Gemini keys.**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live three-paradigm comparison for Chapter 4 Experiment 4-2.
|
||||
|
||||
The PNG chart and the PDF page containing that chart are each submitted to the
|
||||
same two questions through native vision, local text extraction followed by a
|
||||
text-only model, and an agent that decides whether to invoke a vision tool.
|
||||
Every provider call is checkpointed immediately and later copied into the
|
||||
immutable campaign receipts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent
|
||||
CHAPTER_DIR = PROJECT_DIR.parent
|
||||
sys.path.insert(0, str(CHAPTER_DIR))
|
||||
|
||||
from experiment_utils import ChatRecorder, jsonable, sha256_file, write_campaign_evidence # noqa: E402
|
||||
|
||||
ARK_ENDPOINT = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
MOONSHOT_ENDPOINT = "https://api.moonshot.cn/v1"
|
||||
SEED = 37
|
||||
QUESTIONS = [
|
||||
{
|
||||
"id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"required_patterns": [r"\bQ4\b", r"(?:\$\s*)?180\s*M"],
|
||||
},
|
||||
{
|
||||
"id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"required_patterns": [r"\bQ3\b", r"(?:\$\s*)?95\s*M", r"(?:\$\s*)?85\s*M"],
|
||||
},
|
||||
]
|
||||
TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "inspect_visual",
|
||||
"description": "Inspect the original chart or PDF page when exact visual, spatial, or numeric evidence is needed.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"question": {"type": "string"}},
|
||||
"required": ["question"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CheckpointRecorder(ChatRecorder):
|
||||
def __init__(self, *args: Any, checkpoint: Path, **kwargs: Any):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.checkpoint = checkpoint
|
||||
self.checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def create(self, *, purpose: str, **request: Any) -> Any:
|
||||
try:
|
||||
return super().create(purpose=purpose, **request)
|
||||
finally:
|
||||
self.checkpoint.write_text(
|
||||
json.dumps(self.calls, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def data_url(path: Path) -> str:
|
||||
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
||||
return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"
|
||||
|
||||
|
||||
def local_extract(kind: str, original: Path) -> tuple[str, dict[str, Any]]:
|
||||
started = time.perf_counter()
|
||||
if kind == "png":
|
||||
command = ["tesseract", str(original), "stdout", "--psm", "6"]
|
||||
else:
|
||||
command = ["pdftotext", "-layout", str(original), "-"]
|
||||
proc = subprocess.run(command, text=True, capture_output=True, check=True)
|
||||
return proc.stdout.strip(), {
|
||||
"command": command,
|
||||
"stderr": proc.stderr,
|
||||
"latency_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
}
|
||||
|
||||
|
||||
def render_pdf(pdf: Path, output: Path) -> None:
|
||||
prefix = output.with_suffix("")
|
||||
subprocess.run(
|
||||
["pdftoppm", "-png", "-singlefile", "-r", "180", str(pdf), str(prefix)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def answer_text(recorder: CheckpointRecorder, model: str, context: str, question: str, purpose: str) -> str:
|
||||
response = recorder.create(
|
||||
purpose=purpose,
|
||||
model=model,
|
||||
seed=SEED,
|
||||
temperature=0,
|
||||
messages=[
|
||||
{"role": "system", "content": "Answer only from the extracted text. If it lacks the exact visual evidence, say that it is unavailable."},
|
||||
{"role": "user", "content": f"Extracted text:\n{context}\n\nQuestion: {question}"},
|
||||
],
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
def answer_vision(recorder: CheckpointRecorder, model: str, image: Path, question: str, purpose: str) -> str:
|
||||
response = recorder.create(
|
||||
purpose=purpose,
|
||||
model=model,
|
||||
seed=SEED,
|
||||
temperature=0,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"Read the chart carefully. {question} Give exact values and concise supporting visual evidence."},
|
||||
{"type": "image_url", "image_url": {"url": data_url(image)}},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
def answer_with_tool(
|
||||
recorder: CheckpointRecorder,
|
||||
model: str,
|
||||
extracted: str,
|
||||
image: Path,
|
||||
question: str,
|
||||
artifact_id: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are given a cheap text extraction and one visual-inspection tool. "
|
||||
"Call inspect_visual whenever exact chart values or spatial associations are not explicitly established by the text."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": f"Extracted text:\n{extracted}\n\nQuestion: {question}"},
|
||||
]
|
||||
decision = recorder.create(
|
||||
purpose=f"tool-decision:{artifact_id}",
|
||||
model=model,
|
||||
seed=SEED,
|
||||
temperature=0,
|
||||
messages=messages,
|
||||
tools=[TOOL],
|
||||
tool_choice="auto",
|
||||
)
|
||||
message = decision.choices[0].message
|
||||
calls = list(message.tool_calls or [])
|
||||
trace: dict[str, Any] = {"tool_selected": bool(calls), "decision": jsonable(message), "executions": []}
|
||||
if not calls:
|
||||
return message.content or "", trace
|
||||
|
||||
messages.append(message.model_dump(exclude_none=True))
|
||||
for call in calls:
|
||||
arguments = json.loads(call.function.arguments or "{}")
|
||||
tool_question = arguments.get("question") or question
|
||||
result = answer_vision(
|
||||
recorder,
|
||||
model,
|
||||
image,
|
||||
tool_question,
|
||||
f"tool-vision:{artifact_id}:{call.id}",
|
||||
)
|
||||
trace["executions"].append(
|
||||
{"tool_call_id": call.id, "name": call.function.name, "arguments": arguments, "result": result}
|
||||
)
|
||||
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
|
||||
final = recorder.create(
|
||||
purpose=f"tool-final:{artifact_id}",
|
||||
model=model,
|
||||
seed=SEED,
|
||||
temperature=0,
|
||||
messages=messages,
|
||||
tools=[TOOL],
|
||||
tool_choice="none",
|
||||
)
|
||||
return final.choices[0].message.content or "", trace
|
||||
|
||||
|
||||
def exact_correct(answer: str, patterns: list[str]) -> bool:
|
||||
return all(re.search(pattern, answer, flags=re.IGNORECASE) for pattern in patterns)
|
||||
|
||||
|
||||
def judge_answers(recorder: CheckpointRecorder, model: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
payload = [
|
||||
{"id": row["id"], "question": row["question"], "reference": row["expected"], "answer": row["answer"]}
|
||||
for row in rows
|
||||
]
|
||||
response = recorder.create(
|
||||
purpose="external-answer-judge",
|
||||
model=model,
|
||||
seed=SEED,
|
||||
temperature=0,
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Independently judge chart QA answers. Return JSON {items:[{id,correct,score,reason}]}. "
|
||||
"Score 1 only if every requested quarter/value/difference matches the reference; otherwise 0."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||
],
|
||||
)
|
||||
try:
|
||||
return json.loads(response.choices[0].message.content)["items"]
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
|
||||
def tool_version(command: list[str]) -> str:
|
||||
proc = subprocess.run(command, text=True, capture_output=True)
|
||||
return (proc.stdout or proc.stderr).splitlines()[0]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Experiment 4-2 live multimodal campaign")
|
||||
parser.add_argument("--model", default=os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"))
|
||||
parser.add_argument("--judge-model", default=os.getenv("MULTIMODAL_JUDGE_MODEL", "moonshot-v1-8k"))
|
||||
args = parser.parse_args()
|
||||
ark_key = os.getenv("ARK_API_KEY") or os.getenv("DOUBAO_API_KEY")
|
||||
moonshot_key = os.getenv("MOONSHOT_API_KEY")
|
||||
if not ark_key or not moonshot_key:
|
||||
raise RuntimeError("ARK_API_KEY and MOONSHOT_API_KEY are required")
|
||||
|
||||
checkpoint_dir = PROJECT_DIR / "validation" / "checkpoints"
|
||||
checkpoint_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
|
||||
ark = CheckpointRecorder(
|
||||
OpenAI(api_key=ark_key, base_url=ARK_ENDPOINT, timeout=120, max_retries=3),
|
||||
"volcengine-ark",
|
||||
ARK_ENDPOINT,
|
||||
checkpoint=checkpoint_dir / f"{checkpoint_id}-ark.json",
|
||||
)
|
||||
judge = CheckpointRecorder(
|
||||
OpenAI(api_key=moonshot_key, base_url=MOONSHOT_ENDPOINT, timeout=120, max_retries=3),
|
||||
"moonshot",
|
||||
MOONSHOT_ENDPOINT,
|
||||
checkpoint=checkpoint_dir / f"{checkpoint_id}-judge.json",
|
||||
)
|
||||
|
||||
chart = PROJECT_DIR / "test_files" / "sample_chart.png"
|
||||
pdf = PROJECT_DIR / "test_files" / "sample_report.pdf"
|
||||
if not chart.exists() or not pdf.exists():
|
||||
subprocess.run([sys.executable, str(PROJECT_DIR / "create_sample.py")], cwd=PROJECT_DIR, check=True)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
rendered_pdf = Path(temp_dir) / "sample_report_page.png"
|
||||
render_pdf(pdf, rendered_pdf)
|
||||
artifacts = [
|
||||
("png", chart, chart),
|
||||
("pdf", pdf, rendered_pdf),
|
||||
]
|
||||
rows: list[dict[str, Any]] = []
|
||||
artifact_records = []
|
||||
for kind, original, visual in artifacts:
|
||||
extracted, extraction_receipt = local_extract(kind, original)
|
||||
artifact_records.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"source_path": str(original),
|
||||
"source_sha256": sha256_file(original),
|
||||
"visual_input": str(visual),
|
||||
"visual_sha256": sha256_file(visual),
|
||||
"extracted_text": extracted,
|
||||
"extraction": extraction_receipt,
|
||||
}
|
||||
)
|
||||
for spec in QUESTIONS:
|
||||
base = {
|
||||
"artifact": kind,
|
||||
"question_id": spec["id"],
|
||||
"question": spec["question"],
|
||||
"expected": spec["expected"],
|
||||
}
|
||||
started = time.perf_counter()
|
||||
native = answer_vision(ark, args.model, visual, spec["question"], f"native:{kind}:{spec['id']}")
|
||||
rows.append(
|
||||
{
|
||||
**base,
|
||||
"id": f"{kind}:native:{spec['id']}",
|
||||
"paradigm": "native-multimodal",
|
||||
"answer": native,
|
||||
"latency_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
"exact_correct": exact_correct(native, spec["required_patterns"]),
|
||||
}
|
||||
)
|
||||
started = time.perf_counter()
|
||||
text_answer = answer_text(
|
||||
ark, args.model, extracted, spec["question"], f"extract-text:{kind}:{spec['id']}"
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
**base,
|
||||
"id": f"{kind}:extract:{spec['id']}",
|
||||
"paradigm": "extract-to-text",
|
||||
"answer": text_answer,
|
||||
"latency_ms": round(extraction_receipt["latency_ms"] + (time.perf_counter() - started) * 1000, 3),
|
||||
"exact_correct": exact_correct(text_answer, spec["required_patterns"]),
|
||||
}
|
||||
)
|
||||
started = time.perf_counter()
|
||||
tool_answer, tool_trace = answer_with_tool(
|
||||
ark, args.model, extracted, visual, spec["question"], f"{kind}:{spec['id']}"
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
**base,
|
||||
"id": f"{kind}:tool:{spec['id']}",
|
||||
"paradigm": "tool-on-demand",
|
||||
"answer": tool_answer,
|
||||
"latency_ms": round(extraction_receipt["latency_ms"] + (time.perf_counter() - started) * 1000, 3),
|
||||
"exact_correct": exact_correct(tool_answer, spec["required_patterns"]),
|
||||
"tool_trace": tool_trace,
|
||||
}
|
||||
)
|
||||
|
||||
judgements = judge_answers(judge, args.judge_model, rows)
|
||||
judged = {item["id"]: item for item in judgements}
|
||||
for row in rows:
|
||||
row["external_judge"] = judged[row["id"]]
|
||||
summary: dict[str, Any] = {}
|
||||
for paradigm in ("native-multimodal", "extract-to-text", "tool-on-demand"):
|
||||
selected = [row for row in rows if row["paradigm"] == paradigm]
|
||||
summary[paradigm] = {
|
||||
"cases": len(selected),
|
||||
"exact_accuracy": sum(row["exact_correct"] for row in selected) / len(selected),
|
||||
"judge_accuracy": sum(bool(row["external_judge"]["correct"]) for row in selected) / len(selected),
|
||||
"mean_latency_ms": sum(row["latency_ms"] for row in selected) / len(selected),
|
||||
}
|
||||
|
||||
pdf_text = next(item["extracted_text"] for item in artifact_records if item["kind"] == "pdf")
|
||||
tool_rows = [row for row in rows if row["paradigm"] == "tool-on-demand"]
|
||||
acceptance = {
|
||||
"same_two_questions_all_paradigms_and_artifacts": len(rows) == 12,
|
||||
"png_and_pdf_used": {row["artifact"] for row in rows} == {"png", "pdf"},
|
||||
"chart_answers_absent_from_pdf_body_text": not any(
|
||||
value in pdf_text.lower() for value in ("$180", "180m", "$95", "95m", "$85", "85m")
|
||||
),
|
||||
"real_native_vision_calls": len([call for call in ark.calls if call["purpose"].startswith("native:")]) == 4,
|
||||
"tool_selected_on_demand": all(row["tool_trace"]["tool_selected"] for row in tool_rows),
|
||||
"real_tool_vision_calls": len([call for call in ark.calls if call["purpose"].startswith("tool-vision:")]) >= 4,
|
||||
"external_moonshot_judge": len(judge.calls) == 1 and len(judgements) == len(rows),
|
||||
"all_calls_checkpointed": (checkpoint_dir / f"{checkpoint_id}-ark.json").exists()
|
||||
and (checkpoint_dir / f"{checkpoint_id}-judge.json").exists(),
|
||||
}
|
||||
evidence = {
|
||||
"status": "passed" if all(acceptance.values()) else "failed",
|
||||
"providers": {
|
||||
"vision_answerer": {"provider": "Volcengine Ark", "endpoint": ARK_ENDPOINT, "model": args.model, "seed": SEED},
|
||||
"judge": {"provider": "Moonshot", "endpoint": MOONSHOT_ENDPOINT, "model": args.judge_model, "seed": SEED},
|
||||
},
|
||||
"local_tools": {
|
||||
"tesseract": tool_version(["tesseract", "--version"]),
|
||||
"pdftotext": tool_version(["pdftotext", "-v"]),
|
||||
"pdftoppm": tool_version(["pdftoppm", "-v"]),
|
||||
},
|
||||
"artifacts": artifact_records,
|
||||
"questions": QUESTIONS,
|
||||
"results": rows,
|
||||
"summary": summary,
|
||||
"acceptance": acceptance,
|
||||
"checkpoint_files": [str(ark.checkpoint), str(judge.checkpoint)],
|
||||
}
|
||||
manifest = write_campaign_evidence(
|
||||
PROJECT_DIR,
|
||||
"4-2",
|
||||
evidence,
|
||||
receipts=ark.calls + judge.calls,
|
||||
input_paths=[__file__, PROJECT_DIR / "create_sample.py", chart, pdf],
|
||||
)
|
||||
print(json.dumps(summary, indent=2))
|
||||
print(json.dumps(acceptance, indent=2))
|
||||
print(f"evidence: {manifest['run_dir']}")
|
||||
return 0 if all(acceptance.values()) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Configuration for Multimodal Agent
|
||||
Supports multiple providers and extraction modes
|
||||
"""
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _openrouter_model_id(model) -> str:
|
||||
"""Map a provider-native model name to an OpenRouter model id, used by the
|
||||
universal OpenRouter fallback. An explicit OPENROUTER_MODEL env var wins.
|
||||
Vision-capable default (gpt-5.6-luna) so image analysis still works."""
|
||||
override = os.getenv("OPENROUTER_MODEL")
|
||||
if override:
|
||||
return override
|
||||
m = (model or "").strip()
|
||||
if not m:
|
||||
return "openai/gpt-5.6-luna"
|
||||
if "/" in m:
|
||||
return m
|
||||
ml = m.lower()
|
||||
if ml.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
|
||||
return "openai/" + m
|
||||
if ml.startswith("claude-"):
|
||||
return "anthropic/claude-opus-4.8"
|
||||
if ml.startswith("gemini"):
|
||||
return "google/" + m # e.g. gemini-3.5-flash -> google/gemini-3.5-flash
|
||||
# Provider-native ids (doubao-*/qwen/...) -> a widely-available vision model.
|
||||
return "openai/gpt-5.6-luna"
|
||||
|
||||
|
||||
class ExtractionMode(Enum):
|
||||
"""Modes for multimodal content extraction"""
|
||||
NATIVE = "native" # Use model's native multimodal capabilities
|
||||
EXTRACT_TO_TEXT = "extract_to_text" # Convert multimodal to text first
|
||||
|
||||
|
||||
class Provider(Enum):
|
||||
"""Supported model providers"""
|
||||
GEMINI = "gemini"
|
||||
OPENAI = "openai"
|
||||
DOUBAO = "doubao"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
"""Configuration for a specific model"""
|
||||
provider: Provider
|
||||
model_name: str
|
||||
api_key: str
|
||||
base_url: Optional[str] = None
|
||||
supports_native_multimodal: bool = True
|
||||
|
||||
|
||||
class Config:
|
||||
"""Main configuration class for multimodal agent"""
|
||||
|
||||
def __init__(self):
|
||||
# Load API keys from environment.
|
||||
# 兼容常见别名:Gemini 官方 SDK 用 GEMINI_API_KEY,旧文档用 GOOGLE_API_KEY,两者都接受;
|
||||
# 豆包/方舟(Ark)的 Key 环境变量常见为 DOUBAO_API_KEY 或 ARK_API_KEY。
|
||||
self.gemini_api_key = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY", "")
|
||||
self.openai_api_key = os.getenv("OPENAI_API_KEY", "")
|
||||
self.doubao_api_key = os.getenv("DOUBAO_API_KEY") or os.getenv("ARK_API_KEY", "")
|
||||
|
||||
# Universal OpenRouter fallback: when a model's own provider key is
|
||||
# missing but OPENROUTER_API_KEY is present, route that model through
|
||||
# OpenRouter's OpenAI-compatible endpoint.
|
||||
self.openrouter_api_key = os.getenv("OPENROUTER_API_KEY", "")
|
||||
self.openrouter_base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Model configurations
|
||||
self.models = {
|
||||
"gemini-3.5-flash": ModelConfig(
|
||||
provider=Provider.GEMINI,
|
||||
model_name="gemini-3.5-flash",
|
||||
api_key=self.gemini_api_key,
|
||||
supports_native_multimodal=True
|
||||
),
|
||||
"gpt-5": ModelConfig(
|
||||
provider=Provider.OPENAI,
|
||||
model_name="gpt-5",
|
||||
api_key=self.openai_api_key,
|
||||
supports_native_multimodal=True
|
||||
),
|
||||
"gpt-5.6-luna": ModelConfig(
|
||||
provider=Provider.OPENAI,
|
||||
model_name="gpt-5.6-luna",
|
||||
api_key=self.openai_api_key,
|
||||
supports_native_multimodal=True
|
||||
),
|
||||
"doubao-1.6": ModelConfig(
|
||||
provider=Provider.DOUBAO,
|
||||
model_name=os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"),
|
||||
api_key=self.doubao_api_key,
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||||
supports_native_multimodal=True
|
||||
)
|
||||
}
|
||||
|
||||
# Default settings
|
||||
self.default_model = os.getenv("MULTIMODAL_MODEL", "doubao-1.6")
|
||||
self.default_mode = ExtractionMode.NATIVE
|
||||
self.enable_multimodal_tools = False
|
||||
|
||||
# File size limits (in MB)
|
||||
self.max_pdf_size_mb = 20
|
||||
self.max_image_size_mb = 20
|
||||
self.max_audio_size_mb = 25
|
||||
|
||||
# Whisper settings for audio transcription
|
||||
self.whisper_model = "whisper-1"
|
||||
|
||||
# Temperature settings
|
||||
self.temperature = 0.7
|
||||
self.max_tokens = 4096
|
||||
|
||||
def get_model_config(self, model_name: str) -> ModelConfig:
|
||||
"""Get configuration for a specific model"""
|
||||
if model_name not in self.models:
|
||||
raise ValueError(f"Unknown model: {model_name}")
|
||||
return self.models[model_name]
|
||||
|
||||
def validate_api_keys(self) -> Dict[str, bool]:
|
||||
"""Check which API keys are configured"""
|
||||
return {
|
||||
"gemini": bool(self.gemini_api_key),
|
||||
"openai": bool(self.openai_api_key),
|
||||
"doubao": bool(self.doubao_api_key),
|
||||
"openrouter": bool(self.openrouter_api_key)
|
||||
}
|
||||
|
||||
def has_provider_key(self, provider: 'Provider') -> bool:
|
||||
"""Whether the direct API key for a provider is configured."""
|
||||
if provider == Provider.OPENAI:
|
||||
return bool(self.openai_api_key)
|
||||
if provider == Provider.DOUBAO:
|
||||
return bool(self.doubao_api_key)
|
||||
if provider == Provider.GEMINI:
|
||||
return bool(self.gemini_api_key)
|
||||
return False
|
||||
|
||||
def use_openrouter(self, provider: 'Provider') -> bool:
|
||||
"""True when a model's own provider key is missing but OpenRouter is
|
||||
available -> the call should be routed through OpenRouter."""
|
||||
return (not self.has_provider_key(provider)) and bool(self.openrouter_api_key)
|
||||
|
||||
def openai_client_args(self, model_config: 'ModelConfig'):
|
||||
"""Return (client_kwargs, model_name) for an OpenAI-compatible call,
|
||||
applying the universal OpenRouter fallback when needed."""
|
||||
provider = model_config.provider
|
||||
_m = (model_config.model_name or "").lower()
|
||||
_prefer_or = bool(self.openrouter_api_key) and _m.startswith("gpt-5") # 直连 gpt-5.6 需组织实名,优先 OpenRouter
|
||||
if _prefer_or or self.use_openrouter(provider):
|
||||
return (
|
||||
{"api_key": self.openrouter_api_key, "base_url": self.openrouter_base_url},
|
||||
_openrouter_model_id(model_config.model_name),
|
||||
)
|
||||
if provider == Provider.DOUBAO:
|
||||
return {"api_key": self.doubao_api_key, "base_url": model_config.base_url}, model_config.model_name
|
||||
# OPENAI (and any GEMINI forced through the OpenAI-compatible path)
|
||||
return {"api_key": self.openai_api_key}, model_config.model_name
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
离线样例生成器 (Offline sample generator)
|
||||
|
||||
生成一个"含图表的报告"作为多模态样例,用于实验 4-2 对比三种提取范式。
|
||||
产物同时包含:
|
||||
- test_files/sample_chart.png 仅图表(图像模态)
|
||||
- test_files/sample_report.pdf 图表 + 文字说明(文档模态,书中的"含图表的 PDF 报告")
|
||||
|
||||
关键设计:图表里的精确数值(如各季度营收)只出现在柱状图上,正文并未逐一写出。
|
||||
这样在实验中:
|
||||
- 原生多模态模式可以直接"看懂"柱子读出数值;
|
||||
- 提取为文本模式若用通用描述器转写图像,往往丢失精确数值与空间关系;
|
||||
从而让三种范式的取舍可被直接测量,而不是靠猜。
|
||||
|
||||
本脚本完全离线,不需要任何 API Key。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # 无界面后端,纯离线出图
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
# 图表数据:只在柱状图上标注,正文不重复这些精确数字
|
||||
QUARTERS = ["Q1", "Q2", "Q3", "Q4"]
|
||||
REVENUE = [120, 150, 95, 180] # 单位:百万美元 ($M)
|
||||
|
||||
|
||||
def create_chart(output_path: Path) -> Path:
|
||||
"""用 matplotlib 生成一张柱状图(图像模态样例)。"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(6, 4), dpi=150)
|
||||
bars = ax.bar(QUARTERS, REVENUE, color=["#4C72B0", "#55A868", "#C44E52", "#8172B3"])
|
||||
|
||||
# 把精确数值标注在柱子顶端——这些信息只存在于图像里
|
||||
for bar, value in zip(bars, REVENUE):
|
||||
ax.text(
|
||||
bar.get_x() + bar.get_width() / 2,
|
||||
bar.get_height() + 3,
|
||||
f"${value}M",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
fontsize=11,
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
ax.set_title("Acme Corp Quarterly Revenue 2024", fontsize=13, fontweight="bold")
|
||||
ax.set_ylabel("Revenue (in $M)")
|
||||
ax.set_ylim(0, 210)
|
||||
ax.grid(axis="y", linestyle="--", alpha=0.4)
|
||||
fig.tight_layout()
|
||||
|
||||
fig.savefig(output_path)
|
||||
plt.close(fig)
|
||||
return output_path
|
||||
|
||||
|
||||
def create_report_pdf(chart_path: Path, output_path: Path) -> Path:
|
||||
"""把图表和一段文字说明组合成一份 PDF 报告(文档模态样例)。"""
|
||||
try:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate,
|
||||
Paragraph,
|
||||
Spacer,
|
||||
Image as RLImage,
|
||||
)
|
||||
except ImportError:
|
||||
print("提示:未安装 reportlab,跳过 PDF 生成(pip install reportlab)。")
|
||||
return None
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# 正文刻意只给出定性描述,不逐一写出各季度精确数值——数值只在图里
|
||||
body_text = (
|
||||
"This internal report summarizes Acme Corp's revenue performance in 2024. "
|
||||
"Overall the year showed healthy growth, with a mid-year dip followed by a "
|
||||
"strong recovery in the final quarter. The chart below breaks down revenue "
|
||||
"by quarter; management attributes the fourth-quarter surge to the launch of "
|
||||
"the new enterprise product line."
|
||||
)
|
||||
|
||||
doc = SimpleDocTemplate(str(output_path), pagesize=A4)
|
||||
story = [
|
||||
Paragraph("Acme Corp 2024 Revenue Report", styles["Title"]),
|
||||
Spacer(1, 0.4 * cm),
|
||||
Paragraph(body_text, styles["BodyText"]),
|
||||
Spacer(1, 0.6 * cm),
|
||||
RLImage(str(chart_path), width=14 * cm, height=9.3 * cm),
|
||||
]
|
||||
doc.build(story)
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="离线生成含图表的多模态样例(图像 + PDF 报告),供实验 4-2 使用。无需 API Key。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default="test_files",
|
||||
help="样例输出目录(默认:test_files)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-pdf",
|
||||
action="store_true",
|
||||
help="只生成 PNG 图表,不生成 PDF 报告",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
out_dir = Path(args.output_dir)
|
||||
chart_path = create_chart(out_dir / "sample_chart.png")
|
||||
print(f"已生成图表: {chart_path}")
|
||||
|
||||
if not args.no_pdf:
|
||||
pdf_path = create_report_pdf(chart_path, out_dir / "sample_report.pdf")
|
||||
if pdf_path:
|
||||
print(f"已生成报告: {pdf_path}")
|
||||
|
||||
print(
|
||||
"\n提示:图表上的精确季度营收只存在于图像中,正文并未逐一写出。\n"
|
||||
"可用如下问题对比三种范式(原生 / 提取为文本 / 带工具):\n"
|
||||
' "Which quarter had the highest revenue, and what was the exact value?"'
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Demo script showcasing different extraction techniques
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from agent import MultimodalAgent, MultimodalContent
|
||||
from config import ExtractionMode
|
||||
|
||||
|
||||
class _Tee:
|
||||
"""Duplicate stdout writes to a file so --output can save the transcript."""
|
||||
|
||||
def __init__(self, stream, file_handle):
|
||||
self._stream = stream
|
||||
self._file = file_handle
|
||||
|
||||
def write(self, data):
|
||||
self._stream.write(data)
|
||||
self._file.write(data)
|
||||
|
||||
def flush(self):
|
||||
self._stream.flush()
|
||||
self._file.flush()
|
||||
|
||||
|
||||
async def compare_extraction_modes(file_path: str, query: str, model: str = "gemini-3.5-flash"):
|
||||
"""Compare different extraction modes for the same content"""
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"COMPARING EXTRACTION MODES")
|
||||
print(f"File: {file_path}")
|
||||
print(f"Query: {query}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Determine content type
|
||||
path = Path(file_path)
|
||||
suffix = path.suffix.lower()
|
||||
|
||||
if suffix == '.pdf':
|
||||
content_type = "pdf"
|
||||
elif suffix in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']:
|
||||
content_type = "image"
|
||||
elif suffix in ['.mp3', '.wav', '.m4a', '.flac', '.aac', '.ogg']:
|
||||
content_type = "audio"
|
||||
else:
|
||||
print(f"Unsupported file type: {suffix}")
|
||||
return
|
||||
|
||||
# Test with native mode (Gemini)
|
||||
print("\n" + "-"*60)
|
||||
print(f"1. NATIVE MULTIMODAL MODE ({model})")
|
||||
print("-"*60)
|
||||
|
||||
agent_native = MultimodalAgent(
|
||||
model=model,
|
||||
mode=ExtractionMode.NATIVE,
|
||||
enable_tools=False
|
||||
)
|
||||
|
||||
content = MultimodalContent(type=content_type, path=file_path)
|
||||
|
||||
try:
|
||||
result = await agent_native.process_multimodal_content(content, query)
|
||||
print(result)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Test with extract-to-text mode
|
||||
print("\n" + "-"*60)
|
||||
print("2. EXTRACT TO TEXT MODE")
|
||||
print("-"*60)
|
||||
|
||||
agent_extract = MultimodalAgent(
|
||||
model=model,
|
||||
mode=ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=False
|
||||
)
|
||||
|
||||
try:
|
||||
# First extract the content
|
||||
print("Extracting content to text...")
|
||||
extracted = await agent_extract._extract_single_content(content)
|
||||
print("\nExtracted text:")
|
||||
print(extracted)
|
||||
|
||||
# Then answer the query
|
||||
print(f"\nAnswering query with extracted text...")
|
||||
result = await agent_extract._answer_with_context(extracted, query)
|
||||
print(result)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Test with extract-to-text + tools mode
|
||||
print("\n" + "-"*60)
|
||||
print("3. EXTRACT TO TEXT + MULTIMODAL TOOLS")
|
||||
print("-"*60)
|
||||
|
||||
agent_tools = MultimodalAgent(
|
||||
model=model,
|
||||
mode=ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=True
|
||||
)
|
||||
|
||||
try:
|
||||
print("Using extract-to-text with tools enabled for follow-up questions...")
|
||||
|
||||
# Initial processing
|
||||
extracted = await agent_tools._extract_single_content(content)
|
||||
print(f"Extracted {len(extracted)} characters")
|
||||
|
||||
# Simulate a conversation with follow-up
|
||||
async for chunk in agent_tools.chat(query, content, stream=True):
|
||||
print(chunk, end="", flush=True)
|
||||
print()
|
||||
|
||||
# Follow-up question that might use tools
|
||||
if content_type == "image":
|
||||
follow_up = f"What colors are dominant in the image at {file_path}?"
|
||||
elif content_type == "pdf":
|
||||
follow_up = f"What specific data or figures are mentioned in the PDF at {file_path}?"
|
||||
else: # audio
|
||||
follow_up = f"What is the tone or mood of the audio at {file_path}?"
|
||||
|
||||
print(f"\nFollow-up question: {follow_up}")
|
||||
async for chunk in agent_tools.chat(follow_up, None, stream=True):
|
||||
print(chunk, end="", flush=True)
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
async def compare_models(file_path: str, query: str):
|
||||
"""Compare different models for the same task"""
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"COMPARING MODELS")
|
||||
print(f"File: {file_path}")
|
||||
print(f"Query: {query}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Determine content type
|
||||
path = Path(file_path)
|
||||
suffix = path.suffix.lower()
|
||||
|
||||
if suffix == '.pdf':
|
||||
content_type = "pdf"
|
||||
elif suffix in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']:
|
||||
content_type = "image"
|
||||
elif suffix in ['.mp3', '.wav', '.m4a', '.flac', '.aac', '.ogg']:
|
||||
content_type = "audio"
|
||||
else:
|
||||
print(f"Unsupported file type: {suffix}")
|
||||
return
|
||||
|
||||
content = MultimodalContent(type=content_type, path=file_path)
|
||||
|
||||
# Test with different models
|
||||
models = ["gemini-3.5-flash", "gpt-5.6-luna", "doubao-1.6"]
|
||||
|
||||
for model in models:
|
||||
print("\n" + "-"*60)
|
||||
print(f"Model: {model}")
|
||||
print("-"*60)
|
||||
|
||||
try:
|
||||
# Skip if API key not configured
|
||||
from config import Config
|
||||
config = Config()
|
||||
|
||||
if model == "gemini-3.5-flash" and not config.gemini_api_key:
|
||||
print("Skipping: Gemini API key not configured")
|
||||
continue
|
||||
elif model in ["gpt-5.6-luna", "gpt-5"] and not (config.openai_api_key or config.openrouter_api_key):
|
||||
print("Skipping: OpenAI API key not configured")
|
||||
continue
|
||||
elif model == "doubao-1.6" and not config.doubao_api_key:
|
||||
print("Skipping: Doubao API key not configured")
|
||||
continue
|
||||
|
||||
agent = MultimodalAgent(
|
||||
model=model,
|
||||
mode=ExtractionMode.NATIVE if content_type != "audio" or model == "gemini-3.5-flash" else ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=False
|
||||
)
|
||||
|
||||
result = await agent.process_multimodal_content(content, query)
|
||||
print(result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
async def demo_conversation_with_tools():
|
||||
"""Demonstrate a conversation with multimodal tools"""
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print("DEMO: CONVERSATION WITH MULTIMODAL TOOLS")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
agent = MultimodalAgent(
|
||||
model="gemini-3.5-flash",
|
||||
mode=ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=True
|
||||
)
|
||||
|
||||
# Simulate a conversation
|
||||
conversations = [
|
||||
("I need help analyzing some documents. I have PDFs, images, and audio files.", None),
|
||||
("Can you analyze the image at test_files/sample.jpg and tell me what you see?", None),
|
||||
("Now analyze the PDF at test_files/document.pdf and summarize its main points.", None),
|
||||
("What's in the audio file at test_files/recording.mp3?", None),
|
||||
("Based on all these files, what's the common theme?", None)
|
||||
]
|
||||
|
||||
for message, content in conversations:
|
||||
print(f"\nUser: {message}")
|
||||
print("Assistant: ", end="", flush=True)
|
||||
|
||||
try:
|
||||
async for chunk in agent.chat(message, content, stream=True):
|
||||
print(chunk, end="", flush=True)
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
print("(File might not exist - this is a demo)")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""构建实验 4-2 的命令行接口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"实验 4-2:多模态信息提取的三种技术范式对比(原生多模态 / 提取为文本 / 带工具)。\n"
|
||||
"将同一多模态文件和同一问题分别交给三种模式处理,观察表现差异。"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" # 先离线生成含图表的样例(无需 API Key)\n"
|
||||
" python demo.py --generate-sample\n"
|
||||
" # 用生成的图表跑三种范式对比(需要 API Key)\n"
|
||||
" python demo.py --file test_files/sample_chart.png \\\n"
|
||||
' --query \"Which quarter had the highest revenue, and what was the exact value?\"\n'
|
||||
" # 兼容旧写法(位置参数)\n"
|
||||
" python demo.py document.pdf \"总结这份文档的要点\""
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"file", nargs="?", default=None,
|
||||
help="要处理的多模态文件(图像 / PDF 文档 / 音频)。也可用 --file 指定",
|
||||
)
|
||||
parser.add_argument(
|
||||
"query", nargs="?", default=None,
|
||||
help="向该文件提出的问题。也可用 --query 指定",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file", dest="file_opt", default=None,
|
||||
help="要处理的多模态文件(等价于位置参数 file)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query", dest="query_opt", default=None,
|
||||
help="向该文件提出的问题(等价于位置参数 query)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model", default="gemini-3.5-flash",
|
||||
help="原生 / 提取模式使用的模型(默认:gemini-3.5-flash)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-model-comparison", action="store_true",
|
||||
help="只跑三种范式对比,跳过跨模型对比",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generate-sample", action="store_true",
|
||||
help="离线生成含图表的样例文件到 test_files/ 后退出(无需 API Key)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o", default=None,
|
||||
help="将完整对比结果同时写入指定文件(如 result.txt)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def run_comparison(file_path: str, query: str, model: str, skip_model_comparison: bool):
|
||||
"""运行三种范式对比,可选跨模型对比。"""
|
||||
print("="*80)
|
||||
print("MULTIMODAL AGENT DEMO")
|
||||
print("="*80)
|
||||
|
||||
await compare_extraction_modes(file_path, query, model=model)
|
||||
if not skip_model_comparison:
|
||||
await compare_models(file_path, query)
|
||||
|
||||
|
||||
async def main():
|
||||
"""实验入口:解析参数并运行对比。"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# 离线样例生成:不需要 API Key,直接产出图表 + PDF 报告
|
||||
if args.generate_sample:
|
||||
import create_sample
|
||||
sys.argv = ["create_sample.py"] # 用默认输出目录 test_files/
|
||||
create_sample.main()
|
||||
return
|
||||
|
||||
file_path = args.file_opt or args.file
|
||||
query = args.query_opt or args.query
|
||||
|
||||
# 缺少文件或问题时,回退到无需真实文件的对话演示
|
||||
if not file_path or not query:
|
||||
print("="*80)
|
||||
print("MULTIMODAL AGENT DEMO")
|
||||
print("="*80)
|
||||
print("\n未提供 <file> 与 <query>,改为运行对话演示。")
|
||||
print("用法:python demo.py --file <文件> --query <问题>")
|
||||
print("先生成样例:python demo.py --generate-sample\n")
|
||||
await demo_conversation_with_tools()
|
||||
return
|
||||
|
||||
# 支持 --output:把整段对比结果同时落盘
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
original_stdout = sys.stdout
|
||||
sys.stdout = _Tee(original_stdout, fh)
|
||||
try:
|
||||
await run_comparison(file_path, query, args.model, args.skip_model_comparison)
|
||||
finally:
|
||||
sys.stdout = original_stdout
|
||||
print(f"\n完整对比结果已写入:{args.output}")
|
||||
else:
|
||||
await run_comparison(file_path, query, args.model, args.skip_model_comparison)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,24 @@
|
||||
# Multimodal Agent Configuration
|
||||
# Copy this file to .env and fill in your API keys
|
||||
|
||||
# Google Gemini API (GEMINI_API_KEY 亦可,两者都会被读取)
|
||||
GOOGLE_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# OpenAI API (for GPT-4o / GPT-5 vision and Whisper)
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# Doubao API (ByteDance/Volcano Engine; ARK_API_KEY 亦可)
|
||||
DOUBAO_API_KEY=your_doubao_api_key_here
|
||||
|
||||
# Optional: Mistral API for OCR
|
||||
MISTRAL_API_KEY=your_mistral_api_key_here
|
||||
|
||||
# OpenRouter universal fallback (optional): if the selected model's own
|
||||
# provider key (Gemini/OpenAI/Doubao) is missing but OPENROUTER_API_KEY is set,
|
||||
# that model is routed through OpenRouter's OpenAI-compatible endpoint.
|
||||
# Model names are mapped automatically (gemini-3.5-flash -> google/gemini-3.5-flash,
|
||||
# gpt-5.6-luna -> openai/gpt-5.6-luna; vision-capable default openai/gpt-5.6-luna).
|
||||
# Note: audio transcription (Whisper) and native-PDF extraction still need a
|
||||
# direct OpenAI/Gemini key. Set OPENROUTER_MODEL to force a specific model id.
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
# OPENROUTER_MODEL=openai/gpt-5.6-luna
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Main entry point for Multimodal Agent
|
||||
Demonstrates different extraction modes and model capabilities
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from agent import MultimodalAgent, MultimodalContent
|
||||
from config import ExtractionMode, Config
|
||||
|
||||
|
||||
class _Tee:
|
||||
"""将 stdout 同时写入终端与文件,用于 --output。"""
|
||||
|
||||
def __init__(self, stream, file_handle):
|
||||
self._stream = stream
|
||||
self._file = file_handle
|
||||
|
||||
def write(self, data):
|
||||
self._stream.write(data)
|
||||
self._file.write(data)
|
||||
|
||||
def flush(self):
|
||||
self._stream.flush()
|
||||
self._file.flush()
|
||||
|
||||
|
||||
async def process_file(
|
||||
agent: MultimodalAgent,
|
||||
file_path: str,
|
||||
query: Optional[str] = None
|
||||
) -> None:
|
||||
"""Process a single file with the agent"""
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
print(f"Error: File '{file_path}' not found")
|
||||
return
|
||||
|
||||
# Determine content type
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
content_type = "pdf"
|
||||
elif suffix in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']:
|
||||
content_type = "image"
|
||||
elif suffix in ['.mp3', '.wav', '.m4a', '.flac', '.aac', '.ogg']:
|
||||
content_type = "audio"
|
||||
else:
|
||||
print(f"Error: Unsupported file type '{suffix}'")
|
||||
return
|
||||
|
||||
# Create multimodal content
|
||||
content = MultimodalContent(
|
||||
type=content_type,
|
||||
path=file_path
|
||||
)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Processing {content_type.upper()}: {path.name}")
|
||||
print(f"Mode: {agent.extraction_mode.value}")
|
||||
print(f"Model: {agent.current_model}")
|
||||
print(f"Multimodal Tools: {'Enabled' if agent.enable_multimodal_tools else 'Disabled'}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
try:
|
||||
# Process content
|
||||
if agent.extraction_mode == ExtractionMode.NATIVE:
|
||||
# Use native multimodal processing
|
||||
result = await agent.process_multimodal_content(content, query)
|
||||
print("Native Processing Result:")
|
||||
print("-" * 40)
|
||||
print(result)
|
||||
else:
|
||||
# Extract to text mode
|
||||
print("Extracting content to text...")
|
||||
extracted = await agent._extract_single_content(content)
|
||||
print("Extracted Text:")
|
||||
print("-" * 40)
|
||||
print(extracted[:1000] + "..." if len(extracted) > 1000 else extracted)
|
||||
|
||||
if query:
|
||||
print(f"\nAnswering query: {query}")
|
||||
print("-" * 40)
|
||||
answer = await agent._answer_with_context(extracted, query)
|
||||
print(answer)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing file: {e}")
|
||||
|
||||
|
||||
async def interactive_chat(agent: MultimodalAgent) -> None:
|
||||
"""Interactive chat session with the agent"""
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Interactive Multimodal Chat")
|
||||
print(f"Model: {agent.current_model}")
|
||||
print(f"Mode: {agent.extraction_mode.value}")
|
||||
print(f"Multimodal Tools: {'Enabled' if agent.enable_multimodal_tools else 'Disabled'}")
|
||||
print("="*60)
|
||||
print("\nCommands:")
|
||||
print(" /file <path> - Load a multimodal file")
|
||||
print(" /mode <native|extract_to_text> - Switch extraction mode")
|
||||
print(" /model <model_name> - Switch model")
|
||||
print(" /tools <on|off> - Enable/disable multimodal tools")
|
||||
print(" /history - Show conversation history")
|
||||
print(" /clear - Clear conversation history")
|
||||
print(" /quit - Exit")
|
||||
print("\n")
|
||||
|
||||
current_content = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("You: ").strip()
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# Handle commands
|
||||
if user_input.startswith("/"):
|
||||
parts = user_input.split(maxsplit=1)
|
||||
command = parts[0].lower()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if command == "/quit":
|
||||
print("Goodbye!")
|
||||
break
|
||||
|
||||
elif command == "/file":
|
||||
if not args:
|
||||
print("Usage: /file <path>")
|
||||
continue
|
||||
|
||||
path = Path(args)
|
||||
if not path.exists():
|
||||
print(f"File not found: {args}")
|
||||
continue
|
||||
|
||||
# Determine content type
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
content_type = "pdf"
|
||||
elif suffix in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']:
|
||||
content_type = "image"
|
||||
elif suffix in ['.mp3', '.wav', '.m4a', '.flac', '.aac', '.ogg']:
|
||||
content_type = "audio"
|
||||
else:
|
||||
print(f"Unsupported file type: {suffix}")
|
||||
continue
|
||||
|
||||
current_content = MultimodalContent(
|
||||
type=content_type,
|
||||
path=args
|
||||
)
|
||||
|
||||
# Extract content immediately if in extract mode
|
||||
result = await agent.load_and_extract_content(current_content)
|
||||
print(result)
|
||||
|
||||
# In extract mode, content is already extracted, no need to keep it
|
||||
if agent.extraction_mode == ExtractionMode.EXTRACT_TO_TEXT:
|
||||
current_content = None
|
||||
|
||||
elif command == "/mode":
|
||||
if args == "native":
|
||||
agent.extraction_mode = ExtractionMode.NATIVE
|
||||
print("Switched to native multimodal mode")
|
||||
elif args == "extract_to_text":
|
||||
agent.extraction_mode = ExtractionMode.EXTRACT_TO_TEXT
|
||||
print("Switched to extract-to-text mode")
|
||||
else:
|
||||
print("Usage: /mode <native|extract_to_text>")
|
||||
|
||||
elif command == "/model":
|
||||
if args in agent.config.models:
|
||||
agent.current_model = args
|
||||
print(f"Switched to model: {args}")
|
||||
else:
|
||||
print(f"Available models: {', '.join(agent.config.models.keys())}")
|
||||
|
||||
elif command == "/tools":
|
||||
if args == "on":
|
||||
agent.set_multimodal_tools_enabled(True)
|
||||
print("Multimodal tools enabled")
|
||||
elif args == "off":
|
||||
agent.set_multimodal_tools_enabled(False)
|
||||
print("Multimodal tools disabled")
|
||||
else:
|
||||
print("Usage: /tools <on|off>")
|
||||
|
||||
elif command == "/history":
|
||||
history = agent.get_conversation_history()
|
||||
print("\nConversation History:")
|
||||
print("-" * 40)
|
||||
for msg in history:
|
||||
role = msg["role"].upper()
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
preview = content[:200] + "..." if len(content) > 200 else content
|
||||
print(f"{role}: {preview}")
|
||||
print("-" * 40)
|
||||
|
||||
elif command == "/clear":
|
||||
agent.reset_conversation()
|
||||
current_content = None
|
||||
print("Conversation history cleared")
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
|
||||
continue
|
||||
|
||||
# Regular chat message
|
||||
print("\nAssistant: ", end="", flush=True)
|
||||
|
||||
try:
|
||||
async for chunk in agent.chat(user_input, current_content, stream=True):
|
||||
print(chunk, end="", flush=True)
|
||||
print("\n")
|
||||
|
||||
# Clear current content after first use
|
||||
current_content = None
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nInterrupted. Type /quit to exit.")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
continue
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="多模态 Agent:对比原生多模态、提取为文本、带工具三种信息提取范式。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" # 处理图像并提问\n"
|
||||
" python main.py --file test_files/sample_chart.png --query \"图中哪个季度营收最高?\"\n"
|
||||
" # 处理 PDF 文档(提取为文本模式)\n"
|
||||
" python main.py --mode extract_to_text --file report.pdf --query \"总结要点\"\n"
|
||||
" # 进入交互式对话\n"
|
||||
" python main.py --interactive"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--mode", choices=["native", "extract_to_text"], default="native",
|
||||
help="提取模式:native(原生多模态)或 extract_to_text(提取为文本),默认 native")
|
||||
parser.add_argument("--model", default="gemini-3.5-flash",
|
||||
help="使用的模型(默认:gemini-3.5-flash)")
|
||||
parser.add_argument("--tools", action="store_true",
|
||||
help="启用多模态分析工具(analyze_image / analyze_audio / analyze_pdf)")
|
||||
parser.add_argument("--file", help="要处理的单个文件(图像 / PDF 文档 / 音频)")
|
||||
parser.add_argument("--query", help="向该文件提出的问题")
|
||||
parser.add_argument("--output", "-o", help="将处理结果同时写入指定文件")
|
||||
parser.add_argument("--interactive", action="store_true",
|
||||
help="进入交互式对话会话")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate API keys
|
||||
config = Config()
|
||||
api_keys = config.validate_api_keys()
|
||||
|
||||
print("API Key Status:")
|
||||
for provider, has_key in api_keys.items():
|
||||
status = "✓ Configured" if has_key else "✗ Not configured"
|
||||
print(f" {provider.capitalize()}: {status}")
|
||||
|
||||
# Create agent
|
||||
mode = ExtractionMode.NATIVE if args.mode == "native" else ExtractionMode.EXTRACT_TO_TEXT
|
||||
agent = MultimodalAgent(
|
||||
model=args.model,
|
||||
mode=mode,
|
||||
enable_tools=args.tools
|
||||
)
|
||||
|
||||
# Process based on arguments
|
||||
if args.file:
|
||||
if args.output:
|
||||
# 将结果同时写入文件
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
original_stdout = sys.stdout
|
||||
sys.stdout = _Tee(original_stdout, fh)
|
||||
try:
|
||||
await process_file(agent, args.file, args.query)
|
||||
finally:
|
||||
sys.stdout = original_stdout
|
||||
print(f"\n处理结果已写入:{args.output}")
|
||||
else:
|
||||
await process_file(agent, args.file, args.query)
|
||||
elif args.interactive:
|
||||
await interactive_chat(agent)
|
||||
else:
|
||||
# Default to interactive mode
|
||||
await interactive_chat(agent)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Quickstart script for testing multimodal agent
|
||||
Creates sample files and demonstrates capabilities
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
from agent import MultimodalAgent, MultimodalContent
|
||||
from config import ExtractionMode, Config
|
||||
|
||||
|
||||
def create_sample_files():
|
||||
"""Create sample files for testing"""
|
||||
|
||||
# Create test_files directory
|
||||
test_dir = Path("test_files")
|
||||
test_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create a simple text-based "image" (SVG)
|
||||
svg_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="10" width="180" height="180" fill="lightblue" stroke="black" stroke-width="2"/>
|
||||
<circle cx="100" cy="100" r="50" fill="yellow" stroke="orange" stroke-width="3"/>
|
||||
<text x="100" y="105" text-anchor="middle" font-size="20" fill="black">Hello AI!</text>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
svg_path = test_dir / "sample.svg"
|
||||
svg_path.write_text(svg_content, encoding="utf-8")
|
||||
print(f"Created: {svg_path}")
|
||||
|
||||
# Create a simple text file that we'll treat as a "document"
|
||||
doc_content = """
|
||||
# Sample Document for Multimodal Agent Testing
|
||||
|
||||
## Introduction
|
||||
This is a test document created for demonstrating the multimodal agent's capabilities.
|
||||
The agent can process this document in different modes:
|
||||
|
||||
1. **Native Mode**: Direct processing using the model's built-in capabilities
|
||||
2. **Extract to Text**: Convert to text first, then analyze
|
||||
3. **With Tools**: Use specialized tools for detailed analysis
|
||||
|
||||
## Key Features
|
||||
- Support for multiple file formats (PDF, images, audio)
|
||||
- Multiple AI model providers (Gemini, OpenAI, Doubao)
|
||||
- Streaming responses for better user experience
|
||||
- Tool calling for advanced analysis
|
||||
|
||||
## Technical Details
|
||||
The system uses a unified message format compatible with OpenAI's API structure,
|
||||
making it easy to switch between different providers while maintaining consistency.
|
||||
|
||||
## Conclusion
|
||||
This multimodal agent demonstrates state-of-the-art AI capabilities for
|
||||
content understanding and analysis across different modalities.
|
||||
"""
|
||||
|
||||
doc_path = test_dir / "sample_document.txt"
|
||||
doc_path.write_text(doc_content, encoding="utf-8")
|
||||
print(f"Created: {doc_path}")
|
||||
|
||||
return test_dir
|
||||
|
||||
|
||||
async def test_basic_functionality():
|
||||
"""Test basic agent functionality"""
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("QUICKSTART: Testing Multimodal Agent")
|
||||
print("="*60)
|
||||
|
||||
# Check API keys
|
||||
config = Config()
|
||||
api_keys = config.validate_api_keys()
|
||||
|
||||
print("\n1. API Key Status:")
|
||||
print("-" * 40)
|
||||
for provider, has_key in api_keys.items():
|
||||
status = "✅ Configured" if has_key else "❌ Not configured"
|
||||
print(f" {provider.capitalize()}: {status}")
|
||||
|
||||
if not any(api_keys.values()):
|
||||
print("\n⚠️ Warning: No API keys configured!")
|
||||
print("Please copy env.example to .env and add your API keys.")
|
||||
return
|
||||
|
||||
# Create sample files
|
||||
print("\n2. Creating Sample Files:")
|
||||
print("-" * 40)
|
||||
test_dir = create_sample_files()
|
||||
|
||||
# Test with available model
|
||||
if api_keys["gemini"]:
|
||||
model = "gemini-3.5-flash"
|
||||
print(f"\n3. Testing with {model}:")
|
||||
print("-" * 40)
|
||||
|
||||
agent = MultimodalAgent(
|
||||
model=model,
|
||||
mode=ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=False
|
||||
)
|
||||
|
||||
# Process the text document
|
||||
doc_path = test_dir / "sample_document.txt"
|
||||
content = MultimodalContent(
|
||||
type="text",
|
||||
path=str(doc_path),
|
||||
data=doc_path.read_bytes()
|
||||
)
|
||||
|
||||
print("Processing sample document...")
|
||||
try:
|
||||
# Simulate as if it's a PDF for demonstration
|
||||
content.type = "pdf"
|
||||
result = await agent._extract_pdf_to_text(content)
|
||||
print("Extracted content preview:")
|
||||
print(result[:300] + "..." if len(result) > 300 else result)
|
||||
|
||||
# Answer a question
|
||||
print("\nAsking a question about the document...")
|
||||
answer = await agent._answer_with_context(
|
||||
result,
|
||||
"What are the three modes mentioned in the document?"
|
||||
)
|
||||
print("Answer:", answer)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
elif api_keys["openai"]:
|
||||
model = "gpt-5.6-luna"
|
||||
print(f"\n3. Testing with {model}:")
|
||||
print("-" * 40)
|
||||
|
||||
agent = MultimodalAgent(
|
||||
model=model,
|
||||
mode=ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=False
|
||||
)
|
||||
|
||||
print("Note: OpenAI models work best with images.")
|
||||
print("For document processing, Gemini is recommended.")
|
||||
|
||||
else:
|
||||
print("\n3. Skipping tests - no API keys configured")
|
||||
|
||||
|
||||
async def test_conversation_mode():
|
||||
"""Test conversation mode with streaming"""
|
||||
|
||||
config = Config()
|
||||
if not config.gemini_api_key and not config.openai_api_key:
|
||||
print("\nSkipping conversation test - no API keys configured")
|
||||
return
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("4. Testing Conversation Mode")
|
||||
print("="*60)
|
||||
|
||||
# Use available model
|
||||
if config.gemini_api_key:
|
||||
model = "gemini-3.5-flash"
|
||||
else:
|
||||
model = "gpt-5.6-luna"
|
||||
|
||||
agent = MultimodalAgent(
|
||||
model=model,
|
||||
mode=ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=True
|
||||
)
|
||||
|
||||
print(f"Using model: {model}")
|
||||
print("Tools: Enabled")
|
||||
print("\nStarting conversation...")
|
||||
print("-" * 40)
|
||||
|
||||
# Simulate a conversation
|
||||
messages = [
|
||||
"Hello! I'm testing the multimodal agent. Can you explain what you can do?",
|
||||
"What types of files can you process?",
|
||||
"How do the different extraction modes work?"
|
||||
]
|
||||
|
||||
for message in messages:
|
||||
print(f"\nUser: {message}")
|
||||
print("Assistant: ", end="", flush=True)
|
||||
|
||||
try:
|
||||
response_text = ""
|
||||
async for chunk in agent.chat(message, stream=True):
|
||||
print(chunk, end="", flush=True)
|
||||
response_text += chunk
|
||||
print()
|
||||
|
||||
# Small delay for readability
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
break
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all quickstart tests"""
|
||||
|
||||
print("🚀 Multimodal Agent Quickstart")
|
||||
print("=" * 60)
|
||||
|
||||
# Run basic tests
|
||||
await test_basic_functionality()
|
||||
|
||||
# Run conversation test
|
||||
await test_conversation_mode()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✅ Quickstart Complete!")
|
||||
print("="*60)
|
||||
print("\nNext steps:")
|
||||
print("1. Add your API keys to .env file")
|
||||
print("2. Try with your own files: python main.py --file <path> --query <question>")
|
||||
print("3. Start interactive mode: python main.py --interactive")
|
||||
print("4. Run comparisons: python demo.py <file> <query>")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,23 @@
|
||||
# Core dependencies
|
||||
google-genai>=0.1.0 # New Google AI SDK for Gemini
|
||||
openai>=1.50.0
|
||||
httpx>=0.27.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# For async support (asyncio 与 mimetypes 属于 Python 标准库,无需安装)
|
||||
aiofiles>=24.1.0
|
||||
|
||||
# For file handling
|
||||
python-magic>=0.4.27
|
||||
|
||||
# For the offline sample generator (create_sample.py)
|
||||
matplotlib>=3.7.0
|
||||
reportlab>=4.0.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.24.0
|
||||
pytest-mock>=3.14.0
|
||||
|
||||
# Optional: For Mistral OCR
|
||||
mistralai>=1.2.0
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regression tests: _execute_tool must return an error string to the model
|
||||
instead of raising KeyError/JSONDecodeError on malformed LLM tool-call
|
||||
arguments (missing required fields or truncated streamed JSON)."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from agent import MultimodalAgent
|
||||
|
||||
|
||||
def _make_agent():
|
||||
"""Build a MultimodalAgent without __init__ (which needs API keys)."""
|
||||
agent = MultimodalAgent.__new__(MultimodalAgent)
|
||||
calls = []
|
||||
|
||||
async def fake_analyze(path, query):
|
||||
calls.append((path, query))
|
||||
return "analysis ok"
|
||||
|
||||
agent.tools = types.SimpleNamespace(
|
||||
analyze_image=fake_analyze,
|
||||
analyze_audio=fake_analyze,
|
||||
analyze_pdf=fake_analyze,
|
||||
)
|
||||
return agent, calls
|
||||
|
||||
|
||||
def _run(agent, name, arguments):
|
||||
tool_call = {"id": "call_1", "function": {"name": name, "arguments": arguments}}
|
||||
return asyncio.run(agent._execute_tool(tool_call))
|
||||
|
||||
|
||||
def test_missing_query_returns_error_not_keyerror():
|
||||
agent, calls = _make_agent()
|
||||
result = _run(agent, "analyze_image", json.dumps({"image_path": "cat.png"}))
|
||||
assert result.startswith("Error:")
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_missing_path_returns_error_not_keyerror():
|
||||
agent, calls = _make_agent()
|
||||
result = _run(agent, "analyze_pdf", json.dumps({"query": "what is this?"}))
|
||||
assert result.startswith("Error:")
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_malformed_json_returns_error_not_exception():
|
||||
agent, calls = _make_agent()
|
||||
result = _run(agent, "analyze_audio", '{"audio_path": "a.mp3", "que')
|
||||
assert result.startswith("Error:")
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_valid_arguments_still_call_tool():
|
||||
agent, calls = _make_agent()
|
||||
result = _run(agent, "analyze_image", json.dumps({"image_path": "cat.png", "query": "describe"}))
|
||||
assert result == "analysis ok"
|
||||
assert calls == [("cat.png", "describe")]
|
||||
|
||||
|
||||
def test_unknown_tool_still_reported():
|
||||
agent, _ = _make_agent()
|
||||
result = _run(agent, "analyze_video", json.dumps({}))
|
||||
assert "Unknown tool" in result
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Regression tests for the interactive multimodal tools toggle."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from agent import MultimodalAgent, MultimodalTools
|
||||
from main import interactive_chat
|
||||
|
||||
|
||||
def _run_commands(agent, *commands):
|
||||
with patch("builtins.input", side_effect=commands):
|
||||
asyncio.run(interactive_chat(agent))
|
||||
|
||||
|
||||
def test_tools_on_configures_complete_tool_state():
|
||||
agent = MultimodalAgent(enable_tools=False)
|
||||
|
||||
_run_commands(agent, "/tools on", "/quit")
|
||||
|
||||
assert agent.enable_multimodal_tools is True
|
||||
assert isinstance(agent.tools, MultimodalTools)
|
||||
assert {
|
||||
definition["function"]["name"] for definition in agent.tool_definitions
|
||||
} == {"analyze_image", "analyze_audio", "analyze_pdf"}
|
||||
|
||||
|
||||
def test_tools_off_disables_tool_execution():
|
||||
agent = MultimodalAgent(enable_tools=True)
|
||||
|
||||
_run_commands(agent, "/tools off", "/quit")
|
||||
|
||||
assert agent.enable_multimodal_tools is False
|
||||
assert agent.tools is None
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Test script for multimodal agent functionality
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
from pathlib import Path
|
||||
|
||||
from agent import MultimodalAgent, MultimodalContent, MultimodalTools, Message
|
||||
from config import ExtractionMode, Provider
|
||||
|
||||
|
||||
class TestMultimodalContent(unittest.TestCase):
|
||||
"""Test MultimodalContent class"""
|
||||
|
||||
def test_content_creation(self):
|
||||
"""Test creating multimodal content"""
|
||||
content = MultimodalContent(
|
||||
type="image",
|
||||
path="test.jpg",
|
||||
mime_type="image/jpeg"
|
||||
)
|
||||
|
||||
self.assertEqual(content.type, "image")
|
||||
self.assertEqual(content.path, "test.jpg")
|
||||
self.assertEqual(content.mime_type, "image/jpeg")
|
||||
|
||||
def test_get_base64(self):
|
||||
"""Test base64 encoding"""
|
||||
content = MultimodalContent(
|
||||
type="text",
|
||||
data=b"Hello World"
|
||||
)
|
||||
|
||||
base64_str = content.get_base64()
|
||||
self.assertEqual(base64_str, "SGVsbG8gV29ybGQ=")
|
||||
|
||||
|
||||
class TestMessage(unittest.TestCase):
|
||||
"""Test Message class"""
|
||||
|
||||
def test_message_creation(self):
|
||||
"""Test creating messages"""
|
||||
msg = Message(
|
||||
role="user",
|
||||
content="Hello"
|
||||
)
|
||||
|
||||
self.assertEqual(msg.role, "user")
|
||||
self.assertEqual(msg.content, "Hello")
|
||||
|
||||
def test_message_to_dict(self):
|
||||
"""Test converting message to dictionary"""
|
||||
msg = Message(
|
||||
role="assistant",
|
||||
content="Hi there",
|
||||
tool_calls=[{"id": "1", "function": {"name": "test"}}]
|
||||
)
|
||||
|
||||
msg_dict = msg.to_dict()
|
||||
self.assertEqual(msg_dict["role"], "assistant")
|
||||
self.assertEqual(msg_dict["content"], "Hi there")
|
||||
self.assertIn("tool_calls", msg_dict)
|
||||
|
||||
|
||||
class TestMultimodalAgent(unittest.IsolatedAsyncioTestCase):
|
||||
"""Test MultimodalAgent class"""
|
||||
|
||||
async def test_agent_initialization(self):
|
||||
"""Test agent initialization"""
|
||||
agent = MultimodalAgent(
|
||||
model="gemini-3.5-flash",
|
||||
mode=ExtractionMode.NATIVE,
|
||||
enable_tools=False
|
||||
)
|
||||
|
||||
self.assertEqual(agent.current_model, "gemini-3.5-flash")
|
||||
self.assertEqual(agent.extraction_mode, ExtractionMode.NATIVE)
|
||||
self.assertFalse(agent.enable_multimodal_tools)
|
||||
self.assertIsNone(agent.tools)
|
||||
|
||||
async def test_agent_with_tools(self):
|
||||
"""Test agent initialization with tools"""
|
||||
agent = MultimodalAgent(
|
||||
model="gemini-3.5-flash",
|
||||
mode=ExtractionMode.EXTRACT_TO_TEXT,
|
||||
enable_tools=True
|
||||
)
|
||||
|
||||
self.assertTrue(agent.enable_multimodal_tools)
|
||||
self.assertIsNotNone(agent.tools)
|
||||
self.assertEqual(len(agent.tool_definitions), 3)
|
||||
|
||||
async def test_conversation_history(self):
|
||||
"""Test conversation history management"""
|
||||
agent = MultimodalAgent()
|
||||
|
||||
# Add messages
|
||||
agent.add_message(Message(role="user", content="Hello"))
|
||||
agent.add_message(Message(role="assistant", content="Hi"))
|
||||
|
||||
history = agent.get_conversation_history()
|
||||
self.assertEqual(len(history), 2)
|
||||
self.assertEqual(history[0]["role"], "user")
|
||||
self.assertEqual(history[1]["role"], "assistant")
|
||||
|
||||
# Reset conversation
|
||||
agent.reset_conversation()
|
||||
history = agent.get_conversation_history()
|
||||
self.assertEqual(len(history), 0)
|
||||
|
||||
@patch('agent.genai.Client')
|
||||
async def test_extract_pdf_to_text(self, mock_client_class):
|
||||
"""Test PDF extraction to text"""
|
||||
agent = MultimodalAgent(mode=ExtractionMode.EXTRACT_TO_TEXT)
|
||||
|
||||
# Mock Gemini response
|
||||
mock_response = Mock()
|
||||
mock_response.candidates = []
|
||||
mock_response.text = "Extracted PDF text"
|
||||
mock_client = Mock()
|
||||
mock_client.models.generate_content.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
content = MultimodalContent(
|
||||
type="pdf",
|
||||
data=b"PDF content"
|
||||
)
|
||||
|
||||
result = await agent._extract_pdf_to_text(content)
|
||||
self.assertEqual(result, "Extracted PDF text")
|
||||
|
||||
@patch('agent.AsyncOpenAI')
|
||||
async def test_extract_image_to_text(self, mock_openai_class):
|
||||
"""Test image extraction to text"""
|
||||
agent = MultimodalAgent(mode=ExtractionMode.EXTRACT_TO_TEXT)
|
||||
|
||||
# Mock OpenAI response
|
||||
mock_client = AsyncMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_choice = Mock()
|
||||
mock_message = Mock()
|
||||
mock_message.content = "Image description"
|
||||
mock_choice.message = mock_message
|
||||
mock_response.choices = [mock_choice]
|
||||
mock_client.chat.completions.create.return_value = mock_response
|
||||
mock_openai_class.return_value = mock_client
|
||||
|
||||
content = MultimodalContent(
|
||||
type="image",
|
||||
data=b"Image data",
|
||||
mime_type="image/jpeg"
|
||||
)
|
||||
|
||||
result = await agent._extract_image_to_text(content)
|
||||
self.assertEqual(result, "Image description")
|
||||
|
||||
@patch('agent.genai.Client')
|
||||
async def test_process_native_gemini(self, mock_client_class):
|
||||
"""Test native Gemini processing"""
|
||||
agent = MultimodalAgent(
|
||||
model="gemini-3.5-flash",
|
||||
mode=ExtractionMode.NATIVE
|
||||
)
|
||||
|
||||
# Mock Gemini response
|
||||
mock_response = Mock()
|
||||
mock_response.candidates = []
|
||||
mock_response.text = "Gemini analysis result"
|
||||
mock_client = Mock()
|
||||
mock_client.models.generate_content.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
content = MultimodalContent(
|
||||
type="pdf",
|
||||
data=b"PDF content"
|
||||
)
|
||||
|
||||
result = await agent._process_native_gemini(content, "Analyze this")
|
||||
self.assertEqual(result, "Gemini analysis result")
|
||||
|
||||
|
||||
class TestMultimodalTools(unittest.IsolatedAsyncioTestCase):
|
||||
"""Test MultimodalTools class"""
|
||||
|
||||
async def test_tools_initialization(self):
|
||||
"""Test tools initialization"""
|
||||
agent = MultimodalAgent(enable_tools=True)
|
||||
tools = MultimodalTools(agent)
|
||||
|
||||
self.assertEqual(tools.agent, agent)
|
||||
|
||||
@patch('agent.AsyncOpenAI')
|
||||
async def test_analyze_image_tool(self, mock_openai_class):
|
||||
"""Test image analysis tool"""
|
||||
agent = MultimodalAgent(enable_tools=True)
|
||||
|
||||
# Mock OpenAI response
|
||||
mock_client = AsyncMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_choice = Mock()
|
||||
mock_message = Mock()
|
||||
mock_message.content = "Image analysis"
|
||||
mock_choice.message = mock_message
|
||||
mock_response.choices = [mock_choice]
|
||||
mock_client.chat.completions.create.return_value = mock_response
|
||||
mock_openai_class.return_value = mock_client
|
||||
|
||||
# Create temporary test image
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||
tmp.write(b"test image data")
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
result = await agent.tools.analyze_image(tmp_path, "What's in this image?")
|
||||
self.assertEqual(result, "Image analysis")
|
||||
finally:
|
||||
Path(tmp_path).unlink()
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests"""
|
||||
unittest.main(argv=[''], exit=False, verbosity=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_tests()
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"purpose": "external-answer-judge",
|
||||
"provider": "moonshot",
|
||||
"endpoint": "https://api.moonshot.cn/v1",
|
||||
"started_at": "2026-07-29T18:54:20.300357+00:00",
|
||||
"latency_ms": 13009.478,
|
||||
"request": {
|
||||
"model": "moonshot-v1-8k",
|
||||
"seed": 37,
|
||||
"temperature": 0,
|
||||
"response_format": {
|
||||
"type": "json_object"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Independently judge chart QA answers. Return JSON {items:[{id,correct,score,reason}]}. Score 1 only if every requested quarter/value/difference matches the reference; otherwise 0."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[{\"id\": \"png:native:highest\", \"question\": \"Which quarter had the highest revenue, and what was the exact value?\", \"reference\": \"Q4, $180M\", \"answer\": \"The highest revenue was in Q4, with an exact value of $180M. Visual evidence: The Q4 bar is the tallest in the chart, explicitly labeled \\\"$180M\\\" and exceeding the revenue of Q1 ($120M), Q2 ($150M), and Q3 ($95M).\\n\\n**Answer:** Q4 had the highest revenue at $180M.\"}, {\"id\": \"png:extract:highest\", \"question\": \"Which quarter had the highest revenue, and what was the exact value?\", \"reference\": \"Q4, $180M\", \"answer\": \"Q1 had the highest revenue, with an exact value of $180M.\"}, {\"id\": \"png:tool:highest\", \"question\": \"Which quarter had the highest revenue, and what was the exact value?\", \"reference\": \"Q4, $180M\", \"answer\": \"The highest revenue was in Q4 with an exact value of $180M.\"}, {\"id\": \"png:native:lowest_gap\", \"question\": \"Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?\", \"reference\": \"Q3, $95M; Q4 exceeded it by $85M\", \"answer\": \"- **Lowest revenue quarter**: Q3 \\n- **Value**: $95M \\n- **Q4 exceedance**: Q4 revenue ($180M) minus Q3 revenue ($95M) = $85M \\n\\n\\n**Visual evidence**: The red bar for Q3 is the shortest, labeled \\\"$95M,\\\" confirming it as the lowest. Q4’s purple bar is labeled \\\"$180M,\\\" the tallest. The vertical difference between Q4 and Q3 bars corresponds to $85M.\"}, {\"id\": \"png:extract:lowest_gap\", \"question\": \"Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?\", \"reference\": \"Q3, $95M; Q4 exceeded it by $85M\", \"answer\": \"The lowest revenue was in Q1 with $95M, and Q4 exceeded it by $85M.\"}, {\"id\": \"png:tool:lowest_gap\", \"question\": \"Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?\", \"reference\": \"Q3, $95M; Q4 exceeded it by $85M\", \"answer\": \"The lowest revenue occurred in Q3, with a value of $95M. Q4's revenue was $180M, exceeding Q3 by $85M ($180M - $95M). \\n\\n**Answers:** \\n- Lowest revenue quarter: Q3 \\n- Value: $95M \\n- Q4 exceeded it by: $85M\"}, {\"id\": \"pdf:native:highest\", \"question\": \"Which quarter had the highest revenue, and what was the exact value?\", \"reference\": \"Q4, $180M\", \"answer\": \"The fourth quarter (Q4) had the highest revenue, with an exact value of $180M. \\n\\n**Visual evidence**: The Q4 bar in the \\\"Acme Corp Quarterly Revenue 2024\\\" bar graph is the tallest, explicitly labeled \\\"$180M,\\\" exceeding Q1 ($120M), Q2 ($150M), and Q3 ($95M).\"}, {\"id\": \"pdf:extract:highest\", \"question\": \"Which quarter had the highest revenue, and what was the exact value?\", \"reference\": \"Q4, $180M\", \"answer\": \"The fourth quarter had the highest revenue, but the exact value is unavailable.\"}, {\"id\": \"pdf:tool:highest\", \"question\": \"Which quarter had the highest revenue, and what was the exact value?\", \"reference\": \"Q4, $180M\", \"answer\": \"The highest revenue in 2024 was in the fourth quarter (Q4), with an exact value of $180M. This is supported by the visual bar chart labeled \\\"Acme Corp Quarterly Revenue 2024,\\\" which shows Q4 as the tallest bar explicitly marked with \\\"$180M,\\\" exceeding Q1 ($120M), Q2 ($150M), and Q3 ($95M). The report also notes a \\\"strong recovery in the final quarter\\\" attributed to the launch of the new enterprise product line, confirming Q4's surge.\"}, {\"id\": \"pdf:native:lowest_gap\", \"question\": \"Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?\", \"reference\": \"Q3, $95M; Q4 exceeded it by $85M\", \"answer\": \"- **Lowest revenue quarter**: Q3 \\n- **Value**: $95M \\n- **Q4 exceedance**: Q4 revenue ($180M) minus Q3 revenue ($95M) = $85M \\n\\n\\n**Visual evidence**: The bar graph shows Q3 with the shortest bar, labeled $95M. Q4’s bar is the tallest, labeled $180M. The vertical gap between Q3 and Q4 bars corresponds to $85M.\"}, {\"id\": \"pdf:extract:lowest_gap\", \"question\": \"Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?\", \"reference\": \"Q3, $95M; Q4 exceeded it by $85M\", \"answer\": \"unavailable\"}, {\"id\": \"pdf:tool:lowest_gap\", \"question\": \"Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?\", \"reference\": \"Q3, $95M; Q4 exceeded it by $85M\", \"answer\": \"The lowest revenue quarter was Q3, with a value of $95M. Q4 revenue was $180M, exceeding Q3 by $85M. \\n\\n**Details**: The visual inspection confirms Q3 (red bar) as the lowest with $95M, while Q4 (purple bar) is the highest at $180M. The difference is calculated as $180M - $95M = $85M.\"}]"
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a4c5c0efb1450ac9b2119",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "{\"items\":[{\"id\": \"png:native:highest\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer.\"}, {\"id\": \"png:extract:highest\", \"correct\": false, \"score\": 0, \"reason\": \"The answer incorrectly states Q1 as the quarter with the highest revenue instead of Q4.\"}, {\"id\": \"png:tool:highest\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer.\"}, {\"id\": \"png:native:lowest_gap\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer.\"}, {\"id\": \"png:extract:lowest_gap\", \"correct\": false, \"score\": 0, \"reason\": \"The answer incorrectly states Q1 as the quarter with the lowest revenue instead of Q3.\"}, {\"id\": \"png:tool:lowest_gap\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer.\"}, {\"id\": \"pdf:native:highest\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer.\"}, {\"id\": \"pdf:extract:highest\", \"correct\": false, \"score\": 0, \"reason\": \"The answer states that the exact value is unavailable, which contradicts the reference answer that provides the exact value of $180M.\"}, {\"id\": \"pdf:tool:highest\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer and providing additional context.\"}, {\"id\": \"pdf:native:lowest_gap\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer.\"}, {\"id\": \"pdf:extract:lowest_gap\", \"correct\": false, \"score\": 0, \"reason\": \"The answer states that the information is unavailable, which contradicts the reference answer that provides the exact values and the difference.\"}, {\"id\": \"pdf:tool:lowest_gap\", \"correct\": true, \"score\": 1, \"reason\": \"The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer.\"}]}",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785351261,
|
||||
"model": "moonshot-v1-8k",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 781,
|
||||
"prompt_tokens": 1583,
|
||||
"total_tokens": 2364,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"completion_tokens": 781,
|
||||
"prompt_tokens": 1583,
|
||||
"total_tokens": 2364,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
},
|
||||
"response_model": "moonshot-v1-8k",
|
||||
"response_id": "chatcmpl-6a6a4c5c0efb1450ac9b2119"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"schema_version": "chapter3-evidence-v1",
|
||||
"experiment": "4-2",
|
||||
"run_id": "20260729T185433Z-4_2-e028c9db",
|
||||
"created_at": "2026-07-29T18:54:33.367744+00:00",
|
||||
"status": "passed",
|
||||
"run_dir": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/validation/runs/20260729T185433Z-4_2-e028c9db",
|
||||
"artifacts": {
|
||||
"evidence.json": "024358e7b58266eedbedda3cc880a61462c119a89c58e475f4c007dbcb8ff144",
|
||||
"receipts.json": "2aa961a1f7527417dd0d25ac6e542aaad9dc2ffaa7b40a4a450cb5fffe86ceac",
|
||||
"manifest.json": "1a9cc7bfd48717e73a03ebbde7fd786c7da2811a15267715a3794c0f1220362e"
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/campaign.py",
|
||||
"sha256": "6f8162a0e124cef1acada4fbfadc6a1427aa487be24670aaceba2d668820ee3c",
|
||||
"bytes": 16104
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/create_sample.py",
|
||||
"sha256": "1f4fcd0e119a76a74c06744a585d31bc4bd4f755f813f8b2518f77127eaa1e72",
|
||||
"bytes": 4846
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_chart.png",
|
||||
"sha256": "eaf8ac53fbc65a4e6ce0611d441cd12ddd2ed48ef8a727a9d6f22aef70605f39",
|
||||
"bytes": 38417
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_report.pdf",
|
||||
"sha256": "db4828a20a12ad70dab5c196d23e4fa407f75c4e3cff2bda22c401de65cb9c2e",
|
||||
"bytes": 46368
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"native-multimodal": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 1.0,
|
||||
"judge_accuracy": 1.0,
|
||||
"mean_latency_ms": 8011.01425
|
||||
},
|
||||
"extract-to-text": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 0.0,
|
||||
"judge_accuracy": 0.0,
|
||||
"mean_latency_ms": 10867.427
|
||||
},
|
||||
"tool-on-demand": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 1.0,
|
||||
"judge_accuracy": 1.0,
|
||||
"mean_latency_ms": 29794.60875
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"same_two_questions_all_paradigms_and_artifacts": true,
|
||||
"png_and_pdf_used": true,
|
||||
"chart_answers_absent_from_pdf_body_text": true,
|
||||
"real_native_vision_calls": true,
|
||||
"tool_selected_on_demand": true,
|
||||
"real_tool_vision_calls": true,
|
||||
"external_moonshot_judge": true,
|
||||
"all_calls_checkpointed": true
|
||||
}
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"schema_version": "chapter3-evidence-v1",
|
||||
"experiment": "4-2",
|
||||
"run_id": "20260729T185433Z-4_2-e028c9db",
|
||||
"provenance": {
|
||||
"captured_at": "2026-07-29T18:54:33.359939+00:00",
|
||||
"git_revision": "4a7f37cf278bd15948c409f14533017c4c7fbc29",
|
||||
"python": "3.11.4 (main, Jul 5 2023, 08:40:20) [Clang 14.0.6 ]",
|
||||
"platform": "macOS-26.3-arm64-arm-64bit",
|
||||
"credential_presence": {
|
||||
"ARK_API_KEY": true,
|
||||
"MOONSHOT_API_KEY": true,
|
||||
"OPENAI_API_KEY": true,
|
||||
"GEMINI_API_KEY": true,
|
||||
"SILICONFLOW_API_KEY": true
|
||||
}
|
||||
},
|
||||
"status": "passed",
|
||||
"providers": {
|
||||
"vision_answerer": {
|
||||
"provider": "Volcengine Ark",
|
||||
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"model": "doubao-seed-1-6-250615",
|
||||
"seed": 37
|
||||
},
|
||||
"judge": {
|
||||
"provider": "Moonshot",
|
||||
"endpoint": "https://api.moonshot.cn/v1",
|
||||
"model": "moonshot-v1-8k",
|
||||
"seed": 37
|
||||
}
|
||||
},
|
||||
"local_tools": {
|
||||
"tesseract": "tesseract 5.5.2",
|
||||
"pdftotext": "pdftotext version 25.06.0",
|
||||
"pdftoppm": "pdftoppm version 25.06.0"
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"kind": "png",
|
||||
"source_path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_chart.png",
|
||||
"source_sha256": "eaf8ac53fbc65a4e6ce0611d441cd12ddd2ed48ef8a727a9d6f22aef70605f39",
|
||||
"visual_input": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_chart.png",
|
||||
"visual_sha256": "eaf8ac53fbc65a4e6ce0611d441cd12ddd2ed48ef8a727a9d6f22aef70605f39",
|
||||
"extracted_text": "Acme Corp Quarterly Revenue 2024\n200\n$180M\n175\n$150M\n150\nB 5 $120M\n&\n8 100 $95M\nG\n>\n2p\n50\n25\n0\nQl Q2 Q3 a4",
|
||||
"extraction": {
|
||||
"command": [
|
||||
"tesseract",
|
||||
"/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_chart.png",
|
||||
"stdout",
|
||||
"--psm",
|
||||
"6"
|
||||
],
|
||||
"stderr": "",
|
||||
"latency_ms": 203.405
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "pdf",
|
||||
"source_path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_report.pdf",
|
||||
"source_sha256": "db4828a20a12ad70dab5c196d23e4fa407f75c4e3cff2bda22c401de65cb9c2e",
|
||||
"visual_input": "/var/folders/0l/vk1w1b5n2fxfwdlz3f_w25_w0000gp/T/tmpqt5feern/sample_report_page.png",
|
||||
"visual_sha256": "5688daad3a127e783fbb42e71d1d00bd8077d36a1ccf53e19f323d59b4e1d1e5",
|
||||
"extracted_text": "Acme Corp 2024 Revenue Report\n\nThis internal report summarizes Acme Corp's revenue performance in 2024. Overall the year\nshowed healthy growth, with a mid-year dip followed by a strong recovery in the final quarter. The\nchart below breaks down revenue by quarter; management attributes the fourth-quarter surge to the\nlaunch of the new enterprise product line.",
|
||||
"extraction": {
|
||||
"command": [
|
||||
"pdftotext",
|
||||
"-layout",
|
||||
"/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_report.pdf",
|
||||
"-"
|
||||
],
|
||||
"stderr": "",
|
||||
"latency_ms": 20.134
|
||||
}
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"required_patterns": [
|
||||
"\\bQ4\\b",
|
||||
"(?:\\$\\s*)?180\\s*M"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"required_patterns": [
|
||||
"\\bQ3\\b",
|
||||
"(?:\\$\\s*)?95\\s*M",
|
||||
"(?:\\$\\s*)?85\\s*M"
|
||||
]
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"artifact": "png",
|
||||
"question_id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"id": "png:native:highest",
|
||||
"paradigm": "native-multimodal",
|
||||
"answer": "The highest revenue was in Q4, with an exact value of $180M. Visual evidence: The Q4 bar is the tallest in the chart, explicitly labeled \"$180M\" and exceeding the revenue of Q1 ($120M), Q2 ($150M), and Q3 ($95M).\n\n**Answer:** Q4 had the highest revenue at $180M.",
|
||||
"latency_ms": 8094.933,
|
||||
"exact_correct": true,
|
||||
"external_judge": {
|
||||
"id": "png:native:highest",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "png",
|
||||
"question_id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"id": "png:extract:highest",
|
||||
"paradigm": "extract-to-text",
|
||||
"answer": "Q1 had the highest revenue, with an exact value of $180M.",
|
||||
"latency_ms": 7104.31,
|
||||
"exact_correct": false,
|
||||
"external_judge": {
|
||||
"id": "png:extract:highest",
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"reason": "The answer incorrectly states Q1 as the quarter with the highest revenue instead of Q4."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "png",
|
||||
"question_id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"id": "png:tool:highest",
|
||||
"paradigm": "tool-on-demand",
|
||||
"answer": "The highest revenue was in Q4 with an exact value of $180M.",
|
||||
"latency_ms": 31752.022,
|
||||
"exact_correct": true,
|
||||
"tool_trace": {
|
||||
"tool_selected": true,
|
||||
"decision": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_ep2ovjm4i87gakzdym8c7scs",
|
||||
"function": {
|
||||
"arguments": "{\"question\":\"What is the revenue for each quarter (Q1, Q2, Q3, Q4) in the Acme Corp Quarterly Revenue 2024 chart, and which quarter has the highest revenue with its exact value?\"}",
|
||||
"name": "inspect_visual"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"reasoning_content": "我现在需要解决的问题是:“Which quarter had the highest revenue, and what was the exact value?” 也就是哪个季度的收入最高,具体数值是多少。首先,我得仔细看看用户提供的提取文本,看看有没有直接的信息。\n\n提取的文本是“Acme Corp Quarterly Revenue 2024”,然后下面有一些数字和符号,看起来像是图表的数据。具体的数字有200、180M、175、150M、150、B 5 $120M、&、8 100 $95M、G、>、2p、50、25、0,还有Ql Q2 Q3 a4。这里的Ql应该是Q1,a4可能是Q4的笔误。不过这些数字看起来比较混乱,可能是OCR识别错误或者格式问题。\n\n首先,我需要确定每个季度对应的收入数值。文本中出现了几个带有“M”的数值,比如$180M、$150M、$120M、$95M,这些可能是百万美元的收入。但问题在于这些数值和季度的对应关系不明确。比如,Q1、Q2、Q3、Q4分别对应哪个数值呢?\n\n提取的文本里有“Ql Q2 Q3 a4”,可能是Q1到Q4的顺序。但前面的数字排列看起来没有明显的顺序。比如200、175、150、100、50、25、0这些可能是纵轴的刻度,但具体对应哪个季度的数值呢?比如$180M可能对应某个季度,但文本中没有明确说明每个季度的具体数值。\n\n例如,文本中有“$180M”,但它旁边的数字是200和175,可能200是刻度,而180M是实际数值?或者175对应的是$150M?这里的空间关联不明确,因为文本提取可能没有正确捕捉到图表中的位置关系。比如,在柱状图或折线图中,每个季度的柱子或点会对应一个数值,但文本提取可能把这些数值和刻度混在一起了。\n\n因此,根据问题要求,当文本中没有明确建立精确的图表数值或空间关联时,需要调用inspect_visual工具。这里显然各个季度的收入数值没有明确对应,比如Q1的收入是多少,Q2是多少等,文本中的数字排列混乱,无法直接确定。所以必须通过视觉检查来确认每个季度对应的具体收入数值,从而找出最高的那个季度及其数值。\n\n所以,我需要调用inspect_visual工具,询问每个季度的收入数值,特别是最高的那个。具体的问题应该是:“What is the revenue for each quarter (Q1, Q2, Q3, Q4) in the Acme Corp Quarterly Revenue 2024 chart, and which quarter has the highest revenue with its exact value?” 这样就能明确获取所需的信息了。"
|
||||
},
|
||||
"executions": [
|
||||
{
|
||||
"tool_call_id": "call_ep2ovjm4i87gakzdym8c7scs",
|
||||
"name": "inspect_visual",
|
||||
"arguments": {
|
||||
"question": "What is the revenue for each quarter (Q1, Q2, Q3, Q4) in the Acme Corp Quarterly Revenue 2024 chart, and which quarter has the highest revenue with its exact value?"
|
||||
},
|
||||
"result": "**Quarterly Revenues:** \n- Q1: $120M (blue bar, labeled directly on the bar) \n- Q2: $150M (green bar, labeled directly on the bar) \n- Q3: $95M (red bar, labeled directly on the bar) \n- Q4: $180M (purple bar, labeled directly on the bar) \n\n\n**Highest Revenue:** Q4 with $180M (the tallest bar, explicitly labeled $180M)."
|
||||
}
|
||||
]
|
||||
},
|
||||
"external_judge": {
|
||||
"id": "png:tool:highest",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "png",
|
||||
"question_id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"id": "png:native:lowest_gap",
|
||||
"paradigm": "native-multimodal",
|
||||
"answer": "- **Lowest revenue quarter**: Q3 \n- **Value**: $95M \n- **Q4 exceedance**: Q4 revenue ($180M) minus Q3 revenue ($95M) = $85M \n\n\n**Visual evidence**: The red bar for Q3 is the shortest, labeled \"$95M,\" confirming it as the lowest. Q4’s purple bar is labeled \"$180M,\" the tallest. The vertical difference between Q4 and Q3 bars corresponds to $85M.",
|
||||
"latency_ms": 10174.016,
|
||||
"exact_correct": true,
|
||||
"external_judge": {
|
||||
"id": "png:native:lowest_gap",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "png",
|
||||
"question_id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"id": "png:extract:lowest_gap",
|
||||
"paradigm": "extract-to-text",
|
||||
"answer": "The lowest revenue was in Q1 with $95M, and Q4 exceeded it by $85M.",
|
||||
"latency_ms": 29615.343,
|
||||
"exact_correct": false,
|
||||
"external_judge": {
|
||||
"id": "png:extract:lowest_gap",
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"reason": "The answer incorrectly states Q1 as the quarter with the lowest revenue instead of Q3."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "png",
|
||||
"question_id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"id": "png:tool:lowest_gap",
|
||||
"paradigm": "tool-on-demand",
|
||||
"answer": "The lowest revenue occurred in Q3, with a value of $95M. Q4's revenue was $180M, exceeding Q3 by $85M ($180M - $95M). \n\n**Answers:** \n- Lowest revenue quarter: Q3 \n- Value: $95M \n- Q4 exceeded it by: $85M",
|
||||
"latency_ms": 35775.984,
|
||||
"exact_correct": true,
|
||||
"tool_trace": {
|
||||
"tool_selected": true,
|
||||
"decision": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_d6od6lf1thf1cpwv1v6vjzje",
|
||||
"function": {
|
||||
"arguments": "{\"question\":\"What are the revenue values for Q1, Q2, Q3, and Q4 of Acme Corp in 2024? Which quarter has the lowest revenue, what is its value, and what is Q4's revenue?\"}",
|
||||
"name": "inspect_visual"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"reasoning_content": "我现在需要解决的问题是:哪个季度的收入最低,其数值是多少,以及第四季度(Q4)比它高出多少?首先,我得仔细看看提供的提取文本,看看有没有直接的信息。\n\n提取的文本看起来有点混乱,可能是OCR识别的结果不太准确。首先,标题是“Acme Corp Quarterly Revenue 2024”,然后有一些数字和符号。比如“200”、“$180M”、“175”、“$150M”、“150”、“B 5 $120M”、“&”、“8 100 $95M”、“G”、“>”、“2p”、“50”、“25”、“0”,最后是“Ql Q2 Q3 a4”。这里的“Ql”应该是Q1,“a4”可能是Q4的笔误。\n\n首先,我需要确定每个季度对应的收入数值。通常季度是Q1到Q4,所以文本中的Ql、Q2、Q3、a4应该对应这四个季度。但问题在于,这些数字和符号如何与季度对应起来?比如,“$180M”、“$150M”、“$120M”、“$95M”看起来像是可能的收入数值,单位是百万美元。但这些数值是如何分配到各个季度的呢?\n\n提取的文本中没有明确说明每个季度对应的具体数值,只是列出了一些数字和符号。例如,“Ql Q2 Q3 a4”可能是在图表的底部,作为x轴的标签,而上面的数字可能是y轴的刻度或者数据点。但这里的文本提取似乎没有正确对应,比如“B 5 $120M”、“8 100 $95M”这样的组合,可能是识别错误,或者是图表中的某些元素被错误提取。\n\n在这种情况下,由于文本提取不明确,无法直接确定每个季度的收入数值,特别是空间关联(即哪个数值对应哪个季度)没有被文本明确建立。根据系统提示,当需要精确的图表数值或空间关联时,应该调用inspect_visual工具。\n\n因此,我需要调用inspect_visual工具,询问每个季度的具体收入数值,特别是最低的那个季度及其数值,以及Q4的数值,以便计算差额。具体来说,问题应该是:“请提供Acme Corp 2024年各季度(Q1、Q2、Q3、Q4)的收入数值,并指出最低收入的季度及其数值,以及Q4的收入数值。” 这样就能获取所需的精确数据,进而回答问题。"
|
||||
},
|
||||
"executions": [
|
||||
{
|
||||
"tool_call_id": "call_d6od6lf1thf1cpwv1v6vjzje",
|
||||
"name": "inspect_visual",
|
||||
"arguments": {
|
||||
"question": "What are the revenue values for Q1, Q2, Q3, and Q4 of Acme Corp in 2024? Which quarter has the lowest revenue, what is its value, and what is Q4's revenue?"
|
||||
},
|
||||
"result": "**Revenue Values by Quarter (2024):** \n- Q1: $120M (blue bar) \n- Q2: $150M (green bar) \n- Q3: $95M (red bar) \n- Q4: $180M (purple bar) \n\n\n**Lowest Revenue:** Q3, with $95M (smallest bar height, labeled directly). \n\n\n**Q4 Revenue:** $180M (tallest bar, labeled directly). \n\n\n*Visual Evidence:* The bar chart explicitly labels each quarter’s revenue above its respective bar, confirming the values. Q3’s red bar is the shortest, and Q4’s purple bar is the tallest."
|
||||
}
|
||||
]
|
||||
},
|
||||
"external_judge": {
|
||||
"id": "png:tool:lowest_gap",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "pdf",
|
||||
"question_id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"id": "pdf:native:highest",
|
||||
"paradigm": "native-multimodal",
|
||||
"answer": "The fourth quarter (Q4) had the highest revenue, with an exact value of $180M. \n\n**Visual evidence**: The Q4 bar in the \"Acme Corp Quarterly Revenue 2024\" bar graph is the tallest, explicitly labeled \"$180M,\" exceeding Q1 ($120M), Q2 ($150M), and Q3 ($95M).",
|
||||
"latency_ms": 6289.762,
|
||||
"exact_correct": true,
|
||||
"external_judge": {
|
||||
"id": "pdf:native:highest",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "pdf",
|
||||
"question_id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"id": "pdf:extract:highest",
|
||||
"paradigm": "extract-to-text",
|
||||
"answer": "The fourth quarter had the highest revenue, but the exact value is unavailable.",
|
||||
"latency_ms": 3313.326,
|
||||
"exact_correct": false,
|
||||
"external_judge": {
|
||||
"id": "pdf:extract:highest",
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"reason": "The answer states that the exact value is unavailable, which contradicts the reference answer that provides the exact value of $180M."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "pdf",
|
||||
"question_id": "highest",
|
||||
"question": "Which quarter had the highest revenue, and what was the exact value?",
|
||||
"expected": "Q4, $180M",
|
||||
"id": "pdf:tool:highest",
|
||||
"paradigm": "tool-on-demand",
|
||||
"answer": "The highest revenue in 2024 was in the fourth quarter (Q4), with an exact value of $180M. This is supported by the visual bar chart labeled \"Acme Corp Quarterly Revenue 2024,\" which shows Q4 as the tallest bar explicitly marked with \"$180M,\" exceeding Q1 ($120M), Q2 ($150M), and Q3 ($95M). The report also notes a \"strong recovery in the final quarter\" attributed to the launch of the new enterprise product line, confirming Q4's surge.",
|
||||
"latency_ms": 23111.276,
|
||||
"exact_correct": true,
|
||||
"tool_trace": {
|
||||
"tool_selected": true,
|
||||
"decision": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_m638i2ydhoymnhz26ol5oad4",
|
||||
"function": {
|
||||
"arguments": "{\"question\":\"Which quarter had the highest revenue in 2024, and what was the exact revenue value for that quarter?\"}",
|
||||
"name": "inspect_visual"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"reasoning_content": "我现在需要回答用户的问题:“Which quarter had the highest revenue, and what was the exact value?” 根据提供的提取文本,里面提到2024年整体表现健康增长,年中有所下滑,然后第四季度强劲复苏,管理层将第四季度的增长归因于新企业产品线的推出。不过,文本中并没有给出具体的季度收入数值,也没有明确说明哪个季度的收入最高,只是提到第四季度有增长。\n\n根据系统提示,当文本中没有明确的图表数值或空间关联时,需要调用inspect_visual工具。这里的问题需要知道具体的季度收入数值以及哪个季度最高,而文本中只提到第四季度有增长,但没有具体数字,所以必须通过视觉检查来获取这些信息。因此,我需要调用inspect_visual工具,询问哪个季度收入最高以及具体数值。"
|
||||
},
|
||||
"executions": [
|
||||
{
|
||||
"tool_call_id": "call_m638i2ydhoymnhz26ol5oad4",
|
||||
"name": "inspect_visual",
|
||||
"arguments": {
|
||||
"question": "Which quarter had the highest revenue in 2024, and what was the exact revenue value for that quarter?"
|
||||
},
|
||||
"result": "The highest revenue in 2024 was in Q4, with an exact revenue value of $180M. \n\n**Supporting visual evidence**: The bar chart labeled \"Acme Corp Quarterly Revenue 2024\" shows Q4 as the tallest bar, explicitly marked with \"$180M.\" This exceeds Q1 ($120M), Q2 ($150M), and Q3 ($95M). The report also notes a \"strong recovery in the final quarter\" attributed to the new enterprise product line, confirming Q4's surge."
|
||||
}
|
||||
]
|
||||
},
|
||||
"external_judge": {
|
||||
"id": "pdf:tool:highest",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q4 as the quarter with the highest revenue and provides the exact value of $180M, matching the reference answer and providing additional context."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "pdf",
|
||||
"question_id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"id": "pdf:native:lowest_gap",
|
||||
"paradigm": "native-multimodal",
|
||||
"answer": "- **Lowest revenue quarter**: Q3 \n- **Value**: $95M \n- **Q4 exceedance**: Q4 revenue ($180M) minus Q3 revenue ($95M) = $85M \n\n\n**Visual evidence**: The bar graph shows Q3 with the shortest bar, labeled $95M. Q4’s bar is the tallest, labeled $180M. The vertical gap between Q3 and Q4 bars corresponds to $85M.",
|
||||
"latency_ms": 7485.346,
|
||||
"exact_correct": true,
|
||||
"external_judge": {
|
||||
"id": "pdf:native:lowest_gap",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "pdf",
|
||||
"question_id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"id": "pdf:extract:lowest_gap",
|
||||
"paradigm": "extract-to-text",
|
||||
"answer": "unavailable",
|
||||
"latency_ms": 3436.729,
|
||||
"exact_correct": false,
|
||||
"external_judge": {
|
||||
"id": "pdf:extract:lowest_gap",
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"reason": "The answer states that the information is unavailable, which contradicts the reference answer that provides the exact values and the difference."
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact": "pdf",
|
||||
"question_id": "lowest_gap",
|
||||
"question": "Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?",
|
||||
"expected": "Q3, $95M; Q4 exceeded it by $85M",
|
||||
"id": "pdf:tool:lowest_gap",
|
||||
"paradigm": "tool-on-demand",
|
||||
"answer": "The lowest revenue quarter was Q3, with a value of $95M. Q4 revenue was $180M, exceeding Q3 by $85M. \n\n**Details**: The visual inspection confirms Q3 (red bar) as the lowest with $95M, while Q4 (purple bar) is the highest at $180M. The difference is calculated as $180M - $95M = $85M.",
|
||||
"latency_ms": 28539.153,
|
||||
"exact_correct": true,
|
||||
"tool_trace": {
|
||||
"tool_selected": true,
|
||||
"decision": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_df307c9zp27ddcazuecubeqh",
|
||||
"function": {
|
||||
"arguments": "{\"question\":\"Which quarter had the lowest revenue in 2024, what was its revenue value, what was the revenue value of Q4, and by how much did Q4 exceed the lowest quarter?\"}",
|
||||
"name": "inspect_visual"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"reasoning_content": "我现在需要回答用户的问题:“Which quarter had the lowest revenue, what was its value, and by how much did Q4 exceed it?” 首先,我得仔细看看提供的提取文本,看看有没有相关的信息。\n\n根据提取的文本,里面提到“Overall the year showed healthy growth, with a mid-year dip followed by a strong recovery in the final quarter.” 这里提到了年中出现了一个低谷,然后第四季度强劲复苏。所以,最低的季度应该是年中的某个季度,可能是第二季度或者第三季度?不过文本里没有明确说是哪个季度,只是说“mid-year dip”,也就是年中低谷。年中通常指的是第二季度或者第三季度,但具体哪个呢?这时候可能需要视觉检查工具来确认图表中的具体数据。\n\n接下来,问题还问最低收入的具体数值,以及第四季度超过它多少。文本中没有提到任何具体的数值,只提到了第四季度的增长是由于新产品线的推出。所以,这些具体的数值必须通过查看图表才能获得。因此,我需要调用inspect_visual工具来获取这些信息。\n\n所以,我需要分步骤来处理:首先确定哪个季度是最低的,然后获取该季度的数值,再获取第四季度的数值,最后计算两者之间的差值。但根据文本,只能推断出年中低谷,具体哪个季度和数值都不知道,所以必须通过视觉检查来确认。\n\n因此,我应该调用inspect_visual工具,提出以下问题:“Which quarter had the lowest revenue in 2024, what was its revenue value, and what was the revenue value of Q4? Also, calculate the difference between Q4 and the lowest quarter.” 这样就能一次性获取所有需要的信息,包括最低季度、其数值、Q4的数值以及两者的差值。\n\n不过,可能需要更明确的问题结构。比如,先问最低的季度,再问该季度的数值,然后问Q4的数值,最后计算差值。但工具可能可以一次处理多个问题,所以综合成一个问题应该可以。或者分开调用?不过用户允许并行调用,所以可能可以一次问多个问题。不过根据工具的描述,每次调用只能有一个question参数,所以可能需要分多次调用?或者在一个question里包含多个子问题?\n\n根据工具的parameters,question是一个字符串,所以可能可以在一个问题里包含多个询问点。例如:“Which quarter had the lowest revenue in 2024? What was its revenue value? What was the revenue value of Q4? By how much did Q4 exceed the lowest quarter?” 这样工具可能会返回所有这些信息。\n\n因此,我应该构造一个包含所有这些问题的查询,调用inspect_visual工具,以获取所需的数据。这样就能回答用户的问题了。"
|
||||
},
|
||||
"executions": [
|
||||
{
|
||||
"tool_call_id": "call_df307c9zp27ddcazuecubeqh",
|
||||
"name": "inspect_visual",
|
||||
"arguments": {
|
||||
"question": "Which quarter had the lowest revenue in 2024, what was its revenue value, what was the revenue value of Q4, and by how much did Q4 exceed the lowest quarter?"
|
||||
},
|
||||
"result": "- **Lowest revenue quarter**: Q3 \n- **Lowest revenue value**: $95M \n- **Q4 revenue value**: $180M \n- **Q4 exceeds lowest quarter by**: $85M \n\n\n**Visual evidence**: The bar chart shows Q3 (red bar) with the shortest height, labeled $95M. Q4 (purple bar) is the tallest, labeled $180M. The difference is $180M - $95M = $85M."
|
||||
}
|
||||
]
|
||||
},
|
||||
"external_judge": {
|
||||
"id": "pdf:tool:lowest_gap",
|
||||
"correct": true,
|
||||
"score": 1,
|
||||
"reason": "The answer correctly identifies Q3 as the quarter with the lowest revenue, provides the exact value of $95M, and correctly calculates the difference of $85M that Q4 exceeded it by, matching the reference answer."
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"native-multimodal": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 1.0,
|
||||
"judge_accuracy": 1.0,
|
||||
"mean_latency_ms": 8011.01425
|
||||
},
|
||||
"extract-to-text": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 0.0,
|
||||
"judge_accuracy": 0.0,
|
||||
"mean_latency_ms": 10867.427
|
||||
},
|
||||
"tool-on-demand": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 1.0,
|
||||
"judge_accuracy": 1.0,
|
||||
"mean_latency_ms": 29794.60875
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"same_two_questions_all_paradigms_and_artifacts": true,
|
||||
"png_and_pdf_used": true,
|
||||
"chart_answers_absent_from_pdf_body_text": true,
|
||||
"real_native_vision_calls": true,
|
||||
"tool_selected_on_demand": true,
|
||||
"real_tool_vision_calls": true,
|
||||
"external_moonshot_judge": true,
|
||||
"all_calls_checkpointed": true
|
||||
},
|
||||
"checkpoint_files": [
|
||||
"/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/validation/checkpoints/20260729T185104Z-ark.json",
|
||||
"/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/validation/checkpoints/20260729T185104Z-judge.json"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"schema_version": "chapter3-evidence-v1",
|
||||
"experiment": "4-2",
|
||||
"run_id": "20260729T185433Z-4_2-e028c9db",
|
||||
"created_at": "2026-07-29T18:54:33.367744+00:00",
|
||||
"status": "passed",
|
||||
"run_dir": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/validation/runs/20260729T185433Z-4_2-e028c9db",
|
||||
"artifacts": {
|
||||
"evidence.json": "024358e7b58266eedbedda3cc880a61462c119a89c58e475f4c007dbcb8ff144",
|
||||
"receipts.json": "2aa961a1f7527417dd0d25ac6e542aaad9dc2ffaa7b40a4a450cb5fffe86ceac"
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/campaign.py",
|
||||
"sha256": "6f8162a0e124cef1acada4fbfadc6a1427aa487be24670aaceba2d668820ee3c",
|
||||
"bytes": 16104
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/create_sample.py",
|
||||
"sha256": "1f4fcd0e119a76a74c06744a585d31bc4bd4f755f813f8b2518f77127eaa1e72",
|
||||
"bytes": 4846
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_chart.png",
|
||||
"sha256": "eaf8ac53fbc65a4e6ce0611d441cd12ddd2ed48ef8a727a9d6f22aef70605f39",
|
||||
"bytes": 38417
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter4/multimodal-agent/test_files/sample_report.pdf",
|
||||
"sha256": "db4828a20a12ad70dab5c196d23e4fa407f75c4e3cff2bda22c401de65cb9c2e",
|
||||
"bytes": 46368
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"native-multimodal": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 1.0,
|
||||
"judge_accuracy": 1.0,
|
||||
"mean_latency_ms": 8011.01425
|
||||
},
|
||||
"extract-to-text": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 0.0,
|
||||
"judge_accuracy": 0.0,
|
||||
"mean_latency_ms": 10867.427
|
||||
},
|
||||
"tool-on-demand": {
|
||||
"cases": 4,
|
||||
"exact_accuracy": 1.0,
|
||||
"judge_accuracy": 1.0,
|
||||
"mean_latency_ms": 29794.60875
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"same_two_questions_all_paradigms_and_artifacts": true,
|
||||
"png_and_pdf_used": true,
|
||||
"chart_answers_absent_from_pdf_body_text": true,
|
||||
"real_native_vision_calls": true,
|
||||
"tool_selected_on_demand": true,
|
||||
"real_tool_vision_calls": true,
|
||||
"external_moonshot_judge": true,
|
||||
"all_calls_checkpointed": true
|
||||
}
|
||||
}
|
||||
+1987
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user