Files
essay/90_scripts/test_docx_upload.py

259 lines
9.2 KiB
Python
Raw Permalink Normal View History

2026-02-10 13:27:55 +00:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""test_docx_upload.py
Test whether an OpenAI-compatible /v1/chat/completions endpoint supports
direct docx upload + content analysis.
Flow:
1) ping chat/completions
2) probe /v1/files (many gateways return 404)
3) try two "direct docx" variants:
A) messages[].content as an array with {type: input_file, mime_type, data}
B) top-level files: [{filename, mime_type, data}]
4) fallback: extract docx text locally and send as plain text
Security:
- API key is read from an environment variable only; never written to disk.
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import json
import os
import sys
import textwrap
from pathlib import Path
import requests
try:
from docx import Document
except Exception:
Document = None
DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
def now_stamp() -> str:
return dt.datetime.now().strftime("%Y%m%d_%H%M%S")
def safe_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def dump_json(path: Path, obj) -> None:
safe_write(path, json.dumps(obj, ensure_ascii=False, indent=2))
def http_head(url: str, headers: dict, timeout: int = 30) -> requests.Response:
return requests.head(url, headers=headers, timeout=timeout, allow_redirects=False)
def http_post_json(url: str, headers: dict, payload: dict, timeout: int = 120) -> requests.Response:
return requests.post(url, headers=headers, json=payload, timeout=timeout)
def read_docx_text(docx_path: Path) -> str:
if Document is None:
raise RuntimeError("python-docx not installed: pip install python-docx")
doc = Document(str(docx_path))
paras = [p.text.strip() for p in doc.paragraphs if p.text and p.text.strip()]
return "\n".join(paras)
def truncate_text(s: str, max_chars: int) -> str:
if len(s) <= max_chars:
return s
return s[:max_chars] + "\n\n[TRUNCATED] original_len=%d truncated_len=%d" % (len(s), max_chars)
def summarize_response(resp: requests.Response) -> str:
ct = resp.headers.get("Content-Type", "")
return "HTTP %s Content-Type=%s len=%d" % (resp.status_code, ct, len(resp.content))
def main() -> int:
ap = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description="Test docx upload support for chat/completions gateways",
epilog=textwrap.dedent(
"""\
Example:
export API_KEY='sk-***'
python test_docx_upload.py \
--api-base 'http://120.24.249.39:18317' \
--model 'gemini-3-pro-preview' \
--docx './我的论文/飞机稿_20260130.docx' \
--out './_upload_test_out'
"""
),
)
ap.add_argument("--api-base", required=True, help="e.g. http://120.24.249.39:18317")
ap.add_argument("--model", required=True, help="e.g. gemini-3-pro-preview")
ap.add_argument("--docx", required=True, help="Path to .docx")
ap.add_argument(
"--prompt",
default="请用中文提炼该文档的四级大纲,并说明论证逻辑与文风特点。",
help="Prompt to run against the document",
)
ap.add_argument(
"--api-key-env",
default="API_KEY",
help="Environment variable name containing API key (default: API_KEY)",
)
ap.add_argument("--timeout", type=int, default=180, help="POST timeout seconds (default: 180)")
ap.add_argument(
"--max-text-chars",
type=int,
default=60000,
help="Fallback mode: max extracted text characters to send (default: 60000)",
)
ap.add_argument(
"--out",
default="./_upload_test_out_%s" % now_stamp(),
help="Output directory (default: timestamped)",
)
args = ap.parse_args()
api_key = os.getenv(args.api_key_env)
if not api_key:
print("[ERROR] env %s is empty; export %s='...'" % (args.api_key_env, args.api_key_env), file=sys.stderr)
return 2
api_base = args.api_base.rstrip("/")
chat_url = api_base + "/v1/chat/completions"
files_url = api_base + "/v1/files"
outdir = Path(args.out).resolve()
outdir.mkdir(parents=True, exist_ok=True)
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer %s" % api_key,
}
head_headers = {"Authorization": "Bearer %s" % api_key}
docx_path = Path(args.docx)
if not docx_path.exists():
print("[ERROR] docx not found: %s" % docx_path, file=sys.stderr)
return 2
docx_bytes = docx_path.read_bytes()
docx_b64 = base64.b64encode(docx_bytes).decode("ascii")
size_mb = len(docx_bytes) / (1024.0 * 1024.0)
print("[INFO] docx=%s size=%.2fMB out=%s" % (docx_path, size_mb, outdir))
meta = {
"api_base": api_base,
"chat_url": chat_url,
"files_url": files_url,
"model": args.model,
"docx": str(docx_path),
"docx_size_bytes": len(docx_bytes),
"prompt": args.prompt,
"note": "API key is read from env only; never written to output files.",
}
dump_json(outdir / "00_meta.json", meta)
# 1) ping
ping_payload = {"model": args.model, "messages": [{"role": "user", "content": "ping"}]}
dump_json(outdir / "01_ping_request.json", ping_payload)
print("[STEP 1] POST %s ping ..." % chat_url)
ping_resp = http_post_json(chat_url, headers=headers, payload=ping_payload, timeout=args.timeout)
safe_write(outdir / "01_ping_response.txt", ping_resp.text)
print("[STEP 1] %s" % summarize_response(ping_resp))
if ping_resp.status_code != 200:
print("[ERROR] ping failed; see %s" % (outdir / "01_ping_response.txt"), file=sys.stderr)
return 1
# 2) probe /v1/files
print("[STEP 2] HEAD %s ..." % files_url)
try:
files_head = http_head(files_url, headers=head_headers, timeout=30)
safe_write(outdir / "02_files_head_status.txt", "%s\n%s\n" % (files_head.status_code, dict(files_head.headers)))
print("[STEP 2] HTTP %s" % files_head.status_code)
except Exception as e:
safe_write(outdir / "02_files_head_status.txt", "EXCEPTION: %r\n" % (e,))
print("[STEP 2] exception: %r" % (e,))
# 3A) input_file in content array
print("[STEP 3A] try messages[].content input_file ...")
payload_a = {
"model": args.model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": args.prompt},
{"type": "input_file", "mime_type": DOCX_MIME, "data": docx_b64},
],
}
],
}
dump_json(outdir / "03A_input_file_request.json", payload_a)
resp_a = http_post_json(chat_url, headers=headers, payload=payload_a, timeout=args.timeout)
safe_write(outdir / "03A_input_file_response.txt", resp_a.text)
print("[STEP 3A] %s" % summarize_response(resp_a))
if resp_a.status_code == 200:
print("[RESULT] supports variant A (input_file). See 03A_input_file_response.txt")
return 0
# 3B) top-level files field
print("[STEP 3B] try top-level files field ...")
payload_b = {
"model": args.model,
"messages": [{"role": "user", "content": args.prompt}],
"files": [{"filename": docx_path.name, "mime_type": DOCX_MIME, "data": docx_b64}],
}
dump_json(outdir / "03B_files_field_request.json", payload_b)
resp_b = http_post_json(chat_url, headers=headers, payload=payload_b, timeout=args.timeout)
safe_write(outdir / "03B_files_field_response.txt", resp_b.text)
print("[STEP 3B] %s" % summarize_response(resp_b))
if resp_b.status_code == 200:
print("[RESULT] supports variant B (top-level files). See 03B_files_field_response.txt")
return 0
# 4) fallback: extract text and send as plain content
print("[STEP 4] fallback: extract docx text and send as plain text ...")
try:
extracted = read_docx_text(docx_path)
except Exception as e:
safe_write(outdir / "04_fallback_extract_error.txt", "%r\n" % (e,))
print("[RESULT] direct upload A/B failed; fallback extraction failed: %r" % (e,), file=sys.stderr)
return 1
extracted_trunc = truncate_text(extracted, args.max_text_chars)
safe_write(outdir / "04_fallback_extracted_text.txt", extracted_trunc)
payload_c = {
"model": args.model,
"messages": [
{
"role": "user",
"content": "%s\n\n=== 文档正文(从 docx 提取)===\n%s" % (args.prompt, extracted_trunc),
}
],
}
dump_json(outdir / "04_fallback_text_request.json", payload_c)
resp_c = http_post_json(chat_url, headers=headers, payload=payload_c, timeout=args.timeout)
safe_write(outdir / "04_fallback_text_response.txt", resp_c.text)
print("[STEP 4] %s" % summarize_response(resp_c))
if resp_c.status_code == 200:
print("[RESULT] direct upload A/B failed; fallback text mode succeeded.")
return 0
print("[RESULT] direct upload A/B failed and fallback failed; inspect output dir: %s" % outdir, file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())