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
402 lines
16 KiB
Python
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())
|