Files
liqiang b119135836
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
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

402 lines
16 KiB
Python

#!/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())