[{"data":1,"prerenderedAt":94383},["ShallowReactive",2],{"page-\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fconnect-a-chatbot-to-your-docs-with-rag\u002F":3,"all-content-pages":2459},{"id":4,"title":5,"body":6,"description":2418,"extension":2419,"faq":2420,"howto":2436,"meta":2451,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":2455,"published":2452,"seo":2456,"seoTitle":5,"stem":2457,"__hash__":2458},"content\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fconnect-a-chatbot-to-your-docs-with-rag\u002Findex.md","Connect a Chatbot to Your Docs with RAG",{"type":7,"value":8,"toc":2404},"minimark",[9,13,31,47,56,61,64,75,235,239,249,252,314,321,349,362,382,390,394,401,412,661,664,668,675,873,880,884,895,1109,1122,1126,1133,1352,1364,1368,1378,1442,1446,1514,1518,1531,2308,2314,2318,2321,2349,2352,2356,2373,2378,2382,2400],[10,11,5],"h1",{"id":12},"connect-a-chatbot-to-your-docs-with-rag",[14,15,16,17,21,22,25,26,30],"p",{},"This guide shows you how to make a chatbot answer from your own documents in under thirty minutes, using nothing but the ",[18,19,20],"code",{},"openai"," SDK and ",[18,23,24],{},"numpy",". By the end you will have a runnable Python script that reads your files, finds the passages relevant to any question, and feeds them to the model so it stops guessing and starts citing ",[27,28,29],"em",{},"your"," facts.",[14,32,33,34,38,39,42,43,46],{},"The technique is called ",[35,36,37],"strong",{},"RAG",", short for Retrieval-Augmented Generation. In plain terms: before the model writes an answer, your code ",[27,40,41],{},"retrieves"," the few passages from your documents that best match the question, then the model ",[27,44,45],{},"generates"," its reply using that supplied text. The model is never trained on your data; you simply hand it the right reference material at the moment it answers, the way you might slide an open manual across the desk before asking a colleague a question.",[14,48,49,50,55],{},"This is one of the guides under ",[51,52,54],"a",{"href":53},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002F","Custom AI Chatbot Development",". If you have not built a basic bot yet, start there, then come back to make it answer from your knowledge base.",[57,58,60],"h2",{"id":59},"why-rag-instead-of-just-asking-the-model","Why RAG instead of just asking the model?",[14,62,63],{},"A general model knows nothing about your return policy, your pricing, or last week's release notes, and it will confidently invent an answer rather than admit the gap. You could paste your entire handbook into every prompt, but that is slow, expensive, and eventually too large for the model to read. RAG is the middle path: store your documents once, and at question time attach only the handful of passages that actually matter.",[14,65,66,67,70,71,74],{},"The matching happens through ",[35,68,69],{},"embeddings"," — numeric fingerprints of text where similar meanings land close together in mathematical space. The question \"how long do I have to return something?\" and the sentence \"Returns are accepted within 30 days\" produce vectors pointing in nearly the same direction, even though they share almost no words. That is the whole trick: embeddings match on ",[27,72,73],{},"meaning",", not keywords, so customers do not have to phrase questions exactly the way your documents are written.",[76,77,80,231],"figure",{"className":78},[79],"diagram",[81,82,90,91,90,95,90,99,90,110,90,121,90,127,90,131,90,135,90,138,90,145,90,149,90,153,90,156,90,159,90,163,90,166,90,170,90,180,90,185,90,189,90,193,90,196,90,200,90,203,90,207,90,210,90,215,90,223,90,226],"svg",{"viewBox":83,"role":84,"ariaLabelledBy":85,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1100 460","img",[86,87],"ragTitle","ragDesc","xMidYMid meet","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[92,93,94],"title",{"id":86},"Retrieval-augmented generation flow",[96,97,98],"desc",{"id":87},"A question is turned into a vector, searched against embedded document chunks to pull the top matches, then those chunks plus the question are sent to the language model, which returns a grounded answer.",[100,101],"rect",{"x":102,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"0","170","200","72","12","var(--panel-strong)","var(--brand-alt)","2",[111,112,120],"text",{"x":113,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"100","201","Inter, system-ui, sans-serif","13","600","var(--text)","middle","Question",[111,122,126],{"x":113,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"221","11","var(--text-muted)","from the user",[100,128],{"x":129,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"240","var(--brand)",[111,132,134],{"x":133,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"340","Embed",[111,136,137],{"x":133,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"to a vector",[100,139],{"x":129,"y":140,"width":104,"height":141,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},"20","92","var(--panel)","var(--border-strong)","1.5",[111,146,148],{"x":133,"y":147,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"52","Doc chunks",[111,150,152],{"x":133,"y":151,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"74","embedded once,",[111,154,155],{"x":133,"y":141,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"stored as vectors",[100,157],{"x":158,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"480",[111,160,162],{"x":161,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"580","Vector search",[111,164,165],{"x":161,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"top-k matches",[100,167],{"x":168,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},"720","var(--brand-alt-strong)",[111,171,173,174],{"x":172,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"820","Language model",[175,176,179],"tspan",{"x":172,"dy":177,"fontSize":124,"fontWeight":178,"fill":125},"1.2em","400","grounded answer",[181,182],"line",{"x1":104,"y1":183,"x2":184,"y2":183,"stroke":108,"strokeWidth":109},"206","238",[186,187],"polygon",{"points":188,"fill":108},"238,206 230,202 230,210",[181,190],{"x1":133,"y1":191,"x2":133,"y2":192,"stroke":143,"strokeWidth":144},"112","168",[186,194],{"points":195,"fill":143},"340,168 336,160 344,160",[181,197],{"x1":198,"y1":183,"x2":199,"y2":183,"stroke":130,"strokeWidth":109},"440","478",[186,201],{"points":202,"fill":130},"478,206 470,202 470,210",[181,204],{"x1":205,"y1":183,"x2":206,"y2":183,"stroke":169,"strokeWidth":109},"680","718",[186,208],{"points":209,"fill":169},"718,206 710,202 710,210",[111,211,214],{"x":212,"y":213,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"699","166","+ question",[216,217],"path",{"d":218,"fill":219,"stroke":169,"strokeWidth":109,"strokeDashArray":220},"M 820 242 C 820 350, 100 350, 100 244","none",[221,222],"6","5",[186,224],{"points":225,"fill":169},"100,244 96,252 104,252",[111,227,230],{"x":228,"y":229,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"460","368","answer returns to the user",[232,233,234],"figcaption",{},"RAG in one picture: embed the question, search your embedded chunks, hand the best matches plus the question to the model.",[57,236,238],{"id":237},"prerequisites","Prerequisites",[14,240,241,242,245,246,248],{},"You need Python 3.10 or newer (",[18,243,244],{},"python3 --version"," to check) and an OpenAI API key. This guide assumes you have already met the parent section's setup; if not, ",[51,247,54],{"href":53}," covers installing the SDK and storing your key.",[14,250,251],{},"Work inside a virtual environment and install the two libraries this guide adds on top of the SDK:",[253,254,259],"pre",{"className":255,"code":256,"language":257,"meta":258,"style":258},"language-bash shiki shiki-themes github-light github-dark","python3 -m venv .venv\nsource .venv\u002Fbin\u002Factivate          # Windows: .venv\\Scripts\\activate\npip install \"openai>=1.40\" \"httpx>=0.27\" python-dotenv numpy\n","bash","",[18,260,261,280,293],{"__ignoreMap":258},[262,263,265,269,273,277],"span",{"class":181,"line":264},1,[262,266,268],{"class":267},"sScJk","python3",[262,270,272],{"class":271},"sj4cs"," -m",[262,274,276],{"class":275},"sZZnC"," venv",[262,278,279],{"class":275}," .venv\n",[262,281,283,286,289],{"class":181,"line":282},2,[262,284,285],{"class":271},"source",[262,287,288],{"class":275}," .venv\u002Fbin\u002Factivate",[262,290,292],{"class":291},"sJ8bj","          # Windows: .venv\\Scripts\\activate\n",[262,294,296,299,302,305,308,311],{"class":181,"line":295},3,[262,297,298],{"class":267},"pip",[262,300,301],{"class":275}," install",[262,303,304],{"class":275}," \"openai>=1.40\"",[262,306,307],{"class":275}," \"httpx>=0.27\"",[262,309,310],{"class":275}," python-dotenv",[262,312,313],{"class":275}," numpy\n",[14,315,316,317,320],{},"Store your key and model names in a ",[18,318,319],{},".env"," file so they never end up in your code:",[253,322,326],{"className":323,"code":324,"language":325,"meta":258,"style":258},"language-env shiki shiki-themes github-light github-dark","# .env\nOPENAI_API_KEY=sk-your-real-key-here\nCHAT_MODEL=gpt-4o-mini\nEMBED_MODEL=text-embedding-3-small\n","env",[18,327,328,333,338,343],{"__ignoreMap":258},[262,329,330],{"class":181,"line":264},[262,331,332],{},"# .env\n",[262,334,335],{"class":181,"line":282},[262,336,337],{},"OPENAI_API_KEY=sk-your-real-key-here\n",[262,339,340],{"class":181,"line":295},[262,341,342],{},"CHAT_MODEL=gpt-4o-mini\n",[262,344,346],{"class":181,"line":345},4,[262,347,348],{},"EMBED_MODEL=text-embedding-3-small\n",[14,350,351,361],{},[35,352,353,354,356,357,360],{},"Add ",[18,355,319],{}," to your ",[18,358,359],{},".gitignore"," immediately"," so the key is never committed:",[253,363,365],{"className":255,"code":364,"language":257,"meta":258,"style":258},"echo \".env\" >> .gitignore\n",[18,366,367],{"__ignoreMap":258},[262,368,369,372,375,379],{"class":181,"line":264},[262,370,371],{"class":271},"echo",[262,373,374],{"class":275}," \".env\"",[262,376,378],{"class":377},"szBVR"," >>",[262,380,381],{"class":275}," .gitignore\n",[14,383,384,385,389],{},"If a request later fails with an authentication error, ",[51,386,388],{"href":387},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-401-unauthorized-error-openai-python\u002F","Fix the 401 Unauthorized Error in OpenAI Python"," covers every cause.",[57,391,393],{"id":392},"step-1-split-your-documents-into-chunks","Step 1: Split your documents into chunks",[14,395,396,397,400],{},"You cannot embed a whole 50-page handbook as one vector — the meaning would blur into mush and every search would return the entire document. Instead you ",[35,398,399],{},"chunk"," it: split the text into passages of a few hundred words each, with a small overlap so a sentence split across a boundary still appears whole in one chunk.",[14,402,403,404,407,408,411],{},"The function below splits on words and slides a window forward, leaving an overlap between neighbours. It works on any plain string, so you can feed it the contents of a ",[18,405,406],{},".txt"," or ",[18,409,410],{},".md"," file.",[253,413,417],{"className":414,"code":415,"language":416,"meta":258,"style":258},"language-python shiki shiki-themes github-light github-dark","def chunk_text(text: str, chunk_size: int = 300, overlap: int = 50) -> list[str]:\n    \"\"\"Split text into overlapping word-based chunks.\"\"\"\n    words = text.split()\n    chunks = []\n    start = 0\n    while start \u003C len(words):\n        end = start + chunk_size\n        chunks.append(\" \".join(words[start:end]))\n        start += chunk_size - overlap   # step forward, keeping an overlap\n    return chunks\n\n\nsample = open(\"handbook.txt\", encoding=\"utf-8\").read()\nchunks = chunk_text(sample)\nprint(f\"Split into {len(chunks)} chunks\")\n","python",[18,418,419,464,469,480,490,501,519,535,547,568,577,584,589,621,632],{"__ignoreMap":258},[262,420,421,424,427,431,434,437,440,443,446,449,451,453,456,459,461],{"class":181,"line":264},[262,422,423],{"class":377},"def",[262,425,426],{"class":267}," chunk_text",[262,428,430],{"class":429},"sVt8B","(text: ",[262,432,433],{"class":271},"str",[262,435,436],{"class":429},", chunk_size: ",[262,438,439],{"class":271},"int",[262,441,442],{"class":377}," =",[262,444,445],{"class":271}," 300",[262,447,448],{"class":429},", overlap: ",[262,450,439],{"class":271},[262,452,442],{"class":377},[262,454,455],{"class":271}," 50",[262,457,458],{"class":429},") -> list[",[262,460,433],{"class":271},[262,462,463],{"class":429},"]:\n",[262,465,466],{"class":181,"line":282},[262,467,468],{"class":275},"    \"\"\"Split text into overlapping word-based chunks.\"\"\"\n",[262,470,471,474,477],{"class":181,"line":295},[262,472,473],{"class":429},"    words ",[262,475,476],{"class":377},"=",[262,478,479],{"class":429}," text.split()\n",[262,481,482,485,487],{"class":181,"line":345},[262,483,484],{"class":429},"    chunks ",[262,486,476],{"class":377},[262,488,489],{"class":429}," []\n",[262,491,493,496,498],{"class":181,"line":492},5,[262,494,495],{"class":429},"    start ",[262,497,476],{"class":377},[262,499,500],{"class":271}," 0\n",[262,502,504,507,510,513,516],{"class":181,"line":503},6,[262,505,506],{"class":377},"    while",[262,508,509],{"class":429}," start ",[262,511,512],{"class":377},"\u003C",[262,514,515],{"class":271}," len",[262,517,518],{"class":429},"(words):\n",[262,520,522,525,527,529,532],{"class":181,"line":521},7,[262,523,524],{"class":429},"        end ",[262,526,476],{"class":377},[262,528,509],{"class":429},[262,530,531],{"class":377},"+",[262,533,534],{"class":429}," chunk_size\n",[262,536,538,541,544],{"class":181,"line":537},8,[262,539,540],{"class":429},"        chunks.append(",[262,542,543],{"class":275},"\" \"",[262,545,546],{"class":429},".join(words[start:end]))\n",[262,548,550,553,556,559,562,565],{"class":181,"line":549},9,[262,551,552],{"class":429},"        start ",[262,554,555],{"class":377},"+=",[262,557,558],{"class":429}," chunk_size ",[262,560,561],{"class":377},"-",[262,563,564],{"class":429}," overlap   ",[262,566,567],{"class":291},"# step forward, keeping an overlap\n",[262,569,571,574],{"class":181,"line":570},10,[262,572,573],{"class":377},"    return",[262,575,576],{"class":429}," chunks\n",[262,578,580],{"class":181,"line":579},11,[262,581,583],{"emptyLinePlaceholder":582},true,"\n",[262,585,587],{"class":181,"line":586},12,[262,588,583],{"emptyLinePlaceholder":582},[262,590,592,595,597,600,603,606,609,613,615,618],{"class":181,"line":591},13,[262,593,594],{"class":429},"sample ",[262,596,476],{"class":377},[262,598,599],{"class":271}," open",[262,601,602],{"class":429},"(",[262,604,605],{"class":275},"\"handbook.txt\"",[262,607,608],{"class":429},", ",[262,610,612],{"class":611},"s4XuR","encoding",[262,614,476],{"class":377},[262,616,617],{"class":275},"\"utf-8\"",[262,619,620],{"class":429},").read()\n",[262,622,624,627,629],{"class":181,"line":623},14,[262,625,626],{"class":429},"chunks ",[262,628,476],{"class":377},[262,630,631],{"class":429}," chunk_text(sample)\n",[262,633,635,638,640,643,646,649,652,655,658],{"class":181,"line":634},15,[262,636,637],{"class":271},"print",[262,639,602],{"class":429},[262,641,642],{"class":377},"f",[262,644,645],{"class":275},"\"Split into ",[262,647,648],{"class":271},"{len",[262,650,651],{"class":429},"(chunks)",[262,653,654],{"class":271},"}",[262,656,657],{"class":275}," chunks\"",[262,659,660],{"class":429},")\n",[14,662,663],{},"The overlap matters: without it, a fact that lands on a chunk boundary gets cut in half and may never match cleanly. Fifty words is a safe default. If your documents are already short and self-contained — like FAQ entries or product blurbs — skip chunking and treat each entry as one chunk.",[57,665,667],{"id":666},"step-2-create-embeddings-for-each-chunk","Step 2: Create embeddings for each chunk",[14,669,670,671,674],{},"Now turn every chunk into a vector. You send your chunks to the embeddings endpoint and get back one list of numbers per chunk. You do this ",[35,672,673],{},"once"," at startup (or whenever your documents change) and keep the result in memory; embedding is the slow, paid part, so you never want to repeat it per question.",[253,676,678],{"className":414,"code":677,"language":416,"meta":258,"style":258},"import os\nimport numpy as np\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()\nEMBED_MODEL = os.getenv(\"EMBED_MODEL\", \"text-embedding-3-small\")\n\n\ndef embed(texts: list[str]) -> np.ndarray:\n    \"\"\"Return a 2-D array: one embedding row per input text.\"\"\"\n    resp = client.embeddings.create(model=EMBED_MODEL, input=texts)\n    return np.array([item.embedding for item in resp.data])\n\n\nchunk_vectors = embed(chunks)        # embed the whole knowledge base once\nprint(chunk_vectors.shape)           # e.g. (42, 1536) -> 42 chunks, 1536 numbers each\n",[18,679,680,688,701,714,726,730,735,745,765,769,773,788,793,820,839,843,848,862],{"__ignoreMap":258},[262,681,682,685],{"class":181,"line":264},[262,683,684],{"class":377},"import",[262,686,687],{"class":429}," os\n",[262,689,690,692,695,698],{"class":181,"line":282},[262,691,684],{"class":377},[262,693,694],{"class":429}," numpy ",[262,696,697],{"class":377},"as",[262,699,700],{"class":429}," np\n",[262,702,703,706,709,711],{"class":181,"line":295},[262,704,705],{"class":377},"from",[262,707,708],{"class":429}," dotenv ",[262,710,684],{"class":377},[262,712,713],{"class":429}," load_dotenv\n",[262,715,716,718,721,723],{"class":181,"line":345},[262,717,705],{"class":377},[262,719,720],{"class":429}," openai ",[262,722,684],{"class":377},[262,724,725],{"class":429}," OpenAI\n",[262,727,728],{"class":181,"line":492},[262,729,583],{"emptyLinePlaceholder":582},[262,731,732],{"class":181,"line":503},[262,733,734],{"class":429},"load_dotenv()\n",[262,736,737,740,742],{"class":181,"line":521},[262,738,739],{"class":429},"client ",[262,741,476],{"class":377},[262,743,744],{"class":429}," OpenAI()\n",[262,746,747,750,752,755,758,760,763],{"class":181,"line":537},[262,748,749],{"class":271},"EMBED_MODEL",[262,751,442],{"class":377},[262,753,754],{"class":429}," os.getenv(",[262,756,757],{"class":275},"\"EMBED_MODEL\"",[262,759,608],{"class":429},[262,761,762],{"class":275},"\"text-embedding-3-small\"",[262,764,660],{"class":429},[262,766,767],{"class":181,"line":549},[262,768,583],{"emptyLinePlaceholder":582},[262,770,771],{"class":181,"line":570},[262,772,583],{"emptyLinePlaceholder":582},[262,774,775,777,780,783,785],{"class":181,"line":579},[262,776,423],{"class":377},[262,778,779],{"class":267}," embed",[262,781,782],{"class":429},"(texts: list[",[262,784,433],{"class":271},[262,786,787],{"class":429},"]) -> np.ndarray:\n",[262,789,790],{"class":181,"line":586},[262,791,792],{"class":275},"    \"\"\"Return a 2-D array: one embedding row per input text.\"\"\"\n",[262,794,795,798,800,803,806,808,810,812,815,817],{"class":181,"line":591},[262,796,797],{"class":429},"    resp ",[262,799,476],{"class":377},[262,801,802],{"class":429}," client.embeddings.create(",[262,804,805],{"class":611},"model",[262,807,476],{"class":377},[262,809,749],{"class":271},[262,811,608],{"class":429},[262,813,814],{"class":611},"input",[262,816,476],{"class":377},[262,818,819],{"class":429},"texts)\n",[262,821,822,824,827,830,833,836],{"class":181,"line":623},[262,823,573],{"class":377},[262,825,826],{"class":429}," np.array([item.embedding ",[262,828,829],{"class":377},"for",[262,831,832],{"class":429}," item ",[262,834,835],{"class":377},"in",[262,837,838],{"class":429}," resp.data])\n",[262,840,841],{"class":181,"line":634},[262,842,583],{"emptyLinePlaceholder":582},[262,844,846],{"class":181,"line":845},16,[262,847,583],{"emptyLinePlaceholder":582},[262,849,851,854,856,859],{"class":181,"line":850},17,[262,852,853],{"class":429},"chunk_vectors ",[262,855,476],{"class":377},[262,857,858],{"class":429}," embed(chunks)        ",[262,860,861],{"class":291},"# embed the whole knowledge base once\n",[262,863,865,867,870],{"class":181,"line":864},18,[262,866,637],{"class":271},[262,868,869],{"class":429},"(chunk_vectors.shape)           ",[262,871,872],{"class":291},"# e.g. (42, 1536) -> 42 chunks, 1536 numbers each\n",[14,874,875,876,879],{},"The shape tells you everything: one row per chunk, and a fixed number of columns (1536 for ",[18,877,878],{},"text-embedding-3-small",") that is the same for every piece of text. Because the width is fixed, you can compare questions and documents in the same space directly. You can embed a couple of thousand chunks in a single call, far faster than looping one at a time.",[57,881,883],{"id":882},"step-3-search-the-vectors-for-the-question","Step 3: Search the vectors for the question",[14,885,886,887,890,891,894],{},"To answer a question, embed it the same way, then measure which chunk vectors point most nearly the same direction as the question vector. The standard measure is ",[35,888,889],{},"cosine similarity",": it scores two vectors from -1 (opposite) to 1 (identical direction), ignoring their length so longer passages are not unfairly favoured. You keep the highest-scoring ",[18,892,893],{},"top_k"," chunks.",[253,896,898],{"className":414,"code":897,"language":416,"meta":258,"style":258},"def retrieve(question: str, chunks: list[str], chunk_vectors: np.ndarray,\n             top_k: int = 3) -> list[str]:\n    \"\"\"Return the top_k chunks most similar to the question.\"\"\"\n    q = embed([question])[0]\n    # cosine similarity = dot product of length-normalised vectors\n    scores = chunk_vectors @ q \u002F (\n        np.linalg.norm(chunk_vectors, axis=1) * np.linalg.norm(q)\n    )\n    best = scores.argsort()[::-1][:top_k]   # indices of the highest scores\n    return [chunks[i] for i in best]\n\n\nhits = retrieve(\"How long do I have to return an item?\", chunks, chunk_vectors)\nfor h in hits:\n    print(\"-\", h[:80], \"...\")\n",[18,899,900,920,938,943,958,963,985,1007,1012,1032,1049,1053,1057,1073,1085],{"__ignoreMap":258},[262,901,902,904,907,910,912,915,917],{"class":181,"line":264},[262,903,423],{"class":377},[262,905,906],{"class":267}," retrieve",[262,908,909],{"class":429},"(question: ",[262,911,433],{"class":271},[262,913,914],{"class":429},", chunks: list[",[262,916,433],{"class":271},[262,918,919],{"class":429},"], chunk_vectors: np.ndarray,\n",[262,921,922,925,927,929,932,934,936],{"class":181,"line":282},[262,923,924],{"class":429},"             top_k: ",[262,926,439],{"class":271},[262,928,442],{"class":377},[262,930,931],{"class":271}," 3",[262,933,458],{"class":429},[262,935,433],{"class":271},[262,937,463],{"class":429},[262,939,940],{"class":181,"line":295},[262,941,942],{"class":275},"    \"\"\"Return the top_k chunks most similar to the question.\"\"\"\n",[262,944,945,948,950,953,955],{"class":181,"line":345},[262,946,947],{"class":429},"    q ",[262,949,476],{"class":377},[262,951,952],{"class":429}," embed([question])[",[262,954,102],{"class":271},[262,956,957],{"class":429},"]\n",[262,959,960],{"class":181,"line":492},[262,961,962],{"class":291},"    # cosine similarity = dot product of length-normalised vectors\n",[262,964,965,968,970,973,976,979,982],{"class":181,"line":503},[262,966,967],{"class":429},"    scores ",[262,969,476],{"class":377},[262,971,972],{"class":429}," chunk_vectors ",[262,974,975],{"class":377},"@",[262,977,978],{"class":429}," q ",[262,980,981],{"class":377},"\u002F",[262,983,984],{"class":429}," (\n",[262,986,987,990,993,995,998,1001,1004],{"class":181,"line":521},[262,988,989],{"class":429},"        np.linalg.norm(chunk_vectors, ",[262,991,992],{"class":611},"axis",[262,994,476],{"class":377},[262,996,997],{"class":271},"1",[262,999,1000],{"class":429},") ",[262,1002,1003],{"class":377},"*",[262,1005,1006],{"class":429}," np.linalg.norm(q)\n",[262,1008,1009],{"class":181,"line":537},[262,1010,1011],{"class":429},"    )\n",[262,1013,1014,1017,1019,1022,1024,1026,1029],{"class":181,"line":549},[262,1015,1016],{"class":429},"    best ",[262,1018,476],{"class":377},[262,1020,1021],{"class":429}," scores.argsort()[::",[262,1023,561],{"class":377},[262,1025,997],{"class":271},[262,1027,1028],{"class":429},"][:top_k]   ",[262,1030,1031],{"class":291},"# indices of the highest scores\n",[262,1033,1034,1036,1039,1041,1044,1046],{"class":181,"line":570},[262,1035,573],{"class":377},[262,1037,1038],{"class":429}," [chunks[i] ",[262,1040,829],{"class":377},[262,1042,1043],{"class":429}," i ",[262,1045,835],{"class":377},[262,1047,1048],{"class":429}," best]\n",[262,1050,1051],{"class":181,"line":579},[262,1052,583],{"emptyLinePlaceholder":582},[262,1054,1055],{"class":181,"line":586},[262,1056,583],{"emptyLinePlaceholder":582},[262,1058,1059,1062,1064,1067,1070],{"class":181,"line":591},[262,1060,1061],{"class":429},"hits ",[262,1063,476],{"class":377},[262,1065,1066],{"class":429}," retrieve(",[262,1068,1069],{"class":275},"\"How long do I have to return an item?\"",[262,1071,1072],{"class":429},", chunks, chunk_vectors)\n",[262,1074,1075,1077,1080,1082],{"class":181,"line":623},[262,1076,829],{"class":377},[262,1078,1079],{"class":429}," h ",[262,1081,835],{"class":377},[262,1083,1084],{"class":429}," hits:\n",[262,1086,1087,1090,1092,1095,1098,1101,1104,1107],{"class":181,"line":634},[262,1088,1089],{"class":271},"    print",[262,1091,602],{"class":429},[262,1093,1094],{"class":275},"\"-\"",[262,1096,1097],{"class":429},", h[:",[262,1099,1100],{"class":271},"80",[262,1102,1103],{"class":429},"], ",[262,1105,1106],{"class":275},"\"...\"",[262,1108,660],{"class":429},[14,1110,1111,1114,1115,1118,1119,1121],{},[18,1112,1113],{},"scores.argsort()[::-1]"," sorts the indices from lowest to highest score and then reverses them, so the best matches come first; the ",[18,1116,1117],{},"[:top_k]"," slice keeps only as many as you asked for. This is a brute-force search that compares the question against every chunk. That sounds expensive, but for a few hundred or even a few thousand chunks ",[18,1120,24],{}," does it in a blink. Reach for a dedicated vector store only when your collection grows into the tens of thousands.",[57,1123,1125],{"id":1124},"step-4-inject-the-top-k-chunks-into-the-prompt","Step 4: Inject the top-k chunks into the prompt",[14,1127,1128,1129,1132],{},"The final step joins the retrieved chunks into a context block and pastes it into the system prompt, with a strict instruction to answer ",[27,1130,1131],{},"only"," from that text. This is what turns \"the model's best guess\" into \"an answer grounded in your documents.\"",[253,1134,1136],{"className":414,"code":1135,"language":416,"meta":258,"style":258},"def answer(question: str, chunks: list[str], chunk_vectors: np.ndarray) -> str:\n    context = \"\\n\\n\".join(retrieve(question, chunks, chunk_vectors))\n    response = client.chat.completions.create(\n        model=os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\"),\n        messages=[\n            {\"role\": \"system\", \"content\": (\n                \"You answer questions using ONLY the context below. \"\n                \"If the answer is not in the context, say you don't know.\\n\\n\"\n                f\"Context:\\n{context}\"\n            )},\n            {\"role\": \"user\", \"content\": question},\n        ],\n        temperature=0,\n    )\n    return response.choices[0].message.content\n\n\nprint(answer(\"How long do I have to return an item?\", chunks, chunk_vectors))\n",[18,1137,1138,1161,1180,1190,1211,1221,1243,1248,1258,1276,1281,1299,1304,1316,1320,1332,1336,1340],{"__ignoreMap":258},[262,1139,1140,1142,1145,1147,1149,1151,1153,1156,1158],{"class":181,"line":264},[262,1141,423],{"class":377},[262,1143,1144],{"class":267}," answer",[262,1146,909],{"class":429},[262,1148,433],{"class":271},[262,1150,914],{"class":429},[262,1152,433],{"class":271},[262,1154,1155],{"class":429},"], chunk_vectors: np.ndarray) -> ",[262,1157,433],{"class":271},[262,1159,1160],{"class":429},":\n",[262,1162,1163,1166,1168,1171,1174,1177],{"class":181,"line":282},[262,1164,1165],{"class":429},"    context ",[262,1167,476],{"class":377},[262,1169,1170],{"class":275}," \"",[262,1172,1173],{"class":271},"\\n\\n",[262,1175,1176],{"class":275},"\"",[262,1178,1179],{"class":429},".join(retrieve(question, chunks, chunk_vectors))\n",[262,1181,1182,1185,1187],{"class":181,"line":295},[262,1183,1184],{"class":429},"    response ",[262,1186,476],{"class":377},[262,1188,1189],{"class":429}," client.chat.completions.create(\n",[262,1191,1192,1195,1197,1200,1203,1205,1208],{"class":181,"line":345},[262,1193,1194],{"class":611},"        model",[262,1196,476],{"class":377},[262,1198,1199],{"class":429},"os.getenv(",[262,1201,1202],{"class":275},"\"CHAT_MODEL\"",[262,1204,608],{"class":429},[262,1206,1207],{"class":275},"\"gpt-4o-mini\"",[262,1209,1210],{"class":429},"),\n",[262,1212,1213,1216,1218],{"class":181,"line":492},[262,1214,1215],{"class":611},"        messages",[262,1217,476],{"class":377},[262,1219,1220],{"class":429},"[\n",[262,1222,1223,1226,1229,1232,1235,1237,1240],{"class":181,"line":503},[262,1224,1225],{"class":429},"            {",[262,1227,1228],{"class":275},"\"role\"",[262,1230,1231],{"class":429},": ",[262,1233,1234],{"class":275},"\"system\"",[262,1236,608],{"class":429},[262,1238,1239],{"class":275},"\"content\"",[262,1241,1242],{"class":429},": (\n",[262,1244,1245],{"class":181,"line":521},[262,1246,1247],{"class":275},"                \"You answer questions using ONLY the context below. \"\n",[262,1249,1250,1253,1255],{"class":181,"line":537},[262,1251,1252],{"class":275},"                \"If the answer is not in the context, say you don't know.",[262,1254,1173],{"class":271},[262,1256,1257],{"class":275},"\"\n",[262,1259,1260,1263,1266,1269,1272,1274],{"class":181,"line":549},[262,1261,1262],{"class":377},"                f",[262,1264,1265],{"class":275},"\"Context:",[262,1267,1268],{"class":271},"\\n{",[262,1270,1271],{"class":429},"context",[262,1273,654],{"class":271},[262,1275,1257],{"class":275},[262,1277,1278],{"class":181,"line":570},[262,1279,1280],{"class":429},"            )},\n",[262,1282,1283,1285,1287,1289,1292,1294,1296],{"class":181,"line":579},[262,1284,1225],{"class":429},[262,1286,1228],{"class":275},[262,1288,1231],{"class":429},[262,1290,1291],{"class":275},"\"user\"",[262,1293,608],{"class":429},[262,1295,1239],{"class":275},[262,1297,1298],{"class":429},": question},\n",[262,1300,1301],{"class":181,"line":586},[262,1302,1303],{"class":429},"        ],\n",[262,1305,1306,1309,1311,1313],{"class":181,"line":591},[262,1307,1308],{"class":611},"        temperature",[262,1310,476],{"class":377},[262,1312,102],{"class":271},[262,1314,1315],{"class":429},",\n",[262,1317,1318],{"class":181,"line":623},[262,1319,1011],{"class":429},[262,1321,1322,1324,1327,1329],{"class":181,"line":634},[262,1323,573],{"class":377},[262,1325,1326],{"class":429}," response.choices[",[262,1328,102],{"class":271},[262,1330,1331],{"class":429},"].message.content\n",[262,1333,1334],{"class":181,"line":845},[262,1335,583],{"emptyLinePlaceholder":582},[262,1337,1338],{"class":181,"line":850},[262,1339,583],{"emptyLinePlaceholder":582},[262,1341,1342,1344,1347,1349],{"class":181,"line":864},[262,1343,637],{"class":271},[262,1345,1346],{"class":429},"(answer(",[262,1348,1069],{"class":275},[262,1350,1351],{"class":429},", chunks, chunk_vectors))\n",[14,1353,1354,1355,1358,1359,1363],{},"Two details carry the reliability. First, ",[18,1356,1357],{},"temperature=0"," makes the model deterministic and factual rather than creative — exactly what you want when it should be reading off your documents. Second, the \"answer ONLY from the context\" instruction is the single line that stops most invented answers; without it the model will happily blend your context with whatever it half-remembers. Building strict, format-controlling system prompts is a skill in itself, covered in ",[51,1360,1362],{"href":1361},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fwrite-system-prompts-that-control-output-format\u002F","Write System Prompts that Control Output Format",".",[57,1365,1367],{"id":1366},"parameter-quick-reference","Parameter quick reference",[14,1369,1370,1371,1374,1375,1377],{},"These three knobs control retrieval quality. Tune ",[18,1372,1373],{},"chunk_size"," and ",[18,1376,893],{}," first; the embedding model rarely needs changing.",[1379,1380,1381,1397],"table",{},[1382,1383,1384],"thead",{},[1385,1386,1387,1391,1394],"tr",{},[1388,1389,1390],"th",{},"Parameter",[1388,1392,1393],{},"Typical value",[1388,1395,1396],{},"Effect",[1398,1399,1400,1413,1425],"tbody",{},[1385,1401,1402,1407,1410],{},[1403,1404,1405],"td",{},[18,1406,1373],{},[1403,1408,1409],{},"200-500 words",[1403,1411,1412],{},"Smaller chunks give sharper, more precise matches; larger chunks keep related sentences together but dilute relevance.",[1385,1414,1415,1419,1422],{},[1403,1416,1417],{},[18,1418,893],{},[1403,1420,1421],{},"3-5 chunks",[1403,1423,1424],{},"How many passages to inject. More gives the model fuller context but costs tokens and can bury the key fact; fewer is cheaper but risks missing the answer.",[1385,1426,1427,1431,1435],{},[1403,1428,1429],{},[18,1430,749],{},[1403,1432,1433],{},[18,1434,878],{},[1403,1436,1437,1438,1441],{},"The cheap, fast default fits almost every use. Switch to ",[18,1439,1440],{},"text-embedding-3-large"," only if matches are noticeably weak and the extra cost is justified.",[57,1443,1445],{"id":1444},"troubleshooting","Troubleshooting",[1447,1448,1449,1465,1487,1493],"ol",{},[1450,1451,1452,1455,1456,1458,1459,1461,1462,1464],"li",{},[35,1453,1454],{},"The bot answers \"I don't know\" when the answer clearly exists"," — Retrieval missed the right chunk. Cause: chunks too large so the relevant fact was diluted, or ",[18,1457,893],{}," too low. Fix: shrink ",[18,1460,1373],{}," toward 200 words and raise ",[18,1463,893],{}," to 5, then print the retrieved chunks to confirm the fact is in them.",[1450,1466,1467,1476,1477,1479,1480,1482,1483,1486],{},[35,1468,1469,1472,1473],{},[18,1470,1471],{},"openai.BadRequestError"," about input length on ",[18,1474,1475],{},"embeddings.create"," — A single chunk is too long for the embedding model. Cause: a document with no whitespace or an overly large ",[18,1478,1373],{},". Fix: lower ",[18,1481,1373],{},", and confirm ",[18,1484,1485],{},"chunk_text"," actually split the text rather than returning one giant chunk.",[1450,1488,1489,1492],{},[35,1490,1491],{},"All similarity scores look almost identical"," — Your chunks are too similar or too generic to tell apart. Cause: boilerplate text repeated across passages, or chunks so large every one touches every topic. Fix: chunk more finely and strip repeated headers or footers before embedding.",[1450,1494,1495,1500,1501,1503,1504,1506,1507,1509,1510,1363],{},[35,1496,1497],{},[18,1498,1499],{},"BadRequestError: maximum context length"," when answering — The injected context plus the question is too long for the chat model. Cause: ",[18,1502,893],{}," too high or chunks too large. Fix: lower ",[18,1505,893],{},", shrink ",[18,1508,1373],{},", or read ",[51,1511,1513],{"href":1512},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-context-length-exceeded-error-in-python\u002F","Fix the Context-Length-Exceeded Error in Python",[57,1515,1517],{"id":1516},"worked-example-a-runnable-rag-bot","Worked example: a runnable RAG bot",[14,1519,1520,1521,1524,1525,1527,1528,1363],{},"This script ties all four steps into one program. It chunks a small in-line knowledge base, embeds it once, then answers questions from the terminal grounded in those chunks. Save it as ",[18,1522,1523],{},"rag_bot.py",", make sure your ",[18,1526,319],{}," is in place, and run ",[18,1529,1530],{},"python rag_bot.py",[253,1532,1534],{"className":414,"code":1533,"language":416,"meta":258,"style":258},"import os\nimport numpy as np\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(timeout=20.0)\nCHAT_MODEL = os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\")\nEMBED_MODEL = os.getenv(\"EMBED_MODEL\", \"text-embedding-3-small\")\n\n# A tiny knowledge base. In a real app, load these from your files.\nDOCUMENTS = \"\"\"\nReturns are accepted within 30 days of purchase with a valid receipt.\nRefunds are issued to the original payment method within 5 business days.\nKids' helmets are available in red, blue, and matte black, sizes XS to L.\nFree local delivery applies to all orders over $75 within the city.\nOur workshop offers free safety checks every Saturday from 9am to noon.\n\"\"\"\n\n\ndef chunk_text(text: str, chunk_size: int = 60, overlap: int = 10) -> list[str]:\n    words = text.split()\n    chunks, start = [], 0\n    while start \u003C len(words):\n        chunks.append(\" \".join(words[start:start + chunk_size]))\n        start += chunk_size - overlap\n    return chunks\n\n\ndef embed(texts: list[str]) -> np.ndarray:\n    resp = client.embeddings.create(model=EMBED_MODEL, input=texts)\n    return np.array([item.embedding for item in resp.data])\n\n\nCHUNKS = chunk_text(DOCUMENTS)          # split the knowledge base\nCHUNK_VECTORS = embed(CHUNKS)           # embed it once at startup\n\n\ndef retrieve(question: str, top_k: int = 3) -> str:\n    q = embed([question])[0]\n    scores = CHUNK_VECTORS @ q \u002F (np.linalg.norm(CHUNK_VECTORS, axis=1) * np.linalg.norm(q))\n    best = scores.argsort()[::-1][:top_k]\n    return \"\\n\\n\".join(CHUNKS[i] for i in best)\n\n\ndef answer(question: str) -> str:\n    context = retrieve(question)\n    response = client.chat.completions.create(\n        model=CHAT_MODEL,\n        messages=[\n            {\"role\": \"system\", \"content\": \"Answer using ONLY the context below. \"\n             \"If it is not in the context, say you don't know.\\n\\nContext:\\n\" + context},\n            {\"role\": \"user\", \"content\": question},\n        ],\n        temperature=0,\n    )\n    return response.choices[0].message.content\n\n\nif __name__ == \"__main__\":\n    print(\"Docs bot ready. Type 'quit' to exit.\")\n    while True:\n        msg = input(\"You: \").strip()\n        if msg.lower() in {\"quit\", \"exit\"}:\n            break\n        print(\"Bot:\", answer(msg))\n",[18,1535,1536,1542,1552,1562,1572,1576,1580,1599,1616,1632,1636,1641,1651,1656,1661,1666,1671,1676,1681,1686,1691,1726,1735,1749,1762,1777,1791,1798,1803,1808,1821,1844,1859,1864,1869,1888,1907,1912,1917,1944,1957,1994,2010,2038,2043,2048,2065,2075,2084,2095,2104,2124,2146,2163,2168,2179,2184,2195,2200,2205,2222,2234,2244,2263,2288,2294],{"__ignoreMap":258},[262,1537,1538,1540],{"class":181,"line":264},[262,1539,684],{"class":377},[262,1541,687],{"class":429},[262,1543,1544,1546,1548,1550],{"class":181,"line":282},[262,1545,684],{"class":377},[262,1547,694],{"class":429},[262,1549,697],{"class":377},[262,1551,700],{"class":429},[262,1553,1554,1556,1558,1560],{"class":181,"line":295},[262,1555,705],{"class":377},[262,1557,708],{"class":429},[262,1559,684],{"class":377},[262,1561,713],{"class":429},[262,1563,1564,1566,1568,1570],{"class":181,"line":345},[262,1565,705],{"class":377},[262,1567,720],{"class":429},[262,1569,684],{"class":377},[262,1571,725],{"class":429},[262,1573,1574],{"class":181,"line":492},[262,1575,583],{"emptyLinePlaceholder":582},[262,1577,1578],{"class":181,"line":503},[262,1579,734],{"class":429},[262,1581,1582,1584,1586,1589,1592,1594,1597],{"class":181,"line":521},[262,1583,739],{"class":429},[262,1585,476],{"class":377},[262,1587,1588],{"class":429}," OpenAI(",[262,1590,1591],{"class":611},"timeout",[262,1593,476],{"class":377},[262,1595,1596],{"class":271},"20.0",[262,1598,660],{"class":429},[262,1600,1601,1604,1606,1608,1610,1612,1614],{"class":181,"line":537},[262,1602,1603],{"class":271},"CHAT_MODEL",[262,1605,442],{"class":377},[262,1607,754],{"class":429},[262,1609,1202],{"class":275},[262,1611,608],{"class":429},[262,1613,1207],{"class":275},[262,1615,660],{"class":429},[262,1617,1618,1620,1622,1624,1626,1628,1630],{"class":181,"line":549},[262,1619,749],{"class":271},[262,1621,442],{"class":377},[262,1623,754],{"class":429},[262,1625,757],{"class":275},[262,1627,608],{"class":429},[262,1629,762],{"class":275},[262,1631,660],{"class":429},[262,1633,1634],{"class":181,"line":570},[262,1635,583],{"emptyLinePlaceholder":582},[262,1637,1638],{"class":181,"line":579},[262,1639,1640],{"class":291},"# A tiny knowledge base. In a real app, load these from your files.\n",[262,1642,1643,1646,1648],{"class":181,"line":586},[262,1644,1645],{"class":271},"DOCUMENTS",[262,1647,442],{"class":377},[262,1649,1650],{"class":275}," \"\"\"\n",[262,1652,1653],{"class":181,"line":591},[262,1654,1655],{"class":275},"Returns are accepted within 30 days of purchase with a valid receipt.\n",[262,1657,1658],{"class":181,"line":623},[262,1659,1660],{"class":275},"Refunds are issued to the original payment method within 5 business days.\n",[262,1662,1663],{"class":181,"line":634},[262,1664,1665],{"class":275},"Kids' helmets are available in red, blue, and matte black, sizes XS to L.\n",[262,1667,1668],{"class":181,"line":845},[262,1669,1670],{"class":275},"Free local delivery applies to all orders over $75 within the city.\n",[262,1672,1673],{"class":181,"line":850},[262,1674,1675],{"class":275},"Our workshop offers free safety checks every Saturday from 9am to noon.\n",[262,1677,1678],{"class":181,"line":864},[262,1679,1680],{"class":275},"\"\"\"\n",[262,1682,1684],{"class":181,"line":1683},19,[262,1685,583],{"emptyLinePlaceholder":582},[262,1687,1689],{"class":181,"line":1688},20,[262,1690,583],{"emptyLinePlaceholder":582},[262,1692,1694,1696,1698,1700,1702,1704,1706,1708,1711,1713,1715,1717,1720,1722,1724],{"class":181,"line":1693},21,[262,1695,423],{"class":377},[262,1697,426],{"class":267},[262,1699,430],{"class":429},[262,1701,433],{"class":271},[262,1703,436],{"class":429},[262,1705,439],{"class":271},[262,1707,442],{"class":377},[262,1709,1710],{"class":271}," 60",[262,1712,448],{"class":429},[262,1714,439],{"class":271},[262,1716,442],{"class":377},[262,1718,1719],{"class":271}," 10",[262,1721,458],{"class":429},[262,1723,433],{"class":271},[262,1725,463],{"class":429},[262,1727,1729,1731,1733],{"class":181,"line":1728},22,[262,1730,473],{"class":429},[262,1732,476],{"class":377},[262,1734,479],{"class":429},[262,1736,1738,1741,1743,1746],{"class":181,"line":1737},23,[262,1739,1740],{"class":429},"    chunks, start ",[262,1742,476],{"class":377},[262,1744,1745],{"class":429}," [], ",[262,1747,1748],{"class":271},"0\n",[262,1750,1752,1754,1756,1758,1760],{"class":181,"line":1751},24,[262,1753,506],{"class":377},[262,1755,509],{"class":429},[262,1757,512],{"class":377},[262,1759,515],{"class":271},[262,1761,518],{"class":429},[262,1763,1765,1767,1769,1772,1774],{"class":181,"line":1764},25,[262,1766,540],{"class":429},[262,1768,543],{"class":275},[262,1770,1771],{"class":429},".join(words[start:start ",[262,1773,531],{"class":377},[262,1775,1776],{"class":429}," chunk_size]))\n",[262,1778,1780,1782,1784,1786,1788],{"class":181,"line":1779},26,[262,1781,552],{"class":429},[262,1783,555],{"class":377},[262,1785,558],{"class":429},[262,1787,561],{"class":377},[262,1789,1790],{"class":429}," overlap\n",[262,1792,1794,1796],{"class":181,"line":1793},27,[262,1795,573],{"class":377},[262,1797,576],{"class":429},[262,1799,1801],{"class":181,"line":1800},28,[262,1802,583],{"emptyLinePlaceholder":582},[262,1804,1806],{"class":181,"line":1805},29,[262,1807,583],{"emptyLinePlaceholder":582},[262,1809,1811,1813,1815,1817,1819],{"class":181,"line":1810},30,[262,1812,423],{"class":377},[262,1814,779],{"class":267},[262,1816,782],{"class":429},[262,1818,433],{"class":271},[262,1820,787],{"class":429},[262,1822,1824,1826,1828,1830,1832,1834,1836,1838,1840,1842],{"class":181,"line":1823},31,[262,1825,797],{"class":429},[262,1827,476],{"class":377},[262,1829,802],{"class":429},[262,1831,805],{"class":611},[262,1833,476],{"class":377},[262,1835,749],{"class":271},[262,1837,608],{"class":429},[262,1839,814],{"class":611},[262,1841,476],{"class":377},[262,1843,819],{"class":429},[262,1845,1847,1849,1851,1853,1855,1857],{"class":181,"line":1846},32,[262,1848,573],{"class":377},[262,1850,826],{"class":429},[262,1852,829],{"class":377},[262,1854,832],{"class":429},[262,1856,835],{"class":377},[262,1858,838],{"class":429},[262,1860,1862],{"class":181,"line":1861},33,[262,1863,583],{"emptyLinePlaceholder":582},[262,1865,1867],{"class":181,"line":1866},34,[262,1868,583],{"emptyLinePlaceholder":582},[262,1870,1872,1875,1877,1880,1882,1885],{"class":181,"line":1871},35,[262,1873,1874],{"class":271},"CHUNKS",[262,1876,442],{"class":377},[262,1878,1879],{"class":429}," chunk_text(",[262,1881,1645],{"class":271},[262,1883,1884],{"class":429},")          ",[262,1886,1887],{"class":291},"# split the knowledge base\n",[262,1889,1891,1894,1896,1899,1901,1904],{"class":181,"line":1890},36,[262,1892,1893],{"class":271},"CHUNK_VECTORS",[262,1895,442],{"class":377},[262,1897,1898],{"class":429}," embed(",[262,1900,1874],{"class":271},[262,1902,1903],{"class":429},")           ",[262,1905,1906],{"class":291},"# embed it once at startup\n",[262,1908,1910],{"class":181,"line":1909},37,[262,1911,583],{"emptyLinePlaceholder":582},[262,1913,1915],{"class":181,"line":1914},38,[262,1916,583],{"emptyLinePlaceholder":582},[262,1918,1920,1922,1924,1926,1928,1931,1933,1935,1937,1940,1942],{"class":181,"line":1919},39,[262,1921,423],{"class":377},[262,1923,906],{"class":267},[262,1925,909],{"class":429},[262,1927,433],{"class":271},[262,1929,1930],{"class":429},", top_k: ",[262,1932,439],{"class":271},[262,1934,442],{"class":377},[262,1936,931],{"class":271},[262,1938,1939],{"class":429},") -> ",[262,1941,433],{"class":271},[262,1943,1160],{"class":429},[262,1945,1947,1949,1951,1953,1955],{"class":181,"line":1946},40,[262,1948,947],{"class":429},[262,1950,476],{"class":377},[262,1952,952],{"class":429},[262,1954,102],{"class":271},[262,1956,957],{"class":429},[262,1958,1960,1962,1964,1967,1970,1972,1974,1977,1979,1981,1983,1985,1987,1989,1991],{"class":181,"line":1959},41,[262,1961,967],{"class":429},[262,1963,476],{"class":377},[262,1965,1966],{"class":271}," CHUNK_VECTORS",[262,1968,1969],{"class":377}," @",[262,1971,978],{"class":429},[262,1973,981],{"class":377},[262,1975,1976],{"class":429}," (np.linalg.norm(",[262,1978,1893],{"class":271},[262,1980,608],{"class":429},[262,1982,992],{"class":611},[262,1984,476],{"class":377},[262,1986,997],{"class":271},[262,1988,1000],{"class":429},[262,1990,1003],{"class":377},[262,1992,1993],{"class":429}," np.linalg.norm(q))\n",[262,1995,1997,1999,2001,2003,2005,2007],{"class":181,"line":1996},42,[262,1998,1016],{"class":429},[262,2000,476],{"class":377},[262,2002,1021],{"class":429},[262,2004,561],{"class":377},[262,2006,997],{"class":271},[262,2008,2009],{"class":429},"][:top_k]\n",[262,2011,2013,2015,2017,2019,2021,2024,2026,2029,2031,2033,2035],{"class":181,"line":2012},43,[262,2014,573],{"class":377},[262,2016,1170],{"class":275},[262,2018,1173],{"class":271},[262,2020,1176],{"class":275},[262,2022,2023],{"class":429},".join(",[262,2025,1874],{"class":271},[262,2027,2028],{"class":429},"[i] ",[262,2030,829],{"class":377},[262,2032,1043],{"class":429},[262,2034,835],{"class":377},[262,2036,2037],{"class":429}," best)\n",[262,2039,2041],{"class":181,"line":2040},44,[262,2042,583],{"emptyLinePlaceholder":582},[262,2044,2046],{"class":181,"line":2045},45,[262,2047,583],{"emptyLinePlaceholder":582},[262,2049,2051,2053,2055,2057,2059,2061,2063],{"class":181,"line":2050},46,[262,2052,423],{"class":377},[262,2054,1144],{"class":267},[262,2056,909],{"class":429},[262,2058,433],{"class":271},[262,2060,1939],{"class":429},[262,2062,433],{"class":271},[262,2064,1160],{"class":429},[262,2066,2068,2070,2072],{"class":181,"line":2067},47,[262,2069,1165],{"class":429},[262,2071,476],{"class":377},[262,2073,2074],{"class":429}," retrieve(question)\n",[262,2076,2078,2080,2082],{"class":181,"line":2077},48,[262,2079,1184],{"class":429},[262,2081,476],{"class":377},[262,2083,1189],{"class":429},[262,2085,2087,2089,2091,2093],{"class":181,"line":2086},49,[262,2088,1194],{"class":611},[262,2090,476],{"class":377},[262,2092,1603],{"class":271},[262,2094,1315],{"class":429},[262,2096,2098,2100,2102],{"class":181,"line":2097},50,[262,2099,1215],{"class":611},[262,2101,476],{"class":377},[262,2103,1220],{"class":429},[262,2105,2107,2109,2111,2113,2115,2117,2119,2121],{"class":181,"line":2106},51,[262,2108,1225],{"class":429},[262,2110,1228],{"class":275},[262,2112,1231],{"class":429},[262,2114,1234],{"class":275},[262,2116,608],{"class":429},[262,2118,1239],{"class":275},[262,2120,1231],{"class":429},[262,2122,2123],{"class":275},"\"Answer using ONLY the context below. \"\n",[262,2125,2127,2130,2132,2135,2138,2140,2143],{"class":181,"line":2126},52,[262,2128,2129],{"class":275},"             \"If it is not in the context, say you don't know.",[262,2131,1173],{"class":271},[262,2133,2134],{"class":275},"Context:",[262,2136,2137],{"class":271},"\\n",[262,2139,1176],{"class":275},[262,2141,2142],{"class":377}," +",[262,2144,2145],{"class":429}," context},\n",[262,2147,2149,2151,2153,2155,2157,2159,2161],{"class":181,"line":2148},53,[262,2150,1225],{"class":429},[262,2152,1228],{"class":275},[262,2154,1231],{"class":429},[262,2156,1291],{"class":275},[262,2158,608],{"class":429},[262,2160,1239],{"class":275},[262,2162,1298],{"class":429},[262,2164,2166],{"class":181,"line":2165},54,[262,2167,1303],{"class":429},[262,2169,2171,2173,2175,2177],{"class":181,"line":2170},55,[262,2172,1308],{"class":611},[262,2174,476],{"class":377},[262,2176,102],{"class":271},[262,2178,1315],{"class":429},[262,2180,2182],{"class":181,"line":2181},56,[262,2183,1011],{"class":429},[262,2185,2187,2189,2191,2193],{"class":181,"line":2186},57,[262,2188,573],{"class":377},[262,2190,1326],{"class":429},[262,2192,102],{"class":271},[262,2194,1331],{"class":429},[262,2196,2198],{"class":181,"line":2197},58,[262,2199,583],{"emptyLinePlaceholder":582},[262,2201,2203],{"class":181,"line":2202},59,[262,2204,583],{"emptyLinePlaceholder":582},[262,2206,2208,2211,2214,2217,2220],{"class":181,"line":2207},60,[262,2209,2210],{"class":377},"if",[262,2212,2213],{"class":271}," __name__",[262,2215,2216],{"class":377}," ==",[262,2218,2219],{"class":275}," \"__main__\"",[262,2221,1160],{"class":429},[262,2223,2225,2227,2229,2232],{"class":181,"line":2224},61,[262,2226,1089],{"class":271},[262,2228,602],{"class":429},[262,2230,2231],{"class":275},"\"Docs bot ready. Type 'quit' to exit.\"",[262,2233,660],{"class":429},[262,2235,2237,2239,2242],{"class":181,"line":2236},62,[262,2238,506],{"class":377},[262,2240,2241],{"class":271}," True",[262,2243,1160],{"class":429},[262,2245,2247,2250,2252,2255,2257,2260],{"class":181,"line":2246},63,[262,2248,2249],{"class":429},"        msg ",[262,2251,476],{"class":377},[262,2253,2254],{"class":271}," input",[262,2256,602],{"class":429},[262,2258,2259],{"class":275},"\"You: \"",[262,2261,2262],{"class":429},").strip()\n",[262,2264,2266,2269,2272,2274,2277,2280,2282,2285],{"class":181,"line":2265},64,[262,2267,2268],{"class":377},"        if",[262,2270,2271],{"class":429}," msg.lower() ",[262,2273,835],{"class":377},[262,2275,2276],{"class":429}," {",[262,2278,2279],{"class":275},"\"quit\"",[262,2281,608],{"class":429},[262,2283,2284],{"class":275},"\"exit\"",[262,2286,2287],{"class":429},"}:\n",[262,2289,2291],{"class":181,"line":2290},65,[262,2292,2293],{"class":377},"            break\n",[262,2295,2297,2300,2302,2305],{"class":181,"line":2296},66,[262,2298,2299],{"class":271},"        print",[262,2301,602],{"class":429},[262,2303,2304],{"class":275},"\"Bot:\"",[262,2306,2307],{"class":429},", answer(msg))\n",[14,2309,2310,2311,2313],{},"That is a complete RAG chatbot in well under sixty lines, with no vector database and no framework. Swap the in-line ",[18,2312,1645],{}," string for the contents of your real files and you have a bot that answers from your knowledge base.",[57,2315,2317],{"id":2316},"when-to-use-this-vs-alternatives","When to use this vs. alternatives",[14,2319,2320],{},"RAG is one of three ways to make a model speak with your knowledge. Pick by what you actually need to change:",[2322,2323,2324,2333,2343],"ul",{},[1450,2325,2326,2332],{},[35,2327,2328,2329],{},"Use RAG when the model needs your ",[27,2330,2331],{},"facts"," — policies, prices, product details, anything that changes or that the model could not have memorised. It is cheap, updates instantly when you re-embed, and lets the model cite the exact passage it used. This is the right default for documentation and support bots.",[1450,2334,2335,2342],{},[35,2336,2337,2338,2341],{},"Use fine-tuning when you need to change ",[27,2339,2340],{},"style or format",", not facts"," — a consistent brand voice, a strict output shape, or a behaviour the model resists. Fine-tuning bakes that pattern in, but it requires a training run, is awkward to update, and is a poor way to teach facts that change.",[1450,2344,2345,2348],{},[35,2346,2347],{},"Use long context (paste everything) only for small, one-off documents"," — if your whole knowledge fits comfortably in one prompt and rarely changes, skip retrieval and paste it directly. It is the simplest option, but it gets slow and expensive fast and breaks once your documents outgrow the model's context window.",[14,2350,2351],{},"In short: facts that change → RAG; behaviour that persists → fine-tuning; a small fixed document → long context. Most business bots want RAG.",[57,2353,2355],{"id":2354},"next-steps","Next steps",[14,2357,2358,2359,2363,2364,2368,2369,1363],{},"Now that your bot answers from your data, deepen the surrounding chatbot. Make replies appear word by word with ",[51,2360,2362],{"href":2361},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fstream-chatbot-responses-with-python\u002F","Stream Chatbot Responses with Python",", and give it durable, per-user history with ",[51,2365,2367],{"href":2366},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fadd-memory-to-a-python-chatbot\u002F","Add Memory to a Python Chatbot",". To wrap retrieval, memory, and routing in a framework, see ",[51,2370,2372],{"href":2371},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fbuild-a-customer-support-chatbot-with-langchain\u002F","Build a Customer Support Chatbot with LangChain",[14,2374,2375,2376,1363],{},"Back to ",[51,2377,54],{"href":53},[57,2379,2381],{"id":2380},"related-guides","Related guides",[2322,2383,2384,2388,2392,2396],{},[1450,2385,2386],{},[51,2387,54],{"href":53},[1450,2389,2390],{},[51,2391,2367],{"href":2366},[1450,2393,2394],{},[51,2395,2362],{"href":2361},[1450,2397,2398],{},[51,2399,2372],{"href":2371},[2401,2402,2403],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":2405},[2406,2407,2408,2409,2410,2411,2412,2413,2414,2415,2416,2417],{"id":59,"depth":282,"text":60},{"id":237,"depth":282,"text":238},{"id":392,"depth":282,"text":393},{"id":666,"depth":282,"text":667},{"id":882,"depth":282,"text":883},{"id":1124,"depth":282,"text":1125},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":1516,"depth":282,"text":1517},{"id":2316,"depth":282,"text":2317},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Build a Python RAG pipeline: chunk docs, create OpenAI embeddings, search vectors with numpy, and inject top-k context into the prompt so your bot answers from your data.","md",[2421,2424,2427,2430,2433],{"q":2422,"a":2423},"What is RAG in simple terms?","RAG stands for Retrieval-Augmented Generation. Before the model answers, your code searches your own documents for the few passages most relevant to the question and pastes them into the prompt. The model then answers from that supplied text instead of guessing from general knowledge.",{"q":2425,"a":2426},"Do I need a vector database to use RAG?","No. For a few hundred passages a plain numpy array of embeddings and a cosine-similarity search is fast and simple. You only need a dedicated vector database once you have thousands of documents or need to update them while the app is running.",{"q":2428,"a":2429},"Why does the chatbot still make things up after I add RAG?","Usually the retrieved context did not actually contain the answer, or the system prompt did not forbid outside knowledge. Confirm your search returns relevant chunks and instruct the model to answer only from the context and to say it does not know otherwise.",{"q":2431,"a":2432},"How big should each document chunk be?","Aim for roughly 200 to 500 words per chunk with a small overlap between neighbours. Smaller chunks give sharper matches but more fragments to manage; larger chunks keep context together but dilute the match. Start around 300 words and adjust if answers feel incomplete.",{"q":2434,"a":2435},"Is RAG cheaper than fine-tuning a model?","For most teams, yes. RAG needs no training run and you update knowledge by editing files and re-embedding them, which costs fractions of a cent. Fine-tuning changes the model's style or format but is poorly suited to teaching it new facts that change often.",{"name":2437,"steps":2438},"How to connect a chatbot to your docs with RAG",[2439,2442,2445,2448],{"name":2440,"text":2441},"Split your documents into chunks","Break long documents into overlapping passages of a few hundred words each so every match returns a focused, self-contained snippet.",{"name":2443,"text":2444},"Create embeddings for each chunk","Send every chunk to the OpenAI embeddings endpoint to turn it into a numeric vector that captures its meaning.",{"name":2446,"text":2447},"Search the vectors for the question","Embed the user's question and use cosine similarity to find the chunks whose vectors point most nearly the same way.",{"name":2449,"text":2450},"Inject the top-k chunks into the prompt","Paste the best-matching chunks into the system prompt as context and tell the model to answer only from that text.",{},"2026-06-18",false,"\u002Fog-image.png","\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fconnect-a-chatbot-to-your-docs-with-rag",{"title":5,"description":2418},"building-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fconnect-a-chatbot-to-your-docs-with-rag\u002Findex","gYTvfrKx8GHX0rp80RMcZIpm5dPgJZhQpJcuAbi5Tvg",[2460,4073,5391,7025,9393,11061,12721,15715,17748,19797,22651,24304,26530,28960,30293,32846,35137,37017,39773,41150,42918,44585,46216,48014,50666,52252,54204,55582,57265,59609,61111,63419,66011,67172,69425,71328,73675,75152,76307,77077,77999,78847,80800,82378,83642,85943,87691,89475,90992,92888],{"id":2461,"title":2462,"body":2463,"description":4033,"extension":2419,"faq":4034,"howto":4050,"meta":4068,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":4069,"published":2452,"seo":4070,"seoTitle":2462,"stem":4071,"__hash__":4072},"content\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fbulk-rewrite-product-descriptions-with-python\u002Findex.md","Bulk-Rewrite Product Descriptions with Python",{"type":7,"value":2464,"toc":4021},[2465,2468,2471,2474,2476,2489,2503,2523,2529,2537,2545,2559,2574,2581,2585,2595,2699,2715,2719,2722,2909,2920,2924,2929,2932,3347,3358,3362,3373,3380,3688,3698,3702,3711,3770,3780,3782,3785,3888,3890,3910,3930,3945,3958,3960,3985,3992,3994,4018],[10,2466,2462],{"id":2467},"bulk-rewrite-product-descriptions-with-python",[14,2469,2470],{},"This guide shows you how to take a spreadsheet of product descriptions, rewrite every one of them with AI to match a target tone, length, and SEO style, and save the results back to a CSV — all in under fifteen minutes. If you run an online store with hundreds of products, you already know the pain: descriptions copied from a supplier, written by three different people over two years, or simply too thin to rank in search. Rewriting them by hand is a week of work. A short Python script can do the whole catalogue while you make coffee.",[14,2472,2473],{},"A CSV (short for \"comma-separated values\") is just a plain-text spreadsheet — the format every store platform, from Shopify to WooCommerce, can export and import. We will read one, send each row's description to a language model, and write the polished version into a new column.",[57,2475,238],{"id":237},[14,2477,2478,2479,2483,2484,2488],{},"You only need a few things beyond a working Python setup. If Python itself is new to you, start with ",[51,2480,2482],{"href":2481},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fcreate-a-python-virtual-environment-for-ai\u002F","Create a Python Virtual Environment for AI"," so the packages below stay isolated from the rest of your machine. You will also need an OpenAI account with a funded API key; the broader ",[51,2485,2487],{"href":2486},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002F","Understanding LLM APIs"," section explains how keys and billing work if this is your first time.",[14,2490,2491,2492,2495,2496,2498,2499,2502],{},"Install the three packages this script uses. ",[18,2493,2494],{},"pandas"," handles the spreadsheet, ",[18,2497,20],{}," talks to the model, and ",[18,2500,2501],{},"python-dotenv"," keeps your key out of your code.",[253,2504,2506],{"className":255,"code":2505,"language":257,"meta":258,"style":258},"pip install pandas openai python-dotenv\n",[18,2507,2508],{"__ignoreMap":258},[262,2509,2510,2512,2514,2517,2520],{"class":181,"line":264},[262,2511,298],{"class":267},[262,2513,301],{"class":275},[262,2515,2516],{"class":275}," pandas",[262,2518,2519],{"class":275}," openai",[262,2521,2522],{"class":275}," python-dotenv\n",[14,2524,2525,2526,2528],{},"Create a file named ",[18,2527,319],{}," in the same folder as your script and put your key inside it:",[253,2530,2531],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,2532,2533],{"__ignoreMap":258},[262,2534,2535],{"class":181,"line":264},[262,2536,337],{},[14,2538,2539,2540,356,2542,2544],{},"Now add ",[18,2541,319],{},[18,2543,359],{}," file so the key never gets committed to version control or shared by accident — this single line saves a lot of regret:",[253,2546,2547],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,2548,2549],{"__ignoreMap":258},[262,2550,2551,2553,2555,2557],{"class":181,"line":264},[262,2552,371],{"class":271},[262,2554,374],{"class":275},[262,2556,378],{"class":377},[262,2558,381],{"class":275},[14,2560,2561,2562,2565,2566,2569,2570,2573],{},"Finally, you need an input file. The script assumes a CSV with at least a ",[18,2563,2564],{},"product_name"," column and a ",[18,2567,2568],{},"description"," column. A tiny example, ",[18,2571,2572],{},"products.csv",", looks like this:",[253,2575,2579],{"className":2576,"code":2578,"language":111,"meta":258},[2577],"language-text","product_name,description\nStainless Travel Mug,keeps drinks hot. 16oz. dishwasher safe.\nBamboo Cutting Board,wood board for kitchen. medium size.\nWool Hiking Socks,warm socks for hiking trips good quality.\n",[18,2580,2578],{"__ignoreMap":258},[57,2582,2584],{"id":2583},"step-1-load-your-api-key-and-the-openai-client","Step 1: Load your API key and the OpenAI client",[14,2586,2587,2588,2591,2592,2594],{},"Start a file called ",[18,2589,2590],{},"rewrite_descriptions.py",". The first job is to load your key and create the OpenAI client — the object that sends requests to the model. Loading the key from ",[18,2593,319],{}," means the key lives outside your code, exactly where it belongs.",[253,2596,2598],{"className":414,"code":2597,"language":416,"meta":258,"style":258},"import os\nimport time\nimport pandas as pd\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\nfrom openai import APIError, RateLimitError, APITimeoutError\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nMODEL = \"gpt-4o-mini\"\n",[18,2599,2600,2606,2613,2625,2635,2645,2656,2660,2664,2685,2689],{"__ignoreMap":258},[262,2601,2602,2604],{"class":181,"line":264},[262,2603,684],{"class":377},[262,2605,687],{"class":429},[262,2607,2608,2610],{"class":181,"line":282},[262,2609,684],{"class":377},[262,2611,2612],{"class":429}," time\n",[262,2614,2615,2617,2620,2622],{"class":181,"line":295},[262,2616,684],{"class":377},[262,2618,2619],{"class":429}," pandas ",[262,2621,697],{"class":377},[262,2623,2624],{"class":429}," pd\n",[262,2626,2627,2629,2631,2633],{"class":181,"line":345},[262,2628,705],{"class":377},[262,2630,708],{"class":429},[262,2632,684],{"class":377},[262,2634,713],{"class":429},[262,2636,2637,2639,2641,2643],{"class":181,"line":492},[262,2638,705],{"class":377},[262,2640,720],{"class":429},[262,2642,684],{"class":377},[262,2644,725],{"class":429},[262,2646,2647,2649,2651,2653],{"class":181,"line":503},[262,2648,705],{"class":377},[262,2650,720],{"class":429},[262,2652,684],{"class":377},[262,2654,2655],{"class":429}," APIError, RateLimitError, APITimeoutError\n",[262,2657,2658],{"class":181,"line":521},[262,2659,583],{"emptyLinePlaceholder":582},[262,2661,2662],{"class":181,"line":537},[262,2663,734],{"class":429},[262,2665,2666,2668,2670,2672,2675,2677,2679,2682],{"class":181,"line":549},[262,2667,739],{"class":429},[262,2669,476],{"class":377},[262,2671,1588],{"class":429},[262,2673,2674],{"class":611},"api_key",[262,2676,476],{"class":377},[262,2678,1199],{"class":429},[262,2680,2681],{"class":275},"\"OPENAI_API_KEY\"",[262,2683,2684],{"class":429},"))\n",[262,2686,2687],{"class":181,"line":570},[262,2688,583],{"emptyLinePlaceholder":582},[262,2690,2691,2694,2696],{"class":181,"line":579},[262,2692,2693],{"class":271},"MODEL",[262,2695,442],{"class":377},[262,2697,2698],{"class":275}," \"gpt-4o-mini\"\n",[14,2700,2701,2704,2705,608,2708,608,2711,2714],{},[18,2702,2703],{},"gpt-4o-mini"," is the workhorse here: cheap, fast, and more than capable of polishing product copy. We import three specific error types (",[18,2706,2707],{},"RateLimitError",[18,2709,2710],{},"APITimeoutError",[18,2712,2713],{},"APIError",") so the retry logic in Step 3 can react to the failures that actually happen in bulk jobs.",[57,2716,2718],{"id":2717},"step-2-load-and-inspect-your-product-csv","Step 2: Load and inspect your product CSV",[14,2720,2721],{},"Read the spreadsheet into a pandas DataFrame — think of a DataFrame as the spreadsheet held in memory, with rows and named columns you can loop over. Before processing anything, confirm the columns are what you expect. A two-second check here prevents rewriting the wrong field.",[253,2723,2725],{"className":414,"code":2724,"language":416,"meta":258,"style":258},"INPUT_FILE = \"products.csv\"\nDESCRIPTION_COLUMN = \"description\"\n\ndf = pd.read_csv(INPUT_FILE)\nprint(f\"Loaded {len(df)} products\")\nprint(\"Columns found:\", list(df.columns))\n\nif DESCRIPTION_COLUMN not in df.columns:\n    raise ValueError(\n        f\"Column '{DESCRIPTION_COLUMN}' not found. \"\n        f\"Available columns: {list(df.columns)}\"\n    )\n\n# Create the output column up front, filled with empty strings.\nif \"rewritten\" not in df.columns:\n    df[\"rewritten\"] = \"\"\n",[18,2726,2727,2737,2747,2751,2765,2788,2805,2809,2825,2836,2850,2867,2871,2875,2880,2893],{"__ignoreMap":258},[262,2728,2729,2732,2734],{"class":181,"line":264},[262,2730,2731],{"class":271},"INPUT_FILE",[262,2733,442],{"class":377},[262,2735,2736],{"class":275}," \"products.csv\"\n",[262,2738,2739,2742,2744],{"class":181,"line":282},[262,2740,2741],{"class":271},"DESCRIPTION_COLUMN",[262,2743,442],{"class":377},[262,2745,2746],{"class":275}," \"description\"\n",[262,2748,2749],{"class":181,"line":295},[262,2750,583],{"emptyLinePlaceholder":582},[262,2752,2753,2756,2758,2761,2763],{"class":181,"line":345},[262,2754,2755],{"class":429},"df ",[262,2757,476],{"class":377},[262,2759,2760],{"class":429}," pd.read_csv(",[262,2762,2731],{"class":271},[262,2764,660],{"class":429},[262,2766,2767,2769,2771,2773,2776,2778,2781,2783,2786],{"class":181,"line":492},[262,2768,637],{"class":271},[262,2770,602],{"class":429},[262,2772,642],{"class":377},[262,2774,2775],{"class":275},"\"Loaded ",[262,2777,648],{"class":271},[262,2779,2780],{"class":429},"(df)",[262,2782,654],{"class":271},[262,2784,2785],{"class":275}," products\"",[262,2787,660],{"class":429},[262,2789,2790,2792,2794,2797,2799,2802],{"class":181,"line":503},[262,2791,637],{"class":271},[262,2793,602],{"class":429},[262,2795,2796],{"class":275},"\"Columns found:\"",[262,2798,608],{"class":429},[262,2800,2801],{"class":271},"list",[262,2803,2804],{"class":429},"(df.columns))\n",[262,2806,2807],{"class":181,"line":521},[262,2808,583],{"emptyLinePlaceholder":582},[262,2810,2811,2813,2816,2819,2822],{"class":181,"line":537},[262,2812,2210],{"class":377},[262,2814,2815],{"class":271}," DESCRIPTION_COLUMN",[262,2817,2818],{"class":377}," not",[262,2820,2821],{"class":377}," in",[262,2823,2824],{"class":429}," df.columns:\n",[262,2826,2827,2830,2833],{"class":181,"line":549},[262,2828,2829],{"class":377},"    raise",[262,2831,2832],{"class":271}," ValueError",[262,2834,2835],{"class":429},"(\n",[262,2837,2838,2841,2844,2847],{"class":181,"line":570},[262,2839,2840],{"class":377},"        f",[262,2842,2843],{"class":275},"\"Column '",[262,2845,2846],{"class":271},"{DESCRIPTION_COLUMN}",[262,2848,2849],{"class":275},"' not found. \"\n",[262,2851,2852,2854,2857,2860,2863,2865],{"class":181,"line":579},[262,2853,2840],{"class":377},[262,2855,2856],{"class":275},"\"Available columns: ",[262,2858,2859],{"class":271},"{list",[262,2861,2862],{"class":429},"(df.columns)",[262,2864,654],{"class":271},[262,2866,1257],{"class":275},[262,2868,2869],{"class":181,"line":586},[262,2870,1011],{"class":429},[262,2872,2873],{"class":181,"line":591},[262,2874,583],{"emptyLinePlaceholder":582},[262,2876,2877],{"class":181,"line":623},[262,2878,2879],{"class":291},"# Create the output column up front, filled with empty strings.\n",[262,2881,2882,2884,2887,2889,2891],{"class":181,"line":634},[262,2883,2210],{"class":377},[262,2885,2886],{"class":275}," \"rewritten\"",[262,2888,2818],{"class":377},[262,2890,2821],{"class":377},[262,2892,2824],{"class":429},[262,2894,2895,2898,2901,2904,2906],{"class":181,"line":845},[262,2896,2897],{"class":429},"    df[",[262,2899,2900],{"class":275},"\"rewritten\"",[262,2902,2903],{"class":429},"] ",[262,2905,476],{"class":377},[262,2907,2908],{"class":275}," \"\"\n",[14,2910,2911,2912,2915,2916,1363],{},"Creating the ",[18,2913,2914],{},"rewritten"," column now matters for one reason: it lets us skip rows that already have a result if the script is re-run after a crash. That is the foundation of the resumable batching you will add in Step 4. If your raw data is messy — missing values, stray HTML, odd encodings — clean it first with the techniques in ",[51,2917,2919],{"href":2918},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai\u002Fcleaning-csv-data-with-pandas-for-ai\u002F","Cleaning CSV Data with Pandas for AI",[57,2921,2923],{"id":2922},"step-3-write-the-rewrite-function-with-retries","Step 3: Write the rewrite function with retries",[14,2925,2926,2927,1363],{},"This is the core of the script. The function takes one product name and one rough description and returns a polished rewrite. The instructions live in the system prompt — the standing brief that tells the model who it is and how to behave. Spelling out tone, length, and SEO rules here is what turns a generic paraphrase into on-brand copy. If you want to go deeper on shaping output, see ",[51,2928,1362],{"href":1361},[14,2930,2931],{},"Bulk jobs hit transient errors — a brief rate limit, a timed-out request — that succeed instantly on a second try. We wrap the call in a retry loop with exponential backoff, meaning each failed attempt waits longer than the last (2 seconds, then 4, then 8) before trying again. That spacing gives the API room to recover instead of hammering it.",[253,2933,2935],{"className":414,"code":2934,"language":416,"meta":258,"style":258},"SYSTEM_PROMPT = (\n    \"You are an expert e-commerce copywriter. Rewrite the product \"\n    \"description to be persuasive, scannable, and SEO-friendly. \"\n    \"Use a confident, friendly tone. Keep it between 40 and 70 words. \"\n    \"Lead with the main benefit, then list two concrete features. \"\n    \"Naturally include the product name once. Return only the rewritten \"\n    \"description as plain text, with no labels, quotes, or headings.\"\n)\n\n\ndef rewrite_one(product_name: str, description: str, max_retries: int = 4) -> str:\n    user_prompt = (\n        f\"Product name: {product_name}\\n\"\n        f\"Original description: {description}\"\n    )\n\n    for attempt in range(max_retries):\n        try:\n            response = client.chat.completions.create(\n                model=MODEL,\n                messages=[\n                    {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n                    {\"role\": \"user\", \"content\": user_prompt},\n                ],\n                temperature=0.4,\n                max_tokens=200,\n            )\n            return response.choices[0].message.content.strip()\n\n        except (RateLimitError, APITimeoutError, APIError) as err:\n            wait = 2 ** (attempt + 1)\n            print(f\"  Attempt {attempt + 1} failed ({type(err).__name__}). \"\n                  f\"Retrying in {wait}s...\")\n            time.sleep(wait)\n\n    raise RuntimeError(f\"Gave up rewriting '{product_name}' after {max_retries} tries\")\n",[18,2936,2937,2946,2951,2956,2961,2966,2971,2976,2980,2984,2988,3021,3030,3047,3062,3066,3070,3086,3093,3102,3113,3122,3144,3161,3166,3178,3189,3194,3206,3210,3223,3246,3283,3303,3308,3312],{"__ignoreMap":258},[262,2938,2939,2942,2944],{"class":181,"line":264},[262,2940,2941],{"class":271},"SYSTEM_PROMPT",[262,2943,442],{"class":377},[262,2945,984],{"class":429},[262,2947,2948],{"class":181,"line":282},[262,2949,2950],{"class":275},"    \"You are an expert e-commerce copywriter. Rewrite the product \"\n",[262,2952,2953],{"class":181,"line":295},[262,2954,2955],{"class":275},"    \"description to be persuasive, scannable, and SEO-friendly. \"\n",[262,2957,2958],{"class":181,"line":345},[262,2959,2960],{"class":275},"    \"Use a confident, friendly tone. Keep it between 40 and 70 words. \"\n",[262,2962,2963],{"class":181,"line":492},[262,2964,2965],{"class":275},"    \"Lead with the main benefit, then list two concrete features. \"\n",[262,2967,2968],{"class":181,"line":503},[262,2969,2970],{"class":275},"    \"Naturally include the product name once. Return only the rewritten \"\n",[262,2972,2973],{"class":181,"line":521},[262,2974,2975],{"class":275},"    \"description as plain text, with no labels, quotes, or headings.\"\n",[262,2977,2978],{"class":181,"line":537},[262,2979,660],{"class":429},[262,2981,2982],{"class":181,"line":549},[262,2983,583],{"emptyLinePlaceholder":582},[262,2985,2986],{"class":181,"line":570},[262,2987,583],{"emptyLinePlaceholder":582},[262,2989,2990,2992,2995,2998,3000,3003,3005,3008,3010,3012,3015,3017,3019],{"class":181,"line":579},[262,2991,423],{"class":377},[262,2993,2994],{"class":267}," rewrite_one",[262,2996,2997],{"class":429},"(product_name: ",[262,2999,433],{"class":271},[262,3001,3002],{"class":429},", description: ",[262,3004,433],{"class":271},[262,3006,3007],{"class":429},", max_retries: ",[262,3009,439],{"class":271},[262,3011,442],{"class":377},[262,3013,3014],{"class":271}," 4",[262,3016,1939],{"class":429},[262,3018,433],{"class":271},[262,3020,1160],{"class":429},[262,3022,3023,3026,3028],{"class":181,"line":586},[262,3024,3025],{"class":429},"    user_prompt ",[262,3027,476],{"class":377},[262,3029,984],{"class":429},[262,3031,3032,3034,3037,3040,3042,3045],{"class":181,"line":591},[262,3033,2840],{"class":377},[262,3035,3036],{"class":275},"\"Product name: ",[262,3038,3039],{"class":271},"{",[262,3041,2564],{"class":429},[262,3043,3044],{"class":271},"}\\n",[262,3046,1257],{"class":275},[262,3048,3049,3051,3054,3056,3058,3060],{"class":181,"line":623},[262,3050,2840],{"class":377},[262,3052,3053],{"class":275},"\"Original description: ",[262,3055,3039],{"class":271},[262,3057,2568],{"class":429},[262,3059,654],{"class":271},[262,3061,1257],{"class":275},[262,3063,3064],{"class":181,"line":634},[262,3065,1011],{"class":429},[262,3067,3068],{"class":181,"line":845},[262,3069,583],{"emptyLinePlaceholder":582},[262,3071,3072,3075,3078,3080,3083],{"class":181,"line":850},[262,3073,3074],{"class":377},"    for",[262,3076,3077],{"class":429}," attempt ",[262,3079,835],{"class":377},[262,3081,3082],{"class":271}," range",[262,3084,3085],{"class":429},"(max_retries):\n",[262,3087,3088,3091],{"class":181,"line":864},[262,3089,3090],{"class":377},"        try",[262,3092,1160],{"class":429},[262,3094,3095,3098,3100],{"class":181,"line":1683},[262,3096,3097],{"class":429},"            response ",[262,3099,476],{"class":377},[262,3101,1189],{"class":429},[262,3103,3104,3107,3109,3111],{"class":181,"line":1688},[262,3105,3106],{"class":611},"                model",[262,3108,476],{"class":377},[262,3110,2693],{"class":271},[262,3112,1315],{"class":429},[262,3114,3115,3118,3120],{"class":181,"line":1693},[262,3116,3117],{"class":611},"                messages",[262,3119,476],{"class":377},[262,3121,1220],{"class":429},[262,3123,3124,3127,3129,3131,3133,3135,3137,3139,3141],{"class":181,"line":1728},[262,3125,3126],{"class":429},"                    {",[262,3128,1228],{"class":275},[262,3130,1231],{"class":429},[262,3132,1234],{"class":275},[262,3134,608],{"class":429},[262,3136,1239],{"class":275},[262,3138,1231],{"class":429},[262,3140,2941],{"class":271},[262,3142,3143],{"class":429},"},\n",[262,3145,3146,3148,3150,3152,3154,3156,3158],{"class":181,"line":1737},[262,3147,3126],{"class":429},[262,3149,1228],{"class":275},[262,3151,1231],{"class":429},[262,3153,1291],{"class":275},[262,3155,608],{"class":429},[262,3157,1239],{"class":275},[262,3159,3160],{"class":429},": user_prompt},\n",[262,3162,3163],{"class":181,"line":1751},[262,3164,3165],{"class":429},"                ],\n",[262,3167,3168,3171,3173,3176],{"class":181,"line":1764},[262,3169,3170],{"class":611},"                temperature",[262,3172,476],{"class":377},[262,3174,3175],{"class":271},"0.4",[262,3177,1315],{"class":429},[262,3179,3180,3183,3185,3187],{"class":181,"line":1779},[262,3181,3182],{"class":611},"                max_tokens",[262,3184,476],{"class":377},[262,3186,104],{"class":271},[262,3188,1315],{"class":429},[262,3190,3191],{"class":181,"line":1793},[262,3192,3193],{"class":429},"            )\n",[262,3195,3196,3199,3201,3203],{"class":181,"line":1800},[262,3197,3198],{"class":377},"            return",[262,3200,1326],{"class":429},[262,3202,102],{"class":271},[262,3204,3205],{"class":429},"].message.content.strip()\n",[262,3207,3208],{"class":181,"line":1805},[262,3209,583],{"emptyLinePlaceholder":582},[262,3211,3212,3215,3218,3220],{"class":181,"line":1810},[262,3213,3214],{"class":377},"        except",[262,3216,3217],{"class":429}," (RateLimitError, APITimeoutError, APIError) ",[262,3219,697],{"class":377},[262,3221,3222],{"class":429}," err:\n",[262,3224,3225,3228,3230,3233,3236,3239,3241,3244],{"class":181,"line":1823},[262,3226,3227],{"class":429},"            wait ",[262,3229,476],{"class":377},[262,3231,3232],{"class":271}," 2",[262,3234,3235],{"class":377}," **",[262,3237,3238],{"class":429}," (attempt ",[262,3240,531],{"class":377},[262,3242,3243],{"class":271}," 1",[262,3245,660],{"class":429},[262,3247,3248,3251,3253,3255,3258,3260,3263,3265,3268,3271,3274,3277,3280],{"class":181,"line":1846},[262,3249,3250],{"class":271},"            print",[262,3252,602],{"class":429},[262,3254,642],{"class":377},[262,3256,3257],{"class":275},"\"  Attempt ",[262,3259,3039],{"class":271},[262,3261,3262],{"class":429},"attempt ",[262,3264,531],{"class":377},[262,3266,3267],{"class":271}," 1}",[262,3269,3270],{"class":275}," failed (",[262,3272,3273],{"class":271},"{type",[262,3275,3276],{"class":429},"(err).",[262,3278,3279],{"class":271},"__name__}",[262,3281,3282],{"class":275},"). \"\n",[262,3284,3285,3288,3291,3293,3296,3298,3301],{"class":181,"line":1861},[262,3286,3287],{"class":377},"                  f",[262,3289,3290],{"class":275},"\"Retrying in ",[262,3292,3039],{"class":271},[262,3294,3295],{"class":429},"wait",[262,3297,654],{"class":271},[262,3299,3300],{"class":275},"s...\"",[262,3302,660],{"class":429},[262,3304,3305],{"class":181,"line":1866},[262,3306,3307],{"class":429},"            time.sleep(wait)\n",[262,3309,3310],{"class":181,"line":1871},[262,3311,583],{"emptyLinePlaceholder":582},[262,3313,3314,3316,3319,3321,3323,3326,3328,3330,3332,3335,3337,3340,3342,3345],{"class":181,"line":1890},[262,3315,2829],{"class":377},[262,3317,3318],{"class":271}," RuntimeError",[262,3320,602],{"class":429},[262,3322,642],{"class":377},[262,3324,3325],{"class":275},"\"Gave up rewriting '",[262,3327,3039],{"class":271},[262,3329,2564],{"class":429},[262,3331,654],{"class":271},[262,3333,3334],{"class":275},"' after ",[262,3336,3039],{"class":271},[262,3338,3339],{"class":429},"max_retries",[262,3341,654],{"class":271},[262,3343,3344],{"class":275}," tries\"",[262,3346,660],{"class":429},[14,3348,3349,3350,3353,3354,3357],{},"The ",[18,3351,3352],{},"temperature=0.4"," keeps rewrites consistent across your catalogue, and ",[18,3355,3356],{},"max_tokens=200"," caps the length and cost of each reply. Adjust both in the quick-reference table below.",[57,3359,3361],{"id":3360},"step-4-batch-through-every-row-and-save-checkpoints","Step 4: Batch through every row and save checkpoints",[14,3363,3364,3365,3368,3369,3372],{},"Now loop over the DataFrame. Two ideas make this safe for large files. First, ",[35,3366,3367],{},"skip rows that already have a rewrite",", so a re-run picks up exactly where a crash left off instead of paying to redo finished work. Second, ",[35,3370,3371],{},"save a checkpoint every few rows",", so progress is written to disk as you go rather than only at the very end.",[14,3374,3375,3376,1363],{},"A short pause between requests keeps you comfortably under the rate limit. If you do hit limits often, the fix is the same approach taught in ",[51,3377,3379],{"href":3378},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-429-rate-limit-error-in-python\u002F","Fix the 429 Rate-Limit Error in Python",[253,3381,3383],{"className":414,"code":3382,"language":416,"meta":258,"style":258},"CHECKPOINT_FILE = \"products_checkpoint.csv\"\nCHECKPOINT_EVERY = 10   # save to disk after this many rewrites\nPAUSE_SECONDS = 0.5     # gentle spacing between requests\n\nprocessed = 0\nfor index, row in df.iterrows():\n    # Skip rows that already have a rewrite (resume after a crash).\n    if str(row[\"rewritten\"]).strip():\n        continue\n\n    name = str(row.get(\"product_name\", \"this product\"))\n    original = str(row[DESCRIPTION_COLUMN])\n\n    print(f\"[{index + 1}\u002F{len(df)}] Rewriting: {name}\")\n    df.at[index, \"rewritten\"] = rewrite_one(name, original)\n    processed += 1\n\n    if processed % CHECKPOINT_EVERY == 0:\n        df.to_csv(CHECKPOINT_FILE, index=False)\n        print(f\"  Checkpoint saved ({processed} rewritten so far)\")\n\n    time.sleep(PAUSE_SECONDS)\n\nprint(f\"Done. Rewrote {processed} descriptions this run.\")\n",[18,3384,3385,3395,3407,3420,3424,3433,3445,3450,3466,3471,3475,3497,3513,3517,3559,3573,3583,3587,3607,3626,3649,3653,3662,3666],{"__ignoreMap":258},[262,3386,3387,3390,3392],{"class":181,"line":264},[262,3388,3389],{"class":271},"CHECKPOINT_FILE",[262,3391,442],{"class":377},[262,3393,3394],{"class":275}," \"products_checkpoint.csv\"\n",[262,3396,3397,3400,3402,3404],{"class":181,"line":282},[262,3398,3399],{"class":271},"CHECKPOINT_EVERY",[262,3401,442],{"class":377},[262,3403,1719],{"class":271},[262,3405,3406],{"class":291},"   # save to disk after this many rewrites\n",[262,3408,3409,3412,3414,3417],{"class":181,"line":295},[262,3410,3411],{"class":271},"PAUSE_SECONDS",[262,3413,442],{"class":377},[262,3415,3416],{"class":271}," 0.5",[262,3418,3419],{"class":291},"     # gentle spacing between requests\n",[262,3421,3422],{"class":181,"line":345},[262,3423,583],{"emptyLinePlaceholder":582},[262,3425,3426,3429,3431],{"class":181,"line":492},[262,3427,3428],{"class":429},"processed ",[262,3430,476],{"class":377},[262,3432,500],{"class":271},[262,3434,3435,3437,3440,3442],{"class":181,"line":503},[262,3436,829],{"class":377},[262,3438,3439],{"class":429}," index, row ",[262,3441,835],{"class":377},[262,3443,3444],{"class":429}," df.iterrows():\n",[262,3446,3447],{"class":181,"line":521},[262,3448,3449],{"class":291},"    # Skip rows that already have a rewrite (resume after a crash).\n",[262,3451,3452,3455,3458,3461,3463],{"class":181,"line":537},[262,3453,3454],{"class":377},"    if",[262,3456,3457],{"class":271}," str",[262,3459,3460],{"class":429},"(row[",[262,3462,2900],{"class":275},[262,3464,3465],{"class":429},"]).strip():\n",[262,3467,3468],{"class":181,"line":549},[262,3469,3470],{"class":377},"        continue\n",[262,3472,3473],{"class":181,"line":570},[262,3474,583],{"emptyLinePlaceholder":582},[262,3476,3477,3480,3482,3484,3487,3490,3492,3495],{"class":181,"line":579},[262,3478,3479],{"class":429},"    name ",[262,3481,476],{"class":377},[262,3483,3457],{"class":271},[262,3485,3486],{"class":429},"(row.get(",[262,3488,3489],{"class":275},"\"product_name\"",[262,3491,608],{"class":429},[262,3493,3494],{"class":275},"\"this product\"",[262,3496,2684],{"class":429},[262,3498,3499,3502,3504,3506,3508,3510],{"class":181,"line":586},[262,3500,3501],{"class":429},"    original ",[262,3503,476],{"class":377},[262,3505,3457],{"class":271},[262,3507,3460],{"class":429},[262,3509,2741],{"class":271},[262,3511,3512],{"class":429},"])\n",[262,3514,3515],{"class":181,"line":591},[262,3516,583],{"emptyLinePlaceholder":582},[262,3518,3519,3521,3523,3525,3528,3530,3533,3535,3537,3539,3541,3543,3545,3548,3550,3553,3555,3557],{"class":181,"line":623},[262,3520,1089],{"class":271},[262,3522,602],{"class":429},[262,3524,642],{"class":377},[262,3526,3527],{"class":275},"\"[",[262,3529,3039],{"class":271},[262,3531,3532],{"class":429},"index ",[262,3534,531],{"class":377},[262,3536,3267],{"class":271},[262,3538,981],{"class":275},[262,3540,648],{"class":271},[262,3542,2780],{"class":429},[262,3544,654],{"class":271},[262,3546,3547],{"class":275},"] Rewriting: ",[262,3549,3039],{"class":271},[262,3551,3552],{"class":429},"name",[262,3554,654],{"class":271},[262,3556,1176],{"class":275},[262,3558,660],{"class":429},[262,3560,3561,3564,3566,3568,3570],{"class":181,"line":634},[262,3562,3563],{"class":429},"    df.at[index, ",[262,3565,2900],{"class":275},[262,3567,2903],{"class":429},[262,3569,476],{"class":377},[262,3571,3572],{"class":429}," rewrite_one(name, original)\n",[262,3574,3575,3578,3580],{"class":181,"line":845},[262,3576,3577],{"class":429},"    processed ",[262,3579,555],{"class":377},[262,3581,3582],{"class":271}," 1\n",[262,3584,3585],{"class":181,"line":850},[262,3586,583],{"emptyLinePlaceholder":582},[262,3588,3589,3591,3594,3597,3600,3602,3605],{"class":181,"line":864},[262,3590,3454],{"class":377},[262,3592,3593],{"class":429}," processed ",[262,3595,3596],{"class":377},"%",[262,3598,3599],{"class":271}," CHECKPOINT_EVERY",[262,3601,2216],{"class":377},[262,3603,3604],{"class":271}," 0",[262,3606,1160],{"class":429},[262,3608,3609,3612,3614,3616,3619,3621,3624],{"class":181,"line":1683},[262,3610,3611],{"class":429},"        df.to_csv(",[262,3613,3389],{"class":271},[262,3615,608],{"class":429},[262,3617,3618],{"class":611},"index",[262,3620,476],{"class":377},[262,3622,3623],{"class":271},"False",[262,3625,660],{"class":429},[262,3627,3628,3630,3632,3634,3637,3639,3642,3644,3647],{"class":181,"line":1688},[262,3629,2299],{"class":271},[262,3631,602],{"class":429},[262,3633,642],{"class":377},[262,3635,3636],{"class":275},"\"  Checkpoint saved (",[262,3638,3039],{"class":271},[262,3640,3641],{"class":429},"processed",[262,3643,654],{"class":271},[262,3645,3646],{"class":275}," rewritten so far)\"",[262,3648,660],{"class":429},[262,3650,3651],{"class":181,"line":1693},[262,3652,583],{"emptyLinePlaceholder":582},[262,3654,3655,3658,3660],{"class":181,"line":1728},[262,3656,3657],{"class":429},"    time.sleep(",[262,3659,3411],{"class":271},[262,3661,660],{"class":429},[262,3663,3664],{"class":181,"line":1737},[262,3665,583],{"emptyLinePlaceholder":582},[262,3667,3668,3670,3672,3674,3677,3679,3681,3683,3686],{"class":181,"line":1751},[262,3669,637],{"class":271},[262,3671,602],{"class":429},[262,3673,642],{"class":377},[262,3675,3676],{"class":275},"\"Done. Rewrote ",[262,3678,3039],{"class":271},[262,3680,3641],{"class":429},[262,3682,654],{"class":271},[262,3684,3685],{"class":275}," descriptions this run.\"",[262,3687,660],{"class":429},[14,3689,3690,3691,3693,3694,3697],{},"Because finished rewrites are saved into the ",[18,3692,2914],{}," column and checkpointed to disk, you can stop the script at any time with ",[18,3695,3696],{},"Ctrl+C"," and run it again later — it will resume from the first unwritten row.",[57,3699,3701],{"id":3700},"step-5-write-the-finished-results-back-to-csv","Step 5: Write the finished results back to CSV",[14,3703,3704,3705,3707,3708,3710],{},"When the loop finishes, save the complete DataFrame to a clean output file. Keeping the original ",[18,3706,2568],{}," column alongside the new ",[18,3709,2914],{}," column lets you compare the two before you import anything into your store.",[253,3712,3714],{"className":414,"code":3713,"language":416,"meta":258,"style":258},"OUTPUT_FILE = \"products_rewritten.csv\"\ndf.to_csv(OUTPUT_FILE, index=False)\nprint(f\"Saved {len(df)} products to {OUTPUT_FILE}\")\n",[18,3715,3716,3726,3743],{"__ignoreMap":258},[262,3717,3718,3721,3723],{"class":181,"line":264},[262,3719,3720],{"class":271},"OUTPUT_FILE",[262,3722,442],{"class":377},[262,3724,3725],{"class":275}," \"products_rewritten.csv\"\n",[262,3727,3728,3731,3733,3735,3737,3739,3741],{"class":181,"line":282},[262,3729,3730],{"class":429},"df.to_csv(",[262,3732,3720],{"class":271},[262,3734,608],{"class":429},[262,3736,3618],{"class":611},[262,3738,476],{"class":377},[262,3740,3623],{"class":271},[262,3742,660],{"class":429},[262,3744,3745,3747,3749,3751,3754,3756,3758,3760,3763,3766,3768],{"class":181,"line":295},[262,3746,637],{"class":271},[262,3748,602],{"class":429},[262,3750,642],{"class":377},[262,3752,3753],{"class":275},"\"Saved ",[262,3755,648],{"class":271},[262,3757,2780],{"class":429},[262,3759,654],{"class":271},[262,3761,3762],{"class":275}," products to ",[262,3764,3765],{"class":271},"{OUTPUT_FILE}",[262,3767,1176],{"class":275},[262,3769,660],{"class":429},[14,3771,3772,3773,3776,3777,3779],{},"Open ",[18,3774,3775],{},"products_rewritten.csv"," in any spreadsheet app and spot-check a dozen rows. When you are happy, map the ",[18,3778,2914],{}," column to your platform's description field and import. That is the whole pipeline: read, rewrite, save.",[57,3781,1367],{"id":1366},[14,3783,3784],{},"These are the knobs you will actually turn. Everything else can stay at its default.",[1379,3786,3787,3801],{},[1382,3788,3789],{},[1385,3790,3791,3793,3796,3799],{},[1388,3792,1390],{},[1388,3794,3795],{},"Type",[1388,3797,3798],{},"Default",[1388,3800,1396],{},[1398,3802,3803,3823,3840,3856,3872],{},[1385,3804,3805,3809,3812,3816],{},[1403,3806,3807],{},[18,3808,2693],{},[1403,3810,3811],{},"string",[1403,3813,3814],{},[18,3815,2703],{},[1403,3817,3818,3819,3822],{},"Which model rewrites the copy. Use ",[18,3820,3821],{},"gpt-4o"," for higher-stakes or technical products.",[1385,3824,3825,3830,3833,3837],{},[1403,3826,3827],{},[18,3828,3829],{},"temperature",[1403,3831,3832],{},"float",[1403,3834,3835],{},[18,3836,3175],{},[1403,3838,3839],{},"Creativity. Lower is more consistent and on-brand; higher adds variety between products.",[1385,3841,3842,3847,3849,3853],{},[1403,3843,3844],{},[18,3845,3846],{},"max_tokens",[1403,3848,439],{},[1403,3850,3851],{},[18,3852,104],{},[1403,3854,3855],{},"Caps reply length and cost. Roughly 150 tokens covers a 70-word description with headroom.",[1385,3857,3858,3862,3864,3869],{},[1403,3859,3860],{},[18,3861,3399],{},[1403,3863,439],{},[1403,3865,3866],{},[18,3867,3868],{},"10",[1403,3870,3871],{},"How many rewrites between disk saves. Lower it for very long runs to lose less on a crash.",[1385,3873,3874,3878,3880,3885],{},[1403,3875,3876],{},[18,3877,3411],{},[1403,3879,3832],{},[1403,3881,3882],{},[18,3883,3884],{},"0.5",[1403,3886,3887],{},"Delay between requests. Raise it if you hit rate limits on a low usage tier.",[57,3889,1445],{"id":1444},[14,3891,3892,3899,3900,3902,3903,3906,3907,3909],{},[35,3893,3894,3895,3898],{},"1. ",[18,3896,3897],{},"KeyError"," or \"Column not found\" on startup."," The script cannot find your ",[18,3901,2568],{}," column. Print ",[18,3904,3905],{},"list(df.columns)"," and update the ",[18,3908,2741],{}," variable to match the exact name in your file — watch for capital letters or trailing spaces, which CSV exports love to add.",[14,3911,3912,3915,3916,3918,3919,3921,3922,3925,3926,3929],{},[35,3913,3914],{},"2. Every rewrite comes back wrapped in quotes or with a \"Rewritten:\" label."," The model is being too helpful. Tighten the last line of ",[18,3917,2941],{}," (\"Return only the rewritten description as plain text\") and lower ",[18,3920,3829],{}," to ",[18,3923,3924],{},"0.3",". As a backstop, add ",[18,3927,3928],{},".strip('\"')"," to the returned text.",[14,3931,3932,3938,3939,3941,3942,3944],{},[35,3933,3934,3935,1363],{},"3. The script keeps retrying and finally raises ",[18,3936,3937],{},"RuntimeError"," Persistent failure usually means an authentication problem, not a transient one. Confirm your ",[18,3940,319],{}," key is correct and funded — the steps in ",[51,3943,388],{"href":387}," walk through the exact checks.",[14,3946,3947,3950,3951,3953,3954,3957],{},[35,3948,3949],{},"4. Rewrites are far too long or get cut off mid-sentence."," A cut-off reply means ",[18,3952,3846],{}," is too low for your target length; raise it to ",[18,3955,3956],{},"250",". If they are simply long, the model is ignoring the word count — restate the limit as a hard rule (\"Never exceed 70 words\") near the start of the system prompt, where it carries more weight.",[57,3959,2317],{"id":2316},[2322,3961,3962,3968,3974],{},[1450,3963,3964,3967],{},[35,3965,3966],{},"Use this script when you own a real catalogue."," Once you have more than a few dozen products, batching plus resumable checkpoints saves both hours and the cost of redoing work after a crash. It is the right tool for a one-time cleanup or a quarterly refresh of existing copy.",[1450,3969,3970,3973],{},[35,3971,3972],{},"Reach for a chat UI instead for one or two products."," If you only need to polish a handful of descriptions, pasting them into ChatGPT by hand is faster than wiring up a CSV. The script earns its keep through volume.",[1450,3975,3976,3979,3980,3984],{},[35,3977,3978],{},"Build a generation flow, not a rewrite flow, for net-new copy."," If you are writing descriptions from scratch rather than improving existing ones, the longer-form approach in ",[51,3981,3983],{"href":3982},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-blog-posts-with-openai-api\u002F","Generate Blog Posts with the OpenAI API"," is a closer fit. Rewriting shines when there is an \"original\" to improve on.",[14,3986,3987,3988,1363],{},"Once your descriptions are sharp, the same read-loop-write pattern powers the rest of your marketing: tune the prompt and you can turn the same product data into newsletters, ad copy, or meta tags. Back to ",[51,3989,3991],{"href":3990},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002F","AI Copywriting Workflows",[57,3993,2381],{"id":2380},[2322,3995,3996,4001,4006,4013],{},[1450,3997,3998,4000],{},[51,3999,3991],{"href":3990}," — the main guide tying these copy-automation tasks together.",[1450,4002,4003,4005],{},[51,4004,3983],{"href":3982}," — produce long-form articles from a single keyword.",[1450,4007,4008,4012],{},[51,4009,4011],{"href":4010},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-email-newsletters-with-python-and-ai\u002F","Generate Email Newsletters with Python and AI"," — turn product and content data into ready-to-send emails.",[1450,4014,4015,4017],{},[51,4016,2919],{"href":2918}," — prep messy spreadsheets before you feed them to a model.",[2401,4019,4020],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":258,"searchDepth":282,"depth":282,"links":4022},[4023,4024,4025,4026,4027,4028,4029,4030,4031,4032],{"id":237,"depth":282,"text":238},{"id":2583,"depth":282,"text":2584},{"id":2717,"depth":282,"text":2718},{"id":2922,"depth":282,"text":2923},{"id":3360,"depth":282,"text":3361},{"id":3700,"depth":282,"text":3701},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Read a CSV of product descriptions, rewrite each with the OpenAI API for tone, length, and SEO, then save the results back to CSV — with batching and retries.",[4035,4038,4041,4044,4047],{"q":4036,"a":4037},"How many product descriptions can I rewrite at once?","There is no hard limit in the script. The batching loop processes rows one at a time and saves progress periodically, so a CSV with thousands of rows works fine. Your real limits are your OpenAI usage budget and the rate limit on your account tier.",{"q":4039,"a":4040},"Which OpenAI model is best for rewriting product copy?","gpt-4o-mini is the best starting point because it is cheap and fast enough for bulk work while still writing fluent copy. Move up to gpt-4o only if you find the smaller model misses brand nuance or struggles with technical products.",{"q":4042,"a":4043},"Will the rewrites be the same every time I run the script?","Not exactly. Language models add small variations between runs. Set temperature low (around 0.3 to 0.5) for more consistent, predictable copy, or raise it toward 0.8 if you want more creative variety across products.",{"q":4045,"a":4046},"How do I keep my OpenAI API key safe in this script?","Store the key in a .env file and load it with python-dotenv, never paste it directly into your code. Add .env to your .gitignore so the key is never committed to version control or shared by accident.",{"q":4048,"a":4049},"What happens if the script crashes halfway through a large file?","The script writes a checkpoint CSV every few rows, so finished rewrites are saved to disk as it goes. When you re-run it, rows that already have a rewrite are skipped, so you only pay for and process the remaining descriptions.",{"name":4051,"steps":4052},"How to bulk-rewrite product descriptions with Python",[4053,4056,4059,4062,4065],{"name":4054,"text":4055},"Install the tools and load your API key","Install pandas, openai, and python-dotenv, then load your OpenAI key from a .env file.",{"name":4057,"text":4058},"Load and inspect your product CSV","Read the CSV into a pandas DataFrame and confirm the column that holds your descriptions.",{"name":4060,"text":4061},"Write the rewrite function with retries","Build a function that sends one description to the OpenAI API and retries on transient errors.",{"name":4063,"text":4064},"Batch through every row and save checkpoints","Loop over the DataFrame in batches, rewriting each row and saving progress to disk periodically.",{"name":4066,"text":4067},"Write the finished results back to CSV","Save the DataFrame with the new rewritten column to a clean output CSV ready for your store.",{},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fbulk-rewrite-product-descriptions-with-python",{"title":2462,"description":4033},"ai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fbulk-rewrite-product-descriptions-with-python\u002Findex","42WqQv2oD8zblO7oC5nKLKSPxseU31dPJlqeOWjLgLA",{"id":4074,"title":3983,"body":4075,"description":5350,"extension":2419,"faq":5351,"howto":5367,"meta":5385,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":5386,"published":5387,"seo":5388,"seoTitle":3983,"stem":5389,"__hash__":5390},"content\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-blog-posts-with-openai-api\u002Findex.md",{"type":7,"value":4076,"toc":5338},[4077,4080,4083,4089,4091,4094,4110,4125,4133,4137,4146,4155,4162,4176,4183,4260,4266,4270,4273,4462,4477,4481,4484,4701,4714,4718,4721,4858,4864,4868,4871,5024,5027,5130,5133,5145,5152,5156,5159,5230,5232,5272,5274,5301,5307,5311,5313,5335],[10,4078,3983],{"id":4079},"generate-blog-posts-with-the-openai-api",[14,4081,4082],{},"This guide shows you how to turn a single keyword into a full, edited-ready Markdown blog post in about fifteen minutes, using Python and the OpenAI API. You will build a small script that works in four stages: outline, draft, refine, and save. Splitting the work this way gives you far more reliable results than asking the model to \"write a blog post\" in one shot.",[14,4084,4085,4086,4088],{},"If you have never called an AI model from code before, the ",[51,4087,2487],{"href":2486}," section explains the basics first. Otherwise, read on.",[57,4090,238],{"id":237},[14,4092,4093],{},"You only need three things beyond a working Python 3.10 or newer install:",[2322,4095,4096,4102,4107],{},[1450,4097,4098,4099,1363],{},"An OpenAI account and an API key (a secret string that authorizes your requests). Create one in the OpenAI dashboard under ",[35,4100,4101],{},"API keys",[1450,4103,4104,4105,1363],{},"A folder to work in, with a virtual environment so your packages stay isolated. If you have not made one before, follow ",[51,4106,2482],{"href":2481},[1450,4108,4109],{},"The two packages this script uses, installed into that environment:",[253,4111,4113],{"className":255,"code":4112,"language":257,"meta":258,"style":258},"pip install openai python-dotenv\n",[18,4114,4115],{"__ignoreMap":258},[262,4116,4117,4119,4121,4123],{"class":181,"line":264},[262,4118,298],{"class":267},[262,4120,301],{"class":275},[262,4122,2519],{"class":275},[262,4124,2522],{"class":275},[14,4126,3349,4127,4129,4130,4132],{},[18,4128,20],{}," package is the official SDK (software development kit) that talks to the API. ",[18,4131,2501],{}," loads secrets from a file so you never paste your key directly into code.",[57,4134,4136],{"id":4135},"step-1-set-up-your-environment-and-key","Step 1: Set up your environment and key",[14,4138,4139,4140,4142,4143,4145],{},"Create a file called ",[18,4141,319],{}," in your project folder and put your key inside it. The ",[18,4144,319],{}," file holds secrets that should never appear in your code:",[253,4147,4149],{"className":323,"code":4148,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-real-key-goes-here\n",[18,4150,4151],{"__ignoreMap":258},[262,4152,4153],{"class":181,"line":264},[262,4154,4148],{},[14,4156,2539,4157,356,4159,4161],{},[18,4158,319],{},[18,4160,359],{}," file so the key is never committed to version control or pushed to a public repository:",[253,4163,4164],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,4165,4166],{"__ignoreMap":258},[262,4167,4168,4170,4172,4174],{"class":181,"line":264},[262,4169,371],{"class":271},[262,4171,374],{"class":275},[262,4173,378],{"class":377},[262,4175,381],{"class":275},[14,4177,4178,4179,4182],{},"That one line prevents the most common way beginners accidentally leak a paid API key. Next, create a file called ",[18,4180,4181],{},"blog_writer.py"," and load the key into a client object. The client is the gateway you call for every request:",[253,4184,4186],{"className":414,"code":4185,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # reads the .env file into environment variables\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nMODEL = \"gpt-4o-mini\"  # cheap and fast; swap for \"gpt-4o\" if you need stronger writing\n",[18,4187,4188,4194,4204,4214,4218,4226,4244,4248],{"__ignoreMap":258},[262,4189,4190,4192],{"class":181,"line":264},[262,4191,684],{"class":377},[262,4193,687],{"class":429},[262,4195,4196,4198,4200,4202],{"class":181,"line":282},[262,4197,705],{"class":377},[262,4199,708],{"class":429},[262,4201,684],{"class":377},[262,4203,713],{"class":429},[262,4205,4206,4208,4210,4212],{"class":181,"line":295},[262,4207,705],{"class":377},[262,4209,720],{"class":429},[262,4211,684],{"class":377},[262,4213,725],{"class":429},[262,4215,4216],{"class":181,"line":345},[262,4217,583],{"emptyLinePlaceholder":582},[262,4219,4220,4223],{"class":181,"line":492},[262,4221,4222],{"class":429},"load_dotenv()  ",[262,4224,4225],{"class":291},"# reads the .env file into environment variables\n",[262,4227,4228,4230,4232,4234,4236,4238,4240,4242],{"class":181,"line":503},[262,4229,739],{"class":429},[262,4231,476],{"class":377},[262,4233,1588],{"class":429},[262,4235,2674],{"class":611},[262,4237,476],{"class":377},[262,4239,1199],{"class":429},[262,4241,2681],{"class":275},[262,4243,2684],{"class":429},[262,4245,4246],{"class":181,"line":521},[262,4247,583],{"emptyLinePlaceholder":582},[262,4249,4250,4252,4254,4257],{"class":181,"line":537},[262,4251,2693],{"class":271},[262,4253,442],{"class":377},[262,4255,4256],{"class":275}," \"gpt-4o-mini\"",[262,4258,4259],{"class":291},"  # cheap and fast; swap for \"gpt-4o\" if you need stronger writing\n",[14,4261,4262,4263,4265],{},"If this line raises an authentication error, your key is missing or wrong. The ",[51,4264,388],{"href":387}," guide walks through every cause.",[57,4267,4269],{"id":4268},"step-2-generate-an-outline","Step 2: Generate an outline",[14,4271,4272],{},"A good article needs a skeleton before it needs prose. Asking the model for an outline first means the draft follows a deliberate structure instead of rambling. Add this helper function, which returns a clean list of headings:",[253,4274,4276],{"className":414,"code":4275,"language":416,"meta":258,"style":258},"def generate_outline(topic: str, audience: str) -> str:\n    response = client.chat.completions.create(\n        model=MODEL,\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"You are a content strategist. Reply with a blog post outline \"\n                    \"as a Markdown list of H2 and H3 headings only. No prose.\"\n                ),\n            },\n            {\n                \"role\": \"user\",\n                \"content\": (\n                    f\"Create an outline for a blog post about '{topic}' \"\n                    f\"written for {audience}. Aim for 5 to 7 main sections.\"\n                ),\n            },\n        ],\n        temperature=0.6,\n    )\n    return response.choices[0].message.content\n",[18,4277,4278,4301,4309,4319,4327,4332,4343,4350,4355,4360,4365,4370,4374,4384,4390,4408,4425,4429,4433,4437,4448,4452],{"__ignoreMap":258},[262,4279,4280,4282,4285,4288,4290,4293,4295,4297,4299],{"class":181,"line":264},[262,4281,423],{"class":377},[262,4283,4284],{"class":267}," generate_outline",[262,4286,4287],{"class":429},"(topic: ",[262,4289,433],{"class":271},[262,4291,4292],{"class":429},", audience: ",[262,4294,433],{"class":271},[262,4296,1939],{"class":429},[262,4298,433],{"class":271},[262,4300,1160],{"class":429},[262,4302,4303,4305,4307],{"class":181,"line":282},[262,4304,1184],{"class":429},[262,4306,476],{"class":377},[262,4308,1189],{"class":429},[262,4310,4311,4313,4315,4317],{"class":181,"line":295},[262,4312,1194],{"class":611},[262,4314,476],{"class":377},[262,4316,2693],{"class":271},[262,4318,1315],{"class":429},[262,4320,4321,4323,4325],{"class":181,"line":345},[262,4322,1215],{"class":611},[262,4324,476],{"class":377},[262,4326,1220],{"class":429},[262,4328,4329],{"class":181,"line":492},[262,4330,4331],{"class":429},"            {\n",[262,4333,4334,4337,4339,4341],{"class":181,"line":503},[262,4335,4336],{"class":275},"                \"role\"",[262,4338,1231],{"class":429},[262,4340,1234],{"class":275},[262,4342,1315],{"class":429},[262,4344,4345,4348],{"class":181,"line":521},[262,4346,4347],{"class":275},"                \"content\"",[262,4349,1242],{"class":429},[262,4351,4352],{"class":181,"line":537},[262,4353,4354],{"class":275},"                    \"You are a content strategist. Reply with a blog post outline \"\n",[262,4356,4357],{"class":181,"line":549},[262,4358,4359],{"class":275},"                    \"as a Markdown list of H2 and H3 headings only. No prose.\"\n",[262,4361,4362],{"class":181,"line":570},[262,4363,4364],{"class":429},"                ),\n",[262,4366,4367],{"class":181,"line":579},[262,4368,4369],{"class":429},"            },\n",[262,4371,4372],{"class":181,"line":586},[262,4373,4331],{"class":429},[262,4375,4376,4378,4380,4382],{"class":181,"line":591},[262,4377,4336],{"class":275},[262,4379,1231],{"class":429},[262,4381,1291],{"class":275},[262,4383,1315],{"class":429},[262,4385,4386,4388],{"class":181,"line":623},[262,4387,4347],{"class":275},[262,4389,1242],{"class":429},[262,4391,4392,4395,4398,4400,4403,4405],{"class":181,"line":634},[262,4393,4394],{"class":377},"                    f",[262,4396,4397],{"class":275},"\"Create an outline for a blog post about '",[262,4399,3039],{"class":271},[262,4401,4402],{"class":429},"topic",[262,4404,654],{"class":271},[262,4406,4407],{"class":275},"' \"\n",[262,4409,4410,4412,4415,4417,4420,4422],{"class":181,"line":845},[262,4411,4394],{"class":377},[262,4413,4414],{"class":275},"\"written for ",[262,4416,3039],{"class":271},[262,4418,4419],{"class":429},"audience",[262,4421,654],{"class":271},[262,4423,4424],{"class":275},". Aim for 5 to 7 main sections.\"\n",[262,4426,4427],{"class":181,"line":850},[262,4428,4364],{"class":429},[262,4430,4431],{"class":181,"line":864},[262,4432,4369],{"class":429},[262,4434,4435],{"class":181,"line":1683},[262,4436,1303],{"class":429},[262,4438,4439,4441,4443,4446],{"class":181,"line":1688},[262,4440,1308],{"class":611},[262,4442,476],{"class":377},[262,4444,4445],{"class":271},"0.6",[262,4447,1315],{"class":429},[262,4449,4450],{"class":181,"line":1693},[262,4451,1011],{"class":429},[262,4453,4454,4456,4458,4460],{"class":181,"line":1728},[262,4455,573],{"class":377},[262,4457,1326],{"class":429},[262,4459,102],{"class":271},[262,4461,1331],{"class":429},[14,4463,3349,4464,4467,4468,4471,4472,4474,4475,1363],{},[18,4465,4466],{},"system"," message sets the role and the rules; the ",[18,4469,4470],{},"user"," message carries your specific request. A slightly lower ",[18,4473,3829],{}," (0.6) keeps the outline focused. For more on writing instructions that the model actually obeys, see ",[51,4476,1362],{"href":1361},[57,4478,4480],{"id":4479},"step-3-draft-the-full-post","Step 3: Draft the full post",[14,4482,4483],{},"Now feed the outline back to the model and ask it to expand each heading into a finished article. Passing the outline as context is what keeps the draft on track:",[253,4485,4487],{"className":414,"code":4486,"language":416,"meta":258,"style":258},"def generate_draft(topic: str, audience: str, outline: str) -> str:\n    response = client.chat.completions.create(\n        model=MODEL,\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"You are an expert copywriter. Write in clear, plain English. \"\n                    \"Output valid Markdown using ## and ### headings. \"\n                    \"Start with a one-paragraph introduction and no title.\"\n                ),\n            },\n            {\n                \"role\": \"user\",\n                \"content\": (\n                    f\"Write a 1,200-word blog post about '{topic}' for {audience}. \"\n                    f\"Follow this outline exactly:\\n\\n{outline}\\n\\n\"\n                    \"End with three actionable takeaways as a bullet list.\"\n                ),\n            },\n        ],\n        temperature=0.7,\n        max_tokens=2500,\n    )\n    return response.choices[0].message.content\n",[18,4488,4489,4515,4523,4533,4541,4545,4555,4561,4566,4571,4576,4580,4584,4588,4598,4604,4629,4647,4652,4656,4660,4664,4675,4687,4691],{"__ignoreMap":258},[262,4490,4491,4493,4496,4498,4500,4502,4504,4507,4509,4511,4513],{"class":181,"line":264},[262,4492,423],{"class":377},[262,4494,4495],{"class":267}," generate_draft",[262,4497,4287],{"class":429},[262,4499,433],{"class":271},[262,4501,4292],{"class":429},[262,4503,433],{"class":271},[262,4505,4506],{"class":429},", outline: ",[262,4508,433],{"class":271},[262,4510,1939],{"class":429},[262,4512,433],{"class":271},[262,4514,1160],{"class":429},[262,4516,4517,4519,4521],{"class":181,"line":282},[262,4518,1184],{"class":429},[262,4520,476],{"class":377},[262,4522,1189],{"class":429},[262,4524,4525,4527,4529,4531],{"class":181,"line":295},[262,4526,1194],{"class":611},[262,4528,476],{"class":377},[262,4530,2693],{"class":271},[262,4532,1315],{"class":429},[262,4534,4535,4537,4539],{"class":181,"line":345},[262,4536,1215],{"class":611},[262,4538,476],{"class":377},[262,4540,1220],{"class":429},[262,4542,4543],{"class":181,"line":492},[262,4544,4331],{"class":429},[262,4546,4547,4549,4551,4553],{"class":181,"line":503},[262,4548,4336],{"class":275},[262,4550,1231],{"class":429},[262,4552,1234],{"class":275},[262,4554,1315],{"class":429},[262,4556,4557,4559],{"class":181,"line":521},[262,4558,4347],{"class":275},[262,4560,1242],{"class":429},[262,4562,4563],{"class":181,"line":537},[262,4564,4565],{"class":275},"                    \"You are an expert copywriter. Write in clear, plain English. \"\n",[262,4567,4568],{"class":181,"line":549},[262,4569,4570],{"class":275},"                    \"Output valid Markdown using ## and ### headings. \"\n",[262,4572,4573],{"class":181,"line":570},[262,4574,4575],{"class":275},"                    \"Start with a one-paragraph introduction and no title.\"\n",[262,4577,4578],{"class":181,"line":579},[262,4579,4364],{"class":429},[262,4581,4582],{"class":181,"line":586},[262,4583,4369],{"class":429},[262,4585,4586],{"class":181,"line":591},[262,4587,4331],{"class":429},[262,4589,4590,4592,4594,4596],{"class":181,"line":623},[262,4591,4336],{"class":275},[262,4593,1231],{"class":429},[262,4595,1291],{"class":275},[262,4597,1315],{"class":429},[262,4599,4600,4602],{"class":181,"line":634},[262,4601,4347],{"class":275},[262,4603,1242],{"class":429},[262,4605,4606,4608,4611,4613,4615,4617,4620,4622,4624,4626],{"class":181,"line":845},[262,4607,4394],{"class":377},[262,4609,4610],{"class":275},"\"Write a 1,200-word blog post about '",[262,4612,3039],{"class":271},[262,4614,4402],{"class":429},[262,4616,654],{"class":271},[262,4618,4619],{"class":275},"' for ",[262,4621,3039],{"class":271},[262,4623,4419],{"class":429},[262,4625,654],{"class":271},[262,4627,4628],{"class":275},". \"\n",[262,4630,4631,4633,4636,4639,4642,4645],{"class":181,"line":850},[262,4632,4394],{"class":377},[262,4634,4635],{"class":275},"\"Follow this outline exactly:",[262,4637,4638],{"class":271},"\\n\\n{",[262,4640,4641],{"class":429},"outline",[262,4643,4644],{"class":271},"}\\n\\n",[262,4646,1257],{"class":275},[262,4648,4649],{"class":181,"line":864},[262,4650,4651],{"class":275},"                    \"End with three actionable takeaways as a bullet list.\"\n",[262,4653,4654],{"class":181,"line":1683},[262,4655,4364],{"class":429},[262,4657,4658],{"class":181,"line":1688},[262,4659,4369],{"class":429},[262,4661,4662],{"class":181,"line":1693},[262,4663,1303],{"class":429},[262,4665,4666,4668,4670,4673],{"class":181,"line":1728},[262,4667,1308],{"class":611},[262,4669,476],{"class":377},[262,4671,4672],{"class":271},"0.7",[262,4674,1315],{"class":429},[262,4676,4677,4680,4682,4685],{"class":181,"line":1737},[262,4678,4679],{"class":611},"        max_tokens",[262,4681,476],{"class":377},[262,4683,4684],{"class":271},"2500",[262,4686,1315],{"class":429},[262,4688,4689],{"class":181,"line":1751},[262,4690,1011],{"class":429},[262,4692,4693,4695,4697,4699],{"class":181,"line":1764},[262,4694,573],{"class":377},[262,4696,1326],{"class":429},[262,4698,102],{"class":271},[262,4700,1331],{"class":429},[14,4702,4703,4704,4707,4708,4711,4712,1363],{},"Here ",[18,4705,4706],{},"temperature=0.7"," gives the prose a little personality without going off the rails, and ",[18,4709,4710],{},"max_tokens=2500"," leaves enough room for a long article. One token is roughly three-quarters of a word, so 2500 tokens covers a 1,200-word post comfortably. If your draft cuts off mid-sentence, raise ",[18,4713,3846],{},[57,4715,4717],{"id":4716},"step-4-refine-the-draft","Step 4: Refine the draft",[14,4719,4720],{},"First drafts from any model tend to open with a generic sentence and pad the middle. A cheap second pass fixes that. This function asks the model to edit its own work, which is more effective than trying to get a perfect draft in one request:",[253,4722,4724],{"className":414,"code":4723,"language":416,"meta":258,"style":258},"def refine_draft(draft: str) -> str:\n    response = client.chat.completions.create(\n        model=MODEL,\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"You are a ruthless editor. Tighten the writing, cut filler \"\n                    \"and repetition, and rewrite a weak opening line. \"\n                    \"Keep all Markdown headings. Return the full edited post.\"\n                ),\n            },\n            {\"role\": \"user\", \"content\": draft},\n        ],\n        temperature=0.4,\n    )\n    return response.choices[0].message.content\n",[18,4725,4726,4744,4752,4762,4770,4774,4784,4790,4795,4800,4805,4809,4813,4830,4834,4844,4848],{"__ignoreMap":258},[262,4727,4728,4730,4733,4736,4738,4740,4742],{"class":181,"line":264},[262,4729,423],{"class":377},[262,4731,4732],{"class":267}," refine_draft",[262,4734,4735],{"class":429},"(draft: ",[262,4737,433],{"class":271},[262,4739,1939],{"class":429},[262,4741,433],{"class":271},[262,4743,1160],{"class":429},[262,4745,4746,4748,4750],{"class":181,"line":282},[262,4747,1184],{"class":429},[262,4749,476],{"class":377},[262,4751,1189],{"class":429},[262,4753,4754,4756,4758,4760],{"class":181,"line":295},[262,4755,1194],{"class":611},[262,4757,476],{"class":377},[262,4759,2693],{"class":271},[262,4761,1315],{"class":429},[262,4763,4764,4766,4768],{"class":181,"line":345},[262,4765,1215],{"class":611},[262,4767,476],{"class":377},[262,4769,1220],{"class":429},[262,4771,4772],{"class":181,"line":492},[262,4773,4331],{"class":429},[262,4775,4776,4778,4780,4782],{"class":181,"line":503},[262,4777,4336],{"class":275},[262,4779,1231],{"class":429},[262,4781,1234],{"class":275},[262,4783,1315],{"class":429},[262,4785,4786,4788],{"class":181,"line":521},[262,4787,4347],{"class":275},[262,4789,1242],{"class":429},[262,4791,4792],{"class":181,"line":537},[262,4793,4794],{"class":275},"                    \"You are a ruthless editor. Tighten the writing, cut filler \"\n",[262,4796,4797],{"class":181,"line":549},[262,4798,4799],{"class":275},"                    \"and repetition, and rewrite a weak opening line. \"\n",[262,4801,4802],{"class":181,"line":570},[262,4803,4804],{"class":275},"                    \"Keep all Markdown headings. Return the full edited post.\"\n",[262,4806,4807],{"class":181,"line":579},[262,4808,4364],{"class":429},[262,4810,4811],{"class":181,"line":586},[262,4812,4369],{"class":429},[262,4814,4815,4817,4819,4821,4823,4825,4827],{"class":181,"line":591},[262,4816,1225],{"class":429},[262,4818,1228],{"class":275},[262,4820,1231],{"class":429},[262,4822,1291],{"class":275},[262,4824,608],{"class":429},[262,4826,1239],{"class":275},[262,4828,4829],{"class":429},": draft},\n",[262,4831,4832],{"class":181,"line":623},[262,4833,1303],{"class":429},[262,4835,4836,4838,4840,4842],{"class":181,"line":634},[262,4837,1308],{"class":611},[262,4839,476],{"class":377},[262,4841,3175],{"class":271},[262,4843,1315],{"class":429},[262,4845,4846],{"class":181,"line":845},[262,4847,1011],{"class":429},[262,4849,4850,4852,4854,4856],{"class":181,"line":850},[262,4851,573],{"class":377},[262,4853,1326],{"class":429},[262,4855,102],{"class":271},[262,4857,1331],{"class":429},[14,4859,4860,4861,4863],{},"The low ",[18,4862,3352],{}," keeps the editor conservative so it tightens the text instead of rewriting your meaning. This refine step is the single biggest quality lever in the whole script.",[57,4865,4867],{"id":4866},"step-5-save-the-post-to-a-file","Step 5: Save the post to a file",[14,4869,4870],{},"Finally, write the finished Markdown to disk with a clean filename derived from the topic. Validating that the response is not empty protects you from saving a blank file after a failed call:",[253,4872,4874],{"className":414,"code":4873,"language":416,"meta":258,"style":258},"from pathlib import Path\n\n\ndef save_post(text: str, topic: str) -> Path:\n    if not text.strip():\n        raise ValueError(\"Empty response from the API; nothing to save.\")\n    slug = topic.lower().strip().replace(\" \", \"-\")\n    Path(\"output\").mkdir(exist_ok=True)\n    path = Path(f\"output\u002F{slug}.md\")\n    path.write_text(text, encoding=\"utf-8\")\n    return path\n",[18,4875,4876,4888,4892,4896,4915,4924,4938,4956,4977,5004,5017],{"__ignoreMap":258},[262,4877,4878,4880,4883,4885],{"class":181,"line":264},[262,4879,705],{"class":377},[262,4881,4882],{"class":429}," pathlib ",[262,4884,684],{"class":377},[262,4886,4887],{"class":429}," Path\n",[262,4889,4890],{"class":181,"line":282},[262,4891,583],{"emptyLinePlaceholder":582},[262,4893,4894],{"class":181,"line":295},[262,4895,583],{"emptyLinePlaceholder":582},[262,4897,4898,4900,4903,4905,4907,4910,4912],{"class":181,"line":345},[262,4899,423],{"class":377},[262,4901,4902],{"class":267}," save_post",[262,4904,430],{"class":429},[262,4906,433],{"class":271},[262,4908,4909],{"class":429},", topic: ",[262,4911,433],{"class":271},[262,4913,4914],{"class":429},") -> Path:\n",[262,4916,4917,4919,4921],{"class":181,"line":492},[262,4918,3454],{"class":377},[262,4920,2818],{"class":377},[262,4922,4923],{"class":429}," text.strip():\n",[262,4925,4926,4929,4931,4933,4936],{"class":181,"line":503},[262,4927,4928],{"class":377},"        raise",[262,4930,2832],{"class":271},[262,4932,602],{"class":429},[262,4934,4935],{"class":275},"\"Empty response from the API; nothing to save.\"",[262,4937,660],{"class":429},[262,4939,4940,4943,4945,4948,4950,4952,4954],{"class":181,"line":521},[262,4941,4942],{"class":429},"    slug ",[262,4944,476],{"class":377},[262,4946,4947],{"class":429}," topic.lower().strip().replace(",[262,4949,543],{"class":275},[262,4951,608],{"class":429},[262,4953,1094],{"class":275},[262,4955,660],{"class":429},[262,4957,4958,4961,4964,4967,4970,4972,4975],{"class":181,"line":537},[262,4959,4960],{"class":429},"    Path(",[262,4962,4963],{"class":275},"\"output\"",[262,4965,4966],{"class":429},").mkdir(",[262,4968,4969],{"class":611},"exist_ok",[262,4971,476],{"class":377},[262,4973,4974],{"class":271},"True",[262,4976,660],{"class":429},[262,4978,4979,4982,4984,4987,4989,4992,4994,4997,4999,5002],{"class":181,"line":549},[262,4980,4981],{"class":429},"    path ",[262,4983,476],{"class":377},[262,4985,4986],{"class":429}," Path(",[262,4988,642],{"class":377},[262,4990,4991],{"class":275},"\"output\u002F",[262,4993,3039],{"class":271},[262,4995,4996],{"class":429},"slug",[262,4998,654],{"class":271},[262,5000,5001],{"class":275},".md\"",[262,5003,660],{"class":429},[262,5005,5006,5009,5011,5013,5015],{"class":181,"line":570},[262,5007,5008],{"class":429},"    path.write_text(text, ",[262,5010,612],{"class":611},[262,5012,476],{"class":377},[262,5014,617],{"class":275},[262,5016,660],{"class":429},[262,5018,5019,5021],{"class":181,"line":579},[262,5020,573],{"class":377},[262,5022,5023],{"class":429}," path\n",[14,5025,5026],{},"Now wire the four stages together at the bottom of the file and run it:",[253,5028,5030],{"className":414,"code":5029,"language":416,"meta":258,"style":258},"if __name__ == \"__main__\":\n    topic = \"Python automation for marketers\"\n    audience = \"non-technical marketing managers\"\n\n    outline = generate_outline(topic, audience)\n    draft = generate_draft(topic, audience, outline)\n    final = refine_draft(draft)\n    saved_to = save_post(final, topic)\n    print(f\"Saved post to {saved_to}\")\n",[18,5031,5032,5044,5054,5064,5068,5078,5088,5098,5108],{"__ignoreMap":258},[262,5033,5034,5036,5038,5040,5042],{"class":181,"line":264},[262,5035,2210],{"class":377},[262,5037,2213],{"class":271},[262,5039,2216],{"class":377},[262,5041,2219],{"class":275},[262,5043,1160],{"class":429},[262,5045,5046,5049,5051],{"class":181,"line":282},[262,5047,5048],{"class":429},"    topic ",[262,5050,476],{"class":377},[262,5052,5053],{"class":275}," \"Python automation for marketers\"\n",[262,5055,5056,5059,5061],{"class":181,"line":295},[262,5057,5058],{"class":429},"    audience ",[262,5060,476],{"class":377},[262,5062,5063],{"class":275}," \"non-technical marketing managers\"\n",[262,5065,5066],{"class":181,"line":345},[262,5067,583],{"emptyLinePlaceholder":582},[262,5069,5070,5073,5075],{"class":181,"line":492},[262,5071,5072],{"class":429},"    outline ",[262,5074,476],{"class":377},[262,5076,5077],{"class":429}," generate_outline(topic, audience)\n",[262,5079,5080,5083,5085],{"class":181,"line":503},[262,5081,5082],{"class":429},"    draft ",[262,5084,476],{"class":377},[262,5086,5087],{"class":429}," generate_draft(topic, audience, outline)\n",[262,5089,5090,5093,5095],{"class":181,"line":521},[262,5091,5092],{"class":429},"    final ",[262,5094,476],{"class":377},[262,5096,5097],{"class":429}," refine_draft(draft)\n",[262,5099,5100,5103,5105],{"class":181,"line":537},[262,5101,5102],{"class":429},"    saved_to ",[262,5104,476],{"class":377},[262,5106,5107],{"class":429}," save_post(final, topic)\n",[262,5109,5110,5112,5114,5116,5119,5121,5124,5126,5128],{"class":181,"line":549},[262,5111,1089],{"class":271},[262,5113,602],{"class":429},[262,5115,642],{"class":377},[262,5117,5118],{"class":275},"\"Saved post to ",[262,5120,3039],{"class":271},[262,5122,5123],{"class":429},"saved_to",[262,5125,654],{"class":271},[262,5127,1176],{"class":275},[262,5129,660],{"class":429},[14,5131,5132],{},"Run it from your terminal:",[253,5134,5136],{"className":255,"code":5135,"language":257,"meta":258,"style":258},"python blog_writer.py\n",[18,5137,5138],{"__ignoreMap":258},[262,5139,5140,5142],{"class":181,"line":264},[262,5141,416],{"class":267},[262,5143,5144],{"class":275}," blog_writer.py\n",[14,5146,5147,5148,5151],{},"You will find a polished Markdown file inside the ",[18,5149,5150],{},"output"," folder, ready for you to read, fact-check, and publish.",[57,5153,5155],{"id":5154},"key-parameter-quick-reference","Key parameter quick reference",[14,5157,5158],{},"These are the settings you will adjust most often. Tune the temperature per stage rather than using one value everywhere.",[1379,5160,5161,5174],{},[1382,5162,5163],{},[1385,5164,5165,5167,5169,5172],{},[1388,5166,1390],{},[1388,5168,3795],{},[1388,5170,5171],{},"Default here",[1388,5173,1396],{},[1398,5175,5176,5197,5215],{},[1385,5177,5178,5182,5184,5188],{},[1403,5179,5180],{},[18,5181,805],{},[1403,5183,3811],{},[1403,5185,5186],{},[18,5187,2703],{},[1403,5189,5190,5191,5193,5194,5196],{},"Picks the engine. ",[18,5192,2703],{}," is cheapest; ",[18,5195,3821],{}," writes better but costs more.",[1385,5198,5199,5203,5205,5212],{},[1403,5200,5201],{},[18,5202,3829],{},[1403,5204,3832],{},[1403,5206,5207,5209,5210],{},[18,5208,3175],{},"–",[18,5211,4672],{},[1403,5213,5214],{},"Controls randomness. Lower means consistent and safe; higher means creative and varied.",[1385,5216,5217,5221,5223,5227],{},[1403,5218,5219],{},[18,5220,3846],{},[1403,5222,439],{},[1403,5224,5225],{},[18,5226,4684],{},[1403,5228,5229],{},"Caps the length of the reply. Raise it if drafts get cut off; lower it to save money.",[57,5231,1445],{"id":1444},[1447,5233,5234,5243,5254,5261],{},[1450,5235,5236,5239,5240,5242],{},[35,5237,5238],{},"The draft is much shorter than 1,200 words."," Models treat word counts as a loose target. Generating section by section helps, but the reliable fix is to raise ",[18,5241,3846],{}," and to ask explicitly for \"at least 1,200 words\" in the prompt.",[1450,5244,5245,5250,5251,5253],{},[35,5246,5247,5249],{},[18,5248,2707],{}," or a 429 message."," You sent requests too fast or hit your spending cap. Add a short pause between calls, or follow ",[51,5252,3379],{"href":3378}," to add automatic retries.",[1450,5255,5256,5257,5260],{},"**The output is wrapped in a ",[18,5258,5259],{},"markdown code fence.** Some models wrap the whole reply in a fence. Strip it before saving with `text.strip().removeprefix(\"","markdown\").removesuffix(\"```\").strip()`.",[1450,5262,5263,5266,5267,5271],{},[35,5264,5265],{},"The post sounds generic and bland."," That is almost always a weak prompt, not a weak model. Give the model a specific audience, a point of view, and concrete examples to include. The ",[51,5268,5270],{"href":5269},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fprompt-engineering-templates-for-marketers\u002F","Prompt Engineering Templates for Marketers"," guide has ready-made starting points.",[57,5273,2317],{"id":2316},[2322,5275,5276,5282,5292],{},[1450,5277,5278,5281],{},[35,5279,5280],{},"Use this script"," when you write one article at a time, want full control over structure and tone, and plan to edit before publishing. The outline-draft-refine flow gives the best quality for a single piece.",[1450,5283,5284,5287,5288,5291],{},[35,5285,5286],{},"Reach for a batch approach"," when you need dozens of short, similar pieces, such as rewriting a catalog. ",[51,5289,2462],{"href":5290},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fbulk-rewrite-product-descriptions-with-python\u002F"," loops over a list instead of building one long article.",[1450,5293,5294,5297,5298,5300],{},[35,5295,5296],{},"Pick a different format"," when your output is not a long-form post. For a recurring email, ",[51,5299,4011],{"href":4010}," uses a shorter, section-based template that fits an inbox better.",[14,5302,5303,5304,5306],{},"Once this works, fold it into your broader ",[51,5305,3991],{"href":3990}," so a keyword list flows straight into finished drafts.",[14,5308,2375,5309,1363],{},[51,5310,3991],{"href":3990},[57,5312,2381],{"id":2380},[2322,5314,5315,5320,5325,5330],{},[1450,5316,5317,5319],{},[51,5318,3991],{"href":3990}," — the section this guide belongs to.",[1450,5321,5322,5324],{},[51,5323,2462],{"href":5290}," — run the same idea across a whole catalog.",[1450,5326,5327,5329],{},[51,5328,4011],{"href":4010}," — adapt the flow for inbox-ready content.",[1450,5331,5332,5334],{},[51,5333,1362],{"href":1361}," — get more predictable Markdown out of the model.",[2401,5336,5337],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":5339},[5340,5341,5342,5343,5344,5345,5346,5347,5348,5349],{"id":237,"depth":282,"text":238},{"id":4135,"depth":282,"text":4136},{"id":4268,"depth":282,"text":4269},{"id":4479,"depth":282,"text":4480},{"id":4716,"depth":282,"text":4717},{"id":4866,"depth":282,"text":4867},{"id":5154,"depth":282,"text":5155},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Build a Python script that turns one keyword into a full Markdown blog post with the OpenAI API: outline, draft, refine, and save to a file.",[5352,5355,5358,5361,5364],{"q":5353,"a":5354},"Which OpenAI model should I use to write blog posts?","Start with gpt-4o-mini. It is cheap, fast, and good enough for first drafts you will edit anyway. Switch to gpt-4o only if you need stronger reasoning or longer, more nuanced articles.",{"q":5356,"a":5357},"How long should each blog post be?","Ask for 1,000 to 1,500 words in the prompt. The model treats word counts as a target, not a guarantee, so you will usually land within a few hundred words. Build the post in sections to get more reliable length.",{"q":5359,"a":5360},"Will Google penalize AI-generated blog posts?","Google ranks helpful content regardless of how it was produced, but it penalizes thin, unedited spam. Always read, fact-check, and edit the draft before publishing. Treat the script as a writing assistant, not an autopilot.",{"q":5362,"a":5363},"How much does it cost to generate a blog post with the OpenAI API?","With gpt-4o-mini, a single 1,200-word post usually costs well under one US cent. Costs scale with the number of tokens in and out, so longer prompts and longer articles cost a little more.",{"q":5365,"a":5366},"Do I need to know how to code to run this script?","You need to install Python, paste the script, and add your API key. You do not need to write code from scratch. Each step below is copy-paste ready and explained in plain language.",{"name":5368,"steps":5369},"How to generate blog posts with the OpenAI API in Python",[5370,5373,5376,5379,5382],{"name":5371,"text":5372},"Set up your environment and key","Install the openai SDK, create a virtual environment, and load your API key from a .env file.",{"name":5374,"text":5375},"Generate an outline","Ask the model for a structured outline of headings so the draft has a clear skeleton.",{"name":5377,"text":5378},"Draft the full post","Pass the outline back to the model and ask it to expand each heading into a full Markdown article.",{"name":5380,"text":5381},"Refine the draft","Run a second pass that tightens the intro, fixes the tone, and removes filler.",{"name":5383,"text":5384},"Save the post to a file","Write the finished Markdown to a .md file named after the topic slug.",{},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-blog-posts-with-openai-api","2026-05-12",{"title":3983,"description":5350},"ai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-blog-posts-with-openai-api\u002Findex","6UM2ZxsUzNmLn78LqCAiAY0apUmmCFTl-Vk18bejVCg",{"id":5392,"title":4011,"body":5393,"description":6985,"extension":2419,"faq":6986,"howto":7002,"meta":7020,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":7021,"published":2452,"seo":7022,"seoTitle":4011,"stem":7023,"__hash__":7024},"content\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-email-newsletters-with-python-and-ai\u002Findex.md",{"type":7,"value":5394,"toc":6973},[5395,5398,5401,5404,5415,5417,5425,5443,5455,5460,5475,5491,5565,5569,5575,5722,5736,5740,5749,6088,6096,6115,6119,6122,6399,6410,6414,6417,6492,6498,6502,6515,6773,6780,6783,6863,6865,6915,6917,6942,6946,6948,6970],[10,5396,4011],{"id":5397},"generate-email-newsletters-with-python-and-ai",[14,5399,5400],{},"This guide shows you how to turn a messy list of links and notes into a formatted, ready-to-send HTML email newsletter in under fifteen minutes. You collect your items in Python, hand them to a language model (an LLM — the kind of AI that writes text) to draft subject lines and sections, render the result as email-safe HTML, and optionally send it through an email API. No design tools, no copy-paste shuffle.",[14,5402,5403],{},"The pattern is simple: notes in, polished newsletter out. Once it works, running next week's edition is one command. The real win is consistency — most newsletters die because writing one from scratch every week is a chore. When the format, subject lines, and HTML are handled for you, all that is left is gathering links, which you already do as you read.",[14,5405,5406,5407,5409,5410,5414],{},"This guide is part of ",[51,5408,3991],{"href":3990},", and it reuses the same core skill — prompting a model and parsing its reply — that powers the rest of the ",[51,5411,5413],{"href":5412},"\u002Fai-content-creation-marketing-automation\u002F","AI Content Creation & Marketing Automation"," track.",[57,5416,238],{"id":237},[14,5418,5419,5420,5424],{},"You only need a few things beyond a working Python 3.10+ setup. If Python is new to you, start with ",[51,5421,5423],{"href":5422},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002F","Setting Up Python for AI"," and come back. Install the three libraries we use:",[253,5426,5428],{"className":255,"code":5427,"language":257,"meta":258,"style":258},"pip install openai httpx python-dotenv\n",[18,5429,5430],{"__ignoreMap":258},[262,5431,5432,5434,5436,5438,5441],{"class":181,"line":264},[262,5433,298],{"class":267},[262,5435,301],{"class":275},[262,5437,2519],{"class":275},[262,5439,5440],{"class":275}," httpx",[262,5442,2522],{"class":275},[14,5444,4703,5445,5447,5448,5451,5452,5454],{},[18,5446,20],{}," is the official SDK (software development kit — the helper library that talks to the model), ",[18,5449,5450],{},"httpx"," is a modern HTTP client we use to call an email service, and ",[18,5453,2501],{}," loads secrets from a file.",[14,5456,2525,5457,5459],{},[18,5458,319],{}," in your project folder for your keys:",[253,5461,5463],{"className":323,"code":5462,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-openai-key-here\nRESEND_API_KEY=re_your-resend-key-here\n",[18,5464,5465,5470],{"__ignoreMap":258},[262,5466,5467],{"class":181,"line":264},[262,5468,5469],{},"OPENAI_API_KEY=sk-your-openai-key-here\n",[262,5471,5472],{"class":181,"line":282},[262,5473,5474],{},"RESEND_API_KEY=re_your-resend-key-here\n",[14,5476,353,5477,356,5479,5481,5482,5486,5487,5490],{},[18,5478,319],{},[18,5480,359],{}," immediately so you never commit a key to version control. If you do not have an OpenAI key yet, see ",[51,5483,5485],{"href":5484},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fbest-free-ai-apis-for-beginners\u002F","Best Free AI APIs for Beginners"," for free options. The ",[18,5488,5489],{},"RESEND_API_KEY"," is only needed for Step 5 (sending); you can skip it while you build the draft.",[76,5492,5494,5562],{"className":5493},[79],[81,5495,90,5500,90,5503,90,5506,90,5509,90,5512,90,5515,90,5517,90,5520,90,5523,90,5525,90,5528,90,5531,90,5533,90,5536,90,5539,90,5543,90,5545,90,5547],{"viewBox":5496,"role":84,"ariaLabelledBy":5497,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1000 224",[5498,5499],"nlTitle","nlDesc",[92,5501,5502],{"id":5498},"Newsletter generation flow",[96,5504,5505],{"id":5499},"Links and notes flow into a language model, which returns subject lines and sections, which Python renders to HTML and sends through an email API.",[100,5507],{"x":102,"y":5508,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"42",[111,5510,5511],{"x":113,"y":151,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Links + notes",[111,5513,5514],{"x":113,"y":141,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"(Python list)",[100,5516],{"x":129,"y":5508,"width":104,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,5518,5519],{"x":133,"y":151,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"LLM drafts",[111,5521,5522],{"x":133,"y":141,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"subjects, sections",[100,5524],{"x":158,"y":5508,"width":104,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,5526,5527],{"x":161,"y":151,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Render HTML",[111,5529,5530],{"x":161,"y":141,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"(email template)",[100,5532],{"x":168,"y":5508,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,5534,5535],{"x":172,"y":151,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Email API",[111,5537,5538],{"x":172,"y":141,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"(httpx send)",[181,5540],{"x1":104,"y1":5541,"x2":184,"y2":5541,"stroke":143,"strokeWidth":109,"markerEnd":5542},"78","url(#nlArrow)",[181,5544],{"x1":198,"y1":5541,"x2":199,"y2":5541,"stroke":143,"strokeWidth":109,"markerEnd":5542},[181,5546],{"x1":205,"y1":5541,"x2":206,"y2":5541,"stroke":143,"strokeWidth":109,"markerEnd":5542},[5548,5549,5550,5551,90],"defs",{},"\n    ",[5552,5553,5558,5559,5550],"marker",{"id":5554,"markerWidth":5555,"markerHeight":5555,"refX":221,"refY":5556,"orient":5557},"nlArrow","8","3","auto","\n      ",[216,5560],{"d":5561,"fill":143},"M0,0 L6,3 L0,6 Z",[232,5563,5564],{},"The whole pipeline: your notes become drafted copy, then HTML, then a sent email.",[57,5566,5568],{"id":5567},"_1-collect-your-links-and-notes","1. Collect your links and notes",[14,5570,5571,5572,5574],{},"The model writes better copy when you give it your own words, not just URLs. Represent each item as a small dictionary with a title, a link, and a one- or two-sentence note. The note is what stops the AI from inventing facts — it summarizes ",[27,5573,29],{}," summary.",[253,5576,5578],{"className":414,"code":5577,"language":416,"meta":258,"style":258},"items = [\n    {\n        \"title\": \"OpenAI ships structured outputs\",\n        \"url\": \"https:\u002F\u002Fexample.com\u002Fstructured-outputs\",\n        \"note\": \"Models can now return guaranteed-valid JSON. Big for parsing.\",\n    },\n    {\n        \"title\": \"Our new pricing page is live\",\n        \"url\": \"https:\u002F\u002Fexample.com\u002Fpricing\",\n        \"note\": \"Simpler tiers, annual discount, founder plan added.\",\n    },\n    {\n        \"title\": \"Weekend read: how small teams ship fast\",\n        \"url\": \"https:\u002F\u002Fexample.com\u002Fsmall-teams\",\n        \"note\": \"Long essay on keeping scope tight. Worth skimming.\",\n    },\n]\n",[18,5579,5580,5590,5595,5607,5619,5631,5636,5640,5651,5662,5673,5677,5681,5692,5703,5714,5718],{"__ignoreMap":258},[262,5581,5582,5585,5587],{"class":181,"line":264},[262,5583,5584],{"class":429},"items ",[262,5586,476],{"class":377},[262,5588,5589],{"class":429}," [\n",[262,5591,5592],{"class":181,"line":282},[262,5593,5594],{"class":429},"    {\n",[262,5596,5597,5600,5602,5605],{"class":181,"line":295},[262,5598,5599],{"class":275},"        \"title\"",[262,5601,1231],{"class":429},[262,5603,5604],{"class":275},"\"OpenAI ships structured outputs\"",[262,5606,1315],{"class":429},[262,5608,5609,5612,5614,5617],{"class":181,"line":345},[262,5610,5611],{"class":275},"        \"url\"",[262,5613,1231],{"class":429},[262,5615,5616],{"class":275},"\"https:\u002F\u002Fexample.com\u002Fstructured-outputs\"",[262,5618,1315],{"class":429},[262,5620,5621,5624,5626,5629],{"class":181,"line":492},[262,5622,5623],{"class":275},"        \"note\"",[262,5625,1231],{"class":429},[262,5627,5628],{"class":275},"\"Models can now return guaranteed-valid JSON. Big for parsing.\"",[262,5630,1315],{"class":429},[262,5632,5633],{"class":181,"line":503},[262,5634,5635],{"class":429},"    },\n",[262,5637,5638],{"class":181,"line":521},[262,5639,5594],{"class":429},[262,5641,5642,5644,5646,5649],{"class":181,"line":537},[262,5643,5599],{"class":275},[262,5645,1231],{"class":429},[262,5647,5648],{"class":275},"\"Our new pricing page is live\"",[262,5650,1315],{"class":429},[262,5652,5653,5655,5657,5660],{"class":181,"line":549},[262,5654,5611],{"class":275},[262,5656,1231],{"class":429},[262,5658,5659],{"class":275},"\"https:\u002F\u002Fexample.com\u002Fpricing\"",[262,5661,1315],{"class":429},[262,5663,5664,5666,5668,5671],{"class":181,"line":570},[262,5665,5623],{"class":275},[262,5667,1231],{"class":429},[262,5669,5670],{"class":275},"\"Simpler tiers, annual discount, founder plan added.\"",[262,5672,1315],{"class":429},[262,5674,5675],{"class":181,"line":579},[262,5676,5635],{"class":429},[262,5678,5679],{"class":181,"line":586},[262,5680,5594],{"class":429},[262,5682,5683,5685,5687,5690],{"class":181,"line":591},[262,5684,5599],{"class":275},[262,5686,1231],{"class":429},[262,5688,5689],{"class":275},"\"Weekend read: how small teams ship fast\"",[262,5691,1315],{"class":429},[262,5693,5694,5696,5698,5701],{"class":181,"line":623},[262,5695,5611],{"class":275},[262,5697,1231],{"class":429},[262,5699,5700],{"class":275},"\"https:\u002F\u002Fexample.com\u002Fsmall-teams\"",[262,5702,1315],{"class":429},[262,5704,5705,5707,5709,5712],{"class":181,"line":634},[262,5706,5623],{"class":275},[262,5708,1231],{"class":429},[262,5710,5711],{"class":275},"\"Long essay on keeping scope tight. Worth skimming.\"",[262,5713,1315],{"class":429},[262,5715,5716],{"class":181,"line":845},[262,5717,5635],{"class":429},[262,5719,5720],{"class":181,"line":850},[262,5721,957],{"class":429},[14,5723,5724,5725,5728,5729,5732,5733,5735],{},"Keep notes short and factual. You can store this list in your script, read it from a CSV, or pull it from a database — the rest of the pipeline does not care where it comes from. A practical habit: keep a running ",[18,5726,5727],{},"links.csv"," open all week and drop in a title, URL, and note whenever something is worth sharing. By send day the hard part is already done, and reading the file into the ",[18,5730,5731],{},"items"," list is a few lines of Python. If your CSV needs tidying first, ",[51,5734,2919],{"href":2918}," covers the common fixes.",[57,5737,5739],{"id":5738},"_2-generate-subject-lines-and-sections-with-the-llm","2. Generate subject lines and sections with the LLM",[14,5741,5742,5743,5746,5747,1363],{},"Now hand the items to the model and ask for structured output: a few subject-line options plus one written section per item. Asking for JSON (a simple text format of keys and values) makes the result easy to parse in Python. We set ",[18,5744,5745],{},"response_format"," to force valid JSON so you never have to clean up stray text. For more on shaping output, see ",[51,5748,1362],{"href":1361},[253,5750,5752],{"className":414,"code":5751,"language":416,"meta":258,"style":258},"import os\nimport json\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nSYSTEM_PROMPT = (\n    \"You are a newsletter editor. Write only from the notes provided; \"\n    \"never invent facts. Keep a warm, concise tone. Return JSON with keys: \"\n    \"'subjects' (list of 3 short subject-line options) and 'sections' \"\n    \"(list of objects with 'heading', 'body' of 2-3 sentences, and 'url').\"\n)\n\n\ndef draft_newsletter(items: list[dict], topic: str) -> dict:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n            {\n                \"role\": \"user\",\n                \"content\": (\n                    f\"This week's newsletter is about: {topic}.\\n\"\n                    f\"Items as JSON:\\n{json.dumps(items, indent=2)}\"\n                ),\n            },\n        ],\n        temperature=0.6,\n        response_format={\"type\": \"json_object\"},\n    )\n    return json.loads(response.choices[0].message.content)\n\n\ndraft = draft_newsletter(items, topic=\"AI tooling and company updates\")\nprint(draft[\"subjects\"])\n",[18,5753,5754,5760,5767,5777,5787,5791,5795,5813,5817,5825,5830,5835,5840,5845,5849,5853,5857,5881,5889,5899,5907,5927,5931,5941,5947,5966,5992,5996,6000,6004,6014,6033,6037,6049,6053,6057,6076],{"__ignoreMap":258},[262,5755,5756,5758],{"class":181,"line":264},[262,5757,684],{"class":377},[262,5759,687],{"class":429},[262,5761,5762,5764],{"class":181,"line":282},[262,5763,684],{"class":377},[262,5765,5766],{"class":429}," json\n",[262,5768,5769,5771,5773,5775],{"class":181,"line":295},[262,5770,705],{"class":377},[262,5772,708],{"class":429},[262,5774,684],{"class":377},[262,5776,713],{"class":429},[262,5778,5779,5781,5783,5785],{"class":181,"line":345},[262,5780,705],{"class":377},[262,5782,720],{"class":429},[262,5784,684],{"class":377},[262,5786,725],{"class":429},[262,5788,5789],{"class":181,"line":492},[262,5790,583],{"emptyLinePlaceholder":582},[262,5792,5793],{"class":181,"line":503},[262,5794,734],{"class":429},[262,5796,5797,5799,5801,5803,5805,5807,5809,5811],{"class":181,"line":521},[262,5798,739],{"class":429},[262,5800,476],{"class":377},[262,5802,1588],{"class":429},[262,5804,2674],{"class":611},[262,5806,476],{"class":377},[262,5808,1199],{"class":429},[262,5810,2681],{"class":275},[262,5812,2684],{"class":429},[262,5814,5815],{"class":181,"line":537},[262,5816,583],{"emptyLinePlaceholder":582},[262,5818,5819,5821,5823],{"class":181,"line":549},[262,5820,2941],{"class":271},[262,5822,442],{"class":377},[262,5824,984],{"class":429},[262,5826,5827],{"class":181,"line":570},[262,5828,5829],{"class":275},"    \"You are a newsletter editor. Write only from the notes provided; \"\n",[262,5831,5832],{"class":181,"line":579},[262,5833,5834],{"class":275},"    \"never invent facts. Keep a warm, concise tone. Return JSON with keys: \"\n",[262,5836,5837],{"class":181,"line":586},[262,5838,5839],{"class":275},"    \"'subjects' (list of 3 short subject-line options) and 'sections' \"\n",[262,5841,5842],{"class":181,"line":591},[262,5843,5844],{"class":275},"    \"(list of objects with 'heading', 'body' of 2-3 sentences, and 'url').\"\n",[262,5846,5847],{"class":181,"line":623},[262,5848,660],{"class":429},[262,5850,5851],{"class":181,"line":634},[262,5852,583],{"emptyLinePlaceholder":582},[262,5854,5855],{"class":181,"line":845},[262,5856,583],{"emptyLinePlaceholder":582},[262,5858,5859,5861,5864,5867,5870,5873,5875,5877,5879],{"class":181,"line":850},[262,5860,423],{"class":377},[262,5862,5863],{"class":267}," draft_newsletter",[262,5865,5866],{"class":429},"(items: list[",[262,5868,5869],{"class":271},"dict",[262,5871,5872],{"class":429},"], topic: ",[262,5874,433],{"class":271},[262,5876,1939],{"class":429},[262,5878,5869],{"class":271},[262,5880,1160],{"class":429},[262,5882,5883,5885,5887],{"class":181,"line":864},[262,5884,1184],{"class":429},[262,5886,476],{"class":377},[262,5888,1189],{"class":429},[262,5890,5891,5893,5895,5897],{"class":181,"line":1683},[262,5892,1194],{"class":611},[262,5894,476],{"class":377},[262,5896,1207],{"class":275},[262,5898,1315],{"class":429},[262,5900,5901,5903,5905],{"class":181,"line":1688},[262,5902,1215],{"class":611},[262,5904,476],{"class":377},[262,5906,1220],{"class":429},[262,5908,5909,5911,5913,5915,5917,5919,5921,5923,5925],{"class":181,"line":1693},[262,5910,1225],{"class":429},[262,5912,1228],{"class":275},[262,5914,1231],{"class":429},[262,5916,1234],{"class":275},[262,5918,608],{"class":429},[262,5920,1239],{"class":275},[262,5922,1231],{"class":429},[262,5924,2941],{"class":271},[262,5926,3143],{"class":429},[262,5928,5929],{"class":181,"line":1728},[262,5930,4331],{"class":429},[262,5932,5933,5935,5937,5939],{"class":181,"line":1737},[262,5934,4336],{"class":275},[262,5936,1231],{"class":429},[262,5938,1291],{"class":275},[262,5940,1315],{"class":429},[262,5942,5943,5945],{"class":181,"line":1751},[262,5944,4347],{"class":275},[262,5946,1242],{"class":429},[262,5948,5949,5951,5954,5956,5958,5960,5962,5964],{"class":181,"line":1764},[262,5950,4394],{"class":377},[262,5952,5953],{"class":275},"\"This week's newsletter is about: ",[262,5955,3039],{"class":271},[262,5957,4402],{"class":429},[262,5959,654],{"class":271},[262,5961,1363],{"class":275},[262,5963,2137],{"class":271},[262,5965,1257],{"class":275},[262,5967,5968,5970,5973,5975,5978,5981,5983,5985,5988,5990],{"class":181,"line":1779},[262,5969,4394],{"class":377},[262,5971,5972],{"class":275},"\"Items as JSON:",[262,5974,1268],{"class":271},[262,5976,5977],{"class":429},"json.dumps(items, ",[262,5979,5980],{"class":611},"indent",[262,5982,476],{"class":377},[262,5984,109],{"class":271},[262,5986,5987],{"class":429},")",[262,5989,654],{"class":271},[262,5991,1257],{"class":275},[262,5993,5994],{"class":181,"line":1793},[262,5995,4364],{"class":429},[262,5997,5998],{"class":181,"line":1800},[262,5999,4369],{"class":429},[262,6001,6002],{"class":181,"line":1805},[262,6003,1303],{"class":429},[262,6005,6006,6008,6010,6012],{"class":181,"line":1810},[262,6007,1308],{"class":611},[262,6009,476],{"class":377},[262,6011,4445],{"class":271},[262,6013,1315],{"class":429},[262,6015,6016,6019,6021,6023,6026,6028,6031],{"class":181,"line":1823},[262,6017,6018],{"class":611},"        response_format",[262,6020,476],{"class":377},[262,6022,3039],{"class":429},[262,6024,6025],{"class":275},"\"type\"",[262,6027,1231],{"class":429},[262,6029,6030],{"class":275},"\"json_object\"",[262,6032,3143],{"class":429},[262,6034,6035],{"class":181,"line":1846},[262,6036,1011],{"class":429},[262,6038,6039,6041,6044,6046],{"class":181,"line":1861},[262,6040,573],{"class":377},[262,6042,6043],{"class":429}," json.loads(response.choices[",[262,6045,102],{"class":271},[262,6047,6048],{"class":429},"].message.content)\n",[262,6050,6051],{"class":181,"line":1866},[262,6052,583],{"emptyLinePlaceholder":582},[262,6054,6055],{"class":181,"line":1871},[262,6056,583],{"emptyLinePlaceholder":582},[262,6058,6059,6062,6064,6067,6069,6071,6074],{"class":181,"line":1890},[262,6060,6061],{"class":429},"draft ",[262,6063,476],{"class":377},[262,6065,6066],{"class":429}," draft_newsletter(items, ",[262,6068,4402],{"class":611},[262,6070,476],{"class":377},[262,6072,6073],{"class":275},"\"AI tooling and company updates\"",[262,6075,660],{"class":429},[262,6077,6078,6080,6083,6086],{"class":181,"line":1909},[262,6079,637],{"class":271},[262,6081,6082],{"class":429},"(draft[",[262,6084,6085],{"class":275},"\"subjects\"",[262,6087,3512],{"class":429},[14,6089,3349,6090,6092,6093,6095],{},[18,6091,3829],{}," of ",[18,6094,4445],{}," keeps the copy lively without drifting off-brief. If the tone feels off, adjust the system prompt rather than the temperature — explicit instructions beat dial-twiddling. To pin down voice, add a sentence like \"Write in the first person, address the reader as 'you', and keep each section under 60 words.\" Small, specific rules like that move the output more reliably than any numeric setting.",[14,6097,6098,6099,6102,6103,6106,6107,6110,6111,1363],{},"Notice that the system prompt does two jobs at once: it sets the tone ",[27,6100,6101],{},"and"," it defines the exact JSON shape the rest of the script depends on. Keeping both in one place means that when you change the format — say, adding a ",[18,6104,6105],{},"read_time"," field per section — you only edit one string. If you hit a ",[18,6108,6109],{},"JSONDecodeError",", see ",[51,6112,6114],{"href":6113},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-jsondecodeerror-with-ai-api-responses-in-python\u002F","Fix JSONDecodeError with AI API Responses in Python",[57,6116,6118],{"id":6117},"_3-render-the-newsletter-as-html","3. Render the newsletter as HTML",[14,6120,6121],{},"Email clients are stuck in the past, so the safest HTML is plain and uses inline styles (styles written directly on each tag). The function below builds one section per item and picks the first subject line. It returns both the subject and the HTML body so Step 5 can send them.",[253,6123,6125],{"className":414,"code":6124,"language":416,"meta":258,"style":258},"def render_html(draft: dict) -> tuple[str, str]:\n    subject = draft[\"subjects\"][0]\n    blocks = []\n    for s in draft[\"sections\"]:\n        blocks.append(\n            f'\u003Ch2 style=\"font-size:18px;margin:24px 0 8px;color:#111;\">'\n            f'{s[\"heading\"]}\u003C\u002Fh2>'\n            f'\u003Cp style=\"font-size:15px;line-height:1.5;color:#333;margin:0 0 8px;\">'\n            f'{s[\"body\"]}\u003C\u002Fp>'\n            f'\u003Cp style=\"margin:0 0 16px;\">'\n            f'\u003Ca href=\"{s[\"url\"]}\" style=\"color:#2563eb;\">Read more &rarr;\u003C\u002Fa>\u003C\u002Fp>'\n        )\n    body = (\n        '\u003Cdiv style=\"max-width:600px;margin:0 auto;padding:24px;'\n        'font-family:Arial, sans-serif;\">'\n        f'\u003Ch1 style=\"font-size:22px;color:#111;\">{subject}\u003C\u002Fh1>'\n        + \"\".join(blocks)\n        + '\u003Chr style=\"border:none;border-top:1px solid #eee;margin:24px 0;\">'\n        '\u003Cp style=\"font-size:12px;color:#888;\">You are receiving this because '\n        'you subscribed. \u003Ca href=\"{{unsubscribe_url}}\">Unsubscribe\u003C\u002Fa>.\u003C\u002Fp>'\n        \"\u003C\u002Fdiv>\"\n    )\n    return subject, body\n\n\nsubject, html = render_html(draft)\n",[18,6126,6127,6149,6168,6177,6193,6198,6206,6229,6236,6256,6263,6284,6289,6298,6303,6308,6325,6336,6343,6348,6365,6370,6374,6381,6385,6389],{"__ignoreMap":258},[262,6128,6129,6131,6134,6136,6138,6141,6143,6145,6147],{"class":181,"line":264},[262,6130,423],{"class":377},[262,6132,6133],{"class":267}," render_html",[262,6135,4735],{"class":429},[262,6137,5869],{"class":271},[262,6139,6140],{"class":429},") -> tuple[",[262,6142,433],{"class":271},[262,6144,608],{"class":429},[262,6146,433],{"class":271},[262,6148,463],{"class":429},[262,6150,6151,6154,6156,6159,6161,6164,6166],{"class":181,"line":282},[262,6152,6153],{"class":429},"    subject ",[262,6155,476],{"class":377},[262,6157,6158],{"class":429}," draft[",[262,6160,6085],{"class":275},[262,6162,6163],{"class":429},"][",[262,6165,102],{"class":271},[262,6167,957],{"class":429},[262,6169,6170,6173,6175],{"class":181,"line":295},[262,6171,6172],{"class":429},"    blocks ",[262,6174,476],{"class":377},[262,6176,489],{"class":429},[262,6178,6179,6181,6184,6186,6188,6191],{"class":181,"line":345},[262,6180,3074],{"class":377},[262,6182,6183],{"class":429}," s ",[262,6185,835],{"class":377},[262,6187,6158],{"class":429},[262,6189,6190],{"class":275},"\"sections\"",[262,6192,463],{"class":429},[262,6194,6195],{"class":181,"line":492},[262,6196,6197],{"class":429},"        blocks.append(\n",[262,6199,6200,6203],{"class":181,"line":503},[262,6201,6202],{"class":377},"            f",[262,6204,6205],{"class":275},"'\u003Ch2 style=\"font-size:18px;margin:24px 0 8px;color:#111;\">'\n",[262,6207,6208,6210,6213,6215,6218,6221,6224,6226],{"class":181,"line":521},[262,6209,6202],{"class":377},[262,6211,6212],{"class":275},"'",[262,6214,3039],{"class":271},[262,6216,6217],{"class":429},"s[",[262,6219,6220],{"class":275},"\"heading\"",[262,6222,6223],{"class":429},"]",[262,6225,654],{"class":271},[262,6227,6228],{"class":275},"\u003C\u002Fh2>'\n",[262,6230,6231,6233],{"class":181,"line":537},[262,6232,6202],{"class":377},[262,6234,6235],{"class":275},"'\u003Cp style=\"font-size:15px;line-height:1.5;color:#333;margin:0 0 8px;\">'\n",[262,6237,6238,6240,6242,6244,6246,6249,6251,6253],{"class":181,"line":549},[262,6239,6202],{"class":377},[262,6241,6212],{"class":275},[262,6243,3039],{"class":271},[262,6245,6217],{"class":429},[262,6247,6248],{"class":275},"\"body\"",[262,6250,6223],{"class":429},[262,6252,654],{"class":271},[262,6254,6255],{"class":275},"\u003C\u002Fp>'\n",[262,6257,6258,6260],{"class":181,"line":570},[262,6259,6202],{"class":377},[262,6261,6262],{"class":275},"'\u003Cp style=\"margin:0 0 16px;\">'\n",[262,6264,6265,6267,6270,6272,6274,6277,6279,6281],{"class":181,"line":579},[262,6266,6202],{"class":377},[262,6268,6269],{"class":275},"'\u003Ca href=\"",[262,6271,3039],{"class":271},[262,6273,6217],{"class":429},[262,6275,6276],{"class":275},"\"url\"",[262,6278,6223],{"class":429},[262,6280,654],{"class":271},[262,6282,6283],{"class":275},"\" style=\"color:#2563eb;\">Read more &rarr;\u003C\u002Fa>\u003C\u002Fp>'\n",[262,6285,6286],{"class":181,"line":586},[262,6287,6288],{"class":429},"        )\n",[262,6290,6291,6294,6296],{"class":181,"line":591},[262,6292,6293],{"class":429},"    body ",[262,6295,476],{"class":377},[262,6297,984],{"class":429},[262,6299,6300],{"class":181,"line":623},[262,6301,6302],{"class":275},"        '\u003Cdiv style=\"max-width:600px;margin:0 auto;padding:24px;'\n",[262,6304,6305],{"class":181,"line":634},[262,6306,6307],{"class":275},"        'font-family:Arial, sans-serif;\">'\n",[262,6309,6310,6312,6315,6317,6320,6322],{"class":181,"line":845},[262,6311,2840],{"class":377},[262,6313,6314],{"class":275},"'\u003Ch1 style=\"font-size:22px;color:#111;\">",[262,6316,3039],{"class":271},[262,6318,6319],{"class":429},"subject",[262,6321,654],{"class":271},[262,6323,6324],{"class":275},"\u003C\u002Fh1>'\n",[262,6326,6327,6330,6333],{"class":181,"line":850},[262,6328,6329],{"class":377},"        +",[262,6331,6332],{"class":275}," \"\"",[262,6334,6335],{"class":429},".join(blocks)\n",[262,6337,6338,6340],{"class":181,"line":864},[262,6339,6329],{"class":377},[262,6341,6342],{"class":275}," '\u003Chr style=\"border:none;border-top:1px solid #eee;margin:24px 0;\">'\n",[262,6344,6345],{"class":181,"line":1683},[262,6346,6347],{"class":275},"        '\u003Cp style=\"font-size:12px;color:#888;\">You are receiving this because '\n",[262,6349,6350,6353,6356,6359,6362],{"class":181,"line":1688},[262,6351,6352],{"class":275},"        'you subscribed. \u003Ca href=\"",[262,6354,6355],{"class":271},"{{",[262,6357,6358],{"class":275},"unsubscribe_url",[262,6360,6361],{"class":271},"}}",[262,6363,6364],{"class":275},"\">Unsubscribe\u003C\u002Fa>.\u003C\u002Fp>'\n",[262,6366,6367],{"class":181,"line":1693},[262,6368,6369],{"class":275},"        \"\u003C\u002Fdiv>\"\n",[262,6371,6372],{"class":181,"line":1728},[262,6373,1011],{"class":429},[262,6375,6376,6378],{"class":181,"line":1737},[262,6377,573],{"class":377},[262,6379,6380],{"class":429}," subject, body\n",[262,6382,6383],{"class":181,"line":1751},[262,6384,583],{"emptyLinePlaceholder":582},[262,6386,6387],{"class":181,"line":1764},[262,6388,583],{"emptyLinePlaceholder":582},[262,6390,6391,6394,6396],{"class":181,"line":1779},[262,6392,6393],{"class":429},"subject, html ",[262,6395,476],{"class":377},[262,6397,6398],{"class":429}," render_html(draft)\n",[14,6400,6401,6402,6405,6406,6409],{},"Always include an unsubscribe link — most email services and anti-spam laws require one. The ",[18,6403,6404],{},"{{unsubscribe_url}}"," placeholder is replaced by your email provider at send time. Save ",[18,6407,6408],{},"html"," to a file and open it in a browser to preview before sending.",[57,6411,6413],{"id":6412},"_4-preview-and-save-the-draft","4. Preview and save the draft",[14,6415,6416],{},"Before spending a send, write the HTML to disk and eyeball it. This also gives you a record of every edition.",[253,6418,6420],{"className":414,"code":6419,"language":416,"meta":258,"style":258},"from pathlib import Path\n\nPath(\"output\").mkdir(exist_ok=True)\nPath(\"output\u002Fnewsletter.html\").write_text(html, encoding=\"utf-8\")\nprint(f\"Saved. Subject: {subject}\")\n",[18,6421,6422,6432,6436,6453,6471],{"__ignoreMap":258},[262,6423,6424,6426,6428,6430],{"class":181,"line":264},[262,6425,705],{"class":377},[262,6427,4882],{"class":429},[262,6429,684],{"class":377},[262,6431,4887],{"class":429},[262,6433,6434],{"class":181,"line":282},[262,6435,583],{"emptyLinePlaceholder":582},[262,6437,6438,6441,6443,6445,6447,6449,6451],{"class":181,"line":295},[262,6439,6440],{"class":429},"Path(",[262,6442,4963],{"class":275},[262,6444,4966],{"class":429},[262,6446,4969],{"class":611},[262,6448,476],{"class":377},[262,6450,4974],{"class":271},[262,6452,660],{"class":429},[262,6454,6455,6457,6460,6463,6465,6467,6469],{"class":181,"line":345},[262,6456,6440],{"class":429},[262,6458,6459],{"class":275},"\"output\u002Fnewsletter.html\"",[262,6461,6462],{"class":429},").write_text(html, ",[262,6464,612],{"class":611},[262,6466,476],{"class":377},[262,6468,617],{"class":275},[262,6470,660],{"class":429},[262,6472,6473,6475,6477,6479,6482,6484,6486,6488,6490],{"class":181,"line":492},[262,6474,637],{"class":271},[262,6476,602],{"class":429},[262,6478,642],{"class":377},[262,6480,6481],{"class":275},"\"Saved. Subject: ",[262,6483,3039],{"class":271},[262,6485,6319],{"class":429},[262,6487,654],{"class":271},[262,6489,1176],{"class":275},[262,6491,660],{"class":429},[14,6493,3772,6494,6497],{},[18,6495,6496],{},"output\u002Fnewsletter.html"," in any browser. If a section reads awkwardly, edit the note in Step 1 and rerun — the model only knows what you tell it.",[57,6499,6501],{"id":6500},"_5-send-it-through-an-email-api-with-httpx","5. Send it through an email API with httpx",[14,6503,6504,6505,6511,6512,6514],{},"Once the preview looks right, send it. This example uses ",[51,6506,6510],{"href":6507,"rel":6508},"https:\u002F\u002Fresend.com",[6509],"nofollow","Resend",", which has a simple API and a free tier, but Postmark and SendGrid work the same way — only the URL and field names change. We use ",[18,6513,5450],{}," to POST (send) the email as JSON.",[253,6516,6518],{"className":414,"code":6517,"language":416,"meta":258,"style":258},"import httpx\n\n\ndef send_newsletter(subject: str, html: str, to: str, sender: str) -> dict:\n    response = httpx.post(\n        \"https:\u002F\u002Fapi.resend.com\u002Femails\",\n        headers={\n            \"Authorization\": f\"Bearer {os.getenv('RESEND_API_KEY')}\",\n            \"Content-Type\": \"application\u002Fjson\",\n        },\n        json={\n            \"from\": sender,\n            \"to\": [to],\n            \"subject\": subject,\n            \"html\": html,\n        },\n        timeout=30.0,\n    )\n    response.raise_for_status()\n    return response.json()\n\n\nresult = send_newsletter(\n    subject, html, to=\"you@example.com\", sender=\"news@yourdomain.com\"\n)\nprint(\"Sent:\", result.get(\"id\"))\n",[18,6519,6520,6527,6531,6535,6568,6577,6584,6594,6621,6633,6638,6647,6655,6663,6671,6679,6683,6695,6699,6704,6711,6715,6719,6729,6752,6756],{"__ignoreMap":258},[262,6521,6522,6524],{"class":181,"line":264},[262,6523,684],{"class":377},[262,6525,6526],{"class":429}," httpx\n",[262,6528,6529],{"class":181,"line":282},[262,6530,583],{"emptyLinePlaceholder":582},[262,6532,6533],{"class":181,"line":295},[262,6534,583],{"emptyLinePlaceholder":582},[262,6536,6537,6539,6542,6545,6547,6550,6552,6555,6557,6560,6562,6564,6566],{"class":181,"line":345},[262,6538,423],{"class":377},[262,6540,6541],{"class":267}," send_newsletter",[262,6543,6544],{"class":429},"(subject: ",[262,6546,433],{"class":271},[262,6548,6549],{"class":429},", html: ",[262,6551,433],{"class":271},[262,6553,6554],{"class":429},", to: ",[262,6556,433],{"class":271},[262,6558,6559],{"class":429},", sender: ",[262,6561,433],{"class":271},[262,6563,1939],{"class":429},[262,6565,5869],{"class":271},[262,6567,1160],{"class":429},[262,6569,6570,6572,6574],{"class":181,"line":492},[262,6571,1184],{"class":429},[262,6573,476],{"class":377},[262,6575,6576],{"class":429}," httpx.post(\n",[262,6578,6579,6582],{"class":181,"line":503},[262,6580,6581],{"class":275},"        \"https:\u002F\u002Fapi.resend.com\u002Femails\"",[262,6583,1315],{"class":429},[262,6585,6586,6589,6591],{"class":181,"line":521},[262,6587,6588],{"class":611},"        headers",[262,6590,476],{"class":377},[262,6592,6593],{"class":429},"{\n",[262,6595,6596,6599,6601,6603,6606,6608,6610,6613,6615,6617,6619],{"class":181,"line":537},[262,6597,6598],{"class":275},"            \"Authorization\"",[262,6600,1231],{"class":429},[262,6602,642],{"class":377},[262,6604,6605],{"class":275},"\"Bearer ",[262,6607,3039],{"class":271},[262,6609,1199],{"class":429},[262,6611,6612],{"class":275},"'RESEND_API_KEY'",[262,6614,5987],{"class":429},[262,6616,654],{"class":271},[262,6618,1176],{"class":275},[262,6620,1315],{"class":429},[262,6622,6623,6626,6628,6631],{"class":181,"line":549},[262,6624,6625],{"class":275},"            \"Content-Type\"",[262,6627,1231],{"class":429},[262,6629,6630],{"class":275},"\"application\u002Fjson\"",[262,6632,1315],{"class":429},[262,6634,6635],{"class":181,"line":570},[262,6636,6637],{"class":429},"        },\n",[262,6639,6640,6643,6645],{"class":181,"line":579},[262,6641,6642],{"class":611},"        json",[262,6644,476],{"class":377},[262,6646,6593],{"class":429},[262,6648,6649,6652],{"class":181,"line":586},[262,6650,6651],{"class":275},"            \"from\"",[262,6653,6654],{"class":429},": sender,\n",[262,6656,6657,6660],{"class":181,"line":591},[262,6658,6659],{"class":275},"            \"to\"",[262,6661,6662],{"class":429},": [to],\n",[262,6664,6665,6668],{"class":181,"line":623},[262,6666,6667],{"class":275},"            \"subject\"",[262,6669,6670],{"class":429},": subject,\n",[262,6672,6673,6676],{"class":181,"line":634},[262,6674,6675],{"class":275},"            \"html\"",[262,6677,6678],{"class":429},": html,\n",[262,6680,6681],{"class":181,"line":845},[262,6682,6637],{"class":429},[262,6684,6685,6688,6690,6693],{"class":181,"line":850},[262,6686,6687],{"class":611},"        timeout",[262,6689,476],{"class":377},[262,6691,6692],{"class":271},"30.0",[262,6694,1315],{"class":429},[262,6696,6697],{"class":181,"line":864},[262,6698,1011],{"class":429},[262,6700,6701],{"class":181,"line":1683},[262,6702,6703],{"class":429},"    response.raise_for_status()\n",[262,6705,6706,6708],{"class":181,"line":1688},[262,6707,573],{"class":377},[262,6709,6710],{"class":429}," response.json()\n",[262,6712,6713],{"class":181,"line":1693},[262,6714,583],{"emptyLinePlaceholder":582},[262,6716,6717],{"class":181,"line":1728},[262,6718,583],{"emptyLinePlaceholder":582},[262,6720,6721,6724,6726],{"class":181,"line":1737},[262,6722,6723],{"class":429},"result ",[262,6725,476],{"class":377},[262,6727,6728],{"class":429}," send_newsletter(\n",[262,6730,6731,6734,6737,6739,6742,6744,6747,6749],{"class":181,"line":1751},[262,6732,6733],{"class":429},"    subject, html, ",[262,6735,6736],{"class":611},"to",[262,6738,476],{"class":377},[262,6740,6741],{"class":275},"\"you@example.com\"",[262,6743,608],{"class":429},[262,6745,6746],{"class":611},"sender",[262,6748,476],{"class":377},[262,6750,6751],{"class":275},"\"news@yourdomain.com\"\n",[262,6753,6754],{"class":181,"line":1764},[262,6755,660],{"class":429},[262,6757,6758,6760,6762,6765,6768,6771],{"class":181,"line":1779},[262,6759,637],{"class":271},[262,6761,602],{"class":429},[262,6763,6764],{"class":275},"\"Sent:\"",[262,6766,6767],{"class":429},", result.get(",[262,6769,6770],{"class":275},"\"id\"",[262,6772,2684],{"class":429},[14,6774,6775,6776,6779],{},"Always send a test to yourself first. The ",[18,6777,6778],{},"raise_for_status()"," call turns any failure (bad key, unverified domain) into a clear error instead of a silent miss. To email a whole list, loop over your subscribers — but batch carefully and respect your provider's rate limits.",[57,6781,6782],{"id":1366},"Parameter quick-reference",[1379,6784,6785,6797],{},[1382,6786,6787],{},[1385,6788,6789,6791,6793,6795],{},[1388,6790,1390],{},[1388,6792,3795],{},[1388,6794,3798],{},[1388,6796,1396],{},[1398,6798,6799,6814,6829,6845],{},[1385,6800,6801,6805,6807,6811],{},[1403,6802,6803],{},[18,6804,805],{},[1403,6806,433],{},[1403,6808,6809],{},[18,6810,2703],{},[1403,6812,6813],{},"Which LLM drafts the copy. Larger models cost more but handle nuanced tone better.",[1385,6815,6816,6820,6822,6826],{},[1403,6817,6818],{},[18,6819,3829],{},[1403,6821,3832],{},[1403,6823,6824],{},[18,6825,4445],{},[1403,6827,6828],{},"Creativity of the writing. Lower is safer and more literal; higher is more varied.",[1385,6830,6831,6835,6837,6842],{},[1403,6832,6833],{},[18,6834,5745],{},[1403,6836,5869],{},[1403,6838,6839],{},[18,6840,6841],{},"{\"type\": \"json_object\"}",[1403,6843,6844],{},"Forces valid JSON so Python can parse sections without cleanup.",[1385,6846,6847,6851,6853,6857],{},[1403,6848,6849],{},[18,6850,1591],{},[1403,6852,3832],{},[1403,6854,6855],{},[18,6856,6692],{},[1403,6858,6859,6860,6862],{},"Seconds ",[18,6861,5450],{}," waits for the email API before failing instead of hanging.",[57,6864,1445],{"id":1444},[1447,6866,6867,6880,6892,6905],{},[1450,6868,6869,6875,6876,6879],{},[35,6870,6871,6872,1363],{},"The model returns text outside the JSON, breaking ",[18,6873,6874],{},"json.loads()"," Cause: the prompt did not firmly require JSON. Fix: keep ",[18,6877,6878],{},"response_format={\"type\": \"json_object\"}"," set and include the word \"JSON\" in your system prompt, which the format mode requires.",[1450,6881,6882,6885,6886,6888,6889,6891],{},[35,6883,6884],{},"Email arrives but shows raw HTML tags as text."," Cause: you sent the HTML in the API's plain-text field. Fix: pass your markup in the ",[18,6887,6408],{}," field (as shown), not the ",[18,6890,111],{}," field.",[1450,6893,6894,6897,6898,6900,6901,6904],{},[35,6895,6896],{},"Resend returns 403 with a domain error."," Cause: your ",[18,6899,705],{}," domain is not verified. Fix: verify your sending domain in the Resend dashboard, or use their ",[18,6902,6903],{},"onboarding@resend.dev"," test address while developing.",[1450,6906,6907,6910,6911,6914],{},[35,6908,6909],{},"Styles vanish in Outlook."," Cause: Outlook ignores ",[18,6912,6913],{},"\u003Cstyle>"," blocks and many CSS properties. Fix: keep styles inline on each tag (as the template does) and avoid flexbox or grid layouts.",[57,6916,2317],{"id":2316},[2322,6918,6919,6925,6931],{},[1450,6920,6921,6924],{},[35,6922,6923],{},"Use this Python approach when"," you send a recurring newsletter built from links and notes you already collect, and you want the drafting and formatting automated end to end. It scales to many editions for the cost of a few cents each.",[1450,6926,6927,6930],{},[35,6928,6929],{},"Use a no-code tool like Mailchimp or Beehiiv when"," you need drag-and-drop design, audience analytics, and signup forms more than automation, and you only send occasionally. The visual editor is faster for one-off, design-heavy sends.",[1450,6932,6933,6936,6937,6939,6940,1363],{},[35,6934,6935],{},"Use a fuller content pipeline when"," newsletters are one output among many. If you also publish long-form posts, pair this with ",[51,6938,3983],{"href":3982}," so the same notes feed both, and reuse the rewriting patterns from ",[51,6941,2462],{"href":5290},[14,6943,2375,6944,1363],{},[51,6945,3991],{"href":3990},[57,6947,2381],{"id":2380},[2322,6949,6950,6955,6960,6965],{},[1450,6951,6952,6954],{},[51,6953,3991],{"href":3990}," — the main guide for automating written content with Python and AI.",[1450,6956,6957,6959],{},[51,6958,3983],{"href":3982}," — turn a keyword into a full Markdown draft.",[1450,6961,6962,6964],{},[51,6963,2462],{"href":5290}," — rework many short copy items at once.",[1450,6966,6967,6969],{},[51,6968,1362],{"href":1361}," — make the model return clean, parseable structure every time.",[2401,6971,6972],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":258,"searchDepth":282,"depth":282,"links":6974},[6975,6976,6977,6978,6979,6980,6981,6982,6983,6984],{"id":237,"depth":282,"text":238},{"id":5567,"depth":282,"text":5568},{"id":5738,"depth":282,"text":5739},{"id":6117,"depth":282,"text":6118},{"id":6412,"depth":282,"text":6413},{"id":6500,"depth":282,"text":6501},{"id":1366,"depth":282,"text":6782},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Turn a list of links and notes into a formatted HTML email newsletter with Python and an LLM — subject lines, sections, and optional API sending.",[6987,6990,6993,6996,6999],{"q":6988,"a":6989},"Can Python write an email newsletter from a list of links?","Yes. You collect your links and notes into a simple Python list, send them to a language model with a prompt that describes the format you want, and the model returns subject lines and section copy. Python then wraps that text in HTML so it renders as a real email.",{"q":6991,"a":6992},"Do I need a designer to make the HTML email look good?","No. Email clients ignore most modern CSS, so a plain, inline-styled HTML template is both safer and easier than a designed one. The small template in this guide renders correctly in Gmail, Outlook, and Apple Mail without any design tools.",{"q":6994,"a":6995},"Which model should I use for newsletter copy?","A small, fast model like gpt-4o-mini is enough for most newsletters and keeps costs near zero. Use a larger model only if your tone is very specific or your sections are long and analytical.",{"q":6997,"a":6998},"How do I actually send the email once it is generated?","Send it through an email API such as Resend, Postmark, or SendGrid using httpx, an HTTP library for Python. You pass your API key, the recipient, the subject, and the HTML body, and the service delivers it.",{"q":7000,"a":7001},"Will the AI invent facts about my links?","It can, if you give it only a URL. Always pass a short note or summary alongside each link and instruct the model to write only from the notes provided, so it summarizes your words instead of guessing.",{"name":7003,"steps":7004},"How to generate an email newsletter with Python and AI",[7005,7008,7011,7014,7017],{"name":7006,"text":7007},"Install the tools and store your keys","Install the openai SDK, httpx, and python-dotenv, then keep your API keys in a .env file.",{"name":7009,"text":7010},"Collect your links and notes","Gather each newsletter item as a small Python dictionary with a title, URL, and a short note.",{"name":7012,"text":7013},"Generate sections and subject lines with the LLM","Send the items to the model and ask for a JSON object containing subject options and written sections.",{"name":7015,"text":7016},"Render the newsletter as HTML","Wrap the model's sections in a simple inline-styled HTML email template.",{"name":7018,"text":7019},"Send it through an email API with httpx","Post the subject and HTML body to an email service such as Resend using httpx.",{},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-email-newsletters-with-python-and-ai",{"title":4011,"description":6985},"ai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Fgenerate-email-newsletters-with-python-and-ai\u002Findex","39GbpXbn3UU3RV-NjPZBrAnLn5NIL2cSeuS0eiuZqPA",{"id":7026,"title":7027,"body":7028,"description":9354,"extension":2419,"faq":9355,"howto":9371,"meta":9386,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":9387,"published":9388,"seo":9389,"seoTitle":9390,"stem":9391,"__hash__":9392},"content\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Findex.md","AI Copywriting Workflows: A Practical Python Guide",{"type":7,"value":7029,"toc":9341},[7030,7033,7040,7054,7057,7061,7068,7075,7078,7081,7084,7171,7173,7179,7182,7235,7241,7249,7257,7271,7274,7278,7285,7512,7518,7532,7538,7542,7555,7762,7765,7768,7778,7782,7788,7904,7925,7935,7938,7942,7945,8280,8283,8286,8297,8301,8308,8415,8417,8420,8508,8512,8522,9267,9276,9278,9281,9310,9314,9316,9338],[10,7031,7027],{"id":7032},"ai-copywriting-workflows-a-practical-python-guide",[14,7034,7035,7036,7039],{},"Writing the same kind of copy over and over by hand is slow and uneven. You spend an afternoon on twenty product descriptions, the tenth one drifts in tone, and by Friday you cannot remember which version you actually published. An ",[35,7037,7038],{},"AI copywriting workflow"," fixes that by turning copy into a repeatable recipe: you describe the job once, a Python script does the typing, and a quality check catches the weak drafts before anyone reads them.",[14,7041,7042,7043,7046,7047,21,7049,7051,7052,5414],{},"This guide is written for marketers, founders, and creators who are comfortable copying and pasting code but do not write Python for a living. You will build a small, honest pipeline that takes a copy ",[27,7044,7045],{},"brief"," (the facts and instructions for one piece of writing), turns it into a prompt, asks an AI model for a draft, and runs that draft through a review loop. Everything here is complete and runnable on Python 3.10 or newer, using the official ",[18,7048,20],{},[18,7050,5450],{}," for any extra HTTP calls. This section sits inside the larger ",[51,7053,5413],{"href":5412},[14,7055,7056],{},"By the end you will have a script you can point at a spreadsheet of products or topics and walk away. Along the way you will learn how to keep the tone consistent, how to stop the model from inventing claims, and how to know when a draft is good enough to ship.",[57,7058,7060],{"id":7059},"who-needs-this-and-what-it-solves","Who needs this and what it solves",[14,7062,7063,7064,7067],{},"You need a copywriting workflow when the writing is ",[27,7065,7066],{},"repetitive but not identical",". One landing page is a one-off; fifty meta descriptions, a weekly newsletter, or a catalogue of product blurbs is a workflow. The pattern is always the same: real facts go in, on-brand copy comes out, and a check makes sure nothing embarrassing slips through.",[14,7069,7070,7071,7074],{},"The trap most people fall into is treating an AI chat window as the whole solution. You paste a request, get a draft, tweak it, copy it somewhere, and repeat. That works for one or two items and falls apart at twenty, because nothing is recorded, nothing is consistent, and nothing stops a bad draft from going out. A workflow is the opposite: the instructions live in code, every item is built the same way, and a quality gate sits between the model and your audience. The reward is not just speed. It is ",[27,7072,7073],{},"consistency you can trust"," and an audit trail you can point to when someone asks why a piece of copy says what it says.",[14,7076,7077],{},"There is a second, quieter benefit. Once the brief is a structured object rather than a sentence you typed from memory, you stop relying on yourself to remember the rules. Brand voice, length limits, and forbidden claims all become fields and checks that apply automatically. New team members can run the same script and get the same standard of output, which is the difference between a personal habit and a system the business owns.",[14,7079,7080],{},"A good rule of thumb: if you would copy and paste roughly the same prompt more than five times, it belongs in a script. Below that, the manual approach is fine. Above it, the time you spend wiring up the workflow pays for itself within the first batch, and keeps paying every time you run it again.",[14,7082,7083],{},"The diagram below shows the loop you are about to build. A brief becomes a prompt, the prompt produces a draft, the draft passes through a quality gate, and anything that fails goes back for another pass. That feedback loop is the whole point, so keep it in mind as you read.",[76,7085,7087,7168],{"className":7086},[79],[81,7088,90,7093,90,7096,90,7099,90,7102,90,7106,90,7109,90,7111,90,7114,90,7117,90,7119,90,7122,90,7125,90,7127,90,7130,90,7133,90,7138,90,7141,7144,90,7148,90,7152,90,7156],{"viewBox":7089,"role":84,"ariaLabelledBy":7090,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1000 420",[7091,7092],"diagTitle","diagDesc",[92,7094,7095],{"id":7091},"The brief to draft to quality-check loop",[96,7097,7098],{"id":7092},"A copy brief becomes a prompt, the prompt produces an AI draft, the draft is checked, and failing drafts loop back to be regenerated while passing drafts are published.",[100,7100],{"x":102,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"120",[111,7103,7105],{"x":113,"y":7104,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"152","1. Copy brief",[111,7107,7108],{"x":113,"y":103,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"facts + tone",[100,7110],{"x":129,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,7112,7113],{"x":133,"y":7104,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"2. Prompt",[111,7115,7116],{"x":133,"y":103,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"system + user",[100,7118],{"x":158,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,7120,7121],{"x":161,"y":7104,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"3. AI draft",[111,7123,7124],{"x":161,"y":103,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"openai SDK",[100,7126],{"x":168,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,7128,7129],{"x":172,"y":7104,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"4. Quality check",[111,7131,7132],{"x":172,"y":103,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"rules + retry",[181,7134],{"x1":104,"y1":7135,"x2":7136,"y2":7135,"stroke":143,"strokeWidth":109,"markerEnd":7137},"156","236","url(#arrowCopy)",[181,7139],{"x1":198,"y1":7135,"x2":7140,"y2":7135,"stroke":143,"strokeWidth":109,"markerEnd":7137},"476",[181,7142],{"x1":205,"y1":7135,"x2":7143,"y2":7135,"stroke":143,"strokeWidth":109,"markerEnd":7137},"716",[111,7145,7147],{"x":172,"y":7146,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":130,"textAnchor":119},"232","\npass → publish\n",[216,7149],{"d":7150,"fill":219,"stroke":169,"strokeWidth":109,"strokeDashArray":7151,"markerEnd":7137},"M820 192 L820 300 L340 300 L340 194",[221,222],[111,7153,7155],{"x":161,"y":7154,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":125,"textAnchor":119},"320","fail → rewrite",[5548,7157,5550,7158,90],{},[5552,7159,5558,7165,5550],{"id":7160,"viewBox":7161,"refX":7162,"refY":222,"markerWidth":7163,"markerHeight":7163,"orient":7164},"arrowCopy","0 0 10 10","9","7","auto-start-reverse",[216,7166],{"d":7167,"fill":143},"M0 0 L10 5 L0 10 z",[232,7169,7170],{},"Every piece of copy travels the same path; failing drafts loop back instead of being published.",[57,7172,238],{"id":237},[14,7174,7175,7176,7178],{},"You need Python 3.10 or newer and an OpenAI API key. To get a key, create an account at the OpenAI platform, open the API keys page, and generate one. If the idea of API keys is new, the ",[51,7177,2487],{"href":2486}," section explains what they are and how billing works.",[14,7180,7181],{},"First, create an isolated project folder with its own virtual environment so these packages do not collide with anything else on your machine. A virtual environment is just a private box for one project's dependencies.",[253,7183,7185],{"className":255,"code":7184,"language":257,"meta":258,"style":258},"mkdir copy-workflow && cd copy-workflow\npython -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\npip install openai httpx python-dotenv\n",[18,7186,7187,7204,7214,7223],{"__ignoreMap":258},[262,7188,7189,7192,7195,7198,7201],{"class":181,"line":264},[262,7190,7191],{"class":267},"mkdir",[262,7193,7194],{"class":275}," copy-workflow",[262,7196,7197],{"class":429}," && ",[262,7199,7200],{"class":271},"cd",[262,7202,7203],{"class":275}," copy-workflow\n",[262,7205,7206,7208,7210,7212],{"class":181,"line":282},[262,7207,416],{"class":267},[262,7209,272],{"class":271},[262,7211,276],{"class":275},[262,7213,279],{"class":275},[262,7215,7216,7218,7220],{"class":181,"line":295},[262,7217,285],{"class":271},[262,7219,288],{"class":275},[262,7221,7222],{"class":291},"        # Windows: .venv\\Scripts\\activate\n",[262,7224,7225,7227,7229,7231,7233],{"class":181,"line":345},[262,7226,298],{"class":267},[262,7228,301],{"class":275},[262,7230,2519],{"class":275},[262,7232,5440],{"class":275},[262,7234,2522],{"class":275},[14,7236,7237,7238,7240],{},"Now store your API key. Create a file named ",[18,7239,319],{}," in the project folder:",[253,7242,7243],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,7244,7245],{"__ignoreMap":258},[262,7246,7247],{"class":181,"line":264},[262,7248,337],{},[14,7250,7251,7252,356,7254,7256],{},"Immediately add ",[18,7253,319],{},[18,7255,359],{}," so the key never lands in version control or a public repository:",[253,7258,7259],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,7260,7261],{"__ignoreMap":258},[262,7262,7263,7265,7267,7269],{"class":181,"line":264},[262,7264,371],{"class":271},[262,7266,374],{"class":275},[262,7268,378],{"class":377},[262,7270,381],{"class":275},[14,7272,7273],{},"That last step matters. A leaked key can run up real charges before you notice. With the environment ready, you can write the four steps of the workflow.",[57,7275,7277],{"id":7276},"step-1-capture-a-reusable-copy-brief-and-load-your-key","Step 1: Capture a reusable copy brief and load your key",[14,7279,7280,7281,7284],{},"A brief is the structured set of facts and instructions for one piece of copy. Keeping it in a small Python data structure means every draft is built from the same fields, so the output stays consistent. Use a ",[18,7282,7283],{},"dataclass",", which is Python's built-in way to bundle named fields together.",[253,7286,7288],{"className":414,"code":7287,"language":416,"meta":258,"style":258},"import os\nfrom dataclasses import dataclass\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # reads OPENAI_API_KEY from your .env file\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\n@dataclass\nclass CopyBrief:\n    product: str          # what you are writing about\n    audience: str         # who will read it\n    tone: str             # e.g. \"friendly\", \"confident\", \"playful\"\n    key_facts: str        # real, true facts the model must use\n    channel: str          # e.g. \"product page\", \"email subject line\"\n\n\nbrief = CopyBrief(\n    product=\"CloudNote, a note app that syncs across devices\",\n    audience=\"busy freelancers\",\n    tone=\"friendly and reassuring\",\n    key_facts=\"syncs in under 2 seconds; works offline; free for one device\",\n    channel=\"product page intro paragraph\",\n)\n",[18,7289,7290,7296,7308,7318,7328,7332,7339,7357,7361,7365,7370,7380,7390,7400,7410,7420,7430,7434,7438,7448,7460,7472,7484,7496,7508],{"__ignoreMap":258},[262,7291,7292,7294],{"class":181,"line":264},[262,7293,684],{"class":377},[262,7295,687],{"class":429},[262,7297,7298,7300,7303,7305],{"class":181,"line":282},[262,7299,705],{"class":377},[262,7301,7302],{"class":429}," dataclasses ",[262,7304,684],{"class":377},[262,7306,7307],{"class":429}," dataclass\n",[262,7309,7310,7312,7314,7316],{"class":181,"line":295},[262,7311,705],{"class":377},[262,7313,708],{"class":429},[262,7315,684],{"class":377},[262,7317,713],{"class":429},[262,7319,7320,7322,7324,7326],{"class":181,"line":345},[262,7321,705],{"class":377},[262,7323,720],{"class":429},[262,7325,684],{"class":377},[262,7327,725],{"class":429},[262,7329,7330],{"class":181,"line":492},[262,7331,583],{"emptyLinePlaceholder":582},[262,7333,7334,7336],{"class":181,"line":503},[262,7335,4222],{"class":429},[262,7337,7338],{"class":291},"# reads OPENAI_API_KEY from your .env file\n",[262,7340,7341,7343,7345,7347,7349,7351,7353,7355],{"class":181,"line":521},[262,7342,739],{"class":429},[262,7344,476],{"class":377},[262,7346,1588],{"class":429},[262,7348,2674],{"class":611},[262,7350,476],{"class":377},[262,7352,1199],{"class":429},[262,7354,2681],{"class":275},[262,7356,2684],{"class":429},[262,7358,7359],{"class":181,"line":537},[262,7360,583],{"emptyLinePlaceholder":582},[262,7362,7363],{"class":181,"line":549},[262,7364,583],{"emptyLinePlaceholder":582},[262,7366,7367],{"class":181,"line":570},[262,7368,7369],{"class":267},"@dataclass\n",[262,7371,7372,7375,7378],{"class":181,"line":579},[262,7373,7374],{"class":377},"class",[262,7376,7377],{"class":267}," CopyBrief",[262,7379,1160],{"class":429},[262,7381,7382,7385,7387],{"class":181,"line":586},[262,7383,7384],{"class":429},"    product: ",[262,7386,433],{"class":271},[262,7388,7389],{"class":291},"          # what you are writing about\n",[262,7391,7392,7395,7397],{"class":181,"line":591},[262,7393,7394],{"class":429},"    audience: ",[262,7396,433],{"class":271},[262,7398,7399],{"class":291},"         # who will read it\n",[262,7401,7402,7405,7407],{"class":181,"line":623},[262,7403,7404],{"class":429},"    tone: ",[262,7406,433],{"class":271},[262,7408,7409],{"class":291},"             # e.g. \"friendly\", \"confident\", \"playful\"\n",[262,7411,7412,7415,7417],{"class":181,"line":634},[262,7413,7414],{"class":429},"    key_facts: ",[262,7416,433],{"class":271},[262,7418,7419],{"class":291},"        # real, true facts the model must use\n",[262,7421,7422,7425,7427],{"class":181,"line":845},[262,7423,7424],{"class":429},"    channel: ",[262,7426,433],{"class":271},[262,7428,7429],{"class":291},"          # e.g. \"product page\", \"email subject line\"\n",[262,7431,7432],{"class":181,"line":850},[262,7433,583],{"emptyLinePlaceholder":582},[262,7435,7436],{"class":181,"line":864},[262,7437,583],{"emptyLinePlaceholder":582},[262,7439,7440,7443,7445],{"class":181,"line":1683},[262,7441,7442],{"class":429},"brief ",[262,7444,476],{"class":377},[262,7446,7447],{"class":429}," CopyBrief(\n",[262,7449,7450,7453,7455,7458],{"class":181,"line":1688},[262,7451,7452],{"class":611},"    product",[262,7454,476],{"class":377},[262,7456,7457],{"class":275},"\"CloudNote, a note app that syncs across devices\"",[262,7459,1315],{"class":429},[262,7461,7462,7465,7467,7470],{"class":181,"line":1693},[262,7463,7464],{"class":611},"    audience",[262,7466,476],{"class":377},[262,7468,7469],{"class":275},"\"busy freelancers\"",[262,7471,1315],{"class":429},[262,7473,7474,7477,7479,7482],{"class":181,"line":1728},[262,7475,7476],{"class":611},"    tone",[262,7478,476],{"class":377},[262,7480,7481],{"class":275},"\"friendly and reassuring\"",[262,7483,1315],{"class":429},[262,7485,7486,7489,7491,7494],{"class":181,"line":1737},[262,7487,7488],{"class":611},"    key_facts",[262,7490,476],{"class":377},[262,7492,7493],{"class":275},"\"syncs in under 2 seconds; works offline; free for one device\"",[262,7495,1315],{"class":429},[262,7497,7498,7501,7503,7506],{"class":181,"line":1751},[262,7499,7500],{"class":611},"    channel",[262,7502,476],{"class":377},[262,7504,7505],{"class":275},"\"product page intro paragraph\"",[262,7507,1315],{"class":429},[262,7509,7510],{"class":181,"line":1764},[262,7511,660],{"class":429},[14,7513,3349,7514,7517],{},[18,7515,7516],{},"key_facts"," field is your guardrail against invented claims. Whatever you put there is what the model is allowed to say about features. Leave it vague and the model will fill gaps with plausible-sounding fiction, so be specific and truthful.",[14,7519,7520,7521,7523,7524,7527,7528,7531],{},"Why a ",[18,7522,7283],{}," and not a plain dictionary? Two reasons that matter once you have more than a handful of items. First, every brief is forced to have the same fields, so you cannot accidentally forget ",[18,7525,7526],{},"tone"," on one product and not another. Second, your code editor can autocomplete ",[18,7529,7530],{},"brief.audience",", which catches typos before you run anything. If you are loading briefs from a spreadsheet later, each row maps cleanly onto these fields, so the structure you choose here is the structure your whole pipeline speaks.",[14,7533,7534,7535,7537],{},"Keep the brief small and stable. The temptation is to add a dozen fields for every edge case, but a brief that nobody can fill in quickly stops getting used. Five clear fields that cover audience, tone, facts, product, and channel handle the vast majority of marketing copy. Add a field only when you find yourself repeatedly cramming the same extra instruction into ",[18,7536,7516],{}," where it does not belong.",[57,7539,7541],{"id":7540},"step-2-turn-the-brief-into-a-clear-prompt","Step 2: Turn the brief into a clear prompt",[14,7543,7544,7545,7547,7548,7550,7551,1363],{},"A prompt has two halves. The ",[27,7546,4466],{}," message sets the role and rules and stays constant; the ",[27,7549,4470],{}," message carries the specific brief. Splitting them this way keeps your brand rules in one place while the details change per item. For a deeper look at writing instructions that behave predictably, see ",[51,7552,7554],{"href":7553},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002F","Prompt Engineering Basics",[253,7556,7558],{"className":414,"code":7557,"language":416,"meta":258,"style":258},"def build_messages(brief: CopyBrief) -> list[dict]:\n    system = (\n        \"You are a senior copywriter. Write clear, specific marketing copy. \"\n        \"Use ONLY the facts provided; never invent features, prices, or claims. \"\n        \"Avoid hype words like 'revolutionary' or 'guaranteed'. \"\n        \"Return only the copy, with no preamble or quotation marks.\"\n    )\n    user = (\n        f\"Write a {brief.channel} for {brief.product}.\\n\"\n        f\"Audience: {brief.audience}.\\n\"\n        f\"Tone: {brief.tone}.\\n\"\n        f\"Facts you may use: {brief.key_facts}.\\n\"\n        f\"Keep it under 80 words.\"\n    )\n    return [\n        {\"role\": \"system\", \"content\": system},\n        {\"role\": \"user\", \"content\": user},\n    ]\n",[18,7559,7560,7574,7583,7588,7593,7598,7603,7607,7616,7646,7665,7685,7705,7712,7716,7722,7740,7757],{"__ignoreMap":258},[262,7561,7562,7564,7567,7570,7572],{"class":181,"line":264},[262,7563,423],{"class":377},[262,7565,7566],{"class":267}," build_messages",[262,7568,7569],{"class":429},"(brief: CopyBrief) -> list[",[262,7571,5869],{"class":271},[262,7573,463],{"class":429},[262,7575,7576,7579,7581],{"class":181,"line":282},[262,7577,7578],{"class":429},"    system ",[262,7580,476],{"class":377},[262,7582,984],{"class":429},[262,7584,7585],{"class":181,"line":295},[262,7586,7587],{"class":275},"        \"You are a senior copywriter. Write clear, specific marketing copy. \"\n",[262,7589,7590],{"class":181,"line":345},[262,7591,7592],{"class":275},"        \"Use ONLY the facts provided; never invent features, prices, or claims. \"\n",[262,7594,7595],{"class":181,"line":492},[262,7596,7597],{"class":275},"        \"Avoid hype words like 'revolutionary' or 'guaranteed'. \"\n",[262,7599,7600],{"class":181,"line":503},[262,7601,7602],{"class":275},"        \"Return only the copy, with no preamble or quotation marks.\"\n",[262,7604,7605],{"class":181,"line":521},[262,7606,1011],{"class":429},[262,7608,7609,7612,7614],{"class":181,"line":537},[262,7610,7611],{"class":429},"    user ",[262,7613,476],{"class":377},[262,7615,984],{"class":429},[262,7617,7618,7620,7623,7625,7628,7630,7633,7635,7638,7640,7642,7644],{"class":181,"line":549},[262,7619,2840],{"class":377},[262,7621,7622],{"class":275},"\"Write a ",[262,7624,3039],{"class":271},[262,7626,7627],{"class":429},"brief.channel",[262,7629,654],{"class":271},[262,7631,7632],{"class":275}," for ",[262,7634,3039],{"class":271},[262,7636,7637],{"class":429},"brief.product",[262,7639,654],{"class":271},[262,7641,1363],{"class":275},[262,7643,2137],{"class":271},[262,7645,1257],{"class":275},[262,7647,7648,7650,7653,7655,7657,7659,7661,7663],{"class":181,"line":570},[262,7649,2840],{"class":377},[262,7651,7652],{"class":275},"\"Audience: ",[262,7654,3039],{"class":271},[262,7656,7530],{"class":429},[262,7658,654],{"class":271},[262,7660,1363],{"class":275},[262,7662,2137],{"class":271},[262,7664,1257],{"class":275},[262,7666,7667,7669,7672,7674,7677,7679,7681,7683],{"class":181,"line":579},[262,7668,2840],{"class":377},[262,7670,7671],{"class":275},"\"Tone: ",[262,7673,3039],{"class":271},[262,7675,7676],{"class":429},"brief.tone",[262,7678,654],{"class":271},[262,7680,1363],{"class":275},[262,7682,2137],{"class":271},[262,7684,1257],{"class":275},[262,7686,7687,7689,7692,7694,7697,7699,7701,7703],{"class":181,"line":586},[262,7688,2840],{"class":377},[262,7690,7691],{"class":275},"\"Facts you may use: ",[262,7693,3039],{"class":271},[262,7695,7696],{"class":429},"brief.key_facts",[262,7698,654],{"class":271},[262,7700,1363],{"class":275},[262,7702,2137],{"class":271},[262,7704,1257],{"class":275},[262,7706,7707,7709],{"class":181,"line":591},[262,7708,2840],{"class":377},[262,7710,7711],{"class":275},"\"Keep it under 80 words.\"\n",[262,7713,7714],{"class":181,"line":623},[262,7715,1011],{"class":429},[262,7717,7718,7720],{"class":181,"line":634},[262,7719,573],{"class":377},[262,7721,5589],{"class":429},[262,7723,7724,7727,7729,7731,7733,7735,7737],{"class":181,"line":845},[262,7725,7726],{"class":429},"        {",[262,7728,1228],{"class":275},[262,7730,1231],{"class":429},[262,7732,1234],{"class":275},[262,7734,608],{"class":429},[262,7736,1239],{"class":275},[262,7738,7739],{"class":429},": system},\n",[262,7741,7742,7744,7746,7748,7750,7752,7754],{"class":181,"line":850},[262,7743,7726],{"class":429},[262,7745,1228],{"class":275},[262,7747,1231],{"class":429},[262,7749,1291],{"class":275},[262,7751,608],{"class":429},[262,7753,1239],{"class":275},[262,7755,7756],{"class":429},": user},\n",[262,7758,7759],{"class":181,"line":864},[262,7760,7761],{"class":429},"    ]\n",[14,7763,7764],{},"Notice how the rules (\"use only the facts\", \"avoid hype words\") live in the system message. You write them once and every draft inherits them. The user message is just the filled-in brief.",[14,7766,7767],{},"This split is the single most useful habit in the whole workflow. Think of the system message as your style guide and the user message as the work order. When you decide next month that all copy should end with a question, or that one particular word is off-limits, you change the system message in one place and every future draft obeys. If those rules were tangled into each user prompt, you would be editing them everywhere and missing some.",[14,7769,7770,7771,7774,7775,7777],{},"Two small details earn their keep here. Telling the model to \"return only the copy, with no preamble or quotation marks\" stops the chatty wrappers like ",[27,7772,7773],{},"Sure, here is your copy:"," that you would otherwise have to strip out by hand. And the explicit word limit (\"under 80 words\") gives the model a target it can actually hit, rather than leaving length to chance. Vague prompts produce vague results; specific constraints produce copy you can use as-is. If you write a lot of these, the ",[51,7776,5270],{"href":5269}," guide collects ready-made patterns you can drop straight into the system message.",[57,7779,7781],{"id":7780},"step-3-generate-a-draft-with-the-openai-sdk","Step 3: Generate a draft with the OpenAI SDK",[14,7783,7784,7785,7787],{},"Now send the messages to the model. The ",[18,7786,3829],{}," setting controls how creative the model is: lower means steadier and more predictable, higher means more varied. For copy you want to review at scale, a middle value around 0.6 is a sensible default.",[253,7789,7791],{"className":414,"code":7790,"language":416,"meta":258,"style":258},"def generate_copy(brief: CopyBrief, temperature: float = 0.6) -> str:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=build_messages(brief),\n        temperature=temperature,\n        max_tokens=200,        # caps length so costs stay predictable\n    )\n    return response.choices[0].message.content.strip()\n\n\ndraft = generate_copy(brief)\nprint(draft)\n",[18,7792,7793,7816,7824,7834,7843,7852,7866,7870,7880,7884,7888,7897],{"__ignoreMap":258},[262,7794,7795,7797,7800,7803,7805,7807,7810,7812,7814],{"class":181,"line":264},[262,7796,423],{"class":377},[262,7798,7799],{"class":267}," generate_copy",[262,7801,7802],{"class":429},"(brief: CopyBrief, temperature: ",[262,7804,3832],{"class":271},[262,7806,442],{"class":377},[262,7808,7809],{"class":271}," 0.6",[262,7811,1939],{"class":429},[262,7813,433],{"class":271},[262,7815,1160],{"class":429},[262,7817,7818,7820,7822],{"class":181,"line":282},[262,7819,1184],{"class":429},[262,7821,476],{"class":377},[262,7823,1189],{"class":429},[262,7825,7826,7828,7830,7832],{"class":181,"line":295},[262,7827,1194],{"class":611},[262,7829,476],{"class":377},[262,7831,1207],{"class":275},[262,7833,1315],{"class":429},[262,7835,7836,7838,7840],{"class":181,"line":345},[262,7837,1215],{"class":611},[262,7839,476],{"class":377},[262,7841,7842],{"class":429},"build_messages(brief),\n",[262,7844,7845,7847,7849],{"class":181,"line":492},[262,7846,1308],{"class":611},[262,7848,476],{"class":377},[262,7850,7851],{"class":429},"temperature,\n",[262,7853,7854,7856,7858,7860,7863],{"class":181,"line":503},[262,7855,4679],{"class":611},[262,7857,476],{"class":377},[262,7859,104],{"class":271},[262,7861,7862],{"class":429},",        ",[262,7864,7865],{"class":291},"# caps length so costs stay predictable\n",[262,7867,7868],{"class":181,"line":521},[262,7869,1011],{"class":429},[262,7871,7872,7874,7876,7878],{"class":181,"line":537},[262,7873,573],{"class":377},[262,7875,1326],{"class":429},[262,7877,102],{"class":271},[262,7879,3205],{"class":429},[262,7881,7882],{"class":181,"line":549},[262,7883,583],{"emptyLinePlaceholder":582},[262,7885,7886],{"class":181,"line":570},[262,7887,583],{"emptyLinePlaceholder":582},[262,7889,7890,7892,7894],{"class":181,"line":579},[262,7891,6061],{"class":429},[262,7893,476],{"class":377},[262,7895,7896],{"class":429}," generate_copy(brief)\n",[262,7898,7899,7901],{"class":181,"line":586},[262,7900,637],{"class":271},[262,7902,7903],{"class":429},"(draft)\n",[14,7905,7906,7907,7910,7911,7914,7915,7918,7919,7922,7923,1363],{},"The draft text lives at ",[18,7908,7909],{},"response.choices[0].message.content",". That path looks fussy, but it is the standard shape of an OpenAI chat response: a list of ",[18,7912,7913],{},"choices",", each holding a ",[18,7916,7917],{},"message"," with ",[18,7920,7921],{},"content",". To learn how to read every other field and track token usage, follow ",[51,7924,3983],{"href":3982},[14,7926,7927,7928,7930,7931,7934],{},"A word on ",[18,7929,3846],{},", because it surprises people. A ",[27,7932,7933],{},"token"," is roughly three-quarters of a word, so 200 tokens is about 150 words of output. Setting this cap does two things: it stops a runaway response from costing more than you expect, and it gives you a hard ceiling that pairs with the word limit in your prompt. If your drafts come back truncated mid-sentence, the cap is too low for the length you asked for, so raise it. If you are generating short blurbs, lowering it keeps your bill tight across a large batch.",[14,7936,7937],{},"Temperature deserves a second look too, because it is the dial you will reach for most. At a low setting the model plays it safe and tends to produce similar phrasing each time, which is ideal when you want predictable, easily reviewed copy. Turn it up and the model takes more risks with word choice, which is exactly what you want when you ask for three different headline options and pick the best. A practical trick is to generate one steady draft at low temperature for the body and several higher-temperature variants for the headline, then choose. There is no single correct value, only the value that fits the job in front of you.",[57,7939,7941],{"id":7940},"step-4-add-a-quality-check-and-an-edit-loop","Step 4: Add a quality check and an edit loop",[14,7943,7944],{},"A draft you never read is a risk. The quality gate is a small function that scores the draft against simple, honest rules and either accepts it or sends it back. Here the loop regenerates up to a few times before giving up and flagging the item for a human.",[253,7946,7948],{"className":414,"code":7947,"language":416,"meta":258,"style":258},"import re\n\nBANNED = re.compile(r\"\\b(guaranteed|risk-free|100%|best ever)\\b\", re.IGNORECASE)\n\n\ndef passes_checks(text: str, max_words: int = 90) -> tuple[bool, str]:\n    if BANNED.search(text):\n        return False, \"contains a banned hype phrase\"\n    if len(text.split()) > max_words:\n        return False, \"too long\"\n    if len(text) \u003C 30:\n        return False, \"too short or empty\"\n    return True, \"ok\"\n\n\ndef generate_with_retry(brief: CopyBrief, attempts: int = 3) -> str:\n    for attempt in range(1, attempts + 1):\n        draft = generate_copy(brief, temperature=0.6)\n        ok, reason = passes_checks(draft)\n        if ok:\n            return draft\n        print(f\"Attempt {attempt} rejected: {reason}\")\n    raise ValueError(\"No draft passed the quality check; flag for human review.\")\n",[18,7949,7950,7957,7961,8012,8016,8020,8052,8062,8075,8090,8101,8117,8128,8139,8143,8147,8169,8193,8211,8221,8228,8235,8267],{"__ignoreMap":258},[262,7951,7952,7954],{"class":181,"line":264},[262,7953,684],{"class":377},[262,7955,7956],{"class":429}," re\n",[262,7958,7959],{"class":181,"line":282},[262,7960,583],{"emptyLinePlaceholder":582},[262,7962,7963,7966,7968,7971,7974,7976,7979,7983,7986,7989,7991,7994,7996,7999,8002,8004,8007,8010],{"class":181,"line":295},[262,7964,7965],{"class":271},"BANNED",[262,7967,442],{"class":377},[262,7969,7970],{"class":429}," re.compile(",[262,7972,7973],{"class":377},"r",[262,7975,1176],{"class":275},[262,7977,7978],{"class":271},"\\b(",[262,7980,7982],{"class":7981},"sA_wV","guaranteed",[262,7984,7985],{"class":377},"|",[262,7987,7988],{"class":7981},"risk-free",[262,7990,7985],{"class":377},[262,7992,7993],{"class":7981},"100%",[262,7995,7985],{"class":377},[262,7997,7998],{"class":7981},"best ever",[262,8000,8001],{"class":271},")\\b",[262,8003,1176],{"class":275},[262,8005,8006],{"class":429},", re.",[262,8008,8009],{"class":271},"IGNORECASE",[262,8011,660],{"class":429},[262,8013,8014],{"class":181,"line":345},[262,8015,583],{"emptyLinePlaceholder":582},[262,8017,8018],{"class":181,"line":492},[262,8019,583],{"emptyLinePlaceholder":582},[262,8021,8022,8024,8027,8029,8031,8034,8036,8038,8041,8043,8046,8048,8050],{"class":181,"line":503},[262,8023,423],{"class":377},[262,8025,8026],{"class":267}," passes_checks",[262,8028,430],{"class":429},[262,8030,433],{"class":271},[262,8032,8033],{"class":429},", max_words: ",[262,8035,439],{"class":271},[262,8037,442],{"class":377},[262,8039,8040],{"class":271}," 90",[262,8042,6140],{"class":429},[262,8044,8045],{"class":271},"bool",[262,8047,608],{"class":429},[262,8049,433],{"class":271},[262,8051,463],{"class":429},[262,8053,8054,8056,8059],{"class":181,"line":521},[262,8055,3454],{"class":377},[262,8057,8058],{"class":271}," BANNED",[262,8060,8061],{"class":429},".search(text):\n",[262,8063,8064,8067,8070,8072],{"class":181,"line":537},[262,8065,8066],{"class":377},"        return",[262,8068,8069],{"class":271}," False",[262,8071,608],{"class":429},[262,8073,8074],{"class":275},"\"contains a banned hype phrase\"\n",[262,8076,8077,8079,8081,8084,8087],{"class":181,"line":549},[262,8078,3454],{"class":377},[262,8080,515],{"class":271},[262,8082,8083],{"class":429},"(text.split()) ",[262,8085,8086],{"class":377},">",[262,8088,8089],{"class":429}," max_words:\n",[262,8091,8092,8094,8096,8098],{"class":181,"line":570},[262,8093,8066],{"class":377},[262,8095,8069],{"class":271},[262,8097,608],{"class":429},[262,8099,8100],{"class":275},"\"too long\"\n",[262,8102,8103,8105,8107,8110,8112,8115],{"class":181,"line":579},[262,8104,3454],{"class":377},[262,8106,515],{"class":271},[262,8108,8109],{"class":429},"(text) ",[262,8111,512],{"class":377},[262,8113,8114],{"class":271}," 30",[262,8116,1160],{"class":429},[262,8118,8119,8121,8123,8125],{"class":181,"line":586},[262,8120,8066],{"class":377},[262,8122,8069],{"class":271},[262,8124,608],{"class":429},[262,8126,8127],{"class":275},"\"too short or empty\"\n",[262,8129,8130,8132,8134,8136],{"class":181,"line":591},[262,8131,573],{"class":377},[262,8133,2241],{"class":271},[262,8135,608],{"class":429},[262,8137,8138],{"class":275},"\"ok\"\n",[262,8140,8141],{"class":181,"line":623},[262,8142,583],{"emptyLinePlaceholder":582},[262,8144,8145],{"class":181,"line":634},[262,8146,583],{"emptyLinePlaceholder":582},[262,8148,8149,8151,8154,8157,8159,8161,8163,8165,8167],{"class":181,"line":845},[262,8150,423],{"class":377},[262,8152,8153],{"class":267}," generate_with_retry",[262,8155,8156],{"class":429},"(brief: CopyBrief, attempts: ",[262,8158,439],{"class":271},[262,8160,442],{"class":377},[262,8162,931],{"class":271},[262,8164,1939],{"class":429},[262,8166,433],{"class":271},[262,8168,1160],{"class":429},[262,8170,8171,8173,8175,8177,8179,8181,8183,8186,8188,8190],{"class":181,"line":850},[262,8172,3074],{"class":377},[262,8174,3077],{"class":429},[262,8176,835],{"class":377},[262,8178,3082],{"class":271},[262,8180,602],{"class":429},[262,8182,997],{"class":271},[262,8184,8185],{"class":429},", attempts ",[262,8187,531],{"class":377},[262,8189,3243],{"class":271},[262,8191,8192],{"class":429},"):\n",[262,8194,8195,8198,8200,8203,8205,8207,8209],{"class":181,"line":864},[262,8196,8197],{"class":429},"        draft ",[262,8199,476],{"class":377},[262,8201,8202],{"class":429}," generate_copy(brief, ",[262,8204,3829],{"class":611},[262,8206,476],{"class":377},[262,8208,4445],{"class":271},[262,8210,660],{"class":429},[262,8212,8213,8216,8218],{"class":181,"line":1683},[262,8214,8215],{"class":429},"        ok, reason ",[262,8217,476],{"class":377},[262,8219,8220],{"class":429}," passes_checks(draft)\n",[262,8222,8223,8225],{"class":181,"line":1688},[262,8224,2268],{"class":377},[262,8226,8227],{"class":429}," ok:\n",[262,8229,8230,8232],{"class":181,"line":1693},[262,8231,3198],{"class":377},[262,8233,8234],{"class":429}," draft\n",[262,8236,8237,8239,8241,8243,8246,8248,8251,8253,8256,8258,8261,8263,8265],{"class":181,"line":1728},[262,8238,2299],{"class":271},[262,8240,602],{"class":429},[262,8242,642],{"class":377},[262,8244,8245],{"class":275},"\"Attempt ",[262,8247,3039],{"class":271},[262,8249,8250],{"class":429},"attempt",[262,8252,654],{"class":271},[262,8254,8255],{"class":275}," rejected: ",[262,8257,3039],{"class":271},[262,8259,8260],{"class":429},"reason",[262,8262,654],{"class":271},[262,8264,1176],{"class":275},[262,8266,660],{"class":429},[262,8268,8269,8271,8273,8275,8278],{"class":181,"line":1737},[262,8270,2829],{"class":377},[262,8272,2832],{"class":271},[262,8274,602],{"class":429},[262,8276,8277],{"class":275},"\"No draft passed the quality check; flag for human review.\"",[262,8279,660],{"class":429},[14,8281,8282],{},"This is the loop from the diagram. Passing drafts come straight back; failing ones trigger another attempt with the same brief. When the model cannot produce something acceptable, the script stops and tells you, which is far safer than silently publishing weak copy.",[14,8284,8285],{},"The checks here are deliberately simple, and that is the point. You do not need a machine-learning classifier to catch the most common problems. A short list of banned phrases stops the worst hype, a word count keeps length honest, and a minimum length catches empty or broken responses. These three rules will reject the large majority of genuinely bad drafts while almost never rejecting a good one. Start here, watch what slips through over your first few real batches, and add a rule only when you see a real failure it would have caught.",[14,8287,8288,8289,8292,8293,8296],{},"When you do add rules, think in terms of ",[27,8290,8291],{},"flag"," versus ",[27,8294,8295],{},"block",". Some problems, like a banned legal phrase, should hard-block the draft and force a rewrite. Others, like a draft that is slightly long, might only deserve a flag that a human glances at. Mixing the two keeps the loop from rejecting copy that is fine in spirit but imperfect in detail. The version above blocks everything, which is the strict default; loosening specific checks to flags is a sensible upgrade once you trust the pipeline. Either way, the rule that should never be optional is the human fallback: when automation runs out of attempts, a person decides, and nothing reaches the public on autopilot.",[57,8298,8300],{"id":8299},"parameter-reference","Parameter reference",[14,8302,8303,8304,8307],{},"These are the settings you will adjust most often when calling the model. The first three belong to ",[18,8305,8306],{},"chat.completions.create","; the last two are your own knobs from the script above.",[1379,8309,8310,8322],{},[1382,8311,8312],{},[1385,8313,8314,8316,8318,8320],{},[1388,8315,1390],{},[1388,8317,3795],{},[1388,8319,3798],{},[1388,8321,1396],{},[1398,8323,8324,8345,8367,8382,8398],{},[1385,8325,8326,8330,8332,8336],{},[1403,8327,8328],{},[18,8329,805],{},[1403,8331,433],{},[1403,8333,8334],{},[18,8335,2703],{},[1403,8337,8338,8339,8341,8342,8344],{},"Which model writes the copy. ",[18,8340,2703],{}," is cheap and fast; ",[18,8343,3821],{}," is stronger for nuanced pieces.",[1385,8346,8347,8351,8353,8357],{},[1403,8348,8349],{},[18,8350,3829],{},[1403,8352,3832],{},[1403,8354,8355],{},[18,8356,4445],{},[1403,8358,8359,8360,8362,8363,8366],{},"Creativity. Near ",[18,8361,3924],{}," for steady, reviewable copy; near ",[18,8364,8365],{},"0.9"," for varied options to pick from.",[1385,8368,8369,8373,8375,8379],{},[1403,8370,8371],{},[18,8372,3846],{},[1403,8374,439],{},[1403,8376,8377],{},[18,8378,104],{},[1403,8380,8381],{},"Hard cap on output length. Lower it to control cost; raise it for long-form drafts.",[1385,8383,8384,8389,8391,8395],{},[1403,8385,8386],{},[18,8387,8388],{},"attempts",[1403,8390,439],{},[1403,8392,8393],{},[18,8394,5556],{},[1403,8396,8397],{},"How many times the edit loop retries before flagging an item for a human.",[1385,8399,8400,8405,8407,8412],{},[1403,8401,8402],{},[18,8403,8404],{},"max_words",[1403,8406,439],{},[1403,8408,8409],{},[18,8410,8411],{},"90",[1403,8413,8414],{},"Length limit your quality check enforces on the draft.",[57,8416,1445],{"id":1444},[14,8418,8419],{},"These are the errors you are most likely to hit, with the exact message, the cause, and a one-line fix.",[1447,8421,8422,8443,8457,8476,8487,8499],{},[1450,8423,8424,8429,8430,8432,8433,8436,8437,8440,8441,1363],{},[35,8425,8426],{},[18,8427,8428],{},"AuthenticationError: Incorrect API key provided"," — Your key is missing or wrong. Confirm ",[18,8431,319],{}," holds ",[18,8434,8435],{},"OPENAI_API_KEY=sk-..."," and that ",[18,8438,8439],{},"load_dotenv()"," runs before you create the client. See ",[51,8442,388],{"href":387},[1450,8444,8445,8450,8451,8454,8455,1363],{},[35,8446,8447],{},[18,8448,8449],{},"RateLimitError: 429 Too Many Requests"," — You sent calls faster than your tier allows. Add a short ",[18,8452,8453],{},"time.sleep(1)"," between items or follow ",[51,8456,3379],{"href":3378},[1450,8458,8459,8464,8465,8468,8469,8472,8473,1363],{},[35,8460,8461],{},[18,8462,8463],{},"AttributeError: 'NoneType' object has no attribute 'strip'"," — The model returned no content, so ",[18,8466,8467],{},"message.content"," is ",[18,8470,8471],{},"None",". Guard with ",[18,8474,8475],{},"(response.choices[0].message.content or \"\").strip()",[1450,8477,8478,8483,8484,1363],{},[35,8479,8480],{},[18,8481,8482],{},"openai.APIConnectionError"," — Your machine could not reach the API, usually a network blip or proxy. Retry after a moment, and check a corporate firewall is not blocking ",[18,8485,8486],{},"api.openai.com",[1450,8488,8489,8494,8495,8498],{},[35,8490,8491],{},[18,8492,8493],{},"ModuleNotFoundError: No module named 'openai'"," — The package is not installed in the active environment. Run ",[18,8496,8497],{},"pip install openai"," again after confirming your virtual environment is activated.",[1450,8500,8501,8504,8505,8507],{},[35,8502,8503],{},"Drafts ignore your facts and invent features"," — Your ",[18,8506,7516],{}," is too thin or the system rule is buried. Make the facts concrete and keep the \"use ONLY the facts provided\" rule in the system message.",[57,8509,8511],{"id":8510},"worked-example-generate-copy-for-a-list-of-products","Worked example: generate copy for a list of products",[14,8513,8514,8515,8518,8519,1363],{},"This script ties the four steps together. It reads a small list of products, generates one piece of copy for each through the quality loop, and writes the approved results to a CSV you can hand to anyone. Save it as ",[18,8516,8517],{},"run_copy.py"," and run it with ",[18,8520,8521],{},"python run_copy.py",[253,8523,8525],{"className":414,"code":8524,"language":416,"meta":258,"style":258},"import csv\nimport os\nimport re\nimport time\nfrom dataclasses import dataclass, asdict\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()                                  # load OPENAI_API_KEY from .env\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\nBANNED = re.compile(r\"\\b(guaranteed|risk-free|100%|best ever)\\b\", re.IGNORECASE)\n\n\n@dataclass\nclass CopyBrief:\n    product: str\n    audience: str\n    tone: str\n    key_facts: str\n    channel: str = \"product description\"\n\n\ndef generate_copy(brief: CopyBrief, temperature: float = 0.6) -> str:\n    system = (\n        \"You are a senior copywriter. Use ONLY the facts provided; \"\n        \"never invent features or claims. Avoid hype words. Return only the copy.\"\n    )\n    user = (\n        f\"Write a {brief.channel} for {brief.product}. \"\n        f\"Audience: {brief.audience}. Tone: {brief.tone}. \"\n        f\"Facts: {brief.key_facts}. Keep it under 80 words.\"\n    )\n    resp = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"system\", \"content\": system},\n                  {\"role\": \"user\", \"content\": user}],\n        temperature=temperature,\n        max_tokens=200,\n    )\n    return (resp.choices[0].message.content or \"\").strip()\n\n\ndef good_enough(text: str) -> bool:                # the quality gate\n    return bool(text) and not BANNED.search(text) and len(text.split()) \u003C= 90\n\n\ndef write_one(brief: CopyBrief, attempts: int = 3) -> str:\n    for _ in range(attempts):\n        draft = generate_copy(brief)\n        if good_enough(draft):\n            return draft\n        time.sleep(1)                              # brief pause before retry\n    return \"[FLAGGED FOR HUMAN REVIEW]\"\n\n\nbriefs = [\n    CopyBrief(\"CloudNote app\", \"freelancers\", \"friendly\",\n              \"syncs in 2 seconds; works offline; free for one device\"),\n    CopyBrief(\"BrewBot grinder\", \"home coffee fans\", \"confident\",\n              \"40 grind settings; quiet motor; 2-year warranty\"),\n]\n\nwith open(\"copy_output.csv\", \"w\", newline=\"\", encoding=\"utf-8\") as f:\n    writer = csv.writer(f)\n    writer.writerow([\"product\", \"copy\"])\n    for brief in briefs:\n        result = write_one(brief)\n        writer.writerow([brief.product, result])\n        print(f\"Done: {brief.product}\")\n",[18,8526,8527,8534,8540,8546,8552,8563,8573,8583,8587,8595,8613,8651,8655,8659,8663,8671,8678,8684,8690,8696,8707,8711,8715,8735,8743,8748,8753,8757,8765,8787,8810,8826,8830,8838,8848,8869,8887,8895,8905,8909,8928,8932,8936,8957,8987,8991,8995,9016,9030,9038,9045,9051,9064,9071,9075,9079,9088,9108,9115,9134,9141,9145,9149,9191,9201,9216,9228,9239,9245],{"__ignoreMap":258},[262,8528,8529,8531],{"class":181,"line":264},[262,8530,684],{"class":377},[262,8532,8533],{"class":429}," csv\n",[262,8535,8536,8538],{"class":181,"line":282},[262,8537,684],{"class":377},[262,8539,687],{"class":429},[262,8541,8542,8544],{"class":181,"line":295},[262,8543,684],{"class":377},[262,8545,7956],{"class":429},[262,8547,8548,8550],{"class":181,"line":345},[262,8549,684],{"class":377},[262,8551,2612],{"class":429},[262,8553,8554,8556,8558,8560],{"class":181,"line":492},[262,8555,705],{"class":377},[262,8557,7302],{"class":429},[262,8559,684],{"class":377},[262,8561,8562],{"class":429}," dataclass, asdict\n",[262,8564,8565,8567,8569,8571],{"class":181,"line":503},[262,8566,705],{"class":377},[262,8568,708],{"class":429},[262,8570,684],{"class":377},[262,8572,713],{"class":429},[262,8574,8575,8577,8579,8581],{"class":181,"line":521},[262,8576,705],{"class":377},[262,8578,720],{"class":429},[262,8580,684],{"class":377},[262,8582,725],{"class":429},[262,8584,8585],{"class":181,"line":537},[262,8586,583],{"emptyLinePlaceholder":582},[262,8588,8589,8592],{"class":181,"line":549},[262,8590,8591],{"class":429},"load_dotenv()                                  ",[262,8593,8594],{"class":291},"# load OPENAI_API_KEY from .env\n",[262,8596,8597,8599,8601,8603,8605,8607,8609,8611],{"class":181,"line":570},[262,8598,739],{"class":429},[262,8600,476],{"class":377},[262,8602,1588],{"class":429},[262,8604,2674],{"class":611},[262,8606,476],{"class":377},[262,8608,1199],{"class":429},[262,8610,2681],{"class":275},[262,8612,2684],{"class":429},[262,8614,8615,8617,8619,8621,8623,8625,8627,8629,8631,8633,8635,8637,8639,8641,8643,8645,8647,8649],{"class":181,"line":579},[262,8616,7965],{"class":271},[262,8618,442],{"class":377},[262,8620,7970],{"class":429},[262,8622,7973],{"class":377},[262,8624,1176],{"class":275},[262,8626,7978],{"class":271},[262,8628,7982],{"class":7981},[262,8630,7985],{"class":377},[262,8632,7988],{"class":7981},[262,8634,7985],{"class":377},[262,8636,7993],{"class":7981},[262,8638,7985],{"class":377},[262,8640,7998],{"class":7981},[262,8642,8001],{"class":271},[262,8644,1176],{"class":275},[262,8646,8006],{"class":429},[262,8648,8009],{"class":271},[262,8650,660],{"class":429},[262,8652,8653],{"class":181,"line":586},[262,8654,583],{"emptyLinePlaceholder":582},[262,8656,8657],{"class":181,"line":591},[262,8658,583],{"emptyLinePlaceholder":582},[262,8660,8661],{"class":181,"line":623},[262,8662,7369],{"class":267},[262,8664,8665,8667,8669],{"class":181,"line":634},[262,8666,7374],{"class":377},[262,8668,7377],{"class":267},[262,8670,1160],{"class":429},[262,8672,8673,8675],{"class":181,"line":845},[262,8674,7384],{"class":429},[262,8676,8677],{"class":271},"str\n",[262,8679,8680,8682],{"class":181,"line":850},[262,8681,7394],{"class":429},[262,8683,8677],{"class":271},[262,8685,8686,8688],{"class":181,"line":864},[262,8687,7404],{"class":429},[262,8689,8677],{"class":271},[262,8691,8692,8694],{"class":181,"line":1683},[262,8693,7414],{"class":429},[262,8695,8677],{"class":271},[262,8697,8698,8700,8702,8704],{"class":181,"line":1688},[262,8699,7424],{"class":429},[262,8701,433],{"class":271},[262,8703,442],{"class":377},[262,8705,8706],{"class":275}," \"product description\"\n",[262,8708,8709],{"class":181,"line":1693},[262,8710,583],{"emptyLinePlaceholder":582},[262,8712,8713],{"class":181,"line":1728},[262,8714,583],{"emptyLinePlaceholder":582},[262,8716,8717,8719,8721,8723,8725,8727,8729,8731,8733],{"class":181,"line":1737},[262,8718,423],{"class":377},[262,8720,7799],{"class":267},[262,8722,7802],{"class":429},[262,8724,3832],{"class":271},[262,8726,442],{"class":377},[262,8728,7809],{"class":271},[262,8730,1939],{"class":429},[262,8732,433],{"class":271},[262,8734,1160],{"class":429},[262,8736,8737,8739,8741],{"class":181,"line":1751},[262,8738,7578],{"class":429},[262,8740,476],{"class":377},[262,8742,984],{"class":429},[262,8744,8745],{"class":181,"line":1764},[262,8746,8747],{"class":275},"        \"You are a senior copywriter. Use ONLY the facts provided; \"\n",[262,8749,8750],{"class":181,"line":1779},[262,8751,8752],{"class":275},"        \"never invent features or claims. Avoid hype words. Return only the copy.\"\n",[262,8754,8755],{"class":181,"line":1793},[262,8756,1011],{"class":429},[262,8758,8759,8761,8763],{"class":181,"line":1800},[262,8760,7611],{"class":429},[262,8762,476],{"class":377},[262,8764,984],{"class":429},[262,8766,8767,8769,8771,8773,8775,8777,8779,8781,8783,8785],{"class":181,"line":1805},[262,8768,2840],{"class":377},[262,8770,7622],{"class":275},[262,8772,3039],{"class":271},[262,8774,7627],{"class":429},[262,8776,654],{"class":271},[262,8778,7632],{"class":275},[262,8780,3039],{"class":271},[262,8782,7637],{"class":429},[262,8784,654],{"class":271},[262,8786,4628],{"class":275},[262,8788,8789,8791,8793,8795,8797,8799,8802,8804,8806,8808],{"class":181,"line":1810},[262,8790,2840],{"class":377},[262,8792,7652],{"class":275},[262,8794,3039],{"class":271},[262,8796,7530],{"class":429},[262,8798,654],{"class":271},[262,8800,8801],{"class":275},". Tone: ",[262,8803,3039],{"class":271},[262,8805,7676],{"class":429},[262,8807,654],{"class":271},[262,8809,4628],{"class":275},[262,8811,8812,8814,8817,8819,8821,8823],{"class":181,"line":1823},[262,8813,2840],{"class":377},[262,8815,8816],{"class":275},"\"Facts: ",[262,8818,3039],{"class":271},[262,8820,7696],{"class":429},[262,8822,654],{"class":271},[262,8824,8825],{"class":275},". Keep it under 80 words.\"\n",[262,8827,8828],{"class":181,"line":1846},[262,8829,1011],{"class":429},[262,8831,8832,8834,8836],{"class":181,"line":1861},[262,8833,797],{"class":429},[262,8835,476],{"class":377},[262,8837,1189],{"class":429},[262,8839,8840,8842,8844,8846],{"class":181,"line":1866},[262,8841,1194],{"class":611},[262,8843,476],{"class":377},[262,8845,1207],{"class":275},[262,8847,1315],{"class":429},[262,8849,8850,8852,8854,8857,8859,8861,8863,8865,8867],{"class":181,"line":1871},[262,8851,1215],{"class":611},[262,8853,476],{"class":377},[262,8855,8856],{"class":429},"[{",[262,8858,1228],{"class":275},[262,8860,1231],{"class":429},[262,8862,1234],{"class":275},[262,8864,608],{"class":429},[262,8866,1239],{"class":275},[262,8868,7739],{"class":429},[262,8870,8871,8874,8876,8878,8880,8882,8884],{"class":181,"line":1890},[262,8872,8873],{"class":429},"                  {",[262,8875,1228],{"class":275},[262,8877,1231],{"class":429},[262,8879,1291],{"class":275},[262,8881,608],{"class":429},[262,8883,1239],{"class":275},[262,8885,8886],{"class":429},": user}],\n",[262,8888,8889,8891,8893],{"class":181,"line":1909},[262,8890,1308],{"class":611},[262,8892,476],{"class":377},[262,8894,7851],{"class":429},[262,8896,8897,8899,8901,8903],{"class":181,"line":1914},[262,8898,4679],{"class":611},[262,8900,476],{"class":377},[262,8902,104],{"class":271},[262,8904,1315],{"class":429},[262,8906,8907],{"class":181,"line":1919},[262,8908,1011],{"class":429},[262,8910,8911,8913,8916,8918,8921,8924,8926],{"class":181,"line":1946},[262,8912,573],{"class":377},[262,8914,8915],{"class":429}," (resp.choices[",[262,8917,102],{"class":271},[262,8919,8920],{"class":429},"].message.content ",[262,8922,8923],{"class":377},"or",[262,8925,6332],{"class":275},[262,8927,2262],{"class":429},[262,8929,8930],{"class":181,"line":1959},[262,8931,583],{"emptyLinePlaceholder":582},[262,8933,8934],{"class":181,"line":1996},[262,8935,583],{"emptyLinePlaceholder":582},[262,8937,8938,8940,8943,8945,8947,8949,8951,8954],{"class":181,"line":2012},[262,8939,423],{"class":377},[262,8941,8942],{"class":267}," good_enough",[262,8944,430],{"class":429},[262,8946,433],{"class":271},[262,8948,1939],{"class":429},[262,8950,8045],{"class":271},[262,8952,8953],{"class":429},":                ",[262,8955,8956],{"class":291},"# the quality gate\n",[262,8958,8959,8961,8964,8966,8968,8970,8972,8975,8977,8979,8981,8984],{"class":181,"line":2040},[262,8960,573],{"class":377},[262,8962,8963],{"class":271}," bool",[262,8965,8109],{"class":429},[262,8967,6101],{"class":377},[262,8969,2818],{"class":377},[262,8971,8058],{"class":271},[262,8973,8974],{"class":429},".search(text) ",[262,8976,6101],{"class":377},[262,8978,515],{"class":271},[262,8980,8083],{"class":429},[262,8982,8983],{"class":377},"\u003C=",[262,8985,8986],{"class":271}," 90\n",[262,8988,8989],{"class":181,"line":2045},[262,8990,583],{"emptyLinePlaceholder":582},[262,8992,8993],{"class":181,"line":2050},[262,8994,583],{"emptyLinePlaceholder":582},[262,8996,8997,8999,9002,9004,9006,9008,9010,9012,9014],{"class":181,"line":2067},[262,8998,423],{"class":377},[262,9000,9001],{"class":267}," write_one",[262,9003,8156],{"class":429},[262,9005,439],{"class":271},[262,9007,442],{"class":377},[262,9009,931],{"class":271},[262,9011,1939],{"class":429},[262,9013,433],{"class":271},[262,9015,1160],{"class":429},[262,9017,9018,9020,9023,9025,9027],{"class":181,"line":2077},[262,9019,3074],{"class":377},[262,9021,9022],{"class":429}," _ ",[262,9024,835],{"class":377},[262,9026,3082],{"class":271},[262,9028,9029],{"class":429},"(attempts):\n",[262,9031,9032,9034,9036],{"class":181,"line":2086},[262,9033,8197],{"class":429},[262,9035,476],{"class":377},[262,9037,7896],{"class":429},[262,9039,9040,9042],{"class":181,"line":2097},[262,9041,2268],{"class":377},[262,9043,9044],{"class":429}," good_enough(draft):\n",[262,9046,9047,9049],{"class":181,"line":2106},[262,9048,3198],{"class":377},[262,9050,8234],{"class":429},[262,9052,9053,9056,9058,9061],{"class":181,"line":2126},[262,9054,9055],{"class":429},"        time.sleep(",[262,9057,997],{"class":271},[262,9059,9060],{"class":429},")                              ",[262,9062,9063],{"class":291},"# brief pause before retry\n",[262,9065,9066,9068],{"class":181,"line":2148},[262,9067,573],{"class":377},[262,9069,9070],{"class":275}," \"[FLAGGED FOR HUMAN REVIEW]\"\n",[262,9072,9073],{"class":181,"line":2165},[262,9074,583],{"emptyLinePlaceholder":582},[262,9076,9077],{"class":181,"line":2170},[262,9078,583],{"emptyLinePlaceholder":582},[262,9080,9081,9084,9086],{"class":181,"line":2181},[262,9082,9083],{"class":429},"briefs ",[262,9085,476],{"class":377},[262,9087,5589],{"class":429},[262,9089,9090,9093,9096,9098,9101,9103,9106],{"class":181,"line":2186},[262,9091,9092],{"class":429},"    CopyBrief(",[262,9094,9095],{"class":275},"\"CloudNote app\"",[262,9097,608],{"class":429},[262,9099,9100],{"class":275},"\"freelancers\"",[262,9102,608],{"class":429},[262,9104,9105],{"class":275},"\"friendly\"",[262,9107,1315],{"class":429},[262,9109,9110,9113],{"class":181,"line":2197},[262,9111,9112],{"class":275},"              \"syncs in 2 seconds; works offline; free for one device\"",[262,9114,1210],{"class":429},[262,9116,9117,9119,9122,9124,9127,9129,9132],{"class":181,"line":2202},[262,9118,9092],{"class":429},[262,9120,9121],{"class":275},"\"BrewBot grinder\"",[262,9123,608],{"class":429},[262,9125,9126],{"class":275},"\"home coffee fans\"",[262,9128,608],{"class":429},[262,9130,9131],{"class":275},"\"confident\"",[262,9133,1315],{"class":429},[262,9135,9136,9139],{"class":181,"line":2207},[262,9137,9138],{"class":275},"              \"40 grind settings; quiet motor; 2-year warranty\"",[262,9140,1210],{"class":429},[262,9142,9143],{"class":181,"line":2224},[262,9144,957],{"class":429},[262,9146,9147],{"class":181,"line":2236},[262,9148,583],{"emptyLinePlaceholder":582},[262,9150,9151,9154,9156,9158,9161,9163,9166,9168,9171,9173,9176,9178,9180,9182,9184,9186,9188],{"class":181,"line":2246},[262,9152,9153],{"class":377},"with",[262,9155,599],{"class":271},[262,9157,602],{"class":429},[262,9159,9160],{"class":275},"\"copy_output.csv\"",[262,9162,608],{"class":429},[262,9164,9165],{"class":275},"\"w\"",[262,9167,608],{"class":429},[262,9169,9170],{"class":611},"newline",[262,9172,476],{"class":377},[262,9174,9175],{"class":275},"\"\"",[262,9177,608],{"class":429},[262,9179,612],{"class":611},[262,9181,476],{"class":377},[262,9183,617],{"class":275},[262,9185,1000],{"class":429},[262,9187,697],{"class":377},[262,9189,9190],{"class":429}," f:\n",[262,9192,9193,9196,9198],{"class":181,"line":2265},[262,9194,9195],{"class":429},"    writer ",[262,9197,476],{"class":377},[262,9199,9200],{"class":429}," csv.writer(f)\n",[262,9202,9203,9206,9209,9211,9214],{"class":181,"line":2290},[262,9204,9205],{"class":429},"    writer.writerow([",[262,9207,9208],{"class":275},"\"product\"",[262,9210,608],{"class":429},[262,9212,9213],{"class":275},"\"copy\"",[262,9215,3512],{"class":429},[262,9217,9218,9220,9223,9225],{"class":181,"line":2296},[262,9219,3074],{"class":377},[262,9221,9222],{"class":429}," brief ",[262,9224,835],{"class":377},[262,9226,9227],{"class":429}," briefs:\n",[262,9229,9231,9234,9236],{"class":181,"line":9230},67,[262,9232,9233],{"class":429},"        result ",[262,9235,476],{"class":377},[262,9237,9238],{"class":429}," write_one(brief)\n",[262,9240,9242],{"class":181,"line":9241},68,[262,9243,9244],{"class":429},"        writer.writerow([brief.product, result])\n",[262,9246,9248,9250,9252,9254,9257,9259,9261,9263,9265],{"class":181,"line":9247},69,[262,9249,2299],{"class":271},[262,9251,602],{"class":429},[262,9253,642],{"class":377},[262,9255,9256],{"class":275},"\"Done: ",[262,9258,3039],{"class":271},[262,9260,7637],{"class":429},[262,9262,654],{"class":271},[262,9264,1176],{"class":275},[262,9266,660],{"class":429},[14,9268,9269,9270,9273,9274,1363],{},"Swap the ",[18,9271,9272],{},"briefs"," list for rows read from your own spreadsheet and you have a copy machine. To scale this to a full catalogue with the same structure, see ",[51,9275,2462],{"href":5290},[57,9277,2355],{"id":2354},[14,9279,9280],{},"You now have the core loop. Here is a sensible order to build on it:",[1447,9282,9283,9288,9293,9298],{},[1450,9284,9285,9286,1363],{},"Apply the same pattern to long-form writing in ",[51,9287,3983],{"href":3982},[1450,9289,9290,9291,1363],{},"Scale the worked example to a whole catalogue with ",[51,9292,2462],{"href":5290},[1450,9294,9295,9296,1363],{},"Repurpose drafts into a recurring send with ",[51,9297,4011],{"href":4010},[1450,9299,9300,9301,9305,9306,1363],{},"Feed real search terms into your briefs using ",[51,9302,9304],{"href":9303},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002F","SEO Keyword Research with Python",", then push finished copy out with ",[51,9307,9309],{"href":9308},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002F","Automated Social Media Posting",[14,9311,2375,9312,1363],{},[51,9313,5413],{"href":5412},[57,9315,2381],{"id":2380},[2322,9317,9318,9322,9326,9330,9334],{},[1450,9319,9320],{},[51,9321,3983],{"href":3982},[1450,9323,9324],{},[51,9325,2462],{"href":5290},[1450,9327,9328],{},[51,9329,4011],{"href":4010},[1450,9331,9332],{},[51,9333,7554],{"href":7553},[1450,9335,9336],{},[51,9337,5413],{"href":5412},[2401,9339,9340],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}",{"title":258,"searchDepth":282,"depth":282,"links":9342},[9343,9344,9345,9346,9347,9348,9349,9350,9351,9352,9353],{"id":7059,"depth":282,"text":7060},{"id":237,"depth":282,"text":238},{"id":7276,"depth":282,"text":7277},{"id":7540,"depth":282,"text":7541},{"id":7780,"depth":282,"text":7781},{"id":7940,"depth":282,"text":7941},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":8510,"depth":282,"text":8511},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Build a repeatable AI copywriting workflow in Python with the OpenAI SDK and httpx. Covers prompts, quality checks, and a runnable end-to-end script.",[9356,9359,9362,9365,9368],{"q":9357,"a":9358},"Do I need to be a programmer to build an AI copywriting workflow?","No. If you can install Python, copy a script, and paste an API key into a file, you can run everything here. The code is complete and commented, so you change a few words and run it.",{"q":9360,"a":9361},"Which OpenAI model should I use for copywriting?","Start with gpt-4o-mini. It is fast and cheap, and good enough for headlines, product blurbs, and email drafts. Move up to gpt-4o only when you need stronger reasoning or longer, more nuanced pieces.",{"q":9363,"a":9364},"How do I keep the AI from inventing facts about my product?","Put the real facts in the prompt and tell the model to use only what you give it. Then add a quality check that flags drafts containing words like guaranteed or risk-free so a human reviews them before publishing.",{"q":9366,"a":9367},"What does temperature do in a copywriting prompt?","Temperature controls randomness. A low value near 0.3 gives steady, predictable copy that is easy to review. A higher value near 0.9 gives more varied, surprising phrasing that is useful when you want several different options to choose from.",{"q":9369,"a":9370},"How much does it cost to generate copy at scale?","With gpt-4o-mini a short marketing draft costs a fraction of a cent. Generating a few hundred product descriptions usually costs well under a dollar, so batching is cheap as long as you keep prompts tight.",{"name":9372,"steps":9373},"How to build an AI copywriting workflow in Python",[9374,9377,9380,9383],{"name":9375,"text":9376},"Set up the project and credentials","Create a virtual environment, install the openai and httpx packages, and store your API key in a .env file that is ignored by git.",{"name":9378,"text":9379},"Write a reusable copy brief and prompt","Capture audience, tone, and product facts in a structured brief, then turn that brief into a clear system and user prompt.",{"name":9381,"text":9382},"Generate a draft with the OpenAI SDK","Send the prompt to the model, control creativity with temperature, and read the draft text back from the response.",{"name":9384,"text":9385},"Run a quality check and edit loop","Score the draft against simple rules, regenerate or flag anything that fails, and save the approved copy.",{},"\u002Fai-content-creation-marketing-automation\u002Fai-copywriting-workflows","2026-05-07",{"title":7027,"description":9354},"AI Copywriting Workflows with Python","ai-content-creation-marketing-automation\u002Fai-copywriting-workflows\u002Findex","MHasTu-ueOtlTKKzzJnANNj7EO89Hn5l_hZsCO4ozN0",{"id":9394,"title":9395,"body":9396,"description":11023,"extension":2419,"faq":11024,"howto":11040,"meta":11055,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":11056,"published":2452,"seo":11057,"seoTitle":11058,"stem":11059,"__hash__":11060},"content\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fbatch-generate-product-images-with-dall-e-and-python\u002Findex.md","Batch-Generate Product Images with DALL·E and Python",{"type":7,"value":9397,"toc":11012},[9398,9401,9404,9417,9419,9425,9435,9456,9462,9470,9480,9484,9498,9525,9531,9535,9538,9934,9949,9953,9959,10464,10474,10478,10485,10795,10810,10812,10815,10904,10906,10954,10956,10976,10982,10986,10988,11009],[10,9399,9395],{"id":9400},"batch-generate-product-images-with-dalle-and-python",[14,9402,9403],{},"This guide shows you how to turn a spreadsheet of product prompts into a folder of finished images in under fifteen minutes, with retries and a resumable manifest so a stalled run never costs you twice. If you sell anything online, you already know the pain: a hundred listings, each needing a clean hero shot, and no budget for a hundred photo shoots. A short Python script and the DALL·E image model can draft all of them while you do something else.",[14,9405,9406,9407,9411,9412,9416],{},"This is a hands-on guide inside ",[51,9408,9410],{"href":9409},"\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002F","AI Image & Video Generation",". If you want to add text and resize for a specific platform afterwards, the companion guide ",[51,9413,9415],{"href":9414},"\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fcreate-youtube-thumbnails-with-dall-e-3-and-python\u002F","Create YouTube Thumbnails with DALL·E 3 and Python"," covers cropping and overlays in detail.",[57,9418,238],{"id":237},[14,9420,9421,9422,9424],{},"You need Python 3.10 or newer (Python 3.9 reached end-of-life in October 2025) and an OpenAI account with billing enabled, because image generation is a paid endpoint. If you are brand new to API keys, the main guide ",[51,9423,2487],{"href":2486}," walks through obtaining one.",[14,9426,9427,9428,9430,9431,9434],{},"Install the three packages this script uses. We use ",[18,9429,5450],{}," to download the finished image (it is faster and cleaner than raw ",[18,9432,9433],{},"requests",") and Pillow only to verify that each download is a real image:",[253,9436,9438],{"className":255,"code":9437,"language":257,"meta":258,"style":258},"pip install \"openai>=1.30.0\" httpx Pillow python-dotenv\n",[18,9439,9440],{"__ignoreMap":258},[262,9441,9442,9444,9446,9449,9451,9454],{"class":181,"line":264},[262,9443,298],{"class":267},[262,9445,301],{"class":275},[262,9447,9448],{"class":275}," \"openai>=1.30.0\"",[262,9450,5440],{"class":275},[262,9452,9453],{"class":275}," Pillow",[262,9455,2522],{"class":275},[14,9457,9458,9459,9461],{},"Store your key in a ",[18,9460,319],{}," file so it never lands in your code or your git history:",[253,9463,9464],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,9465,9466],{"__ignoreMap":258},[262,9467,9468],{"class":181,"line":264},[262,9469,337],{},[14,9471,353,9472,356,9474,9476,9477,9479],{},[18,9473,319],{},[18,9475,359],{}," immediately so the key is never committed. If you ever do see an authentication failure, the focused guide ",[51,9478,388],{"href":387}," explains the usual causes.",[57,9481,9483],{"id":9482},"step-1-prepare-a-csv-of-product-prompts","Step 1 — Prepare a CSV of product prompts",[14,9485,9486,9487,9489,9490,9493,9494,9497],{},"A batch job reads its work from a file. Create ",[18,9488,2572],{}," with one row per image. Keep an ",[18,9491,9492],{},"id"," column (a stable identifier, like a SKU) so filenames are predictable, and a ",[18,9495,9496],{},"prompt"," column describing exactly what you want. The more concrete the prompt, the more usable the result:",[253,9499,9503],{"className":9500,"code":9501,"language":9502,"meta":258,"style":258},"language-csv shiki shiki-themes github-light github-dark","id,prompt\nSKU-1001,\"Studio product photo of a matte black ceramic coffee mug on a white seamless background, soft diffused lighting, centered, no text\"\nSKU-1002,\"Studio product photo of a tan leather wallet open to show card slots, white seamless background, soft shadow, centered, no text\"\nSKU-1003,\"Studio product photo of a stainless steel water bottle, condensation droplets, white seamless background, soft top light, centered, no text\"\n","csv",[18,9504,9505,9510,9515,9520],{"__ignoreMap":258},[262,9506,9507],{"class":181,"line":264},[262,9508,9509],{},"id,prompt\n",[262,9511,9512],{"class":181,"line":282},[262,9513,9514],{},"SKU-1001,\"Studio product photo of a matte black ceramic coffee mug on a white seamless background, soft diffused lighting, centered, no text\"\n",[262,9516,9517],{"class":181,"line":295},[262,9518,9519],{},"SKU-1002,\"Studio product photo of a tan leather wallet open to show card slots, white seamless background, soft shadow, centered, no text\"\n",[262,9521,9522],{"class":181,"line":345},[262,9523,9524],{},"SKU-1003,\"Studio product photo of a stainless steel water bottle, condensation droplets, white seamless background, soft top light, centered, no text\"\n",[14,9526,9527,9528,9530],{},"Two phrases earn their place in almost every product prompt: \"white seamless background\" gives you a clean cutout-ready image, and \"no text\" stops the model from inventing garbled labels. If you maintain product data elsewhere and your CSV is messy, ",[51,9529,2919],{"href":2918}," shows how to normalise it before you spend money generating images.",[57,9532,9534],{"id":9533},"step-2-write-a-resilient-generate-function","Step 2 — Write a resilient generate function",[14,9536,9537],{},"The core of the job is a single function that takes one prompt and returns the finished image bytes. Two things make it production-ready rather than a toy: it retries on rate-limit errors with exponential backoff (waiting longer after each failure), and it downloads the image immediately, because the URL the API returns expires after roughly an hour.",[253,9539,9541],{"className":414,"code":9540,"language":416,"meta":258,"style":258},"import time\nimport httpx\nfrom openai import OpenAI, RateLimitError, APIError, BadRequestError\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\n\ndef generate_image(prompt: str, *, size: str = \"1024x1024\",\n                   quality: str = \"standard\", max_retries: int = 5) -> bytes:\n    \"\"\"Generate one image and return its raw PNG bytes.\"\"\"\n    for attempt in range(max_retries):\n        try:\n            response = client.images.generate(\n                model=\"dall-e-3\",\n                prompt=prompt,\n                size=size,\n                quality=quality,\n                n=1,                      # dall-e-3 only supports n=1\n                response_format=\"url\",\n            )\n            image_url = response.data[0].url\n            return httpx.get(image_url, timeout=30).content\n        except RateLimitError:\n            wait = 2 ** attempt           # 1s, 2s, 4s, 8s, 16s\n            print(f\"Rate limited, waiting {wait}s...\")\n            time.sleep(wait)\n        except BadRequestError as exc:\n            # A rejected prompt will never succeed on retry, so stop now.\n            raise RuntimeError(f\"Prompt rejected: {exc}\") from exc\n        except APIError as exc:\n            print(f\"Transient API error: {exc}, retrying...\")\n            time.sleep(2 ** attempt)\n    raise RuntimeError(\"Max retries exceeded\")\n",[18,9542,9543,9549,9555,9566,9570,9582,9586,9590,9618,9646,9651,9663,9669,9678,9689,9699,9709,9719,9734,9745,9749,9764,9781,9788,9804,9825,9829,9841,9846,9876,9887,9909,9921],{"__ignoreMap":258},[262,9544,9545,9547],{"class":181,"line":264},[262,9546,684],{"class":377},[262,9548,2612],{"class":429},[262,9550,9551,9553],{"class":181,"line":282},[262,9552,684],{"class":377},[262,9554,6526],{"class":429},[262,9556,9557,9559,9561,9563],{"class":181,"line":295},[262,9558,705],{"class":377},[262,9560,720],{"class":429},[262,9562,684],{"class":377},[262,9564,9565],{"class":429}," OpenAI, RateLimitError, APIError, BadRequestError\n",[262,9567,9568],{"class":181,"line":345},[262,9569,583],{"emptyLinePlaceholder":582},[262,9571,9572,9574,9576,9579],{"class":181,"line":492},[262,9573,739],{"class":429},[262,9575,476],{"class":377},[262,9577,9578],{"class":429}," OpenAI()  ",[262,9580,9581],{"class":291},"# reads OPENAI_API_KEY from the environment\n",[262,9583,9584],{"class":181,"line":503},[262,9585,583],{"emptyLinePlaceholder":582},[262,9587,9588],{"class":181,"line":521},[262,9589,583],{"emptyLinePlaceholder":582},[262,9591,9592,9594,9597,9600,9602,9604,9606,9609,9611,9613,9616],{"class":181,"line":537},[262,9593,423],{"class":377},[262,9595,9596],{"class":267}," generate_image",[262,9598,9599],{"class":429},"(prompt: ",[262,9601,433],{"class":271},[262,9603,608],{"class":429},[262,9605,1003],{"class":377},[262,9607,9608],{"class":429},", size: ",[262,9610,433],{"class":271},[262,9612,442],{"class":377},[262,9614,9615],{"class":275}," \"1024x1024\"",[262,9617,1315],{"class":429},[262,9619,9620,9623,9625,9627,9630,9632,9634,9636,9639,9641,9644],{"class":181,"line":549},[262,9621,9622],{"class":429},"                   quality: ",[262,9624,433],{"class":271},[262,9626,442],{"class":377},[262,9628,9629],{"class":275}," \"standard\"",[262,9631,3007],{"class":429},[262,9633,439],{"class":271},[262,9635,442],{"class":377},[262,9637,9638],{"class":271}," 5",[262,9640,1939],{"class":429},[262,9642,9643],{"class":271},"bytes",[262,9645,1160],{"class":429},[262,9647,9648],{"class":181,"line":570},[262,9649,9650],{"class":275},"    \"\"\"Generate one image and return its raw PNG bytes.\"\"\"\n",[262,9652,9653,9655,9657,9659,9661],{"class":181,"line":579},[262,9654,3074],{"class":377},[262,9656,3077],{"class":429},[262,9658,835],{"class":377},[262,9660,3082],{"class":271},[262,9662,3085],{"class":429},[262,9664,9665,9667],{"class":181,"line":586},[262,9666,3090],{"class":377},[262,9668,1160],{"class":429},[262,9670,9671,9673,9675],{"class":181,"line":591},[262,9672,3097],{"class":429},[262,9674,476],{"class":377},[262,9676,9677],{"class":429}," client.images.generate(\n",[262,9679,9680,9682,9684,9687],{"class":181,"line":623},[262,9681,3106],{"class":611},[262,9683,476],{"class":377},[262,9685,9686],{"class":275},"\"dall-e-3\"",[262,9688,1315],{"class":429},[262,9690,9691,9694,9696],{"class":181,"line":634},[262,9692,9693],{"class":611},"                prompt",[262,9695,476],{"class":377},[262,9697,9698],{"class":429},"prompt,\n",[262,9700,9701,9704,9706],{"class":181,"line":845},[262,9702,9703],{"class":611},"                size",[262,9705,476],{"class":377},[262,9707,9708],{"class":429},"size,\n",[262,9710,9711,9714,9716],{"class":181,"line":850},[262,9712,9713],{"class":611},"                quality",[262,9715,476],{"class":377},[262,9717,9718],{"class":429},"quality,\n",[262,9720,9721,9724,9726,9728,9731],{"class":181,"line":864},[262,9722,9723],{"class":611},"                n",[262,9725,476],{"class":377},[262,9727,997],{"class":271},[262,9729,9730],{"class":429},",                      ",[262,9732,9733],{"class":291},"# dall-e-3 only supports n=1\n",[262,9735,9736,9739,9741,9743],{"class":181,"line":1683},[262,9737,9738],{"class":611},"                response_format",[262,9740,476],{"class":377},[262,9742,6276],{"class":275},[262,9744,1315],{"class":429},[262,9746,9747],{"class":181,"line":1688},[262,9748,3193],{"class":429},[262,9750,9751,9754,9756,9759,9761],{"class":181,"line":1693},[262,9752,9753],{"class":429},"            image_url ",[262,9755,476],{"class":377},[262,9757,9758],{"class":429}," response.data[",[262,9760,102],{"class":271},[262,9762,9763],{"class":429},"].url\n",[262,9765,9766,9768,9771,9773,9775,9778],{"class":181,"line":1728},[262,9767,3198],{"class":377},[262,9769,9770],{"class":429}," httpx.get(image_url, ",[262,9772,1591],{"class":611},[262,9774,476],{"class":377},[262,9776,9777],{"class":271},"30",[262,9779,9780],{"class":429},").content\n",[262,9782,9783,9785],{"class":181,"line":1737},[262,9784,3214],{"class":377},[262,9786,9787],{"class":429}," RateLimitError:\n",[262,9789,9790,9792,9794,9796,9798,9801],{"class":181,"line":1751},[262,9791,3227],{"class":429},[262,9793,476],{"class":377},[262,9795,3232],{"class":271},[262,9797,3235],{"class":377},[262,9799,9800],{"class":429}," attempt           ",[262,9802,9803],{"class":291},"# 1s, 2s, 4s, 8s, 16s\n",[262,9805,9806,9808,9810,9812,9815,9817,9819,9821,9823],{"class":181,"line":1764},[262,9807,3250],{"class":271},[262,9809,602],{"class":429},[262,9811,642],{"class":377},[262,9813,9814],{"class":275},"\"Rate limited, waiting ",[262,9816,3039],{"class":271},[262,9818,3295],{"class":429},[262,9820,654],{"class":271},[262,9822,3300],{"class":275},[262,9824,660],{"class":429},[262,9826,9827],{"class":181,"line":1779},[262,9828,3307],{"class":429},[262,9830,9831,9833,9836,9838],{"class":181,"line":1793},[262,9832,3214],{"class":377},[262,9834,9835],{"class":429}," BadRequestError ",[262,9837,697],{"class":377},[262,9839,9840],{"class":429}," exc:\n",[262,9842,9843],{"class":181,"line":1800},[262,9844,9845],{"class":291},"            # A rejected prompt will never succeed on retry, so stop now.\n",[262,9847,9848,9851,9853,9855,9857,9860,9862,9865,9867,9869,9871,9873],{"class":181,"line":1805},[262,9849,9850],{"class":377},"            raise",[262,9852,3318],{"class":271},[262,9854,602],{"class":429},[262,9856,642],{"class":377},[262,9858,9859],{"class":275},"\"Prompt rejected: ",[262,9861,3039],{"class":271},[262,9863,9864],{"class":429},"exc",[262,9866,654],{"class":271},[262,9868,1176],{"class":275},[262,9870,1000],{"class":429},[262,9872,705],{"class":377},[262,9874,9875],{"class":429}," exc\n",[262,9877,9878,9880,9883,9885],{"class":181,"line":1810},[262,9879,3214],{"class":377},[262,9881,9882],{"class":429}," APIError ",[262,9884,697],{"class":377},[262,9886,9840],{"class":429},[262,9888,9889,9891,9893,9895,9898,9900,9902,9904,9907],{"class":181,"line":1823},[262,9890,3250],{"class":271},[262,9892,602],{"class":429},[262,9894,642],{"class":377},[262,9896,9897],{"class":275},"\"Transient API error: ",[262,9899,3039],{"class":271},[262,9901,9864],{"class":429},[262,9903,654],{"class":271},[262,9905,9906],{"class":275},", retrying...\"",[262,9908,660],{"class":429},[262,9910,9911,9914,9916,9918],{"class":181,"line":1846},[262,9912,9913],{"class":429},"            time.sleep(",[262,9915,109],{"class":271},[262,9917,3235],{"class":377},[262,9919,9920],{"class":429}," attempt)\n",[262,9922,9923,9925,9927,9929,9932],{"class":181,"line":1861},[262,9924,2829],{"class":377},[262,9926,3318],{"class":271},[262,9928,602],{"class":429},[262,9930,9931],{"class":275},"\"Max retries exceeded\"",[262,9933,660],{"class":429},[14,9935,9936,9937,9939,9940,9942,9943,9946,9947,1363],{},"The distinction between the two error types matters. A ",[18,9938,2707],{}," or a generic ",[18,9941,2713],{}," is temporary, so we wait and try again. A ",[18,9944,9945],{},"BadRequestError"," means the prompt itself was rejected (usually by the content filter), so retrying would only burn time and money; we raise immediately and let the caller log it. For more on the rate-limit case specifically, see ",[51,9948,3379],{"href":3378},[57,9950,9952],{"id":9951},"step-3-loop-over-the-csv-and-save-files","Step 3 — Loop over the CSV and save files",[14,9954,9955,9956,9958],{},"Now wrap that function in a loop that reads every row, writes each image to an output folder, and keeps going when one row fails instead of crashing the whole run. We name each file from the row's ",[18,9957,9492],{}," so a rerun overwrites cleanly and never produces duplicates:",[253,9960,9962],{"className":414,"code":9961,"language":416,"meta":258,"style":258},"import csv\nfrom pathlib import Path\nfrom PIL import Image\nimport io\n\n\ndef run_batch(csv_path: str, output_dir: str, *,\n              size: str = \"1024x1024\", quality: str = \"standard\",\n              throttle: float = 1.0) -> list[dict]:\n    out = Path(output_dir)\n    out.mkdir(parents=True, exist_ok=True)\n    results: list[dict] = []\n\n    with open(csv_path, newline=\"\", encoding=\"utf-8\") as fh:\n        for row in csv.DictReader(fh):\n            file_path = out \u002F f\"{row['id']}.png\"\n            if file_path.exists():\n                print(f\"Skipping {row['id']} (already done)\")\n                continue\n            try:\n                data = generate_image(row[\"prompt\"], size=size, quality=quality)\n                Image.open(io.BytesIO(data)).verify()   # confirm it is a real image\n                file_path.write_bytes(data)\n                results.append({\"id\": row[\"id\"], \"prompt\": row[\"prompt\"],\n                                \"file\": str(file_path), \"status\": \"ok\"})\n                print(f\"Saved {file_path}\")\n            except Exception as exc:\n                results.append({\"id\": row[\"id\"], \"prompt\": row[\"prompt\"],\n                                \"file\": \"\", \"status\": f\"error: {exc}\"})\n                print(f\"Failed {row['id']}: {exc}\")\n            time.sleep(throttle)   # stay under the per-minute rate limit\n    return results\n",[18,9963,9964,9970,9980,9993,10000,10004,10008,10031,10053,10071,10081,10103,10116,10120,10151,10164,10196,10204,10231,10236,10243,10274,10282,10287,10310,10333,10354,10367,10387,10416,10449,10457],{"__ignoreMap":258},[262,9965,9966,9968],{"class":181,"line":264},[262,9967,684],{"class":377},[262,9969,8533],{"class":429},[262,9971,9972,9974,9976,9978],{"class":181,"line":282},[262,9973,705],{"class":377},[262,9975,4882],{"class":429},[262,9977,684],{"class":377},[262,9979,4887],{"class":429},[262,9981,9982,9984,9987,9990],{"class":181,"line":295},[262,9983,705],{"class":377},[262,9985,9986],{"class":271}," PIL",[262,9988,9989],{"class":377}," import",[262,9991,9992],{"class":429}," Image\n",[262,9994,9995,9997],{"class":181,"line":345},[262,9996,684],{"class":377},[262,9998,9999],{"class":429}," io\n",[262,10001,10002],{"class":181,"line":492},[262,10003,583],{"emptyLinePlaceholder":582},[262,10005,10006],{"class":181,"line":503},[262,10007,583],{"emptyLinePlaceholder":582},[262,10009,10010,10012,10015,10018,10020,10023,10025,10027,10029],{"class":181,"line":521},[262,10011,423],{"class":377},[262,10013,10014],{"class":267}," run_batch",[262,10016,10017],{"class":429},"(csv_path: ",[262,10019,433],{"class":271},[262,10021,10022],{"class":429},", output_dir: ",[262,10024,433],{"class":271},[262,10026,608],{"class":429},[262,10028,1003],{"class":377},[262,10030,1315],{"class":429},[262,10032,10033,10036,10038,10040,10042,10045,10047,10049,10051],{"class":181,"line":537},[262,10034,10035],{"class":429},"              size: ",[262,10037,433],{"class":271},[262,10039,442],{"class":377},[262,10041,9615],{"class":275},[262,10043,10044],{"class":429},", quality: ",[262,10046,433],{"class":271},[262,10048,442],{"class":377},[262,10050,9629],{"class":275},[262,10052,1315],{"class":429},[262,10054,10055,10058,10060,10062,10065,10067,10069],{"class":181,"line":549},[262,10056,10057],{"class":429},"              throttle: ",[262,10059,3832],{"class":271},[262,10061,442],{"class":377},[262,10063,10064],{"class":271}," 1.0",[262,10066,458],{"class":429},[262,10068,5869],{"class":271},[262,10070,463],{"class":429},[262,10072,10073,10076,10078],{"class":181,"line":570},[262,10074,10075],{"class":429},"    out ",[262,10077,476],{"class":377},[262,10079,10080],{"class":429}," Path(output_dir)\n",[262,10082,10083,10086,10089,10091,10093,10095,10097,10099,10101],{"class":181,"line":579},[262,10084,10085],{"class":429},"    out.mkdir(",[262,10087,10088],{"class":611},"parents",[262,10090,476],{"class":377},[262,10092,4974],{"class":271},[262,10094,608],{"class":429},[262,10096,4969],{"class":611},[262,10098,476],{"class":377},[262,10100,4974],{"class":271},[262,10102,660],{"class":429},[262,10104,10105,10108,10110,10112,10114],{"class":181,"line":586},[262,10106,10107],{"class":429},"    results: list[",[262,10109,5869],{"class":271},[262,10111,2903],{"class":429},[262,10113,476],{"class":377},[262,10115,489],{"class":429},[262,10117,10118],{"class":181,"line":591},[262,10119,583],{"emptyLinePlaceholder":582},[262,10121,10122,10125,10127,10130,10132,10134,10136,10138,10140,10142,10144,10146,10148],{"class":181,"line":623},[262,10123,10124],{"class":377},"    with",[262,10126,599],{"class":271},[262,10128,10129],{"class":429},"(csv_path, ",[262,10131,9170],{"class":611},[262,10133,476],{"class":377},[262,10135,9175],{"class":275},[262,10137,608],{"class":429},[262,10139,612],{"class":611},[262,10141,476],{"class":377},[262,10143,617],{"class":275},[262,10145,1000],{"class":429},[262,10147,697],{"class":377},[262,10149,10150],{"class":429}," fh:\n",[262,10152,10153,10156,10159,10161],{"class":181,"line":634},[262,10154,10155],{"class":377},"        for",[262,10157,10158],{"class":429}," row ",[262,10160,835],{"class":377},[262,10162,10163],{"class":429}," csv.DictReader(fh):\n",[262,10165,10166,10169,10171,10174,10176,10179,10181,10183,10186,10189,10191,10193],{"class":181,"line":845},[262,10167,10168],{"class":429},"            file_path ",[262,10170,476],{"class":377},[262,10172,10173],{"class":429}," out ",[262,10175,981],{"class":377},[262,10177,10178],{"class":377}," f",[262,10180,1176],{"class":275},[262,10182,3039],{"class":271},[262,10184,10185],{"class":429},"row[",[262,10187,10188],{"class":275},"'id'",[262,10190,6223],{"class":429},[262,10192,654],{"class":271},[262,10194,10195],{"class":275},".png\"\n",[262,10197,10198,10201],{"class":181,"line":850},[262,10199,10200],{"class":377},"            if",[262,10202,10203],{"class":429}," file_path.exists():\n",[262,10205,10206,10209,10211,10213,10216,10218,10220,10222,10224,10226,10229],{"class":181,"line":864},[262,10207,10208],{"class":271},"                print",[262,10210,602],{"class":429},[262,10212,642],{"class":377},[262,10214,10215],{"class":275},"\"Skipping ",[262,10217,3039],{"class":271},[262,10219,10185],{"class":429},[262,10221,10188],{"class":275},[262,10223,6223],{"class":429},[262,10225,654],{"class":271},[262,10227,10228],{"class":275}," (already done)\"",[262,10230,660],{"class":429},[262,10232,10233],{"class":181,"line":1683},[262,10234,10235],{"class":377},"                continue\n",[262,10237,10238,10241],{"class":181,"line":1688},[262,10239,10240],{"class":377},"            try",[262,10242,1160],{"class":429},[262,10244,10245,10248,10250,10253,10256,10258,10261,10263,10266,10269,10271],{"class":181,"line":1693},[262,10246,10247],{"class":429},"                data ",[262,10249,476],{"class":377},[262,10251,10252],{"class":429}," generate_image(row[",[262,10254,10255],{"class":275},"\"prompt\"",[262,10257,1103],{"class":429},[262,10259,10260],{"class":611},"size",[262,10262,476],{"class":377},[262,10264,10265],{"class":429},"size, ",[262,10267,10268],{"class":611},"quality",[262,10270,476],{"class":377},[262,10272,10273],{"class":429},"quality)\n",[262,10275,10276,10279],{"class":181,"line":1728},[262,10277,10278],{"class":429},"                Image.open(io.BytesIO(data)).verify()   ",[262,10280,10281],{"class":291},"# confirm it is a real image\n",[262,10283,10284],{"class":181,"line":1737},[262,10285,10286],{"class":429},"                file_path.write_bytes(data)\n",[262,10288,10289,10292,10294,10297,10299,10301,10303,10305,10307],{"class":181,"line":1751},[262,10290,10291],{"class":429},"                results.append({",[262,10293,6770],{"class":275},[262,10295,10296],{"class":429},": row[",[262,10298,6770],{"class":275},[262,10300,1103],{"class":429},[262,10302,10255],{"class":275},[262,10304,10296],{"class":429},[262,10306,10255],{"class":275},[262,10308,10309],{"class":429},"],\n",[262,10311,10312,10315,10317,10319,10322,10325,10327,10330],{"class":181,"line":1764},[262,10313,10314],{"class":275},"                                \"file\"",[262,10316,1231],{"class":429},[262,10318,433],{"class":271},[262,10320,10321],{"class":429},"(file_path), ",[262,10323,10324],{"class":275},"\"status\"",[262,10326,1231],{"class":429},[262,10328,10329],{"class":275},"\"ok\"",[262,10331,10332],{"class":429},"})\n",[262,10334,10335,10337,10339,10341,10343,10345,10348,10350,10352],{"class":181,"line":1779},[262,10336,10208],{"class":271},[262,10338,602],{"class":429},[262,10340,642],{"class":377},[262,10342,3753],{"class":275},[262,10344,3039],{"class":271},[262,10346,10347],{"class":429},"file_path",[262,10349,654],{"class":271},[262,10351,1176],{"class":275},[262,10353,660],{"class":429},[262,10355,10356,10359,10362,10365],{"class":181,"line":1793},[262,10357,10358],{"class":377},"            except",[262,10360,10361],{"class":271}," Exception",[262,10363,10364],{"class":377}," as",[262,10366,9840],{"class":429},[262,10368,10369,10371,10373,10375,10377,10379,10381,10383,10385],{"class":181,"line":1800},[262,10370,10291],{"class":429},[262,10372,6770],{"class":275},[262,10374,10296],{"class":429},[262,10376,6770],{"class":275},[262,10378,1103],{"class":429},[262,10380,10255],{"class":275},[262,10382,10296],{"class":429},[262,10384,10255],{"class":275},[262,10386,10309],{"class":429},[262,10388,10389,10391,10393,10395,10397,10399,10401,10403,10406,10408,10410,10412,10414],{"class":181,"line":1805},[262,10390,10314],{"class":275},[262,10392,1231],{"class":429},[262,10394,9175],{"class":275},[262,10396,608],{"class":429},[262,10398,10324],{"class":275},[262,10400,1231],{"class":429},[262,10402,642],{"class":377},[262,10404,10405],{"class":275},"\"error: ",[262,10407,3039],{"class":271},[262,10409,9864],{"class":429},[262,10411,654],{"class":271},[262,10413,1176],{"class":275},[262,10415,10332],{"class":429},[262,10417,10418,10420,10422,10424,10427,10429,10431,10433,10435,10437,10439,10441,10443,10445,10447],{"class":181,"line":1810},[262,10419,10208],{"class":271},[262,10421,602],{"class":429},[262,10423,642],{"class":377},[262,10425,10426],{"class":275},"\"Failed ",[262,10428,3039],{"class":271},[262,10430,10185],{"class":429},[262,10432,10188],{"class":275},[262,10434,6223],{"class":429},[262,10436,654],{"class":271},[262,10438,1231],{"class":275},[262,10440,3039],{"class":271},[262,10442,9864],{"class":429},[262,10444,654],{"class":271},[262,10446,1176],{"class":275},[262,10448,660],{"class":429},[262,10450,10451,10454],{"class":181,"line":1823},[262,10452,10453],{"class":429},"            time.sleep(throttle)   ",[262,10455,10456],{"class":291},"# stay under the per-minute rate limit\n",[262,10458,10459,10461],{"class":181,"line":1846},[262,10460,573],{"class":377},[262,10462,10463],{"class":429}," results\n",[14,10465,3349,10466,10469,10470,10473],{},[18,10467,10468],{},"if file_path.exists()"," check is what makes the batch resumable: rerun the same command after a crash and it skips everything already on disk, so you only pay for the rows that still need images. The ",[18,10471,10472],{},"throttle"," sleep keeps the loop comfortably under your account's images-per-minute limit.",[57,10475,10477],{"id":10476},"step-4-record-a-manifest","Step 4 — Record a manifest",[14,10479,10480,10481,10484],{},"A manifest is the receipt for the whole run: a single file that maps every prompt to the image it produced, with a status and a timestamp. It is what lets you audit results, hand the folder to a teammate, or feed the successful rows into the next step of your pipeline. Write it once, at the end, from the ",[18,10482,10483],{},"results"," list:",[253,10486,10488],{"className":414,"code":10487,"language":416,"meta":258,"style":258},"import csv\nfrom datetime import datetime, timezone\n\n\ndef write_manifest(results: list[dict], manifest_path: str = \"manifest.csv\") -> None:\n    stamp = datetime.now(timezone.utc).isoformat()\n    fields = [\"id\", \"prompt\", \"file\", \"status\", \"generated_at\"]\n    with open(manifest_path, \"w\", newline=\"\", encoding=\"utf-8\") as fh:\n        writer = csv.DictWriter(fh, fieldnames=fields)\n        writer.writeheader()\n        for r in results:\n            writer.writerow({**r, \"generated_at\": stamp})\n\n\nif __name__ == \"__main__\":\n    results = run_batch(\"products.csv\", \"output\", quality=\"standard\")\n    write_manifest(results)\n    ok = sum(1 for r in results if r[\"status\"] == \"ok\")\n    print(f\"Done: {ok}\u002F{len(results)} images generated\")\n",[18,10489,10490,10496,10508,10512,10516,10544,10554,10586,10619,10637,10642,10654,10670,10674,10678,10690,10718,10723,10764],{"__ignoreMap":258},[262,10491,10492,10494],{"class":181,"line":264},[262,10493,684],{"class":377},[262,10495,8533],{"class":429},[262,10497,10498,10500,10503,10505],{"class":181,"line":282},[262,10499,705],{"class":377},[262,10501,10502],{"class":429}," datetime ",[262,10504,684],{"class":377},[262,10506,10507],{"class":429}," datetime, timezone\n",[262,10509,10510],{"class":181,"line":295},[262,10511,583],{"emptyLinePlaceholder":582},[262,10513,10514],{"class":181,"line":345},[262,10515,583],{"emptyLinePlaceholder":582},[262,10517,10518,10520,10523,10526,10528,10531,10533,10535,10538,10540,10542],{"class":181,"line":492},[262,10519,423],{"class":377},[262,10521,10522],{"class":267}," write_manifest",[262,10524,10525],{"class":429},"(results: list[",[262,10527,5869],{"class":271},[262,10529,10530],{"class":429},"], manifest_path: ",[262,10532,433],{"class":271},[262,10534,442],{"class":377},[262,10536,10537],{"class":275}," \"manifest.csv\"",[262,10539,1939],{"class":429},[262,10541,8471],{"class":271},[262,10543,1160],{"class":429},[262,10545,10546,10549,10551],{"class":181,"line":503},[262,10547,10548],{"class":429},"    stamp ",[262,10550,476],{"class":377},[262,10552,10553],{"class":429}," datetime.now(timezone.utc).isoformat()\n",[262,10555,10556,10559,10561,10564,10566,10568,10570,10572,10575,10577,10579,10581,10584],{"class":181,"line":521},[262,10557,10558],{"class":429},"    fields ",[262,10560,476],{"class":377},[262,10562,10563],{"class":429}," [",[262,10565,6770],{"class":275},[262,10567,608],{"class":429},[262,10569,10255],{"class":275},[262,10571,608],{"class":429},[262,10573,10574],{"class":275},"\"file\"",[262,10576,608],{"class":429},[262,10578,10324],{"class":275},[262,10580,608],{"class":429},[262,10582,10583],{"class":275},"\"generated_at\"",[262,10585,957],{"class":429},[262,10587,10588,10590,10592,10595,10597,10599,10601,10603,10605,10607,10609,10611,10613,10615,10617],{"class":181,"line":537},[262,10589,10124],{"class":377},[262,10591,599],{"class":271},[262,10593,10594],{"class":429},"(manifest_path, ",[262,10596,9165],{"class":275},[262,10598,608],{"class":429},[262,10600,9170],{"class":611},[262,10602,476],{"class":377},[262,10604,9175],{"class":275},[262,10606,608],{"class":429},[262,10608,612],{"class":611},[262,10610,476],{"class":377},[262,10612,617],{"class":275},[262,10614,1000],{"class":429},[262,10616,697],{"class":377},[262,10618,10150],{"class":429},[262,10620,10621,10624,10626,10629,10632,10634],{"class":181,"line":549},[262,10622,10623],{"class":429},"        writer ",[262,10625,476],{"class":377},[262,10627,10628],{"class":429}," csv.DictWriter(fh, ",[262,10630,10631],{"class":611},"fieldnames",[262,10633,476],{"class":377},[262,10635,10636],{"class":429},"fields)\n",[262,10638,10639],{"class":181,"line":570},[262,10640,10641],{"class":429},"        writer.writeheader()\n",[262,10643,10644,10646,10649,10651],{"class":181,"line":579},[262,10645,10155],{"class":377},[262,10647,10648],{"class":429}," r ",[262,10650,835],{"class":377},[262,10652,10653],{"class":429}," results:\n",[262,10655,10656,10659,10662,10665,10667],{"class":181,"line":586},[262,10657,10658],{"class":429},"            writer.writerow({",[262,10660,10661],{"class":377},"**",[262,10663,10664],{"class":429},"r, ",[262,10666,10583],{"class":275},[262,10668,10669],{"class":429},": stamp})\n",[262,10671,10672],{"class":181,"line":591},[262,10673,583],{"emptyLinePlaceholder":582},[262,10675,10676],{"class":181,"line":623},[262,10677,583],{"emptyLinePlaceholder":582},[262,10679,10680,10682,10684,10686,10688],{"class":181,"line":634},[262,10681,2210],{"class":377},[262,10683,2213],{"class":271},[262,10685,2216],{"class":377},[262,10687,2219],{"class":275},[262,10689,1160],{"class":429},[262,10691,10692,10695,10697,10700,10703,10705,10707,10709,10711,10713,10716],{"class":181,"line":845},[262,10693,10694],{"class":429},"    results ",[262,10696,476],{"class":377},[262,10698,10699],{"class":429}," run_batch(",[262,10701,10702],{"class":275},"\"products.csv\"",[262,10704,608],{"class":429},[262,10706,4963],{"class":275},[262,10708,608],{"class":429},[262,10710,10268],{"class":611},[262,10712,476],{"class":377},[262,10714,10715],{"class":275},"\"standard\"",[262,10717,660],{"class":429},[262,10719,10720],{"class":181,"line":850},[262,10721,10722],{"class":429},"    write_manifest(results)\n",[262,10724,10725,10728,10730,10733,10735,10737,10740,10742,10744,10747,10749,10752,10754,10756,10759,10762],{"class":181,"line":864},[262,10726,10727],{"class":429},"    ok ",[262,10729,476],{"class":377},[262,10731,10732],{"class":271}," sum",[262,10734,602],{"class":429},[262,10736,997],{"class":271},[262,10738,10739],{"class":377}," for",[262,10741,10648],{"class":429},[262,10743,835],{"class":377},[262,10745,10746],{"class":429}," results ",[262,10748,2210],{"class":377},[262,10750,10751],{"class":429}," r[",[262,10753,10324],{"class":275},[262,10755,2903],{"class":429},[262,10757,10758],{"class":377},"==",[262,10760,10761],{"class":275}," \"ok\"",[262,10763,660],{"class":429},[262,10765,10766,10768,10770,10772,10774,10776,10779,10781,10783,10785,10788,10790,10793],{"class":181,"line":1683},[262,10767,1089],{"class":271},[262,10769,602],{"class":429},[262,10771,642],{"class":377},[262,10773,9256],{"class":275},[262,10775,3039],{"class":271},[262,10777,10778],{"class":429},"ok",[262,10780,654],{"class":271},[262,10782,981],{"class":275},[262,10784,648],{"class":271},[262,10786,10787],{"class":429},"(results)",[262,10789,654],{"class":271},[262,10791,10792],{"class":275}," images generated\"",[262,10794,660],{"class":429},[14,10796,10797,10798,10801,10802,10805,10806,10809],{},"Run the whole thing with ",[18,10799,10800],{},"python batch_images.py",". You end with an ",[18,10803,10804],{},"output\u002F"," folder of PNGs and a ",[18,10807,10808],{},"manifest.csv"," you can open in any spreadsheet to see exactly which products succeeded and which need attention.",[57,10811,1367],{"id":1366},[14,10813,10814],{},"These four arguments control the cost and shape of every image. The model fixes which sizes and quality levels are even allowed:",[1379,10816,10817,10828],{},[1382,10818,10819],{},[1385,10820,10821,10823,10826],{},[1388,10822,1390],{},[1388,10824,10825],{},"Allowed values",[1388,10827,1396],{},[1398,10829,10830,10850,10870,10889],{},[1385,10831,10832,10836,10844],{},[1403,10833,10834],{},[18,10835,805],{},[1403,10837,10838,608,10841],{},[18,10839,10840],{},"dall-e-3",[18,10842,10843],{},"dall-e-2",[1403,10845,10846,10847,10849],{},"Picks the model; ",[18,10848,10840],{}," gives far better product realism.",[1385,10851,10852,10856,10867],{},[1403,10853,10854],{},[18,10855,10260],{},[1403,10857,10858,608,10861,608,10864],{},[18,10859,10860],{},"1024x1024",[18,10862,10863],{},"1024x1792",[18,10865,10866],{},"1792x1024",[1403,10868,10869],{},"Output dimensions; square suits most product listings.",[1385,10871,10872,10876,10884],{},[1403,10873,10874],{},[18,10875,10268],{},[1403,10877,10878,608,10881],{},[18,10879,10880],{},"standard",[18,10882,10883],{},"hd",[1403,10885,10886,10888],{},[18,10887,10883],{}," adds finer detail at roughly double the per-image cost.",[1385,10890,10891,10896,10901],{},[1403,10892,10893],{},[18,10894,10895],{},"n",[1403,10897,10898,10900],{},[18,10899,997],{}," (dall-e-3)",[1403,10902,10903],{},"Images per request; dall-e-3 forces 1, so batches are loops.",[57,10905,1445],{"id":1444},[1447,10907,10908,10916,10929,10942],{},[1450,10909,10910,10915],{},[35,10911,10912,1363],{},[18,10913,10914],{},"BadRequestError: content policy violation"," The prompt tripped the safety filter, often on brand names, real people, or trademarked logos. Rewrite the prompt to describe the object generically and rerun only that row.",[1450,10917,10918,10924,10925,10928],{},[35,10919,10920,10923],{},[18,10921,10922],{},"httpx.ReadTimeout"," while downloading."," The image URL is valid for about an hour but the download itself stalled. Raise the ",[18,10926,10927],{},"httpx.get"," timeout to 60 seconds, and make sure you save bytes inside the same loop iteration rather than collecting URLs to fetch later.",[1450,10930,10931,10934,10935,10937,10938,10941],{},[35,10932,10933],{},"Every row fails with a 429."," Your ",[18,10936,10472],{}," value is too low for your account tier. Increase the ",[18,10939,10940],{},"time.sleep"," between calls to 2 or 3 seconds, or request a higher rate limit from your provider dashboard.",[1450,10943,10944,10947,10948,10950,10951,10953],{},[35,10945,10946],{},"Images look right but filenames collide."," Two CSV rows share the same ",[18,10949,9492],{},", so the second overwrites the first. Make the ",[18,10952,9492],{}," column unique (append a suffix) before running, since the script keys every file on it.",[57,10955,2317],{"id":2316},[2322,10957,10958,10964,10970],{},[1450,10959,10960,10963],{},[35,10961,10962],{},"Use this batch script"," when you have dozens or hundreds of products that each need a fresh, consistent generated image and no existing photography. The loop plus manifest pays off the moment manual one-by-one prompting becomes tedious.",[1450,10965,10966,10969],{},[35,10967,10968],{},"Use a single interactive call"," when you only need one or two hero images and want to iterate on the prompt by hand. Batching adds overhead that a two-image job does not justify.",[1450,10971,10972,10975],{},[35,10973,10974],{},"Use real product photography or background-removal tools"," when you must show the actual item exactly as it ships. Generated images are ideal for concepts, mockups, and placeholders, but they invent details a customer-facing catalogue may need to be literal about.",[14,10977,10978,10979,10981],{},"For a related text-heavy batch job, see ",[51,10980,2462],{"href":5290},", which pairs naturally with generated images to refresh a whole catalogue at once.",[14,10983,2375,10984,1363],{},[51,10985,9410],{"href":9409},[57,10987,2381],{"id":2380},[2322,10989,10990,10994,10999,11004],{},[1450,10991,10992,5319],{},[51,10993,9410],{"href":9409},[1450,10995,10996,10998],{},[51,10997,9415],{"href":9414}," — add text overlays and resize generated images for a platform.",[1450,11000,11001,11003],{},[51,11002,2462],{"href":5290}," — generate the copy that sits beside these images.",[1450,11005,11006,11008],{},[51,11007,3379],{"href":3378}," — go deeper on the rate-limit handling this script relies on.",[2401,11010,11011],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":11013},[11014,11015,11016,11017,11018,11019,11020,11021,11022],{"id":237,"depth":282,"text":238},{"id":9482,"depth":282,"text":9483},{"id":9533,"depth":282,"text":9534},{"id":9951,"depth":282,"text":9952},{"id":10476,"depth":282,"text":10477},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Read a CSV of product prompts, generate images in a loop with DALL-E and Python, handle rate limits and retries, and save a clean manifest of every file.",[11025,11028,11031,11034,11037],{"q":11026,"a":11027},"Can DALL-E generate more than one image per request?","DALL-E 3 only supports n=1 per request, so a batch is a loop of single calls. The older dall-e-2 model accepts n up to 10, but its quality is much lower for product visuals.",{"q":11029,"a":11030},"How much does it cost to batch-generate product images?","DALL-E 3 charges per generated image, roughly 0.04 USD for a standard 1024x1024 image and about 0.08 USD for HD as of 2026. A 200-row CSV at standard quality costs around 8 USD.",{"q":11032,"a":11033},"What is a manifest and why keep one?","A manifest is a small CSV or JSON file that records which prompt produced which image file, plus status and timestamps. It lets you resume a failed batch and trace every output back to its input row.",{"q":11035,"a":11036},"Why am I getting a 429 error during a large batch?","A 429 means you have hit your account's images-per-minute rate limit. Slow the loop down with a short sleep between calls and retry failed rows with exponential backoff instead of hammering the API.",{"q":11038,"a":11039},"How long do the image URLs from the API stay valid?","Image URLs returned by the API expire after about an hour, so download and save the bytes immediately in the same loop iteration rather than collecting URLs to fetch later.",{"name":11041,"steps":11042},"How to batch-generate product images with DALL-E and Python",[11043,11046,11049,11052],{"name":11044,"text":11045},"Prepare a CSV of product prompts","Create a CSV with one row per product, including a stable id and a descriptive prompt column.",{"name":11047,"text":11048},"Write a resilient generate function","Call the OpenAI images API for a single prompt with retries and exponential backoff on rate limits.",{"name":11050,"text":11051},"Loop over the CSV and save files","Iterate every row, download the returned image bytes, and write each file to an output folder.",{"name":11053,"text":11054},"Record a manifest","Append the prompt, output path, status, and timestamp for each row so the batch is resumable.",{},"\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fbatch-generate-product-images-with-dall-e-and-python",{"title":9395,"description":11023},"Batch-Generate Product Images with DALL-E & Python","ai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fbatch-generate-product-images-with-dall-e-and-python\u002Findex","6CkDcF4OGMy02cZV0cZXjKReRXnmU_wEvNGRTkmQsnE",{"id":11062,"title":11063,"body":11064,"description":12679,"extension":2419,"faq":12680,"howto":12696,"meta":12714,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":12715,"published":12716,"seo":12717,"seoTitle":12718,"stem":12719,"__hash__":12720},"content\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fcreate-youtube-thumbnails-with-dall-e-3-and-python\u002Findex.md","Create YouTube Thumbnails with DALL-E 3 and Python",{"type":7,"value":11065,"toc":12668},[11066,11069,11078,11089,11091,11097,11114,11117,11136,11151,11157,11166,11173,11176,11218,11222,11229,11494,11506,11509,11584,11591,11595,11602,11884,11893,11913,11923,11927,11930,12024,12032,12036,12048,12430,12437,12443,12454,12456,12463,12553,12555,12609,12611,12634,12637,12641,12643,12665],[10,11067,11063],{"id":11068},"create-youtube-thumbnails-with-dall-e-3-and-python",[14,11070,11071,11072,11074,11075,11077],{},"This guide shows you how to generate branded, upload-ready 1280x720 YouTube thumbnails with DALL-E 3 and Python in under 15 minutes — generate the art with the API, add clean text with Pillow, and export at the exact size YouTube wants. It is part of ",[51,11073,9410],{"href":9409},", and it pairs naturally with the broader ",[51,11076,5413],{"href":5412}," workflow once you start producing visuals in volume.",[14,11079,11080,11081,11084,11085,11088],{},"A thumbnail is the small clickable image viewers see before they watch. It is the single biggest lever on your click-through rate, and designing one by hand for every upload is slow. The trick that makes this reliable: let DALL-E 3 paint the ",[27,11082,11083],{},"background"," (which it is great at) and let Python add the ",[27,11086,11087],{},"words"," (which DALL-E 3 is bad at). You get eye-catching art and crisp, legible text every time.",[57,11090,238],{"id":237},[14,11092,11093,11094,11096],{},"This guide assumes you already have Python 3.10 or newer and a code editor. If you are starting from scratch, work through ",[51,11095,2482],{"href":2481}," first. Beyond that, you need three things:",[1447,11098,11099,11104,11107],{},[1450,11100,11101,11102,1363],{},"An OpenAI account with billing enabled and an API key. If your key throws an auth error, see ",[51,11103,388],{"href":387},[1450,11105,11106],{},"The libraries below.",[1450,11108,11109,11110,11113],{},"A bold ",[18,11111,11112],{},".ttf"," font file for your title text (every operating system ships with at least one).",[14,11115,11116],{},"Install the dependencies:",[253,11118,11120],{"className":255,"code":11119,"language":257,"meta":258,"style":258},"pip install \"openai>=1.30.0\" python-dotenv Pillow httpx\n",[18,11121,11122],{"__ignoreMap":258},[262,11123,11124,11126,11128,11130,11132,11134],{"class":181,"line":264},[262,11125,298],{"class":267},[262,11127,301],{"class":275},[262,11129,9448],{"class":275},[262,11131,310],{"class":275},[262,11133,9453],{"class":275},[262,11135,6526],{"class":275},[14,11137,11138,11140,11141,11144,11145,11147,11148,11150],{},[18,11139,20],{}," is the official SDK that calls DALL-E 3. ",[18,11142,11143],{},"Pillow"," is the image library that crops, resizes, and draws text. ",[18,11146,2501],{}," loads your secret key from a file, and ",[18,11149,5450],{}," downloads the generated image over HTTP.",[14,11152,11153,11154,11156],{},"Store your key in a file named ",[18,11155,319],{}," in your project folder so it never ends up hard-coded in your script:",[253,11158,11160],{"className":323,"code":11159,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-key-here\n",[18,11161,11162],{"__ignoreMap":258},[262,11163,11164],{"class":181,"line":264},[262,11165,11159],{},[14,11167,353,11168,356,11170,11172],{},[18,11169,319],{},[18,11171,359],{}," immediately, so you never commit your secret key to version control.",[14,11174,11175],{},"Then load it at the top of your script:",[253,11177,11179],{"className":414,"code":11178,"language":416,"meta":258,"style":258},"from dotenv import load_dotenv\nimport os\n\nload_dotenv()\nAPI_KEY = os.getenv(\"OPENAI_API_KEY\")\n",[18,11180,11181,11191,11197,11201,11205],{"__ignoreMap":258},[262,11182,11183,11185,11187,11189],{"class":181,"line":264},[262,11184,705],{"class":377},[262,11186,708],{"class":429},[262,11188,684],{"class":377},[262,11190,713],{"class":429},[262,11192,11193,11195],{"class":181,"line":282},[262,11194,684],{"class":377},[262,11196,687],{"class":429},[262,11198,11199],{"class":181,"line":295},[262,11200,583],{"emptyLinePlaceholder":582},[262,11202,11203],{"class":181,"line":345},[262,11204,734],{"class":429},[262,11206,11207,11210,11212,11214,11216],{"class":181,"line":492},[262,11208,11209],{"class":271},"API_KEY",[262,11211,442],{"class":377},[262,11213,754],{"class":429},[262,11215,2681],{"class":275},[262,11217,660],{"class":429},[57,11219,11221],{"id":11220},"step-1-generate-the-background-art-with-dall-e-3","Step 1: Generate the background art with DALL-E 3",[14,11223,11224,11225,11228],{},"DALL-E 3 generates one image per request (",[18,11226,11227],{},"n=1"," is the only value it accepts). The function below asks for a square HD image, downloads the raw bytes, and retries with exponential backoff if you hit a rate limit. Exponential backoff means each retry waits a little longer than the last, which gives OpenAI's servers room to recover.",[253,11230,11232],{"className":414,"code":11231,"language":416,"meta":258,"style":258},"import time\nimport httpx\nfrom openai import OpenAI, RateLimitError, BadRequestError\n\nclient = OpenAI(api_key=API_KEY)\n\n\ndef generate_dalle_image(prompt: str) -> bytes:\n    \"\"\"Generate a square image and return its raw bytes.\"\"\"\n    for attempt in range(3):\n        try:\n            response = client.images.generate(\n                model=\"dall-e-3\",\n                prompt=prompt,\n                size=\"1024x1024\",\n                quality=\"hd\",\n                style=\"vivid\",\n                response_format=\"url\",\n            )\n            img_url = response.data[0].url\n            return httpx.get(img_url, timeout=30).content\n        except RateLimitError:\n            time.sleep(2 ** attempt)\n        except BadRequestError as e:\n            raise RuntimeError(f\"Prompt rejected by OpenAI: {e}\")\n    raise RuntimeError(\"Max retries exceeded\")\n",[18,11233,11234,11240,11246,11257,11261,11277,11281,11285,11302,11307,11323,11329,11337,11347,11355,11366,11377,11389,11399,11403,11416,11431,11437,11447,11458,11482],{"__ignoreMap":258},[262,11235,11236,11238],{"class":181,"line":264},[262,11237,684],{"class":377},[262,11239,2612],{"class":429},[262,11241,11242,11244],{"class":181,"line":282},[262,11243,684],{"class":377},[262,11245,6526],{"class":429},[262,11247,11248,11250,11252,11254],{"class":181,"line":295},[262,11249,705],{"class":377},[262,11251,720],{"class":429},[262,11253,684],{"class":377},[262,11255,11256],{"class":429}," OpenAI, RateLimitError, BadRequestError\n",[262,11258,11259],{"class":181,"line":345},[262,11260,583],{"emptyLinePlaceholder":582},[262,11262,11263,11265,11267,11269,11271,11273,11275],{"class":181,"line":492},[262,11264,739],{"class":429},[262,11266,476],{"class":377},[262,11268,1588],{"class":429},[262,11270,2674],{"class":611},[262,11272,476],{"class":377},[262,11274,11209],{"class":271},[262,11276,660],{"class":429},[262,11278,11279],{"class":181,"line":503},[262,11280,583],{"emptyLinePlaceholder":582},[262,11282,11283],{"class":181,"line":521},[262,11284,583],{"emptyLinePlaceholder":582},[262,11286,11287,11289,11292,11294,11296,11298,11300],{"class":181,"line":537},[262,11288,423],{"class":377},[262,11290,11291],{"class":267}," generate_dalle_image",[262,11293,9599],{"class":429},[262,11295,433],{"class":271},[262,11297,1939],{"class":429},[262,11299,9643],{"class":271},[262,11301,1160],{"class":429},[262,11303,11304],{"class":181,"line":549},[262,11305,11306],{"class":275},"    \"\"\"Generate a square image and return its raw bytes.\"\"\"\n",[262,11308,11309,11311,11313,11315,11317,11319,11321],{"class":181,"line":570},[262,11310,3074],{"class":377},[262,11312,3077],{"class":429},[262,11314,835],{"class":377},[262,11316,3082],{"class":271},[262,11318,602],{"class":429},[262,11320,5556],{"class":271},[262,11322,8192],{"class":429},[262,11324,11325,11327],{"class":181,"line":579},[262,11326,3090],{"class":377},[262,11328,1160],{"class":429},[262,11330,11331,11333,11335],{"class":181,"line":586},[262,11332,3097],{"class":429},[262,11334,476],{"class":377},[262,11336,9677],{"class":429},[262,11338,11339,11341,11343,11345],{"class":181,"line":591},[262,11340,3106],{"class":611},[262,11342,476],{"class":377},[262,11344,9686],{"class":275},[262,11346,1315],{"class":429},[262,11348,11349,11351,11353],{"class":181,"line":623},[262,11350,9693],{"class":611},[262,11352,476],{"class":377},[262,11354,9698],{"class":429},[262,11356,11357,11359,11361,11364],{"class":181,"line":634},[262,11358,9703],{"class":611},[262,11360,476],{"class":377},[262,11362,11363],{"class":275},"\"1024x1024\"",[262,11365,1315],{"class":429},[262,11367,11368,11370,11372,11375],{"class":181,"line":845},[262,11369,9713],{"class":611},[262,11371,476],{"class":377},[262,11373,11374],{"class":275},"\"hd\"",[262,11376,1315],{"class":429},[262,11378,11379,11382,11384,11387],{"class":181,"line":850},[262,11380,11381],{"class":611},"                style",[262,11383,476],{"class":377},[262,11385,11386],{"class":275},"\"vivid\"",[262,11388,1315],{"class":429},[262,11390,11391,11393,11395,11397],{"class":181,"line":864},[262,11392,9738],{"class":611},[262,11394,476],{"class":377},[262,11396,6276],{"class":275},[262,11398,1315],{"class":429},[262,11400,11401],{"class":181,"line":1683},[262,11402,3193],{"class":429},[262,11404,11405,11408,11410,11412,11414],{"class":181,"line":1688},[262,11406,11407],{"class":429},"            img_url ",[262,11409,476],{"class":377},[262,11411,9758],{"class":429},[262,11413,102],{"class":271},[262,11415,9763],{"class":429},[262,11417,11418,11420,11423,11425,11427,11429],{"class":181,"line":1693},[262,11419,3198],{"class":377},[262,11421,11422],{"class":429}," httpx.get(img_url, ",[262,11424,1591],{"class":611},[262,11426,476],{"class":377},[262,11428,9777],{"class":271},[262,11430,9780],{"class":429},[262,11432,11433,11435],{"class":181,"line":1728},[262,11434,3214],{"class":377},[262,11436,9787],{"class":429},[262,11438,11439,11441,11443,11445],{"class":181,"line":1737},[262,11440,9913],{"class":429},[262,11442,109],{"class":271},[262,11444,3235],{"class":377},[262,11446,9920],{"class":429},[262,11448,11449,11451,11453,11455],{"class":181,"line":1751},[262,11450,3214],{"class":377},[262,11452,9835],{"class":429},[262,11454,697],{"class":377},[262,11456,11457],{"class":429}," e:\n",[262,11459,11460,11462,11464,11466,11468,11471,11473,11476,11478,11480],{"class":181,"line":1764},[262,11461,9850],{"class":377},[262,11463,3318],{"class":271},[262,11465,602],{"class":429},[262,11467,642],{"class":377},[262,11469,11470],{"class":275},"\"Prompt rejected by OpenAI: ",[262,11472,3039],{"class":271},[262,11474,11475],{"class":429},"e",[262,11477,654],{"class":271},[262,11479,1176],{"class":275},[262,11481,660],{"class":429},[262,11483,11484,11486,11488,11490,11492],{"class":181,"line":1779},[262,11485,2829],{"class":377},[262,11487,3318],{"class":271},[262,11489,602],{"class":429},[262,11491,9931],{"class":275},[262,11493,660],{"class":429},[14,11495,3349,11496,11498,11499,11501,11502,11505],{},[18,11497,2401],{}," parameter is your strongest creative dial: ",[18,11500,11386],{}," produces high-contrast, bold images that suit entertainment and gaming, while ",[18,11503,11504],{},"\"natural\""," produces calmer, realistic images that suit tech and education. Note this is a parameter on the API call, not a word you type into the prompt.",[14,11507,11508],{},"Write prompts that leave room for text. Ask for the subject on one side and empty space on the other:",[253,11510,11512],{"className":414,"code":11511,"language":416,"meta":258,"style":258},"PROMPT_TEMPLATE = (\n    \"YouTube thumbnail background: {subject}, dramatic studio lighting, \"\n    \"bold complementary colors, large clean empty space on the left third, \"\n    \"no text, no words, no letters, cinematic, high detail\"\n)\n\nprompt = PROMPT_TEMPLATE.format(subject=\"a glowing laptop on a dark desk\")\nraw_bytes = generate_dalle_image(prompt)\n",[18,11513,11514,11523,11534,11539,11544,11548,11552,11574],{"__ignoreMap":258},[262,11515,11516,11519,11521],{"class":181,"line":264},[262,11517,11518],{"class":271},"PROMPT_TEMPLATE",[262,11520,442],{"class":377},[262,11522,984],{"class":429},[262,11524,11525,11528,11531],{"class":181,"line":282},[262,11526,11527],{"class":275},"    \"YouTube thumbnail background: ",[262,11529,11530],{"class":271},"{subject}",[262,11532,11533],{"class":275},", dramatic studio lighting, \"\n",[262,11535,11536],{"class":181,"line":295},[262,11537,11538],{"class":275},"    \"bold complementary colors, large clean empty space on the left third, \"\n",[262,11540,11541],{"class":181,"line":345},[262,11542,11543],{"class":275},"    \"no text, no words, no letters, cinematic, high detail\"\n",[262,11545,11546],{"class":181,"line":492},[262,11547,660],{"class":429},[262,11549,11550],{"class":181,"line":503},[262,11551,583],{"emptyLinePlaceholder":582},[262,11553,11554,11557,11559,11562,11565,11567,11569,11572],{"class":181,"line":521},[262,11555,11556],{"class":429},"prompt ",[262,11558,476],{"class":377},[262,11560,11561],{"class":271}," PROMPT_TEMPLATE",[262,11563,11564],{"class":429},".format(",[262,11566,6319],{"class":611},[262,11568,476],{"class":377},[262,11570,11571],{"class":275},"\"a glowing laptop on a dark desk\"",[262,11573,660],{"class":429},[262,11575,11576,11579,11581],{"class":181,"line":537},[262,11577,11578],{"class":429},"raw_bytes ",[262,11580,476],{"class":377},[262,11582,11583],{"class":429}," generate_dalle_image(prompt)\n",[14,11585,11586,11587,11590],{},"Telling DALL-E 3 explicitly that there should be ",[27,11588,11589],{},"no text"," keeps it from scribbling its own garbled lettering, leaving a clean canvas for the words you add in the next step.",[57,11592,11594],{"id":11593},"step-2-crop-resize-and-add-branded-text-with-pillow","Step 2: Crop, resize, and add branded text with Pillow",[14,11596,11597,11598,11601],{},"DALL-E 3 returns a 1024x1024 square, but YouTube wants 1280x720 (a 16:9 widescreen shape). Pillow's ",[18,11599,11600],{},"ImageOps.fit"," center-crops the square to the right shape and resizes it in one call using the LANCZOS filter, which keeps edges sharp. Then you draw the title twice — once in black, offset by a few pixels as a drop shadow, and once in white on top — so the text stays readable over any background.",[253,11603,11605],{"className":414,"code":11604,"language":416,"meta":258,"style":258},"from PIL import Image, ImageOps, ImageDraw, ImageFont\nimport io\n\n\ndef format_to_youtube(\n    raw_bytes: bytes, title: str, font_path: str, output_path: str\n) -> None:\n    \"\"\"Crop to 1280x720, add a title with a drop shadow, and save as PNG.\"\"\"\n    img = Image.open(io.BytesIO(raw_bytes)).convert(\"RGB\")\n    img = ImageOps.fit(img, (1280, 720), method=Image.Resampling.LANCZOS)\n\n    draw = ImageDraw.Draw(img)\n    font = ImageFont.truetype(font_path, 84)\n\n    text_x, text_y = 360, 600\n    # Drop shadow (offset by 5px) for contrast on busy backgrounds\n    draw.text((text_x + 5, text_y + 5), title, fill=\"#000000\", font=font, anchor=\"mm\")\n    # Primary white text on top\n    draw.text((text_x, text_y), title, fill=\"#FFFFFF\", font=font, anchor=\"mm\")\n\n    img.save(output_path, format=\"PNG\", optimize=True)\n",[18,11606,11607,11618,11624,11628,11632,11641,11663,11671,11676,11691,11723,11727,11737,11752,11756,11771,11776,11823,11828,11856,11860],{"__ignoreMap":258},[262,11608,11609,11611,11613,11615],{"class":181,"line":264},[262,11610,705],{"class":377},[262,11612,9986],{"class":271},[262,11614,9989],{"class":377},[262,11616,11617],{"class":429}," Image, ImageOps, ImageDraw, ImageFont\n",[262,11619,11620,11622],{"class":181,"line":282},[262,11621,684],{"class":377},[262,11623,9999],{"class":429},[262,11625,11626],{"class":181,"line":295},[262,11627,583],{"emptyLinePlaceholder":582},[262,11629,11630],{"class":181,"line":345},[262,11631,583],{"emptyLinePlaceholder":582},[262,11633,11634,11636,11639],{"class":181,"line":492},[262,11635,423],{"class":377},[262,11637,11638],{"class":267}," format_to_youtube",[262,11640,2835],{"class":429},[262,11642,11643,11646,11648,11651,11653,11656,11658,11661],{"class":181,"line":503},[262,11644,11645],{"class":429},"    raw_bytes: ",[262,11647,9643],{"class":271},[262,11649,11650],{"class":429},", title: ",[262,11652,433],{"class":271},[262,11654,11655],{"class":429},", font_path: ",[262,11657,433],{"class":271},[262,11659,11660],{"class":429},", output_path: ",[262,11662,8677],{"class":271},[262,11664,11665,11667,11669],{"class":181,"line":521},[262,11666,1939],{"class":429},[262,11668,8471],{"class":271},[262,11670,1160],{"class":429},[262,11672,11673],{"class":181,"line":537},[262,11674,11675],{"class":275},"    \"\"\"Crop to 1280x720, add a title with a drop shadow, and save as PNG.\"\"\"\n",[262,11677,11678,11681,11683,11686,11689],{"class":181,"line":549},[262,11679,11680],{"class":429},"    img ",[262,11682,476],{"class":377},[262,11684,11685],{"class":429}," Image.open(io.BytesIO(raw_bytes)).convert(",[262,11687,11688],{"class":275},"\"RGB\"",[262,11690,660],{"class":429},[262,11692,11693,11695,11697,11700,11703,11705,11707,11710,11713,11715,11718,11721],{"class":181,"line":570},[262,11694,11680],{"class":429},[262,11696,476],{"class":377},[262,11698,11699],{"class":429}," ImageOps.fit(img, (",[262,11701,11702],{"class":271},"1280",[262,11704,608],{"class":429},[262,11706,168],{"class":271},[262,11708,11709],{"class":429},"), ",[262,11711,11712],{"class":611},"method",[262,11714,476],{"class":377},[262,11716,11717],{"class":429},"Image.Resampling.",[262,11719,11720],{"class":271},"LANCZOS",[262,11722,660],{"class":429},[262,11724,11725],{"class":181,"line":579},[262,11726,583],{"emptyLinePlaceholder":582},[262,11728,11729,11732,11734],{"class":181,"line":586},[262,11730,11731],{"class":429},"    draw ",[262,11733,476],{"class":377},[262,11735,11736],{"class":429}," ImageDraw.Draw(img)\n",[262,11738,11739,11742,11744,11747,11750],{"class":181,"line":591},[262,11740,11741],{"class":429},"    font ",[262,11743,476],{"class":377},[262,11745,11746],{"class":429}," ImageFont.truetype(font_path, ",[262,11748,11749],{"class":271},"84",[262,11751,660],{"class":429},[262,11753,11754],{"class":181,"line":623},[262,11755,583],{"emptyLinePlaceholder":582},[262,11757,11758,11761,11763,11766,11768],{"class":181,"line":634},[262,11759,11760],{"class":429},"    text_x, text_y ",[262,11762,476],{"class":377},[262,11764,11765],{"class":271}," 360",[262,11767,608],{"class":429},[262,11769,11770],{"class":271},"600\n",[262,11772,11773],{"class":181,"line":845},[262,11774,11775],{"class":291},"    # Drop shadow (offset by 5px) for contrast on busy backgrounds\n",[262,11777,11778,11781,11783,11785,11788,11790,11792,11795,11798,11800,11803,11805,11808,11810,11813,11816,11818,11821],{"class":181,"line":850},[262,11779,11780],{"class":429},"    draw.text((text_x ",[262,11782,531],{"class":377},[262,11784,9638],{"class":271},[262,11786,11787],{"class":429},", text_y ",[262,11789,531],{"class":377},[262,11791,9638],{"class":271},[262,11793,11794],{"class":429},"), title, ",[262,11796,11797],{"class":611},"fill",[262,11799,476],{"class":377},[262,11801,11802],{"class":275},"\"#000000\"",[262,11804,608],{"class":429},[262,11806,11807],{"class":611},"font",[262,11809,476],{"class":377},[262,11811,11812],{"class":429},"font, ",[262,11814,11815],{"class":611},"anchor",[262,11817,476],{"class":377},[262,11819,11820],{"class":275},"\"mm\"",[262,11822,660],{"class":429},[262,11824,11825],{"class":181,"line":864},[262,11826,11827],{"class":291},"    # Primary white text on top\n",[262,11829,11830,11833,11835,11837,11840,11842,11844,11846,11848,11850,11852,11854],{"class":181,"line":1683},[262,11831,11832],{"class":429},"    draw.text((text_x, text_y), title, ",[262,11834,11797],{"class":611},[262,11836,476],{"class":377},[262,11838,11839],{"class":275},"\"#FFFFFF\"",[262,11841,608],{"class":429},[262,11843,11807],{"class":611},[262,11845,476],{"class":377},[262,11847,11812],{"class":429},[262,11849,11815],{"class":611},[262,11851,476],{"class":377},[262,11853,11820],{"class":275},[262,11855,660],{"class":429},[262,11857,11858],{"class":181,"line":1688},[262,11859,583],{"emptyLinePlaceholder":582},[262,11861,11862,11865,11868,11870,11873,11875,11878,11880,11882],{"class":181,"line":1693},[262,11863,11864],{"class":429},"    img.save(output_path, ",[262,11866,11867],{"class":611},"format",[262,11869,476],{"class":377},[262,11871,11872],{"class":275},"\"PNG\"",[262,11874,608],{"class":429},[262,11876,11877],{"class":611},"optimize",[262,11879,476],{"class":377},[262,11881,4974],{"class":271},[262,11883,660],{"class":429},[14,11885,11886,11889,11890,11892],{},[18,11887,11888],{},"ImageFont.truetype"," needs a path to a real ",[18,11891,11112],{}," file. Common bold fonts you can point at:",[2322,11894,11895,11901,11907],{},[1450,11896,11897,11898],{},"Linux: ",[18,11899,11900],{},"\u002Fusr\u002Fshare\u002Ffonts\u002Ftruetype\u002Fdejavu\u002FDejaVuSans-Bold.ttf",[1450,11902,11903,11904],{},"macOS: ",[18,11905,11906],{},"\u002FSystem\u002FLibrary\u002FFonts\u002FSupplemental\u002FArial Bold.ttf",[1450,11908,11909,11910],{},"Windows: ",[18,11911,11912],{},"C:\\Windows\\Fonts\\arialbd.ttf",[14,11914,3349,11915,11918,11919,11922],{},[18,11916,11917],{},"anchor=\"mm\""," argument centers the text on the coordinate you give, so ",[18,11920,11921],{},"(360, 600)"," places the title's middle in the lower-left area — right where you left empty space in your prompt.",[57,11924,11926],{"id":11925},"step-3-export-a-single-thumbnail-end-to-end","Step 3: Export a single thumbnail end to end",[14,11928,11929],{},"With both functions in place, generating one finished thumbnail is two lines. Saving as an optimized PNG keeps quality high while trimming file size to stay well under YouTube's 2 MB limit.",[253,11931,11933],{"className":414,"code":11932,"language":416,"meta":258,"style":258},"raw_bytes = generate_dalle_image(\n    PROMPT_TEMPLATE.format(subject=\"a glowing laptop on a dark desk\")\n)\nformat_to_youtube(\n    raw_bytes,\n    title=\"PYTHON IN 2026\",\n    font_path=\"\u002Fusr\u002Fshare\u002Ffonts\u002Ftruetype\u002Fdejavu\u002FDejaVuSans-Bold.ttf\",\n    output_path=\"thumbnail.png\",\n)\nprint(\"Saved thumbnail.png\")\n",[18,11934,11935,11944,11959,11963,11968,11973,11985,11997,12009,12013],{"__ignoreMap":258},[262,11936,11937,11939,11941],{"class":181,"line":264},[262,11938,11578],{"class":429},[262,11940,476],{"class":377},[262,11942,11943],{"class":429}," generate_dalle_image(\n",[262,11945,11946,11949,11951,11953,11955,11957],{"class":181,"line":282},[262,11947,11948],{"class":271},"    PROMPT_TEMPLATE",[262,11950,11564],{"class":429},[262,11952,6319],{"class":611},[262,11954,476],{"class":377},[262,11956,11571],{"class":275},[262,11958,660],{"class":429},[262,11960,11961],{"class":181,"line":295},[262,11962,660],{"class":429},[262,11964,11965],{"class":181,"line":345},[262,11966,11967],{"class":429},"format_to_youtube(\n",[262,11969,11970],{"class":181,"line":492},[262,11971,11972],{"class":429},"    raw_bytes,\n",[262,11974,11975,11978,11980,11983],{"class":181,"line":503},[262,11976,11977],{"class":611},"    title",[262,11979,476],{"class":377},[262,11981,11982],{"class":275},"\"PYTHON IN 2026\"",[262,11984,1315],{"class":429},[262,11986,11987,11990,11992,11995],{"class":181,"line":521},[262,11988,11989],{"class":611},"    font_path",[262,11991,476],{"class":377},[262,11993,11994],{"class":275},"\"\u002Fusr\u002Fshare\u002Ffonts\u002Ftruetype\u002Fdejavu\u002FDejaVuSans-Bold.ttf\"",[262,11996,1315],{"class":429},[262,11998,11999,12002,12004,12007],{"class":181,"line":537},[262,12000,12001],{"class":611},"    output_path",[262,12003,476],{"class":377},[262,12005,12006],{"class":275},"\"thumbnail.png\"",[262,12008,1315],{"class":429},[262,12010,12011],{"class":181,"line":549},[262,12012,660],{"class":429},[262,12014,12015,12017,12019,12022],{"class":181,"line":570},[262,12016,637],{"class":271},[262,12018,602],{"class":429},[262,12020,12021],{"class":275},"\"Saved thumbnail.png\"",[262,12023,660],{"class":429},[14,12025,3772,12026,12029,12030,1363],{},[18,12027,12028],{},"thumbnail.png"," and you should see a 1280x720 image with your title set cleanly in the lower-left corner. If the text runs off the edge, shorten the title or lower the font size from ",[18,12031,11749],{},[57,12033,12035],{"id":12034},"step-4-batch-many-thumbnails-from-a-csv","Step 4: Batch many thumbnails from a CSV",[14,12037,12038,12039,1374,12041,12043,12044,12047],{},"The real payoff is generating a whole channel's worth of thumbnails in one run. Put your videos in a CSV with ",[18,12040,92],{},[18,12042,9496],{}," columns, then loop over the rows. A small ",[18,12045,12046],{},"slugify"," helper turns each title into a safe filename, and any single failure is caught and logged so one bad row never stops the batch.",[253,12049,12051],{"className":414,"code":12050,"language":416,"meta":258,"style":258},"import csv\nimport re\nfrom pathlib import Path\n\n\ndef slugify(text: str) -> str:\n    \"\"\"Turn a title into a filesystem-safe filename.\"\"\"\n    return re.sub(r\"[^\\w\\s-]\", \"\", text.lower()).strip().replace(\" \", \"-\")\n\n\ndef process_batch(csv_path: str, output_dir: str, font_path: str) -> None:\n    Path(output_dir).mkdir(parents=True, exist_ok=True)\n    with open(csv_path, newline=\"\", encoding=\"utf-8\") as f:\n        for row in csv.DictReader(f):\n            try:\n                raw = generate_dalle_image(row[\"prompt\"])\n                out_file = Path(output_dir) \u002F f\"{slugify(row['title'])}.png\"\n                format_to_youtube(raw, row[\"title\"], font_path, str(out_file))\n                print(f\"Saved: {out_file}\")\n            except Exception as e:\n                print(f\"Failed {row['title']}: {e}\")\n\n\nif __name__ == \"__main__\":\n    process_batch(\n        csv_path=\"videos.csv\",\n        output_dir=\"thumbnails\",\n        font_path=\"\u002Fusr\u002Fshare\u002Ffonts\u002Ftruetype\u002Fdejavu\u002FDejaVuSans-Bold.ttf\",\n    )\n",[18,12052,12053,12059,12065,12075,12079,12083,12100,12105,12142,12146,12150,12175,12196,12224,12235,12241,12255,12286,12302,12324,12334,12366,12370,12374,12386,12391,12403,12415,12426],{"__ignoreMap":258},[262,12054,12055,12057],{"class":181,"line":264},[262,12056,684],{"class":377},[262,12058,8533],{"class":429},[262,12060,12061,12063],{"class":181,"line":282},[262,12062,684],{"class":377},[262,12064,7956],{"class":429},[262,12066,12067,12069,12071,12073],{"class":181,"line":295},[262,12068,705],{"class":377},[262,12070,4882],{"class":429},[262,12072,684],{"class":377},[262,12074,4887],{"class":429},[262,12076,12077],{"class":181,"line":345},[262,12078,583],{"emptyLinePlaceholder":582},[262,12080,12081],{"class":181,"line":492},[262,12082,583],{"emptyLinePlaceholder":582},[262,12084,12085,12087,12090,12092,12094,12096,12098],{"class":181,"line":503},[262,12086,423],{"class":377},[262,12088,12089],{"class":267}," slugify",[262,12091,430],{"class":429},[262,12093,433],{"class":271},[262,12095,1939],{"class":429},[262,12097,433],{"class":271},[262,12099,1160],{"class":429},[262,12101,12102],{"class":181,"line":521},[262,12103,12104],{"class":275},"    \"\"\"Turn a title into a filesystem-safe filename.\"\"\"\n",[262,12106,12107,12109,12112,12114,12116,12119,12122,12125,12127,12129,12131,12134,12136,12138,12140],{"class":181,"line":537},[262,12108,573],{"class":377},[262,12110,12111],{"class":429}," re.sub(",[262,12113,7973],{"class":377},[262,12115,1176],{"class":275},[262,12117,12118],{"class":271},"[",[262,12120,12121],{"class":377},"^",[262,12123,12124],{"class":271},"\\w\\s-]",[262,12126,1176],{"class":275},[262,12128,608],{"class":429},[262,12130,9175],{"class":275},[262,12132,12133],{"class":429},", text.lower()).strip().replace(",[262,12135,543],{"class":275},[262,12137,608],{"class":429},[262,12139,1094],{"class":275},[262,12141,660],{"class":429},[262,12143,12144],{"class":181,"line":549},[262,12145,583],{"emptyLinePlaceholder":582},[262,12147,12148],{"class":181,"line":570},[262,12149,583],{"emptyLinePlaceholder":582},[262,12151,12152,12154,12157,12159,12161,12163,12165,12167,12169,12171,12173],{"class":181,"line":579},[262,12153,423],{"class":377},[262,12155,12156],{"class":267}," process_batch",[262,12158,10017],{"class":429},[262,12160,433],{"class":271},[262,12162,10022],{"class":429},[262,12164,433],{"class":271},[262,12166,11655],{"class":429},[262,12168,433],{"class":271},[262,12170,1939],{"class":429},[262,12172,8471],{"class":271},[262,12174,1160],{"class":429},[262,12176,12177,12180,12182,12184,12186,12188,12190,12192,12194],{"class":181,"line":586},[262,12178,12179],{"class":429},"    Path(output_dir).mkdir(",[262,12181,10088],{"class":611},[262,12183,476],{"class":377},[262,12185,4974],{"class":271},[262,12187,608],{"class":429},[262,12189,4969],{"class":611},[262,12191,476],{"class":377},[262,12193,4974],{"class":271},[262,12195,660],{"class":429},[262,12197,12198,12200,12202,12204,12206,12208,12210,12212,12214,12216,12218,12220,12222],{"class":181,"line":591},[262,12199,10124],{"class":377},[262,12201,599],{"class":271},[262,12203,10129],{"class":429},[262,12205,9170],{"class":611},[262,12207,476],{"class":377},[262,12209,9175],{"class":275},[262,12211,608],{"class":429},[262,12213,612],{"class":611},[262,12215,476],{"class":377},[262,12217,617],{"class":275},[262,12219,1000],{"class":429},[262,12221,697],{"class":377},[262,12223,9190],{"class":429},[262,12225,12226,12228,12230,12232],{"class":181,"line":623},[262,12227,10155],{"class":377},[262,12229,10158],{"class":429},[262,12231,835],{"class":377},[262,12233,12234],{"class":429}," csv.DictReader(f):\n",[262,12236,12237,12239],{"class":181,"line":634},[262,12238,10240],{"class":377},[262,12240,1160],{"class":429},[262,12242,12243,12246,12248,12251,12253],{"class":181,"line":845},[262,12244,12245],{"class":429},"                raw ",[262,12247,476],{"class":377},[262,12249,12250],{"class":429}," generate_dalle_image(row[",[262,12252,10255],{"class":275},[262,12254,3512],{"class":429},[262,12256,12257,12260,12262,12265,12267,12269,12271,12273,12276,12279,12282,12284],{"class":181,"line":850},[262,12258,12259],{"class":429},"                out_file ",[262,12261,476],{"class":377},[262,12263,12264],{"class":429}," Path(output_dir) ",[262,12266,981],{"class":377},[262,12268,10178],{"class":377},[262,12270,1176],{"class":275},[262,12272,3039],{"class":271},[262,12274,12275],{"class":429},"slugify(row[",[262,12277,12278],{"class":275},"'title'",[262,12280,12281],{"class":429},"])",[262,12283,654],{"class":271},[262,12285,10195],{"class":275},[262,12287,12288,12291,12294,12297,12299],{"class":181,"line":864},[262,12289,12290],{"class":429},"                format_to_youtube(raw, row[",[262,12292,12293],{"class":275},"\"title\"",[262,12295,12296],{"class":429},"], font_path, ",[262,12298,433],{"class":271},[262,12300,12301],{"class":429},"(out_file))\n",[262,12303,12304,12306,12308,12310,12313,12315,12318,12320,12322],{"class":181,"line":1683},[262,12305,10208],{"class":271},[262,12307,602],{"class":429},[262,12309,642],{"class":377},[262,12311,12312],{"class":275},"\"Saved: ",[262,12314,3039],{"class":271},[262,12316,12317],{"class":429},"out_file",[262,12319,654],{"class":271},[262,12321,1176],{"class":275},[262,12323,660],{"class":429},[262,12325,12326,12328,12330,12332],{"class":181,"line":1688},[262,12327,10358],{"class":377},[262,12329,10361],{"class":271},[262,12331,10364],{"class":377},[262,12333,11457],{"class":429},[262,12335,12336,12338,12340,12342,12344,12346,12348,12350,12352,12354,12356,12358,12360,12362,12364],{"class":181,"line":1693},[262,12337,10208],{"class":271},[262,12339,602],{"class":429},[262,12341,642],{"class":377},[262,12343,10426],{"class":275},[262,12345,3039],{"class":271},[262,12347,10185],{"class":429},[262,12349,12278],{"class":275},[262,12351,6223],{"class":429},[262,12353,654],{"class":271},[262,12355,1231],{"class":275},[262,12357,3039],{"class":271},[262,12359,11475],{"class":429},[262,12361,654],{"class":271},[262,12363,1176],{"class":275},[262,12365,660],{"class":429},[262,12367,12368],{"class":181,"line":1728},[262,12369,583],{"emptyLinePlaceholder":582},[262,12371,12372],{"class":181,"line":1737},[262,12373,583],{"emptyLinePlaceholder":582},[262,12375,12376,12378,12380,12382,12384],{"class":181,"line":1751},[262,12377,2210],{"class":377},[262,12379,2213],{"class":271},[262,12381,2216],{"class":377},[262,12383,2219],{"class":275},[262,12385,1160],{"class":429},[262,12387,12388],{"class":181,"line":1764},[262,12389,12390],{"class":429},"    process_batch(\n",[262,12392,12393,12396,12398,12401],{"class":181,"line":1779},[262,12394,12395],{"class":611},"        csv_path",[262,12397,476],{"class":377},[262,12399,12400],{"class":275},"\"videos.csv\"",[262,12402,1315],{"class":429},[262,12404,12405,12408,12410,12413],{"class":181,"line":1793},[262,12406,12407],{"class":611},"        output_dir",[262,12409,476],{"class":377},[262,12411,12412],{"class":275},"\"thumbnails\"",[262,12414,1315],{"class":429},[262,12416,12417,12420,12422,12424],{"class":181,"line":1800},[262,12418,12419],{"class":611},"        font_path",[262,12421,476],{"class":377},[262,12423,11994],{"class":275},[262,12425,1315],{"class":429},[262,12427,12428],{"class":181,"line":1805},[262,12429,1011],{"class":429},[14,12431,12432,12433,12436],{},"A matching ",[18,12434,12435],{},"videos.csv"," looks like this:",[253,12438,12441],{"className":12439,"code":12440,"language":111},[2577],"title,prompt\nPython in 2026,YouTube thumbnail background: a glowing laptop on a dark desk, no text, empty space on left\nBuild a Chatbot,YouTube thumbnail background: a friendly robot mascot, no text, empty space on left\n",[18,12442,12440],{"__ignoreMap":258},[14,12444,12445,12446,12449,12450,12453],{},"Run ",[18,12447,12448],{},"python main.py"," and every row becomes a finished thumbnail in the ",[18,12451,12452],{},"thumbnails\u002F"," folder.",[57,12455,5155],{"id":5154},[14,12457,12458,12459,12462],{},"These are the settings on the ",[18,12460,12461],{},"client.images.generate"," call you will adjust most often.",[1379,12464,12465,12477],{},[1382,12466,12467],{},[1385,12468,12469,12471,12473,12475],{},[1388,12470,1390],{},[1388,12472,3795],{},[1388,12474,3798],{},[1388,12476,1396],{},[1398,12478,12479,12498,12515,12535],{},[1385,12480,12481,12485,12487,12491],{},[1403,12482,12483],{},[18,12484,10260],{},[1403,12486,433],{},[1403,12488,12489],{},[18,12490,11363],{},[1403,12492,12493,12494,12497],{},"Output dimensions. Use ",[18,12495,12496],{},"\"1792x1024\""," for a wider source crop with less center-cropping.",[1385,12499,12500,12504,12506,12510],{},[1403,12501,12502],{},[18,12503,10268],{},[1403,12505,433],{},[1403,12507,12508],{},[18,12509,10715],{},[1403,12511,12512,12514],{},[18,12513,11374],{}," adds finer detail and costs roughly double; worth it for thumbnails.",[1385,12516,12517,12521,12523,12527],{},[1403,12518,12519],{},[18,12520,2401],{},[1403,12522,433],{},[1403,12524,12525],{},[18,12526,11386],{},[1403,12528,12529,12531,12532,12534],{},[18,12530,11386],{}," for bold, high-contrast art; ",[18,12533,11504],{}," for calmer, realistic scenes.",[1385,12536,12537,12541,12543,12547],{},[1403,12538,12539],{},[18,12540,10895],{},[1403,12542,439],{},[1403,12544,12545],{},[18,12546,997],{},[1403,12548,12549,12550,12552],{},"Number of images. DALL-E 3 only accepts ",[18,12551,997],{},"; loop the call to make variations.",[57,12554,1445],{"id":1444},[1447,12556,12557,12564,12582,12596],{},[1450,12558,12559,12563],{},[35,12560,12561],{},[18,12562,10914],{}," — Your prompt tripped OpenAI's safety filter, often from brand names, real people, or violent wording. Rephrase with generic descriptions (\"a confident speaker\") instead of named individuals, then retry.",[1450,12565,12566,12571,12572,12575,12576,12578,12579,12581],{},[35,12567,12568],{},[18,12569,12570],{},"OSError: cannot open resource"," — Pillow could not find your font file. The path in ",[18,12573,12574],{},"font_path"," is wrong or the file does not exist. Copy a ",[18,12577,11112],{}," into your project folder and point ",[18,12580,12574],{}," at it directly.",[1450,12583,12584,12587,12588,12591,12592,12595],{},[35,12585,12586],{},"Blurry or pixelated text"," — You either generated a small image or upscaled it. Always generate at 1024x1024 or larger and resize ",[27,12589,12590],{},"down"," to 1280x720 with ",[18,12593,12594],{},"Image.Resampling.LANCZOS",", never up.",[1450,12597,12598,12603,12604,12606,12607,1363],{},[35,12599,12600,12602],{},[18,12601,2707],{}," on big batches"," — You are sending requests faster than your account tier allows. The retry loop handles short spikes, but for large runs add a ",[18,12605,8453],{}," between rows. For the full fix, see ",[51,12608,3379],{"href":3378},[57,12610,2317],{"id":2316},[2322,12612,12613,12622,12628],{},[1450,12614,12615,12618,12619,1363],{},[35,12616,12617],{},"Use this DALL-E 3 workflow"," when you publish often, want a consistent on-brand look, and need text added programmatically. It shines for batch runs where you regenerate dozens of thumbnails from a spreadsheet. The same generate-then-overlay pattern scales straight into ",[51,12620,9395],{"href":12621},"\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fbatch-generate-product-images-with-dall-e-and-python\u002F",[1450,12623,12624,12627],{},[35,12625,12626],{},"Use a template tool like Canva or Figma"," when you make one or two thumbnails a week and prefer dragging elements by hand. There is no code, but no automation either.",[1450,12629,12630,12633],{},[35,12631,12632],{},"Use a stock photo plus Pillow"," when you need a specific real product or person that DALL-E 3 cannot or should not invent. You skip the generation step and run only the cropping and text code from Step 2.",[14,12635,12636],{},"For the design itself, A\u002FB test your prompts against real performance: change one variable at a time, publish, and watch the click-through rate in YouTube Studio.",[14,12638,2375,12639,1363],{},[51,12640,9410],{"href":9409},[57,12642,2381],{"id":2380},[2322,12644,12645,12650,12655,12660],{},[1450,12646,12647,12649],{},[51,12648,9410],{"href":9409}," — the section overview for generating visuals with Python and AI.",[1450,12651,12652,12654],{},[51,12653,9395],{"href":12621}," — apply the same batch pattern to product shots.",[1450,12656,12657,12659],{},[51,12658,5413],{"href":5412}," — the main guide tying visuals into your wider content pipeline.",[1450,12661,12662,12664],{},[51,12663,3379],{"href":3378}," — handle rate limits during large generation runs.",[2401,12666,12667],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":258,"searchDepth":282,"depth":282,"links":12669},[12670,12671,12672,12673,12674,12675,12676,12677,12678],{"id":237,"depth":282,"text":238},{"id":11220,"depth":282,"text":11221},{"id":11593,"depth":282,"text":11594},{"id":11925,"depth":282,"text":11926},{"id":12034,"depth":282,"text":12035},{"id":5154,"depth":282,"text":5155},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Generate branded 1280x720 YouTube thumbnails with DALL-E 3 and Python. Step-by-step: generate the art, add text with Pillow, and export at scale.",[12681,12684,12687,12690,12693],{"q":12682,"a":12683},"What size does a YouTube thumbnail need to be?","YouTube recommends 1280x720 pixels with a 16:9 aspect ratio and a file under 2 MB in JPG, PNG, or GIF format. DALL-E 3 outputs square images, so you crop and resize to 1280x720 before uploading.",{"q":12685,"a":12686},"Can DALL-E 3 put readable text on a thumbnail?","Not reliably. DALL-E 3 often garbles words and letters, so the safe approach is to generate the background art with the API and then add clean, branded text yourself using the Pillow library in Python.",{"q":12688,"a":12689},"How much does it cost to generate a thumbnail with DALL-E 3?","As of 2026 a standard 1024x1024 image is about 0.04 US dollars and an HD image is about 0.08 US dollars. Generating one thumbnail per video is inexpensive, but always check current OpenAI pricing before large batches.",{"q":12691,"a":12692},"Why is my generated thumbnail blurry after resizing?","Blurriness usually comes from upscaling a small image or using a low-quality resampling filter. Generate at 1024x1024 or larger, then resize down to 1280x720 with the LANCZOS filter to keep edges crisp.",{"q":12694,"a":12695},"Do I need a paid OpenAI account to use DALL-E 3?","Yes. DALL-E 3 image generation requires an OpenAI account with billing enabled and a valid API key. There is no permanently free tier for image generation.",{"name":12697,"steps":12698},"How to create YouTube thumbnails with DALL-E 3 and Python",[12699,12702,12705,12708,12711],{"name":12700,"text":12701},"Install dependencies and store your API key","Create a virtual environment, install the openai and Pillow libraries, and save your OpenAI key in a .env file.",{"name":12703,"text":12704},"Generate the background art with DALL-E 3","Call the images API to produce a 1024x1024 image and download the raw bytes.",{"name":12706,"text":12707},"Crop, resize, and add branded text with Pillow","Center-crop the square image to 1280x720 and draw a high-contrast title with a drop shadow.",{"name":12709,"text":12710},"Export a print-ready 1280x720 PNG","Save the finished thumbnail as an optimized PNG that meets YouTube's upload requirements.",{"name":12712,"text":12713},"Batch many thumbnails from a CSV","Loop over a CSV of titles and prompts to generate a full channel's thumbnails in one run.",{},"\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fcreate-youtube-thumbnails-with-dall-e-3-and-python","2026-05-13",{"title":11063,"description":12679},"YouTube Thumbnails with DALL-E 3 and Python","ai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Fcreate-youtube-thumbnails-with-dall-e-3-and-python\u002Findex","bqoJz5qgKAk817c9Y6ujWz3FQNxbsjnvYwflWR9E2mk",{"id":12722,"title":12723,"body":12724,"description":15677,"extension":2419,"faq":15678,"howto":15694,"meta":15709,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":15710,"published":15711,"seo":15712,"seoTitle":12723,"stem":15713,"__hash__":15714},"content\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Findex.md","AI Image Generation with Python and DALL·E",{"type":7,"value":12725,"toc":15659},[12726,12729,12732,12744,12748,12751,12765,12781,12786,12789,12792,12895,12897,12903,12906,12943,12959,12964,12972,12982,12993,12996,13053,13069,13073,13076,13308,13319,13348,13352,13369,13375,13379,13393,13570,13573,13624,13628,13631,13895,13902,14079,14085,14100,14104,14113,14389,14409,14425,14435,14577,14587,14591,14597,14615,14637,14639,14648,14807,14809,14812,14889,14893,14896,15593,15604,15606,15609,15629,15633,15635,15657],[10,12727,12723],{"id":12728},"ai-image-generation-with-python-and-dalle",[14,12730,12731],{},"You need a dozen on-brand images for a campaign — a hero banner, a few social squares, a blog header — and you do not have a designer on call. Stock photos look generic, and a design tool eats an afternoon. With about forty lines of Python and the OpenAI images API, you can describe each image in plain English, generate it, resize it for every platform, and repeat the whole run tomorrow with new prompts. This guide shows you exactly how, even if you have never written an image-generation script before.",[14,12733,12734,12735,12737,12738,12740,12741,12743],{},"This is the visual half of ",[51,12736,5413],{"href":5412},". The text half — headlines, captions, and body copy — lives in ",[51,12739,3991],{"href":3990},", and once your images exist you can hand them off to ",[51,12742,9309],{"href":9308}," to publish them on a schedule.",[57,12745,12747],{"id":12746},"who-this-is-for-and-what-you-will-build","Who this is for and what you will build",[14,12749,12750],{},"This guide is for creators, marketers, and founders who can run a Python script but are not professional developers. By the end you will have a small, reusable image generator that:",[2322,12752,12753,12756,12759,12762],{},[1450,12754,12755],{},"Turns a text prompt into a saved PNG file.",[1450,12757,12758],{},"Lets you control the image size, quality, and background.",[1450,12760,12761],{},"Cleans up the output with Pillow — crop, resize, convert, watermark.",[1450,12763,12764],{},"Runs over a list of prompts to produce a full set of assets in one pass.",[14,12766,12767,12768,12770,12771,12774,12775,12777,12778,12780],{},"We use the ",[18,12769,20],{}," SDK throughout, because the same models that power DALL·E are reachable through one consistent Python interface. The newest model is ",[18,12772,12773],{},"gpt-image-1","; the older ",[18,12776,10840],{}," model uses the same ",[18,12779,12461],{}," call, so you can switch between them by changing one string.",[12782,12783,12785],"h3",{"id":12784},"how-ai-image-generation-actually-works","How AI image generation actually works",[14,12787,12788],{},"You do not need the math, but a one-paragraph mental model saves a lot of confused debugging. A text-to-image model has been trained on millions of image-and-caption pairs, so it has learned the statistical link between words and visual patterns. When you send a prompt, the model starts from random noise and gradually refines it into a picture that matches the words you gave it — a process called diffusion. Two practical consequences fall out of this. First, the same prompt can produce slightly different images each run, because the starting noise is random; that is a feature, not a bug, and it is how you get variations. Second, the model only knows what your words describe, so the quality of your output is mostly the quality of your prompt. The rest of this guide leans on that: most of your iteration time goes into wording, and Python handles the repetitive mechanics of calling, saving, and resizing.",[14,12790,12791],{},"Because the heavy computation happens on OpenAI's servers, your script does only three small jobs every time: send the prompt, wait for the response, and write the returned bytes to a file. That is why none of the code below needs a GPU or any special hardware — your laptop is just a thin client making an API call.",[76,12793,12795,12892],{"className":12794},[79],[81,12796,90,12799,90,12802,90,12805,90,12807,90,12811,90,12814,90,12817,90,12821,90,12823,90,12827,90,12832,90,12835,90,12839,90,12841,90,12845,90,12848,90,12852,90,12854,90,12857,90,12860,90,12865,90,12868,90,12870,90,12873,90,12876,90,12879,90,12884,90,12886,90,12889],{"viewBox":12797,"role":84,"ariaLabelledBy":12798,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1120 400",[7091,7092],[92,12800,12801],{"id":7091},"Prompt to assets image pipeline",[96,12803,12804],{"id":7092},"A text prompt flows into the OpenAI image model, which returns variants that Pillow post-processes into platform-ready assets.",[100,12806],{"x":102,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,12808,12810],{"x":113,"y":12809,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"150","Text prompt",[111,12812,12813],{"x":113,"y":103,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"plain English",[100,12815],{"x":12816,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"280",[111,12818,12820],{"x":12819,"y":12809,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"380","OpenAI image",[111,12822,12773],{"x":12819,"y":103,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,12824],{"x":12825,"y":140,"width":104,"height":12826,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},"560","60",[111,12828,12831],{"x":12829,"y":12830,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"660","55","Variant 1",[100,12833],{"x":12825,"y":12834,"width":104,"height":12826,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},"126",[111,12836,12838],{"x":12829,"y":12837,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"161","Variant 2",[100,12840],{"x":12825,"y":7146,"width":104,"height":12826,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,12842,12844],{"x":12829,"y":12843,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"267","Variant n",[100,12846],{"x":12847,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},"840",[111,12849,12851],{"x":12850,"y":12809,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"940","Assets",[111,12853,11143],{"x":12850,"y":103,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[181,12855],{"x1":104,"y1":7135,"x2":12856,"y2":7135,"stroke":125,"strokeWidth":109},"272",[186,12858],{"points":12859,"fill":125},"280,156 272,152 272,160",[181,12861],{"x1":158,"y1":12862,"x2":12863,"y2":12864,"stroke":125,"strokeWidth":109},"146","552","58",[186,12866],{"points":12867,"fill":125},"560,50 551,52 556,59",[181,12869],{"x1":158,"y1":7135,"x2":12863,"y2":7135,"stroke":125,"strokeWidth":109},[186,12871],{"points":12872,"fill":125},"560,156 552,152 552,160",[181,12874],{"x1":158,"y1":213,"x2":12863,"y2":12875,"stroke":125,"strokeWidth":109},"254",[186,12877],{"points":12878,"fill":125},"560,262 551,260 556,253",[181,12880],{"x1":12881,"y1":12882,"x2":12883,"y2":12862,"stroke":125,"strokeWidth":109},"760","50","832",[181,12885],{"x1":12881,"y1":7135,"x2":12883,"y2":7135,"stroke":125,"strokeWidth":109},[181,12887],{"x1":12881,"y1":12888,"x2":12883,"y2":213,"stroke":125,"strokeWidth":109},"262",[186,12890],{"points":12891,"fill":125},"840,156 832,152 832,160",[232,12893,12894],{},"One prompt becomes several variants, which Pillow trims and resizes into platform-ready assets.",[57,12896,238],{"id":237},[14,12898,12899,12900,12902],{},"You need Python 3.10 or newer and a paid OpenAI account with image access enabled (image models are not on the free tier). If Python is not set up yet, start with ",[51,12901,5423],{"href":5422},", then come back here.",[14,12904,12905],{},"Create a project folder and a virtual environment so these packages stay isolated from the rest of your system:",[253,12907,12909],{"className":255,"code":12908,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate    # on Windows: .venv\\Scripts\\activate\npip install openai pillow python-dotenv\n",[18,12910,12911,12921,12930],{"__ignoreMap":258},[262,12912,12913,12915,12917,12919],{"class":181,"line":264},[262,12914,416],{"class":267},[262,12916,272],{"class":271},[262,12918,276],{"class":275},[262,12920,279],{"class":275},[262,12922,12923,12925,12927],{"class":181,"line":282},[262,12924,285],{"class":271},[262,12926,288],{"class":275},[262,12928,12929],{"class":291},"    # on Windows: .venv\\Scripts\\activate\n",[262,12931,12932,12934,12936,12938,12941],{"class":181,"line":295},[262,12933,298],{"class":267},[262,12935,301],{"class":275},[262,12937,2519],{"class":275},[262,12939,12940],{"class":275}," pillow",[262,12942,2522],{"class":275},[14,12944,12945,12947,12948,12951,12952,12955,12956,12958],{},[18,12946,20],{}," is the official SDK that talks to the image model. ",[18,12949,12950],{},"pillow"," (imported as ",[18,12953,12954],{},"PIL",") is the standard Python library for opening, cropping, and saving images. ",[18,12957,2501],{}," loads your secret API key from a file so it never appears in your code.",[14,12960,2525,12961,12963],{},[18,12962,319],{}," next to your script and paste your key into it:",[253,12965,12966],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,12967,12968],{"__ignoreMap":258},[262,12969,12970],{"class":181,"line":264},[262,12971,337],{},[14,12973,7251,12974,356,12976,12978,12979,12981],{},[18,12975,319],{},[18,12977,359],{}," file so the key is never committed to version control or pushed to GitHub. A leaked key can run up real charges on your account. If you are new to API keys and authentication, ",[51,12980,2487],{"href":2486}," walks through the whole flow.",[14,12983,12984,12985,12988,12989,12992],{},"One more thing before you write code: image generation is billed per image, with the price rising as you increase size and quality. A single test image costs cents, but a careless loop over a thousand prompts at ",[18,12986,12987],{},"high"," quality adds up. Set a spending limit in your OpenAI account dashboard, and during development always test prompts at ",[18,12990,12991],{},"low"," quality and small batches. You only spend on the final, polished renders once you are confident the wording is right.",[14,12994,12995],{},"Load the key once at the top of every script:",[253,12997,12999],{"className":414,"code":12998,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n",[18,13000,13001,13007,13017,13027,13031,13035],{"__ignoreMap":258},[262,13002,13003,13005],{"class":181,"line":264},[262,13004,684],{"class":377},[262,13006,687],{"class":429},[262,13008,13009,13011,13013,13015],{"class":181,"line":282},[262,13010,705],{"class":377},[262,13012,708],{"class":429},[262,13014,684],{"class":377},[262,13016,713],{"class":429},[262,13018,13019,13021,13023,13025],{"class":181,"line":295},[262,13020,705],{"class":377},[262,13022,720],{"class":429},[262,13024,684],{"class":377},[262,13026,725],{"class":429},[262,13028,13029],{"class":181,"line":345},[262,13030,583],{"emptyLinePlaceholder":582},[262,13032,13033],{"class":181,"line":492},[262,13034,734],{"class":429},[262,13036,13037,13039,13041,13043,13045,13047,13049,13051],{"class":181,"line":503},[262,13038,739],{"class":429},[262,13040,476],{"class":377},[262,13042,1588],{"class":429},[262,13044,2674],{"class":611},[262,13046,476],{"class":377},[262,13048,1199],{"class":429},[262,13050,2681],{"class":275},[262,13052,2684],{"class":429},[14,13054,13055,13057,13058,13060,13061,13064,13065,13068],{},[18,13056,8439],{}," reads the ",[18,13059,319],{}," file into your environment, and ",[18,13062,13063],{},"OpenAI(...)"," opens an authenticated connection. You will reuse this ",[18,13066,13067],{},"client"," object in every step below.",[57,13070,13072],{"id":13071},"step-1-generate-your-first-image","Step 1: Generate your first image",[14,13074,13075],{},"The image model takes a text prompt and returns the picture as base64-encoded data — a long text string that represents the raw bytes of the file. You decode that string and write it to disk. Here is the smallest complete example:",[253,13077,13079],{"className":414,"code":13078,"language":416,"meta":258,"style":258},"import base64\nimport os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef generate_image(prompt: str, output_path: str) -> None:\n    \"\"\"Generate one image from a prompt and save it as a file.\"\"\"\n    result = client.images.generate(\n        model=\"gpt-image-1\",\n        prompt=prompt,\n        size=\"1024x1024\",\n    )\n    image_bytes = base64.b64decode(result.data[0].b64_json)\n    with open(output_path, \"wb\") as handle:\n        handle.write(image_bytes)\n    print(f\"Saved {output_path}\")\n\n\ngenerate_image(\n    \"A friendly robot watering a small plant on a sunny desk, flat illustration\",\n    \"robot_plant.png\",\n)\n",[18,13080,13081,13088,13094,13104,13114,13118,13122,13140,13144,13148,13168,13173,13182,13193,13202,13213,13217,13232,13251,13256,13277,13281,13285,13290,13297,13304],{"__ignoreMap":258},[262,13082,13083,13085],{"class":181,"line":264},[262,13084,684],{"class":377},[262,13086,13087],{"class":429}," base64\n",[262,13089,13090,13092],{"class":181,"line":282},[262,13091,684],{"class":377},[262,13093,687],{"class":429},[262,13095,13096,13098,13100,13102],{"class":181,"line":295},[262,13097,705],{"class":377},[262,13099,708],{"class":429},[262,13101,684],{"class":377},[262,13103,713],{"class":429},[262,13105,13106,13108,13110,13112],{"class":181,"line":345},[262,13107,705],{"class":377},[262,13109,720],{"class":429},[262,13111,684],{"class":377},[262,13113,725],{"class":429},[262,13115,13116],{"class":181,"line":492},[262,13117,583],{"emptyLinePlaceholder":582},[262,13119,13120],{"class":181,"line":503},[262,13121,734],{"class":429},[262,13123,13124,13126,13128,13130,13132,13134,13136,13138],{"class":181,"line":521},[262,13125,739],{"class":429},[262,13127,476],{"class":377},[262,13129,1588],{"class":429},[262,13131,2674],{"class":611},[262,13133,476],{"class":377},[262,13135,1199],{"class":429},[262,13137,2681],{"class":275},[262,13139,2684],{"class":429},[262,13141,13142],{"class":181,"line":537},[262,13143,583],{"emptyLinePlaceholder":582},[262,13145,13146],{"class":181,"line":549},[262,13147,583],{"emptyLinePlaceholder":582},[262,13149,13150,13152,13154,13156,13158,13160,13162,13164,13166],{"class":181,"line":570},[262,13151,423],{"class":377},[262,13153,9596],{"class":267},[262,13155,9599],{"class":429},[262,13157,433],{"class":271},[262,13159,11660],{"class":429},[262,13161,433],{"class":271},[262,13163,1939],{"class":429},[262,13165,8471],{"class":271},[262,13167,1160],{"class":429},[262,13169,13170],{"class":181,"line":579},[262,13171,13172],{"class":275},"    \"\"\"Generate one image from a prompt and save it as a file.\"\"\"\n",[262,13174,13175,13178,13180],{"class":181,"line":586},[262,13176,13177],{"class":429},"    result ",[262,13179,476],{"class":377},[262,13181,9677],{"class":429},[262,13183,13184,13186,13188,13191],{"class":181,"line":591},[262,13185,1194],{"class":611},[262,13187,476],{"class":377},[262,13189,13190],{"class":275},"\"gpt-image-1\"",[262,13192,1315],{"class":429},[262,13194,13195,13198,13200],{"class":181,"line":623},[262,13196,13197],{"class":611},"        prompt",[262,13199,476],{"class":377},[262,13201,9698],{"class":429},[262,13203,13204,13207,13209,13211],{"class":181,"line":634},[262,13205,13206],{"class":611},"        size",[262,13208,476],{"class":377},[262,13210,11363],{"class":275},[262,13212,1315],{"class":429},[262,13214,13215],{"class":181,"line":845},[262,13216,1011],{"class":429},[262,13218,13219,13222,13224,13227,13229],{"class":181,"line":850},[262,13220,13221],{"class":429},"    image_bytes ",[262,13223,476],{"class":377},[262,13225,13226],{"class":429}," base64.b64decode(result.data[",[262,13228,102],{"class":271},[262,13230,13231],{"class":429},"].b64_json)\n",[262,13233,13234,13236,13238,13241,13244,13246,13248],{"class":181,"line":864},[262,13235,10124],{"class":377},[262,13237,599],{"class":271},[262,13239,13240],{"class":429},"(output_path, ",[262,13242,13243],{"class":275},"\"wb\"",[262,13245,1000],{"class":429},[262,13247,697],{"class":377},[262,13249,13250],{"class":429}," handle:\n",[262,13252,13253],{"class":181,"line":1683},[262,13254,13255],{"class":429},"        handle.write(image_bytes)\n",[262,13257,13258,13260,13262,13264,13266,13268,13271,13273,13275],{"class":181,"line":1688},[262,13259,1089],{"class":271},[262,13261,602],{"class":429},[262,13263,642],{"class":377},[262,13265,3753],{"class":275},[262,13267,3039],{"class":271},[262,13269,13270],{"class":429},"output_path",[262,13272,654],{"class":271},[262,13274,1176],{"class":275},[262,13276,660],{"class":429},[262,13278,13279],{"class":181,"line":1693},[262,13280,583],{"emptyLinePlaceholder":582},[262,13282,13283],{"class":181,"line":1728},[262,13284,583],{"emptyLinePlaceholder":582},[262,13286,13287],{"class":181,"line":1737},[262,13288,13289],{"class":429},"generate_image(\n",[262,13291,13292,13295],{"class":181,"line":1751},[262,13293,13294],{"class":275},"    \"A friendly robot watering a small plant on a sunny desk, flat illustration\"",[262,13296,1315],{"class":429},[262,13298,13299,13302],{"class":181,"line":1764},[262,13300,13301],{"class":275},"    \"robot_plant.png\"",[262,13303,1315],{"class":429},[262,13305,13306],{"class":181,"line":1779},[262,13307,660],{"class":429},[14,13309,13310,13311,13314,13315,13318],{},"Run it with ",[18,13312,13313],{},"python your_script.py",". After a few seconds you will have ",[18,13316,13317],{},"robot_plant.png"," in your folder. A few details worth knowing:",[2322,13320,13321,13335,13345],{},[1450,13322,13323,13326,13327,13329,13330,13332,13333,1363],{},[18,13324,13325],{},"model=\"gpt-image-1\""," selects the current image model. Swap in ",[18,13328,9686],{}," to use the older one — the rest of the call is the same, though ",[18,13331,10840],{}," only allows ",[18,13334,11227],{},[1450,13336,13337,13340,13341,13344],{},[18,13338,13339],{},"result.data[0]"," is the first (and here, only) image returned. The ",[18,13342,13343],{},".b64_json"," field holds the encoded image text.",[1450,13346,13347],{},"Be specific in your prompt. \"Flat illustration\", \"studio lighting\", \"muted pastel palette\", and \"no text\" all steer the result. Vague prompts give vague images.",[12782,13349,13351],{"id":13350},"writing-prompts-that-produce-usable-assets","Writing prompts that produce usable assets",[14,13353,13354,13355,13357,13358,13360,13361,13364,13365,13368],{},"The single biggest lever on your results is the prompt. A useful prompt usually names four things in plain language: the ",[35,13356,6319],{}," (what is in the frame), the ",[35,13359,2401],{}," (illustration, photo, 3D render, watercolor), the ",[35,13362,13363],{},"composition"," (top-down, close-up, centered, lots of empty space), and the ",[35,13366,13367],{},"mood or palette"," (warm, muted, high-contrast, pastel). A prompt like \"A ceramic coffee mug on a wooden table, product photography, soft morning light, shallow depth of field, lots of negative space on the right\" gives the model far more to work with than \"a coffee mug\".",[14,13370,13371,13372,13374],{},"Two habits make a real difference for marketing work. Add \"no text\" or \"no lettering\" when you plan to overlay your own headline later, because models often render garbled fake text inside images. And keep a short list of style phrases that match your brand — your go-to palette, lighting, and finish — so every batch looks like it belongs to the same family. You can even have a language model draft and tighten these prompts for you; the techniques in ",[51,13373,3991],{"href":3990}," apply directly to writing image prompts, not just body copy.",[57,13376,13378],{"id":13377},"step-2-control-size-quality-and-style","Step 2: Control size, quality, and style",[14,13380,13381,13382,13385,13386,608,13388,13390,13391,1363],{},"The same ",[18,13383,13384],{},"generate"," call accepts parameters that change the shape, polish, and look of the output. The three you will reach for most are ",[18,13387,10260],{},[18,13389,10268],{},", and ",[18,13392,11083],{},[253,13394,13396],{"className":414,"code":13395,"language":416,"meta":258,"style":258},"def generate_styled(prompt: str, output_path: str) -> None:\n    result = client.images.generate(\n        model=\"gpt-image-1\",\n        prompt=prompt,\n        size=\"1536x1024\",       # wide landscape, good for blog headers\n        quality=\"high\",         # more detail, higher cost\n        background=\"transparent\",  # PNG with no backdrop, for logos and overlays\n        n=1,\n    )\n    image_bytes = base64.b64decode(result.data[0].b64_json)\n    with open(output_path, \"wb\") as handle:\n        handle.write(image_bytes)\n\n\ngenerate_styled(\n    \"A minimalist line-art coffee cup icon, single color, no background\",\n    \"coffee_icon.png\",\n)\n",[18,13397,13398,13419,13427,13437,13445,13460,13476,13492,13503,13507,13519,13535,13539,13543,13547,13552,13559,13566],{"__ignoreMap":258},[262,13399,13400,13402,13405,13407,13409,13411,13413,13415,13417],{"class":181,"line":264},[262,13401,423],{"class":377},[262,13403,13404],{"class":267}," generate_styled",[262,13406,9599],{"class":429},[262,13408,433],{"class":271},[262,13410,11660],{"class":429},[262,13412,433],{"class":271},[262,13414,1939],{"class":429},[262,13416,8471],{"class":271},[262,13418,1160],{"class":429},[262,13420,13421,13423,13425],{"class":181,"line":282},[262,13422,13177],{"class":429},[262,13424,476],{"class":377},[262,13426,9677],{"class":429},[262,13428,13429,13431,13433,13435],{"class":181,"line":295},[262,13430,1194],{"class":611},[262,13432,476],{"class":377},[262,13434,13190],{"class":275},[262,13436,1315],{"class":429},[262,13438,13439,13441,13443],{"class":181,"line":345},[262,13440,13197],{"class":611},[262,13442,476],{"class":377},[262,13444,9698],{"class":429},[262,13446,13447,13449,13451,13454,13457],{"class":181,"line":492},[262,13448,13206],{"class":611},[262,13450,476],{"class":377},[262,13452,13453],{"class":275},"\"1536x1024\"",[262,13455,13456],{"class":429},",       ",[262,13458,13459],{"class":291},"# wide landscape, good for blog headers\n",[262,13461,13462,13465,13467,13470,13473],{"class":181,"line":503},[262,13463,13464],{"class":611},"        quality",[262,13466,476],{"class":377},[262,13468,13469],{"class":275},"\"high\"",[262,13471,13472],{"class":429},",         ",[262,13474,13475],{"class":291},"# more detail, higher cost\n",[262,13477,13478,13481,13483,13486,13489],{"class":181,"line":521},[262,13479,13480],{"class":611},"        background",[262,13482,476],{"class":377},[262,13484,13485],{"class":275},"\"transparent\"",[262,13487,13488],{"class":429},",  ",[262,13490,13491],{"class":291},"# PNG with no backdrop, for logos and overlays\n",[262,13493,13494,13497,13499,13501],{"class":181,"line":537},[262,13495,13496],{"class":611},"        n",[262,13498,476],{"class":377},[262,13500,997],{"class":271},[262,13502,1315],{"class":429},[262,13504,13505],{"class":181,"line":549},[262,13506,1011],{"class":429},[262,13508,13509,13511,13513,13515,13517],{"class":181,"line":570},[262,13510,13221],{"class":429},[262,13512,476],{"class":377},[262,13514,13226],{"class":429},[262,13516,102],{"class":271},[262,13518,13231],{"class":429},[262,13520,13521,13523,13525,13527,13529,13531,13533],{"class":181,"line":579},[262,13522,10124],{"class":377},[262,13524,599],{"class":271},[262,13526,13240],{"class":429},[262,13528,13243],{"class":275},[262,13530,1000],{"class":429},[262,13532,697],{"class":377},[262,13534,13250],{"class":429},[262,13536,13537],{"class":181,"line":586},[262,13538,13255],{"class":429},[262,13540,13541],{"class":181,"line":591},[262,13542,583],{"emptyLinePlaceholder":582},[262,13544,13545],{"class":181,"line":623},[262,13546,583],{"emptyLinePlaceholder":582},[262,13548,13549],{"class":181,"line":634},[262,13550,13551],{"class":429},"generate_styled(\n",[262,13553,13554,13557],{"class":181,"line":845},[262,13555,13556],{"class":275},"    \"A minimalist line-art coffee cup icon, single color, no background\"",[262,13558,1315],{"class":429},[262,13560,13561,13564],{"class":181,"line":850},[262,13562,13563],{"class":275},"    \"coffee_icon.png\"",[262,13565,1315],{"class":429},[262,13567,13568],{"class":181,"line":864},[262,13569,660],{"class":429},[14,13571,13572],{},"How to think about each one:",[2322,13574,13575,13592,13604,13618],{},[1450,13576,13577,13580,13581,13583,13584,13587,13588,13591],{},[35,13578,13579],{},"Size"," sets the aspect ratio. Use ",[18,13582,10860],{}," for social squares, ",[18,13585,13586],{},"1536x1024"," for wide banners, and ",[18,13589,13590],{},"1024x1536"," for portrait stories. Pick the closest ratio to your target, then crop exactly in Step 3.",[1450,13593,13594,13597,13598,13600,13601,13603],{},[35,13595,13596],{},"Quality"," trades cost for detail. Start at ",[18,13599,12991],{}," while you iterate on prompts, then switch to ",[18,13602,12987],{}," for the final render so you are not paying premium rates for throwaway tests.",[1450,13605,13606,13609,13610,13613,13614,13617],{},[35,13607,13608],{},"Background"," can be ",[18,13611,13612],{},"transparent"," for icons and logos you want to layer over other designs, or ",[18,13615,13616],{},"opaque"," for a normal filled image. Transparent backgrounds only work with PNG output.",[1450,13619,13620,13623],{},[35,13621,13622],{},"Style"," is expressed in the prompt itself, not a separate parameter — words like \"photorealistic\", \"watercolor\", or \"3D render\" do the work.",[57,13625,13627],{"id":13626},"step-3-post-process-with-pillow","Step 3: Post-process with Pillow",[14,13629,13630],{},"The model gives you a clean image, but real assets need exact dimensions, a specific format, or a watermark. Pillow handles all of that. The most common job is cropping a square down to a precise platform size:",[253,13632,13634],{"className":414,"code":13633,"language":416,"meta":258,"style":258},"from PIL import Image\n\n\ndef crop_to_size(input_path: str, output_path: str, size: tuple[int, int]) -> None:\n    \"\"\"Resize and center-crop an image to an exact width and height.\"\"\"\n    img = Image.open(input_path).convert(\"RGB\")\n    target_w, target_h = size\n    # Scale so the image fully covers the target box, then crop the overflow.\n    scale = max(target_w \u002F img.width, target_h \u002F img.height)\n    new_size = (round(img.width * scale), round(img.height * scale))\n    img = img.resize(new_size, Image.Resampling.LANCZOS)\n    left = (img.width - target_w) \u002F\u002F 2\n    top = (img.height - target_h) \u002F\u002F 2\n    img = img.crop((left, top, left + target_w, top + target_h))\n    img.save(output_path, \"JPEG\", quality=90)\n\n\ncrop_to_size(\"robot_plant.png\", \"robot_instagram.jpg\", (1080, 1080))\n",[18,13635,13636,13646,13650,13654,13686,13691,13704,13714,13719,13742,13773,13786,13807,13826,13845,13862,13866,13870],{"__ignoreMap":258},[262,13637,13638,13640,13642,13644],{"class":181,"line":264},[262,13639,705],{"class":377},[262,13641,9986],{"class":271},[262,13643,9989],{"class":377},[262,13645,9992],{"class":429},[262,13647,13648],{"class":181,"line":282},[262,13649,583],{"emptyLinePlaceholder":582},[262,13651,13652],{"class":181,"line":295},[262,13653,583],{"emptyLinePlaceholder":582},[262,13655,13656,13658,13661,13664,13666,13668,13670,13673,13675,13677,13679,13682,13684],{"class":181,"line":345},[262,13657,423],{"class":377},[262,13659,13660],{"class":267}," crop_to_size",[262,13662,13663],{"class":429},"(input_path: ",[262,13665,433],{"class":271},[262,13667,11660],{"class":429},[262,13669,433],{"class":271},[262,13671,13672],{"class":429},", size: tuple[",[262,13674,439],{"class":271},[262,13676,608],{"class":429},[262,13678,439],{"class":271},[262,13680,13681],{"class":429},"]) -> ",[262,13683,8471],{"class":271},[262,13685,1160],{"class":429},[262,13687,13688],{"class":181,"line":492},[262,13689,13690],{"class":275},"    \"\"\"Resize and center-crop an image to an exact width and height.\"\"\"\n",[262,13692,13693,13695,13697,13700,13702],{"class":181,"line":503},[262,13694,11680],{"class":429},[262,13696,476],{"class":377},[262,13698,13699],{"class":429}," Image.open(input_path).convert(",[262,13701,11688],{"class":275},[262,13703,660],{"class":429},[262,13705,13706,13709,13711],{"class":181,"line":521},[262,13707,13708],{"class":429},"    target_w, target_h ",[262,13710,476],{"class":377},[262,13712,13713],{"class":429}," size\n",[262,13715,13716],{"class":181,"line":537},[262,13717,13718],{"class":291},"    # Scale so the image fully covers the target box, then crop the overflow.\n",[262,13720,13721,13724,13726,13729,13732,13734,13737,13739],{"class":181,"line":549},[262,13722,13723],{"class":429},"    scale ",[262,13725,476],{"class":377},[262,13727,13728],{"class":271}," max",[262,13730,13731],{"class":429},"(target_w ",[262,13733,981],{"class":377},[262,13735,13736],{"class":429}," img.width, target_h ",[262,13738,981],{"class":377},[262,13740,13741],{"class":429}," img.height)\n",[262,13743,13744,13747,13749,13752,13755,13758,13760,13763,13765,13768,13770],{"class":181,"line":570},[262,13745,13746],{"class":429},"    new_size ",[262,13748,476],{"class":377},[262,13750,13751],{"class":429}," (",[262,13753,13754],{"class":271},"round",[262,13756,13757],{"class":429},"(img.width ",[262,13759,1003],{"class":377},[262,13761,13762],{"class":429}," scale), ",[262,13764,13754],{"class":271},[262,13766,13767],{"class":429},"(img.height ",[262,13769,1003],{"class":377},[262,13771,13772],{"class":429}," scale))\n",[262,13774,13775,13777,13779,13782,13784],{"class":181,"line":579},[262,13776,11680],{"class":429},[262,13778,476],{"class":377},[262,13780,13781],{"class":429}," img.resize(new_size, Image.Resampling.",[262,13783,11720],{"class":271},[262,13785,660],{"class":429},[262,13787,13788,13791,13793,13796,13798,13801,13804],{"class":181,"line":586},[262,13789,13790],{"class":429},"    left ",[262,13792,476],{"class":377},[262,13794,13795],{"class":429}," (img.width ",[262,13797,561],{"class":377},[262,13799,13800],{"class":429}," target_w) ",[262,13802,13803],{"class":377},"\u002F\u002F",[262,13805,13806],{"class":271}," 2\n",[262,13808,13809,13812,13814,13817,13819,13822,13824],{"class":181,"line":591},[262,13810,13811],{"class":429},"    top ",[262,13813,476],{"class":377},[262,13815,13816],{"class":429}," (img.height ",[262,13818,561],{"class":377},[262,13820,13821],{"class":429}," target_h) ",[262,13823,13803],{"class":377},[262,13825,13806],{"class":271},[262,13827,13828,13830,13832,13835,13837,13840,13842],{"class":181,"line":623},[262,13829,11680],{"class":429},[262,13831,476],{"class":377},[262,13833,13834],{"class":429}," img.crop((left, top, left ",[262,13836,531],{"class":377},[262,13838,13839],{"class":429}," target_w, top ",[262,13841,531],{"class":377},[262,13843,13844],{"class":429}," target_h))\n",[262,13846,13847,13849,13852,13854,13856,13858,13860],{"class":181,"line":634},[262,13848,11864],{"class":429},[262,13850,13851],{"class":275},"\"JPEG\"",[262,13853,608],{"class":429},[262,13855,10268],{"class":611},[262,13857,476],{"class":377},[262,13859,8411],{"class":271},[262,13861,660],{"class":429},[262,13863,13864],{"class":181,"line":845},[262,13865,583],{"emptyLinePlaceholder":582},[262,13867,13868],{"class":181,"line":850},[262,13869,583],{"emptyLinePlaceholder":582},[262,13871,13872,13875,13878,13880,13883,13886,13889,13891,13893],{"class":181,"line":864},[262,13873,13874],{"class":429},"crop_to_size(",[262,13876,13877],{"class":275},"\"robot_plant.png\"",[262,13879,608],{"class":429},[262,13881,13882],{"class":275},"\"robot_instagram.jpg\"",[262,13884,13885],{"class":429},", (",[262,13887,13888],{"class":271},"1080",[262,13890,608],{"class":429},[262,13892,13888],{"class":271},[262,13894,2684],{"class":429},[14,13896,13897,13898,13901],{},"This scales the image up just enough to cover the target box, then trims the edges so you get an exact ",[18,13899,13900],{},"1080x1080"," with no stretching. To add a simple text watermark, draw onto the image before saving:",[253,13903,13905],{"className":414,"code":13904,"language":416,"meta":258,"style":258},"from PIL import Image, ImageDraw, ImageFont\n\n\ndef add_watermark(input_path: str, output_path: str, text: str) -> None:\n    img = Image.open(input_path).convert(\"RGBA\")\n    draw = ImageDraw.Draw(img)\n    font = ImageFont.load_default(size=36)\n    draw.text((20, img.height - 60), text, fill=(255, 255, 255, 200), font=font)\n    img.convert(\"RGB\").save(output_path, \"PNG\")\n\n\nadd_watermark(\"robot_instagram.jpg\", \"robot_branded.png\", \"@yourbrand\")\n",[18,13906,13907,13918,13922,13926,13952,13965,13973,13991,14038,14052,14056,14060],{"__ignoreMap":258},[262,13908,13909,13911,13913,13915],{"class":181,"line":264},[262,13910,705],{"class":377},[262,13912,9986],{"class":271},[262,13914,9989],{"class":377},[262,13916,13917],{"class":429}," Image, ImageDraw, ImageFont\n",[262,13919,13920],{"class":181,"line":282},[262,13921,583],{"emptyLinePlaceholder":582},[262,13923,13924],{"class":181,"line":295},[262,13925,583],{"emptyLinePlaceholder":582},[262,13927,13928,13930,13933,13935,13937,13939,13941,13944,13946,13948,13950],{"class":181,"line":345},[262,13929,423],{"class":377},[262,13931,13932],{"class":267}," add_watermark",[262,13934,13663],{"class":429},[262,13936,433],{"class":271},[262,13938,11660],{"class":429},[262,13940,433],{"class":271},[262,13942,13943],{"class":429},", text: ",[262,13945,433],{"class":271},[262,13947,1939],{"class":429},[262,13949,8471],{"class":271},[262,13951,1160],{"class":429},[262,13953,13954,13956,13958,13960,13963],{"class":181,"line":492},[262,13955,11680],{"class":429},[262,13957,476],{"class":377},[262,13959,13699],{"class":429},[262,13961,13962],{"class":275},"\"RGBA\"",[262,13964,660],{"class":429},[262,13966,13967,13969,13971],{"class":181,"line":503},[262,13968,11731],{"class":429},[262,13970,476],{"class":377},[262,13972,11736],{"class":429},[262,13974,13975,13977,13979,13982,13984,13986,13989],{"class":181,"line":521},[262,13976,11741],{"class":429},[262,13978,476],{"class":377},[262,13980,13981],{"class":429}," ImageFont.load_default(",[262,13983,10260],{"class":611},[262,13985,476],{"class":377},[262,13987,13988],{"class":271},"36",[262,13990,660],{"class":429},[262,13992,13993,13996,13998,14001,14003,14005,14008,14010,14012,14014,14017,14019,14021,14023,14025,14027,14029,14031,14033,14035],{"class":181,"line":537},[262,13994,13995],{"class":429},"    draw.text((",[262,13997,140],{"class":271},[262,13999,14000],{"class":429},", img.height ",[262,14002,561],{"class":377},[262,14004,1710],{"class":271},[262,14006,14007],{"class":429},"), text, ",[262,14009,11797],{"class":611},[262,14011,476],{"class":377},[262,14013,602],{"class":429},[262,14015,14016],{"class":271},"255",[262,14018,608],{"class":429},[262,14020,14016],{"class":271},[262,14022,608],{"class":429},[262,14024,14016],{"class":271},[262,14026,608],{"class":429},[262,14028,104],{"class":271},[262,14030,11709],{"class":429},[262,14032,11807],{"class":611},[262,14034,476],{"class":377},[262,14036,14037],{"class":429},"font)\n",[262,14039,14040,14043,14045,14048,14050],{"class":181,"line":549},[262,14041,14042],{"class":429},"    img.convert(",[262,14044,11688],{"class":275},[262,14046,14047],{"class":429},").save(output_path, ",[262,14049,11872],{"class":275},[262,14051,660],{"class":429},[262,14053,14054],{"class":181,"line":570},[262,14055,583],{"emptyLinePlaceholder":582},[262,14057,14058],{"class":181,"line":579},[262,14059,583],{"emptyLinePlaceholder":582},[262,14061,14062,14065,14067,14069,14072,14074,14077],{"class":181,"line":586},[262,14063,14064],{"class":429},"add_watermark(",[262,14066,13882],{"class":275},[262,14068,608],{"class":429},[262,14070,14071],{"class":275},"\"robot_branded.png\"",[262,14073,608],{"class":429},[262,14075,14076],{"class":275},"\"@yourbrand\"",[262,14078,660],{"class":429},[14,14080,14081,14082,14084],{},"For platform-specific sizing — exact YouTube dimensions, safe zones, and overlay text — the ",[51,14083,9415],{"href":9414}," guide builds on this same Pillow pattern.",[14,14086,14087,14088,14091,14092,14095,14096,14099],{},"A few Pillow habits keep your assets clean. Always ",[18,14089,14090],{},"convert(\"RGB\")"," before saving a JPEG, because the model can return an image with an alpha (transparency) channel that JPEG cannot store, and the save will fail otherwise. Save your master copies as PNG to avoid the slight quality loss that JPEG compression introduces, then export JPEGs only for the final platform versions where file size matters. And when you crop, scale the image to ",[27,14093,14094],{},"cover"," the target box rather than ",[27,14097,14098],{},"fit"," inside it — covering and trimming the overflow, as the code above does, fills the whole frame without leaving empty bars or distorting the picture. These small rules are the difference between assets that look hand-finished and ones that look mechanically resized.",[57,14101,14103],{"id":14102},"step-4-batch-generate-a-set-of-images","Step 4: Batch-generate a set of images",[14,14105,14106,14107,14109,14110,14112],{},"The real time saving comes from generating many images in one run. With ",[18,14108,12773],{}," you can ask for several variations of a single prompt by setting ",[18,14111,10895],{},", or loop over a list of different prompts to build a whole campaign.",[253,14114,14116],{"className":414,"code":14115,"language":416,"meta":258,"style":258},"import base64\n\n\ndef generate_batch(prompts: list[str], folder: str = \"output\") -> None:\n    \"\"\"Generate one image per prompt and save them with tidy filenames.\"\"\"\n    os.makedirs(folder, exist_ok=True)\n    for index, prompt in enumerate(prompts, start=1):\n        result = client.images.generate(\n            model=\"gpt-image-1\",\n            prompt=prompt,\n            size=\"1024x1024\",\n            quality=\"medium\",\n        )\n        image_bytes = base64.b64decode(result.data[0].b64_json)\n        path = os.path.join(folder, f\"asset_{index:02d}.png\")\n        with open(path, \"wb\") as handle:\n            handle.write(image_bytes)\n        print(f\"Saved {path}\")\n\n\nprompts = [\n    \"A cozy reading nook with autumn light, warm illustration\",\n    \"A sleek laptop on a marble desk, product photography\",\n    \"An abstract gradient background in teal and coral, no text\",\n]\ngenerate_batch(prompts)\n",[18,14117,14118,14124,14128,14132,14160,14165,14178,14202,14210,14221,14230,14241,14253,14257,14270,14299,14317,14322,14342,14346,14350,14359,14366,14373,14380,14384],{"__ignoreMap":258},[262,14119,14120,14122],{"class":181,"line":264},[262,14121,684],{"class":377},[262,14123,13087],{"class":429},[262,14125,14126],{"class":181,"line":282},[262,14127,583],{"emptyLinePlaceholder":582},[262,14129,14130],{"class":181,"line":295},[262,14131,583],{"emptyLinePlaceholder":582},[262,14133,14134,14136,14139,14142,14144,14147,14149,14151,14154,14156,14158],{"class":181,"line":345},[262,14135,423],{"class":377},[262,14137,14138],{"class":267}," generate_batch",[262,14140,14141],{"class":429},"(prompts: list[",[262,14143,433],{"class":271},[262,14145,14146],{"class":429},"], folder: ",[262,14148,433],{"class":271},[262,14150,442],{"class":377},[262,14152,14153],{"class":275}," \"output\"",[262,14155,1939],{"class":429},[262,14157,8471],{"class":271},[262,14159,1160],{"class":429},[262,14161,14162],{"class":181,"line":492},[262,14163,14164],{"class":275},"    \"\"\"Generate one image per prompt and save them with tidy filenames.\"\"\"\n",[262,14166,14167,14170,14172,14174,14176],{"class":181,"line":503},[262,14168,14169],{"class":429},"    os.makedirs(folder, ",[262,14171,4969],{"class":611},[262,14173,476],{"class":377},[262,14175,4974],{"class":271},[262,14177,660],{"class":429},[262,14179,14180,14182,14185,14187,14190,14193,14196,14198,14200],{"class":181,"line":521},[262,14181,3074],{"class":377},[262,14183,14184],{"class":429}," index, prompt ",[262,14186,835],{"class":377},[262,14188,14189],{"class":271}," enumerate",[262,14191,14192],{"class":429},"(prompts, ",[262,14194,14195],{"class":611},"start",[262,14197,476],{"class":377},[262,14199,997],{"class":271},[262,14201,8192],{"class":429},[262,14203,14204,14206,14208],{"class":181,"line":537},[262,14205,9233],{"class":429},[262,14207,476],{"class":377},[262,14209,9677],{"class":429},[262,14211,14212,14215,14217,14219],{"class":181,"line":549},[262,14213,14214],{"class":611},"            model",[262,14216,476],{"class":377},[262,14218,13190],{"class":275},[262,14220,1315],{"class":429},[262,14222,14223,14226,14228],{"class":181,"line":570},[262,14224,14225],{"class":611},"            prompt",[262,14227,476],{"class":377},[262,14229,9698],{"class":429},[262,14231,14232,14235,14237,14239],{"class":181,"line":579},[262,14233,14234],{"class":611},"            size",[262,14236,476],{"class":377},[262,14238,11363],{"class":275},[262,14240,1315],{"class":429},[262,14242,14243,14246,14248,14251],{"class":181,"line":586},[262,14244,14245],{"class":611},"            quality",[262,14247,476],{"class":377},[262,14249,14250],{"class":275},"\"medium\"",[262,14252,1315],{"class":429},[262,14254,14255],{"class":181,"line":591},[262,14256,6288],{"class":429},[262,14258,14259,14262,14264,14266,14268],{"class":181,"line":623},[262,14260,14261],{"class":429},"        image_bytes ",[262,14263,476],{"class":377},[262,14265,13226],{"class":429},[262,14267,102],{"class":271},[262,14269,13231],{"class":429},[262,14271,14272,14275,14277,14280,14282,14285,14287,14289,14292,14294,14297],{"class":181,"line":634},[262,14273,14274],{"class":429},"        path ",[262,14276,476],{"class":377},[262,14278,14279],{"class":429}," os.path.join(folder, ",[262,14281,642],{"class":377},[262,14283,14284],{"class":275},"\"asset_",[262,14286,3039],{"class":271},[262,14288,3618],{"class":429},[262,14290,14291],{"class":377},":02d",[262,14293,654],{"class":271},[262,14295,14296],{"class":275},".png\"",[262,14298,660],{"class":429},[262,14300,14301,14304,14306,14309,14311,14313,14315],{"class":181,"line":845},[262,14302,14303],{"class":377},"        with",[262,14305,599],{"class":271},[262,14307,14308],{"class":429},"(path, ",[262,14310,13243],{"class":275},[262,14312,1000],{"class":429},[262,14314,697],{"class":377},[262,14316,13250],{"class":429},[262,14318,14319],{"class":181,"line":850},[262,14320,14321],{"class":429},"            handle.write(image_bytes)\n",[262,14323,14324,14326,14328,14330,14332,14334,14336,14338,14340],{"class":181,"line":864},[262,14325,2299],{"class":271},[262,14327,602],{"class":429},[262,14329,642],{"class":377},[262,14331,3753],{"class":275},[262,14333,3039],{"class":271},[262,14335,216],{"class":429},[262,14337,654],{"class":271},[262,14339,1176],{"class":275},[262,14341,660],{"class":429},[262,14343,14344],{"class":181,"line":1683},[262,14345,583],{"emptyLinePlaceholder":582},[262,14347,14348],{"class":181,"line":1688},[262,14349,583],{"emptyLinePlaceholder":582},[262,14351,14352,14355,14357],{"class":181,"line":1693},[262,14353,14354],{"class":429},"prompts ",[262,14356,476],{"class":377},[262,14358,5589],{"class":429},[262,14360,14361,14364],{"class":181,"line":1728},[262,14362,14363],{"class":275},"    \"A cozy reading nook with autumn light, warm illustration\"",[262,14365,1315],{"class":429},[262,14367,14368,14371],{"class":181,"line":1737},[262,14369,14370],{"class":275},"    \"A sleek laptop on a marble desk, product photography\"",[262,14372,1315],{"class":429},[262,14374,14375,14378],{"class":181,"line":1751},[262,14376,14377],{"class":275},"    \"An abstract gradient background in teal and coral, no text\"",[262,14379,1315],{"class":429},[262,14381,14382],{"class":181,"line":1764},[262,14383,957],{"class":429},[262,14385,14386],{"class":181,"line":1779},[262,14387,14388],{"class":429},"generate_batch(prompts)\n",[14,14390,14391,14392,14394,14395,14398,14399,608,14402,14405,14406,14408],{},"Each prompt becomes one numbered PNG inside an ",[18,14393,5150],{}," folder. The ",[18,14396,14397],{},"{index:02d}"," format pads numbers to two digits (",[18,14400,14401],{},"asset_01.png",[18,14403,14404],{},"asset_02.png",") so files sort correctly. If you need dozens of product shots from a spreadsheet rather than a hardcoded list, the ",[51,14407,9395],{"href":12621}," guide drives the same loop from a CSV file.",[14,14410,14411,14412,14415,14416,14418,14419,14422,14423,1363],{},"When you batch, generate at ",[18,14413,14414],{},"medium"," quality first to confirm the prompts look right, then re-run the winners at ",[18,14417,12987],{}," for the final assets. Generating many images quickly can trip rate limits — if that happens, add a short ",[18,14420,14421],{},"time.sleep()"," between calls, as covered in ",[51,14424,3379],{"href":3378},[14,14426,14427,14428,981,14431,14434],{},"Two more practices pay off as soon as your batches grow past a handful of images. First, wrap each generate call in a ",[18,14429,14430],{},"try",[18,14432,14433],{},"except"," block so a single failed prompt does not abort the whole run — log the failure, keep going, and re-run the misses afterward:",[253,14436,14438],{"className":414,"code":14437,"language":416,"meta":258,"style":258},"import time\n\nfor index, prompt in enumerate(prompts, start=1):\n    try:\n        result = client.images.generate(\n            model=\"gpt-image-1\", prompt=prompt, size=\"1024x1024\"\n        )\n        # ... decode and save as before ...\n    except Exception as error:\n        print(f\"Prompt {index} failed: {error}\")\n        time.sleep(2)   # brief pause before the next attempt\n        continue\n",[18,14439,14440,14446,14450,14470,14477,14485,14509,14513,14518,14530,14561,14573],{"__ignoreMap":258},[262,14441,14442,14444],{"class":181,"line":264},[262,14443,684],{"class":377},[262,14445,2612],{"class":429},[262,14447,14448],{"class":181,"line":282},[262,14449,583],{"emptyLinePlaceholder":582},[262,14451,14452,14454,14456,14458,14460,14462,14464,14466,14468],{"class":181,"line":295},[262,14453,829],{"class":377},[262,14455,14184],{"class":429},[262,14457,835],{"class":377},[262,14459,14189],{"class":271},[262,14461,14192],{"class":429},[262,14463,14195],{"class":611},[262,14465,476],{"class":377},[262,14467,997],{"class":271},[262,14469,8192],{"class":429},[262,14471,14472,14475],{"class":181,"line":345},[262,14473,14474],{"class":377},"    try",[262,14476,1160],{"class":429},[262,14478,14479,14481,14483],{"class":181,"line":492},[262,14480,9233],{"class":429},[262,14482,476],{"class":377},[262,14484,9677],{"class":429},[262,14486,14487,14489,14491,14493,14495,14497,14499,14502,14504,14506],{"class":181,"line":503},[262,14488,14214],{"class":611},[262,14490,476],{"class":377},[262,14492,13190],{"class":275},[262,14494,608],{"class":429},[262,14496,9496],{"class":611},[262,14498,476],{"class":377},[262,14500,14501],{"class":429},"prompt, ",[262,14503,10260],{"class":611},[262,14505,476],{"class":377},[262,14507,14508],{"class":275},"\"1024x1024\"\n",[262,14510,14511],{"class":181,"line":521},[262,14512,6288],{"class":429},[262,14514,14515],{"class":181,"line":537},[262,14516,14517],{"class":291},"        # ... decode and save as before ...\n",[262,14519,14520,14523,14525,14527],{"class":181,"line":549},[262,14521,14522],{"class":377},"    except",[262,14524,10361],{"class":271},[262,14526,10364],{"class":377},[262,14528,14529],{"class":429}," error:\n",[262,14531,14532,14534,14536,14538,14541,14543,14545,14547,14550,14552,14555,14557,14559],{"class":181,"line":570},[262,14533,2299],{"class":271},[262,14535,602],{"class":429},[262,14537,642],{"class":377},[262,14539,14540],{"class":275},"\"Prompt ",[262,14542,3039],{"class":271},[262,14544,3618],{"class":429},[262,14546,654],{"class":271},[262,14548,14549],{"class":275}," failed: ",[262,14551,3039],{"class":271},[262,14553,14554],{"class":429},"error",[262,14556,654],{"class":271},[262,14558,1176],{"class":275},[262,14560,660],{"class":429},[262,14562,14563,14565,14567,14570],{"class":181,"line":579},[262,14564,9055],{"class":429},[262,14566,109],{"class":271},[262,14568,14569],{"class":429},")   ",[262,14571,14572],{"class":291},"# brief pause before the next attempt\n",[262,14574,14575],{"class":181,"line":586},[262,14576,3470],{"class":377},[14,14578,14579,14580,14582,14583,14586],{},"Second, give your files meaningful names. ",[18,14581,14401],{}," works for a throwaway run, but for an ongoing library a name that encodes the campaign, the platform, and the date — ",[18,14584,14585],{},"summer-sale_instagram_2026-06-18.png"," — makes assets findable months later without opening every file. A tiny naming convention now saves real searching effort once you have generated hundreds of images.",[57,14588,14590],{"id":14589},"choosing-between-gpt-image-1-and-dall-e-3","Choosing between gpt-image-1 and dall-e-3",[14,14592,14593,14594,14596],{},"Both models run from the same ",[18,14595,12461],{}," call, so switching is a one-line change. The differences that matter in practice:",[2322,14598,14599,14607],{},[1450,14600,14601,14603,14604,14606],{},[35,14602,12773],{}," is the newer model. It follows complex prompts more faithfully, can render legible text when you actually want it, supports transparent backgrounds, and lets you request several images in one call with ",[18,14605,10895],{},". Use it as your default.",[1450,14608,14609,14611,14612,14614],{},[35,14610,10840],{}," is older and slightly cheaper for some sizes. It is capped at one image per call (",[18,14613,11227],{},"), so a batch means a loop of separate requests. Reach for it only if you have an existing script built around it or a specific cost reason.",[14,14616,14617,14618,14620,14621,14623,14624,14626,14627,1374,14629,14631,14632,14636],{},"A common pattern is to draft and preview at ",[18,14619,12991],{}," quality on ",[18,14622,12773],{},", lock the prompt, then render finals at ",[18,14625,12987],{},". Because both models share the same parameters, you never rewrite your pipeline — you only change the ",[18,14628,805],{},[18,14630,10268],{}," strings. If you are weighing OpenAI against other providers more broadly, ",[51,14633,14635],{"href":14634},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fopenai-vs-anthropic-api-for-beginners\u002F","OpenAI vs Anthropic API for Beginners"," covers how to think about that choice for whole projects.",[57,14638,8300],{"id":8299},[14,14640,14641,14642,14644,14645,14647],{},"These are the parameters you pass to ",[18,14643,12461],{},". Defaults reflect the ",[18,14646,12773],{}," model.",[1379,14649,14650,14662],{},[1382,14651,14652],{},[1385,14653,14654,14656,14658,14660],{},[1388,14655,1390],{},[1388,14657,3795],{},[1388,14659,3798],{},[1388,14661,1396],{},[1398,14663,14664,14684,14697,14719,14740,14762,14782],{},[1385,14665,14666,14670,14672,14675],{},[1403,14667,14668],{},[18,14669,805],{},[1403,14671,3811],{},[1403,14673,14674],{},"none (required)",[1403,14676,14677,14678,14680,14681,14683],{},"Which model to use: ",[18,14679,12773],{}," (newest) or ",[18,14682,10840],{}," (older).",[1385,14685,14686,14690,14692,14694],{},[1403,14687,14688],{},[18,14689,9496],{},[1403,14691,3811],{},[1403,14693,14674],{},[1403,14695,14696],{},"The plain-English description of the image to create.",[1385,14698,14699,14703,14705,14709],{},[1403,14700,14701],{},[18,14702,10260],{},[1403,14704,3811],{},[1403,14706,14707],{},[18,14708,10860],{},[1403,14710,14711,14712,608,14714,14716,14717,1363],{},"Output dimensions: ",[18,14713,10860],{},[18,14715,13590],{},", or ",[18,14718,13586],{},[1385,14720,14721,14725,14727,14731],{},[1403,14722,14723],{},[18,14724,10268],{},[1403,14726,3811],{},[1403,14728,14729],{},[18,14730,14414],{},[1403,14732,14733,14734,608,14736,14716,14738,1363],{},"Detail and cost level: ",[18,14735,12991],{},[18,14737,14414],{},[18,14739,12987],{},[1385,14741,14742,14746,14749,14753],{},[1403,14743,14744],{},[18,14745,10895],{},[1403,14747,14748],{},"integer",[1403,14750,14751],{},[18,14752,997],{},[1403,14754,14755,14756,14758,14759,14761],{},"Number of images to return per call (",[18,14757,12773],{}," only; ",[18,14760,10840],{}," is fixed at 1).",[1385,14763,14764,14768,14770,14774],{},[1403,14765,14766],{},[18,14767,11083],{},[1403,14769,3811],{},[1403,14771,14772],{},[18,14773,13616],{},[1403,14775,14776,14778,14779,14781],{},[18,14777,13612],{}," produces a PNG with no backdrop; ",[18,14780,13616],{}," fills it.",[1385,14783,14784,14789,14791,14796],{},[1403,14785,14786],{},[18,14787,14788],{},"output_format",[1403,14790,3811],{},[1403,14792,14793],{},[18,14794,14795],{},"png",[1403,14797,14798,14799,608,14801,14716,14804,1363],{},"File format of the returned bytes: ",[18,14800,14795],{},[18,14802,14803],{},"jpeg",[18,14805,14806],{},"webp",[57,14808,1445],{"id":1444},[14,14810,14811],{},"These are the errors you are most likely to hit, with the exact cause and a one-line fix.",[1447,14813,14814,14828,14836,14848,14869,14877],{},[1450,14815,14816,8429,14820,14822,14823,14825,14826,1363],{},[35,14817,14818],{},[18,14819,8428],{},[18,14821,319],{}," sits in the folder you run the script from and the line reads ",[18,14824,8435],{},". See ",[51,14827,388],{"href":387},[1450,14829,14830,14835],{},[35,14831,14832],{},[18,14833,14834],{},"BadRequestError: ... safety system"," — Your prompt was blocked for policy reasons (real people, violence, trademarks). Rewrite it to describe a generic subject and try again.",[1450,14837,14838,14843,14844,14847],{},[35,14839,14840],{},[18,14841,14842],{},"RateLimitError: ... requests per minute"," — You sent calls faster than your tier allows. Add ",[18,14845,14846],{},"import time; time.sleep(2)"," inside your batch loop, or request fewer images at once.",[1450,14849,14850,14859,14860,14862,14863,14865,14866,1363],{},[35,14851,14852,14855,14856],{},[18,14853,14854],{},"TypeError: 'NoneType' object is not subscriptable"," on ",[18,14857,14858],{},"b64_json"," — You requested a URL response instead of base64, so ",[18,14861,14858],{}," is empty. With ",[18,14864,12773],{}," the image is returned as base64 by default; make sure you are reading ",[18,14867,14868],{},"result.data[0].b64_json",[1450,14870,14871,14876],{},[35,14872,14873],{},[18,14874,14875],{},"PIL.UnidentifiedImageError"," — Pillow could not open the file because the write failed or the path is wrong. Confirm the generate step finished and the PNG actually exists before you post-process it.",[1450,14878,14879,14884,14885,14888],{},[35,14880,14881],{},[18,14882,14883],{},"BadRequestError: invalid size"," — You passed a size the model does not support, such as ",[18,14886,14887],{},"512x512",". Use one of the listed sizes, then resize down with Pillow in Step 3.",[57,14890,14892],{"id":14891},"worked-example-a-full-campaign-image-script","Worked example: a full campaign image script",[14,14894,14895],{},"This script ties everything together. It reads a list of prompts, generates each image, crops it to a square social size, adds a watermark, and saves the finished files into a dated folder — a complete run you can adapt for any campaign.",[253,14897,14899],{"className":414,"code":14898,"language":416,"meta":258,"style":258},"import base64\nimport os\nfrom datetime import date\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\nfrom PIL import Image, ImageDraw, ImageFont\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nCAMPAIGN_PROMPTS = [\n    \"A warm flat-lay of artisan coffee and pastries, top-down, no text\",\n    \"A bright minimal workspace with a notebook and plant, soft daylight\",\n    \"A bold abstract gradient in orange and purple, smooth, no text\",\n]\nWATERMARK = \"@yourbrand\"\nTARGET = (1080, 1080)\n\n\ndef make_asset(prompt: str, out_path: str) -> None:\n    \"\"\"Generate, crop, and watermark a single campaign image.\"\"\"\n    result = client.images.generate(\n        model=\"gpt-image-1\", prompt=prompt, size=\"1024x1024\", quality=\"high\"\n    )\n    raw = base64.b64decode(result.data[0].b64_json)\n    tmp = out_path + \".tmp.png\"\n    with open(tmp, \"wb\") as handle:        # save the raw model output first\n        handle.write(raw)\n\n    img = Image.open(tmp).convert(\"RGB\")   # crop to an exact square\n    scale = max(TARGET[0] \u002F img.width, TARGET[1] \u002F img.height)\n    img = img.resize((round(img.width * scale), round(img.height * scale)),\n                     Image.Resampling.LANCZOS)\n    left, top = (img.width - TARGET[0]) \u002F\u002F 2, (img.height - TARGET[1]) \u002F\u002F 2\n    img = img.crop((left, top, left + TARGET[0], top + TARGET[1]))\n\n    draw = ImageDraw.Draw(img)             # stamp the brand watermark\n    draw.text((24, TARGET[1] - 56), WATERMARK,\n              fill=(255, 255, 255), font=ImageFont.load_default(size=34))\n    img.save(out_path, \"JPEG\", quality=92)\n    os.remove(tmp)\n    print(f\"Done {out_path}\")\n\n\ndef run() -> None:\n    folder = f\"campaign_{date.today().isoformat()}\"\n    os.makedirs(folder, exist_ok=True)\n    for i, prompt in enumerate(CAMPAIGN_PROMPTS, start=1):\n        make_asset(prompt, os.path.join(folder, f\"post_{i:02d}.jpg\"))\n\n\nif __name__ == \"__main__\":\n    run()\n",[18,14900,14901,14907,14913,14924,14934,14944,14954,14958,14962,14980,14984,14993,15000,15007,15014,15018,15028,15045,15049,15053,15075,15080,15088,15119,15123,15136,15151,15172,15177,15181,15197,15232,15258,15267,15309,15339,15343,15355,15383,15420,15437,15442,15464,15468,15472,15486,15507,15519,15544,15568,15572,15576,15588],{"__ignoreMap":258},[262,14902,14903,14905],{"class":181,"line":264},[262,14904,684],{"class":377},[262,14906,13087],{"class":429},[262,14908,14909,14911],{"class":181,"line":282},[262,14910,684],{"class":377},[262,14912,687],{"class":429},[262,14914,14915,14917,14919,14921],{"class":181,"line":295},[262,14916,705],{"class":377},[262,14918,10502],{"class":429},[262,14920,684],{"class":377},[262,14922,14923],{"class":429}," date\n",[262,14925,14926,14928,14930,14932],{"class":181,"line":345},[262,14927,705],{"class":377},[262,14929,708],{"class":429},[262,14931,684],{"class":377},[262,14933,713],{"class":429},[262,14935,14936,14938,14940,14942],{"class":181,"line":492},[262,14937,705],{"class":377},[262,14939,720],{"class":429},[262,14941,684],{"class":377},[262,14943,725],{"class":429},[262,14945,14946,14948,14950,14952],{"class":181,"line":503},[262,14947,705],{"class":377},[262,14949,9986],{"class":271},[262,14951,9989],{"class":377},[262,14953,13917],{"class":429},[262,14955,14956],{"class":181,"line":521},[262,14957,583],{"emptyLinePlaceholder":582},[262,14959,14960],{"class":181,"line":537},[262,14961,734],{"class":429},[262,14963,14964,14966,14968,14970,14972,14974,14976,14978],{"class":181,"line":549},[262,14965,739],{"class":429},[262,14967,476],{"class":377},[262,14969,1588],{"class":429},[262,14971,2674],{"class":611},[262,14973,476],{"class":377},[262,14975,1199],{"class":429},[262,14977,2681],{"class":275},[262,14979,2684],{"class":429},[262,14981,14982],{"class":181,"line":570},[262,14983,583],{"emptyLinePlaceholder":582},[262,14985,14986,14989,14991],{"class":181,"line":579},[262,14987,14988],{"class":271},"CAMPAIGN_PROMPTS",[262,14990,442],{"class":377},[262,14992,5589],{"class":429},[262,14994,14995,14998],{"class":181,"line":586},[262,14996,14997],{"class":275},"    \"A warm flat-lay of artisan coffee and pastries, top-down, no text\"",[262,14999,1315],{"class":429},[262,15001,15002,15005],{"class":181,"line":591},[262,15003,15004],{"class":275},"    \"A bright minimal workspace with a notebook and plant, soft daylight\"",[262,15006,1315],{"class":429},[262,15008,15009,15012],{"class":181,"line":623},[262,15010,15011],{"class":275},"    \"A bold abstract gradient in orange and purple, smooth, no text\"",[262,15013,1315],{"class":429},[262,15015,15016],{"class":181,"line":634},[262,15017,957],{"class":429},[262,15019,15020,15023,15025],{"class":181,"line":845},[262,15021,15022],{"class":271},"WATERMARK",[262,15024,442],{"class":377},[262,15026,15027],{"class":275}," \"@yourbrand\"\n",[262,15029,15030,15033,15035,15037,15039,15041,15043],{"class":181,"line":850},[262,15031,15032],{"class":271},"TARGET",[262,15034,442],{"class":377},[262,15036,13751],{"class":429},[262,15038,13888],{"class":271},[262,15040,608],{"class":429},[262,15042,13888],{"class":271},[262,15044,660],{"class":429},[262,15046,15047],{"class":181,"line":864},[262,15048,583],{"emptyLinePlaceholder":582},[262,15050,15051],{"class":181,"line":1683},[262,15052,583],{"emptyLinePlaceholder":582},[262,15054,15055,15057,15060,15062,15064,15067,15069,15071,15073],{"class":181,"line":1688},[262,15056,423],{"class":377},[262,15058,15059],{"class":267}," make_asset",[262,15061,9599],{"class":429},[262,15063,433],{"class":271},[262,15065,15066],{"class":429},", out_path: ",[262,15068,433],{"class":271},[262,15070,1939],{"class":429},[262,15072,8471],{"class":271},[262,15074,1160],{"class":429},[262,15076,15077],{"class":181,"line":1693},[262,15078,15079],{"class":275},"    \"\"\"Generate, crop, and watermark a single campaign image.\"\"\"\n",[262,15081,15082,15084,15086],{"class":181,"line":1728},[262,15083,13177],{"class":429},[262,15085,476],{"class":377},[262,15087,9677],{"class":429},[262,15089,15090,15092,15094,15096,15098,15100,15102,15104,15106,15108,15110,15112,15114,15116],{"class":181,"line":1737},[262,15091,1194],{"class":611},[262,15093,476],{"class":377},[262,15095,13190],{"class":275},[262,15097,608],{"class":429},[262,15099,9496],{"class":611},[262,15101,476],{"class":377},[262,15103,14501],{"class":429},[262,15105,10260],{"class":611},[262,15107,476],{"class":377},[262,15109,11363],{"class":275},[262,15111,608],{"class":429},[262,15113,10268],{"class":611},[262,15115,476],{"class":377},[262,15117,15118],{"class":275},"\"high\"\n",[262,15120,15121],{"class":181,"line":1751},[262,15122,1011],{"class":429},[262,15124,15125,15128,15130,15132,15134],{"class":181,"line":1764},[262,15126,15127],{"class":429},"    raw ",[262,15129,476],{"class":377},[262,15131,13226],{"class":429},[262,15133,102],{"class":271},[262,15135,13231],{"class":429},[262,15137,15138,15141,15143,15146,15148],{"class":181,"line":1779},[262,15139,15140],{"class":429},"    tmp ",[262,15142,476],{"class":377},[262,15144,15145],{"class":429}," out_path ",[262,15147,531],{"class":377},[262,15149,15150],{"class":275}," \".tmp.png\"\n",[262,15152,15153,15155,15157,15160,15162,15164,15166,15169],{"class":181,"line":1793},[262,15154,10124],{"class":377},[262,15156,599],{"class":271},[262,15158,15159],{"class":429},"(tmp, ",[262,15161,13243],{"class":275},[262,15163,1000],{"class":429},[262,15165,697],{"class":377},[262,15167,15168],{"class":429}," handle:        ",[262,15170,15171],{"class":291},"# save the raw model output first\n",[262,15173,15174],{"class":181,"line":1800},[262,15175,15176],{"class":429},"        handle.write(raw)\n",[262,15178,15179],{"class":181,"line":1805},[262,15180,583],{"emptyLinePlaceholder":582},[262,15182,15183,15185,15187,15190,15192,15194],{"class":181,"line":1810},[262,15184,11680],{"class":429},[262,15186,476],{"class":377},[262,15188,15189],{"class":429}," Image.open(tmp).convert(",[262,15191,11688],{"class":275},[262,15193,14569],{"class":429},[262,15195,15196],{"class":291},"# crop to an exact square\n",[262,15198,15199,15201,15203,15205,15207,15209,15211,15213,15215,15217,15220,15222,15224,15226,15228,15230],{"class":181,"line":1823},[262,15200,13723],{"class":429},[262,15202,476],{"class":377},[262,15204,13728],{"class":271},[262,15206,602],{"class":429},[262,15208,15032],{"class":271},[262,15210,12118],{"class":429},[262,15212,102],{"class":271},[262,15214,2903],{"class":429},[262,15216,981],{"class":377},[262,15218,15219],{"class":429}," img.width, ",[262,15221,15032],{"class":271},[262,15223,12118],{"class":429},[262,15225,997],{"class":271},[262,15227,2903],{"class":429},[262,15229,981],{"class":377},[262,15231,13741],{"class":429},[262,15233,15234,15236,15238,15241,15243,15245,15247,15249,15251,15253,15255],{"class":181,"line":1846},[262,15235,11680],{"class":429},[262,15237,476],{"class":377},[262,15239,15240],{"class":429}," img.resize((",[262,15242,13754],{"class":271},[262,15244,13757],{"class":429},[262,15246,1003],{"class":377},[262,15248,13762],{"class":429},[262,15250,13754],{"class":271},[262,15252,13767],{"class":429},[262,15254,1003],{"class":377},[262,15256,15257],{"class":429}," scale)),\n",[262,15259,15260,15263,15265],{"class":181,"line":1861},[262,15261,15262],{"class":429},"                     Image.Resampling.",[262,15264,11720],{"class":271},[262,15266,660],{"class":429},[262,15268,15269,15272,15274,15276,15278,15281,15283,15285,15288,15290,15292,15295,15297,15299,15301,15303,15305,15307],{"class":181,"line":1866},[262,15270,15271],{"class":429},"    left, top ",[262,15273,476],{"class":377},[262,15275,13795],{"class":429},[262,15277,561],{"class":377},[262,15279,15280],{"class":271}," TARGET",[262,15282,12118],{"class":429},[262,15284,102],{"class":271},[262,15286,15287],{"class":429},"]) ",[262,15289,13803],{"class":377},[262,15291,3232],{"class":271},[262,15293,15294],{"class":429},", (img.height ",[262,15296,561],{"class":377},[262,15298,15280],{"class":271},[262,15300,12118],{"class":429},[262,15302,997],{"class":271},[262,15304,15287],{"class":429},[262,15306,13803],{"class":377},[262,15308,13806],{"class":271},[262,15310,15311,15313,15315,15317,15319,15321,15323,15325,15328,15330,15332,15334,15336],{"class":181,"line":1871},[262,15312,11680],{"class":429},[262,15314,476],{"class":377},[262,15316,13834],{"class":429},[262,15318,531],{"class":377},[262,15320,15280],{"class":271},[262,15322,12118],{"class":429},[262,15324,102],{"class":271},[262,15326,15327],{"class":429},"], top ",[262,15329,531],{"class":377},[262,15331,15280],{"class":271},[262,15333,12118],{"class":429},[262,15335,997],{"class":271},[262,15337,15338],{"class":429},"]))\n",[262,15340,15341],{"class":181,"line":1890},[262,15342,583],{"emptyLinePlaceholder":582},[262,15344,15345,15347,15349,15352],{"class":181,"line":1909},[262,15346,11731],{"class":429},[262,15348,476],{"class":377},[262,15350,15351],{"class":429}," ImageDraw.Draw(img)             ",[262,15353,15354],{"class":291},"# stamp the brand watermark\n",[262,15356,15357,15359,15362,15364,15366,15368,15370,15372,15374,15377,15379,15381],{"class":181,"line":1914},[262,15358,13995],{"class":429},[262,15360,15361],{"class":271},"24",[262,15363,608],{"class":429},[262,15365,15032],{"class":271},[262,15367,12118],{"class":429},[262,15369,997],{"class":271},[262,15371,2903],{"class":429},[262,15373,561],{"class":377},[262,15375,15376],{"class":271}," 56",[262,15378,11709],{"class":429},[262,15380,15022],{"class":271},[262,15382,1315],{"class":429},[262,15384,15385,15388,15390,15392,15394,15396,15398,15400,15402,15404,15406,15408,15411,15413,15415,15418],{"class":181,"line":1919},[262,15386,15387],{"class":611},"              fill",[262,15389,476],{"class":377},[262,15391,602],{"class":429},[262,15393,14016],{"class":271},[262,15395,608],{"class":429},[262,15397,14016],{"class":271},[262,15399,608],{"class":429},[262,15401,14016],{"class":271},[262,15403,11709],{"class":429},[262,15405,11807],{"class":611},[262,15407,476],{"class":377},[262,15409,15410],{"class":429},"ImageFont.load_default(",[262,15412,10260],{"class":611},[262,15414,476],{"class":377},[262,15416,15417],{"class":271},"34",[262,15419,2684],{"class":429},[262,15421,15422,15425,15427,15429,15431,15433,15435],{"class":181,"line":1946},[262,15423,15424],{"class":429},"    img.save(out_path, ",[262,15426,13851],{"class":275},[262,15428,608],{"class":429},[262,15430,10268],{"class":611},[262,15432,476],{"class":377},[262,15434,141],{"class":271},[262,15436,660],{"class":429},[262,15438,15439],{"class":181,"line":1959},[262,15440,15441],{"class":429},"    os.remove(tmp)\n",[262,15443,15444,15446,15448,15450,15453,15455,15458,15460,15462],{"class":181,"line":1996},[262,15445,1089],{"class":271},[262,15447,602],{"class":429},[262,15449,642],{"class":377},[262,15451,15452],{"class":275},"\"Done ",[262,15454,3039],{"class":271},[262,15456,15457],{"class":429},"out_path",[262,15459,654],{"class":271},[262,15461,1176],{"class":275},[262,15463,660],{"class":429},[262,15465,15466],{"class":181,"line":2012},[262,15467,583],{"emptyLinePlaceholder":582},[262,15469,15470],{"class":181,"line":2040},[262,15471,583],{"emptyLinePlaceholder":582},[262,15473,15474,15476,15479,15482,15484],{"class":181,"line":2045},[262,15475,423],{"class":377},[262,15477,15478],{"class":267}," run",[262,15480,15481],{"class":429},"() -> ",[262,15483,8471],{"class":271},[262,15485,1160],{"class":429},[262,15487,15488,15491,15493,15495,15498,15500,15503,15505],{"class":181,"line":2050},[262,15489,15490],{"class":429},"    folder ",[262,15492,476],{"class":377},[262,15494,10178],{"class":377},[262,15496,15497],{"class":275},"\"campaign_",[262,15499,3039],{"class":271},[262,15501,15502],{"class":429},"date.today().isoformat()",[262,15504,654],{"class":271},[262,15506,1257],{"class":275},[262,15508,15509,15511,15513,15515,15517],{"class":181,"line":2067},[262,15510,14169],{"class":429},[262,15512,4969],{"class":611},[262,15514,476],{"class":377},[262,15516,4974],{"class":271},[262,15518,660],{"class":429},[262,15520,15521,15523,15526,15528,15530,15532,15534,15536,15538,15540,15542],{"class":181,"line":2077},[262,15522,3074],{"class":377},[262,15524,15525],{"class":429}," i, prompt ",[262,15527,835],{"class":377},[262,15529,14189],{"class":271},[262,15531,602],{"class":429},[262,15533,14988],{"class":271},[262,15535,608],{"class":429},[262,15537,14195],{"class":611},[262,15539,476],{"class":377},[262,15541,997],{"class":271},[262,15543,8192],{"class":429},[262,15545,15546,15549,15551,15554,15556,15559,15561,15563,15566],{"class":181,"line":2086},[262,15547,15548],{"class":429},"        make_asset(prompt, os.path.join(folder, ",[262,15550,642],{"class":377},[262,15552,15553],{"class":275},"\"post_",[262,15555,3039],{"class":271},[262,15557,15558],{"class":429},"i",[262,15560,14291],{"class":377},[262,15562,654],{"class":271},[262,15564,15565],{"class":275},".jpg\"",[262,15567,2684],{"class":429},[262,15569,15570],{"class":181,"line":2097},[262,15571,583],{"emptyLinePlaceholder":582},[262,15573,15574],{"class":181,"line":2106},[262,15575,583],{"emptyLinePlaceholder":582},[262,15577,15578,15580,15582,15584,15586],{"class":181,"line":2126},[262,15579,2210],{"class":377},[262,15581,2213],{"class":271},[262,15583,2216],{"class":377},[262,15585,2219],{"class":275},[262,15587,1160],{"class":429},[262,15589,15590],{"class":181,"line":2148},[262,15591,15592],{"class":429},"    run()\n",[14,15594,15595,15596,15599,15600,15603],{},"Save it, run ",[18,15597,15598],{},"python campaign.py",", and you will get a folder like ",[18,15601,15602],{},"campaign_2026-06-18"," holding three finished, watermarked, exactly-sized social images. Change the prompts list and run it again for the next campaign.",[57,15605,2355],{"id":2354},[14,15607,15608],{},"Now that you can generate, style, and batch images, follow these guides to apply the skill to specific jobs:",[1447,15610,15611,15616,15621],{},[1450,15612,15613,15614,1363],{},"Make click-worthy video art with ",[51,15615,9415],{"href":9414},[1450,15617,15618,15619,1363],{},"Produce a full catalog from a spreadsheet with ",[51,15620,9395],{"href":12621},[1450,15622,15623,15624,15626,15627,1363],{},"Pair your images with captions from ",[51,15625,3991],{"href":3990},", then publish both through ",[51,15628,9309],{"href":9308},[14,15630,2375,15631,1363],{},[51,15632,5413],{"href":5412},[57,15634,2381],{"id":2380},[2322,15636,15637,15641,15645,15649,15653],{},[1450,15638,15639],{},[51,15640,9415],{"href":9414},[1450,15642,15643],{},[51,15644,9395],{"href":12621},[1450,15646,15647],{},[51,15648,3991],{"href":3990},[1450,15650,15651],{},[51,15652,9309],{"href":9308},[1450,15654,15655],{},[51,15656,5413],{"href":5412},[2401,15658,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":15660},[15661,15664,15665,15668,15669,15670,15671,15672,15673,15674,15675,15676],{"id":12746,"depth":282,"text":12747,"children":15662},[15663],{"id":12784,"depth":295,"text":12785},{"id":237,"depth":282,"text":238},{"id":13071,"depth":282,"text":13072,"children":15666},[15667],{"id":13350,"depth":295,"text":13351},{"id":13377,"depth":282,"text":13378},{"id":13626,"depth":282,"text":13627},{"id":14102,"depth":282,"text":14103},{"id":14589,"depth":282,"text":14590},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":14891,"depth":282,"text":14892},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Generate marketing images with Python and the OpenAI images API. Control size, quality, and style, post-process with Pillow, and batch-generate at scale.",[15679,15682,15685,15688,15691],{"q":15680,"a":15681},"Which OpenAI model should I use to generate images in Python?","Use the gpt-image-1 model through the client.images.generate call for the newest, highest-quality output, or dall-e-3 if you need the older interface. Both run from the same openai SDK with one Python function.",{"q":15683,"a":15684},"How many images can I generate per API call?","With gpt-image-1 you can request several images in one call using the n parameter. dall-e-3 is limited to one image per call, so you loop or run concurrent requests to produce a batch.",{"q":15686,"a":15687},"What image sizes does the OpenAI images API support?","Common supported sizes are 1024x1024 (square), 1024x1536 (portrait), and 1536x1024 (landscape). Request the closest aspect ratio to your target, then crop or pad with Pillow for an exact fit.",{"q":15689,"a":15690},"Do I need a GPU to generate AI images with Python?","No. The OpenAI images API runs the model on OpenAI's servers, so any laptop can call it. You only need a GPU if you run an open-weight model locally, which is a separate, more advanced setup.",{"q":15692,"a":15693},"How much does it cost to generate an image with the OpenAI API?","Pricing depends on the model, size, and quality you choose, and is billed per image. Lower quality and smaller sizes cost less, so test with cheap settings before running large batches.",{"name":15695,"steps":15696},"How to generate images with Python and the OpenAI images API",[15697,15700,15703,15706],{"name":15698,"text":15699},"Generate your first image","Call client.images.generate with a model and prompt, then decode and save the returned image to a file.",{"name":15701,"text":15702},"Control size, quality, and style","Set the size, quality, and background parameters to match the platform and look you need.",{"name":15704,"text":15705},"Post-process with Pillow","Open the saved image with Pillow to crop, resize, convert format, or add a watermark.",{"name":15707,"text":15708},"Batch-generate a set of images","Loop over a list of prompts and save each result with a structured filename for repeatable runs.",{},"\u002Fai-content-creation-marketing-automation\u002Fai-image-video-generation","2026-05-08",{"title":12723,"description":15677},"ai-content-creation-marketing-automation\u002Fai-image-video-generation\u002Findex","5nNYLgN5xaw_v8qp8QooCOOkqsDKzP4eYqmAMvQBrf4",{"id":15716,"title":15717,"body":15718,"description":17711,"extension":2419,"faq":17712,"howto":17728,"meta":17743,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":17744,"published":2452,"seo":17745,"seoTitle":15717,"stem":17746,"__hash__":17747},"content\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fbulk-schedule-social-posts-with-python\u002Findex.md","Bulk-Schedule Social Posts with Python",{"type":7,"value":15719,"toc":17700},[15720,15723,15729,15737,15739,15742,15762,15768,15788,15795,15806,15831,15837,15841,15844,16310,16319,16323,16331,16691,16711,16715,16724,17103,17118,17122,17133,17464,17481,17485,17587,17589,17646,17648,17668,17670,17694,17698],[10,15721,15717],{"id":15722},"bulk-schedule-social-posts-with-python",[14,15724,15725,15726,15728],{},"This guide shows you how to take a spreadsheet of social posts and publish them on a schedule with Python in under thirty minutes — reading a CSV of text, datetime, and platform, cleaning the copy with an LLM (large language model, the kind of AI behind ChatGPT) where it needs work, and firing each post at its planned time through an API. It is a practical building block for ",[51,15727,9309],{"href":9308}," that you own end to end, with no monthly SaaS bill.",[14,15730,15731,15732,15736],{},"If you already know how to send one post — for example from ",[51,15733,15735],{"href":15734},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fschedule-instagram-posts-using-python-and-ai\u002F","Schedule Instagram Posts Using Python and AI"," — this guide is the next step up: running dozens of posts from a single file on a timer.",[57,15738,238],{"id":237},[14,15740,15741],{},"You need Python 3.10+ and the four libraries below. Everything else is in the standard library.",[253,15743,15745],{"className":255,"code":15744,"language":257,"meta":258,"style":258},"pip install httpx openai apscheduler python-dotenv\n",[18,15746,15747],{"__ignoreMap":258},[262,15748,15749,15751,15753,15755,15757,15760],{"class":181,"line":264},[262,15750,298],{"class":267},[262,15752,301],{"class":275},[262,15754,5440],{"class":275},[262,15756,2519],{"class":275},[262,15758,15759],{"class":275}," apscheduler",[262,15761,2522],{"class":275},[14,15763,15764,15765,15767],{},"Store your credentials in a ",[18,15766,319],{}," file so they never end up in your code or your git history:",[253,15769,15771],{"className":323,"code":15770,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-...\nSOCIAL_API_TOKEN=your_platform_token\nSOCIAL_API_URL=https:\u002F\u002Fapi.example-social.com\u002Fv1\u002Fposts\n",[18,15772,15773,15778,15783],{"__ignoreMap":258},[262,15774,15775],{"class":181,"line":264},[262,15776,15777],{},"OPENAI_API_KEY=sk-...\n",[262,15779,15780],{"class":181,"line":282},[262,15781,15782],{},"SOCIAL_API_TOKEN=your_platform_token\n",[262,15784,15785],{"class":181,"line":295},[262,15786,15787],{},"SOCIAL_API_URL=https:\u002F\u002Fapi.example-social.com\u002Fv1\u002Fposts\n",[14,15789,353,15790,356,15792,15794],{},[18,15791,319],{},[18,15793,359],{}," right away so these secrets are never committed.",[14,15796,15797,15798,15801,15802,15805],{},"Finally, prepare a ",[18,15799,15800],{},"posts.csv"," file. Three columns are required: the post text, the datetime to publish (ISO 8601, like ",[18,15803,15804],{},"2026-06-20T14:30:00","), and the target platform.",[253,15807,15809],{"className":9500,"code":15808,"language":9502,"meta":258,"style":258},"id,text,publish_at,platform,needs_cleanup\n1,new blog post is live go read it,2026-06-20T09:00:00,twitter,true\n2,Our Q3 webinar is open for sign-ups. Save your seat.,2026-06-20T15:00:00,linkedin,false\n3,behind the scenes pics from todays shoot,2026-06-21T18:30:00,twitter,true\n",[18,15810,15811,15816,15821,15826],{"__ignoreMap":258},[262,15812,15813],{"class":181,"line":264},[262,15814,15815],{},"id,text,publish_at,platform,needs_cleanup\n",[262,15817,15818],{"class":181,"line":282},[262,15819,15820],{},"1,new blog post is live go read it,2026-06-20T09:00:00,twitter,true\n",[262,15822,15823],{"class":181,"line":295},[262,15824,15825],{},"2,Our Q3 webinar is open for sign-ups. Save your seat.,2026-06-20T15:00:00,linkedin,false\n",[262,15827,15828],{"class":181,"line":345},[262,15829,15830],{},"3,behind the scenes pics from todays shoot,2026-06-21T18:30:00,twitter,true\n",[14,15832,3349,15833,15836],{},[18,15834,15835],{},"needs_cleanup"," column lets you mark which rows the LLM should rewrite, so polished copy is left alone and you only pay for the rows that need help.",[57,15838,15840],{"id":15839},"step-1-read-and-validate-the-csv","Step 1: Read and validate the CSV",[14,15842,15843],{},"Never trust a spreadsheet. A single bad date or empty cell will crash a naive loop halfway through a batch, leaving some posts sent and others not. Read every row into a typed object first, validate it, and collect the problems in one place.",[253,15845,15847],{"className":414,"code":15846,"language":416,"meta":258,"style":258},"import csv\nfrom dataclasses import dataclass\nfrom datetime import datetime\n\n\n@dataclass\nclass Post:\n    id: str\n    text: str\n    publish_at: datetime\n    platform: str\n    needs_cleanup: bool\n\n\ndef load_posts(path: str) -> tuple[list[Post], list[str]]:\n    posts: list[Post] = []\n    errors: list[str] = []\n    with open(path, newline=\"\", encoding=\"utf-8\") as f:\n        for line_no, row in enumerate(csv.DictReader(f), start=2):\n            try:\n                post = Post(\n                    id=row[\"id\"].strip(),\n                    text=row[\"text\"].strip(),\n                    publish_at=datetime.fromisoformat(row[\"publish_at\"].strip()),\n                    platform=row[\"platform\"].strip().lower(),\n                    needs_cleanup=row[\"needs_cleanup\"].strip().lower() == \"true\",\n                )\n                if not post.text:\n                    raise ValueError(\"text is empty\")\n                posts.append(post)\n            except (KeyError, ValueError) as exc:\n                errors.append(f\"line {line_no}: {exc}\")\n    return posts, errors\n\n\nif __name__ == \"__main__\":\n    posts, errors = load_posts(\"posts.csv\")\n    print(f\"Loaded {len(posts)} valid posts, {len(errors)} skipped.\")\n    for err in errors:\n        print(\"  \", err)\n",[18,15848,15849,15855,15865,15876,15880,15884,15888,15897,15906,15913,15918,15925,15933,15937,15941,15961,15970,15983,16011,16033,16039,16049,16063,16077,16093,16108,16130,16135,16145,16159,16164,16183,16212,16219,16223,16227,16239,16254,16286,16298],{"__ignoreMap":258},[262,15850,15851,15853],{"class":181,"line":264},[262,15852,684],{"class":377},[262,15854,8533],{"class":429},[262,15856,15857,15859,15861,15863],{"class":181,"line":282},[262,15858,705],{"class":377},[262,15860,7302],{"class":429},[262,15862,684],{"class":377},[262,15864,7307],{"class":429},[262,15866,15867,15869,15871,15873],{"class":181,"line":295},[262,15868,705],{"class":377},[262,15870,10502],{"class":429},[262,15872,684],{"class":377},[262,15874,15875],{"class":429}," datetime\n",[262,15877,15878],{"class":181,"line":345},[262,15879,583],{"emptyLinePlaceholder":582},[262,15881,15882],{"class":181,"line":492},[262,15883,583],{"emptyLinePlaceholder":582},[262,15885,15886],{"class":181,"line":503},[262,15887,7369],{"class":267},[262,15889,15890,15892,15895],{"class":181,"line":521},[262,15891,7374],{"class":377},[262,15893,15894],{"class":267}," Post",[262,15896,1160],{"class":429},[262,15898,15899,15902,15904],{"class":181,"line":537},[262,15900,15901],{"class":271},"    id",[262,15903,1231],{"class":429},[262,15905,8677],{"class":271},[262,15907,15908,15911],{"class":181,"line":549},[262,15909,15910],{"class":429},"    text: ",[262,15912,8677],{"class":271},[262,15914,15915],{"class":181,"line":570},[262,15916,15917],{"class":429},"    publish_at: datetime\n",[262,15919,15920,15923],{"class":181,"line":579},[262,15921,15922],{"class":429},"    platform: ",[262,15924,8677],{"class":271},[262,15926,15927,15930],{"class":181,"line":586},[262,15928,15929],{"class":429},"    needs_cleanup: ",[262,15931,15932],{"class":271},"bool\n",[262,15934,15935],{"class":181,"line":591},[262,15936,583],{"emptyLinePlaceholder":582},[262,15938,15939],{"class":181,"line":623},[262,15940,583],{"emptyLinePlaceholder":582},[262,15942,15943,15945,15948,15951,15953,15956,15958],{"class":181,"line":634},[262,15944,423],{"class":377},[262,15946,15947],{"class":267}," load_posts",[262,15949,15950],{"class":429},"(path: ",[262,15952,433],{"class":271},[262,15954,15955],{"class":429},") -> tuple[list[Post], list[",[262,15957,433],{"class":271},[262,15959,15960],{"class":429},"]]:\n",[262,15962,15963,15966,15968],{"class":181,"line":845},[262,15964,15965],{"class":429},"    posts: list[Post] ",[262,15967,476],{"class":377},[262,15969,489],{"class":429},[262,15971,15972,15975,15977,15979,15981],{"class":181,"line":850},[262,15973,15974],{"class":429},"    errors: list[",[262,15976,433],{"class":271},[262,15978,2903],{"class":429},[262,15980,476],{"class":377},[262,15982,489],{"class":429},[262,15984,15985,15987,15989,15991,15993,15995,15997,15999,16001,16003,16005,16007,16009],{"class":181,"line":864},[262,15986,10124],{"class":377},[262,15988,599],{"class":271},[262,15990,14308],{"class":429},[262,15992,9170],{"class":611},[262,15994,476],{"class":377},[262,15996,9175],{"class":275},[262,15998,608],{"class":429},[262,16000,612],{"class":611},[262,16002,476],{"class":377},[262,16004,617],{"class":275},[262,16006,1000],{"class":429},[262,16008,697],{"class":377},[262,16010,9190],{"class":429},[262,16012,16013,16015,16018,16020,16022,16025,16027,16029,16031],{"class":181,"line":1683},[262,16014,10155],{"class":377},[262,16016,16017],{"class":429}," line_no, row ",[262,16019,835],{"class":377},[262,16021,14189],{"class":271},[262,16023,16024],{"class":429},"(csv.DictReader(f), ",[262,16026,14195],{"class":611},[262,16028,476],{"class":377},[262,16030,109],{"class":271},[262,16032,8192],{"class":429},[262,16034,16035,16037],{"class":181,"line":1688},[262,16036,10240],{"class":377},[262,16038,1160],{"class":429},[262,16040,16041,16044,16046],{"class":181,"line":1693},[262,16042,16043],{"class":429},"                post ",[262,16045,476],{"class":377},[262,16047,16048],{"class":429}," Post(\n",[262,16050,16051,16054,16056,16058,16060],{"class":181,"line":1728},[262,16052,16053],{"class":611},"                    id",[262,16055,476],{"class":377},[262,16057,10185],{"class":429},[262,16059,6770],{"class":275},[262,16061,16062],{"class":429},"].strip(),\n",[262,16064,16065,16068,16070,16072,16075],{"class":181,"line":1737},[262,16066,16067],{"class":611},"                    text",[262,16069,476],{"class":377},[262,16071,10185],{"class":429},[262,16073,16074],{"class":275},"\"text\"",[262,16076,16062],{"class":429},[262,16078,16079,16082,16084,16087,16090],{"class":181,"line":1751},[262,16080,16081],{"class":611},"                    publish_at",[262,16083,476],{"class":377},[262,16085,16086],{"class":429},"datetime.fromisoformat(row[",[262,16088,16089],{"class":275},"\"publish_at\"",[262,16091,16092],{"class":429},"].strip()),\n",[262,16094,16095,16098,16100,16102,16105],{"class":181,"line":1764},[262,16096,16097],{"class":611},"                    platform",[262,16099,476],{"class":377},[262,16101,10185],{"class":429},[262,16103,16104],{"class":275},"\"platform\"",[262,16106,16107],{"class":429},"].strip().lower(),\n",[262,16109,16110,16113,16115,16117,16120,16123,16125,16128],{"class":181,"line":1779},[262,16111,16112],{"class":611},"                    needs_cleanup",[262,16114,476],{"class":377},[262,16116,10185],{"class":429},[262,16118,16119],{"class":275},"\"needs_cleanup\"",[262,16121,16122],{"class":429},"].strip().lower() ",[262,16124,10758],{"class":377},[262,16126,16127],{"class":275}," \"true\"",[262,16129,1315],{"class":429},[262,16131,16132],{"class":181,"line":1793},[262,16133,16134],{"class":429},"                )\n",[262,16136,16137,16140,16142],{"class":181,"line":1800},[262,16138,16139],{"class":377},"                if",[262,16141,2818],{"class":377},[262,16143,16144],{"class":429}," post.text:\n",[262,16146,16147,16150,16152,16154,16157],{"class":181,"line":1805},[262,16148,16149],{"class":377},"                    raise",[262,16151,2832],{"class":271},[262,16153,602],{"class":429},[262,16155,16156],{"class":275},"\"text is empty\"",[262,16158,660],{"class":429},[262,16160,16161],{"class":181,"line":1810},[262,16162,16163],{"class":429},"                posts.append(post)\n",[262,16165,16166,16168,16170,16172,16174,16177,16179,16181],{"class":181,"line":1823},[262,16167,10358],{"class":377},[262,16169,13751],{"class":429},[262,16171,3897],{"class":271},[262,16173,608],{"class":429},[262,16175,16176],{"class":271},"ValueError",[262,16178,1000],{"class":429},[262,16180,697],{"class":377},[262,16182,9840],{"class":429},[262,16184,16185,16188,16190,16193,16195,16198,16200,16202,16204,16206,16208,16210],{"class":181,"line":1846},[262,16186,16187],{"class":429},"                errors.append(",[262,16189,642],{"class":377},[262,16191,16192],{"class":275},"\"line ",[262,16194,3039],{"class":271},[262,16196,16197],{"class":429},"line_no",[262,16199,654],{"class":271},[262,16201,1231],{"class":275},[262,16203,3039],{"class":271},[262,16205,9864],{"class":429},[262,16207,654],{"class":271},[262,16209,1176],{"class":275},[262,16211,660],{"class":429},[262,16213,16214,16216],{"class":181,"line":1861},[262,16215,573],{"class":377},[262,16217,16218],{"class":429}," posts, errors\n",[262,16220,16221],{"class":181,"line":1866},[262,16222,583],{"emptyLinePlaceholder":582},[262,16224,16225],{"class":181,"line":1871},[262,16226,583],{"emptyLinePlaceholder":582},[262,16228,16229,16231,16233,16235,16237],{"class":181,"line":1890},[262,16230,2210],{"class":377},[262,16232,2213],{"class":271},[262,16234,2216],{"class":377},[262,16236,2219],{"class":275},[262,16238,1160],{"class":429},[262,16240,16241,16244,16246,16249,16252],{"class":181,"line":1909},[262,16242,16243],{"class":429},"    posts, errors ",[262,16245,476],{"class":377},[262,16247,16248],{"class":429}," load_posts(",[262,16250,16251],{"class":275},"\"posts.csv\"",[262,16253,660],{"class":429},[262,16255,16256,16258,16260,16262,16264,16266,16269,16271,16274,16276,16279,16281,16284],{"class":181,"line":1914},[262,16257,1089],{"class":271},[262,16259,602],{"class":429},[262,16261,642],{"class":377},[262,16263,2775],{"class":275},[262,16265,648],{"class":271},[262,16267,16268],{"class":429},"(posts)",[262,16270,654],{"class":271},[262,16272,16273],{"class":275}," valid posts, ",[262,16275,648],{"class":271},[262,16277,16278],{"class":429},"(errors)",[262,16280,654],{"class":271},[262,16282,16283],{"class":275}," skipped.\"",[262,16285,660],{"class":429},[262,16287,16288,16290,16293,16295],{"class":181,"line":1919},[262,16289,3074],{"class":377},[262,16291,16292],{"class":429}," err ",[262,16294,835],{"class":377},[262,16296,16297],{"class":429}," errors:\n",[262,16299,16300,16302,16304,16307],{"class":181,"line":1946},[262,16301,2299],{"class":271},[262,16303,602],{"class":429},[262,16305,16306],{"class":275},"\"  \"",[262,16308,16309],{"class":429},", err)\n",[14,16311,16312,16315,16316,16318],{},[18,16313,16314],{},"datetime.fromisoformat"," parses the ISO 8601 strings and raises ",[18,16317,16176],{}," on anything malformed, so bad dates are caught here rather than at send time. The function returns valid posts and a list of human-readable errors so you can fix the spreadsheet before scheduling anything.",[57,16320,16322],{"id":16321},"step-2-clean-or-generate-copy-with-an-llm","Step 2: Clean or generate copy with an LLM",[14,16324,16325,16326,16328,16329,1363],{},"Rows marked ",[18,16327,15835],{}," get sent to the OpenAI API for a quick rewrite — fixing capitalisation, tightening wording, and respecting each platform's length limits. Rows that are already polished are left exactly as written, which keeps your token cost down. For deeper copy work, see ",[51,16330,3991],{"href":3990},[253,16332,16334],{"className":414,"code":16333,"language":416,"meta":258,"style":258},"import os\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nCHAR_LIMITS = {\"twitter\": 280, \"linkedin\": 3000, \"mastodon\": 500}\n\n\ndef clean_copy(text: str, platform: str) -> str:\n    limit = CHAR_LIMITS.get(platform, 280)\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"You polish social media copy. Fix grammar and capitalisation, \"\n                    \"keep the original meaning and any links, and never exceed the \"\n                    \"character limit. Return only the rewritten post, no quotes.\"\n                ),\n            },\n            {\n                \"role\": \"user\",\n                \"content\": f\"Platform: {platform} (max {limit} chars).\\nPost: {text}\",\n            },\n        ],\n        temperature=0.4,\n    )\n    return response.choices[0].message.content.strip()[:limit]\n\n\ndef apply_cleanup(posts: list[Post]) -> None:\n    for post in posts:\n        if post.needs_cleanup:\n            post.text = clean_copy(post.text, post.platform)\n",[18,16335,16336,16342,16352,16362,16366,16370,16388,16392,16431,16435,16439,16461,16478,16486,16496,16504,16508,16518,16524,16529,16534,16539,16543,16547,16551,16561,16607,16611,16615,16625,16629,16640,16644,16648,16662,16674,16681],{"__ignoreMap":258},[262,16337,16338,16340],{"class":181,"line":264},[262,16339,684],{"class":377},[262,16341,687],{"class":429},[262,16343,16344,16346,16348,16350],{"class":181,"line":282},[262,16345,705],{"class":377},[262,16347,720],{"class":429},[262,16349,684],{"class":377},[262,16351,725],{"class":429},[262,16353,16354,16356,16358,16360],{"class":181,"line":295},[262,16355,705],{"class":377},[262,16357,708],{"class":429},[262,16359,684],{"class":377},[262,16361,713],{"class":429},[262,16363,16364],{"class":181,"line":345},[262,16365,583],{"emptyLinePlaceholder":582},[262,16367,16368],{"class":181,"line":492},[262,16369,734],{"class":429},[262,16371,16372,16374,16376,16378,16380,16382,16384,16386],{"class":181,"line":503},[262,16373,739],{"class":429},[262,16375,476],{"class":377},[262,16377,1588],{"class":429},[262,16379,2674],{"class":611},[262,16381,476],{"class":377},[262,16383,1199],{"class":429},[262,16385,2681],{"class":275},[262,16387,2684],{"class":429},[262,16389,16390],{"class":181,"line":521},[262,16391,583],{"emptyLinePlaceholder":582},[262,16393,16394,16397,16399,16401,16404,16406,16408,16410,16413,16415,16418,16420,16423,16425,16428],{"class":181,"line":537},[262,16395,16396],{"class":271},"CHAR_LIMITS",[262,16398,442],{"class":377},[262,16400,2276],{"class":429},[262,16402,16403],{"class":275},"\"twitter\"",[262,16405,1231],{"class":429},[262,16407,12816],{"class":271},[262,16409,608],{"class":429},[262,16411,16412],{"class":275},"\"linkedin\"",[262,16414,1231],{"class":429},[262,16416,16417],{"class":271},"3000",[262,16419,608],{"class":429},[262,16421,16422],{"class":275},"\"mastodon\"",[262,16424,1231],{"class":429},[262,16426,16427],{"class":271},"500",[262,16429,16430],{"class":429},"}\n",[262,16432,16433],{"class":181,"line":549},[262,16434,583],{"emptyLinePlaceholder":582},[262,16436,16437],{"class":181,"line":570},[262,16438,583],{"emptyLinePlaceholder":582},[262,16440,16441,16443,16446,16448,16450,16453,16455,16457,16459],{"class":181,"line":579},[262,16442,423],{"class":377},[262,16444,16445],{"class":267}," clean_copy",[262,16447,430],{"class":429},[262,16449,433],{"class":271},[262,16451,16452],{"class":429},", platform: ",[262,16454,433],{"class":271},[262,16456,1939],{"class":429},[262,16458,433],{"class":271},[262,16460,1160],{"class":429},[262,16462,16463,16466,16468,16471,16474,16476],{"class":181,"line":586},[262,16464,16465],{"class":429},"    limit ",[262,16467,476],{"class":377},[262,16469,16470],{"class":271}," CHAR_LIMITS",[262,16472,16473],{"class":429},".get(platform, ",[262,16475,12816],{"class":271},[262,16477,660],{"class":429},[262,16479,16480,16482,16484],{"class":181,"line":591},[262,16481,1184],{"class":429},[262,16483,476],{"class":377},[262,16485,1189],{"class":429},[262,16487,16488,16490,16492,16494],{"class":181,"line":623},[262,16489,1194],{"class":611},[262,16491,476],{"class":377},[262,16493,1207],{"class":275},[262,16495,1315],{"class":429},[262,16497,16498,16500,16502],{"class":181,"line":634},[262,16499,1215],{"class":611},[262,16501,476],{"class":377},[262,16503,1220],{"class":429},[262,16505,16506],{"class":181,"line":845},[262,16507,4331],{"class":429},[262,16509,16510,16512,16514,16516],{"class":181,"line":850},[262,16511,4336],{"class":275},[262,16513,1231],{"class":429},[262,16515,1234],{"class":275},[262,16517,1315],{"class":429},[262,16519,16520,16522],{"class":181,"line":864},[262,16521,4347],{"class":275},[262,16523,1242],{"class":429},[262,16525,16526],{"class":181,"line":1683},[262,16527,16528],{"class":275},"                    \"You polish social media copy. Fix grammar and capitalisation, \"\n",[262,16530,16531],{"class":181,"line":1688},[262,16532,16533],{"class":275},"                    \"keep the original meaning and any links, and never exceed the \"\n",[262,16535,16536],{"class":181,"line":1693},[262,16537,16538],{"class":275},"                    \"character limit. Return only the rewritten post, no quotes.\"\n",[262,16540,16541],{"class":181,"line":1728},[262,16542,4364],{"class":429},[262,16544,16545],{"class":181,"line":1737},[262,16546,4369],{"class":429},[262,16548,16549],{"class":181,"line":1751},[262,16550,4331],{"class":429},[262,16552,16553,16555,16557,16559],{"class":181,"line":1764},[262,16554,4336],{"class":275},[262,16556,1231],{"class":429},[262,16558,1291],{"class":275},[262,16560,1315],{"class":429},[262,16562,16563,16565,16567,16569,16572,16574,16577,16579,16582,16584,16587,16589,16592,16594,16597,16599,16601,16603,16605],{"class":181,"line":1779},[262,16564,4347],{"class":275},[262,16566,1231],{"class":429},[262,16568,642],{"class":377},[262,16570,16571],{"class":275},"\"Platform: ",[262,16573,3039],{"class":271},[262,16575,16576],{"class":429},"platform",[262,16578,654],{"class":271},[262,16580,16581],{"class":275}," (max ",[262,16583,3039],{"class":271},[262,16585,16586],{"class":429},"limit",[262,16588,654],{"class":271},[262,16590,16591],{"class":275}," chars).",[262,16593,2137],{"class":271},[262,16595,16596],{"class":275},"Post: ",[262,16598,3039],{"class":271},[262,16600,111],{"class":429},[262,16602,654],{"class":271},[262,16604,1176],{"class":275},[262,16606,1315],{"class":429},[262,16608,16609],{"class":181,"line":1793},[262,16610,4369],{"class":429},[262,16612,16613],{"class":181,"line":1800},[262,16614,1303],{"class":429},[262,16616,16617,16619,16621,16623],{"class":181,"line":1805},[262,16618,1308],{"class":611},[262,16620,476],{"class":377},[262,16622,3175],{"class":271},[262,16624,1315],{"class":429},[262,16626,16627],{"class":181,"line":1810},[262,16628,1011],{"class":429},[262,16630,16631,16633,16635,16637],{"class":181,"line":1823},[262,16632,573],{"class":377},[262,16634,1326],{"class":429},[262,16636,102],{"class":271},[262,16638,16639],{"class":429},"].message.content.strip()[:limit]\n",[262,16641,16642],{"class":181,"line":1846},[262,16643,583],{"emptyLinePlaceholder":582},[262,16645,16646],{"class":181,"line":1861},[262,16647,583],{"emptyLinePlaceholder":582},[262,16649,16650,16652,16655,16658,16660],{"class":181,"line":1866},[262,16651,423],{"class":377},[262,16653,16654],{"class":267}," apply_cleanup",[262,16656,16657],{"class":429},"(posts: list[Post]) -> ",[262,16659,8471],{"class":271},[262,16661,1160],{"class":429},[262,16663,16664,16666,16669,16671],{"class":181,"line":1871},[262,16665,3074],{"class":377},[262,16667,16668],{"class":429}," post ",[262,16670,835],{"class":377},[262,16672,16673],{"class":429}," posts:\n",[262,16675,16676,16678],{"class":181,"line":1890},[262,16677,2268],{"class":377},[262,16679,16680],{"class":429}," post.needs_cleanup:\n",[262,16682,16683,16686,16688],{"class":181,"line":1909},[262,16684,16685],{"class":429},"            post.text ",[262,16687,476],{"class":377},[262,16689,16690],{"class":429}," clean_copy(post.text, post.platform)\n",[14,16692,16693,16694,16696,16697,16699,16700,6092,16702,16704,16705,16708,16709,1363],{},"A ",[18,16695,4466],{}," prompt sets the rewriting rules once, the ",[18,16698,4470],{}," message carries the post and its limit, and a low ",[18,16701,3829],{},[18,16703,3175],{}," keeps results consistent rather than creative. The final ",[18,16706,16707],{},"[:limit]"," slice is a hard backstop in case the model ignores its instructions. To learn how these prompts work, read ",[51,16710,1362],{"href":1361},[57,16712,16714],{"id":16713},"step-3-build-a-posting-client-with-httpx","Step 3: Build a posting client with httpx",[14,16716,16717,16718,16720,16721,16723],{},"Each post goes out through one small function. Using ",[18,16719,5450],{}," gives you a clean timeout and an easy path to async later, and it routes by the ",[18,16722,16576],{}," field so one CSV can feed several networks. Mark each post as sent so a re-run never double-posts.",[253,16725,16727],{"className":414,"code":16726,"language":416,"meta":258,"style":258},"import json\nfrom pathlib import Path\nimport httpx\n\nAPI_URL = os.getenv(\"SOCIAL_API_URL\")\nAPI_TOKEN = os.getenv(\"SOCIAL_API_TOKEN\")\nSENT_FILE = Path(\"sent_ids.json\")\n\n\ndef load_sent() -> set[str]:\n    if SENT_FILE.exists():\n        return set(json.loads(SENT_FILE.read_text()))\n    return set()\n\n\ndef mark_sent(post_id: str, sent: set[str]) -> None:\n    sent.add(post_id)\n    SENT_FILE.write_text(json.dumps(sorted(sent)))\n\n\ndef publish(post: Post, sent: set[str]) -> None:\n    if post.id in sent:\n        print(f\"Skipping already-sent post {post.id}\")\n        return\n    payload = {\"text\": post.text, \"platform\": post.platform}\n    headers = {\"Authorization\": f\"Bearer {API_TOKEN}\"}\n    with httpx.Client(timeout=15) as http:\n        resp = http.post(API_URL, json=payload, headers=headers)\n        resp.raise_for_status()\n    mark_sent(post.id, sent)\n    print(f\"Published post {post.id} to {post.platform}\")\n",[18,16728,16729,16735,16745,16751,16755,16769,16783,16797,16801,16805,16819,16829,16844,16853,16857,16861,16884,16889,16903,16907,16911,16929,16941,16963,16968,16987,17012,17033,17063,17068,17073],{"__ignoreMap":258},[262,16730,16731,16733],{"class":181,"line":264},[262,16732,684],{"class":377},[262,16734,5766],{"class":429},[262,16736,16737,16739,16741,16743],{"class":181,"line":282},[262,16738,705],{"class":377},[262,16740,4882],{"class":429},[262,16742,684],{"class":377},[262,16744,4887],{"class":429},[262,16746,16747,16749],{"class":181,"line":295},[262,16748,684],{"class":377},[262,16750,6526],{"class":429},[262,16752,16753],{"class":181,"line":345},[262,16754,583],{"emptyLinePlaceholder":582},[262,16756,16757,16760,16762,16764,16767],{"class":181,"line":492},[262,16758,16759],{"class":271},"API_URL",[262,16761,442],{"class":377},[262,16763,754],{"class":429},[262,16765,16766],{"class":275},"\"SOCIAL_API_URL\"",[262,16768,660],{"class":429},[262,16770,16771,16774,16776,16778,16781],{"class":181,"line":503},[262,16772,16773],{"class":271},"API_TOKEN",[262,16775,442],{"class":377},[262,16777,754],{"class":429},[262,16779,16780],{"class":275},"\"SOCIAL_API_TOKEN\"",[262,16782,660],{"class":429},[262,16784,16785,16788,16790,16792,16795],{"class":181,"line":521},[262,16786,16787],{"class":271},"SENT_FILE",[262,16789,442],{"class":377},[262,16791,4986],{"class":429},[262,16793,16794],{"class":275},"\"sent_ids.json\"",[262,16796,660],{"class":429},[262,16798,16799],{"class":181,"line":537},[262,16800,583],{"emptyLinePlaceholder":582},[262,16802,16803],{"class":181,"line":549},[262,16804,583],{"emptyLinePlaceholder":582},[262,16806,16807,16809,16812,16815,16817],{"class":181,"line":570},[262,16808,423],{"class":377},[262,16810,16811],{"class":267}," load_sent",[262,16813,16814],{"class":429},"() -> set[",[262,16816,433],{"class":271},[262,16818,463],{"class":429},[262,16820,16821,16823,16826],{"class":181,"line":579},[262,16822,3454],{"class":377},[262,16824,16825],{"class":271}," SENT_FILE",[262,16827,16828],{"class":429},".exists():\n",[262,16830,16831,16833,16836,16839,16841],{"class":181,"line":586},[262,16832,8066],{"class":377},[262,16834,16835],{"class":271}," set",[262,16837,16838],{"class":429},"(json.loads(",[262,16840,16787],{"class":271},[262,16842,16843],{"class":429},".read_text()))\n",[262,16845,16846,16848,16850],{"class":181,"line":591},[262,16847,573],{"class":377},[262,16849,16835],{"class":271},[262,16851,16852],{"class":429},"()\n",[262,16854,16855],{"class":181,"line":623},[262,16856,583],{"emptyLinePlaceholder":582},[262,16858,16859],{"class":181,"line":634},[262,16860,583],{"emptyLinePlaceholder":582},[262,16862,16863,16865,16868,16871,16873,16876,16878,16880,16882],{"class":181,"line":845},[262,16864,423],{"class":377},[262,16866,16867],{"class":267}," mark_sent",[262,16869,16870],{"class":429},"(post_id: ",[262,16872,433],{"class":271},[262,16874,16875],{"class":429},", sent: set[",[262,16877,433],{"class":271},[262,16879,13681],{"class":429},[262,16881,8471],{"class":271},[262,16883,1160],{"class":429},[262,16885,16886],{"class":181,"line":850},[262,16887,16888],{"class":429},"    sent.add(post_id)\n",[262,16890,16891,16894,16897,16900],{"class":181,"line":864},[262,16892,16893],{"class":271},"    SENT_FILE",[262,16895,16896],{"class":429},".write_text(json.dumps(",[262,16898,16899],{"class":271},"sorted",[262,16901,16902],{"class":429},"(sent)))\n",[262,16904,16905],{"class":181,"line":1683},[262,16906,583],{"emptyLinePlaceholder":582},[262,16908,16909],{"class":181,"line":1688},[262,16910,583],{"emptyLinePlaceholder":582},[262,16912,16913,16915,16918,16921,16923,16925,16927],{"class":181,"line":1693},[262,16914,423],{"class":377},[262,16916,16917],{"class":267}," publish",[262,16919,16920],{"class":429},"(post: Post, sent: set[",[262,16922,433],{"class":271},[262,16924,13681],{"class":429},[262,16926,8471],{"class":271},[262,16928,1160],{"class":429},[262,16930,16931,16933,16936,16938],{"class":181,"line":1728},[262,16932,3454],{"class":377},[262,16934,16935],{"class":429}," post.id ",[262,16937,835],{"class":377},[262,16939,16940],{"class":429}," sent:\n",[262,16942,16943,16945,16947,16949,16952,16954,16957,16959,16961],{"class":181,"line":1737},[262,16944,2299],{"class":271},[262,16946,602],{"class":429},[262,16948,642],{"class":377},[262,16950,16951],{"class":275},"\"Skipping already-sent post ",[262,16953,3039],{"class":271},[262,16955,16956],{"class":429},"post.id",[262,16958,654],{"class":271},[262,16960,1176],{"class":275},[262,16962,660],{"class":429},[262,16964,16965],{"class":181,"line":1751},[262,16966,16967],{"class":377},"        return\n",[262,16969,16970,16973,16975,16977,16979,16982,16984],{"class":181,"line":1764},[262,16971,16972],{"class":429},"    payload ",[262,16974,476],{"class":377},[262,16976,2276],{"class":429},[262,16978,16074],{"class":275},[262,16980,16981],{"class":429},": post.text, ",[262,16983,16104],{"class":275},[262,16985,16986],{"class":429},": post.platform}\n",[262,16988,16989,16992,16994,16996,16999,17001,17003,17005,17008,17010],{"class":181,"line":1779},[262,16990,16991],{"class":429},"    headers ",[262,16993,476],{"class":377},[262,16995,2276],{"class":429},[262,16997,16998],{"class":275},"\"Authorization\"",[262,17000,1231],{"class":429},[262,17002,642],{"class":377},[262,17004,6605],{"class":275},[262,17006,17007],{"class":271},"{API_TOKEN}",[262,17009,1176],{"class":275},[262,17011,16430],{"class":429},[262,17013,17014,17016,17019,17021,17023,17026,17028,17030],{"class":181,"line":1793},[262,17015,10124],{"class":377},[262,17017,17018],{"class":429}," httpx.Client(",[262,17020,1591],{"class":611},[262,17022,476],{"class":377},[262,17024,17025],{"class":271},"15",[262,17027,1000],{"class":429},[262,17029,697],{"class":377},[262,17031,17032],{"class":429}," http:\n",[262,17034,17035,17038,17040,17043,17045,17047,17050,17052,17055,17058,17060],{"class":181,"line":1800},[262,17036,17037],{"class":429},"        resp ",[262,17039,476],{"class":377},[262,17041,17042],{"class":429}," http.post(",[262,17044,16759],{"class":271},[262,17046,608],{"class":429},[262,17048,17049],{"class":611},"json",[262,17051,476],{"class":377},[262,17053,17054],{"class":429},"payload, ",[262,17056,17057],{"class":611},"headers",[262,17059,476],{"class":377},[262,17061,17062],{"class":429},"headers)\n",[262,17064,17065],{"class":181,"line":1805},[262,17066,17067],{"class":429},"        resp.raise_for_status()\n",[262,17069,17070],{"class":181,"line":1810},[262,17071,17072],{"class":429},"    mark_sent(post.id, sent)\n",[262,17074,17075,17077,17079,17081,17084,17086,17088,17090,17092,17094,17097,17099,17101],{"class":181,"line":1823},[262,17076,1089],{"class":271},[262,17078,602],{"class":429},[262,17080,642],{"class":377},[262,17082,17083],{"class":275},"\"Published post ",[262,17085,3039],{"class":271},[262,17087,16956],{"class":429},[262,17089,654],{"class":271},[262,17091,3921],{"class":275},[262,17093,3039],{"class":271},[262,17095,17096],{"class":429},"post.platform",[262,17098,654],{"class":271},[262,17100,1176],{"class":275},[262,17102,660],{"class":429},[14,17104,3349,17105,17108,17109,17111,17112,1374,17114,17117],{},[18,17106,17107],{},"sent_ids.json"," file is your safety net: if the script crashes or you run it again, posts already delivered are skipped. ",[18,17110,6778],{}," turns any failed HTTP response into an exception so a rejected post never gets marked sent. Swap the ",[18,17113,16759],{},[18,17115,17116],{},"payload"," shape to match your real platform; the structure stays the same.",[57,17119,17121],{"id":17120},"step-4-schedule-everything-with-apscheduler","Step 4: Schedule everything with APScheduler",[14,17123,17124,17125,17128,17129,17132],{},"Now connect the pieces. ",[18,17126,17127],{},"APScheduler"," (Advanced Python Scheduler) holds a job for each post and fires it at its ",[18,17130,17131],{},"publish_at"," time. A persistent job store written to SQLite means posts survive a restart, so a job due while the script was briefly down still runs.",[253,17134,17136],{"className":414,"code":17135,"language":416,"meta":258,"style":258},"from apscheduler.schedulers.blocking import BlockingScheduler\nfrom apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore\n\n\ndef schedule_all(path: str) -> None:\n    posts, errors = load_posts(path)\n    for err in errors:\n        print(\"Skipped:\", err)\n    apply_cleanup(posts)\n    sent = load_sent()\n\n    scheduler = BlockingScheduler(\n        jobstores={\"default\": SQLAlchemyJobStore(url=\"sqlite:\u002F\u002F\u002Fjobs.sqlite\")}\n    )\n    now = datetime.now()\n    for post in posts:\n        if post.publish_at \u003C= now:\n            publish(post, sent)  # already due, send immediately\n            continue\n        scheduler.add_job(\n            publish,\n            \"date\",\n            run_date=post.publish_at,\n            args=[post, sent],\n            id=f\"post-{post.id}\",\n            replace_existing=True,\n            misfire_grace_time=3600,\n        )\n    print(f\"Scheduled {len(scheduler.get_jobs())} posts. Press Ctrl+C to stop.\")\n    scheduler.start()\n\n\nif __name__ == \"__main__\":\n    schedule_all(\"posts.csv\")\n",[18,17137,17138,17150,17162,17166,17170,17187,17196,17206,17217,17222,17232,17236,17246,17272,17276,17286,17296,17308,17316,17321,17326,17331,17338,17348,17358,17380,17391,17403,17407,17430,17435,17439,17443,17455],{"__ignoreMap":258},[262,17139,17140,17142,17145,17147],{"class":181,"line":264},[262,17141,705],{"class":377},[262,17143,17144],{"class":429}," apscheduler.schedulers.blocking ",[262,17146,684],{"class":377},[262,17148,17149],{"class":429}," BlockingScheduler\n",[262,17151,17152,17154,17157,17159],{"class":181,"line":282},[262,17153,705],{"class":377},[262,17155,17156],{"class":429}," apscheduler.jobstores.sqlalchemy ",[262,17158,684],{"class":377},[262,17160,17161],{"class":429}," SQLAlchemyJobStore\n",[262,17163,17164],{"class":181,"line":295},[262,17165,583],{"emptyLinePlaceholder":582},[262,17167,17168],{"class":181,"line":345},[262,17169,583],{"emptyLinePlaceholder":582},[262,17171,17172,17174,17177,17179,17181,17183,17185],{"class":181,"line":492},[262,17173,423],{"class":377},[262,17175,17176],{"class":267}," schedule_all",[262,17178,15950],{"class":429},[262,17180,433],{"class":271},[262,17182,1939],{"class":429},[262,17184,8471],{"class":271},[262,17186,1160],{"class":429},[262,17188,17189,17191,17193],{"class":181,"line":503},[262,17190,16243],{"class":429},[262,17192,476],{"class":377},[262,17194,17195],{"class":429}," load_posts(path)\n",[262,17197,17198,17200,17202,17204],{"class":181,"line":521},[262,17199,3074],{"class":377},[262,17201,16292],{"class":429},[262,17203,835],{"class":377},[262,17205,16297],{"class":429},[262,17207,17208,17210,17212,17215],{"class":181,"line":537},[262,17209,2299],{"class":271},[262,17211,602],{"class":429},[262,17213,17214],{"class":275},"\"Skipped:\"",[262,17216,16309],{"class":429},[262,17218,17219],{"class":181,"line":549},[262,17220,17221],{"class":429},"    apply_cleanup(posts)\n",[262,17223,17224,17227,17229],{"class":181,"line":570},[262,17225,17226],{"class":429},"    sent ",[262,17228,476],{"class":377},[262,17230,17231],{"class":429}," load_sent()\n",[262,17233,17234],{"class":181,"line":579},[262,17235,583],{"emptyLinePlaceholder":582},[262,17237,17238,17241,17243],{"class":181,"line":586},[262,17239,17240],{"class":429},"    scheduler ",[262,17242,476],{"class":377},[262,17244,17245],{"class":429}," BlockingScheduler(\n",[262,17247,17248,17251,17253,17255,17258,17261,17264,17266,17269],{"class":181,"line":591},[262,17249,17250],{"class":611},"        jobstores",[262,17252,476],{"class":377},[262,17254,3039],{"class":429},[262,17256,17257],{"class":275},"\"default\"",[262,17259,17260],{"class":429},": SQLAlchemyJobStore(",[262,17262,17263],{"class":611},"url",[262,17265,476],{"class":377},[262,17267,17268],{"class":275},"\"sqlite:\u002F\u002F\u002Fjobs.sqlite\"",[262,17270,17271],{"class":429},")}\n",[262,17273,17274],{"class":181,"line":623},[262,17275,1011],{"class":429},[262,17277,17278,17281,17283],{"class":181,"line":634},[262,17279,17280],{"class":429},"    now ",[262,17282,476],{"class":377},[262,17284,17285],{"class":429}," datetime.now()\n",[262,17287,17288,17290,17292,17294],{"class":181,"line":845},[262,17289,3074],{"class":377},[262,17291,16668],{"class":429},[262,17293,835],{"class":377},[262,17295,16673],{"class":429},[262,17297,17298,17300,17303,17305],{"class":181,"line":850},[262,17299,2268],{"class":377},[262,17301,17302],{"class":429}," post.publish_at ",[262,17304,8983],{"class":377},[262,17306,17307],{"class":429}," now:\n",[262,17309,17310,17313],{"class":181,"line":864},[262,17311,17312],{"class":429},"            publish(post, sent)  ",[262,17314,17315],{"class":291},"# already due, send immediately\n",[262,17317,17318],{"class":181,"line":1683},[262,17319,17320],{"class":377},"            continue\n",[262,17322,17323],{"class":181,"line":1688},[262,17324,17325],{"class":429},"        scheduler.add_job(\n",[262,17327,17328],{"class":181,"line":1693},[262,17329,17330],{"class":429},"            publish,\n",[262,17332,17333,17336],{"class":181,"line":1728},[262,17334,17335],{"class":275},"            \"date\"",[262,17337,1315],{"class":429},[262,17339,17340,17343,17345],{"class":181,"line":1737},[262,17341,17342],{"class":611},"            run_date",[262,17344,476],{"class":377},[262,17346,17347],{"class":429},"post.publish_at,\n",[262,17349,17350,17353,17355],{"class":181,"line":1751},[262,17351,17352],{"class":611},"            args",[262,17354,476],{"class":377},[262,17356,17357],{"class":429},"[post, sent],\n",[262,17359,17360,17363,17365,17367,17370,17372,17374,17376,17378],{"class":181,"line":1764},[262,17361,17362],{"class":611},"            id",[262,17364,476],{"class":377},[262,17366,642],{"class":377},[262,17368,17369],{"class":275},"\"post-",[262,17371,3039],{"class":271},[262,17373,16956],{"class":429},[262,17375,654],{"class":271},[262,17377,1176],{"class":275},[262,17379,1315],{"class":429},[262,17381,17382,17385,17387,17389],{"class":181,"line":1779},[262,17383,17384],{"class":611},"            replace_existing",[262,17386,476],{"class":377},[262,17388,4974],{"class":271},[262,17390,1315],{"class":429},[262,17392,17393,17396,17398,17401],{"class":181,"line":1793},[262,17394,17395],{"class":611},"            misfire_grace_time",[262,17397,476],{"class":377},[262,17399,17400],{"class":271},"3600",[262,17402,1315],{"class":429},[262,17404,17405],{"class":181,"line":1800},[262,17406,6288],{"class":429},[262,17408,17409,17411,17413,17415,17418,17420,17423,17425,17428],{"class":181,"line":1805},[262,17410,1089],{"class":271},[262,17412,602],{"class":429},[262,17414,642],{"class":377},[262,17416,17417],{"class":275},"\"Scheduled ",[262,17419,648],{"class":271},[262,17421,17422],{"class":429},"(scheduler.get_jobs())",[262,17424,654],{"class":271},[262,17426,17427],{"class":275}," posts. Press Ctrl+C to stop.\"",[262,17429,660],{"class":429},[262,17431,17432],{"class":181,"line":1810},[262,17433,17434],{"class":429},"    scheduler.start()\n",[262,17436,17437],{"class":181,"line":1823},[262,17438,583],{"emptyLinePlaceholder":582},[262,17440,17441],{"class":181,"line":1846},[262,17442,583],{"emptyLinePlaceholder":582},[262,17444,17445,17447,17449,17451,17453],{"class":181,"line":1861},[262,17446,2210],{"class":377},[262,17448,2213],{"class":271},[262,17450,2216],{"class":377},[262,17452,2219],{"class":275},[262,17454,1160],{"class":429},[262,17456,17457,17460,17462],{"class":181,"line":1866},[262,17458,17459],{"class":429},"    schedule_all(",[262,17461,16251],{"class":275},[262,17463,660],{"class":429},[14,17465,3349,17466,17469,17470,17473,17474,17477,17478,17480],{},[18,17467,17468],{},"\"date\""," trigger fires a job once at a fixed moment. ",[18,17471,17472],{},"misfire_grace_time=3600"," tells APScheduler that a post fired up to an hour late is still acceptable rather than dropped — useful if the machine was asleep. ",[18,17475,17476],{},"replace_existing=True"," plus the stable ",[18,17479,9492],{}," means re-running the script updates jobs instead of duplicating them. Posts already past their time are sent on the spot so a late start never silently swallows them.",[57,17482,17484],{"id":17483},"quick-reference-key-parameters","Quick reference: key parameters",[1379,17486,17487,17499],{},[1382,17488,17489],{},[1385,17490,17491,17493,17495,17497],{},[1388,17492,1390],{},[1388,17494,3795],{},[1388,17496,3798],{},[1388,17498,1396],{},[1398,17500,17501,17520,17537,17556,17571],{},[1385,17502,17503,17508,17511,17514],{},[1403,17504,17505],{},[18,17506,17507],{},"run_date",[1403,17509,17510],{},"datetime",[1403,17512,17513],{},"required",[1403,17515,17516,17517,17519],{},"Exact moment the ",[18,17518,17468],{}," job fires the post",[1385,17521,17522,17527,17530,17534],{},[1403,17523,17524],{},[18,17525,17526],{},"misfire_grace_time",[1403,17528,17529],{},"int (seconds)",[1403,17531,17532],{},[18,17533,997],{},[1403,17535,17536],{},"How late a job may fire before it is skipped",[1385,17538,17539,17544,17546,17550],{},[1403,17540,17541],{},[18,17542,17543],{},"replace_existing",[1403,17545,8045],{},[1403,17547,17548],{},[18,17549,3623],{},[1403,17551,17552,17553,17555],{},"If ",[18,17554,4974],{},", re-adding a job id updates instead of erroring",[1385,17557,17558,17563,17566,17568],{},[1403,17559,17560,17562],{},[18,17561,1591],{}," (httpx)",[1403,17564,17565],{},"int\u002Ffloat",[1403,17567,219],{},[1403,17569,17570],{},"Seconds before an API request is abandoned",[1385,17572,17573,17577,17579,17584],{},[1403,17574,17575],{},[18,17576,3829],{},[1403,17578,3832],{},[1403,17580,17581],{},[18,17582,17583],{},"1.0",[1403,17585,17586],{},"Lower values make LLM rewrites more predictable",[57,17588,1445],{"id":1444},[1447,17590,17591,17599,17615,17637],{},[1450,17592,17593,10934,17596,17598],{},[35,17594,17595],{},"Posts fire instantly instead of at the scheduled time.",[18,17597,17131],{}," values parsed into times in the past, so the \"already due\" branch sends them. Check that the CSV uses future ISO 8601 datetimes and that your machine's clock and time zone are correct.",[1450,17600,17601,17606,17607,17610,17611,17614],{},[35,17602,17603,1363],{},[18,17604,17605],{},"ModuleNotFoundError: No module named 'sqlalchemy'"," The SQLAlchemy job store needs SQLAlchemy installed. Run ",[18,17608,17609],{},"pip install sqlalchemy",", or drop the ",[18,17612,17613],{},"jobstores"," argument to use the default in-memory store while testing.",[1450,17616,17617,17622,17623,17626,17627,17629,17630,17633,17634,17636],{},[35,17618,17619,1363],{},[18,17620,17621],{},"httpx.HTTPStatusError: 401 Unauthorized"," The platform token is missing or wrong. Confirm ",[18,17624,17625],{},"SOCIAL_API_TOKEN"," is loaded from ",[18,17628,319],{}," and that the ",[18,17631,17632],{},"Authorization"," header matches the scheme your API expects. See ",[51,17635,388],{"href":387}," for the same debugging pattern.",[1450,17638,17639,17642,17643,17645],{},[35,17640,17641],{},"The LLM returns text longer than the limit."," Models occasionally ignore length rules, which is why the ",[18,17644,16707],{}," slice exists as a hard cut. If truncation breaks sentences, lower the requested limit in the prompt by a small margin so the model leaves room.",[57,17647,2317],{"id":2316},[2322,17649,17650,17656,17662],{},[1450,17651,17652,17655],{},[35,17653,17654],{},"Self-hosted Python scheduler (this guide):"," Best when you want full control, a free pipeline, custom logic like LLM cleanup, and posting to niche or multiple platforms from one CSV. The trade-off is that you run and monitor the process yourself — keep it alive as a service or a cron-launched job, and watch the logs.",[1450,17657,17658,17661],{},[35,17659,17660],{},"A SaaS scheduler (Buffer, Hootsuite, Later):"," Best when you want a calendar UI for non-technical teammates, guaranteed uptime, and built-in analytics without maintaining anything. The trade-off is a monthly fee, fixed platform support, and little room for custom AI steps in the middle of the flow.",[1450,17663,17664,17667],{},[35,17665,17666],{},"A hybrid approach:"," Use this script to generate and clean copy in bulk, then export the polished rows to a SaaS tool's import format. You get AI-assisted writing plus a managed publishing layer, at the cost of moving a file between two systems.",[57,17669,2381],{"id":2380},[2322,17671,17672,17677,17682,17689],{},[1450,17673,17674,17676],{},[51,17675,9309],{"href":9308}," — the main guide this page sits under.",[1450,17678,17679,17681],{},[51,17680,15735],{"href":15734}," — the single-platform version with Meta's Graph API.",[1450,17683,17684,17688],{},[51,17685,17687],{"href":17686},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fgenerate-twitter-threads-with-python-and-ai\u002F","Generate Twitter Threads with Python and AI"," — turn one idea into a multi-post thread before scheduling.",[1450,17690,17691,17693],{},[51,17692,3991],{"href":3990}," — deeper techniques for the copy-cleanup step.",[14,17695,2375,17696,1363],{},[51,17697,9309],{"href":9308},[2401,17699,12667],{},{"title":258,"searchDepth":282,"depth":282,"links":17701},[17702,17703,17704,17705,17706,17707,17708,17709,17710],{"id":237,"depth":282,"text":238},{"id":15839,"depth":282,"text":15840},{"id":16321,"depth":282,"text":16322},{"id":16713,"depth":282,"text":16714},{"id":17120,"depth":282,"text":17121},{"id":17483,"depth":282,"text":17484},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Read a CSV of social posts, clean copy with an LLM, and bulk-schedule them with Python using APScheduler and httpx — no SaaS scheduler needed.",[17713,17716,17719,17722,17725],{"q":17714,"a":17715},"Can I schedule posts for different platforms from one CSV?","Yes. Add a platform column to each row and route each post to the matching API client at send time. One CSV can drive Twitter, LinkedIn, and Mastodon in the same run as long as you store one set of credentials per platform.",{"q":17717,"a":17718},"Do I need a paid scheduling tool like Buffer or Hootsuite?","No. A small Python script with APScheduler and httpx can read your CSV, hold posts in a queue, and fire each one at its scheduled time. You only pay for the LLM calls used to clean copy, which is usually a few cents per batch.",{"q":17720,"a":17721},"What happens if my script is not running when a post is due?","If you use an in-memory scheduler, a post due while the script is off is missed. Use APScheduler with a SQLAlchemy job store, or run the script as a service that restarts automatically, so due posts fire on the next start.",{"q":17723,"a":17724},"How do I avoid posting the same row twice?","Give every row a stable id and record sent ids to a small status file or database. Before sending, skip any row whose id is already marked sent. This makes re-running the script safe after a crash.",{"q":17726,"a":17727},"Why use httpx instead of requests for posting?","httpx supports both synchronous and asynchronous calls with the same API, has built-in timeouts, and handles connection pooling cleanly. That matters when you fire many posts in a batch and want to add async later without rewriting your client.",{"name":17729,"steps":17730},"How to bulk-schedule social posts with Python",[17731,17734,17737,17740],{"name":17732,"text":17733},"Read and validate the CSV of posts","Load each row's text, datetime, and platform, then validate types and skip malformed rows.",{"name":17735,"text":17736},"Clean or generate copy with an LLM","Send rows that need rewriting to the OpenAI API and keep good rows untouched to save tokens.",{"name":17738,"text":17739},"Build a schedule queue","Turn each validated row into a job with a fire time and a stable id for de-duplication.",{"name":17741,"text":17742},"Schedule and post with APScheduler and httpx","Register each job with APScheduler and post to the platform API with httpx when it fires.",{},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fbulk-schedule-social-posts-with-python",{"title":15717,"description":17711},"ai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fbulk-schedule-social-posts-with-python\u002Findex","puxETxbohqImNstrA4s4eZ07HVXZKstgDk9TXVZlGO4",{"id":17749,"title":17687,"body":17750,"description":19760,"extension":2419,"faq":19761,"howto":19777,"meta":19792,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":19793,"published":2452,"seo":19794,"seoTitle":17687,"stem":19795,"__hash__":19796},"content\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fgenerate-twitter-threads-with-python-and-ai\u002Findex.md",{"type":7,"value":17751,"toc":19747},[17752,17755,17758,17764,17766,17772,17807,17811,17823,17839,17844,17859,17866,17880,17884,17894,18187,18198,18202,18205,18372,18382,18386,18401,18787,18798,18802,18805,18869,18873,18876,19621,19623,19680,19682,19711,19713,19721,19723,19744],[10,17753,17687],{"id":17754},"generate-twitter-threads-with-python-and-ai",[14,17756,17757],{},"This guide shows you how to turn a topic or an article into a clean, numbered X\u002FTwitter thread with Python in under fifteen minutes. You hand the script a subject, it drafts a hook, the supporting posts, and a closing call to action (the \"do this next\" line at the end), enforces a character limit on every post so none get rejected, and either prints the thread for you to copy or publishes it for you.",[14,17759,17760,17761,17763],{},"Writing threads by hand is slow, and pasting a topic into a chat window gives you a wall of text you still have to chop into posts. A small script fixes both problems and slots neatly into your wider ",[51,17762,9309],{"href":9308}," routine.",[57,17765,238],{"id":237},[14,17767,17768,17769,17771],{},"You only need a few things beyond a working Python install. If you have not set Python up yet, start with ",[51,17770,5423],{"href":5422}," and come back here.",[2322,17773,17774,17783,17792,17801],{},[1450,17775,17776,17779,17780,1363],{},[35,17777,17778],{},"Python 3.10 or newer."," Check with ",[18,17781,17782],{},"python --version",[1450,17784,17785,17788,17789,17791],{},[35,17786,17787],{},"An OpenAI API key."," If the key step is new to you, the parent track ",[51,17790,2487],{"href":2486}," walks through getting and testing one.",[1450,17793,17794,17797,17798,17800],{},[35,17795,17796],{},"A virtual environment"," so this project's packages stay isolated. See ",[51,17799,2482],{"href":2481}," if you have not made one before.",[1450,17802,17803,17806],{},[35,17804,17805],{},"An X API key and access token"," — only if you want Step 4 to publish automatically. You can skip this and copy the thread by hand.",[57,17808,17810],{"id":17809},"_1-install-dependencies-and-set-up-credentials","1. Install dependencies and set up credentials",[14,17812,17813,17814,17816,17817,17819,17820,17822],{},"Activate your virtual environment, then install the two libraries this script uses. We prefer ",[18,17815,5450],{}," over the older ",[18,17818,9433],{}," library because it handles modern HTTP cleanly and is the library the ",[18,17821,20],{}," SDK already depends on.",[253,17824,17825],{"className":255,"code":5427,"language":257,"meta":258,"style":258},[18,17826,17827],{"__ignoreMap":258},[262,17828,17829,17831,17833,17835,17837],{"class":181,"line":264},[262,17830,298],{"class":267},[262,17832,301],{"class":275},[262,17834,2519],{"class":275},[262,17836,5440],{"class":275},[262,17838,2522],{"class":275},[14,17840,2525,17841,17843],{},[18,17842,319],{}," in your project folder and add your keys. The X values are only needed for auto-posting in Step 4.",[253,17845,17847],{"className":323,"code":17846,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-openai-key\nX_BEARER_TOKEN=your-x-oauth2-user-token\n",[18,17848,17849,17854],{"__ignoreMap":258},[262,17850,17851],{"class":181,"line":264},[262,17852,17853],{},"OPENAI_API_KEY=sk-your-openai-key\n",[262,17855,17856],{"class":181,"line":282},[262,17857,17858],{},"X_BEARER_TOKEN=your-x-oauth2-user-token\n",[14,17860,353,17861,356,17863,17865],{},[18,17862,319],{},[18,17864,359],{}," file immediately so you never commit your secret keys to version control.",[253,17867,17868],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,17869,17870],{"__ignoreMap":258},[262,17871,17872,17874,17876,17878],{"class":181,"line":264},[262,17873,371],{"class":271},[262,17875,374],{"class":275},[262,17877,378],{"class":377},[262,17879,381],{"class":275},[57,17881,17883],{"id":17882},"_2-generate-the-thread-with-the-openai-sdk","2. Generate the thread with the OpenAI SDK",[14,17885,17886,17887,17889,17890,17893],{},"Now ask the model to draft the thread. The key trick is the ",[35,17888,9496],{}," (the instructions you send the model): we ask for a numbered list of short posts, name the hook and call to action explicitly, and request plain JSON so Python can read the result reliably. We do ",[27,17891,17892],{},"not"," trust the model to count characters — that comes in Step 3.",[253,17895,17897],{"className":414,"code":17896,"language":416,"meta":258,"style":258},"import os\nimport json\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef draft_thread(topic: str, target_posts: int = 7, limit: int = 280) -> list[str]:\n    prompt = f\"\"\"Write an engaging X\u002FTwitter thread about: \"{topic}\".\n\nRules:\n- Produce about {target_posts} short posts.\n- Post 1 is a scroll-stopping HOOK with a bold claim or question.\n- Each middle post makes exactly one clear point.\n- The final post is a call to action (ask readers to follow, reply, or share).\n- Keep every post under {limit} characters. Do not number them yourself.\n- No hashtags except at most one in the final post.\n\nReturn ONLY valid JSON: {{\"posts\": [\"post one\", \"post two\", ...]}}\"\"\"\n\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        response_format={\"type\": \"json_object\"},\n    )\n    data = json.loads(response.choices[0].message.content)\n    return data[\"posts\"]\n",[18,17898,17899,17905,17911,17921,17931,17935,17939,17957,17961,17965,18002,18023,18027,18032,18047,18052,18057,18062,18076,18081,18085,18099,18103,18111,18121,18142,18158,18162,18175],{"__ignoreMap":258},[262,17900,17901,17903],{"class":181,"line":264},[262,17902,684],{"class":377},[262,17904,687],{"class":429},[262,17906,17907,17909],{"class":181,"line":282},[262,17908,684],{"class":377},[262,17910,5766],{"class":429},[262,17912,17913,17915,17917,17919],{"class":181,"line":295},[262,17914,705],{"class":377},[262,17916,720],{"class":429},[262,17918,684],{"class":377},[262,17920,725],{"class":429},[262,17922,17923,17925,17927,17929],{"class":181,"line":345},[262,17924,705],{"class":377},[262,17926,708],{"class":429},[262,17928,684],{"class":377},[262,17930,713],{"class":429},[262,17932,17933],{"class":181,"line":492},[262,17934,583],{"emptyLinePlaceholder":582},[262,17936,17937],{"class":181,"line":503},[262,17938,734],{"class":429},[262,17940,17941,17943,17945,17947,17949,17951,17953,17955],{"class":181,"line":521},[262,17942,739],{"class":429},[262,17944,476],{"class":377},[262,17946,1588],{"class":429},[262,17948,2674],{"class":611},[262,17950,476],{"class":377},[262,17952,1199],{"class":429},[262,17954,2681],{"class":275},[262,17956,2684],{"class":429},[262,17958,17959],{"class":181,"line":537},[262,17960,583],{"emptyLinePlaceholder":582},[262,17962,17963],{"class":181,"line":549},[262,17964,583],{"emptyLinePlaceholder":582},[262,17966,17967,17969,17972,17974,17976,17979,17981,17983,17986,17989,17991,17993,17996,17998,18000],{"class":181,"line":570},[262,17968,423],{"class":377},[262,17970,17971],{"class":267}," draft_thread",[262,17973,4287],{"class":429},[262,17975,433],{"class":271},[262,17977,17978],{"class":429},", target_posts: ",[262,17980,439],{"class":271},[262,17982,442],{"class":377},[262,17984,17985],{"class":271}," 7",[262,17987,17988],{"class":429},", limit: ",[262,17990,439],{"class":271},[262,17992,442],{"class":377},[262,17994,17995],{"class":271}," 280",[262,17997,458],{"class":429},[262,17999,433],{"class":271},[262,18001,463],{"class":429},[262,18003,18004,18007,18009,18011,18014,18016,18018,18020],{"class":181,"line":579},[262,18005,18006],{"class":429},"    prompt ",[262,18008,476],{"class":377},[262,18010,10178],{"class":377},[262,18012,18013],{"class":275},"\"\"\"Write an engaging X\u002FTwitter thread about: \"",[262,18015,3039],{"class":271},[262,18017,4402],{"class":429},[262,18019,654],{"class":271},[262,18021,18022],{"class":275},"\".\n",[262,18024,18025],{"class":181,"line":586},[262,18026,583],{"emptyLinePlaceholder":582},[262,18028,18029],{"class":181,"line":591},[262,18030,18031],{"class":275},"Rules:\n",[262,18033,18034,18037,18039,18042,18044],{"class":181,"line":623},[262,18035,18036],{"class":275},"- Produce about ",[262,18038,3039],{"class":271},[262,18040,18041],{"class":429},"target_posts",[262,18043,654],{"class":271},[262,18045,18046],{"class":275}," short posts.\n",[262,18048,18049],{"class":181,"line":634},[262,18050,18051],{"class":275},"- Post 1 is a scroll-stopping HOOK with a bold claim or question.\n",[262,18053,18054],{"class":181,"line":845},[262,18055,18056],{"class":275},"- Each middle post makes exactly one clear point.\n",[262,18058,18059],{"class":181,"line":850},[262,18060,18061],{"class":275},"- The final post is a call to action (ask readers to follow, reply, or share).\n",[262,18063,18064,18067,18069,18071,18073],{"class":181,"line":864},[262,18065,18066],{"class":275},"- Keep every post under ",[262,18068,3039],{"class":271},[262,18070,16586],{"class":429},[262,18072,654],{"class":271},[262,18074,18075],{"class":275}," characters. Do not number them yourself.\n",[262,18077,18078],{"class":181,"line":1683},[262,18079,18080],{"class":275},"- No hashtags except at most one in the final post.\n",[262,18082,18083],{"class":181,"line":1688},[262,18084,583],{"emptyLinePlaceholder":582},[262,18086,18087,18090,18092,18095,18097],{"class":181,"line":1693},[262,18088,18089],{"class":275},"Return ONLY valid JSON: ",[262,18091,6355],{"class":271},[262,18093,18094],{"class":275},"\"posts\": [\"post one\", \"post two\", ...]",[262,18096,6361],{"class":271},[262,18098,1680],{"class":275},[262,18100,18101],{"class":181,"line":1728},[262,18102,583],{"emptyLinePlaceholder":582},[262,18104,18105,18107,18109],{"class":181,"line":1737},[262,18106,1184],{"class":429},[262,18108,476],{"class":377},[262,18110,1189],{"class":429},[262,18112,18113,18115,18117,18119],{"class":181,"line":1751},[262,18114,1194],{"class":611},[262,18116,476],{"class":377},[262,18118,1207],{"class":275},[262,18120,1315],{"class":429},[262,18122,18123,18125,18127,18129,18131,18133,18135,18137,18139],{"class":181,"line":1764},[262,18124,1215],{"class":611},[262,18126,476],{"class":377},[262,18128,8856],{"class":429},[262,18130,1228],{"class":275},[262,18132,1231],{"class":429},[262,18134,1291],{"class":275},[262,18136,608],{"class":429},[262,18138,1239],{"class":275},[262,18140,18141],{"class":429},": prompt}],\n",[262,18143,18144,18146,18148,18150,18152,18154,18156],{"class":181,"line":1779},[262,18145,6018],{"class":611},[262,18147,476],{"class":377},[262,18149,3039],{"class":429},[262,18151,6025],{"class":275},[262,18153,1231],{"class":429},[262,18155,6030],{"class":275},[262,18157,3143],{"class":429},[262,18159,18160],{"class":181,"line":1793},[262,18161,1011],{"class":429},[262,18163,18164,18167,18169,18171,18173],{"class":181,"line":1800},[262,18165,18166],{"class":429},"    data ",[262,18168,476],{"class":377},[262,18170,6043],{"class":429},[262,18172,102],{"class":271},[262,18174,6048],{"class":429},[262,18176,18177,18179,18182,18185],{"class":181,"line":1805},[262,18178,573],{"class":377},[262,18180,18181],{"class":429}," data[",[262,18183,18184],{"class":275},"\"posts\"",[262,18186,957],{"class":429},[14,18188,18189,18190,18192,18193,18195,18196,1363],{},"Setting ",[18,18191,6878],{}," tells the model to return strictly valid JSON, which stops the script crashing on stray text. If you want to feed in a full article instead of a short topic, just pass the article text as the ",[18,18194,4402],{}," argument — the same prompt summarizes it into a thread. For deeper control over output shape, see ",[51,18197,1362],{"href":1361},[57,18199,18201],{"id":18200},"_3-validate-and-enforce-per-tweet-character-limits","3. Validate and enforce per-tweet character limits",[14,18203,18204],{},"Language models cannot count characters reliably, so the model's \"under 280\" promise is only a suggestion. Python is the source of truth. This step walks every post, and any that overshoots your limit is split on a sentence or word boundary so no tweet is ever rejected by X.",[253,18206,18208],{"className":414,"code":18207,"language":416,"meta":258,"style":258},"def enforce_limit(posts: list[str], limit: int = 280) -> list[str]:\n    cleaned: list[str] = []\n    for post in posts:\n        text = post.strip()\n        while len(text) > limit:\n            # Find the last space before the limit to avoid cutting a word.\n            cut = text.rfind(\" \", 0, limit)\n            if cut == -1:  # one very long word; hard cut.\n                cut = limit\n            cleaned.append(text[:cut].strip())\n            text = text[cut:].strip()\n        if text:\n            cleaned.append(text)\n    return cleaned\n",[18,18209,18210,18237,18250,18260,18270,18284,18289,18308,18328,18338,18343,18353,18360,18365],{"__ignoreMap":258},[262,18211,18212,18214,18217,18220,18222,18225,18227,18229,18231,18233,18235],{"class":181,"line":264},[262,18213,423],{"class":377},[262,18215,18216],{"class":267}," enforce_limit",[262,18218,18219],{"class":429},"(posts: list[",[262,18221,433],{"class":271},[262,18223,18224],{"class":429},"], limit: ",[262,18226,439],{"class":271},[262,18228,442],{"class":377},[262,18230,17995],{"class":271},[262,18232,458],{"class":429},[262,18234,433],{"class":271},[262,18236,463],{"class":429},[262,18238,18239,18242,18244,18246,18248],{"class":181,"line":282},[262,18240,18241],{"class":429},"    cleaned: list[",[262,18243,433],{"class":271},[262,18245,2903],{"class":429},[262,18247,476],{"class":377},[262,18249,489],{"class":429},[262,18251,18252,18254,18256,18258],{"class":181,"line":295},[262,18253,3074],{"class":377},[262,18255,16668],{"class":429},[262,18257,835],{"class":377},[262,18259,16673],{"class":429},[262,18261,18262,18265,18267],{"class":181,"line":345},[262,18263,18264],{"class":429},"        text ",[262,18266,476],{"class":377},[262,18268,18269],{"class":429}," post.strip()\n",[262,18271,18272,18275,18277,18279,18281],{"class":181,"line":492},[262,18273,18274],{"class":377},"        while",[262,18276,515],{"class":271},[262,18278,8109],{"class":429},[262,18280,8086],{"class":377},[262,18282,18283],{"class":429}," limit:\n",[262,18285,18286],{"class":181,"line":503},[262,18287,18288],{"class":291},"            # Find the last space before the limit to avoid cutting a word.\n",[262,18290,18291,18294,18296,18299,18301,18303,18305],{"class":181,"line":521},[262,18292,18293],{"class":429},"            cut ",[262,18295,476],{"class":377},[262,18297,18298],{"class":429}," text.rfind(",[262,18300,543],{"class":275},[262,18302,608],{"class":429},[262,18304,102],{"class":271},[262,18306,18307],{"class":429},", limit)\n",[262,18309,18310,18312,18315,18317,18320,18322,18325],{"class":181,"line":537},[262,18311,10200],{"class":377},[262,18313,18314],{"class":429}," cut ",[262,18316,10758],{"class":377},[262,18318,18319],{"class":377}," -",[262,18321,997],{"class":271},[262,18323,18324],{"class":429},":  ",[262,18326,18327],{"class":291},"# one very long word; hard cut.\n",[262,18329,18330,18333,18335],{"class":181,"line":549},[262,18331,18332],{"class":429},"                cut ",[262,18334,476],{"class":377},[262,18336,18337],{"class":429}," limit\n",[262,18339,18340],{"class":181,"line":570},[262,18341,18342],{"class":429},"            cleaned.append(text[:cut].strip())\n",[262,18344,18345,18348,18350],{"class":181,"line":579},[262,18346,18347],{"class":429},"            text ",[262,18349,476],{"class":377},[262,18351,18352],{"class":429}," text[cut:].strip()\n",[262,18354,18355,18357],{"class":181,"line":586},[262,18356,2268],{"class":377},[262,18358,18359],{"class":429}," text:\n",[262,18361,18362],{"class":181,"line":591},[262,18363,18364],{"class":429},"            cleaned.append(text)\n",[262,18366,18367,18369],{"class":181,"line":623},[262,18368,573],{"class":377},[262,18370,18371],{"class":429}," cleaned\n",[14,18373,18374,18375,18378,18379,18381],{},"This guarantees every returned post fits. Because splitting can add posts, validate ",[27,18376,18377],{},"after"," generation rather than fighting the model to hit an exact count. If you would rather reserve room for the \"1\u002F8\" counter you add in the next step, lower the ",[18,18380,16586],{}," you pass here by about six characters.",[57,18383,18385],{"id":18384},"_4-print-a-clean-numbered-list-or-post-via-httpx","4. Print a clean numbered list or post via httpx",[14,18387,18388,18389,18392,18393,18396,18397,18400],{},"Finally, number the thread for readability and either print it to copy by hand or publish it. Numbering uses an ",[18,18390,18391],{},"n\u002Ftotal"," format so readers know how far through they are. Posting to X uses the v2 ",[18,18394,18395],{},"\u002F2\u002Ftweets"," endpoint, where each reply points at the previous post's ID with ",[18,18398,18399],{},"in_reply_to_tweet_id"," to keep the thread connected.",[253,18402,18404],{"className":414,"code":18403,"language":416,"meta":258,"style":258},"import httpx\n\n\ndef number_thread(posts: list[str]) -> list[str]:\n    total = len(posts)\n    return [f\"{post}\\n\\n{i}\u002F{total}\" for i, post in enumerate(posts, start=1)]\n\n\ndef print_thread(posts: list[str]) -> None:\n    for post in posts:\n        print(post)\n        print(\"-\" * 40)\n\n\ndef post_to_x(posts: list[str]) -> list[str]:\n    headers = {\"Authorization\": f\"Bearer {os.getenv('X_BEARER_TOKEN')}\"}\n    ids: list[str] = []\n    with httpx.Client(timeout=30) as http:\n        reply_to = None\n        for post in posts:\n            payload = {\"text\": post}\n            if reply_to:\n                payload[\"reply\"] = {\"in_reply_to_tweet_id\": reply_to}\n            res = http.post(\n                \"https:\u002F\u002Fapi.twitter.com\u002F2\u002Ftweets\",\n                headers=headers,\n                json=payload,\n            )\n            res.raise_for_status()\n            reply_to = res.json()[\"data\"][\"id\"]\n            ids.append(reply_to)\n    return ids\n",[18,18405,18406,18412,18416,18420,18438,18450,18504,18508,18512,18529,18539,18546,18562,18566,18570,18587,18618,18631,18649,18659,18669,18683,18690,18710,18720,18727,18737,18747,18751,18756,18775,18780],{"__ignoreMap":258},[262,18407,18408,18410],{"class":181,"line":264},[262,18409,684],{"class":377},[262,18411,6526],{"class":429},[262,18413,18414],{"class":181,"line":282},[262,18415,583],{"emptyLinePlaceholder":582},[262,18417,18418],{"class":181,"line":295},[262,18419,583],{"emptyLinePlaceholder":582},[262,18421,18422,18424,18427,18429,18431,18434,18436],{"class":181,"line":345},[262,18423,423],{"class":377},[262,18425,18426],{"class":267}," number_thread",[262,18428,18219],{"class":429},[262,18430,433],{"class":271},[262,18432,18433],{"class":429},"]) -> list[",[262,18435,433],{"class":271},[262,18437,463],{"class":429},[262,18439,18440,18443,18445,18447],{"class":181,"line":492},[262,18441,18442],{"class":429},"    total ",[262,18444,476],{"class":377},[262,18446,515],{"class":271},[262,18448,18449],{"class":429},"(posts)\n",[262,18451,18452,18454,18456,18458,18460,18462,18465,18468,18470,18472,18474,18476,18479,18481,18483,18485,18488,18490,18492,18495,18497,18499,18501],{"class":181,"line":503},[262,18453,573],{"class":377},[262,18455,10563],{"class":429},[262,18457,642],{"class":377},[262,18459,1176],{"class":275},[262,18461,3039],{"class":271},[262,18463,18464],{"class":429},"post",[262,18466,18467],{"class":271},"}\\n\\n{",[262,18469,15558],{"class":429},[262,18471,654],{"class":271},[262,18473,981],{"class":275},[262,18475,3039],{"class":271},[262,18477,18478],{"class":429},"total",[262,18480,654],{"class":271},[262,18482,1176],{"class":275},[262,18484,10739],{"class":377},[262,18486,18487],{"class":429}," i, post ",[262,18489,835],{"class":377},[262,18491,14189],{"class":271},[262,18493,18494],{"class":429},"(posts, ",[262,18496,14195],{"class":611},[262,18498,476],{"class":377},[262,18500,997],{"class":271},[262,18502,18503],{"class":429},")]\n",[262,18505,18506],{"class":181,"line":521},[262,18507,583],{"emptyLinePlaceholder":582},[262,18509,18510],{"class":181,"line":537},[262,18511,583],{"emptyLinePlaceholder":582},[262,18513,18514,18516,18519,18521,18523,18525,18527],{"class":181,"line":549},[262,18515,423],{"class":377},[262,18517,18518],{"class":267}," print_thread",[262,18520,18219],{"class":429},[262,18522,433],{"class":271},[262,18524,13681],{"class":429},[262,18526,8471],{"class":271},[262,18528,1160],{"class":429},[262,18530,18531,18533,18535,18537],{"class":181,"line":570},[262,18532,3074],{"class":377},[262,18534,16668],{"class":429},[262,18536,835],{"class":377},[262,18538,16673],{"class":429},[262,18540,18541,18543],{"class":181,"line":579},[262,18542,2299],{"class":271},[262,18544,18545],{"class":429},"(post)\n",[262,18547,18548,18550,18552,18554,18557,18560],{"class":181,"line":586},[262,18549,2299],{"class":271},[262,18551,602],{"class":429},[262,18553,1094],{"class":275},[262,18555,18556],{"class":377}," *",[262,18558,18559],{"class":271}," 40",[262,18561,660],{"class":429},[262,18563,18564],{"class":181,"line":591},[262,18565,583],{"emptyLinePlaceholder":582},[262,18567,18568],{"class":181,"line":623},[262,18569,583],{"emptyLinePlaceholder":582},[262,18571,18572,18574,18577,18579,18581,18583,18585],{"class":181,"line":634},[262,18573,423],{"class":377},[262,18575,18576],{"class":267}," post_to_x",[262,18578,18219],{"class":429},[262,18580,433],{"class":271},[262,18582,18433],{"class":429},[262,18584,433],{"class":271},[262,18586,463],{"class":429},[262,18588,18589,18591,18593,18595,18597,18599,18601,18603,18605,18607,18610,18612,18614,18616],{"class":181,"line":845},[262,18590,16991],{"class":429},[262,18592,476],{"class":377},[262,18594,2276],{"class":429},[262,18596,16998],{"class":275},[262,18598,1231],{"class":429},[262,18600,642],{"class":377},[262,18602,6605],{"class":275},[262,18604,3039],{"class":271},[262,18606,1199],{"class":429},[262,18608,18609],{"class":275},"'X_BEARER_TOKEN'",[262,18611,5987],{"class":429},[262,18613,654],{"class":271},[262,18615,1176],{"class":275},[262,18617,16430],{"class":429},[262,18619,18620,18623,18625,18627,18629],{"class":181,"line":850},[262,18621,18622],{"class":429},"    ids: list[",[262,18624,433],{"class":271},[262,18626,2903],{"class":429},[262,18628,476],{"class":377},[262,18630,489],{"class":429},[262,18632,18633,18635,18637,18639,18641,18643,18645,18647],{"class":181,"line":864},[262,18634,10124],{"class":377},[262,18636,17018],{"class":429},[262,18638,1591],{"class":611},[262,18640,476],{"class":377},[262,18642,9777],{"class":271},[262,18644,1000],{"class":429},[262,18646,697],{"class":377},[262,18648,17032],{"class":429},[262,18650,18651,18654,18656],{"class":181,"line":1683},[262,18652,18653],{"class":429},"        reply_to ",[262,18655,476],{"class":377},[262,18657,18658],{"class":271}," None\n",[262,18660,18661,18663,18665,18667],{"class":181,"line":1688},[262,18662,10155],{"class":377},[262,18664,16668],{"class":429},[262,18666,835],{"class":377},[262,18668,16673],{"class":429},[262,18670,18671,18674,18676,18678,18680],{"class":181,"line":1693},[262,18672,18673],{"class":429},"            payload ",[262,18675,476],{"class":377},[262,18677,2276],{"class":429},[262,18679,16074],{"class":275},[262,18681,18682],{"class":429},": post}\n",[262,18684,18685,18687],{"class":181,"line":1728},[262,18686,10200],{"class":377},[262,18688,18689],{"class":429}," reply_to:\n",[262,18691,18692,18695,18698,18700,18702,18704,18707],{"class":181,"line":1737},[262,18693,18694],{"class":429},"                payload[",[262,18696,18697],{"class":275},"\"reply\"",[262,18699,2903],{"class":429},[262,18701,476],{"class":377},[262,18703,2276],{"class":429},[262,18705,18706],{"class":275},"\"in_reply_to_tweet_id\"",[262,18708,18709],{"class":429},": reply_to}\n",[262,18711,18712,18715,18717],{"class":181,"line":1751},[262,18713,18714],{"class":429},"            res ",[262,18716,476],{"class":377},[262,18718,18719],{"class":429}," http.post(\n",[262,18721,18722,18725],{"class":181,"line":1764},[262,18723,18724],{"class":275},"                \"https:\u002F\u002Fapi.twitter.com\u002F2\u002Ftweets\"",[262,18726,1315],{"class":429},[262,18728,18729,18732,18734],{"class":181,"line":1779},[262,18730,18731],{"class":611},"                headers",[262,18733,476],{"class":377},[262,18735,18736],{"class":429},"headers,\n",[262,18738,18739,18742,18744],{"class":181,"line":1793},[262,18740,18741],{"class":611},"                json",[262,18743,476],{"class":377},[262,18745,18746],{"class":429},"payload,\n",[262,18748,18749],{"class":181,"line":1800},[262,18750,3193],{"class":429},[262,18752,18753],{"class":181,"line":1805},[262,18754,18755],{"class":429},"            res.raise_for_status()\n",[262,18757,18758,18761,18763,18766,18769,18771,18773],{"class":181,"line":1810},[262,18759,18760],{"class":429},"            reply_to ",[262,18762,476],{"class":377},[262,18764,18765],{"class":429}," res.json()[",[262,18767,18768],{"class":275},"\"data\"",[262,18770,6163],{"class":429},[262,18772,6770],{"class":275},[262,18774,957],{"class":429},[262,18776,18777],{"class":181,"line":1823},[262,18778,18779],{"class":429},"            ids.append(reply_to)\n",[262,18781,18782,18784],{"class":181,"line":1846},[262,18783,573],{"class":377},[262,18785,18786],{"class":429}," ids\n",[14,18788,18789,18790,18793,18794,18797],{},"Call ",[18,18791,18792],{},"print_thread"," while you are testing and only switch to ",[18,18795,18796],{},"post_to_x"," once the output reads well. Posting in order matters: each reply must wait for the previous post's ID, so never send them in parallel.",[57,18799,18801],{"id":18800},"quick-reference","Quick reference",[14,18803,18804],{},"These are the parameters you will tune most often.",[1379,18806,18807,18819],{},[1382,18808,18809],{},[1385,18810,18811,18813,18815,18817],{},[1388,18812,1390],{},[1388,18814,3795],{},[1388,18816,3798],{},[1388,18818,1396],{},[1398,18820,18821,18836,18854],{},[1385,18822,18823,18827,18829,18833],{},[1403,18824,18825],{},[18,18826,18041],{},[1403,18828,439],{},[1403,18830,18831],{},[18,18832,7163],{},[1403,18834,18835],{},"Roughly how many posts the model drafts before splitting.",[1385,18837,18838,18842,18844,18848],{},[1403,18839,18840],{},[18,18841,16586],{},[1403,18843,439],{},[1403,18845,18846],{},[18,18847,12816],{},[1403,18849,18850,18851,1363],{},"Hard character ceiling enforced per post by ",[18,18852,18853],{},"enforce_limit",[1385,18855,18856,18860,18862,18866],{},[1403,18857,18858],{},[18,18859,805],{},[1403,18861,433],{},[1403,18863,18864],{},[18,18865,2703],{},[1403,18867,18868],{},"Which OpenAI model writes the thread; larger models suit long articles.",[57,18870,18872],{"id":18871},"worked-example","Worked example",[14,18874,18875],{},"This script ties the four steps together. Run it as-is to print a thread; uncomment the final line to publish.",[253,18877,18879],{"className":414,"code":18878,"language":416,"meta":258,"style":258},"import os\nimport json\nimport httpx\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef draft_thread(topic: str, target_posts: int = 7, limit: int = 280) -> list[str]:\n    prompt = f\"\"\"Write an engaging X\u002FTwitter thread about: \"{topic}\".\nPost 1 is a scroll-stopping HOOK. Each middle post makes one point.\nThe final post is a call to action. Keep each post under {limit} chars.\nAim for about {target_posts} posts. Do not number them.\nReturn ONLY JSON: {{\"posts\": [\"...\", \"...\"]}}\"\"\"\n    res = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        response_format={\"type\": \"json_object\"},\n    )\n    return json.loads(res.choices[0].message.content)[\"posts\"]\n\n\ndef enforce_limit(posts: list[str], limit: int = 274) -> list[str]:\n    cleaned: list[str] = []\n    for post in posts:                       # reserve ~6 chars for the n\u002Ftotal tag\n        text = post.strip()\n        while len(text) > limit:\n            cut = text.rfind(\" \", 0, limit)\n            cut = limit if cut == -1 else cut\n            cleaned.append(text[:cut].strip())\n            text = text[cut:].strip()\n        if text:\n            cleaned.append(text)\n    return cleaned\n\n\ndef number_thread(posts: list[str]) -> list[str]:\n    total = len(posts)\n    return [f\"{p}\\n\\n{i}\u002F{total}\" for i, p in enumerate(posts, start=1)]\n\n\ndef post_to_x(posts: list[str]) -> list[str]:\n    headers = {\"Authorization\": f\"Bearer {os.getenv('X_BEARER_TOKEN')}\"}\n    ids, reply_to = [], None\n    with httpx.Client(timeout=30) as http:\n        for post in posts:\n            payload = {\"text\": post}\n            if reply_to:\n                payload[\"reply\"] = {\"in_reply_to_tweet_id\": reply_to}\n            res = http.post(\"https:\u002F\u002Fapi.twitter.com\u002F2\u002Ftweets\", headers=headers, json=payload)\n            res.raise_for_status()\n            reply_to = res.json()[\"data\"][\"id\"]\n            ids.append(reply_to)\n    return ids\n\n\nif __name__ == \"__main__\":\n    raw = draft_thread(\"Why non-developers should learn Python in 2026\")\n    thread = number_thread(enforce_limit(raw))\n    for post in thread:\n        print(post)\n        print(\"-\" * 40)\n    # post_to_x(thread)   # uncomment to publish to X\n",[18,18880,18881,18887,18893,18899,18909,18919,18923,18927,18945,18949,18953,18985,19003,19008,19022,19036,19050,19059,19069,19089,19105,19109,19125,19129,19133,19158,19170,19184,19192,19204,19220,19245,19249,19257,19263,19267,19273,19277,19281,19297,19307,19356,19360,19364,19380,19410,19422,19440,19450,19462,19468,19484,19511,19515,19531,19535,19541,19545,19549,19561,19575,19585,19596,19602,19616],{"__ignoreMap":258},[262,18882,18883,18885],{"class":181,"line":264},[262,18884,684],{"class":377},[262,18886,687],{"class":429},[262,18888,18889,18891],{"class":181,"line":282},[262,18890,684],{"class":377},[262,18892,5766],{"class":429},[262,18894,18895,18897],{"class":181,"line":295},[262,18896,684],{"class":377},[262,18898,6526],{"class":429},[262,18900,18901,18903,18905,18907],{"class":181,"line":345},[262,18902,705],{"class":377},[262,18904,720],{"class":429},[262,18906,684],{"class":377},[262,18908,725],{"class":429},[262,18910,18911,18913,18915,18917],{"class":181,"line":492},[262,18912,705],{"class":377},[262,18914,708],{"class":429},[262,18916,684],{"class":377},[262,18918,713],{"class":429},[262,18920,18921],{"class":181,"line":503},[262,18922,583],{"emptyLinePlaceholder":582},[262,18924,18925],{"class":181,"line":521},[262,18926,734],{"class":429},[262,18928,18929,18931,18933,18935,18937,18939,18941,18943],{"class":181,"line":537},[262,18930,739],{"class":429},[262,18932,476],{"class":377},[262,18934,1588],{"class":429},[262,18936,2674],{"class":611},[262,18938,476],{"class":377},[262,18940,1199],{"class":429},[262,18942,2681],{"class":275},[262,18944,2684],{"class":429},[262,18946,18947],{"class":181,"line":549},[262,18948,583],{"emptyLinePlaceholder":582},[262,18950,18951],{"class":181,"line":570},[262,18952,583],{"emptyLinePlaceholder":582},[262,18954,18955,18957,18959,18961,18963,18965,18967,18969,18971,18973,18975,18977,18979,18981,18983],{"class":181,"line":579},[262,18956,423],{"class":377},[262,18958,17971],{"class":267},[262,18960,4287],{"class":429},[262,18962,433],{"class":271},[262,18964,17978],{"class":429},[262,18966,439],{"class":271},[262,18968,442],{"class":377},[262,18970,17985],{"class":271},[262,18972,17988],{"class":429},[262,18974,439],{"class":271},[262,18976,442],{"class":377},[262,18978,17995],{"class":271},[262,18980,458],{"class":429},[262,18982,433],{"class":271},[262,18984,463],{"class":429},[262,18986,18987,18989,18991,18993,18995,18997,18999,19001],{"class":181,"line":586},[262,18988,18006],{"class":429},[262,18990,476],{"class":377},[262,18992,10178],{"class":377},[262,18994,18013],{"class":275},[262,18996,3039],{"class":271},[262,18998,4402],{"class":429},[262,19000,654],{"class":271},[262,19002,18022],{"class":275},[262,19004,19005],{"class":181,"line":591},[262,19006,19007],{"class":275},"Post 1 is a scroll-stopping HOOK. Each middle post makes one point.\n",[262,19009,19010,19013,19015,19017,19019],{"class":181,"line":623},[262,19011,19012],{"class":275},"The final post is a call to action. Keep each post under ",[262,19014,3039],{"class":271},[262,19016,16586],{"class":429},[262,19018,654],{"class":271},[262,19020,19021],{"class":275}," chars.\n",[262,19023,19024,19027,19029,19031,19033],{"class":181,"line":634},[262,19025,19026],{"class":275},"Aim for about ",[262,19028,3039],{"class":271},[262,19030,18041],{"class":429},[262,19032,654],{"class":271},[262,19034,19035],{"class":275}," posts. Do not number them.\n",[262,19037,19038,19041,19043,19046,19048],{"class":181,"line":845},[262,19039,19040],{"class":275},"Return ONLY JSON: ",[262,19042,6355],{"class":271},[262,19044,19045],{"class":275},"\"posts\": [\"...\", \"...\"]",[262,19047,6361],{"class":271},[262,19049,1680],{"class":275},[262,19051,19052,19055,19057],{"class":181,"line":850},[262,19053,19054],{"class":429},"    res ",[262,19056,476],{"class":377},[262,19058,1189],{"class":429},[262,19060,19061,19063,19065,19067],{"class":181,"line":864},[262,19062,1194],{"class":611},[262,19064,476],{"class":377},[262,19066,1207],{"class":275},[262,19068,1315],{"class":429},[262,19070,19071,19073,19075,19077,19079,19081,19083,19085,19087],{"class":181,"line":1683},[262,19072,1215],{"class":611},[262,19074,476],{"class":377},[262,19076,8856],{"class":429},[262,19078,1228],{"class":275},[262,19080,1231],{"class":429},[262,19082,1291],{"class":275},[262,19084,608],{"class":429},[262,19086,1239],{"class":275},[262,19088,18141],{"class":429},[262,19090,19091,19093,19095,19097,19099,19101,19103],{"class":181,"line":1688},[262,19092,6018],{"class":611},[262,19094,476],{"class":377},[262,19096,3039],{"class":429},[262,19098,6025],{"class":275},[262,19100,1231],{"class":429},[262,19102,6030],{"class":275},[262,19104,3143],{"class":429},[262,19106,19107],{"class":181,"line":1693},[262,19108,1011],{"class":429},[262,19110,19111,19113,19116,19118,19121,19123],{"class":181,"line":1728},[262,19112,573],{"class":377},[262,19114,19115],{"class":429}," json.loads(res.choices[",[262,19117,102],{"class":271},[262,19119,19120],{"class":429},"].message.content)[",[262,19122,18184],{"class":275},[262,19124,957],{"class":429},[262,19126,19127],{"class":181,"line":1737},[262,19128,583],{"emptyLinePlaceholder":582},[262,19130,19131],{"class":181,"line":1751},[262,19132,583],{"emptyLinePlaceholder":582},[262,19134,19135,19137,19139,19141,19143,19145,19147,19149,19152,19154,19156],{"class":181,"line":1764},[262,19136,423],{"class":377},[262,19138,18216],{"class":267},[262,19140,18219],{"class":429},[262,19142,433],{"class":271},[262,19144,18224],{"class":429},[262,19146,439],{"class":271},[262,19148,442],{"class":377},[262,19150,19151],{"class":271}," 274",[262,19153,458],{"class":429},[262,19155,433],{"class":271},[262,19157,463],{"class":429},[262,19159,19160,19162,19164,19166,19168],{"class":181,"line":1779},[262,19161,18241],{"class":429},[262,19163,433],{"class":271},[262,19165,2903],{"class":429},[262,19167,476],{"class":377},[262,19169,489],{"class":429},[262,19171,19172,19174,19176,19178,19181],{"class":181,"line":1793},[262,19173,3074],{"class":377},[262,19175,16668],{"class":429},[262,19177,835],{"class":377},[262,19179,19180],{"class":429}," posts:                       ",[262,19182,19183],{"class":291},"# reserve ~6 chars for the n\u002Ftotal tag\n",[262,19185,19186,19188,19190],{"class":181,"line":1800},[262,19187,18264],{"class":429},[262,19189,476],{"class":377},[262,19191,18269],{"class":429},[262,19193,19194,19196,19198,19200,19202],{"class":181,"line":1805},[262,19195,18274],{"class":377},[262,19197,515],{"class":271},[262,19199,8109],{"class":429},[262,19201,8086],{"class":377},[262,19203,18283],{"class":429},[262,19205,19206,19208,19210,19212,19214,19216,19218],{"class":181,"line":1810},[262,19207,18293],{"class":429},[262,19209,476],{"class":377},[262,19211,18298],{"class":429},[262,19213,543],{"class":275},[262,19215,608],{"class":429},[262,19217,102],{"class":271},[262,19219,18307],{"class":429},[262,19221,19222,19224,19226,19229,19231,19233,19235,19237,19239,19242],{"class":181,"line":1823},[262,19223,18293],{"class":429},[262,19225,476],{"class":377},[262,19227,19228],{"class":429}," limit ",[262,19230,2210],{"class":377},[262,19232,18314],{"class":429},[262,19234,10758],{"class":377},[262,19236,18319],{"class":377},[262,19238,997],{"class":271},[262,19240,19241],{"class":377}," else",[262,19243,19244],{"class":429}," cut\n",[262,19246,19247],{"class":181,"line":1846},[262,19248,18342],{"class":429},[262,19250,19251,19253,19255],{"class":181,"line":1861},[262,19252,18347],{"class":429},[262,19254,476],{"class":377},[262,19256,18352],{"class":429},[262,19258,19259,19261],{"class":181,"line":1866},[262,19260,2268],{"class":377},[262,19262,18359],{"class":429},[262,19264,19265],{"class":181,"line":1871},[262,19266,18364],{"class":429},[262,19268,19269,19271],{"class":181,"line":1890},[262,19270,573],{"class":377},[262,19272,18371],{"class":429},[262,19274,19275],{"class":181,"line":1909},[262,19276,583],{"emptyLinePlaceholder":582},[262,19278,19279],{"class":181,"line":1914},[262,19280,583],{"emptyLinePlaceholder":582},[262,19282,19283,19285,19287,19289,19291,19293,19295],{"class":181,"line":1919},[262,19284,423],{"class":377},[262,19286,18426],{"class":267},[262,19288,18219],{"class":429},[262,19290,433],{"class":271},[262,19292,18433],{"class":429},[262,19294,433],{"class":271},[262,19296,463],{"class":429},[262,19298,19299,19301,19303,19305],{"class":181,"line":1946},[262,19300,18442],{"class":429},[262,19302,476],{"class":377},[262,19304,515],{"class":271},[262,19306,18449],{"class":429},[262,19308,19309,19311,19313,19315,19317,19319,19321,19323,19325,19327,19329,19331,19333,19335,19337,19339,19342,19344,19346,19348,19350,19352,19354],{"class":181,"line":1959},[262,19310,573],{"class":377},[262,19312,10563],{"class":429},[262,19314,642],{"class":377},[262,19316,1176],{"class":275},[262,19318,3039],{"class":271},[262,19320,14],{"class":429},[262,19322,18467],{"class":271},[262,19324,15558],{"class":429},[262,19326,654],{"class":271},[262,19328,981],{"class":275},[262,19330,3039],{"class":271},[262,19332,18478],{"class":429},[262,19334,654],{"class":271},[262,19336,1176],{"class":275},[262,19338,10739],{"class":377},[262,19340,19341],{"class":429}," i, p ",[262,19343,835],{"class":377},[262,19345,14189],{"class":271},[262,19347,18494],{"class":429},[262,19349,14195],{"class":611},[262,19351,476],{"class":377},[262,19353,997],{"class":271},[262,19355,18503],{"class":429},[262,19357,19358],{"class":181,"line":1996},[262,19359,583],{"emptyLinePlaceholder":582},[262,19361,19362],{"class":181,"line":2012},[262,19363,583],{"emptyLinePlaceholder":582},[262,19365,19366,19368,19370,19372,19374,19376,19378],{"class":181,"line":2040},[262,19367,423],{"class":377},[262,19369,18576],{"class":267},[262,19371,18219],{"class":429},[262,19373,433],{"class":271},[262,19375,18433],{"class":429},[262,19377,433],{"class":271},[262,19379,463],{"class":429},[262,19381,19382,19384,19386,19388,19390,19392,19394,19396,19398,19400,19402,19404,19406,19408],{"class":181,"line":2045},[262,19383,16991],{"class":429},[262,19385,476],{"class":377},[262,19387,2276],{"class":429},[262,19389,16998],{"class":275},[262,19391,1231],{"class":429},[262,19393,642],{"class":377},[262,19395,6605],{"class":275},[262,19397,3039],{"class":271},[262,19399,1199],{"class":429},[262,19401,18609],{"class":275},[262,19403,5987],{"class":429},[262,19405,654],{"class":271},[262,19407,1176],{"class":275},[262,19409,16430],{"class":429},[262,19411,19412,19415,19417,19419],{"class":181,"line":2050},[262,19413,19414],{"class":429},"    ids, reply_to ",[262,19416,476],{"class":377},[262,19418,1745],{"class":429},[262,19420,19421],{"class":271},"None\n",[262,19423,19424,19426,19428,19430,19432,19434,19436,19438],{"class":181,"line":2067},[262,19425,10124],{"class":377},[262,19427,17018],{"class":429},[262,19429,1591],{"class":611},[262,19431,476],{"class":377},[262,19433,9777],{"class":271},[262,19435,1000],{"class":429},[262,19437,697],{"class":377},[262,19439,17032],{"class":429},[262,19441,19442,19444,19446,19448],{"class":181,"line":2077},[262,19443,10155],{"class":377},[262,19445,16668],{"class":429},[262,19447,835],{"class":377},[262,19449,16673],{"class":429},[262,19451,19452,19454,19456,19458,19460],{"class":181,"line":2086},[262,19453,18673],{"class":429},[262,19455,476],{"class":377},[262,19457,2276],{"class":429},[262,19459,16074],{"class":275},[262,19461,18682],{"class":429},[262,19463,19464,19466],{"class":181,"line":2097},[262,19465,10200],{"class":377},[262,19467,18689],{"class":429},[262,19469,19470,19472,19474,19476,19478,19480,19482],{"class":181,"line":2106},[262,19471,18694],{"class":429},[262,19473,18697],{"class":275},[262,19475,2903],{"class":429},[262,19477,476],{"class":377},[262,19479,2276],{"class":429},[262,19481,18706],{"class":275},[262,19483,18709],{"class":429},[262,19485,19486,19488,19490,19492,19495,19497,19499,19501,19504,19506,19508],{"class":181,"line":2126},[262,19487,18714],{"class":429},[262,19489,476],{"class":377},[262,19491,17042],{"class":429},[262,19493,19494],{"class":275},"\"https:\u002F\u002Fapi.twitter.com\u002F2\u002Ftweets\"",[262,19496,608],{"class":429},[262,19498,17057],{"class":611},[262,19500,476],{"class":377},[262,19502,19503],{"class":429},"headers, ",[262,19505,17049],{"class":611},[262,19507,476],{"class":377},[262,19509,19510],{"class":429},"payload)\n",[262,19512,19513],{"class":181,"line":2148},[262,19514,18755],{"class":429},[262,19516,19517,19519,19521,19523,19525,19527,19529],{"class":181,"line":2165},[262,19518,18760],{"class":429},[262,19520,476],{"class":377},[262,19522,18765],{"class":429},[262,19524,18768],{"class":275},[262,19526,6163],{"class":429},[262,19528,6770],{"class":275},[262,19530,957],{"class":429},[262,19532,19533],{"class":181,"line":2170},[262,19534,18779],{"class":429},[262,19536,19537,19539],{"class":181,"line":2181},[262,19538,573],{"class":377},[262,19540,18786],{"class":429},[262,19542,19543],{"class":181,"line":2186},[262,19544,583],{"emptyLinePlaceholder":582},[262,19546,19547],{"class":181,"line":2197},[262,19548,583],{"emptyLinePlaceholder":582},[262,19550,19551,19553,19555,19557,19559],{"class":181,"line":2202},[262,19552,2210],{"class":377},[262,19554,2213],{"class":271},[262,19556,2216],{"class":377},[262,19558,2219],{"class":275},[262,19560,1160],{"class":429},[262,19562,19563,19565,19567,19570,19573],{"class":181,"line":2207},[262,19564,15127],{"class":429},[262,19566,476],{"class":377},[262,19568,19569],{"class":429}," draft_thread(",[262,19571,19572],{"class":275},"\"Why non-developers should learn Python in 2026\"",[262,19574,660],{"class":429},[262,19576,19577,19580,19582],{"class":181,"line":2224},[262,19578,19579],{"class":429},"    thread ",[262,19581,476],{"class":377},[262,19583,19584],{"class":429}," number_thread(enforce_limit(raw))\n",[262,19586,19587,19589,19591,19593],{"class":181,"line":2236},[262,19588,3074],{"class":377},[262,19590,16668],{"class":429},[262,19592,835],{"class":377},[262,19594,19595],{"class":429}," thread:\n",[262,19597,19598,19600],{"class":181,"line":2246},[262,19599,2299],{"class":271},[262,19601,18545],{"class":429},[262,19603,19604,19606,19608,19610,19612,19614],{"class":181,"line":2265},[262,19605,2299],{"class":271},[262,19607,602],{"class":429},[262,19609,1094],{"class":275},[262,19611,18556],{"class":377},[262,19613,18559],{"class":271},[262,19615,660],{"class":429},[262,19617,19618],{"class":181,"line":2290},[262,19619,19620],{"class":291},"    # post_to_x(thread)   # uncomment to publish to X\n",[57,19622,1445],{"id":1444},[1447,19624,19625,19638,19650,19665],{},[1450,19626,19627,19632,19633,19635,19636,1363],{},[35,19628,19629],{},[18,19630,19631],{},"json.decoder.JSONDecodeError"," — the model returned text that is not valid JSON. Confirm ",[18,19634,6878],{}," is set and that your prompt contains the word \"JSON\". If it persists, see ",[51,19637,6114],{"href":6113},[1450,19639,19640,19643,19644,19646,19647,19649],{},[35,19641,19642],{},"A tweet is rejected for being too long"," — you are posting the raw model output instead of running ",[18,19645,18853],{}," first. Always validate length in Python, and remember the ",[18,19648,18391],{}," tag adds about six characters, so lower the limit you pass accordingly.",[1450,19651,19652,19658,19659,19662,19663,1363],{},[35,19653,19654,19657],{},[18,19655,19656],{},"401 Unauthorized"," from the X API"," — your bearer token is missing, expired, or lacks write permission. Regenerate a user-context OAuth 2.0 token with the ",[18,19660,19661],{},"tweet.write"," scope in the X developer portal, not an app-only token. The OpenAI equivalent is covered in ",[51,19664,388],{"href":387},[1450,19666,19667,19673,19674,19677,19678,1363],{},[35,19668,19669,19672],{},[18,19670,19671],{},"429 Too Many Requests"," while posting"," — you hit X's per-window write cap, which is low on free tiers. Add a short ",[18,19675,19676],{},"time.sleep(2)"," between posts and avoid re-running the whole thread on every test. The same backoff idea is explained in ",[51,19679,3379],{"href":3378},[57,19681,2317],{"id":2316},[2322,19683,19684,19693,19702],{},[1450,19685,19686,19689,19690,19692],{},[35,19687,19688],{},"Use this script when"," you publish threads regularly and want consistent hooks, enforced limits, and the option to draft from a topic ",[27,19691,8923],{}," a full article in one step. It is free to run beyond a few cents of API cost and needs no third-party subscription.",[1450,19694,19695,19698,19699,1363],{},[35,19696,19697],{},"Use a scheduling tool instead when"," your main need is timing many posts across several networks rather than generating thread copy. For a Python-native approach to that, see ",[51,19700,15717],{"href":19701},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fbulk-schedule-social-posts-with-python\u002F",[1450,19703,19704,19707,19708,19710],{},[35,19705,19706],{},"Use a single-image flow instead when"," the platform is visual rather than text-first. For that, ",[51,19709,15735],{"href":15734}," is the better fit, since Instagram rewards one strong image and caption over a chain of text posts.",[57,19712,2355],{"id":2354},[14,19714,19715,19716,19718,19719,1363],{},"Once your threads read well, batch the whole pipeline so one run drafts a week of content, then hand the queue to ",[51,19717,15717],{"href":19701}," for timed delivery. Back to ",[51,19720,9309],{"href":9308},[57,19722,2381],{"id":2380},[2322,19724,19725,19729,19734,19739],{},[1450,19726,19727,17676],{},[51,19728,9309],{"href":9308},[1450,19730,19731,19733],{},[51,19732,15735],{"href":15734}," — the visual-first counterpart for image platforms.",[1450,19735,19736,19738],{},[51,19737,15717],{"href":19701}," — queue and time many posts at once.",[1450,19740,19741,19743],{},[51,19742,3983],{"href":3982}," — turn the same topic into a long-form article.",[2401,19745,19746],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":258,"searchDepth":282,"depth":282,"links":19748},[19749,19750,19751,19752,19753,19754,19755,19756,19757,19758,19759],{"id":237,"depth":282,"text":238},{"id":17809,"depth":282,"text":17810},{"id":17882,"depth":282,"text":17883},{"id":18200,"depth":282,"text":18201},{"id":18384,"depth":282,"text":18385},{"id":18800,"depth":282,"text":18801},{"id":18871,"depth":282,"text":18872},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Turn any topic or article into a numbered X\u002FTwitter thread with Python and the OpenAI SDK. Enforce per-tweet limits, hooks, CTAs, and optionally auto-post.",[19762,19765,19768,19771,19774],{"q":19763,"a":19764},"What is the character limit for a single tweet in a thread?","Standard X accounts are limited to 280 characters per post. Premium (paid) accounts can write up to 25,000 characters, but threads still read best when each post stays near 280 so they stay scannable. This guide enforces a configurable limit so your output works for any account type.",{"q":19766,"a":19767},"Can I generate a Twitter thread without paying for the X API?","Yes. Generating the thread text only needs the OpenAI API, which has a low pay-per-use cost. You only need X API access if you want the script to publish the thread automatically. You can always copy the generated text and post it by hand for free.",{"q":19769,"a":19770},"How do I make the AI count characters correctly?","Language models cannot count characters reliably, so never trust them to enforce a limit. Ask the model for an ideal number of short posts, then validate and split every post in Python with len(). Code is the source of truth, not the prompt.",{"q":19772,"a":19773},"How many tweets should a thread have?","Five to nine posts is a reliable range for most topics. The first post is the hook, the middle posts each make one point, and the last post is the call to action. Fewer than four feels thin and more than twelve loses readers.",{"q":19775,"a":19776},"Do I need a different OpenAI model for this?","No. A small, fast model like gpt-4o-mini handles thread generation well and keeps costs near zero. Larger models only help when your source article is long or highly technical and you want denser summarization.",{"name":19778,"steps":19779},"How to generate a Twitter thread with Python and AI",[19780,19783,19786,19789],{"name":19781,"text":19782},"Install dependencies and set up credentials","Create a virtual environment, install the openai and httpx libraries, and store your API keys in a .env file.",{"name":19784,"text":19785},"Generate the thread with the OpenAI SDK","Send your topic or article to the model with a prompt that asks for a numbered list of short posts with a hook and a call to action.",{"name":19787,"text":19788},"Validate and enforce per-tweet character limits","Loop over each post in Python, check its length, and split any post that exceeds your limit so no tweet is ever rejected.",{"name":19790,"text":19791},"Print a clean numbered list or post via httpx","Output the finished thread as a numbered list to copy by hand, or send each post to the X API in order using httpx.",{},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fgenerate-twitter-threads-with-python-and-ai",{"title":17687,"description":19760},"ai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fgenerate-twitter-threads-with-python-and-ai\u002Findex","Em9TMk4z2B-P0dPXkIoH7K2AQdc3QKKg_72qiO1wGM8",{"id":19798,"title":19799,"body":19800,"description":22613,"extension":2419,"faq":22614,"howto":22630,"meta":22645,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":22646,"published":15711,"seo":22647,"seoTitle":22648,"stem":22649,"__hash__":22650},"content\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Findex.md","Automated Social Media Posting with Python and AI",{"type":7,"value":19801,"toc":22600},[19802,19805,19808,19814,19817,19820,19823,19827,19830,19946,19948,19954,19957,19995,20011,20017,20036,20044,20058,20064,20068,20071,20389,20398,20407,20416,20420,20423,20903,20909,20917,20921,20924,21197,21214,21232,21236,21242,21550,21562,21574,21576,21579,21726,21728,21731,21814,21818,21821,22526,22536,22538,22541,22561,22570,22574,22576,22598],[10,19803,19799],{"id":19804},"automated-social-media-posting-with-python-and-ai",[14,19806,19807],{},"If you run social accounts for a brand, a side project, or a client, you know the real cost is not writing one post. It is writing forty, reshaping each one for a different network, then logging in at 9 a.m. on a Tuesday to publish them by hand. Automated social media posting moves that whole routine into a Python program: an AI model drafts the copy, your code reshapes it per platform, a queue holds it until the right moment, and an API call sends it live while you do something else.",[14,19809,19810,19811,19813],{},"This is the main guide for the ",[35,19812,9309],{}," section. It walks through the full pipeline once, end to end, so the individual recipes that follow have a shared backbone. You do not need to be a developer. You need Python installed, an API key from one AI provider, and a developer token from at least one social network. Everything else is here.",[14,19815,19816],{},"By the end you will have four moving parts that fit together: a copy generator, a per-platform formatter, a scheduling queue, and a publisher. Each is a short, readable function. The worked example near the end stitches them into one runnable script you can adapt the same afternoon.",[14,19818,19819],{},"A quick note on why this beats a paid scheduling tool. Off-the-shelf dashboards are fine until you want something they do not offer: a custom hashtag rule, a brand-specific tone, posting from your own spreadsheet, or simply not paying per seat for a small team. Owning the pipeline in Python means every one of those is a few lines of code you control, and the AI model in Step 1 is a far better copywriter than the canned suggestions most tools bundle. The trade is that you maintain it yourself. The good news is that the whole thing is small enough to read in one sitting, which is exactly the point of this guide.",[14,19821,19822],{},"One more thing before the code: keep the steps independent. The single biggest mistake people make is fusing generation, formatting, scheduling, and publishing into one giant function. When that function breaks, you cannot tell which part failed, and a retry re-runs everything including the expensive AI call. Four small functions cost nothing extra and save you hours of debugging later.",[57,19824,19826],{"id":19825},"the-pipeline-at-a-glance","The pipeline at a glance",[14,19828,19829],{},"The hard part of social automation is not any single step. It is keeping the steps decoupled so a failure in one does not corrupt the others. A bad API token should not lose your drafted copy. A rate limit on one network should not block posts to another. The diagram below shows the flow this guide builds: content goes in on the left, gets shaped and queued in the middle, and fans out to platform APIs on the right.",[76,19831,19833,19943],{"className":19832},[79],[81,19834,90,19837,90,19840,90,19843,90,19853,90,19856,90,19860,90,19864,90,19866,90,19870,90,19874,90,19876,90,19879,90,19882,90,19884,90,19888,90,19892,90,19894,90,19897,90,19900,90,19902,90,19906,90,19909,90,19912,90,19915,90,19919,90,19923,90,19926,90,19928,90,19931,90,19934,90,19938,90,19940],{"viewBox":19835,"role":84,"ariaLabelledBy":19836,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1040 460",[7091,7092],[92,19838,19839],{"id":7091},"Social posting pipeline: content to scheduler queue to platform APIs",[96,19841,19842],{"id":7092},"A topic brief flows into an AI copy generator, then a per-platform formatter, then a scheduling queue, which fans out to three platform API publishers.",[5548,19844,5550,19845,90],{},[5552,19846,5558,19850,5550],{"id":19847,"markerWidth":7162,"markerHeight":7162,"refX":7163,"refY":19848,"orient":5557,"markerUnits":19849},"arrowSmp","4","userSpaceOnUse",[216,19851],{"d":19852,"fill":143},"M0,0 L8,4 L0,8 Z",[100,19854],{"x":102,"y":19855,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"180",[111,19857,19859],{"x":113,"y":19858,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"211","Topic brief",[111,19861,19863],{"x":113,"y":19862,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"230","+ media path",[100,19865],{"x":129,"y":140,"width":104,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,19867,19869],{"x":133,"y":19868,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"51","1. LLM copy",[111,19871,19873],{"x":133,"y":19872,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"70","caption + tags",[100,19875],{"x":129,"y":19855,"width":104,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,19877,19878],{"x":133,"y":19858,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"2. Formatter",[111,19880,19881],{"x":133,"y":19862,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"platform limits",[100,19883],{"x":129,"y":133,"width":104,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,19885,19887],{"x":133,"y":19886,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"371","3. Queue",[111,19889,19891],{"x":133,"y":19890,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"390","timezone-aware",[100,19893],{"x":158,"y":19855,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,19895,19896],{"x":161,"y":19858,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"4. Publisher",[111,19898,19899],{"x":161,"y":19862,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"httpx POST",[100,19901],{"x":12881,"y":140,"width":104,"height":12826,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,19903,19905],{"x":19904,"y":12830,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"860","X API",[100,19907],{"x":12881,"y":19908,"width":104,"height":12826,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},"186",[111,19910,19911],{"x":19904,"y":123,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"IG API",[100,19913],{"x":12881,"y":19914,"width":104,"height":12826,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},"352",[111,19916,19918],{"x":19904,"y":19917,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"387","LinkedIn",[181,19920],{"x1":104,"y1":19921,"x2":129,"y2":12826,"stroke":108,"strokeWidth":109,"markerEnd":19922},"208","url(#arrowSmp)",[181,19924],{"x1":104,"y1":19925,"x2":129,"y2":19925,"stroke":108,"strokeWidth":109,"markerEnd":19922},"216",[181,19927],{"x1":133,"y1":141,"x2":133,"y2":19855,"stroke":130,"strokeWidth":109,"markerEnd":19922},[181,19929],{"x1":133,"y1":19930,"x2":133,"y2":133,"stroke":130,"strokeWidth":109,"markerEnd":19922},"252",[181,19932],{"x1":198,"y1":19933,"x2":161,"y2":19930,"stroke":130,"strokeWidth":109,"markerEnd":19922},"376",[181,19935],{"x1":205,"y1":19936,"x2":12881,"y2":19937,"stroke":169,"strokeWidth":109,"markerEnd":19922},"204","56",[181,19939],{"x1":205,"y1":19925,"x2":12881,"y2":19925,"stroke":169,"strokeWidth":109,"markerEnd":19922},[181,19941],{"x1":205,"y1":19942,"x2":12881,"y2":19933,"stroke":169,"strokeWidth":109,"markerEnd":19922},"228",[232,19944,19945],{},"Each stage is a separate function, so a failure in one never corrupts the others.",[57,19947,238],{"id":237},[14,19949,19950,19951,19953],{},"You need Python 3.10 or newer. Check your version with ",[18,19952,17782],{},". If it prints 3.9 or lower, install a current release first.",[14,19955,19956],{},"Work inside a virtual environment so these packages stay isolated from the rest of your system. From your project folder:",[253,19958,19960],{"className":255,"code":19959,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate    # Windows: .venv\\Scripts\\activate\npip install openai httpx apscheduler python-dotenv\n",[18,19961,19962,19972,19981],{"__ignoreMap":258},[262,19963,19964,19966,19968,19970],{"class":181,"line":264},[262,19965,416],{"class":267},[262,19967,272],{"class":271},[262,19969,276],{"class":275},[262,19971,279],{"class":275},[262,19973,19974,19976,19978],{"class":181,"line":282},[262,19975,285],{"class":271},[262,19977,288],{"class":275},[262,19979,19980],{"class":291},"    # Windows: .venv\\Scripts\\activate\n",[262,19982,19983,19985,19987,19989,19991,19993],{"class":181,"line":295},[262,19984,298],{"class":267},[262,19986,301],{"class":275},[262,19988,2519],{"class":275},[262,19990,5440],{"class":275},[262,19992,15759],{"class":275},[262,19994,2522],{"class":275},[14,19996,19997,19998,20000,20001,20003,20004,20007,20008,20010],{},"Here is what each package does. ",[18,19999,20],{}," is the SDK that talks to the AI model and drafts your copy. ",[18,20002,5450],{}," is a modern HTTP client used to call each social platform's API. ",[18,20005,20006],{},"apscheduler"," is a scheduling library that fires jobs at times you choose, with proper timezone handling. ",[18,20009,2501],{}," loads your secret keys from a file so they never touch your code.",[14,20012,20013,20014,20016],{},"Now create a ",[18,20015,319],{}," file in the same folder and add your credentials. The exact platform keys depend on which networks you target; the AI key is always needed.",[253,20018,20020],{"className":323,"code":20019,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-key-here\nX_BEARER_TOKEN=your-x-api-token\nLINKEDIN_ACCESS_TOKEN=your-linkedin-token\n",[18,20021,20022,20026,20031],{"__ignoreMap":258},[262,20023,20024],{"class":181,"line":264},[262,20025,11159],{},[262,20027,20028],{"class":181,"line":282},[262,20029,20030],{},"X_BEARER_TOKEN=your-x-api-token\n",[262,20032,20033],{"class":181,"line":295},[262,20034,20035],{},"LINKEDIN_ACCESS_TOKEN=your-linkedin-token\n",[14,20037,20038,20039,356,20041,20043],{},"Before you commit anything, add ",[18,20040,319],{},[18,20042,359],{}," so your keys never reach GitHub. One line is enough:",[253,20045,20046],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,20047,20048],{"__ignoreMap":258},[262,20049,20050,20052,20054,20056],{"class":181,"line":264},[262,20051,371],{"class":271},[262,20053,374],{"class":275},[262,20055,378],{"class":377},[262,20057,381],{"class":275},[14,20059,20060,20061,20063],{},"That single line is the difference between a private key and a key strangers can copy out of your public repository. Do it before the first commit, every time. If you are new to where these keys come from, the ",[51,20062,5485],{"href":5484}," guide covers signing up and finding your first token.",[57,20065,20067],{"id":20066},"step-1-generate-post-copy-with-an-llm","Step 1: Generate post copy with an LLM",[14,20069,20070],{},"The first stage turns a one-line brief into real post copy. Instead of asking the model for a blob of text, ask it for structured fields you can use directly: a caption and a list of hashtags. Forcing JSON output means your code can read the result reliably instead of parsing prose.",[253,20072,20074],{"className":414,"code":20073,"language":416,"meta":258,"style":258},"import os\nimport json\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()  # reads .env into the environment\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef generate_copy(topic: str, audience: str) -> dict:\n    \"\"\"Draft a caption and hashtags for a social post from a short brief.\"\"\"\n    prompt = (\n        f\"Write one social media post about: {topic}. \"\n        f\"Audience: {audience}. Keep it punchy and human. \"\n        \"Return JSON with two keys: 'caption' (a string) and \"\n        \"'hashtags' (a list of 3-6 lowercase tags without the # symbol).\"\n    )\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        response_format={\"type\": \"json_object\"},\n        temperature=0.8,\n    )\n    return json.loads(response.choices[0].message.content)\n\n\nif __name__ == \"__main__\":\n    draft = generate_copy(\"our new debugging course launch\", \"junior developers\")\n    print(draft[\"caption\"])\n    print(\" \".join(f\"#{tag}\" for tag in draft[\"hashtags\"]))\n",[18,20075,20076,20082,20088,20098,20108,20112,20119,20137,20141,20145,20165,20170,20178,20193,20208,20213,20218,20222,20230,20240,20260,20276,20287,20291,20301,20305,20309,20321,20340,20351],{"__ignoreMap":258},[262,20077,20078,20080],{"class":181,"line":264},[262,20079,684],{"class":377},[262,20081,687],{"class":429},[262,20083,20084,20086],{"class":181,"line":282},[262,20085,684],{"class":377},[262,20087,5766],{"class":429},[262,20089,20090,20092,20094,20096],{"class":181,"line":295},[262,20091,705],{"class":377},[262,20093,720],{"class":429},[262,20095,684],{"class":377},[262,20097,725],{"class":429},[262,20099,20100,20102,20104,20106],{"class":181,"line":345},[262,20101,705],{"class":377},[262,20103,708],{"class":429},[262,20105,684],{"class":377},[262,20107,713],{"class":429},[262,20109,20110],{"class":181,"line":492},[262,20111,583],{"emptyLinePlaceholder":582},[262,20113,20114,20116],{"class":181,"line":503},[262,20115,4222],{"class":429},[262,20117,20118],{"class":291},"# reads .env into the environment\n",[262,20120,20121,20123,20125,20127,20129,20131,20133,20135],{"class":181,"line":521},[262,20122,739],{"class":429},[262,20124,476],{"class":377},[262,20126,1588],{"class":429},[262,20128,2674],{"class":611},[262,20130,476],{"class":377},[262,20132,1199],{"class":429},[262,20134,2681],{"class":275},[262,20136,2684],{"class":429},[262,20138,20139],{"class":181,"line":537},[262,20140,583],{"emptyLinePlaceholder":582},[262,20142,20143],{"class":181,"line":549},[262,20144,583],{"emptyLinePlaceholder":582},[262,20146,20147,20149,20151,20153,20155,20157,20159,20161,20163],{"class":181,"line":570},[262,20148,423],{"class":377},[262,20150,7799],{"class":267},[262,20152,4287],{"class":429},[262,20154,433],{"class":271},[262,20156,4292],{"class":429},[262,20158,433],{"class":271},[262,20160,1939],{"class":429},[262,20162,5869],{"class":271},[262,20164,1160],{"class":429},[262,20166,20167],{"class":181,"line":579},[262,20168,20169],{"class":275},"    \"\"\"Draft a caption and hashtags for a social post from a short brief.\"\"\"\n",[262,20171,20172,20174,20176],{"class":181,"line":586},[262,20173,18006],{"class":429},[262,20175,476],{"class":377},[262,20177,984],{"class":429},[262,20179,20180,20182,20185,20187,20189,20191],{"class":181,"line":591},[262,20181,2840],{"class":377},[262,20183,20184],{"class":275},"\"Write one social media post about: ",[262,20186,3039],{"class":271},[262,20188,4402],{"class":429},[262,20190,654],{"class":271},[262,20192,4628],{"class":275},[262,20194,20195,20197,20199,20201,20203,20205],{"class":181,"line":623},[262,20196,2840],{"class":377},[262,20198,7652],{"class":275},[262,20200,3039],{"class":271},[262,20202,4419],{"class":429},[262,20204,654],{"class":271},[262,20206,20207],{"class":275},". Keep it punchy and human. \"\n",[262,20209,20210],{"class":181,"line":634},[262,20211,20212],{"class":275},"        \"Return JSON with two keys: 'caption' (a string) and \"\n",[262,20214,20215],{"class":181,"line":845},[262,20216,20217],{"class":275},"        \"'hashtags' (a list of 3-6 lowercase tags without the # symbol).\"\n",[262,20219,20220],{"class":181,"line":850},[262,20221,1011],{"class":429},[262,20223,20224,20226,20228],{"class":181,"line":864},[262,20225,1184],{"class":429},[262,20227,476],{"class":377},[262,20229,1189],{"class":429},[262,20231,20232,20234,20236,20238],{"class":181,"line":1683},[262,20233,1194],{"class":611},[262,20235,476],{"class":377},[262,20237,1207],{"class":275},[262,20239,1315],{"class":429},[262,20241,20242,20244,20246,20248,20250,20252,20254,20256,20258],{"class":181,"line":1688},[262,20243,1215],{"class":611},[262,20245,476],{"class":377},[262,20247,8856],{"class":429},[262,20249,1228],{"class":275},[262,20251,1231],{"class":429},[262,20253,1291],{"class":275},[262,20255,608],{"class":429},[262,20257,1239],{"class":275},[262,20259,18141],{"class":429},[262,20261,20262,20264,20266,20268,20270,20272,20274],{"class":181,"line":1693},[262,20263,6018],{"class":611},[262,20265,476],{"class":377},[262,20267,3039],{"class":429},[262,20269,6025],{"class":275},[262,20271,1231],{"class":429},[262,20273,6030],{"class":275},[262,20275,3143],{"class":429},[262,20277,20278,20280,20282,20285],{"class":181,"line":1728},[262,20279,1308],{"class":611},[262,20281,476],{"class":377},[262,20283,20284],{"class":271},"0.8",[262,20286,1315],{"class":429},[262,20288,20289],{"class":181,"line":1737},[262,20290,1011],{"class":429},[262,20292,20293,20295,20297,20299],{"class":181,"line":1751},[262,20294,573],{"class":377},[262,20296,6043],{"class":429},[262,20298,102],{"class":271},[262,20300,6048],{"class":429},[262,20302,20303],{"class":181,"line":1764},[262,20304,583],{"emptyLinePlaceholder":582},[262,20306,20307],{"class":181,"line":1779},[262,20308,583],{"emptyLinePlaceholder":582},[262,20310,20311,20313,20315,20317,20319],{"class":181,"line":1793},[262,20312,2210],{"class":377},[262,20314,2213],{"class":271},[262,20316,2216],{"class":377},[262,20318,2219],{"class":275},[262,20320,1160],{"class":429},[262,20322,20323,20325,20327,20330,20333,20335,20338],{"class":181,"line":1800},[262,20324,5082],{"class":429},[262,20326,476],{"class":377},[262,20328,20329],{"class":429}," generate_copy(",[262,20331,20332],{"class":275},"\"our new debugging course launch\"",[262,20334,608],{"class":429},[262,20336,20337],{"class":275},"\"junior developers\"",[262,20339,660],{"class":429},[262,20341,20342,20344,20346,20349],{"class":181,"line":1805},[262,20343,1089],{"class":271},[262,20345,6082],{"class":429},[262,20347,20348],{"class":275},"\"caption\"",[262,20350,3512],{"class":429},[262,20352,20353,20355,20357,20359,20361,20363,20366,20368,20371,20373,20375,20377,20380,20382,20384,20387],{"class":181,"line":1810},[262,20354,1089],{"class":271},[262,20356,602],{"class":429},[262,20358,543],{"class":275},[262,20360,2023],{"class":429},[262,20362,642],{"class":377},[262,20364,20365],{"class":275},"\"#",[262,20367,3039],{"class":271},[262,20369,20370],{"class":429},"tag",[262,20372,654],{"class":271},[262,20374,1176],{"class":275},[262,20376,10739],{"class":377},[262,20378,20379],{"class":429}," tag ",[262,20381,835],{"class":377},[262,20383,6158],{"class":429},[262,20385,20386],{"class":275},"\"hashtags\"",[262,20388,15338],{"class":429},[14,20390,3349,20391,20393,20394,20397],{},[18,20392,6878],{}," argument is the important part. It tells the model to return valid JSON (a structured data format your code can read field by field), so ",[18,20395,20396],{},"json.loads"," will not choke on stray text. Without it, a model will sometimes wrap its answer in a friendly sentence like \"Sure, here is your post!\", and that one sentence breaks the parse. Forcing JSON removes the guesswork.",[14,20399,3349,20400,20403,20404,20406],{},[18,20401,20402],{},"temperature=0.8"," controls how adventurous the copy is. Temperature is a dial from 0 to 2: low values make the model pick its most likely words, which reads safe and a little repetitive; high values let it reach for surprising phrasing. For social copy, somewhere between 0.6 and 0.9 usually lands well. Drop it toward ",[18,20405,3924],{}," when you need consistency across a campaign, and raise it when posts are starting to sound the same.",[14,20408,20409,20410,20412,20413,20415],{},"Notice that this function does exactly one thing: it returns a dictionary. It does not format, schedule, or publish. That discipline is what lets you test it in isolation by printing the result, and what lets the formatter in the next step treat its output as plain data. For deeper control of how the model shapes its replies, the ",[51,20411,3991],{"href":3990}," section covers prompt patterns that scale across hundreds of posts, and the ",[51,20414,3983],{"href":3982}," guide shows the same JSON technique applied to longer formats.",[57,20417,20419],{"id":20418},"step-2-format-the-copy-for-each-platform","Step 2: Format the copy for each platform",[14,20421,20422],{},"A caption that fits on LinkedIn will be truncated on X. The formatter takes the raw draft and reshapes it for one target network: it enforces the character limit, decides how many hashtags to keep, and chooses where to put them. Keep this logic in plain Python so it is easy to read and adjust per platform.",[253,20424,20426],{"className":414,"code":20425,"language":416,"meta":258,"style":258},"PLATFORM_RULES = {\n    \"x\": {\"limit\": 280, \"max_tags\": 3, \"tags_inline\": True},\n    \"instagram\": {\"limit\": 2200, \"max_tags\": 6, \"tags_inline\": False},\n    \"linkedin\": {\"limit\": 3000, \"max_tags\": 3, \"tags_inline\": False},\n}\n\n\ndef format_for_platform(draft: dict, platform: str) -> str:\n    \"\"\"Reshape a draft into a single ready-to-post string for one network.\"\"\"\n    rules = PLATFORM_RULES.get(platform)\n    if rules is None:\n        raise ValueError(f\"Unknown platform: {platform}\")\n\n    tags = draft[\"hashtags\"][: rules[\"max_tags\"]]\n    tag_line = \" \".join(f\"#{t}\" for t in tags)\n    caption = draft[\"caption\"].strip()\n\n    if rules[\"tags_inline\"]:\n        post = f\"{caption} {tag_line}\".strip()\n    else:\n        post = f\"{caption}\\n\\n{tag_line}\".strip()\n\n    if len(post) > rules[\"limit\"]:\n        # Trim the caption, keep the tags, leave room for an ellipsis.\n        room = rules[\"limit\"] - len(tag_line) - 4\n        caption = caption[:room].rstrip() + \"...\"\n        joiner = \" \" if rules[\"tags_inline\"] else \"\\n\\n\"\n        post = f\"{caption}{joiner}{tag_line}\".strip()\n    return post\n",[18,20427,20428,20438,20473,20505,20536,20540,20544,20548,20569,20574,20587,20602,20625,20629,20648,20683,20697,20701,20712,20742,20749,20773,20777,20794,20799,20824,20839,20866,20896],{"__ignoreMap":258},[262,20429,20430,20433,20435],{"class":181,"line":264},[262,20431,20432],{"class":271},"PLATFORM_RULES",[262,20434,442],{"class":377},[262,20436,20437],{"class":429}," {\n",[262,20439,20440,20443,20446,20449,20451,20453,20455,20458,20460,20462,20464,20467,20469,20471],{"class":181,"line":282},[262,20441,20442],{"class":275},"    \"x\"",[262,20444,20445],{"class":429},": {",[262,20447,20448],{"class":275},"\"limit\"",[262,20450,1231],{"class":429},[262,20452,12816],{"class":271},[262,20454,608],{"class":429},[262,20456,20457],{"class":275},"\"max_tags\"",[262,20459,1231],{"class":429},[262,20461,5556],{"class":271},[262,20463,608],{"class":429},[262,20465,20466],{"class":275},"\"tags_inline\"",[262,20468,1231],{"class":429},[262,20470,4974],{"class":271},[262,20472,3143],{"class":429},[262,20474,20475,20478,20480,20482,20484,20487,20489,20491,20493,20495,20497,20499,20501,20503],{"class":181,"line":295},[262,20476,20477],{"class":275},"    \"instagram\"",[262,20479,20445],{"class":429},[262,20481,20448],{"class":275},[262,20483,1231],{"class":429},[262,20485,20486],{"class":271},"2200",[262,20488,608],{"class":429},[262,20490,20457],{"class":275},[262,20492,1231],{"class":429},[262,20494,221],{"class":271},[262,20496,608],{"class":429},[262,20498,20466],{"class":275},[262,20500,1231],{"class":429},[262,20502,3623],{"class":271},[262,20504,3143],{"class":429},[262,20506,20507,20510,20512,20514,20516,20518,20520,20522,20524,20526,20528,20530,20532,20534],{"class":181,"line":345},[262,20508,20509],{"class":275},"    \"linkedin\"",[262,20511,20445],{"class":429},[262,20513,20448],{"class":275},[262,20515,1231],{"class":429},[262,20517,16417],{"class":271},[262,20519,608],{"class":429},[262,20521,20457],{"class":275},[262,20523,1231],{"class":429},[262,20525,5556],{"class":271},[262,20527,608],{"class":429},[262,20529,20466],{"class":275},[262,20531,1231],{"class":429},[262,20533,3623],{"class":271},[262,20535,3143],{"class":429},[262,20537,20538],{"class":181,"line":492},[262,20539,16430],{"class":429},[262,20541,20542],{"class":181,"line":503},[262,20543,583],{"emptyLinePlaceholder":582},[262,20545,20546],{"class":181,"line":521},[262,20547,583],{"emptyLinePlaceholder":582},[262,20549,20550,20552,20555,20557,20559,20561,20563,20565,20567],{"class":181,"line":537},[262,20551,423],{"class":377},[262,20553,20554],{"class":267}," format_for_platform",[262,20556,4735],{"class":429},[262,20558,5869],{"class":271},[262,20560,16452],{"class":429},[262,20562,433],{"class":271},[262,20564,1939],{"class":429},[262,20566,433],{"class":271},[262,20568,1160],{"class":429},[262,20570,20571],{"class":181,"line":549},[262,20572,20573],{"class":275},"    \"\"\"Reshape a draft into a single ready-to-post string for one network.\"\"\"\n",[262,20575,20576,20579,20581,20584],{"class":181,"line":570},[262,20577,20578],{"class":429},"    rules ",[262,20580,476],{"class":377},[262,20582,20583],{"class":271}," PLATFORM_RULES",[262,20585,20586],{"class":429},".get(platform)\n",[262,20588,20589,20591,20594,20597,20600],{"class":181,"line":579},[262,20590,3454],{"class":377},[262,20592,20593],{"class":429}," rules ",[262,20595,20596],{"class":377},"is",[262,20598,20599],{"class":271}," None",[262,20601,1160],{"class":429},[262,20603,20604,20606,20608,20610,20612,20615,20617,20619,20621,20623],{"class":181,"line":586},[262,20605,4928],{"class":377},[262,20607,2832],{"class":271},[262,20609,602],{"class":429},[262,20611,642],{"class":377},[262,20613,20614],{"class":275},"\"Unknown platform: ",[262,20616,3039],{"class":271},[262,20618,16576],{"class":429},[262,20620,654],{"class":271},[262,20622,1176],{"class":275},[262,20624,660],{"class":429},[262,20626,20627],{"class":181,"line":591},[262,20628,583],{"emptyLinePlaceholder":582},[262,20630,20631,20634,20636,20638,20640,20643,20645],{"class":181,"line":623},[262,20632,20633],{"class":429},"    tags ",[262,20635,476],{"class":377},[262,20637,6158],{"class":429},[262,20639,20386],{"class":275},[262,20641,20642],{"class":429},"][: rules[",[262,20644,20457],{"class":275},[262,20646,20647],{"class":429},"]]\n",[262,20649,20650,20653,20655,20658,20660,20662,20664,20666,20669,20671,20673,20675,20678,20680],{"class":181,"line":634},[262,20651,20652],{"class":429},"    tag_line ",[262,20654,476],{"class":377},[262,20656,20657],{"class":275}," \" \"",[262,20659,2023],{"class":429},[262,20661,642],{"class":377},[262,20663,20365],{"class":275},[262,20665,3039],{"class":271},[262,20667,20668],{"class":429},"t",[262,20670,654],{"class":271},[262,20672,1176],{"class":275},[262,20674,10739],{"class":377},[262,20676,20677],{"class":429}," t ",[262,20679,835],{"class":377},[262,20681,20682],{"class":429}," tags)\n",[262,20684,20685,20688,20690,20692,20694],{"class":181,"line":845},[262,20686,20687],{"class":429},"    caption ",[262,20689,476],{"class":377},[262,20691,6158],{"class":429},[262,20693,20348],{"class":275},[262,20695,20696],{"class":429},"].strip()\n",[262,20698,20699],{"class":181,"line":850},[262,20700,583],{"emptyLinePlaceholder":582},[262,20702,20703,20705,20708,20710],{"class":181,"line":864},[262,20704,3454],{"class":377},[262,20706,20707],{"class":429}," rules[",[262,20709,20466],{"class":275},[262,20711,463],{"class":429},[262,20713,20714,20717,20719,20721,20723,20725,20728,20730,20732,20735,20737,20739],{"class":181,"line":1683},[262,20715,20716],{"class":429},"        post ",[262,20718,476],{"class":377},[262,20720,10178],{"class":377},[262,20722,1176],{"class":275},[262,20724,3039],{"class":271},[262,20726,20727],{"class":429},"caption",[262,20729,654],{"class":271},[262,20731,2276],{"class":271},[262,20733,20734],{"class":429},"tag_line",[262,20736,654],{"class":271},[262,20738,1176],{"class":275},[262,20740,20741],{"class":429},".strip()\n",[262,20743,20744,20747],{"class":181,"line":1688},[262,20745,20746],{"class":377},"    else",[262,20748,1160],{"class":429},[262,20750,20751,20753,20755,20757,20759,20761,20763,20765,20767,20769,20771],{"class":181,"line":1693},[262,20752,20716],{"class":429},[262,20754,476],{"class":377},[262,20756,10178],{"class":377},[262,20758,1176],{"class":275},[262,20760,3039],{"class":271},[262,20762,20727],{"class":429},[262,20764,18467],{"class":271},[262,20766,20734],{"class":429},[262,20768,654],{"class":271},[262,20770,1176],{"class":275},[262,20772,20741],{"class":429},[262,20774,20775],{"class":181,"line":1728},[262,20776,583],{"emptyLinePlaceholder":582},[262,20778,20779,20781,20783,20786,20788,20790,20792],{"class":181,"line":1737},[262,20780,3454],{"class":377},[262,20782,515],{"class":271},[262,20784,20785],{"class":429},"(post) ",[262,20787,8086],{"class":377},[262,20789,20707],{"class":429},[262,20791,20448],{"class":275},[262,20793,463],{"class":429},[262,20795,20796],{"class":181,"line":1751},[262,20797,20798],{"class":291},"        # Trim the caption, keep the tags, leave room for an ellipsis.\n",[262,20800,20801,20804,20806,20808,20810,20812,20814,20816,20819,20821],{"class":181,"line":1764},[262,20802,20803],{"class":429},"        room ",[262,20805,476],{"class":377},[262,20807,20707],{"class":429},[262,20809,20448],{"class":275},[262,20811,2903],{"class":429},[262,20813,561],{"class":377},[262,20815,515],{"class":271},[262,20817,20818],{"class":429},"(tag_line) ",[262,20820,561],{"class":377},[262,20822,20823],{"class":271}," 4\n",[262,20825,20826,20829,20831,20834,20836],{"class":181,"line":1779},[262,20827,20828],{"class":429},"        caption ",[262,20830,476],{"class":377},[262,20832,20833],{"class":429}," caption[:room].rstrip() ",[262,20835,531],{"class":377},[262,20837,20838],{"class":275}," \"...\"\n",[262,20840,20841,20844,20846,20848,20851,20853,20855,20857,20860,20862,20864],{"class":181,"line":1793},[262,20842,20843],{"class":429},"        joiner ",[262,20845,476],{"class":377},[262,20847,20657],{"class":275},[262,20849,20850],{"class":377}," if",[262,20852,20707],{"class":429},[262,20854,20466],{"class":275},[262,20856,2903],{"class":429},[262,20858,20859],{"class":377},"else",[262,20861,1170],{"class":275},[262,20863,1173],{"class":271},[262,20865,1257],{"class":275},[262,20867,20868,20870,20872,20874,20876,20878,20880,20883,20886,20888,20890,20892,20894],{"class":181,"line":1800},[262,20869,20716],{"class":429},[262,20871,476],{"class":377},[262,20873,10178],{"class":377},[262,20875,1176],{"class":275},[262,20877,3039],{"class":271},[262,20879,20727],{"class":429},[262,20881,20882],{"class":271},"}{",[262,20884,20885],{"class":429},"joiner",[262,20887,20882],{"class":271},[262,20889,20734],{"class":429},[262,20891,654],{"class":271},[262,20893,1176],{"class":275},[262,20895,20741],{"class":429},[262,20897,20898,20900],{"class":181,"line":1805},[262,20899,573],{"class":377},[262,20901,20902],{"class":429}," post\n",[14,20904,20905,20906,20908],{},"Look at how the rules live in a dictionary rather than scattered through ",[18,20907,2210],{}," statements. X gets a 280-character ceiling, three hashtags, and tags placed inline at the end of the sentence. Instagram tolerates a much longer caption, more tags, and the convention of dropping tags onto their own line below the caption. LinkedIn sits in between and reads as more formal. When a new network appears, or a platform changes its limit, you edit one row and the logic adapts. That is the payoff of keeping configuration separate from code.",[14,20910,20911,20912,20914,20915,1363],{},"The trimming branch is worth reading closely. If the assembled post exceeds the limit, it shortens the caption while preserving every hashtag, because losing a tag costs you reach but losing a few words of caption rarely matters. It leaves four characters of headroom for the ellipsis so the result never spills one character over the cap. This function never calls an external service, so it runs instantly and is trivial to test: feed it a draft, print the output, and eyeball it. Add a row to ",[18,20913,20432],{}," for any network you need; the rest of the pipeline does not change. To bulk-process many drafts through this same formatter at once, see ",[51,20916,15717],{"href":19701},[57,20918,20920],{"id":20919},"step-3-schedule-posts-with-a-queue","Step 3: Schedule posts with a queue",[14,20922,20923],{},"Now you have ready strings. They should not go out the instant they are generated; they should go out at the times you planned. APScheduler gives you a timezone-aware scheduler that holds jobs and fires each one at its appointed moment. Think of it as a queue where every item carries its own alarm clock.",[253,20925,20927],{"className":414,"code":20926,"language":416,"meta":258,"style":258},"from datetime import datetime\nfrom zoneinfo import ZoneInfo\nfrom apscheduler.schedulers.background import BackgroundScheduler\n\nscheduler = BackgroundScheduler(timezone=\"UTC\")\n\n\ndef enqueue_post(publish_fn, platform: str, text: str, when: datetime) -> None:\n    \"\"\"Place a single post on the queue to fire at a specific time.\"\"\"\n    scheduler.add_job(\n        publish_fn,\n        trigger=\"date\",\n        run_date=when,\n        args=[platform, text],\n        id=f\"{platform}-{when.isoformat()}\",\n        replace_existing=True,\n    )\n    print(f\"Queued {platform} post for {when.isoformat()}\")\n\n\n# Example: queue a post for 9:30 a.m. in New York.\nwhen = datetime(2026, 6, 19, 9, 30, tzinfo=ZoneInfo(\"America\u002FNew_York\"))\n",[18,20928,20929,20939,20951,20963,20967,20987,20991,20995,21018,21023,21028,21033,21044,21054,21064,21094,21105,21109,21139,21143,21147,21152],{"__ignoreMap":258},[262,20930,20931,20933,20935,20937],{"class":181,"line":264},[262,20932,705],{"class":377},[262,20934,10502],{"class":429},[262,20936,684],{"class":377},[262,20938,15875],{"class":429},[262,20940,20941,20943,20946,20948],{"class":181,"line":282},[262,20942,705],{"class":377},[262,20944,20945],{"class":429}," zoneinfo ",[262,20947,684],{"class":377},[262,20949,20950],{"class":429}," ZoneInfo\n",[262,20952,20953,20955,20958,20960],{"class":181,"line":295},[262,20954,705],{"class":377},[262,20956,20957],{"class":429}," apscheduler.schedulers.background ",[262,20959,684],{"class":377},[262,20961,20962],{"class":429}," BackgroundScheduler\n",[262,20964,20965],{"class":181,"line":345},[262,20966,583],{"emptyLinePlaceholder":582},[262,20968,20969,20972,20974,20977,20980,20982,20985],{"class":181,"line":492},[262,20970,20971],{"class":429},"scheduler ",[262,20973,476],{"class":377},[262,20975,20976],{"class":429}," BackgroundScheduler(",[262,20978,20979],{"class":611},"timezone",[262,20981,476],{"class":377},[262,20983,20984],{"class":275},"\"UTC\"",[262,20986,660],{"class":429},[262,20988,20989],{"class":181,"line":503},[262,20990,583],{"emptyLinePlaceholder":582},[262,20992,20993],{"class":181,"line":521},[262,20994,583],{"emptyLinePlaceholder":582},[262,20996,20997,20999,21002,21005,21007,21009,21011,21014,21016],{"class":181,"line":537},[262,20998,423],{"class":377},[262,21000,21001],{"class":267}," enqueue_post",[262,21003,21004],{"class":429},"(publish_fn, platform: ",[262,21006,433],{"class":271},[262,21008,13943],{"class":429},[262,21010,433],{"class":271},[262,21012,21013],{"class":429},", when: datetime) -> ",[262,21015,8471],{"class":271},[262,21017,1160],{"class":429},[262,21019,21020],{"class":181,"line":549},[262,21021,21022],{"class":275},"    \"\"\"Place a single post on the queue to fire at a specific time.\"\"\"\n",[262,21024,21025],{"class":181,"line":570},[262,21026,21027],{"class":429},"    scheduler.add_job(\n",[262,21029,21030],{"class":181,"line":579},[262,21031,21032],{"class":429},"        publish_fn,\n",[262,21034,21035,21038,21040,21042],{"class":181,"line":586},[262,21036,21037],{"class":611},"        trigger",[262,21039,476],{"class":377},[262,21041,17468],{"class":275},[262,21043,1315],{"class":429},[262,21045,21046,21049,21051],{"class":181,"line":591},[262,21047,21048],{"class":611},"        run_date",[262,21050,476],{"class":377},[262,21052,21053],{"class":429},"when,\n",[262,21055,21056,21059,21061],{"class":181,"line":623},[262,21057,21058],{"class":611},"        args",[262,21060,476],{"class":377},[262,21062,21063],{"class":429},"[platform, text],\n",[262,21065,21066,21069,21071,21073,21075,21077,21079,21081,21083,21085,21088,21090,21092],{"class":181,"line":634},[262,21067,21068],{"class":611},"        id",[262,21070,476],{"class":377},[262,21072,642],{"class":377},[262,21074,1176],{"class":275},[262,21076,3039],{"class":271},[262,21078,16576],{"class":429},[262,21080,654],{"class":271},[262,21082,561],{"class":275},[262,21084,3039],{"class":271},[262,21086,21087],{"class":429},"when.isoformat()",[262,21089,654],{"class":271},[262,21091,1176],{"class":275},[262,21093,1315],{"class":429},[262,21095,21096,21099,21101,21103],{"class":181,"line":845},[262,21097,21098],{"class":611},"        replace_existing",[262,21100,476],{"class":377},[262,21102,4974],{"class":271},[262,21104,1315],{"class":429},[262,21106,21107],{"class":181,"line":850},[262,21108,1011],{"class":429},[262,21110,21111,21113,21115,21117,21120,21122,21124,21126,21129,21131,21133,21135,21137],{"class":181,"line":864},[262,21112,1089],{"class":271},[262,21114,602],{"class":429},[262,21116,642],{"class":377},[262,21118,21119],{"class":275},"\"Queued ",[262,21121,3039],{"class":271},[262,21123,16576],{"class":429},[262,21125,654],{"class":271},[262,21127,21128],{"class":275}," post for ",[262,21130,3039],{"class":271},[262,21132,21087],{"class":429},[262,21134,654],{"class":271},[262,21136,1176],{"class":275},[262,21138,660],{"class":429},[262,21140,21141],{"class":181,"line":1683},[262,21142,583],{"emptyLinePlaceholder":582},[262,21144,21145],{"class":181,"line":1688},[262,21146,583],{"emptyLinePlaceholder":582},[262,21148,21149],{"class":181,"line":1693},[262,21150,21151],{"class":291},"# Example: queue a post for 9:30 a.m. in New York.\n",[262,21153,21154,21157,21159,21162,21165,21167,21169,21171,21174,21176,21178,21180,21182,21184,21187,21189,21192,21195],{"class":181,"line":1728},[262,21155,21156],{"class":429},"when ",[262,21158,476],{"class":377},[262,21160,21161],{"class":429}," datetime(",[262,21163,21164],{"class":271},"2026",[262,21166,608],{"class":429},[262,21168,221],{"class":271},[262,21170,608],{"class":429},[262,21172,21173],{"class":271},"19",[262,21175,608],{"class":429},[262,21177,7162],{"class":271},[262,21179,608],{"class":429},[262,21181,9777],{"class":271},[262,21183,608],{"class":429},[262,21185,21186],{"class":611},"tzinfo",[262,21188,476],{"class":377},[262,21190,21191],{"class":429},"ZoneInfo(",[262,21193,21194],{"class":275},"\"America\u002FNew_York\"",[262,21196,2684],{"class":429},[14,21198,3349,21199,21202,21203,7918,21206,21209,21210,21213],{},[18,21200,21201],{},"trigger=\"date\""," option means \"run once at this exact time\". If you instead want a post every weekday morning, swap in ",[18,21204,21205],{},"trigger=\"cron\"",[18,21207,21208],{},"day_of_week=\"mon-fri\", hour=9, minute=30"," and the same job will fire on a repeating schedule with no further code. That ",[18,21211,21212],{},"cron"," trigger is the same idea as a system cron job, except it lives inside your Python process where you can test it, log it, and change it without editing operating-system files.",[14,21215,21216,21217,21219,21220,21222,21223,21226,21227,21231],{},"Two details prevent the most common scheduling bugs. First, giving each job a stable ",[18,21218,9492],{}," plus ",[18,21221,17476],{}," means re-running your script updates the existing schedule instead of stacking up duplicate posts, which is exactly what happens the first time you forget. Second, always attach a timezone to your datetime, as shown with ",[18,21224,21225],{},"ZoneInfo",", so 9:30 means 9:30 for your audience rather than 9:30 in whatever timezone the server happens to use. A scheduler that posts your morning content at 4 a.m. because the server runs in UTC is a classic, avoidable mistake. If you are still learning how repeated jobs and timed scripts fit together, the ",[51,21228,21230],{"href":21229},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks\u002F","Automating Repetitive Tasks with Python"," section builds that foundation from scratch.",[57,21233,21235],{"id":21234},"step-4-publish-through-the-platform-api","Step 4: Publish through the platform API",[14,21237,21238,21239,21241],{},"The last stage sends a formatted post to the network. Each platform has its own endpoint and payload, but the shape is the same: a POST request with an authorization header and a JSON body. Use ",[18,21240,5450],{}," and always check the response status so a silent failure does not look like success.",[253,21243,21245],{"className":414,"code":21244,"language":416,"meta":258,"style":258},"import os\nimport httpx\n\n\ndef publish(platform: str, text: str) -> dict:\n    \"\"\"Send one formatted post to its platform API and confirm the result.\"\"\"\n    if platform == \"x\":\n        url = \"https:\u002F\u002Fapi.twitter.com\u002F2\u002Ftweets\"\n        token = os.getenv(\"X_BEARER_TOKEN\")\n        payload = {\"text\": text}\n    elif platform == \"linkedin\":\n        url = \"https:\u002F\u002Fapi.linkedin.com\u002Fv2\u002FugcPosts\"\n        token = os.getenv(\"LINKEDIN_ACCESS_TOKEN\")\n        payload = {\"commentary\": text}  # simplified body\n    else:\n        raise ValueError(f\"No publisher configured for {platform}\")\n\n    headers = {\"Authorization\": f\"Bearer {token}\",\n               \"Content-Type\": \"application\u002Fjson\"}\n    with httpx.Client(timeout=20.0) as http:\n        response = http.post(url, headers=headers, json=payload)\n        response.raise_for_status()\n    print(f\"Published to {platform}: HTTP {response.status_code}\")\n    return response.json()\n",[18,21246,21247,21253,21259,21263,21267,21288,21293,21307,21317,21331,21345,21359,21368,21381,21398,21404,21427,21431,21457,21468,21486,21508,21513,21544],{"__ignoreMap":258},[262,21248,21249,21251],{"class":181,"line":264},[262,21250,684],{"class":377},[262,21252,687],{"class":429},[262,21254,21255,21257],{"class":181,"line":282},[262,21256,684],{"class":377},[262,21258,6526],{"class":429},[262,21260,21261],{"class":181,"line":295},[262,21262,583],{"emptyLinePlaceholder":582},[262,21264,21265],{"class":181,"line":345},[262,21266,583],{"emptyLinePlaceholder":582},[262,21268,21269,21271,21273,21276,21278,21280,21282,21284,21286],{"class":181,"line":492},[262,21270,423],{"class":377},[262,21272,16917],{"class":267},[262,21274,21275],{"class":429},"(platform: ",[262,21277,433],{"class":271},[262,21279,13943],{"class":429},[262,21281,433],{"class":271},[262,21283,1939],{"class":429},[262,21285,5869],{"class":271},[262,21287,1160],{"class":429},[262,21289,21290],{"class":181,"line":503},[262,21291,21292],{"class":275},"    \"\"\"Send one formatted post to its platform API and confirm the result.\"\"\"\n",[262,21294,21295,21297,21300,21302,21305],{"class":181,"line":521},[262,21296,3454],{"class":377},[262,21298,21299],{"class":429}," platform ",[262,21301,10758],{"class":377},[262,21303,21304],{"class":275}," \"x\"",[262,21306,1160],{"class":429},[262,21308,21309,21312,21314],{"class":181,"line":537},[262,21310,21311],{"class":429},"        url ",[262,21313,476],{"class":377},[262,21315,21316],{"class":275}," \"https:\u002F\u002Fapi.twitter.com\u002F2\u002Ftweets\"\n",[262,21318,21319,21322,21324,21326,21329],{"class":181,"line":549},[262,21320,21321],{"class":429},"        token ",[262,21323,476],{"class":377},[262,21325,754],{"class":429},[262,21327,21328],{"class":275},"\"X_BEARER_TOKEN\"",[262,21330,660],{"class":429},[262,21332,21333,21336,21338,21340,21342],{"class":181,"line":570},[262,21334,21335],{"class":429},"        payload ",[262,21337,476],{"class":377},[262,21339,2276],{"class":429},[262,21341,16074],{"class":275},[262,21343,21344],{"class":429},": text}\n",[262,21346,21347,21350,21352,21354,21357],{"class":181,"line":579},[262,21348,21349],{"class":377},"    elif",[262,21351,21299],{"class":429},[262,21353,10758],{"class":377},[262,21355,21356],{"class":275}," \"linkedin\"",[262,21358,1160],{"class":429},[262,21360,21361,21363,21365],{"class":181,"line":586},[262,21362,21311],{"class":429},[262,21364,476],{"class":377},[262,21366,21367],{"class":275}," \"https:\u002F\u002Fapi.linkedin.com\u002Fv2\u002FugcPosts\"\n",[262,21369,21370,21372,21374,21376,21379],{"class":181,"line":591},[262,21371,21321],{"class":429},[262,21373,476],{"class":377},[262,21375,754],{"class":429},[262,21377,21378],{"class":275},"\"LINKEDIN_ACCESS_TOKEN\"",[262,21380,660],{"class":429},[262,21382,21383,21385,21387,21389,21392,21395],{"class":181,"line":623},[262,21384,21335],{"class":429},[262,21386,476],{"class":377},[262,21388,2276],{"class":429},[262,21390,21391],{"class":275},"\"commentary\"",[262,21393,21394],{"class":429},": text}  ",[262,21396,21397],{"class":291},"# simplified body\n",[262,21399,21400,21402],{"class":181,"line":634},[262,21401,20746],{"class":377},[262,21403,1160],{"class":429},[262,21405,21406,21408,21410,21412,21414,21417,21419,21421,21423,21425],{"class":181,"line":845},[262,21407,4928],{"class":377},[262,21409,2832],{"class":271},[262,21411,602],{"class":429},[262,21413,642],{"class":377},[262,21415,21416],{"class":275},"\"No publisher configured for ",[262,21418,3039],{"class":271},[262,21420,16576],{"class":429},[262,21422,654],{"class":271},[262,21424,1176],{"class":275},[262,21426,660],{"class":429},[262,21428,21429],{"class":181,"line":850},[262,21430,583],{"emptyLinePlaceholder":582},[262,21432,21433,21435,21437,21439,21441,21443,21445,21447,21449,21451,21453,21455],{"class":181,"line":864},[262,21434,16991],{"class":429},[262,21436,476],{"class":377},[262,21438,2276],{"class":429},[262,21440,16998],{"class":275},[262,21442,1231],{"class":429},[262,21444,642],{"class":377},[262,21446,6605],{"class":275},[262,21448,3039],{"class":271},[262,21450,7933],{"class":429},[262,21452,654],{"class":271},[262,21454,1176],{"class":275},[262,21456,1315],{"class":429},[262,21458,21459,21462,21464,21466],{"class":181,"line":1683},[262,21460,21461],{"class":275},"               \"Content-Type\"",[262,21463,1231],{"class":429},[262,21465,6630],{"class":275},[262,21467,16430],{"class":429},[262,21469,21470,21472,21474,21476,21478,21480,21482,21484],{"class":181,"line":1688},[262,21471,10124],{"class":377},[262,21473,17018],{"class":429},[262,21475,1591],{"class":611},[262,21477,476],{"class":377},[262,21479,1596],{"class":271},[262,21481,1000],{"class":429},[262,21483,697],{"class":377},[262,21485,17032],{"class":429},[262,21487,21488,21491,21493,21496,21498,21500,21502,21504,21506],{"class":181,"line":1693},[262,21489,21490],{"class":429},"        response ",[262,21492,476],{"class":377},[262,21494,21495],{"class":429}," http.post(url, ",[262,21497,17057],{"class":611},[262,21499,476],{"class":377},[262,21501,19503],{"class":429},[262,21503,17049],{"class":611},[262,21505,476],{"class":377},[262,21507,19510],{"class":429},[262,21509,21510],{"class":181,"line":1728},[262,21511,21512],{"class":429},"        response.raise_for_status()\n",[262,21514,21515,21517,21519,21521,21524,21526,21528,21530,21533,21535,21538,21540,21542],{"class":181,"line":1737},[262,21516,1089],{"class":271},[262,21518,602],{"class":429},[262,21520,642],{"class":377},[262,21522,21523],{"class":275},"\"Published to ",[262,21525,3039],{"class":271},[262,21527,16576],{"class":429},[262,21529,654],{"class":271},[262,21531,21532],{"class":275},": HTTP ",[262,21534,3039],{"class":271},[262,21536,21537],{"class":429},"response.status_code",[262,21539,654],{"class":271},[262,21541,1176],{"class":275},[262,21543,660],{"class":429},[262,21545,21546,21548],{"class":181,"line":1751},[262,21547,573],{"class":377},[262,21549,6710],{"class":429},[14,21551,21552,21554,21555,21557,21558,21561],{},[18,21553,6778],{}," is the line that keeps you honest. An HTTP response carries a status code: 200-range codes mean success, 400-range codes mean you sent something wrong, and 500-range codes mean the platform broke. Without this call, a rejected post returns a perfectly normal-looking response object and your script prints nothing alarming, so you only discover the failure when someone asks why the post never went up. ",[18,21556,6778],{}," turns any 4xx or 5xx response into a loud Python error you cannot miss. The ",[18,21559,21560],{},"timeout=20.0"," is the other safety net: it stops your scheduler from hanging forever if a platform stalls, freeing the process to move on to the next job.",[14,21563,3349,21564,21567,21568,21570,21571,21573],{},[18,21565,21566],{},"with httpx.Client(...)"," block is a small but real best practice. It opens a connection, sends the request, and closes the connection cleanly even if an error fires mid-request, so you do not leak open sockets across a long-running scheduler. Each network's exact payload differs, so always check its current API docs before going live; the bodies shown here are deliberately simplified to keep the shape visible. The Instagram flow in particular needs a two-step upload (create a media container, then publish it), which the ",[51,21569,15735],{"href":15734}," guide walks through in full. For thread-style content on X, the ",[51,21572,17687],{"href":17686}," guide chains several of these publish calls together, passing each reply the id of the post before it.",[57,21575,8300],{"id":8299},[14,21577,21578],{},"These are the settings you will adjust most often across the four steps. Defaults are sensible starting points.",[1379,21580,21581,21593],{},[1382,21582,21583],{},[1385,21584,21585,21587,21589,21591],{},[1388,21586,1390],{},[1388,21588,3795],{},[1388,21590,3798],{},[1388,21592,1396],{},[1398,21594,21595,21610,21625,21643,21657,21671,21693,21709],{},[1385,21596,21597,21601,21603,21607],{},[1403,21598,21599],{},[18,21600,805],{},[1403,21602,433],{},[1403,21604,21605],{},[18,21606,2703],{},[1403,21608,21609],{},"Which AI model drafts the copy. Larger models cost more but write tighter.",[1385,21611,21612,21616,21618,21622],{},[1403,21613,21614],{},[18,21615,3829],{},[1403,21617,3832],{},[1403,21619,21620],{},[18,21621,20284],{},[1403,21623,21624],{},"Creativity of the copy. Lower is safer and more repetitive; higher is bolder.",[1385,21626,21627,21631,21633,21637],{},[1403,21628,21629],{},[18,21630,5745],{},[1403,21632,5869],{},[1403,21634,21635],{},[18,21636,6841],{},[1403,21638,21639,21640,21642],{},"Forces valid JSON so ",[18,21641,20396],{}," never fails on stray prose.",[1385,21644,21645,21649,21651,21654],{},[1403,21646,21647],{},[18,21648,16586],{},[1403,21650,439],{},[1403,21652,21653],{},"per platform",[1403,21655,21656],{},"Character ceiling the formatter enforces before trimming.",[1385,21658,21659,21664,21666,21668],{},[1403,21660,21661],{},[18,21662,21663],{},"max_tags",[1403,21665,439],{},[1403,21667,21653],{},[1403,21669,21670],{},"How many hashtags survive the formatter.",[1385,21672,21673,21678,21680,21685],{},[1403,21674,21675],{},[18,21676,21677],{},"trigger",[1403,21679,433],{},[1403,21681,21682],{},[18,21683,21684],{},"date",[1403,21686,21687,21689,21690,21692],{},[18,21688,21684],{}," fires once; ",[18,21691,21212],{}," repeats on a recurring schedule.",[1385,21694,21695,21699,21701,21706],{},[1403,21696,21697],{},[18,21698,20979],{},[1403,21700,433],{},[1403,21702,21703],{},[18,21704,21705],{},"UTC",[1403,21707,21708],{},"The clock the scheduler reads. Set per audience, not per server.",[1385,21710,21711,21715,21717,21721],{},[1403,21712,21713],{},[18,21714,1591],{},[1403,21716,3832],{},[1403,21718,21719],{},[18,21720,1596],{},[1403,21722,6859,21723,21725],{},[18,21724,5450],{}," waits before giving up on a slow platform.",[57,21727,1445],{"id":1444},[14,21729,21730],{},"These are the errors you are most likely to hit, with the real cause and a one-line fix for each.",[1447,21732,21733,21753,21766,21773,21784,21804],{},[1450,21734,21735,8504,21740,21743,21744,21746,21747,21749,21750,21752],{},[35,21736,21737],{},[18,21738,21739],{},"openai.AuthenticationError: Incorrect API key provided",[18,21741,21742],{},"OPENAI_API_KEY"," is missing or wrong. Confirm ",[18,21745,319],{}," exists in the folder you run from and that ",[18,21748,8439],{}," runs before you create the client. The ",[51,21751,388],{"href":387}," guide covers this in depth.",[1450,21754,21755,21760,21761,21763,21764,1363],{},[35,21756,21757],{},[18,21758,21759],{},"json.decoder.JSONDecodeError: Expecting value"," — The model returned prose instead of JSON. Keep ",[18,21762,6878],{}," and make sure the word \"JSON\" appears in your prompt. See ",[51,21765,6114],{"href":6113},[1450,21767,21768,21772],{},[35,21769,21770],{},[18,21771,17621],{}," — Your platform token is expired or lacks posting permission. Regenerate a long-lived token in the platform's developer portal and confirm the app has write scope.",[1450,21774,21775,21780,21781,21783],{},[35,21776,21777],{},[18,21778,21779],{},"httpx.HTTPStatusError: 429 Too Many Requests"," — You posted faster than the platform allows. Space jobs out in the scheduler and add a short delay between accounts. The ",[51,21782,3379],{"href":3378}," guide shows a backoff pattern.",[1450,21785,21786,21791,21792,21794,21795,3921,21797,21800,21801,21803],{},[35,21787,21788],{},[18,21789,21790],{},"apscheduler.jobstores.base.ConflictingIdError"," — Two jobs share an ",[18,21793,9492],{},". Add ",[18,21796,17476],{},[18,21798,21799],{},"add_job",", or build a unique ",[18,21802,9492],{}," per post as shown in Step 3.",[1450,21805,21806,21809,21810,21813],{},[35,21807,21808],{},"Posts fire at the wrong time"," — Your datetime had no timezone, so the server's clock was used. Always attach ",[18,21811,21812],{},"tzinfo=ZoneInfo(\"Area\u002FCity\")"," to every scheduled datetime.",[57,21815,21817],{"id":21816},"worked-example-a-complete-scheduler","Worked example: a complete scheduler",[14,21819,21820],{},"This script stitches all four steps into one runnable file. It drafts copy, formats it for X, queues it for a chosen time, and publishes it when the moment arrives. Run it, then leave it running until the job fires.",[253,21822,21824],{"className":414,"code":21823,"language":416,"meta":258,"style":258},"import os\nimport json\nimport time\nfrom datetime import datetime, timedelta\nfrom zoneinfo import ZoneInfo\n\nimport httpx\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\nfrom apscheduler.schedulers.background import BackgroundScheduler\n\nload_dotenv()                                      # load secrets from .env\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\nscheduler = BackgroundScheduler(timezone=\"UTC\")\n\n\ndef generate_copy(topic: str) -> dict:\n    prompt = (f\"Write one X post about {topic}. Return JSON with keys \"\n              \"'caption' (string) and 'hashtags' (list of 3 lowercase tags).\")\n    res = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        response_format={\"type\": \"json_object\"},\n    )\n    return json.loads(res.choices[0].message.content)\n\n\ndef format_for_x(draft: dict) -> str:\n    tags = \" \".join(f\"#{t}\" for t in draft[\"hashtags\"][:3])\n    post = f\"{draft['caption']} {tags}\".strip()\n    return post[:277] + \"...\" if len(post) > 280 else post   # keep under 280\n\n\ndef publish(platform: str, text: str) -> None:\n    headers = {\"Authorization\": f\"Bearer {os.getenv('X_BEARER_TOKEN')}\",\n               \"Content-Type\": \"application\u002Fjson\"}\n    with httpx.Client(timeout=20.0) as http:\n        r = http.post(\"https:\u002F\u002Fapi.twitter.com\u002F2\u002Ftweets\",\n                      headers=headers, json={\"text\": text})\n        r.raise_for_status()\n    print(f\"Published to {platform}: HTTP {r.status_code}\")\n\n\nif __name__ == \"__main__\":\n    draft = generate_copy(\"shipping a tiny side project this weekend\")\n    ready = format_for_x(draft)\n    when = datetime.now(ZoneInfo(\"UTC\")) + timedelta(seconds=15)   # demo delay\n    scheduler.add_job(publish, \"date\", run_date=when,\n                      args=[\"x\", ready], id=\"demo-post\", replace_existing=True)\n    scheduler.start()\n    print(f\"Queued for {when.isoformat()}: {ready}\")\n    time.sleep(30)                                  # keep the process alive\n",[18,21825,21826,21832,21838,21844,21855,21865,21869,21875,21885,21895,21905,21909,21917,21935,21951,21955,21959,21975,21997,22004,22012,22022,22042,22058,22062,22072,22076,22080,22097,22136,22170,22205,22209,22213,22233,22263,22273,22291,22304,22324,22329,22358,22362,22366,22378,22391,22401,22433,22448,22480,22484,22514],{"__ignoreMap":258},[262,21827,21828,21830],{"class":181,"line":264},[262,21829,684],{"class":377},[262,21831,687],{"class":429},[262,21833,21834,21836],{"class":181,"line":282},[262,21835,684],{"class":377},[262,21837,5766],{"class":429},[262,21839,21840,21842],{"class":181,"line":295},[262,21841,684],{"class":377},[262,21843,2612],{"class":429},[262,21845,21846,21848,21850,21852],{"class":181,"line":345},[262,21847,705],{"class":377},[262,21849,10502],{"class":429},[262,21851,684],{"class":377},[262,21853,21854],{"class":429}," datetime, timedelta\n",[262,21856,21857,21859,21861,21863],{"class":181,"line":492},[262,21858,705],{"class":377},[262,21860,20945],{"class":429},[262,21862,684],{"class":377},[262,21864,20950],{"class":429},[262,21866,21867],{"class":181,"line":503},[262,21868,583],{"emptyLinePlaceholder":582},[262,21870,21871,21873],{"class":181,"line":521},[262,21872,684],{"class":377},[262,21874,6526],{"class":429},[262,21876,21877,21879,21881,21883],{"class":181,"line":537},[262,21878,705],{"class":377},[262,21880,720],{"class":429},[262,21882,684],{"class":377},[262,21884,725],{"class":429},[262,21886,21887,21889,21891,21893],{"class":181,"line":549},[262,21888,705],{"class":377},[262,21890,708],{"class":429},[262,21892,684],{"class":377},[262,21894,713],{"class":429},[262,21896,21897,21899,21901,21903],{"class":181,"line":570},[262,21898,705],{"class":377},[262,21900,20957],{"class":429},[262,21902,684],{"class":377},[262,21904,20962],{"class":429},[262,21906,21907],{"class":181,"line":579},[262,21908,583],{"emptyLinePlaceholder":582},[262,21910,21911,21914],{"class":181,"line":586},[262,21912,21913],{"class":429},"load_dotenv()                                      ",[262,21915,21916],{"class":291},"# load secrets from .env\n",[262,21918,21919,21921,21923,21925,21927,21929,21931,21933],{"class":181,"line":591},[262,21920,739],{"class":429},[262,21922,476],{"class":377},[262,21924,1588],{"class":429},[262,21926,2674],{"class":611},[262,21928,476],{"class":377},[262,21930,1199],{"class":429},[262,21932,2681],{"class":275},[262,21934,2684],{"class":429},[262,21936,21937,21939,21941,21943,21945,21947,21949],{"class":181,"line":623},[262,21938,20971],{"class":429},[262,21940,476],{"class":377},[262,21942,20976],{"class":429},[262,21944,20979],{"class":611},[262,21946,476],{"class":377},[262,21948,20984],{"class":275},[262,21950,660],{"class":429},[262,21952,21953],{"class":181,"line":634},[262,21954,583],{"emptyLinePlaceholder":582},[262,21956,21957],{"class":181,"line":845},[262,21958,583],{"emptyLinePlaceholder":582},[262,21960,21961,21963,21965,21967,21969,21971,21973],{"class":181,"line":850},[262,21962,423],{"class":377},[262,21964,7799],{"class":267},[262,21966,4287],{"class":429},[262,21968,433],{"class":271},[262,21970,1939],{"class":429},[262,21972,5869],{"class":271},[262,21974,1160],{"class":429},[262,21976,21977,21979,21981,21983,21985,21988,21990,21992,21994],{"class":181,"line":864},[262,21978,18006],{"class":429},[262,21980,476],{"class":377},[262,21982,13751],{"class":429},[262,21984,642],{"class":377},[262,21986,21987],{"class":275},"\"Write one X post about ",[262,21989,3039],{"class":271},[262,21991,4402],{"class":429},[262,21993,654],{"class":271},[262,21995,21996],{"class":275},". Return JSON with keys \"\n",[262,21998,21999,22002],{"class":181,"line":1683},[262,22000,22001],{"class":275},"              \"'caption' (string) and 'hashtags' (list of 3 lowercase tags).\"",[262,22003,660],{"class":429},[262,22005,22006,22008,22010],{"class":181,"line":1688},[262,22007,19054],{"class":429},[262,22009,476],{"class":377},[262,22011,1189],{"class":429},[262,22013,22014,22016,22018,22020],{"class":181,"line":1693},[262,22015,1194],{"class":611},[262,22017,476],{"class":377},[262,22019,1207],{"class":275},[262,22021,1315],{"class":429},[262,22023,22024,22026,22028,22030,22032,22034,22036,22038,22040],{"class":181,"line":1728},[262,22025,1215],{"class":611},[262,22027,476],{"class":377},[262,22029,8856],{"class":429},[262,22031,1228],{"class":275},[262,22033,1231],{"class":429},[262,22035,1291],{"class":275},[262,22037,608],{"class":429},[262,22039,1239],{"class":275},[262,22041,18141],{"class":429},[262,22043,22044,22046,22048,22050,22052,22054,22056],{"class":181,"line":1737},[262,22045,6018],{"class":611},[262,22047,476],{"class":377},[262,22049,3039],{"class":429},[262,22051,6025],{"class":275},[262,22053,1231],{"class":429},[262,22055,6030],{"class":275},[262,22057,3143],{"class":429},[262,22059,22060],{"class":181,"line":1751},[262,22061,1011],{"class":429},[262,22063,22064,22066,22068,22070],{"class":181,"line":1764},[262,22065,573],{"class":377},[262,22067,19115],{"class":429},[262,22069,102],{"class":271},[262,22071,6048],{"class":429},[262,22073,22074],{"class":181,"line":1779},[262,22075,583],{"emptyLinePlaceholder":582},[262,22077,22078],{"class":181,"line":1793},[262,22079,583],{"emptyLinePlaceholder":582},[262,22081,22082,22084,22087,22089,22091,22093,22095],{"class":181,"line":1800},[262,22083,423],{"class":377},[262,22085,22086],{"class":267}," format_for_x",[262,22088,4735],{"class":429},[262,22090,5869],{"class":271},[262,22092,1939],{"class":429},[262,22094,433],{"class":271},[262,22096,1160],{"class":429},[262,22098,22099,22101,22103,22105,22107,22109,22111,22113,22115,22117,22119,22121,22123,22125,22127,22129,22132,22134],{"class":181,"line":1805},[262,22100,20633],{"class":429},[262,22102,476],{"class":377},[262,22104,20657],{"class":275},[262,22106,2023],{"class":429},[262,22108,642],{"class":377},[262,22110,20365],{"class":275},[262,22112,3039],{"class":271},[262,22114,20668],{"class":429},[262,22116,654],{"class":271},[262,22118,1176],{"class":275},[262,22120,10739],{"class":377},[262,22122,20677],{"class":429},[262,22124,835],{"class":377},[262,22126,6158],{"class":429},[262,22128,20386],{"class":275},[262,22130,22131],{"class":429},"][:",[262,22133,5556],{"class":271},[262,22135,3512],{"class":429},[262,22137,22138,22141,22143,22145,22147,22149,22152,22155,22157,22159,22161,22164,22166,22168],{"class":181,"line":1810},[262,22139,22140],{"class":429},"    post ",[262,22142,476],{"class":377},[262,22144,10178],{"class":377},[262,22146,1176],{"class":275},[262,22148,3039],{"class":271},[262,22150,22151],{"class":429},"draft[",[262,22153,22154],{"class":275},"'caption'",[262,22156,6223],{"class":429},[262,22158,654],{"class":271},[262,22160,2276],{"class":271},[262,22162,22163],{"class":429},"tags",[262,22165,654],{"class":271},[262,22167,1176],{"class":275},[262,22169,20741],{"class":429},[262,22171,22172,22174,22177,22180,22182,22184,22187,22189,22191,22193,22195,22197,22199,22202],{"class":181,"line":1823},[262,22173,573],{"class":377},[262,22175,22176],{"class":429}," post[:",[262,22178,22179],{"class":271},"277",[262,22181,2903],{"class":429},[262,22183,531],{"class":377},[262,22185,22186],{"class":275}," \"...\"",[262,22188,20850],{"class":377},[262,22190,515],{"class":271},[262,22192,20785],{"class":429},[262,22194,8086],{"class":377},[262,22196,17995],{"class":271},[262,22198,19241],{"class":377},[262,22200,22201],{"class":429}," post   ",[262,22203,22204],{"class":291},"# keep under 280\n",[262,22206,22207],{"class":181,"line":1846},[262,22208,583],{"emptyLinePlaceholder":582},[262,22210,22211],{"class":181,"line":1861},[262,22212,583],{"emptyLinePlaceholder":582},[262,22214,22215,22217,22219,22221,22223,22225,22227,22229,22231],{"class":181,"line":1866},[262,22216,423],{"class":377},[262,22218,16917],{"class":267},[262,22220,21275],{"class":429},[262,22222,433],{"class":271},[262,22224,13943],{"class":429},[262,22226,433],{"class":271},[262,22228,1939],{"class":429},[262,22230,8471],{"class":271},[262,22232,1160],{"class":429},[262,22234,22235,22237,22239,22241,22243,22245,22247,22249,22251,22253,22255,22257,22259,22261],{"class":181,"line":1871},[262,22236,16991],{"class":429},[262,22238,476],{"class":377},[262,22240,2276],{"class":429},[262,22242,16998],{"class":275},[262,22244,1231],{"class":429},[262,22246,642],{"class":377},[262,22248,6605],{"class":275},[262,22250,3039],{"class":271},[262,22252,1199],{"class":429},[262,22254,18609],{"class":275},[262,22256,5987],{"class":429},[262,22258,654],{"class":271},[262,22260,1176],{"class":275},[262,22262,1315],{"class":429},[262,22264,22265,22267,22269,22271],{"class":181,"line":1890},[262,22266,21461],{"class":275},[262,22268,1231],{"class":429},[262,22270,6630],{"class":275},[262,22272,16430],{"class":429},[262,22274,22275,22277,22279,22281,22283,22285,22287,22289],{"class":181,"line":1909},[262,22276,10124],{"class":377},[262,22278,17018],{"class":429},[262,22280,1591],{"class":611},[262,22282,476],{"class":377},[262,22284,1596],{"class":271},[262,22286,1000],{"class":429},[262,22288,697],{"class":377},[262,22290,17032],{"class":429},[262,22292,22293,22296,22298,22300,22302],{"class":181,"line":1914},[262,22294,22295],{"class":429},"        r ",[262,22297,476],{"class":377},[262,22299,17042],{"class":429},[262,22301,19494],{"class":275},[262,22303,1315],{"class":429},[262,22305,22306,22309,22311,22313,22315,22317,22319,22321],{"class":181,"line":1919},[262,22307,22308],{"class":611},"                      headers",[262,22310,476],{"class":377},[262,22312,19503],{"class":429},[262,22314,17049],{"class":611},[262,22316,476],{"class":377},[262,22318,3039],{"class":429},[262,22320,16074],{"class":275},[262,22322,22323],{"class":429},": text})\n",[262,22325,22326],{"class":181,"line":1946},[262,22327,22328],{"class":429},"        r.raise_for_status()\n",[262,22330,22331,22333,22335,22337,22339,22341,22343,22345,22347,22349,22352,22354,22356],{"class":181,"line":1959},[262,22332,1089],{"class":271},[262,22334,602],{"class":429},[262,22336,642],{"class":377},[262,22338,21523],{"class":275},[262,22340,3039],{"class":271},[262,22342,16576],{"class":429},[262,22344,654],{"class":271},[262,22346,21532],{"class":275},[262,22348,3039],{"class":271},[262,22350,22351],{"class":429},"r.status_code",[262,22353,654],{"class":271},[262,22355,1176],{"class":275},[262,22357,660],{"class":429},[262,22359,22360],{"class":181,"line":1996},[262,22361,583],{"emptyLinePlaceholder":582},[262,22363,22364],{"class":181,"line":2012},[262,22365,583],{"emptyLinePlaceholder":582},[262,22367,22368,22370,22372,22374,22376],{"class":181,"line":2040},[262,22369,2210],{"class":377},[262,22371,2213],{"class":271},[262,22373,2216],{"class":377},[262,22375,2219],{"class":275},[262,22377,1160],{"class":429},[262,22379,22380,22382,22384,22386,22389],{"class":181,"line":2045},[262,22381,5082],{"class":429},[262,22383,476],{"class":377},[262,22385,20329],{"class":429},[262,22387,22388],{"class":275},"\"shipping a tiny side project this weekend\"",[262,22390,660],{"class":429},[262,22392,22393,22396,22398],{"class":181,"line":2050},[262,22394,22395],{"class":429},"    ready ",[262,22397,476],{"class":377},[262,22399,22400],{"class":429}," format_for_x(draft)\n",[262,22402,22403,22406,22408,22411,22413,22416,22418,22421,22424,22426,22428,22430],{"class":181,"line":2067},[262,22404,22405],{"class":429},"    when ",[262,22407,476],{"class":377},[262,22409,22410],{"class":429}," datetime.now(ZoneInfo(",[262,22412,20984],{"class":275},[262,22414,22415],{"class":429},")) ",[262,22417,531],{"class":377},[262,22419,22420],{"class":429}," timedelta(",[262,22422,22423],{"class":611},"seconds",[262,22425,476],{"class":377},[262,22427,17025],{"class":271},[262,22429,14569],{"class":429},[262,22431,22432],{"class":291},"# demo delay\n",[262,22434,22435,22438,22440,22442,22444,22446],{"class":181,"line":2077},[262,22436,22437],{"class":429},"    scheduler.add_job(publish, ",[262,22439,17468],{"class":275},[262,22441,608],{"class":429},[262,22443,17507],{"class":611},[262,22445,476],{"class":377},[262,22447,21053],{"class":429},[262,22449,22450,22453,22455,22457,22460,22463,22465,22467,22470,22472,22474,22476,22478],{"class":181,"line":2086},[262,22451,22452],{"class":611},"                      args",[262,22454,476],{"class":377},[262,22456,12118],{"class":429},[262,22458,22459],{"class":275},"\"x\"",[262,22461,22462],{"class":429},", ready], ",[262,22464,9492],{"class":611},[262,22466,476],{"class":377},[262,22468,22469],{"class":275},"\"demo-post\"",[262,22471,608],{"class":429},[262,22473,17543],{"class":611},[262,22475,476],{"class":377},[262,22477,4974],{"class":271},[262,22479,660],{"class":429},[262,22481,22482],{"class":181,"line":2097},[262,22483,17434],{"class":429},[262,22485,22486,22488,22490,22492,22495,22497,22499,22501,22503,22505,22508,22510,22512],{"class":181,"line":2106},[262,22487,1089],{"class":271},[262,22489,602],{"class":429},[262,22491,642],{"class":377},[262,22493,22494],{"class":275},"\"Queued for ",[262,22496,3039],{"class":271},[262,22498,21087],{"class":429},[262,22500,654],{"class":271},[262,22502,1231],{"class":275},[262,22504,3039],{"class":271},[262,22506,22507],{"class":429},"ready",[262,22509,654],{"class":271},[262,22511,1176],{"class":275},[262,22513,660],{"class":429},[262,22515,22516,22518,22520,22523],{"class":181,"line":2126},[262,22517,3657],{"class":429},[262,22519,9777],{"class":271},[262,22521,22522],{"class":429},")                                  ",[262,22524,22525],{"class":291},"# keep the process alive\n",[14,22527,3349,22528,22531,22532,22535],{},[18,22529,22530],{},"timedelta(seconds=15)"," makes the demo fire almost immediately so you can confirm the loop works; swap in a real future datetime for production. The final ",[18,22533,22534],{},"time.sleep(30)"," keeps the program alive long enough for the background scheduler to run the job, since a script that exits takes its scheduler with it.",[57,22537,2355],{"id":2354},[14,22539,22540],{},"You now have the full backbone. The child guides in this section each go deep on one platform or pattern:",[1447,22542,22543,22549,22555],{},[1450,22544,22545,22546,22548],{},"Start with ",[51,22547,15735],{"href":15734}," to handle Instagram's two-step media upload, which is the trickiest API in this set.",[1450,22550,22551,22552,22554],{},"Move to ",[51,22553,15717],{"href":19701}," when you want to load a whole month of posts from a spreadsheet in one run.",[1450,22556,22557,22558,22560],{},"Then read ",[51,22559,17687],{"href":17686}," to chain several publish calls into a single connected thread.",[14,22562,22563,22564,22566,22567,22569],{},"To feed this pipeline better raw material, pair it with ",[51,22565,9304],{"href":9303}," so your posts target topics people actually search for, and lean on ",[51,22568,3991],{"href":3990}," to refine the copy generator in Step 1.",[14,22571,2375,22572,1363],{},[51,22573,5413],{"href":5412},[57,22575,2381],{"id":2380},[2322,22577,22578,22582,22586,22590,22594],{},[1450,22579,22580],{},[51,22581,15735],{"href":15734},[1450,22583,22584],{},[51,22585,15717],{"href":19701},[1450,22587,22588],{},[51,22589,17687],{"href":17686},[1450,22591,22592],{},[51,22593,3991],{"href":3990},[1450,22595,22596],{},[51,22597,9304],{"href":9303},[2401,22599,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":22601},[22602,22603,22604,22605,22606,22607,22608,22609,22610,22611,22612],{"id":19825,"depth":282,"text":19826},{"id":237,"depth":282,"text":238},{"id":20066,"depth":282,"text":20067},{"id":20418,"depth":282,"text":20419},{"id":20919,"depth":282,"text":20920},{"id":21234,"depth":282,"text":21235},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":21816,"depth":282,"text":21817},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Automate social media posting end to end with Python and AI: generate copy with an LLM, format per platform, schedule with a queue, and publish via httpx.",[22615,22618,22621,22624,22627],{"q":22616,"a":22617},"Do I need a developer account to post automatically?","Yes. Every major network (Instagram, X\u002FTwitter, LinkedIn, Facebook) requires a developer app and an access token before its API will accept posts. You create the app once in the platform's developer portal, then store the token in a .env file your script reads.",{"q":22619,"a":22620},"Can one Python script post to several platforms at once?","It can, but each platform has its own endpoint, payload shape, and character limit. The clean pattern is one publisher function per platform behind a shared scheduler, so the queue stays simple while each function speaks the right API dialect.",{"q":22622,"a":22623},"Is automated posting against the rules?","Posting your own original content through a platform's official API is allowed and expected. What gets accounts banned is scraping, fake engagement, or hammering endpoints past the rate limit. Stay on official APIs and respect the documented limits.",{"q":22625,"a":22626},"Should I use cron or a Python scheduler?","Use a Python scheduler like APScheduler when the script runs continuously and you want timezone-aware jobs in code. Use system cron when you prefer a one-shot script the operating system triggers. Both work; this guide shows the queue-plus-scheduler approach because it is easier to test.",{"q":22628,"a":22629},"How do I keep my access tokens out of GitHub?","Store every token in a .env file and add .env to your .gitignore before the first commit. Load the values at runtime with python-dotenv. Never paste a token directly into a .py file that you push to a repository.",{"name":22631,"steps":22632},"How to automate social media posting with Python and AI",[22633,22636,22639,22642],{"name":22634,"text":22635},"Generate post copy with an LLM","Call an AI model to draft a caption and hashtags from a short topic brief and return structured fields.",{"name":22637,"text":22638},"Format the copy for each platform","Trim, tag, and reshape the draft to match each network's character limits and conventions.",{"name":22640,"text":22641},"Schedule posts with a queue","Place each ready post on a timezone-aware queue so the scheduler fires it at the chosen moment.",{"name":22643,"text":22644},"Publish through the platform API","Send the formatted post to the network's endpoint with httpx and confirm the response status.",{},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting",{"title":19799,"description":22613},"Automated Social Media Posting with Python","ai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Findex","Tdm42PHSqx_sygBxsE59fyYR3AtBIvksQ1JthLm_gHU",{"id":22652,"title":15735,"body":22653,"description":24263,"extension":2419,"faq":24264,"howto":24280,"meta":24298,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":24299,"published":12716,"seo":24300,"seoTitle":24301,"stem":24302,"__hash__":24303},"content\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fschedule-instagram-posts-using-python-and-ai\u002Findex.md",{"type":7,"value":22654,"toc":24252},[22655,22658,22661,22670,22672,22678,22711,22714,22730,22742,22761,22764,22778,22781,22785,22794,23012,23022,23025,23128,23132,23135,23138,23316,23319,23323,23334,23344,23421,23902,23908,23912,23918,24061,24064,24068,24128,24130,24198,24200,24221,24225,24227,24249],[10,22656,15735],{"id":22657},"schedule-instagram-posts-using-python-and-ai",[14,22659,22660],{},"This guide shows you how to write an Instagram caption with AI and then schedule the post through Instagram's official API in about 20 minutes, with no third-party posting service in the middle. You stay in control of the caption, the timing, and the image, and everything runs from a single Python script.",[14,22662,22663,22664,22666,22667,22669],{},"The approach has two halves. First, an LLM (large language model, the kind of AI that writes text) drafts a caption and hashtags. Second, the Instagram Graph API (Meta's official programming interface for Instagram Business accounts) creates the post and sets it to publish at a future time. This fits naturally into a wider ",[51,22665,9309],{"href":9308}," routine and the broader ",[51,22668,5413],{"href":5412}," workflow.",[57,22671,238],{"id":237},[14,22673,22674,22675,22677],{},"This guide assumes you already have Python 3.10 or newer and a working virtual environment. If you do not, follow ",[51,22676,2482],{"href":2481}," first. Beyond that, you need three Instagram-specific things:",[1447,22679,22680,22686,22705],{},[1450,22681,22682,22685],{},[35,22683,22684],{},"An Instagram Business or Creator account"," linked to a Facebook Page. Personal accounts cannot publish through the API. You can convert your account for free in the Instagram app under Settings.",[1450,22687,22688,22691,22692,608,22695,608,22698,13390,22701,22704],{},[35,22689,22690],{},"A Meta Developer App"," with the ",[18,22693,22694],{},"instagram_basic",[18,22696,22697],{},"instagram_content_publish",[18,22699,22700],{},"pages_read_engagement",[18,22702,22703],{},"pages_manage_posts"," permissions. You create this at developers.facebook.com.",[1450,22706,22707,22710],{},[35,22708,22709],{},"A long-lived access token"," generated through the Meta Graph API Explorer. Short-lived tokens expire in about an hour; the long-lived version lasts roughly 60 days.",[14,22712,22713],{},"Install the libraries used here:",[253,22715,22716],{"className":255,"code":5427,"language":257,"meta":258,"style":258},[18,22717,22718],{"__ignoreMap":258},[262,22719,22720,22722,22724,22726,22728],{"class":181,"line":264},[262,22721,298],{"class":267},[262,22723,301],{"class":275},[262,22725,2519],{"class":275},[262,22727,5440],{"class":275},[262,22729,2522],{"class":275},[14,22731,22732,22733,22735,22736,22738,22739,22741],{},"We use ",[18,22734,5450],{}," (a modern HTTP client that handles both regular and async requests) for all calls to Meta, and the ",[18,22737,20],{}," SDK for the caption. Store every secret in a file named ",[18,22740,319],{}," in your project folder:",[253,22743,22745],{"className":323,"code":22744,"language":325,"meta":258,"style":258},"IG_USER_ID=your_ig_business_account_id\nIG_ACCESS_TOKEN=your_long_lived_token\nOPENAI_API_KEY=sk-...\n",[18,22746,22747,22752,22757],{"__ignoreMap":258},[262,22748,22749],{"class":181,"line":264},[262,22750,22751],{},"IG_USER_ID=your_ig_business_account_id\n",[262,22753,22754],{"class":181,"line":282},[262,22755,22756],{},"IG_ACCESS_TOKEN=your_long_lived_token\n",[262,22758,22759],{"class":181,"line":295},[262,22760,15777],{},[14,22762,22763],{},"Then immediately add that file to your ignore list so it never reaches a public repository:",[253,22765,22766],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,22767,22768],{"__ignoreMap":258},[262,22769,22770,22772,22774,22776],{"class":181,"line":264},[262,22771,371],{"class":271},[262,22773,374],{"class":275},[262,22775,378],{"class":377},[262,22777,381],{"class":275},[14,22779,22780],{},"A leaked access token lets anyone post as you, so this one line matters more than it looks.",[57,22782,22784],{"id":22783},"step-1-generate-the-caption-with-an-llm","Step 1: Generate the caption with an LLM",[14,22786,22787,22788,22790,22791,22793],{},"Ask the model for structured output so you can read the caption and hashtags separately instead of parsing one blob of text. We request JSON and set ",[18,22789,5745],{}," so the model is forced to return valid JSON. The same prompt-as-template idea appears in ",[51,22792,5270],{"href":5269}," if you want to refine the wording.",[253,22795,22797],{"className":414,"code":22796,"language":416,"meta":258,"style":258},"import os\nimport json\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef generate_caption(topic: str) -> dict:\n    \"\"\"Return {'caption': str, 'hashtags': [str, ...]} for a topic.\"\"\"\n    prompt = (\n        f\"Write an Instagram caption for: '{topic}'. \"\n        \"Keep the caption under 2000 characters, friendly and concrete. \"\n        \"Return JSON with two keys: 'caption' (string) and \"\n        \"'hashtags' (a list of 5 to 8 short hashtag strings without the # sign).\"\n    )\n\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        response_format={\"type\": \"json_object\"},\n        temperature=0.8,\n    )\n    return json.loads(response.choices[0].message.content)\n",[18,22798,22799,22805,22811,22821,22831,22835,22839,22857,22861,22865,22882,22887,22895,22911,22916,22921,22926,22930,22934,22942,22952,22972,22988,22998,23002],{"__ignoreMap":258},[262,22800,22801,22803],{"class":181,"line":264},[262,22802,684],{"class":377},[262,22804,687],{"class":429},[262,22806,22807,22809],{"class":181,"line":282},[262,22808,684],{"class":377},[262,22810,5766],{"class":429},[262,22812,22813,22815,22817,22819],{"class":181,"line":295},[262,22814,705],{"class":377},[262,22816,720],{"class":429},[262,22818,684],{"class":377},[262,22820,725],{"class":429},[262,22822,22823,22825,22827,22829],{"class":181,"line":345},[262,22824,705],{"class":377},[262,22826,708],{"class":429},[262,22828,684],{"class":377},[262,22830,713],{"class":429},[262,22832,22833],{"class":181,"line":492},[262,22834,583],{"emptyLinePlaceholder":582},[262,22836,22837],{"class":181,"line":503},[262,22838,734],{"class":429},[262,22840,22841,22843,22845,22847,22849,22851,22853,22855],{"class":181,"line":521},[262,22842,739],{"class":429},[262,22844,476],{"class":377},[262,22846,1588],{"class":429},[262,22848,2674],{"class":611},[262,22850,476],{"class":377},[262,22852,1199],{"class":429},[262,22854,2681],{"class":275},[262,22856,2684],{"class":429},[262,22858,22859],{"class":181,"line":537},[262,22860,583],{"emptyLinePlaceholder":582},[262,22862,22863],{"class":181,"line":549},[262,22864,583],{"emptyLinePlaceholder":582},[262,22866,22867,22869,22872,22874,22876,22878,22880],{"class":181,"line":570},[262,22868,423],{"class":377},[262,22870,22871],{"class":267}," generate_caption",[262,22873,4287],{"class":429},[262,22875,433],{"class":271},[262,22877,1939],{"class":429},[262,22879,5869],{"class":271},[262,22881,1160],{"class":429},[262,22883,22884],{"class":181,"line":579},[262,22885,22886],{"class":275},"    \"\"\"Return {'caption': str, 'hashtags': [str, ...]} for a topic.\"\"\"\n",[262,22888,22889,22891,22893],{"class":181,"line":586},[262,22890,18006],{"class":429},[262,22892,476],{"class":377},[262,22894,984],{"class":429},[262,22896,22897,22899,22902,22904,22906,22908],{"class":181,"line":591},[262,22898,2840],{"class":377},[262,22900,22901],{"class":275},"\"Write an Instagram caption for: '",[262,22903,3039],{"class":271},[262,22905,4402],{"class":429},[262,22907,654],{"class":271},[262,22909,22910],{"class":275},"'. \"\n",[262,22912,22913],{"class":181,"line":623},[262,22914,22915],{"class":275},"        \"Keep the caption under 2000 characters, friendly and concrete. \"\n",[262,22917,22918],{"class":181,"line":634},[262,22919,22920],{"class":275},"        \"Return JSON with two keys: 'caption' (string) and \"\n",[262,22922,22923],{"class":181,"line":845},[262,22924,22925],{"class":275},"        \"'hashtags' (a list of 5 to 8 short hashtag strings without the # sign).\"\n",[262,22927,22928],{"class":181,"line":850},[262,22929,1011],{"class":429},[262,22931,22932],{"class":181,"line":864},[262,22933,583],{"emptyLinePlaceholder":582},[262,22935,22936,22938,22940],{"class":181,"line":1683},[262,22937,1184],{"class":429},[262,22939,476],{"class":377},[262,22941,1189],{"class":429},[262,22943,22944,22946,22948,22950],{"class":181,"line":1688},[262,22945,1194],{"class":611},[262,22947,476],{"class":377},[262,22949,1207],{"class":275},[262,22951,1315],{"class":429},[262,22953,22954,22956,22958,22960,22962,22964,22966,22968,22970],{"class":181,"line":1693},[262,22955,1215],{"class":611},[262,22957,476],{"class":377},[262,22959,8856],{"class":429},[262,22961,1228],{"class":275},[262,22963,1231],{"class":429},[262,22965,1291],{"class":275},[262,22967,608],{"class":429},[262,22969,1239],{"class":275},[262,22971,18141],{"class":429},[262,22973,22974,22976,22978,22980,22982,22984,22986],{"class":181,"line":1728},[262,22975,6018],{"class":611},[262,22977,476],{"class":377},[262,22979,3039],{"class":429},[262,22981,6025],{"class":275},[262,22983,1231],{"class":429},[262,22985,6030],{"class":275},[262,22987,3143],{"class":429},[262,22989,22990,22992,22994,22996],{"class":181,"line":1737},[262,22991,1308],{"class":611},[262,22993,476],{"class":377},[262,22995,20284],{"class":271},[262,22997,1315],{"class":429},[262,22999,23000],{"class":181,"line":1751},[262,23001,1011],{"class":429},[262,23003,23004,23006,23008,23010],{"class":181,"line":1764},[262,23005,573],{"class":377},[262,23007,6043],{"class":429},[262,23009,102],{"class":271},[262,23011,6048],{"class":429},[14,23013,3349,23014,6092,23016,23018,23019,23021],{},[18,23015,3829],{},[18,23017,20284],{}," keeps captions varied across runs so your feed does not read like a template. Lower it toward ",[18,23020,3924],{}," if you want a steadier, more predictable brand voice.",[14,23023,23024],{},"A small helper turns the model's output into one caption string. Instagram counts hashtags toward the 2,200-character limit and allows at most 30 of them, so we keep the list short and join everything cleanly:",[253,23026,23028],{"className":414,"code":23027,"language":416,"meta":258,"style":258},"def build_full_caption(content: dict) -> str:\n    tags = \" \".join(f\"#{t.lstrip('#')}\" for t in content[\"hashtags\"])\n    caption = content[\"caption\"].strip()\n    return f\"{caption}\\n\\n{tags}\"[:2200]\n",[18,23029,23030,23048,23089,23101],{"__ignoreMap":258},[262,23031,23032,23034,23037,23040,23042,23044,23046],{"class":181,"line":264},[262,23033,423],{"class":377},[262,23035,23036],{"class":267}," build_full_caption",[262,23038,23039],{"class":429},"(content: ",[262,23041,5869],{"class":271},[262,23043,1939],{"class":429},[262,23045,433],{"class":271},[262,23047,1160],{"class":429},[262,23049,23050,23052,23054,23056,23058,23060,23062,23064,23067,23070,23072,23074,23076,23078,23080,23082,23085,23087],{"class":181,"line":282},[262,23051,20633],{"class":429},[262,23053,476],{"class":377},[262,23055,20657],{"class":275},[262,23057,2023],{"class":429},[262,23059,642],{"class":377},[262,23061,20365],{"class":275},[262,23063,3039],{"class":271},[262,23065,23066],{"class":429},"t.lstrip(",[262,23068,23069],{"class":275},"'#'",[262,23071,5987],{"class":429},[262,23073,654],{"class":271},[262,23075,1176],{"class":275},[262,23077,10739],{"class":377},[262,23079,20677],{"class":429},[262,23081,835],{"class":377},[262,23083,23084],{"class":429}," content[",[262,23086,20386],{"class":275},[262,23088,3512],{"class":429},[262,23090,23091,23093,23095,23097,23099],{"class":181,"line":295},[262,23092,20687],{"class":429},[262,23094,476],{"class":377},[262,23096,23084],{"class":429},[262,23098,20348],{"class":275},[262,23100,20696],{"class":429},[262,23102,23103,23105,23107,23109,23111,23113,23115,23117,23119,23121,23124,23126],{"class":181,"line":345},[262,23104,573],{"class":377},[262,23106,10178],{"class":377},[262,23108,1176],{"class":275},[262,23110,3039],{"class":271},[262,23112,20727],{"class":429},[262,23114,18467],{"class":271},[262,23116,22163],{"class":429},[262,23118,654],{"class":271},[262,23120,1176],{"class":275},[262,23122,23123],{"class":429},"[:",[262,23125,20486],{"class":271},[262,23127,957],{"class":429},[57,23129,23131],{"id":23130},"step-2-prepare-the-media","Step 2: Prepare the media",[14,23133,23134],{},"Meta does not accept image uploads from your machine for feed posts. Instead, you give it a public URL and Meta's servers download the file. That means the image must live somewhere reachable by anyone, over HTTPS, with no login and no redirect. Common hosts are an S3 bucket, a Cloudflare R2 bucket, or any plain static web server.",[14,23136,23137],{},"Before you try to publish, confirm the URL behaves the way Meta expects:",[253,23139,23141],{"className":414,"code":23140,"language":416,"meta":258,"style":258},"import httpx\n\n\ndef check_image_url(img_url: str) -> None:\n    \"\"\"Raise if the URL is not a directly reachable image.\"\"\"\n    resp = httpx.head(img_url, follow_redirects=False, timeout=15)\n    if resp.status_code != 200:\n        raise ValueError(f\"Image URL returned {resp.status_code}, expected 200\")\n\n    content_type = resp.headers.get(\"content-type\", \"\")\n    if not content_type.startswith((\"image\u002Fjpeg\", \"image\u002Fpng\")):\n        raise ValueError(f\"Unexpected content-type: {content_type!r}\")\n",[18,23142,23143,23149,23153,23157,23175,23180,23206,23221,23246,23250,23269,23289],{"__ignoreMap":258},[262,23144,23145,23147],{"class":181,"line":264},[262,23146,684],{"class":377},[262,23148,6526],{"class":429},[262,23150,23151],{"class":181,"line":282},[262,23152,583],{"emptyLinePlaceholder":582},[262,23154,23155],{"class":181,"line":295},[262,23156,583],{"emptyLinePlaceholder":582},[262,23158,23159,23161,23164,23167,23169,23171,23173],{"class":181,"line":345},[262,23160,423],{"class":377},[262,23162,23163],{"class":267}," check_image_url",[262,23165,23166],{"class":429},"(img_url: ",[262,23168,433],{"class":271},[262,23170,1939],{"class":429},[262,23172,8471],{"class":271},[262,23174,1160],{"class":429},[262,23176,23177],{"class":181,"line":492},[262,23178,23179],{"class":275},"    \"\"\"Raise if the URL is not a directly reachable image.\"\"\"\n",[262,23181,23182,23184,23186,23189,23192,23194,23196,23198,23200,23202,23204],{"class":181,"line":503},[262,23183,797],{"class":429},[262,23185,476],{"class":377},[262,23187,23188],{"class":429}," httpx.head(img_url, ",[262,23190,23191],{"class":611},"follow_redirects",[262,23193,476],{"class":377},[262,23195,3623],{"class":271},[262,23197,608],{"class":429},[262,23199,1591],{"class":611},[262,23201,476],{"class":377},[262,23203,17025],{"class":271},[262,23205,660],{"class":429},[262,23207,23208,23210,23213,23216,23219],{"class":181,"line":521},[262,23209,3454],{"class":377},[262,23211,23212],{"class":429}," resp.status_code ",[262,23214,23215],{"class":377},"!=",[262,23217,23218],{"class":271}," 200",[262,23220,1160],{"class":429},[262,23222,23223,23225,23227,23229,23231,23234,23236,23239,23241,23244],{"class":181,"line":537},[262,23224,4928],{"class":377},[262,23226,2832],{"class":271},[262,23228,602],{"class":429},[262,23230,642],{"class":377},[262,23232,23233],{"class":275},"\"Image URL returned ",[262,23235,3039],{"class":271},[262,23237,23238],{"class":429},"resp.status_code",[262,23240,654],{"class":271},[262,23242,23243],{"class":275},", expected 200\"",[262,23245,660],{"class":429},[262,23247,23248],{"class":181,"line":549},[262,23249,583],{"emptyLinePlaceholder":582},[262,23251,23252,23255,23257,23260,23263,23265,23267],{"class":181,"line":570},[262,23253,23254],{"class":429},"    content_type ",[262,23256,476],{"class":377},[262,23258,23259],{"class":429}," resp.headers.get(",[262,23261,23262],{"class":275},"\"content-type\"",[262,23264,608],{"class":429},[262,23266,9175],{"class":275},[262,23268,660],{"class":429},[262,23270,23271,23273,23275,23278,23281,23283,23286],{"class":181,"line":579},[262,23272,3454],{"class":377},[262,23274,2818],{"class":377},[262,23276,23277],{"class":429}," content_type.startswith((",[262,23279,23280],{"class":275},"\"image\u002Fjpeg\"",[262,23282,608],{"class":429},[262,23284,23285],{"class":275},"\"image\u002Fpng\"",[262,23287,23288],{"class":429},")):\n",[262,23290,23291,23293,23295,23297,23299,23302,23304,23307,23310,23312,23314],{"class":181,"line":586},[262,23292,4928],{"class":377},[262,23294,2832],{"class":271},[262,23296,602],{"class":429},[262,23298,642],{"class":377},[262,23300,23301],{"class":275},"\"Unexpected content-type: ",[262,23303,3039],{"class":271},[262,23305,23306],{"class":429},"content_type",[262,23308,23309],{"class":377},"!r",[262,23311,654],{"class":271},[262,23313,1176],{"class":275},[262,23315,660],{"class":429},[14,23317,23318],{},"Running this check first turns a confusing Graph API error later into a clear message now. Instagram feed images also work best as JPEG between 320 and 1440 pixels wide, with an aspect ratio from 4:5 to 1.91:1.",[57,23320,23322],{"id":23321},"step-3-create-and-schedule-the-post","Step 3: Create and schedule the post",[14,23324,23325,23326,23329,23330,23333],{},"Publishing is a two-call dance. First you create a ",[27,23327,23328],{},"media container"," (Meta's staging slot that holds the image plus caption). The container is not instant: Meta downloads and processes the image, so you poll a status field until it reads ",[18,23331,23332],{},"FINISHED",". Then you publish the container, and that is where the scheduled time goes.",[14,23335,23336,23337,23340,23341,23343],{},"Set ",[18,23338,23339],{},"scheduled_publish_time"," to a Unix timestamp (a plain integer count of seconds) between 10 minutes and 75 days in the future. It must be an ",[18,23342,439],{}," — floating-point values are rejected.",[76,23345,23347,23418],{"className":23346},[79],[81,23348,90,23351,90,23354,90,23357,90,23365,90,23368,90,23376,90,23378,90,23386,90,23388,90,23395,90,23398,90,23407,90,23412,90,23415],{"viewBox":23349,"role":84,"ariaLabelledBy":23350,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1080 380",[7091,7092],[92,23352,23353],{"id":7091},"Instagram scheduled-post flow",[96,23355,23356],{"id":7092},"Caption and image feed into a media container, which is polled until finished, then published with a future timestamp.",[5548,23358,5550,23359,90],{},[5552,23360,5558,23362,5550],{"id":23361,"viewBox":7161,"refX":7162,"refY":222,"markerWidth":7163,"markerHeight":7163,"orient":7164},"diagArrow",[216,23363],{"d":23364,"fill":125},"M0,0 L10,5 L0,10 z",[100,23366],{"x":102,"y":23367,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"40",[111,23369,5550,23370,5550,23373,90],{"x":113,"y":19872,"fontFamily":115,"textAnchor":119},[175,23371,23372],{"fontSize":116,"fontWeight":117,"fill":118},"AI caption",[175,23374,23375],{"x":113,"dy":177,"fontSize":116,"fontWeight":117,"fill":118},"+ image URL",[100,23377],{"x":12816,"y":23367,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,23379,5550,23380,5550,23383,90],{"x":12819,"y":19872,"fontFamily":115,"textAnchor":119},[175,23381,23382],{"fontSize":116,"fontWeight":117,"fill":118},"Create media",[175,23384,23385],{"x":12819,"dy":177,"fontSize":116,"fontWeight":117,"fill":118},"container",[100,23387],{"x":12825,"y":23367,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,23389,5550,23390,5550,23393,90],{"x":12829,"y":19872,"fontFamily":115,"textAnchor":119},[175,23391,23392],{"fontSize":116,"fontWeight":117,"fill":118},"Poll until",[175,23394,23332],{"x":12829,"dy":177,"fontSize":116,"fontWeight":117,"fill":169},[100,23396],{"x":23397,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"800",[111,23399,5550,23401,5550,23404,90],{"x":23400,"y":104,"fontFamily":115,"textAnchor":119},"900",[175,23402,23403],{"fontSize":116,"fontWeight":117,"fill":118},"Publish with",[175,23405,23406],{"x":23400,"dy":177,"fontSize":116,"fontWeight":117,"fill":118},"future time",[181,23408],{"x1":104,"y1":23409,"x2":23410,"y2":23409,"stroke":125,"strokeWidth":109,"markerEnd":23411},"76","276","url(#diagArrow)",[181,23413],{"x1":158,"y1":23409,"x2":23414,"y2":23409,"stroke":125,"strokeWidth":109,"markerEnd":23411},"556",[216,23416],{"d":23417,"fill":219,"stroke":125,"strokeWidth":109,"markerEnd":23411},"M660,112 L660,206 L796,206",[232,23419,23420],{},"The caption and image become a container; once Meta marks it FINISHED, you publish it with a future timestamp.",[253,23422,23424],{"className":414,"code":23423,"language":416,"meta":258,"style":258},"import time\n\nIG_USER_ID = os.getenv(\"IG_USER_ID\")\nTOKEN = os.getenv(\"IG_ACCESS_TOKEN\")\nBASE_URL = f\"https:\u002F\u002Fgraph.facebook.com\u002Fv18.0\u002F{IG_USER_ID}\"\n\n\ndef create_container(client: httpx.Client, img_url: str, caption: str) -> str:\n    resp = client.post(\n        f\"{BASE_URL}\u002Fmedia\",\n        params={\"image_url\": img_url, \"caption\": caption, \"access_token\": TOKEN},\n    )\n    resp.raise_for_status()\n    return resp.json()[\"id\"]\n\n\ndef wait_until_ready(client: httpx.Client, container_id: str) -> None:\n    for _ in range(10):\n        resp = client.get(\n            f\"https:\u002F\u002Fgraph.facebook.com\u002Fv18.0\u002F{container_id}\",\n            params={\"fields\": \"status_code\", \"access_token\": TOKEN},\n        )\n        if resp.json().get(\"status_code\") == \"FINISHED\":\n            return\n        time.sleep(3)\n    raise RuntimeError(\"Media container did not reach FINISHED in time\")\n\n\ndef schedule_post(img_url: str, caption: str, hours_from_now: int = 24) -> dict:\n    with httpx.Client(timeout=30) as client:\n        container_id = create_container(client, img_url, caption)\n        wait_until_ready(client, container_id)\n\n        publish_at = int(time.time()) + hours_from_now * 3600\n        resp = client.post(\n            f\"{BASE_URL}\u002Fmedia_publish\",\n            params={\n                \"creation_id\": container_id,\n                \"scheduled_publish_time\": publish_at,\n                \"access_token\": TOKEN,\n            },\n        )\n        resp.raise_for_status()\n        return resp.json()\n",[18,23425,23426,23432,23436,23450,23464,23481,23485,23489,23512,23521,23535,23564,23568,23573,23584,23588,23592,23610,23626,23635,23652,23679,23683,23701,23706,23714,23727,23731,23735,23766,23785,23795,23800,23804,23827,23835,23848,23856,23864,23872,23883,23887,23891,23895],{"__ignoreMap":258},[262,23427,23428,23430],{"class":181,"line":264},[262,23429,684],{"class":377},[262,23431,2612],{"class":429},[262,23433,23434],{"class":181,"line":282},[262,23435,583],{"emptyLinePlaceholder":582},[262,23437,23438,23441,23443,23445,23448],{"class":181,"line":295},[262,23439,23440],{"class":271},"IG_USER_ID",[262,23442,442],{"class":377},[262,23444,754],{"class":429},[262,23446,23447],{"class":275},"\"IG_USER_ID\"",[262,23449,660],{"class":429},[262,23451,23452,23455,23457,23459,23462],{"class":181,"line":345},[262,23453,23454],{"class":271},"TOKEN",[262,23456,442],{"class":377},[262,23458,754],{"class":429},[262,23460,23461],{"class":275},"\"IG_ACCESS_TOKEN\"",[262,23463,660],{"class":429},[262,23465,23466,23469,23471,23473,23476,23479],{"class":181,"line":492},[262,23467,23468],{"class":271},"BASE_URL",[262,23470,442],{"class":377},[262,23472,10178],{"class":377},[262,23474,23475],{"class":275},"\"https:\u002F\u002Fgraph.facebook.com\u002Fv18.0\u002F",[262,23477,23478],{"class":271},"{IG_USER_ID}",[262,23480,1257],{"class":275},[262,23482,23483],{"class":181,"line":503},[262,23484,583],{"emptyLinePlaceholder":582},[262,23486,23487],{"class":181,"line":521},[262,23488,583],{"emptyLinePlaceholder":582},[262,23490,23491,23493,23496,23499,23501,23504,23506,23508,23510],{"class":181,"line":537},[262,23492,423],{"class":377},[262,23494,23495],{"class":267}," create_container",[262,23497,23498],{"class":429},"(client: httpx.Client, img_url: ",[262,23500,433],{"class":271},[262,23502,23503],{"class":429},", caption: ",[262,23505,433],{"class":271},[262,23507,1939],{"class":429},[262,23509,433],{"class":271},[262,23511,1160],{"class":429},[262,23513,23514,23516,23518],{"class":181,"line":549},[262,23515,797],{"class":429},[262,23517,476],{"class":377},[262,23519,23520],{"class":429}," client.post(\n",[262,23522,23523,23525,23527,23530,23533],{"class":181,"line":570},[262,23524,2840],{"class":377},[262,23526,1176],{"class":275},[262,23528,23529],{"class":271},"{BASE_URL}",[262,23531,23532],{"class":275},"\u002Fmedia\"",[262,23534,1315],{"class":429},[262,23536,23537,23540,23542,23544,23547,23550,23552,23555,23558,23560,23562],{"class":181,"line":579},[262,23538,23539],{"class":611},"        params",[262,23541,476],{"class":377},[262,23543,3039],{"class":429},[262,23545,23546],{"class":275},"\"image_url\"",[262,23548,23549],{"class":429},": img_url, ",[262,23551,20348],{"class":275},[262,23553,23554],{"class":429},": caption, ",[262,23556,23557],{"class":275},"\"access_token\"",[262,23559,1231],{"class":429},[262,23561,23454],{"class":271},[262,23563,3143],{"class":429},[262,23565,23566],{"class":181,"line":586},[262,23567,1011],{"class":429},[262,23569,23570],{"class":181,"line":591},[262,23571,23572],{"class":429},"    resp.raise_for_status()\n",[262,23574,23575,23577,23580,23582],{"class":181,"line":623},[262,23576,573],{"class":377},[262,23578,23579],{"class":429}," resp.json()[",[262,23581,6770],{"class":275},[262,23583,957],{"class":429},[262,23585,23586],{"class":181,"line":634},[262,23587,583],{"emptyLinePlaceholder":582},[262,23589,23590],{"class":181,"line":845},[262,23591,583],{"emptyLinePlaceholder":582},[262,23593,23594,23596,23599,23602,23604,23606,23608],{"class":181,"line":850},[262,23595,423],{"class":377},[262,23597,23598],{"class":267}," wait_until_ready",[262,23600,23601],{"class":429},"(client: httpx.Client, container_id: ",[262,23603,433],{"class":271},[262,23605,1939],{"class":429},[262,23607,8471],{"class":271},[262,23609,1160],{"class":429},[262,23611,23612,23614,23616,23618,23620,23622,23624],{"class":181,"line":864},[262,23613,3074],{"class":377},[262,23615,9022],{"class":429},[262,23617,835],{"class":377},[262,23619,3082],{"class":271},[262,23621,602],{"class":429},[262,23623,3868],{"class":271},[262,23625,8192],{"class":429},[262,23627,23628,23630,23632],{"class":181,"line":1683},[262,23629,17037],{"class":429},[262,23631,476],{"class":377},[262,23633,23634],{"class":429}," client.get(\n",[262,23636,23637,23639,23641,23643,23646,23648,23650],{"class":181,"line":1688},[262,23638,6202],{"class":377},[262,23640,23475],{"class":275},[262,23642,3039],{"class":271},[262,23644,23645],{"class":429},"container_id",[262,23647,654],{"class":271},[262,23649,1176],{"class":275},[262,23651,1315],{"class":429},[262,23653,23654,23657,23659,23661,23664,23666,23669,23671,23673,23675,23677],{"class":181,"line":1693},[262,23655,23656],{"class":611},"            params",[262,23658,476],{"class":377},[262,23660,3039],{"class":429},[262,23662,23663],{"class":275},"\"fields\"",[262,23665,1231],{"class":429},[262,23667,23668],{"class":275},"\"status_code\"",[262,23670,608],{"class":429},[262,23672,23557],{"class":275},[262,23674,1231],{"class":429},[262,23676,23454],{"class":271},[262,23678,3143],{"class":429},[262,23680,23681],{"class":181,"line":1728},[262,23682,6288],{"class":429},[262,23684,23685,23687,23690,23692,23694,23696,23699],{"class":181,"line":1737},[262,23686,2268],{"class":377},[262,23688,23689],{"class":429}," resp.json().get(",[262,23691,23668],{"class":275},[262,23693,1000],{"class":429},[262,23695,10758],{"class":377},[262,23697,23698],{"class":275}," \"FINISHED\"",[262,23700,1160],{"class":429},[262,23702,23703],{"class":181,"line":1751},[262,23704,23705],{"class":377},"            return\n",[262,23707,23708,23710,23712],{"class":181,"line":1764},[262,23709,9055],{"class":429},[262,23711,5556],{"class":271},[262,23713,660],{"class":429},[262,23715,23716,23718,23720,23722,23725],{"class":181,"line":1779},[262,23717,2829],{"class":377},[262,23719,3318],{"class":271},[262,23721,602],{"class":429},[262,23723,23724],{"class":275},"\"Media container did not reach FINISHED in time\"",[262,23726,660],{"class":429},[262,23728,23729],{"class":181,"line":1793},[262,23730,583],{"emptyLinePlaceholder":582},[262,23732,23733],{"class":181,"line":1800},[262,23734,583],{"emptyLinePlaceholder":582},[262,23736,23737,23739,23742,23744,23746,23748,23750,23753,23755,23757,23760,23762,23764],{"class":181,"line":1805},[262,23738,423],{"class":377},[262,23740,23741],{"class":267}," schedule_post",[262,23743,23166],{"class":429},[262,23745,433],{"class":271},[262,23747,23503],{"class":429},[262,23749,433],{"class":271},[262,23751,23752],{"class":429},", hours_from_now: ",[262,23754,439],{"class":271},[262,23756,442],{"class":377},[262,23758,23759],{"class":271}," 24",[262,23761,1939],{"class":429},[262,23763,5869],{"class":271},[262,23765,1160],{"class":429},[262,23767,23768,23770,23772,23774,23776,23778,23780,23782],{"class":181,"line":1810},[262,23769,10124],{"class":377},[262,23771,17018],{"class":429},[262,23773,1591],{"class":611},[262,23775,476],{"class":377},[262,23777,9777],{"class":271},[262,23779,1000],{"class":429},[262,23781,697],{"class":377},[262,23783,23784],{"class":429}," client:\n",[262,23786,23787,23790,23792],{"class":181,"line":1823},[262,23788,23789],{"class":429},"        container_id ",[262,23791,476],{"class":377},[262,23793,23794],{"class":429}," create_container(client, img_url, caption)\n",[262,23796,23797],{"class":181,"line":1846},[262,23798,23799],{"class":429},"        wait_until_ready(client, container_id)\n",[262,23801,23802],{"class":181,"line":1861},[262,23803,583],{"emptyLinePlaceholder":582},[262,23805,23806,23809,23811,23814,23817,23819,23822,23824],{"class":181,"line":1866},[262,23807,23808],{"class":429},"        publish_at ",[262,23810,476],{"class":377},[262,23812,23813],{"class":271}," int",[262,23815,23816],{"class":429},"(time.time()) ",[262,23818,531],{"class":377},[262,23820,23821],{"class":429}," hours_from_now ",[262,23823,1003],{"class":377},[262,23825,23826],{"class":271}," 3600\n",[262,23828,23829,23831,23833],{"class":181,"line":1871},[262,23830,17037],{"class":429},[262,23832,476],{"class":377},[262,23834,23520],{"class":429},[262,23836,23837,23839,23841,23843,23846],{"class":181,"line":1890},[262,23838,6202],{"class":377},[262,23840,1176],{"class":275},[262,23842,23529],{"class":271},[262,23844,23845],{"class":275},"\u002Fmedia_publish\"",[262,23847,1315],{"class":429},[262,23849,23850,23852,23854],{"class":181,"line":1909},[262,23851,23656],{"class":611},[262,23853,476],{"class":377},[262,23855,6593],{"class":429},[262,23857,23858,23861],{"class":181,"line":1914},[262,23859,23860],{"class":275},"                \"creation_id\"",[262,23862,23863],{"class":429},": container_id,\n",[262,23865,23866,23869],{"class":181,"line":1919},[262,23867,23868],{"class":275},"                \"scheduled_publish_time\"",[262,23870,23871],{"class":429},": publish_at,\n",[262,23873,23874,23877,23879,23881],{"class":181,"line":1946},[262,23875,23876],{"class":275},"                \"access_token\"",[262,23878,1231],{"class":429},[262,23880,23454],{"class":271},[262,23882,1315],{"class":429},[262,23884,23885],{"class":181,"line":1959},[262,23886,4369],{"class":429},[262,23888,23889],{"class":181,"line":1996},[262,23890,6288],{"class":429},[262,23892,23893],{"class":181,"line":2012},[262,23894,17067],{"class":429},[262,23896,23897,23899],{"class":181,"line":2040},[262,23898,8066],{"class":377},[262,23900,23901],{"class":429}," resp.json()\n",[14,23903,23904,23905,23907],{},"If you want a post to go out immediately instead, simply omit ",[18,23906,23339],{}," from the second call.",[57,23909,23911],{"id":23910},"step-4-verify-the-scheduled-post","Step 4: Verify the scheduled post",[14,23913,23914,23915,23917],{},"A successful publish call returns JSON with an ",[18,23916,9492],{},". Print it and confirm the post shows up under your Instagram scheduled content (in the Meta Business Suite planner). Wiring the verify step into the run makes failures loud instead of silent:",[253,23919,23921],{"className":414,"code":23920,"language":416,"meta":258,"style":258},"def main() -> None:\n    content = generate_caption(\"Our new Python automation course launch\")\n    caption = build_full_caption(content)\n    check_image_url(\"https:\u002F\u002Fyour-cdn.example.com\u002Flaunch.jpg\")\n\n    result = schedule_post(\n        \"https:\u002F\u002Fyour-cdn.example.com\u002Flaunch.jpg\",\n        caption,\n        hours_from_now=24,\n    )\n    print(f\"Scheduled. Post creation id: {result['id']}\")\n\n\nif __name__ == \"__main__\":\n    main()\n",[18,23922,23923,23936,23951,23960,23970,23974,23983,23990,23995,24006,24010,24036,24040,24044,24056],{"__ignoreMap":258},[262,23924,23925,23927,23930,23932,23934],{"class":181,"line":264},[262,23926,423],{"class":377},[262,23928,23929],{"class":267}," main",[262,23931,15481],{"class":429},[262,23933,8471],{"class":271},[262,23935,1160],{"class":429},[262,23937,23938,23941,23943,23946,23949],{"class":181,"line":282},[262,23939,23940],{"class":429},"    content ",[262,23942,476],{"class":377},[262,23944,23945],{"class":429}," generate_caption(",[262,23947,23948],{"class":275},"\"Our new Python automation course launch\"",[262,23950,660],{"class":429},[262,23952,23953,23955,23957],{"class":181,"line":295},[262,23954,20687],{"class":429},[262,23956,476],{"class":377},[262,23958,23959],{"class":429}," build_full_caption(content)\n",[262,23961,23962,23965,23968],{"class":181,"line":345},[262,23963,23964],{"class":429},"    check_image_url(",[262,23966,23967],{"class":275},"\"https:\u002F\u002Fyour-cdn.example.com\u002Flaunch.jpg\"",[262,23969,660],{"class":429},[262,23971,23972],{"class":181,"line":492},[262,23973,583],{"emptyLinePlaceholder":582},[262,23975,23976,23978,23980],{"class":181,"line":503},[262,23977,13177],{"class":429},[262,23979,476],{"class":377},[262,23981,23982],{"class":429}," schedule_post(\n",[262,23984,23985,23988],{"class":181,"line":521},[262,23986,23987],{"class":275},"        \"https:\u002F\u002Fyour-cdn.example.com\u002Flaunch.jpg\"",[262,23989,1315],{"class":429},[262,23991,23992],{"class":181,"line":537},[262,23993,23994],{"class":429},"        caption,\n",[262,23996,23997,24000,24002,24004],{"class":181,"line":549},[262,23998,23999],{"class":611},"        hours_from_now",[262,24001,476],{"class":377},[262,24003,15361],{"class":271},[262,24005,1315],{"class":429},[262,24007,24008],{"class":181,"line":570},[262,24009,1011],{"class":429},[262,24011,24012,24014,24016,24018,24021,24023,24026,24028,24030,24032,24034],{"class":181,"line":579},[262,24013,1089],{"class":271},[262,24015,602],{"class":429},[262,24017,642],{"class":377},[262,24019,24020],{"class":275},"\"Scheduled. Post creation id: ",[262,24022,3039],{"class":271},[262,24024,24025],{"class":429},"result[",[262,24027,10188],{"class":275},[262,24029,6223],{"class":429},[262,24031,654],{"class":271},[262,24033,1176],{"class":275},[262,24035,660],{"class":429},[262,24037,24038],{"class":181,"line":586},[262,24039,583],{"emptyLinePlaceholder":582},[262,24041,24042],{"class":181,"line":591},[262,24043,583],{"emptyLinePlaceholder":582},[262,24045,24046,24048,24050,24052,24054],{"class":181,"line":623},[262,24047,2210],{"class":377},[262,24049,2213],{"class":271},[262,24051,2216],{"class":377},[262,24053,2219],{"class":275},[262,24055,1160],{"class":429},[262,24057,24058],{"class":181,"line":634},[262,24059,24060],{"class":429},"    main()\n",[14,24062,24063],{},"That is the full loop: caption, media check, schedule, confirm. Because Meta holds the scheduled post on its own servers, your computer can be off when the post actually goes live.",[57,24065,24067],{"id":24066},"key-parameters-quick-reference","Key parameters quick reference",[1379,24069,24070,24081],{},[1382,24071,24072],{},[1385,24073,24074,24076,24079],{},[1388,24075,1390],{},[1388,24077,24078],{},"Where",[1388,24080,1396],{},[1398,24082,24083,24098,24114],{},[1385,24084,24085,24089,24095],{},[1403,24086,24087],{},[18,24088,23339],{},[1403,24090,24091,24094],{},[18,24092,24093],{},"media_publish"," call",[1403,24096,24097],{},"Unix timestamp (int) for go-live; must be 10 min to 75 days ahead.",[1385,24099,24100,24105,24111],{},[1403,24101,24102],{},[18,24103,24104],{},"image_url",[1403,24106,24107,24110],{},[18,24108,24109],{},"media"," container call",[1403,24112,24113],{},"Public HTTPS image Meta downloads; no login or redirect allowed.",[1385,24115,24116,24120,24125],{},[1403,24117,24118],{},[18,24119,3829],{},[1403,24121,24122],{},[18,24123,24124],{},"generate_caption",[1403,24126,24127],{},"Higher (0.8) varies caption wording; lower (0.3) stays on-brand.",[57,24129,1445],{"id":1444},[1447,24131,24132,24147,24163,24186],{},[1450,24133,24134,24140,24141,24144,24145,1363],{},[35,24135,24136,24139],{},[18,24137,24138],{},"OAuthException"," code 190."," Your access token expired or was revoked. Long-lived tokens last about 60 days, so regenerate one in the Graph API Explorer and update ",[18,24142,24143],{},"IG_ACCESS_TOKEN"," in ",[18,24146,319],{},[1450,24148,24149,24155,24156,24158,24159,24162],{},[35,24150,24151,24154],{},[18,24152,24153],{},"Invalid parameter"," code 100 on publish."," Usually ",[18,24157,23339],{}," is outside the 10-minute-to-75-day window or was sent as a float. Wrap it in ",[18,24160,24161],{},"int()"," and confirm it is in the future.",[1450,24164,24165,24171,24172,24175,24176,24178,24179,407,24182,24185],{},[35,24166,24167,24168,1363],{},"Container stuck in ",[18,24169,24170],{},"IN_PROGRESS"," Meta cannot fetch your image. Run ",[18,24173,24174],{},"check_image_url"," and make sure the link is public HTTPS, returns ",[18,24177,104],{},", has an ",[18,24180,24181],{},"image\u002Fjpeg",[18,24183,24184],{},"image\u002Fpng"," type, and never redirects.",[1450,24187,24188,24193,24194,24197],{},[35,24189,24190,1363],{},[18,24191,24192],{},"Application request limit reached"," You hit the 50-posts-per-24-hours cap. Read the ",[18,24195,24196],{},"x-business-use-case-usage"," response header to see your usage and slow down before retrying.",[57,24199,2317],{"id":2316},[2322,24201,24202,24207,24215],{},[1450,24203,24204,24206],{},[35,24205,5280],{}," when you want one Instagram account on autopilot with AI captions and full control over timing, and you are comfortable managing a Meta access token.",[1450,24208,24209,24214],{},[35,24210,24211,24212],{},"Use ",[51,24213,15717],{"href":19701}," when you are queuing many posts at once across a content calendar, where reading rows from a spreadsheet matters more than per-post tuning.",[1450,24216,24217,24220],{},[35,24218,24219],{},"Use a hosted scheduler"," (Buffer, Later, Meta Business Suite by hand) when you do not want to maintain code or tokens at all and a monthly fee is acceptable. You trade flexibility and AI integration for convenience.",[14,24222,2375,24223,1363],{},[51,24224,9309],{"href":9308},[57,24226,2381],{"id":2380},[2322,24228,24229,24234,24239,24244],{},[1450,24230,24231,24233],{},[51,24232,9309],{"href":9308}," — the section hub for every posting workflow.",[1450,24235,24236,24238],{},[51,24237,15717],{"href":19701}," — queue a whole calendar of posts in one run.",[1450,24240,24241,24243],{},[51,24242,17687],{"href":17686}," — turn one idea into a structured multi-tweet thread.",[1450,24245,24246,24248],{},[51,24247,5270],{"href":5269}," — reusable prompts to sharpen your captions.",[2401,24250,24251],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":24253},[24254,24255,24256,24257,24258,24259,24260,24261,24262],{"id":237,"depth":282,"text":238},{"id":22783,"depth":282,"text":22784},{"id":23130,"depth":282,"text":23131},{"id":23321,"depth":282,"text":23322},{"id":23910,"depth":282,"text":23911},{"id":24066,"depth":282,"text":24067},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Automate Instagram publishing with Python: generate a caption with an LLM, then schedule and publish through the Instagram Graph API using httpx.",[24265,24268,24271,24274,24277],{"q":24266,"a":24267},"Can you schedule Instagram posts with the Graph API?","Yes. The Instagram Graph API lets a Business or Creator account create a media container and publish it, and Meta supports a scheduled_publish_time between 10 minutes and 75 days in the future for single image and video posts.",{"q":24269,"a":24270},"Do I need a personal Instagram account or a Business account?","You need an Instagram Business or Creator account that is linked to a Facebook Page. Personal accounts cannot use the Content Publishing API, so convert your account in the Instagram app settings first.",{"q":24272,"a":24273},"Why does my image fail to publish even though the URL works in my browser?","Meta's servers fetch the image themselves, so it must be a public HTTPS URL with no login, no redirect, and a valid image\u002Fjpeg or image\u002Fpng type. Links behind authentication or signed URLs that expire often fail.",{"q":24275,"a":24276},"How many posts can I publish per day through the API?","Instagram allows 50 API-published posts per account in a rolling 24-hour window. The x-business-use-case-usage response header reports how much of that quota you have used.",{"q":24278,"a":24279},"Do I have to keep my computer running for scheduled posts?","If you set scheduled_publish_time, Meta publishes the post on its own servers, so your machine can be off. If you instead schedule with a local tool like APScheduler, your script must be running at publish time.",{"name":24281,"steps":24282},"How to schedule Instagram posts with Python and AI",[24283,24286,24289,24292,24295],{"name":24284,"text":24285},"Set up credentials and install dependencies","Install the libraries, link a Business account to a Facebook Page, and store your Instagram user ID, access token, and OpenAI key in a .env file.",{"name":24287,"text":24288},"Generate the caption with an LLM","Call the OpenAI API to produce a caption and hashtags as JSON so the text is easy to parse and validate.",{"name":24290,"text":24291},"Prepare the media","Host the image on a public HTTPS URL and confirm it returns a valid image type with no redirect.",{"name":24293,"text":24294},"Create and schedule the post","Create a media container, poll until it is finished, then publish it with a scheduled_publish_time Unix timestamp.",{"name":24296,"text":24297},"Verify the scheduled post","Check the response for a creation ID and confirm the post appears in your Instagram scheduled content.",{},"\u002Fai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fschedule-instagram-posts-using-python-and-ai",{"title":15735,"description":24263},"Schedule Instagram Posts with Python and AI","ai-content-creation-marketing-automation\u002Fautomated-social-media-posting\u002Fschedule-instagram-posts-using-python-and-ai\u002Findex","-y55hYwJfayEk9d5z1tYFigREAbBHer_hVA2iCSF7SY",{"id":24305,"title":24306,"body":24307,"description":26489,"extension":2419,"faq":26490,"howto":26506,"meta":26523,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":26524,"published":26525,"seo":26526,"seoTitle":26527,"stem":26528,"__hash__":26529},"content\u002Fai-content-creation-marketing-automation\u002Findex.md","AI Content Creation & Marketing Automation with Python",{"type":7,"value":24308,"toc":26477},[24309,24312,24315,24318,24321,24328,24429,24433,24436,24466,24469,24472,24474,24483,24486,24529,24541,24557,24562,24576,24589,24595,24599,24606,24617,24629,24878,24888,24902,24906,24916,24919,24928,25210,25220,25233,25237,25248,25251,25254,25260,25546,25562,25566,25569,25572,25578,25839,25852,25856,25859,26269,26279,26293,26297,26386,26388,26391,26438,26440,26443,26469,26474],[10,24310,24306],{"id":24311},"ai-content-creation-marketing-automation-with-python",[14,24313,24314],{},"You are a marketer, creator, or founder, not a software engineer. Yet every week you write the same kinds of posts, resize the same images, paste captions into the same scheduler, and squint at the same spreadsheets trying to guess what worked. The repetitive parts of content marketing eat the hours you wanted to spend on strategy and ideas. Python plus an AI model can take those repetitive parts off your plate, and you do not need a computer science degree to wire it up.",[14,24316,24317],{},"This guide treats Python as plumbing, not programming. You will paste short scripts, change a few words to match your brand, and run one command. The AI does the writing and image-making; Python connects it to your tools and runs it on a schedule. By the end of this hub you will understand how to turn a one-line idea into a finished post, complete with copy, an image, and a scheduled publish time, and how to check whether anyone actually engaged with it.",[14,24319,24320],{},"The reason Python sits at the center of all this is that it is the common language every AI service speaks. OpenAI, the platform behind ChatGPT and DALL-E, publishes an official Python toolkit. So do most social networks, email services, and analytics tools. That means one short script can pull text from one service, an image from another, and push the result to a third, without you ever opening a browser tab. A no-code tool can do one of these jobs in isolation; a few lines of Python can do all of them and tie them together exactly the way your brand needs. You are not learning to build software, you are learning to connect tools you already pay for so they run themselves.",[14,24322,24323,24324,24327],{},"Throughout, \"API\" means ",[27,24325,24326],{},"application programming interface",", a doorway that lets your script talk to a service like OpenAI. \"Model\" means the AI itself, such as the one behind ChatGPT. \"Prompt\" is the instruction you send it. A \"token\" is the unit AI services bill by, roughly three-quarters of a word, so a 500-word draft is around 650 tokens of output. Knowing that one term keeps cost from feeling like a mystery. Wherever a concept has its own detailed walkthrough, you will see a link to it, so you can read this top to bottom or jump straight to the task you need.",[76,24329,24331,24426],{"className":24330},[79],[81,24332,90,24337,90,24340,90,24343,90,24345,90,24348,90,24352,90,24354,90,24357,90,24360,90,24362,90,24366,90,24369,90,24373,90,24375,90,24379,90,24381,90,24385,90,24387,90,24390,90,24393,90,24396,90,24400,90,24403,90,24407,90,24410,90,24412,90,24415,90,24419,90,24422],{"viewBox":24333,"role":84,"ariaLabelledBy":24334,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1020 560",[24335,24336],"ovTitle","ovDesc",[92,24338,24339],{"id":24335},"A Python marketing automation pipeline",[96,24341,24342],{"id":24336},"An idea flows through Python into text, image, and scheduling steps, then results flow back as data to refine the next round.",[100,24344],{"x":102,"y":19855,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,24346,24347],{"x":113,"y":19858,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Your idea",[111,24349,24351],{"x":113,"y":24350,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"229","a one-line brief",[100,24353],{"x":12816,"y":19855,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},[111,24355,24356],{"x":12819,"y":19858,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Python script",[111,24358,24359],{"x":12819,"y":24350,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"the orchestrator",[100,24361],{"x":12825,"y":23367,"width":104,"height":12826,"rx":106,"fill":142,"stroke":169,"strokeWidth":144},[111,24363,24365],{"x":12829,"y":24364,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"75","Text model",[100,24367],{"x":12825,"y":24368,"width":104,"height":12826,"rx":106,"fill":142,"stroke":169,"strokeWidth":144},"140",[111,24370,24372],{"x":12829,"y":24371,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"175","Image model",[100,24374],{"x":12825,"y":129,"width":104,"height":12826,"rx":106,"fill":142,"stroke":169,"strokeWidth":144},[111,24376,24378],{"x":12829,"y":24377,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"275","Scheduler API",[100,24380],{"x":12825,"y":133,"width":104,"height":12826,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,24382,24384],{"x":12829,"y":24383,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"375","Analytics",[181,24386],{"x1":104,"y1":19925,"x2":12856,"y2":19925,"stroke":108,"strokeWidth":109},[186,24388],{"points":24389,"fill":108},"272,211 282,216 272,221",[181,24391],{"x1":158,"y1":24392,"x2":12863,"y2":151,"stroke":130,"strokeWidth":109},"210",[186,24394],{"points":24395,"fill":130},"546,67 556,70 550,79",[181,24397],{"x1":158,"y1":24398,"x2":12863,"y2":24399,"stroke":130,"strokeWidth":109},"214","172",[186,24401],{"points":24402,"fill":130},"544,167 555,170 548,178",[181,24404],{"x1":158,"y1":24405,"x2":12863,"y2":24406,"stroke":130,"strokeWidth":109},"222","268",[186,24408],{"points":24409,"fill":130},"548,262 556,270 544,272",[181,24411],{"x1":12829,"y1":178,"x2":12829,"y2":198,"stroke":143,"strokeWidth":109},[181,24413],{"x1":12829,"y1":198,"x2":113,"y2":198,"stroke":143,"strokeWidth":109,"strokeDashArray":24414},[222,19848],[181,24416],{"x1":113,"y1":198,"x2":113,"y2":24417,"stroke":143,"strokeWidth":109,"strokeDashArray":24418},"258",[222,19848],[186,24420],{"points":24421,"fill":143},"95,264 100,254 105,264",[111,24423,24425],{"x":178,"y":24424,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"464","results feed back",[232,24427,24428],{},"One Python script sits in the middle, calling AI models and platform APIs, then learning from the results.",[57,24430,24432],{"id":24431},"what-this-guide-covers","What this guide covers",[14,24434,24435],{},"This hub is organized into four practical sections. Each one stands alone, but together they form a complete content pipeline, from blank page to published post to measured result.",[2322,24437,24438,24445,24452,24459],{},[1450,24439,24440,24444],{},[35,24441,24442],{},[51,24443,3991],{"href":3990}," turns the OpenAI API into a drafting partner for blog posts, product descriptions, and email newsletters. This is where you learn to send a brief and get back usable text in a consistent brand voice.",[1450,24446,24447,24451],{},[35,24448,24449],{},[51,24450,9410],{"href":9409}," covers making visuals on demand with DALL-E, from YouTube thumbnails to batches of product images, without touching design software.",[1450,24453,24454,24458],{},[35,24455,24456],{},[51,24457,9309],{"href":9308}," shows how to schedule and publish content across platforms on a timetable, including Instagram posts, bulk schedules, and Twitter threads.",[1450,24460,24461,24465],{},[35,24462,24463],{},[51,24464,9304],{"href":9303}," uses code and data to find what your audience searches for, group related keywords, and write meta descriptions at scale.",[14,24467,24468],{},"In the four core sections below, you will get a runnable example for each of these four jobs: drafting text, generating an image, scheduling a post, and measuring engagement. Then a short mini-project stitches them together. You can read the sections in order or skip to the one that solves today's problem.",[14,24470,24471],{},"A useful way to picture the whole hub is as an assembly line with four stations. Station one writes words. Station two makes pictures. Station three decides when each finished piece goes out. Station four watches how it landed and reports back so station one writes better words next time. The magic is not in any single station, it is in the fact that a script can run the whole line unattended. Once you have built each station once, you rarely touch them again; you just feed in new ideas at one end and collect published, measured content at the other. The rest of this guide builds those four stations one at a time, then connects them.",[57,24473,238],{"id":237},[14,24475,24476,24477,24479,24480,24482],{},"You need Python 3.10 or newer, the ",[18,24478,298],{}," package installer (it ships with Python), and a habit of working inside a virtual environment so your projects do not interfere with each other. If any of that is new, the ",[51,24481,5423],{"href":5422}," guide walks through installation on Mac and Windows step by step.",[14,24484,24485],{},"Check your Python version, create an isolated environment, and activate it:",[253,24487,24489],{"className":255,"code":24488,"language":257,"meta":258,"style":258},"python3 --version          # should print 3.10 or higher\npython3 -m venv .venv       # create a virtual environment in a .venv folder\nsource .venv\u002Fbin\u002Factivate   # activate it on Mac\u002FLinux\n# .venv\\Scripts\\activate    # use this line instead on Windows\n",[18,24490,24491,24501,24515,24524],{"__ignoreMap":258},[262,24492,24493,24495,24498],{"class":181,"line":264},[262,24494,268],{"class":267},[262,24496,24497],{"class":271}," --version",[262,24499,24500],{"class":291},"          # should print 3.10 or higher\n",[262,24502,24503,24505,24507,24509,24512],{"class":181,"line":282},[262,24504,268],{"class":267},[262,24506,272],{"class":271},[262,24508,276],{"class":275},[262,24510,24511],{"class":275}," .venv",[262,24513,24514],{"class":291},"       # create a virtual environment in a .venv folder\n",[262,24516,24517,24519,24521],{"class":181,"line":295},[262,24518,285],{"class":271},[262,24520,288],{"class":275},[262,24522,24523],{"class":291},"   # activate it on Mac\u002FLinux\n",[262,24525,24526],{"class":181,"line":345},[262,24527,24528],{"class":291},"# .venv\\Scripts\\activate    # use this line instead on Windows\n",[14,24530,24531,24532,24534,24535,24537,24538,24540],{},"With the environment active, install the libraries every example in this hub uses. The ",[18,24533,20],{}," SDK is the official toolkit for talking to OpenAI's models, ",[18,24536,5450],{}," is a modern way to download files over the web, and ",[18,24539,2501],{}," reads your secret keys from a file:",[253,24542,24543],{"className":255,"code":5427,"language":257,"meta":258,"style":258},[18,24544,24545],{"__ignoreMap":258},[262,24546,24547,24549,24551,24553,24555],{"class":181,"line":264},[262,24548,298],{"class":267},[262,24550,301],{"class":275},[262,24552,2519],{"class":275},[262,24554,5440],{"class":275},[262,24556,2522],{"class":275},[14,24558,24559,24560,22741],{},"You also need an OpenAI API key, which is a long secret string from your account dashboard. Never paste it directly into your code. Instead, store it in a file named ",[18,24561,319],{},[253,24563,24565],{"className":323,"code":24564,"language":325,"meta":258,"style":258},"# .env\nOPENAI_API_KEY=sk-proj-your-real-key-here\n",[18,24566,24567,24571],{"__ignoreMap":258},[262,24568,24569],{"class":181,"line":264},[262,24570,332],{},[262,24572,24573],{"class":181,"line":282},[262,24574,24575],{},"OPENAI_API_KEY=sk-proj-your-real-key-here\n",[14,24577,7251,24578,356,24580,24582,24583,24585,24586,24588],{},[18,24579,319],{},[18,24581,359],{}," file so the key is never committed or shared. A leaked key can be used by anyone to spend your money, and they are surprisingly easy to leak by accident when you copy a project folder or push code to GitHub. The ",[18,24584,359],{}," file is a plain text list of files git should never track; adding the single line ",[18,24587,319],{}," to it is the cheapest insurance you will ever buy.",[14,24590,24591,24592,24594],{},"One more piece of housekeeping before any real spending happens: open your OpenAI account dashboard and set a monthly usage limit. This is a hard cap that stops all requests once you hit it, which means a buggy loop or a typo can never run up a bill larger than the number you choose. Set it low to start, perhaps five or ten dollars, and raise it once you trust your scripts. If you want to understand what an API key is doing, how requests are billed, and why each example here is structured the way it is, read ",[51,24593,2487],{"href":2486}," before going further. It also covers the most common errors you will hit, so when something breaks you will know whether it is your key, your quota, or your code.",[57,24596,24598],{"id":24597},"generate-marketing-copy-with-the-openai-api","Generate marketing copy with the OpenAI API",[14,24600,24601,24602,24605],{},"The first job is text. A ",[27,24603,24604],{},"chat model"," takes a written instruction and returns written output. For marketing, that means you describe what you want, a captioned product launch, a punchy subject line, a short blog intro, and the model drafts it. Your script's job is to pass the model a clear brief and hand back the result.",[14,24607,24608,24609,24612,24613,24616],{},"The single most important idea in copywriting with AI is the split between the ",[27,24610,24611],{},"system message"," and the ",[27,24614,24615],{},"user message",". The system message is where you set the rules that never change: who the model is pretending to be, your brand voice, banned words, length limits, formatting. The user message is the one specific request for this run. Keeping them separate means you write your brand rules once and reuse them across every caption, email, and headline, so the output stays on-brand even when the topic changes. This is the same discipline professional teams use, and it is the difference between AI copy that sounds like your brand and AI copy that sounds like a robot.",[14,24618,24619,24620,24622,24623,24625,24626,24628],{},"The example below defines a small function you can reuse for any copy task. The ",[18,24621,4466],{}," message sets the persona and rules, the ",[18,24624,4470],{}," message carries the specific request, and ",[18,24627,3829],{}," controls how creative versus predictable the output is (lower is safer and more consistent, higher is more inventive).",[253,24630,24632],{"className":414,"code":24631,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # read OPENAI_API_KEY from your .env file\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef write_copy(brief: str, voice: str = \"friendly and concise\") -> str:\n    \"\"\"Turn a one-line brief into marketing copy.\"\"\"\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",  # fast and cheap; good for most copy\n        messages=[\n            {\"role\": \"system\", \"content\": f\"You are a marketing copywriter. Write in a {voice} voice. No emojis.\"},\n            {\"role\": \"user\", \"content\": brief},\n        ],\n        temperature=0.7,\n    )\n    return response.choices[0].message.content.strip()\n\n\nif __name__ == \"__main__\":\n    caption = write_copy(\"Instagram caption for a new oat-milk latte, under 30 words.\")\n    print(caption)\n",[18,24633,24634,24640,24650,24660,24664,24671,24689,24693,24697,24725,24730,24738,24751,24759,24792,24809,24813,24823,24827,24837,24841,24845,24857,24871],{"__ignoreMap":258},[262,24635,24636,24638],{"class":181,"line":264},[262,24637,684],{"class":377},[262,24639,687],{"class":429},[262,24641,24642,24644,24646,24648],{"class":181,"line":282},[262,24643,705],{"class":377},[262,24645,708],{"class":429},[262,24647,684],{"class":377},[262,24649,713],{"class":429},[262,24651,24652,24654,24656,24658],{"class":181,"line":295},[262,24653,705],{"class":377},[262,24655,720],{"class":429},[262,24657,684],{"class":377},[262,24659,725],{"class":429},[262,24661,24662],{"class":181,"line":345},[262,24663,583],{"emptyLinePlaceholder":582},[262,24665,24666,24668],{"class":181,"line":492},[262,24667,4222],{"class":429},[262,24669,24670],{"class":291},"# read OPENAI_API_KEY from your .env file\n",[262,24672,24673,24675,24677,24679,24681,24683,24685,24687],{"class":181,"line":503},[262,24674,739],{"class":429},[262,24676,476],{"class":377},[262,24678,1588],{"class":429},[262,24680,2674],{"class":611},[262,24682,476],{"class":377},[262,24684,1199],{"class":429},[262,24686,2681],{"class":275},[262,24688,2684],{"class":429},[262,24690,24691],{"class":181,"line":521},[262,24692,583],{"emptyLinePlaceholder":582},[262,24694,24695],{"class":181,"line":537},[262,24696,583],{"emptyLinePlaceholder":582},[262,24698,24699,24701,24704,24707,24709,24712,24714,24716,24719,24721,24723],{"class":181,"line":549},[262,24700,423],{"class":377},[262,24702,24703],{"class":267}," write_copy",[262,24705,24706],{"class":429},"(brief: ",[262,24708,433],{"class":271},[262,24710,24711],{"class":429},", voice: ",[262,24713,433],{"class":271},[262,24715,442],{"class":377},[262,24717,24718],{"class":275}," \"friendly and concise\"",[262,24720,1939],{"class":429},[262,24722,433],{"class":271},[262,24724,1160],{"class":429},[262,24726,24727],{"class":181,"line":570},[262,24728,24729],{"class":275},"    \"\"\"Turn a one-line brief into marketing copy.\"\"\"\n",[262,24731,24732,24734,24736],{"class":181,"line":579},[262,24733,1184],{"class":429},[262,24735,476],{"class":377},[262,24737,1189],{"class":429},[262,24739,24740,24742,24744,24746,24748],{"class":181,"line":586},[262,24741,1194],{"class":611},[262,24743,476],{"class":377},[262,24745,1207],{"class":275},[262,24747,13488],{"class":429},[262,24749,24750],{"class":291},"# fast and cheap; good for most copy\n",[262,24752,24753,24755,24757],{"class":181,"line":591},[262,24754,1215],{"class":611},[262,24756,476],{"class":377},[262,24758,1220],{"class":429},[262,24760,24761,24763,24765,24767,24769,24771,24773,24775,24777,24780,24782,24785,24787,24790],{"class":181,"line":623},[262,24762,1225],{"class":429},[262,24764,1228],{"class":275},[262,24766,1231],{"class":429},[262,24768,1234],{"class":275},[262,24770,608],{"class":429},[262,24772,1239],{"class":275},[262,24774,1231],{"class":429},[262,24776,642],{"class":377},[262,24778,24779],{"class":275},"\"You are a marketing copywriter. Write in a ",[262,24781,3039],{"class":271},[262,24783,24784],{"class":429},"voice",[262,24786,654],{"class":271},[262,24788,24789],{"class":275}," voice. No emojis.\"",[262,24791,3143],{"class":429},[262,24793,24794,24796,24798,24800,24802,24804,24806],{"class":181,"line":634},[262,24795,1225],{"class":429},[262,24797,1228],{"class":275},[262,24799,1231],{"class":429},[262,24801,1291],{"class":275},[262,24803,608],{"class":429},[262,24805,1239],{"class":275},[262,24807,24808],{"class":429},": brief},\n",[262,24810,24811],{"class":181,"line":845},[262,24812,1303],{"class":429},[262,24814,24815,24817,24819,24821],{"class":181,"line":850},[262,24816,1308],{"class":611},[262,24818,476],{"class":377},[262,24820,4672],{"class":271},[262,24822,1315],{"class":429},[262,24824,24825],{"class":181,"line":864},[262,24826,1011],{"class":429},[262,24828,24829,24831,24833,24835],{"class":181,"line":1683},[262,24830,573],{"class":377},[262,24832,1326],{"class":429},[262,24834,102],{"class":271},[262,24836,3205],{"class":429},[262,24838,24839],{"class":181,"line":1688},[262,24840,583],{"emptyLinePlaceholder":582},[262,24842,24843],{"class":181,"line":1693},[262,24844,583],{"emptyLinePlaceholder":582},[262,24846,24847,24849,24851,24853,24855],{"class":181,"line":1728},[262,24848,2210],{"class":377},[262,24850,2213],{"class":271},[262,24852,2216],{"class":377},[262,24854,2219],{"class":275},[262,24856,1160],{"class":429},[262,24858,24859,24861,24863,24866,24869],{"class":181,"line":1737},[262,24860,20687],{"class":429},[262,24862,476],{"class":377},[262,24864,24865],{"class":429}," write_copy(",[262,24867,24868],{"class":275},"\"Instagram caption for a new oat-milk latte, under 30 words.\"",[262,24870,660],{"class":429},[262,24872,24873,24875],{"class":181,"line":1751},[262,24874,1089],{"class":271},[262,24876,24877],{"class":429},"(caption)\n",[14,24879,13310,24880,24883,24884,24887],{},[18,24881,24882],{},"python write_copy.py",". Change the brief string and run again to draft anything else. The same pattern scales from one caption to hundreds: loop over a list of products and call ",[18,24885,24886],{},"write_copy"," for each, and you have drafted a season's worth of captions in the time it takes to make coffee. That batching ability is where the real hours come back. Writing one caption by hand is quick; writing two hundred consistent ones is a lost afternoon, and AI plus a loop turns it into a thirty-second script run.",[14,24889,24890,24891,24893,24894,1374,24896,24898,24899,24901],{},"A word on quality control, because it matters more than speed. Treat every draft as a first draft, not a final one. The model is excellent at structure, rhythm, and getting you past the blank page, but it does not know your latest pricing, your legal constraints, or the small true details that make copy trustworthy. The workflow that actually works is generate fast, edit carefully, and keep a short list of your best human-edited examples to feed back into the system message as a style reference. To go deeper on prompts, brand voice, and batch drafting, the ",[51,24892,3991],{"href":3990}," section has full walkthroughs, including how to ",[51,24895,3983],{"href":3982},[51,24897,2462],{"href":5290},". If your output is coming back in the wrong shape, the ",[51,24900,1362],{"href":1361}," guide is the fastest fix.",[57,24903,24905],{"id":24904},"create-images-with-dall-e","Create images with DALL-E",[14,24907,24908,24909,24912,24913,24915],{},"The second job is visuals. DALL-E is OpenAI's ",[27,24910,24911],{},"image model",": you send a text description and it returns a picture. The endpoint gives you a temporary URL to the generated image, so your script downloads that URL and saves the file to disk. This is where ",[18,24914,5450],{}," comes in, it fetches the image bytes so you can write them to a local file. The temporary part matters: the URL the service hands back expires after a short while, so your script should download and save the image right away rather than storing the link for later.",[14,24917,24918],{},"Generating images in code unlocks something the chat interface cannot do well, which is volume with consistency. If you run an online store with fifty products, you do not want to type fifty prompts by hand and download fifty files one click at a time. You want to keep your product list in a spreadsheet, write the prompt template once, and let a loop produce every image with the same lighting, framing, and style. The script below is the single-image building block; everything larger is just that block inside a loop.",[14,24920,24921,24922,24924,24925,24927],{},"The function below generates one image from a prompt and saves it. The ",[18,24923,10260],{}," parameter sets the dimensions; ",[18,24926,10860],{}," is square, and DALL-E 3 also supports wide and tall formats for thumbnails and stories. Choosing the right size up front saves you from cropping later, so think about where the image will live before you generate it.",[253,24929,24931],{"className":414,"code":24930,"language":416,"meta":258,"style":258},"import os\nimport httpx\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef make_image(prompt: str, out_path: str, size: str = \"1024x1024\") -> str:\n    \"\"\"Generate an image from a prompt and save it to out_path.\"\"\"\n    result = client.images.generate(\n        model=\"dall-e-3\",\n        prompt=prompt,\n        size=size,\n        n=1,  # number of images; DALL-E 3 returns one at a time\n    )\n    image_url = result.data[0].url\n    image_bytes = httpx.get(image_url, timeout=60).content\n    with open(out_path, \"wb\") as f:\n        f.write(image_bytes)\n    return out_path\n\n\nif __name__ == \"__main__\":\n    path = make_image(\n        \"A warm, minimalist photo of an oat-milk latte on a wooden cafe table, soft morning light\",\n        \"latte.png\",\n    )\n    print(f\"Saved {path}\")\n",[18,24932,24933,24939,24945,24955,24965,24969,24973,24991,24995,24999,25028,25033,25041,25051,25059,25067,25080,25084,25098,25114,25131,25136,25143,25147,25151,25163,25172,25179,25186,25190],{"__ignoreMap":258},[262,24934,24935,24937],{"class":181,"line":264},[262,24936,684],{"class":377},[262,24938,687],{"class":429},[262,24940,24941,24943],{"class":181,"line":282},[262,24942,684],{"class":377},[262,24944,6526],{"class":429},[262,24946,24947,24949,24951,24953],{"class":181,"line":295},[262,24948,705],{"class":377},[262,24950,708],{"class":429},[262,24952,684],{"class":377},[262,24954,713],{"class":429},[262,24956,24957,24959,24961,24963],{"class":181,"line":345},[262,24958,705],{"class":377},[262,24960,720],{"class":429},[262,24962,684],{"class":377},[262,24964,725],{"class":429},[262,24966,24967],{"class":181,"line":492},[262,24968,583],{"emptyLinePlaceholder":582},[262,24970,24971],{"class":181,"line":503},[262,24972,734],{"class":429},[262,24974,24975,24977,24979,24981,24983,24985,24987,24989],{"class":181,"line":521},[262,24976,739],{"class":429},[262,24978,476],{"class":377},[262,24980,1588],{"class":429},[262,24982,2674],{"class":611},[262,24984,476],{"class":377},[262,24986,1199],{"class":429},[262,24988,2681],{"class":275},[262,24990,2684],{"class":429},[262,24992,24993],{"class":181,"line":537},[262,24994,583],{"emptyLinePlaceholder":582},[262,24996,24997],{"class":181,"line":549},[262,24998,583],{"emptyLinePlaceholder":582},[262,25000,25001,25003,25006,25008,25010,25012,25014,25016,25018,25020,25022,25024,25026],{"class":181,"line":570},[262,25002,423],{"class":377},[262,25004,25005],{"class":267}," make_image",[262,25007,9599],{"class":429},[262,25009,433],{"class":271},[262,25011,15066],{"class":429},[262,25013,433],{"class":271},[262,25015,9608],{"class":429},[262,25017,433],{"class":271},[262,25019,442],{"class":377},[262,25021,9615],{"class":275},[262,25023,1939],{"class":429},[262,25025,433],{"class":271},[262,25027,1160],{"class":429},[262,25029,25030],{"class":181,"line":579},[262,25031,25032],{"class":275},"    \"\"\"Generate an image from a prompt and save it to out_path.\"\"\"\n",[262,25034,25035,25037,25039],{"class":181,"line":586},[262,25036,13177],{"class":429},[262,25038,476],{"class":377},[262,25040,9677],{"class":429},[262,25042,25043,25045,25047,25049],{"class":181,"line":591},[262,25044,1194],{"class":611},[262,25046,476],{"class":377},[262,25048,9686],{"class":275},[262,25050,1315],{"class":429},[262,25052,25053,25055,25057],{"class":181,"line":623},[262,25054,13197],{"class":611},[262,25056,476],{"class":377},[262,25058,9698],{"class":429},[262,25060,25061,25063,25065],{"class":181,"line":634},[262,25062,13206],{"class":611},[262,25064,476],{"class":377},[262,25066,9708],{"class":429},[262,25068,25069,25071,25073,25075,25077],{"class":181,"line":845},[262,25070,13496],{"class":611},[262,25072,476],{"class":377},[262,25074,997],{"class":271},[262,25076,13488],{"class":429},[262,25078,25079],{"class":291},"# number of images; DALL-E 3 returns one at a time\n",[262,25081,25082],{"class":181,"line":850},[262,25083,1011],{"class":429},[262,25085,25086,25089,25091,25094,25096],{"class":181,"line":864},[262,25087,25088],{"class":429},"    image_url ",[262,25090,476],{"class":377},[262,25092,25093],{"class":429}," result.data[",[262,25095,102],{"class":271},[262,25097,9763],{"class":429},[262,25099,25100,25102,25104,25106,25108,25110,25112],{"class":181,"line":1683},[262,25101,13221],{"class":429},[262,25103,476],{"class":377},[262,25105,9770],{"class":429},[262,25107,1591],{"class":611},[262,25109,476],{"class":377},[262,25111,12826],{"class":271},[262,25113,9780],{"class":429},[262,25115,25116,25118,25120,25123,25125,25127,25129],{"class":181,"line":1688},[262,25117,10124],{"class":377},[262,25119,599],{"class":271},[262,25121,25122],{"class":429},"(out_path, ",[262,25124,13243],{"class":275},[262,25126,1000],{"class":429},[262,25128,697],{"class":377},[262,25130,9190],{"class":429},[262,25132,25133],{"class":181,"line":1693},[262,25134,25135],{"class":429},"        f.write(image_bytes)\n",[262,25137,25138,25140],{"class":181,"line":1728},[262,25139,573],{"class":377},[262,25141,25142],{"class":429}," out_path\n",[262,25144,25145],{"class":181,"line":1737},[262,25146,583],{"emptyLinePlaceholder":582},[262,25148,25149],{"class":181,"line":1751},[262,25150,583],{"emptyLinePlaceholder":582},[262,25152,25153,25155,25157,25159,25161],{"class":181,"line":1764},[262,25154,2210],{"class":377},[262,25156,2213],{"class":271},[262,25158,2216],{"class":377},[262,25160,2219],{"class":275},[262,25162,1160],{"class":429},[262,25164,25165,25167,25169],{"class":181,"line":1779},[262,25166,4981],{"class":429},[262,25168,476],{"class":377},[262,25170,25171],{"class":429}," make_image(\n",[262,25173,25174,25177],{"class":181,"line":1793},[262,25175,25176],{"class":275},"        \"A warm, minimalist photo of an oat-milk latte on a wooden cafe table, soft morning light\"",[262,25178,1315],{"class":429},[262,25180,25181,25184],{"class":181,"line":1800},[262,25182,25183],{"class":275},"        \"latte.png\"",[262,25185,1315],{"class":429},[262,25187,25188],{"class":181,"line":1805},[262,25189,1011],{"class":429},[262,25191,25192,25194,25196,25198,25200,25202,25204,25206,25208],{"class":181,"line":1810},[262,25193,1089],{"class":271},[262,25195,602],{"class":429},[262,25197,642],{"class":377},[262,25199,3753],{"class":275},[262,25201,3039],{"class":271},[262,25203,216],{"class":429},[262,25205,654],{"class":271},[262,25207,1176],{"class":275},[262,25209,660],{"class":429},[14,25211,12445,25212,25215,25216,25219],{},[18,25213,25214],{},"python make_image.py"," and you will find ",[18,25217,25218],{},"latte.png"," in your folder. Write the prompt the way you would brief a photographer: subject, setting, mood, and lighting. Vague prompts produce generic stock-photo results, while specific ones, naming the surface, the time of day, the camera angle, and the feeling, produce images that look intentional. Keep a few prompt templates that consistently match your brand and reuse them; consistency across a feed reads as professionalism even when no single image is remarkable.",[14,25221,25222,25223,25225,25226,25229,25230,25232],{},"Two cost notes worth internalizing. Images are priced per generation, not per token, and they cost several cents each rather than fractions of a cent, so a careless batch is the fastest way to spend real money. Always test a prompt template on one image, confirm it looks right, and only then loop it across your full list. For platform-specific work like ",[51,25224,11063],{"href":9414}," or running a whole catalog through it with ",[51,25227,25228],{"href":12621},"Batch-Generate Product Images with DALL-E and Python",", see the ",[51,25231,9410],{"href":9409}," section.",[57,25234,25236],{"id":25235},"schedule-and-publish-automatically","Schedule and publish automatically",[14,25238,25239,25240,25243,25244,25247],{},"The third job is timing. Drafting and image-making happen on your schedule, but posts should go live when your audience is online. The clean pattern is to separate ",[27,25241,25242],{},"what"," to post from ",[27,25245,25246],{},"when"," to post it: write your content into a queue (a simple file or a list with timestamps), then run a small loop that checks the clock and publishes anything that is due.",[14,25249,25250],{},"This separation is worth pausing on, because it is the design decision that keeps automation from becoming a headache. If your script generated and posted in one breath, you would have to run it at the exact moment you wanted each post live, which defeats the purpose. By writing finished posts into a queue with a scheduled time, you can prepare a week of content in one sitting and let a tiny, dumb publisher do nothing but check the clock and send. The queue is also your safety net: you can open the file, read every pending post, and delete or edit anything before it goes out. Nothing publishes that you have not had the chance to see.",[14,25252,25253],{},"One firm rule for this station: always use each platform's official API and respect its rate limits, the cap on how many requests you may send in a window. Scraping the website or sending posts in a rapid burst is the quickest way to get an account flagged or blocked. Spacing requests out with a short pause, and keeping a human in the loop, keeps your automation on the right side of the rules.",[14,25255,25256,25257,25259],{},"The example below uses a JSON file as the queue. Each entry has a caption, an image path, and a ",[18,25258,17131],{}," time. A real publisher would call a platform API at the marked step; the structure stays the same whether you post to Instagram, X, or LinkedIn.",[253,25261,25263],{"className":414,"code":25262,"language":416,"meta":258,"style":258},"import json\nfrom datetime import datetime, timezone\n\n\ndef load_queue(path: str = \"queue.json\") -> list[dict]:\n    with open(path) as f:\n        return json.load(f)\n\n\ndef publish_due_posts(path: str = \"queue.json\") -> None:\n    \"\"\"Publish every queued post whose time has arrived.\"\"\"\n    queue = load_queue(path)\n    now = datetime.now(timezone.utc)\n    for post in queue:\n        due = datetime.fromisoformat(post[\"publish_at\"])\n        if not post.get(\"published\") and due \u003C= now:\n            # Replace this print with a real platform API call.\n            print(f\"Publishing: {post['caption']}  (image: {post['image']})\")\n            post[\"published\"] = True\n    with open(path, \"w\") as f:\n        json.dump(queue, f, indent=2)\n\n\nif __name__ == \"__main__\":\n    publish_due_posts()\n",[18,25264,25265,25271,25281,25285,25289,25311,25324,25331,25335,25339,25360,25365,25375,25384,25395,25409,25432,25437,25478,25492,25508,25521,25525,25529,25541],{"__ignoreMap":258},[262,25266,25267,25269],{"class":181,"line":264},[262,25268,684],{"class":377},[262,25270,5766],{"class":429},[262,25272,25273,25275,25277,25279],{"class":181,"line":282},[262,25274,705],{"class":377},[262,25276,10502],{"class":429},[262,25278,684],{"class":377},[262,25280,10507],{"class":429},[262,25282,25283],{"class":181,"line":295},[262,25284,583],{"emptyLinePlaceholder":582},[262,25286,25287],{"class":181,"line":345},[262,25288,583],{"emptyLinePlaceholder":582},[262,25290,25291,25293,25296,25298,25300,25302,25305,25307,25309],{"class":181,"line":492},[262,25292,423],{"class":377},[262,25294,25295],{"class":267}," load_queue",[262,25297,15950],{"class":429},[262,25299,433],{"class":271},[262,25301,442],{"class":377},[262,25303,25304],{"class":275}," \"queue.json\"",[262,25306,458],{"class":429},[262,25308,5869],{"class":271},[262,25310,463],{"class":429},[262,25312,25313,25315,25317,25320,25322],{"class":181,"line":503},[262,25314,10124],{"class":377},[262,25316,599],{"class":271},[262,25318,25319],{"class":429},"(path) ",[262,25321,697],{"class":377},[262,25323,9190],{"class":429},[262,25325,25326,25328],{"class":181,"line":521},[262,25327,8066],{"class":377},[262,25329,25330],{"class":429}," json.load(f)\n",[262,25332,25333],{"class":181,"line":537},[262,25334,583],{"emptyLinePlaceholder":582},[262,25336,25337],{"class":181,"line":549},[262,25338,583],{"emptyLinePlaceholder":582},[262,25340,25341,25343,25346,25348,25350,25352,25354,25356,25358],{"class":181,"line":570},[262,25342,423],{"class":377},[262,25344,25345],{"class":267}," publish_due_posts",[262,25347,15950],{"class":429},[262,25349,433],{"class":271},[262,25351,442],{"class":377},[262,25353,25304],{"class":275},[262,25355,1939],{"class":429},[262,25357,8471],{"class":271},[262,25359,1160],{"class":429},[262,25361,25362],{"class":181,"line":579},[262,25363,25364],{"class":275},"    \"\"\"Publish every queued post whose time has arrived.\"\"\"\n",[262,25366,25367,25370,25372],{"class":181,"line":586},[262,25368,25369],{"class":429},"    queue ",[262,25371,476],{"class":377},[262,25373,25374],{"class":429}," load_queue(path)\n",[262,25376,25377,25379,25381],{"class":181,"line":591},[262,25378,17280],{"class":429},[262,25380,476],{"class":377},[262,25382,25383],{"class":429}," datetime.now(timezone.utc)\n",[262,25385,25386,25388,25390,25392],{"class":181,"line":623},[262,25387,3074],{"class":377},[262,25389,16668],{"class":429},[262,25391,835],{"class":377},[262,25393,25394],{"class":429}," queue:\n",[262,25396,25397,25400,25402,25405,25407],{"class":181,"line":634},[262,25398,25399],{"class":429},"        due ",[262,25401,476],{"class":377},[262,25403,25404],{"class":429}," datetime.fromisoformat(post[",[262,25406,16089],{"class":275},[262,25408,3512],{"class":429},[262,25410,25411,25413,25415,25418,25421,25423,25425,25428,25430],{"class":181,"line":845},[262,25412,2268],{"class":377},[262,25414,2818],{"class":377},[262,25416,25417],{"class":429}," post.get(",[262,25419,25420],{"class":275},"\"published\"",[262,25422,1000],{"class":429},[262,25424,6101],{"class":377},[262,25426,25427],{"class":429}," due ",[262,25429,8983],{"class":377},[262,25431,17307],{"class":429},[262,25433,25434],{"class":181,"line":850},[262,25435,25436],{"class":291},"            # Replace this print with a real platform API call.\n",[262,25438,25439,25441,25443,25445,25448,25450,25453,25455,25457,25459,25462,25464,25466,25469,25471,25473,25476],{"class":181,"line":864},[262,25440,3250],{"class":271},[262,25442,602],{"class":429},[262,25444,642],{"class":377},[262,25446,25447],{"class":275},"\"Publishing: ",[262,25449,3039],{"class":271},[262,25451,25452],{"class":429},"post[",[262,25454,22154],{"class":275},[262,25456,6223],{"class":429},[262,25458,654],{"class":271},[262,25460,25461],{"class":275},"  (image: ",[262,25463,3039],{"class":271},[262,25465,25452],{"class":429},[262,25467,25468],{"class":275},"'image'",[262,25470,6223],{"class":429},[262,25472,654],{"class":271},[262,25474,25475],{"class":275},")\"",[262,25477,660],{"class":429},[262,25479,25480,25483,25485,25487,25489],{"class":181,"line":1683},[262,25481,25482],{"class":429},"            post[",[262,25484,25420],{"class":275},[262,25486,2903],{"class":429},[262,25488,476],{"class":377},[262,25490,25491],{"class":271}," True\n",[262,25493,25494,25496,25498,25500,25502,25504,25506],{"class":181,"line":1688},[262,25495,10124],{"class":377},[262,25497,599],{"class":271},[262,25499,14308],{"class":429},[262,25501,9165],{"class":275},[262,25503,1000],{"class":429},[262,25505,697],{"class":377},[262,25507,9190],{"class":429},[262,25509,25510,25513,25515,25517,25519],{"class":181,"line":1693},[262,25511,25512],{"class":429},"        json.dump(queue, f, ",[262,25514,5980],{"class":611},[262,25516,476],{"class":377},[262,25518,109],{"class":271},[262,25520,660],{"class":429},[262,25522,25523],{"class":181,"line":1728},[262,25524,583],{"emptyLinePlaceholder":582},[262,25526,25527],{"class":181,"line":1737},[262,25528,583],{"emptyLinePlaceholder":582},[262,25530,25531,25533,25535,25537,25539],{"class":181,"line":1751},[262,25532,2210],{"class":377},[262,25534,2213],{"class":271},[262,25536,2216],{"class":377},[262,25538,2219],{"class":275},[262,25540,1160],{"class":429},[262,25542,25543],{"class":181,"line":1764},[262,25544,25545],{"class":429},"    publish_due_posts()\n",[14,25547,25548,25549,25551,25552,25554,25555,25557,25558,1374,25560,1363],{},"Schedule this script to run every few minutes with a system tool like ",[18,25550,21212],{}," on Mac\u002FLinux or Task Scheduler on Windows, and your queue empties itself at the right times. The ",[51,25553,21230],{"href":21229}," guide covers how to set those timers up. For real platform connections, including rate-limit-safe patterns, see ",[51,25556,9309],{"href":9308}," and specific walkthroughs like ",[51,25559,15735],{"href":15734},[51,25561,15717],{"href":19701},[57,25563,25565],{"id":25564},"measure-what-works-with-data","Measure what works with data",[14,25567,25568],{},"The fourth job closes the loop. Without numbers, you are guessing which content earned attention. Python is excellent at pulling engagement data, usually a CSV export or an API response, and summarizing it so the next round of content learns from the last. A CSV is just a spreadsheet saved as plain text, the format every platform offers when you export your analytics, and Python reads it without any special tools.",[14,25570,25571],{},"The metric to focus on is engagement rate, likes divided by impressions, rather than raw likes. Raw likes reward posts that simply reached more people; engagement rate tells you which content people actually responded to relative to how many saw it. That distinction is what lets a small account learn as fast as a large one. Once you can rank your posts by engagement rate, the strategy writes itself: do more of what scored high, drop what scored low, and feed your top performers back into the system message from the copywriting station as concrete examples of what works for your audience.",[14,25573,25574,25575,25577],{},"The example below reads a CSV of past posts with their likes and impressions, calculates an engagement rate, and ranks them. No extra libraries are needed; Python's built-in ",[18,25576,9502],{}," module handles the file.",[253,25579,25581],{"className":414,"code":25580,"language":416,"meta":258,"style":258},"import csv\n\n\ndef top_posts(path: str = \"results.csv\", top_n: int = 5) -> list[dict]:\n    \"\"\"Rank posts by engagement rate (likes \u002F impressions).\"\"\"\n    rows = []\n    with open(path, newline=\"\") as f:\n        for row in csv.DictReader(f):\n            impressions = int(row[\"impressions\"]) or 1  # avoid divide-by-zero\n            row[\"rate\"] = round(int(row[\"likes\"]) \u002F impressions, 4)\n            rows.append(row)\n    rows.sort(key=lambda r: r[\"rate\"], reverse=True)\n    return rows[:top_n]\n\n\nif __name__ == \"__main__\":\n    for post in top_posts():\n        print(f\"{post['rate']:.2%}  {post['caption']}\")\n",[18,25582,25583,25589,25593,25597,25628,25633,25642,25662,25672,25695,25730,25735,25762,25769,25773,25777,25789,25800],{"__ignoreMap":258},[262,25584,25585,25587],{"class":181,"line":264},[262,25586,684],{"class":377},[262,25588,8533],{"class":429},[262,25590,25591],{"class":181,"line":282},[262,25592,583],{"emptyLinePlaceholder":582},[262,25594,25595],{"class":181,"line":295},[262,25596,583],{"emptyLinePlaceholder":582},[262,25598,25599,25601,25604,25606,25608,25610,25613,25616,25618,25620,25622,25624,25626],{"class":181,"line":345},[262,25600,423],{"class":377},[262,25602,25603],{"class":267}," top_posts",[262,25605,15950],{"class":429},[262,25607,433],{"class":271},[262,25609,442],{"class":377},[262,25611,25612],{"class":275}," \"results.csv\"",[262,25614,25615],{"class":429},", top_n: ",[262,25617,439],{"class":271},[262,25619,442],{"class":377},[262,25621,9638],{"class":271},[262,25623,458],{"class":429},[262,25625,5869],{"class":271},[262,25627,463],{"class":429},[262,25629,25630],{"class":181,"line":492},[262,25631,25632],{"class":275},"    \"\"\"Rank posts by engagement rate (likes \u002F impressions).\"\"\"\n",[262,25634,25635,25638,25640],{"class":181,"line":503},[262,25636,25637],{"class":429},"    rows ",[262,25639,476],{"class":377},[262,25641,489],{"class":429},[262,25643,25644,25646,25648,25650,25652,25654,25656,25658,25660],{"class":181,"line":521},[262,25645,10124],{"class":377},[262,25647,599],{"class":271},[262,25649,14308],{"class":429},[262,25651,9170],{"class":611},[262,25653,476],{"class":377},[262,25655,9175],{"class":275},[262,25657,1000],{"class":429},[262,25659,697],{"class":377},[262,25661,9190],{"class":429},[262,25663,25664,25666,25668,25670],{"class":181,"line":537},[262,25665,10155],{"class":377},[262,25667,10158],{"class":429},[262,25669,835],{"class":377},[262,25671,12234],{"class":429},[262,25673,25674,25677,25679,25681,25683,25686,25688,25690,25692],{"class":181,"line":549},[262,25675,25676],{"class":429},"            impressions ",[262,25678,476],{"class":377},[262,25680,23813],{"class":271},[262,25682,3460],{"class":429},[262,25684,25685],{"class":275},"\"impressions\"",[262,25687,15287],{"class":429},[262,25689,8923],{"class":377},[262,25691,3243],{"class":271},[262,25693,25694],{"class":291},"  # avoid divide-by-zero\n",[262,25696,25697,25700,25703,25705,25707,25710,25712,25714,25716,25719,25721,25723,25726,25728],{"class":181,"line":570},[262,25698,25699],{"class":429},"            row[",[262,25701,25702],{"class":275},"\"rate\"",[262,25704,2903],{"class":429},[262,25706,476],{"class":377},[262,25708,25709],{"class":271}," round",[262,25711,602],{"class":429},[262,25713,439],{"class":271},[262,25715,3460],{"class":429},[262,25717,25718],{"class":275},"\"likes\"",[262,25720,15287],{"class":429},[262,25722,981],{"class":377},[262,25724,25725],{"class":429}," impressions, ",[262,25727,19848],{"class":271},[262,25729,660],{"class":429},[262,25731,25732],{"class":181,"line":579},[262,25733,25734],{"class":429},"            rows.append(row)\n",[262,25736,25737,25740,25743,25746,25749,25751,25753,25756,25758,25760],{"class":181,"line":586},[262,25738,25739],{"class":429},"    rows.sort(",[262,25741,25742],{"class":611},"key",[262,25744,25745],{"class":377},"=lambda",[262,25747,25748],{"class":429}," r: r[",[262,25750,25702],{"class":275},[262,25752,1103],{"class":429},[262,25754,25755],{"class":611},"reverse",[262,25757,476],{"class":377},[262,25759,4974],{"class":271},[262,25761,660],{"class":429},[262,25763,25764,25766],{"class":181,"line":591},[262,25765,573],{"class":377},[262,25767,25768],{"class":429}," rows[:top_n]\n",[262,25770,25771],{"class":181,"line":623},[262,25772,583],{"emptyLinePlaceholder":582},[262,25774,25775],{"class":181,"line":634},[262,25776,583],{"emptyLinePlaceholder":582},[262,25778,25779,25781,25783,25785,25787],{"class":181,"line":845},[262,25780,2210],{"class":377},[262,25782,2213],{"class":271},[262,25784,2216],{"class":377},[262,25786,2219],{"class":275},[262,25788,1160],{"class":429},[262,25790,25791,25793,25795,25797],{"class":181,"line":850},[262,25792,3074],{"class":377},[262,25794,16668],{"class":429},[262,25796,835],{"class":377},[262,25798,25799],{"class":429}," top_posts():\n",[262,25801,25802,25804,25806,25808,25810,25812,25814,25817,25819,25822,25824,25827,25829,25831,25833,25835,25837],{"class":181,"line":864},[262,25803,2299],{"class":271},[262,25805,602],{"class":429},[262,25807,642],{"class":377},[262,25809,1176],{"class":275},[262,25811,3039],{"class":271},[262,25813,25452],{"class":429},[262,25815,25816],{"class":275},"'rate'",[262,25818,6223],{"class":429},[262,25820,25821],{"class":377},":.2%",[262,25823,654],{"class":271},[262,25825,25826],{"class":271},"  {",[262,25828,25452],{"class":429},[262,25830,22154],{"class":275},[262,25832,6223],{"class":429},[262,25834,654],{"class":271},[262,25836,1176],{"class":275},[262,25838,660],{"class":429},[14,25840,25841,25842,25844,25845,25847,25848,1363],{},"Feed your winners back into the copywriting step as examples of what works. For richer analysis, the ",[51,25843,2919],{"href":2918}," guide shows how to handle messy exports, and the ",[51,25846,9304],{"href":9303}," section turns the same data muscles toward finding new topics, including a ",[51,25849,25851],{"href":25850},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fpython-script-for-competitor-keyword-analysis\u002F","Python Script for Competitor Keyword Analysis",[57,25853,25855],{"id":25854},"mini-project-idea-to-scheduled-post-in-one-script","Mini-project: idea to scheduled post in one script",[14,25857,25858],{},"Now combine all four jobs. This script takes a single idea, drafts a caption, generates a matching image, and adds the finished post to your publishing queue with a scheduled time. It reuses the patterns above and stays under thirty lines of logic. Read it as proof that the whole pipeline is small: three labelled steps, each one a few lines, with the output of the first two flowing into the third.",[253,25860,25862],{"className":414,"code":25861,"language":416,"meta":258,"style":258},"import json\nimport httpx\nfrom datetime import datetime, timezone, timedelta\nfrom openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\n\ndef build_post(idea: str, minutes_from_now: int = 60) -> dict:\n    # 1. Draft the caption.\n    caption = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\"role\": \"system\", \"content\": \"You write short, warm social captions. No emojis.\"},\n            {\"role\": \"user\", \"content\": idea},\n        ],\n    ).choices[0].message.content.strip()\n\n    # 2. Generate a matching image and save it.\n    url = client.images.generate(model=\"dall-e-3\", prompt=idea, size=\"1024x1024\").data[0].url\n    image_path = \"post_image.png\"\n    with open(image_path, \"wb\") as f:\n        f.write(httpx.get(url, timeout=60).content)\n\n    # 3. Queue it for publishing later.\n    publish_at = datetime.now(timezone.utc) + timedelta(minutes=minutes_from_now)\n    return {\"caption\": caption, \"image\": image_path, \"publish_at\": publish_at.isoformat(), \"published\": False}\n\n\nif __name__ == \"__main__\":\n    post = build_post(\"Announce our new oat-milk latte for autumn\")\n    with open(\"queue.json\", \"w\") as f:\n        json.dump([post], f, indent=2)\n    print(f\"Queued: {post['caption']}\")\n",[18,25863,25864,25870,25876,25887,25897,25901,25911,25915,25919,25946,25951,25959,25969,25977,25998,26015,26019,26028,26032,26037,26075,26085,26102,26116,26120,26125,26147,26176,26180,26184,26196,26210,26231,26244],{"__ignoreMap":258},[262,25865,25866,25868],{"class":181,"line":264},[262,25867,684],{"class":377},[262,25869,5766],{"class":429},[262,25871,25872,25874],{"class":181,"line":282},[262,25873,684],{"class":377},[262,25875,6526],{"class":429},[262,25877,25878,25880,25882,25884],{"class":181,"line":295},[262,25879,705],{"class":377},[262,25881,10502],{"class":429},[262,25883,684],{"class":377},[262,25885,25886],{"class":429}," datetime, timezone, timedelta\n",[262,25888,25889,25891,25893,25895],{"class":181,"line":345},[262,25890,705],{"class":377},[262,25892,720],{"class":429},[262,25894,684],{"class":377},[262,25896,725],{"class":429},[262,25898,25899],{"class":181,"line":492},[262,25900,583],{"emptyLinePlaceholder":582},[262,25902,25903,25905,25907,25909],{"class":181,"line":503},[262,25904,739],{"class":429},[262,25906,476],{"class":377},[262,25908,9578],{"class":429},[262,25910,9581],{"class":291},[262,25912,25913],{"class":181,"line":521},[262,25914,583],{"emptyLinePlaceholder":582},[262,25916,25917],{"class":181,"line":537},[262,25918,583],{"emptyLinePlaceholder":582},[262,25920,25921,25923,25926,25929,25931,25934,25936,25938,25940,25942,25944],{"class":181,"line":549},[262,25922,423],{"class":377},[262,25924,25925],{"class":267}," build_post",[262,25927,25928],{"class":429},"(idea: ",[262,25930,433],{"class":271},[262,25932,25933],{"class":429},", minutes_from_now: ",[262,25935,439],{"class":271},[262,25937,442],{"class":377},[262,25939,1710],{"class":271},[262,25941,1939],{"class":429},[262,25943,5869],{"class":271},[262,25945,1160],{"class":429},[262,25947,25948],{"class":181,"line":570},[262,25949,25950],{"class":291},"    # 1. Draft the caption.\n",[262,25952,25953,25955,25957],{"class":181,"line":579},[262,25954,20687],{"class":429},[262,25956,476],{"class":377},[262,25958,1189],{"class":429},[262,25960,25961,25963,25965,25967],{"class":181,"line":586},[262,25962,1194],{"class":611},[262,25964,476],{"class":377},[262,25966,1207],{"class":275},[262,25968,1315],{"class":429},[262,25970,25971,25973,25975],{"class":181,"line":591},[262,25972,1215],{"class":611},[262,25974,476],{"class":377},[262,25976,1220],{"class":429},[262,25978,25979,25981,25983,25985,25987,25989,25991,25993,25996],{"class":181,"line":623},[262,25980,1225],{"class":429},[262,25982,1228],{"class":275},[262,25984,1231],{"class":429},[262,25986,1234],{"class":275},[262,25988,608],{"class":429},[262,25990,1239],{"class":275},[262,25992,1231],{"class":429},[262,25994,25995],{"class":275},"\"You write short, warm social captions. No emojis.\"",[262,25997,3143],{"class":429},[262,25999,26000,26002,26004,26006,26008,26010,26012],{"class":181,"line":634},[262,26001,1225],{"class":429},[262,26003,1228],{"class":275},[262,26005,1231],{"class":429},[262,26007,1291],{"class":275},[262,26009,608],{"class":429},[262,26011,1239],{"class":275},[262,26013,26014],{"class":429},": idea},\n",[262,26016,26017],{"class":181,"line":845},[262,26018,1303],{"class":429},[262,26020,26021,26024,26026],{"class":181,"line":850},[262,26022,26023],{"class":429},"    ).choices[",[262,26025,102],{"class":271},[262,26027,3205],{"class":429},[262,26029,26030],{"class":181,"line":864},[262,26031,583],{"emptyLinePlaceholder":582},[262,26033,26034],{"class":181,"line":1683},[262,26035,26036],{"class":291},"    # 2. Generate a matching image and save it.\n",[262,26038,26039,26042,26044,26047,26049,26051,26053,26055,26057,26059,26062,26064,26066,26068,26071,26073],{"class":181,"line":1688},[262,26040,26041],{"class":429},"    url ",[262,26043,476],{"class":377},[262,26045,26046],{"class":429}," client.images.generate(",[262,26048,805],{"class":611},[262,26050,476],{"class":377},[262,26052,9686],{"class":275},[262,26054,608],{"class":429},[262,26056,9496],{"class":611},[262,26058,476],{"class":377},[262,26060,26061],{"class":429},"idea, ",[262,26063,10260],{"class":611},[262,26065,476],{"class":377},[262,26067,11363],{"class":275},[262,26069,26070],{"class":429},").data[",[262,26072,102],{"class":271},[262,26074,9763],{"class":429},[262,26076,26077,26080,26082],{"class":181,"line":1693},[262,26078,26079],{"class":429},"    image_path ",[262,26081,476],{"class":377},[262,26083,26084],{"class":275}," \"post_image.png\"\n",[262,26086,26087,26089,26091,26094,26096,26098,26100],{"class":181,"line":1728},[262,26088,10124],{"class":377},[262,26090,599],{"class":271},[262,26092,26093],{"class":429},"(image_path, ",[262,26095,13243],{"class":275},[262,26097,1000],{"class":429},[262,26099,697],{"class":377},[262,26101,9190],{"class":429},[262,26103,26104,26107,26109,26111,26113],{"class":181,"line":1737},[262,26105,26106],{"class":429},"        f.write(httpx.get(url, ",[262,26108,1591],{"class":611},[262,26110,476],{"class":377},[262,26112,12826],{"class":271},[262,26114,26115],{"class":429},").content)\n",[262,26117,26118],{"class":181,"line":1751},[262,26119,583],{"emptyLinePlaceholder":582},[262,26121,26122],{"class":181,"line":1764},[262,26123,26124],{"class":291},"    # 3. Queue it for publishing later.\n",[262,26126,26127,26130,26132,26135,26137,26139,26142,26144],{"class":181,"line":1779},[262,26128,26129],{"class":429},"    publish_at ",[262,26131,476],{"class":377},[262,26133,26134],{"class":429}," datetime.now(timezone.utc) ",[262,26136,531],{"class":377},[262,26138,22420],{"class":429},[262,26140,26141],{"class":611},"minutes",[262,26143,476],{"class":377},[262,26145,26146],{"class":429},"minutes_from_now)\n",[262,26148,26149,26151,26153,26155,26157,26160,26163,26165,26168,26170,26172,26174],{"class":181,"line":1793},[262,26150,573],{"class":377},[262,26152,2276],{"class":429},[262,26154,20348],{"class":275},[262,26156,23554],{"class":429},[262,26158,26159],{"class":275},"\"image\"",[262,26161,26162],{"class":429},": image_path, ",[262,26164,16089],{"class":275},[262,26166,26167],{"class":429},": publish_at.isoformat(), ",[262,26169,25420],{"class":275},[262,26171,1231],{"class":429},[262,26173,3623],{"class":271},[262,26175,16430],{"class":429},[262,26177,26178],{"class":181,"line":1800},[262,26179,583],{"emptyLinePlaceholder":582},[262,26181,26182],{"class":181,"line":1805},[262,26183,583],{"emptyLinePlaceholder":582},[262,26185,26186,26188,26190,26192,26194],{"class":181,"line":1810},[262,26187,2210],{"class":377},[262,26189,2213],{"class":271},[262,26191,2216],{"class":377},[262,26193,2219],{"class":275},[262,26195,1160],{"class":429},[262,26197,26198,26200,26202,26205,26208],{"class":181,"line":1823},[262,26199,22140],{"class":429},[262,26201,476],{"class":377},[262,26203,26204],{"class":429}," build_post(",[262,26206,26207],{"class":275},"\"Announce our new oat-milk latte for autumn\"",[262,26209,660],{"class":429},[262,26211,26212,26214,26216,26218,26221,26223,26225,26227,26229],{"class":181,"line":1846},[262,26213,10124],{"class":377},[262,26215,599],{"class":271},[262,26217,602],{"class":429},[262,26219,26220],{"class":275},"\"queue.json\"",[262,26222,608],{"class":429},[262,26224,9165],{"class":275},[262,26226,1000],{"class":429},[262,26228,697],{"class":377},[262,26230,9190],{"class":429},[262,26232,26233,26236,26238,26240,26242],{"class":181,"line":1861},[262,26234,26235],{"class":429},"        json.dump([post], f, ",[262,26237,5980],{"class":611},[262,26239,476],{"class":377},[262,26241,109],{"class":271},[262,26243,660],{"class":429},[262,26245,26246,26248,26250,26252,26255,26257,26259,26261,26263,26265,26267],{"class":181,"line":1866},[262,26247,1089],{"class":271},[262,26249,602],{"class":429},[262,26251,642],{"class":377},[262,26253,26254],{"class":275},"\"Queued: ",[262,26256,3039],{"class":271},[262,26258,25452],{"class":429},[262,26260,22154],{"class":275},[262,26262,6223],{"class":429},[262,26264,654],{"class":271},[262,26266,1176],{"class":275},[262,26268,660],{"class":429},[14,26270,13310,26271,26274,26275,26278],{},[18,26272,26273],{},"python build_post.py",". You get a caption, an image file, and a ",[18,26276,26277],{},"queue.json"," entry timed an hour out, exactly the file the scheduler from the previous section reads. That is a complete content pipeline in one short script, and every piece is something you can extend independently.",[14,26280,26281,26282,26285,26286,26289,26290,26292],{},"From here, growth is a matter of swapping parts rather than rewriting. Want ten posts instead of one? Wrap ",[18,26283,26284],{},"build_post"," in a loop over a list of ideas. Want them spread across the week? Increase ",[18,26287,26288],{},"minutes_from_now"," for each. Want to post to a real platform? Replace the ",[18,26291,637],{}," in the scheduler with that platform's API call. Want the ideas themselves to come from data? Feed in topics from your keyword research instead of typing them. Each station has a clear input and a clear output, which is what makes the line easy to extend without it collapsing. That modularity is the whole point: you build small, reliable pieces once and recombine them forever.",[57,26294,26296],{"id":26295},"common-mistakes-and-how-to-fix-them","Common mistakes and how to fix them",[1379,26298,26299,26309],{},[1382,26300,26301],{},[1385,26302,26303,26306],{},[1388,26304,26305],{},"Mistake",[1388,26307,26308],{},"Fix",[1398,26310,26311,26330,26338,26346,26354,26365,26378],{},[1385,26312,26313,26316],{},[1403,26314,26315],{},"Hardcoding your API key in the script",[1403,26317,26318,26319,26321,26322,26324,26325,3921,26327,26329],{},"Store it in a ",[18,26320,319],{}," file and load it with ",[18,26323,2501],{},"; add ",[18,26326,319],{},[18,26328,359],{}," so it is never shared.",[1385,26331,26332,26335],{},[1403,26333,26334],{},"Publishing AI drafts without editing",[1403,26336,26337],{},"Always read and fact-check generated copy before it goes live; the model can sound confident while being wrong.",[1385,26339,26340,26343],{},[1403,26341,26342],{},"Running an image batch with no spending cap",[1403,26344,26345],{},"Set a monthly hard limit in your provider dashboard before looping over a large list.",[1385,26347,26348,26351],{},[1403,26349,26350],{},"Posting to a platform with no delay between calls",[1403,26352,26353],{},"Add a short pause between API requests to stay under rate limits and avoid temporary blocks.",[1385,26355,26356,26362],{},[1403,26357,26358,26359,26361],{},"Letting ",[18,26360,3829],{}," sit too high for factual copy",[1403,26363,26364],{},"Lower it toward 0.2-0.4 for product details and specs; reserve higher values for brainstorming.",[1385,26366,26367,26370],{},[1403,26368,26369],{},"Saving images straight from the URL without a timeout",[1403,26371,26372,26373,3921,26375,26377],{},"Pass a ",[18,26374,1591],{},[18,26376,10927],{}," so a slow response fails fast instead of hanging your script.",[1385,26379,26380,26383],{},[1403,26381,26382],{},"Measuring nothing, so every post is a guess",[1403,26384,26385],{},"Export engagement to a CSV and rank it in Python; feed the winners back into your prompts.",[57,26387,2355],{"id":2354},[14,26389,26390],{},"Work through these in order to go from the examples here to a workflow you run every week:",[1447,26392,26393,26401,26407,26415,26420,26428],{},[1450,26394,26395,26396,26398,26399,1363],{},"Get your environment solid by finishing ",[51,26397,5423],{"href":5422},", then create a reusable project with ",[51,26400,2482],{"href":2481},[1450,26402,26403,26404,26406],{},"Learn how requests and keys actually work in ",[51,26405,2487],{"href":2486}," so errors stop being mysterious.",[1450,26408,26409,26410,26412,26413,1363],{},"Master drafting in ",[51,26411,3991],{"href":3990},", starting with ",[51,26414,4011],{"href":4010},[1450,26416,26417,26418,1363],{},"Add visuals through ",[51,26419,9410],{"href":9409},[1450,26421,26422,26423,26425,26426,1363],{},"Put it on a timetable with ",[51,26424,9309],{"href":9308},", then expand reach using ",[51,26427,17687],{"href":17686},[1450,26429,26430,26431,26433,26434,1363],{},"Find your next topics with ",[51,26432,9304],{"href":9303}," and ship pages faster by learning to ",[51,26435,26437],{"href":26436},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgenerate-meta-descriptions-in-bulk-with-python\u002F","Generate Meta Descriptions in Bulk with Python",[57,26439,2381],{"id":2380},[14,26441,26442],{},"This hub is one of three on the site. Once your content engine runs, the other two help you build the skills underneath it and turn it into a product.",[2322,26444,26445,26452,26459,26464],{},[1450,26446,26447,26451],{},[51,26448,26450],{"href":26449},"\u002Fpython-ai-fundamentals-for-non-developers\u002F","Python AI Fundamentals for Non-Developers"," — the groundwork: installing Python, calling APIs, and basic automation.",[1450,26453,26454,26458],{},[51,26455,26457],{"href":26456},"\u002Fbuilding-ai-powered-business-applications\u002F","Building AI-Powered Business Applications"," — go beyond content into chatbots, CRM integrations, and full apps.",[1450,26460,26461,26463],{},[51,26462,7554],{"href":7553}," — get sharper, more reliable output from every model you call.",[1450,26465,26466,26468],{},[51,26467,54],{"href":53}," — package your content know-how into a conversational tool.",[14,26470,2375,26471,26473],{},[51,26472,26450],{"href":26449}," if you still need the setup basics before automating your marketing.",[2401,26475,26476],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":26478},[26479,26480,26481,26482,26483,26484,26485,26486,26487,26488],{"id":24431,"depth":282,"text":24432},{"id":237,"depth":282,"text":238},{"id":24597,"depth":282,"text":24598},{"id":24904,"depth":282,"text":24905},{"id":25235,"depth":282,"text":25236},{"id":25564,"depth":282,"text":25565},{"id":25854,"depth":282,"text":25855},{"id":26295,"depth":282,"text":26296},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Automate content creation and marketing with Python and AI: copywriting, DALL-E images, social scheduling, and SEO keyword research, with runnable code.",[26491,26494,26497,26500,26503],{"q":26492,"a":26493},"Do I need to know how to code to automate marketing with Python?","No. You need to copy, edit, and run short scripts, not write them from scratch. If you can change a few words in a file and run one command in a terminal, you can use everything on this site. Start with the setup guide, then work through the examples here one at a time.",{"q":26495,"a":26496},"How much does it cost to run these AI content tools?","Most text tasks cost fractions of a cent per request, and a single AI image costs a few cents. A small marketer running drafts, captions, and a handful of images each day usually spends a few dollars a month. You can set hard spending limits in your provider dashboard so a runaway script never surprises you.",{"q":26498,"a":26499},"Which AI model should I use for writing marketing copy?","A current general-purpose model such as gpt-4o-mini is fast and cheap enough for captions, subject lines, and first drafts, while a larger model is worth the extra cost for long-form articles you will publish. Test both on your own brand voice and pick the cheapest one that meets your quality bar.",{"q":26501,"a":26502},"Is AI-generated content bad for SEO?","Search engines reward helpful, accurate content regardless of how it was drafted, and they penalize thin, mass-produced pages. Use AI to draft and research faster, then edit every piece for accuracy and add your own expertise before publishing. Automated keyword research and meta descriptions are low-risk wins.",{"q":26504,"a":26505},"Can I schedule social posts automatically without breaking platform rules?","Yes, if you use each platform's official API and respect its rate limits and content policies. Avoid scraping or fake engagement, space your requests out, and keep a human reviewing what goes live. The scheduling guides here show compliant patterns with built-in delays.",{"name":26507,"steps":26508},"How to build an AI content and marketing automation workflow with Python",[26509,26512,26515,26517,26520],{"name":26510,"text":26511},"Set up Python and your API keys","Install Python, create a virtual environment, and store your OpenAI key in a .env file.",{"name":26513,"text":26514},"Generate text with the OpenAI API","Call a chat model to turn a short brief into a usable marketing draft.",{"name":24905,"text":26516},"Send a prompt to the image endpoint and save the returned picture to disk.",{"name":26518,"text":26519},"Schedule and publish the output","Queue your drafts and images to post at set times through a platform API.",{"name":26521,"text":26522},"Measure results with data","Pull engagement numbers into Python to see which content performed best.",{},"\u002Fai-content-creation-marketing-automation","2026-05-01",{"title":24306,"description":26489},"AI Content & Marketing Automation","ai-content-creation-marketing-automation\u002Findex","8HsfXEKzUb2rdxB1V1iUGVA-bFtwhfJEtjp-ggZgSos",{"id":26531,"title":26437,"body":26532,"description":28923,"extension":2419,"faq":28924,"howto":28940,"meta":28955,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":28956,"published":2452,"seo":28957,"seoTitle":26437,"stem":28958,"__hash__":28959},"content\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgenerate-meta-descriptions-in-bulk-with-python\u002Findex.md",{"type":7,"value":26533,"toc":28911},[26534,26537,26544,26553,26555,26563,26566,26602,26617,26623,26627,26633,26641,26648,26660,26664,26670,26861,26879,26883,26886,26889,27352,27363,27367,27370,27717,27724,27726,27828,27832,27845,28763,28784,28786,28851,28853,28872,28883,28885,28909],[10,26535,26437],{"id":26536},"generate-meta-descriptions-in-bulk-with-python",[14,26538,26539,26540,26543],{},"This guide shows you how to turn a CSV of pages into a CSV of polished, length-checked meta descriptions in under fifteen minutes. A ",[27,26541,26542],{},"meta description"," is the short snippet of text that appears under your page title in Google search results; it does not directly change rankings, but a sharp one lifts your click-through rate, and writing hundreds of them by hand is the kind of chore Python was built to delete.",[14,26545,26546,26547,26549,26550,26552],{},"You will read a CSV of URLs, titles, and target keywords, send each row to an AI model that writes a description, enforce a hard 155-character limit, remove duplicates, and export a clean file you can paste straight into your content management system. Everything here runs on Python 3.10 or newer with two libraries: ",[18,26548,2494],{}," for the data and the official ",[18,26551,20],{}," SDK for the writing.",[57,26554,238],{"id":237},[14,26556,26557,26558,26560,26561,1363],{},"This guide assumes you already have Python installed and know how to run a script from your terminal. If you do not, start with ",[51,26559,2482],{"href":2481}," and come back here. You will also need an OpenAI API key; if calls fail with an auth error later, see ",[51,26562,388],{"href":387},[14,26564,26565],{},"Create and activate a virtual environment, then install the two libraries you need:",[253,26567,26569],{"className":255,"code":26568,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate  # Windows: .venv\\Scripts\\activate\npip install pandas openai python-dotenv\n",[18,26570,26571,26581,26590],{"__ignoreMap":258},[262,26572,26573,26575,26577,26579],{"class":181,"line":264},[262,26574,416],{"class":267},[262,26576,272],{"class":271},[262,26578,276],{"class":275},[262,26580,279],{"class":275},[262,26582,26583,26585,26587],{"class":181,"line":282},[262,26584,285],{"class":271},[262,26586,288],{"class":275},[262,26588,26589],{"class":291},"  # Windows: .venv\\Scripts\\activate\n",[262,26591,26592,26594,26596,26598,26600],{"class":181,"line":295},[262,26593,298],{"class":267},[262,26595,301],{"class":275},[262,26597,2516],{"class":275},[262,26599,2519],{"class":275},[262,26601,2522],{"class":275},[14,26603,26604,26605,608,26607,13390,26609,26612,26613,26616],{},"Your input CSV needs three columns named exactly ",[18,26606,17263],{},[18,26608,92],{},[18,26610,26611],{},"keyword",". A small sample looks like this; save it as ",[18,26614,26615],{},"pages.csv",":",[253,26618,26621],{"className":26619,"code":26620,"language":111,"meta":258},[2577],"url,title,keyword\nhttps:\u002F\u002Fexample.com\u002Fblue-widgets,Blue Widgets for Small Workshops,blue widgets\nhttps:\u002F\u002Fexample.com\u002Fred-gadgets,Red Gadgets That Last a Decade,durable red gadgets\nhttps:\u002F\u002Fexample.com\u002Fgreen-tools,Green Tools for Home Gardeners,eco garden tools\n",[18,26622,26620],{"__ignoreMap":258},[57,26624,26626],{"id":26625},"step-1-store-your-api-key-safely","Step 1: Store your API key safely",[14,26628,26629,26630,26632],{},"Never paste your API key into the script itself. Put it in a file named ",[18,26631,319],{}," in the same folder, so it stays out of your code and out of version control:",[253,26634,26635],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,26636,26637],{"__ignoreMap":258},[262,26638,26639],{"class":181,"line":264},[262,26640,337],{},[14,26642,353,26643,356,26645,26647],{},[18,26644,319],{},[18,26646,359],{}," immediately so the key is never committed to a repository. A single leaked key can run up a real bill, so this one-line habit matters more than it looks.",[14,26649,26650,26651,26653,26654,26656,26657,26659],{},"The script loads this key automatically with ",[18,26652,2501],{},", which reads ",[18,26655,319],{}," into your environment when the program starts. If you want a deeper grounding in how these keys and calls work, read ",[51,26658,2487],{"href":2486}," alongside this guide.",[57,26661,26663],{"id":26662},"step-2-load-and-validate-the-input-csv","Step 2: Load and validate the input CSV",[14,26665,26666,26667,26669],{},"Before sending anything to the API, load the CSV into a ",[18,26668,2494],{}," DataFrame (a table of rows and columns) and confirm the three required columns are present. Catching a missing column now saves you from a confusing crash halfway through a paid run.",[253,26671,26673],{"className":414,"code":26672,"language":416,"meta":258,"style":258},"import pandas as pd\n\nREQUIRED_COLUMNS = {\"url\", \"title\", \"keyword\"}\n\n\ndef load_pages(csv_path: str) -> pd.DataFrame:\n    df = pd.read_csv(csv_path, dtype=str).fillna(\"\")\n    missing = REQUIRED_COLUMNS - set(df.columns)\n    if missing:\n        raise ValueError(f\"CSV is missing required columns: {missing}\")\n    # Drop rows with no title and no keyword — nothing to describe.\n    df = df[(df[\"title\"].str.strip() != \"\") | (df[\"keyword\"].str.strip() != \"\")]\n    return df.reset_index(drop=True)\n",[18,26674,26675,26685,26689,26711,26715,26719,26733,26757,26774,26781,26805,26810,26845],{"__ignoreMap":258},[262,26676,26677,26679,26681,26683],{"class":181,"line":264},[262,26678,684],{"class":377},[262,26680,2619],{"class":429},[262,26682,697],{"class":377},[262,26684,2624],{"class":429},[262,26686,26687],{"class":181,"line":282},[262,26688,583],{"emptyLinePlaceholder":582},[262,26690,26691,26694,26696,26698,26700,26702,26704,26706,26709],{"class":181,"line":295},[262,26692,26693],{"class":271},"REQUIRED_COLUMNS",[262,26695,442],{"class":377},[262,26697,2276],{"class":429},[262,26699,6276],{"class":275},[262,26701,608],{"class":429},[262,26703,12293],{"class":275},[262,26705,608],{"class":429},[262,26707,26708],{"class":275},"\"keyword\"",[262,26710,16430],{"class":429},[262,26712,26713],{"class":181,"line":345},[262,26714,583],{"emptyLinePlaceholder":582},[262,26716,26717],{"class":181,"line":492},[262,26718,583],{"emptyLinePlaceholder":582},[262,26720,26721,26723,26726,26728,26730],{"class":181,"line":503},[262,26722,423],{"class":377},[262,26724,26725],{"class":267}," load_pages",[262,26727,10017],{"class":429},[262,26729,433],{"class":271},[262,26731,26732],{"class":429},") -> pd.DataFrame:\n",[262,26734,26735,26738,26740,26743,26746,26748,26750,26753,26755],{"class":181,"line":521},[262,26736,26737],{"class":429},"    df ",[262,26739,476],{"class":377},[262,26741,26742],{"class":429}," pd.read_csv(csv_path, ",[262,26744,26745],{"class":611},"dtype",[262,26747,476],{"class":377},[262,26749,433],{"class":271},[262,26751,26752],{"class":429},").fillna(",[262,26754,9175],{"class":275},[262,26756,660],{"class":429},[262,26758,26759,26762,26764,26767,26769,26771],{"class":181,"line":537},[262,26760,26761],{"class":429},"    missing ",[262,26763,476],{"class":377},[262,26765,26766],{"class":271}," REQUIRED_COLUMNS",[262,26768,18319],{"class":377},[262,26770,16835],{"class":271},[262,26772,26773],{"class":429},"(df.columns)\n",[262,26775,26776,26778],{"class":181,"line":549},[262,26777,3454],{"class":377},[262,26779,26780],{"class":429}," missing:\n",[262,26782,26783,26785,26787,26789,26791,26794,26796,26799,26801,26803],{"class":181,"line":570},[262,26784,4928],{"class":377},[262,26786,2832],{"class":271},[262,26788,602],{"class":429},[262,26790,642],{"class":377},[262,26792,26793],{"class":275},"\"CSV is missing required columns: ",[262,26795,3039],{"class":271},[262,26797,26798],{"class":429},"missing",[262,26800,654],{"class":271},[262,26802,1176],{"class":275},[262,26804,660],{"class":429},[262,26806,26807],{"class":181,"line":579},[262,26808,26809],{"class":291},"    # Drop rows with no title and no keyword — nothing to describe.\n",[262,26811,26812,26814,26816,26819,26821,26824,26826,26828,26830,26832,26835,26837,26839,26841,26843],{"class":181,"line":586},[262,26813,26737],{"class":429},[262,26815,476],{"class":377},[262,26817,26818],{"class":429}," df[(df[",[262,26820,12293],{"class":275},[262,26822,26823],{"class":429},"].str.strip() ",[262,26825,23215],{"class":377},[262,26827,6332],{"class":275},[262,26829,1000],{"class":429},[262,26831,7985],{"class":377},[262,26833,26834],{"class":429}," (df[",[262,26836,26708],{"class":275},[262,26838,26823],{"class":429},[262,26840,23215],{"class":377},[262,26842,6332],{"class":275},[262,26844,18503],{"class":429},[262,26846,26847,26849,26852,26855,26857,26859],{"class":181,"line":591},[262,26848,573],{"class":377},[262,26850,26851],{"class":429}," df.reset_index(",[262,26853,26854],{"class":611},"drop",[262,26856,476],{"class":377},[262,26858,4974],{"class":271},[262,26860,660],{"class":429},[14,26862,26863,26864,26867,26868,26871,26872,26875,26876,26878],{},"Reading everything as ",[18,26865,26866],{},"dtype=str"," and calling ",[18,26869,26870],{},".fillna(\"\")"," means an empty cell becomes an empty string rather than the float ",[18,26873,26874],{},"NaN",", which would otherwise sneak into your prompts and produce garbage. If your source data is messy in other ways, ",[51,26877,2919],{"href":2918}," covers the wider toolkit.",[57,26880,26882],{"id":26881},"step-3-generate-a-length-checked-description-for-each-row","Step 3: Generate a length-checked description for each row",[14,26884,26885],{},"This is the core of the script. For every row you send the title and keyword to the model, ask for a description under your character budget, and then verify the length yourself in Python. The model is good but not perfectly obedient about counting characters, so the code is the real enforcer, not the prompt.",[14,26887,26888],{},"The function asks for a target of 150 characters — slightly under the 155 hard limit — which gives the model a little headroom and reduces retries. If a result is still too long, it retries once with a stricter instruction, and if that fails too it truncates cleanly at the last whole word.",[253,26890,26892],{"className":414,"code":26891,"language":416,"meta":258,"style":258},"import os\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\nMAX_CHARS = 155\nTARGET_CHARS = 150\n\n\ndef _truncate_at_word(text: str, limit: int) -> str:\n    if len(text) \u003C= limit:\n        return text\n    cut = text[: limit + 1].rsplit(\" \", 1)[0]\n    return cut.rstrip(\",.;:- \") + \"…\"\n\n\ndef write_description(title: str, keyword: str, model: str = \"gpt-4o-mini\") -> str:\n    prompt = (\n        \"Write one SEO meta description for the web page below. \"\n        f\"It MUST be {TARGET_CHARS} characters or fewer, written as a single \"\n        \"active-voice sentence that invites a click. Naturally include the \"\n        \"keyword. Do not use quotation marks or emoji.\\n\\n\"\n        f\"Page title: {title}\\n\"\n        f\"Target keyword: {keyword}\"\n    )\n    for attempt in range(2):\n        # On the second attempt, push the model to be shorter.\n        instruction = prompt if attempt == 0 else prompt + \"\\n\\nYour last answer was too long. Be shorter.\"\n        response = client.chat.completions.create(\n            model=model,\n            messages=[{\"role\": \"user\", \"content\": instruction}],\n            temperature=0.7,\n            max_tokens=80,\n        )\n        text = response.choices[0].message.content.strip().strip('\"')\n        if len(text) \u003C= MAX_CHARS:\n            return text\n    # Both attempts overshot — truncate at the last full word.\n    return _truncate_at_word(text, MAX_CHARS)\n",[18,26893,26894,26900,26910,26920,26924,26928,26947,26951,26961,26971,26975,26979,27000,27012,27019,27049,27066,27070,27074,27106,27114,27119,27132,27137,27146,27161,27176,27180,27196,27201,27232,27240,27249,27271,27282,27293,27297,27315,27330,27336,27341],{"__ignoreMap":258},[262,26895,26896,26898],{"class":181,"line":264},[262,26897,684],{"class":377},[262,26899,687],{"class":429},[262,26901,26902,26904,26906,26908],{"class":181,"line":282},[262,26903,705],{"class":377},[262,26905,720],{"class":429},[262,26907,684],{"class":377},[262,26909,725],{"class":429},[262,26911,26912,26914,26916,26918],{"class":181,"line":295},[262,26913,705],{"class":377},[262,26915,708],{"class":429},[262,26917,684],{"class":377},[262,26919,713],{"class":429},[262,26921,26922],{"class":181,"line":345},[262,26923,583],{"emptyLinePlaceholder":582},[262,26925,26926],{"class":181,"line":492},[262,26927,734],{"class":429},[262,26929,26930,26932,26934,26936,26938,26940,26943,26945],{"class":181,"line":503},[262,26931,739],{"class":429},[262,26933,476],{"class":377},[262,26935,1588],{"class":429},[262,26937,2674],{"class":611},[262,26939,476],{"class":377},[262,26941,26942],{"class":429},"os.environ[",[262,26944,2681],{"class":275},[262,26946,3512],{"class":429},[262,26948,26949],{"class":181,"line":521},[262,26950,583],{"emptyLinePlaceholder":582},[262,26952,26953,26956,26958],{"class":181,"line":537},[262,26954,26955],{"class":271},"MAX_CHARS",[262,26957,442],{"class":377},[262,26959,26960],{"class":271}," 155\n",[262,26962,26963,26966,26968],{"class":181,"line":549},[262,26964,26965],{"class":271},"TARGET_CHARS",[262,26967,442],{"class":377},[262,26969,26970],{"class":271}," 150\n",[262,26972,26973],{"class":181,"line":570},[262,26974,583],{"emptyLinePlaceholder":582},[262,26976,26977],{"class":181,"line":579},[262,26978,583],{"emptyLinePlaceholder":582},[262,26980,26981,26983,26986,26988,26990,26992,26994,26996,26998],{"class":181,"line":586},[262,26982,423],{"class":377},[262,26984,26985],{"class":267}," _truncate_at_word",[262,26987,430],{"class":429},[262,26989,433],{"class":271},[262,26991,17988],{"class":429},[262,26993,439],{"class":271},[262,26995,1939],{"class":429},[262,26997,433],{"class":271},[262,26999,1160],{"class":429},[262,27001,27002,27004,27006,27008,27010],{"class":181,"line":591},[262,27003,3454],{"class":377},[262,27005,515],{"class":271},[262,27007,8109],{"class":429},[262,27009,8983],{"class":377},[262,27011,18283],{"class":429},[262,27013,27014,27016],{"class":181,"line":623},[262,27015,8066],{"class":377},[262,27017,27018],{"class":429}," text\n",[262,27020,27021,27024,27026,27029,27031,27033,27036,27038,27040,27042,27045,27047],{"class":181,"line":634},[262,27022,27023],{"class":429},"    cut ",[262,27025,476],{"class":377},[262,27027,27028],{"class":429}," text[: limit ",[262,27030,531],{"class":377},[262,27032,3243],{"class":271},[262,27034,27035],{"class":429},"].rsplit(",[262,27037,543],{"class":275},[262,27039,608],{"class":429},[262,27041,997],{"class":271},[262,27043,27044],{"class":429},")[",[262,27046,102],{"class":271},[262,27048,957],{"class":429},[262,27050,27051,27053,27056,27059,27061,27063],{"class":181,"line":845},[262,27052,573],{"class":377},[262,27054,27055],{"class":429}," cut.rstrip(",[262,27057,27058],{"class":275},"\",.;:- \"",[262,27060,1000],{"class":429},[262,27062,531],{"class":377},[262,27064,27065],{"class":275}," \"…\"\n",[262,27067,27068],{"class":181,"line":850},[262,27069,583],{"emptyLinePlaceholder":582},[262,27071,27072],{"class":181,"line":864},[262,27073,583],{"emptyLinePlaceholder":582},[262,27075,27076,27078,27081,27084,27086,27089,27091,27094,27096,27098,27100,27102,27104],{"class":181,"line":1683},[262,27077,423],{"class":377},[262,27079,27080],{"class":267}," write_description",[262,27082,27083],{"class":429},"(title: ",[262,27085,433],{"class":271},[262,27087,27088],{"class":429},", keyword: ",[262,27090,433],{"class":271},[262,27092,27093],{"class":429},", model: ",[262,27095,433],{"class":271},[262,27097,442],{"class":377},[262,27099,4256],{"class":275},[262,27101,1939],{"class":429},[262,27103,433],{"class":271},[262,27105,1160],{"class":429},[262,27107,27108,27110,27112],{"class":181,"line":1688},[262,27109,18006],{"class":429},[262,27111,476],{"class":377},[262,27113,984],{"class":429},[262,27115,27116],{"class":181,"line":1693},[262,27117,27118],{"class":275},"        \"Write one SEO meta description for the web page below. \"\n",[262,27120,27121,27123,27126,27129],{"class":181,"line":1728},[262,27122,2840],{"class":377},[262,27124,27125],{"class":275},"\"It MUST be ",[262,27127,27128],{"class":271},"{TARGET_CHARS}",[262,27130,27131],{"class":275}," characters or fewer, written as a single \"\n",[262,27133,27134],{"class":181,"line":1737},[262,27135,27136],{"class":275},"        \"active-voice sentence that invites a click. Naturally include the \"\n",[262,27138,27139,27142,27144],{"class":181,"line":1751},[262,27140,27141],{"class":275},"        \"keyword. Do not use quotation marks or emoji.",[262,27143,1173],{"class":271},[262,27145,1257],{"class":275},[262,27147,27148,27150,27153,27155,27157,27159],{"class":181,"line":1764},[262,27149,2840],{"class":377},[262,27151,27152],{"class":275},"\"Page title: ",[262,27154,3039],{"class":271},[262,27156,92],{"class":429},[262,27158,3044],{"class":271},[262,27160,1257],{"class":275},[262,27162,27163,27165,27168,27170,27172,27174],{"class":181,"line":1779},[262,27164,2840],{"class":377},[262,27166,27167],{"class":275},"\"Target keyword: ",[262,27169,3039],{"class":271},[262,27171,26611],{"class":429},[262,27173,654],{"class":271},[262,27175,1257],{"class":275},[262,27177,27178],{"class":181,"line":1793},[262,27179,1011],{"class":429},[262,27181,27182,27184,27186,27188,27190,27192,27194],{"class":181,"line":1800},[262,27183,3074],{"class":377},[262,27185,3077],{"class":429},[262,27187,835],{"class":377},[262,27189,3082],{"class":271},[262,27191,602],{"class":429},[262,27193,109],{"class":271},[262,27195,8192],{"class":429},[262,27197,27198],{"class":181,"line":1805},[262,27199,27200],{"class":291},"        # On the second attempt, push the model to be shorter.\n",[262,27202,27203,27206,27208,27211,27213,27215,27217,27219,27221,27223,27225,27227,27229],{"class":181,"line":1810},[262,27204,27205],{"class":429},"        instruction ",[262,27207,476],{"class":377},[262,27209,27210],{"class":429}," prompt ",[262,27212,2210],{"class":377},[262,27214,3077],{"class":429},[262,27216,10758],{"class":377},[262,27218,3604],{"class":271},[262,27220,19241],{"class":377},[262,27222,27210],{"class":429},[262,27224,531],{"class":377},[262,27226,1170],{"class":275},[262,27228,1173],{"class":271},[262,27230,27231],{"class":275},"Your last answer was too long. Be shorter.\"\n",[262,27233,27234,27236,27238],{"class":181,"line":1823},[262,27235,21490],{"class":429},[262,27237,476],{"class":377},[262,27239,1189],{"class":429},[262,27241,27242,27244,27246],{"class":181,"line":1846},[262,27243,14214],{"class":611},[262,27245,476],{"class":377},[262,27247,27248],{"class":429},"model,\n",[262,27250,27251,27254,27256,27258,27260,27262,27264,27266,27268],{"class":181,"line":1861},[262,27252,27253],{"class":611},"            messages",[262,27255,476],{"class":377},[262,27257,8856],{"class":429},[262,27259,1228],{"class":275},[262,27261,1231],{"class":429},[262,27263,1291],{"class":275},[262,27265,608],{"class":429},[262,27267,1239],{"class":275},[262,27269,27270],{"class":429},": instruction}],\n",[262,27272,27273,27276,27278,27280],{"class":181,"line":1866},[262,27274,27275],{"class":611},"            temperature",[262,27277,476],{"class":377},[262,27279,4672],{"class":271},[262,27281,1315],{"class":429},[262,27283,27284,27287,27289,27291],{"class":181,"line":1871},[262,27285,27286],{"class":611},"            max_tokens",[262,27288,476],{"class":377},[262,27290,1100],{"class":271},[262,27292,1315],{"class":429},[262,27294,27295],{"class":181,"line":1890},[262,27296,6288],{"class":429},[262,27298,27299,27301,27303,27305,27307,27310,27313],{"class":181,"line":1909},[262,27300,18264],{"class":429},[262,27302,476],{"class":377},[262,27304,1326],{"class":429},[262,27306,102],{"class":271},[262,27308,27309],{"class":429},"].message.content.strip().strip(",[262,27311,27312],{"class":275},"'\"'",[262,27314,660],{"class":429},[262,27316,27317,27319,27321,27323,27325,27328],{"class":181,"line":1914},[262,27318,2268],{"class":377},[262,27320,515],{"class":271},[262,27322,8109],{"class":429},[262,27324,8983],{"class":377},[262,27326,27327],{"class":271}," MAX_CHARS",[262,27329,1160],{"class":429},[262,27331,27332,27334],{"class":181,"line":1919},[262,27333,3198],{"class":377},[262,27335,27018],{"class":429},[262,27337,27338],{"class":181,"line":1946},[262,27339,27340],{"class":291},"    # Both attempts overshot — truncate at the last full word.\n",[262,27342,27343,27345,27348,27350],{"class":181,"line":1959},[262,27344,573],{"class":377},[262,27346,27347],{"class":429}," _truncate_at_word(text, ",[262,27349,26955],{"class":271},[262,27351,660],{"class":429},[14,27353,3349,27354,27356,27357,27360,27361,1363],{},[18,27355,3829],{}," of 0.7 gives the descriptions some variety so they do not all read like the same template. Setting ",[18,27358,27359],{},"max_tokens=80"," is a cheap safety rail: a meta description never needs more than that, and it stops a runaway response from costing extra. To understand why a system prompt could tighten this further, see ",[51,27362,1362],{"href":1361},[57,27364,27366],{"id":27365},"step-4-dedupe-flag-and-export","Step 4: Dedupe, flag, and export",[14,27368,27369],{},"Bulk generation occasionally produces two near-identical descriptions, especially when several pages target overlapping keywords. Duplicate snippets across your site look careless to both users and search engines, so the final step removes exact duplicates, flags anything that had to be truncated, and writes the result to a new CSV.",[253,27371,27373],{"className":414,"code":27372,"language":416,"meta":258,"style":258},"def add_descriptions(df: pd.DataFrame) -> pd.DataFrame:\n    descriptions = []\n    for row in df.itertuples(index=False):\n        desc = write_description(row.title, row.keyword)\n        descriptions.append(desc)\n    df = df.copy()\n    df[\"meta_description\"] = descriptions\n    df[\"char_count\"] = df[\"meta_description\"].str.len()\n    # Flag exact duplicates (keep the first, mark the rest).\n    df[\"is_duplicate\"] = df.duplicated(subset=\"meta_description\", keep=\"first\")\n    # Flag anything that was truncated so a human can review it.\n    df[\"needs_review\"] = df[\"meta_description\"].str.endswith(\"…\")\n    return df\n\n\ndef export(df: pd.DataFrame, out_path: str = \"meta_descriptions.csv\") -> None:\n    df.to_csv(out_path, index=False, encoding=\"utf-8-sig\")\n    print(f\"Wrote {len(df)} rows to {out_path}\")\n    dupes = int(df[\"is_duplicate\"].sum())\n    review = int(df[\"needs_review\"].sum())\n    if dupes:\n        print(f\"  ⚠ {dupes} duplicate description(s) — rewrite for variety.\")\n    if review:\n        print(f\"  ⚠ {review} description(s) were truncated — review wording.\")\n",[18,27374,27375,27385,27394,27413,27423,27428,27437,27451,27470,27475,27508,27513,27536,27543,27547,27551,27574,27596,27626,27643,27658,27665,27688,27695],{"__ignoreMap":258},[262,27376,27377,27379,27382],{"class":181,"line":264},[262,27378,423],{"class":377},[262,27380,27381],{"class":267}," add_descriptions",[262,27383,27384],{"class":429},"(df: pd.DataFrame) -> pd.DataFrame:\n",[262,27386,27387,27390,27392],{"class":181,"line":282},[262,27388,27389],{"class":429},"    descriptions ",[262,27391,476],{"class":377},[262,27393,489],{"class":429},[262,27395,27396,27398,27400,27402,27405,27407,27409,27411],{"class":181,"line":295},[262,27397,3074],{"class":377},[262,27399,10158],{"class":429},[262,27401,835],{"class":377},[262,27403,27404],{"class":429}," df.itertuples(",[262,27406,3618],{"class":611},[262,27408,476],{"class":377},[262,27410,3623],{"class":271},[262,27412,8192],{"class":429},[262,27414,27415,27418,27420],{"class":181,"line":345},[262,27416,27417],{"class":429},"        desc ",[262,27419,476],{"class":377},[262,27421,27422],{"class":429}," write_description(row.title, row.keyword)\n",[262,27424,27425],{"class":181,"line":492},[262,27426,27427],{"class":429},"        descriptions.append(desc)\n",[262,27429,27430,27432,27434],{"class":181,"line":503},[262,27431,26737],{"class":429},[262,27433,476],{"class":377},[262,27435,27436],{"class":429}," df.copy()\n",[262,27438,27439,27441,27444,27446,27448],{"class":181,"line":521},[262,27440,2897],{"class":429},[262,27442,27443],{"class":275},"\"meta_description\"",[262,27445,2903],{"class":429},[262,27447,476],{"class":377},[262,27449,27450],{"class":429}," descriptions\n",[262,27452,27453,27455,27458,27460,27462,27465,27467],{"class":181,"line":537},[262,27454,2897],{"class":429},[262,27456,27457],{"class":275},"\"char_count\"",[262,27459,2903],{"class":429},[262,27461,476],{"class":377},[262,27463,27464],{"class":429}," df[",[262,27466,27443],{"class":275},[262,27468,27469],{"class":429},"].str.len()\n",[262,27471,27472],{"class":181,"line":549},[262,27473,27474],{"class":291},"    # Flag exact duplicates (keep the first, mark the rest).\n",[262,27476,27477,27479,27482,27484,27486,27489,27492,27494,27496,27498,27501,27503,27506],{"class":181,"line":570},[262,27478,2897],{"class":429},[262,27480,27481],{"class":275},"\"is_duplicate\"",[262,27483,2903],{"class":429},[262,27485,476],{"class":377},[262,27487,27488],{"class":429}," df.duplicated(",[262,27490,27491],{"class":611},"subset",[262,27493,476],{"class":377},[262,27495,27443],{"class":275},[262,27497,608],{"class":429},[262,27499,27500],{"class":611},"keep",[262,27502,476],{"class":377},[262,27504,27505],{"class":275},"\"first\"",[262,27507,660],{"class":429},[262,27509,27510],{"class":181,"line":579},[262,27511,27512],{"class":291},"    # Flag anything that was truncated so a human can review it.\n",[262,27514,27515,27517,27520,27522,27524,27526,27528,27531,27534],{"class":181,"line":586},[262,27516,2897],{"class":429},[262,27518,27519],{"class":275},"\"needs_review\"",[262,27521,2903],{"class":429},[262,27523,476],{"class":377},[262,27525,27464],{"class":429},[262,27527,27443],{"class":275},[262,27529,27530],{"class":429},"].str.endswith(",[262,27532,27533],{"class":275},"\"…\"",[262,27535,660],{"class":429},[262,27537,27538,27540],{"class":181,"line":591},[262,27539,573],{"class":377},[262,27541,27542],{"class":429}," df\n",[262,27544,27545],{"class":181,"line":623},[262,27546,583],{"emptyLinePlaceholder":582},[262,27548,27549],{"class":181,"line":634},[262,27550,583],{"emptyLinePlaceholder":582},[262,27552,27553,27555,27558,27561,27563,27565,27568,27570,27572],{"class":181,"line":845},[262,27554,423],{"class":377},[262,27556,27557],{"class":267}," export",[262,27559,27560],{"class":429},"(df: pd.DataFrame, out_path: ",[262,27562,433],{"class":271},[262,27564,442],{"class":377},[262,27566,27567],{"class":275}," \"meta_descriptions.csv\"",[262,27569,1939],{"class":429},[262,27571,8471],{"class":271},[262,27573,1160],{"class":429},[262,27575,27576,27579,27581,27583,27585,27587,27589,27591,27594],{"class":181,"line":850},[262,27577,27578],{"class":429},"    df.to_csv(out_path, ",[262,27580,3618],{"class":611},[262,27582,476],{"class":377},[262,27584,3623],{"class":271},[262,27586,608],{"class":429},[262,27588,612],{"class":611},[262,27590,476],{"class":377},[262,27592,27593],{"class":275},"\"utf-8-sig\"",[262,27595,660],{"class":429},[262,27597,27598,27600,27602,27604,27607,27609,27611,27613,27616,27618,27620,27622,27624],{"class":181,"line":864},[262,27599,1089],{"class":271},[262,27601,602],{"class":429},[262,27603,642],{"class":377},[262,27605,27606],{"class":275},"\"Wrote ",[262,27608,648],{"class":271},[262,27610,2780],{"class":429},[262,27612,654],{"class":271},[262,27614,27615],{"class":275}," rows to ",[262,27617,3039],{"class":271},[262,27619,15457],{"class":429},[262,27621,654],{"class":271},[262,27623,1176],{"class":275},[262,27625,660],{"class":429},[262,27627,27628,27631,27633,27635,27638,27640],{"class":181,"line":1683},[262,27629,27630],{"class":429},"    dupes ",[262,27632,476],{"class":377},[262,27634,23813],{"class":271},[262,27636,27637],{"class":429},"(df[",[262,27639,27481],{"class":275},[262,27641,27642],{"class":429},"].sum())\n",[262,27644,27645,27648,27650,27652,27654,27656],{"class":181,"line":1688},[262,27646,27647],{"class":429},"    review ",[262,27649,476],{"class":377},[262,27651,23813],{"class":271},[262,27653,27637],{"class":429},[262,27655,27519],{"class":275},[262,27657,27642],{"class":429},[262,27659,27660,27662],{"class":181,"line":1693},[262,27661,3454],{"class":377},[262,27663,27664],{"class":429}," dupes:\n",[262,27666,27667,27669,27671,27673,27676,27678,27681,27683,27686],{"class":181,"line":1728},[262,27668,2299],{"class":271},[262,27670,602],{"class":429},[262,27672,642],{"class":377},[262,27674,27675],{"class":275},"\"  ⚠ ",[262,27677,3039],{"class":271},[262,27679,27680],{"class":429},"dupes",[262,27682,654],{"class":271},[262,27684,27685],{"class":275}," duplicate description(s) — rewrite for variety.\"",[262,27687,660],{"class":429},[262,27689,27690,27692],{"class":181,"line":1737},[262,27691,3454],{"class":377},[262,27693,27694],{"class":429}," review:\n",[262,27696,27697,27699,27701,27703,27705,27707,27710,27712,27715],{"class":181,"line":1751},[262,27698,2299],{"class":271},[262,27700,602],{"class":429},[262,27702,642],{"class":377},[262,27704,27675],{"class":275},[262,27706,3039],{"class":271},[262,27708,27709],{"class":429},"review",[262,27711,654],{"class":271},[262,27713,27714],{"class":275}," description(s) were truncated — review wording.\"",[262,27716,660],{"class":429},[14,27718,27719,27720,27723],{},"Saving with ",[18,27721,27722],{},"encoding=\"utf-8-sig\""," keeps accented characters and the truncation ellipsis intact when the file is opened in Excel, which otherwise mangles them. The two flag columns mean you do not have to eyeball every row — you can filter the spreadsheet to just the handful that need a human touch.",[57,27725,17484],{"id":17483},[1379,27727,27728,27740],{},[1382,27729,27730],{},[1385,27731,27732,27734,27736,27738],{},[1388,27733,1390],{},[1388,27735,3795],{},[1388,27737,3798],{},[1388,27739,1396],{},[1398,27741,27742,27760,27776,27794,27813],{},[1385,27743,27744,27748,27750,27754],{},[1403,27745,27746],{},[18,27747,805],{},[1403,27749,433],{},[1403,27751,27752],{},[18,27753,1207],{},[1403,27755,27756,27757,27759],{},"Which model writes the copy. Swap to ",[18,27758,3821],{}," for higher quality at higher cost.",[1385,27761,27762,27766,27768,27773],{},[1403,27763,27764],{},[18,27765,26955],{},[1403,27767,439],{},[1403,27769,27770],{},[18,27771,27772],{},"155",[1403,27774,27775],{},"The hard limit. Descriptions longer than this are truncated at the last full word.",[1385,27777,27778,27782,27784,27788],{},[1403,27779,27780],{},[18,27781,26965],{},[1403,27783,439],{},[1403,27785,27786],{},[18,27787,12809],{},[1403,27789,27790,27791,27793],{},"The length you ask the model to hit, kept just under ",[18,27792,26955],{}," for headroom.",[1385,27795,27796,27800,27802,27806],{},[1403,27797,27798],{},[18,27799,3829],{},[1403,27801,3832],{},[1403,27803,27804],{},[18,27805,4672],{},[1403,27807,27808,27809,27812],{},"Higher means more varied wording; lower (e.g. ",[18,27810,27811],{},"0.2",") means safer, more repetitive copy.",[1385,27814,27815,27819,27821,27825],{},[1403,27816,27817],{},[18,27818,3846],{},[1403,27820,439],{},[1403,27822,27823],{},[18,27824,1100],{},[1403,27826,27827],{},"Caps response length so a single call can never balloon in cost.",[57,27829,27831],{"id":27830},"worked-example-the-full-script","Worked example: the full script",[14,27833,27834,27835,27838,27839,27841,27842,1363],{},"Save this as ",[18,27836,27837],{},"meta_descriptions.py",", drop your ",[18,27840,26615],{}," next to it, and run ",[18,27843,27844],{},"python meta_descriptions.py",[253,27846,27848],{"className":414,"code":27847,"language":416,"meta":258,"style":258},"import os\nimport pandas as pd\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\nREQUIRED_COLUMNS = {\"url\", \"title\", \"keyword\"}\nMAX_CHARS = 155\nTARGET_CHARS = 150\n\n\ndef load_pages(csv_path: str) -> pd.DataFrame:\n    df = pd.read_csv(csv_path, dtype=str).fillna(\"\")\n    missing = REQUIRED_COLUMNS - set(df.columns)\n    if missing:\n        raise ValueError(f\"CSV is missing required columns: {missing}\")\n    df = df[(df[\"title\"].str.strip() != \"\") | (df[\"keyword\"].str.strip() != \"\")]\n    return df.reset_index(drop=True)\n\n\ndef _truncate_at_word(text: str, limit: int) -> str:\n    if len(text) \u003C= limit:\n        return text\n    cut = text[: limit + 1].rsplit(\" \", 1)[0]\n    return cut.rstrip(\",.;:- \") + \"…\"\n\n\ndef write_description(title: str, keyword: str, model: str = \"gpt-4o-mini\") -> str:\n    prompt = (\n        \"Write one SEO meta description for the web page below. \"\n        f\"It MUST be {TARGET_CHARS} characters or fewer, written as a single \"\n        \"active-voice sentence that invites a click. Naturally include the \"\n        \"keyword. Do not use quotation marks or emoji.\\n\\n\"\n        f\"Page title: {title}\\nTarget keyword: {keyword}\"\n    )\n    text = \"\"\n    for attempt in range(2):\n        instruction = prompt if attempt == 0 else prompt + \"\\n\\nYour last answer was too long. Be shorter.\"\n        response = client.chat.completions.create(\n            model=model,\n            messages=[{\"role\": \"user\", \"content\": instruction}],\n            temperature=0.7,\n            max_tokens=80,\n        )\n        text = response.choices[0].message.content.strip().strip('\"')\n        if len(text) \u003C= MAX_CHARS:\n            return text\n    return _truncate_at_word(text, MAX_CHARS)\n\n\ndef add_descriptions(df: pd.DataFrame) -> pd.DataFrame:\n    df = df.copy()\n    df[\"meta_description\"] = [\n        write_description(row.title, row.keyword)\n        for row in df.itertuples(index=False)\n    ]\n    df[\"char_count\"] = df[\"meta_description\"].str.len()\n    df[\"is_duplicate\"] = df.duplicated(subset=\"meta_description\", keep=\"first\")\n    df[\"needs_review\"] = df[\"meta_description\"].str.endswith(\"…\")\n    return df\n\n\ndef export(df: pd.DataFrame, out_path: str = \"meta_descriptions.csv\") -> None:\n    df.to_csv(out_path, index=False, encoding=\"utf-8-sig\")\n    print(f\"Wrote {len(df)} rows to {out_path}\")\n    if df[\"is_duplicate\"].any():\n        print(f\"  ⚠ {int(df['is_duplicate'].sum())} duplicate(s) — rewrite for variety.\")\n    if df[\"needs_review\"].any():\n        print(f\"  ⚠ {int(df['needs_review'].sum())} truncated — review wording.\")\n\n\nif __name__ == \"__main__\":\n    pages = load_pages(\"pages.csv\")\n    result = add_descriptions(pages)\n    export(result)\n",[18,27849,27850,27856,27866,27876,27886,27890,27894,27912,27916,27936,27944,27952,27956,27960,27972,27992,28006,28012,28034,28066,28080,28084,28088,28108,28120,28126,28152,28166,28170,28174,28202,28210,28214,28224,28228,28236,28259,28263,28272,28288,28316,28324,28332,28352,28362,28372,28376,28392,28406,28412,28422,28426,28430,28438,28446,28458,28463,28481,28485,28501,28529,28549,28555,28559,28563,28583,28603,28631,28642,28670,28681,28708,28713,28718,28731,28747,28757],{"__ignoreMap":258},[262,27851,27852,27854],{"class":181,"line":264},[262,27853,684],{"class":377},[262,27855,687],{"class":429},[262,27857,27858,27860,27862,27864],{"class":181,"line":282},[262,27859,684],{"class":377},[262,27861,2619],{"class":429},[262,27863,697],{"class":377},[262,27865,2624],{"class":429},[262,27867,27868,27870,27872,27874],{"class":181,"line":295},[262,27869,705],{"class":377},[262,27871,720],{"class":429},[262,27873,684],{"class":377},[262,27875,725],{"class":429},[262,27877,27878,27880,27882,27884],{"class":181,"line":345},[262,27879,705],{"class":377},[262,27881,708],{"class":429},[262,27883,684],{"class":377},[262,27885,713],{"class":429},[262,27887,27888],{"class":181,"line":492},[262,27889,583],{"emptyLinePlaceholder":582},[262,27891,27892],{"class":181,"line":503},[262,27893,734],{"class":429},[262,27895,27896,27898,27900,27902,27904,27906,27908,27910],{"class":181,"line":521},[262,27897,739],{"class":429},[262,27899,476],{"class":377},[262,27901,1588],{"class":429},[262,27903,2674],{"class":611},[262,27905,476],{"class":377},[262,27907,26942],{"class":429},[262,27909,2681],{"class":275},[262,27911,3512],{"class":429},[262,27913,27914],{"class":181,"line":537},[262,27915,583],{"emptyLinePlaceholder":582},[262,27917,27918,27920,27922,27924,27926,27928,27930,27932,27934],{"class":181,"line":549},[262,27919,26693],{"class":271},[262,27921,442],{"class":377},[262,27923,2276],{"class":429},[262,27925,6276],{"class":275},[262,27927,608],{"class":429},[262,27929,12293],{"class":275},[262,27931,608],{"class":429},[262,27933,26708],{"class":275},[262,27935,16430],{"class":429},[262,27937,27938,27940,27942],{"class":181,"line":570},[262,27939,26955],{"class":271},[262,27941,442],{"class":377},[262,27943,26960],{"class":271},[262,27945,27946,27948,27950],{"class":181,"line":579},[262,27947,26965],{"class":271},[262,27949,442],{"class":377},[262,27951,26970],{"class":271},[262,27953,27954],{"class":181,"line":586},[262,27955,583],{"emptyLinePlaceholder":582},[262,27957,27958],{"class":181,"line":591},[262,27959,583],{"emptyLinePlaceholder":582},[262,27961,27962,27964,27966,27968,27970],{"class":181,"line":623},[262,27963,423],{"class":377},[262,27965,26725],{"class":267},[262,27967,10017],{"class":429},[262,27969,433],{"class":271},[262,27971,26732],{"class":429},[262,27973,27974,27976,27978,27980,27982,27984,27986,27988,27990],{"class":181,"line":634},[262,27975,26737],{"class":429},[262,27977,476],{"class":377},[262,27979,26742],{"class":429},[262,27981,26745],{"class":611},[262,27983,476],{"class":377},[262,27985,433],{"class":271},[262,27987,26752],{"class":429},[262,27989,9175],{"class":275},[262,27991,660],{"class":429},[262,27993,27994,27996,27998,28000,28002,28004],{"class":181,"line":845},[262,27995,26761],{"class":429},[262,27997,476],{"class":377},[262,27999,26766],{"class":271},[262,28001,18319],{"class":377},[262,28003,16835],{"class":271},[262,28005,26773],{"class":429},[262,28007,28008,28010],{"class":181,"line":850},[262,28009,3454],{"class":377},[262,28011,26780],{"class":429},[262,28013,28014,28016,28018,28020,28022,28024,28026,28028,28030,28032],{"class":181,"line":864},[262,28015,4928],{"class":377},[262,28017,2832],{"class":271},[262,28019,602],{"class":429},[262,28021,642],{"class":377},[262,28023,26793],{"class":275},[262,28025,3039],{"class":271},[262,28027,26798],{"class":429},[262,28029,654],{"class":271},[262,28031,1176],{"class":275},[262,28033,660],{"class":429},[262,28035,28036,28038,28040,28042,28044,28046,28048,28050,28052,28054,28056,28058,28060,28062,28064],{"class":181,"line":1683},[262,28037,26737],{"class":429},[262,28039,476],{"class":377},[262,28041,26818],{"class":429},[262,28043,12293],{"class":275},[262,28045,26823],{"class":429},[262,28047,23215],{"class":377},[262,28049,6332],{"class":275},[262,28051,1000],{"class":429},[262,28053,7985],{"class":377},[262,28055,26834],{"class":429},[262,28057,26708],{"class":275},[262,28059,26823],{"class":429},[262,28061,23215],{"class":377},[262,28063,6332],{"class":275},[262,28065,18503],{"class":429},[262,28067,28068,28070,28072,28074,28076,28078],{"class":181,"line":1688},[262,28069,573],{"class":377},[262,28071,26851],{"class":429},[262,28073,26854],{"class":611},[262,28075,476],{"class":377},[262,28077,4974],{"class":271},[262,28079,660],{"class":429},[262,28081,28082],{"class":181,"line":1693},[262,28083,583],{"emptyLinePlaceholder":582},[262,28085,28086],{"class":181,"line":1728},[262,28087,583],{"emptyLinePlaceholder":582},[262,28089,28090,28092,28094,28096,28098,28100,28102,28104,28106],{"class":181,"line":1737},[262,28091,423],{"class":377},[262,28093,26985],{"class":267},[262,28095,430],{"class":429},[262,28097,433],{"class":271},[262,28099,17988],{"class":429},[262,28101,439],{"class":271},[262,28103,1939],{"class":429},[262,28105,433],{"class":271},[262,28107,1160],{"class":429},[262,28109,28110,28112,28114,28116,28118],{"class":181,"line":1751},[262,28111,3454],{"class":377},[262,28113,515],{"class":271},[262,28115,8109],{"class":429},[262,28117,8983],{"class":377},[262,28119,18283],{"class":429},[262,28121,28122,28124],{"class":181,"line":1764},[262,28123,8066],{"class":377},[262,28125,27018],{"class":429},[262,28127,28128,28130,28132,28134,28136,28138,28140,28142,28144,28146,28148,28150],{"class":181,"line":1779},[262,28129,27023],{"class":429},[262,28131,476],{"class":377},[262,28133,27028],{"class":429},[262,28135,531],{"class":377},[262,28137,3243],{"class":271},[262,28139,27035],{"class":429},[262,28141,543],{"class":275},[262,28143,608],{"class":429},[262,28145,997],{"class":271},[262,28147,27044],{"class":429},[262,28149,102],{"class":271},[262,28151,957],{"class":429},[262,28153,28154,28156,28158,28160,28162,28164],{"class":181,"line":1793},[262,28155,573],{"class":377},[262,28157,27055],{"class":429},[262,28159,27058],{"class":275},[262,28161,1000],{"class":429},[262,28163,531],{"class":377},[262,28165,27065],{"class":275},[262,28167,28168],{"class":181,"line":1800},[262,28169,583],{"emptyLinePlaceholder":582},[262,28171,28172],{"class":181,"line":1805},[262,28173,583],{"emptyLinePlaceholder":582},[262,28175,28176,28178,28180,28182,28184,28186,28188,28190,28192,28194,28196,28198,28200],{"class":181,"line":1810},[262,28177,423],{"class":377},[262,28179,27080],{"class":267},[262,28181,27083],{"class":429},[262,28183,433],{"class":271},[262,28185,27088],{"class":429},[262,28187,433],{"class":271},[262,28189,27093],{"class":429},[262,28191,433],{"class":271},[262,28193,442],{"class":377},[262,28195,4256],{"class":275},[262,28197,1939],{"class":429},[262,28199,433],{"class":271},[262,28201,1160],{"class":429},[262,28203,28204,28206,28208],{"class":181,"line":1823},[262,28205,18006],{"class":429},[262,28207,476],{"class":377},[262,28209,984],{"class":429},[262,28211,28212],{"class":181,"line":1846},[262,28213,27118],{"class":275},[262,28215,28216,28218,28220,28222],{"class":181,"line":1861},[262,28217,2840],{"class":377},[262,28219,27125],{"class":275},[262,28221,27128],{"class":271},[262,28223,27131],{"class":275},[262,28225,28226],{"class":181,"line":1866},[262,28227,27136],{"class":275},[262,28229,28230,28232,28234],{"class":181,"line":1871},[262,28231,27141],{"class":275},[262,28233,1173],{"class":271},[262,28235,1257],{"class":275},[262,28237,28238,28240,28242,28244,28246,28248,28251,28253,28255,28257],{"class":181,"line":1890},[262,28239,2840],{"class":377},[262,28241,27152],{"class":275},[262,28243,3039],{"class":271},[262,28245,92],{"class":429},[262,28247,3044],{"class":271},[262,28249,28250],{"class":275},"Target keyword: ",[262,28252,3039],{"class":271},[262,28254,26611],{"class":429},[262,28256,654],{"class":271},[262,28258,1257],{"class":275},[262,28260,28261],{"class":181,"line":1909},[262,28262,1011],{"class":429},[262,28264,28265,28268,28270],{"class":181,"line":1914},[262,28266,28267],{"class":429},"    text ",[262,28269,476],{"class":377},[262,28271,2908],{"class":275},[262,28273,28274,28276,28278,28280,28282,28284,28286],{"class":181,"line":1919},[262,28275,3074],{"class":377},[262,28277,3077],{"class":429},[262,28279,835],{"class":377},[262,28281,3082],{"class":271},[262,28283,602],{"class":429},[262,28285,109],{"class":271},[262,28287,8192],{"class":429},[262,28289,28290,28292,28294,28296,28298,28300,28302,28304,28306,28308,28310,28312,28314],{"class":181,"line":1946},[262,28291,27205],{"class":429},[262,28293,476],{"class":377},[262,28295,27210],{"class":429},[262,28297,2210],{"class":377},[262,28299,3077],{"class":429},[262,28301,10758],{"class":377},[262,28303,3604],{"class":271},[262,28305,19241],{"class":377},[262,28307,27210],{"class":429},[262,28309,531],{"class":377},[262,28311,1170],{"class":275},[262,28313,1173],{"class":271},[262,28315,27231],{"class":275},[262,28317,28318,28320,28322],{"class":181,"line":1959},[262,28319,21490],{"class":429},[262,28321,476],{"class":377},[262,28323,1189],{"class":429},[262,28325,28326,28328,28330],{"class":181,"line":1996},[262,28327,14214],{"class":611},[262,28329,476],{"class":377},[262,28331,27248],{"class":429},[262,28333,28334,28336,28338,28340,28342,28344,28346,28348,28350],{"class":181,"line":2012},[262,28335,27253],{"class":611},[262,28337,476],{"class":377},[262,28339,8856],{"class":429},[262,28341,1228],{"class":275},[262,28343,1231],{"class":429},[262,28345,1291],{"class":275},[262,28347,608],{"class":429},[262,28349,1239],{"class":275},[262,28351,27270],{"class":429},[262,28353,28354,28356,28358,28360],{"class":181,"line":2040},[262,28355,27275],{"class":611},[262,28357,476],{"class":377},[262,28359,4672],{"class":271},[262,28361,1315],{"class":429},[262,28363,28364,28366,28368,28370],{"class":181,"line":2045},[262,28365,27286],{"class":611},[262,28367,476],{"class":377},[262,28369,1100],{"class":271},[262,28371,1315],{"class":429},[262,28373,28374],{"class":181,"line":2050},[262,28375,6288],{"class":429},[262,28377,28378,28380,28382,28384,28386,28388,28390],{"class":181,"line":2067},[262,28379,18264],{"class":429},[262,28381,476],{"class":377},[262,28383,1326],{"class":429},[262,28385,102],{"class":271},[262,28387,27309],{"class":429},[262,28389,27312],{"class":275},[262,28391,660],{"class":429},[262,28393,28394,28396,28398,28400,28402,28404],{"class":181,"line":2077},[262,28395,2268],{"class":377},[262,28397,515],{"class":271},[262,28399,8109],{"class":429},[262,28401,8983],{"class":377},[262,28403,27327],{"class":271},[262,28405,1160],{"class":429},[262,28407,28408,28410],{"class":181,"line":2086},[262,28409,3198],{"class":377},[262,28411,27018],{"class":429},[262,28413,28414,28416,28418,28420],{"class":181,"line":2097},[262,28415,573],{"class":377},[262,28417,27347],{"class":429},[262,28419,26955],{"class":271},[262,28421,660],{"class":429},[262,28423,28424],{"class":181,"line":2106},[262,28425,583],{"emptyLinePlaceholder":582},[262,28427,28428],{"class":181,"line":2126},[262,28429,583],{"emptyLinePlaceholder":582},[262,28431,28432,28434,28436],{"class":181,"line":2148},[262,28433,423],{"class":377},[262,28435,27381],{"class":267},[262,28437,27384],{"class":429},[262,28439,28440,28442,28444],{"class":181,"line":2165},[262,28441,26737],{"class":429},[262,28443,476],{"class":377},[262,28445,27436],{"class":429},[262,28447,28448,28450,28452,28454,28456],{"class":181,"line":2170},[262,28449,2897],{"class":429},[262,28451,27443],{"class":275},[262,28453,2903],{"class":429},[262,28455,476],{"class":377},[262,28457,5589],{"class":429},[262,28459,28460],{"class":181,"line":2181},[262,28461,28462],{"class":429},"        write_description(row.title, row.keyword)\n",[262,28464,28465,28467,28469,28471,28473,28475,28477,28479],{"class":181,"line":2186},[262,28466,10155],{"class":377},[262,28468,10158],{"class":429},[262,28470,835],{"class":377},[262,28472,27404],{"class":429},[262,28474,3618],{"class":611},[262,28476,476],{"class":377},[262,28478,3623],{"class":271},[262,28480,660],{"class":429},[262,28482,28483],{"class":181,"line":2197},[262,28484,7761],{"class":429},[262,28486,28487,28489,28491,28493,28495,28497,28499],{"class":181,"line":2202},[262,28488,2897],{"class":429},[262,28490,27457],{"class":275},[262,28492,2903],{"class":429},[262,28494,476],{"class":377},[262,28496,27464],{"class":429},[262,28498,27443],{"class":275},[262,28500,27469],{"class":429},[262,28502,28503,28505,28507,28509,28511,28513,28515,28517,28519,28521,28523,28525,28527],{"class":181,"line":2207},[262,28504,2897],{"class":429},[262,28506,27481],{"class":275},[262,28508,2903],{"class":429},[262,28510,476],{"class":377},[262,28512,27488],{"class":429},[262,28514,27491],{"class":611},[262,28516,476],{"class":377},[262,28518,27443],{"class":275},[262,28520,608],{"class":429},[262,28522,27500],{"class":611},[262,28524,476],{"class":377},[262,28526,27505],{"class":275},[262,28528,660],{"class":429},[262,28530,28531,28533,28535,28537,28539,28541,28543,28545,28547],{"class":181,"line":2224},[262,28532,2897],{"class":429},[262,28534,27519],{"class":275},[262,28536,2903],{"class":429},[262,28538,476],{"class":377},[262,28540,27464],{"class":429},[262,28542,27443],{"class":275},[262,28544,27530],{"class":429},[262,28546,27533],{"class":275},[262,28548,660],{"class":429},[262,28550,28551,28553],{"class":181,"line":2236},[262,28552,573],{"class":377},[262,28554,27542],{"class":429},[262,28556,28557],{"class":181,"line":2246},[262,28558,583],{"emptyLinePlaceholder":582},[262,28560,28561],{"class":181,"line":2265},[262,28562,583],{"emptyLinePlaceholder":582},[262,28564,28565,28567,28569,28571,28573,28575,28577,28579,28581],{"class":181,"line":2290},[262,28566,423],{"class":377},[262,28568,27557],{"class":267},[262,28570,27560],{"class":429},[262,28572,433],{"class":271},[262,28574,442],{"class":377},[262,28576,27567],{"class":275},[262,28578,1939],{"class":429},[262,28580,8471],{"class":271},[262,28582,1160],{"class":429},[262,28584,28585,28587,28589,28591,28593,28595,28597,28599,28601],{"class":181,"line":2296},[262,28586,27578],{"class":429},[262,28588,3618],{"class":611},[262,28590,476],{"class":377},[262,28592,3623],{"class":271},[262,28594,608],{"class":429},[262,28596,612],{"class":611},[262,28598,476],{"class":377},[262,28600,27593],{"class":275},[262,28602,660],{"class":429},[262,28604,28605,28607,28609,28611,28613,28615,28617,28619,28621,28623,28625,28627,28629],{"class":181,"line":9230},[262,28606,1089],{"class":271},[262,28608,602],{"class":429},[262,28610,642],{"class":377},[262,28612,27606],{"class":275},[262,28614,648],{"class":271},[262,28616,2780],{"class":429},[262,28618,654],{"class":271},[262,28620,27615],{"class":275},[262,28622,3039],{"class":271},[262,28624,15457],{"class":429},[262,28626,654],{"class":271},[262,28628,1176],{"class":275},[262,28630,660],{"class":429},[262,28632,28633,28635,28637,28639],{"class":181,"line":9241},[262,28634,3454],{"class":377},[262,28636,27464],{"class":429},[262,28638,27481],{"class":275},[262,28640,28641],{"class":429},"].any():\n",[262,28643,28644,28646,28648,28650,28652,28655,28657,28660,28663,28665,28668],{"class":181,"line":9247},[262,28645,2299],{"class":271},[262,28647,602],{"class":429},[262,28649,642],{"class":377},[262,28651,27675],{"class":275},[262,28653,28654],{"class":271},"{int",[262,28656,27637],{"class":429},[262,28658,28659],{"class":275},"'is_duplicate'",[262,28661,28662],{"class":429},"].sum())",[262,28664,654],{"class":271},[262,28666,28667],{"class":275}," duplicate(s) — rewrite for variety.\"",[262,28669,660],{"class":429},[262,28671,28673,28675,28677,28679],{"class":181,"line":28672},70,[262,28674,3454],{"class":377},[262,28676,27464],{"class":429},[262,28678,27519],{"class":275},[262,28680,28641],{"class":429},[262,28682,28684,28686,28688,28690,28692,28694,28696,28699,28701,28703,28706],{"class":181,"line":28683},71,[262,28685,2299],{"class":271},[262,28687,602],{"class":429},[262,28689,642],{"class":377},[262,28691,27675],{"class":275},[262,28693,28654],{"class":271},[262,28695,27637],{"class":429},[262,28697,28698],{"class":275},"'needs_review'",[262,28700,28662],{"class":429},[262,28702,654],{"class":271},[262,28704,28705],{"class":275}," truncated — review wording.\"",[262,28707,660],{"class":429},[262,28709,28711],{"class":181,"line":28710},72,[262,28712,583],{"emptyLinePlaceholder":582},[262,28714,28716],{"class":181,"line":28715},73,[262,28717,583],{"emptyLinePlaceholder":582},[262,28719,28721,28723,28725,28727,28729],{"class":181,"line":28720},74,[262,28722,2210],{"class":377},[262,28724,2213],{"class":271},[262,28726,2216],{"class":377},[262,28728,2219],{"class":275},[262,28730,1160],{"class":429},[262,28732,28734,28737,28739,28742,28745],{"class":181,"line":28733},75,[262,28735,28736],{"class":429},"    pages ",[262,28738,476],{"class":377},[262,28740,28741],{"class":429}," load_pages(",[262,28743,28744],{"class":275},"\"pages.csv\"",[262,28746,660],{"class":429},[262,28748,28750,28752,28754],{"class":181,"line":28749},76,[262,28751,13177],{"class":429},[262,28753,476],{"class":377},[262,28755,28756],{"class":429}," add_descriptions(pages)\n",[262,28758,28760],{"class":181,"line":28759},77,[262,28761,28762],{"class":429},"    export(result)\n",[14,28764,28765,28766,608,28769,608,28772,13390,28775,28778,28779,1374,28781,28783],{},"The output file carries your original columns plus ",[18,28767,28768],{},"meta_description",[18,28770,28771],{},"char_count",[18,28773,28774],{},"is_duplicate",[18,28776,28777],{},"needs_review",". Open it, sort by ",[18,28780,28774],{},[18,28782,28777],{},", fix the few flagged rows, and the rest is ready to ship.",[57,28785,1445],{"id":1444},[1447,28787,28788,28805,28824,28837],{},[1450,28789,28790,28795,28796,28798,28799,28801,28802,28804],{},[35,28791,28792],{},[18,28793,28794],{},"KeyError: 'OPENAI_API_KEY'"," — Python cannot find your key. The cause is almost always a missing or misnamed ",[18,28797,319],{}," file, or running the script from a different folder. Confirm ",[18,28800,319],{}," sits beside the script and contains the line ",[18,28803,8435],{},", then run again.",[1450,28806,28807,28813,28814,28817,28818,28821,28822,1363],{},[35,28808,28809,28812],{},[18,28810,28811],{},"openai.RateLimitError"," or a 429"," — you are sending requests faster than your account tier allows. Add a short ",[18,28815,28816],{},"time.sleep(0.5)"," inside the loop in ",[18,28819,28820],{},"add_descriptions",", or batch the run in chunks. The full remedy is in ",[51,28823,3379],{"href":3378},[1450,28825,28826,28829,28830,28833,28834,28836],{},[35,28827,28828],{},"Descriptions still arrive over 155 characters"," — the model occasionally ignores the count. The script already retries once and then truncates, so the exported file is always within the limit; rows that needed truncating carry a trailing ",[18,28831,28832],{},"…"," and a ",[18,28835,28777],{}," flag for a quick human pass.",[1450,28838,28839,28847,28848,1363],{},[35,28840,28841,28844,28845],{},[18,28842,28843],{},"UnicodeDecodeError"," when reading ",[18,28846,26615],{}," — your CSV was saved in a non-UTF-8 encoding (common from Excel on Windows). Re-save it as \"CSV UTF-8\", or load it with ",[18,28849,28850],{},"pd.read_csv(csv_path, dtype=str, encoding=\"latin-1\")",[57,28852,2317],{"id":2316},[2322,28854,28855,28860,28866],{},[1450,28856,28857,28859],{},[35,28858,5280],{}," when you have dozens to thousands of pages, want consistent length enforcement, and need a repeatable, auditable file you can re-run whenever titles change. The character check and dedupe flags are the real value over copy-pasting into a chat window.",[1450,28861,28862,28865],{},[35,28863,28864],{},"Use your CMS or an SEO plugin's auto-generator"," when you only have a handful of pages or your platform already fills descriptions from page content. For a five-page site, a manual pass is faster than setting up Python.",[1450,28867,28868,28871],{},[35,28869,28870],{},"Write the highest-value pages by hand"," when a page drives serious revenue. Your homepage, top product, and flagship guide deserve a human-crafted snippet; let this script handle the long list of everything else.",[14,28873,28874,28875,28877,28878,28880,28881,1363],{},"Once your descriptions are generated, the natural next move is fitting them into a wider workflow — pair this with ",[51,28876,25851],{"href":25850}," to make sure every snippet targets a keyword worth ranking for, or feed the output into your blog pipeline with ",[51,28879,3983],{"href":3982},". Back to ",[51,28882,9304],{"href":9303},[57,28884,2381],{"id":2380},[2322,28886,28887,28892,28897,28904],{},[1450,28888,28889,28891],{},[51,28890,9304],{"href":9303}," — the main guide this page belongs to.",[1450,28893,28894,28896],{},[51,28895,25851],{"href":25850}," — find the keywords worth targeting before you describe pages.",[1450,28898,28899,28903],{},[51,28900,28902],{"href":28901},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgroup-keywords-with-python-and-embeddings\u002F","Group Keywords with Python and Embeddings"," — cluster related keywords so each page targets a distinct theme.",[1450,28905,28906,28908],{},[51,28907,2919],{"href":2918}," — tidy messy input files before feeding them to the script.",[2401,28910,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":28912},[28913,28914,28915,28916,28917,28918,28919,28920,28921,28922],{"id":237,"depth":282,"text":238},{"id":26625,"depth":282,"text":26626},{"id":26662,"depth":282,"text":26663},{"id":26881,"depth":282,"text":26882},{"id":27365,"depth":282,"text":27366},{"id":17483,"depth":282,"text":17484},{"id":27830,"depth":282,"text":27831},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Generate SEO meta descriptions in bulk with Python and the OpenAI API: read a CSV, enforce the 155-character limit, dedupe, and export clean results.",[28925,28928,28931,28934,28937],{"q":28926,"a":28927},"How long should a meta description be?","Keep it at 155 characters or fewer. Google truncates longer snippets with an ellipsis, so anything past that limit risks getting cut off mid-sentence in search results. Aim for 120 to 155 characters for the fullest snippet.",{"q":28929,"a":28930},"Which OpenAI model is best for generating meta descriptions in bulk?","gpt-4o-mini is the practical default. It writes natural marketing copy, costs a fraction of a cent per description, and is fast enough to process hundreds of rows in a few minutes. Use gpt-4o only if you need higher writing quality for a small, high-value set of pages.",{"q":28932,"a":28933},"How do I stop the AI from writing descriptions that are too long?","Ask for fewer characters than your hard limit in the prompt, then verify the length in Python after every response. If a description still exceeds 155 characters, automatically retry once with a stricter instruction or truncate at the last full word.",{"q":28935,"a":28936},"Can I run this without writing a single line of the prompt myself?","Yes. The script in this guide ships a ready-made prompt template. You only supply a CSV with url, title, and keyword columns, set your API key, and run one command. You can tweak the wording later once you see the output.",{"q":28938,"a":28939},"How much does it cost to generate meta descriptions for a whole site?","With gpt-4o-mini, each description costs roughly a few hundredths of a cent. Generating descriptions for 1,000 pages typically lands well under one US dollar, since each request sends only a title and keyword and returns one short sentence.",{"name":28941,"steps":28942},"How to generate meta descriptions in bulk with Python",[28943,28946,28949,28952],{"name":28944,"text":28945},"Install dependencies and add your API key","Create a virtual environment, install pandas and the OpenAI SDK, and store your API key in a .env file.",{"name":28947,"text":28948},"Load and validate the input CSV","Read a CSV of URLs, titles, and keywords into a pandas DataFrame and check that the required columns exist.",{"name":28950,"text":28951},"Generate and length-check each description","Send each row to the OpenAI API with a length-aware prompt and verify the result is 155 characters or fewer.",{"name":28953,"text":28954},"Dedupe and export the final CSV","Remove duplicate descriptions, flag any that need a manual rewrite, and write a clean CSV ready for your CMS.",{},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgenerate-meta-descriptions-in-bulk-with-python",{"title":26437,"description":28923},"ai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgenerate-meta-descriptions-in-bulk-with-python\u002Findex","4LoElRSAMn74bK608S4vLi_QC7mmoe8hUmnORzijCIA",{"id":28961,"title":28902,"body":28962,"description":30253,"extension":2419,"faq":30254,"howto":30270,"meta":30288,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":30289,"published":2452,"seo":30290,"seoTitle":28902,"stem":30291,"__hash__":30292},"content\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgroup-keywords-with-python-and-embeddings\u002Findex.md",{"type":7,"value":28963,"toc":30241},[28964,28967,28970,28973,28982,28984,28990,28993,29033,29039,29047,29060,29070,29074,29077,29190,29200,29204,29207,29210,29434,29454,29457,29478,29482,29485,29488,29636,29663,29669,29673,29676,29920,29937,29941,29944,30055,30061,30063,30125,30127,30182,30184,30207,30215,30217,30239],[10,28965,28902],{"id":28966},"group-keywords-with-python-and-embeddings",[14,28968,28969],{},"This guide shows you how to turn a flat list of keywords into named topic groups in under fifteen minutes, using OpenAI embeddings to measure meaning, k-means clustering to gather similar keywords, and an LLM to give each group a readable label.",[14,28971,28972],{},"If you have ever exported a thousand keywords from a research tool and stared at a wall of phrases, this is the fix. Instead of sorting by hand, you let the maths group keywords that mean the same thing — even when they share no words — and then ask a model to name what each group is about. The result is a CSV where every keyword carries a group number and a plain-English label you can plan content around.",[14,28974,28975,28976,28978,28979,28981],{},"This is a focused task page under the main ",[51,28977,9304],{"href":9303}," guide. If you have not pulled a keyword list yet, the ",[51,28980,25851],{"href":25850}," guide produces exactly the kind of CSV this one expects as input.",[57,28983,238],{"id":237},[14,28985,28986,28987,28989],{},"You need Python 3.10 or newer and an OpenAI API key. If you are setting Python up for the first time, follow ",[51,28988,2482],{"href":2481}," first, then come back here.",[14,28991,28992],{},"Create a project folder, activate a virtual environment, and install the four libraries this guide uses:",[253,28994,28996],{"className":255,"code":28995,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate  # Windows: .venv\\Scripts\\activate\npip install pandas numpy scikit-learn openai\n",[18,28997,28998,29008,29016],{"__ignoreMap":258},[262,28999,29000,29002,29004,29006],{"class":181,"line":264},[262,29001,416],{"class":267},[262,29003,272],{"class":271},[262,29005,276],{"class":275},[262,29007,279],{"class":275},[262,29009,29010,29012,29014],{"class":181,"line":282},[262,29011,285],{"class":271},[262,29013,288],{"class":275},[262,29015,26589],{"class":291},[262,29017,29018,29020,29022,29024,29027,29030],{"class":181,"line":295},[262,29019,298],{"class":267},[262,29021,301],{"class":275},[262,29023,2516],{"class":275},[262,29025,29026],{"class":275}," numpy",[262,29028,29029],{"class":275}," scikit-learn",[262,29031,29032],{"class":275}," openai\n",[14,29034,29035,29036,29038],{},"Store your API key in a ",[18,29037,319],{}," file so it never ends up in your code:",[253,29040,29041],{"className":323,"code":11159,"language":325,"meta":258,"style":258},[18,29042,29043],{"__ignoreMap":258},[262,29044,29045],{"class":181,"line":264},[262,29046,11159],{},[14,29048,353,29049,356,29051,29053,29054,29056,29057,29059],{},[18,29050,319],{},[18,29052,359],{}," immediately so you never commit the key by accident. The OpenAI SDK reads ",[18,29055,21742],{}," from the environment automatically, so as long as the variable is set you do not have to pass the key in code. If you hit an authentication wall, the ",[51,29058,388],{"href":387}," guide covers the usual causes.",[14,29061,29062,29063,29066,29067,29069],{},"You also need a keyword file. For this guide, assume a ",[18,29064,29065],{},"keywords.csv"," with a single ",[18,29068,26611],{}," column, one phrase per row.",[57,29071,29073],{"id":29072},"step-1-load-your-keywords-into-a-dataframe","Step 1: Load your keywords into a DataFrame",[14,29075,29076],{},"Start by reading the CSV into pandas. A DataFrame is just a table in memory — think of a spreadsheet you can manipulate in code. Strip whitespace and drop blanks and duplicates so they do not waste embedding calls.",[253,29078,29080],{"className":414,"code":29079,"language":416,"meta":258,"style":258},"import pandas as pd\n\ndf = pd.read_csv(\"keywords.csv\")\ndf[\"keyword\"] = df[\"keyword\"].astype(str).str.strip()\ndf = df[df[\"keyword\"] != \"\"].drop_duplicates(\"keyword\").reset_index(drop=True)\n\nprint(f\"Loaded {len(df)} unique keywords\")\n",[18,29081,29082,29092,29096,29109,29132,29165,29169],{"__ignoreMap":258},[262,29083,29084,29086,29088,29090],{"class":181,"line":264},[262,29085,684],{"class":377},[262,29087,2619],{"class":429},[262,29089,697],{"class":377},[262,29091,2624],{"class":429},[262,29093,29094],{"class":181,"line":282},[262,29095,583],{"emptyLinePlaceholder":582},[262,29097,29098,29100,29102,29104,29107],{"class":181,"line":295},[262,29099,2755],{"class":429},[262,29101,476],{"class":377},[262,29103,2760],{"class":429},[262,29105,29106],{"class":275},"\"keywords.csv\"",[262,29108,660],{"class":429},[262,29110,29111,29114,29116,29118,29120,29122,29124,29127,29129],{"class":181,"line":345},[262,29112,29113],{"class":429},"df[",[262,29115,26708],{"class":275},[262,29117,2903],{"class":429},[262,29119,476],{"class":377},[262,29121,27464],{"class":429},[262,29123,26708],{"class":275},[262,29125,29126],{"class":429},"].astype(",[262,29128,433],{"class":271},[262,29130,29131],{"class":429},").str.strip()\n",[262,29133,29134,29136,29138,29141,29143,29145,29147,29149,29152,29154,29157,29159,29161,29163],{"class":181,"line":492},[262,29135,2755],{"class":429},[262,29137,476],{"class":377},[262,29139,29140],{"class":429}," df[df[",[262,29142,26708],{"class":275},[262,29144,2903],{"class":429},[262,29146,23215],{"class":377},[262,29148,6332],{"class":275},[262,29150,29151],{"class":429},"].drop_duplicates(",[262,29153,26708],{"class":275},[262,29155,29156],{"class":429},").reset_index(",[262,29158,26854],{"class":611},[262,29160,476],{"class":377},[262,29162,4974],{"class":271},[262,29164,660],{"class":429},[262,29166,29167],{"class":181,"line":503},[262,29168,583],{"emptyLinePlaceholder":582},[262,29170,29171,29173,29175,29177,29179,29181,29183,29185,29188],{"class":181,"line":521},[262,29172,637],{"class":271},[262,29174,602],{"class":429},[262,29176,642],{"class":377},[262,29178,2775],{"class":275},[262,29180,648],{"class":271},[262,29182,2780],{"class":429},[262,29184,654],{"class":271},[262,29186,29187],{"class":275}," unique keywords\"",[262,29189,660],{"class":429},[14,29191,29192,29193,29196,29197,29199],{},"Keeping the list clean here matters because every keyword you send becomes a billed embedding, and duplicates only inflate the cost without changing the result. The ",[18,29194,29195],{},"reset_index(drop=True)"," call renumbers the rows from zero after the filtering, which keeps the DataFrame aligned with the embedding array you build in the next step — if the row order and the vector order ever drift apart, every group label will be wrong, so it is worth getting right now. If your raw export is messy, the ",[51,29198,2919],{"href":2918}," guide goes deeper on the tidy-up step, including how to handle stray casing and near-duplicate phrases.",[57,29201,29203],{"id":29202},"step-2-create-embeddings-with-the-openai-sdk","Step 2: Create embeddings with the OpenAI SDK",[14,29205,29206],{},"An embedding turns each keyword into a list of numbers (a vector) that captures its meaning. Two keywords that mean similar things get similar vectors, which is what makes grouping by meaning possible.",[14,29208,29209],{},"Send the keywords in batches rather than one request per keyword — the embedding endpoint accepts many inputs at once, which is far faster and cheaper. The code below batches in chunks of 256 and stacks the results into a single NumPy array, where each row is one keyword's vector.",[253,29211,29213],{"className":414,"code":29212,"language":416,"meta":258,"style":258},"import numpy as np\nfrom openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\nEMBED_MODEL = \"text-embedding-3-small\"\n\n\ndef embed_keywords(keywords: list[str], batch_size: int = 256) -> np.ndarray:\n    vectors = []\n    for start in range(0, len(keywords), batch_size):\n        batch = keywords[start : start + batch_size]\n        response = client.embeddings.create(model=EMBED_MODEL, input=batch)\n        vectors.extend(item.embedding for item in response.data)\n    return np.array(vectors, dtype=np.float32)\n\n\nembeddings = embed_keywords(df[\"keyword\"].tolist())\nprint(f\"Embedded into a {embeddings.shape} matrix\")\n",[18,29214,29215,29225,29235,29239,29249,29258,29262,29266,29291,29300,29322,29337,29360,29374,29388,29392,29396,29411],{"__ignoreMap":258},[262,29216,29217,29219,29221,29223],{"class":181,"line":264},[262,29218,684],{"class":377},[262,29220,694],{"class":429},[262,29222,697],{"class":377},[262,29224,700],{"class":429},[262,29226,29227,29229,29231,29233],{"class":181,"line":282},[262,29228,705],{"class":377},[262,29230,720],{"class":429},[262,29232,684],{"class":377},[262,29234,725],{"class":429},[262,29236,29237],{"class":181,"line":295},[262,29238,583],{"emptyLinePlaceholder":582},[262,29240,29241,29243,29245,29247],{"class":181,"line":345},[262,29242,739],{"class":429},[262,29244,476],{"class":377},[262,29246,9578],{"class":429},[262,29248,9581],{"class":291},[262,29250,29251,29253,29255],{"class":181,"line":492},[262,29252,749],{"class":271},[262,29254,442],{"class":377},[262,29256,29257],{"class":275}," \"text-embedding-3-small\"\n",[262,29259,29260],{"class":181,"line":503},[262,29261,583],{"emptyLinePlaceholder":582},[262,29263,29264],{"class":181,"line":521},[262,29265,583],{"emptyLinePlaceholder":582},[262,29267,29268,29270,29273,29276,29278,29281,29283,29285,29288],{"class":181,"line":537},[262,29269,423],{"class":377},[262,29271,29272],{"class":267}," embed_keywords",[262,29274,29275],{"class":429},"(keywords: list[",[262,29277,433],{"class":271},[262,29279,29280],{"class":429},"], batch_size: ",[262,29282,439],{"class":271},[262,29284,442],{"class":377},[262,29286,29287],{"class":271}," 256",[262,29289,29290],{"class":429},") -> np.ndarray:\n",[262,29292,29293,29296,29298],{"class":181,"line":549},[262,29294,29295],{"class":429},"    vectors ",[262,29297,476],{"class":377},[262,29299,489],{"class":429},[262,29301,29302,29304,29306,29308,29310,29312,29314,29316,29319],{"class":181,"line":570},[262,29303,3074],{"class":377},[262,29305,509],{"class":429},[262,29307,835],{"class":377},[262,29309,3082],{"class":271},[262,29311,602],{"class":429},[262,29313,102],{"class":271},[262,29315,608],{"class":429},[262,29317,29318],{"class":271},"len",[262,29320,29321],{"class":429},"(keywords), batch_size):\n",[262,29323,29324,29327,29329,29332,29334],{"class":181,"line":579},[262,29325,29326],{"class":429},"        batch ",[262,29328,476],{"class":377},[262,29330,29331],{"class":429}," keywords[start : start ",[262,29333,531],{"class":377},[262,29335,29336],{"class":429}," batch_size]\n",[262,29338,29339,29341,29343,29345,29347,29349,29351,29353,29355,29357],{"class":181,"line":586},[262,29340,21490],{"class":429},[262,29342,476],{"class":377},[262,29344,802],{"class":429},[262,29346,805],{"class":611},[262,29348,476],{"class":377},[262,29350,749],{"class":271},[262,29352,608],{"class":429},[262,29354,814],{"class":611},[262,29356,476],{"class":377},[262,29358,29359],{"class":429},"batch)\n",[262,29361,29362,29365,29367,29369,29371],{"class":181,"line":591},[262,29363,29364],{"class":429},"        vectors.extend(item.embedding ",[262,29366,829],{"class":377},[262,29368,832],{"class":429},[262,29370,835],{"class":377},[262,29372,29373],{"class":429}," response.data)\n",[262,29375,29376,29378,29381,29383,29385],{"class":181,"line":623},[262,29377,573],{"class":377},[262,29379,29380],{"class":429}," np.array(vectors, ",[262,29382,26745],{"class":611},[262,29384,476],{"class":377},[262,29386,29387],{"class":429},"np.float32)\n",[262,29389,29390],{"class":181,"line":634},[262,29391,583],{"emptyLinePlaceholder":582},[262,29393,29394],{"class":181,"line":845},[262,29395,583],{"emptyLinePlaceholder":582},[262,29397,29398,29401,29403,29406,29408],{"class":181,"line":850},[262,29399,29400],{"class":429},"embeddings ",[262,29402,476],{"class":377},[262,29404,29405],{"class":429}," embed_keywords(df[",[262,29407,26708],{"class":275},[262,29409,29410],{"class":429},"].tolist())\n",[262,29412,29413,29415,29417,29419,29422,29424,29427,29429,29432],{"class":181,"line":864},[262,29414,637],{"class":271},[262,29416,602],{"class":429},[262,29418,642],{"class":377},[262,29420,29421],{"class":275},"\"Embedded into a ",[262,29423,3039],{"class":271},[262,29425,29426],{"class":429},"embeddings.shape",[262,29428,654],{"class":271},[262,29430,29431],{"class":275}," matrix\"",[262,29433,660],{"class":429},[14,29435,3349,29436,29438,29439,29442,29443,29446,29447,29450,29451,29453],{},[18,29437,878],{}," model returns 1,536 numbers per keyword, so a 1,000-keyword list becomes a 1,000-by-1,536 matrix. That is the input k-means needs. Notice that the order of the returned vectors matches the order you sent the keywords in, which is why the cleaning step preserved row order — ",[18,29440,29441],{},"response.data"," comes back in the same sequence as your batch. Storing the array as ",[18,29444,29445],{},"float32"," rather than the default ",[18,29448,29449],{},"float64"," halves the memory it uses with no meaningful loss of accuracy for this task. If you see a rate-limit error on a very large list, the ",[51,29452,3379],{"href":3378}," guide shows how to add backoff between batches.",[14,29455,29456],{},"Save the matrix to disk so you never have to pay for the same embeddings twice:",[253,29458,29460],{"className":414,"code":29459,"language":416,"meta":258,"style":258},"np.save(\"embeddings.npy\", embeddings)\n# Reload later with: embeddings = np.load(\"embeddings.npy\")\n",[18,29461,29462,29473],{"__ignoreMap":258},[262,29463,29464,29467,29470],{"class":181,"line":264},[262,29465,29466],{"class":429},"np.save(",[262,29468,29469],{"class":275},"\"embeddings.npy\"",[262,29471,29472],{"class":429},", embeddings)\n",[262,29474,29475],{"class":181,"line":282},[262,29476,29477],{"class":291},"# Reload later with: embeddings = np.load(\"embeddings.npy\")\n",[57,29479,29481],{"id":29480},"step-3-group-the-embeddings-with-k-means","Step 3: Group the embeddings with k-means",[14,29483,29484],{},"K-means clustering is a classic algorithm that sorts points into a fixed number of groups, where each group is built around its own centre and every point joins the nearest centre. Here the \"points\" are your keyword vectors, so keywords with similar meaning land in the same group.",[14,29486,29487],{},"You have to tell k-means how many groups to find. A solid starting guess is the square root of your keyword count. The code below normalizes the vectors first — scaling each to the same length — so that distance reflects direction (meaning) rather than magnitude, which works well for text embeddings.",[253,29489,29491],{"className":414,"code":29490,"language":416,"meta":258,"style":258},"import math\nfrom sklearn.cluster import KMeans\nfrom sklearn.preprocessing import normalize\n\nn_clusters = max(2, round(math.sqrt(len(df))))\nnormalized = normalize(embeddings)  # unit-length vectors, cosine-friendly\n\nkmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=\"auto\")\ndf[\"group_id\"] = kmeans.fit_predict(normalized)\n\nprint(df[\"group_id\"].value_counts().sort_index())\n",[18,29492,29493,29500,29512,29524,29528,29553,29566,29570,29607,29621,29625],{"__ignoreMap":258},[262,29494,29495,29497],{"class":181,"line":264},[262,29496,684],{"class":377},[262,29498,29499],{"class":429}," math\n",[262,29501,29502,29504,29507,29509],{"class":181,"line":282},[262,29503,705],{"class":377},[262,29505,29506],{"class":429}," sklearn.cluster ",[262,29508,684],{"class":377},[262,29510,29511],{"class":429}," KMeans\n",[262,29513,29514,29516,29519,29521],{"class":181,"line":295},[262,29515,705],{"class":377},[262,29517,29518],{"class":429}," sklearn.preprocessing ",[262,29520,684],{"class":377},[262,29522,29523],{"class":429}," normalize\n",[262,29525,29526],{"class":181,"line":345},[262,29527,583],{"emptyLinePlaceholder":582},[262,29529,29530,29533,29535,29537,29539,29541,29543,29545,29548,29550],{"class":181,"line":492},[262,29531,29532],{"class":429},"n_clusters ",[262,29534,476],{"class":377},[262,29536,13728],{"class":271},[262,29538,602],{"class":429},[262,29540,109],{"class":271},[262,29542,608],{"class":429},[262,29544,13754],{"class":271},[262,29546,29547],{"class":429},"(math.sqrt(",[262,29549,29318],{"class":271},[262,29551,29552],{"class":429},"(df))))\n",[262,29554,29555,29558,29560,29563],{"class":181,"line":503},[262,29556,29557],{"class":429},"normalized ",[262,29559,476],{"class":377},[262,29561,29562],{"class":429}," normalize(embeddings)  ",[262,29564,29565],{"class":291},"# unit-length vectors, cosine-friendly\n",[262,29567,29568],{"class":181,"line":521},[262,29569,583],{"emptyLinePlaceholder":582},[262,29571,29572,29575,29577,29580,29583,29585,29588,29591,29593,29595,29597,29600,29602,29605],{"class":181,"line":537},[262,29573,29574],{"class":429},"kmeans ",[262,29576,476],{"class":377},[262,29578,29579],{"class":429}," KMeans(",[262,29581,29582],{"class":611},"n_clusters",[262,29584,476],{"class":377},[262,29586,29587],{"class":429},"n_clusters, ",[262,29589,29590],{"class":611},"random_state",[262,29592,476],{"class":377},[262,29594,5508],{"class":271},[262,29596,608],{"class":429},[262,29598,29599],{"class":611},"n_init",[262,29601,476],{"class":377},[262,29603,29604],{"class":275},"\"auto\"",[262,29606,660],{"class":429},[262,29608,29609,29611,29614,29616,29618],{"class":181,"line":549},[262,29610,29113],{"class":429},[262,29612,29613],{"class":275},"\"group_id\"",[262,29615,2903],{"class":429},[262,29617,476],{"class":377},[262,29619,29620],{"class":429}," kmeans.fit_predict(normalized)\n",[262,29622,29623],{"class":181,"line":570},[262,29624,583],{"emptyLinePlaceholder":582},[262,29626,29627,29629,29631,29633],{"class":181,"line":579},[262,29628,637],{"class":271},[262,29630,27637],{"class":429},[262,29632,29613],{"class":275},[262,29634,29635],{"class":429},"].value_counts().sort_index())\n",[14,29637,29638,29639,29642,29643,29646,29647,29650,29651,29654,29655,29658,29659,29662],{},"After ",[18,29640,29641],{},"fit_predict",", every row in ",[18,29644,29645],{},"df"," has a ",[18,29648,29649],{},"group_id"," — an integer from 0 to ",[18,29652,29653],{},"n_clusters - 1",". Keywords sharing a number belong together. The ",[18,29656,29657],{},"random_state=42"," line makes the result reproducible, so re-running the script gives the same groups; change the number or remove it if you want to see how stable the grouping is across runs. The ",[18,29660,29661],{},"n_init=\"auto\""," setting tells scikit-learn to try several starting positions and keep the best one, which guards against k-means settling on a poor arrangement by chance.",[14,29664,29665,29666,29668],{},"Print the value counts to sanity-check the sizes: a healthy result has groups of roughly comparable size. If one group swallows most of your keywords while the rest are tiny, that usually means ",[18,29667,29582],{}," is too low for how varied your list is — raise it and re-run. Because the embeddings are already saved to disk, this clustering step is fast and free to repeat as many times as you like, so treat the group count as a dial to experiment with rather than a value you must get right on the first try.",[57,29670,29672],{"id":29671},"step-4-label-each-group-with-an-llm","Step 4: Label each group with an LLM",[14,29674,29675],{},"A group number is not actionable on its own. To make each group readable, send a sample of its keywords to a chat model and ask for a short label. Sampling a handful of keywords per group keeps the prompt small and the cost low while still giving the model enough to work with.",[253,29677,29679],{"className":414,"code":29678,"language":416,"meta":258,"style":258},"import json\n\n\ndef label_group(keywords: list[str]) -> str:\n    sample = keywords[:25]\n    prompt = (\n        \"These keywords belong to one topic group. Reply with a JSON object \"\n        '{\"label\": \"...\"} where label is a concise 2-4 word name for the group.\\n\\n'\n        + \"\\n\".join(sample)\n    )\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        temperature=0,\n        response_format={\"type\": \"json_object\"},\n    )\n    return json.loads(response.choices[0].message.content)[\"label\"]\n\n\nlabels = {}\nfor group_id, rows in df.groupby(\"group_id\"):\n    labels[group_id] = label_group(rows[\"keyword\"].tolist())\n\ndf[\"group_label\"] = df[\"group_id\"].map(labels)\n",[18,29680,29681,29687,29691,29695,29712,29727,29735,29740,29750,29763,29767,29775,29785,29805,29815,29831,29835,29850,29854,29858,29868,29884,29898,29902],{"__ignoreMap":258},[262,29682,29683,29685],{"class":181,"line":264},[262,29684,684],{"class":377},[262,29686,5766],{"class":429},[262,29688,29689],{"class":181,"line":282},[262,29690,583],{"emptyLinePlaceholder":582},[262,29692,29693],{"class":181,"line":295},[262,29694,583],{"emptyLinePlaceholder":582},[262,29696,29697,29699,29702,29704,29706,29708,29710],{"class":181,"line":345},[262,29698,423],{"class":377},[262,29700,29701],{"class":267}," label_group",[262,29703,29275],{"class":429},[262,29705,433],{"class":271},[262,29707,13681],{"class":429},[262,29709,433],{"class":271},[262,29711,1160],{"class":429},[262,29713,29714,29717,29719,29722,29725],{"class":181,"line":492},[262,29715,29716],{"class":429},"    sample ",[262,29718,476],{"class":377},[262,29720,29721],{"class":429}," keywords[:",[262,29723,29724],{"class":271},"25",[262,29726,957],{"class":429},[262,29728,29729,29731,29733],{"class":181,"line":503},[262,29730,18006],{"class":429},[262,29732,476],{"class":377},[262,29734,984],{"class":429},[262,29736,29737],{"class":181,"line":521},[262,29738,29739],{"class":275},"        \"These keywords belong to one topic group. Reply with a JSON object \"\n",[262,29741,29742,29745,29747],{"class":181,"line":537},[262,29743,29744],{"class":275},"        '{\"label\": \"...\"} where label is a concise 2-4 word name for the group.",[262,29746,1173],{"class":271},[262,29748,29749],{"class":275},"'\n",[262,29751,29752,29754,29756,29758,29760],{"class":181,"line":549},[262,29753,6329],{"class":377},[262,29755,1170],{"class":275},[262,29757,2137],{"class":271},[262,29759,1176],{"class":275},[262,29761,29762],{"class":429},".join(sample)\n",[262,29764,29765],{"class":181,"line":570},[262,29766,1011],{"class":429},[262,29768,29769,29771,29773],{"class":181,"line":579},[262,29770,1184],{"class":429},[262,29772,476],{"class":377},[262,29774,1189],{"class":429},[262,29776,29777,29779,29781,29783],{"class":181,"line":586},[262,29778,1194],{"class":611},[262,29780,476],{"class":377},[262,29782,1207],{"class":275},[262,29784,1315],{"class":429},[262,29786,29787,29789,29791,29793,29795,29797,29799,29801,29803],{"class":181,"line":591},[262,29788,1215],{"class":611},[262,29790,476],{"class":377},[262,29792,8856],{"class":429},[262,29794,1228],{"class":275},[262,29796,1231],{"class":429},[262,29798,1291],{"class":275},[262,29800,608],{"class":429},[262,29802,1239],{"class":275},[262,29804,18141],{"class":429},[262,29806,29807,29809,29811,29813],{"class":181,"line":623},[262,29808,1308],{"class":611},[262,29810,476],{"class":377},[262,29812,102],{"class":271},[262,29814,1315],{"class":429},[262,29816,29817,29819,29821,29823,29825,29827,29829],{"class":181,"line":634},[262,29818,6018],{"class":611},[262,29820,476],{"class":377},[262,29822,3039],{"class":429},[262,29824,6025],{"class":275},[262,29826,1231],{"class":429},[262,29828,6030],{"class":275},[262,29830,3143],{"class":429},[262,29832,29833],{"class":181,"line":845},[262,29834,1011],{"class":429},[262,29836,29837,29839,29841,29843,29845,29848],{"class":181,"line":850},[262,29838,573],{"class":377},[262,29840,6043],{"class":429},[262,29842,102],{"class":271},[262,29844,19120],{"class":429},[262,29846,29847],{"class":275},"\"label\"",[262,29849,957],{"class":429},[262,29851,29852],{"class":181,"line":864},[262,29853,583],{"emptyLinePlaceholder":582},[262,29855,29856],{"class":181,"line":1683},[262,29857,583],{"emptyLinePlaceholder":582},[262,29859,29860,29863,29865],{"class":181,"line":1688},[262,29861,29862],{"class":429},"labels ",[262,29864,476],{"class":377},[262,29866,29867],{"class":429}," {}\n",[262,29869,29870,29872,29875,29877,29880,29882],{"class":181,"line":1693},[262,29871,829],{"class":377},[262,29873,29874],{"class":429}," group_id, rows ",[262,29876,835],{"class":377},[262,29878,29879],{"class":429}," df.groupby(",[262,29881,29613],{"class":275},[262,29883,8192],{"class":429},[262,29885,29886,29889,29891,29894,29896],{"class":181,"line":1728},[262,29887,29888],{"class":429},"    labels[group_id] ",[262,29890,476],{"class":377},[262,29892,29893],{"class":429}," label_group(rows[",[262,29895,26708],{"class":275},[262,29897,29410],{"class":429},[262,29899,29900],{"class":181,"line":1737},[262,29901,583],{"emptyLinePlaceholder":582},[262,29903,29904,29906,29909,29911,29913,29915,29917],{"class":181,"line":1751},[262,29905,29113],{"class":429},[262,29907,29908],{"class":275},"\"group_label\"",[262,29910,2903],{"class":429},[262,29912,476],{"class":377},[262,29914,27464],{"class":429},[262,29916,29613],{"class":275},[262,29918,29919],{"class":429},"].map(labels)\n",[14,29921,18189,29922,29924,29925,29927,29928,29931,29932,29934,29935,1363],{},[18,29923,1357],{}," keeps labels stable and literal, and ",[18,29926,6878],{}," forces clean JSON so you do not have to parse loose text. The ",[18,29929,29930],{},"groupby(\"group_id\")"," loop runs one cheap chat call per group, not one per keyword, so even a list split into thirty groups costs only thirty small requests. Capping the sample at 25 keywords keeps each prompt short while still giving the model a clear picture of the group — sending all of a large group's keywords would cost more without improving the label. If a response ever fails to parse, the ",[51,29933,6114],{"href":6113}," guide explains how to harden this. For tips on writing prompts that return tidy, structured output, see ",[51,29936,1362],{"href":1361},[57,29938,29940],{"id":29939},"step-5-save-the-labelled-result","Step 5: Save the labelled result",[14,29942,29943],{},"Finally, write a CSV sorted by group so related keywords sit next to each other. This is the file you actually plan around.",[253,29945,29947],{"className":414,"code":29946,"language":416,"meta":258,"style":258},"df = df.sort_values([\"group_id\", \"keyword\"]).reset_index(drop=True)\ndf[[\"group_id\", \"group_label\", \"keyword\"]].to_csv(\n    \"grouped_keywords.csv\", index=False, encoding=\"utf-8-sig\"\n)\nprint(f\"Wrote {len(df)} keywords in {df['group_id'].nunique()} groups\")\n",[18,29948,29949,29975,29993,30015,30019],{"__ignoreMap":258},[262,29950,29951,29953,29955,29958,29960,29962,29964,29967,29969,29971,29973],{"class":181,"line":264},[262,29952,2755],{"class":429},[262,29954,476],{"class":377},[262,29956,29957],{"class":429}," df.sort_values([",[262,29959,29613],{"class":275},[262,29961,608],{"class":429},[262,29963,26708],{"class":275},[262,29965,29966],{"class":429},"]).reset_index(",[262,29968,26854],{"class":611},[262,29970,476],{"class":377},[262,29972,4974],{"class":271},[262,29974,660],{"class":429},[262,29976,29977,29980,29982,29984,29986,29988,29990],{"class":181,"line":282},[262,29978,29979],{"class":429},"df[[",[262,29981,29613],{"class":275},[262,29983,608],{"class":429},[262,29985,29908],{"class":275},[262,29987,608],{"class":429},[262,29989,26708],{"class":275},[262,29991,29992],{"class":429},"]].to_csv(\n",[262,29994,29995,29998,30000,30002,30004,30006,30008,30010,30012],{"class":181,"line":295},[262,29996,29997],{"class":275},"    \"grouped_keywords.csv\"",[262,29999,608],{"class":429},[262,30001,3618],{"class":611},[262,30003,476],{"class":377},[262,30005,3623],{"class":271},[262,30007,608],{"class":429},[262,30009,612],{"class":611},[262,30011,476],{"class":377},[262,30013,30014],{"class":275},"\"utf-8-sig\"\n",[262,30016,30017],{"class":181,"line":345},[262,30018,660],{"class":429},[262,30020,30021,30023,30025,30027,30029,30031,30033,30035,30038,30040,30042,30045,30048,30050,30053],{"class":181,"line":492},[262,30022,637],{"class":271},[262,30024,602],{"class":429},[262,30026,642],{"class":377},[262,30028,27606],{"class":275},[262,30030,648],{"class":271},[262,30032,2780],{"class":429},[262,30034,654],{"class":271},[262,30036,30037],{"class":275}," keywords in ",[262,30039,3039],{"class":271},[262,30041,29113],{"class":429},[262,30043,30044],{"class":275},"'group_id'",[262,30046,30047],{"class":429},"].nunique()",[262,30049,654],{"class":271},[262,30051,30052],{"class":275}," groups\"",[262,30054,660],{"class":429},[14,30056,3772,30057,30060],{},[18,30058,30059],{},"grouped_keywords.csv"," and you will see every keyword tagged with a number and a name like \"Beginner Yoga Poses\" or \"Home Espresso Machines.\" That is your raw list turned into a map you can build content against.",[57,30062,1367],{"id":1366},[1379,30064,30065,30075],{},[1382,30066,30067],{},[1385,30068,30069,30071,30073],{},[1388,30070,1390],{},[1388,30072,1393],{},[1388,30074,1396],{},[1398,30076,30077,30097,30111],{},[1385,30078,30079,30083,30087],{},[1403,30080,30081],{},[18,30082,749],{},[1403,30084,30085],{},[18,30086,878],{},[1403,30088,30089,30090,30093,30094,30096],{},"Which model turns keywords into vectors. The ",[18,30091,30092],{},"-small"," model is cheap and accurate enough for grouping; ",[18,30095,1440],{}," is pricier and slightly sharper.",[1385,30098,30099,30103,30108],{},[1403,30100,30101],{},[18,30102,29582],{},[1403,30104,30105],{},[18,30106,30107],{},"round(sqrt(n))",[1403,30109,30110],{},"How many groups k-means creates. Raise it for finer, narrower groups; lower it for fewer, broader ones.",[1385,30112,30113,30119,30122],{},[1403,30114,30115,30116],{},"metric \u002F ",[18,30117,30118],{},"normalize",[1403,30120,30121],{},"unit-length (cosine)",[1403,30123,30124],{},"Normalizing vectors makes distance reflect meaning rather than length, which is the right choice for text embeddings.",[57,30126,1445],{"id":1444},[1447,30128,30129,30150,30161,30173],{},[1450,30130,30131,30137,30138,30140,30141,30144,30145,30147,30148,1363],{},[35,30132,30133,30136],{},[18,30134,30135],{},"AuthenticationError"," on the first embedding call."," The SDK could not find a valid ",[18,30139,21742],{},". Confirm the variable is exported in your current shell (",[18,30142,30143],{},"echo $OPENAI_API_KEY",") and that your ",[18,30146,319],{}," is loaded. See ",[51,30149,388],{"href":387},[1450,30151,30152,30157,30158,30160],{},[35,30153,30154,1363],{},[18,30155,30156],{},"ValueError: n_samples=... should be >= n_clusters"," You asked for more groups than you have keywords. Lower ",[18,30159,29582],{},", or check that your CSV actually loaded rows — an empty or near-empty list triggers this.",[1450,30162,30163,30166,30167,30169,30170,30172],{},[35,30164,30165],{},"One group contains almost everything."," Your keywords may be very similar, or ",[18,30168,29582],{}," is too low. Raise ",[18,30171,29582],{}," and re-run; the embeddings are saved, so only the fast clustering step repeats.",[1450,30174,30175,30178,30179,30181],{},[35,30176,30177],{},"Labels come back as messy text instead of JSON."," Make sure you kept ",[18,30180,6878],{}," and that the word \"JSON\" appears in the prompt — OpenAI requires both for guaranteed JSON output.",[57,30183,2317],{"id":2316},[2322,30185,30186,30192,30198],{},[1450,30187,30188,30191],{},[35,30189,30190],{},"Use embeddings + k-means when"," you have a large, mixed keyword list and want groups based on meaning, including synonyms and phrasing the keywords do not literally share. This is the most robust option for messy real-world exports.",[1450,30193,30194,30197],{},[35,30195,30196],{},"Use simple word or stem matching when"," your list is small and the keywords are tidy variations of the same root, and you want zero API cost. It is fast but blind to synonyms, so \"trainers\" and \"sneakers\" stay apart.",[1450,30199,30200,30203,30204,30206],{},[35,30201,30202],{},"Use rule-based intent tagging when"," you care about buyer intent (informational vs. transactional) rather than topic. Grouping by meaning and tagging by intent answer different questions — the ",[51,30205,25851],{"href":25850}," guide does the intent side.",[14,30208,30209,30210,30212,30213,1363],{},"Once your keywords are grouped, a natural next step is writing the pages: feed each group's label into ",[51,30211,26437],{"href":26436}," to draft snippets at scale. Back to ",[51,30214,9304],{"href":9303},[57,30216,2381],{"id":2380},[2322,30218,30219,30224,30229,30234],{},[1450,30220,30221,30223],{},[51,30222,9304],{"href":9303}," — the main guide this task sits under.",[1450,30225,30226,30228],{},[51,30227,25851],{"href":25850}," — produce the keyword list this guide groups.",[1450,30230,30231,30233],{},[51,30232,26437],{"href":26436}," — turn each group into ready-to-use snippets.",[1450,30235,30236,30238],{},[51,30237,2919],{"href":2918}," — tidy a messy keyword export before embedding it.",[2401,30240,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":30242},[30243,30244,30245,30246,30247,30248,30249,30250,30251,30252],{"id":237,"depth":282,"text":238},{"id":29072,"depth":282,"text":29073},{"id":29202,"depth":282,"text":29203},{"id":29480,"depth":282,"text":29481},{"id":29671,"depth":282,"text":29672},{"id":29939,"depth":282,"text":29940},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Turn a flat keyword list into labelled topic groups using OpenAI embeddings, k-means clustering with scikit-learn, and an LLM to name each group.",[30255,30258,30261,30264,30267],{"q":30256,"a":30257},"What is a keyword embedding?","An embedding is a list of numbers that captures the meaning of a phrase. Keywords with similar meaning get similar number lists, so you can measure how close two keywords are even when they share no words.",{"q":30259,"a":30260},"Do I need a paid OpenAI account to group keywords this way?","Yes, embeddings are billed per token, but the cost is tiny. The text-embedding-3-small model prices a few thousand keywords at well under one US dollar, so a single run is effectively pocket change.",{"q":30262,"a":30263},"How many groups should I ask k-means for?","Start with roughly the square root of your keyword count, then adjust. If groups feel too broad, raise the number; if you see near-duplicate groups, lower it. There is no single correct value.",{"q":30265,"a":30266},"Why use embeddings instead of just matching shared words?","Word matching misses synonyms and intent. Embeddings place 'cheap running shoes' and 'affordable trainers' close together even though they share no words, which word matching cannot do.",{"q":30268,"a":30269},"Can I run this without an internet connection?","The embedding step needs the OpenAI API, so it requires internet. Once you have saved the embeddings to disk, the k-means clustering itself runs fully offline on your machine.",{"name":30271,"steps":30272},"How to group keywords with Python and embeddings",[30273,30276,30279,30282,30285],{"name":30274,"text":30275},"Install the tools and load your keywords","Set up a virtual environment, install the libraries, and read your keyword list into a pandas DataFrame.",{"name":30277,"text":30278},"Create embeddings with the OpenAI SDK","Send your keywords to the embedding model in batches and store the returned vectors as a NumPy array.",{"name":30280,"text":30281},"Group the embeddings with k-means","Run scikit-learn's KMeans on the vectors to assign every keyword to a numbered group.",{"name":30283,"text":30284},"Label each group with an LLM","Send the keywords from each group to a chat model and ask for a short, human-readable group name.",{"name":30286,"text":30287},"Save the labelled result","Merge the group numbers and names back onto your DataFrame and write a CSV you can act on.",{},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgroup-keywords-with-python-and-embeddings",{"title":28902,"description":30253},"ai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fgroup-keywords-with-python-and-embeddings\u002Findex","rkn3jpEDHU_maMQxNWyILE69Xu6-Q2IQh7iyat7t4cE",{"id":30294,"title":9304,"body":30295,"description":32810,"extension":2419,"faq":32811,"howto":32827,"meta":32841,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":32842,"published":9388,"seo":32843,"seoTitle":9304,"stem":32844,"__hash__":32845},"content\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Findex.md",{"type":7,"value":30296,"toc":32798},[30297,30300,30311,30314,30326,30401,30403,30417,30423,30465,30474,30482,30493,30507,30521,30527,30531,30542,30827,30861,30874,30878,30899,30905,31148,31156,31167,31171,31178,31188,31383,31400,31409,31413,31416,31684,31687,31693,31695,31847,31849,31996,32000,32024,32692,32698,32719,32721,32724,32763,32767,32769,32796],[10,30298,9304],{"id":30299},"seo-keyword-research-with-python",[14,30301,30302,30303,30306,30307,30310],{},"Manual keyword research falls apart the moment your spreadsheet passes a few hundred rows. You scroll, you eyeball, you guess which terms belong together, and you miss obvious overlaps because two phrases happen to use different words for the same thing. The classic failure mode is duplication: you write one page about ",[18,30304,30305],{},"\"cheap flights\""," and a second about ",[18,30308,30309],{},"\"budget airfare\""," without realising they serve the same searcher, so the two pages compete with each other in Google instead of one strong page winning. A Python workflow fixes this: it reads your keywords, measures how similar they are in meaning, groups the related ones automatically, and ranks the groups so you know what to write first.",[14,30312,30313],{},"This guide is for creators, marketers, and founders who can run a Python script but are not full-time developers. By the end you will have a small program that takes a flat list of search terms and returns tidy groups of related keywords, each scored by how worthwhile it is to target. You do not need a degree in machine learning, and you do not need an expensive all-in-one SEO platform. You need a list of keywords, an OpenAI API key, and about thirty minutes.",[14,30315,30316,30317,30320,30321,30323,30324,1363],{},"We will build the pipeline in four steps: fetch your keywords, turn each one into numbers that capture its meaning (an ",[27,30318,30319],{},"embedding","), group the related ones with a standard clustering algorithm, and finally prioritise the groups. Each step is a small, self-contained function, so you can run them one at a time in a notebook while you learn, then chain them once you trust the output — the same four functions become the worked example at the end of the page. This page sits under the ",[51,30322,5413],{"href":5412}," hub, and it feeds directly into the writing guides over in ",[51,30325,3991],{"href":3990},[76,30327,30329,30398],{"className":30328},[79],[81,30330,90,30335,90,30338,90,30341,30348,90,30357,90,30359,90,30363,90,30367,90,30369,90,30372,90,30375,90,30377,90,30380,90,30383,90,30385,90,30388,90,30391,90,30394,90,30396],{"viewBox":30331,"role":84,"ariaLabelledBy":30332,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1080 280",[30333,30334],"pipeTitle","pipeDesc",[92,30336,30337],{"id":30333},"Keyword research pipeline",[96,30339,30340],{"id":30334},"A four-stage flow: fetch keywords, embed them, group related ones, then prioritise the groups for writing.",[5548,30342,5550,30343,90],{},[5552,30344,5558,30346,5550],{"id":30345,"viewBox":7161,"refX":7162,"refY":222,"markerWidth":7163,"markerHeight":7163,"orient":7164},"pipeArrow",[216,30347],{"d":7167,"fill":169},[111,30349,30351,30354],{"x":16427,"y":30350,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"22",[175,30352,30353],{"x":16427},"\nPlain list in →\n",[175,30355,30356],{"x":16427,"dy":177},"\ntopic pages out\n",[100,30358],{"x":102,"y":11749,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,30360,30362],{"x":113,"y":30361,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"118","1. Fetch",[111,30364,30366],{"x":113,"y":30365,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"136","CSV or SERP API",[100,30368],{"x":129,"y":11749,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,30370,30371],{"x":133,"y":30361,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"2. Embed",[111,30373,30374],{"x":133,"y":30365,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"OpenAI API",[100,30376],{"x":158,"y":11749,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,30378,30379],{"x":161,"y":30361,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"3. Group",[111,30381,30382],{"x":161,"y":30365,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"k-means",[100,30384],{"x":168,"y":11749,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,30386,30387],{"x":172,"y":30361,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"4. Prioritise",[111,30389,30390],{"x":172,"y":30365,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"score & rank",[181,30392],{"x1":104,"y1":7101,"x2":129,"y2":7101,"stroke":169,"strokeWidth":109,"markerEnd":30393},"url(#pipeArrow)",[181,30395],{"x1":198,"y1":7101,"x2":158,"y2":7101,"stroke":169,"strokeWidth":109,"markerEnd":30393},[181,30397],{"x1":205,"y1":7101,"x2":168,"y2":7101,"stroke":169,"strokeWidth":109,"markerEnd":30393},[232,30399,30400],{},"The whole workflow: a flat list goes in, scored groups of related keywords come out.",[57,30402,238],{"id":237},[14,30404,19950,30405,30407,30408,407,30412,30416],{},[18,30406,17782],{},". If it prints anything lower, follow ",[51,30409,30411],{"href":30410},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-on-windows\u002F","How to Install Python for AI on Windows",[51,30413,30415],{"href":30414},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-projects-on-mac\u002F","How to Install Python for AI Projects on Mac"," first.",[14,30418,30419,30420,30422],{},"Create an isolated workspace so these libraries do not collide with other projects. If virtual environments are new to you, ",[51,30421,2482],{"href":2481}," walks through it.",[253,30424,30426],{"className":255,"code":30425,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate      # Windows: .venv\\Scripts\\activate\npip install openai httpx pandas scikit-learn numpy python-dotenv\n",[18,30427,30428,30438,30447],{"__ignoreMap":258},[262,30429,30430,30432,30434,30436],{"class":181,"line":264},[262,30431,416],{"class":267},[262,30433,272],{"class":271},[262,30435,276],{"class":275},[262,30437,279],{"class":275},[262,30439,30440,30442,30444],{"class":181,"line":282},[262,30441,285],{"class":271},[262,30443,288],{"class":275},[262,30445,30446],{"class":291},"      # Windows: .venv\\Scripts\\activate\n",[262,30448,30449,30451,30453,30455,30457,30459,30461,30463],{"class":181,"line":295},[262,30450,298],{"class":267},[262,30452,301],{"class":275},[262,30454,2519],{"class":275},[262,30456,5440],{"class":275},[262,30458,2516],{"class":275},[262,30460,29029],{"class":275},[262,30462,29026],{"class":275},[262,30464,2522],{"class":275},[14,30466,30467,30468,30470,30471,30473],{},"You will need an OpenAI API key. Create one in your OpenAI account dashboard, then store it in a file named ",[18,30469,319],{}," in your project folder. A ",[18,30472,319],{}," file keeps secrets out of your code so you never paste a key into a script by accident.",[253,30475,30476],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,30477,30478],{"__ignoreMap":258},[262,30479,30480],{"class":181,"line":264},[262,30481,337],{},[14,30483,353,30484,356,30486,30488,30489,30492],{},[18,30485,319],{},[18,30487,359],{}," immediately so the key is never committed to version control. One careless ",[18,30490,30491],{},"git push"," with a live key in it is the single most common way beginners leak credentials.",[253,30494,30495],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,30496,30497],{"__ignoreMap":258},[262,30498,30499,30501,30503,30505],{"class":181,"line":264},[262,30500,371],{"class":271},[262,30502,374],{"class":275},[262,30504,378],{"class":377},[262,30506,381],{"class":275},[14,30508,30509,30510,1374,30513,30516,30517,30520],{},"That is the entire setup. Everything below assumes this environment is active, which you can confirm at any time: the shell prompt should show ",[18,30511,30512],{},"(.venv)",[18,30514,30515],{},"pip show openai"," should print a version number rather than \"not found\". If you close your terminal and come back later, re-run ",[18,30518,30519],{},"source .venv\u002Fbin\u002Factivate"," first — the activation only lasts for the current session.",[14,30522,30523,30524,30526],{},"One cost note before we start, because beginners often worry about surprise bills. Embedding text is one of the cheapest things you can do with the OpenAI API: ",[18,30525,878],{}," costs roughly two cents per million tokens, and an average keyword is only a few tokens long, so embedding ten thousand keywords costs a fraction of a cent. The grouping and prioritising steps run entirely on your own machine with scikit-learn and cost nothing. You can run this whole workflow dozens of times while you experiment without it showing up meaningfully on your bill.",[57,30528,30530],{"id":30529},"step-1-fetch-and-clean-your-keyword-list","Step 1: Fetch and clean your keyword list",[14,30532,30533,30534,30536,30537,17816,30539,30541],{},"Every workflow starts with a flat list of search terms. The easiest source is a CSV export from Google Search Console, a free keyword tool, or a spreadsheet you already keep. If you want to pull data from a SERP API instead, the ",[18,30535,5450],{}," pattern below works for any provider that returns JSON. We prefer ",[18,30538,5450],{},[18,30540,9433],{}," library because it has the same friendly API plus built-in timeouts and modern HTTP handling.",[253,30543,30545],{"className":414,"code":30544,"language":416,"meta":258,"style":258},"import httpx\nimport pandas as pd\n\n\ndef load_keywords_from_csv(path: str) -> pd.DataFrame:\n    \"\"\"Load keywords from a CSV with at least a 'keyword' column.\"\"\"\n    df = pd.read_csv(path)\n    df[\"keyword\"] = df[\"keyword\"].astype(str).str.strip().str.lower()\n    df = df.drop_duplicates(subset=\"keyword\")\n    df = df[df[\"keyword\"].str.len() > 2].reset_index(drop=True)\n    return df\n\n\ndef fetch_keywords_from_api(seed: str, api_key: str) -> pd.DataFrame:\n    \"\"\"Example SERP-API call. Swap the URL and field names for your provider.\"\"\"\n    response = httpx.get(\n        \"https:\u002F\u002Fapi.your-serp-provider.com\u002Fv1\u002Fkeywords\",\n        params={\"q\": seed, \"api_key\": api_key, \"limit\": 100},\n        timeout=30.0,\n    )\n    response.raise_for_status()\n    rows = response.json()[\"data\"]\n    return pd.DataFrame(rows)\n\n\nkeywords_df = load_keywords_from_csv(\"keywords.csv\")\nprint(f\"Loaded {len(keywords_df)} unique keywords\")\n",[18,30546,30547,30553,30563,30567,30571,30584,30589,30598,30619,30636,30664,30670,30674,30678,30697,30702,30711,30718,30746,30756,30760,30764,30777,30784,30788,30792,30806],{"__ignoreMap":258},[262,30548,30549,30551],{"class":181,"line":264},[262,30550,684],{"class":377},[262,30552,6526],{"class":429},[262,30554,30555,30557,30559,30561],{"class":181,"line":282},[262,30556,684],{"class":377},[262,30558,2619],{"class":429},[262,30560,697],{"class":377},[262,30562,2624],{"class":429},[262,30564,30565],{"class":181,"line":295},[262,30566,583],{"emptyLinePlaceholder":582},[262,30568,30569],{"class":181,"line":345},[262,30570,583],{"emptyLinePlaceholder":582},[262,30572,30573,30575,30578,30580,30582],{"class":181,"line":492},[262,30574,423],{"class":377},[262,30576,30577],{"class":267}," load_keywords_from_csv",[262,30579,15950],{"class":429},[262,30581,433],{"class":271},[262,30583,26732],{"class":429},[262,30585,30586],{"class":181,"line":503},[262,30587,30588],{"class":275},"    \"\"\"Load keywords from a CSV with at least a 'keyword' column.\"\"\"\n",[262,30590,30591,30593,30595],{"class":181,"line":521},[262,30592,26737],{"class":429},[262,30594,476],{"class":377},[262,30596,30597],{"class":429}," pd.read_csv(path)\n",[262,30599,30600,30602,30604,30606,30608,30610,30612,30614,30616],{"class":181,"line":537},[262,30601,2897],{"class":429},[262,30603,26708],{"class":275},[262,30605,2903],{"class":429},[262,30607,476],{"class":377},[262,30609,27464],{"class":429},[262,30611,26708],{"class":275},[262,30613,29126],{"class":429},[262,30615,433],{"class":271},[262,30617,30618],{"class":429},").str.strip().str.lower()\n",[262,30620,30621,30623,30625,30628,30630,30632,30634],{"class":181,"line":549},[262,30622,26737],{"class":429},[262,30624,476],{"class":377},[262,30626,30627],{"class":429}," df.drop_duplicates(",[262,30629,27491],{"class":611},[262,30631,476],{"class":377},[262,30633,26708],{"class":275},[262,30635,660],{"class":429},[262,30637,30638,30640,30642,30644,30646,30649,30651,30653,30656,30658,30660,30662],{"class":181,"line":570},[262,30639,26737],{"class":429},[262,30641,476],{"class":377},[262,30643,29140],{"class":429},[262,30645,26708],{"class":275},[262,30647,30648],{"class":429},"].str.len() ",[262,30650,8086],{"class":377},[262,30652,3232],{"class":271},[262,30654,30655],{"class":429},"].reset_index(",[262,30657,26854],{"class":611},[262,30659,476],{"class":377},[262,30661,4974],{"class":271},[262,30663,660],{"class":429},[262,30665,30666,30668],{"class":181,"line":579},[262,30667,573],{"class":377},[262,30669,27542],{"class":429},[262,30671,30672],{"class":181,"line":586},[262,30673,583],{"emptyLinePlaceholder":582},[262,30675,30676],{"class":181,"line":591},[262,30677,583],{"emptyLinePlaceholder":582},[262,30679,30680,30682,30685,30688,30690,30693,30695],{"class":181,"line":623},[262,30681,423],{"class":377},[262,30683,30684],{"class":267}," fetch_keywords_from_api",[262,30686,30687],{"class":429},"(seed: ",[262,30689,433],{"class":271},[262,30691,30692],{"class":429},", api_key: ",[262,30694,433],{"class":271},[262,30696,26732],{"class":429},[262,30698,30699],{"class":181,"line":634},[262,30700,30701],{"class":275},"    \"\"\"Example SERP-API call. Swap the URL and field names for your provider.\"\"\"\n",[262,30703,30704,30706,30708],{"class":181,"line":845},[262,30705,1184],{"class":429},[262,30707,476],{"class":377},[262,30709,30710],{"class":429}," httpx.get(\n",[262,30712,30713,30716],{"class":181,"line":850},[262,30714,30715],{"class":275},"        \"https:\u002F\u002Fapi.your-serp-provider.com\u002Fv1\u002Fkeywords\"",[262,30717,1315],{"class":429},[262,30719,30720,30722,30724,30726,30729,30732,30735,30738,30740,30742,30744],{"class":181,"line":864},[262,30721,23539],{"class":611},[262,30723,476],{"class":377},[262,30725,3039],{"class":429},[262,30727,30728],{"class":275},"\"q\"",[262,30730,30731],{"class":429},": seed, ",[262,30733,30734],{"class":275},"\"api_key\"",[262,30736,30737],{"class":429},": api_key, ",[262,30739,20448],{"class":275},[262,30741,1231],{"class":429},[262,30743,113],{"class":271},[262,30745,3143],{"class":429},[262,30747,30748,30750,30752,30754],{"class":181,"line":1683},[262,30749,6687],{"class":611},[262,30751,476],{"class":377},[262,30753,6692],{"class":271},[262,30755,1315],{"class":429},[262,30757,30758],{"class":181,"line":1688},[262,30759,1011],{"class":429},[262,30761,30762],{"class":181,"line":1693},[262,30763,6703],{"class":429},[262,30765,30766,30768,30770,30773,30775],{"class":181,"line":1728},[262,30767,25637],{"class":429},[262,30769,476],{"class":377},[262,30771,30772],{"class":429}," response.json()[",[262,30774,18768],{"class":275},[262,30776,957],{"class":429},[262,30778,30779,30781],{"class":181,"line":1737},[262,30780,573],{"class":377},[262,30782,30783],{"class":429}," pd.DataFrame(rows)\n",[262,30785,30786],{"class":181,"line":1751},[262,30787,583],{"emptyLinePlaceholder":582},[262,30789,30790],{"class":181,"line":1764},[262,30791,583],{"emptyLinePlaceholder":582},[262,30793,30794,30797,30799,30802,30804],{"class":181,"line":1779},[262,30795,30796],{"class":429},"keywords_df ",[262,30798,476],{"class":377},[262,30800,30801],{"class":429}," load_keywords_from_csv(",[262,30803,29106],{"class":275},[262,30805,660],{"class":429},[262,30807,30808,30810,30812,30814,30816,30818,30821,30823,30825],{"class":181,"line":1793},[262,30809,637],{"class":271},[262,30811,602],{"class":429},[262,30813,642],{"class":377},[262,30815,2775],{"class":275},[262,30817,648],{"class":271},[262,30819,30820],{"class":429},"(keywords_df)",[262,30822,654],{"class":271},[262,30824,29187],{"class":275},[262,30826,660],{"class":429},[14,30828,30829,30830,30833,30834,30837,30838,1374,30841,30844,30845,30848,30849,30852,30853,30856,30857,30860],{},"The cleaning matters more than it looks, and it is worth understanding each line rather than copying it blindly. ",[18,30831,30832],{},"astype(str)"," guards against pandas reading a column of mostly-numbers as integers and choking on a real phrase. ",[18,30835,30836],{},"str.strip()"," removes leading and trailing spaces, which are invisible in a spreadsheet but make ",[18,30839,30840],{},"\"python seo\"",[18,30842,30843],{},"\"python seo \""," look like two different terms. ",[18,30846,30847],{},"str.lower()"," folds case so ",[18,30850,30851],{},"\"Python SEO\""," collapses into the same term. ",[18,30854,30855],{},"drop_duplicates"," then removes the exact repeats those steps just exposed, often a surprising fraction of a raw export. Finally, the length filter drops rows shorter than three characters, clearing out stray single letters, empty cells that survived as ",[18,30858,30859],{},"\"nan\""," strings, and other junk that would otherwise waste an embedding call and pollute a group.",[14,30862,30863,30864,30866,30867,30870,30871,30873],{},"A CSV with a ",[18,30865,26611],{}," column (and ideally a ",[18,30868,30869],{},"volume"," column for monthly search volume) is all the rest of this guide needs. If your export carries extra columns — clicks, impressions, position, difficulty — leave them in. They ride along untouched through the whole pipeline and are waiting in the final output, which means you can sort or filter on them later without re-running anything. The one column the prioritising step looks for by name is ",[18,30872,30869],{},"; everything else is along for the trip.",[57,30875,30877],{"id":30876},"step-2-turn-each-keyword-into-an-embedding","Step 2: Turn each keyword into an embedding",[14,30879,30880,30881,30883,30884,30886,30887,1374,30889,30891,30892,1374,30895,30898],{},"An ",[27,30882,30319],{}," is a list of numbers that represents the meaning of a piece of text. The OpenAI API reads each keyword and returns a vector — for ",[18,30885,878],{}," that is 1,536 numbers — positioned so that phrases with similar meaning end up close together in mathematical space. A helpful mental picture: imagine every keyword as a pin dropped onto an enormous map with 1,536 dimensions instead of two. Pins for related ideas land near each other, pins for unrelated ideas land far apart, and the distance between two pins measures how similar their meanings are. This is what lets a computer see that ",[18,30888,30305],{},[18,30890,30309],{}," belong together even though they share no words, and that ",[18,30893,30894],{},"\"python tutorial\"",[18,30896,30897],{},"\"python snake care\""," belong apart even though they share one.",[14,30900,30901,30902,30904],{},"The endpoint accepts a whole batch of texts in one call, so embedding a few thousand keywords is a handful of requests, not thousands. We use the official ",[18,30903,20],{}," SDK, which reads your key from the environment automatically.",[253,30906,30908],{"className":414,"code":30907,"language":416,"meta":258,"style":258},"import os\n\nimport numpy as np\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\n\ndef embed_keywords(keywords: list[str], model: str = \"text-embedding-3-small\") -> np.ndarray:\n    \"\"\"Return a 2-D array where each row is one keyword's embedding.\"\"\"\n    vectors: list[list[float]] = []\n    for start in range(0, len(keywords), 1000):\n        batch = keywords[start : start + 1000]\n        response = client.embeddings.create(model=model, input=batch)\n        vectors.extend(item.embedding for item in response.data)\n    return np.array(vectors, dtype=\"float32\")\n\n\nembeddings = embed_keywords(keywords_df[\"keyword\"].tolist())\nprint(f\"Embedded into shape {embeddings.shape}\")\n",[18,30909,30910,30916,30920,30930,30940,30950,30954,30958,30968,30972,30976,30998,31003,31017,31043,31058,31079,31091,31106,31110,31114,31127],{"__ignoreMap":258},[262,30911,30912,30914],{"class":181,"line":264},[262,30913,684],{"class":377},[262,30915,687],{"class":429},[262,30917,30918],{"class":181,"line":282},[262,30919,583],{"emptyLinePlaceholder":582},[262,30921,30922,30924,30926,30928],{"class":181,"line":295},[262,30923,684],{"class":377},[262,30925,694],{"class":429},[262,30927,697],{"class":377},[262,30929,700],{"class":429},[262,30931,30932,30934,30936,30938],{"class":181,"line":345},[262,30933,705],{"class":377},[262,30935,708],{"class":429},[262,30937,684],{"class":377},[262,30939,713],{"class":429},[262,30941,30942,30944,30946,30948],{"class":181,"line":492},[262,30943,705],{"class":377},[262,30945,720],{"class":429},[262,30947,684],{"class":377},[262,30949,725],{"class":429},[262,30951,30952],{"class":181,"line":503},[262,30953,583],{"emptyLinePlaceholder":582},[262,30955,30956],{"class":181,"line":521},[262,30957,734],{"class":429},[262,30959,30960,30962,30964,30966],{"class":181,"line":537},[262,30961,739],{"class":429},[262,30963,476],{"class":377},[262,30965,9578],{"class":429},[262,30967,9581],{"class":291},[262,30969,30970],{"class":181,"line":549},[262,30971,583],{"emptyLinePlaceholder":582},[262,30973,30974],{"class":181,"line":570},[262,30975,583],{"emptyLinePlaceholder":582},[262,30977,30978,30980,30982,30984,30986,30989,30991,30993,30996],{"class":181,"line":579},[262,30979,423],{"class":377},[262,30981,29272],{"class":267},[262,30983,29275],{"class":429},[262,30985,433],{"class":271},[262,30987,30988],{"class":429},"], model: ",[262,30990,433],{"class":271},[262,30992,442],{"class":377},[262,30994,30995],{"class":275}," \"text-embedding-3-small\"",[262,30997,29290],{"class":429},[262,30999,31000],{"class":181,"line":586},[262,31001,31002],{"class":275},"    \"\"\"Return a 2-D array where each row is one keyword's embedding.\"\"\"\n",[262,31004,31005,31008,31010,31013,31015],{"class":181,"line":591},[262,31006,31007],{"class":429},"    vectors: list[list[",[262,31009,3832],{"class":271},[262,31011,31012],{"class":429},"]] ",[262,31014,476],{"class":377},[262,31016,489],{"class":429},[262,31018,31019,31021,31023,31025,31027,31029,31031,31033,31035,31038,31041],{"class":181,"line":623},[262,31020,3074],{"class":377},[262,31022,509],{"class":429},[262,31024,835],{"class":377},[262,31026,3082],{"class":271},[262,31028,602],{"class":429},[262,31030,102],{"class":271},[262,31032,608],{"class":429},[262,31034,29318],{"class":271},[262,31036,31037],{"class":429},"(keywords), ",[262,31039,31040],{"class":271},"1000",[262,31042,8192],{"class":429},[262,31044,31045,31047,31049,31051,31053,31056],{"class":181,"line":634},[262,31046,29326],{"class":429},[262,31048,476],{"class":377},[262,31050,29331],{"class":429},[262,31052,531],{"class":377},[262,31054,31055],{"class":271}," 1000",[262,31057,957],{"class":429},[262,31059,31060,31062,31064,31066,31068,31070,31073,31075,31077],{"class":181,"line":845},[262,31061,21490],{"class":429},[262,31063,476],{"class":377},[262,31065,802],{"class":429},[262,31067,805],{"class":611},[262,31069,476],{"class":377},[262,31071,31072],{"class":429},"model, ",[262,31074,814],{"class":611},[262,31076,476],{"class":377},[262,31078,29359],{"class":429},[262,31080,31081,31083,31085,31087,31089],{"class":181,"line":850},[262,31082,29364],{"class":429},[262,31084,829],{"class":377},[262,31086,832],{"class":429},[262,31088,835],{"class":377},[262,31090,29373],{"class":429},[262,31092,31093,31095,31097,31099,31101,31104],{"class":181,"line":864},[262,31094,573],{"class":377},[262,31096,29380],{"class":429},[262,31098,26745],{"class":611},[262,31100,476],{"class":377},[262,31102,31103],{"class":275},"\"float32\"",[262,31105,660],{"class":429},[262,31107,31108],{"class":181,"line":1683},[262,31109,583],{"emptyLinePlaceholder":582},[262,31111,31112],{"class":181,"line":1688},[262,31113,583],{"emptyLinePlaceholder":582},[262,31115,31116,31118,31120,31123,31125],{"class":181,"line":1693},[262,31117,29400],{"class":429},[262,31119,476],{"class":377},[262,31121,31122],{"class":429}," embed_keywords(keywords_df[",[262,31124,26708],{"class":275},[262,31126,29410],{"class":429},[262,31128,31129,31131,31133,31135,31138,31140,31142,31144,31146],{"class":181,"line":1728},[262,31130,637],{"class":271},[262,31132,602],{"class":429},[262,31134,642],{"class":377},[262,31136,31137],{"class":275},"\"Embedded into shape ",[262,31139,3039],{"class":271},[262,31141,29426],{"class":429},[262,31143,654],{"class":271},[262,31145,1176],{"class":275},[262,31147,660],{"class":429},[14,31149,31150,31151,29446,31153,31155],{},"Batching in chunks of 1,000 keeps each request comfortably under the API's per-request input limit of 2,048 inputs and well within the token budget per call. Just as importantly, the order is preserved: the API returns embeddings in exactly the order you sent the keywords, so row five of the array always corresponds to row five of the DataFrame. That alignment is what lets the later steps match a vector back to its keyword by position alone, with no IDs to track. We store the result as ",[18,31152,29445],{},[18,31154,29449],{}," because it halves the memory footprint with no measurable loss in grouping quality — a real saving once lists run into the hundreds of thousands.",[14,31157,31158,31159,981,31161,31163,31164,31166],{},"If you process tens of thousands of keywords, wrap the call in a ",[18,31160,14430],{},[18,31162,14433],{}," and add a brief ",[18,31165,10940],{}," between batches so a momentary rate limit does not abandon work you have already paid for. The result is a NumPy array with one row per keyword, ready to feed straight into the grouping step.",[57,31168,31170],{"id":31169},"step-3-group-related-keywords-with-k-means","Step 3: Group related keywords with k-means",[14,31172,31173,31174,31177],{},"Now we let the machine find the structure. ",[27,31175,31176],{},"K-means clustering"," is a classic algorithm that sorts points into a chosen number of groups so that each point sits with its nearest neighbours. Applied to keyword embeddings, it produces groups of related keywords — the natural topic pages you should consider building.",[14,31179,31180,31181,31183,31184,31187],{},"You pick how many groups to create with the ",[18,31182,29582],{}," setting. A useful rule of thumb is one group for every 15 to 25 keywords; we compute that automatically below so the number scales with your list size. The intuition is simple: a page targeting fewer than fifteen related terms is usually too thin to rank well, while one stretched across more than twenty-five tries to answer too many questions at once. Because the count is derived from ",[18,31185,31186],{},"len(df)",", a list of 600 keywords produces about 30 groups and a list of 1,200 about 60 — the granularity stays constant as your data grows.",[253,31189,31191],{"className":414,"code":31190,"language":416,"meta":258,"style":258},"from sklearn.cluster import KMeans\nfrom sklearn.preprocessing import normalize\n\n\ndef group_keywords(df: pd.DataFrame, embeddings: np.ndarray, per_group: int = 20) -> pd.DataFrame:\n    \"\"\"Add a 'group_id' column by clustering the embeddings.\"\"\"\n    n_clusters = max(2, len(df) \u002F\u002F per_group)\n    # Normalising makes k-means group by direction (meaning), not magnitude.\n    unit_vectors = normalize(embeddings)\n    model = KMeans(n_clusters=n_clusters, random_state=42, n_init=\"auto\")\n    df = df.copy()\n    df[\"group_id\"] = model.fit_predict(unit_vectors)\n    return df\n\n\ngrouped_df = group_keywords(keywords_df, embeddings)\nprint(grouped_df.groupby(\"group_id\").size().sort_values(ascending=False).head())\n",[18,31192,31193,31203,31213,31217,31221,31240,31245,31270,31275,31285,31316,31324,31337,31343,31347,31351,31361],{"__ignoreMap":258},[262,31194,31195,31197,31199,31201],{"class":181,"line":264},[262,31196,705],{"class":377},[262,31198,29506],{"class":429},[262,31200,684],{"class":377},[262,31202,29511],{"class":429},[262,31204,31205,31207,31209,31211],{"class":181,"line":282},[262,31206,705],{"class":377},[262,31208,29518],{"class":429},[262,31210,684],{"class":377},[262,31212,29523],{"class":429},[262,31214,31215],{"class":181,"line":295},[262,31216,583],{"emptyLinePlaceholder":582},[262,31218,31219],{"class":181,"line":345},[262,31220,583],{"emptyLinePlaceholder":582},[262,31222,31223,31225,31228,31231,31233,31235,31238],{"class":181,"line":492},[262,31224,423],{"class":377},[262,31226,31227],{"class":267}," group_keywords",[262,31229,31230],{"class":429},"(df: pd.DataFrame, embeddings: np.ndarray, per_group: ",[262,31232,439],{"class":271},[262,31234,442],{"class":377},[262,31236,31237],{"class":271}," 20",[262,31239,26732],{"class":429},[262,31241,31242],{"class":181,"line":503},[262,31243,31244],{"class":275},"    \"\"\"Add a 'group_id' column by clustering the embeddings.\"\"\"\n",[262,31246,31247,31250,31252,31254,31256,31258,31260,31262,31265,31267],{"class":181,"line":521},[262,31248,31249],{"class":429},"    n_clusters ",[262,31251,476],{"class":377},[262,31253,13728],{"class":271},[262,31255,602],{"class":429},[262,31257,109],{"class":271},[262,31259,608],{"class":429},[262,31261,29318],{"class":271},[262,31263,31264],{"class":429},"(df) ",[262,31266,13803],{"class":377},[262,31268,31269],{"class":429}," per_group)\n",[262,31271,31272],{"class":181,"line":537},[262,31273,31274],{"class":291},"    # Normalising makes k-means group by direction (meaning), not magnitude.\n",[262,31276,31277,31280,31282],{"class":181,"line":549},[262,31278,31279],{"class":429},"    unit_vectors ",[262,31281,476],{"class":377},[262,31283,31284],{"class":429}," normalize(embeddings)\n",[262,31286,31287,31290,31292,31294,31296,31298,31300,31302,31304,31306,31308,31310,31312,31314],{"class":181,"line":570},[262,31288,31289],{"class":429},"    model ",[262,31291,476],{"class":377},[262,31293,29579],{"class":429},[262,31295,29582],{"class":611},[262,31297,476],{"class":377},[262,31299,29587],{"class":429},[262,31301,29590],{"class":611},[262,31303,476],{"class":377},[262,31305,5508],{"class":271},[262,31307,608],{"class":429},[262,31309,29599],{"class":611},[262,31311,476],{"class":377},[262,31313,29604],{"class":275},[262,31315,660],{"class":429},[262,31317,31318,31320,31322],{"class":181,"line":579},[262,31319,26737],{"class":429},[262,31321,476],{"class":377},[262,31323,27436],{"class":429},[262,31325,31326,31328,31330,31332,31334],{"class":181,"line":586},[262,31327,2897],{"class":429},[262,31329,29613],{"class":275},[262,31331,2903],{"class":429},[262,31333,476],{"class":377},[262,31335,31336],{"class":429}," model.fit_predict(unit_vectors)\n",[262,31338,31339,31341],{"class":181,"line":591},[262,31340,573],{"class":377},[262,31342,27542],{"class":429},[262,31344,31345],{"class":181,"line":623},[262,31346,583],{"emptyLinePlaceholder":582},[262,31348,31349],{"class":181,"line":634},[262,31350,583],{"emptyLinePlaceholder":582},[262,31352,31353,31356,31358],{"class":181,"line":845},[262,31354,31355],{"class":429},"grouped_df ",[262,31357,476],{"class":377},[262,31359,31360],{"class":429}," group_keywords(keywords_df, embeddings)\n",[262,31362,31363,31365,31368,31370,31373,31376,31378,31380],{"class":181,"line":850},[262,31364,637],{"class":271},[262,31366,31367],{"class":429},"(grouped_df.groupby(",[262,31369,29613],{"class":275},[262,31371,31372],{"class":429},").size().sort_values(",[262,31374,31375],{"class":611},"ascending",[262,31377,476],{"class":377},[262,31379,3623],{"class":271},[262,31381,31382],{"class":429},").head())\n",[14,31384,31385,31386,31389,31390,31393,31394,31396,31397,31399],{},"Normalising the vectors before clustering is a small but important detail. By default k-means measures straight-line distance, which is sensitive to how ",[27,31387,31388],{},"long"," each vector is as well as which way it points. For embeddings the length is mostly noise and the direction carries the meaning, so ",[18,31391,31392],{},"normalize()"," rescales every vector to the same length and leaves only its direction to compare — the difference between groups that track topics and groups that cluster by wording quirks. The ",[18,31395,29657],{}," argument fixes the random seed, so the same input produces the same groups every run, and ",[18,31398,29661],{}," lets scikit-learn restart a sensible number of times and keep the best result, guarding against an unlucky start that lands in a poor split.",[14,31401,31402,31403,31405,31406,31408],{},"If you want a deeper treatment of this exact technique, including how to test several values of ",[18,31404,29582],{}," and how to spot when k-means is the wrong tool, the dedicated guide ",[51,31407,28902],{"href":28901}," covers tuning and alternatives.",[57,31410,31412],{"id":31411},"step-4-label-and-prioritise-each-group","Step 4: Label and prioritise each group",[14,31414,31415],{},"A group of keyword IDs is not actionable until you can read it and rank it. Two things make it useful: a human-readable label (the keyword closest to the group's centre is a great summary) and a score that tells you which group to write for first. A simple, honest score is total search volume across the group — that is the size of the audience a page targeting it could reach.",[253,31417,31419],{"className":414,"code":31418,"language":416,"meta":258,"style":258},"import numpy as np\nfrom sklearn.metrics.pairwise import cosine_similarity\n\n\ndef summarise_groups(df: pd.DataFrame, embeddings: np.ndarray) -> pd.DataFrame:\n    summaries = []\n    for group_id, members in df.groupby(\"group_id\"):\n        idx = members.index.to_numpy()\n        centre = embeddings[idx].mean(axis=0, keepdims=True)\n        closest = idx[cosine_similarity(centre, embeddings[idx])[0].argmax()]\n        label = df.loc[closest, \"keyword\"]\n        volume = int(members[\"volume\"].sum()) if \"volume\" in df else len(members)\n        summaries.append(\n            {\"group_id\": group_id, \"label\": label,\n             \"keywords\": len(members), \"opportunity\": volume}\n        )\n    return pd.DataFrame(summaries).sort_values(\"opportunity\", ascending=False)\n\n\nreport = summarise_groups(grouped_df, embeddings)\nprint(report.head(10).to_string(index=False))\n",[18,31420,31421,31431,31443,31447,31451,31461,31470,31485,31495,31522,31537,31551,31586,31591,31605,31623,31627,31646,31650,31654,31664],{"__ignoreMap":258},[262,31422,31423,31425,31427,31429],{"class":181,"line":264},[262,31424,684],{"class":377},[262,31426,694],{"class":429},[262,31428,697],{"class":377},[262,31430,700],{"class":429},[262,31432,31433,31435,31438,31440],{"class":181,"line":282},[262,31434,705],{"class":377},[262,31436,31437],{"class":429}," sklearn.metrics.pairwise ",[262,31439,684],{"class":377},[262,31441,31442],{"class":429}," cosine_similarity\n",[262,31444,31445],{"class":181,"line":295},[262,31446,583],{"emptyLinePlaceholder":582},[262,31448,31449],{"class":181,"line":345},[262,31450,583],{"emptyLinePlaceholder":582},[262,31452,31453,31455,31458],{"class":181,"line":492},[262,31454,423],{"class":377},[262,31456,31457],{"class":267}," summarise_groups",[262,31459,31460],{"class":429},"(df: pd.DataFrame, embeddings: np.ndarray) -> pd.DataFrame:\n",[262,31462,31463,31466,31468],{"class":181,"line":503},[262,31464,31465],{"class":429},"    summaries ",[262,31467,476],{"class":377},[262,31469,489],{"class":429},[262,31471,31472,31474,31477,31479,31481,31483],{"class":181,"line":521},[262,31473,3074],{"class":377},[262,31475,31476],{"class":429}," group_id, members ",[262,31478,835],{"class":377},[262,31480,29879],{"class":429},[262,31482,29613],{"class":275},[262,31484,8192],{"class":429},[262,31486,31487,31490,31492],{"class":181,"line":537},[262,31488,31489],{"class":429},"        idx ",[262,31491,476],{"class":377},[262,31493,31494],{"class":429}," members.index.to_numpy()\n",[262,31496,31497,31500,31502,31505,31507,31509,31511,31513,31516,31518,31520],{"class":181,"line":549},[262,31498,31499],{"class":429},"        centre ",[262,31501,476],{"class":377},[262,31503,31504],{"class":429}," embeddings[idx].mean(",[262,31506,992],{"class":611},[262,31508,476],{"class":377},[262,31510,102],{"class":271},[262,31512,608],{"class":429},[262,31514,31515],{"class":611},"keepdims",[262,31517,476],{"class":377},[262,31519,4974],{"class":271},[262,31521,660],{"class":429},[262,31523,31524,31527,31529,31532,31534],{"class":181,"line":570},[262,31525,31526],{"class":429},"        closest ",[262,31528,476],{"class":377},[262,31530,31531],{"class":429}," idx[cosine_similarity(centre, embeddings[idx])[",[262,31533,102],{"class":271},[262,31535,31536],{"class":429},"].argmax()]\n",[262,31538,31539,31542,31544,31547,31549],{"class":181,"line":579},[262,31540,31541],{"class":429},"        label ",[262,31543,476],{"class":377},[262,31545,31546],{"class":429}," df.loc[closest, ",[262,31548,26708],{"class":275},[262,31550,957],{"class":429},[262,31552,31553,31556,31558,31560,31563,31566,31569,31571,31574,31576,31579,31581,31583],{"class":181,"line":586},[262,31554,31555],{"class":429},"        volume ",[262,31557,476],{"class":377},[262,31559,23813],{"class":271},[262,31561,31562],{"class":429},"(members[",[262,31564,31565],{"class":275},"\"volume\"",[262,31567,31568],{"class":429},"].sum()) ",[262,31570,2210],{"class":377},[262,31572,31573],{"class":275}," \"volume\"",[262,31575,2821],{"class":377},[262,31577,31578],{"class":429}," df ",[262,31580,20859],{"class":377},[262,31582,515],{"class":271},[262,31584,31585],{"class":429},"(members)\n",[262,31587,31588],{"class":181,"line":591},[262,31589,31590],{"class":429},"        summaries.append(\n",[262,31592,31593,31595,31597,31600,31602],{"class":181,"line":623},[262,31594,1225],{"class":429},[262,31596,29613],{"class":275},[262,31598,31599],{"class":429},": group_id, ",[262,31601,29847],{"class":275},[262,31603,31604],{"class":429},": label,\n",[262,31606,31607,31610,31612,31614,31617,31620],{"class":181,"line":634},[262,31608,31609],{"class":275},"             \"keywords\"",[262,31611,1231],{"class":429},[262,31613,29318],{"class":271},[262,31615,31616],{"class":429},"(members), ",[262,31618,31619],{"class":275},"\"opportunity\"",[262,31621,31622],{"class":429},": volume}\n",[262,31624,31625],{"class":181,"line":845},[262,31626,6288],{"class":429},[262,31628,31629,31631,31634,31636,31638,31640,31642,31644],{"class":181,"line":850},[262,31630,573],{"class":377},[262,31632,31633],{"class":429}," pd.DataFrame(summaries).sort_values(",[262,31635,31619],{"class":275},[262,31637,608],{"class":429},[262,31639,31375],{"class":611},[262,31641,476],{"class":377},[262,31643,3623],{"class":271},[262,31645,660],{"class":429},[262,31647,31648],{"class":181,"line":864},[262,31649,583],{"emptyLinePlaceholder":582},[262,31651,31652],{"class":181,"line":1683},[262,31653,583],{"emptyLinePlaceholder":582},[262,31655,31656,31659,31661],{"class":181,"line":1688},[262,31657,31658],{"class":429},"report ",[262,31660,476],{"class":377},[262,31662,31663],{"class":429}," summarise_groups(grouped_df, embeddings)\n",[262,31665,31666,31668,31671,31673,31676,31678,31680,31682],{"class":181,"line":1693},[262,31667,637],{"class":271},[262,31669,31670],{"class":429},"(report.head(",[262,31672,3868],{"class":271},[262,31674,31675],{"class":429},").to_string(",[262,31677,3618],{"class":611},[262,31679,476],{"class":377},[262,31681,3623],{"class":271},[262,31683,2684],{"class":429},[14,31685,31686],{},"The labelling logic is worth pausing on, because it is what turns an anonymous group number into something you can act on. For each group we average all its member vectors to find the group's centre — the mathematical \"middle\" of that cluster of meanings — and then pick the single real keyword closest to that centre using cosine similarity. That keyword is the most representative phrase in the group, a far better label than an arbitrary first row.",[14,31688,31689,31690,31692],{},"The output is a ranked table: each row is one topic page, named after its most representative keyword, with the number of keywords it covers and an opportunity score. Write for the top rows first. If your CSV has no ",[18,31691,30869],{}," column the score falls back to group size, which still surfaces the broadest, most-supported topics. Search volume is an honest first-pass score, but two refinements pay off quickly: weight the score by how easy each group looks to rank for if your export carries a difficulty column, and skim the smallest groups by hand, because a tight cluster of three high-intent buying terms can be worth more than a sprawling group of fifty informational ones. The numbers point you at the opportunities; your judgement still picks the order.",[57,31694,8300],{"id":8299},[1379,31696,31697,31710],{},[1382,31698,31699],{},[1385,31700,31701,31704,31706,31708],{},[1388,31702,31703],{},"Name",[1388,31705,3795],{},[1388,31707,3798],{},[1388,31709,1396],{},[1398,31711,31712,31737,31753,31769,31790,31813,31830],{},[1385,31713,31714,31719,31723,31727],{},[1403,31715,31716,31718],{},[18,31717,805],{}," (embeddings)",[1403,31720,31721],{},[18,31722,433],{},[1403,31724,31725],{},[18,31726,762],{},[1403,31728,31729,31730,31732,31733,31736],{},"Which OpenAI embedding model to use. ",[18,31731,30092],{}," is cheap and accurate; ",[18,31734,31735],{},"-large"," is more precise but slower and pricier.",[1385,31738,31739,31743,31748,31750],{},[1403,31740,31741,31718],{},[18,31742,814],{},[1403,31744,31745],{},[18,31746,31747],{},"list[str]",[1403,31749,17513],{},[1403,31751,31752],{},"The batch of keywords to embed. Up to 2,048 items per request.",[1385,31754,31755,31759,31763,31766],{},[1403,31756,31757],{},[18,31758,29582],{},[1403,31760,31761],{},[18,31762,439],{},[1403,31764,31765],{},"computed",[1403,31767,31768],{},"How many groups k-means creates. More groups means tighter, more specific topics.",[1385,31770,31771,31776,31780,31784],{},[1403,31772,31773],{},[18,31774,31775],{},"per_group",[1403,31777,31778],{},[18,31779,439],{},[1403,31781,31782],{},[18,31783,140],{},[1403,31785,31786,31787,31789],{},"Target keywords per group; drives the ",[18,31788,29582],{}," calculation. Lower it for finer splits.",[1385,31791,31792,31796,31803,31807],{},[1403,31793,31794],{},[18,31795,29599],{},[1403,31797,31798,31800,31801],{},[18,31799,433],{}," \u002F ",[18,31802,439],{},[1403,31804,31805],{},[18,31806,29604],{},[1403,31808,31809,31810,31812],{},"How many times k-means restarts to find the best result. ",[18,31811,29604],{}," is the recommended modern default.",[1385,31814,31815,31819,31823,31827],{},[1403,31816,31817],{},[18,31818,29590],{},[1403,31820,31821],{},[18,31822,439],{},[1403,31824,31825],{},[18,31826,5508],{},[1403,31828,31829],{},"Fixes the random seed so you get the same groups on every run.",[1385,31831,31832,31836,31840,31844],{},[1403,31833,31834,17562],{},[18,31835,1591],{},[1403,31837,31838],{},[18,31839,3832],{},[1403,31841,31842],{},[18,31843,6692],{},[1403,31845,31846],{},"Seconds before an API request is abandoned. Raise it on slow connections.",[57,31848,1445],{"id":1444},[1447,31850,31851,31866,31880,31902,31916,31930,31942,31969,31978],{},[1450,31852,31853,31857,31858,31860,31861,31863,31864,1363],{},[35,31854,31855],{},[18,31856,21739],{}," — The key in ",[18,31859,319],{}," is wrong, expired, or not being loaded. Confirm ",[18,31862,8439],{}," runs before you create the client, and check there are no quotes or spaces around the key. The full walk-through is in ",[51,31865,388],{"href":387},[1450,31867,31868,31873,31874,31876,31877,31879],{},[35,31869,31870],{},[18,31871,31872],{},"openai.RateLimitError: Rate limit reached"," — You sent batches too quickly. Add a short ",[18,31875,8453],{}," between requests, or shrink the batch size. See ",[51,31878,3379],{"href":3378}," for a retry pattern.",[1450,31881,31882,31887,31888,407,31891,31894,31895,31898,31899,1363],{},[35,31883,31884],{},[18,31885,31886],{},"KeyError: 'keyword'"," — Your CSV's column is named something else (often ",[18,31889,31890],{},"Keyword",[18,31892,31893],{},"Query","). Rename it with ",[18,31896,31897],{},"df = df.rename(columns={\"Query\": \"keyword\"})"," right after loading, or set the correct name in ",[18,31900,31901],{},"pd.read_csv",[1450,31903,31904,31909,31910,31913,31914,1363],{},[35,31905,31906],{},[18,31907,31908],{},"ValueError: n_samples=… should be >= n_clusters=…"," — You asked for more groups than you have keywords. This happens on tiny lists; the ",[18,31911,31912],{},"max(2, len(df) \u002F\u002F per_group)"," guard prevents it, so make sure you are using the helper rather than a hard-coded ",[18,31915,29582],{},[1450,31917,31918,31922,31923,31925,31926,31929],{},[35,31919,31920],{},[18,31921,10922],{}," — The SERP API took longer than your ",[18,31924,1591],{},". Raise it to ",[18,31927,31928],{},"60.0",", and confirm the endpoint URL is correct — a wrong path often hangs instead of returning an error.",[1450,31931,31932,31935,31936,31938,31939,31941],{},[35,31933,31934],{},"Groups look random or mixed"," — You probably skipped normalising the vectors, or your keyword list is too small for embeddings to find structure. Confirm ",[18,31937,31392],{}," runs before ",[18,31940,29641],{},", and aim for at least a few hundred keywords.",[1450,31943,31944,31952,31953,31955,31956,31958,31959,31962,31963,31965,31966,1363],{},[35,31945,31946,31948,31949,5987],{},[18,31947,8493],{}," (or ",[18,31950,31951],{},"sklearn"," — The library is missing from the active environment, almost always because the virtual environment is not activated or you installed into a different one. Re-run ",[18,31954,30519],{},", confirm the prompt shows ",[18,31957,30512],{},", then re-run the ",[18,31960,31961],{},"pip install"," line. Note the import name for scikit-learn is ",[18,31964,31951],{},", not the package name ",[18,31967,31968],{},"scikit-learn",[1450,31970,31971,31974,31975,31977],{},[35,31972,31973],{},"The same keyword shows up as the label for several groups"," — This means your list has near-duplicate keywords that survived cleaning (for example with trailing punctuation), so distinct groups end up centred on the same phrase. Tighten the cleaning step, or lower ",[18,31976,31775],{}," so the algorithm draws sharper boundaries, and re-run.",[1450,31979,31980,31987,31988,31991,31992,31995],{},[35,31981,31982,14855,31984],{},[18,31983,14854],{},[18,31985,31986],{},"response.json()"," — The SERP API returned an error body without the ",[18,31989,31990],{},"data"," field you indexed. Print ",[18,31993,31994],{},"response.text"," before parsing to see the real message; it is usually an auth failure or a malformed query parameter rather than a bug in your code.",[57,31997,31999],{"id":31998},"worked-example-full-pipeline-in-one-script","Worked example: full pipeline in one script",[14,32001,32002,32003,32006,32007,32009,32010,32012,32013,32015,32016,32019,32020,32023],{},"This script ties the four steps together. Save it as ",[18,32004,32005],{},"keyword_research.py",", put a ",[18,32008,29065],{}," (with ",[18,32011,26611],{}," and optional ",[18,32014,30869],{}," columns) beside it, and run ",[18,32017,32018],{},"python keyword_research.py",". It writes a ranked ",[18,32021,32022],{},"keyword_groups.csv"," you can open in any spreadsheet.",[253,32025,32027],{"className":414,"code":32026,"language":416,"meta":258,"style":258},"import os\n\nimport numpy as np\nimport pandas as pd\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\nfrom sklearn.cluster import KMeans\nfrom sklearn.metrics.pairwise import cosine_similarity\nfrom sklearn.preprocessing import normalize\n\nload_dotenv()                      # load OPENAI_API_KEY from .env into the environment\nclient = OpenAI()                  # SDK reads the key automatically; no key in code\n\n# 1. Load and clean the keyword list.\ndf = pd.read_csv(\"keywords.csv\")                              # expects a 'keyword' column\ndf[\"keyword\"] = df[\"keyword\"].astype(str).str.strip().str.lower()  # normalise casing\u002Fspaces\ndf = df.drop_duplicates(\"keyword\")                           # remove the repeats that exposes\ndf = df[df[\"keyword\"].str.len() > 2].reset_index(drop=True)  # drop junk; reset index to 0..n\nprint(f\"Loaded {len(df)} keywords\")\n\n# 2. Embed every keyword in batches of 1,000 (well under the 2,048-input limit).\nvectors: list[list[float]] = []\nfor start in range(0, len(df), 1000):                        # walk the list in 1,000-row windows\n    batch = df[\"keyword\"].iloc[start : start + 1000].tolist()\n    resp = client.embeddings.create(model=\"text-embedding-3-small\", input=batch)\n    vectors.extend(item.embedding for item in resp.data)     # order is preserved by the API\nembeddings = normalize(np.array(vectors, dtype=\"float32\"))   # to unit vectors: compare by meaning\n\n# 3. Group related keywords with k-means (≈20 keywords per group).\nn_clusters = max(2, len(df) \u002F\u002F 20)                           # scales with list size; min of 2\ndf[\"group_id\"] = KMeans(n_clusters=n_clusters, random_state=42,\n                        n_init=\"auto\").fit_predict(embeddings)  # stable, reproducible groups\n\n# 4. Label each group by its central keyword and score it by volume.\nrows = []\nfor gid, members in df.groupby(\"group_id\"):                  # one iteration per group\n    idx = members.index.to_numpy()                           # row positions of this group\n    centre = embeddings[idx].mean(axis=0, keepdims=True)     # the group's average direction\n    # The member closest to that centre is the most representative phrase -> the label.\n    label = df.loc[idx[cosine_similarity(centre, embeddings[idx])[0].argmax()], \"keyword\"]\n    volume = int(members[\"volume\"].sum()) if \"volume\" in df else len(members)  # score, with fallback\n    rows.append({\"group\": gid, \"topic\": label, \"keywords\": len(members), \"opportunity\": volume})\n\nreport = pd.DataFrame(rows).sort_values(\"opportunity\", ascending=False)  # best opportunities first\nreport.to_csv(\"keyword_groups.csv\", index=False)            # open this in any spreadsheet\nprint(report.head(10).to_string(index=False))\n",[18,32028,32029,32035,32039,32049,32059,32069,32079,32089,32099,32109,32113,32121,32133,32137,32142,32157,32181,32197,32227,32248,32252,32257,32270,32299,32320,32342,32359,32380,32384,32389,32416,32442,32457,32461,32466,32475,32494,32507,32536,32541,32560,32593,32624,32628,32652,32674],{"__ignoreMap":258},[262,32030,32031,32033],{"class":181,"line":264},[262,32032,684],{"class":377},[262,32034,687],{"class":429},[262,32036,32037],{"class":181,"line":282},[262,32038,583],{"emptyLinePlaceholder":582},[262,32040,32041,32043,32045,32047],{"class":181,"line":295},[262,32042,684],{"class":377},[262,32044,694],{"class":429},[262,32046,697],{"class":377},[262,32048,700],{"class":429},[262,32050,32051,32053,32055,32057],{"class":181,"line":345},[262,32052,684],{"class":377},[262,32054,2619],{"class":429},[262,32056,697],{"class":377},[262,32058,2624],{"class":429},[262,32060,32061,32063,32065,32067],{"class":181,"line":492},[262,32062,705],{"class":377},[262,32064,708],{"class":429},[262,32066,684],{"class":377},[262,32068,713],{"class":429},[262,32070,32071,32073,32075,32077],{"class":181,"line":503},[262,32072,705],{"class":377},[262,32074,720],{"class":429},[262,32076,684],{"class":377},[262,32078,725],{"class":429},[262,32080,32081,32083,32085,32087],{"class":181,"line":521},[262,32082,705],{"class":377},[262,32084,29506],{"class":429},[262,32086,684],{"class":377},[262,32088,29511],{"class":429},[262,32090,32091,32093,32095,32097],{"class":181,"line":537},[262,32092,705],{"class":377},[262,32094,31437],{"class":429},[262,32096,684],{"class":377},[262,32098,31442],{"class":429},[262,32100,32101,32103,32105,32107],{"class":181,"line":549},[262,32102,705],{"class":377},[262,32104,29518],{"class":429},[262,32106,684],{"class":377},[262,32108,29523],{"class":429},[262,32110,32111],{"class":181,"line":570},[262,32112,583],{"emptyLinePlaceholder":582},[262,32114,32115,32118],{"class":181,"line":579},[262,32116,32117],{"class":429},"load_dotenv()                      ",[262,32119,32120],{"class":291},"# load OPENAI_API_KEY from .env into the environment\n",[262,32122,32123,32125,32127,32130],{"class":181,"line":586},[262,32124,739],{"class":429},[262,32126,476],{"class":377},[262,32128,32129],{"class":429}," OpenAI()                  ",[262,32131,32132],{"class":291},"# SDK reads the key automatically; no key in code\n",[262,32134,32135],{"class":181,"line":591},[262,32136,583],{"emptyLinePlaceholder":582},[262,32138,32139],{"class":181,"line":623},[262,32140,32141],{"class":291},"# 1. Load and clean the keyword list.\n",[262,32143,32144,32146,32148,32150,32152,32154],{"class":181,"line":634},[262,32145,2755],{"class":429},[262,32147,476],{"class":377},[262,32149,2760],{"class":429},[262,32151,29106],{"class":275},[262,32153,9060],{"class":429},[262,32155,32156],{"class":291},"# expects a 'keyword' column\n",[262,32158,32159,32161,32163,32165,32167,32169,32171,32173,32175,32178],{"class":181,"line":845},[262,32160,29113],{"class":429},[262,32162,26708],{"class":275},[262,32164,2903],{"class":429},[262,32166,476],{"class":377},[262,32168,27464],{"class":429},[262,32170,26708],{"class":275},[262,32172,29126],{"class":429},[262,32174,433],{"class":271},[262,32176,32177],{"class":429},").str.strip().str.lower()  ",[262,32179,32180],{"class":291},"# normalise casing\u002Fspaces\n",[262,32182,32183,32185,32187,32189,32191,32194],{"class":181,"line":850},[262,32184,2755],{"class":429},[262,32186,476],{"class":377},[262,32188,30627],{"class":429},[262,32190,26708],{"class":275},[262,32192,32193],{"class":429},")                           ",[262,32195,32196],{"class":291},"# remove the repeats that exposes\n",[262,32198,32199,32201,32203,32205,32207,32209,32211,32213,32215,32217,32219,32221,32224],{"class":181,"line":864},[262,32200,2755],{"class":429},[262,32202,476],{"class":377},[262,32204,29140],{"class":429},[262,32206,26708],{"class":275},[262,32208,30648],{"class":429},[262,32210,8086],{"class":377},[262,32212,3232],{"class":271},[262,32214,30655],{"class":429},[262,32216,26854],{"class":611},[262,32218,476],{"class":377},[262,32220,4974],{"class":271},[262,32222,32223],{"class":429},")  ",[262,32225,32226],{"class":291},"# drop junk; reset index to 0..n\n",[262,32228,32229,32231,32233,32235,32237,32239,32241,32243,32246],{"class":181,"line":1683},[262,32230,637],{"class":271},[262,32232,602],{"class":429},[262,32234,642],{"class":377},[262,32236,2775],{"class":275},[262,32238,648],{"class":271},[262,32240,2780],{"class":429},[262,32242,654],{"class":271},[262,32244,32245],{"class":275}," keywords\"",[262,32247,660],{"class":429},[262,32249,32250],{"class":181,"line":1688},[262,32251,583],{"emptyLinePlaceholder":582},[262,32253,32254],{"class":181,"line":1693},[262,32255,32256],{"class":291},"# 2. Embed every keyword in batches of 1,000 (well under the 2,048-input limit).\n",[262,32258,32259,32262,32264,32266,32268],{"class":181,"line":1728},[262,32260,32261],{"class":429},"vectors: list[list[",[262,32263,3832],{"class":271},[262,32265,31012],{"class":429},[262,32267,476],{"class":377},[262,32269,489],{"class":429},[262,32271,32272,32274,32276,32278,32280,32282,32284,32286,32288,32291,32293,32296],{"class":181,"line":1737},[262,32273,829],{"class":377},[262,32275,509],{"class":429},[262,32277,835],{"class":377},[262,32279,3082],{"class":271},[262,32281,602],{"class":429},[262,32283,102],{"class":271},[262,32285,608],{"class":429},[262,32287,29318],{"class":271},[262,32289,32290],{"class":429},"(df), ",[262,32292,31040],{"class":271},[262,32294,32295],{"class":429},"):                        ",[262,32297,32298],{"class":291},"# walk the list in 1,000-row windows\n",[262,32300,32301,32304,32306,32308,32310,32313,32315,32317],{"class":181,"line":1751},[262,32302,32303],{"class":429},"    batch ",[262,32305,476],{"class":377},[262,32307,27464],{"class":429},[262,32309,26708],{"class":275},[262,32311,32312],{"class":429},"].iloc[start : start ",[262,32314,531],{"class":377},[262,32316,31055],{"class":271},[262,32318,32319],{"class":429},"].tolist()\n",[262,32321,32322,32324,32326,32328,32330,32332,32334,32336,32338,32340],{"class":181,"line":1764},[262,32323,797],{"class":429},[262,32325,476],{"class":377},[262,32327,802],{"class":429},[262,32329,805],{"class":611},[262,32331,476],{"class":377},[262,32333,762],{"class":275},[262,32335,608],{"class":429},[262,32337,814],{"class":611},[262,32339,476],{"class":377},[262,32341,29359],{"class":429},[262,32343,32344,32347,32349,32351,32353,32356],{"class":181,"line":1779},[262,32345,32346],{"class":429},"    vectors.extend(item.embedding ",[262,32348,829],{"class":377},[262,32350,832],{"class":429},[262,32352,835],{"class":377},[262,32354,32355],{"class":429}," resp.data)     ",[262,32357,32358],{"class":291},"# order is preserved by the API\n",[262,32360,32361,32363,32365,32368,32370,32372,32374,32377],{"class":181,"line":1793},[262,32362,29400],{"class":429},[262,32364,476],{"class":377},[262,32366,32367],{"class":429}," normalize(np.array(vectors, ",[262,32369,26745],{"class":611},[262,32371,476],{"class":377},[262,32373,31103],{"class":275},[262,32375,32376],{"class":429},"))   ",[262,32378,32379],{"class":291},"# to unit vectors: compare by meaning\n",[262,32381,32382],{"class":181,"line":1800},[262,32383,583],{"emptyLinePlaceholder":582},[262,32385,32386],{"class":181,"line":1805},[262,32387,32388],{"class":291},"# 3. Group related keywords with k-means (≈20 keywords per group).\n",[262,32390,32391,32393,32395,32397,32399,32401,32403,32405,32407,32409,32411,32413],{"class":181,"line":1810},[262,32392,29532],{"class":429},[262,32394,476],{"class":377},[262,32396,13728],{"class":271},[262,32398,602],{"class":429},[262,32400,109],{"class":271},[262,32402,608],{"class":429},[262,32404,29318],{"class":271},[262,32406,31264],{"class":429},[262,32408,13803],{"class":377},[262,32410,31237],{"class":271},[262,32412,32193],{"class":429},[262,32414,32415],{"class":291},"# scales with list size; min of 2\n",[262,32417,32418,32420,32422,32424,32426,32428,32430,32432,32434,32436,32438,32440],{"class":181,"line":1823},[262,32419,29113],{"class":429},[262,32421,29613],{"class":275},[262,32423,2903],{"class":429},[262,32425,476],{"class":377},[262,32427,29579],{"class":429},[262,32429,29582],{"class":611},[262,32431,476],{"class":377},[262,32433,29587],{"class":429},[262,32435,29590],{"class":611},[262,32437,476],{"class":377},[262,32439,5508],{"class":271},[262,32441,1315],{"class":429},[262,32443,32444,32447,32449,32451,32454],{"class":181,"line":1846},[262,32445,32446],{"class":611},"                        n_init",[262,32448,476],{"class":377},[262,32450,29604],{"class":275},[262,32452,32453],{"class":429},").fit_predict(embeddings)  ",[262,32455,32456],{"class":291},"# stable, reproducible groups\n",[262,32458,32459],{"class":181,"line":1861},[262,32460,583],{"emptyLinePlaceholder":582},[262,32462,32463],{"class":181,"line":1866},[262,32464,32465],{"class":291},"# 4. Label each group by its central keyword and score it by volume.\n",[262,32467,32468,32471,32473],{"class":181,"line":1871},[262,32469,32470],{"class":429},"rows ",[262,32472,476],{"class":377},[262,32474,489],{"class":429},[262,32476,32477,32479,32482,32484,32486,32488,32491],{"class":181,"line":1890},[262,32478,829],{"class":377},[262,32480,32481],{"class":429}," gid, members ",[262,32483,835],{"class":377},[262,32485,29879],{"class":429},[262,32487,29613],{"class":275},[262,32489,32490],{"class":429},"):                  ",[262,32492,32493],{"class":291},"# one iteration per group\n",[262,32495,32496,32499,32501,32504],{"class":181,"line":1909},[262,32497,32498],{"class":429},"    idx ",[262,32500,476],{"class":377},[262,32502,32503],{"class":429}," members.index.to_numpy()                           ",[262,32505,32506],{"class":291},"# row positions of this group\n",[262,32508,32509,32512,32514,32516,32518,32520,32522,32524,32526,32528,32530,32533],{"class":181,"line":1914},[262,32510,32511],{"class":429},"    centre ",[262,32513,476],{"class":377},[262,32515,31504],{"class":429},[262,32517,992],{"class":611},[262,32519,476],{"class":377},[262,32521,102],{"class":271},[262,32523,608],{"class":429},[262,32525,31515],{"class":611},[262,32527,476],{"class":377},[262,32529,4974],{"class":271},[262,32531,32532],{"class":429},")     ",[262,32534,32535],{"class":291},"# the group's average direction\n",[262,32537,32538],{"class":181,"line":1919},[262,32539,32540],{"class":291},"    # The member closest to that centre is the most representative phrase -> the label.\n",[262,32542,32543,32546,32548,32551,32553,32556,32558],{"class":181,"line":1946},[262,32544,32545],{"class":429},"    label ",[262,32547,476],{"class":377},[262,32549,32550],{"class":429}," df.loc[idx[cosine_similarity(centre, embeddings[idx])[",[262,32552,102],{"class":271},[262,32554,32555],{"class":429},"].argmax()], ",[262,32557,26708],{"class":275},[262,32559,957],{"class":429},[262,32561,32562,32565,32567,32569,32571,32573,32575,32577,32579,32581,32583,32585,32587,32590],{"class":181,"line":1959},[262,32563,32564],{"class":429},"    volume ",[262,32566,476],{"class":377},[262,32568,23813],{"class":271},[262,32570,31562],{"class":429},[262,32572,31565],{"class":275},[262,32574,31568],{"class":429},[262,32576,2210],{"class":377},[262,32578,31573],{"class":275},[262,32580,2821],{"class":377},[262,32582,31578],{"class":429},[262,32584,20859],{"class":377},[262,32586,515],{"class":271},[262,32588,32589],{"class":429},"(members)  ",[262,32591,32592],{"class":291},"# score, with fallback\n",[262,32594,32595,32598,32601,32604,32607,32610,32613,32615,32617,32619,32621],{"class":181,"line":1996},[262,32596,32597],{"class":429},"    rows.append({",[262,32599,32600],{"class":275},"\"group\"",[262,32602,32603],{"class":429},": gid, ",[262,32605,32606],{"class":275},"\"topic\"",[262,32608,32609],{"class":429},": label, ",[262,32611,32612],{"class":275},"\"keywords\"",[262,32614,1231],{"class":429},[262,32616,29318],{"class":271},[262,32618,31616],{"class":429},[262,32620,31619],{"class":275},[262,32622,32623],{"class":429},": volume})\n",[262,32625,32626],{"class":181,"line":2012},[262,32627,583],{"emptyLinePlaceholder":582},[262,32629,32630,32632,32634,32637,32639,32641,32643,32645,32647,32649],{"class":181,"line":2040},[262,32631,31658],{"class":429},[262,32633,476],{"class":377},[262,32635,32636],{"class":429}," pd.DataFrame(rows).sort_values(",[262,32638,31619],{"class":275},[262,32640,608],{"class":429},[262,32642,31375],{"class":611},[262,32644,476],{"class":377},[262,32646,3623],{"class":271},[262,32648,32223],{"class":429},[262,32650,32651],{"class":291},"# best opportunities first\n",[262,32653,32654,32657,32660,32662,32664,32666,32668,32671],{"class":181,"line":2045},[262,32655,32656],{"class":429},"report.to_csv(",[262,32658,32659],{"class":275},"\"keyword_groups.csv\"",[262,32661,608],{"class":429},[262,32663,3618],{"class":611},[262,32665,476],{"class":377},[262,32667,3623],{"class":271},[262,32669,32670],{"class":429},")            ",[262,32672,32673],{"class":291},"# open this in any spreadsheet\n",[262,32675,32676,32678,32680,32682,32684,32686,32688,32690],{"class":181,"line":2050},[262,32677,637],{"class":271},[262,32679,31670],{"class":429},[262,32681,3868],{"class":271},[262,32683,31675],{"class":429},[262,32685,3618],{"class":611},[262,32687,476],{"class":377},[262,32689,3623],{"class":271},[262,32691,2684],{"class":429},[14,32693,32694,32695,32697],{},"Thirty lines turn a messy export into a prioritised content plan. Run it again whenever your keyword list grows; the fixed ",[18,32696,29590],{}," keeps the groups stable between runs, so new keywords slot into existing groups rather than reshuffling everything you already planned.",[14,32699,32700,32701,32703,32704,32706,32707,32710,32711,32714,32715,32718],{},"To read the result, open ",[18,32702,32022],{}," and start at the top. Each row names a page to write, the ",[18,32705,4402],{}," column gives you a working title, the ",[18,32708,32709],{},"keywords"," column tells you how much material you have to cover it, and ",[18,32712,32713],{},"opportunity"," ranks the rows by audience size. To inspect the keywords inside a single group rather than just its label, add a line like ",[18,32716,32717],{},"df[df[\"group_id\"] == 3].to_csv(\"group_3.csv\", index=False)"," to dump that group's full membership — the fastest way to sanity-check that the grouping matches your own sense of the topic before you commit to writing.",[57,32720,2355],{"id":2354},[14,32722,32723],{},"You now have ranked groups of related keywords. Here is where each part of the workflow goes deeper:",[1447,32725,32726,32735,32744,32755],{},[1450,32727,32728,32731,32732,32734],{},[35,32729,32730],{},"Find what competitors rank for that you do not."," Run the ",[51,32733,25851],{"href":25850}," to fill gaps in your list before you embed it.",[1450,32736,32737,32740,32741,32743],{},[35,32738,32739],{},"Tune the grouping."," ",[51,32742,28902],{"href":28901}," covers choosing the right number of groups and labelling them more cleanly.",[1450,32745,32746,32749,32750,32752,32753,1363],{},[35,32747,32748],{},"Turn topics into pages."," Feed your top groups into ",[51,32751,26437],{"href":26436}," and the drafting recipes in ",[51,32754,3991],{"href":3990},[1450,32756,32757,32760,32761,1363],{},[35,32758,32759],{},"Distribute what you publish."," Once pages are live, push them out with ",[51,32762,9309],{"href":9308},[14,32764,2375,32765,1363],{},[51,32766,5413],{"href":5412},[57,32768,2381],{"id":2380},[2322,32770,32771,32776,32781,32786,32791],{},[1450,32772,32773,32775],{},[51,32774,25851],{"href":25850}," — find the terms rivals rank for and you miss.",[1450,32777,32778,32780],{},[51,32779,28902],{"href":28901}," — a deeper dive into the clustering step.",[1450,32782,32783,32785],{},[51,32784,26437],{"href":26436}," — turn each group into publish-ready metadata.",[1450,32787,32788,32790],{},[51,32789,3991],{"href":3990}," — draft the pages your keyword groups point to.",[1450,32792,32793,32795],{},[51,32794,5413],{"href":5412}," — the main hub for every guide in this track.",[2401,32797,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":32799},[32800,32801,32802,32803,32804,32805,32806,32807,32808,32809],{"id":237,"depth":282,"text":238},{"id":30529,"depth":282,"text":30530},{"id":30876,"depth":282,"text":30877},{"id":31169,"depth":282,"text":31170},{"id":31411,"depth":282,"text":31412},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":31998,"depth":282,"text":31999},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Automate SEO keyword research with Python: fetch search terms, embed them with the OpenAI API, group related keywords, and prioritise what to write.",[32812,32815,32818,32821,32824],{"q":32813,"a":32814},"Do I need a paid SEO tool to do keyword research with Python?","No. You need search-term data from somewhere, but that can come from a free keyword export, your Google Search Console account, or a low-cost SERP API. Python handles the grouping and prioritising for free once you have a plain list of keywords.",{"q":32816,"a":32817},"Why use AI embeddings instead of just matching words?","Word matching treats 'cheap flights' and 'budget airfare' as unrelated because they share no words. Embeddings turn each phrase into numbers that capture meaning, so related keywords land near each other even when the wording differs.",{"q":32819,"a":32820},"How many keywords can I process at once?","The OpenAI embeddings endpoint accepts up to 2,048 inputs per request, so a few thousand keywords cost only a handful of API calls and a few cents. For tens of thousands, batch them in chunks and add a short pause between requests.",{"q":32822,"a":32823},"Which OpenAI model should I use for keyword embeddings?","Use text-embedding-3-small. It is fast, costs a fraction of a cent per thousand keywords, and is accurate enough to group search terms reliably. Only move to text-embedding-3-large if grouping quality is clearly too coarse.",{"q":32825,"a":32826},"How do I decide how many groups to split keywords into?","Start with one group per roughly 15 to 25 keywords, then look at the results. If groups feel too broad, raise the number; if you see near-duplicate groups, lower it. There is no single correct value, so iterate on a sample first.",{"name":32828,"steps":32829},"How to do SEO keyword research with Python",[32830,32832,32835,32838],{"name":9375,"text":32831},"Create a virtual environment, install the libraries, and store your API key in a .env file.",{"name":32833,"text":32834},"Fetch and clean your keyword list","Load search terms from a CSV or SERP API into a pandas DataFrame and remove noise.",{"name":32836,"text":32837},"Embed the keywords with the OpenAI API","Turn each keyword into a numeric vector that captures its meaning.",{"name":32839,"text":32840},"Group related keywords and prioritise them","Cluster the vectors with k-means, label each group, and rank them by opportunity.",{},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python",{"title":9304,"description":32810},"ai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Findex","irgoiMOl4VSg8rnF0W02kQkeeVcDS8T0ECN6VKLyI68",{"id":32847,"title":25851,"body":32848,"description":35097,"extension":2419,"faq":35098,"howto":35114,"meta":35132,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":35133,"published":5387,"seo":35134,"seoTitle":25851,"stem":35135,"__hash__":35136},"content\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fpython-script-for-competitor-keyword-analysis\u002Findex.md",{"type":7,"value":32849,"toc":35085},[32850,32853,32860,32863,32873,32875,32880,32920,32938,32943,32951,32958,32972,32981,32985,32998,33001,33644,33647,33678,33692,33696,33699,33800,33806,33851,33868,33872,33881,33999,34014,34018,34037,34368,34389,34400,34431,34435,34446,34823,34826,34872,34879,34881,34971,34973,35020,35022,35049,35054,35058,35060,35082],[10,32851,25851],{"id":32852},"python-script-for-competitor-keyword-analysis",[14,32854,32855,32856,32859],{},"This guide shows you how to build a Python script that pulls keywords from a competitor's pages, compares them to your own, finds the gaps you should fill, and labels each gap by search intent with an LLM — in about twenty minutes. \"Search intent\" just means ",[27,32857,32858],{},"why"," someone typed a phrase: to learn something, to compare options, or to buy. No SEO subscription required.",[14,32861,32862],{},"The idea is simple. Competitors have already done expensive keyword research, and the words they emphasise on their best pages are a free signal of what works in your niche. We will turn that signal into a tidy spreadsheet of opportunities.",[14,32864,32865,32866,32869,32870,32872],{},"The script comes in four small parts, and each one stands on its own so you can stop early or swap a piece out. First we gather keywords from competitor pages. Then we compare them with the keywords you already target. Next we filter down to the gaps — phrases they use and you do not. Finally, we ask an LLM to label each gap by intent so you know which ones to act on first. By the end you will have a ",[18,32867,32868],{},"keyword_gaps.csv"," file ready to drop into your content plan. This work sits inside the wider ",[51,32871,9304],{"href":9303}," track, so once the basics click you can layer on grouping and bulk metadata generation.",[57,32874,238],{"id":237},[14,32876,32877,32878,12902],{},"You only need a few things beyond a working Python setup. If Python is not installed yet, start with ",[51,32879,2482],{"href":2481},[253,32881,32883],{"className":255,"code":32882,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate  # Windows: .venv\\Scripts\\activate\npip install httpx beautifulsoup4 pandas openai python-dotenv\n",[18,32884,32885,32895,32903],{"__ignoreMap":258},[262,32886,32887,32889,32891,32893],{"class":181,"line":264},[262,32888,416],{"class":267},[262,32890,272],{"class":271},[262,32892,276],{"class":275},[262,32894,279],{"class":275},[262,32896,32897,32899,32901],{"class":181,"line":282},[262,32898,285],{"class":271},[262,32900,288],{"class":275},[262,32902,26589],{"class":291},[262,32904,32905,32907,32909,32911,32914,32916,32918],{"class":181,"line":295},[262,32906,298],{"class":267},[262,32908,301],{"class":275},[262,32910,5440],{"class":275},[262,32912,32913],{"class":275}," beautifulsoup4",[262,32915,2516],{"class":275},[262,32917,2519],{"class":275},[262,32919,2522],{"class":275},[14,32921,22732,32922,32924,32925,32928,32929,32931,32932,32934,32935,32937],{},[18,32923,5450],{}," to fetch pages, ",[18,32926,32927],{},"beautifulsoup4"," to read the HTML, ",[18,32930,2494],{}," to compare keyword sets, and the ",[18,32933,20],{}," SDK to label intent. The ",[18,32936,2501],{}," package loads your API key from a file so it never ends up in your code.",[14,32939,2525,32940,32942],{},[18,32941,319],{}," next to your script with one line:",[253,32944,32945],{"className":323,"code":11159,"language":325,"meta":258,"style":258},[18,32946,32947],{"__ignoreMap":258},[262,32948,32949],{"class":181,"line":264},[262,32950,11159],{},[14,32952,353,32953,356,32955,32957],{},[18,32954,319],{},[18,32956,359],{}," immediately so your secret key is never committed to version control. A leaked key can run up real charges on your account.",[253,32959,32960],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,32961,32962],{"__ignoreMap":258},[262,32963,32964,32966,32968,32970],{"class":181,"line":264},[262,32965,371],{"class":271},[262,32967,374],{"class":275},[262,32969,378],{"class":377},[262,32971,381],{"class":275},[14,32973,32974,32975,32977,32978,32980],{},"If you have never made an LLM call before, ",[51,32976,2487],{"href":2486}," walks through the basics, and if your key is rejected, ",[51,32979,388],{"href":387}," covers the usual causes.",[57,32982,32984],{"id":32983},"step-1-gather-competitor-keywords","Step 1: Gather competitor keywords",[14,32986,32987,32988,32990,32991,32993,32994,32997],{},"First we fetch each competitor page and pull out candidate keywords. We read the page title, the meta description, and every heading (",[18,32989,10],{}," through ",[18,32992,12782],{},"), because those are where a page declares the topics it most wants to rank for. Then we break that text into one-, two-, and three-word phrases (called ",[27,32995,32996],{},"n-grams",") and count how often each appears.",[14,32999,33000],{},"We drop common filler words (\"the\", \"and\", \"with\") so they do not crowd out real keywords. Keeping the keyword-gathering logic in its own function means you can test it on a single URL before running the whole pipeline.",[253,33002,33004],{"className":414,"code":33003,"language":416,"meta":258,"style":258},"import re\nimport httpx\nimport pandas as pd\nfrom bs4 import BeautifulSoup\n\nSTOPWORDS = {\n    \"the\", \"and\", \"for\", \"with\", \"you\", \"your\", \"our\", \"this\", \"that\",\n    \"are\", \"from\", \"have\", \"has\", \"was\", \"can\", \"will\", \"how\", \"what\",\n    \"http\", \"https\", \"www\", \"com\", \"org\", \"click\", \"read\", \"more\", \"all\",\n}\n\n\ndef gather_keywords(urls: list[str]) -> pd.DataFrame:\n    \"\"\"Fetch each URL and return a DataFrame of keyword + frequency.\"\"\"\n    headers = {\"User-Agent\": \"Mozilla\u002F5.0 (keyword-research-bot)\"}\n    grams: list[str] = []\n    with httpx.Client(headers=headers, timeout=10.0, follow_redirects=True) as client:\n        for url in urls:\n            resp = client.get(url)\n            resp.raise_for_status()\n            soup = BeautifulSoup(resp.text, \"html.parser\")\n            parts = [soup.title.string if soup.title else \"\"]\n            meta = soup.find(\"meta\", attrs={\"name\": \"description\"})\n            if meta and meta.get(\"content\"):\n                parts.append(meta[\"content\"])\n            parts += [h.get_text() for h in soup.find_all([\"h1\", \"h2\", \"h3\"])]\n            text = re.sub(r\"[^a-z\\s]\", \" \", \" \".join(parts).lower())\n            tokens = [t for t in text.split() if t not in STOPWORDS and len(t) > 2]\n            for n in (1, 2, 3):\n                grams += [\" \".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]\n\n    df = pd.Series(grams).value_counts().reset_index()\n    df.columns = [\"keyword\", \"frequency\"]\n    return df[df[\"frequency\"] >= 2].reset_index(drop=True)\n",[18,33005,33006,33012,33018,33028,33040,33044,33053,33100,33147,33194,33198,33202,33206,33221,33226,33244,33257,33290,33302,33312,33317,33332,33353,33385,33401,33410,33444,33476,33520,33544,33588,33592,33601,33619],{"__ignoreMap":258},[262,33007,33008,33010],{"class":181,"line":264},[262,33009,684],{"class":377},[262,33011,7956],{"class":429},[262,33013,33014,33016],{"class":181,"line":282},[262,33015,684],{"class":377},[262,33017,6526],{"class":429},[262,33019,33020,33022,33024,33026],{"class":181,"line":295},[262,33021,684],{"class":377},[262,33023,2619],{"class":429},[262,33025,697],{"class":377},[262,33027,2624],{"class":429},[262,33029,33030,33032,33035,33037],{"class":181,"line":345},[262,33031,705],{"class":377},[262,33033,33034],{"class":429}," bs4 ",[262,33036,684],{"class":377},[262,33038,33039],{"class":429}," BeautifulSoup\n",[262,33041,33042],{"class":181,"line":492},[262,33043,583],{"emptyLinePlaceholder":582},[262,33045,33046,33049,33051],{"class":181,"line":503},[262,33047,33048],{"class":271},"STOPWORDS",[262,33050,442],{"class":377},[262,33052,20437],{"class":429},[262,33054,33055,33058,33060,33063,33065,33068,33070,33073,33075,33078,33080,33083,33085,33088,33090,33093,33095,33098],{"class":181,"line":521},[262,33056,33057],{"class":275},"    \"the\"",[262,33059,608],{"class":429},[262,33061,33062],{"class":275},"\"and\"",[262,33064,608],{"class":429},[262,33066,33067],{"class":275},"\"for\"",[262,33069,608],{"class":429},[262,33071,33072],{"class":275},"\"with\"",[262,33074,608],{"class":429},[262,33076,33077],{"class":275},"\"you\"",[262,33079,608],{"class":429},[262,33081,33082],{"class":275},"\"your\"",[262,33084,608],{"class":429},[262,33086,33087],{"class":275},"\"our\"",[262,33089,608],{"class":429},[262,33091,33092],{"class":275},"\"this\"",[262,33094,608],{"class":429},[262,33096,33097],{"class":275},"\"that\"",[262,33099,1315],{"class":429},[262,33101,33102,33105,33107,33110,33112,33115,33117,33120,33122,33125,33127,33130,33132,33135,33137,33140,33142,33145],{"class":181,"line":537},[262,33103,33104],{"class":275},"    \"are\"",[262,33106,608],{"class":429},[262,33108,33109],{"class":275},"\"from\"",[262,33111,608],{"class":429},[262,33113,33114],{"class":275},"\"have\"",[262,33116,608],{"class":429},[262,33118,33119],{"class":275},"\"has\"",[262,33121,608],{"class":429},[262,33123,33124],{"class":275},"\"was\"",[262,33126,608],{"class":429},[262,33128,33129],{"class":275},"\"can\"",[262,33131,608],{"class":429},[262,33133,33134],{"class":275},"\"will\"",[262,33136,608],{"class":429},[262,33138,33139],{"class":275},"\"how\"",[262,33141,608],{"class":429},[262,33143,33144],{"class":275},"\"what\"",[262,33146,1315],{"class":429},[262,33148,33149,33152,33154,33157,33159,33162,33164,33167,33169,33172,33174,33177,33179,33182,33184,33187,33189,33192],{"class":181,"line":549},[262,33150,33151],{"class":275},"    \"http\"",[262,33153,608],{"class":429},[262,33155,33156],{"class":275},"\"https\"",[262,33158,608],{"class":429},[262,33160,33161],{"class":275},"\"www\"",[262,33163,608],{"class":429},[262,33165,33166],{"class":275},"\"com\"",[262,33168,608],{"class":429},[262,33170,33171],{"class":275},"\"org\"",[262,33173,608],{"class":429},[262,33175,33176],{"class":275},"\"click\"",[262,33178,608],{"class":429},[262,33180,33181],{"class":275},"\"read\"",[262,33183,608],{"class":429},[262,33185,33186],{"class":275},"\"more\"",[262,33188,608],{"class":429},[262,33190,33191],{"class":275},"\"all\"",[262,33193,1315],{"class":429},[262,33195,33196],{"class":181,"line":570},[262,33197,16430],{"class":429},[262,33199,33200],{"class":181,"line":579},[262,33201,583],{"emptyLinePlaceholder":582},[262,33203,33204],{"class":181,"line":586},[262,33205,583],{"emptyLinePlaceholder":582},[262,33207,33208,33210,33213,33216,33218],{"class":181,"line":591},[262,33209,423],{"class":377},[262,33211,33212],{"class":267}," gather_keywords",[262,33214,33215],{"class":429},"(urls: list[",[262,33217,433],{"class":271},[262,33219,33220],{"class":429},"]) -> pd.DataFrame:\n",[262,33222,33223],{"class":181,"line":623},[262,33224,33225],{"class":275},"    \"\"\"Fetch each URL and return a DataFrame of keyword + frequency.\"\"\"\n",[262,33227,33228,33230,33232,33234,33237,33239,33242],{"class":181,"line":634},[262,33229,16991],{"class":429},[262,33231,476],{"class":377},[262,33233,2276],{"class":429},[262,33235,33236],{"class":275},"\"User-Agent\"",[262,33238,1231],{"class":429},[262,33240,33241],{"class":275},"\"Mozilla\u002F5.0 (keyword-research-bot)\"",[262,33243,16430],{"class":429},[262,33245,33246,33249,33251,33253,33255],{"class":181,"line":845},[262,33247,33248],{"class":429},"    grams: list[",[262,33250,433],{"class":271},[262,33252,2903],{"class":429},[262,33254,476],{"class":377},[262,33256,489],{"class":429},[262,33258,33259,33261,33263,33265,33267,33269,33271,33273,33276,33278,33280,33282,33284,33286,33288],{"class":181,"line":850},[262,33260,10124],{"class":377},[262,33262,17018],{"class":429},[262,33264,17057],{"class":611},[262,33266,476],{"class":377},[262,33268,19503],{"class":429},[262,33270,1591],{"class":611},[262,33272,476],{"class":377},[262,33274,33275],{"class":271},"10.0",[262,33277,608],{"class":429},[262,33279,23191],{"class":611},[262,33281,476],{"class":377},[262,33283,4974],{"class":271},[262,33285,1000],{"class":429},[262,33287,697],{"class":377},[262,33289,23784],{"class":429},[262,33291,33292,33294,33297,33299],{"class":181,"line":864},[262,33293,10155],{"class":377},[262,33295,33296],{"class":429}," url ",[262,33298,835],{"class":377},[262,33300,33301],{"class":429}," urls:\n",[262,33303,33304,33307,33309],{"class":181,"line":1683},[262,33305,33306],{"class":429},"            resp ",[262,33308,476],{"class":377},[262,33310,33311],{"class":429}," client.get(url)\n",[262,33313,33314],{"class":181,"line":1688},[262,33315,33316],{"class":429},"            resp.raise_for_status()\n",[262,33318,33319,33322,33324,33327,33330],{"class":181,"line":1693},[262,33320,33321],{"class":429},"            soup ",[262,33323,476],{"class":377},[262,33325,33326],{"class":429}," BeautifulSoup(resp.text, ",[262,33328,33329],{"class":275},"\"html.parser\"",[262,33331,660],{"class":429},[262,33333,33334,33337,33339,33342,33344,33347,33349,33351],{"class":181,"line":1728},[262,33335,33336],{"class":429},"            parts ",[262,33338,476],{"class":377},[262,33340,33341],{"class":429}," [soup.title.string ",[262,33343,2210],{"class":377},[262,33345,33346],{"class":429}," soup.title ",[262,33348,20859],{"class":377},[262,33350,6332],{"class":275},[262,33352,957],{"class":429},[262,33354,33355,33358,33360,33363,33366,33368,33371,33373,33375,33378,33380,33383],{"class":181,"line":1737},[262,33356,33357],{"class":429},"            meta ",[262,33359,476],{"class":377},[262,33361,33362],{"class":429}," soup.find(",[262,33364,33365],{"class":275},"\"meta\"",[262,33367,608],{"class":429},[262,33369,33370],{"class":611},"attrs",[262,33372,476],{"class":377},[262,33374,3039],{"class":429},[262,33376,33377],{"class":275},"\"name\"",[262,33379,1231],{"class":429},[262,33381,33382],{"class":275},"\"description\"",[262,33384,10332],{"class":429},[262,33386,33387,33389,33392,33394,33397,33399],{"class":181,"line":1751},[262,33388,10200],{"class":377},[262,33390,33391],{"class":429}," meta ",[262,33393,6101],{"class":377},[262,33395,33396],{"class":429}," meta.get(",[262,33398,1239],{"class":275},[262,33400,8192],{"class":429},[262,33402,33403,33406,33408],{"class":181,"line":1764},[262,33404,33405],{"class":429},"                parts.append(meta[",[262,33407,1239],{"class":275},[262,33409,3512],{"class":429},[262,33411,33412,33414,33416,33419,33421,33423,33425,33428,33431,33433,33436,33438,33441],{"class":181,"line":1779},[262,33413,33336],{"class":429},[262,33415,555],{"class":377},[262,33417,33418],{"class":429}," [h.get_text() ",[262,33420,829],{"class":377},[262,33422,1079],{"class":429},[262,33424,835],{"class":377},[262,33426,33427],{"class":429}," soup.find_all([",[262,33429,33430],{"class":275},"\"h1\"",[262,33432,608],{"class":429},[262,33434,33435],{"class":275},"\"h2\"",[262,33437,608],{"class":429},[262,33439,33440],{"class":275},"\"h3\"",[262,33442,33443],{"class":429},"])]\n",[262,33445,33446,33448,33450,33452,33454,33456,33458,33460,33463,33465,33467,33469,33471,33473],{"class":181,"line":1793},[262,33447,18347],{"class":429},[262,33449,476],{"class":377},[262,33451,12111],{"class":429},[262,33453,7973],{"class":377},[262,33455,1176],{"class":275},[262,33457,12118],{"class":271},[262,33459,12121],{"class":377},[262,33461,33462],{"class":271},"a-z\\s]",[262,33464,1176],{"class":275},[262,33466,608],{"class":429},[262,33468,543],{"class":275},[262,33470,608],{"class":429},[262,33472,543],{"class":275},[262,33474,33475],{"class":429},".join(parts).lower())\n",[262,33477,33478,33481,33483,33486,33488,33490,33492,33495,33497,33499,33501,33503,33506,33509,33511,33514,33516,33518],{"class":181,"line":1800},[262,33479,33480],{"class":429},"            tokens ",[262,33482,476],{"class":377},[262,33484,33485],{"class":429}," [t ",[262,33487,829],{"class":377},[262,33489,20677],{"class":429},[262,33491,835],{"class":377},[262,33493,33494],{"class":429}," text.split() ",[262,33496,2210],{"class":377},[262,33498,20677],{"class":429},[262,33500,17892],{"class":377},[262,33502,2821],{"class":377},[262,33504,33505],{"class":271}," STOPWORDS",[262,33507,33508],{"class":377}," and",[262,33510,515],{"class":271},[262,33512,33513],{"class":429},"(t) ",[262,33515,8086],{"class":377},[262,33517,3232],{"class":271},[262,33519,957],{"class":429},[262,33521,33522,33525,33528,33530,33532,33534,33536,33538,33540,33542],{"class":181,"line":1805},[262,33523,33524],{"class":377},"            for",[262,33526,33527],{"class":429}," n ",[262,33529,835],{"class":377},[262,33531,13751],{"class":429},[262,33533,997],{"class":271},[262,33535,608],{"class":429},[262,33537,109],{"class":271},[262,33539,608],{"class":429},[262,33541,5556],{"class":271},[262,33543,8192],{"class":429},[262,33545,33546,33549,33551,33553,33555,33558,33560,33563,33565,33567,33569,33571,33573,33575,33578,33580,33582,33584,33586],{"class":181,"line":1810},[262,33547,33548],{"class":429},"                grams ",[262,33550,555],{"class":377},[262,33552,10563],{"class":429},[262,33554,543],{"class":275},[262,33556,33557],{"class":429},".join(tokens[i:i ",[262,33559,531],{"class":377},[262,33561,33562],{"class":429}," n]) ",[262,33564,829],{"class":377},[262,33566,1043],{"class":429},[262,33568,835],{"class":377},[262,33570,3082],{"class":271},[262,33572,602],{"class":429},[262,33574,29318],{"class":271},[262,33576,33577],{"class":429},"(tokens) ",[262,33579,561],{"class":377},[262,33581,33527],{"class":429},[262,33583,531],{"class":377},[262,33585,3243],{"class":271},[262,33587,18503],{"class":429},[262,33589,33590],{"class":181,"line":1823},[262,33591,583],{"emptyLinePlaceholder":582},[262,33593,33594,33596,33598],{"class":181,"line":1846},[262,33595,26737],{"class":429},[262,33597,476],{"class":377},[262,33599,33600],{"class":429}," pd.Series(grams).value_counts().reset_index()\n",[262,33602,33603,33606,33608,33610,33612,33614,33617],{"class":181,"line":1861},[262,33604,33605],{"class":429},"    df.columns ",[262,33607,476],{"class":377},[262,33609,10563],{"class":429},[262,33611,26708],{"class":275},[262,33613,608],{"class":429},[262,33615,33616],{"class":275},"\"frequency\"",[262,33618,957],{"class":429},[262,33620,33621,33623,33625,33627,33629,33632,33634,33636,33638,33640,33642],{"class":181,"line":1866},[262,33622,573],{"class":377},[262,33624,29140],{"class":429},[262,33626,33616],{"class":275},[262,33628,2903],{"class":429},[262,33630,33631],{"class":377},">=",[262,33633,3232],{"class":271},[262,33635,30655],{"class":429},[262,33637,26854],{"class":611},[262,33639,476],{"class":377},[262,33641,4974],{"class":271},[262,33643,660],{"class":429},[14,33645,33646],{},"Test it on one URL before going further:",[253,33648,33650],{"className":414,"code":33649,"language":416,"meta":258,"style":258},"competitor = gather_keywords([\"https:\u002F\u002Fexample.com\u002Fblog\u002Fsome-strong-post\"])\nprint(competitor.head(15))\n",[18,33651,33652,33667],{"__ignoreMap":258},[262,33653,33654,33657,33659,33662,33665],{"class":181,"line":264},[262,33655,33656],{"class":429},"competitor ",[262,33658,476],{"class":377},[262,33660,33661],{"class":429}," gather_keywords([",[262,33663,33664],{"class":275},"\"https:\u002F\u002Fexample.com\u002Fblog\u002Fsome-strong-post\"",[262,33666,3512],{"class":429},[262,33668,33669,33671,33674,33676],{"class":181,"line":282},[262,33670,637],{"class":271},[262,33672,33673],{"class":429},"(competitor.head(",[262,33675,17025],{"class":271},[262,33677,2684],{"class":429},[14,33679,33680,33681,33683,33684,33687,33688,3921,33690,1363],{},"You should see a ranked table of phrases. If most rows look like junk, widen the ",[18,33682,33048],{}," set or raise the ",[18,33685,33686],{},"frequency"," threshold from ",[18,33689,109],{},[18,33691,5556],{},[57,33693,33695],{"id":33694},"step-2-compare-keyword-sets-with-pandas","Step 2: Compare keyword sets with pandas",[14,33697,33698],{},"Now we load your own keywords — the phrases your pages already target — and compare them with the competitor list. Your keywords can come from a one-column CSV you export from your site, your analytics tool, or even a quick list you type by hand. The goal is a single DataFrame that shows, for every competitor phrase, whether you also use it.",[253,33700,33702],{"className":414,"code":33701,"language":416,"meta":258,"style":258},"def compare_keywords(competitor_df: pd.DataFrame, my_keywords: list[str]) -> pd.DataFrame:\n    \"\"\"Mark which competitor keywords you already cover.\"\"\"\n    mine = {k.strip().lower() for k in my_keywords}\n    merged = competitor_df.copy()\n    merged[\"i_cover_it\"] = merged[\"keyword\"].isin(mine)\n    return merged.sort_values(\"frequency\", ascending=False).reset_index(drop=True)\n",[18,33703,33704,33718,33723,33743,33753,33773],{"__ignoreMap":258},[262,33705,33706,33708,33711,33714,33716],{"class":181,"line":264},[262,33707,423],{"class":377},[262,33709,33710],{"class":267}," compare_keywords",[262,33712,33713],{"class":429},"(competitor_df: pd.DataFrame, my_keywords: list[",[262,33715,433],{"class":271},[262,33717,33220],{"class":429},[262,33719,33720],{"class":181,"line":282},[262,33721,33722],{"class":275},"    \"\"\"Mark which competitor keywords you already cover.\"\"\"\n",[262,33724,33725,33728,33730,33733,33735,33738,33740],{"class":181,"line":295},[262,33726,33727],{"class":429},"    mine ",[262,33729,476],{"class":377},[262,33731,33732],{"class":429}," {k.strip().lower() ",[262,33734,829],{"class":377},[262,33736,33737],{"class":429}," k ",[262,33739,835],{"class":377},[262,33741,33742],{"class":429}," my_keywords}\n",[262,33744,33745,33748,33750],{"class":181,"line":345},[262,33746,33747],{"class":429},"    merged ",[262,33749,476],{"class":377},[262,33751,33752],{"class":429}," competitor_df.copy()\n",[262,33754,33755,33758,33761,33763,33765,33768,33770],{"class":181,"line":492},[262,33756,33757],{"class":429},"    merged[",[262,33759,33760],{"class":275},"\"i_cover_it\"",[262,33762,2903],{"class":429},[262,33764,476],{"class":377},[262,33766,33767],{"class":429}," merged[",[262,33769,26708],{"class":275},[262,33771,33772],{"class":429},"].isin(mine)\n",[262,33774,33775,33777,33780,33782,33784,33786,33788,33790,33792,33794,33796,33798],{"class":181,"line":503},[262,33776,573],{"class":377},[262,33778,33779],{"class":429}," merged.sort_values(",[262,33781,33616],{"class":275},[262,33783,608],{"class":429},[262,33785,31375],{"class":611},[262,33787,476],{"class":377},[262,33789,3623],{"class":271},[262,33791,29156],{"class":429},[262,33793,26854],{"class":611},[262,33795,476],{"class":377},[262,33797,4974],{"class":271},[262,33799,660],{"class":429},[14,33801,33802,33803,33805],{},"Load your keywords from a CSV with a single ",[18,33804,26611],{}," column and run the comparison:",[253,33807,33809],{"className":414,"code":33808,"language":416,"meta":258,"style":258},"my_df = pd.read_csv(\"my_keywords.csv\")\ncompared = compare_keywords(competitor, my_df[\"keyword\"].tolist())\nprint(compared[\"i_cover_it\"].value_counts())\n",[18,33810,33811,33825,33839],{"__ignoreMap":258},[262,33812,33813,33816,33818,33820,33823],{"class":181,"line":264},[262,33814,33815],{"class":429},"my_df ",[262,33817,476],{"class":377},[262,33819,2760],{"class":429},[262,33821,33822],{"class":275},"\"my_keywords.csv\"",[262,33824,660],{"class":429},[262,33826,33827,33830,33832,33835,33837],{"class":181,"line":282},[262,33828,33829],{"class":429},"compared ",[262,33831,476],{"class":377},[262,33833,33834],{"class":429}," compare_keywords(competitor, my_df[",[262,33836,26708],{"class":275},[262,33838,29410],{"class":429},[262,33840,33841,33843,33846,33848],{"class":181,"line":295},[262,33842,637],{"class":271},[262,33844,33845],{"class":429},"(compared[",[262,33847,33760],{"class":275},[262,33849,33850],{"class":429},"].value_counts())\n",[14,33852,3349,33853,33856,33857,33860,33861,33864,33865,33867],{},[18,33854,33855],{},"value_counts()"," line gives you a quick overview: how many competitor phrases you already cover versus how many you do not. Using a Python ",[18,33858,33859],{},"set"," for your keywords keeps the ",[18,33862,33863],{},"isin"," lookup fast even with thousands of phrases. If you want to go deeper on pandas itself, ",[51,33866,2919],{"href":2918}," covers the tidying steps that make merges like this reliable.",[57,33869,33871],{"id":33870},"step-3-find-the-gaps","Step 3: Find the gaps",[14,33873,33874,33875,8468,33878,33880],{},"A gap is any competitor phrase where ",[18,33876,33877],{},"i_cover_it",[18,33879,3623],{},". Those are the topics worth adding to your plan. We keep the most frequent gaps first, because a phrase a competitor repeats across headings is one they care about most.",[253,33882,33884],{"className":414,"code":33883,"language":416,"meta":258,"style":258},"def find_gaps(compared_df: pd.DataFrame, top_n: int = 30) -> pd.DataFrame:\n    \"\"\"Return the competitor keywords you do not yet cover.\"\"\"\n    gaps = compared_df[~compared_df[\"i_cover_it\"]].copy()\n    gaps = gaps.drop(columns=[\"i_cover_it\"])\n    return gaps.head(top_n).reset_index(drop=True)\n\n\ngaps = find_gaps(compared, top_n=30)\nprint(gaps)\n",[18,33885,33886,33904,33909,33930,33950,33965,33969,33973,33992],{"__ignoreMap":258},[262,33887,33888,33890,33893,33896,33898,33900,33902],{"class":181,"line":264},[262,33889,423],{"class":377},[262,33891,33892],{"class":267}," find_gaps",[262,33894,33895],{"class":429},"(compared_df: pd.DataFrame, top_n: ",[262,33897,439],{"class":271},[262,33899,442],{"class":377},[262,33901,8114],{"class":271},[262,33903,26732],{"class":429},[262,33905,33906],{"class":181,"line":282},[262,33907,33908],{"class":275},"    \"\"\"Return the competitor keywords you do not yet cover.\"\"\"\n",[262,33910,33911,33914,33916,33919,33922,33925,33927],{"class":181,"line":295},[262,33912,33913],{"class":429},"    gaps ",[262,33915,476],{"class":377},[262,33917,33918],{"class":429}," compared_df[",[262,33920,33921],{"class":377},"~",[262,33923,33924],{"class":429},"compared_df[",[262,33926,33760],{"class":275},[262,33928,33929],{"class":429},"]].copy()\n",[262,33931,33932,33934,33936,33939,33942,33944,33946,33948],{"class":181,"line":345},[262,33933,33913],{"class":429},[262,33935,476],{"class":377},[262,33937,33938],{"class":429}," gaps.drop(",[262,33940,33941],{"class":611},"columns",[262,33943,476],{"class":377},[262,33945,12118],{"class":429},[262,33947,33760],{"class":275},[262,33949,3512],{"class":429},[262,33951,33952,33954,33957,33959,33961,33963],{"class":181,"line":492},[262,33953,573],{"class":377},[262,33955,33956],{"class":429}," gaps.head(top_n).reset_index(",[262,33958,26854],{"class":611},[262,33960,476],{"class":377},[262,33962,4974],{"class":271},[262,33964,660],{"class":429},[262,33966,33967],{"class":181,"line":503},[262,33968,583],{"emptyLinePlaceholder":582},[262,33970,33971],{"class":181,"line":521},[262,33972,583],{"emptyLinePlaceholder":582},[262,33974,33975,33978,33980,33983,33986,33988,33990],{"class":181,"line":537},[262,33976,33977],{"class":429},"gaps ",[262,33979,476],{"class":377},[262,33981,33982],{"class":429}," find_gaps(compared, ",[262,33984,33985],{"class":611},"top_n",[262,33987,476],{"class":377},[262,33989,9777],{"class":271},[262,33991,660],{"class":429},[262,33993,33994,33996],{"class":181,"line":549},[262,33995,637],{"class":271},[262,33997,33998],{"class":429},"(gaps)\n",[14,34000,3349,34001,34003,34004,34007,34008,34010,34011,34013],{},[18,34002,33921],{}," operator means \"not\", so ",[18,34005,34006],{},"~compared_df[\"i_cover_it\"]"," selects the rows where you do ",[27,34009,17892],{}," cover the phrase. Trimming to ",[18,34012,33985],{}," keeps the output focused; thirty strong opportunities are far more useful than three hundred noisy ones. At this point you already have a usable result — a ranked list of topics to write about next.",[57,34015,34017],{"id":34016},"step-4-label-intent-with-an-llm","Step 4: Label intent with an LLM",[14,34019,34020,34021,34023,34024,608,34027,14716,34030,34033,34034,34036],{},"Raw gap phrases are more actionable once you know ",[27,34022,32858],{}," people search them. We send the whole gap list to an LLM in a single request and ask it to tag each phrase as ",[18,34025,34026],{},"informational",[18,34028,34029],{},"commercial",[18,34031,34032],{},"transactional",". Batching every keyword into one call keeps the run cheap and fast — one request instead of dozens. We ask for JSON so the answer is easy to parse, and we set ",[18,34035,1357],{}," so the labels stay consistent between runs.",[253,34038,34040],{"className":414,"code":34039,"language":416,"meta":258,"style":258},"import os\nimport json\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\n\ndef label_intent(gaps_df: pd.DataFrame) -> pd.DataFrame:\n    keywords = gaps_df[\"keyword\"].tolist()\n    prompt = (\n        \"For each keyword, label the search intent as one of: \"\n        \"informational, commercial, or transactional. \"\n        \"Return JSON: {\\\"results\\\": [{\\\"keyword\\\": ..., \\\"intent\\\": ...}]}. \"\n        f\"Keywords: {keywords}\"\n    )\n    resp = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        temperature=0,\n        response_format={\"type\": \"json_object\"},\n    )\n    data = json.loads(resp.choices[0].message.content)\n    labels = pd.DataFrame(data[\"results\"])\n    return gaps_df.merge(labels, on=\"keyword\", how=\"left\")\n\n\nlabelled = label_intent(gaps)\nlabelled.to_csv(\"keyword_gaps.csv\", index=False, encoding=\"utf-8-sig\")\nprint(labelled)\n",[18,34041,34042,34048,34054,34064,34074,34078,34082,34092,34096,34100,34110,34124,34132,34137,34142,34176,34191,34195,34203,34213,34233,34243,34259,34263,34276,34291,34317,34321,34325,34335,34361],{"__ignoreMap":258},[262,34043,34044,34046],{"class":181,"line":264},[262,34045,684],{"class":377},[262,34047,687],{"class":429},[262,34049,34050,34052],{"class":181,"line":282},[262,34051,684],{"class":377},[262,34053,5766],{"class":429},[262,34055,34056,34058,34060,34062],{"class":181,"line":295},[262,34057,705],{"class":377},[262,34059,720],{"class":429},[262,34061,684],{"class":377},[262,34063,725],{"class":429},[262,34065,34066,34068,34070,34072],{"class":181,"line":345},[262,34067,705],{"class":377},[262,34069,708],{"class":429},[262,34071,684],{"class":377},[262,34073,713],{"class":429},[262,34075,34076],{"class":181,"line":492},[262,34077,583],{"emptyLinePlaceholder":582},[262,34079,34080],{"class":181,"line":503},[262,34081,734],{"class":429},[262,34083,34084,34086,34088,34090],{"class":181,"line":521},[262,34085,739],{"class":429},[262,34087,476],{"class":377},[262,34089,9578],{"class":429},[262,34091,9581],{"class":291},[262,34093,34094],{"class":181,"line":537},[262,34095,583],{"emptyLinePlaceholder":582},[262,34097,34098],{"class":181,"line":549},[262,34099,583],{"emptyLinePlaceholder":582},[262,34101,34102,34104,34107],{"class":181,"line":570},[262,34103,423],{"class":377},[262,34105,34106],{"class":267}," label_intent",[262,34108,34109],{"class":429},"(gaps_df: pd.DataFrame) -> pd.DataFrame:\n",[262,34111,34112,34115,34117,34120,34122],{"class":181,"line":579},[262,34113,34114],{"class":429},"    keywords ",[262,34116,476],{"class":377},[262,34118,34119],{"class":429}," gaps_df[",[262,34121,26708],{"class":275},[262,34123,32319],{"class":429},[262,34125,34126,34128,34130],{"class":181,"line":586},[262,34127,18006],{"class":429},[262,34129,476],{"class":377},[262,34131,984],{"class":429},[262,34133,34134],{"class":181,"line":591},[262,34135,34136],{"class":275},"        \"For each keyword, label the search intent as one of: \"\n",[262,34138,34139],{"class":181,"line":623},[262,34140,34141],{"class":275},"        \"informational, commercial, or transactional. \"\n",[262,34143,34144,34147,34150,34152,34154,34157,34159,34161,34163,34166,34168,34171,34173],{"class":181,"line":634},[262,34145,34146],{"class":275},"        \"Return JSON: {",[262,34148,34149],{"class":271},"\\\"",[262,34151,10483],{"class":275},[262,34153,34149],{"class":271},[262,34155,34156],{"class":275},": [{",[262,34158,34149],{"class":271},[262,34160,26611],{"class":275},[262,34162,34149],{"class":271},[262,34164,34165],{"class":275},": ..., ",[262,34167,34149],{"class":271},[262,34169,34170],{"class":275},"intent",[262,34172,34149],{"class":271},[262,34174,34175],{"class":275},": ...}]}. \"\n",[262,34177,34178,34180,34183,34185,34187,34189],{"class":181,"line":845},[262,34179,2840],{"class":377},[262,34181,34182],{"class":275},"\"Keywords: ",[262,34184,3039],{"class":271},[262,34186,32709],{"class":429},[262,34188,654],{"class":271},[262,34190,1257],{"class":275},[262,34192,34193],{"class":181,"line":850},[262,34194,1011],{"class":429},[262,34196,34197,34199,34201],{"class":181,"line":864},[262,34198,797],{"class":429},[262,34200,476],{"class":377},[262,34202,1189],{"class":429},[262,34204,34205,34207,34209,34211],{"class":181,"line":1683},[262,34206,1194],{"class":611},[262,34208,476],{"class":377},[262,34210,1207],{"class":275},[262,34212,1315],{"class":429},[262,34214,34215,34217,34219,34221,34223,34225,34227,34229,34231],{"class":181,"line":1688},[262,34216,1215],{"class":611},[262,34218,476],{"class":377},[262,34220,8856],{"class":429},[262,34222,1228],{"class":275},[262,34224,1231],{"class":429},[262,34226,1291],{"class":275},[262,34228,608],{"class":429},[262,34230,1239],{"class":275},[262,34232,18141],{"class":429},[262,34234,34235,34237,34239,34241],{"class":181,"line":1693},[262,34236,1308],{"class":611},[262,34238,476],{"class":377},[262,34240,102],{"class":271},[262,34242,1315],{"class":429},[262,34244,34245,34247,34249,34251,34253,34255,34257],{"class":181,"line":1728},[262,34246,6018],{"class":611},[262,34248,476],{"class":377},[262,34250,3039],{"class":429},[262,34252,6025],{"class":275},[262,34254,1231],{"class":429},[262,34256,6030],{"class":275},[262,34258,3143],{"class":429},[262,34260,34261],{"class":181,"line":1737},[262,34262,1011],{"class":429},[262,34264,34265,34267,34269,34272,34274],{"class":181,"line":1751},[262,34266,18166],{"class":429},[262,34268,476],{"class":377},[262,34270,34271],{"class":429}," json.loads(resp.choices[",[262,34273,102],{"class":271},[262,34275,6048],{"class":429},[262,34277,34278,34281,34283,34286,34289],{"class":181,"line":1764},[262,34279,34280],{"class":429},"    labels ",[262,34282,476],{"class":377},[262,34284,34285],{"class":429}," pd.DataFrame(data[",[262,34287,34288],{"class":275},"\"results\"",[262,34290,3512],{"class":429},[262,34292,34293,34295,34298,34301,34303,34305,34307,34310,34312,34315],{"class":181,"line":1779},[262,34294,573],{"class":377},[262,34296,34297],{"class":429}," gaps_df.merge(labels, ",[262,34299,34300],{"class":611},"on",[262,34302,476],{"class":377},[262,34304,26708],{"class":275},[262,34306,608],{"class":429},[262,34308,34309],{"class":611},"how",[262,34311,476],{"class":377},[262,34313,34314],{"class":275},"\"left\"",[262,34316,660],{"class":429},[262,34318,34319],{"class":181,"line":1793},[262,34320,583],{"emptyLinePlaceholder":582},[262,34322,34323],{"class":181,"line":1800},[262,34324,583],{"emptyLinePlaceholder":582},[262,34326,34327,34330,34332],{"class":181,"line":1805},[262,34328,34329],{"class":429},"labelled ",[262,34331,476],{"class":377},[262,34333,34334],{"class":429}," label_intent(gaps)\n",[262,34336,34337,34340,34343,34345,34347,34349,34351,34353,34355,34357,34359],{"class":181,"line":1810},[262,34338,34339],{"class":429},"labelled.to_csv(",[262,34341,34342],{"class":275},"\"keyword_gaps.csv\"",[262,34344,608],{"class":429},[262,34346,3618],{"class":611},[262,34348,476],{"class":377},[262,34350,3623],{"class":271},[262,34352,608],{"class":429},[262,34354,612],{"class":611},[262,34356,476],{"class":377},[262,34358,27593],{"class":275},[262,34360,660],{"class":429},[262,34362,34363,34365],{"class":181,"line":1823},[262,34364,637],{"class":271},[262,34366,34367],{"class":429},"(labelled)\n",[14,34369,34370,34371,34373,34374,34376,34377,34379,34380,34382,34383,34385,34386,34388],{},"We call ",[18,34372,8439],{}," so the key from your ",[18,34375,319],{}," file is available, then create the client with no arguments — the ",[18,34378,20],{}," SDK picks up ",[18,34381,21742],{}," automatically, which keeps the secret out of your code. The ",[18,34384,2703],{}," model is inexpensive and more than capable for short labelling tasks. Saving with ",[18,34387,27722],{}," makes the CSV open cleanly in Excel.",[14,34390,34391,34392,34394,34395,1374,34397,34399],{},"Now sort by intent to plan your work: ",[18,34393,34026],{}," gaps usually become blog posts, while ",[18,34396,34029],{},[18,34398,34032],{}," gaps point to comparison or product pages.",[253,34401,34403],{"className":414,"code":34402,"language":416,"meta":258,"style":258},"print(labelled.sort_values(\"intent\").groupby(\"intent\")[\"keyword\"].apply(list))\n",[18,34404,34405],{"__ignoreMap":258},[262,34406,34407,34409,34412,34415,34418,34420,34422,34424,34427,34429],{"class":181,"line":264},[262,34408,637],{"class":271},[262,34410,34411],{"class":429},"(labelled.sort_values(",[262,34413,34414],{"class":275},"\"intent\"",[262,34416,34417],{"class":429},").groupby(",[262,34419,34414],{"class":275},[262,34421,27044],{"class":429},[262,34423,26708],{"class":275},[262,34425,34426],{"class":429},"].apply(",[262,34428,2801],{"class":271},[262,34430,2684],{"class":429},[57,34432,34434],{"id":34433},"putting-it-together-a-runnable-script","Putting it together: a runnable script",[14,34436,34437,34438,34441,34442,34445],{},"Here is the whole pipeline as one file you can save as ",[18,34439,34440],{},"competitor_gaps.py"," and run from the command line. It accepts competitor URLs and a CSV of your own keywords, then writes a labelled gap report. The ",[18,34443,34444],{},"argparse"," block turns it into a small command-line tool so you can re-run it on different competitors without editing code.",[253,34447,34449],{"className":414,"code":34448,"language":416,"meta":258,"style":258},"#!\u002Fusr\u002Fbin\u002Fenv python3\n\"\"\"Find and label keyword gaps against a competitor.\"\"\"\nimport argparse\nimport pandas as pd\nfrom dotenv import load_dotenv\n\n# Paste gather_keywords, compare_keywords, find_gaps and label_intent here,\n# along with their imports (httpx, BeautifulSoup, re, OpenAI, json, os).\n\nload_dotenv()\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(description=\"Competitor keyword gap finder\")\n    parser.add_argument(\"--urls\", nargs=\"+\", required=True, help=\"Competitor page URLs\")\n    parser.add_argument(\"--mine\", required=True, help=\"CSV with a 'keyword' column\")\n    parser.add_argument(\"--out\", default=\"keyword_gaps.csv\", help=\"Output CSV path\")\n    parser.add_argument(\"--top\", type=int, default=30, help=\"How many gaps to keep\")\n    args = parser.parse_args()\n\n    competitor = gather_keywords(args.urls)\n    my_keywords = pd.read_csv(args.mine)[\"keyword\"].tolist()\n    compared = compare_keywords(competitor, my_keywords)\n    gaps = find_gaps(compared, top_n=args.top)\n    labelled = label_intent(gaps)\n    labelled.to_csv(args.out, index=False, encoding=\"utf-8-sig\")\n    print(f\"Wrote {len(labelled)} keyword gaps to {args.out}\")\n\n\nif __name__ == \"__main__\":\n    main()\n",[18,34450,34451,34456,34461,34468,34478,34488,34492,34497,34502,34506,34510,34514,34518,34530,34549,34587,34613,34640,34675,34685,34689,34699,34713,34723,34738,34747,34768,34799,34803,34807,34819],{"__ignoreMap":258},[262,34452,34453],{"class":181,"line":264},[262,34454,34455],{"class":291},"#!\u002Fusr\u002Fbin\u002Fenv python3\n",[262,34457,34458],{"class":181,"line":282},[262,34459,34460],{"class":275},"\"\"\"Find and label keyword gaps against a competitor.\"\"\"\n",[262,34462,34463,34465],{"class":181,"line":295},[262,34464,684],{"class":377},[262,34466,34467],{"class":429}," argparse\n",[262,34469,34470,34472,34474,34476],{"class":181,"line":345},[262,34471,684],{"class":377},[262,34473,2619],{"class":429},[262,34475,697],{"class":377},[262,34477,2624],{"class":429},[262,34479,34480,34482,34484,34486],{"class":181,"line":492},[262,34481,705],{"class":377},[262,34483,708],{"class":429},[262,34485,684],{"class":377},[262,34487,713],{"class":429},[262,34489,34490],{"class":181,"line":503},[262,34491,583],{"emptyLinePlaceholder":582},[262,34493,34494],{"class":181,"line":521},[262,34495,34496],{"class":291},"# Paste gather_keywords, compare_keywords, find_gaps and label_intent here,\n",[262,34498,34499],{"class":181,"line":537},[262,34500,34501],{"class":291},"# along with their imports (httpx, BeautifulSoup, re, OpenAI, json, os).\n",[262,34503,34504],{"class":181,"line":549},[262,34505,583],{"emptyLinePlaceholder":582},[262,34507,34508],{"class":181,"line":570},[262,34509,734],{"class":429},[262,34511,34512],{"class":181,"line":579},[262,34513,583],{"emptyLinePlaceholder":582},[262,34515,34516],{"class":181,"line":586},[262,34517,583],{"emptyLinePlaceholder":582},[262,34519,34520,34522,34524,34526,34528],{"class":181,"line":591},[262,34521,423],{"class":377},[262,34523,23929],{"class":267},[262,34525,15481],{"class":429},[262,34527,8471],{"class":271},[262,34529,1160],{"class":429},[262,34531,34532,34535,34537,34540,34542,34544,34547],{"class":181,"line":623},[262,34533,34534],{"class":429},"    parser ",[262,34536,476],{"class":377},[262,34538,34539],{"class":429}," argparse.ArgumentParser(",[262,34541,2568],{"class":611},[262,34543,476],{"class":377},[262,34545,34546],{"class":275},"\"Competitor keyword gap finder\"",[262,34548,660],{"class":429},[262,34550,34551,34554,34557,34559,34562,34564,34567,34569,34571,34573,34575,34577,34580,34582,34585],{"class":181,"line":634},[262,34552,34553],{"class":429},"    parser.add_argument(",[262,34555,34556],{"class":275},"\"--urls\"",[262,34558,608],{"class":429},[262,34560,34561],{"class":611},"nargs",[262,34563,476],{"class":377},[262,34565,34566],{"class":275},"\"+\"",[262,34568,608],{"class":429},[262,34570,17513],{"class":611},[262,34572,476],{"class":377},[262,34574,4974],{"class":271},[262,34576,608],{"class":429},[262,34578,34579],{"class":611},"help",[262,34581,476],{"class":377},[262,34583,34584],{"class":275},"\"Competitor page URLs\"",[262,34586,660],{"class":429},[262,34588,34589,34591,34594,34596,34598,34600,34602,34604,34606,34608,34611],{"class":181,"line":845},[262,34590,34553],{"class":429},[262,34592,34593],{"class":275},"\"--mine\"",[262,34595,608],{"class":429},[262,34597,17513],{"class":611},[262,34599,476],{"class":377},[262,34601,4974],{"class":271},[262,34603,608],{"class":429},[262,34605,34579],{"class":611},[262,34607,476],{"class":377},[262,34609,34610],{"class":275},"\"CSV with a 'keyword' column\"",[262,34612,660],{"class":429},[262,34614,34615,34617,34620,34622,34625,34627,34629,34631,34633,34635,34638],{"class":181,"line":850},[262,34616,34553],{"class":429},[262,34618,34619],{"class":275},"\"--out\"",[262,34621,608],{"class":429},[262,34623,34624],{"class":611},"default",[262,34626,476],{"class":377},[262,34628,34342],{"class":275},[262,34630,608],{"class":429},[262,34632,34579],{"class":611},[262,34634,476],{"class":377},[262,34636,34637],{"class":275},"\"Output CSV path\"",[262,34639,660],{"class":429},[262,34641,34642,34644,34647,34649,34652,34654,34656,34658,34660,34662,34664,34666,34668,34670,34673],{"class":181,"line":864},[262,34643,34553],{"class":429},[262,34645,34646],{"class":275},"\"--top\"",[262,34648,608],{"class":429},[262,34650,34651],{"class":611},"type",[262,34653,476],{"class":377},[262,34655,439],{"class":271},[262,34657,608],{"class":429},[262,34659,34624],{"class":611},[262,34661,476],{"class":377},[262,34663,9777],{"class":271},[262,34665,608],{"class":429},[262,34667,34579],{"class":611},[262,34669,476],{"class":377},[262,34671,34672],{"class":275},"\"How many gaps to keep\"",[262,34674,660],{"class":429},[262,34676,34677,34680,34682],{"class":181,"line":1683},[262,34678,34679],{"class":429},"    args ",[262,34681,476],{"class":377},[262,34683,34684],{"class":429}," parser.parse_args()\n",[262,34686,34687],{"class":181,"line":1688},[262,34688,583],{"emptyLinePlaceholder":582},[262,34690,34691,34694,34696],{"class":181,"line":1693},[262,34692,34693],{"class":429},"    competitor ",[262,34695,476],{"class":377},[262,34697,34698],{"class":429}," gather_keywords(args.urls)\n",[262,34700,34701,34704,34706,34709,34711],{"class":181,"line":1728},[262,34702,34703],{"class":429},"    my_keywords ",[262,34705,476],{"class":377},[262,34707,34708],{"class":429}," pd.read_csv(args.mine)[",[262,34710,26708],{"class":275},[262,34712,32319],{"class":429},[262,34714,34715,34718,34720],{"class":181,"line":1737},[262,34716,34717],{"class":429},"    compared ",[262,34719,476],{"class":377},[262,34721,34722],{"class":429}," compare_keywords(competitor, my_keywords)\n",[262,34724,34725,34727,34729,34731,34733,34735],{"class":181,"line":1751},[262,34726,33913],{"class":429},[262,34728,476],{"class":377},[262,34730,33982],{"class":429},[262,34732,33985],{"class":611},[262,34734,476],{"class":377},[262,34736,34737],{"class":429},"args.top)\n",[262,34739,34740,34743,34745],{"class":181,"line":1764},[262,34741,34742],{"class":429},"    labelled ",[262,34744,476],{"class":377},[262,34746,34334],{"class":429},[262,34748,34749,34752,34754,34756,34758,34760,34762,34764,34766],{"class":181,"line":1779},[262,34750,34751],{"class":429},"    labelled.to_csv(args.out, ",[262,34753,3618],{"class":611},[262,34755,476],{"class":377},[262,34757,3623],{"class":271},[262,34759,608],{"class":429},[262,34761,612],{"class":611},[262,34763,476],{"class":377},[262,34765,27593],{"class":275},[262,34767,660],{"class":429},[262,34769,34770,34772,34774,34776,34778,34780,34783,34785,34788,34790,34793,34795,34797],{"class":181,"line":1793},[262,34771,1089],{"class":271},[262,34773,602],{"class":429},[262,34775,642],{"class":377},[262,34777,27606],{"class":275},[262,34779,648],{"class":271},[262,34781,34782],{"class":429},"(labelled)",[262,34784,654],{"class":271},[262,34786,34787],{"class":275}," keyword gaps to ",[262,34789,3039],{"class":271},[262,34791,34792],{"class":429},"args.out",[262,34794,654],{"class":271},[262,34796,1176],{"class":275},[262,34798,660],{"class":429},[262,34800,34801],{"class":181,"line":1800},[262,34802,583],{"emptyLinePlaceholder":582},[262,34804,34805],{"class":181,"line":1805},[262,34806,583],{"emptyLinePlaceholder":582},[262,34808,34809,34811,34813,34815,34817],{"class":181,"line":1810},[262,34810,2210],{"class":377},[262,34812,2213],{"class":271},[262,34814,2216],{"class":377},[262,34816,2219],{"class":275},[262,34818,1160],{"class":429},[262,34820,34821],{"class":181,"line":1823},[262,34822,24060],{"class":429},[14,34824,34825],{},"Run it like this, passing one or more competitor URLs:",[253,34827,34829],{"className":255,"code":34828,"language":257,"meta":258,"style":258},"python competitor_gaps.py \\\n  --urls https:\u002F\u002Fcompetitor.com\u002Fblog\u002Fpost-a https:\u002F\u002Fcompetitor.com\u002Fblog\u002Fpost-b \\\n  --mine my_keywords.csv \\\n  --out keyword_gaps.csv\n",[18,34830,34831,34841,34854,34864],{"__ignoreMap":258},[262,34832,34833,34835,34838],{"class":181,"line":264},[262,34834,416],{"class":267},[262,34836,34837],{"class":275}," competitor_gaps.py",[262,34839,34840],{"class":271}," \\\n",[262,34842,34843,34846,34849,34852],{"class":181,"line":282},[262,34844,34845],{"class":271},"  --urls",[262,34847,34848],{"class":275}," https:\u002F\u002Fcompetitor.com\u002Fblog\u002Fpost-a",[262,34850,34851],{"class":275}," https:\u002F\u002Fcompetitor.com\u002Fblog\u002Fpost-b",[262,34853,34840],{"class":271},[262,34855,34856,34859,34862],{"class":181,"line":295},[262,34857,34858],{"class":271},"  --mine",[262,34860,34861],{"class":275}," my_keywords.csv",[262,34863,34840],{"class":271},[262,34865,34866,34869],{"class":181,"line":345},[262,34867,34868],{"class":271},"  --out",[262,34870,34871],{"class":275}," keyword_gaps.csv\n",[14,34873,34874,34875,34878],{},"The script prints a confirmation line and leaves a CSV you can open in any spreadsheet. Because each stage is its own function, you can swap out a single piece — for example, replace ",[18,34876,34877],{},"gather_keywords"," with a reader that pulls phrases from an export instead of live pages — without touching the rest of the pipeline.",[57,34880,24067],{"id":24066},[1379,34882,34883,34895],{},[1382,34884,34885],{},[1385,34886,34887,34889,34891,34893],{},[1388,34888,1390],{},[1388,34890,24078],{},[1388,34892,3798],{},[1388,34894,1396],{},[1398,34896,34897,34915,34933,34951],{},[1385,34898,34899,34904,34908,34912],{},[1403,34900,34901],{},[18,34902,34903],{},"frequency >= 2",[1403,34905,34906],{},[18,34907,34877],{},[1403,34909,34910],{},[18,34911,109],{},[1403,34913,34914],{},"Minimum times a phrase must appear to be kept. Raise it to cut noise on large pages.",[1385,34916,34917,34921,34926,34930],{},[1403,34918,34919],{},[18,34920,33985],{},[1403,34922,34923],{},[18,34924,34925],{},"find_gaps",[1403,34927,34928],{},[18,34929,9777],{},[1403,34931,34932],{},"How many top gaps to keep. Lower it for a tighter content plan.",[1385,34934,34935,34939,34944,34948],{},[1403,34936,34937],{},[18,34938,805],{},[1403,34940,34941],{},[18,34942,34943],{},"label_intent",[1403,34945,34946],{},[18,34947,2703],{},[1403,34949,34950],{},"The LLM used for labelling. Cheap and accurate for short phrases.",[1385,34952,34953,34957,34961,34965],{},[1403,34954,34955],{},[18,34956,3829],{},[1403,34958,34959],{},[18,34960,34943],{},[1403,34962,34963],{},[18,34964,102],{},[1403,34966,34967,34968,34970],{},"Controls randomness. Keep at ",[18,34969,102],{}," so the same keyword always gets the same label.",[57,34972,1445],{"id":1444},[1447,34974,34975,34987,34993,35010],{},[1450,34976,34977,34982,34983,34986],{},[35,34978,34979],{},[18,34980,34981],{},"httpx.HTTPStatusError: 403 Forbidden"," — the competitor's server blocked the request. Cause: a missing or default user agent. Fix: keep the custom ",[18,34984,34985],{},"User-Agent"," header shown above, and slow down by fetching only a few pages at a time.",[1450,34988,34989,34992],{},[35,34990,34991],{},"Empty or junk keyword table"," — the page loaded its content with JavaScript, so the raw HTML has little text. Cause: client-rendered pages. Fix: pick article or blog URLs that render text server-side, or target the competitor's older static pages.",[1450,34994,34995,35000,35001,35003,35004,1374,35006,14825,35008,1363],{},[35,34996,34997,34999],{},[18,34998,19631],{}," after the LLM call"," — the model returned text that is not valid JSON. Cause: the ",[18,35002,5745],{}," line was removed or the model wandered off format. Fix: keep ",[18,35005,6878],{},[18,35007,1357],{},[51,35009,6114],{"href":6113},[1450,35011,35012,35017,35018,1363],{},[35,35013,35014,35016],{},[18,35015,2707],{}," (429) on the LLM call"," — too many requests in a short window. Cause: looping a separate call per keyword instead of batching. Fix: send all keywords in one request as shown, and if it persists, read ",[51,35019,3379],{"href":3378},[57,35021,2317],{"id":2316},[2322,35023,35024,35030,35040],{},[1450,35025,35026,35029],{},[35,35027,35028],{},"Use this Python script"," when you want a free, repeatable gap analysis you can run on any set of competitor pages and pipe straight into a content plan. It is ideal for small teams without a paid SEO seat.",[1450,35031,35032,35035,35036,35039],{},[35,35033,35034],{},"Use a paid SEO tool"," (Ahrefs, Semrush) when you specifically need search-volume and ranking-difficulty numbers. This script tells you ",[27,35037,35038],{},"which"," topics a competitor emphasises, not how many people search them each month — combine the two for the full picture.",[1450,35041,35042,35045,35046,35048],{},[35,35043,35044],{},"Group the results instead of listing them"," when you have hundreds of gaps and want themes rather than a flat list. Feed your gap CSV into ",[51,35047,28902],{"href":28901},", which uses clustering to bundle related phrases into topics automatically.",[14,35050,35051,35052,1363],{},"Once you have your gap list, the natural next step is to turn each opportunity into a page — and to write the snippets that get it clicked with ",[51,35053,26437],{"href":26436},[14,35055,2375,35056,1363],{},[51,35057,9304],{"href":9303},[57,35059,2381],{"id":2380},[2322,35061,35062,35067,35072,35077],{},[1450,35063,35064,35066],{},[51,35065,9304],{"href":9303}," — the main guide for this track.",[1450,35068,35069,35071],{},[51,35070,28902],{"href":28901}," — bundle your gap list into topics with clustering.",[1450,35073,35074,35076],{},[51,35075,26437],{"href":26436}," — write the snippets for the pages you plan from these gaps.",[1450,35078,35079,35081],{},[51,35080,2919],{"href":2918}," — tidy your keyword exports before comparing them.",[2401,35083,35084],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":35086},[35087,35088,35089,35090,35091,35092,35093,35094,35095,35096],{"id":237,"depth":282,"text":238},{"id":32983,"depth":282,"text":32984},{"id":33694,"depth":282,"text":33695},{"id":33870,"depth":282,"text":33871},{"id":34016,"depth":282,"text":34017},{"id":34433,"depth":282,"text":34434},{"id":24066,"depth":282,"text":24067},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Build a Python script that gathers competitor keywords, compares them with pandas, finds your content gaps, and labels search intent with an LLM.",[35099,35102,35105,35108,35111],{"q":35100,"a":35101},"Do I need an SEO tool subscription to analyse competitor keywords?","No. You can pull keywords straight from a competitor's public pages with Python, then compare them to your own. A paid tool gives you search-volume data, but the gap analysis itself runs entirely on free libraries.",{"q":35103,"a":35104},"Is it legal to scrape a competitor's website for keywords?","Reading public HTML for analysis is generally fine, but always respect the site's robots.txt and terms of service, request pages slowly, and never hammer a server. This guide fetches only a handful of pages with polite delays.",{"q":35106,"a":35107},"What counts as a keyword gap?","A keyword gap is a phrase a competitor ranks for or writes about that you do not cover at all. Finding these gaps tells you which topics to add to your content plan next.",{"q":35109,"a":35110},"Why use an LLM to label keywords instead of rules?","Simple rules miss nuance, like a phrase that looks informational but is really commercial. An LLM reads each phrase in context and labels search intent more accurately, which helps you prioritise which gaps to fill first.",{"q":35112,"a":35113},"How many competitor URLs should I feed the script?","Start with three to five of a competitor's strongest pages. More pages add noise and slow the run. You can always re-run the script on different page sets and combine the results later.",{"name":35115,"steps":35116},"How to analyse competitor keywords with Python",[35117,35120,35123,35126,35129],{"name":35118,"text":35119},"Install dependencies and set up your API key","Create a virtual environment, install httpx, pandas and the openai SDK, and store your API key in a .env file.",{"name":35121,"text":35122},"Gather competitor keywords","Fetch each competitor page with httpx, strip the HTML, and extract candidate keywords into a list.",{"name":35124,"text":35125},"Compare keyword sets with pandas","Load your own keywords and the competitor keywords into DataFrames and merge them to see overlap.",{"name":35127,"text":35128},"Find the gaps","Filter the merged DataFrame to phrases the competitor uses that you do not, ranked by frequency.",{"name":35130,"text":35131},"Label intent with an LLM","Send the gap keywords to an LLM in one batch and add an intent label to each row.",{},"\u002Fai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fpython-script-for-competitor-keyword-analysis",{"title":25851,"description":35097},"ai-content-creation-marketing-automation\u002Fseo-keyword-research-with-python\u002Fpython-script-for-competitor-keyword-analysis\u002Findex","_WMCJqXlhnqkPB8Fq4VuCW0Ru6dKLYotSljUII865_k",{"id":35138,"title":35139,"body":35140,"description":36980,"extension":2419,"faq":36981,"howto":36997,"meta":37012,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":37013,"published":2452,"seo":37014,"seoTitle":35139,"stem":37015,"__hash__":37016},"content\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fenrich-crm-leads-with-ai-in-python\u002Findex.md","Enrich CRM Leads with AI in Python",{"type":7,"value":35141,"toc":36968},[35142,35145,35148,35151,35153,35162,35165,35185,35190,35198,35205,35213,35217,35224,35523,35529,35532,35536,35545,35965,35975,35981,35985,35988,36100,36107,36111,36114,36347,36364,36368,36375,36734,36745,36747,36829,36831,36902,36904,36933,36939,36941,36965],[10,35143,35139],{"id":35144},"enrich-crm-leads-with-ai-in-python",[14,35146,35147],{},"This guide shows you how to turn a messy CRM lead into a tidy, structured record in under fifteen minutes: an AI model reads the raw text a lead left behind, infers the industry, company size, and buying intent, writes a one-line summary, and your Python script saves all of it back to the contact.",[14,35149,35150],{},"Most leads arrive half-empty. Someone fills in a name and an email, drops a sentence into a \"tell us about your project\" box, and that is all your sales team has to work with. The useful facts are buried in that sentence, plus the email domain and a job title, and nobody has time to read and tag hundreds of them by hand. An AI model can do that reading and tagging in seconds, and it can hand back results in a fixed shape your CRM understands.",[57,35152,238],{"id":237},[14,35154,35155,35156,35158,35159,35161],{},"You only need a few things beyond a working Python install. If you have not set up Python yet, start with ",[51,35157,2482],{"href":2481}," and come back. New to calling AI models from code? The plain-English walkthrough in ",[51,35160,2487],{"href":2486}," covers the request-and-response pattern this guide builds on.",[14,35163,35164],{},"You need Python 3.10 or newer, a funded OpenAI account, and the two packages below.",[253,35166,35168],{"className":255,"code":35167,"language":257,"meta":258,"style":258},"python -m pip install \"openai>=1.40\" python-dotenv\n",[18,35169,35170],{"__ignoreMap":258},[262,35171,35172,35174,35176,35179,35181,35183],{"class":181,"line":264},[262,35173,416],{"class":267},[262,35175,272],{"class":271},[262,35177,35178],{"class":275}," pip",[262,35180,301],{"class":275},[262,35182,304],{"class":275},[262,35184,2522],{"class":275},[14,35186,2525,35187,35189],{},[18,35188,319],{}," in your project folder and add your key:",[253,35191,35192],{"className":323,"code":11159,"language":325,"meta":258,"style":258},[18,35193,35194],{"__ignoreMap":258},[262,35195,35196],{"class":181,"line":264},[262,35197,11159],{},[14,35199,353,35200,356,35202,35204],{},[18,35201,319],{},[18,35203,359],{}," so your key is never committed to version control.",[14,35206,35207,35208,35212],{},"This guide focuses on the AI enrichment itself and prints the result. To push results into a live CRM, pair it with ",[51,35209,35211],{"href":35210},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsync-hubspot-contacts-with-python\u002F","Sync HubSpot Contacts with Python",", which covers the authenticated update call in detail.",[57,35214,35216],{"id":35215},"step-1-define-the-fields-you-want-back","Step 1: Define the fields you want back",[14,35218,35219,35220,35223],{},"Before you call any model, decide exactly what \"enriched\" means for your business. Vague requests get vague answers. A schema is just a list of the fields you want, their types, and the allowed values, written so the model has no room to improvise. Notice that every category includes an ",[18,35221,35222],{},"unknown"," option, so the model has an honest answer when the text gives it nothing to go on.",[253,35225,35227],{"className":414,"code":35226,"language":416,"meta":258,"style":258},"# schema.py\nLEAD_SCHEMA = {\n    \"type\": \"object\",\n    \"additionalProperties\": False,\n    \"properties\": {\n        \"industry\": {\n            \"type\": \"string\",\n            \"description\": \"Best guess at the lead's industry, e.g. 'SaaS', 'Healthcare', 'E-commerce'. Use 'unknown' if unclear.\",\n        },\n        \"company_size\": {\n            \"type\": \"string\",\n            \"enum\": [\"1-10\", \"11-50\", \"51-200\", \"201-1000\", \"1000+\", \"unknown\"],\n        },\n        \"intent\": {\n            \"type\": \"string\",\n            \"enum\": [\"ready_to_buy\", \"evaluating\", \"researching\", \"just_browsing\", \"unknown\"],\n        },\n        \"summary\": {\n            \"type\": \"string\",\n            \"description\": \"One plain sentence describing who the lead is and what they want.\",\n        },\n        \"confidence\": {\n            \"type\": \"number\",\n            \"description\": \"How confident you are in these inferences, from 0.0 to 1.0.\",\n        },\n    },\n    \"required\": [\"industry\", \"company_size\", \"intent\", \"summary\", \"confidence\"],\n}\n",[18,35228,35229,35234,35243,35255,35266,35274,35281,35293,35305,35309,35316,35326,35364,35368,35375,35385,35415,35419,35426,35436,35447,35451,35458,35469,35480,35484,35488,35519],{"__ignoreMap":258},[262,35230,35231],{"class":181,"line":264},[262,35232,35233],{"class":291},"# schema.py\n",[262,35235,35236,35239,35241],{"class":181,"line":282},[262,35237,35238],{"class":271},"LEAD_SCHEMA",[262,35240,442],{"class":377},[262,35242,20437],{"class":429},[262,35244,35245,35248,35250,35253],{"class":181,"line":295},[262,35246,35247],{"class":275},"    \"type\"",[262,35249,1231],{"class":429},[262,35251,35252],{"class":275},"\"object\"",[262,35254,1315],{"class":429},[262,35256,35257,35260,35262,35264],{"class":181,"line":345},[262,35258,35259],{"class":275},"    \"additionalProperties\"",[262,35261,1231],{"class":429},[262,35263,3623],{"class":271},[262,35265,1315],{"class":429},[262,35267,35268,35271],{"class":181,"line":492},[262,35269,35270],{"class":275},"    \"properties\"",[262,35272,35273],{"class":429},": {\n",[262,35275,35276,35279],{"class":181,"line":503},[262,35277,35278],{"class":275},"        \"industry\"",[262,35280,35273],{"class":429},[262,35282,35283,35286,35288,35291],{"class":181,"line":521},[262,35284,35285],{"class":275},"            \"type\"",[262,35287,1231],{"class":429},[262,35289,35290],{"class":275},"\"string\"",[262,35292,1315],{"class":429},[262,35294,35295,35298,35300,35303],{"class":181,"line":537},[262,35296,35297],{"class":275},"            \"description\"",[262,35299,1231],{"class":429},[262,35301,35302],{"class":275},"\"Best guess at the lead's industry, e.g. 'SaaS', 'Healthcare', 'E-commerce'. Use 'unknown' if unclear.\"",[262,35304,1315],{"class":429},[262,35306,35307],{"class":181,"line":549},[262,35308,6637],{"class":429},[262,35310,35311,35314],{"class":181,"line":570},[262,35312,35313],{"class":275},"        \"company_size\"",[262,35315,35273],{"class":429},[262,35317,35318,35320,35322,35324],{"class":181,"line":579},[262,35319,35285],{"class":275},[262,35321,1231],{"class":429},[262,35323,35290],{"class":275},[262,35325,1315],{"class":429},[262,35327,35328,35331,35334,35337,35339,35342,35344,35347,35349,35352,35354,35357,35359,35362],{"class":181,"line":586},[262,35329,35330],{"class":275},"            \"enum\"",[262,35332,35333],{"class":429},": [",[262,35335,35336],{"class":275},"\"1-10\"",[262,35338,608],{"class":429},[262,35340,35341],{"class":275},"\"11-50\"",[262,35343,608],{"class":429},[262,35345,35346],{"class":275},"\"51-200\"",[262,35348,608],{"class":429},[262,35350,35351],{"class":275},"\"201-1000\"",[262,35353,608],{"class":429},[262,35355,35356],{"class":275},"\"1000+\"",[262,35358,608],{"class":429},[262,35360,35361],{"class":275},"\"unknown\"",[262,35363,10309],{"class":429},[262,35365,35366],{"class":181,"line":591},[262,35367,6637],{"class":429},[262,35369,35370,35373],{"class":181,"line":623},[262,35371,35372],{"class":275},"        \"intent\"",[262,35374,35273],{"class":429},[262,35376,35377,35379,35381,35383],{"class":181,"line":634},[262,35378,35285],{"class":275},[262,35380,1231],{"class":429},[262,35382,35290],{"class":275},[262,35384,1315],{"class":429},[262,35386,35387,35389,35391,35394,35396,35399,35401,35404,35406,35409,35411,35413],{"class":181,"line":845},[262,35388,35330],{"class":275},[262,35390,35333],{"class":429},[262,35392,35393],{"class":275},"\"ready_to_buy\"",[262,35395,608],{"class":429},[262,35397,35398],{"class":275},"\"evaluating\"",[262,35400,608],{"class":429},[262,35402,35403],{"class":275},"\"researching\"",[262,35405,608],{"class":429},[262,35407,35408],{"class":275},"\"just_browsing\"",[262,35410,608],{"class":429},[262,35412,35361],{"class":275},[262,35414,10309],{"class":429},[262,35416,35417],{"class":181,"line":850},[262,35418,6637],{"class":429},[262,35420,35421,35424],{"class":181,"line":864},[262,35422,35423],{"class":275},"        \"summary\"",[262,35425,35273],{"class":429},[262,35427,35428,35430,35432,35434],{"class":181,"line":1683},[262,35429,35285],{"class":275},[262,35431,1231],{"class":429},[262,35433,35290],{"class":275},[262,35435,1315],{"class":429},[262,35437,35438,35440,35442,35445],{"class":181,"line":1688},[262,35439,35297],{"class":275},[262,35441,1231],{"class":429},[262,35443,35444],{"class":275},"\"One plain sentence describing who the lead is and what they want.\"",[262,35446,1315],{"class":429},[262,35448,35449],{"class":181,"line":1693},[262,35450,6637],{"class":429},[262,35452,35453,35456],{"class":181,"line":1728},[262,35454,35455],{"class":275},"        \"confidence\"",[262,35457,35273],{"class":429},[262,35459,35460,35462,35464,35467],{"class":181,"line":1737},[262,35461,35285],{"class":275},[262,35463,1231],{"class":429},[262,35465,35466],{"class":275},"\"number\"",[262,35468,1315],{"class":429},[262,35470,35471,35473,35475,35478],{"class":181,"line":1751},[262,35472,35297],{"class":275},[262,35474,1231],{"class":429},[262,35476,35477],{"class":275},"\"How confident you are in these inferences, from 0.0 to 1.0.\"",[262,35479,1315],{"class":429},[262,35481,35482],{"class":181,"line":1764},[262,35483,6637],{"class":429},[262,35485,35486],{"class":181,"line":1779},[262,35487,5635],{"class":429},[262,35489,35490,35493,35495,35498,35500,35503,35505,35507,35509,35512,35514,35517],{"class":181,"line":1793},[262,35491,35492],{"class":275},"    \"required\"",[262,35494,35333],{"class":429},[262,35496,35497],{"class":275},"\"industry\"",[262,35499,608],{"class":429},[262,35501,35502],{"class":275},"\"company_size\"",[262,35504,608],{"class":429},[262,35506,34414],{"class":275},[262,35508,608],{"class":429},[262,35510,35511],{"class":275},"\"summary\"",[262,35513,608],{"class":429},[262,35515,35516],{"class":275},"\"confidence\"",[262,35518,10309],{"class":429},[262,35520,35521],{"class":181,"line":1800},[262,35522,16430],{"class":429},[14,35524,3349,35525,35528],{},[18,35526,35527],{},"enum"," lists matter. They turn open-ended guesses into a small set of values your sales filters can actually count on, so \"company size\" is always one of six tidy buckets rather than free text like \"smallish\" or \"a few hundred people\".",[14,35530,35531],{},"Keep the schema lean. Every field you add is one more thing the model has to reason about and one more column your team has to act on. Start with the four or five fields that drive a real decision, ship them, and add more only when a teammate asks for them. A bloated schema produces slower, costlier calls and rarely earns its keep.",[57,35533,35535],{"id":35534},"step-2-send-one-lead-to-the-model-in-structured-output-mode","Step 2: Send one lead to the model in structured-output mode",[14,35537,35538,35539,35541,35542,35544],{},"Structured-output mode is the part that makes this reliable. When you attach your schema with ",[18,35540,5745],{},", the model is forced to return valid JSON that matches your fields exactly, so you never scrape an answer out of a paragraph of prose. If you have ever wrestled with broken parsing, the background in ",[51,35543,6114],{"href":6113}," shows why this approach removes the problem at the source.",[253,35546,35548],{"className":414,"code":35547,"language":416,"meta":258,"style":258},"# enrich.py\nimport json\nimport os\n\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nfrom schema import LEAD_SCHEMA\n\nload_dotenv()\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\nSYSTEM_PROMPT = (\n    \"You enrich raw CRM leads. Infer each field only from the text provided. \"\n    \"Never invent specific facts. When the text does not support a value, \"\n    \"use 'unknown' and lower the confidence score.\"\n)\n\n\ndef enrich_lead(lead: dict) -> dict:\n    \"\"\"Send one raw lead to the model and return structured enrichment.\"\"\"\n    user_message = (\n        f\"Name: {lead.get('name', '')}\\n\"\n        f\"Email: {lead.get('email', '')}\\n\"\n        f\"Job title: {lead.get('job_title', '')}\\n\"\n        f\"Message: {lead.get('message', '')}\"\n    )\n\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        temperature=0,\n        messages=[\n            {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n            {\"role\": \"user\", \"content\": user_message},\n        ],\n        response_format={\n            \"type\": \"json_schema\",\n            \"json_schema\": {\n                \"name\": \"lead_enrichment\",\n                \"schema\": LEAD_SCHEMA,\n                \"strict\": True,\n            },\n        },\n    )\n\n    return json.loads(response.choices[0].message.content)\n",[18,35549,35550,35555,35561,35567,35571,35581,35591,35595,35607,35611,35615,35625,35629,35637,35642,35647,35652,35656,35660,35664,35682,35687,35696,35722,35746,35770,35794,35798,35802,35810,35820,35830,35838,35858,35875,35879,35887,35898,35905,35917,35928,35939,35943,35947,35951,35955],{"__ignoreMap":258},[262,35551,35552],{"class":181,"line":264},[262,35553,35554],{"class":291},"# enrich.py\n",[262,35556,35557,35559],{"class":181,"line":282},[262,35558,684],{"class":377},[262,35560,5766],{"class":429},[262,35562,35563,35565],{"class":181,"line":295},[262,35564,684],{"class":377},[262,35566,687],{"class":429},[262,35568,35569],{"class":181,"line":345},[262,35570,583],{"emptyLinePlaceholder":582},[262,35572,35573,35575,35577,35579],{"class":181,"line":492},[262,35574,705],{"class":377},[262,35576,708],{"class":429},[262,35578,684],{"class":377},[262,35580,713],{"class":429},[262,35582,35583,35585,35587,35589],{"class":181,"line":503},[262,35584,705],{"class":377},[262,35586,720],{"class":429},[262,35588,684],{"class":377},[262,35590,725],{"class":429},[262,35592,35593],{"class":181,"line":521},[262,35594,583],{"emptyLinePlaceholder":582},[262,35596,35597,35599,35602,35604],{"class":181,"line":537},[262,35598,705],{"class":377},[262,35600,35601],{"class":429}," schema ",[262,35603,684],{"class":377},[262,35605,35606],{"class":271}," LEAD_SCHEMA\n",[262,35608,35609],{"class":181,"line":549},[262,35610,583],{"emptyLinePlaceholder":582},[262,35612,35613],{"class":181,"line":570},[262,35614,734],{"class":429},[262,35616,35617,35619,35621,35623],{"class":181,"line":579},[262,35618,739],{"class":429},[262,35620,476],{"class":377},[262,35622,9578],{"class":429},[262,35624,9581],{"class":291},[262,35626,35627],{"class":181,"line":586},[262,35628,583],{"emptyLinePlaceholder":582},[262,35630,35631,35633,35635],{"class":181,"line":591},[262,35632,2941],{"class":271},[262,35634,442],{"class":377},[262,35636,984],{"class":429},[262,35638,35639],{"class":181,"line":623},[262,35640,35641],{"class":275},"    \"You enrich raw CRM leads. Infer each field only from the text provided. \"\n",[262,35643,35644],{"class":181,"line":634},[262,35645,35646],{"class":275},"    \"Never invent specific facts. When the text does not support a value, \"\n",[262,35648,35649],{"class":181,"line":845},[262,35650,35651],{"class":275},"    \"use 'unknown' and lower the confidence score.\"\n",[262,35653,35654],{"class":181,"line":850},[262,35655,660],{"class":429},[262,35657,35658],{"class":181,"line":864},[262,35659,583],{"emptyLinePlaceholder":582},[262,35661,35662],{"class":181,"line":1683},[262,35663,583],{"emptyLinePlaceholder":582},[262,35665,35666,35668,35671,35674,35676,35678,35680],{"class":181,"line":1688},[262,35667,423],{"class":377},[262,35669,35670],{"class":267}," enrich_lead",[262,35672,35673],{"class":429},"(lead: ",[262,35675,5869],{"class":271},[262,35677,1939],{"class":429},[262,35679,5869],{"class":271},[262,35681,1160],{"class":429},[262,35683,35684],{"class":181,"line":1693},[262,35685,35686],{"class":275},"    \"\"\"Send one raw lead to the model and return structured enrichment.\"\"\"\n",[262,35688,35689,35692,35694],{"class":181,"line":1728},[262,35690,35691],{"class":429},"    user_message ",[262,35693,476],{"class":377},[262,35695,984],{"class":429},[262,35697,35698,35700,35703,35705,35708,35711,35713,35716,35718,35720],{"class":181,"line":1737},[262,35699,2840],{"class":377},[262,35701,35702],{"class":275},"\"Name: ",[262,35704,3039],{"class":271},[262,35706,35707],{"class":429},"lead.get(",[262,35709,35710],{"class":275},"'name'",[262,35712,608],{"class":429},[262,35714,35715],{"class":275},"''",[262,35717,5987],{"class":429},[262,35719,3044],{"class":271},[262,35721,1257],{"class":275},[262,35723,35724,35726,35729,35731,35733,35736,35738,35740,35742,35744],{"class":181,"line":1751},[262,35725,2840],{"class":377},[262,35727,35728],{"class":275},"\"Email: ",[262,35730,3039],{"class":271},[262,35732,35707],{"class":429},[262,35734,35735],{"class":275},"'email'",[262,35737,608],{"class":429},[262,35739,35715],{"class":275},[262,35741,5987],{"class":429},[262,35743,3044],{"class":271},[262,35745,1257],{"class":275},[262,35747,35748,35750,35753,35755,35757,35760,35762,35764,35766,35768],{"class":181,"line":1764},[262,35749,2840],{"class":377},[262,35751,35752],{"class":275},"\"Job title: ",[262,35754,3039],{"class":271},[262,35756,35707],{"class":429},[262,35758,35759],{"class":275},"'job_title'",[262,35761,608],{"class":429},[262,35763,35715],{"class":275},[262,35765,5987],{"class":429},[262,35767,3044],{"class":271},[262,35769,1257],{"class":275},[262,35771,35772,35774,35777,35779,35781,35784,35786,35788,35790,35792],{"class":181,"line":1779},[262,35773,2840],{"class":377},[262,35775,35776],{"class":275},"\"Message: ",[262,35778,3039],{"class":271},[262,35780,35707],{"class":429},[262,35782,35783],{"class":275},"'message'",[262,35785,608],{"class":429},[262,35787,35715],{"class":275},[262,35789,5987],{"class":429},[262,35791,654],{"class":271},[262,35793,1257],{"class":275},[262,35795,35796],{"class":181,"line":1793},[262,35797,1011],{"class":429},[262,35799,35800],{"class":181,"line":1800},[262,35801,583],{"emptyLinePlaceholder":582},[262,35803,35804,35806,35808],{"class":181,"line":1805},[262,35805,1184],{"class":429},[262,35807,476],{"class":377},[262,35809,1189],{"class":429},[262,35811,35812,35814,35816,35818],{"class":181,"line":1810},[262,35813,1194],{"class":611},[262,35815,476],{"class":377},[262,35817,1207],{"class":275},[262,35819,1315],{"class":429},[262,35821,35822,35824,35826,35828],{"class":181,"line":1823},[262,35823,1308],{"class":611},[262,35825,476],{"class":377},[262,35827,102],{"class":271},[262,35829,1315],{"class":429},[262,35831,35832,35834,35836],{"class":181,"line":1846},[262,35833,1215],{"class":611},[262,35835,476],{"class":377},[262,35837,1220],{"class":429},[262,35839,35840,35842,35844,35846,35848,35850,35852,35854,35856],{"class":181,"line":1861},[262,35841,1225],{"class":429},[262,35843,1228],{"class":275},[262,35845,1231],{"class":429},[262,35847,1234],{"class":275},[262,35849,608],{"class":429},[262,35851,1239],{"class":275},[262,35853,1231],{"class":429},[262,35855,2941],{"class":271},[262,35857,3143],{"class":429},[262,35859,35860,35862,35864,35866,35868,35870,35872],{"class":181,"line":1866},[262,35861,1225],{"class":429},[262,35863,1228],{"class":275},[262,35865,1231],{"class":429},[262,35867,1291],{"class":275},[262,35869,608],{"class":429},[262,35871,1239],{"class":275},[262,35873,35874],{"class":429},": user_message},\n",[262,35876,35877],{"class":181,"line":1871},[262,35878,1303],{"class":429},[262,35880,35881,35883,35885],{"class":181,"line":1890},[262,35882,6018],{"class":611},[262,35884,476],{"class":377},[262,35886,6593],{"class":429},[262,35888,35889,35891,35893,35896],{"class":181,"line":1909},[262,35890,35285],{"class":275},[262,35892,1231],{"class":429},[262,35894,35895],{"class":275},"\"json_schema\"",[262,35897,1315],{"class":429},[262,35899,35900,35903],{"class":181,"line":1914},[262,35901,35902],{"class":275},"            \"json_schema\"",[262,35904,35273],{"class":429},[262,35906,35907,35910,35912,35915],{"class":181,"line":1919},[262,35908,35909],{"class":275},"                \"name\"",[262,35911,1231],{"class":429},[262,35913,35914],{"class":275},"\"lead_enrichment\"",[262,35916,1315],{"class":429},[262,35918,35919,35922,35924,35926],{"class":181,"line":1946},[262,35920,35921],{"class":275},"                \"schema\"",[262,35923,1231],{"class":429},[262,35925,35238],{"class":271},[262,35927,1315],{"class":429},[262,35929,35930,35933,35935,35937],{"class":181,"line":1959},[262,35931,35932],{"class":275},"                \"strict\"",[262,35934,1231],{"class":429},[262,35936,4974],{"class":271},[262,35938,1315],{"class":429},[262,35940,35941],{"class":181,"line":1996},[262,35942,4369],{"class":429},[262,35944,35945],{"class":181,"line":2012},[262,35946,6637],{"class":429},[262,35948,35949],{"class":181,"line":2040},[262,35950,1011],{"class":429},[262,35952,35953],{"class":181,"line":2045},[262,35954,583],{"emptyLinePlaceholder":582},[262,35956,35957,35959,35961,35963],{"class":181,"line":2050},[262,35958,573],{"class":377},[262,35960,6043],{"class":429},[262,35962,102],{"class":271},[262,35964,6048],{"class":429},[14,35966,35967,35968,35971,35972,35974],{},"The email domain is doing quiet work here. ",[18,35969,35970],{},"dana@acme-clinic.com"," nudges the model toward healthcare without you writing a single rule. Setting ",[18,35973,1357],{}," keeps results steady, so the same lead returns the same labels on Monday and Friday.",[14,35976,35977,35978,35980],{},"The system prompt is your guardrail against invented facts. The two instructions that matter most are \"infer only from the text provided\" and \"use 'unknown' when the text does not support a value\". Without them, a model will happily guess a headcount it has no way of knowing. With them, a sparse lead returns honest ",[18,35979,35222],{}," values and a low confidence score, which is exactly what you want feeding into the gate in the next step.",[57,35982,35984],{"id":35983},"step-3-validate-before-you-trust-the-result","Step 3: Validate before you trust the result",[14,35986,35987],{},"The model returns valid JSON, but \"valid JSON\" is not the same as \"good data\". A quick check protects your CRM from low-confidence guesses landing in fields your team treats as fact. Route anything below your threshold to a human queue instead of writing it blindly.",[253,35989,35991],{"className":414,"code":35990,"language":416,"meta":258,"style":258},"# validate.py\ndef is_trustworthy(enrichment: dict, threshold: float = 0.6) -> bool:\n    \"\"\"Decide whether enrichment is confident enough to auto-apply.\"\"\"\n    if enrichment[\"confidence\"] \u003C threshold:\n        return False\n    # An 'unknown' industry on a high-confidence record is contradictory.\n    if enrichment[\"industry\"] == \"unknown\" and enrichment[\"confidence\"] > 0.8:\n        return False\n    return True\n",[18,35992,35993,35998,36025,36030,36046,36053,36058,36088,36094],{"__ignoreMap":258},[262,35994,35995],{"class":181,"line":264},[262,35996,35997],{"class":291},"# validate.py\n",[262,35999,36000,36002,36005,36008,36010,36013,36015,36017,36019,36021,36023],{"class":181,"line":282},[262,36001,423],{"class":377},[262,36003,36004],{"class":267}," is_trustworthy",[262,36006,36007],{"class":429},"(enrichment: ",[262,36009,5869],{"class":271},[262,36011,36012],{"class":429},", threshold: ",[262,36014,3832],{"class":271},[262,36016,442],{"class":377},[262,36018,7809],{"class":271},[262,36020,1939],{"class":429},[262,36022,8045],{"class":271},[262,36024,1160],{"class":429},[262,36026,36027],{"class":181,"line":295},[262,36028,36029],{"class":275},"    \"\"\"Decide whether enrichment is confident enough to auto-apply.\"\"\"\n",[262,36031,36032,36034,36037,36039,36041,36043],{"class":181,"line":345},[262,36033,3454],{"class":377},[262,36035,36036],{"class":429}," enrichment[",[262,36038,35516],{"class":275},[262,36040,2903],{"class":429},[262,36042,512],{"class":377},[262,36044,36045],{"class":429}," threshold:\n",[262,36047,36048,36050],{"class":181,"line":492},[262,36049,8066],{"class":377},[262,36051,36052],{"class":271}," False\n",[262,36054,36055],{"class":181,"line":503},[262,36056,36057],{"class":291},"    # An 'unknown' industry on a high-confidence record is contradictory.\n",[262,36059,36060,36062,36064,36066,36068,36070,36073,36075,36077,36079,36081,36083,36086],{"class":181,"line":521},[262,36061,3454],{"class":377},[262,36063,36036],{"class":429},[262,36065,35497],{"class":275},[262,36067,2903],{"class":429},[262,36069,10758],{"class":377},[262,36071,36072],{"class":275}," \"unknown\"",[262,36074,33508],{"class":377},[262,36076,36036],{"class":429},[262,36078,35516],{"class":275},[262,36080,2903],{"class":429},[262,36082,8086],{"class":377},[262,36084,36085],{"class":271}," 0.8",[262,36087,1160],{"class":429},[262,36089,36090,36092],{"class":181,"line":537},[262,36091,8066],{"class":377},[262,36093,36052],{"class":271},[262,36095,36096,36098],{"class":181,"line":549},[262,36097,573],{"class":377},[262,36099,25491],{"class":271},[14,36101,36102,36103,36106],{},"Tune the ",[18,36104,36105],{},"threshold"," to your appetite for risk. A team that prizes clean data sets it high and reviews more leads by hand; a team that wants every record tagged sets it low and accepts a few rough guesses.",[57,36108,36110],{"id":36109},"step-4-write-the-enriched-fields-back-to-the-crm","Step 4: Write the enriched fields back to the CRM",[14,36112,36113],{},"The final step sends your parsed result to the CRM as an update on the matching contact. The shape of this call depends on your CRM, but the pattern is always the same: an authenticated request that maps your enrichment fields onto the right custom properties. Below is the HubSpot-style version; swap the URL and field names for your own system.",[253,36115,36117],{"className":414,"code":36116,"language":416,"meta":258,"style":258},"# writeback.py\nimport os\n\nimport httpx\n\n\ndef write_back(contact_id: str, enrichment: dict) -> None:\n    \"\"\"Save enrichment to custom properties on a CRM contact.\"\"\"\n    token = os.environ[\"HUBSPOT_TOKEN\"]\n    url = f\"https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002F{contact_id}\"\n    payload = {\n        \"properties\": {\n            \"ai_industry\": enrichment[\"industry\"],\n            \"ai_company_size\": enrichment[\"company_size\"],\n            \"ai_intent\": enrichment[\"intent\"],\n            \"ai_summary\": enrichment[\"summary\"],\n        }\n    }\n    response = httpx.patch(\n        url,\n        json=payload,\n        headers={\"Authorization\": f\"Bearer {token}\"},\n        timeout=30.0,\n    )\n    response.raise_for_status()\n",[18,36118,36119,36124,36130,36134,36140,36144,36148,36171,36176,36191,36211,36219,36226,36238,36249,36260,36271,36276,36281,36290,36295,36303,36329,36339,36343],{"__ignoreMap":258},[262,36120,36121],{"class":181,"line":264},[262,36122,36123],{"class":291},"# writeback.py\n",[262,36125,36126,36128],{"class":181,"line":282},[262,36127,684],{"class":377},[262,36129,687],{"class":429},[262,36131,36132],{"class":181,"line":295},[262,36133,583],{"emptyLinePlaceholder":582},[262,36135,36136,36138],{"class":181,"line":345},[262,36137,684],{"class":377},[262,36139,6526],{"class":429},[262,36141,36142],{"class":181,"line":492},[262,36143,583],{"emptyLinePlaceholder":582},[262,36145,36146],{"class":181,"line":503},[262,36147,583],{"emptyLinePlaceholder":582},[262,36149,36150,36152,36155,36158,36160,36163,36165,36167,36169],{"class":181,"line":521},[262,36151,423],{"class":377},[262,36153,36154],{"class":267}," write_back",[262,36156,36157],{"class":429},"(contact_id: ",[262,36159,433],{"class":271},[262,36161,36162],{"class":429},", enrichment: ",[262,36164,5869],{"class":271},[262,36166,1939],{"class":429},[262,36168,8471],{"class":271},[262,36170,1160],{"class":429},[262,36172,36173],{"class":181,"line":537},[262,36174,36175],{"class":275},"    \"\"\"Save enrichment to custom properties on a CRM contact.\"\"\"\n",[262,36177,36178,36181,36183,36186,36189],{"class":181,"line":549},[262,36179,36180],{"class":429},"    token ",[262,36182,476],{"class":377},[262,36184,36185],{"class":429}," os.environ[",[262,36187,36188],{"class":275},"\"HUBSPOT_TOKEN\"",[262,36190,957],{"class":429},[262,36192,36193,36195,36197,36199,36202,36204,36207,36209],{"class":181,"line":570},[262,36194,26041],{"class":429},[262,36196,476],{"class":377},[262,36198,10178],{"class":377},[262,36200,36201],{"class":275},"\"https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002F",[262,36203,3039],{"class":271},[262,36205,36206],{"class":429},"contact_id",[262,36208,654],{"class":271},[262,36210,1257],{"class":275},[262,36212,36213,36215,36217],{"class":181,"line":579},[262,36214,16972],{"class":429},[262,36216,476],{"class":377},[262,36218,20437],{"class":429},[262,36220,36221,36224],{"class":181,"line":586},[262,36222,36223],{"class":275},"        \"properties\"",[262,36225,35273],{"class":429},[262,36227,36228,36231,36234,36236],{"class":181,"line":591},[262,36229,36230],{"class":275},"            \"ai_industry\"",[262,36232,36233],{"class":429},": enrichment[",[262,36235,35497],{"class":275},[262,36237,10309],{"class":429},[262,36239,36240,36243,36245,36247],{"class":181,"line":623},[262,36241,36242],{"class":275},"            \"ai_company_size\"",[262,36244,36233],{"class":429},[262,36246,35502],{"class":275},[262,36248,10309],{"class":429},[262,36250,36251,36254,36256,36258],{"class":181,"line":634},[262,36252,36253],{"class":275},"            \"ai_intent\"",[262,36255,36233],{"class":429},[262,36257,34414],{"class":275},[262,36259,10309],{"class":429},[262,36261,36262,36265,36267,36269],{"class":181,"line":845},[262,36263,36264],{"class":275},"            \"ai_summary\"",[262,36266,36233],{"class":429},[262,36268,35511],{"class":275},[262,36270,10309],{"class":429},[262,36272,36273],{"class":181,"line":850},[262,36274,36275],{"class":429},"        }\n",[262,36277,36278],{"class":181,"line":864},[262,36279,36280],{"class":429},"    }\n",[262,36282,36283,36285,36287],{"class":181,"line":1683},[262,36284,1184],{"class":429},[262,36286,476],{"class":377},[262,36288,36289],{"class":429}," httpx.patch(\n",[262,36291,36292],{"class":181,"line":1688},[262,36293,36294],{"class":429},"        url,\n",[262,36296,36297,36299,36301],{"class":181,"line":1693},[262,36298,6642],{"class":611},[262,36300,476],{"class":377},[262,36302,18746],{"class":429},[262,36304,36305,36307,36309,36311,36313,36315,36317,36319,36321,36323,36325,36327],{"class":181,"line":1728},[262,36306,6588],{"class":611},[262,36308,476],{"class":377},[262,36310,3039],{"class":429},[262,36312,16998],{"class":275},[262,36314,1231],{"class":429},[262,36316,642],{"class":377},[262,36318,6605],{"class":275},[262,36320,3039],{"class":271},[262,36322,7933],{"class":429},[262,36324,654],{"class":271},[262,36326,1176],{"class":275},[262,36328,3143],{"class":429},[262,36330,36331,36333,36335,36337],{"class":181,"line":1737},[262,36332,6687],{"class":611},[262,36334,476],{"class":377},[262,36336,6692],{"class":271},[262,36338,1315],{"class":429},[262,36340,36341],{"class":181,"line":1751},[262,36342,1011],{"class":429},[262,36344,36345],{"class":181,"line":1764},[262,36346,6703],{"class":429},[14,36348,36349,36350,608,36353,36356,36357,36360,36361,36363],{},"Create the custom properties (",[18,36351,36352],{},"ai_industry",[18,36354,36355],{},"ai_intent",", and the rest) in your CRM settings before you run this, or the update will be rejected for unknown fields. Add your ",[18,36358,36359],{},"HUBSPOT_TOKEN"," to the same ",[18,36362,319],{}," file and keep that file out of version control.",[57,36365,36367],{"id":36366},"worked-example-enrich-a-batch-end-to-end","Worked example: enrich a batch end to end",[14,36369,36370,36371,36374],{},"This script ties the four steps together. It reads leads, enriches each one, applies the confidence gate, and either writes the result back or flags it for review. It uses a small sample list so you can run it immediately; replace ",[18,36372,36373],{},"SAMPLE_LEADS"," with rows pulled from your CRM.",[253,36376,36378],{"className":414,"code":36377,"language":416,"meta":258,"style":258},"# run.py\nimport time\n\nfrom enrich import enrich_lead\nfrom validate import is_trustworthy\n# from writeback import write_back  # uncomment when your CRM fields exist\n\nSAMPLE_LEADS = [\n    {\n        \"id\": \"101\",\n        \"name\": \"Dana Reyes\",\n        \"email\": \"dana@acme-clinic.com\",\n        \"job_title\": \"Operations Lead\",\n        \"message\": \"We run three clinics and need to automate patient reminders soon.\",\n    },\n    {\n        \"id\": \"102\",\n        \"name\": \"Sam Cole\",\n        \"email\": \"sam@gmail.com\",\n        \"job_title\": \"\",\n        \"message\": \"Just looking around, might come back later.\",\n    },\n]\n\n\ndef main() -> None:\n    for lead in SAMPLE_LEADS:\n        enrichment = enrich_lead(lead)\n        if is_trustworthy(enrichment):\n            print(f\"AUTO  {lead['name']}: {enrichment['summary']}\")\n            # write_back(lead[\"id\"], enrichment)\n        else:\n            print(f\"REVIEW {lead['name']}: low confidence -> human queue\")\n        time.sleep(0.5)  # gentle pacing keeps you under rate limits\n\n\nif __name__ == \"__main__\":\n    main()\n",[18,36379,36380,36385,36391,36395,36407,36419,36424,36428,36436,36440,36452,36464,36476,36488,36500,36504,36508,36519,36530,36541,36551,36562,36566,36570,36574,36578,36590,36604,36614,36621,36661,36666,36673,36699,36710,36714,36718,36730],{"__ignoreMap":258},[262,36381,36382],{"class":181,"line":264},[262,36383,36384],{"class":291},"# run.py\n",[262,36386,36387,36389],{"class":181,"line":282},[262,36388,684],{"class":377},[262,36390,2612],{"class":429},[262,36392,36393],{"class":181,"line":295},[262,36394,583],{"emptyLinePlaceholder":582},[262,36396,36397,36399,36402,36404],{"class":181,"line":345},[262,36398,705],{"class":377},[262,36400,36401],{"class":429}," enrich ",[262,36403,684],{"class":377},[262,36405,36406],{"class":429}," enrich_lead\n",[262,36408,36409,36411,36414,36416],{"class":181,"line":492},[262,36410,705],{"class":377},[262,36412,36413],{"class":429}," validate ",[262,36415,684],{"class":377},[262,36417,36418],{"class":429}," is_trustworthy\n",[262,36420,36421],{"class":181,"line":503},[262,36422,36423],{"class":291},"# from writeback import write_back  # uncomment when your CRM fields exist\n",[262,36425,36426],{"class":181,"line":521},[262,36427,583],{"emptyLinePlaceholder":582},[262,36429,36430,36432,36434],{"class":181,"line":537},[262,36431,36373],{"class":271},[262,36433,442],{"class":377},[262,36435,5589],{"class":429},[262,36437,36438],{"class":181,"line":549},[262,36439,5594],{"class":429},[262,36441,36442,36445,36447,36450],{"class":181,"line":570},[262,36443,36444],{"class":275},"        \"id\"",[262,36446,1231],{"class":429},[262,36448,36449],{"class":275},"\"101\"",[262,36451,1315],{"class":429},[262,36453,36454,36457,36459,36462],{"class":181,"line":579},[262,36455,36456],{"class":275},"        \"name\"",[262,36458,1231],{"class":429},[262,36460,36461],{"class":275},"\"Dana Reyes\"",[262,36463,1315],{"class":429},[262,36465,36466,36469,36471,36474],{"class":181,"line":586},[262,36467,36468],{"class":275},"        \"email\"",[262,36470,1231],{"class":429},[262,36472,36473],{"class":275},"\"dana@acme-clinic.com\"",[262,36475,1315],{"class":429},[262,36477,36478,36481,36483,36486],{"class":181,"line":591},[262,36479,36480],{"class":275},"        \"job_title\"",[262,36482,1231],{"class":429},[262,36484,36485],{"class":275},"\"Operations Lead\"",[262,36487,1315],{"class":429},[262,36489,36490,36493,36495,36498],{"class":181,"line":623},[262,36491,36492],{"class":275},"        \"message\"",[262,36494,1231],{"class":429},[262,36496,36497],{"class":275},"\"We run three clinics and need to automate patient reminders soon.\"",[262,36499,1315],{"class":429},[262,36501,36502],{"class":181,"line":634},[262,36503,5635],{"class":429},[262,36505,36506],{"class":181,"line":845},[262,36507,5594],{"class":429},[262,36509,36510,36512,36514,36517],{"class":181,"line":850},[262,36511,36444],{"class":275},[262,36513,1231],{"class":429},[262,36515,36516],{"class":275},"\"102\"",[262,36518,1315],{"class":429},[262,36520,36521,36523,36525,36528],{"class":181,"line":864},[262,36522,36456],{"class":275},[262,36524,1231],{"class":429},[262,36526,36527],{"class":275},"\"Sam Cole\"",[262,36529,1315],{"class":429},[262,36531,36532,36534,36536,36539],{"class":181,"line":1683},[262,36533,36468],{"class":275},[262,36535,1231],{"class":429},[262,36537,36538],{"class":275},"\"sam@gmail.com\"",[262,36540,1315],{"class":429},[262,36542,36543,36545,36547,36549],{"class":181,"line":1688},[262,36544,36480],{"class":275},[262,36546,1231],{"class":429},[262,36548,9175],{"class":275},[262,36550,1315],{"class":429},[262,36552,36553,36555,36557,36560],{"class":181,"line":1693},[262,36554,36492],{"class":275},[262,36556,1231],{"class":429},[262,36558,36559],{"class":275},"\"Just looking around, might come back later.\"",[262,36561,1315],{"class":429},[262,36563,36564],{"class":181,"line":1728},[262,36565,5635],{"class":429},[262,36567,36568],{"class":181,"line":1737},[262,36569,957],{"class":429},[262,36571,36572],{"class":181,"line":1751},[262,36573,583],{"emptyLinePlaceholder":582},[262,36575,36576],{"class":181,"line":1764},[262,36577,583],{"emptyLinePlaceholder":582},[262,36579,36580,36582,36584,36586,36588],{"class":181,"line":1779},[262,36581,423],{"class":377},[262,36583,23929],{"class":267},[262,36585,15481],{"class":429},[262,36587,8471],{"class":271},[262,36589,1160],{"class":429},[262,36591,36592,36594,36597,36599,36602],{"class":181,"line":1793},[262,36593,3074],{"class":377},[262,36595,36596],{"class":429}," lead ",[262,36598,835],{"class":377},[262,36600,36601],{"class":271}," SAMPLE_LEADS",[262,36603,1160],{"class":429},[262,36605,36606,36609,36611],{"class":181,"line":1800},[262,36607,36608],{"class":429},"        enrichment ",[262,36610,476],{"class":377},[262,36612,36613],{"class":429}," enrich_lead(lead)\n",[262,36615,36616,36618],{"class":181,"line":1805},[262,36617,2268],{"class":377},[262,36619,36620],{"class":429}," is_trustworthy(enrichment):\n",[262,36622,36623,36625,36627,36629,36632,36634,36637,36639,36641,36643,36645,36647,36650,36653,36655,36657,36659],{"class":181,"line":1810},[262,36624,3250],{"class":271},[262,36626,602],{"class":429},[262,36628,642],{"class":377},[262,36630,36631],{"class":275},"\"AUTO  ",[262,36633,3039],{"class":271},[262,36635,36636],{"class":429},"lead[",[262,36638,35710],{"class":275},[262,36640,6223],{"class":429},[262,36642,654],{"class":271},[262,36644,1231],{"class":275},[262,36646,3039],{"class":271},[262,36648,36649],{"class":429},"enrichment[",[262,36651,36652],{"class":275},"'summary'",[262,36654,6223],{"class":429},[262,36656,654],{"class":271},[262,36658,1176],{"class":275},[262,36660,660],{"class":429},[262,36662,36663],{"class":181,"line":1823},[262,36664,36665],{"class":291},"            # write_back(lead[\"id\"], enrichment)\n",[262,36667,36668,36671],{"class":181,"line":1846},[262,36669,36670],{"class":377},"        else",[262,36672,1160],{"class":429},[262,36674,36675,36677,36679,36681,36684,36686,36688,36690,36692,36694,36697],{"class":181,"line":1861},[262,36676,3250],{"class":271},[262,36678,602],{"class":429},[262,36680,642],{"class":377},[262,36682,36683],{"class":275},"\"REVIEW ",[262,36685,3039],{"class":271},[262,36687,36636],{"class":429},[262,36689,35710],{"class":275},[262,36691,6223],{"class":429},[262,36693,654],{"class":271},[262,36695,36696],{"class":275},": low confidence -> human queue\"",[262,36698,660],{"class":429},[262,36700,36701,36703,36705,36707],{"class":181,"line":1866},[262,36702,9055],{"class":429},[262,36704,3884],{"class":271},[262,36706,32223],{"class":429},[262,36708,36709],{"class":291},"# gentle pacing keeps you under rate limits\n",[262,36711,36712],{"class":181,"line":1871},[262,36713,583],{"emptyLinePlaceholder":582},[262,36715,36716],{"class":181,"line":1890},[262,36717,583],{"emptyLinePlaceholder":582},[262,36719,36720,36722,36724,36726,36728],{"class":181,"line":1909},[262,36721,2210],{"class":377},[262,36723,2213],{"class":271},[262,36725,2216],{"class":377},[262,36727,2219],{"class":275},[262,36729,1160],{"class":429},[262,36731,36732],{"class":181,"line":1914},[262,36733,24060],{"class":429},[14,36735,13310,36736,36739,36740,36742,36743,1363],{},[18,36737,36738],{},"python run.py",". The clinic lead should come back as healthcare with a clear \"evaluating\" or \"ready_to_buy\" intent and a high confidence score, while the browsing lead should land in the review queue. The ",[18,36741,28816],{}," is your friend on large batches; for a deeper fix when volume climbs, see ",[51,36744,3379],{"href":3378},[57,36746,1367],{"id":1366},[1379,36748,36749,36761],{},[1382,36750,36751],{},[1385,36752,36753,36755,36757,36759],{},[1388,36754,1390],{},[1388,36756,3795],{},[1388,36758,3798],{},[1388,36760,1396],{},[1398,36762,36763,36778,36796,36814],{},[1385,36764,36765,36769,36771,36775],{},[1403,36766,36767],{},[18,36768,805],{},[1403,36770,3811],{},[1403,36772,36773],{},[18,36774,2703],{},[1403,36776,36777],{},"The model that reads and classifies each lead. The compact mini model is cheap and accurate enough for this task; upgrade only if labels feel weak.",[1385,36779,36780,36784,36786,36790],{},[1403,36781,36782],{},[18,36783,3829],{},[1403,36785,3832],{},[1403,36787,36788],{},[18,36789,102],{},[1403,36791,36792,36793,36795],{},"Controls randomness. Keep it at ",[18,36794,102],{}," so the same lead always gets the same labels.",[1385,36797,36798,36802,36805,36807],{},[1403,36799,36800],{},[18,36801,5745],{},[1403,36803,36804],{},"object",[1403,36806,219],{},[1403,36808,36809,36810,36813],{},"Attaches your JSON schema. With ",[18,36811,36812],{},"strict: True",", the model must return valid JSON matching your fields.",[1385,36815,36816,36820,36822,36826],{},[1403,36817,36818],{},[18,36819,36105],{},[1403,36821,3832],{},[1403,36823,36824],{},[18,36825,4445],{},[1403,36827,36828],{},"Your confidence cut-off. Records below it go to human review instead of being written back.",[57,36830,1445],{"id":1444},[1447,36832,36833,36852,36876,36888],{},[1450,36834,36835,8429,36840,36842,36843,36845,36846,36849,36850,1363],{},[35,36836,36837],{},[18,36838,36839],{},"openai.AuthenticationError: 401",[18,36841,319],{}," sits in the folder you run from and that ",[18,36844,8439],{}," is called before ",[18,36847,36848],{},"OpenAI()",". The step-by-step cure is in ",[51,36851,388],{"href":387},[1450,36853,36854,36862,36863,36865,36866,36868,36869,36872,36873,36875],{},[35,36855,36856,36858,36859,36861],{},[18,36857,9945],{}," mentioning ",[18,36860,5745],{}," or schema"," — Your schema is malformed. With ",[18,36864,36812],{},", every property must appear in the ",[18,36867,17513],{}," list and ",[18,36870,36871],{},"additionalProperties"," must be ",[18,36874,3623],{},". Match the schema in Step 1 exactly.",[1450,36877,36878,36883,36884,36887],{},[35,36879,36880,36881],{},"Every field comes back ",[18,36882,35222],{}," — The model has nothing to work with. Check that you are actually passing the message text and email into ",[18,36885,36886],{},"user_message","; an empty string in, empty inferences out.",[1450,36889,36890,36896,36897,608,36899,36901],{},[35,36891,36892,36893],{},"HubSpot returns ",[18,36894,36895],{},"400 Property ... does not exist"," — You are writing to custom fields that have not been created yet. Add ",[18,36898,36352],{},[18,36900,36355],{},", and the others in your CRM's property settings before running the write-back.",[57,36903,2317],{"id":2316},[2322,36905,36906,36912,36918],{},[1450,36907,36908,36911],{},[35,36909,36910],{},"Use AI enrichment when the signal lives in free text"," — a project description, a support note, or a job title the model can interpret. This is exactly where rigid rules fall down and a language model shines.",[1450,36913,36914,36917],{},[35,36915,36916],{},"Use a data-enrichment provider (Clearbit, Apollo) when you need verified firmographics"," — headcount, funding, exact revenue. Those services look companies up in a database; the AI here only infers from the text in front of it, so prefer a database when you need hard facts rather than smart guesses.",[1450,36919,36920,36928,36929,36932],{},[35,36921,36922,36923,981,36925,36927],{},"Use simple ",[18,36924,2210],{},[18,36926,20859],{}," rules when the mapping is fixed"," — for example, \"any ",[18,36930,36931],{},".edu"," email is the education segment\". A rule is free, instant, and never wrong, so do not reach for a model when a one-line condition already settles it.",[14,36934,2375,36935,1363],{},[51,36936,36938],{"href":36937},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002F","CRM Data Integration with AI",[57,36940,2381],{"id":2380},[2322,36942,36943,36948,36953,36960],{},[1450,36944,36945,36947],{},[51,36946,36938],{"href":36937}," — the main guide covering the full fetch, clean, enrich, and write-back loop.",[1450,36949,36950,36952],{},[51,36951,35211],{"href":35210}," — pull and update contacts so enriched data has somewhere to land.",[1450,36954,36955,36959],{},[51,36956,36958],{"href":36957},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsummarize-sales-calls-to-your-crm-with-python\u002F","Summarize Sales Calls to Your CRM with Python"," — another AI-to-CRM workflow that pairs naturally with lead enrichment.",[1450,36961,36962,36964],{},[51,36963,2487],{"href":2486}," — the foundations of calling AI models from Python.",[2401,36966,36967],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":36969},[36970,36971,36972,36973,36974,36975,36976,36977,36978,36979],{"id":237,"depth":282,"text":238},{"id":35215,"depth":282,"text":35216},{"id":35534,"depth":282,"text":35535},{"id":35983,"depth":282,"text":35984},{"id":36109,"depth":282,"text":36110},{"id":36366,"depth":282,"text":36367},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Use the openai SDK to enrich raw CRM leads in Python: infer industry, company size, intent, and a tidy summary, then write the structured result back to your CRM.",[36982,36985,36988,36991,36994],{"q":36983,"a":36984},"What does it mean to enrich a CRM lead?","Enriching a lead means adding useful, structured details that the lead did not provide directly. Here you ask an AI model to infer fields like industry, company size, and buying intent from the raw text a lead left behind, then save those fields back to the contact record.",{"q":36986,"a":36987},"Will the AI invent facts about my leads?","It can, which is why you cap the model's freedom. You ask only for inferences it can reasonably draw from the supplied text, give it an explicit 'unknown' option for every field, and store a confidence score so your sales team knows when to trust a value.",{"q":36989,"a":36990},"Do I need a paid OpenAI plan for this?","You need a funded OpenAI account because lead enrichment runs through the paid API, not the free chat product. Costs are small: classifying a single lead with a compact model usually costs a fraction of a cent.",{"q":36992,"a":36993},"How do I force the model to return clean JSON every time?","Use the API's structured-output mode by passing a response_format that names a JSON schema. The model is then constrained to return valid JSON matching your fields, so you never have to parse messy free text.",{"q":36995,"a":36996},"Is it safe to send lead data to an AI model?","Send only the fields you need to make the inference, never passwords or payment details. The OpenAI API does not train on data sent through the API by default, but you should still strip sensitive values before the call and check your provider's retention policy.",{"name":36998,"steps":36999},"How to enrich CRM leads with AI in Python",[37000,37003,37006,37009],{"name":37001,"text":37002},"Install dependencies and store keys","Install the openai and python-dotenv packages and put your API key in a .env file.",{"name":37004,"text":37005},"Define the enrichment schema","Describe the exact fields you want back, such as industry, company size, intent, and a summary, as a JSON schema.",{"name":37007,"text":37008},"Call the model in structured-output mode","Send each raw lead to the model with the schema attached so it returns clean, validated JSON.",{"name":37010,"text":37011},"Write the enriched fields back to the CRM","Send the parsed result to your CRM's API as an update on the matching contact record.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fenrich-crm-leads-with-ai-in-python",{"title":35139,"description":36980},"building-ai-powered-business-applications\u002Fcrm-data-integration\u002Fenrich-crm-leads-with-ai-in-python\u002Findex","0sJr0LWYG6ewkFQIkSn6Z45iRV2bP12K4A6YmrkZjCY",{"id":37018,"title":37019,"body":37020,"description":39734,"extension":2419,"faq":39735,"howto":39751,"meta":39766,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":39767,"published":39768,"seo":39769,"seoTitle":39770,"stem":39771,"__hash__":39772},"content\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Findex.md","CRM Data Integration with Python and AI",{"type":7,"value":37021,"toc":39721},[37022,37025,37032,37051,37053,37056,37070,37081,37084,37182,37184,37196,37199,37219,37241,37247,37266,37274,37288,37294,37297,37301,37318,37504,37515,37524,37528,37531,37538,37781,37786,37798,37802,37812,38091,38097,38320,38337,38349,38353,38367,38482,38492,38494,38497,38611,38613,38702,38706,38724,39648,39650,39653,39691,39695,39697,39719],[10,37023,37019],{"id":37024},"crm-data-integration-with-python-and-ai",[14,37026,37027,37028,37031],{},"Your customer data is scattered. Names are typed three different ways, half the company fields are blank, and the notes your sales team left after each call are trapped in free text that no report can read. ",[35,37029,37030],{},"CRM data integration"," is the work of pulling those records out of your customer relationship management tool, cleaning them up, and adding new structured information back in. When you connect that pipeline to an AI model, you can do something a spreadsheet never could: read every messy note, score every lead, and tag every account automatically.",[14,37033,37034,37035,37041,37042,37048,37049,1363],{},"This guide shows you how to build that pipeline in Python, even if you have never written a sync job before. You will pull contacts from a CRM over its API (the doorway a program uses to talk to a service), clean the fields, enrich each contact with an AI model, and write the results back. We use the official ",[51,37036,37039],{"href":37037,"rel":37038},"https:\u002F\u002Fgithub.com\u002Fopenai\u002Fopenai-python",[6509],[18,37040,20],{}," SDK for the AI calls and ",[51,37043,37046],{"href":37044,"rel":37045},"https:\u002F\u002Fwww.python-httpx.org\u002F",[6509],[18,37047,5450],{}," for the CRM calls, both of which are modern, well-supported, and beginner-friendly. This is one of the core building blocks of ",[51,37050,26457],{"href":26456},[57,37052,12747],{"id":12746},[14,37054,37055],{},"This is for founders, marketers, and operations people who run a CRM (HubSpot, Pipedrive, Salesforce, Zoho, and the like) and want to stop doing data hygiene by hand. By the end you will have a single Python script that:",[1447,37057,37058,37061,37064,37067],{},[1450,37059,37060],{},"Reads contacts from your CRM in safe, paged batches.",[1450,37062,37063],{},"Standardizes emails, phone numbers, and company names.",[1450,37065,37066],{},"Asks an AI model to classify each contact's industry and write a one-line summary.",[1450,37068,37069],{},"Writes those AI fields back onto each contact so your team sees them inside the CRM.",[14,37071,37072,37073,608,37075,13390,37078,37080],{},"The same fetch → clean → enrich → write-back shape powers every project in this section, including ",[51,37074,35211],{"href":35210},[51,37076,35139],{"href":37077},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fenrich-crm-leads-with-ai-in-python\u002F",[51,37079,36958],{"href":36957},". Learn it once here and those guides will feel like small variations rather than new puzzles.",[14,37082,37083],{},"It helps to picture why each stage exists. Fetching is about getting the data out reliably, in batches that respect the API's limits. Cleaning is about making the data consistent, so that \"Acme Inc.\", \"acme inc\", and \"ACME\" all become one company instead of three. Enriching is where the AI earns its keep, reading text a rule-based script could never parse and turning it into a tidy label or summary. Writing back closes the loop, putting that intelligence where your team already works instead of in some separate spreadsheet nobody opens. Skip any one of those stages and the pipeline stops being useful: clean data with no enrichment is just tidy data, and enrichment that never gets written back is intelligence trapped in a terminal window.",[76,37085,37087,37179],{"className":37086},[79],[81,37088,90,37091,90,37094,90,37097,90,37101,90,37105,90,37109,90,37113,90,37116,90,37120,90,37124,90,37127,90,37131,90,37133,90,37137,90,37139,90,37142,90,37146,90,37151,90,37153,90,37156,90,37160,90,37164,90,37167,90,37170],{"viewBox":37089,"role":84,"ariaLabelledBy":37090,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1160 380",[7091,7092],[92,37092,37093],{"id":7091},"CRM, Python, and AI data flow",[96,37095,37096],{"id":7092},"Contacts flow from the CRM into a Python script, which cleans the data, sends it to an AI model for enrichment, then writes the results back to the CRM.",[100,37098],{"x":102,"y":37099,"width":37100,"height":141,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"110","220",[111,37102,37104],{"x":37099,"y":37103,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"142","Your CRM",[111,37106,37108],{"x":37099,"y":37107,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"164","REST API",[111,37110,37112],{"x":37099,"y":37111,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"182","contacts",[100,37114],{"x":37115,"y":113,"width":37100,"height":191,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"430",[111,37117,24356],{"x":37118,"y":37119,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"540","132",[111,37121,37123],{"x":37118,"y":37122,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"154","httpx: fetch",[111,37125,37126],{"x":37118,"y":24399,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"pandas: clean",[111,37128,37130],{"x":37118,"y":37129,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"190","httpx: write",[100,37132],{"x":19904,"y":37099,"width":37100,"height":141,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,37134,37136],{"x":37135,"y":37103,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"970","AI model",[111,37138,7124],{"x":37135,"y":37107,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[111,37140,37141],{"x":37135,"y":37111,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"classify + sum",[181,37143],{"x1":37100,"y1":24368,"x2":37144,"y2":24368,"stroke":143,"strokeWidth":109,"markerEnd":37145},"428","url(#arrowFlow)",[111,37147,37150],{"x":37148,"y":37149,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"324","128","1. fetch",[181,37152],{"x1":37115,"y1":19908,"x2":24405,"y2":19908,"stroke":143,"strokeWidth":109,"markerEnd":37145},[111,37154,37155],{"x":37148,"y":183,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"4. write back",[181,37157],{"x1":37158,"y1":24368,"x2":37159,"y2":24368,"stroke":143,"strokeWidth":109,"markerEnd":37145},"650","858",[111,37161,37163],{"x":37162,"y":37149,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"754","2. send",[181,37165],{"x1":19904,"y1":19908,"x2":37166,"y2":19908,"stroke":143,"strokeWidth":109,"markerEnd":37145},"652",[111,37168,37169],{"x":37162,"y":183,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"3. enriched",[5548,37171,5550,37172,90],{},[5552,37173,5558,37176,5550],{"id":37174,"markerWidth":3868,"markerHeight":3868,"refX":5555,"refY":5556,"orient":5557,"markerUnits":37175},"arrowFlow","strokeWidth",[216,37177],{"d":37178,"fill":143},"M0,0 L8,3 L0,6 Z",[232,37180,37181],{},"The pipeline is a loop: Python pulls contacts, hands them to the AI model, and writes the enriched fields straight back to the CRM.",[57,37183,238],{"id":237},[14,37185,19950,37186,37188,37189,37191,37192,37195],{},[18,37187,17782],{},". If you have not set Python up yet, follow ",[51,37190,5423],{"href":5422}," first, and ideally work inside a ",[51,37193,37194],{"href":2481},"Python virtual environment"," so this project's packages stay isolated from the rest of your system.",[14,37197,37198],{},"Install the three packages this guide uses:",[253,37200,37202],{"className":255,"code":37201,"language":257,"meta":258,"style":258},"pip install openai httpx python-dotenv pandas\n",[18,37203,37204],{"__ignoreMap":258},[262,37205,37206,37208,37210,37212,37214,37216],{"class":181,"line":264},[262,37207,298],{"class":267},[262,37209,301],{"class":275},[262,37211,2519],{"class":275},[262,37213,5440],{"class":275},[262,37215,310],{"class":275},[262,37217,37218],{"class":275}," pandas\n",[2322,37220,37221,37226,37231,37236],{},[1450,37222,37223,37225],{},[18,37224,20],{}," is the official SDK for calling AI models.",[1450,37227,37228,37230],{},[18,37229,5450],{}," is a modern HTTP client we use to talk to the CRM's API.",[1450,37232,37233,37235],{},[18,37234,2501],{}," loads your secret keys from a file instead of hardcoding them.",[1450,37237,37238,37240],{},[18,37239,2494],{}," is a table library that makes cleaning rows of data quick.",[14,37242,37243,37244,37246],{},"Now create a file named ",[18,37245,319],{}," in your project folder to hold your credentials. A credential is just a secret password your code uses to prove it is allowed to access a service.",[253,37248,37250],{"className":323,"code":37249,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-openai-key-here\nCRM_API_TOKEN=your-crm-private-app-token\nCRM_BASE_URL=https:\u002F\u002Fapi.hubapi.com\n",[18,37251,37252,37256,37261],{"__ignoreMap":258},[262,37253,37254],{"class":181,"line":264},[262,37255,5469],{},[262,37257,37258],{"class":181,"line":282},[262,37259,37260],{},"CRM_API_TOKEN=your-crm-private-app-token\n",[262,37262,37263],{"class":181,"line":295},[262,37264,37265],{},"CRM_BASE_URL=https:\u002F\u002Fapi.hubapi.com\n",[14,37267,37268,37269,356,37271,37273],{},"Important: add ",[18,37270,319],{},[18,37272,359],{}," file so these secrets never get committed to version control. If you skip this, your keys can leak the moment you push to GitHub. One line does it:",[253,37275,37276],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,37277,37278],{"__ignoreMap":258},[262,37279,37280,37282,37284,37286],{"class":181,"line":264},[262,37281,371],{"class":271},[262,37283,374],{"class":275},[262,37285,378],{"class":377},[262,37287,381],{"class":275},[14,37289,37290,37291,37293],{},"If your AI keys are new to you, ",[51,37292,2487],{"href":2486}," explains where they come from, how billing works, and how to read the errors a model returns.",[14,37295,37296],{},"One more decision before you write any code: what data should ever leave your CRM? A good rule is to send the model only the fields it needs to do its job. The industry classifier needs the company name and a few notes — it does not need email addresses, phone numbers, deal values, or anything that identifies a real person. Stripping those out before the AI call is both safer and cheaper, because you pay for every word you send. We will keep this principle in mind through every step: clean locally, send the minimum, and write the result back. Most AI providers, including the OpenAI API, do not train on data you send through the API, but the only data that can never leak is the data you never send.",[57,37298,37300],{"id":37299},"step-1-load-credentials-and-create-your-clients","Step 1 — Load credentials and create your clients",[14,37302,37303,37304,37306,37307,37309,37310,37313,37314,37317],{},"Every script starts by reading your secrets and creating two \"clients\" — small objects that hold the connection details so you do not repeat them on every call. We load the ",[18,37305,319],{}," file with ",[18,37308,2501],{},", then build an ",[18,37311,37312],{},"OpenAI"," client for AI calls and an ",[18,37315,37316],{},"httpx.Client"," for CRM calls.",[253,37319,37321],{"className":414,"code":37320,"language":416,"meta":258,"style":258},"import os\nimport httpx\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # reads the .env file into environment variables\n\nOPENAI_API_KEY = os.environ[\"OPENAI_API_KEY\"]\nCRM_API_TOKEN = os.environ[\"CRM_API_TOKEN\"]\nCRM_BASE_URL = os.environ[\"CRM_BASE_URL\"]\n\n# The AI client. It picks up OPENAI_API_KEY automatically, but we pass it to be explicit.\nai = OpenAI(api_key=OPENAI_API_KEY)\n\n# The CRM client. timeout means \"give up after 30 seconds\" so the script never hangs forever.\ncrm = httpx.Client(\n    base_url=CRM_BASE_URL,\n    headers={\"Authorization\": f\"Bearer {CRM_API_TOKEN}\"},\n    timeout=30.0,\n)\n",[18,37322,37323,37329,37335,37345,37355,37359,37365,37369,37381,37395,37409,37413,37418,37435,37439,37444,37454,37465,37489,37500],{"__ignoreMap":258},[262,37324,37325,37327],{"class":181,"line":264},[262,37326,684],{"class":377},[262,37328,687],{"class":429},[262,37330,37331,37333],{"class":181,"line":282},[262,37332,684],{"class":377},[262,37334,6526],{"class":429},[262,37336,37337,37339,37341,37343],{"class":181,"line":295},[262,37338,705],{"class":377},[262,37340,708],{"class":429},[262,37342,684],{"class":377},[262,37344,713],{"class":429},[262,37346,37347,37349,37351,37353],{"class":181,"line":345},[262,37348,705],{"class":377},[262,37350,720],{"class":429},[262,37352,684],{"class":377},[262,37354,725],{"class":429},[262,37356,37357],{"class":181,"line":492},[262,37358,583],{"emptyLinePlaceholder":582},[262,37360,37361,37363],{"class":181,"line":503},[262,37362,4222],{"class":429},[262,37364,4225],{"class":291},[262,37366,37367],{"class":181,"line":521},[262,37368,583],{"emptyLinePlaceholder":582},[262,37370,37371,37373,37375,37377,37379],{"class":181,"line":537},[262,37372,21742],{"class":271},[262,37374,442],{"class":377},[262,37376,36185],{"class":429},[262,37378,2681],{"class":275},[262,37380,957],{"class":429},[262,37382,37383,37386,37388,37390,37393],{"class":181,"line":549},[262,37384,37385],{"class":271},"CRM_API_TOKEN",[262,37387,442],{"class":377},[262,37389,36185],{"class":429},[262,37391,37392],{"class":275},"\"CRM_API_TOKEN\"",[262,37394,957],{"class":429},[262,37396,37397,37400,37402,37404,37407],{"class":181,"line":570},[262,37398,37399],{"class":271},"CRM_BASE_URL",[262,37401,442],{"class":377},[262,37403,36185],{"class":429},[262,37405,37406],{"class":275},"\"CRM_BASE_URL\"",[262,37408,957],{"class":429},[262,37410,37411],{"class":181,"line":579},[262,37412,583],{"emptyLinePlaceholder":582},[262,37414,37415],{"class":181,"line":586},[262,37416,37417],{"class":291},"# The AI client. It picks up OPENAI_API_KEY automatically, but we pass it to be explicit.\n",[262,37419,37420,37423,37425,37427,37429,37431,37433],{"class":181,"line":591},[262,37421,37422],{"class":429},"ai ",[262,37424,476],{"class":377},[262,37426,1588],{"class":429},[262,37428,2674],{"class":611},[262,37430,476],{"class":377},[262,37432,21742],{"class":271},[262,37434,660],{"class":429},[262,37436,37437],{"class":181,"line":623},[262,37438,583],{"emptyLinePlaceholder":582},[262,37440,37441],{"class":181,"line":634},[262,37442,37443],{"class":291},"# The CRM client. timeout means \"give up after 30 seconds\" so the script never hangs forever.\n",[262,37445,37446,37449,37451],{"class":181,"line":845},[262,37447,37448],{"class":429},"crm ",[262,37450,476],{"class":377},[262,37452,37453],{"class":429}," httpx.Client(\n",[262,37455,37456,37459,37461,37463],{"class":181,"line":850},[262,37457,37458],{"class":611},"    base_url",[262,37460,476],{"class":377},[262,37462,37399],{"class":271},[262,37464,1315],{"class":429},[262,37466,37467,37470,37472,37474,37476,37478,37480,37482,37485,37487],{"class":181,"line":864},[262,37468,37469],{"class":611},"    headers",[262,37471,476],{"class":377},[262,37473,3039],{"class":429},[262,37475,16998],{"class":275},[262,37477,1231],{"class":429},[262,37479,642],{"class":377},[262,37481,6605],{"class":275},[262,37483,37484],{"class":271},"{CRM_API_TOKEN}",[262,37486,1176],{"class":275},[262,37488,3143],{"class":429},[262,37490,37491,37494,37496,37498],{"class":181,"line":1683},[262,37492,37493],{"class":611},"    timeout",[262,37495,476],{"class":377},[262,37497,6692],{"class":271},[262,37499,1315],{"class":429},[262,37501,37502],{"class":181,"line":1688},[262,37503,660],{"class":429},[14,37505,37506,37507,37510,37511,37514],{},"Using ",[18,37508,37509],{},"os.environ[\"KEY\"]"," (with square brackets) instead of ",[18,37512,37513],{},"os.getenv(\"KEY\")"," means the script stops with a clear error if a key is missing, rather than silently sending a blank token and failing later with a confusing message. This \"fail fast\" habit saves you from the most common beginner trap, where a typo in a key name produces a vague 401 error twenty lines later instead of an obvious one on the first line.",[14,37516,37517,37518,37520,37521,37523],{},"A quick word on the two clients. They look similar but do very different jobs. The ",[18,37519,37312],{}," client knows how to format requests the AI model expects and how to read its replies — you never touch raw HTTP with it. The ",[18,37522,37316],{}," is more general: it talks to any web API, and we point it at your CRM. Creating each client once and reusing it (rather than rebuilding it on every loop) keeps the underlying network connection alive, which makes a sync of thousands of contacts noticeably faster. Think of the client as a phone line you open once and keep talking on, instead of redialing for every sentence.",[57,37525,37527],{"id":37526},"step-2-pull-contacts-from-the-crm-in-pages","Step 2 — Pull contacts from the CRM in pages",[14,37529,37530],{},"CRMs do not hand you all your contacts at once. They return a \"page\" of records plus a pointer to the next page. This is called pagination, and respecting it is how you sync 50,000 contacts without running out of memory or tripping a rate limit (the cap on how many requests you can make per minute).",[14,37532,37533,37534,37537],{},"The loop below keeps asking for the next page until the CRM stops sending a ",[18,37535,37536],{},"paging.next.after"," cursor.",[253,37539,37541],{"className":414,"code":37540,"language":416,"meta":258,"style":258},"def fetch_contacts(page_size: int = 100) -> list[dict]:\n    \"\"\"Pull every contact from the CRM, one page at a time.\"\"\"\n    contacts: list[dict] = []\n    after: str | None = None\n\n    while True:\n        params = {\n            \"limit\": page_size,\n            \"properties\": \"email,phone,company,notes_last_contacted\",\n        }\n        if after:\n            params[\"after\"] = after\n\n        response = crm.get(\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\", params=params)\n        response.raise_for_status()  # turn HTTP errors (401, 429, 500) into Python exceptions\n        data = response.json()\n\n        contacts.extend(data.get(\"results\", []))\n\n        # The CRM tells us where the next page starts; if it is missing, we are done.\n        after = data.get(\"paging\", {}).get(\"next\", {}).get(\"after\")\n        if not after:\n            break\n\n    return contacts\n",[18,37542,37543,37566,37571,37584,37600,37604,37612,37621,37629,37641,37645,37652,37667,37671,37693,37701,37710,37714,37724,37728,37733,37758,37766,37770,37774],{"__ignoreMap":258},[262,37544,37545,37547,37550,37553,37555,37557,37560,37562,37564],{"class":181,"line":264},[262,37546,423],{"class":377},[262,37548,37549],{"class":267}," fetch_contacts",[262,37551,37552],{"class":429},"(page_size: ",[262,37554,439],{"class":271},[262,37556,442],{"class":377},[262,37558,37559],{"class":271}," 100",[262,37561,458],{"class":429},[262,37563,5869],{"class":271},[262,37565,463],{"class":429},[262,37567,37568],{"class":181,"line":282},[262,37569,37570],{"class":275},"    \"\"\"Pull every contact from the CRM, one page at a time.\"\"\"\n",[262,37572,37573,37576,37578,37580,37582],{"class":181,"line":295},[262,37574,37575],{"class":429},"    contacts: list[",[262,37577,5869],{"class":271},[262,37579,2903],{"class":429},[262,37581,476],{"class":377},[262,37583,489],{"class":429},[262,37585,37586,37589,37591,37594,37596,37598],{"class":181,"line":345},[262,37587,37588],{"class":429},"    after: ",[262,37590,433],{"class":271},[262,37592,37593],{"class":377}," |",[262,37595,20599],{"class":271},[262,37597,442],{"class":377},[262,37599,18658],{"class":271},[262,37601,37602],{"class":181,"line":492},[262,37603,583],{"emptyLinePlaceholder":582},[262,37605,37606,37608,37610],{"class":181,"line":503},[262,37607,506],{"class":377},[262,37609,2241],{"class":271},[262,37611,1160],{"class":429},[262,37613,37614,37617,37619],{"class":181,"line":521},[262,37615,37616],{"class":429},"        params ",[262,37618,476],{"class":377},[262,37620,20437],{"class":429},[262,37622,37623,37626],{"class":181,"line":537},[262,37624,37625],{"class":275},"            \"limit\"",[262,37627,37628],{"class":429},": page_size,\n",[262,37630,37631,37634,37636,37639],{"class":181,"line":549},[262,37632,37633],{"class":275},"            \"properties\"",[262,37635,1231],{"class":429},[262,37637,37638],{"class":275},"\"email,phone,company,notes_last_contacted\"",[262,37640,1315],{"class":429},[262,37642,37643],{"class":181,"line":570},[262,37644,36275],{"class":429},[262,37646,37647,37649],{"class":181,"line":579},[262,37648,2268],{"class":377},[262,37650,37651],{"class":429}," after:\n",[262,37653,37654,37657,37660,37662,37664],{"class":181,"line":586},[262,37655,37656],{"class":429},"            params[",[262,37658,37659],{"class":275},"\"after\"",[262,37661,2903],{"class":429},[262,37663,476],{"class":377},[262,37665,37666],{"class":429}," after\n",[262,37668,37669],{"class":181,"line":591},[262,37670,583],{"emptyLinePlaceholder":582},[262,37672,37673,37675,37677,37680,37683,37685,37688,37690],{"class":181,"line":623},[262,37674,21490],{"class":429},[262,37676,476],{"class":377},[262,37678,37679],{"class":429}," crm.get(",[262,37681,37682],{"class":275},"\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\"",[262,37684,608],{"class":429},[262,37686,37687],{"class":611},"params",[262,37689,476],{"class":377},[262,37691,37692],{"class":429},"params)\n",[262,37694,37695,37698],{"class":181,"line":634},[262,37696,37697],{"class":429},"        response.raise_for_status()  ",[262,37699,37700],{"class":291},"# turn HTTP errors (401, 429, 500) into Python exceptions\n",[262,37702,37703,37706,37708],{"class":181,"line":845},[262,37704,37705],{"class":429},"        data ",[262,37707,476],{"class":377},[262,37709,6710],{"class":429},[262,37711,37712],{"class":181,"line":850},[262,37713,583],{"emptyLinePlaceholder":582},[262,37715,37716,37719,37721],{"class":181,"line":864},[262,37717,37718],{"class":429},"        contacts.extend(data.get(",[262,37720,34288],{"class":275},[262,37722,37723],{"class":429},", []))\n",[262,37725,37726],{"class":181,"line":1683},[262,37727,583],{"emptyLinePlaceholder":582},[262,37729,37730],{"class":181,"line":1688},[262,37731,37732],{"class":291},"        # The CRM tells us where the next page starts; if it is missing, we are done.\n",[262,37734,37735,37738,37740,37743,37746,37749,37752,37754,37756],{"class":181,"line":1693},[262,37736,37737],{"class":429},"        after ",[262,37739,476],{"class":377},[262,37741,37742],{"class":429}," data.get(",[262,37744,37745],{"class":275},"\"paging\"",[262,37747,37748],{"class":429},", {}).get(",[262,37750,37751],{"class":275},"\"next\"",[262,37753,37748],{"class":429},[262,37755,37659],{"class":275},[262,37757,660],{"class":429},[262,37759,37760,37762,37764],{"class":181,"line":1728},[262,37761,2268],{"class":377},[262,37763,2818],{"class":377},[262,37765,37651],{"class":429},[262,37767,37768],{"class":181,"line":1737},[262,37769,2293],{"class":377},[262,37771,37772],{"class":181,"line":1751},[262,37773,583],{"emptyLinePlaceholder":582},[262,37775,37776,37778],{"class":181,"line":1764},[262,37777,573],{"class":377},[262,37779,37780],{"class":429}," contacts\n",[14,37782,37783,37785],{},[18,37784,6778],{}," is your safety net: if the CRM returns a 401 (bad token) or 429 (too many requests), the script raises an exception instead of quietly storing an error message as if it were data. Without it, a failed request would hand back a small JSON error blob, your loop would treat that blob as a \"contact,\" and you would not notice the problem until the cleaning step produced nonsense.",[14,37787,37788,37789,37792,37793,37795,37796,1363],{},"Notice the ",[18,37790,37791],{},"properties"," parameter. Most CRMs return only a handful of default fields unless you ask for more by name, so we list exactly the four we want: email, phone, company, and the last-contacted notes. Requesting only what you need keeps each response small and fast, and it doubles as a privacy control — fields you never fetch can never accidentally end up in an AI prompt. The cursor pattern (",[18,37794,37536],{},") is the other thing worth understanding. Rather than asking for \"page 2\" by number, the CRM hands you an opaque token that means \"start right after the last record you saw.\" This is more reliable than page numbers when records are being added or deleted mid-sync, because it never skips or repeats a row. For a deeper look at one CRM's exact endpoints and property names, see ",[51,37797,35211],{"href":35210},[57,37799,37801],{"id":37800},"step-3-clean-the-data-then-enrich-each-contact-with-ai","Step 3 — Clean the data, then enrich each contact with AI",[14,37803,37804,37805,37808,37809,37811],{},"Raw CRM data is messy: ",[18,37806,37807],{},"JANE@COMPANY.COM ",", phone numbers with dashes and spaces, company names in mixed case. Clean these first so the AI sees consistent input and your downstream reports group correctly. We use ",[18,37810,2494],{}," for the cleaning because it handles whole columns in one line.",[253,37813,37815],{"className":414,"code":37814,"language":416,"meta":258,"style":258},"import pandas as pd\n\n\ndef clean_contacts(raw: list[dict]) -> pd.DataFrame:\n    \"\"\"Flatten the CRM payload and normalize the messy fields.\"\"\"\n    rows = [\n        {\n            \"id\": c[\"id\"],\n            \"email\": (c[\"properties\"].get(\"email\") or \"\").lower().strip(),\n            \"phone\": (c[\"properties\"].get(\"phone\") or \"\"),\n            \"company\": (c[\"properties\"].get(\"company\") or \"\").strip().title(),\n            \"notes\": (c[\"properties\"].get(\"notes_last_contacted\") or \"\").strip(),\n        }\n        for c in raw\n    ]\n    df = pd.DataFrame(rows)\n    df[\"phone\"] = df[\"phone\"].str.replace(r\"\\D\", \"\", regex=True)  # keep digits only\n    df = df[df[\"email\"] != \"\"]  # drop contacts with no email\n    return df.drop_duplicates(subset=[\"email\"]).reset_index(drop=True)\n",[18,37816,37817,37827,37831,37835,37849,37854,37862,37867,37879,37905,37927,37950,37973,37977,37989,37993,38001,38045,38067],{"__ignoreMap":258},[262,37818,37819,37821,37823,37825],{"class":181,"line":264},[262,37820,684],{"class":377},[262,37822,2619],{"class":429},[262,37824,697],{"class":377},[262,37826,2624],{"class":429},[262,37828,37829],{"class":181,"line":282},[262,37830,583],{"emptyLinePlaceholder":582},[262,37832,37833],{"class":181,"line":295},[262,37834,583],{"emptyLinePlaceholder":582},[262,37836,37837,37839,37842,37845,37847],{"class":181,"line":345},[262,37838,423],{"class":377},[262,37840,37841],{"class":267}," clean_contacts",[262,37843,37844],{"class":429},"(raw: list[",[262,37846,5869],{"class":271},[262,37848,33220],{"class":429},[262,37850,37851],{"class":181,"line":492},[262,37852,37853],{"class":275},"    \"\"\"Flatten the CRM payload and normalize the messy fields.\"\"\"\n",[262,37855,37856,37858,37860],{"class":181,"line":503},[262,37857,25637],{"class":429},[262,37859,476],{"class":377},[262,37861,5589],{"class":429},[262,37863,37864],{"class":181,"line":521},[262,37865,37866],{"class":429},"        {\n",[262,37868,37869,37872,37875,37877],{"class":181,"line":537},[262,37870,37871],{"class":275},"            \"id\"",[262,37873,37874],{"class":429},": c[",[262,37876,6770],{"class":275},[262,37878,10309],{"class":429},[262,37880,37881,37884,37887,37890,37893,37896,37898,37900,37902],{"class":181,"line":549},[262,37882,37883],{"class":275},"            \"email\"",[262,37885,37886],{"class":429},": (c[",[262,37888,37889],{"class":275},"\"properties\"",[262,37891,37892],{"class":429},"].get(",[262,37894,37895],{"class":275},"\"email\"",[262,37897,1000],{"class":429},[262,37899,8923],{"class":377},[262,37901,6332],{"class":275},[262,37903,37904],{"class":429},").lower().strip(),\n",[262,37906,37907,37910,37912,37914,37916,37919,37921,37923,37925],{"class":181,"line":570},[262,37908,37909],{"class":275},"            \"phone\"",[262,37911,37886],{"class":429},[262,37913,37889],{"class":275},[262,37915,37892],{"class":429},[262,37917,37918],{"class":275},"\"phone\"",[262,37920,1000],{"class":429},[262,37922,8923],{"class":377},[262,37924,6332],{"class":275},[262,37926,1210],{"class":429},[262,37928,37929,37932,37934,37936,37938,37941,37943,37945,37947],{"class":181,"line":579},[262,37930,37931],{"class":275},"            \"company\"",[262,37933,37886],{"class":429},[262,37935,37889],{"class":275},[262,37937,37892],{"class":429},[262,37939,37940],{"class":275},"\"company\"",[262,37942,1000],{"class":429},[262,37944,8923],{"class":377},[262,37946,6332],{"class":275},[262,37948,37949],{"class":429},").strip().title(),\n",[262,37951,37952,37955,37957,37959,37961,37964,37966,37968,37970],{"class":181,"line":586},[262,37953,37954],{"class":275},"            \"notes\"",[262,37956,37886],{"class":429},[262,37958,37889],{"class":275},[262,37960,37892],{"class":429},[262,37962,37963],{"class":275},"\"notes_last_contacted\"",[262,37965,1000],{"class":429},[262,37967,8923],{"class":377},[262,37969,6332],{"class":275},[262,37971,37972],{"class":429},").strip(),\n",[262,37974,37975],{"class":181,"line":591},[262,37976,36275],{"class":429},[262,37978,37979,37981,37984,37986],{"class":181,"line":623},[262,37980,10155],{"class":377},[262,37982,37983],{"class":429}," c ",[262,37985,835],{"class":377},[262,37987,37988],{"class":429}," raw\n",[262,37990,37991],{"class":181,"line":634},[262,37992,7761],{"class":429},[262,37994,37995,37997,37999],{"class":181,"line":845},[262,37996,26737],{"class":429},[262,37998,476],{"class":377},[262,38000,30783],{"class":429},[262,38002,38003,38005,38007,38009,38011,38013,38015,38018,38020,38022,38025,38027,38029,38031,38033,38036,38038,38040,38042],{"class":181,"line":850},[262,38004,2897],{"class":429},[262,38006,37918],{"class":275},[262,38008,2903],{"class":429},[262,38010,476],{"class":377},[262,38012,27464],{"class":429},[262,38014,37918],{"class":275},[262,38016,38017],{"class":429},"].str.replace(",[262,38019,7973],{"class":377},[262,38021,1176],{"class":275},[262,38023,38024],{"class":271},"\\D",[262,38026,1176],{"class":275},[262,38028,608],{"class":429},[262,38030,9175],{"class":275},[262,38032,608],{"class":429},[262,38034,38035],{"class":611},"regex",[262,38037,476],{"class":377},[262,38039,4974],{"class":271},[262,38041,32223],{"class":429},[262,38043,38044],{"class":291},"# keep digits only\n",[262,38046,38047,38049,38051,38053,38055,38057,38059,38061,38064],{"class":181,"line":864},[262,38048,26737],{"class":429},[262,38050,476],{"class":377},[262,38052,29140],{"class":429},[262,38054,37895],{"class":275},[262,38056,2903],{"class":429},[262,38058,23215],{"class":377},[262,38060,6332],{"class":275},[262,38062,38063],{"class":429},"]  ",[262,38065,38066],{"class":291},"# drop contacts with no email\n",[262,38068,38069,38071,38073,38075,38077,38079,38081,38083,38085,38087,38089],{"class":181,"line":1683},[262,38070,573],{"class":377},[262,38072,30627],{"class":429},[262,38074,27491],{"class":611},[262,38076,476],{"class":377},[262,38078,12118],{"class":429},[262,38080,37895],{"class":275},[262,38082,29966],{"class":429},[262,38084,26854],{"class":611},[262,38086,476],{"class":377},[262,38088,4974],{"class":271},[262,38090,660],{"class":429},[14,38092,38093,38094,38096],{},"Now the enrichment. For each contact we ask the AI model to read the company name and notes, then return a structured answer: a guessed industry and a one-line summary. We force the model to reply as JSON (a strict text format programs can parse) using ",[18,38095,5745],{},", so we never have to guess at free-form text.",[253,38098,38100],{"className":414,"code":38099,"language":416,"meta":258,"style":258},"import json\n\n\ndef enrich_contact(company: str, notes: str) -> dict:\n    \"\"\"Ask the AI model to classify and summarize one contact.\"\"\"\n    prompt = (\n        f\"Company: {company or 'unknown'}\\n\"\n        f\"Notes: {notes or 'none'}\\n\\n\"\n        \"Return JSON with two keys: 'industry' (a short label like 'SaaS' or \"\n        \"'Retail') and 'summary' (one sentence, under 20 words).\"\n    )\n    response = ai.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\"role\": \"system\", \"content\": \"You label business contacts. Reply only with JSON.\"},\n            {\"role\": \"user\", \"content\": prompt},\n        ],\n        response_format={\"type\": \"json_object\"},\n        temperature=0,  # 0 = consistent, repeatable answers\n    )\n    return json.loads(response.choices[0].message.content)\n",[18,38101,38102,38108,38112,38116,38139,38144,38152,38173,38194,38199,38204,38208,38217,38227,38235,38256,38273,38277,38293,38306,38310],{"__ignoreMap":258},[262,38103,38104,38106],{"class":181,"line":264},[262,38105,684],{"class":377},[262,38107,5766],{"class":429},[262,38109,38110],{"class":181,"line":282},[262,38111,583],{"emptyLinePlaceholder":582},[262,38113,38114],{"class":181,"line":295},[262,38115,583],{"emptyLinePlaceholder":582},[262,38117,38118,38120,38123,38126,38128,38131,38133,38135,38137],{"class":181,"line":345},[262,38119,423],{"class":377},[262,38121,38122],{"class":267}," enrich_contact",[262,38124,38125],{"class":429},"(company: ",[262,38127,433],{"class":271},[262,38129,38130],{"class":429},", notes: ",[262,38132,433],{"class":271},[262,38134,1939],{"class":429},[262,38136,5869],{"class":271},[262,38138,1160],{"class":429},[262,38140,38141],{"class":181,"line":492},[262,38142,38143],{"class":275},"    \"\"\"Ask the AI model to classify and summarize one contact.\"\"\"\n",[262,38145,38146,38148,38150],{"class":181,"line":503},[262,38147,18006],{"class":429},[262,38149,476],{"class":377},[262,38151,984],{"class":429},[262,38153,38154,38156,38159,38161,38164,38166,38169,38171],{"class":181,"line":521},[262,38155,2840],{"class":377},[262,38157,38158],{"class":275},"\"Company: ",[262,38160,3039],{"class":271},[262,38162,38163],{"class":429},"company ",[262,38165,8923],{"class":377},[262,38167,38168],{"class":275}," 'unknown'",[262,38170,3044],{"class":271},[262,38172,1257],{"class":275},[262,38174,38175,38177,38180,38182,38185,38187,38190,38192],{"class":181,"line":537},[262,38176,2840],{"class":377},[262,38178,38179],{"class":275},"\"Notes: ",[262,38181,3039],{"class":271},[262,38183,38184],{"class":429},"notes ",[262,38186,8923],{"class":377},[262,38188,38189],{"class":275}," 'none'",[262,38191,4644],{"class":271},[262,38193,1257],{"class":275},[262,38195,38196],{"class":181,"line":549},[262,38197,38198],{"class":275},"        \"Return JSON with two keys: 'industry' (a short label like 'SaaS' or \"\n",[262,38200,38201],{"class":181,"line":570},[262,38202,38203],{"class":275},"        \"'Retail') and 'summary' (one sentence, under 20 words).\"\n",[262,38205,38206],{"class":181,"line":579},[262,38207,1011],{"class":429},[262,38209,38210,38212,38214],{"class":181,"line":586},[262,38211,1184],{"class":429},[262,38213,476],{"class":377},[262,38215,38216],{"class":429}," ai.chat.completions.create(\n",[262,38218,38219,38221,38223,38225],{"class":181,"line":591},[262,38220,1194],{"class":611},[262,38222,476],{"class":377},[262,38224,1207],{"class":275},[262,38226,1315],{"class":429},[262,38228,38229,38231,38233],{"class":181,"line":623},[262,38230,1215],{"class":611},[262,38232,476],{"class":377},[262,38234,1220],{"class":429},[262,38236,38237,38239,38241,38243,38245,38247,38249,38251,38254],{"class":181,"line":634},[262,38238,1225],{"class":429},[262,38240,1228],{"class":275},[262,38242,1231],{"class":429},[262,38244,1234],{"class":275},[262,38246,608],{"class":429},[262,38248,1239],{"class":275},[262,38250,1231],{"class":429},[262,38252,38253],{"class":275},"\"You label business contacts. Reply only with JSON.\"",[262,38255,3143],{"class":429},[262,38257,38258,38260,38262,38264,38266,38268,38270],{"class":181,"line":845},[262,38259,1225],{"class":429},[262,38261,1228],{"class":275},[262,38263,1231],{"class":429},[262,38265,1291],{"class":275},[262,38267,608],{"class":429},[262,38269,1239],{"class":275},[262,38271,38272],{"class":429},": prompt},\n",[262,38274,38275],{"class":181,"line":850},[262,38276,1303],{"class":429},[262,38278,38279,38281,38283,38285,38287,38289,38291],{"class":181,"line":864},[262,38280,6018],{"class":611},[262,38282,476],{"class":377},[262,38284,3039],{"class":429},[262,38286,6025],{"class":275},[262,38288,1231],{"class":429},[262,38290,6030],{"class":275},[262,38292,3143],{"class":429},[262,38294,38295,38297,38299,38301,38303],{"class":181,"line":1683},[262,38296,1308],{"class":611},[262,38298,476],{"class":377},[262,38300,102],{"class":271},[262,38302,13488],{"class":429},[262,38304,38305],{"class":291},"# 0 = consistent, repeatable answers\n",[262,38307,38308],{"class":181,"line":1688},[262,38309,1011],{"class":429},[262,38311,38312,38314,38316,38318],{"class":181,"line":1693},[262,38313,573],{"class":377},[262,38315,6043],{"class":429},[262,38317,102],{"class":271},[262,38319,6048],{"class":429},[14,38321,22732,38322,38324,38325,38327,38328,38330,38331,38333,38334,38336],{},[18,38323,2703],{}," here because classification and short summaries do not need an expensive model, and ",[18,38326,1357],{}," makes the output stable so the same contact always gets the same label. Temperature controls how much randomness the model adds to its wording: at ",[18,38329,102],{}," it picks the most likely answer every time, which is exactly what you want for data fields that should be consistent across a report. The ",[18,38332,4466],{}," message sets the model's role and reminds it to reply with JSON only, while the ",[18,38335,4470],{}," message carries the specific contact. Keeping these two messages separate is a small prompt-engineering habit that makes the model's behavior far more predictable than cramming everything into one block of text.",[14,38338,38339,38340,38342,38343,38345,38346,38348],{},"Why force JSON at all? Because the alternative is parsing free-form sentences, which breaks the moment the model decides to be chatty. By setting ",[18,38341,6878],{}," and asking for named keys, you get back something Python can read with a single ",[18,38344,20396],{}," call, every time. If you want to score leads by buying intent or extract more fields, ",[51,38347,35139],{"href":37077}," builds directly on this step.",[57,38350,38352],{"id":38351},"step-4-write-the-enriched-data-back-to-the-crm","Step 4 — Write the enriched data back to the CRM",[14,38354,38355,38356,1374,38359,38362,38363,38366],{},"Enrichment is only useful if your team can see it inside the CRM. We send the AI's ",[18,38357,38358],{},"industry",[18,38360,38361],{},"summary"," back to custom properties on each contact with a ",[18,38364,38365],{},"PATCH"," request — the HTTP verb that means \"update part of an existing record.\" Make sure those custom properties exist in your CRM first, or the write will be rejected.",[253,38368,38370],{"className":414,"code":38369,"language":416,"meta":258,"style":258},"def write_back(contact_id: str, enrichment: dict) -> None:\n    \"\"\"Update one contact with the AI-generated fields.\"\"\"\n    payload = {\n        \"properties\": {\n            \"ai_industry\": enrichment.get(\"industry\", \"\"),\n            \"ai_summary\": enrichment.get(\"summary\", \"\"),\n        }\n    }\n    response = crm.patch(f\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002F{contact_id}\", json=payload)\n    response.raise_for_status()\n",[18,38371,38372,38392,38397,38405,38411,38426,38440,38444,38448,38478],{"__ignoreMap":258},[262,38373,38374,38376,38378,38380,38382,38384,38386,38388,38390],{"class":181,"line":264},[262,38375,423],{"class":377},[262,38377,36154],{"class":267},[262,38379,36157],{"class":429},[262,38381,433],{"class":271},[262,38383,36162],{"class":429},[262,38385,5869],{"class":271},[262,38387,1939],{"class":429},[262,38389,8471],{"class":271},[262,38391,1160],{"class":429},[262,38393,38394],{"class":181,"line":282},[262,38395,38396],{"class":275},"    \"\"\"Update one contact with the AI-generated fields.\"\"\"\n",[262,38398,38399,38401,38403],{"class":181,"line":295},[262,38400,16972],{"class":429},[262,38402,476],{"class":377},[262,38404,20437],{"class":429},[262,38406,38407,38409],{"class":181,"line":345},[262,38408,36223],{"class":275},[262,38410,35273],{"class":429},[262,38412,38413,38415,38418,38420,38422,38424],{"class":181,"line":492},[262,38414,36230],{"class":275},[262,38416,38417],{"class":429},": enrichment.get(",[262,38419,35497],{"class":275},[262,38421,608],{"class":429},[262,38423,9175],{"class":275},[262,38425,1210],{"class":429},[262,38427,38428,38430,38432,38434,38436,38438],{"class":181,"line":503},[262,38429,36264],{"class":275},[262,38431,38417],{"class":429},[262,38433,35511],{"class":275},[262,38435,608],{"class":429},[262,38437,9175],{"class":275},[262,38439,1210],{"class":429},[262,38441,38442],{"class":181,"line":521},[262,38443,36275],{"class":429},[262,38445,38446],{"class":181,"line":537},[262,38447,36280],{"class":429},[262,38449,38450,38452,38454,38457,38459,38462,38464,38466,38468,38470,38472,38474,38476],{"class":181,"line":549},[262,38451,1184],{"class":429},[262,38453,476],{"class":377},[262,38455,38456],{"class":429}," crm.patch(",[262,38458,642],{"class":377},[262,38460,38461],{"class":275},"\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002F",[262,38463,3039],{"class":271},[262,38465,36206],{"class":429},[262,38467,654],{"class":271},[262,38469,1176],{"class":275},[262,38471,608],{"class":429},[262,38473,17049],{"class":611},[262,38475,476],{"class":377},[262,38477,19510],{"class":429},[262,38479,38480],{"class":181,"line":570},[262,38481,6703],{"class":429},[14,38483,38484,38485,38488,38489,38491],{},"That is the full loop. A couple of production notes before you run it at scale. First, write-backs are the one step that changes your live data, so test against a handful of contacts before turning it loose on your whole database — a ",[18,38486,38487],{},"df.head(5)"," while you experiment is cheap insurance. Second, ",[18,38490,38365],{}," updates only the fields you name, leaving everything else on the contact untouched; that is exactly the behavior you want, because you are adding intelligence, not overwriting your sales team's work. The next section assembles these four functions into one script you can run today.",[57,38493,8300],{"id":8299},[14,38495,38496],{},"These are the settings you will most often adjust as you adapt the pipeline.",[1379,38498,38499,38511],{},[1382,38500,38501],{},[1385,38502,38503,38505,38507,38509],{},[1388,38504,1390],{},[1388,38506,3795],{},[1388,38508,3798],{},[1388,38510,1396],{},[1398,38512,38513,38529,38544,38565,38580,38595],{},[1385,38514,38515,38520,38522,38526],{},[1403,38516,38517],{},[18,38518,38519],{},"page_size",[1403,38521,439],{},[1403,38523,38524],{},[18,38525,113],{},[1403,38527,38528],{},"How many contacts each CRM page returns. Lower it if you hit memory or rate limits.",[1385,38530,38531,38535,38537,38541],{},[1403,38532,38533],{},[18,38534,805],{},[1403,38536,433],{},[1403,38538,38539],{},[18,38540,1207],{},[1403,38542,38543],{},"Which AI model enriches each contact. Bigger models cost more but reason better.",[1385,38545,38546,38550,38552,38556],{},[1403,38547,38548],{},[18,38549,3829],{},[1403,38551,3832],{},[1403,38553,38554],{},[18,38555,102],{},[1403,38557,38558,38559,38561,38562,38564],{},"Randomness of AI output. ",[18,38560,102],{}," gives repeatable labels; raise toward ",[18,38563,997],{}," for varied wording.",[1385,38566,38567,38571,38573,38577],{},[1403,38568,38569],{},[18,38570,5745],{},[1403,38572,5869],{},[1403,38574,38575],{},[18,38576,6841],{},[1403,38578,38579],{},"Forces the model to return parseable JSON instead of free text.",[1385,38581,38582,38586,38588,38592],{},[1403,38583,38584],{},[18,38585,1591],{},[1403,38587,3832],{},[1403,38589,38590],{},[18,38591,6692],{},[1403,38593,38594],{},"Seconds httpx waits before giving up on a slow CRM response.",[1385,38596,38597,38601,38603,38608],{},[1403,38598,38599],{},[18,38600,37791],{},[1403,38602,433],{},[1403,38604,38605],{},[18,38606,38607],{},"\"email,phone,...\"",[1403,38609,38610],{},"Comma-separated CRM fields to fetch. Request only what you need to keep payloads small.",[57,38612,1445],{"id":1444},[1447,38614,38615,38635,38652,38664,38679,38694],{},[1450,38616,38617,8504,38621,38623,38624,38626,38627,38629,38630,38632,38633,1363],{},[35,38618,38619],{},[18,38620,28794],{},[18,38622,319],{}," file was not found or the key name is misspelled. Cause: the script runs from a different folder than the ",[18,38625,319],{}," file, or ",[18,38628,8439],{}," runs after you read the variable. Fix: call ",[18,38631,8439],{}," at the very top and run the script from the folder containing ",[18,38634,319],{},[1450,38636,38637,38641,38642,38645,38646,38649,38650,1363],{},[35,38638,38639],{},[18,38640,17621],{}," — The CRM rejected your token. Cause: an expired, revoked, or wrong token, or a missing ",[18,38643,38644],{},"Bearer "," prefix. Fix: regenerate the token in your CRM's private-app settings and confirm the header reads ",[18,38647,38648],{},"Authorization: Bearer \u003Ctoken>",". The same logic for AI keys is covered in ",[51,38651,388],{"href":387},[1450,38653,38654,38658,38659,38661,38662,1363],{},[35,38655,38656],{},[18,38657,21779],{}," — You sent requests faster than the API allows. Cause: looping with no pause between calls. Fix: add a short ",[18,38660,28816],{}," between AI calls and wrap network calls in retry-with-backoff. See ",[51,38663,3379],{"href":3378},[1450,38665,38666,38670,38671,38673,38674,38676,38677,1363],{},[35,38667,38668],{},[18,38669,19631],{}," — The AI reply was not valid JSON. Cause: ",[18,38672,5745],{}," was omitted, so the model wrapped its answer in prose. Fix: keep ",[18,38675,6878],{}," and instruct the model to reply with JSON only. See ",[51,38678,6114],{"href":6113},[1450,38680,38681,38687,38688,407,38690,38693],{},[35,38682,38683,38686],{},[18,38684,38685],{},"400 Bad Request"," on write-back"," — The CRM rejected the update. Cause: the custom property (",[18,38689,36352],{},[18,38691,38692],{},"ai_summary",") does not exist yet. Fix: create the properties in your CRM's settings before running the write step, matching the exact internal names.",[1450,38695,38696,38699,38700,1363],{},[35,38697,38698],{},"AI cost or context errors on long notes"," — A very long notes field can blow past the model's input limit or run up your bill. Cause: sending entire call transcripts unfiltered. Fix: truncate notes to the first few hundred characters, or summarize them first. See ",[51,38701,1513],{"href":1512},[57,38703,38705],{"id":38704},"full-worked-example","Full worked example",[14,38707,27834,38708,38711,38712,38714,38715,1374,38717,38719,38720,38723],{},[18,38709,38710],{},"crm_ai_sync.py",", fill in your ",[18,38713,319],{},", create the ",[18,38716,36352],{},[18,38718,38692],{}," custom properties in your CRM, then run ",[18,38721,38722],{},"python crm_ai_sync.py",". It fetches, cleans, enriches, and writes back, with retries and a pause to stay under rate limits.",[253,38725,38727],{"className":414,"code":38726,"language":416,"meta":258,"style":258},"import os\nimport json\nimport time\nimport httpx\nimport pandas as pd\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # load secrets; remember .env must be in .gitignore\n\nai = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\ncrm = httpx.Client(\n    base_url=os.environ[\"CRM_BASE_URL\"],\n    headers={\"Authorization\": f\"Bearer {os.environ['CRM_API_TOKEN']}\"},\n    timeout=30.0,\n)\n\n\ndef fetch_contacts(page_size: int = 100) -> list[dict]:\n    contacts, after = [], None\n    while True:\n        params = {\"limit\": page_size, \"properties\": \"email,phone,company,notes_last_contacted\"}\n        if after:\n            params[\"after\"] = after\n        resp = crm.get(\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\", params=params)\n        resp.raise_for_status()\n        data = resp.json()\n        contacts.extend(data.get(\"results\", []))\n        after = data.get(\"paging\", {}).get(\"next\", {}).get(\"after\")\n        if not after:\n            return contacts\n\n\ndef clean_contacts(raw: list[dict]) -> pd.DataFrame:\n    rows = [{\n        \"id\": c[\"id\"],\n        \"email\": (c[\"properties\"].get(\"email\") or \"\").lower().strip(),\n        \"company\": (c[\"properties\"].get(\"company\") or \"\").strip().title(),\n        \"notes\": (c[\"properties\"].get(\"notes_last_contacted\") or \"\").strip()[:400],\n    } for c in raw]\n    df = pd.DataFrame(rows)\n    df = df[df[\"email\"] != \"\"]\n    return df.drop_duplicates(subset=[\"email\"]).reset_index(drop=True)\n\n\ndef enrich_contact(company: str, notes: str, retries: int = 3) -> dict:\n    prompt = (f\"Company: {company or 'unknown'}\\nNotes: {notes or 'none'}\\n\\n\"\n              \"Return JSON with 'industry' (short label) and 'summary' (one sentence under 20 words).\")\n    for attempt in range(retries):\n        try:\n            resp = ai.chat.completions.create(\n                model=\"gpt-4o-mini\",\n                messages=[{\"role\": \"system\", \"content\": \"You label business contacts. Reply only with JSON.\"},\n                          {\"role\": \"user\", \"content\": prompt}],\n                response_format={\"type\": \"json_object\"},\n                temperature=0,\n            )\n            return json.loads(resp.choices[0].message.content)\n        except Exception as err:  # back off and retry on transient failures\n            if attempt == retries - 1:\n                raise\n            time.sleep(2 ** attempt)\n\n\ndef write_back(contact_id: str, enrichment: dict) -> None:\n    payload = {\"properties\": {\"ai_industry\": enrichment.get(\"industry\", \"\"),\n                              \"ai_summary\": enrichment.get(\"summary\", \"\")}}\n    crm.patch(f\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002F{contact_id}\", json=payload).raise_for_status()\n\n\nif __name__ == \"__main__\":\n    df = clean_contacts(fetch_contacts())\n    print(f\"Enriching {len(df)} contacts...\")\n    for row in df.itertuples():\n        enrichment = enrich_contact(row.company, row.notes)\n        write_back(row.id, enrichment)\n        time.sleep(0.5)  # gentle pause to respect rate limits\n    print(\"Done. Check the ai_industry and ai_summary fields in your CRM.\")\n",[18,38728,38729,38735,38741,38747,38753,38763,38773,38783,38787,38794,38798,38816,38824,38836,38867,38877,38881,38885,38889,38909,38920,38928,38949,38955,38967,38985,38989,38997,39005,39025,39033,39039,39043,39047,39059,39068,39078,39098,39119,39145,39159,39167,39185,39209,39213,39217,39246,39283,39290,39303,39309,39317,39327,39351,39368,39384,39394,39398,39408,39422,39439,39444,39454,39458,39462,39482,39507,39523,39549,39553,39557,39569,39578,39600,39611,39620,39625,39636],{"__ignoreMap":258},[262,38730,38731,38733],{"class":181,"line":264},[262,38732,684],{"class":377},[262,38734,687],{"class":429},[262,38736,38737,38739],{"class":181,"line":282},[262,38738,684],{"class":377},[262,38740,5766],{"class":429},[262,38742,38743,38745],{"class":181,"line":295},[262,38744,684],{"class":377},[262,38746,2612],{"class":429},[262,38748,38749,38751],{"class":181,"line":345},[262,38750,684],{"class":377},[262,38752,6526],{"class":429},[262,38754,38755,38757,38759,38761],{"class":181,"line":492},[262,38756,684],{"class":377},[262,38758,2619],{"class":429},[262,38760,697],{"class":377},[262,38762,2624],{"class":429},[262,38764,38765,38767,38769,38771],{"class":181,"line":503},[262,38766,705],{"class":377},[262,38768,708],{"class":429},[262,38770,684],{"class":377},[262,38772,713],{"class":429},[262,38774,38775,38777,38779,38781],{"class":181,"line":521},[262,38776,705],{"class":377},[262,38778,720],{"class":429},[262,38780,684],{"class":377},[262,38782,725],{"class":429},[262,38784,38785],{"class":181,"line":537},[262,38786,583],{"emptyLinePlaceholder":582},[262,38788,38789,38791],{"class":181,"line":549},[262,38790,4222],{"class":429},[262,38792,38793],{"class":291},"# load secrets; remember .env must be in .gitignore\n",[262,38795,38796],{"class":181,"line":570},[262,38797,583],{"emptyLinePlaceholder":582},[262,38799,38800,38802,38804,38806,38808,38810,38812,38814],{"class":181,"line":579},[262,38801,37422],{"class":429},[262,38803,476],{"class":377},[262,38805,1588],{"class":429},[262,38807,2674],{"class":611},[262,38809,476],{"class":377},[262,38811,26942],{"class":429},[262,38813,2681],{"class":275},[262,38815,3512],{"class":429},[262,38817,38818,38820,38822],{"class":181,"line":586},[262,38819,37448],{"class":429},[262,38821,476],{"class":377},[262,38823,37453],{"class":429},[262,38825,38826,38828,38830,38832,38834],{"class":181,"line":591},[262,38827,37458],{"class":611},[262,38829,476],{"class":377},[262,38831,26942],{"class":429},[262,38833,37406],{"class":275},[262,38835,10309],{"class":429},[262,38837,38838,38840,38842,38844,38846,38848,38850,38852,38854,38856,38859,38861,38863,38865],{"class":181,"line":623},[262,38839,37469],{"class":611},[262,38841,476],{"class":377},[262,38843,3039],{"class":429},[262,38845,16998],{"class":275},[262,38847,1231],{"class":429},[262,38849,642],{"class":377},[262,38851,6605],{"class":275},[262,38853,3039],{"class":271},[262,38855,26942],{"class":429},[262,38857,38858],{"class":275},"'CRM_API_TOKEN'",[262,38860,6223],{"class":429},[262,38862,654],{"class":271},[262,38864,1176],{"class":275},[262,38866,3143],{"class":429},[262,38868,38869,38871,38873,38875],{"class":181,"line":634},[262,38870,37493],{"class":611},[262,38872,476],{"class":377},[262,38874,6692],{"class":271},[262,38876,1315],{"class":429},[262,38878,38879],{"class":181,"line":845},[262,38880,660],{"class":429},[262,38882,38883],{"class":181,"line":850},[262,38884,583],{"emptyLinePlaceholder":582},[262,38886,38887],{"class":181,"line":864},[262,38888,583],{"emptyLinePlaceholder":582},[262,38890,38891,38893,38895,38897,38899,38901,38903,38905,38907],{"class":181,"line":1683},[262,38892,423],{"class":377},[262,38894,37549],{"class":267},[262,38896,37552],{"class":429},[262,38898,439],{"class":271},[262,38900,442],{"class":377},[262,38902,37559],{"class":271},[262,38904,458],{"class":429},[262,38906,5869],{"class":271},[262,38908,463],{"class":429},[262,38910,38911,38914,38916,38918],{"class":181,"line":1688},[262,38912,38913],{"class":429},"    contacts, after ",[262,38915,476],{"class":377},[262,38917,1745],{"class":429},[262,38919,19421],{"class":271},[262,38921,38922,38924,38926],{"class":181,"line":1693},[262,38923,506],{"class":377},[262,38925,2241],{"class":271},[262,38927,1160],{"class":429},[262,38929,38930,38932,38934,38936,38938,38941,38943,38945,38947],{"class":181,"line":1728},[262,38931,37616],{"class":429},[262,38933,476],{"class":377},[262,38935,2276],{"class":429},[262,38937,20448],{"class":275},[262,38939,38940],{"class":429},": page_size, ",[262,38942,37889],{"class":275},[262,38944,1231],{"class":429},[262,38946,37638],{"class":275},[262,38948,16430],{"class":429},[262,38950,38951,38953],{"class":181,"line":1737},[262,38952,2268],{"class":377},[262,38954,37651],{"class":429},[262,38956,38957,38959,38961,38963,38965],{"class":181,"line":1751},[262,38958,37656],{"class":429},[262,38960,37659],{"class":275},[262,38962,2903],{"class":429},[262,38964,476],{"class":377},[262,38966,37666],{"class":429},[262,38968,38969,38971,38973,38975,38977,38979,38981,38983],{"class":181,"line":1764},[262,38970,17037],{"class":429},[262,38972,476],{"class":377},[262,38974,37679],{"class":429},[262,38976,37682],{"class":275},[262,38978,608],{"class":429},[262,38980,37687],{"class":611},[262,38982,476],{"class":377},[262,38984,37692],{"class":429},[262,38986,38987],{"class":181,"line":1779},[262,38988,17067],{"class":429},[262,38990,38991,38993,38995],{"class":181,"line":1793},[262,38992,37705],{"class":429},[262,38994,476],{"class":377},[262,38996,23901],{"class":429},[262,38998,38999,39001,39003],{"class":181,"line":1800},[262,39000,37718],{"class":429},[262,39002,34288],{"class":275},[262,39004,37723],{"class":429},[262,39006,39007,39009,39011,39013,39015,39017,39019,39021,39023],{"class":181,"line":1805},[262,39008,37737],{"class":429},[262,39010,476],{"class":377},[262,39012,37742],{"class":429},[262,39014,37745],{"class":275},[262,39016,37748],{"class":429},[262,39018,37751],{"class":275},[262,39020,37748],{"class":429},[262,39022,37659],{"class":275},[262,39024,660],{"class":429},[262,39026,39027,39029,39031],{"class":181,"line":1810},[262,39028,2268],{"class":377},[262,39030,2818],{"class":377},[262,39032,37651],{"class":429},[262,39034,39035,39037],{"class":181,"line":1823},[262,39036,3198],{"class":377},[262,39038,37780],{"class":429},[262,39040,39041],{"class":181,"line":1846},[262,39042,583],{"emptyLinePlaceholder":582},[262,39044,39045],{"class":181,"line":1861},[262,39046,583],{"emptyLinePlaceholder":582},[262,39048,39049,39051,39053,39055,39057],{"class":181,"line":1866},[262,39050,423],{"class":377},[262,39052,37841],{"class":267},[262,39054,37844],{"class":429},[262,39056,5869],{"class":271},[262,39058,33220],{"class":429},[262,39060,39061,39063,39065],{"class":181,"line":1871},[262,39062,25637],{"class":429},[262,39064,476],{"class":377},[262,39066,39067],{"class":429}," [{\n",[262,39069,39070,39072,39074,39076],{"class":181,"line":1890},[262,39071,36444],{"class":275},[262,39073,37874],{"class":429},[262,39075,6770],{"class":275},[262,39077,10309],{"class":429},[262,39079,39080,39082,39084,39086,39088,39090,39092,39094,39096],{"class":181,"line":1909},[262,39081,36468],{"class":275},[262,39083,37886],{"class":429},[262,39085,37889],{"class":275},[262,39087,37892],{"class":429},[262,39089,37895],{"class":275},[262,39091,1000],{"class":429},[262,39093,8923],{"class":377},[262,39095,6332],{"class":275},[262,39097,37904],{"class":429},[262,39099,39100,39103,39105,39107,39109,39111,39113,39115,39117],{"class":181,"line":1914},[262,39101,39102],{"class":275},"        \"company\"",[262,39104,37886],{"class":429},[262,39106,37889],{"class":275},[262,39108,37892],{"class":429},[262,39110,37940],{"class":275},[262,39112,1000],{"class":429},[262,39114,8923],{"class":377},[262,39116,6332],{"class":275},[262,39118,37949],{"class":429},[262,39120,39121,39124,39126,39128,39130,39132,39134,39136,39138,39141,39143],{"class":181,"line":1919},[262,39122,39123],{"class":275},"        \"notes\"",[262,39125,37886],{"class":429},[262,39127,37889],{"class":275},[262,39129,37892],{"class":429},[262,39131,37963],{"class":275},[262,39133,1000],{"class":429},[262,39135,8923],{"class":377},[262,39137,6332],{"class":275},[262,39139,39140],{"class":429},").strip()[:",[262,39142,178],{"class":271},[262,39144,10309],{"class":429},[262,39146,39147,39150,39152,39154,39156],{"class":181,"line":1946},[262,39148,39149],{"class":429},"    } ",[262,39151,829],{"class":377},[262,39153,37983],{"class":429},[262,39155,835],{"class":377},[262,39157,39158],{"class":429}," raw]\n",[262,39160,39161,39163,39165],{"class":181,"line":1959},[262,39162,26737],{"class":429},[262,39164,476],{"class":377},[262,39166,30783],{"class":429},[262,39168,39169,39171,39173,39175,39177,39179,39181,39183],{"class":181,"line":1996},[262,39170,26737],{"class":429},[262,39172,476],{"class":377},[262,39174,29140],{"class":429},[262,39176,37895],{"class":275},[262,39178,2903],{"class":429},[262,39180,23215],{"class":377},[262,39182,6332],{"class":275},[262,39184,957],{"class":429},[262,39186,39187,39189,39191,39193,39195,39197,39199,39201,39203,39205,39207],{"class":181,"line":2012},[262,39188,573],{"class":377},[262,39190,30627],{"class":429},[262,39192,27491],{"class":611},[262,39194,476],{"class":377},[262,39196,12118],{"class":429},[262,39198,37895],{"class":275},[262,39200,29966],{"class":429},[262,39202,26854],{"class":611},[262,39204,476],{"class":377},[262,39206,4974],{"class":271},[262,39208,660],{"class":429},[262,39210,39211],{"class":181,"line":2040},[262,39212,583],{"emptyLinePlaceholder":582},[262,39214,39215],{"class":181,"line":2045},[262,39216,583],{"emptyLinePlaceholder":582},[262,39218,39219,39221,39223,39225,39227,39229,39231,39234,39236,39238,39240,39242,39244],{"class":181,"line":2050},[262,39220,423],{"class":377},[262,39222,38122],{"class":267},[262,39224,38125],{"class":429},[262,39226,433],{"class":271},[262,39228,38130],{"class":429},[262,39230,433],{"class":271},[262,39232,39233],{"class":429},", retries: ",[262,39235,439],{"class":271},[262,39237,442],{"class":377},[262,39239,931],{"class":271},[262,39241,1939],{"class":429},[262,39243,5869],{"class":271},[262,39245,1160],{"class":429},[262,39247,39248,39250,39252,39254,39256,39258,39260,39262,39264,39266,39268,39271,39273,39275,39277,39279,39281],{"class":181,"line":2067},[262,39249,18006],{"class":429},[262,39251,476],{"class":377},[262,39253,13751],{"class":429},[262,39255,642],{"class":377},[262,39257,38158],{"class":275},[262,39259,3039],{"class":271},[262,39261,38163],{"class":429},[262,39263,8923],{"class":377},[262,39265,38168],{"class":275},[262,39267,3044],{"class":271},[262,39269,39270],{"class":275},"Notes: ",[262,39272,3039],{"class":271},[262,39274,38184],{"class":429},[262,39276,8923],{"class":377},[262,39278,38189],{"class":275},[262,39280,4644],{"class":271},[262,39282,1257],{"class":275},[262,39284,39285,39288],{"class":181,"line":2077},[262,39286,39287],{"class":275},"              \"Return JSON with 'industry' (short label) and 'summary' (one sentence under 20 words).\"",[262,39289,660],{"class":429},[262,39291,39292,39294,39296,39298,39300],{"class":181,"line":2086},[262,39293,3074],{"class":377},[262,39295,3077],{"class":429},[262,39297,835],{"class":377},[262,39299,3082],{"class":271},[262,39301,39302],{"class":429},"(retries):\n",[262,39304,39305,39307],{"class":181,"line":2097},[262,39306,3090],{"class":377},[262,39308,1160],{"class":429},[262,39310,39311,39313,39315],{"class":181,"line":2106},[262,39312,33306],{"class":429},[262,39314,476],{"class":377},[262,39316,38216],{"class":429},[262,39318,39319,39321,39323,39325],{"class":181,"line":2126},[262,39320,3106],{"class":611},[262,39322,476],{"class":377},[262,39324,1207],{"class":275},[262,39326,1315],{"class":429},[262,39328,39329,39331,39333,39335,39337,39339,39341,39343,39345,39347,39349],{"class":181,"line":2148},[262,39330,3117],{"class":611},[262,39332,476],{"class":377},[262,39334,8856],{"class":429},[262,39336,1228],{"class":275},[262,39338,1231],{"class":429},[262,39340,1234],{"class":275},[262,39342,608],{"class":429},[262,39344,1239],{"class":275},[262,39346,1231],{"class":429},[262,39348,38253],{"class":275},[262,39350,3143],{"class":429},[262,39352,39353,39356,39358,39360,39362,39364,39366],{"class":181,"line":2165},[262,39354,39355],{"class":429},"                          {",[262,39357,1228],{"class":275},[262,39359,1231],{"class":429},[262,39361,1291],{"class":275},[262,39363,608],{"class":429},[262,39365,1239],{"class":275},[262,39367,18141],{"class":429},[262,39369,39370,39372,39374,39376,39378,39380,39382],{"class":181,"line":2170},[262,39371,9738],{"class":611},[262,39373,476],{"class":377},[262,39375,3039],{"class":429},[262,39377,6025],{"class":275},[262,39379,1231],{"class":429},[262,39381,6030],{"class":275},[262,39383,3143],{"class":429},[262,39385,39386,39388,39390,39392],{"class":181,"line":2181},[262,39387,3170],{"class":611},[262,39389,476],{"class":377},[262,39391,102],{"class":271},[262,39393,1315],{"class":429},[262,39395,39396],{"class":181,"line":2186},[262,39397,3193],{"class":429},[262,39399,39400,39402,39404,39406],{"class":181,"line":2197},[262,39401,3198],{"class":377},[262,39403,34271],{"class":429},[262,39405,102],{"class":271},[262,39407,6048],{"class":429},[262,39409,39410,39412,39414,39416,39419],{"class":181,"line":2202},[262,39411,3214],{"class":377},[262,39413,10361],{"class":271},[262,39415,10364],{"class":377},[262,39417,39418],{"class":429}," err:  ",[262,39420,39421],{"class":291},"# back off and retry on transient failures\n",[262,39423,39424,39426,39428,39430,39433,39435,39437],{"class":181,"line":2207},[262,39425,10200],{"class":377},[262,39427,3077],{"class":429},[262,39429,10758],{"class":377},[262,39431,39432],{"class":429}," retries ",[262,39434,561],{"class":377},[262,39436,3243],{"class":271},[262,39438,1160],{"class":429},[262,39440,39441],{"class":181,"line":2224},[262,39442,39443],{"class":377},"                raise\n",[262,39445,39446,39448,39450,39452],{"class":181,"line":2236},[262,39447,9913],{"class":429},[262,39449,109],{"class":271},[262,39451,3235],{"class":377},[262,39453,9920],{"class":429},[262,39455,39456],{"class":181,"line":2246},[262,39457,583],{"emptyLinePlaceholder":582},[262,39459,39460],{"class":181,"line":2265},[262,39461,583],{"emptyLinePlaceholder":582},[262,39463,39464,39466,39468,39470,39472,39474,39476,39478,39480],{"class":181,"line":2290},[262,39465,423],{"class":377},[262,39467,36154],{"class":267},[262,39469,36157],{"class":429},[262,39471,433],{"class":271},[262,39473,36162],{"class":429},[262,39475,5869],{"class":271},[262,39477,1939],{"class":429},[262,39479,8471],{"class":271},[262,39481,1160],{"class":429},[262,39483,39484,39486,39488,39490,39492,39494,39497,39499,39501,39503,39505],{"class":181,"line":2296},[262,39485,16972],{"class":429},[262,39487,476],{"class":377},[262,39489,2276],{"class":429},[262,39491,37889],{"class":275},[262,39493,20445],{"class":429},[262,39495,39496],{"class":275},"\"ai_industry\"",[262,39498,38417],{"class":429},[262,39500,35497],{"class":275},[262,39502,608],{"class":429},[262,39504,9175],{"class":275},[262,39506,1210],{"class":429},[262,39508,39509,39512,39514,39516,39518,39520],{"class":181,"line":9230},[262,39510,39511],{"class":275},"                              \"ai_summary\"",[262,39513,38417],{"class":429},[262,39515,35511],{"class":275},[262,39517,608],{"class":429},[262,39519,9175],{"class":275},[262,39521,39522],{"class":429},")}}\n",[262,39524,39525,39528,39530,39532,39534,39536,39538,39540,39542,39544,39546],{"class":181,"line":9241},[262,39526,39527],{"class":429},"    crm.patch(",[262,39529,642],{"class":377},[262,39531,38461],{"class":275},[262,39533,3039],{"class":271},[262,39535,36206],{"class":429},[262,39537,654],{"class":271},[262,39539,1176],{"class":275},[262,39541,608],{"class":429},[262,39543,17049],{"class":611},[262,39545,476],{"class":377},[262,39547,39548],{"class":429},"payload).raise_for_status()\n",[262,39550,39551],{"class":181,"line":9247},[262,39552,583],{"emptyLinePlaceholder":582},[262,39554,39555],{"class":181,"line":28672},[262,39556,583],{"emptyLinePlaceholder":582},[262,39558,39559,39561,39563,39565,39567],{"class":181,"line":28683},[262,39560,2210],{"class":377},[262,39562,2213],{"class":271},[262,39564,2216],{"class":377},[262,39566,2219],{"class":275},[262,39568,1160],{"class":429},[262,39570,39571,39573,39575],{"class":181,"line":28710},[262,39572,26737],{"class":429},[262,39574,476],{"class":377},[262,39576,39577],{"class":429}," clean_contacts(fetch_contacts())\n",[262,39579,39580,39582,39584,39586,39589,39591,39593,39595,39598],{"class":181,"line":28715},[262,39581,1089],{"class":271},[262,39583,602],{"class":429},[262,39585,642],{"class":377},[262,39587,39588],{"class":275},"\"Enriching ",[262,39590,648],{"class":271},[262,39592,2780],{"class":429},[262,39594,654],{"class":271},[262,39596,39597],{"class":275}," contacts...\"",[262,39599,660],{"class":429},[262,39601,39602,39604,39606,39608],{"class":181,"line":28720},[262,39603,3074],{"class":377},[262,39605,10158],{"class":429},[262,39607,835],{"class":377},[262,39609,39610],{"class":429}," df.itertuples():\n",[262,39612,39613,39615,39617],{"class":181,"line":28733},[262,39614,36608],{"class":429},[262,39616,476],{"class":377},[262,39618,39619],{"class":429}," enrich_contact(row.company, row.notes)\n",[262,39621,39622],{"class":181,"line":28749},[262,39623,39624],{"class":429},"        write_back(row.id, enrichment)\n",[262,39626,39627,39629,39631,39633],{"class":181,"line":28759},[262,39628,9055],{"class":429},[262,39630,3884],{"class":271},[262,39632,32223],{"class":429},[262,39634,39635],{"class":291},"# gentle pause to respect rate limits\n",[262,39637,39639,39641,39643,39646],{"class":181,"line":39638},78,[262,39640,1089],{"class":271},[262,39642,602],{"class":429},[262,39644,39645],{"class":275},"\"Done. Check the ai_industry and ai_summary fields in your CRM.\"",[262,39647,660],{"class":429},[57,39649,2355],{"id":2354},[14,39651,39652],{},"You now have a working pipeline. From here, deepen one stage at a time:",[1447,39654,39655,39662,39669,39676],{},[1450,39656,39657,7918,39660,1363],{},[35,39658,39659],{},"Master one CRM's exact API",[51,39661,35211],{"href":35210},[1450,39663,39664,7918,39667,1363],{},[35,39665,39666],{},"Go beyond labels into lead scoring",[51,39668,35139],{"href":37077},[1450,39670,39671,7918,39674,1363],{},[35,39672,39673],{},"Turn recorded calls into CRM notes",[51,39675,36958],{"href":36957},[1450,39677,39678,39681,39682,14716,39684,7918,39687,1363],{},[35,39679,39680],{},"Put this behind a chat interface"," so your team can ask questions of the data with ",[51,39683,54],{"href":53},[35,39685,39686],{},"package it as a product",[51,39688,39690],{"href":39689},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002F","SaaS MVP with Python and AI",[14,39692,2375,39693,1363],{},[51,39694,26457],{"href":26456},[57,39696,2381],{"id":2380},[2322,39698,39699,39703,39707,39711,39715],{},[1450,39700,39701],{},[51,39702,35211],{"href":35210},[1450,39704,39705],{},[51,39706,35139],{"href":37077},[1450,39708,39709],{},[51,39710,36958],{"href":36957},[1450,39712,39713],{},[51,39714,54],{"href":53},[1450,39716,39717],{},[51,39718,39690],{"href":39689},[2401,39720,5337],{},{"title":258,"searchDepth":282,"depth":282,"links":39722},[39723,39724,39725,39726,39727,39728,39729,39730,39731,39732,39733],{"id":12746,"depth":282,"text":12747},{"id":237,"depth":282,"text":238},{"id":37299,"depth":282,"text":37300},{"id":37526,"depth":282,"text":37527},{"id":37800,"depth":282,"text":37801},{"id":38351,"depth":282,"text":38352},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":38704,"depth":282,"text":38705},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Connect your CRM to Python and AI: pull contacts, clean them, enrich each lead with an LLM, and write results back safely using the openai SDK and httpx.",[39736,39739,39742,39745,39748],{"q":39737,"a":39738},"Do I need to be a programmer to connect my CRM to Python?","No. If you can copy a code block, save a file, and run one command in your terminal, you can complete this guide. Every step is explained in plain language and the full script is provided at the end.",{"q":39740,"a":39741},"Which CRMs work with this approach?","Any CRM with a REST API works, including HubSpot, Pipedrive, Salesforce, and Zoho. This guide uses HubSpot-style endpoints as the example, but the pattern of fetch, clean, enrich, and write back is identical everywhere.",{"q":39743,"a":39744},"Is it safe to send customer data to an AI model?","Send only the fields you actually need, never passwords or payment details, and check your AI provider's data-retention policy. The OpenAI API does not train on data sent through the API by default, but you should still strip out anything sensitive before the call.",{"q":39746,"a":39747},"How do I avoid hitting rate limits when syncing thousands of contacts?","Fetch records in pages instead of all at once, add a short pause between AI calls, and wrap network calls in retry logic with exponential backoff. This guide shows the exact pattern.",{"q":39749,"a":39750},"Where do I store my API keys?","Put them in a .env file in your project folder and load them with python-dotenv. Always add .env to your .gitignore so your keys never get committed to version control.",{"name":39752,"steps":39753},"How to integrate your CRM with Python and AI",[39754,39757,39760,39763],{"name":39755,"text":39756},"Set up credentials and dependencies","Install the openai, httpx, and python-dotenv packages and store your CRM and AI keys in a .env file.",{"name":39758,"text":39759},"Pull contacts from the CRM","Call the CRM's REST API with httpx, paging through results so you never load everything into memory at once.",{"name":39761,"text":39762},"Clean and enrich each record with AI","Normalize the raw fields, then ask the AI model to classify, summarize, or score each contact.",{"name":39764,"text":39765},"Write the enriched data back to the CRM","Send the AI results back to custom fields on each contact with an authenticated PATCH request.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration","2026-05-06",{"title":37019,"description":39734},"CRM Data Integration with Python & AI","building-ai-powered-business-applications\u002Fcrm-data-integration\u002Findex","4apPjMIkGGV2_w1Tn_WBTFZTGF4AusALKDE0mkJi3_w",{"id":39774,"title":36958,"body":39775,"description":41113,"extension":2419,"faq":41114,"howto":41130,"meta":41145,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":41146,"published":2452,"seo":41147,"seoTitle":36958,"stem":41148,"__hash__":41149},"content\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsummarize-sales-calls-to-your-crm-with-python\u002Findex.md",{"type":7,"value":39776,"toc":41102},[39777,39780,39783,39789,39791,39803,39806,39822,39837,39842,39858,39865,39879,39893,39897,39909,40090,40102,40111,40114,40118,40124,40324,40344,40365,40369,40372,40571,40589,40593,40600,40817,40836,40839,40928,40930,40933,41000,41002,41056,41058,41080,41084,41086,41100],[10,39778,36958],{"id":39779},"summarize-sales-calls-to-your-crm-with-python",[14,39781,39782],{},"This guide shows you how to turn a raw sales-call transcript into a tidy CRM note in under fifteen minutes. You will feed the transcript to an AI model, get back a structured summary with key points, next steps, and a sentiment read, then log that summary as an activity on the right contact — all from one Python script. No more re-listening to recordings or typing up notes by hand after every call.",[14,39784,39785,39786,39788],{},"This is one of the practical workflows in ",[51,39787,36938],{"href":36937},". It reuses the same fetch-clean-enrich-write-back shape from the main guide, but the \"enrich\" stage here reads a conversation instead of a contact record, and the \"write back\" stage creates a note rather than updating a field.",[57,39790,238],{"id":237},[14,39792,39793,39794,39796,39797,39799,39800,39802],{},"You need Python 3.10 or newer. Check with ",[18,39795,17782],{},". If Python is new to you, work through ",[51,39798,5423],{"href":5422}," first, ideally inside a ",[51,39801,37194],{"href":2481}," so this project's packages stay isolated.",[14,39804,39805],{},"Install the packages this guide uses:",[253,39807,39808],{"className":255,"code":5427,"language":257,"meta":258,"style":258},[18,39809,39810],{"__ignoreMap":258},[262,39811,39812,39814,39816,39818,39820],{"class":181,"line":264},[262,39813,298],{"class":267},[262,39815,301],{"class":275},[262,39817,2519],{"class":275},[262,39819,5440],{"class":275},[262,39821,2522],{"class":275},[2322,39823,39824,39828,39833],{},[1450,39825,39826,37225],{},[18,39827,20],{},[1450,39829,39830,39832],{},[18,39831,5450],{}," is a modern HTTP client we use to talk to the CRM's API (the doorway a program uses to talk to a service).",[1450,39834,39835,37235],{},[18,39836,2501],{},[14,39838,2525,39839,39841],{},[18,39840,319],{}," in your project folder for your credentials. A credential is just a secret your code uses to prove it is allowed to access a service.",[253,39843,39844],{"className":323,"code":37249,"language":325,"meta":258,"style":258},[18,39845,39846,39850,39854],{"__ignoreMap":258},[262,39847,39848],{"class":181,"line":264},[262,39849,5469],{},[262,39851,39852],{"class":181,"line":282},[262,39853,37260],{},[262,39855,39856],{"class":181,"line":295},[262,39857,37265],{},[14,39859,353,39860,356,39862,39864],{},[18,39861,319],{},[18,39863,359],{}," so these secrets never get committed to version control:",[253,39866,39867],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,39868,39869],{"__ignoreMap":258},[262,39870,39871,39873,39875,39877],{"class":181,"line":264},[262,39872,371],{"class":271},[262,39874,374],{"class":275},[262,39876,378],{"class":377},[262,39878,381],{"class":275},[14,39880,37290,39881,39883,39884,39887,39888,39890,39891,1363],{},[51,39882,2487],{"href":2486}," explains where they come from and how billing works. You also need the CRM ",[18,39885,39886],{},"contactId"," of the person the call was with — a number your CRM shows in the contact's URL or detail page. This guide assumes you already pull contacts from the CRM; the main ",[51,39889,36938],{"href":36937}," guide shows how, and so does ",[51,39892,35211],{"href":35210},[57,39894,39896],{"id":39895},"step-1-load-credentials-and-the-transcript","Step 1 — Load credentials and the transcript",[14,39898,39899,39900,39902,39903,39905,39906,1363],{},"Start by reading your secrets and creating two clients — small objects that hold connection details so you do not repeat them on every call. We build an ",[18,39901,37312],{}," client for the summary and an ",[18,39904,37316],{}," for the CRM. For now we read the transcript from a plain text file named ",[18,39907,39908],{},"transcript.txt",[253,39910,39912],{"className":414,"code":39911,"language":416,"meta":258,"style":258},"import os\nimport httpx\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # reads the .env file into environment variables\n\nai = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\ncrm = httpx.Client(\n    base_url=os.environ[\"CRM_BASE_URL\"],\n    headers={\"Authorization\": f\"Bearer {os.environ['CRM_API_TOKEN']}\"},\n    timeout=30.0,\n)\n\nwith open(\"transcript.txt\", \"r\", encoding=\"utf-8\") as f:\n    transcript = f.read()\n",[18,39913,39914,39920,39926,39936,39946,39950,39956,39960,39978,39982,39990,40002,40032,40042,40046,40050,40080],{"__ignoreMap":258},[262,39915,39916,39918],{"class":181,"line":264},[262,39917,684],{"class":377},[262,39919,687],{"class":429},[262,39921,39922,39924],{"class":181,"line":282},[262,39923,684],{"class":377},[262,39925,6526],{"class":429},[262,39927,39928,39930,39932,39934],{"class":181,"line":295},[262,39929,705],{"class":377},[262,39931,708],{"class":429},[262,39933,684],{"class":377},[262,39935,713],{"class":429},[262,39937,39938,39940,39942,39944],{"class":181,"line":345},[262,39939,705],{"class":377},[262,39941,720],{"class":429},[262,39943,684],{"class":377},[262,39945,725],{"class":429},[262,39947,39948],{"class":181,"line":492},[262,39949,583],{"emptyLinePlaceholder":582},[262,39951,39952,39954],{"class":181,"line":503},[262,39953,4222],{"class":429},[262,39955,4225],{"class":291},[262,39957,39958],{"class":181,"line":521},[262,39959,583],{"emptyLinePlaceholder":582},[262,39961,39962,39964,39966,39968,39970,39972,39974,39976],{"class":181,"line":537},[262,39963,37422],{"class":429},[262,39965,476],{"class":377},[262,39967,1588],{"class":429},[262,39969,2674],{"class":611},[262,39971,476],{"class":377},[262,39973,26942],{"class":429},[262,39975,2681],{"class":275},[262,39977,3512],{"class":429},[262,39979,39980],{"class":181,"line":549},[262,39981,583],{"emptyLinePlaceholder":582},[262,39983,39984,39986,39988],{"class":181,"line":570},[262,39985,37448],{"class":429},[262,39987,476],{"class":377},[262,39989,37453],{"class":429},[262,39991,39992,39994,39996,39998,40000],{"class":181,"line":579},[262,39993,37458],{"class":611},[262,39995,476],{"class":377},[262,39997,26942],{"class":429},[262,39999,37406],{"class":275},[262,40001,10309],{"class":429},[262,40003,40004,40006,40008,40010,40012,40014,40016,40018,40020,40022,40024,40026,40028,40030],{"class":181,"line":586},[262,40005,37469],{"class":611},[262,40007,476],{"class":377},[262,40009,3039],{"class":429},[262,40011,16998],{"class":275},[262,40013,1231],{"class":429},[262,40015,642],{"class":377},[262,40017,6605],{"class":275},[262,40019,3039],{"class":271},[262,40021,26942],{"class":429},[262,40023,38858],{"class":275},[262,40025,6223],{"class":429},[262,40027,654],{"class":271},[262,40029,1176],{"class":275},[262,40031,3143],{"class":429},[262,40033,40034,40036,40038,40040],{"class":181,"line":591},[262,40035,37493],{"class":611},[262,40037,476],{"class":377},[262,40039,6692],{"class":271},[262,40041,1315],{"class":429},[262,40043,40044],{"class":181,"line":623},[262,40045,660],{"class":429},[262,40047,40048],{"class":181,"line":634},[262,40049,583],{"emptyLinePlaceholder":582},[262,40051,40052,40054,40056,40058,40061,40063,40066,40068,40070,40072,40074,40076,40078],{"class":181,"line":845},[262,40053,9153],{"class":377},[262,40055,599],{"class":271},[262,40057,602],{"class":429},[262,40059,40060],{"class":275},"\"transcript.txt\"",[262,40062,608],{"class":429},[262,40064,40065],{"class":275},"\"r\"",[262,40067,608],{"class":429},[262,40069,612],{"class":611},[262,40071,476],{"class":377},[262,40073,617],{"class":275},[262,40075,1000],{"class":429},[262,40077,697],{"class":377},[262,40079,9190],{"class":429},[262,40081,40082,40085,40087],{"class":181,"line":850},[262,40083,40084],{"class":429},"    transcript ",[262,40086,476],{"class":377},[262,40088,40089],{"class":429}," f.read()\n",[14,40091,37506,40092,40094,40095,40097,40098,40101],{},[18,40093,37509],{}," (square brackets) rather than ",[18,40096,37513],{}," means the script stops with a clear error if a key is missing, instead of silently sending a blank token and failing later with a confusing message. This \"fail fast\" habit saves you from the most common beginner trap, where a typo in a key name produces a vague 401 error far down the script instead of an obvious one on the first line. The ",[18,40099,40100],{},"timeout=30.0"," means \"give up after 30 seconds\" so the script never hangs forever on a slow response.",[14,40103,40104,40105,40107,40108,40110],{},"A word on the two clients, because they look similar but do different jobs. The ",[18,40106,37312],{}," client knows how to format requests the AI model expects and read its replies — you never touch raw HTTP with it. The ",[18,40109,37316],{}," is more general: it can talk to any web API, and we point it at your CRM. Creating each client once and reusing it (rather than rebuilding it inside a loop) keeps the network connection alive, which matters when you summarize a batch of calls in one run.",[14,40112,40113],{},"About the transcript itself: this guide reads it from a file so the workflow is easy to test, but in practice it will arrive from wherever your calls live — a meeting tool's export, a webhook payload, or a row pulled straight from the CRM. Whatever the source, the only thing the rest of the script needs is a single string of text. If your transcript is very long — say an hour-plus call — trim it to the parts that matter before this step, both to control cost and to stay inside the model's input limit, since you pay for every word you send.",[57,40115,40117],{"id":40116},"step-2-summarize-the-transcript-with-an-llm","Step 2 — Summarize the transcript with an LLM",[14,40119,40120,40121,40123],{},"Now the core of the workflow. We ask the AI model to read the transcript and return a structured answer as JSON (a strict text format programs can parse). Forcing JSON with ",[18,40122,5745],{}," means we never have to guess at free-form text — we get the same named keys every time.",[253,40125,40127],{"className":414,"code":40126,"language":416,"meta":258,"style":258},"import json\n\n\ndef summarize_call(transcript: str) -> dict:\n    \"\"\"Ask the AI model to summarize a sales call into structured fields.\"\"\"\n    system = (\n        \"You are a sales-operations assistant. Read the call transcript and \"\n        \"reply only with JSON containing exactly these keys: \"\n        \"'key_points' (a list of 3-5 short strings), \"\n        \"'next_steps' (a list of short action strings, owner first if mentioned), \"\n        \"and 'sentiment' (one word: 'positive', 'neutral', or 'negative').\"\n    )\n    response = ai.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\"role\": \"system\", \"content\": system},\n            {\"role\": \"user\", \"content\": f\"Transcript:\\n{transcript}\"},\n        ],\n        response_format={\"type\": \"json_object\"},\n        temperature=0,  # 0 = consistent, repeatable summaries\n    )\n    return json.loads(response.choices[0].message.content)\n",[18,40128,40129,40135,40139,40143,40161,40166,40174,40179,40184,40189,40194,40199,40203,40211,40221,40229,40245,40277,40281,40297,40310,40314],{"__ignoreMap":258},[262,40130,40131,40133],{"class":181,"line":264},[262,40132,684],{"class":377},[262,40134,5766],{"class":429},[262,40136,40137],{"class":181,"line":282},[262,40138,583],{"emptyLinePlaceholder":582},[262,40140,40141],{"class":181,"line":295},[262,40142,583],{"emptyLinePlaceholder":582},[262,40144,40145,40147,40150,40153,40155,40157,40159],{"class":181,"line":345},[262,40146,423],{"class":377},[262,40148,40149],{"class":267}," summarize_call",[262,40151,40152],{"class":429},"(transcript: ",[262,40154,433],{"class":271},[262,40156,1939],{"class":429},[262,40158,5869],{"class":271},[262,40160,1160],{"class":429},[262,40162,40163],{"class":181,"line":492},[262,40164,40165],{"class":275},"    \"\"\"Ask the AI model to summarize a sales call into structured fields.\"\"\"\n",[262,40167,40168,40170,40172],{"class":181,"line":503},[262,40169,7578],{"class":429},[262,40171,476],{"class":377},[262,40173,984],{"class":429},[262,40175,40176],{"class":181,"line":521},[262,40177,40178],{"class":275},"        \"You are a sales-operations assistant. Read the call transcript and \"\n",[262,40180,40181],{"class":181,"line":537},[262,40182,40183],{"class":275},"        \"reply only with JSON containing exactly these keys: \"\n",[262,40185,40186],{"class":181,"line":549},[262,40187,40188],{"class":275},"        \"'key_points' (a list of 3-5 short strings), \"\n",[262,40190,40191],{"class":181,"line":570},[262,40192,40193],{"class":275},"        \"'next_steps' (a list of short action strings, owner first if mentioned), \"\n",[262,40195,40196],{"class":181,"line":579},[262,40197,40198],{"class":275},"        \"and 'sentiment' (one word: 'positive', 'neutral', or 'negative').\"\n",[262,40200,40201],{"class":181,"line":586},[262,40202,1011],{"class":429},[262,40204,40205,40207,40209],{"class":181,"line":591},[262,40206,1184],{"class":429},[262,40208,476],{"class":377},[262,40210,38216],{"class":429},[262,40212,40213,40215,40217,40219],{"class":181,"line":623},[262,40214,1194],{"class":611},[262,40216,476],{"class":377},[262,40218,1207],{"class":275},[262,40220,1315],{"class":429},[262,40222,40223,40225,40227],{"class":181,"line":634},[262,40224,1215],{"class":611},[262,40226,476],{"class":377},[262,40228,1220],{"class":429},[262,40230,40231,40233,40235,40237,40239,40241,40243],{"class":181,"line":845},[262,40232,1225],{"class":429},[262,40234,1228],{"class":275},[262,40236,1231],{"class":429},[262,40238,1234],{"class":275},[262,40240,608],{"class":429},[262,40242,1239],{"class":275},[262,40244,7739],{"class":429},[262,40246,40247,40249,40251,40253,40255,40257,40259,40261,40263,40266,40268,40271,40273,40275],{"class":181,"line":850},[262,40248,1225],{"class":429},[262,40250,1228],{"class":275},[262,40252,1231],{"class":429},[262,40254,1291],{"class":275},[262,40256,608],{"class":429},[262,40258,1239],{"class":275},[262,40260,1231],{"class":429},[262,40262,642],{"class":377},[262,40264,40265],{"class":275},"\"Transcript:",[262,40267,1268],{"class":271},[262,40269,40270],{"class":429},"transcript",[262,40272,654],{"class":271},[262,40274,1176],{"class":275},[262,40276,3143],{"class":429},[262,40278,40279],{"class":181,"line":864},[262,40280,1303],{"class":429},[262,40282,40283,40285,40287,40289,40291,40293,40295],{"class":181,"line":1683},[262,40284,6018],{"class":611},[262,40286,476],{"class":377},[262,40288,3039],{"class":429},[262,40290,6025],{"class":275},[262,40292,1231],{"class":429},[262,40294,6030],{"class":275},[262,40296,3143],{"class":429},[262,40298,40299,40301,40303,40305,40307],{"class":181,"line":1688},[262,40300,1308],{"class":611},[262,40302,476],{"class":377},[262,40304,102],{"class":271},[262,40306,13488],{"class":429},[262,40308,40309],{"class":291},"# 0 = consistent, repeatable summaries\n",[262,40311,40312],{"class":181,"line":1693},[262,40313,1011],{"class":429},[262,40315,40316,40318,40320,40322],{"class":181,"line":1728},[262,40317,573],{"class":377},[262,40319,6043],{"class":429},[262,40321,102],{"class":271},[262,40323,6048],{"class":429},[14,40325,22732,40326,40328,40329,40331,40332,40334,40335,40337,40338,40340,40341,40343],{},[18,40327,2703],{}," because summarizing a transcript does not need an expensive model, and ",[18,40330,1357],{}," makes the output stable so the same transcript always produces the same summary. Temperature controls how much randomness the model adds: at ",[18,40333,102],{}," it picks the most likely wording every time, which is what you want for notes that should read consistently. Keeping the ",[18,40336,4466],{}," message (the role and the rules) separate from the ",[18,40339,4470],{}," message (the transcript) is a small prompt-engineering habit that makes the model far more predictable — see ",[51,40342,1362],{"href":1361}," for more on this. The result is a plain Python dictionary with three keys you can rely on.",[14,40345,40346,40347,40349,40350,40352,40353,40356,40357,40360,40361,40364],{},"Why force JSON at all? Because the alternative is parsing free-form sentences, which breaks the moment the model decides to be chatty or add a friendly preamble. By setting ",[18,40348,6878],{}," and naming the exact keys you want, you get something Python reads with a single ",[18,40351,20396],{}," call, every time. The three keys here are deliberately chosen: ",[18,40354,40355],{},"key_points"," captures what was discussed, ",[18,40358,40359],{},"next_steps"," captures who owes what, and ",[18,40362,40363],{},"sentiment"," gives you a one-word read you can later filter or report on. If you wanted to extract more — budget mentioned, competitors named, objections raised — you would just add those keys to the system message and to the format step that follows. The structure scales as far as your sales process needs.",[57,40366,40368],{"id":40367},"step-3-format-the-summary-as-a-crm-note","Step 3 — Format the summary as a CRM note",[14,40370,40371],{},"The JSON summary is great for code, but your sales team wants something readable. This step turns the dictionary into a short block of text with headings and bullet points that scans well inside the CRM.",[253,40373,40375],{"className":414,"code":40374,"language":416,"meta":258,"style":258},"def format_note(summary: dict) -> str:\n    \"\"\"Turn the structured summary into readable note text.\"\"\"\n    key_points = \"\\n\".join(f\"- {p}\" for p in summary.get(\"key_points\", []))\n    next_steps = \"\\n\".join(f\"- {s}\" for s in summary.get(\"next_steps\", []))\n    sentiment = summary.get(\"sentiment\", \"unknown\").capitalize()\n\n    return (\n        \"AI Call Summary\\n\\n\"\n        f\"Sentiment: {sentiment}\\n\\n\"\n        f\"Key points:\\n{key_points}\\n\\n\"\n        f\"Next steps:\\n{next_steps}\"\n    )\n",[18,40376,40377,40395,40400,40443,40484,40503,40507,40513,40522,40537,40552,40567],{"__ignoreMap":258},[262,40378,40379,40381,40384,40387,40389,40391,40393],{"class":181,"line":264},[262,40380,423],{"class":377},[262,40382,40383],{"class":267}," format_note",[262,40385,40386],{"class":429},"(summary: ",[262,40388,5869],{"class":271},[262,40390,1939],{"class":429},[262,40392,433],{"class":271},[262,40394,1160],{"class":429},[262,40396,40397],{"class":181,"line":282},[262,40398,40399],{"class":275},"    \"\"\"Turn the structured summary into readable note text.\"\"\"\n",[262,40401,40402,40405,40407,40409,40411,40413,40415,40417,40420,40422,40424,40426,40428,40430,40433,40435,40438,40441],{"class":181,"line":295},[262,40403,40404],{"class":429},"    key_points ",[262,40406,476],{"class":377},[262,40408,1170],{"class":275},[262,40410,2137],{"class":271},[262,40412,1176],{"class":275},[262,40414,2023],{"class":429},[262,40416,642],{"class":377},[262,40418,40419],{"class":275},"\"- ",[262,40421,3039],{"class":271},[262,40423,14],{"class":429},[262,40425,654],{"class":271},[262,40427,1176],{"class":275},[262,40429,10739],{"class":377},[262,40431,40432],{"class":429}," p ",[262,40434,835],{"class":377},[262,40436,40437],{"class":429}," summary.get(",[262,40439,40440],{"class":275},"\"key_points\"",[262,40442,37723],{"class":429},[262,40444,40445,40448,40450,40452,40454,40456,40458,40460,40462,40464,40467,40469,40471,40473,40475,40477,40479,40482],{"class":181,"line":345},[262,40446,40447],{"class":429},"    next_steps ",[262,40449,476],{"class":377},[262,40451,1170],{"class":275},[262,40453,2137],{"class":271},[262,40455,1176],{"class":275},[262,40457,2023],{"class":429},[262,40459,642],{"class":377},[262,40461,40419],{"class":275},[262,40463,3039],{"class":271},[262,40465,40466],{"class":429},"s",[262,40468,654],{"class":271},[262,40470,1176],{"class":275},[262,40472,10739],{"class":377},[262,40474,6183],{"class":429},[262,40476,835],{"class":377},[262,40478,40437],{"class":429},[262,40480,40481],{"class":275},"\"next_steps\"",[262,40483,37723],{"class":429},[262,40485,40486,40489,40491,40493,40496,40498,40500],{"class":181,"line":492},[262,40487,40488],{"class":429},"    sentiment ",[262,40490,476],{"class":377},[262,40492,40437],{"class":429},[262,40494,40495],{"class":275},"\"sentiment\"",[262,40497,608],{"class":429},[262,40499,35361],{"class":275},[262,40501,40502],{"class":429},").capitalize()\n",[262,40504,40505],{"class":181,"line":503},[262,40506,583],{"emptyLinePlaceholder":582},[262,40508,40509,40511],{"class":181,"line":521},[262,40510,573],{"class":377},[262,40512,984],{"class":429},[262,40514,40515,40518,40520],{"class":181,"line":537},[262,40516,40517],{"class":275},"        \"AI Call Summary",[262,40519,1173],{"class":271},[262,40521,1257],{"class":275},[262,40523,40524,40526,40529,40531,40533,40535],{"class":181,"line":549},[262,40525,2840],{"class":377},[262,40527,40528],{"class":275},"\"Sentiment: ",[262,40530,3039],{"class":271},[262,40532,40363],{"class":429},[262,40534,4644],{"class":271},[262,40536,1257],{"class":275},[262,40538,40539,40541,40544,40546,40548,40550],{"class":181,"line":570},[262,40540,2840],{"class":377},[262,40542,40543],{"class":275},"\"Key points:",[262,40545,1268],{"class":271},[262,40547,40355],{"class":429},[262,40549,4644],{"class":271},[262,40551,1257],{"class":275},[262,40553,40554,40556,40559,40561,40563,40565],{"class":181,"line":579},[262,40555,2840],{"class":377},[262,40557,40558],{"class":275},"\"Next steps:",[262,40560,1268],{"class":271},[262,40562,40359],{"class":429},[262,40564,654],{"class":271},[262,40566,1257],{"class":275},[262,40568,40569],{"class":181,"line":586},[262,40570,1011],{"class":429},[14,40572,40573,40574,40576,40577,40580,40581,40584,40585,40588],{},"This is plain text on purpose. Some CRMs render note bodies as HTML and some as plain text, so a clean, line-broken string is the safest default that looks right everywhere. If your CRM supports HTML notes, you can swap the ",[18,40575,561],{}," bullets for ",[18,40578,40579],{},"\u003Cul>\u003Cli>"," tags later. Using ",[18,40582,40583],{},"summary.get(\"key_points\", [])"," instead of ",[18,40586,40587],{},"summary[\"key_points\"]"," means the function still works even if the model omits a field, rather than crashing on a missing key.",[57,40590,40592],{"id":40591},"step-4-log-the-note-on-the-crm-contact","Step 4 — Log the note on the CRM contact",[14,40594,40595,40596,40599],{},"Finally, we post the note to the CRM and link it to the contact. In HubSpot-style APIs a note is its own object that you create, then associate with a contact in the same request. We send a ",[18,40597,40598],{},"POST"," — the HTTP verb that means \"create a new record\" — with the note text and the contact association.",[253,40601,40603],{"className":414,"code":40602,"language":416,"meta":258,"style":258},"import time\n\n\ndef log_note(contact_id: str, note_text: str) -> str:\n    \"\"\"Create a note in the CRM and associate it with a contact.\"\"\"\n    payload = {\n        \"properties\": {\n            \"hs_note_body\": note_text,\n            \"hs_timestamp\": int(time.time() * 1000),  # CRM expects epoch milliseconds\n        },\n        \"associations\": [\n            {\n                \"to\": {\"id\": contact_id},\n                \"types\": [\n                    {\n                        \"associationCategory\": \"HUBSPOT_DEFINED\",\n                        \"associationTypeId\": 202,  # note-to-contact link\n                    }\n                ],\n            }\n        ],\n    }\n    response = crm.post(\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fnotes\", json=payload)\n    response.raise_for_status()  # turn HTTP errors (401, 429, 400) into exceptions\n    return response.json()[\"id\"]\n",[18,40604,40605,40611,40615,40619,40641,40646,40654,40660,40668,40690,40694,40702,40706,40718,40725,40730,40742,40757,40762,40766,40771,40775,40779,40799,40807],{"__ignoreMap":258},[262,40606,40607,40609],{"class":181,"line":264},[262,40608,684],{"class":377},[262,40610,2612],{"class":429},[262,40612,40613],{"class":181,"line":282},[262,40614,583],{"emptyLinePlaceholder":582},[262,40616,40617],{"class":181,"line":295},[262,40618,583],{"emptyLinePlaceholder":582},[262,40620,40621,40623,40626,40628,40630,40633,40635,40637,40639],{"class":181,"line":345},[262,40622,423],{"class":377},[262,40624,40625],{"class":267}," log_note",[262,40627,36157],{"class":429},[262,40629,433],{"class":271},[262,40631,40632],{"class":429},", note_text: ",[262,40634,433],{"class":271},[262,40636,1939],{"class":429},[262,40638,433],{"class":271},[262,40640,1160],{"class":429},[262,40642,40643],{"class":181,"line":492},[262,40644,40645],{"class":275},"    \"\"\"Create a note in the CRM and associate it with a contact.\"\"\"\n",[262,40647,40648,40650,40652],{"class":181,"line":503},[262,40649,16972],{"class":429},[262,40651,476],{"class":377},[262,40653,20437],{"class":429},[262,40655,40656,40658],{"class":181,"line":521},[262,40657,36223],{"class":275},[262,40659,35273],{"class":429},[262,40661,40662,40665],{"class":181,"line":537},[262,40663,40664],{"class":275},"            \"hs_note_body\"",[262,40666,40667],{"class":429},": note_text,\n",[262,40669,40670,40673,40675,40677,40680,40682,40684,40687],{"class":181,"line":549},[262,40671,40672],{"class":275},"            \"hs_timestamp\"",[262,40674,1231],{"class":429},[262,40676,439],{"class":271},[262,40678,40679],{"class":429},"(time.time() ",[262,40681,1003],{"class":377},[262,40683,31055],{"class":271},[262,40685,40686],{"class":429},"),  ",[262,40688,40689],{"class":291},"# CRM expects epoch milliseconds\n",[262,40691,40692],{"class":181,"line":570},[262,40693,6637],{"class":429},[262,40695,40696,40699],{"class":181,"line":579},[262,40697,40698],{"class":275},"        \"associations\"",[262,40700,40701],{"class":429},": [\n",[262,40703,40704],{"class":181,"line":586},[262,40705,4331],{"class":429},[262,40707,40708,40711,40713,40715],{"class":181,"line":591},[262,40709,40710],{"class":275},"                \"to\"",[262,40712,20445],{"class":429},[262,40714,6770],{"class":275},[262,40716,40717],{"class":429},": contact_id},\n",[262,40719,40720,40723],{"class":181,"line":623},[262,40721,40722],{"class":275},"                \"types\"",[262,40724,40701],{"class":429},[262,40726,40727],{"class":181,"line":634},[262,40728,40729],{"class":429},"                    {\n",[262,40731,40732,40735,40737,40740],{"class":181,"line":845},[262,40733,40734],{"class":275},"                        \"associationCategory\"",[262,40736,1231],{"class":429},[262,40738,40739],{"class":275},"\"HUBSPOT_DEFINED\"",[262,40741,1315],{"class":429},[262,40743,40744,40747,40749,40752,40754],{"class":181,"line":850},[262,40745,40746],{"class":275},"                        \"associationTypeId\"",[262,40748,1231],{"class":429},[262,40750,40751],{"class":271},"202",[262,40753,13488],{"class":429},[262,40755,40756],{"class":291},"# note-to-contact link\n",[262,40758,40759],{"class":181,"line":864},[262,40760,40761],{"class":429},"                    }\n",[262,40763,40764],{"class":181,"line":1683},[262,40765,3165],{"class":429},[262,40767,40768],{"class":181,"line":1688},[262,40769,40770],{"class":429},"            }\n",[262,40772,40773],{"class":181,"line":1693},[262,40774,1303],{"class":429},[262,40776,40777],{"class":181,"line":1728},[262,40778,36280],{"class":429},[262,40780,40781,40783,40785,40788,40791,40793,40795,40797],{"class":181,"line":1737},[262,40782,1184],{"class":429},[262,40784,476],{"class":377},[262,40786,40787],{"class":429}," crm.post(",[262,40789,40790],{"class":275},"\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fnotes\"",[262,40792,608],{"class":429},[262,40794,17049],{"class":611},[262,40796,476],{"class":377},[262,40798,19510],{"class":429},[262,40800,40801,40804],{"class":181,"line":1751},[262,40802,40803],{"class":429},"    response.raise_for_status()  ",[262,40805,40806],{"class":291},"# turn HTTP errors (401, 429, 400) into exceptions\n",[262,40808,40809,40811,40813,40815],{"class":181,"line":1764},[262,40810,573],{"class":377},[262,40812,30772],{"class":429},[262,40814,6770],{"class":275},[262,40816,957],{"class":429},[14,40818,40819,40821,40822,40825,40826,40829,40830,6092,40833,40835],{},[18,40820,6778],{}," is your safety net: if the CRM returns a 401 (bad token) or 400 (malformed payload), the script raises an exception instead of quietly continuing as if the note saved. The ",[18,40823,40824],{},"hs_timestamp"," field is required and must be epoch milliseconds — the number of milliseconds since 1970 — which is why we multiply ",[18,40827,40828],{},"time.time()"," by 1000. The ",[18,40831,40832],{},"associationTypeId",[18,40834,40751],{}," is HubSpot's built-in code for linking a note to a contact; other CRMs use their own association mechanism, but the idea is identical: create the note, then point it at the contact so it shows up on that person's timeline.",[14,40837,40838],{},"Put the four functions together and the whole flow is four lines:",[253,40840,40842],{"className":414,"code":40841,"language":416,"meta":258,"style":258},"summary = summarize_call(transcript)\nnote_text = format_note(summary)\nnote_id = log_note(contact_id=\"12345\", note_text=note_text)\nprint(f\"Logged note {note_id} with sentiment: {summary['sentiment']}\")\n",[18,40843,40844,40854,40864,40891],{"__ignoreMap":258},[262,40845,40846,40849,40851],{"class":181,"line":264},[262,40847,40848],{"class":429},"summary ",[262,40850,476],{"class":377},[262,40852,40853],{"class":429}," summarize_call(transcript)\n",[262,40855,40856,40859,40861],{"class":181,"line":282},[262,40857,40858],{"class":429},"note_text ",[262,40860,476],{"class":377},[262,40862,40863],{"class":429}," format_note(summary)\n",[262,40865,40866,40869,40871,40874,40876,40878,40881,40883,40886,40888],{"class":181,"line":295},[262,40867,40868],{"class":429},"note_id ",[262,40870,476],{"class":377},[262,40872,40873],{"class":429}," log_note(",[262,40875,36206],{"class":611},[262,40877,476],{"class":377},[262,40879,40880],{"class":275},"\"12345\"",[262,40882,608],{"class":429},[262,40884,40885],{"class":611},"note_text",[262,40887,476],{"class":377},[262,40889,40890],{"class":429},"note_text)\n",[262,40892,40893,40895,40897,40899,40902,40904,40907,40909,40912,40914,40917,40920,40922,40924,40926],{"class":181,"line":345},[262,40894,637],{"class":271},[262,40896,602],{"class":429},[262,40898,642],{"class":377},[262,40900,40901],{"class":275},"\"Logged note ",[262,40903,3039],{"class":271},[262,40905,40906],{"class":429},"note_id",[262,40908,654],{"class":271},[262,40910,40911],{"class":275}," with sentiment: ",[262,40913,3039],{"class":271},[262,40915,40916],{"class":429},"summary[",[262,40918,40919],{"class":275},"'sentiment'",[262,40921,6223],{"class":429},[262,40923,654],{"class":271},[262,40925,1176],{"class":275},[262,40927,660],{"class":429},[57,40929,18801],{"id":18800},[14,40931,40932],{},"The settings you will most often adjust as you adapt this workflow.",[1379,40934,40935,40947],{},[1382,40936,40937],{},[1385,40938,40939,40941,40943,40945],{},[1388,40940,1390],{},[1388,40942,3795],{},[1388,40944,3798],{},[1388,40946,1396],{},[1398,40948,40949,40964,40985],{},[1385,40950,40951,40955,40957,40961],{},[1403,40952,40953],{},[18,40954,805],{},[1403,40956,433],{},[1403,40958,40959],{},[18,40960,1207],{},[1403,40962,40963],{},"Which AI model writes the summary. Bigger models cost more but handle long, messy calls better.",[1385,40965,40966,40970,40972,40976],{},[1403,40967,40968],{},[18,40969,3829],{},[1403,40971,3832],{},[1403,40973,40974],{},[18,40975,102],{},[1403,40977,40978,40979,40981,40982,40984],{},"Randomness of the summary. ",[18,40980,102],{}," gives repeatable notes; raise toward ",[18,40983,997],{}," for more varied wording.",[1385,40986,40987,40991,40993,40997],{},[1403,40988,40989],{},[18,40990,40832],{},[1403,40992,439],{},[1403,40994,40995],{},[18,40996,40751],{},[1403,40998,40999],{},"The CRM code that links the note to a contact. Use your CRM's own value if you are not on HubSpot.",[57,41001,1445],{"id":1444},[1447,41003,41004,41017,41034,41048],{},[1450,41005,41006,38670,41010,38673,41012,41014,41015,1363],{},[35,41007,41008],{},[18,41009,19631],{},[18,41011,5745],{},[18,41013,6878],{}," and tell the model to reply with JSON only. See ",[51,41016,6114],{"href":6113},[1450,41018,41019,41024,41025,41027,41028,41030,41031,41033],{},[35,41020,41021,41023],{},[18,41022,38685],{}," when logging the note"," — The CRM rejected the payload. Cause: a missing required field like ",[18,41026,40824],{},", or a ",[18,41029,36206],{}," that does not exist. Fix: include ",[18,41032,40824],{}," as epoch milliseconds and double-check the contact ID against the contact's URL in your CRM.",[1450,41035,41036,41040,41041,38645,41043,41045,41046,1363],{},[35,41037,41038],{},[18,41039,17621],{}," — The CRM rejected your token. Cause: an expired or wrong token, or a missing ",[18,41042,38644],{},[18,41044,38648],{},". The same logic for AI keys is in ",[51,41047,388],{"href":387},[1450,41049,41050,41053,41054,1363],{},[35,41051,41052],{},"Summary cut off or an input-limit error on a long call"," — The transcript was too long for the model. Cause: sending an entire hour-plus transcript unfiltered. Fix: trim the transcript or summarize it in chunks first. See ",[51,41055,1513],{"href":1512},[57,41057,2317],{"id":2316},[2322,41059,41060,41065,41071],{},[1450,41061,41062,41064],{},[35,41063,5280],{}," when you have call transcripts and want a consistent, structured note on every contact without manual typing — it is fast, cheap, and runs unattended over a batch of calls.",[1450,41066,41067,41070],{},[35,41068,41069],{},"Use your meeting tool's built-in AI summary"," when you only need a human to read the recap and you do not care about a structured, machine-readable record in the CRM. Those summaries are convenient but live in the meeting tool, not on the contact, and you cannot control their format.",[1450,41072,41073,41076,41077,41079],{},[35,41074,41075],{},"Use a fuller enrichment pipeline"," when you want to score the lead or update structured fields as well as log a note. In that case, pair this with ",[51,41078,35139],{"href":37077},", which writes AI results to custom fields rather than notes.",[14,41081,2375,41082,1363],{},[51,41083,36938],{"href":36937},[57,41085,2381],{"id":2380},[2322,41087,41088,41092,41096],{},[1450,41089,41090],{},[51,41091,36938],{"href":36937},[1450,41093,41094],{},[51,41095,35211],{"href":35210},[1450,41097,41098],{},[51,41099,35139],{"href":37077},[2401,41101,5337],{},{"title":258,"searchDepth":282,"depth":282,"links":41103},[41104,41105,41106,41107,41108,41109,41110,41111,41112],{"id":237,"depth":282,"text":238},{"id":39895,"depth":282,"text":39896},{"id":40116,"depth":282,"text":40117},{"id":40367,"depth":282,"text":40368},{"id":40591,"depth":282,"text":40592},{"id":18800,"depth":282,"text":18801},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Turn a sales-call transcript into a structured summary with key points, next steps, and sentiment, then log it as a CRM note using the openai SDK and httpx.",[41115,41118,41121,41124,41127],{"q":41116,"a":41117},"Do I need the call audio, or just the transcript?","Just the transcript text. This guide starts from a written transcript, which you can get from a meeting tool like Fireflies, Otter, or Zoom's built-in transcription, or from your own notes pasted into a text file.",{"q":41119,"a":41120},"Will the AI summary always return the same fields?","Yes, if you force JSON output and name the keys you want. By setting response_format to a JSON object and asking for key_points, next_steps, and sentiment by name, every call returns the same shape your code can read reliably.",{"q":41122,"a":41123},"Which CRMs can I log the note to?","Any CRM with a REST API, including HubSpot, Pipedrive, Salesforce, and Zoho. This guide uses HubSpot-style note and association endpoints, but the pattern of post a note then link it to a contact is the same everywhere.",{"q":41125,"a":41126},"Is it safe to send a call transcript to an AI model?","Send only the transcript text you need and strip out anything sensitive like payment details first. The OpenAI API does not train on data sent through the API by default, but you should still avoid sending more than the summary task requires.",{"q":41128,"a":41129},"How much does it cost to summarize a call?","Very little. A typical 30-minute transcript runs a few thousand tokens, so on a small model like gpt-4o-mini each summary costs a fraction of a cent. Cost scales with transcript length, so trim filler if your calls run long.",{"name":41131,"steps":41132},"How to summarize sales calls to your CRM with Python",[41133,41136,41139,41142],{"name":41134,"text":41135},"Load credentials and the transcript","Read your OpenAI and CRM keys from a .env file and load the call transcript text into your script.",{"name":41137,"text":41138},"Summarize the transcript with an LLM","Ask the AI model to return structured JSON with key points, next steps, and a sentiment label.",{"name":41140,"text":41141},"Format the summary as a CRM note","Turn the JSON summary into readable note text your sales team can scan at a glance.",{"name":41143,"text":41144},"Log the note on the CRM contact","Post the note to the CRM with httpx and associate it with the right contact record.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsummarize-sales-calls-to-your-crm-with-python",{"title":36958,"description":41113},"building-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsummarize-sales-calls-to-your-crm-with-python\u002Findex","HtRFwdx5QHepUbvEzwmIEMZ4E_QHouQW5jNchTieRtQ",{"id":41151,"title":35211,"body":41152,"description":42881,"extension":2419,"faq":42882,"howto":42898,"meta":42913,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":42914,"published":2452,"seo":42915,"seoTitle":35211,"stem":42916,"__hash__":42917},"content\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsync-hubspot-contacts-with-python\u002Findex.md",{"type":7,"value":41153,"toc":42870},[41154,41157,41166,41179,41181,41190,41205,41223,41228,41237,41244,41258,41262,41272,41440,41451,41455,41465,41811,41826,41830,41846,42214,42220,42224,42230,42658,42661,42664,42739,42741,42807,42809,42833,42844,42846,42868],[10,41155,35211],{"id":41156},"sync-hubspot-contacts-with-python",[14,41158,41159,41160,41165],{},"This guide shows you how to pull and push HubSpot contacts from Python in under 15 minutes, using a private-app token and the lightweight ",[51,41161,41163],{"href":37044,"rel":41162},[6509],[18,41164,5450],{}," HTTP client. You will read every contact in your account, then create or update records without making duplicates.",[14,41167,16693,41168,41171,41172,41175,41176,41178],{},[27,41169,41170],{},"contact"," in HubSpot is a person record (name, email, company, and so on). The ",[27,41173,41174],{},"CRM API"," is the web interface HubSpot exposes so your code can read and write those records. We talk to it over plain HTTP, so there is no special SDK to learn. If HTTP requests and tokens are new to you, the parent section ",[51,41177,36938],{"href":36937}," walks through the bigger picture first.",[57,41180,238],{"id":237},[14,41182,41183,41184,41186,41187,41189],{},"You need Python 3.10 or newer and two small packages. ",[18,41185,5450],{}," makes the API calls and ",[18,41188,2501],{}," loads your token from a file so it never lives in your code.",[253,41191,41193],{"className":255,"code":41192,"language":257,"meta":258,"style":258},"pip install httpx python-dotenv\n",[18,41194,41195],{"__ignoreMap":258},[262,41196,41197,41199,41201,41203],{"class":181,"line":264},[262,41198,298],{"class":267},[262,41200,301],{"class":275},[262,41202,5440],{"class":275},[262,41204,2522],{"class":275},[14,41206,41207,41208,41211,41212,41215,41216,1374,41219,41222],{},"Next, create the token. In HubSpot, open ",[35,41209,41210],{},"Settings → Integrations → Private Apps → Create a private app",". Under the ",[35,41213,41214],{},"Scopes"," tab, tick ",[18,41217,41218],{},"crm.objects.contacts.read",[18,41220,41221],{},"crm.objects.contacts.write",", then create the app and copy the access token it shows you.",[14,41224,41225,41226,22741],{},"Save that token in a file named ",[18,41227,319],{},[253,41229,41231],{"className":323,"code":41230,"language":325,"meta":258,"style":258},"HUBSPOT_TOKEN=pat-na1-your-token-here\n",[18,41232,41233],{"__ignoreMap":258},[262,41234,41235],{"class":181,"line":264},[262,41236,41230],{},[14,41238,353,41239,356,41241,41243],{},[18,41240,319],{},[18,41242,359],{}," now so the token never gets committed to a repository.",[253,41245,41246],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,41247,41248],{"__ignoreMap":258},[262,41249,41250,41252,41254,41256],{"class":181,"line":264},[262,41251,371],{"class":271},[262,41253,374],{"class":275},[262,41255,378],{"class":377},[262,41257,381],{"class":275},[57,41259,41261],{"id":41260},"step-1-authenticate-and-make-your-first-call","Step 1: Authenticate and make your first call",[14,41263,41264,41265,41268,41269,41271],{},"Every request to HubSpot carries your token in an ",[18,41266,41267],{},"Authorization: Bearer"," header. The snippet below loads the token, builds a reusable ",[18,41270,37316],{}," (which keeps the connection open and attaches the header to every call), and confirms the credentials work by fetching a single contact.",[253,41273,41275],{"className":414,"code":41274,"language":416,"meta":258,"style":258},"import os\nimport httpx\nfrom dotenv import load_dotenv\n\nload_dotenv()\nTOKEN = os.environ[\"HUBSPOT_TOKEN\"]\nBASE = \"https:\u002F\u002Fapi.hubapi.com\"\n\nclient = httpx.Client(\n    base_url=BASE,\n    headers={\"Authorization\": f\"Bearer {TOKEN}\"},\n    timeout=30.0,\n)\n\n# Ask for just one contact to confirm the token works.\nresp = client.get(\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\", params={\"limit\": 1})\nresp.raise_for_status()  # turns any 4xx\u002F5xx into a clear Python error\nprint(resp.json())\n",[18,41276,41277,41283,41289,41299,41303,41307,41319,41329,41333,41341,41351,41374,41384,41388,41392,41397,41425,41433],{"__ignoreMap":258},[262,41278,41279,41281],{"class":181,"line":264},[262,41280,684],{"class":377},[262,41282,687],{"class":429},[262,41284,41285,41287],{"class":181,"line":282},[262,41286,684],{"class":377},[262,41288,6526],{"class":429},[262,41290,41291,41293,41295,41297],{"class":181,"line":295},[262,41292,705],{"class":377},[262,41294,708],{"class":429},[262,41296,684],{"class":377},[262,41298,713],{"class":429},[262,41300,41301],{"class":181,"line":345},[262,41302,583],{"emptyLinePlaceholder":582},[262,41304,41305],{"class":181,"line":492},[262,41306,734],{"class":429},[262,41308,41309,41311,41313,41315,41317],{"class":181,"line":503},[262,41310,23454],{"class":271},[262,41312,442],{"class":377},[262,41314,36185],{"class":429},[262,41316,36188],{"class":275},[262,41318,957],{"class":429},[262,41320,41321,41324,41326],{"class":181,"line":521},[262,41322,41323],{"class":271},"BASE",[262,41325,442],{"class":377},[262,41327,41328],{"class":275}," \"https:\u002F\u002Fapi.hubapi.com\"\n",[262,41330,41331],{"class":181,"line":537},[262,41332,583],{"emptyLinePlaceholder":582},[262,41334,41335,41337,41339],{"class":181,"line":549},[262,41336,739],{"class":429},[262,41338,476],{"class":377},[262,41340,37453],{"class":429},[262,41342,41343,41345,41347,41349],{"class":181,"line":570},[262,41344,37458],{"class":611},[262,41346,476],{"class":377},[262,41348,41323],{"class":271},[262,41350,1315],{"class":429},[262,41352,41353,41355,41357,41359,41361,41363,41365,41367,41370,41372],{"class":181,"line":579},[262,41354,37469],{"class":611},[262,41356,476],{"class":377},[262,41358,3039],{"class":429},[262,41360,16998],{"class":275},[262,41362,1231],{"class":429},[262,41364,642],{"class":377},[262,41366,6605],{"class":275},[262,41368,41369],{"class":271},"{TOKEN}",[262,41371,1176],{"class":275},[262,41373,3143],{"class":429},[262,41375,41376,41378,41380,41382],{"class":181,"line":586},[262,41377,37493],{"class":611},[262,41379,476],{"class":377},[262,41381,6692],{"class":271},[262,41383,1315],{"class":429},[262,41385,41386],{"class":181,"line":591},[262,41387,660],{"class":429},[262,41389,41390],{"class":181,"line":623},[262,41391,583],{"emptyLinePlaceholder":582},[262,41393,41394],{"class":181,"line":634},[262,41395,41396],{"class":291},"# Ask for just one contact to confirm the token works.\n",[262,41398,41399,41402,41404,41407,41409,41411,41413,41415,41417,41419,41421,41423],{"class":181,"line":845},[262,41400,41401],{"class":429},"resp ",[262,41403,476],{"class":377},[262,41405,41406],{"class":429}," client.get(",[262,41408,37682],{"class":275},[262,41410,608],{"class":429},[262,41412,37687],{"class":611},[262,41414,476],{"class":377},[262,41416,3039],{"class":429},[262,41418,20448],{"class":275},[262,41420,1231],{"class":429},[262,41422,997],{"class":271},[262,41424,10332],{"class":429},[262,41426,41427,41430],{"class":181,"line":850},[262,41428,41429],{"class":429},"resp.raise_for_status()  ",[262,41431,41432],{"class":291},"# turns any 4xx\u002F5xx into a clear Python error\n",[262,41434,41435,41437],{"class":181,"line":864},[262,41436,637],{"class":271},[262,41438,41439],{"class":429},"(resp.json())\n",[14,41441,41442,41443,41446,41447,41450],{},"If you see a JSON object back, your token and scopes are correct. A ",[18,41444,41445],{},"401"," here means the token is wrong or missing; a ",[18,41448,41449],{},"403"," means it lacks the contacts scopes.",[57,41452,41454],{"id":41453},"step-2-pull-every-contact-with-pagination","Step 2: Pull every contact with pagination",[14,41456,41457,41458,41461,41462,41464],{},"The list endpoint returns at most 100 contacts per call. To read more, you follow a ",[27,41459,41460],{},"cursor"," (a bookmark HubSpot hands back so the next call resumes where the last one stopped). The response includes ",[18,41463,37536],{}," while more pages remain, and omits it on the final page. Loop until it disappears.",[253,41466,41468],{"className":414,"code":41467,"language":416,"meta":258,"style":258},"def fetch_all_contacts(client, properties=None):\n    contacts = []\n    after = None\n    params = {\"limit\": 100}\n    if properties:\n        # Ask only for the fields you need to keep responses small.\n        params[\"properties\"] = \",\".join(properties)\n\n    while True:\n        if after:\n            params[\"after\"] = after\n        resp = client.get(\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\", params=params)\n        resp.raise_for_status()\n        data = resp.json()\n\n        contacts.extend(data[\"results\"])\n\n        paging = data.get(\"paging\")\n        if paging and \"next\" in paging:\n            after = paging[\"next\"][\"after\"]\n        else:\n            break  # no cursor means we read the last page\n\n    return contacts\n\n\npeople = fetch_all_contacts(client, properties=[\"email\", \"firstname\", \"lastname\"])\nprint(f\"Pulled {len(people)} contacts\")\nfor person in people[:3]:\n    props = person[\"properties\"]\n    print(person[\"id\"], props.get(\"email\"), props.get(\"firstname\"))\n",[18,41469,41470,41486,41495,41504,41521,41528,41533,41550,41554,41562,41568,41580,41598,41602,41610,41614,41623,41627,41640,41657,41675,41681,41689,41693,41699,41703,41707,41737,41760,41776,41790],{"__ignoreMap":258},[262,41471,41472,41474,41477,41480,41482,41484],{"class":181,"line":264},[262,41473,423],{"class":377},[262,41475,41476],{"class":267}," fetch_all_contacts",[262,41478,41479],{"class":429},"(client, properties",[262,41481,476],{"class":377},[262,41483,8471],{"class":271},[262,41485,8192],{"class":429},[262,41487,41488,41491,41493],{"class":181,"line":282},[262,41489,41490],{"class":429},"    contacts ",[262,41492,476],{"class":377},[262,41494,489],{"class":429},[262,41496,41497,41500,41502],{"class":181,"line":295},[262,41498,41499],{"class":429},"    after ",[262,41501,476],{"class":377},[262,41503,18658],{"class":271},[262,41505,41506,41509,41511,41513,41515,41517,41519],{"class":181,"line":345},[262,41507,41508],{"class":429},"    params ",[262,41510,476],{"class":377},[262,41512,2276],{"class":429},[262,41514,20448],{"class":275},[262,41516,1231],{"class":429},[262,41518,113],{"class":271},[262,41520,16430],{"class":429},[262,41522,41523,41525],{"class":181,"line":492},[262,41524,3454],{"class":377},[262,41526,41527],{"class":429}," properties:\n",[262,41529,41530],{"class":181,"line":503},[262,41531,41532],{"class":291},"        # Ask only for the fields you need to keep responses small.\n",[262,41534,41535,41538,41540,41542,41544,41547],{"class":181,"line":521},[262,41536,41537],{"class":429},"        params[",[262,41539,37889],{"class":275},[262,41541,2903],{"class":429},[262,41543,476],{"class":377},[262,41545,41546],{"class":275}," \",\"",[262,41548,41549],{"class":429},".join(properties)\n",[262,41551,41552],{"class":181,"line":537},[262,41553,583],{"emptyLinePlaceholder":582},[262,41555,41556,41558,41560],{"class":181,"line":549},[262,41557,506],{"class":377},[262,41559,2241],{"class":271},[262,41561,1160],{"class":429},[262,41563,41564,41566],{"class":181,"line":570},[262,41565,2268],{"class":377},[262,41567,37651],{"class":429},[262,41569,41570,41572,41574,41576,41578],{"class":181,"line":579},[262,41571,37656],{"class":429},[262,41573,37659],{"class":275},[262,41575,2903],{"class":429},[262,41577,476],{"class":377},[262,41579,37666],{"class":429},[262,41581,41582,41584,41586,41588,41590,41592,41594,41596],{"class":181,"line":586},[262,41583,17037],{"class":429},[262,41585,476],{"class":377},[262,41587,41406],{"class":429},[262,41589,37682],{"class":275},[262,41591,608],{"class":429},[262,41593,37687],{"class":611},[262,41595,476],{"class":377},[262,41597,37692],{"class":429},[262,41599,41600],{"class":181,"line":591},[262,41601,17067],{"class":429},[262,41603,41604,41606,41608],{"class":181,"line":623},[262,41605,37705],{"class":429},[262,41607,476],{"class":377},[262,41609,23901],{"class":429},[262,41611,41612],{"class":181,"line":634},[262,41613,583],{"emptyLinePlaceholder":582},[262,41615,41616,41619,41621],{"class":181,"line":845},[262,41617,41618],{"class":429},"        contacts.extend(data[",[262,41620,34288],{"class":275},[262,41622,3512],{"class":429},[262,41624,41625],{"class":181,"line":850},[262,41626,583],{"emptyLinePlaceholder":582},[262,41628,41629,41632,41634,41636,41638],{"class":181,"line":864},[262,41630,41631],{"class":429},"        paging ",[262,41633,476],{"class":377},[262,41635,37742],{"class":429},[262,41637,37745],{"class":275},[262,41639,660],{"class":429},[262,41641,41642,41644,41647,41649,41652,41654],{"class":181,"line":1683},[262,41643,2268],{"class":377},[262,41645,41646],{"class":429}," paging ",[262,41648,6101],{"class":377},[262,41650,41651],{"class":275}," \"next\"",[262,41653,2821],{"class":377},[262,41655,41656],{"class":429}," paging:\n",[262,41658,41659,41662,41664,41667,41669,41671,41673],{"class":181,"line":1688},[262,41660,41661],{"class":429},"            after ",[262,41663,476],{"class":377},[262,41665,41666],{"class":429}," paging[",[262,41668,37751],{"class":275},[262,41670,6163],{"class":429},[262,41672,37659],{"class":275},[262,41674,957],{"class":429},[262,41676,41677,41679],{"class":181,"line":1693},[262,41678,36670],{"class":377},[262,41680,1160],{"class":429},[262,41682,41683,41686],{"class":181,"line":1728},[262,41684,41685],{"class":377},"            break",[262,41687,41688],{"class":291},"  # no cursor means we read the last page\n",[262,41690,41691],{"class":181,"line":1737},[262,41692,583],{"emptyLinePlaceholder":582},[262,41694,41695,41697],{"class":181,"line":1751},[262,41696,573],{"class":377},[262,41698,37780],{"class":429},[262,41700,41701],{"class":181,"line":1764},[262,41702,583],{"emptyLinePlaceholder":582},[262,41704,41705],{"class":181,"line":1779},[262,41706,583],{"emptyLinePlaceholder":582},[262,41708,41709,41712,41714,41717,41719,41721,41723,41725,41727,41730,41732,41735],{"class":181,"line":1793},[262,41710,41711],{"class":429},"people ",[262,41713,476],{"class":377},[262,41715,41716],{"class":429}," fetch_all_contacts(client, ",[262,41718,37791],{"class":611},[262,41720,476],{"class":377},[262,41722,12118],{"class":429},[262,41724,37895],{"class":275},[262,41726,608],{"class":429},[262,41728,41729],{"class":275},"\"firstname\"",[262,41731,608],{"class":429},[262,41733,41734],{"class":275},"\"lastname\"",[262,41736,3512],{"class":429},[262,41738,41739,41741,41743,41745,41748,41750,41753,41755,41758],{"class":181,"line":1800},[262,41740,637],{"class":271},[262,41742,602],{"class":429},[262,41744,642],{"class":377},[262,41746,41747],{"class":275},"\"Pulled ",[262,41749,648],{"class":271},[262,41751,41752],{"class":429},"(people)",[262,41754,654],{"class":271},[262,41756,41757],{"class":275}," contacts\"",[262,41759,660],{"class":429},[262,41761,41762,41764,41767,41769,41772,41774],{"class":181,"line":1805},[262,41763,829],{"class":377},[262,41765,41766],{"class":429}," person ",[262,41768,835],{"class":377},[262,41770,41771],{"class":429}," people[:",[262,41773,5556],{"class":271},[262,41775,463],{"class":429},[262,41777,41778,41781,41783,41786,41788],{"class":181,"line":1810},[262,41779,41780],{"class":429},"    props ",[262,41782,476],{"class":377},[262,41784,41785],{"class":429}," person[",[262,41787,37889],{"class":275},[262,41789,957],{"class":429},[262,41791,41792,41794,41797,41799,41802,41804,41807,41809],{"class":181,"line":1823},[262,41793,1089],{"class":271},[262,41795,41796],{"class":429},"(person[",[262,41798,6770],{"class":275},[262,41800,41801],{"class":429},"], props.get(",[262,41803,37895],{"class":275},[262,41805,41806],{"class":429},"), props.get(",[262,41808,41729],{"class":275},[262,41810,2684],{"class":429},[14,41812,41813,41814,41816,41817,41819,41820,41822,41823,41825],{},"Each item in ",[18,41815,10483],{}," has a stable ",[18,41818,9492],{}," (the contact's internal HubSpot id) and a ",[18,41821,37791],{}," dictionary holding the fields you requested. You will use that ",[18,41824,9492],{}," in the next step to update records.",[57,41827,41829],{"id":41828},"step-3-upsert-a-single-contact","Step 3: Upsert a single contact",[14,41831,30880,41832,41835,41836,41838,41839,41841,41842,41845],{},[27,41833,41834],{},"upsert"," means \"update if it already exists, otherwise create it.\" HubSpot has no single upsert call for contacts, so you search by email first. If the search returns a match you ",[18,41837,38365],{}," that record by its id; if not, you ",[18,41840,40598],{}," a new one. Both endpoints expect a ",[18,41843,41844],{},"{\"properties\": {...}}"," body.",[253,41847,41849],{"className":414,"code":41848,"language":416,"meta":258,"style":258},"def upsert_contact(client, email, properties):\n    body = {\"properties\": {\"email\": email, **properties}}\n\n    # 1. Look for an existing contact with this email.\n    search = client.post(\n        \"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002Fsearch\",\n        json={\n            \"filterGroups\": [{\n                \"filters\": [\n                    {\"propertyName\": \"email\", \"operator\": \"EQ\", \"value\": email}\n                ]\n            }],\n            \"properties\": [\"email\"],\n            \"limit\": 1,\n        },\n    )\n    search.raise_for_status()\n    results = search.json()[\"results\"]\n\n    if results:\n        # 2a. Found one: update it by id.\n        contact_id = results[0][\"id\"]\n        resp = client.patch(f\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002F{contact_id}\", json=body)\n    else:\n        # 2b. None found: create a new contact.\n        resp = client.post(\"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\", json=body)\n\n    resp.raise_for_status()\n    return resp.json()\n\n\nsaved = upsert_contact(\n    client,\n    email=\"ada@example.com\",\n    properties={\"firstname\": \"Ada\", \"lastname\": \"Lovelace\", \"company\": \"Analytical Engines\"},\n)\nprint(\"Saved contact id:\", saved[\"id\"])\n",[18,41850,41851,41861,41883,41887,41892,41901,41908,41916,41924,41931,41960,41965,41970,41980,41990,41994,41998,42003,42016,42020,42026,42031,42049,42079,42085,42090,42109,42113,42117,42123,42127,42131,42141,42146,42158,42194,42198],{"__ignoreMap":258},[262,41852,41853,41855,41858],{"class":181,"line":264},[262,41854,423],{"class":377},[262,41856,41857],{"class":267}," upsert_contact",[262,41859,41860],{"class":429},"(client, email, properties):\n",[262,41862,41863,41865,41867,41869,41871,41873,41875,41878,41880],{"class":181,"line":282},[262,41864,6293],{"class":429},[262,41866,476],{"class":377},[262,41868,2276],{"class":429},[262,41870,37889],{"class":275},[262,41872,20445],{"class":429},[262,41874,37895],{"class":275},[262,41876,41877],{"class":429},": email, ",[262,41879,10661],{"class":377},[262,41881,41882],{"class":429},"properties}}\n",[262,41884,41885],{"class":181,"line":295},[262,41886,583],{"emptyLinePlaceholder":582},[262,41888,41889],{"class":181,"line":345},[262,41890,41891],{"class":291},"    # 1. Look for an existing contact with this email.\n",[262,41893,41894,41897,41899],{"class":181,"line":492},[262,41895,41896],{"class":429},"    search ",[262,41898,476],{"class":377},[262,41900,23520],{"class":429},[262,41902,41903,41906],{"class":181,"line":503},[262,41904,41905],{"class":275},"        \"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002Fsearch\"",[262,41907,1315],{"class":429},[262,41909,41910,41912,41914],{"class":181,"line":521},[262,41911,6642],{"class":611},[262,41913,476],{"class":377},[262,41915,6593],{"class":429},[262,41917,41918,41921],{"class":181,"line":537},[262,41919,41920],{"class":275},"            \"filterGroups\"",[262,41922,41923],{"class":429},": [{\n",[262,41925,41926,41929],{"class":181,"line":549},[262,41927,41928],{"class":275},"                \"filters\"",[262,41930,40701],{"class":429},[262,41932,41933,41935,41938,41940,41942,41944,41947,41949,41952,41954,41957],{"class":181,"line":570},[262,41934,3126],{"class":429},[262,41936,41937],{"class":275},"\"propertyName\"",[262,41939,1231],{"class":429},[262,41941,37895],{"class":275},[262,41943,608],{"class":429},[262,41945,41946],{"class":275},"\"operator\"",[262,41948,1231],{"class":429},[262,41950,41951],{"class":275},"\"EQ\"",[262,41953,608],{"class":429},[262,41955,41956],{"class":275},"\"value\"",[262,41958,41959],{"class":429},": email}\n",[262,41961,41962],{"class":181,"line":579},[262,41963,41964],{"class":429},"                ]\n",[262,41966,41967],{"class":181,"line":586},[262,41968,41969],{"class":429},"            }],\n",[262,41971,41972,41974,41976,41978],{"class":181,"line":591},[262,41973,37633],{"class":275},[262,41975,35333],{"class":429},[262,41977,37895],{"class":275},[262,41979,10309],{"class":429},[262,41981,41982,41984,41986,41988],{"class":181,"line":623},[262,41983,37625],{"class":275},[262,41985,1231],{"class":429},[262,41987,997],{"class":271},[262,41989,1315],{"class":429},[262,41991,41992],{"class":181,"line":634},[262,41993,6637],{"class":429},[262,41995,41996],{"class":181,"line":845},[262,41997,1011],{"class":429},[262,41999,42000],{"class":181,"line":850},[262,42001,42002],{"class":429},"    search.raise_for_status()\n",[262,42004,42005,42007,42009,42012,42014],{"class":181,"line":864},[262,42006,10694],{"class":429},[262,42008,476],{"class":377},[262,42010,42011],{"class":429}," search.json()[",[262,42013,34288],{"class":275},[262,42015,957],{"class":429},[262,42017,42018],{"class":181,"line":1683},[262,42019,583],{"emptyLinePlaceholder":582},[262,42021,42022,42024],{"class":181,"line":1688},[262,42023,3454],{"class":377},[262,42025,10653],{"class":429},[262,42027,42028],{"class":181,"line":1693},[262,42029,42030],{"class":291},"        # 2a. Found one: update it by id.\n",[262,42032,42033,42036,42038,42041,42043,42045,42047],{"class":181,"line":1728},[262,42034,42035],{"class":429},"        contact_id ",[262,42037,476],{"class":377},[262,42039,42040],{"class":429}," results[",[262,42042,102],{"class":271},[262,42044,6163],{"class":429},[262,42046,6770],{"class":275},[262,42048,957],{"class":429},[262,42050,42051,42053,42055,42058,42060,42062,42064,42066,42068,42070,42072,42074,42076],{"class":181,"line":1737},[262,42052,17037],{"class":429},[262,42054,476],{"class":377},[262,42056,42057],{"class":429}," client.patch(",[262,42059,642],{"class":377},[262,42061,38461],{"class":275},[262,42063,3039],{"class":271},[262,42065,36206],{"class":429},[262,42067,654],{"class":271},[262,42069,1176],{"class":275},[262,42071,608],{"class":429},[262,42073,17049],{"class":611},[262,42075,476],{"class":377},[262,42077,42078],{"class":429},"body)\n",[262,42080,42081,42083],{"class":181,"line":1751},[262,42082,20746],{"class":377},[262,42084,1160],{"class":429},[262,42086,42087],{"class":181,"line":1764},[262,42088,42089],{"class":291},"        # 2b. None found: create a new contact.\n",[262,42091,42092,42094,42096,42099,42101,42103,42105,42107],{"class":181,"line":1779},[262,42093,17037],{"class":429},[262,42095,476],{"class":377},[262,42097,42098],{"class":429}," client.post(",[262,42100,37682],{"class":275},[262,42102,608],{"class":429},[262,42104,17049],{"class":611},[262,42106,476],{"class":377},[262,42108,42078],{"class":429},[262,42110,42111],{"class":181,"line":1793},[262,42112,583],{"emptyLinePlaceholder":582},[262,42114,42115],{"class":181,"line":1800},[262,42116,23572],{"class":429},[262,42118,42119,42121],{"class":181,"line":1805},[262,42120,573],{"class":377},[262,42122,23901],{"class":429},[262,42124,42125],{"class":181,"line":1810},[262,42126,583],{"emptyLinePlaceholder":582},[262,42128,42129],{"class":181,"line":1823},[262,42130,583],{"emptyLinePlaceholder":582},[262,42132,42133,42136,42138],{"class":181,"line":1846},[262,42134,42135],{"class":429},"saved ",[262,42137,476],{"class":377},[262,42139,42140],{"class":429}," upsert_contact(\n",[262,42142,42143],{"class":181,"line":1861},[262,42144,42145],{"class":429},"    client,\n",[262,42147,42148,42151,42153,42156],{"class":181,"line":1866},[262,42149,42150],{"class":611},"    email",[262,42152,476],{"class":377},[262,42154,42155],{"class":275},"\"ada@example.com\"",[262,42157,1315],{"class":429},[262,42159,42160,42163,42165,42167,42169,42171,42174,42176,42178,42180,42183,42185,42187,42189,42192],{"class":181,"line":1871},[262,42161,42162],{"class":611},"    properties",[262,42164,476],{"class":377},[262,42166,3039],{"class":429},[262,42168,41729],{"class":275},[262,42170,1231],{"class":429},[262,42172,42173],{"class":275},"\"Ada\"",[262,42175,608],{"class":429},[262,42177,41734],{"class":275},[262,42179,1231],{"class":429},[262,42181,42182],{"class":275},"\"Lovelace\"",[262,42184,608],{"class":429},[262,42186,37940],{"class":275},[262,42188,1231],{"class":429},[262,42190,42191],{"class":275},"\"Analytical Engines\"",[262,42193,3143],{"class":429},[262,42195,42196],{"class":181,"line":1890},[262,42197,660],{"class":429},[262,42199,42200,42202,42204,42207,42210,42212],{"class":181,"line":1909},[262,42201,637],{"class":271},[262,42203,602],{"class":429},[262,42205,42206],{"class":275},"\"Saved contact id:\"",[262,42208,42209],{"class":429},", saved[",[262,42211,6770],{"class":275},[262,42213,3512],{"class":429},[14,42215,42216,42217,42219],{},"Run this twice with the same email and you will see the same ",[18,42218,9492],{}," both times: the first call creates the contact, the second updates it in place rather than making a duplicate.",[57,42221,42223],{"id":42222},"step-4-run-a-full-two-way-sync","Step 4: Run a full two-way sync",[14,42225,42226,42227,42229],{},"Now combine the pieces. The script below pulls everyone from HubSpot to show what you already have, then upserts a small source list (imagine it came from a spreadsheet or signup form). A short ",[18,42228,10940],{}," between writes keeps you under HubSpot's rate limit. The whole thing is wrapped so the client always closes cleanly.",[253,42231,42233],{"className":414,"code":42232,"language":416,"meta":258,"style":258},"import os\nimport time\nimport httpx\nfrom dotenv import load_dotenv\n\nload_dotenv()\nTOKEN = os.environ[\"HUBSPOT_TOKEN\"]\n\n# Contacts you want to push into HubSpot.\nSOURCE = [\n    {\"email\": \"grace@example.com\", \"firstname\": \"Grace\", \"lastname\": \"Hopper\"},\n    {\"email\": \"alan@example.com\", \"firstname\": \"Alan\", \"lastname\": \"Turing\"},\n]\n\nwith httpx.Client(\n    base_url=\"https:\u002F\u002Fapi.hubapi.com\",\n    headers={\"Authorization\": f\"Bearer {TOKEN}\"},\n    timeout=30.0,\n) as client:\n    # Pull side: see what is already there.\n    existing = fetch_all_contacts(client, properties=[\"email\"])\n    print(f\"HubSpot currently has {len(existing)} contacts\")\n\n    # Push side: upsert each source record.\n    for row in SOURCE:\n        email = row.pop(\"email\")\n        try:\n            result = upsert_contact(client, email, row)\n            print(f\"Synced {email} -> {result['id']}\")\n        except httpx.HTTPStatusError as err:\n            if err.response.status_code == 429:\n                wait = int(err.response.headers.get(\"Retry-After\", \"10\"))\n                print(f\"Rate limited; pausing {wait}s\")\n                time.sleep(wait)\n            else:\n                raise\n        time.sleep(0.2)  # stay comfortably under the rate limit\n",[18,42234,42235,42241,42247,42253,42263,42267,42271,42283,42287,42292,42301,42333,42364,42368,42372,42378,42389,42411,42421,42429,42434,42453,42475,42479,42484,42497,42511,42517,42527,42562,42573,42587,42609,42631,42636,42643,42647],{"__ignoreMap":258},[262,42236,42237,42239],{"class":181,"line":264},[262,42238,684],{"class":377},[262,42240,687],{"class":429},[262,42242,42243,42245],{"class":181,"line":282},[262,42244,684],{"class":377},[262,42246,2612],{"class":429},[262,42248,42249,42251],{"class":181,"line":295},[262,42250,684],{"class":377},[262,42252,6526],{"class":429},[262,42254,42255,42257,42259,42261],{"class":181,"line":345},[262,42256,705],{"class":377},[262,42258,708],{"class":429},[262,42260,684],{"class":377},[262,42262,713],{"class":429},[262,42264,42265],{"class":181,"line":492},[262,42266,583],{"emptyLinePlaceholder":582},[262,42268,42269],{"class":181,"line":503},[262,42270,734],{"class":429},[262,42272,42273,42275,42277,42279,42281],{"class":181,"line":521},[262,42274,23454],{"class":271},[262,42276,442],{"class":377},[262,42278,36185],{"class":429},[262,42280,36188],{"class":275},[262,42282,957],{"class":429},[262,42284,42285],{"class":181,"line":537},[262,42286,583],{"emptyLinePlaceholder":582},[262,42288,42289],{"class":181,"line":549},[262,42290,42291],{"class":291},"# Contacts you want to push into HubSpot.\n",[262,42293,42294,42297,42299],{"class":181,"line":570},[262,42295,42296],{"class":271},"SOURCE",[262,42298,442],{"class":377},[262,42300,5589],{"class":429},[262,42302,42303,42306,42308,42310,42313,42315,42317,42319,42322,42324,42326,42328,42331],{"class":181,"line":579},[262,42304,42305],{"class":429},"    {",[262,42307,37895],{"class":275},[262,42309,1231],{"class":429},[262,42311,42312],{"class":275},"\"grace@example.com\"",[262,42314,608],{"class":429},[262,42316,41729],{"class":275},[262,42318,1231],{"class":429},[262,42320,42321],{"class":275},"\"Grace\"",[262,42323,608],{"class":429},[262,42325,41734],{"class":275},[262,42327,1231],{"class":429},[262,42329,42330],{"class":275},"\"Hopper\"",[262,42332,3143],{"class":429},[262,42334,42335,42337,42339,42341,42344,42346,42348,42350,42353,42355,42357,42359,42362],{"class":181,"line":586},[262,42336,42305],{"class":429},[262,42338,37895],{"class":275},[262,42340,1231],{"class":429},[262,42342,42343],{"class":275},"\"alan@example.com\"",[262,42345,608],{"class":429},[262,42347,41729],{"class":275},[262,42349,1231],{"class":429},[262,42351,42352],{"class":275},"\"Alan\"",[262,42354,608],{"class":429},[262,42356,41734],{"class":275},[262,42358,1231],{"class":429},[262,42360,42361],{"class":275},"\"Turing\"",[262,42363,3143],{"class":429},[262,42365,42366],{"class":181,"line":591},[262,42367,957],{"class":429},[262,42369,42370],{"class":181,"line":623},[262,42371,583],{"emptyLinePlaceholder":582},[262,42373,42374,42376],{"class":181,"line":634},[262,42375,9153],{"class":377},[262,42377,37453],{"class":429},[262,42379,42380,42382,42384,42387],{"class":181,"line":845},[262,42381,37458],{"class":611},[262,42383,476],{"class":377},[262,42385,42386],{"class":275},"\"https:\u002F\u002Fapi.hubapi.com\"",[262,42388,1315],{"class":429},[262,42390,42391,42393,42395,42397,42399,42401,42403,42405,42407,42409],{"class":181,"line":850},[262,42392,37469],{"class":611},[262,42394,476],{"class":377},[262,42396,3039],{"class":429},[262,42398,16998],{"class":275},[262,42400,1231],{"class":429},[262,42402,642],{"class":377},[262,42404,6605],{"class":275},[262,42406,41369],{"class":271},[262,42408,1176],{"class":275},[262,42410,3143],{"class":429},[262,42412,42413,42415,42417,42419],{"class":181,"line":864},[262,42414,37493],{"class":611},[262,42416,476],{"class":377},[262,42418,6692],{"class":271},[262,42420,1315],{"class":429},[262,42422,42423,42425,42427],{"class":181,"line":1683},[262,42424,1000],{"class":429},[262,42426,697],{"class":377},[262,42428,23784],{"class":429},[262,42430,42431],{"class":181,"line":1688},[262,42432,42433],{"class":291},"    # Pull side: see what is already there.\n",[262,42435,42436,42439,42441,42443,42445,42447,42449,42451],{"class":181,"line":1693},[262,42437,42438],{"class":429},"    existing ",[262,42440,476],{"class":377},[262,42442,41716],{"class":429},[262,42444,37791],{"class":611},[262,42446,476],{"class":377},[262,42448,12118],{"class":429},[262,42450,37895],{"class":275},[262,42452,3512],{"class":429},[262,42454,42455,42457,42459,42461,42464,42466,42469,42471,42473],{"class":181,"line":1728},[262,42456,1089],{"class":271},[262,42458,602],{"class":429},[262,42460,642],{"class":377},[262,42462,42463],{"class":275},"\"HubSpot currently has ",[262,42465,648],{"class":271},[262,42467,42468],{"class":429},"(existing)",[262,42470,654],{"class":271},[262,42472,41757],{"class":275},[262,42474,660],{"class":429},[262,42476,42477],{"class":181,"line":1737},[262,42478,583],{"emptyLinePlaceholder":582},[262,42480,42481],{"class":181,"line":1751},[262,42482,42483],{"class":291},"    # Push side: upsert each source record.\n",[262,42485,42486,42488,42490,42492,42495],{"class":181,"line":1764},[262,42487,3074],{"class":377},[262,42489,10158],{"class":429},[262,42491,835],{"class":377},[262,42493,42494],{"class":271}," SOURCE",[262,42496,1160],{"class":429},[262,42498,42499,42502,42504,42507,42509],{"class":181,"line":1779},[262,42500,42501],{"class":429},"        email ",[262,42503,476],{"class":377},[262,42505,42506],{"class":429}," row.pop(",[262,42508,37895],{"class":275},[262,42510,660],{"class":429},[262,42512,42513,42515],{"class":181,"line":1793},[262,42514,3090],{"class":377},[262,42516,1160],{"class":429},[262,42518,42519,42522,42524],{"class":181,"line":1800},[262,42520,42521],{"class":429},"            result ",[262,42523,476],{"class":377},[262,42525,42526],{"class":429}," upsert_contact(client, email, row)\n",[262,42528,42529,42531,42533,42535,42538,42540,42543,42545,42548,42550,42552,42554,42556,42558,42560],{"class":181,"line":1805},[262,42530,3250],{"class":271},[262,42532,602],{"class":429},[262,42534,642],{"class":377},[262,42536,42537],{"class":275},"\"Synced ",[262,42539,3039],{"class":271},[262,42541,42542],{"class":429},"email",[262,42544,654],{"class":271},[262,42546,42547],{"class":275}," -> ",[262,42549,3039],{"class":271},[262,42551,24025],{"class":429},[262,42553,10188],{"class":275},[262,42555,6223],{"class":429},[262,42557,654],{"class":271},[262,42559,1176],{"class":275},[262,42561,660],{"class":429},[262,42563,42564,42566,42569,42571],{"class":181,"line":1810},[262,42565,3214],{"class":377},[262,42567,42568],{"class":429}," httpx.HTTPStatusError ",[262,42570,697],{"class":377},[262,42572,3222],{"class":429},[262,42574,42575,42577,42580,42582,42585],{"class":181,"line":1823},[262,42576,10200],{"class":377},[262,42578,42579],{"class":429}," err.response.status_code ",[262,42581,10758],{"class":377},[262,42583,42584],{"class":271}," 429",[262,42586,1160],{"class":429},[262,42588,42589,42592,42594,42596,42599,42602,42604,42607],{"class":181,"line":1846},[262,42590,42591],{"class":429},"                wait ",[262,42593,476],{"class":377},[262,42595,23813],{"class":271},[262,42597,42598],{"class":429},"(err.response.headers.get(",[262,42600,42601],{"class":275},"\"Retry-After\"",[262,42603,608],{"class":429},[262,42605,42606],{"class":275},"\"10\"",[262,42608,2684],{"class":429},[262,42610,42611,42613,42615,42617,42620,42622,42624,42626,42629],{"class":181,"line":1861},[262,42612,10208],{"class":271},[262,42614,602],{"class":429},[262,42616,642],{"class":377},[262,42618,42619],{"class":275},"\"Rate limited; pausing ",[262,42621,3039],{"class":271},[262,42623,3295],{"class":429},[262,42625,654],{"class":271},[262,42627,42628],{"class":275},"s\"",[262,42630,660],{"class":429},[262,42632,42633],{"class":181,"line":1866},[262,42634,42635],{"class":429},"                time.sleep(wait)\n",[262,42637,42638,42641],{"class":181,"line":1871},[262,42639,42640],{"class":377},"            else",[262,42642,1160],{"class":429},[262,42644,42645],{"class":181,"line":1890},[262,42646,39443],{"class":377},[262,42648,42649,42651,42653,42655],{"class":181,"line":1909},[262,42650,9055],{"class":429},[262,42652,27811],{"class":271},[262,42654,32223],{"class":429},[262,42656,42657],{"class":291},"# stay comfortably under the rate limit\n",[14,42659,42660],{},"This is the core of a repeatable sync: pull to understand the current state, then push your changes with upserts. From here you can schedule it to run on a timer or trigger it from a webhook.",[57,42662,42663],{"id":5154},"Key parameter quick-reference",[1379,42665,42666,42678],{},[1382,42667,42668],{},[1385,42669,42670,42672,42674,42676],{},[1388,42671,1390],{},[1388,42673,3795],{},[1388,42675,3798],{},[1388,42677,1396],{},[1398,42679,42680,42693,42709,42724],{},[1385,42681,42682,42686,42688,42690],{},[1403,42683,42684],{},[18,42685,16586],{},[1403,42687,439],{},[1403,42689,3868],{},[1403,42691,42692],{},"Contacts per page on the list endpoint; max is 100.",[1385,42694,42695,42699,42701,42703],{},[1403,42696,42697],{},[18,42698,18377],{},[1403,42700,3811],{},[1403,42702,219],{},[1403,42704,42705,42706,42708],{},"Paging cursor from ",[18,42707,37536],{},"; resumes the list at the next page.",[1385,42710,42711,42715,42718,42721],{},[1403,42712,42713],{},[18,42714,37791],{},[1403,42716,42717],{},"comma-separated string",[1403,42719,42720],{},"a few defaults",[1403,42722,42723],{},"Which contact fields to return; request only what you need.",[1385,42725,42726,42731,42734,42736],{},[1403,42727,42728],{},[18,42729,42730],{},"Retry-After",[1403,42732,42733],{},"response header (seconds)",[1403,42735,219],{},[1403,42737,42738],{},"On a 429, how long to wait before retrying.",[57,42740,1445],{"id":1444},[1447,42742,42743,42760,42773,42790],{},[1450,42744,42745,42749,42750,42752,42753,42756,42757,42759],{},[35,42746,42747],{},[18,42748,19656],{}," — The token is missing, mistyped, or expired. Confirm ",[18,42751,319],{}," holds the full ",[18,42754,42755],{},"pat-na1-..."," string and that ",[18,42758,8439],{}," runs before you read it. Regenerate the token in the private app if needed.",[1450,42761,42762,42767,42768,1374,42770,42772],{},[35,42763,42764],{},[18,42765,42766],{},"403 Forbidden"," — The token is valid but lacks a scope. Open your private app, add ",[18,42769,41218],{},[18,42771,41221],{},", save, and copy the refreshed token.",[1450,42774,42775,42779,42780,42782,42783,42785,42786,42789],{},[35,42776,42777],{},[18,42778,19671],{}," — You exceeded the rate limit. Read the ",[18,42781,42730],{}," header, ",[18,42784,10940],{}," for that many seconds, and retry. Adding a small ",[18,42787,42788],{},"sleep"," between writes, as in Step 4, usually prevents it.",[1450,42791,42792,42798,42799,42802,42803,42806],{},[35,42793,42794,42797],{},[18,42795,42796],{},"KeyError: 'paging'"," or an endless loop"," — You are reading the cursor incorrectly. Use ",[18,42800,42801],{},"data.get(\"paging\")"," and break when it is absent; the final page has no ",[18,42804,42805],{},"paging"," key at all.",[57,42808,2317],{"id":2316},[2322,42810,42811,42817,42827],{},[1450,42812,42813,42816],{},[35,42814,42815],{},"Use this httpx approach"," when you want full control, minimal dependencies, and a clear view of exactly what each request sends. It is ideal for scripts, scheduled jobs, and small business apps where you would rather not learn a heavier library.",[1450,42818,42819,42826],{},[35,42820,42821,42822,42825],{},"Use the official ",[18,42823,42824],{},"hubspot-api-client"," SDK"," when you call many different HubSpot objects (deals, tickets, companies) and want typed models and built-in retries. It hides the raw HTTP at the cost of an extra dependency and some abstraction.",[1450,42828,42829,42832],{},[35,42830,42831],{},"Use HubSpot's no-code imports or workflows"," when you only need a one-time CSV upload or a simple in-app automation. Reach for Python the moment you need custom logic, scheduling, or to combine HubSpot with another system.",[14,42834,42835,42836,42838,42839,42841,42842,1363],{},"Once your contacts are flowing in cleanly, the natural next step is to make them more useful: ",[51,42837,35139],{"href":37077}," fills in missing fields automatically, and ",[51,42840,36958],{"href":36957}," writes call notes straight onto the contact. Back to ",[51,42843,36938],{"href":36937},[57,42845,2381],{"id":2380},[2322,42847,42848,42853,42858,42863],{},[1450,42849,42850,42852],{},[51,42851,36938],{"href":36937}," — the main guide for connecting your CRM to Python and AI.",[1450,42854,42855,42857],{},[51,42856,35139],{"href":37077}," — auto-fill missing lead details after you sync them.",[1450,42859,42860,42862],{},[51,42861,36958],{"href":36957}," — push AI-written call summaries onto your contacts.",[1450,42864,42865,42867],{},[51,42866,26457],{"href":26456}," — the wider track this section belongs to.",[2401,42869,19746],{},{"title":258,"searchDepth":282,"depth":282,"links":42871},[42872,42873,42874,42875,42876,42877,42878,42879,42880],{"id":237,"depth":282,"text":238},{"id":41260,"depth":282,"text":41261},{"id":41453,"depth":282,"text":41454},{"id":41828,"depth":282,"text":41829},{"id":42222,"depth":282,"text":42223},{"id":5154,"depth":282,"text":42663},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Pull and push HubSpot contacts with Python and httpx: authenticate with a private-app token, paginate the list, and upsert records in under 15 minutes.",[42883,42886,42889,42892,42895],{"q":42884,"a":42885},"Do I need a paid HubSpot plan to use the contacts API?","No. The contacts CRM API is available on the free HubSpot plan. You only need to create a private app inside your account to get an access token, which takes a couple of minutes in the settings.",{"q":42887,"a":42888},"What is a private-app token in HubSpot?","It is a long-lived access token tied to a specific app you create in your HubSpot account. You pick the scopes (permissions) it gets, like reading and writing contacts, and HubSpot gives you a token string you send as a Bearer header on every request.",{"q":42890,"a":42891},"How many contacts can I fetch per request?","The list endpoint returns up to 100 contacts per page. To get more, you follow the paging cursor that HubSpot returns until it stops sending one, which signals the last page.",{"q":42893,"a":42894},"How do I update a contact instead of creating a duplicate?","Search for the contact by email first. If HubSpot returns a matching record you send a PATCH to update it by its id; if not, you send a POST to create it. This create-or-update pattern is called an upsert.",{"q":42896,"a":42897},"Why am I getting a 429 error when syncing many contacts?","You are sending requests faster than HubSpot's rate limit allows. Add a short pause between requests and retry after the number of seconds in the Retry-After response header.",{"name":42899,"steps":42900},"How to sync HubSpot contacts with Python",[42901,42904,42907,42910],{"name":42902,"text":42903},"Authenticate with a private-app token","Create a HubSpot private app, copy its access token into a .env file, and load it in Python.",{"name":42905,"text":42906},"Pull contacts with pagination","Call the contacts list endpoint and follow the paging cursor until every page is read.",{"name":42908,"text":42909},"Upsert a contact","Search by email, then PATCH the matching record or POST a new one.",{"name":42911,"text":42912},"Run a full two-way sync","Combine pulling and upserting into one script that reconciles a source list with HubSpot.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsync-hubspot-contacts-with-python",{"title":35211,"description":42881},"building-ai-powered-business-applications\u002Fcrm-data-integration\u002Fsync-hubspot-contacts-with-python\u002Findex","9Kvnl_knwb_kZcAmhWfNGTflcfngUbumI-j7Niss1nI",{"id":42919,"title":2367,"body":42920,"description":44548,"extension":2419,"faq":44549,"howto":44565,"meta":44580,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":44581,"published":2452,"seo":44582,"seoTitle":2367,"stem":44583,"__hash__":44584},"content\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fadd-memory-to-a-python-chatbot\u002Findex.md",{"type":7,"value":42921,"toc":44536},[42922,42925,42928,42935,42937,42942,42949,42967,42973,42981,42988,42994,42998,43016,43264,43275,43279,43286,43293,43502,43508,43512,43519,43719,43726,43730,43733,44319,44329,44333,44401,44403,44465,44467,44489,44492,44494,44504,44506,44533],[10,42923,2367],{"id":42924},"add-memory-to-a-python-chatbot",[14,42926,42927],{},"This guide shows you how to give a Python chatbot real conversation memory in under fifteen minutes. By the end, your bot will remember earlier turns, stay inside a token budget so it never crashes, and compress long chats into a rolling summary.",[14,42929,42930,42931,42934],{},"If you have ever built a quick chatbot and watched it forget your name one message later, the cause is simple: a chat API call is ",[35,42932,42933],{},"stateless",", meaning the model only sees exactly what you send in that one request. It has no hidden memory of previous calls. Send only the latest question and the model answers in a vacuum. Memory is not a feature you switch on; it is a habit your code builds by resending the conversation each time. This guide builds that habit step by step, then upgrades it so long conversations stay cheap and fast.",[57,42936,238],{"id":237},[14,42938,42939,42940,28989],{},"You only need a working Python setup and an API key. If you are starting from zero, follow ",[51,42941,2482],{"href":2481},[14,42943,42944,42945,42948],{},"Install the two packages used below. The ",[18,42946,42947],{},"tiktoken"," package counts tokens the same way the model does, so your budgets are accurate.",[253,42950,42952],{"className":255,"code":42951,"language":257,"meta":258,"style":258},"pip install openai tiktoken python-dotenv\n",[18,42953,42954],{"__ignoreMap":258},[262,42955,42956,42958,42960,42962,42965],{"class":181,"line":264},[262,42957,298],{"class":267},[262,42959,301],{"class":275},[262,42961,2519],{"class":275},[262,42963,42964],{"class":275}," tiktoken",[262,42966,2522],{"class":275},[14,42968,42969,42970,42972],{},"Create a ",[18,42971,319],{}," file in your project folder with your key:",[253,42974,42975],{"className":323,"code":11159,"language":325,"meta":258,"style":258},[18,42976,42977],{"__ignoreMap":258},[262,42978,42979],{"class":181,"line":264},[262,42980,11159],{},[14,42982,353,42983,356,42985,42987],{},[18,42984,319],{},[18,42986,359],{}," so your key never lands in version control.",[14,42989,42990,42991,42993],{},"If you are still fuzzy on how chat messages and roles work, the section on ",[51,42992,2487],{"href":2486}," explains the request format this guide builds on.",[57,42995,42997],{"id":42996},"step-1-keep-a-running-message-history","Step 1: Keep a running message history",[14,42999,43000,43001,43004,43005,608,43007,14716,43009,43012,43013,43015],{},"Memory starts as a plain Python list. Every entry is a dictionary with a ",[18,43002,43003],{},"role"," (one of ",[18,43006,4466],{},[18,43008,4470],{},[18,43010,43011],{},"assistant",") and the ",[18,43014,7921],{}," text. The trick is to append both sides of every exchange and resend the entire list on each call.",[253,43017,43019],{"className":414,"code":43018,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\n# The system message sets behavior and stays at the top for the whole chat.\nmessages = [\n    {\"role\": \"system\", \"content\": \"You are a friendly assistant. Be concise.\"}\n]\n\n\ndef chat(user_text: str) -> str:\n    messages.append({\"role\": \"user\", \"content\": user_text})\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=messages,\n    )\n    reply = response.choices[0].message.content\n    messages.append({\"role\": \"assistant\", \"content\": reply})\n    return reply\n\n\nprint(chat(\"Hi, my name is Sam.\"))\nprint(chat(\"What's my name?\"))  # It now answers \"Sam\" because history was resent.\n",[18,43020,43021,43027,43037,43047,43051,43055,43073,43077,43082,43091,43112,43116,43120,43124,43142,43160,43168,43178,43187,43191,43204,43222,43229,43233,43237,43249],{"__ignoreMap":258},[262,43022,43023,43025],{"class":181,"line":264},[262,43024,684],{"class":377},[262,43026,687],{"class":429},[262,43028,43029,43031,43033,43035],{"class":181,"line":282},[262,43030,705],{"class":377},[262,43032,708],{"class":429},[262,43034,684],{"class":377},[262,43036,713],{"class":429},[262,43038,43039,43041,43043,43045],{"class":181,"line":295},[262,43040,705],{"class":377},[262,43042,720],{"class":429},[262,43044,684],{"class":377},[262,43046,725],{"class":429},[262,43048,43049],{"class":181,"line":345},[262,43050,583],{"emptyLinePlaceholder":582},[262,43052,43053],{"class":181,"line":492},[262,43054,734],{"class":429},[262,43056,43057,43059,43061,43063,43065,43067,43069,43071],{"class":181,"line":503},[262,43058,739],{"class":429},[262,43060,476],{"class":377},[262,43062,1588],{"class":429},[262,43064,2674],{"class":611},[262,43066,476],{"class":377},[262,43068,26942],{"class":429},[262,43070,2681],{"class":275},[262,43072,3512],{"class":429},[262,43074,43075],{"class":181,"line":521},[262,43076,583],{"emptyLinePlaceholder":582},[262,43078,43079],{"class":181,"line":537},[262,43080,43081],{"class":291},"# The system message sets behavior and stays at the top for the whole chat.\n",[262,43083,43084,43087,43089],{"class":181,"line":549},[262,43085,43086],{"class":429},"messages ",[262,43088,476],{"class":377},[262,43090,5589],{"class":429},[262,43092,43093,43095,43097,43099,43101,43103,43105,43107,43110],{"class":181,"line":570},[262,43094,42305],{"class":429},[262,43096,1228],{"class":275},[262,43098,1231],{"class":429},[262,43100,1234],{"class":275},[262,43102,608],{"class":429},[262,43104,1239],{"class":275},[262,43106,1231],{"class":429},[262,43108,43109],{"class":275},"\"You are a friendly assistant. Be concise.\"",[262,43111,16430],{"class":429},[262,43113,43114],{"class":181,"line":579},[262,43115,957],{"class":429},[262,43117,43118],{"class":181,"line":586},[262,43119,583],{"emptyLinePlaceholder":582},[262,43121,43122],{"class":181,"line":591},[262,43123,583],{"emptyLinePlaceholder":582},[262,43125,43126,43128,43131,43134,43136,43138,43140],{"class":181,"line":623},[262,43127,423],{"class":377},[262,43129,43130],{"class":267}," chat",[262,43132,43133],{"class":429},"(user_text: ",[262,43135,433],{"class":271},[262,43137,1939],{"class":429},[262,43139,433],{"class":271},[262,43141,1160],{"class":429},[262,43143,43144,43147,43149,43151,43153,43155,43157],{"class":181,"line":634},[262,43145,43146],{"class":429},"    messages.append({",[262,43148,1228],{"class":275},[262,43150,1231],{"class":429},[262,43152,1291],{"class":275},[262,43154,608],{"class":429},[262,43156,1239],{"class":275},[262,43158,43159],{"class":429},": user_text})\n",[262,43161,43162,43164,43166],{"class":181,"line":845},[262,43163,1184],{"class":429},[262,43165,476],{"class":377},[262,43167,1189],{"class":429},[262,43169,43170,43172,43174,43176],{"class":181,"line":850},[262,43171,1194],{"class":611},[262,43173,476],{"class":377},[262,43175,1207],{"class":275},[262,43177,1315],{"class":429},[262,43179,43180,43182,43184],{"class":181,"line":864},[262,43181,1215],{"class":611},[262,43183,476],{"class":377},[262,43185,43186],{"class":429},"messages,\n",[262,43188,43189],{"class":181,"line":1683},[262,43190,1011],{"class":429},[262,43192,43193,43196,43198,43200,43202],{"class":181,"line":1688},[262,43194,43195],{"class":429},"    reply ",[262,43197,476],{"class":377},[262,43199,1326],{"class":429},[262,43201,102],{"class":271},[262,43203,1331],{"class":429},[262,43205,43206,43208,43210,43212,43215,43217,43219],{"class":181,"line":1693},[262,43207,43146],{"class":429},[262,43209,1228],{"class":275},[262,43211,1231],{"class":429},[262,43213,43214],{"class":275},"\"assistant\"",[262,43216,608],{"class":429},[262,43218,1239],{"class":275},[262,43220,43221],{"class":429},": reply})\n",[262,43223,43224,43226],{"class":181,"line":1728},[262,43225,573],{"class":377},[262,43227,43228],{"class":429}," reply\n",[262,43230,43231],{"class":181,"line":1737},[262,43232,583],{"emptyLinePlaceholder":582},[262,43234,43235],{"class":181,"line":1751},[262,43236,583],{"emptyLinePlaceholder":582},[262,43238,43239,43241,43244,43247],{"class":181,"line":1764},[262,43240,637],{"class":271},[262,43242,43243],{"class":429},"(chat(",[262,43245,43246],{"class":275},"\"Hi, my name is Sam.\"",[262,43248,2684],{"class":429},[262,43250,43251,43253,43255,43258,43261],{"class":181,"line":1779},[262,43252,637],{"class":271},[262,43254,43243],{"class":429},[262,43256,43257],{"class":275},"\"What's my name?\"",[262,43259,43260],{"class":429},"))  ",[262,43262,43263],{"class":291},"# It now answers \"Sam\" because history was resent.\n",[14,43265,43266,43267,43270,43271,43274],{},"The second call works because ",[18,43268,43269],{},"messages"," carried the first exchange along with the new question. Remove the two ",[18,43272,43273],{},"append"," lines and the bot forgets instantly. That is the whole idea of memory: keep the list, resend the list.",[57,43276,43278],{"id":43277},"step-2-count-tokens-and-trim-to-a-budget","Step 2: Count tokens and trim to a budget",[14,43280,43281,43282,43285],{},"A growing list has a hard ceiling. Every model has a ",[35,43283,43284],{},"context window",", the maximum number of tokens (chunks of text, roughly four characters each) it can read at once. Push past it and you get a context-length error and a failed call. Even before that limit, longer histories cost more, because you pay for input tokens on every turn.",[14,43287,43288,43289,43292],{},"The fix is a ",[35,43290,43291],{},"token budget",": pick a comfortable ceiling, measure the history, and drop the oldest turns once you cross it. Always keep the system message and trim from the front.",[253,43294,43296],{"className":414,"code":43295,"language":416,"meta":258,"style":258},"import tiktoken\n\nENCODING = tiktoken.get_encoding(\"o200k_base\")  # used by gpt-4o family models\n\n\ndef count_tokens(msgs: list[dict]) -> int:\n    # ~4 tokens of overhead per message wrap the role and formatting.\n    return sum(len(ENCODING.encode(m[\"content\"])) + 4 for m in msgs)\n\n\ndef trim_to_budget(msgs: list[dict], max_tokens: int = 3000) -> list[dict]:\n    system = msgs[0]\n    rest = msgs[1:]\n    while rest and count_tokens([system] + rest) > max_tokens:\n        rest.pop(0)  # drop the oldest non-system turn\n    return [system] + rest\n",[18,43297,43298,43305,43309,43327,43331,43335,43353,43358,43394,43398,43402,43429,43442,43456,43478,43490],{"__ignoreMap":258},[262,43299,43300,43302],{"class":181,"line":264},[262,43301,684],{"class":377},[262,43303,43304],{"class":429}," tiktoken\n",[262,43306,43307],{"class":181,"line":282},[262,43308,583],{"emptyLinePlaceholder":582},[262,43310,43311,43314,43316,43319,43322,43324],{"class":181,"line":295},[262,43312,43313],{"class":271},"ENCODING",[262,43315,442],{"class":377},[262,43317,43318],{"class":429}," tiktoken.get_encoding(",[262,43320,43321],{"class":275},"\"o200k_base\"",[262,43323,32223],{"class":429},[262,43325,43326],{"class":291},"# used by gpt-4o family models\n",[262,43328,43329],{"class":181,"line":345},[262,43330,583],{"emptyLinePlaceholder":582},[262,43332,43333],{"class":181,"line":492},[262,43334,583],{"emptyLinePlaceholder":582},[262,43336,43337,43339,43342,43345,43347,43349,43351],{"class":181,"line":503},[262,43338,423],{"class":377},[262,43340,43341],{"class":267}," count_tokens",[262,43343,43344],{"class":429},"(msgs: list[",[262,43346,5869],{"class":271},[262,43348,13681],{"class":429},[262,43350,439],{"class":271},[262,43352,1160],{"class":429},[262,43354,43355],{"class":181,"line":521},[262,43356,43357],{"class":291},"    # ~4 tokens of overhead per message wrap the role and formatting.\n",[262,43359,43360,43362,43364,43366,43368,43370,43372,43375,43377,43380,43382,43384,43386,43389,43391],{"class":181,"line":537},[262,43361,573],{"class":377},[262,43363,10732],{"class":271},[262,43365,602],{"class":429},[262,43367,29318],{"class":271},[262,43369,602],{"class":429},[262,43371,43313],{"class":271},[262,43373,43374],{"class":429},".encode(m[",[262,43376,1239],{"class":275},[262,43378,43379],{"class":429},"])) ",[262,43381,531],{"class":377},[262,43383,3014],{"class":271},[262,43385,10739],{"class":377},[262,43387,43388],{"class":429}," m ",[262,43390,835],{"class":377},[262,43392,43393],{"class":429}," msgs)\n",[262,43395,43396],{"class":181,"line":549},[262,43397,583],{"emptyLinePlaceholder":582},[262,43399,43400],{"class":181,"line":570},[262,43401,583],{"emptyLinePlaceholder":582},[262,43403,43404,43406,43409,43411,43413,43416,43418,43420,43423,43425,43427],{"class":181,"line":579},[262,43405,423],{"class":377},[262,43407,43408],{"class":267}," trim_to_budget",[262,43410,43344],{"class":429},[262,43412,5869],{"class":271},[262,43414,43415],{"class":429},"], max_tokens: ",[262,43417,439],{"class":271},[262,43419,442],{"class":377},[262,43421,43422],{"class":271}," 3000",[262,43424,458],{"class":429},[262,43426,5869],{"class":271},[262,43428,463],{"class":429},[262,43430,43431,43433,43435,43438,43440],{"class":181,"line":586},[262,43432,7578],{"class":429},[262,43434,476],{"class":377},[262,43436,43437],{"class":429}," msgs[",[262,43439,102],{"class":271},[262,43441,957],{"class":429},[262,43443,43444,43447,43449,43451,43453],{"class":181,"line":591},[262,43445,43446],{"class":429},"    rest ",[262,43448,476],{"class":377},[262,43450,43437],{"class":429},[262,43452,997],{"class":271},[262,43454,43455],{"class":429},":]\n",[262,43457,43458,43460,43463,43465,43468,43470,43473,43475],{"class":181,"line":623},[262,43459,506],{"class":377},[262,43461,43462],{"class":429}," rest ",[262,43464,6101],{"class":377},[262,43466,43467],{"class":429}," count_tokens([system] ",[262,43469,531],{"class":377},[262,43471,43472],{"class":429}," rest) ",[262,43474,8086],{"class":377},[262,43476,43477],{"class":429}," max_tokens:\n",[262,43479,43480,43483,43485,43487],{"class":181,"line":634},[262,43481,43482],{"class":429},"        rest.pop(",[262,43484,102],{"class":271},[262,43486,32223],{"class":429},[262,43488,43489],{"class":291},"# drop the oldest non-system turn\n",[262,43491,43492,43494,43497,43499],{"class":181,"line":845},[262,43493,573],{"class":377},[262,43495,43496],{"class":429}," [system] ",[262,43498,531],{"class":377},[262,43500,43501],{"class":429}," rest\n",[14,43503,18789,43504,43507],{},[18,43505,43506],{},"trim_to_budget"," right before each API request. The bot now stays inside the window forever, no matter how long the chat runs. The downside is honest: trimmed turns are gone, so the bot may forget early details. Step 3 fixes that.",[57,43509,43511],{"id":43510},"step-3-add-rolling-summary-memory","Step 3: Add rolling-summary memory",[14,43513,43514,43515,43518],{},"Trimming protects you from crashes but throws away context. ",[35,43516,43517],{},"Rolling-summary memory"," keeps the best of both worlds: instead of deleting old turns, you ask the model to compress them into a short recap, then store that recap in the system context. The bot keeps a running gist of the whole conversation plus the most recent turns word for word.",[253,43520,43522],{"className":414,"code":43521,"language":416,"meta":258,"style":258},"def summarize(old_msgs: list[dict], prior_summary: str = \"\") -> str:\n    transcript = \"\\n\".join(f\"{m['role']}: {m['content']}\" for m in old_msgs)\n    prompt = (\n        \"Update the running summary of this conversation. \"\n        \"Keep names, decisions, and open questions. Be under 120 words.\\n\\n\"\n        f\"Previous summary:\\n{prior_summary or '(none)'}\\n\\n\"\n        f\"New messages:\\n{transcript}\"\n    )\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return response.choices[0].message.content\n",[18,43523,43524,43551,43605,43613,43618,43627,43648,43663,43667,43675,43685,43705,43709],{"__ignoreMap":258},[262,43525,43526,43528,43531,43534,43536,43539,43541,43543,43545,43547,43549],{"class":181,"line":264},[262,43527,423],{"class":377},[262,43529,43530],{"class":267}," summarize",[262,43532,43533],{"class":429},"(old_msgs: list[",[262,43535,5869],{"class":271},[262,43537,43538],{"class":429},"], prior_summary: ",[262,43540,433],{"class":271},[262,43542,442],{"class":377},[262,43544,6332],{"class":275},[262,43546,1939],{"class":429},[262,43548,433],{"class":271},[262,43550,1160],{"class":429},[262,43552,43553,43555,43557,43559,43561,43563,43565,43567,43569,43571,43574,43577,43579,43581,43583,43585,43587,43590,43592,43594,43596,43598,43600,43602],{"class":181,"line":282},[262,43554,40084],{"class":429},[262,43556,476],{"class":377},[262,43558,1170],{"class":275},[262,43560,2137],{"class":271},[262,43562,1176],{"class":275},[262,43564,2023],{"class":429},[262,43566,642],{"class":377},[262,43568,1176],{"class":275},[262,43570,3039],{"class":271},[262,43572,43573],{"class":429},"m[",[262,43575,43576],{"class":275},"'role'",[262,43578,6223],{"class":429},[262,43580,654],{"class":271},[262,43582,1231],{"class":275},[262,43584,3039],{"class":271},[262,43586,43573],{"class":429},[262,43588,43589],{"class":275},"'content'",[262,43591,6223],{"class":429},[262,43593,654],{"class":271},[262,43595,1176],{"class":275},[262,43597,10739],{"class":377},[262,43599,43388],{"class":429},[262,43601,835],{"class":377},[262,43603,43604],{"class":429}," old_msgs)\n",[262,43606,43607,43609,43611],{"class":181,"line":295},[262,43608,18006],{"class":429},[262,43610,476],{"class":377},[262,43612,984],{"class":429},[262,43614,43615],{"class":181,"line":345},[262,43616,43617],{"class":275},"        \"Update the running summary of this conversation. \"\n",[262,43619,43620,43623,43625],{"class":181,"line":492},[262,43621,43622],{"class":275},"        \"Keep names, decisions, and open questions. Be under 120 words.",[262,43624,1173],{"class":271},[262,43626,1257],{"class":275},[262,43628,43629,43631,43634,43636,43639,43641,43644,43646],{"class":181,"line":503},[262,43630,2840],{"class":377},[262,43632,43633],{"class":275},"\"Previous summary:",[262,43635,1268],{"class":271},[262,43637,43638],{"class":429},"prior_summary ",[262,43640,8923],{"class":377},[262,43642,43643],{"class":275}," '(none)'",[262,43645,4644],{"class":271},[262,43647,1257],{"class":275},[262,43649,43650,43652,43655,43657,43659,43661],{"class":181,"line":521},[262,43651,2840],{"class":377},[262,43653,43654],{"class":275},"\"New messages:",[262,43656,1268],{"class":271},[262,43658,40270],{"class":429},[262,43660,654],{"class":271},[262,43662,1257],{"class":275},[262,43664,43665],{"class":181,"line":537},[262,43666,1011],{"class":429},[262,43668,43669,43671,43673],{"class":181,"line":549},[262,43670,1184],{"class":429},[262,43672,476],{"class":377},[262,43674,1189],{"class":429},[262,43676,43677,43679,43681,43683],{"class":181,"line":570},[262,43678,1194],{"class":611},[262,43680,476],{"class":377},[262,43682,1207],{"class":275},[262,43684,1315],{"class":429},[262,43686,43687,43689,43691,43693,43695,43697,43699,43701,43703],{"class":181,"line":579},[262,43688,1215],{"class":611},[262,43690,476],{"class":377},[262,43692,8856],{"class":429},[262,43694,1228],{"class":275},[262,43696,1231],{"class":429},[262,43698,1291],{"class":275},[262,43700,608],{"class":429},[262,43702,1239],{"class":275},[262,43704,18141],{"class":429},[262,43706,43707],{"class":181,"line":586},[262,43708,1011],{"class":429},[262,43710,43711,43713,43715,43717],{"class":181,"line":591},[262,43712,573],{"class":377},[262,43714,1326],{"class":429},[262,43716,102],{"class":271},[262,43718,1331],{"class":429},[14,43720,43721,43722,43725],{},"When the history grows too large, peel off the oldest turns, feed them to ",[18,43723,43724],{},"summarize",", and fold the result back into the system message. The recap costs a fraction of the tokens the raw turns would, so the bot remembers a long chat without resending all of it.",[57,43727,43729],{"id":43728},"step-4-wrap-it-in-a-reusable-chat-loop","Step 4: Wrap it in a reusable chat loop",[14,43731,43732],{},"Now combine the pieces into one small class you can drop into any project. It holds the recent messages, the running summary, and the budget logic in one place.",[253,43734,43736],{"className":414,"code":43735,"language":416,"meta":258,"style":258},"class ChatMemory:\n    def __init__(self, system: str, max_tokens: int = 3000, keep_recent: int = 6):\n        self.base_system = system\n        self.summary = \"\"\n        self.recent: list[dict] = []\n        self.max_tokens = max_tokens\n        self.keep_recent = keep_recent  # turns to keep verbatim after summarizing\n\n    def _system_message(self) -> dict:\n        text = self.base_system\n        if self.summary:\n            text += f\"\\n\\nConversation so far:\\n{self.summary}\"\n        return {\"role\": \"system\", \"content\": text}\n\n    def _maybe_summarize(self) -> None:\n        msgs = [self._system_message()] + self.recent\n        if count_tokens(msgs) \u003C= self.max_tokens:\n            return\n        # Summarize everything except the most recent turns, then drop them.\n        to_summarize = self.recent[: -self.keep_recent] or self.recent[:1]\n        self.summary = summarize(to_summarize, self.summary)\n        self.recent = self.recent[len(to_summarize):]\n\n    def ask(self, user_text: str) -> str:\n        self.recent.append({\"role\": \"user\", \"content\": user_text})\n        self._maybe_summarize()\n        response = client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            messages=[self._system_message()] + self.recent,\n        )\n        reply = response.choices[0].message.content\n        self.recent.append({\"role\": \"assistant\", \"content\": reply})\n        return reply\n\n\nif __name__ == \"__main__\":\n    bot = ChatMemory(system=\"You are a friendly assistant. Be concise.\")\n    print(\"Chatbot ready. Type 'quit' to exit.\")\n    while True:\n        text = input(\"\\nYou: \").strip()\n        if text.lower() in {\"quit\", \"exit\"}:\n            break\n        if not text:\n            continue\n        print(f\"\\nBot: {bot.ask(text)}\")\n",[18,43737,43738,43747,43781,43794,43805,43820,43832,43847,43851,43865,43877,43886,43911,43929,43933,43946,43968,43982,43986,43991,44021,44037,44056,44060,44078,44097,44104,44112,44122,44141,44145,44158,44176,44182,44186,44190,44202,44220,44231,44239,44258,44277,44281,44289,44293],{"__ignoreMap":258},[262,43739,43740,43742,43745],{"class":181,"line":264},[262,43741,7374],{"class":377},[262,43743,43744],{"class":267}," ChatMemory",[262,43746,1160],{"class":429},[262,43748,43749,43752,43755,43758,43760,43763,43765,43767,43769,43772,43774,43776,43779],{"class":181,"line":282},[262,43750,43751],{"class":377},"    def",[262,43753,43754],{"class":271}," __init__",[262,43756,43757],{"class":429},"(self, system: ",[262,43759,433],{"class":271},[262,43761,43762],{"class":429},", max_tokens: ",[262,43764,439],{"class":271},[262,43766,442],{"class":377},[262,43768,43422],{"class":271},[262,43770,43771],{"class":429},", keep_recent: ",[262,43773,439],{"class":271},[262,43775,442],{"class":377},[262,43777,43778],{"class":271}," 6",[262,43780,8192],{"class":429},[262,43782,43783,43786,43789,43791],{"class":181,"line":295},[262,43784,43785],{"class":271},"        self",[262,43787,43788],{"class":429},".base_system ",[262,43790,476],{"class":377},[262,43792,43793],{"class":429}," system\n",[262,43795,43796,43798,43801,43803],{"class":181,"line":345},[262,43797,43785],{"class":271},[262,43799,43800],{"class":429},".summary ",[262,43802,476],{"class":377},[262,43804,2908],{"class":275},[262,43806,43807,43809,43812,43814,43816,43818],{"class":181,"line":492},[262,43808,43785],{"class":271},[262,43810,43811],{"class":429},".recent: list[",[262,43813,5869],{"class":271},[262,43815,2903],{"class":429},[262,43817,476],{"class":377},[262,43819,489],{"class":429},[262,43821,43822,43824,43827,43829],{"class":181,"line":503},[262,43823,43785],{"class":271},[262,43825,43826],{"class":429},".max_tokens ",[262,43828,476],{"class":377},[262,43830,43831],{"class":429}," max_tokens\n",[262,43833,43834,43836,43839,43841,43844],{"class":181,"line":521},[262,43835,43785],{"class":271},[262,43837,43838],{"class":429},".keep_recent ",[262,43840,476],{"class":377},[262,43842,43843],{"class":429}," keep_recent  ",[262,43845,43846],{"class":291},"# turns to keep verbatim after summarizing\n",[262,43848,43849],{"class":181,"line":537},[262,43850,583],{"emptyLinePlaceholder":582},[262,43852,43853,43855,43858,43861,43863],{"class":181,"line":549},[262,43854,43751],{"class":377},[262,43856,43857],{"class":267}," _system_message",[262,43859,43860],{"class":429},"(self) -> ",[262,43862,5869],{"class":271},[262,43864,1160],{"class":429},[262,43866,43867,43869,43871,43874],{"class":181,"line":570},[262,43868,18264],{"class":429},[262,43870,476],{"class":377},[262,43872,43873],{"class":271}," self",[262,43875,43876],{"class":429},".base_system\n",[262,43878,43879,43881,43883],{"class":181,"line":579},[262,43880,2268],{"class":377},[262,43882,43873],{"class":271},[262,43884,43885],{"class":429},".summary:\n",[262,43887,43888,43890,43892,43894,43896,43898,43901,43904,43907,43909],{"class":181,"line":586},[262,43889,18347],{"class":429},[262,43891,555],{"class":377},[262,43893,10178],{"class":377},[262,43895,1176],{"class":275},[262,43897,1173],{"class":271},[262,43899,43900],{"class":275},"Conversation so far:",[262,43902,43903],{"class":271},"\\n{self",[262,43905,43906],{"class":429},".summary",[262,43908,654],{"class":271},[262,43910,1257],{"class":275},[262,43912,43913,43915,43917,43919,43921,43923,43925,43927],{"class":181,"line":591},[262,43914,8066],{"class":377},[262,43916,2276],{"class":429},[262,43918,1228],{"class":275},[262,43920,1231],{"class":429},[262,43922,1234],{"class":275},[262,43924,608],{"class":429},[262,43926,1239],{"class":275},[262,43928,21344],{"class":429},[262,43930,43931],{"class":181,"line":623},[262,43932,583],{"emptyLinePlaceholder":582},[262,43934,43935,43937,43940,43942,43944],{"class":181,"line":634},[262,43936,43751],{"class":377},[262,43938,43939],{"class":267}," _maybe_summarize",[262,43941,43860],{"class":429},[262,43943,8471],{"class":271},[262,43945,1160],{"class":429},[262,43947,43948,43951,43953,43955,43958,43961,43963,43965],{"class":181,"line":845},[262,43949,43950],{"class":429},"        msgs ",[262,43952,476],{"class":377},[262,43954,10563],{"class":429},[262,43956,43957],{"class":271},"self",[262,43959,43960],{"class":429},"._system_message()] ",[262,43962,531],{"class":377},[262,43964,43873],{"class":271},[262,43966,43967],{"class":429},".recent\n",[262,43969,43970,43972,43975,43977,43979],{"class":181,"line":850},[262,43971,2268],{"class":377},[262,43973,43974],{"class":429}," count_tokens(msgs) ",[262,43976,8983],{"class":377},[262,43978,43873],{"class":271},[262,43980,43981],{"class":429},".max_tokens:\n",[262,43983,43984],{"class":181,"line":864},[262,43985,23705],{"class":377},[262,43987,43988],{"class":181,"line":1683},[262,43989,43990],{"class":291},"        # Summarize everything except the most recent turns, then drop them.\n",[262,43992,43993,43996,43998,44000,44003,44005,44007,44010,44012,44014,44017,44019],{"class":181,"line":1688},[262,43994,43995],{"class":429},"        to_summarize ",[262,43997,476],{"class":377},[262,43999,43873],{"class":271},[262,44001,44002],{"class":429},".recent[: ",[262,44004,561],{"class":377},[262,44006,43957],{"class":271},[262,44008,44009],{"class":429},".keep_recent] ",[262,44011,8923],{"class":377},[262,44013,43873],{"class":271},[262,44015,44016],{"class":429},".recent[:",[262,44018,997],{"class":271},[262,44020,957],{"class":429},[262,44022,44023,44025,44027,44029,44032,44034],{"class":181,"line":1693},[262,44024,43785],{"class":271},[262,44026,43800],{"class":429},[262,44028,476],{"class":377},[262,44030,44031],{"class":429}," summarize(to_summarize, ",[262,44033,43957],{"class":271},[262,44035,44036],{"class":429},".summary)\n",[262,44038,44039,44041,44044,44046,44048,44051,44053],{"class":181,"line":1728},[262,44040,43785],{"class":271},[262,44042,44043],{"class":429},".recent ",[262,44045,476],{"class":377},[262,44047,43873],{"class":271},[262,44049,44050],{"class":429},".recent[",[262,44052,29318],{"class":271},[262,44054,44055],{"class":429},"(to_summarize):]\n",[262,44057,44058],{"class":181,"line":1737},[262,44059,583],{"emptyLinePlaceholder":582},[262,44061,44062,44064,44067,44070,44072,44074,44076],{"class":181,"line":1751},[262,44063,43751],{"class":377},[262,44065,44066],{"class":267}," ask",[262,44068,44069],{"class":429},"(self, user_text: ",[262,44071,433],{"class":271},[262,44073,1939],{"class":429},[262,44075,433],{"class":271},[262,44077,1160],{"class":429},[262,44079,44080,44082,44085,44087,44089,44091,44093,44095],{"class":181,"line":1764},[262,44081,43785],{"class":271},[262,44083,44084],{"class":429},".recent.append({",[262,44086,1228],{"class":275},[262,44088,1231],{"class":429},[262,44090,1291],{"class":275},[262,44092,608],{"class":429},[262,44094,1239],{"class":275},[262,44096,43159],{"class":429},[262,44098,44099,44101],{"class":181,"line":1779},[262,44100,43785],{"class":271},[262,44102,44103],{"class":429},"._maybe_summarize()\n",[262,44105,44106,44108,44110],{"class":181,"line":1793},[262,44107,21490],{"class":429},[262,44109,476],{"class":377},[262,44111,1189],{"class":429},[262,44113,44114,44116,44118,44120],{"class":181,"line":1800},[262,44115,14214],{"class":611},[262,44117,476],{"class":377},[262,44119,1207],{"class":275},[262,44121,1315],{"class":429},[262,44123,44124,44126,44128,44130,44132,44134,44136,44138],{"class":181,"line":1805},[262,44125,27253],{"class":611},[262,44127,476],{"class":377},[262,44129,12118],{"class":429},[262,44131,43957],{"class":271},[262,44133,43960],{"class":429},[262,44135,531],{"class":377},[262,44137,43873],{"class":271},[262,44139,44140],{"class":429},".recent,\n",[262,44142,44143],{"class":181,"line":1810},[262,44144,6288],{"class":429},[262,44146,44147,44150,44152,44154,44156],{"class":181,"line":1823},[262,44148,44149],{"class":429},"        reply ",[262,44151,476],{"class":377},[262,44153,1326],{"class":429},[262,44155,102],{"class":271},[262,44157,1331],{"class":429},[262,44159,44160,44162,44164,44166,44168,44170,44172,44174],{"class":181,"line":1846},[262,44161,43785],{"class":271},[262,44163,44084],{"class":429},[262,44165,1228],{"class":275},[262,44167,1231],{"class":429},[262,44169,43214],{"class":275},[262,44171,608],{"class":429},[262,44173,1239],{"class":275},[262,44175,43221],{"class":429},[262,44177,44178,44180],{"class":181,"line":1861},[262,44179,8066],{"class":377},[262,44181,43228],{"class":429},[262,44183,44184],{"class":181,"line":1866},[262,44185,583],{"emptyLinePlaceholder":582},[262,44187,44188],{"class":181,"line":1871},[262,44189,583],{"emptyLinePlaceholder":582},[262,44191,44192,44194,44196,44198,44200],{"class":181,"line":1890},[262,44193,2210],{"class":377},[262,44195,2213],{"class":271},[262,44197,2216],{"class":377},[262,44199,2219],{"class":275},[262,44201,1160],{"class":429},[262,44203,44204,44207,44209,44212,44214,44216,44218],{"class":181,"line":1909},[262,44205,44206],{"class":429},"    bot ",[262,44208,476],{"class":377},[262,44210,44211],{"class":429}," ChatMemory(",[262,44213,4466],{"class":611},[262,44215,476],{"class":377},[262,44217,43109],{"class":275},[262,44219,660],{"class":429},[262,44221,44222,44224,44226,44229],{"class":181,"line":1914},[262,44223,1089],{"class":271},[262,44225,602],{"class":429},[262,44227,44228],{"class":275},"\"Chatbot ready. Type 'quit' to exit.\"",[262,44230,660],{"class":429},[262,44232,44233,44235,44237],{"class":181,"line":1919},[262,44234,506],{"class":377},[262,44236,2241],{"class":271},[262,44238,1160],{"class":429},[262,44240,44241,44243,44245,44247,44249,44251,44253,44256],{"class":181,"line":1946},[262,44242,18264],{"class":429},[262,44244,476],{"class":377},[262,44246,2254],{"class":271},[262,44248,602],{"class":429},[262,44250,1176],{"class":275},[262,44252,2137],{"class":271},[262,44254,44255],{"class":275},"You: \"",[262,44257,2262],{"class":429},[262,44259,44260,44262,44265,44267,44269,44271,44273,44275],{"class":181,"line":1959},[262,44261,2268],{"class":377},[262,44263,44264],{"class":429}," text.lower() ",[262,44266,835],{"class":377},[262,44268,2276],{"class":429},[262,44270,2279],{"class":275},[262,44272,608],{"class":429},[262,44274,2284],{"class":275},[262,44276,2287],{"class":429},[262,44278,44279],{"class":181,"line":1996},[262,44280,2293],{"class":377},[262,44282,44283,44285,44287],{"class":181,"line":2012},[262,44284,2268],{"class":377},[262,44286,2818],{"class":377},[262,44288,18359],{"class":429},[262,44290,44291],{"class":181,"line":2040},[262,44292,17320],{"class":377},[262,44294,44295,44297,44299,44301,44303,44305,44308,44310,44313,44315,44317],{"class":181,"line":2045},[262,44296,2299],{"class":271},[262,44298,602],{"class":429},[262,44300,642],{"class":377},[262,44302,1176],{"class":275},[262,44304,2137],{"class":271},[262,44306,44307],{"class":275},"Bot: ",[262,44309,3039],{"class":271},[262,44311,44312],{"class":429},"bot.ask(text)",[262,44314,654],{"class":271},[262,44316,1176],{"class":275},[262,44318,660],{"class":429},[14,44320,44321,44322,1374,44325,44328],{},"Run it, chat for a while, and watch it stay responsive past the point a naive bot would crash or forget. To keep that memory after the program closes, save ",[18,44323,44324],{},"bot.summary",[18,44326,44327],{},"bot.recent"," to a file or database between sessions.",[57,44330,44332],{"id":44331},"key-parameters","Key parameters",[1379,44334,44335,44347],{},[1382,44336,44337],{},[1385,44338,44339,44341,44343,44345],{},[1388,44340,1390],{},[1388,44342,3795],{},[1388,44344,3798],{},[1388,44346,1396],{},[1398,44348,44349,44366,44384],{},[1385,44350,44351,44355,44359,44363],{},[1403,44352,44353],{},[18,44354,3846],{},[1403,44356,44357],{},[18,44358,439],{},[1403,44360,44361],{},[18,44362,16417],{},[1403,44364,44365],{},"Token ceiling for system plus recent messages before a summary is triggered. Raise it for richer memory, lower it to cut cost.",[1385,44367,44368,44373,44377,44381],{},[1403,44369,44370],{},[18,44371,44372],{},"keep_recent",[1403,44374,44375],{},[18,44376,439],{},[1403,44378,44379],{},[18,44380,221],{},[1403,44382,44383],{},"How many of the latest turns stay word for word after summarizing. Higher keeps more verbatim detail.",[1385,44385,44386,44390,44394,44398],{},[1403,44387,44388],{},[18,44389,805],{},[1403,44391,44392],{},[18,44393,433],{},[1403,44395,44396],{},[18,44397,1207],{},[1403,44399,44400],{},"The chat model. Cheap models are fine for both replies and summaries.",[57,44402,1445],{"id":1444},[1447,44404,44405,44429,44438,44451],{},[1450,44406,44407,44412,44413,407,44415,44418,44419,44422,44423,44425,44426,44428],{},[35,44408,44409,44411],{},[18,44410,1471],{}," mentioning maximum context length."," Your history outgrew the window. Make sure ",[18,44414,43506],{},[18,44416,44417],{},"_maybe_summarize"," runs ",[27,44420,44421],{},"before"," every call, and lower ",[18,44424,3846],{}," to leave headroom for the model's reply. See ",[51,44427,1513],{"href":1512}," for the full breakdown.",[1450,44430,44431,44434,44435,44437],{},[35,44432,44433],{},"The bot still forgets things after summarizing."," Your summary prompt is dropping key facts. Tell it explicitly to preserve names, decisions, and numbers, and raise ",[18,44436,44372],{}," so more recent turns stay verbatim.",[1450,44439,44440,44444,44445,44447,44448,44450],{},[35,44441,44442,1363],{},[18,44443,28794],{}," Your key did not load. Confirm ",[18,44446,319],{}," sits in the folder you run the script from and that ",[18,44449,8439],{}," runs before you read the variable.",[1450,44452,44453,44456,44457,44460,44461,44464],{},[35,44454,44455],{},"Token counts look wrong or off by a lot."," You picked the wrong encoding. Use ",[18,44458,44459],{},"o200k_base"," for the gpt-4o family; older models use ",[18,44462,44463],{},"cl100k_base",". A mismatched encoding makes your budget unreliable.",[57,44466,2317],{"id":2316},[2322,44468,44469,44475,44480],{},[1450,44470,44471,44474],{},[35,44472,44473],{},"Full message history"," (Step 1) is best for short, self-contained chats: support tickets, quick Q&A, demos. It is the simplest and most faithful, but cost and risk grow with every turn, so it does not suit long sessions.",[1450,44476,44477,44479],{},[35,44478,43517],{}," (Steps 3-4) fits long, ongoing conversations where the gist matters more than every word: coaching bots, multi-step assistants, tutors. It keeps cost flat, at the price of some lost detail in the summarized parts.",[1450,44481,44482,44485,44486,1363],{},[35,44483,44484],{},"Vector memory"," is the right tool when the bot must recall specific facts from a large knowledge base or many past sessions, rather than just the current thread. Instead of resending text, you retrieve only the relevant chunks per question. That is the approach in ",[51,44487,5],{"href":44488},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fconnect-a-chatbot-to-your-docs-with-rag\u002F",[14,44490,44491],{},"Many production bots combine all three: a rolling summary for the live thread, vector memory for long-term recall, and recent turns kept verbatim.",[57,44493,2355],{"id":2354},[14,44495,44496,44497,44499,44500,28880,44502,1363],{},"With memory in place, make replies feel instant by sending tokens as they arrive in ",[51,44498,2362],{"href":2361},", then give your bot real knowledge to draw on in ",[51,44501,5],{"href":44488},[51,44503,54],{"href":53},[57,44505,2381],{"id":2380},[2322,44507,44508,44513,44518,44523,44528],{},[1450,44509,44510,44512],{},[51,44511,54],{"href":53}," — the main guide for building chatbots end to end.",[1450,44514,44515,44517],{},[51,44516,2362],{"href":2361}," — make replies appear word by word.",[1450,44519,44520,44522],{},[51,44521,5],{"href":44488}," — add vector memory and source-grounded answers.",[1450,44524,44525,44527],{},[51,44526,2372],{"href":2371}," — a framework-based take on session memory.",[1450,44529,44530,44532],{},[51,44531,1513],{"href":1512}," — handle histories that outgrow the window.",[2401,44534,44535],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":258,"searchDepth":282,"depth":282,"links":44537},[44538,44539,44540,44541,44542,44543,44544,44545,44546,44547],{"id":237,"depth":282,"text":238},{"id":42996,"depth":282,"text":42997},{"id":43277,"depth":282,"text":43278},{"id":43510,"depth":282,"text":43511},{"id":43728,"depth":282,"text":43729},{"id":44331,"depth":282,"text":44332},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Give your Python chatbot real conversation memory: keep message history, trim by token budget, and add rolling-summary memory for long chats with the openai SDK.",[44550,44553,44556,44559,44562],{"q":44551,"a":44552},"Why does my chatbot forget what I just said?","Each API call is stateless, meaning the model only sees the messages you send in that single request. If you only send the latest user message, the model has no record of earlier turns. Memory is something your code adds by resending the running conversation every time.",{"q":44554,"a":44555},"How do I add memory to an OpenAI chatbot in Python?","Keep a Python list of message dictionaries with roles of system, user, and assistant. Append every user message and every reply to the list, then send the whole list on each call. That growing list is the chatbot's memory.",{"q":44557,"a":44558},"How many past messages should I keep?","Keep as many as fit inside a token budget you set, usually a few thousand tokens below the model's context limit. Count tokens with tiktoken and drop or summarize the oldest turns once you exceed the budget.",{"q":44560,"a":44561},"What is rolling-summary memory?","Rolling-summary memory replaces the oldest messages with a short AI-written summary of them. The chatbot keeps a compact recap of early conversation plus the most recent verbatim turns, so it remembers long chats without sending every message every time.",{"q":44563,"a":44564},"Does adding memory cost more money?","Yes, because you resend prior messages on every call and pay for those input tokens again. Trimming by token budget and using rolling summaries keeps the cost flat instead of growing with every turn.",{"name":44566,"steps":44567},"How to add memory to a Python chatbot",[44568,44571,44574,44577],{"name":44569,"text":44570},"Keep a running message history","Store every user message and assistant reply in a Python list and resend the whole list on each API call.",{"name":44572,"text":44573},"Count tokens and trim to a budget","Measure the history with tiktoken and drop the oldest turns once it exceeds a token budget you set.",{"name":44575,"text":44576},"Add rolling-summary memory","Summarize trimmed-off turns into a short recap and keep that recap in the system context for long chats.",{"name":44578,"text":44579},"Wrap it in a reusable chat loop","Combine history, trimming, and summary into one ChatMemory class you can drop into any chatbot.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fadd-memory-to-a-python-chatbot",{"title":2367,"description":44548},"building-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fadd-memory-to-a-python-chatbot\u002Findex","FtNz7lgrEHlEJ5r5kkXix4w6RTQN2SmcsCR5BQFJVW4",{"id":44586,"title":2372,"body":44587,"description":46174,"extension":2419,"faq":46175,"howto":46191,"meta":46209,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":46210,"published":46211,"seo":46212,"seoTitle":46213,"stem":46214,"__hash__":46215},"content\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fbuild-a-customer-support-chatbot-with-langchain\u002Findex.md",{"type":7,"value":44588,"toc":46163},[44589,44592,44595,44598,44600,44607,44632,44635,44657,44662,44670,44679,44693,44696,44711,44718,44722,44731,44746,44969,44977,44981,44996,45002,45150,45153,45229,45234,45238,45245,45248,45475,45478,45650,45656,45660,45672,45933,45943,45945,46040,46042,46096,46098,46101,46123,46126,46134,46138,46140,46161],[10,44590,2372],{"id":44591},"build-a-customer-support-chatbot-with-langchain",[14,44593,44594],{},"This guide shows you how to build a working customer support chatbot in Python using LangChain, with a model, a persona prompt, conversation memory, and a lookup tool, in about 30 minutes. LangChain is an open-source library that connects an AI model to prompts, memory, and your own functions so you write less plumbing and more logic.",[14,44596,44597],{},"By the end you will have a chatbot that remembers what the customer said earlier, stays in a support tone, and can look up a real order status instead of inventing one.",[57,44599,238],{"id":237},[14,44601,44602,44603,407,44605,30416],{},"You only need a few things beyond a working Python setup. If Python is not installed yet, follow ",[51,44604,30411],{"href":30410},[51,44606,30415],{"href":30414},[2322,44608,44609,44615,44624],{},[1450,44610,44611,17779,44613,1363],{},[35,44612,17778],{},[18,44614,17782],{},[1450,44616,44617,44620,44621,44623],{},[35,44618,44619],{},"A virtual environment."," See ",[51,44622,2482],{"href":2481}," if this is new.",[1450,44625,44626,44628,44629,44631],{},[35,44627,17787],{}," If you are choosing a provider, ",[51,44630,14635],{"href":14634}," compares them.",[14,44633,44634],{},"Install the libraries:",[253,44636,44638],{"className":255,"code":44637,"language":257,"meta":258,"style":258},"pip install langchain langchain-openai langchain-community python-dotenv\n",[18,44639,44640],{"__ignoreMap":258},[262,44641,44642,44644,44646,44649,44652,44655],{"class":181,"line":264},[262,44643,298],{"class":267},[262,44645,301],{"class":275},[262,44647,44648],{"class":275}," langchain",[262,44650,44651],{"class":275}," langchain-openai",[262,44653,44654],{"class":275}," langchain-community",[262,44656,2522],{"class":275},[14,44658,11153,44659,44661],{},[18,44660,319],{}," in the project folder so it never lands in your code:",[253,44663,44664],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,44665,44666],{"__ignoreMap":258},[262,44667,44668],{"class":181,"line":264},[262,44669,337],{},[14,44671,44672,44678],{},[35,44673,353,44674,356,44676,360],{},[18,44675,319],{},[18,44677,359],{}," so you never commit your key to a repository:",[253,44680,44681],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,44682,44683],{"__ignoreMap":258},[262,44684,44685,44687,44689,44691],{"class":181,"line":264},[262,44686,371],{"class":271},[262,44688,374],{"class":275},[262,44690,378],{"class":377},[262,44692,381],{"class":275},[14,44694,44695],{},"Confirm the key loads before you go further:",[253,44697,44699],{"className":255,"code":44698,"language":257,"meta":258,"style":258},"python -c \"import os; from dotenv import load_dotenv; load_dotenv(); print('Key loaded:', bool(os.getenv('OPENAI_API_KEY')))\"\n",[18,44700,44701],{"__ignoreMap":258},[262,44702,44703,44705,44708],{"class":181,"line":264},[262,44704,416],{"class":267},[262,44706,44707],{"class":271}," -c",[262,44709,44710],{"class":275}," \"import os; from dotenv import load_dotenv; load_dotenv(); print('Key loaded:', bool(os.getenv('OPENAI_API_KEY')))\"\n",[14,44712,44713,44714,44717],{},"If that prints ",[18,44715,44716],{},"Key loaded: True",", you are ready.",[57,44719,44721],{"id":44720},"step-1-connect-a-model-and-a-prompt","Step 1: Connect a model and a prompt",[14,44723,44724,44725,44727,44728,44730],{},"The two building blocks are the ",[35,44726,805],{}," (the AI that writes replies) and the ",[35,44729,9496],{}," (the instructions that shape its tone). In LangChain you create a model object once and reuse it.",[14,44732,44733,44734,44737,44738,44741,44742,44745],{},"The prompt below uses a ",[18,44735,44736],{},"ChatPromptTemplate",", which is a reusable message template. The ",[18,44739,44740],{},"MessagesPlaceholder"," is a slot where past conversation turns will be inserted in the next step, and ",[18,44743,44744],{},"{input}"," is where the customer's new message goes.",[253,44747,44749],{"className":414,"code":44748,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\nfrom langchain_core.output_parsers import StrOutputParser\n\nload_dotenv()\n\nSYSTEM_PROMPT = \"\"\"You are a Tier-1 customer support agent for an online store.\n- Keep a professional, empathetic, solution-oriented tone.\n- If you are missing information, ask one clarifying question instead of guessing.\n- Never invent a policy, refund amount, or order status. Use the tools provided.\n- Keep replies under three sentences unless steps are required.\"\"\"\n\nprompt = ChatPromptTemplate.from_messages([\n    (\"system\", SYSTEM_PROMPT),\n    MessagesPlaceholder(variable_name=\"history\"),\n    (\"human\", \"{input}\"),\n])\n\n# temperature 0.2 keeps answers steady and predictable for support\nllm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0.2)\n\n# StrOutputParser turns the model's reply object into plain text\nchain = prompt | llm | StrOutputParser()\n",[18,44750,44751,44757,44767,44779,44791,44803,44807,44811,44815,44824,44829,44834,44839,44844,44848,44857,44870,44885,44902,44906,44910,44915,44941,44945,44950],{"__ignoreMap":258},[262,44752,44753,44755],{"class":181,"line":264},[262,44754,684],{"class":377},[262,44756,687],{"class":429},[262,44758,44759,44761,44763,44765],{"class":181,"line":282},[262,44760,705],{"class":377},[262,44762,708],{"class":429},[262,44764,684],{"class":377},[262,44766,713],{"class":429},[262,44768,44769,44771,44774,44776],{"class":181,"line":295},[262,44770,705],{"class":377},[262,44772,44773],{"class":429}," langchain_openai ",[262,44775,684],{"class":377},[262,44777,44778],{"class":429}," ChatOpenAI\n",[262,44780,44781,44783,44786,44788],{"class":181,"line":345},[262,44782,705],{"class":377},[262,44784,44785],{"class":429}," langchain_core.prompts ",[262,44787,684],{"class":377},[262,44789,44790],{"class":429}," ChatPromptTemplate, MessagesPlaceholder\n",[262,44792,44793,44795,44798,44800],{"class":181,"line":492},[262,44794,705],{"class":377},[262,44796,44797],{"class":429}," langchain_core.output_parsers ",[262,44799,684],{"class":377},[262,44801,44802],{"class":429}," StrOutputParser\n",[262,44804,44805],{"class":181,"line":503},[262,44806,583],{"emptyLinePlaceholder":582},[262,44808,44809],{"class":181,"line":521},[262,44810,734],{"class":429},[262,44812,44813],{"class":181,"line":537},[262,44814,583],{"emptyLinePlaceholder":582},[262,44816,44817,44819,44821],{"class":181,"line":549},[262,44818,2941],{"class":271},[262,44820,442],{"class":377},[262,44822,44823],{"class":275}," \"\"\"You are a Tier-1 customer support agent for an online store.\n",[262,44825,44826],{"class":181,"line":570},[262,44827,44828],{"class":275},"- Keep a professional, empathetic, solution-oriented tone.\n",[262,44830,44831],{"class":181,"line":579},[262,44832,44833],{"class":275},"- If you are missing information, ask one clarifying question instead of guessing.\n",[262,44835,44836],{"class":181,"line":586},[262,44837,44838],{"class":275},"- Never invent a policy, refund amount, or order status. Use the tools provided.\n",[262,44840,44841],{"class":181,"line":591},[262,44842,44843],{"class":275},"- Keep replies under three sentences unless steps are required.\"\"\"\n",[262,44845,44846],{"class":181,"line":623},[262,44847,583],{"emptyLinePlaceholder":582},[262,44849,44850,44852,44854],{"class":181,"line":634},[262,44851,11556],{"class":429},[262,44853,476],{"class":377},[262,44855,44856],{"class":429}," ChatPromptTemplate.from_messages([\n",[262,44858,44859,44862,44864,44866,44868],{"class":181,"line":845},[262,44860,44861],{"class":429},"    (",[262,44863,1234],{"class":275},[262,44865,608],{"class":429},[262,44867,2941],{"class":271},[262,44869,1210],{"class":429},[262,44871,44872,44875,44878,44880,44883],{"class":181,"line":850},[262,44873,44874],{"class":429},"    MessagesPlaceholder(",[262,44876,44877],{"class":611},"variable_name",[262,44879,476],{"class":377},[262,44881,44882],{"class":275},"\"history\"",[262,44884,1210],{"class":429},[262,44886,44887,44889,44892,44894,44896,44898,44900],{"class":181,"line":864},[262,44888,44861],{"class":429},[262,44890,44891],{"class":275},"\"human\"",[262,44893,608],{"class":429},[262,44895,1176],{"class":275},[262,44897,44744],{"class":271},[262,44899,1176],{"class":275},[262,44901,1210],{"class":429},[262,44903,44904],{"class":181,"line":1683},[262,44905,3512],{"class":429},[262,44907,44908],{"class":181,"line":1688},[262,44909,583],{"emptyLinePlaceholder":582},[262,44911,44912],{"class":181,"line":1693},[262,44913,44914],{"class":291},"# temperature 0.2 keeps answers steady and predictable for support\n",[262,44916,44917,44920,44922,44925,44927,44929,44931,44933,44935,44937,44939],{"class":181,"line":1728},[262,44918,44919],{"class":429},"llm ",[262,44921,476],{"class":377},[262,44923,44924],{"class":429}," ChatOpenAI(",[262,44926,805],{"class":611},[262,44928,476],{"class":377},[262,44930,1207],{"class":275},[262,44932,608],{"class":429},[262,44934,3829],{"class":611},[262,44936,476],{"class":377},[262,44938,27811],{"class":271},[262,44940,660],{"class":429},[262,44942,44943],{"class":181,"line":1737},[262,44944,583],{"emptyLinePlaceholder":582},[262,44946,44947],{"class":181,"line":1751},[262,44948,44949],{"class":291},"# StrOutputParser turns the model's reply object into plain text\n",[262,44951,44952,44955,44957,44959,44961,44964,44966],{"class":181,"line":1764},[262,44953,44954],{"class":429},"chain ",[262,44956,476],{"class":377},[262,44958,27210],{"class":429},[262,44960,7985],{"class":377},[262,44962,44963],{"class":429}," llm ",[262,44965,7985],{"class":377},[262,44967,44968],{"class":429}," StrOutputParser()\n",[14,44970,3349,44971,44973,44974,1363],{},[18,44972,7985],{}," pipe joins the pieces into a single sequence: the prompt fills in, the model answers, and the parser hands you a clean string. You can already test it with ",[18,44975,44976],{},"chain.invoke({\"input\": \"Hi\", \"history\": []})",[57,44978,44980],{"id":44979},"step-2-add-memory-so-the-bot-remembers","Step 2: Add memory so the bot remembers",[14,44982,44983,44984,44987,44988,44991,44992,44995],{},"A support bot that forgets the previous message is frustrating. LangChain solves this with ",[35,44985,44986],{},"message history",": it saves every turn under a ",[18,44989,44990],{},"session_id"," and replays it into the ",[18,44993,44994],{},"history"," placeholder automatically.",[14,44997,44998,45001],{},[18,44999,45000],{},"RunnableWithMessageHistory"," wraps your chain and does this for you. You supply a function that returns a storage object for a given session id. Below it is in-memory, which is fine for development.",[253,45003,45005],{"className":414,"code":45004,"language":416,"meta":258,"style":258},"from langchain_core.runnables.history import RunnableWithMessageHistory\nfrom langchain_community.chat_message_histories import ChatMessageHistory\n\nstore: dict[str, ChatMessageHistory] = {}\n\ndef get_session_history(session_id: str) -> ChatMessageHistory:\n    if session_id not in store:\n        store[session_id] = ChatMessageHistory()\n    return store[session_id]\n\nchatbot = RunnableWithMessageHistory(\n    chain,\n    get_session_history,\n    input_messages_key=\"input\",\n    history_messages_key=\"history\",\n)\n",[18,45006,45007,45019,45031,45035,45049,45053,45068,45082,45092,45099,45103,45113,45118,45123,45135,45146],{"__ignoreMap":258},[262,45008,45009,45011,45014,45016],{"class":181,"line":264},[262,45010,705],{"class":377},[262,45012,45013],{"class":429}," langchain_core.runnables.history ",[262,45015,684],{"class":377},[262,45017,45018],{"class":429}," RunnableWithMessageHistory\n",[262,45020,45021,45023,45026,45028],{"class":181,"line":282},[262,45022,705],{"class":377},[262,45024,45025],{"class":429}," langchain_community.chat_message_histories ",[262,45027,684],{"class":377},[262,45029,45030],{"class":429}," ChatMessageHistory\n",[262,45032,45033],{"class":181,"line":295},[262,45034,583],{"emptyLinePlaceholder":582},[262,45036,45037,45040,45042,45045,45047],{"class":181,"line":345},[262,45038,45039],{"class":429},"store: dict[",[262,45041,433],{"class":271},[262,45043,45044],{"class":429},", ChatMessageHistory] ",[262,45046,476],{"class":377},[262,45048,29867],{"class":429},[262,45050,45051],{"class":181,"line":492},[262,45052,583],{"emptyLinePlaceholder":582},[262,45054,45055,45057,45060,45063,45065],{"class":181,"line":503},[262,45056,423],{"class":377},[262,45058,45059],{"class":267}," get_session_history",[262,45061,45062],{"class":429},"(session_id: ",[262,45064,433],{"class":271},[262,45066,45067],{"class":429},") -> ChatMessageHistory:\n",[262,45069,45070,45072,45075,45077,45079],{"class":181,"line":521},[262,45071,3454],{"class":377},[262,45073,45074],{"class":429}," session_id ",[262,45076,17892],{"class":377},[262,45078,2821],{"class":377},[262,45080,45081],{"class":429}," store:\n",[262,45083,45084,45087,45089],{"class":181,"line":537},[262,45085,45086],{"class":429},"        store[session_id] ",[262,45088,476],{"class":377},[262,45090,45091],{"class":429}," ChatMessageHistory()\n",[262,45093,45094,45096],{"class":181,"line":549},[262,45095,573],{"class":377},[262,45097,45098],{"class":429}," store[session_id]\n",[262,45100,45101],{"class":181,"line":570},[262,45102,583],{"emptyLinePlaceholder":582},[262,45104,45105,45108,45110],{"class":181,"line":579},[262,45106,45107],{"class":429},"chatbot ",[262,45109,476],{"class":377},[262,45111,45112],{"class":429}," RunnableWithMessageHistory(\n",[262,45114,45115],{"class":181,"line":586},[262,45116,45117],{"class":429},"    chain,\n",[262,45119,45120],{"class":181,"line":591},[262,45121,45122],{"class":429},"    get_session_history,\n",[262,45124,45125,45128,45130,45133],{"class":181,"line":623},[262,45126,45127],{"class":611},"    input_messages_key",[262,45129,476],{"class":377},[262,45131,45132],{"class":275},"\"input\"",[262,45134,1315],{"class":429},[262,45136,45137,45140,45142,45144],{"class":181,"line":634},[262,45138,45139],{"class":611},"    history_messages_key",[262,45141,476],{"class":377},[262,45143,44882],{"class":275},[262,45145,1315],{"class":429},[262,45147,45148],{"class":181,"line":845},[262,45149,660],{"class":429},[14,45151,45152],{},"Now each call needs a session id so the bot knows which conversation it is in:",[253,45154,45156],{"className":414,"code":45155,"language":416,"meta":258,"style":258},"config = {\"configurable\": {\"session_id\": \"customer-42\"}}\nprint(chatbot.invoke({\"input\": \"My order is late.\"}, config=config))\nprint(chatbot.invoke({\"input\": \"What did I just ask about?\"}, config=config))\n",[18,45157,45158,45183,45208],{"__ignoreMap":258},[262,45159,45160,45163,45165,45167,45170,45172,45175,45177,45180],{"class":181,"line":264},[262,45161,45162],{"class":429},"config ",[262,45164,476],{"class":377},[262,45166,2276],{"class":429},[262,45168,45169],{"class":275},"\"configurable\"",[262,45171,20445],{"class":429},[262,45173,45174],{"class":275},"\"session_id\"",[262,45176,1231],{"class":429},[262,45178,45179],{"class":275},"\"customer-42\"",[262,45181,45182],{"class":429},"}}\n",[262,45184,45185,45187,45190,45192,45194,45197,45200,45203,45205],{"class":181,"line":282},[262,45186,637],{"class":271},[262,45188,45189],{"class":429},"(chatbot.invoke({",[262,45191,45132],{"class":275},[262,45193,1231],{"class":429},[262,45195,45196],{"class":275},"\"My order is late.\"",[262,45198,45199],{"class":429},"}, ",[262,45201,45202],{"class":611},"config",[262,45204,476],{"class":377},[262,45206,45207],{"class":429},"config))\n",[262,45209,45210,45212,45214,45216,45218,45221,45223,45225,45227],{"class":181,"line":295},[262,45211,637],{"class":271},[262,45213,45189],{"class":429},[262,45215,45132],{"class":275},[262,45217,1231],{"class":429},[262,45219,45220],{"class":275},"\"What did I just ask about?\"",[262,45222,45199],{"class":429},[262,45224,45202],{"class":611},[262,45226,476],{"class":377},[262,45228,45207],{"class":429},[14,45230,45231,45232,1363],{},"The second reply will reference the late order, because the first turn is now in memory. For a deeper look at persistence options, see ",[51,45233,2367],{"href":2366},[57,45235,45237],{"id":45236},"step-3-give-the-bot-a-tool-to-look-up-real-answers","Step 3: Give the bot a tool to look up real answers",[14,45239,45240,45241,45244],{},"Left alone, a model will happily guess an order status. A ",[35,45242,45243],{},"tool"," is a plain Python function the model is allowed to call when it needs a fact. LangChain reads the function's name, type hints, and docstring to decide when and how to call it, so write a clear docstring.",[14,45246,45247],{},"Here we add a fake order-lookup function. In a real system this would query your database or order API.",[253,45249,45251],{"className":414,"code":45250,"language":416,"meta":258,"style":258},"from langchain_core.tools import tool\n\n# Pretend this is your real order database\nORDERS = {\n    \"1001\": {\"status\": \"shipped\", \"eta\": \"June 20\"},\n    \"1002\": {\"status\": \"processing\", \"eta\": \"June 24\"},\n}\n\n@tool\ndef lookup_order(order_id: str) -> str:\n    \"\"\"Look up the status and delivery estimate for an order by its ID.\"\"\"\n    order = ORDERS.get(order_id)\n    if not order:\n        return f\"No order found with ID {order_id}.\"\n    return f\"Order {order_id}: {order['status']}, estimated delivery {order['eta']}.\"\n\n# Bind the tool so the model knows it can call it\nllm_with_tools = llm.bind_tools([lookup_order])\n",[18,45252,45253,45265,45269,45274,45283,45309,45334,45338,45342,45347,45365,45370,45383,45392,45411,45456,45460,45465],{"__ignoreMap":258},[262,45254,45255,45257,45260,45262],{"class":181,"line":264},[262,45256,705],{"class":377},[262,45258,45259],{"class":429}," langchain_core.tools ",[262,45261,684],{"class":377},[262,45263,45264],{"class":429}," tool\n",[262,45266,45267],{"class":181,"line":282},[262,45268,583],{"emptyLinePlaceholder":582},[262,45270,45271],{"class":181,"line":295},[262,45272,45273],{"class":291},"# Pretend this is your real order database\n",[262,45275,45276,45279,45281],{"class":181,"line":345},[262,45277,45278],{"class":271},"ORDERS",[262,45280,442],{"class":377},[262,45282,20437],{"class":429},[262,45284,45285,45288,45290,45292,45294,45297,45299,45302,45304,45307],{"class":181,"line":492},[262,45286,45287],{"class":275},"    \"1001\"",[262,45289,20445],{"class":429},[262,45291,10324],{"class":275},[262,45293,1231],{"class":429},[262,45295,45296],{"class":275},"\"shipped\"",[262,45298,608],{"class":429},[262,45300,45301],{"class":275},"\"eta\"",[262,45303,1231],{"class":429},[262,45305,45306],{"class":275},"\"June 20\"",[262,45308,3143],{"class":429},[262,45310,45311,45314,45316,45318,45320,45323,45325,45327,45329,45332],{"class":181,"line":503},[262,45312,45313],{"class":275},"    \"1002\"",[262,45315,20445],{"class":429},[262,45317,10324],{"class":275},[262,45319,1231],{"class":429},[262,45321,45322],{"class":275},"\"processing\"",[262,45324,608],{"class":429},[262,45326,45301],{"class":275},[262,45328,1231],{"class":429},[262,45330,45331],{"class":275},"\"June 24\"",[262,45333,3143],{"class":429},[262,45335,45336],{"class":181,"line":521},[262,45337,16430],{"class":429},[262,45339,45340],{"class":181,"line":537},[262,45341,583],{"emptyLinePlaceholder":582},[262,45343,45344],{"class":181,"line":549},[262,45345,45346],{"class":267},"@tool\n",[262,45348,45349,45351,45354,45357,45359,45361,45363],{"class":181,"line":570},[262,45350,423],{"class":377},[262,45352,45353],{"class":267}," lookup_order",[262,45355,45356],{"class":429},"(order_id: ",[262,45358,433],{"class":271},[262,45360,1939],{"class":429},[262,45362,433],{"class":271},[262,45364,1160],{"class":429},[262,45366,45367],{"class":181,"line":579},[262,45368,45369],{"class":275},"    \"\"\"Look up the status and delivery estimate for an order by its ID.\"\"\"\n",[262,45371,45372,45375,45377,45380],{"class":181,"line":586},[262,45373,45374],{"class":429},"    order ",[262,45376,476],{"class":377},[262,45378,45379],{"class":271}," ORDERS",[262,45381,45382],{"class":429},".get(order_id)\n",[262,45384,45385,45387,45389],{"class":181,"line":591},[262,45386,3454],{"class":377},[262,45388,2818],{"class":377},[262,45390,45391],{"class":429}," order:\n",[262,45393,45394,45396,45398,45401,45403,45406,45408],{"class":181,"line":623},[262,45395,8066],{"class":377},[262,45397,10178],{"class":377},[262,45399,45400],{"class":275},"\"No order found with ID ",[262,45402,3039],{"class":271},[262,45404,45405],{"class":429},"order_id",[262,45407,654],{"class":271},[262,45409,45410],{"class":275},".\"\n",[262,45412,45413,45415,45417,45420,45422,45424,45426,45428,45430,45433,45436,45438,45440,45443,45445,45447,45450,45452,45454],{"class":181,"line":634},[262,45414,573],{"class":377},[262,45416,10178],{"class":377},[262,45418,45419],{"class":275},"\"Order ",[262,45421,3039],{"class":271},[262,45423,45405],{"class":429},[262,45425,654],{"class":271},[262,45427,1231],{"class":275},[262,45429,3039],{"class":271},[262,45431,45432],{"class":429},"order[",[262,45434,45435],{"class":275},"'status'",[262,45437,6223],{"class":429},[262,45439,654],{"class":271},[262,45441,45442],{"class":275},", estimated delivery ",[262,45444,3039],{"class":271},[262,45446,45432],{"class":429},[262,45448,45449],{"class":275},"'eta'",[262,45451,6223],{"class":429},[262,45453,654],{"class":271},[262,45455,45410],{"class":275},[262,45457,45458],{"class":181,"line":845},[262,45459,583],{"emptyLinePlaceholder":582},[262,45461,45462],{"class":181,"line":850},[262,45463,45464],{"class":291},"# Bind the tool so the model knows it can call it\n",[262,45466,45467,45470,45472],{"class":181,"line":864},[262,45468,45469],{"class":429},"llm_with_tools ",[262,45471,476],{"class":377},[262,45473,45474],{"class":429}," llm.bind_tools([lookup_order])\n",[14,45476,45477],{},"To let the model actually run the tool and then answer using the result, route any tool calls back to your function. The small helper below handles both cases: a direct text reply, or a request to call the tool.",[253,45479,45481],{"className":414,"code":45480,"language":416,"meta":258,"style":258},"from langchain_core.messages import HumanMessage, ToolMessage\n\ndef answer_with_tools(user_input: str) -> str:\n    messages = [(\"system\", SYSTEM_PROMPT), HumanMessage(user_input)]\n    ai_msg = llm_with_tools.invoke(messages)\n\n    # If the model asked for no tool, return its text directly\n    if not ai_msg.tool_calls:\n        return ai_msg.content\n\n    # Otherwise run each requested tool and feed the result back\n    messages.append(ai_msg)\n    for call in ai_msg.tool_calls:\n        result = lookup_order.invoke(call[\"args\"])\n        messages.append(ToolMessage(result, tool_call_id=call[\"id\"]))\n    return llm_with_tools.invoke(messages).content\n\nprint(answer_with_tools(\"Where is order 1001?\"))\n",[18,45482,45483,45495,45499,45517,45536,45546,45550,45555,45564,45571,45575,45580,45585,45596,45610,45627,45634,45638],{"__ignoreMap":258},[262,45484,45485,45487,45490,45492],{"class":181,"line":264},[262,45486,705],{"class":377},[262,45488,45489],{"class":429}," langchain_core.messages ",[262,45491,684],{"class":377},[262,45493,45494],{"class":429}," HumanMessage, ToolMessage\n",[262,45496,45497],{"class":181,"line":282},[262,45498,583],{"emptyLinePlaceholder":582},[262,45500,45501,45503,45506,45509,45511,45513,45515],{"class":181,"line":295},[262,45502,423],{"class":377},[262,45504,45505],{"class":267}," answer_with_tools",[262,45507,45508],{"class":429},"(user_input: ",[262,45510,433],{"class":271},[262,45512,1939],{"class":429},[262,45514,433],{"class":271},[262,45516,1160],{"class":429},[262,45518,45519,45522,45524,45527,45529,45531,45533],{"class":181,"line":345},[262,45520,45521],{"class":429},"    messages ",[262,45523,476],{"class":377},[262,45525,45526],{"class":429}," [(",[262,45528,1234],{"class":275},[262,45530,608],{"class":429},[262,45532,2941],{"class":271},[262,45534,45535],{"class":429},"), HumanMessage(user_input)]\n",[262,45537,45538,45541,45543],{"class":181,"line":492},[262,45539,45540],{"class":429},"    ai_msg ",[262,45542,476],{"class":377},[262,45544,45545],{"class":429}," llm_with_tools.invoke(messages)\n",[262,45547,45548],{"class":181,"line":503},[262,45549,583],{"emptyLinePlaceholder":582},[262,45551,45552],{"class":181,"line":521},[262,45553,45554],{"class":291},"    # If the model asked for no tool, return its text directly\n",[262,45556,45557,45559,45561],{"class":181,"line":537},[262,45558,3454],{"class":377},[262,45560,2818],{"class":377},[262,45562,45563],{"class":429}," ai_msg.tool_calls:\n",[262,45565,45566,45568],{"class":181,"line":549},[262,45567,8066],{"class":377},[262,45569,45570],{"class":429}," ai_msg.content\n",[262,45572,45573],{"class":181,"line":570},[262,45574,583],{"emptyLinePlaceholder":582},[262,45576,45577],{"class":181,"line":579},[262,45578,45579],{"class":291},"    # Otherwise run each requested tool and feed the result back\n",[262,45581,45582],{"class":181,"line":586},[262,45583,45584],{"class":429},"    messages.append(ai_msg)\n",[262,45586,45587,45589,45592,45594],{"class":181,"line":591},[262,45588,3074],{"class":377},[262,45590,45591],{"class":429}," call ",[262,45593,835],{"class":377},[262,45595,45563],{"class":429},[262,45597,45598,45600,45602,45605,45608],{"class":181,"line":623},[262,45599,9233],{"class":429},[262,45601,476],{"class":377},[262,45603,45604],{"class":429}," lookup_order.invoke(call[",[262,45606,45607],{"class":275},"\"args\"",[262,45609,3512],{"class":429},[262,45611,45612,45615,45618,45620,45623,45625],{"class":181,"line":634},[262,45613,45614],{"class":429},"        messages.append(ToolMessage(result, ",[262,45616,45617],{"class":611},"tool_call_id",[262,45619,476],{"class":377},[262,45621,45622],{"class":429},"call[",[262,45624,6770],{"class":275},[262,45626,15338],{"class":429},[262,45628,45629,45631],{"class":181,"line":845},[262,45630,573],{"class":377},[262,45632,45633],{"class":429}," llm_with_tools.invoke(messages).content\n",[262,45635,45636],{"class":181,"line":850},[262,45637,583],{"emptyLinePlaceholder":582},[262,45639,45640,45642,45645,45648],{"class":181,"line":864},[262,45641,637],{"class":271},[262,45643,45644],{"class":429},"(answer_with_tools(",[262,45646,45647],{"class":275},"\"Where is order 1001?\"",[262,45649,2684],{"class":429},[14,45651,45652,45653,45655],{},"Now the bot returns the real shipped status for order 1001 instead of a made-up date. If you would rather have the bot answer from a knowledge base of help articles, ",[51,45654,5],{"href":44488}," shows the retrieval approach.",[57,45657,45659],{"id":45658},"step-4-run-an-interactive-chat-loop","Step 4: Run an interactive chat loop",[14,45661,45662,45663,45666,45667,981,45669,45671],{},"Tie it together with a loop that reads customer messages and prints replies. This version uses the memory-aware ",[18,45664,45665],{},"chatbot"," from Step 2 so the conversation stays in context. A ",[18,45668,14430],{},[18,45670,14433],{}," keeps a single bad request from crashing the session.",[253,45673,45675],{"className":414,"code":45674,"language":416,"meta":258,"style":258},"from openai import OpenAIError, RateLimitError\n\ndef reply(user_input: str, session_id: str) -> str:\n    try:\n        return chatbot.invoke(\n            {\"input\": user_input},\n            config={\"configurable\": {\"session_id\": session_id}},\n        )\n    except RateLimitError:\n        return \"We are busy right now. Please try again in 30 seconds.\"\n    except OpenAIError as e:\n        return f\"Sorry, something went wrong: {e}\"\n\nif __name__ == \"__main__\":\n    session_id = \"support-session-01\"\n    print(\"Support chatbot ready. Type 'quit' to exit.\")\n    while True:\n        message = input(\"\\nCustomer: \").strip()\n        if message.lower() in {\"quit\", \"exit\"}:\n            print(\"Session closed.\")\n            break\n        if not message:\n            continue\n        print(f\"Agent: {reply(message, session_id)}\")\n",[18,45676,45677,45688,45692,45714,45720,45727,45736,45754,45758,45764,45771,45782,45799,45803,45815,45825,45836,45844,45864,45883,45894,45898,45907,45911],{"__ignoreMap":258},[262,45678,45679,45681,45683,45685],{"class":181,"line":264},[262,45680,705],{"class":377},[262,45682,720],{"class":429},[262,45684,684],{"class":377},[262,45686,45687],{"class":429}," OpenAIError, RateLimitError\n",[262,45689,45690],{"class":181,"line":282},[262,45691,583],{"emptyLinePlaceholder":582},[262,45693,45694,45696,45699,45701,45703,45706,45708,45710,45712],{"class":181,"line":295},[262,45695,423],{"class":377},[262,45697,45698],{"class":267}," reply",[262,45700,45508],{"class":429},[262,45702,433],{"class":271},[262,45704,45705],{"class":429},", session_id: ",[262,45707,433],{"class":271},[262,45709,1939],{"class":429},[262,45711,433],{"class":271},[262,45713,1160],{"class":429},[262,45715,45716,45718],{"class":181,"line":345},[262,45717,14474],{"class":377},[262,45719,1160],{"class":429},[262,45721,45722,45724],{"class":181,"line":492},[262,45723,8066],{"class":377},[262,45725,45726],{"class":429}," chatbot.invoke(\n",[262,45728,45729,45731,45733],{"class":181,"line":503},[262,45730,1225],{"class":429},[262,45732,45132],{"class":275},[262,45734,45735],{"class":429},": user_input},\n",[262,45737,45738,45741,45743,45745,45747,45749,45751],{"class":181,"line":521},[262,45739,45740],{"class":611},"            config",[262,45742,476],{"class":377},[262,45744,3039],{"class":429},[262,45746,45169],{"class":275},[262,45748,20445],{"class":429},[262,45750,45174],{"class":275},[262,45752,45753],{"class":429},": session_id}},\n",[262,45755,45756],{"class":181,"line":537},[262,45757,6288],{"class":429},[262,45759,45760,45762],{"class":181,"line":549},[262,45761,14522],{"class":377},[262,45763,9787],{"class":429},[262,45765,45766,45768],{"class":181,"line":570},[262,45767,8066],{"class":377},[262,45769,45770],{"class":275}," \"We are busy right now. Please try again in 30 seconds.\"\n",[262,45772,45773,45775,45778,45780],{"class":181,"line":579},[262,45774,14522],{"class":377},[262,45776,45777],{"class":429}," OpenAIError ",[262,45779,697],{"class":377},[262,45781,11457],{"class":429},[262,45783,45784,45786,45788,45791,45793,45795,45797],{"class":181,"line":586},[262,45785,8066],{"class":377},[262,45787,10178],{"class":377},[262,45789,45790],{"class":275},"\"Sorry, something went wrong: ",[262,45792,3039],{"class":271},[262,45794,11475],{"class":429},[262,45796,654],{"class":271},[262,45798,1257],{"class":275},[262,45800,45801],{"class":181,"line":591},[262,45802,583],{"emptyLinePlaceholder":582},[262,45804,45805,45807,45809,45811,45813],{"class":181,"line":623},[262,45806,2210],{"class":377},[262,45808,2213],{"class":271},[262,45810,2216],{"class":377},[262,45812,2219],{"class":275},[262,45814,1160],{"class":429},[262,45816,45817,45820,45822],{"class":181,"line":634},[262,45818,45819],{"class":429},"    session_id ",[262,45821,476],{"class":377},[262,45823,45824],{"class":275}," \"support-session-01\"\n",[262,45826,45827,45829,45831,45834],{"class":181,"line":845},[262,45828,1089],{"class":271},[262,45830,602],{"class":429},[262,45832,45833],{"class":275},"\"Support chatbot ready. Type 'quit' to exit.\"",[262,45835,660],{"class":429},[262,45837,45838,45840,45842],{"class":181,"line":850},[262,45839,506],{"class":377},[262,45841,2241],{"class":271},[262,45843,1160],{"class":429},[262,45845,45846,45849,45851,45853,45855,45857,45859,45862],{"class":181,"line":864},[262,45847,45848],{"class":429},"        message ",[262,45850,476],{"class":377},[262,45852,2254],{"class":271},[262,45854,602],{"class":429},[262,45856,1176],{"class":275},[262,45858,2137],{"class":271},[262,45860,45861],{"class":275},"Customer: \"",[262,45863,2262],{"class":429},[262,45865,45866,45868,45871,45873,45875,45877,45879,45881],{"class":181,"line":1683},[262,45867,2268],{"class":377},[262,45869,45870],{"class":429}," message.lower() ",[262,45872,835],{"class":377},[262,45874,2276],{"class":429},[262,45876,2279],{"class":275},[262,45878,608],{"class":429},[262,45880,2284],{"class":275},[262,45882,2287],{"class":429},[262,45884,45885,45887,45889,45892],{"class":181,"line":1688},[262,45886,3250],{"class":271},[262,45888,602],{"class":429},[262,45890,45891],{"class":275},"\"Session closed.\"",[262,45893,660],{"class":429},[262,45895,45896],{"class":181,"line":1693},[262,45897,2293],{"class":377},[262,45899,45900,45902,45904],{"class":181,"line":1728},[262,45901,2268],{"class":377},[262,45903,2818],{"class":377},[262,45905,45906],{"class":429}," message:\n",[262,45908,45909],{"class":181,"line":1737},[262,45910,17320],{"class":377},[262,45912,45913,45915,45917,45919,45922,45924,45927,45929,45931],{"class":181,"line":1751},[262,45914,2299],{"class":271},[262,45916,602],{"class":429},[262,45918,642],{"class":377},[262,45920,45921],{"class":275},"\"Agent: ",[262,45923,3039],{"class":271},[262,45925,45926],{"class":429},"reply(message, session_id)",[262,45928,654],{"class":271},[262,45930,1176],{"class":275},[262,45932,660],{"class":429},[14,45934,45935,45936,8518,45939,45942],{},"Save the file as ",[18,45937,45938],{},"support_bot.py",[18,45940,45941],{},"python support_bot.py",". Ask a question, then ask a follow-up, and watch it keep the thread.",[57,45944,24067],{"id":24066},[1379,45946,45947,45960],{},[1382,45948,45949],{},[1385,45950,45951,45953,45955,45957],{},[1388,45952,1390],{},[1388,45954,24078],{},[1388,45956,3798],{},[1388,45958,45959],{},"What it does",[1398,45961,45962,45981,46004,46020],{},[1385,45963,45964,45968,45973,45975],{},[1403,45965,45966],{},[18,45967,805],{},[1403,45969,45970],{},[18,45971,45972],{},"ChatOpenAI(...)",[1403,45974,17513],{},[1403,45976,45977,45978,45980],{},"Which model answers. ",[18,45979,2703],{}," is cheap and fast for support.",[1385,45982,45983,45987,45991,45995],{},[1403,45984,45985],{},[18,45986,3829],{},[1403,45988,45989],{},[18,45990,45972],{},[1403,45992,45993],{},[18,45994,4672],{},[1403,45996,45997,45998,5209,46001,46003],{},"How varied replies are. Use ",[18,45999,46000],{},"0.0",[18,46002,3924],{}," so support answers stay consistent.",[1385,46005,46006,46010,46015,46017],{},[1403,46007,46008],{},[18,46009,44990],{},[1403,46011,46012],{},[18,46013,46014],{},"config={\"configurable\": ...}",[1403,46016,17513],{},[1403,46018,46019],{},"Picks which conversation's memory to load. One id per customer thread.",[1385,46021,46022,46027,46032,46034],{},[1403,46023,46024],{},[18,46025,46026],{},"history_messages_key",[1403,46028,46029],{},[18,46030,46031],{},"RunnableWithMessageHistory(...)",[1403,46033,219],{},[1403,46035,46036,46037,46039],{},"Must match the ",[18,46038,44740],{}," name so past turns get injected.",[57,46041,1445],{"id":1444},[1447,46043,46044,46060,46075,46086],{},[1450,46045,46046,46051,46052,46054,46055,46057,46058,1363],{},[35,46047,46048],{},[18,46049,46050],{},"ValidationError: OPENAI_API_KEY ... field required"," — The key did not load. Check that ",[18,46053,319],{}," is in the folder you run from and that you call ",[18,46056,8439],{}," before creating the model. See ",[51,46059,388],{"href":387},[1450,46061,46062,46065,46066,46068,46069,46071,46072,46074],{},[35,46063,46064],{},"The bot forgets earlier messages"," — You either changed the ",[18,46067,44990],{}," between calls or your ",[18,46070,46026],{}," does not match the ",[18,46073,44740],{}," name. Keep both consistent.",[1450,46076,46077,46082,46083,46085],{},[35,46078,46079],{},[18,46080,46081],{},"RateLimitError: 429"," — You sent requests faster than your plan allows. Wait, then add backoff. ",[51,46084,3379],{"href":3378}," has a retry pattern.",[1450,46087,46088,46091,46092,46095],{},[35,46089,46090],{},"The model ignores your tool"," — Make sure you called ",[18,46093,46094],{},"llm.bind_tools([...])"," and that the tool's docstring clearly describes when to use it. A vague docstring makes the model skip it.",[57,46097,2317],{"id":2316},[14,46099,46100],{},"LangChain saves time, but it is not always the right pick. Here is when each approach fits.",[2322,46102,46103,46109,46117],{},[1450,46104,46105,46108],{},[35,46106,46107],{},"Use LangChain"," when you want memory, tools, and retrieval to plug together with little glue code, or when you may swap models later. The wrappers shown here are the payoff.",[1450,46110,46111,46116],{},[35,46112,46113,46114,42825],{},"Use the raw ",[18,46115,20],{}," when your bot is a single prompt with no memory or tools. The extra LangChain layer adds dependencies and abstraction you will not use, so a direct API call is simpler to read and debug.",[1450,46118,46119,46122],{},[35,46120,46121],{},"Use a hosted platform"," (a no-code support tool) when you need a polished widget, analytics, and human handoff out of the box, and you are willing to trade custom logic for speed.",[14,46124,46125],{},"A good rule: reach for LangChain once you need at least two of memory, tools, and document retrieval. Below that, stay with the plain SDK.",[14,46127,46128,46129,46131,46132,1363],{},"To take this further, add real-time replies with ",[51,46130,2362],{"href":2361},", or wire it to your sales data through ",[51,46133,36938],{"href":36937},[14,46135,2375,46136,1363],{},[51,46137,54],{"href":53},[57,46139,2381],{"id":2380},[2322,46141,46142,46146,46151,46156],{},[1450,46143,46144,35066],{},[51,46145,54],{"href":53},[1450,46147,46148,46150],{},[51,46149,2367],{"href":2366}," — persist conversations beyond a single run.",[1450,46152,46153,46155],{},[51,46154,2362],{"href":2361}," — show replies word by word.",[1450,46157,46158,46160],{},[51,46159,5],{"href":44488}," — answer from your help center.",[2401,46162,19746],{},{"title":258,"searchDepth":282,"depth":282,"links":46164},[46165,46166,46167,46168,46169,46170,46171,46172,46173],{"id":237,"depth":282,"text":238},{"id":44720,"depth":282,"text":44721},{"id":44979,"depth":282,"text":44980},{"id":45236,"depth":282,"text":45237},{"id":45658,"depth":282,"text":45659},{"id":24066,"depth":282,"text":24067},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Build a LangChain customer support chatbot in Python: model, prompt, memory, a tool, and a chat loop. Runnable code, troubleshooting, and when to use LangChain.",[46176,46179,46182,46185,46188],{"q":46177,"a":46178},"Do I need to know machine learning to build a chatbot with LangChain?","No. LangChain handles the AI parts for you. You only write plain Python to connect a prompt, a model, and a memory store, then call them in a loop. Basic Python is enough.",{"q":46180,"a":46181},"Does LangChain cost money to run?","LangChain itself is free and open source. You pay only the model provider, such as OpenAI, per request. A small support bot for testing typically costs a few cents per day on gpt-4o-mini.",{"q":46183,"a":46184},"How does the chatbot remember earlier messages?","Each conversation has a session id. LangChain stores past messages for that id and replays them into every new prompt, so the model sees the full thread and can answer follow-up questions in context.",{"q":46186,"a":46187},"What is a tool in a LangChain chatbot?","A tool is a normal Python function the model can call when it needs a real answer, such as looking up an order status or checking a return policy. It lets the bot give facts instead of guessing.",{"q":46189,"a":46190},"Can I use a model other than OpenAI?","Yes. LangChain wraps many providers behind one interface. You swap the model object, for example ChatAnthropic instead of ChatOpenAI, and the rest of your prompt, memory, and loop code stays the same.",{"name":46192,"steps":46193},"How to build a customer support chatbot with LangChain",[46194,46197,46200,46203,46206],{"name":46195,"text":46196},"Install dependencies and set your API key","Install LangChain and create a .env file holding your model provider key.",{"name":46198,"text":46199},"Connect a model and a prompt","Create a ChatOpenAI model and a ChatPromptTemplate that sets the support persona.",{"name":46201,"text":46202},"Add memory so the bot remembers the conversation","Wrap the chain in RunnableWithMessageHistory keyed by a session id.",{"name":46204,"text":46205},"Give the bot a tool to look up real answers","Define a Python function as a tool and let the model call it for facts.",{"name":46207,"text":46208},"Run an interactive chat loop","Read customer messages in a loop and print the agent's replies.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fbuild-a-customer-support-chatbot-with-langchain","2026-05-11",{"title":2372,"description":46174},"Support Chatbot with LangChain in Python","building-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fbuild-a-customer-support-chatbot-with-langchain\u002Findex","bohi91ntz636JMNC_b4f3dWCuA1-KekzVCJai8vWsvM",{"id":4,"title":5,"body":46217,"description":2418,"extension":2419,"faq":48000,"howto":48006,"meta":48012,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":2455,"published":2452,"seo":48013,"seoTitle":5,"stem":2457,"__hash__":2458},{"type":7,"value":46218,"toc":47986},[46219,46221,46229,46237,46241,46243,46245,46251,46320,46322,46328,46330,46366,46370,46390,46398,46412,46416,46418,46422,46428,46602,46604,46606,46610,46768,46772,46774,46780,46950,46958,46960,46964,47142,47148,47150,47156,47204,47206,47250,47252,47260,47922,47926,47928,47930,47948,47950,47952,47960,47964,47966,47984],[10,46220,5],{"id":12},[14,46222,16,46223,21,46225,25,46227,30],{},[18,46224,20],{},[18,46226,24],{},[27,46228,29],{},[14,46230,33,46231,38,46233,42,46235,46],{},[35,46232,37],{},[27,46234,41],{},[27,46236,45],{},[14,46238,49,46239,55],{},[51,46240,54],{"href":53},[57,46242,60],{"id":59},[14,46244,63],{},[14,46246,66,46247,70,46249,74],{},[35,46248,69],{},[27,46250,73],{},[76,46252,46254,46318],{"className":46253},[79],[81,46255,90,46257,90,46259,90,46261,90,46263,90,46265,90,46267,90,46269,90,46271,90,46273,90,46275,90,46277,90,46279,90,46281,90,46283,90,46285,90,46287,90,46289,90,46293,90,46295,90,46297,90,46299,90,46301,90,46303,90,46305,90,46307,90,46309,90,46311,90,46314,90,46316],{"viewBox":83,"role":84,"ariaLabelledBy":46256,"preserveAspectRatio":88,"xmlns":89},[86,87],[92,46258,94],{"id":86},[96,46260,98],{"id":87},[100,46262],{"x":102,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,46264,120],{"x":113,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[111,46266,126],{"x":113,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,46268],{"x":129,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},[111,46270,134],{"x":133,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[111,46272,137],{"x":133,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,46274],{"x":129,"y":140,"width":104,"height":141,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,46276,148],{"x":133,"y":147,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[111,46278,152],{"x":133,"y":151,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[111,46280,155],{"x":133,"y":141,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,46282],{"x":158,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},[111,46284,162],{"x":161,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[111,46286,165],{"x":161,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,46288],{"x":168,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,46290,173,46291],{"x":172,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[175,46292,179],{"x":172,"dy":177,"fontSize":124,"fontWeight":178,"fill":125},[181,46294],{"x1":104,"y1":183,"x2":184,"y2":183,"stroke":108,"strokeWidth":109},[186,46296],{"points":188,"fill":108},[181,46298],{"x1":133,"y1":191,"x2":133,"y2":192,"stroke":143,"strokeWidth":144},[186,46300],{"points":195,"fill":143},[181,46302],{"x1":198,"y1":183,"x2":199,"y2":183,"stroke":130,"strokeWidth":109},[186,46304],{"points":202,"fill":130},[181,46306],{"x1":205,"y1":183,"x2":206,"y2":183,"stroke":169,"strokeWidth":109},[186,46308],{"points":209,"fill":169},[111,46310,214],{"x":212,"y":213,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[216,46312],{"d":218,"fill":219,"stroke":169,"strokeWidth":109,"strokeDashArray":46313},[221,222],[186,46315],{"points":225,"fill":169},[111,46317,230],{"x":228,"y":229,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[232,46319,234],{},[57,46321,238],{"id":237},[14,46323,241,46324,245,46326,248],{},[18,46325,244],{},[51,46327,54],{"href":53},[14,46329,251],{},[253,46331,46332],{"className":255,"code":256,"language":257,"meta":258,"style":258},[18,46333,46334,46344,46352],{"__ignoreMap":258},[262,46335,46336,46338,46340,46342],{"class":181,"line":264},[262,46337,268],{"class":267},[262,46339,272],{"class":271},[262,46341,276],{"class":275},[262,46343,279],{"class":275},[262,46345,46346,46348,46350],{"class":181,"line":282},[262,46347,285],{"class":271},[262,46349,288],{"class":275},[262,46351,292],{"class":291},[262,46353,46354,46356,46358,46360,46362,46364],{"class":181,"line":295},[262,46355,298],{"class":267},[262,46357,301],{"class":275},[262,46359,304],{"class":275},[262,46361,307],{"class":275},[262,46363,310],{"class":275},[262,46365,313],{"class":275},[14,46367,316,46368,320],{},[18,46369,319],{},[253,46371,46372],{"className":323,"code":324,"language":325,"meta":258,"style":258},[18,46373,46374,46378,46382,46386],{"__ignoreMap":258},[262,46375,46376],{"class":181,"line":264},[262,46377,332],{},[262,46379,46380],{"class":181,"line":282},[262,46381,337],{},[262,46383,46384],{"class":181,"line":295},[262,46385,342],{},[262,46387,46388],{"class":181,"line":345},[262,46389,348],{},[14,46391,46392,361],{},[35,46393,353,46394,356,46396,360],{},[18,46395,319],{},[18,46397,359],{},[253,46399,46400],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,46401,46402],{"__ignoreMap":258},[262,46403,46404,46406,46408,46410],{"class":181,"line":264},[262,46405,371],{"class":271},[262,46407,374],{"class":275},[262,46409,378],{"class":377},[262,46411,381],{"class":275},[14,46413,384,46414,389],{},[51,46415,388],{"href":387},[57,46417,393],{"id":392},[14,46419,396,46420,400],{},[35,46421,399],{},[14,46423,403,46424,407,46426,411],{},[18,46425,406],{},[18,46427,410],{},[253,46429,46430],{"className":414,"code":415,"language":416,"meta":258,"style":258},[18,46431,46432,46464,46468,46476,46484,46492,46504,46516,46524,46538,46544,46548,46552,46574,46582],{"__ignoreMap":258},[262,46433,46434,46436,46438,46440,46442,46444,46446,46448,46450,46452,46454,46456,46458,46460,46462],{"class":181,"line":264},[262,46435,423],{"class":377},[262,46437,426],{"class":267},[262,46439,430],{"class":429},[262,46441,433],{"class":271},[262,46443,436],{"class":429},[262,46445,439],{"class":271},[262,46447,442],{"class":377},[262,46449,445],{"class":271},[262,46451,448],{"class":429},[262,46453,439],{"class":271},[262,46455,442],{"class":377},[262,46457,455],{"class":271},[262,46459,458],{"class":429},[262,46461,433],{"class":271},[262,46463,463],{"class":429},[262,46465,46466],{"class":181,"line":282},[262,46467,468],{"class":275},[262,46469,46470,46472,46474],{"class":181,"line":295},[262,46471,473],{"class":429},[262,46473,476],{"class":377},[262,46475,479],{"class":429},[262,46477,46478,46480,46482],{"class":181,"line":345},[262,46479,484],{"class":429},[262,46481,476],{"class":377},[262,46483,489],{"class":429},[262,46485,46486,46488,46490],{"class":181,"line":492},[262,46487,495],{"class":429},[262,46489,476],{"class":377},[262,46491,500],{"class":271},[262,46493,46494,46496,46498,46500,46502],{"class":181,"line":503},[262,46495,506],{"class":377},[262,46497,509],{"class":429},[262,46499,512],{"class":377},[262,46501,515],{"class":271},[262,46503,518],{"class":429},[262,46505,46506,46508,46510,46512,46514],{"class":181,"line":521},[262,46507,524],{"class":429},[262,46509,476],{"class":377},[262,46511,509],{"class":429},[262,46513,531],{"class":377},[262,46515,534],{"class":429},[262,46517,46518,46520,46522],{"class":181,"line":537},[262,46519,540],{"class":429},[262,46521,543],{"class":275},[262,46523,546],{"class":429},[262,46525,46526,46528,46530,46532,46534,46536],{"class":181,"line":549},[262,46527,552],{"class":429},[262,46529,555],{"class":377},[262,46531,558],{"class":429},[262,46533,561],{"class":377},[262,46535,564],{"class":429},[262,46537,567],{"class":291},[262,46539,46540,46542],{"class":181,"line":570},[262,46541,573],{"class":377},[262,46543,576],{"class":429},[262,46545,46546],{"class":181,"line":579},[262,46547,583],{"emptyLinePlaceholder":582},[262,46549,46550],{"class":181,"line":586},[262,46551,583],{"emptyLinePlaceholder":582},[262,46553,46554,46556,46558,46560,46562,46564,46566,46568,46570,46572],{"class":181,"line":591},[262,46555,594],{"class":429},[262,46557,476],{"class":377},[262,46559,599],{"class":271},[262,46561,602],{"class":429},[262,46563,605],{"class":275},[262,46565,608],{"class":429},[262,46567,612],{"class":611},[262,46569,476],{"class":377},[262,46571,617],{"class":275},[262,46573,620],{"class":429},[262,46575,46576,46578,46580],{"class":181,"line":623},[262,46577,626],{"class":429},[262,46579,476],{"class":377},[262,46581,631],{"class":429},[262,46583,46584,46586,46588,46590,46592,46594,46596,46598,46600],{"class":181,"line":634},[262,46585,637],{"class":271},[262,46587,602],{"class":429},[262,46589,642],{"class":377},[262,46591,645],{"class":275},[262,46593,648],{"class":271},[262,46595,651],{"class":429},[262,46597,654],{"class":271},[262,46599,657],{"class":275},[262,46601,660],{"class":429},[14,46603,663],{},[57,46605,667],{"id":666},[14,46607,670,46608,674],{},[35,46609,673],{},[253,46611,46612],{"className":414,"code":677,"language":416,"meta":258,"style":258},[18,46613,46614,46620,46630,46640,46650,46654,46658,46666,46682,46686,46690,46702,46706,46728,46742,46746,46750,46760],{"__ignoreMap":258},[262,46615,46616,46618],{"class":181,"line":264},[262,46617,684],{"class":377},[262,46619,687],{"class":429},[262,46621,46622,46624,46626,46628],{"class":181,"line":282},[262,46623,684],{"class":377},[262,46625,694],{"class":429},[262,46627,697],{"class":377},[262,46629,700],{"class":429},[262,46631,46632,46634,46636,46638],{"class":181,"line":295},[262,46633,705],{"class":377},[262,46635,708],{"class":429},[262,46637,684],{"class":377},[262,46639,713],{"class":429},[262,46641,46642,46644,46646,46648],{"class":181,"line":345},[262,46643,705],{"class":377},[262,46645,720],{"class":429},[262,46647,684],{"class":377},[262,46649,725],{"class":429},[262,46651,46652],{"class":181,"line":492},[262,46653,583],{"emptyLinePlaceholder":582},[262,46655,46656],{"class":181,"line":503},[262,46657,734],{"class":429},[262,46659,46660,46662,46664],{"class":181,"line":521},[262,46661,739],{"class":429},[262,46663,476],{"class":377},[262,46665,744],{"class":429},[262,46667,46668,46670,46672,46674,46676,46678,46680],{"class":181,"line":537},[262,46669,749],{"class":271},[262,46671,442],{"class":377},[262,46673,754],{"class":429},[262,46675,757],{"class":275},[262,46677,608],{"class":429},[262,46679,762],{"class":275},[262,46681,660],{"class":429},[262,46683,46684],{"class":181,"line":549},[262,46685,583],{"emptyLinePlaceholder":582},[262,46687,46688],{"class":181,"line":570},[262,46689,583],{"emptyLinePlaceholder":582},[262,46691,46692,46694,46696,46698,46700],{"class":181,"line":579},[262,46693,423],{"class":377},[262,46695,779],{"class":267},[262,46697,782],{"class":429},[262,46699,433],{"class":271},[262,46701,787],{"class":429},[262,46703,46704],{"class":181,"line":586},[262,46705,792],{"class":275},[262,46707,46708,46710,46712,46714,46716,46718,46720,46722,46724,46726],{"class":181,"line":591},[262,46709,797],{"class":429},[262,46711,476],{"class":377},[262,46713,802],{"class":429},[262,46715,805],{"class":611},[262,46717,476],{"class":377},[262,46719,749],{"class":271},[262,46721,608],{"class":429},[262,46723,814],{"class":611},[262,46725,476],{"class":377},[262,46727,819],{"class":429},[262,46729,46730,46732,46734,46736,46738,46740],{"class":181,"line":623},[262,46731,573],{"class":377},[262,46733,826],{"class":429},[262,46735,829],{"class":377},[262,46737,832],{"class":429},[262,46739,835],{"class":377},[262,46741,838],{"class":429},[262,46743,46744],{"class":181,"line":634},[262,46745,583],{"emptyLinePlaceholder":582},[262,46747,46748],{"class":181,"line":845},[262,46749,583],{"emptyLinePlaceholder":582},[262,46751,46752,46754,46756,46758],{"class":181,"line":850},[262,46753,853],{"class":429},[262,46755,476],{"class":377},[262,46757,858],{"class":429},[262,46759,861],{"class":291},[262,46761,46762,46764,46766],{"class":181,"line":864},[262,46763,637],{"class":271},[262,46765,869],{"class":429},[262,46767,872],{"class":291},[14,46769,875,46770,879],{},[18,46771,878],{},[57,46773,883],{"id":882},[14,46775,886,46776,890,46778,894],{},[35,46777,889],{},[18,46779,893],{},[253,46781,46782],{"className":414,"code":897,"language":416,"meta":258,"style":258},[18,46783,46784,46800,46816,46820,46832,46836,46852,46868,46872,46888,46902,46906,46910,46922,46932],{"__ignoreMap":258},[262,46785,46786,46788,46790,46792,46794,46796,46798],{"class":181,"line":264},[262,46787,423],{"class":377},[262,46789,906],{"class":267},[262,46791,909],{"class":429},[262,46793,433],{"class":271},[262,46795,914],{"class":429},[262,46797,433],{"class":271},[262,46799,919],{"class":429},[262,46801,46802,46804,46806,46808,46810,46812,46814],{"class":181,"line":282},[262,46803,924],{"class":429},[262,46805,439],{"class":271},[262,46807,442],{"class":377},[262,46809,931],{"class":271},[262,46811,458],{"class":429},[262,46813,433],{"class":271},[262,46815,463],{"class":429},[262,46817,46818],{"class":181,"line":295},[262,46819,942],{"class":275},[262,46821,46822,46824,46826,46828,46830],{"class":181,"line":345},[262,46823,947],{"class":429},[262,46825,476],{"class":377},[262,46827,952],{"class":429},[262,46829,102],{"class":271},[262,46831,957],{"class":429},[262,46833,46834],{"class":181,"line":492},[262,46835,962],{"class":291},[262,46837,46838,46840,46842,46844,46846,46848,46850],{"class":181,"line":503},[262,46839,967],{"class":429},[262,46841,476],{"class":377},[262,46843,972],{"class":429},[262,46845,975],{"class":377},[262,46847,978],{"class":429},[262,46849,981],{"class":377},[262,46851,984],{"class":429},[262,46853,46854,46856,46858,46860,46862,46864,46866],{"class":181,"line":521},[262,46855,989],{"class":429},[262,46857,992],{"class":611},[262,46859,476],{"class":377},[262,46861,997],{"class":271},[262,46863,1000],{"class":429},[262,46865,1003],{"class":377},[262,46867,1006],{"class":429},[262,46869,46870],{"class":181,"line":537},[262,46871,1011],{"class":429},[262,46873,46874,46876,46878,46880,46882,46884,46886],{"class":181,"line":549},[262,46875,1016],{"class":429},[262,46877,476],{"class":377},[262,46879,1021],{"class":429},[262,46881,561],{"class":377},[262,46883,997],{"class":271},[262,46885,1028],{"class":429},[262,46887,1031],{"class":291},[262,46889,46890,46892,46894,46896,46898,46900],{"class":181,"line":570},[262,46891,573],{"class":377},[262,46893,1038],{"class":429},[262,46895,829],{"class":377},[262,46897,1043],{"class":429},[262,46899,835],{"class":377},[262,46901,1048],{"class":429},[262,46903,46904],{"class":181,"line":579},[262,46905,583],{"emptyLinePlaceholder":582},[262,46907,46908],{"class":181,"line":586},[262,46909,583],{"emptyLinePlaceholder":582},[262,46911,46912,46914,46916,46918,46920],{"class":181,"line":591},[262,46913,1061],{"class":429},[262,46915,476],{"class":377},[262,46917,1066],{"class":429},[262,46919,1069],{"class":275},[262,46921,1072],{"class":429},[262,46923,46924,46926,46928,46930],{"class":181,"line":623},[262,46925,829],{"class":377},[262,46927,1079],{"class":429},[262,46929,835],{"class":377},[262,46931,1084],{"class":429},[262,46933,46934,46936,46938,46940,46942,46944,46946,46948],{"class":181,"line":634},[262,46935,1089],{"class":271},[262,46937,602],{"class":429},[262,46939,1094],{"class":275},[262,46941,1097],{"class":429},[262,46943,1100],{"class":271},[262,46945,1103],{"class":429},[262,46947,1106],{"class":275},[262,46949,660],{"class":429},[14,46951,46952,1114,46954,1118,46956,1121],{},[18,46953,1113],{},[18,46955,1117],{},[18,46957,24],{},[57,46959,1125],{"id":1124},[14,46961,1128,46962,1132],{},[27,46963,1131],{},[253,46965,46966],{"className":414,"code":1135,"language":416,"meta":258,"style":258},[18,46967,46968,46988,47002,47010,47026,47034,47050,47054,47062,47076,47080,47096,47100,47110,47114,47124,47128,47132],{"__ignoreMap":258},[262,46969,46970,46972,46974,46976,46978,46980,46982,46984,46986],{"class":181,"line":264},[262,46971,423],{"class":377},[262,46973,1144],{"class":267},[262,46975,909],{"class":429},[262,46977,433],{"class":271},[262,46979,914],{"class":429},[262,46981,433],{"class":271},[262,46983,1155],{"class":429},[262,46985,433],{"class":271},[262,46987,1160],{"class":429},[262,46989,46990,46992,46994,46996,46998,47000],{"class":181,"line":282},[262,46991,1165],{"class":429},[262,46993,476],{"class":377},[262,46995,1170],{"class":275},[262,46997,1173],{"class":271},[262,46999,1176],{"class":275},[262,47001,1179],{"class":429},[262,47003,47004,47006,47008],{"class":181,"line":295},[262,47005,1184],{"class":429},[262,47007,476],{"class":377},[262,47009,1189],{"class":429},[262,47011,47012,47014,47016,47018,47020,47022,47024],{"class":181,"line":345},[262,47013,1194],{"class":611},[262,47015,476],{"class":377},[262,47017,1199],{"class":429},[262,47019,1202],{"class":275},[262,47021,608],{"class":429},[262,47023,1207],{"class":275},[262,47025,1210],{"class":429},[262,47027,47028,47030,47032],{"class":181,"line":492},[262,47029,1215],{"class":611},[262,47031,476],{"class":377},[262,47033,1220],{"class":429},[262,47035,47036,47038,47040,47042,47044,47046,47048],{"class":181,"line":503},[262,47037,1225],{"class":429},[262,47039,1228],{"class":275},[262,47041,1231],{"class":429},[262,47043,1234],{"class":275},[262,47045,608],{"class":429},[262,47047,1239],{"class":275},[262,47049,1242],{"class":429},[262,47051,47052],{"class":181,"line":521},[262,47053,1247],{"class":275},[262,47055,47056,47058,47060],{"class":181,"line":537},[262,47057,1252],{"class":275},[262,47059,1173],{"class":271},[262,47061,1257],{"class":275},[262,47063,47064,47066,47068,47070,47072,47074],{"class":181,"line":549},[262,47065,1262],{"class":377},[262,47067,1265],{"class":275},[262,47069,1268],{"class":271},[262,47071,1271],{"class":429},[262,47073,654],{"class":271},[262,47075,1257],{"class":275},[262,47077,47078],{"class":181,"line":570},[262,47079,1280],{"class":429},[262,47081,47082,47084,47086,47088,47090,47092,47094],{"class":181,"line":579},[262,47083,1225],{"class":429},[262,47085,1228],{"class":275},[262,47087,1231],{"class":429},[262,47089,1291],{"class":275},[262,47091,608],{"class":429},[262,47093,1239],{"class":275},[262,47095,1298],{"class":429},[262,47097,47098],{"class":181,"line":586},[262,47099,1303],{"class":429},[262,47101,47102,47104,47106,47108],{"class":181,"line":591},[262,47103,1308],{"class":611},[262,47105,476],{"class":377},[262,47107,102],{"class":271},[262,47109,1315],{"class":429},[262,47111,47112],{"class":181,"line":623},[262,47113,1011],{"class":429},[262,47115,47116,47118,47120,47122],{"class":181,"line":634},[262,47117,573],{"class":377},[262,47119,1326],{"class":429},[262,47121,102],{"class":271},[262,47123,1331],{"class":429},[262,47125,47126],{"class":181,"line":845},[262,47127,583],{"emptyLinePlaceholder":582},[262,47129,47130],{"class":181,"line":850},[262,47131,583],{"emptyLinePlaceholder":582},[262,47133,47134,47136,47138,47140],{"class":181,"line":864},[262,47135,637],{"class":271},[262,47137,1346],{"class":429},[262,47139,1069],{"class":275},[262,47141,1351],{"class":429},[14,47143,1354,47144,1358,47146,1363],{},[18,47145,1357],{},[51,47147,1362],{"href":1361},[57,47149,1367],{"id":1366},[14,47151,1370,47152,1374,47154,1377],{},[18,47153,1373],{},[18,47155,893],{},[1379,47157,47158,47168],{},[1382,47159,47160],{},[1385,47161,47162,47164,47166],{},[1388,47163,1390],{},[1388,47165,1393],{},[1388,47167,1396],{},[1398,47169,47170,47180,47190],{},[1385,47171,47172,47176,47178],{},[1403,47173,47174],{},[18,47175,1373],{},[1403,47177,1409],{},[1403,47179,1412],{},[1385,47181,47182,47186,47188],{},[1403,47183,47184],{},[18,47185,893],{},[1403,47187,1421],{},[1403,47189,1424],{},[1385,47191,47192,47196,47200],{},[1403,47193,47194],{},[18,47195,749],{},[1403,47197,47198],{},[18,47199,878],{},[1403,47201,1437,47202,1441],{},[18,47203,1440],{},[57,47205,1445],{"id":1444},[1447,47207,47208,47218,47232,47236],{},[1450,47209,47210,1455,47212,1458,47214,1461,47216,1464],{},[35,47211,1454],{},[18,47213,893],{},[18,47215,1373],{},[18,47217,893],{},[1450,47219,47220,1476,47226,1479,47228,1482,47230,1486],{},[35,47221,47222,1472,47224],{},[18,47223,1471],{},[18,47225,1475],{},[18,47227,1373],{},[18,47229,1373],{},[18,47231,1485],{},[1450,47233,47234,1492],{},[35,47235,1491],{},[1450,47237,47238,1500,47242,1503,47244,1506,47246,1509,47248,1363],{},[35,47239,47240],{},[18,47241,1499],{},[18,47243,893],{},[18,47245,893],{},[18,47247,1373],{},[51,47249,1513],{"href":1512},[57,47251,1517],{"id":1516},[14,47253,1520,47254,1524,47256,1527,47258,1363],{},[18,47255,1523],{},[18,47257,319],{},[18,47259,1530],{},[253,47261,47262],{"className":414,"code":1533,"language":416,"meta":258,"style":258},[18,47263,47264,47270,47280,47290,47300,47304,47308,47324,47340,47356,47360,47364,47372,47376,47380,47384,47388,47392,47396,47400,47404,47436,47444,47454,47466,47478,47490,47496,47500,47504,47516,47538,47552,47556,47560,47574,47588,47592,47596,47620,47632,47664,47678,47702,47706,47710,47726,47734,47742,47752,47760,47778,47794,47810,47814,47824,47828,47838,47842,47846,47858,47868,47876,47890,47908,47912],{"__ignoreMap":258},[262,47265,47266,47268],{"class":181,"line":264},[262,47267,684],{"class":377},[262,47269,687],{"class":429},[262,47271,47272,47274,47276,47278],{"class":181,"line":282},[262,47273,684],{"class":377},[262,47275,694],{"class":429},[262,47277,697],{"class":377},[262,47279,700],{"class":429},[262,47281,47282,47284,47286,47288],{"class":181,"line":295},[262,47283,705],{"class":377},[262,47285,708],{"class":429},[262,47287,684],{"class":377},[262,47289,713],{"class":429},[262,47291,47292,47294,47296,47298],{"class":181,"line":345},[262,47293,705],{"class":377},[262,47295,720],{"class":429},[262,47297,684],{"class":377},[262,47299,725],{"class":429},[262,47301,47302],{"class":181,"line":492},[262,47303,583],{"emptyLinePlaceholder":582},[262,47305,47306],{"class":181,"line":503},[262,47307,734],{"class":429},[262,47309,47310,47312,47314,47316,47318,47320,47322],{"class":181,"line":521},[262,47311,739],{"class":429},[262,47313,476],{"class":377},[262,47315,1588],{"class":429},[262,47317,1591],{"class":611},[262,47319,476],{"class":377},[262,47321,1596],{"class":271},[262,47323,660],{"class":429},[262,47325,47326,47328,47330,47332,47334,47336,47338],{"class":181,"line":537},[262,47327,1603],{"class":271},[262,47329,442],{"class":377},[262,47331,754],{"class":429},[262,47333,1202],{"class":275},[262,47335,608],{"class":429},[262,47337,1207],{"class":275},[262,47339,660],{"class":429},[262,47341,47342,47344,47346,47348,47350,47352,47354],{"class":181,"line":549},[262,47343,749],{"class":271},[262,47345,442],{"class":377},[262,47347,754],{"class":429},[262,47349,757],{"class":275},[262,47351,608],{"class":429},[262,47353,762],{"class":275},[262,47355,660],{"class":429},[262,47357,47358],{"class":181,"line":570},[262,47359,583],{"emptyLinePlaceholder":582},[262,47361,47362],{"class":181,"line":579},[262,47363,1640],{"class":291},[262,47365,47366,47368,47370],{"class":181,"line":586},[262,47367,1645],{"class":271},[262,47369,442],{"class":377},[262,47371,1650],{"class":275},[262,47373,47374],{"class":181,"line":591},[262,47375,1655],{"class":275},[262,47377,47378],{"class":181,"line":623},[262,47379,1660],{"class":275},[262,47381,47382],{"class":181,"line":634},[262,47383,1665],{"class":275},[262,47385,47386],{"class":181,"line":845},[262,47387,1670],{"class":275},[262,47389,47390],{"class":181,"line":850},[262,47391,1675],{"class":275},[262,47393,47394],{"class":181,"line":864},[262,47395,1680],{"class":275},[262,47397,47398],{"class":181,"line":1683},[262,47399,583],{"emptyLinePlaceholder":582},[262,47401,47402],{"class":181,"line":1688},[262,47403,583],{"emptyLinePlaceholder":582},[262,47405,47406,47408,47410,47412,47414,47416,47418,47420,47422,47424,47426,47428,47430,47432,47434],{"class":181,"line":1693},[262,47407,423],{"class":377},[262,47409,426],{"class":267},[262,47411,430],{"class":429},[262,47413,433],{"class":271},[262,47415,436],{"class":429},[262,47417,439],{"class":271},[262,47419,442],{"class":377},[262,47421,1710],{"class":271},[262,47423,448],{"class":429},[262,47425,439],{"class":271},[262,47427,442],{"class":377},[262,47429,1719],{"class":271},[262,47431,458],{"class":429},[262,47433,433],{"class":271},[262,47435,463],{"class":429},[262,47437,47438,47440,47442],{"class":181,"line":1728},[262,47439,473],{"class":429},[262,47441,476],{"class":377},[262,47443,479],{"class":429},[262,47445,47446,47448,47450,47452],{"class":181,"line":1737},[262,47447,1740],{"class":429},[262,47449,476],{"class":377},[262,47451,1745],{"class":429},[262,47453,1748],{"class":271},[262,47455,47456,47458,47460,47462,47464],{"class":181,"line":1751},[262,47457,506],{"class":377},[262,47459,509],{"class":429},[262,47461,512],{"class":377},[262,47463,515],{"class":271},[262,47465,518],{"class":429},[262,47467,47468,47470,47472,47474,47476],{"class":181,"line":1764},[262,47469,540],{"class":429},[262,47471,543],{"class":275},[262,47473,1771],{"class":429},[262,47475,531],{"class":377},[262,47477,1776],{"class":429},[262,47479,47480,47482,47484,47486,47488],{"class":181,"line":1779},[262,47481,552],{"class":429},[262,47483,555],{"class":377},[262,47485,558],{"class":429},[262,47487,561],{"class":377},[262,47489,1790],{"class":429},[262,47491,47492,47494],{"class":181,"line":1793},[262,47493,573],{"class":377},[262,47495,576],{"class":429},[262,47497,47498],{"class":181,"line":1800},[262,47499,583],{"emptyLinePlaceholder":582},[262,47501,47502],{"class":181,"line":1805},[262,47503,583],{"emptyLinePlaceholder":582},[262,47505,47506,47508,47510,47512,47514],{"class":181,"line":1810},[262,47507,423],{"class":377},[262,47509,779],{"class":267},[262,47511,782],{"class":429},[262,47513,433],{"class":271},[262,47515,787],{"class":429},[262,47517,47518,47520,47522,47524,47526,47528,47530,47532,47534,47536],{"class":181,"line":1823},[262,47519,797],{"class":429},[262,47521,476],{"class":377},[262,47523,802],{"class":429},[262,47525,805],{"class":611},[262,47527,476],{"class":377},[262,47529,749],{"class":271},[262,47531,608],{"class":429},[262,47533,814],{"class":611},[262,47535,476],{"class":377},[262,47537,819],{"class":429},[262,47539,47540,47542,47544,47546,47548,47550],{"class":181,"line":1846},[262,47541,573],{"class":377},[262,47543,826],{"class":429},[262,47545,829],{"class":377},[262,47547,832],{"class":429},[262,47549,835],{"class":377},[262,47551,838],{"class":429},[262,47553,47554],{"class":181,"line":1861},[262,47555,583],{"emptyLinePlaceholder":582},[262,47557,47558],{"class":181,"line":1866},[262,47559,583],{"emptyLinePlaceholder":582},[262,47561,47562,47564,47566,47568,47570,47572],{"class":181,"line":1871},[262,47563,1874],{"class":271},[262,47565,442],{"class":377},[262,47567,1879],{"class":429},[262,47569,1645],{"class":271},[262,47571,1884],{"class":429},[262,47573,1887],{"class":291},[262,47575,47576,47578,47580,47582,47584,47586],{"class":181,"line":1890},[262,47577,1893],{"class":271},[262,47579,442],{"class":377},[262,47581,1898],{"class":429},[262,47583,1874],{"class":271},[262,47585,1903],{"class":429},[262,47587,1906],{"class":291},[262,47589,47590],{"class":181,"line":1909},[262,47591,583],{"emptyLinePlaceholder":582},[262,47593,47594],{"class":181,"line":1914},[262,47595,583],{"emptyLinePlaceholder":582},[262,47597,47598,47600,47602,47604,47606,47608,47610,47612,47614,47616,47618],{"class":181,"line":1919},[262,47599,423],{"class":377},[262,47601,906],{"class":267},[262,47603,909],{"class":429},[262,47605,433],{"class":271},[262,47607,1930],{"class":429},[262,47609,439],{"class":271},[262,47611,442],{"class":377},[262,47613,931],{"class":271},[262,47615,1939],{"class":429},[262,47617,433],{"class":271},[262,47619,1160],{"class":429},[262,47621,47622,47624,47626,47628,47630],{"class":181,"line":1946},[262,47623,947],{"class":429},[262,47625,476],{"class":377},[262,47627,952],{"class":429},[262,47629,102],{"class":271},[262,47631,957],{"class":429},[262,47633,47634,47636,47638,47640,47642,47644,47646,47648,47650,47652,47654,47656,47658,47660,47662],{"class":181,"line":1959},[262,47635,967],{"class":429},[262,47637,476],{"class":377},[262,47639,1966],{"class":271},[262,47641,1969],{"class":377},[262,47643,978],{"class":429},[262,47645,981],{"class":377},[262,47647,1976],{"class":429},[262,47649,1893],{"class":271},[262,47651,608],{"class":429},[262,47653,992],{"class":611},[262,47655,476],{"class":377},[262,47657,997],{"class":271},[262,47659,1000],{"class":429},[262,47661,1003],{"class":377},[262,47663,1993],{"class":429},[262,47665,47666,47668,47670,47672,47674,47676],{"class":181,"line":1996},[262,47667,1016],{"class":429},[262,47669,476],{"class":377},[262,47671,1021],{"class":429},[262,47673,561],{"class":377},[262,47675,997],{"class":271},[262,47677,2009],{"class":429},[262,47679,47680,47682,47684,47686,47688,47690,47692,47694,47696,47698,47700],{"class":181,"line":2012},[262,47681,573],{"class":377},[262,47683,1170],{"class":275},[262,47685,1173],{"class":271},[262,47687,1176],{"class":275},[262,47689,2023],{"class":429},[262,47691,1874],{"class":271},[262,47693,2028],{"class":429},[262,47695,829],{"class":377},[262,47697,1043],{"class":429},[262,47699,835],{"class":377},[262,47701,2037],{"class":429},[262,47703,47704],{"class":181,"line":2040},[262,47705,583],{"emptyLinePlaceholder":582},[262,47707,47708],{"class":181,"line":2045},[262,47709,583],{"emptyLinePlaceholder":582},[262,47711,47712,47714,47716,47718,47720,47722,47724],{"class":181,"line":2050},[262,47713,423],{"class":377},[262,47715,1144],{"class":267},[262,47717,909],{"class":429},[262,47719,433],{"class":271},[262,47721,1939],{"class":429},[262,47723,433],{"class":271},[262,47725,1160],{"class":429},[262,47727,47728,47730,47732],{"class":181,"line":2067},[262,47729,1165],{"class":429},[262,47731,476],{"class":377},[262,47733,2074],{"class":429},[262,47735,47736,47738,47740],{"class":181,"line":2077},[262,47737,1184],{"class":429},[262,47739,476],{"class":377},[262,47741,1189],{"class":429},[262,47743,47744,47746,47748,47750],{"class":181,"line":2086},[262,47745,1194],{"class":611},[262,47747,476],{"class":377},[262,47749,1603],{"class":271},[262,47751,1315],{"class":429},[262,47753,47754,47756,47758],{"class":181,"line":2097},[262,47755,1215],{"class":611},[262,47757,476],{"class":377},[262,47759,1220],{"class":429},[262,47761,47762,47764,47766,47768,47770,47772,47774,47776],{"class":181,"line":2106},[262,47763,1225],{"class":429},[262,47765,1228],{"class":275},[262,47767,1231],{"class":429},[262,47769,1234],{"class":275},[262,47771,608],{"class":429},[262,47773,1239],{"class":275},[262,47775,1231],{"class":429},[262,47777,2123],{"class":275},[262,47779,47780,47782,47784,47786,47788,47790,47792],{"class":181,"line":2126},[262,47781,2129],{"class":275},[262,47783,1173],{"class":271},[262,47785,2134],{"class":275},[262,47787,2137],{"class":271},[262,47789,1176],{"class":275},[262,47791,2142],{"class":377},[262,47793,2145],{"class":429},[262,47795,47796,47798,47800,47802,47804,47806,47808],{"class":181,"line":2148},[262,47797,1225],{"class":429},[262,47799,1228],{"class":275},[262,47801,1231],{"class":429},[262,47803,1291],{"class":275},[262,47805,608],{"class":429},[262,47807,1239],{"class":275},[262,47809,1298],{"class":429},[262,47811,47812],{"class":181,"line":2165},[262,47813,1303],{"class":429},[262,47815,47816,47818,47820,47822],{"class":181,"line":2170},[262,47817,1308],{"class":611},[262,47819,476],{"class":377},[262,47821,102],{"class":271},[262,47823,1315],{"class":429},[262,47825,47826],{"class":181,"line":2181},[262,47827,1011],{"class":429},[262,47829,47830,47832,47834,47836],{"class":181,"line":2186},[262,47831,573],{"class":377},[262,47833,1326],{"class":429},[262,47835,102],{"class":271},[262,47837,1331],{"class":429},[262,47839,47840],{"class":181,"line":2197},[262,47841,583],{"emptyLinePlaceholder":582},[262,47843,47844],{"class":181,"line":2202},[262,47845,583],{"emptyLinePlaceholder":582},[262,47847,47848,47850,47852,47854,47856],{"class":181,"line":2207},[262,47849,2210],{"class":377},[262,47851,2213],{"class":271},[262,47853,2216],{"class":377},[262,47855,2219],{"class":275},[262,47857,1160],{"class":429},[262,47859,47860,47862,47864,47866],{"class":181,"line":2224},[262,47861,1089],{"class":271},[262,47863,602],{"class":429},[262,47865,2231],{"class":275},[262,47867,660],{"class":429},[262,47869,47870,47872,47874],{"class":181,"line":2236},[262,47871,506],{"class":377},[262,47873,2241],{"class":271},[262,47875,1160],{"class":429},[262,47877,47878,47880,47882,47884,47886,47888],{"class":181,"line":2246},[262,47879,2249],{"class":429},[262,47881,476],{"class":377},[262,47883,2254],{"class":271},[262,47885,602],{"class":429},[262,47887,2259],{"class":275},[262,47889,2262],{"class":429},[262,47891,47892,47894,47896,47898,47900,47902,47904,47906],{"class":181,"line":2265},[262,47893,2268],{"class":377},[262,47895,2271],{"class":429},[262,47897,835],{"class":377},[262,47899,2276],{"class":429},[262,47901,2279],{"class":275},[262,47903,608],{"class":429},[262,47905,2284],{"class":275},[262,47907,2287],{"class":429},[262,47909,47910],{"class":181,"line":2290},[262,47911,2293],{"class":377},[262,47913,47914,47916,47918,47920],{"class":181,"line":2296},[262,47915,2299],{"class":271},[262,47917,602],{"class":429},[262,47919,2304],{"class":275},[262,47921,2307],{"class":429},[14,47923,2310,47924,2313],{},[18,47925,1645],{},[57,47927,2317],{"id":2316},[14,47929,2320],{},[2322,47931,47932,47938,47944],{},[1450,47933,47934,2332],{},[35,47935,2328,47936],{},[27,47937,2331],{},[1450,47939,47940,2342],{},[35,47941,2337,47942,2341],{},[27,47943,2340],{},[1450,47945,47946,2348],{},[35,47947,2347],{},[14,47949,2351],{},[57,47951,2355],{"id":2354},[14,47953,2358,47954,2363,47956,2368,47958,1363],{},[51,47955,2362],{"href":2361},[51,47957,2367],{"href":2366},[51,47959,2372],{"href":2371},[14,47961,2375,47962,1363],{},[51,47963,54],{"href":53},[57,47965,2381],{"id":2380},[2322,47967,47968,47972,47976,47980],{},[1450,47969,47970],{},[51,47971,54],{"href":53},[1450,47973,47974],{},[51,47975,2367],{"href":2366},[1450,47977,47978],{},[51,47979,2362],{"href":2361},[1450,47981,47982],{},[51,47983,2372],{"href":2371},[2401,47985,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":47987},[47988,47989,47990,47991,47992,47993,47994,47995,47996,47997,47998,47999],{"id":59,"depth":282,"text":60},{"id":237,"depth":282,"text":238},{"id":392,"depth":282,"text":393},{"id":666,"depth":282,"text":667},{"id":882,"depth":282,"text":883},{"id":1124,"depth":282,"text":1125},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":1516,"depth":282,"text":1517},{"id":2316,"depth":282,"text":2317},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},[48001,48002,48003,48004,48005],{"q":2422,"a":2423},{"q":2425,"a":2426},{"q":2428,"a":2429},{"q":2431,"a":2432},{"q":2434,"a":2435},{"name":2437,"steps":48007},[48008,48009,48010,48011],{"name":2440,"text":2441},{"name":2443,"text":2444},{"name":2446,"text":2447},{"name":2449,"text":2450},{},{"title":5,"description":2418},{"id":48015,"title":48016,"body":48017,"description":50627,"extension":2419,"faq":50628,"howto":50644,"meta":50659,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":50660,"published":50661,"seo":50662,"seoTitle":50663,"stem":50664,"__hash__":50665},"content\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Findex.md","Custom AI Chatbot Development: A Step-by-Step Python Guide",{"type":7,"value":48018,"toc":50613},[48019,48022,48034,48040,48049,48053,48056,48062,48173,48175,48182,48187,48223,48229,48249,48258,48272,48276,48280,48292,48305,48470,48486,48495,48500,48504,48511,48756,48768,48772,48781,48790,48796,49293,49304,49308,49311,49574,49601,49605,49614,49619,49621,49627,49756,49758,49831,49835,49846,50530,50533,50535,50538,50575,50583,50587,50589,50611],[10,48020,48016],{"id":48021},"custom-ai-chatbot-development-a-step-by-step-python-guide",[14,48023,48024,48025,48027,48028,48030,48031,48033],{},"A chatbot you build yourself does things the drag-and-drop platforms never will: it answers from ",[27,48026,29],{}," documents, follows ",[27,48029,29],{}," tone, plugs into ",[27,48032,29],{}," tools, and never charges you per seat. The catch is that most tutorials jump straight to heavyweight frameworks and leave you with a black box you cannot debug. This guide does the opposite. You will build a working chatbot from a single API call, then add the three features that separate a toy from something you can put in front of customers: memory, retrieval, and graceful error handling.",[14,48035,48036,48037,48039],{},"You do not need a machine learning background. The model you will use is already trained and hosted by a provider; your job is to send it the right messages and handle the replies. If you can write a ",[18,48038,829],{}," loop and read a dictionary, you can finish this guide. By the end you will have a runnable chatbot script and a clear map of which child guide to read next for each feature you want to deepen.",[14,48041,48042,48043,48045,48046,48048],{},"This is one section of ",[51,48044,26457],{"href":26456},". If you are brand new to calling models from Python, read ",[51,48047,2487],{"href":2486}," first to see how requests, keys, and responses fit together.",[57,48050,48052],{"id":48051},"what-a-chatbot-actually-does","What a chatbot actually does",[14,48054,48055],{},"Before the code, hold one picture in your head. A chatbot is a loop. The user sends a message, your Python app gathers some context (the conversation so far, maybe a few relevant document snippets), sends all of it to the model, and shows the reply back to the user. Everything else in this guide is just making each part of that loop smarter.",[14,48057,48058,48059,48061],{},"The single most important thing to understand is that the model is ",[35,48060,42933],{},". It has no memory between calls and no live connection to your business. Each API request is a clean slate: it sees only the text you send in that one request and nothing else. That sounds like a limitation, but it is actually freeing, because it means every \"smart\" feature reduces to the same job — deciding what text to put into the next request. Memory is text you re-send. Retrieval is text you look up and attach. Tools are descriptions of functions you include. Once that clicks, the rest of this guide is mechanical.",[76,48063,48065,48170],{"className":48064},[79],[81,48066,90,48071,90,48074,90,48077,90,48079,90,48082,90,48085,90,48089,90,48094,90,48097,90,48100,90,48102,90,48105,90,48109,90,48112,90,48114,90,48118,90,48122,90,48126,90,48129,90,48132,90,48135,90,48138,90,48141,90,48144,90,48147,90,48149,90,48152,90,48156,90,48159,90,48163,90,48166],{"viewBox":48067,"role":84,"ariaLabelledBy":48068,"preserveAspectRatio":88,"xmlns":89},"-40 -40 920 500",[48069,48070],"botTitle","botDesc",[92,48072,48073],{"id":48069},"Chatbot request flow with memory and retrieval",[96,48075,48076],{"id":48070},"A user message flows into the Python app, which gathers conversation history and retrieved document snippets, sends everything to the language model, and returns the reply to the user.",[100,48078],{"x":102,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,48080,48081],{"x":113,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"User",[111,48083,48084],{"x":113,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"asks a question",[100,48086],{"x":7154,"y":48087,"width":104,"height":48088,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"165","82",[111,48090,48093],{"x":48091,"y":48092,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"420","194","Your Python app",[111,48095,48096],{"x":48091,"y":24398,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"builds messages,",[111,48098,48099],{"x":48091,"y":24350,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"calls the API",[100,48101],{"x":7154,"y":140,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,48103,48104],{"x":48091,"y":19868,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Conversation",[111,48106,48108],{"x":48091,"y":48107,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"66","memory",[111,48110,48111],{"x":48091,"y":11749,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"past turns",[100,48113],{"x":7154,"y":7154,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,48115,48117],{"x":48091,"y":48116,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"351","Document",[111,48119,48121],{"x":48091,"y":48120,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"366","retrieval",[111,48123,48125],{"x":48091,"y":48124,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"384","relevant snippets",[100,48127],{"x":48128,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},"640",[111,48130,173],{"x":48131,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"740",[111,48133,48134],{"x":48131,"y":123,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"generates reply",[181,48136],{"x1":104,"y1":183,"x2":48137,"y2":183,"stroke":108,"strokeWidth":109},"312",[186,48139],{"points":48140,"fill":108},"320,206 312,202 312,210",[181,48142],{"x1":48091,"y1":141,"x2":48091,"y2":48143,"stroke":143,"strokeWidth":144},"157",[186,48145],{"points":48146,"fill":143},"420,165 416,157 424,157",[181,48148],{"x1":48091,"y1":7154,"x2":48091,"y2":14016,"stroke":143,"strokeWidth":144},[186,48150],{"points":48151,"fill":143},"420,247 416,255 424,255",[181,48153],{"x1":48154,"y1":183,"x2":48155,"y2":183,"stroke":130,"strokeWidth":109},"520","632",[186,48157],{"points":48158,"fill":130},"640,206 632,202 632,210",[216,48160],{"d":48161,"fill":219,"stroke":169,"strokeWidth":109,"strokeDashArray":48162},"M 740 242 C 740 300, 100 300, 100 250",[221,222],[186,48164],{"points":48165,"fill":169},"100,242 96,250 104,250",[111,48167,48169],{"x":48091,"y":48168,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"293","reply to the user",[232,48171,48172],{},"Every chatbot is the same loop: gather context, call the model, return the reply. Memory and retrieval just enrich what you send.",[57,48174,238],{"id":237},[14,48176,39793,48177,48179,48180,30416],{},[18,48178,244],{},". If that command fails or shows an older version, follow ",[51,48181,5423],{"href":5422},[14,48183,48184,48185,30422],{},"Work inside a virtual environment so this project's packages stay isolated from the rest of your system. If virtual environments are new to you, ",[51,48186,2482],{"href":2481},[253,48188,48189],{"className":255,"code":256,"language":257,"meta":258,"style":258},[18,48190,48191,48201,48209],{"__ignoreMap":258},[262,48192,48193,48195,48197,48199],{"class":181,"line":264},[262,48194,268],{"class":267},[262,48196,272],{"class":271},[262,48198,276],{"class":275},[262,48200,279],{"class":275},[262,48202,48203,48205,48207],{"class":181,"line":282},[262,48204,285],{"class":271},[262,48206,288],{"class":275},[262,48208,292],{"class":291},[262,48210,48211,48213,48215,48217,48219,48221],{"class":181,"line":295},[262,48212,298],{"class":267},[262,48214,301],{"class":275},[262,48216,304],{"class":275},[262,48218,307],{"class":275},[262,48220,310],{"class":275},[262,48222,313],{"class":275},[14,48224,48225,48226,48228],{},"You also need an API key from your model provider. Create a ",[18,48227,319],{}," file in your project folder to hold it so it never gets pasted into code:",[253,48230,48231],{"className":323,"code":324,"language":325,"meta":258,"style":258},[18,48232,48233,48237,48241,48245],{"__ignoreMap":258},[262,48234,48235],{"class":181,"line":264},[262,48236,332],{},[262,48238,48239],{"class":181,"line":282},[262,48240,337],{},[262,48242,48243],{"class":181,"line":295},[262,48244,342],{},[262,48246,48247],{"class":181,"line":345},[262,48248,348],{},[14,48250,48251,48257],{},[35,48252,353,48253,356,48255,360],{},[18,48254,319],{},[18,48256,359],{}," so your secret key is never committed to version control. One leaked key can run up a real bill.",[253,48259,48260],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,48261,48262],{"__ignoreMap":258},[262,48263,48264,48266,48268,48270],{"class":181,"line":264},[262,48265,371],{"class":271},[262,48267,374],{"class":275},[262,48269,378],{"class":377},[262,48271,381],{"class":275},[14,48273,384,48274,389],{},[51,48275,388],{"href":387},[57,48277,48279],{"id":48278},"step-1-send-your-first-chat-message","Step 1: Send your first chat message",[14,48281,48282,48283,48285,48286,48288,48289,48291],{},"Start with the smallest possible chatbot: one message in, one reply out. The ",[18,48284,20],{}," SDK reads your key from the environment automatically, so you only describe ",[27,48287,25242],{}," you want, not ",[27,48290,34309],{}," to authenticate.",[14,48293,48294,48295,48297,48298,48300,48301,1374,48303,1363],{},"Two ideas to know. The ",[35,48296,24611],{}," sets the bot's personality and rules; the user never sees it. The ",[35,48299,24615],{}," is what the person typed. You send both as a list of dictionaries, each with a ",[18,48302,43003],{},[18,48304,7921],{},[253,48306,48308],{"className":414,"code":48307,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\nresponse = client.chat.completions.create(\n    model=os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\"),\n    messages=[\n        {\"role\": \"system\", \"content\": \"You are a concise, friendly support assistant for a bike shop.\"},\n        {\"role\": \"user\", \"content\": \"Do you sell helmets for kids?\"},\n    ],\n    temperature=0.3,\n)\n\nprint(response.choices[0].message.content)\n",[18,48309,48310,48316,48326,48336,48340,48344,48354,48358,48367,48384,48393,48414,48435,48440,48451,48455,48459],{"__ignoreMap":258},[262,48311,48312,48314],{"class":181,"line":264},[262,48313,684],{"class":377},[262,48315,687],{"class":429},[262,48317,48318,48320,48322,48324],{"class":181,"line":282},[262,48319,705],{"class":377},[262,48321,708],{"class":429},[262,48323,684],{"class":377},[262,48325,713],{"class":429},[262,48327,48328,48330,48332,48334],{"class":181,"line":295},[262,48329,705],{"class":377},[262,48331,720],{"class":429},[262,48333,684],{"class":377},[262,48335,725],{"class":429},[262,48337,48338],{"class":181,"line":345},[262,48339,583],{"emptyLinePlaceholder":582},[262,48341,48342],{"class":181,"line":492},[262,48343,734],{"class":429},[262,48345,48346,48348,48350,48352],{"class":181,"line":503},[262,48347,739],{"class":429},[262,48349,476],{"class":377},[262,48351,9578],{"class":429},[262,48353,9581],{"class":291},[262,48355,48356],{"class":181,"line":521},[262,48357,583],{"emptyLinePlaceholder":582},[262,48359,48360,48363,48365],{"class":181,"line":537},[262,48361,48362],{"class":429},"response ",[262,48364,476],{"class":377},[262,48366,1189],{"class":429},[262,48368,48369,48372,48374,48376,48378,48380,48382],{"class":181,"line":549},[262,48370,48371],{"class":611},"    model",[262,48373,476],{"class":377},[262,48375,1199],{"class":429},[262,48377,1202],{"class":275},[262,48379,608],{"class":429},[262,48381,1207],{"class":275},[262,48383,1210],{"class":429},[262,48385,48386,48389,48391],{"class":181,"line":570},[262,48387,48388],{"class":611},"    messages",[262,48390,476],{"class":377},[262,48392,1220],{"class":429},[262,48394,48395,48397,48399,48401,48403,48405,48407,48409,48412],{"class":181,"line":579},[262,48396,7726],{"class":429},[262,48398,1228],{"class":275},[262,48400,1231],{"class":429},[262,48402,1234],{"class":275},[262,48404,608],{"class":429},[262,48406,1239],{"class":275},[262,48408,1231],{"class":429},[262,48410,48411],{"class":275},"\"You are a concise, friendly support assistant for a bike shop.\"",[262,48413,3143],{"class":429},[262,48415,48416,48418,48420,48422,48424,48426,48428,48430,48433],{"class":181,"line":586},[262,48417,7726],{"class":429},[262,48419,1228],{"class":275},[262,48421,1231],{"class":429},[262,48423,1291],{"class":275},[262,48425,608],{"class":429},[262,48427,1239],{"class":275},[262,48429,1231],{"class":429},[262,48431,48432],{"class":275},"\"Do you sell helmets for kids?\"",[262,48434,3143],{"class":429},[262,48436,48437],{"class":181,"line":591},[262,48438,48439],{"class":429},"    ],\n",[262,48441,48442,48445,48447,48449],{"class":181,"line":623},[262,48443,48444],{"class":611},"    temperature",[262,48446,476],{"class":377},[262,48448,3924],{"class":271},[262,48450,1315],{"class":429},[262,48452,48453],{"class":181,"line":634},[262,48454,660],{"class":429},[262,48456,48457],{"class":181,"line":845},[262,48458,583],{"emptyLinePlaceholder":582},[262,48460,48461,48463,48466,48468],{"class":181,"line":850},[262,48462,637],{"class":271},[262,48464,48465],{"class":429},"(response.choices[",[262,48467,102],{"class":271},[262,48469,6048],{"class":429},[14,48471,48472,48473,48475,48476,48478,48479,48482,48483,48485],{},"Run it. You should see a short, on-brand answer. The reply lives at ",[18,48474,7909],{}," — that nesting trips up everyone once, so commit it to memory. The ",[18,48477,7913],{}," part is a list because the model can return several alternative answers in one call; you almost always want the first one. ",[18,48480,48481],{},"temperature=0.3"," keeps the answer focused; raise it toward ",[18,48484,17583],{}," for more creative, varied wording.",[14,48487,48488,48489,48491,48492,48494],{},"Why a small model like ",[18,48490,2703],{},"? For a support bot, speed and cost matter more than raw reasoning power, and a small model answers a grounded question just as well as a large one for a fraction of the price. Start small; only reach for a bigger model if you see the bot fumbling genuinely hard reasoning. If you are still deciding which provider and model to start with, ",[51,48493,5485],{"href":5484}," compares the options without commitment.",[14,48496,48497,48498,1363],{},"The system prompt does a lot of heavy lifting here — it is where you set tone, scope, and the rules the bot must never break. A vague system prompt produces a vague, rambling bot, so be specific about what it should and should not do. To learn how to make it reliably enforce tone, format, and refusals, read ",[51,48499,1362],{"href":1361},[57,48501,48503],{"id":48502},"step-2-add-conversation-memory","Step 2: Add conversation memory",[14,48505,48506,48507,48510],{},"The call above forgets everything the instant it returns. Ask \"what colours does it come in?\" next and the model has no idea what \"it\" means. The fix is simple: keep a growing list of messages and resend the whole list every turn. The model has no hidden memory of its own — ",[27,48508,48509],{},"you"," are the memory.",[253,48512,48514],{"className":414,"code":48513,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()\n\n# The running history. The system message stays pinned at the front.\nmessages = [\n    {\"role\": \"system\", \"content\": \"You are a concise, friendly support assistant for a bike shop.\"},\n]\n\n\ndef ask(user_text: str) -> str:\n    messages.append({\"role\": \"user\", \"content\": user_text})\n    response = client.chat.completions.create(\n        model=os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\"),\n        messages=messages,\n        temperature=0.3,\n    )\n    reply = response.choices[0].message.content\n    messages.append({\"role\": \"assistant\", \"content\": reply})  # remember the answer too\n    return reply\n\n\nprint(ask(\"Do you sell helmets for kids?\"))\nprint(ask(\"What colours does it come in?\"))  # 'it' now resolves correctly\n",[18,48515,48516,48522,48532,48542,48546,48550,48558,48562,48567,48575,48595,48599,48603,48607,48623,48639,48647,48663,48671,48681,48685,48697,48717,48723,48727,48731,48742],{"__ignoreMap":258},[262,48517,48518,48520],{"class":181,"line":264},[262,48519,684],{"class":377},[262,48521,687],{"class":429},[262,48523,48524,48526,48528,48530],{"class":181,"line":282},[262,48525,705],{"class":377},[262,48527,708],{"class":429},[262,48529,684],{"class":377},[262,48531,713],{"class":429},[262,48533,48534,48536,48538,48540],{"class":181,"line":295},[262,48535,705],{"class":377},[262,48537,720],{"class":429},[262,48539,684],{"class":377},[262,48541,725],{"class":429},[262,48543,48544],{"class":181,"line":345},[262,48545,583],{"emptyLinePlaceholder":582},[262,48547,48548],{"class":181,"line":492},[262,48549,734],{"class":429},[262,48551,48552,48554,48556],{"class":181,"line":503},[262,48553,739],{"class":429},[262,48555,476],{"class":377},[262,48557,744],{"class":429},[262,48559,48560],{"class":181,"line":521},[262,48561,583],{"emptyLinePlaceholder":582},[262,48563,48564],{"class":181,"line":537},[262,48565,48566],{"class":291},"# The running history. The system message stays pinned at the front.\n",[262,48568,48569,48571,48573],{"class":181,"line":549},[262,48570,43086],{"class":429},[262,48572,476],{"class":377},[262,48574,5589],{"class":429},[262,48576,48577,48579,48581,48583,48585,48587,48589,48591,48593],{"class":181,"line":570},[262,48578,42305],{"class":429},[262,48580,1228],{"class":275},[262,48582,1231],{"class":429},[262,48584,1234],{"class":275},[262,48586,608],{"class":429},[262,48588,1239],{"class":275},[262,48590,1231],{"class":429},[262,48592,48411],{"class":275},[262,48594,3143],{"class":429},[262,48596,48597],{"class":181,"line":579},[262,48598,957],{"class":429},[262,48600,48601],{"class":181,"line":586},[262,48602,583],{"emptyLinePlaceholder":582},[262,48604,48605],{"class":181,"line":591},[262,48606,583],{"emptyLinePlaceholder":582},[262,48608,48609,48611,48613,48615,48617,48619,48621],{"class":181,"line":623},[262,48610,423],{"class":377},[262,48612,44066],{"class":267},[262,48614,43133],{"class":429},[262,48616,433],{"class":271},[262,48618,1939],{"class":429},[262,48620,433],{"class":271},[262,48622,1160],{"class":429},[262,48624,48625,48627,48629,48631,48633,48635,48637],{"class":181,"line":634},[262,48626,43146],{"class":429},[262,48628,1228],{"class":275},[262,48630,1231],{"class":429},[262,48632,1291],{"class":275},[262,48634,608],{"class":429},[262,48636,1239],{"class":275},[262,48638,43159],{"class":429},[262,48640,48641,48643,48645],{"class":181,"line":845},[262,48642,1184],{"class":429},[262,48644,476],{"class":377},[262,48646,1189],{"class":429},[262,48648,48649,48651,48653,48655,48657,48659,48661],{"class":181,"line":850},[262,48650,1194],{"class":611},[262,48652,476],{"class":377},[262,48654,1199],{"class":429},[262,48656,1202],{"class":275},[262,48658,608],{"class":429},[262,48660,1207],{"class":275},[262,48662,1210],{"class":429},[262,48664,48665,48667,48669],{"class":181,"line":864},[262,48666,1215],{"class":611},[262,48668,476],{"class":377},[262,48670,43186],{"class":429},[262,48672,48673,48675,48677,48679],{"class":181,"line":1683},[262,48674,1308],{"class":611},[262,48676,476],{"class":377},[262,48678,3924],{"class":271},[262,48680,1315],{"class":429},[262,48682,48683],{"class":181,"line":1688},[262,48684,1011],{"class":429},[262,48686,48687,48689,48691,48693,48695],{"class":181,"line":1693},[262,48688,43195],{"class":429},[262,48690,476],{"class":377},[262,48692,1326],{"class":429},[262,48694,102],{"class":271},[262,48696,1331],{"class":429},[262,48698,48699,48701,48703,48705,48707,48709,48711,48714],{"class":181,"line":1728},[262,48700,43146],{"class":429},[262,48702,1228],{"class":275},[262,48704,1231],{"class":429},[262,48706,43214],{"class":275},[262,48708,608],{"class":429},[262,48710,1239],{"class":275},[262,48712,48713],{"class":429},": reply})  ",[262,48715,48716],{"class":291},"# remember the answer too\n",[262,48718,48719,48721],{"class":181,"line":1737},[262,48720,573],{"class":377},[262,48722,43228],{"class":429},[262,48724,48725],{"class":181,"line":1751},[262,48726,583],{"emptyLinePlaceholder":582},[262,48728,48729],{"class":181,"line":1764},[262,48730,583],{"emptyLinePlaceholder":582},[262,48732,48733,48735,48738,48740],{"class":181,"line":1779},[262,48734,637],{"class":271},[262,48736,48737],{"class":429},"(ask(",[262,48739,48432],{"class":275},[262,48741,2684],{"class":429},[262,48743,48744,48746,48748,48751,48753],{"class":181,"line":1793},[262,48745,637],{"class":271},[262,48747,48737],{"class":429},[262,48749,48750],{"class":275},"\"What colours does it come in?\"",[262,48752,43260],{"class":429},[262,48754,48755],{"class":291},"# 'it' now resolves correctly\n",[14,48757,48758,48759,48762,48763,48765,48766,1363],{},"Notice that you append ",[27,48760,48761],{},"both"," the user message and the assistant's reply. If you only stored user turns, the bot would lose its own answers and contradict itself. This list-based approach is everything memory really is. The catch is that the list grows forever, and every model has a limit on how much text it can read at once. When a long chat eventually hits that wall you will see a context-length error; ",[51,48764,1513],{"href":1512}," shows how to trim or summarise old turns. For production-grade strategies like storing history in a database keyed by session, see ",[51,48767,2367],{"href":2366},[57,48769,48771],{"id":48770},"step-3-ground-answers-in-your-own-data-with-retrieval","Step 3: Ground answers in your own data with retrieval",[14,48773,48774,48775,48777,48778,48780],{},"A general model knows nothing about ",[27,48776,29],{}," return policy or ",[27,48779,29],{}," product catalogue, and it will happily invent an answer rather than admit it. Retrieval fixes this. Before answering, you find the snippets of your own documents most relevant to the question and paste them into the prompt as context. This pattern is called RAG — Retrieval-Augmented Generation.",[14,48782,48783,48784,48786,48787,48789],{},"The matching step uses ",[35,48785,69],{},": numeric fingerprints of text where similar meanings sit close together in mathematical space. The phrase \"kids' helmet colours\" and the sentence \"Kids' helmets come in red, blue, and matte black\" produce vectors that point in nearly the same direction, even though they share few exact words. That is the magic — embeddings match on ",[27,48788,73],{},", not keywords, so a customer does not have to phrase their question exactly the way your document is written.",[14,48791,48792,48793,48795],{},"The workflow is three steps: embed your documents once and keep the vectors, embed the user's question at query time, then measure which document vectors point most nearly the same way as the question vector. Here is a minimal, dependency-light version using ",[18,48794,24],{}," for the similarity maths so you can see exactly what happens with no framework hiding the logic.",[253,48797,48799],{"className":414,"code":48798,"language":416,"meta":258,"style":258},"import os\nimport numpy as np\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()\nEMBED_MODEL = os.getenv(\"EMBED_MODEL\", \"text-embedding-3-small\")\n\n# Your knowledge base. In a real app these come from files or a database.\ndocs = [\n    \"Returns are accepted within 30 days with a receipt.\",\n    \"Kids' helmets come in red, blue, and matte black.\",\n    \"We offer free local delivery on orders over $75.\",\n]\n\n\ndef embed(texts: list[str]) -> np.ndarray:\n    resp = client.embeddings.create(model=EMBED_MODEL, input=texts)\n    return np.array([d.embedding for d in resp.data])\n\n\ndoc_vectors = embed(docs)  # embed the knowledge base once at startup\n\n\ndef retrieve(question: str, top_k: int = 2) -> list[str]:\n    q_vector = embed([question])[0]\n    # cosine similarity = dot product of unit-normalised vectors\n    scores = doc_vectors @ q_vector \u002F (\n        np.linalg.norm(doc_vectors, axis=1) * np.linalg.norm(q_vector)\n    )\n    best = scores.argsort()[::-1][:top_k]\n    return [docs[i] for i in best]\n\n\ndef answer_with_context(question: str) -> str:\n    context = \"\\n\".join(retrieve(question))\n    response = client.chat.completions.create(\n        model=os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\"),\n        messages=[\n            {\"role\": \"system\", \"content\": (\n                \"Answer using only the context below. \"\n                \"If the answer is not in the context, say you don't know.\\n\\n\"\n                f\"Context:\\n{context}\"\n            )},\n            {\"role\": \"user\", \"content\": question},\n        ],\n        temperature=0,\n    )\n    return response.choices[0].message.content\n\n\nprint(answer_with_context(\"What colours do the kids helmets come in?\"))\n",[18,48800,48801,48807,48817,48827,48837,48841,48845,48853,48869,48873,48878,48887,48894,48901,48908,48912,48916,48920,48932,48954,48970,48974,48978,48991,48995,48999,49023,49036,49041,49059,49077,49081,49095,49110,49114,49118,49135,49150,49158,49174,49182,49198,49203,49211,49225,49229,49245,49249,49259,49263,49273,49277,49281],{"__ignoreMap":258},[262,48802,48803,48805],{"class":181,"line":264},[262,48804,684],{"class":377},[262,48806,687],{"class":429},[262,48808,48809,48811,48813,48815],{"class":181,"line":282},[262,48810,684],{"class":377},[262,48812,694],{"class":429},[262,48814,697],{"class":377},[262,48816,700],{"class":429},[262,48818,48819,48821,48823,48825],{"class":181,"line":295},[262,48820,705],{"class":377},[262,48822,708],{"class":429},[262,48824,684],{"class":377},[262,48826,713],{"class":429},[262,48828,48829,48831,48833,48835],{"class":181,"line":345},[262,48830,705],{"class":377},[262,48832,720],{"class":429},[262,48834,684],{"class":377},[262,48836,725],{"class":429},[262,48838,48839],{"class":181,"line":492},[262,48840,583],{"emptyLinePlaceholder":582},[262,48842,48843],{"class":181,"line":503},[262,48844,734],{"class":429},[262,48846,48847,48849,48851],{"class":181,"line":521},[262,48848,739],{"class":429},[262,48850,476],{"class":377},[262,48852,744],{"class":429},[262,48854,48855,48857,48859,48861,48863,48865,48867],{"class":181,"line":537},[262,48856,749],{"class":271},[262,48858,442],{"class":377},[262,48860,754],{"class":429},[262,48862,757],{"class":275},[262,48864,608],{"class":429},[262,48866,762],{"class":275},[262,48868,660],{"class":429},[262,48870,48871],{"class":181,"line":549},[262,48872,583],{"emptyLinePlaceholder":582},[262,48874,48875],{"class":181,"line":570},[262,48876,48877],{"class":291},"# Your knowledge base. In a real app these come from files or a database.\n",[262,48879,48880,48883,48885],{"class":181,"line":579},[262,48881,48882],{"class":429},"docs ",[262,48884,476],{"class":377},[262,48886,5589],{"class":429},[262,48888,48889,48892],{"class":181,"line":586},[262,48890,48891],{"class":275},"    \"Returns are accepted within 30 days with a receipt.\"",[262,48893,1315],{"class":429},[262,48895,48896,48899],{"class":181,"line":591},[262,48897,48898],{"class":275},"    \"Kids' helmets come in red, blue, and matte black.\"",[262,48900,1315],{"class":429},[262,48902,48903,48906],{"class":181,"line":623},[262,48904,48905],{"class":275},"    \"We offer free local delivery on orders over $75.\"",[262,48907,1315],{"class":429},[262,48909,48910],{"class":181,"line":634},[262,48911,957],{"class":429},[262,48913,48914],{"class":181,"line":845},[262,48915,583],{"emptyLinePlaceholder":582},[262,48917,48918],{"class":181,"line":850},[262,48919,583],{"emptyLinePlaceholder":582},[262,48921,48922,48924,48926,48928,48930],{"class":181,"line":864},[262,48923,423],{"class":377},[262,48925,779],{"class":267},[262,48927,782],{"class":429},[262,48929,433],{"class":271},[262,48931,787],{"class":429},[262,48933,48934,48936,48938,48940,48942,48944,48946,48948,48950,48952],{"class":181,"line":1683},[262,48935,797],{"class":429},[262,48937,476],{"class":377},[262,48939,802],{"class":429},[262,48941,805],{"class":611},[262,48943,476],{"class":377},[262,48945,749],{"class":271},[262,48947,608],{"class":429},[262,48949,814],{"class":611},[262,48951,476],{"class":377},[262,48953,819],{"class":429},[262,48955,48956,48958,48961,48963,48966,48968],{"class":181,"line":1688},[262,48957,573],{"class":377},[262,48959,48960],{"class":429}," np.array([d.embedding ",[262,48962,829],{"class":377},[262,48964,48965],{"class":429}," d ",[262,48967,835],{"class":377},[262,48969,838],{"class":429},[262,48971,48972],{"class":181,"line":1693},[262,48973,583],{"emptyLinePlaceholder":582},[262,48975,48976],{"class":181,"line":1728},[262,48977,583],{"emptyLinePlaceholder":582},[262,48979,48980,48983,48985,48988],{"class":181,"line":1737},[262,48981,48982],{"class":429},"doc_vectors ",[262,48984,476],{"class":377},[262,48986,48987],{"class":429}," embed(docs)  ",[262,48989,48990],{"class":291},"# embed the knowledge base once at startup\n",[262,48992,48993],{"class":181,"line":1751},[262,48994,583],{"emptyLinePlaceholder":582},[262,48996,48997],{"class":181,"line":1764},[262,48998,583],{"emptyLinePlaceholder":582},[262,49000,49001,49003,49005,49007,49009,49011,49013,49015,49017,49019,49021],{"class":181,"line":1779},[262,49002,423],{"class":377},[262,49004,906],{"class":267},[262,49006,909],{"class":429},[262,49008,433],{"class":271},[262,49010,1930],{"class":429},[262,49012,439],{"class":271},[262,49014,442],{"class":377},[262,49016,3232],{"class":271},[262,49018,458],{"class":429},[262,49020,433],{"class":271},[262,49022,463],{"class":429},[262,49024,49025,49028,49030,49032,49034],{"class":181,"line":1793},[262,49026,49027],{"class":429},"    q_vector ",[262,49029,476],{"class":377},[262,49031,952],{"class":429},[262,49033,102],{"class":271},[262,49035,957],{"class":429},[262,49037,49038],{"class":181,"line":1800},[262,49039,49040],{"class":291},"    # cosine similarity = dot product of unit-normalised vectors\n",[262,49042,49043,49045,49047,49050,49052,49055,49057],{"class":181,"line":1805},[262,49044,967],{"class":429},[262,49046,476],{"class":377},[262,49048,49049],{"class":429}," doc_vectors ",[262,49051,975],{"class":377},[262,49053,49054],{"class":429}," q_vector ",[262,49056,981],{"class":377},[262,49058,984],{"class":429},[262,49060,49061,49064,49066,49068,49070,49072,49074],{"class":181,"line":1810},[262,49062,49063],{"class":429},"        np.linalg.norm(doc_vectors, ",[262,49065,992],{"class":611},[262,49067,476],{"class":377},[262,49069,997],{"class":271},[262,49071,1000],{"class":429},[262,49073,1003],{"class":377},[262,49075,49076],{"class":429}," np.linalg.norm(q_vector)\n",[262,49078,49079],{"class":181,"line":1823},[262,49080,1011],{"class":429},[262,49082,49083,49085,49087,49089,49091,49093],{"class":181,"line":1846},[262,49084,1016],{"class":429},[262,49086,476],{"class":377},[262,49088,1021],{"class":429},[262,49090,561],{"class":377},[262,49092,997],{"class":271},[262,49094,2009],{"class":429},[262,49096,49097,49099,49102,49104,49106,49108],{"class":181,"line":1861},[262,49098,573],{"class":377},[262,49100,49101],{"class":429}," [docs[i] ",[262,49103,829],{"class":377},[262,49105,1043],{"class":429},[262,49107,835],{"class":377},[262,49109,1048],{"class":429},[262,49111,49112],{"class":181,"line":1866},[262,49113,583],{"emptyLinePlaceholder":582},[262,49115,49116],{"class":181,"line":1871},[262,49117,583],{"emptyLinePlaceholder":582},[262,49119,49120,49122,49125,49127,49129,49131,49133],{"class":181,"line":1890},[262,49121,423],{"class":377},[262,49123,49124],{"class":267}," answer_with_context",[262,49126,909],{"class":429},[262,49128,433],{"class":271},[262,49130,1939],{"class":429},[262,49132,433],{"class":271},[262,49134,1160],{"class":429},[262,49136,49137,49139,49141,49143,49145,49147],{"class":181,"line":1909},[262,49138,1165],{"class":429},[262,49140,476],{"class":377},[262,49142,1170],{"class":275},[262,49144,2137],{"class":271},[262,49146,1176],{"class":275},[262,49148,49149],{"class":429},".join(retrieve(question))\n",[262,49151,49152,49154,49156],{"class":181,"line":1914},[262,49153,1184],{"class":429},[262,49155,476],{"class":377},[262,49157,1189],{"class":429},[262,49159,49160,49162,49164,49166,49168,49170,49172],{"class":181,"line":1919},[262,49161,1194],{"class":611},[262,49163,476],{"class":377},[262,49165,1199],{"class":429},[262,49167,1202],{"class":275},[262,49169,608],{"class":429},[262,49171,1207],{"class":275},[262,49173,1210],{"class":429},[262,49175,49176,49178,49180],{"class":181,"line":1946},[262,49177,1215],{"class":611},[262,49179,476],{"class":377},[262,49181,1220],{"class":429},[262,49183,49184,49186,49188,49190,49192,49194,49196],{"class":181,"line":1959},[262,49185,1225],{"class":429},[262,49187,1228],{"class":275},[262,49189,1231],{"class":429},[262,49191,1234],{"class":275},[262,49193,608],{"class":429},[262,49195,1239],{"class":275},[262,49197,1242],{"class":429},[262,49199,49200],{"class":181,"line":1996},[262,49201,49202],{"class":275},"                \"Answer using only the context below. \"\n",[262,49204,49205,49207,49209],{"class":181,"line":2012},[262,49206,1252],{"class":275},[262,49208,1173],{"class":271},[262,49210,1257],{"class":275},[262,49212,49213,49215,49217,49219,49221,49223],{"class":181,"line":2040},[262,49214,1262],{"class":377},[262,49216,1265],{"class":275},[262,49218,1268],{"class":271},[262,49220,1271],{"class":429},[262,49222,654],{"class":271},[262,49224,1257],{"class":275},[262,49226,49227],{"class":181,"line":2045},[262,49228,1280],{"class":429},[262,49230,49231,49233,49235,49237,49239,49241,49243],{"class":181,"line":2050},[262,49232,1225],{"class":429},[262,49234,1228],{"class":275},[262,49236,1231],{"class":429},[262,49238,1291],{"class":275},[262,49240,608],{"class":429},[262,49242,1239],{"class":275},[262,49244,1298],{"class":429},[262,49246,49247],{"class":181,"line":2067},[262,49248,1303],{"class":429},[262,49250,49251,49253,49255,49257],{"class":181,"line":2077},[262,49252,1308],{"class":611},[262,49254,476],{"class":377},[262,49256,102],{"class":271},[262,49258,1315],{"class":429},[262,49260,49261],{"class":181,"line":2086},[262,49262,1011],{"class":429},[262,49264,49265,49267,49269,49271],{"class":181,"line":2097},[262,49266,573],{"class":377},[262,49268,1326],{"class":429},[262,49270,102],{"class":271},[262,49272,1331],{"class":429},[262,49274,49275],{"class":181,"line":2106},[262,49276,583],{"emptyLinePlaceholder":582},[262,49278,49279],{"class":181,"line":2126},[262,49280,583],{"emptyLinePlaceholder":582},[262,49282,49283,49285,49288,49291],{"class":181,"line":2148},[262,49284,637],{"class":271},[262,49286,49287],{"class":429},"(answer_with_context(",[262,49289,49290],{"class":275},"\"What colours do the kids helmets come in?\"",[262,49292,2684],{"class":429},[14,49294,49295,49296,49298,49299,49301,49302,1363],{},"The system prompt does the safety work: it tells the model to answer ",[27,49297,1131],{}," from the context and to admit ignorance otherwise. That single instruction removes most invented answers. For a few documents, ",[18,49300,24],{}," is plenty; once you have thousands of snippets you will want a proper vector store. The full pipeline — chunking files, persisting vectors, and scaling search — lives in ",[51,49303,5],{"href":44488},[57,49305,49307],{"id":49306},"step-4-handle-errors-and-rate-limits","Step 4: Handle errors and rate limits",[14,49309,49310],{},"A chatbot that crashes on the first network hiccup is not ready for anyone. Three things go wrong in the real world: the network drops, the provider is briefly overloaded (a rate limit), and the model returns something your code did not expect. Wrap your call so a single failure degrades into a polite message instead of a stack trace, and retry the temporary ones.",[253,49312,49314],{"className":414,"code":49313,"language":416,"meta":258,"style":258},"import os\nimport time\nfrom dotenv import load_dotenv\nfrom openai import OpenAI, RateLimitError, APIError\n\nload_dotenv()\nclient = OpenAI(timeout=20.0)  # never wait forever on a slow response\n\n\ndef safe_chat(messages: list[dict], retries: int = 3) -> str:\n    for attempt in range(retries):\n        try:\n            response = client.chat.completions.create(\n                model=os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\"),\n                messages=messages,\n                temperature=0.3,\n            )\n            return response.choices[0].message.content\n        except RateLimitError:\n            wait = 2 ** attempt  # back off: 1s, 2s, 4s\n            print(f\"Rate limited, retrying in {wait}s...\")\n            time.sleep(wait)\n        except APIError as exc:\n            print(f\"API error: {exc}\")\n            break\n    return \"Sorry, I'm having trouble right now. Please try again in a moment.\"\n",[18,49315,49316,49322,49328,49338,49349,49353,49357,49376,49380,49384,49411,49423,49429,49437,49453,49461,49471,49475,49485,49491,49507,49528,49532,49542,49563,49567],{"__ignoreMap":258},[262,49317,49318,49320],{"class":181,"line":264},[262,49319,684],{"class":377},[262,49321,687],{"class":429},[262,49323,49324,49326],{"class":181,"line":282},[262,49325,684],{"class":377},[262,49327,2612],{"class":429},[262,49329,49330,49332,49334,49336],{"class":181,"line":295},[262,49331,705],{"class":377},[262,49333,708],{"class":429},[262,49335,684],{"class":377},[262,49337,713],{"class":429},[262,49339,49340,49342,49344,49346],{"class":181,"line":345},[262,49341,705],{"class":377},[262,49343,720],{"class":429},[262,49345,684],{"class":377},[262,49347,49348],{"class":429}," OpenAI, RateLimitError, APIError\n",[262,49350,49351],{"class":181,"line":492},[262,49352,583],{"emptyLinePlaceholder":582},[262,49354,49355],{"class":181,"line":503},[262,49356,734],{"class":429},[262,49358,49359,49361,49363,49365,49367,49369,49371,49373],{"class":181,"line":521},[262,49360,739],{"class":429},[262,49362,476],{"class":377},[262,49364,1588],{"class":429},[262,49366,1591],{"class":611},[262,49368,476],{"class":377},[262,49370,1596],{"class":271},[262,49372,32223],{"class":429},[262,49374,49375],{"class":291},"# never wait forever on a slow response\n",[262,49377,49378],{"class":181,"line":537},[262,49379,583],{"emptyLinePlaceholder":582},[262,49381,49382],{"class":181,"line":549},[262,49383,583],{"emptyLinePlaceholder":582},[262,49385,49386,49388,49391,49394,49396,49399,49401,49403,49405,49407,49409],{"class":181,"line":570},[262,49387,423],{"class":377},[262,49389,49390],{"class":267}," safe_chat",[262,49392,49393],{"class":429},"(messages: list[",[262,49395,5869],{"class":271},[262,49397,49398],{"class":429},"], retries: ",[262,49400,439],{"class":271},[262,49402,442],{"class":377},[262,49404,931],{"class":271},[262,49406,1939],{"class":429},[262,49408,433],{"class":271},[262,49410,1160],{"class":429},[262,49412,49413,49415,49417,49419,49421],{"class":181,"line":579},[262,49414,3074],{"class":377},[262,49416,3077],{"class":429},[262,49418,835],{"class":377},[262,49420,3082],{"class":271},[262,49422,39302],{"class":429},[262,49424,49425,49427],{"class":181,"line":586},[262,49426,3090],{"class":377},[262,49428,1160],{"class":429},[262,49430,49431,49433,49435],{"class":181,"line":591},[262,49432,3097],{"class":429},[262,49434,476],{"class":377},[262,49436,1189],{"class":429},[262,49438,49439,49441,49443,49445,49447,49449,49451],{"class":181,"line":623},[262,49440,3106],{"class":611},[262,49442,476],{"class":377},[262,49444,1199],{"class":429},[262,49446,1202],{"class":275},[262,49448,608],{"class":429},[262,49450,1207],{"class":275},[262,49452,1210],{"class":429},[262,49454,49455,49457,49459],{"class":181,"line":634},[262,49456,3117],{"class":611},[262,49458,476],{"class":377},[262,49460,43186],{"class":429},[262,49462,49463,49465,49467,49469],{"class":181,"line":845},[262,49464,3170],{"class":611},[262,49466,476],{"class":377},[262,49468,3924],{"class":271},[262,49470,1315],{"class":429},[262,49472,49473],{"class":181,"line":850},[262,49474,3193],{"class":429},[262,49476,49477,49479,49481,49483],{"class":181,"line":864},[262,49478,3198],{"class":377},[262,49480,1326],{"class":429},[262,49482,102],{"class":271},[262,49484,1331],{"class":429},[262,49486,49487,49489],{"class":181,"line":1683},[262,49488,3214],{"class":377},[262,49490,9787],{"class":429},[262,49492,49493,49495,49497,49499,49501,49504],{"class":181,"line":1688},[262,49494,3227],{"class":429},[262,49496,476],{"class":377},[262,49498,3232],{"class":271},[262,49500,3235],{"class":377},[262,49502,49503],{"class":429}," attempt  ",[262,49505,49506],{"class":291},"# back off: 1s, 2s, 4s\n",[262,49508,49509,49511,49513,49515,49518,49520,49522,49524,49526],{"class":181,"line":1693},[262,49510,3250],{"class":271},[262,49512,602],{"class":429},[262,49514,642],{"class":377},[262,49516,49517],{"class":275},"\"Rate limited, retrying in ",[262,49519,3039],{"class":271},[262,49521,3295],{"class":429},[262,49523,654],{"class":271},[262,49525,3300],{"class":275},[262,49527,660],{"class":429},[262,49529,49530],{"class":181,"line":1728},[262,49531,3307],{"class":429},[262,49533,49534,49536,49538,49540],{"class":181,"line":1737},[262,49535,3214],{"class":377},[262,49537,9882],{"class":429},[262,49539,697],{"class":377},[262,49541,9840],{"class":429},[262,49543,49544,49546,49548,49550,49553,49555,49557,49559,49561],{"class":181,"line":1751},[262,49545,3250],{"class":271},[262,49547,602],{"class":429},[262,49549,642],{"class":377},[262,49551,49552],{"class":275},"\"API error: ",[262,49554,3039],{"class":271},[262,49556,9864],{"class":429},[262,49558,654],{"class":271},[262,49560,1176],{"class":275},[262,49562,660],{"class":429},[262,49564,49565],{"class":181,"line":1764},[262,49566,2293],{"class":377},[262,49568,49569,49571],{"class":181,"line":1779},[262,49570,573],{"class":377},[262,49572,49573],{"class":275}," \"Sorry, I'm having trouble right now. Please try again in a moment.\"\n",[14,49575,3349,49576,49579,49580,49583,49584,49586,49587,49589,49590,49592,49593,49595,49596,49600],{},[18,49577,49578],{},"2 ** attempt"," pattern is ",[35,49581,49582],{},"exponential backoff",": each retry waits longer (one second, then two, then four), giving the provider room to recover instead of hammering it with instant retries that would only deepen the overload. Notice the two error types are handled differently: a ",[18,49585,2707],{}," is temporary, so you retry it, while a general ",[18,49588,2713],{}," usually signals a real problem with your request, so you stop and surface it rather than looping uselessly. Setting ",[18,49591,21560],{}," on the client means a stuck request fails fast rather than freezing your whole app while one user waits on a hung connection. If rate limits become a regular problem rather than a rare blip, ",[51,49594,3379],{"href":3378}," explains the limits and how to stay under them, and ",[51,49597,49599],{"href":49598},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Frate-limit-ai-api-calls-in-a-saas-with-python\u002F","Rate-Limit AI API Calls in a SaaS with Python"," shows how to throttle your own users fairly.",[57,49602,49604],{"id":49603},"plain-sdk-or-langchain","Plain SDK or LangChain?",[14,49606,49607,49608,49610,49611,49613],{},"You have now built a complete chatbot with nothing but the ",[18,49609,20],{}," SDK, ",[18,49612,24],{},", and Python lists. That is deliberate: you can see and debug every moving part, and there is no framework version churn to chase. For most business bots, this is all you ever need.",[14,49615,49616,49617,1363],{},"LangChain earns its place when your loop grows complicated — when the bot must decide between several tools, chain many steps, swap between providers behind one interface, or use prebuilt retrieval and memory components so you write less plumbing. The trade is a steeper learning curve and more abstraction between you and the API, which can make bugs harder to trace. A practical rule: start with the plain SDK as shown here, and reach for LangChain only when you feel yourself rebuilding its features by hand. The fully framework-based version, with routing and fallbacks, is covered in ",[51,49618,2372],{"href":2371},[57,49620,8300],{"id":8299},[14,49622,49623,49624,49626],{},"These are the settings you will reach for most when calling the chat endpoint. Tune ",[18,49625,3829],{}," first; leave the rest at their defaults until you have a reason to change them.",[1379,49628,49629,49641],{},[1382,49630,49631],{},[1385,49632,49633,49635,49637,49639],{},[1388,49634,1390],{},[1388,49636,3795],{},[1388,49638,3798],{},[1388,49640,1396],{},[1398,49642,49643,49658,49673,49694,49708,49724,49737],{},[1385,49644,49645,49649,49651,49653],{},[1403,49646,49647],{},[18,49648,805],{},[1403,49650,433],{},[1403,49652,14674],{},[1403,49654,45977,49655,49657],{},[18,49656,2703],{}," is cheap and fast; larger models reason better at higher cost.",[1385,49659,49660,49664,49668,49670],{},[1403,49661,49662],{},[18,49663,43269],{},[1403,49665,2801,49666],{},[262,49667,5869],{},[1403,49669,14674],{},[1403,49671,49672],{},"The full conversation: system, user, and assistant turns in order.",[1385,49674,49675,49679,49681,49685],{},[1403,49676,49677],{},[18,49678,3829],{},[1403,49680,3832],{},[1403,49682,49683],{},[18,49684,17583],{},[1403,49686,49687,49688,49690,49691,49693],{},"Randomness. ",[18,49689,102],{}," is deterministic and factual; ",[18,49692,17583],{},"+ is creative and varied. Use low values for support bots.",[1385,49695,49696,49700,49702,49705],{},[1403,49697,49698],{},[18,49699,3846],{},[1403,49701,439],{},[1403,49703,49704],{},"model max",[1403,49706,49707],{},"Hard cap on reply length. Set it to control cost and stop runaway answers.",[1385,49709,49710,49715,49717,49721],{},[1403,49711,49712],{},[18,49713,49714],{},"top_p",[1403,49716,3832],{},[1403,49718,49719],{},[18,49720,17583],{},[1403,49722,49723],{},"Alternative to temperature that limits word choice to the most likely options. Change one, not both.",[1385,49725,49726,49730,49732,49734],{},[1403,49727,49728],{},[18,49729,1591],{},[1403,49731,3832],{},[1403,49733,219],{},[1403,49735,49736],{},"Seconds to wait before giving up on a request. Set it on the client so a slow call cannot hang your app.",[1385,49738,49739,49744,49746,49750],{},[1403,49740,49741],{},[18,49742,49743],{},"stream",[1403,49745,8045],{},[1403,49747,49748],{},[18,49749,3623],{},[1403,49751,49752,49753,49755],{},"When ",[18,49754,4974],{},", tokens arrive as they are generated for a live typing effect.",[57,49757,1445],{"id":1444},[1447,49759,49760,49779,49786,49796,49811,49821],{},[1450,49761,49762,49767,49768,49770,49771,49773,49774,49776,49777,1363],{},[35,49763,49764],{},[18,49765,49766],{},"AuthenticationError: No API key provided"," — Your key is not loaded. Cause: ",[18,49769,8439],{}," was not called, or ",[18,49772,319],{}," is in the wrong folder. Fix: call ",[18,49775,8439],{}," before creating the client and run your script from the folder that holds ",[18,49778,319],{},[1450,49780,49781,49785],{},[35,49782,49783],{},[18,49784,46081],{}," — You sent requests faster than your plan allows, or you are out of credit. Cause: a loop firing calls with no pause, or an empty balance. Fix: add the exponential backoff from Step 4 and check your billing dashboard.",[1450,49787,49788,49792,49793,49795],{},[35,49789,49790],{},[18,49791,1499],{}," — The conversation plus context is too long for the model. Cause: an ever-growing ",[18,49794,43269],{}," list. Fix: keep only the last several turns, or summarise older ones into one short note.",[1450,49797,49798,49803,49804,49806,49807,49810],{},[35,49799,49800],{},[18,49801,49802],{},"AttributeError: 'NoneType' object has no attribute ..."," — You read the reply from the wrong place. Cause: the reply is at ",[18,49805,7909],{},", not ",[18,49808,49809],{},"response.content",". Fix: use the full path.",[1450,49812,49813,49816,49817,49820],{},[35,49814,49815],{},"The bot answers from general knowledge instead of your documents"," — Retrieval context was empty or ignored. Cause: no snippets matched, or the system prompt did not forbid outside answers. Fix: confirm ",[18,49818,49819],{},"retrieve()"," returns text and add \"answer only from the context\" to the system message.",[1450,49822,49823,49827,49828,49830],{},[35,49824,49825],{},[18,49826,10922],{}," — The request took longer than your timeout. Cause: a large request or a slow network. Fix: raise the client ",[18,49829,1591],{},", shorten the prompt, or switch to a faster model.",[57,49832,49834],{"id":49833},"worked-example-a-complete-chatbot","Worked example: a complete chatbot",[14,49836,49837,49838,1524,49841,1527,49843,1363],{},"This script ties every step together into one runnable program: it loads a small knowledge base, retrieves relevant context per question, remembers the conversation, and survives errors with retries. Save it as ",[18,49839,49840],{},"chatbot.py",[18,49842,319],{},[18,49844,49845],{},"python chatbot.py",[253,49847,49849],{"className":414,"code":49848,"language":416,"meta":258,"style":258},"import os\nimport time\nimport numpy as np\nfrom dotenv import load_dotenv\nfrom openai import OpenAI, RateLimitError\n\nload_dotenv()                                              # load keys from .env\nclient = OpenAI(timeout=20.0)                              # fail fast on slow calls\nCHAT_MODEL = os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\")\nEMBED_MODEL = os.getenv(\"EMBED_MODEL\", \"text-embedding-3-small\")\n\nKNOWLEDGE = [                                              # your facts live here\n    \"Returns are accepted within 30 days with a receipt.\",\n    \"Kids' helmets come in red, blue, and matte black.\",\n    \"Free local delivery applies to orders over $75.\",\n]\n\n\ndef embed(texts: list[str]) -> np.ndarray:                # turn text into vectors\n    resp = client.embeddings.create(model=EMBED_MODEL, input=texts)\n    return np.array([d.embedding for d in resp.data])\n\n\nDOC_VECTORS = embed(KNOWLEDGE)                             # embed knowledge once\n\n\ndef retrieve(question: str, top_k: int = 2) -> str:       # find relevant facts\n    q = embed([question])[0]\n    scores = DOC_VECTORS @ q \u002F (np.linalg.norm(DOC_VECTORS, axis=1) * np.linalg.norm(q))\n    return \"\\n\".join(KNOWLEDGE[i] for i in scores.argsort()[::-1][:top_k])\n\n\nhistory = [{\"role\": \"system\", \"content\": \"You are a concise bike-shop assistant. \"\n           \"Answer only from the provided context; otherwise say you don't know.\"}]\n\n\ndef reply(question: str, retries: int = 3) -> str:        # one full turn\n    context = retrieve(question)\n    history.append({\"role\": \"user\", \"content\": f\"Context:\\n{context}\\n\\nQuestion: {question}\"})\n    for attempt in range(retries):\n        try:\n            out = client.chat.completions.create(model=CHAT_MODEL, messages=history, temperature=0)\n            answer = out.choices[0].message.content\n            history.append({\"role\": \"assistant\", \"content\": answer})  # remember reply\n            return answer\n        except RateLimitError:\n            time.sleep(2 ** attempt)                       # exponential backoff\n    return \"Sorry, I'm having trouble right now. Please try again shortly.\"\n\n\nif __name__ == \"__main__\":                                # simple terminal loop\n    print(\"Bike-shop bot ready. Type 'quit' to exit.\")\n    while True:\n        msg = input(\"You: \").strip()\n        if msg.lower() in {\"quit\", \"exit\"}:\n            break\n        print(\"Bot:\", reply(msg))\n",[18,49850,49851,49857,49863,49873,49883,49894,49898,49906,49925,49941,49957,49961,49974,49980,49986,49993,49997,50001,50005,50021,50043,50057,50061,50065,50082,50086,50090,50118,50130,50163,50194,50198,50202,50227,50235,50239,50243,50271,50279,50320,50332,50338,50371,50385,50406,50413,50419,50433,50440,50444,50448,50464,50475,50483,50497,50515,50519],{"__ignoreMap":258},[262,49852,49853,49855],{"class":181,"line":264},[262,49854,684],{"class":377},[262,49856,687],{"class":429},[262,49858,49859,49861],{"class":181,"line":282},[262,49860,684],{"class":377},[262,49862,2612],{"class":429},[262,49864,49865,49867,49869,49871],{"class":181,"line":295},[262,49866,684],{"class":377},[262,49868,694],{"class":429},[262,49870,697],{"class":377},[262,49872,700],{"class":429},[262,49874,49875,49877,49879,49881],{"class":181,"line":345},[262,49876,705],{"class":377},[262,49878,708],{"class":429},[262,49880,684],{"class":377},[262,49882,713],{"class":429},[262,49884,49885,49887,49889,49891],{"class":181,"line":492},[262,49886,705],{"class":377},[262,49888,720],{"class":429},[262,49890,684],{"class":377},[262,49892,49893],{"class":429}," OpenAI, RateLimitError\n",[262,49895,49896],{"class":181,"line":503},[262,49897,583],{"emptyLinePlaceholder":582},[262,49899,49900,49903],{"class":181,"line":521},[262,49901,49902],{"class":429},"load_dotenv()                                              ",[262,49904,49905],{"class":291},"# load keys from .env\n",[262,49907,49908,49910,49912,49914,49916,49918,49920,49922],{"class":181,"line":537},[262,49909,739],{"class":429},[262,49911,476],{"class":377},[262,49913,1588],{"class":429},[262,49915,1591],{"class":611},[262,49917,476],{"class":377},[262,49919,1596],{"class":271},[262,49921,9060],{"class":429},[262,49923,49924],{"class":291},"# fail fast on slow calls\n",[262,49926,49927,49929,49931,49933,49935,49937,49939],{"class":181,"line":549},[262,49928,1603],{"class":271},[262,49930,442],{"class":377},[262,49932,754],{"class":429},[262,49934,1202],{"class":275},[262,49936,608],{"class":429},[262,49938,1207],{"class":275},[262,49940,660],{"class":429},[262,49942,49943,49945,49947,49949,49951,49953,49955],{"class":181,"line":570},[262,49944,749],{"class":271},[262,49946,442],{"class":377},[262,49948,754],{"class":429},[262,49950,757],{"class":275},[262,49952,608],{"class":429},[262,49954,762],{"class":275},[262,49956,660],{"class":429},[262,49958,49959],{"class":181,"line":579},[262,49960,583],{"emptyLinePlaceholder":582},[262,49962,49963,49966,49968,49971],{"class":181,"line":586},[262,49964,49965],{"class":271},"KNOWLEDGE",[262,49967,442],{"class":377},[262,49969,49970],{"class":429}," [                                              ",[262,49972,49973],{"class":291},"# your facts live here\n",[262,49975,49976,49978],{"class":181,"line":591},[262,49977,48891],{"class":275},[262,49979,1315],{"class":429},[262,49981,49982,49984],{"class":181,"line":623},[262,49983,48898],{"class":275},[262,49985,1315],{"class":429},[262,49987,49988,49991],{"class":181,"line":634},[262,49989,49990],{"class":275},"    \"Free local delivery applies to orders over $75.\"",[262,49992,1315],{"class":429},[262,49994,49995],{"class":181,"line":845},[262,49996,957],{"class":429},[262,49998,49999],{"class":181,"line":850},[262,50000,583],{"emptyLinePlaceholder":582},[262,50002,50003],{"class":181,"line":864},[262,50004,583],{"emptyLinePlaceholder":582},[262,50006,50007,50009,50011,50013,50015,50018],{"class":181,"line":1683},[262,50008,423],{"class":377},[262,50010,779],{"class":267},[262,50012,782],{"class":429},[262,50014,433],{"class":271},[262,50016,50017],{"class":429},"]) -> np.ndarray:                ",[262,50019,50020],{"class":291},"# turn text into vectors\n",[262,50022,50023,50025,50027,50029,50031,50033,50035,50037,50039,50041],{"class":181,"line":1688},[262,50024,797],{"class":429},[262,50026,476],{"class":377},[262,50028,802],{"class":429},[262,50030,805],{"class":611},[262,50032,476],{"class":377},[262,50034,749],{"class":271},[262,50036,608],{"class":429},[262,50038,814],{"class":611},[262,50040,476],{"class":377},[262,50042,819],{"class":429},[262,50044,50045,50047,50049,50051,50053,50055],{"class":181,"line":1693},[262,50046,573],{"class":377},[262,50048,48960],{"class":429},[262,50050,829],{"class":377},[262,50052,48965],{"class":429},[262,50054,835],{"class":377},[262,50056,838],{"class":429},[262,50058,50059],{"class":181,"line":1728},[262,50060,583],{"emptyLinePlaceholder":582},[262,50062,50063],{"class":181,"line":1737},[262,50064,583],{"emptyLinePlaceholder":582},[262,50066,50067,50070,50072,50074,50076,50079],{"class":181,"line":1751},[262,50068,50069],{"class":271},"DOC_VECTORS",[262,50071,442],{"class":377},[262,50073,1898],{"class":429},[262,50075,49965],{"class":271},[262,50077,50078],{"class":429},")                             ",[262,50080,50081],{"class":291},"# embed knowledge once\n",[262,50083,50084],{"class":181,"line":1764},[262,50085,583],{"emptyLinePlaceholder":582},[262,50087,50088],{"class":181,"line":1779},[262,50089,583],{"emptyLinePlaceholder":582},[262,50091,50092,50094,50096,50098,50100,50102,50104,50106,50108,50110,50112,50115],{"class":181,"line":1793},[262,50093,423],{"class":377},[262,50095,906],{"class":267},[262,50097,909],{"class":429},[262,50099,433],{"class":271},[262,50101,1930],{"class":429},[262,50103,439],{"class":271},[262,50105,442],{"class":377},[262,50107,3232],{"class":271},[262,50109,1939],{"class":429},[262,50111,433],{"class":271},[262,50113,50114],{"class":429},":       ",[262,50116,50117],{"class":291},"# find relevant facts\n",[262,50119,50120,50122,50124,50126,50128],{"class":181,"line":1800},[262,50121,947],{"class":429},[262,50123,476],{"class":377},[262,50125,952],{"class":429},[262,50127,102],{"class":271},[262,50129,957],{"class":429},[262,50131,50132,50134,50136,50139,50141,50143,50145,50147,50149,50151,50153,50155,50157,50159,50161],{"class":181,"line":1805},[262,50133,967],{"class":429},[262,50135,476],{"class":377},[262,50137,50138],{"class":271}," DOC_VECTORS",[262,50140,1969],{"class":377},[262,50142,978],{"class":429},[262,50144,981],{"class":377},[262,50146,1976],{"class":429},[262,50148,50069],{"class":271},[262,50150,608],{"class":429},[262,50152,992],{"class":611},[262,50154,476],{"class":377},[262,50156,997],{"class":271},[262,50158,1000],{"class":429},[262,50160,1003],{"class":377},[262,50162,1993],{"class":429},[262,50164,50165,50167,50169,50171,50173,50175,50177,50179,50181,50183,50185,50187,50189,50191],{"class":181,"line":1810},[262,50166,573],{"class":377},[262,50168,1170],{"class":275},[262,50170,2137],{"class":271},[262,50172,1176],{"class":275},[262,50174,2023],{"class":429},[262,50176,49965],{"class":271},[262,50178,2028],{"class":429},[262,50180,829],{"class":377},[262,50182,1043],{"class":429},[262,50184,835],{"class":377},[262,50186,1021],{"class":429},[262,50188,561],{"class":377},[262,50190,997],{"class":271},[262,50192,50193],{"class":429},"][:top_k])\n",[262,50195,50196],{"class":181,"line":1823},[262,50197,583],{"emptyLinePlaceholder":582},[262,50199,50200],{"class":181,"line":1846},[262,50201,583],{"emptyLinePlaceholder":582},[262,50203,50204,50207,50209,50212,50214,50216,50218,50220,50222,50224],{"class":181,"line":1861},[262,50205,50206],{"class":429},"history ",[262,50208,476],{"class":377},[262,50210,50211],{"class":429}," [{",[262,50213,1228],{"class":275},[262,50215,1231],{"class":429},[262,50217,1234],{"class":275},[262,50219,608],{"class":429},[262,50221,1239],{"class":275},[262,50223,1231],{"class":429},[262,50225,50226],{"class":275},"\"You are a concise bike-shop assistant. \"\n",[262,50228,50229,50232],{"class":181,"line":1866},[262,50230,50231],{"class":275},"           \"Answer only from the provided context; otherwise say you don't know.\"",[262,50233,50234],{"class":429},"}]\n",[262,50236,50237],{"class":181,"line":1871},[262,50238,583],{"emptyLinePlaceholder":582},[262,50240,50241],{"class":181,"line":1890},[262,50242,583],{"emptyLinePlaceholder":582},[262,50244,50245,50247,50249,50251,50253,50255,50257,50259,50261,50263,50265,50268],{"class":181,"line":1909},[262,50246,423],{"class":377},[262,50248,45698],{"class":267},[262,50250,909],{"class":429},[262,50252,433],{"class":271},[262,50254,39233],{"class":429},[262,50256,439],{"class":271},[262,50258,442],{"class":377},[262,50260,931],{"class":271},[262,50262,1939],{"class":429},[262,50264,433],{"class":271},[262,50266,50267],{"class":429},":        ",[262,50269,50270],{"class":291},"# one full turn\n",[262,50272,50273,50275,50277],{"class":181,"line":1914},[262,50274,1165],{"class":429},[262,50276,476],{"class":377},[262,50278,2074],{"class":429},[262,50280,50281,50284,50286,50288,50290,50292,50294,50296,50298,50300,50302,50304,50306,50309,50311,50314,50316,50318],{"class":181,"line":1919},[262,50282,50283],{"class":429},"    history.append({",[262,50285,1228],{"class":275},[262,50287,1231],{"class":429},[262,50289,1291],{"class":275},[262,50291,608],{"class":429},[262,50293,1239],{"class":275},[262,50295,1231],{"class":429},[262,50297,642],{"class":377},[262,50299,1265],{"class":275},[262,50301,1268],{"class":271},[262,50303,1271],{"class":429},[262,50305,4644],{"class":271},[262,50307,50308],{"class":275},"Question: ",[262,50310,3039],{"class":271},[262,50312,50313],{"class":429},"question",[262,50315,654],{"class":271},[262,50317,1176],{"class":275},[262,50319,10332],{"class":429},[262,50321,50322,50324,50326,50328,50330],{"class":181,"line":1946},[262,50323,3074],{"class":377},[262,50325,3077],{"class":429},[262,50327,835],{"class":377},[262,50329,3082],{"class":271},[262,50331,39302],{"class":429},[262,50333,50334,50336],{"class":181,"line":1959},[262,50335,3090],{"class":377},[262,50337,1160],{"class":429},[262,50339,50340,50343,50345,50348,50350,50352,50354,50356,50358,50360,50363,50365,50367,50369],{"class":181,"line":1996},[262,50341,50342],{"class":429},"            out ",[262,50344,476],{"class":377},[262,50346,50347],{"class":429}," client.chat.completions.create(",[262,50349,805],{"class":611},[262,50351,476],{"class":377},[262,50353,1603],{"class":271},[262,50355,608],{"class":429},[262,50357,43269],{"class":611},[262,50359,476],{"class":377},[262,50361,50362],{"class":429},"history, ",[262,50364,3829],{"class":611},[262,50366,476],{"class":377},[262,50368,102],{"class":271},[262,50370,660],{"class":429},[262,50372,50373,50376,50378,50381,50383],{"class":181,"line":2012},[262,50374,50375],{"class":429},"            answer ",[262,50377,476],{"class":377},[262,50379,50380],{"class":429}," out.choices[",[262,50382,102],{"class":271},[262,50384,1331],{"class":429},[262,50386,50387,50390,50392,50394,50396,50398,50400,50403],{"class":181,"line":2040},[262,50388,50389],{"class":429},"            history.append({",[262,50391,1228],{"class":275},[262,50393,1231],{"class":429},[262,50395,43214],{"class":275},[262,50397,608],{"class":429},[262,50399,1239],{"class":275},[262,50401,50402],{"class":429},": answer})  ",[262,50404,50405],{"class":291},"# remember reply\n",[262,50407,50408,50410],{"class":181,"line":2045},[262,50409,3198],{"class":377},[262,50411,50412],{"class":429}," answer\n",[262,50414,50415,50417],{"class":181,"line":2050},[262,50416,3214],{"class":377},[262,50418,9787],{"class":429},[262,50420,50421,50423,50425,50427,50430],{"class":181,"line":2067},[262,50422,9913],{"class":429},[262,50424,109],{"class":271},[262,50426,3235],{"class":377},[262,50428,50429],{"class":429}," attempt)                       ",[262,50431,50432],{"class":291},"# exponential backoff\n",[262,50434,50435,50437],{"class":181,"line":2077},[262,50436,573],{"class":377},[262,50438,50439],{"class":275}," \"Sorry, I'm having trouble right now. Please try again shortly.\"\n",[262,50441,50442],{"class":181,"line":2086},[262,50443,583],{"emptyLinePlaceholder":582},[262,50445,50446],{"class":181,"line":2097},[262,50447,583],{"emptyLinePlaceholder":582},[262,50449,50450,50452,50454,50456,50458,50461],{"class":181,"line":2106},[262,50451,2210],{"class":377},[262,50453,2213],{"class":271},[262,50455,2216],{"class":377},[262,50457,2219],{"class":275},[262,50459,50460],{"class":429},":                                ",[262,50462,50463],{"class":291},"# simple terminal loop\n",[262,50465,50466,50468,50470,50473],{"class":181,"line":2126},[262,50467,1089],{"class":271},[262,50469,602],{"class":429},[262,50471,50472],{"class":275},"\"Bike-shop bot ready. Type 'quit' to exit.\"",[262,50474,660],{"class":429},[262,50476,50477,50479,50481],{"class":181,"line":2148},[262,50478,506],{"class":377},[262,50480,2241],{"class":271},[262,50482,1160],{"class":429},[262,50484,50485,50487,50489,50491,50493,50495],{"class":181,"line":2165},[262,50486,2249],{"class":429},[262,50488,476],{"class":377},[262,50490,2254],{"class":271},[262,50492,602],{"class":429},[262,50494,2259],{"class":275},[262,50496,2262],{"class":429},[262,50498,50499,50501,50503,50505,50507,50509,50511,50513],{"class":181,"line":2170},[262,50500,2268],{"class":377},[262,50502,2271],{"class":429},[262,50504,835],{"class":377},[262,50506,2276],{"class":429},[262,50508,2279],{"class":275},[262,50510,608],{"class":429},[262,50512,2284],{"class":275},[262,50514,2287],{"class":429},[262,50516,50517],{"class":181,"line":2181},[262,50518,2293],{"class":377},[262,50520,50521,50523,50525,50527],{"class":181,"line":2186},[262,50522,2299],{"class":271},[262,50524,602],{"class":429},[262,50526,2304],{"class":275},[262,50528,50529],{"class":429},", reply(msg))\n",[14,50531,50532],{},"That is a real chatbot: roughly forty lines, no framework, grounded in your data, with memory and error handling. Everything past this point is depth on one of these four building blocks.",[57,50534,2355],{"id":2354},[14,50536,50537],{},"Pick the feature your project needs next and follow its dedicated guide:",[1447,50539,50540,50548,50556,50567],{},[1450,50541,50542,50545,50546,1363],{},[35,50543,50544],{},"Make it feel instant."," Show the reply word by word instead of after a pause with ",[51,50547,2362],{"href":2361},[1450,50549,50550,50553,50554,1363],{},[35,50551,50552],{},"Make memory durable."," Move history out of a Python list and into a database keyed by user session with ",[51,50555,2367],{"href":2366},[1450,50557,50558,50561,50562,50564,50565,1363],{},[35,50559,50560],{},"Scale retrieval."," Replace the ",[18,50563,24],{}," search with a real document pipeline in ",[51,50566,5],{"href":44488},[1450,50568,50569,50572,50573,1363],{},[35,50570,50571],{},"Productise it."," Add routing, fallbacks, and a framework layer with ",[51,50574,2372],{"href":2371},[14,50576,50577,50578,50580,50581,1363],{},"When your bot is solid, wire it into the rest of your stack: feed conversations into your pipeline with ",[51,50579,36938],{"href":36937},", or package it as a paid product with ",[51,50582,39690],{"href":39689},[14,50584,2375,50585,1363],{},[51,50586,26457],{"href":26456},[57,50588,2381],{"id":2380},[2322,50590,50591,50595,50599,50603,50607],{},[1450,50592,50593],{},[51,50594,2372],{"href":2371},[1450,50596,50597],{},[51,50598,2367],{"href":2366},[1450,50600,50601],{},[51,50602,2362],{"href":2361},[1450,50604,50605],{},[51,50606,5],{"href":44488},[1450,50608,50609],{},[51,50610,2487],{"href":2486},[2401,50612,35084],{},{"title":258,"searchDepth":282,"depth":282,"links":50614},[50615,50616,50617,50618,50619,50620,50621,50622,50623,50624,50625,50626],{"id":48051,"depth":282,"text":48052},{"id":237,"depth":282,"text":238},{"id":48278,"depth":282,"text":48279},{"id":48502,"depth":282,"text":48503},{"id":48770,"depth":282,"text":48771},{"id":49306,"depth":282,"text":49307},{"id":49603,"depth":282,"text":49604},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":49833,"depth":282,"text":49834},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Build a custom AI chatbot with Python step by step: OpenAI SDK calls, conversation memory, retrieval, streaming, and error handling for real business use.",[50629,50632,50635,50638,50641],{"q":50630,"a":50631},"Do I need machine learning skills to build a custom AI chatbot in Python?","No. You call a hosted model like GPT-4o-mini over an API, so the model is already trained. You only need basic Python to send messages and handle the replies. Most of the work is wiring, prompts, and memory, not training a model.",{"q":50633,"a":50634},"How much does it cost to run a Python chatbot?","With a small model such as gpt-4o-mini, a typical short exchange costs a fraction of a cent. Costs scale with how much text you send and receive, so trimming conversation history and using small models keeps spend low. You can set hard usage caps in your provider dashboard.",{"q":50636,"a":50637},"What is the difference between a chatbot with memory and a stateless one?","A stateless chatbot forgets every previous turn, so each message is answered in isolation. A chatbot with memory resends past turns so the model can refer back to them, which makes follow-up questions like 'and what about the cheaper plan?' work correctly.",{"q":50639,"a":50640},"Do I have to use LangChain to build a chatbot?","No. You can build a complete, production-ready chatbot with just the openai SDK and a Python list for history. LangChain helps when you add retrieval, tools, or many chained steps, but it adds a learning curve, so start without it.",{"q":50642,"a":50643},"How do I stop my chatbot from making up answers?","Ground it in your own documents using retrieval, instruct it in the system prompt to say 'I don't know' when the context lacks an answer, and keep the temperature low. Retrieval plus a strict system prompt removes most invented answers.",{"name":50645,"steps":50646},"How to build a custom AI chatbot with Python",[50647,50650,50653,50656],{"name":50648,"text":50649},"Install the SDK and store your API key","Create a virtual environment, install the openai and httpx libraries, and save your API key in a .env file that is ignored by git.",{"name":50651,"text":50652},"Send your first chat message","Use the openai SDK to send a system prompt and a user message, then print the model's reply.",{"name":50654,"text":50655},"Add conversation memory","Keep a running list of messages and resend it on every turn so the model remembers earlier turns.",{"name":50657,"text":50658},"Ground answers in your own data with retrieval","Find the most relevant snippets from your documents and pass them into the prompt as context before the model answers.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development","2026-05-05",{"title":48016,"description":50627},"Custom AI Chatbot Development with Python","building-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Findex","1czvZ2WaCv_fv2hH60m0F2MsY0cnY7yvNg7fTObAKEg",{"id":50667,"title":2362,"body":50668,"description":52216,"extension":2419,"faq":52217,"howto":52233,"meta":52247,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":52248,"published":2452,"seo":52249,"seoTitle":2362,"stem":52250,"__hash__":52251},"content\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fstream-chatbot-responses-with-python\u002Findex.md",{"type":7,"value":50669,"toc":52206},[50670,50673,50676,50687,50693,50695,50705,50711,50750,50754,50771,50780,50794,50799,50803,50814,50828,51060,51074,51078,51084,51498,51510,51514,51529,51544,51907,51920,51935,51949,51981,51987,51989,51999,52072,52074,52152,52154,52174,52180,52184,52186,52204],[10,50671,2362],{"id":50672},"stream-chatbot-responses-with-python",[14,50674,50675],{},"This guide shows you how to make your chatbot's replies appear word by word in under fifteen minutes, both in a terminal and over a web connection. By the end you will have a command-line bot that types its answer live and a FastAPI endpoint a browser can read token by token.",[14,50677,50678,50679,50682,50683,50686],{},"The reason to bother is one thing: how the wait ",[27,50680,50681],{},"feels",". A blocking call returns nothing until the entire answer is ready, so the user watches a frozen screen for two, three, four seconds. Streaming sends the reply in tiny pieces — called ",[35,50684,50685],{},"tokens",", roughly word fragments — the instant the model produces each one. The first words land in a fraction of a second, so the bot feels alive even though the total time to finish is unchanged. That single difference is why every polished chat product streams.",[14,50688,50689,50690,50692],{},"This guide builds directly on the chatbot from ",[51,50691,54],{"href":53},". If you have not sent a basic chat message from Python yet, start there, then come back to add live output.",[57,50694,238],{"id":237},[14,50696,241,50697,50699,50700,1374,50702,50704],{},[18,50698,244],{}," to check) and an OpenAI API key. If Python or virtual environments are new, ",[51,50701,5423],{"href":5422},[51,50703,2482],{"href":2481}," cover both.",[14,50706,50707,50708,50710],{},"Work inside a virtual environment and install the two libraries this guide adds on top of the base chatbot — the ",[18,50709,20],{}," SDK and FastAPI with its built-in server:",[253,50712,50714],{"className":255,"code":50713,"language":257,"meta":258,"style":258},"python3 -m venv .venv\nsource .venv\u002Fbin\u002Factivate          # Windows: .venv\\Scripts\\activate\npip install \"openai>=1.40\" python-dotenv \"fastapi>=0.110\" \"uvicorn[standard]>=0.29\"\n",[18,50715,50716,50726,50734],{"__ignoreMap":258},[262,50717,50718,50720,50722,50724],{"class":181,"line":264},[262,50719,268],{"class":267},[262,50721,272],{"class":271},[262,50723,276],{"class":275},[262,50725,279],{"class":275},[262,50727,50728,50730,50732],{"class":181,"line":282},[262,50729,285],{"class":271},[262,50731,288],{"class":275},[262,50733,292],{"class":291},[262,50735,50736,50738,50740,50742,50744,50747],{"class":181,"line":295},[262,50737,298],{"class":267},[262,50739,301],{"class":275},[262,50741,304],{"class":275},[262,50743,310],{"class":275},[262,50745,50746],{"class":275}," \"fastapi>=0.110\"",[262,50748,50749],{"class":275}," \"uvicorn[standard]>=0.29\"\n",[14,50751,9458,50752,29038],{},[18,50753,319],{},[253,50755,50757],{"className":323,"code":50756,"language":325,"meta":258,"style":258},"# .env\nOPENAI_API_KEY=sk-your-real-key-here\nCHAT_MODEL=gpt-4o-mini\n",[18,50758,50759,50763,50767],{"__ignoreMap":258},[262,50760,50761],{"class":181,"line":264},[262,50762,332],{},[262,50764,50765],{"class":181,"line":282},[262,50766,337],{},[262,50768,50769],{"class":181,"line":295},[262,50770,342],{},[14,50772,50773,50779],{},[35,50774,353,50775,356,50777,360],{},[18,50776,319],{},[18,50778,359],{}," so the secret key is never committed:",[253,50781,50782],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,50783,50784],{"__ignoreMap":258},[262,50785,50786,50788,50790,50792],{"class":181,"line":264},[262,50787,371],{"class":271},[262,50789,374],{"class":275},[262,50791,378],{"class":377},[262,50793,381],{"class":275},[14,50795,384,50796,50798],{},[51,50797,388],{"href":387}," lists every cause.",[57,50800,50802],{"id":50801},"step-1-stream-tokens-in-a-command-line-chatbot","Step 1: Stream tokens in a command-line chatbot",[14,50804,50805,50806,50809,50810,50813],{},"A normal call hands you the whole reply at once. Adding ",[18,50807,50808],{},"stream=True"," flips a switch: instead of one finished answer, the SDK returns an ",[35,50811,50812],{},"iterator"," — an object you loop over — that yields a small chunk each time the model produces more text. You print each chunk the moment it lands, and the answer types itself out.",[14,50815,50816,50817,50820,50821,50824,50825,50827],{},"Each chunk carries its new text at ",[18,50818,50819],{},"chunk.choices[0].delta.content",". The word ",[35,50822,50823],{},"delta"," means \"the bit that changed since the last chunk.\" Sometimes that field is ",[18,50826,8471],{}," (the very first and very last chunks carry no text), so you guard against it before printing.",[253,50829,50831],{"className":414,"code":50830,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\nstream = client.chat.completions.create(\n    model=os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\"),\n    messages=[\n        {\"role\": \"system\", \"content\": \"You are a concise, friendly bike-shop assistant.\"},\n        {\"role\": \"user\", \"content\": \"Recommend a first road bike for a commuter.\"},\n    ],\n    temperature=0.4,\n    stream=True,\n)\n\nfor chunk in stream:\n    token = chunk.choices[0].delta.content\n    if token:                          # skip the empty opening and closing chunks\n        print(token, end=\"\", flush=True)\nprint()                                # final newline after the answer\n",[18,50832,50833,50839,50849,50859,50863,50867,50877,50881,50890,50906,50914,50935,50956,50960,50970,50981,50985,50989,51001,51015,51025,51050],{"__ignoreMap":258},[262,50834,50835,50837],{"class":181,"line":264},[262,50836,684],{"class":377},[262,50838,687],{"class":429},[262,50840,50841,50843,50845,50847],{"class":181,"line":282},[262,50842,705],{"class":377},[262,50844,708],{"class":429},[262,50846,684],{"class":377},[262,50848,713],{"class":429},[262,50850,50851,50853,50855,50857],{"class":181,"line":295},[262,50852,705],{"class":377},[262,50854,720],{"class":429},[262,50856,684],{"class":377},[262,50858,725],{"class":429},[262,50860,50861],{"class":181,"line":345},[262,50862,583],{"emptyLinePlaceholder":582},[262,50864,50865],{"class":181,"line":492},[262,50866,734],{"class":429},[262,50868,50869,50871,50873,50875],{"class":181,"line":503},[262,50870,739],{"class":429},[262,50872,476],{"class":377},[262,50874,9578],{"class":429},[262,50876,9581],{"class":291},[262,50878,50879],{"class":181,"line":521},[262,50880,583],{"emptyLinePlaceholder":582},[262,50882,50883,50886,50888],{"class":181,"line":537},[262,50884,50885],{"class":429},"stream ",[262,50887,476],{"class":377},[262,50889,1189],{"class":429},[262,50891,50892,50894,50896,50898,50900,50902,50904],{"class":181,"line":549},[262,50893,48371],{"class":611},[262,50895,476],{"class":377},[262,50897,1199],{"class":429},[262,50899,1202],{"class":275},[262,50901,608],{"class":429},[262,50903,1207],{"class":275},[262,50905,1210],{"class":429},[262,50907,50908,50910,50912],{"class":181,"line":570},[262,50909,48388],{"class":611},[262,50911,476],{"class":377},[262,50913,1220],{"class":429},[262,50915,50916,50918,50920,50922,50924,50926,50928,50930,50933],{"class":181,"line":579},[262,50917,7726],{"class":429},[262,50919,1228],{"class":275},[262,50921,1231],{"class":429},[262,50923,1234],{"class":275},[262,50925,608],{"class":429},[262,50927,1239],{"class":275},[262,50929,1231],{"class":429},[262,50931,50932],{"class":275},"\"You are a concise, friendly bike-shop assistant.\"",[262,50934,3143],{"class":429},[262,50936,50937,50939,50941,50943,50945,50947,50949,50951,50954],{"class":181,"line":586},[262,50938,7726],{"class":429},[262,50940,1228],{"class":275},[262,50942,1231],{"class":429},[262,50944,1291],{"class":275},[262,50946,608],{"class":429},[262,50948,1239],{"class":275},[262,50950,1231],{"class":429},[262,50952,50953],{"class":275},"\"Recommend a first road bike for a commuter.\"",[262,50955,3143],{"class":429},[262,50957,50958],{"class":181,"line":591},[262,50959,48439],{"class":429},[262,50961,50962,50964,50966,50968],{"class":181,"line":623},[262,50963,48444],{"class":611},[262,50965,476],{"class":377},[262,50967,3175],{"class":271},[262,50969,1315],{"class":429},[262,50971,50972,50975,50977,50979],{"class":181,"line":634},[262,50973,50974],{"class":611},"    stream",[262,50976,476],{"class":377},[262,50978,4974],{"class":271},[262,50980,1315],{"class":429},[262,50982,50983],{"class":181,"line":845},[262,50984,660],{"class":429},[262,50986,50987],{"class":181,"line":850},[262,50988,583],{"emptyLinePlaceholder":582},[262,50990,50991,50993,50996,50998],{"class":181,"line":864},[262,50992,829],{"class":377},[262,50994,50995],{"class":429}," chunk ",[262,50997,835],{"class":377},[262,50999,51000],{"class":429}," stream:\n",[262,51002,51003,51005,51007,51010,51012],{"class":181,"line":1683},[262,51004,36180],{"class":429},[262,51006,476],{"class":377},[262,51008,51009],{"class":429}," chunk.choices[",[262,51011,102],{"class":271},[262,51013,51014],{"class":429},"].delta.content\n",[262,51016,51017,51019,51022],{"class":181,"line":1688},[262,51018,3454],{"class":377},[262,51020,51021],{"class":429}," token:                          ",[262,51023,51024],{"class":291},"# skip the empty opening and closing chunks\n",[262,51026,51027,51029,51032,51035,51037,51039,51041,51044,51046,51048],{"class":181,"line":1693},[262,51028,2299],{"class":271},[262,51030,51031],{"class":429},"(token, ",[262,51033,51034],{"class":611},"end",[262,51036,476],{"class":377},[262,51038,9175],{"class":275},[262,51040,608],{"class":429},[262,51042,51043],{"class":611},"flush",[262,51045,476],{"class":377},[262,51047,4974],{"class":271},[262,51049,660],{"class":429},[262,51051,51052,51054,51057],{"class":181,"line":1728},[262,51053,637],{"class":271},[262,51055,51056],{"class":429},"()                                ",[262,51058,51059],{"class":291},"# final newline after the answer\n",[14,51061,51062,51063,51066,51067,51069,51070,51073],{},"Two details make the live effect work. ",[18,51064,51065],{},"end=\"\""," stops ",[18,51068,637],{}," from adding a line break after every token, so the words flow into one continuous answer. ",[18,51071,51072],{},"flush=True"," forces Python to push each token to the screen immediately instead of holding it in a buffer — without it, your terminal might show nothing until the whole reply is done, defeating the point. Run the script and you will watch the recommendation appear a few words at a time.",[57,51075,51077],{"id":51076},"step-2-collect-the-full-reply-while-streaming","Step 2: Collect the full reply while streaming",[14,51079,51080,51081,51083],{},"Printing live is great for a person watching, but your program usually needs the finished text too — to store it in conversation memory, log it, or send it on. The fix is simple: build up a string as the tokens fly past. You print ",[27,51082,6101],{}," save in the same loop.",[253,51085,51087],{"className":414,"code":51086,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()\nMODEL = os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\")\n\n# Conversation memory: the system message stays pinned at the front.\nmessages = [\n    {\"role\": \"system\", \"content\": \"You are a concise, friendly bike-shop assistant.\"},\n]\n\n\ndef stream_reply(user_text: str) -> str:\n    messages.append({\"role\": \"user\", \"content\": user_text})\n    stream = client.chat.completions.create(\n        model=MODEL, messages=messages, temperature=0.4, stream=True,\n    )\n    parts: list[str] = []\n    for chunk in stream:\n        token = chunk.choices[0].delta.content\n        if token:\n            print(token, end=\"\", flush=True)\n            parts.append(token)        # keep every piece\n    print()\n    full = \"\".join(parts)              # the complete reply\n    messages.append({\"role\": \"assistant\", \"content\": full})  # remember it\n    return full\n\n\nif __name__ == \"__main__\":\n    print(\"Streaming bot ready. Type 'quit' to exit.\")\n    while True:\n        msg = input(\"\\nYou: \").strip()\n        if msg.lower() in {\"quit\", \"exit\"}:\n            break\n        print(\"Bot: \", end=\"\")\n        stream_reply(msg)\n",[18,51088,51089,51095,51105,51115,51119,51123,51131,51147,51151,51156,51164,51184,51188,51192,51196,51213,51229,51238,51271,51275,51288,51298,51310,51317,51339,51347,51353,51368,51388,51395,51399,51403,51415,51426,51434,51452,51470,51474,51493],{"__ignoreMap":258},[262,51090,51091,51093],{"class":181,"line":264},[262,51092,684],{"class":377},[262,51094,687],{"class":429},[262,51096,51097,51099,51101,51103],{"class":181,"line":282},[262,51098,705],{"class":377},[262,51100,708],{"class":429},[262,51102,684],{"class":377},[262,51104,713],{"class":429},[262,51106,51107,51109,51111,51113],{"class":181,"line":295},[262,51108,705],{"class":377},[262,51110,720],{"class":429},[262,51112,684],{"class":377},[262,51114,725],{"class":429},[262,51116,51117],{"class":181,"line":345},[262,51118,583],{"emptyLinePlaceholder":582},[262,51120,51121],{"class":181,"line":492},[262,51122,734],{"class":429},[262,51124,51125,51127,51129],{"class":181,"line":503},[262,51126,739],{"class":429},[262,51128,476],{"class":377},[262,51130,744],{"class":429},[262,51132,51133,51135,51137,51139,51141,51143,51145],{"class":181,"line":521},[262,51134,2693],{"class":271},[262,51136,442],{"class":377},[262,51138,754],{"class":429},[262,51140,1202],{"class":275},[262,51142,608],{"class":429},[262,51144,1207],{"class":275},[262,51146,660],{"class":429},[262,51148,51149],{"class":181,"line":537},[262,51150,583],{"emptyLinePlaceholder":582},[262,51152,51153],{"class":181,"line":549},[262,51154,51155],{"class":291},"# Conversation memory: the system message stays pinned at the front.\n",[262,51157,51158,51160,51162],{"class":181,"line":570},[262,51159,43086],{"class":429},[262,51161,476],{"class":377},[262,51163,5589],{"class":429},[262,51165,51166,51168,51170,51172,51174,51176,51178,51180,51182],{"class":181,"line":579},[262,51167,42305],{"class":429},[262,51169,1228],{"class":275},[262,51171,1231],{"class":429},[262,51173,1234],{"class":275},[262,51175,608],{"class":429},[262,51177,1239],{"class":275},[262,51179,1231],{"class":429},[262,51181,50932],{"class":275},[262,51183,3143],{"class":429},[262,51185,51186],{"class":181,"line":586},[262,51187,957],{"class":429},[262,51189,51190],{"class":181,"line":591},[262,51191,583],{"emptyLinePlaceholder":582},[262,51193,51194],{"class":181,"line":623},[262,51195,583],{"emptyLinePlaceholder":582},[262,51197,51198,51200,51203,51205,51207,51209,51211],{"class":181,"line":634},[262,51199,423],{"class":377},[262,51201,51202],{"class":267}," stream_reply",[262,51204,43133],{"class":429},[262,51206,433],{"class":271},[262,51208,1939],{"class":429},[262,51210,433],{"class":271},[262,51212,1160],{"class":429},[262,51214,51215,51217,51219,51221,51223,51225,51227],{"class":181,"line":845},[262,51216,43146],{"class":429},[262,51218,1228],{"class":275},[262,51220,1231],{"class":429},[262,51222,1291],{"class":275},[262,51224,608],{"class":429},[262,51226,1239],{"class":275},[262,51228,43159],{"class":429},[262,51230,51231,51234,51236],{"class":181,"line":850},[262,51232,51233],{"class":429},"    stream ",[262,51235,476],{"class":377},[262,51237,1189],{"class":429},[262,51239,51240,51242,51244,51246,51248,51250,51252,51255,51257,51259,51261,51263,51265,51267,51269],{"class":181,"line":864},[262,51241,1194],{"class":611},[262,51243,476],{"class":377},[262,51245,2693],{"class":271},[262,51247,608],{"class":429},[262,51249,43269],{"class":611},[262,51251,476],{"class":377},[262,51253,51254],{"class":429},"messages, ",[262,51256,3829],{"class":611},[262,51258,476],{"class":377},[262,51260,3175],{"class":271},[262,51262,608],{"class":429},[262,51264,49743],{"class":611},[262,51266,476],{"class":377},[262,51268,4974],{"class":271},[262,51270,1315],{"class":429},[262,51272,51273],{"class":181,"line":1683},[262,51274,1011],{"class":429},[262,51276,51277,51280,51282,51284,51286],{"class":181,"line":1688},[262,51278,51279],{"class":429},"    parts: list[",[262,51281,433],{"class":271},[262,51283,2903],{"class":429},[262,51285,476],{"class":377},[262,51287,489],{"class":429},[262,51289,51290,51292,51294,51296],{"class":181,"line":1693},[262,51291,3074],{"class":377},[262,51293,50995],{"class":429},[262,51295,835],{"class":377},[262,51297,51000],{"class":429},[262,51299,51300,51302,51304,51306,51308],{"class":181,"line":1728},[262,51301,21321],{"class":429},[262,51303,476],{"class":377},[262,51305,51009],{"class":429},[262,51307,102],{"class":271},[262,51309,51014],{"class":429},[262,51311,51312,51314],{"class":181,"line":1737},[262,51313,2268],{"class":377},[262,51315,51316],{"class":429}," token:\n",[262,51318,51319,51321,51323,51325,51327,51329,51331,51333,51335,51337],{"class":181,"line":1751},[262,51320,3250],{"class":271},[262,51322,51031],{"class":429},[262,51324,51034],{"class":611},[262,51326,476],{"class":377},[262,51328,9175],{"class":275},[262,51330,608],{"class":429},[262,51332,51043],{"class":611},[262,51334,476],{"class":377},[262,51336,4974],{"class":271},[262,51338,660],{"class":429},[262,51340,51341,51344],{"class":181,"line":1764},[262,51342,51343],{"class":429},"            parts.append(token)        ",[262,51345,51346],{"class":291},"# keep every piece\n",[262,51348,51349,51351],{"class":181,"line":1779},[262,51350,1089],{"class":271},[262,51352,16852],{"class":429},[262,51354,51355,51358,51360,51362,51365],{"class":181,"line":1793},[262,51356,51357],{"class":429},"    full ",[262,51359,476],{"class":377},[262,51361,6332],{"class":275},[262,51363,51364],{"class":429},".join(parts)              ",[262,51366,51367],{"class":291},"# the complete reply\n",[262,51369,51370,51372,51374,51376,51378,51380,51382,51385],{"class":181,"line":1800},[262,51371,43146],{"class":429},[262,51373,1228],{"class":275},[262,51375,1231],{"class":429},[262,51377,43214],{"class":275},[262,51379,608],{"class":429},[262,51381,1239],{"class":275},[262,51383,51384],{"class":429},": full})  ",[262,51386,51387],{"class":291},"# remember it\n",[262,51389,51390,51392],{"class":181,"line":1805},[262,51391,573],{"class":377},[262,51393,51394],{"class":429}," full\n",[262,51396,51397],{"class":181,"line":1810},[262,51398,583],{"emptyLinePlaceholder":582},[262,51400,51401],{"class":181,"line":1823},[262,51402,583],{"emptyLinePlaceholder":582},[262,51404,51405,51407,51409,51411,51413],{"class":181,"line":1846},[262,51406,2210],{"class":377},[262,51408,2213],{"class":271},[262,51410,2216],{"class":377},[262,51412,2219],{"class":275},[262,51414,1160],{"class":429},[262,51416,51417,51419,51421,51424],{"class":181,"line":1861},[262,51418,1089],{"class":271},[262,51420,602],{"class":429},[262,51422,51423],{"class":275},"\"Streaming bot ready. Type 'quit' to exit.\"",[262,51425,660],{"class":429},[262,51427,51428,51430,51432],{"class":181,"line":1866},[262,51429,506],{"class":377},[262,51431,2241],{"class":271},[262,51433,1160],{"class":429},[262,51435,51436,51438,51440,51442,51444,51446,51448,51450],{"class":181,"line":1871},[262,51437,2249],{"class":429},[262,51439,476],{"class":377},[262,51441,2254],{"class":271},[262,51443,602],{"class":429},[262,51445,1176],{"class":275},[262,51447,2137],{"class":271},[262,51449,44255],{"class":275},[262,51451,2262],{"class":429},[262,51453,51454,51456,51458,51460,51462,51464,51466,51468],{"class":181,"line":1890},[262,51455,2268],{"class":377},[262,51457,2271],{"class":429},[262,51459,835],{"class":377},[262,51461,2276],{"class":429},[262,51463,2279],{"class":275},[262,51465,608],{"class":429},[262,51467,2284],{"class":275},[262,51469,2287],{"class":429},[262,51471,51472],{"class":181,"line":1909},[262,51473,2293],{"class":377},[262,51475,51476,51478,51480,51483,51485,51487,51489,51491],{"class":181,"line":1914},[262,51477,2299],{"class":271},[262,51479,602],{"class":429},[262,51481,51482],{"class":275},"\"Bot: \"",[262,51484,608],{"class":429},[262,51486,51034],{"class":611},[262,51488,476],{"class":377},[262,51490,9175],{"class":275},[262,51492,660],{"class":429},[262,51494,51495],{"class":181,"line":1919},[262,51496,51497],{"class":429},"        stream_reply(msg)\n",[14,51499,51500,51501,51504,51505,51507,51508,1363],{},"Collecting into a list of ",[18,51502,51503],{},"parts"," and joining once at the end is faster than gluing strings together inside the loop, and it gives you the whole answer to append to ",[18,51506,43269],{},". Because you store the assistant's reply, the bot keeps context across turns just like a blocking one. To go deeper on storing history beyond a single session, see ",[51,51509,2367],{"href":2366},[57,51511,51513],{"id":51512},"step-3-serve-a-streaming-endpoint-with-fastapi","Step 3: Serve a streaming endpoint with FastAPI",[14,51515,51516,51517,51520,51521,51524,51525,51528],{},"A terminal is fine for testing, but real users sit in a browser. To stream to them, you keep one HTTP connection open and push tokens down it as they arrive. The standard, dependency-free way to do this is ",[35,51518,51519],{},"Server-Sent Events (SSE)"," — a simple text format where each message is a line beginning with ",[18,51522,51523],{},"data:"," and ending in a blank line. Browsers read SSE natively with the built-in ",[18,51526,51527],{},"EventSource"," API.",[14,51530,51531,51532,51535,51536,51539,51540,51543],{},"In FastAPI you return a ",[18,51533,51534],{},"StreamingResponse"," fed by a ",[35,51537,51538],{},"generator"," — a function that ",[18,51541,51542],{},"yield","s values one at a time instead of returning all at once. Each token the model produces becomes one SSE message.",[253,51545,51547],{"className":414,"code":51546,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom fastapi import FastAPI\nfrom fastapi.responses import StreamingResponse\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()\nMODEL = os.getenv(\"CHAT_MODEL\", \"gpt-4o-mini\")\napp = FastAPI()\n\n\ndef token_stream(question: str):\n    stream = client.chat.completions.create(\n        model=MODEL,\n        messages=[\n            {\"role\": \"system\", \"content\": \"You are a concise bike-shop assistant.\"},\n            {\"role\": \"user\", \"content\": question},\n        ],\n        temperature=0.4,\n        stream=True,\n    )\n    for chunk in stream:\n        token = chunk.choices[0].delta.content\n        if token:\n            yield f\"data: {token}\\n\\n\"   # one SSE message per token\n    yield \"data: [DONE]\\n\\n\"             # tell the browser the answer is complete\n\n\n@app.get(\"\u002Fchat\")\ndef chat(q: str):\n    return StreamingResponse(\n        token_stream(q),\n        media_type=\"text\u002Fevent-stream\",\n        headers={\"Cache-Control\": \"no-cache\", \"X-Accel-Buffering\": \"no\"},\n    )\n",[18,51548,51549,51555,51565,51577,51589,51599,51603,51607,51615,51631,51641,51645,51649,51662,51670,51680,51688,51709,51725,51729,51739,51750,51754,51764,51776,51782,51803,51818,51822,51826,51838,51851,51858,51863,51875,51903],{"__ignoreMap":258},[262,51550,51551,51553],{"class":181,"line":264},[262,51552,684],{"class":377},[262,51554,687],{"class":429},[262,51556,51557,51559,51561,51563],{"class":181,"line":282},[262,51558,705],{"class":377},[262,51560,708],{"class":429},[262,51562,684],{"class":377},[262,51564,713],{"class":429},[262,51566,51567,51569,51572,51574],{"class":181,"line":295},[262,51568,705],{"class":377},[262,51570,51571],{"class":429}," fastapi ",[262,51573,684],{"class":377},[262,51575,51576],{"class":429}," FastAPI\n",[262,51578,51579,51581,51584,51586],{"class":181,"line":345},[262,51580,705],{"class":377},[262,51582,51583],{"class":429}," fastapi.responses ",[262,51585,684],{"class":377},[262,51587,51588],{"class":429}," StreamingResponse\n",[262,51590,51591,51593,51595,51597],{"class":181,"line":492},[262,51592,705],{"class":377},[262,51594,720],{"class":429},[262,51596,684],{"class":377},[262,51598,725],{"class":429},[262,51600,51601],{"class":181,"line":503},[262,51602,583],{"emptyLinePlaceholder":582},[262,51604,51605],{"class":181,"line":521},[262,51606,734],{"class":429},[262,51608,51609,51611,51613],{"class":181,"line":537},[262,51610,739],{"class":429},[262,51612,476],{"class":377},[262,51614,744],{"class":429},[262,51616,51617,51619,51621,51623,51625,51627,51629],{"class":181,"line":549},[262,51618,2693],{"class":271},[262,51620,442],{"class":377},[262,51622,754],{"class":429},[262,51624,1202],{"class":275},[262,51626,608],{"class":429},[262,51628,1207],{"class":275},[262,51630,660],{"class":429},[262,51632,51633,51636,51638],{"class":181,"line":570},[262,51634,51635],{"class":429},"app ",[262,51637,476],{"class":377},[262,51639,51640],{"class":429}," FastAPI()\n",[262,51642,51643],{"class":181,"line":579},[262,51644,583],{"emptyLinePlaceholder":582},[262,51646,51647],{"class":181,"line":586},[262,51648,583],{"emptyLinePlaceholder":582},[262,51650,51651,51653,51656,51658,51660],{"class":181,"line":591},[262,51652,423],{"class":377},[262,51654,51655],{"class":267}," token_stream",[262,51657,909],{"class":429},[262,51659,433],{"class":271},[262,51661,8192],{"class":429},[262,51663,51664,51666,51668],{"class":181,"line":623},[262,51665,51233],{"class":429},[262,51667,476],{"class":377},[262,51669,1189],{"class":429},[262,51671,51672,51674,51676,51678],{"class":181,"line":634},[262,51673,1194],{"class":611},[262,51675,476],{"class":377},[262,51677,2693],{"class":271},[262,51679,1315],{"class":429},[262,51681,51682,51684,51686],{"class":181,"line":845},[262,51683,1215],{"class":611},[262,51685,476],{"class":377},[262,51687,1220],{"class":429},[262,51689,51690,51692,51694,51696,51698,51700,51702,51704,51707],{"class":181,"line":850},[262,51691,1225],{"class":429},[262,51693,1228],{"class":275},[262,51695,1231],{"class":429},[262,51697,1234],{"class":275},[262,51699,608],{"class":429},[262,51701,1239],{"class":275},[262,51703,1231],{"class":429},[262,51705,51706],{"class":275},"\"You are a concise bike-shop assistant.\"",[262,51708,3143],{"class":429},[262,51710,51711,51713,51715,51717,51719,51721,51723],{"class":181,"line":864},[262,51712,1225],{"class":429},[262,51714,1228],{"class":275},[262,51716,1231],{"class":429},[262,51718,1291],{"class":275},[262,51720,608],{"class":429},[262,51722,1239],{"class":275},[262,51724,1298],{"class":429},[262,51726,51727],{"class":181,"line":1683},[262,51728,1303],{"class":429},[262,51730,51731,51733,51735,51737],{"class":181,"line":1688},[262,51732,1308],{"class":611},[262,51734,476],{"class":377},[262,51736,3175],{"class":271},[262,51738,1315],{"class":429},[262,51740,51741,51744,51746,51748],{"class":181,"line":1693},[262,51742,51743],{"class":611},"        stream",[262,51745,476],{"class":377},[262,51747,4974],{"class":271},[262,51749,1315],{"class":429},[262,51751,51752],{"class":181,"line":1728},[262,51753,1011],{"class":429},[262,51755,51756,51758,51760,51762],{"class":181,"line":1737},[262,51757,3074],{"class":377},[262,51759,50995],{"class":429},[262,51761,835],{"class":377},[262,51763,51000],{"class":429},[262,51765,51766,51768,51770,51772,51774],{"class":181,"line":1751},[262,51767,21321],{"class":429},[262,51769,476],{"class":377},[262,51771,51009],{"class":429},[262,51773,102],{"class":271},[262,51775,51014],{"class":429},[262,51777,51778,51780],{"class":181,"line":1764},[262,51779,2268],{"class":377},[262,51781,51316],{"class":429},[262,51783,51784,51787,51789,51792,51794,51796,51798,51800],{"class":181,"line":1779},[262,51785,51786],{"class":377},"            yield",[262,51788,10178],{"class":377},[262,51790,51791],{"class":275},"\"data: ",[262,51793,3039],{"class":271},[262,51795,7933],{"class":429},[262,51797,4644],{"class":271},[262,51799,1176],{"class":275},[262,51801,51802],{"class":291},"   # one SSE message per token\n",[262,51804,51805,51808,51811,51813,51815],{"class":181,"line":1793},[262,51806,51807],{"class":377},"    yield",[262,51809,51810],{"class":275}," \"data: [DONE]",[262,51812,1173],{"class":271},[262,51814,1176],{"class":275},[262,51816,51817],{"class":291},"             # tell the browser the answer is complete\n",[262,51819,51820],{"class":181,"line":1800},[262,51821,583],{"emptyLinePlaceholder":582},[262,51823,51824],{"class":181,"line":1805},[262,51825,583],{"emptyLinePlaceholder":582},[262,51827,51828,51831,51833,51836],{"class":181,"line":1810},[262,51829,51830],{"class":267},"@app.get",[262,51832,602],{"class":429},[262,51834,51835],{"class":275},"\"\u002Fchat\"",[262,51837,660],{"class":429},[262,51839,51840,51842,51844,51847,51849],{"class":181,"line":1823},[262,51841,423],{"class":377},[262,51843,43130],{"class":267},[262,51845,51846],{"class":429},"(q: ",[262,51848,433],{"class":271},[262,51850,8192],{"class":429},[262,51852,51853,51855],{"class":181,"line":1846},[262,51854,573],{"class":377},[262,51856,51857],{"class":429}," StreamingResponse(\n",[262,51859,51860],{"class":181,"line":1861},[262,51861,51862],{"class":429},"        token_stream(q),\n",[262,51864,51865,51868,51870,51873],{"class":181,"line":1866},[262,51866,51867],{"class":611},"        media_type",[262,51869,476],{"class":377},[262,51871,51872],{"class":275},"\"text\u002Fevent-stream\"",[262,51874,1315],{"class":429},[262,51876,51877,51879,51881,51883,51886,51888,51891,51893,51896,51898,51901],{"class":181,"line":1871},[262,51878,6588],{"class":611},[262,51880,476],{"class":377},[262,51882,3039],{"class":429},[262,51884,51885],{"class":275},"\"Cache-Control\"",[262,51887,1231],{"class":429},[262,51889,51890],{"class":275},"\"no-cache\"",[262,51892,608],{"class":429},[262,51894,51895],{"class":275},"\"X-Accel-Buffering\"",[262,51897,1231],{"class":429},[262,51899,51900],{"class":275},"\"no\"",[262,51902,3143],{"class":429},[262,51904,51905],{"class":181,"line":1890},[262,51906,1011],{"class":429},[14,51908,27834,51909,51912,51913,51916,51917,26616],{},[18,51910,51911],{},"server.py"," and start it with ",[18,51914,51915],{},"uvicorn server:app --reload",". Then watch tokens arrive in your terminal with ",[18,51918,51919],{},"curl",[253,51921,51923],{"className":255,"code":51922,"language":257,"meta":258,"style":258},"curl -N \"http:\u002F\u002F127.0.0.1:8000\u002Fchat?q=Recommend%20a%20commuter%20bike\"\n",[18,51924,51925],{"__ignoreMap":258},[262,51926,51927,51929,51932],{"class":181,"line":264},[262,51928,51919],{"class":267},[262,51930,51931],{"class":271}," -N",[262,51933,51934],{"class":275}," \"http:\u002F\u002F127.0.0.1:8000\u002Fchat?q=Recommend%20a%20commuter%20bike\"\n",[14,51936,3349,51937,51940,51941,51944,51945,51948],{},[18,51938,51939],{},"-N"," flag disables curl's own buffering so you see each token land. The ",[18,51942,51943],{},"media_type=\"text\u002Fevent-stream\""," header is what marks the response as SSE, and ",[18,51946,51947],{},"X-Accel-Buffering: no"," asks reverse proxies like nginx not to hold the tokens back. A browser would consume the same endpoint like this:",[253,51950,51954],{"className":51951,"code":51952,"language":51953,"meta":258,"style":258},"language-javascript shiki shiki-themes github-light github-dark","const source = new EventSource(\"\u002Fchat?q=Recommend a commuter bike\");\nsource.onmessage = (event) => {\n  if (event.data === \"[DONE]\") { source.close(); return; }\n  document.getElementById(\"reply\").textContent += event.data;\n};\n","javascript",[18,51955,51956,51961,51966,51971,51976],{"__ignoreMap":258},[262,51957,51958],{"class":181,"line":264},[262,51959,51960],{},"const source = new EventSource(\"\u002Fchat?q=Recommend a commuter bike\");\n",[262,51962,51963],{"class":181,"line":282},[262,51964,51965],{},"source.onmessage = (event) => {\n",[262,51967,51968],{"class":181,"line":295},[262,51969,51970],{},"  if (event.data === \"[DONE]\") { source.close(); return; }\n",[262,51972,51973],{"class":181,"line":345},[262,51974,51975],{},"  document.getElementById(\"reply\").textContent += event.data;\n",[262,51977,51978],{"class":181,"line":492},[262,51979,51980],{},"};\n",[14,51982,51983,51984,51986],{},"Each token appends to the page the instant it arrives, giving the same live-typing effect a polished chat app has. When you are ready to put this behind real users, pair it with ",[51,51985,49599],{"href":49598}," so one user cannot exhaust your quota.",[57,51988,1367],{"id":1366},[14,51990,51991,51992,608,51994,608,51996,51998],{},"These are the settings specific to streaming. Everything else (",[18,51993,805],{},[18,51995,3829],{},[18,51997,43269],{},") works exactly as it does in a blocking call.",[1379,52000,52001,52013],{},[1382,52002,52003],{},[1385,52004,52005,52007,52009,52011],{},[1388,52006,1390],{},[1388,52008,3795],{},[1388,52010,3798],{},[1388,52012,1396],{},[1398,52014,52015,52032,52052],{},[1385,52016,52017,52021,52023,52027],{},[1403,52018,52019],{},[18,52020,49743],{},[1403,52022,8045],{},[1403,52024,52025],{},[18,52026,3623],{},[1403,52028,49752,52029,52031],{},[18,52030,4974],{},", the call returns an iterator of token chunks instead of one finished reply.",[1385,52033,52034,52039,52041,52045],{},[1403,52035,52036],{},[18,52037,52038],{},"stream_options",[1403,52040,5869],{},[1403,52042,52043],{},[18,52044,8471],{},[1403,52046,52047,52048,52051],{},"Pass ",[18,52049,52050],{},"{\"include_usage\": True}"," to receive a token-count summary in the final chunk.",[1385,52053,52054,52059,52061,52063],{},[1403,52055,52056],{},[18,52057,52058],{},"media_type",[1403,52060,433],{},[1403,52062,219],{},[1403,52064,52065,52066,52068,52069,52071],{},"Set to ",[18,52067,51872],{}," on the ",[18,52070,51534],{}," so browsers treat the output as SSE.",[57,52073,1445],{"id":1444},[1447,52075,52076,52093,52111,52132],{},[1450,52077,52078,52081,52082,52084,52085,3921,52087,52089,52090,52092],{},[35,52079,52080],{},"The reply prints all at once instead of typing out."," Output is being buffered. Cause: a missing ",[18,52083,51072],{}," in a CLI, or a proxy buffering the HTTP response. Fix: add ",[18,52086,51072],{},[18,52088,637],{},", and send the ",[18,52091,51947],{}," header plus an early token so proxies release the stream.",[1450,52094,52095,52102,52103,52106,52107,52110],{},[35,52096,52097,52099,52100,1363],{},[18,52098,14854],{}," or printing the word ",[18,52101,8471],{}," You read ",[18,52104,52105],{},"delta.content"," without checking it. Cause: the first and last chunks carry no text. Fix: guard with ",[18,52108,52109],{},"if token:"," before printing or appending, as in every example here.",[1450,52112,52113,52120,52121,52124,52125,52127,52128,52131],{},[35,52114,52115,52116,52119],{},"The browser shows nothing but ",[18,52117,52118],{},"curl -N"," works."," The browser is buffering a tiny first response. Cause: some browsers wait for a few hundred bytes before firing ",[18,52122,52123],{},"onmessage",". Fix: ",[18,52126,51542],{}," a short padding comment line such as ",[18,52129,52130],{},"\": ok\\n\\n\""," right after the connection opens.",[1450,52133,52134,52143,52144,49806,52146,52148,52149,52151],{},[35,52135,52136,52138,52139,52142],{},[18,52137,7909],{}," raises ",[18,52140,52141],{},"AttributeError"," when streaming."," You used the blocking access path on a stream. Cause: streamed chunks expose ",[18,52145,50823],{},[18,52147,7917],{},". Fix: read ",[18,52150,50819],{}," inside the loop instead.",[57,52153,2317],{"id":2316},[2322,52155,52156,52162,52168],{},[1450,52157,52158,52161],{},[35,52159,52160],{},"Stream when a person is watching a long reply."," Chat windows, support bots, and anything that writes more than a sentence feel dramatically more responsive when tokens appear live, even though the total time is identical.",[1450,52163,52164,52167],{},[35,52165,52166],{},"Stay blocking for short or machine-read replies."," If the answer is a single label, a JSON object, or feeds another program rather than a human, streaming adds parsing complexity for no benefit — just take the whole reply at once.",[1450,52169,52170,52173],{},[35,52171,52172],{},"Skip streaming when you must validate before showing anything."," If you need to check, reformat, or moderate the full answer before the user sees a word, a blocking call is simpler because you have the complete text in hand before deciding what to display.",[14,52175,52176,52177,52179],{},"When your streaming bot also needs to answer from your own documents, layer retrieval on top with ",[51,52178,5],{"href":44488}," — you stream the final answer exactly the same way.",[14,52181,2375,52182,1363],{},[51,52183,54],{"href":53},[57,52185,2381],{"id":2380},[2322,52187,52188,52192,52196,52200],{},[1450,52189,52190],{},[51,52191,54],{"href":53},[1450,52193,52194],{},[51,52195,2367],{"href":2366},[1450,52197,52198],{},[51,52199,5],{"href":44488},[1450,52201,52202],{},[51,52203,2372],{"href":2371},[2401,52205,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":52207},[52208,52209,52210,52211,52212,52213,52214,52215],{"id":237,"depth":282,"text":238},{"id":50801,"depth":282,"text":50802},{"id":51076,"depth":282,"text":51077},{"id":51512,"depth":282,"text":51513},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Stream AI chatbot replies token by token in Python: use the OpenAI SDK with stream=True, print live in a CLI, and serve a FastAPI SSE endpoint.",[52218,52221,52224,52227,52230],{"q":52219,"a":52220},"What does stream=True actually do in the OpenAI SDK?","It tells the API to send the reply in small pieces called tokens as the model generates them, instead of waiting for the whole answer. Your code receives a stream of chunks you can print or forward the moment each one arrives, so the user sees text appear live.",{"q":52222,"a":52223},"Does streaming make the chatbot answer faster?","No, the total time to finish is about the same. Streaming changes when the user sees the first words: instead of staring at a blank screen for several seconds, they see text within a fraction of a second. It only improves the feeling of speed, not the real speed.",{"q":52225,"a":52226},"How do I stream responses to a web browser from Python?","Expose a FastAPI endpoint that returns a StreamingResponse driven by a generator. Yield each token as a Server-Sent Events line so the browser receives them one at a time over a single open connection. The browser reads them with the EventSource API.",{"q":52228,"a":52229},"Can I still count tokens or get the full reply when streaming?","Yes. Collect each chunk into a string as it arrives and you end up with the complete reply once the stream finishes. For usage totals, pass stream_options with include_usage set to true and read the usage field from the final chunk.",{"q":52231,"a":52232},"Why is my streamed text arriving all at once instead of gradually?","Something between the model and the user is buffering. Common causes are a proxy or browser buffering small responses, or forgetting to flush output in a CLI. Disable buffering, flush after each token, and send an early padding byte for browsers.",{"name":52234,"steps":52235},"How to stream chatbot responses with Python",[52236,52238,52241,52244],{"name":50648,"text":52237},"Set up a virtual environment, install the openai and fastapi libraries, and save your API key in a .env file ignored by git.",{"name":52239,"text":52240},"Stream tokens in a command-line chatbot","Call the chat endpoint with stream=True and print each token as it arrives so the reply types out live in the terminal.",{"name":52242,"text":52243},"Collect the full reply while streaming","Append every token chunk to a string as it prints so you keep the complete answer for memory or logging.",{"name":52245,"text":52246},"Serve a streaming endpoint with FastAPI","Wrap the token stream in a generator and return it as a StreamingResponse using Server-Sent Events so a browser receives tokens live.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fstream-chatbot-responses-with-python",{"title":2362,"description":52216},"building-ai-powered-business-applications\u002Fcustom-ai-chatbot-development\u002Fstream-chatbot-responses-with-python\u002Findex","W_6etlokZXkx04i446wOkDjikk7ZKTY8QDv9R4eI4UE",{"id":52253,"title":26457,"body":52254,"description":54180,"extension":2419,"faq":54181,"howto":54197,"meta":54198,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":54199,"published":26525,"seo":54200,"seoTitle":54201,"stem":54202,"__hash__":54203},"content\u002Fbuilding-ai-powered-business-applications\u002Findex.md",{"type":7,"value":52255,"toc":54167},[52256,52259,52262,52265,52359,52361,52364,52387,52398,52400,52403,52415,52425,52462,52465,52495,52498,52556,52562,52566,52569,52584,52587,52743,52750,52756,52759,52762,52766,52772,52779,53007,53019,53028,53031,53041,53047,53051,53061,53066,53085,53091,53105,53108,53219,53232,53242,53257,53261,53264,53281,53529,53549,53565,53572,53576,53582,53921,53931,53955,53958,53962,53965,53977,53990,53994,54072,54074,54077,54122,54128,54130,54133,54160,54164],[10,52257,26457],{"id":52258},"building-ai-powered-business-applications",[14,52260,52261],{},"You have an idea that an AI model could do something genuinely useful for your business: answer support questions, clean up messy lead data, draft proposals, or power a tool your customers pay for. The hard part is rarely the prompt. It is everything around it: how do you wrap a model call in something a customer can actually use, connect it to your real data, keep your API key out of version control, and make sure a bad day at your AI provider does not take your product down or run up a four-figure bill overnight?",[14,52263,52264],{},"This is the guide for turning a clever prompt into a dependable product. It is written for founders, product people, and small teams who can run Python but are not career software engineers. Every term is defined the first time it appears, and every snippet is complete and runnable on Python 3.10 or newer. By the end you will understand how AI features are structured, how to feed them your own data safely, and you will have built a tiny but real AI service of your own.",[76,52266,52268,52356],{"className":52267},[79],[81,52269,90,52272,90,52275,90,52278,90,52280,90,52286,90,52290,90,52294,90,52297,90,52300,90,52302,90,52305,90,52309,90,52311,90,52314,90,52317,90,52319,90,52323,90,52326,90,52329,90,52333,90,52337,90,52340,90,52342,90,52347],{"viewBox":52270,"role":84,"ariaLabelledBy":52271,"preserveAspectRatio":88,"xmlns":89},"-40 -40 900 480",[7091,7092],[92,52273,52274],{"id":7091},"Architecture of an AI-powered business application",[96,52276,52277],{"id":7092},"A request flows from a user through an API layer, which loads configuration and business data, calls an LLM with retries and limits, and returns a structured response.",[100,52279],{"x":140,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,52281,52282,52283],{"x":7101,"y":114,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"User or",[175,52284,52285],{"x":7101,"dy":177},"customer",[100,52287],{"x":52288,"y":52289,"width":37100,"height":141,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},"300","160",[111,52291,52293],{"x":52292,"y":37129,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"410","Your Python API",[111,52295,52296],{"x":52292,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"(FastAPI)",[111,52298,52299],{"x":52292,"y":19942,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"validate · route",[100,52301],{"x":52288,"y":140,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,52303,52304],{"x":52292,"y":19868,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Secrets & config",[111,52306,52308],{"x":52292,"y":52307,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"71",".env \u002F env vars",[100,52310],{"x":52288,"y":7154,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,52312,52313],{"x":52292,"y":48116,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Business data",[111,52315,52316],{"x":52292,"y":19886,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"CRM · DB · files",[100,52318],{"x":117,"y":52289,"width":37100,"height":141,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},[111,52320,52322],{"x":52321,"y":37129,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"710","LLM provider",[111,52324,52325],{"x":52321,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"retries · timeout",[111,52327,52328],{"x":52321,"y":19942,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"token + cost caps",[181,52330],{"x1":37100,"y1":104,"x2":52331,"y2":104,"stroke":108,"strokeWidth":109,"markerEnd":52332},"298","url(#arrowB)",[181,52334],{"x1":48154,"y1":52335,"x2":52336,"y2":52335,"stroke":130,"strokeWidth":109,"markerEnd":52332},"195","598",[181,52338],{"x1":52292,"y1":52289,"x2":52292,"y2":52339,"stroke":143,"strokeWidth":144,"markerEnd":52332},"94",[181,52341],{"x1":52292,"y1":7154,"x2":52292,"y2":12875,"stroke":143,"strokeWidth":144,"markerEnd":52332},[181,52343],{"x1":52336,"y1":52344,"x2":52345,"y2":52344,"stroke":125,"strokeWidth":144,"strokeDashArray":52346,"markerEnd":52332},"217","522",[222,19848],[5548,52348,5550,52349,90],{},[5552,52350,5558,52353,5550],{"id":52351,"markerWidth":7162,"markerHeight":7162,"refX":7163,"refY":52352,"orient":5557},"arrowB","4.5",[216,52354],{"d":52355,"fill":125},"M0,0 L9,4.5 L0,9 z",[232,52357,52358],{},"Every AI business app is the same loop: your API validates a request, pulls config and data, calls the model with guardrails, and returns a structured answer.",[57,52360,24432],{"id":24431},[14,52362,52363],{},"This hub is the map for three connected tracks. Read this page top to bottom to learn the patterns that every AI feature shares, then dive into the track that matches what you are building:",[2322,52365,52366,52373,52380],{},[1450,52367,52368,52372],{},[35,52369,52370],{},[51,52371,36938],{"href":36937}," — connect AI to your customer records: sync contacts, enrich leads, and turn call notes into structured data your sales team can act on.",[1450,52374,52375,52379],{},[35,52376,52377],{},[51,52378,54],{"href":53}," — build assistants that hold a conversation, remember context, stream their replies, and answer from your own documents.",[1450,52381,52382,52386],{},[35,52383,52384],{},[51,52385,39690],{"href":39689}," — wrap an AI feature in a product you can charge for: user accounts, Stripe billing, and per-customer usage limits.",[14,52388,52389,52390,52392,52393,1374,52395,52397],{},"The four sections below cover the foundations all three depend on: how to structure an app around an AI feature, how to connect a model to your business data, how to manage secrets and configuration, and how to handle errors, retries, and costs so the thing stays up and stays cheap. After those, an end-to-end mini-project assembles them into a working service you can run today, followed by a table of the mistakes that bite newcomers most and a numbered path to whichever track fits your product. If any of this assumes Python knowledge you do not yet have, start with ",[51,52391,26450],{"href":26449}," and come back — the ",[51,52394,5423],{"href":5422},[51,52396,2487],{"href":2486}," sections in particular cover everything you need before the code here will run.",[57,52399,238],{"id":237},[14,52401,52402],{},"You need Python 3.10 or newer. Check what you have:",[253,52404,52406],{"className":255,"code":52405,"language":257,"meta":258,"style":258},"python3 --version\n",[18,52407,52408],{"__ignoreMap":258},[262,52409,52410,52412],{"class":181,"line":264},[262,52411,268],{"class":267},[262,52413,52414],{"class":271}," --version\n",[14,52416,52417,52418,52420,52421,52424],{},"If that prints a version below 3.10 or an error, follow ",[51,52419,5423],{"href":5422}," first. Then create an isolated workspace so this project's packages do not collide with anything else on your machine. A ",[35,52422,52423],{},"virtual environment"," is a private folder that holds one project's Python packages:",[253,52426,52428],{"className":255,"code":52427,"language":257,"meta":258,"style":258},"mkdir ai-business-app && cd ai-business-app\npython3 -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\n",[18,52429,52430,52444,52454],{"__ignoreMap":258},[262,52431,52432,52434,52437,52439,52441],{"class":181,"line":264},[262,52433,7191],{"class":267},[262,52435,52436],{"class":275}," ai-business-app",[262,52438,7197],{"class":429},[262,52440,7200],{"class":271},[262,52442,52443],{"class":275}," ai-business-app\n",[262,52445,52446,52448,52450,52452],{"class":181,"line":282},[262,52447,268],{"class":267},[262,52449,272],{"class":271},[262,52451,276],{"class":275},[262,52453,279],{"class":275},[262,52455,52456,52458,52460],{"class":181,"line":295},[262,52457,285],{"class":271},[262,52459,288],{"class":275},[262,52461,7222],{"class":291},[14,52463,52464],{},"With the environment active, install the packages used throughout this guide:",[253,52466,52468],{"className":255,"code":52467,"language":257,"meta":258,"style":258},"pip install \"fastapi>=0.110\" \"uvicorn[standard]>=0.29\" \"openai>=1.30\" \"httpx>=0.27\" \"pydantic>=2.6\" \"python-dotenv>=1.0\" \"tenacity>=8.2\"\n",[18,52469,52470],{"__ignoreMap":258},[262,52471,52472,52474,52476,52478,52481,52484,52486,52489,52492],{"class":181,"line":264},[262,52473,298],{"class":267},[262,52475,301],{"class":275},[262,52477,50746],{"class":275},[262,52479,52480],{"class":275}," \"uvicorn[standard]>=0.29\"",[262,52482,52483],{"class":275}," \"openai>=1.30\"",[262,52485,307],{"class":275},[262,52487,52488],{"class":275}," \"pydantic>=2.6\"",[262,52490,52491],{"class":275}," \"python-dotenv>=1.0\"",[262,52493,52494],{"class":275}," \"tenacity>=8.2\"\n",[14,52496,52497],{},"Here is what each one does, in plain terms:",[2322,52499,52500,52508,52516,52523,52530,52538,52548],{},[1450,52501,52502,52507],{},[35,52503,52504],{},[18,52505,52506],{},"fastapi"," — turns your Python functions into a web service customers can call.",[1450,52509,52510,52515],{},[35,52511,52512],{},[18,52513,52514],{},"uvicorn"," — the program that actually runs your FastAPI service.",[1450,52517,52518,52522],{},[35,52519,52520],{},[18,52521,20],{}," — the official client for talking to an LLM (a large language model, the AI that generates text).",[1450,52524,52525,52529],{},[35,52526,52527],{},[18,52528,5450],{}," — a modern HTTP client for calling any other API, such as your CRM.",[1450,52531,52532,52537],{},[35,52533,52534],{},[18,52535,52536],{},"pydantic"," — validates incoming and outgoing data so malformed requests fail clearly instead of crashing deep in your code.",[1450,52539,52540,52544,52545,52547],{},[35,52541,52542],{},[18,52543,2501],{}," — loads secrets from a ",[18,52546,319],{}," file during local development.",[1450,52549,52550,52555],{},[35,52551,52552],{},[18,52553,52554],{},"tenacity"," — retries failed calls automatically with sensible backoff.",[14,52557,52558,52559,52561],{},"You will also need an API key from an LLM provider. If you have not chosen one, ",[51,52560,2487],{"href":2486}," compares the options and shows how to get a key.",[57,52563,52565],{"id":52564},"core-concept-1-how-to-structure-an-app-around-an-ai-feature","Core concept 1: How to structure an app around an AI feature",[14,52567,52568],{},"The single biggest mistake new builders make is scattering model calls throughout their code. A prompt lives in one file, the API key in another, a retry loop copy-pasted into a third. When the AI provider changes its pricing or you want to swap models, you are hunting through the whole project. The feature works in a demo, then becomes a maze the first time you need to change anything — and with AI features, you will need to change things constantly, because models, prices, and your own understanding of the problem all keep moving.",[14,52570,52571,52572,52575,52576,52579,52580,52583],{},"The fix is a simple three-layer shape. The ",[35,52573,52574],{},"API layer"," receives requests and validates them. The ",[35,52577,52578],{},"service layer"," holds your business logic and the one function that calls the model. The ",[35,52581,52582],{},"config layer"," holds settings and secrets. Each layer only knows about the one below it, so you can change your prompt without touching your web routes, or swap providers without rewriting your endpoints.",[14,52585,52586],{},"Here is the service layer in isolation: one function that owns the model call. Everything else in your app talks to this, never to the AI provider directly.",[253,52588,52590],{"className":414,"code":52589,"language":416,"meta":258,"style":258},"# service.py\nfrom openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment automatically\n\n\ndef summarize(text: str) -> str:\n    \"\"\"Turn a block of text into a two-sentence summary.\"\"\"\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        max_tokens=120,\n        messages=[\n            {\"role\": \"system\", \"content\": \"Summarize the user's text in two sentences.\"},\n            {\"role\": \"user\", \"content\": text},\n        ],\n    )\n    return response.choices[0].message.content\n",[18,52591,52592,52597,52607,52611,52622,52626,52630,52646,52651,52659,52669,52679,52687,52708,52725,52729,52733],{"__ignoreMap":258},[262,52593,52594],{"class":181,"line":264},[262,52595,52596],{"class":291},"# service.py\n",[262,52598,52599,52601,52603,52605],{"class":181,"line":282},[262,52600,705],{"class":377},[262,52602,720],{"class":429},[262,52604,684],{"class":377},[262,52606,725],{"class":429},[262,52608,52609],{"class":181,"line":295},[262,52610,583],{"emptyLinePlaceholder":582},[262,52612,52613,52615,52617,52619],{"class":181,"line":345},[262,52614,739],{"class":429},[262,52616,476],{"class":377},[262,52618,9578],{"class":429},[262,52620,52621],{"class":291},"# reads OPENAI_API_KEY from the environment automatically\n",[262,52623,52624],{"class":181,"line":492},[262,52625,583],{"emptyLinePlaceholder":582},[262,52627,52628],{"class":181,"line":503},[262,52629,583],{"emptyLinePlaceholder":582},[262,52631,52632,52634,52636,52638,52640,52642,52644],{"class":181,"line":521},[262,52633,423],{"class":377},[262,52635,43530],{"class":267},[262,52637,430],{"class":429},[262,52639,433],{"class":271},[262,52641,1939],{"class":429},[262,52643,433],{"class":271},[262,52645,1160],{"class":429},[262,52647,52648],{"class":181,"line":537},[262,52649,52650],{"class":275},"    \"\"\"Turn a block of text into a two-sentence summary.\"\"\"\n",[262,52652,52653,52655,52657],{"class":181,"line":549},[262,52654,1184],{"class":429},[262,52656,476],{"class":377},[262,52658,1189],{"class":429},[262,52660,52661,52663,52665,52667],{"class":181,"line":570},[262,52662,1194],{"class":611},[262,52664,476],{"class":377},[262,52666,1207],{"class":275},[262,52668,1315],{"class":429},[262,52670,52671,52673,52675,52677],{"class":181,"line":579},[262,52672,4679],{"class":611},[262,52674,476],{"class":377},[262,52676,7101],{"class":271},[262,52678,1315],{"class":429},[262,52680,52681,52683,52685],{"class":181,"line":586},[262,52682,1215],{"class":611},[262,52684,476],{"class":377},[262,52686,1220],{"class":429},[262,52688,52689,52691,52693,52695,52697,52699,52701,52703,52706],{"class":181,"line":591},[262,52690,1225],{"class":429},[262,52692,1228],{"class":275},[262,52694,1231],{"class":429},[262,52696,1234],{"class":275},[262,52698,608],{"class":429},[262,52700,1239],{"class":275},[262,52702,1231],{"class":429},[262,52704,52705],{"class":275},"\"Summarize the user's text in two sentences.\"",[262,52707,3143],{"class":429},[262,52709,52710,52712,52714,52716,52718,52720,52722],{"class":181,"line":623},[262,52711,1225],{"class":429},[262,52713,1228],{"class":275},[262,52715,1231],{"class":429},[262,52717,1291],{"class":275},[262,52719,608],{"class":429},[262,52721,1239],{"class":275},[262,52723,52724],{"class":429},": text},\n",[262,52726,52727],{"class":181,"line":634},[262,52728,1303],{"class":429},[262,52730,52731],{"class":181,"line":845},[262,52732,1011],{"class":429},[262,52734,52735,52737,52739,52741],{"class":181,"line":850},[262,52736,573],{"class":377},[262,52738,1326],{"class":429},[262,52740,102],{"class":271},[262,52742,1331],{"class":429},[14,52744,52745,52746,52749],{},"Because the model call lives behind ",[18,52747,52748],{},"summarize()",", your web routes, your tests, and your scheduled jobs can all reuse it. When you later need streaming, retries, or a different model, you change this one file. This is the discipline that keeps a growing AI product manageable.",[14,52751,52752,52753,52755],{},"It helps to think about why the layers stay separate. The API layer's only job is to deal with the outside world: parse the incoming request, reject anything malformed, and hand a clean Python object to the service layer. It should contain no prompts and no API keys. The service layer is where your product's intelligence lives — the prompt wording, the choice of model, the rules for what to do with the response. The config layer is boring on purpose: it reads settings once and hands them out, so nothing else has to know whether a value came from a ",[18,52754,319],{}," file or a production secret manager.",[14,52757,52758],{},"This separation pays off the first time something changes, and in an AI product something always changes. Providers release cheaper models, raise prices, or deprecate the one you were using. A regulator or a big customer asks you to switch to a provider in a particular region. You decide a feature needs a more capable model while the rest stay cheap. If every model call is funnelled through a handful of service functions, each of those is a small, contained edit. If your model calls are sprinkled across twenty route handlers, each one is a hunt-and-replace exercise that risks breaking something you forgot about.",[14,52760,52761],{},"A practical rule of thumb: a single AI feature should have exactly one service function that calls the model, and that function should take ordinary Python values in and return ordinary Python values out. No FastAPI objects, no HTTP status codes, no request headers. That way you can call it from a web route today, a background worker tomorrow, and a test suite the whole time, without changing a line of it.",[57,52763,52765],{"id":52764},"core-concept-2-connecting-ai-to-your-business-data-and-apis","Core concept 2: Connecting AI to your business data and APIs",[14,52767,52768,52769,52771],{},"A model on its own only knows what was in its training data. It does not know your customers, your prices, or last week's support tickets, and it has no way to look them up unless you provide them. This is the most common source of disappointment for first-time builders: the model gives plausible but generic answers because it was never shown the specifics. The value of a business AI feature comes almost entirely from feeding it ",[27,52770,29],{}," data at the moment of the request. There are two patterns for doing that, and choosing the right one depends mainly on how much data is relevant.",[14,52773,52774,52775,52778],{},"The first is ",[35,52776,52777],{},"passing data in the prompt",": you fetch the relevant records yourself and include them in the message you send. This is the right pattern when you already know which data is relevant — for example, summarizing one specific sales call or enriching one named lead.",[253,52780,52782],{"className":414,"code":52781,"language":416,"meta":258,"style":258},"# enrich.py\nimport httpx\nfrom openai import OpenAI\n\nclient = OpenAI()\n\n\ndef fetch_company(domain: str) -> dict:\n    \"\"\"Pull public company info from an external API.\"\"\"\n    resp = httpx.get(f\"https:\u002F\u002Fapi.example-enrich.com\u002Fcompany\u002F{domain}\", timeout=10.0)\n    resp.raise_for_status()\n    return resp.json()\n\n\ndef describe_lead(domain: str) -> str:\n    company = fetch_company(domain)\n    prompt = f\"Write a one-line sales note about this company:\\n{company}\"\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        max_tokens=80,\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return response.choices[0].message.content\n",[18,52783,52784,52788,52794,52804,52808,52816,52820,52824,52842,52847,52880,52884,52890,52894,52898,52915,52925,52945,52953,52963,52973,52993,52997],{"__ignoreMap":258},[262,52785,52786],{"class":181,"line":264},[262,52787,35554],{"class":291},[262,52789,52790,52792],{"class":181,"line":282},[262,52791,684],{"class":377},[262,52793,6526],{"class":429},[262,52795,52796,52798,52800,52802],{"class":181,"line":295},[262,52797,705],{"class":377},[262,52799,720],{"class":429},[262,52801,684],{"class":377},[262,52803,725],{"class":429},[262,52805,52806],{"class":181,"line":345},[262,52807,583],{"emptyLinePlaceholder":582},[262,52809,52810,52812,52814],{"class":181,"line":492},[262,52811,739],{"class":429},[262,52813,476],{"class":377},[262,52815,744],{"class":429},[262,52817,52818],{"class":181,"line":503},[262,52819,583],{"emptyLinePlaceholder":582},[262,52821,52822],{"class":181,"line":521},[262,52823,583],{"emptyLinePlaceholder":582},[262,52825,52826,52828,52831,52834,52836,52838,52840],{"class":181,"line":537},[262,52827,423],{"class":377},[262,52829,52830],{"class":267}," fetch_company",[262,52832,52833],{"class":429},"(domain: ",[262,52835,433],{"class":271},[262,52837,1939],{"class":429},[262,52839,5869],{"class":271},[262,52841,1160],{"class":429},[262,52843,52844],{"class":181,"line":549},[262,52845,52846],{"class":275},"    \"\"\"Pull public company info from an external API.\"\"\"\n",[262,52848,52849,52851,52853,52856,52858,52861,52863,52866,52868,52870,52872,52874,52876,52878],{"class":181,"line":570},[262,52850,797],{"class":429},[262,52852,476],{"class":377},[262,52854,52855],{"class":429}," httpx.get(",[262,52857,642],{"class":377},[262,52859,52860],{"class":275},"\"https:\u002F\u002Fapi.example-enrich.com\u002Fcompany\u002F",[262,52862,3039],{"class":271},[262,52864,52865],{"class":429},"domain",[262,52867,654],{"class":271},[262,52869,1176],{"class":275},[262,52871,608],{"class":429},[262,52873,1591],{"class":611},[262,52875,476],{"class":377},[262,52877,33275],{"class":271},[262,52879,660],{"class":429},[262,52881,52882],{"class":181,"line":579},[262,52883,23572],{"class":429},[262,52885,52886,52888],{"class":181,"line":586},[262,52887,573],{"class":377},[262,52889,23901],{"class":429},[262,52891,52892],{"class":181,"line":591},[262,52893,583],{"emptyLinePlaceholder":582},[262,52895,52896],{"class":181,"line":623},[262,52897,583],{"emptyLinePlaceholder":582},[262,52899,52900,52902,52905,52907,52909,52911,52913],{"class":181,"line":634},[262,52901,423],{"class":377},[262,52903,52904],{"class":267}," describe_lead",[262,52906,52833],{"class":429},[262,52908,433],{"class":271},[262,52910,1939],{"class":429},[262,52912,433],{"class":271},[262,52914,1160],{"class":429},[262,52916,52917,52920,52922],{"class":181,"line":845},[262,52918,52919],{"class":429},"    company ",[262,52921,476],{"class":377},[262,52923,52924],{"class":429}," fetch_company(domain)\n",[262,52926,52927,52929,52931,52933,52936,52938,52941,52943],{"class":181,"line":850},[262,52928,18006],{"class":429},[262,52930,476],{"class":377},[262,52932,10178],{"class":377},[262,52934,52935],{"class":275},"\"Write a one-line sales note about this company:",[262,52937,1268],{"class":271},[262,52939,52940],{"class":429},"company",[262,52942,654],{"class":271},[262,52944,1257],{"class":275},[262,52946,52947,52949,52951],{"class":181,"line":864},[262,52948,1184],{"class":429},[262,52950,476],{"class":377},[262,52952,1189],{"class":429},[262,52954,52955,52957,52959,52961],{"class":181,"line":1683},[262,52956,1194],{"class":611},[262,52958,476],{"class":377},[262,52960,1207],{"class":275},[262,52962,1315],{"class":429},[262,52964,52965,52967,52969,52971],{"class":181,"line":1688},[262,52966,4679],{"class":611},[262,52968,476],{"class":377},[262,52970,1100],{"class":271},[262,52972,1315],{"class":429},[262,52974,52975,52977,52979,52981,52983,52985,52987,52989,52991],{"class":181,"line":1693},[262,52976,1215],{"class":611},[262,52978,476],{"class":377},[262,52980,8856],{"class":429},[262,52982,1228],{"class":275},[262,52984,1231],{"class":429},[262,52986,1291],{"class":275},[262,52988,608],{"class":429},[262,52990,1239],{"class":275},[262,52992,18141],{"class":429},[262,52994,52995],{"class":181,"line":1728},[262,52996,1011],{"class":429},[262,52998,52999,53001,53003,53005],{"class":181,"line":1737},[262,53000,573],{"class":377},[262,53002,1326],{"class":429},[262,53004,102],{"class":271},[262,53006,1331],{"class":429},[14,53008,37788,53009,53012,53013,53015,53016,53018],{},[18,53010,53011],{},"timeout=10.0"," on the HTTP call and the ",[18,53014,6778],{}," that turns a failed lookup into a clear error rather than feeding garbage to the model. Connecting AI to real systems is mostly the unglamorous work of fetching, validating, and shaping data before it ever reaches a prompt. The ",[51,53017,36938],{"href":36937}," track applies this pattern to live customer systems like HubSpot.",[14,53020,53021,53022,53024,53025,53027],{},"The second pattern is ",[35,53023,48121],{},": when you have more documents than fit in one prompt, you search for the most relevant pieces first and pass only those. That technique is covered in depth under ",[51,53026,54],{"href":53},", where a chatbot answers from your own documentation.",[14,53029,53030],{},"Three habits separate a reliable data connection from a fragile one. First, always set a timeout on outbound calls, as in the example above; without one, a single unresponsive service can tie up your app indefinitely. Second, validate what comes back before you trust it. An external API can return an error page, an empty list, or a field you did not expect, and feeding that straight into a prompt produces confident nonsense. Third, shape the data into the smallest useful form before it reaches the model. Sending a customer's entire raw record wastes tokens and buries the relevant facts; sending a tidy line or two of the fields that matter is cheaper and produces sharper output.",[14,53032,53033,53034,53036,53037,53040],{},"There is also a question of ",[27,53035,25246],{}," you fetch. Some features run on demand, in direct response to a user action — those fetch data inside the request, like ",[18,53038,53039],{},"describe_lead"," above. Others run on a schedule, such as a nightly job that enriches every new lead from the day. Scheduled jobs reuse the very same service functions, which is exactly why keeping the model call free of web-specific objects matters: a function that takes a domain string and returns a sales note works identically whether a customer triggered it or a cron job did.",[14,53042,53043,53044,53046],{},"If the data you need lives in a CRM, the official SDK or REST API of that system is almost always a better path than scraping a dashboard or exporting CSVs by hand. The ",[51,53045,36938],{"href":36937}," track walks through syncing HubSpot contacts, enriching leads, and turning call recordings into structured CRM fields using exactly the fetch-validate-shape loop described here.",[57,53048,53050],{"id":53049},"core-concept-3-secret-management-and-configuration","Core concept 3: Secret management and configuration",[14,53052,53053,53054,53057,53058,53060],{},"Your API key is a password that can spend real money. It must never appear in your code or your Git history. The standard approach is to keep secrets in ",[35,53055,53056],{},"environment variables"," — values your operating system holds outside your code — and load them from a ",[18,53059,319],{}," file while developing.",[14,53062,42969,53063,53065],{},[18,53064,319],{}," file in your project root:",[253,53067,53069],{"className":323,"code":53068,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-real-key-here\nENRICH_API_KEY=your-enrichment-key\nMAX_OUTPUT_TOKENS=200\n",[18,53070,53071,53075,53080],{"__ignoreMap":258},[262,53072,53073],{"class":181,"line":264},[262,53074,337],{},[262,53076,53077],{"class":181,"line":282},[262,53078,53079],{},"ENRICH_API_KEY=your-enrichment-key\n",[262,53081,53082],{"class":181,"line":295},[262,53083,53084],{},"MAX_OUTPUT_TOKENS=200\n",[14,53086,53087,53088,53090],{},"Immediately add it to ",[18,53089,359],{}," so the file is never committed:",[253,53092,53093],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,53094,53095],{"__ignoreMap":258},[262,53096,53097,53099,53101,53103],{"class":181,"line":264},[262,53098,371],{"class":271},[262,53100,374],{"class":275},[262,53102,378],{"class":377},[262,53104,381],{"class":275},[14,53106,53107],{},"Now centralize how your app reads these values. A single settings object means there is exactly one place that knows your configuration, and your code fails loudly at startup if something required is missing — far better than a confusing crash mid-request:",[253,53109,53111],{"className":414,"code":53110,"language":416,"meta":258,"style":258},"# config.py\nimport os\nfrom dotenv import load_dotenv\n\nload_dotenv()  # reads .env into the environment during local development\n\n\nclass Settings:\n    openai_api_key: str = os.environ[\"OPENAI_API_KEY\"]\n    max_output_tokens: int = int(os.getenv(\"MAX_OUTPUT_TOKENS\", \"200\"))\n\n\nsettings = Settings()\n",[18,53112,53113,53118,53124,53134,53138,53145,53149,53153,53162,53177,53201,53205,53209],{"__ignoreMap":258},[262,53114,53115],{"class":181,"line":264},[262,53116,53117],{"class":291},"# config.py\n",[262,53119,53120,53122],{"class":181,"line":282},[262,53121,684],{"class":377},[262,53123,687],{"class":429},[262,53125,53126,53128,53130,53132],{"class":181,"line":295},[262,53127,705],{"class":377},[262,53129,708],{"class":429},[262,53131,684],{"class":377},[262,53133,713],{"class":429},[262,53135,53136],{"class":181,"line":345},[262,53137,583],{"emptyLinePlaceholder":582},[262,53139,53140,53142],{"class":181,"line":492},[262,53141,4222],{"class":429},[262,53143,53144],{"class":291},"# reads .env into the environment during local development\n",[262,53146,53147],{"class":181,"line":503},[262,53148,583],{"emptyLinePlaceholder":582},[262,53150,53151],{"class":181,"line":521},[262,53152,583],{"emptyLinePlaceholder":582},[262,53154,53155,53157,53160],{"class":181,"line":537},[262,53156,7374],{"class":377},[262,53158,53159],{"class":267}," Settings",[262,53161,1160],{"class":429},[262,53163,53164,53167,53169,53171,53173,53175],{"class":181,"line":549},[262,53165,53166],{"class":429},"    openai_api_key: ",[262,53168,433],{"class":271},[262,53170,442],{"class":377},[262,53172,36185],{"class":429},[262,53174,2681],{"class":275},[262,53176,957],{"class":429},[262,53178,53179,53182,53184,53186,53188,53191,53194,53196,53199],{"class":181,"line":570},[262,53180,53181],{"class":429},"    max_output_tokens: ",[262,53183,439],{"class":271},[262,53185,442],{"class":377},[262,53187,23813],{"class":271},[262,53189,53190],{"class":429},"(os.getenv(",[262,53192,53193],{"class":275},"\"MAX_OUTPUT_TOKENS\"",[262,53195,608],{"class":429},[262,53197,53198],{"class":275},"\"200\"",[262,53200,2684],{"class":429},[262,53202,53203],{"class":181,"line":579},[262,53204,583],{"emptyLinePlaceholder":582},[262,53206,53207],{"class":181,"line":586},[262,53208,583],{"emptyLinePlaceholder":582},[262,53210,53211,53214,53216],{"class":181,"line":591},[262,53212,53213],{"class":429},"settings ",[262,53215,476],{"class":377},[262,53217,53218],{"class":429}," Settings()\n",[14,53220,37506,53221,53224,53225,53228,53229,53231],{},[18,53222,53223],{},"os.environ[\"OPENAI_API_KEY\"]"," (with square brackets) rather than ",[18,53226,53227],{},".get()"," means the app refuses to start without a key, which is exactly what you want. A missing key should be a loud failure the moment you launch, not a mysterious error the first time a customer hits the feature. In production you do not ship the ",[18,53230,319],{}," file at all — your hosting provider lets you set the same variables through its dashboard or secret manager, and the identical code reads them.",[14,53233,53234,53235,1374,53238,53241],{},"The distinction between ",[35,53236,53237],{},"secrets",[35,53239,53240],{},"configuration"," is worth drawing clearly. A secret is anything that grants access or spends money: API keys, database passwords, signing tokens. Leaking one is a real incident. Configuration is everything else that you might tune without code changes: which model to use, how many tokens to allow, how long a timeout should be. Both belong in environment variables so you can change them per environment, but only secrets need to be guarded as carefully as a password. A useful test: if a value appearing in a screenshot or a log would make you nervous, it is a secret and must never be logged or committed.",[14,53243,53244,53245,53247,53248,53250,53251,53253,53254,53256],{},"One mistake catches almost everyone at least once: accidentally committing a ",[18,53246,319],{}," file before adding it to ",[18,53249,359],{},". If that happens, treat the key as compromised even if you delete the file afterwards, because it still lives in your repository's history. Rotate the key — generate a fresh one in your provider's dashboard and revoke the old one — rather than assuming the deletion was enough. Getting the ",[18,53252,359],{}," entry in place before you ever write a real key into ",[18,53255,319],{}," avoids the whole problem.",[57,53258,53260],{"id":53259},"core-concept-4-error-handling-retries-and-cost-control","Core concept 4: Error handling, retries, and cost control",[14,53262,53263],{},"AI providers fail in predictable ways: a request times out, you hit a rate limit, or the service has a brief outage. A production feature absorbs these without crashing and without retrying forever. Three controls cover almost everything.",[14,53265,53266,53269,53270,53272,53273,53276,53277,53280],{},[35,53267,53268],{},"Retries with backoff"," handle transient failures. The ",[18,53271,52554],{}," library wraps a function so it retries a few times, waiting a little longer each attempt. ",[35,53274,53275],{},"Timeouts"," stop a single slow call from hanging your whole service. ",[35,53278,53279],{},"Token caps"," put a ceiling on how much each call can cost, since you pay per token (a token is roughly three-quarters of a word).",[253,53282,53284],{"className":414,"code":53283,"language":416,"meta":258,"style":258},"# robust_service.py\nfrom openai import OpenAI, APITimeoutError, RateLimitError\nfrom tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type\nfrom config import settings\n\nclient = OpenAI(timeout=20.0)  # never wait more than 20 seconds for a reply\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(min=1, max=10),\n    retry=retry_if_exception_type((APITimeoutError, RateLimitError)),\n)\ndef safe_summarize(text: str) -> str:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        max_tokens=settings.max_output_tokens,  # hard cost ceiling per call\n        messages=[\n            {\"role\": \"system\", \"content\": \"Summarize the user's text in two sentences.\"},\n            {\"role\": \"user\", \"content\": text},\n        ],\n    )\n    return response.choices[0].message.content\n",[18,53285,53286,53291,53302,53314,53326,53330,53349,53353,53357,53364,53378,53406,53416,53420,53437,53445,53455,53467,53475,53495,53511,53515,53519],{"__ignoreMap":258},[262,53287,53288],{"class":181,"line":264},[262,53289,53290],{"class":291},"# robust_service.py\n",[262,53292,53293,53295,53297,53299],{"class":181,"line":282},[262,53294,705],{"class":377},[262,53296,720],{"class":429},[262,53298,684],{"class":377},[262,53300,53301],{"class":429}," OpenAI, APITimeoutError, RateLimitError\n",[262,53303,53304,53306,53309,53311],{"class":181,"line":295},[262,53305,705],{"class":377},[262,53307,53308],{"class":429}," tenacity ",[262,53310,684],{"class":377},[262,53312,53313],{"class":429}," retry, stop_after_attempt, wait_exponential, retry_if_exception_type\n",[262,53315,53316,53318,53321,53323],{"class":181,"line":345},[262,53317,705],{"class":377},[262,53319,53320],{"class":429}," config ",[262,53322,684],{"class":377},[262,53324,53325],{"class":429}," settings\n",[262,53327,53328],{"class":181,"line":492},[262,53329,583],{"emptyLinePlaceholder":582},[262,53331,53332,53334,53336,53338,53340,53342,53344,53346],{"class":181,"line":503},[262,53333,739],{"class":429},[262,53335,476],{"class":377},[262,53337,1588],{"class":429},[262,53339,1591],{"class":611},[262,53341,476],{"class":377},[262,53343,1596],{"class":271},[262,53345,32223],{"class":429},[262,53347,53348],{"class":291},"# never wait more than 20 seconds for a reply\n",[262,53350,53351],{"class":181,"line":521},[262,53352,583],{"emptyLinePlaceholder":582},[262,53354,53355],{"class":181,"line":537},[262,53356,583],{"emptyLinePlaceholder":582},[262,53358,53359,53362],{"class":181,"line":549},[262,53360,53361],{"class":267},"@retry",[262,53363,2835],{"class":429},[262,53365,53366,53369,53371,53374,53376],{"class":181,"line":570},[262,53367,53368],{"class":611},"    stop",[262,53370,476],{"class":377},[262,53372,53373],{"class":429},"stop_after_attempt(",[262,53375,5556],{"class":271},[262,53377,1210],{"class":429},[262,53379,53380,53383,53385,53388,53391,53393,53395,53397,53400,53402,53404],{"class":181,"line":579},[262,53381,53382],{"class":611},"    wait",[262,53384,476],{"class":377},[262,53386,53387],{"class":429},"wait_exponential(",[262,53389,53390],{"class":611},"min",[262,53392,476],{"class":377},[262,53394,997],{"class":271},[262,53396,608],{"class":429},[262,53398,53399],{"class":611},"max",[262,53401,476],{"class":377},[262,53403,3868],{"class":271},[262,53405,1210],{"class":429},[262,53407,53408,53411,53413],{"class":181,"line":586},[262,53409,53410],{"class":611},"    retry",[262,53412,476],{"class":377},[262,53414,53415],{"class":429},"retry_if_exception_type((APITimeoutError, RateLimitError)),\n",[262,53417,53418],{"class":181,"line":591},[262,53419,660],{"class":429},[262,53421,53422,53424,53427,53429,53431,53433,53435],{"class":181,"line":623},[262,53423,423],{"class":377},[262,53425,53426],{"class":267}," safe_summarize",[262,53428,430],{"class":429},[262,53430,433],{"class":271},[262,53432,1939],{"class":429},[262,53434,433],{"class":271},[262,53436,1160],{"class":429},[262,53438,53439,53441,53443],{"class":181,"line":634},[262,53440,1184],{"class":429},[262,53442,476],{"class":377},[262,53444,1189],{"class":429},[262,53446,53447,53449,53451,53453],{"class":181,"line":845},[262,53448,1194],{"class":611},[262,53450,476],{"class":377},[262,53452,1207],{"class":275},[262,53454,1315],{"class":429},[262,53456,53457,53459,53461,53464],{"class":181,"line":850},[262,53458,4679],{"class":611},[262,53460,476],{"class":377},[262,53462,53463],{"class":429},"settings.max_output_tokens,  ",[262,53465,53466],{"class":291},"# hard cost ceiling per call\n",[262,53468,53469,53471,53473],{"class":181,"line":864},[262,53470,1215],{"class":611},[262,53472,476],{"class":377},[262,53474,1220],{"class":429},[262,53476,53477,53479,53481,53483,53485,53487,53489,53491,53493],{"class":181,"line":1683},[262,53478,1225],{"class":429},[262,53480,1228],{"class":275},[262,53482,1231],{"class":429},[262,53484,1234],{"class":275},[262,53486,608],{"class":429},[262,53488,1239],{"class":275},[262,53490,1231],{"class":429},[262,53492,52705],{"class":275},[262,53494,3143],{"class":429},[262,53496,53497,53499,53501,53503,53505,53507,53509],{"class":181,"line":1688},[262,53498,1225],{"class":429},[262,53500,1228],{"class":275},[262,53502,1231],{"class":429},[262,53504,1291],{"class":275},[262,53506,608],{"class":429},[262,53508,1239],{"class":275},[262,53510,52724],{"class":429},[262,53512,53513],{"class":181,"line":1693},[262,53514,1303],{"class":429},[262,53516,53517],{"class":181,"line":1728},[262,53518,1011],{"class":429},[262,53520,53521,53523,53525,53527],{"class":181,"line":1737},[262,53522,573],{"class":377},[262,53524,1326],{"class":429},[262,53526,102],{"class":271},[262,53528,1331],{"class":429},[14,53530,53531,53532,53534,53535,53538,53539,53542,53543,53546,53547,1363],{},"This retries ",[27,53533,1131],{}," on timeouts and rate limits — not on a bad request, which would fail the same way every time. The distinction matters: retrying a ",[27,53536,53537],{},"transient"," error (a brief blip that will likely succeed on the next attempt) is helpful, but retrying a ",[27,53540,53541],{},"permanent"," error (a malformed request, an invalid key) just wastes time and money while the user waits. The ",[18,53544,53545],{},"wait_exponential"," setting spaces the attempts further apart each time — one second, then two, then four — which is the polite way to back off when a provider is overloaded, rather than hammering it the instant it rejects you. For the full anatomy of these errors, see ",[51,53548,3379],{"href":3378},[14,53550,53551,53552,53555,53556,53558,53559,53562,53563,1363],{},"Cost control has two layers, and they are easy to confuse. The first is ",[35,53553,53554],{},"per-call",": capping ",[18,53557,3846],{}," so no single request can balloon into a long, expensive response. That is what the snippet above does, and you should set it on every call. The second is ",[35,53560,53561],{},"per-customer",": stopping one user — or one runaway script of theirs — from making thousands of requests and draining your monthly budget before you notice. That control lives at the product level, not the model-call level, and it is covered in ",[51,53564,49599],{"href":49598},[14,53566,53567,53568,53571],{},"A final piece of the resilience puzzle is what your users see when something does fail despite the retries. The goal is a graceful, honest message — \"We could not generate that right now, please try again\" — returned quickly, rather than a spinning loader or a raw stack trace. In the mini-project below you will see this handled with a single ",[18,53569,53570],{},"try\u002Fexcept"," that turns any provider failure into a clean error response. That small habit is the difference between an outage your customers shrug off and one they remember.",[57,53573,53575],{"id":53574},"mini-project-a-working-ai-endpoint-in-25-lines","Mini-project: a working AI endpoint in 25 lines",[14,53577,53578,53579,26616],{},"Now assemble the concepts into something real: a FastAPI service with one endpoint that takes text and returns an AI summary as JSON. This is the seed every AI product grows from. Save it as ",[18,53580,53581],{},"app.py",[253,53583,53585],{"className":414,"code":53584,"language":416,"meta":258,"style":258},"# app.py\nfrom fastapi import FastAPI, HTTPException\nfrom pydantic import BaseModel\nfrom openai import OpenAI, OpenAIError\nfrom dotenv import load_dotenv\n\nload_dotenv()\napp = FastAPI(title=\"Summary Service\")\nclient = OpenAI(timeout=20.0)\n\n\nclass SummaryRequest(BaseModel):\n    text: str\n\n\n@app.post(\"\u002Fsummarize\")\ndef summarize(req: SummaryRequest):\n    if not req.text.strip():\n        raise HTTPException(status_code=422, detail=\"Text cannot be empty.\")\n    try:\n        result = client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            max_tokens=150,\n            messages=[\n                {\"role\": \"system\", \"content\": \"Summarize the text in two sentences.\"},\n                {\"role\": \"user\", \"content\": req.text},\n            ],\n        )\n    except OpenAIError as exc:\n        raise HTTPException(status_code=502, detail=f\"AI provider error: {exc}\")\n    return {\"summary\": result.choices[0].message.content}\n",[18,53586,53587,53592,53603,53615,53626,53636,53640,53644,53662,53678,53682,53686,53700,53706,53710,53714,53726,53735,53744,53771,53777,53785,53795,53805,53813,53835,53852,53857,53861,53871,53905],{"__ignoreMap":258},[262,53588,53589],{"class":181,"line":264},[262,53590,53591],{"class":291},"# app.py\n",[262,53593,53594,53596,53598,53600],{"class":181,"line":282},[262,53595,705],{"class":377},[262,53597,51571],{"class":429},[262,53599,684],{"class":377},[262,53601,53602],{"class":429}," FastAPI, HTTPException\n",[262,53604,53605,53607,53610,53612],{"class":181,"line":295},[262,53606,705],{"class":377},[262,53608,53609],{"class":429}," pydantic ",[262,53611,684],{"class":377},[262,53613,53614],{"class":429}," BaseModel\n",[262,53616,53617,53619,53621,53623],{"class":181,"line":345},[262,53618,705],{"class":377},[262,53620,720],{"class":429},[262,53622,684],{"class":377},[262,53624,53625],{"class":429}," OpenAI, OpenAIError\n",[262,53627,53628,53630,53632,53634],{"class":181,"line":492},[262,53629,705],{"class":377},[262,53631,708],{"class":429},[262,53633,684],{"class":377},[262,53635,713],{"class":429},[262,53637,53638],{"class":181,"line":503},[262,53639,583],{"emptyLinePlaceholder":582},[262,53641,53642],{"class":181,"line":521},[262,53643,734],{"class":429},[262,53645,53646,53648,53650,53653,53655,53657,53660],{"class":181,"line":537},[262,53647,51635],{"class":429},[262,53649,476],{"class":377},[262,53651,53652],{"class":429}," FastAPI(",[262,53654,92],{"class":611},[262,53656,476],{"class":377},[262,53658,53659],{"class":275},"\"Summary Service\"",[262,53661,660],{"class":429},[262,53663,53664,53666,53668,53670,53672,53674,53676],{"class":181,"line":549},[262,53665,739],{"class":429},[262,53667,476],{"class":377},[262,53669,1588],{"class":429},[262,53671,1591],{"class":611},[262,53673,476],{"class":377},[262,53675,1596],{"class":271},[262,53677,660],{"class":429},[262,53679,53680],{"class":181,"line":570},[262,53681,583],{"emptyLinePlaceholder":582},[262,53683,53684],{"class":181,"line":579},[262,53685,583],{"emptyLinePlaceholder":582},[262,53687,53688,53690,53693,53695,53698],{"class":181,"line":586},[262,53689,7374],{"class":377},[262,53691,53692],{"class":267}," SummaryRequest",[262,53694,602],{"class":429},[262,53696,53697],{"class":267},"BaseModel",[262,53699,8192],{"class":429},[262,53701,53702,53704],{"class":181,"line":591},[262,53703,15910],{"class":429},[262,53705,8677],{"class":271},[262,53707,53708],{"class":181,"line":623},[262,53709,583],{"emptyLinePlaceholder":582},[262,53711,53712],{"class":181,"line":634},[262,53713,583],{"emptyLinePlaceholder":582},[262,53715,53716,53719,53721,53724],{"class":181,"line":845},[262,53717,53718],{"class":267},"@app.post",[262,53720,602],{"class":429},[262,53722,53723],{"class":275},"\"\u002Fsummarize\"",[262,53725,660],{"class":429},[262,53727,53728,53730,53732],{"class":181,"line":850},[262,53729,423],{"class":377},[262,53731,43530],{"class":267},[262,53733,53734],{"class":429},"(req: SummaryRequest):\n",[262,53736,53737,53739,53741],{"class":181,"line":864},[262,53738,3454],{"class":377},[262,53740,2818],{"class":377},[262,53742,53743],{"class":429}," req.text.strip():\n",[262,53745,53746,53748,53751,53754,53756,53759,53761,53764,53766,53769],{"class":181,"line":1683},[262,53747,4928],{"class":377},[262,53749,53750],{"class":429}," HTTPException(",[262,53752,53753],{"class":611},"status_code",[262,53755,476],{"class":377},[262,53757,53758],{"class":271},"422",[262,53760,608],{"class":429},[262,53762,53763],{"class":611},"detail",[262,53765,476],{"class":377},[262,53767,53768],{"class":275},"\"Text cannot be empty.\"",[262,53770,660],{"class":429},[262,53772,53773,53775],{"class":181,"line":1688},[262,53774,14474],{"class":377},[262,53776,1160],{"class":429},[262,53778,53779,53781,53783],{"class":181,"line":1693},[262,53780,9233],{"class":429},[262,53782,476],{"class":377},[262,53784,1189],{"class":429},[262,53786,53787,53789,53791,53793],{"class":181,"line":1728},[262,53788,14214],{"class":611},[262,53790,476],{"class":377},[262,53792,1207],{"class":275},[262,53794,1315],{"class":429},[262,53796,53797,53799,53801,53803],{"class":181,"line":1737},[262,53798,27286],{"class":611},[262,53800,476],{"class":377},[262,53802,12809],{"class":271},[262,53804,1315],{"class":429},[262,53806,53807,53809,53811],{"class":181,"line":1751},[262,53808,27253],{"class":611},[262,53810,476],{"class":377},[262,53812,1220],{"class":429},[262,53814,53815,53818,53820,53822,53824,53826,53828,53830,53833],{"class":181,"line":1764},[262,53816,53817],{"class":429},"                {",[262,53819,1228],{"class":275},[262,53821,1231],{"class":429},[262,53823,1234],{"class":275},[262,53825,608],{"class":429},[262,53827,1239],{"class":275},[262,53829,1231],{"class":429},[262,53831,53832],{"class":275},"\"Summarize the text in two sentences.\"",[262,53834,3143],{"class":429},[262,53836,53837,53839,53841,53843,53845,53847,53849],{"class":181,"line":1779},[262,53838,53817],{"class":429},[262,53840,1228],{"class":275},[262,53842,1231],{"class":429},[262,53844,1291],{"class":275},[262,53846,608],{"class":429},[262,53848,1239],{"class":275},[262,53850,53851],{"class":429},": req.text},\n",[262,53853,53854],{"class":181,"line":1793},[262,53855,53856],{"class":429},"            ],\n",[262,53858,53859],{"class":181,"line":1800},[262,53860,6288],{"class":429},[262,53862,53863,53865,53867,53869],{"class":181,"line":1805},[262,53864,14522],{"class":377},[262,53866,45777],{"class":429},[262,53868,697],{"class":377},[262,53870,9840],{"class":429},[262,53872,53873,53875,53877,53879,53881,53884,53886,53888,53890,53892,53895,53897,53899,53901,53903],{"class":181,"line":1810},[262,53874,4928],{"class":377},[262,53876,53750],{"class":429},[262,53878,53753],{"class":611},[262,53880,476],{"class":377},[262,53882,53883],{"class":271},"502",[262,53885,608],{"class":429},[262,53887,53763],{"class":611},[262,53889,476],{"class":377},[262,53891,642],{"class":377},[262,53893,53894],{"class":275},"\"AI provider error: ",[262,53896,3039],{"class":271},[262,53898,9864],{"class":429},[262,53900,654],{"class":271},[262,53902,1176],{"class":275},[262,53904,660],{"class":429},[262,53906,53907,53909,53911,53913,53916,53918],{"class":181,"line":1823},[262,53908,573],{"class":377},[262,53910,2276],{"class":429},[262,53912,35511],{"class":275},[262,53914,53915],{"class":429},": result.choices[",[262,53917,102],{"class":271},[262,53919,53920],{"class":429},"].message.content}\n",[14,53922,13310,53923,53926,53927,53930],{},[18,53924,53925],{},"uvicorn app:app --reload",", then open ",[18,53928,53929],{},"http:\u002F\u002F127.0.0.1:8000\u002Fdocs"," in your browser. FastAPI generates an interactive page where you can paste text and try the endpoint live. In one short file you have validation (empty text is rejected), a timeout, a clear error if the provider fails, and a structured JSON response. Every section in the three tracks above extends this exact shape.",[14,53932,53933,53934,53937,53938,53940,53941,53943,53944,53947,53948,53950,53951,53954],{},"Walk through what each piece earns its place doing. The ",[18,53935,53936],{},"SummaryRequest"," model is your validation: FastAPI automatically rejects a request that is missing the ",[18,53939,111],{}," field or sends the wrong type, returning a clear ",[18,53942,53758],{}," before your code runs. The empty-string check catches the subtler case of text that is present but blank. The ",[18,53945,53946],{},"try\u002Fexcept OpenAIError"," wraps the one operation that can fail for reasons outside your control, and translates any provider problem into a ",[18,53949,53883],{}," — the HTTP status that means \"an upstream service I depend on failed\" — so the caller gets a meaningful answer instead of a crash. The ",[18,53952,53953],{},"max_tokens=150"," is your per-call cost ceiling. And the return value is a plain dictionary, which FastAPI serializes to JSON for you.",[14,53956,53957],{},"From here, every feature in this guide is a variation on these twenty-five lines. A chatbot adds a list of previous messages instead of a single string. A CRM enricher fetches a record before building the prompt. A paid SaaS feature adds an authentication check and a usage counter ahead of the model call. The skeleton — validate, call the service, handle failure, return structured data — does not change.",[57,53959,53961],{"id":53960},"putting-it-together-from-prototype-to-production","Putting it together: from prototype to production",[14,53963,53964],{},"The four concepts above are the load-bearing walls of any AI business application, but it helps to see how they relate as your product matures. A prototype can get away with a single file, a hard-coded model name, and no retries — it only has to convince you the idea works. A product cannot. The move from one to the other is mostly about adding the guardrails this guide describes, in roughly this order: pull secrets into config, put the model call behind a service function, add timeouts and retries, cap tokens, then add per-customer limits and accounts.",[14,53966,53967,53968,53970,53971,53973,53974,53976],{},"You do not need all of that on day one, and trying to build it all up front is its own trap — you can spend a month on infrastructure for a feature nobody wants. A healthier sequence is to ship the smallest honest version, watch how real users push on it, and add resilience where reality demands it. The mini-project endpoint is a perfectly reasonable thing to put in front of a handful of early users behind a simple password. What you should ",[27,53969,17892],{}," defer is the cheap, high-leverage stuff: a ",[18,53972,359],{}," entry for your ",[18,53975,319],{},", a timeout on every call, and a token cap. Those cost nothing and prevent the failures that hurt most.",[14,53978,53979,53980,53982,53983,53986,53987,53989],{},"When you do deploy, the same code you ran locally runs in production unchanged. The differences are environmental, not structural: instead of a ",[18,53981,319],{}," file you set environment variables in your host's dashboard, instead of ",[18,53984,53985],{},"--reload"," you run ",[18,53988,52514],{}," without it, and you put the service behind your provider's HTTPS layer. Because your configuration already reads from environment variables and your secrets were never in the code, there is nothing to rewrite — which is the entire reason the config layer was worth the small upfront effort.",[57,53991,53993],{"id":53992},"common-mistakes","Common mistakes",[1379,53995,53996,54004],{},[1382,53997,53998],{},[1385,53999,54000,54002],{},[1388,54001,26305],{},[1388,54003,26308],{},[1398,54005,54006,54019,54029,54037,54048,54056,54064],{},[1385,54007,54008,54011],{},[1403,54009,54010],{},"Hard-coding the API key in your Python file",[1403,54012,54013,54014,54016,54017,1363],{},"Load it from an environment variable and keep it in a ",[18,54015,319],{}," file that is listed in ",[18,54018,359],{},[1385,54020,54021,54024],{},[1403,54022,54023],{},"No timeout on model or API calls",[1403,54025,23336,54026,54028],{},[18,54027,21560],{}," on the client so one slow call cannot hang your whole service.",[1385,54030,54031,54034],{},[1403,54032,54033],{},"Retrying every error, including bad requests",[1403,54035,54036],{},"Retry only transient errors (timeouts, rate limits); let permanent errors fail fast.",[1385,54038,54039,54042],{},[1403,54040,54041],{},"No cap on output tokens",[1403,54043,54044,54045,54047],{},"Always set ",[18,54046,3846],{},"; an uncapped response can be long, slow, and expensive.",[1385,54049,54050,54053],{},[1403,54051,54052],{},"Scattering model calls across many files",[1403,54054,54055],{},"Put the model call behind one service function so swapping models is a one-file change.",[1385,54057,54058,54061],{},[1403,54059,54060],{},"Trusting raw model output as valid JSON or data",[1403,54062,54063],{},"Validate it with Pydantic and handle the case where the model returns something unexpected.",[1385,54065,54066,54069],{},[1403,54067,54068],{},"Letting one user make unlimited requests",[1403,54070,54071],{},"Add per-customer rate limiting before launch so a single account cannot drain your budget.",[57,54073,2355],{"id":2354},[14,54075,54076],{},"Work through these in order to go from this hub to a shipped feature:",[1447,54078,54079,54087,54090,54101,54110],{},[1450,54080,54081,54082,54084,54085,1363],{},"Confirm your toolchain by completing ",[51,54083,5423],{"href":5422}," and getting a key via ",[51,54086,2487],{"href":2486},[1450,54088,54089],{},"Run the mini-project above end to end so you have a live endpoint of your own.",[1450,54091,54092,54093,54095,54096,54098,54099,1363],{},"Pick your track: connect to data with ",[51,54094,36938],{"href":36937},", build a conversational interface with ",[51,54097,54],{"href":53},", or wrap it in a product with a ",[51,54100,39690],{"href":39689},[1450,54102,54103,54104,54107,54108,1363],{},"Add resilience: handle the ",[51,54105,54106],{"href":3378},"429 Rate-Limit Error in Python"," and put per-user caps in place with ",[51,54109,49599],{"href":49598},[1450,54111,54112,54113,54117,54118,1363],{},"Harden it for customers: add accounts with ",[51,54114,54116],{"href":54115},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-user-authentication-to-a-python-ai-app\u002F","Add User Authentication to a Python AI App"," and billing with ",[51,54119,54121],{"href":54120},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-stripe-billing-to-an-ai-saas-with-python\u002F","Add Stripe Billing to an AI SaaS with Python",[14,54123,54124,54125,54127],{},"The patterns here — one service function, centralized config, timeouts, retries, and token caps — carry through every guide on the site. Master them once and the rest is variation. If you find yourself stuck on a specific error rather than the architecture, the ",[51,54126,2487],{"href":2486}," section has focused fixes for the exact messages you are most likely to hit, from authentication failures to rate limits to malformed responses.",[57,54129,2381],{"id":2380},[14,54131,54132],{},"This page is the main guide for the business-applications track. Explore the connected material here:",[2322,54134,54135,54140,54145,54150,54155],{},[1450,54136,54137,54139],{},[51,54138,36938],{"href":36937}," — feed your AI features clean, live customer data.",[1450,54141,54142,54144],{},[51,54143,54],{"href":53}," — build assistants with memory, streaming, and document search.",[1450,54146,54147,54149],{},[51,54148,39690],{"href":39689}," — turn an AI feature into a paid product.",[1450,54151,54152,54154],{},[51,54153,26450],{"href":26449}," — the groundwork: Python setup, LLM APIs, and prompt basics.",[1450,54156,54157,54159],{},[51,54158,5413],{"href":5412}," — apply the same Python skills to content and marketing workflows.",[14,54161,2375,54162,1363],{},[51,54163,26450],{"href":26449},[2401,54165,54166],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":54168},[54169,54170,54171,54172,54173,54174,54175,54176,54177,54178,54179],{"id":24431,"depth":282,"text":24432},{"id":237,"depth":282,"text":238},{"id":52564,"depth":282,"text":52565},{"id":52764,"depth":282,"text":52765},{"id":53049,"depth":282,"text":53050},{"id":53259,"depth":282,"text":53260},{"id":53574,"depth":282,"text":53575},{"id":53960,"depth":282,"text":53961},{"id":53992,"depth":282,"text":53993},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"A founder's guide to building AI-powered business applications with Python: app architecture, connecting to your data, secrets, retries, and a working LLM API.",[54182,54185,54188,54191,54194],{"q":54183,"a":54184},"Do I need to be a developer to build an AI business application?","No. If you can run Python from a terminal and read a code snippet, you can build a working AI feature. This guide assumes no prior software-engineering background and explains every term the first time it appears.",{"q":54186,"a":54187},"Which Python framework should I use to serve an AI feature?","FastAPI is the most common choice for AI applications because it is fast, handles asynchronous calls to slow AI services well, and generates documentation for your endpoints automatically. You can start with a single file and grow from there.",{"q":54189,"a":54190},"How do I keep my OpenAI API key safe in a real application?","Store it in an environment variable, never in your code. Use a .env file locally and add that file to .gitignore so it is never committed. In production, set the key through your hosting provider's secret manager instead of a file.",{"q":54192,"a":54193},"What stops an AI feature from running up a huge API bill?","Three controls together: a hard cap on output tokens per request, a per-user rate limit so one customer cannot flood your service, and timeouts plus retries so failed calls do not silently retry forever. Set all three before launch.",{"q":54195,"a":54196},"What is the smallest useful AI business application I can ship?","A single FastAPI endpoint that takes user text, sends it to an LLM with a clear system prompt, and returns the result as JSON. That is roughly 25 lines of Python and is the foundation for chatbots, CRM enrichment, and SaaS tools alike.",null,{},"\u002Fbuilding-ai-powered-business-applications",{"title":26457,"description":54180},"Building AI-Powered Business Apps in Python","building-ai-powered-business-applications\u002Findex","GzThmzqpyP_qsKnfB9uVmLEbU0dsftT25QAHYHrEMSQ",{"id":54205,"title":54121,"body":54206,"description":55544,"extension":2419,"faq":55545,"howto":55561,"meta":55576,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":55577,"published":2452,"seo":55578,"seoTitle":55579,"stem":55580,"__hash__":55581},"content\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-stripe-billing-to-an-ai-saas-with-python\u002Findex.md",{"type":7,"value":54207,"toc":55534},[54208,54211,54214,54228,54230,54241,54255,54276,54295,54302,54316,54322,54326,54336,54580,54601,54605,54608,54618,54893,54906,54910,54923,55284,55297,55323,55325,55411,55413,55477,55479,55506,55511,55515,55517,55531],[10,54209,54121],{"id":54210},"add-stripe-billing-to-an-ai-saas-with-python",[14,54212,54213],{},"This guide shows you how to charge users a monthly subscription for your AI app with Stripe in under 30 minutes, without ever handling a card number yourself. You will create a product and a price, send users to a hosted payment page, and unlock paid access the moment Stripe confirms the money moved.",[14,54215,54216,54217,54219,54220,54223,54224,54227],{},"It builds directly on the service from ",[51,54218,39690],{"href":39689},", the main guide for this section, where each user already has a record with a plan. Billing is the piece that turns that ",[18,54221,54222],{},"\"plan\": \"free\""," field into ",[18,54225,54226],{},"\"plan\": \"pro\""," after a real payment.",[57,54229,238],{"id":237},[14,54231,54232,54233,54236,54237,54240],{},"You need the FastAPI service from the main guide running, Python 3.10 or newer, and a free Stripe account. From the Stripe Dashboard, switch to ",[35,54234,54235],{},"Test mode"," (the toggle in the top corner) and copy your secret key from ",[35,54238,54239],{},"Developers → API keys",". Install the one new package this guide adds:",[253,54242,54244],{"className":255,"code":54243,"language":257,"meta":258,"style":258},"pip install \"stripe>=9.0\"\n",[18,54245,54246],{"__ignoreMap":258},[262,54247,54248,54250,54252],{"class":181,"line":264},[262,54249,298],{"class":267},[262,54251,301],{"class":275},[262,54253,54254],{"class":275}," \"stripe>=9.0\"\n",[14,54256,54257,54258,54261,54262,54265,54266,54261,54269,54272,54273,54275],{},"Stripe gives you two secrets. The ",[35,54259,54260],{},"secret key"," (starts with ",[18,54263,54264],{},"sk_test_",") authenticates your API calls. The ",[35,54267,54268],{},"webhook signing secret",[18,54270,54271],{},"whsec_",") proves that incoming events really came from Stripe. Store both in ",[18,54274,319],{},", never in your code, because anything in your code can leak into Git history:",[253,54277,54279],{"className":323,"code":54278,"language":325,"meta":258,"style":258},"# .env\nSTRIPE_SECRET_KEY=sk_test_your_real_key_here\nSTRIPE_WEBHOOK_SECRET=whsec_filled_in_during_step_4\n",[18,54280,54281,54285,54290],{"__ignoreMap":258},[262,54282,54283],{"class":181,"line":264},[262,54284,332],{},[262,54286,54287],{"class":181,"line":282},[262,54288,54289],{},"STRIPE_SECRET_KEY=sk_test_your_real_key_here\n",[262,54291,54292],{"class":181,"line":295},[262,54293,54294],{},"STRIPE_WEBHOOK_SECRET=whsec_filled_in_during_step_4\n",[14,54296,353,54297,356,54299,54301],{},[18,54298,319],{},[18,54300,359],{}," immediately so your keys are never committed:",[253,54303,54304],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,54305,54306],{"__ignoreMap":258},[262,54307,54308,54310,54312,54314],{"class":181,"line":264},[262,54309,371],{"class":271},[262,54311,374],{"class":275},[262,54313,378],{"class":377},[262,54315,381],{"class":275},[14,54317,54318,54319,54321],{},"If your service does not yet authenticate callers, set that up first with ",[51,54320,54116],{"href":54115},", because billing only makes sense once you can tell users apart.",[57,54323,54325],{"id":54324},"step-1-create-a-product-and-a-recurring-price","Step 1: Create a product and a recurring price",[14,54327,54328,54329,54331,54332,54335],{},"Stripe separates ",[27,54330,25242],{}," you sell (a product) from ",[27,54333,54334],{},"what it costs"," (a price). You create these once, not on every signup, so run the snippet below as a throwaway script rather than wiring it into your app. It makes a \"Pro Plan\" product and a $20-per-month price, then prints the price ID you will need next.",[253,54337,54339],{"className":414,"code":54338,"language":416,"meta":258,"style":258},"# create_price.py — run this once, then delete it\nimport os\nfrom pathlib import Path\n\nimport stripe\n\nfor line in (Path(__file__).parent \u002F \".env\").read_text().splitlines():\n    if line and not line.startswith(\"#\") and \"=\" in line:\n        k, v = line.split(\"=\", 1)\n        os.environ.setdefault(k.strip(), v.strip())\n\nstripe.api_key = os.environ[\"STRIPE_SECRET_KEY\"]\n\nproduct = stripe.Product.create(name=\"Pro Plan\")\nprice = stripe.Price.create(\n    product=product.id,\n    unit_amount=2000,            # amount in cents: $20.00\n    currency=\"usd\",\n    recurring={\"interval\": \"month\"},\n)\nprint(\"Price ID:\", price.id)     # looks like price_1AbC...\n",[18,54340,54341,54346,54352,54362,54366,54373,54377,54402,54430,54449,54454,54458,54472,54476,54495,54505,54514,54530,54542,54561,54565],{"__ignoreMap":258},[262,54342,54343],{"class":181,"line":264},[262,54344,54345],{"class":291},"# create_price.py — run this once, then delete it\n",[262,54347,54348,54350],{"class":181,"line":282},[262,54349,684],{"class":377},[262,54351,687],{"class":429},[262,54353,54354,54356,54358,54360],{"class":181,"line":295},[262,54355,705],{"class":377},[262,54357,4882],{"class":429},[262,54359,684],{"class":377},[262,54361,4887],{"class":429},[262,54363,54364],{"class":181,"line":345},[262,54365,583],{"emptyLinePlaceholder":582},[262,54367,54368,54370],{"class":181,"line":492},[262,54369,684],{"class":377},[262,54371,54372],{"class":429}," stripe\n",[262,54374,54375],{"class":181,"line":503},[262,54376,583],{"emptyLinePlaceholder":582},[262,54378,54379,54381,54384,54386,54389,54392,54395,54397,54399],{"class":181,"line":521},[262,54380,829],{"class":377},[262,54382,54383],{"class":429}," line ",[262,54385,835],{"class":377},[262,54387,54388],{"class":429}," (Path(",[262,54390,54391],{"class":271},"__file__",[262,54393,54394],{"class":429},").parent ",[262,54396,981],{"class":377},[262,54398,374],{"class":275},[262,54400,54401],{"class":429},").read_text().splitlines():\n",[262,54403,54404,54406,54408,54410,54412,54415,54418,54420,54422,54425,54427],{"class":181,"line":537},[262,54405,3454],{"class":377},[262,54407,54383],{"class":429},[262,54409,6101],{"class":377},[262,54411,2818],{"class":377},[262,54413,54414],{"class":429}," line.startswith(",[262,54416,54417],{"class":275},"\"#\"",[262,54419,1000],{"class":429},[262,54421,6101],{"class":377},[262,54423,54424],{"class":275}," \"=\"",[262,54426,2821],{"class":377},[262,54428,54429],{"class":429}," line:\n",[262,54431,54432,54435,54437,54440,54443,54445,54447],{"class":181,"line":549},[262,54433,54434],{"class":429},"        k, v ",[262,54436,476],{"class":377},[262,54438,54439],{"class":429}," line.split(",[262,54441,54442],{"class":275},"\"=\"",[262,54444,608],{"class":429},[262,54446,997],{"class":271},[262,54448,660],{"class":429},[262,54450,54451],{"class":181,"line":570},[262,54452,54453],{"class":429},"        os.environ.setdefault(k.strip(), v.strip())\n",[262,54455,54456],{"class":181,"line":579},[262,54457,583],{"emptyLinePlaceholder":582},[262,54459,54460,54463,54465,54467,54470],{"class":181,"line":586},[262,54461,54462],{"class":429},"stripe.api_key ",[262,54464,476],{"class":377},[262,54466,36185],{"class":429},[262,54468,54469],{"class":275},"\"STRIPE_SECRET_KEY\"",[262,54471,957],{"class":429},[262,54473,54474],{"class":181,"line":591},[262,54475,583],{"emptyLinePlaceholder":582},[262,54477,54478,54481,54483,54486,54488,54490,54493],{"class":181,"line":623},[262,54479,54480],{"class":429},"product ",[262,54482,476],{"class":377},[262,54484,54485],{"class":429}," stripe.Product.create(",[262,54487,3552],{"class":611},[262,54489,476],{"class":377},[262,54491,54492],{"class":275},"\"Pro Plan\"",[262,54494,660],{"class":429},[262,54496,54497,54500,54502],{"class":181,"line":634},[262,54498,54499],{"class":429},"price ",[262,54501,476],{"class":377},[262,54503,54504],{"class":429}," stripe.Price.create(\n",[262,54506,54507,54509,54511],{"class":181,"line":845},[262,54508,7452],{"class":611},[262,54510,476],{"class":377},[262,54512,54513],{"class":429},"product.id,\n",[262,54515,54516,54519,54521,54524,54527],{"class":181,"line":850},[262,54517,54518],{"class":611},"    unit_amount",[262,54520,476],{"class":377},[262,54522,54523],{"class":271},"2000",[262,54525,54526],{"class":429},",            ",[262,54528,54529],{"class":291},"# amount in cents: $20.00\n",[262,54531,54532,54535,54537,54540],{"class":181,"line":864},[262,54533,54534],{"class":611},"    currency",[262,54536,476],{"class":377},[262,54538,54539],{"class":275},"\"usd\"",[262,54541,1315],{"class":429},[262,54543,54544,54547,54549,54551,54554,54556,54559],{"class":181,"line":1683},[262,54545,54546],{"class":611},"    recurring",[262,54548,476],{"class":377},[262,54550,3039],{"class":429},[262,54552,54553],{"class":275},"\"interval\"",[262,54555,1231],{"class":429},[262,54557,54558],{"class":275},"\"month\"",[262,54560,3143],{"class":429},[262,54562,54563],{"class":181,"line":1688},[262,54564,660],{"class":429},[262,54566,54567,54569,54571,54574,54577],{"class":181,"line":1693},[262,54568,637],{"class":271},[262,54570,602],{"class":429},[262,54572,54573],{"class":275},"\"Price ID:\"",[262,54575,54576],{"class":429},", price.id)     ",[262,54578,54579],{"class":291},"# looks like price_1AbC...\n",[14,54581,12445,54582,54585,54586,54589,54590,54592,54593,54596,54597,54600],{},[18,54583,54584],{},"python create_price.py",". Copy the printed ",[18,54587,54588],{},"Price ID"," into your ",[18,54591,319],{}," as ",[18,54594,54595],{},"STRIPE_PRICE_ID",", because Checkout charges a specific price, not a product. You can also create products and prices by hand in the Dashboard under ",[35,54598,54599],{},"Product catalog","; the API just makes it repeatable.",[57,54602,54604],{"id":54603},"step-2-open-a-checkout-session","Step 2: Open a Checkout Session",[14,54606,54607],{},"A Checkout Session is a single hosted payment page that Stripe builds for one customer. You tell Stripe which price to charge and where to send the user afterward, Stripe returns a URL, and you redirect the user there. The card form lives entirely on Stripe's pages, so card numbers never reach your server.",[14,54609,54610,54611,54614,54615,54617],{},"The key detail is ",[18,54612,54613],{},"client_reference_id",": it carries your own user's ID through Stripe and comes back in the webhook, which is how you later know ",[27,54616,35038],{}," of your users paid.",[253,54619,54621],{"className":414,"code":54620,"language":416,"meta":258,"style":258},"# billing.py\nimport os\n\nimport stripe\nfrom fastapi import APIRouter, Depends\nfrom fastapi.responses import RedirectResponse\n\nstripe.api_key = os.environ[\"STRIPE_SECRET_KEY\"]\nrouter = APIRouter()\n\n# current_user comes from your auth layer (see the Authentication guide).\nfrom auth import current_user  # returns {\"id\": \"...\", \"plan\": \"...\", \"email\": \"...\"}\n\n\n@router.post(\"\u002Fbilling\u002Fcheckout\")\ndef start_checkout(user: dict = Depends(current_user)) -> RedirectResponse:\n    session = stripe.checkout.Session.create(\n        mode=\"subscription\",                       # recurring, not one-off\n        line_items=[{\"price\": os.environ[\"STRIPE_PRICE_ID\"], \"quantity\": 1}],\n        client_reference_id=user[\"id\"],            # ties the payment to your user\n        customer_email=user[\"email\"],              # pre-fills the email field\n        success_url=\"http:\u002F\u002F127.0.0.1:8000\u002Fbilling\u002Fsuccess?session_id={CHECKOUT_SESSION_ID}\",\n        cancel_url=\"http:\u002F\u002F127.0.0.1:8000\u002Fbilling\u002Fcancel\",\n    )\n    return RedirectResponse(session.url, status_code=303)\n",[18,54622,54623,54628,54634,54638,54644,54655,54666,54670,54682,54692,54696,54701,54716,54720,54724,54736,54753,54763,54779,54809,54827,54844,54861,54873,54877],{"__ignoreMap":258},[262,54624,54625],{"class":181,"line":264},[262,54626,54627],{"class":291},"# billing.py\n",[262,54629,54630,54632],{"class":181,"line":282},[262,54631,684],{"class":377},[262,54633,687],{"class":429},[262,54635,54636],{"class":181,"line":295},[262,54637,583],{"emptyLinePlaceholder":582},[262,54639,54640,54642],{"class":181,"line":345},[262,54641,684],{"class":377},[262,54643,54372],{"class":429},[262,54645,54646,54648,54650,54652],{"class":181,"line":492},[262,54647,705],{"class":377},[262,54649,51571],{"class":429},[262,54651,684],{"class":377},[262,54653,54654],{"class":429}," APIRouter, Depends\n",[262,54656,54657,54659,54661,54663],{"class":181,"line":503},[262,54658,705],{"class":377},[262,54660,51583],{"class":429},[262,54662,684],{"class":377},[262,54664,54665],{"class":429}," RedirectResponse\n",[262,54667,54668],{"class":181,"line":521},[262,54669,583],{"emptyLinePlaceholder":582},[262,54671,54672,54674,54676,54678,54680],{"class":181,"line":537},[262,54673,54462],{"class":429},[262,54675,476],{"class":377},[262,54677,36185],{"class":429},[262,54679,54469],{"class":275},[262,54681,957],{"class":429},[262,54683,54684,54687,54689],{"class":181,"line":549},[262,54685,54686],{"class":429},"router ",[262,54688,476],{"class":377},[262,54690,54691],{"class":429}," APIRouter()\n",[262,54693,54694],{"class":181,"line":570},[262,54695,583],{"emptyLinePlaceholder":582},[262,54697,54698],{"class":181,"line":579},[262,54699,54700],{"class":291},"# current_user comes from your auth layer (see the Authentication guide).\n",[262,54702,54703,54705,54708,54710,54713],{"class":181,"line":586},[262,54704,705],{"class":377},[262,54706,54707],{"class":429}," auth ",[262,54709,684],{"class":377},[262,54711,54712],{"class":429}," current_user  ",[262,54714,54715],{"class":291},"# returns {\"id\": \"...\", \"plan\": \"...\", \"email\": \"...\"}\n",[262,54717,54718],{"class":181,"line":591},[262,54719,583],{"emptyLinePlaceholder":582},[262,54721,54722],{"class":181,"line":623},[262,54723,583],{"emptyLinePlaceholder":582},[262,54725,54726,54729,54731,54734],{"class":181,"line":634},[262,54727,54728],{"class":267},"@router.post",[262,54730,602],{"class":429},[262,54732,54733],{"class":275},"\"\u002Fbilling\u002Fcheckout\"",[262,54735,660],{"class":429},[262,54737,54738,54740,54743,54746,54748,54750],{"class":181,"line":845},[262,54739,423],{"class":377},[262,54741,54742],{"class":267}," start_checkout",[262,54744,54745],{"class":429},"(user: ",[262,54747,5869],{"class":271},[262,54749,442],{"class":377},[262,54751,54752],{"class":429}," Depends(current_user)) -> RedirectResponse:\n",[262,54754,54755,54758,54760],{"class":181,"line":850},[262,54756,54757],{"class":429},"    session ",[262,54759,476],{"class":377},[262,54761,54762],{"class":429}," stripe.checkout.Session.create(\n",[262,54764,54765,54768,54770,54773,54776],{"class":181,"line":864},[262,54766,54767],{"class":611},"        mode",[262,54769,476],{"class":377},[262,54771,54772],{"class":275},"\"subscription\"",[262,54774,54775],{"class":429},",                       ",[262,54777,54778],{"class":291},"# recurring, not one-off\n",[262,54780,54781,54784,54786,54788,54791,54794,54797,54799,54802,54804,54806],{"class":181,"line":1683},[262,54782,54783],{"class":611},"        line_items",[262,54785,476],{"class":377},[262,54787,8856],{"class":429},[262,54789,54790],{"class":275},"\"price\"",[262,54792,54793],{"class":429},": os.environ[",[262,54795,54796],{"class":275},"\"STRIPE_PRICE_ID\"",[262,54798,1103],{"class":429},[262,54800,54801],{"class":275},"\"quantity\"",[262,54803,1231],{"class":429},[262,54805,997],{"class":271},[262,54807,54808],{"class":429},"}],\n",[262,54810,54811,54814,54816,54819,54821,54824],{"class":181,"line":1688},[262,54812,54813],{"class":611},"        client_reference_id",[262,54815,476],{"class":377},[262,54817,54818],{"class":429},"user[",[262,54820,6770],{"class":275},[262,54822,54823],{"class":429},"],            ",[262,54825,54826],{"class":291},"# ties the payment to your user\n",[262,54828,54829,54832,54834,54836,54838,54841],{"class":181,"line":1693},[262,54830,54831],{"class":611},"        customer_email",[262,54833,476],{"class":377},[262,54835,54818],{"class":429},[262,54837,37895],{"class":275},[262,54839,54840],{"class":429},"],              ",[262,54842,54843],{"class":291},"# pre-fills the email field\n",[262,54845,54846,54849,54851,54854,54857,54859],{"class":181,"line":1728},[262,54847,54848],{"class":611},"        success_url",[262,54850,476],{"class":377},[262,54852,54853],{"class":275},"\"http:\u002F\u002F127.0.0.1:8000\u002Fbilling\u002Fsuccess?session_id=",[262,54855,54856],{"class":271},"{CHECKOUT_SESSION_ID}",[262,54858,1176],{"class":275},[262,54860,1315],{"class":429},[262,54862,54863,54866,54868,54871],{"class":181,"line":1737},[262,54864,54865],{"class":611},"        cancel_url",[262,54867,476],{"class":377},[262,54869,54870],{"class":275},"\"http:\u002F\u002F127.0.0.1:8000\u002Fbilling\u002Fcancel\"",[262,54872,1315],{"class":429},[262,54874,54875],{"class":181,"line":1751},[262,54876,1011],{"class":429},[262,54878,54879,54881,54884,54886,54888,54891],{"class":181,"line":1764},[262,54880,573],{"class":377},[262,54882,54883],{"class":429}," RedirectResponse(session.url, ",[262,54885,53753],{"class":611},[262,54887,476],{"class":377},[262,54889,54890],{"class":271},"303",[262,54892,660],{"class":429},[14,54894,54895,54896,54898,54899,54902,54903,54905],{},"Hitting this endpoint sends the user to Stripe's page. The ",[18,54897,54856],{}," placeholder is filled in by Stripe, so your success page can look the session up if needed. Note that reaching ",[18,54900,54901],{},"success_url"," does ",[35,54904,17892],{}," prove payment cleared, which is why the next step matters.",[57,54907,54909],{"id":54908},"step-3-activate-access-from-the-webhook","Step 3: Activate access from the webhook",[14,54911,54912,54913,54915,54916,54919,54920,54922],{},"The webhook is the only event you can trust to unlock paid features. Stripe sends an HTTP ",[18,54914,40598],{}," to an endpoint you control whenever something happens, and you react to ",[18,54917,54918],{},"checkout.session.completed",", which fires once a subscription payment succeeds. Before trusting the payload, you verify its signature with your ",[18,54921,54271],{}," secret, so an attacker cannot forge a \"you got paid\" event.",[253,54924,54926],{"className":414,"code":54925,"language":416,"meta":258,"style":258},"# webhook.py\nimport os\n\nimport stripe\nfrom fastapi import APIRouter, Request, HTTPException\n\nstripe.api_key = os.environ[\"STRIPE_SECRET_KEY\"]\nWEBHOOK_SECRET = os.environ[\"STRIPE_WEBHOOK_SECRET\"]\nrouter = APIRouter()\n\nfrom auth import USERS  # your user table: {user_id: {...}}\n\n\n@router.post(\"\u002Fbilling\u002Fwebhook\")\nasync def stripe_webhook(request: Request) -> dict:\n    payload = await request.body()                 # raw bytes, do not parse first\n    signature = request.headers.get(\"stripe-signature\", \"\")\n    try:\n        event = stripe.Webhook.construct_event(payload, signature, WEBHOOK_SECRET)\n    except (ValueError, stripe.SignatureVerificationError):\n        raise HTTPException(status_code=400, detail=\"Invalid signature\")\n\n    if event[\"type\"] == \"checkout.session.completed\":\n        session = event[\"data\"][\"object\"]\n        user_id = session[\"client_reference_id\"]   # the id you sent in Step 2\n        for user in USERS.values():\n            if user[\"id\"] == user_id:\n                user[\"plan\"] = \"pro\"                # unlock paid access\n                user[\"stripe_customer\"] = session[\"customer\"]\n    return {\"received\": True}                       # 200 tells Stripe to stop retrying\n",[18,54927,54928,54933,54939,54943,54949,54960,54964,54976,54990,54998,55002,55016,55020,55024,55035,55053,55068,55087,55093,55107,55118,55141,55145,55163,55180,55199,55213,55229,55247,55265],{"__ignoreMap":258},[262,54929,54930],{"class":181,"line":264},[262,54931,54932],{"class":291},"# webhook.py\n",[262,54934,54935,54937],{"class":181,"line":282},[262,54936,684],{"class":377},[262,54938,687],{"class":429},[262,54940,54941],{"class":181,"line":295},[262,54942,583],{"emptyLinePlaceholder":582},[262,54944,54945,54947],{"class":181,"line":345},[262,54946,684],{"class":377},[262,54948,54372],{"class":429},[262,54950,54951,54953,54955,54957],{"class":181,"line":492},[262,54952,705],{"class":377},[262,54954,51571],{"class":429},[262,54956,684],{"class":377},[262,54958,54959],{"class":429}," APIRouter, Request, HTTPException\n",[262,54961,54962],{"class":181,"line":503},[262,54963,583],{"emptyLinePlaceholder":582},[262,54965,54966,54968,54970,54972,54974],{"class":181,"line":521},[262,54967,54462],{"class":429},[262,54969,476],{"class":377},[262,54971,36185],{"class":429},[262,54973,54469],{"class":275},[262,54975,957],{"class":429},[262,54977,54978,54981,54983,54985,54988],{"class":181,"line":537},[262,54979,54980],{"class":271},"WEBHOOK_SECRET",[262,54982,442],{"class":377},[262,54984,36185],{"class":429},[262,54986,54987],{"class":275},"\"STRIPE_WEBHOOK_SECRET\"",[262,54989,957],{"class":429},[262,54991,54992,54994,54996],{"class":181,"line":549},[262,54993,54686],{"class":429},[262,54995,476],{"class":377},[262,54997,54691],{"class":429},[262,54999,55000],{"class":181,"line":570},[262,55001,583],{"emptyLinePlaceholder":582},[262,55003,55004,55006,55008,55010,55013],{"class":181,"line":579},[262,55005,705],{"class":377},[262,55007,54707],{"class":429},[262,55009,684],{"class":377},[262,55011,55012],{"class":271}," USERS",[262,55014,55015],{"class":291},"  # your user table: {user_id: {...}}\n",[262,55017,55018],{"class":181,"line":586},[262,55019,583],{"emptyLinePlaceholder":582},[262,55021,55022],{"class":181,"line":591},[262,55023,583],{"emptyLinePlaceholder":582},[262,55025,55026,55028,55030,55033],{"class":181,"line":623},[262,55027,54728],{"class":267},[262,55029,602],{"class":429},[262,55031,55032],{"class":275},"\"\u002Fbilling\u002Fwebhook\"",[262,55034,660],{"class":429},[262,55036,55037,55040,55043,55046,55049,55051],{"class":181,"line":634},[262,55038,55039],{"class":377},"async",[262,55041,55042],{"class":377}," def",[262,55044,55045],{"class":267}," stripe_webhook",[262,55047,55048],{"class":429},"(request: Request) -> ",[262,55050,5869],{"class":271},[262,55052,1160],{"class":429},[262,55054,55055,55057,55059,55062,55065],{"class":181,"line":845},[262,55056,16972],{"class":429},[262,55058,476],{"class":377},[262,55060,55061],{"class":377}," await",[262,55063,55064],{"class":429}," request.body()                 ",[262,55066,55067],{"class":291},"# raw bytes, do not parse first\n",[262,55069,55070,55073,55075,55078,55081,55083,55085],{"class":181,"line":850},[262,55071,55072],{"class":429},"    signature ",[262,55074,476],{"class":377},[262,55076,55077],{"class":429}," request.headers.get(",[262,55079,55080],{"class":275},"\"stripe-signature\"",[262,55082,608],{"class":429},[262,55084,9175],{"class":275},[262,55086,660],{"class":429},[262,55088,55089,55091],{"class":181,"line":864},[262,55090,14474],{"class":377},[262,55092,1160],{"class":429},[262,55094,55095,55098,55100,55103,55105],{"class":181,"line":1683},[262,55096,55097],{"class":429},"        event ",[262,55099,476],{"class":377},[262,55101,55102],{"class":429}," stripe.Webhook.construct_event(payload, signature, ",[262,55104,54980],{"class":271},[262,55106,660],{"class":429},[262,55108,55109,55111,55113,55115],{"class":181,"line":1688},[262,55110,14522],{"class":377},[262,55112,13751],{"class":429},[262,55114,16176],{"class":271},[262,55116,55117],{"class":429},", stripe.SignatureVerificationError):\n",[262,55119,55120,55122,55124,55126,55128,55130,55132,55134,55136,55139],{"class":181,"line":1693},[262,55121,4928],{"class":377},[262,55123,53750],{"class":429},[262,55125,53753],{"class":611},[262,55127,476],{"class":377},[262,55129,178],{"class":271},[262,55131,608],{"class":429},[262,55133,53763],{"class":611},[262,55135,476],{"class":377},[262,55137,55138],{"class":275},"\"Invalid signature\"",[262,55140,660],{"class":429},[262,55142,55143],{"class":181,"line":1728},[262,55144,583],{"emptyLinePlaceholder":582},[262,55146,55147,55149,55152,55154,55156,55158,55161],{"class":181,"line":1737},[262,55148,3454],{"class":377},[262,55150,55151],{"class":429}," event[",[262,55153,6025],{"class":275},[262,55155,2903],{"class":429},[262,55157,10758],{"class":377},[262,55159,55160],{"class":275}," \"checkout.session.completed\"",[262,55162,1160],{"class":429},[262,55164,55165,55168,55170,55172,55174,55176,55178],{"class":181,"line":1751},[262,55166,55167],{"class":429},"        session ",[262,55169,476],{"class":377},[262,55171,55151],{"class":429},[262,55173,18768],{"class":275},[262,55175,6163],{"class":429},[262,55177,35252],{"class":275},[262,55179,957],{"class":429},[262,55181,55182,55185,55187,55190,55193,55196],{"class":181,"line":1764},[262,55183,55184],{"class":429},"        user_id ",[262,55186,476],{"class":377},[262,55188,55189],{"class":429}," session[",[262,55191,55192],{"class":275},"\"client_reference_id\"",[262,55194,55195],{"class":429},"]   ",[262,55197,55198],{"class":291},"# the id you sent in Step 2\n",[262,55200,55201,55203,55206,55208,55210],{"class":181,"line":1779},[262,55202,10155],{"class":377},[262,55204,55205],{"class":429}," user ",[262,55207,835],{"class":377},[262,55209,55012],{"class":271},[262,55211,55212],{"class":429},".values():\n",[262,55214,55215,55217,55220,55222,55224,55226],{"class":181,"line":1793},[262,55216,10200],{"class":377},[262,55218,55219],{"class":429}," user[",[262,55221,6770],{"class":275},[262,55223,2903],{"class":429},[262,55225,10758],{"class":377},[262,55227,55228],{"class":429}," user_id:\n",[262,55230,55231,55234,55237,55239,55241,55244],{"class":181,"line":1800},[262,55232,55233],{"class":429},"                user[",[262,55235,55236],{"class":275},"\"plan\"",[262,55238,2903],{"class":429},[262,55240,476],{"class":377},[262,55242,55243],{"class":275}," \"pro\"",[262,55245,55246],{"class":291},"                # unlock paid access\n",[262,55248,55249,55251,55254,55256,55258,55260,55263],{"class":181,"line":1805},[262,55250,55233],{"class":429},[262,55252,55253],{"class":275},"\"stripe_customer\"",[262,55255,2903],{"class":429},[262,55257,476],{"class":377},[262,55259,55189],{"class":429},[262,55261,55262],{"class":275},"\"customer\"",[262,55264,957],{"class":429},[262,55266,55267,55269,55271,55274,55276,55278,55281],{"class":181,"line":1810},[262,55268,573],{"class":377},[262,55270,2276],{"class":429},[262,55272,55273],{"class":275},"\"received\"",[262,55275,1231],{"class":429},[262,55277,4974],{"class":271},[262,55279,55280],{"class":429},"}                       ",[262,55282,55283],{"class":291},"# 200 tells Stripe to stop retrying\n",[14,55285,55286,55287,3921,55290,55293,55294,55296],{},"Always pass the ",[35,55288,55289],{},"raw request body",[18,55291,55292],{},"construct_event","; if you let FastAPI parse the JSON first, the signature will not match and every event fails. Return a ",[18,55295,104],{}," quickly: Stripe retries any webhook that does not get a fast success, so do the heavy work after responding if it is slow.",[14,55298,55299,55300,55305,55306,55309,55310,55312,55313,55315,55316,55319,55320,1363],{},"To test locally, install the ",[51,55301,55304],{"href":55302,"rel":55303},"https:\u002F\u002Fdocs.stripe.com\u002Fstripe-cli",[6509],"Stripe CLI"," and run ",[18,55307,55308],{},"stripe listen --forward-to 127.0.0.1:8000\u002Fbilling\u002Fwebhook",". It prints a ",[18,55311,54271],{}," secret for the session; paste that into your ",[18,55314,319],{},", then run ",[18,55317,55318],{},"stripe trigger checkout.session.completed"," to fire a fake event and watch your user flip to ",[18,55321,55322],{},"pro",[57,55324,1367],{"id":1366},[1379,55326,55327,55339],{},[1382,55328,55329],{},[1385,55330,55331,55333,55335,55337],{},[1388,55332,1390],{},[1388,55334,24078],{},[1388,55336,3798],{},[1388,55338,1396],{},[1398,55340,55341,55362,55380,55393],{},[1385,55342,55343,55348,55351,55353],{},[1403,55344,55345],{},[18,55346,55347],{},"mode",[1403,55349,55350],{},"Checkout Session",[1403,55352,14674],{},[1403,55354,55355,55357,55358,55361],{},[18,55356,54772],{}," for recurring billing; ",[18,55359,55360],{},"\"payment\""," for a one-off charge.",[1385,55363,55364,55369,55372,55374],{},[1403,55365,55366],{},[18,55367,55368],{},"unit_amount",[1403,55370,55371],{},"Price",[1403,55373,14674],{},[1403,55375,55376,55377,55379],{},"Price in the smallest currency unit (cents). ",[18,55378,54523],{}," means $20.00.",[1385,55381,55382,55386,55388,55390],{},[1403,55383,55384],{},[18,55385,54613],{},[1403,55387,55350],{},[1403,55389,219],{},[1403,55391,55392],{},"Your own user ID, returned in the webhook so you know who paid.",[1385,55394,55395,55400,55404,55406],{},[1403,55396,55397],{},[18,55398,55399],{},"STRIPE_WEBHOOK_SECRET",[1403,55401,55402],{},[18,55403,319],{},[1403,55405,14674],{},[1403,55407,3349,55408,55410],{},[18,55409,54271],{}," secret used to verify each event is genuinely from Stripe.",[57,55412,1445],{"id":1444},[1447,55414,55415,55433,55449,55464],{},[1450,55416,55417,55422,55423,1482,55426,55428,55429,55432],{},[35,55418,55419],{},[18,55420,55421],{},"stripe.SignatureVerificationError"," — You parsed the body before verifying, or used the wrong signing secret. Pass the raw bytes from ",[18,55424,55425],{},"await request.body()",[18,55427,55399],{}," matches the one your ",[18,55430,55431],{},"stripe listen"," session or Dashboard endpoint shows.",[1450,55434,55435,55440,55441,55444,55445,55448],{},[35,55436,55437,55439],{},[18,55438,54918],{}," never arrives"," — Your webhook is not reachable. With the Stripe CLI, keep ",[18,55442,55443],{},"stripe listen --forward-to ..."," running in a second terminal; in production, register the public URL under ",[35,55446,55447],{},"Developers → Webhooks"," in the Dashboard.",[1450,55450,55451,55456,55457,55459,55460,55463],{},[35,55452,55453],{},[18,55454,55455],{},"stripe.error.InvalidRequestError: No such price"," — ",[18,55458,54595],{}," is wrong or from live mode while your key is test mode. Re-run ",[18,55461,55462],{},"create_price.py"," in the same mode as your secret key and copy the fresh ID.",[1450,55465,55466,55469,55470,55472,55473,55476],{},[35,55467,55468],{},"User charged but still on the free plan"," — The webhook arrived but ",[18,55471,54613],{}," did not match any user. Confirm Step 2 sets ",[18,55474,55475],{},"client_reference_id=user[\"id\"]"," and that the webhook compares against that same ID.",[57,55478,2317],{"id":2316},[2322,55480,55481,55490,55496],{},[1450,55482,55483,55486,55487,55489],{},[35,55484,55485],{},"Checkout Session (this guide)"," — Best when access is tied to a logged-in user and you want full control over when and how the redirect happens. The ",[18,55488,54613],{}," link makes it the right choice for unlocking features per account in an AI SaaS.",[1450,55491,55492,55495],{},[35,55493,55494],{},"Payment Links"," — A no-code URL you create in the Dashboard and paste anywhere. Great for a quick paywall or a launch before you have auth, but it does not carry your user ID automatically, so mapping a payment back to an account is harder. Reach for it when speed beats integration.",[1450,55497,55498,55501,55502,55505],{},[35,55499,55500],{},"Billing Portal"," — Not a way to take the first payment, but the hosted page where existing customers upgrade, change cards, or cancel. Add it after this guide so you do not have to build subscription management yourself; create a portal session for the saved ",[18,55503,55504],{},"stripe_customer"," ID and redirect.",[14,55507,55508,55509,1363],{},"Once billing works, protect your margin so a paying user cannot run up unlimited model cost with ",[51,55510,49599],{"href":49598},[14,55512,2375,55513,1363],{},[51,55514,39690],{"href":39689},[57,55516,2381],{"id":2380},[2322,55518,55519,55523,55527],{},[1450,55520,55521],{},[51,55522,39690],{"href":39689},[1450,55524,55525],{},[51,55526,54116],{"href":54115},[1450,55528,55529],{},[51,55530,49599],{"href":49598},[2401,55532,55533],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":55535},[55536,55537,55538,55539,55540,55541,55542,55543],{"id":237,"depth":282,"text":238},{"id":54324,"depth":282,"text":54325},{"id":54603,"depth":282,"text":54604},{"id":54908,"depth":282,"text":54909},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Add Stripe subscriptions to a FastAPI AI app: create products and prices, open a Checkout Session, and unlock access from the webhook. Runnable Python.",[55546,55549,55552,55555,55558],{"q":55547,"a":55548},"Do I need to handle credit card numbers to bill with Stripe?","No. Stripe Checkout hosts the card form on Stripe's own pages, so card numbers never touch your server. You redirect the user to Stripe, they pay, and Stripe sends your server an event saying it succeeded. This keeps you out of most PCI compliance scope.",{"q":55550,"a":55551},"Why must I activate access from a webhook instead of the success page?","The success page only proves the user reached it, not that the payment cleared, and users can close the tab before redirecting. The webhook is Stripe telling your server directly that money moved, so it is the only event you can trust to unlock paid features.",{"q":55553,"a":55554},"What is the difference between a product and a price in Stripe?","A product is the thing you sell, like 'Pro Plan'. A price is how much it costs and how often, like '$20 per month'. One product can have several prices, such as a monthly and a yearly option, and Checkout always charges a specific price.",{"q":55556,"a":55557},"How do I test Stripe billing without real money?","Use your test-mode API keys and the test card number 4242 4242 4242 4242 with any future expiry and any CVC. Stripe processes it like a real payment but charges nothing, and the Stripe CLI can forward webhook events to your local server.",{"q":55559,"a":55560},"How do I let customers cancel or update their own subscription?","Use the Stripe Billing Portal. You create a portal session for a logged-in customer and redirect them to it, and Stripe gives them a hosted page to change plans, update cards, or cancel without any extra code from you.",{"name":55562,"steps":55563},"How to add Stripe billing to a FastAPI AI SaaS",[55564,55567,55570,55573],{"name":55565,"text":55566},"Install Stripe and store your keys","Install the stripe SDK and FastAPI, then put your secret key and webhook signing secret in a .env file that Git ignores.",{"name":55568,"text":55569},"Create a product and a recurring price","Run a one-time script that creates a Pro product and a monthly price, and note the price ID for Checkout.",{"name":55571,"text":55572},"Open a Checkout Session","Add an endpoint that creates a subscription Checkout Session and redirects the user to Stripe's hosted payment page.",{"name":55574,"text":55575},"Activate access from the webhook","Verify the webhook signature, react to checkout.session.completed, and mark the matching user as paid.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-stripe-billing-to-an-ai-saas-with-python",{"title":54121,"description":55544},"Add Stripe Billing to an AI SaaS in Python","building-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-stripe-billing-to-an-ai-saas-with-python\u002Findex","hZsm-doXKKNPwwZL4UaLMvUq4I37Xydw52oDj5g7b5A",{"id":55583,"title":54116,"body":55584,"description":57228,"extension":2419,"faq":57229,"howto":57245,"meta":57260,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":57261,"published":2452,"seo":57262,"seoTitle":54116,"stem":57263,"__hash__":57264},"content\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-user-authentication-to-a-python-ai-app\u002Findex.md",{"type":7,"value":55585,"toc":57215},[55586,55589,55598,55616,55618,55624,55667,55686,55692,55697,55711,55716,55736,55743,55757,55767,55854,55864,55868,55877,56009,56012,56175,56179,56193,56312,56332,56508,56511,56515,56522,56793,56803,56983,57009,57011,57094,57096,57155,57157,57177,57179,57189,57191,57213],[10,55587,54116],{"id":55588},"add-user-authentication-to-a-python-ai-app",[14,55590,55591,55592,55597],{},"This guide shows you how to add real user authentication to a ",[51,55593,55596],{"href":55594,"rel":55595},"https:\u002F\u002Ffastapi.tiangolo.com\u002F",[6509],"FastAPI"," AI app in about twenty minutes: hashed passwords, signed login tokens, and a protected route that knows who is calling. Once an AI endpoint costs you money per request, you need to know that the person hitting it is a real, logged-in user, not an anonymous stranger burning your OpenAI budget. Authentication is the gate that answers \"who is this?\" before any model call runs.",[14,55599,55600,55601,55604,55605,55608,55609,55611,55612,1374,55614,1363],{},"We will build four small pieces: a way to store users with hashed passwords, a ",[18,55602,55603],{},"\u002Flogin"," route that hands back a token, a token check that runs on every protected request, and a ",[18,55606,55607],{},"\u002Fme"," route that reads the current user. This sits under ",[51,55610,39690],{"href":39689},", the main guide for turning an AI feature into a billable product, and pairs naturally with ",[51,55613,54121],{"href":54120},[51,55615,49599],{"href":49598},[57,55617,238],{"id":237},[14,55619,55620,55621,55623],{},"You need Python 3.10 or newer and a working FastAPI app. If you have followed ",[51,55622,39690],{"href":39689}," you already have most of this. This guide only adds the auth layer, so the only new pieces are the password and token libraries.",[253,55625,55627],{"className":255,"code":55626,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate          # Windows: .venv\\Scripts\\activate\npip install \"fastapi>=0.110\" \"uvicorn[standard]\" \"passlib[bcrypt]\" \"python-jose[cryptography]\" \"python-multipart\"\n",[18,55628,55629,55639,55647],{"__ignoreMap":258},[262,55630,55631,55633,55635,55637],{"class":181,"line":264},[262,55632,416],{"class":267},[262,55634,272],{"class":271},[262,55636,276],{"class":275},[262,55638,279],{"class":275},[262,55640,55641,55643,55645],{"class":181,"line":282},[262,55642,285],{"class":271},[262,55644,288],{"class":275},[262,55646,292],{"class":291},[262,55648,55649,55651,55653,55655,55658,55661,55664],{"class":181,"line":295},[262,55650,298],{"class":267},[262,55652,301],{"class":275},[262,55654,50746],{"class":275},[262,55656,55657],{"class":275}," \"uvicorn[standard]\"",[262,55659,55660],{"class":275}," \"passlib[bcrypt]\"",[262,55662,55663],{"class":275}," \"python-jose[cryptography]\"",[262,55665,55666],{"class":275}," \"python-multipart\"\n",[14,55668,55669,55670,55673,55674,55677,55678,55681,55682,55685],{},"A quick note on the libraries: ",[18,55671,55672],{},"passlib"," is the password-hashing toolkit (with ",[18,55675,55676],{},"bcrypt"," as the actual hashing algorithm), ",[18,55679,55680],{},"python-jose"," signs and verifies JWTs, and ",[18,55683,55684],{},"python-multipart"," lets FastAPI read the form fields that the standard login flow uses.",[57,55687,55689,55690],{"id":55688},"step-1-store-a-jwt-secret-in-env","Step 1: Store a JWT secret in ",[18,55691,319],{},[14,55693,55694,55695,411],{},"A JWT is only as safe as the secret key used to sign it. Anyone who knows that key can forge a token for any user, so it must never live in your source code. Generate a long random string and put it in a ",[18,55696,319],{},[253,55698,55700],{"className":255,"code":55699,"language":257,"meta":258,"style":258},"python -c \"import secrets; print(secrets.token_urlsafe(32))\"\n",[18,55701,55702],{"__ignoreMap":258},[262,55703,55704,55706,55708],{"class":181,"line":264},[262,55705,416],{"class":267},[262,55707,44707],{"class":271},[262,55709,55710],{"class":275}," \"import secrets; print(secrets.token_urlsafe(32))\"\n",[14,55712,55713,55714,26616],{},"Paste the output into ",[18,55715,319],{},[253,55717,55719],{"className":323,"code":55718,"language":325,"meta":258,"style":258},"JWT_SECRET=paste-your-long-random-string-here\nJWT_ALGORITHM=HS256\nACCESS_TOKEN_MINUTES=30\n",[18,55720,55721,55726,55731],{"__ignoreMap":258},[262,55722,55723],{"class":181,"line":264},[262,55724,55725],{},"JWT_SECRET=paste-your-long-random-string-here\n",[262,55727,55728],{"class":181,"line":282},[262,55729,55730],{},"JWT_ALGORITHM=HS256\n",[262,55732,55733],{"class":181,"line":295},[262,55734,55735],{},"ACCESS_TOKEN_MINUTES=30\n",[14,55737,353,55738,356,55740,55742],{},[18,55739,319],{},[18,55741,359],{}," right now so the secret never reaches Git:",[253,55744,55745],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,55746,55747],{"__ignoreMap":258},[262,55748,55749,55751,55753,55755],{"class":181,"line":264},[262,55750,371],{"class":271},[262,55752,374],{"class":275},[262,55754,378],{"class":377},[262,55756,381],{"class":275},[14,55758,55759,55760,55762,55763,55766],{},"Load these values at startup with ",[18,55761,2501],{}," (install it with ",[18,55764,55765],{},"pip install python-dotenv","):",[253,55768,55770],{"className":414,"code":55769,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nJWT_SECRET = os.environ[\"JWT_SECRET\"]\nJWT_ALGORITHM = os.getenv(\"JWT_ALGORITHM\", \"HS256\")\nACCESS_TOKEN_MINUTES = int(os.getenv(\"ACCESS_TOKEN_MINUTES\", \"30\"))\n",[18,55771,55772,55778,55788,55792,55796,55800,55814,55833],{"__ignoreMap":258},[262,55773,55774,55776],{"class":181,"line":264},[262,55775,684],{"class":377},[262,55777,687],{"class":429},[262,55779,55780,55782,55784,55786],{"class":181,"line":282},[262,55781,705],{"class":377},[262,55783,708],{"class":429},[262,55785,684],{"class":377},[262,55787,713],{"class":429},[262,55789,55790],{"class":181,"line":295},[262,55791,583],{"emptyLinePlaceholder":582},[262,55793,55794],{"class":181,"line":345},[262,55795,734],{"class":429},[262,55797,55798],{"class":181,"line":492},[262,55799,583],{"emptyLinePlaceholder":582},[262,55801,55802,55805,55807,55809,55812],{"class":181,"line":503},[262,55803,55804],{"class":271},"JWT_SECRET",[262,55806,442],{"class":377},[262,55808,36185],{"class":429},[262,55810,55811],{"class":275},"\"JWT_SECRET\"",[262,55813,957],{"class":429},[262,55815,55816,55819,55821,55823,55826,55828,55831],{"class":181,"line":521},[262,55817,55818],{"class":271},"JWT_ALGORITHM",[262,55820,442],{"class":377},[262,55822,754],{"class":429},[262,55824,55825],{"class":275},"\"JWT_ALGORITHM\"",[262,55827,608],{"class":429},[262,55829,55830],{"class":275},"\"HS256\"",[262,55832,660],{"class":429},[262,55834,55835,55838,55840,55842,55844,55847,55849,55852],{"class":181,"line":537},[262,55836,55837],{"class":271},"ACCESS_TOKEN_MINUTES",[262,55839,442],{"class":377},[262,55841,23813],{"class":271},[262,55843,53190],{"class":429},[262,55845,55846],{"class":275},"\"ACCESS_TOKEN_MINUTES\"",[262,55848,608],{"class":429},[262,55850,55851],{"class":275},"\"30\"",[262,55853,2684],{"class":429},[14,55855,37506,55856,55859,55860,55863],{},[18,55857,55858],{},"os.environ[\"JWT_SECRET\"]"," (not ",[18,55861,55862],{},".get",") means the app refuses to start if the secret is missing, which is exactly what you want.",[57,55865,55867],{"id":55866},"step-2-hash-and-verify-passwords","Step 2: Hash and verify passwords",[14,55869,55870,55871,55873,55874,1363],{},"Never store a raw password. Store a one-way bcrypt hash, which cannot be reversed back into the original text. At login you hash the submitted password and compare hashes. ",[18,55872,55672],{}," gives you both operations through a ",[18,55875,55876],{},"CryptContext",[253,55878,55880],{"className":414,"code":55879,"language":416,"meta":258,"style":258},"from passlib.context import CryptContext\n\npwd_context = CryptContext(schemes=[\"bcrypt\"], deprecated=\"auto\")\n\n\ndef hash_password(plain: str) -> str:\n    \"\"\"Turn a raw password into a bcrypt hash safe to store.\"\"\"\n    return pwd_context.hash(plain)\n\n\ndef verify_password(plain: str, hashed: str) -> bool:\n    \"\"\"Check a submitted password against the stored hash.\"\"\"\n    return pwd_context.verify(plain, hashed)\n",[18,55881,55882,55894,55898,55929,55933,55937,55955,55960,55967,55971,55975,55997,56002],{"__ignoreMap":258},[262,55883,55884,55886,55889,55891],{"class":181,"line":264},[262,55885,705],{"class":377},[262,55887,55888],{"class":429}," passlib.context ",[262,55890,684],{"class":377},[262,55892,55893],{"class":429}," CryptContext\n",[262,55895,55896],{"class":181,"line":282},[262,55897,583],{"emptyLinePlaceholder":582},[262,55899,55900,55903,55905,55908,55911,55913,55915,55918,55920,55923,55925,55927],{"class":181,"line":295},[262,55901,55902],{"class":429},"pwd_context ",[262,55904,476],{"class":377},[262,55906,55907],{"class":429}," CryptContext(",[262,55909,55910],{"class":611},"schemes",[262,55912,476],{"class":377},[262,55914,12118],{"class":429},[262,55916,55917],{"class":275},"\"bcrypt\"",[262,55919,1103],{"class":429},[262,55921,55922],{"class":611},"deprecated",[262,55924,476],{"class":377},[262,55926,29604],{"class":275},[262,55928,660],{"class":429},[262,55930,55931],{"class":181,"line":345},[262,55932,583],{"emptyLinePlaceholder":582},[262,55934,55935],{"class":181,"line":492},[262,55936,583],{"emptyLinePlaceholder":582},[262,55938,55939,55941,55944,55947,55949,55951,55953],{"class":181,"line":503},[262,55940,423],{"class":377},[262,55942,55943],{"class":267}," hash_password",[262,55945,55946],{"class":429},"(plain: ",[262,55948,433],{"class":271},[262,55950,1939],{"class":429},[262,55952,433],{"class":271},[262,55954,1160],{"class":429},[262,55956,55957],{"class":181,"line":521},[262,55958,55959],{"class":275},"    \"\"\"Turn a raw password into a bcrypt hash safe to store.\"\"\"\n",[262,55961,55962,55964],{"class":181,"line":537},[262,55963,573],{"class":377},[262,55965,55966],{"class":429}," pwd_context.hash(plain)\n",[262,55968,55969],{"class":181,"line":549},[262,55970,583],{"emptyLinePlaceholder":582},[262,55972,55973],{"class":181,"line":570},[262,55974,583],{"emptyLinePlaceholder":582},[262,55976,55977,55979,55982,55984,55986,55989,55991,55993,55995],{"class":181,"line":579},[262,55978,423],{"class":377},[262,55980,55981],{"class":267}," verify_password",[262,55983,55946],{"class":429},[262,55985,433],{"class":271},[262,55987,55988],{"class":429},", hashed: ",[262,55990,433],{"class":271},[262,55992,1939],{"class":429},[262,55994,8045],{"class":271},[262,55996,1160],{"class":429},[262,55998,55999],{"class":181,"line":586},[262,56000,56001],{"class":275},"    \"\"\"Check a submitted password against the stored hash.\"\"\"\n",[262,56003,56004,56006],{"class":181,"line":591},[262,56005,573],{"class":377},[262,56007,56008],{"class":429}," pwd_context.verify(plain, hashed)\n",[14,56010,56011],{},"For this guide we keep users in a plain dictionary so you can run it without a database. Swap this for a real table once it works. Notice the stored value is the hash, never the password.",[253,56013,56015],{"className":414,"code":56014,"language":416,"meta":258,"style":258},"# A stand-in \"database\". Replace with Postgres or SQLite later.\nfake_users: dict[str, dict] = {}\n\n\ndef create_user(email: str, password: str) -> dict:\n    if email in fake_users:\n        raise ValueError(\"User already exists\")\n    user = {\"id\": len(fake_users) + 1, \"email\": email,\n            \"hashed_password\": hash_password(password)}\n    fake_users[email] = user\n    return user\n\n\n# Seed one user so you have something to log in with.\ncreate_user(\"founder@example.com\", \"supersecret123\")\n",[18,56016,56017,56022,56039,56043,56047,56070,56082,56095,56123,56131,56141,56147,56151,56155,56160],{"__ignoreMap":258},[262,56018,56019],{"class":181,"line":264},[262,56020,56021],{"class":291},"# A stand-in \"database\". Replace with Postgres or SQLite later.\n",[262,56023,56024,56027,56029,56031,56033,56035,56037],{"class":181,"line":282},[262,56025,56026],{"class":429},"fake_users: dict[",[262,56028,433],{"class":271},[262,56030,608],{"class":429},[262,56032,5869],{"class":271},[262,56034,2903],{"class":429},[262,56036,476],{"class":377},[262,56038,29867],{"class":429},[262,56040,56041],{"class":181,"line":295},[262,56042,583],{"emptyLinePlaceholder":582},[262,56044,56045],{"class":181,"line":345},[262,56046,583],{"emptyLinePlaceholder":582},[262,56048,56049,56051,56054,56057,56059,56062,56064,56066,56068],{"class":181,"line":492},[262,56050,423],{"class":377},[262,56052,56053],{"class":267}," create_user",[262,56055,56056],{"class":429},"(email: ",[262,56058,433],{"class":271},[262,56060,56061],{"class":429},", password: ",[262,56063,433],{"class":271},[262,56065,1939],{"class":429},[262,56067,5869],{"class":271},[262,56069,1160],{"class":429},[262,56071,56072,56074,56077,56079],{"class":181,"line":503},[262,56073,3454],{"class":377},[262,56075,56076],{"class":429}," email ",[262,56078,835],{"class":377},[262,56080,56081],{"class":429}," fake_users:\n",[262,56083,56084,56086,56088,56090,56093],{"class":181,"line":521},[262,56085,4928],{"class":377},[262,56087,2832],{"class":271},[262,56089,602],{"class":429},[262,56091,56092],{"class":275},"\"User already exists\"",[262,56094,660],{"class":429},[262,56096,56097,56099,56101,56103,56105,56107,56109,56112,56114,56116,56118,56120],{"class":181,"line":537},[262,56098,7611],{"class":429},[262,56100,476],{"class":377},[262,56102,2276],{"class":429},[262,56104,6770],{"class":275},[262,56106,1231],{"class":429},[262,56108,29318],{"class":271},[262,56110,56111],{"class":429},"(fake_users) ",[262,56113,531],{"class":377},[262,56115,3243],{"class":271},[262,56117,608],{"class":429},[262,56119,37895],{"class":275},[262,56121,56122],{"class":429},": email,\n",[262,56124,56125,56128],{"class":181,"line":549},[262,56126,56127],{"class":275},"            \"hashed_password\"",[262,56129,56130],{"class":429},": hash_password(password)}\n",[262,56132,56133,56136,56138],{"class":181,"line":570},[262,56134,56135],{"class":429},"    fake_users[email] ",[262,56137,476],{"class":377},[262,56139,56140],{"class":429}," user\n",[262,56142,56143,56145],{"class":181,"line":579},[262,56144,573],{"class":377},[262,56146,56140],{"class":429},[262,56148,56149],{"class":181,"line":586},[262,56150,583],{"emptyLinePlaceholder":582},[262,56152,56153],{"class":181,"line":591},[262,56154,583],{"emptyLinePlaceholder":582},[262,56156,56157],{"class":181,"line":623},[262,56158,56159],{"class":291},"# Seed one user so you have something to log in with.\n",[262,56161,56162,56165,56168,56170,56173],{"class":181,"line":634},[262,56163,56164],{"class":429},"create_user(",[262,56166,56167],{"class":275},"\"founder@example.com\"",[262,56169,608],{"class":429},[262,56171,56172],{"class":275},"\"supersecret123\"",[262,56174,660],{"class":429},[57,56176,56178],{"id":56177},"step-3-issue-a-jwt-access-token-on-login","Step 3: Issue a JWT access token on login",[14,56180,56181,56182,56185,56186,56189,56190,56192],{},"When a user proves their password, you hand them a signed token. The token's ",[18,56183,56184],{},"sub"," (subject) claim holds the user id, and ",[18,56187,56188],{},"exp"," (expiry) tells the server when it stops being valid. ",[18,56191,55680],{}," encodes and signs it with your secret.",[253,56194,56196],{"className":414,"code":56195,"language":416,"meta":258,"style":258},"from datetime import datetime, timedelta, timezone\nfrom jose import jwt\n\n\ndef create_access_token(user_id: int) -> str:\n    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_MINUTES)\n    payload = {\"sub\": str(user_id), \"exp\": expire}\n    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)\n",[18,56197,56198,56209,56221,56225,56229,56247,56268,56292],{"__ignoreMap":258},[262,56199,56200,56202,56204,56206],{"class":181,"line":264},[262,56201,705],{"class":377},[262,56203,10502],{"class":429},[262,56205,684],{"class":377},[262,56207,56208],{"class":429}," datetime, timedelta, timezone\n",[262,56210,56211,56213,56216,56218],{"class":181,"line":282},[262,56212,705],{"class":377},[262,56214,56215],{"class":429}," jose ",[262,56217,684],{"class":377},[262,56219,56220],{"class":429}," jwt\n",[262,56222,56223],{"class":181,"line":295},[262,56224,583],{"emptyLinePlaceholder":582},[262,56226,56227],{"class":181,"line":345},[262,56228,583],{"emptyLinePlaceholder":582},[262,56230,56231,56233,56236,56239,56241,56243,56245],{"class":181,"line":492},[262,56232,423],{"class":377},[262,56234,56235],{"class":267}," create_access_token",[262,56237,56238],{"class":429},"(user_id: ",[262,56240,439],{"class":271},[262,56242,1939],{"class":429},[262,56244,433],{"class":271},[262,56246,1160],{"class":429},[262,56248,56249,56252,56254,56256,56258,56260,56262,56264,56266],{"class":181,"line":503},[262,56250,56251],{"class":429},"    expire ",[262,56253,476],{"class":377},[262,56255,26134],{"class":429},[262,56257,531],{"class":377},[262,56259,22420],{"class":429},[262,56261,26141],{"class":611},[262,56263,476],{"class":377},[262,56265,55837],{"class":271},[262,56267,660],{"class":429},[262,56269,56270,56272,56274,56276,56279,56281,56283,56286,56289],{"class":181,"line":521},[262,56271,16972],{"class":429},[262,56273,476],{"class":377},[262,56275,2276],{"class":429},[262,56277,56278],{"class":275},"\"sub\"",[262,56280,1231],{"class":429},[262,56282,433],{"class":271},[262,56284,56285],{"class":429},"(user_id), ",[262,56287,56288],{"class":275},"\"exp\"",[262,56290,56291],{"class":429},": expire}\n",[262,56293,56294,56296,56299,56301,56303,56306,56308,56310],{"class":181,"line":537},[262,56295,573],{"class":377},[262,56297,56298],{"class":429}," jwt.encode(payload, ",[262,56300,55804],{"class":271},[262,56302,608],{"class":429},[262,56304,56305],{"class":611},"algorithm",[262,56307,476],{"class":377},[262,56309,55818],{"class":271},[262,56311,660],{"class":429},[14,56313,56314,56315,56317,56318,56321,56322,1374,56325,56328,56329,56331],{},"Now wire up the ",[18,56316,55603],{}," route. FastAPI's ",[18,56319,56320],{},"OAuth2PasswordRequestForm"," reads the standard ",[18,56323,56324],{},"username",[18,56326,56327],{},"password"," form fields, so tools and the built-in docs page work out of the box. We treat ",[18,56330,56324],{}," as the email.",[253,56333,56335],{"className":414,"code":56334,"language":416,"meta":258,"style":258},"from fastapi import FastAPI, Depends, HTTPException, status\nfrom fastapi.security import OAuth2PasswordRequestForm\n\napp = FastAPI()\n\n\n@app.post(\"\u002Flogin\")\ndef login(form: OAuth2PasswordRequestForm = Depends()):\n    user = fake_users.get(form.username)\n    if not user or not verify_password(form.password, user[\"hashed_password\"]):\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Incorrect email or password\",\n        )\n    token = create_access_token(user[\"id\"])\n    return {\"access_token\": token, \"token_type\": \"bearer\"}\n",[18,56336,56337,56348,56360,56364,56372,56376,56380,56391,56406,56415,56436,56443,56458,56470,56474,56487],{"__ignoreMap":258},[262,56338,56339,56341,56343,56345],{"class":181,"line":264},[262,56340,705],{"class":377},[262,56342,51571],{"class":429},[262,56344,684],{"class":377},[262,56346,56347],{"class":429}," FastAPI, Depends, HTTPException, status\n",[262,56349,56350,56352,56355,56357],{"class":181,"line":282},[262,56351,705],{"class":377},[262,56353,56354],{"class":429}," fastapi.security ",[262,56356,684],{"class":377},[262,56358,56359],{"class":429}," OAuth2PasswordRequestForm\n",[262,56361,56362],{"class":181,"line":295},[262,56363,583],{"emptyLinePlaceholder":582},[262,56365,56366,56368,56370],{"class":181,"line":345},[262,56367,51635],{"class":429},[262,56369,476],{"class":377},[262,56371,51640],{"class":429},[262,56373,56374],{"class":181,"line":492},[262,56375,583],{"emptyLinePlaceholder":582},[262,56377,56378],{"class":181,"line":503},[262,56379,583],{"emptyLinePlaceholder":582},[262,56381,56382,56384,56386,56389],{"class":181,"line":521},[262,56383,53718],{"class":267},[262,56385,602],{"class":429},[262,56387,56388],{"class":275},"\"\u002Flogin\"",[262,56390,660],{"class":429},[262,56392,56393,56395,56398,56401,56403],{"class":181,"line":537},[262,56394,423],{"class":377},[262,56396,56397],{"class":267}," login",[262,56399,56400],{"class":429},"(form: OAuth2PasswordRequestForm ",[262,56402,476],{"class":377},[262,56404,56405],{"class":429}," Depends()):\n",[262,56407,56408,56410,56412],{"class":181,"line":549},[262,56409,7611],{"class":429},[262,56411,476],{"class":377},[262,56413,56414],{"class":429}," fake_users.get(form.username)\n",[262,56416,56417,56419,56421,56423,56425,56427,56430,56433],{"class":181,"line":570},[262,56418,3454],{"class":377},[262,56420,2818],{"class":377},[262,56422,55205],{"class":429},[262,56424,8923],{"class":377},[262,56426,2818],{"class":377},[262,56428,56429],{"class":429}," verify_password(form.password, user[",[262,56431,56432],{"class":275},"\"hashed_password\"",[262,56434,56435],{"class":429},"]):\n",[262,56437,56438,56440],{"class":181,"line":579},[262,56439,4928],{"class":377},[262,56441,56442],{"class":429}," HTTPException(\n",[262,56444,56445,56448,56450,56453,56456],{"class":181,"line":586},[262,56446,56447],{"class":611},"            status_code",[262,56449,476],{"class":377},[262,56451,56452],{"class":429},"status.",[262,56454,56455],{"class":271},"HTTP_401_UNAUTHORIZED",[262,56457,1315],{"class":429},[262,56459,56460,56463,56465,56468],{"class":181,"line":591},[262,56461,56462],{"class":611},"            detail",[262,56464,476],{"class":377},[262,56466,56467],{"class":275},"\"Incorrect email or password\"",[262,56469,1315],{"class":429},[262,56471,56472],{"class":181,"line":623},[262,56473,6288],{"class":429},[262,56475,56476,56478,56480,56483,56485],{"class":181,"line":634},[262,56477,36180],{"class":429},[262,56479,476],{"class":377},[262,56481,56482],{"class":429}," create_access_token(user[",[262,56484,6770],{"class":275},[262,56486,3512],{"class":429},[262,56488,56489,56491,56493,56495,56498,56501,56503,56506],{"class":181,"line":845},[262,56490,573],{"class":377},[262,56492,2276],{"class":429},[262,56494,23557],{"class":275},[262,56496,56497],{"class":429},": token, ",[262,56499,56500],{"class":275},"\"token_type\"",[262,56502,1231],{"class":429},[262,56504,56505],{"class":275},"\"bearer\"",[262,56507,16430],{"class":429},[14,56509,56510],{},"Returning the same \"Incorrect email or password\" message whether the email or the password was wrong is deliberate. It stops an attacker from learning which emails are registered.",[57,56512,56514],{"id":56513},"step-4-protect-a-route-and-read-the-current-user","Step 4: Protect a route and read the current user",[14,56516,56517,56518,56521],{},"The last piece is a dependency that runs before any protected route. It pulls the token out of the ",[18,56519,56520],{},"Authorization: Bearer ..."," header, decodes it, and turns the user id back into a user. If the token is missing, expired, or forged, it raises a 401 and the route never runs.",[253,56523,56525],{"className":414,"code":56524,"language":416,"meta":258,"style":258},"from fastapi.security import OAuth2PasswordBearer\nfrom jose import JWTError\n\noauth2_scheme = OAuth2PasswordBearer(tokenUrl=\"login\")\n\n\ndef get_current_user(token: str = Depends(oauth2_scheme)) -> dict:\n    credentials_error = HTTPException(\n        status_code=status.HTTP_401_UNAUTHORIZED,\n        detail=\"Could not validate credentials\",\n        headers={\"WWW-Authenticate\": \"Bearer\"},\n    )\n    try:\n        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])\n        user_id = int(payload[\"sub\"])\n    except (JWTError, KeyError, ValueError):\n        raise credentials_error\n\n    user = next((u for u in fake_users.values() if u[\"id\"] == user_id), None)\n    if user is None:\n        raise credentials_error\n    return user\n",[18,56526,56527,56538,56549,56553,56573,56577,56581,56602,56611,56624,56636,56654,56658,56664,56688,56703,56718,56725,56729,56769,56781,56787],{"__ignoreMap":258},[262,56528,56529,56531,56533,56535],{"class":181,"line":264},[262,56530,705],{"class":377},[262,56532,56354],{"class":429},[262,56534,684],{"class":377},[262,56536,56537],{"class":429}," OAuth2PasswordBearer\n",[262,56539,56540,56542,56544,56546],{"class":181,"line":282},[262,56541,705],{"class":377},[262,56543,56215],{"class":429},[262,56545,684],{"class":377},[262,56547,56548],{"class":429}," JWTError\n",[262,56550,56551],{"class":181,"line":295},[262,56552,583],{"emptyLinePlaceholder":582},[262,56554,56555,56558,56560,56563,56566,56568,56571],{"class":181,"line":345},[262,56556,56557],{"class":429},"oauth2_scheme ",[262,56559,476],{"class":377},[262,56561,56562],{"class":429}," OAuth2PasswordBearer(",[262,56564,56565],{"class":611},"tokenUrl",[262,56567,476],{"class":377},[262,56569,56570],{"class":275},"\"login\"",[262,56572,660],{"class":429},[262,56574,56575],{"class":181,"line":492},[262,56576,583],{"emptyLinePlaceholder":582},[262,56578,56579],{"class":181,"line":503},[262,56580,583],{"emptyLinePlaceholder":582},[262,56582,56583,56585,56588,56591,56593,56595,56598,56600],{"class":181,"line":521},[262,56584,423],{"class":377},[262,56586,56587],{"class":267}," get_current_user",[262,56589,56590],{"class":429},"(token: ",[262,56592,433],{"class":271},[262,56594,442],{"class":377},[262,56596,56597],{"class":429}," Depends(oauth2_scheme)) -> ",[262,56599,5869],{"class":271},[262,56601,1160],{"class":429},[262,56603,56604,56607,56609],{"class":181,"line":537},[262,56605,56606],{"class":429},"    credentials_error ",[262,56608,476],{"class":377},[262,56610,56442],{"class":429},[262,56612,56613,56616,56618,56620,56622],{"class":181,"line":549},[262,56614,56615],{"class":611},"        status_code",[262,56617,476],{"class":377},[262,56619,56452],{"class":429},[262,56621,56455],{"class":271},[262,56623,1315],{"class":429},[262,56625,56626,56629,56631,56634],{"class":181,"line":570},[262,56627,56628],{"class":611},"        detail",[262,56630,476],{"class":377},[262,56632,56633],{"class":275},"\"Could not validate credentials\"",[262,56635,1315],{"class":429},[262,56637,56638,56640,56642,56644,56647,56649,56652],{"class":181,"line":579},[262,56639,6588],{"class":611},[262,56641,476],{"class":377},[262,56643,3039],{"class":429},[262,56645,56646],{"class":275},"\"WWW-Authenticate\"",[262,56648,1231],{"class":429},[262,56650,56651],{"class":275},"\"Bearer\"",[262,56653,3143],{"class":429},[262,56655,56656],{"class":181,"line":586},[262,56657,1011],{"class":429},[262,56659,56660,56662],{"class":181,"line":591},[262,56661,14474],{"class":377},[262,56663,1160],{"class":429},[262,56665,56666,56668,56670,56673,56675,56677,56680,56682,56684,56686],{"class":181,"line":623},[262,56667,21335],{"class":429},[262,56669,476],{"class":377},[262,56671,56672],{"class":429}," jwt.decode(token, ",[262,56674,55804],{"class":271},[262,56676,608],{"class":429},[262,56678,56679],{"class":611},"algorithms",[262,56681,476],{"class":377},[262,56683,12118],{"class":429},[262,56685,55818],{"class":271},[262,56687,3512],{"class":429},[262,56689,56690,56692,56694,56696,56699,56701],{"class":181,"line":634},[262,56691,55184],{"class":429},[262,56693,476],{"class":377},[262,56695,23813],{"class":271},[262,56697,56698],{"class":429},"(payload[",[262,56700,56278],{"class":275},[262,56702,3512],{"class":429},[262,56704,56705,56707,56710,56712,56714,56716],{"class":181,"line":845},[262,56706,14522],{"class":377},[262,56708,56709],{"class":429}," (JWTError, ",[262,56711,3897],{"class":271},[262,56713,608],{"class":429},[262,56715,16176],{"class":271},[262,56717,8192],{"class":429},[262,56719,56720,56722],{"class":181,"line":850},[262,56721,4928],{"class":377},[262,56723,56724],{"class":429}," credentials_error\n",[262,56726,56727],{"class":181,"line":864},[262,56728,583],{"emptyLinePlaceholder":582},[262,56730,56731,56733,56735,56738,56741,56743,56746,56748,56751,56753,56756,56758,56760,56762,56765,56767],{"class":181,"line":1683},[262,56732,7611],{"class":429},[262,56734,476],{"class":377},[262,56736,56737],{"class":271}," next",[262,56739,56740],{"class":429},"((u ",[262,56742,829],{"class":377},[262,56744,56745],{"class":429}," u ",[262,56747,835],{"class":377},[262,56749,56750],{"class":429}," fake_users.values() ",[262,56752,2210],{"class":377},[262,56754,56755],{"class":429}," u[",[262,56757,6770],{"class":275},[262,56759,2903],{"class":429},[262,56761,10758],{"class":377},[262,56763,56764],{"class":429}," user_id), ",[262,56766,8471],{"class":271},[262,56768,660],{"class":429},[262,56770,56771,56773,56775,56777,56779],{"class":181,"line":1688},[262,56772,3454],{"class":377},[262,56774,55205],{"class":429},[262,56776,20596],{"class":377},[262,56778,20599],{"class":271},[262,56780,1160],{"class":429},[262,56782,56783,56785],{"class":181,"line":1693},[262,56784,4928],{"class":377},[262,56786,56724],{"class":429},[262,56788,56789,56791],{"class":181,"line":1728},[262,56790,573],{"class":377},[262,56792,56140],{"class":429},[14,56794,56795,56796,56799,56800,56802],{},"Any route that adds ",[18,56797,56798],{},"current_user = Depends(get_current_user)"," is now locked. Here is a public route, a ",[18,56801,55607],{}," route that reads the logged-in user, and a protected AI endpoint that only runs for authenticated callers.",[253,56804,56806],{"className":414,"code":56805,"language":416,"meta":258,"style":258},"@app.get(\"\u002F\")\ndef public_home():\n    return {\"message\": \"Anyone can see this.\"}\n\n\n@app.get(\"\u002Fme\")\ndef read_me(current_user: dict = Depends(get_current_user)):\n    return {\"id\": current_user[\"id\"], \"email\": current_user[\"email\"]}\n\n\n@app.post(\"\u002Fgenerate\")\ndef generate(prompt: str, current_user: dict = Depends(get_current_user)):\n    # current_user is guaranteed here, so you know who to bill.\n    return {\"user\": current_user[\"email\"], \"result\": f\"AI output for: {prompt}\"}\n",[18,56807,56808,56819,56829,56845,56849,56853,56864,56881,56905,56909,56913,56924,56944,56949],{"__ignoreMap":258},[262,56809,56810,56812,56814,56817],{"class":181,"line":264},[262,56811,51830],{"class":267},[262,56813,602],{"class":429},[262,56815,56816],{"class":275},"\"\u002F\"",[262,56818,660],{"class":429},[262,56820,56821,56823,56826],{"class":181,"line":282},[262,56822,423],{"class":377},[262,56824,56825],{"class":267}," public_home",[262,56827,56828],{"class":429},"():\n",[262,56830,56831,56833,56835,56838,56840,56843],{"class":181,"line":295},[262,56832,573],{"class":377},[262,56834,2276],{"class":429},[262,56836,56837],{"class":275},"\"message\"",[262,56839,1231],{"class":429},[262,56841,56842],{"class":275},"\"Anyone can see this.\"",[262,56844,16430],{"class":429},[262,56846,56847],{"class":181,"line":345},[262,56848,583],{"emptyLinePlaceholder":582},[262,56850,56851],{"class":181,"line":492},[262,56852,583],{"emptyLinePlaceholder":582},[262,56854,56855,56857,56859,56862],{"class":181,"line":503},[262,56856,51830],{"class":267},[262,56858,602],{"class":429},[262,56860,56861],{"class":275},"\"\u002Fme\"",[262,56863,660],{"class":429},[262,56865,56866,56868,56871,56874,56876,56878],{"class":181,"line":521},[262,56867,423],{"class":377},[262,56869,56870],{"class":267}," read_me",[262,56872,56873],{"class":429},"(current_user: ",[262,56875,5869],{"class":271},[262,56877,442],{"class":377},[262,56879,56880],{"class":429}," Depends(get_current_user)):\n",[262,56882,56883,56885,56887,56889,56892,56894,56896,56898,56900,56902],{"class":181,"line":537},[262,56884,573],{"class":377},[262,56886,2276],{"class":429},[262,56888,6770],{"class":275},[262,56890,56891],{"class":429},": current_user[",[262,56893,6770],{"class":275},[262,56895,1103],{"class":429},[262,56897,37895],{"class":275},[262,56899,56891],{"class":429},[262,56901,37895],{"class":275},[262,56903,56904],{"class":429},"]}\n",[262,56906,56907],{"class":181,"line":549},[262,56908,583],{"emptyLinePlaceholder":582},[262,56910,56911],{"class":181,"line":570},[262,56912,583],{"emptyLinePlaceholder":582},[262,56914,56915,56917,56919,56922],{"class":181,"line":579},[262,56916,53718],{"class":267},[262,56918,602],{"class":429},[262,56920,56921],{"class":275},"\"\u002Fgenerate\"",[262,56923,660],{"class":429},[262,56925,56926,56928,56931,56933,56935,56938,56940,56942],{"class":181,"line":586},[262,56927,423],{"class":377},[262,56929,56930],{"class":267}," generate",[262,56932,9599],{"class":429},[262,56934,433],{"class":271},[262,56936,56937],{"class":429},", current_user: ",[262,56939,5869],{"class":271},[262,56941,442],{"class":377},[262,56943,56880],{"class":429},[262,56945,56946],{"class":181,"line":591},[262,56947,56948],{"class":291},"    # current_user is guaranteed here, so you know who to bill.\n",[262,56950,56951,56953,56955,56957,56959,56961,56963,56966,56968,56970,56973,56975,56977,56979,56981],{"class":181,"line":623},[262,56952,573],{"class":377},[262,56954,2276],{"class":429},[262,56956,1291],{"class":275},[262,56958,56891],{"class":429},[262,56960,37895],{"class":275},[262,56962,1103],{"class":429},[262,56964,56965],{"class":275},"\"result\"",[262,56967,1231],{"class":429},[262,56969,642],{"class":377},[262,56971,56972],{"class":275},"\"AI output for: ",[262,56974,3039],{"class":271},[262,56976,9496],{"class":429},[262,56978,654],{"class":271},[262,56980,1176],{"class":275},[262,56982,16430],{"class":429},[14,56984,13310,56985,56988,56989,56991,56992,56995,56996,31800,56999,57002,57003,1374,57005,57008],{},[18,56986,56987],{},"uvicorn main:app --reload",", open ",[18,56990,53929],{},", click ",[35,56993,56994],{},"Authorize",", and log in with ",[18,56997,56998],{},"founder@example.com",[18,57000,57001],{},"supersecret123",". The ",[18,57004,55607],{},[18,57006,57007],{},"\u002Fgenerate"," routes now work; calling them without a token returns 401.",[57,57010,24067],{"id":24066},[1379,57012,57013,57025],{},[1382,57014,57015],{},[1385,57016,57017,57019,57021,57023],{},[1388,57018,1390],{},[1388,57020,3795],{},[1388,57022,3798],{},[1388,57024,1396],{},[1398,57026,57027,57042,57061,57078],{},[1385,57028,57029,57033,57035,57039],{},[1403,57030,57031],{},[18,57032,55837],{},[1403,57034,439],{},[1403,57036,57037],{},[18,57038,9777],{},[1403,57040,57041],{},"How long a token stays valid before the user must log in again.",[1385,57043,57044,57048,57050,57054],{},[1403,57045,57046],{},[18,57047,55818],{},[1403,57049,433],{},[1403,57051,57052],{},[18,57053,55830],{},[1403,57055,57056,57057,57060],{},"Signing algorithm; ",[18,57058,57059],{},"HS256"," uses one shared secret and suits a single server.",[1385,57062,57063,57068,57070,57075],{},[1403,57064,57065,57067],{},[18,57066,55910],{}," (CryptContext)",[1403,57069,2801],{},[1403,57071,57072],{},[18,57073,57074],{},"[\"bcrypt\"]",[1403,57076,57077],{},"Hashing algorithm for passwords; bcrypt is the safe default.",[1385,57079,57080,57085,57087,57091],{},[1403,57081,57082,57084],{},[18,57083,56565],{}," (OAuth2PasswordBearer)",[1403,57086,433],{},[1403,57088,57089],{},[18,57090,56570],{},[1403,57092,57093],{},"The path FastAPI's docs page posts credentials to when you click Authorize.",[57,57095,1445],{"id":1444},[1447,57097,57098,57113,57130,57142],{},[1450,57099,57100,57106,57107,57109,57110,57112],{},[35,57101,57102,57105],{},[18,57103,57104],{},"401 Could not validate credentials"," right after login."," The token expired or the secret changed. Check that ",[18,57108,55837],{}," is not tiny and that ",[18,57111,55804],{}," is identical between issuing and decoding. Restarting with a different secret invalidates every existing token.",[1450,57114,57115,57120,57121,57123,57124,57126,57127,1363],{},[35,57116,57117,1363],{},[18,57118,57119],{},"AttributeError: module 'bcrypt' has no attribute '__about__'"," This comes from a version mismatch between ",[18,57122,55672],{}," and a newer ",[18,57125,55676],{},". Pin them: ",[18,57128,57129],{},"pip install \"passlib[bcrypt]\" \"bcrypt\u003C4.1\"",[1450,57131,57132,57137,57138,57141],{},[35,57133,57134,1363],{},[18,57135,57136],{},"Form data requires \"python-multipart\""," The login route reads form fields, so install the package: ",[18,57139,57140],{},"pip install python-multipart",", then restart uvicorn.",[1450,57143,57144,10934,57150,57152,57153,44450],{},[35,57145,57146,57149],{},[18,57147,57148],{},"KeyError: 'JWT_SECRET'"," at startup.",[18,57151,319],{}," is missing or not loaded. Confirm the file sits next to where you run the app and that ",[18,57154,8439],{},[57,57156,2317],{"id":2316},[2322,57158,57159,57165,57171],{},[1450,57160,57161,57164],{},[35,57162,57163],{},"JWT access tokens (this guide):"," Best for APIs and AI SaaS backends where clients call you with a bearer token. They are stateless, so any server can verify a request without a shared session store, which scales cleanly. Use this when you control the client and serve JSON.",[1450,57166,57167,57170],{},[35,57168,57169],{},"Server-side sessions with a cookie:"," Better for a classic server-rendered website where the browser holds a session cookie and the server keeps session state. They are trivial to revoke instantly, but they need a shared session store once you run more than one server, which adds infrastructure.",[1450,57172,57173,57176],{},[35,57174,57175],{},"A managed auth provider (Auth0, Clerk, Supabase Auth):"," Best when you need social login, password resets, and multi-factor auth without building them. You trade a monthly cost and an external dependency for not maintaining auth code yourself. Reach for this once auth becomes a distraction from your actual product.",[57,57178,2355],{"id":2354},[14,57180,57181,57182,57184,57185,28880,57187,1363],{},"With auth in place, add the two guardrails that protect your margin: meter each user with ",[51,57183,49599],{"href":49598}," so no one runs up your bill, then charge them with ",[51,57186,54121],{"href":54120},[51,57188,39690],{"href":39689},[57,57190,2381],{"id":2380},[2322,57192,57193,57198,57203,57208],{},[1450,57194,57195,57197],{},[51,57196,39690],{"href":39689}," — the main guide this builds on.",[1450,57199,57200,57202],{},[51,57201,54121],{"href":54120}," — charge your authenticated users.",[1450,57204,57205,57207],{},[51,57206,49599],{"href":49598}," — cap usage per logged-in user.",[1450,57209,57210,57212],{},[51,57211,26457],{"href":26456}," — the top-level track for shipping AI products.",[2401,57214,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":57216},[57217,57218,57220,57221,57222,57223,57224,57225,57226,57227],{"id":237,"depth":282,"text":238},{"id":55688,"depth":282,"text":57219},"Step 1: Store a JWT secret in .env",{"id":55866,"depth":282,"text":55867},{"id":56177,"depth":282,"text":56178},{"id":56513,"depth":282,"text":56514},{"id":24066,"depth":282,"text":24067},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Add login and JWT auth to a FastAPI AI app: hash passwords with bcrypt, issue access tokens, protect a route, and read the current user. Runnable Python.",[57230,57233,57236,57239,57242],{"q":57231,"a":57232},"How do I add login to a FastAPI app?","Store each user's email and a bcrypt password hash, then expose a \u002Flogin route that checks the password and returns a signed JWT access token. Clients send that token on every later request, and a dependency decodes it to identify the user.",{"q":57234,"a":57235},"Should I store passwords or password hashes?","Never store the raw password. Store only a one-way bcrypt hash created with passlib. When a user logs in you hash the submitted password and compare it to the stored hash, so a database leak never exposes real passwords.",{"q":57237,"a":57238},"What is a JWT access token?","A JWT (JSON Web Token) is a signed string that carries the user's identity and an expiry time. Your server signs it with a secret key, so it can later verify the token is genuine without a database lookup, which makes it fast for protecting API routes.",{"q":57240,"a":57241},"Where do I store the JWT secret key?","Put it in a .env file as a long random string and load it at startup, never hard-code it in your source. Anyone who knows the secret can forge valid tokens, so keep .env out of Git and rotate the key if it leaks.",{"q":57243,"a":57244},"How long should a JWT access token last?","Short, usually 15 to 60 minutes. A short expiry limits damage if a token is stolen. Pair it with a longer-lived refresh token once your app grows, but a single short access token is fine for an MVP.",{"name":57246,"steps":57247},"How to add user authentication to a Python AI app",[57248,57251,57254,57257],{"name":57249,"text":57250},"Install dependencies and set secrets","Install FastAPI, passlib with bcrypt, and python-jose, then store a JWT secret key in a .env file ignored by Git.",{"name":57252,"text":57253},"Hash and verify passwords","Use passlib to hash new passwords with bcrypt and to verify a submitted password against the stored hash at login.",{"name":57255,"text":57256},"Issue a JWT access token on login","On a successful password check, sign a JWT containing the user's id and an expiry time using your secret key.",{"name":57258,"text":57259},"Protect a route and read the current user","Add a FastAPI dependency that decodes the token, rejects invalid ones, and hands the route the authenticated user.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-user-authentication-to-a-python-ai-app",{"title":54116,"description":57228},"building-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Fadd-user-authentication-to-a-python-ai-app\u002Findex","HCZmslyAXMF9P_roZ8n860JB0CMfkxTsVE8pAFQpYN0",{"id":57266,"title":57267,"body":57268,"description":59571,"extension":2419,"faq":59572,"howto":59588,"meta":59603,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":59604,"published":50661,"seo":59605,"seoTitle":59606,"stem":59607,"__hash__":59608},"content\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Findex.md","SaaS MVP with Python & AI: A Step-by-Step Build Guide",{"type":7,"value":57269,"toc":59558},[57270,57273,57276,57292,57296,57299,57302,57392,57394,57402,57405,57443,57456,57469,57476,57490,57496,57500,57516,57715,57745,57748,57752,57757,57764,57962,57969,57979,57983,57986,57991,58126,58129,58143,58147,58160,58339,58344,58354,58369,58371,58377,58487,58489,58492,58631,58635,58646,59464,59488,59498,59500,59503,59528,59532,59534,59556],[10,57271,57267],{"id":57272},"saas-mvp-with-python-ai-a-step-by-step-build-guide",[14,57274,57275],{},"You have an idea for a product where users type something in, an AI model does the hard part, and they pay you for it. The gap between that idea and a working, billable web service is smaller than it looks, but it has a few sharp edges: you have to know which user is calling, stop one person from running up a bill that wipes out your margin, and record enough about each call to charge for it later. This guide walks you through a minimum viable product (MVP) for exactly that, using Python.",[14,57277,57278,57279,57282,57283,57285,57286,57288,57289,57291],{},"An MVP is the smallest version of your product that real users can pay for. For an AI SaaS, that means one endpoint that takes a request, checks who is asking, makes sure they are allowed to ask, runs the model, and returns a result you can bill. We will build that endpoint with ",[51,57280,55596],{"href":55594,"rel":57281},[6509]," (a modern Python web framework that validates requests for you), the ",[18,57284,20],{}," SDK, and ",[18,57287,5450],{}," for any other outbound calls. By the end you will have a runnable service and a clear map of what to add next. This guide sits under ",[51,57290,26457],{"href":26456},", the main guide for turning AI features into products.",[57,57293,57295],{"id":57294},"who-this-is-for-and-what-you-are-building","Who this is for and what you are building",[14,57297,57298],{},"This is for founders, indie hackers, and creators who can read Python but have never shipped a paid web service. You do not need a front end, a payment processor account, or a cloud provider to follow along. You need Python on your machine and an OpenAI key.",[14,57300,57301],{},"The mental model matters more than any single line of code. Every request to an AI SaaS travels the same path: it arrives, you confirm the caller is a real user (authentication), you confirm they have requests left (rate limiting), you do the expensive AI work, and you record what it cost so you can charge for it (billing). If any link in that chain is missing, you either leak money or you cannot collect it. A service with no auth lets anyone burn your OpenAI credit; one with auth but no rate limit lets a single buggy client loop forever and hand you a four-figure bill overnight; one that does both but records nothing has no way to send an invoice. The diagram below shows the full lifecycle so you can hold it in your head while you read.",[76,57303,57305,57389],{"className":57304},[79],[81,57306,90,57309,90,57312,90,57315,90,57317,90,57321,90,57324,90,57326,90,57329,90,57332,90,57334,90,57337,90,57340,90,57342,90,57345,90,57347,90,57349,90,57352,90,57356,90,57359,90,57362,90,57365,90,57369,90,57371,90,57373,90,57376,90,57379,90,57382],{"viewBox":57307,"role":84,"ariaLabelledBy":57308,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1140 480",[7091,7092],[92,57310,57311],{"id":7091},"AI SaaS request lifecycle",[96,57313,57314],{"id":7092},"A request flows through authentication, rate limiting, the language model call, and usage recording for billing, with a rejection path back to the client.",[100,57316],{"x":102,"y":52289,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,57318,57320],{"x":113,"y":57319,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"192","Client",[111,57322,57323],{"x":113,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"request + key",[100,57325],{"x":129,"y":52289,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,57327,57328],{"x":133,"y":57319,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Auth",[111,57330,57331],{"x":133,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"who is this?",[100,57333],{"x":158,"y":52289,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,57335,57336],{"x":161,"y":57319,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Rate limit",[111,57338,57339],{"x":161,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"any left?",[100,57341],{"x":168,"y":52289,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,57343,57344],{"x":172,"y":57319,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"LLM call",[111,57346,7124],{"x":172,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,57348],{"x":168,"y":7154,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},[111,57350,57351],{"x":172,"y":19914,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Record usage",[111,57353,57355],{"x":172,"y":57354,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"370","for billing",[100,57357],{"x":57358,"y":140,"width":104,"height":105,"rx":106,"fill":142,"stroke":169,"strokeWidth":109},"360",[111,57360,57361],{"x":228,"y":147,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Reject: 401 \u002F 429",[111,57363,57364],{"x":228,"y":19872,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"auth or rate limit",[181,57366],{"x1":104,"y1":57367,"x2":184,"y2":57367,"stroke":125,"strokeWidth":109,"markerEnd":57368},"196","url(#arrowSaas)",[181,57370],{"x1":198,"y1":57367,"x2":199,"y2":57367,"stroke":125,"strokeWidth":109,"markerEnd":57368},[181,57372],{"x1":205,"y1":57367,"x2":206,"y2":57367,"stroke":125,"strokeWidth":109,"markerEnd":57368},[181,57374],{"x1":172,"y1":7146,"x2":172,"y2":57375,"stroke":125,"strokeWidth":109,"markerEnd":57368},"318",[181,57377],{"x1":57358,"y1":52289,"x2":48091,"y2":52339,"stroke":169,"strokeWidth":109,"strokeDashArray":57378,"markerEnd":57368},[222,19848],[181,57380],{"x1":12825,"y1":52289,"x2":16427,"y2":52339,"stroke":169,"strokeWidth":109,"strokeDashArray":57381,"markerEnd":57368},[222,19848],[5548,57383,5550,57384,90],{},[5552,57385,5558,57387,5550],{"id":57386,"markerWidth":7162,"markerHeight":7162,"refX":7163,"refY":52352,"orient":5557},"arrowSaas",[216,57388],{"d":52355,"fill":125},[232,57390,57391],{},"Every paid AI request passes auth and a rate-limit check before the model runs, and successful calls record usage so you can bill.",[57,57393,238],{"id":237},[14,57395,39793,57396,57398,57399,57401],{},[18,57397,17782],{},". If you have never set up an isolated Python workspace, read ",[51,57400,2482],{"href":2481}," first, then come back.",[14,57403,57404],{},"Create and activate a virtual environment, then install the four packages this guide uses:",[253,57406,57408],{"className":255,"code":57407,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\npip install \"fastapi>=0.110\" \"uvicorn[standard]>=0.29\" \"openai>=1.30\" \"httpx>=0.27\"\n",[18,57409,57410,57420,57428],{"__ignoreMap":258},[262,57411,57412,57414,57416,57418],{"class":181,"line":264},[262,57413,416],{"class":267},[262,57415,272],{"class":271},[262,57417,276],{"class":275},[262,57419,279],{"class":275},[262,57421,57422,57424,57426],{"class":181,"line":282},[262,57423,285],{"class":271},[262,57425,288],{"class":275},[262,57427,7222],{"class":291},[262,57429,57430,57432,57434,57436,57438,57440],{"class":181,"line":295},[262,57431,298],{"class":267},[262,57433,301],{"class":275},[262,57435,50746],{"class":275},[262,57437,52480],{"class":275},[262,57439,52483],{"class":275},[262,57441,57442],{"class":275}," \"httpx>=0.27\"\n",[14,57444,57445,57447,57448,57450,57451,2498,57453,57455],{},[18,57446,52506],{}," is the web framework, ",[18,57449,52514],{}," runs it, ",[18,57452,20],{},[18,57454,5450],{}," handles any other HTTP call you make later. Now store your secret key. Never paste an API key directly into your code, because anything in your code can end up in your Git history where it is hard to remove.",[253,57457,57459],{"className":323,"code":57458,"language":325,"meta":258,"style":258},"# .env\nOPENAI_API_KEY=sk-your-real-key-here\n",[18,57460,57461,57465],{"__ignoreMap":258},[262,57462,57463],{"class":181,"line":264},[262,57464,332],{},[262,57466,57467],{"class":181,"line":282},[262,57468,337],{},[14,57470,353,57471,356,57473,57475],{},[18,57472,319],{},[18,57474,359],{}," immediately so the file with your key is never committed:",[253,57477,57478],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,57479,57480],{"__ignoreMap":258},[262,57481,57482,57484,57486,57488],{"class":181,"line":264},[262,57483,371],{"class":271},[262,57485,374],{"class":275},[262,57487,378],{"class":377},[262,57489,381],{"class":275},[14,57491,57492,57493,57495],{},"If you are unsure how API keys and model calls work at all, the ",[51,57494,2487],{"href":2486}," section explains the basics before you wire them into a service.",[57,57497,57499],{"id":57498},"step-1-stand-up-a-fastapi-service-that-reads-your-key","Step 1: Stand up a FastAPI service that reads your key",[14,57501,57502,57503,57505,57506,57508,57509,57511,57512,57515],{},"Start with the smallest possible service that loads your key and answers a health check. The ",[18,57504,20],{}," SDK reads ",[18,57507,21742],{}," from the environment automatically, so you only need to load the ",[18,57510,319],{}," file into the environment first. We will use ",[18,57513,57514],{},"os.environ"," and a tiny loader rather than an extra dependency.",[253,57517,57519],{"className":414,"code":57518,"language":416,"meta":258,"style":258},"# main.py\nimport os\nfrom pathlib import Path\n\nfrom fastapi import FastAPI\n\n# Minimal .env loader: read KEY=VALUE lines into the environment.\nfor line in Path(\".env\").read_text().splitlines():\n    if line and not line.startswith(\"#\") and \"=\" in line:\n        key, value = line.split(\"=\", 1)\n        os.environ.setdefault(key.strip(), value.strip())\n\napp = FastAPI(title=\"AI SaaS MVP\")\n\n\n@app.get(\"\u002Fhealth\")\ndef health() -> dict:\n    has_key = bool(os.environ.get(\"OPENAI_API_KEY\"))\n    return {\"status\": \"ok\", \"openai_key_loaded\": has_key}\n",[18,57520,57521,57526,57532,57542,57546,57556,57560,57565,57580,57604,57621,57626,57630,57647,57651,57655,57666,57679,57695],{"__ignoreMap":258},[262,57522,57523],{"class":181,"line":264},[262,57524,57525],{"class":291},"# main.py\n",[262,57527,57528,57530],{"class":181,"line":282},[262,57529,684],{"class":377},[262,57531,687],{"class":429},[262,57533,57534,57536,57538,57540],{"class":181,"line":295},[262,57535,705],{"class":377},[262,57537,4882],{"class":429},[262,57539,684],{"class":377},[262,57541,4887],{"class":429},[262,57543,57544],{"class":181,"line":345},[262,57545,583],{"emptyLinePlaceholder":582},[262,57547,57548,57550,57552,57554],{"class":181,"line":492},[262,57549,705],{"class":377},[262,57551,51571],{"class":429},[262,57553,684],{"class":377},[262,57555,51576],{"class":429},[262,57557,57558],{"class":181,"line":503},[262,57559,583],{"emptyLinePlaceholder":582},[262,57561,57562],{"class":181,"line":521},[262,57563,57564],{"class":291},"# Minimal .env loader: read KEY=VALUE lines into the environment.\n",[262,57566,57567,57569,57571,57573,57575,57578],{"class":181,"line":537},[262,57568,829],{"class":377},[262,57570,54383],{"class":429},[262,57572,835],{"class":377},[262,57574,4986],{"class":429},[262,57576,57577],{"class":275},"\".env\"",[262,57579,54401],{"class":429},[262,57581,57582,57584,57586,57588,57590,57592,57594,57596,57598,57600,57602],{"class":181,"line":549},[262,57583,3454],{"class":377},[262,57585,54383],{"class":429},[262,57587,6101],{"class":377},[262,57589,2818],{"class":377},[262,57591,54414],{"class":429},[262,57593,54417],{"class":275},[262,57595,1000],{"class":429},[262,57597,6101],{"class":377},[262,57599,54424],{"class":275},[262,57601,2821],{"class":377},[262,57603,54429],{"class":429},[262,57605,57606,57609,57611,57613,57615,57617,57619],{"class":181,"line":570},[262,57607,57608],{"class":429},"        key, value ",[262,57610,476],{"class":377},[262,57612,54439],{"class":429},[262,57614,54442],{"class":275},[262,57616,608],{"class":429},[262,57618,997],{"class":271},[262,57620,660],{"class":429},[262,57622,57623],{"class":181,"line":579},[262,57624,57625],{"class":429},"        os.environ.setdefault(key.strip(), value.strip())\n",[262,57627,57628],{"class":181,"line":586},[262,57629,583],{"emptyLinePlaceholder":582},[262,57631,57632,57634,57636,57638,57640,57642,57645],{"class":181,"line":591},[262,57633,51635],{"class":429},[262,57635,476],{"class":377},[262,57637,53652],{"class":429},[262,57639,92],{"class":611},[262,57641,476],{"class":377},[262,57643,57644],{"class":275},"\"AI SaaS MVP\"",[262,57646,660],{"class":429},[262,57648,57649],{"class":181,"line":623},[262,57650,583],{"emptyLinePlaceholder":582},[262,57652,57653],{"class":181,"line":634},[262,57654,583],{"emptyLinePlaceholder":582},[262,57656,57657,57659,57661,57664],{"class":181,"line":845},[262,57658,51830],{"class":267},[262,57660,602],{"class":429},[262,57662,57663],{"class":275},"\"\u002Fhealth\"",[262,57665,660],{"class":429},[262,57667,57668,57670,57673,57675,57677],{"class":181,"line":850},[262,57669,423],{"class":377},[262,57671,57672],{"class":267}," health",[262,57674,15481],{"class":429},[262,57676,5869],{"class":271},[262,57678,1160],{"class":429},[262,57680,57681,57684,57686,57688,57691,57693],{"class":181,"line":864},[262,57682,57683],{"class":429},"    has_key ",[262,57685,476],{"class":377},[262,57687,8963],{"class":271},[262,57689,57690],{"class":429},"(os.environ.get(",[262,57692,2681],{"class":275},[262,57694,2684],{"class":429},[262,57696,57697,57699,57701,57703,57705,57707,57709,57712],{"class":181,"line":1683},[262,57698,573],{"class":377},[262,57700,2276],{"class":429},[262,57702,10324],{"class":275},[262,57704,1231],{"class":429},[262,57706,10329],{"class":275},[262,57708,608],{"class":429},[262,57710,57711],{"class":275},"\"openai_key_loaded\"",[262,57713,57714],{"class":429},": has_key}\n",[14,57716,13310,57717,57719,57720,57723,57724,57727,57728,57731,57732,57734,57735,57738,57739,57741,57742,57744],{},[18,57718,56987],{}," and open ",[18,57721,57722],{},"http:\u002F\u002F127.0.0.1:8000\u002Fhealth",". You should see ",[18,57725,57726],{},"\"openai_key_loaded\": true",". If it says ",[18,57729,57730],{},"false",", your ",[18,57733,319],{}," file is not where the script is looking: the loader reads ",[18,57736,57737],{},"Path(\".env\")",", which is resolved relative to the directory you launched ",[18,57740,52514],{}," from, not the directory the file lives in. Start the server from the project root, or pass an absolute path. FastAPI also gives you free interactive docs at ",[18,57743,53929],{},", which is where you will test the AI endpoint in the next steps.",[14,57746,57747],{},"A health check is more than a convenience: your hosting platform pings an endpoint like this to decide whether your process is alive and should keep receiving traffic. Keep it cheap and dependency-free, so a temporary OpenAI outage does not make the platform think your whole service is down and restart it in a loop.",[57,57749,57751],{"id":57750},"step-2-authenticate-every-request","Step 2: Authenticate every request",[14,57753,57754,57755,1363],{},"Authentication is just answering \"who is this caller?\" before you do any work. For an MVP, the simplest reliable scheme is an API key per user: you hand each customer a secret string, they send it on every request, and you look it up. We will keep the user table in a Python dict for now; swapping it for a real database is a later step covered in ",[51,57756,54116],{"href":54115},[14,57758,57759,57760,57763],{},"FastAPI's dependency system lets you attach this check to any endpoint with one line. The caller sends their key in an ",[18,57761,57762],{},"X-API-Key"," header, and the dependency turns that key into a user record or rejects the request with HTTP 401.",[253,57765,57767],{"className":414,"code":57766,"language":416,"meta":258,"style":258},"# auth.py\nfrom fastapi import Header, HTTPException\n\n# In a real app this lives in a database. Each user has a plan limit.\nUSERS = {\n    \"key_free_abc\": {\"id\": \"u_1\", \"plan\": \"free\", \"monthly_limit\": 20},\n    \"key_pro_xyz\": {\"id\": \"u_2\", \"plan\": \"pro\", \"monthly_limit\": 5000},\n}\n\n\ndef current_user(x_api_key: str = Header(...)) -> dict:\n    user = USERS.get(x_api_key)\n    if user is None:\n        raise HTTPException(status_code=401, detail=\"Invalid or missing API key\")\n    return user\n",[18,57768,57769,57774,57785,57789,57794,57803,57837,57871,57875,57879,57883,57910,57921,57933,57956],{"__ignoreMap":258},[262,57770,57771],{"class":181,"line":264},[262,57772,57773],{"class":291},"# auth.py\n",[262,57775,57776,57778,57780,57782],{"class":181,"line":282},[262,57777,705],{"class":377},[262,57779,51571],{"class":429},[262,57781,684],{"class":377},[262,57783,57784],{"class":429}," Header, HTTPException\n",[262,57786,57787],{"class":181,"line":295},[262,57788,583],{"emptyLinePlaceholder":582},[262,57790,57791],{"class":181,"line":345},[262,57792,57793],{"class":291},"# In a real app this lives in a database. Each user has a plan limit.\n",[262,57795,57796,57799,57801],{"class":181,"line":492},[262,57797,57798],{"class":271},"USERS",[262,57800,442],{"class":377},[262,57802,20437],{"class":429},[262,57804,57805,57808,57810,57812,57814,57817,57819,57821,57823,57826,57828,57831,57833,57835],{"class":181,"line":503},[262,57806,57807],{"class":275},"    \"key_free_abc\"",[262,57809,20445],{"class":429},[262,57811,6770],{"class":275},[262,57813,1231],{"class":429},[262,57815,57816],{"class":275},"\"u_1\"",[262,57818,608],{"class":429},[262,57820,55236],{"class":275},[262,57822,1231],{"class":429},[262,57824,57825],{"class":275},"\"free\"",[262,57827,608],{"class":429},[262,57829,57830],{"class":275},"\"monthly_limit\"",[262,57832,1231],{"class":429},[262,57834,140],{"class":271},[262,57836,3143],{"class":429},[262,57838,57839,57842,57844,57846,57848,57851,57853,57855,57857,57860,57862,57864,57866,57869],{"class":181,"line":521},[262,57840,57841],{"class":275},"    \"key_pro_xyz\"",[262,57843,20445],{"class":429},[262,57845,6770],{"class":275},[262,57847,1231],{"class":429},[262,57849,57850],{"class":275},"\"u_2\"",[262,57852,608],{"class":429},[262,57854,55236],{"class":275},[262,57856,1231],{"class":429},[262,57858,57859],{"class":275},"\"pro\"",[262,57861,608],{"class":429},[262,57863,57830],{"class":275},[262,57865,1231],{"class":429},[262,57867,57868],{"class":271},"5000",[262,57870,3143],{"class":429},[262,57872,57873],{"class":181,"line":537},[262,57874,16430],{"class":429},[262,57876,57877],{"class":181,"line":549},[262,57878,583],{"emptyLinePlaceholder":582},[262,57880,57881],{"class":181,"line":570},[262,57882,583],{"emptyLinePlaceholder":582},[262,57884,57885,57887,57890,57893,57895,57897,57900,57903,57906,57908],{"class":181,"line":579},[262,57886,423],{"class":377},[262,57888,57889],{"class":267}," current_user",[262,57891,57892],{"class":429},"(x_api_key: ",[262,57894,433],{"class":271},[262,57896,442],{"class":377},[262,57898,57899],{"class":429}," Header(",[262,57901,57902],{"class":271},"...",[262,57904,57905],{"class":429},")) -> ",[262,57907,5869],{"class":271},[262,57909,1160],{"class":429},[262,57911,57912,57914,57916,57918],{"class":181,"line":586},[262,57913,7611],{"class":429},[262,57915,476],{"class":377},[262,57917,55012],{"class":271},[262,57919,57920],{"class":429},".get(x_api_key)\n",[262,57922,57923,57925,57927,57929,57931],{"class":181,"line":591},[262,57924,3454],{"class":377},[262,57926,55205],{"class":429},[262,57928,20596],{"class":377},[262,57930,20599],{"class":271},[262,57932,1160],{"class":429},[262,57934,57935,57937,57939,57941,57943,57945,57947,57949,57951,57954],{"class":181,"line":623},[262,57936,4928],{"class":377},[262,57938,53750],{"class":429},[262,57940,53753],{"class":611},[262,57942,476],{"class":377},[262,57944,41445],{"class":271},[262,57946,608],{"class":429},[262,57948,53763],{"class":611},[262,57950,476],{"class":377},[262,57952,57953],{"class":275},"\"Invalid or missing API key\"",[262,57955,660],{"class":429},[262,57957,57958,57960],{"class":181,"line":634},[262,57959,573],{"class":377},[262,57961,56140],{"class":429},[14,57963,57964,57965,57968],{},"Any endpoint that adds ",[18,57966,57967],{},"user: dict = Depends(current_user)"," to its signature is now protected: unauthenticated calls never reach your model. This is the same idea as a login, just expressed as a key instead of a username and password, which suits machine-to-machine SaaS APIs well.",[14,57970,57971,57972,57975,57976,57978],{},"Two details separate a toy version of this from one you can ship. First, store keys hashed, not in plain text: if your user table leaks, plain-text keys hand an attacker every customer's account, whereas a hash reveals nothing usable. Keep a short non-secret prefix in plain text to look the row up quickly, then verify the rest against the hash. Second, compare keys in constant time with Python's ",[18,57973,57974],{},"secrets.compare_digest",", which avoids the timing leaks a naive ",[18,57977,10758],{}," on secrets can introduce. Neither matters for a localhost demo, but both are cheap to add before your first real customer.",[57,57980,57982],{"id":57981},"step-3-rate-limit-and-meter-usage","Step 3: Rate-limit and meter usage",[14,57984,57985],{},"This is the step that protects your bank account. Before you call the model, you check how many requests the user has made this period against their plan limit. If they are over, you reject with HTTP 429 (the standard \"too many requests\" status) and never touch the paid API. If they are under, you let the call through and increment their counter only after it succeeds, so failed calls do not eat someone's quota.",[14,57987,57988,57989,1363],{},"For an MVP, an in-memory counter is fine. The catch, which trips up almost everyone, is that an in-memory dict resets when the process restarts and is not shared across multiple workers. Once you run more than one process you must move this state to a shared store like Redis, which is the focus of ",[51,57990,49599],{"href":49598},[253,57992,57994],{"className":414,"code":57993,"language":416,"meta":258,"style":258},"# usage.py\nfrom collections import defaultdict\n\n# user_id -> number of AI calls this period. Reset monthly in production.\n_usage: dict[str, int] = defaultdict(int)\n\n\ndef remaining(user: dict) -> int:\n    return user[\"monthly_limit\"] - _usage[user[\"id\"]]\n\n\ndef record_call(user: dict) -> None:\n    _usage[user[\"id\"]] += 1\n",[18,57995,57996,58001,58013,58017,58022,58044,58048,58052,58069,58088,58092,58096,58113],{"__ignoreMap":258},[262,57997,57998],{"class":181,"line":264},[262,57999,58000],{"class":291},"# usage.py\n",[262,58002,58003,58005,58008,58010],{"class":181,"line":282},[262,58004,705],{"class":377},[262,58006,58007],{"class":429}," collections ",[262,58009,684],{"class":377},[262,58011,58012],{"class":429}," defaultdict\n",[262,58014,58015],{"class":181,"line":295},[262,58016,583],{"emptyLinePlaceholder":582},[262,58018,58019],{"class":181,"line":345},[262,58020,58021],{"class":291},"# user_id -> number of AI calls this period. Reset monthly in production.\n",[262,58023,58024,58027,58029,58031,58033,58035,58037,58040,58042],{"class":181,"line":492},[262,58025,58026],{"class":429},"_usage: dict[",[262,58028,433],{"class":271},[262,58030,608],{"class":429},[262,58032,439],{"class":271},[262,58034,2903],{"class":429},[262,58036,476],{"class":377},[262,58038,58039],{"class":429}," defaultdict(",[262,58041,439],{"class":271},[262,58043,660],{"class":429},[262,58045,58046],{"class":181,"line":503},[262,58047,583],{"emptyLinePlaceholder":582},[262,58049,58050],{"class":181,"line":521},[262,58051,583],{"emptyLinePlaceholder":582},[262,58053,58054,58056,58059,58061,58063,58065,58067],{"class":181,"line":537},[262,58055,423],{"class":377},[262,58057,58058],{"class":267}," remaining",[262,58060,54745],{"class":429},[262,58062,5869],{"class":271},[262,58064,1939],{"class":429},[262,58066,439],{"class":271},[262,58068,1160],{"class":429},[262,58070,58071,58073,58075,58077,58079,58081,58084,58086],{"class":181,"line":549},[262,58072,573],{"class":377},[262,58074,55219],{"class":429},[262,58076,57830],{"class":275},[262,58078,2903],{"class":429},[262,58080,561],{"class":377},[262,58082,58083],{"class":429}," _usage[user[",[262,58085,6770],{"class":275},[262,58087,20647],{"class":429},[262,58089,58090],{"class":181,"line":570},[262,58091,583],{"emptyLinePlaceholder":582},[262,58093,58094],{"class":181,"line":579},[262,58095,583],{"emptyLinePlaceholder":582},[262,58097,58098,58100,58103,58105,58107,58109,58111],{"class":181,"line":586},[262,58099,423],{"class":377},[262,58101,58102],{"class":267}," record_call",[262,58104,54745],{"class":429},[262,58106,5869],{"class":271},[262,58108,1939],{"class":429},[262,58110,8471],{"class":271},[262,58112,1160],{"class":429},[262,58114,58115,58118,58120,58122,58124],{"class":181,"line":591},[262,58116,58117],{"class":429},"    _usage[user[",[262,58119,6770],{"class":275},[262,58121,31012],{"class":429},[262,58123,555],{"class":377},[262,58125,3582],{"class":271},[14,58127,58128],{},"Checking before and recording after keeps the logic honest: a user is only charged a request when they actually got a result. We will wire the 429 rejection into the endpoint in the next step.",[14,58130,58131,58132,58135,58136,58139,58140,58142],{},"It helps to separate two limits people often conflate. A ",[27,58133,58134],{},"plan limit"," is a business rule, the number of calls a customer paid for this month, resetting on their billing cycle. A ",[27,58137,58138],{},"burst limit"," is an abuse guard, a cap on calls per second or minute, that stops one client from hammering you regardless of how much they paid. The counter above handles the plan limit; the burst limit wants a separate short-lived window (\"no more than five requests in ten seconds\"), which is far easier in Redis than a plain dict because Redis can expire keys for you. Whenever you return a 429, include a ",[18,58141,42730],{}," header so well-behaved clients back off instead of retrying in a tight loop and making the problem worse.",[57,58144,58146],{"id":58145},"step-4-call-the-model-and-return-a-billable-result","Step 4: Call the model and return a billable result",[14,58148,58149,58150,58152,58153,58155,58156,58159],{},"Now the actual AI work. The ",[18,58151,20],{}," SDK gives you a client whose ",[18,58154,8306],{}," method sends your prompt and returns the model's reply plus a ",[18,58157,58158],{},"usage"," object with exact token counts. Those token counts are what you eventually turn into money, so capture them and store them with the call. Returning them in the response also lets your front end show users how much they have spent.",[253,58161,58163],{"className":414,"code":58162,"language":416,"meta":258,"style":258},"# ai.py\nfrom openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\n\ndef run_completion(prompt: str) -> dict:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\"role\": \"system\", \"content\": \"You are a concise, helpful assistant.\"},\n            {\"role\": \"user\", \"content\": prompt},\n        ],\n        max_tokens=400,\n    )\n    return {\n        \"text\": response.choices[0].message.content,\n        \"input_tokens\": response.usage.prompt_tokens,\n        \"output_tokens\": response.usage.completion_tokens,\n    }\n",[18,58164,58165,58170,58180,58184,58194,58198,58202,58219,58227,58237,58245,58266,58282,58286,58296,58300,58306,58319,58327,58335],{"__ignoreMap":258},[262,58166,58167],{"class":181,"line":264},[262,58168,58169],{"class":291},"# ai.py\n",[262,58171,58172,58174,58176,58178],{"class":181,"line":282},[262,58173,705],{"class":377},[262,58175,720],{"class":429},[262,58177,684],{"class":377},[262,58179,725],{"class":429},[262,58181,58182],{"class":181,"line":295},[262,58183,583],{"emptyLinePlaceholder":582},[262,58185,58186,58188,58190,58192],{"class":181,"line":345},[262,58187,739],{"class":429},[262,58189,476],{"class":377},[262,58191,9578],{"class":429},[262,58193,9581],{"class":291},[262,58195,58196],{"class":181,"line":492},[262,58197,583],{"emptyLinePlaceholder":582},[262,58199,58200],{"class":181,"line":503},[262,58201,583],{"emptyLinePlaceholder":582},[262,58203,58204,58206,58209,58211,58213,58215,58217],{"class":181,"line":521},[262,58205,423],{"class":377},[262,58207,58208],{"class":267}," run_completion",[262,58210,9599],{"class":429},[262,58212,433],{"class":271},[262,58214,1939],{"class":429},[262,58216,5869],{"class":271},[262,58218,1160],{"class":429},[262,58220,58221,58223,58225],{"class":181,"line":537},[262,58222,1184],{"class":429},[262,58224,476],{"class":377},[262,58226,1189],{"class":429},[262,58228,58229,58231,58233,58235],{"class":181,"line":549},[262,58230,1194],{"class":611},[262,58232,476],{"class":377},[262,58234,1207],{"class":275},[262,58236,1315],{"class":429},[262,58238,58239,58241,58243],{"class":181,"line":570},[262,58240,1215],{"class":611},[262,58242,476],{"class":377},[262,58244,1220],{"class":429},[262,58246,58247,58249,58251,58253,58255,58257,58259,58261,58264],{"class":181,"line":579},[262,58248,1225],{"class":429},[262,58250,1228],{"class":275},[262,58252,1231],{"class":429},[262,58254,1234],{"class":275},[262,58256,608],{"class":429},[262,58258,1239],{"class":275},[262,58260,1231],{"class":429},[262,58262,58263],{"class":275},"\"You are a concise, helpful assistant.\"",[262,58265,3143],{"class":429},[262,58267,58268,58270,58272,58274,58276,58278,58280],{"class":181,"line":586},[262,58269,1225],{"class":429},[262,58271,1228],{"class":275},[262,58273,1231],{"class":429},[262,58275,1291],{"class":275},[262,58277,608],{"class":429},[262,58279,1239],{"class":275},[262,58281,38272],{"class":429},[262,58283,58284],{"class":181,"line":591},[262,58285,1303],{"class":429},[262,58287,58288,58290,58292,58294],{"class":181,"line":623},[262,58289,4679],{"class":611},[262,58291,476],{"class":377},[262,58293,178],{"class":271},[262,58295,1315],{"class":429},[262,58297,58298],{"class":181,"line":634},[262,58299,1011],{"class":429},[262,58301,58302,58304],{"class":181,"line":845},[262,58303,573],{"class":377},[262,58305,20437],{"class":429},[262,58307,58308,58311,58314,58316],{"class":181,"line":850},[262,58309,58310],{"class":275},"        \"text\"",[262,58312,58313],{"class":429},": response.choices[",[262,58315,102],{"class":271},[262,58317,58318],{"class":429},"].message.content,\n",[262,58320,58321,58324],{"class":181,"line":864},[262,58322,58323],{"class":275},"        \"input_tokens\"",[262,58325,58326],{"class":429},": response.usage.prompt_tokens,\n",[262,58328,58329,58332],{"class":181,"line":1683},[262,58330,58331],{"class":275},"        \"output_tokens\"",[262,58333,58334],{"class":429},": response.usage.completion_tokens,\n",[262,58336,58337],{"class":181,"line":1688},[262,58338,36280],{"class":429},[14,58340,58341,58342,1363],{},"This function is intentionally narrow: it takes a prompt and returns text plus token counts. Everything else, the auth check, the limit check, and the usage record, wraps around it in the endpoint. The worked example below ties all four steps into one file you can run. To turn those recorded tokens into invoices, see ",[51,58343,54121],{"href":54120},[14,58345,58346,58347,58349,58350,58353],{},"Two production habits belong here. The first is cost control beyond the call count. Counting requests is blunt, because one user's prompts might be ten times longer than another's and you pay per token, not per request. Even on a flat plan, watch these counts: a spike in average tokens per call is an early warning that someone is pasting huge documents into your prompt and eroding your margin. Capping ",[18,58348,3846],{}," bounds the output side; validating prompt length (as the worked example does with ",[18,58351,58352],{},"max_length",") bounds the input side. Together they put a predictable ceiling on the cost of any single call.",[14,58355,58356,58357,981,58359,58361,58362,58365,58366,58368],{},"The second habit is failing gracefully. Model calls go over the network, so they will occasionally time out, get rate-limited by the provider, or return a refusal. Wrap the call in a ",[18,58358,14430],{},[18,58360,14433],{}," and translate provider errors into clean HTTP responses, so a hiccup at OpenAI surfaces as a tidy ",[18,58363,58364],{},"503"," rather than an unhandled traceback. Pass a ",[18,58367,1591],{}," to the client so a stalled request fails fast instead of holding a worker open and blocking other customers.",[57,58370,8300],{"id":8299},[14,58372,58373,58374,58376],{},"These are the values you will tune most often as your MVP grows. The model parameters are passed to ",[18,58375,8306],{},"; the others come from the code above.",[1379,58378,58379,58391],{},[1382,58380,58381],{},[1385,58382,58383,58385,58387,58389],{},[1388,58384,1390],{},[1388,58386,3795],{},[1388,58388,3798],{},[1388,58390,1396],{},[1398,58392,58393,58408,58423,58441,58456,58470],{},[1385,58394,58395,58399,58401,58405],{},[1403,58396,58397],{},[18,58398,805],{},[1403,58400,433],{},[1403,58402,58403],{},[18,58404,2703],{},[1403,58406,58407],{},"Which model runs. Cheaper models cut cost; larger ones improve quality.",[1385,58409,58410,58414,58416,58420],{},[1403,58411,58412],{},[18,58413,3846],{},[1403,58415,439],{},[1403,58417,58418],{},[18,58419,178],{},[1403,58421,58422],{},"Caps output length. Lower values reduce cost and runaway responses.",[1385,58424,58425,58429,58431,58435],{},[1403,58426,58427],{},[18,58428,3829],{},[1403,58430,3832],{},[1403,58432,58433],{},[18,58434,17583],{},[1403,58436,58437,58438,58440],{},"Randomness of output. Set ",[18,58439,27811],{}," for factual tasks, higher for creative ones.",[1385,58442,58443,58448,58450,58453],{},[1403,58444,58445],{},[18,58446,58447],{},"monthly_limit",[1403,58449,439],{},[1403,58451,58452],{},"per plan",[1403,58454,58455],{},"Maximum AI calls a user may make before getting HTTP 429.",[1385,58457,58458,58462,58465,58467],{},[1403,58459,58460],{},[18,58461,57762],{},[1403,58463,58464],{},"header",[1403,58466,17513],{},[1403,58468,58469],{},"The caller's secret. Resolves to a user or triggers HTTP 401.",[1385,58471,58472,58476,58478,58481],{},[1403,58473,58474],{},[18,58475,1591],{},[1403,58477,3832],{},[1403,58479,58480],{},"SDK default",[1403,58482,58483,58484,1363],{},"Seconds to wait on the model before failing. Pass to ",[18,58485,58486],{},"OpenAI(timeout=30)",[57,58488,1445],{"id":1444},[14,58490,58491],{},"These are the errors you will actually hit while building this service, with the cause and the one-line fix.",[1447,58493,58494,58516,58527,58546,58557,58569,58587,58602,58618],{},[1450,58495,58496,8504,58500,58502,58503,58506,58507,58510,58511,14825,58514,1363],{},[35,58497,58498],{},[18,58499,21739],{},[18,58501,21742],{}," is missing, mistyped, or not loaded. Confirm ",[18,58504,58505],{},"\u002Fhealth"," shows ",[18,58508,58509],{},"openai_key_loaded: true"," and that the key starts with ",[18,58512,58513],{},"sk-",[51,58515,388],{"href":387},[1450,58517,58518,58526],{},[35,58519,58520,58522,58523],{},[18,58521,52506],{}," returns ",[18,58524,58525],{},"422 Unprocessable Entity"," — The request body did not match your Pydantic model, usually a missing field or wrong type. Check the response detail; it names the exact field that failed.",[1450,58528,58529,58535,58536,58538,58539,58541,58542,58545],{},[35,58530,58531,58534],{},[18,58532,58533],{},"401 Invalid or missing API key"," on every call"," — You forgot the ",[18,58537,57762],{}," header, or your key is not in the ",[18,58540,57798],{}," dict. In ",[18,58543,58544],{},"\u002Fdocs",", click \"Authorize\" or add the header manually before sending.",[1450,58547,58548,58554,58555,1363],{},[35,58549,58550,58553],{},[18,58551,58552],{},"openai.RateLimitError: 429"," from OpenAI itself"," — This is the provider throttling you, not your own limit. Slow your calls or add retries with backoff. See ",[51,58556,3379],{"href":3378},[1450,58558,58559,55456,58562,58564,58565,58568],{},[35,58560,58561],{},"Usage counter resets after a code change",[18,58563,53985],{}," restarts the process, wiping the in-memory ",[18,58566,58567],{},"_usage"," dict. This is expected in development; move the counter to Redis for anything real.",[1450,58570,58571,58576,58577,58579,58580,58583,58584,1363],{},[35,58572,58573],{},[18,58574,58575],{},"AttributeError: 'NoneType' object has no attribute 'content'"," — The model returned no message, often because the request was filtered or ",[18,58578,3846],{}," was 0. Log the full ",[18,58581,58582],{},"response"," object and check ",[18,58585,58586],{},"finish_reason",[1450,58588,58589,58595,58596,58598,58599,58601],{},[35,58590,58591,58594],{},[18,58592,58593],{},"openai.APITimeoutError"," under load"," — The default timeout is generous, and a slow model call can hold a worker open while other requests queue behind it. Pass an explicit ",[18,58597,58486],{}," and surface a ",[18,58600,58364],{}," to the caller so one stalled call cannot back up your whole service.",[1450,58603,58604,58611,58612,58614,58615,58617],{},[35,58605,58606,58607,58610],{},"Two workers report different ",[18,58608,58609],{},"calls_used"," for the same user"," — Each ",[18,58613,52514],{}," worker has its own in-memory ",[18,58616,58158],{}," dict, so the count splits across processes and your limit effectively multiplies by the worker count. The sign you have outgrown the dict and need a shared store like Redis.",[1450,58619,58620,58626,58627,58630],{},[35,58621,58622,58625],{},[18,58623,58624],{},"FileNotFoundError: '.env'"," on startup"," — The loader runs at import time and cannot find the file because the server started from a different directory. Use ",[18,58628,58629],{},"Path(__file__).parent \u002F \".env\""," so it resolves next to your code regardless of the working directory.",[57,58632,58634],{"id":58633},"worked-example-a-complete-ai-saas-endpoint","Worked example: a complete AI SaaS endpoint",[14,58636,58637,58638,58640,58641,58643,58644,1363],{},"This single file ties together all four steps: it loads your key, authenticates the caller, enforces their rate limit, calls the model, records usage, and returns a billable result. Save it as ",[18,58639,53581],{},", run ",[18,58642,53925],{},", and test it from ",[18,58645,53929],{},[253,58647,58649],{"className":414,"code":58648,"language":416,"meta":258,"style":258},"# app.py — a minimal, runnable AI SaaS endpoint\nimport os\nfrom collections import defaultdict\nfrom pathlib import Path\n\nfrom fastapi import Depends, FastAPI, Header, HTTPException\nfrom fastapi.responses import JSONResponse\nfrom openai import OpenAI, APIError, RateLimitError\nfrom pydantic import BaseModel, Field\n\n# Load .env into the environment (keep .env in .gitignore).\n# Resolve the path next to this file so it works from any working directory.\nfor _line in (Path(__file__).parent \u002F \".env\").read_text().splitlines():\n    if _line and not _line.startswith(\"#\") and \"=\" in _line:\n        _k, _v = _line.split(\"=\", 1)\n        os.environ.setdefault(_k.strip(), _v.strip())\n\napp = FastAPI(title=\"AI SaaS MVP\")\nclient = OpenAI(timeout=30)  # fail fast instead of holding a worker open\n\n# Stand-in for a user table and a usage table (use a database in production).\nUSERS = {\"key_pro_xyz\": {\"id\": \"u_2\", \"plan\": \"pro\", \"monthly_limit\": 5000}}\nusage: dict[str, int] = defaultdict(int)\n\n\nclass GenerateRequest(BaseModel):\n    # max_length bounds the *input* cost; the model's max_tokens bounds output.\n    prompt: str = Field(..., min_length=3, max_length=2000)\n\n\ndef current_user(x_api_key: str = Header(...)) -> dict:\n    user = USERS.get(x_api_key)  # auth: resolve the key to a user\n    if user is None:\n        raise HTTPException(status_code=401, detail=\"Invalid API key\")\n    return user\n\n\n@app.get(\"\u002Fhealth\")\ndef health() -> dict:\n    # Cheap, dependency-free check your host pings to decide you are alive.\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"\u002Fv1\u002Fgenerate\")\ndef generate(req: GenerateRequest, user: dict = Depends(current_user)) -> dict:\n    # Rate limit: refuse before spending money if the user is out of calls.\n    if usage[user[\"id\"]] >= user[\"monthly_limit\"]:\n        raise HTTPException(\n            status_code=429,\n            detail=\"Monthly limit reached\",\n            headers={\"Retry-After\": \"3600\"},  # tell good clients when to retry\n        )\n    try:\n        resp = client.chat.completions.create(  # the paid AI work\n            model=\"gpt-4o-mini\",\n            messages=[{\"role\": \"user\", \"content\": req.prompt}],\n            max_tokens=400,  # caps output length, and therefore cost\n        )\n    except RateLimitError:  # OpenAI throttled us, not the user's plan\n        raise HTTPException(status_code=503, detail=\"Model busy, retry shortly\")\n    except APIError:  # any other provider-side failure\n        raise HTTPException(status_code=502, detail=\"Model call failed\")\n\n    usage[user[\"id\"]] += 1  # record only after a successful call, for billing\n    return {\n        \"text\": resp.choices[0].message.content,\n        \"input_tokens\": resp.usage.prompt_tokens,   # store these per call\n        \"output_tokens\": resp.usage.completion_tokens,\n        \"tokens\": resp.usage.total_tokens,           # the number you bill on\n        \"calls_used\": usage[user[\"id\"]],\n        \"calls_remaining\": user[\"monthly_limit\"] - usage[user[\"id\"]],\n    }\n",[18,58650,58651,58656,58662,58672,58682,58686,58697,58708,58719,58730,58734,58739,58744,58765,58791,58809,58814,58818,58834,58853,58857,58862,58899,58920,58924,58928,58941,58946,58979,58983,58987,59009,59023,59035,59058,59064,59068,59072,59082,59094,59099,59113,59117,59121,59132,59152,59157,59176,59182,59193,59204,59226,59230,59236,59248,59258,59279,59292,59296,59306,59329,59339,59362,59366,59382,59388,59399,59409,59416,59427,59440,59460],{"__ignoreMap":258},[262,58652,58653],{"class":181,"line":264},[262,58654,58655],{"class":291},"# app.py — a minimal, runnable AI SaaS endpoint\n",[262,58657,58658,58660],{"class":181,"line":282},[262,58659,684],{"class":377},[262,58661,687],{"class":429},[262,58663,58664,58666,58668,58670],{"class":181,"line":295},[262,58665,705],{"class":377},[262,58667,58007],{"class":429},[262,58669,684],{"class":377},[262,58671,58012],{"class":429},[262,58673,58674,58676,58678,58680],{"class":181,"line":345},[262,58675,705],{"class":377},[262,58677,4882],{"class":429},[262,58679,684],{"class":377},[262,58681,4887],{"class":429},[262,58683,58684],{"class":181,"line":492},[262,58685,583],{"emptyLinePlaceholder":582},[262,58687,58688,58690,58692,58694],{"class":181,"line":503},[262,58689,705],{"class":377},[262,58691,51571],{"class":429},[262,58693,684],{"class":377},[262,58695,58696],{"class":429}," Depends, FastAPI, Header, HTTPException\n",[262,58698,58699,58701,58703,58705],{"class":181,"line":521},[262,58700,705],{"class":377},[262,58702,51583],{"class":429},[262,58704,684],{"class":377},[262,58706,58707],{"class":429}," JSONResponse\n",[262,58709,58710,58712,58714,58716],{"class":181,"line":537},[262,58711,705],{"class":377},[262,58713,720],{"class":429},[262,58715,684],{"class":377},[262,58717,58718],{"class":429}," OpenAI, APIError, RateLimitError\n",[262,58720,58721,58723,58725,58727],{"class":181,"line":549},[262,58722,705],{"class":377},[262,58724,53609],{"class":429},[262,58726,684],{"class":377},[262,58728,58729],{"class":429}," BaseModel, Field\n",[262,58731,58732],{"class":181,"line":570},[262,58733,583],{"emptyLinePlaceholder":582},[262,58735,58736],{"class":181,"line":579},[262,58737,58738],{"class":291},"# Load .env into the environment (keep .env in .gitignore).\n",[262,58740,58741],{"class":181,"line":586},[262,58742,58743],{"class":291},"# Resolve the path next to this file so it works from any working directory.\n",[262,58745,58746,58748,58751,58753,58755,58757,58759,58761,58763],{"class":181,"line":591},[262,58747,829],{"class":377},[262,58749,58750],{"class":429}," _line ",[262,58752,835],{"class":377},[262,58754,54388],{"class":429},[262,58756,54391],{"class":271},[262,58758,54394],{"class":429},[262,58760,981],{"class":377},[262,58762,374],{"class":275},[262,58764,54401],{"class":429},[262,58766,58767,58769,58771,58773,58775,58778,58780,58782,58784,58786,58788],{"class":181,"line":623},[262,58768,3454],{"class":377},[262,58770,58750],{"class":429},[262,58772,6101],{"class":377},[262,58774,2818],{"class":377},[262,58776,58777],{"class":429}," _line.startswith(",[262,58779,54417],{"class":275},[262,58781,1000],{"class":429},[262,58783,6101],{"class":377},[262,58785,54424],{"class":275},[262,58787,2821],{"class":377},[262,58789,58790],{"class":429}," _line:\n",[262,58792,58793,58796,58798,58801,58803,58805,58807],{"class":181,"line":634},[262,58794,58795],{"class":429},"        _k, _v ",[262,58797,476],{"class":377},[262,58799,58800],{"class":429}," _line.split(",[262,58802,54442],{"class":275},[262,58804,608],{"class":429},[262,58806,997],{"class":271},[262,58808,660],{"class":429},[262,58810,58811],{"class":181,"line":845},[262,58812,58813],{"class":429},"        os.environ.setdefault(_k.strip(), _v.strip())\n",[262,58815,58816],{"class":181,"line":850},[262,58817,583],{"emptyLinePlaceholder":582},[262,58819,58820,58822,58824,58826,58828,58830,58832],{"class":181,"line":864},[262,58821,51635],{"class":429},[262,58823,476],{"class":377},[262,58825,53652],{"class":429},[262,58827,92],{"class":611},[262,58829,476],{"class":377},[262,58831,57644],{"class":275},[262,58833,660],{"class":429},[262,58835,58836,58838,58840,58842,58844,58846,58848,58850],{"class":181,"line":1683},[262,58837,739],{"class":429},[262,58839,476],{"class":377},[262,58841,1588],{"class":429},[262,58843,1591],{"class":611},[262,58845,476],{"class":377},[262,58847,9777],{"class":271},[262,58849,32223],{"class":429},[262,58851,58852],{"class":291},"# fail fast instead of holding a worker open\n",[262,58854,58855],{"class":181,"line":1688},[262,58856,583],{"emptyLinePlaceholder":582},[262,58858,58859],{"class":181,"line":1693},[262,58860,58861],{"class":291},"# Stand-in for a user table and a usage table (use a database in production).\n",[262,58863,58864,58866,58868,58870,58873,58875,58877,58879,58881,58883,58885,58887,58889,58891,58893,58895,58897],{"class":181,"line":1728},[262,58865,57798],{"class":271},[262,58867,442],{"class":377},[262,58869,2276],{"class":429},[262,58871,58872],{"class":275},"\"key_pro_xyz\"",[262,58874,20445],{"class":429},[262,58876,6770],{"class":275},[262,58878,1231],{"class":429},[262,58880,57850],{"class":275},[262,58882,608],{"class":429},[262,58884,55236],{"class":275},[262,58886,1231],{"class":429},[262,58888,57859],{"class":275},[262,58890,608],{"class":429},[262,58892,57830],{"class":275},[262,58894,1231],{"class":429},[262,58896,57868],{"class":271},[262,58898,45182],{"class":429},[262,58900,58901,58904,58906,58908,58910,58912,58914,58916,58918],{"class":181,"line":1737},[262,58902,58903],{"class":429},"usage: dict[",[262,58905,433],{"class":271},[262,58907,608],{"class":429},[262,58909,439],{"class":271},[262,58911,2903],{"class":429},[262,58913,476],{"class":377},[262,58915,58039],{"class":429},[262,58917,439],{"class":271},[262,58919,660],{"class":429},[262,58921,58922],{"class":181,"line":1751},[262,58923,583],{"emptyLinePlaceholder":582},[262,58925,58926],{"class":181,"line":1764},[262,58927,583],{"emptyLinePlaceholder":582},[262,58929,58930,58932,58935,58937,58939],{"class":181,"line":1779},[262,58931,7374],{"class":377},[262,58933,58934],{"class":267}," GenerateRequest",[262,58936,602],{"class":429},[262,58938,53697],{"class":267},[262,58940,8192],{"class":429},[262,58942,58943],{"class":181,"line":1793},[262,58944,58945],{"class":291},"    # max_length bounds the *input* cost; the model's max_tokens bounds output.\n",[262,58947,58948,58951,58953,58955,58958,58960,58962,58965,58967,58969,58971,58973,58975,58977],{"class":181,"line":1800},[262,58949,58950],{"class":429},"    prompt: ",[262,58952,433],{"class":271},[262,58954,442],{"class":377},[262,58956,58957],{"class":429}," Field(",[262,58959,57902],{"class":271},[262,58961,608],{"class":429},[262,58963,58964],{"class":611},"min_length",[262,58966,476],{"class":377},[262,58968,5556],{"class":271},[262,58970,608],{"class":429},[262,58972,58352],{"class":611},[262,58974,476],{"class":377},[262,58976,54523],{"class":271},[262,58978,660],{"class":429},[262,58980,58981],{"class":181,"line":1805},[262,58982,583],{"emptyLinePlaceholder":582},[262,58984,58985],{"class":181,"line":1810},[262,58986,583],{"emptyLinePlaceholder":582},[262,58988,58989,58991,58993,58995,58997,58999,59001,59003,59005,59007],{"class":181,"line":1823},[262,58990,423],{"class":377},[262,58992,57889],{"class":267},[262,58994,57892],{"class":429},[262,58996,433],{"class":271},[262,58998,442],{"class":377},[262,59000,57899],{"class":429},[262,59002,57902],{"class":271},[262,59004,57905],{"class":429},[262,59006,5869],{"class":271},[262,59008,1160],{"class":429},[262,59010,59011,59013,59015,59017,59020],{"class":181,"line":1846},[262,59012,7611],{"class":429},[262,59014,476],{"class":377},[262,59016,55012],{"class":271},[262,59018,59019],{"class":429},".get(x_api_key)  ",[262,59021,59022],{"class":291},"# auth: resolve the key to a user\n",[262,59024,59025,59027,59029,59031,59033],{"class":181,"line":1861},[262,59026,3454],{"class":377},[262,59028,55205],{"class":429},[262,59030,20596],{"class":377},[262,59032,20599],{"class":271},[262,59034,1160],{"class":429},[262,59036,59037,59039,59041,59043,59045,59047,59049,59051,59053,59056],{"class":181,"line":1866},[262,59038,4928],{"class":377},[262,59040,53750],{"class":429},[262,59042,53753],{"class":611},[262,59044,476],{"class":377},[262,59046,41445],{"class":271},[262,59048,608],{"class":429},[262,59050,53763],{"class":611},[262,59052,476],{"class":377},[262,59054,59055],{"class":275},"\"Invalid API key\"",[262,59057,660],{"class":429},[262,59059,59060,59062],{"class":181,"line":1871},[262,59061,573],{"class":377},[262,59063,56140],{"class":429},[262,59065,59066],{"class":181,"line":1890},[262,59067,583],{"emptyLinePlaceholder":582},[262,59069,59070],{"class":181,"line":1909},[262,59071,583],{"emptyLinePlaceholder":582},[262,59073,59074,59076,59078,59080],{"class":181,"line":1914},[262,59075,51830],{"class":267},[262,59077,602],{"class":429},[262,59079,57663],{"class":275},[262,59081,660],{"class":429},[262,59083,59084,59086,59088,59090,59092],{"class":181,"line":1919},[262,59085,423],{"class":377},[262,59087,57672],{"class":267},[262,59089,15481],{"class":429},[262,59091,5869],{"class":271},[262,59093,1160],{"class":429},[262,59095,59096],{"class":181,"line":1946},[262,59097,59098],{"class":291},"    # Cheap, dependency-free check your host pings to decide you are alive.\n",[262,59100,59101,59103,59105,59107,59109,59111],{"class":181,"line":1959},[262,59102,573],{"class":377},[262,59104,2276],{"class":429},[262,59106,10324],{"class":275},[262,59108,1231],{"class":429},[262,59110,10329],{"class":275},[262,59112,16430],{"class":429},[262,59114,59115],{"class":181,"line":1996},[262,59116,583],{"emptyLinePlaceholder":582},[262,59118,59119],{"class":181,"line":2012},[262,59120,583],{"emptyLinePlaceholder":582},[262,59122,59123,59125,59127,59130],{"class":181,"line":2040},[262,59124,53718],{"class":267},[262,59126,602],{"class":429},[262,59128,59129],{"class":275},"\"\u002Fv1\u002Fgenerate\"",[262,59131,660],{"class":429},[262,59133,59134,59136,59138,59141,59143,59145,59148,59150],{"class":181,"line":2045},[262,59135,423],{"class":377},[262,59137,56930],{"class":267},[262,59139,59140],{"class":429},"(req: GenerateRequest, user: ",[262,59142,5869],{"class":271},[262,59144,442],{"class":377},[262,59146,59147],{"class":429}," Depends(current_user)) -> ",[262,59149,5869],{"class":271},[262,59151,1160],{"class":429},[262,59153,59154],{"class":181,"line":2050},[262,59155,59156],{"class":291},"    # Rate limit: refuse before spending money if the user is out of calls.\n",[262,59158,59159,59161,59164,59166,59168,59170,59172,59174],{"class":181,"line":2067},[262,59160,3454],{"class":377},[262,59162,59163],{"class":429}," usage[user[",[262,59165,6770],{"class":275},[262,59167,31012],{"class":429},[262,59169,33631],{"class":377},[262,59171,55219],{"class":429},[262,59173,57830],{"class":275},[262,59175,463],{"class":429},[262,59177,59178,59180],{"class":181,"line":2077},[262,59179,4928],{"class":377},[262,59181,56442],{"class":429},[262,59183,59184,59186,59188,59191],{"class":181,"line":2086},[262,59185,56447],{"class":611},[262,59187,476],{"class":377},[262,59189,59190],{"class":271},"429",[262,59192,1315],{"class":429},[262,59194,59195,59197,59199,59202],{"class":181,"line":2097},[262,59196,56462],{"class":611},[262,59198,476],{"class":377},[262,59200,59201],{"class":275},"\"Monthly limit reached\"",[262,59203,1315],{"class":429},[262,59205,59206,59209,59211,59213,59215,59217,59220,59223],{"class":181,"line":2106},[262,59207,59208],{"class":611},"            headers",[262,59210,476],{"class":377},[262,59212,3039],{"class":429},[262,59214,42601],{"class":275},[262,59216,1231],{"class":429},[262,59218,59219],{"class":275},"\"3600\"",[262,59221,59222],{"class":429},"},  ",[262,59224,59225],{"class":291},"# tell good clients when to retry\n",[262,59227,59228],{"class":181,"line":2126},[262,59229,6288],{"class":429},[262,59231,59232,59234],{"class":181,"line":2148},[262,59233,14474],{"class":377},[262,59235,1160],{"class":429},[262,59237,59238,59240,59242,59245],{"class":181,"line":2165},[262,59239,17037],{"class":429},[262,59241,476],{"class":377},[262,59243,59244],{"class":429}," client.chat.completions.create(  ",[262,59246,59247],{"class":291},"# the paid AI work\n",[262,59249,59250,59252,59254,59256],{"class":181,"line":2170},[262,59251,14214],{"class":611},[262,59253,476],{"class":377},[262,59255,1207],{"class":275},[262,59257,1315],{"class":429},[262,59259,59260,59262,59264,59266,59268,59270,59272,59274,59276],{"class":181,"line":2181},[262,59261,27253],{"class":611},[262,59263,476],{"class":377},[262,59265,8856],{"class":429},[262,59267,1228],{"class":275},[262,59269,1231],{"class":429},[262,59271,1291],{"class":275},[262,59273,608],{"class":429},[262,59275,1239],{"class":275},[262,59277,59278],{"class":429},": req.prompt}],\n",[262,59280,59281,59283,59285,59287,59289],{"class":181,"line":2186},[262,59282,27286],{"class":611},[262,59284,476],{"class":377},[262,59286,178],{"class":271},[262,59288,13488],{"class":429},[262,59290,59291],{"class":291},"# caps output length, and therefore cost\n",[262,59293,59294],{"class":181,"line":2197},[262,59295,6288],{"class":429},[262,59297,59298,59300,59303],{"class":181,"line":2202},[262,59299,14522],{"class":377},[262,59301,59302],{"class":429}," RateLimitError:  ",[262,59304,59305],{"class":291},"# OpenAI throttled us, not the user's plan\n",[262,59307,59308,59310,59312,59314,59316,59318,59320,59322,59324,59327],{"class":181,"line":2207},[262,59309,4928],{"class":377},[262,59311,53750],{"class":429},[262,59313,53753],{"class":611},[262,59315,476],{"class":377},[262,59317,58364],{"class":271},[262,59319,608],{"class":429},[262,59321,53763],{"class":611},[262,59323,476],{"class":377},[262,59325,59326],{"class":275},"\"Model busy, retry shortly\"",[262,59328,660],{"class":429},[262,59330,59331,59333,59336],{"class":181,"line":2224},[262,59332,14522],{"class":377},[262,59334,59335],{"class":429}," APIError:  ",[262,59337,59338],{"class":291},"# any other provider-side failure\n",[262,59340,59341,59343,59345,59347,59349,59351,59353,59355,59357,59360],{"class":181,"line":2236},[262,59342,4928],{"class":377},[262,59344,53750],{"class":429},[262,59346,53753],{"class":611},[262,59348,476],{"class":377},[262,59350,53883],{"class":271},[262,59352,608],{"class":429},[262,59354,53763],{"class":611},[262,59356,476],{"class":377},[262,59358,59359],{"class":275},"\"Model call failed\"",[262,59361,660],{"class":429},[262,59363,59364],{"class":181,"line":2246},[262,59365,583],{"emptyLinePlaceholder":582},[262,59367,59368,59371,59373,59375,59377,59379],{"class":181,"line":2265},[262,59369,59370],{"class":429},"    usage[user[",[262,59372,6770],{"class":275},[262,59374,31012],{"class":429},[262,59376,555],{"class":377},[262,59378,3243],{"class":271},[262,59380,59381],{"class":291},"  # record only after a successful call, for billing\n",[262,59383,59384,59386],{"class":181,"line":2290},[262,59385,573],{"class":377},[262,59387,20437],{"class":429},[262,59389,59390,59392,59395,59397],{"class":181,"line":2296},[262,59391,58310],{"class":275},[262,59393,59394],{"class":429},": resp.choices[",[262,59396,102],{"class":271},[262,59398,58318],{"class":429},[262,59400,59401,59403,59406],{"class":181,"line":9230},[262,59402,58323],{"class":275},[262,59404,59405],{"class":429},": resp.usage.prompt_tokens,   ",[262,59407,59408],{"class":291},"# store these per call\n",[262,59410,59411,59413],{"class":181,"line":9241},[262,59412,58331],{"class":275},[262,59414,59415],{"class":429},": resp.usage.completion_tokens,\n",[262,59417,59418,59421,59424],{"class":181,"line":9247},[262,59419,59420],{"class":275},"        \"tokens\"",[262,59422,59423],{"class":429},": resp.usage.total_tokens,           ",[262,59425,59426],{"class":291},"# the number you bill on\n",[262,59428,59429,59432,59435,59437],{"class":181,"line":28672},[262,59430,59431],{"class":275},"        \"calls_used\"",[262,59433,59434],{"class":429},": usage[user[",[262,59436,6770],{"class":275},[262,59438,59439],{"class":429},"]],\n",[262,59441,59442,59445,59448,59450,59452,59454,59456,59458],{"class":181,"line":28683},[262,59443,59444],{"class":275},"        \"calls_remaining\"",[262,59446,59447],{"class":429},": user[",[262,59449,57830],{"class":275},[262,59451,2903],{"class":429},[262,59453,561],{"class":377},[262,59455,59163],{"class":429},[262,59457,6770],{"class":275},[262,59459,59439],{"class":429},[262,59461,59462],{"class":181,"line":28710},[262,59463,36280],{"class":429},[14,59465,59466,59467,3921,59469,59472,59473,59476,59477,59480,59481,59484,59485,59487],{},"Send a ",[18,59468,40598],{},[18,59470,59471],{},"\u002Fv1\u002Fgenerate"," with the header ",[18,59474,59475],{},"X-API-Key: key_pro_xyz"," and a JSON body like ",[18,59478,59479],{},"{\"prompt\": \"Write a tagline for a dog-walking app\"}",". You get back the model's text, the input and output token counts for billing, and how many calls the user has spent and has left. Notice how the four guardrails read top to bottom: validate the body (Pydantic), confirm the caller (",[18,59482,59483],{},"Depends","), check the limit, do the paid work inside a ",[18,59486,14430],{},", and only then record usage. Each fails closed with a clear status code rather than letting a bad request reach the expensive part. That is a complete, paid AI service in under 60 lines, missing only a real database and a payment processor, both of which are next.",[14,59489,59490,59491,1374,59494,59497],{},"The split between ",[18,59492,59493],{},"input_tokens",[18,59495,59496],{},"output_tokens"," is deliberate: providers price the two differently and output is usually dearer. Storing both per call, rather than just the total, lets you later answer \"which customers cost us the most to serve\" and \"is our flat plan still profitable,\" and it costs nothing to capture now.",[57,59499,2355],{"id":2354},[14,59501,59502],{},"You have the core loop working. Build out the production pieces in this order, each in its own guide:",[1447,59504,59505,59510,59515,59520],{},[1450,59506,59507,59508,1363],{},"Replace the dict-based users with real sign-up and sessions in ",[51,59509,54116],{"href":54115},[1450,59511,59512,59513,1363],{},"Move the usage counter to a shared store so it survives restarts and multiple workers, following ",[51,59514,49599],{"href":49598},[1450,59516,59517,59518,1363],{},"Turn recorded usage into revenue with ",[51,59519,54121],{"href":54120},[1450,59521,59522,59523,59525,59526,1363],{},"If your product is conversational, add session memory and prompt design from ",[51,59524,54],{"href":53},"; if it works with customer records, see ",[51,59527,36938],{"href":36937},[14,59529,2375,59530,1363],{},[51,59531,26457],{"href":26456},[57,59533,2381],{"id":2380},[2322,59535,59536,59540,59544,59548,59552],{},[1450,59537,59538],{},[51,59539,54121],{"href":54120},[1450,59541,59542],{},[51,59543,54116],{"href":54115},[1450,59545,59546],{},[51,59547,49599],{"href":49598},[1450,59549,59550],{},[51,59551,54],{"href":53},[1450,59553,59554],{},[51,59555,36938],{"href":36937},[2401,59557,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":59559},[59560,59561,59562,59563,59564,59565,59566,59567,59568,59569,59570],{"id":57294,"depth":282,"text":57295},{"id":237,"depth":282,"text":238},{"id":57498,"depth":282,"text":57499},{"id":57750,"depth":282,"text":57751},{"id":57981,"depth":282,"text":57982},{"id":58145,"depth":282,"text":58146},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":58633,"depth":282,"text":58634},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Build a working AI SaaS MVP with Python, FastAPI, and the OpenAI SDK. Covers auth, rate limits, billing hooks, and a runnable endpoint you can ship.",[59573,59576,59579,59582,59585],{"q":59574,"a":59575},"What is the fastest stack to build an AI SaaS MVP in Python?","FastAPI plus the openai SDK and httpx is the fastest reliable stack. FastAPI gives you async request handling and automatic validation, the openai SDK handles the model calls, and httpx covers any other external APIs. You can ship a paid endpoint in a few hundred lines.",{"q":59577,"a":59578},"Do I need a database to launch an AI SaaS MVP?","You need somewhere to store users and usage counts, but it can be small. SQLite works for early validation and a single dict in memory works for a demo. Move to Postgres only once you have paying users and more than one server process.",{"q":59580,"a":59581},"How do I stop one user from running up a huge OpenAI bill?","Put a usage counter behind every AI call and reject requests once a user passes their plan limit. Check the limit before you call the model, increment after a successful call, and return HTTP 429 when they are over. This is the single most important guardrail in an AI SaaS.",{"q":59583,"a":59584},"Should I charge per request or per month for an AI SaaS?","Most MVPs start with a flat monthly plan that includes a fixed number of AI requests, because it is simple to explain and easy to bill. Usage-based pricing is more accurate but adds metering complexity, so add it once you understand your real token costs.",{"q":59586,"a":59587},"Can I run this MVP without Docker or Kubernetes?","Yes. A single uvicorn process on one small server handles an early MVP fine. Containers and orchestration matter once you scale, but they add friction during validation, so skip them until you have traction.",{"name":59589,"steps":59590},"How to build an AI SaaS MVP with Python",[59591,59594,59597,59600],{"name":59592,"text":59593},"Set up the project and secrets","Create a virtual environment, install FastAPI, the openai SDK, and httpx, and store your API key in a .env file that is ignored by Git.",{"name":59595,"text":59596},"Authenticate every request","Require an API key on each call and resolve it to a known user before any AI work runs.",{"name":59598,"text":59599},"Rate-limit and meter usage","Check each user's request count against their plan limit, reject over-limit calls with HTTP 429, and record usage after a successful call.",{"name":59601,"text":59602},"Call the model and return billable output","Send the prompt to the OpenAI API, capture the token usage, and return a clean JSON response your front end and billing system can read.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai",{"title":57267,"description":59571},"SaaS MVP with Python & AI: Build Guide","building-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Findex","7xhlolzY0tN4dSZEwLfZENnWXxXRYmA3MQaKJ5IHXC8",{"id":59610,"title":49599,"body":59611,"description":61073,"extension":2419,"faq":61074,"howto":61090,"meta":61105,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":61106,"published":2452,"seo":61107,"seoTitle":61108,"stem":61109,"__hash__":61110},"content\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Frate-limit-ai-api-calls-in-a-saas-with-python\u002Findex.md",{"type":7,"value":59612,"toc":61061},[59613,59616,59619,59625,59627,59638,59641,59677,59690,59704,59708,59715,59951,59968,59972,59982,60299,60321,60325,60339,60563,60584,60588,60594,60601,60846,60863,60865,60868,60933,60944,60946,60998,61000,61022,61024,61035,61039,61041,61059],[10,59614,49599],{"id":59615},"rate-limit-ai-api-calls-in-a-saas-with-python",[14,59617,59618],{},"This guide shows you how to cap how often each user can call your AI endpoints, so one client cannot run up your bill or overload your service, in under twenty minutes. You will build a limiter in memory first, return a proper HTTP 429 from FastAPI, upgrade it to a token bucket, and finally move it to Redis so the limit holds across many processes.",[14,59620,59621,59622,59624],{},"Every AI call costs real money and takes real time. Without a per-user cap, a single buggy script in a retry loop, or one customer who decides to scrape, can hand you a four-figure provider bill overnight and slow every other user down. A rate limit is the guardrail that makes the cost of any one account predictable. This guide is part of the ",[51,59623,39690],{"href":39689}," section, which builds the surrounding auth and billing pieces.",[57,59626,238],{"id":237},[14,59628,241,59629,59631,59632,59634,59635,59637],{},[18,59630,17782],{}," to check) and the FastAPI service from the ",[51,59633,39690],{"href":39689}," guide, or any FastAPI app where you can identify the caller. Identifying the caller is the job of authentication, covered in ",[51,59636,54116],{"href":54115},"; a rate limit is per user, so you must know who is asking before you can count them.",[14,59639,59640],{},"Install what the in-memory steps need now, and Redis only when you reach step 4:",[253,59642,59644],{"className":255,"code":59643,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\npip install \"fastapi>=0.110\" \"uvicorn[standard]>=0.29\" \"redis>=5.0\"\n",[18,59645,59646,59656,59664],{"__ignoreMap":258},[262,59647,59648,59650,59652,59654],{"class":181,"line":264},[262,59649,416],{"class":267},[262,59651,272],{"class":271},[262,59653,276],{"class":275},[262,59655,279],{"class":275},[262,59657,59658,59660,59662],{"class":181,"line":282},[262,59659,285],{"class":271},[262,59661,288],{"class":275},[262,59663,7222],{"class":291},[262,59665,59666,59668,59670,59672,59674],{"class":181,"line":295},[262,59667,298],{"class":267},[262,59669,301],{"class":275},[262,59671,50746],{"class":275},[262,59673,52480],{"class":275},[262,59675,59676],{"class":275}," \"redis>=5.0\"\n",[14,59678,59679,59680,59682,59683,59685,59686,356,59688,57475],{},"There are no secret keys in this guide itself, but the service you attach it to loads an ",[18,59681,21742],{}," from a ",[18,59684,319],{}," file. If you keep one, add ",[18,59687,319],{},[18,59689,359],{},[253,59691,59692],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,59693,59694],{"__ignoreMap":258},[262,59695,59696,59698,59700,59702],{"class":181,"line":264},[262,59697,371],{"class":271},[262,59699,374],{"class":275},[262,59701,378],{"class":377},[262,59703,381],{"class":275},[57,59705,59707],{"id":59706},"step-1-build-an-in-memory-fixed-window-limiter","Step 1: Build an in-memory fixed-window limiter",[14,59709,59710,59711,59714],{},"The simplest limiter is a ",[27,59712,59713],{},"fixed window",": pick a window length (say 60 seconds) and a limit (say 30 requests), and count each user's calls inside the current window. When the clock ticks into the next window, the count resets. It is a handful of lines and needs no extra services, which makes it perfect for a single-process MVP.",[253,59716,59718],{"className":414,"code":59717,"language":416,"meta":258,"style":258},"# limiter.py\nimport time\nfrom collections import defaultdict\n\n# user_id -> (window_start_epoch, request_count_in_window)\n_windows: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))\n\n\ndef check_fixed_window(user_id: str, limit: int = 30, window: int = 60) -> int:\n    \"\"\"Return seconds to wait if over limit, else 0 (the call is allowed).\"\"\"\n    now = time.monotonic()\n    start, count = _windows[user_id]\n    if now - start >= window:          # window expired: start a fresh one\n        _windows[user_id] = (now, 1)\n        return 0\n    if count >= limit:                 # over the limit inside this window\n        return int(window - (now - start)) + 1\n    _windows[user_id] = (start, count + 1)\n    return 0\n",[18,59719,59720,59725,59731,59741,59745,59750,59786,59790,59794,59828,59833,59842,59852,59871,59885,59891,59906,59929,59945],{"__ignoreMap":258},[262,59721,59722],{"class":181,"line":264},[262,59723,59724],{"class":291},"# limiter.py\n",[262,59726,59727,59729],{"class":181,"line":282},[262,59728,684],{"class":377},[262,59730,2612],{"class":429},[262,59732,59733,59735,59737,59739],{"class":181,"line":295},[262,59734,705],{"class":377},[262,59736,58007],{"class":429},[262,59738,684],{"class":377},[262,59740,58012],{"class":429},[262,59742,59743],{"class":181,"line":345},[262,59744,583],{"emptyLinePlaceholder":582},[262,59746,59747],{"class":181,"line":492},[262,59748,59749],{"class":291},"# user_id -> (window_start_epoch, request_count_in_window)\n",[262,59751,59752,59755,59757,59760,59762,59764,59766,59768,59770,59772,59775,59778,59780,59782,59784],{"class":181,"line":503},[262,59753,59754],{"class":429},"_windows: dict[",[262,59756,433],{"class":271},[262,59758,59759],{"class":429},", tuple[",[262,59761,3832],{"class":271},[262,59763,608],{"class":429},[262,59765,439],{"class":271},[262,59767,31012],{"class":429},[262,59769,476],{"class":377},[262,59771,58039],{"class":429},[262,59773,59774],{"class":377},"lambda",[262,59776,59777],{"class":429},": (",[262,59779,46000],{"class":271},[262,59781,608],{"class":429},[262,59783,102],{"class":271},[262,59785,2684],{"class":429},[262,59787,59788],{"class":181,"line":521},[262,59789,583],{"emptyLinePlaceholder":582},[262,59791,59792],{"class":181,"line":537},[262,59793,583],{"emptyLinePlaceholder":582},[262,59795,59796,59798,59801,59803,59805,59807,59809,59811,59813,59816,59818,59820,59822,59824,59826],{"class":181,"line":549},[262,59797,423],{"class":377},[262,59799,59800],{"class":267}," check_fixed_window",[262,59802,56238],{"class":429},[262,59804,433],{"class":271},[262,59806,17988],{"class":429},[262,59808,439],{"class":271},[262,59810,442],{"class":377},[262,59812,8114],{"class":271},[262,59814,59815],{"class":429},", window: ",[262,59817,439],{"class":271},[262,59819,442],{"class":377},[262,59821,1710],{"class":271},[262,59823,1939],{"class":429},[262,59825,439],{"class":271},[262,59827,1160],{"class":429},[262,59829,59830],{"class":181,"line":570},[262,59831,59832],{"class":275},"    \"\"\"Return seconds to wait if over limit, else 0 (the call is allowed).\"\"\"\n",[262,59834,59835,59837,59839],{"class":181,"line":579},[262,59836,17280],{"class":429},[262,59838,476],{"class":377},[262,59840,59841],{"class":429}," time.monotonic()\n",[262,59843,59844,59847,59849],{"class":181,"line":586},[262,59845,59846],{"class":429},"    start, count ",[262,59848,476],{"class":377},[262,59850,59851],{"class":429}," _windows[user_id]\n",[262,59853,59854,59856,59859,59861,59863,59865,59868],{"class":181,"line":591},[262,59855,3454],{"class":377},[262,59857,59858],{"class":429}," now ",[262,59860,561],{"class":377},[262,59862,509],{"class":429},[262,59864,33631],{"class":377},[262,59866,59867],{"class":429}," window:          ",[262,59869,59870],{"class":291},"# window expired: start a fresh one\n",[262,59872,59873,59876,59878,59881,59883],{"class":181,"line":623},[262,59874,59875],{"class":429},"        _windows[user_id] ",[262,59877,476],{"class":377},[262,59879,59880],{"class":429}," (now, ",[262,59882,997],{"class":271},[262,59884,660],{"class":429},[262,59886,59887,59889],{"class":181,"line":634},[262,59888,8066],{"class":377},[262,59890,500],{"class":271},[262,59892,59893,59895,59898,59900,59903],{"class":181,"line":845},[262,59894,3454],{"class":377},[262,59896,59897],{"class":429}," count ",[262,59899,33631],{"class":377},[262,59901,59902],{"class":429}," limit:                 ",[262,59904,59905],{"class":291},"# over the limit inside this window\n",[262,59907,59908,59910,59912,59915,59917,59920,59922,59925,59927],{"class":181,"line":850},[262,59909,8066],{"class":377},[262,59911,23813],{"class":271},[262,59913,59914],{"class":429},"(window ",[262,59916,561],{"class":377},[262,59918,59919],{"class":429}," (now ",[262,59921,561],{"class":377},[262,59923,59924],{"class":429}," start)) ",[262,59926,531],{"class":377},[262,59928,3582],{"class":271},[262,59930,59931,59934,59936,59939,59941,59943],{"class":181,"line":864},[262,59932,59933],{"class":429},"    _windows[user_id] ",[262,59935,476],{"class":377},[262,59937,59938],{"class":429}," (start, count ",[262,59940,531],{"class":377},[262,59942,3243],{"class":271},[262,59944,660],{"class":429},[262,59946,59947,59949],{"class":181,"line":1683},[262,59948,573],{"class":377},[262,59950,500],{"class":271},[14,59952,59953,59954,59956,59957,59960,59961,59963,59964,59967],{},"The function returns ",[18,59955,102],{}," when the call is allowed and the number of seconds to wait when it is not. Using ",[18,59958,59959],{},"time.monotonic()"," rather than ",[18,59962,40828],{}," matters: a monotonic clock never jumps backward when the system time is adjusted, so your windows cannot be skewed by a clock change. The downside of a fixed window is the ",[27,59965,59966],{},"boundary burst",": a user can make 30 calls in the last second of one window and 30 more in the first second of the next, briefly doing double the rate. Step 3 fixes that.",[57,59969,59971],{"id":59970},"step-2-return-http-429-with-retry-after-in-fastapi","Step 2: Return HTTP 429 with Retry-After in FastAPI",[14,59973,59974,59975,59978,59979,59981],{},"A limiter is only useful when your endpoint enforces it. Wrap the check in a FastAPI dependency so any protected route runs it automatically. When the user is over the limit, raise an ",[18,59976,59977],{},"HTTPException"," with status 429 (the standard \"too many requests\" code) and a ",[18,59980,42730],{}," header telling the client how many seconds to back off.",[253,59983,59985],{"className":414,"code":59984,"language":416,"meta":258,"style":258},"# app.py\nfrom fastapi import Depends, FastAPI, Header, HTTPException\n\nfrom limiter import check_fixed_window\n\napp = FastAPI(title=\"Rate-limited AI SaaS\")\n\n# Stand-in for a real user table; resolve the API key to a user id.\nUSERS = {\"key_pro_xyz\": \"u_2\", \"key_free_abc\": \"u_1\"}\n\n\ndef rate_limited_user(x_api_key: str = Header(...)) -> str:\n    user_id = USERS.get(x_api_key)\n    if user_id is None:\n        raise HTTPException(status_code=401, detail=\"Invalid API key\")\n    retry_after = check_fixed_window(user_id, limit=30, window=60)\n    if retry_after:\n        raise HTTPException(\n            status_code=429,\n            detail=\"Rate limit exceeded\",\n            headers={\"Retry-After\": str(retry_after)},\n        )\n    return user_id\n\n\n@app.post(\"\u002Fv1\u002Fgenerate\")\ndef generate(user_id: str = Depends(rate_limited_user)) -> dict:\n    # Your paid AI work goes here; it only runs if the limit check passed.\n    return {\"ok\": True, \"user\": user_id}\n",[18,59986,59987,59991,60001,60005,60017,60021,60038,60042,60047,60072,60076,60080,60103,60114,60127,60149,60176,60183,60189,60199,60210,60227,60231,60238,60242,60246,60256,60275,60280],{"__ignoreMap":258},[262,59988,59989],{"class":181,"line":264},[262,59990,53591],{"class":291},[262,59992,59993,59995,59997,59999],{"class":181,"line":282},[262,59994,705],{"class":377},[262,59996,51571],{"class":429},[262,59998,684],{"class":377},[262,60000,58696],{"class":429},[262,60002,60003],{"class":181,"line":295},[262,60004,583],{"emptyLinePlaceholder":582},[262,60006,60007,60009,60012,60014],{"class":181,"line":345},[262,60008,705],{"class":377},[262,60010,60011],{"class":429}," limiter ",[262,60013,684],{"class":377},[262,60015,60016],{"class":429}," check_fixed_window\n",[262,60018,60019],{"class":181,"line":492},[262,60020,583],{"emptyLinePlaceholder":582},[262,60022,60023,60025,60027,60029,60031,60033,60036],{"class":181,"line":503},[262,60024,51635],{"class":429},[262,60026,476],{"class":377},[262,60028,53652],{"class":429},[262,60030,92],{"class":611},[262,60032,476],{"class":377},[262,60034,60035],{"class":275},"\"Rate-limited AI SaaS\"",[262,60037,660],{"class":429},[262,60039,60040],{"class":181,"line":521},[262,60041,583],{"emptyLinePlaceholder":582},[262,60043,60044],{"class":181,"line":537},[262,60045,60046],{"class":291},"# Stand-in for a real user table; resolve the API key to a user id.\n",[262,60048,60049,60051,60053,60055,60057,60059,60061,60063,60066,60068,60070],{"class":181,"line":549},[262,60050,57798],{"class":271},[262,60052,442],{"class":377},[262,60054,2276],{"class":429},[262,60056,58872],{"class":275},[262,60058,1231],{"class":429},[262,60060,57850],{"class":275},[262,60062,608],{"class":429},[262,60064,60065],{"class":275},"\"key_free_abc\"",[262,60067,1231],{"class":429},[262,60069,57816],{"class":275},[262,60071,16430],{"class":429},[262,60073,60074],{"class":181,"line":570},[262,60075,583],{"emptyLinePlaceholder":582},[262,60077,60078],{"class":181,"line":579},[262,60079,583],{"emptyLinePlaceholder":582},[262,60081,60082,60084,60087,60089,60091,60093,60095,60097,60099,60101],{"class":181,"line":586},[262,60083,423],{"class":377},[262,60085,60086],{"class":267}," rate_limited_user",[262,60088,57892],{"class":429},[262,60090,433],{"class":271},[262,60092,442],{"class":377},[262,60094,57899],{"class":429},[262,60096,57902],{"class":271},[262,60098,57905],{"class":429},[262,60100,433],{"class":271},[262,60102,1160],{"class":429},[262,60104,60105,60108,60110,60112],{"class":181,"line":591},[262,60106,60107],{"class":429},"    user_id ",[262,60109,476],{"class":377},[262,60111,55012],{"class":271},[262,60113,57920],{"class":429},[262,60115,60116,60118,60121,60123,60125],{"class":181,"line":623},[262,60117,3454],{"class":377},[262,60119,60120],{"class":429}," user_id ",[262,60122,20596],{"class":377},[262,60124,20599],{"class":271},[262,60126,1160],{"class":429},[262,60128,60129,60131,60133,60135,60137,60139,60141,60143,60145,60147],{"class":181,"line":634},[262,60130,4928],{"class":377},[262,60132,53750],{"class":429},[262,60134,53753],{"class":611},[262,60136,476],{"class":377},[262,60138,41445],{"class":271},[262,60140,608],{"class":429},[262,60142,53763],{"class":611},[262,60144,476],{"class":377},[262,60146,59055],{"class":275},[262,60148,660],{"class":429},[262,60150,60151,60154,60156,60159,60161,60163,60165,60167,60170,60172,60174],{"class":181,"line":845},[262,60152,60153],{"class":429},"    retry_after ",[262,60155,476],{"class":377},[262,60157,60158],{"class":429}," check_fixed_window(user_id, ",[262,60160,16586],{"class":611},[262,60162,476],{"class":377},[262,60164,9777],{"class":271},[262,60166,608],{"class":429},[262,60168,60169],{"class":611},"window",[262,60171,476],{"class":377},[262,60173,12826],{"class":271},[262,60175,660],{"class":429},[262,60177,60178,60180],{"class":181,"line":850},[262,60179,3454],{"class":377},[262,60181,60182],{"class":429}," retry_after:\n",[262,60184,60185,60187],{"class":181,"line":864},[262,60186,4928],{"class":377},[262,60188,56442],{"class":429},[262,60190,60191,60193,60195,60197],{"class":181,"line":1683},[262,60192,56447],{"class":611},[262,60194,476],{"class":377},[262,60196,59190],{"class":271},[262,60198,1315],{"class":429},[262,60200,60201,60203,60205,60208],{"class":181,"line":1688},[262,60202,56462],{"class":611},[262,60204,476],{"class":377},[262,60206,60207],{"class":275},"\"Rate limit exceeded\"",[262,60209,1315],{"class":429},[262,60211,60212,60214,60216,60218,60220,60222,60224],{"class":181,"line":1693},[262,60213,59208],{"class":611},[262,60215,476],{"class":377},[262,60217,3039],{"class":429},[262,60219,42601],{"class":275},[262,60221,1231],{"class":429},[262,60223,433],{"class":271},[262,60225,60226],{"class":429},"(retry_after)},\n",[262,60228,60229],{"class":181,"line":1728},[262,60230,6288],{"class":429},[262,60232,60233,60235],{"class":181,"line":1737},[262,60234,573],{"class":377},[262,60236,60237],{"class":429}," user_id\n",[262,60239,60240],{"class":181,"line":1751},[262,60241,583],{"emptyLinePlaceholder":582},[262,60243,60244],{"class":181,"line":1764},[262,60245,583],{"emptyLinePlaceholder":582},[262,60247,60248,60250,60252,60254],{"class":181,"line":1779},[262,60249,53718],{"class":267},[262,60251,602],{"class":429},[262,60253,59129],{"class":275},[262,60255,660],{"class":429},[262,60257,60258,60260,60262,60264,60266,60268,60271,60273],{"class":181,"line":1793},[262,60259,423],{"class":377},[262,60261,56930],{"class":267},[262,60263,56238],{"class":429},[262,60265,433],{"class":271},[262,60267,442],{"class":377},[262,60269,60270],{"class":429}," Depends(rate_limited_user)) -> ",[262,60272,5869],{"class":271},[262,60274,1160],{"class":429},[262,60276,60277],{"class":181,"line":1800},[262,60278,60279],{"class":291},"    # Your paid AI work goes here; it only runs if the limit check passed.\n",[262,60281,60282,60284,60286,60288,60290,60292,60294,60296],{"class":181,"line":1805},[262,60283,573],{"class":377},[262,60285,2276],{"class":429},[262,60287,10329],{"class":275},[262,60289,1231],{"class":429},[262,60291,4974],{"class":271},[262,60293,608],{"class":429},[262,60295,1291],{"class":275},[262,60297,60298],{"class":429},": user_id}\n",[14,60300,13310,60301,60303,60304,60306,60307,60309,60310,60312,60313,60315,60316,60318,60319,1363],{},[18,60302,53925],{}," and send more than 30 ",[18,60305,40598],{}," requests to ",[18,60308,59471],{}," within a minute using the header ",[18,60311,59475],{},". The 31st gets a ",[18,60314,59190],{}," with a ",[18,60317,42730],{}," header. The check runs as a dependency, so the limit is enforced before the expensive model call ever starts, which is the whole point: you reject over-limit traffic before spending a cent on it. Note that the provider can also return its own 429 from inside the AI call; that is a different limit, and handling it is covered in ",[51,60320,3379],{"href":3378},[57,60322,60324],{"id":60323},"step-3-switch-to-a-token-bucket-for-smoother-bursts","Step 3: Switch to a token bucket for smoother bursts",[14,60326,16693,60327,60330,60331,60334,60335,60338],{},[27,60328,60329],{},"token bucket"," avoids the boundary-burst problem and feels fairer to users. Picture a bucket that holds up to ",[18,60332,60333],{},"capacity"," tokens and refills at a steady ",[18,60336,60337],{},"rate"," tokens per second. Each request spends one token; if the bucket is empty, the request is refused. A user who has been quiet builds up a small reserve and can spend a short burst, but their sustained rate can never exceed the refill rate.",[253,60340,60342],{"className":414,"code":60341,"language":416,"meta":258,"style":258},"# token_bucket.py\nimport time\n\n# user_id -> (tokens_available, last_refill_timestamp)\n_buckets: dict[str, tuple[float, float]] = {}\n\n\ndef check_token_bucket(user_id: str, rate: float = 0.5, capacity: int = 30) -> int:\n    \"\"\"rate = tokens added per second; capacity = max burst. 0 means allowed.\"\"\"\n    now = time.monotonic()\n    tokens, last = _buckets.get(user_id, (float(capacity), now))\n    tokens = min(capacity, tokens + (now - last) * rate)  # refill since last call\n    if tokens \u003C 1:\n        _buckets[user_id] = (tokens, now)\n        return int((1 - tokens) \u002F rate) + 1                # seconds until 1 token\n    _buckets[user_id] = (tokens - 1, now)                  # spend one token\n    return 0\n",[18,60343,60344,60349,60355,60359,60364,60385,60389,60393,60428,60433,60441,60456,60486,60499,60509,60537,60557],{"__ignoreMap":258},[262,60345,60346],{"class":181,"line":264},[262,60347,60348],{"class":291},"# token_bucket.py\n",[262,60350,60351,60353],{"class":181,"line":282},[262,60352,684],{"class":377},[262,60354,2612],{"class":429},[262,60356,60357],{"class":181,"line":295},[262,60358,583],{"emptyLinePlaceholder":582},[262,60360,60361],{"class":181,"line":345},[262,60362,60363],{"class":291},"# user_id -> (tokens_available, last_refill_timestamp)\n",[262,60365,60366,60369,60371,60373,60375,60377,60379,60381,60383],{"class":181,"line":492},[262,60367,60368],{"class":429},"_buckets: dict[",[262,60370,433],{"class":271},[262,60372,59759],{"class":429},[262,60374,3832],{"class":271},[262,60376,608],{"class":429},[262,60378,3832],{"class":271},[262,60380,31012],{"class":429},[262,60382,476],{"class":377},[262,60384,29867],{"class":429},[262,60386,60387],{"class":181,"line":503},[262,60388,583],{"emptyLinePlaceholder":582},[262,60390,60391],{"class":181,"line":521},[262,60392,583],{"emptyLinePlaceholder":582},[262,60394,60395,60397,60400,60402,60404,60407,60409,60411,60413,60416,60418,60420,60422,60424,60426],{"class":181,"line":537},[262,60396,423],{"class":377},[262,60398,60399],{"class":267}," check_token_bucket",[262,60401,56238],{"class":429},[262,60403,433],{"class":271},[262,60405,60406],{"class":429},", rate: ",[262,60408,3832],{"class":271},[262,60410,442],{"class":377},[262,60412,3416],{"class":271},[262,60414,60415],{"class":429},", capacity: ",[262,60417,439],{"class":271},[262,60419,442],{"class":377},[262,60421,8114],{"class":271},[262,60423,1939],{"class":429},[262,60425,439],{"class":271},[262,60427,1160],{"class":429},[262,60429,60430],{"class":181,"line":549},[262,60431,60432],{"class":275},"    \"\"\"rate = tokens added per second; capacity = max burst. 0 means allowed.\"\"\"\n",[262,60434,60435,60437,60439],{"class":181,"line":570},[262,60436,17280],{"class":429},[262,60438,476],{"class":377},[262,60440,59841],{"class":429},[262,60442,60443,60446,60448,60451,60453],{"class":181,"line":579},[262,60444,60445],{"class":429},"    tokens, last ",[262,60447,476],{"class":377},[262,60449,60450],{"class":429}," _buckets.get(user_id, (",[262,60452,3832],{"class":271},[262,60454,60455],{"class":429},"(capacity), now))\n",[262,60457,60458,60461,60463,60466,60469,60471,60473,60475,60478,60480,60483],{"class":181,"line":586},[262,60459,60460],{"class":429},"    tokens ",[262,60462,476],{"class":377},[262,60464,60465],{"class":271}," min",[262,60467,60468],{"class":429},"(capacity, tokens ",[262,60470,531],{"class":377},[262,60472,59919],{"class":429},[262,60474,561],{"class":377},[262,60476,60477],{"class":429}," last) ",[262,60479,1003],{"class":377},[262,60481,60482],{"class":429}," rate)  ",[262,60484,60485],{"class":291},"# refill since last call\n",[262,60487,60488,60490,60493,60495,60497],{"class":181,"line":591},[262,60489,3454],{"class":377},[262,60491,60492],{"class":429}," tokens ",[262,60494,512],{"class":377},[262,60496,3243],{"class":271},[262,60498,1160],{"class":429},[262,60500,60501,60504,60506],{"class":181,"line":623},[262,60502,60503],{"class":429},"        _buckets[user_id] ",[262,60505,476],{"class":377},[262,60507,60508],{"class":429}," (tokens, now)\n",[262,60510,60511,60513,60515,60518,60520,60522,60525,60527,60530,60532,60534],{"class":181,"line":634},[262,60512,8066],{"class":377},[262,60514,23813],{"class":271},[262,60516,60517],{"class":429},"((",[262,60519,997],{"class":271},[262,60521,18319],{"class":377},[262,60523,60524],{"class":429}," tokens) ",[262,60526,981],{"class":377},[262,60528,60529],{"class":429}," rate) ",[262,60531,531],{"class":377},[262,60533,3243],{"class":271},[262,60535,60536],{"class":291},"                # seconds until 1 token\n",[262,60538,60539,60542,60544,60547,60549,60551,60554],{"class":181,"line":845},[262,60540,60541],{"class":429},"    _buckets[user_id] ",[262,60543,476],{"class":377},[262,60545,60546],{"class":429}," (tokens ",[262,60548,561],{"class":377},[262,60550,3243],{"class":271},[262,60552,60553],{"class":429},", now)                  ",[262,60555,60556],{"class":291},"# spend one token\n",[262,60558,60559,60561],{"class":181,"line":850},[262,60560,573],{"class":377},[262,60562,500],{"class":271},[14,60564,60565,60566,1374,60569,60572,60573,7632,60576,60579,60580,60583],{},"With ",[18,60567,60568],{},"rate=0.5",[18,60570,60571],{},"capacity=30",", a user gets one new request every two seconds on average but can fire up to 30 in quick succession after a quiet spell. To use it, swap ",[18,60574,60575],{},"check_fixed_window",[18,60577,60578],{},"check_token_bucket"," inside ",[18,60581,60582],{},"rate_limited_user",". The refill is computed lazily on each call from the elapsed time, so there is no background timer to run and the bucket state stays tiny. This smooth behaviour is why token buckets are the common choice for public APIs.",[57,60585,60587],{"id":60586},"step-4-move-the-limiter-to-redis-for-multiple-workers","Step 4: Move the limiter to Redis for multiple workers",[14,60589,60590,60591,60593],{},"Both versions above keep their state in a Python dict, which lives inside one process. The moment you run multiple ",[18,60592,52514],{}," workers, or more than one server, each has its own dict and its own count, so your effective limit multiplies by the number of workers and stops being enforced. The fix is a shared store. Redis is the standard choice because it is fast, lives outside your app processes, and can expire keys for you, which makes the window reset free.",[14,60595,60596,60597,60600],{},"Start a Redis instance (",[18,60598,60599],{},"docker run -p 6379:6379 redis"," is the quickest), then use an atomic increment so two simultaneous requests cannot both read a stale count:",[253,60602,60604],{"className":414,"code":60603,"language":416,"meta":258,"style":258},"# redis_limiter.py\nimport redis\nfrom fastapi import HTTPException\n\nr = redis.Redis(host=\"localhost\", port=6379, decode_responses=True)\n\n\ndef check_redis_window(user_id: str, limit: int = 30, window: int = 60) -> None:\n    key = f\"ratelimit:{user_id}\"\n    count = r.incr(key)              # atomic: increments and returns new value\n    if count == 1:\n        r.expire(key, window)        # first hit starts the window's countdown\n    if count > limit:\n        ttl = r.ttl(key)             # seconds left until Redis clears the key\n        raise HTTPException(\n            status_code=429,\n            detail=\"Rate limit exceeded\",\n            headers={\"Retry-After\": str(max(ttl, 1))},\n        )\n",[18,60605,60606,60611,60618,60629,60633,60672,60676,60680,60713,60734,60747,60759,60767,60777,60790,60796,60806,60816,60842],{"__ignoreMap":258},[262,60607,60608],{"class":181,"line":264},[262,60609,60610],{"class":291},"# redis_limiter.py\n",[262,60612,60613,60615],{"class":181,"line":282},[262,60614,684],{"class":377},[262,60616,60617],{"class":429}," redis\n",[262,60619,60620,60622,60624,60626],{"class":181,"line":295},[262,60621,705],{"class":377},[262,60623,51571],{"class":429},[262,60625,684],{"class":377},[262,60627,60628],{"class":429}," HTTPException\n",[262,60630,60631],{"class":181,"line":345},[262,60632,583],{"emptyLinePlaceholder":582},[262,60634,60635,60638,60640,60643,60646,60648,60651,60653,60656,60658,60661,60663,60666,60668,60670],{"class":181,"line":492},[262,60636,60637],{"class":429},"r ",[262,60639,476],{"class":377},[262,60641,60642],{"class":429}," redis.Redis(",[262,60644,60645],{"class":611},"host",[262,60647,476],{"class":377},[262,60649,60650],{"class":275},"\"localhost\"",[262,60652,608],{"class":429},[262,60654,60655],{"class":611},"port",[262,60657,476],{"class":377},[262,60659,60660],{"class":271},"6379",[262,60662,608],{"class":429},[262,60664,60665],{"class":611},"decode_responses",[262,60667,476],{"class":377},[262,60669,4974],{"class":271},[262,60671,660],{"class":429},[262,60673,60674],{"class":181,"line":503},[262,60675,583],{"emptyLinePlaceholder":582},[262,60677,60678],{"class":181,"line":521},[262,60679,583],{"emptyLinePlaceholder":582},[262,60681,60682,60684,60687,60689,60691,60693,60695,60697,60699,60701,60703,60705,60707,60709,60711],{"class":181,"line":537},[262,60683,423],{"class":377},[262,60685,60686],{"class":267}," check_redis_window",[262,60688,56238],{"class":429},[262,60690,433],{"class":271},[262,60692,17988],{"class":429},[262,60694,439],{"class":271},[262,60696,442],{"class":377},[262,60698,8114],{"class":271},[262,60700,59815],{"class":429},[262,60702,439],{"class":271},[262,60704,442],{"class":377},[262,60706,1710],{"class":271},[262,60708,1939],{"class":429},[262,60710,8471],{"class":271},[262,60712,1160],{"class":429},[262,60714,60715,60718,60720,60722,60725,60727,60730,60732],{"class":181,"line":549},[262,60716,60717],{"class":429},"    key ",[262,60719,476],{"class":377},[262,60721,10178],{"class":377},[262,60723,60724],{"class":275},"\"ratelimit:",[262,60726,3039],{"class":271},[262,60728,60729],{"class":429},"user_id",[262,60731,654],{"class":271},[262,60733,1257],{"class":275},[262,60735,60736,60739,60741,60744],{"class":181,"line":570},[262,60737,60738],{"class":429},"    count ",[262,60740,476],{"class":377},[262,60742,60743],{"class":429}," r.incr(key)              ",[262,60745,60746],{"class":291},"# atomic: increments and returns new value\n",[262,60748,60749,60751,60753,60755,60757],{"class":181,"line":579},[262,60750,3454],{"class":377},[262,60752,59897],{"class":429},[262,60754,10758],{"class":377},[262,60756,3243],{"class":271},[262,60758,1160],{"class":429},[262,60760,60761,60764],{"class":181,"line":586},[262,60762,60763],{"class":429},"        r.expire(key, window)        ",[262,60765,60766],{"class":291},"# first hit starts the window's countdown\n",[262,60768,60769,60771,60773,60775],{"class":181,"line":591},[262,60770,3454],{"class":377},[262,60772,59897],{"class":429},[262,60774,8086],{"class":377},[262,60776,18283],{"class":429},[262,60778,60779,60782,60784,60787],{"class":181,"line":623},[262,60780,60781],{"class":429},"        ttl ",[262,60783,476],{"class":377},[262,60785,60786],{"class":429}," r.ttl(key)             ",[262,60788,60789],{"class":291},"# seconds left until Redis clears the key\n",[262,60791,60792,60794],{"class":181,"line":634},[262,60793,4928],{"class":377},[262,60795,56442],{"class":429},[262,60797,60798,60800,60802,60804],{"class":181,"line":845},[262,60799,56447],{"class":611},[262,60801,476],{"class":377},[262,60803,59190],{"class":271},[262,60805,1315],{"class":429},[262,60807,60808,60810,60812,60814],{"class":181,"line":850},[262,60809,56462],{"class":611},[262,60811,476],{"class":377},[262,60813,60207],{"class":275},[262,60815,1315],{"class":429},[262,60817,60818,60820,60822,60824,60826,60828,60830,60832,60834,60837,60839],{"class":181,"line":864},[262,60819,59208],{"class":611},[262,60821,476],{"class":377},[262,60823,3039],{"class":429},[262,60825,42601],{"class":275},[262,60827,1231],{"class":429},[262,60829,433],{"class":271},[262,60831,602],{"class":429},[262,60833,53399],{"class":271},[262,60835,60836],{"class":429},"(ttl, ",[262,60838,997],{"class":271},[262,60840,60841],{"class":429},"))},\n",[262,60843,60844],{"class":181,"line":1683},[262,60845,6288],{"class":429},[14,60847,60848,60851,60852,60855,60856,60858,60859,60862],{},[18,60849,60850],{},"r.incr"," is atomic, so even under concurrent load each request gets a unique, correct count. The first request to create the key also sets its expiry, and Redis deletes the key automatically when the window ends, giving you a self-resetting fixed window with no cleanup code. The ",[18,60853,60854],{},"ttl"," (time to live) is exactly the seconds left in the window, which is the perfect value for ",[18,60857,42730],{},". Drop ",[18,60860,60861],{},"check_redis_window(user_id)"," into your FastAPI dependency in place of the in-memory check, and the same limit now holds across every worker and server you run.",[57,60864,1367],{"id":1366},[14,60866,60867],{},"These are the three knobs you tune for any of the limiters above.",[1379,60869,60870,60885],{},[1382,60871,60872],{},[1385,60873,60874,60876,60879,60882],{},[1388,60875,1390],{},[1388,60877,60878],{},"Limit",[1388,60880,60881],{},"Window",[1388,60883,60884],{},"Scope",[1398,60886,60887,60904,60918],{},[1385,60888,60889,60895,60898,60901],{},[1403,60890,60891,31800,60893],{},[18,60892,16586],{},[18,60894,60333],{},[1403,60896,60897],{},"Max calls allowed before a 429",[1403,60899,60900],{},"n\u002Fa",[1403,60902,60903],{},"Per user, per window",[1385,60905,60906,60910,60912,60915],{},[1403,60907,60908],{},[18,60909,60169],{},[1403,60911,60900],{},[1403,60913,60914],{},"Length of the counting interval in seconds",[1403,60916,60917],{},"Resets per window",[1385,60919,60920,60924,60927,60930],{},[1403,60921,60922],{},[18,60923,60337],{},[1403,60925,60926],{},"Sustained calls per second (token bucket)",[1403,60928,60929],{},"Continuous refill",[1403,60931,60932],{},"Per user, ongoing",[14,60934,60935,60936,60939,60940,60943],{},"A common starting point for an AI SaaS is ",[18,60937,60938],{},"limit=30, window=60"," per user on a paid plan, and something far tighter, such as ",[18,60941,60942],{},"limit=5, window=60",", on a free tier to blunt abuse before anyone has paid you anything.",[57,60945,1445],{"id":1444},[1447,60947,60948,60954,60972,60982],{},[1450,60949,60950,60953],{},[35,60951,60952],{},"Every worker enforces its own limit, so users get roughly N times the cap"," — Each process has its own in-memory dict. Move the counter to Redis (step 4) so all workers share one count.",[1450,60955,60956,60961,60962,60964,60965,60967,60968,60971],{},[35,60957,3349,60958,60960],{},[18,60959,42730],{}," header is missing from the 429 response"," — You raised the ",[18,60963,59977],{}," without the ",[18,60966,17057],{}," argument. Pass ",[18,60969,60970],{},"headers={\"Retry-After\": str(seconds)}"," so clients know when to retry instead of hammering you.",[1450,60973,60974,60977,60978,60981],{},[35,60975,60976],{},"Two concurrent requests both slip past the limit"," — A read-then-write check has a race between reading the count and writing the new one. Use Redis ",[18,60979,60980],{},"incr",", which increments atomically in a single operation, so no two requests can read the same stale value.",[1450,60983,60984,60987,60988,60990,60991,60994,60995,1363],{},[35,60985,60986],{},"Redis keys never expire and the limit jams permanently"," — You called ",[18,60989,60980],{}," but never set an expiry, so the count climbs forever. Call ",[18,60992,60993],{},"r.expire(key, window)"," when the count is 1, and confirm with ",[18,60996,60997],{},"redis-cli ttl ratelimit:\u003Cuser_id>",[57,60999,2317],{"id":2316},[2322,61001,61002,61008,61014],{},[1450,61003,61004,61007],{},[35,61005,61006],{},"In-memory limiter"," — Use it for a single-process MVP, local development, or a demo. It is zero extra infrastructure and a few lines of code, but it does not survive a restart and is not shared across workers, so it cannot enforce a true limit once you scale out.",[1450,61009,61010,61013],{},[35,61011,61012],{},"Redis limiter"," — Use it the moment you run more than one worker or server, which is most real deployments. It enforces one shared, atomic count everywhere and resets windows for free via key expiry, at the cost of running and connecting to a Redis instance.",[1450,61015,61016,61019,61020,1363],{},[35,61017,61018],{},"Gateway or platform rate limiting"," — Tools like an API gateway, a reverse proxy, or your cloud's edge can rate-limit before traffic even reaches your code. Reach for this when you want to shed abusive load early or limit by IP, but keep an application-level limit too, because only your code knows the per-user plan and can return a precise ",[18,61021,42730],{},[57,61023,2355],{"id":2354},[14,61025,61026,61027,61029,61030,61032,61033,1363],{},"With per-user limits in place, wire them into the rest of your service: identify callers reliably with ",[51,61028,54116],{"href":54115},", then turn the requests you do allow into revenue with ",[51,61031,54121],{"href":54120},". When the provider's own throttle trips inside your AI call, handle it cleanly with ",[51,61034,3379],{"href":3378},[14,61036,2375,61037,1363],{},[51,61038,39690],{"href":39689},[57,61040,2381],{"id":2380},[2322,61042,61043,61047,61051,61055],{},[1450,61044,61045],{},[51,61046,39690],{"href":39689},[1450,61048,61049],{},[51,61050,54121],{"href":54120},[1450,61052,61053],{},[51,61054,54116],{"href":54115},[1450,61056,61057],{},[51,61058,3379],{"href":3378},[2401,61060,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":61062},[61063,61064,61065,61066,61067,61068,61069,61070,61071,61072],{"id":237,"depth":282,"text":238},{"id":59706,"depth":282,"text":59707},{"id":59970,"depth":282,"text":59971},{"id":60323,"depth":282,"text":60324},{"id":60586,"depth":282,"text":60587},{"id":1366,"depth":282,"text":1367},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Protect cost and stability by rate-limiting per-user AI calls in Python. Build in-memory and Redis limiters that return HTTP 429 with Retry-After in FastAPI.",[61075,61078,61081,61084,61087],{"q":61076,"a":61077},"Why should I rate-limit AI API calls in my SaaS?","Each AI call costs you real money and time, so one buggy or abusive client can run up a large bill or starve other users of capacity. A per-user rate limit caps how often any single customer can call the model, which protects both your margin and your service's stability.",{"q":61079,"a":61080},"What is the difference between a token bucket and a fixed window?","A fixed window counts requests inside a clock interval, such as 60 per minute, and resets the count when the window rolls over. A token bucket refills allowance gradually and lets a user spend a small burst at once, which smooths traffic instead of allowing a spike right after each reset.",{"q":61082,"a":61083},"When do I need Redis instead of an in-memory limiter?","Use Redis as soon as you run more than one process or server, because an in-memory counter lives inside a single process and is not shared. With several workers each keeping its own count, your real limit multiplies by the worker count and stops being enforced.",{"q":61085,"a":61086},"What HTTP status should a rate-limited request return?","Return HTTP 429 Too Many Requests, and include a Retry-After header telling the client how many seconds to wait. Well-behaved clients read that header and back off instead of retrying in a tight loop that makes the overload worse.",{"q":61088,"a":61089},"Does rate-limiting my own API stop OpenAI from rate-limiting me?","Indirectly, yes. Capping how fast your users can call you bounds how fast you call the provider, which makes you far less likely to trip the provider's own 429 limit. The two limits are separate, though: yours protects your cost, theirs protects their capacity.",{"name":61091,"steps":61092},"How to rate-limit AI API calls in a Python SaaS",[61093,61096,61099,61102],{"name":61094,"text":61095},"Build an in-memory fixed-window limiter","Count each user's requests inside a time window and reject calls once they pass their limit.",{"name":61097,"text":61098},"Return HTTP 429 with Retry-After in FastAPI","Wrap the check in a FastAPI dependency that raises a 429 response and tells the client when to retry.",{"name":61100,"text":61101},"Switch to a token bucket for smoother bursts","Refill allowance gradually so users can spend a small burst without a hard spike at each reset.",{"name":61103,"text":61104},"Move the limiter to Redis for multiple workers","Store counts in Redis so the limit holds across every process and server in your deployment.",{},"\u002Fbuilding-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Frate-limit-ai-api-calls-in-a-saas-with-python",{"title":49599,"description":61073},"Rate-Limit AI API Calls in a Python SaaS","building-ai-powered-business-applications\u002Fsaas-mvp-with-python-ai\u002Frate-limit-ai-api-calls-in-a-saas-with-python\u002Findex","4xe86khRvmucwEDh0XYdPH0e2yCurLbvgqtP4Hf7Als",{"id":61112,"title":61113,"body":61114,"description":63380,"extension":2419,"faq":63381,"howto":63397,"meta":63412,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":63413,"published":63414,"seo":63415,"seoTitle":63416,"stem":63417,"__hash__":63418},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks\u002Findex.md","Automating Repetitive Tasks with Python and AI",{"type":7,"value":61115,"toc":63367},[61116,61119,61126,61133,61139,61216,61218,61233,61236,61242,61250,61252,61259,61269,61272,61310,61329,61334,61342,61350,61364,61372,61376,61379,61385,61571,61597,61613,61617,61627,61638,61895,61914,61927,61931,61934,62095,62109,62115,62119,62125,62300,62315,62347,62349,62352,62470,62472,62572,62576,62593,63294,63296,63299,63331,63335,63337,63364],[10,61117,61113],{"id":61118},"automating-repetitive-tasks-with-python-and-ai",[14,61120,61121,61122,61125],{},"Every week you probably spend hours doing the same small jobs by hand: sorting an inbox, reading invoices to copy three numbers into a spreadsheet, tagging support messages, or renaming files. None of it is hard. It is just slow, and it never ends. The reason it resists the old style of automation is that the inputs are messy and need a little judgement. A rule like \"if the subject contains ",[27,61123,61124],{},"invoice",", file it\" breaks the moment a real human writes \"Re: your bill from last month.\"",[14,61127,61128,61129,61132],{},"This guide shows you how to build a small Python program that does these jobs for you by leaning on an AI model for the judgement part. The shape is always the same and only four steps long: ",[35,61130,61131],{},"read your inputs, ask an AI to classify or transform each one, write the results somewhere useful, and put it on a schedule"," so it runs without you. You will write real, runnable code in each step, see a complete worked example you can copy, and learn how to fix the handful of errors that trip up beginners.",[14,61134,61135,61136,61138],{},"You do not need a computer science background. If you have followed ",[51,61137,26450],{"href":26449}," far enough to run a script and call an API once, you have everything you need here. Where a task needs deeper knowledge (cleaning the data first, or understanding how an API charges you), this guide links to the section that covers it.",[76,61140,61142,61213],{"className":61141},[79],[81,61143,90,61148,90,61151,61154,90,61160,90,61162,90,61166,90,61169,90,61171,90,61174,90,61177,90,61179,90,61182,90,61185,90,61189,90,61191,90,61193,90,61197,90,61201,90,61205],{"viewBox":61144,"role":84,"ariaLabelledBy":61145,"preserveAspectRatio":88,"xmlns":89},"-40 -40 920 460",[61146,61147],"loopTitle","loopDesc",[92,61149,61150],{"id":61146},"The four-step AI automation loop",[96,61152,61153],{"id":61147},"Inputs flow into a Python script, which sends each item to an AI model, writes the results out, and a scheduler triggers the whole loop again on a timer.",[111,61155,61156,61157],{"x":48091,"y":23367,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"\nRun once by hand,\n",[175,61158,61159],{"x":48091,"dy":177},"\nthen loop forever\n",[100,61161],{"x":102,"y":24368,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,61163,61165],{"x":113,"y":61164,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"173","1. Read inputs",[111,61167,61168],{"x":113,"y":57319,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"emails, files, rows",[100,61170],{"x":12816,"y":24368,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,61172,61173],{"x":12819,"y":61164,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"2. Call the AI",[111,61175,61176],{"x":12819,"y":57319,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"classify \u002F transform",[100,61178],{"x":12825,"y":24368,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,61180,61181],{"x":12829,"y":61164,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"3. Write output",[111,61183,61184],{"x":12829,"y":57319,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"CSV, files, API",[181,61186],{"x1":104,"y1":61187,"x2":12856,"y2":61187,"stroke":130,"strokeWidth":109,"markerEnd":61188},"176","url(#arrowLoop)",[181,61190],{"x1":158,"y1":61187,"x2":12863,"y2":61187,"stroke":130,"strokeWidth":109,"markerEnd":61188},[100,61192],{"x":12816,"y":52288,"width":104,"height":12826,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,61194,61196],{"x":12819,"y":61195,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"335","4. Scheduler",[216,61198],{"d":61199,"fill":219,"stroke":169,"strokeWidth":109,"strokeDashArray":61200,"markerEnd":61188},"M660 212 L660 330 L484 330",[222,19848],[216,61202],{"d":61203,"fill":219,"stroke":169,"strokeWidth":109,"strokeDashArray":61204,"markerEnd":61188},"M276 330 L100 330 L100 216",[222,19848],[5548,61206,5550,61207,90],{},[5552,61208,5558,61210,5550],{"id":61209,"markerWidth":7162,"markerHeight":7162,"refX":7163,"refY":19848,"orient":5557},"arrowLoop",[216,61211],{"d":61212,"fill":130},"M0 0 L8 4 L0 8 z",[232,61214,61215],{},"The same four steps power every automation in this guide: read, classify, write, repeat on a schedule.",[57,61217,12747],{"id":12746},[14,61219,61220,61221,608,61224,608,61227,14716,61229,61232],{},"You are a good fit for this guide if you do a recurring task that involves reading text a person wrote and deciding what to do with it. The classic example, and the one we build toward, is inbox sorting: unread mail comes in, the AI labels each message as ",[18,61222,61223],{},"urgent",[18,61225,61226],{},"newsletter",[18,61228,61124],{},[18,61230,61231],{},"general",", and the labels get written to a log you can act on. The exact task does not matter. Swap emails for support tickets, PDF invoices, or rows in a spreadsheet and the code barely changes.",[14,61234,61235],{},"The skill you are really learning is how to split a chore into two kinds of work: the parts a computer can do with fixed rules, and the parts that need a human-style read of messy text. Plain Python is perfect for the fixed parts — listing files, looping, writing a CSV — and it is free and instant. The AI handles only the judgement: \"is this email urgent?\", \"what is the total on this invoice?\", \"is this review positive or negative?\". Keeping that split clear is what makes your automation cheap, fast, and easy to debug, because most of the program is ordinary code you can read at a glance.",[14,61237,61238,61239,61241],{},"It also helps to know when ",[27,61240,17892],{}," to reach for an AI at all. If a task has a clean rule — \"move every file older than 30 days into an archive folder\" — a few lines of plain Python beat an AI on speed, cost, and reliability every time. Save the model for the moments where a rule would need a hundred exceptions to work. A good test: if you can describe the decision to a coworker in one sentence and they would always agree on the answer, write a rule; if reasonable people would sometimes disagree, that is judgement, and that is where the AI earns its place.",[14,61243,61244,61245,61249],{},"By the end you will have a single Python file that reads a batch of inputs, sends each to an AI model, saves the answers, and runs itself every morning. From there, the dedicated ",[51,61246,61248],{"href":61247},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks\u002Fpython-script-to-automate-email-sorting\u002F","Python Script to Automate Email Sorting"," guide takes the same pattern all the way to a live Gmail inbox.",[57,61251,238],{"id":237},[14,61253,61254,61255,61258],{},"You need ",[35,61256,61257],{},"Python 3.10 or newer",". Check what you have:",[253,61260,61261],{"className":255,"code":52405,"language":257,"meta":258,"style":258},[18,61262,61263],{"__ignoreMap":258},[262,61264,61265,61267],{"class":181,"line":264},[262,61266,268],{"class":267},[262,61268,52414],{"class":271},[14,61270,61271],{},"Create a project folder, make an isolated virtual environment (a private copy of Python so these packages do not collide with anything else on your machine), and install the four libraries this guide uses:",[253,61273,61275],{"className":255,"code":61274,"language":257,"meta":258,"style":258},"python3 -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\npip install openai python-dotenv schedule httpx\n",[18,61276,61277,61287,61295],{"__ignoreMap":258},[262,61278,61279,61281,61283,61285],{"class":181,"line":264},[262,61280,268],{"class":267},[262,61282,272],{"class":271},[262,61284,276],{"class":275},[262,61286,279],{"class":275},[262,61288,61289,61291,61293],{"class":181,"line":282},[262,61290,285],{"class":271},[262,61292,288],{"class":275},[262,61294,7222],{"class":291},[262,61296,61297,61299,61301,61303,61305,61308],{"class":181,"line":295},[262,61298,298],{"class":267},[262,61300,301],{"class":275},[262,61302,2519],{"class":275},[262,61304,310],{"class":275},[262,61306,61307],{"class":275}," schedule",[262,61309,6526],{"class":275},[14,61311,61312,61313,61315,61316,61318,61319,61322,61323,61325,61326,61328],{},"Here is what each one does: ",[18,61314,20],{}," is the official SDK for talking to the AI model, ",[18,61317,2501],{}," loads your secret key from a file, ",[18,61320,61321],{},"schedule"," runs your job on a timer, and ",[18,61324,5450],{}," is a modern HTTP client used if you ever call an API the SDK does not cover. If installation as it stands fails, the ",[51,61327,5423],{"href":5422}," section walks through OS-specific fixes.",[14,61330,61331,61332,22741],{},"Next, store your API key. Never paste a key directly into your code, because anyone who sees the file then has your key. Create a file named ",[18,61333,319],{},[253,61335,61336],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,61337,61338],{"__ignoreMap":258},[262,61339,61340],{"class":181,"line":264},[262,61341,337],{},[14,61343,61344,61345,356,61347,61349],{},"Then immediately add ",[18,61346,319],{},[18,61348,359],{}," so the key is never committed or shared:",[253,61351,61352],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,61353,61354],{"__ignoreMap":258},[262,61355,61356,61358,61360,61362],{"class":181,"line":264},[262,61357,371],{"class":271},[262,61359,374],{"class":275},[262,61361,378],{"class":377},[262,61363,381],{"class":275},[14,61365,61366,61367,61369,61370,1363],{},"That ",[18,61368,359],{}," line is the single most important habit in this guide. A leaked key can run up real charges on your account. To understand how keys, models, and billing actually work, read ",[51,61371,2487],{"href":2486},[57,61373,61375],{"id":61374},"step-1-read-your-inputs","Step 1: Read your inputs",[14,61377,61378],{},"Every automation starts by pulling the work into Python as a plain list of small text records. Keep each record short — a subject line, one row, one file's text — because the AI charges by how much text you send and reads faster when there is less of it.",[14,61380,61381,61382,61384],{},"To keep this step easy to test, we read from a folder of ",[18,61383,406],{}," files. Each file is one item to process. Later you can replace this function with one that reads a live inbox or a spreadsheet; the rest of the program will not care where the text came from.",[253,61386,61388],{"className":414,"code":61387,"language":416,"meta":258,"style":258},"from pathlib import Path\n\n\ndef read_inputs(folder: str) -> list[dict]:\n    \"\"\"Load every .txt file in a folder as a list of records.\"\"\"\n    records = []\n    for path in sorted(Path(folder).glob(\"*.txt\")):\n        text = path.read_text(encoding=\"utf-8\", errors=\"ignore\").strip()\n        if text:\n            records.append({\"id\": path.name, \"text\": text})\n    return records\n\n\nif __name__ == \"__main__\":\n    items = read_inputs(\"inbox\")\n    print(f\"Loaded {len(items)} items to process\")\n",[18,61389,61390,61400,61404,61408,61426,61431,61440,61460,61487,61493,61507,61514,61518,61522,61534,61549],{"__ignoreMap":258},[262,61391,61392,61394,61396,61398],{"class":181,"line":264},[262,61393,705],{"class":377},[262,61395,4882],{"class":429},[262,61397,684],{"class":377},[262,61399,4887],{"class":429},[262,61401,61402],{"class":181,"line":282},[262,61403,583],{"emptyLinePlaceholder":582},[262,61405,61406],{"class":181,"line":295},[262,61407,583],{"emptyLinePlaceholder":582},[262,61409,61410,61412,61415,61418,61420,61422,61424],{"class":181,"line":345},[262,61411,423],{"class":377},[262,61413,61414],{"class":267}," read_inputs",[262,61416,61417],{"class":429},"(folder: ",[262,61419,433],{"class":271},[262,61421,458],{"class":429},[262,61423,5869],{"class":271},[262,61425,463],{"class":429},[262,61427,61428],{"class":181,"line":492},[262,61429,61430],{"class":275},"    \"\"\"Load every .txt file in a folder as a list of records.\"\"\"\n",[262,61432,61433,61436,61438],{"class":181,"line":503},[262,61434,61435],{"class":429},"    records ",[262,61437,476],{"class":377},[262,61439,489],{"class":429},[262,61441,61442,61444,61447,61449,61452,61455,61458],{"class":181,"line":521},[262,61443,3074],{"class":377},[262,61445,61446],{"class":429}," path ",[262,61448,835],{"class":377},[262,61450,61451],{"class":271}," sorted",[262,61453,61454],{"class":429},"(Path(folder).glob(",[262,61456,61457],{"class":275},"\"*.txt\"",[262,61459,23288],{"class":429},[262,61461,61462,61464,61466,61469,61471,61473,61475,61477,61480,61482,61485],{"class":181,"line":537},[262,61463,18264],{"class":429},[262,61465,476],{"class":377},[262,61467,61468],{"class":429}," path.read_text(",[262,61470,612],{"class":611},[262,61472,476],{"class":377},[262,61474,617],{"class":275},[262,61476,608],{"class":429},[262,61478,61479],{"class":611},"errors",[262,61481,476],{"class":377},[262,61483,61484],{"class":275},"\"ignore\"",[262,61486,2262],{"class":429},[262,61488,61489,61491],{"class":181,"line":549},[262,61490,2268],{"class":377},[262,61492,18359],{"class":429},[262,61494,61495,61498,61500,61503,61505],{"class":181,"line":570},[262,61496,61497],{"class":429},"            records.append({",[262,61499,6770],{"class":275},[262,61501,61502],{"class":429},": path.name, ",[262,61504,16074],{"class":275},[262,61506,22323],{"class":429},[262,61508,61509,61511],{"class":181,"line":579},[262,61510,573],{"class":377},[262,61512,61513],{"class":429}," records\n",[262,61515,61516],{"class":181,"line":586},[262,61517,583],{"emptyLinePlaceholder":582},[262,61519,61520],{"class":181,"line":591},[262,61521,583],{"emptyLinePlaceholder":582},[262,61523,61524,61526,61528,61530,61532],{"class":181,"line":623},[262,61525,2210],{"class":377},[262,61527,2213],{"class":271},[262,61529,2216],{"class":377},[262,61531,2219],{"class":275},[262,61533,1160],{"class":429},[262,61535,61536,61539,61541,61544,61547],{"class":181,"line":634},[262,61537,61538],{"class":429},"    items ",[262,61540,476],{"class":377},[262,61542,61543],{"class":429}," read_inputs(",[262,61545,61546],{"class":275},"\"inbox\"",[262,61548,660],{"class":429},[262,61550,61551,61553,61555,61557,61559,61561,61564,61566,61569],{"class":181,"line":845},[262,61552,1089],{"class":271},[262,61554,602],{"class":429},[262,61556,642],{"class":377},[262,61558,2775],{"class":275},[262,61560,648],{"class":271},[262,61562,61563],{"class":429},"(items)",[262,61565,654],{"class":271},[262,61567,61568],{"class":275}," items to process\"",[262,61570,660],{"class":429},[14,61572,61573,61574,61577,61578,61580,61581,61584,61585,61588,61589,61592,61593,61596],{},"Make a folder named ",[18,61575,61576],{},"inbox",", drop a couple of ",[18,61579,406],{}," files into it, and run the script. You should see a count of how many it found. A few details in this small function are worth understanding because they save you pain later. ",[18,61582,61583],{},"Path(folder).glob(\"*.txt\")"," finds every text file without you having to know their names in advance, and wrapping it in ",[18,61586,61587],{},"sorted()"," makes the order predictable so two runs behave the same way. The ",[18,61590,61591],{},"errors=\"ignore\""," argument tells Python to skip stray characters instead of crashing — real-world files are full of odd bytes from copy-pasted symbols and foreign keyboards, and a skipped character is far better than a failed run. The ",[18,61594,61595],{},"if text:"," check quietly drops empty files so you never waste an AI call on nothing.",[14,61598,61599,61600,24612,61602,61604,61605,61607,61608,61612],{},"Notice too that each record is a small dictionary with an ",[18,61601,9492],{},[18,61603,111],{},". That ",[18,61606,9492],{}," is your thread back to the original — the filename here, but a message ID or a database key in a real system — so that when the AI returns an answer you always know which item it belongs to. Keep your records small and consistent in shape, and every later step stays simple. If your inputs are spreadsheets or messy exports rather than tidy text files, run them through the ",[51,61609,61611],{"href":61610},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai\u002F","Data Cleaning for AI"," steps first, because the cleaner the text you hand the model, the more accurate and cheaper its answers become.",[57,61614,61616],{"id":61615},"step-2-call-an-ai-to-classify-or-transform-each-item","Step 2: Call an AI to classify or transform each item",[14,61618,61619,61620,61622,61623,61626],{},"This is where the judgement happens. You send the model a short instruction (a ",[27,61621,9496],{},") plus the item's text, and it sends back an answer. The trick to making this reliable inside a program is to ask for a ",[35,61624,61625],{},"predictable, machine-readable shape"," — here, a single JSON object — rather than a friendly paragraph.",[14,61628,12767,61629,61631,61632,61634,61635,61637],{},[18,61630,20],{}," SDK and ask the model to return JSON with ",[18,61633,5745],{},". Setting a low ",[18,61636,3829],{}," (how much randomness the model uses) keeps answers consistent, which is exactly what you want when the same input should always get the same label.",[253,61639,61641],{"className":414,"code":61640,"language":416,"meta":258,"style":258},"import os\nimport json\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef classify(text: str) -> dict:\n    \"\"\"Ask the AI to label one item and return a parsed dict.\"\"\"\n    prompt = (\n        \"Classify the message below into exactly one category: \"\n        \"'urgent', 'newsletter', 'invoice', or 'general'. \"\n        \"Reply with JSON shaped like {\\\"category\\\": \\\"...\\\", \\\"reason\\\": \\\"...\\\"}.\\n\\n\"\n        f\"Message:\\n{text[:1500]}\"\n    )\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        temperature=0,\n        response_format={\"type\": \"json_object\"},\n    )\n    return json.loads(response.choices[0].message.content)\n",[18,61642,61643,61649,61655,61665,61675,61679,61683,61701,61705,61709,61726,61731,61739,61744,61749,61792,61813,61817,61825,61835,61855,61865,61881,61885],{"__ignoreMap":258},[262,61644,61645,61647],{"class":181,"line":264},[262,61646,684],{"class":377},[262,61648,687],{"class":429},[262,61650,61651,61653],{"class":181,"line":282},[262,61652,684],{"class":377},[262,61654,5766],{"class":429},[262,61656,61657,61659,61661,61663],{"class":181,"line":295},[262,61658,705],{"class":377},[262,61660,720],{"class":429},[262,61662,684],{"class":377},[262,61664,725],{"class":429},[262,61666,61667,61669,61671,61673],{"class":181,"line":345},[262,61668,705],{"class":377},[262,61670,708],{"class":429},[262,61672,684],{"class":377},[262,61674,713],{"class":429},[262,61676,61677],{"class":181,"line":492},[262,61678,583],{"emptyLinePlaceholder":582},[262,61680,61681],{"class":181,"line":503},[262,61682,734],{"class":429},[262,61684,61685,61687,61689,61691,61693,61695,61697,61699],{"class":181,"line":521},[262,61686,739],{"class":429},[262,61688,476],{"class":377},[262,61690,1588],{"class":429},[262,61692,2674],{"class":611},[262,61694,476],{"class":377},[262,61696,1199],{"class":429},[262,61698,2681],{"class":275},[262,61700,2684],{"class":429},[262,61702,61703],{"class":181,"line":537},[262,61704,583],{"emptyLinePlaceholder":582},[262,61706,61707],{"class":181,"line":549},[262,61708,583],{"emptyLinePlaceholder":582},[262,61710,61711,61713,61716,61718,61720,61722,61724],{"class":181,"line":570},[262,61712,423],{"class":377},[262,61714,61715],{"class":267}," classify",[262,61717,430],{"class":429},[262,61719,433],{"class":271},[262,61721,1939],{"class":429},[262,61723,5869],{"class":271},[262,61725,1160],{"class":429},[262,61727,61728],{"class":181,"line":579},[262,61729,61730],{"class":275},"    \"\"\"Ask the AI to label one item and return a parsed dict.\"\"\"\n",[262,61732,61733,61735,61737],{"class":181,"line":586},[262,61734,18006],{"class":429},[262,61736,476],{"class":377},[262,61738,984],{"class":429},[262,61740,61741],{"class":181,"line":591},[262,61742,61743],{"class":275},"        \"Classify the message below into exactly one category: \"\n",[262,61745,61746],{"class":181,"line":623},[262,61747,61748],{"class":275},"        \"'urgent', 'newsletter', 'invoice', or 'general'. \"\n",[262,61750,61751,61754,61756,61759,61761,61763,61765,61767,61769,61771,61773,61775,61777,61779,61781,61783,61785,61788,61790],{"class":181,"line":634},[262,61752,61753],{"class":275},"        \"Reply with JSON shaped like {",[262,61755,34149],{"class":271},[262,61757,61758],{"class":275},"category",[262,61760,34149],{"class":271},[262,61762,1231],{"class":275},[262,61764,34149],{"class":271},[262,61766,57902],{"class":275},[262,61768,34149],{"class":271},[262,61770,608],{"class":275},[262,61772,34149],{"class":271},[262,61774,8260],{"class":275},[262,61776,34149],{"class":271},[262,61778,1231],{"class":275},[262,61780,34149],{"class":271},[262,61782,57902],{"class":275},[262,61784,34149],{"class":271},[262,61786,61787],{"class":275},"}.",[262,61789,1173],{"class":271},[262,61791,1257],{"class":275},[262,61793,61794,61796,61799,61801,61804,61807,61809,61811],{"class":181,"line":845},[262,61795,2840],{"class":377},[262,61797,61798],{"class":275},"\"Message:",[262,61800,1268],{"class":271},[262,61802,61803],{"class":429},"text[:",[262,61805,61806],{"class":271},"1500",[262,61808,6223],{"class":429},[262,61810,654],{"class":271},[262,61812,1257],{"class":275},[262,61814,61815],{"class":181,"line":850},[262,61816,1011],{"class":429},[262,61818,61819,61821,61823],{"class":181,"line":864},[262,61820,1184],{"class":429},[262,61822,476],{"class":377},[262,61824,1189],{"class":429},[262,61826,61827,61829,61831,61833],{"class":181,"line":1683},[262,61828,1194],{"class":611},[262,61830,476],{"class":377},[262,61832,1207],{"class":275},[262,61834,1315],{"class":429},[262,61836,61837,61839,61841,61843,61845,61847,61849,61851,61853],{"class":181,"line":1688},[262,61838,1215],{"class":611},[262,61840,476],{"class":377},[262,61842,8856],{"class":429},[262,61844,1228],{"class":275},[262,61846,1231],{"class":429},[262,61848,1291],{"class":275},[262,61850,608],{"class":429},[262,61852,1239],{"class":275},[262,61854,18141],{"class":429},[262,61856,61857,61859,61861,61863],{"class":181,"line":1693},[262,61858,1308],{"class":611},[262,61860,476],{"class":377},[262,61862,102],{"class":271},[262,61864,1315],{"class":429},[262,61866,61867,61869,61871,61873,61875,61877,61879],{"class":181,"line":1728},[262,61868,6018],{"class":611},[262,61870,476],{"class":377},[262,61872,3039],{"class":429},[262,61874,6025],{"class":275},[262,61876,1231],{"class":429},[262,61878,6030],{"class":275},[262,61880,3143],{"class":429},[262,61882,61883],{"class":181,"line":1737},[262,61884,1011],{"class":429},[262,61886,61887,61889,61891,61893],{"class":181,"line":1751},[262,61888,573],{"class":377},[262,61890,6043],{"class":429},[262,61892,102],{"class":271},[262,61894,6048],{"class":429},[14,61896,61897,61898,61901,61902,61904,61905,61907,61908,61910,61911,61913],{},"Several choices in this function are doing real work. The ",[18,61899,61900],{},"text[:1500]"," cap protects you from sending a giant document by accident, which would slow the call down and run up cost — the model is paid per chunk of text, called a ",[27,61903,7933],{},", so trimming directly saves money. Setting ",[18,61906,1357],{}," removes randomness, which means the same email gets the same label every time; that consistency is exactly what you want for sorting, even though you would raise it for creative rewriting. The ",[18,61909,6878],{}," line tells the model to reply with valid JSON instead of a chatty sentence, so ",[18,61912,20396],{}," can turn the answer straight into a Python dictionary your code can use.",[14,61915,61916,61917,61919,61920,61922,61923,1374,61925,1363],{},"The prompt itself deserves care, because it is the difference between an automation you trust and one you constantly babysit. Notice that it names the exact categories allowed, shows the precise JSON shape to return, and labels the user's text clearly so the model never confuses your instructions with the content. Asking for a short ",[18,61918,8260],{}," alongside the ",[18,61921,61758],{}," is a cheap trick that pays off twice: it nudges the model to think before answering, which improves accuracy, and it gives you a human-readable note to check when a label looks wrong. To go deeper on writing prompts that hold their shape and on how models and pricing work, see ",[51,61924,7554],{"href":7553},[51,61926,2487],{"href":2486},[57,61928,61930],{"id":61929},"step-3-write-the-results-somewhere-useful","Step 3: Write the results somewhere useful",[14,61932,61933],{},"A classification you cannot see is wasted work. Write each result to an output the rest of your tools can read. A CSV file is the friendliest choice: it opens in any spreadsheet and is trivial to append to as new items arrive.",[253,61935,61937],{"className":414,"code":61936,"language":416,"meta":258,"style":258},"import csv\nfrom pathlib import Path\n\n\ndef write_results(rows: list[dict], outfile: str = \"results.csv\") -> None:\n    \"\"\"Append classified rows to a CSV, writing the header once.\"\"\"\n    fields = [\"id\", \"category\", \"reason\"]\n    file_exists = Path(outfile).exists()\n    with open(outfile, \"a\", newline=\"\", encoding=\"utf-8\") as f:\n        writer = csv.DictWriter(f, fieldnames=fields)\n        if not file_exists:\n            writer.writeheader()\n        writer.writerows(rows)\n",[18,61938,61939,61945,61955,61959,61963,61990,61995,62017,62027,62061,62076,62085,62090],{"__ignoreMap":258},[262,61940,61941,61943],{"class":181,"line":264},[262,61942,684],{"class":377},[262,61944,8533],{"class":429},[262,61946,61947,61949,61951,61953],{"class":181,"line":282},[262,61948,705],{"class":377},[262,61950,4882],{"class":429},[262,61952,684],{"class":377},[262,61954,4887],{"class":429},[262,61956,61957],{"class":181,"line":295},[262,61958,583],{"emptyLinePlaceholder":582},[262,61960,61961],{"class":181,"line":345},[262,61962,583],{"emptyLinePlaceholder":582},[262,61964,61965,61967,61970,61973,61975,61978,61980,61982,61984,61986,61988],{"class":181,"line":492},[262,61966,423],{"class":377},[262,61968,61969],{"class":267}," write_results",[262,61971,61972],{"class":429},"(rows: list[",[262,61974,5869],{"class":271},[262,61976,61977],{"class":429},"], outfile: ",[262,61979,433],{"class":271},[262,61981,442],{"class":377},[262,61983,25612],{"class":275},[262,61985,1939],{"class":429},[262,61987,8471],{"class":271},[262,61989,1160],{"class":429},[262,61991,61992],{"class":181,"line":503},[262,61993,61994],{"class":275},"    \"\"\"Append classified rows to a CSV, writing the header once.\"\"\"\n",[262,61996,61997,61999,62001,62003,62005,62007,62010,62012,62015],{"class":181,"line":521},[262,61998,10558],{"class":429},[262,62000,476],{"class":377},[262,62002,10563],{"class":429},[262,62004,6770],{"class":275},[262,62006,608],{"class":429},[262,62008,62009],{"class":275},"\"category\"",[262,62011,608],{"class":429},[262,62013,62014],{"class":275},"\"reason\"",[262,62016,957],{"class":429},[262,62018,62019,62022,62024],{"class":181,"line":537},[262,62020,62021],{"class":429},"    file_exists ",[262,62023,476],{"class":377},[262,62025,62026],{"class":429}," Path(outfile).exists()\n",[262,62028,62029,62031,62033,62036,62039,62041,62043,62045,62047,62049,62051,62053,62055,62057,62059],{"class":181,"line":549},[262,62030,10124],{"class":377},[262,62032,599],{"class":271},[262,62034,62035],{"class":429},"(outfile, ",[262,62037,62038],{"class":275},"\"a\"",[262,62040,608],{"class":429},[262,62042,9170],{"class":611},[262,62044,476],{"class":377},[262,62046,9175],{"class":275},[262,62048,608],{"class":429},[262,62050,612],{"class":611},[262,62052,476],{"class":377},[262,62054,617],{"class":275},[262,62056,1000],{"class":429},[262,62058,697],{"class":377},[262,62060,9190],{"class":429},[262,62062,62063,62065,62067,62070,62072,62074],{"class":181,"line":570},[262,62064,10623],{"class":429},[262,62066,476],{"class":377},[262,62068,62069],{"class":429}," csv.DictWriter(f, ",[262,62071,10631],{"class":611},[262,62073,476],{"class":377},[262,62075,10636],{"class":429},[262,62077,62078,62080,62082],{"class":181,"line":579},[262,62079,2268],{"class":377},[262,62081,2818],{"class":377},[262,62083,62084],{"class":429}," file_exists:\n",[262,62086,62087],{"class":181,"line":586},[262,62088,62089],{"class":429},"            writer.writeheader()\n",[262,62091,62092],{"class":181,"line":591},[262,62093,62094],{"class":429},"        writer.writerows(rows)\n",[14,62096,62097,62098,62100,62101,62104,62105,62108],{},"Opening the file in append mode (",[18,62099,62038],{},") means each run adds to the history instead of erasing it, and the ",[18,62102,62103],{},"file_exists"," check writes the column header only the first time so you do not get a header line buried in the middle of your data. The ",[18,62106,62107],{},"newline=\"\""," argument is a small but important detail on Windows: without it the CSV module inserts blank rows between every record. Once results land in a CSV you can open it in any spreadsheet, filter by category, and act on the urgent items first — the automation has done the reading for you.",[14,62110,62111,62112,62114],{},"Writing to a file is the safest place to start because it never fails silently and you can always inspect it. If your real output needs to land in a CRM, a Slack channel, or another web service, the same idea applies — you swap the CSV writer for an API call and keep the rest of the loop untouched. That swap-one-part habit is what makes these pipelines durable: each step has one job, so you can change where the data comes from or where it goes without rewriting the logic in between. The ",[51,62113,26457],{"href":26456}," track covers those downstream integrations in depth.",[57,62116,62118],{"id":62117},"step-4-schedule-it-so-it-runs-on-its-own","Step 4: Schedule it so it runs on its own",[14,62120,62121,62122,62124],{},"The point of automation is that you stop touching it. Wrap the three steps above into one function, then trigger that function on a timer. The ",[18,62123,61321],{}," library is the simplest option for a machine you keep running:",[253,62126,62128],{"className":414,"code":62127,"language":416,"meta":258,"style":258},"import schedule\nimport time\n\n\ndef run_cycle() -> None:\n    items = read_inputs(\"inbox\")\n    rows = []\n    for item in items:\n        result = classify(item[\"text\"])\n        rows.append({\"id\": item[\"id\"], **result})\n    write_results(rows)\n    print(f\"Processed {len(rows)} items\")\n\n\nschedule.every().day.at(\"08:00\").do(run_cycle)\n\nwhile True:\n    schedule.run_pending()\n    time.sleep(60)\n",[18,62129,62130,62137,62143,62147,62151,62164,62176,62184,62195,62208,62227,62232,62255,62259,62263,62274,62278,62287,62292],{"__ignoreMap":258},[262,62131,62132,62134],{"class":181,"line":264},[262,62133,684],{"class":377},[262,62135,62136],{"class":429}," schedule\n",[262,62138,62139,62141],{"class":181,"line":282},[262,62140,684],{"class":377},[262,62142,2612],{"class":429},[262,62144,62145],{"class":181,"line":295},[262,62146,583],{"emptyLinePlaceholder":582},[262,62148,62149],{"class":181,"line":345},[262,62150,583],{"emptyLinePlaceholder":582},[262,62152,62153,62155,62158,62160,62162],{"class":181,"line":492},[262,62154,423],{"class":377},[262,62156,62157],{"class":267}," run_cycle",[262,62159,15481],{"class":429},[262,62161,8471],{"class":271},[262,62163,1160],{"class":429},[262,62165,62166,62168,62170,62172,62174],{"class":181,"line":503},[262,62167,61538],{"class":429},[262,62169,476],{"class":377},[262,62171,61543],{"class":429},[262,62173,61546],{"class":275},[262,62175,660],{"class":429},[262,62177,62178,62180,62182],{"class":181,"line":521},[262,62179,25637],{"class":429},[262,62181,476],{"class":377},[262,62183,489],{"class":429},[262,62185,62186,62188,62190,62192],{"class":181,"line":537},[262,62187,3074],{"class":377},[262,62189,832],{"class":429},[262,62191,835],{"class":377},[262,62193,62194],{"class":429}," items:\n",[262,62196,62197,62199,62201,62204,62206],{"class":181,"line":549},[262,62198,9233],{"class":429},[262,62200,476],{"class":377},[262,62202,62203],{"class":429}," classify(item[",[262,62205,16074],{"class":275},[262,62207,3512],{"class":429},[262,62209,62210,62213,62215,62218,62220,62222,62224],{"class":181,"line":570},[262,62211,62212],{"class":429},"        rows.append({",[262,62214,6770],{"class":275},[262,62216,62217],{"class":429},": item[",[262,62219,6770],{"class":275},[262,62221,1103],{"class":429},[262,62223,10661],{"class":377},[262,62225,62226],{"class":429},"result})\n",[262,62228,62229],{"class":181,"line":579},[262,62230,62231],{"class":429},"    write_results(rows)\n",[262,62233,62234,62236,62238,62240,62243,62245,62248,62250,62253],{"class":181,"line":586},[262,62235,1089],{"class":271},[262,62237,602],{"class":429},[262,62239,642],{"class":377},[262,62241,62242],{"class":275},"\"Processed ",[262,62244,648],{"class":271},[262,62246,62247],{"class":429},"(rows)",[262,62249,654],{"class":271},[262,62251,62252],{"class":275}," items\"",[262,62254,660],{"class":429},[262,62256,62257],{"class":181,"line":591},[262,62258,583],{"emptyLinePlaceholder":582},[262,62260,62261],{"class":181,"line":623},[262,62262,583],{"emptyLinePlaceholder":582},[262,62264,62265,62268,62271],{"class":181,"line":634},[262,62266,62267],{"class":429},"schedule.every().day.at(",[262,62269,62270],{"class":275},"\"08:00\"",[262,62272,62273],{"class":429},").do(run_cycle)\n",[262,62275,62276],{"class":181,"line":845},[262,62277,583],{"emptyLinePlaceholder":582},[262,62279,62280,62283,62285],{"class":181,"line":850},[262,62281,62282],{"class":377},"while",[262,62284,2241],{"class":271},[262,62286,1160],{"class":429},[262,62288,62289],{"class":181,"line":864},[262,62290,62291],{"class":429},"    schedule.run_pending()\n",[262,62293,62294,62296,62298],{"class":181,"line":1683},[262,62295,3657],{"class":429},[262,62297,12826],{"class":271},[262,62299,660],{"class":429},[14,62301,62302,62303,62306,62307,62310,62311,62314],{},"This keeps Python running and fires ",[18,62304,62305],{},"run_cycle"," every morning at 08:00. The ",[18,62308,62309],{},"while True"," loop with ",[18,62312,62313],{},"time.sleep(60)"," simply wakes up once a minute, asks whether any job is due, and goes back to sleep — light enough that you can leave it running all day. The catch is that it only works while that program stays alive: close the terminal or shut the laptop and the schedule stops. That is fine for testing, but not for something you depend on.",[14,62316,62317,62318,62321,62322,62325,62326,62329,62330,62333,62334,62336,62337,62340,62341,62343,62344,62346],{},"For anything important, a system ",[35,62319,62320],{},"cron job"," is more robust because it is run by the operating system itself and survives reboots. On Mac or Linux, type ",[18,62323,62324],{},"crontab -e"," and add a line like ",[18,62327,62328],{},"0 8 * * * \u002Fpath\u002Fto\u002F.venv\u002Fbin\u002Fpython \u002Fpath\u002Fto\u002Fscript.py",", which runs the script every day at 08:00 without any terminal open. The five fields before the command are minute, hour, day-of-month, month, and day-of-week, so ",[18,62331,62332],{},"0 8 * * *"," reads as \"minute 0 of hour 8, every day.\" Cron starts your script in a bare environment with hardly any settings, which is the single most common reason scheduled jobs mysteriously do nothing: relative paths like ",[18,62335,61576],{}," point somewhere unexpected. Always use absolute paths to both the Python executable and your script, and add a line such as ",[18,62338,62339],{},"os.chdir(Path(__file__).parent)"," at the top so the script always runs from its own folder and finds both your ",[18,62342,61576],{}," and your ",[18,62345,319],{},". If you would rather run on a machine you do not own, a small always-on cloud server runs the same cron line so your automation works even when your computer is off.",[57,62348,8300],{"id":8299},[14,62350,62351],{},"These are the settings you will adjust most often as you adapt the code to your own task.",[1379,62353,62354,62366],{},[1382,62355,62356],{},[1385,62357,62358,62360,62362,62364],{},[1388,62359,1390],{},[1388,62361,3795],{},[1388,62363,3798],{},[1388,62365,1396],{},[1398,62367,62368,62383,62404,62419,62435,62455],{},[1385,62369,62370,62374,62376,62380],{},[1403,62371,62372],{},[18,62373,805],{},[1403,62375,433],{},[1403,62377,62378],{},[18,62379,1207],{},[1403,62381,62382],{},"Which AI model answers. Smaller models are cheaper and faster; larger ones reason better on hard inputs.",[1385,62384,62385,62389,62391,62395],{},[1403,62386,62387],{},[18,62388,3829],{},[1403,62390,3832],{},[1403,62392,62393],{},[18,62394,102],{},[1403,62396,62397,62398,62400,62401,62403],{},"Randomness of the answer. Keep at ",[18,62399,102],{}," for consistent classification; raise toward ",[18,62402,997],{}," only for creative rewriting.",[1385,62405,62406,62410,62412,62416],{},[1403,62407,62408],{},[18,62409,5745],{},[1403,62411,5869],{},[1403,62413,62414],{},[18,62415,6841],{},[1403,62417,62418],{},"Forces valid JSON output so your parsing step does not break.",[1385,62420,62421,62425,62428,62432],{},[1403,62422,62423],{},[18,62424,61900],{},[1403,62426,62427],{},"int slice",[1403,62429,62430],{},[18,62431,61806],{},[1403,62433,62434],{},"Maximum characters sent per item. Lower it to cut cost; raise it if items get cut off.",[1385,62436,62437,62442,62444,62448],{},[1403,62438,62439],{},[18,62440,62441],{},"schedule.every().day.at()",[1403,62443,433],{},[1403,62445,62446],{},[18,62447,62270],{},[1403,62449,62450,62451,62454],{},"When the loop runs. Accepts 24-hour ",[18,62452,62453],{},"\"HH:MM\""," times.",[1385,62456,62457,62461,62463,62467],{},[1403,62458,62459],{},[18,62460,62313],{},[1403,62462,439],{},[1403,62464,62465],{},[18,62466,12826],{},[1403,62468,62469],{},"Seconds between schedule checks. 60 is plenty; lower values waste CPU.",[57,62471,1445],{"id":1444},[1447,62473,62474,62490,62502,62522,62537,62558],{},[1450,62475,62476,62480,62481,62483,62484,62486,62487,62489],{},[35,62477,62478],{},[18,62479,21739],{}," — Your key is wrong, expired, or not being loaded. Confirm ",[18,62482,319],{}," sits in the folder you run from and that the line reads ",[18,62485,8435],{}," with no quotes or spaces. The ",[51,62488,388],{"href":387}," guide covers every cause.",[1450,62491,62492,62496,62497,62499,62500,5253],{},[35,62493,62494],{},[18,62495,31872],{}," — You sent requests faster than your plan allows, or your free credits ran out. Add a short ",[18,62498,8453],{}," between items in the loop, or follow ",[51,62501,3379],{"href":3378},[1450,62503,62504,62508,62509,62511,62512,3921,62514,62516,62517,62519,62520,1363],{},[35,62505,62506],{},[18,62507,21759],{}," — The model returned text that is not valid JSON. Keep ",[18,62510,6878],{},", lower ",[18,62513,3829],{},[18,62515,102],{},", and wrap ",[18,62518,20396],{}," in a try\u002Fexcept so one bad item does not stop the batch. See ",[51,62521,6114],{"href":6113},[1450,62523,62524,62529,62530,62533,62534,62536],{},[35,62525,62526],{},[18,62527,62528],{},"This model's maximum context length is ... tokens"," — You sent an item that is too long. Trim it harder with a smaller slice such as ",[18,62531,62532],{},"text[:800]",", or split the document into parts. The ",[51,62535,1513],{"href":1512}," guide explains the math.",[1450,62538,62539,62543,62544,62546,62547,62550,62551,62554,62555,1363],{},[35,62540,62541],{},[18,62542,8493],{}," — Your virtual environment is not active, so Python cannot see the package. Run ",[18,62545,30519],{}," (Windows: ",[18,62548,62549],{},".venv\\Scripts\\activate",") and reinstall, then check ",[18,62552,62553],{},"which python3"," points inside ",[18,62556,62557],{},".venv",[1450,62559,62560,62563,62564,1374,62566,62568,62569,62571],{},[35,62561,62562],{},"Cron job runs but does nothing"," — Cron starts in a bare environment with the wrong working directory, so relative paths like ",[18,62565,61576],{},[18,62567,319],{}," are not found. Use absolute paths everywhere, or add ",[18,62570,62339],{}," at the top of your script so it always runs from its own folder.",[57,62573,62575],{"id":62574},"worked-example-a-complete-inbox-classifier","Worked example: a complete inbox classifier",[14,62577,62578,62579,62582,62583,62585,62586,62588,62589,62592],{},"This single file ties all four steps together. Save it as ",[18,62580,62581],{},"automate.py",", put a few ",[18,62584,406],{}," files in an ",[18,62587,61576],{}," folder, and run ",[18,62590,62591],{},"python automate.py"," for an immediate pass — or uncomment the schedule block to leave it running every morning.",[253,62594,62596],{"className":414,"code":62595,"language":416,"meta":258,"style":258},"import os\nimport csv\nimport json\nimport time\nfrom pathlib import Path\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()                                          # read OPENAI_API_KEY from .env (keep .env in .gitignore)\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef read_inputs(folder: str) -> list[dict]:            # Step 1: load each .txt file as one record\n    records = []\n    for path in sorted(Path(folder).glob(\"*.txt\")):\n        text = path.read_text(encoding=\"utf-8\", errors=\"ignore\").strip()\n        if text:\n            records.append({\"id\": path.name, \"text\": text})\n    return records\n\n\ndef classify(text: str) -> dict:                       # Step 2: ask the AI for a JSON label\n    prompt = (\n        \"Classify the message into exactly one category: \"\n        \"'urgent', 'newsletter', 'invoice', or 'general'. \"\n        \"Reply as JSON: {\\\"category\\\": \\\"...\\\", \\\"reason\\\": \\\"...\\\"}.\\n\\n\"\n        f\"Message:\\n{text[:1500]}\"\n    )\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n        temperature=0,\n        response_format={\"type\": \"json_object\"},\n    )\n    return json.loads(response.choices[0].message.content)\n\n\ndef write_results(rows: list[dict], outfile: str = \"results.csv\") -> None:   # Step 3: save to CSV\n    fields = [\"id\", \"category\", \"reason\"]\n    new_file = not Path(outfile).exists()\n    with open(outfile, \"a\", newline=\"\", encoding=\"utf-8\") as f:\n        writer = csv.DictWriter(f, fieldnames=fields)\n        if new_file:\n            writer.writeheader()\n        writer.writerows(rows)\n\n\ndef run_cycle() -> None:                               # Step 4: one full pass, ready to schedule\n    rows = []\n    for item in read_inputs(\"inbox\"):\n        try:\n            rows.append({\"id\": item[\"id\"], **classify(item[\"text\"])})\n        except Exception as error:                     # one bad item must not stop the batch\n            print(f\"Skipped {item['id']}: {error}\")\n    write_results(rows)\n    print(f\"Processed {len(rows)} items into results.csv\")\n\n\nif __name__ == \"__main__\":\n    run_cycle()\n    # import schedule\n    # schedule.every().day.at(\"08:00\").do(run_cycle)\n    # while True:\n    #     schedule.run_pending()\n    #     time.sleep(60)\n",[18,62597,62598,62604,62610,62616,62622,62632,62642,62652,62656,62664,62682,62686,62690,62710,62718,62734,62758,62764,62776,62782,62786,62790,62810,62818,62823,62827,62868,62886,62890,62898,62908,62928,62938,62954,62958,62968,62972,62976,63004,63024,63035,63067,63081,63088,63092,63096,63100,63104,63120,63128,63142,63148,63171,63185,63219,63223,63244,63248,63252,63264,63269,63274,63279,63284,63289],{"__ignoreMap":258},[262,62599,62600,62602],{"class":181,"line":264},[262,62601,684],{"class":377},[262,62603,687],{"class":429},[262,62605,62606,62608],{"class":181,"line":282},[262,62607,684],{"class":377},[262,62609,8533],{"class":429},[262,62611,62612,62614],{"class":181,"line":295},[262,62613,684],{"class":377},[262,62615,5766],{"class":429},[262,62617,62618,62620],{"class":181,"line":345},[262,62619,684],{"class":377},[262,62621,2612],{"class":429},[262,62623,62624,62626,62628,62630],{"class":181,"line":492},[262,62625,705],{"class":377},[262,62627,4882],{"class":429},[262,62629,684],{"class":377},[262,62631,4887],{"class":429},[262,62633,62634,62636,62638,62640],{"class":181,"line":503},[262,62635,705],{"class":377},[262,62637,720],{"class":429},[262,62639,684],{"class":377},[262,62641,725],{"class":429},[262,62643,62644,62646,62648,62650],{"class":181,"line":521},[262,62645,705],{"class":377},[262,62647,708],{"class":429},[262,62649,684],{"class":377},[262,62651,713],{"class":429},[262,62653,62654],{"class":181,"line":537},[262,62655,583],{"emptyLinePlaceholder":582},[262,62657,62658,62661],{"class":181,"line":549},[262,62659,62660],{"class":429},"load_dotenv()                                          ",[262,62662,62663],{"class":291},"# read OPENAI_API_KEY from .env (keep .env in .gitignore)\n",[262,62665,62666,62668,62670,62672,62674,62676,62678,62680],{"class":181,"line":570},[262,62667,739],{"class":429},[262,62669,476],{"class":377},[262,62671,1588],{"class":429},[262,62673,2674],{"class":611},[262,62675,476],{"class":377},[262,62677,1199],{"class":429},[262,62679,2681],{"class":275},[262,62681,2684],{"class":429},[262,62683,62684],{"class":181,"line":579},[262,62685,583],{"emptyLinePlaceholder":582},[262,62687,62688],{"class":181,"line":586},[262,62689,583],{"emptyLinePlaceholder":582},[262,62691,62692,62694,62696,62698,62700,62702,62704,62707],{"class":181,"line":591},[262,62693,423],{"class":377},[262,62695,61414],{"class":267},[262,62697,61417],{"class":429},[262,62699,433],{"class":271},[262,62701,458],{"class":429},[262,62703,5869],{"class":271},[262,62705,62706],{"class":429},"]:            ",[262,62708,62709],{"class":291},"# Step 1: load each .txt file as one record\n",[262,62711,62712,62714,62716],{"class":181,"line":623},[262,62713,61435],{"class":429},[262,62715,476],{"class":377},[262,62717,489],{"class":429},[262,62719,62720,62722,62724,62726,62728,62730,62732],{"class":181,"line":634},[262,62721,3074],{"class":377},[262,62723,61446],{"class":429},[262,62725,835],{"class":377},[262,62727,61451],{"class":271},[262,62729,61454],{"class":429},[262,62731,61457],{"class":275},[262,62733,23288],{"class":429},[262,62735,62736,62738,62740,62742,62744,62746,62748,62750,62752,62754,62756],{"class":181,"line":845},[262,62737,18264],{"class":429},[262,62739,476],{"class":377},[262,62741,61468],{"class":429},[262,62743,612],{"class":611},[262,62745,476],{"class":377},[262,62747,617],{"class":275},[262,62749,608],{"class":429},[262,62751,61479],{"class":611},[262,62753,476],{"class":377},[262,62755,61484],{"class":275},[262,62757,2262],{"class":429},[262,62759,62760,62762],{"class":181,"line":850},[262,62761,2268],{"class":377},[262,62763,18359],{"class":429},[262,62765,62766,62768,62770,62772,62774],{"class":181,"line":864},[262,62767,61497],{"class":429},[262,62769,6770],{"class":275},[262,62771,61502],{"class":429},[262,62773,16074],{"class":275},[262,62775,22323],{"class":429},[262,62777,62778,62780],{"class":181,"line":1683},[262,62779,573],{"class":377},[262,62781,61513],{"class":429},[262,62783,62784],{"class":181,"line":1688},[262,62785,583],{"emptyLinePlaceholder":582},[262,62787,62788],{"class":181,"line":1693},[262,62789,583],{"emptyLinePlaceholder":582},[262,62791,62792,62794,62796,62798,62800,62802,62804,62807],{"class":181,"line":1728},[262,62793,423],{"class":377},[262,62795,61715],{"class":267},[262,62797,430],{"class":429},[262,62799,433],{"class":271},[262,62801,1939],{"class":429},[262,62803,5869],{"class":271},[262,62805,62806],{"class":429},":                       ",[262,62808,62809],{"class":291},"# Step 2: ask the AI for a JSON label\n",[262,62811,62812,62814,62816],{"class":181,"line":1737},[262,62813,18006],{"class":429},[262,62815,476],{"class":377},[262,62817,984],{"class":429},[262,62819,62820],{"class":181,"line":1751},[262,62821,62822],{"class":275},"        \"Classify the message into exactly one category: \"\n",[262,62824,62825],{"class":181,"line":1764},[262,62826,61748],{"class":275},[262,62828,62829,62832,62834,62836,62838,62840,62842,62844,62846,62848,62850,62852,62854,62856,62858,62860,62862,62864,62866],{"class":181,"line":1779},[262,62830,62831],{"class":275},"        \"Reply as JSON: {",[262,62833,34149],{"class":271},[262,62835,61758],{"class":275},[262,62837,34149],{"class":271},[262,62839,1231],{"class":275},[262,62841,34149],{"class":271},[262,62843,57902],{"class":275},[262,62845,34149],{"class":271},[262,62847,608],{"class":275},[262,62849,34149],{"class":271},[262,62851,8260],{"class":275},[262,62853,34149],{"class":271},[262,62855,1231],{"class":275},[262,62857,34149],{"class":271},[262,62859,57902],{"class":275},[262,62861,34149],{"class":271},[262,62863,61787],{"class":275},[262,62865,1173],{"class":271},[262,62867,1257],{"class":275},[262,62869,62870,62872,62874,62876,62878,62880,62882,62884],{"class":181,"line":1793},[262,62871,2840],{"class":377},[262,62873,61798],{"class":275},[262,62875,1268],{"class":271},[262,62877,61803],{"class":429},[262,62879,61806],{"class":271},[262,62881,6223],{"class":429},[262,62883,654],{"class":271},[262,62885,1257],{"class":275},[262,62887,62888],{"class":181,"line":1800},[262,62889,1011],{"class":429},[262,62891,62892,62894,62896],{"class":181,"line":1805},[262,62893,1184],{"class":429},[262,62895,476],{"class":377},[262,62897,1189],{"class":429},[262,62899,62900,62902,62904,62906],{"class":181,"line":1810},[262,62901,1194],{"class":611},[262,62903,476],{"class":377},[262,62905,1207],{"class":275},[262,62907,1315],{"class":429},[262,62909,62910,62912,62914,62916,62918,62920,62922,62924,62926],{"class":181,"line":1823},[262,62911,1215],{"class":611},[262,62913,476],{"class":377},[262,62915,8856],{"class":429},[262,62917,1228],{"class":275},[262,62919,1231],{"class":429},[262,62921,1291],{"class":275},[262,62923,608],{"class":429},[262,62925,1239],{"class":275},[262,62927,18141],{"class":429},[262,62929,62930,62932,62934,62936],{"class":181,"line":1846},[262,62931,1308],{"class":611},[262,62933,476],{"class":377},[262,62935,102],{"class":271},[262,62937,1315],{"class":429},[262,62939,62940,62942,62944,62946,62948,62950,62952],{"class":181,"line":1861},[262,62941,6018],{"class":611},[262,62943,476],{"class":377},[262,62945,3039],{"class":429},[262,62947,6025],{"class":275},[262,62949,1231],{"class":429},[262,62951,6030],{"class":275},[262,62953,3143],{"class":429},[262,62955,62956],{"class":181,"line":1866},[262,62957,1011],{"class":429},[262,62959,62960,62962,62964,62966],{"class":181,"line":1871},[262,62961,573],{"class":377},[262,62963,6043],{"class":429},[262,62965,102],{"class":271},[262,62967,6048],{"class":429},[262,62969,62970],{"class":181,"line":1890},[262,62971,583],{"emptyLinePlaceholder":582},[262,62973,62974],{"class":181,"line":1909},[262,62975,583],{"emptyLinePlaceholder":582},[262,62977,62978,62980,62982,62984,62986,62988,62990,62992,62994,62996,62998,63001],{"class":181,"line":1914},[262,62979,423],{"class":377},[262,62981,61969],{"class":267},[262,62983,61972],{"class":429},[262,62985,5869],{"class":271},[262,62987,61977],{"class":429},[262,62989,433],{"class":271},[262,62991,442],{"class":377},[262,62993,25612],{"class":275},[262,62995,1939],{"class":429},[262,62997,8471],{"class":271},[262,62999,63000],{"class":429},":   ",[262,63002,63003],{"class":291},"# Step 3: save to CSV\n",[262,63005,63006,63008,63010,63012,63014,63016,63018,63020,63022],{"class":181,"line":1919},[262,63007,10558],{"class":429},[262,63009,476],{"class":377},[262,63011,10563],{"class":429},[262,63013,6770],{"class":275},[262,63015,608],{"class":429},[262,63017,62009],{"class":275},[262,63019,608],{"class":429},[262,63021,62014],{"class":275},[262,63023,957],{"class":429},[262,63025,63026,63029,63031,63033],{"class":181,"line":1946},[262,63027,63028],{"class":429},"    new_file ",[262,63030,476],{"class":377},[262,63032,2818],{"class":377},[262,63034,62026],{"class":429},[262,63036,63037,63039,63041,63043,63045,63047,63049,63051,63053,63055,63057,63059,63061,63063,63065],{"class":181,"line":1959},[262,63038,10124],{"class":377},[262,63040,599],{"class":271},[262,63042,62035],{"class":429},[262,63044,62038],{"class":275},[262,63046,608],{"class":429},[262,63048,9170],{"class":611},[262,63050,476],{"class":377},[262,63052,9175],{"class":275},[262,63054,608],{"class":429},[262,63056,612],{"class":611},[262,63058,476],{"class":377},[262,63060,617],{"class":275},[262,63062,1000],{"class":429},[262,63064,697],{"class":377},[262,63066,9190],{"class":429},[262,63068,63069,63071,63073,63075,63077,63079],{"class":181,"line":1996},[262,63070,10623],{"class":429},[262,63072,476],{"class":377},[262,63074,62069],{"class":429},[262,63076,10631],{"class":611},[262,63078,476],{"class":377},[262,63080,10636],{"class":429},[262,63082,63083,63085],{"class":181,"line":2012},[262,63084,2268],{"class":377},[262,63086,63087],{"class":429}," new_file:\n",[262,63089,63090],{"class":181,"line":2040},[262,63091,62089],{"class":429},[262,63093,63094],{"class":181,"line":2045},[262,63095,62094],{"class":429},[262,63097,63098],{"class":181,"line":2050},[262,63099,583],{"emptyLinePlaceholder":582},[262,63101,63102],{"class":181,"line":2067},[262,63103,583],{"emptyLinePlaceholder":582},[262,63105,63106,63108,63110,63112,63114,63117],{"class":181,"line":2077},[262,63107,423],{"class":377},[262,63109,62157],{"class":267},[262,63111,15481],{"class":429},[262,63113,8471],{"class":271},[262,63115,63116],{"class":429},":                               ",[262,63118,63119],{"class":291},"# Step 4: one full pass, ready to schedule\n",[262,63121,63122,63124,63126],{"class":181,"line":2086},[262,63123,25637],{"class":429},[262,63125,476],{"class":377},[262,63127,489],{"class":429},[262,63129,63130,63132,63134,63136,63138,63140],{"class":181,"line":2097},[262,63131,3074],{"class":377},[262,63133,832],{"class":429},[262,63135,835],{"class":377},[262,63137,61543],{"class":429},[262,63139,61546],{"class":275},[262,63141,8192],{"class":429},[262,63143,63144,63146],{"class":181,"line":2106},[262,63145,3090],{"class":377},[262,63147,1160],{"class":429},[262,63149,63150,63153,63155,63157,63159,63161,63163,63166,63168],{"class":181,"line":2126},[262,63151,63152],{"class":429},"            rows.append({",[262,63154,6770],{"class":275},[262,63156,62217],{"class":429},[262,63158,6770],{"class":275},[262,63160,1103],{"class":429},[262,63162,10661],{"class":377},[262,63164,63165],{"class":429},"classify(item[",[262,63167,16074],{"class":275},[262,63169,63170],{"class":429},"])})\n",[262,63172,63173,63175,63177,63179,63182],{"class":181,"line":2148},[262,63174,3214],{"class":377},[262,63176,10361],{"class":271},[262,63178,10364],{"class":377},[262,63180,63181],{"class":429}," error:                     ",[262,63183,63184],{"class":291},"# one bad item must not stop the batch\n",[262,63186,63187,63189,63191,63193,63196,63198,63201,63203,63205,63207,63209,63211,63213,63215,63217],{"class":181,"line":2165},[262,63188,3250],{"class":271},[262,63190,602],{"class":429},[262,63192,642],{"class":377},[262,63194,63195],{"class":275},"\"Skipped ",[262,63197,3039],{"class":271},[262,63199,63200],{"class":429},"item[",[262,63202,10188],{"class":275},[262,63204,6223],{"class":429},[262,63206,654],{"class":271},[262,63208,1231],{"class":275},[262,63210,3039],{"class":271},[262,63212,14554],{"class":429},[262,63214,654],{"class":271},[262,63216,1176],{"class":275},[262,63218,660],{"class":429},[262,63220,63221],{"class":181,"line":2170},[262,63222,62231],{"class":429},[262,63224,63225,63227,63229,63231,63233,63235,63237,63239,63242],{"class":181,"line":2181},[262,63226,1089],{"class":271},[262,63228,602],{"class":429},[262,63230,642],{"class":377},[262,63232,62242],{"class":275},[262,63234,648],{"class":271},[262,63236,62247],{"class":429},[262,63238,654],{"class":271},[262,63240,63241],{"class":275}," items into results.csv\"",[262,63243,660],{"class":429},[262,63245,63246],{"class":181,"line":2186},[262,63247,583],{"emptyLinePlaceholder":582},[262,63249,63250],{"class":181,"line":2197},[262,63251,583],{"emptyLinePlaceholder":582},[262,63253,63254,63256,63258,63260,63262],{"class":181,"line":2202},[262,63255,2210],{"class":377},[262,63257,2213],{"class":271},[262,63259,2216],{"class":377},[262,63261,2219],{"class":275},[262,63263,1160],{"class":429},[262,63265,63266],{"class":181,"line":2207},[262,63267,63268],{"class":429},"    run_cycle()\n",[262,63270,63271],{"class":181,"line":2224},[262,63272,63273],{"class":291},"    # import schedule\n",[262,63275,63276],{"class":181,"line":2236},[262,63277,63278],{"class":291},"    # schedule.every().day.at(\"08:00\").do(run_cycle)\n",[262,63280,63281],{"class":181,"line":2246},[262,63282,63283],{"class":291},"    # while True:\n",[262,63285,63286],{"class":181,"line":2265},[262,63287,63288],{"class":291},"    #     schedule.run_pending()\n",[262,63290,63291],{"class":181,"line":2290},[262,63292,63293],{"class":291},"    #     time.sleep(60)\n",[57,63295,2355],{"id":2354},[14,63297,63298],{},"You now have a working loop. Here is how to grow it:",[1447,63300,63301,63313,63322],{},[1450,63302,63303,63306,63307,63309,63310,63312],{},[35,63304,63305],{},"Point it at real email."," Follow the ",[51,63308,61248],{"href":61247}," guide to replace the ",[18,63311,61576],{}," folder with a live Gmail account over IMAP.",[1450,63314,63315,63318,63319,63321],{},[35,63316,63317],{},"Feed it cleaner data."," If your inputs are spreadsheets or exports, the ",[51,63320,2919],{"href":2918}," walkthrough gets them ready before the AI sees them.",[1450,63323,63324,63327,63328,63330],{},[35,63325,63326],{},"Tighten your prompts."," Better instructions mean more accurate labels and fewer parsing errors; the ",[51,63329,7554],{"href":7553}," section shows how.",[14,63332,2375,63333,1363],{},[51,63334,26450],{"href":26449},[57,63336,2381],{"id":2380},[2322,63338,63339,63344,63349,63354,63359],{},[1450,63340,63341,63343],{},[51,63342,61248],{"href":61247}," — take this pattern all the way to a live inbox.",[1450,63345,63346,63348],{},[51,63347,61611],{"href":61610}," — prepare messy inputs so your automation stays accurate.",[1450,63350,63351,63353],{},[51,63352,2487],{"href":2486}," — how keys, models, and billing work behind the AI step.",[1450,63355,63356,63358],{},[51,63357,5423],{"href":5422}," — install Python and your tools the right way.",[1450,63360,63361,63363],{},[51,63362,26450],{"href":26449}," — the full beginner track this section belongs to.",[2401,63365,63366],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":63368},[63369,63370,63371,63372,63373,63374,63375,63376,63377,63378,63379],{"id":12746,"depth":282,"text":12747},{"id":237,"depth":282,"text":238},{"id":61374,"depth":282,"text":61375},{"id":61615,"depth":282,"text":61616},{"id":61929,"depth":282,"text":61930},{"id":62117,"depth":282,"text":62118},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":62574,"depth":282,"text":62575},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Build a Python automation loop that reads inputs, calls an AI to classify or transform them, writes results, and runs on a schedule. A practical guide for non-developers.",[63382,63385,63388,63391,63394],{"q":63383,"a":63384},"Do I need to know how to code to automate tasks with Python and AI?","No. You can copy the scripts in this guide, change a folder path and a prompt, and run them. You only need Python 3.10 or newer installed and an API key from an AI provider.",{"q":63386,"a":63387},"How much does it cost to run an AI automation?","A small model like gpt-4o-mini costs a fraction of a cent per request, so classifying a few hundred items a day usually costs pennies. Costs rise with longer inputs and larger models, so trim text and pick the smallest model that works.",{"q":63389,"a":63390},"What kinds of tasks are good candidates for AI automation?","Repetitive jobs that involve reading messy text and making a judgement: sorting emails, tagging support tickets, pulling fields out of invoices, or summarizing documents. Tasks with fixed rules and no ambiguity are better handled by plain Python without an AI.",{"q":63392,"a":63393},"Will my automation keep running after I close my laptop?","Only if you schedule it on a machine that stays on, such as a small cloud server, or a cron job on an always-on computer. A script started in your terminal stops when the terminal or computer shuts down.",{"q":63395,"a":63396},"How do I stop the AI from returning text I cannot parse?","Ask for JSON explicitly, set a low temperature, and request structured output when the model supports it. Always wrap the parsing step in a try\u002Fexcept so one bad response does not crash the whole batch.",{"name":63398,"steps":63399},"How to automate a repetitive task with Python and AI",[63400,63403,63406,63409],{"name":63401,"text":63402},"Read your inputs","Load the emails, files, or rows you want to process into Python as a list of small text records.",{"name":63404,"text":63405},"Call an AI to classify or transform each item","Send each record to an AI model with a clear prompt and parse the structured answer it returns.",{"name":63407,"text":63408},"Write the results out","Save the AI's output to a CSV, a folder of files, or another system so the work is captured.",{"name":63410,"text":63411},"Schedule it to run on its own","Wrap the steps in one function and trigger it on a timer with the schedule library or a cron job.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks","2026-05-02",{"title":61113,"description":63380},"Automate Repetitive Tasks with Python and AI","python-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks\u002Findex","CcOTjzl8Kj-YnVXdx0FSfEBoq0HZzQLa0Ye4N2D2JKA",{"id":63420,"title":61248,"body":63421,"description":65970,"extension":2419,"faq":65971,"howto":65987,"meta":66005,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":66006,"published":66007,"seo":66008,"seoTitle":61248,"stem":66009,"__hash__":66010},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks\u002Fpython-script-to-automate-email-sorting\u002Findex.md",{"type":7,"value":63422,"toc":65957},[63423,63426,63429,63439,63441,63447,63480,63491,63505,63510,63534,63541,63555,63559,63574,63769,63776,63780,63790,64103,64113,64117,64130,64135,64617,64620,64624,64631,64729,64739,64743,64753,64830,64837,64839,64903,64905,64971,64973,64985,65905,65907,65927,65931,65933,65955],[10,63424,61248],{"id":63425},"python-script-to-automate-email-sorting",[14,63427,63428],{},"This guide shows you how to read your inbox over IMAP, classify every unread message with an LLM (a large language model, the kind of AI that reads and writes text), and move or label each email by category — all in a single Python script you can run by hand or schedule. Manual inbox triage quietly eats hours from creators, marketers, and founders; by the end of this page you will have a script that does it for you in under fifteen minutes of setup.",[14,63430,63431,63432,63434,63435,63438],{},"Plain keyword rules are a fine starting point, but they break the moment an email is phrased in a way you did not predict. A bill that says \"your statement is ready\" never contains the word \"invoice\", so a keyword filter sails right past it. An LLM reads the ",[27,63433,73],{}," of a message, so it can route that email to your ",[35,63436,63437],{},"Invoices"," folder anyway. This guide keeps a fast keyword shortcut for the obvious cases and falls back to the model for everything else.",[57,63440,238],{"id":237},[14,63442,63443,63444,63446],{},"This guide assumes you already have Python 3.10 or newer and a virtual environment ready. If not, start with ",[51,63445,2482],{"href":2481}," and come back. Beyond that, you need three things specific to this task:",[1447,63448,63449,63458,63468],{},[1450,63450,63451,63454,63455,1363],{},[35,63452,63453],{},"IMAP access turned on."," IMAP (Internet Message Access Protocol) is the standard way programs read a mailbox. Enable it in your provider's settings — in Gmail it lives under ",[27,63456,63457],{},"Settings → Forwarding and POP\u002FIMAP",[1450,63459,63460,63463,63464,63467],{},[35,63461,63462],{},"An app password."," With two-factor authentication on, most providers block your normal password from third-party apps. Generate a dedicated 16-character app password instead (Gmail: ",[27,63465,63466],{},"Google Account → Security → App passwords","). It can be revoked any time without touching your main login.",[1450,63469,63470,63473,63474,63476,63477,63479],{},[35,63471,63472],{},"An LLM API key."," This guide uses the ",[18,63475,20],{}," SDK. If you are new to keys and pricing, read ",[51,63478,2487],{"href":2486}," and pick a cheap, fast model.",[14,63481,3349,63482,1374,63485,63487,63488,63490],{},[18,63483,63484],{},"imaplib",[18,63486,42542],{}," modules ship with Python, so the only packages to install are the OpenAI SDK and a helper for reading ",[18,63489,319],{}," files:",[253,63492,63493],{"className":255,"code":4112,"language":257,"meta":258,"style":258},[18,63494,63495],{"__ignoreMap":258},[262,63496,63497,63499,63501,63503],{"class":181,"line":264},[262,63498,298],{"class":267},[262,63500,301],{"class":275},[262,63502,2519],{"class":275},[262,63504,2522],{"class":275},[14,63506,42969,63507,63509],{},[18,63508,319],{}," file next to your script to hold every secret:",[253,63511,63513],{"className":323,"code":63512,"language":325,"meta":258,"style":258},"IMAP_SERVER=imap.gmail.com\nEMAIL_ADDRESS=you@gmail.com\nAPP_PASSWORD=your_16_char_app_password\nOPENAI_API_KEY=sk-your-key-here\n",[18,63514,63515,63520,63525,63530],{"__ignoreMap":258},[262,63516,63517],{"class":181,"line":264},[262,63518,63519],{},"IMAP_SERVER=imap.gmail.com\n",[262,63521,63522],{"class":181,"line":282},[262,63523,63524],{},"EMAIL_ADDRESS=you@gmail.com\n",[262,63526,63527],{"class":181,"line":295},[262,63528,63529],{},"APP_PASSWORD=your_16_char_app_password\n",[262,63531,63532],{"class":181,"line":345},[262,63533,11159],{},[14,63535,353,63536,356,63538,63540],{},[18,63537,319],{},[18,63539,359],{}," immediately so these credentials never land in version control:",[253,63542,63543],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,63544,63545],{"__ignoreMap":258},[262,63546,63547,63549,63551,63553],{"class":181,"line":264},[262,63548,371],{"class":271},[262,63550,374],{"class":275},[262,63552,378],{"class":377},[262,63554,381],{"class":275},[57,63556,63558],{"id":63557},"step-1-connect-to-your-inbox-over-imap","Step 1: Connect to your inbox over IMAP",[14,63560,63561,63562,63565,63566,63569,63570,63573],{},"First, load your secrets from the environment and open a secure connection. ",[18,63563,63564],{},"IMAP4_SSL"," encrypts the whole session, and ",[18,63567,63568],{},"select(\"inbox\")"," tells the server which mailbox to work in. Searching for ",[18,63571,63572],{},"UNSEEN"," returns only the unread messages, so you never reprocess mail you have already sorted.",[253,63575,63577],{"className":414,"code":63576,"language":416,"meta":258,"style":258},"import imaplib\nimport os\n\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nIMAP_SERVER = os.environ[\"IMAP_SERVER\"]\nEMAIL_ADDRESS = os.environ[\"EMAIL_ADDRESS\"]\nAPP_PASSWORD = os.environ[\"APP_PASSWORD\"]\n\n\ndef connect():\n    mail = imaplib.IMAP4_SSL(IMAP_SERVER)\n    mail.login(EMAIL_ADDRESS, APP_PASSWORD)\n    mail.select(\"inbox\")\n    return mail\n\n\ndef unread_ids(mail):\n    _status, messages = mail.search(None, \"UNSEEN\")\n    return messages[0].split()\n",[18,63578,63579,63586,63592,63596,63606,63610,63614,63618,63632,63646,63660,63664,63668,63677,63691,63704,63713,63720,63724,63728,63738,63757],{"__ignoreMap":258},[262,63580,63581,63583],{"class":181,"line":264},[262,63582,684],{"class":377},[262,63584,63585],{"class":429}," imaplib\n",[262,63587,63588,63590],{"class":181,"line":282},[262,63589,684],{"class":377},[262,63591,687],{"class":429},[262,63593,63594],{"class":181,"line":295},[262,63595,583],{"emptyLinePlaceholder":582},[262,63597,63598,63600,63602,63604],{"class":181,"line":345},[262,63599,705],{"class":377},[262,63601,708],{"class":429},[262,63603,684],{"class":377},[262,63605,713],{"class":429},[262,63607,63608],{"class":181,"line":492},[262,63609,583],{"emptyLinePlaceholder":582},[262,63611,63612],{"class":181,"line":503},[262,63613,734],{"class":429},[262,63615,63616],{"class":181,"line":521},[262,63617,583],{"emptyLinePlaceholder":582},[262,63619,63620,63623,63625,63627,63630],{"class":181,"line":537},[262,63621,63622],{"class":271},"IMAP_SERVER",[262,63624,442],{"class":377},[262,63626,36185],{"class":429},[262,63628,63629],{"class":275},"\"IMAP_SERVER\"",[262,63631,957],{"class":429},[262,63633,63634,63637,63639,63641,63644],{"class":181,"line":549},[262,63635,63636],{"class":271},"EMAIL_ADDRESS",[262,63638,442],{"class":377},[262,63640,36185],{"class":429},[262,63642,63643],{"class":275},"\"EMAIL_ADDRESS\"",[262,63645,957],{"class":429},[262,63647,63648,63651,63653,63655,63658],{"class":181,"line":570},[262,63649,63650],{"class":271},"APP_PASSWORD",[262,63652,442],{"class":377},[262,63654,36185],{"class":429},[262,63656,63657],{"class":275},"\"APP_PASSWORD\"",[262,63659,957],{"class":429},[262,63661,63662],{"class":181,"line":579},[262,63663,583],{"emptyLinePlaceholder":582},[262,63665,63666],{"class":181,"line":586},[262,63667,583],{"emptyLinePlaceholder":582},[262,63669,63670,63672,63675],{"class":181,"line":591},[262,63671,423],{"class":377},[262,63673,63674],{"class":267}," connect",[262,63676,56828],{"class":429},[262,63678,63679,63682,63684,63687,63689],{"class":181,"line":623},[262,63680,63681],{"class":429},"    mail ",[262,63683,476],{"class":377},[262,63685,63686],{"class":429}," imaplib.IMAP4_SSL(",[262,63688,63622],{"class":271},[262,63690,660],{"class":429},[262,63692,63693,63696,63698,63700,63702],{"class":181,"line":634},[262,63694,63695],{"class":429},"    mail.login(",[262,63697,63636],{"class":271},[262,63699,608],{"class":429},[262,63701,63650],{"class":271},[262,63703,660],{"class":429},[262,63705,63706,63709,63711],{"class":181,"line":845},[262,63707,63708],{"class":429},"    mail.select(",[262,63710,61546],{"class":275},[262,63712,660],{"class":429},[262,63714,63715,63717],{"class":181,"line":850},[262,63716,573],{"class":377},[262,63718,63719],{"class":429}," mail\n",[262,63721,63722],{"class":181,"line":864},[262,63723,583],{"emptyLinePlaceholder":582},[262,63725,63726],{"class":181,"line":1683},[262,63727,583],{"emptyLinePlaceholder":582},[262,63729,63730,63732,63735],{"class":181,"line":1688},[262,63731,423],{"class":377},[262,63733,63734],{"class":267}," unread_ids",[262,63736,63737],{"class":429},"(mail):\n",[262,63739,63740,63743,63745,63748,63750,63752,63755],{"class":181,"line":1693},[262,63741,63742],{"class":429},"    _status, messages ",[262,63744,476],{"class":377},[262,63746,63747],{"class":429}," mail.search(",[262,63749,8471],{"class":271},[262,63751,608],{"class":429},[262,63753,63754],{"class":275},"\"UNSEEN\"",[262,63756,660],{"class":429},[262,63758,63759,63761,63764,63766],{"class":181,"line":1728},[262,63760,573],{"class":377},[262,63762,63763],{"class":429}," messages[",[262,63765,102],{"class":271},[262,63767,63768],{"class":429},"].split()\n",[14,63770,63771,63772,63775],{},"Each entry from ",[18,63773,63774],{},"unread_ids"," is a small numeric identifier the server uses to refer to one message. You will hand these IDs back to the server in the next steps to fetch, copy, and flag each email.",[57,63777,63779],{"id":63778},"step-2-pull-the-parts-an-llm-needs-to-read","Step 2: Pull the parts an LLM needs to read",[14,63781,63782,63783,63785,63786,63789],{},"You do not need the entire raw email to classify it — the subject and the first slice of the body are plenty, and keeping the snippet short keeps the API call cheap and fast. The ",[18,63784,42542],{}," module parses the raw bytes, and ",[18,63787,63788],{},"decode_header"," handles subjects that arrive MIME-encoded (the format used for non-English characters), so accented or non-ASCII subjects come through cleanly.",[253,63791,63793],{"className":414,"code":63792,"language":416,"meta":258,"style":258},"import email\nfrom email.header import decode_header\n\n\ndef decode_subject(msg):\n    raw = decode_header(msg.get(\"Subject\", \"\"))[0][0]\n    if isinstance(raw, bytes):\n        return raw.decode(\"utf-8\", errors=\"ignore\")\n    return raw or \"\"\n\n\ndef body_snippet(msg, limit=600):\n    if msg.is_multipart():\n        for part in msg.walk():\n            if part.get_content_type() == \"text\u002Fplain\":\n                payload = part.get_payload(decode=True) or b\"\"\n                return payload.decode(\"utf-8\", errors=\"ignore\")[:limit]\n        return \"\"\n    payload = msg.get_payload(decode=True) or b\"\"\n    return payload.decode(\"utf-8\", errors=\"ignore\")[:limit]\n\n\ndef fetch_message(mail, eid):\n    _status, msg_data = mail.fetch(eid, \"(RFC822)\")\n    return email.message_from_bytes(msg_data[0][1])\n",[18,63794,63795,63802,63814,63818,63822,63832,63859,63873,63892,63903,63907,63911,63927,63934,63946,63960,63987,64008,64014,64037,64055,64059,64063,64073,64088],{"__ignoreMap":258},[262,63796,63797,63799],{"class":181,"line":264},[262,63798,684],{"class":377},[262,63800,63801],{"class":429}," email\n",[262,63803,63804,63806,63809,63811],{"class":181,"line":282},[262,63805,705],{"class":377},[262,63807,63808],{"class":429}," email.header ",[262,63810,684],{"class":377},[262,63812,63813],{"class":429}," decode_header\n",[262,63815,63816],{"class":181,"line":295},[262,63817,583],{"emptyLinePlaceholder":582},[262,63819,63820],{"class":181,"line":345},[262,63821,583],{"emptyLinePlaceholder":582},[262,63823,63824,63826,63829],{"class":181,"line":492},[262,63825,423],{"class":377},[262,63827,63828],{"class":267}," decode_subject",[262,63830,63831],{"class":429},"(msg):\n",[262,63833,63834,63836,63838,63841,63844,63846,63848,63851,63853,63855,63857],{"class":181,"line":503},[262,63835,15127],{"class":429},[262,63837,476],{"class":377},[262,63839,63840],{"class":429}," decode_header(msg.get(",[262,63842,63843],{"class":275},"\"Subject\"",[262,63845,608],{"class":429},[262,63847,9175],{"class":275},[262,63849,63850],{"class":429},"))[",[262,63852,102],{"class":271},[262,63854,6163],{"class":429},[262,63856,102],{"class":271},[262,63858,957],{"class":429},[262,63860,63861,63863,63866,63869,63871],{"class":181,"line":521},[262,63862,3454],{"class":377},[262,63864,63865],{"class":271}," isinstance",[262,63867,63868],{"class":429},"(raw, ",[262,63870,9643],{"class":271},[262,63872,8192],{"class":429},[262,63874,63875,63877,63880,63882,63884,63886,63888,63890],{"class":181,"line":537},[262,63876,8066],{"class":377},[262,63878,63879],{"class":429}," raw.decode(",[262,63881,617],{"class":275},[262,63883,608],{"class":429},[262,63885,61479],{"class":611},[262,63887,476],{"class":377},[262,63889,61484],{"class":275},[262,63891,660],{"class":429},[262,63893,63894,63896,63899,63901],{"class":181,"line":549},[262,63895,573],{"class":377},[262,63897,63898],{"class":429}," raw ",[262,63900,8923],{"class":377},[262,63902,2908],{"class":275},[262,63904,63905],{"class":181,"line":570},[262,63906,583],{"emptyLinePlaceholder":582},[262,63908,63909],{"class":181,"line":579},[262,63910,583],{"emptyLinePlaceholder":582},[262,63912,63913,63915,63918,63921,63923,63925],{"class":181,"line":586},[262,63914,423],{"class":377},[262,63916,63917],{"class":267}," body_snippet",[262,63919,63920],{"class":429},"(msg, limit",[262,63922,476],{"class":377},[262,63924,117],{"class":271},[262,63926,8192],{"class":429},[262,63928,63929,63931],{"class":181,"line":591},[262,63930,3454],{"class":377},[262,63932,63933],{"class":429}," msg.is_multipart():\n",[262,63935,63936,63938,63941,63943],{"class":181,"line":623},[262,63937,10155],{"class":377},[262,63939,63940],{"class":429}," part ",[262,63942,835],{"class":377},[262,63944,63945],{"class":429}," msg.walk():\n",[262,63947,63948,63950,63953,63955,63958],{"class":181,"line":634},[262,63949,10200],{"class":377},[262,63951,63952],{"class":429}," part.get_content_type() ",[262,63954,10758],{"class":377},[262,63956,63957],{"class":275}," \"text\u002Fplain\"",[262,63959,1160],{"class":429},[262,63961,63962,63965,63967,63970,63973,63975,63977,63979,63981,63984],{"class":181,"line":845},[262,63963,63964],{"class":429},"                payload ",[262,63966,476],{"class":377},[262,63968,63969],{"class":429}," part.get_payload(",[262,63971,63972],{"class":611},"decode",[262,63974,476],{"class":377},[262,63976,4974],{"class":271},[262,63978,1000],{"class":429},[262,63980,8923],{"class":377},[262,63982,63983],{"class":377}," b",[262,63985,63986],{"class":275},"\"\"\n",[262,63988,63989,63992,63995,63997,63999,64001,64003,64005],{"class":181,"line":850},[262,63990,63991],{"class":377},"                return",[262,63993,63994],{"class":429}," payload.decode(",[262,63996,617],{"class":275},[262,63998,608],{"class":429},[262,64000,61479],{"class":611},[262,64002,476],{"class":377},[262,64004,61484],{"class":275},[262,64006,64007],{"class":429},")[:limit]\n",[262,64009,64010,64012],{"class":181,"line":864},[262,64011,8066],{"class":377},[262,64013,2908],{"class":275},[262,64015,64016,64018,64020,64023,64025,64027,64029,64031,64033,64035],{"class":181,"line":1683},[262,64017,16972],{"class":429},[262,64019,476],{"class":377},[262,64021,64022],{"class":429}," msg.get_payload(",[262,64024,63972],{"class":611},[262,64026,476],{"class":377},[262,64028,4974],{"class":271},[262,64030,1000],{"class":429},[262,64032,8923],{"class":377},[262,64034,63983],{"class":377},[262,64036,63986],{"class":275},[262,64038,64039,64041,64043,64045,64047,64049,64051,64053],{"class":181,"line":1688},[262,64040,573],{"class":377},[262,64042,63994],{"class":429},[262,64044,617],{"class":275},[262,64046,608],{"class":429},[262,64048,61479],{"class":611},[262,64050,476],{"class":377},[262,64052,61484],{"class":275},[262,64054,64007],{"class":429},[262,64056,64057],{"class":181,"line":1693},[262,64058,583],{"emptyLinePlaceholder":582},[262,64060,64061],{"class":181,"line":1728},[262,64062,583],{"emptyLinePlaceholder":582},[262,64064,64065,64067,64070],{"class":181,"line":1737},[262,64066,423],{"class":377},[262,64068,64069],{"class":267}," fetch_message",[262,64071,64072],{"class":429},"(mail, eid):\n",[262,64074,64075,64078,64080,64083,64086],{"class":181,"line":1751},[262,64076,64077],{"class":429},"    _status, msg_data ",[262,64079,476],{"class":377},[262,64081,64082],{"class":429}," mail.fetch(eid, ",[262,64084,64085],{"class":275},"\"(RFC822)\"",[262,64087,660],{"class":429},[262,64089,64090,64092,64095,64097,64099,64101],{"class":181,"line":1764},[262,64091,573],{"class":377},[262,64093,64094],{"class":429}," email.message_from_bytes(msg_data[",[262,64096,102],{"class":271},[262,64098,6163],{"class":429},[262,64100,997],{"class":271},[262,64102,3512],{"class":429},[14,64104,64105,64108,64109,64112],{},[18,64106,64107],{},"is_multipart()"," matters because many emails carry both a plain-text and an HTML copy; walking the parts and grabbing the ",[18,64110,64111],{},"text\u002Fplain"," version gives the model clean text instead of a wall of HTML tags.",[57,64114,64116],{"id":64115},"step-3-classify-each-email-with-an-llm","Step 3: Classify each email with an LLM",[14,64118,64119,64120,64122,64123,64125,64126,64129],{},"Now the interesting part. You give the model your list of category names and the email's subject plus snippet, and ask it to reply with exactly one category. The ",[18,64121,4466],{}," message sets the rules; the ",[18,64124,1357],{}," setting makes the answer as consistent as possible so the same email always lands in the same folder. If the model returns anything outside your list, you fall back to ",[18,64127,64128],{},"Other"," so an email is never lost.",[14,64131,64132,64133,1363],{},"This guide leans on plain instruction following; for a deeper look at shaping model output, see ",[51,64134,1362],{"href":1361},[253,64136,64138],{"className":414,"code":64137,"language":416,"meta":258,"style":258},"from openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\nCATEGORIES = [\"Invoices\", \"Newsletters\", \"Clients\", \"Other\"]\n\n# Cheap keyword shortcut: skip the API call when the subject is obvious.\nKEYWORD_RULES = {\n    \"Invoices\": [\"invoice\", \"receipt\", \"payment\", \"statement\"],\n    \"Newsletters\": [\"newsletter\", \"digest\", \"unsubscribe\"],\n    \"Clients\": [\"project\", \"contract\", \"meeting\"],\n}\n\n\ndef keyword_guess(subject):\n    low = subject.lower()\n    for category, words in KEYWORD_RULES.items():\n        if any(word in low for word in words):\n            return category\n    return None\n\n\ndef classify(subject, snippet):\n    shortcut = keyword_guess(subject)\n    if shortcut:\n        return shortcut\n\n    options = \", \".join(CATEGORIES)\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        temperature=0,\n        max_tokens=4,\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"You sort emails into folders. Reply with exactly one \"\n                    f\"of these category names and nothing else: {options}.\"\n                ),\n            },\n            {\n                \"role\": \"user\",\n                \"content\": f\"Subject: {subject}\\n\\nBody:\\n{snippet}\",\n            },\n        ],\n    )\n    answer = response.choices[0].message.content.strip()\n    return answer if answer in CATEGORIES else \"Other\"\n",[18,64139,64140,64150,64154,64164,64168,64197,64201,64206,64215,64241,64263,64285,64289,64293,64297,64307,64317,64332,64357,64364,64370,64374,64378,64387,64397,64404,64411,64415,64431,64439,64449,64459,64469,64477,64481,64491,64497,64502,64518,64522,64526,64530,64540,64571,64575,64579,64583,64596],{"__ignoreMap":258},[262,64141,64142,64144,64146,64148],{"class":181,"line":264},[262,64143,705],{"class":377},[262,64145,720],{"class":429},[262,64147,684],{"class":377},[262,64149,725],{"class":429},[262,64151,64152],{"class":181,"line":282},[262,64153,583],{"emptyLinePlaceholder":582},[262,64155,64156,64158,64160,64162],{"class":181,"line":295},[262,64157,739],{"class":429},[262,64159,476],{"class":377},[262,64161,9578],{"class":429},[262,64163,9581],{"class":291},[262,64165,64166],{"class":181,"line":345},[262,64167,583],{"emptyLinePlaceholder":582},[262,64169,64170,64173,64175,64177,64180,64182,64185,64187,64190,64192,64195],{"class":181,"line":492},[262,64171,64172],{"class":271},"CATEGORIES",[262,64174,442],{"class":377},[262,64176,10563],{"class":429},[262,64178,64179],{"class":275},"\"Invoices\"",[262,64181,608],{"class":429},[262,64183,64184],{"class":275},"\"Newsletters\"",[262,64186,608],{"class":429},[262,64188,64189],{"class":275},"\"Clients\"",[262,64191,608],{"class":429},[262,64193,64194],{"class":275},"\"Other\"",[262,64196,957],{"class":429},[262,64198,64199],{"class":181,"line":503},[262,64200,583],{"emptyLinePlaceholder":582},[262,64202,64203],{"class":181,"line":521},[262,64204,64205],{"class":291},"# Cheap keyword shortcut: skip the API call when the subject is obvious.\n",[262,64207,64208,64211,64213],{"class":181,"line":537},[262,64209,64210],{"class":271},"KEYWORD_RULES",[262,64212,442],{"class":377},[262,64214,20437],{"class":429},[262,64216,64217,64220,64222,64225,64227,64230,64232,64234,64236,64239],{"class":181,"line":549},[262,64218,64219],{"class":275},"    \"Invoices\"",[262,64221,35333],{"class":429},[262,64223,64224],{"class":275},"\"invoice\"",[262,64226,608],{"class":429},[262,64228,64229],{"class":275},"\"receipt\"",[262,64231,608],{"class":429},[262,64233,55360],{"class":275},[262,64235,608],{"class":429},[262,64237,64238],{"class":275},"\"statement\"",[262,64240,10309],{"class":429},[262,64242,64243,64246,64248,64251,64253,64256,64258,64261],{"class":181,"line":570},[262,64244,64245],{"class":275},"    \"Newsletters\"",[262,64247,35333],{"class":429},[262,64249,64250],{"class":275},"\"newsletter\"",[262,64252,608],{"class":429},[262,64254,64255],{"class":275},"\"digest\"",[262,64257,608],{"class":429},[262,64259,64260],{"class":275},"\"unsubscribe\"",[262,64262,10309],{"class":429},[262,64264,64265,64268,64270,64273,64275,64278,64280,64283],{"class":181,"line":579},[262,64266,64267],{"class":275},"    \"Clients\"",[262,64269,35333],{"class":429},[262,64271,64272],{"class":275},"\"project\"",[262,64274,608],{"class":429},[262,64276,64277],{"class":275},"\"contract\"",[262,64279,608],{"class":429},[262,64281,64282],{"class":275},"\"meeting\"",[262,64284,10309],{"class":429},[262,64286,64287],{"class":181,"line":586},[262,64288,16430],{"class":429},[262,64290,64291],{"class":181,"line":591},[262,64292,583],{"emptyLinePlaceholder":582},[262,64294,64295],{"class":181,"line":623},[262,64296,583],{"emptyLinePlaceholder":582},[262,64298,64299,64301,64304],{"class":181,"line":634},[262,64300,423],{"class":377},[262,64302,64303],{"class":267}," keyword_guess",[262,64305,64306],{"class":429},"(subject):\n",[262,64308,64309,64312,64314],{"class":181,"line":845},[262,64310,64311],{"class":429},"    low ",[262,64313,476],{"class":377},[262,64315,64316],{"class":429}," subject.lower()\n",[262,64318,64319,64321,64324,64326,64329],{"class":181,"line":850},[262,64320,3074],{"class":377},[262,64322,64323],{"class":429}," category, words ",[262,64325,835],{"class":377},[262,64327,64328],{"class":271}," KEYWORD_RULES",[262,64330,64331],{"class":429},".items():\n",[262,64333,64334,64336,64339,64342,64344,64347,64349,64352,64354],{"class":181,"line":864},[262,64335,2268],{"class":377},[262,64337,64338],{"class":271}," any",[262,64340,64341],{"class":429},"(word ",[262,64343,835],{"class":377},[262,64345,64346],{"class":429}," low ",[262,64348,829],{"class":377},[262,64350,64351],{"class":429}," word ",[262,64353,835],{"class":377},[262,64355,64356],{"class":429}," words):\n",[262,64358,64359,64361],{"class":181,"line":1683},[262,64360,3198],{"class":377},[262,64362,64363],{"class":429}," category\n",[262,64365,64366,64368],{"class":181,"line":1688},[262,64367,573],{"class":377},[262,64369,18658],{"class":271},[262,64371,64372],{"class":181,"line":1693},[262,64373,583],{"emptyLinePlaceholder":582},[262,64375,64376],{"class":181,"line":1728},[262,64377,583],{"emptyLinePlaceholder":582},[262,64379,64380,64382,64384],{"class":181,"line":1737},[262,64381,423],{"class":377},[262,64383,61715],{"class":267},[262,64385,64386],{"class":429},"(subject, snippet):\n",[262,64388,64389,64392,64394],{"class":181,"line":1751},[262,64390,64391],{"class":429},"    shortcut ",[262,64393,476],{"class":377},[262,64395,64396],{"class":429}," keyword_guess(subject)\n",[262,64398,64399,64401],{"class":181,"line":1764},[262,64400,3454],{"class":377},[262,64402,64403],{"class":429}," shortcut:\n",[262,64405,64406,64408],{"class":181,"line":1779},[262,64407,8066],{"class":377},[262,64409,64410],{"class":429}," shortcut\n",[262,64412,64413],{"class":181,"line":1793},[262,64414,583],{"emptyLinePlaceholder":582},[262,64416,64417,64420,64422,64425,64427,64429],{"class":181,"line":1800},[262,64418,64419],{"class":429},"    options ",[262,64421,476],{"class":377},[262,64423,64424],{"class":275}," \", \"",[262,64426,2023],{"class":429},[262,64428,64172],{"class":271},[262,64430,660],{"class":429},[262,64432,64433,64435,64437],{"class":181,"line":1805},[262,64434,1184],{"class":429},[262,64436,476],{"class":377},[262,64438,1189],{"class":429},[262,64440,64441,64443,64445,64447],{"class":181,"line":1810},[262,64442,1194],{"class":611},[262,64444,476],{"class":377},[262,64446,1207],{"class":275},[262,64448,1315],{"class":429},[262,64450,64451,64453,64455,64457],{"class":181,"line":1823},[262,64452,1308],{"class":611},[262,64454,476],{"class":377},[262,64456,102],{"class":271},[262,64458,1315],{"class":429},[262,64460,64461,64463,64465,64467],{"class":181,"line":1846},[262,64462,4679],{"class":611},[262,64464,476],{"class":377},[262,64466,19848],{"class":271},[262,64468,1315],{"class":429},[262,64470,64471,64473,64475],{"class":181,"line":1861},[262,64472,1215],{"class":611},[262,64474,476],{"class":377},[262,64476,1220],{"class":429},[262,64478,64479],{"class":181,"line":1866},[262,64480,4331],{"class":429},[262,64482,64483,64485,64487,64489],{"class":181,"line":1871},[262,64484,4336],{"class":275},[262,64486,1231],{"class":429},[262,64488,1234],{"class":275},[262,64490,1315],{"class":429},[262,64492,64493,64495],{"class":181,"line":1890},[262,64494,4347],{"class":275},[262,64496,1242],{"class":429},[262,64498,64499],{"class":181,"line":1909},[262,64500,64501],{"class":275},"                    \"You sort emails into folders. Reply with exactly one \"\n",[262,64503,64504,64506,64509,64511,64514,64516],{"class":181,"line":1914},[262,64505,4394],{"class":377},[262,64507,64508],{"class":275},"\"of these category names and nothing else: ",[262,64510,3039],{"class":271},[262,64512,64513],{"class":429},"options",[262,64515,654],{"class":271},[262,64517,45410],{"class":275},[262,64519,64520],{"class":181,"line":1919},[262,64521,4364],{"class":429},[262,64523,64524],{"class":181,"line":1946},[262,64525,4369],{"class":429},[262,64527,64528],{"class":181,"line":1959},[262,64529,4331],{"class":429},[262,64531,64532,64534,64536,64538],{"class":181,"line":1996},[262,64533,4336],{"class":275},[262,64535,1231],{"class":429},[262,64537,1291],{"class":275},[262,64539,1315],{"class":429},[262,64541,64542,64544,64546,64548,64551,64553,64555,64557,64560,64562,64565,64567,64569],{"class":181,"line":2012},[262,64543,4347],{"class":275},[262,64545,1231],{"class":429},[262,64547,642],{"class":377},[262,64549,64550],{"class":275},"\"Subject: ",[262,64552,3039],{"class":271},[262,64554,6319],{"class":429},[262,64556,4644],{"class":271},[262,64558,64559],{"class":275},"Body:",[262,64561,1268],{"class":271},[262,64563,64564],{"class":429},"snippet",[262,64566,654],{"class":271},[262,64568,1176],{"class":275},[262,64570,1315],{"class":429},[262,64572,64573],{"class":181,"line":2040},[262,64574,4369],{"class":429},[262,64576,64577],{"class":181,"line":2045},[262,64578,1303],{"class":429},[262,64580,64581],{"class":181,"line":2050},[262,64582,1011],{"class":429},[262,64584,64585,64588,64590,64592,64594],{"class":181,"line":2067},[262,64586,64587],{"class":429},"    answer ",[262,64589,476],{"class":377},[262,64591,1326],{"class":429},[262,64593,102],{"class":271},[262,64595,3205],{"class":429},[262,64597,64598,64600,64603,64605,64607,64609,64612,64614],{"class":181,"line":2077},[262,64599,573],{"class":377},[262,64601,64602],{"class":429}," answer ",[262,64604,2210],{"class":377},[262,64606,64602],{"class":429},[262,64608,835],{"class":377},[262,64610,64611],{"class":271}," CATEGORIES",[262,64613,19241],{"class":377},[262,64615,64616],{"class":275}," \"Other\"\n",[14,64618,64619],{},"The keyword shortcut is worth keeping: it costs nothing, runs instantly, and handles the bulk of routine mail. The LLM only sees the messages that keywords cannot confidently place, which keeps your bill low while still catching the tricky ones.",[57,64621,64623],{"id":64622},"step-4-move-or-label-each-message-by-category","Step 4: Move or label each message by category",[14,64625,64626,64627,64630],{},"With a category in hand, copy the email into the matching folder and mark it read so it is excluded from the next run. IMAP servers cannot move a message in one call, so the standard pattern is copy, flag the original as deleted, then ",[18,64628,64629],{},"expunge"," to remove it from the inbox. Creating the folders up front means a brand-new category never triggers a \"folder does not exist\" error.",[253,64632,64634],{"className":414,"code":64633,"language":416,"meta":258,"style":258},"def ensure_folders(mail):\n    for category in CATEGORIES:\n        mail.create(category)  # harmless if the folder already exists\n\n\ndef route(mail, eid, category):\n    mail.copy(eid, category)\n    mail.store(eid, \"+FLAGS\", \"\\\\Seen\")\n    mail.store(eid, \"+FLAGS\", \"\\\\Deleted\")  # remove from inbox after copy\n",[18,64635,64636,64645,64658,64666,64670,64674,64684,64689,64709],{"__ignoreMap":258},[262,64637,64638,64640,64643],{"class":181,"line":264},[262,64639,423],{"class":377},[262,64641,64642],{"class":267}," ensure_folders",[262,64644,63737],{"class":429},[262,64646,64647,64649,64652,64654,64656],{"class":181,"line":282},[262,64648,3074],{"class":377},[262,64650,64651],{"class":429}," category ",[262,64653,835],{"class":377},[262,64655,64611],{"class":271},[262,64657,1160],{"class":429},[262,64659,64660,64663],{"class":181,"line":295},[262,64661,64662],{"class":429},"        mail.create(category)  ",[262,64664,64665],{"class":291},"# harmless if the folder already exists\n",[262,64667,64668],{"class":181,"line":345},[262,64669,583],{"emptyLinePlaceholder":582},[262,64671,64672],{"class":181,"line":492},[262,64673,583],{"emptyLinePlaceholder":582},[262,64675,64676,64678,64681],{"class":181,"line":503},[262,64677,423],{"class":377},[262,64679,64680],{"class":267}," route",[262,64682,64683],{"class":429},"(mail, eid, category):\n",[262,64685,64686],{"class":181,"line":521},[262,64687,64688],{"class":429},"    mail.copy(eid, category)\n",[262,64690,64691,64694,64697,64699,64701,64704,64707],{"class":181,"line":537},[262,64692,64693],{"class":429},"    mail.store(eid, ",[262,64695,64696],{"class":275},"\"+FLAGS\"",[262,64698,608],{"class":429},[262,64700,1176],{"class":275},[262,64702,64703],{"class":271},"\\\\",[262,64705,64706],{"class":275},"Seen\"",[262,64708,660],{"class":429},[262,64710,64711,64713,64715,64717,64719,64721,64724,64726],{"class":181,"line":549},[262,64712,64693],{"class":429},[262,64714,64696],{"class":275},[262,64716,608],{"class":429},[262,64718,1176],{"class":275},[262,64720,64703],{"class":271},[262,64722,64723],{"class":275},"Deleted\"",[262,64725,32223],{"class":429},[262,64727,64728],{"class":291},"# remove from inbox after copy\n",[14,64730,64731,64732,64735,64736,64738],{},"If you would rather keep everything in the inbox and only tag it (Gmail treats labels as folders, so a copied message simply gains a label), drop the ",[18,64733,64734],{},"\\\\Deleted"," line and skip the ",[18,64737,64629],{}," call below. That leaves the original in place with the new label attached.",[57,64740,64742],{"id":64741},"step-5-schedule-the-script-to-run-on-its-own","Step 5: Schedule the script to run on its own",[14,64744,64745,64746,64749,64750,64752],{},"Wire the pieces together into a ",[18,64747,64748],{},"main()"," function, run it once by hand to confirm folders fill correctly, then hand it to your operating system's scheduler. This is the same approach covered across ",[51,64751,21230],{"href":21229}," — do the work once, then let a schedule repeat it.",[253,64754,64756],{"className":414,"code":64755,"language":416,"meta":258,"style":258},"0 *\u002F4 * * * \u002Fpath\u002Fto\u002F.venv\u002Fbin\u002Fpython \u002Fpath\u002Fto\u002Femail_sorter.py >> \u002Ftmp\u002Fsorter.log 2>&1\n",[18,64757,64758],{"__ignoreMap":258},[262,64759,64760,64762,64765,64767,64769,64771,64773,64776,64778,64780,64782,64784,64786,64788,64791,64793,64796,64798,64800,64802,64804,64806,64809,64812,64814,64817,64819,64822,64824,64827],{"class":181,"line":264},[262,64761,102],{"class":271},[262,64763,64764],{"class":377}," *\u002F",[262,64766,19848],{"class":271},[262,64768,18556],{"class":377},[262,64770,18556],{"class":377},[262,64772,18556],{"class":377},[262,64774,64775],{"class":377}," \u002F",[262,64777,216],{"class":429},[262,64779,981],{"class":377},[262,64781,6736],{"class":429},[262,64783,981],{"class":377},[262,64785,62557],{"class":429},[262,64787,981],{"class":377},[262,64789,64790],{"class":271},"bin",[262,64792,981],{"class":377},[262,64794,64795],{"class":429},"python ",[262,64797,981],{"class":377},[262,64799,216],{"class":429},[262,64801,981],{"class":377},[262,64803,6736],{"class":429},[262,64805,981],{"class":377},[262,64807,64808],{"class":429},"email_sorter.py ",[262,64810,64811],{"class":377},">>",[262,64813,64775],{"class":377},[262,64815,64816],{"class":429},"tmp",[262,64818,981],{"class":377},[262,64820,64821],{"class":429},"sorter.log ",[262,64823,109],{"class":271},[262,64825,64826],{"class":377},">&",[262,64828,64829],{"class":271},"1\n",[14,64831,64832,64833,64836],{},"On Windows, open Task Scheduler, create a basic task, point the program to your virtual environment's ",[18,64834,64835],{},"python.exe",", and pass the full path to the script as the argument. Either way, log the output so you can see what the script did between runs.",[57,64838,44332],{"id":44331},[1379,64840,64841,64853],{},[1382,64842,64843],{},[1385,64844,64845,64847,64849,64851],{},[1388,64846,1390],{},[1388,64848,3795],{},[1388,64850,3798],{},[1388,64852,1396],{},[1398,64854,64855,64870,64887],{},[1385,64856,64857,64861,64863,64867],{},[1403,64858,64859],{},[18,64860,805],{},[1403,64862,433],{},[1403,64864,64865],{},[18,64866,2703],{},[1403,64868,64869],{},"The classifier model. Smaller models are cheaper and fast enough for one-word category answers.",[1385,64871,64872,64876,64878,64882],{},[1403,64873,64874],{},[18,64875,3829],{},[1403,64877,3832],{},[1403,64879,64880],{},[18,64881,102],{},[1403,64883,34967,64884,64886],{},[18,64885,102],{}," so the same email is always sorted the same way.",[1385,64888,64889,64894,64896,64900],{},[1403,64890,64891],{},[18,64892,64893],{},"body_snippet(limit=...)",[1403,64895,439],{},[1403,64897,64898],{},[18,64899,117],{},[1403,64901,64902],{},"How many characters of the body the model sees. Larger means more context but a slightly higher cost.",[57,64904,1445],{"id":1444},[1447,64906,64907,64919,64938,64957],{},[1450,64908,64909,64914,64915,54592,64917,1363],{},[35,64910,64911],{},[18,64912,64913],{},"imaplib.error: Authentication failed"," — Your provider rejected the login. With two-factor authentication on, your normal password will not work; generate an app password and put it in ",[18,64916,319],{},[18,64918,63650],{},[1450,64920,64921,64930,64931,64934,64935,64937],{},[35,64922,64923,64926,64927],{},[18,64924,64925],{},"NONEXISTENT"," error on ",[18,64928,64929],{},"mail.copy()"," — The target folder does not exist yet. Call ",[18,64932,64933],{},"ensure_folders(mail)"," once before the sorting loop so every category in ",[18,64936,64172],{}," is created first.",[1450,64939,64940,64946,64947,64949,64950,8436,64952,8440,64954,64956],{},[35,64941,64942,64945],{},[18,64943,64944],{},"openai.AuthenticationError"," \u002F 401"," — The API key is missing or wrong. Confirm ",[18,64948,21742],{}," is set in ",[18,64951,319],{},[18,64953,8439],{},[51,64955,388],{"href":387}," for the full checklist.",[1450,64958,64959,64964,64965,64967,64968,64970],{},[35,64960,64961,64963],{},[18,64962,28811],{}," \u002F 429"," — You are sending requests faster than your tier allows. Add a short ",[18,64966,14421],{}," between emails or lean harder on the keyword shortcut. The fixes in ",[51,64969,3379],{"href":3378}," apply directly here.",[57,64972,38705],{"id":38704},[14,64974,64975,64976,38711,64979,64981,64982,1363],{},"This is every piece assembled into one runnable file. Save it as ",[18,64977,64978],{},"email_sorter.py",[18,64980,319],{},", and run ",[18,64983,64984],{},"python email_sorter.py",[253,64986,64988],{"className":414,"code":64987,"language":416,"meta":258,"style":258},"import email\nimport os\nfrom email.header import decode_header\n\nimport imaplib\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI()\n\nIMAP_SERVER = os.environ[\"IMAP_SERVER\"]\nEMAIL_ADDRESS = os.environ[\"EMAIL_ADDRESS\"]\nAPP_PASSWORD = os.environ[\"APP_PASSWORD\"]\n\nCATEGORIES = [\"Invoices\", \"Newsletters\", \"Clients\", \"Other\"]\nKEYWORD_RULES = {\n    \"Invoices\": [\"invoice\", \"receipt\", \"payment\", \"statement\"],\n    \"Newsletters\": [\"newsletter\", \"digest\", \"unsubscribe\"],\n    \"Clients\": [\"project\", \"contract\", \"meeting\"],\n}\n\n\ndef decode_subject(msg):\n    raw = decode_header(msg.get(\"Subject\", \"\"))[0][0]\n    return raw.decode(\"utf-8\", \"ignore\") if isinstance(raw, bytes) else (raw or \"\")\n\n\ndef body_snippet(msg, limit=600):\n    if msg.is_multipart():\n        for part in msg.walk():\n            if part.get_content_type() == \"text\u002Fplain\":\n                data = part.get_payload(decode=True) or b\"\"\n                return data.decode(\"utf-8\", \"ignore\")[:limit]\n        return \"\"\n    data = msg.get_payload(decode=True) or b\"\"\n    return data.decode(\"utf-8\", \"ignore\")[:limit]\n\n\ndef classify(subject, snippet):\n    low = subject.lower()\n    for category, words in KEYWORD_RULES.items():\n        if any(word in low for word in words):\n            return category  # fast, free keyword match\n\n    options = \", \".join(CATEGORIES)\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        temperature=0,\n        max_tokens=4,\n        messages=[\n            {\"role\": \"system\", \"content\": (\n                \"You sort emails into folders. Reply with exactly one of \"\n                f\"these category names and nothing else: {options}.\")},\n            {\"role\": \"user\", \"content\": f\"Subject: {subject}\\n\\nBody:\\n{snippet}\"},\n        ],\n    )\n    answer = response.choices[0].message.content.strip()\n    return answer if answer in CATEGORIES else \"Other\"\n\n\ndef main():\n    mail = imaplib.IMAP4_SSL(IMAP_SERVER)\n    mail.login(EMAIL_ADDRESS, APP_PASSWORD)\n    mail.select(\"inbox\")\n    for category in CATEGORIES:\n        mail.create(category)  # safe even if it exists\n\n    _status, messages = mail.search(None, \"UNSEEN\")\n    for eid in messages[0].split():\n        _status, data = mail.fetch(eid, \"(RFC822)\")\n        msg = email.message_from_bytes(data[0][1])\n        category = classify(decode_subject(msg), body_snippet(msg))\n        mail.copy(eid, category)\n        mail.store(eid, \"+FLAGS\", \"\\\\Seen\")\n        mail.store(eid, \"+FLAGS\", \"\\\\Deleted\")\n        print(f\"Sorted into {category}: {decode_subject(msg)[:60]}\")\n\n    mail.expunge()\n    mail.logout()\n\n\nif __name__ == \"__main__\":\n    main()\n",[18,64989,64990,64996,65002,65012,65016,65022,65032,65042,65046,65050,65058,65062,65074,65086,65098,65102,65126,65134,65156,65174,65192,65196,65200,65204,65212,65236,65271,65275,65279,65293,65299,65309,65321,65343,65358,65364,65386,65400,65404,65408,65416,65424,65436,65456,65466,65470,65484,65492,65502,65512,65522,65530,65546,65551,65570,65608,65612,65616,65628,65646,65650,65654,65662,65674,65686,65694,65706,65713,65717,65733,65749,65762,65779,65789,65794,65811,65827,65861,65865,65871,65877,65882,65887,65900],{"__ignoreMap":258},[262,64991,64992,64994],{"class":181,"line":264},[262,64993,684],{"class":377},[262,64995,63801],{"class":429},[262,64997,64998,65000],{"class":181,"line":282},[262,64999,684],{"class":377},[262,65001,687],{"class":429},[262,65003,65004,65006,65008,65010],{"class":181,"line":295},[262,65005,705],{"class":377},[262,65007,63808],{"class":429},[262,65009,684],{"class":377},[262,65011,63813],{"class":429},[262,65013,65014],{"class":181,"line":345},[262,65015,583],{"emptyLinePlaceholder":582},[262,65017,65018,65020],{"class":181,"line":492},[262,65019,684],{"class":377},[262,65021,63585],{"class":429},[262,65023,65024,65026,65028,65030],{"class":181,"line":503},[262,65025,705],{"class":377},[262,65027,708],{"class":429},[262,65029,684],{"class":377},[262,65031,713],{"class":429},[262,65033,65034,65036,65038,65040],{"class":181,"line":521},[262,65035,705],{"class":377},[262,65037,720],{"class":429},[262,65039,684],{"class":377},[262,65041,725],{"class":429},[262,65043,65044],{"class":181,"line":537},[262,65045,583],{"emptyLinePlaceholder":582},[262,65047,65048],{"class":181,"line":549},[262,65049,734],{"class":429},[262,65051,65052,65054,65056],{"class":181,"line":570},[262,65053,739],{"class":429},[262,65055,476],{"class":377},[262,65057,744],{"class":429},[262,65059,65060],{"class":181,"line":579},[262,65061,583],{"emptyLinePlaceholder":582},[262,65063,65064,65066,65068,65070,65072],{"class":181,"line":586},[262,65065,63622],{"class":271},[262,65067,442],{"class":377},[262,65069,36185],{"class":429},[262,65071,63629],{"class":275},[262,65073,957],{"class":429},[262,65075,65076,65078,65080,65082,65084],{"class":181,"line":591},[262,65077,63636],{"class":271},[262,65079,442],{"class":377},[262,65081,36185],{"class":429},[262,65083,63643],{"class":275},[262,65085,957],{"class":429},[262,65087,65088,65090,65092,65094,65096],{"class":181,"line":623},[262,65089,63650],{"class":271},[262,65091,442],{"class":377},[262,65093,36185],{"class":429},[262,65095,63657],{"class":275},[262,65097,957],{"class":429},[262,65099,65100],{"class":181,"line":634},[262,65101,583],{"emptyLinePlaceholder":582},[262,65103,65104,65106,65108,65110,65112,65114,65116,65118,65120,65122,65124],{"class":181,"line":845},[262,65105,64172],{"class":271},[262,65107,442],{"class":377},[262,65109,10563],{"class":429},[262,65111,64179],{"class":275},[262,65113,608],{"class":429},[262,65115,64184],{"class":275},[262,65117,608],{"class":429},[262,65119,64189],{"class":275},[262,65121,608],{"class":429},[262,65123,64194],{"class":275},[262,65125,957],{"class":429},[262,65127,65128,65130,65132],{"class":181,"line":850},[262,65129,64210],{"class":271},[262,65131,442],{"class":377},[262,65133,20437],{"class":429},[262,65135,65136,65138,65140,65142,65144,65146,65148,65150,65152,65154],{"class":181,"line":864},[262,65137,64219],{"class":275},[262,65139,35333],{"class":429},[262,65141,64224],{"class":275},[262,65143,608],{"class":429},[262,65145,64229],{"class":275},[262,65147,608],{"class":429},[262,65149,55360],{"class":275},[262,65151,608],{"class":429},[262,65153,64238],{"class":275},[262,65155,10309],{"class":429},[262,65157,65158,65160,65162,65164,65166,65168,65170,65172],{"class":181,"line":1683},[262,65159,64245],{"class":275},[262,65161,35333],{"class":429},[262,65163,64250],{"class":275},[262,65165,608],{"class":429},[262,65167,64255],{"class":275},[262,65169,608],{"class":429},[262,65171,64260],{"class":275},[262,65173,10309],{"class":429},[262,65175,65176,65178,65180,65182,65184,65186,65188,65190],{"class":181,"line":1688},[262,65177,64267],{"class":275},[262,65179,35333],{"class":429},[262,65181,64272],{"class":275},[262,65183,608],{"class":429},[262,65185,64277],{"class":275},[262,65187,608],{"class":429},[262,65189,64282],{"class":275},[262,65191,10309],{"class":429},[262,65193,65194],{"class":181,"line":1693},[262,65195,16430],{"class":429},[262,65197,65198],{"class":181,"line":1728},[262,65199,583],{"emptyLinePlaceholder":582},[262,65201,65202],{"class":181,"line":1737},[262,65203,583],{"emptyLinePlaceholder":582},[262,65205,65206,65208,65210],{"class":181,"line":1751},[262,65207,423],{"class":377},[262,65209,63828],{"class":267},[262,65211,63831],{"class":429},[262,65213,65214,65216,65218,65220,65222,65224,65226,65228,65230,65232,65234],{"class":181,"line":1764},[262,65215,15127],{"class":429},[262,65217,476],{"class":377},[262,65219,63840],{"class":429},[262,65221,63843],{"class":275},[262,65223,608],{"class":429},[262,65225,9175],{"class":275},[262,65227,63850],{"class":429},[262,65229,102],{"class":271},[262,65231,6163],{"class":429},[262,65233,102],{"class":271},[262,65235,957],{"class":429},[262,65237,65238,65240,65242,65244,65246,65248,65250,65252,65254,65256,65258,65260,65262,65265,65267,65269],{"class":181,"line":1779},[262,65239,573],{"class":377},[262,65241,63879],{"class":429},[262,65243,617],{"class":275},[262,65245,608],{"class":429},[262,65247,61484],{"class":275},[262,65249,1000],{"class":429},[262,65251,2210],{"class":377},[262,65253,63865],{"class":271},[262,65255,63868],{"class":429},[262,65257,9643],{"class":271},[262,65259,1000],{"class":429},[262,65261,20859],{"class":377},[262,65263,65264],{"class":429}," (raw ",[262,65266,8923],{"class":377},[262,65268,6332],{"class":275},[262,65270,660],{"class":429},[262,65272,65273],{"class":181,"line":1793},[262,65274,583],{"emptyLinePlaceholder":582},[262,65276,65277],{"class":181,"line":1800},[262,65278,583],{"emptyLinePlaceholder":582},[262,65280,65281,65283,65285,65287,65289,65291],{"class":181,"line":1805},[262,65282,423],{"class":377},[262,65284,63917],{"class":267},[262,65286,63920],{"class":429},[262,65288,476],{"class":377},[262,65290,117],{"class":271},[262,65292,8192],{"class":429},[262,65294,65295,65297],{"class":181,"line":1810},[262,65296,3454],{"class":377},[262,65298,63933],{"class":429},[262,65300,65301,65303,65305,65307],{"class":181,"line":1823},[262,65302,10155],{"class":377},[262,65304,63940],{"class":429},[262,65306,835],{"class":377},[262,65308,63945],{"class":429},[262,65310,65311,65313,65315,65317,65319],{"class":181,"line":1846},[262,65312,10200],{"class":377},[262,65314,63952],{"class":429},[262,65316,10758],{"class":377},[262,65318,63957],{"class":275},[262,65320,1160],{"class":429},[262,65322,65323,65325,65327,65329,65331,65333,65335,65337,65339,65341],{"class":181,"line":1861},[262,65324,10247],{"class":429},[262,65326,476],{"class":377},[262,65328,63969],{"class":429},[262,65330,63972],{"class":611},[262,65332,476],{"class":377},[262,65334,4974],{"class":271},[262,65336,1000],{"class":429},[262,65338,8923],{"class":377},[262,65340,63983],{"class":377},[262,65342,63986],{"class":275},[262,65344,65345,65347,65350,65352,65354,65356],{"class":181,"line":1866},[262,65346,63991],{"class":377},[262,65348,65349],{"class":429}," data.decode(",[262,65351,617],{"class":275},[262,65353,608],{"class":429},[262,65355,61484],{"class":275},[262,65357,64007],{"class":429},[262,65359,65360,65362],{"class":181,"line":1871},[262,65361,8066],{"class":377},[262,65363,2908],{"class":275},[262,65365,65366,65368,65370,65372,65374,65376,65378,65380,65382,65384],{"class":181,"line":1890},[262,65367,18166],{"class":429},[262,65369,476],{"class":377},[262,65371,64022],{"class":429},[262,65373,63972],{"class":611},[262,65375,476],{"class":377},[262,65377,4974],{"class":271},[262,65379,1000],{"class":429},[262,65381,8923],{"class":377},[262,65383,63983],{"class":377},[262,65385,63986],{"class":275},[262,65387,65388,65390,65392,65394,65396,65398],{"class":181,"line":1909},[262,65389,573],{"class":377},[262,65391,65349],{"class":429},[262,65393,617],{"class":275},[262,65395,608],{"class":429},[262,65397,61484],{"class":275},[262,65399,64007],{"class":429},[262,65401,65402],{"class":181,"line":1914},[262,65403,583],{"emptyLinePlaceholder":582},[262,65405,65406],{"class":181,"line":1919},[262,65407,583],{"emptyLinePlaceholder":582},[262,65409,65410,65412,65414],{"class":181,"line":1946},[262,65411,423],{"class":377},[262,65413,61715],{"class":267},[262,65415,64386],{"class":429},[262,65417,65418,65420,65422],{"class":181,"line":1959},[262,65419,64311],{"class":429},[262,65421,476],{"class":377},[262,65423,64316],{"class":429},[262,65425,65426,65428,65430,65432,65434],{"class":181,"line":1996},[262,65427,3074],{"class":377},[262,65429,64323],{"class":429},[262,65431,835],{"class":377},[262,65433,64328],{"class":271},[262,65435,64331],{"class":429},[262,65437,65438,65440,65442,65444,65446,65448,65450,65452,65454],{"class":181,"line":2012},[262,65439,2268],{"class":377},[262,65441,64338],{"class":271},[262,65443,64341],{"class":429},[262,65445,835],{"class":377},[262,65447,64346],{"class":429},[262,65449,829],{"class":377},[262,65451,64351],{"class":429},[262,65453,835],{"class":377},[262,65455,64356],{"class":429},[262,65457,65458,65460,65463],{"class":181,"line":2040},[262,65459,3198],{"class":377},[262,65461,65462],{"class":429}," category  ",[262,65464,65465],{"class":291},"# fast, free keyword match\n",[262,65467,65468],{"class":181,"line":2045},[262,65469,583],{"emptyLinePlaceholder":582},[262,65471,65472,65474,65476,65478,65480,65482],{"class":181,"line":2050},[262,65473,64419],{"class":429},[262,65475,476],{"class":377},[262,65477,64424],{"class":275},[262,65479,2023],{"class":429},[262,65481,64172],{"class":271},[262,65483,660],{"class":429},[262,65485,65486,65488,65490],{"class":181,"line":2067},[262,65487,1184],{"class":429},[262,65489,476],{"class":377},[262,65491,1189],{"class":429},[262,65493,65494,65496,65498,65500],{"class":181,"line":2077},[262,65495,1194],{"class":611},[262,65497,476],{"class":377},[262,65499,1207],{"class":275},[262,65501,1315],{"class":429},[262,65503,65504,65506,65508,65510],{"class":181,"line":2086},[262,65505,1308],{"class":611},[262,65507,476],{"class":377},[262,65509,102],{"class":271},[262,65511,1315],{"class":429},[262,65513,65514,65516,65518,65520],{"class":181,"line":2097},[262,65515,4679],{"class":611},[262,65517,476],{"class":377},[262,65519,19848],{"class":271},[262,65521,1315],{"class":429},[262,65523,65524,65526,65528],{"class":181,"line":2106},[262,65525,1215],{"class":611},[262,65527,476],{"class":377},[262,65529,1220],{"class":429},[262,65531,65532,65534,65536,65538,65540,65542,65544],{"class":181,"line":2126},[262,65533,1225],{"class":429},[262,65535,1228],{"class":275},[262,65537,1231],{"class":429},[262,65539,1234],{"class":275},[262,65541,608],{"class":429},[262,65543,1239],{"class":275},[262,65545,1242],{"class":429},[262,65547,65548],{"class":181,"line":2148},[262,65549,65550],{"class":275},"                \"You sort emails into folders. Reply with exactly one of \"\n",[262,65552,65553,65555,65558,65560,65562,65564,65567],{"class":181,"line":2165},[262,65554,1262],{"class":377},[262,65556,65557],{"class":275},"\"these category names and nothing else: ",[262,65559,3039],{"class":271},[262,65561,64513],{"class":429},[262,65563,654],{"class":271},[262,65565,65566],{"class":275},".\"",[262,65568,65569],{"class":429},")},\n",[262,65571,65572,65574,65576,65578,65580,65582,65584,65586,65588,65590,65592,65594,65596,65598,65600,65602,65604,65606],{"class":181,"line":2170},[262,65573,1225],{"class":429},[262,65575,1228],{"class":275},[262,65577,1231],{"class":429},[262,65579,1291],{"class":275},[262,65581,608],{"class":429},[262,65583,1239],{"class":275},[262,65585,1231],{"class":429},[262,65587,642],{"class":377},[262,65589,64550],{"class":275},[262,65591,3039],{"class":271},[262,65593,6319],{"class":429},[262,65595,4644],{"class":271},[262,65597,64559],{"class":275},[262,65599,1268],{"class":271},[262,65601,64564],{"class":429},[262,65603,654],{"class":271},[262,65605,1176],{"class":275},[262,65607,3143],{"class":429},[262,65609,65610],{"class":181,"line":2181},[262,65611,1303],{"class":429},[262,65613,65614],{"class":181,"line":2186},[262,65615,1011],{"class":429},[262,65617,65618,65620,65622,65624,65626],{"class":181,"line":2197},[262,65619,64587],{"class":429},[262,65621,476],{"class":377},[262,65623,1326],{"class":429},[262,65625,102],{"class":271},[262,65627,3205],{"class":429},[262,65629,65630,65632,65634,65636,65638,65640,65642,65644],{"class":181,"line":2202},[262,65631,573],{"class":377},[262,65633,64602],{"class":429},[262,65635,2210],{"class":377},[262,65637,64602],{"class":429},[262,65639,835],{"class":377},[262,65641,64611],{"class":271},[262,65643,19241],{"class":377},[262,65645,64616],{"class":275},[262,65647,65648],{"class":181,"line":2207},[262,65649,583],{"emptyLinePlaceholder":582},[262,65651,65652],{"class":181,"line":2224},[262,65653,583],{"emptyLinePlaceholder":582},[262,65655,65656,65658,65660],{"class":181,"line":2236},[262,65657,423],{"class":377},[262,65659,23929],{"class":267},[262,65661,56828],{"class":429},[262,65663,65664,65666,65668,65670,65672],{"class":181,"line":2246},[262,65665,63681],{"class":429},[262,65667,476],{"class":377},[262,65669,63686],{"class":429},[262,65671,63622],{"class":271},[262,65673,660],{"class":429},[262,65675,65676,65678,65680,65682,65684],{"class":181,"line":2265},[262,65677,63695],{"class":429},[262,65679,63636],{"class":271},[262,65681,608],{"class":429},[262,65683,63650],{"class":271},[262,65685,660],{"class":429},[262,65687,65688,65690,65692],{"class":181,"line":2290},[262,65689,63708],{"class":429},[262,65691,61546],{"class":275},[262,65693,660],{"class":429},[262,65695,65696,65698,65700,65702,65704],{"class":181,"line":2296},[262,65697,3074],{"class":377},[262,65699,64651],{"class":429},[262,65701,835],{"class":377},[262,65703,64611],{"class":271},[262,65705,1160],{"class":429},[262,65707,65708,65710],{"class":181,"line":9230},[262,65709,64662],{"class":429},[262,65711,65712],{"class":291},"# safe even if it exists\n",[262,65714,65715],{"class":181,"line":9241},[262,65716,583],{"emptyLinePlaceholder":582},[262,65718,65719,65721,65723,65725,65727,65729,65731],{"class":181,"line":9247},[262,65720,63742],{"class":429},[262,65722,476],{"class":377},[262,65724,63747],{"class":429},[262,65726,8471],{"class":271},[262,65728,608],{"class":429},[262,65730,63754],{"class":275},[262,65732,660],{"class":429},[262,65734,65735,65737,65740,65742,65744,65746],{"class":181,"line":28672},[262,65736,3074],{"class":377},[262,65738,65739],{"class":429}," eid ",[262,65741,835],{"class":377},[262,65743,63763],{"class":429},[262,65745,102],{"class":271},[262,65747,65748],{"class":429},"].split():\n",[262,65750,65751,65754,65756,65758,65760],{"class":181,"line":28683},[262,65752,65753],{"class":429},"        _status, data ",[262,65755,476],{"class":377},[262,65757,64082],{"class":429},[262,65759,64085],{"class":275},[262,65761,660],{"class":429},[262,65763,65764,65766,65768,65771,65773,65775,65777],{"class":181,"line":28710},[262,65765,2249],{"class":429},[262,65767,476],{"class":377},[262,65769,65770],{"class":429}," email.message_from_bytes(data[",[262,65772,102],{"class":271},[262,65774,6163],{"class":429},[262,65776,997],{"class":271},[262,65778,3512],{"class":429},[262,65780,65781,65784,65786],{"class":181,"line":28715},[262,65782,65783],{"class":429},"        category ",[262,65785,476],{"class":377},[262,65787,65788],{"class":429}," classify(decode_subject(msg), body_snippet(msg))\n",[262,65790,65791],{"class":181,"line":28720},[262,65792,65793],{"class":429},"        mail.copy(eid, category)\n",[262,65795,65796,65799,65801,65803,65805,65807,65809],{"class":181,"line":28733},[262,65797,65798],{"class":429},"        mail.store(eid, ",[262,65800,64696],{"class":275},[262,65802,608],{"class":429},[262,65804,1176],{"class":275},[262,65806,64703],{"class":271},[262,65808,64706],{"class":275},[262,65810,660],{"class":429},[262,65812,65813,65815,65817,65819,65821,65823,65825],{"class":181,"line":28749},[262,65814,65798],{"class":429},[262,65816,64696],{"class":275},[262,65818,608],{"class":429},[262,65820,1176],{"class":275},[262,65822,64703],{"class":271},[262,65824,64723],{"class":275},[262,65826,660],{"class":429},[262,65828,65829,65831,65833,65835,65838,65840,65842,65844,65846,65848,65851,65853,65855,65857,65859],{"class":181,"line":28759},[262,65830,2299],{"class":271},[262,65832,602],{"class":429},[262,65834,642],{"class":377},[262,65836,65837],{"class":275},"\"Sorted into ",[262,65839,3039],{"class":271},[262,65841,61758],{"class":429},[262,65843,654],{"class":271},[262,65845,1231],{"class":275},[262,65847,3039],{"class":271},[262,65849,65850],{"class":429},"decode_subject(msg)[:",[262,65852,12826],{"class":271},[262,65854,6223],{"class":429},[262,65856,654],{"class":271},[262,65858,1176],{"class":275},[262,65860,660],{"class":429},[262,65862,65863],{"class":181,"line":39638},[262,65864,583],{"emptyLinePlaceholder":582},[262,65866,65868],{"class":181,"line":65867},79,[262,65869,65870],{"class":429},"    mail.expunge()\n",[262,65872,65874],{"class":181,"line":65873},80,[262,65875,65876],{"class":429},"    mail.logout()\n",[262,65878,65880],{"class":181,"line":65879},81,[262,65881,583],{"emptyLinePlaceholder":582},[262,65883,65885],{"class":181,"line":65884},82,[262,65886,583],{"emptyLinePlaceholder":582},[262,65888,65890,65892,65894,65896,65898],{"class":181,"line":65889},83,[262,65891,2210],{"class":377},[262,65893,2213],{"class":271},[262,65895,2216],{"class":377},[262,65897,2219],{"class":275},[262,65899,1160],{"class":429},[262,65901,65903],{"class":181,"line":65902},84,[262,65904,24060],{"class":429},[57,65906,2317],{"id":2316},[2322,65908,65909,65915,65921],{},[1450,65910,65911,65914],{},[35,65912,65913],{},"Use this LLM script when"," your mail is varied and hard to capture with fixed words — freelance client threads, receipts from dozens of vendors, mixed-language newsletters. The model's reading of intent is what earns its keep.",[1450,65916,65917,65920],{},[35,65918,65919],{},"Stick with pure keyword filters when"," your categories are simple and predictable (one billing address, a handful of known senders). The built-in rules in Gmail or Outlook are free, instant, and need no script at all.",[1450,65922,65923,65926],{},[35,65924,65925],{},"Reach for a managed tool when"," you want a no-code interface and do not mind a subscription — services like Zapier or your provider's native rules can route mail without Python, though they cost more and read meaning less well than an LLM does.",[14,65928,2375,65929,1363],{},[51,65930,21230],{"href":21229},[57,65932,2381],{"id":2380},[2322,65934,65935,65940,65945,65950],{},[1450,65936,65937,65939],{},[51,65938,21230],{"href":21229}," — the main guide this script belongs to.",[1450,65941,65942,65944],{},[51,65943,2487],{"href":2486}," — keys, models, and pricing for the classification step.",[1450,65946,65947,65949],{},[51,65948,61611],{"href":61610}," — prepare messy text before you feed it to a model.",[1450,65951,65952,65954],{},[51,65953,1362],{"href":1361}," — keep the model returning exactly one clean category.",[2401,65956,19746],{},{"title":258,"searchDepth":282,"depth":282,"links":65958},[65959,65960,65961,65962,65963,65964,65965,65966,65967,65968,65969],{"id":237,"depth":282,"text":238},{"id":63557,"depth":282,"text":63558},{"id":63778,"depth":282,"text":63779},{"id":64115,"depth":282,"text":64116},{"id":64622,"depth":282,"text":64623},{"id":64741,"depth":282,"text":64742},{"id":44331,"depth":282,"text":44332},{"id":1444,"depth":282,"text":1445},{"id":38704,"depth":282,"text":38705},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Copy-paste Python script to auto-sort your inbox: read mail over IMAP, classify each message with an LLM, then move or label it by category. Run it or schedule it.",[65972,65975,65978,65981,65984],{"q":65973,"a":65974},"Do I need a paid email account to automate sorting with Python?","No. Any provider that supports IMAP works, including free Gmail, Outlook, and Fastmail accounts. You enable IMAP in your settings and connect with an app password. The Python standard library handles the connection at no cost.",{"q":65976,"a":65977},"Will this script delete or send any emails?","No. The script only reads messages, copies them into folders or applies labels, and marks them as read. It never deletes mail or sends replies, so a misclassification just puts an email in the wrong folder, where you can still find it.",{"q":65979,"a":65980},"How much does the LLM classification cost per email?","Classifying a subject line and a short snippet costs a tiny fraction of a cent on a small model like gpt-4o-mini. Sorting a few hundred emails a day typically stays under a few cents because each request sends only a short prompt and expects a one-word answer.",{"q":65982,"a":65983},"Why use an LLM instead of plain keyword rules?","Keyword rules miss anything phrased differently, such as a bill that never says 'invoice'. An LLM reads the meaning of the message, so it routes a 'your statement is ready' email to Invoices even when no keyword matches.",{"q":65985,"a":65986},"Is it safe to put my email password in the script?","Never hard-code your real password. Use a dedicated app password stored in a .env file that is listed in .gitignore, so the secret stays out of your code and out of version control.",{"name":65988,"steps":65989},"How to automate email sorting with Python and an LLM",[65990,65993,65996,65999,66002],{"name":65991,"text":65992},"Enable IMAP and store credentials","Turn on IMAP access, create an app password, and save your email and API keys in a .env file.",{"name":65994,"text":65995},"Connect to your inbox over IMAP","Use Python's imaplib to log in securely and fetch the unread messages you want to sort.",{"name":65997,"text":65998},"Classify each email with an LLM","Send the subject and a short snippet to the model and ask it to return one category name.",{"name":66000,"text":66001},"Move or label by category","Copy each message into the matching folder, mark it read, and clean up the original.",{"name":66003,"text":66004},"Schedule the script to run on its own","Add the script to cron or Task Scheduler so your inbox stays sorted without manual runs.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks\u002Fpython-script-to-automate-email-sorting","2026-05-09",{"title":61248,"description":65970},"python-ai-fundamentals-for-non-developers\u002Fautomating-repetitive-tasks\u002Fpython-script-to-automate-email-sorting\u002Findex","IkklNFDHgX7vF0Yx2J9DIFpocOj9QOUhD0_B1awfYc8",{"id":66012,"title":66013,"body":66014,"description":67130,"extension":2419,"faq":67131,"howto":67147,"meta":67165,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":67166,"published":67167,"seo":67168,"seoTitle":67169,"stem":67170,"__hash__":67171},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai\u002Fcleaning-csv-data-with-pandas-for-ai\u002Findex.md","Cleaning CSV Data with Pandas for AI: A Step-by-Step Script",{"type":7,"value":66015,"toc":67118},[66016,66019,66022,66025,66037,66039,66045,66048,66061,66079,66128,66138,66142,66145,66223,66232,66238,66242,66245,66374,66385,66389,66392,66545,66555,66568,66572,66575,66718,66742,66745,66749,66752,66890,66902,66905,67007,67009,67063,67065,67087,67091,67093,67115],[10,66017,66013],{"id":66018},"cleaning-csv-data-with-pandas-for-ai-a-step-by-step-script",[14,66020,66021],{},"This guide shows you how to turn a messy CSV file into a clean, model-ready dataset with Pandas in under fifteen minutes. You will load a raw file, fix missing values, drop duplicates, set the right column types, normalize text, and export a file you can hand straight to an AI step. Replace the example column names with your own headers, run the script, and you get a predictable dataset every time.",[14,66023,66024],{},"Pandas is the standard Python library for working with table-shaped data (rows and columns, like a spreadsheet). A DataFrame is the in-memory table Pandas builds from your file. If those two terms are new, that is fine, the steps below explain each line as you go.",[14,66026,66027,66028,1374,66031,66034,66035,1363],{},"Raw exports cause more AI problems than most people expect. Inconsistent casing turns ",[18,66029,66030],{},"Acme",[18,66032,66033],{},"acme"," into two different things. Trailing spaces hide in text fields. Empty cells trigger API validation errors. A clean file fixes all of that before the data ever reaches a model, which is why data hygiene is a foundation skill covered across ",[51,66036,61611],{"href":61610},[57,66038,238],{"id":237},[14,66040,66041,66042,66044],{},"This guide assumes you already have Python 3.10 or newer and a working virtual environment. If you do not, set one up first with ",[51,66043,2482],{"href":2481},", then return here.",[14,66046,66047],{},"Install the one dependency you need:",[253,66049,66051],{"className":255,"code":66050,"language":257,"meta":258,"style":258},"pip install pandas\n",[18,66052,66053],{"__ignoreMap":258},[262,66054,66055,66057,66059],{"class":181,"line":264},[262,66056,298],{"class":267},[262,66058,301],{"class":275},[262,66060,37218],{"class":275},[14,66062,66063,66064,66067,66068,608,66070,608,66073,13390,66075,66078],{},"You also need a CSV file to clean. The examples below use a file named ",[18,66065,66066],{},"input.csv"," with columns called ",[18,66069,52940],{},[18,66071,66072],{},"prompt_text",[18,66074,61758],{},[18,66076,66077],{},"price",". Swap these for your real headers as you follow along. To see your actual column names, run a quick check first:",[253,66080,66082],{"className":414,"code":66081,"language":416,"meta":258,"style":258},"import pandas as pd\n\ndf = pd.read_csv(\"input.csv\")\nprint(df.columns.tolist())\nprint(df.shape)  # (rows, columns)\n",[18,66083,66084,66094,66098,66111,66118],{"__ignoreMap":258},[262,66085,66086,66088,66090,66092],{"class":181,"line":264},[262,66087,684],{"class":377},[262,66089,2619],{"class":429},[262,66091,697],{"class":377},[262,66093,2624],{"class":429},[262,66095,66096],{"class":181,"line":282},[262,66097,583],{"emptyLinePlaceholder":582},[262,66099,66100,66102,66104,66106,66109],{"class":181,"line":295},[262,66101,2755],{"class":429},[262,66103,476],{"class":377},[262,66105,2760],{"class":429},[262,66107,66108],{"class":275},"\"input.csv\"",[262,66110,660],{"class":429},[262,66112,66113,66115],{"class":181,"line":345},[262,66114,637],{"class":271},[262,66116,66117],{"class":429},"(df.columns.tolist())\n",[262,66119,66120,66122,66125],{"class":181,"line":492},[262,66121,637],{"class":271},[262,66123,66124],{"class":429},"(df.shape)  ",[262,66126,66127],{"class":291},"# (rows, columns)\n",[14,66129,66130,66133,66134,66137],{},[18,66131,66132],{},"df.shape"," prints a pair like ",[18,66135,66136],{},"(1200, 4)",", meaning 1,200 rows and 4 columns. Note that starting number, you will compare it against the cleaned count at the end to confirm nothing went missing by accident.",[57,66139,66141],{"id":66140},"step-1-load-the-csv-and-inspect-it","Step 1: Load the CSV and inspect it",[14,66143,66144],{},"Always look at the data before you change it. Loading and inspecting takes seconds and saves you from cleaning the wrong column.",[253,66146,66148],{"className":414,"code":66147,"language":416,"meta":258,"style":258},"import pandas as pd\n\n# Load the raw file. utf-8-sig safely strips the hidden BOM\n# marker that Excel adds to the start of exported files.\ndf = pd.read_csv(\"input.csv\", encoding=\"utf-8-sig\")\n\n# Inspect structure before changing anything.\nprint(df.info())   # column names, non-null counts, and types\nprint(df.head())   # the first five rows\n",[18,66149,66150,66160,66164,66169,66174,66194,66198,66203,66213],{"__ignoreMap":258},[262,66151,66152,66154,66156,66158],{"class":181,"line":264},[262,66153,684],{"class":377},[262,66155,2619],{"class":429},[262,66157,697],{"class":377},[262,66159,2624],{"class":429},[262,66161,66162],{"class":181,"line":282},[262,66163,583],{"emptyLinePlaceholder":582},[262,66165,66166],{"class":181,"line":295},[262,66167,66168],{"class":291},"# Load the raw file. utf-8-sig safely strips the hidden BOM\n",[262,66170,66171],{"class":181,"line":345},[262,66172,66173],{"class":291},"# marker that Excel adds to the start of exported files.\n",[262,66175,66176,66178,66180,66182,66184,66186,66188,66190,66192],{"class":181,"line":492},[262,66177,2755],{"class":429},[262,66179,476],{"class":377},[262,66181,2760],{"class":429},[262,66183,66108],{"class":275},[262,66185,608],{"class":429},[262,66187,612],{"class":611},[262,66189,476],{"class":377},[262,66191,27593],{"class":275},[262,66193,660],{"class":429},[262,66195,66196],{"class":181,"line":503},[262,66197,583],{"emptyLinePlaceholder":582},[262,66199,66200],{"class":181,"line":521},[262,66201,66202],{"class":291},"# Inspect structure before changing anything.\n",[262,66204,66205,66207,66210],{"class":181,"line":537},[262,66206,637],{"class":271},[262,66208,66209],{"class":429},"(df.info())   ",[262,66211,66212],{"class":291},"# column names, non-null counts, and types\n",[262,66214,66215,66217,66220],{"class":181,"line":549},[262,66216,637],{"class":271},[262,66218,66219],{"class":429},"(df.head())   ",[262,66221,66222],{"class":291},"# the first five rows\n",[14,66224,66225,66228,66229,66231],{},[18,66226,66227],{},"df.info()"," is the most useful single command here. It lists every column, how many non-null (non-empty) values it holds, and the type Pandas guessed for it. If a column you expect to be numbers shows up as ",[18,66230,36804],{}," (Pandas's label for text), that is an early warning that the column contains stray text you will fix in Step 3.",[14,66233,66234,66235,66237],{},"A BOM (byte order mark) is an invisible character some programs write at the start of a file. Reading with ",[18,66236,27722],{}," removes it so it does not contaminate your first column header.",[57,66239,66241],{"id":66240},"step-2-fix-missing-values","Step 2: Fix missing values",[14,66243,66244],{},"Missing values are the most common cause of AI API errors, because most endpoints reject empty or null text. Handle required fields and optional fields differently.",[253,66246,66248],{"className":414,"code":66247,"language":416,"meta":258,"style":258},"# Required fields: drop any row missing the text you will send\n# to the model or the label you group by. A row with no prompt\n# text is unusable, so remove it.\ndf = df.dropna(subset=[\"prompt_text\", \"category\"])\n\n# Optional fields: keep the row but fill the gap with a clear\n# placeholder so the value is explicit, not silently empty.\ndf[\"company\"] = df[\"company\"].fillna(\"unknown\")\n\n# Confirm there are no remaining gaps in the required columns.\nremaining_gaps = df[[\"prompt_text\", \"category\"]].isnull().sum().sum()\nprint(f\"Remaining gaps in required columns: {remaining_gaps}\")\n",[18,66249,66250,66255,66260,66265,66289,66293,66298,66303,66324,66328,66333,66352],{"__ignoreMap":258},[262,66251,66252],{"class":181,"line":264},[262,66253,66254],{"class":291},"# Required fields: drop any row missing the text you will send\n",[262,66256,66257],{"class":181,"line":282},[262,66258,66259],{"class":291},"# to the model or the label you group by. A row with no prompt\n",[262,66261,66262],{"class":181,"line":295},[262,66263,66264],{"class":291},"# text is unusable, so remove it.\n",[262,66266,66267,66269,66271,66274,66276,66278,66280,66283,66285,66287],{"class":181,"line":345},[262,66268,2755],{"class":429},[262,66270,476],{"class":377},[262,66272,66273],{"class":429}," df.dropna(",[262,66275,27491],{"class":611},[262,66277,476],{"class":377},[262,66279,12118],{"class":429},[262,66281,66282],{"class":275},"\"prompt_text\"",[262,66284,608],{"class":429},[262,66286,62009],{"class":275},[262,66288,3512],{"class":429},[262,66290,66291],{"class":181,"line":492},[262,66292,583],{"emptyLinePlaceholder":582},[262,66294,66295],{"class":181,"line":503},[262,66296,66297],{"class":291},"# Optional fields: keep the row but fill the gap with a clear\n",[262,66299,66300],{"class":181,"line":521},[262,66301,66302],{"class":291},"# placeholder so the value is explicit, not silently empty.\n",[262,66304,66305,66307,66309,66311,66313,66315,66317,66320,66322],{"class":181,"line":537},[262,66306,29113],{"class":429},[262,66308,37940],{"class":275},[262,66310,2903],{"class":429},[262,66312,476],{"class":377},[262,66314,27464],{"class":429},[262,66316,37940],{"class":275},[262,66318,66319],{"class":429},"].fillna(",[262,66321,35361],{"class":275},[262,66323,660],{"class":429},[262,66325,66326],{"class":181,"line":549},[262,66327,583],{"emptyLinePlaceholder":582},[262,66329,66330],{"class":181,"line":570},[262,66331,66332],{"class":291},"# Confirm there are no remaining gaps in the required columns.\n",[262,66334,66335,66338,66340,66343,66345,66347,66349],{"class":181,"line":579},[262,66336,66337],{"class":429},"remaining_gaps ",[262,66339,476],{"class":377},[262,66341,66342],{"class":429}," df[[",[262,66344,66282],{"class":275},[262,66346,608],{"class":429},[262,66348,62009],{"class":275},[262,66350,66351],{"class":429},"]].isnull().sum().sum()\n",[262,66353,66354,66356,66358,66360,66363,66365,66368,66370,66372],{"class":181,"line":586},[262,66355,637],{"class":271},[262,66357,602],{"class":429},[262,66359,642],{"class":377},[262,66361,66362],{"class":275},"\"Remaining gaps in required columns: ",[262,66364,3039],{"class":271},[262,66366,66367],{"class":429},"remaining_gaps",[262,66369,654],{"class":271},[262,66371,1176],{"class":275},[262,66373,660],{"class":429},[14,66375,66376,66377,66380,66381,66384],{},"The difference matters. ",[18,66378,66379],{},"dropna"," deletes rows, so you only want it where a missing value makes the row worthless. ",[18,66382,66383],{},"fillna"," keeps the row and replaces the gap, which preserves data you can still use. Print the gap count to confirm the required columns are now fully populated.",[57,66386,66388],{"id":66387},"step-3-remove-duplicates-and-fix-types","Step 3: Remove duplicates and fix types",[14,66390,66391],{},"Duplicate rows inflate costs (you pay to process the same text twice) and skew any counts or analysis. Wrong types cause crashes the moment you call a text method on a number.",[253,66393,66395],{"className":414,"code":66394,"language":416,"meta":258,"style":258},"# Drop rows that are exact duplicates across every column.\nbefore = len(df)\ndf = df.drop_duplicates()\nprint(f\"Removed {before - len(df)} duplicate rows\")\n\n# Cast each column to the type your AI step expects.\n# Text columns must be strings before any .str operation.\ndf[\"prompt_text\"] = df[\"prompt_text\"].astype(str)\ndf[\"category\"] = df[\"category\"].astype(str)\n\n# Numeric columns: convert text to numbers, turning any\n# unparseable value (like \"N\u002FA\") into a true missing value.\ndf[\"price\"] = pd.to_numeric(df[\"price\"], errors=\"coerce\")\n",[18,66396,66397,66402,66414,66423,66451,66455,66460,66465,66485,66505,66509,66514,66519],{"__ignoreMap":258},[262,66398,66399],{"class":181,"line":264},[262,66400,66401],{"class":291},"# Drop rows that are exact duplicates across every column.\n",[262,66403,66404,66407,66409,66411],{"class":181,"line":282},[262,66405,66406],{"class":429},"before ",[262,66408,476],{"class":377},[262,66410,515],{"class":271},[262,66412,66413],{"class":429},"(df)\n",[262,66415,66416,66418,66420],{"class":181,"line":295},[262,66417,2755],{"class":429},[262,66419,476],{"class":377},[262,66421,66422],{"class":429}," df.drop_duplicates()\n",[262,66424,66425,66427,66429,66431,66434,66436,66438,66440,66442,66444,66446,66449],{"class":181,"line":345},[262,66426,637],{"class":271},[262,66428,602],{"class":429},[262,66430,642],{"class":377},[262,66432,66433],{"class":275},"\"Removed ",[262,66435,3039],{"class":271},[262,66437,66406],{"class":429},[262,66439,561],{"class":377},[262,66441,515],{"class":271},[262,66443,2780],{"class":429},[262,66445,654],{"class":271},[262,66447,66448],{"class":275}," duplicate rows\"",[262,66450,660],{"class":429},[262,66452,66453],{"class":181,"line":492},[262,66454,583],{"emptyLinePlaceholder":582},[262,66456,66457],{"class":181,"line":503},[262,66458,66459],{"class":291},"# Cast each column to the type your AI step expects.\n",[262,66461,66462],{"class":181,"line":521},[262,66463,66464],{"class":291},"# Text columns must be strings before any .str operation.\n",[262,66466,66467,66469,66471,66473,66475,66477,66479,66481,66483],{"class":181,"line":537},[262,66468,29113],{"class":429},[262,66470,66282],{"class":275},[262,66472,2903],{"class":429},[262,66474,476],{"class":377},[262,66476,27464],{"class":429},[262,66478,66282],{"class":275},[262,66480,29126],{"class":429},[262,66482,433],{"class":271},[262,66484,660],{"class":429},[262,66486,66487,66489,66491,66493,66495,66497,66499,66501,66503],{"class":181,"line":549},[262,66488,29113],{"class":429},[262,66490,62009],{"class":275},[262,66492,2903],{"class":429},[262,66494,476],{"class":377},[262,66496,27464],{"class":429},[262,66498,62009],{"class":275},[262,66500,29126],{"class":429},[262,66502,433],{"class":271},[262,66504,660],{"class":429},[262,66506,66507],{"class":181,"line":570},[262,66508,583],{"emptyLinePlaceholder":582},[262,66510,66511],{"class":181,"line":579},[262,66512,66513],{"class":291},"# Numeric columns: convert text to numbers, turning any\n",[262,66515,66516],{"class":181,"line":586},[262,66517,66518],{"class":291},"# unparseable value (like \"N\u002FA\") into a true missing value.\n",[262,66520,66521,66523,66525,66527,66529,66532,66534,66536,66538,66540,66543],{"class":181,"line":591},[262,66522,29113],{"class":429},[262,66524,54790],{"class":275},[262,66526,2903],{"class":429},[262,66528,476],{"class":377},[262,66530,66531],{"class":429}," pd.to_numeric(df[",[262,66533,54790],{"class":275},[262,66535,1103],{"class":429},[262,66537,61479],{"class":611},[262,66539,476],{"class":377},[262,66541,66542],{"class":275},"\"coerce\"",[262,66544,660],{"class":429},[14,66546,66547,66550,66551,66554],{},[18,66548,66549],{},"drop_duplicates()"," with no arguments removes rows that match across all columns. If two rows should count as duplicates based on one key column only, pass ",[18,66552,66553],{},"subset=[\"prompt_text\"]"," to compare just that field.",[14,66556,66557,66560,66561,66564,66565,66567],{},[18,66558,66559],{},"pd.to_numeric(..., errors=\"coerce\")"," is the safe way to convert text to numbers. The ",[18,66562,66563],{},"errors=\"coerce\""," setting turns anything it cannot parse into ",[18,66566,26874],{}," (Pandas's missing-value marker) instead of crashing. After this line you can fill or drop those new gaps the same way you did in Step 2.",[57,66569,66571],{"id":66570},"step-4-normalize-text-fields","Step 4: Normalize text fields",[14,66573,66574],{},"Normalizing means forcing text into one consistent shape so identical values actually match. This is the step that most improves prompt accuracy and embedding quality.",[253,66576,66578],{"className":414,"code":66577,"language":416,"meta":258,"style":258},"# Build the cleaning into one readable chain per column.\ndf[\"prompt_text\"] = (\n    df[\"prompt_text\"]\n    .str.strip()                       # remove leading\u002Ftrailing spaces\n    .str.replace(r\"\\s+\", \" \", regex=True)  # collapse runs of whitespace\n    .str.replace(r\"[\\r\\n]+\", \" \", regex=True)  # flatten line breaks\n)\n\n# Lowercase category labels so \"Sales\", \"sales\", and \"SALES\"\n# all become one value you can group and filter on.\ndf[\"category\"] = df[\"category\"].str.strip().str.lower()\n",[18,66579,66580,66585,66597,66605,66613,66646,66683,66687,66691,66696,66701],{"__ignoreMap":258},[262,66581,66582],{"class":181,"line":264},[262,66583,66584],{"class":291},"# Build the cleaning into one readable chain per column.\n",[262,66586,66587,66589,66591,66593,66595],{"class":181,"line":282},[262,66588,29113],{"class":429},[262,66590,66282],{"class":275},[262,66592,2903],{"class":429},[262,66594,476],{"class":377},[262,66596,984],{"class":429},[262,66598,66599,66601,66603],{"class":181,"line":295},[262,66600,2897],{"class":429},[262,66602,66282],{"class":275},[262,66604,957],{"class":429},[262,66606,66607,66610],{"class":181,"line":345},[262,66608,66609],{"class":429},"    .str.strip()                       ",[262,66611,66612],{"class":291},"# remove leading\u002Ftrailing spaces\n",[262,66614,66615,66618,66620,66622,66625,66627,66629,66631,66633,66635,66637,66639,66641,66643],{"class":181,"line":492},[262,66616,66617],{"class":429},"    .str.replace(",[262,66619,7973],{"class":377},[262,66621,1176],{"class":275},[262,66623,66624],{"class":271},"\\s",[262,66626,531],{"class":377},[262,66628,1176],{"class":275},[262,66630,608],{"class":429},[262,66632,543],{"class":275},[262,66634,608],{"class":429},[262,66636,38035],{"class":611},[262,66638,476],{"class":377},[262,66640,4974],{"class":271},[262,66642,32223],{"class":429},[262,66644,66645],{"class":291},"# collapse runs of whitespace\n",[262,66647,66648,66650,66652,66654,66656,66660,66662,66664,66666,66668,66670,66672,66674,66676,66678,66680],{"class":181,"line":503},[262,66649,66617],{"class":429},[262,66651,7973],{"class":377},[262,66653,1176],{"class":275},[262,66655,12118],{"class":271},[262,66657,66659],{"class":66658},"snhLl","\\r\\n",[262,66661,6223],{"class":271},[262,66663,531],{"class":377},[262,66665,1176],{"class":275},[262,66667,608],{"class":429},[262,66669,543],{"class":275},[262,66671,608],{"class":429},[262,66673,38035],{"class":611},[262,66675,476],{"class":377},[262,66677,4974],{"class":271},[262,66679,32223],{"class":429},[262,66681,66682],{"class":291},"# flatten line breaks\n",[262,66684,66685],{"class":181,"line":521},[262,66686,660],{"class":429},[262,66688,66689],{"class":181,"line":537},[262,66690,583],{"emptyLinePlaceholder":582},[262,66692,66693],{"class":181,"line":549},[262,66694,66695],{"class":291},"# Lowercase category labels so \"Sales\", \"sales\", and \"SALES\"\n",[262,66697,66698],{"class":181,"line":570},[262,66699,66700],{"class":291},"# all become one value you can group and filter on.\n",[262,66702,66703,66705,66707,66709,66711,66713,66715],{"class":181,"line":579},[262,66704,29113],{"class":429},[262,66706,62009],{"class":275},[262,66708,2903],{"class":429},[262,66710,476],{"class":377},[262,66712,27464],{"class":429},[262,66714,62009],{"class":275},[262,66716,66717],{"class":429},"].str.strip().str.lower()\n",[14,66719,66720,66721,66724,66725,66728,66729,1374,66732,66734,66735,1374,66738,66741],{},"Each method in the chain does one job. ",[18,66722,66723],{},".str.strip()"," trims the outer spaces. ",[18,66726,66727],{},".str.replace(r\"\\s+\", \" \", regex=True)"," collapses double and triple spaces into single ones. The line-break replacement flattens hidden ",[18,66730,66731],{},"\\r",[18,66733,2137],{}," characters that break CSV and JSON payloads. Lowercasing labels is what lets later grouping treat ",[18,66736,66737],{},"Sales",[18,66739,66740],{},"sales"," as the same category.",[14,66743,66744],{},"Be deliberate about which columns you lowercase. Lowercasing a category label is helpful; lowercasing a sentence you plan to show a user later may not be. Apply it only where consistent matching matters more than the original casing.",[57,66746,66748],{"id":66747},"step-5-validate-and-export-a-clean-csv","Step 5: Validate and export a clean CSV",[14,66750,66751],{},"Before you trust the file, run three quick checks, then write it to a new name so your raw data stays intact.",[253,66753,66755],{"className":414,"code":66754,"language":416,"meta":258,"style":258},"# 1. No gaps left in required columns.\nassert df[[\"prompt_text\", \"category\"]].isnull().sum().sum() == 0\n\n# 2. Row count dropped only as much as expected.\nprint(f\"Final rows: {len(df)}\")\n\n# 3. Spot-check a few cleaned rows by eye.\nprint(df.sample(min(5, len(df))))\n\n# Export to a NEW file in plain UTF-8. index=False stops Pandas\n# from writing an extra unnamed row-number column.\ndf.to_csv(\"clean_output.csv\", index=False, encoding=\"utf-8\")\nprint(\"Saved clean_output.csv, ready for your AI step.\")\n",[18,66756,66757,66762,66782,66786,66791,66812,66816,66821,66840,66844,66849,66854,66879],{"__ignoreMap":258},[262,66758,66759],{"class":181,"line":264},[262,66760,66761],{"class":291},"# 1. No gaps left in required columns.\n",[262,66763,66764,66767,66769,66771,66773,66775,66778,66780],{"class":181,"line":282},[262,66765,66766],{"class":377},"assert",[262,66768,66342],{"class":429},[262,66770,66282],{"class":275},[262,66772,608],{"class":429},[262,66774,62009],{"class":275},[262,66776,66777],{"class":429},"]].isnull().sum().sum() ",[262,66779,10758],{"class":377},[262,66781,500],{"class":271},[262,66783,66784],{"class":181,"line":295},[262,66785,583],{"emptyLinePlaceholder":582},[262,66787,66788],{"class":181,"line":345},[262,66789,66790],{"class":291},"# 2. Row count dropped only as much as expected.\n",[262,66792,66793,66795,66797,66799,66802,66804,66806,66808,66810],{"class":181,"line":492},[262,66794,637],{"class":271},[262,66796,602],{"class":429},[262,66798,642],{"class":377},[262,66800,66801],{"class":275},"\"Final rows: ",[262,66803,648],{"class":271},[262,66805,2780],{"class":429},[262,66807,654],{"class":271},[262,66809,1176],{"class":275},[262,66811,660],{"class":429},[262,66813,66814],{"class":181,"line":503},[262,66815,583],{"emptyLinePlaceholder":582},[262,66817,66818],{"class":181,"line":521},[262,66819,66820],{"class":291},"# 3. Spot-check a few cleaned rows by eye.\n",[262,66822,66823,66825,66828,66830,66832,66834,66836,66838],{"class":181,"line":537},[262,66824,637],{"class":271},[262,66826,66827],{"class":429},"(df.sample(",[262,66829,53390],{"class":271},[262,66831,602],{"class":429},[262,66833,222],{"class":271},[262,66835,608],{"class":429},[262,66837,29318],{"class":271},[262,66839,29552],{"class":429},[262,66841,66842],{"class":181,"line":549},[262,66843,583],{"emptyLinePlaceholder":582},[262,66845,66846],{"class":181,"line":570},[262,66847,66848],{"class":291},"# Export to a NEW file in plain UTF-8. index=False stops Pandas\n",[262,66850,66851],{"class":181,"line":579},[262,66852,66853],{"class":291},"# from writing an extra unnamed row-number column.\n",[262,66855,66856,66858,66861,66863,66865,66867,66869,66871,66873,66875,66877],{"class":181,"line":586},[262,66857,3730],{"class":429},[262,66859,66860],{"class":275},"\"clean_output.csv\"",[262,66862,608],{"class":429},[262,66864,3618],{"class":611},[262,66866,476],{"class":377},[262,66868,3623],{"class":271},[262,66870,608],{"class":429},[262,66872,612],{"class":611},[262,66874,476],{"class":377},[262,66876,617],{"class":275},[262,66878,660],{"class":429},[262,66880,66881,66883,66885,66888],{"class":181,"line":591},[262,66882,637],{"class":271},[262,66884,602],{"class":429},[262,66886,66887],{"class":275},"\"Saved clean_output.csv, ready for your AI step.\"",[262,66889,660],{"class":429},[14,66891,66892,66893,66896,66897,66899,66900,1363],{},"Writing to ",[18,66894,66895],{},"clean_output.csv"," instead of overwriting ",[18,66898,66066],{}," means you can re-run with different settings if you spot a problem. The cleaned file is now ready to feed into an AI workflow, whether you are sending each row to a model or building embeddings from it. For where that data goes next, see ",[51,66901,2487],{"href":2486},[57,66903,66904],{"id":5154},"Key-parameter quick reference",[1379,66906,66907,66920],{},[1382,66908,66909],{},[1385,66910,66911,66913,66916,66918],{},[1388,66912,1390],{},[1388,66914,66915],{},"Method",[1388,66917,3798],{},[1388,66919,1396],{},[1398,66921,66922,66946,66964,66988],{},[1385,66923,66924,66928,66935,66938],{},[1403,66925,66926],{},[18,66927,612],{},[1403,66929,66930,31800,66932],{},[18,66931,31901],{},[18,66933,66934],{},"to_csv",[1403,66936,66937],{},"platform default",[1403,66939,52065,66940,66942,66943,66945],{},[18,66941,27593],{}," to strip Excel's BOM on read; ",[18,66944,617],{}," on write keeps accents and emoji intact.",[1385,66947,66948,66952,66958,66961],{},[1403,66949,66950],{},[18,66951,27491],{},[1403,66953,66954,31800,66956],{},[18,66955,66379],{},[18,66957,30855],{},[1403,66959,66960],{},"all columns",[1403,66962,66963],{},"Limits the check to the listed columns, so you only act on the fields that matter.",[1385,66965,66966,66970,66975,66980],{},[1403,66967,66968],{},[18,66969,61479],{},[1403,66971,66972],{},[18,66973,66974],{},"pd.to_numeric",[1403,66976,66977],{},[18,66978,66979],{},"\"raise\"",[1403,66981,52065,66982,66984,66985,66987],{},[18,66983,66542],{}," to turn unparseable values into ",[18,66986,26874],{}," instead of crashing the script.",[1385,66989,66990,66994,66998,67002],{},[1403,66991,66992],{},[18,66993,3618],{},[1403,66995,66996],{},[18,66997,66934],{},[1403,66999,67000],{},[18,67001,4974],{},[1403,67003,52065,67004,67006],{},[18,67005,3623],{}," to avoid writing an extra unnamed row-number column to your output file.",[57,67008,1445],{"id":1444},[1447,67010,67011,67022,67038,67053],{},[1450,67012,67013,67018,67019,1363],{},[35,67014,67015],{},[18,67016,67017],{},"UnicodeDecodeError: 'utf-8' codec can't decode byte"," — Cause: the file is not UTF-8, often it is Latin-1 from an older system. Fix: pass the matching encoding, for example ",[18,67020,67021],{},"pd.read_csv(\"input.csv\", encoding=\"latin-1\")",[1450,67023,67024,67029,67030,67033,67034,67037],{},[35,67025,67026],{},[18,67027,67028],{},"AttributeError: Can only use .str accessor with string values"," — Cause: you called a ",[18,67031,67032],{},".str"," method on a column that holds numbers or mixed types. Fix: cast it first with ",[18,67035,67036],{},"df[\"col\"] = df[\"col\"].astype(str)"," before the normalization chain.",[1450,67039,67040,67045,67046,67049,67050,1363],{},[35,67041,67042],{},[18,67043,67044],{},"KeyError: 'prompt_text'"," — Cause: the column name in your code does not match the file, often due to a trailing space or different casing in the header. Fix: run ",[18,67047,67048],{},"print(df.columns.tolist())"," and copy the exact name, or normalize headers with ",[18,67051,67052],{},"df.columns = df.columns.str.strip()",[1450,67054,67055,67058,67059,67062],{},[35,67056,67057],{},"Cleaned file shows garbled symbols in Excel"," — Cause: Excel expects a BOM to read UTF-8 correctly. Fix: write with ",[18,67060,67061],{},"df.to_csv(\"clean_output.csv\", index=False, encoding=\"utf-8-sig\")"," so Excel renders accents and emoji properly.",[57,67064,2317],{"id":2316},[2322,67066,67067,67073,67079],{},[1450,67068,67069,67072],{},[35,67070,67071],{},"Use this Pandas script when"," your data fits in memory (up to a few hundred thousand rows on a normal laptop) and you want full control over each cleaning rule. This is the right default for almost every creator, marketer, or founder project.",[1450,67074,67075,67078],{},[35,67076,67077],{},"Reach for a database query instead when"," the file is too large to load at once or already lives in a SQL system. Filtering and deduplicating in SQL before exporting a smaller, cleaner CSV avoids loading the whole thing into Python.",[1450,67080,67081,67084,67085,1363],{},[35,67082,67083],{},"Skip dedicated cleaning code when"," the work is genuinely one-off and tiny, say a dozen rows you can fix by hand in a spreadsheet. A script pays off when you will repeat the job or need the result to be exactly reproducible. If this cleaning is part of a recurring pipeline, wrap it in a scheduled run as shown in ",[51,67086,21230],{"href":21229},[14,67088,2375,67089,1363],{},[51,67090,61611],{"href":61610},[57,67092,2381],{"id":2380},[2322,67094,67095,67100,67105,67110],{},[1450,67096,67097,67099],{},[51,67098,61611],{"href":61610}," — the main guide for preparing data for AI, with the full set of preprocessing techniques.",[1450,67101,67102,67104],{},[51,67103,21230],{"href":21229}," — turn this one-off clean-up into a script that runs on a schedule.",[1450,67106,67107,67109],{},[51,67108,2487],{"href":2486}," — where your cleaned data goes next when you send it to a model.",[1450,67111,67112,67114],{},[51,67113,2482],{"href":2481}," — set up the isolated Python environment these scripts assume.",[2401,67116,67117],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .snhLl, html code.shiki .snhLl{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}",{"title":258,"searchDepth":282,"depth":282,"links":67119},[67120,67121,67122,67123,67124,67125,67126,67127,67128,67129],{"id":237,"depth":282,"text":238},{"id":66140,"depth":282,"text":66141},{"id":66240,"depth":282,"text":66241},{"id":66387,"depth":282,"text":66388},{"id":66570,"depth":282,"text":66571},{"id":66747,"depth":282,"text":66748},{"id":5154,"depth":282,"text":66904},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Copy-paste Pandas script to clean CSV files for AI. Fix missing values, drop duplicates, set types, normalize text, and export a model-ready CSV.",[67132,67135,67138,67141,67144],{"q":67133,"a":67134},"Why does messy CSV data break AI prompts and embeddings?","Inconsistent casing, stray whitespace, and missing values make a model treat identical concepts as different inputs. That splits them into different tokens, wastes context window, and lowers embedding quality. Cleaning the data first makes results consistent and cheaper.",{"q":67136,"a":67137},"Do I need to remove every row with a missing value?","No. Only drop rows that are missing the fields your AI step actually needs, such as the text column you send to the model. For other columns, filling a placeholder like 'unknown' usually keeps more usable data.",{"q":67139,"a":67140},"Why am I getting an AttributeError when I run .str.lower() on a column?","Pandas only allows .str methods on text columns. If the column mixes numbers and text, cast it first with .astype(str). The error means at least one value is not a string.",{"q":67142,"a":67143},"How do I keep accented characters and emoji intact while cleaning?","Read and write the file with utf-8 encoding (use encoding='utf-8-sig' for Excel exports). Avoid forcing ASCII. Pandas preserves Unicode text by default as long as the encoding matches the source file.",{"q":67145,"a":67146},"Is it safe to overwrite my original CSV with the cleaned version?","Write to a new filename instead, such as clean_output.csv. Keeping the raw file means you can re-run the script with different settings if you spot a mistake, without losing the source data.",{"name":67148,"steps":67149},"How to clean CSV data with Pandas for AI",[67150,67153,67156,67159,67162],{"name":67151,"text":67152},"Load the CSV with Pandas","Read the file into a DataFrame and inspect its shape, columns, and types before changing anything.",{"name":67154,"text":67155},"Fix missing values","Drop rows that lack required fields and fill optional gaps with a clear placeholder.",{"name":67157,"text":67158},"Remove duplicates and fix types","Drop duplicate rows and cast each column to the type your AI step expects.",{"name":67160,"text":67161},"Normalize text fields","Strip whitespace, collapse internal spaces, and lowercase text so identical values match.",{"name":67163,"text":67164},"Export a clean, AI-ready CSV","Validate the result and write it to a new UTF-8 file for your AI pipeline.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai\u002Fcleaning-csv-data-with-pandas-for-ai","2026-05-10",{"title":66013,"description":67130},"Clean CSV Data with Pandas for AI","python-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai\u002Fcleaning-csv-data-with-pandas-for-ai\u002Findex","PX7CBVkkwBab02wJOMbZLdsnLLlay046e50kD_IkEc8",{"id":67173,"title":67174,"body":67175,"description":69386,"extension":2419,"faq":69387,"howto":69403,"meta":69418,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":69419,"published":69420,"seo":69421,"seoTitle":69422,"stem":69423,"__hash__":69424},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai\u002Findex.md","Data Cleaning for AI: A Step-by-Step Python Guide",{"type":7,"value":67176,"toc":69373},[67177,67180,67183,67189,67195,67286,67288,67291,67294,67297,67299,67305,67308,67329,67335,67349,67354,67400,67417,67424,67430,67434,67441,67588,67611,67614,67630,67634,67637,67643,67665,67671,67792,67803,67809,67888,67904,67908,67925,67928,67955,67971,68200,68215,68228,68232,68235,68311,68318,68410,68417,68419,68422,68593,68595,68598,68682,68686,68699,69312,69314,69317,69337,69341,69343,69370],[10,67178,67174],{"id":67179},"data-cleaning-for-ai-a-step-by-step-python-guide",[14,67181,67182],{},"You exported a spreadsheet of customer feedback, fed it straight into an AI model, and got back nonsense: half the rows summarized blank cells, the costs were higher than expected, and a few records crashed with a type error. The model was not broken. The data was. Every AI tool, from a simple summarizer to a full machine-learning pipeline, is only as reliable as the numbers and text you hand it.",[14,67184,67185,67186,67188],{},"Data cleaning is the unglamorous work that decides whether your AI project succeeds. It means taking a messy export full of blank cells, duplicate rows, mixed-up date formats, and inconsistent text, then turning it into a tidy table where every column means exactly one thing and every value is the type you expect. This guide walks you through that process in plain Python, using ",[35,67187,2494],{}," (the standard Python library for working with tables of data). You will load a messy file, fix the four problems that wreck almost every dataset, normalize text so an AI model can read it, and export a clean result you can trust.",[14,67190,67191,67192,67194],{},"This is a core skill in the ",[51,67193,26450],{"href":26449}," track. You do not need a data-science degree to follow along. If you can run a Python script and read a spreadsheet, you can clean data well. By the end you will have a reusable cleaning script and a clear checklist you can apply to any future dataset.",[76,67196,67198,67283],{"className":67197},[79],[81,67199,90,67202,90,67205,90,67208,90,67211,90,67214,90,67217,90,67220,90,67222,90,67227,90,67231,90,67233,90,67236,90,67239,90,67241,90,67245,90,67248,90,67251,90,67254,90,67257,90,67259,90,67262,90,67265,90,67268,90,67270,90,67273,90,67276],{"viewBox":67200,"role":84,"ariaLabelledBy":67201,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1020 400",[7091,7092],[92,67203,67204],{"id":7091},"The data cleaning pipeline",[96,67206,67207],{"id":7092},"Raw messy data flows through four cleaning stages and exits as a clean dataset ready for an AI model.",[100,67209],{"x":102,"y":67210,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"124",[111,67212,67213],{"x":113,"y":37122,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Raw messy data",[111,67215,67216],{"x":113,"y":61187,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"blanks, dupes",[216,67218],{"d":67219,"stroke":143,"strokeWidth":109,"markerEnd":37145},"M200 160 L236 160",[100,67221],{"x":129,"y":15417,"width":37100,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,67223,67226],{"x":67224,"y":67225,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"350","64","1. Load &",[111,67228,67230],{"x":67224,"y":67229,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"86","audit",[100,67232],{"x":129,"y":67210,"width":37100,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,67234,67235],{"x":67224,"y":37122,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"2. Fix gaps,",[111,67237,67238],{"x":67224,"y":61187,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"dupes, types",[100,67240],{"x":129,"y":24398,"width":37100,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,67242,67244],{"x":67224,"y":67243,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"244","3. Normalize",[111,67246,111],{"x":67224,"y":67247,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"266",[216,67249],{"d":67250,"stroke":143,"strokeWidth":144,"markerEnd":37145},"M460 70 L496 134",[216,67252],{"d":67253,"stroke":143,"strokeWidth":144,"markerEnd":37145},"M460 160 L496 160",[216,67255],{"d":67256,"stroke":143,"strokeWidth":144,"markerEnd":37145},"M460 250 L496 186",[100,67258],{"x":16427,"y":67210,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,67260,67261],{"x":117,"y":37122,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"4. Validate",[111,67263,67264],{"x":117,"y":61187,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"& export",[216,67266],{"d":67267,"stroke":143,"strokeWidth":109,"markerEnd":37145},"M700 160 L736 160",[100,67269],{"x":48131,"y":67210,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,67271,67272],{"x":12847,"y":37122,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Clean data",[111,67274,67275],{"x":12847,"y":61187,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"for AI model",[5548,67277,5550,67278,90],{},[5552,67279,5558,67280,5550],{"id":37174,"markerWidth":7162,"markerHeight":7162,"refX":7163,"refY":52352,"orient":5557},[216,67281],{"d":67282,"fill":143},"M0 0 L9 4.5 L0 9 z",[232,67284,67285],{},"Every dataset travels the same path: load and audit, fix structure, normalize text, then validate and export.",[57,67287,7060],{"id":7059},[14,67289,67290],{},"If you are a marketer pulling campaign exports, a founder merging spreadsheets from two tools, a creator collecting audience feedback, or a student preparing a dataset for a class project, this guide is for you. The task is always the same: you have a file that a human can roughly read but a machine cannot trust, and you need to make it consistent enough that an AI model produces stable, repeatable results.",[14,67292,67293],{},"Skipping this step does not throw an obvious error. Instead it leaks bad data quietly. A model trained on duplicate rows over-weights those examples. An API call on a blank cell wastes tokens and returns empty summaries. A column that is secretly text instead of a number breaks every calculation downstream. Clean data prevents all of this before it starts.",[14,67295,67296],{},"The good news is that the vast majority of dirty data comes down to just four recurring problems: missing values, duplicate rows, wrong data types, and inconsistent text. Once you know how to spot and fix those four, you can clean almost any tabular file you will ever meet. The rest of this guide walks through each one in order, using a single small dataset so you can see exactly what changes at every step. Treat the workflow as a checklist you run top to bottom rather than a set of tricks to memorize.",[57,67298,238],{"id":237},[14,67300,67301,67302,67304],{},"You need Python 3.10 or higher. Python 3.9 reached end-of-life in October 2025, so upgrade if you are still on it. If your machine is not set up yet, work through ",[51,67303,5423],{"href":5422}," first to install Python and create a virtual environment.",[14,67306,67307],{},"Install the libraries you need. Pandas does the heavy lifting; the others handle Excel files, Parquet export, and reading credentials from a file:",[253,67309,67311],{"className":255,"code":67310,"language":257,"meta":258,"style":258},"pip install pandas openpyxl pyarrow python-dotenv\n",[18,67312,67313],{"__ignoreMap":258},[262,67314,67315,67317,67319,67321,67324,67327],{"class":181,"line":264},[262,67316,298],{"class":267},[262,67318,301],{"class":275},[262,67320,2516],{"class":275},[262,67322,67323],{"class":275}," openpyxl",[262,67325,67326],{"class":275}," pyarrow",[262,67328,2522],{"class":275},[14,67330,67331,67332,67334],{},"Most cleaning needs no API keys at all. But if your pipeline later pulls data from a service or sends records to an AI API, store the keys in a ",[18,67333,319],{}," file (a plain text file holding secret values) rather than pasting them into your code:",[253,67336,67338],{"className":323,"code":67337,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-key-here\nDATA_SOURCE_URL=https:\u002F\u002Fexample.com\u002Fexport\n",[18,67339,67340,67344],{"__ignoreMap":258},[262,67341,67342],{"class":181,"line":264},[262,67343,11159],{},[262,67345,67346],{"class":181,"line":282},[262,67347,67348],{},"DATA_SOURCE_URL=https:\u002F\u002Fexample.com\u002Fexport\n",[14,67350,67351,67352,26616],{},"Load those values at the top of your script with ",[18,67353,2501],{},[253,67355,67357],{"className":414,"code":67356,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\n\nload_dotenv()  # reads the .env file in your project folder\napi_key = os.environ.get(\"OPENAI_API_KEY\")\n",[18,67358,67359,67365,67375,67379,67386],{"__ignoreMap":258},[262,67360,67361,67363],{"class":181,"line":264},[262,67362,684],{"class":377},[262,67364,687],{"class":429},[262,67366,67367,67369,67371,67373],{"class":181,"line":282},[262,67368,705],{"class":377},[262,67370,708],{"class":429},[262,67372,684],{"class":377},[262,67374,713],{"class":429},[262,67376,67377],{"class":181,"line":295},[262,67378,583],{"emptyLinePlaceholder":582},[262,67380,67381,67383],{"class":181,"line":345},[262,67382,4222],{"class":429},[262,67384,67385],{"class":291},"# reads the .env file in your project folder\n",[262,67387,67388,67391,67393,67396,67398],{"class":181,"line":492},[262,67389,67390],{"class":429},"api_key ",[262,67392,476],{"class":377},[262,67394,67395],{"class":429}," os.environ.get(",[262,67397,2681],{"class":275},[262,67399,660],{"class":429},[14,67401,67402,67405,67406,356,67408,67410,67411,67413,67414,67416],{},[35,67403,67404],{},"Important:"," add ",[18,67407,319],{},[18,67409,359],{}," file so you never commit secrets to version control. A single line — ",[18,67412,319],{}," — in ",[18,67415,359],{}," is enough to keep your keys off GitHub.",[14,67418,67419,67420,67423],{},"To follow the steps with real numbers, save this small messy file as ",[18,67421,67422],{},"raw_feedback.csv",". It deliberately contains every problem this guide fixes:",[253,67425,67428],{"className":67426,"code":67427,"language":111,"meta":258},[2577],"customer_id,name,signup_date,plan_price,feedback\n101,  Ada Lovelace ,2026-01-04,29.99,\"\u003Cp>Great tool!\u003C\u002Fp>   Saved me hours.\"\n102,Grace Hopper,2026\u002F01\u002F05,29.99,\"Confusing onboarding,  but support helped.\"\n102,Grace Hopper,2026\u002F01\u002F05,29.99,\"Confusing onboarding,  but support helped.\"\n103,Alan Turing,,,\"  loved   it!!! \"\n104,KATHERINE JOHNSON,2026-01-07,unknown,\n",[18,67429,67427],{"__ignoreMap":258},[57,67431,67433],{"id":67432},"step-1-load-messy-data-with-pandas","Step 1: Load messy data with pandas",[14,67435,67436,67437,67440],{},"Start every cleaning job with an audit. You cannot fix problems you have not seen. Read the file into a ",[35,67438,67439],{},"DataFrame"," (pandas' name for a table of rows and columns) and run three quick checks: the shape and column types, a summary of the numbers, and a count of missing values per column.",[253,67442,67444],{"className":414,"code":67443,"language":416,"meta":258,"style":258},"import pandas as pd\n\n# Load the raw file. encoding and on_bad_lines guard against common surprises.\ndf = pd.read_csv(\"raw_feedback.csv\", encoding=\"utf-8\", on_bad_lines=\"warn\")\n\nprint(\"Shape (rows, columns):\", df.shape)\nprint(\"\\nColumn types:\\n\", df.dtypes)\nprint(\"\\nMissing values per column:\\n\", df.isnull().sum())\nprint(\"\\nDuplicate rows:\", df.duplicated().sum())\nprint(\"\\nFirst rows:\\n\", df.head())\n",[18,67445,67446,67456,67460,67465,67496,67500,67512,67532,67552,67568],{"__ignoreMap":258},[262,67447,67448,67450,67452,67454],{"class":181,"line":264},[262,67449,684],{"class":377},[262,67451,2619],{"class":429},[262,67453,697],{"class":377},[262,67455,2624],{"class":429},[262,67457,67458],{"class":181,"line":282},[262,67459,583],{"emptyLinePlaceholder":582},[262,67461,67462],{"class":181,"line":295},[262,67463,67464],{"class":291},"# Load the raw file. encoding and on_bad_lines guard against common surprises.\n",[262,67466,67467,67469,67471,67473,67476,67478,67480,67482,67484,67486,67489,67491,67494],{"class":181,"line":345},[262,67468,2755],{"class":429},[262,67470,476],{"class":377},[262,67472,2760],{"class":429},[262,67474,67475],{"class":275},"\"raw_feedback.csv\"",[262,67477,608],{"class":429},[262,67479,612],{"class":611},[262,67481,476],{"class":377},[262,67483,617],{"class":275},[262,67485,608],{"class":429},[262,67487,67488],{"class":611},"on_bad_lines",[262,67490,476],{"class":377},[262,67492,67493],{"class":275},"\"warn\"",[262,67495,660],{"class":429},[262,67497,67498],{"class":181,"line":492},[262,67499,583],{"emptyLinePlaceholder":582},[262,67501,67502,67504,67506,67509],{"class":181,"line":503},[262,67503,637],{"class":271},[262,67505,602],{"class":429},[262,67507,67508],{"class":275},"\"Shape (rows, columns):\"",[262,67510,67511],{"class":429},", df.shape)\n",[262,67513,67514,67516,67518,67520,67522,67525,67527,67529],{"class":181,"line":521},[262,67515,637],{"class":271},[262,67517,602],{"class":429},[262,67519,1176],{"class":275},[262,67521,2137],{"class":271},[262,67523,67524],{"class":275},"Column types:",[262,67526,2137],{"class":271},[262,67528,1176],{"class":275},[262,67530,67531],{"class":429},", df.dtypes)\n",[262,67533,67534,67536,67538,67540,67542,67545,67547,67549],{"class":181,"line":537},[262,67535,637],{"class":271},[262,67537,602],{"class":429},[262,67539,1176],{"class":275},[262,67541,2137],{"class":271},[262,67543,67544],{"class":275},"Missing values per column:",[262,67546,2137],{"class":271},[262,67548,1176],{"class":275},[262,67550,67551],{"class":429},", df.isnull().sum())\n",[262,67553,67554,67556,67558,67560,67562,67565],{"class":181,"line":549},[262,67555,637],{"class":271},[262,67557,602],{"class":429},[262,67559,1176],{"class":275},[262,67561,2137],{"class":271},[262,67563,67564],{"class":275},"Duplicate rows:\"",[262,67566,67567],{"class":429},", df.duplicated().sum())\n",[262,67569,67570,67572,67574,67576,67578,67581,67583,67585],{"class":181,"line":570},[262,67571,637],{"class":271},[262,67573,602],{"class":429},[262,67575,1176],{"class":275},[262,67577,2137],{"class":271},[262,67579,67580],{"class":275},"First rows:",[262,67582,2137],{"class":271},[262,67584,1176],{"class":275},[262,67586,67587],{"class":429},", df.head())\n",[14,67589,67590,67591,1374,67594,67597,67598,67601,67602,67604,67605,67607,67608,67610],{},"Running this against the sample file tells you a lot. You will see five rows, one full duplicate, a missing ",[18,67592,67593],{},"signup_date",[18,67595,67596],{},"plan_price"," on row 103, a missing ",[18,67599,67600],{},"feedback"," on row 104, and a ",[18,67603,67596],{}," column that pandas read as text (",[18,67606,36804],{},") instead of a number, because one cell says ",[18,67609,35222],{},". The dates are also inconsistent: some use dashes, some use slashes.",[14,67612,67613],{},"Write these findings down before you change anything. That note becomes your baseline. After cleaning, you will check the result against it to confirm you fixed what you meant to and nothing more. A baseline turns cleaning from guesswork into something you can verify: if you started with five rows and one duplicate, you should end with four unique rows, and you can prove it.",[14,67615,67616,67617,1374,67620,67623,67624,67626,67627,67629],{},"Two small habits make this audit far more useful. First, look at the actual values, not just the counts — ",[18,67618,67619],{},"df.head()",[18,67621,67622],{},"df.sample(5)"," often reveal a stray symbol or an unexpected format that a summary hides. Second, archive the raw file before you touch it. Copy ",[18,67625,67422],{}," somewhere safe so you can always re-run cleaning from scratch when you discover a bug a week later. If you are working specifically with spreadsheet exports, the companion guide ",[51,67628,2919],{"href":2918}," goes deeper on encoding quirks, delimiters, and multi-sheet Excel files.",[57,67631,67633],{"id":67632},"step-2-handle-missing-values-duplicates-and-types","Step 2: Handle missing values, duplicates, and types",[14,67635,67636],{},"Now fix the structure. Tackle the three problems in a sensible order: remove duplicates first so you are not imputing values into rows you will delete anyway, then handle missing values column by column, then force each column into the right type.",[14,67638,67639,67642],{},[35,67640,67641],{},"Remove duplicates."," Exact duplicate rows almost always come from a bad export or a join gone wrong. Drop them and reset the row numbers:",[253,67644,67646],{"className":414,"code":67645,"language":416,"meta":258,"style":258},"df = df.drop_duplicates().reset_index(drop=True)\n",[18,67647,67648],{"__ignoreMap":258},[262,67649,67650,67652,67654,67657,67659,67661,67663],{"class":181,"line":264},[262,67651,2755],{"class":429},[262,67653,476],{"class":377},[262,67655,67656],{"class":429}," df.drop_duplicates().reset_index(",[262,67658,26854],{"class":611},[262,67660,476],{"class":377},[262,67662,4974],{"class":271},[262,67664,660],{"class":429},[14,67666,67667,67670],{},[35,67668,67669],{},"Handle missing values per column."," There is no single rule. Decide column by column based on what the column means:",[253,67672,67674],{"className":414,"code":67673,"language":416,"meta":258,"style":258},"# Drop rows missing a critical identifier — these records are unusable.\ndf = df.dropna(subset=[\"customer_id\"])\n\n# Fill missing prices with the median of the known prices.\n# First turn non-numeric text like \"unknown\" into a real missing value.\ndf[\"plan_price\"] = pd.to_numeric(df[\"plan_price\"], errors=\"coerce\")\ndf[\"plan_price\"] = df[\"plan_price\"].fillna(df[\"plan_price\"].median())\n\n# Fill missing feedback with an explicit placeholder, not a blank.\ndf[\"feedback\"] = df[\"feedback\"].fillna(\"no feedback provided\")\n",[18,67675,67676,67681,67700,67704,67709,67714,67739,67761,67765,67770],{"__ignoreMap":258},[262,67677,67678],{"class":181,"line":264},[262,67679,67680],{"class":291},"# Drop rows missing a critical identifier — these records are unusable.\n",[262,67682,67683,67685,67687,67689,67691,67693,67695,67698],{"class":181,"line":282},[262,67684,2755],{"class":429},[262,67686,476],{"class":377},[262,67688,66273],{"class":429},[262,67690,27491],{"class":611},[262,67692,476],{"class":377},[262,67694,12118],{"class":429},[262,67696,67697],{"class":275},"\"customer_id\"",[262,67699,3512],{"class":429},[262,67701,67702],{"class":181,"line":295},[262,67703,583],{"emptyLinePlaceholder":582},[262,67705,67706],{"class":181,"line":345},[262,67707,67708],{"class":291},"# Fill missing prices with the median of the known prices.\n",[262,67710,67711],{"class":181,"line":492},[262,67712,67713],{"class":291},"# First turn non-numeric text like \"unknown\" into a real missing value.\n",[262,67715,67716,67718,67721,67723,67725,67727,67729,67731,67733,67735,67737],{"class":181,"line":503},[262,67717,29113],{"class":429},[262,67719,67720],{"class":275},"\"plan_price\"",[262,67722,2903],{"class":429},[262,67724,476],{"class":377},[262,67726,66531],{"class":429},[262,67728,67720],{"class":275},[262,67730,1103],{"class":429},[262,67732,61479],{"class":611},[262,67734,476],{"class":377},[262,67736,66542],{"class":275},[262,67738,660],{"class":429},[262,67740,67741,67743,67745,67747,67749,67751,67753,67756,67758],{"class":181,"line":521},[262,67742,29113],{"class":429},[262,67744,67720],{"class":275},[262,67746,2903],{"class":429},[262,67748,476],{"class":377},[262,67750,27464],{"class":429},[262,67752,67720],{"class":275},[262,67754,67755],{"class":429},"].fillna(df[",[262,67757,67720],{"class":275},[262,67759,67760],{"class":429},"].median())\n",[262,67762,67763],{"class":181,"line":537},[262,67764,583],{"emptyLinePlaceholder":582},[262,67766,67767],{"class":181,"line":549},[262,67768,67769],{"class":291},"# Fill missing feedback with an explicit placeholder, not a blank.\n",[262,67771,67772,67774,67777,67779,67781,67783,67785,67787,67790],{"class":181,"line":570},[262,67773,29113],{"class":429},[262,67775,67776],{"class":275},"\"feedback\"",[262,67778,2903],{"class":429},[262,67780,476],{"class":377},[262,67782,27464],{"class":429},[262,67784,67776],{"class":275},[262,67786,66319],{"class":429},[262,67788,67789],{"class":275},"\"no feedback provided\"",[262,67791,660],{"class":429},[14,67793,3349,67794,67796,67797,67799,67800,67802],{},[18,67795,66563],{}," flag is the key trick here. It tells pandas to convert anything it cannot read as a number — like the word ",[18,67798,35222],{}," — into ",[18,67801,26874],{}," (pandas' marker for a missing value) instead of crashing. You then fill those gaps with the median, which is more robust to outliers than the average.",[14,67804,67805,67808],{},[35,67806,67807],{},"Fix data types."," A column stored as the wrong type breaks everything downstream. Convert the ID to a clean integer string, the date to a real date, and the price to a number:",[253,67810,67812],{"className":414,"code":67811,"language":416,"meta":258,"style":258},"# IDs are identifiers, not math — keep them as strings without decimals.\ndf[\"customer_id\"] = df[\"customer_id\"].astype(int).astype(str)\n\n# Parse mixed date formats into real dates; bad dates become NaT (missing).\ndf[\"signup_date\"] = pd.to_datetime(df[\"signup_date\"], format=\"mixed\", errors=\"coerce\")\n",[18,67813,67814,67819,67844,67848,67853],{"__ignoreMap":258},[262,67815,67816],{"class":181,"line":264},[262,67817,67818],{"class":291},"# IDs are identifiers, not math — keep them as strings without decimals.\n",[262,67820,67821,67823,67825,67827,67829,67831,67833,67835,67837,67840,67842],{"class":181,"line":282},[262,67822,29113],{"class":429},[262,67824,67697],{"class":275},[262,67826,2903],{"class":429},[262,67828,476],{"class":377},[262,67830,27464],{"class":429},[262,67832,67697],{"class":275},[262,67834,29126],{"class":429},[262,67836,439],{"class":271},[262,67838,67839],{"class":429},").astype(",[262,67841,433],{"class":271},[262,67843,660],{"class":429},[262,67845,67846],{"class":181,"line":295},[262,67847,583],{"emptyLinePlaceholder":582},[262,67849,67850],{"class":181,"line":345},[262,67851,67852],{"class":291},"# Parse mixed date formats into real dates; bad dates become NaT (missing).\n",[262,67854,67855,67857,67860,67862,67864,67867,67869,67871,67873,67875,67878,67880,67882,67884,67886],{"class":181,"line":492},[262,67856,29113],{"class":429},[262,67858,67859],{"class":275},"\"signup_date\"",[262,67861,2903],{"class":429},[262,67863,476],{"class":377},[262,67865,67866],{"class":429}," pd.to_datetime(df[",[262,67868,67859],{"class":275},[262,67870,1103],{"class":429},[262,67872,11867],{"class":611},[262,67874,476],{"class":377},[262,67876,67877],{"class":275},"\"mixed\"",[262,67879,608],{"class":429},[262,67881,61479],{"class":611},[262,67883,476],{"class":377},[262,67885,66542],{"class":275},[262,67887,660],{"class":429},[14,67889,3349,67890,67893,67894,1374,67897,67900,67901,67903],{},[18,67891,67892],{},"format=\"mixed\""," option lets pandas figure out each date individually, so ",[18,67895,67896],{},"2026-01-04",[18,67898,67899],{},"2026\u002F01\u002F05"," both parse correctly. After this step, run your audit again and confirm the duplicate is gone, no critical IDs are missing, and ",[18,67902,67596],{}," is now a real number.",[57,67905,67907],{"id":67906},"step-3-normalize-text-for-ai","Step 3: Normalize text for AI",[14,67909,67910,67911,608,67914,13390,67917,67920,67921,67924],{},"Text is where AI projects quietly lose accuracy and money. The same name written as ",[18,67912,67913],{}," Ada Lovelace",[18,67915,67916],{},"KATHERINE JOHNSON",[18,67918,67919],{},"Grace Hopper"," looks like three different styles to a model. Feedback wrapped in HTML tags like ",[18,67922,67923],{},"\u003Cp>"," wastes tokens and confuses prompts. Normalizing means putting text into one predictable shape.",[14,67926,67927],{},"Start with the simple column-wide cleanups pandas gives you for free:",[253,67929,67931],{"className":414,"code":67930,"language":416,"meta":258,"style":258},"# Strip leading\u002Ftrailing spaces and standardize the name column.\ndf[\"name\"] = df[\"name\"].str.strip().str.title()\n",[18,67932,67933,67938],{"__ignoreMap":258},[262,67934,67935],{"class":181,"line":264},[262,67936,67937],{"class":291},"# Strip leading\u002Ftrailing spaces and standardize the name column.\n",[262,67939,67940,67942,67944,67946,67948,67950,67952],{"class":181,"line":282},[262,67941,29113],{"class":429},[262,67943,33377],{"class":275},[262,67945,2903],{"class":429},[262,67947,476],{"class":377},[262,67949,27464],{"class":429},[262,67951,33377],{"class":275},[262,67953,67954],{"class":429},"].str.strip().str.title()\n",[14,67956,67957,67959,67960,67963,67964,67966,67967,67970],{},[18,67958,66723],{}," removes the padding spaces and ",[18,67961,67962],{},".str.title()"," turns ",[18,67965,67916],{}," into ",[18,67968,67969],{},"Katherine Johnson"," so casing is consistent. For the free-text feedback you need more control, so write a small function and apply it to the whole column:",[253,67972,67974],{"className":414,"code":67973,"language":416,"meta":258,"style":258},"import re\n\n\ndef clean_text(value: str) -> str:\n    \"\"\"Return a normalized, model-friendly version of a text value.\"\"\"\n    if not isinstance(value, str):\n        return \"\"\n    text = re.sub(r\"\u003C[^>]+>\", \" \", value)          # remove HTML tags\n    text = re.sub(r\"[^\\w\\s.,!?'-]\", \" \", text)      # drop stray symbols\n    text = re.sub(r\"([.!?])\\1+\", r\"\\1\", text)       # collapse \"!!!\" to \"!\"\n    text = re.sub(r\"\\s+\", \" \", text).strip()        # collapse whitespace\n    return text\n\n\ndf[\"feedback\"] = df[\"feedback\"].apply(clean_text)\n",[18,67975,67976,67982,67986,67990,68008,68013,68028,68034,68071,68102,68141,68169,68175,68179,68183],{"__ignoreMap":258},[262,67977,67978,67980],{"class":181,"line":264},[262,67979,684],{"class":377},[262,67981,7956],{"class":429},[262,67983,67984],{"class":181,"line":282},[262,67985,583],{"emptyLinePlaceholder":582},[262,67987,67988],{"class":181,"line":295},[262,67989,583],{"emptyLinePlaceholder":582},[262,67991,67992,67994,67997,68000,68002,68004,68006],{"class":181,"line":345},[262,67993,423],{"class":377},[262,67995,67996],{"class":267}," clean_text",[262,67998,67999],{"class":429},"(value: ",[262,68001,433],{"class":271},[262,68003,1939],{"class":429},[262,68005,433],{"class":271},[262,68007,1160],{"class":429},[262,68009,68010],{"class":181,"line":492},[262,68011,68012],{"class":275},"    \"\"\"Return a normalized, model-friendly version of a text value.\"\"\"\n",[262,68014,68015,68017,68019,68021,68024,68026],{"class":181,"line":503},[262,68016,3454],{"class":377},[262,68018,2818],{"class":377},[262,68020,63865],{"class":271},[262,68022,68023],{"class":429},"(value, ",[262,68025,433],{"class":271},[262,68027,8192],{"class":429},[262,68029,68030,68032],{"class":181,"line":521},[262,68031,8066],{"class":377},[262,68033,2908],{"class":275},[262,68035,68036,68038,68040,68042,68044,68046,68048,68050,68052,68055,68057,68059,68061,68063,68065,68068],{"class":181,"line":537},[262,68037,28267],{"class":429},[262,68039,476],{"class":377},[262,68041,12111],{"class":429},[262,68043,7973],{"class":377},[262,68045,1176],{"class":275},[262,68047,512],{"class":7981},[262,68049,12118],{"class":271},[262,68051,12121],{"class":377},[262,68053,68054],{"class":271},">]",[262,68056,531],{"class":377},[262,68058,8086],{"class":7981},[262,68060,1176],{"class":275},[262,68062,608],{"class":429},[262,68064,543],{"class":275},[262,68066,68067],{"class":429},", value)          ",[262,68069,68070],{"class":291},"# remove HTML tags\n",[262,68072,68073,68075,68077,68079,68081,68083,68085,68087,68090,68092,68094,68096,68099],{"class":181,"line":549},[262,68074,28267],{"class":429},[262,68076,476],{"class":377},[262,68078,12111],{"class":429},[262,68080,7973],{"class":377},[262,68082,1176],{"class":275},[262,68084,12118],{"class":271},[262,68086,12121],{"class":377},[262,68088,68089],{"class":271},"\\w\\s.,!?'-]",[262,68091,1176],{"class":275},[262,68093,608],{"class":429},[262,68095,543],{"class":275},[262,68097,68098],{"class":429},", text)      ",[262,68100,68101],{"class":291},"# drop stray symbols\n",[262,68103,68104,68106,68108,68110,68112,68114,68117,68121,68123,68125,68127,68129,68131,68133,68135,68138],{"class":181,"line":570},[262,68105,28267],{"class":429},[262,68107,476],{"class":377},[262,68109,12111],{"class":429},[262,68111,7973],{"class":377},[262,68113,1176],{"class":275},[262,68115,68116],{"class":271},"([.!?])",[262,68118,68120],{"class":68119},"s9eBZ","\\1",[262,68122,531],{"class":377},[262,68124,1176],{"class":275},[262,68126,608],{"class":429},[262,68128,7973],{"class":377},[262,68130,1176],{"class":275},[262,68132,68120],{"class":68119},[262,68134,1176],{"class":275},[262,68136,68137],{"class":429},", text)       ",[262,68139,68140],{"class":291},"# collapse \"!!!\" to \"!\"\n",[262,68142,68143,68145,68147,68149,68151,68153,68155,68157,68159,68161,68163,68166],{"class":181,"line":579},[262,68144,28267],{"class":429},[262,68146,476],{"class":377},[262,68148,12111],{"class":429},[262,68150,7973],{"class":377},[262,68152,1176],{"class":275},[262,68154,66624],{"class":271},[262,68156,531],{"class":377},[262,68158,1176],{"class":275},[262,68160,608],{"class":429},[262,68162,543],{"class":275},[262,68164,68165],{"class":429},", text).strip()        ",[262,68167,68168],{"class":291},"# collapse whitespace\n",[262,68170,68171,68173],{"class":181,"line":586},[262,68172,573],{"class":377},[262,68174,27018],{"class":429},[262,68176,68177],{"class":181,"line":591},[262,68178,583],{"emptyLinePlaceholder":582},[262,68180,68181],{"class":181,"line":623},[262,68182,583],{"emptyLinePlaceholder":582},[262,68184,68185,68187,68189,68191,68193,68195,68197],{"class":181,"line":634},[262,68186,29113],{"class":429},[262,68188,67776],{"class":275},[262,68190,2903],{"class":429},[262,68192,476],{"class":377},[262,68194,27464],{"class":429},[262,68196,67776],{"class":275},[262,68198,68199],{"class":429},"].apply(clean_text)\n",[14,68201,68202,68203,68206,68207,68210,68211,68214],{},"Each line targets one problem. The first removes HTML so ",[18,68204,68205],{},"\u003Cp>Great tool!\u003C\u002Fp>"," becomes ",[18,68208,68209],{},"Great tool!",". The second drops symbols that carry no meaning while keeping useful punctuation. The third collapses runs of repeated punctuation like ",[18,68212,68213],{},"!!!"," down to a single mark. The last squeezes every block of spaces, tabs, and newlines into a single space and trims the ends.",[14,68216,68217,68218,1374,68221,68224,68225,68227],{},"Notice what this function does not do: it does not lowercase the feedback or strip out all punctuation. For modern AI models, casing and punctuation often carry meaning — ",[18,68219,68220],{},"cheap",[18,68222,68223],{},"CHEAP!"," signal different emotions. Over-cleaning is a real risk. Clean enough to be consistent, not so much that you erase signal. Once your text is normalized this cleanly, it slots straight into prompts; see ",[51,68226,2487],{"href":2486}," for how to format those records into requests and manage token limits.",[57,68229,68231],{"id":68230},"step-4-export-the-clean-dataset","Step 4: Export the clean dataset",[14,68233,68234],{},"Before you save, validate. A short set of assertions catches mistakes immediately instead of letting a quiet bug travel into your model:",[253,68236,68238],{"className":414,"code":68237,"language":416,"meta":258,"style":258},"assert df[\"customer_id\"].is_unique, \"Duplicate customer IDs remain\"\nassert df[\"plan_price\"].notnull().all(), \"Some prices are still missing\"\nassert df[\"plan_price\"].between(0, 1000).all(), \"A price is outside the expected range\"\nprint(\"Validation passed:\", len(df), \"clean rows\")\n",[18,68239,68240,68254,68268,68291],{"__ignoreMap":258},[262,68241,68242,68244,68246,68248,68251],{"class":181,"line":264},[262,68243,66766],{"class":377},[262,68245,27464],{"class":429},[262,68247,67697],{"class":275},[262,68249,68250],{"class":429},"].is_unique, ",[262,68252,68253],{"class":275},"\"Duplicate customer IDs remain\"\n",[262,68255,68256,68258,68260,68262,68265],{"class":181,"line":282},[262,68257,66766],{"class":377},[262,68259,27464],{"class":429},[262,68261,67720],{"class":275},[262,68263,68264],{"class":429},"].notnull().all(), ",[262,68266,68267],{"class":275},"\"Some prices are still missing\"\n",[262,68269,68270,68272,68274,68276,68279,68281,68283,68285,68288],{"class":181,"line":295},[262,68271,66766],{"class":377},[262,68273,27464],{"class":429},[262,68275,67720],{"class":275},[262,68277,68278],{"class":429},"].between(",[262,68280,102],{"class":271},[262,68282,608],{"class":429},[262,68284,31040],{"class":271},[262,68286,68287],{"class":429},").all(), ",[262,68289,68290],{"class":275},"\"A price is outside the expected range\"\n",[262,68292,68293,68295,68297,68300,68302,68304,68306,68309],{"class":181,"line":345},[262,68294,637],{"class":271},[262,68296,602],{"class":429},[262,68298,68299],{"class":275},"\"Validation passed:\"",[262,68301,608],{"class":429},[262,68303,29318],{"class":271},[262,68305,32290],{"class":429},[262,68307,68308],{"class":275},"\"clean rows\"",[262,68310,660],{"class":429},[14,68312,68313,68314,68317],{},"If every assertion passes, export. Choose the format that fits where the data is going. ",[35,68315,68316],{},"Never overwrite your raw file"," — keep the original so you can re-run cleaning if you find a bug later.",[253,68319,68321],{"className":414,"code":68320,"language":416,"meta":258,"style":258},"# CSV: human-readable, opens in any spreadsheet.\ndf.to_csv(\"clean_feedback.csv\", index=False)\n\n# Parquet: faster to reload and remembers data types exactly.\ndf.to_parquet(\"clean_feedback.parquet\", index=False)\n\n# JSON lines: one record per line, ideal for feeding an AI API row by row.\ndf.to_json(\"clean_feedback.jsonl\", orient=\"records\", lines=True)\n",[18,68322,68323,68328,68345,68349,68354,68372,68376,68381],{"__ignoreMap":258},[262,68324,68325],{"class":181,"line":264},[262,68326,68327],{"class":291},"# CSV: human-readable, opens in any spreadsheet.\n",[262,68329,68330,68332,68335,68337,68339,68341,68343],{"class":181,"line":282},[262,68331,3730],{"class":429},[262,68333,68334],{"class":275},"\"clean_feedback.csv\"",[262,68336,608],{"class":429},[262,68338,3618],{"class":611},[262,68340,476],{"class":377},[262,68342,3623],{"class":271},[262,68344,660],{"class":429},[262,68346,68347],{"class":181,"line":295},[262,68348,583],{"emptyLinePlaceholder":582},[262,68350,68351],{"class":181,"line":345},[262,68352,68353],{"class":291},"# Parquet: faster to reload and remembers data types exactly.\n",[262,68355,68356,68359,68362,68364,68366,68368,68370],{"class":181,"line":492},[262,68357,68358],{"class":429},"df.to_parquet(",[262,68360,68361],{"class":275},"\"clean_feedback.parquet\"",[262,68363,608],{"class":429},[262,68365,3618],{"class":611},[262,68367,476],{"class":377},[262,68369,3623],{"class":271},[262,68371,660],{"class":429},[262,68373,68374],{"class":181,"line":503},[262,68375,583],{"emptyLinePlaceholder":582},[262,68377,68378],{"class":181,"line":521},[262,68379,68380],{"class":291},"# JSON lines: one record per line, ideal for feeding an AI API row by row.\n",[262,68382,68383,68386,68389,68391,68394,68396,68399,68401,68404,68406,68408],{"class":181,"line":537},[262,68384,68385],{"class":429},"df.to_json(",[262,68387,68388],{"class":275},"\"clean_feedback.jsonl\"",[262,68390,608],{"class":429},[262,68392,68393],{"class":611},"orient",[262,68395,476],{"class":377},[262,68397,68398],{"class":275},"\"records\"",[262,68400,608],{"class":429},[262,68402,68403],{"class":611},"lines",[262,68405,476],{"class":377},[262,68407,4974],{"class":271},[262,68409,660],{"class":429},[14,68411,68412,68413,68416],{},"CSV is best for sharing and quick inspection. Parquet is best when you will reload the data often, because it preserves the data types you worked so hard to fix. Newline-delimited JSON (",[18,68414,68415],{},".jsonl",") is best when each row becomes one AI API call. Now your dataset is consistent, typed, and validated — ready for any model.",[57,68418,8300],{"id":8299},[14,68420,68421],{},"The cleaning functions above all take optional arguments that change their behavior. These are the ones you will reach for most often.",[1379,68423,68424,68436],{},[1382,68425,68426],{},[1385,68427,68428,68430,68432,68434],{},[1388,68429,1390],{},[1388,68431,3795],{},[1388,68433,3798],{},[1388,68435,1396],{},[1398,68437,68438,68461,68485,68506,68523,68538,68558,68575],{},[1385,68439,68440,68445,68447,68451],{},[1403,68441,68442],{},[18,68443,68444],{},"pd.read_csv(encoding=...)",[1403,68446,433],{},[1403,68448,68449],{},[18,68450,617],{},[1403,68452,68453,68454,407,68457,68460],{},"Character encoding of the file. Use ",[18,68455,68456],{},"\"latin-1\"",[18,68458,68459],{},"\"cp1252\""," for older Excel exports that fail to load.",[1385,68462,68463,68468,68470,68475],{},[1403,68464,68465],{},[18,68466,68467],{},"pd.read_csv(on_bad_lines=...)",[1403,68469,433],{},[1403,68471,68472],{},[18,68473,68474],{},"\"error\"",[1403,68476,68477,68478,68480,68481,68484],{},"What to do with malformed rows. ",[18,68479,67493],{}," skips and reports them; ",[18,68482,68483],{},"\"skip\""," skips silently.",[1385,68486,68487,68492,68494,68499],{},[1403,68488,68489],{},[18,68490,68491],{},"drop_duplicates(subset=...)",[1403,68493,2801],{},[1403,68495,68496,68498],{},[18,68497,8471],{}," (all columns)",[1403,68500,68501,68502,68505],{},"Which columns define a duplicate. Pass ",[18,68503,68504],{},"[\"customer_id\"]"," to dedupe on ID alone.",[1385,68507,68508,68513,68515,68520],{},[1403,68509,68510],{},[18,68511,68512],{},"dropna(subset=...)",[1403,68514,2801],{},[1403,68516,68517,68519],{},[18,68518,8471],{}," (any column)",[1403,68521,68522],{},"Drop a row only if these specific columns are missing, instead of any column.",[1385,68524,68525,68530,68533,68535],{},[1403,68526,68527],{},[18,68528,68529],{},"fillna(value=...)",[1403,68531,68532],{},"scalar",[1403,68534,17513],{},[1403,68536,68537],{},"The value used to replace missing entries. Common picks: a median, a mode, or a placeholder string.",[1385,68539,68540,68545,68547,68551],{},[1403,68541,68542],{},[18,68543,68544],{},"pd.to_numeric(errors=...)",[1403,68546,433],{},[1403,68548,68549],{},[18,68550,66979],{},[1403,68552,68553,68555,68556,66987],{},[18,68554,66542],{}," turns unparseable text into ",[18,68557,26874],{},[1385,68559,68560,68565,68567,68570],{},[1403,68561,68562],{},[18,68563,68564],{},"pd.to_datetime(format=...)",[1403,68566,433],{},[1403,68568,68569],{},"inferred",[1403,68571,23336,68572,68574],{},[18,68573,67877],{}," to parse rows with different date formats individually.",[1385,68576,68577,68582,68584,68588],{},[1403,68578,68579],{},[18,68580,68581],{},"to_csv(index=...)",[1403,68583,8045],{},[1403,68585,68586],{},[18,68587,4974],{},[1403,68589,23336,68590,68592],{},[18,68591,3623],{}," to avoid writing pandas' row numbers as an extra unnamed column.",[57,68594,1445],{"id":1444},[14,68596,68597],{},"These are the errors you will most likely hit, with the exact message, the cause, and a one-line fix.",[1447,68599,68600,68611,68630,68643,68659,68671],{},[1450,68601,68602,68607,68608,1363],{},[35,68603,68604],{},[18,68605,68606],{},"UnicodeDecodeError: 'utf-8' codec can't decode byte 0x..."," — The file is not saved in UTF-8, common with exports from older Windows tools. Reload with the matching encoding: ",[18,68609,68610],{},"pd.read_csv(\"file.csv\", encoding=\"latin-1\")",[1450,68612,68613,60987,68618,68621,68622,68624,68625,1231,68627,1363],{},[35,68614,68615],{},[18,68616,68617],{},"ValueError: Unable to parse string \"unknown\" at position 4",[18,68619,68620],{},"pd.to_numeric()"," on a column containing non-numeric text. Add ",[18,68623,66563],{}," so the bad value becomes ",[18,68626,26874],{},[18,68628,68629],{},"pd.to_numeric(df[\"plan_price\"], errors=\"coerce\")",[1450,68631,68632,68637,68638,68640,68641,1363],{},[35,68633,68634],{},[18,68635,68636],{},"KeyError: 'customer_id'"," — The column name does not match exactly, often because of a trailing space or different casing in the header. Run ",[18,68639,67048],{}," to see the real names, then strip them with ",[18,68642,67052],{},[1450,68644,68645,68650,68651,68654,68655,68658],{},[35,68646,68647],{},[18,68648,68649],{},"SettingWithCopyWarning: A value is trying to be set on a copy of a slice"," — You are editing a filtered slice of the DataFrame, not the original. Assign back to a full column (",[18,68652,68653],{},"df[\"col\"] = ...",") or call ",[18,68656,68657],{},".copy()"," when you create the subset.",[1450,68660,68661,68666,68667,68670],{},[35,68662,68663,68665],{},[18,68664,14854],{}," inside your text function"," — A cell holds a missing value, not a string, so string operations fail. Guard the function with ",[18,68668,68669],{},"if not isinstance(value, str): return \"\""," as shown in Step 3.",[1450,68672,68673,68678,68679,68681],{},[35,68674,68675],{},[18,68676,68677],{},"ValueError: time data '2026\u002F01\u002F05' doesn't match format"," — Your dates use more than one format. Replace a fixed format string with ",[18,68680,67892],{}," so pandas parses each value on its own.",[57,68683,68685],{"id":68684},"worked-example-a-complete-cleaning-script","Worked example: a complete cleaning script",[14,68687,68688,68689,8518,68692,68695,68696,68698],{},"This script ties every step together. Save it as ",[18,68690,68691],{},"clean_dataset.py",[18,68693,68694],{},"python clean_dataset.py"," against the ",[18,68697,67422],{}," sample. It loads, audits, fixes structure, normalizes text, validates, and exports in one pass — exactly the reusable pipeline you want for any future dataset.",[253,68700,68702],{"className":414,"code":68701,"language":416,"meta":258,"style":258},"import re\nimport pandas as pd\n\nRAW_FILE = \"raw_feedback.csv\"\nCLEAN_FILE = \"clean_feedback.csv\"\n\n\ndef clean_text(value: str) -> str:\n    \"\"\"Normalize a free-text field into a consistent, model-friendly form.\"\"\"\n    if not isinstance(value, str):\n        return \"no feedback provided\"\n    text = re.sub(r\"\u003C[^>]+>\", \" \", value)        # strip HTML tags\n    text = re.sub(r\"[^\\w\\s.,!?'-]\", \" \", text)    # drop stray symbols\n    text = re.sub(r\"([.!?])\\1+\", r\"\\1\", text)     # collapse repeated punctuation\n    text = re.sub(r\"\\s+\", \" \", text).strip()      # collapse whitespace\n    return text or \"no feedback provided\"\n\n\ndef clean_dataset(path: str) -> pd.DataFrame:\n    df = pd.read_csv(path, encoding=\"utf-8\", on_bad_lines=\"warn\")\n    print(f\"Loaded {len(df)} rows, {df.duplicated().sum()} duplicates found\")\n\n    df = df.drop_duplicates().reset_index(drop=True)        # remove duplicates\n    df = df.dropna(subset=[\"customer_id\"])                  # require an ID\n\n    df[\"plan_price\"] = pd.to_numeric(df[\"plan_price\"], errors=\"coerce\")\n    df[\"plan_price\"] = df[\"plan_price\"].fillna(df[\"plan_price\"].median())\n\n    df[\"customer_id\"] = df[\"customer_id\"].astype(int).astype(str)\n    df[\"signup_date\"] = pd.to_datetime(df[\"signup_date\"], format=\"mixed\", errors=\"coerce\")\n    df[\"name\"] = df[\"name\"].str.strip().str.title()\n    df[\"feedback\"] = df[\"feedback\"].apply(clean_text)\n    return df\n\n\nif __name__ == \"__main__\":\n    df = clean_dataset(RAW_FILE)\n\n    assert df[\"customer_id\"].is_unique, \"Duplicate customer IDs remain\"\n    assert df[\"plan_price\"].notnull().all(), \"Missing prices remain\"\n\n    df.to_csv(CLEAN_FILE, index=False)\n    print(f\"Saved {len(df)} clean rows to {CLEAN_FILE}\")\n",[18,68703,68704,68710,68720,68724,68734,68744,68748,68752,68768,68773,68787,68794,68830,68859,68895,68922,68933,68937,68941,68954,68979,69010,69014,69034,69056,69060,69084,69104,69108,69132,69164,69180,69196,69202,69206,69210,69222,69235,69239,69252,69265,69269,69286],{"__ignoreMap":258},[262,68705,68706,68708],{"class":181,"line":264},[262,68707,684],{"class":377},[262,68709,7956],{"class":429},[262,68711,68712,68714,68716,68718],{"class":181,"line":282},[262,68713,684],{"class":377},[262,68715,2619],{"class":429},[262,68717,697],{"class":377},[262,68719,2624],{"class":429},[262,68721,68722],{"class":181,"line":295},[262,68723,583],{"emptyLinePlaceholder":582},[262,68725,68726,68729,68731],{"class":181,"line":345},[262,68727,68728],{"class":271},"RAW_FILE",[262,68730,442],{"class":377},[262,68732,68733],{"class":275}," \"raw_feedback.csv\"\n",[262,68735,68736,68739,68741],{"class":181,"line":492},[262,68737,68738],{"class":271},"CLEAN_FILE",[262,68740,442],{"class":377},[262,68742,68743],{"class":275}," \"clean_feedback.csv\"\n",[262,68745,68746],{"class":181,"line":503},[262,68747,583],{"emptyLinePlaceholder":582},[262,68749,68750],{"class":181,"line":521},[262,68751,583],{"emptyLinePlaceholder":582},[262,68753,68754,68756,68758,68760,68762,68764,68766],{"class":181,"line":537},[262,68755,423],{"class":377},[262,68757,67996],{"class":267},[262,68759,67999],{"class":429},[262,68761,433],{"class":271},[262,68763,1939],{"class":429},[262,68765,433],{"class":271},[262,68767,1160],{"class":429},[262,68769,68770],{"class":181,"line":549},[262,68771,68772],{"class":275},"    \"\"\"Normalize a free-text field into a consistent, model-friendly form.\"\"\"\n",[262,68774,68775,68777,68779,68781,68783,68785],{"class":181,"line":570},[262,68776,3454],{"class":377},[262,68778,2818],{"class":377},[262,68780,63865],{"class":271},[262,68782,68023],{"class":429},[262,68784,433],{"class":271},[262,68786,8192],{"class":429},[262,68788,68789,68791],{"class":181,"line":579},[262,68790,8066],{"class":377},[262,68792,68793],{"class":275}," \"no feedback provided\"\n",[262,68795,68796,68798,68800,68802,68804,68806,68808,68810,68812,68814,68816,68818,68820,68822,68824,68827],{"class":181,"line":586},[262,68797,28267],{"class":429},[262,68799,476],{"class":377},[262,68801,12111],{"class":429},[262,68803,7973],{"class":377},[262,68805,1176],{"class":275},[262,68807,512],{"class":7981},[262,68809,12118],{"class":271},[262,68811,12121],{"class":377},[262,68813,68054],{"class":271},[262,68815,531],{"class":377},[262,68817,8086],{"class":7981},[262,68819,1176],{"class":275},[262,68821,608],{"class":429},[262,68823,543],{"class":275},[262,68825,68826],{"class":429},", value)        ",[262,68828,68829],{"class":291},"# strip HTML tags\n",[262,68831,68832,68834,68836,68838,68840,68842,68844,68846,68848,68850,68852,68854,68857],{"class":181,"line":591},[262,68833,28267],{"class":429},[262,68835,476],{"class":377},[262,68837,12111],{"class":429},[262,68839,7973],{"class":377},[262,68841,1176],{"class":275},[262,68843,12118],{"class":271},[262,68845,12121],{"class":377},[262,68847,68089],{"class":271},[262,68849,1176],{"class":275},[262,68851,608],{"class":429},[262,68853,543],{"class":275},[262,68855,68856],{"class":429},", text)    ",[262,68858,68101],{"class":291},[262,68860,68861,68863,68865,68867,68869,68871,68873,68875,68877,68879,68881,68883,68885,68887,68889,68892],{"class":181,"line":623},[262,68862,28267],{"class":429},[262,68864,476],{"class":377},[262,68866,12111],{"class":429},[262,68868,7973],{"class":377},[262,68870,1176],{"class":275},[262,68872,68116],{"class":271},[262,68874,68120],{"class":68119},[262,68876,531],{"class":377},[262,68878,1176],{"class":275},[262,68880,608],{"class":429},[262,68882,7973],{"class":377},[262,68884,1176],{"class":275},[262,68886,68120],{"class":68119},[262,68888,1176],{"class":275},[262,68890,68891],{"class":429},", text)     ",[262,68893,68894],{"class":291},"# collapse repeated punctuation\n",[262,68896,68897,68899,68901,68903,68905,68907,68909,68911,68913,68915,68917,68920],{"class":181,"line":634},[262,68898,28267],{"class":429},[262,68900,476],{"class":377},[262,68902,12111],{"class":429},[262,68904,7973],{"class":377},[262,68906,1176],{"class":275},[262,68908,66624],{"class":271},[262,68910,531],{"class":377},[262,68912,1176],{"class":275},[262,68914,608],{"class":429},[262,68916,543],{"class":275},[262,68918,68919],{"class":429},", text).strip()      ",[262,68921,68168],{"class":291},[262,68923,68924,68926,68929,68931],{"class":181,"line":845},[262,68925,573],{"class":377},[262,68927,68928],{"class":429}," text ",[262,68930,8923],{"class":377},[262,68932,68793],{"class":275},[262,68934,68935],{"class":181,"line":850},[262,68936,583],{"emptyLinePlaceholder":582},[262,68938,68939],{"class":181,"line":864},[262,68940,583],{"emptyLinePlaceholder":582},[262,68942,68943,68945,68948,68950,68952],{"class":181,"line":1683},[262,68944,423],{"class":377},[262,68946,68947],{"class":267}," clean_dataset",[262,68949,15950],{"class":429},[262,68951,433],{"class":271},[262,68953,26732],{"class":429},[262,68955,68956,68958,68960,68963,68965,68967,68969,68971,68973,68975,68977],{"class":181,"line":1688},[262,68957,26737],{"class":429},[262,68959,476],{"class":377},[262,68961,68962],{"class":429}," pd.read_csv(path, ",[262,68964,612],{"class":611},[262,68966,476],{"class":377},[262,68968,617],{"class":275},[262,68970,608],{"class":429},[262,68972,67488],{"class":611},[262,68974,476],{"class":377},[262,68976,67493],{"class":275},[262,68978,660],{"class":429},[262,68980,68981,68983,68985,68987,68989,68991,68993,68995,68998,69000,69003,69005,69008],{"class":181,"line":1693},[262,68982,1089],{"class":271},[262,68984,602],{"class":429},[262,68986,642],{"class":377},[262,68988,2775],{"class":275},[262,68990,648],{"class":271},[262,68992,2780],{"class":429},[262,68994,654],{"class":271},[262,68996,68997],{"class":275}," rows, ",[262,68999,3039],{"class":271},[262,69001,69002],{"class":429},"df.duplicated().sum()",[262,69004,654],{"class":271},[262,69006,69007],{"class":275}," duplicates found\"",[262,69009,660],{"class":429},[262,69011,69012],{"class":181,"line":1728},[262,69013,583],{"emptyLinePlaceholder":582},[262,69015,69016,69018,69020,69022,69024,69026,69028,69031],{"class":181,"line":1737},[262,69017,26737],{"class":429},[262,69019,476],{"class":377},[262,69021,67656],{"class":429},[262,69023,26854],{"class":611},[262,69025,476],{"class":377},[262,69027,4974],{"class":271},[262,69029,69030],{"class":429},")        ",[262,69032,69033],{"class":291},"# remove duplicates\n",[262,69035,69036,69038,69040,69042,69044,69046,69048,69050,69053],{"class":181,"line":1751},[262,69037,26737],{"class":429},[262,69039,476],{"class":377},[262,69041,66273],{"class":429},[262,69043,27491],{"class":611},[262,69045,476],{"class":377},[262,69047,12118],{"class":429},[262,69049,67697],{"class":275},[262,69051,69052],{"class":429},"])                  ",[262,69054,69055],{"class":291},"# require an ID\n",[262,69057,69058],{"class":181,"line":1764},[262,69059,583],{"emptyLinePlaceholder":582},[262,69061,69062,69064,69066,69068,69070,69072,69074,69076,69078,69080,69082],{"class":181,"line":1779},[262,69063,2897],{"class":429},[262,69065,67720],{"class":275},[262,69067,2903],{"class":429},[262,69069,476],{"class":377},[262,69071,66531],{"class":429},[262,69073,67720],{"class":275},[262,69075,1103],{"class":429},[262,69077,61479],{"class":611},[262,69079,476],{"class":377},[262,69081,66542],{"class":275},[262,69083,660],{"class":429},[262,69085,69086,69088,69090,69092,69094,69096,69098,69100,69102],{"class":181,"line":1793},[262,69087,2897],{"class":429},[262,69089,67720],{"class":275},[262,69091,2903],{"class":429},[262,69093,476],{"class":377},[262,69095,27464],{"class":429},[262,69097,67720],{"class":275},[262,69099,67755],{"class":429},[262,69101,67720],{"class":275},[262,69103,67760],{"class":429},[262,69105,69106],{"class":181,"line":1800},[262,69107,583],{"emptyLinePlaceholder":582},[262,69109,69110,69112,69114,69116,69118,69120,69122,69124,69126,69128,69130],{"class":181,"line":1805},[262,69111,2897],{"class":429},[262,69113,67697],{"class":275},[262,69115,2903],{"class":429},[262,69117,476],{"class":377},[262,69119,27464],{"class":429},[262,69121,67697],{"class":275},[262,69123,29126],{"class":429},[262,69125,439],{"class":271},[262,69127,67839],{"class":429},[262,69129,433],{"class":271},[262,69131,660],{"class":429},[262,69133,69134,69136,69138,69140,69142,69144,69146,69148,69150,69152,69154,69156,69158,69160,69162],{"class":181,"line":1810},[262,69135,2897],{"class":429},[262,69137,67859],{"class":275},[262,69139,2903],{"class":429},[262,69141,476],{"class":377},[262,69143,67866],{"class":429},[262,69145,67859],{"class":275},[262,69147,1103],{"class":429},[262,69149,11867],{"class":611},[262,69151,476],{"class":377},[262,69153,67877],{"class":275},[262,69155,608],{"class":429},[262,69157,61479],{"class":611},[262,69159,476],{"class":377},[262,69161,66542],{"class":275},[262,69163,660],{"class":429},[262,69165,69166,69168,69170,69172,69174,69176,69178],{"class":181,"line":1823},[262,69167,2897],{"class":429},[262,69169,33377],{"class":275},[262,69171,2903],{"class":429},[262,69173,476],{"class":377},[262,69175,27464],{"class":429},[262,69177,33377],{"class":275},[262,69179,67954],{"class":429},[262,69181,69182,69184,69186,69188,69190,69192,69194],{"class":181,"line":1846},[262,69183,2897],{"class":429},[262,69185,67776],{"class":275},[262,69187,2903],{"class":429},[262,69189,476],{"class":377},[262,69191,27464],{"class":429},[262,69193,67776],{"class":275},[262,69195,68199],{"class":429},[262,69197,69198,69200],{"class":181,"line":1861},[262,69199,573],{"class":377},[262,69201,27542],{"class":429},[262,69203,69204],{"class":181,"line":1866},[262,69205,583],{"emptyLinePlaceholder":582},[262,69207,69208],{"class":181,"line":1871},[262,69209,583],{"emptyLinePlaceholder":582},[262,69211,69212,69214,69216,69218,69220],{"class":181,"line":1890},[262,69213,2210],{"class":377},[262,69215,2213],{"class":271},[262,69217,2216],{"class":377},[262,69219,2219],{"class":275},[262,69221,1160],{"class":429},[262,69223,69224,69226,69228,69231,69233],{"class":181,"line":1909},[262,69225,26737],{"class":429},[262,69227,476],{"class":377},[262,69229,69230],{"class":429}," clean_dataset(",[262,69232,68728],{"class":271},[262,69234,660],{"class":429},[262,69236,69237],{"class":181,"line":1914},[262,69238,583],{"emptyLinePlaceholder":582},[262,69240,69241,69244,69246,69248,69250],{"class":181,"line":1919},[262,69242,69243],{"class":377},"    assert",[262,69245,27464],{"class":429},[262,69247,67697],{"class":275},[262,69249,68250],{"class":429},[262,69251,68253],{"class":275},[262,69253,69254,69256,69258,69260,69262],{"class":181,"line":1946},[262,69255,69243],{"class":377},[262,69257,27464],{"class":429},[262,69259,67720],{"class":275},[262,69261,68264],{"class":429},[262,69263,69264],{"class":275},"\"Missing prices remain\"\n",[262,69266,69267],{"class":181,"line":1959},[262,69268,583],{"emptyLinePlaceholder":582},[262,69270,69271,69274,69276,69278,69280,69282,69284],{"class":181,"line":1996},[262,69272,69273],{"class":429},"    df.to_csv(",[262,69275,68738],{"class":271},[262,69277,608],{"class":429},[262,69279,3618],{"class":611},[262,69281,476],{"class":377},[262,69283,3623],{"class":271},[262,69285,660],{"class":429},[262,69287,69288,69290,69292,69294,69296,69298,69300,69302,69305,69308,69310],{"class":181,"line":2012},[262,69289,1089],{"class":271},[262,69291,602],{"class":429},[262,69293,642],{"class":377},[262,69295,3753],{"class":275},[262,69297,648],{"class":271},[262,69299,2780],{"class":429},[262,69301,654],{"class":271},[262,69303,69304],{"class":275}," clean rows to ",[262,69306,69307],{"class":271},"{CLEAN_FILE}",[262,69309,1176],{"class":275},[262,69311,660],{"class":429},[57,69313,2355],{"id":2354},[14,69315,69316],{},"You now have a repeatable way to turn any messy export into a model-ready dataset. To go further:",[1447,69318,69319,69325,69331],{},[1450,69320,69321,69322,69324],{},"Work through ",[51,69323,2919],{"href":2918}," to handle trickier real-world CSV problems like wrong delimiters, multi-line cells, and broken encodings.",[1450,69326,69327,69328,69330],{},"Turn your cleaning script into a hands-off job by reading ",[51,69329,21230],{"href":21229},", so new files get cleaned on a schedule without you touching them.",[1450,69332,69333,69334,69336],{},"Feed your clean records into a model by studying ",[51,69335,2487],{"href":2486},", where you will format the data into requests and manage token limits and costs.",[14,69338,2375,69339,1363],{},[51,69340,61611],{"href":61610},[57,69342,2381],{"id":2380},[2322,69344,69345,69350,69355,69360,69365],{},[1450,69346,69347,69349],{},[51,69348,2919],{"href":2918}," — a deeper, CSV-specific walkthrough of the techniques in this guide.",[1450,69351,69352,69354],{},[51,69353,21230],{"href":21229}," — schedule and automate your cleaning pipeline.",[1450,69356,69357,69359],{},[51,69358,2487],{"href":2486}," — send your clean data to an AI model the right way.",[1450,69361,69362,69364],{},[51,69363,5423],{"href":5422}," — install Python and pandas before you start.",[1450,69366,69367,69369],{},[51,69368,26450],{"href":26449}," — the full beginner track this guide belongs to.",[2401,69371,69372],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":258,"searchDepth":282,"depth":282,"links":69374},[69375,69376,69377,69378,69379,69380,69381,69382,69383,69384,69385],{"id":7059,"depth":282,"text":7060},{"id":237,"depth":282,"text":238},{"id":67432,"depth":282,"text":67433},{"id":67632,"depth":282,"text":67633},{"id":67906,"depth":282,"text":67907},{"id":68230,"depth":282,"text":68231},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":68684,"depth":282,"text":68685},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Prepare datasets for AI with Python. Load messy data with pandas, fix missing values and duplicates, normalize text, and export a clean dataset ready for any model.",[69388,69391,69394,69397,69400],{"q":69389,"a":69390},"Why do I need to clean data before sending it to an AI model?","AI models and APIs inherit whatever noise lives in your input. Missing values, duplicate rows, and inconsistent text quietly skew results, inflate token costs, and trigger validation errors. Cleaning first means your model sees consistent, trustworthy data and your results become reproducible.",{"q":69392,"a":69393},"What is the best Python library for data cleaning?","Pandas is the standard for tabular data. It reads CSV, Excel, and JSON into a DataFrame, then gives you one-line tools to drop duplicates, fill gaps, fix data types, and normalize text. For text-only work you pair it with Python's built-in re module for pattern cleanup.",{"q":69395,"a":69396},"Should I delete rows with missing values or fill them in?","It depends on the column. Drop a row only when a critical identifier is missing or the gap makes the record useless. For numeric columns, fill gaps with the median; for categories, fill with the most common value. Deleting too aggressively shrinks your dataset and can erase real patterns.",{"q":69398,"a":69399},"How do I clean text so it works well with an LLM?","Strip HTML tags, collapse repeated whitespace into single spaces, remove stray control characters, and standardize casing where it does not change meaning. Keep punctuation that carries meaning. Clean text reduces token waste and makes prompts behave consistently across records.",{"q":69401,"a":69402},"What file format should I export cleaned data in?","CSV is fine for sharing and spreadsheets. For larger datasets or repeated loading, Parquet is faster and preserves data types exactly. For feeding records straight into an AI API, export newline-delimited JSON so each record is one validated object.",{"name":69404,"steps":69405},"How to clean a dataset for AI with Python",[69406,69409,69412,69415],{"name":69407,"text":69408},"Load messy data with pandas","Read your raw CSV into a pandas DataFrame and run a quick audit to see missing values, duplicates, and column types.",{"name":69410,"text":69411},"Handle missing values, duplicates, and types","Drop exact duplicates, fill or remove missing values column by column, and force each column into the correct data type.",{"name":69413,"text":69414},"Normalize text for AI","Strip HTML, collapse whitespace, standardize casing, and remove stray characters so text records are consistent.",{"name":69416,"text":69417},"Export the clean dataset","Validate the result and save it to CSV, Parquet, or JSON ready for an AI model or API.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai","2026-05-04",{"title":67174,"description":69386},"Data Cleaning for AI with Python","python-ai-fundamentals-for-non-developers\u002Fdata-cleaning-for-ai\u002Findex","n6KAHVZT9FmvuuHhyBbfGI66d5O2jAYB1NnEKL41cwo",{"id":69426,"title":26450,"body":69427,"description":71294,"extension":2419,"faq":71295,"howto":71311,"meta":71323,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":71324,"published":26525,"seo":71325,"seoTitle":26450,"stem":71326,"__hash__":71327},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Findex.md",{"type":7,"value":69428,"toc":71280},[69429,69432,69435,69438,69441,69444,69535,69537,69540,69580,69583,69586,69588,69593,69611,69620,69623,69703,69715,69724,69728,69731,69734,69740,69876,69887,69890,69909,69913,69924,69928,69931,69934,70023,70046,70052,70055,70144,70155,70184,70188,70191,70199,70218,70227,70319,70327,70341,70350,70364,70368,70374,70643,70660,70678,70682,70688,71045,71068,71086,71088,71194,71196,71199,71245,71247,71250,71272,71277],[10,69430,26450],{"id":69431},"python-ai-fundamentals-for-non-developers",[14,69433,69434],{},"You have a clear idea for using AI: sort your inbox, rewrite product descriptions, summarise a pile of survey responses. Then you open a tutorial and hit a wall of terminal commands, API keys, and JSON that nobody explains. This guide removes that wall. If you can copy a recipe and follow it step by step, you can write a Python script that talks to an AI model and does real work. No engineering background required, no maths, no jargon left undefined.",[14,69436,69437],{},"Python is the plain-English layer between you and the AI models you want to use. You will not build a neural network from scratch. You will write short scripts, usually under thirty lines, that send a request to a model and do something useful with the answer. Everything below is runnable today on a normal laptop.",[14,69439,69440],{},"Why Python specifically, when you could just use a chatbot in your browser? Because a browser handles one request at a time and forgets everything when you close the tab. A script can run the same instruction over a thousand rows of a spreadsheet, save the results to a file, run on a schedule while you sleep, and connect the answer to your other tools. The chat window is a conversation; a script is a machine you build once and reuse forever. Python is the most popular language for this because it reads almost like English and because every AI provider ships official tools for it, so you spend your time on the task, not on fighting the language.",[14,69442,69443],{},"You do not need to understand how the model works inside to use it well, any more than you need to understand a car engine to drive. The model is a service you send text to and get text back from. Your job is the four things on either side of that exchange: getting your machine ready, keeping your key safe, shaping the data you send, and handling whatever comes back. Master those and you can build almost anything a non-developer needs from AI.",[76,69445,69447,69532],{"className":69446},[79],[81,69448,90,69451,90,69454,90,69457,90,69463,90,69465,90,69469,90,69472,90,69475,90,69478,90,69482,90,69485,90,69489,90,69493,90,69496,90,69499,90,69502,90,69504,90,69507,90,69510,90,69513,90,69515,90,69518,90,69522,90,69526,90,69530],{"viewBox":69449,"role":84,"ariaLabelledBy":69450,"preserveAspectRatio":88,"xmlns":89},"-40 -40 820 470",[7091,7092],[92,69452,69453],{"id":7091},"How the five sections of this guide fit together",[96,69455,69456],{"id":7092},"A Python script reads secrets and clean data, sends a prompt over an LLM API, handles errors and retries, and returns a result you can use.",[5548,69458,5550,69459,90],{},[5552,69460,5558,69461,5550],{"id":23361,"viewBox":7161,"refX":7162,"refY":222,"markerWidth":7163,"markerHeight":7163,"orient":7164},[216,69462],{"d":23364,"fill":125},[100,69464],{"x":9777,"y":140,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,69466,69468],{"x":69467,"y":12882,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"130","Set up Python",[111,69470,69471],{"x":69467,"y":19872,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"venv + install SDK",[100,69473],{"x":9777,"y":69474,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},"174",[111,69476,69477],{"x":69467,"y":19936,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Secrets in .env",[111,69479,69481],{"x":69467,"y":69480,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"224","API key, not in code",[100,69483],{"x":9777,"y":69484,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},"328",[111,69486,69488],{"x":69467,"y":69487,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"358","Clean data in",[111,69490,69492],{"x":69467,"y":69491,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"378","CSV, JSON, text",[100,69494],{"x":69495,"y":69474,"width":104,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"290",[111,69497,69498],{"x":19890,"y":19936,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Your script",[111,69500,69501],{"x":19890,"y":69480,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"prompt + retries",[100,69503],{"x":37118,"y":69474,"width":104,"height":105,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},[111,69505,69506],{"x":48128,"y":19936,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"LLM API",[111,69508,69509],{"x":48128,"y":69480,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"returns JSON",[181,69511],{"x1":19862,"y1":19937,"x2":69512,"y2":57367,"stroke":108,"strokeWidth":109,"markerEnd":23411},"288",[181,69514],{"x1":19862,"y1":24392,"x2":69512,"y2":24392,"stroke":143,"strokeWidth":109,"markerEnd":23411},[181,69516],{"x1":19862,"y1":69517,"x2":69512,"y2":69480,"stroke":143,"strokeWidth":109,"markerEnd":23411},"364",[181,69519],{"x1":69520,"y1":104,"x2":69521,"y2":104,"stroke":130,"strokeWidth":109,"markerEnd":23411},"490","538",[181,69523],{"x1":37118,"y1":37100,"x2":69524,"y2":37100,"stroke":169,"strokeWidth":109,"strokeDashArray":69525,"markerEnd":23411},"492",[222,19848],[111,69527,69529],{"x":69528,"y":19908,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"515","request",[111,69531,58582],{"x":69528,"y":129,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[232,69533,69534],{},"The five tracks below, in the order a real script uses them: set up, secure your key, feed clean data, send the prompt, handle the answer.",[57,69536,24432],{"id":24431},[14,69538,69539],{},"This hub is split into five tracks. Read them in order if you are brand new, or jump straight to the one you need. Each links to deeper step-by-step guides.",[2322,69541,69542,69552,69559,69566,69573],{},[1450,69543,69544,69548,69549,69551],{},[35,69545,69546],{},[51,69547,5423],{"href":5422}," — install Python the right way, create an isolated workspace, and install the libraries every later step depends on. Start here if ",[18,69550,416],{}," does nothing in your terminal.",[1450,69553,69554,69558],{},[35,69555,69556],{},[51,69557,2487],{"href":2486}," — what actually happens when your code talks to a model: requests, responses, tokens, rate limits, and how to read the JSON that comes back. An LLM (large language model) is the AI that generates text; an API (application programming interface) is the doorway your code knocks on to use it.",[1450,69560,69561,69565],{},[35,69562,69563],{},[51,69564,7554],{"href":7553}," — how to write instructions that get reliable, correctly formatted answers instead of rambling ones, including system prompts and few-shot examples.",[1450,69567,69568,69572],{},[35,69569,69570],{},[51,69571,21230],{"href":21229}," — turn a one-off script into a workflow that loops over many items, retries on failure, and runs unattended.",[1450,69574,69575,69579],{},[35,69576,69577],{},[51,69578,61611],{"href":61610}," — get messy spreadsheets and exports into a tidy shape before you feed them to a model, because clean input is the difference between a useful answer and garbage.",[14,69581,69582],{},"The four core sections in this guide teach the concepts that every one of those tracks assumes you already understand: how Python reaches an AI service, what an API payload looks like, how to keep your key safe, and how to recover when a request fails. We finish by stitching all four into one small working project.",[14,69584,69585],{},"A quick word on why the order matters. These tracks are not a random menu; they are a dependency chain. You cannot call an API without Python installed, you cannot call it safely without managing your key, and you cannot trust the output without clean input and reliable error handling. Skipping ahead is the usual reason a beginner's first script throws a cryptic error: it is almost never the AI that broke, but a missing library, an unloaded key, or a malformed input from earlier in the chain. Spend an hour on the foundation and the flashy parts, generating content, building chatbots, automating your whole workflow, stop fighting you. Everything in the two related guides at the end builds directly on the four concepts below, so this is the page to get solid on first.",[57,69587,238],{"id":237},[14,69589,24476,69590,69592],{},[18,69591,298],{}," package installer (it ships with Python), and a terminal. To check what you have, run:",[253,69594,69596],{"className":255,"code":69595,"language":257,"meta":258,"style":258},"python3 --version\npip3 --version\n",[18,69597,69598,69604],{"__ignoreMap":258},[262,69599,69600,69602],{"class":181,"line":264},[262,69601,268],{"class":267},[262,69603,52414],{"class":271},[262,69605,69606,69609],{"class":181,"line":282},[262,69607,69608],{"class":267},"pip3",[262,69610,52414],{"class":271},[14,69612,17552,69613,69615,69616,407,69618,1363],{},[18,69614,268],{}," is not found, or the version is below 3.10, follow the full walkthrough for your machine: ",[51,69617,30415],{"href":30414},[51,69619,30411],{"href":30410},[14,69621,69622],{},"Next, create a virtual environment. A virtual environment is a private folder that holds the libraries for one project, so installing something here can never break another project or your system Python. Always work inside one.",[253,69624,69626],{"className":255,"code":69625,"language":257,"meta":258,"style":258},"# Create a project folder and an isolated environment inside it\nmkdir ai-fundamentals && cd ai-fundamentals\npython3 -m venv .venv\n\n# Activate it\nsource .venv\u002Fbin\u002Factivate        # macOS \u002F Linux\n# .venv\\Scripts\\activate         # Windows PowerShell\n\n# Install the libraries used throughout this guide\npip install openai httpx python-dotenv pandas\n",[18,69627,69628,69633,69647,69657,69661,69666,69675,69680,69684,69689],{"__ignoreMap":258},[262,69629,69630],{"class":181,"line":264},[262,69631,69632],{"class":291},"# Create a project folder and an isolated environment inside it\n",[262,69634,69635,69637,69640,69642,69644],{"class":181,"line":282},[262,69636,7191],{"class":267},[262,69638,69639],{"class":275}," ai-fundamentals",[262,69641,7197],{"class":429},[262,69643,7200],{"class":271},[262,69645,69646],{"class":275}," ai-fundamentals\n",[262,69648,69649,69651,69653,69655],{"class":181,"line":295},[262,69650,268],{"class":267},[262,69652,272],{"class":271},[262,69654,276],{"class":275},[262,69656,279],{"class":275},[262,69658,69659],{"class":181,"line":345},[262,69660,583],{"emptyLinePlaceholder":582},[262,69662,69663],{"class":181,"line":492},[262,69664,69665],{"class":291},"# Activate it\n",[262,69667,69668,69670,69672],{"class":181,"line":503},[262,69669,285],{"class":271},[262,69671,288],{"class":275},[262,69673,69674],{"class":291},"        # macOS \u002F Linux\n",[262,69676,69677],{"class":181,"line":521},[262,69678,69679],{"class":291},"# .venv\\Scripts\\activate         # Windows PowerShell\n",[262,69681,69682],{"class":181,"line":537},[262,69683,583],{"emptyLinePlaceholder":582},[262,69685,69686],{"class":181,"line":549},[262,69687,69688],{"class":291},"# Install the libraries used throughout this guide\n",[262,69690,69691,69693,69695,69697,69699,69701],{"class":181,"line":570},[262,69692,298],{"class":267},[262,69694,301],{"class":275},[262,69696,2519],{"class":275},[262,69698,5440],{"class":275},[262,69700,310],{"class":275},[262,69702,37218],{"class":275},[14,69704,69705,69706,69708,69709,69711,69712,69714],{},"Your prompt should now start with ",[18,69707,30512],{},". That prefix is your signal that the environment is active and any ",[18,69710,31961],{}," will land safely inside this project. You activate it once per terminal session; if you close the window and come back later, run the activate line again before working. If anything here is unfamiliar, the dedicated guide on how to ",[51,69713,2482],{"href":2481}," explains every line.",[14,69716,69717,69718,69720,69721,69723],{},"You will also need an API key from a model provider. Sign up with one, create a key in its dashboard, and copy it somewhere safe for a moment; we put it into a ",[18,69719,319],{}," file in the secrets section below. If cost is a concern, start with the ",[51,69722,5485],{"href":5484},", which lists providers with a genuinely usable free tier so you can complete every example here without entering a card. With Python ready, your workspace isolated, and a key in hand, you have everything the four core concepts need.",[57,69725,69727],{"id":69726},"concept-1-how-python-talks-to-an-ai-service","Concept 1: How Python talks to an AI service",[14,69729,69730],{},"An AI model does not live on your laptop. It runs on a provider's servers. Your Python script sends a message over the internet asking the model a question, and the server sends back an answer. This conversation happens over HTTP, the same protocol your web browser uses to load pages. The only difference is your script is the one making the request, not a browser.",[14,69732,69733],{},"Picture it as ordering at a counter. You write down exactly what you want (the prompt), hand the slip to the server along with proof you are allowed to order (your API key), and a moment later you receive a tray (the response). You never see the kitchen. You do not need to. The entire skill is writing a clear order slip and knowing how to unwrap the tray that comes back. Once that clicks, swapping one model for another, or one provider for another, is just ordering from a different counter with the same routine.",[14,69735,69736,69737,69739],{},"There are two ways to make that request. The first is the official ",[18,69738,20],{}," SDK, a Python package that wraps all the messy details, authentication, formatting, retries, into a few clean lines. This is what you will use most of the time.",[253,69741,69743],{"className":414,"code":69742,"language":416,"meta":258,"style":258},"# talk.py\nimport os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()                       # read variables from a .env file\nclient = OpenAI()                   # automatically picks up OPENAI_API_KEY\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",            # a small, cheap, fast model\n    messages=[\n        {\"role\": \"user\", \"content\": \"Explain what an API is in one sentence.\"}\n    ],\n)\n\nprint(response.choices[0].message.content)\n",[18,69744,69745,69750,69756,69766,69776,69780,69788,69800,69804,69812,69825,69833,69854,69858,69862,69866],{"__ignoreMap":258},[262,69746,69747],{"class":181,"line":264},[262,69748,69749],{"class":291},"# talk.py\n",[262,69751,69752,69754],{"class":181,"line":282},[262,69753,684],{"class":377},[262,69755,687],{"class":429},[262,69757,69758,69760,69762,69764],{"class":181,"line":295},[262,69759,705],{"class":377},[262,69761,708],{"class":429},[262,69763,684],{"class":377},[262,69765,713],{"class":429},[262,69767,69768,69770,69772,69774],{"class":181,"line":345},[262,69769,705],{"class":377},[262,69771,720],{"class":429},[262,69773,684],{"class":377},[262,69775,725],{"class":429},[262,69777,69778],{"class":181,"line":492},[262,69779,583],{"emptyLinePlaceholder":582},[262,69781,69782,69785],{"class":181,"line":503},[262,69783,69784],{"class":429},"load_dotenv()                       ",[262,69786,69787],{"class":291},"# read variables from a .env file\n",[262,69789,69790,69792,69794,69797],{"class":181,"line":521},[262,69791,739],{"class":429},[262,69793,476],{"class":377},[262,69795,69796],{"class":429}," OpenAI()                   ",[262,69798,69799],{"class":291},"# automatically picks up OPENAI_API_KEY\n",[262,69801,69802],{"class":181,"line":537},[262,69803,583],{"emptyLinePlaceholder":582},[262,69805,69806,69808,69810],{"class":181,"line":549},[262,69807,48362],{"class":429},[262,69809,476],{"class":377},[262,69811,1189],{"class":429},[262,69813,69814,69816,69818,69820,69822],{"class":181,"line":570},[262,69815,48371],{"class":611},[262,69817,476],{"class":377},[262,69819,1207],{"class":275},[262,69821,54526],{"class":429},[262,69823,69824],{"class":291},"# a small, cheap, fast model\n",[262,69826,69827,69829,69831],{"class":181,"line":579},[262,69828,48388],{"class":611},[262,69830,476],{"class":377},[262,69832,1220],{"class":429},[262,69834,69835,69837,69839,69841,69843,69845,69847,69849,69852],{"class":181,"line":586},[262,69836,7726],{"class":429},[262,69838,1228],{"class":275},[262,69840,1231],{"class":429},[262,69842,1291],{"class":275},[262,69844,608],{"class":429},[262,69846,1239],{"class":275},[262,69848,1231],{"class":429},[262,69850,69851],{"class":275},"\"Explain what an API is in one sentence.\"",[262,69853,16430],{"class":429},[262,69855,69856],{"class":181,"line":591},[262,69857,48439],{"class":429},[262,69859,69860],{"class":181,"line":623},[262,69861,660],{"class":429},[262,69863,69864],{"class":181,"line":634},[262,69865,583],{"emptyLinePlaceholder":582},[262,69867,69868,69870,69872,69874],{"class":181,"line":845},[262,69869,637],{"class":271},[262,69871,48465],{"class":429},[262,69873,102],{"class":271},[262,69875,6048],{"class":429},[14,69877,13310,69878,57002,69881,69883,69884,69886],{},[18,69879,69880],{},"python talk.py",[18,69882,43269],{}," list is the conversation so far, and ",[18,69885,7909],{}," is the model's reply as plain text. That single field is what you will use, save, or pass to the next step in almost every script you write.",[14,69888,69889],{},"Notice what the SDK quietly handled. It found your API key, attached it to the request in the correct format, chose the right web address for the model, encoded your message as JSON, sent it over the network, waited for the answer, decoded the JSON that came back, and gave you a clean Python object to read from. If any of those steps had to be done by hand, this snippet would be four times as long. That is the whole point of an SDK: it turns a dozen fiddly steps into one method call so you can focus on the question you are asking, not the plumbing.",[14,69891,69892,69893,69895,69896,69898,69899,69901,69902,69904,69905,1363],{},"The second way is to send the raw HTTP request yourself with ",[18,69894,5450],{},", a modern library for making web requests. You rarely need this with OpenAI, but it is worth seeing once because it shows what the SDK is doing for you, and because many other providers do not ship an SDK. Think of the SDK as a phone's contacts app and ",[18,69897,5450],{}," as dialling the full number by hand: both place the call, but one remembers the details for you. We will use ",[18,69900,5450],{}," in the error-handling section below. To compare providers before you commit, read ",[51,69903,14635],{"href":14634},", and if low latency matters for your project, weigh up ",[51,69906,69908],{"href":69907},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fgroq-vs-openrouter-free-tier\u002F","Groq vs OpenRouter Free Tier",[12782,69910,69912],{"id":69911},"choosing-a-model","Choosing a model",[14,69914,3349,69915,69917,69918,69920,69921,69923],{},[18,69916,805],{}," field decides which AI answers you, and the choice is mostly about a trade-off between cost, speed, and capability. Smaller models like ",[18,69919,2703],{}," are cheap and fast and handle the vast majority of everyday tasks: classifying text, rewriting, extracting fields, summarising short documents. Larger models cost more and respond more slowly but reason better on hard, multi-step problems. The sensible default for a non-developer is to start with the smallest, cheapest model, see if its answers are good enough for your task, and only reach for a bigger one if they are not. You will be surprised how often the cheap model is plenty, and how much money that habit saves once a script is processing thousands of items. Store the model name in your ",[18,69922,319],{}," as shown later so you can switch it in one place without touching your code.",[57,69925,69927],{"id":69926},"concept-2-api-payloads-and-json","Concept 2: API payloads and JSON",[14,69929,69930],{},"Every request you send and every response you get back is JSON (JavaScript Object Notation), a text format for structured data. It looks almost exactly like a Python dictionary: keys in quotes, values after a colon, wrapped in curly braces. Understanding its shape is the single most useful skill in this guide, because once you can read a response, you can pull out exactly the piece you need.",[14,69932,69933],{},"The data you send is called the payload. For a chat request it contains the model name and the list of messages:",[253,69935,69938],{"className":69936,"code":69937,"language":17049,"meta":258,"style":258},"language-json shiki shiki-themes github-light github-dark","{\n  \"model\": \"gpt-4o-mini\",\n  \"messages\": [\n    {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n    {\"role\": \"user\", \"content\": \"Summarise this review in five words.\"}\n  ],\n  \"temperature\": 0.3\n}\n",[18,69939,69940,69944,69955,69962,69983,70004,70009,70019],{"__ignoreMap":258},[262,69941,69942],{"class":181,"line":264},[262,69943,6593],{"class":429},[262,69945,69946,69949,69951,69953],{"class":181,"line":282},[262,69947,69948],{"class":271},"  \"model\"",[262,69950,1231],{"class":429},[262,69952,1207],{"class":275},[262,69954,1315],{"class":429},[262,69956,69957,69960],{"class":181,"line":295},[262,69958,69959],{"class":271},"  \"messages\"",[262,69961,40701],{"class":429},[262,69963,69964,69966,69968,69970,69972,69974,69976,69978,69981],{"class":181,"line":345},[262,69965,42305],{"class":429},[262,69967,1228],{"class":271},[262,69969,1231],{"class":429},[262,69971,1234],{"class":275},[262,69973,608],{"class":429},[262,69975,1239],{"class":271},[262,69977,1231],{"class":429},[262,69979,69980],{"class":275},"\"You are a helpful assistant.\"",[262,69982,3143],{"class":429},[262,69984,69985,69987,69989,69991,69993,69995,69997,69999,70002],{"class":181,"line":492},[262,69986,42305],{"class":429},[262,69988,1228],{"class":271},[262,69990,1231],{"class":429},[262,69992,1291],{"class":275},[262,69994,608],{"class":429},[262,69996,1239],{"class":271},[262,69998,1231],{"class":429},[262,70000,70001],{"class":275},"\"Summarise this review in five words.\"",[262,70003,16430],{"class":429},[262,70005,70006],{"class":181,"line":503},[262,70007,70008],{"class":429},"  ],\n",[262,70010,70011,70014,70016],{"class":181,"line":521},[262,70012,70013],{"class":271},"  \"temperature\"",[262,70015,1231],{"class":429},[262,70017,70018],{"class":271},"0.3\n",[262,70020,70021],{"class":181,"line":537},[262,70022,16430],{"class":429},[14,70024,3349,70025,70027,70028,70030,70031,70033,70034,70036,70037,70039,70040,70042,70043,70045],{},[18,70026,43003],{}," tells the model who is speaking. A ",[18,70029,4466],{}," message sets behaviour and rules, a ",[18,70032,4470],{}," message is the actual request, and an ",[18,70035,43011],{}," message is something the model said earlier in the conversation. The ",[18,70038,3829],{}," controls randomness: low values (around ",[18,70041,27811],{},") give consistent, predictable answers, higher values (around ",[18,70044,8365],{},") give more varied, creative ones. For anything where you need to parse the result, like the tagging project later, keep temperature low so the format stays stable.",[14,70047,70048,70049,70051],{},"A few habits make payloads easy to live with. Keep your system message short and specific, because a vague instruction produces vague output. Send only the data the task actually needs, both to save money and because a model given a wall of irrelevant text often loses the thread. And remember that the model has no memory between requests: each call starts fresh, so if you want it to remember the last answer, you have to include that answer in the ",[18,70050,43269],{}," list yourself. Everything the model knows about the current task must be inside the payload you send.",[14,70053,70054],{},"The response comes back as JSON too. The reply text is nested a few layers deep, which trips up most beginners. Here is the part you care about:",[253,70056,70058],{"className":414,"code":70057,"language":416,"meta":258,"style":258},"# Reading the same response with plain Python after an httpx call\ndata = response.json()                       # turn JSON text into a dict\ntext = data[\"choices\"][0][\"message\"][\"content\"]\ntokens_used = data[\"usage\"][\"total_tokens\"]  # how much this call \"cost\"\nprint(text, \"—\", tokens_used, \"tokens\")\n",[18,70059,70060,70065,70078,70104,70126],{"__ignoreMap":258},[262,70061,70062],{"class":181,"line":264},[262,70063,70064],{"class":291},"# Reading the same response with plain Python after an httpx call\n",[262,70066,70067,70070,70072,70075],{"class":181,"line":282},[262,70068,70069],{"class":429},"data ",[262,70071,476],{"class":377},[262,70073,70074],{"class":429}," response.json()                       ",[262,70076,70077],{"class":291},"# turn JSON text into a dict\n",[262,70079,70080,70083,70085,70087,70090,70092,70094,70096,70098,70100,70102],{"class":181,"line":295},[262,70081,70082],{"class":429},"text ",[262,70084,476],{"class":377},[262,70086,18181],{"class":429},[262,70088,70089],{"class":275},"\"choices\"",[262,70091,6163],{"class":429},[262,70093,102],{"class":271},[262,70095,6163],{"class":429},[262,70097,56837],{"class":275},[262,70099,6163],{"class":429},[262,70101,1239],{"class":275},[262,70103,957],{"class":429},[262,70105,70106,70109,70111,70113,70116,70118,70121,70123],{"class":181,"line":345},[262,70107,70108],{"class":429},"tokens_used ",[262,70110,476],{"class":377},[262,70112,18181],{"class":429},[262,70114,70115],{"class":275},"\"usage\"",[262,70117,6163],{"class":429},[262,70119,70120],{"class":275},"\"total_tokens\"",[262,70122,38063],{"class":429},[262,70124,70125],{"class":291},"# how much this call \"cost\"\n",[262,70127,70128,70130,70133,70136,70139,70142],{"class":181,"line":492},[262,70129,637],{"class":271},[262,70131,70132],{"class":429},"(text, ",[262,70134,70135],{"class":275},"\"—\"",[262,70137,70138],{"class":429},", tokens_used, ",[262,70140,70141],{"class":275},"\"tokens\"",[262,70143,660],{"class":429},[14,70145,70146,70147,70149,70150,70152,70153,1363],{},"A token is a chunk of text, roughly three-quarters of a word, and providers charge by the token and limit how many fit in one request. The ",[18,70148,58158],{}," block tells you exactly how many you used. If you ever see a \"context length exceeded\" message, you sent too many at once; the fix is in ",[51,70151,1513],{"href":1512},". When the response is not valid JSON at all, usually because the request failed, see ",[51,70154,6114],{"href":6113},[14,70156,70157,70158,70161,70162,70164,70165,70168,70169,70172,70173,70176,70177,70180,70181,70183],{},"The bracket-and-quote path, ",[18,70159,70160],{},"data[\"choices\"][0][\"message\"][\"content\"]",", reads left to right like a set of nested drawers. ",[18,70163,31990],{}," is the whole response; ",[18,70166,70167],{},"[\"choices\"]"," opens the drawer holding possible answers; ",[18,70170,70171],{},"[0]"," takes the first one (there is usually only one); ",[18,70174,70175],{},"[\"message\"]"," opens that answer; and ",[18,70178,70179],{},"[\"content\"]"," is the text itself. Practising this once on a real response, even by printing ",[18,70182,31990],{}," and reading it slowly, pays off forever, because every provider returns this same nested shape with only minor naming differences. The moment you can confidently dig the value you want out of a JSON response, the API stops feeling like a black box and starts feeling like a tool you control.",[57,70185,70187],{"id":70186},"concept-3-authentication-and-secret-management","Concept 3: Authentication and secret management",[14,70189,70190],{},"To prove your script is allowed to use the model, you send an API key, a long secret string the provider gives you. Treat it like a password. Anyone who has it can spend your money. The number one mistake beginners make is pasting the key directly into a script and then sharing that script, posting it online, or committing it to GitHub where bots scrape it within minutes.",[14,70192,70193,70194,70196,70197,22741],{},"The safe pattern is to keep the key in a separate ",[18,70195,319],{}," file that never leaves your machine. Create a file called ",[18,70198,319],{},[253,70200,70202],{"className":323,"code":70201,"language":325,"meta":258,"style":258},"# .env  — never commit this file\nOPENAI_API_KEY=sk-proj-your-real-key-here\nMODEL_NAME=gpt-4o-mini\n",[18,70203,70204,70209,70213],{"__ignoreMap":258},[262,70205,70206],{"class":181,"line":264},[262,70207,70208],{},"# .env  — never commit this file\n",[262,70210,70211],{"class":181,"line":282},[262,70212,24575],{},[262,70214,70215],{"class":181,"line":295},[262,70216,70217],{},"MODEL_NAME=gpt-4o-mini\n",[14,70219,70220,70221,70223,70224,26616],{},"Then load it into your script with ",[18,70222,2501],{},", which reads the file and makes each line available through ",[18,70225,70226],{},"os.getenv",[253,70228,70230],{"className":414,"code":70229,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\n\nload_dotenv()                              # reads .env into the environment\napi_key = os.getenv(\"OPENAI_API_KEY\")\nmodel = os.getenv(\"MODEL_NAME\", \"gpt-4o-mini\")  # second arg is a fallback\n\nif not api_key:\n    raise SystemExit(\"No API key found. Did you create a .env file?\")\n",[18,70231,70232,70238,70248,70252,70259,70271,70292,70296,70305],{"__ignoreMap":258},[262,70233,70234,70236],{"class":181,"line":264},[262,70235,684],{"class":377},[262,70237,687],{"class":429},[262,70239,70240,70242,70244,70246],{"class":181,"line":282},[262,70241,705],{"class":377},[262,70243,708],{"class":429},[262,70245,684],{"class":377},[262,70247,713],{"class":429},[262,70249,70250],{"class":181,"line":295},[262,70251,583],{"emptyLinePlaceholder":582},[262,70253,70254,70257],{"class":181,"line":345},[262,70255,70256],{"class":429},"load_dotenv()                              ",[262,70258,20118],{"class":291},[262,70260,70261,70263,70265,70267,70269],{"class":181,"line":492},[262,70262,67390],{"class":429},[262,70264,476],{"class":377},[262,70266,754],{"class":429},[262,70268,2681],{"class":275},[262,70270,660],{"class":429},[262,70272,70273,70276,70278,70280,70283,70285,70287,70289],{"class":181,"line":503},[262,70274,70275],{"class":429},"model ",[262,70277,476],{"class":377},[262,70279,754],{"class":429},[262,70281,70282],{"class":275},"\"MODEL_NAME\"",[262,70284,608],{"class":429},[262,70286,1207],{"class":275},[262,70288,32223],{"class":429},[262,70290,70291],{"class":291},"# second arg is a fallback\n",[262,70293,70294],{"class":181,"line":521},[262,70295,583],{"emptyLinePlaceholder":582},[262,70297,70298,70300,70302],{"class":181,"line":537},[262,70299,2210],{"class":377},[262,70301,2818],{"class":377},[262,70303,70304],{"class":429}," api_key:\n",[262,70306,70307,70309,70312,70314,70317],{"class":181,"line":549},[262,70308,2829],{"class":377},[262,70310,70311],{"class":271}," SystemExit",[262,70313,602],{"class":429},[262,70315,70316],{"class":275},"\"No API key found. Did you create a .env file?\"",[262,70318,660],{"class":429},[14,70320,70321,70322,356,70324,70326],{},"Critically, add ",[18,70323,319],{},[18,70325,359],{}," so it is never committed to version control:",[253,70328,70329],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,70330,70331],{"__ignoreMap":258},[262,70332,70333,70335,70337,70339],{"class":181,"line":264},[262,70334,371],{"class":271},[262,70336,374],{"class":275},[262,70338,378],{"class":377},[262,70340,381],{"class":275},[14,70342,70343,70344,70347,70348,1363],{},"With this in place, your code references ",[18,70345,70346],{},"os.getenv(\"OPENAI_API_KEY\")"," and the actual secret stays in one local file you never share. If a request comes back rejected, you most likely have a missing, wrong, or unloaded key; the exact diagnosis is in ",[51,70349,388],{"href":387},[14,70351,70352,70353,70355,70356,70358,70359,21219,70361,70363],{},"This pattern matters more than it first appears. Leaked keys are one of the most common and most expensive beginner mistakes: scrapers constantly scan public repositories for strings that look like API keys, and a found key can rack up real charges before you notice. The ",[18,70354,319],{}," approach also keeps your code portable. When you move a script to another machine or hand it to a teammate, they create their own ",[18,70357,319],{}," with their own key, and nothing in your shared code ever has to change. If you suspect a key has been exposed, do not try to hide it; go to the provider's dashboard and revoke it, then generate a new one. A revoked key is harmless no matter who has it. Build the ",[18,70360,319],{},[18,70362,359],{}," habit on your very first project and you will never have to unlearn the dangerous shortcut of hard-coding secrets.",[57,70365,70367],{"id":70366},"concept-4-error-handling-and-retries","Concept 4: Error handling and retries",[14,70369,70370,70371,70373],{},"Networks hiccup and providers throttle busy accounts, so a script that assumes every request succeeds will eventually crash mid-job. Real workflows wrap each call in error handling and retry failed requests with a growing delay between attempts, a pattern called exponential backoff (wait 1 second, then 2, then 4). This is where raw ",[18,70372,5450],{}," is instructive, because it shows the status codes the SDK normally hides.",[253,70375,70377],{"className":414,"code":70376,"language":416,"meta":258,"style":258},"import time\nimport httpx\n\n\ndef call_model(api_key: str, payload: dict, max_retries: int = 3) -> dict:\n    url = \"https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fchat\u002Fcompletions\"\n    headers = {\"Authorization\": f\"Bearer {api_key}\"}\n\n    for attempt in range(max_retries):\n        try:\n            r = httpx.post(url, json=payload, headers=headers, timeout=30.0)\n            r.raise_for_status()           # raises if status is 4xx or 5xx\n            return r.json()\n        except httpx.HTTPStatusError as e:\n            status = e.response.status_code\n            if status == 429 and attempt \u003C max_retries - 1:\n                wait = 2 ** attempt        # 1s, then 2s, then 4s\n                print(f\"Rate limited. Waiting {wait}s...\")\n                time.sleep(wait)\n                continue\n            raise                          # any other error: stop and report\n    raise RuntimeError(\"Gave up after retries\")\n",[18,70378,70379,70385,70391,70395,70399,70430,70439,70465,70469,70481,70487,70517,70525,70532,70542,70552,70578,70594,70615,70619,70623,70630],{"__ignoreMap":258},[262,70380,70381,70383],{"class":181,"line":264},[262,70382,684],{"class":377},[262,70384,2612],{"class":429},[262,70386,70387,70389],{"class":181,"line":282},[262,70388,684],{"class":377},[262,70390,6526],{"class":429},[262,70392,70393],{"class":181,"line":295},[262,70394,583],{"emptyLinePlaceholder":582},[262,70396,70397],{"class":181,"line":345},[262,70398,583],{"emptyLinePlaceholder":582},[262,70400,70401,70403,70406,70409,70411,70414,70416,70418,70420,70422,70424,70426,70428],{"class":181,"line":492},[262,70402,423],{"class":377},[262,70404,70405],{"class":267}," call_model",[262,70407,70408],{"class":429},"(api_key: ",[262,70410,433],{"class":271},[262,70412,70413],{"class":429},", payload: ",[262,70415,5869],{"class":271},[262,70417,3007],{"class":429},[262,70419,439],{"class":271},[262,70421,442],{"class":377},[262,70423,931],{"class":271},[262,70425,1939],{"class":429},[262,70427,5869],{"class":271},[262,70429,1160],{"class":429},[262,70431,70432,70434,70436],{"class":181,"line":503},[262,70433,26041],{"class":429},[262,70435,476],{"class":377},[262,70437,70438],{"class":275}," \"https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fchat\u002Fcompletions\"\n",[262,70440,70441,70443,70445,70447,70449,70451,70453,70455,70457,70459,70461,70463],{"class":181,"line":521},[262,70442,16991],{"class":429},[262,70444,476],{"class":377},[262,70446,2276],{"class":429},[262,70448,16998],{"class":275},[262,70450,1231],{"class":429},[262,70452,642],{"class":377},[262,70454,6605],{"class":275},[262,70456,3039],{"class":271},[262,70458,2674],{"class":429},[262,70460,654],{"class":271},[262,70462,1176],{"class":275},[262,70464,16430],{"class":429},[262,70466,70467],{"class":181,"line":537},[262,70468,583],{"emptyLinePlaceholder":582},[262,70470,70471,70473,70475,70477,70479],{"class":181,"line":549},[262,70472,3074],{"class":377},[262,70474,3077],{"class":429},[262,70476,835],{"class":377},[262,70478,3082],{"class":271},[262,70480,3085],{"class":429},[262,70482,70483,70485],{"class":181,"line":570},[262,70484,3090],{"class":377},[262,70486,1160],{"class":429},[262,70488,70489,70492,70494,70497,70499,70501,70503,70505,70507,70509,70511,70513,70515],{"class":181,"line":579},[262,70490,70491],{"class":429},"            r ",[262,70493,476],{"class":377},[262,70495,70496],{"class":429}," httpx.post(url, ",[262,70498,17049],{"class":611},[262,70500,476],{"class":377},[262,70502,17054],{"class":429},[262,70504,17057],{"class":611},[262,70506,476],{"class":377},[262,70508,19503],{"class":429},[262,70510,1591],{"class":611},[262,70512,476],{"class":377},[262,70514,6692],{"class":271},[262,70516,660],{"class":429},[262,70518,70519,70522],{"class":181,"line":586},[262,70520,70521],{"class":429},"            r.raise_for_status()           ",[262,70523,70524],{"class":291},"# raises if status is 4xx or 5xx\n",[262,70526,70527,70529],{"class":181,"line":591},[262,70528,3198],{"class":377},[262,70530,70531],{"class":429}," r.json()\n",[262,70533,70534,70536,70538,70540],{"class":181,"line":623},[262,70535,3214],{"class":377},[262,70537,42568],{"class":429},[262,70539,697],{"class":377},[262,70541,11457],{"class":429},[262,70543,70544,70547,70549],{"class":181,"line":634},[262,70545,70546],{"class":429},"            status ",[262,70548,476],{"class":377},[262,70550,70551],{"class":429}," e.response.status_code\n",[262,70553,70554,70556,70559,70561,70563,70565,70567,70569,70572,70574,70576],{"class":181,"line":845},[262,70555,10200],{"class":377},[262,70557,70558],{"class":429}," status ",[262,70560,10758],{"class":377},[262,70562,42584],{"class":271},[262,70564,33508],{"class":377},[262,70566,3077],{"class":429},[262,70568,512],{"class":377},[262,70570,70571],{"class":429}," max_retries ",[262,70573,561],{"class":377},[262,70575,3243],{"class":271},[262,70577,1160],{"class":429},[262,70579,70580,70582,70584,70586,70588,70591],{"class":181,"line":850},[262,70581,42591],{"class":429},[262,70583,476],{"class":377},[262,70585,3232],{"class":271},[262,70587,3235],{"class":377},[262,70589,70590],{"class":429}," attempt        ",[262,70592,70593],{"class":291},"# 1s, then 2s, then 4s\n",[262,70595,70596,70598,70600,70602,70605,70607,70609,70611,70613],{"class":181,"line":864},[262,70597,10208],{"class":271},[262,70599,602],{"class":429},[262,70601,642],{"class":377},[262,70603,70604],{"class":275},"\"Rate limited. Waiting ",[262,70606,3039],{"class":271},[262,70608,3295],{"class":429},[262,70610,654],{"class":271},[262,70612,3300],{"class":275},[262,70614,660],{"class":429},[262,70616,70617],{"class":181,"line":1683},[262,70618,42635],{"class":429},[262,70620,70621],{"class":181,"line":1688},[262,70622,10235],{"class":377},[262,70624,70625,70627],{"class":181,"line":1693},[262,70626,9850],{"class":377},[262,70628,70629],{"class":291},"                          # any other error: stop and report\n",[262,70631,70632,70634,70636,70638,70641],{"class":181,"line":1728},[262,70633,2829],{"class":377},[262,70635,3318],{"class":271},[262,70637,602],{"class":429},[262,70639,70640],{"class":275},"\"Gave up after retries\"",[262,70642,660],{"class":429},[14,70644,70645,70646,70648,70649,70651,70652,70654,70655,57002,70657,70659],{},"Status code ",[18,70647,59190],{}," means \"too many requests, slow down\", which is exactly the case worth retrying. Codes like ",[18,70650,41445],{}," (bad key) or ",[18,70653,178],{}," (malformed request) will never fix themselves, so the code re-raises them immediately instead of looping pointlessly. When you hit throttling repeatedly, the full remedy is in ",[51,70656,3379],{"href":3378},[18,70658,20],{}," SDK retries automatically, but writing this once teaches you what reliability actually requires.",[14,70661,70662,70663,70665,70666,70668,70669,70671,70672,70674,70675,70677],{},"Two ideas in that code are worth keeping for every script you write. The first is the ",[18,70664,1591],{},". Without it, a single stalled request can hang your whole program forever, waiting for a reply that never comes; thirty seconds is a sensible ceiling. The second is being deliberate about which errors you retry. Retrying a temporary problem like rate limiting is smart, but retrying a permanent one like a wrong API key just wastes time and money while the same failure repeats. The skill is telling them apart: roughly, codes in the ",[18,70667,16427],{}," range and ",[18,70670,59190],{}," are worth a retry because they are the server's fault or a temporary limit, while ",[18,70673,178],{},"-range codes other than ",[18,70676,59190],{}," mean your request itself is wrong and need a code fix instead. This same defensive thinking, do not block forever, retry only what can recover, scales straight from one call into the unattended workflows covered later.",[57,70679,70681],{"id":70680},"mini-project-tag-customer-feedback-automatically","Mini-project: tag customer feedback automatically",[14,70683,70684,70685,70687],{},"Now stitch all four concepts into one script that does a job you would otherwise do by hand. It reads a CSV of customer comments, asks the model to label each one's sentiment and topic as structured JSON, and saves the results to a new file. It uses the SDK for clarity, loads its key from ",[18,70686,319],{},", sends a clear system prompt, and handles failures so one bad row does not sink the whole run.",[253,70689,70691],{"className":414,"code":70690,"language":416,"meta":258,"style":258},"# tag_feedback.py\nimport json\nimport pandas as pd\nfrom openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment \u002F .env\n\nSYSTEM = (\n    \"You are a feedback analyst. For each comment, reply with ONLY a JSON \"\n    'object: {\"sentiment\": \"positive|neutral|negative\", \"topic\": \"\u003C2-3 words>\"}.'\n)\n\n\ndef tag(comment: str) -> dict:\n    try:\n        resp = client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            temperature=0,\n            response_format={\"type\": \"json_object\"},  # forces valid JSON back\n            messages=[\n                {\"role\": \"system\", \"content\": SYSTEM},\n                {\"role\": \"user\", \"content\": comment},\n            ],\n        )\n        return json.loads(resp.choices[0].message.content)\n    except Exception as err:                # never let one row crash the batch\n        return {\"sentiment\": \"error\", \"topic\": str(err)[:40]}\n\n\ndf = pd.read_csv(\"feedback.csv\")            # needs a column named \"comment\"\nresults = df[\"comment\"].apply(tag).apply(pd.Series)\ndf = pd.concat([df, results], axis=1)\ndf.to_csv(\"feedback_tagged.csv\", index=False)\nprint(f\"Tagged {len(df)} comments -> feedback_tagged.csv\")\n",[18,70692,70693,70698,70704,70714,70724,70728,70739,70743,70752,70757,70762,70766,70770,70774,70792,70798,70806,70816,70826,70846,70854,70874,70891,70895,70899,70909,70923,70950,70954,70958,70974,70989,71006,71023],{"__ignoreMap":258},[262,70694,70695],{"class":181,"line":264},[262,70696,70697],{"class":291},"# tag_feedback.py\n",[262,70699,70700,70702],{"class":181,"line":282},[262,70701,684],{"class":377},[262,70703,5766],{"class":429},[262,70705,70706,70708,70710,70712],{"class":181,"line":295},[262,70707,684],{"class":377},[262,70709,2619],{"class":429},[262,70711,697],{"class":377},[262,70713,2624],{"class":429},[262,70715,70716,70718,70720,70722],{"class":181,"line":345},[262,70717,705],{"class":377},[262,70719,720],{"class":429},[262,70721,684],{"class":377},[262,70723,725],{"class":429},[262,70725,70726],{"class":181,"line":492},[262,70727,583],{"emptyLinePlaceholder":582},[262,70729,70730,70732,70734,70736],{"class":181,"line":503},[262,70731,739],{"class":429},[262,70733,476],{"class":377},[262,70735,9578],{"class":429},[262,70737,70738],{"class":291},"# reads OPENAI_API_KEY from the environment \u002F .env\n",[262,70740,70741],{"class":181,"line":521},[262,70742,583],{"emptyLinePlaceholder":582},[262,70744,70745,70748,70750],{"class":181,"line":537},[262,70746,70747],{"class":271},"SYSTEM",[262,70749,442],{"class":377},[262,70751,984],{"class":429},[262,70753,70754],{"class":181,"line":549},[262,70755,70756],{"class":275},"    \"You are a feedback analyst. For each comment, reply with ONLY a JSON \"\n",[262,70758,70759],{"class":181,"line":570},[262,70760,70761],{"class":275},"    'object: {\"sentiment\": \"positive|neutral|negative\", \"topic\": \"\u003C2-3 words>\"}.'\n",[262,70763,70764],{"class":181,"line":579},[262,70765,660],{"class":429},[262,70767,70768],{"class":181,"line":586},[262,70769,583],{"emptyLinePlaceholder":582},[262,70771,70772],{"class":181,"line":591},[262,70773,583],{"emptyLinePlaceholder":582},[262,70775,70776,70778,70781,70784,70786,70788,70790],{"class":181,"line":623},[262,70777,423],{"class":377},[262,70779,70780],{"class":267}," tag",[262,70782,70783],{"class":429},"(comment: ",[262,70785,433],{"class":271},[262,70787,1939],{"class":429},[262,70789,5869],{"class":271},[262,70791,1160],{"class":429},[262,70793,70794,70796],{"class":181,"line":634},[262,70795,14474],{"class":377},[262,70797,1160],{"class":429},[262,70799,70800,70802,70804],{"class":181,"line":845},[262,70801,17037],{"class":429},[262,70803,476],{"class":377},[262,70805,1189],{"class":429},[262,70807,70808,70810,70812,70814],{"class":181,"line":850},[262,70809,14214],{"class":611},[262,70811,476],{"class":377},[262,70813,1207],{"class":275},[262,70815,1315],{"class":429},[262,70817,70818,70820,70822,70824],{"class":181,"line":864},[262,70819,27275],{"class":611},[262,70821,476],{"class":377},[262,70823,102],{"class":271},[262,70825,1315],{"class":429},[262,70827,70828,70831,70833,70835,70837,70839,70841,70843],{"class":181,"line":1683},[262,70829,70830],{"class":611},"            response_format",[262,70832,476],{"class":377},[262,70834,3039],{"class":429},[262,70836,6025],{"class":275},[262,70838,1231],{"class":429},[262,70840,6030],{"class":275},[262,70842,59222],{"class":429},[262,70844,70845],{"class":291},"# forces valid JSON back\n",[262,70847,70848,70850,70852],{"class":181,"line":1688},[262,70849,27253],{"class":611},[262,70851,476],{"class":377},[262,70853,1220],{"class":429},[262,70855,70856,70858,70860,70862,70864,70866,70868,70870,70872],{"class":181,"line":1693},[262,70857,53817],{"class":429},[262,70859,1228],{"class":275},[262,70861,1231],{"class":429},[262,70863,1234],{"class":275},[262,70865,608],{"class":429},[262,70867,1239],{"class":275},[262,70869,1231],{"class":429},[262,70871,70747],{"class":271},[262,70873,3143],{"class":429},[262,70875,70876,70878,70880,70882,70884,70886,70888],{"class":181,"line":1728},[262,70877,53817],{"class":429},[262,70879,1228],{"class":275},[262,70881,1231],{"class":429},[262,70883,1291],{"class":275},[262,70885,608],{"class":429},[262,70887,1239],{"class":275},[262,70889,70890],{"class":429},": comment},\n",[262,70892,70893],{"class":181,"line":1737},[262,70894,53856],{"class":429},[262,70896,70897],{"class":181,"line":1751},[262,70898,6288],{"class":429},[262,70900,70901,70903,70905,70907],{"class":181,"line":1764},[262,70902,8066],{"class":377},[262,70904,34271],{"class":429},[262,70906,102],{"class":271},[262,70908,6048],{"class":429},[262,70910,70911,70913,70915,70917,70920],{"class":181,"line":1779},[262,70912,14522],{"class":377},[262,70914,10361],{"class":271},[262,70916,10364],{"class":377},[262,70918,70919],{"class":429}," err:                ",[262,70921,70922],{"class":291},"# never let one row crash the batch\n",[262,70924,70925,70927,70929,70931,70933,70935,70937,70939,70941,70943,70946,70948],{"class":181,"line":1793},[262,70926,8066],{"class":377},[262,70928,2276],{"class":429},[262,70930,40495],{"class":275},[262,70932,1231],{"class":429},[262,70934,68474],{"class":275},[262,70936,608],{"class":429},[262,70938,32606],{"class":275},[262,70940,1231],{"class":429},[262,70942,433],{"class":271},[262,70944,70945],{"class":429},"(err)[:",[262,70947,23367],{"class":271},[262,70949,56904],{"class":429},[262,70951,70952],{"class":181,"line":1800},[262,70953,583],{"emptyLinePlaceholder":582},[262,70955,70956],{"class":181,"line":1805},[262,70957,583],{"emptyLinePlaceholder":582},[262,70959,70960,70962,70964,70966,70969,70971],{"class":181,"line":1810},[262,70961,2755],{"class":429},[262,70963,476],{"class":377},[262,70965,2760],{"class":429},[262,70967,70968],{"class":275},"\"feedback.csv\"",[262,70970,32670],{"class":429},[262,70972,70973],{"class":291},"# needs a column named \"comment\"\n",[262,70975,70976,70979,70981,70983,70986],{"class":181,"line":1823},[262,70977,70978],{"class":429},"results ",[262,70980,476],{"class":377},[262,70982,27464],{"class":429},[262,70984,70985],{"class":275},"\"comment\"",[262,70987,70988],{"class":429},"].apply(tag).apply(pd.Series)\n",[262,70990,70991,70993,70995,70998,71000,71002,71004],{"class":181,"line":1846},[262,70992,2755],{"class":429},[262,70994,476],{"class":377},[262,70996,70997],{"class":429}," pd.concat([df, results], ",[262,70999,992],{"class":611},[262,71001,476],{"class":377},[262,71003,997],{"class":271},[262,71005,660],{"class":429},[262,71007,71008,71010,71013,71015,71017,71019,71021],{"class":181,"line":1861},[262,71009,3730],{"class":429},[262,71011,71012],{"class":275},"\"feedback_tagged.csv\"",[262,71014,608],{"class":429},[262,71016,3618],{"class":611},[262,71018,476],{"class":377},[262,71020,3623],{"class":271},[262,71022,660],{"class":429},[262,71024,71025,71027,71029,71031,71034,71036,71038,71040,71043],{"class":181,"line":1866},[262,71026,637],{"class":271},[262,71028,602],{"class":429},[262,71030,642],{"class":377},[262,71032,71033],{"class":275},"\"Tagged ",[262,71035,648],{"class":271},[262,71037,2780],{"class":429},[262,71039,654],{"class":271},[262,71041,71042],{"class":275}," comments -> feedback_tagged.csv\"",[262,71044,660],{"class":429},[14,71046,42969,71047,60315,71050,71053,71054,71057,71058,1374,71060,71062,71063,71065,71066,1363],{},[18,71048,71049],{},"feedback.csv",[18,71051,71052],{},"comment"," column, run ",[18,71055,71056],{},"python tag_feedback.py",", and you get a new file with ",[18,71059,40363],{},[18,71061,4402],{}," columns filled in. That is a real automation: the same loop scales from five rows to five thousand. To turn it into a scheduled, fully unattended job, follow ",[51,71064,21230],{"href":21229},"; to sharpen the instructions so the labels are even more consistent, see how to ",[51,71067,1362],{"href":1361},[14,71069,71070,71071,71074,71075,71077,71078,71080,71081,981,71083,71085],{},"Look at how each of the four concepts appears in those twenty-odd lines. Concept 1 is the ",[18,71072,71073],{},"client.chat.completions.create"," call that reaches the AI service. Concept 2 is the JSON payload going out (model, messages, format) and the structured JSON coming back, which ",[18,71076,20396],{}," turns into a dictionary you can read. Concept 3 is invisible but present: the client reads the key from your ",[18,71079,319],{},", so no secret appears in the file you could safely share. Concept 4 is the ",[18,71082,14430],{},[18,71084,14433],{}," that catches any failure and records it in the row instead of crashing the run, so a single odd comment never costs you the other 4,999 results. That layering, secure key, clean data in, careful request, safe handling of the answer, is the template behind almost every practical script in this niche. Change the system prompt and the input column and the very same skeleton drafts emails, rewrites product copy, or extracts dates from documents.",[57,71087,26296],{"id":26295},[1379,71089,71090,71098],{},[1382,71091,71092],{},[1385,71093,71094,71096],{},[1388,71095,26305],{},[1388,71097,26308],{},[1398,71099,71100,71111,71133,71147,71163,71175,71186],{},[1385,71101,71102,71105],{},[1403,71103,71104],{},"Running scripts with system Python and breaking other tools",[1403,71106,71107,71108,71110],{},"Always activate a virtual environment first (",[18,71109,30519],{},"); install everything inside it.",[1385,71112,71113,71120],{},[1403,71114,71115,71116,71119],{},"Pasting the API key straight into the ",[18,71117,71118],{},".py"," file",[1403,71121,71122,71123,71125,71126,71128,71129,3921,71131,1363],{},"Move it to ",[18,71124,319],{},", load with ",[18,71127,2501],{},", and add ",[18,71130,319],{},[18,71132,359],{},[1385,71134,71135,71141],{},[1403,71136,71137,71138,71140],{},"Reading the response as ",[18,71139,49809],{}," and getting nonsense",[1403,71142,71143,71144,71146],{},"The reply text lives at ",[18,71145,7909],{},", not the top level.",[1385,71148,71149,71152],{},[1403,71150,71151],{},"Assuming every request succeeds",[1403,71153,71154,71155,981,71157,71159,71160,71162],{},"Wrap calls in ",[18,71156,14430],{},[18,71158,14433],{}," and retry ",[18,71161,59190],{}," errors with exponential backoff.",[1385,71164,71165,71168],{},[1403,71166,71167],{},"Sending an entire 50-page document in one request",[1403,71169,71170,71171,71174],{},"Split long text into chunks; watch ",[18,71172,71173],{},"usage.total_tokens"," to stay under the model's limit.",[1385,71176,71177,71180],{},[1403,71178,71179],{},"Letting the model \"decide\" the output format freely",[1403,71181,71182,71183,71185],{},"Set a system prompt and ",[18,71184,6878],{}," so you can parse the answer reliably.",[1385,71187,71188,71191],{},[1403,71189,71190],{},"Feeding raw, messy spreadsheet data straight to the model",[1403,71192,71193],{},"Clean and standardise columns first so the model is not guessing what your data means.",[57,71195,2355],{"id":2354},[14,71197,71198],{},"Work through the five tracks in this order for a complete foundation:",[1447,71200,71201,71209,71221,71229,71237],{},[1450,71202,71203,71204,71206,71207,1363],{},"Get your machine ready with ",[51,71205,5423],{"href":5422},", then confirm your isolated workspace by following ",[51,71208,2482],{"href":2481},[1450,71210,71211,71212,71214,71215,24612,71218,1363],{},"Learn the request-and-response cycle in depth with ",[51,71213,2487],{"href":2486},", and bookmark the fixes for the ",[51,71216,71217],{"href":387},"401 Unauthorized error",[51,71219,71220],{"href":3378},"429 rate-limit error",[1450,71222,71223,71224,71226,71227,1363],{},"Make your prompts reliable with ",[51,71225,7554],{"href":7553},", starting from ready-made ",[51,71228,5270],{"href":5269},[1450,71230,71231,71232,71234,71235,1363],{},"Tidy your inputs with ",[51,71233,61611],{"href":61610}," and the hands-on guide to ",[51,71236,2919],{"href":2918},[1450,71238,71239,71240,71242,71243,1363],{},"Put it on autopilot with ",[51,71241,21230],{"href":21229},", for example a ",[51,71244,61248],{"href":61247},[57,71246,2381],{"id":2380},[14,71248,71249],{},"Once the fundamentals here feel comfortable, the other two main guides show you how to apply them to real outcomes.",[2322,71251,71252,71257,71262,71267],{},[1450,71253,71254,71256],{},[51,71255,5413],{"href":5412}," — apply these scripts to blog posts, social media, and SEO work.",[1450,71258,71259,71261],{},[51,71260,26457],{"href":26456}," — go further into chatbots, CRM integration, and shippable products.",[1450,71263,71264,71266],{},[51,71265,2487],{"href":2486}," — the deeper reference for everything in concepts 1 and 2.",[1450,71268,71269,71271],{},[51,71270,7554],{"href":7553}," — the next skill to master after you can make a call work.",[14,71273,2375,71274,71276],{},[51,71275,26450],{"href":26449}," — this hub is the home base for every section above.",[2401,71278,71279],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":71281},[71282,71283,71284,71287,71288,71289,71290,71291,71292,71293],{"id":24431,"depth":282,"text":24432},{"id":237,"depth":282,"text":238},{"id":69726,"depth":282,"text":69727,"children":71285},[71286],{"id":69911,"depth":295,"text":69912},{"id":69926,"depth":282,"text":69927},{"id":70186,"depth":282,"text":70187},{"id":70366,"depth":282,"text":70367},{"id":70680,"depth":282,"text":70681},{"id":26295,"depth":282,"text":26296},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Learn Python AI without a coding background: setup, virtual environments, LLM APIs, secrets, error handling, and automation workflows you can run today.",[71296,71299,71302,71305,71308],{"q":71297,"a":71298},"Do I need a computer science degree to use Python with AI?","No. You only need to install Python, copy a few short scripts, and run them. The hardest concepts here are reading a JSON response and keeping an API key secret, both of which take minutes to learn.",{"q":71300,"a":71301},"Which Python version should I install for AI work?","Install Python 3.10 or newer. The official OpenAI SDK and modern HTTP libraries assume 3.10+ syntax, and using an older version is one of the most common causes of confusing install errors.",{"q":71303,"a":71304},"Is it free to call an AI model from Python?","Most hosted models charge per token, but several providers offer a free tier with monthly limits. You can build and test everything in this guide for a few cents, or for free using a beginner-friendly free API.",{"q":71306,"a":71307},"What is the difference between the OpenAI SDK and calling the API with httpx?","The SDK is a Python wrapper that handles authentication, retries, and response parsing for you. Using httpx means you send the raw HTTP request yourself, which is useful for learning what happens under the hood or calling providers without an SDK.",{"q":71309,"a":71310},"How do I keep my API key from leaking when I share my code?","Store the key in a .env file, load it with python-dotenv, and add .env to your .gitignore so it never gets committed. Never paste a real key directly into a script you plan to publish.",{"name":71312,"steps":71313},"How to tag customer feedback with Python and an AI model",[71314,71317,71320],{"name":71315,"text":71316},"Set up the project and key","Create a virtual environment, install the openai and pandas libraries, and store your API key in a .env file.",{"name":71318,"text":71319},"Write the tagging function","Send each comment to the model with a system prompt that asks for sentiment and topic as JSON, and parse the reply.",{"name":71321,"text":71322},"Run it over a CSV","Read feedback.csv with pandas, apply the tagging function to every row, and save the labelled results to a new file.",{},"\u002Fpython-ai-fundamentals-for-non-developers",{"title":26450,"description":71294},"python-ai-fundamentals-for-non-developers\u002Findex","HNB3KIwn4IuwNKyt2dv6TCmojVe5GBjgN3mqPhmhOHE",{"id":71329,"title":71330,"body":71331,"description":73638,"extension":2419,"faq":73639,"howto":73655,"meta":73670,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":73671,"published":69420,"seo":73672,"seoTitle":71330,"stem":73673,"__hash__":73674},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Findex.md","Prompt Engineering Basics with Python",{"type":7,"value":71332,"toc":73626},[71333,71336,71339,71342,71345,71348,71356,71457,71459,71465,71474,71488,71493,71501,71509,71523,71526,71582,71586,71597,71600,71603,71812,71815,71821,71825,71828,71851,72070,72075,72079,72082,72113,72295,72302,72306,72309,72312,72315,72559,72562,72564,72567,72731,72733,72736,72829,72833,72843,73558,73569,73571,73574,73596,73600,73602,73624],[10,71334,71330],{"id":71335},"prompt-engineering-basics-with-python",[14,71337,71338],{},"You have probably typed a question into an AI chat box, got a decent answer, then tried the same thing the next day and got something messy, off-topic, or in the wrong format. That gap between \"it worked once\" and \"it works every time\" is what prompt engineering closes. Prompt engineering is simply the craft of writing the instructions you send to a language model so it returns the result you actually need, reliably and in a shape you can use.",[14,71340,71341],{},"This guide is for creators, marketers, founders, and students who can run a Python file but are not full-time programmers. You will move from typing prompts by hand into a chat window to sending them from a short, repeatable Python script. That shift matters because a script lets you reuse a proven prompt, run it across hundreds of inputs, and check the output automatically instead of eyeballing each result.",[14,71343,71344],{},"A \"language model\" (or LLM, short for large language model) is the AI behind tools like ChatGPT. When you call it from Python, you send it a list of messages and a few settings, and it sends back text. The model has no memory of earlier calls and no hidden knowledge of your intent; everything it acts on lives in the messages you send and the settings you attach. That is liberating once it clicks, because a vague result is almost never the model being stubborn. It is the prompt leaving room for interpretation, and a prompt is something you can edit and test until that room disappears.",[14,71346,71347],{},"The whole skill is therefore learning what to put in those messages and how to tune the settings. By the end you will know how to split instructions into system and user prompts, teach the model by example, force clean structured output like JSON, and iterate until a prompt is dependable. Every code block stands alone, so you can paste it into a file and run it as soon as you finish reading.",[14,71349,71350,71351,71353,71354,1363],{},"If you are brand new to Python and AI, the broader ",[51,71352,26450],{"href":26449}," hub walks through the surrounding pieces. To understand how the requests below actually travel to the model and come back, read the sibling section on ",[51,71355,2487],{"href":2486},[76,71357,71359,71454],{"className":71358},[79],[81,71360,90,71363,90,71366,90,71369,90,71376,90,71379,90,71382,90,71385,90,71389,90,71393,90,71395,90,71398,90,71402,90,71405,90,71409,71413,90,71423,90,71427,90,71430,90,71435,90,71438,90,71441,90,71444,90,71447,90,71451],{"viewBox":71361,"role":84,"ariaLabelledBy":71362,"preserveAspectRatio":88,"xmlns":89},"-40 -40 980 480",[7091,7092],[92,71364,71365],{"id":7091},"Anatomy of a prompt sent to a language model",[96,71367,71368],{"id":7092},"A messages list made of a system message, optional few-shot example pair, and a user message flows into the model and returns a structured reply.",[5548,71370,5550,71371,90],{},[5552,71372,5558,71374,5550],{"id":71373,"viewBox":7161,"refX":7162,"refY":222,"markerWidth":7163,"markerHeight":7163,"orient":7164},"arrowPe",[216,71375],{"d":7167,"fill":130},[100,71377],{"x":140,"y":140,"width":52288,"height":12819,"rx":71378,"fill":142,"stroke":143,"strokeWidth":144},"14",[111,71380,71381],{"x":103,"y":147,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":125,"textAnchor":119},"messages = [ ... ]",[100,71383],{"x":23367,"y":105,"width":71384,"height":105,"rx":106,"fill":107,"stroke":130,"strokeWidth":109},"260",[111,71386,71388],{"x":103,"y":71387,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":130,"textAnchor":119},"103","role: system",[111,71390,71392],{"x":103,"y":71391,"fontFamily":115,"fontSize":124,"fill":118,"textAnchor":119},"123","Who + the rules",[100,71394],{"x":23367,"y":37107,"width":71384,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,71396,71397],{"x":103,"y":52335,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":169,"textAnchor":119},"role: user \u002F asst",[111,71399,71401],{"x":103,"y":71400,"fontFamily":115,"fontSize":124,"fill":118,"textAnchor":119},"215","Few-shot pairs",[100,71403],{"x":23367,"y":71404,"width":71384,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"256",[111,71406,71408],{"x":103,"y":71407,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":169,"textAnchor":119},"287","role: user",[111,71410,71412],{"x":103,"y":71411,"fontFamily":115,"fontSize":124,"fill":118,"textAnchor":119},"307","The real task",[111,71414,71416,71417,71420],{"x":103,"y":71415,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"362","\n+ temperature,\n",[175,71418,71419],{"x":103,"dy":177},"\nmax_tokens,\n",[175,71421,71422],{"x":103,"dy":177},"\nresponse_format\n",[216,71424],{"d":71425,"fill":219,"stroke":130,"strokeWidth":109,"markerEnd":71426},"M328 210 L408 210","url(#arrowPe)",[100,71428],{"x":71429,"y":69474,"width":104,"height":105,"rx":106,"fill":130,"stroke":169,"strokeWidth":109},"412",[111,71431,71434],{"x":71432,"y":71433,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":142,"textAnchor":119},"512","205","Language",[111,71436,805],{"x":71432,"y":71437,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":142,"textAnchor":119},"225",[216,71439],{"d":71440,"fill":219,"stroke":130,"strokeWidth":109,"markerEnd":71426},"M620 210 L692 210",[100,71442],{"x":71443,"y":52289,"width":104,"height":113,"rx":106,"fill":107,"stroke":143,"strokeWidth":144},"700",[111,71445,71446],{"x":23397,"y":37129,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Reply",[111,71448,71450],{"x":23397,"y":71449,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"212","{ \"title\": \"...\",",[111,71452,71453],{"x":23397,"y":19862,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"\"tags\": [] }",[232,71455,71456],{},"A prompt is a list of role-tagged messages plus settings. The system message rules them all, examples teach the pattern, and the user message asks the question.",[57,71458,238],{"id":237},[14,71460,71461,71462,71464],{},"You need Python 3.10 or newer and a working virtual environment. If you have not set one up, follow ",[51,71463,5423],{"href":5422}," first, then come back here. You also need an OpenAI API key, which is a secret password that lets your code talk to the model.",[14,71466,71467,71468,71470,71471,71473],{},"Install the two packages used throughout this guide. The ",[18,71469,20],{}," package is the official SDK (software development kit, the ready-made code that talks to the API for you), and ",[18,71472,2501],{}," reads secrets from a file so they never end up in your code.",[253,71475,71476],{"className":255,"code":4112,"language":257,"meta":258,"style":258},[18,71477,71478],{"__ignoreMap":258},[262,71479,71480,71482,71484,71486],{"class":181,"line":264},[262,71481,298],{"class":267},[262,71483,301],{"class":275},[262,71485,2519],{"class":275},[262,71487,2522],{"class":275},[14,71489,2525,71490,71492],{},[18,71491,319],{}," in your project folder and paste your key into it:",[253,71494,71495],{"className":323,"code":24575,"language":325,"meta":258,"style":258},[18,71496,71497],{"__ignoreMap":258},[262,71498,71499],{"class":181,"line":264},[262,71500,24575],{},[14,71502,71503,71504,356,71506,71508],{},"Before you do anything else, add ",[18,71505,319],{},[18,71507,359],{}," file so your secret key is never committed to version control or pushed to a public repository. One leaked key can run up a large bill on your account.",[253,71510,71511],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,71512,71513],{"__ignoreMap":258},[262,71514,71515,71517,71519,71521],{"class":181,"line":264},[262,71516,371],{"class":271},[262,71518,374],{"class":275},[262,71520,378],{"class":377},[262,71522,381],{"class":275},[14,71524,71525],{},"With that in place, the boilerplate at the top of every script in this guide loads your key once:",[253,71527,71528],{"className":414,"code":12998,"language":416,"meta":258,"style":258},[18,71529,71530,71536,71546,71556,71560,71564],{"__ignoreMap":258},[262,71531,71532,71534],{"class":181,"line":264},[262,71533,684],{"class":377},[262,71535,687],{"class":429},[262,71537,71538,71540,71542,71544],{"class":181,"line":282},[262,71539,705],{"class":377},[262,71541,708],{"class":429},[262,71543,684],{"class":377},[262,71545,713],{"class":429},[262,71547,71548,71550,71552,71554],{"class":181,"line":295},[262,71549,705],{"class":377},[262,71551,720],{"class":429},[262,71553,684],{"class":377},[262,71555,725],{"class":429},[262,71557,71558],{"class":181,"line":345},[262,71559,583],{"emptyLinePlaceholder":582},[262,71561,71562],{"class":181,"line":492},[262,71563,734],{"class":429},[262,71565,71566,71568,71570,71572,71574,71576,71578,71580],{"class":181,"line":503},[262,71567,739],{"class":429},[262,71569,476],{"class":377},[262,71571,1588],{"class":429},[262,71573,2674],{"class":611},[262,71575,476],{"class":377},[262,71577,1199],{"class":429},[262,71579,2681],{"class":275},[262,71581,2684],{"class":429},[57,71583,71585],{"id":71584},"step-1-separate-the-system-prompt-from-the-user-prompt","Step 1 — Separate the system prompt from the user prompt",[14,71587,71588,71589,71591,71592,1374,71594,71596],{},"Every request you send is a list of messages, and each message has a ",[18,71590,43003],{},". The two roles you will use most are ",[18,71593,4466],{},[18,71595,4470],{},". The system prompt is where you tell the model who it is, what rules to follow, and how to behave for the entire conversation. The user prompt is the specific thing you want done right now. Keeping them separate is the single biggest upgrade over typing one long blob into a chat box: the rules stay constant while the task changes.",[14,71598,71599],{},"Think of it like hiring an assistant. The system prompt is the job description and house style you give them on day one: who they are, what they must never do, the tone they speak in, and the format every deliverable should follow. The user prompt is the task you hand them each morning. You write the job description once and reuse it forever, while the morning tasks change endlessly without ever touching the rules.",[14,71601,71602],{},"There is a practical reason this matters beyond tidiness. When the rules and the task share one blob of text, the model has to guess where your standing instructions end and the request begins, and it sometimes treats data as an instruction or vice versa. Splitting them into separate roles removes that ambiguity. The system message carries weight across the whole exchange, so anything you put there (\"always reply in one sentence\") applies to every user message that follows, even ones you have not written yet.",[253,71604,71606],{"className":414,"code":71605,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef summarize(text: str) -> str:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"You are a concise editor. Summarize the user's text in \"\n                    \"exactly one sentence. Never add opinions or extra detail.\"\n                ),\n            },\n            {\"role\": \"user\", \"content\": text},\n        ],\n        temperature=0.2,\n    )\n    return response.choices[0].message.content\n\n\nprint(summarize(\"Our quarterly sales rose 12 percent, driven mostly by repeat customers in Europe.\"))\n",[18,71607,71608,71614,71624,71634,71638,71642,71660,71664,71668,71684,71692,71702,71710,71714,71724,71730,71735,71740,71744,71748,71764,71768,71778,71782,71792,71796,71800],{"__ignoreMap":258},[262,71609,71610,71612],{"class":181,"line":264},[262,71611,684],{"class":377},[262,71613,687],{"class":429},[262,71615,71616,71618,71620,71622],{"class":181,"line":282},[262,71617,705],{"class":377},[262,71619,708],{"class":429},[262,71621,684],{"class":377},[262,71623,713],{"class":429},[262,71625,71626,71628,71630,71632],{"class":181,"line":295},[262,71627,705],{"class":377},[262,71629,720],{"class":429},[262,71631,684],{"class":377},[262,71633,725],{"class":429},[262,71635,71636],{"class":181,"line":345},[262,71637,583],{"emptyLinePlaceholder":582},[262,71639,71640],{"class":181,"line":492},[262,71641,734],{"class":429},[262,71643,71644,71646,71648,71650,71652,71654,71656,71658],{"class":181,"line":503},[262,71645,739],{"class":429},[262,71647,476],{"class":377},[262,71649,1588],{"class":429},[262,71651,2674],{"class":611},[262,71653,476],{"class":377},[262,71655,1199],{"class":429},[262,71657,2681],{"class":275},[262,71659,2684],{"class":429},[262,71661,71662],{"class":181,"line":521},[262,71663,583],{"emptyLinePlaceholder":582},[262,71665,71666],{"class":181,"line":537},[262,71667,583],{"emptyLinePlaceholder":582},[262,71669,71670,71672,71674,71676,71678,71680,71682],{"class":181,"line":549},[262,71671,423],{"class":377},[262,71673,43530],{"class":267},[262,71675,430],{"class":429},[262,71677,433],{"class":271},[262,71679,1939],{"class":429},[262,71681,433],{"class":271},[262,71683,1160],{"class":429},[262,71685,71686,71688,71690],{"class":181,"line":570},[262,71687,1184],{"class":429},[262,71689,476],{"class":377},[262,71691,1189],{"class":429},[262,71693,71694,71696,71698,71700],{"class":181,"line":579},[262,71695,1194],{"class":611},[262,71697,476],{"class":377},[262,71699,1207],{"class":275},[262,71701,1315],{"class":429},[262,71703,71704,71706,71708],{"class":181,"line":586},[262,71705,1215],{"class":611},[262,71707,476],{"class":377},[262,71709,1220],{"class":429},[262,71711,71712],{"class":181,"line":591},[262,71713,4331],{"class":429},[262,71715,71716,71718,71720,71722],{"class":181,"line":623},[262,71717,4336],{"class":275},[262,71719,1231],{"class":429},[262,71721,1234],{"class":275},[262,71723,1315],{"class":429},[262,71725,71726,71728],{"class":181,"line":634},[262,71727,4347],{"class":275},[262,71729,1242],{"class":429},[262,71731,71732],{"class":181,"line":845},[262,71733,71734],{"class":275},"                    \"You are a concise editor. Summarize the user's text in \"\n",[262,71736,71737],{"class":181,"line":850},[262,71738,71739],{"class":275},"                    \"exactly one sentence. Never add opinions or extra detail.\"\n",[262,71741,71742],{"class":181,"line":864},[262,71743,4364],{"class":429},[262,71745,71746],{"class":181,"line":1683},[262,71747,4369],{"class":429},[262,71749,71750,71752,71754,71756,71758,71760,71762],{"class":181,"line":1688},[262,71751,1225],{"class":429},[262,71753,1228],{"class":275},[262,71755,1231],{"class":429},[262,71757,1291],{"class":275},[262,71759,608],{"class":429},[262,71761,1239],{"class":275},[262,71763,52724],{"class":429},[262,71765,71766],{"class":181,"line":1693},[262,71767,1303],{"class":429},[262,71769,71770,71772,71774,71776],{"class":181,"line":1728},[262,71771,1308],{"class":611},[262,71773,476],{"class":377},[262,71775,27811],{"class":271},[262,71777,1315],{"class":429},[262,71779,71780],{"class":181,"line":1737},[262,71781,1011],{"class":429},[262,71783,71784,71786,71788,71790],{"class":181,"line":1751},[262,71785,573],{"class":377},[262,71787,1326],{"class":429},[262,71789,102],{"class":271},[262,71791,1331],{"class":429},[262,71793,71794],{"class":181,"line":1764},[262,71795,583],{"emptyLinePlaceholder":582},[262,71797,71798],{"class":181,"line":1779},[262,71799,583],{"emptyLinePlaceholder":582},[262,71801,71802,71804,71807,71810],{"class":181,"line":1793},[262,71803,637],{"class":271},[262,71805,71806],{"class":429},"(summarize(",[262,71808,71809],{"class":275},"\"Our quarterly sales rose 12 percent, driven mostly by repeat customers in Europe.\"",[262,71811,2684],{"class":429},[14,71813,71814],{},"The model now follows the system rules no matter what text arrives in the user message. Swap the user text for any other paragraph and you still get a single-sentence summary. That separation is what makes a prompt reusable. Notice two deliberate choices in the system content. The instruction is specific (\"exactly one sentence\") rather than soft (\"keep it short\"), because the model honours measurable rules far more reliably than fuzzy ones. And the rule that bans opinions and extra detail closes off the most common ways a summary drifts: editorialising and padding. A good system prompt spends as much energy saying what not to do as what to do.",[14,71816,4860,71817,71820],{},[18,71818,71819],{},"temperature=0.2"," is also intentional. Summaries should be steady, so the same paragraph produces nearly the same one-liner each time. The habit to form now is simple: whenever the task has a single correct shape, push temperature toward zero.",[57,71822,71824],{"id":71823},"step-2-teach-the-model-with-few-shot-examples","Step 2 — Teach the model with few-shot examples",[14,71826,71827],{},"Sometimes a plain instruction is not enough and the model guesses at the format you want. The fix is \"few-shot\" prompting: you show the model two or three completed examples before giving it the real input. The examples are the most reliable way to lock in tone, structure, and edge cases, because the model copies the pattern instead of inventing one.",[14,71829,71830,71831,1374,71833,71835,71836,71838,71839,71841,71842,71844,71845,71847,71848,71850],{},"You provide examples by adding fake ",[18,71832,4470],{},[18,71834,43011],{}," turns to the messages list. The ",[18,71837,43011],{}," role represents the model's own past replies, so an ",[18,71840,43011],{}," message you write by hand reads as \"here is exactly how you should respond\". The model treats those handwritten turns as proof of its own style, which is why few-shot prompting is so effective: you are not describing the output you want, you are demonstrating it, and demonstration leaves nothing to interpretation. The order matters too. Each example is a complete ",[18,71843,4470],{}," then ",[18,71846,43011],{}," pair, and the real request comes last as a lone ",[18,71849,4470],{}," turn so the model knows that is the one to answer. Below, we teach the model to turn a raw product note into a punchy tagline.",[253,71852,71854],{"className":414,"code":71853,"language":416,"meta":258,"style":258},"def write_tagline(product_note: str) -> str:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\"role\": \"system\", \"content\": \"You write short marketing taglines, 6 words max.\"},\n            {\"role\": \"user\", \"content\": \"A water bottle that keeps drinks cold for 24 hours.\"},\n            {\"role\": \"assistant\", \"content\": \"Cold sips, all day long.\"},\n            {\"role\": \"user\", \"content\": \"Noise-cancelling headphones with a 40-hour battery.\"},\n            {\"role\": \"assistant\", \"content\": \"Silence that lasts the whole week.\"},\n            {\"role\": \"user\", \"content\": product_note},\n        ],\n        temperature=0.7,\n    )\n    return response.choices[0].message.content\n\n\nprint(write_tagline(\"A standing desk that adjusts height with one tap.\"))\n",[18,71855,71856,71874,71882,71892,71900,71921,71942,71963,71984,72005,72022,72026,72036,72040,72050,72054,72058],{"__ignoreMap":258},[262,71857,71858,71860,71863,71866,71868,71870,71872],{"class":181,"line":264},[262,71859,423],{"class":377},[262,71861,71862],{"class":267}," write_tagline",[262,71864,71865],{"class":429},"(product_note: ",[262,71867,433],{"class":271},[262,71869,1939],{"class":429},[262,71871,433],{"class":271},[262,71873,1160],{"class":429},[262,71875,71876,71878,71880],{"class":181,"line":282},[262,71877,1184],{"class":429},[262,71879,476],{"class":377},[262,71881,1189],{"class":429},[262,71883,71884,71886,71888,71890],{"class":181,"line":295},[262,71885,1194],{"class":611},[262,71887,476],{"class":377},[262,71889,1207],{"class":275},[262,71891,1315],{"class":429},[262,71893,71894,71896,71898],{"class":181,"line":345},[262,71895,1215],{"class":611},[262,71897,476],{"class":377},[262,71899,1220],{"class":429},[262,71901,71902,71904,71906,71908,71910,71912,71914,71916,71919],{"class":181,"line":492},[262,71903,1225],{"class":429},[262,71905,1228],{"class":275},[262,71907,1231],{"class":429},[262,71909,1234],{"class":275},[262,71911,608],{"class":429},[262,71913,1239],{"class":275},[262,71915,1231],{"class":429},[262,71917,71918],{"class":275},"\"You write short marketing taglines, 6 words max.\"",[262,71920,3143],{"class":429},[262,71922,71923,71925,71927,71929,71931,71933,71935,71937,71940],{"class":181,"line":503},[262,71924,1225],{"class":429},[262,71926,1228],{"class":275},[262,71928,1231],{"class":429},[262,71930,1291],{"class":275},[262,71932,608],{"class":429},[262,71934,1239],{"class":275},[262,71936,1231],{"class":429},[262,71938,71939],{"class":275},"\"A water bottle that keeps drinks cold for 24 hours.\"",[262,71941,3143],{"class":429},[262,71943,71944,71946,71948,71950,71952,71954,71956,71958,71961],{"class":181,"line":521},[262,71945,1225],{"class":429},[262,71947,1228],{"class":275},[262,71949,1231],{"class":429},[262,71951,43214],{"class":275},[262,71953,608],{"class":429},[262,71955,1239],{"class":275},[262,71957,1231],{"class":429},[262,71959,71960],{"class":275},"\"Cold sips, all day long.\"",[262,71962,3143],{"class":429},[262,71964,71965,71967,71969,71971,71973,71975,71977,71979,71982],{"class":181,"line":537},[262,71966,1225],{"class":429},[262,71968,1228],{"class":275},[262,71970,1231],{"class":429},[262,71972,1291],{"class":275},[262,71974,608],{"class":429},[262,71976,1239],{"class":275},[262,71978,1231],{"class":429},[262,71980,71981],{"class":275},"\"Noise-cancelling headphones with a 40-hour battery.\"",[262,71983,3143],{"class":429},[262,71985,71986,71988,71990,71992,71994,71996,71998,72000,72003],{"class":181,"line":549},[262,71987,1225],{"class":429},[262,71989,1228],{"class":275},[262,71991,1231],{"class":429},[262,71993,43214],{"class":275},[262,71995,608],{"class":429},[262,71997,1239],{"class":275},[262,71999,1231],{"class":429},[262,72001,72002],{"class":275},"\"Silence that lasts the whole week.\"",[262,72004,3143],{"class":429},[262,72006,72007,72009,72011,72013,72015,72017,72019],{"class":181,"line":570},[262,72008,1225],{"class":429},[262,72010,1228],{"class":275},[262,72012,1231],{"class":429},[262,72014,1291],{"class":275},[262,72016,608],{"class":429},[262,72018,1239],{"class":275},[262,72020,72021],{"class":429},": product_note},\n",[262,72023,72024],{"class":181,"line":579},[262,72025,1303],{"class":429},[262,72027,72028,72030,72032,72034],{"class":181,"line":586},[262,72029,1308],{"class":611},[262,72031,476],{"class":377},[262,72033,4672],{"class":271},[262,72035,1315],{"class":429},[262,72037,72038],{"class":181,"line":591},[262,72039,1011],{"class":429},[262,72041,72042,72044,72046,72048],{"class":181,"line":623},[262,72043,573],{"class":377},[262,72045,1326],{"class":429},[262,72047,102],{"class":271},[262,72049,1331],{"class":429},[262,72051,72052],{"class":181,"line":634},[262,72053,583],{"emptyLinePlaceholder":582},[262,72055,72056],{"class":181,"line":845},[262,72057,583],{"emptyLinePlaceholder":582},[262,72059,72060,72062,72065,72068],{"class":181,"line":850},[262,72061,637],{"class":271},[262,72063,72064],{"class":429},"(write_tagline(",[262,72066,72067],{"class":275},"\"A standing desk that adjusts height with one tap.\"",[262,72069,2684],{"class":429},[14,72071,72072,72073,1363],{},"Two examples are usually enough. If the model still drifts, add one more example that covers the case it gets wrong, rather than writing a longer paragraph of instructions. Examples almost always beat explanations, because an explanation describes the target from the outside while an example places the model directly inside it. There is a cost to weigh, though: every example becomes part of the request and counts toward the token budget you pay for. So choose examples that each teach something distinct. Two taglines that solve the same problem the same way waste space; two that show different lengths, tones, or edge cases earn their place. When the model fails on a specific kind of input, the fix is rarely a fourth generic example. It is one targeted example built from the exact case it fumbled. For ready-made example sets aimed at content work, see ",[51,72074,5270],{"href":5269},[57,72076,72078],{"id":72077},"step-3-control-the-output-format-and-get-clean-json","Step 3 — Control the output format and get clean JSON",[14,72080,72081],{},"If you plan to use the model's answer in another program, you need it in a predictable, machine-readable shape. Free-form prose is fine for a human to read but painful for code to parse. The cleanest target is JSON (JavaScript Object Notation, a simple text format of keys and values that nearly every tool understands).",[14,72083,72084,72085,72087,72088,72091,72092,407,72094,72097,72098,72101,72102,3921,72104,72106,72107,72109,72110,72112],{},"There are two halves to forcing JSON, and you need both because each does a different job. First, describe the exact keys you want in the prompt. The ",[18,72086,5745],{}," parameter guarantees the reply is ",[27,72089,72090],{},"syntactically"," valid JSON, but it does not care which keys you expect, so the prompt is the only place that decides whether you get ",[18,72093,3552],{},[18,72095,72096],{},"full_name",", and whether a missing field comes back as ",[18,72099,72100],{},"null"," or an empty string. Second, set ",[18,72103,5745],{},[18,72105,6841],{},", which makes the API guarantee the reply parses. Skip this second half and the model will sometimes wrap its JSON in a markdown code fence or add a sentence before it, and your ",[18,72108,20396],{}," call will crash on the stray text. With both halves in place you can call ",[18,72111,20396],{}," without any defensive string-cleaning, and a parse that works on one input works on a thousand.",[253,72114,72116],{"className":414,"code":72115,"language":416,"meta":258,"style":258},"import json\n\n\ndef extract_contact(raw_text: str) -> dict:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"Extract contact details. Respond with JSON containing the keys \"\n                    \"name, email, and company. Use null if a field is missing.\"\n                ),\n            },\n            {\"role\": \"user\", \"content\": raw_text},\n        ],\n        temperature=0,\n        response_format={\"type\": \"json_object\"},\n    )\n    return json.loads(response.choices[0].message.content)\n\n\nprint(extract_contact(\"Hi, I'm Dana Lee from Northwind. Reach me at dana@northwind.io.\"))\n",[18,72117,72118,72124,72128,72132,72150,72158,72168,72176,72180,72190,72196,72201,72206,72210,72214,72231,72235,72245,72261,72265,72275,72279,72283],{"__ignoreMap":258},[262,72119,72120,72122],{"class":181,"line":264},[262,72121,684],{"class":377},[262,72123,5766],{"class":429},[262,72125,72126],{"class":181,"line":282},[262,72127,583],{"emptyLinePlaceholder":582},[262,72129,72130],{"class":181,"line":295},[262,72131,583],{"emptyLinePlaceholder":582},[262,72133,72134,72136,72139,72142,72144,72146,72148],{"class":181,"line":345},[262,72135,423],{"class":377},[262,72137,72138],{"class":267}," extract_contact",[262,72140,72141],{"class":429},"(raw_text: ",[262,72143,433],{"class":271},[262,72145,1939],{"class":429},[262,72147,5869],{"class":271},[262,72149,1160],{"class":429},[262,72151,72152,72154,72156],{"class":181,"line":492},[262,72153,1184],{"class":429},[262,72155,476],{"class":377},[262,72157,1189],{"class":429},[262,72159,72160,72162,72164,72166],{"class":181,"line":503},[262,72161,1194],{"class":611},[262,72163,476],{"class":377},[262,72165,1207],{"class":275},[262,72167,1315],{"class":429},[262,72169,72170,72172,72174],{"class":181,"line":521},[262,72171,1215],{"class":611},[262,72173,476],{"class":377},[262,72175,1220],{"class":429},[262,72177,72178],{"class":181,"line":537},[262,72179,4331],{"class":429},[262,72181,72182,72184,72186,72188],{"class":181,"line":549},[262,72183,4336],{"class":275},[262,72185,1231],{"class":429},[262,72187,1234],{"class":275},[262,72189,1315],{"class":429},[262,72191,72192,72194],{"class":181,"line":570},[262,72193,4347],{"class":275},[262,72195,1242],{"class":429},[262,72197,72198],{"class":181,"line":579},[262,72199,72200],{"class":275},"                    \"Extract contact details. Respond with JSON containing the keys \"\n",[262,72202,72203],{"class":181,"line":586},[262,72204,72205],{"class":275},"                    \"name, email, and company. Use null if a field is missing.\"\n",[262,72207,72208],{"class":181,"line":591},[262,72209,4364],{"class":429},[262,72211,72212],{"class":181,"line":623},[262,72213,4369],{"class":429},[262,72215,72216,72218,72220,72222,72224,72226,72228],{"class":181,"line":634},[262,72217,1225],{"class":429},[262,72219,1228],{"class":275},[262,72221,1231],{"class":429},[262,72223,1291],{"class":275},[262,72225,608],{"class":429},[262,72227,1239],{"class":275},[262,72229,72230],{"class":429},": raw_text},\n",[262,72232,72233],{"class":181,"line":845},[262,72234,1303],{"class":429},[262,72236,72237,72239,72241,72243],{"class":181,"line":850},[262,72238,1308],{"class":611},[262,72240,476],{"class":377},[262,72242,102],{"class":271},[262,72244,1315],{"class":429},[262,72246,72247,72249,72251,72253,72255,72257,72259],{"class":181,"line":864},[262,72248,6018],{"class":611},[262,72250,476],{"class":377},[262,72252,3039],{"class":429},[262,72254,6025],{"class":275},[262,72256,1231],{"class":429},[262,72258,6030],{"class":275},[262,72260,3143],{"class":429},[262,72262,72263],{"class":181,"line":1683},[262,72264,1011],{"class":429},[262,72266,72267,72269,72271,72273],{"class":181,"line":1688},[262,72268,573],{"class":377},[262,72270,6043],{"class":429},[262,72272,102],{"class":271},[262,72274,6048],{"class":429},[262,72276,72277],{"class":181,"line":1693},[262,72278,583],{"emptyLinePlaceholder":582},[262,72280,72281],{"class":181,"line":1728},[262,72282,583],{"emptyLinePlaceholder":582},[262,72284,72285,72287,72290,72293],{"class":181,"line":1737},[262,72286,637],{"class":271},[262,72288,72289],{"class":429},"(extract_contact(",[262,72291,72292],{"class":275},"\"Hi, I'm Dana Lee from Northwind. Reach me at dana@northwind.io.\"",[262,72294,2684],{"class":429},[14,72296,18189,72297,72299,72300,1363],{},[18,72298,1357],{}," here is deliberate: extraction should give the same answer every time, not creative variety. For deeper control over structure and a fuller treatment of validation, follow ",[51,72301,1362],{"href":1361},[57,72303,72305],{"id":72304},"step-4-iterate-on-a-prompt-until-it-is-reliable","Step 4 — Iterate on a prompt until it is reliable",[14,72307,72308],{},"A first-draft prompt rarely behaves perfectly. Iteration is the loop where you change one element, rerun the script against the same inputs, and keep the version that performs best. The key discipline is changing only one thing at a time so you know what caused any improvement, exactly like adjusting a single ingredient in a recipe.",[14,72310,72311],{},"The reason for changing one element at a time is not pedantry. If you reword the instruction and lower the temperature in the same edit and the output improves, you have learned nothing about which change helped. Disciplined iteration turns prompt engineering into a controlled experiment: a fixed set of inputs, one variable changed, a result you can attribute to it.",[14,72313,72314],{},"The simplest way to compare versions is to run several prompts over a fixed set of test inputs and print the results side by side. The inputs should stay the same every time and should deliberately include the awkward cases, not just the easy ones, because a prompt that handles \"Refund my order\" but mangles \"I love this but want a refund\" is not yet reliable. Below, a small helper runs any system prompt against a shared list so you can judge two phrasings fairly.",[253,72316,72318],{"className":414,"code":72317,"language":416,"meta":258,"style":258},"def try_prompt(system_prompt: str, inputs: list[str]) -> None:\n    for text in inputs:\n        response = client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            messages=[\n                {\"role\": \"system\", \"content\": system_prompt},\n                {\"role\": \"user\", \"content\": text},\n            ],\n            temperature=0,\n            max_tokens=60,\n        )\n        print(f\"IN:  {text}\\nOUT: {response.choices[0].message.content}\\n\")\n\n\ntests = [\"Refund my order #4471\", \"Where is my package?\", \"I love this product!\"]\n\nprint(\"--- Version A ---\")\ntry_prompt(\"Classify the message as: refund, shipping, or praise.\", tests)\n\nprint(\"--- Version B ---\")\ntry_prompt(\"Classify the message in one lowercase word: refund, shipping, or praise.\", tests)\n",[18,72319,72320,72343,72354,72362,72372,72380,72397,72413,72417,72427,72437,72441,72477,72481,72485,72509,72513,72524,72535,72539,72550],{"__ignoreMap":258},[262,72321,72322,72324,72327,72330,72332,72335,72337,72339,72341],{"class":181,"line":264},[262,72323,423],{"class":377},[262,72325,72326],{"class":267}," try_prompt",[262,72328,72329],{"class":429},"(system_prompt: ",[262,72331,433],{"class":271},[262,72333,72334],{"class":429},", inputs: list[",[262,72336,433],{"class":271},[262,72338,13681],{"class":429},[262,72340,8471],{"class":271},[262,72342,1160],{"class":429},[262,72344,72345,72347,72349,72351],{"class":181,"line":282},[262,72346,3074],{"class":377},[262,72348,68928],{"class":429},[262,72350,835],{"class":377},[262,72352,72353],{"class":429}," inputs:\n",[262,72355,72356,72358,72360],{"class":181,"line":295},[262,72357,21490],{"class":429},[262,72359,476],{"class":377},[262,72361,1189],{"class":429},[262,72363,72364,72366,72368,72370],{"class":181,"line":345},[262,72365,14214],{"class":611},[262,72367,476],{"class":377},[262,72369,1207],{"class":275},[262,72371,1315],{"class":429},[262,72373,72374,72376,72378],{"class":181,"line":492},[262,72375,27253],{"class":611},[262,72377,476],{"class":377},[262,72379,1220],{"class":429},[262,72381,72382,72384,72386,72388,72390,72392,72394],{"class":181,"line":503},[262,72383,53817],{"class":429},[262,72385,1228],{"class":275},[262,72387,1231],{"class":429},[262,72389,1234],{"class":275},[262,72391,608],{"class":429},[262,72393,1239],{"class":275},[262,72395,72396],{"class":429},": system_prompt},\n",[262,72398,72399,72401,72403,72405,72407,72409,72411],{"class":181,"line":521},[262,72400,53817],{"class":429},[262,72402,1228],{"class":275},[262,72404,1231],{"class":429},[262,72406,1291],{"class":275},[262,72408,608],{"class":429},[262,72410,1239],{"class":275},[262,72412,52724],{"class":429},[262,72414,72415],{"class":181,"line":537},[262,72416,53856],{"class":429},[262,72418,72419,72421,72423,72425],{"class":181,"line":549},[262,72420,27275],{"class":611},[262,72422,476],{"class":377},[262,72424,102],{"class":271},[262,72426,1315],{"class":429},[262,72428,72429,72431,72433,72435],{"class":181,"line":570},[262,72430,27286],{"class":611},[262,72432,476],{"class":377},[262,72434,12826],{"class":271},[262,72436,1315],{"class":429},[262,72438,72439],{"class":181,"line":579},[262,72440,6288],{"class":429},[262,72442,72443,72445,72447,72449,72452,72454,72456,72458,72461,72463,72466,72468,72471,72473,72475],{"class":181,"line":586},[262,72444,2299],{"class":271},[262,72446,602],{"class":429},[262,72448,642],{"class":377},[262,72450,72451],{"class":275},"\"IN:  ",[262,72453,3039],{"class":271},[262,72455,111],{"class":429},[262,72457,3044],{"class":271},[262,72459,72460],{"class":275},"OUT: ",[262,72462,3039],{"class":271},[262,72464,72465],{"class":429},"response.choices[",[262,72467,102],{"class":271},[262,72469,72470],{"class":429},"].message.content",[262,72472,3044],{"class":271},[262,72474,1176],{"class":275},[262,72476,660],{"class":429},[262,72478,72479],{"class":181,"line":591},[262,72480,583],{"emptyLinePlaceholder":582},[262,72482,72483],{"class":181,"line":623},[262,72484,583],{"emptyLinePlaceholder":582},[262,72486,72487,72490,72492,72494,72497,72499,72502,72504,72507],{"class":181,"line":634},[262,72488,72489],{"class":429},"tests ",[262,72491,476],{"class":377},[262,72493,10563],{"class":429},[262,72495,72496],{"class":275},"\"Refund my order #4471\"",[262,72498,608],{"class":429},[262,72500,72501],{"class":275},"\"Where is my package?\"",[262,72503,608],{"class":429},[262,72505,72506],{"class":275},"\"I love this product!\"",[262,72508,957],{"class":429},[262,72510,72511],{"class":181,"line":845},[262,72512,583],{"emptyLinePlaceholder":582},[262,72514,72515,72517,72519,72522],{"class":181,"line":850},[262,72516,637],{"class":271},[262,72518,602],{"class":429},[262,72520,72521],{"class":275},"\"--- Version A ---\"",[262,72523,660],{"class":429},[262,72525,72526,72529,72532],{"class":181,"line":864},[262,72527,72528],{"class":429},"try_prompt(",[262,72530,72531],{"class":275},"\"Classify the message as: refund, shipping, or praise.\"",[262,72533,72534],{"class":429},", tests)\n",[262,72536,72537],{"class":181,"line":1683},[262,72538,583],{"emptyLinePlaceholder":582},[262,72540,72541,72543,72545,72548],{"class":181,"line":1688},[262,72542,637],{"class":271},[262,72544,602],{"class":429},[262,72546,72547],{"class":275},"\"--- Version B ---\"",[262,72549,660],{"class":429},[262,72551,72552,72554,72557],{"class":181,"line":1693},[262,72553,72528],{"class":429},[262,72555,72556],{"class":275},"\"Classify the message in one lowercase word: refund, shipping, or praise.\"",[262,72558,72534],{"class":429},[14,72560,72561],{},"Version B adds \"one lowercase word\", which tends to produce cleaner, more uniform output. Run both, eyeball the results, and adopt the winner. Save your winning prompts in a file so you can track changes over time, like any other working asset.",[57,72563,8300],{"id":8299},[14,72565,72566],{},"These are the settings you pass alongside your messages. Tuning them is half of prompt engineering, so keep this table handy.",[1379,72568,72569,72581],{},[1382,72570,72571],{},[1385,72572,72573,72575,72577,72579],{},[1388,72574,1390],{},[1388,72576,3795],{},[1388,72578,3798],{},[1388,72580,1396],{},[1398,72582,72583,72602,72633,72648,72662,72680,72697,72716],{},[1385,72584,72585,72589,72591,72593],{},[1403,72586,72587],{},[18,72588,805],{},[1403,72590,3811],{},[1403,72592,14674],{},[1403,72594,72595,72596,72598,72599,72601],{},"Which model answers, e.g. ",[18,72597,2703],{}," for cheap fast work or ",[18,72600,3821],{}," for harder reasoning.",[1385,72603,72604,72608,72610,72614],{},[1403,72605,72606],{},[18,72607,3829],{},[1403,72609,3832],{},[1403,72611,72612],{},[18,72613,17583],{},[1403,72615,72616,72617,3921,72619,72622,72623,561,72625,72627,72628,561,72630,72632],{},"Randomness from ",[18,72618,46000],{},[18,72620,72621],{},"2.0",". Use ",[18,72624,102],{},[18,72626,3924],{}," for facts and JSON, ",[18,72629,4672],{},[18,72631,17583],{}," for creative copy.",[1385,72634,72635,72639,72641,72645],{},[1403,72636,72637],{},[18,72638,49714],{},[1403,72640,3832],{},[1403,72642,72643],{},[18,72644,17583],{},[1403,72646,72647],{},"Nucleus sampling, an alternative to temperature. Lower values narrow word choice. Tune one or the other, not both.",[1385,72649,72650,72654,72656,72659],{},[1403,72651,72652],{},[18,72653,3846],{},[1403,72655,439],{},[1403,72657,72658],{},"model limit",[1403,72660,72661],{},"Caps the length of the reply. A token is roughly four characters; set this to avoid runaway, costly responses.",[1385,72663,72664,72668,72670,72675],{},[1403,72665,72666],{},[18,72667,5745],{},[1403,72669,36804],{},[1403,72671,72672],{},[18,72673,72674],{},"{\"type\": \"text\"}",[1403,72676,52065,72677,72679],{},[18,72678,6841],{}," to guarantee parseable JSON output.",[1385,72681,72682,72687,72690,72694],{},[1403,72683,72684],{},[18,72685,72686],{},"stop",[1403,72688,72689],{},"list of strings",[1403,72691,72692],{},[18,72693,72100],{},[1403,72695,72696],{},"Strings that, when produced, end the response early. Useful for trimming trailing chatter.",[1385,72698,72699,72704,72706,72710],{},[1403,72700,72701],{},[18,72702,72703],{},"seed",[1403,72705,439],{},[1403,72707,72708],{},[18,72709,72100],{},[1403,72711,72712,72713,72715],{},"Asks for repeatable output across runs when paired with ",[18,72714,1357],{},". Best-effort, not a guarantee.",[1385,72717,72718,72722,72724,72728],{},[1403,72719,72720],{},[18,72721,10895],{},[1403,72723,439],{},[1403,72725,72726],{},[18,72727,997],{},[1403,72729,72730],{},"How many separate completions to return for one prompt. Raising it multiplies cost.",[57,72732,1445],{"id":1444},[14,72734,72735],{},"These are the errors you will most likely hit while building the scripts above, with the real cause and a one-line fix.",[1447,72737,72738,72756,72768,72778,72793,72799,72810],{},[1450,72739,72740,72744,72745,72747,72748,72750,72751,72753,72754,1363],{},[35,72741,72742],{},[18,72743,21739],{}," — Your key is missing, mistyped, or the ",[18,72746,319],{}," file is in the wrong folder. Run your script from the same directory as ",[18,72749,319],{},", or print ",[18,72752,70346],{}," to confirm it loaded. For a full walkthrough, see ",[51,72755,388],{"href":387},[1450,72757,72758,72762,72763,72765,72766,1363],{},[35,72759,72760],{},[18,72761,31872],{}," — You sent requests faster than your account tier allows, or you are out of credit. Slow down with a short ",[18,72764,14421],{}," between calls and check your billing balance. The deeper fix is in ",[51,72767,3379],{"href":3378},[1450,72769,72770,72774,72775,72777],{},[35,72771,72772],{},[18,72773,21759],{}," — The model wrapped its JSON in a markdown code fence or added a sentence around it. Add ",[18,72776,6878],{}," to the call so the reply is always parseable.",[1450,72779,72780,72785,72786,72788,72789,14825,72791,1363],{},[35,72781,72782],{},[18,72783,72784],{},"openai.BadRequestError: ... context length"," — Your messages plus the requested ",[18,72787,3846],{}," exceed the model's window. Shorten the input or lower ",[18,72790,3846],{},[51,72792,1513],{"href":1512},[1450,72794,72795,72798],{},[35,72796,72797],{},"The model ignores your system prompt"," — The instruction is too vague or buried under conflicting examples. Make the rule specific (\"reply in one lowercase word\") and remove examples that contradict it.",[1450,72800,72801,8504,72804,72806,72807,72809],{},[35,72802,72803],{},"Output changes on every run",[18,72805,3829],{}," is high. Drop it to ",[18,72808,102],{}," for extraction and classification tasks; reserve higher values for creative writing.",[1450,72811,72812,55456,72815,72817,72818,72821,72822,72825,72826,72828],{},[35,72813,72814],{},"The JSON parses but a key is missing or misspelled",[18,72816,5745],{}," only guarantees valid JSON, never the right ",[27,72819,72820],{},"shape",". The model returned ",[18,72823,72824],{},"{\"full_name\": ...}"," when your code expected ",[18,72827,3552],{},", or dropped a key entirely. Name every key explicitly in the system prompt, say what to put when a value is unknown, and validate the parsed object in code before trusting it (the worked example below shows the guard pattern).",[57,72830,72832],{"id":72831},"worked-example-a-reusable-classifier-with-validation","Worked example: a reusable classifier with validation",[14,72834,72835,72836,8518,72839,72842],{},"This script ties together everything above. It defines one system prompt with few-shot examples, forces JSON output, validates the result against a known set of labels, and runs across a batch of inputs while staying resilient when a single message fails. Save it as ",[18,72837,72838],{},"classify.py",[18,72840,72841],{},"python classify.py",". The inline comments explain why each part exists, not just what it does.",[253,72844,72846],{"className":414,"code":72845,"language":416,"meta":258,"style":258},"import json\nimport os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\n# Load the API key from .env so the secret never lives in the code itself.\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n# The single source of truth for valid labels. Validation below checks\n# against this set, so adding a new category means editing one line here.\nALLOWED = {\"refund\", \"shipping\", \"praise\", \"other\"}\n\n# The system prompt names every key the JSON must contain and spells out the\n# allowed values. response_format guarantees valid JSON; this text guarantees\n# the right *shape*. The two work together — neither is enough alone.\nSYSTEM = (\n    \"You label customer messages. Respond with JSON only, no prose: \"\n    '{\"category\": \"\u003Cone of refund, shipping, praise, other>\", '\n    '\"urgent\": true or false}. Set urgent to true only when the customer '\n    \"needs action today.\"\n)\n\n\ndef classify(message: str) -> dict:\n    \"\"\"Return a validated {category, urgent} dict for one customer message.\"\"\"\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        temperature=0,                            # extraction must be repeatable\n        response_format={\"type\": \"json_object\"},  # forces parseable JSON\n        max_tokens=40,                            # the reply is tiny; cap the cost\n        messages=[\n            {\"role\": \"system\", \"content\": SYSTEM},\n            # One few-shot pair shows the exact JSON style we expect back.\n            {\"role\": \"user\", \"content\": \"My package never arrived and I need it today!\"},\n            {\"role\": \"assistant\", \"content\": '{\"category\": \"shipping\", \"urgent\": true}'},\n            # The real message comes last as a lone user turn.\n            {\"role\": \"user\", \"content\": message},\n        ],\n    )\n    data = json.loads(response.choices[0].message.content)\n\n    # response_format cannot guarantee the right keys or values, so validate.\n    # Any unexpected label is folded into \"other\" rather than trusted blindly.\n    if data.get(\"category\") not in ALLOWED:\n        data[\"category\"] = \"other\"\n    # Coerce urgent to a real bool in case the model returns \"true\" as text.\n    data[\"urgent\"] = bool(data.get(\"urgent\"))\n    return data\n\n\ndef classify_safely(message: str) -> dict:\n    \"\"\"Wrap classify so one bad response never stops the whole batch.\"\"\"\n    try:\n        return classify(message)\n    except (json.JSONDecodeError, KeyError) as err:\n        # Log the failure and return a safe default so the loop continues.\n        print(f\"  (could not classify, defaulting to other: {err})\")\n        return {\"category\": \"other\", \"urgent\": False}\n\n\nif __name__ == \"__main__\":\n    inbox = [\n        \"Please refund order #4471, it was the wrong size.\",\n        \"This is the best gadget I have ever bought!\",\n        \"When will my order ship?\",\n        \"Love the product, but it arrived broken — can I get a refund today?\",\n    ]\n    for note in inbox:\n        result = classify_safely(note)\n        flag = \"URGENT\" if result[\"urgent\"] else \"normal\"\n        print(f\"[{result['category']:>8}] [{flag:>6}] {note}\")\n",[18,72847,72848,72854,72860,72870,72880,72884,72889,72893,72911,72915,72920,72925,72954,72958,72963,72968,72973,72981,72986,72991,72996,73001,73005,73009,73013,73030,73035,73043,73053,73067,73086,73099,73107,73127,73132,73153,73174,73179,73196,73200,73204,73216,73220,73225,73230,73249,73263,73268,73289,73296,73300,73304,73321,73326,73332,73339,73354,73359,73381,73403,73407,73411,73423,73432,73439,73446,73453,73460,73464,73476,73485,73509],{"__ignoreMap":258},[262,72849,72850,72852],{"class":181,"line":264},[262,72851,684],{"class":377},[262,72853,5766],{"class":429},[262,72855,72856,72858],{"class":181,"line":282},[262,72857,684],{"class":377},[262,72859,687],{"class":429},[262,72861,72862,72864,72866,72868],{"class":181,"line":295},[262,72863,705],{"class":377},[262,72865,708],{"class":429},[262,72867,684],{"class":377},[262,72869,713],{"class":429},[262,72871,72872,72874,72876,72878],{"class":181,"line":345},[262,72873,705],{"class":377},[262,72875,720],{"class":429},[262,72877,684],{"class":377},[262,72879,725],{"class":429},[262,72881,72882],{"class":181,"line":492},[262,72883,583],{"emptyLinePlaceholder":582},[262,72885,72886],{"class":181,"line":503},[262,72887,72888],{"class":291},"# Load the API key from .env so the secret never lives in the code itself.\n",[262,72890,72891],{"class":181,"line":521},[262,72892,734],{"class":429},[262,72894,72895,72897,72899,72901,72903,72905,72907,72909],{"class":181,"line":537},[262,72896,739],{"class":429},[262,72898,476],{"class":377},[262,72900,1588],{"class":429},[262,72902,2674],{"class":611},[262,72904,476],{"class":377},[262,72906,1199],{"class":429},[262,72908,2681],{"class":275},[262,72910,2684],{"class":429},[262,72912,72913],{"class":181,"line":549},[262,72914,583],{"emptyLinePlaceholder":582},[262,72916,72917],{"class":181,"line":570},[262,72918,72919],{"class":291},"# The single source of truth for valid labels. Validation below checks\n",[262,72921,72922],{"class":181,"line":579},[262,72923,72924],{"class":291},"# against this set, so adding a new category means editing one line here.\n",[262,72926,72927,72930,72932,72934,72937,72939,72942,72944,72947,72949,72952],{"class":181,"line":586},[262,72928,72929],{"class":271},"ALLOWED",[262,72931,442],{"class":377},[262,72933,2276],{"class":429},[262,72935,72936],{"class":275},"\"refund\"",[262,72938,608],{"class":429},[262,72940,72941],{"class":275},"\"shipping\"",[262,72943,608],{"class":429},[262,72945,72946],{"class":275},"\"praise\"",[262,72948,608],{"class":429},[262,72950,72951],{"class":275},"\"other\"",[262,72953,16430],{"class":429},[262,72955,72956],{"class":181,"line":591},[262,72957,583],{"emptyLinePlaceholder":582},[262,72959,72960],{"class":181,"line":623},[262,72961,72962],{"class":291},"# The system prompt names every key the JSON must contain and spells out the\n",[262,72964,72965],{"class":181,"line":634},[262,72966,72967],{"class":291},"# allowed values. response_format guarantees valid JSON; this text guarantees\n",[262,72969,72970],{"class":181,"line":845},[262,72971,72972],{"class":291},"# the right *shape*. The two work together — neither is enough alone.\n",[262,72974,72975,72977,72979],{"class":181,"line":850},[262,72976,70747],{"class":271},[262,72978,442],{"class":377},[262,72980,984],{"class":429},[262,72982,72983],{"class":181,"line":864},[262,72984,72985],{"class":275},"    \"You label customer messages. Respond with JSON only, no prose: \"\n",[262,72987,72988],{"class":181,"line":1683},[262,72989,72990],{"class":275},"    '{\"category\": \"\u003Cone of refund, shipping, praise, other>\", '\n",[262,72992,72993],{"class":181,"line":1688},[262,72994,72995],{"class":275},"    '\"urgent\": true or false}. Set urgent to true only when the customer '\n",[262,72997,72998],{"class":181,"line":1693},[262,72999,73000],{"class":275},"    \"needs action today.\"\n",[262,73002,73003],{"class":181,"line":1728},[262,73004,660],{"class":429},[262,73006,73007],{"class":181,"line":1737},[262,73008,583],{"emptyLinePlaceholder":582},[262,73010,73011],{"class":181,"line":1751},[262,73012,583],{"emptyLinePlaceholder":582},[262,73014,73015,73017,73019,73022,73024,73026,73028],{"class":181,"line":1764},[262,73016,423],{"class":377},[262,73018,61715],{"class":267},[262,73020,73021],{"class":429},"(message: ",[262,73023,433],{"class":271},[262,73025,1939],{"class":429},[262,73027,5869],{"class":271},[262,73029,1160],{"class":429},[262,73031,73032],{"class":181,"line":1779},[262,73033,73034],{"class":275},"    \"\"\"Return a validated {category, urgent} dict for one customer message.\"\"\"\n",[262,73036,73037,73039,73041],{"class":181,"line":1793},[262,73038,1184],{"class":429},[262,73040,476],{"class":377},[262,73042,1189],{"class":429},[262,73044,73045,73047,73049,73051],{"class":181,"line":1800},[262,73046,1194],{"class":611},[262,73048,476],{"class":377},[262,73050,1207],{"class":275},[262,73052,1315],{"class":429},[262,73054,73055,73057,73059,73061,73064],{"class":181,"line":1805},[262,73056,1308],{"class":611},[262,73058,476],{"class":377},[262,73060,102],{"class":271},[262,73062,73063],{"class":429},",                            ",[262,73065,73066],{"class":291},"# extraction must be repeatable\n",[262,73068,73069,73071,73073,73075,73077,73079,73081,73083],{"class":181,"line":1810},[262,73070,6018],{"class":611},[262,73072,476],{"class":377},[262,73074,3039],{"class":429},[262,73076,6025],{"class":275},[262,73078,1231],{"class":429},[262,73080,6030],{"class":275},[262,73082,59222],{"class":429},[262,73084,73085],{"class":291},"# forces parseable JSON\n",[262,73087,73088,73090,73092,73094,73096],{"class":181,"line":1823},[262,73089,4679],{"class":611},[262,73091,476],{"class":377},[262,73093,23367],{"class":271},[262,73095,73063],{"class":429},[262,73097,73098],{"class":291},"# the reply is tiny; cap the cost\n",[262,73100,73101,73103,73105],{"class":181,"line":1846},[262,73102,1215],{"class":611},[262,73104,476],{"class":377},[262,73106,1220],{"class":429},[262,73108,73109,73111,73113,73115,73117,73119,73121,73123,73125],{"class":181,"line":1861},[262,73110,1225],{"class":429},[262,73112,1228],{"class":275},[262,73114,1231],{"class":429},[262,73116,1234],{"class":275},[262,73118,608],{"class":429},[262,73120,1239],{"class":275},[262,73122,1231],{"class":429},[262,73124,70747],{"class":271},[262,73126,3143],{"class":429},[262,73128,73129],{"class":181,"line":1866},[262,73130,73131],{"class":291},"            # One few-shot pair shows the exact JSON style we expect back.\n",[262,73133,73134,73136,73138,73140,73142,73144,73146,73148,73151],{"class":181,"line":1871},[262,73135,1225],{"class":429},[262,73137,1228],{"class":275},[262,73139,1231],{"class":429},[262,73141,1291],{"class":275},[262,73143,608],{"class":429},[262,73145,1239],{"class":275},[262,73147,1231],{"class":429},[262,73149,73150],{"class":275},"\"My package never arrived and I need it today!\"",[262,73152,3143],{"class":429},[262,73154,73155,73157,73159,73161,73163,73165,73167,73169,73172],{"class":181,"line":1890},[262,73156,1225],{"class":429},[262,73158,1228],{"class":275},[262,73160,1231],{"class":429},[262,73162,43214],{"class":275},[262,73164,608],{"class":429},[262,73166,1239],{"class":275},[262,73168,1231],{"class":429},[262,73170,73171],{"class":275},"'{\"category\": \"shipping\", \"urgent\": true}'",[262,73173,3143],{"class":429},[262,73175,73176],{"class":181,"line":1909},[262,73177,73178],{"class":291},"            # The real message comes last as a lone user turn.\n",[262,73180,73181,73183,73185,73187,73189,73191,73193],{"class":181,"line":1914},[262,73182,1225],{"class":429},[262,73184,1228],{"class":275},[262,73186,1231],{"class":429},[262,73188,1291],{"class":275},[262,73190,608],{"class":429},[262,73192,1239],{"class":275},[262,73194,73195],{"class":429},": message},\n",[262,73197,73198],{"class":181,"line":1919},[262,73199,1303],{"class":429},[262,73201,73202],{"class":181,"line":1946},[262,73203,1011],{"class":429},[262,73205,73206,73208,73210,73212,73214],{"class":181,"line":1959},[262,73207,18166],{"class":429},[262,73209,476],{"class":377},[262,73211,6043],{"class":429},[262,73213,102],{"class":271},[262,73215,6048],{"class":429},[262,73217,73218],{"class":181,"line":1996},[262,73219,583],{"emptyLinePlaceholder":582},[262,73221,73222],{"class":181,"line":2012},[262,73223,73224],{"class":291},"    # response_format cannot guarantee the right keys or values, so validate.\n",[262,73226,73227],{"class":181,"line":2040},[262,73228,73229],{"class":291},"    # Any unexpected label is folded into \"other\" rather than trusted blindly.\n",[262,73231,73232,73234,73236,73238,73240,73242,73244,73247],{"class":181,"line":2045},[262,73233,3454],{"class":377},[262,73235,37742],{"class":429},[262,73237,62009],{"class":275},[262,73239,1000],{"class":429},[262,73241,17892],{"class":377},[262,73243,2821],{"class":377},[262,73245,73246],{"class":271}," ALLOWED",[262,73248,1160],{"class":429},[262,73250,73251,73254,73256,73258,73260],{"class":181,"line":2050},[262,73252,73253],{"class":429},"        data[",[262,73255,62009],{"class":275},[262,73257,2903],{"class":429},[262,73259,476],{"class":377},[262,73261,73262],{"class":275}," \"other\"\n",[262,73264,73265],{"class":181,"line":2067},[262,73266,73267],{"class":291},"    # Coerce urgent to a real bool in case the model returns \"true\" as text.\n",[262,73269,73270,73273,73276,73278,73280,73282,73285,73287],{"class":181,"line":2077},[262,73271,73272],{"class":429},"    data[",[262,73274,73275],{"class":275},"\"urgent\"",[262,73277,2903],{"class":429},[262,73279,476],{"class":377},[262,73281,8963],{"class":271},[262,73283,73284],{"class":429},"(data.get(",[262,73286,73275],{"class":275},[262,73288,2684],{"class":429},[262,73290,73291,73293],{"class":181,"line":2086},[262,73292,573],{"class":377},[262,73294,73295],{"class":429}," data\n",[262,73297,73298],{"class":181,"line":2097},[262,73299,583],{"emptyLinePlaceholder":582},[262,73301,73302],{"class":181,"line":2106},[262,73303,583],{"emptyLinePlaceholder":582},[262,73305,73306,73308,73311,73313,73315,73317,73319],{"class":181,"line":2126},[262,73307,423],{"class":377},[262,73309,73310],{"class":267}," classify_safely",[262,73312,73021],{"class":429},[262,73314,433],{"class":271},[262,73316,1939],{"class":429},[262,73318,5869],{"class":271},[262,73320,1160],{"class":429},[262,73322,73323],{"class":181,"line":2148},[262,73324,73325],{"class":275},"    \"\"\"Wrap classify so one bad response never stops the whole batch.\"\"\"\n",[262,73327,73328,73330],{"class":181,"line":2165},[262,73329,14474],{"class":377},[262,73331,1160],{"class":429},[262,73333,73334,73336],{"class":181,"line":2170},[262,73335,8066],{"class":377},[262,73337,73338],{"class":429}," classify(message)\n",[262,73340,73341,73343,73346,73348,73350,73352],{"class":181,"line":2181},[262,73342,14522],{"class":377},[262,73344,73345],{"class":429}," (json.JSONDecodeError, ",[262,73347,3897],{"class":271},[262,73349,1000],{"class":429},[262,73351,697],{"class":377},[262,73353,3222],{"class":429},[262,73355,73356],{"class":181,"line":2186},[262,73357,73358],{"class":291},"        # Log the failure and return a safe default so the loop continues.\n",[262,73360,73361,73363,73365,73367,73370,73372,73375,73377,73379],{"class":181,"line":2197},[262,73362,2299],{"class":271},[262,73364,602],{"class":429},[262,73366,642],{"class":377},[262,73368,73369],{"class":275},"\"  (could not classify, defaulting to other: ",[262,73371,3039],{"class":271},[262,73373,73374],{"class":429},"err",[262,73376,654],{"class":271},[262,73378,25475],{"class":275},[262,73380,660],{"class":429},[262,73382,73383,73385,73387,73389,73391,73393,73395,73397,73399,73401],{"class":181,"line":2202},[262,73384,8066],{"class":377},[262,73386,2276],{"class":429},[262,73388,62009],{"class":275},[262,73390,1231],{"class":429},[262,73392,72951],{"class":275},[262,73394,608],{"class":429},[262,73396,73275],{"class":275},[262,73398,1231],{"class":429},[262,73400,3623],{"class":271},[262,73402,16430],{"class":429},[262,73404,73405],{"class":181,"line":2207},[262,73406,583],{"emptyLinePlaceholder":582},[262,73408,73409],{"class":181,"line":2224},[262,73410,583],{"emptyLinePlaceholder":582},[262,73412,73413,73415,73417,73419,73421],{"class":181,"line":2236},[262,73414,2210],{"class":377},[262,73416,2213],{"class":271},[262,73418,2216],{"class":377},[262,73420,2219],{"class":275},[262,73422,1160],{"class":429},[262,73424,73425,73428,73430],{"class":181,"line":2246},[262,73426,73427],{"class":429},"    inbox ",[262,73429,476],{"class":377},[262,73431,5589],{"class":429},[262,73433,73434,73437],{"class":181,"line":2265},[262,73435,73436],{"class":275},"        \"Please refund order #4471, it was the wrong size.\"",[262,73438,1315],{"class":429},[262,73440,73441,73444],{"class":181,"line":2290},[262,73442,73443],{"class":275},"        \"This is the best gadget I have ever bought!\"",[262,73445,1315],{"class":429},[262,73447,73448,73451],{"class":181,"line":2296},[262,73449,73450],{"class":275},"        \"When will my order ship?\"",[262,73452,1315],{"class":429},[262,73454,73455,73458],{"class":181,"line":9230},[262,73456,73457],{"class":275},"        \"Love the product, but it arrived broken — can I get a refund today?\"",[262,73459,1315],{"class":429},[262,73461,73462],{"class":181,"line":9241},[262,73463,7761],{"class":429},[262,73465,73466,73468,73471,73473],{"class":181,"line":9247},[262,73467,3074],{"class":377},[262,73469,73470],{"class":429}," note ",[262,73472,835],{"class":377},[262,73474,73475],{"class":429}," inbox:\n",[262,73477,73478,73480,73482],{"class":181,"line":28672},[262,73479,9233],{"class":429},[262,73481,476],{"class":377},[262,73483,73484],{"class":429}," classify_safely(note)\n",[262,73486,73487,73490,73492,73495,73497,73500,73502,73504,73506],{"class":181,"line":28683},[262,73488,73489],{"class":429},"        flag ",[262,73491,476],{"class":377},[262,73493,73494],{"class":275}," \"URGENT\"",[262,73496,20850],{"class":377},[262,73498,73499],{"class":429}," result[",[262,73501,73275],{"class":275},[262,73503,2903],{"class":429},[262,73505,20859],{"class":377},[262,73507,73508],{"class":275}," \"normal\"\n",[262,73510,73511,73513,73515,73517,73519,73521,73523,73526,73528,73531,73533,73536,73538,73540,73543,73545,73547,73549,73552,73554,73556],{"class":181,"line":28710},[262,73512,2299],{"class":271},[262,73514,602],{"class":429},[262,73516,642],{"class":377},[262,73518,3527],{"class":275},[262,73520,3039],{"class":271},[262,73522,24025],{"class":429},[262,73524,73525],{"class":275},"'category'",[262,73527,6223],{"class":429},[262,73529,73530],{"class":377},":>8",[262,73532,654],{"class":271},[262,73534,73535],{"class":275},"] [",[262,73537,3039],{"class":271},[262,73539,8291],{"class":429},[262,73541,73542],{"class":377},":>6",[262,73544,654],{"class":271},[262,73546,2903],{"class":275},[262,73548,3039],{"class":271},[262,73550,73551],{"class":429},"note",[262,73553,654],{"class":271},[262,73555,1176],{"class":275},[262,73557,660],{"class":429},[14,73559,73560,73561,73564,73565,73568],{},"Run it and you get a clean, validated label for every message, ready to route into a spreadsheet, a database, or another script. Two details are doing quiet but important work. The validation step means a stray or hallucinated label can never leak downstream; it is rewritten to ",[18,73562,73563],{},"other",". And the ",[18,73566,73567],{},"classify_safely"," wrapper means a single malformed reply, which will eventually happen across a large batch, logs a warning and continues instead of crashing the run. That combination of a tight prompt and defensive code is the payoff of prompt engineering: the same prompt, applied at scale, with output you can trust even on the awkward, mixed-intent message.",[57,73570,2355],{"id":2354},[14,73572,73573],{},"You can now structure prompts, teach by example, enforce JSON, and iterate. Build on that in this order:",[1447,73575,73576,73581,73586,73591],{},[1450,73577,73578,73579,1363],{},"Turn proven prompts into ready-to-paste sets with ",[51,73580,5270],{"href":5269},[1450,73582,73583,73584,1363],{},"Get strict, reliable structure every time by following ",[51,73585,1362],{"href":1361},[1450,73587,73588,73589,1363],{},"Understand the requests behind the SDK with ",[51,73590,2487],{"href":2486},[1450,73592,73593,73594,1363],{},"Put your classifier to work on real chores in ",[51,73595,21230],{"href":21229},[14,73597,2375,73598,1363],{},[51,73599,26450],{"href":26449},[57,73601,2381],{"id":2380},[2322,73603,73604,73608,73612,73616,73620],{},[1450,73605,73606],{},[51,73607,5270],{"href":5269},[1450,73609,73610],{},[51,73611,1362],{"href":1361},[1450,73613,73614],{},[51,73615,2487],{"href":2486},[1450,73617,73618],{},[51,73619,21230],{"href":21229},[1450,73621,73622],{},[51,73623,26450],{"href":26449},[2401,73625,19746],{},{"title":258,"searchDepth":282,"depth":282,"links":73627},[73628,73629,73630,73631,73632,73633,73634,73635,73636,73637],{"id":237,"depth":282,"text":238},{"id":71584,"depth":282,"text":71585},{"id":71823,"depth":282,"text":71824},{"id":72077,"depth":282,"text":72078},{"id":72304,"depth":282,"text":72305},{"id":8299,"depth":282,"text":8300},{"id":1444,"depth":282,"text":1445},{"id":72831,"depth":282,"text":72832},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Learn prompt engineering with Python: system vs user prompts, few-shot examples, JSON output, and parameter tuning to get reliable AI results every time.",[73640,73643,73646,73649,73652],{"q":73641,"a":73642},"What is the difference between a system prompt and a user prompt?","A system prompt sets the model's role, rules, and tone for the whole conversation. A user prompt is the specific request you want answered right now. The system prompt shapes how every user prompt is interpreted.",{"q":73644,"a":73645},"What is few-shot prompting?","Few-shot prompting means showing the model two or three worked examples of the input and the output you want before giving it the real task. The examples teach the model the exact pattern and format to copy, which makes results far more consistent.",{"q":73647,"a":73648},"How do I force an AI model to return valid JSON?","Ask for JSON in the prompt and also set the response_format parameter to {\"type\": \"json_object\"} when calling the OpenAI API. The parameter guarantees the response is parseable JSON, while the prompt defines which keys it should contain.",{"q":73650,"a":73651},"What temperature should I use for prompt engineering?","Use a temperature of 0.0 to 0.3 for factual, repeatable tasks like extraction, classification, and JSON output. Use 0.7 to 1.0 for creative writing where variety helps. Lower values make the same prompt return nearly the same answer each time.",{"q":73653,"a":73654},"Do I need to know how to code to learn prompt engineering?","No. You can practise prompt structure in any chat tool. Moving the same prompts into a short Python script simply lets you reuse them, feed in many inputs, and validate the output automatically instead of copying and pasting by hand.",{"name":73656,"steps":73657},"How to engineer reliable prompts with Python",[73658,73661,73664,73667],{"name":73659,"text":73660},"Separate system and user prompts","Put the model's role and rules in the system message and the specific task in the user message.",{"name":73662,"text":73663},"Add few-shot examples","Show two or three example input-output pairs so the model copies the pattern you want.",{"name":73665,"text":73666},"Lock the output format","Request structured output and set response_format to json_object so the reply parses cleanly.",{"name":73668,"text":73669},"Iterate on the prompt","Change one element at a time, rerun the script, and keep the version that scores best.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics",{"title":71330,"description":73638},"python-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Findex","YYbQbOMASwszrmnCCbkUhHrAfCVNb7Q4eY98YS6LrhY",{"id":73676,"title":5270,"body":73677,"description":75112,"extension":2419,"faq":75113,"howto":75129,"meta":75147,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":75148,"published":46211,"seo":75149,"seoTitle":5270,"stem":75150,"__hash__":75151},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fprompt-engineering-templates-for-marketers\u002Findex.md",{"type":7,"value":73678,"toc":75100},[73679,73682,73685,73688,73690,73702,73705,73738,73744,73753,73760,73764,73770,74089,74100,74104,74110,74225,74228,74286,74290,74293,74440,74448,74452,74455,74570,74576,74580,74587,74901,74907,74909,74916,74986,74988,75049,75051,75074,75076,75098],[10,73680,5270],{"id":73681},"prompt-engineering-templates-for-marketers",[14,73683,73684],{},"This guide shows you how to build reusable prompt templates in Python that turn out ad copy, email sequences, and social posts on demand, in well under an hour. If you have ever retyped the same prompt into a chat box with small tweaks, then copied the answer into a spreadsheet by hand, this replaces all of that with a script you run once per campaign.",[14,73686,73687],{},"A template is just instruction text with blanks in it, like the product name or the platform, that you fill with real values in code. Because the wording stays fixed and only the specifics change, every output comes back in the same shape and style. That consistency is what lets you feed the results straight into your scheduler or content calendar instead of cleaning up each one by hand. You will write three templates, fill them from Python, call the OpenAI API, and save everything to a file you can open in any spreadsheet.",[57,73689,238],{"id":237},[14,73691,73692,73693,73695,73696,73698,73699,73701],{},"You need Python 3.10 or newer and an OpenAI API key. If Python is not set up yet, work through ",[51,73694,5423],{"href":5422}," first, and if API keys and requests are new to you, ",[51,73697,2487],{"href":2486}," covers them from scratch. The broader ",[51,73700,7554],{"href":7553}," section explains the ideas behind the templates below.",[14,73703,73704],{},"Create a project folder and an isolated environment so these packages do not collide with anything else on your machine. A virtual environment is a private copy of Python that only this project uses.",[253,73706,73708],{"className":255,"code":73707,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate  # Windows: .venv\\Scripts\\activate\npip install openai python-dotenv\n",[18,73709,73710,73720,73728],{"__ignoreMap":258},[262,73711,73712,73714,73716,73718],{"class":181,"line":264},[262,73713,416],{"class":267},[262,73715,272],{"class":271},[262,73717,276],{"class":275},[262,73719,279],{"class":275},[262,73721,73722,73724,73726],{"class":181,"line":282},[262,73723,285],{"class":271},[262,73725,288],{"class":275},[262,73727,26589],{"class":291},[262,73729,73730,73732,73734,73736],{"class":181,"line":295},[262,73731,298],{"class":267},[262,73733,301],{"class":275},[262,73735,2519],{"class":275},[262,73737,2522],{"class":275},[14,73739,73740,73741,73743],{},"Store your API key in a file named ",[18,73742,319],{}," in the project root rather than pasting it into the script, so the key never ends up in your code:",[253,73745,73747],{"className":323,"code":73746,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=your_api_key_here\n",[18,73748,73749],{"__ignoreMap":258},[262,73750,73751],{"class":181,"line":264},[262,73752,73746],{},[14,73754,353,73755,356,73757,73759],{},[18,73756,319],{},[18,73758,359],{}," so the key is never committed to version control. A leaked key can be used by anyone to run up charges on your account.",[57,73761,73763],{"id":73762},"step-1-set-up-the-client-and-a-reusable-api-caller","Step 1: Set up the client and a reusable API caller",[14,73765,73766,73767,73769],{},"Every template will send a prompt through the same path, so write that path once. The code below loads your key, creates the OpenAI client, and wraps the API call in a small helper that asks for JSON and retries briefly if the network hiccups. JSON mode (the ",[18,73768,5745],{}," setting) forces the model to return valid JSON, so reading the result back never trips over a stray sentence.",[253,73771,73773],{"className":414,"code":73772,"language":416,"meta":258,"style":258},"import os\nimport json\nimport time\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef run_prompt(prompt: str, system: str = \"Return ONLY valid JSON.\",\n               temperature: float = 0.4, retries: int = 2) -> dict:\n    \"\"\"Send one filled prompt and return the parsed JSON object.\"\"\"\n    for attempt in range(retries):\n        try:\n            response = client.chat.completions.create(\n                model=\"gpt-4o-mini\",\n                messages=[\n                    {\"role\": \"system\", \"content\": system},\n                    {\"role\": \"user\", \"content\": prompt},\n                ],\n                response_format={\"type\": \"json_object\"},\n                temperature=temperature,\n                timeout=30,\n            )\n            return json.loads(response.choices[0].message.content)\n        except Exception as error:\n            if attempt == retries - 1:\n                raise\n            print(f\"Retrying after error: {error}\")\n            time.sleep(2)\n",[18,73774,73775,73781,73787,73793,73803,73813,73817,73821,73839,73843,73847,73870,73896,73901,73913,73919,73927,73937,73945,73961,73977,73981,73997,74005,74016,74020,74030,74040,74056,74060,74081],{"__ignoreMap":258},[262,73776,73777,73779],{"class":181,"line":264},[262,73778,684],{"class":377},[262,73780,687],{"class":429},[262,73782,73783,73785],{"class":181,"line":282},[262,73784,684],{"class":377},[262,73786,5766],{"class":429},[262,73788,73789,73791],{"class":181,"line":295},[262,73790,684],{"class":377},[262,73792,2612],{"class":429},[262,73794,73795,73797,73799,73801],{"class":181,"line":345},[262,73796,705],{"class":377},[262,73798,708],{"class":429},[262,73800,684],{"class":377},[262,73802,713],{"class":429},[262,73804,73805,73807,73809,73811],{"class":181,"line":492},[262,73806,705],{"class":377},[262,73808,720],{"class":429},[262,73810,684],{"class":377},[262,73812,725],{"class":429},[262,73814,73815],{"class":181,"line":503},[262,73816,583],{"emptyLinePlaceholder":582},[262,73818,73819],{"class":181,"line":521},[262,73820,734],{"class":429},[262,73822,73823,73825,73827,73829,73831,73833,73835,73837],{"class":181,"line":537},[262,73824,739],{"class":429},[262,73826,476],{"class":377},[262,73828,1588],{"class":429},[262,73830,2674],{"class":611},[262,73832,476],{"class":377},[262,73834,1199],{"class":429},[262,73836,2681],{"class":275},[262,73838,2684],{"class":429},[262,73840,73841],{"class":181,"line":549},[262,73842,583],{"emptyLinePlaceholder":582},[262,73844,73845],{"class":181,"line":570},[262,73846,583],{"emptyLinePlaceholder":582},[262,73848,73849,73851,73854,73856,73858,73861,73863,73865,73868],{"class":181,"line":579},[262,73850,423],{"class":377},[262,73852,73853],{"class":267}," run_prompt",[262,73855,9599],{"class":429},[262,73857,433],{"class":271},[262,73859,73860],{"class":429},", system: ",[262,73862,433],{"class":271},[262,73864,442],{"class":377},[262,73866,73867],{"class":275}," \"Return ONLY valid JSON.\"",[262,73869,1315],{"class":429},[262,73871,73872,73875,73877,73879,73882,73884,73886,73888,73890,73892,73894],{"class":181,"line":586},[262,73873,73874],{"class":429},"               temperature: ",[262,73876,3832],{"class":271},[262,73878,442],{"class":377},[262,73880,73881],{"class":271}," 0.4",[262,73883,39233],{"class":429},[262,73885,439],{"class":271},[262,73887,442],{"class":377},[262,73889,3232],{"class":271},[262,73891,1939],{"class":429},[262,73893,5869],{"class":271},[262,73895,1160],{"class":429},[262,73897,73898],{"class":181,"line":591},[262,73899,73900],{"class":275},"    \"\"\"Send one filled prompt and return the parsed JSON object.\"\"\"\n",[262,73902,73903,73905,73907,73909,73911],{"class":181,"line":623},[262,73904,3074],{"class":377},[262,73906,3077],{"class":429},[262,73908,835],{"class":377},[262,73910,3082],{"class":271},[262,73912,39302],{"class":429},[262,73914,73915,73917],{"class":181,"line":634},[262,73916,3090],{"class":377},[262,73918,1160],{"class":429},[262,73920,73921,73923,73925],{"class":181,"line":845},[262,73922,3097],{"class":429},[262,73924,476],{"class":377},[262,73926,1189],{"class":429},[262,73928,73929,73931,73933,73935],{"class":181,"line":850},[262,73930,3106],{"class":611},[262,73932,476],{"class":377},[262,73934,1207],{"class":275},[262,73936,1315],{"class":429},[262,73938,73939,73941,73943],{"class":181,"line":864},[262,73940,3117],{"class":611},[262,73942,476],{"class":377},[262,73944,1220],{"class":429},[262,73946,73947,73949,73951,73953,73955,73957,73959],{"class":181,"line":1683},[262,73948,3126],{"class":429},[262,73950,1228],{"class":275},[262,73952,1231],{"class":429},[262,73954,1234],{"class":275},[262,73956,608],{"class":429},[262,73958,1239],{"class":275},[262,73960,7739],{"class":429},[262,73962,73963,73965,73967,73969,73971,73973,73975],{"class":181,"line":1688},[262,73964,3126],{"class":429},[262,73966,1228],{"class":275},[262,73968,1231],{"class":429},[262,73970,1291],{"class":275},[262,73972,608],{"class":429},[262,73974,1239],{"class":275},[262,73976,38272],{"class":429},[262,73978,73979],{"class":181,"line":1693},[262,73980,3165],{"class":429},[262,73982,73983,73985,73987,73989,73991,73993,73995],{"class":181,"line":1728},[262,73984,9738],{"class":611},[262,73986,476],{"class":377},[262,73988,3039],{"class":429},[262,73990,6025],{"class":275},[262,73992,1231],{"class":429},[262,73994,6030],{"class":275},[262,73996,3143],{"class":429},[262,73998,73999,74001,74003],{"class":181,"line":1737},[262,74000,3170],{"class":611},[262,74002,476],{"class":377},[262,74004,7851],{"class":429},[262,74006,74007,74010,74012,74014],{"class":181,"line":1751},[262,74008,74009],{"class":611},"                timeout",[262,74011,476],{"class":377},[262,74013,9777],{"class":271},[262,74015,1315],{"class":429},[262,74017,74018],{"class":181,"line":1764},[262,74019,3193],{"class":429},[262,74021,74022,74024,74026,74028],{"class":181,"line":1779},[262,74023,3198],{"class":377},[262,74025,6043],{"class":429},[262,74027,102],{"class":271},[262,74029,6048],{"class":429},[262,74031,74032,74034,74036,74038],{"class":181,"line":1793},[262,74033,3214],{"class":377},[262,74035,10361],{"class":271},[262,74037,10364],{"class":377},[262,74039,14529],{"class":429},[262,74041,74042,74044,74046,74048,74050,74052,74054],{"class":181,"line":1800},[262,74043,10200],{"class":377},[262,74045,3077],{"class":429},[262,74047,10758],{"class":377},[262,74049,39432],{"class":429},[262,74051,561],{"class":377},[262,74053,3243],{"class":271},[262,74055,1160],{"class":429},[262,74057,74058],{"class":181,"line":1805},[262,74059,39443],{"class":377},[262,74061,74062,74064,74066,74068,74071,74073,74075,74077,74079],{"class":181,"line":1810},[262,74063,3250],{"class":271},[262,74065,602],{"class":429},[262,74067,642],{"class":377},[262,74069,74070],{"class":275},"\"Retrying after error: ",[262,74072,3039],{"class":271},[262,74074,14554],{"class":429},[262,74076,654],{"class":271},[262,74078,1176],{"class":275},[262,74080,660],{"class":429},[262,74082,74083,74085,74087],{"class":181,"line":1823},[262,74084,9913],{"class":429},[262,74086,109],{"class":271},[262,74088,660],{"class":429},[14,74090,3349,74091,74093,74094,74096,74097,74099],{},[18,74092,4466],{}," message sets the rules for every reply, the ",[18,74095,3829],{}," controls how random the wording is, and the ",[18,74098,9496],{}," carries the specifics. Keeping all three as arguments means each template can adjust them without rewriting the call.",[57,74101,74103],{"id":74102},"step-2-write-a-reusable-ad-copy-template","Step 2: Write a reusable ad copy template",[14,74105,74106,74107,74109],{},"A template is a regular Python string with placeholders. Here an f-string (a string prefixed with ",[18,74108,642],{}," that lets you drop variables straight into the text using curly braces) injects the product, audience, platform, and tone. The function returns a ready-to-use ad with a headline, body, and call to action.",[253,74111,74113],{"className":414,"code":74112,"language":416,"meta":258,"style":258},"def generate_ad_copy(product_name: str, target_audience: str,\n                     platform: str, tone: str) -> dict:\n    prompt = f\"\"\"Write one {platform} ad for '{product_name}'.\nAudience: {target_audience}.\nTone: {tone}.\nOnly use facts implied by the product name; do not invent prices or claims.\nReturn a JSON object with keys: 'headline', 'body', 'cta'.\"\"\"\n    return run_prompt(prompt)\n",[18,74114,74115,74133,74151,74180,74195,74208,74213,74218],{"__ignoreMap":258},[262,74116,74117,74119,74122,74124,74126,74129,74131],{"class":181,"line":264},[262,74118,423],{"class":377},[262,74120,74121],{"class":267}," generate_ad_copy",[262,74123,2997],{"class":429},[262,74125,433],{"class":271},[262,74127,74128],{"class":429},", target_audience: ",[262,74130,433],{"class":271},[262,74132,1315],{"class":429},[262,74134,74135,74138,74140,74143,74145,74147,74149],{"class":181,"line":282},[262,74136,74137],{"class":429},"                     platform: ",[262,74139,433],{"class":271},[262,74141,74142],{"class":429},", tone: ",[262,74144,433],{"class":271},[262,74146,1939],{"class":429},[262,74148,5869],{"class":271},[262,74150,1160],{"class":429},[262,74152,74153,74155,74157,74159,74162,74164,74166,74168,74171,74173,74175,74177],{"class":181,"line":295},[262,74154,18006],{"class":429},[262,74156,476],{"class":377},[262,74158,10178],{"class":377},[262,74160,74161],{"class":275},"\"\"\"Write one ",[262,74163,3039],{"class":271},[262,74165,16576],{"class":429},[262,74167,654],{"class":271},[262,74169,74170],{"class":275}," ad for '",[262,74172,3039],{"class":271},[262,74174,2564],{"class":429},[262,74176,654],{"class":271},[262,74178,74179],{"class":275},"'.\n",[262,74181,74182,74185,74187,74190,74192],{"class":181,"line":345},[262,74183,74184],{"class":275},"Audience: ",[262,74186,3039],{"class":271},[262,74188,74189],{"class":429},"target_audience",[262,74191,654],{"class":271},[262,74193,74194],{"class":275},".\n",[262,74196,74197,74200,74202,74204,74206],{"class":181,"line":492},[262,74198,74199],{"class":275},"Tone: ",[262,74201,3039],{"class":271},[262,74203,7526],{"class":429},[262,74205,654],{"class":271},[262,74207,74194],{"class":275},[262,74209,74210],{"class":181,"line":503},[262,74211,74212],{"class":275},"Only use facts implied by the product name; do not invent prices or claims.\n",[262,74214,74215],{"class":181,"line":521},[262,74216,74217],{"class":275},"Return a JSON object with keys: 'headline', 'body', 'cta'.\"\"\"\n",[262,74219,74220,74222],{"class":181,"line":537},[262,74221,573],{"class":377},[262,74223,74224],{"class":429}," run_prompt(prompt)\n",[14,74226,74227],{},"Because the structure is fixed, you can call this for any product on any platform and always get the same three fields back. Try it with a single line:",[253,74229,74231],{"className":414,"code":74230,"language":416,"meta":258,"style":258},"ad = generate_ad_copy(\"EcoBottle\", \"fitness enthusiasts\", \"Instagram\", \"motivational\")\nprint(ad[\"headline\"], \"|\", ad[\"cta\"])\n",[18,74232,74233,74263],{"__ignoreMap":258},[262,74234,74235,74238,74240,74243,74246,74248,74251,74253,74256,74258,74261],{"class":181,"line":264},[262,74236,74237],{"class":429},"ad ",[262,74239,476],{"class":377},[262,74241,74242],{"class":429}," generate_ad_copy(",[262,74244,74245],{"class":275},"\"EcoBottle\"",[262,74247,608],{"class":429},[262,74249,74250],{"class":275},"\"fitness enthusiasts\"",[262,74252,608],{"class":429},[262,74254,74255],{"class":275},"\"Instagram\"",[262,74257,608],{"class":429},[262,74259,74260],{"class":275},"\"motivational\"",[262,74262,660],{"class":429},[262,74264,74265,74267,74270,74273,74275,74278,74281,74284],{"class":181,"line":282},[262,74266,637],{"class":271},[262,74268,74269],{"class":429},"(ad[",[262,74271,74272],{"class":275},"\"headline\"",[262,74274,1103],{"class":429},[262,74276,74277],{"class":275},"\"|\"",[262,74279,74280],{"class":429},", ad[",[262,74282,74283],{"class":275},"\"cta\"",[262,74285,3512],{"class":429},[57,74287,74289],{"id":74288},"step-3-build-an-email-sequence-template","Step 3: Build an email sequence template",[14,74291,74292],{},"Some assets are made of several pieces. A drip sequence is a set of emails sent over days, each building on the last. The template stays the same; a loop fills it once per email and passes the position so the model knows where each message sits in the series.",[253,74294,74296],{"className":414,"code":74295,"language":416,"meta":258,"style":258},"def build_email_sequence(campaign_goal: str, customer_segment: str,\n                         sequence_length: int) -> list[dict]:\n    emails = []\n    for position in range(1, sequence_length + 1):\n        prompt = f\"\"\"Write email {position} of a {sequence_length}-part drip sequence.\nGoal: {campaign_goal}.\nAudience segment: {customer_segment}.\nReturn a JSON object with keys: 'subject', 'preview_text', 'body'.\"\"\"\n        emails.append(run_prompt(prompt))\n    return emails\n",[18,74297,74298,74317,74330,74339,74363,74395,74409,74423,74428,74433],{"__ignoreMap":258},[262,74299,74300,74302,74305,74308,74310,74313,74315],{"class":181,"line":264},[262,74301,423],{"class":377},[262,74303,74304],{"class":267}," build_email_sequence",[262,74306,74307],{"class":429},"(campaign_goal: ",[262,74309,433],{"class":271},[262,74311,74312],{"class":429},", customer_segment: ",[262,74314,433],{"class":271},[262,74316,1315],{"class":429},[262,74318,74319,74322,74324,74326,74328],{"class":181,"line":282},[262,74320,74321],{"class":429},"                         sequence_length: ",[262,74323,439],{"class":271},[262,74325,458],{"class":429},[262,74327,5869],{"class":271},[262,74329,463],{"class":429},[262,74331,74332,74335,74337],{"class":181,"line":295},[262,74333,74334],{"class":429},"    emails ",[262,74336,476],{"class":377},[262,74338,489],{"class":429},[262,74340,74341,74343,74346,74348,74350,74352,74354,74357,74359,74361],{"class":181,"line":345},[262,74342,3074],{"class":377},[262,74344,74345],{"class":429}," position ",[262,74347,835],{"class":377},[262,74349,3082],{"class":271},[262,74351,602],{"class":429},[262,74353,997],{"class":271},[262,74355,74356],{"class":429},", sequence_length ",[262,74358,531],{"class":377},[262,74360,3243],{"class":271},[262,74362,8192],{"class":429},[262,74364,74365,74368,74370,74372,74375,74377,74380,74382,74385,74387,74390,74392],{"class":181,"line":492},[262,74366,74367],{"class":429},"        prompt ",[262,74369,476],{"class":377},[262,74371,10178],{"class":377},[262,74373,74374],{"class":275},"\"\"\"Write email ",[262,74376,3039],{"class":271},[262,74378,74379],{"class":429},"position",[262,74381,654],{"class":271},[262,74383,74384],{"class":275}," of a ",[262,74386,3039],{"class":271},[262,74388,74389],{"class":429},"sequence_length",[262,74391,654],{"class":271},[262,74393,74394],{"class":275},"-part drip sequence.\n",[262,74396,74397,74400,74402,74405,74407],{"class":181,"line":503},[262,74398,74399],{"class":275},"Goal: ",[262,74401,3039],{"class":271},[262,74403,74404],{"class":429},"campaign_goal",[262,74406,654],{"class":271},[262,74408,74194],{"class":275},[262,74410,74411,74414,74416,74419,74421],{"class":181,"line":521},[262,74412,74413],{"class":275},"Audience segment: ",[262,74415,3039],{"class":271},[262,74417,74418],{"class":429},"customer_segment",[262,74420,654],{"class":271},[262,74422,74194],{"class":275},[262,74424,74425],{"class":181,"line":537},[262,74426,74427],{"class":275},"Return a JSON object with keys: 'subject', 'preview_text', 'body'.\"\"\"\n",[262,74429,74430],{"class":181,"line":549},[262,74431,74432],{"class":429},"        emails.append(run_prompt(prompt))\n",[262,74434,74435,74437],{"class":181,"line":570},[262,74436,573],{"class":377},[262,74438,74439],{"class":429}," emails\n",[14,74441,74442,74443,1374,74445,74447],{},"Passing ",[18,74444,74379],{},[18,74446,74389],{}," into the prompt gives the model the context it needs to vary each email, so the first one welcomes the reader and the last one pushes for the sale.",[57,74449,74451],{"id":74450},"step-4-build-a-social-calendar-template","Step 4: Build a social calendar template",[14,74453,74454],{},"JSON mode always returns a single top-level object, so when you want a list of posts, ask the model to wrap that list inside a named key and pull it out after parsing. This template produces a full calendar with text, hashtags, an image idea, and a date for each post.",[253,74456,74458],{"className":414,"code":74457,"language":416,"meta":258,"style":258},"def generate_social_calendar(niche: str, post_count: int,\n                             content_themes: list[str]) -> list[dict]:\n    prompt = f\"\"\"Create a {post_count}-post social calendar for the {niche} niche.\nThemes to rotate through: {', '.join(content_themes)}.\nReturn a JSON object with one key 'posts' whose value is an array of objects,\neach with keys: 'post_text', 'hashtags', 'image_idea', 'scheduled_date'.\"\"\"\n    result = run_prompt(prompt)\n    return result.get(\"posts\", [])\n",[18,74459,74460,74479,74492,74523,74540,74545,74550,74558],{"__ignoreMap":258},[262,74461,74462,74464,74467,74470,74472,74475,74477],{"class":181,"line":264},[262,74463,423],{"class":377},[262,74465,74466],{"class":267}," generate_social_calendar",[262,74468,74469],{"class":429},"(niche: ",[262,74471,433],{"class":271},[262,74473,74474],{"class":429},", post_count: ",[262,74476,439],{"class":271},[262,74478,1315],{"class":429},[262,74480,74481,74484,74486,74488,74490],{"class":181,"line":282},[262,74482,74483],{"class":429},"                             content_themes: list[",[262,74485,433],{"class":271},[262,74487,18433],{"class":429},[262,74489,5869],{"class":271},[262,74491,463],{"class":429},[262,74493,74494,74496,74498,74500,74503,74505,74508,74510,74513,74515,74518,74520],{"class":181,"line":295},[262,74495,18006],{"class":429},[262,74497,476],{"class":377},[262,74499,10178],{"class":377},[262,74501,74502],{"class":275},"\"\"\"Create a ",[262,74504,3039],{"class":271},[262,74506,74507],{"class":429},"post_count",[262,74509,654],{"class":271},[262,74511,74512],{"class":275},"-post social calendar for the ",[262,74514,3039],{"class":271},[262,74516,74517],{"class":429},"niche",[262,74519,654],{"class":271},[262,74521,74522],{"class":275}," niche.\n",[262,74524,74525,74528,74530,74533,74536,74538],{"class":181,"line":345},[262,74526,74527],{"class":275},"Themes to rotate through: ",[262,74529,3039],{"class":271},[262,74531,74532],{"class":275},"', '",[262,74534,74535],{"class":429},".join(content_themes)",[262,74537,654],{"class":271},[262,74539,74194],{"class":275},[262,74541,74542],{"class":181,"line":492},[262,74543,74544],{"class":275},"Return a JSON object with one key 'posts' whose value is an array of objects,\n",[262,74546,74547],{"class":181,"line":503},[262,74548,74549],{"class":275},"each with keys: 'post_text', 'hashtags', 'image_idea', 'scheduled_date'.\"\"\"\n",[262,74551,74552,74554,74556],{"class":181,"line":521},[262,74553,13177],{"class":429},[262,74555,476],{"class":377},[262,74557,74224],{"class":429},[262,74559,74560,74562,74565,74567],{"class":181,"line":537},[262,74561,573],{"class":377},[262,74563,74564],{"class":429}," result.get(",[262,74566,18184],{"class":275},[262,74568,74569],{"class":429},", [])\n",[14,74571,3349,74572,74575],{},[18,74573,74574],{},"', '.join(content_themes)"," turns your list of themes into a comma-separated string the model can read, so you pass themes as a normal Python list and the template handles the formatting.",[57,74577,74579],{"id":74578},"step-5-save-the-outputs-to-a-file","Step 5: Save the outputs to a file",[14,74581,74582,74583,74586],{},"Generated copy is only useful once it leaves the script. This helper writes any list of results to a CSV that opens in Excel, Google Sheets, or most scheduling tools. The ",[18,74584,74585],{},"__main__"," block ties the whole flow together: generate, then save.",[253,74588,74590],{"className":414,"code":74589,"language":416,"meta":258,"style":258},"import csv\n\n\ndef export_to_csv(rows: list[dict], filename: str = \"marketing_outputs.csv\") -> None:\n    if not rows:\n        print(\"Nothing to save.\")\n        return\n    with open(filename, \"w\", newline=\"\", encoding=\"utf-8\") as file:\n        writer = csv.DictWriter(file, fieldnames=rows[0].keys())\n        writer.writeheader()\n        writer.writerows(rows)\n    print(f\"Saved {len(rows)} rows to {filename}\")\n\n\nif __name__ == \"__main__\":\n    ads = [generate_ad_copy(\"EcoBottle\", \"fitness enthusiasts\", \"Instagram\", \"motivational\")]\n    export_to_csv(ads, \"ads.csv\")\n\n    emails = build_email_sequence(\"trial signups\", \"new subscribers\", 3)\n    export_to_csv(emails, \"emails.csv\")\n\n    calendar = generate_social_calendar(\"sustainable fitness\", 7,\n                                        [\"product tips\", \"customer stories\", \"behind the scenes\"])\n    export_to_csv(calendar, \"calendar.csv\")\n",[18,74591,74592,74598,74602,74606,74633,74642,74653,74657,74692,74718,74722,74726,74755,74759,74763,74775,74801,74811,74815,74838,74848,74852,74871,74891],{"__ignoreMap":258},[262,74593,74594,74596],{"class":181,"line":264},[262,74595,684],{"class":377},[262,74597,8533],{"class":429},[262,74599,74600],{"class":181,"line":282},[262,74601,583],{"emptyLinePlaceholder":582},[262,74603,74604],{"class":181,"line":295},[262,74605,583],{"emptyLinePlaceholder":582},[262,74607,74608,74610,74613,74615,74617,74620,74622,74624,74627,74629,74631],{"class":181,"line":345},[262,74609,423],{"class":377},[262,74611,74612],{"class":267}," export_to_csv",[262,74614,61972],{"class":429},[262,74616,5869],{"class":271},[262,74618,74619],{"class":429},"], filename: ",[262,74621,433],{"class":271},[262,74623,442],{"class":377},[262,74625,74626],{"class":275}," \"marketing_outputs.csv\"",[262,74628,1939],{"class":429},[262,74630,8471],{"class":271},[262,74632,1160],{"class":429},[262,74634,74635,74637,74639],{"class":181,"line":492},[262,74636,3454],{"class":377},[262,74638,2818],{"class":377},[262,74640,74641],{"class":429}," rows:\n",[262,74643,74644,74646,74648,74651],{"class":181,"line":503},[262,74645,2299],{"class":271},[262,74647,602],{"class":429},[262,74649,74650],{"class":275},"\"Nothing to save.\"",[262,74652,660],{"class":429},[262,74654,74655],{"class":181,"line":521},[262,74656,16967],{"class":377},[262,74658,74659,74661,74663,74666,74668,74670,74672,74674,74676,74678,74680,74682,74684,74686,74688,74690],{"class":181,"line":537},[262,74660,10124],{"class":377},[262,74662,599],{"class":271},[262,74664,74665],{"class":429},"(filename, ",[262,74667,9165],{"class":275},[262,74669,608],{"class":429},[262,74671,9170],{"class":611},[262,74673,476],{"class":377},[262,74675,9175],{"class":275},[262,74677,608],{"class":429},[262,74679,612],{"class":611},[262,74681,476],{"class":377},[262,74683,617],{"class":275},[262,74685,1000],{"class":429},[262,74687,697],{"class":377},[262,74689,71119],{"class":611},[262,74691,1160],{"class":429},[262,74693,74694,74696,74698,74701,74704,74706,74708,74710,74713,74715],{"class":181,"line":549},[262,74695,10623],{"class":429},[262,74697,476],{"class":377},[262,74699,74700],{"class":429}," csv.DictWriter(",[262,74702,74703],{"class":611},"file",[262,74705,608],{"class":429},[262,74707,10631],{"class":611},[262,74709,476],{"class":377},[262,74711,74712],{"class":429},"rows[",[262,74714,102],{"class":271},[262,74716,74717],{"class":429},"].keys())\n",[262,74719,74720],{"class":181,"line":570},[262,74721,10641],{"class":429},[262,74723,74724],{"class":181,"line":579},[262,74725,62094],{"class":429},[262,74727,74728,74730,74732,74734,74736,74738,74740,74742,74744,74746,74749,74751,74753],{"class":181,"line":586},[262,74729,1089],{"class":271},[262,74731,602],{"class":429},[262,74733,642],{"class":377},[262,74735,3753],{"class":275},[262,74737,648],{"class":271},[262,74739,62247],{"class":429},[262,74741,654],{"class":271},[262,74743,27615],{"class":275},[262,74745,3039],{"class":271},[262,74747,74748],{"class":429},"filename",[262,74750,654],{"class":271},[262,74752,1176],{"class":275},[262,74754,660],{"class":429},[262,74756,74757],{"class":181,"line":591},[262,74758,583],{"emptyLinePlaceholder":582},[262,74760,74761],{"class":181,"line":623},[262,74762,583],{"emptyLinePlaceholder":582},[262,74764,74765,74767,74769,74771,74773],{"class":181,"line":634},[262,74766,2210],{"class":377},[262,74768,2213],{"class":271},[262,74770,2216],{"class":377},[262,74772,2219],{"class":275},[262,74774,1160],{"class":429},[262,74776,74777,74780,74782,74785,74787,74789,74791,74793,74795,74797,74799],{"class":181,"line":845},[262,74778,74779],{"class":429},"    ads ",[262,74781,476],{"class":377},[262,74783,74784],{"class":429}," [generate_ad_copy(",[262,74786,74245],{"class":275},[262,74788,608],{"class":429},[262,74790,74250],{"class":275},[262,74792,608],{"class":429},[262,74794,74255],{"class":275},[262,74796,608],{"class":429},[262,74798,74260],{"class":275},[262,74800,18503],{"class":429},[262,74802,74803,74806,74809],{"class":181,"line":850},[262,74804,74805],{"class":429},"    export_to_csv(ads, ",[262,74807,74808],{"class":275},"\"ads.csv\"",[262,74810,660],{"class":429},[262,74812,74813],{"class":181,"line":864},[262,74814,583],{"emptyLinePlaceholder":582},[262,74816,74817,74819,74821,74824,74827,74829,74832,74834,74836],{"class":181,"line":1683},[262,74818,74334],{"class":429},[262,74820,476],{"class":377},[262,74822,74823],{"class":429}," build_email_sequence(",[262,74825,74826],{"class":275},"\"trial signups\"",[262,74828,608],{"class":429},[262,74830,74831],{"class":275},"\"new subscribers\"",[262,74833,608],{"class":429},[262,74835,5556],{"class":271},[262,74837,660],{"class":429},[262,74839,74840,74843,74846],{"class":181,"line":1688},[262,74841,74842],{"class":429},"    export_to_csv(emails, ",[262,74844,74845],{"class":275},"\"emails.csv\"",[262,74847,660],{"class":429},[262,74849,74850],{"class":181,"line":1693},[262,74851,583],{"emptyLinePlaceholder":582},[262,74853,74854,74857,74859,74862,74865,74867,74869],{"class":181,"line":1728},[262,74855,74856],{"class":429},"    calendar ",[262,74858,476],{"class":377},[262,74860,74861],{"class":429}," generate_social_calendar(",[262,74863,74864],{"class":275},"\"sustainable fitness\"",[262,74866,608],{"class":429},[262,74868,7163],{"class":271},[262,74870,1315],{"class":429},[262,74872,74873,74876,74879,74881,74884,74886,74889],{"class":181,"line":1737},[262,74874,74875],{"class":429},"                                        [",[262,74877,74878],{"class":275},"\"product tips\"",[262,74880,608],{"class":429},[262,74882,74883],{"class":275},"\"customer stories\"",[262,74885,608],{"class":429},[262,74887,74888],{"class":275},"\"behind the scenes\"",[262,74890,3512],{"class":429},[262,74892,74893,74896,74899],{"class":181,"line":1751},[262,74894,74895],{"class":429},"    export_to_csv(calendar, ",[262,74897,74898],{"class":275},"\"calendar.csv\"",[262,74900,660],{"class":429},[14,74902,74903,74904,74906],{},"Run the file with ",[18,74905,13313],{},". You will get three CSV files, one per asset type, each ready to review and import.",[57,74908,5155],{"id":5154},[14,74910,74911,74912,74915],{},"These are the settings on the API call that most affect your marketing output. Adjust them in ",[18,74913,74914],{},"run_prompt"," or pass them per template.",[1379,74917,74918,74930],{},[1382,74919,74920],{},[1385,74921,74922,74924,74926,74928],{},[1388,74923,1390],{},[1388,74925,3795],{},[1388,74927,5171],{},[1388,74929,1396],{},[1398,74931,74932,74952,74970],{},[1385,74933,74934,74938,74940,74944],{},[1403,74935,74936],{},[18,74937,805],{},[1403,74939,3811],{},[1403,74941,74942],{},[18,74943,2703],{},[1403,74945,45977,74946,74948,74949,74951],{},[18,74947,2703],{}," is fast and cheap; ",[18,74950,3821],{}," is stronger for nuanced copy.",[1385,74953,74954,74958,74960,74964],{},[1403,74955,74956],{},[18,74957,3829],{},[1403,74959,3832],{},[1403,74961,74962],{},[18,74963,3175],{},[1403,74965,74966,74967,74969],{},"Randomness of wording. Near ",[18,74968,102],{}," gives consistent, on-brand copy; higher gives more variety.",[1385,74971,74972,74976,74978,74983],{},[1403,74973,74974],{},[18,74975,5745],{},[1403,74977,36804],{},[1403,74979,74980],{},[18,74981,74982],{},"json_object",[1403,74984,74985],{},"Forces valid JSON so your code can read the result without text cleanup.",[57,74987,1445],{"id":1444},[1447,74989,74990,75004,75021,75037],{},[1450,74991,74992,74998,74999,75001,75002,1363],{},[35,74993,74994,74997],{},[18,74995,74996],{},"json.JSONDecodeError"," when reading the result."," The model returned text that is not valid JSON, usually because JSON mode was off or the system message did not ask for JSON. Keep ",[18,75000,6878],{}," on the call and the words \"Return ONLY valid JSON\" in the system message. The deeper fixes are in ",[51,75003,6114],{"href":6113},[1450,75005,75006,75012,75013,75016,75017,75020],{},[35,75007,75008,75011],{},[18,75009,75010],{},"KeyError: 'posts'"," from the calendar function."," The model wrapped its list under a different key name. The ",[18,75014,75015],{},".get(\"posts\", [])"," already returns an empty list instead of crashing, but if it keeps happening, repeat the exact key name ",[18,75018,75019],{},"'posts'"," in the prompt so the model cannot drift.",[1450,75022,75023,75026,75027,75029,75030,13390,75032,75034,75035,1363],{},[35,75024,75025],{},"A 401 error before any output appears."," Your key was not loaded. Confirm the ",[18,75028,319],{}," file sits next to the script, the variable is spelled ",[18,75031,21742],{},[18,75033,8439],{}," runs before the client is created. Step through it with ",[51,75036,388],{"href":387},[1450,75038,75039,75042,75043,75045,75046,75048],{},[35,75040,75041],{},"A 429 error when generating long sequences."," Too many calls landed too quickly for your account tier. The loop in the email and calendar templates sends one request at a time, so add a short ",[18,75044,8453],{}," between calls, or follow ",[51,75047,3379],{"href":3378}," for a proper backoff.",[57,75050,2317],{"id":2316},[2322,75052,75053,75059,75065],{},[1450,75054,75055,75058],{},[35,75056,75057],{},"Use Python templates"," when you generate the same kind of asset over and over, such as ads for a catalogue of products or a weekly social calendar. The cost of writing the script pays off the second time you run it, and every output stays consistent.",[1450,75060,75061,75064],{},[35,75062,75063],{},"Use a chat window"," when you are exploring ideas for a single one-off piece and the wording matters more than repeatability. There is no point scripting something you will write once.",[1450,75066,75067,75070,75071,75073],{},[35,75068,75069],{},"Reach for stricter output control"," when the shape of the result has to be exact, for example feeding a database. Pair these templates with the validation approach in ",[51,75072,1362],{"href":1361}," so missing fields are caught before they reach your other tools.",[57,75075,2381],{"id":2380},[2322,75077,75078,75082,75086,75090,75094],{},[1450,75079,2375,75080,1363],{},[51,75081,7554],{"href":7553},[1450,75083,75084],{},[51,75085,1362],{"href":1361},[1450,75087,75088],{},[51,75089,2487],{"href":2486},[1450,75091,75092],{},[51,75093,4011],{"href":4010},[1450,75095,75096],{},[51,75097,26450],{"href":26449},[2401,75099,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":75101},[75102,75103,75104,75105,75106,75107,75108,75109,75110,75111],{"id":237,"depth":282,"text":238},{"id":73762,"depth":282,"text":73763},{"id":74102,"depth":282,"text":74103},{"id":74288,"depth":282,"text":74289},{"id":74450,"depth":282,"text":74451},{"id":74578,"depth":282,"text":74579},{"id":5154,"depth":282,"text":5155},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Build reusable Python prompt templates for ads, emails, and social posts. Fill variables, call the OpenAI API, and save consistent outputs every run.",[75114,75117,75120,75123,75126],{"q":75115,"a":75116},"What is a prompt template?","A prompt template is a reusable block of instruction text with blanks in it, like the product name or the platform. You fill the blanks with real values in your code, so every prompt has the same proven structure and only the specifics change. It turns a one-off message into something you can run a hundred times.",{"q":75118,"a":75119},"Why use Python instead of typing prompts into ChatGPT?","Typing prompts by hand is slow and inconsistent, and you cannot save or reuse the wording. A Python script fills the same template, calls the API, and writes the results to a file in seconds. You get repeatable outputs and a record of exactly what was sent.",{"q":75121,"a":75122},"How do I get the same style of output every time?","Lower the temperature setting toward 0 so the model is less random, put your formatting rules in the system message, and ask for JSON so the shape is fixed. Together these three things make the output predictable enough to drop straight into a spreadsheet.",{"q":75124,"a":75125},"Which OpenAI model should marketers use for copy?","gpt-4o-mini is fast and cheap and handles ad copy, emails, and social posts well, so it is the right default for high-volume marketing work. Move up to gpt-4o only if you need noticeably stronger reasoning or longer, more nuanced pieces.",{"q":75127,"a":75128},"How do I stop the model from inventing fake claims?","Tell it in the system message to only use facts you provide, and pass any real details, such as price or features, as variables in the prompt. Keep temperature low and always review generated claims before publishing.",{"name":75130,"steps":75131},"How to build prompt templates for marketing with Python",[75132,75135,75138,75141,75144],{"name":75133,"text":75134},"Set up the project and API key","Create a virtual environment, install the openai SDK, and store your key in a .env file.",{"name":75136,"text":75137},"Write a reusable prompt template with variables","Define the prompt as text with blanks for product, audience, and platform, then fill them in code.",{"name":75139,"text":75140},"Call the OpenAI API and parse the result","Send the filled prompt through the chat API with JSON mode and read the structured answer back.",{"name":75142,"text":75143},"Generate ads, emails, and social posts from the templates","Wrap each template in a function so you can produce any campaign asset with one call.",{"name":75145,"text":75146},"Save the outputs to a file","Write the generated copy to a CSV so it imports cleanly into your scheduler or CRM.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fprompt-engineering-templates-for-marketers",{"title":5270,"description":75112},"python-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fprompt-engineering-templates-for-marketers\u002Findex","xEP0Q4wA8PO6SADsCNOf49eJi6YMvi8uUkbtH5y6HxU",{"id":75153,"title":1362,"body":75154,"description":76269,"extension":2419,"faq":76270,"howto":76286,"meta":76301,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":76302,"published":2452,"seo":76303,"seoTitle":76304,"stem":76305,"__hash__":76306},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fwrite-system-prompts-that-control-output-format\u002Findex.md",{"type":7,"value":75155,"toc":76257},[75156,75159,75162,75165,75167,75176,75185,75221,75226,75234,75240,75244,75263,75342,75346,75349,75463,75470,75474,75486,75635,75655,75659,75666,75787,75805,75809,75812,76078,76087,76089,76152,76154,76199,76201,76221,76227,76231,76233,76255],[10,75157,1362],{"id":75158},"write-system-prompts-that-control-output-format",[14,75160,75161],{},"This guide shows you how to force an AI model to return data in a fixed, reliable shape, such as JSON with named fields, in under fifteen minutes. If you have ever asked a model for \"a JSON object\" and got back a chatty paragraph wrapped around it, this is the fix. You will combine three layers that reinforce each other: a strict system prompt, the API's JSON mode, and a pydantic check that catches anything the model gets wrong before it reaches the rest of your program.",[14,75163,75164],{},"The payoff is code you can trust. Once the output shape is guaranteed, you can feed the model's answers straight into a spreadsheet, a database, or another script without writing brittle text-cleaning hacks. This is the difference between a one-off experiment and an automation you can leave running.",[57,75166,238],{"id":237},[14,75168,75169,75170,75172,75173,75175],{},"You need Python 3.10 or newer and an OpenAI API key. If you have not set up Python yet, work through ",[51,75171,5423],{"href":5422}," first, and if the API itself is new to you, ",[51,75174,2487],{"href":2486}," covers keys and requests from scratch.",[14,75177,75178,75179,75181,75182,75184],{},"Install the two libraries this guide uses. The ",[18,75180,20],{}," SDK talks to the API, and ",[18,75183,52536],{}," (a library that checks data against a shape you define) validates the results.",[253,75186,75188],{"className":255,"code":75187,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate  # Windows: .venv\\Scripts\\activate\npip install openai pydantic python-dotenv\n",[18,75189,75190,75200,75208],{"__ignoreMap":258},[262,75191,75192,75194,75196,75198],{"class":181,"line":264},[262,75193,416],{"class":267},[262,75195,272],{"class":271},[262,75197,276],{"class":275},[262,75199,279],{"class":275},[262,75201,75202,75204,75206],{"class":181,"line":282},[262,75203,285],{"class":271},[262,75205,288],{"class":275},[262,75207,26589],{"class":291},[262,75209,75210,75212,75214,75216,75219],{"class":181,"line":295},[262,75211,298],{"class":267},[262,75213,301],{"class":275},[262,75215,2519],{"class":275},[262,75217,75218],{"class":275}," pydantic",[262,75220,2522],{"class":275},[14,75222,9458,75223,75225],{},[18,75224,319],{}," file so it never lands in your code:",[253,75227,75228],{"className":323,"code":11159,"language":325,"meta":258,"style":258},[18,75229,75230],{"__ignoreMap":258},[262,75231,75232],{"class":181,"line":264},[262,75233,11159],{},[14,75235,353,75236,356,75238,35204],{},[18,75237,319],{},[18,75239,359],{},[57,75241,75243],{"id":75242},"how-the-three-layers-fit-together","How the three layers fit together",[14,75245,75246,75247,75250,75251,75254,75255,75258,75259,75262],{},"Before the steps, it helps to see why one layer is not enough. The ",[35,75248,75249],{},"system prompt"," is your most authoritative instruction, but on its own the model can still drift. ",[35,75252,75253],{},"JSON mode"," guarantees the text ",[27,75256,75257],{},"parses"," as JSON, but not that it has the right keys. ",[35,75260,75261],{},"Pydantic validation"," confirms the keys and types are exactly what you expected, but only after the call returns. Each layer covers the previous one's blind spot.",[76,75264,75266,75339],{"className":75265},[79],[81,75267,90,75270,90,75273,90,75276,90,75278,90,75281,90,75284,90,75286,90,75289,90,75292,90,75294,90,75298,90,75301,90,75304,90,75307,90,75310,90,75313,90,75315,90,75319,90,75323,90,75325,90,75328,90,75331,90,75333,90,75336],{"viewBox":75268,"role":84,"ariaLabelledBy":75269,"preserveAspectRatio":88,"xmlns":89},"-40 -40 860 400",[7091,7092],[92,75271,75272],{"id":7091},"Three layers that control output format",[96,75274,75275],{"id":7092},"A flow showing the system prompt, then JSON mode, then pydantic validation, with a retry loop back to the model when validation fails.",[100,75277],{"x":102,"y":140,"width":37100,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,75279,75280],{"x":37099,"y":19868,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"1. System prompt",[111,75282,75283],{"x":37099,"y":52307,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"states contract",[100,75285],{"x":12816,"y":140,"width":37100,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,75287,75288],{"x":19890,"y":19868,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"2. JSON mode",[111,75290,75291],{"x":19890,"y":52307,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"forces JSON",[100,75293],{"x":12825,"y":140,"width":37100,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,75295,75297],{"x":75296,"y":19868,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"670","3. Pydantic",[111,75299,75300],{"x":75296,"y":52307,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"checks keys\u002Ftypes",[181,75302],{"x1":37100,"y1":19937,"x2":75303,"y2":19937,"stroke":143,"strokeWidth":109},"270",[186,75305],{"points":75306,"fill":143},"280,56 268,51 268,61",[181,75308],{"x1":16427,"y1":19937,"x2":75309,"y2":19937,"stroke":143,"strokeWidth":109},"550",[186,75311],{"points":75312,"fill":143},"560,56 548,51 548,61",[100,75314],{"x":12816,"y":37100,"width":37100,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,75316,75318],{"x":19890,"y":75317,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"251","Retry with error",[111,75320,75322],{"x":19890,"y":75321,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"271","if validation fails",[181,75324],{"x1":75296,"y1":141,"x2":75296,"y2":71404,"stroke":130,"strokeWidth":109},[181,75326],{"x1":75296,"y1":71404,"x2":75327,"y2":71404,"stroke":130,"strokeWidth":109},"510",[186,75329],{"points":75330,"fill":130},"500,256 512,251 512,261",[181,75332],{"x1":12816,"y1":71404,"x2":37099,"y2":71404,"stroke":130,"strokeWidth":109},[181,75334],{"x1":37099,"y1":71404,"x2":37099,"y2":75335,"stroke":130,"strokeWidth":109},"102",[186,75337],{"points":75338,"fill":130},"110,92 105,104 115,104",[232,75340,75341],{},"Each layer covers the gap the one before it leaves open, and a failed check loops the error back to the model.",[57,75343,75345],{"id":75344},"step-1-pin-the-output-shape-in-the-system-prompt","Step 1: Pin the output shape in the system prompt",[14,75347,75348],{},"The system prompt is the single most important lever you have. Models give system-role instructions more weight than regular user messages, so this is where the format contract belongs. Be specific, list the exact keys, and include one short example of the output you want. An example does more work than a paragraph of description because the model can copy its structure directly.",[253,75350,75352],{"className":414,"code":75351,"language":416,"meta":258,"style":258},"import os\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nSYSTEM_PROMPT = \"\"\"You extract contact details from messy text.\nReturn ONLY a JSON object with exactly these keys:\n  \"name\": string, the person's full name\n  \"email\": string, their email address, or \"\" if none is present\n  \"company\": string, their employer, or \"\" if unknown\nDo not add commentary, markdown, or extra keys.\nExample: {\"name\": \"Ada Lovelace\", \"email\": \"ada@analytical.io\", \"company\": \"Analytical Engines\"}\"\"\"\n\nuser_text = \"Hi, I'm Grace Hopper from Cobol Corp. Reach me at grace@cobolcorp.com.\"\n",[18,75353,75354,75360,75370,75380,75384,75388,75406,75410,75419,75424,75429,75434,75439,75444,75449,75453],{"__ignoreMap":258},[262,75355,75356,75358],{"class":181,"line":264},[262,75357,684],{"class":377},[262,75359,687],{"class":429},[262,75361,75362,75364,75366,75368],{"class":181,"line":282},[262,75363,705],{"class":377},[262,75365,720],{"class":429},[262,75367,684],{"class":377},[262,75369,725],{"class":429},[262,75371,75372,75374,75376,75378],{"class":181,"line":295},[262,75373,705],{"class":377},[262,75375,708],{"class":429},[262,75377,684],{"class":377},[262,75379,713],{"class":429},[262,75381,75382],{"class":181,"line":345},[262,75383,583],{"emptyLinePlaceholder":582},[262,75385,75386],{"class":181,"line":492},[262,75387,734],{"class":429},[262,75389,75390,75392,75394,75396,75398,75400,75402,75404],{"class":181,"line":503},[262,75391,739],{"class":429},[262,75393,476],{"class":377},[262,75395,1588],{"class":429},[262,75397,2674],{"class":611},[262,75399,476],{"class":377},[262,75401,1199],{"class":429},[262,75403,2681],{"class":275},[262,75405,2684],{"class":429},[262,75407,75408],{"class":181,"line":521},[262,75409,583],{"emptyLinePlaceholder":582},[262,75411,75412,75414,75416],{"class":181,"line":537},[262,75413,2941],{"class":271},[262,75415,442],{"class":377},[262,75417,75418],{"class":275}," \"\"\"You extract contact details from messy text.\n",[262,75420,75421],{"class":181,"line":549},[262,75422,75423],{"class":275},"Return ONLY a JSON object with exactly these keys:\n",[262,75425,75426],{"class":181,"line":570},[262,75427,75428],{"class":275},"  \"name\": string, the person's full name\n",[262,75430,75431],{"class":181,"line":579},[262,75432,75433],{"class":275},"  \"email\": string, their email address, or \"\" if none is present\n",[262,75435,75436],{"class":181,"line":586},[262,75437,75438],{"class":275},"  \"company\": string, their employer, or \"\" if unknown\n",[262,75440,75441],{"class":181,"line":591},[262,75442,75443],{"class":275},"Do not add commentary, markdown, or extra keys.\n",[262,75445,75446],{"class":181,"line":623},[262,75447,75448],{"class":275},"Example: {\"name\": \"Ada Lovelace\", \"email\": \"ada@analytical.io\", \"company\": \"Analytical Engines\"}\"\"\"\n",[262,75450,75451],{"class":181,"line":634},[262,75452,583],{"emptyLinePlaceholder":582},[262,75454,75455,75458,75460],{"class":181,"line":845},[262,75456,75457],{"class":429},"user_text ",[262,75459,476],{"class":377},[262,75461,75462],{"class":275}," \"Hi, I'm Grace Hopper from Cobol Corp. Reach me at grace@cobolcorp.com.\"\n",[14,75464,75465,75466,75469],{},"Notice the prompt names every key, gives a fallback for missing values (an empty string rather than a guess), and forbids extras. That last sentence matters: without it, models like to add a friendly ",[18,75467,75468],{},"\"note\""," field you never asked for.",[57,75471,75473],{"id":75472},"step-2-turn-on-json-mode","Step 2: Turn on JSON mode",[14,75475,75476,75477,75479,75480,75482,75483,75485],{},"JSON mode is the API setting ",[18,75478,6878],{},". With it on, the model is constrained to emit text that parses as valid JSON, so ",[18,75481,20396],{}," will not choke on a stray sentence. One rule comes with it: your prompt must mention JSON somewhere, which the system prompt above already does. Pair it with ",[18,75484,1357],{}," so the model picks the most predictable wording every time.",[253,75487,75489],{"className":414,"code":75488,"language":416,"meta":258,"style":258},"import json\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n        {\"role\": \"user\", \"content\": user_text},\n    ],\n    response_format={\"type\": \"json_object\"},\n    temperature=0,\n)\n\nraw = response.choices[0].message.content\ndata = json.loads(raw)\nprint(data)  # {'name': 'Grace Hopper', 'email': 'grace@cobolcorp.com', 'company': 'Cobol Corp'}\n",[18,75490,75491,75497,75501,75509,75519,75527,75547,75564,75568,75585,75595,75599,75603,75616,75625],{"__ignoreMap":258},[262,75492,75493,75495],{"class":181,"line":264},[262,75494,684],{"class":377},[262,75496,5766],{"class":429},[262,75498,75499],{"class":181,"line":282},[262,75500,583],{"emptyLinePlaceholder":582},[262,75502,75503,75505,75507],{"class":181,"line":295},[262,75504,48362],{"class":429},[262,75506,476],{"class":377},[262,75508,1189],{"class":429},[262,75510,75511,75513,75515,75517],{"class":181,"line":345},[262,75512,48371],{"class":611},[262,75514,476],{"class":377},[262,75516,1207],{"class":275},[262,75518,1315],{"class":429},[262,75520,75521,75523,75525],{"class":181,"line":492},[262,75522,48388],{"class":611},[262,75524,476],{"class":377},[262,75526,1220],{"class":429},[262,75528,75529,75531,75533,75535,75537,75539,75541,75543,75545],{"class":181,"line":503},[262,75530,7726],{"class":429},[262,75532,1228],{"class":275},[262,75534,1231],{"class":429},[262,75536,1234],{"class":275},[262,75538,608],{"class":429},[262,75540,1239],{"class":275},[262,75542,1231],{"class":429},[262,75544,2941],{"class":271},[262,75546,3143],{"class":429},[262,75548,75549,75551,75553,75555,75557,75559,75561],{"class":181,"line":521},[262,75550,7726],{"class":429},[262,75552,1228],{"class":275},[262,75554,1231],{"class":429},[262,75556,1291],{"class":275},[262,75558,608],{"class":429},[262,75560,1239],{"class":275},[262,75562,75563],{"class":429},": user_text},\n",[262,75565,75566],{"class":181,"line":537},[262,75567,48439],{"class":429},[262,75569,75570,75573,75575,75577,75579,75581,75583],{"class":181,"line":549},[262,75571,75572],{"class":611},"    response_format",[262,75574,476],{"class":377},[262,75576,3039],{"class":429},[262,75578,6025],{"class":275},[262,75580,1231],{"class":429},[262,75582,6030],{"class":275},[262,75584,3143],{"class":429},[262,75586,75587,75589,75591,75593],{"class":181,"line":570},[262,75588,48444],{"class":611},[262,75590,476],{"class":377},[262,75592,102],{"class":271},[262,75594,1315],{"class":429},[262,75596,75597],{"class":181,"line":579},[262,75598,660],{"class":429},[262,75600,75601],{"class":181,"line":586},[262,75602,583],{"emptyLinePlaceholder":582},[262,75604,75605,75608,75610,75612,75614],{"class":181,"line":591},[262,75606,75607],{"class":429},"raw ",[262,75609,476],{"class":377},[262,75611,1326],{"class":429},[262,75613,102],{"class":271},[262,75615,1331],{"class":429},[262,75617,75618,75620,75622],{"class":181,"line":623},[262,75619,70069],{"class":429},[262,75621,476],{"class":377},[262,75623,75624],{"class":429}," json.loads(raw)\n",[262,75626,75627,75629,75632],{"class":181,"line":634},[262,75628,637],{"class":271},[262,75630,75631],{"class":429},"(data)  ",[262,75633,75634],{"class":291},"# {'name': 'Grace Hopper', 'email': 'grace@cobolcorp.com', 'company': 'Cobol Corp'}\n",[14,75636,75637,75638,75640,75641,75644,75645,75648,75649,75651,75652,75654],{},"JSON mode guarantees the text ",[27,75639,75257],{},", but it does not guarantee the ",[27,75642,75643],{},"contents",". The model could still return ",[18,75646,75647],{},"{\"full_name\": \"Grace Hopper\"}"," with the wrong key, or drop the ",[18,75650,52940],{}," field entirely. That gap is exactly what the next step closes. If you do hit a parsing failure despite JSON mode, the dedicated walkthrough ",[51,75653,6114],{"href":6113}," covers the usual culprits.",[57,75656,75658],{"id":75657},"step-3-validate-the-result-with-pydantic","Step 3: Validate the result with pydantic",[14,75660,75661,75662,75665],{},"Pydantic lets you declare the shape you expect as a small class, then check any parsed data against it in one line. If a field is missing, misnamed, or the wrong type, pydantic raises a ",[18,75663,75664],{},"ValidationError"," with a clear message instead of letting the broken data slip into the rest of your program.",[253,75667,75669],{"className":414,"code":75668,"language":416,"meta":258,"style":258},"from pydantic import BaseModel, EmailStr, ValidationError\n\n\nclass Contact(BaseModel):\n    name: str\n    email: str  # use EmailStr to also reject malformed addresses\n    company: str\n\n\ntry:\n    contact = Contact.model_validate(data)\n    print(contact.name, contact.email)\nexcept ValidationError as exc:\n    print(\"The model returned an unexpected shape:\")\n    print(exc)\n",[18,75670,75671,75682,75686,75690,75703,75710,75720,75727,75731,75735,75741,75751,75758,75769,75780],{"__ignoreMap":258},[262,75672,75673,75675,75677,75679],{"class":181,"line":264},[262,75674,705],{"class":377},[262,75676,53609],{"class":429},[262,75678,684],{"class":377},[262,75680,75681],{"class":429}," BaseModel, EmailStr, ValidationError\n",[262,75683,75684],{"class":181,"line":282},[262,75685,583],{"emptyLinePlaceholder":582},[262,75687,75688],{"class":181,"line":295},[262,75689,583],{"emptyLinePlaceholder":582},[262,75691,75692,75694,75697,75699,75701],{"class":181,"line":345},[262,75693,7374],{"class":377},[262,75695,75696],{"class":267}," Contact",[262,75698,602],{"class":429},[262,75700,53697],{"class":267},[262,75702,8192],{"class":429},[262,75704,75705,75708],{"class":181,"line":492},[262,75706,75707],{"class":429},"    name: ",[262,75709,8677],{"class":271},[262,75711,75712,75715,75717],{"class":181,"line":503},[262,75713,75714],{"class":429},"    email: ",[262,75716,433],{"class":271},[262,75718,75719],{"class":291},"  # use EmailStr to also reject malformed addresses\n",[262,75721,75722,75725],{"class":181,"line":521},[262,75723,75724],{"class":429},"    company: ",[262,75726,8677],{"class":271},[262,75728,75729],{"class":181,"line":537},[262,75730,583],{"emptyLinePlaceholder":582},[262,75732,75733],{"class":181,"line":549},[262,75734,583],{"emptyLinePlaceholder":582},[262,75736,75737,75739],{"class":181,"line":570},[262,75738,14430],{"class":377},[262,75740,1160],{"class":429},[262,75742,75743,75746,75748],{"class":181,"line":579},[262,75744,75745],{"class":429},"    contact ",[262,75747,476],{"class":377},[262,75749,75750],{"class":429}," Contact.model_validate(data)\n",[262,75752,75753,75755],{"class":181,"line":586},[262,75754,1089],{"class":271},[262,75756,75757],{"class":429},"(contact.name, contact.email)\n",[262,75759,75760,75762,75765,75767],{"class":181,"line":591},[262,75761,14433],{"class":377},[262,75763,75764],{"class":429}," ValidationError ",[262,75766,697],{"class":377},[262,75768,9840],{"class":429},[262,75770,75771,75773,75775,75778],{"class":181,"line":623},[262,75772,1089],{"class":271},[262,75774,602],{"class":429},[262,75776,75777],{"class":275},"\"The model returned an unexpected shape:\"",[262,75779,660],{"class":429},[262,75781,75782,75784],{"class":181,"line":634},[262,75783,1089],{"class":271},[262,75785,75786],{"class":429},"(exc)\n",[14,75788,3349,75789,75792,75793,75796,75797,75800,75801,75804],{},[18,75790,75791],{},"Contact"," class is your single source of truth for the format. Add ",[18,75794,75795],{},"EmailStr"," (install with ",[18,75798,75799],{},"pip install \"pydantic[email]\"",") when you want the email checked too, or mark a field optional with a default like ",[18,75802,75803],{},"company: str = \"\"",". Because the class doubles as documentation, anyone reading your code can see the exact contract at a glance.",[57,75806,75808],{"id":75807},"step-4-handle-when-the-model-strays","Step 4: Handle when the model strays",[14,75810,75811],{},"Even with all three layers, a model occasionally returns something that fails validation. The robust pattern is a short retry loop that feeds the validation error back to the model so it can correct itself, then gives up after a fixed number of attempts rather than looping forever.",[253,75813,75815],{"className":414,"code":75814,"language":416,"meta":258,"style":258},"def get_validated_contact(text: str, max_attempts: int = 3) -> Contact:\n    messages = [\n        {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n        {\"role\": \"user\", \"content\": text},\n    ]\n    for attempt in range(max_attempts):\n        response = client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            messages=messages,\n            response_format={\"type\": \"json_object\"},\n            temperature=0,\n        )\n        raw = response.choices[0].message.content\n        try:\n            return Contact.model_validate_json(raw)\n        except ValidationError as exc:\n            messages.append({\"role\": \"assistant\", \"content\": raw})\n            messages.append({\n                \"role\": \"user\",\n                \"content\": f\"That failed validation:\\n{exc}\\nReturn corrected JSON only.\",\n            })\n    raise RuntimeError(f\"No valid output after {max_attempts} attempts\")\n",[18,75816,75817,75840,75848,75868,75884,75888,75901,75909,75919,75927,75943,75953,75957,75970,75976,75983,75993,76011,76016,76026,76048,76053],{"__ignoreMap":258},[262,75818,75819,75821,75824,75826,75828,75831,75833,75835,75837],{"class":181,"line":264},[262,75820,423],{"class":377},[262,75822,75823],{"class":267}," get_validated_contact",[262,75825,430],{"class":429},[262,75827,433],{"class":271},[262,75829,75830],{"class":429},", max_attempts: ",[262,75832,439],{"class":271},[262,75834,442],{"class":377},[262,75836,931],{"class":271},[262,75838,75839],{"class":429},") -> Contact:\n",[262,75841,75842,75844,75846],{"class":181,"line":282},[262,75843,45521],{"class":429},[262,75845,476],{"class":377},[262,75847,5589],{"class":429},[262,75849,75850,75852,75854,75856,75858,75860,75862,75864,75866],{"class":181,"line":295},[262,75851,7726],{"class":429},[262,75853,1228],{"class":275},[262,75855,1231],{"class":429},[262,75857,1234],{"class":275},[262,75859,608],{"class":429},[262,75861,1239],{"class":275},[262,75863,1231],{"class":429},[262,75865,2941],{"class":271},[262,75867,3143],{"class":429},[262,75869,75870,75872,75874,75876,75878,75880,75882],{"class":181,"line":345},[262,75871,7726],{"class":429},[262,75873,1228],{"class":275},[262,75875,1231],{"class":429},[262,75877,1291],{"class":275},[262,75879,608],{"class":429},[262,75881,1239],{"class":275},[262,75883,52724],{"class":429},[262,75885,75886],{"class":181,"line":492},[262,75887,7761],{"class":429},[262,75889,75890,75892,75894,75896,75898],{"class":181,"line":503},[262,75891,3074],{"class":377},[262,75893,3077],{"class":429},[262,75895,835],{"class":377},[262,75897,3082],{"class":271},[262,75899,75900],{"class":429},"(max_attempts):\n",[262,75902,75903,75905,75907],{"class":181,"line":521},[262,75904,21490],{"class":429},[262,75906,476],{"class":377},[262,75908,1189],{"class":429},[262,75910,75911,75913,75915,75917],{"class":181,"line":537},[262,75912,14214],{"class":611},[262,75914,476],{"class":377},[262,75916,1207],{"class":275},[262,75918,1315],{"class":429},[262,75920,75921,75923,75925],{"class":181,"line":549},[262,75922,27253],{"class":611},[262,75924,476],{"class":377},[262,75926,43186],{"class":429},[262,75928,75929,75931,75933,75935,75937,75939,75941],{"class":181,"line":570},[262,75930,70830],{"class":611},[262,75932,476],{"class":377},[262,75934,3039],{"class":429},[262,75936,6025],{"class":275},[262,75938,1231],{"class":429},[262,75940,6030],{"class":275},[262,75942,3143],{"class":429},[262,75944,75945,75947,75949,75951],{"class":181,"line":579},[262,75946,27275],{"class":611},[262,75948,476],{"class":377},[262,75950,102],{"class":271},[262,75952,1315],{"class":429},[262,75954,75955],{"class":181,"line":586},[262,75956,6288],{"class":429},[262,75958,75959,75962,75964,75966,75968],{"class":181,"line":591},[262,75960,75961],{"class":429},"        raw ",[262,75963,476],{"class":377},[262,75965,1326],{"class":429},[262,75967,102],{"class":271},[262,75969,1331],{"class":429},[262,75971,75972,75974],{"class":181,"line":623},[262,75973,3090],{"class":377},[262,75975,1160],{"class":429},[262,75977,75978,75980],{"class":181,"line":634},[262,75979,3198],{"class":377},[262,75981,75982],{"class":429}," Contact.model_validate_json(raw)\n",[262,75984,75985,75987,75989,75991],{"class":181,"line":845},[262,75986,3214],{"class":377},[262,75988,75764],{"class":429},[262,75990,697],{"class":377},[262,75992,9840],{"class":429},[262,75994,75995,75998,76000,76002,76004,76006,76008],{"class":181,"line":850},[262,75996,75997],{"class":429},"            messages.append({",[262,75999,1228],{"class":275},[262,76001,1231],{"class":429},[262,76003,43214],{"class":275},[262,76005,608],{"class":429},[262,76007,1239],{"class":275},[262,76009,76010],{"class":429},": raw})\n",[262,76012,76013],{"class":181,"line":864},[262,76014,76015],{"class":429},"            messages.append({\n",[262,76017,76018,76020,76022,76024],{"class":181,"line":1683},[262,76019,4336],{"class":275},[262,76021,1231],{"class":429},[262,76023,1291],{"class":275},[262,76025,1315],{"class":429},[262,76027,76028,76030,76032,76034,76037,76039,76041,76043,76046],{"class":181,"line":1688},[262,76029,4347],{"class":275},[262,76031,1231],{"class":429},[262,76033,642],{"class":377},[262,76035,76036],{"class":275},"\"That failed validation:",[262,76038,1268],{"class":271},[262,76040,9864],{"class":429},[262,76042,3044],{"class":271},[262,76044,76045],{"class":275},"Return corrected JSON only.\"",[262,76047,1315],{"class":429},[262,76049,76050],{"class":181,"line":1693},[262,76051,76052],{"class":429},"            })\n",[262,76054,76055,76057,76059,76061,76063,76066,76068,76071,76073,76076],{"class":181,"line":1728},[262,76056,2829],{"class":377},[262,76058,3318],{"class":271},[262,76060,602],{"class":429},[262,76062,642],{"class":377},[262,76064,76065],{"class":275},"\"No valid output after ",[262,76067,3039],{"class":271},[262,76069,76070],{"class":429},"max_attempts",[262,76072,654],{"class":271},[262,76074,76075],{"class":275}," attempts\"",[262,76077,660],{"class":429},[14,76079,76080,76083,76084,76086],{},[18,76081,76082],{},"Contact.model_validate_json(raw)"," parses and validates in a single call, so you skip the separate ",[18,76085,20396],{},". Appending the assistant's bad reply and the error gives the model the context it needs to fix its own mistake, which usually succeeds on the second try.",[57,76088,6782],{"id":1366},[1379,76090,76091,76103],{},[1382,76092,76093],{},[1385,76094,76095,76097,76099,76101],{},[1388,76096,1390],{},[1388,76098,3795],{},[1388,76100,3798],{},[1388,76102,1396],{},[1398,76104,76105,76120,76138],{},[1385,76106,76107,76111,76113,76115],{},[1403,76108,76109],{},[18,76110,5745],{},[1403,76112,5869],{},[1403,76114,219],{},[1403,76116,52065,76117,76119],{},[18,76118,6841],{}," to force parseable JSON; prompt must mention JSON.",[1385,76121,76122,76126,76128,76132],{},[1403,76123,76124],{},[18,76125,3829],{},[1403,76127,3832],{},[1403,76129,76130],{},[18,76131,17583],{},[1403,76133,76134,76135,76137],{},"Lower to ",[18,76136,102],{}," for the most consistent, repeatable shape; higher values invite drift.",[1385,76139,76140,76145,76147,76149],{},[1403,76141,76142,76144],{},[18,76143,4466],{}," role message",[1403,76146,433],{},[1403,76148,219],{},[1403,76150,76151],{},"Highest-priority slot for the format contract, key list, and one example.",[57,76153,1445],{"id":1444},[1447,76155,76156,76167,76175,76189],{},[1450,76157,76158,76163,76164,76166],{},[35,76159,76160,76162],{},[18,76161,20396],{}," raises but JSON mode is on."," You almost certainly forgot to pass ",[18,76165,6878],{}," on this specific call, or the request hit an error before the format applied. Confirm the parameter is present and that the response is not an error object.",[1450,76168,76169,76174],{},[35,76170,76171,1363],{},[18,76172,76173],{},"BadRequestError: 'messages' must contain the word 'json'"," JSON mode requires the word \"json\" somewhere in your messages. Add it to the system prompt, as the examples here do.",[1450,76176,76177,76183,76184,40584,76186,76188],{},[35,76178,76179,76180,76182],{},"Pydantic ",[18,76181,75664],{}," on a key the model renamed."," The model used ",[18,76185,72096],{},[18,76187,3552],{},". Tighten the system prompt with the exact key spelling and an example, and keep the retry loop from Step 4, which feeds the error back for a correction.",[1450,76190,76191,76198],{},[35,76192,76193,76194,76197],{},"The model wraps JSON in ",[18,76195,76196],{},"```json"," fences."," This happens when JSON mode is off. Turn JSON mode on, which strips the fences, rather than peeling them with string slicing.",[57,76200,2317],{"id":2316},[2322,76202,76203,76209,76215],{},[1450,76204,76205,76208],{},[35,76206,76207],{},"System prompt alone, no JSON mode:"," Fine for quick experiments or when your downstream code only reads the prose. As soon as another program parses the output, add JSON mode so you are not cleaning text by hand.",[1450,76210,76211,76214],{},[35,76212,76213],{},"JSON mode plus pydantic (this guide):"," The right default when you need one fixed object back and want a guarantee that the keys and types are correct. Simple, fast, and easy to reason about.",[1450,76216,76217,76220],{},[35,76218,76219],{},"Function or tool calling:"," Reach for this when the model must choose between several shaped actions or trigger real code, not just return one object. It carries more setup, so prefer JSON mode when a single schema covers your need.",[14,76222,76223,76224,76226],{},"Once you are comfortable enforcing a shape, the next move is reusing these patterns across real campaigns with ",[51,76225,5270],{"href":5269},", which applies the same JSON-and-validation discipline to ad copy and email sequences.",[14,76228,2375,76229,1363],{},[51,76230,7554],{"href":7553},[57,76232,2381],{"id":2380},[2322,76234,76235,76240,76245,76250],{},[1450,76236,76237,76239],{},[51,76238,7554],{"href":7553}," — the main guide on instructing models reliably.",[1450,76241,76242,76244],{},[51,76243,5270],{"href":5269}," — ready-to-run templates that use these same format controls.",[1450,76246,76247,76249],{},[51,76248,6114],{"href":6113}," — what to do when parsing still fails.",[1450,76251,76252,76254],{},[51,76253,2487],{"href":2486}," — keys, requests, and responses from the ground up.",[2401,76256,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":76258},[76259,76260,76261,76262,76263,76264,76265,76266,76267,76268],{"id":237,"depth":282,"text":238},{"id":75242,"depth":282,"text":75243},{"id":75344,"depth":282,"text":75345},{"id":75472,"depth":282,"text":75473},{"id":75657,"depth":282,"text":75658},{"id":75807,"depth":282,"text":75808},{"id":1366,"depth":282,"text":6782},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Use the system prompt and JSON mode to force reliable output shapes from an LLM in Python, then validate with pydantic and recover when the model strays.",[76271,76274,76277,76280,76283],{"q":76272,"a":76273},"What is a system prompt?","A system prompt is a separate instruction you pass to the model under the 'system' role that sets the rules for the whole conversation, like the output shape and tone. The model treats it with higher priority than ordinary user messages, so it is the right place to demand a fixed format.",{"q":76275,"a":76276},"What does JSON mode do in the OpenAI API?","JSON mode is the response_format={'type': 'json_object'} setting. It forces the model to return syntactically valid JSON so json.loads never fails on a stray sentence. It does not check that the keys or types match what you wanted, so you still validate the parsed object yourself.",{"q":76278,"a":76279},"How do I make sure the JSON has the exact fields I asked for?","Define the shape as a pydantic model and call MyModel.model_validate(parsed_data). If a field is missing or has the wrong type, pydantic raises a ValidationError you can catch, instead of letting bad data flow downstream.",{"q":76281,"a":76282},"Why does the model sometimes ignore my format instructions?","A high temperature, a vague or buried instruction, or a request that fights the format (asking for prose and JSON at once) all push the model off shape. Lower temperature to 0, put the rules in the system prompt, and show one concrete example of the exact output you want.",{"q":76284,"a":76285},"Should I use JSON mode or function calling for structured output?","JSON mode is simplest when you just need one object back. Function or tool calling is better when the model must choose between several shaped actions or call real code. For one fixed schema, JSON mode plus pydantic validation is usually enough.",{"name":76287,"steps":76288},"How to write system prompts that control output format",[76289,76292,76295,76298],{"name":76290,"text":76291},"Pin the output shape in the system prompt","Put the exact format rules and one example under the system role so the model treats them as the contract for every reply.",{"name":76293,"text":76294},"Turn on JSON mode","Set response_format to a json_object so the model is forced to return parseable JSON.",{"name":76296,"text":76297},"Validate the result with pydantic","Parse the JSON and run it through a pydantic model so missing or wrong-typed fields raise a clear error.",{"name":76299,"text":76300},"Handle when the model strays","Catch validation failures, send the error back to the model, and retry a small number of times.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fwrite-system-prompts-that-control-output-format",{"title":1362,"description":76269},"Write System Prompts to Control Output Format","python-ai-fundamentals-for-non-developers\u002Fprompt-engineering-basics\u002Fwrite-system-prompts-that-control-output-format\u002Findex","lSxQsksbJ1IK6IvAH0yM0Z9ZMkwpGVQGB9f7L2K9diY",{"id":76308,"title":2482,"body":76309,"description":77037,"extension":2419,"faq":77038,"howto":77054,"meta":77072,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":77073,"published":2452,"seo":77074,"seoTitle":2482,"stem":77075,"__hash__":77076},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fcreate-a-python-virtual-environment-for-ai\u002Findex.md",{"type":7,"value":76310,"toc":77025},[76311,76314,76317,76328,76330,76338,76348,76362,76366,76375,76378,76407,76410,76427,76433,76456,76460,76463,76465,76477,76479,76488,76494,76503,76519,76531,76535,76555,76584,76593,76622,76628,76640,76644,76649,76667,76677,76693,76700,76721,76725,76736,76762,76781,76783,76864,76866,76935,76937,76988,76998,77000,77022],[10,76312,2482],{"id":76313},"create-a-python-virtual-environment-for-ai",[14,76315,76316],{},"This guide shows you how to create, activate, and manage a Python virtual environment for an AI project in about ten minutes, on Mac, Windows, or Linux. A virtual environment is a private folder that holds its own copy of Python and its own installed packages, kept completely separate from the Python that came with your computer. It is the single habit that prevents the most common beginner headache: two projects fighting over which version of a package is installed.",[14,76318,76319,76320,76322,76323,76325,76326,5414],{},"Here is the problem it solves. Say you install the ",[18,76321,20],{}," SDK (the official toolkit for calling OpenAI models) straight into your system Python for a blog-writing script. Months later you start a second project that needs a newer, slightly incompatible release. Upgrade it for the new project and the old one breaks; keep the old one and the new project will not run. Only one version can exist at a time. A virtual environment per project removes that conflict entirely, because each folder carries its own pinned packages and they never see each other. This page lives inside ",[51,76324,5423],{"href":5422},", part of the ",[51,76327,26450],{"href":26449},[57,76329,238],{"id":237},[14,76331,76332,76333,407,76335,76337],{},"You only need Python 3.10 or newer installed and reachable from your terminal (the text window where you type commands instead of clicking). If you have not done that yet, follow the platform guide that matches your machine: ",[51,76334,30415],{"href":30414},[51,76336,30411],{"href":30410},". Confirm the version before you start:",[253,76339,76340],{"className":255,"code":52405,"language":257,"meta":258,"style":258},[18,76341,76342],{"__ignoreMap":258},[262,76343,76344,76346],{"class":181,"line":264},[262,76345,268],{"class":267},[262,76347,52414],{"class":271},[14,76349,76350,76351,76353,76354,76357,76358,76361],{},"On Windows the command is usually ",[18,76352,17782],{},". Either way you want to see ",[18,76355,76356],{},"Python 3.10"," or higher. The ",[18,76359,76360],{},"venv"," tool used below ships inside Python itself, so there is nothing extra to install.",[57,76363,76365],{"id":76364},"_1-create-the-virtual-environment","1. Create the virtual environment",[14,76367,76368,76369,76371,76372,76374],{},"Make a folder for your project, move into it, and ask Python to build an environment named ",[18,76370,62557],{}," inside it. The leading dot keeps the folder tidy and out of the way, and ",[18,76373,62557],{}," is the name editors like VS Code look for automatically.",[14,76376,76377],{},"On Mac or Linux:",[253,76379,76381],{"className":255,"code":76380,"language":257,"meta":258,"style":258},"mkdir ai-project && cd ai-project\npython3 -m venv .venv\n",[18,76382,76383,76397],{"__ignoreMap":258},[262,76384,76385,76387,76390,76392,76394],{"class":181,"line":264},[262,76386,7191],{"class":267},[262,76388,76389],{"class":275}," ai-project",[262,76391,7197],{"class":429},[262,76393,7200],{"class":271},[262,76395,76396],{"class":275}," ai-project\n",[262,76398,76399,76401,76403,76405],{"class":181,"line":282},[262,76400,268],{"class":267},[262,76402,272],{"class":271},[262,76404,276],{"class":275},[262,76406,279],{"class":275},[14,76408,76409],{},"On Windows (PowerShell):",[253,76411,76415],{"className":76412,"code":76413,"language":76414,"meta":258,"style":258},"language-powershell shiki shiki-themes github-light github-dark","mkdir ai-project; cd ai-project\npython -m venv .venv\n","powershell",[18,76416,76417,76422],{"__ignoreMap":258},[262,76418,76419],{"class":181,"line":264},[262,76420,76421],{},"mkdir ai-project; cd ai-project\n",[262,76423,76424],{"class":181,"line":282},[262,76425,76426],{},"python -m venv .venv\n",[14,76428,76429,76430,76432],{},"This creates a ",[18,76431,62557],{}," subfolder containing a private Python interpreter and a place for packages. It takes a few seconds and produces no output when it succeeds. You have not changed anything yet — the environment exists but is not active. Think of this step as building a clean room; the next step is walking into it.",[14,76434,76435,76436,407,76438,40584,76440,76442,76443,76445,76446,76449,76450,76452,76453,76455],{},"A word on naming and placement. People sometimes call the folder ",[18,76437,325],{},[18,76439,76360],{},[18,76441,62557],{},", and that works, but the dotted name is worth adopting as a habit for two reasons: editors and tools look for ",[18,76444,62557],{}," by default, and the leading dot hides the folder from casual file listings so it stops cluttering your view. Keep exactly one environment per project, sitting at the top level of the project folder, right next to your code and your ",[18,76447,76448],{},"requirements.txt",". Do not nest a project inside another project's environment, and do not share one environment across several projects — that reintroduces the version conflict you are trying to avoid. If you ever want a completely fresh start, you can safely delete the whole ",[18,76451,62557],{}," folder and run the create command again; nothing in it is precious, because ",[18,76454,76448],{}," is the real record of what your project needs.",[57,76457,76459],{"id":76458},"_2-activate-the-environment","2. Activate the environment",[14,76461,76462],{},"Activating tells your terminal to use the environment's private Python and pip instead of the system ones. The command differs by platform, which is the single most common point of confusion, so use the line that matches yours exactly.",[14,76464,76377],{},[253,76466,76468],{"className":255,"code":76467,"language":257,"meta":258,"style":258},"source .venv\u002Fbin\u002Factivate\n",[18,76469,76470],{"__ignoreMap":258},[262,76471,76472,76474],{"class":181,"line":264},[262,76473,285],{"class":271},[262,76475,76476],{"class":275}," .venv\u002Fbin\u002Factivate\n",[14,76478,76409],{},[253,76480,76482],{"className":76412,"code":76481,"language":76414,"meta":258,"style":258},".venv\\Scripts\\Activate.ps1\n",[18,76483,76484],{"__ignoreMap":258},[262,76485,76486],{"class":181,"line":264},[262,76487,76481],{},[14,76489,76490,76491,55766],{},"On Windows (older Command Prompt, ",[18,76492,76493],{},"cmd.exe",[253,76495,76497],{"className":255,"code":76496,"language":257,"meta":258,"style":258},".venv\\Scripts\\activate.bat\n",[18,76498,76499],{"__ignoreMap":258},[262,76500,76501],{"class":181,"line":264},[262,76502,76496],{"class":267},[14,76504,76505,76506,76508,76509,76511,76512,76514,76515,76518],{},"You will know it worked because your prompt now starts with ",[18,76507,30512],{},". From this point, every ",[18,76510,31961],{}," lands inside the environment, and ",[18,76513,416],{}," runs the isolated interpreter. When you are done working, type ",[18,76516,76517],{},"deactivate"," to step back out — the folder stays on disk, ready to activate again next time. You activate once per terminal session; closing the terminal deactivates it automatically.",[14,76520,76521,76522,76524,76525,76527,76528,76530],{},"If activation feels like a step you will forget, that is normal at first, and the ",[18,76523,30512],{}," marker in your prompt is your safety check. Before you install anything or run a script, glance at the start of the line: no ",[18,76526,30512],{},", no isolation. Many editors smooth this over for you. In VS Code, once you select the interpreter inside ",[18,76529,62557],{},", any terminal it opens activates the environment automatically, and so does the green Run button. Until that becomes second nature, the habit to build is simple — open a terminal, move into the project folder, activate, then work.",[57,76532,76534],{"id":76533},"_3-install-your-ai-packages","3. Install your AI packages",[14,76536,60565,76537,76539,76540,76542,76543,76545,76546,76548,76549,76551,76552,76554],{},[18,76538,30512],{}," showing, upgrade ",[18,76541,298],{}," (Python's package installer) so you avoid stale-version warnings, then install the core stack for API-based AI work. We use the ",[18,76544,20],{}," SDK for model calls, ",[18,76547,5450],{}," for any direct HTTP requests (it handles modern features ",[18,76550,9433],{}," does not), and ",[18,76553,2501],{}," to load secrets from a file.",[253,76556,76558],{"className":255,"code":76557,"language":257,"meta":258,"style":258},"pip install --upgrade pip\npip install openai httpx python-dotenv\n",[18,76559,76560,76572],{"__ignoreMap":258},[262,76561,76562,76564,76566,76569],{"class":181,"line":264},[262,76563,298],{"class":267},[262,76565,301],{"class":275},[262,76567,76568],{"class":271}," --upgrade",[262,76570,76571],{"class":275}," pip\n",[262,76573,76574,76576,76578,76580,76582],{"class":181,"line":282},[262,76575,298],{"class":267},[262,76577,301],{"class":275},[262,76579,2519],{"class":275},[262,76581,5440],{"class":275},[262,76583,2522],{"class":275},[14,76585,76586,76587,76589,76590,76592],{},"These packages install only inside ",[18,76588,62557],{},", so your system Python stays untouched. To prove the isolation is real, ask your shell which Python is active — the path should point inside your project's ",[18,76591,62557],{}," folder:",[253,76594,76596],{"className":255,"code":76595,"language":257,"meta":258,"style":258},"# Mac\u002FLinux\nwhich python\n# Windows PowerShell\nwhere.exe python\n",[18,76597,76598,76603,76610,76615],{"__ignoreMap":258},[262,76599,76600],{"class":181,"line":264},[262,76601,76602],{"class":291},"# Mac\u002FLinux\n",[262,76604,76605,76607],{"class":181,"line":282},[262,76606,35038],{"class":271},[262,76608,76609],{"class":275}," python\n",[262,76611,76612],{"class":181,"line":295},[262,76613,76614],{"class":291},"# Windows PowerShell\n",[262,76616,76617,76620],{"class":181,"line":345},[262,76618,76619],{"class":267},"where.exe",[262,76621,76609],{"class":275},[14,76623,76624,76625,76627],{},"If that path runs through your ",[18,76626,62557],{}," folder, every install from now on is safely contained.",[14,76629,76630,76631,76633,76634,76636,76637,76639],{},"It is worth understanding what just happened, because it explains why the isolation holds. ",[18,76632,298],{}," installs packages into the Python it is bound to, and activating the environment swapped in the ",[18,76635,298],{}," that lives inside ",[18,76638,62557],{},". So the three commands you ran — upgrade pip, install the packages, check the path — all operated on the private copy, never on your system Python. That is the whole mechanism: one folder, one interpreter, one set of packages, with nothing leaking out to other projects or in from them.",[57,76641,76643],{"id":76642},"_4-freeze-requirementstxt","4. Freeze requirements.txt",[14,76645,16693,76646,76648],{},[18,76647,76448],{}," file is a plain list of the exact package versions your project uses. It lets you — or a teammate, or your future self on a new laptop — rebuild an identical environment with one command. Generate it from the currently installed packages:",[253,76650,76652],{"className":255,"code":76651,"language":257,"meta":258,"style":258},"pip freeze > requirements.txt\n",[18,76653,76654],{"__ignoreMap":258},[262,76655,76656,76658,76661,76664],{"class":181,"line":264},[262,76657,298],{"class":267},[262,76659,76660],{"class":275}," freeze",[262,76662,76663],{"class":377}," >",[262,76665,76666],{"class":275}," requirements.txt\n",[14,76668,76669,76670,76673,76674,76676],{},"Open the file and you will see pinned lines like ",[18,76671,76672],{},"openai==1.51.0",". To recreate the environment elsewhere, someone clones your project, creates and activates a fresh ",[18,76675,62557],{},", and runs:",[253,76678,76680],{"className":255,"code":76679,"language":257,"meta":258,"style":258},"pip install -r requirements.txt\n",[18,76681,76682],{"__ignoreMap":258},[262,76683,76684,76686,76688,76691],{"class":181,"line":264},[262,76685,298],{"class":267},[262,76687,301],{"class":275},[262,76689,76690],{"class":271}," -r",[262,76692,76666],{"class":275},[14,76694,76695,76696,76699],{},"Re-run ",[18,76697,76698],{},"pip freeze > requirements.txt"," whenever you add or upgrade a package, and commit the updated file. This is what makes an AI project reproducible instead of \"works on my machine.\"",[14,76701,76702,76703,76706,76707,608,76709,608,76711,76713,76714,76716,76717,76720],{},"One subtle point worth knowing: ",[18,76704,76705],{},"pip freeze"," records every installed package, including the smaller libraries your main packages depend on. That is fine and even helpful for exact reproduction. If you later want a cleaner file that lists only the packages you chose directly — ",[18,76708,20],{},[18,76710,5450],{},[18,76712,2501],{}," — you can write those by hand into ",[18,76715,76448],{}," instead, and ",[18,76718,76719],{},"pip install -r requirements.txt"," will pull in their dependencies automatically. For a beginner, the frozen version is the safer choice, because it pins everything and removes any chance of a surprise upgrade changing behaviour between machines.",[57,76722,76724],{"id":76723},"_5-ignore-the-environment-in-git","5. Ignore the environment in Git",[14,76726,3349,76727,76729,76730,76732,76733,76735],{},[18,76728,62557],{}," folder can hold thousands of files and is specific to your operating system, so it should never go into version control. Neither should any ",[18,76731,319],{}," file holding API keys. Create or append to ",[18,76734,359],{}," in your project root:",[253,76737,76739],{"className":255,"code":76738,"language":257,"meta":258,"style":258},"echo \".venv\u002F\" >> .gitignore\necho \".env\" >> .gitignore\n",[18,76740,76741,76752],{"__ignoreMap":258},[262,76742,76743,76745,76748,76750],{"class":181,"line":264},[262,76744,371],{"class":271},[262,76746,76747],{"class":275}," \".venv\u002F\"",[262,76749,378],{"class":377},[262,76751,381],{"class":275},[262,76753,76754,76756,76758,76760],{"class":181,"line":282},[262,76755,371],{"class":271},[262,76757,374],{"class":275},[262,76759,378],{"class":377},[262,76761,381],{"class":275},[14,76763,76764,76765,76767,76768,1374,76770,76772,76773,76775,76776,3921,76778,76780],{},"That is the rule to internalize: commit ",[18,76766,76448],{},", ignore ",[18,76769,62557],{},[18,76771,319],{},". Whenever you store a credential in a ",[18,76774,319],{}," file, add ",[18,76777,319],{},[18,76779,359],{}," in the same breath so a key never lands in a public repository.",[57,76782,18801],{"id":18800},[1379,76784,76785,76797],{},[1382,76786,76787],{},[1385,76788,76789,76792,76795],{},[1388,76790,76791],{},"Command",[1388,76793,76794],{},"Default \u002F where",[1388,76796,1396],{},[1398,76798,76799,76816,76828,76840,76852],{},[1385,76800,76801,76806,76813],{},[1403,76802,76803],{},[18,76804,76805],{},"python -m venv .venv",[1403,76807,76808,76809,76812],{},"creates ",[18,76810,76811],{},".venv\u002F"," in current folder",[1403,76814,76815],{},"builds the isolated environment",[1385,76817,76818,76822,76825],{},[1403,76819,76820],{},[18,76821,30519],{},[1403,76823,76824],{},"Mac\u002FLinux only",[1403,76826,76827],{},"activates the environment for this session",[1385,76829,76830,76835,76838],{},[1403,76831,76832],{},[18,76833,76834],{},".venv\\Scripts\\Activate.ps1",[1403,76836,76837],{},"Windows PowerShell only",[1403,76839,76827],{},[1385,76841,76842,76846,76849],{},[1403,76843,76844],{},[18,76845,76698],{},[1403,76847,76848],{},"writes to project root",[1403,76850,76851],{},"records exact installed versions",[1385,76853,76854,76858,76861],{},[1403,76855,76856],{},[18,76857,76517],{},[1403,76859,76860],{},"works on any platform",[1403,76862,76863],{},"exits the environment, returns to system Python",[57,76865,1445],{"id":1444},[1447,76867,76868,76884,76895,76906,76921],{},[1450,76869,76870,76875,76876,76879,76880,76883],{},[35,76871,76872],{},[18,76873,76874],{},"Activate.ps1 cannot be loaded because running scripts is disabled"," — Windows PowerShell blocks scripts by default. The cause is the execution policy. Run ",[18,76877,76878],{},"Set-ExecutionPolicy -Scope CurrentUser RemoteSigned"," once, answer ",[18,76881,76882],{},"Y",", then activate again. This affects only your user account, not the whole system.",[1450,76885,76886,76891,76892,76894],{},[35,76887,76888,76890],{},[18,76889,31961],{}," puts packages somewhere unexpected, or imports fail"," — Your environment is not active. The cause is a fresh terminal, which always starts deactivated. Re-run the activation command for your platform and confirm ",[18,76893,30512],{}," appears in the prompt before installing.",[1450,76896,76897,76903,76904,1363],{},[35,76898,76899,76902],{},[18,76900,76901],{},"which python"," points to the wrong interpreter"," — Your editor or terminal is still using system Python. The cause is usually a tool that opened before you activated. Close and reopen the terminal, activate again, and in VS Code use \"Python: Select Interpreter\" to choose the one inside ",[18,76905,62557],{},[1450,76907,76908,76914,76915,76917,76918,76920],{},[35,76909,76910,76913],{},[18,76911,76912],{},"source: command not found"," on Windows"," — The ",[18,76916,285],{}," command is Mac and Linux only. The cause is copying a Mac instruction into PowerShell. Use ",[18,76919,76834],{}," instead, with backslashes.",[1450,76922,76923,76929,76930,58640,76932,76934],{},[35,76924,76925,76928],{},[18,76926,76927],{},"No module named openai"," even though you installed it"," — You installed into one environment and are running from another, or from system Python. The cause is almost always a missing or mismatched activation. Activate the project's ",[18,76931,62557],{},[18,76933,30515],{}," to confirm the package is present, then run your script from that same active session.",[57,76936,2317],{"id":2316},[2322,76938,76939,76951,76965],{},[1450,76940,76941,76945,76946,76948,76949,1363],{},[35,76942,24211,76943],{},[18,76944,76360],{}," for almost every project in this track. It ships with Python, needs nothing extra, and is the right default for API-based AI work that mostly installs the ",[18,76947,20],{}," SDK and a few helpers. If you are unsure, choose ",[18,76950,76360],{},[1450,76952,76953,76958,76959,76961,76962,76964],{},[35,76954,24211,76955],{},[18,76956,76957],{},"conda"," when a project depends on heavy scientific or GPU libraries with non-Python parts — think CUDA-backed deep-learning stacks. ",[18,76960,76957],{}," manages those system-level dependencies that plain ",[18,76963,298],{}," cannot, at the cost of a larger install and a slower workflow.",[1450,76966,76967,76972,76973,76975,76976,13751,76978,71844,76981,76984,76985,76987],{},[35,76968,24211,76969],{},[18,76970,76971],{},"uv"," when speed matters and you are comfortable trying a newer tool. ",[18,76974,76971],{}," creates environments and installs packages in seconds rather than minutes, with commands close to ",[18,76977,76360],{},[18,76979,76980],{},"uv venv",[18,76982,76983],{},"uv pip install ...","). It is an excellent upgrade once the basics here feel natural, and it reads the same ",[18,76986,76448],{}," you already produce.",[14,76989,76990,76991,76993,76994,28880,76996,1363],{},"With an isolated environment in place, you are ready to install AI packages confidently and run your first model call. A good next step is to compare which models to call in ",[51,76992,14635],{"href":14634}," or to find no-cost options in ",[51,76995,5485],{"href":5484},[51,76997,5423],{"href":5422},[57,76999,2381],{"id":2380},[2322,77001,77002,77007,77012,77017],{},[1450,77003,77004,77006],{},[51,77005,5423],{"href":5422}," — the full setup section this guide belongs to.",[1450,77008,77009,77011],{},[51,77010,30415],{"href":30414}," — get Python itself onto a Mac first.",[1450,77013,77014,77016],{},[51,77015,30411],{"href":30410}," — the Windows install walkthrough.",[1450,77018,77019,77021],{},[51,77020,5485],{"href":5484}," — pick a model to call once your environment is ready.",[2401,77023,77024],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":258,"searchDepth":282,"depth":282,"links":77026},[77027,77028,77029,77030,77031,77032,77033,77034,77035,77036],{"id":237,"depth":282,"text":238},{"id":76364,"depth":282,"text":76365},{"id":76458,"depth":282,"text":76459},{"id":76533,"depth":282,"text":76534},{"id":76642,"depth":282,"text":76643},{"id":76723,"depth":282,"text":76724},{"id":18800,"depth":282,"text":18801},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Create and activate a Python virtual environment with venv on Mac, Windows, and Linux, install AI packages, freeze requirements.txt, and avoid version clashes.",[77039,77042,77045,77048,77051],{"q":77040,"a":77041},"What is a Python virtual environment?","A virtual environment is a private folder that holds its own copy of Python and its own installed packages, separate from the system Python and every other project. It lets each AI project pin the exact package versions it needs without affecting anything else on your computer.",{"q":77043,"a":77044},"Do I need a virtual environment for an AI project?","Yes, for any project that installs packages like the openai SDK. Without one, every project shares the same packages, so upgrading a library for one project can silently break another. A virtual environment keeps each project's versions isolated and reproducible.",{"q":77046,"a":77047},"How do I activate a venv on Windows?","In PowerShell, run .venv\\Scripts\\Activate.ps1 from your project folder. If PowerShell blocks the script, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned once, then activate again. Your prompt shows (.venv) when it is active.",{"q":77049,"a":77050},"What is the difference between venv, conda, and uv?","venv ships with Python and is enough for most API-based AI work. conda manages whole scientific stacks and non-Python dependencies like CUDA. uv is a fast newer tool that creates environments and installs packages in seconds, with a workflow close to venv.",{"q":77052,"a":77053},"Should I commit my virtual environment to Git?","No. Add .venv to your .gitignore and commit only requirements.txt. Anyone who clones your project recreates the same environment from that file, so the folder itself never belongs in version control.",{"name":77055,"steps":77056},"How to create a Python virtual environment for AI",[77057,77060,77063,77066,77069],{"name":77058,"text":77059},"Make a project folder and create the environment","Create a folder for your project and run python -m venv .venv inside it to build an isolated environment.",{"name":77061,"text":77062},"Activate the environment","Run the activation command for your operating system so packages install into the environment instead of system Python.",{"name":77064,"text":77065},"Install your AI packages","Upgrade pip, then install openai, httpx, and python-dotenv into the active environment.",{"name":77067,"text":77068},"Freeze your requirements","Run pip freeze into requirements.txt so the exact versions can be reinstalled later or by a teammate.",{"name":77070,"text":77071},"Ignore the environment in Git","Add .venv and .env to .gitignore so the environment folder and your secrets never get committed.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fcreate-a-python-virtual-environment-for-ai",{"title":2482,"description":77037},"python-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fcreate-a-python-virtual-environment-for-ai\u002Findex","KmyA3jciUlH8tDX8jzxjICsvksYZzNNgxZMXjwLMPj8",{"id":77078,"title":30411,"body":77079,"description":77962,"extension":2419,"faq":77963,"howto":77979,"meta":77994,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":77995,"published":2452,"seo":77996,"seoTitle":30411,"stem":77997,"__hash__":77998},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-on-windows\u002Findex.md",{"type":7,"value":77080,"toc":77950},[77081,77084,77087,77096,77098,77101,77132,77135,77139,77155,77165,77240,77251,77255,77262,77265,77280,77294,77301,77316,77333,77337,77343,77346,77365,77382,77390,77399,77408,77412,77428,77436,77439,77465,77471,77479,77488,77496,77499,77508,77525,77529,77535,77685,77688,77697,77710,77712,77715,77796,77798,77801,77886,77888,77891,77918,77923,77927,77929,77947],[10,77082,30411],{"id":77083},"how-to-install-python-for-ai-on-windows",[14,77085,77086],{},"This guide shows you how to install Python 3.10 or newer on Windows and get it ready for AI work in under fifteen minutes. By the end you will have Python on your PATH, a verified install in PowerShell, an isolated virtual environment, and the core AI packages installed and ready to call a model.",[14,77088,77089,77090,77092,77093,77095],{},"Windows has one quirk that trips up almost every beginner: a single checkbox in the installer decides whether the ",[18,77091,416],{}," command works at all afterwards. Get that one detail right and the rest is straightforward copy-and-paste. This guide is part of the ",[51,77094,5423],{"href":5422}," section, which covers the same ground for every operating system.",[57,77097,238],{"id":237},[14,77099,77100],{},"This guide assumes only what is specific to Windows. You need:",[2322,77102,77103,77109,77118],{},[1450,77104,77105,77108],{},[35,77106,77107],{},"Windows 10 or 11"," with an internet connection to download Python and packages.",[1450,77110,77111,77114,77115,77117],{},[35,77112,77113],{},"PowerShell",", which ships with every modern Windows install. Click Start, type ",[18,77116,77113],{},", and press Enter to open it. PowerShell is the text window where you type one command at a time and press Enter. It will not damage anything, and the worst a typo does is show an error you can read and fix.",[1450,77119,77120,77123,77124,77129,77130,1363],{},[35,77121,77122],{},"(For later AI work only) an API key."," An API key is a secret password that lets your script call a paid or free-tier model. You can generate one at ",[51,77125,77128],{"href":77126,"rel":77127},"https:\u002F\u002Fplatform.openai.com\u002Fapi-keys",[6509],"platform.openai.com\u002Fapi-keys",", or compare no-cost options in ",[51,77131,5485],{"href":5484},[14,77133,77134],{},"You do not need a programming background or administrator rights. Every command below is something you copy, paste into PowerShell with Ctrl+V, and run.",[57,77136,77138],{"id":77137},"step-1-download-and-run-the-pythonorg-installer","Step 1: Download and run the python.org installer",[14,77140,3772,77141,77146,77147,77150,77151,77154],{},[51,77142,77145],{"href":77143,"rel":77144},"https:\u002F\u002Fwww.python.org\u002Fdownloads\u002F",[6509],"python.org\u002Fdownloads"," in your browser. The site detects Windows and shows a yellow ",[35,77148,77149],{},"Download Python 3.x.x"," button for the latest stable release. Click it to download the 64-bit installer, then double-click the downloaded ",[18,77152,77153],{},".exe"," file to launch it.",[14,77156,77157,77158,77161,77162,77164],{},"The first installer screen is the only one that matters, and it is where most beginners go wrong. Before you click anything, ",[35,77159,77160],{},"tick the box labeled \"Add python.exe to PATH\""," at the bottom of the window. This single checkbox is what lets you type ",[18,77163,416],{}," in any folder and have Windows find it. If you skip it, every later command in this guide fails with \"python is not recognized.\"",[76,77166,77168,77237],{"className":77167},[79],[81,77169,90,77172,90,77175,90,77178,90,77185,90,77187,90,77192,90,77195,90,77197,90,77201,90,77205,90,77207,90,77211,90,77214,90,77216,90,77219,90,77223,90,77226,90,77228,90,77231,90,77234],{"viewBox":77170,"role":84,"ariaLabelledBy":77171,"preserveAspectRatio":88,"xmlns":89},"-40 -40 700 420",[7091,7092],[92,77173,77174],{"id":7091},"The Add python.exe to PATH checkbox decision",[96,77176,77177],{"id":7092},"Ticking Add python.exe to PATH during install makes the python command work everywhere, while leaving it unticked causes a not-recognized error.",[5548,77179,5550,77180,90],{},[5552,77181,5558,77183,5550],{"id":77182,"markerWidth":3868,"markerHeight":3868,"refX":5555,"refY":5556,"orient":5557,"markerUnits":37175},"arrowWin",[216,77184],{"d":37178,"fill":130},[100,77186],{"x":24392,"y":102,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,77188,77191],{"x":77189,"y":77190,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"310","32","python.org",[111,77193,77194],{"x":77189,"y":12882,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"installer screen",[100,77196],{"x":104,"y":191,"width":37100,"height":105,"rx":106,"fill":142,"stroke":130,"strokeWidth":109},[111,77198,77200],{"x":77189,"y":77199,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"144","Add python.exe",[111,77202,77204],{"x":77189,"y":77203,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"162","to PATH?",[181,77206],{"x1":77189,"y1":105,"x2":77189,"y2":191,"stroke":130,"strokeWidth":109},[181,77208],{"x1":75303,"y1":77209,"x2":12809,"y2":67243,"stroke":130,"strokeWidth":109,"markerEnd":77210},"184","url(#arrowWin)",[181,77212],{"x1":67224,"y1":77209,"x2":77213,"y2":67243,"stroke":143,"strokeWidth":109,"markerEnd":77210},"470",[100,77215],{"x":9777,"y":19930,"width":129,"height":141,"rx":106,"fill":142,"stroke":169,"strokeWidth":109},[111,77217,77218],{"x":12809,"y":69512,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Ticked",[111,77220,77222],{"x":12809,"y":77221,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"308","python works in",[111,77224,77225],{"x":12809,"y":37148,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"every folder",[100,77227],{"x":67224,"y":19930,"width":129,"height":141,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,77229,77230],{"x":77213,"y":69512,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Unticked",[111,77232,77233],{"x":77213,"y":77221,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"\"python is not",[111,77235,77236],{"x":77213,"y":37148,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"recognized\" errors",[232,77238,77239],{},"One checkbox decides whether the python command works everywhere or fails on first use.",[14,77241,77242,77243,77246,77247,77250],{},"With the box ticked, click ",[35,77244,77245],{},"Install Now",". Accept the User Account Control prompt if it appears, wait for the progress bar to finish, and click ",[35,77248,77249],{},"Close",". If the final screen offers a \"Disable path length limit\" button, click it — it removes an old Windows restriction that can break deeply nested package paths. Your install is now complete; the next step proves it.",[57,77252,77254],{"id":77253},"step-2-verify-the-install-in-powershell","Step 2: Verify the install in PowerShell",[14,77256,77257,77258,77261],{},"Open a ",[35,77259,77260],{},"fresh"," PowerShell window after the install finishes. This matters: PowerShell reads its list of available commands once when it opens, so a window you had open before installing Python will not see it yet. Close any old windows and open a new one from the Start menu.",[14,77263,77264],{},"Now confirm the version:",[253,77266,77268],{"className":76412,"code":77267,"language":76414,"meta":258,"style":258},"python --version\npip --version\n",[18,77269,77270,77275],{"__ignoreMap":258},[262,77271,77272],{"class":181,"line":264},[262,77273,77274],{},"python --version\n",[262,77276,77277],{"class":181,"line":282},[262,77278,77279],{},"pip --version\n",[14,77281,77282,77283,77286,77287,77289,77290,77293],{},"You should see something like ",[18,77284,77285],{},"Python 3.12.4"," and a pip version line. ",[18,77288,298],{}," is the tool that installs Python packages, and it ships with Python automatically. The version must start with ",[18,77291,77292],{},"3.10"," or higher; if it does, you are ready for the next step.",[14,77295,77296,77297,77300],{},"Windows also installs the ",[35,77298,77299],{},"py launcher",", a small helper that finds and runs your Python versions for you. Confirm it works too:",[253,77302,77304],{"className":76412,"code":77303,"language":76414,"meta":258,"style":258},"py --version\npy -0\n",[18,77305,77306,77311],{"__ignoreMap":258},[262,77307,77308],{"class":181,"line":264},[262,77309,77310],{},"py --version\n",[262,77312,77313],{"class":181,"line":282},[262,77314,77315],{},"py -0\n",[14,77317,77318,77319,77322,77323,77325,77326,77328,77329,77332],{},"The first line prints the default Python version; the second lists every Python the launcher can see. The ",[18,77320,77321],{},"py"," command is useful because it always works even when the bare ",[18,77324,416],{}," command does not, which makes it a reliable fallback if your PATH is ever misconfigured. If ",[18,77327,17782],{}," fails but ",[18,77330,77331],{},"py --version"," works, jump to the troubleshooting section — your PATH needs fixing, and the py launcher is your bridge in the meantime.",[57,77334,77336],{"id":77335},"step-3-create-and-activate-a-virtual-environment","Step 3: Create and activate a virtual environment",[14,77338,77339,77340,77342],{},"A virtual environment is a private copy of Python and its packages that lives inside one project folder. It stops different projects from fighting over package versions and keeps your main install clean. You build one with a single command. The ",[51,77341,2482],{"href":2481}," guide covers the concept in depth; here is the Windows-specific version.",[14,77344,77345],{},"Make a project folder, move into it, and create the environment:",[253,77347,77349],{"className":76412,"code":77348,"language":76414,"meta":258,"style":258},"mkdir ai-workspace\ncd ai-workspace\npython -m venv .venv\n",[18,77350,77351,77356,77361],{"__ignoreMap":258},[262,77352,77353],{"class":181,"line":264},[262,77354,77355],{},"mkdir ai-workspace\n",[262,77357,77358],{"class":181,"line":282},[262,77359,77360],{},"cd ai-workspace\n",[262,77362,77363],{"class":181,"line":295},[262,77364,76426],{},[14,77366,3349,77367,77369,77370,77372,77373,77376,77377,1374,77379,77381],{},[18,77368,76805],{}," command builds a small private copy of Python inside a folder called ",[18,77371,62557],{},". Creating it is not the same as using it — you must ",[35,77374,77375],{},"activate"," it so that ",[18,77378,416],{},[18,77380,298],{}," point at the private copy:",[253,77383,77384],{"className":76412,"code":76481,"language":76414,"meta":258,"style":258},[18,77385,77386],{"__ignoreMap":258},[262,77387,77388],{"class":181,"line":264},[262,77389,76481],{},[14,77391,77392,77393,77395,77396,77398],{},"After activation, your prompt shows a ",[18,77394,30512],{}," prefix — visual proof that you are inside the isolated environment. If PowerShell instead shows a red error about scripts being disabled, that is the execution policy blocking the activation script; the troubleshooting section below has the one-line fix. When you finish for the day, type ",[18,77397,76517],{}," to step back out.",[14,77400,77401,77402,77404,77405,77407],{},"Get into the habit of checking for that ",[18,77403,30512],{}," prefix before you run any ",[18,77406,31961],{},". The most common reason a package seems \"missing\" later is that it was installed while the environment was inactive, so it landed in your main Python instead.",[57,77409,77411],{"id":77410},"step-4-install-the-ai-packages","Step 4: Install the AI packages",[14,77413,60565,77414,77416,77417,21,77419,77421,77422,77424,77425,77427],{},[18,77415,30512],{}," showing in your prompt, install the core toolkit. We use the official ",[18,77418,20],{},[18,77420,5450],{}," (a modern HTTP library) rather than the older ",[18,77423,9433],{},", because the SDK depends on ",[18,77426,5450],{}," under the hood and handles authentication, retries, and timeouts for you:",[253,77429,77430],{"className":76412,"code":5427,"language":76414,"meta":258,"style":258},[18,77431,77432],{"__ignoreMap":258},[262,77433,77434],{"class":181,"line":264},[262,77435,5427],{},[14,77437,77438],{},"Here is what each package does:",[2322,77440,77441,77448,77455],{},[1450,77442,77443,77447],{},[35,77444,77445],{},[18,77446,20],{}," — the official SDK for calling OpenAI models with one clean function call.",[1450,77449,77450,77454],{},[35,77451,77452],{},[18,77453,5450],{}," — the HTTP engine the SDK uses to reach the API; you rarely call it directly but it must be present.",[1450,77456,77457,77461,77462,77464],{},[35,77458,77459],{},[18,77460,2501],{}," — loads secrets such as your API key from a ",[18,77463,319],{}," file so they never get hard-coded into your scripts.",[14,77466,77467,77468,77470],{},"Once the install finishes, lock the exact versions into a ",[18,77469,76448],{}," file so the setup is reproducible on any other machine:",[253,77472,77473],{"className":76412,"code":76651,"language":76414,"meta":258,"style":258},[18,77474,77475],{"__ignoreMap":258},[262,77476,77477],{"class":181,"line":264},[262,77478,76651],{},[14,77480,77481,77482,77484,77485,77487],{},"To restore those versions elsewhere, you would run ",[18,77483,76719],{}," inside a fresh activated environment. Next, store your API key safely. Create a file named ",[18,77486,319],{}," in your project root with one line:",[253,77489,77490],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,77491,77492],{"__ignoreMap":258},[262,77493,77494],{"class":181,"line":264},[262,77495,337],{},[14,77497,77498],{},"Immediately tell Git to ignore that file so your secret never gets committed:",[253,77500,77502],{"className":76412,"code":77501,"language":76414,"meta":258,"style":258},"Add-Content .gitignore \".env\"\n",[18,77503,77504],{"__ignoreMap":258},[262,77505,77506],{"class":181,"line":264},[262,77507,77501],{},[14,77509,77510,77511,3921,77513,77515,77516,77519,77520,77522,77523,1363],{},"Always add ",[18,77512,319],{},[18,77514,359],{}," before your first commit — a leaked API key can run up real charges on your account. Watch one Windows trap here: Notepad and File Explorer can silently save the file as ",[18,77517,77518],{},".env.txt",", which ",[18,77521,8439],{}," will never find. To learn how the key authenticates each request, read ",[51,77524,2487],{"href":2486},[57,77526,77528],{"id":77527},"step-5-run-a-quick-verification","Step 5: Run a quick verification",[14,77530,77531,77532,76735],{},"Confirm every layer works together. Create a file named ",[18,77533,77534],{},"verify_setup.py",[253,77536,77538],{"className":414,"code":77537,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\n# Load OPENAI_API_KEY from the .env file into the environment\nload_dotenv()\n\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\"role\": \"user\", \"content\": \"Reply with exactly: Windows setup verified.\"},\n    ],\n    max_tokens=10,\n)\n\nprint(response.choices[0].message.content)\n",[18,77539,77540,77546,77556,77566,77570,77575,77579,77583,77601,77605,77613,77623,77631,77652,77656,77667,77671,77675],{"__ignoreMap":258},[262,77541,77542,77544],{"class":181,"line":264},[262,77543,684],{"class":377},[262,77545,687],{"class":429},[262,77547,77548,77550,77552,77554],{"class":181,"line":282},[262,77549,705],{"class":377},[262,77551,708],{"class":429},[262,77553,684],{"class":377},[262,77555,713],{"class":429},[262,77557,77558,77560,77562,77564],{"class":181,"line":295},[262,77559,705],{"class":377},[262,77561,720],{"class":429},[262,77563,684],{"class":377},[262,77565,725],{"class":429},[262,77567,77568],{"class":181,"line":345},[262,77569,583],{"emptyLinePlaceholder":582},[262,77571,77572],{"class":181,"line":492},[262,77573,77574],{"class":291},"# Load OPENAI_API_KEY from the .env file into the environment\n",[262,77576,77577],{"class":181,"line":503},[262,77578,734],{"class":429},[262,77580,77581],{"class":181,"line":521},[262,77582,583],{"emptyLinePlaceholder":582},[262,77584,77585,77587,77589,77591,77593,77595,77597,77599],{"class":181,"line":537},[262,77586,739],{"class":429},[262,77588,476],{"class":377},[262,77590,1588],{"class":429},[262,77592,2674],{"class":611},[262,77594,476],{"class":377},[262,77596,1199],{"class":429},[262,77598,2681],{"class":275},[262,77600,2684],{"class":429},[262,77602,77603],{"class":181,"line":549},[262,77604,583],{"emptyLinePlaceholder":582},[262,77606,77607,77609,77611],{"class":181,"line":570},[262,77608,48362],{"class":429},[262,77610,476],{"class":377},[262,77612,1189],{"class":429},[262,77614,77615,77617,77619,77621],{"class":181,"line":579},[262,77616,48371],{"class":611},[262,77618,476],{"class":377},[262,77620,1207],{"class":275},[262,77622,1315],{"class":429},[262,77624,77625,77627,77629],{"class":181,"line":586},[262,77626,48388],{"class":611},[262,77628,476],{"class":377},[262,77630,1220],{"class":429},[262,77632,77633,77635,77637,77639,77641,77643,77645,77647,77650],{"class":181,"line":591},[262,77634,7726],{"class":429},[262,77636,1228],{"class":275},[262,77638,1231],{"class":429},[262,77640,1291],{"class":275},[262,77642,608],{"class":429},[262,77644,1239],{"class":275},[262,77646,1231],{"class":429},[262,77648,77649],{"class":275},"\"Reply with exactly: Windows setup verified.\"",[262,77651,3143],{"class":429},[262,77653,77654],{"class":181,"line":623},[262,77655,48439],{"class":429},[262,77657,77658,77661,77663,77665],{"class":181,"line":634},[262,77659,77660],{"class":611},"    max_tokens",[262,77662,476],{"class":377},[262,77664,3868],{"class":271},[262,77666,1315],{"class":429},[262,77668,77669],{"class":181,"line":845},[262,77670,660],{"class":429},[262,77672,77673],{"class":181,"line":850},[262,77674,583],{"emptyLinePlaceholder":582},[262,77676,77677,77679,77681,77683],{"class":181,"line":864},[262,77678,637],{"class":271},[262,77680,48465],{"class":429},[262,77682,102],{"class":271},[262,77684,6048],{"class":429},[14,77686,77687],{},"Run it from your activated environment:",[253,77689,77691],{"className":76412,"code":77690,"language":76414,"meta":258,"style":258},"python verify_setup.py\n",[18,77692,77693],{"__ignoreMap":258},[262,77694,77695],{"class":181,"line":264},[262,77696,77690],{},[14,77698,77699,77700,77703,77704,77706,77707,77709],{},"If you see ",[18,77701,77702],{},"Windows setup verified."," printed in PowerShell, every layer is working: Python is installed and modern, the virtual environment is active, the ",[18,77705,20],{}," SDK imported cleanly, your ",[18,77708,319],{}," file loaded, and your key was accepted by a live model.",[57,77711,18801],{"id":18800},[14,77713,77714],{},"Keep this table handy while you work on Windows.",[1379,77716,77717,77728],{},[1382,77718,77719],{},[1385,77720,77721,77723,77725],{},[1388,77722,76791],{},[1388,77724,45959],{},[1388,77726,77727],{},"Notes",[1398,77729,77730,77745,77757,77772,77784],{},[1385,77731,77732,77736,77739],{},[1403,77733,77734],{},[18,77735,17782],{},[1403,77737,77738],{},"prints the installed Python version",[1403,77740,77741,77742,77744],{},"must read ",[18,77743,77292],{}," or higher",[1385,77746,77747,77751,77754],{},[1403,77748,77749],{},[18,77750,77331],{},[1403,77752,77753],{},"runs the py launcher",[1403,77755,77756],{},"reliable fallback if PATH is broken",[1385,77758,77759,77763,77766],{},[1403,77760,77761],{},[18,77762,76805],{},[1403,77764,77765],{},"creates a virtual environment",[1403,77767,77768,77769,77771],{},"named ",[18,77770,62557],{}," inside the project folder",[1385,77773,77774,77778,77781],{},[1403,77775,77776],{},[18,77777,76834],{},[1403,77779,77780],{},"activates the environment",[1403,77782,77783],{},"needs RemoteSigned execution policy",[1385,77785,77786,77790,77793],{},[1403,77787,77788],{},[18,77789,76878],{},[1403,77791,77792],{},"allows local scripts to run",[1403,77794,77795],{},"run once to unblock activation",[57,77797,1445],{"id":1444},[14,77799,77800],{},"These are the exact errors Windows beginners hit most, with the cause and a one-line fix.",[1447,77802,77803,77823,77836,77852,77865],{},[1450,77804,77805,77810,77811,77814,77815,77818,77819,40584,77821,1363],{},[35,77806,77807,1363],{},[18,77808,77809],{},"'python' is not recognized as an internal or external command"," Python is not on your PATH because the ",[27,77812,77813],{},"Add python.exe to PATH"," box was left unticked. Re-run the installer, choose ",[35,77816,77817],{},"Modify",", tick the option, and open a fresh PowerShell window. In the meantime, use ",[18,77820,77321],{},[18,77822,416],{},[1450,77824,77825,77830,77831,76879,77833,77835],{},[35,77826,77827,1363],{},[18,77828,77829],{},"Activate.ps1 cannot be loaded because running scripts is disabled on this system"," PowerShell blocks scripts by default for security. Run ",[18,77832,76878],{},[18,77834,76882],{},", then run the activation command again.",[1450,77837,77838,77844,77845,31948,77848,77851],{},[35,77839,77840,77841,77843],{},"The version command works but ",[18,77842,298],{}," does not."," pip is present but not on your PATH. Use ",[18,77846,77847],{},"python -m pip install \u003Cpackage>",[18,77849,77850],{},"py -m pip install \u003Cpackage>","), which calls pip through Python directly and always works.",[1450,77853,77854,77858,77859,77861,77862,1363],{},[35,77855,77856,1363],{},[18,77857,8493],{}," You installed the package outside the active environment. Confirm ",[18,77860,30512],{}," shows in your prompt, then re-run ",[18,77863,77864],{},"pip install openai httpx python-dotenv",[1450,77866,77867,77875,77876,77878,77879,77882,77883,77885],{},[35,77868,77869,77871,77872,77874],{},[18,77870,21742],{}," loads as ",[18,77873,8471],{}," even though the file exists."," Notepad saved the file as ",[18,77877,77518],{},". In File Explorer, enable ",[27,77880,77881],{},"File name extensions"," under the View menu, then rename the file to exactly ",[18,77884,319],{}," with no extension.",[57,77887,2317],{"id":2316},[14,77889,77890],{},"There are three common ways to get Python on Windows, and the right one depends on how you plan to work.",[2322,77892,77893,77906,77912],{},[1450,77894,77895,77898,77899,77901,77902,77905],{},[35,77896,77897],{},"python.org installer (this guide):"," the best default for AI work. You get the ",[18,77900,77321],{}," launcher, the ",[27,77903,77904],{},"Add to PATH"," checkbox, full control over the install location, and a setup that matches almost every tutorial you will read. Choose this unless you have a specific reason not to.",[1450,77907,77908,77911],{},[35,77909,77910],{},"Microsoft Store:"," convenient for casual learning and auto-updates, but it sandboxes file access in ways that occasionally confuse virtual environments and package installs. It also omits some launcher behavior. Fine for a first taste of Python; switch to the python.org installer once you start building real projects.",[1450,77913,77914,77917],{},[35,77915,77916],{},"WSL (Windows Subsystem for Linux):"," runs a full Linux environment inside Windows, so you follow Mac\u002FLinux instructions instead. Pick this if you want your machine to mirror a Linux server you will deploy to, or if a library only ships Linux wheels. It adds a learning curve, so it suits developers comfortable with a Linux shell more than first-time beginners.",[14,77919,77920,77921,1363],{},"If you are on a Mac instead, follow ",[51,77922,30415],{"href":30414},[14,77924,2375,77925,1363],{},[51,77926,5423],{"href":5422},[57,77928,2381],{"id":2380},[2322,77930,77931,77935,77939,77943],{},[1450,77932,77933],{},[51,77934,5423],{"href":5422},[1450,77936,77937],{},[51,77938,30415],{"href":30414},[1450,77940,77941],{},[51,77942,2482],{"href":2481},[1450,77944,77945],{},[51,77946,2487],{"href":2486},[2401,77948,77949],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":258,"searchDepth":282,"depth":282,"links":77951},[77952,77953,77954,77955,77956,77957,77958,77959,77960,77961],{"id":237,"depth":282,"text":238},{"id":77137,"depth":282,"text":77138},{"id":77253,"depth":282,"text":77254},{"id":77335,"depth":282,"text":77336},{"id":77410,"depth":282,"text":77411},{"id":77527,"depth":282,"text":77528},{"id":18800,"depth":282,"text":18801},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Install Python 3.10+ on Windows for AI work: download from python.org, add to PATH, verify in PowerShell, create a virtual environment, and install AI packages.",[77964,77967,77970,77973,77976],{"q":77965,"a":77966},"Which Python version should I install on Windows for AI?","Install the latest stable Python 3.10 or newer from python.org. The openai SDK and most current AI libraries dropped support for Python 3.9 after it reached end-of-life in October 2025, so do not pick anything older than 3.10.",{"q":77968,"a":77969},"Why does Windows say python is not recognized?","Windows cannot find Python on your PATH, the list of folders it searches for commands. This almost always means the Add python.exe to PATH box was left unticked during install. Re-run the installer, choose Modify, and enable that option, then open a fresh PowerShell window.",{"q":77971,"a":77972},"Should I install Python from the Microsoft Store or python.org?","For AI work, use the installer from python.org. It gives you the py launcher, full control over the install location, and the Add to PATH checkbox. The Microsoft Store version is fine for casual learning but sandboxes file access in ways that can confuse virtual environments and package installs.",{"q":77974,"a":77975},"How do I activate a virtual environment in PowerShell?","Run .venv\\Scripts\\Activate.ps1 from your project folder. If PowerShell blocks it with a script execution error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned once, then activate again.",{"q":77977,"a":77978},"Do I need administrator rights to install Python on Windows?","No. The python.org installer can install just for your user account without admin rights. Untick Install for all users if you do not have an administrator password, and Python will install into your user folder instead.",{"name":77980,"steps":77981},"How to install Python for AI on Windows",[77982,77985,77988,77991],{"name":77983,"text":77984},"Download and run the python.org installer","Download the latest Python 3.10+ Windows installer from python.org and tick Add python.exe to PATH before installing.",{"name":77986,"text":77987},"Verify the install in PowerShell","Open a fresh PowerShell window and confirm the version with python --version and the py launcher with py --version.",{"name":77989,"text":77990},"Create and activate a virtual environment","Make a project folder, run python -m venv .venv, then activate it with the PowerShell activation script.",{"name":77992,"text":77993},"Install the AI packages","With the environment active, install openai, httpx, and python-dotenv, then lock the versions into requirements.txt.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-on-windows",{"title":30411,"description":77962},"python-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-on-windows\u002Findex","cGQuSfWumD9WnTD_yRBhSKTXFEJEIg2DaeqtieUSn9M",{"id":78000,"title":30415,"body":78001,"description":78806,"extension":2419,"faq":78807,"howto":78823,"meta":78841,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":78842,"published":66007,"seo":78843,"seoTitle":78844,"stem":78845,"__hash__":78846},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-projects-on-mac\u002Findex.md",{"type":7,"value":78002,"toc":78794},[78003,78006,78009,78014,78016,78019,78049,78063,78067,78070,78073,78096,78103,78133,78140,78152,78160,78164,78167,78173,78194,78207,78221,78231,78237,78241,78244,78247,78282,78299,78303,78317,78343,78349,78358,78366,78380,78383,78387,78390,78404,78413,78548,78551,78563,78568,78572,78653,78655,78731,78733,78757,78763,78767,78769,78791],[10,78004,30415],{"id":78005},"how-to-install-python-for-ai-projects-on-mac",[14,78007,78008],{},"This guide shows you how to install a clean, isolated Python for AI work on your Mac in under fifteen minutes, without touching the Python that macOS depends on. By the end you will have a versioned Python, a project-specific virtual environment (a private folder that keeps each project's packages separate), and the core AI libraries installed and verified.",[14,78010,78011,78012,1363],{},"macOS ships with its own Python, but it is there for the operating system's own tools. Installing AI packages on top of it can cause permission errors and, in the worst case, break system utilities. The fix is simple: install a separate Python and keep every project in its own environment. If you want the bigger picture on why isolation matters, the foundational concepts live in ",[51,78013,26450],{"href":26449},[57,78015,238],{"id":237},[14,78017,78018],{},"You only need a few things before you start:",[2322,78020,78021,78027,78037,78043],{},[1450,78022,78023,78026],{},[35,78024,78025],{},"macOS 12 or newer",", on either Apple Silicon (M1\u002FM2\u002FM3) or an Intel chip. Both work; the only difference is where Homebrew installs.",[1450,78028,78029,78032,78033,78036],{},[35,78030,78031],{},"The Terminal app",", found in ",[18,78034,78035],{},"Applications → Utilities → Terminal",". Every command below is typed there and run by pressing Return.",[1450,78038,78039,78042],{},[35,78040,78041],{},"An internet connection"," for downloading Homebrew, Python, and the AI packages.",[1450,78044,78045,78048],{},[35,78046,78047],{},"An OpenAI API key"," if you plan to call a model. You can create one in your OpenAI account dashboard. You do not need it to install anything; you only need it when you run code that talks to a model.",[14,78050,78051,78052,78055,78056,78058,78059,78062],{},"A quick reassurance about the commands: a line starting with ",[18,78053,78054],{},"$"," is something you type, but you copy only the part after ",[18,78057,78054],{},". Lines that start with ",[18,78060,78061],{},"#"," in bash are comments and are ignored.",[57,78064,78066],{"id":78065},"step-1-install-homebrew","Step 1: Install Homebrew",[14,78068,78069],{},"Homebrew is a package manager (a tool that downloads and installs software for you) that keeps everything tidy in its own folder, so it never overwrites macOS system files. Most Mac developers use it, and it makes installing and updating Python a single command later on.",[14,78071,78072],{},"Paste this into Terminal and press Return:",[253,78074,78076],{"className":255,"code":78075,"language":257,"meta":258,"style":258},"\u002Fbin\u002Fbash -c \"$(curl -fsSL https:\u002F\u002Fraw.githubusercontent.com\u002FHomebrew\u002Finstall\u002FHEAD\u002Finstall.sh)\"\n",[18,78077,78078],{"__ignoreMap":258},[262,78079,78080,78083,78085,78088,78090,78093],{"class":181,"line":264},[262,78081,78082],{"class":267},"\u002Fbin\u002Fbash",[262,78084,44707],{"class":271},[262,78086,78087],{"class":275}," \"$(",[262,78089,51919],{"class":267},[262,78091,78092],{"class":271}," -fsSL",[262,78094,78095],{"class":275}," https:\u002F\u002Fraw.githubusercontent.com\u002FHomebrew\u002Finstall\u002FHEAD\u002Finstall.sh)\"\n",[14,78097,78098,78099,78102],{},"The installer explains what it will do and asks for your Mac password to continue. When it finishes, it prints one or two lines telling you to add Homebrew to your shell. On Apple Silicon, Homebrew installs to ",[18,78100,78101],{},"\u002Fopt\u002Fhomebrew",", so run:",[253,78104,78106],{"className":255,"code":78105,"language":257,"meta":258,"style":258},"echo 'eval \"$(\u002Fopt\u002Fhomebrew\u002Fbin\u002Fbrew shellenv)\"' >> ~\u002F.zprofile\neval \"$(\u002Fopt\u002Fhomebrew\u002Fbin\u002Fbrew shellenv)\"\n",[18,78107,78108,78120],{"__ignoreMap":258},[262,78109,78110,78112,78115,78117],{"class":181,"line":264},[262,78111,371],{"class":271},[262,78113,78114],{"class":275}," 'eval \"$(\u002Fopt\u002Fhomebrew\u002Fbin\u002Fbrew shellenv)\"'",[262,78116,378],{"class":377},[262,78118,78119],{"class":275}," ~\u002F.zprofile\n",[262,78121,78122,78125,78127,78130],{"class":181,"line":282},[262,78123,78124],{"class":271},"eval",[262,78126,78087],{"class":275},[262,78128,78129],{"class":267},"\u002Fopt\u002Fhomebrew\u002Fbin\u002Fbrew",[262,78131,78132],{"class":275}," shellenv)\"\n",[14,78134,78135,78136,78139],{},"On Intel Macs, Homebrew installs to ",[18,78137,78138],{},"\u002Fusr\u002Flocal"," and is already on your PATH (the list of folders your Mac searches for commands) once installation completes. Confirm Homebrew is working:",[253,78141,78143],{"className":255,"code":78142,"language":257,"meta":258,"style":258},"brew --version\n",[18,78144,78145],{"__ignoreMap":258},[262,78146,78147,78150],{"class":181,"line":264},[262,78148,78149],{"class":267},"brew",[262,78151,52414],{"class":271},[14,78153,78154,78155,78159],{},"If you would rather skip Homebrew entirely, jump to ",[51,78156,78158],{"href":78157},"#step-2-install-python","Step 2's python.org option"," — you can install Python without it.",[57,78161,78163],{"id":78162},"step-2-install-python","Step 2: Install Python",[14,78165,78166],{},"You have two good ways to install Python. Pick one; you do not need both.",[14,78168,78169,78172],{},[35,78170,78171],{},"Option A — Homebrew (recommended for most people)."," This installs a specific Python version as a clearly named binary and makes future upgrades easy.",[253,78174,78176],{"className":255,"code":78175,"language":257,"meta":258,"style":258},"brew install python@3.11\npython3.11 --version\n",[18,78177,78178,78187],{"__ignoreMap":258},[262,78179,78180,78182,78184],{"class":181,"line":264},[262,78181,78149],{"class":267},[262,78183,301],{"class":275},[262,78185,78186],{"class":275}," python@3.11\n",[262,78188,78189,78192],{"class":181,"line":282},[262,78190,78191],{"class":267},"python3.11",[262,78193,52414],{"class":271},[14,78195,78196,78197,78200,78201,78203,78204,78206],{},"You should see ",[18,78198,78199],{},"Python 3.11.x",". Homebrew installs Python as the versioned command ",[18,78202,78191],{},". Use that exact name when creating environments — do not rely on the bare ",[18,78205,268],{},", because it can point to an older release.",[14,78208,78209,78212,78213,78216,78217,78220],{},[35,78210,78211],{},"Option B — the python.org installer (recommended if you prefer a double-click installer)."," Go to ",[51,78214,77145],{"href":77143,"rel":78215},[6509],", download the latest macOS installer for Python 3.11 or newer, and double-click the ",[18,78218,78219],{},".pkg"," file. Follow the prompts, then confirm in Terminal:",[253,78222,78223],{"className":255,"code":52405,"language":257,"meta":258,"style":258},[18,78224,78225],{"__ignoreMap":258},[262,78226,78227,78229],{"class":181,"line":264},[262,78228,268],{"class":267},[262,78230,52414],{"class":271},[14,78232,78233,78234,1363],{},"Either option gives you a Python that is safe to build AI projects on. Whichever you choose, never modify the system Python in ",[18,78235,78236],{},"\u002Fusr\u002Fbin",[57,78238,78240],{"id":78239},"step-3-create-a-virtual-environment","Step 3: Create a virtual environment",[14,78242,78243],{},"Never install AI packages directly into your main Python. Instead, create a virtual environment (often shortened to \"venv\") — a self-contained folder that holds one project's packages. This keeps projects from clashing and lets you delete a project cleanly by removing its folder.",[14,78245,78246],{},"Make a project folder, create the environment inside it, and activate it:",[253,78248,78250],{"className":255,"code":78249,"language":257,"meta":258,"style":258},"mkdir ~\u002Fai-workspace && cd ~\u002Fai-workspace\npython3.11 -m venv .venv\nsource .venv\u002Fbin\u002Factivate\n",[18,78251,78252,78266,78276],{"__ignoreMap":258},[262,78253,78254,78256,78259,78261,78263],{"class":181,"line":264},[262,78255,7191],{"class":267},[262,78257,78258],{"class":275}," ~\u002Fai-workspace",[262,78260,7197],{"class":429},[262,78262,7200],{"class":271},[262,78264,78265],{"class":275}," ~\u002Fai-workspace\n",[262,78267,78268,78270,78272,78274],{"class":181,"line":282},[262,78269,78191],{"class":267},[262,78271,272],{"class":271},[262,78273,276],{"class":275},[262,78275,279],{"class":275},[262,78277,78278,78280],{"class":181,"line":295},[262,78279,285],{"class":271},[262,78281,76476],{"class":275},[14,78283,78284,78285,78287,78288,78290,78291,78293,78294,78296,78297,1363],{},"If you installed from python.org, use ",[18,78286,268],{}," in place of ",[18,78289,78191],{}," on the second line. Once activated, your prompt shows ",[18,78292,30512],{}," at the start of the line — that is your signal that the environment is on. To leave it later, type ",[18,78295,76517],{},". For a deeper walkthrough of environments across operating systems, see ",[51,78298,2482],{"href":2481},[57,78300,78302],{"id":78301},"step-4-install-the-core-ai-packages","Step 4: Install the core AI packages",[14,78304,78305,78306,78308,78309,21,78311,78313,78314,78316],{},"With the environment active, upgrade ",[18,78307,298],{}," (Python's package installer) and install the essentials. We use the official ",[18,78310,20],{},[18,78312,5450],{}," (a modern library for making web requests) rather than older alternatives, plus ",[18,78315,2501],{}," for loading secrets safely.",[253,78318,78319],{"className":255,"code":76557,"language":257,"meta":258,"style":258},[18,78320,78321,78331],{"__ignoreMap":258},[262,78322,78323,78325,78327,78329],{"class":181,"line":264},[262,78324,298],{"class":267},[262,78326,301],{"class":275},[262,78328,76568],{"class":271},[262,78330,76571],{"class":275},[262,78332,78333,78335,78337,78339,78341],{"class":181,"line":282},[262,78334,298],{"class":267},[262,78336,301],{"class":275},[262,78338,2519],{"class":275},[262,78340,5440],{"class":275},[262,78342,2522],{"class":275},[14,78344,78345,78346,78348],{},"Now store your API key in a ",[18,78347,319],{}," file (a small text file holding secrets) in the project root:",[253,78350,78352],{"className":323,"code":78351,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-proj-your-key-here\n",[18,78353,78354],{"__ignoreMap":258},[262,78355,78356],{"class":181,"line":264},[262,78357,78351],{},[14,78359,78360,78361,3921,78363,78365],{},"Then add ",[18,78362,319],{},[18,78364,359],{}," immediately, so your key is never committed to version control or shared:",[253,78367,78368],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,78369,78370],{"__ignoreMap":258},[262,78371,78372,78374,78376,78378],{"class":181,"line":264},[262,78373,371],{"class":271},[262,78375,374],{"class":275},[262,78377,378],{"class":377},[262,78379,381],{"class":275},[14,78381,78382],{},"That one line is the difference between a private key and a leaked one — never skip it.",[57,78384,78386],{"id":78385},"step-5-verify-the-setup","Step 5: Verify the setup",[14,78388,78389],{},"First, a quick import check to confirm the packages load without path errors:",[253,78391,78393],{"className":255,"code":78392,"language":257,"meta":258,"style":258},"python -c \"import openai, httpx; print('AI environment verified.')\"\n",[18,78394,78395],{"__ignoreMap":258},[262,78396,78397,78399,78401],{"class":181,"line":264},[262,78398,416],{"class":267},[262,78400,44707],{"class":271},[262,78402,78403],{"class":275}," \"import openai, httpx; print('AI environment verified.')\"\n",[14,78405,78406,78407,78409,78410,22741],{},"If that prints the success message, run a tiny script to confirm everything fits together, including reading the key from ",[18,78408,319],{},". Save this as ",[18,78411,78412],{},"check.py",[253,78414,78416],{"className":414,"code":78415,"language":416,"meta":258,"style":258},"# check.py — confirms Python, packages, and your .env key all work\nimport sys\nfrom dotenv import load_dotenv\nimport os\n\nload_dotenv()  # reads the .env file into environment variables\n\nprint(f\"Python version: {sys.version.split()[0]}\")\n\nkey = os.getenv(\"OPENAI_API_KEY\")\nif key and key.startswith(\"sk-\"):\n    print(\"API key loaded from .env: OK\")\nelse:\n    print(\"API key not found — check your .env file\")\n",[18,78417,78418,78423,78430,78440,78446,78450,78456,78460,78486,78490,78503,78520,78531,78537],{"__ignoreMap":258},[262,78419,78420],{"class":181,"line":264},[262,78421,78422],{"class":291},"# check.py — confirms Python, packages, and your .env key all work\n",[262,78424,78425,78427],{"class":181,"line":282},[262,78426,684],{"class":377},[262,78428,78429],{"class":429}," sys\n",[262,78431,78432,78434,78436,78438],{"class":181,"line":295},[262,78433,705],{"class":377},[262,78435,708],{"class":429},[262,78437,684],{"class":377},[262,78439,713],{"class":429},[262,78441,78442,78444],{"class":181,"line":345},[262,78443,684],{"class":377},[262,78445,687],{"class":429},[262,78447,78448],{"class":181,"line":492},[262,78449,583],{"emptyLinePlaceholder":582},[262,78451,78452,78454],{"class":181,"line":503},[262,78453,4222],{"class":429},[262,78455,4225],{"class":291},[262,78457,78458],{"class":181,"line":521},[262,78459,583],{"emptyLinePlaceholder":582},[262,78461,78462,78464,78466,78468,78471,78473,78476,78478,78480,78482,78484],{"class":181,"line":537},[262,78463,637],{"class":271},[262,78465,602],{"class":429},[262,78467,642],{"class":377},[262,78469,78470],{"class":275},"\"Python version: ",[262,78472,3039],{"class":271},[262,78474,78475],{"class":429},"sys.version.split()[",[262,78477,102],{"class":271},[262,78479,6223],{"class":429},[262,78481,654],{"class":271},[262,78483,1176],{"class":275},[262,78485,660],{"class":429},[262,78487,78488],{"class":181,"line":549},[262,78489,583],{"emptyLinePlaceholder":582},[262,78491,78492,78495,78497,78499,78501],{"class":181,"line":570},[262,78493,78494],{"class":429},"key ",[262,78496,476],{"class":377},[262,78498,754],{"class":429},[262,78500,2681],{"class":275},[262,78502,660],{"class":429},[262,78504,78505,78507,78510,78512,78515,78518],{"class":181,"line":579},[262,78506,2210],{"class":377},[262,78508,78509],{"class":429}," key ",[262,78511,6101],{"class":377},[262,78513,78514],{"class":429}," key.startswith(",[262,78516,78517],{"class":275},"\"sk-\"",[262,78519,8192],{"class":429},[262,78521,78522,78524,78526,78529],{"class":181,"line":586},[262,78523,1089],{"class":271},[262,78525,602],{"class":429},[262,78527,78528],{"class":275},"\"API key loaded from .env: OK\"",[262,78530,660],{"class":429},[262,78532,78533,78535],{"class":181,"line":591},[262,78534,20859],{"class":377},[262,78536,1160],{"class":429},[262,78538,78539,78541,78543,78546],{"class":181,"line":623},[262,78540,1089],{"class":271},[262,78542,602],{"class":429},[262,78544,78545],{"class":275},"\"API key not found — check your .env file\"",[262,78547,660],{"class":429},[14,78549,78550],{},"Run it:",[253,78552,78554],{"className":255,"code":78553,"language":257,"meta":258,"style":258},"python check.py\n",[18,78555,78556],{"__ignoreMap":258},[262,78557,78558,78560],{"class":181,"line":264},[262,78559,416],{"class":267},[262,78561,78562],{"class":275}," check.py\n",[14,78564,78565,78566,1363],{},"You should see your Python version and a confirmation that the key loaded. Your Mac is now ready for AI scripting. To make your first real call, head to ",[51,78567,2487],{"href":2486},[57,78569,78571],{"id":78570},"key-commands-quick-reference","Key commands quick reference",[1379,78573,78574,78585],{},[1382,78575,78576],{},[1385,78577,78578,78580,78582],{},[1388,78579,76791],{},[1388,78581,45959],{},[1388,78583,78584],{},"When you use it",[1398,78586,78587,78600,78615,78629,78641],{},[1385,78588,78589,78594,78597],{},[1403,78590,78591],{},[18,78592,78593],{},"brew install python@3.11",[1403,78595,78596],{},"Installs a versioned Python via Homebrew",[1403,78598,78599],{},"Once, during setup",[1385,78601,78602,78607,78612],{},[1403,78603,78604],{},[18,78605,78606],{},"python3.11 -m venv .venv",[1403,78608,78609,78610],{},"Creates a virtual environment named ",[18,78611,62557],{},[1403,78613,78614],{},"Once per new project",[1385,78616,78617,78621,78626],{},[1403,78618,78619],{},[18,78620,30519],{},[1403,78622,78623,78624,5987],{},"Turns the environment on (prompt shows ",[18,78625,30512],{},[1403,78627,78628],{},"Every time you open the project",[1385,78630,78631,78635,78638],{},[1403,78632,78633],{},[18,78634,76517],{},[1403,78636,78637],{},"Turns the active environment off",[1403,78639,78640],{},"When you finish working",[1385,78642,78643,78647,78650],{},[1403,78644,78645],{},[18,78646,76901],{},[1403,78648,78649],{},"Shows which Python is currently active",[1403,78651,78652],{},"To check the environment is on",[57,78654,1445],{"id":1444},[1447,78656,78657,78679,78696,78716],{},[1450,78658,78659,78664,78665,49806,78667,78669,78670,78672,78673,78675,78676,78678],{},[35,78660,78661],{},[18,78662,78663],{},"command not found: python"," — On macOS the installed command is usually ",[18,78666,268],{},[18,78668,416],{},". Plain ",[18,78671,416],{}," only works inside an activated virtual environment. Either activate your environment with ",[18,78674,30519],{},", or use ",[18,78677,268],{}," directly.",[1450,78680,78681,78687,78688,78691,78692,78695],{},[35,78682,78683,78686],{},[18,78684,78685],{},"brew: command not found"," after installing Homebrew"," — The shell setup line did not run. On Apple Silicon, run ",[18,78689,78690],{},"eval \"$(\u002Fopt\u002Fhomebrew\u002Fbin\u002Fbrew shellenv)\""," again, then add it to ",[18,78693,78694],{},"~\u002F.zprofile"," so it loads in every new terminal window. Close and reopen Terminal to confirm.",[1450,78697,78698,78701,78702,78704,78705,78708,78709,608,78711,78713,78714,1363],{},[35,78699,78700],{},"The wrong Python runs (multiple Pythons installed)"," — Run ",[18,78703,62553],{}," to see which one is active. If it points to ",[18,78706,78707],{},"\u002Fusr\u002Fbin\u002Fpython3",", that is the system Python; create and activate a virtual environment so your project uses its own copy instead. Inside an active ",[18,78710,62557],{},[18,78712,76901],{}," should point to a path containing ",[18,78715,62557],{},[1450,78717,78718,78723,78724,78726,78727,78730],{},[35,78719,78720,78722],{},[18,78721,31961],{}," fails with a permissions error"," — This almost always means no virtual environment is active, so pip is trying to write into a protected system folder. Check your prompt shows ",[18,78725,30512],{},". If not, activate the environment first, then run the install again. Never use ",[18,78728,78729],{},"sudo pip install"," to force it.",[57,78732,2317],{"id":2316},[2322,78734,78735,78741,78747],{},[1450,78736,78737,78740],{},[35,78738,78739],{},"Homebrew (this guide's main path)"," — Best if you want one tool to install and update Python, Git, and other utilities from the terminal, and you are comfortable running an occasional command. This is the right default for most creators and founders.",[1450,78742,78743,78746],{},[35,78744,78745],{},"python.org installer"," — Best if you prefer a double-click installer over the terminal and want a version that never changes underneath you. A solid choice if you only need Python for one or two projects.",[1450,78748,78749,78752,78753,78756],{},[35,78750,78751],{},"pyenv"," — Best if you expect to juggle several Python versions across different projects (for example, an older library that needs 3.10 alongside a newer one on 3.12). Install it with ",[18,78754,78755],{},"brew install pyenv",". It is the most flexible option but adds setup overhead, so reach for it only once you actually need multiple versions.",[14,78758,78759,78760,78762],{},"Setting up on a different machine? Follow ",[51,78761,30411],{"href":30410}," for the Windows equivalent of every step here.",[14,78764,2375,78765,1363],{},[51,78766,5423],{"href":5422},[57,78768,2381],{"id":2380},[2322,78770,78771,78776,78781,78786],{},[1450,78772,78773,78775],{},[51,78774,5423],{"href":5422}," — the main guide for this section, with the full setup track.",[1450,78777,78778,78780],{},[51,78779,30411],{"href":30410}," — the same workflow for a Windows machine.",[1450,78782,78783,78785],{},[51,78784,2482],{"href":2481}," — go deeper on virtual environments and dependency management.",[1450,78787,78788,78790],{},[51,78789,26450],{"href":26449}," — the foundational concepts behind everything in this section.",[2401,78792,78793],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":258,"searchDepth":282,"depth":282,"links":78795},[78796,78797,78798,78799,78800,78801,78802,78803,78804,78805],{"id":237,"depth":282,"text":238},{"id":78065,"depth":282,"text":78066},{"id":78162,"depth":282,"text":78163},{"id":78239,"depth":282,"text":78240},{"id":78301,"depth":282,"text":78302},{"id":78385,"depth":282,"text":78386},{"id":78570,"depth":282,"text":78571},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Install a clean, isolated Python environment for AI on macOS without touching the system Python. Covers Homebrew, python.org, pyenv, venv, and SDK setup.",[78808,78811,78814,78817,78820],{"q":78809,"a":78810},"Can I use the Python that already comes with my Mac?","No. The Python that ships with macOS is reserved for system tools, and modifying it can break parts of the operating system. Install a separate Python with Homebrew or from python.org and use that for all your AI projects.",{"q":78812,"a":78813},"Do I need to install Python differently on an M1, M2, or M3 Mac?","The steps are the same, but Homebrew installs to \u002Fopt\u002Fhomebrew on Apple Silicon instead of \u002Fusr\u002Flocal on Intel Macs. As long as you run the shell setup line printed by the installer, everything works the same way afterward.",{"q":78815,"a":78816},"Should I install Python with Homebrew or download it from python.org?","Homebrew is best if you want one tool to manage everything and update easily from the terminal. The python.org installer is best if you prefer a double-click installer and a fixed version that never changes underneath you.",{"q":78818,"a":78819},"Why do I keep getting 'command not found: python'?","On a Mac the command is usually python3, not python. Inside an activated virtual environment, plain python works because the environment creates that alias for you.",{"q":78821,"a":78822},"How do I keep my OpenAI API key safe on my Mac?","Store it in a .env file in your project folder and load it at runtime, never paste it directly into your code. Always add .env to your .gitignore so the key is never committed or shared.",{"name":78824,"steps":78825},"How to install Python for AI projects on Mac",[78826,78829,78832,78835,78838],{"name":78827,"text":78828},"Install Homebrew","Run the official Homebrew installer and load it into your shell so you can manage Python safely.",{"name":78830,"text":78831},"Install Python","Install Python 3.11 with Homebrew or the python.org installer, then confirm the version.",{"name":78833,"text":78834},"Create a virtual environment","Make a project folder, create a venv inside it, and activate it before installing anything.",{"name":78836,"text":78837},"Install AI packages","Upgrade pip and install the openai, httpx, and python-dotenv packages into the virtual environment.",{"name":78839,"text":78840},"Verify the setup","Run a one-line import check and a tiny script to confirm Python and your packages load correctly.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-projects-on-mac",{"title":30415,"description":78806},"Install Python for AI on Mac","python-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Fhow-to-install-python-for-ai-projects-on-mac\u002Findex","jWLipjOwv7j-IKcoJv0WR3MH7Uxx5Nvg68zN87qwLMU",{"id":78848,"title":78849,"body":78850,"description":80766,"extension":2419,"faq":80767,"howto":80783,"meta":80794,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":80795,"published":63414,"seo":80796,"seoTitle":80797,"stem":80798,"__hash__":80799},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Findex.md","Setting Up Python for AI: A Step-by-Step Guide for Beginners",{"type":7,"value":78851,"toc":80752},[78852,78855,78858,78864,78867,78869,78872,78900,78903,78907,78910,78919,78925,79016,79018,79021,79067,79082,79086,79098,79101,79119,79133,79136,79179,79187,79216,79220,79226,79288,79296,79306,79347,79361,79379,79383,79397,79413,79415,79438,79443,79457,79463,79469,79477,79480,79494,79503,79512,79516,79521,79669,79671,79701,79712,79760,79764,79767,79933,79935,79938,80084,80088,80095,80667,80678,80680,80683,80721,80725,80727,80749],[10,78853,78849],{"id":78854},"setting-up-python-for-ai-a-step-by-step-guide-for-beginners",[14,78856,78857],{},"You found a Python script that promises to write your marketing copy, clean your spreadsheets, or answer customer questions, and the very first instruction is \"set up your environment.\" For most creators, marketers, and founders, that single line is where the project stalls. This guide removes that wall. By the end you will have a working Python installation, a clean and isolated workspace, the core AI packages installed, and a script that prints a real answer from an AI model on your own machine.",[14,78859,78860,78861,78863],{},"You do not need a programming background. You need a computer, about twenty minutes, and the willingness to copy four small blocks of commands into a terminal (the text-based window where you type instructions instead of clicking buttons). This section sits inside the ",[51,78862,26450],{"href":26449}," track, and it is the foundation every later guide assumes you have finished.",[14,78865,78866],{},"A quick word on the terminal, since it intimidates more newcomers than anything else here. The terminal is just a window that runs one command at a time: you type a line, press Enter, and the computer does exactly what you asked. It does not auto-correct and will not damage anything by being open. The worst that happens when you mistype is an error message you read and fix. Treat every command block below as something you copy, paste with Ctrl+V (or Command+V on Mac), and run — you never have to memorize any of it. If a command seems to hang with no output, that often means it is working, since installs can take a minute or two on a slow connection.",[57,78868,24432],{"id":24431},[14,78870,78871],{},"We work through four numbered steps, each with commands you can paste directly:",[1447,78873,78874,78880,78885,78895],{},[1450,78875,78876,78879],{},[35,78877,78878],{},"Install the Python runtime"," so your computer can read and run Python code.",[1450,78881,78882,78884],{},[35,78883,78833],{}," so each project keeps its own private set of packages.",[1450,78886,78887,78889,78890,21,78892,78894],{},[35,78888,78836],{}," like the ",[18,78891,20],{},[18,78893,5450],{}," and lock their versions.",[1450,78896,78897,78899],{},[35,78898,78839],{}," by running a short script that talks to a live AI model.",[14,78901,78902],{},"After the steps you get a reference table of the commands and options, a troubleshooting list for the errors beginners hit most, a complete worked example script, and a short list of where to go next.",[57,78904,78906],{"id":78905},"the-problem-why-just-install-python-goes-wrong","The problem: why \"just install Python\" goes wrong",[14,78908,78909],{},"Many computers already ship with some version of Python, and many tutorials tell you to install packages straight into it. That sounds simple and it causes most beginner headaches. The version your operating system bundles is often older than AI libraries require, and it is sometimes used by the system itself, so changing it can break other software.",[14,78911,78912,78913,78915,78916,78918],{},"The deeper trap is shared packages. If you install the ",[18,78914,20],{}," SDK (the official toolkit for calling OpenAI models) directly into your system Python, then a second project needs a different version, upgrading one quietly breaks the other. You end up with a tangle nobody can untangle. The fix is a ",[35,78917,52423],{},": a self-contained folder that holds its own copy of Python and its own packages, separate from the system and separate from every other project.",[14,78920,78921,78922,78924],{},"Picture two projects on your laptop. One is a blog-writing script built against ",[18,78923,20],{}," version 1.30, and one is a data-cleaning script that needs a newer 1.40 release with a small incompatible change. Without isolation, only one version can exist at a time, so installing what one project wants silently breaks the other. With a virtual environment per project, each folder keeps its own pinned versions and they never see each other. The good news for a non-developer is that you get all of that safety from a single command in Step 2, and you never have to think about the mechanics again.",[76,78926,78928,79013],{"className":78927},[79],[81,78929,90,78932,90,78935,90,78938,90,78945,90,78947,90,78950,90,78954,90,78956,90,78960,90,78962,78968,78972,78975,90,78979,90,78983,90,78985,90,78988,90,78991,90,78993,90,78996,90,78999,90,79002,90,79004,90,79007,90,79010],{"viewBox":78930,"role":84,"ariaLabelledBy":78931,"preserveAspectRatio":88,"xmlns":89},"-40 -40 800 470",[7091,7092],[92,78933,78934],{"id":7091},"System Python versus an isolated project virtual environment",[96,78936,78937],{"id":7092},"System Python is shared by the operating system and is fragile, while each project virtual environment holds its own packages and can be deleted safely.",[5548,78939,5550,78940,90],{},[5552,78941,5558,78943,5550],{"id":78942,"markerWidth":3868,"markerHeight":3868,"refX":5555,"refY":5556,"orient":5557,"markerUnits":37175},"arrowSetup",[216,78944],{"d":37178,"fill":130},[100,78946],{"x":102,"y":102,"width":7154,"height":67224,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,78948,78949],{"x":52289,"y":15417,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"System Python",[111,78951,78953],{"x":52289,"y":78952,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"54","Shared, fragile",[100,78955],{"x":23367,"y":1100,"width":129,"height":12826,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,78957,78959],{"x":52289,"y":78958,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"115","Used by the OS",[100,78961],{"x":23367,"y":52289,"width":129,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,78963,78964,78965],{"x":52289,"y":37129,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"One global",[175,78966,78967],{"x":52289,"dy":177},"package pile",[111,78969,78971],{"x":52289,"y":78970,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"282","\nChange a version\n",[111,78973,78974],{"x":52289,"y":52331,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"\nhere and other\n",[111,78976,78978],{"x":52289,"y":78977,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"314","\nprojects break\n",[181,78980],{"x1":7154,"y1":24371,"x2":78981,"y2":24371,"stroke":130,"strokeWidth":109,"markerEnd":78982},"398","url(#arrowSetup)",[100,78984],{"x":178,"y":102,"width":7154,"height":67224,"rx":106,"fill":142,"stroke":169,"strokeWidth":109},[111,78986,78987],{"x":12825,"y":15417,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Project folders",[111,78989,78990],{"x":12825,"y":78952,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"Isolated folders",[100,78992],{"x":198,"y":1100,"width":129,"height":141,"rx":106,"fill":107,"stroke":169,"strokeWidth":144},[111,78994,78995],{"x":12825,"y":191,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"blog-writer\u002F.venv",[111,78997,78998],{"x":12825,"y":37119,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"openai 1.x, httpx",[111,79000,2501],{"x":12825,"y":79001,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"148",[100,79003],{"x":198,"y":57319,"width":129,"height":141,"rx":106,"fill":107,"stroke":169,"strokeWidth":144},[111,79005,79006],{"x":12825,"y":69480,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"data-cleaner\u002F.venv",[111,79008,79009],{"x":12825,"y":67243,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"pandas, a newer",[111,79011,79012],{"x":12825,"y":71384,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"openai, no clash",[232,79014,79015],{},"Keep system Python untouched and give every project its own virtual environment so package versions never collide.",[57,79017,238],{"id":237},[14,79019,79020],{},"Before the four steps, make sure you have:",[2322,79022,79023,79029,79045,79050,79056],{},[1450,79024,79025,79028],{},[35,79026,79027],{},"A computer running macOS, Windows, or Linux"," with administrator rights to install software.",[1450,79030,79031,79034,79035,79038,79039,407,79041,79044],{},[35,79032,79033],{},"Terminal access."," On Mac this is the ",[35,79036,79037],{},"Terminal"," app; on Windows it is ",[35,79040,77113],{},[35,79042,79043],{},"Command Prompt",". You open it, type a command, and press Enter.",[1450,79046,79047,79049],{},[35,79048,78041],{}," to download Python and packages.",[1450,79051,79052,79055],{},[35,79053,79054],{},"About 1 GB of free disk space"," for Python plus the AI libraries.",[1450,79057,79058,77123,79061,79064,79065,1363],{},[35,79059,79060],{},"(For the final step only) an AI API key.",[51,79062,77128],{"href":77126,"rel":79063},[6509],", or compare free options in ",[51,79066,5485],{"href":5484},[14,79068,79069,79070,79072,79073,40584,79075,1374,79077,40584,79079,79081],{},"Throughout this guide, commands prefixed with ",[18,79071,268],{}," are written for Mac and Linux. On Windows, type ",[18,79074,416],{},[18,79076,268],{},[18,79078,298],{},[18,79080,69608],{},". We will flag the differences as they come up.",[57,79083,79085],{"id":79084},"step-1-install-the-python-runtime","Step 1: Install the Python runtime",[14,79087,79088,79089,79091,79092,79094,79095,79097],{},"The runtime is the program that reads your ",[18,79090,71118],{}," files and runs them. Install ",[35,79093,61257],{}," because the ",[18,79096,20],{}," SDK and most current AI libraries no longer support older versions. Python 3.9 reached end-of-life in October 2025, so do not pick it even if a tutorial suggests it.",[14,79099,79100],{},"The cleanest way depends on your operating system, and each has a dedicated guide:",[2322,79102,79103,79111],{},[1450,79104,79105,79108,79109,1363],{},[35,79106,79107],{},"Mac:"," Homebrew gives you a modern Python without touching the fragile system copy. Follow ",[51,79110,30415],{"href":30414},[1450,79112,79113,79116,79117,1363],{},[35,79114,79115],{},"Windows:"," The official installer works well, as long as you tick one critical box. Follow ",[51,79118,30411],{"href":30410},[14,79120,79121,79122,79125,79126,79129,79130,79132],{},"If you prefer the quick path, download the latest stable release from ",[51,79123,77145],{"href":77143,"rel":79124},[6509]," and run the installer. On Windows, ",[35,79127,79128],{},"check the box labeled \"Add python.exe to PATH\""," before clicking ",[27,79131,77245],{}," — this single checkbox prevents the most common beginner error.",[14,79134,79135],{},"Once installed, open a fresh terminal window and confirm the version:",[253,79137,79139],{"className":255,"code":79138,"language":257,"meta":258,"style":258},"# macOS \u002F Linux\npython3 --version\npip3 --version\n\n# Windows\npython --version\npip --version\n",[18,79140,79141,79146,79152,79158,79162,79167,79173],{"__ignoreMap":258},[262,79142,79143],{"class":181,"line":264},[262,79144,79145],{"class":291},"# macOS \u002F Linux\n",[262,79147,79148,79150],{"class":181,"line":282},[262,79149,268],{"class":267},[262,79151,52414],{"class":271},[262,79153,79154,79156],{"class":181,"line":295},[262,79155,69608],{"class":267},[262,79157,52414],{"class":271},[262,79159,79160],{"class":181,"line":345},[262,79161,583],{"emptyLinePlaceholder":582},[262,79163,79164],{"class":181,"line":492},[262,79165,79166],{"class":291},"# Windows\n",[262,79168,79169,79171],{"class":181,"line":503},[262,79170,416],{"class":267},[262,79172,52414],{"class":271},[262,79174,79175,79177],{"class":181,"line":521},[262,79176,298],{"class":267},[262,79178,52414],{"class":271},[14,79180,77282,79181,79183,79184,79186],{},[18,79182,77285],{}," and a pip version number. ",[18,79185,298],{}," is the tool that installs Python packages; it ships with Python automatically. If either command returns an error, jump to the troubleshooting section below before continuing.",[14,79188,79189,79190,79192,79193,79196,79197,79199,79200,79202,79203,79206,79207,79209,79210,79212,79213,79215],{},"Two details catch beginners here. First, the version number must start with ",[18,79191,77292],{}," or higher; if you see ",[18,79194,79195],{},"Python 2.7",", an ancient bundled copy answered instead of the one you installed, so use the ",[18,79198,268],{}," command rather than ",[18,79201,416],{},". Second, open a ",[27,79204,79205],{},"new"," terminal window after installing Python. The terminal reads its list of available programs once when it starts, so a window opened before the install does not know Python exists yet, and opening a fresh one fixes many \"I installed it but the command still fails\" reports. On Windows, if the version command works but ",[18,79208,298],{}," does not, the ",[27,79211,77813],{}," checkbox was left unticked during install — re-run the installer, choose ",[27,79214,77817],{},", and enable it.",[57,79217,79219],{"id":79218},"step-2-create-a-virtual-environment","Step 2: Create a virtual environment",[14,79221,79222,79223,79225],{},"Now build the isolated workspace described earlier. Make a project folder, move into it, and create a virtual environment named ",[18,79224,62557],{}," inside it:",[253,79227,79229],{"className":255,"code":79228,"language":257,"meta":258,"style":258},"# macOS \u002F Linux\nmkdir ai-workspace\ncd ai-workspace\npython3 -m venv .venv\n\n# Windows\nmkdir ai-workspace\ncd ai-workspace\npython -m venv .venv\n",[18,79230,79231,79235,79242,79248,79258,79262,79266,79272,79278],{"__ignoreMap":258},[262,79232,79233],{"class":181,"line":264},[262,79234,79145],{"class":291},[262,79236,79237,79239],{"class":181,"line":282},[262,79238,7191],{"class":267},[262,79240,79241],{"class":275}," ai-workspace\n",[262,79243,79244,79246],{"class":181,"line":295},[262,79245,7200],{"class":271},[262,79247,79241],{"class":275},[262,79249,79250,79252,79254,79256],{"class":181,"line":345},[262,79251,268],{"class":267},[262,79253,272],{"class":271},[262,79255,276],{"class":275},[262,79257,279],{"class":275},[262,79259,79260],{"class":181,"line":492},[262,79261,583],{"emptyLinePlaceholder":582},[262,79263,79264],{"class":181,"line":503},[262,79265,79166],{"class":291},[262,79267,79268,79270],{"class":181,"line":521},[262,79269,7191],{"class":267},[262,79271,79241],{"class":275},[262,79273,79274,79276],{"class":181,"line":537},[262,79275,7200],{"class":271},[262,79277,79241],{"class":275},[262,79279,79280,79282,79284,79286],{"class":181,"line":549},[262,79281,416],{"class":267},[262,79283,272],{"class":271},[262,79285,276],{"class":275},[262,79287,279],{"class":275},[14,79289,3349,79290,79292,79293,79295],{},[18,79291,76805],{}," command tells Python to build a small private copy of itself in a hidden folder called ",[18,79294,62557],{},". Nothing is installed globally; everything will live inside this folder.",[14,79297,79298,79299,77376,79301,1374,79303,79305],{},"Creating the environment is not the same as using it. You must ",[35,79300,77375],{},[18,79302,416],{},[18,79304,298],{}," point at the private copy instead of the system one:",[253,79307,79309],{"className":255,"code":79308,"language":257,"meta":258,"style":258},"# macOS \u002F Linux\nsource .venv\u002Fbin\u002Factivate\n\n# Windows (PowerShell)\n.venv\\Scripts\\Activate.ps1\n\n# Windows (Command Prompt)\n.venv\\Scripts\\activate.bat\n",[18,79310,79311,79315,79321,79325,79330,79334,79338,79343],{"__ignoreMap":258},[262,79312,79313],{"class":181,"line":264},[262,79314,79145],{"class":291},[262,79316,79317,79319],{"class":181,"line":282},[262,79318,285],{"class":271},[262,79320,76476],{"class":275},[262,79322,79323],{"class":181,"line":295},[262,79324,583],{"emptyLinePlaceholder":582},[262,79326,79327],{"class":181,"line":345},[262,79328,79329],{"class":291},"# Windows (PowerShell)\n",[262,79331,79332],{"class":181,"line":492},[262,79333,76481],{"class":267},[262,79335,79336],{"class":181,"line":503},[262,79337,583],{"emptyLinePlaceholder":582},[262,79339,79340],{"class":181,"line":521},[262,79341,79342],{"class":291},"# Windows (Command Prompt)\n",[262,79344,79345],{"class":181,"line":537},[262,79346,76496],{"class":267},[14,79348,79349,79350,79352,79353,79355,79356,79358,79359,1363],{},"After activation your prompt shows a ",[18,79351,30512],{}," prefix, your visual proof that you are working inside the isolated environment. Every package you install from now on lands in ",[18,79354,62557],{}," and nowhere else. When you finish for the day, type ",[18,79357,76517],{}," to step back out. For a deeper walkthrough of activation, multiple environments, and common gotchas, see ",[51,79360,2482],{"href":2481},[14,79362,77401,79363,77404,79365,79367,79368,79370,79371,58640,79373,79375,79376,79378],{},[18,79364,30512],{},[18,79366,31961],{}," — it is the most reliable signal that you are installing into the right place. The most common cause of a package \"not being there\" later is installing it while the environment was inactive, so it landed in system Python instead. Two more habits save time. Name the folder ",[18,79369,62557],{}," every time rather than inventing a new name per project; the leading dot keeps it out of your file listings, and editors like VS Code auto-detect a folder by that exact name. And because the environment lives entirely inside your project folder, you can delete and rebuild it whenever it gets confused: remove ",[18,79372,62557],{},[18,79374,76805],{}," again, reactivate, and reinstall from ",[18,79377,76448],{},". Nothing else on your machine is touched.",[57,79380,79382],{"id":79381},"step-3-install-ai-packages","Step 3: Install AI packages",[14,79384,60565,79385,77416,79387,21,79389,77421,79391,79393,79394,79396],{},[18,79386,30512],{},[18,79388,20],{},[18,79390,5450],{},[18,79392,9433],{}," library, because the SDK depends on ",[18,79395,5450],{}," under the hood and handles authentication, retries, and timeouts for you.",[253,79398,79399],{"className":255,"code":5427,"language":257,"meta":258,"style":258},[18,79400,79401],{"__ignoreMap":258},[262,79402,79403,79405,79407,79409,79411],{"class":181,"line":264},[262,79404,298],{"class":267},[262,79406,301],{"class":275},[262,79408,2519],{"class":275},[262,79410,5440],{"class":275},[262,79412,2522],{"class":275},[14,79414,77438],{},[2322,79416,79417,79423,79430],{},[1450,79418,79419,77447],{},[35,79420,79421],{},[18,79422,20],{},[1450,79424,79425,79429],{},[35,79426,79427],{},[18,79428,5450],{}," — the HTTP engine the SDK uses to talk to the API; you rarely call it directly but it must be present.",[1450,79431,79432,77461,79436,77464],{},[35,79433,79434],{},[18,79435,2501],{},[18,79437,319],{},[14,79439,77467,79440,79442],{},[18,79441,76448],{}," file. Anyone who later copies your project (including future you, on a new laptop) can recreate the same setup with one command:",[253,79444,79445],{"className":255,"code":76651,"language":257,"meta":258,"style":258},[18,79446,79447],{"__ignoreMap":258},[262,79448,79449,79451,79453,79455],{"class":181,"line":264},[262,79450,298],{"class":267},[262,79452,76660],{"class":275},[262,79454,76663],{"class":377},[262,79456,76666],{"class":275},[14,79458,79459,79460,79462],{},"To restore those exact versions elsewhere, you would run ",[18,79461,76719],{}," inside a fresh activated environment. This single file is what makes an AI project reproducible instead of \"works on my machine.\"",[14,79464,79465,79466,79468],{},"Next, store your API key safely. Create a file named ",[18,79467,319],{}," in your project root containing one line:",[253,79470,79471],{"className":323,"code":337,"language":325,"meta":258,"style":258},[18,79472,79473],{"__ignoreMap":258},[262,79474,79475],{"class":181,"line":264},[262,79476,337],{},[14,79478,79479],{},"Immediately tell Git to ignore that file so your secret never gets committed or pushed:",[253,79481,79482],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,79483,79484],{"__ignoreMap":258},[262,79485,79486,79488,79490,79492],{"class":181,"line":264},[262,79487,371],{"class":271},[262,79489,374],{"class":275},[262,79491,378],{"class":377},[262,79493,381],{"class":275},[14,79495,77510,79496,3921,79498,79500,79501,1363],{},[18,79497,319],{},[18,79499,359],{}," before your first commit — a leaked API key can be used to run up real charges on your account. If you want to understand how the key authenticates each request, read ",[51,79502,2487],{"href":2486},[14,79504,79505,79506,79508,79509,79511],{},"Two small things make this step go smoothly. The whole filename is literally ",[18,79507,319],{},", with no extension, and on Mac and Linux that leading dot hides it from a normal file listing by design. When you write the key, do not wrap it in quotes and do not leave a trailing space; both produce a key that looks correct but fails to authenticate. The value should read ",[18,79510,8435],{}," with nothing else on the line. And never paste a real key into a chat window, a screenshot, or a commit — treating it like a password you would never share avoids the most expensive beginner mistake in AI work.",[57,79513,79515],{"id":79514},"step-4-verify-the-setup","Step 4: Verify the setup",[14,79517,79518,79519,76735],{},"The final step proves every piece works together: Python runs, the environment is active, the SDK is installed, and your key authenticates. Create a file named ",[18,79520,77534],{},[253,79522,79524],{"className":414,"code":79523,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\n# Load OPENAI_API_KEY from the .env file into the environment\nload_dotenv()\n\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\"role\": \"user\", \"content\": \"Reply with exactly: Setup verified.\"},\n    ],\n    max_tokens=10,\n)\n\nprint(response.choices[0].message.content)\n",[18,79525,79526,79532,79542,79552,79556,79560,79564,79568,79586,79590,79598,79608,79616,79637,79641,79651,79655,79659],{"__ignoreMap":258},[262,79527,79528,79530],{"class":181,"line":264},[262,79529,684],{"class":377},[262,79531,687],{"class":429},[262,79533,79534,79536,79538,79540],{"class":181,"line":282},[262,79535,705],{"class":377},[262,79537,708],{"class":429},[262,79539,684],{"class":377},[262,79541,713],{"class":429},[262,79543,79544,79546,79548,79550],{"class":181,"line":295},[262,79545,705],{"class":377},[262,79547,720],{"class":429},[262,79549,684],{"class":377},[262,79551,725],{"class":429},[262,79553,79554],{"class":181,"line":345},[262,79555,583],{"emptyLinePlaceholder":582},[262,79557,79558],{"class":181,"line":492},[262,79559,77574],{"class":291},[262,79561,79562],{"class":181,"line":503},[262,79563,734],{"class":429},[262,79565,79566],{"class":181,"line":521},[262,79567,583],{"emptyLinePlaceholder":582},[262,79569,79570,79572,79574,79576,79578,79580,79582,79584],{"class":181,"line":537},[262,79571,739],{"class":429},[262,79573,476],{"class":377},[262,79575,1588],{"class":429},[262,79577,2674],{"class":611},[262,79579,476],{"class":377},[262,79581,1199],{"class":429},[262,79583,2681],{"class":275},[262,79585,2684],{"class":429},[262,79587,79588],{"class":181,"line":549},[262,79589,583],{"emptyLinePlaceholder":582},[262,79591,79592,79594,79596],{"class":181,"line":570},[262,79593,48362],{"class":429},[262,79595,476],{"class":377},[262,79597,1189],{"class":429},[262,79599,79600,79602,79604,79606],{"class":181,"line":579},[262,79601,48371],{"class":611},[262,79603,476],{"class":377},[262,79605,1207],{"class":275},[262,79607,1315],{"class":429},[262,79609,79610,79612,79614],{"class":181,"line":586},[262,79611,48388],{"class":611},[262,79613,476],{"class":377},[262,79615,1220],{"class":429},[262,79617,79618,79620,79622,79624,79626,79628,79630,79632,79635],{"class":181,"line":591},[262,79619,7726],{"class":429},[262,79621,1228],{"class":275},[262,79623,1231],{"class":429},[262,79625,1291],{"class":275},[262,79627,608],{"class":429},[262,79629,1239],{"class":275},[262,79631,1231],{"class":429},[262,79633,79634],{"class":275},"\"Reply with exactly: Setup verified.\"",[262,79636,3143],{"class":429},[262,79638,79639],{"class":181,"line":623},[262,79640,48439],{"class":429},[262,79642,79643,79645,79647,79649],{"class":181,"line":634},[262,79644,77660],{"class":611},[262,79646,476],{"class":377},[262,79648,3868],{"class":271},[262,79650,1315],{"class":429},[262,79652,79653],{"class":181,"line":845},[262,79654,660],{"class":429},[262,79656,79657],{"class":181,"line":850},[262,79658,583],{"emptyLinePlaceholder":582},[262,79660,79661,79663,79665,79667],{"class":181,"line":864},[262,79662,637],{"class":271},[262,79664,48465],{"class":429},[262,79666,102],{"class":271},[262,79668,6048],{"class":429},[14,79670,77687],{},[253,79672,79674],{"className":255,"code":79673,"language":257,"meta":258,"style":258},"# macOS \u002F Linux\npython3 verify_setup.py\n\n# Windows\npython verify_setup.py\n",[18,79675,79676,79680,79687,79691,79695],{"__ignoreMap":258},[262,79677,79678],{"class":181,"line":264},[262,79679,79145],{"class":291},[262,79681,79682,79684],{"class":181,"line":282},[262,79683,268],{"class":267},[262,79685,79686],{"class":275}," verify_setup.py\n",[262,79688,79689],{"class":181,"line":295},[262,79690,583],{"emptyLinePlaceholder":582},[262,79692,79693],{"class":181,"line":345},[262,79694,79166],{"class":291},[262,79696,79697,79699],{"class":181,"line":492},[262,79698,416],{"class":267},[262,79700,79686],{"class":275},[14,79702,77699,79703,79706,79707,77706,79709,79711],{},[18,79704,79705],{},"Setup verified."," printed in your terminal, every layer of your stack is working: Python is installed and modern, the virtual environment is active, the ",[18,79708,20],{},[18,79710,319],{}," file loaded, and your key was accepted by a live model. That one line confirms all five things at once.",[14,79713,79714,79715,79718,79719,79722,79723,79725,79726,79728,79729,79731,79732,79734,79735,79737,79738,79740,79741,79744,79745,79747,79748,79750,79751,79753,79754,79756,79757,79759],{},"It helps to know what each line does, because you will reuse this exact pattern in nearly every AI project. The imports bring in three tools: ",[18,79716,79717],{},"os"," reads values from the environment, ",[18,79720,79721],{},"load_dotenv"," pulls the contents of ",[18,79724,319],{}," into that environment, and ",[18,79727,37312],{}," is the client class that talks to the API. Calling ",[18,79730,8439],{}," reads your ",[18,79733,319],{}," file so ",[18,79736,70346],{}," can find the key. The ",[18,79739,13067],{}," object holds your authenticated connection, and ",[18,79742,79743],{},"client.chat.completions.create(...)"," is the actual request: you name a ",[18,79746,805],{},", pass a list of ",[18,79749,43269],{},", and cap the reply length with ",[18,79752,3846],{},". The answer comes back nested in the response, which is why you read it from ",[18,79755,7909],{},". If you see an error instead, the troubleshooting section below covers the usual causes, including a missing key or an old ",[18,79758,805],{}," name.",[57,79761,79763],{"id":79762},"command-and-option-reference","Command and option reference",[14,79765,79766],{},"Keep this table handy while you work. It summarizes the commands and options used above.",[1379,79768,79769,79783],{},[1382,79770,79771],{},[1385,79772,79773,79776,79779,79781],{},[1388,79774,79775],{},"Command \u002F option",[1388,79777,79778],{},"What it sets",[1388,79780,3798],{},[1388,79782,1396],{},[1398,79784,79785,79802,79824,79838,79858,79874,79888,79902,79917],{},[1385,79786,79787,79791,79794,79799],{},[1403,79788,79789],{},[18,79790,76805],{},[1403,79792,79793],{},"name of the environment folder",[1403,79795,79796,79798],{},[18,79797,62557],{}," (your choice)",[1403,79800,79801],{},"Creates an isolated Python copy inside the named folder.",[1385,79803,79804,79808,79811,79814],{},[1403,79805,79806],{},[18,79807,30519],{},[1403,79809,79810],{},"activation (Mac\u002FLinux)",[1403,79812,79813],{},"inactive",[1403,79815,79816,79817,1374,79819,79821,79822,1363],{},"Points ",[18,79818,416],{},[18,79820,298],{}," at the environment; shows ",[18,79823,30512],{},[1385,79825,79826,79830,79833,79835],{},[1403,79827,79828],{},[18,79829,76834],{},[1403,79831,79832],{},"activation (Windows)",[1403,79834,79813],{},[1403,79836,79837],{},"Same as above, for PowerShell.",[1385,79839,79840,79844,79847,79850],{},[1403,79841,79842],{},[18,79843,76517],{},[1403,79845,79846],{},"leave the environment",[1403,79848,79849],{},"—",[1403,79851,79852,79853,1374,79855,79857],{},"Returns ",[18,79854,416],{},[18,79856,298],{}," to the system version.",[1385,79859,79860,79865,79868,79871],{},[1403,79861,79862],{},[18,79863,79864],{},"pip install \u003Cpkg>",[1403,79866,79867],{},"add a package",[1403,79869,79870],{},"latest version",[1403,79872,79873],{},"Installs into the active environment only.",[1385,79875,79876,79880,79883,79885],{},[1403,79877,79878],{},[18,79879,76698],{},[1403,79881,79882],{},"record versions",[1403,79884,79849],{},[1403,79886,79887],{},"Saves exact installed versions for reproducibility.",[1385,79889,79890,79894,79897,79899],{},[1403,79891,79892],{},[18,79893,76719],{},[1403,79895,79896],{},"restore versions",[1403,79898,79849],{},[1403,79900,79901],{},"Recreates the recorded setup in a new environment.",[1385,79903,79904,79909,79912,79914],{},[1403,79905,79906],{},[18,79907,79908],{},"model=\"gpt-4o-mini\"",[1403,79910,79911],{},"which AI model to call",[1403,79913,14674],{},[1403,79915,79916],{},"Chooses a fast, low-cost model for testing.",[1385,79918,79919,79924,79927,79930],{},[1403,79920,79921],{},[18,79922,79923],{},"max_tokens=10",[1403,79925,79926],{},"reply length cap",[1403,79928,79929],{},"model default",[1403,79931,79932],{},"Limits the response so test calls stay cheap.",[57,79934,1445],{"id":1444},[14,79936,79937],{},"These are the exact errors beginners hit most, with the cause and a one-line fix for each.",[1447,79939,79940,79959,79974,79993,80004,80016,80034,80042,80069],{},[1450,79941,79942,79952,79953,79955,79956,79958],{},[35,79943,79944,79947,79948,79951],{},[18,79945,79946],{},"python: command not found"," (Mac\u002FLinux) or ",[18,79949,79950],{},"'python' is not recognized"," (Windows)."," Your shell cannot find Python on its PATH. On Windows, re-run the installer and tick ",[27,79954,77813],{},"; on Mac, install via Homebrew and use the ",[18,79957,268],{}," command.",[1450,79960,79961,79966,79967,31948,79970,79973],{},[35,79962,79963,1363],{},[18,79964,79965],{},"pip: command not found"," pip is not on your PATH even though Python is. Use ",[18,79968,79969],{},"python3 -m pip install \u003Cpkg>",[18,79971,79972],{},"python -m pip"," on Windows), which calls pip through Python directly.",[1450,79975,79976,79981,79982,79984,79985,7918,79988,79947,79991,79951],{},[35,79977,79978,1363],{},[18,79979,79980],{},"source: no such file or directory: .venv\u002Fbin\u002Factivate"," The environment was never created, or you are in the wrong folder. Run ",[18,79983,76805],{}," first, and confirm you are inside ",[18,79986,79987],{},"ai-workspace",[18,79989,79990],{},"pwd",[18,79992,7200],{},[1450,79994,79995,80000,80001,80003],{},[35,79996,79997,79999],{},[18,79998,76874],{}," (Windows PowerShell)."," PowerShell blocks scripts by default. Run ",[18,80002,76878],{}," once, then activate again.",[1450,80005,80006,80010,80011,80013,80014,1363],{},[35,80007,80008,1363],{},[18,80009,8493],{}," You installed the package outside the active environment, or forgot to activate it. Confirm ",[18,80012,30512],{}," is in your prompt, then re-run ",[18,80015,77864],{},[1450,80017,80018,80022,80023,80025,80026,80028,80029,80031,80032,1363],{},[35,80019,80020,1363],{},[18,80021,21739],{}," Your key is missing, expired, or has stray quotes or spaces in ",[18,80024,319],{},". Check the ",[18,80027,319],{}," line reads ",[18,80030,8435],{}," with no quotes; for a full walkthrough see ",[51,80033,388],{"href":387},[1450,80035,80036,80041],{},[35,80037,80038,80040],{},[18,80039,28811],{}," or a message about quota or insufficient funds."," Your key is valid but the account has no available credit, often on a brand-new account before any payment method or free credit is set up. Add a payment method or switch to a free-tier option, then run the script again; nothing is wrong with your Python setup.",[1450,80043,80044,80051,80052,80054,80055,80057,80058,80060,80061,80063,80064,55859,80066,80068],{},[35,80045,80046,80047,58522,80049,1363],{},"The key loads but ",[18,80048,70346],{},[18,80050,8471],{}," Your script is running in a different folder from the ",[18,80053,319],{}," file, so ",[18,80056,8439],{}," cannot find it. Run the script from the same project folder that holds ",[18,80059,319],{},", or pass an explicit path to ",[18,80062,79721],{},". Confirming the file is named exactly ",[18,80065,319],{},[18,80067,77518],{},", which Windows can add silently) clears this up most of the time.",[1450,80070,80071,80077,80078,80080,80081,80083],{},[35,80072,80073,80076],{},[18,80074,80075],{},"NotFoundError"," or a message that the model does not exist."," The ",[18,80079,805],{}," name is misspelled or retired. Use a current small model such as ",[18,80082,2703],{}," for testing, and copy the name exactly, since model identifiers are case-sensitive and easy to mistype.",[57,80085,80087],{"id":80086},"worked-example-a-setup-health-check-script","Worked example: a setup health-check script",[14,80089,80090,80091,80094],{},"Once your basic verification passes, this slightly larger script gives you a reusable health check. It confirms the Python version, checks that the SDK imported, checks that the key is loaded and shaped correctly, makes a live call, and reports each result in plain language. The script is deliberately defensive: it stops at the first problem it finds and tells you the fix, so you never have to guess which layer failed. Save it as ",[18,80092,80093],{},"health_check.py"," and run it any time a project misbehaves.",[253,80096,80098],{"className":414,"code":80097,"language":416,"meta":258,"style":258},"import os\nimport sys\nfrom dotenv import load_dotenv\n\n# 1. Confirm the Python version meets the 3.10+ requirement.\n#    sys.version_info gives the running interpreter's version as numbers,\n#    so we can compare it directly instead of parsing a string.\nmajor, minor = sys.version_info[:2]\nif (major, minor) \u003C (3, 10):\n    print(f\"FAIL: Python {major}.{minor} is too old. Install 3.10 or newer.\")\n    sys.exit(1)\nprint(f\"OK: Python {major}.{minor} detected.\")\n\n# 2. Confirm the openai SDK is installed inside THIS environment.\n#    A failed import here almost always means the virtual environment\n#    was not active when you ran pip install.\ntry:\n    from openai import OpenAI, APIError, AuthenticationError, RateLimitError\nexcept ModuleNotFoundError:\n    print(\"FAIL: The openai package is missing. Activate .venv, then run:\")\n    print(\"      pip install openai httpx python-dotenv\")\n    sys.exit(1)\nprint(\"OK: openai SDK imported successfully.\")\n\n# 3. Load the .env file and confirm the API key is present and well-formed.\n#    A key with stray quotes or spaces looks correct but fails to authenticate,\n#    so we trim whitespace and sanity-check the expected prefix.\nload_dotenv()\napi_key = os.getenv(\"OPENAI_API_KEY\")\nif not api_key:\n    print(\"FAIL: OPENAI_API_KEY not found. Check your .env file exists here.\")\n    sys.exit(1)\n\napi_key = api_key.strip()\nif not api_key.startswith(\"sk-\"):\n    print(\"FAIL: The key does not look right. Remove any quotes or spaces in .env.\")\n    sys.exit(1)\nprint(\"OK: API key loaded from .env and looks well-formed.\")\n\n# 4. Make a tiny live call to confirm the key actually authenticates.\n#    We keep max_tokens small so this health check stays nearly free to run.\nclient = OpenAI(api_key=api_key)\ntry:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": \"Reply with exactly: Setup verified.\"}],\n        max_tokens=10,\n    )\n    print(f\"OK: Model replied -> {response.choices[0].message.content}\")\n    print(\"All checks passed. Your environment is ready for AI work.\")\nexcept AuthenticationError:\n    print(\"FAIL: The API key was rejected. Generate a fresh key and update .env.\")\n    sys.exit(1)\nexcept RateLimitError:\n    print(\"FAIL: The key works but the account has no available quota or credit.\")\n    sys.exit(1)\nexcept APIError as e:\n    print(f\"FAIL: The API returned an error -> {e}\")\n    sys.exit(1)\n",[18,80099,80100,80106,80112,80122,80126,80131,80136,80141,80155,80174,80206,80215,80245,80249,80254,80259,80264,80270,80282,80291,80302,80313,80321,80332,80336,80341,80346,80351,80355,80367,80375,80386,80394,80398,80407,80420,80431,80439,80450,80454,80459,80464,80479,80485,80493,80503,80527,80537,80541,80566,80577,80584,80595,80603,80609,80620,80628,80638,80659],{"__ignoreMap":258},[262,80101,80102,80104],{"class":181,"line":264},[262,80103,684],{"class":377},[262,80105,687],{"class":429},[262,80107,80108,80110],{"class":181,"line":282},[262,80109,684],{"class":377},[262,80111,78429],{"class":429},[262,80113,80114,80116,80118,80120],{"class":181,"line":295},[262,80115,705],{"class":377},[262,80117,708],{"class":429},[262,80119,684],{"class":377},[262,80121,713],{"class":429},[262,80123,80124],{"class":181,"line":345},[262,80125,583],{"emptyLinePlaceholder":582},[262,80127,80128],{"class":181,"line":492},[262,80129,80130],{"class":291},"# 1. Confirm the Python version meets the 3.10+ requirement.\n",[262,80132,80133],{"class":181,"line":503},[262,80134,80135],{"class":291},"#    sys.version_info gives the running interpreter's version as numbers,\n",[262,80137,80138],{"class":181,"line":521},[262,80139,80140],{"class":291},"#    so we can compare it directly instead of parsing a string.\n",[262,80142,80143,80146,80148,80151,80153],{"class":181,"line":537},[262,80144,80145],{"class":429},"major, minor ",[262,80147,476],{"class":377},[262,80149,80150],{"class":429}," sys.version_info[:",[262,80152,109],{"class":271},[262,80154,957],{"class":429},[262,80156,80157,80159,80162,80164,80166,80168,80170,80172],{"class":181,"line":549},[262,80158,2210],{"class":377},[262,80160,80161],{"class":429}," (major, minor) ",[262,80163,512],{"class":377},[262,80165,13751],{"class":429},[262,80167,5556],{"class":271},[262,80169,608],{"class":429},[262,80171,3868],{"class":271},[262,80173,8192],{"class":429},[262,80175,80176,80178,80180,80182,80185,80187,80190,80192,80194,80196,80199,80201,80204],{"class":181,"line":570},[262,80177,1089],{"class":271},[262,80179,602],{"class":429},[262,80181,642],{"class":377},[262,80183,80184],{"class":275},"\"FAIL: Python ",[262,80186,3039],{"class":271},[262,80188,80189],{"class":429},"major",[262,80191,654],{"class":271},[262,80193,1363],{"class":275},[262,80195,3039],{"class":271},[262,80197,80198],{"class":429},"minor",[262,80200,654],{"class":271},[262,80202,80203],{"class":275}," is too old. Install 3.10 or newer.\"",[262,80205,660],{"class":429},[262,80207,80208,80211,80213],{"class":181,"line":579},[262,80209,80210],{"class":429},"    sys.exit(",[262,80212,997],{"class":271},[262,80214,660],{"class":429},[262,80216,80217,80219,80221,80223,80226,80228,80230,80232,80234,80236,80238,80240,80243],{"class":181,"line":586},[262,80218,637],{"class":271},[262,80220,602],{"class":429},[262,80222,642],{"class":377},[262,80224,80225],{"class":275},"\"OK: Python ",[262,80227,3039],{"class":271},[262,80229,80189],{"class":429},[262,80231,654],{"class":271},[262,80233,1363],{"class":275},[262,80235,3039],{"class":271},[262,80237,80198],{"class":429},[262,80239,654],{"class":271},[262,80241,80242],{"class":275}," detected.\"",[262,80244,660],{"class":429},[262,80246,80247],{"class":181,"line":591},[262,80248,583],{"emptyLinePlaceholder":582},[262,80250,80251],{"class":181,"line":623},[262,80252,80253],{"class":291},"# 2. Confirm the openai SDK is installed inside THIS environment.\n",[262,80255,80256],{"class":181,"line":634},[262,80257,80258],{"class":291},"#    A failed import here almost always means the virtual environment\n",[262,80260,80261],{"class":181,"line":845},[262,80262,80263],{"class":291},"#    was not active when you ran pip install.\n",[262,80265,80266,80268],{"class":181,"line":850},[262,80267,14430],{"class":377},[262,80269,1160],{"class":429},[262,80271,80272,80275,80277,80279],{"class":181,"line":864},[262,80273,80274],{"class":377},"    from",[262,80276,720],{"class":429},[262,80278,684],{"class":377},[262,80280,80281],{"class":429}," OpenAI, APIError, AuthenticationError, RateLimitError\n",[262,80283,80284,80286,80289],{"class":181,"line":1683},[262,80285,14433],{"class":377},[262,80287,80288],{"class":271}," ModuleNotFoundError",[262,80290,1160],{"class":429},[262,80292,80293,80295,80297,80300],{"class":181,"line":1688},[262,80294,1089],{"class":271},[262,80296,602],{"class":429},[262,80298,80299],{"class":275},"\"FAIL: The openai package is missing. Activate .venv, then run:\"",[262,80301,660],{"class":429},[262,80303,80304,80306,80308,80311],{"class":181,"line":1693},[262,80305,1089],{"class":271},[262,80307,602],{"class":429},[262,80309,80310],{"class":275},"\"      pip install openai httpx python-dotenv\"",[262,80312,660],{"class":429},[262,80314,80315,80317,80319],{"class":181,"line":1728},[262,80316,80210],{"class":429},[262,80318,997],{"class":271},[262,80320,660],{"class":429},[262,80322,80323,80325,80327,80330],{"class":181,"line":1737},[262,80324,637],{"class":271},[262,80326,602],{"class":429},[262,80328,80329],{"class":275},"\"OK: openai SDK imported successfully.\"",[262,80331,660],{"class":429},[262,80333,80334],{"class":181,"line":1751},[262,80335,583],{"emptyLinePlaceholder":582},[262,80337,80338],{"class":181,"line":1764},[262,80339,80340],{"class":291},"# 3. Load the .env file and confirm the API key is present and well-formed.\n",[262,80342,80343],{"class":181,"line":1779},[262,80344,80345],{"class":291},"#    A key with stray quotes or spaces looks correct but fails to authenticate,\n",[262,80347,80348],{"class":181,"line":1793},[262,80349,80350],{"class":291},"#    so we trim whitespace and sanity-check the expected prefix.\n",[262,80352,80353],{"class":181,"line":1800},[262,80354,734],{"class":429},[262,80356,80357,80359,80361,80363,80365],{"class":181,"line":1805},[262,80358,67390],{"class":429},[262,80360,476],{"class":377},[262,80362,754],{"class":429},[262,80364,2681],{"class":275},[262,80366,660],{"class":429},[262,80368,80369,80371,80373],{"class":181,"line":1810},[262,80370,2210],{"class":377},[262,80372,2818],{"class":377},[262,80374,70304],{"class":429},[262,80376,80377,80379,80381,80384],{"class":181,"line":1823},[262,80378,1089],{"class":271},[262,80380,602],{"class":429},[262,80382,80383],{"class":275},"\"FAIL: OPENAI_API_KEY not found. Check your .env file exists here.\"",[262,80385,660],{"class":429},[262,80387,80388,80390,80392],{"class":181,"line":1846},[262,80389,80210],{"class":429},[262,80391,997],{"class":271},[262,80393,660],{"class":429},[262,80395,80396],{"class":181,"line":1861},[262,80397,583],{"emptyLinePlaceholder":582},[262,80399,80400,80402,80404],{"class":181,"line":1866},[262,80401,67390],{"class":429},[262,80403,476],{"class":377},[262,80405,80406],{"class":429}," api_key.strip()\n",[262,80408,80409,80411,80413,80416,80418],{"class":181,"line":1871},[262,80410,2210],{"class":377},[262,80412,2818],{"class":377},[262,80414,80415],{"class":429}," api_key.startswith(",[262,80417,78517],{"class":275},[262,80419,8192],{"class":429},[262,80421,80422,80424,80426,80429],{"class":181,"line":1890},[262,80423,1089],{"class":271},[262,80425,602],{"class":429},[262,80427,80428],{"class":275},"\"FAIL: The key does not look right. Remove any quotes or spaces in .env.\"",[262,80430,660],{"class":429},[262,80432,80433,80435,80437],{"class":181,"line":1909},[262,80434,80210],{"class":429},[262,80436,997],{"class":271},[262,80438,660],{"class":429},[262,80440,80441,80443,80445,80448],{"class":181,"line":1914},[262,80442,637],{"class":271},[262,80444,602],{"class":429},[262,80446,80447],{"class":275},"\"OK: API key loaded from .env and looks well-formed.\"",[262,80449,660],{"class":429},[262,80451,80452],{"class":181,"line":1919},[262,80453,583],{"emptyLinePlaceholder":582},[262,80455,80456],{"class":181,"line":1946},[262,80457,80458],{"class":291},"# 4. Make a tiny live call to confirm the key actually authenticates.\n",[262,80460,80461],{"class":181,"line":1959},[262,80462,80463],{"class":291},"#    We keep max_tokens small so this health check stays nearly free to run.\n",[262,80465,80466,80468,80470,80472,80474,80476],{"class":181,"line":1996},[262,80467,739],{"class":429},[262,80469,476],{"class":377},[262,80471,1588],{"class":429},[262,80473,2674],{"class":611},[262,80475,476],{"class":377},[262,80477,80478],{"class":429},"api_key)\n",[262,80480,80481,80483],{"class":181,"line":2012},[262,80482,14430],{"class":377},[262,80484,1160],{"class":429},[262,80486,80487,80489,80491],{"class":181,"line":2040},[262,80488,1184],{"class":429},[262,80490,476],{"class":377},[262,80492,1189],{"class":429},[262,80494,80495,80497,80499,80501],{"class":181,"line":2045},[262,80496,1194],{"class":611},[262,80498,476],{"class":377},[262,80500,1207],{"class":275},[262,80502,1315],{"class":429},[262,80504,80505,80507,80509,80511,80513,80515,80517,80519,80521,80523,80525],{"class":181,"line":2050},[262,80506,1215],{"class":611},[262,80508,476],{"class":377},[262,80510,8856],{"class":429},[262,80512,1228],{"class":275},[262,80514,1231],{"class":429},[262,80516,1291],{"class":275},[262,80518,608],{"class":429},[262,80520,1239],{"class":275},[262,80522,1231],{"class":429},[262,80524,79634],{"class":275},[262,80526,54808],{"class":429},[262,80528,80529,80531,80533,80535],{"class":181,"line":2067},[262,80530,4679],{"class":611},[262,80532,476],{"class":377},[262,80534,3868],{"class":271},[262,80536,1315],{"class":429},[262,80538,80539],{"class":181,"line":2077},[262,80540,1011],{"class":429},[262,80542,80543,80545,80547,80549,80552,80554,80556,80558,80560,80562,80564],{"class":181,"line":2086},[262,80544,1089],{"class":271},[262,80546,602],{"class":429},[262,80548,642],{"class":377},[262,80550,80551],{"class":275},"\"OK: Model replied -> ",[262,80553,3039],{"class":271},[262,80555,72465],{"class":429},[262,80557,102],{"class":271},[262,80559,72470],{"class":429},[262,80561,654],{"class":271},[262,80563,1176],{"class":275},[262,80565,660],{"class":429},[262,80567,80568,80570,80572,80575],{"class":181,"line":2097},[262,80569,1089],{"class":271},[262,80571,602],{"class":429},[262,80573,80574],{"class":275},"\"All checks passed. Your environment is ready for AI work.\"",[262,80576,660],{"class":429},[262,80578,80579,80581],{"class":181,"line":2106},[262,80580,14433],{"class":377},[262,80582,80583],{"class":429}," AuthenticationError:\n",[262,80585,80586,80588,80590,80593],{"class":181,"line":2126},[262,80587,1089],{"class":271},[262,80589,602],{"class":429},[262,80591,80592],{"class":275},"\"FAIL: The API key was rejected. Generate a fresh key and update .env.\"",[262,80594,660],{"class":429},[262,80596,80597,80599,80601],{"class":181,"line":2148},[262,80598,80210],{"class":429},[262,80600,997],{"class":271},[262,80602,660],{"class":429},[262,80604,80605,80607],{"class":181,"line":2165},[262,80606,14433],{"class":377},[262,80608,9787],{"class":429},[262,80610,80611,80613,80615,80618],{"class":181,"line":2170},[262,80612,1089],{"class":271},[262,80614,602],{"class":429},[262,80616,80617],{"class":275},"\"FAIL: The key works but the account has no available quota or credit.\"",[262,80619,660],{"class":429},[262,80621,80622,80624,80626],{"class":181,"line":2181},[262,80623,80210],{"class":429},[262,80625,997],{"class":271},[262,80627,660],{"class":429},[262,80629,80630,80632,80634,80636],{"class":181,"line":2186},[262,80631,14433],{"class":377},[262,80633,9882],{"class":429},[262,80635,697],{"class":377},[262,80637,11457],{"class":429},[262,80639,80640,80642,80644,80646,80649,80651,80653,80655,80657],{"class":181,"line":2197},[262,80641,1089],{"class":271},[262,80643,602],{"class":429},[262,80645,642],{"class":377},[262,80647,80648],{"class":275},"\"FAIL: The API returned an error -> ",[262,80650,3039],{"class":271},[262,80652,11475],{"class":429},[262,80654,654],{"class":271},[262,80656,1176],{"class":275},[262,80658,660],{"class":429},[262,80660,80661,80663,80665],{"class":181,"line":2202},[262,80662,80210],{"class":429},[262,80664,997],{"class":271},[262,80666,660],{"class":429},[14,80668,80669,80670,80673,80674,80677],{},"Each ",[18,80671,80672],{},"OK"," line tells you which layer is healthy, and each ",[18,80675,80676],{},"FAIL"," line points to the exact fix. Because the script exits the moment a check fails, the last line you see is always the one that needs your attention. The checks run in dependency order — no point testing your key before confirming the SDK imported — so a clean run reads top to bottom like a checklist. Keeping a script like this in every project turns \"something is broken\" into a thirty-second diagnosis, and it is just as useful when you rebuild the environment on a new laptop.",[57,80679,2355],{"id":2354},[14,80681,80682],{},"Your foundation is solid. Build on it in this order:",[1447,80684,80685,80693,80703,80712],{},[1450,80686,80687,80690,80691,1363],{},[35,80688,80689],{},"Deepen your environment skills."," Learn to manage several projects at once in ",[51,80692,2482],{"href":2481},[1450,80694,80695,80698,80699,407,80701,1363],{},[35,80696,80697],{},"Match your operating system exactly."," If anything in Step 1 felt rushed, follow ",[51,80700,30415],{"href":30414},[51,80702,30411],{"href":30410},[1450,80704,80705,80708,80709,80711],{},[35,80706,80707],{},"Understand the API you just called."," Move on to ",[51,80710,2487],{"href":2486}," to learn what each request actually sends and how billing works.",[1450,80713,80714,80717,80718,80720],{},[35,80715,80716],{},"Write better instructions for the model."," Continue to ",[51,80719,7554],{"href":7553}," to get reliable, well-formatted output.",[14,80722,2375,80723,1363],{},[51,80724,26450],{"href":26449},[57,80726,2381],{"id":2380},[2322,80728,80729,80733,80737,80741,80745],{},[1450,80730,80731],{},[51,80732,30415],{"href":30414},[1450,80734,80735],{},[51,80736,30411],{"href":30410},[1450,80738,80739],{},[51,80740,2482],{"href":2481},[1450,80742,80743],{},[51,80744,2487],{"href":2486},[1450,80746,80747],{},[51,80748,26450],{"href":26449},[2401,80750,80751],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":80753},[80754,80755,80756,80757,80758,80759,80760,80761,80762,80763,80764,80765],{"id":24431,"depth":282,"text":24432},{"id":78905,"depth":282,"text":78906},{"id":237,"depth":282,"text":238},{"id":79084,"depth":282,"text":79085},{"id":79218,"depth":282,"text":79219},{"id":79381,"depth":282,"text":79382},{"id":79514,"depth":282,"text":79515},{"id":79762,"depth":282,"text":79763},{"id":1444,"depth":282,"text":1445},{"id":80086,"depth":282,"text":80087},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Install Python, create a virtual environment, install AI packages, and verify your setup. A step-by-step guide for non-developers building AI workflows.",[80768,80771,80774,80777,80780],{"q":80769,"a":80770},"What Python version do I need for AI projects?","Use Python 3.10 or newer. Python 3.9 reached end-of-life in October 2025, and the openai SDK plus most modern AI libraries now expect 3.10+. Pick the latest stable 3.x release shown on python.org.",{"q":80772,"a":80773},"Do I need to know how to code to set up Python for AI?","No. You only need to copy and run a handful of commands in your terminal. The four steps in this guide are install Python, create a virtual environment, install packages, and run a verification script.",{"q":80775,"a":80776},"What is a virtual environment and why does it matter?","A virtual environment is a private copy of Python and its packages that lives inside your project folder. It stops different projects from fighting over package versions and keeps your system Python clean and safe to upgrade.",{"q":80778,"a":80779},"Why is my computer saying python is not recognized or command not found?","Your shell cannot find Python on its PATH. On Windows, re-run the installer and tick Add python.exe to PATH. On Mac, install Python with Homebrew and use the python3 command instead of python.",{"q":80781,"a":80782},"Do I have to pay for an API key to follow this guide?","Not to set up Python itself. The install, virtual environment, and package steps are free. You only need a paid or free-tier API key when you run the final script that calls a live AI model.",{"name":80784,"steps":80785},"How to set up Python for AI",[80786,80788,80790,80792],{"name":78878,"text":80787},"Download and install Python 3.10 or newer from python.org or Homebrew, then confirm the version in your terminal.",{"name":78833,"text":80789},"Make a project folder and run python -m venv .venv, then activate it so packages install in isolation.",{"name":78836,"text":80791},"With the environment active, install openai, httpx, and python-dotenv, then freeze them into requirements.txt.",{"name":78839,"text":80793},"Run a short script that loads your API key from a .env file and prints a reply from a live model.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai",{"title":78849,"description":80766},"Setting Up Python for AI: A Beginner Guide","python-ai-fundamentals-for-non-developers\u002Fsetting-up-python-for-ai\u002Findex","_hU9VNGvQDwvhRFt5W4pgwXpPf81PCMx03SK0q6g1tU",{"id":80801,"title":80802,"body":80803,"description":82338,"extension":2419,"faq":82339,"howto":82355,"meta":82373,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":82374,"published":67167,"seo":82375,"seoTitle":5485,"stem":82376,"__hash__":82377},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fbest-free-ai-apis-for-beginners\u002Findex.md","Best Free AI APIs for Beginners: A Python Quickstart",{"type":7,"value":80804,"toc":82326},[80805,80808,80822,80825,80827,80833,80842,80877,80880,80907,80912,80932,80944,80958,80961,80965,80975,81124,81141,81145,81157,81266,81271,81275,81294,81435,81441,81445,81448,81838,81841,81892,81896,81902,82104,82116,82120,82123,82212,82214,82217,82273,82275,82297,82301,82303,82324],[10,80806,80802],{"id":80807},"best-free-ai-apis-for-beginners-a-python-quickstart",[14,80809,80810,80811,608,80814,80817,80818,80821],{},"This guide shows you how to connect to three genuinely free AI APIs from Python in under ten minutes, with one connector you can reuse everywhere. You will wire up ",[35,80812,80813],{},"Groq",[35,80815,80816],{},"OpenRouter",", and the ",[35,80819,80820],{},"Hugging Face Inference API"," — all of which give you working keys with no credit card — and end with a single function that talks to any of them.",[14,80823,80824],{},"\"API\" just means a way for your code to send a request to a service and get an answer back. An \"AI API\" is that, where the service on the other end is a large language model (the kind of model that powers chat assistants). A \"free tier\" is an allowance you can keep using within set limits, as opposed to a trial that expires.",[57,80826,238],{"id":237},[14,80828,80829,80830,80832],{},"You only need a working Python setup and three free accounts. If Python or virtual environments are new to you, start with ",[51,80831,2482],{"href":2481},", then come back.",[14,80834,80835,80836,80838,80839,80841],{},"This guide uses Python 3.10 or newer, the official ",[18,80837,20],{}," SDK (which works against any OpenAI-compatible endpoint, not just OpenAI), and ",[18,80840,5450],{}," for the one provider that speaks a different format.",[253,80843,80845],{"className":255,"code":80844,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate  # Windows: .venv\\Scripts\\activate\npip install openai httpx python-dotenv\n",[18,80846,80847,80857,80865],{"__ignoreMap":258},[262,80848,80849,80851,80853,80855],{"class":181,"line":264},[262,80850,416],{"class":267},[262,80852,272],{"class":271},[262,80854,276],{"class":275},[262,80856,279],{"class":275},[262,80858,80859,80861,80863],{"class":181,"line":282},[262,80860,285],{"class":271},[262,80862,288],{"class":275},[262,80864,26589],{"class":291},[262,80866,80867,80869,80871,80873,80875],{"class":181,"line":295},[262,80868,298],{"class":267},[262,80870,301],{"class":275},[262,80872,2519],{"class":275},[262,80874,5440],{"class":275},[262,80876,2522],{"class":275},[14,80878,80879],{},"Now grab one free key from each provider. None of these ask for a card:",[2322,80881,80882,80890,80898],{},[1450,80883,80884,80886,80887,1363],{},[35,80885,80813],{}," — sign up at console.groq.com and create an API key. Copy it as ",[18,80888,80889],{},"GROQ_API_KEY",[1450,80891,80892,80894,80895,1363],{},[35,80893,80816],{}," — sign up at openrouter.ai, open Keys, and create a key. Copy it as ",[18,80896,80897],{},"OPENROUTER_API_KEY",[1450,80899,80900,80903,80904,1363],{},[35,80901,80902],{},"Hugging Face"," — sign up at huggingface.co, open Settings then Access Tokens, and create a read token. Copy it as ",[18,80905,80906],{},"HUGGINGFACE_API_KEY",[14,80908,2525,80909,80911],{},[18,80910,319],{}," in your project root and paste them in:",[253,80913,80915],{"className":323,"code":80914,"language":325,"meta":258,"style":258},"GROQ_API_KEY=gsk_your_groq_key_here\nOPENROUTER_API_KEY=sk-or-your_openrouter_key_here\nHUGGINGFACE_API_KEY=hf_your_token_here\n",[18,80916,80917,80922,80927],{"__ignoreMap":258},[262,80918,80919],{"class":181,"line":264},[262,80920,80921],{},"GROQ_API_KEY=gsk_your_groq_key_here\n",[262,80923,80924],{"class":181,"line":282},[262,80925,80926],{},"OPENROUTER_API_KEY=sk-or-your_openrouter_key_here\n",[262,80928,80929],{"class":181,"line":295},[262,80930,80931],{},"HUGGINGFACE_API_KEY=hf_your_token_here\n",[14,80933,80934,80940,80941,80943],{},[35,80935,353,80936,356,80938,360],{},[18,80937,319],{},[18,80939,359],{}," so you never commit your keys. One line in ",[18,80942,359],{}," does it:",[253,80945,80946],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,80947,80948],{"__ignoreMap":258},[262,80949,80950,80952,80954,80956],{"class":181,"line":264},[262,80951,371],{"class":271},[262,80953,374],{"class":275},[262,80955,378],{"class":377},[262,80957,381],{"class":275},[14,80959,80960],{},"A leaked key can be used by strangers and burn through your limits, so this single step matters more than anything else in the guide.",[57,80962,80964],{"id":80963},"step-1-connect-to-groq-with-the-openai-sdk","Step 1: Connect to Groq with the openai SDK",[14,80966,80967,80968,80970,80971,80974],{},"Groq runs open models on hardware tuned for speed, and its API copies OpenAI's format exactly. That means you can use the official ",[18,80969,20],{}," SDK and only change one setting — the ",[18,80972,80973],{},"base_url"," — to point it at Groq instead.",[253,80976,80978],{"className":414,"code":80977,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\n\ngroq = OpenAI(\n    base_url=\"https:\u002F\u002Fapi.groq.com\u002Fopenai\u002Fv1\",\n    api_key=os.environ[\"GROQ_API_KEY\"],\n)\n\nresponse = groq.chat.completions.create(\n    model=\"llama-3.1-8b-instant\",\n    messages=[{\"role\": \"user\", \"content\": \"Explain an API in one sentence.\"}],\n)\n\nprint(response.choices[0].message.content)\n",[18,80979,80980,80986,80996,81006,81010,81014,81018,81028,81039,81053,81057,81061,81070,81081,81106,81110,81114],{"__ignoreMap":258},[262,80981,80982,80984],{"class":181,"line":264},[262,80983,684],{"class":377},[262,80985,687],{"class":429},[262,80987,80988,80990,80992,80994],{"class":181,"line":282},[262,80989,705],{"class":377},[262,80991,708],{"class":429},[262,80993,684],{"class":377},[262,80995,713],{"class":429},[262,80997,80998,81000,81002,81004],{"class":181,"line":295},[262,80999,705],{"class":377},[262,81001,720],{"class":429},[262,81003,684],{"class":377},[262,81005,725],{"class":429},[262,81007,81008],{"class":181,"line":345},[262,81009,583],{"emptyLinePlaceholder":582},[262,81011,81012],{"class":181,"line":492},[262,81013,734],{"class":429},[262,81015,81016],{"class":181,"line":503},[262,81017,583],{"emptyLinePlaceholder":582},[262,81019,81020,81023,81025],{"class":181,"line":521},[262,81021,81022],{"class":429},"groq ",[262,81024,476],{"class":377},[262,81026,81027],{"class":429}," OpenAI(\n",[262,81029,81030,81032,81034,81037],{"class":181,"line":537},[262,81031,37458],{"class":611},[262,81033,476],{"class":377},[262,81035,81036],{"class":275},"\"https:\u002F\u002Fapi.groq.com\u002Fopenai\u002Fv1\"",[262,81038,1315],{"class":429},[262,81040,81041,81044,81046,81048,81051],{"class":181,"line":549},[262,81042,81043],{"class":611},"    api_key",[262,81045,476],{"class":377},[262,81047,26942],{"class":429},[262,81049,81050],{"class":275},"\"GROQ_API_KEY\"",[262,81052,10309],{"class":429},[262,81054,81055],{"class":181,"line":570},[262,81056,660],{"class":429},[262,81058,81059],{"class":181,"line":579},[262,81060,583],{"emptyLinePlaceholder":582},[262,81062,81063,81065,81067],{"class":181,"line":586},[262,81064,48362],{"class":429},[262,81066,476],{"class":377},[262,81068,81069],{"class":429}," groq.chat.completions.create(\n",[262,81071,81072,81074,81076,81079],{"class":181,"line":591},[262,81073,48371],{"class":611},[262,81075,476],{"class":377},[262,81077,81078],{"class":275},"\"llama-3.1-8b-instant\"",[262,81080,1315],{"class":429},[262,81082,81083,81085,81087,81089,81091,81093,81095,81097,81099,81101,81104],{"class":181,"line":623},[262,81084,48388],{"class":611},[262,81086,476],{"class":377},[262,81088,8856],{"class":429},[262,81090,1228],{"class":275},[262,81092,1231],{"class":429},[262,81094,1291],{"class":275},[262,81096,608],{"class":429},[262,81098,1239],{"class":275},[262,81100,1231],{"class":429},[262,81102,81103],{"class":275},"\"Explain an API in one sentence.\"",[262,81105,54808],{"class":429},[262,81107,81108],{"class":181,"line":634},[262,81109,660],{"class":429},[262,81111,81112],{"class":181,"line":845},[262,81113,583],{"emptyLinePlaceholder":582},[262,81115,81116,81118,81120,81122],{"class":181,"line":850},[262,81117,637],{"class":271},[262,81119,48465],{"class":429},[262,81121,102],{"class":271},[262,81123,6048],{"class":429},[14,81125,3349,81126,81128,81129,6092,81131,81133,81134,81136,81137,81140],{},[18,81127,43269],{}," list is the conversation so far. The ",[18,81130,43003],{},[18,81132,1291],{}," marks your input; the model replies in the ",[18,81135,7913],{}," array, and ",[18,81138,81139],{},"choices[0].message.content"," is the text you want. Run the file and you should see a one-sentence answer in well under a second.",[57,81142,81144],{"id":81143},"step-2-connect-to-openrouter-the-same-way","Step 2: Connect to OpenRouter the same way",[14,81146,81147,81148,81150,81151,81153,81154,1363],{},"OpenRouter is a single doorway to dozens of models — some paid, several free. Because it is also OpenAI-compatible, the only things that change from Step 1 are the ",[18,81149,80973],{},", the key, and the ",[18,81152,805],{}," name. Free models on OpenRouter end in ",[18,81155,81156],{},":free",[253,81158,81160],{"className":414,"code":81159,"language":416,"meta":258,"style":258},"openrouter = OpenAI(\n    base_url=\"https:\u002F\u002Fopenrouter.ai\u002Fapi\u002Fv1\",\n    api_key=os.environ[\"OPENROUTER_API_KEY\"],\n)\n\nresponse = openrouter.chat.completions.create(\n    model=\"meta-llama\u002Fllama-3.1-8b-instruct:free\",\n    messages=[{\"role\": \"user\", \"content\": \"Name three free AI APIs.\"}],\n)\n\nprint(response.choices[0].message.content)\n",[18,81161,81162,81171,81182,81195,81199,81203,81212,81223,81248,81252,81256],{"__ignoreMap":258},[262,81163,81164,81167,81169],{"class":181,"line":264},[262,81165,81166],{"class":429},"openrouter ",[262,81168,476],{"class":377},[262,81170,81027],{"class":429},[262,81172,81173,81175,81177,81180],{"class":181,"line":282},[262,81174,37458],{"class":611},[262,81176,476],{"class":377},[262,81178,81179],{"class":275},"\"https:\u002F\u002Fopenrouter.ai\u002Fapi\u002Fv1\"",[262,81181,1315],{"class":429},[262,81183,81184,81186,81188,81190,81193],{"class":181,"line":295},[262,81185,81043],{"class":611},[262,81187,476],{"class":377},[262,81189,26942],{"class":429},[262,81191,81192],{"class":275},"\"OPENROUTER_API_KEY\"",[262,81194,10309],{"class":429},[262,81196,81197],{"class":181,"line":345},[262,81198,660],{"class":429},[262,81200,81201],{"class":181,"line":492},[262,81202,583],{"emptyLinePlaceholder":582},[262,81204,81205,81207,81209],{"class":181,"line":503},[262,81206,48362],{"class":429},[262,81208,476],{"class":377},[262,81210,81211],{"class":429}," openrouter.chat.completions.create(\n",[262,81213,81214,81216,81218,81221],{"class":181,"line":521},[262,81215,48371],{"class":611},[262,81217,476],{"class":377},[262,81219,81220],{"class":275},"\"meta-llama\u002Fllama-3.1-8b-instruct:free\"",[262,81222,1315],{"class":429},[262,81224,81225,81227,81229,81231,81233,81235,81237,81239,81241,81243,81246],{"class":181,"line":537},[262,81226,48388],{"class":611},[262,81228,476],{"class":377},[262,81230,8856],{"class":429},[262,81232,1228],{"class":275},[262,81234,1231],{"class":429},[262,81236,1291],{"class":275},[262,81238,608],{"class":429},[262,81240,1239],{"class":275},[262,81242,1231],{"class":429},[262,81244,81245],{"class":275},"\"Name three free AI APIs.\"",[262,81247,54808],{"class":429},[262,81249,81250],{"class":181,"line":549},[262,81251,660],{"class":429},[262,81253,81254],{"class":181,"line":570},[262,81255,583],{"emptyLinePlaceholder":582},[262,81257,81258,81260,81262,81264],{"class":181,"line":579},[262,81259,637],{"class":271},[262,81261,48465],{"class":429},[262,81263,102],{"class":271},[262,81265,6048],{"class":429},[14,81267,81268,81269,1363],{},"This is the payoff of OpenAI-compatible APIs: once you know one, you know all of them. If you want a side-by-side on which of these two to reach for, see ",[51,81270,69908],{"href":69907},[57,81272,81274],{"id":81273},"step-3-connect-to-hugging-face-inference-with-httpx","Step 3: Connect to Hugging Face Inference with httpx",[14,81276,81277,81278,81281,81282,81284,81285,81287,81288,81290,81291,81293],{},"Hugging Face is the odd one out. Its general Inference API takes a plain ",[18,81279,81280],{},"inputs"," string instead of a ",[18,81283,43269],{}," array, and it returns a list of results instead of a ",[18,81286,7913],{}," object. Because it is not OpenAI-compatible, you call it directly with ",[18,81289,5450],{}," (a modern HTTP library) rather than the ",[18,81292,20],{}," SDK.",[253,81295,81297],{"className":414,"code":81296,"language":416,"meta":258,"style":258},"import httpx\n\nHF_URL = \"https:\u002F\u002Fapi-inference.huggingface.co\u002Fmodels\u002Fmistralai\u002FMistral-7B-Instruct-v0.2\"\n\nheaders = {\"Authorization\": f\"Bearer {os.environ['HUGGINGFACE_API_KEY']}\"}\npayload = {\"inputs\": \"Explain an API in one sentence.\"}\n\nresponse = httpx.post(HF_URL, headers=headers, json=payload, timeout=60)\nresponse.raise_for_status()\n\nprint(response.json()[0][\"generated_text\"])\n",[18,81298,81299,81305,81309,81319,81323,81355,81373,81377,81410,81415,81419],{"__ignoreMap":258},[262,81300,81301,81303],{"class":181,"line":264},[262,81302,684],{"class":377},[262,81304,6526],{"class":429},[262,81306,81307],{"class":181,"line":282},[262,81308,583],{"emptyLinePlaceholder":582},[262,81310,81311,81314,81316],{"class":181,"line":295},[262,81312,81313],{"class":271},"HF_URL",[262,81315,442],{"class":377},[262,81317,81318],{"class":275}," \"https:\u002F\u002Fapi-inference.huggingface.co\u002Fmodels\u002Fmistralai\u002FMistral-7B-Instruct-v0.2\"\n",[262,81320,81321],{"class":181,"line":345},[262,81322,583],{"emptyLinePlaceholder":582},[262,81324,81325,81328,81330,81332,81334,81336,81338,81340,81342,81344,81347,81349,81351,81353],{"class":181,"line":492},[262,81326,81327],{"class":429},"headers ",[262,81329,476],{"class":377},[262,81331,2276],{"class":429},[262,81333,16998],{"class":275},[262,81335,1231],{"class":429},[262,81337,642],{"class":377},[262,81339,6605],{"class":275},[262,81341,3039],{"class":271},[262,81343,26942],{"class":429},[262,81345,81346],{"class":275},"'HUGGINGFACE_API_KEY'",[262,81348,6223],{"class":429},[262,81350,654],{"class":271},[262,81352,1176],{"class":275},[262,81354,16430],{"class":429},[262,81356,81357,81360,81362,81364,81367,81369,81371],{"class":181,"line":503},[262,81358,81359],{"class":429},"payload ",[262,81361,476],{"class":377},[262,81363,2276],{"class":429},[262,81365,81366],{"class":275},"\"inputs\"",[262,81368,1231],{"class":429},[262,81370,81103],{"class":275},[262,81372,16430],{"class":429},[262,81374,81375],{"class":181,"line":521},[262,81376,583],{"emptyLinePlaceholder":582},[262,81378,81379,81381,81383,81386,81388,81390,81392,81394,81396,81398,81400,81402,81404,81406,81408],{"class":181,"line":537},[262,81380,48362],{"class":429},[262,81382,476],{"class":377},[262,81384,81385],{"class":429}," httpx.post(",[262,81387,81313],{"class":271},[262,81389,608],{"class":429},[262,81391,17057],{"class":611},[262,81393,476],{"class":377},[262,81395,19503],{"class":429},[262,81397,17049],{"class":611},[262,81399,476],{"class":377},[262,81401,17054],{"class":429},[262,81403,1591],{"class":611},[262,81405,476],{"class":377},[262,81407,12826],{"class":271},[262,81409,660],{"class":429},[262,81411,81412],{"class":181,"line":549},[262,81413,81414],{"class":429},"response.raise_for_status()\n",[262,81416,81417],{"class":181,"line":570},[262,81418,583],{"emptyLinePlaceholder":582},[262,81420,81421,81423,81426,81428,81430,81433],{"class":181,"line":579},[262,81422,637],{"class":271},[262,81424,81425],{"class":429},"(response.json()[",[262,81427,102],{"class":271},[262,81429,6163],{"class":429},[262,81431,81432],{"class":275},"\"generated_text\"",[262,81434,3512],{"class":429},[14,81436,81437,81438,81440],{},"The model name lives in the URL here, not in the payload. The first call to a model can be slow because Hugging Face may need to load it onto a server, which is why the ",[18,81439,1591],{}," is set generously to 60 seconds.",[57,81442,81444],{"id":81443},"step-4-wrap-all-three-in-one-unified-connector","Step 4: Wrap all three in one unified connector",[14,81446,81447],{},"You now have three working calls that look slightly different. The point of a connector is to hide those differences behind one function, so the rest of your program can say \"ask provider X this prompt\" and not care how each API is shaped.",[253,81449,81451],{"className":414,"code":81450,"language":416,"meta":258,"style":258},"import os\nimport httpx\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\n\nCLIENTS = {\n    \"groq\": OpenAI(\n        base_url=\"https:\u002F\u002Fapi.groq.com\u002Fopenai\u002Fv1\",\n        api_key=os.environ[\"GROQ_API_KEY\"],\n    ),\n    \"openrouter\": OpenAI(\n        base_url=\"https:\u002F\u002Fopenrouter.ai\u002Fapi\u002Fv1\",\n        api_key=os.environ[\"OPENROUTER_API_KEY\"],\n    ),\n}\n\nMODELS = {\n    \"groq\": \"llama-3.1-8b-instant\",\n    \"openrouter\": \"meta-llama\u002Fllama-3.1-8b-instruct:free\",\n}\n\nHF_URL = \"https:\u002F\u002Fapi-inference.huggingface.co\u002Fmodels\u002Fmistralai\u002FMistral-7B-Instruct-v0.2\"\n\n\ndef ask(provider: str, prompt: str) -> str:\n    \"\"\"Send one prompt to any free provider and return clean text.\"\"\"\n    if provider == \"huggingface\":\n        headers = {\"Authorization\": f\"Bearer {os.environ['HUGGINGFACE_API_KEY']}\"}\n        response = httpx.post(\n            HF_URL, headers=headers, json={\"inputs\": prompt}, timeout=60\n        )\n        response.raise_for_status()\n        return response.json()[0][\"generated_text\"]\n\n    # OpenAI-compatible providers: Groq and OpenRouter\n    completion = CLIENTS[provider].chat.completions.create(\n        model=MODELS[provider],\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return completion.choices[0].message.content\n",[18,81452,81453,81459,81465,81475,81485,81489,81493,81497,81506,81514,81525,81538,81543,81550,81560,81572,81576,81580,81584,81593,81603,81613,81617,81621,81629,81633,81637,81659,81664,81678,81709,81717,81748,81752,81756,81770,81774,81779,81792,81803,81823,81827],{"__ignoreMap":258},[262,81454,81455,81457],{"class":181,"line":264},[262,81456,684],{"class":377},[262,81458,687],{"class":429},[262,81460,81461,81463],{"class":181,"line":282},[262,81462,684],{"class":377},[262,81464,6526],{"class":429},[262,81466,81467,81469,81471,81473],{"class":181,"line":295},[262,81468,705],{"class":377},[262,81470,708],{"class":429},[262,81472,684],{"class":377},[262,81474,713],{"class":429},[262,81476,81477,81479,81481,81483],{"class":181,"line":345},[262,81478,705],{"class":377},[262,81480,720],{"class":429},[262,81482,684],{"class":377},[262,81484,725],{"class":429},[262,81486,81487],{"class":181,"line":492},[262,81488,583],{"emptyLinePlaceholder":582},[262,81490,81491],{"class":181,"line":503},[262,81492,734],{"class":429},[262,81494,81495],{"class":181,"line":521},[262,81496,583],{"emptyLinePlaceholder":582},[262,81498,81499,81502,81504],{"class":181,"line":537},[262,81500,81501],{"class":271},"CLIENTS",[262,81503,442],{"class":377},[262,81505,20437],{"class":429},[262,81507,81508,81511],{"class":181,"line":549},[262,81509,81510],{"class":275},"    \"groq\"",[262,81512,81513],{"class":429},": OpenAI(\n",[262,81515,81516,81519,81521,81523],{"class":181,"line":570},[262,81517,81518],{"class":611},"        base_url",[262,81520,476],{"class":377},[262,81522,81036],{"class":275},[262,81524,1315],{"class":429},[262,81526,81527,81530,81532,81534,81536],{"class":181,"line":579},[262,81528,81529],{"class":611},"        api_key",[262,81531,476],{"class":377},[262,81533,26942],{"class":429},[262,81535,81050],{"class":275},[262,81537,10309],{"class":429},[262,81539,81540],{"class":181,"line":586},[262,81541,81542],{"class":429},"    ),\n",[262,81544,81545,81548],{"class":181,"line":591},[262,81546,81547],{"class":275},"    \"openrouter\"",[262,81549,81513],{"class":429},[262,81551,81552,81554,81556,81558],{"class":181,"line":623},[262,81553,81518],{"class":611},[262,81555,476],{"class":377},[262,81557,81179],{"class":275},[262,81559,1315],{"class":429},[262,81561,81562,81564,81566,81568,81570],{"class":181,"line":634},[262,81563,81529],{"class":611},[262,81565,476],{"class":377},[262,81567,26942],{"class":429},[262,81569,81192],{"class":275},[262,81571,10309],{"class":429},[262,81573,81574],{"class":181,"line":845},[262,81575,81542],{"class":429},[262,81577,81578],{"class":181,"line":850},[262,81579,16430],{"class":429},[262,81581,81582],{"class":181,"line":864},[262,81583,583],{"emptyLinePlaceholder":582},[262,81585,81586,81589,81591],{"class":181,"line":1683},[262,81587,81588],{"class":271},"MODELS",[262,81590,442],{"class":377},[262,81592,20437],{"class":429},[262,81594,81595,81597,81599,81601],{"class":181,"line":1688},[262,81596,81510],{"class":275},[262,81598,1231],{"class":429},[262,81600,81078],{"class":275},[262,81602,1315],{"class":429},[262,81604,81605,81607,81609,81611],{"class":181,"line":1693},[262,81606,81547],{"class":275},[262,81608,1231],{"class":429},[262,81610,81220],{"class":275},[262,81612,1315],{"class":429},[262,81614,81615],{"class":181,"line":1728},[262,81616,16430],{"class":429},[262,81618,81619],{"class":181,"line":1737},[262,81620,583],{"emptyLinePlaceholder":582},[262,81622,81623,81625,81627],{"class":181,"line":1751},[262,81624,81313],{"class":271},[262,81626,442],{"class":377},[262,81628,81318],{"class":275},[262,81630,81631],{"class":181,"line":1764},[262,81632,583],{"emptyLinePlaceholder":582},[262,81634,81635],{"class":181,"line":1779},[262,81636,583],{"emptyLinePlaceholder":582},[262,81638,81639,81641,81643,81646,81648,81651,81653,81655,81657],{"class":181,"line":1793},[262,81640,423],{"class":377},[262,81642,44066],{"class":267},[262,81644,81645],{"class":429},"(provider: ",[262,81647,433],{"class":271},[262,81649,81650],{"class":429},", prompt: ",[262,81652,433],{"class":271},[262,81654,1939],{"class":429},[262,81656,433],{"class":271},[262,81658,1160],{"class":429},[262,81660,81661],{"class":181,"line":1800},[262,81662,81663],{"class":275},"    \"\"\"Send one prompt to any free provider and return clean text.\"\"\"\n",[262,81665,81666,81668,81671,81673,81676],{"class":181,"line":1805},[262,81667,3454],{"class":377},[262,81669,81670],{"class":429}," provider ",[262,81672,10758],{"class":377},[262,81674,81675],{"class":275}," \"huggingface\"",[262,81677,1160],{"class":429},[262,81679,81680,81683,81685,81687,81689,81691,81693,81695,81697,81699,81701,81703,81705,81707],{"class":181,"line":1810},[262,81681,81682],{"class":429},"        headers ",[262,81684,476],{"class":377},[262,81686,2276],{"class":429},[262,81688,16998],{"class":275},[262,81690,1231],{"class":429},[262,81692,642],{"class":377},[262,81694,6605],{"class":275},[262,81696,3039],{"class":271},[262,81698,26942],{"class":429},[262,81700,81346],{"class":275},[262,81702,6223],{"class":429},[262,81704,654],{"class":271},[262,81706,1176],{"class":275},[262,81708,16430],{"class":429},[262,81710,81711,81713,81715],{"class":181,"line":1823},[262,81712,21490],{"class":429},[262,81714,476],{"class":377},[262,81716,6576],{"class":429},[262,81718,81719,81722,81724,81726,81728,81730,81732,81734,81736,81738,81741,81743,81745],{"class":181,"line":1846},[262,81720,81721],{"class":271},"            HF_URL",[262,81723,608],{"class":429},[262,81725,17057],{"class":611},[262,81727,476],{"class":377},[262,81729,19503],{"class":429},[262,81731,17049],{"class":611},[262,81733,476],{"class":377},[262,81735,3039],{"class":429},[262,81737,81366],{"class":275},[262,81739,81740],{"class":429},": prompt}, ",[262,81742,1591],{"class":611},[262,81744,476],{"class":377},[262,81746,81747],{"class":271},"60\n",[262,81749,81750],{"class":181,"line":1861},[262,81751,6288],{"class":429},[262,81753,81754],{"class":181,"line":1866},[262,81755,21512],{"class":429},[262,81757,81758,81760,81762,81764,81766,81768],{"class":181,"line":1871},[262,81759,8066],{"class":377},[262,81761,30772],{"class":429},[262,81763,102],{"class":271},[262,81765,6163],{"class":429},[262,81767,81432],{"class":275},[262,81769,957],{"class":429},[262,81771,81772],{"class":181,"line":1890},[262,81773,583],{"emptyLinePlaceholder":582},[262,81775,81776],{"class":181,"line":1909},[262,81777,81778],{"class":291},"    # OpenAI-compatible providers: Groq and OpenRouter\n",[262,81780,81781,81784,81786,81789],{"class":181,"line":1914},[262,81782,81783],{"class":429},"    completion ",[262,81785,476],{"class":377},[262,81787,81788],{"class":271}," CLIENTS",[262,81790,81791],{"class":429},"[provider].chat.completions.create(\n",[262,81793,81794,81796,81798,81800],{"class":181,"line":1919},[262,81795,1194],{"class":611},[262,81797,476],{"class":377},[262,81799,81588],{"class":271},[262,81801,81802],{"class":429},"[provider],\n",[262,81804,81805,81807,81809,81811,81813,81815,81817,81819,81821],{"class":181,"line":1946},[262,81806,1215],{"class":611},[262,81808,476],{"class":377},[262,81810,8856],{"class":429},[262,81812,1228],{"class":275},[262,81814,1231],{"class":429},[262,81816,1291],{"class":275},[262,81818,608],{"class":429},[262,81820,1239],{"class":275},[262,81822,18141],{"class":429},[262,81824,81825],{"class":181,"line":1959},[262,81826,1011],{"class":429},[262,81828,81829,81831,81834,81836],{"class":181,"line":1996},[262,81830,573],{"class":377},[262,81832,81833],{"class":429}," completion.choices[",[262,81835,102],{"class":271},[262,81837,1331],{"class":429},[14,81839,81840],{},"Now any of these three lines works, and switching providers is a one-word change:",[253,81842,81844],{"className":414,"code":81843,"language":416,"meta":258,"style":258},"print(ask(\"groq\", \"Give me a fun fact about octopuses.\"))\nprint(ask(\"openrouter\", \"Give me a fun fact about octopuses.\"))\nprint(ask(\"huggingface\", \"Give me a fun fact about octopuses.\"))\n",[18,81845,81846,81862,81877],{"__ignoreMap":258},[262,81847,81848,81850,81852,81855,81857,81860],{"class":181,"line":264},[262,81849,637],{"class":271},[262,81851,48737],{"class":429},[262,81853,81854],{"class":275},"\"groq\"",[262,81856,608],{"class":429},[262,81858,81859],{"class":275},"\"Give me a fun fact about octopuses.\"",[262,81861,2684],{"class":429},[262,81863,81864,81866,81868,81871,81873,81875],{"class":181,"line":282},[262,81865,637],{"class":271},[262,81867,48737],{"class":429},[262,81869,81870],{"class":275},"\"openrouter\"",[262,81872,608],{"class":429},[262,81874,81859],{"class":275},[262,81876,2684],{"class":429},[262,81878,81879,81881,81883,81886,81888,81890],{"class":181,"line":295},[262,81880,637],{"class":271},[262,81882,48737],{"class":429},[262,81884,81885],{"class":275},"\"huggingface\"",[262,81887,608],{"class":429},[262,81889,81859],{"class":275},[262,81891,2684],{"class":429},[57,81893,81895],{"id":81894},"step-5-add-safe-retries-for-rate-limits","Step 5: Add safe retries for rate limits",[14,81897,81898,81899,81901],{},"Free tiers cap how many requests you can send per minute. When you go over, the API replies with a ",[18,81900,59190],{}," status, which means \"too many requests, slow down.\" The fix is to wait and try again, doubling the wait each time — a pattern called exponential backoff. This keeps short bursts from crashing your script.",[253,81903,81905],{"className":414,"code":81904,"language":416,"meta":258,"style":258},"import time\nfrom openai import RateLimitError\n\n\ndef safe_ask(provider: str, prompt: str, retries: int = 3) -> str:\n    for attempt in range(retries):\n        try:\n            return ask(provider, prompt)\n        except (RateLimitError, httpx.HTTPStatusError) as error:\n            status = getattr(getattr(error, \"response\", None), \"status_code\", None)\n            if status == 429 and attempt \u003C retries - 1:\n                time.sleep(2 ** attempt)  # wait 1s, then 2s, then 4s\n                continue\n            raise\n    raise RuntimeError(f\"Failed after {retries} retries\")\n",[18,81906,81907,81913,81924,81928,81932,81961,81973,81979,81986,81997,82031,82055,82070,82074,82079],{"__ignoreMap":258},[262,81908,81909,81911],{"class":181,"line":264},[262,81910,684],{"class":377},[262,81912,2612],{"class":429},[262,81914,81915,81917,81919,81921],{"class":181,"line":282},[262,81916,705],{"class":377},[262,81918,720],{"class":429},[262,81920,684],{"class":377},[262,81922,81923],{"class":429}," RateLimitError\n",[262,81925,81926],{"class":181,"line":295},[262,81927,583],{"emptyLinePlaceholder":582},[262,81929,81930],{"class":181,"line":345},[262,81931,583],{"emptyLinePlaceholder":582},[262,81933,81934,81936,81939,81941,81943,81945,81947,81949,81951,81953,81955,81957,81959],{"class":181,"line":492},[262,81935,423],{"class":377},[262,81937,81938],{"class":267}," safe_ask",[262,81940,81645],{"class":429},[262,81942,433],{"class":271},[262,81944,81650],{"class":429},[262,81946,433],{"class":271},[262,81948,39233],{"class":429},[262,81950,439],{"class":271},[262,81952,442],{"class":377},[262,81954,931],{"class":271},[262,81956,1939],{"class":429},[262,81958,433],{"class":271},[262,81960,1160],{"class":429},[262,81962,81963,81965,81967,81969,81971],{"class":181,"line":503},[262,81964,3074],{"class":377},[262,81966,3077],{"class":429},[262,81968,835],{"class":377},[262,81970,3082],{"class":271},[262,81972,39302],{"class":429},[262,81974,81975,81977],{"class":181,"line":521},[262,81976,3090],{"class":377},[262,81978,1160],{"class":429},[262,81980,81981,81983],{"class":181,"line":537},[262,81982,3198],{"class":377},[262,81984,81985],{"class":429}," ask(provider, prompt)\n",[262,81987,81988,81990,81993,81995],{"class":181,"line":549},[262,81989,3214],{"class":377},[262,81991,81992],{"class":429}," (RateLimitError, httpx.HTTPStatusError) ",[262,81994,697],{"class":377},[262,81996,14529],{"class":429},[262,81998,81999,82001,82003,82006,82008,82011,82014,82017,82019,82021,82023,82025,82027,82029],{"class":181,"line":570},[262,82000,70546],{"class":429},[262,82002,476],{"class":377},[262,82004,82005],{"class":271}," getattr",[262,82007,602],{"class":429},[262,82009,82010],{"class":271},"getattr",[262,82012,82013],{"class":429},"(error, ",[262,82015,82016],{"class":275},"\"response\"",[262,82018,608],{"class":429},[262,82020,8471],{"class":271},[262,82022,11709],{"class":429},[262,82024,23668],{"class":275},[262,82026,608],{"class":429},[262,82028,8471],{"class":271},[262,82030,660],{"class":429},[262,82032,82033,82035,82037,82039,82041,82043,82045,82047,82049,82051,82053],{"class":181,"line":579},[262,82034,10200],{"class":377},[262,82036,70558],{"class":429},[262,82038,10758],{"class":377},[262,82040,42584],{"class":271},[262,82042,33508],{"class":377},[262,82044,3077],{"class":429},[262,82046,512],{"class":377},[262,82048,39432],{"class":429},[262,82050,561],{"class":377},[262,82052,3243],{"class":271},[262,82054,1160],{"class":429},[262,82056,82057,82060,82062,82064,82067],{"class":181,"line":586},[262,82058,82059],{"class":429},"                time.sleep(",[262,82061,109],{"class":271},[262,82063,3235],{"class":377},[262,82065,82066],{"class":429}," attempt)  ",[262,82068,82069],{"class":291},"# wait 1s, then 2s, then 4s\n",[262,82071,82072],{"class":181,"line":591},[262,82073,10235],{"class":377},[262,82075,82076],{"class":181,"line":623},[262,82077,82078],{"class":377},"            raise\n",[262,82080,82081,82083,82085,82087,82089,82092,82094,82097,82099,82102],{"class":181,"line":634},[262,82082,2829],{"class":377},[262,82084,3318],{"class":271},[262,82086,602],{"class":429},[262,82088,642],{"class":377},[262,82090,82091],{"class":275},"\"Failed after ",[262,82093,3039],{"class":271},[262,82095,82096],{"class":429},"retries",[262,82098,654],{"class":271},[262,82100,82101],{"class":275}," retries\"",[262,82103,660],{"class":429},[14,82105,18789,82106,82109,82110,82113,82114,1363],{},[18,82107,82108],{},"safe_ask"," exactly like ",[18,82111,82112],{},"ask",", and it will quietly recover from the occasional rate-limit bump. For a deeper look at this error, read ",[51,82115,3379],{"href":3378},[57,82117,82119],{"id":82118},"free-api-quick-reference","Free API quick reference",[14,82121,82122],{},"The three providers differ in how you call them and what they are best at. Keep this table next to you while you experiment.",[1379,82124,82125,82144],{},[1382,82126,82127],{},[1385,82128,82129,82132,82135,82138,82141],{},[1388,82130,82131],{},"Provider",[1388,82133,82134],{},"Library to use",[1388,82136,82137],{},"Request shape",[1388,82139,82140],{},"Reads result from",[1388,82142,82143],{},"Best for",[1398,82145,82146,82170,82191],{},[1385,82147,82148,82150,82158,82163,82167],{},[1403,82149,80813],{},[1403,82151,82152,82154,82155,82157],{},[18,82153,20],{}," SDK (",[18,82156,80973],{}," set)",[1403,82159,82160,82162],{},[18,82161,43269],{}," array",[1403,82164,82165],{},[18,82166,81139],{},[1403,82168,82169],{},"Fastest responses, easiest start",[1385,82171,82172,82174,82180,82184,82188],{},[1403,82173,80816],{},[1403,82175,82176,82154,82178,82157],{},[18,82177,20],{},[18,82179,80973],{},[1403,82181,82182,82162],{},[18,82183,43269],{},[1403,82185,82186],{},[18,82187,81139],{},[1403,82189,82190],{},"Trying many models from one key",[1385,82192,82193,82195,82199,82204,82209],{},[1403,82194,80902],{},[1403,82196,82197],{},[18,82198,5450],{},[1403,82200,82201,82203],{},[18,82202,81280],{}," string",[1403,82205,82206],{},[18,82207,82208],{},"json()[0][\"generated_text\"]",[1403,82210,82211],{},"Open models for non-chat tasks",[57,82213,1445],{"id":1444},[14,82215,82216],{},"A few errors trip up almost everyone on their first run. Here is what each one means and how to clear it.",[1447,82218,82219,82236,82248,82263],{},[1450,82220,82221,82226,82227,82229,82230,82232,82233,82235],{},[35,82222,82223],{},[18,82224,82225],{},"KeyError: 'GROQ_API_KEY'"," — Python could not find that key. Cause: your ",[18,82228,319],{}," file is missing the line, or ",[18,82231,8439],{}," never ran. Fix: confirm the variable name matches exactly and that ",[18,82234,8439],{}," is called before you read the key.",[1450,82237,82238,82242,82243,82245,82246,1363],{},[35,82239,82240],{},[18,82241,19656],{}," — the provider rejected your key. Cause: a typo, a trailing space, or a key copied for the wrong provider. Fix: regenerate the key, paste it freshly into ",[18,82244,319],{},", and check you are sending it to the matching service. See ",[51,82247,388],{"href":387},[1450,82249,82250,82259,82260,82262],{},[35,82251,82252,407,82255,82258],{},[18,82253,82254],{},"KeyError: 0",[18,82256,82257],{},"TypeError"," on the Hugging Face response"," — the model returned an object, not the usual list. Cause: the model is still loading, or it returned an error message instead of text. Fix: print ",[18,82261,31986],{}," to see the raw reply; if it says the model is loading, wait a few seconds and retry.",[1450,82264,82265,82269,82270,82272],{},[35,82266,82267],{},[18,82268,19671],{}," — you hit the free rate limit. Cause: too many calls in a short window. Fix: use the ",[18,82271,82108],{}," retry wrapper from Step 5 and space your calls out.",[57,82274,2317],{"id":2316},[2322,82276,82277,82283,82291],{},[1450,82278,82279,82282],{},[35,82280,82281],{},"Use these free tiers"," when you are learning, prototyping, or running low-volume personal projects. They cost nothing and are more than fast enough to build real scripts.",[1450,82284,82285,82288,82289,1363],{},[35,82286,82287],{},"Reach for a paid OpenAI or Anthropic key"," when you need the strongest reasoning, larger context windows, or higher guaranteed rate limits for production traffic. The trade-offs are laid out in ",[51,82290,14635],{"href":14634},[1450,82292,82293,82296],{},[35,82294,82295],{},"Self-host an open model"," only once your volume is large enough that per-request fees outweigh the cost and effort of running your own server. For most beginners, that day is a long way off.",[14,82298,2375,82299,1363],{},[51,82300,2487],{"href":2486},[57,82302,2381],{"id":2380},[2322,82304,82305,82309,82314,82319],{},[1450,82306,82307,17676],{},[51,82308,2487],{"href":2486},[1450,82310,82311,82313],{},[51,82312,69908],{"href":69907}," — pick between the two fastest free options.",[1450,82315,82316,82318],{},[51,82317,14635],{"href":14634}," — when a paid key is worth it.",[1450,82320,82321,82323],{},[51,82322,3379],{"href":3378}," — handle the most common free-tier error.",[2401,82325,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":82327},[82328,82329,82330,82331,82332,82333,82334,82335,82336,82337],{"id":237,"depth":282,"text":238},{"id":80963,"depth":282,"text":80964},{"id":81143,"depth":282,"text":81144},{"id":81273,"depth":282,"text":81274},{"id":81443,"depth":282,"text":81444},{"id":81894,"depth":282,"text":81895},{"id":82118,"depth":282,"text":82119},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Connect to OpenRouter, Hugging Face Inference, and Groq free tiers with Python in under 10 minutes. One unified connector, runnable scripts, no credit card.",[82340,82343,82346,82349,82352],{"q":82341,"a":82342},"Which free AI API is best for a complete beginner?","Groq is the easiest to start with because it is OpenAI-compatible, very fast, and gives you a generous free request quota with no card on file. OpenRouter is the best for trying many different models from one key, and Hugging Face Inference is best when you want open models for tasks beyond chat, like summarization or classification.",{"q":82344,"a":82345},"Do these free AI APIs require a credit card?","No. Groq, OpenRouter, and Hugging Face all let you create an account and generate an API key without entering payment details. You only need a card if you later choose to upgrade to a paid plan with higher limits.",{"q":82347,"a":82348},"What is the difference between a free tier and a free trial?","A free tier is an ongoing allowance you can use indefinitely within set rate limits. A free trial is a one-time pool of credits that expires after a period or once spent. The three providers in this guide all offer genuine free tiers, not just trials.",{"q":82350,"a":82351},"Why does Hugging Face return a different response shape from OpenRouter and Groq?","OpenRouter and Groq both copy OpenAI's chat-completions format, so they return a choices array with message objects. The Hugging Face Inference API for many models takes a plain inputs string and returns a list of generated-text results, so your code has to read it differently.",{"q":82353,"a":82354},"Can I use the official openai Python SDK with these free APIs?","Yes, for the OpenAI-compatible ones. You point the openai client's base_url at OpenRouter or Groq and pass the matching API key. Hugging Face's general inference endpoint is not OpenAI-compatible, so you call it with httpx instead.",{"name":82356,"steps":82357},"How to connect to free AI APIs with Python",[82358,82361,82364,82367,82370],{"name":82359,"text":82360},"Set up your environment and keys","Create a virtual environment, install the openai and httpx libraries, and store each provider's free key in a .env file.",{"name":82362,"text":82363},"Connect to Groq and OpenRouter with the openai SDK","Point the openai client's base_url at each provider to send chat requests with one familiar interface.",{"name":82365,"text":82366},"Connect to Hugging Face Inference with httpx","Call the Hugging Face endpoint with a plain inputs string and read the generated_text from the returned list.",{"name":82368,"text":82369},"Wrap all three in one unified connector","Build a single function that picks the right provider, handles the response shape, and returns clean text.",{"name":82371,"text":82372},"Add safe retries for rate limits","Catch 429 responses and retry with exponential backoff so short bursts do not crash your script.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fbest-free-ai-apis-for-beginners",{"title":80802,"description":82338},"python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fbest-free-ai-apis-for-beginners\u002Findex","wHzGNAN_UpHf_fhVjNvUMjwGFQ2gtSdew0laiU_8eK8",{"id":82379,"title":388,"body":82380,"description":83602,"extension":2419,"faq":83603,"howto":83619,"meta":83637,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":83638,"published":2452,"seo":83639,"seoTitle":388,"stem":83640,"__hash__":83641},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-401-unauthorized-error-openai-python\u002Findex.md",{"type":7,"value":82381,"toc":83588},[82382,82385,82391,82400,82404,82413,82419,82434,82436,82444,82478,82483,82491,82498,82512,82516,82522,82633,82648,82652,82658,82827,82830,82834,82837,83014,83026,83030,83036,83165,83186,83190,83193,83311,83318,83322,83414,83416,83471,83475,83478,83520,83522,83558,83562,83564,83586],[10,82383,388],{"id":82384},"fix-the-401-unauthorized-error-in-openai-python",[14,82386,82387,82388,82390],{},"This guide shows you how to fix the OpenAI ",[18,82389,19656],{}," error in Python in\nunder ten minutes. A 401 always means one thing: the server could not confirm who\nyou are. Your request arrived, but the API key it carried was missing, mistyped,\nwrong, expired, or aimed at the wrong account. The good news is that the model\nnever ran, so a 401 costs you nothing and is safe to retry once fixed.",[14,82392,82393,82394,82396,82397,82399],{},"We will look at the exact message you see, then walk through numbered fixes. Each\nfix has runnable Python 3.10+ you can paste and run. If you are brand new to keys\nand requests, read ",[51,82395,2487],{"href":2486},"\nfirst, since this page assumes you already have a key and the ",[18,82398,20],{}," SDK installed.",[57,82401,82403],{"id":82402},"the-exact-error-you-see","The exact error you see",[14,82405,82406,82407,82409,82410,82412],{},"When the key fails, the ",[18,82408,20],{}," SDK raises an ",[18,82411,30135],{}," and prints\nsomething close to this:",[253,82414,82417],{"className":82415,"code":82416,"language":111,"meta":258},[2577],"openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Incorrect\nAPI key provided: sk-abcd****. You can find your API key at\nhttps:\u002F\u002Fplatform.openai.com\u002Faccount\u002Fapi-keys.', 'type': 'invalid_request_error',\n'param': None, 'code': 'invalid_api_key'}}\n",[18,82418,82416],{"__ignoreMap":258},[14,82420,82421,82422,82424,82425,82427,82428,82430,82431,82433],{},"The key signal is the number ",[18,82423,41445],{}," and the class name ",[18,82426,30135],{},". If\nyou instead see ",[18,82429,59190],{},", that is a rate or quota problem covered in\n",[51,82432,3379],{"href":3378},",\nnot an auth problem. Confirm you really have a 401 before applying these fixes.",[57,82435,238],{"id":237},[14,82437,82438,82439,49610,82441,82443],{},"You only need what differs from the parent section: the ",[18,82440,20],{},[18,82442,2501],{},"\nto load your key, and a recent Python. Pin versions so your results match this guide.",[253,82445,82447],{"className":255,"code":82446,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate          # Windows: .venv\\Scripts\\activate\npip install \"openai>=1.40\" \"python-dotenv>=1.0\"\n",[18,82448,82449,82459,82467],{"__ignoreMap":258},[262,82450,82451,82453,82455,82457],{"class":181,"line":264},[262,82452,416],{"class":267},[262,82454,272],{"class":271},[262,82456,276],{"class":275},[262,82458,279],{"class":275},[262,82460,82461,82463,82465],{"class":181,"line":282},[262,82462,285],{"class":271},[262,82464,288],{"class":275},[262,82466,292],{"class":291},[262,82468,82469,82471,82473,82475],{"class":181,"line":295},[262,82470,298],{"class":267},[262,82472,301],{"class":275},[262,82474,304],{"class":275},[262,82476,82477],{"class":275}," \"python-dotenv>=1.0\"\n",[14,82479,42969,82480,82482],{},[18,82481,319],{}," file next to your script with your key on one line, no quotes and\nno spaces:",[253,82484,82485],{"className":323,"code":4148,"language":325,"meta":258,"style":258},[18,82486,82487],{"__ignoreMap":258},[262,82488,82489],{"class":181,"line":264},[262,82490,4148],{},[14,82492,353,82493,356,82495,82497],{},[18,82494,319],{},[18,82496,359],{}," so the key never lands in version control. A leaked\nkey can be used by strangers and billed to you.",[253,82499,82500],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,82501,82502],{"__ignoreMap":258},[262,82503,82504,82506,82508,82510],{"class":181,"line":264},[262,82505,371],{"class":271},[262,82507,374],{"class":275},[262,82509,378],{"class":377},[262,82511,381],{"class":275},[57,82513,82515],{"id":82514},"fix-1-confirm-the-key-is-actually-loaded","Fix 1 — Confirm the key is actually loaded",[14,82517,82518,82519,82521],{},"The most common cause of a 401 is that your program never read the key at all. A\n",[18,82520,319],{}," file does nothing by itself; you must load it. This check prints the key's\nlength and a masked prefix so you can see whether it arrived without ever exposing\nthe full secret.",[253,82523,82525],{"className":414,"code":82524,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\n\nload_dotenv()  # reads .env from the current working directory\n\nkey = os.environ.get(\"OPENAI_API_KEY\")\nif not key:\n    print(\"No key found. .env not loaded, or the variable name is wrong.\")\nelse:\n    print(f\"Key loaded: length {len(key)}, starts with {key[:6]}...\")\n",[18,82526,82527,82533,82543,82547,82554,82558,82570,82579,82590,82596],{"__ignoreMap":258},[262,82528,82529,82531],{"class":181,"line":264},[262,82530,684],{"class":377},[262,82532,687],{"class":429},[262,82534,82535,82537,82539,82541],{"class":181,"line":282},[262,82536,705],{"class":377},[262,82538,708],{"class":429},[262,82540,684],{"class":377},[262,82542,713],{"class":429},[262,82544,82545],{"class":181,"line":295},[262,82546,583],{"emptyLinePlaceholder":582},[262,82548,82549,82551],{"class":181,"line":345},[262,82550,4222],{"class":429},[262,82552,82553],{"class":291},"# reads .env from the current working directory\n",[262,82555,82556],{"class":181,"line":492},[262,82557,583],{"emptyLinePlaceholder":582},[262,82559,82560,82562,82564,82566,82568],{"class":181,"line":503},[262,82561,78494],{"class":429},[262,82563,476],{"class":377},[262,82565,67395],{"class":429},[262,82567,2681],{"class":275},[262,82569,660],{"class":429},[262,82571,82572,82574,82576],{"class":181,"line":521},[262,82573,2210],{"class":377},[262,82575,2818],{"class":377},[262,82577,82578],{"class":429}," key:\n",[262,82580,82581,82583,82585,82588],{"class":181,"line":537},[262,82582,1089],{"class":271},[262,82584,602],{"class":429},[262,82586,82587],{"class":275},"\"No key found. .env not loaded, or the variable name is wrong.\"",[262,82589,660],{"class":429},[262,82591,82592,82594],{"class":181,"line":549},[262,82593,20859],{"class":377},[262,82595,1160],{"class":429},[262,82597,82598,82600,82602,82604,82607,82609,82612,82614,82617,82619,82622,82624,82626,82628,82631],{"class":181,"line":570},[262,82599,1089],{"class":271},[262,82601,602],{"class":429},[262,82603,642],{"class":377},[262,82605,82606],{"class":275},"\"Key loaded: length ",[262,82608,648],{"class":271},[262,82610,82611],{"class":429},"(key)",[262,82613,654],{"class":271},[262,82615,82616],{"class":275},", starts with ",[262,82618,3039],{"class":271},[262,82620,82621],{"class":429},"key[:",[262,82623,221],{"class":271},[262,82625,6223],{"class":429},[262,82627,654],{"class":271},[262,82629,82630],{"class":275},"...\"",[262,82632,660],{"class":429},[14,82634,82635,82636,82638,82639,82641,82642,82644,82645,1363],{},"A healthy key prints a length in the dozens and starts with ",[18,82637,58513],{},". If you see\n\"No key found\", the ",[18,82640,319],{}," file is in a different folder than where you run the\nscript, or the variable is misnamed. Run the script from the folder that holds\n",[18,82643,319],{},", or pass an explicit path: ",[18,82646,82647],{},"load_dotenv(\"\u002Ffull\u002Fpath\u002Fto\u002F.env\")",[57,82649,82651],{"id":82650},"fix-2-catch-the-error-and-read-its-details","Fix 2 — Catch the error and read its details",[14,82653,82654,82655,82657],{},"Wrap your call so the program tells you precisely what went wrong instead of\ncrashing. The ",[18,82656,20],{}," SDK gives the status code and an exact message you can act on.",[253,82659,82661],{"className":414,"code":82660,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI, AuthenticationError\n\nload_dotenv()\nclient = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\ntry:\n    resp = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": \"ping\"}],\n    )\n    print(resp.choices[0].message.content)\nexcept AuthenticationError as err:\n    print(f\"401 auth failed (status {err.status_code}): {err.message}\")\n",[18,82662,82663,82669,82679,82690,82694,82698,82716,82720,82726,82734,82744,82769,82773,82784,82795],{"__ignoreMap":258},[262,82664,82665,82667],{"class":181,"line":264},[262,82666,684],{"class":377},[262,82668,687],{"class":429},[262,82670,82671,82673,82675,82677],{"class":181,"line":282},[262,82672,705],{"class":377},[262,82674,708],{"class":429},[262,82676,684],{"class":377},[262,82678,713],{"class":429},[262,82680,82681,82683,82685,82687],{"class":181,"line":295},[262,82682,705],{"class":377},[262,82684,720],{"class":429},[262,82686,684],{"class":377},[262,82688,82689],{"class":429}," OpenAI, AuthenticationError\n",[262,82691,82692],{"class":181,"line":345},[262,82693,583],{"emptyLinePlaceholder":582},[262,82695,82696],{"class":181,"line":492},[262,82697,734],{"class":429},[262,82699,82700,82702,82704,82706,82708,82710,82712,82714],{"class":181,"line":503},[262,82701,739],{"class":429},[262,82703,476],{"class":377},[262,82705,1588],{"class":429},[262,82707,2674],{"class":611},[262,82709,476],{"class":377},[262,82711,26942],{"class":429},[262,82713,2681],{"class":275},[262,82715,3512],{"class":429},[262,82717,82718],{"class":181,"line":521},[262,82719,583],{"emptyLinePlaceholder":582},[262,82721,82722,82724],{"class":181,"line":537},[262,82723,14430],{"class":377},[262,82725,1160],{"class":429},[262,82727,82728,82730,82732],{"class":181,"line":549},[262,82729,797],{"class":429},[262,82731,476],{"class":377},[262,82733,1189],{"class":429},[262,82735,82736,82738,82740,82742],{"class":181,"line":570},[262,82737,1194],{"class":611},[262,82739,476],{"class":377},[262,82741,1207],{"class":275},[262,82743,1315],{"class":429},[262,82745,82746,82748,82750,82752,82754,82756,82758,82760,82762,82764,82767],{"class":181,"line":579},[262,82747,1215],{"class":611},[262,82749,476],{"class":377},[262,82751,8856],{"class":429},[262,82753,1228],{"class":275},[262,82755,1231],{"class":429},[262,82757,1291],{"class":275},[262,82759,608],{"class":429},[262,82761,1239],{"class":275},[262,82763,1231],{"class":429},[262,82765,82766],{"class":275},"\"ping\"",[262,82768,54808],{"class":429},[262,82770,82771],{"class":181,"line":586},[262,82772,1011],{"class":429},[262,82774,82775,82777,82780,82782],{"class":181,"line":591},[262,82776,1089],{"class":271},[262,82778,82779],{"class":429},"(resp.choices[",[262,82781,102],{"class":271},[262,82783,6048],{"class":429},[262,82785,82786,82788,82791,82793],{"class":181,"line":623},[262,82787,14433],{"class":377},[262,82789,82790],{"class":429}," AuthenticationError ",[262,82792,697],{"class":377},[262,82794,3222],{"class":429},[262,82796,82797,82799,82801,82803,82806,82808,82811,82813,82816,82818,82821,82823,82825],{"class":181,"line":634},[262,82798,1089],{"class":271},[262,82800,602],{"class":429},[262,82802,642],{"class":377},[262,82804,82805],{"class":275},"\"401 auth failed (status ",[262,82807,3039],{"class":271},[262,82809,82810],{"class":429},"err.status_code",[262,82812,654],{"class":271},[262,82814,82815],{"class":275},"): ",[262,82817,3039],{"class":271},[262,82819,82820],{"class":429},"err.message",[262,82822,654],{"class":271},[262,82824,1176],{"class":275},[262,82826,660],{"class":429},[14,82828,82829],{},"Read the printed message. Phrases like \"Incorrect API key provided\" point to a\nwrong or mistyped key (Fix 3). Phrases mentioning an organization or project point\nto an account mismatch (Fix 4).",[57,82831,82833],{"id":82832},"fix-3-check-the-key-is-correct-current-and-clean","Fix 3 — Check the key is correct, current, and clean",[14,82835,82836],{},"A 401 with \"Incorrect API key\" means the string is wrong. Three things cause this:\na typo, a key that was revoked or regenerated, or hidden characters copied along\nwith the key. This check catches the hidden-character case, which is easy to miss.",[253,82838,82840],{"className":414,"code":82839,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\n\nload_dotenv()\nkey = os.environ.get(\"OPENAI_API_KEY\", \"\")\n\nproblems = []\nif not key.startswith(\"sk-\"):\n    problems.append(\"key does not start with 'sk-'\")\nif key != key.strip():\n    problems.append(\"key has leading or trailing whitespace\")\nif '\"' in key or \"'\" in key:\n    problems.append(\"key contains quote characters from the .env file\")\n\nprint(\"Problems:\" if problems else \"Key format looks clean.\")\nfor p in problems:\n    print(\" -\", p)\n",[18,82841,82842,82848,82858,82862,82866,82882,82886,82895,82907,82917,82928,82937,82957,82966,82970,82991,83002],{"__ignoreMap":258},[262,82843,82844,82846],{"class":181,"line":264},[262,82845,684],{"class":377},[262,82847,687],{"class":429},[262,82849,82850,82852,82854,82856],{"class":181,"line":282},[262,82851,705],{"class":377},[262,82853,708],{"class":429},[262,82855,684],{"class":377},[262,82857,713],{"class":429},[262,82859,82860],{"class":181,"line":295},[262,82861,583],{"emptyLinePlaceholder":582},[262,82863,82864],{"class":181,"line":345},[262,82865,734],{"class":429},[262,82867,82868,82870,82872,82874,82876,82878,82880],{"class":181,"line":492},[262,82869,78494],{"class":429},[262,82871,476],{"class":377},[262,82873,67395],{"class":429},[262,82875,2681],{"class":275},[262,82877,608],{"class":429},[262,82879,9175],{"class":275},[262,82881,660],{"class":429},[262,82883,82884],{"class":181,"line":503},[262,82885,583],{"emptyLinePlaceholder":582},[262,82887,82888,82891,82893],{"class":181,"line":521},[262,82889,82890],{"class":429},"problems ",[262,82892,476],{"class":377},[262,82894,489],{"class":429},[262,82896,82897,82899,82901,82903,82905],{"class":181,"line":537},[262,82898,2210],{"class":377},[262,82900,2818],{"class":377},[262,82902,78514],{"class":429},[262,82904,78517],{"class":275},[262,82906,8192],{"class":429},[262,82908,82909,82912,82915],{"class":181,"line":549},[262,82910,82911],{"class":429},"    problems.append(",[262,82913,82914],{"class":275},"\"key does not start with 'sk-'\"",[262,82916,660],{"class":429},[262,82918,82919,82921,82923,82925],{"class":181,"line":570},[262,82920,2210],{"class":377},[262,82922,78509],{"class":429},[262,82924,23215],{"class":377},[262,82926,82927],{"class":429}," key.strip():\n",[262,82929,82930,82932,82935],{"class":181,"line":579},[262,82931,82911],{"class":429},[262,82933,82934],{"class":275},"\"key has leading or trailing whitespace\"",[262,82936,660],{"class":429},[262,82938,82939,82941,82944,82946,82948,82950,82953,82955],{"class":181,"line":586},[262,82940,2210],{"class":377},[262,82942,82943],{"class":275}," '\"'",[262,82945,2821],{"class":377},[262,82947,78509],{"class":429},[262,82949,8923],{"class":377},[262,82951,82952],{"class":275}," \"'\"",[262,82954,2821],{"class":377},[262,82956,82578],{"class":429},[262,82958,82959,82961,82964],{"class":181,"line":591},[262,82960,82911],{"class":429},[262,82962,82963],{"class":275},"\"key contains quote characters from the .env file\"",[262,82965,660],{"class":429},[262,82967,82968],{"class":181,"line":623},[262,82969,583],{"emptyLinePlaceholder":582},[262,82971,82972,82974,82976,82979,82981,82984,82986,82989],{"class":181,"line":634},[262,82973,637],{"class":271},[262,82975,602],{"class":429},[262,82977,82978],{"class":275},"\"Problems:\"",[262,82980,20850],{"class":377},[262,82982,82983],{"class":429}," problems ",[262,82985,20859],{"class":377},[262,82987,82988],{"class":275}," \"Key format looks clean.\"",[262,82990,660],{"class":429},[262,82992,82993,82995,82997,82999],{"class":181,"line":845},[262,82994,829],{"class":377},[262,82996,40432],{"class":429},[262,82998,835],{"class":377},[262,83000,83001],{"class":429}," problems:\n",[262,83003,83004,83006,83008,83011],{"class":181,"line":850},[262,83005,1089],{"class":271},[262,83007,602],{"class":429},[262,83009,83010],{"class":275},"\" -\"",[262,83012,83013],{"class":429},", p)\n",[14,83015,83016,83017,83019,83020,83022,83023,83025],{},"If the format looks clean but the call still fails, the key itself is likely wrong\nor revoked. Open the API keys page in your OpenAI account, create a fresh key, and\npaste it into ",[18,83018,319],{}," with no surrounding quotes. Never wrap the value in quotes in\na ",[18,83021,319],{}," file; ",[18,83024,2501],{}," keeps them as part of the string.",[57,83027,83029],{"id":83028},"fix-4-match-the-organization-project-and-provider","Fix 4 — Match the organization, project, and provider",[14,83031,83032,83033,83035],{},"A correctly typed, current key can still return 401 if it points at the wrong\nplace. This happens when your account has more than one organization or project, or\nwhen ",[18,83034,80973],{}," is aimed at a different provider whose servers reject an OpenAI key.",[253,83037,83039],{"className":414,"code":83038,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\n\nclient = OpenAI(\n    api_key=os.environ[\"OPENAI_API_KEY\"],\n    organization=os.environ.get(\"OPENAI_ORG_ID\"),    # optional, set only if needed\n    project=os.environ.get(\"OPENAI_PROJECT_ID\"),     # optional, set only if needed\n    base_url=\"https:\u002F\u002Fapi.openai.com\u002Fv1\",            # the real OpenAI endpoint\n)\nprint(\"Talking to:\", client.base_url)\n",[18,83040,83041,83047,83057,83067,83071,83075,83079,83087,83099,83118,83135,83149,83153],{"__ignoreMap":258},[262,83042,83043,83045],{"class":181,"line":264},[262,83044,684],{"class":377},[262,83046,687],{"class":429},[262,83048,83049,83051,83053,83055],{"class":181,"line":282},[262,83050,705],{"class":377},[262,83052,708],{"class":429},[262,83054,684],{"class":377},[262,83056,713],{"class":429},[262,83058,83059,83061,83063,83065],{"class":181,"line":295},[262,83060,705],{"class":377},[262,83062,720],{"class":429},[262,83064,684],{"class":377},[262,83066,725],{"class":429},[262,83068,83069],{"class":181,"line":345},[262,83070,583],{"emptyLinePlaceholder":582},[262,83072,83073],{"class":181,"line":492},[262,83074,734],{"class":429},[262,83076,83077],{"class":181,"line":503},[262,83078,583],{"emptyLinePlaceholder":582},[262,83080,83081,83083,83085],{"class":181,"line":521},[262,83082,739],{"class":429},[262,83084,476],{"class":377},[262,83086,81027],{"class":429},[262,83088,83089,83091,83093,83095,83097],{"class":181,"line":537},[262,83090,81043],{"class":611},[262,83092,476],{"class":377},[262,83094,26942],{"class":429},[262,83096,2681],{"class":275},[262,83098,10309],{"class":429},[262,83100,83101,83104,83106,83109,83112,83115],{"class":181,"line":549},[262,83102,83103],{"class":611},"    organization",[262,83105,476],{"class":377},[262,83107,83108],{"class":429},"os.environ.get(",[262,83110,83111],{"class":275},"\"OPENAI_ORG_ID\"",[262,83113,83114],{"class":429},"),    ",[262,83116,83117],{"class":291},"# optional, set only if needed\n",[262,83119,83120,83123,83125,83127,83130,83133],{"class":181,"line":570},[262,83121,83122],{"class":611},"    project",[262,83124,476],{"class":377},[262,83126,83108],{"class":429},[262,83128,83129],{"class":275},"\"OPENAI_PROJECT_ID\"",[262,83131,83132],{"class":429},"),     ",[262,83134,83117],{"class":291},[262,83136,83137,83139,83141,83144,83146],{"class":181,"line":579},[262,83138,37458],{"class":611},[262,83140,476],{"class":377},[262,83142,83143],{"class":275},"\"https:\u002F\u002Fapi.openai.com\u002Fv1\"",[262,83145,54526],{"class":429},[262,83147,83148],{"class":291},"# the real OpenAI endpoint\n",[262,83150,83151],{"class":181,"line":586},[262,83152,660],{"class":429},[262,83154,83155,83157,83159,83162],{"class":181,"line":591},[262,83156,637],{"class":271},[262,83158,602],{"class":429},[262,83160,83161],{"class":275},"\"Talking to:\"",[262,83163,83164],{"class":429},", client.base_url)\n",[14,83166,83167,83168,83170,83171,83174,83175,83177,83178,83181,83182,83185],{},"Confirm ",[18,83169,80973],{}," reads ",[18,83172,83173],{},"https:\u002F\u002Fapi.openai.com\u002Fv1",". If you earlier set it to a\nfree or third-party gateway, an OpenAI key will be rejected there. Mixing providers\nis a frequent trap when following tutorials that compare services such as\n",[51,83176,69908],{"href":69907},";\neach provider needs its own matching key. Likewise, only set ",[18,83179,83180],{},"organization"," and\n",[18,83183,83184],{},"project"," if your key belongs to that exact org and project. A mismatched org or\nproject ID is just as fatal as a bad key.",[57,83187,83189],{"id":83188},"fix-5-run-a-minimal-working-call","Fix 5 — Run a minimal working call",[14,83191,83192],{},"Once the checks pass, prove the fix with the smallest possible request. A clean\nreply here means your authentication is fully working.",[253,83194,83196],{"className":414,"code":83195,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\nresp = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[{\"role\": \"user\", \"content\": \"Reply with the single word: working\"}],\n)\nprint(resp.choices[0].message.content)\n",[18,83197,83198,83204,83214,83224,83228,83232,83250,83254,83262,83272,83297,83301],{"__ignoreMap":258},[262,83199,83200,83202],{"class":181,"line":264},[262,83201,684],{"class":377},[262,83203,687],{"class":429},[262,83205,83206,83208,83210,83212],{"class":181,"line":282},[262,83207,705],{"class":377},[262,83209,708],{"class":429},[262,83211,684],{"class":377},[262,83213,713],{"class":429},[262,83215,83216,83218,83220,83222],{"class":181,"line":295},[262,83217,705],{"class":377},[262,83219,720],{"class":429},[262,83221,684],{"class":377},[262,83223,725],{"class":429},[262,83225,83226],{"class":181,"line":345},[262,83227,583],{"emptyLinePlaceholder":582},[262,83229,83230],{"class":181,"line":492},[262,83231,734],{"class":429},[262,83233,83234,83236,83238,83240,83242,83244,83246,83248],{"class":181,"line":503},[262,83235,739],{"class":429},[262,83237,476],{"class":377},[262,83239,1588],{"class":429},[262,83241,2674],{"class":611},[262,83243,476],{"class":377},[262,83245,26942],{"class":429},[262,83247,2681],{"class":275},[262,83249,3512],{"class":429},[262,83251,83252],{"class":181,"line":521},[262,83253,583],{"emptyLinePlaceholder":582},[262,83255,83256,83258,83260],{"class":181,"line":537},[262,83257,41401],{"class":429},[262,83259,476],{"class":377},[262,83261,1189],{"class":429},[262,83263,83264,83266,83268,83270],{"class":181,"line":549},[262,83265,48371],{"class":611},[262,83267,476],{"class":377},[262,83269,1207],{"class":275},[262,83271,1315],{"class":429},[262,83273,83274,83276,83278,83280,83282,83284,83286,83288,83290,83292,83295],{"class":181,"line":570},[262,83275,48388],{"class":611},[262,83277,476],{"class":377},[262,83279,8856],{"class":429},[262,83281,1228],{"class":275},[262,83283,1231],{"class":429},[262,83285,1291],{"class":275},[262,83287,608],{"class":429},[262,83289,1239],{"class":275},[262,83291,1231],{"class":429},[262,83293,83294],{"class":275},"\"Reply with the single word: working\"",[262,83296,54808],{"class":429},[262,83298,83299],{"class":181,"line":579},[262,83300,660],{"class":429},[262,83302,83303,83305,83307,83309],{"class":181,"line":586},[262,83304,637],{"class":271},[262,83306,82779],{"class":429},[262,83308,102],{"class":271},[262,83310,6048],{"class":429},[14,83312,83313,83314,83317],{},"If this prints ",[18,83315,83316],{},"working",", the 401 is solved. Build the rest of your program on top\nof this proven client.",[57,83319,83321],{"id":83320},"cause-and-fix-quick-reference","Cause and fix quick reference",[1379,83323,83324,83333],{},[1382,83325,83326],{},[1385,83327,83328,83331],{},[1388,83329,83330],{},"Cause",[1388,83332,26308],{},[1398,83334,83335,83349,83363,83377,83387,83399],{},[1385,83336,83337,83342],{},[1403,83338,83339,83341],{},[18,83340,319],{}," never loaded",[1403,83343,18789,83344,83346,83347],{},[18,83345,8439],{}," before reading the key; run from the folder holding ",[18,83348,319],{},[1385,83350,83351,83354],{},[1403,83352,83353],{},"Wrong variable name",[1403,83355,24211,83356,83358,83359,1374,83361],{},[18,83357,21742],{}," exactly, in both ",[18,83360,319],{},[18,83362,57514],{},[1385,83364,83365,83368],{},[1403,83366,83367],{},"Key mistyped or has quotes\u002Fspaces",[1403,83369,83370,83371,24612,83374,83376],{},"Re-paste cleanly with no quotes; check ",[18,83372,83373],{},"key.strip()",[18,83375,58513],{}," prefix",[1385,83378,83379,83382],{},[1403,83380,83381],{},"Key revoked or expired",[1403,83383,83384,83385],{},"Generate a new key in your OpenAI account and update ",[18,83386,319],{},[1385,83388,83389,83392],{},[1403,83390,83391],{},"Wrong org or project",[1403,83393,23336,83394,1374,83396,83398],{},[18,83395,83180],{},[18,83397,83184],{}," to match the key, or remove them",[1385,83400,83401,83406],{},[1403,83402,83403,83405],{},[18,83404,80973],{}," points at another provider",[1403,83407,83408,83409,3921,83411,83413],{},"Reset ",[18,83410,80973],{},[18,83412,83173],{}," and use that provider's own key",[57,83415,1445],{"id":1444},[1447,83417,83418,83437,83456,83465],{},[1450,83419,83420,83424,83425,83427,83428,83430,83431,83433,83434,1363],{},[35,83421,83422],{},[18,83423,28794],{}," — The variable is not in the environment.\nCause: ",[18,83426,8439],{}," ran from the wrong folder, or the ",[18,83429,319],{}," line is misspelled.\nFix: run the script from the folder containing ",[18,83432,319],{},", or load an absolute path\nwith ",[18,83435,83436],{},"load_dotenv(\"\u002Ffull\u002Fpath\u002F.env\")",[1450,83438,83439,83442,83443,83445,83446,83448,83449,83452,83453,1363],{},[35,83440,83441],{},"401 only when running from cron or a server"," — The shell that loads ",[18,83444,319],{},"\ndiffers from the one your job uses. Cause: a hardcoded environment variable on\nthe machine overrides ",[18,83447,319],{},". Fix: add ",[18,83450,83451],{},"load_dotenv(override=True)"," so the file\nwins, or unset the stale variable with ",[18,83454,83455],{},"unset OPENAI_API_KEY",[1450,83457,83458,83461,83462,83464],{},[35,83459,83460],{},"Key works in the browser playground but not in code"," — You copied a session\ntoken, not an API key, or the wrong account's key. Fix: copy the key from the\nAPI keys page (it starts with ",[18,83463,58513],{},") and confirm you are signed into the right\naccount.",[1450,83466,83467,83470],{},[35,83468,83469],{},"401 right after it worked yesterday"," — The key was rotated or revoked, or a\nfree trial expired. Fix: generate a new key and confirm your account has active\nbilling or remaining quota.",[57,83472,83474],{"id":83473},"still-failing-final-checklist","Still failing? Final checklist",[14,83476,83477],{},"Work down this list and the 401 almost always falls:",[2322,83479,83480,83486,83493,83499,83502,83509,83517],{},[1450,83481,83482,83483,83485],{},"The key prints a non-zero length and a ",[18,83484,58513],{}," prefix at runtime.",[1450,83487,83488,83490,83491,1363],{},[18,83489,319],{}," has no quotes, no trailing spaces, and uses the name ",[18,83492,21742],{},[1450,83494,83495,83496,83498],{},"You ran the script from the folder that holds ",[18,83497,319],{},", or loaded an absolute path.",[1450,83500,83501],{},"The key is current: not revoked, not from an expired trial.",[1450,83503,83504,8468,83506,83508],{},[18,83505,80973],{},[18,83507,83173],{},", not a third-party gateway.",[1450,83510,83511,83512,407,83514,83516],{},"Any ",[18,83513,83180],{},[18,83515,83184],{}," value matches the key, or is left unset.",[1450,83518,83519],{},"Your account has billing set up or free quota remaining.",[57,83521,2317],{"id":2316},[2322,83523,83524,83536,83547],{},[1450,83525,83526,83529,83530,83532,83533,83535],{},[35,83527,83528],{},"Use this guide"," when the status is ",[18,83531,41445],{}," or the SDK raises ",[18,83534,30135],{}," —\nthe cause is always identity, never the prompt or model.",[1450,83537,83538,83543,83544,83546],{},[35,83539,83540,83541],{},"Switch to ",[51,83542,3379],{"href":3378},"\nif you see ",[18,83545,59190],{},"; that is throttling or quota, and your key is fine.",[1450,83548,83549,83554,83555,83557],{},[35,83550,83551,83552],{},"Read ",[51,83553,6114],{"href":6113},"\nif the call succeeds but parsing the reply fails; that is a response-shape issue,\nnot authentication. And see\n",[51,83556,1513],{"href":1512},"\nwhen the request is too long rather than unauthorized.",[14,83559,2375,83560,1363],{},[51,83561,2487],{"href":2486},[57,83563,2381],{"id":2380},[2322,83565,83566,83571,83576,83581],{},[1450,83567,83568,83570],{},[51,83569,2487],{"href":2486}," — the main guide for keys, requests, and parameters.",[1450,83572,83573,83575],{},[51,83574,3379],{"href":3378}," — when the issue is throttling, not identity.",[1450,83577,83578,83580],{},[51,83579,6114],{"href":6113}," — when the call works but the reply will not parse.",[1450,83582,83583,83585],{},[51,83584,1513],{"href":1512}," — when the request is simply too long.",[2401,83587,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":83589},[83590,83591,83592,83593,83594,83595,83596,83597,83598,83599,83600,83601],{"id":82402,"depth":282,"text":82403},{"id":237,"depth":282,"text":238},{"id":82514,"depth":282,"text":82515},{"id":82650,"depth":282,"text":82651},{"id":82832,"depth":282,"text":82833},{"id":83028,"depth":282,"text":83029},{"id":83188,"depth":282,"text":83189},{"id":83320,"depth":282,"text":83321},{"id":1444,"depth":282,"text":1445},{"id":83473,"depth":282,"text":83474},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Fix the OpenAI 401 Unauthorized error in Python: diagnose a missing, mistyped, wrong, or unloaded API key with runnable checks and a minimal working call.",[83604,83607,83610,83613,83616],{"q":83605,"a":83606},"What does a 401 Unauthorized error from OpenAI mean?","It means the OpenAI server could not verify who you are. Your request reached the server, but the API key it carried was missing, wrong, expired, or revoked. The model never ran, so you are not charged for a 401.",{"q":83608,"a":83609},"Why do I get 401 even though my key is in a .env file?","Usually the key was never loaded into the program. The .env file does nothing on its own. You must call load_dotenv() before you read the key, and the .env file must sit in the folder where you run the script.",{"q":83611,"a":83612},"Can a 401 happen with a valid key?","Yes. A valid key still fails if it belongs to a different organization or project than the one your request targets, or if your base_url points at a different provider that does not recognize the key. Match the key to the right org, project, and provider.",{"q":83614,"a":83615},"How do I know if my key is mistyped or actually wrong?","Print the key's length and first few characters at runtime. An OpenAI key starts with sk- and is long. If it is empty, short, or shows quotes or spaces, it was loaded incorrectly rather than being a bad key.",{"q":83617,"a":83618},"Is it safe to print my API key while debugging?","Never print the whole key. Print only its length and a masked prefix such as the first six characters. A full key in your terminal history or logs is a leaked secret that strangers can bill to you.",{"name":83620,"steps":83621},"How to fix the 401 Unauthorized error in OpenAI Python",[83622,83625,83628,83631,83634],{"name":83623,"text":83624},"Read the exact error message","Run the failing call and confirm it is an AuthenticationError with HTTP status 401, not a different error.",{"name":83626,"text":83627},"Load and verify the key","Call load_dotenv(), then print the key length and a masked prefix to confirm it was read.",{"name":83629,"text":83630},"Check the key is correct and current","Confirm the key starts with sk-, is not expired or revoked, and was copied with no spaces or quotes.",{"name":83632,"text":83633},"Match the org, project, and provider","Ensure the key, organization, project, and base_url all point at the same OpenAI account.",{"name":83635,"text":83636},"Run a minimal working call","Send one tiny chat request to prove the fix and confirm a clean response.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-401-unauthorized-error-openai-python",{"title":388,"description":83602},"python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-401-unauthorized-error-openai-python\u002Findex","3r7LDDJoi0buJuweNjvY1stvRau-9IyUeT2_pepzLic",{"id":83643,"title":3379,"body":83644,"description":85906,"extension":2419,"faq":85907,"howto":85923,"meta":85938,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":85939,"published":2452,"seo":85940,"seoTitle":3379,"stem":85941,"__hash__":85942},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-429-rate-limit-error-in-python\u002Findex.md",{"type":7,"value":83645,"toc":85893},[83646,83649,83658,83666,83670,83673,83679,83692,83838,83850,83853,83939,83958,83962,83968,83986,84212,84226,84230,84236,84505,84511,84515,84525,84784,84792,84796,84806,84816,84881,84887,85158,85165,85168,85231,85233,85288,85292,85301,85823,85829,85831,85857,85861,85863,85890],[10,83647,3379],{"id":83648},"fix-the-429-rate-limit-error-in-python",[14,83650,83651,83652,83654,83655,83657],{},"This guide shows you how to diagnose and fix the ",[18,83653,59190],{}," rate-limit error from AI APIs in Python in under fifteen minutes. The ",[18,83656,59190],{}," status code (the number HTTP uses for \"Too Many Requests\") means the provider accepted your request but refused to run it because you sent too much, too fast. Your code is almost certainly fine — you just need to slow down and retry politely. By the end you will have a drop-in helper that handles this automatically.",[14,83659,83660,83661,83663,83664,1363],{},"This page sits under ",[51,83662,2487],{"href":2486},", so it assumes you already have a working API call. If you are still wiring one up, start with ",[51,83665,5485],{"href":5484},[57,83667,83669],{"id":83668},"what-the-error-actually-looks-like","What the error actually looks like",[14,83671,83672],{},"When you call the OpenAI SDK and trip a limit, you get something like this:",[253,83674,83677],{"className":83675,"code":83676,"language":111,"meta":258},[2577],"openai.RateLimitError: Error code: 429 - {'error': {'message': 'Rate limit\nreached for gpt-4o in organization org-abc123 on requests per min (RPM):\nLimit 3500, Used 3500, Requested 1. Please try again in 17ms.', 'type':\n'requests', 'code': 'rate_limit_exceeded'}}\n",[18,83678,83676],{"__ignoreMap":258},[14,83680,83681,83682,83684,83685,608,83688,83691],{},"The single most useful habit is to print the whole message. It tells you ",[27,83683,35038],{}," limit you hit — ",[18,83686,83687],{},"requests per min (RPM)",[18,83689,83690],{},"tokens per min (TPM)",", or a billing quota — and that decides the fix.",[253,83693,83695],{"className":414,"code":83694,"language":416,"meta":258,"style":258},"import openai\nfrom openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY from the environment\n\ntry:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": \"Say hello\"}],\n    )\n    print(response.choices[0].message.content)\nexcept openai.RateLimitError as error:\n    # Print the full body so you can see WHICH limit you hit\n    print(\"Rate limited:\", error.message)\n    print(\"Response headers:\", dict(error.response.headers))\n",[18,83696,83697,83703,83713,83717,83727,83731,83737,83745,83755,83780,83784,83794,83805,83810,83822],{"__ignoreMap":258},[262,83698,83699,83701],{"class":181,"line":264},[262,83700,684],{"class":377},[262,83702,29032],{"class":429},[262,83704,83705,83707,83709,83711],{"class":181,"line":282},[262,83706,705],{"class":377},[262,83708,720],{"class":429},[262,83710,684],{"class":377},[262,83712,725],{"class":429},[262,83714,83715],{"class":181,"line":295},[262,83716,583],{"emptyLinePlaceholder":582},[262,83718,83719,83721,83723,83725],{"class":181,"line":345},[262,83720,739],{"class":429},[262,83722,476],{"class":377},[262,83724,9578],{"class":429},[262,83726,9581],{"class":291},[262,83728,83729],{"class":181,"line":492},[262,83730,583],{"emptyLinePlaceholder":582},[262,83732,83733,83735],{"class":181,"line":503},[262,83734,14430],{"class":377},[262,83736,1160],{"class":429},[262,83738,83739,83741,83743],{"class":181,"line":521},[262,83740,1184],{"class":429},[262,83742,476],{"class":377},[262,83744,1189],{"class":429},[262,83746,83747,83749,83751,83753],{"class":181,"line":537},[262,83748,1194],{"class":611},[262,83750,476],{"class":377},[262,83752,1207],{"class":275},[262,83754,1315],{"class":429},[262,83756,83757,83759,83761,83763,83765,83767,83769,83771,83773,83775,83778],{"class":181,"line":549},[262,83758,1215],{"class":611},[262,83760,476],{"class":377},[262,83762,8856],{"class":429},[262,83764,1228],{"class":275},[262,83766,1231],{"class":429},[262,83768,1291],{"class":275},[262,83770,608],{"class":429},[262,83772,1239],{"class":275},[262,83774,1231],{"class":429},[262,83776,83777],{"class":275},"\"Say hello\"",[262,83779,54808],{"class":429},[262,83781,83782],{"class":181,"line":570},[262,83783,1011],{"class":429},[262,83785,83786,83788,83790,83792],{"class":181,"line":579},[262,83787,1089],{"class":271},[262,83789,48465],{"class":429},[262,83791,102],{"class":271},[262,83793,6048],{"class":429},[262,83795,83796,83798,83801,83803],{"class":181,"line":586},[262,83797,14433],{"class":377},[262,83799,83800],{"class":429}," openai.RateLimitError ",[262,83802,697],{"class":377},[262,83804,14529],{"class":429},[262,83806,83807],{"class":181,"line":591},[262,83808,83809],{"class":291},"    # Print the full body so you can see WHICH limit you hit\n",[262,83811,83812,83814,83816,83819],{"class":181,"line":623},[262,83813,1089],{"class":271},[262,83815,602],{"class":429},[262,83817,83818],{"class":275},"\"Rate limited:\"",[262,83820,83821],{"class":429},", error.message)\n",[262,83823,83824,83826,83828,83831,83833,83835],{"class":181,"line":634},[262,83825,1089],{"class":271},[262,83827,602],{"class":429},[262,83829,83830],{"class":275},"\"Response headers:\"",[262,83832,608],{"class":429},[262,83834,5869],{"class":271},[262,83836,83837],{"class":429},"(error.response.headers))\n",[14,83839,9458,83840,26321,83842,83844,83845,356,83847,83849],{},[18,83841,319],{},[18,83843,2501],{}," rather than pasting it in code. Always add ",[18,83846,319],{},[18,83848,359],{}," so your key never reaches GitHub.",[57,83851,83852],{"id":83320},"Cause and fix quick-reference",[1379,83854,83855,83867],{},[1382,83856,83857],{},[1385,83858,83859,83862,83864],{},[1388,83860,83861],{},"Message says",[1388,83863,83330],{},[1388,83865,83866],{},"Fastest fix",[1398,83868,83869,83881,83899,83915,83928],{},[1385,83870,83871,83875,83878],{},[1403,83872,83873],{},[18,83874,83687],{},[1403,83876,83877],{},"Too many calls in 60 seconds",[1403,83879,83880],{},"Add backoff and retry; batch work into fewer calls",[1385,83882,83883,83887,83893],{},[1403,83884,83885],{},[18,83886,83690],{},[1403,83888,83889,83890,83892],{},"Prompts or ",[18,83891,3846],{}," too large",[1403,83894,83895,83896,83898],{},"Lower ",[18,83897,3846],{},"; shorten input; spread calls out",[1385,83900,83901,83909,83912],{},[1403,83902,83903,31800,83906],{},[18,83904,83905],{},"quota",[18,83907,83908],{},"insufficient_quota",[1403,83910,83911],{},"Out of credit or no billing set up",[1403,83913,83914],{},"Add a payment method; raise your usage limit",[1385,83916,83917,83922,83925],{},[1403,83918,83919],{},[18,83920,83921],{},"Please try again in Ns",[1403,83923,83924],{},"Short, temporary spike",[1403,83926,83927],{},"Sleep for that duration, then retry once",[1385,83929,83930,83933,83936],{},[1403,83931,83932],{},"429 under heavy parallelism",[1403,83934,83935],{},"Too many concurrent requests",[1403,83937,83938],{},"Cap concurrency with a semaphore",[14,83940,83941,83942,83945,83946,83948,83949,407,83951,83953,83954,83957],{},"The crucial split: a ",[35,83943,83944],{},"rate limit"," (RPM or TPM) clears in seconds and retrying works. A ",[35,83947,83905],{}," error (the words ",[18,83950,83905],{},[18,83952,83908],{},") will ",[27,83955,83956],{},"never"," clear by retrying — you must add billing or raise your limit. Retrying a quota error just wastes time.",[57,83959,83961],{"id":83960},"step-1-add-exponential-backoff-with-tenacity","Step 1: Add exponential backoff with tenacity",[14,83963,83964,83965,83967],{},"Exponential backoff means: when a call fails, wait a bit and try again; if it fails again, wait twice as long; keep doubling up to a ceiling. This gives the per-minute window time to reset. The ",[18,83966,52554],{}," library does it in a few lines.",[253,83969,83971],{"className":255,"code":83970,"language":257,"meta":258,"style":258},"pip install tenacity openai python-dotenv\n",[18,83972,83973],{"__ignoreMap":258},[262,83974,83975,83977,83979,83982,83984],{"class":181,"line":264},[262,83976,298],{"class":267},[262,83978,301],{"class":275},[262,83980,83981],{"class":275}," tenacity",[262,83983,2519],{"class":275},[262,83985,2522],{"class":275},[253,83987,83989],{"className":414,"code":83988,"language":416,"meta":258,"style":258},"from openai import OpenAI\nimport openai\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\nclient = OpenAI()\n\n\n@retry(\n    retry=retry_if_exception_type(openai.RateLimitError),\n    wait=wait_exponential(multiplier=1, min=2, max=60),\n    stop=stop_after_attempt(6),\n)\ndef ask(prompt: str) -> str:\n    response = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return response.choices[0].message.content\n\n\nprint(ask(\"Write one sentence about backoff.\"))\n",[18,83990,83991,84001,84007,84017,84022,84027,84032,84037,84041,84045,84053,84057,84061,84067,84076,84109,84121,84125,84141,84149,84159,84179,84183,84193,84197,84201],{"__ignoreMap":258},[262,83992,83993,83995,83997,83999],{"class":181,"line":264},[262,83994,705],{"class":377},[262,83996,720],{"class":429},[262,83998,684],{"class":377},[262,84000,725],{"class":429},[262,84002,84003,84005],{"class":181,"line":282},[262,84004,684],{"class":377},[262,84006,29032],{"class":429},[262,84008,84009,84011,84013,84015],{"class":181,"line":295},[262,84010,705],{"class":377},[262,84012,53308],{"class":429},[262,84014,684],{"class":377},[262,84016,984],{"class":429},[262,84018,84019],{"class":181,"line":345},[262,84020,84021],{"class":429},"    retry,\n",[262,84023,84024],{"class":181,"line":492},[262,84025,84026],{"class":429},"    stop_after_attempt,\n",[262,84028,84029],{"class":181,"line":503},[262,84030,84031],{"class":429},"    wait_exponential,\n",[262,84033,84034],{"class":181,"line":521},[262,84035,84036],{"class":429},"    retry_if_exception_type,\n",[262,84038,84039],{"class":181,"line":537},[262,84040,660],{"class":429},[262,84042,84043],{"class":181,"line":549},[262,84044,583],{"emptyLinePlaceholder":582},[262,84046,84047,84049,84051],{"class":181,"line":570},[262,84048,739],{"class":429},[262,84050,476],{"class":377},[262,84052,744],{"class":429},[262,84054,84055],{"class":181,"line":579},[262,84056,583],{"emptyLinePlaceholder":582},[262,84058,84059],{"class":181,"line":586},[262,84060,583],{"emptyLinePlaceholder":582},[262,84062,84063,84065],{"class":181,"line":591},[262,84064,53361],{"class":267},[262,84066,2835],{"class":429},[262,84068,84069,84071,84073],{"class":181,"line":623},[262,84070,53410],{"class":611},[262,84072,476],{"class":377},[262,84074,84075],{"class":429},"retry_if_exception_type(openai.RateLimitError),\n",[262,84077,84078,84080,84082,84084,84087,84089,84091,84093,84095,84097,84099,84101,84103,84105,84107],{"class":181,"line":634},[262,84079,53382],{"class":611},[262,84081,476],{"class":377},[262,84083,53387],{"class":429},[262,84085,84086],{"class":611},"multiplier",[262,84088,476],{"class":377},[262,84090,997],{"class":271},[262,84092,608],{"class":429},[262,84094,53390],{"class":611},[262,84096,476],{"class":377},[262,84098,109],{"class":271},[262,84100,608],{"class":429},[262,84102,53399],{"class":611},[262,84104,476],{"class":377},[262,84106,12826],{"class":271},[262,84108,1210],{"class":429},[262,84110,84111,84113,84115,84117,84119],{"class":181,"line":845},[262,84112,53368],{"class":611},[262,84114,476],{"class":377},[262,84116,53373],{"class":429},[262,84118,221],{"class":271},[262,84120,1210],{"class":429},[262,84122,84123],{"class":181,"line":850},[262,84124,660],{"class":429},[262,84126,84127,84129,84131,84133,84135,84137,84139],{"class":181,"line":864},[262,84128,423],{"class":377},[262,84130,44066],{"class":267},[262,84132,9599],{"class":429},[262,84134,433],{"class":271},[262,84136,1939],{"class":429},[262,84138,433],{"class":271},[262,84140,1160],{"class":429},[262,84142,84143,84145,84147],{"class":181,"line":1683},[262,84144,1184],{"class":429},[262,84146,476],{"class":377},[262,84148,1189],{"class":429},[262,84150,84151,84153,84155,84157],{"class":181,"line":1688},[262,84152,1194],{"class":611},[262,84154,476],{"class":377},[262,84156,1207],{"class":275},[262,84158,1315],{"class":429},[262,84160,84161,84163,84165,84167,84169,84171,84173,84175,84177],{"class":181,"line":1693},[262,84162,1215],{"class":611},[262,84164,476],{"class":377},[262,84166,8856],{"class":429},[262,84168,1228],{"class":275},[262,84170,1231],{"class":429},[262,84172,1291],{"class":275},[262,84174,608],{"class":429},[262,84176,1239],{"class":275},[262,84178,18141],{"class":429},[262,84180,84181],{"class":181,"line":1728},[262,84182,1011],{"class":429},[262,84184,84185,84187,84189,84191],{"class":181,"line":1737},[262,84186,573],{"class":377},[262,84188,1326],{"class":429},[262,84190,102],{"class":271},[262,84192,1331],{"class":429},[262,84194,84195],{"class":181,"line":1751},[262,84196,583],{"emptyLinePlaceholder":582},[262,84198,84199],{"class":181,"line":1764},[262,84200,583],{"emptyLinePlaceholder":582},[262,84202,84203,84205,84207,84210],{"class":181,"line":1779},[262,84204,637],{"class":271},[262,84206,48737],{"class":429},[262,84208,84209],{"class":275},"\"Write one sentence about backoff.\"",[262,84211,2684],{"class":429},[14,84213,84214,84217,84218,84221,84222,84225],{},[18,84215,84216],{},"wait_exponential(min=2, max=60)"," waits 2 seconds, then 4, 8, 16, and so on, capped at 60. ",[18,84219,84220],{},"retry_if_exception_type(openai.RateLimitError)"," makes sure you only retry rate-limit failures, not real bugs like a typo in the model name. ",[18,84223,84224],{},"stop_after_attempt(6)"," prevents an infinite loop if the limit never clears.",[57,84227,84229],{"id":84228},"step-2-do-the-same-thing-manually-no-extra-library","Step 2: Do the same thing manually (no extra library)",[14,84231,84232,84233,84235],{},"If you would rather not add a dependency, the same logic is a short loop. This is worth understanding even if you use ",[18,84234,52554],{},", because it shows exactly what backoff does.",[253,84237,84239],{"className":414,"code":84238,"language":416,"meta":258,"style":258},"import time\nimport openai\nfrom openai import OpenAI\n\nclient = OpenAI()\n\n\ndef ask_with_backoff(prompt: str, max_retries: int = 6) -> str:\n    delay = 2.0  # seconds, doubles each failure\n    for attempt in range(max_retries):\n        try:\n            response = client.chat.completions.create(\n                model=\"gpt-4o-mini\",\n                messages=[{\"role\": \"user\", \"content\": prompt}],\n            )\n            return response.choices[0].message.content\n        except openai.RateLimitError as error:\n            if attempt == max_retries - 1:\n                raise  # give up after the last attempt\n            print(f\"429 hit. Waiting {delay:.0f}s (attempt {attempt + 1}).\")\n            time.sleep(delay)\n            delay = min(delay * 2, 60)  # cap the wait at 60s\n    raise RuntimeError(\"Unreachable\")\n",[18,84240,84241,84247,84253,84263,84267,84275,84279,84283,84308,84321,84333,84339,84347,84357,84377,84381,84391,84401,84417,84425,84462,84467,84492],{"__ignoreMap":258},[262,84242,84243,84245],{"class":181,"line":264},[262,84244,684],{"class":377},[262,84246,2612],{"class":429},[262,84248,84249,84251],{"class":181,"line":282},[262,84250,684],{"class":377},[262,84252,29032],{"class":429},[262,84254,84255,84257,84259,84261],{"class":181,"line":295},[262,84256,705],{"class":377},[262,84258,720],{"class":429},[262,84260,684],{"class":377},[262,84262,725],{"class":429},[262,84264,84265],{"class":181,"line":345},[262,84266,583],{"emptyLinePlaceholder":582},[262,84268,84269,84271,84273],{"class":181,"line":492},[262,84270,739],{"class":429},[262,84272,476],{"class":377},[262,84274,744],{"class":429},[262,84276,84277],{"class":181,"line":503},[262,84278,583],{"emptyLinePlaceholder":582},[262,84280,84281],{"class":181,"line":521},[262,84282,583],{"emptyLinePlaceholder":582},[262,84284,84285,84287,84290,84292,84294,84296,84298,84300,84302,84304,84306],{"class":181,"line":537},[262,84286,423],{"class":377},[262,84288,84289],{"class":267}," ask_with_backoff",[262,84291,9599],{"class":429},[262,84293,433],{"class":271},[262,84295,3007],{"class":429},[262,84297,439],{"class":271},[262,84299,442],{"class":377},[262,84301,43778],{"class":271},[262,84303,1939],{"class":429},[262,84305,433],{"class":271},[262,84307,1160],{"class":429},[262,84309,84310,84313,84315,84318],{"class":181,"line":549},[262,84311,84312],{"class":429},"    delay ",[262,84314,476],{"class":377},[262,84316,84317],{"class":271}," 2.0",[262,84319,84320],{"class":291},"  # seconds, doubles each failure\n",[262,84322,84323,84325,84327,84329,84331],{"class":181,"line":570},[262,84324,3074],{"class":377},[262,84326,3077],{"class":429},[262,84328,835],{"class":377},[262,84330,3082],{"class":271},[262,84332,3085],{"class":429},[262,84334,84335,84337],{"class":181,"line":579},[262,84336,3090],{"class":377},[262,84338,1160],{"class":429},[262,84340,84341,84343,84345],{"class":181,"line":586},[262,84342,3097],{"class":429},[262,84344,476],{"class":377},[262,84346,1189],{"class":429},[262,84348,84349,84351,84353,84355],{"class":181,"line":591},[262,84350,3106],{"class":611},[262,84352,476],{"class":377},[262,84354,1207],{"class":275},[262,84356,1315],{"class":429},[262,84358,84359,84361,84363,84365,84367,84369,84371,84373,84375],{"class":181,"line":623},[262,84360,3117],{"class":611},[262,84362,476],{"class":377},[262,84364,8856],{"class":429},[262,84366,1228],{"class":275},[262,84368,1231],{"class":429},[262,84370,1291],{"class":275},[262,84372,608],{"class":429},[262,84374,1239],{"class":275},[262,84376,18141],{"class":429},[262,84378,84379],{"class":181,"line":634},[262,84380,3193],{"class":429},[262,84382,84383,84385,84387,84389],{"class":181,"line":845},[262,84384,3198],{"class":377},[262,84386,1326],{"class":429},[262,84388,102],{"class":271},[262,84390,1331],{"class":429},[262,84392,84393,84395,84397,84399],{"class":181,"line":850},[262,84394,3214],{"class":377},[262,84396,83800],{"class":429},[262,84398,697],{"class":377},[262,84400,14529],{"class":429},[262,84402,84403,84405,84407,84409,84411,84413,84415],{"class":181,"line":864},[262,84404,10200],{"class":377},[262,84406,3077],{"class":429},[262,84408,10758],{"class":377},[262,84410,70571],{"class":429},[262,84412,561],{"class":377},[262,84414,3243],{"class":271},[262,84416,1160],{"class":429},[262,84418,84419,84422],{"class":181,"line":1683},[262,84420,84421],{"class":377},"                raise",[262,84423,84424],{"class":291},"  # give up after the last attempt\n",[262,84426,84427,84429,84431,84433,84436,84438,84441,84444,84446,84449,84451,84453,84455,84457,84460],{"class":181,"line":1688},[262,84428,3250],{"class":271},[262,84430,602],{"class":429},[262,84432,642],{"class":377},[262,84434,84435],{"class":275},"\"429 hit. Waiting ",[262,84437,3039],{"class":271},[262,84439,84440],{"class":429},"delay",[262,84442,84443],{"class":377},":.0f",[262,84445,654],{"class":271},[262,84447,84448],{"class":275},"s (attempt ",[262,84450,3039],{"class":271},[262,84452,3262],{"class":429},[262,84454,531],{"class":377},[262,84456,3267],{"class":271},[262,84458,84459],{"class":275},").\"",[262,84461,660],{"class":429},[262,84463,84464],{"class":181,"line":1693},[262,84465,84466],{"class":429},"            time.sleep(delay)\n",[262,84468,84469,84472,84474,84476,84479,84481,84483,84485,84487,84489],{"class":181,"line":1728},[262,84470,84471],{"class":429},"            delay ",[262,84473,476],{"class":377},[262,84475,60465],{"class":271},[262,84477,84478],{"class":429},"(delay ",[262,84480,1003],{"class":377},[262,84482,3232],{"class":271},[262,84484,608],{"class":429},[262,84486,12826],{"class":271},[262,84488,32223],{"class":429},[262,84490,84491],{"class":291},"# cap the wait at 60s\n",[262,84493,84494,84496,84498,84500,84503],{"class":181,"line":1737},[262,84495,2829],{"class":377},[262,84497,3318],{"class":271},[262,84499,602],{"class":429},[262,84501,84502],{"class":275},"\"Unreachable\"",[262,84504,660],{"class":429},[14,84506,84507,84508,84510],{},"The pattern is always the same: catch ",[18,84509,2707],{},", sleep, double the delay, cap it, and re-raise on the last attempt so genuine outages still surface.",[57,84512,84514],{"id":84513},"step-3-respect-the-retry-after-header","Step 3: Respect the Retry-After header",[14,84516,84517,84518,84521,84522,84524],{},"Many providers tell you ",[27,84519,84520],{},"exactly"," how long to wait in a ",[18,84523,42730],{}," response header (a value in seconds). Honouring it is more polite and more efficient than guessing, because you wait the real amount rather than a doubling estimate.",[253,84526,84528],{"className":414,"code":84527,"language":416,"meta":258,"style":258},"import time\nimport openai\nfrom openai import OpenAI\n\nclient = OpenAI()\n\n\ndef ask_respecting_retry_after(prompt: str, max_retries: int = 6) -> str:\n    for attempt in range(max_retries):\n        try:\n            response = client.chat.completions.create(\n                model=\"gpt-4o-mini\",\n                messages=[{\"role\": \"user\", \"content\": prompt}],\n            )\n            return response.choices[0].message.content\n        except openai.RateLimitError as error:\n            if attempt == max_retries - 1:\n                raise\n            # Use the server's suggested wait if present, else fall back\n            retry_after = error.response.headers.get(\"retry-after\")\n            wait = float(retry_after) if retry_after else 2 ** attempt\n            print(f\"429. Server asked to wait {wait:.1f}s.\")\n            time.sleep(wait)\n    raise RuntimeError(\"Unreachable\")\n",[18,84529,84530,84536,84542,84552,84556,84564,84568,84572,84597,84609,84615,84623,84633,84653,84657,84667,84677,84693,84697,84702,84717,84743,84768,84772],{"__ignoreMap":258},[262,84531,84532,84534],{"class":181,"line":264},[262,84533,684],{"class":377},[262,84535,2612],{"class":429},[262,84537,84538,84540],{"class":181,"line":282},[262,84539,684],{"class":377},[262,84541,29032],{"class":429},[262,84543,84544,84546,84548,84550],{"class":181,"line":295},[262,84545,705],{"class":377},[262,84547,720],{"class":429},[262,84549,684],{"class":377},[262,84551,725],{"class":429},[262,84553,84554],{"class":181,"line":345},[262,84555,583],{"emptyLinePlaceholder":582},[262,84557,84558,84560,84562],{"class":181,"line":492},[262,84559,739],{"class":429},[262,84561,476],{"class":377},[262,84563,744],{"class":429},[262,84565,84566],{"class":181,"line":503},[262,84567,583],{"emptyLinePlaceholder":582},[262,84569,84570],{"class":181,"line":521},[262,84571,583],{"emptyLinePlaceholder":582},[262,84573,84574,84576,84579,84581,84583,84585,84587,84589,84591,84593,84595],{"class":181,"line":537},[262,84575,423],{"class":377},[262,84577,84578],{"class":267}," ask_respecting_retry_after",[262,84580,9599],{"class":429},[262,84582,433],{"class":271},[262,84584,3007],{"class":429},[262,84586,439],{"class":271},[262,84588,442],{"class":377},[262,84590,43778],{"class":271},[262,84592,1939],{"class":429},[262,84594,433],{"class":271},[262,84596,1160],{"class":429},[262,84598,84599,84601,84603,84605,84607],{"class":181,"line":549},[262,84600,3074],{"class":377},[262,84602,3077],{"class":429},[262,84604,835],{"class":377},[262,84606,3082],{"class":271},[262,84608,3085],{"class":429},[262,84610,84611,84613],{"class":181,"line":570},[262,84612,3090],{"class":377},[262,84614,1160],{"class":429},[262,84616,84617,84619,84621],{"class":181,"line":579},[262,84618,3097],{"class":429},[262,84620,476],{"class":377},[262,84622,1189],{"class":429},[262,84624,84625,84627,84629,84631],{"class":181,"line":586},[262,84626,3106],{"class":611},[262,84628,476],{"class":377},[262,84630,1207],{"class":275},[262,84632,1315],{"class":429},[262,84634,84635,84637,84639,84641,84643,84645,84647,84649,84651],{"class":181,"line":591},[262,84636,3117],{"class":611},[262,84638,476],{"class":377},[262,84640,8856],{"class":429},[262,84642,1228],{"class":275},[262,84644,1231],{"class":429},[262,84646,1291],{"class":275},[262,84648,608],{"class":429},[262,84650,1239],{"class":275},[262,84652,18141],{"class":429},[262,84654,84655],{"class":181,"line":623},[262,84656,3193],{"class":429},[262,84658,84659,84661,84663,84665],{"class":181,"line":634},[262,84660,3198],{"class":377},[262,84662,1326],{"class":429},[262,84664,102],{"class":271},[262,84666,1331],{"class":429},[262,84668,84669,84671,84673,84675],{"class":181,"line":845},[262,84670,3214],{"class":377},[262,84672,83800],{"class":429},[262,84674,697],{"class":377},[262,84676,14529],{"class":429},[262,84678,84679,84681,84683,84685,84687,84689,84691],{"class":181,"line":850},[262,84680,10200],{"class":377},[262,84682,3077],{"class":429},[262,84684,10758],{"class":377},[262,84686,70571],{"class":429},[262,84688,561],{"class":377},[262,84690,3243],{"class":271},[262,84692,1160],{"class":429},[262,84694,84695],{"class":181,"line":864},[262,84696,39443],{"class":377},[262,84698,84699],{"class":181,"line":1683},[262,84700,84701],{"class":291},"            # Use the server's suggested wait if present, else fall back\n",[262,84703,84704,84707,84709,84712,84715],{"class":181,"line":1688},[262,84705,84706],{"class":429},"            retry_after ",[262,84708,476],{"class":377},[262,84710,84711],{"class":429}," error.response.headers.get(",[262,84713,84714],{"class":275},"\"retry-after\"",[262,84716,660],{"class":429},[262,84718,84719,84721,84723,84726,84729,84731,84734,84736,84738,84740],{"class":181,"line":1693},[262,84720,3227],{"class":429},[262,84722,476],{"class":377},[262,84724,84725],{"class":271}," float",[262,84727,84728],{"class":429},"(retry_after) ",[262,84730,2210],{"class":377},[262,84732,84733],{"class":429}," retry_after ",[262,84735,20859],{"class":377},[262,84737,3232],{"class":271},[262,84739,3235],{"class":377},[262,84741,84742],{"class":429}," attempt\n",[262,84744,84745,84747,84749,84751,84754,84756,84758,84761,84763,84766],{"class":181,"line":1728},[262,84746,3250],{"class":271},[262,84748,602],{"class":429},[262,84750,642],{"class":377},[262,84752,84753],{"class":275},"\"429. Server asked to wait ",[262,84755,3039],{"class":271},[262,84757,3295],{"class":429},[262,84759,84760],{"class":377},":.1f",[262,84762,654],{"class":271},[262,84764,84765],{"class":275},"s.\"",[262,84767,660],{"class":429},[262,84769,84770],{"class":181,"line":1737},[262,84771,3307],{"class":429},[262,84773,84774,84776,84778,84780,84782],{"class":181,"line":1751},[262,84775,2829],{"class":377},[262,84777,3318],{"class":271},[262,84779,602],{"class":429},[262,84781,84502],{"class":275},[262,84783,660],{"class":429},[14,84785,49752,84786,84788,84789,84791],{},[18,84787,42730],{}," is missing, the code falls back to ",[18,84790,49578],{}," (1, 2, 4, 8 seconds) so you always have a sane delay.",[57,84793,84795],{"id":84794},"step-4-batch-requests-and-lower-concurrency","Step 4: Batch requests and lower concurrency",[14,84797,84798,84799,84801,84802,84805],{},"Backoff handles bursts, but the real cure for repeated ",[18,84800,59190],{},"s is sending ",[27,84803,84804],{},"less",". Two levers matter.",[14,84807,84808,84812,84813,84815],{},[35,84809,83895,84810,1363],{},[18,84811,3846],{}," Token-per-minute limits count both your input and the model's output. A generous ",[18,84814,3846],{}," reserves a big slice of your budget on every call. Cap it to what you actually need:",[253,84817,84819],{"className":414,"code":84818,"language":416,"meta":258,"style":258},"response = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[{\"role\": \"user\", \"content\": \"Summarize in one line.\"}],\n    max_tokens=60,  # don't reserve thousands of tokens you won't use\n)\n",[18,84820,84821,84829,84839,84864,84877],{"__ignoreMap":258},[262,84822,84823,84825,84827],{"class":181,"line":264},[262,84824,48362],{"class":429},[262,84826,476],{"class":377},[262,84828,1189],{"class":429},[262,84830,84831,84833,84835,84837],{"class":181,"line":282},[262,84832,48371],{"class":611},[262,84834,476],{"class":377},[262,84836,1207],{"class":275},[262,84838,1315],{"class":429},[262,84840,84841,84843,84845,84847,84849,84851,84853,84855,84857,84859,84862],{"class":181,"line":295},[262,84842,48388],{"class":611},[262,84844,476],{"class":377},[262,84846,8856],{"class":429},[262,84848,1228],{"class":275},[262,84850,1231],{"class":429},[262,84852,1291],{"class":275},[262,84854,608],{"class":429},[262,84856,1239],{"class":275},[262,84858,1231],{"class":429},[262,84860,84861],{"class":275},"\"Summarize in one line.\"",[262,84863,54808],{"class":429},[262,84865,84866,84868,84870,84872,84874],{"class":181,"line":345},[262,84867,77660],{"class":611},[262,84869,476],{"class":377},[262,84871,12826],{"class":271},[262,84873,13488],{"class":429},[262,84875,84876],{"class":291},"# don't reserve thousands of tokens you won't use\n",[262,84878,84879],{"class":181,"line":492},[262,84880,660],{"class":429},[14,84882,84883,84886],{},[35,84884,84885],{},"Cap concurrency."," If you fan out requests with threads or async, you can blow past the per-minute limit in seconds. A semaphore (a counter that only lets N tasks run at once) keeps you under the ceiling.",[253,84888,84890],{"className":414,"code":84889,"language":416,"meta":258,"style":258},"import asyncio\nfrom openai import AsyncOpenAI\n\nclient = AsyncOpenAI()\nsemaphore = asyncio.Semaphore(5)  # at most 5 calls in flight at once\n\n\nasync def ask_async(prompt: str) -> str:\n    async with semaphore:  # blocks until a slot is free\n        response = await client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            messages=[{\"role\": \"user\", \"content\": prompt}],\n            max_tokens=80,\n        )\n        return response.choices[0].message.content\n\n\nasync def main() -> None:\n    prompts = [f\"Give me fact #{i}.\" for i in range(40)]\n    results = await asyncio.gather(*(ask_async(p) for p in prompts))\n    for line in results:\n        print(line)\n\n\nasyncio.run(main())\n",[18,84891,84892,84899,84910,84914,84923,84940,84944,84948,84967,84981,84991,85001,85021,85031,85035,85045,85049,85053,85067,85103,85128,85138,85145,85149,85153],{"__ignoreMap":258},[262,84893,84894,84896],{"class":181,"line":264},[262,84895,684],{"class":377},[262,84897,84898],{"class":429}," asyncio\n",[262,84900,84901,84903,84905,84907],{"class":181,"line":282},[262,84902,705],{"class":377},[262,84904,720],{"class":429},[262,84906,684],{"class":377},[262,84908,84909],{"class":429}," AsyncOpenAI\n",[262,84911,84912],{"class":181,"line":295},[262,84913,583],{"emptyLinePlaceholder":582},[262,84915,84916,84918,84920],{"class":181,"line":345},[262,84917,739],{"class":429},[262,84919,476],{"class":377},[262,84921,84922],{"class":429}," AsyncOpenAI()\n",[262,84924,84925,84928,84930,84933,84935,84937],{"class":181,"line":492},[262,84926,84927],{"class":429},"semaphore ",[262,84929,476],{"class":377},[262,84931,84932],{"class":429}," asyncio.Semaphore(",[262,84934,222],{"class":271},[262,84936,32223],{"class":429},[262,84938,84939],{"class":291},"# at most 5 calls in flight at once\n",[262,84941,84942],{"class":181,"line":503},[262,84943,583],{"emptyLinePlaceholder":582},[262,84945,84946],{"class":181,"line":521},[262,84947,583],{"emptyLinePlaceholder":582},[262,84949,84950,84952,84954,84957,84959,84961,84963,84965],{"class":181,"line":537},[262,84951,55039],{"class":377},[262,84953,55042],{"class":377},[262,84955,84956],{"class":267}," ask_async",[262,84958,9599],{"class":429},[262,84960,433],{"class":271},[262,84962,1939],{"class":429},[262,84964,433],{"class":271},[262,84966,1160],{"class":429},[262,84968,84969,84972,84975,84978],{"class":181,"line":549},[262,84970,84971],{"class":377},"    async",[262,84973,84974],{"class":377}," with",[262,84976,84977],{"class":429}," semaphore:  ",[262,84979,84980],{"class":291},"# blocks until a slot is free\n",[262,84982,84983,84985,84987,84989],{"class":181,"line":570},[262,84984,21490],{"class":429},[262,84986,476],{"class":377},[262,84988,55061],{"class":377},[262,84990,1189],{"class":429},[262,84992,84993,84995,84997,84999],{"class":181,"line":579},[262,84994,14214],{"class":611},[262,84996,476],{"class":377},[262,84998,1207],{"class":275},[262,85000,1315],{"class":429},[262,85002,85003,85005,85007,85009,85011,85013,85015,85017,85019],{"class":181,"line":586},[262,85004,27253],{"class":611},[262,85006,476],{"class":377},[262,85008,8856],{"class":429},[262,85010,1228],{"class":275},[262,85012,1231],{"class":429},[262,85014,1291],{"class":275},[262,85016,608],{"class":429},[262,85018,1239],{"class":275},[262,85020,18141],{"class":429},[262,85022,85023,85025,85027,85029],{"class":181,"line":591},[262,85024,27286],{"class":611},[262,85026,476],{"class":377},[262,85028,1100],{"class":271},[262,85030,1315],{"class":429},[262,85032,85033],{"class":181,"line":623},[262,85034,6288],{"class":429},[262,85036,85037,85039,85041,85043],{"class":181,"line":634},[262,85038,8066],{"class":377},[262,85040,1326],{"class":429},[262,85042,102],{"class":271},[262,85044,1331],{"class":429},[262,85046,85047],{"class":181,"line":845},[262,85048,583],{"emptyLinePlaceholder":582},[262,85050,85051],{"class":181,"line":850},[262,85052,583],{"emptyLinePlaceholder":582},[262,85054,85055,85057,85059,85061,85063,85065],{"class":181,"line":864},[262,85056,55039],{"class":377},[262,85058,55042],{"class":377},[262,85060,23929],{"class":267},[262,85062,15481],{"class":429},[262,85064,8471],{"class":271},[262,85066,1160],{"class":429},[262,85068,85069,85072,85074,85076,85078,85081,85083,85085,85087,85089,85091,85093,85095,85097,85099,85101],{"class":181,"line":1683},[262,85070,85071],{"class":429},"    prompts ",[262,85073,476],{"class":377},[262,85075,10563],{"class":429},[262,85077,642],{"class":377},[262,85079,85080],{"class":275},"\"Give me fact #",[262,85082,3039],{"class":271},[262,85084,15558],{"class":429},[262,85086,654],{"class":271},[262,85088,65566],{"class":275},[262,85090,10739],{"class":377},[262,85092,1043],{"class":429},[262,85094,835],{"class":377},[262,85096,3082],{"class":271},[262,85098,602],{"class":429},[262,85100,23367],{"class":271},[262,85102,18503],{"class":429},[262,85104,85105,85107,85109,85111,85114,85116,85119,85121,85123,85125],{"class":181,"line":1688},[262,85106,10694],{"class":429},[262,85108,476],{"class":377},[262,85110,55061],{"class":377},[262,85112,85113],{"class":429}," asyncio.gather(",[262,85115,1003],{"class":377},[262,85117,85118],{"class":429},"(ask_async(p) ",[262,85120,829],{"class":377},[262,85122,40432],{"class":429},[262,85124,835],{"class":377},[262,85126,85127],{"class":429}," prompts))\n",[262,85129,85130,85132,85134,85136],{"class":181,"line":1693},[262,85131,3074],{"class":377},[262,85133,54383],{"class":429},[262,85135,835],{"class":377},[262,85137,10653],{"class":429},[262,85139,85140,85142],{"class":181,"line":1728},[262,85141,2299],{"class":271},[262,85143,85144],{"class":429},"(line)\n",[262,85146,85147],{"class":181,"line":1737},[262,85148,583],{"emptyLinePlaceholder":582},[262,85150,85151],{"class":181,"line":1751},[262,85152,583],{"emptyLinePlaceholder":582},[262,85154,85155],{"class":181,"line":1764},[262,85156,85157],{"class":429},"asyncio.run(main())\n",[14,85159,85160,85161,85164],{},"Wherever you can, also ",[27,85162,85163],{},"batch"," logically related work into one prompt — asking for ten summaries in a single call uses one request slot instead of ten.",[57,85166,85167],{"id":24066},"Key parameters quick-reference",[1379,85169,85170,85182],{},[1382,85171,85172],{},[1385,85173,85174,85176,85179],{},[1388,85175,1390],{},[1388,85177,85178],{},"What it controls",[1388,85180,85181],{},"Sensible starting value",[1398,85183,85184,85202,85216],{},[1385,85185,85186,85191,85194],{},[1403,85187,85188],{},[18,85189,85190],{},"wait_exponential(min, max)",[1403,85192,85193],{},"Backoff floor and ceiling, in seconds",[1403,85195,85196,608,85199],{},[18,85197,85198],{},"min=2",[18,85200,85201],{},"max=60",[1385,85203,85204,85209,85212],{},[1403,85205,85206],{},[18,85207,85208],{},"stop_after_attempt(n)",[1403,85210,85211],{},"How many tries before giving up",[1403,85213,85214],{},[18,85215,221],{},[1385,85217,85218,85223,85226],{},[1403,85219,85220],{},[18,85221,85222],{},"Semaphore(n)",[1403,85224,85225],{},"Max requests running at the same time",[1403,85227,85228,85230],{},[18,85229,222],{}," (raise slowly)",[57,85232,1445],{"id":1444},[1447,85234,85235,85246,85261,85278],{},[1450,85236,85237,85240,85241,407,85243,85245],{},[35,85238,85239],{},"Retrying forever and never recovering."," If the message contains ",[18,85242,83908],{},[18,85244,83905],{},", you are out of credit, not rate-limited. No amount of backoff helps. Add a payment method in your provider dashboard and raise your monthly usage limit.",[1450,85247,85248,85251,85252,85255,85256,85258,85259,1363],{},[35,85249,85250],{},"Still getting 429 with only a handful of calls."," You are almost certainly hitting the tokens-per-minute limit, not requests-per-minute. Print the message to confirm it says ",[18,85253,85254],{},"(TPM)",", then lower ",[18,85257,3846],{}," and shorten your prompt. Very long contexts are a frequent cause — see ",[51,85260,1513],{"href":1512},[1450,85262,85263,85266,85267,85269,85270,85272,85273,85275,85276,1363],{},[35,85264,85265],{},"Backoff catches the wrong errors."," If your retry wrapper also swallows authentication or JSON failures, it will retry pointlessly. Catch only ",[18,85268,28811],{},". A ",[18,85271,41445],{}," is a credential problem — see ",[51,85274,388],{"href":387}," — and a malformed body is covered in ",[51,85277,6114],{"href":6113},[1450,85279,85280,85283,85284,85287],{},[35,85281,85282],{},"Limit returns the moment your script ends."," Concurrency spikes are bursty. Confirm a single sequential call works, then reintroduce parallelism behind a ",[18,85285,85286],{},"Semaphore"," and increase the count one step at a time until you stay clean.",[57,85289,85291],{"id":85290},"worked-example-a-resilient-rate-aware-client","Worked example: a resilient, rate-aware client",[14,85293,85294,85295,85297,85298,85300],{},"This script combines everything above — ",[18,85296,42730],{},"-aware backoff, capped concurrency, a modest ",[18,85299,3846],{}," — into one reusable async helper you can paste into a project.",[253,85302,85304],{"className":414,"code":85303,"language":416,"meta":258,"style":258},"import asyncio\nimport openai\nfrom openai import AsyncOpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()  # remember: add .env to your .gitignore\n\nclient = AsyncOpenAI()\nsemaphore = asyncio.Semaphore(5)  # cap concurrent calls\n\n\nasync def ask(prompt: str, max_retries: int = 6) -> str:\n    \"\"\"Call the API with backoff that honours Retry-After.\"\"\"\n    async with semaphore:\n        for attempt in range(max_retries):\n            try:\n                response = await client.chat.completions.create(\n                    model=\"gpt-4o-mini\",\n                    messages=[{\"role\": \"user\", \"content\": prompt}],\n                    max_tokens=120,\n                )\n                return response.choices[0].message.content\n            except openai.RateLimitError as error:\n                # A quota error will never clear — fail loudly instead of looping.\n                if \"insufficient_quota\" in str(error):\n                    raise RuntimeError(\"Out of quota: add billing.\") from error\n                if attempt == max_retries - 1:\n                    raise\n                retry_after = error.response.headers.get(\"retry-after\")\n                wait = float(retry_after) if retry_after else 2 ** attempt\n                print(f\"429 on '{prompt[:20]}...'  waiting {wait:.1f}s\")\n                await asyncio.sleep(wait)\n        raise RuntimeError(\"Exhausted retries\")\n\n\nasync def main() -> None:\n    prompts = [f\"One fun fact about the number {n}.\" for n in range(20)]\n    answers = await asyncio.gather(*(ask(p) for p in prompts))\n    for prompt, answer in zip(prompts, answers):\n        print(f\"Q: {prompt}\\nA: {answer}\\n\")\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n",[18,85305,85306,85312,85318,85328,85338,85342,85349,85353,85361,85376,85380,85384,85410,85415,85424,85436,85442,85453,85464,85485,85496,85500,85510,85520,85525,85539,85557,85573,85578,85591,85613,85650,85658,85671,85675,85679,85693,85728,85752,85767,85798,85802,85806,85818],{"__ignoreMap":258},[262,85307,85308,85310],{"class":181,"line":264},[262,85309,684],{"class":377},[262,85311,84898],{"class":429},[262,85313,85314,85316],{"class":181,"line":282},[262,85315,684],{"class":377},[262,85317,29032],{"class":429},[262,85319,85320,85322,85324,85326],{"class":181,"line":295},[262,85321,705],{"class":377},[262,85323,720],{"class":429},[262,85325,684],{"class":377},[262,85327,84909],{"class":429},[262,85329,85330,85332,85334,85336],{"class":181,"line":345},[262,85331,705],{"class":377},[262,85333,708],{"class":429},[262,85335,684],{"class":377},[262,85337,713],{"class":429},[262,85339,85340],{"class":181,"line":492},[262,85341,583],{"emptyLinePlaceholder":582},[262,85343,85344,85346],{"class":181,"line":503},[262,85345,4222],{"class":429},[262,85347,85348],{"class":291},"# remember: add .env to your .gitignore\n",[262,85350,85351],{"class":181,"line":521},[262,85352,583],{"emptyLinePlaceholder":582},[262,85354,85355,85357,85359],{"class":181,"line":537},[262,85356,739],{"class":429},[262,85358,476],{"class":377},[262,85360,84922],{"class":429},[262,85362,85363,85365,85367,85369,85371,85373],{"class":181,"line":549},[262,85364,84927],{"class":429},[262,85366,476],{"class":377},[262,85368,84932],{"class":429},[262,85370,222],{"class":271},[262,85372,32223],{"class":429},[262,85374,85375],{"class":291},"# cap concurrent calls\n",[262,85377,85378],{"class":181,"line":570},[262,85379,583],{"emptyLinePlaceholder":582},[262,85381,85382],{"class":181,"line":579},[262,85383,583],{"emptyLinePlaceholder":582},[262,85385,85386,85388,85390,85392,85394,85396,85398,85400,85402,85404,85406,85408],{"class":181,"line":586},[262,85387,55039],{"class":377},[262,85389,55042],{"class":377},[262,85391,44066],{"class":267},[262,85393,9599],{"class":429},[262,85395,433],{"class":271},[262,85397,3007],{"class":429},[262,85399,439],{"class":271},[262,85401,442],{"class":377},[262,85403,43778],{"class":271},[262,85405,1939],{"class":429},[262,85407,433],{"class":271},[262,85409,1160],{"class":429},[262,85411,85412],{"class":181,"line":591},[262,85413,85414],{"class":275},"    \"\"\"Call the API with backoff that honours Retry-After.\"\"\"\n",[262,85416,85417,85419,85421],{"class":181,"line":623},[262,85418,84971],{"class":377},[262,85420,84974],{"class":377},[262,85422,85423],{"class":429}," semaphore:\n",[262,85425,85426,85428,85430,85432,85434],{"class":181,"line":634},[262,85427,10155],{"class":377},[262,85429,3077],{"class":429},[262,85431,835],{"class":377},[262,85433,3082],{"class":271},[262,85435,3085],{"class":429},[262,85437,85438,85440],{"class":181,"line":845},[262,85439,10240],{"class":377},[262,85441,1160],{"class":429},[262,85443,85444,85447,85449,85451],{"class":181,"line":850},[262,85445,85446],{"class":429},"                response ",[262,85448,476],{"class":377},[262,85450,55061],{"class":377},[262,85452,1189],{"class":429},[262,85454,85455,85458,85460,85462],{"class":181,"line":864},[262,85456,85457],{"class":611},"                    model",[262,85459,476],{"class":377},[262,85461,1207],{"class":275},[262,85463,1315],{"class":429},[262,85465,85466,85469,85471,85473,85475,85477,85479,85481,85483],{"class":181,"line":1683},[262,85467,85468],{"class":611},"                    messages",[262,85470,476],{"class":377},[262,85472,8856],{"class":429},[262,85474,1228],{"class":275},[262,85476,1231],{"class":429},[262,85478,1291],{"class":275},[262,85480,608],{"class":429},[262,85482,1239],{"class":275},[262,85484,18141],{"class":429},[262,85486,85487,85490,85492,85494],{"class":181,"line":1688},[262,85488,85489],{"class":611},"                    max_tokens",[262,85491,476],{"class":377},[262,85493,7101],{"class":271},[262,85495,1315],{"class":429},[262,85497,85498],{"class":181,"line":1693},[262,85499,16134],{"class":429},[262,85501,85502,85504,85506,85508],{"class":181,"line":1728},[262,85503,63991],{"class":377},[262,85505,1326],{"class":429},[262,85507,102],{"class":271},[262,85509,1331],{"class":429},[262,85511,85512,85514,85516,85518],{"class":181,"line":1737},[262,85513,10358],{"class":377},[262,85515,83800],{"class":429},[262,85517,697],{"class":377},[262,85519,14529],{"class":429},[262,85521,85522],{"class":181,"line":1751},[262,85523,85524],{"class":291},"                # A quota error will never clear — fail loudly instead of looping.\n",[262,85526,85527,85529,85532,85534,85536],{"class":181,"line":1764},[262,85528,16139],{"class":377},[262,85530,85531],{"class":275}," \"insufficient_quota\"",[262,85533,2821],{"class":377},[262,85535,3457],{"class":271},[262,85537,85538],{"class":429},"(error):\n",[262,85540,85541,85543,85545,85547,85550,85552,85554],{"class":181,"line":1779},[262,85542,16149],{"class":377},[262,85544,3318],{"class":271},[262,85546,602],{"class":429},[262,85548,85549],{"class":275},"\"Out of quota: add billing.\"",[262,85551,1000],{"class":429},[262,85553,705],{"class":377},[262,85555,85556],{"class":429}," error\n",[262,85558,85559,85561,85563,85565,85567,85569,85571],{"class":181,"line":1793},[262,85560,16139],{"class":377},[262,85562,3077],{"class":429},[262,85564,10758],{"class":377},[262,85566,70571],{"class":429},[262,85568,561],{"class":377},[262,85570,3243],{"class":271},[262,85572,1160],{"class":429},[262,85574,85575],{"class":181,"line":1800},[262,85576,85577],{"class":377},"                    raise\n",[262,85579,85580,85583,85585,85587,85589],{"class":181,"line":1805},[262,85581,85582],{"class":429},"                retry_after ",[262,85584,476],{"class":377},[262,85586,84711],{"class":429},[262,85588,84714],{"class":275},[262,85590,660],{"class":429},[262,85592,85593,85595,85597,85599,85601,85603,85605,85607,85609,85611],{"class":181,"line":1810},[262,85594,42591],{"class":429},[262,85596,476],{"class":377},[262,85598,84725],{"class":271},[262,85600,84728],{"class":429},[262,85602,2210],{"class":377},[262,85604,84733],{"class":429},[262,85606,20859],{"class":377},[262,85608,3232],{"class":271},[262,85610,3235],{"class":377},[262,85612,84742],{"class":429},[262,85614,85615,85617,85619,85621,85624,85626,85629,85631,85633,85635,85638,85640,85642,85644,85646,85648],{"class":181,"line":1823},[262,85616,10208],{"class":271},[262,85618,602],{"class":429},[262,85620,642],{"class":377},[262,85622,85623],{"class":275},"\"429 on '",[262,85625,3039],{"class":271},[262,85627,85628],{"class":429},"prompt[:",[262,85630,140],{"class":271},[262,85632,6223],{"class":429},[262,85634,654],{"class":271},[262,85636,85637],{"class":275},"...'  waiting ",[262,85639,3039],{"class":271},[262,85641,3295],{"class":429},[262,85643,84760],{"class":377},[262,85645,654],{"class":271},[262,85647,42628],{"class":275},[262,85649,660],{"class":429},[262,85651,85652,85655],{"class":181,"line":1846},[262,85653,85654],{"class":377},"                await",[262,85656,85657],{"class":429}," asyncio.sleep(wait)\n",[262,85659,85660,85662,85664,85666,85669],{"class":181,"line":1861},[262,85661,4928],{"class":377},[262,85663,3318],{"class":271},[262,85665,602],{"class":429},[262,85667,85668],{"class":275},"\"Exhausted retries\"",[262,85670,660],{"class":429},[262,85672,85673],{"class":181,"line":1866},[262,85674,583],{"emptyLinePlaceholder":582},[262,85676,85677],{"class":181,"line":1871},[262,85678,583],{"emptyLinePlaceholder":582},[262,85680,85681,85683,85685,85687,85689,85691],{"class":181,"line":1890},[262,85682,55039],{"class":377},[262,85684,55042],{"class":377},[262,85686,23929],{"class":267},[262,85688,15481],{"class":429},[262,85690,8471],{"class":271},[262,85692,1160],{"class":429},[262,85694,85695,85697,85699,85701,85703,85706,85708,85710,85712,85714,85716,85718,85720,85722,85724,85726],{"class":181,"line":1909},[262,85696,85071],{"class":429},[262,85698,476],{"class":377},[262,85700,10563],{"class":429},[262,85702,642],{"class":377},[262,85704,85705],{"class":275},"\"One fun fact about the number ",[262,85707,3039],{"class":271},[262,85709,10895],{"class":429},[262,85711,654],{"class":271},[262,85713,65566],{"class":275},[262,85715,10739],{"class":377},[262,85717,33527],{"class":429},[262,85719,835],{"class":377},[262,85721,3082],{"class":271},[262,85723,602],{"class":429},[262,85725,140],{"class":271},[262,85727,18503],{"class":429},[262,85729,85730,85733,85735,85737,85739,85741,85744,85746,85748,85750],{"class":181,"line":1914},[262,85731,85732],{"class":429},"    answers ",[262,85734,476],{"class":377},[262,85736,55061],{"class":377},[262,85738,85113],{"class":429},[262,85740,1003],{"class":377},[262,85742,85743],{"class":429},"(ask(p) ",[262,85745,829],{"class":377},[262,85747,40432],{"class":429},[262,85749,835],{"class":377},[262,85751,85127],{"class":429},[262,85753,85754,85756,85759,85761,85764],{"class":181,"line":1919},[262,85755,3074],{"class":377},[262,85757,85758],{"class":429}," prompt, answer ",[262,85760,835],{"class":377},[262,85762,85763],{"class":271}," zip",[262,85765,85766],{"class":429},"(prompts, answers):\n",[262,85768,85769,85771,85773,85775,85778,85780,85782,85784,85787,85789,85792,85794,85796],{"class":181,"line":1946},[262,85770,2299],{"class":271},[262,85772,602],{"class":429},[262,85774,642],{"class":377},[262,85776,85777],{"class":275},"\"Q: ",[262,85779,3039],{"class":271},[262,85781,9496],{"class":429},[262,85783,3044],{"class":271},[262,85785,85786],{"class":275},"A: ",[262,85788,3039],{"class":271},[262,85790,85791],{"class":429},"answer",[262,85793,3044],{"class":271},[262,85795,1176],{"class":275},[262,85797,660],{"class":429},[262,85799,85800],{"class":181,"line":1959},[262,85801,583],{"emptyLinePlaceholder":582},[262,85803,85804],{"class":181,"line":1996},[262,85805,583],{"emptyLinePlaceholder":582},[262,85807,85808,85810,85812,85814,85816],{"class":181,"line":2012},[262,85809,2210],{"class":377},[262,85811,2213],{"class":271},[262,85813,2216],{"class":377},[262,85815,2219],{"class":275},[262,85817,1160],{"class":429},[262,85819,85820],{"class":181,"line":2040},[262,85821,85822],{"class":429},"    asyncio.run(main())\n",[14,85824,85825,85826,85828],{},"Run it and you will see most calls succeed instantly, the occasional ",[18,85827,59190],{}," get absorbed by a short wait, and quota errors stop the program cleanly with an actionable message.",[57,85830,2317],{"id":2316},[2322,85832,85833,85839,85851],{},[1450,85834,85835,85838],{},[35,85836,85837],{},"Use client-side backoff (this guide)"," when you are a single user or a small script and just need calls to stop crashing on bursts. It is the right tool for almost every beginner case.",[1450,85840,85841,85844,85845,85848,85849,1363],{},[35,85842,85843],{},"Use a server-side rate limiter"," when you are building an app that exposes AI features to ",[27,85846,85847],{},"your own"," users and need to throttle each of them fairly. That is a different problem — see ",[51,85850,49599],{"href":49598},[1450,85852,85853,85856],{},[35,85854,85855],{},"Raise your limit instead of retrying"," when you consistently need more throughput than backoff can buy you. Upgrade your usage tier in the provider dashboard rather than fighting the ceiling in code.",[14,85858,2375,85859,1363],{},[51,85860,2487],{"href":2486},[57,85862,2381],{"id":2380},[2322,85864,85865,85870,85875,85880,85885],{},[1450,85866,85867,85869],{},[51,85868,2487],{"href":2486}," — the main guide for working with AI APIs in Python.",[1450,85871,85872,85874],{},[51,85873,388],{"href":387}," — when the problem is your key, not your rate.",[1450,85876,85877,85879],{},[51,85878,6114],{"href":6113}," — handle malformed or non-JSON responses.",[1450,85881,85882,85884],{},[51,85883,1513],{"href":1512}," — shrink prompts that blow the token budget.",[1450,85886,85887,85889],{},[51,85888,49599],{"href":49598}," — throttle your own users server-side.",[2401,85891,85892],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":258,"searchDepth":282,"depth":282,"links":85894},[85895,85896,85897,85898,85899,85900,85901,85902,85903,85904,85905],{"id":83668,"depth":282,"text":83669},{"id":83320,"depth":282,"text":83852},{"id":83960,"depth":282,"text":83961},{"id":84228,"depth":282,"text":84229},{"id":84513,"depth":282,"text":84514},{"id":84794,"depth":282,"text":84795},{"id":24066,"depth":282,"text":85167},{"id":1444,"depth":282,"text":1445},{"id":85290,"depth":282,"text":85291},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Diagnose and fix RateLimitError (HTTP 429) from AI APIs in Python: exponential backoff, Retry-After, batching, and lower concurrency. Runnable code.",[85908,85911,85914,85917,85920],{"q":85909,"a":85910},"What does the 429 error mean in Python?","HTTP 429 means 'Too Many Requests'. The AI provider accepted your request but refused to process it because you exceeded a limit: requests per minute, tokens per minute, or your spending quota. It is a temporary signal to slow down, not a sign your code is broken.",{"q":85912,"a":85913},"How do I fix RateLimitError from the OpenAI Python SDK?","Wrap the API call in a retry loop that waits longer after each failure (exponential backoff). Read the Retry-After header if the response includes one, and respect it. If the error mentions quota or billing, the fix is adding payment details rather than retrying.",{"q":85915,"a":85916},"What is the difference between a rate limit and a quota?","A rate limit caps how fast you send requests or tokens in a short window, like 3,500 requests per minute. A quota caps your total spending or usage over a billing period. A rate limit clears in seconds; a quota only clears when you raise your limit or your billing cycle resets.",{"q":85918,"a":85919},"Does exponential backoff slow down my whole program?","Only when you are actually being rate-limited. On a successful first attempt there is no delay at all. Backoff just inserts growing pauses between failed retries, which is far faster overall than crashing and restarting your script.",{"q":85921,"a":85922},"Why do I get 429 errors with very few requests?","The most common reason is hitting the tokens-per-minute limit rather than the requests-per-minute limit. A single long prompt or a large max_tokens setting can consume your whole token budget in one call. Lower max_tokens or shorten your input to fix it.",{"name":85924,"steps":85925},"How to fix the 429 rate-limit error in Python",[85926,85929,85932,85935],{"name":85927,"text":85928},"Read the exact error","Print the full RateLimitError message to learn whether you hit a request limit, a token limit, or a billing quota.",{"name":85930,"text":85931},"Add exponential backoff with retries","Wrap the API call in a retry loop that waits longer after each failure using tenacity or a manual loop.",{"name":85933,"text":85934},"Respect the Retry-After header","When the response includes a Retry-After value, sleep for exactly that long instead of guessing.",{"name":85936,"text":85937},"Batch requests and lower concurrency","Combine work into fewer calls and reduce how many requests run at the same time to stay under the per-minute ceiling.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-429-rate-limit-error-in-python",{"title":3379,"description":85906},"python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-429-rate-limit-error-in-python\u002Findex","HkCX6Hox9HAPKw1Htyr5-tm4wN--vpScq8kUFpRu_ho",{"id":85944,"title":1513,"body":85945,"description":87650,"extension":2419,"faq":87651,"howto":87667,"meta":87685,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":87686,"published":2452,"seo":87687,"seoTitle":87688,"stem":87689,"__hash__":87690},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-context-length-exceeded-error-in-python\u002Findex.md",{"type":7,"value":85946,"toc":87635},[85947,85950,85953,85959,85965,85969,85978,85984,85999,86003,86008,86011,86028,86031,86037,86042,86046,86109,86111,86123,86159,86164,86172,86186,86194,86198,86204,86465,86468,86472,86483,86736,86739,86743,86746,87094,87099,87103,87113,87287,87293,87297,87303,87484,87491,87495,87535,87537,87584,87586,87605,87609,87611,87633],[10,85948,1513],{"id":85949},"fix-the-context-length-exceeded-error-in-python",[14,85951,85952],{},"This guide shows you how to fix the \"maximum context length exceeded\" error in Python in under fifteen minutes, with runnable code for every fix.",[14,85954,85955,85956,85958],{},"You sent a long document, a fat chat history, or a big batch of text to a model, and instead of an answer you got a red wall of text complaining about a context length. The fix is never mysterious once you understand one rule: a model can only hold a fixed number of ",[35,85957,50685],{}," (small chunks of text, roughly three-quarters of a word each) at one time, and that budget covers both what you send and what you ask it to write back. Go over the budget and the request is refused before the model even starts.",[14,85960,85961,85962,85964],{},"This is one of the ",[51,85963,2487],{"href":2486}," guides, written for creators, marketers, founders, and students who can run a Python file but have never had to manage token budgets by hand. By the end you will measure tokens precisely, cut your input down to fit, and pick the right model so the error stops coming back.",[57,85966,85968],{"id":85967},"the-exact-error-you-are-seeing","The exact error you are seeing",[14,85970,85971,85972,85974,85975,85977],{},"When the total goes over the limit, the ",[18,85973,20],{}," SDK raises a ",[18,85976,9945],{},". The message looks like this:",[253,85979,85982],{"className":85980,"code":85981,"language":111,"meta":258},[2577],"openai.BadRequestError: Error code: 400 - {'error': {'message': \"This model's\nmaximum context length is 16385 tokens. However, your messages resulted in\n17421 tokens. Please reduce the length of the messages.\", 'type':\n'invalid_request_error', 'param': 'messages', 'code': 'context_length_exceeded'}}\n",[18,85983,85981],{"__ignoreMap":258},[14,85985,85986,85987,85990,85991,85994,85995,85998],{},"The two numbers are everything. ",[35,85988,85989],{},"16385"," is the model's window — the total token budget. ",[35,85992,85993],{},"17421"," is what you sent. You are 1,036 tokens over, so you need to free at least that much, plus enough room for the reply. The error code ",[18,85996,85997],{},"context_length_exceeded"," is the machine-readable name for exactly this problem.",[57,86000,86002],{"id":86001},"how-tokens-and-context-windows-work","How tokens and context windows work",[14,86004,16693,86005,86007],{},[35,86006,43284],{}," is the maximum number of tokens a model can read and write in a single call. Think of it as a fixed-size table: your prompt, the system instructions, the chat history, and the reply the model generates all have to sit on that table at once. Nothing spills over the edge.",[14,86009,86010],{},"The total breaks into two parts:",[2322,86012,86013,86019],{},[1450,86014,86015,86018],{},[35,86016,86017],{},"Input tokens"," — your system message, user message, and any conversation history. The provider counts these before the model runs.",[1450,86020,86021,86024,86025,86027],{},[35,86022,86023],{},"Output tokens"," — the reply, capped by the ",[18,86026,3846],{}," value you set. The provider reserves this much space in advance.",[14,86029,86030],{},"The rule the model enforces is simple:",[253,86032,86035],{"className":86033,"code":86034,"language":111,"meta":258},[2577],"input_tokens + max_tokens  must be  \u003C=  context_window\n",[18,86036,86034],{"__ignoreMap":258},[14,86038,17552,86039,86041],{},[18,86040,3846],{}," is large, it eats into the room left for input. That is why the same prompt can succeed with a short reply cap and fail with a long one. Every fix below is just a way to make one side of that inequality smaller.",[57,86043,86045],{"id":86044},"quick-reference-cause-to-fix","Quick reference: cause to fix",[1379,86047,86048,86060],{},[1382,86049,86050],{},[1385,86051,86052,86055,86058],{},[1388,86053,86054],{},"What is too big",[1388,86056,86057],{},"Why it happens",[1388,86059,83866],{},[1398,86061,86062,86073,86084,86098],{},[1385,86063,86064,86067,86070],{},[1403,86065,86066],{},"Input alone over the window",[1403,86068,86069],{},"One huge document or file pasted into the prompt",[1403,86071,86072],{},"Chunk the document and process pieces",[1385,86074,86075,86078,86081],{},[1403,86076,86077],{},"Growing chat history",[1403,86079,86080],{},"Every turn is appended, so it never shrinks",[1403,86082,86083],{},"Trim old turns or summarize the history",[1385,86085,86086,86091,86094],{},[1403,86087,86088,86090],{},[18,86089,3846],{}," set too high",[1403,86092,86093],{},"Reply cap reserves more space than is free",[1403,86095,83895,86096],{},[18,86097,3846],{},[1385,86099,86100,86103,86106],{},[1403,86101,86102],{},"Input near the limit on a small model",[1403,86104,86105],{},"Model window is only a few thousand tokens",[1403,86107,86108],{},"Switch to a larger-context model",[57,86110,238],{"id":237},[14,86112,86113,86114,86116,86117,86119,86120,86122],{},"You only need the ",[18,86115,20],{}," SDK, the ",[18,86118,42947],{}," token counter, and ",[18,86121,2501],{}," for your key. Confirm you are on Python 3.10 or newer, then install:",[253,86124,86126],{"className":255,"code":86125,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\npip install \"openai>=1.40\" \"tiktoken>=0.7\" \"python-dotenv>=1.0\"\n",[18,86127,86128,86138,86146],{"__ignoreMap":258},[262,86129,86130,86132,86134,86136],{"class":181,"line":264},[262,86131,416],{"class":267},[262,86133,272],{"class":271},[262,86135,276],{"class":275},[262,86137,279],{"class":275},[262,86139,86140,86142,86144],{"class":181,"line":282},[262,86141,285],{"class":271},[262,86143,288],{"class":275},[262,86145,7222],{"class":291},[262,86147,86148,86150,86152,86154,86157],{"class":181,"line":295},[262,86149,298],{"class":267},[262,86151,301],{"class":275},[262,86153,304],{"class":275},[262,86155,86156],{"class":275}," \"tiktoken>=0.7\"",[262,86158,82477],{"class":275},[14,86160,9458,86161,86163],{},[18,86162,319],{}," file and keep it out of version control:",[253,86165,86166],{"className":323,"code":4148,"language":325,"meta":258,"style":258},[18,86167,86168],{"__ignoreMap":258},[262,86169,86170],{"class":181,"line":264},[262,86171,4148],{},[253,86173,86174],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,86175,86176],{"__ignoreMap":258},[262,86177,86178,86180,86182,86184],{"class":181,"line":264},[262,86179,371],{"class":271},[262,86181,374],{"class":275},[262,86183,378],{"class":377},[262,86185,381],{"class":275},[14,86187,61366,86188,86190,86191,86193],{},[18,86189,359],{}," line is non-negotiable — a key pushed to a public repository can be found and billed to you within minutes. If your environment is not set up yet, the ",[51,86192,2487],{"href":2486}," section covers the full installation first.",[57,86195,86197],{"id":86196},"fix-1-count-tokens-with-tiktoken-before-you-send","Fix 1: Count tokens with tiktoken before you send",[14,86199,86200,86201,86203],{},"The first move is to stop guessing. ",[18,86202,42947],{}," is OpenAI's own tokenizer, so it counts exactly the way the model does. Measure your messages before sending and you will know whether you are over the limit and by how much.",[253,86205,86207],{"className":414,"code":86206,"language":416,"meta":258,"style":258},"import tiktoken\n\n\ndef count_tokens(messages: list[dict], model: str = \"gpt-4o-mini\") -> int:\n    \"\"\"Count the tokens a list of chat messages will use.\"\"\"\n    try:\n        encoding = tiktoken.encoding_for_model(model)\n    except KeyError:\n        encoding = tiktoken.get_encoding(\"cl100k_base\")  # safe fallback\n    tokens = 0\n    for message in messages:\n        tokens += 4  # every message carries a few tokens of overhead\n        tokens += len(encoding.encode(message[\"content\"]))\n    tokens += 2  # the reply is primed with a couple of tokens\n    return tokens\n\n\nmessages = [\n    {\"role\": \"system\", \"content\": \"You summarize reports concisely.\"},\n    {\"role\": \"user\", \"content\": \"Summarize this: \" + \"lorem ipsum \" * 500},\n]\n\ninput_tokens = count_tokens(messages)\nprint(f\"Input tokens: {input_tokens}\")\n",[18,86208,86209,86215,86219,86223,86247,86252,86258,86268,86277,86293,86301,86313,86325,86340,86351,86358,86362,86366,86374,86395,86426,86430,86434,86444],{"__ignoreMap":258},[262,86210,86211,86213],{"class":181,"line":264},[262,86212,684],{"class":377},[262,86214,43304],{"class":429},[262,86216,86217],{"class":181,"line":282},[262,86218,583],{"emptyLinePlaceholder":582},[262,86220,86221],{"class":181,"line":295},[262,86222,583],{"emptyLinePlaceholder":582},[262,86224,86225,86227,86229,86231,86233,86235,86237,86239,86241,86243,86245],{"class":181,"line":345},[262,86226,423],{"class":377},[262,86228,43341],{"class":267},[262,86230,49393],{"class":429},[262,86232,5869],{"class":271},[262,86234,30988],{"class":429},[262,86236,433],{"class":271},[262,86238,442],{"class":377},[262,86240,4256],{"class":275},[262,86242,1939],{"class":429},[262,86244,439],{"class":271},[262,86246,1160],{"class":429},[262,86248,86249],{"class":181,"line":492},[262,86250,86251],{"class":275},"    \"\"\"Count the tokens a list of chat messages will use.\"\"\"\n",[262,86253,86254,86256],{"class":181,"line":503},[262,86255,14474],{"class":377},[262,86257,1160],{"class":429},[262,86259,86260,86263,86265],{"class":181,"line":521},[262,86261,86262],{"class":429},"        encoding ",[262,86264,476],{"class":377},[262,86266,86267],{"class":429}," tiktoken.encoding_for_model(model)\n",[262,86269,86270,86272,86275],{"class":181,"line":537},[262,86271,14522],{"class":377},[262,86273,86274],{"class":271}," KeyError",[262,86276,1160],{"class":429},[262,86278,86279,86281,86283,86285,86288,86290],{"class":181,"line":549},[262,86280,86262],{"class":429},[262,86282,476],{"class":377},[262,86284,43318],{"class":429},[262,86286,86287],{"class":275},"\"cl100k_base\"",[262,86289,32223],{"class":429},[262,86291,86292],{"class":291},"# safe fallback\n",[262,86294,86295,86297,86299],{"class":181,"line":570},[262,86296,60460],{"class":429},[262,86298,476],{"class":377},[262,86300,500],{"class":271},[262,86302,86303,86305,86308,86310],{"class":181,"line":579},[262,86304,3074],{"class":377},[262,86306,86307],{"class":429}," message ",[262,86309,835],{"class":377},[262,86311,86312],{"class":429}," messages:\n",[262,86314,86315,86318,86320,86322],{"class":181,"line":586},[262,86316,86317],{"class":429},"        tokens ",[262,86319,555],{"class":377},[262,86321,3014],{"class":271},[262,86323,86324],{"class":291},"  # every message carries a few tokens of overhead\n",[262,86326,86327,86329,86331,86333,86336,86338],{"class":181,"line":591},[262,86328,86317],{"class":429},[262,86330,555],{"class":377},[262,86332,515],{"class":271},[262,86334,86335],{"class":429},"(encoding.encode(message[",[262,86337,1239],{"class":275},[262,86339,15338],{"class":429},[262,86341,86342,86344,86346,86348],{"class":181,"line":623},[262,86343,60460],{"class":429},[262,86345,555],{"class":377},[262,86347,3232],{"class":271},[262,86349,86350],{"class":291},"  # the reply is primed with a couple of tokens\n",[262,86352,86353,86355],{"class":181,"line":634},[262,86354,573],{"class":377},[262,86356,86357],{"class":429}," tokens\n",[262,86359,86360],{"class":181,"line":845},[262,86361,583],{"emptyLinePlaceholder":582},[262,86363,86364],{"class":181,"line":850},[262,86365,583],{"emptyLinePlaceholder":582},[262,86367,86368,86370,86372],{"class":181,"line":864},[262,86369,43086],{"class":429},[262,86371,476],{"class":377},[262,86373,5589],{"class":429},[262,86375,86376,86378,86380,86382,86384,86386,86388,86390,86393],{"class":181,"line":1683},[262,86377,42305],{"class":429},[262,86379,1228],{"class":275},[262,86381,1231],{"class":429},[262,86383,1234],{"class":275},[262,86385,608],{"class":429},[262,86387,1239],{"class":275},[262,86389,1231],{"class":429},[262,86391,86392],{"class":275},"\"You summarize reports concisely.\"",[262,86394,3143],{"class":429},[262,86396,86397,86399,86401,86403,86405,86407,86409,86411,86414,86416,86419,86421,86424],{"class":181,"line":1688},[262,86398,42305],{"class":429},[262,86400,1228],{"class":275},[262,86402,1231],{"class":429},[262,86404,1291],{"class":275},[262,86406,608],{"class":429},[262,86408,1239],{"class":275},[262,86410,1231],{"class":429},[262,86412,86413],{"class":275},"\"Summarize this: \"",[262,86415,2142],{"class":377},[262,86417,86418],{"class":275}," \"lorem ipsum \"",[262,86420,18556],{"class":377},[262,86422,86423],{"class":271}," 500",[262,86425,3143],{"class":429},[262,86427,86428],{"class":181,"line":1693},[262,86429,957],{"class":429},[262,86431,86432],{"class":181,"line":1728},[262,86433,583],{"emptyLinePlaceholder":582},[262,86435,86436,86439,86441],{"class":181,"line":1737},[262,86437,86438],{"class":429},"input_tokens ",[262,86440,476],{"class":377},[262,86442,86443],{"class":429}," count_tokens(messages)\n",[262,86445,86446,86448,86450,86452,86455,86457,86459,86461,86463],{"class":181,"line":1751},[262,86447,637],{"class":271},[262,86449,602],{"class":429},[262,86451,642],{"class":377},[262,86453,86454],{"class":275},"\"Input tokens: ",[262,86456,3039],{"class":271},[262,86458,59493],{"class":429},[262,86460,654],{"class":271},[262,86462,1176],{"class":275},[262,86464,660],{"class":429},[14,86466,86467],{},"The per-message overhead exists because the model wraps each message in a little structure of its own. The count will not be perfect to the last token, but it is close enough to keep you safely under any window. Compare the result against your model's limit and the planned reply size before you ever make a call.",[57,86469,86471],{"id":86470},"fix-2-trim-or-chunk-the-input","Fix 2: Trim or chunk the input",[14,86473,86474,86475,86478,86479,86482],{},"If a single document is the problem, you have two choices. ",[35,86476,86477],{},"Trimming"," keeps only the part that fits. ",[35,86480,86481],{},"Chunking"," splits the document into pieces that each fit the window, processes them one at a time, then combines the results. Chunking is the right answer when you cannot afford to throw text away.",[253,86484,86486],{"className":414,"code":86485,"language":416,"meta":258,"style":258},"import tiktoken\n\nencoding = tiktoken.get_encoding(\"cl100k_base\")\n\n\ndef chunk_text(text: str, max_tokens: int = 3000) -> list[str]:\n    \"\"\"Split text into chunks that each stay under max_tokens.\"\"\"\n    tokens = encoding.encode(text)\n    chunks = []\n    for start in range(0, len(tokens), max_tokens):\n        piece = tokens[start:start + max_tokens]\n        chunks.append(encoding.decode(piece))\n    return chunks\n\n\nlong_document = \"word \" * 20000\npieces = chunk_text(long_document, max_tokens=3000)\nprint(f\"Split into {len(pieces)} chunks\")\n\nfor i, piece in enumerate(pieces, start=1):\n    print(f\"Chunk {i}: {len(encoding.encode(piece))} tokens\")\n    # send each chunk to the model separately, then combine the replies\n",[18,86487,86488,86494,86498,86511,86515,86519,86543,86548,86557,86565,86586,86601,86606,86612,86616,86620,86635,86653,86674,86678,86700,86731],{"__ignoreMap":258},[262,86489,86490,86492],{"class":181,"line":264},[262,86491,684],{"class":377},[262,86493,43304],{"class":429},[262,86495,86496],{"class":181,"line":282},[262,86497,583],{"emptyLinePlaceholder":582},[262,86499,86500,86503,86505,86507,86509],{"class":181,"line":295},[262,86501,86502],{"class":429},"encoding ",[262,86504,476],{"class":377},[262,86506,43318],{"class":429},[262,86508,86287],{"class":275},[262,86510,660],{"class":429},[262,86512,86513],{"class":181,"line":345},[262,86514,583],{"emptyLinePlaceholder":582},[262,86516,86517],{"class":181,"line":492},[262,86518,583],{"emptyLinePlaceholder":582},[262,86520,86521,86523,86525,86527,86529,86531,86533,86535,86537,86539,86541],{"class":181,"line":503},[262,86522,423],{"class":377},[262,86524,426],{"class":267},[262,86526,430],{"class":429},[262,86528,433],{"class":271},[262,86530,43762],{"class":429},[262,86532,439],{"class":271},[262,86534,442],{"class":377},[262,86536,43422],{"class":271},[262,86538,458],{"class":429},[262,86540,433],{"class":271},[262,86542,463],{"class":429},[262,86544,86545],{"class":181,"line":521},[262,86546,86547],{"class":275},"    \"\"\"Split text into chunks that each stay under max_tokens.\"\"\"\n",[262,86549,86550,86552,86554],{"class":181,"line":537},[262,86551,60460],{"class":429},[262,86553,476],{"class":377},[262,86555,86556],{"class":429}," encoding.encode(text)\n",[262,86558,86559,86561,86563],{"class":181,"line":549},[262,86560,484],{"class":429},[262,86562,476],{"class":377},[262,86564,489],{"class":429},[262,86566,86567,86569,86571,86573,86575,86577,86579,86581,86583],{"class":181,"line":570},[262,86568,3074],{"class":377},[262,86570,509],{"class":429},[262,86572,835],{"class":377},[262,86574,3082],{"class":271},[262,86576,602],{"class":429},[262,86578,102],{"class":271},[262,86580,608],{"class":429},[262,86582,29318],{"class":271},[262,86584,86585],{"class":429},"(tokens), max_tokens):\n",[262,86587,86588,86591,86593,86596,86598],{"class":181,"line":579},[262,86589,86590],{"class":429},"        piece ",[262,86592,476],{"class":377},[262,86594,86595],{"class":429}," tokens[start:start ",[262,86597,531],{"class":377},[262,86599,86600],{"class":429}," max_tokens]\n",[262,86602,86603],{"class":181,"line":586},[262,86604,86605],{"class":429},"        chunks.append(encoding.decode(piece))\n",[262,86607,86608,86610],{"class":181,"line":591},[262,86609,573],{"class":377},[262,86611,576],{"class":429},[262,86613,86614],{"class":181,"line":623},[262,86615,583],{"emptyLinePlaceholder":582},[262,86617,86618],{"class":181,"line":634},[262,86619,583],{"emptyLinePlaceholder":582},[262,86621,86622,86625,86627,86630,86632],{"class":181,"line":845},[262,86623,86624],{"class":429},"long_document ",[262,86626,476],{"class":377},[262,86628,86629],{"class":275}," \"word \"",[262,86631,18556],{"class":377},[262,86633,86634],{"class":271}," 20000\n",[262,86636,86637,86640,86642,86645,86647,86649,86651],{"class":181,"line":850},[262,86638,86639],{"class":429},"pieces ",[262,86641,476],{"class":377},[262,86643,86644],{"class":429}," chunk_text(long_document, ",[262,86646,3846],{"class":611},[262,86648,476],{"class":377},[262,86650,16417],{"class":271},[262,86652,660],{"class":429},[262,86654,86655,86657,86659,86661,86663,86665,86668,86670,86672],{"class":181,"line":864},[262,86656,637],{"class":271},[262,86658,602],{"class":429},[262,86660,642],{"class":377},[262,86662,645],{"class":275},[262,86664,648],{"class":271},[262,86666,86667],{"class":429},"(pieces)",[262,86669,654],{"class":271},[262,86671,657],{"class":275},[262,86673,660],{"class":429},[262,86675,86676],{"class":181,"line":1683},[262,86677,583],{"emptyLinePlaceholder":582},[262,86679,86680,86682,86685,86687,86689,86692,86694,86696,86698],{"class":181,"line":1688},[262,86681,829],{"class":377},[262,86683,86684],{"class":429}," i, piece ",[262,86686,835],{"class":377},[262,86688,14189],{"class":271},[262,86690,86691],{"class":429},"(pieces, ",[262,86693,14195],{"class":611},[262,86695,476],{"class":377},[262,86697,997],{"class":271},[262,86699,8192],{"class":429},[262,86701,86702,86704,86706,86708,86711,86713,86715,86717,86719,86721,86724,86726,86729],{"class":181,"line":1693},[262,86703,1089],{"class":271},[262,86705,602],{"class":429},[262,86707,642],{"class":377},[262,86709,86710],{"class":275},"\"Chunk ",[262,86712,3039],{"class":271},[262,86714,15558],{"class":429},[262,86716,654],{"class":271},[262,86718,1231],{"class":275},[262,86720,648],{"class":271},[262,86722,86723],{"class":429},"(encoding.encode(piece))",[262,86725,654],{"class":271},[262,86727,86728],{"class":275}," tokens\"",[262,86730,660],{"class":429},[262,86732,86733],{"class":181,"line":1728},[262,86734,86735],{"class":291},"    # send each chunk to the model separately, then combine the replies\n",[14,86737,86738],{},"Encoding the whole text, slicing the token list, and decoding each slice guarantees every chunk is genuinely under the limit — measured in tokens, not characters, so it is exact. Leave headroom (here 3,000 tokens out of a larger window) for the system message and the reply.",[57,86740,86742],{"id":86741},"fix-3-summarize-the-conversation-history","Fix 3: Summarize the conversation history",[14,86744,86745],{},"In a chatbot, the message list grows with every turn, so a long conversation eventually overflows the window on its own. The fix is to replace the old turns with a short summary the model writes for you, keeping the meaning while shedding most of the tokens.",[253,86747,86749],{"className":414,"code":86748,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n\ndef summarize_history(history: list[dict], keep_last: int = 4) -> list[dict]:\n    \"\"\"Replace old turns with a summary, keeping the most recent ones.\"\"\"\n    if len(history) \u003C= keep_last:\n        return history\n\n    old_turns = history[:-keep_last]\n    transcript = \"\\n\".join(f\"{m['role']}: {m['content']}\" for m in old_turns)\n\n    summary = client.chat.completions.create(\n        model=\"gpt-4o-mini\",\n        messages=[\n            {\"role\": \"system\", \"content\": \"Summarize this chat in 3 sentences.\"},\n            {\"role\": \"user\", \"content\": transcript},\n        ],\n        max_tokens=150,\n    )\n    summary_text = summary.choices[0].message.content\n\n    return [\n        {\"role\": \"system\", \"content\": f\"Earlier conversation: {summary_text}\"},\n        *history[-keep_last:],\n    ]\n",[18,86750,86751,86757,86767,86777,86781,86785,86803,86807,86811,86838,86843,86857,86864,86868,86883,86934,86938,86947,86957,86965,86986,87003,87007,87017,87021,87035,87039,87045,87077,87090],{"__ignoreMap":258},[262,86752,86753,86755],{"class":181,"line":264},[262,86754,684],{"class":377},[262,86756,687],{"class":429},[262,86758,86759,86761,86763,86765],{"class":181,"line":282},[262,86760,705],{"class":377},[262,86762,708],{"class":429},[262,86764,684],{"class":377},[262,86766,713],{"class":429},[262,86768,86769,86771,86773,86775],{"class":181,"line":295},[262,86770,705],{"class":377},[262,86772,720],{"class":429},[262,86774,684],{"class":377},[262,86776,725],{"class":429},[262,86778,86779],{"class":181,"line":345},[262,86780,583],{"emptyLinePlaceholder":582},[262,86782,86783],{"class":181,"line":492},[262,86784,734],{"class":429},[262,86786,86787,86789,86791,86793,86795,86797,86799,86801],{"class":181,"line":503},[262,86788,739],{"class":429},[262,86790,476],{"class":377},[262,86792,1588],{"class":429},[262,86794,2674],{"class":611},[262,86796,476],{"class":377},[262,86798,1199],{"class":429},[262,86800,2681],{"class":275},[262,86802,2684],{"class":429},[262,86804,86805],{"class":181,"line":521},[262,86806,583],{"emptyLinePlaceholder":582},[262,86808,86809],{"class":181,"line":537},[262,86810,583],{"emptyLinePlaceholder":582},[262,86812,86813,86815,86818,86821,86823,86826,86828,86830,86832,86834,86836],{"class":181,"line":549},[262,86814,423],{"class":377},[262,86816,86817],{"class":267}," summarize_history",[262,86819,86820],{"class":429},"(history: list[",[262,86822,5869],{"class":271},[262,86824,86825],{"class":429},"], keep_last: ",[262,86827,439],{"class":271},[262,86829,442],{"class":377},[262,86831,3014],{"class":271},[262,86833,458],{"class":429},[262,86835,5869],{"class":271},[262,86837,463],{"class":429},[262,86839,86840],{"class":181,"line":570},[262,86841,86842],{"class":275},"    \"\"\"Replace old turns with a summary, keeping the most recent ones.\"\"\"\n",[262,86844,86845,86847,86849,86852,86854],{"class":181,"line":579},[262,86846,3454],{"class":377},[262,86848,515],{"class":271},[262,86850,86851],{"class":429},"(history) ",[262,86853,8983],{"class":377},[262,86855,86856],{"class":429}," keep_last:\n",[262,86858,86859,86861],{"class":181,"line":586},[262,86860,8066],{"class":377},[262,86862,86863],{"class":429}," history\n",[262,86865,86866],{"class":181,"line":591},[262,86867,583],{"emptyLinePlaceholder":582},[262,86869,86870,86873,86875,86878,86880],{"class":181,"line":623},[262,86871,86872],{"class":429},"    old_turns ",[262,86874,476],{"class":377},[262,86876,86877],{"class":429}," history[:",[262,86879,561],{"class":377},[262,86881,86882],{"class":429},"keep_last]\n",[262,86884,86885,86887,86889,86891,86893,86895,86897,86899,86901,86903,86905,86907,86909,86911,86913,86915,86917,86919,86921,86923,86925,86927,86929,86931],{"class":181,"line":634},[262,86886,40084],{"class":429},[262,86888,476],{"class":377},[262,86890,1170],{"class":275},[262,86892,2137],{"class":271},[262,86894,1176],{"class":275},[262,86896,2023],{"class":429},[262,86898,642],{"class":377},[262,86900,1176],{"class":275},[262,86902,3039],{"class":271},[262,86904,43573],{"class":429},[262,86906,43576],{"class":275},[262,86908,6223],{"class":429},[262,86910,654],{"class":271},[262,86912,1231],{"class":275},[262,86914,3039],{"class":271},[262,86916,43573],{"class":429},[262,86918,43589],{"class":275},[262,86920,6223],{"class":429},[262,86922,654],{"class":271},[262,86924,1176],{"class":275},[262,86926,10739],{"class":377},[262,86928,43388],{"class":429},[262,86930,835],{"class":377},[262,86932,86933],{"class":429}," old_turns)\n",[262,86935,86936],{"class":181,"line":845},[262,86937,583],{"emptyLinePlaceholder":582},[262,86939,86940,86943,86945],{"class":181,"line":850},[262,86941,86942],{"class":429},"    summary ",[262,86944,476],{"class":377},[262,86946,1189],{"class":429},[262,86948,86949,86951,86953,86955],{"class":181,"line":864},[262,86950,1194],{"class":611},[262,86952,476],{"class":377},[262,86954,1207],{"class":275},[262,86956,1315],{"class":429},[262,86958,86959,86961,86963],{"class":181,"line":1683},[262,86960,1215],{"class":611},[262,86962,476],{"class":377},[262,86964,1220],{"class":429},[262,86966,86967,86969,86971,86973,86975,86977,86979,86981,86984],{"class":181,"line":1688},[262,86968,1225],{"class":429},[262,86970,1228],{"class":275},[262,86972,1231],{"class":429},[262,86974,1234],{"class":275},[262,86976,608],{"class":429},[262,86978,1239],{"class":275},[262,86980,1231],{"class":429},[262,86982,86983],{"class":275},"\"Summarize this chat in 3 sentences.\"",[262,86985,3143],{"class":429},[262,86987,86988,86990,86992,86994,86996,86998,87000],{"class":181,"line":1693},[262,86989,1225],{"class":429},[262,86991,1228],{"class":275},[262,86993,1231],{"class":429},[262,86995,1291],{"class":275},[262,86997,608],{"class":429},[262,86999,1239],{"class":275},[262,87001,87002],{"class":429},": transcript},\n",[262,87004,87005],{"class":181,"line":1728},[262,87006,1303],{"class":429},[262,87008,87009,87011,87013,87015],{"class":181,"line":1737},[262,87010,4679],{"class":611},[262,87012,476],{"class":377},[262,87014,12809],{"class":271},[262,87016,1315],{"class":429},[262,87018,87019],{"class":181,"line":1751},[262,87020,1011],{"class":429},[262,87022,87023,87026,87028,87031,87033],{"class":181,"line":1764},[262,87024,87025],{"class":429},"    summary_text ",[262,87027,476],{"class":377},[262,87029,87030],{"class":429}," summary.choices[",[262,87032,102],{"class":271},[262,87034,1331],{"class":429},[262,87036,87037],{"class":181,"line":1779},[262,87038,583],{"emptyLinePlaceholder":582},[262,87040,87041,87043],{"class":181,"line":1793},[262,87042,573],{"class":377},[262,87044,5589],{"class":429},[262,87046,87047,87049,87051,87053,87055,87057,87059,87061,87063,87066,87068,87071,87073,87075],{"class":181,"line":1800},[262,87048,7726],{"class":429},[262,87050,1228],{"class":275},[262,87052,1231],{"class":429},[262,87054,1234],{"class":275},[262,87056,608],{"class":429},[262,87058,1239],{"class":275},[262,87060,1231],{"class":429},[262,87062,642],{"class":377},[262,87064,87065],{"class":275},"\"Earlier conversation: ",[262,87067,3039],{"class":271},[262,87069,87070],{"class":429},"summary_text",[262,87072,654],{"class":271},[262,87074,1176],{"class":275},[262,87076,3143],{"class":429},[262,87078,87079,87082,87085,87087],{"class":181,"line":1805},[262,87080,87081],{"class":377},"        *",[262,87083,87084],{"class":429},"history[",[262,87086,561],{"class":377},[262,87088,87089],{"class":429},"keep_last:],\n",[262,87091,87092],{"class":181,"line":1810},[262,87093,7761],{"class":429},[14,87095,87096,87097,1363],{},"This keeps the most recent turns verbatim, where detail matters most, and compresses everything older into three sentences. The conversation can run indefinitely without the token count creeping upward. If you are building a chatbot, the deeper version of this pattern lives in ",[51,87098,2367],{"href":2366},[57,87100,87102],{"id":87101},"fix-4-lower-max_tokens-to-reserve-less-reply-space","Fix 4: Lower max_tokens to reserve less reply space",[14,87104,87105,87106,87109,87110,87112],{},"Remember the rule: ",[18,87107,87108],{},"input_tokens + max_tokens"," must fit the window. When ",[18,87111,3846],{}," is set high \"just in case,\" it reserves space your input could be using. If your reply genuinely needs only a few hundred tokens, cap it there and free the rest for input.",[253,87114,87116],{"className":414,"code":87115,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\"role\": \"system\", \"content\": \"You write one-line summaries.\"},\n        {\"role\": \"user\", \"content\": \"Summarize: \" + \"data \" * 1000},\n    ],\n    max_tokens=60,   # a one-line reply needs little space; free the rest for input\n)\n\nprint(response.choices[0].message.content)\n",[18,87117,87118,87124,87134,87144,87148,87152,87170,87174,87182,87192,87200,87221,87251,87255,87269,87273,87277],{"__ignoreMap":258},[262,87119,87120,87122],{"class":181,"line":264},[262,87121,684],{"class":377},[262,87123,687],{"class":429},[262,87125,87126,87128,87130,87132],{"class":181,"line":282},[262,87127,705],{"class":377},[262,87129,708],{"class":429},[262,87131,684],{"class":377},[262,87133,713],{"class":429},[262,87135,87136,87138,87140,87142],{"class":181,"line":295},[262,87137,705],{"class":377},[262,87139,720],{"class":429},[262,87141,684],{"class":377},[262,87143,725],{"class":429},[262,87145,87146],{"class":181,"line":345},[262,87147,583],{"emptyLinePlaceholder":582},[262,87149,87150],{"class":181,"line":492},[262,87151,734],{"class":429},[262,87153,87154,87156,87158,87160,87162,87164,87166,87168],{"class":181,"line":503},[262,87155,739],{"class":429},[262,87157,476],{"class":377},[262,87159,1588],{"class":429},[262,87161,2674],{"class":611},[262,87163,476],{"class":377},[262,87165,1199],{"class":429},[262,87167,2681],{"class":275},[262,87169,2684],{"class":429},[262,87171,87172],{"class":181,"line":521},[262,87173,583],{"emptyLinePlaceholder":582},[262,87175,87176,87178,87180],{"class":181,"line":537},[262,87177,48362],{"class":429},[262,87179,476],{"class":377},[262,87181,1189],{"class":429},[262,87183,87184,87186,87188,87190],{"class":181,"line":549},[262,87185,48371],{"class":611},[262,87187,476],{"class":377},[262,87189,1207],{"class":275},[262,87191,1315],{"class":429},[262,87193,87194,87196,87198],{"class":181,"line":570},[262,87195,48388],{"class":611},[262,87197,476],{"class":377},[262,87199,1220],{"class":429},[262,87201,87202,87204,87206,87208,87210,87212,87214,87216,87219],{"class":181,"line":579},[262,87203,7726],{"class":429},[262,87205,1228],{"class":275},[262,87207,1231],{"class":429},[262,87209,1234],{"class":275},[262,87211,608],{"class":429},[262,87213,1239],{"class":275},[262,87215,1231],{"class":429},[262,87217,87218],{"class":275},"\"You write one-line summaries.\"",[262,87220,3143],{"class":429},[262,87222,87223,87225,87227,87229,87231,87233,87235,87237,87240,87242,87245,87247,87249],{"class":181,"line":586},[262,87224,7726],{"class":429},[262,87226,1228],{"class":275},[262,87228,1231],{"class":429},[262,87230,1291],{"class":275},[262,87232,608],{"class":429},[262,87234,1239],{"class":275},[262,87236,1231],{"class":429},[262,87238,87239],{"class":275},"\"Summarize: \"",[262,87241,2142],{"class":377},[262,87243,87244],{"class":275}," \"data \"",[262,87246,18556],{"class":377},[262,87248,31055],{"class":271},[262,87250,3143],{"class":429},[262,87252,87253],{"class":181,"line":591},[262,87254,48439],{"class":429},[262,87256,87257,87259,87261,87263,87266],{"class":181,"line":623},[262,87258,77660],{"class":611},[262,87260,476],{"class":377},[262,87262,12826],{"class":271},[262,87264,87265],{"class":429},",   ",[262,87267,87268],{"class":291},"# a one-line reply needs little space; free the rest for input\n",[262,87270,87271],{"class":181,"line":634},[262,87272,660],{"class":429},[262,87274,87275],{"class":181,"line":845},[262,87276,583],{"emptyLinePlaceholder":582},[262,87278,87279,87281,87283,87285],{"class":181,"line":850},[262,87280,637],{"class":271},[262,87282,48465],{"class":429},[262,87284,102],{"class":271},[262,87286,6048],{"class":429},[14,87288,87289,87290,87292],{},"This only helps when the input itself fits. If your prompt alone already overflows the window, no ",[18,87291,3846],{}," value will save you — go back to Fix 2 or Fix 3 to shrink the input first.",[57,87294,87296],{"id":87295},"fix-5-choose-a-larger-context-model","Fix 5: Choose a larger-context model",[14,87298,87299,87300,87302],{},"Some inputs are simply large and should not be cut. A long contract, a full transcript, or a research paper may need to be seen whole. The cleanest fix there is a model with a bigger window. A small model might hold around 16,000 tokens; a larger one such as ",[18,87301,3821],{}," holds about 128,000 — roughly eight times the room.",[253,87304,87306],{"className":414,"code":87305,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\n# A 128k-token window absorbs far more input in a single call.\nresponse = client.chat.completions.create(\n    model=\"gpt-4o\",\n    messages=[\n        {\"role\": \"system\", \"content\": \"You answer questions about documents.\"},\n        {\"role\": \"user\", \"content\": \"Here is a long report:\\n\" + \"fact \" * 30000},\n    ],\n    max_tokens=500,\n)\n\nprint(response.choices[0].message.content)\n",[18,87307,87308,87314,87324,87334,87338,87342,87360,87364,87369,87377,87388,87396,87417,87452,87456,87466,87470,87474],{"__ignoreMap":258},[262,87309,87310,87312],{"class":181,"line":264},[262,87311,684],{"class":377},[262,87313,687],{"class":429},[262,87315,87316,87318,87320,87322],{"class":181,"line":282},[262,87317,705],{"class":377},[262,87319,708],{"class":429},[262,87321,684],{"class":377},[262,87323,713],{"class":429},[262,87325,87326,87328,87330,87332],{"class":181,"line":295},[262,87327,705],{"class":377},[262,87329,720],{"class":429},[262,87331,684],{"class":377},[262,87333,725],{"class":429},[262,87335,87336],{"class":181,"line":345},[262,87337,583],{"emptyLinePlaceholder":582},[262,87339,87340],{"class":181,"line":492},[262,87341,734],{"class":429},[262,87343,87344,87346,87348,87350,87352,87354,87356,87358],{"class":181,"line":503},[262,87345,739],{"class":429},[262,87347,476],{"class":377},[262,87349,1588],{"class":429},[262,87351,2674],{"class":611},[262,87353,476],{"class":377},[262,87355,1199],{"class":429},[262,87357,2681],{"class":275},[262,87359,2684],{"class":429},[262,87361,87362],{"class":181,"line":521},[262,87363,583],{"emptyLinePlaceholder":582},[262,87365,87366],{"class":181,"line":537},[262,87367,87368],{"class":291},"# A 128k-token window absorbs far more input in a single call.\n",[262,87370,87371,87373,87375],{"class":181,"line":549},[262,87372,48362],{"class":429},[262,87374,476],{"class":377},[262,87376,1189],{"class":429},[262,87378,87379,87381,87383,87386],{"class":181,"line":570},[262,87380,48371],{"class":611},[262,87382,476],{"class":377},[262,87384,87385],{"class":275},"\"gpt-4o\"",[262,87387,1315],{"class":429},[262,87389,87390,87392,87394],{"class":181,"line":579},[262,87391,48388],{"class":611},[262,87393,476],{"class":377},[262,87395,1220],{"class":429},[262,87397,87398,87400,87402,87404,87406,87408,87410,87412,87415],{"class":181,"line":586},[262,87399,7726],{"class":429},[262,87401,1228],{"class":275},[262,87403,1231],{"class":429},[262,87405,1234],{"class":275},[262,87407,608],{"class":429},[262,87409,1239],{"class":275},[262,87411,1231],{"class":429},[262,87413,87414],{"class":275},"\"You answer questions about documents.\"",[262,87416,3143],{"class":429},[262,87418,87419,87421,87423,87425,87427,87429,87431,87433,87436,87438,87440,87442,87445,87447,87450],{"class":181,"line":591},[262,87420,7726],{"class":429},[262,87422,1228],{"class":275},[262,87424,1231],{"class":429},[262,87426,1291],{"class":275},[262,87428,608],{"class":429},[262,87430,1239],{"class":275},[262,87432,1231],{"class":429},[262,87434,87435],{"class":275},"\"Here is a long report:",[262,87437,2137],{"class":271},[262,87439,1176],{"class":275},[262,87441,2142],{"class":377},[262,87443,87444],{"class":275}," \"fact \"",[262,87446,18556],{"class":377},[262,87448,87449],{"class":271}," 30000",[262,87451,3143],{"class":429},[262,87453,87454],{"class":181,"line":623},[262,87455,48439],{"class":429},[262,87457,87458,87460,87462,87464],{"class":181,"line":634},[262,87459,77660],{"class":611},[262,87461,476],{"class":377},[262,87463,16427],{"class":271},[262,87465,1315],{"class":429},[262,87467,87468],{"class":181,"line":845},[262,87469,660],{"class":429},[262,87471,87472],{"class":181,"line":850},[262,87473,583],{"emptyLinePlaceholder":582},[262,87475,87476,87478,87480,87482],{"class":181,"line":864},[262,87477,637],{"class":271},[262,87479,48465],{"class":429},[262,87481,102],{"class":271},[262,87483,6048],{"class":429},[14,87485,87486,87487,1374,87489,1363],{},"A larger window is the least-effort fix, but it is not free: bigger models often cost more per token, and even a 128,000-token window has a ceiling. For genuinely huge data, pair a large model with the chunking from Fix 2. To compare windows and prices across providers, see ",[51,87488,14635],{"href":14634},[51,87490,5485],{"href":5484},[57,87492,87494],{"id":87493},"key-parameters-at-a-glance","Key parameters at a glance",[1379,87496,87497,87506],{},[1382,87498,87499],{},[1385,87500,87501,87503],{},[1388,87502,1390],{},[1388,87504,87505],{},"Effect on the limit",[1398,87507,87508,87517,87526],{},[1385,87509,87510,87514],{},[1403,87511,87512],{},[18,87513,805],{},[1403,87515,87516],{},"Sets the window size. A larger-context model raises the total token budget.",[1385,87518,87519,87523],{},[1403,87520,87521],{},[18,87522,3846],{},[1403,87524,87525],{},"Reserves space for the reply. Lower it to leave more room for input.",[1385,87527,87528,87532],{},[1403,87529,87530],{},[18,87531,43269],{},[1403,87533,87534],{},"Holds all input tokens. Trim or summarize this to shrink input.",[57,87536,1445],{"id":1444},[1447,87538,87539,87552,87572,87578],{},[1450,87540,87541,87546,87547,87549,87550,1363],{},[35,87542,87543,87544,1363],{},"The error returns even after lowering ",[18,87545,3846],{}," Your input alone is over the window, so reserving less reply space cannot help. Count the input with ",[18,87548,42947],{}," (Fix 1); if it is already near the limit, you must chunk or summarize the input rather than touch ",[18,87551,3846],{},[1450,87553,87554,87563,87564,87567,87568,87571],{},[35,87555,87556,87559,87560,87562],{},[18,87557,87558],{},"tiktoken.encoding_for_model"," raises a ",[18,87561,3897],{}," for a new model."," The library does not yet know that model's name. Fall back to ",[18,87565,87566],{},"tiktoken.get_encoding(\"cl100k_base\")",", which matches most current OpenAI models, exactly as the ",[18,87569,87570],{},"count_tokens"," helper in Fix 1 does.",[1450,87573,87574,87577],{},[35,87575,87576],{},"Your token count looks right but the request still fails by a few tokens."," Token counting is an estimate that misses small per-message overhead. Leave a safety margin — aim to stay 5-10% under the window rather than right at the edge.",[1450,87579,87580,87583],{},[35,87581,87582],{},"A chunked job gives disconnected or repetitive answers."," Each chunk was processed with no memory of the others. Summarize every chunk first, then make one final call that combines the summaries, so the model sees the whole picture at the end.",[57,87585,2317],{"id":2316},[2322,87587,87588,87594,87600],{},[1450,87589,87590,87593],{},[35,87591,87592],{},"Trim or summarize"," when the task is a chat that keeps growing — it is cheap and keeps recent detail sharp, which matters most in conversation.",[1450,87595,87596,87599],{},[35,87597,87598],{},"Chunk the input"," when you must process a large document in full and cannot drop any of it, accepting the extra calls that come with it.",[1450,87601,87602,87604],{},[35,87603,86108],{}," when the input is large, indivisible, and worth the higher per-call cost — the least code, but not the cheapest.",[14,87606,2375,87607,1363],{},[51,87608,2487],{"href":2486},[57,87610,2381],{"id":2380},[2322,87612,87613,87618,87623,87628],{},[1450,87614,87615,87617],{},[51,87616,2487],{"href":2486}," — the main guide for this track, covering setup, keys, and parameters.",[1450,87619,87620,87622],{},[51,87621,388],{"href":387}," — when your key is missing or mistyped.",[1450,87624,87625,87627],{},[51,87626,3379],{"href":3378}," — when you send calls faster than your tier allows.",[1450,87629,87630,87632],{},[51,87631,6114],{"href":6113}," — when the model's reply will not parse as JSON.",[2401,87634,2403],{},{"title":258,"searchDepth":282,"depth":282,"links":87636},[87637,87638,87639,87640,87641,87642,87643,87644,87645,87646,87647,87648,87649],{"id":85967,"depth":282,"text":85968},{"id":86001,"depth":282,"text":86002},{"id":86044,"depth":282,"text":86045},{"id":237,"depth":282,"text":238},{"id":86196,"depth":282,"text":86197},{"id":86470,"depth":282,"text":86471},{"id":86741,"depth":282,"text":86742},{"id":87101,"depth":282,"text":87102},{"id":87295,"depth":282,"text":87296},{"id":87493,"depth":282,"text":87494},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Fix the maximum context length exceeded error in Python. Count tokens with tiktoken, trim and summarize input, lower max_tokens, and pick a larger model.",[87652,87655,87658,87661,87664],{"q":87653,"a":87654},"What does maximum context length exceeded mean?","It means your input tokens plus the tokens you asked the model to generate are larger than the model's fixed window. Every model can only hold a set number of tokens at once, counting both your prompt and the reply. When the total goes over that limit, the request is rejected before any answer is produced.",{"q":87656,"a":87657},"How do I count tokens before sending a request?","Install the tiktoken library and load the encoding for your model, then call encode on your text and measure the length of the list. That number is your input token count. Add the max_tokens you plan to request, and if the sum is under the model's window you are safe.",{"q":87659,"a":87660},"Will lowering max_tokens fix the error?","Sometimes. max_tokens reserves space for the reply, so a smaller value leaves more room for input. But if the input alone already fills the window, lowering max_tokens will not help and you must trim, chunk, or summarize the input instead.",{"q":87662,"a":87663},"What is the difference between trimming and summarizing input?","Trimming drops whole pieces of text, like older chat turns, to get under the limit quickly. Summarizing replaces long text with a shorter version the model wrote, keeping the meaning while cutting the token count. Trimming is faster and cheaper; summarizing preserves more context.",{"q":87665,"a":87666},"Does switching to a larger-context model always solve it?","A model with a bigger window, such as one that holds 128,000 tokens, can absorb far more input and fixes most everyday cases. But larger windows can cost more per call and still have a ceiling, so combine a larger model with token counting and trimming for very big documents.",{"name":87668,"steps":87669},"How to fix the context-length-exceeded error in Python",[87670,87673,87676,87679,87682],{"name":87671,"text":87672},"Count your tokens with tiktoken","Measure the input token count and add your planned reply length to see how far over the window you are.",{"name":87674,"text":87675},"Trim or chunk the input","Drop older or less important text, or split a long document into pieces that each fit the window.",{"name":87677,"text":87678},"Summarize the conversation history","Replace long chat history with a short model-written summary so the meaning survives but the token count drops.",{"name":87680,"text":87681},"Lower max_tokens to reserve less reply space","Reduce the reply ceiling so more of the window is free for your input.",{"name":87683,"text":87684},"Choose a larger-context model","Switch to a model with a bigger window when your input is genuinely large and cannot be trimmed.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-context-length-exceeded-error-in-python",{"title":1513,"description":87650},"Fix Context-Length-Exceeded Error in Python","python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-context-length-exceeded-error-in-python\u002Findex","FLQeiozQ8-XE4lJt-gufNeBOJ0D_6GTBeMdmrT2Pt9g",{"id":87692,"title":6114,"body":87693,"description":89438,"extension":2419,"faq":89439,"howto":89455,"meta":89469,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":89470,"published":2452,"seo":89471,"seoTitle":89472,"stem":89473,"__hash__":89474},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-jsondecodeerror-with-ai-api-responses-in-python\u002Findex.md",{"type":7,"value":87694,"toc":89425},[87695,87698,87704,87717,87719,87722,87762,87768,87802,87812,87815,87832,87838,87840,87926,87928,87938,87955,87961,87969,87978,87982,87991,87996,88185,88201,88205,88216,88497,88566,88572,88700,88704,88716,88845,88854,88858,88861,89242,89250,89252,89305,89307,89357,89359,89389,89393,89395,89422],[10,87696,6114],{"id":87697},"fix-jsondecodeerror-with-ai-api-responses-in-python",[14,87699,87700,87701,87703],{},"This guide shows you how to stop ",[18,87702,74996],{}," when you ask a language model (the AI behind tools like ChatGPT) for JSON and Python refuses to read it. You will get four ordered fixes with runnable Python 3.10+ code, and you can apply the first one in under five minutes.",[14,87705,87706,87707,87710,87711,87713,87714,87716],{},"The trap is subtle. The API call itself succeeds, and the HTTP response is valid JSON. But the ",[27,87708,87709],{},"text the model wrote"," inside that response is not — it added a sentence, a markdown fence, or only sent half of it. When you feed that text to ",[18,87712,20396],{},", Python objects. This is one of the most common errors people hit right after they finish ",[51,87715,2487],{"href":2486},", so it is worth fixing properly once.",[57,87718,85968],{"id":85967},[14,87720,87721],{},"You wrote something like this and it blew up:",[253,87723,87725],{"className":414,"code":87724,"language":416,"meta":258,"style":258},"import json\n\nreply = response.choices[0].message.content\ndata = json.loads(reply)   # \u003C- raises here\n",[18,87726,87727,87733,87737,87750],{"__ignoreMap":258},[262,87728,87729,87731],{"class":181,"line":264},[262,87730,684],{"class":377},[262,87732,5766],{"class":429},[262,87734,87735],{"class":181,"line":282},[262,87736,583],{"emptyLinePlaceholder":582},[262,87738,87739,87742,87744,87746,87748],{"class":181,"line":295},[262,87740,87741],{"class":429},"reply ",[262,87743,476],{"class":377},[262,87745,1326],{"class":429},[262,87747,102],{"class":271},[262,87749,1331],{"class":429},[262,87751,87752,87754,87756,87759],{"class":181,"line":345},[262,87753,70069],{"class":429},[262,87755,476],{"class":377},[262,87757,87758],{"class":429}," json.loads(reply)   ",[262,87760,87761],{"class":291},"# \u003C- raises here\n",[253,87763,87766],{"className":87764,"code":87765,"language":111,"meta":258},[2577],"Traceback (most recent call last):\n  File \"app.py\", line 12, in \u003Cmodule>\n    data = json.loads(reply)\njson.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)\n",[18,87767,87765],{"__ignoreMap":258},[14,87769,87770,87772,87773,87775,87776,87779,87780,87783,87784,87787,87788,87790,87791,87794,87795,407,87798,87801],{},[18,87771,74996],{}," is the exception ",[18,87774,20396],{}," raises when a string is not valid JSON. The message tells you ",[27,87777,87778],{},"where"," parsing failed, which is the fastest clue to what the model did wrong. ",[18,87781,87782],{},"line 1 column 1 (char 0)"," means it failed on the very first character — typically because the model started with a word like ",[18,87785,87786],{},"Here"," or with a backtick before any ",[18,87789,3039],{},". A message like ",[18,87792,87793],{},"Extra data: line 5 column 1"," means the JSON itself was fine but the model kept typing afterwards, so the parser hit unexpected text once the object closed. An ",[18,87796,87797],{},"Expecting ',' delimiter",[18,87799,87800],{},"Unterminated string"," message usually means the response was cut off partway through, which is common with streaming or a too-short token limit.",[14,87803,87804,87805,87808,87809,87811],{},"Note that ",[18,87806,87807],{},"requests.exceptions.JSONDecodeError"," (raised by ",[18,87810,31986],{},") is a subclass of the same standard-library exception, so the same diagnosis applies whether you parse the body yourself or let the SDK do it.",[14,87813,87814],{},"The first thing to do, always, is look at the raw string:",[253,87816,87818],{"className":414,"code":87817,"language":416,"meta":258,"style":258},"print(repr(reply))\n",[18,87819,87820],{"__ignoreMap":258},[262,87821,87822,87824,87826,87829],{"class":181,"line":264},[262,87823,637],{"class":271},[262,87825,602],{"class":429},[262,87827,87828],{"class":271},"repr",[262,87830,87831],{"class":429},"(reply))\n",[14,87833,87834,87837],{},[18,87835,87836],{},"repr()"," shows you hidden characters and the exact wrapper text, which tells you which of the fixes below you need.",[57,87839,86045],{"id":86044},[1379,87841,87842,87854],{},[1382,87843,87844],{},[1385,87845,87846,87849,87852],{},[1388,87847,87848],{},"What the model returned",[1388,87850,87851],{},"Error you get",[1388,87853,26308],{},[1398,87855,87856,87872,87886,87899,87912],{},[1385,87857,87858,87864,87869],{},[1403,87859,87860,87861,5987],{},"Prose then JSON (",[18,87862,87863],{},"Sure! Here is...",[1403,87865,87866],{},[18,87867,87868],{},"Expecting value: line 1 column 1",[1403,87870,87871],{},"JSON mode (Fix 1) or extraction (Fix 2)",[1385,87873,87874,87879,87883],{},[1403,87875,87876,87877,5987],{},"Markdown fence (",[18,87878,76196],{},[1403,87880,87881],{},[18,87882,87868],{},[1403,87884,87885],{},"Strip fences (Fix 2)",[1385,87887,87888,87891,87896],{},[1403,87889,87890],{},"Valid JSON plus trailing text",[1403,87892,87893],{},[18,87894,87895],{},"Extra data: line N",[1403,87897,87898],{},"Extraction (Fix 2)",[1385,87900,87901,87904,87909],{},[1403,87902,87903],{},"Half a response from streaming",[1403,87905,87906,87908],{},[18,87907,87797],{}," \u002F truncated",[1403,87910,87911],{},"Join chunks before parsing (Fix 2)",[1385,87913,87914,87917,87923],{},[1403,87915,87916],{},"JSON parses but a key is missing",[1403,87918,87919,87920,87922],{},"No ",[18,87921,6109],{},", fails later",[1403,87924,87925],{},"Schema validation (Fix 3)",[57,87927,238],{"id":237},[14,87929,87930,87931,57285,87933,87935,87936,1363],{},"You need Python 3.10+, the ",[18,87932,20],{},[18,87934,52536],{}," for the validation step. Install them into a virtual environment so they stay isolated — if you have not set one up yet, see ",[51,87937,2482],{"href":2481},[253,87939,87941],{"className":255,"code":87940,"language":257,"meta":258,"style":258},"pip install openai pydantic python-dotenv\n",[18,87942,87943],{"__ignoreMap":258},[262,87944,87945,87947,87949,87951,87953],{"class":181,"line":264},[262,87946,298],{"class":267},[262,87948,301],{"class":275},[262,87950,2519],{"class":275},[262,87952,75218],{"class":275},[262,87954,2522],{"class":275},[14,87956,87957,87958,87960],{},"Put your key in a ",[18,87959,319],{}," file:",[253,87962,87963],{"className":323,"code":11159,"language":325,"meta":258,"style":258},[18,87964,87965],{"__ignoreMap":258},[262,87966,87967],{"class":181,"line":264},[262,87968,11159],{},[14,87970,353,87971,356,87973,87975,87976,1363],{},[18,87972,319],{},[18,87974,359],{}," so you never commit your key. If your key itself is rejected, the symptom is different — see ",[51,87977,388],{"href":387},[57,87979,87981],{"id":87980},"fix-1-turn-on-json-mode-so-the-provider-guarantees-valid-json","Fix 1: Turn on JSON mode so the provider guarantees valid JSON",[14,87983,87984,87985,87987,87988,87990],{},"The strongest fix is to stop the model from ever wrapping its answer. OpenAI-compatible chat APIs accept a ",[18,87986,5745],{}," parameter. Set it to ",[18,87989,6841],{}," and the provider guarantees the reply is syntactically valid JSON — no fences, no prose.",[14,87992,87993,87994,1363],{},"There is one rule: your prompt must contain the word \"JSON\", or the API rejects the request. Telling the model the shape you want is good practice anyway. Writing prompts that pin down the output shape is a skill in itself, covered in ",[51,87995,1362],{"href":1361},[253,87997,87999],{"className":414,"code":87998,"language":416,"meta":258,"style":258},"import json\nimport os\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    response_format={\"type\": \"json_object\"},  # the key line\n    messages=[\n        {\"role\": \"system\", \"content\": \"Reply only with JSON.\"},\n        {\"role\": \"user\", \"content\": \"Give name and age for a fictional pilot as JSON.\"},\n    ],\n)\n\ndata = json.loads(response.choices[0].message.content)  # safe now\nprint(data)\n",[18,88000,88001,88007,88013,88023,88033,88037,88041,88059,88063,88071,88081,88100,88108,88129,88150,88154,88158,88162,88178],{"__ignoreMap":258},[262,88002,88003,88005],{"class":181,"line":264},[262,88004,684],{"class":377},[262,88006,5766],{"class":429},[262,88008,88009,88011],{"class":181,"line":282},[262,88010,684],{"class":377},[262,88012,687],{"class":429},[262,88014,88015,88017,88019,88021],{"class":181,"line":295},[262,88016,705],{"class":377},[262,88018,720],{"class":429},[262,88020,684],{"class":377},[262,88022,725],{"class":429},[262,88024,88025,88027,88029,88031],{"class":181,"line":345},[262,88026,705],{"class":377},[262,88028,708],{"class":429},[262,88030,684],{"class":377},[262,88032,713],{"class":429},[262,88034,88035],{"class":181,"line":492},[262,88036,583],{"emptyLinePlaceholder":582},[262,88038,88039],{"class":181,"line":503},[262,88040,734],{"class":429},[262,88042,88043,88045,88047,88049,88051,88053,88055,88057],{"class":181,"line":521},[262,88044,739],{"class":429},[262,88046,476],{"class":377},[262,88048,1588],{"class":429},[262,88050,2674],{"class":611},[262,88052,476],{"class":377},[262,88054,26942],{"class":429},[262,88056,2681],{"class":275},[262,88058,3512],{"class":429},[262,88060,88061],{"class":181,"line":537},[262,88062,583],{"emptyLinePlaceholder":582},[262,88064,88065,88067,88069],{"class":181,"line":549},[262,88066,48362],{"class":429},[262,88068,476],{"class":377},[262,88070,1189],{"class":429},[262,88072,88073,88075,88077,88079],{"class":181,"line":570},[262,88074,48371],{"class":611},[262,88076,476],{"class":377},[262,88078,1207],{"class":275},[262,88080,1315],{"class":429},[262,88082,88083,88085,88087,88089,88091,88093,88095,88097],{"class":181,"line":579},[262,88084,75572],{"class":611},[262,88086,476],{"class":377},[262,88088,3039],{"class":429},[262,88090,6025],{"class":275},[262,88092,1231],{"class":429},[262,88094,6030],{"class":275},[262,88096,59222],{"class":429},[262,88098,88099],{"class":291},"# the key line\n",[262,88101,88102,88104,88106],{"class":181,"line":586},[262,88103,48388],{"class":611},[262,88105,476],{"class":377},[262,88107,1220],{"class":429},[262,88109,88110,88112,88114,88116,88118,88120,88122,88124,88127],{"class":181,"line":591},[262,88111,7726],{"class":429},[262,88113,1228],{"class":275},[262,88115,1231],{"class":429},[262,88117,1234],{"class":275},[262,88119,608],{"class":429},[262,88121,1239],{"class":275},[262,88123,1231],{"class":429},[262,88125,88126],{"class":275},"\"Reply only with JSON.\"",[262,88128,3143],{"class":429},[262,88130,88131,88133,88135,88137,88139,88141,88143,88145,88148],{"class":181,"line":623},[262,88132,7726],{"class":429},[262,88134,1228],{"class":275},[262,88136,1231],{"class":429},[262,88138,1291],{"class":275},[262,88140,608],{"class":429},[262,88142,1239],{"class":275},[262,88144,1231],{"class":429},[262,88146,88147],{"class":275},"\"Give name and age for a fictional pilot as JSON.\"",[262,88149,3143],{"class":429},[262,88151,88152],{"class":181,"line":634},[262,88153,48439],{"class":429},[262,88155,88156],{"class":181,"line":845},[262,88157,660],{"class":429},[262,88159,88160],{"class":181,"line":850},[262,88161,583],{"emptyLinePlaceholder":582},[262,88163,88164,88166,88168,88170,88172,88175],{"class":181,"line":864},[262,88165,70069],{"class":429},[262,88167,476],{"class":377},[262,88169,6043],{"class":429},[262,88171,102],{"class":271},[262,88173,88174],{"class":429},"].message.content)  ",[262,88176,88177],{"class":291},"# safe now\n",[262,88179,88180,88182],{"class":181,"line":1683},[262,88181,637],{"class":271},[262,88183,88184],{"class":429},"(data)\n",[14,88186,88187,88188,88190,88191,88193,88194,88197,88198,88200],{},"With JSON mode on, ",[18,88189,20396],{}," will not raise a ",[18,88192,6109],{}," from formatting noise, because the provider validates the JSON on its side before sending it back. Use this whenever your provider supports it. Two caveats worth knowing: JSON mode guarantees ",[27,88195,88196],{},"syntax"," only, not that the keys match what you asked for — that is what Fix 3 handles — and it does not stop a response from being cut off if you set the token limit too low, which is what Fix 2's streaming path and the context-length guide below address. The next fix is your safety net for the providers and models that ignore ",[18,88199,5745],{}," entirely.",[57,88202,88204],{"id":88203},"fix-2-extract-json-robustly-when-the-model-still-adds-noise","Fix 2: Extract JSON robustly when the model still adds noise",[14,88206,88207,88208,88210,88211,88213,88214,1363],{},"Some models, free tiers, and local servers ignore or do not support ",[18,88209,5745],{},". For those, clean the string before you parse it. Two patterns cover almost every case: strip markdown code fences, then slice out the first complete JSON object using the positions of the first ",[18,88212,3039],{}," and last ",[18,88215,654],{},[253,88217,88219],{"className":414,"code":88218,"language":416,"meta":258,"style":258},"import json\nimport re\n\n\ndef extract_json(text: str) -> dict:\n    \"\"\"Pull a JSON object out of model text that may include prose or fences.\"\"\"\n    text = text.strip()\n\n    # 1. Remove a leading\u002Ftrailing markdown code fence if present.\n    fence = re.match(r\"^```(?:json)?\\s*(.*?)\\s*```$\", text, re.DOTALL)\n    if fence:\n        text = fence.group(1).strip()\n\n    # 2. Try parsing the cleaned text directly.\n    try:\n        return json.loads(text)\n    except json.JSONDecodeError:\n        pass\n\n    # 3. Fall back to slicing the outermost { ... } and parse that.\n    start, end = text.find(\"{\"), text.rfind(\"}\")\n    if start != -1 and end != -1 and end > start:\n        return json.loads(text[start : end + 1])\n\n    raise ValueError(f\"No JSON object found in: {text!r}\")\n",[18,88220,88221,88227,88233,88237,88241,88258,88263,88272,88276,88281,88339,88346,88359,88363,88368,88374,88381,88388,88393,88397,88402,88423,88455,88468,88472],{"__ignoreMap":258},[262,88222,88223,88225],{"class":181,"line":264},[262,88224,684],{"class":377},[262,88226,5766],{"class":429},[262,88228,88229,88231],{"class":181,"line":282},[262,88230,684],{"class":377},[262,88232,7956],{"class":429},[262,88234,88235],{"class":181,"line":295},[262,88236,583],{"emptyLinePlaceholder":582},[262,88238,88239],{"class":181,"line":345},[262,88240,583],{"emptyLinePlaceholder":582},[262,88242,88243,88245,88248,88250,88252,88254,88256],{"class":181,"line":492},[262,88244,423],{"class":377},[262,88246,88247],{"class":267}," extract_json",[262,88249,430],{"class":429},[262,88251,433],{"class":271},[262,88253,1939],{"class":429},[262,88255,5869],{"class":271},[262,88257,1160],{"class":429},[262,88259,88260],{"class":181,"line":503},[262,88261,88262],{"class":275},"    \"\"\"Pull a JSON object out of model text that may include prose or fences.\"\"\"\n",[262,88264,88265,88267,88269],{"class":181,"line":521},[262,88266,28267],{"class":429},[262,88268,476],{"class":377},[262,88270,88271],{"class":429}," text.strip()\n",[262,88273,88274],{"class":181,"line":537},[262,88275,583],{"emptyLinePlaceholder":582},[262,88277,88278],{"class":181,"line":549},[262,88279,88280],{"class":291},"    # 1. Remove a leading\u002Ftrailing markdown code fence if present.\n",[262,88282,88283,88286,88288,88291,88293,88295,88297,88300,88303,88305,88307,88310,88312,88314,88317,88320,88323,88325,88327,88329,88331,88334,88337],{"class":181,"line":570},[262,88284,88285],{"class":429},"    fence ",[262,88287,476],{"class":377},[262,88289,88290],{"class":429}," re.match(",[262,88292,7973],{"class":377},[262,88294,1176],{"class":275},[262,88296,12121],{"class":271},[262,88298,88299],{"class":7981},"```",[262,88301,88302],{"class":271},"(?:",[262,88304,17049],{"class":7981},[262,88306,5987],{"class":271},[262,88308,88309],{"class":377},"?",[262,88311,66624],{"class":271},[262,88313,1003],{"class":377},[262,88315,88316],{"class":271},"(.",[262,88318,88319],{"class":377},"*?",[262,88321,88322],{"class":271},")\\s",[262,88324,1003],{"class":377},[262,88326,88299],{"class":7981},[262,88328,78054],{"class":271},[262,88330,1176],{"class":275},[262,88332,88333],{"class":429},", text, re.",[262,88335,88336],{"class":271},"DOTALL",[262,88338,660],{"class":429},[262,88340,88341,88343],{"class":181,"line":579},[262,88342,3454],{"class":377},[262,88344,88345],{"class":429}," fence:\n",[262,88347,88348,88350,88352,88355,88357],{"class":181,"line":586},[262,88349,18264],{"class":429},[262,88351,476],{"class":377},[262,88353,88354],{"class":429}," fence.group(",[262,88356,997],{"class":271},[262,88358,2262],{"class":429},[262,88360,88361],{"class":181,"line":591},[262,88362,583],{"emptyLinePlaceholder":582},[262,88364,88365],{"class":181,"line":623},[262,88366,88367],{"class":291},"    # 2. Try parsing the cleaned text directly.\n",[262,88369,88370,88372],{"class":181,"line":634},[262,88371,14474],{"class":377},[262,88373,1160],{"class":429},[262,88375,88376,88378],{"class":181,"line":845},[262,88377,8066],{"class":377},[262,88379,88380],{"class":429}," json.loads(text)\n",[262,88382,88383,88385],{"class":181,"line":850},[262,88384,14522],{"class":377},[262,88386,88387],{"class":429}," json.JSONDecodeError:\n",[262,88389,88390],{"class":181,"line":864},[262,88391,88392],{"class":377},"        pass\n",[262,88394,88395],{"class":181,"line":1683},[262,88396,583],{"emptyLinePlaceholder":582},[262,88398,88399],{"class":181,"line":1688},[262,88400,88401],{"class":291},"    # 3. Fall back to slicing the outermost { ... } and parse that.\n",[262,88403,88404,88407,88409,88412,88415,88418,88421],{"class":181,"line":1693},[262,88405,88406],{"class":429},"    start, end ",[262,88408,476],{"class":377},[262,88410,88411],{"class":429}," text.find(",[262,88413,88414],{"class":275},"\"{\"",[262,88416,88417],{"class":429},"), text.rfind(",[262,88419,88420],{"class":275},"\"}\"",[262,88422,660],{"class":429},[262,88424,88425,88427,88429,88431,88433,88435,88437,88440,88442,88444,88446,88448,88450,88452],{"class":181,"line":1728},[262,88426,3454],{"class":377},[262,88428,509],{"class":429},[262,88430,23215],{"class":377},[262,88432,18319],{"class":377},[262,88434,997],{"class":271},[262,88436,33508],{"class":377},[262,88438,88439],{"class":429}," end ",[262,88441,23215],{"class":377},[262,88443,18319],{"class":377},[262,88445,997],{"class":271},[262,88447,33508],{"class":377},[262,88449,88439],{"class":429},[262,88451,8086],{"class":377},[262,88453,88454],{"class":429}," start:\n",[262,88456,88457,88459,88462,88464,88466],{"class":181,"line":1737},[262,88458,8066],{"class":377},[262,88460,88461],{"class":429}," json.loads(text[start : end ",[262,88463,531],{"class":377},[262,88465,3243],{"class":271},[262,88467,3512],{"class":429},[262,88469,88470],{"class":181,"line":1751},[262,88471,583],{"emptyLinePlaceholder":582},[262,88473,88474,88476,88478,88480,88482,88485,88487,88489,88491,88493,88495],{"class":181,"line":1764},[262,88475,2829],{"class":377},[262,88477,2832],{"class":271},[262,88479,602],{"class":429},[262,88481,642],{"class":377},[262,88483,88484],{"class":275},"\"No JSON object found in: ",[262,88486,3039],{"class":271},[262,88488,111],{"class":429},[262,88490,23309],{"class":377},[262,88492,654],{"class":271},[262,88494,1176],{"class":275},[262,88496,660],{"class":429},[253,88498,88500],{"className":414,"code":88499,"language":416,"meta":258,"style":258},"messy = \"Sure! Here is the data:\\n```json\\n{\\\"name\\\": \\\"Mara\\\", \\\"age\\\": 34}\\n```\\nHope that helps!\"\nprint(extract_json(messy))   # {'name': 'Mara', 'age': 34}\n",[18,88501,88502,88556],{"__ignoreMap":258},[262,88503,88504,88507,88509,88512,88514,88516,88518,88520,88522,88524,88526,88528,88530,88533,88535,88537,88539,88542,88544,88547,88549,88551,88553],{"class":181,"line":264},[262,88505,88506],{"class":429},"messy ",[262,88508,476],{"class":377},[262,88510,88511],{"class":275}," \"Sure! Here is the data:",[262,88513,2137],{"class":271},[262,88515,76196],{"class":275},[262,88517,2137],{"class":271},[262,88519,3039],{"class":275},[262,88521,34149],{"class":271},[262,88523,3552],{"class":275},[262,88525,34149],{"class":271},[262,88527,1231],{"class":275},[262,88529,34149],{"class":271},[262,88531,88532],{"class":275},"Mara",[262,88534,34149],{"class":271},[262,88536,608],{"class":275},[262,88538,34149],{"class":271},[262,88540,88541],{"class":275},"age",[262,88543,34149],{"class":271},[262,88545,88546],{"class":275},": 34}",[262,88548,2137],{"class":271},[262,88550,88299],{"class":275},[262,88552,2137],{"class":271},[262,88554,88555],{"class":275},"Hope that helps!\"\n",[262,88557,88558,88560,88563],{"class":181,"line":282},[262,88559,637],{"class":271},[262,88561,88562],{"class":429},"(extract_json(messy))   ",[262,88564,88565],{"class":291},"# {'name': 'Mara', 'age': 34}\n",[14,88567,88568,88569,88571],{},"This also handles streaming. When you stream a response, each chunk is only a fragment, so you must join every chunk into one string and parse ",[27,88570,18377],{}," the loop ends — never inside it:",[253,88573,88575],{"className":414,"code":88574,"language":416,"meta":258,"style":258},"chunks = []\nstream = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    stream=True,\n    messages=[{\"role\": \"user\", \"content\": \"Return a JSON object with one key 'ok'.\"}],\n)\nfor event in stream:\n    piece = event.choices[0].delta.content\n    if piece:\n        chunks.append(piece)\n\ndata = extract_json(\"\".join(chunks))   # parse once, at the end\n",[18,88576,88577,88585,88593,88603,88613,88638,88642,88653,88667,88674,88679,88683],{"__ignoreMap":258},[262,88578,88579,88581,88583],{"class":181,"line":264},[262,88580,626],{"class":429},[262,88582,476],{"class":377},[262,88584,489],{"class":429},[262,88586,88587,88589,88591],{"class":181,"line":282},[262,88588,50885],{"class":429},[262,88590,476],{"class":377},[262,88592,1189],{"class":429},[262,88594,88595,88597,88599,88601],{"class":181,"line":295},[262,88596,48371],{"class":611},[262,88598,476],{"class":377},[262,88600,1207],{"class":275},[262,88602,1315],{"class":429},[262,88604,88605,88607,88609,88611],{"class":181,"line":345},[262,88606,50974],{"class":611},[262,88608,476],{"class":377},[262,88610,4974],{"class":271},[262,88612,1315],{"class":429},[262,88614,88615,88617,88619,88621,88623,88625,88627,88629,88631,88633,88636],{"class":181,"line":492},[262,88616,48388],{"class":611},[262,88618,476],{"class":377},[262,88620,8856],{"class":429},[262,88622,1228],{"class":275},[262,88624,1231],{"class":429},[262,88626,1291],{"class":275},[262,88628,608],{"class":429},[262,88630,1239],{"class":275},[262,88632,1231],{"class":429},[262,88634,88635],{"class":275},"\"Return a JSON object with one key 'ok'.\"",[262,88637,54808],{"class":429},[262,88639,88640],{"class":181,"line":503},[262,88641,660],{"class":429},[262,88643,88644,88646,88649,88651],{"class":181,"line":521},[262,88645,829],{"class":377},[262,88647,88648],{"class":429}," event ",[262,88650,835],{"class":377},[262,88652,51000],{"class":429},[262,88654,88655,88658,88660,88663,88665],{"class":181,"line":537},[262,88656,88657],{"class":429},"    piece ",[262,88659,476],{"class":377},[262,88661,88662],{"class":429}," event.choices[",[262,88664,102],{"class":271},[262,88666,51014],{"class":429},[262,88668,88669,88671],{"class":181,"line":549},[262,88670,3454],{"class":377},[262,88672,88673],{"class":429}," piece:\n",[262,88675,88676],{"class":181,"line":570},[262,88677,88678],{"class":429},"        chunks.append(piece)\n",[262,88680,88681],{"class":181,"line":579},[262,88682,583],{"emptyLinePlaceholder":582},[262,88684,88685,88687,88689,88692,88694,88697],{"class":181,"line":586},[262,88686,70069],{"class":429},[262,88688,476],{"class":377},[262,88690,88691],{"class":429}," extract_json(",[262,88693,9175],{"class":275},[262,88695,88696],{"class":429},".join(chunks))   ",[262,88698,88699],{"class":291},"# parse once, at the end\n",[57,88701,88703],{"id":88702},"fix-3-validate-the-shape-with-a-pydantic-schema","Fix 3: Validate the shape with a pydantic schema",[14,88705,88706,88708,88709,88712,88713,88715],{},[18,88707,20396],{}," only checks syntax. A reply like ",[18,88710,88711],{},"{\"age\": \"thirty\"}"," parses fine, then crashes later when your code does math on a string. ",[18,88714,52536],{}," lets you declare the exact shape you expect and validates in one line, with a clear error when the data is wrong.",[253,88717,88719],{"className":414,"code":88718,"language":416,"meta":258,"style":258},"from pydantic import BaseModel, ValidationError\n\n\nclass Pilot(BaseModel):\n    name: str\n    age: int\n\n\nraw = extract_json('{\"name\": \"Mara\", \"age\": \"34\"}')\n\ntry:\n    pilot = Pilot.model_validate(raw)   # coerces \"34\" -> 34, or raises\n    print(pilot.name, pilot.age + 1)\nexcept ValidationError as err:\n    print(\"Bad shape from model:\", err)\n",[18,88720,88721,88732,88736,88740,88753,88759,88767,88771,88775,88788,88792,88798,88811,88824,88834],{"__ignoreMap":258},[262,88722,88723,88725,88727,88729],{"class":181,"line":264},[262,88724,705],{"class":377},[262,88726,53609],{"class":429},[262,88728,684],{"class":377},[262,88730,88731],{"class":429}," BaseModel, ValidationError\n",[262,88733,88734],{"class":181,"line":282},[262,88735,583],{"emptyLinePlaceholder":582},[262,88737,88738],{"class":181,"line":295},[262,88739,583],{"emptyLinePlaceholder":582},[262,88741,88742,88744,88747,88749,88751],{"class":181,"line":345},[262,88743,7374],{"class":377},[262,88745,88746],{"class":267}," Pilot",[262,88748,602],{"class":429},[262,88750,53697],{"class":267},[262,88752,8192],{"class":429},[262,88754,88755,88757],{"class":181,"line":492},[262,88756,75707],{"class":429},[262,88758,8677],{"class":271},[262,88760,88761,88764],{"class":181,"line":503},[262,88762,88763],{"class":429},"    age: ",[262,88765,88766],{"class":271},"int\n",[262,88768,88769],{"class":181,"line":521},[262,88770,583],{"emptyLinePlaceholder":582},[262,88772,88773],{"class":181,"line":537},[262,88774,583],{"emptyLinePlaceholder":582},[262,88776,88777,88779,88781,88783,88786],{"class":181,"line":549},[262,88778,75607],{"class":429},[262,88780,476],{"class":377},[262,88782,88691],{"class":429},[262,88784,88785],{"class":275},"'{\"name\": \"Mara\", \"age\": \"34\"}'",[262,88787,660],{"class":429},[262,88789,88790],{"class":181,"line":570},[262,88791,583],{"emptyLinePlaceholder":582},[262,88793,88794,88796],{"class":181,"line":579},[262,88795,14430],{"class":377},[262,88797,1160],{"class":429},[262,88799,88800,88803,88805,88808],{"class":181,"line":586},[262,88801,88802],{"class":429},"    pilot ",[262,88804,476],{"class":377},[262,88806,88807],{"class":429}," Pilot.model_validate(raw)   ",[262,88809,88810],{"class":291},"# coerces \"34\" -> 34, or raises\n",[262,88812,88813,88815,88818,88820,88822],{"class":181,"line":591},[262,88814,1089],{"class":271},[262,88816,88817],{"class":429},"(pilot.name, pilot.age ",[262,88819,531],{"class":377},[262,88821,3243],{"class":271},[262,88823,660],{"class":429},[262,88825,88826,88828,88830,88832],{"class":181,"line":623},[262,88827,14433],{"class":377},[262,88829,75764],{"class":429},[262,88831,697],{"class":377},[262,88833,3222],{"class":429},[262,88835,88836,88838,88840,88843],{"class":181,"line":634},[262,88837,1089],{"class":271},[262,88839,602],{"class":429},[262,88841,88842],{"class":275},"\"Bad shape from model:\"",[262,88844,16309],{"class":429},[14,88846,88847,88850,88851,88853],{},[18,88848,88849],{},"model_validate"," will coerce a clean numeric string into an ",[18,88852,439],{},", but reject genuine nonsense — giving you a precise message instead of a confusing crash three functions later.",[57,88855,88857],{"id":88856},"fix-4-retry-automatically-when-parsing-fails","Fix 4: Retry automatically when parsing fails",[14,88859,88860],{},"Even with the fixes above, a model occasionally returns garbage. Instead of crashing the whole run, catch the failure and ask again. The loop below combines all four ideas: it requests JSON mode, extracts robustly, validates with pydantic, and retries on any failure.",[253,88862,88864],{"className":414,"code":88863,"language":416,"meta":258,"style":258},"import json\nimport os\nfrom openai import OpenAI\nfrom pydantic import BaseModel, ValidationError\nfrom dotenv import load_dotenv\n\nload_dotenv()\nclient = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n\n\nclass Pilot(BaseModel):\n    name: str\n    age: int\n\n\ndef get_pilot(prompt: str, max_tries: int = 3) -> Pilot:\n    \"\"\"Ask for a Pilot as JSON, retrying if parsing or validation fails.\"\"\"\n    for attempt in range(1, max_tries + 1):\n        response = client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            response_format={\"type\": \"json_object\"},\n            messages=[\n                {\"role\": \"system\", \"content\": \"Reply only with JSON: keys 'name' (string) and 'age' (integer).\"},\n                {\"role\": \"user\", \"content\": prompt},\n            ],\n        )\n        text = response.choices[0].message.content\n        try:\n            return Pilot.model_validate(extract_json(text))\n        except (json.JSONDecodeError, ValueError, ValidationError) as err:\n            print(f\"Attempt {attempt} failed: {err}\")\n            if attempt == max_tries:\n                raise\n\n    raise RuntimeError(\"unreachable\")\n\n\npilot = get_pilot(\"Invent a fighter pilot as JSON.\")\nprint(pilot.model_dump())\n",[18,88865,88866,88872,88878,88888,88898,88908,88912,88916,88934,88938,88942,88954,88960,88966,88970,88974,88997,89002,89025,89033,89043,89059,89067,89088,89104,89108,89112,89124,89130,89137,89152,89180,89191,89195,89199,89212,89216,89220,89235],{"__ignoreMap":258},[262,88867,88868,88870],{"class":181,"line":264},[262,88869,684],{"class":377},[262,88871,5766],{"class":429},[262,88873,88874,88876],{"class":181,"line":282},[262,88875,684],{"class":377},[262,88877,687],{"class":429},[262,88879,88880,88882,88884,88886],{"class":181,"line":295},[262,88881,705],{"class":377},[262,88883,720],{"class":429},[262,88885,684],{"class":377},[262,88887,725],{"class":429},[262,88889,88890,88892,88894,88896],{"class":181,"line":345},[262,88891,705],{"class":377},[262,88893,53609],{"class":429},[262,88895,684],{"class":377},[262,88897,88731],{"class":429},[262,88899,88900,88902,88904,88906],{"class":181,"line":492},[262,88901,705],{"class":377},[262,88903,708],{"class":429},[262,88905,684],{"class":377},[262,88907,713],{"class":429},[262,88909,88910],{"class":181,"line":503},[262,88911,583],{"emptyLinePlaceholder":582},[262,88913,88914],{"class":181,"line":521},[262,88915,734],{"class":429},[262,88917,88918,88920,88922,88924,88926,88928,88930,88932],{"class":181,"line":537},[262,88919,739],{"class":429},[262,88921,476],{"class":377},[262,88923,1588],{"class":429},[262,88925,2674],{"class":611},[262,88927,476],{"class":377},[262,88929,26942],{"class":429},[262,88931,2681],{"class":275},[262,88933,3512],{"class":429},[262,88935,88936],{"class":181,"line":549},[262,88937,583],{"emptyLinePlaceholder":582},[262,88939,88940],{"class":181,"line":570},[262,88941,583],{"emptyLinePlaceholder":582},[262,88943,88944,88946,88948,88950,88952],{"class":181,"line":579},[262,88945,7374],{"class":377},[262,88947,88746],{"class":267},[262,88949,602],{"class":429},[262,88951,53697],{"class":267},[262,88953,8192],{"class":429},[262,88955,88956,88958],{"class":181,"line":586},[262,88957,75707],{"class":429},[262,88959,8677],{"class":271},[262,88961,88962,88964],{"class":181,"line":591},[262,88963,88763],{"class":429},[262,88965,88766],{"class":271},[262,88967,88968],{"class":181,"line":623},[262,88969,583],{"emptyLinePlaceholder":582},[262,88971,88972],{"class":181,"line":634},[262,88973,583],{"emptyLinePlaceholder":582},[262,88975,88976,88978,88981,88983,88985,88988,88990,88992,88994],{"class":181,"line":845},[262,88977,423],{"class":377},[262,88979,88980],{"class":267}," get_pilot",[262,88982,9599],{"class":429},[262,88984,433],{"class":271},[262,88986,88987],{"class":429},", max_tries: ",[262,88989,439],{"class":271},[262,88991,442],{"class":377},[262,88993,931],{"class":271},[262,88995,88996],{"class":429},") -> Pilot:\n",[262,88998,88999],{"class":181,"line":850},[262,89000,89001],{"class":275},"    \"\"\"Ask for a Pilot as JSON, retrying if parsing or validation fails.\"\"\"\n",[262,89003,89004,89006,89008,89010,89012,89014,89016,89019,89021,89023],{"class":181,"line":864},[262,89005,3074],{"class":377},[262,89007,3077],{"class":429},[262,89009,835],{"class":377},[262,89011,3082],{"class":271},[262,89013,602],{"class":429},[262,89015,997],{"class":271},[262,89017,89018],{"class":429},", max_tries ",[262,89020,531],{"class":377},[262,89022,3243],{"class":271},[262,89024,8192],{"class":429},[262,89026,89027,89029,89031],{"class":181,"line":1683},[262,89028,21490],{"class":429},[262,89030,476],{"class":377},[262,89032,1189],{"class":429},[262,89034,89035,89037,89039,89041],{"class":181,"line":1688},[262,89036,14214],{"class":611},[262,89038,476],{"class":377},[262,89040,1207],{"class":275},[262,89042,1315],{"class":429},[262,89044,89045,89047,89049,89051,89053,89055,89057],{"class":181,"line":1693},[262,89046,70830],{"class":611},[262,89048,476],{"class":377},[262,89050,3039],{"class":429},[262,89052,6025],{"class":275},[262,89054,1231],{"class":429},[262,89056,6030],{"class":275},[262,89058,3143],{"class":429},[262,89060,89061,89063,89065],{"class":181,"line":1728},[262,89062,27253],{"class":611},[262,89064,476],{"class":377},[262,89066,1220],{"class":429},[262,89068,89069,89071,89073,89075,89077,89079,89081,89083,89086],{"class":181,"line":1737},[262,89070,53817],{"class":429},[262,89072,1228],{"class":275},[262,89074,1231],{"class":429},[262,89076,1234],{"class":275},[262,89078,608],{"class":429},[262,89080,1239],{"class":275},[262,89082,1231],{"class":429},[262,89084,89085],{"class":275},"\"Reply only with JSON: keys 'name' (string) and 'age' (integer).\"",[262,89087,3143],{"class":429},[262,89089,89090,89092,89094,89096,89098,89100,89102],{"class":181,"line":1751},[262,89091,53817],{"class":429},[262,89093,1228],{"class":275},[262,89095,1231],{"class":429},[262,89097,1291],{"class":275},[262,89099,608],{"class":429},[262,89101,1239],{"class":275},[262,89103,38272],{"class":429},[262,89105,89106],{"class":181,"line":1764},[262,89107,53856],{"class":429},[262,89109,89110],{"class":181,"line":1779},[262,89111,6288],{"class":429},[262,89113,89114,89116,89118,89120,89122],{"class":181,"line":1793},[262,89115,18264],{"class":429},[262,89117,476],{"class":377},[262,89119,1326],{"class":429},[262,89121,102],{"class":271},[262,89123,1331],{"class":429},[262,89125,89126,89128],{"class":181,"line":1800},[262,89127,3090],{"class":377},[262,89129,1160],{"class":429},[262,89131,89132,89134],{"class":181,"line":1805},[262,89133,3198],{"class":377},[262,89135,89136],{"class":429}," Pilot.model_validate(extract_json(text))\n",[262,89138,89139,89141,89143,89145,89148,89150],{"class":181,"line":1810},[262,89140,3214],{"class":377},[262,89142,73345],{"class":429},[262,89144,16176],{"class":271},[262,89146,89147],{"class":429},", ValidationError) ",[262,89149,697],{"class":377},[262,89151,3222],{"class":429},[262,89153,89154,89156,89158,89160,89162,89164,89166,89168,89170,89172,89174,89176,89178],{"class":181,"line":1823},[262,89155,3250],{"class":271},[262,89157,602],{"class":429},[262,89159,642],{"class":377},[262,89161,8245],{"class":275},[262,89163,3039],{"class":271},[262,89165,8250],{"class":429},[262,89167,654],{"class":271},[262,89169,14549],{"class":275},[262,89171,3039],{"class":271},[262,89173,73374],{"class":429},[262,89175,654],{"class":271},[262,89177,1176],{"class":275},[262,89179,660],{"class":429},[262,89181,89182,89184,89186,89188],{"class":181,"line":1846},[262,89183,10200],{"class":377},[262,89185,3077],{"class":429},[262,89187,10758],{"class":377},[262,89189,89190],{"class":429}," max_tries:\n",[262,89192,89193],{"class":181,"line":1861},[262,89194,39443],{"class":377},[262,89196,89197],{"class":181,"line":1866},[262,89198,583],{"emptyLinePlaceholder":582},[262,89200,89201,89203,89205,89207,89210],{"class":181,"line":1871},[262,89202,2829],{"class":377},[262,89204,3318],{"class":271},[262,89206,602],{"class":429},[262,89208,89209],{"class":275},"\"unreachable\"",[262,89211,660],{"class":429},[262,89213,89214],{"class":181,"line":1890},[262,89215,583],{"emptyLinePlaceholder":582},[262,89217,89218],{"class":181,"line":1909},[262,89219,583],{"emptyLinePlaceholder":582},[262,89221,89222,89225,89227,89230,89233],{"class":181,"line":1914},[262,89223,89224],{"class":429},"pilot ",[262,89226,476],{"class":377},[262,89228,89229],{"class":429}," get_pilot(",[262,89231,89232],{"class":275},"\"Invent a fighter pilot as JSON.\"",[262,89234,660],{"class":429},[262,89236,89237,89239],{"class":181,"line":1919},[262,89238,637],{"class":271},[262,89240,89241],{"class":429},"(pilot.model_dump())\n",[14,89243,89244,89245,89247,89248,1363],{},"This pattern is production-grade: one bad reply no longer takes down your script. If you start hitting ",[18,89246,59190],{}," responses because retries fire too often, slow them down with the techniques in ",[51,89249,3379],{"href":3378},[57,89251,44332],{"id":44331},[1379,89253,89254,89264],{},[1382,89255,89256],{},[1385,89257,89258,89260,89262],{},[1388,89259,1390],{},[1388,89261,24078],{},[1388,89263,1396],{},[1398,89265,89266,89279,89292],{},[1385,89267,89268,89272,89276],{},[1403,89269,89270],{},[18,89271,6878],{},[1403,89273,89274],{},[18,89275,8306],{},[1403,89277,89278],{},"Provider guarantees syntactically valid JSON; prompt must mention \"JSON\"",[1385,89280,89281,89286,89289],{},[1403,89282,89283],{},[18,89284,89285],{},"max_tries",[1403,89287,89288],{},"your retry loop",[1403,89290,89291],{},"How many times to re-prompt before raising — 3 is a sane default",[1385,89293,89294,89299,89302],{},[1403,89295,89296],{},[18,89297,89298],{},"model_validate()",[1403,89300,89301],{},"pydantic model",[1403,89303,89304],{},"Validates and coerces the parsed dict against your declared schema",[57,89306,1445],{"id":1444},[1447,89308,89309,89321,89335,89346],{},[1450,89310,89311,89316,89317,89320],{},[35,89312,89313],{},[18,89314,89315],{},"Expecting value: line 1 column 1 (char 0)"," — The string starts with prose or a backtick, or is empty. Cause: the model ignored JSON mode or you forgot it. Fix: turn on Fix 1, and wrap parsing in ",[18,89318,89319],{},"extract_json"," from Fix 2.",[1450,89322,89323,89328,89329,89331,89332,89334],{},[35,89324,89325],{},[18,89326,89327],{},"Extra data: line N column 1"," — Valid JSON followed by trailing text like \"Hope that helps!\". Cause: the model kept talking after the object. Fix: use ",[18,89330,89319],{},", which slices to the last ",[18,89333,654],{}," and ignores the rest.",[1450,89336,89337,89342,89343,89345],{},[35,89338,89339],{},[18,89340,89341],{},"This model's prompt must contain the word 'json'"," — The API rejected the request, not the parse. Cause: ",[18,89344,5745],{}," JSON mode requires the literal word \"JSON\" somewhere in your messages. Fix: add \"Reply with JSON\" to your system prompt.",[1450,89347,89348,55456,89354,89356],{},[35,89349,89350,89351,89353],{},"Parses fine but ",[18,89352,3897],{}," or wrong type later",[18,89355,20396],{}," passed, but a field is missing or a string where you expected a number. Cause: you validated syntax, not shape. Fix: add the pydantic check from Fix 3 right after parsing.",[57,89358,2317],{"id":2316},[2322,89360,89361,89370,89381],{},[1450,89362,89363,89366,89367,89369],{},[35,89364,89365],{},"Use JSON mode (Fix 1) by default"," when your provider supports ",[18,89368,5745],{}," — it removes the problem at the source and needs the least code.",[1450,89371,89372,89375,89376,89378,89379,1363],{},[35,89373,89374],{},"Use extraction (Fix 2)"," when you are on a free tier, a local model, or an OpenAI-compatible endpoint that ignores ",[18,89377,5745],{},". Comparing those options is covered in ",[51,89380,5485],{"href":5484},[1450,89382,89383,89386,89387,1363],{},[35,89384,89385],{},"Add pydantic plus retry (Fixes 3-4)"," for anything running unattended — a scheduled job, a chatbot, or a SaaS endpoint — where a single malformed reply must not crash the run. If the model returns truncated JSON because the answer is too long, that is a different problem; see ",[51,89388,1513],{"href":1512},[14,89390,2375,89391,1363],{},[51,89392,2487],{"href":2486},[57,89394,2381],{"id":2380},[2322,89396,89397,89402,89407,89412,89417],{},[1450,89398,89399,89401],{},[51,89400,2487],{"href":2486}," — the main guide for this section.",[1450,89403,89404,89406],{},[51,89405,388],{"href":387}," — when the key itself is rejected.",[1450,89408,89409,89411],{},[51,89410,3379],{"href":3378}," — slow down retries that trip rate limits.",[1450,89413,89414,89416],{},[51,89415,1513],{"href":1512}," — when responses are truncated.",[1450,89418,89419,89421],{},[51,89420,1362],{"href":1361}," — get clean JSON at the source.",[2401,89423,89424],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}",{"title":258,"searchDepth":282,"depth":282,"links":89426},[89427,89428,89429,89430,89431,89432,89433,89434,89435,89436,89437],{"id":85967,"depth":282,"text":85968},{"id":86044,"depth":282,"text":86045},{"id":237,"depth":282,"text":238},{"id":87980,"depth":282,"text":87981},{"id":88203,"depth":282,"text":88204},{"id":88702,"depth":282,"text":88703},{"id":88856,"depth":282,"text":88857},{"id":44331,"depth":282,"text":44332},{"id":1444,"depth":282,"text":1445},{"id":2316,"depth":282,"text":2317},{"id":2380,"depth":282,"text":2381},"Stop json.JSONDecodeError when parsing LLM output in Python. Use JSON mode, robust extraction, pydantic validation, and retry-on-fail with runnable code.",[89440,89443,89446,89449,89452],{"q":89441,"a":89442},"Why does json.loads raise JSONDecodeError on a perfectly good API response?","The HTTP response was valid JSON, but the model's text answer inside it was not. Language models often wrap their JSON in prose or markdown code fences, so the string you pass to json.loads is not pure JSON.",{"q":89444,"a":89445},"What does 'Expecting value: line 1 column 1 (char 0)' mean?","It means the very first character is not a valid JSON token, usually because the text starts with a word, a backtick fence, or is empty. Print the raw string before parsing to see what the model actually returned.",{"q":89447,"a":89448},"How do I force a model to return only JSON?","Set response_format to {'type': 'json_object'} on chat completions for OpenAI-compatible APIs and instruct the model to reply with JSON. This is the most reliable fix because the provider guarantees syntactically valid JSON.",{"q":89450,"a":89451},"Why do streamed responses break json.loads?","Streaming sends the answer in small chunks, so each chunk is only a fragment of the final JSON. You must collect every chunk into one string and parse only after the stream finishes.",{"q":89453,"a":89454},"Should I validate the parsed JSON even after json.loads succeeds?","Yes. json.loads only checks syntax, not whether the keys and types are what your code expects. A schema check with pydantic catches missing fields and wrong types before they cause errors deeper in your program.",{"name":89456,"steps":89457},"How to fix JSONDecodeError with AI API responses in Python",[89458,89460,89463,89466],{"name":76293,"text":89459},"Set response_format to a JSON object so the provider guarantees syntactically valid JSON in the reply.",{"name":89461,"text":89462},"Extract JSON robustly","Strip markdown fences and slice out the first JSON object when the model still adds prose around it.",{"name":89464,"text":89465},"Validate with a schema","Parse the cleaned string into a pydantic model so missing keys and wrong types fail loudly and early.",{"name":89467,"text":89468},"Retry on parse failure","Wrap the call in a loop that re-prompts the model when parsing or validation fails.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-jsondecodeerror-with-ai-api-responses-in-python",{"title":6114,"description":89438},"Fix JSONDecodeError with AI API Responses","python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Ffix-jsondecodeerror-with-ai-api-responses-in-python\u002Findex","UsjVjOUNSLyW08SactOvXA6H5xtQS0OOqxRjL8_SrXM",{"id":89476,"title":69908,"body":89477,"description":90970,"extension":2419,"faq":90971,"howto":54197,"meta":90987,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":90988,"published":2452,"seo":90989,"seoTitle":69908,"stem":90990,"__hash__":90991},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fgroq-vs-openrouter-free-tier\u002Findex.md",{"type":7,"value":89478,"toc":90956},[89479,89482,89485,89498,89504,89508,89513,89521,89524,89641,89643,89652,89695,89701,89716,89725,89739,89742,89746,89759,89762,89766,89960,89977,89981,90213,90227,90231,90234,90713,90719,90723,90726,90795,90799,90802,90822,90828,90830,90885,90889,90920,90925,90929,90931,90953],[10,89480,69908],{"id":89481},"groq-vs-openrouter-free-tier",[14,89483,89484],{},"You want to practise calling a language model without spending money, and two names keep coming up: Groq and OpenRouter. Both have a free tier, both work from Python, and both claim to be beginner-friendly. But they solve different problems, and picking the wrong one for your task means slow experiments or hitting a limit wall you did not expect. This guide compares them head to head and shows you the exact Python to call each, so you can choose in minutes and start building.",[14,89486,89487,89488,89490,89491,89493,89494,89497],{},"The good news for a beginner: you barely need to learn anything new. If you have already worked through ",[51,89489,2487],{"href":2486},", you know the ",[18,89492,20],{}," SDK pattern. Both Groq and OpenRouter are ",[35,89495,89496],{},"OpenAI-compatible"," — they accept the same request shape OpenAI uses — so the same SDK talks to either one. You change three things (the web address, the key, and the model name) and your code just works.",[14,89499,89500,89501,89503],{},"This is one guide inside the ",[51,89502,2487],{"href":2486}," section, written for creators, marketers, founders, and students who can run a Python file but have never compared API providers before.",[57,89505,89507],{"id":89506},"what-each-service-is-in-one-line","What each service is, in one line",[14,89509,89510,89512],{},[35,89511,80813],{}," is a single provider that runs a curated set of open-weight models (like Llama and Mixtral) on custom hardware built for one thing: speed. You get a small menu of fast models and very low latency.",[14,89514,89515,89517,89518,89520],{},[35,89516,80816],{}," is a marketplace. One key and one web address give you access to hundreds of models from many providers — including a rotating set of genuinely free models marked with a ",[18,89519,81156],{}," suffix. You trade a little speed for enormous choice and the ability to switch models by changing one string.",[14,89522,89523],{},"The matrix below sums up the trade-offs you actually care about as a beginner.",[76,89525,89527,89638],{"className":89526},[79],[81,89528,90,89533,90,89536,90,89539,90,90,89543,90,89546,90,89548,90,89551,90,90,89553,90,89555,90,89558,90,89560,90,89563,90,89565,90,90,89572,90,89574,90,89578,90,89580,90,89586,90,89588,90,90,89594,90,89596,90,89600,90,89602,90,89609,90,89611,90,90,89617,90,89619,90,89623,90,89625,90,89632,90,89634],{"viewBox":89529,"role":84,"ariaLabelledBy":89530,"preserveAspectRatio":88,"xmlns":89},"-40 -40 980 540",[89531,89532],"cmpTitle","cmpDesc",[92,89534,89535],{"id":89531},"Groq versus OpenRouter free tier on four dimensions",[96,89537,89538],{"id":89532},"A comparison matrix rating Groq and OpenRouter on speed and latency, model choice, free-tier limits, and setup, showing Groq leads on speed while OpenRouter leads on model choice.",[111,89540,89542],{"x":89541,"y":15361,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"450","Groq vs OpenRouter",[100,89544],{"x":67224,"y":89545,"width":37100,"height":12826,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},"44",[111,89547,80813],{"x":228,"y":1100,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[100,89549],{"x":89550,"y":89545,"width":37100,"height":12826,"rx":106,"fill":107,"stroke":169,"strokeWidth":109},"610",[111,89552,80816],{"x":168,"y":1100,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[100,89554],{"x":140,"y":67210,"width":69495,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89556,89557],{"x":23367,"y":48087,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":14195},"Speed \u002F latency",[100,89559],{"x":67224,"y":67210,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89561,89562],{"x":228,"y":48087,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":108,"textAnchor":119},"Very fast",[100,89564],{"x":89550,"y":67210,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89566,89568,89569],{"x":168,"y":89567,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"158","Depends on",[175,89570,89571],{"x":168,"dy":177},"the model",[100,89573],{"x":140,"y":19925,"width":69495,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89575,89577],{"x":23367,"y":89576,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":14195},"257","Model choice",[100,89579],{"x":67224,"y":19925,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89581,89582,89583],{"x":228,"y":3956,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"Small, curated",[175,89584,89585],{"x":228,"dy":177},"menu",[100,89587],{"x":89550,"y":19925,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89589,89590,89591],{"x":168,"y":3956,"fontFamily":115,"fontSize":124,"fontWeight":117,"fill":169,"textAnchor":119},"Hundreds of",[175,89592,89593],{"x":168,"dy":177},"models",[100,89595],{"x":140,"y":77221,"width":69495,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89597,89599],{"x":23367,"y":89598,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":14195},"349","Free-tier limits",[100,89601],{"x":67224,"y":77221,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89603,89605,89606],{"x":228,"y":89604,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"342","Daily + minute",[175,89607,89608],{"x":228,"dy":177},"caps",[100,89610],{"x":89550,"y":77221,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89612,89613,89614],{"x":168,"y":89604,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"Free models",[175,89615,89616],{"x":168,"dy":177},"capped per day",[100,89618],{"x":140,"y":178,"width":69495,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89620,89622],{"x":23367,"y":89621,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":14195},"441","Setup effort",[100,89624],{"x":67224,"y":178,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89626,89628,89629],{"x":228,"y":89627,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":108,"textAnchor":119},"434","One key,",[175,89630,89631],{"x":228,"dy":177},"no card",[100,89633],{"x":89550,"y":178,"width":37100,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,89635,89628,89636],{"x":168,"y":89627,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":108,"textAnchor":119},[175,89637,89631],{"x":168,"dy":177},[232,89639,89640],{},"Groq wins on raw speed and a simple menu; OpenRouter wins on the sheer number of models behind one key. Both start free with just an account.",[57,89642,238],{"id":237},[14,89644,89645,89646,89648,89649,89651],{},"You only need a working Python 3.10 or newer setup and the ",[18,89647,20],{}," SDK. If you have not installed it yet, the parent ",[51,89650,2487],{"href":2486}," guide covers it; here is the short version:",[253,89653,89655],{"className":255,"code":89654,"language":257,"meta":258,"style":258},"python --version                       # must print 3.10 or higher\npython -m venv .venv\nsource .venv\u002Fbin\u002Factivate               # Windows: .venv\\Scripts\\activate\npip install \"openai>=1.40\" \"python-dotenv>=1.0\"\n",[18,89656,89657,89666,89676,89685],{"__ignoreMap":258},[262,89658,89659,89661,89663],{"class":181,"line":264},[262,89660,416],{"class":267},[262,89662,24497],{"class":271},[262,89664,89665],{"class":291},"                       # must print 3.10 or higher\n",[262,89667,89668,89670,89672,89674],{"class":181,"line":282},[262,89669,416],{"class":267},[262,89671,272],{"class":271},[262,89673,276],{"class":275},[262,89675,279],{"class":275},[262,89677,89678,89680,89682],{"class":181,"line":295},[262,89679,285],{"class":271},[262,89681,288],{"class":275},[262,89683,89684],{"class":291},"               # Windows: .venv\\Scripts\\activate\n",[262,89686,89687,89689,89691,89693],{"class":181,"line":345},[262,89688,298],{"class":267},[262,89690,301],{"class":275},[262,89692,304],{"class":275},[262,89694,82477],{"class":275},[14,89696,89697,89698,89700],{},"Now sign up for both services and grab a key from each dashboard. Neither asks for a credit card to begin. Put both keys in a single ",[18,89699,319],{}," file (a plain text file that holds your secrets so they never sit inside your code):",[253,89702,89704],{"className":323,"code":89703,"language":325,"meta":258,"style":258},"GROQ_API_KEY=gsk_your_real_groq_key_here\nOPENROUTER_API_KEY=sk-or-your_real_openrouter_key_here\n",[18,89705,89706,89711],{"__ignoreMap":258},[262,89707,89708],{"class":181,"line":264},[262,89709,89710],{},"GROQ_API_KEY=gsk_your_real_groq_key_here\n",[262,89712,89713],{"class":181,"line":282},[262,89714,89715],{},"OPENROUTER_API_KEY=sk-or-your_real_openrouter_key_here\n",[14,89717,89718,89724],{},[35,89719,7251,89720,356,89722],{},[18,89721,319],{},[18,89723,359],{}," so a key never gets committed and shared:",[253,89726,89727],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,89728,89729],{"__ignoreMap":258},[262,89730,89731,89733,89735,89737],{"class":181,"line":264},[262,89732,371],{"class":271},[262,89734,374],{"class":275},[262,89736,378],{"class":377},[262,89738,381],{"class":275},[14,89740,89741],{},"A key pushed to a public repository can be found and abused within minutes, and the usage lands on your account — so this one line matters more than any code below.",[57,89743,89745],{"id":89744},"the-key-idea-one-sdk-two-base-urls","The key idea: one SDK, two base URLs",[14,89747,89748,89749,89752,89753,89755,89756,89758],{},"Every OpenAI-compatible service has a ",[35,89750,89751],{},"base URL"," — the web address the SDK sends requests to. By default the ",[18,89754,20],{}," SDK points at OpenAI's own address. To talk to Groq or OpenRouter, you override ",[18,89757,80973],{}," when you create the client and pass that service's key. The model name changes too, because each service uses its own catalogue. Nothing else moves.",[14,89760,89761],{},"Here is the same first request written for each. Notice how little differs.",[12782,89763,89765],{"id":89764},"calling-groq-from-python","Calling Groq from Python",[253,89767,89769],{"className":414,"code":89768,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # loads GROQ_API_KEY from .env (which is in .gitignore)\n\nclient = OpenAI(\n    api_key=os.getenv(\"GROQ_API_KEY\"),\n    base_url=\"https:\u002F\u002Fapi.groq.com\u002Fopenai\u002Fv1\",   # point the SDK at Groq\n)\n\nresponse = client.chat.completions.create(\n    model=\"llama-3.3-70b-versatile\",             # a fast model on Groq's menu\n    messages=[\n        {\"role\": \"system\", \"content\": \"You are a concise assistant.\"},\n        {\"role\": \"user\", \"content\": \"Explain what a free API tier is in one sentence.\"},\n    ],\n)\n\nprint(response.choices[0].message.content)\nprint(\"Tokens used:\", response.usage.total_tokens)\n",[18,89770,89771,89777,89787,89797,89801,89808,89812,89820,89832,89845,89849,89853,89861,89876,89884,89905,89926,89930,89934,89938,89948],{"__ignoreMap":258},[262,89772,89773,89775],{"class":181,"line":264},[262,89774,684],{"class":377},[262,89776,687],{"class":429},[262,89778,89779,89781,89783,89785],{"class":181,"line":282},[262,89780,705],{"class":377},[262,89782,708],{"class":429},[262,89784,684],{"class":377},[262,89786,713],{"class":429},[262,89788,89789,89791,89793,89795],{"class":181,"line":295},[262,89790,705],{"class":377},[262,89792,720],{"class":429},[262,89794,684],{"class":377},[262,89796,725],{"class":429},[262,89798,89799],{"class":181,"line":345},[262,89800,583],{"emptyLinePlaceholder":582},[262,89802,89803,89805],{"class":181,"line":492},[262,89804,4222],{"class":429},[262,89806,89807],{"class":291},"# loads GROQ_API_KEY from .env (which is in .gitignore)\n",[262,89809,89810],{"class":181,"line":503},[262,89811,583],{"emptyLinePlaceholder":582},[262,89813,89814,89816,89818],{"class":181,"line":521},[262,89815,739],{"class":429},[262,89817,476],{"class":377},[262,89819,81027],{"class":429},[262,89821,89822,89824,89826,89828,89830],{"class":181,"line":537},[262,89823,81043],{"class":611},[262,89825,476],{"class":377},[262,89827,1199],{"class":429},[262,89829,81050],{"class":275},[262,89831,1210],{"class":429},[262,89833,89834,89836,89838,89840,89842],{"class":181,"line":549},[262,89835,37458],{"class":611},[262,89837,476],{"class":377},[262,89839,81036],{"class":275},[262,89841,87265],{"class":429},[262,89843,89844],{"class":291},"# point the SDK at Groq\n",[262,89846,89847],{"class":181,"line":570},[262,89848,660],{"class":429},[262,89850,89851],{"class":181,"line":579},[262,89852,583],{"emptyLinePlaceholder":582},[262,89854,89855,89857,89859],{"class":181,"line":586},[262,89856,48362],{"class":429},[262,89858,476],{"class":377},[262,89860,1189],{"class":429},[262,89862,89863,89865,89867,89870,89873],{"class":181,"line":591},[262,89864,48371],{"class":611},[262,89866,476],{"class":377},[262,89868,89869],{"class":275},"\"llama-3.3-70b-versatile\"",[262,89871,89872],{"class":429},",             ",[262,89874,89875],{"class":291},"# a fast model on Groq's menu\n",[262,89877,89878,89880,89882],{"class":181,"line":623},[262,89879,48388],{"class":611},[262,89881,476],{"class":377},[262,89883,1220],{"class":429},[262,89885,89886,89888,89890,89892,89894,89896,89898,89900,89903],{"class":181,"line":634},[262,89887,7726],{"class":429},[262,89889,1228],{"class":275},[262,89891,1231],{"class":429},[262,89893,1234],{"class":275},[262,89895,608],{"class":429},[262,89897,1239],{"class":275},[262,89899,1231],{"class":429},[262,89901,89902],{"class":275},"\"You are a concise assistant.\"",[262,89904,3143],{"class":429},[262,89906,89907,89909,89911,89913,89915,89917,89919,89921,89924],{"class":181,"line":845},[262,89908,7726],{"class":429},[262,89910,1228],{"class":275},[262,89912,1231],{"class":429},[262,89914,1291],{"class":275},[262,89916,608],{"class":429},[262,89918,1239],{"class":275},[262,89920,1231],{"class":429},[262,89922,89923],{"class":275},"\"Explain what a free API tier is in one sentence.\"",[262,89925,3143],{"class":429},[262,89927,89928],{"class":181,"line":850},[262,89929,48439],{"class":429},[262,89931,89932],{"class":181,"line":864},[262,89933,660],{"class":429},[262,89935,89936],{"class":181,"line":1683},[262,89937,583],{"emptyLinePlaceholder":582},[262,89939,89940,89942,89944,89946],{"class":181,"line":1688},[262,89941,637],{"class":271},[262,89943,48465],{"class":429},[262,89945,102],{"class":271},[262,89947,6048],{"class":429},[262,89949,89950,89952,89954,89957],{"class":181,"line":1693},[262,89951,637],{"class":271},[262,89953,602],{"class":429},[262,89955,89956],{"class":275},"\"Tokens used:\"",[262,89958,89959],{"class":429},", response.usage.total_tokens)\n",[14,89961,89962,89963,89965,89966,80817,89968,89970,89971,89973,89974,89976],{},"Run this and the reply usually arrives almost instantly — Groq's whole reason to exist is low latency. The ",[18,89964,43269],{}," list, the way you read ",[18,89967,7909],{},[18,89969,58158],{}," counts are identical to plain OpenAI. Only ",[18,89972,80973],{},", the key, and ",[18,89975,805],{}," are Groq-specific.",[12782,89978,89980],{"id":89979},"calling-openrouter-from-python","Calling OpenRouter from Python",[253,89982,89984],{"className":414,"code":89983,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # loads OPENROUTER_API_KEY from .env (which is in .gitignore)\n\nclient = OpenAI(\n    api_key=os.getenv(\"OPENROUTER_API_KEY\"),\n    base_url=\"https:\u002F\u002Fopenrouter.ai\u002Fapi\u002Fv1\",     # point the SDK at OpenRouter\n)\n\nresponse = client.chat.completions.create(\n    model=\"meta-llama\u002Fllama-3.3-70b-instruct:free\",  # the :free suffix = no charge\n    messages=[\n        {\"role\": \"system\", \"content\": \"You are a concise assistant.\"},\n        {\"role\": \"user\", \"content\": \"Explain what an API marketplace is in one sentence.\"},\n    ],\n    extra_headers={                              # optional, used for OpenRouter rankings\n        \"HTTP-Referer\": \"https:\u002F\u002Fyour-site.example\",\n        \"X-Title\": \"My Beginner App\",\n    },\n)\n\nprint(response.choices[0].message.content)\nprint(\"Tokens used:\", response.usage.total_tokens)\n",[18,89985,89986,89992,90002,90012,90016,90023,90027,90035,90047,90061,90065,90069,90077,90091,90099,90119,90140,90144,90157,90169,90181,90185,90189,90193,90203],{"__ignoreMap":258},[262,89987,89988,89990],{"class":181,"line":264},[262,89989,684],{"class":377},[262,89991,687],{"class":429},[262,89993,89994,89996,89998,90000],{"class":181,"line":282},[262,89995,705],{"class":377},[262,89997,708],{"class":429},[262,89999,684],{"class":377},[262,90001,713],{"class":429},[262,90003,90004,90006,90008,90010],{"class":181,"line":295},[262,90005,705],{"class":377},[262,90007,720],{"class":429},[262,90009,684],{"class":377},[262,90011,725],{"class":429},[262,90013,90014],{"class":181,"line":345},[262,90015,583],{"emptyLinePlaceholder":582},[262,90017,90018,90020],{"class":181,"line":492},[262,90019,4222],{"class":429},[262,90021,90022],{"class":291},"# loads OPENROUTER_API_KEY from .env (which is in .gitignore)\n",[262,90024,90025],{"class":181,"line":503},[262,90026,583],{"emptyLinePlaceholder":582},[262,90028,90029,90031,90033],{"class":181,"line":521},[262,90030,739],{"class":429},[262,90032,476],{"class":377},[262,90034,81027],{"class":429},[262,90036,90037,90039,90041,90043,90045],{"class":181,"line":537},[262,90038,81043],{"class":611},[262,90040,476],{"class":377},[262,90042,1199],{"class":429},[262,90044,81192],{"class":275},[262,90046,1210],{"class":429},[262,90048,90049,90051,90053,90055,90058],{"class":181,"line":549},[262,90050,37458],{"class":611},[262,90052,476],{"class":377},[262,90054,81179],{"class":275},[262,90056,90057],{"class":429},",     ",[262,90059,90060],{"class":291},"# point the SDK at OpenRouter\n",[262,90062,90063],{"class":181,"line":570},[262,90064,660],{"class":429},[262,90066,90067],{"class":181,"line":579},[262,90068,583],{"emptyLinePlaceholder":582},[262,90070,90071,90073,90075],{"class":181,"line":586},[262,90072,48362],{"class":429},[262,90074,476],{"class":377},[262,90076,1189],{"class":429},[262,90078,90079,90081,90083,90086,90088],{"class":181,"line":591},[262,90080,48371],{"class":611},[262,90082,476],{"class":377},[262,90084,90085],{"class":275},"\"meta-llama\u002Fllama-3.3-70b-instruct:free\"",[262,90087,13488],{"class":429},[262,90089,90090],{"class":291},"# the :free suffix = no charge\n",[262,90092,90093,90095,90097],{"class":181,"line":623},[262,90094,48388],{"class":611},[262,90096,476],{"class":377},[262,90098,1220],{"class":429},[262,90100,90101,90103,90105,90107,90109,90111,90113,90115,90117],{"class":181,"line":634},[262,90102,7726],{"class":429},[262,90104,1228],{"class":275},[262,90106,1231],{"class":429},[262,90108,1234],{"class":275},[262,90110,608],{"class":429},[262,90112,1239],{"class":275},[262,90114,1231],{"class":429},[262,90116,89902],{"class":275},[262,90118,3143],{"class":429},[262,90120,90121,90123,90125,90127,90129,90131,90133,90135,90138],{"class":181,"line":845},[262,90122,7726],{"class":429},[262,90124,1228],{"class":275},[262,90126,1231],{"class":429},[262,90128,1291],{"class":275},[262,90130,608],{"class":429},[262,90132,1239],{"class":275},[262,90134,1231],{"class":429},[262,90136,90137],{"class":275},"\"Explain what an API marketplace is in one sentence.\"",[262,90139,3143],{"class":429},[262,90141,90142],{"class":181,"line":850},[262,90143,48439],{"class":429},[262,90145,90146,90149,90151,90154],{"class":181,"line":864},[262,90147,90148],{"class":611},"    extra_headers",[262,90150,476],{"class":377},[262,90152,90153],{"class":429},"{                              ",[262,90155,90156],{"class":291},"# optional, used for OpenRouter rankings\n",[262,90158,90159,90162,90164,90167],{"class":181,"line":1683},[262,90160,90161],{"class":275},"        \"HTTP-Referer\"",[262,90163,1231],{"class":429},[262,90165,90166],{"class":275},"\"https:\u002F\u002Fyour-site.example\"",[262,90168,1315],{"class":429},[262,90170,90171,90174,90176,90179],{"class":181,"line":1688},[262,90172,90173],{"class":275},"        \"X-Title\"",[262,90175,1231],{"class":429},[262,90177,90178],{"class":275},"\"My Beginner App\"",[262,90180,1315],{"class":429},[262,90182,90183],{"class":181,"line":1693},[262,90184,5635],{"class":429},[262,90186,90187],{"class":181,"line":1728},[262,90188,660],{"class":429},[262,90190,90191],{"class":181,"line":1737},[262,90192,583],{"emptyLinePlaceholder":582},[262,90194,90195,90197,90199,90201],{"class":181,"line":1751},[262,90196,637],{"class":271},[262,90198,48465],{"class":429},[262,90200,102],{"class":271},[262,90202,6048],{"class":429},[262,90204,90205,90207,90209,90211],{"class":181,"line":1764},[262,90206,637],{"class":271},[262,90208,602],{"class":429},[262,90210,89956],{"class":275},[262,90212,89959],{"class":429},[14,90214,90215,90216,90218,90219,90222,90223,90226],{},"The only structural difference is the ",[18,90217,81156],{}," model name and the optional ",[18,90220,90221],{},"extra_headers",", which OpenRouter uses to attribute traffic on its public rankings (you can omit them entirely). Switch to a different model — say ",[18,90224,90225],{},"mistralai\u002Fmistral-7b-instruct:free"," — by changing one string, and that is the marketplace's superpower.",[57,90228,90230],{"id":90229},"one-function-that-talks-to-either-service","One function that talks to either service",[14,90232,90233],{},"Because the calling code is nearly identical, you can wrap both behind a single helper and flip between them with one argument. This is the pattern to keep once your experiments grow.",[253,90235,90237],{"className":414,"code":90236,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # both keys come from .env (which is in .gitignore)\n\n# A small registry of where each provider lives and which model to use.\nPROVIDERS = {\n    \"groq\": {\n        \"base_url\": \"https:\u002F\u002Fapi.groq.com\u002Fopenai\u002Fv1\",\n        \"key_env\": \"GROQ_API_KEY\",\n        \"model\": \"llama-3.3-70b-versatile\",\n    },\n    \"openrouter\": {\n        \"base_url\": \"https:\u002F\u002Fopenrouter.ai\u002Fapi\u002Fv1\",\n        \"key_env\": \"OPENROUTER_API_KEY\",\n        \"model\": \"meta-llama\u002Fllama-3.3-70b-instruct:free\",\n    },\n}\n\n\ndef ask(prompt: str, provider: str = \"groq\") -> str:\n    \"\"\"Send one prompt to the chosen provider and return the reply text.\"\"\"\n    config = PROVIDERS[provider]\n    client = OpenAI(\n        api_key=os.getenv(config[\"key_env\"]),\n        base_url=config[\"base_url\"],\n    )\n    response = client.chat.completions.create(\n        model=config[\"model\"],\n        messages=[\n            {\"role\": \"system\", \"content\": \"You are a concise, helpful assistant.\"},\n            {\"role\": \"user\", \"content\": prompt},\n        ],\n        temperature=0.3,   # low = consistent answers\n        max_tokens=200,    # cap the reply to stay inside free limits\n    )\n    usage = response.usage\n    print(f\"[{provider}] tokens: {usage.total_tokens}\")  # keep usage visible\n    return response.choices[0].message.content\n\n\nif __name__ == \"__main__\":\n    question = \"Summarise the difference between Groq and OpenRouter in two sentences.\"\n    print(\"--- Groq ---\")\n    print(ask(question, provider=\"groq\"))\n    print(\"\\n--- OpenRouter ---\")\n    print(ask(question, provider=\"openrouter\"))\n",[18,90238,90239,90245,90255,90265,90269,90276,90280,90285,90294,90300,90311,90322,90333,90337,90343,90353,90363,90373,90377,90381,90385,90389,90415,90420,90433,90442,90457,90471,90475,90483,90496,90504,90524,90540,90544,90557,90571,90575,90585,90618,90628,90632,90636,90648,90658,90669,90684,90699],{"__ignoreMap":258},[262,90240,90241,90243],{"class":181,"line":264},[262,90242,684],{"class":377},[262,90244,687],{"class":429},[262,90246,90247,90249,90251,90253],{"class":181,"line":282},[262,90248,705],{"class":377},[262,90250,708],{"class":429},[262,90252,684],{"class":377},[262,90254,713],{"class":429},[262,90256,90257,90259,90261,90263],{"class":181,"line":295},[262,90258,705],{"class":377},[262,90260,720],{"class":429},[262,90262,684],{"class":377},[262,90264,725],{"class":429},[262,90266,90267],{"class":181,"line":345},[262,90268,583],{"emptyLinePlaceholder":582},[262,90270,90271,90273],{"class":181,"line":492},[262,90272,4222],{"class":429},[262,90274,90275],{"class":291},"# both keys come from .env (which is in .gitignore)\n",[262,90277,90278],{"class":181,"line":503},[262,90279,583],{"emptyLinePlaceholder":582},[262,90281,90282],{"class":181,"line":521},[262,90283,90284],{"class":291},"# A small registry of where each provider lives and which model to use.\n",[262,90286,90287,90290,90292],{"class":181,"line":537},[262,90288,90289],{"class":271},"PROVIDERS",[262,90291,442],{"class":377},[262,90293,20437],{"class":429},[262,90295,90296,90298],{"class":181,"line":549},[262,90297,81510],{"class":275},[262,90299,35273],{"class":429},[262,90301,90302,90305,90307,90309],{"class":181,"line":570},[262,90303,90304],{"class":275},"        \"base_url\"",[262,90306,1231],{"class":429},[262,90308,81036],{"class":275},[262,90310,1315],{"class":429},[262,90312,90313,90316,90318,90320],{"class":181,"line":579},[262,90314,90315],{"class":275},"        \"key_env\"",[262,90317,1231],{"class":429},[262,90319,81050],{"class":275},[262,90321,1315],{"class":429},[262,90323,90324,90327,90329,90331],{"class":181,"line":586},[262,90325,90326],{"class":275},"        \"model\"",[262,90328,1231],{"class":429},[262,90330,89869],{"class":275},[262,90332,1315],{"class":429},[262,90334,90335],{"class":181,"line":591},[262,90336,5635],{"class":429},[262,90338,90339,90341],{"class":181,"line":623},[262,90340,81547],{"class":275},[262,90342,35273],{"class":429},[262,90344,90345,90347,90349,90351],{"class":181,"line":634},[262,90346,90304],{"class":275},[262,90348,1231],{"class":429},[262,90350,81179],{"class":275},[262,90352,1315],{"class":429},[262,90354,90355,90357,90359,90361],{"class":181,"line":845},[262,90356,90315],{"class":275},[262,90358,1231],{"class":429},[262,90360,81192],{"class":275},[262,90362,1315],{"class":429},[262,90364,90365,90367,90369,90371],{"class":181,"line":850},[262,90366,90326],{"class":275},[262,90368,1231],{"class":429},[262,90370,90085],{"class":275},[262,90372,1315],{"class":429},[262,90374,90375],{"class":181,"line":864},[262,90376,5635],{"class":429},[262,90378,90379],{"class":181,"line":1683},[262,90380,16430],{"class":429},[262,90382,90383],{"class":181,"line":1688},[262,90384,583],{"emptyLinePlaceholder":582},[262,90386,90387],{"class":181,"line":1693},[262,90388,583],{"emptyLinePlaceholder":582},[262,90390,90391,90393,90395,90397,90399,90402,90404,90406,90409,90411,90413],{"class":181,"line":1728},[262,90392,423],{"class":377},[262,90394,44066],{"class":267},[262,90396,9599],{"class":429},[262,90398,433],{"class":271},[262,90400,90401],{"class":429},", provider: ",[262,90403,433],{"class":271},[262,90405,442],{"class":377},[262,90407,90408],{"class":275}," \"groq\"",[262,90410,1939],{"class":429},[262,90412,433],{"class":271},[262,90414,1160],{"class":429},[262,90416,90417],{"class":181,"line":1737},[262,90418,90419],{"class":275},"    \"\"\"Send one prompt to the chosen provider and return the reply text.\"\"\"\n",[262,90421,90422,90425,90427,90430],{"class":181,"line":1751},[262,90423,90424],{"class":429},"    config ",[262,90426,476],{"class":377},[262,90428,90429],{"class":271}," PROVIDERS",[262,90431,90432],{"class":429},"[provider]\n",[262,90434,90435,90438,90440],{"class":181,"line":1764},[262,90436,90437],{"class":429},"    client ",[262,90439,476],{"class":377},[262,90441,81027],{"class":429},[262,90443,90444,90446,90448,90451,90454],{"class":181,"line":1779},[262,90445,81529],{"class":611},[262,90447,476],{"class":377},[262,90449,90450],{"class":429},"os.getenv(config[",[262,90452,90453],{"class":275},"\"key_env\"",[262,90455,90456],{"class":429},"]),\n",[262,90458,90459,90461,90463,90466,90469],{"class":181,"line":1793},[262,90460,81518],{"class":611},[262,90462,476],{"class":377},[262,90464,90465],{"class":429},"config[",[262,90467,90468],{"class":275},"\"base_url\"",[262,90470,10309],{"class":429},[262,90472,90473],{"class":181,"line":1800},[262,90474,1011],{"class":429},[262,90476,90477,90479,90481],{"class":181,"line":1805},[262,90478,1184],{"class":429},[262,90480,476],{"class":377},[262,90482,1189],{"class":429},[262,90484,90485,90487,90489,90491,90494],{"class":181,"line":1810},[262,90486,1194],{"class":611},[262,90488,476],{"class":377},[262,90490,90465],{"class":429},[262,90492,90493],{"class":275},"\"model\"",[262,90495,10309],{"class":429},[262,90497,90498,90500,90502],{"class":181,"line":1823},[262,90499,1215],{"class":611},[262,90501,476],{"class":377},[262,90503,1220],{"class":429},[262,90505,90506,90508,90510,90512,90514,90516,90518,90520,90522],{"class":181,"line":1846},[262,90507,1225],{"class":429},[262,90509,1228],{"class":275},[262,90511,1231],{"class":429},[262,90513,1234],{"class":275},[262,90515,608],{"class":429},[262,90517,1239],{"class":275},[262,90519,1231],{"class":429},[262,90521,58263],{"class":275},[262,90523,3143],{"class":429},[262,90525,90526,90528,90530,90532,90534,90536,90538],{"class":181,"line":1861},[262,90527,1225],{"class":429},[262,90529,1228],{"class":275},[262,90531,1231],{"class":429},[262,90533,1291],{"class":275},[262,90535,608],{"class":429},[262,90537,1239],{"class":275},[262,90539,38272],{"class":429},[262,90541,90542],{"class":181,"line":1866},[262,90543,1303],{"class":429},[262,90545,90546,90548,90550,90552,90554],{"class":181,"line":1871},[262,90547,1308],{"class":611},[262,90549,476],{"class":377},[262,90551,3924],{"class":271},[262,90553,87265],{"class":429},[262,90555,90556],{"class":291},"# low = consistent answers\n",[262,90558,90559,90561,90563,90565,90568],{"class":181,"line":1890},[262,90560,4679],{"class":611},[262,90562,476],{"class":377},[262,90564,104],{"class":271},[262,90566,90567],{"class":429},",    ",[262,90569,90570],{"class":291},"# cap the reply to stay inside free limits\n",[262,90572,90573],{"class":181,"line":1909},[262,90574,1011],{"class":429},[262,90576,90577,90580,90582],{"class":181,"line":1914},[262,90578,90579],{"class":429},"    usage ",[262,90581,476],{"class":377},[262,90583,90584],{"class":429}," response.usage\n",[262,90586,90587,90589,90591,90593,90595,90597,90600,90602,90605,90607,90609,90611,90613,90615],{"class":181,"line":1919},[262,90588,1089],{"class":271},[262,90590,602],{"class":429},[262,90592,642],{"class":377},[262,90594,3527],{"class":275},[262,90596,3039],{"class":271},[262,90598,90599],{"class":429},"provider",[262,90601,654],{"class":271},[262,90603,90604],{"class":275},"] tokens: ",[262,90606,3039],{"class":271},[262,90608,71173],{"class":429},[262,90610,654],{"class":271},[262,90612,1176],{"class":275},[262,90614,32223],{"class":429},[262,90616,90617],{"class":291},"# keep usage visible\n",[262,90619,90620,90622,90624,90626],{"class":181,"line":1946},[262,90621,573],{"class":377},[262,90623,1326],{"class":429},[262,90625,102],{"class":271},[262,90627,1331],{"class":429},[262,90629,90630],{"class":181,"line":1959},[262,90631,583],{"emptyLinePlaceholder":582},[262,90633,90634],{"class":181,"line":1996},[262,90635,583],{"emptyLinePlaceholder":582},[262,90637,90638,90640,90642,90644,90646],{"class":181,"line":2012},[262,90639,2210],{"class":377},[262,90641,2213],{"class":271},[262,90643,2216],{"class":377},[262,90645,2219],{"class":275},[262,90647,1160],{"class":429},[262,90649,90650,90653,90655],{"class":181,"line":2040},[262,90651,90652],{"class":429},"    question ",[262,90654,476],{"class":377},[262,90656,90657],{"class":275}," \"Summarise the difference between Groq and OpenRouter in two sentences.\"\n",[262,90659,90660,90662,90664,90667],{"class":181,"line":2045},[262,90661,1089],{"class":271},[262,90663,602],{"class":429},[262,90665,90666],{"class":275},"\"--- Groq ---\"",[262,90668,660],{"class":429},[262,90670,90671,90673,90676,90678,90680,90682],{"class":181,"line":2050},[262,90672,1089],{"class":271},[262,90674,90675],{"class":429},"(ask(question, ",[262,90677,90599],{"class":611},[262,90679,476],{"class":377},[262,90681,81854],{"class":275},[262,90683,2684],{"class":429},[262,90685,90686,90688,90690,90692,90694,90697],{"class":181,"line":2067},[262,90687,1089],{"class":271},[262,90689,602],{"class":429},[262,90691,1176],{"class":275},[262,90693,2137],{"class":271},[262,90695,90696],{"class":275},"--- OpenRouter ---\"",[262,90698,660],{"class":429},[262,90700,90701,90703,90705,90707,90709,90711],{"class":181,"line":2077},[262,90702,1089],{"class":271},[262,90704,90675],{"class":429},[262,90706,90599],{"class":611},[262,90708,476],{"class":377},[262,90710,81870],{"class":275},[262,90712,2684],{"class":429},[14,90714,13310,90715,90718],{},[18,90716,90717],{},"python compare.py"," and you get the same question answered by both services, with a token count for each. Switching providers is now a one-word change — exactly the flexibility the OpenAI-compatible format buys you.",[57,90720,90722],{"id":90721},"key-configuration-quick-reference","Key configuration quick-reference",[14,90724,90725],{},"These are the only values that differ between the two providers. Everything else in your request stays the same.",[1379,90727,90728,90739],{},[1382,90729,90730],{},[1385,90731,90732,90735,90737],{},[1388,90733,90734],{},"Setting",[1388,90736,80813],{},[1388,90738,80816],{},[1398,90740,90741,90757,90778],{},[1385,90742,90743,90747,90752],{},[1403,90744,90745],{},[18,90746,80973],{},[1403,90748,90749],{},[18,90750,90751],{},"https:\u002F\u002Fapi.groq.com\u002Fopenai\u002Fv1",[1403,90753,90754],{},[18,90755,90756],{},"https:\u002F\u002Fopenrouter.ai\u002Fapi\u002Fv1",[1385,90758,90759,90763,90771],{},[1403,90760,90761],{},[18,90762,2674],{},[1403,90764,90765,90767,90768,5987],{},[18,90766,80889],{}," (starts ",[18,90769,90770],{},"gsk_",[1403,90772,90773,90767,90775,5987],{},[18,90774,80897],{},[18,90776,90777],{},"sk-or-",[1385,90779,90780,90784,90790],{},[1403,90781,90782],{},[18,90783,805],{},[1403,90785,90786,90787],{},"e.g. ",[18,90788,90789],{},"llama-3.3-70b-versatile",[1403,90791,90786,90792],{},[18,90793,90794],{},"meta-llama\u002Fllama-3.3-70b-instruct:free",[57,90796,90798],{"id":90797},"how-the-free-tiers-actually-limit-you","How the free tiers actually limit you",[14,90800,90801],{},"Free does not mean unlimited. Knowing each cap stops a confusing failure mid-project.",[2322,90803,90804,90813],{},[1450,90805,90806,90808,90809,90812],{},[35,90807,80813],{}," limits you by ",[35,90810,90811],{},"requests and tokens per minute and per day",". Hit the per-minute cap and you get a 429 error; wait a moment and it clears. The daily cap resets each day. Limits vary by model, and faster models tend to have tighter ones.",[1450,90814,90815,90817,90818,90821],{},[35,90816,80816],{}," caps its ",[35,90819,90820],{},"free models per day"," across your account, with a low per-minute ceiling. Adding a small credit balance can lift the daily ceiling on free models even though you are not paying for the calls themselves. Paid models bill per token with no such daily cap.",[14,90823,90824,90825,90827],{},"Either way, the symptom of going over is the same 429 response. The full recovery pattern — waiting and retrying with an increasing delay — lives in ",[51,90826,3379],{"href":3378},", and it works unchanged against both services.",[57,90829,1445],{"id":1444},[1447,90831,90832,90849,90860,90870],{},[1450,90833,90834,90839,90840,90843,90844,90846,90847,1363],{},[35,90835,90836],{},[18,90837,90838],{},"AuthenticationError: Error code: 401"," — The wrong key is reaching the wrong service. A Groq key (",[18,90841,90842],{},"gsk_...",") sent to OpenRouter's ",[18,90845,80973],{},", or vice versa, fails here. Confirm each client uses the matching key and address. The complete fix is in ",[51,90848,388],{"href":387},[1450,90850,90851,90856,90857,90859],{},[35,90852,90853],{},[18,90854,90855],{},"NotFoundError: Error code: 404 - model not found"," — The model name does not exist on that provider. Groq and OpenRouter use different catalogues, and on OpenRouter free models need the exact ",[18,90858,81156],{}," suffix. Copy the model id straight from the provider's model list.",[1450,90861,90862,90867,90868,1363],{},[35,90863,90864],{},[18,90865,90866],{},"RateLimitError: Error code: 429"," — You exceeded the free per-minute or daily cap. Slow down, retry after a short pause, or switch to the other provider for that run. See ",[51,90869,3379],{"href":3378},[1450,90871,90872,90880,90881,90884],{},[35,90873,90874,90875,90877,90878,5987],{},"Empty or ",[18,90876,8471],{}," reply (",[18,90879,58575],{}," — A free model occasionally returns nothing under heavy load. Check ",[18,90882,90883],{},"response.choices[0].finish_reason"," before reading the text, and retry once if it is empty rather than assuming a string is always there.",[57,90886,90888],{"id":90887},"when-to-use-which","When to use which",[2322,90890,90891,90897,90903,90909],{},[1450,90892,90893,90896],{},[35,90894,90895],{},"Use Groq when latency matters."," Live demos, a chatbot that should feel instant, or any loop where you call the model many times in a row — Groq's speed is the deciding factor.",[1450,90898,90899,90902],{},[35,90900,90901],{},"Use OpenRouter when you want choice."," Trying a model Groq does not host, comparing several models side by side, or keeping one key and one bill across many providers — that breadth is the whole point of a marketplace.",[1450,90904,90905,90908],{},[35,90906,90907],{},"Use Groq to learn the basics fast, then OpenRouter to explore."," Start on Groq because the menu is small and the replies are quick, then move to OpenRouter once you want to test models Groq lacks.",[1450,90910,90911,90914,90915,90917,90918,1363],{},[35,90912,90913],{},"Use either as a free practice ground before paying."," Both let you master the ",[51,90916,69506],{"href":2486}," workflow at zero cost; once you need top-tier paid models, weigh them in ",[51,90919,14635],{"href":14634},[14,90921,90922,90923,1363],{},"For a wider tour of cost-free options beyond these two, see ",[51,90924,5485],{"href":5484},[14,90926,2375,90927,1363],{},[51,90928,2487],{"href":2486},[57,90930,2381],{"id":2380},[2322,90932,90933,90938,90943,90948],{},[1450,90934,90935,90937],{},[51,90936,2487],{"href":2486}," — the main guide on how API calls work, the parent of this comparison.",[1450,90939,90940,90942],{},[51,90941,5485],{"href":5484}," — a wider survey of services you can use without paying.",[1450,90944,90945,90947],{},[51,90946,14635],{"href":14634}," — compare the two leading paid providers when you outgrow free tiers.",[1450,90949,90950,90952],{},[51,90951,3379],{"href":3378}," — handle the cap you will eventually hit on either free tier.",[2401,90954,90955],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":258,"searchDepth":282,"depth":282,"links":90957},[90958,90959,90960,90964,90965,90966,90967,90968,90969],{"id":89506,"depth":282,"text":89507},{"id":237,"depth":282,"text":238},{"id":89744,"depth":282,"text":89745,"children":90961},[90962,90963],{"id":89764,"depth":295,"text":89765},{"id":89979,"depth":295,"text":89980},{"id":90229,"depth":282,"text":90230},{"id":90721,"depth":282,"text":90722},{"id":90797,"depth":282,"text":90798},{"id":1444,"depth":282,"text":1445},{"id":90887,"depth":282,"text":90888},{"id":2380,"depth":282,"text":2381},"Compare the Groq and OpenRouter free tiers for beginners: models, latency, rate limits, and how to call each from Python with the OpenAI-compatible SDK.",[90972,90975,90978,90981,90984],{"q":90973,"a":90974},"Are the Groq and OpenRouter free tiers really free?","Both let you make real model calls without entering a credit card to start. Groq gives free access with daily and per-minute caps. OpenRouter offers a set of free models (marked with a :free suffix) plus paid models you can top up later, so you can begin at zero cost on both.",{"q":90976,"a":90977},"Can I use the openai Python SDK with Groq and OpenRouter?","Yes. Both services are OpenAI-compatible, meaning they accept the same request shape as OpenAI. You install the openai SDK once, then point its base_url at Groq or OpenRouter and pass that service's key. Your calling code stays almost identical between them.",{"q":90979,"a":90980},"Which one is faster, Groq or OpenRouter?","Groq is built for speed and typically streams tokens far faster than most providers because it runs models on custom hardware. OpenRouter routes your request to a third-party host, so its speed depends on which model and provider you pick. For raw latency, Groq usually wins.",{"q":90982,"a":90983},"Why would I choose OpenRouter over Groq?","Choose OpenRouter when you want one key to reach many different models, including ones Groq does not host, and a single bill if you later pay. It is a marketplace, so it trades a little speed for far more model choice and easy switching.",{"q":90985,"a":90986},"Do I need different code to switch between them?","Almost none. Because both speak the OpenAI format, you change only the base_url, the API key, and the model name. Everything else (messages, temperature, reading the reply) is the same, so you can wrap both behind one small function.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fgroq-vs-openrouter-free-tier",{"title":69908,"description":90970},"python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fgroq-vs-openrouter-free-tier\u002Findex","DkRE82SV3cU466KABHnKYVftLloEL0hjdpV4q2706XU",{"id":90993,"title":90994,"body":90995,"description":92849,"extension":2419,"faq":92850,"howto":92866,"meta":92881,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":92882,"published":92883,"seo":92884,"seoTitle":92885,"stem":92886,"__hash__":92887},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Findex.md","Understanding LLM APIs: A Step-by-Step Python Guide for Beginners",{"type":7,"value":90996,"toc":92836},[90997,91000,91003,91006,91011,91013,91016,91019,91022,91124,91128,91131,91141,91147,91150,91195,91216,91221,91229,91239,91253,91256,91260,91263,91420,91440,91444,91450,91565,91581,91585,91592,91785,91800,91804,91807,92000,92013,92015,92021,92208,92212,92215,92303,92307,92314,92746,92752,92754,92757,92801,92805,92807,92834],[10,90998,90994],{"id":90999},"understanding-llm-apis-a-step-by-step-python-guide-for-beginners",[14,91001,91002],{},"You have a task an AI could clearly help with: drafting replies, summarising a report, sorting messy notes. You have heard that large language models can do this. But every tutorial seems to assume you already know what an \"endpoint\" is, what a \"token\" costs, and why your first attempt returns a wall of red error text instead of an answer. That gap is what this guide closes.",[14,91004,91005],{},"A large language model API (application programming interface — a web address your program talks to) lets you borrow a powerful AI model over the internet. You never train it, host it, or even download it. Your Python script sends some text; a model running on the provider's servers writes a reply; the reply comes back to your script as data you can read and reuse. By the end of this guide you will install the right tools, store your key without leaking it, send a real request, understand every setting you can tune, and fix the four or five errors that trip up almost everyone on their first day.",[14,91007,48042,91008,91010],{},[51,91009,26450],{"href":26449},", written for creators, marketers, founders, and students who are comfortable copying a command but have never shipped production code.",[57,91012,12747],{"id":12746},[14,91014,91015],{},"You need this guide if you can run a Python file but have never made one talk to an AI service. The task is simple to state and surprisingly easy to get slightly wrong: take a string of text, send it to a model, and get a useful reply back, reliably, without exposing your billing key or blowing your budget.",[14,91017,91018],{},"We will build that piece by piece. First the environment, so nothing conflicts. Then secure key handling, so your credentials stay yours. Then a real request and a careful read of the response. Then the knobs you can turn to change how the model behaves. Each step is a runnable Python file, not a fragment, so you can paste it and watch it work.",[14,91020,91021],{},"The flow below is the whole mental model. Everything in this guide is a piece of this picture.",[76,91023,91025,91121],{"className":91024},[79],[81,91026,90,91031,90,91034,90,91037,90,91039,90,91046,90,91048,90,91054,90,91056,90,91062,90,91064,90,91070,90,91072,90,91075,90,91077,90,91080,90,91082,90,91085,90,91091,90,91094,90,91097,90,91100,90,91103,90,91109,90,91112,90,91115,90,91118],{"viewBox":91027,"role":84,"ariaLabelledBy":91028,"preserveAspectRatio":88,"xmlns":89},"-40 -40 1000 360",[91029,91030],"llmFlowTitle","llmFlowDesc",[92,91032,91033],{"id":91029},"How a Python request travels through an LLM API",[96,91035,91036],{"id":91030},"Your Python script sends a prompt over HTTPS; the service tokenises the text, the model predicts tokens, and a JSON response with text and usage returns to your script.",[100,91038],{"x":102,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,91040,91042,91043],{"x":113,"y":91041,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"151","Your Python",[175,91044,91045],{"x":113,"dy":177},"script",[100,91047],{"x":129,"y":7101,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,91049,91050,91051],{"x":133,"y":91041,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Tokenizer",[175,91052,91053],{"x":133,"dy":177,"fontSize":124,"fontWeight":178,"fill":125},"text to tokens",[100,91055],{"x":158,"y":7101,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":109},[111,91057,91058,91059],{"x":161,"y":91041,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"Model",[175,91060,91061],{"x":161,"dy":177,"fontSize":124,"fontWeight":178,"fill":125},"predicts tokens",[100,91063],{"x":168,"y":7101,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":109},[111,91065,91066,91067],{"x":172,"y":91041,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"JSON response",[175,91068,91069],{"x":172,"dy":177,"fontSize":124,"fontWeight":178,"fill":125},"text + usage",[181,91071],{"x1":104,"y1":7135,"x2":184,"y2":7135,"stroke":130,"strokeWidth":109},[186,91073],{"points":91074,"fill":130},"238,156 228,151 228,161",[181,91076],{"x1":198,"y1":7135,"x2":199,"y2":7135,"stroke":130,"strokeWidth":109},[186,91078],{"points":91079,"fill":130},"478,156 468,151 468,161",[181,91081],{"x1":205,"y1":7135,"x2":206,"y2":7135,"stroke":130,"strokeWidth":109},[186,91083],{"points":91084,"fill":130},"718,156 708,151 708,161",[111,91086,91087,91088],{"x":228,"y":67225,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":125,"textAnchor":119},"request: prompt",[175,91089,91090],{"x":228,"dy":177},"+ parameters",[181,91092],{"x1":113,"y1":7101,"x2":113,"y2":8411,"stroke":125,"strokeWidth":144,"strokeDashArray":91093},[19848,5556],[181,91095],{"x1":113,"y1":8411,"x2":172,"y2":8411,"stroke":125,"strokeWidth":144,"strokeDashArray":91096},[19848,5556],[181,91098],{"x1":172,"y1":8411,"x2":172,"y2":30361,"stroke":125,"strokeWidth":144,"strokeDashArray":91099},[19848,5556],[186,91101],{"points":91102,"fill":125},"820,118 815,108 825,108",[111,91104,91105,91106],{"x":228,"y":24406,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":125,"textAnchor":119},"response travels",[175,91107,91108],{"x":228,"dy":177},"back the same way",[181,91110],{"x1":172,"y1":57319,"x2":172,"y2":24405,"stroke":169,"strokeWidth":144,"strokeDashArray":91111},[19848,5556],[181,91113],{"x1":172,"y1":24405,"x2":113,"y2":24405,"stroke":169,"strokeWidth":144,"strokeDashArray":91114},[19848,5556],[181,91116],{"x1":113,"y1":24405,"x2":113,"y2":48092,"stroke":169,"strokeWidth":144,"strokeDashArray":91117},[19848,5556],[186,91119],{"points":91120,"fill":169},"100,194 95,204 105,204",[232,91122,91123],{},"Every call is a round trip: your script sends text and settings, the service turns text into tokens, the model predicts a reply, and a JSON response carries the text and token usage back to you.",[57,91125,91127],{"id":91126},"prerequisites-setting-up-a-clean-environment","Prerequisites: setting up a clean environment",[14,91129,91130],{},"A clean, isolated workspace stops one project's libraries from breaking another's. Confirm you are on Python 3.10 or newer, since older versions reached end-of-life and miss features the modern SDK relies on:",[253,91132,91133],{"className":255,"code":77274,"language":257,"meta":258,"style":258},[18,91134,91135],{"__ignoreMap":258},[262,91136,91137,91139],{"class":181,"line":264},[262,91138,416],{"class":267},[262,91140,52414],{"class":271},[14,91142,91143,91144,91146],{},"If that prints anything below 3.10, install a current version first — the ",[51,91145,5423],{"href":5422}," section walks through it for each operating system.",[14,91148,91149],{},"Now create a virtual environment (a private folder that holds this project's libraries) and install what you need:",[253,91151,91153],{"className":255,"code":91152,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\npip install \"openai>=1.40\" \"httpx>=0.27\" \"python-dotenv>=1.0\"\npip freeze > requirements.txt\n",[18,91154,91155,91165,91173,91185],{"__ignoreMap":258},[262,91156,91157,91159,91161,91163],{"class":181,"line":264},[262,91158,416],{"class":267},[262,91160,272],{"class":271},[262,91162,276],{"class":275},[262,91164,279],{"class":275},[262,91166,91167,91169,91171],{"class":181,"line":282},[262,91168,285],{"class":271},[262,91170,288],{"class":275},[262,91172,7222],{"class":291},[262,91174,91175,91177,91179,91181,91183],{"class":181,"line":295},[262,91176,298],{"class":267},[262,91178,301],{"class":275},[262,91180,304],{"class":275},[262,91182,307],{"class":275},[262,91184,82477],{"class":275},[262,91186,91187,91189,91191,91193],{"class":181,"line":345},[262,91188,298],{"class":267},[262,91190,76660],{"class":275},[262,91192,76663],{"class":377},[262,91194,76666],{"class":275},[14,91196,91197,91198,91202,91203,91207,91208,91212,91213,91215],{},"We install three things. The ",[35,91199,91200,42825],{},[18,91201,20],{}," is the friendly, official wrapper that turns a network call into one Python function. ",[35,91204,91205],{},[18,91206,5450],{}," is a modern HTTP library; the SDK uses it under the hood, and we will use it directly once to show what is really happening on the wire. ",[35,91209,91210],{},[18,91211,2501],{}," loads secrets from a file so they never live in your code. Pinning versions with ",[18,91214,76705],{}," means the same code runs the same way next month and on a teammate's machine.",[14,91217,91218,91219,22741],{},"Next, store your key. Generate one in your provider's dashboard, then create a file named ",[18,91220,319],{},[253,91222,91223],{"className":323,"code":4148,"language":325,"meta":258,"style":258},[18,91224,91225],{"__ignoreMap":258},[262,91226,91227],{"class":181,"line":264},[262,91228,4148],{},[14,91230,91231,91232,91238],{},"Treat that key like the password to your bank. ",[35,91233,7251,91234,356,91236,71119],{},[18,91235,319],{},[18,91237,359],{}," so it is never committed or shared:",[253,91240,91241],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,91242,91243],{"__ignoreMap":258},[262,91244,91245,91247,91249,91251],{"class":181,"line":264},[262,91246,371],{"class":271},[262,91248,374],{"class":275},[262,91250,378],{"class":377},[262,91252,381],{"class":275},[14,91254,91255],{},"That one line is the difference between a private credential and a public one. A key pushed to a repository can be found and used by strangers within minutes, and the charges land on you.",[57,91257,91259],{"id":91258},"step-1-send-your-first-request-with-the-openai-sdk","Step 1: Send your first request with the openai SDK",[14,91261,91262],{},"With the environment ready, a working call is only a few lines. The pattern is always the same: load the key, create a client, then send a list of messages.",[253,91264,91266],{"className":414,"code":91265,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # reads .env and puts the key into the environment\n\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\"role\": \"system\", \"content\": \"You are a concise assistant.\"},\n        {\"role\": \"user\", \"content\": \"Explain what a token is in one sentence.\"},\n    ],\n)\n\nprint(response.choices[0].message.content)\n",[18,91267,91268,91274,91284,91294,91298,91305,91309,91327,91331,91339,91349,91357,91377,91398,91402,91406,91410],{"__ignoreMap":258},[262,91269,91270,91272],{"class":181,"line":264},[262,91271,684],{"class":377},[262,91273,687],{"class":429},[262,91275,91276,91278,91280,91282],{"class":181,"line":282},[262,91277,705],{"class":377},[262,91279,708],{"class":429},[262,91281,684],{"class":377},[262,91283,713],{"class":429},[262,91285,91286,91288,91290,91292],{"class":181,"line":295},[262,91287,705],{"class":377},[262,91289,720],{"class":429},[262,91291,684],{"class":377},[262,91293,725],{"class":429},[262,91295,91296],{"class":181,"line":345},[262,91297,583],{"emptyLinePlaceholder":582},[262,91299,91300,91302],{"class":181,"line":492},[262,91301,4222],{"class":429},[262,91303,91304],{"class":291},"# reads .env and puts the key into the environment\n",[262,91306,91307],{"class":181,"line":503},[262,91308,583],{"emptyLinePlaceholder":582},[262,91310,91311,91313,91315,91317,91319,91321,91323,91325],{"class":181,"line":521},[262,91312,739],{"class":429},[262,91314,476],{"class":377},[262,91316,1588],{"class":429},[262,91318,2674],{"class":611},[262,91320,476],{"class":377},[262,91322,1199],{"class":429},[262,91324,2681],{"class":275},[262,91326,2684],{"class":429},[262,91328,91329],{"class":181,"line":537},[262,91330,583],{"emptyLinePlaceholder":582},[262,91332,91333,91335,91337],{"class":181,"line":549},[262,91334,48362],{"class":429},[262,91336,476],{"class":377},[262,91338,1189],{"class":429},[262,91340,91341,91343,91345,91347],{"class":181,"line":570},[262,91342,48371],{"class":611},[262,91344,476],{"class":377},[262,91346,1207],{"class":275},[262,91348,1315],{"class":429},[262,91350,91351,91353,91355],{"class":181,"line":579},[262,91352,48388],{"class":611},[262,91354,476],{"class":377},[262,91356,1220],{"class":429},[262,91358,91359,91361,91363,91365,91367,91369,91371,91373,91375],{"class":181,"line":586},[262,91360,7726],{"class":429},[262,91362,1228],{"class":275},[262,91364,1231],{"class":429},[262,91366,1234],{"class":275},[262,91368,608],{"class":429},[262,91370,1239],{"class":275},[262,91372,1231],{"class":429},[262,91374,89902],{"class":275},[262,91376,3143],{"class":429},[262,91378,91379,91381,91383,91385,91387,91389,91391,91393,91396],{"class":181,"line":591},[262,91380,7726],{"class":429},[262,91382,1228],{"class":275},[262,91384,1231],{"class":429},[262,91386,1291],{"class":275},[262,91388,608],{"class":429},[262,91390,1239],{"class":275},[262,91392,1231],{"class":429},[262,91394,91395],{"class":275},"\"Explain what a token is in one sentence.\"",[262,91397,3143],{"class":429},[262,91399,91400],{"class":181,"line":623},[262,91401,48439],{"class":429},[262,91403,91404],{"class":181,"line":634},[262,91405,660],{"class":429},[262,91407,91408],{"class":181,"line":845},[262,91409,583],{"emptyLinePlaceholder":582},[262,91411,91412,91414,91416,91418],{"class":181,"line":850},[262,91413,637],{"class":271},[262,91415,48465],{"class":429},[262,91417,102],{"class":271},[262,91419,6048],{"class":429},[14,91421,3349,91422,91424,91425,91427,91428,91430,91431,91434,91435,57002,91437,91439],{},[18,91423,43269],{}," list is a short conversation. A ",[35,91426,24611],{}," sets the model's role and rules; a ",[35,91429,24615],{}," is your actual request. The model reads both and writes an ",[35,91432,91433],{},"assistant message"," in reply. You get that reply at ",[18,91436,7909],{},[18,91438,2703],{}," model is small, fast, and cheap — perfect for learning. Run this file and you should see a single tidy sentence print to your terminal.",[57,91441,91443],{"id":91442},"step-2-read-the-response-and-track-your-usage","Step 2: Read the response and track your usage",[14,91445,91446,91447,91449],{},"The reply text is the headline, but the response carries more. The most important extra is ",[35,91448,58158],{}," — the count of tokens consumed, which is exactly what you are billed on. Logging it from day one keeps costs from surprising you.",[253,91451,91453],{"className":414,"code":91452,"language":416,"meta":258,"style":258},"print(\"Reply:\", response.choices[0].message.content)\nprint(\"Why it stopped:\", response.choices[0].finish_reason)\n\nusage = response.usage\nprint(f\"Prompt tokens:     {usage.prompt_tokens}\")\nprint(f\"Completion tokens: {usage.completion_tokens}\")\nprint(f\"Total tokens:      {usage.total_tokens}\")\n",[18,91454,91455,91471,91487,91491,91500,91522,91544],{"__ignoreMap":258},[262,91456,91457,91459,91461,91464,91467,91469],{"class":181,"line":264},[262,91458,637],{"class":271},[262,91460,602],{"class":429},[262,91462,91463],{"class":275},"\"Reply:\"",[262,91465,91466],{"class":429},", response.choices[",[262,91468,102],{"class":271},[262,91470,6048],{"class":429},[262,91472,91473,91475,91477,91480,91482,91484],{"class":181,"line":282},[262,91474,637],{"class":271},[262,91476,602],{"class":429},[262,91478,91479],{"class":275},"\"Why it stopped:\"",[262,91481,91466],{"class":429},[262,91483,102],{"class":271},[262,91485,91486],{"class":429},"].finish_reason)\n",[262,91488,91489],{"class":181,"line":295},[262,91490,583],{"emptyLinePlaceholder":582},[262,91492,91493,91496,91498],{"class":181,"line":345},[262,91494,91495],{"class":429},"usage ",[262,91497,476],{"class":377},[262,91499,90584],{"class":429},[262,91501,91502,91504,91506,91508,91511,91513,91516,91518,91520],{"class":181,"line":492},[262,91503,637],{"class":271},[262,91505,602],{"class":429},[262,91507,642],{"class":377},[262,91509,91510],{"class":275},"\"Prompt tokens:     ",[262,91512,3039],{"class":271},[262,91514,91515],{"class":429},"usage.prompt_tokens",[262,91517,654],{"class":271},[262,91519,1176],{"class":275},[262,91521,660],{"class":429},[262,91523,91524,91526,91528,91530,91533,91535,91538,91540,91542],{"class":181,"line":503},[262,91525,637],{"class":271},[262,91527,602],{"class":429},[262,91529,642],{"class":377},[262,91531,91532],{"class":275},"\"Completion tokens: ",[262,91534,3039],{"class":271},[262,91536,91537],{"class":429},"usage.completion_tokens",[262,91539,654],{"class":271},[262,91541,1176],{"class":275},[262,91543,660],{"class":429},[262,91545,91546,91548,91550,91552,91555,91557,91559,91561,91563],{"class":181,"line":521},[262,91547,637],{"class":271},[262,91549,602],{"class":429},[262,91551,642],{"class":377},[262,91553,91554],{"class":275},"\"Total tokens:      ",[262,91556,3039],{"class":271},[262,91558,71173],{"class":429},[262,91560,654],{"class":271},[262,91562,1176],{"class":275},[262,91564,660],{"class":429},[14,91566,91567,91569,91570,91573,91574,91577,91578,91580],{},[18,91568,58586],{}," tells you why the model stopped. ",[18,91571,91572],{},"\"stop\""," means it finished naturally; ",[18,91575,91576],{},"\"length\""," means it hit your ",[18,91579,3846],{}," cap and was cut off mid-thought — a sign to raise the limit. The token counts let you estimate cost: multiply by the model's per-token price from the dashboard. A reply that cost a fraction of a cent today can cost real money at scale, so make this visible early.",[57,91582,91584],{"id":91583},"step-3-see-the-raw-http-call-with-httpx","Step 3: See the raw HTTP call with httpx",[14,91586,91587,91588,26616],{},"The SDK hides the network so you can focus on your task, but it helps to see what it sends just once. Underneath, the SDK makes an ordinary HTTPS request — a POST with a header carrying your key and a JSON body carrying your prompt. Here is that same call written by hand with ",[35,91589,91590],{},[18,91591,5450],{},[253,91593,91595],{"className":414,"code":91594,"language":416,"meta":258,"style":258},"import os\nimport httpx\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nresponse = httpx.post(\n    \"https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fchat\u002Fcompletions\",\n    headers={\"Authorization\": f\"Bearer {os.getenv('OPENAI_API_KEY')}\"},\n    json={\n        \"model\": \"gpt-4o-mini\",\n        \"messages\": [{\"role\": \"user\", \"content\": \"Say hello in five words.\"}],\n    },\n    timeout=30.0,\n)\n\nresponse.raise_for_status()          # turns a 4xx\u002F5xx status into an exception\ndata = response.json()               # parse the JSON body into a dict\nprint(data[\"choices\"][0][\"message\"][\"content\"])\n",[18,91596,91597,91603,91609,91619,91623,91627,91631,91639,91646,91677,91686,91696,91720,91724,91734,91738,91742,91750,91762],{"__ignoreMap":258},[262,91598,91599,91601],{"class":181,"line":264},[262,91600,684],{"class":377},[262,91602,687],{"class":429},[262,91604,91605,91607],{"class":181,"line":282},[262,91606,684],{"class":377},[262,91608,6526],{"class":429},[262,91610,91611,91613,91615,91617],{"class":181,"line":295},[262,91612,705],{"class":377},[262,91614,708],{"class":429},[262,91616,684],{"class":377},[262,91618,713],{"class":429},[262,91620,91621],{"class":181,"line":345},[262,91622,583],{"emptyLinePlaceholder":582},[262,91624,91625],{"class":181,"line":492},[262,91626,734],{"class":429},[262,91628,91629],{"class":181,"line":503},[262,91630,583],{"emptyLinePlaceholder":582},[262,91632,91633,91635,91637],{"class":181,"line":521},[262,91634,48362],{"class":429},[262,91636,476],{"class":377},[262,91638,6576],{"class":429},[262,91640,91641,91644],{"class":181,"line":537},[262,91642,91643],{"class":275},"    \"https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fchat\u002Fcompletions\"",[262,91645,1315],{"class":429},[262,91647,91648,91650,91652,91654,91656,91658,91660,91662,91664,91666,91669,91671,91673,91675],{"class":181,"line":549},[262,91649,37469],{"class":611},[262,91651,476],{"class":377},[262,91653,3039],{"class":429},[262,91655,16998],{"class":275},[262,91657,1231],{"class":429},[262,91659,642],{"class":377},[262,91661,6605],{"class":275},[262,91663,3039],{"class":271},[262,91665,1199],{"class":429},[262,91667,91668],{"class":275},"'OPENAI_API_KEY'",[262,91670,5987],{"class":429},[262,91672,654],{"class":271},[262,91674,1176],{"class":275},[262,91676,3143],{"class":429},[262,91678,91679,91682,91684],{"class":181,"line":570},[262,91680,91681],{"class":611},"    json",[262,91683,476],{"class":377},[262,91685,6593],{"class":429},[262,91687,91688,91690,91692,91694],{"class":181,"line":579},[262,91689,90326],{"class":275},[262,91691,1231],{"class":429},[262,91693,1207],{"class":275},[262,91695,1315],{"class":429},[262,91697,91698,91701,91703,91705,91707,91709,91711,91713,91715,91718],{"class":181,"line":586},[262,91699,91700],{"class":275},"        \"messages\"",[262,91702,34156],{"class":429},[262,91704,1228],{"class":275},[262,91706,1231],{"class":429},[262,91708,1291],{"class":275},[262,91710,608],{"class":429},[262,91712,1239],{"class":275},[262,91714,1231],{"class":429},[262,91716,91717],{"class":275},"\"Say hello in five words.\"",[262,91719,54808],{"class":429},[262,91721,91722],{"class":181,"line":591},[262,91723,5635],{"class":429},[262,91725,91726,91728,91730,91732],{"class":181,"line":623},[262,91727,37493],{"class":611},[262,91729,476],{"class":377},[262,91731,6692],{"class":271},[262,91733,1315],{"class":429},[262,91735,91736],{"class":181,"line":634},[262,91737,660],{"class":429},[262,91739,91740],{"class":181,"line":845},[262,91741,583],{"emptyLinePlaceholder":582},[262,91743,91744,91747],{"class":181,"line":850},[262,91745,91746],{"class":429},"response.raise_for_status()          ",[262,91748,91749],{"class":291},"# turns a 4xx\u002F5xx status into an exception\n",[262,91751,91752,91754,91756,91759],{"class":181,"line":864},[262,91753,70069],{"class":429},[262,91755,476],{"class":377},[262,91757,91758],{"class":429}," response.json()               ",[262,91760,91761],{"class":291},"# parse the JSON body into a dict\n",[262,91763,91764,91766,91769,91771,91773,91775,91777,91779,91781,91783],{"class":181,"line":1683},[262,91765,637],{"class":271},[262,91767,91768],{"class":429},"(data[",[262,91770,70089],{"class":275},[262,91772,6163],{"class":429},[262,91774,102],{"class":271},[262,91776,6163],{"class":429},[262,91778,56837],{"class":275},[262,91780,6163],{"class":429},[262,91782,1239],{"class":275},[262,91784,3512],{"class":429},[14,91786,91787,91788,91790,91791,91794,91795,21219,91797,91799],{},"Three things are worth noticing. The ",[18,91789,17632],{}," header is how the server knows the request is yours — a wrong or missing key here is exactly what causes a 401 error. The ",[18,91792,91793],{},"json="," body is the payload the SDK builds for you automatically. And ",[18,91796,6778],{},[18,91798,31986],{}," are the manual steps the SDK normally does on your behalf. You will almost always prefer the SDK, but now the magic is no longer a mystery.",[57,91801,91803],{"id":91802},"step-4-tune-the-models-behaviour-with-parameters","Step 4: Tune the model's behaviour with parameters",[14,91805,91806],{},"The same prompt can produce a wide range of replies depending on a handful of settings you pass alongside it. These control length, randomness, and format. Understanding them is the difference between fighting the model and directing it.",[253,91808,91810],{"className":414,"code":91809,"language":416,"meta":258,"style":258},"response = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\"role\": \"system\", \"content\": \"You write short marketing taglines.\"},\n        {\"role\": \"user\", \"content\": \"A tagline for a calm productivity app.\"},\n    ],\n    temperature=0.9,     # higher = more varied, creative wording\n    max_tokens=30,       # hard cap on the reply length\n    top_p=1.0,           # alternative way to limit randomness\n    n=3,                 # ask for three separate options at once\n)\n\nfor i, choice in enumerate(response.choices, start=1):\n    print(f\"Option {i}: {choice.message.content}\")\n",[18,91811,91812,91820,91830,91838,91859,91880,91884,91897,91910,91925,91940,91944,91948,91970],{"__ignoreMap":258},[262,91813,91814,91816,91818],{"class":181,"line":264},[262,91815,48362],{"class":429},[262,91817,476],{"class":377},[262,91819,1189],{"class":429},[262,91821,91822,91824,91826,91828],{"class":181,"line":282},[262,91823,48371],{"class":611},[262,91825,476],{"class":377},[262,91827,1207],{"class":275},[262,91829,1315],{"class":429},[262,91831,91832,91834,91836],{"class":181,"line":295},[262,91833,48388],{"class":611},[262,91835,476],{"class":377},[262,91837,1220],{"class":429},[262,91839,91840,91842,91844,91846,91848,91850,91852,91854,91857],{"class":181,"line":345},[262,91841,7726],{"class":429},[262,91843,1228],{"class":275},[262,91845,1231],{"class":429},[262,91847,1234],{"class":275},[262,91849,608],{"class":429},[262,91851,1239],{"class":275},[262,91853,1231],{"class":429},[262,91855,91856],{"class":275},"\"You write short marketing taglines.\"",[262,91858,3143],{"class":429},[262,91860,91861,91863,91865,91867,91869,91871,91873,91875,91878],{"class":181,"line":492},[262,91862,7726],{"class":429},[262,91864,1228],{"class":275},[262,91866,1231],{"class":429},[262,91868,1291],{"class":275},[262,91870,608],{"class":429},[262,91872,1239],{"class":275},[262,91874,1231],{"class":429},[262,91876,91877],{"class":275},"\"A tagline for a calm productivity app.\"",[262,91879,3143],{"class":429},[262,91881,91882],{"class":181,"line":503},[262,91883,48439],{"class":429},[262,91885,91886,91888,91890,91892,91894],{"class":181,"line":521},[262,91887,48444],{"class":611},[262,91889,476],{"class":377},[262,91891,8365],{"class":271},[262,91893,90057],{"class":429},[262,91895,91896],{"class":291},"# higher = more varied, creative wording\n",[262,91898,91899,91901,91903,91905,91907],{"class":181,"line":537},[262,91900,77660],{"class":611},[262,91902,476],{"class":377},[262,91904,9777],{"class":271},[262,91906,13456],{"class":429},[262,91908,91909],{"class":291},"# hard cap on the reply length\n",[262,91911,91912,91915,91917,91919,91922],{"class":181,"line":549},[262,91913,91914],{"class":611},"    top_p",[262,91916,476],{"class":377},[262,91918,17583],{"class":271},[262,91920,91921],{"class":429},",           ",[262,91923,91924],{"class":291},"# alternative way to limit randomness\n",[262,91926,91927,91930,91932,91934,91937],{"class":181,"line":570},[262,91928,91929],{"class":611},"    n",[262,91931,476],{"class":377},[262,91933,5556],{"class":271},[262,91935,91936],{"class":429},",                 ",[262,91938,91939],{"class":291},"# ask for three separate options at once\n",[262,91941,91942],{"class":181,"line":579},[262,91943,660],{"class":429},[262,91945,91946],{"class":181,"line":586},[262,91947,583],{"emptyLinePlaceholder":582},[262,91949,91950,91952,91955,91957,91959,91962,91964,91966,91968],{"class":181,"line":591},[262,91951,829],{"class":377},[262,91953,91954],{"class":429}," i, choice ",[262,91956,835],{"class":377},[262,91958,14189],{"class":271},[262,91960,91961],{"class":429},"(response.choices, ",[262,91963,14195],{"class":611},[262,91965,476],{"class":377},[262,91967,997],{"class":271},[262,91969,8192],{"class":429},[262,91971,91972,91974,91976,91978,91981,91983,91985,91987,91989,91991,91994,91996,91998],{"class":181,"line":623},[262,91973,1089],{"class":271},[262,91975,602],{"class":429},[262,91977,642],{"class":377},[262,91979,91980],{"class":275},"\"Option ",[262,91982,3039],{"class":271},[262,91984,15558],{"class":429},[262,91986,654],{"class":271},[262,91988,1231],{"class":275},[262,91990,3039],{"class":271},[262,91992,91993],{"class":429},"choice.message.content",[262,91995,654],{"class":271},[262,91997,1176],{"class":275},[262,91999,660],{"class":429},[14,92001,92002,92003,92005,92006,92009,92010,92012],{},"For a creative task, a higher ",[18,92004,3829],{}," gives you variety; for a factual one, drop it near zero so answers stay consistent. Asking for ",[18,92007,92008],{},"n=3"," returns three candidates in a single call, which is handy for brainstorming. The next section explains each setting in full. To go deeper on writing the messages themselves, the ",[51,92011,7554],{"href":7553}," section covers system prompts and output control.",[57,92014,8300],{"id":8299},[14,92016,92017,92018,92020],{},"These are the settings you will reach for most. Pass them as keyword arguments to ",[18,92019,8306],{},". Defaults shown are the common OpenAI defaults; other providers are similar but check their docs.",[1379,92022,92023,92035],{},[1382,92024,92025],{},[1385,92026,92027,92029,92031,92033],{},[1388,92028,31703],{},[1388,92030,3795],{},[1388,92032,3798],{},[1388,92034,1396],{},[1398,92036,92037,92052,92077,92097,92110,92128,92143,92159,92178,92195],{},[1385,92038,92039,92043,92045,92047],{},[1403,92040,92041],{},[18,92042,805],{},[1403,92044,3811],{},[1403,92046,14674],{},[1403,92048,45977,92049,92051],{},[18,92050,2703],{}," is cheap and fast; larger models reason better but cost more.",[1385,92053,92054,92058,92061,92063],{},[1403,92055,92056],{},[18,92057,43269],{},[1403,92059,92060],{},"list of dicts",[1403,92062,14674],{},[1403,92064,92065,92066,13751,92068,608,92070,14716,92072,92074,92075,1363],{},"The conversation. Each item has a ",[18,92067,43003],{},[18,92069,4466],{},[18,92071,4470],{},[18,92073,43011],{},") and ",[18,92076,7921],{},[1385,92078,92079,92083,92085,92089],{},[1403,92080,92081],{},[18,92082,3829],{},[1403,92084,3832],{},[1403,92086,92087],{},[18,92088,17583],{},[1403,92090,92091,92092,3921,92094,92096],{},"Randomness, from ",[18,92093,46000],{},[18,92095,72621],{},". Low values give consistent, focused replies; high values give varied, creative ones.",[1385,92098,92099,92103,92105,92107],{},[1403,92100,92101],{},[18,92102,3846],{},[1403,92104,14748],{},[1403,92106,49704],{},[1403,92108,92109],{},"Hard ceiling on the reply length in tokens. Set it low while testing to cap costs.",[1385,92111,92112,92116,92118,92122],{},[1403,92113,92114],{},[18,92115,49714],{},[1403,92117,3832],{},[1403,92119,92120],{},[18,92121,17583],{},[1403,92123,92124,92125,92127],{},"Nucleus sampling, an alternative to ",[18,92126,3829],{},". Lower values narrow word choice. Tune one, not both.",[1385,92129,92130,92134,92136,92140],{},[1403,92131,92132],{},[18,92133,10895],{},[1403,92135,14748],{},[1403,92137,92138],{},[18,92139,997],{},[1403,92141,92142],{},"How many separate replies to generate per call. Each one is billed.",[1385,92144,92145,92149,92152,92156],{},[1403,92146,92147],{},[18,92148,72686],{},[1403,92150,92151],{},"string or list",[1403,92153,92154],{},[18,92155,72100],{},[1403,92157,92158],{},"Text that, when produced, ends the reply early. Useful for fixed formats.",[1385,92160,92161,92165,92168,92172],{},[1403,92162,92163],{},[18,92164,49743],{},[1403,92166,92167],{},"boolean",[1403,92169,92170],{},[18,92171,57730],{},[1403,92173,49752,92174,92177],{},[18,92175,92176],{},"true",", the reply arrives token by token instead of all at once.",[1385,92179,92180,92184,92186,92190],{},[1403,92181,92182],{},[18,92183,5745],{},[1403,92185,5869],{},[1403,92187,92188],{},[18,92189,72100],{},[1403,92191,52065,92192,92194],{},[18,92193,6841],{}," to force valid JSON output.",[1385,92196,92197,92201,92203,92205],{},[1403,92198,92199],{},[18,92200,1591],{},[1403,92202,3832],{},[1403,92204,58480],{},[1403,92206,92207],{},"Seconds to wait before giving up on a slow request.",[57,92209,92211],{"id":92210},"troubleshooting-common-errors","Troubleshooting common errors",[14,92213,92214],{},"These are the errors almost everyone hits in their first week. Each gets a dedicated guide if you need the deep version.",[1447,92216,92217,92236,92246,92259,92272,92290],{},[1450,92218,92219,92224,92225,92227,92228,92230,92231,92233,92234,1363],{},[35,92220,92221],{},[18,92222,92223],{},"AuthenticationError: Error code: 401 - Incorrect API key provided"," — Your key is missing, mistyped, or not being loaded. Most often ",[18,92226,319],{}," was never read, so the variable is empty. Confirm ",[18,92229,8439],{}," runs before you create the client and that the key in ",[18,92232,319],{}," has no quotes or stray spaces. Full walkthrough: ",[51,92235,388],{"href":387},[1450,92237,92238,92243,92244,1363],{},[35,92239,92240],{},[18,92241,92242],{},"RateLimitError: Error code: 429 - Rate limit reached for requests"," — You sent calls faster than your tier allows, or you have hit a spending cap. Wait, then retry with an increasing delay (exponential backoff). Step-by-step fix: ",[51,92245,3379],{"href":3378},[1450,92247,92248,92253,92254,92256,92257,1363],{},[35,92249,92250],{},[18,92251,92252],{},"json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)"," — You tried to parse the model's text as JSON, but it wrapped the JSON in prose or code fences. Add ",[18,92255,6878],{}," and ask explicitly for JSON. Details: ",[51,92258,6114],{"href":6113},[1450,92260,92261,92266,92267,92269,92270,1363],{},[35,92262,92263],{},[18,92264,92265],{},"BadRequestError: ... maximum context length is N tokens, however you requested M"," — Your prompt plus requested reply is larger than the model's window. Shorten the input, summarise long documents, or lower ",[18,92268,3846],{},". How to fix it: ",[51,92271,1513],{"href":1512},[1450,92273,92274,92279,92280,92282,92283,92285,92286,92289],{},[35,92275,92276],{},[18,92277,92278],{},"APITimeoutError: Request timed out"," — The model took longer than your ",[18,92281,1591],{}," allowed, common with large prompts or long replies. Raise the ",[18,92284,1591],{}," value (for example ",[18,92287,92288],{},"timeout=60",") and consider streaming so partial output arrives sooner.",[1450,92291,92292,92296,92297,92299,92300,92302],{},[35,92293,92294],{},[18,92295,58575],{}," — You read ",[18,92298,8467],{}," when the reply was empty, often because the request was filtered or stopped early. Check ",[18,92301,58586],{}," before using the text and handle the empty case instead of assuming a string is always present.",[57,92304,92306],{"id":92305},"worked-example-a-small-safe-api-client","Worked example: a small, safe API client",[14,92308,92309,92310,92313],{},"This script ties everything together. It loads the key safely, sends a request, retries politely when rate-limited, and reports both the reply and the token cost. Save it as ",[18,92311,92312],{},"ask.py"," and run it.",[253,92315,92317],{"className":414,"code":92316,"language":416,"meta":258,"style":258},"import os\nimport time\nfrom dotenv import load_dotenv\nfrom openai import OpenAI, RateLimitError, APITimeoutError\n\nload_dotenv()  # pulls OPENAI_API_KEY from .env (remember: .env is in .gitignore)\n\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"), timeout=30.0)\n\n\ndef ask(prompt: str, max_retries: int = 3) -> str:\n    \"\"\"Send one prompt, retry on rate limits, and return the reply text.\"\"\"\n    messages = [\n        {\"role\": \"system\", \"content\": \"You are a concise, helpful assistant.\"},\n        {\"role\": \"user\", \"content\": prompt},\n    ]\n    for attempt in range(max_retries):\n        try:\n            response = client.chat.completions.create(\n                model=\"gpt-4o-mini\",\n                messages=messages,\n                temperature=0.3,   # low = consistent answers\n                max_tokens=200,    # cap the reply to control cost\n            )\n            usage = response.usage\n            print(f\"[tokens: {usage.total_tokens} total]\")  # keep cost visible\n            return response.choices[0].message.content\n        except (RateLimitError, APITimeoutError) as error:\n            wait = 2 ** attempt + 0.5  # 1.5s, 2.5s, 4.5s — exponential backoff\n            print(f\"Attempt {attempt + 1} failed ({error.__class__.__name__}); \"\n                  f\"retrying in {wait}s...\")\n            time.sleep(wait)\n    raise RuntimeError(f\"Gave up after {max_retries} attempts.\")\n\n\nif __name__ == \"__main__\":\n    answer = ask(\"Summarise what an LLM API does in two sentences.\")\n    print(answer)\n",[18,92318,92319,92325,92331,92341,92352,92356,92363,92367,92393,92397,92401,92425,92430,92438,92458,92474,92478,92490,92496,92504,92514,92522,92534,92547,92551,92560,92585,92595,92606,92625,92660,92677,92681,92705,92709,92713,92725,92739],{"__ignoreMap":258},[262,92320,92321,92323],{"class":181,"line":264},[262,92322,684],{"class":377},[262,92324,687],{"class":429},[262,92326,92327,92329],{"class":181,"line":282},[262,92328,684],{"class":377},[262,92330,2612],{"class":429},[262,92332,92333,92335,92337,92339],{"class":181,"line":295},[262,92334,705],{"class":377},[262,92336,708],{"class":429},[262,92338,684],{"class":377},[262,92340,713],{"class":429},[262,92342,92343,92345,92347,92349],{"class":181,"line":345},[262,92344,705],{"class":377},[262,92346,720],{"class":429},[262,92348,684],{"class":377},[262,92350,92351],{"class":429}," OpenAI, RateLimitError, APITimeoutError\n",[262,92353,92354],{"class":181,"line":492},[262,92355,583],{"emptyLinePlaceholder":582},[262,92357,92358,92360],{"class":181,"line":503},[262,92359,4222],{"class":429},[262,92361,92362],{"class":291},"# pulls OPENAI_API_KEY from .env (remember: .env is in .gitignore)\n",[262,92364,92365],{"class":181,"line":521},[262,92366,583],{"emptyLinePlaceholder":582},[262,92368,92369,92371,92373,92375,92377,92379,92381,92383,92385,92387,92389,92391],{"class":181,"line":537},[262,92370,739],{"class":429},[262,92372,476],{"class":377},[262,92374,1588],{"class":429},[262,92376,2674],{"class":611},[262,92378,476],{"class":377},[262,92380,1199],{"class":429},[262,92382,2681],{"class":275},[262,92384,11709],{"class":429},[262,92386,1591],{"class":611},[262,92388,476],{"class":377},[262,92390,6692],{"class":271},[262,92392,660],{"class":429},[262,92394,92395],{"class":181,"line":549},[262,92396,583],{"emptyLinePlaceholder":582},[262,92398,92399],{"class":181,"line":570},[262,92400,583],{"emptyLinePlaceholder":582},[262,92402,92403,92405,92407,92409,92411,92413,92415,92417,92419,92421,92423],{"class":181,"line":579},[262,92404,423],{"class":377},[262,92406,44066],{"class":267},[262,92408,9599],{"class":429},[262,92410,433],{"class":271},[262,92412,3007],{"class":429},[262,92414,439],{"class":271},[262,92416,442],{"class":377},[262,92418,931],{"class":271},[262,92420,1939],{"class":429},[262,92422,433],{"class":271},[262,92424,1160],{"class":429},[262,92426,92427],{"class":181,"line":586},[262,92428,92429],{"class":275},"    \"\"\"Send one prompt, retry on rate limits, and return the reply text.\"\"\"\n",[262,92431,92432,92434,92436],{"class":181,"line":591},[262,92433,45521],{"class":429},[262,92435,476],{"class":377},[262,92437,5589],{"class":429},[262,92439,92440,92442,92444,92446,92448,92450,92452,92454,92456],{"class":181,"line":623},[262,92441,7726],{"class":429},[262,92443,1228],{"class":275},[262,92445,1231],{"class":429},[262,92447,1234],{"class":275},[262,92449,608],{"class":429},[262,92451,1239],{"class":275},[262,92453,1231],{"class":429},[262,92455,58263],{"class":275},[262,92457,3143],{"class":429},[262,92459,92460,92462,92464,92466,92468,92470,92472],{"class":181,"line":634},[262,92461,7726],{"class":429},[262,92463,1228],{"class":275},[262,92465,1231],{"class":429},[262,92467,1291],{"class":275},[262,92469,608],{"class":429},[262,92471,1239],{"class":275},[262,92473,38272],{"class":429},[262,92475,92476],{"class":181,"line":845},[262,92477,7761],{"class":429},[262,92479,92480,92482,92484,92486,92488],{"class":181,"line":850},[262,92481,3074],{"class":377},[262,92483,3077],{"class":429},[262,92485,835],{"class":377},[262,92487,3082],{"class":271},[262,92489,3085],{"class":429},[262,92491,92492,92494],{"class":181,"line":864},[262,92493,3090],{"class":377},[262,92495,1160],{"class":429},[262,92497,92498,92500,92502],{"class":181,"line":1683},[262,92499,3097],{"class":429},[262,92501,476],{"class":377},[262,92503,1189],{"class":429},[262,92505,92506,92508,92510,92512],{"class":181,"line":1688},[262,92507,3106],{"class":611},[262,92509,476],{"class":377},[262,92511,1207],{"class":275},[262,92513,1315],{"class":429},[262,92515,92516,92518,92520],{"class":181,"line":1693},[262,92517,3117],{"class":611},[262,92519,476],{"class":377},[262,92521,43186],{"class":429},[262,92523,92524,92526,92528,92530,92532],{"class":181,"line":1728},[262,92525,3170],{"class":611},[262,92527,476],{"class":377},[262,92529,3924],{"class":271},[262,92531,87265],{"class":429},[262,92533,90556],{"class":291},[262,92535,92536,92538,92540,92542,92544],{"class":181,"line":1737},[262,92537,3182],{"class":611},[262,92539,476],{"class":377},[262,92541,104],{"class":271},[262,92543,90567],{"class":429},[262,92545,92546],{"class":291},"# cap the reply to control cost\n",[262,92548,92549],{"class":181,"line":1751},[262,92550,3193],{"class":429},[262,92552,92553,92556,92558],{"class":181,"line":1764},[262,92554,92555],{"class":429},"            usage ",[262,92557,476],{"class":377},[262,92559,90584],{"class":429},[262,92561,92562,92564,92566,92568,92571,92573,92575,92577,92580,92582],{"class":181,"line":1779},[262,92563,3250],{"class":271},[262,92565,602],{"class":429},[262,92567,642],{"class":377},[262,92569,92570],{"class":275},"\"[tokens: ",[262,92572,3039],{"class":271},[262,92574,71173],{"class":429},[262,92576,654],{"class":271},[262,92578,92579],{"class":275}," total]\"",[262,92581,32223],{"class":429},[262,92583,92584],{"class":291},"# keep cost visible\n",[262,92586,92587,92589,92591,92593],{"class":181,"line":1793},[262,92588,3198],{"class":377},[262,92590,1326],{"class":429},[262,92592,102],{"class":271},[262,92594,1331],{"class":429},[262,92596,92597,92599,92602,92604],{"class":181,"line":1800},[262,92598,3214],{"class":377},[262,92600,92601],{"class":429}," (RateLimitError, APITimeoutError) ",[262,92603,697],{"class":377},[262,92605,14529],{"class":429},[262,92607,92608,92610,92612,92614,92616,92618,92620,92622],{"class":181,"line":1805},[262,92609,3227],{"class":429},[262,92611,476],{"class":377},[262,92613,3232],{"class":271},[262,92615,3235],{"class":377},[262,92617,3077],{"class":429},[262,92619,531],{"class":377},[262,92621,3416],{"class":271},[262,92623,92624],{"class":291},"  # 1.5s, 2.5s, 4.5s — exponential backoff\n",[262,92626,92627,92629,92631,92633,92635,92637,92639,92641,92643,92645,92647,92650,92653,92655,92657],{"class":181,"line":1810},[262,92628,3250],{"class":271},[262,92630,602],{"class":429},[262,92632,642],{"class":377},[262,92634,8245],{"class":275},[262,92636,3039],{"class":271},[262,92638,3262],{"class":429},[262,92640,531],{"class":377},[262,92642,3267],{"class":271},[262,92644,3270],{"class":275},[262,92646,3039],{"class":271},[262,92648,92649],{"class":429},"error.",[262,92651,92652],{"class":271},"__class__",[262,92654,1363],{"class":429},[262,92656,3279],{"class":271},[262,92658,92659],{"class":275},"); \"\n",[262,92661,92662,92664,92667,92669,92671,92673,92675],{"class":181,"line":1823},[262,92663,3287],{"class":377},[262,92665,92666],{"class":275},"\"retrying in ",[262,92668,3039],{"class":271},[262,92670,3295],{"class":429},[262,92672,654],{"class":271},[262,92674,3300],{"class":275},[262,92676,660],{"class":429},[262,92678,92679],{"class":181,"line":1846},[262,92680,3307],{"class":429},[262,92682,92683,92685,92687,92689,92691,92694,92696,92698,92700,92703],{"class":181,"line":1861},[262,92684,2829],{"class":377},[262,92686,3318],{"class":271},[262,92688,602],{"class":429},[262,92690,642],{"class":377},[262,92692,92693],{"class":275},"\"Gave up after ",[262,92695,3039],{"class":271},[262,92697,3339],{"class":429},[262,92699,654],{"class":271},[262,92701,92702],{"class":275}," attempts.\"",[262,92704,660],{"class":429},[262,92706,92707],{"class":181,"line":1866},[262,92708,583],{"emptyLinePlaceholder":582},[262,92710,92711],{"class":181,"line":1871},[262,92712,583],{"emptyLinePlaceholder":582},[262,92714,92715,92717,92719,92721,92723],{"class":181,"line":1890},[262,92716,2210],{"class":377},[262,92718,2213],{"class":271},[262,92720,2216],{"class":377},[262,92722,2219],{"class":275},[262,92724,1160],{"class":429},[262,92726,92727,92729,92731,92734,92737],{"class":181,"line":1909},[262,92728,64587],{"class":429},[262,92730,476],{"class":377},[262,92732,92733],{"class":429}," ask(",[262,92735,92736],{"class":275},"\"Summarise what an LLM API does in two sentences.\"",[262,92738,660],{"class":429},[262,92740,92741,92743],{"class":181,"line":1914},[262,92742,1089],{"class":271},[262,92744,92745],{"class":429},"(answer)\n",[14,92747,13310,92748,92751],{},[18,92749,92750],{},"python ask.py",". You get a clean answer, a one-line token report, and automatic recovery if the service briefly throttles you — the three habits that separate a toy script from one you can trust.",[57,92753,2355],{"id":2354},[14,92755,92756],{},"You can now call a model, read its reply, tune its behaviour, and recover from the common failures. Here is where to go next, depending on what you want to do.",[2322,92758,92759,92770,92779,92793],{},[1450,92760,92761,92764,92765,92767,92768,1363],{},[35,92762,92763],{},"Spend nothing while you practise."," Several services offer generous free access. Compare them in ",[51,92766,5485],{"href":5484},", then weigh the two most popular paid options in ",[51,92769,14635],{"href":14634},[1450,92771,92772,92775,92776,92778],{},[35,92773,92774],{},"Want maximum speed or breadth for free?"," Read ",[51,92777,69908],{"href":69907}," to pick a fast free endpoint for experiments.",[1450,92780,92781,92784,92785,92787,92788,80817,92790,1363],{},[35,92782,92783],{},"Hit a wall?"," Bookmark the focused fixes for the ",[51,92786,71217],{"href":387},", the ",[51,92789,71220],{"href":3378},[51,92791,92792],{"href":1512},"context-length-exceeded error",[1450,92794,92795,80708,92798,92800],{},[35,92796,92797],{},"Ready to write better prompts?",[51,92799,7554],{"href":7553}," to get more reliable, better-formatted answers from the same model.",[14,92802,2375,92803,1363],{},[51,92804,26450],{"href":26449},[57,92806,2381],{"id":2380},[2322,92808,92809,92814,92819,92824,92829],{},[1450,92810,92811,92813],{},[51,92812,5423],{"href":5422}," — get Python, pip, and virtual environments ready before your first call.",[1450,92815,92816,92818],{},[51,92817,5485],{"href":5484}," — practise without spending anything.",[1450,92820,92821,92823],{},[51,92822,14635],{"href":14634}," — choose your first paid provider with confidence.",[1450,92825,92826,92828],{},[51,92827,6114],{"href":6113}," — make models return clean, parseable data.",[1450,92830,92831,92833],{},[51,92832,7554],{"href":7553}," — the next section in this track.",[2401,92835,63366],{},{"title":258,"searchDepth":282,"depth":282,"links":92837},[92838,92839,92840,92841,92842,92843,92844,92845,92846,92847,92848],{"id":12746,"depth":282,"text":12747},{"id":91126,"depth":282,"text":91127},{"id":91258,"depth":282,"text":91259},{"id":91442,"depth":282,"text":91443},{"id":91583,"depth":282,"text":91584},{"id":91802,"depth":282,"text":91803},{"id":8299,"depth":282,"text":8300},{"id":92210,"depth":282,"text":92211},{"id":92305,"depth":282,"text":92306},{"id":2354,"depth":282,"text":2355},{"id":2380,"depth":282,"text":2381},"Learn how LLM APIs work and call one from Python. Covers setup, secure keys, request crafting, parameters, and fixing the most common errors from scratch.",[92851,92854,92857,92860,92863],{"q":92852,"a":92853},"What is an LLM API in plain terms?","An LLM API is a web address you send text to and get generated text back from. Your Python script sends a prompt over the internet, a hosted language model writes a reply, and the service returns it as structured data. You never download or run the model yourself.",{"q":92855,"a":92856},"Do I need to know machine learning to call an LLM API?","No. Calling an LLM API is ordinary web programming. You send a request with your question and a few settings, then read the answer from the response. The hard machine-learning work happens on the provider's servers, not in your code.",{"q":92858,"a":92859},"What is a token and why does it matter?","A token is a small chunk of text, roughly three-quarters of a word in English. Providers count tokens to set both your bill and the size limit of a request. Watching your token counts keeps costs predictable and avoids context-length errors.",{"q":92861,"a":92862},"Is it safe to put my API key in my Python file?","No. Never paste a key directly into code you might share or commit. Store it in a .env file that is listed in .gitignore, then load it at runtime. A leaked key can be used by strangers and billed to you.",{"q":92864,"a":92865},"Which model should a beginner start with?","Start with a small, cheap model such as gpt-4o-mini. It answers most everyday tasks well, costs very little per call, and lets you experiment freely. Move up to a larger model only when a task clearly needs deeper reasoning.",{"name":92867,"steps":92868},"How to call an LLM API from Python",[92869,92872,92875,92878],{"name":92870,"text":92871},"Install the libraries and pin versions","Create a virtual environment and install the openai SDK, httpx, and python-dotenv.",{"name":92873,"text":92874},"Store your API key safely","Put your key in a .env file, add .env to .gitignore, and load it at runtime.",{"name":92876,"text":92877},"Send your first request","Initialise the client and call the chat completions endpoint with a system and user message.",{"name":92879,"text":92880},"Read the response and track usage","Pull the generated text from the response object and log the token counts so costs stay visible.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis","2026-05-03",{"title":90994,"description":92849},"Understanding LLM APIs: A Python Guide","python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Findex","dF-_T8kOyz3vYTZpn7tGHfAKG_vTdF4VlBv2UJ2IThc",{"id":92889,"title":14635,"body":92890,"description":94361,"extension":2419,"faq":94362,"howto":54197,"meta":94378,"modified":2452,"navigation":582,"noindex":2453,"ogImage":2454,"path":94379,"published":2452,"seo":94380,"seoTitle":14635,"stem":94381,"__hash__":94382},"content\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fopenai-vs-anthropic-api-for-beginners\u002Findex.md",{"type":7,"value":92891,"toc":94346},[92892,92895,92898,92901,92907,92911,92914,93031,93035,93041,93051,93054,93102,93108,93122,93131,93145,93148,93152,93155,93159,93327,93346,93350,93533,93550,93553,93557,93562,93701,93704,93708,93717,93723,93732,93736,93739,93746,93753,93756,93758,93761,93791,93795,93798,94289,94300,94304,94311,94315,94317,94344],[10,92893,14635],{"id":92894},"openai-vs-anthropic-api-for-beginners",[14,92896,92897],{},"You have called one large language model API and it worked. Now you keep seeing two names everywhere — OpenAI and Anthropic — and you cannot tell whether they are interchangeable, whether the code is wildly different, or which one you should commit to first. This guide answers all three, fairly, with the same task written in both so you can see the differences with your own eyes instead of taking anyone's word for it.",[14,92899,92900],{},"Both companies do the same core thing: they host a powerful AI model and rent it to you over the internet. Your Python script sends some text, a model on their servers writes a reply, and the reply comes back as data. OpenAI makes the GPT family of models; Anthropic makes the Claude family. Neither is \"the AI\" and the other a copy — they are two well-funded labs with strong models, and a beginner can do excellent work with either. The real question is not which is better in the abstract, but which fits the task in front of you.",[14,92902,92903,92904,92906],{},"This is one guide in ",[51,92905,2487],{"href":2486},", written for creators, marketers, founders, and students who can run a Python file but have not shipped production code.",[57,92908,92910],{"id":92909},"the-four-differences-that-actually-matter","The four differences that actually matter",[14,92912,92913],{},"Strip away the marketing and the practical gap between the two APIs comes down to four things you will touch in your first hour: how you call the model, where you put the system instruction, how you are billed, and what surrounds each API. The diagram below contrasts them on exactly those dimensions, and the rest of the guide expands each one with runnable code.",[76,92915,92917,93028],{"className":92916},[79],[81,92918,90,92921,90,92924,90,92927,90,92931,90,92934,90,92937,90,92940,90,92943,90,92945,90,92948,90,92951,90,92953,90,92956,90,92959,90,92961,90,92964,90,92966,90,92968,90,92971,90,92973,90,92975,90,92978,90,92981,90,92983,90,92987,90,92990,90,92992,90,92995,90,92998,90,93000,90,93002,90,93004,90,93006,90,93010,90,93013,90,93015,90,93018,90,93020,90,93022,90,93025],{"viewBox":92919,"role":84,"ariaLabelledBy":92920,"preserveAspectRatio":88,"xmlns":89},"-40 -40 800 500",[89531,89532],[92,92922,92923],{"id":89531},"OpenAI versus Anthropic across four beginner-facing dimensions",[96,92925,92926],{"id":89532},"A comparison matrix with four rows — SDK call shape, system prompt handling, pricing model, and ecosystem — showing how the OpenAI and Anthropic Python APIs differ on each.",[111,92928,37312],{"x":57358,"y":92929,"fontFamily":115,"fontSize":92930,"fontWeight":71443,"fill":130,"textAnchor":119},"26","16",[111,92932,92933],{"x":117,"y":92929,"fontFamily":115,"fontSize":92930,"fontWeight":71443,"fill":169,"textAnchor":119},"Anthropic",[100,92935],{"x":140,"y":92936,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},"48",[111,92938,92939],{"x":7101,"y":1100,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"SDK call",[111,92941,72820],{"x":7101,"y":92942,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"98",[100,92944],{"x":71384,"y":92936,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,92946,92947],{"x":57358,"y":1100,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"chat.completions",[111,92949,92950],{"x":57358,"y":92942,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},".create",[100,92952],{"x":16427,"y":92936,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,92954,92955],{"x":117,"y":1100,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"messages.create",[111,92957,92958],{"x":117,"y":92942,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"content blocks",[100,92960],{"x":140,"y":52289,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,92962,92963],{"x":7101,"y":57319,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"System",[111,92965,9496],{"x":7101,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,92967],{"x":71384,"y":52289,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,92969,92970],{"x":57358,"y":57319,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"inside messages",[111,92972,71388],{"x":57358,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,92974],{"x":16427,"y":52289,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,92976,92977],{"x":117,"y":57319,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"separate param",[111,92979,92980],{"x":117,"y":24392,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"system=...",[100,92982],{"x":140,"y":12856,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,92984,92986],{"x":7101,"y":92985,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"304","Pricing",[111,92988,805],{"x":7101,"y":92989,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"322",[100,92991],{"x":71384,"y":12856,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,92993,92994],{"x":57358,"y":92985,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"per token",[111,92996,92997],{"x":57358,"y":92989,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"in + out priced",[100,92999],{"x":16427,"y":12856,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,93001,92994],{"x":117,"y":92985,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},[111,93003,92997],{"x":117,"y":92989,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,93005],{"x":140,"y":48124,"width":104,"height":105,"rx":106,"fill":142,"stroke":143,"strokeWidth":144},[111,93007,93009],{"x":7101,"y":93008,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"416","Ecosystem",[111,93011,93012],{"x":7101,"y":89627,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"extras",[100,93014],{"x":71384,"y":48124,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,93016,93017],{"x":57358,"y":93008,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"images, audio",[111,93019,69],{"x":57358,"y":89627,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},[100,93021],{"x":16427,"y":48124,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"strokeWidth":144},[111,93023,93024],{"x":117,"y":93008,"fontFamily":115,"fontSize":116,"fontWeight":117,"fill":118,"textAnchor":119},"long context",[111,93026,93027],{"x":117,"y":89627,"fontFamily":115,"fontSize":124,"fill":125,"textAnchor":119},"agent tooling",[232,93029,93030],{},"The two APIs agree on the big idea and differ in four small, learnable places: the call you write, where the system instruction goes, how tokens are billed, and what each ecosystem adds around the model.",[57,93032,93034],{"id":93033},"prerequisites-two-keys-two-packages","Prerequisites: two keys, two packages",[14,93036,93037,93038,93040],{},"You need Python 3.10 or newer and a virtual environment. If either is missing, the ",[51,93039,5423],{"href":5422}," section covers it. Confirm your version first:",[253,93042,93043],{"className":255,"code":77274,"language":257,"meta":258,"style":258},[18,93044,93045],{"__ignoreMap":258},[262,93046,93047,93049],{"class":181,"line":264},[262,93048,416],{"class":267},[262,93050,52414],{"class":271},[14,93052,93053],{},"Each provider gives you a key from its own dashboard, and each has its own SDK. Install both so you can run the side-by-side examples:",[253,93055,93057],{"className":255,"code":93056,"language":257,"meta":258,"style":258},"python -m venv .venv\nsource .venv\u002Fbin\u002Factivate        # Windows: .venv\\Scripts\\activate\npip install \"openai>=1.40\" \"anthropic>=0.40\" \"httpx>=0.27\" \"python-dotenv>=1.0\"\npip freeze > requirements.txt\n",[18,93058,93059,93069,93077,93092],{"__ignoreMap":258},[262,93060,93061,93063,93065,93067],{"class":181,"line":264},[262,93062,416],{"class":267},[262,93064,272],{"class":271},[262,93066,276],{"class":275},[262,93068,279],{"class":275},[262,93070,93071,93073,93075],{"class":181,"line":282},[262,93072,285],{"class":271},[262,93074,288],{"class":275},[262,93076,7222],{"class":291},[262,93078,93079,93081,93083,93085,93088,93090],{"class":181,"line":295},[262,93080,298],{"class":267},[262,93082,301],{"class":275},[262,93084,304],{"class":275},[262,93086,93087],{"class":275}," \"anthropic>=0.40\"",[262,93089,307],{"class":275},[262,93091,82477],{"class":275},[262,93093,93094,93096,93098,93100],{"class":181,"line":345},[262,93095,298],{"class":267},[262,93097,76660],{"class":275},[262,93099,76663],{"class":377},[262,93101,76666],{"class":275},[14,93103,93104,93105,93107],{},"Store both keys in a ",[18,93106,319],{}," file in your project folder:",[253,93109,93111],{"className":323,"code":93110,"language":325,"meta":258,"style":258},"OPENAI_API_KEY=sk-your-openai-key-here\nANTHROPIC_API_KEY=sk-ant-your-anthropic-key-here\n",[18,93112,93113,93117],{"__ignoreMap":258},[262,93114,93115],{"class":181,"line":264},[262,93116,5469],{},[262,93118,93119],{"class":181,"line":282},[262,93120,93121],{},"ANTHROPIC_API_KEY=sk-ant-your-anthropic-key-here\n",[14,93123,93124,93130],{},[35,93125,7251,93126,356,93128],{},[18,93127,319],{},[18,93129,359],{}," so a credential never lands in a public repository:",[253,93132,93133],{"className":255,"code":364,"language":257,"meta":258,"style":258},[18,93134,93135],{"__ignoreMap":258},[262,93136,93137,93139,93141,93143],{"class":181,"line":264},[262,93138,371],{"class":271},[262,93140,374],{"class":275},[262,93142,378],{"class":377},[262,93144,381],{"class":275},[14,93146,93147],{},"A key pushed to a repository can be found and billed by strangers within minutes, so this one line matters more than any model choice you will make.",[57,93149,93151],{"id":93150},"the-same-task-in-both-sdks","The same task in both SDKs",[14,93153,93154],{},"Here is the identical task — a short, summarised reply with a clear role instruction — written first for OpenAI, then for Anthropic. Read them together and the differences jump out.",[12782,93156,93158],{"id":93157},"openai-version","OpenAI version",[253,93160,93162],{"className":414,"code":93161,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()  # reads .env; .env is in .gitignore\n\nclient = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\n\nresponse = client.chat.completions.create(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\"role\": \"system\", \"content\": \"You are a concise assistant.\"},\n        {\"role\": \"user\", \"content\": \"Summarise what an API key is in two sentences.\"},\n    ],\n)\n\nprint(response.choices[0].message.content)\nprint(\"Tokens:\", response.usage.total_tokens)\n",[18,93163,93164,93170,93180,93190,93194,93201,93205,93223,93227,93235,93245,93253,93273,93294,93298,93302,93306,93316],{"__ignoreMap":258},[262,93165,93166,93168],{"class":181,"line":264},[262,93167,684],{"class":377},[262,93169,687],{"class":429},[262,93171,93172,93174,93176,93178],{"class":181,"line":282},[262,93173,705],{"class":377},[262,93175,708],{"class":429},[262,93177,684],{"class":377},[262,93179,713],{"class":429},[262,93181,93182,93184,93186,93188],{"class":181,"line":295},[262,93183,705],{"class":377},[262,93185,720],{"class":429},[262,93187,684],{"class":377},[262,93189,725],{"class":429},[262,93191,93192],{"class":181,"line":345},[262,93193,583],{"emptyLinePlaceholder":582},[262,93195,93196,93198],{"class":181,"line":492},[262,93197,4222],{"class":429},[262,93199,93200],{"class":291},"# reads .env; .env is in .gitignore\n",[262,93202,93203],{"class":181,"line":503},[262,93204,583],{"emptyLinePlaceholder":582},[262,93206,93207,93209,93211,93213,93215,93217,93219,93221],{"class":181,"line":521},[262,93208,739],{"class":429},[262,93210,476],{"class":377},[262,93212,1588],{"class":429},[262,93214,2674],{"class":611},[262,93216,476],{"class":377},[262,93218,1199],{"class":429},[262,93220,2681],{"class":275},[262,93222,2684],{"class":429},[262,93224,93225],{"class":181,"line":537},[262,93226,583],{"emptyLinePlaceholder":582},[262,93228,93229,93231,93233],{"class":181,"line":549},[262,93230,48362],{"class":429},[262,93232,476],{"class":377},[262,93234,1189],{"class":429},[262,93236,93237,93239,93241,93243],{"class":181,"line":570},[262,93238,48371],{"class":611},[262,93240,476],{"class":377},[262,93242,1207],{"class":275},[262,93244,1315],{"class":429},[262,93246,93247,93249,93251],{"class":181,"line":579},[262,93248,48388],{"class":611},[262,93250,476],{"class":377},[262,93252,1220],{"class":429},[262,93254,93255,93257,93259,93261,93263,93265,93267,93269,93271],{"class":181,"line":586},[262,93256,7726],{"class":429},[262,93258,1228],{"class":275},[262,93260,1231],{"class":429},[262,93262,1234],{"class":275},[262,93264,608],{"class":429},[262,93266,1239],{"class":275},[262,93268,1231],{"class":429},[262,93270,89902],{"class":275},[262,93272,3143],{"class":429},[262,93274,93275,93277,93279,93281,93283,93285,93287,93289,93292],{"class":181,"line":591},[262,93276,7726],{"class":429},[262,93278,1228],{"class":275},[262,93280,1231],{"class":429},[262,93282,1291],{"class":275},[262,93284,608],{"class":429},[262,93286,1239],{"class":275},[262,93288,1231],{"class":429},[262,93290,93291],{"class":275},"\"Summarise what an API key is in two sentences.\"",[262,93293,3143],{"class":429},[262,93295,93296],{"class":181,"line":623},[262,93297,48439],{"class":429},[262,93299,93300],{"class":181,"line":634},[262,93301,660],{"class":429},[262,93303,93304],{"class":181,"line":845},[262,93305,583],{"emptyLinePlaceholder":582},[262,93307,93308,93310,93312,93314],{"class":181,"line":850},[262,93309,637],{"class":271},[262,93311,48465],{"class":429},[262,93313,102],{"class":271},[262,93315,6048],{"class":429},[262,93317,93318,93320,93322,93325],{"class":181,"line":864},[262,93319,637],{"class":271},[262,93321,602],{"class":429},[262,93323,93324],{"class":275},"\"Tokens:\"",[262,93326,89959],{"class":429},[14,93328,93329,93330,93332,93333,93336,93337,93339,93340,93342,93343,93345],{},"The system instruction is the first item in the ",[18,93331,43269],{}," list, tagged ",[18,93334,93335],{},"role: \"system\"",". The reply lives at ",[18,93338,7909],{},", because OpenAI can return several ",[18,93341,7913],{}," per call. You do not have to set ",[18,93344,3846],{},"; the model uses its default if you leave it off.",[12782,93347,93349],{"id":93348},"anthropic-version","Anthropic version",[253,93351,93353],{"className":414,"code":93352,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom anthropic import Anthropic\n\nload_dotenv()  # reads .env; .env is in .gitignore\n\nclient = Anthropic(api_key=os.getenv(\"ANTHROPIC_API_KEY\"))\n\nresponse = client.messages.create(\n    model=\"claude-haiku-4-5\",\n    max_tokens=300,                      # required on every Anthropic call\n    system=\"You are a concise assistant.\",\n    messages=[\n        {\"role\": \"user\", \"content\": \"Summarise what an API key is in two sentences.\"},\n    ],\n)\n\nprint(response.content[0].text)\nprint(\"Tokens:\", response.usage.input_tokens + response.usage.output_tokens)\n",[18,93354,93355,93361,93371,93383,93387,93393,93397,93417,93421,93430,93441,93454,93465,93473,93493,93497,93501,93505,93517],{"__ignoreMap":258},[262,93356,93357,93359],{"class":181,"line":264},[262,93358,684],{"class":377},[262,93360,687],{"class":429},[262,93362,93363,93365,93367,93369],{"class":181,"line":282},[262,93364,705],{"class":377},[262,93366,708],{"class":429},[262,93368,684],{"class":377},[262,93370,713],{"class":429},[262,93372,93373,93375,93378,93380],{"class":181,"line":295},[262,93374,705],{"class":377},[262,93376,93377],{"class":429}," anthropic ",[262,93379,684],{"class":377},[262,93381,93382],{"class":429}," Anthropic\n",[262,93384,93385],{"class":181,"line":345},[262,93386,583],{"emptyLinePlaceholder":582},[262,93388,93389,93391],{"class":181,"line":492},[262,93390,4222],{"class":429},[262,93392,93200],{"class":291},[262,93394,93395],{"class":181,"line":503},[262,93396,583],{"emptyLinePlaceholder":582},[262,93398,93399,93401,93403,93406,93408,93410,93412,93415],{"class":181,"line":521},[262,93400,739],{"class":429},[262,93402,476],{"class":377},[262,93404,93405],{"class":429}," Anthropic(",[262,93407,2674],{"class":611},[262,93409,476],{"class":377},[262,93411,1199],{"class":429},[262,93413,93414],{"class":275},"\"ANTHROPIC_API_KEY\"",[262,93416,2684],{"class":429},[262,93418,93419],{"class":181,"line":537},[262,93420,583],{"emptyLinePlaceholder":582},[262,93422,93423,93425,93427],{"class":181,"line":549},[262,93424,48362],{"class":429},[262,93426,476],{"class":377},[262,93428,93429],{"class":429}," client.messages.create(\n",[262,93431,93432,93434,93436,93439],{"class":181,"line":570},[262,93433,48371],{"class":611},[262,93435,476],{"class":377},[262,93437,93438],{"class":275},"\"claude-haiku-4-5\"",[262,93440,1315],{"class":429},[262,93442,93443,93445,93447,93449,93451],{"class":181,"line":579},[262,93444,77660],{"class":611},[262,93446,476],{"class":377},[262,93448,52288],{"class":271},[262,93450,9730],{"class":429},[262,93452,93453],{"class":291},"# required on every Anthropic call\n",[262,93455,93456,93459,93461,93463],{"class":181,"line":586},[262,93457,93458],{"class":611},"    system",[262,93460,476],{"class":377},[262,93462,89902],{"class":275},[262,93464,1315],{"class":429},[262,93466,93467,93469,93471],{"class":181,"line":591},[262,93468,48388],{"class":611},[262,93470,476],{"class":377},[262,93472,1220],{"class":429},[262,93474,93475,93477,93479,93481,93483,93485,93487,93489,93491],{"class":181,"line":623},[262,93476,7726],{"class":429},[262,93478,1228],{"class":275},[262,93480,1231],{"class":429},[262,93482,1291],{"class":275},[262,93484,608],{"class":429},[262,93486,1239],{"class":275},[262,93488,1231],{"class":429},[262,93490,93291],{"class":275},[262,93492,3143],{"class":429},[262,93494,93495],{"class":181,"line":634},[262,93496,48439],{"class":429},[262,93498,93499],{"class":181,"line":845},[262,93500,660],{"class":429},[262,93502,93503],{"class":181,"line":850},[262,93504,583],{"emptyLinePlaceholder":582},[262,93506,93507,93509,93512,93514],{"class":181,"line":864},[262,93508,637],{"class":271},[262,93510,93511],{"class":429},"(response.content[",[262,93513,102],{"class":271},[262,93515,93516],{"class":429},"].text)\n",[262,93518,93519,93521,93523,93525,93528,93530],{"class":181,"line":1683},[262,93520,637],{"class":271},[262,93522,602],{"class":429},[262,93524,93324],{"class":275},[262,93526,93527],{"class":429},", response.usage.input_tokens ",[262,93529,531],{"class":377},[262,93531,93532],{"class":429}," response.usage.output_tokens)\n",[14,93534,93535,93536,93539,93540,93542,93543,93545,93546,93549],{},"Three things changed. The system instruction is a top-level ",[18,93537,93538],{},"system="," parameter, not a message. The ",[18,93541,3846],{}," argument is ",[35,93544,17513],{}," — leave it out and Anthropic returns an error. And the reply lives at ",[18,93547,93548],{},"response.content[0].text",", because Anthropic returns a list of content blocks rather than choices. Everything else — the user message, loading the key, the model name — follows the same shape you already know.",[14,93551,93552],{},"That is the whole story of \"different code\". Two SDK names, one moved parameter, one required argument, and a different path to the reply text. The thinking you do about the task itself is identical.",[57,93554,93556],{"id":93555},"message-format-and-system-prompts-side-by-side","Message format and system prompts, side by side",[14,93558,93559,93560,1363],{},"The system prompt is the single most useful control you have on either provider — it sets the model's role, tone, and rules before the user ever speaks. The wording you write is fully portable; only its location moves. To go deeper on crafting these, see ",[51,93561,1362],{"href":1361},[1379,93563,93564,93575],{},[1382,93565,93566],{},[1385,93567,93568,93571,93573],{},[1388,93569,93570],{},"Concern",[1388,93572,37312],{},[1388,93574,92933],{},[1398,93576,93577,93596,93610,93624,93642,93655,93668,93687],{},[1385,93578,93579,93582,93588],{},[1403,93580,93581],{},"Library \u002F install",[1403,93583,93584,608,93586],{},[18,93585,20],{},[18,93587,8497],{},[1403,93589,93590,608,93593],{},[18,93591,93592],{},"anthropic",[18,93594,93595],{},"pip install anthropic",[1385,93597,93598,93601,93605],{},[1403,93599,93600],{},"Client class",[1403,93602,93603],{},[18,93604,36848],{},[1403,93606,93607],{},[18,93608,93609],{},"Anthropic()",[1385,93611,93612,93615,93619],{},[1403,93613,93614],{},"Call method",[1403,93616,93617],{},[18,93618,71073],{},[1403,93620,93621],{},[18,93622,93623],{},"client.messages.create",[1385,93625,93626,93629,93636],{},[1403,93627,93628],{},"System prompt",[1403,93630,93631,93632,608,93634],{},"item in ",[18,93633,43269],{},[18,93635,93335],{},[1403,93637,93638,93639,93641],{},"top-level ",[18,93640,93538],{}," parameter",[1385,93643,93644,93648,93651],{},[1403,93645,93646],{},[18,93647,3846],{},[1403,93649,93650],{},"optional (defaults applied)",[1403,93652,93653,58534],{},[35,93654,17513],{},[1385,93656,93657,93660,93664],{},[1403,93658,93659],{},"Reply text",[1403,93661,93662],{},[18,93663,7909],{},[1403,93665,93666],{},[18,93667,93548],{},[1385,93669,93670,93673,93680],{},[1403,93671,93672],{},"Token usage",[1403,93674,93675,31800,93677],{},[18,93676,91515],{},[18,93678,93679],{},"completion_tokens",[1403,93681,93682,31800,93685],{},[18,93683,93684],{},"usage.input_tokens",[18,93686,59496],{},[1385,93688,93689,93692,93696],{},[1403,93690,93691],{},"Beginner model",[1403,93693,93694],{},[18,93695,2703],{},[1403,93697,93698],{},[18,93699,93700],{},"claude-haiku-4-5",[14,93702,93703],{},"Keep this table next to you when you port a script from one provider to the other. Nine times out of ten, those eight rows are every change you need to make.",[57,93705,93707],{"id":93706},"pricing-and-the-token-model","Pricing and the token model",[14,93709,93710,93711,93713,93714,93716],{},"Both providers bill the same way: by the ",[35,93712,7933],{},", a small chunk of text roughly three-quarters of a word. You pay one rate for the tokens you send (input) and a higher rate for the tokens the model writes back (output). That shared model means the cost-control habits you learn on one carry straight to the other: keep prompts tight, cap ",[18,93715,3846],{},", and log usage on every call.",[14,93718,93719,93720,93722],{},"Where they differ is the per-token price and the model line-up. Each provider offers a cheap small model for everyday work and a pricier large model for hard reasoning. OpenAI's ",[18,93721,2703],{}," and Anthropic's Claude Haiku tier are the inexpensive entry points; both cost a fraction of a cent for a short reply. The large models — OpenAI's GPT-4-class and Anthropic's Claude Opus tier — cost meaningfully more per token but reason more deeply.",[14,93724,93725,93726,93728,93729,93731],{},"Because per-token prices change, never hard-code a number from a blog post into your budget. Read the live figure from each provider's pricing page, and measure your own usage with the token counts every response returns. If you want to practise without spending at all, ",[51,93727,5485],{"href":5484}," covers free options, and ",[51,93730,69908],{"href":69907}," compares two fast free endpoints.",[57,93733,93735],{"id":93734},"strengths-of-each-in-plain-terms","Strengths of each, in plain terms",[14,93737,93738],{},"Neither API wins outright, and the honest answer is that both are excellent for a beginner. Their character differs at the edges.",[14,93740,93741,93742,93745],{},"OpenAI's strength is ",[35,93743,93744],{},"breadth of ecosystem",". Alongside text, it offers image generation, speech-to-text and text-to-speech, and embeddings (numeric representations of text used for search), all under one key and one library. If your project mixes media — generate a thumbnail, transcribe a clip, then write a caption — staying inside one provider is convenient, and the sheer volume of tutorials means most beginner questions are already answered somewhere.",[14,93747,93748,93749,93752],{},"Anthropic's strength is ",[35,93750,93751],{},"long, careful text work",". Claude models handle very large inputs in a single request, which suits summarising long documents or working across a whole codebase, and the API is built with agent and tool workflows in mind. Many writers also find Claude's prose steadier for nuanced, long-form tasks. If your work is mostly reading and writing a lot of text reliably, it is a natural fit.",[14,93754,93755],{},"These are tendencies, not walls. You can generate captions with Claude and write long essays with GPT. Pick based on the bulk of your work, not an exception.",[57,93757,90888],{"id":90887},[14,93759,93760],{},"Use this as a quick decision aid, not a law:",[2322,93762,93763,93769,93774,93780,93785],{},[1450,93764,93765,93768],{},[35,93766,93767],{},"Pick OpenAI when"," your project needs more than text — images, audio, or embeddings — and you want it all under one library and one key.",[1450,93770,93771,93773],{},[35,93772,93767],{}," you are brand new and want the largest pool of tutorials, sample code, and community answers to lean on.",[1450,93775,93776,93779],{},[35,93777,93778],{},"Pick Anthropic when"," your task is reading or writing large amounts of text, especially long documents or multi-file context that needs a big input window.",[1450,93781,93782,93784],{},[35,93783,93778],{}," you are building an agent or tool-using workflow and want an API designed around that pattern.",[1450,93786,93787,93790],{},[35,93788,93789],{},"Pick either"," for a standard chatbot, a summariser, or a content generator — both do these well, so choose on price, on a model you like the tone of, or simply on which key you already have.",[57,93792,93794],{"id":93793},"a-provider-agnostic-wrapper","A provider-agnostic wrapper",[14,93796,93797],{},"Once you understand both, you can hide the difference behind one function and switch providers with a single argument. This worked example does exactly that, so the rest of your program never needs to care which model answered.",[253,93799,93801],{"className":414,"code":93800,"language":416,"meta":258,"style":258},"import os\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\nfrom anthropic import Anthropic\n\nload_dotenv()  # pulls both keys from .env (which is in .gitignore)\n\nopenai_client = OpenAI(api_key=os.getenv(\"OPENAI_API_KEY\"))\nanthropic_client = Anthropic(api_key=os.getenv(\"ANTHROPIC_API_KEY\"))\n\n\ndef ask(prompt: str, provider: str = \"openai\", system: str = \"You are concise.\") -> str:\n    \"\"\"Send one prompt to either provider and return the reply text.\"\"\"\n    if provider == \"openai\":\n        response = openai_client.chat.completions.create(\n            model=\"gpt-4o-mini\",\n            messages=[\n                {\"role\": \"system\", \"content\": system},\n                {\"role\": \"user\", \"content\": prompt},\n            ],\n        )\n        usage = response.usage\n        print(f\"[openai tokens: {usage.total_tokens}]\")\n        return response.choices[0].message.content\n\n    if provider == \"anthropic\":\n        response = anthropic_client.messages.create(\n            model=\"claude-haiku-4-5\",\n            max_tokens=300,            # required for Anthropic\n            system=system,             # system is a top-level parameter\n            messages=[{\"role\": \"user\", \"content\": prompt}],\n        )\n        usage = response.usage\n        print(f\"[anthropic tokens: {usage.input_tokens + usage.output_tokens}]\")\n        return response.content[0].text\n\n    raise ValueError(f\"Unknown provider: {provider}\")\n\n\nif __name__ == \"__main__\":\n    question = \"Explain what an LLM API does in two sentences.\"\n    print(\"OpenAI:   \", ask(question, provider=\"openai\"))\n    print(\"Anthropic:\", ask(question, provider=\"anthropic\"))\n",[18,93802,93803,93809,93819,93829,93839,93843,93850,93854,93873,93892,93896,93900,93934,93939,93951,93960,93970,93978,93994,94010,94014,94018,94027,94049,94059,94063,94076,94085,94095,94108,94121,94141,94145,94153,94180,94192,94196,94219,94223,94227,94239,94248,94269],{"__ignoreMap":258},[262,93804,93805,93807],{"class":181,"line":264},[262,93806,684],{"class":377},[262,93808,687],{"class":429},[262,93810,93811,93813,93815,93817],{"class":181,"line":282},[262,93812,705],{"class":377},[262,93814,708],{"class":429},[262,93816,684],{"class":377},[262,93818,713],{"class":429},[262,93820,93821,93823,93825,93827],{"class":181,"line":295},[262,93822,705],{"class":377},[262,93824,720],{"class":429},[262,93826,684],{"class":377},[262,93828,725],{"class":429},[262,93830,93831,93833,93835,93837],{"class":181,"line":345},[262,93832,705],{"class":377},[262,93834,93377],{"class":429},[262,93836,684],{"class":377},[262,93838,93382],{"class":429},[262,93840,93841],{"class":181,"line":492},[262,93842,583],{"emptyLinePlaceholder":582},[262,93844,93845,93847],{"class":181,"line":503},[262,93846,4222],{"class":429},[262,93848,93849],{"class":291},"# pulls both keys from .env (which is in .gitignore)\n",[262,93851,93852],{"class":181,"line":521},[262,93853,583],{"emptyLinePlaceholder":582},[262,93855,93856,93859,93861,93863,93865,93867,93869,93871],{"class":181,"line":537},[262,93857,93858],{"class":429},"openai_client ",[262,93860,476],{"class":377},[262,93862,1588],{"class":429},[262,93864,2674],{"class":611},[262,93866,476],{"class":377},[262,93868,1199],{"class":429},[262,93870,2681],{"class":275},[262,93872,2684],{"class":429},[262,93874,93875,93878,93880,93882,93884,93886,93888,93890],{"class":181,"line":549},[262,93876,93877],{"class":429},"anthropic_client ",[262,93879,476],{"class":377},[262,93881,93405],{"class":429},[262,93883,2674],{"class":611},[262,93885,476],{"class":377},[262,93887,1199],{"class":429},[262,93889,93414],{"class":275},[262,93891,2684],{"class":429},[262,93893,93894],{"class":181,"line":570},[262,93895,583],{"emptyLinePlaceholder":582},[262,93897,93898],{"class":181,"line":579},[262,93899,583],{"emptyLinePlaceholder":582},[262,93901,93902,93904,93906,93908,93910,93912,93914,93916,93919,93921,93923,93925,93928,93930,93932],{"class":181,"line":586},[262,93903,423],{"class":377},[262,93905,44066],{"class":267},[262,93907,9599],{"class":429},[262,93909,433],{"class":271},[262,93911,90401],{"class":429},[262,93913,433],{"class":271},[262,93915,442],{"class":377},[262,93917,93918],{"class":275}," \"openai\"",[262,93920,73860],{"class":429},[262,93922,433],{"class":271},[262,93924,442],{"class":377},[262,93926,93927],{"class":275}," \"You are concise.\"",[262,93929,1939],{"class":429},[262,93931,433],{"class":271},[262,93933,1160],{"class":429},[262,93935,93936],{"class":181,"line":591},[262,93937,93938],{"class":275},"    \"\"\"Send one prompt to either provider and return the reply text.\"\"\"\n",[262,93940,93941,93943,93945,93947,93949],{"class":181,"line":623},[262,93942,3454],{"class":377},[262,93944,81670],{"class":429},[262,93946,10758],{"class":377},[262,93948,93918],{"class":275},[262,93950,1160],{"class":429},[262,93952,93953,93955,93957],{"class":181,"line":634},[262,93954,21490],{"class":429},[262,93956,476],{"class":377},[262,93958,93959],{"class":429}," openai_client.chat.completions.create(\n",[262,93961,93962,93964,93966,93968],{"class":181,"line":845},[262,93963,14214],{"class":611},[262,93965,476],{"class":377},[262,93967,1207],{"class":275},[262,93969,1315],{"class":429},[262,93971,93972,93974,93976],{"class":181,"line":850},[262,93973,27253],{"class":611},[262,93975,476],{"class":377},[262,93977,1220],{"class":429},[262,93979,93980,93982,93984,93986,93988,93990,93992],{"class":181,"line":864},[262,93981,53817],{"class":429},[262,93983,1228],{"class":275},[262,93985,1231],{"class":429},[262,93987,1234],{"class":275},[262,93989,608],{"class":429},[262,93991,1239],{"class":275},[262,93993,7739],{"class":429},[262,93995,93996,93998,94000,94002,94004,94006,94008],{"class":181,"line":1683},[262,93997,53817],{"class":429},[262,93999,1228],{"class":275},[262,94001,1231],{"class":429},[262,94003,1291],{"class":275},[262,94005,608],{"class":429},[262,94007,1239],{"class":275},[262,94009,38272],{"class":429},[262,94011,94012],{"class":181,"line":1688},[262,94013,53856],{"class":429},[262,94015,94016],{"class":181,"line":1693},[262,94017,6288],{"class":429},[262,94019,94020,94023,94025],{"class":181,"line":1728},[262,94021,94022],{"class":429},"        usage ",[262,94024,476],{"class":377},[262,94026,90584],{"class":429},[262,94028,94029,94031,94033,94035,94038,94040,94042,94044,94047],{"class":181,"line":1737},[262,94030,2299],{"class":271},[262,94032,602],{"class":429},[262,94034,642],{"class":377},[262,94036,94037],{"class":275},"\"[openai tokens: ",[262,94039,3039],{"class":271},[262,94041,71173],{"class":429},[262,94043,654],{"class":271},[262,94045,94046],{"class":275},"]\"",[262,94048,660],{"class":429},[262,94050,94051,94053,94055,94057],{"class":181,"line":1751},[262,94052,8066],{"class":377},[262,94054,1326],{"class":429},[262,94056,102],{"class":271},[262,94058,1331],{"class":429},[262,94060,94061],{"class":181,"line":1764},[262,94062,583],{"emptyLinePlaceholder":582},[262,94064,94065,94067,94069,94071,94074],{"class":181,"line":1779},[262,94066,3454],{"class":377},[262,94068,81670],{"class":429},[262,94070,10758],{"class":377},[262,94072,94073],{"class":275}," \"anthropic\"",[262,94075,1160],{"class":429},[262,94077,94078,94080,94082],{"class":181,"line":1793},[262,94079,21490],{"class":429},[262,94081,476],{"class":377},[262,94083,94084],{"class":429}," anthropic_client.messages.create(\n",[262,94086,94087,94089,94091,94093],{"class":181,"line":1800},[262,94088,14214],{"class":611},[262,94090,476],{"class":377},[262,94092,93438],{"class":275},[262,94094,1315],{"class":429},[262,94096,94097,94099,94101,94103,94105],{"class":181,"line":1805},[262,94098,27286],{"class":611},[262,94100,476],{"class":377},[262,94102,52288],{"class":271},[262,94104,54526],{"class":429},[262,94106,94107],{"class":291},"# required for Anthropic\n",[262,94109,94110,94113,94115,94118],{"class":181,"line":1810},[262,94111,94112],{"class":611},"            system",[262,94114,476],{"class":377},[262,94116,94117],{"class":429},"system,             ",[262,94119,94120],{"class":291},"# system is a top-level parameter\n",[262,94122,94123,94125,94127,94129,94131,94133,94135,94137,94139],{"class":181,"line":1823},[262,94124,27253],{"class":611},[262,94126,476],{"class":377},[262,94128,8856],{"class":429},[262,94130,1228],{"class":275},[262,94132,1231],{"class":429},[262,94134,1291],{"class":275},[262,94136,608],{"class":429},[262,94138,1239],{"class":275},[262,94140,18141],{"class":429},[262,94142,94143],{"class":181,"line":1846},[262,94144,6288],{"class":429},[262,94146,94147,94149,94151],{"class":181,"line":1861},[262,94148,94022],{"class":429},[262,94150,476],{"class":377},[262,94152,90584],{"class":429},[262,94154,94155,94157,94159,94161,94164,94166,94169,94171,94174,94176,94178],{"class":181,"line":1866},[262,94156,2299],{"class":271},[262,94158,602],{"class":429},[262,94160,642],{"class":377},[262,94162,94163],{"class":275},"\"[anthropic tokens: ",[262,94165,3039],{"class":271},[262,94167,94168],{"class":429},"usage.input_tokens ",[262,94170,531],{"class":377},[262,94172,94173],{"class":429}," usage.output_tokens",[262,94175,654],{"class":271},[262,94177,94046],{"class":275},[262,94179,660],{"class":429},[262,94181,94182,94184,94187,94189],{"class":181,"line":1871},[262,94183,8066],{"class":377},[262,94185,94186],{"class":429}," response.content[",[262,94188,102],{"class":271},[262,94190,94191],{"class":429},"].text\n",[262,94193,94194],{"class":181,"line":1890},[262,94195,583],{"emptyLinePlaceholder":582},[262,94197,94198,94200,94202,94204,94206,94209,94211,94213,94215,94217],{"class":181,"line":1909},[262,94199,2829],{"class":377},[262,94201,2832],{"class":271},[262,94203,602],{"class":429},[262,94205,642],{"class":377},[262,94207,94208],{"class":275},"\"Unknown provider: ",[262,94210,3039],{"class":271},[262,94212,90599],{"class":429},[262,94214,654],{"class":271},[262,94216,1176],{"class":275},[262,94218,660],{"class":429},[262,94220,94221],{"class":181,"line":1914},[262,94222,583],{"emptyLinePlaceholder":582},[262,94224,94225],{"class":181,"line":1919},[262,94226,583],{"emptyLinePlaceholder":582},[262,94228,94229,94231,94233,94235,94237],{"class":181,"line":1946},[262,94230,2210],{"class":377},[262,94232,2213],{"class":271},[262,94234,2216],{"class":377},[262,94236,2219],{"class":275},[262,94238,1160],{"class":429},[262,94240,94241,94243,94245],{"class":181,"line":1959},[262,94242,90652],{"class":429},[262,94244,476],{"class":377},[262,94246,94247],{"class":275}," \"Explain what an LLM API does in two sentences.\"\n",[262,94249,94250,94252,94254,94257,94260,94262,94264,94267],{"class":181,"line":1996},[262,94251,1089],{"class":271},[262,94253,602],{"class":429},[262,94255,94256],{"class":275},"\"OpenAI:   \"",[262,94258,94259],{"class":429},", ask(question, ",[262,94261,90599],{"class":611},[262,94263,476],{"class":377},[262,94265,94266],{"class":275},"\"openai\"",[262,94268,2684],{"class":429},[262,94270,94271,94273,94275,94278,94280,94282,94284,94287],{"class":181,"line":2012},[262,94272,1089],{"class":271},[262,94274,602],{"class":429},[262,94276,94277],{"class":275},"\"Anthropic:\"",[262,94279,94259],{"class":429},[262,94281,90599],{"class":611},[262,94283,476],{"class":377},[262,94285,94286],{"class":275},"\"anthropic\"",[262,94288,2684],{"class":429},[14,94290,13310,94291,94293,94294,94296,94297,94299],{},[18,94292,90717],{},". You will see both providers answer the same question, each printing its own token report. This pattern — one ",[18,94295,82112],{}," function, a ",[18,94298,90599],{}," switch — is exactly how many production apps stay flexible, choosing a provider per task or falling back to the other when one is busy.",[57,94301,94303],{"id":94302},"a-quick-decision-summary","A quick decision summary",[14,94305,94306,94307,94310],{},"If you remember one thing: ",[35,94308,94309],{},"for mixed-media or maximum tutorials, start with OpenAI; for heavy text and big inputs, start with Anthropic; for everything else, pick whichever key you already hold and move on."," The code differences are small and the prompt thinking is shared, so the second provider is an afternoon of learning, not a fresh start. Commit to one, ship something, and add the other when a real task asks for it.",[14,94312,2375,94313,1363],{},[51,94314,2487],{"href":2486},[57,94316,2381],{"id":2380},[2322,94318,94319,94324,94329,94334,94339],{},[1450,94320,94321,94323],{},[51,94322,2487],{"href":2486}," — the main guide for this section, covering how any LLM API works.",[1450,94325,94326,94328],{},[51,94327,5485],{"href":5484}," — practise with either provider without spending.",[1450,94330,94331,94333],{},[51,94332,69908],{"href":69907}," — compare two fast free endpoints for experiments.",[1450,94335,94336,94338],{},[51,94337,1362],{"href":1361}," — get reliable, well-shaped replies on either API.",[1450,94340,94341,94343],{},[51,94342,5423],{"href":5422}," — install Python and a virtual environment before your first call.",[2401,94345,63366],{},{"title":258,"searchDepth":282,"depth":282,"links":94347},[94348,94349,94350,94354,94355,94356,94357,94358,94359,94360],{"id":92909,"depth":282,"text":92910},{"id":93033,"depth":282,"text":93034},{"id":93150,"depth":282,"text":93151,"children":94351},[94352,94353],{"id":93157,"depth":295,"text":93158},{"id":93348,"depth":295,"text":93349},{"id":93555,"depth":282,"text":93556},{"id":93706,"depth":282,"text":93707},{"id":93734,"depth":282,"text":93735},{"id":90887,"depth":282,"text":90888},{"id":93793,"depth":282,"text":93794},{"id":94302,"depth":282,"text":94303},{"id":2380,"depth":282,"text":2381},"A fair, beginner-friendly comparison of the OpenAI and Anthropic Python APIs: SDKs, message formats, system prompts, pricing, and when to pick which.",[94363,94366,94369,94372,94375],{"q":94364,"a":94365},"What is the main difference between the OpenAI and Anthropic APIs?","Both let your Python script send text to a hosted model and get text back, but the request shapes differ. OpenAI puts the system instruction inside the messages list and returns choices; Anthropic takes the system instruction as a separate parameter and always requires you to set max_tokens. The concepts map closely, so skills transfer either way.",{"q":94367,"a":94368},"Do OpenAI and Anthropic use different Python libraries?","Yes. OpenAI uses the openai package and you call client.chat.completions.create. Anthropic uses the anthropic package and you call client.messages.create. You install each with pip and authenticate with that provider's own API key.",{"q":94370,"a":94371},"Which API is cheaper for a beginner?","For light experimentation both have small, inexpensive models that cost a fraction of a cent per request. OpenAI's gpt-4o-mini and Anthropic's Claude Haiku are the cheapest tiers. Always check each provider's current pricing page, since per-token prices change.",{"q":94373,"a":94374},"Can I use the same prompt with both providers?","Mostly yes. A plain instruction works on either. You will adjust where the system instruction goes and how you read the reply, but the wording of your prompt itself usually transfers without changes.",{"q":94376,"a":94377},"Should I learn one API or both?","Start with one so you build momentum, then learn the second when a project needs it. The mental model is shared, so the second provider takes an afternoon, not a week. Many production apps keep both wired up and switch based on cost or task.",{},"\u002Fpython-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fopenai-vs-anthropic-api-for-beginners",{"title":14635,"description":94361},"python-ai-fundamentals-for-non-developers\u002Funderstanding-llm-apis\u002Fopenai-vs-anthropic-api-for-beginners\u002Findex","1dCVt9r8yprCcJHdxwAcnrYo8Bmy8WjjArKgwIEP62E",1781811441534]