📌Expert Workflows · Step 1: AI PDF Summarizer · Edition A (Developer) · Part 4 of 4 — final
🎯 Why Bother With This
Your pipeline currently produces a prose summary. Prose can be stored and displayed, but a predictable JSON shape is easier to validate, index, render consistently, and pass between authorised components.
This final part requestsstructured JSON, validates malformed or unexpected responses, and shows how to wrap the earlier tutorial functions in a CLI. It is an educational integration example, not a production deployment claim.
🛠️ Gather These First
- Parts 1–3 complete: API connection, extraction, chunking, retry logic
📝 Step 1: Asking for Structure
Change the reduce step to request a specific shape:
SUMMARY_SCHEMA_PROMPT = """\
Below are section summaries from a single document, in order.
Return ONLY a JSON object with exactly these keys:
{
"title": "a short descriptive title for the document",
"summary": "one paragraph, max 80 words",
"key_points": ["3-8 short strings"],
"action_items": ["0-5 concrete actions, empty list if none"],
"confidence": "high | medium | low"
}
Rules:
- Use only information present below. Do not invent facts.
- If the content is too fragmentary to summarize, set confidence to "low".
- Output the JSON object and nothing else.
---
{content}
---"""
Three things earn their place.“Only information present below”narrows the model’s job to reorganizing text you supplied rather than recalling anything, which is a practical way to reduce unsupported additions.confidencegives the model a legitimate way to signal a bad input instead of inventing a confident summary of noise. And“nothing else”is still only an instruction unless the selected provider and model enforce a compatible structured-output mode, so the next section validates the response instead of assuming compliance.
Some providers and models support JSON mode or schema-constrained output. Check the current documentation for the selected model and account; parameter names, schema support, and access can change. Even constrained JSON still needs semantic and type validation.
📝 Step 2: Defensive Parsing
import json
import re
FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def parse_json_safely(raw: str) -> dict | None:
"""Parse model output that is *probably* JSON."""
cleaned = FENCE_RE.sub("", raw.strip())
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# Fallback: extract the outermost {...} block
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
return None
Then normalize, because valid JSON with unexpected keys will still break your code — Python’s jsonmodule documentation is worth skimming for exactly what it will and won’t accept:
def normalize_summary(data: dict) -> dict:
key_points = data.get("key_points") or data.get("keyPoints") or []
actions = data.get("action_items") or data.get("actionItems") or []
return {
"title": str(data.get("title") or "Untitled document"),
"summary": str(data.get("summary") or ""),
"key_points": [str(x) for x in key_points][:8],
"action_items": [str(x) for x in actions][:5],
"confidence": str(data.get("confidence") or "medium").lower(),
}
📋 The Structured-Output Validation Checklist
A successfuljson.loads()is not a successful parse — it only proves thesyntaxwas valid, not that the data is what your code expects. Run every AI-returned object through this before trusting it, because each item is a failure mode the validator should handle explicitly:
- [ ]It parsed at all— wrapped in
try/except, with only a request ID, error class, and non-sensitive diagnostics logged on failure - [ ]The keys match your schema— the model may rename
key_pointstokeyPoints(the schema mismatch illustrated in this example) - [ ]Required fields are present— a missing field should raise, not silently become empty
- [ ]Types are right— a field you expect as a list isn’t returned as a string
- [ ]Values are sane— an empty list or a one-word summary means the extraction upstream failed
The principle underneath:the model is a text generator, not a data source you can trust structurally.Even with JSON mode, validate the shape against what your code needs, because the failure that reaches production is never the one that crashes loudly — it’s the plausible-looking object with the quietly wrong key.
⚠️ Troubleshooting — “Expecting value: line 1 column 1”
🔴 BEFORE
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
The prompt said “output the JSON object and nothing else.” The model returned:
Here is the summary you requested:
```json
{"title": "Q3 Report", "summary": "...", "keyPoints": [...]}
Let me know if you’d like more detail.
Cause: conversational text, markdown fences, renamed keys, and wrong field types are separate failure modes. Provider-supported schema constraints can improve syntax, but the application must still validate the returned object.
Correction: parse the candidate JSON, then validate its allowed keys, required fields, types, enumerated values, and list sizes with the strict function below.
def normalize_summary(data: object) -> dict:
if not isinstance(data, dict):
raise ValueError("Expected a JSON object.")
allowed_keys = {
"title",
"summary",
"key_points",
"keyPoints",
"action_items",
"actionItems",
"confidence",
}
unexpected = set(data) - allowed_keys
if unexpected:
raise ValueError(f"Unexpected keys: {sorted(unexpected)}")
title = data.get("title")
summary = data.get("summary")
key_points = data.get("key_points", data.get("keyPoints"))
action_items = data.get("action_items", data.get("actionItems"))
confidence = data.get("confidence")
if not isinstance(title, str):
raise ValueError("title must be a string.")
if not isinstance(summary, str):
raise ValueError("summary must be a string.")
if not isinstance(key_points, list):
raise ValueError("key_points must be a list.")
if not all(isinstance(item, str) for item in key_points):
raise ValueError("key_points must contain strings only.")
if not isinstance(action_items, list):
raise ValueError("action_items must be a list.")
if not all(isinstance(item, str) for item in action_items):
raise ValueError("action_items must contain strings only.")
if confidence not in {"high", "medium", "low"}:
raise ValueError(
"confidence must be high, medium, or low."
)
if len(key_points) > 8:
raise ValueError("key_points cannot contain more than 8 items.")
if len(action_items) > 5:
raise ValueError("action_items cannot contain more than 5 items.")
return {
"title": title,
"summary": summary,
"key_points": key_points,
"action_items": action_items,
"confidence": confidence,
}
This validator raises on malformed structure. The caller should report a sanitized failure and preserve the authorised source for review; returning raw model text as if it matched the schema would hide the error.
💡 Author’s note: A common silent failure is the keyPoints variant: parsing succeeds, but the application receives an empty list because it expects key_points. Normalizing aliases at the system boundary prevents this mismatch from spreading.
Connect the structured request
The CLI below also uses build_client(), test_connection(), inspect_pdf(), extract_text(), chunk_text(), and with_retries() from Parts 1–3. Keep those functions in the same module or import them explicitly. Add this missing structured-request function before the CLI:
def summarize_structured(client, model: str, content: str) -> dict:
prompt = SUMMARY_SCHEMA_PROMPT.replace("{content}", content)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=900,
)
raw = response.choices[0].message.content or ""
parsed = parse_json_safely(raw)
if parsed is None:
raise ValueError("Model output was not a valid JSON object.")
return normalize_summary(parsed)
Boundary: this sends content to the configured provider. Use only public, owned, or authorised documents and confirm the provider’s current retention, training, regional-processing, quota, and billing terms before a live request.
📝 Step 3: The CLI
Wire it together insummarize.py:
import argparse
import json
from pathlib import Path
defsummarize_document_structured(
client,
model: str,
text: str,
max_chars: int=6000,
) -> dict:
chunks=chunk_text(text, max_chars=max_chars)
summaries= []
fori, chunkinenumerate(chunks, start=1):
print(f"[{i}/{len(chunks)}] summarizing…")
try:
result=with_retries(
lambda: summarize_chunk(
client,
model,
chunk,
i,
len(chunks),
)
)
ifresult!="SKIP":
summaries.append(result)
exceptExceptionasexc:
print(f"Chunk {i} failed permanently: {exc}")
ifnotsummaries:
raiseRuntimeError(
"No chunks were summarized successfully."
)
combined="\n\n".join(summaries)
returnsummarize_structured(
client,
model,
combined,
)
def main() -> None:
parser = argparse.ArgumentParser(description="Summarize a PDF with a free AI API.")
parser.add_argument("pdf", help="path to the PDF file")
parser.add_argument("--out", help="write JSON result to this file")
parser.add_argument("--max-chars", type=int, default=6000, help="chunk size")
parser.add_argument("--check", action="store_true", help="test API connection and exit")
args = parser.parse_args()
client = build_client()
if args.check:
print(test_connection(client))
return
info = inspect_pdf(args.pdf)
print(f"{info['pages']} pages, {info['total_chars']} characters")
if info["likely_scanned"]:
raise SystemExit("This PDF appears to be scanned. Run OCR first.")
text = extract_text(args.pdf)
result = summarize_document_structured(client, MODEL, text, max_chars=args.max_chars)
print(f"\n{result['title']} (confidence: {result['confidence']})\n")
print(result["summary"], "\n")
for point in result["key_points"]:
print(f" • {point}")
if args.out:
Path(args.out).write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"\nsaved to {args.out}")
if __name__ == "__main__":
main()
python summarize.py report.pdf --out summary.json
ensure_ascii=Falsekeeps non-English text readable in the saved file instead of escaping it into\uXXXXsequences — the same encoding discipline from part 2, applied on the way out.
⚠️ The Part People Skip (Don’t)
You now have a working tool, which is exactly when it’s most tempting to trust it. Three warnings before you use this on anything that matters.
The summary is not verified.The model reorganizes text it was given, and it can still misattribute, merge two claims into one, or overstate a hedge. For anything consequential — contracts, medical documents, financial reports — the summary is amapfor finding the relevant pages, never a replacement for reading them. Thatconfidencefield is the model’s guess, not a guarantee.
Watch what you’re uploading.Every chunk is sent to a third-party service. Read your provider’s data policy before summarizing client contracts or anything containing personal information, and never put confidential material through a free tier without checking whether inputs are used for training.
Cost and quota are real.A long document is dozens of calls. Print the chunk count, keep the cache from part 3, and know your daily limit before you batch-process a folder.
✅ Your Finished Tool
You now have an educational command-line integration for PDF inspection, bounded chunking, retry handling, structured JSON parsing, schema validation, and a connection check. It assumes the earlier tutorial functions are present and has not been demonstrated against every PDF, provider, model, language, or failure mode. Add dependency pinning, automated tests, durable checkpoints, access controls, cost limits, privacy analysis, and deployment hardening before production use.
💡 Take It Further
Two upgrades worth an evening each: add--format markdownto emit a formatted brief instead of JSON, and add a--foldermode that processes a directory and writes one combined index file. Folder mode can help with repeated authorised work, but it also requires per-file isolation, resumable checkpoints, privacy review, and a cost preview before execution.
❓ This Week’s Task
Run your tool on three different PDFs — a clean text document, something with tables, and something in a language other than English. Note where the quality drops. That observation is what you’ll fix in Step 2, when this same engine gets a user interface.
🔗 What’s Next
Next in the course:[Step 2] Your First AI Chrome Extension— where this summarizer stops living in your terminal and starts working on any web page you’re reading. Full roadmap:The Expert Workflows Curriculum.
Written and edited by G. Troy. See the editorial standards for sourcing and AI-assistance practices.
Structured output boundary
JSON mode or a schema can improve parseability but does not guarantee factual accuracy or semantic validity. Validate types, required fields, ranges, and source-grounded claims; reject malformed output; preserve the original document for review.
Verified Structured Output Evidence
Tested: 30 August 2026 on Windows 10 with local Python. These deterministic checks used synthetic JSON and made no live AI-provider request.
Download the secret-free verified structured-output package (ZIP)
| Offline check | Result |
|---|---|
| Python syntax | PASS |
| Plain JSON object | PASS |
| Fenced JSON object | PASS |
| Outer object extraction | PASS |
| Malformed response rejection | PASS |
| Camel-case normalization | PASS |
| List caps | PASS |
| Confidence guard | PASS |
| Unexpected type rejection | PASS |
| UTF-8 readable output | PASS |
| Secret and live-call absence | PASS |
SHA-256: BDDA30934E4F2F1B500C35A1142464E1C2736D674AE9793A482E16761AB97519
Verification boundary: These checks verify deterministic parsing, normalization, type guards, list limits, confidence fallback, and UTF-8 file output. They do not prove that a provider will follow a schema, that a model summary is factually correct, or that a live workflow is secure, private, affordable, or production-ready. Review consequential summaries against the authorised source document.

![[Step 1-A-4/4] Structured JSON Output & Shipping the Tool (Developer Edition)](https://lifetechhack.com/wp-content/uploads/2026/07/EW-Step1-A-4-of-4-1100x450.png)