{"slug": "what-does-the-card-say-text-extraction-using-amazon-nova-2-lite", "title": "What does the card say? Text extraction using Amazon Nova 2 Lite", "summary": "An AWS developer integrated Amazon Nova 2 Lite via Amazon Bedrock into an event-driven image processing pipeline to extract text and icon metadata from AWS Builder Cards. The multimodal model is invoked through a simple converse() API call, replacing Amazon Textract and Amazon Rekognition because those services cannot interpret the card's icons. The extracted data is stored in DynamoDB as card metadata.", "body_md": "**No container, no weights, no memory limit to worry about. The only thing I own in this model is a prompt and it did not always go well.**\n\nTwo models in this pipeline have already had their own article: [card-detect](https://dev.to/aws-builders/is-this-even-a-valid-card-zero-shot-image-classification-model-in-a-lambda-container-58oj) lambda, which answers *is this even an AWS Builder Card?*, and [image-processor](https://dev.to/aws-builders/where-exactly-is-the-card-in-this-photo-image-segmentation-model-inside-a-maxed-out-lambda-51da) lambda, which answers *where exactly is the card in this photo?* Whole pipeline in a nutshell is also described [here](https://dev.to/aws-builders/from-anonymous-photo-to-a-published-page-an-event-driven-ai-image-processing-pipeline-on-aws-2m2n).\n\nSo what is the current situation?\n\nThe card is straight and clean by now, but I still don't know what it says.\n\nThis article describes what happens next. There is another model which is not part of any lambda, but is being called by `image-processor`\n\n. That model is `Nova 2 Lite`\n\nliving in `Amazon Bedrock`\n\nand it extracts the card's text. Once extracted, the same lambda write that extracted text to `DynamoDB`\n\nas a card's metadata.\n\nBoth previous articles run the same three questions before putting a model into a lambda. Since this model does not run in the lambda, I don't even care about the answers:\n\n`Bedrock`\n\n.No container to build, nothing to bake into an image, no `ECR`\n\npush, no cold start to measure, no `10240 MB`\n\nceiling to hit.\n\nThe model is `Amazon Nova 2 Lite`\n\nand it doesn't even run in my account.\n\nThe lambda make an API call towards `Bedrock`\n\n, where the text is extracted and later stored in `DynamoDB`\n\nas a card's metadata. The only thing I have to worry about here is IAM permissions and writing a good prompt.\n\nReading a card is not just OCR! Here is the actual problem and it is not **just** the text recognition:\n\nA player knows what an orange circle means, but model has to be told.\n\nSome cards carry all 3 icons, others just 1 or 2 and there are cards with none.\n\nAWS offer couple of resources capable of recognizing the text:\n\n`Amazon Textract`\n\n`Amazon Reckognition`\n\nBut their inability to understand and interpret the icons made them unusable for this case. This is the only reason I used multimodal model, capable of both tasks.\n\nThe whole thing is just a `converse()`\n\nAPI call:\n\n``` python\ndef extract_card_fields(png_bytes):\n    # ... (docstring trimmed) ...\n\n    # Invoke the Bedrock vision model.\n    resp = bedrock.converse(\n        modelId=BEDROCK_MODEL_ID,\n        system=[{\"text\": EXTRACT_SYSTEM}],\n        messages=[{\n            \"role\": \"user\",\n            \"content\": [\n                {\"text\": EXTRACT_PROMPT},\n                {\"image\": {\"format\": \"png\", \"source\": {\"bytes\": png_bytes}}},\n            ],\n        }],\n        inferenceConfig={\"maxTokens\": 1200, \"temperature\": 0.0},\n    )\n\n    # Parse the model response.\n    text = resp[\"output\"][\"message\"][\"content\"][0][\"text\"]\n    return _parse_json(text)\n```\n\nI set up `temperature`\n\nto `0.0`\n\nbecause I want it to always return answer it is most confident about. I am expecting always the same answer, no matter how many times the same card goes in. That actually makes sense, since it is reading the text.\n\nIn order for the model to do anything, it needs its instructions - the prompt. Especially in case of the icons.\n\n```\n# ...(beginning and title omitted)...\n\nTASK 2: EFFECT\nThe effect is the gameplay area in the middle of the card.\n\nThe effect area may contain small icons.\nNot all icon types are always present on s csrd.\nA card may have: 0 icons, or 1 icon, or 2 icons or all 3 icons.\nOnly use icons that are actually visible on this card.\n\nThere are exactly 3 possible icon types:\n\nICON TYPE 1: CREDIT\nVisual appearance: orange circle contains a white number. That number has NO plus sign.\nExample: orange circle with \"1\"\nThis icon can be small, so look carefully.\nMeaning: \"Get N credit\", if N = 1.\n\nICON TYPE 2: DRAW CARDS\nVisual appearance: black rounded rectangle contains a white number. the number ALWAYS has a plus sign, like \"+1\" or \"+2\"\nThis icon can be small, so look carefully.\nMeaning: \"Draw N card from your Resources Pile\" if N = 1. \"Draw N cards from your Resources Pile\" if N > 1.\n\nICON TYPE 3: CLOUD ADOPTION EFFECT\nVisual appearance: small white cloud shape with black outline contains a black number. the number ALWAYS has a plus sign, like \"+1\" or \"+2\".\nThis icon can be small, so look carefully.\nMeaning: \"Use N cloud adoption effect\" if N = 1. \"Use N cloud adoption effects\" if N > 1.\n\n# ...(rest of the prompt omitted)...\n```\n\nDuring the local and live testing, I came into several issues but all I was able to fix with tuning the prompt.\n\nThe effects were where prompt was loosing to most. Sometimes it was ignoring the icons, other time it was adding them where they weren't, like for this card:\n\nI ran it 11 times and 7 times I got it wrong:\n\n`Get 1 credit. `\n\n.\n\nDraw 1 card from your Resources Pile.\n\nUse 1 cloud adoption effect**----> THIS IS NOT ON THE CARD!**\n\nThe card has **no cloud adoption effect**, but yet it was fabricating it!\n\nThe solution is easier than you think. After I added section `IMPORTANT ICON RULES`\n\ninto the prompt, the icon hallucination stopped.\n\n```\nIMPORTANT ICON RULES:\nIf a number has NO plus sign and is inside an orange circle, it means credits.\nIf a number has a plus sign and is inside a dark rounded rectangle, it means draw cards.\nIf a number has a plus sign and is inside a white cloud outline, it means cloud adoption effects.\nA plus sign never means credits.\nDo not invent missing icons.\nDo not mention credits unless an orange circle is visible.\nDo not mention drawing cards unless a dark rounded rectangle is visible.\nDo not mention cloud adoption effects unless a white cloud icon is visible.\n```\n\nAnother problem I had was with Credits (a number in orange circle).\n\nWith initial prompt, it was only interpreting it as: `Get 1 credit`\n\n, no matter the number in the circle.\n\nThe solution was making the prompt into **few-shot example** prompt, adding examples like:\n\n```\n\"Get N credit\", if N = 1.\n\n\"Draw N card from your Resources Pile\" if N = 1. \"Draw N cards from your Resources Pile\" if N > 1.\n\n\"Use N cloud adoption effect\" if N = 1. \"Use N cloud adoption effects\" if N > 1\n```\n\nI am expecting a model to read the text on the card, and return 3 key:value pairs:\n\nBut here's where AWS Builder Cards fights back again - some of them have a \"subtitle\":\n\nWithout considering that into the prompt, this was the result of the text extraction:\n\n| What model extracted | Full card's title |\n|---|---|\n`AWS certified Solutions Architect` |\n`AWS certified Solutions Architect Associate` |\n`AWS certified Solutions Architect` |\n`AWS certified Solutions Architect Professional` |\n`AWS certified Developer` |\n`AWS certified Developer Associate` |\n`AWS certified Sysops Administrator` |\n`AWS certified Sysops Administrator Associate` |\n\nLook at the first two rows. Those are two physically different cards - a **Solutions Architect Associate** and a **Solutions Architect Professional**, but the model returned **the same title** for both. If I had trusted the drafts (without manual approvals), my catalog would hold the same card twice and be missing another one.\n\nI had 2 options how to deal with that:\n\nOf course I fixed the prompt - the less manual job for me during the approvals, the better!\n\n```\nEXTRACT_PROMPT = \"\"\"Return ONLY valid JSON.\n\nThe JSON must have exactly these keys:\n\n{\n\"title\": \"\",\n\"effect\": \"\",\n\"description\": \"\"\n}\n\nRead the card from top to bottom.\n\nTASK 1: TITLE\nThe title is the card name. It can have two parts.\n\nPart 1 - the text in the title bar at the top of the card. \nAlways present.\nRead it in full, exactly as printed.\n\nPart 2 - a qualifier printed in its own banner inside the artwork, below the title bar. \nOnly some cards have this. Examples of what it looks like: ASSOCIATE, PROFESSIONAL, FOUNDATIONAL, SPECIALTY.\n\nIf a banner like that is visible, the title is Part 1 followed by Part 2, written in normal capitalisation:\n\"AWS certified Solutions Architect\" + \"ASSOCIATE\" -> \"AWS certified Solutions Architect Associate\"\n\nIf no such banner is visible, the title is Part 1 alone.\nDo not add a qualifier that is not printed on the card.\n\nExamples: \"AWS Cloud Practitioner\", \"David\", \"AWS certified Solutions Architect Professional\".\nIf you cannot read it, use \"\".\n\n# ...(rest of the prompt omitted)...\n```\n\nThat's just enough for model to understand when the card has a \"subtitle\".\n\nThat may seem like not important, but consider text on the the cards contain bold text, links, sepparate lines, etc... If I want final card page to look like the card itself, I have to follow that.\n\nAgain, this is something that would take me 10 seconds during the manual approval, but why if I can do it with prompt? Few shots example will do the job\n\n```\nIf you spot text in bold, write it as for makrdown files - that means like this: **this is bold text**\n\nAny internet link (URL) you spot, you must write in this format: [link](link).\n```\n\nHaving implemented all prompt modifications, now I can say in **most** cases, this prompt works 100%. Occasionally there some some minimal hickups, but generally it works perfectly.\n\nAs you can imagine, I did not write this prompt at once. At least 6 versions of it went live, after I was happy with the outputs.\n\nBedrock returns **title**, **description** and **effect** back to `image-processing`\n\nlambda.\n\nTo create a card's slug markdown file which is performed by lambda `review-editor`\n\nin the next steps **LINK TO ARTICLE 5 - TBD**, more values are actually needed.\n\nTherefore the `image-processor`\n\nlambda actually gathers a lot more values, before sending them to `DynamoDB`\n\n.\n\n``` python\ndef write_card_item(card_id, event, year, raw_key, fin_key, fields, uploader=\"\"):\n    # Generate a timestamp for the new record\n    now = datetime.now(timezone.utc).isoformat()\n\n    # The year is only appended when the title does not already carry it\n    title_draft = fields.get(\"title\", \"\")\n    year_str = str(year).strip()\n    slug_src = f\"{title_draft} {year_str}\" if year_str and year_str not in title_draft else title_draft\n    slug_draft = slugify(slug_src)\n\n    # Store the pending card record in DynamoDB\n    ddb.put_item(\n        TableName=DDB_TABLE,\n        Item={\n            \"cardId\": {\"S\": card_id},                                        # ---> from S3 key (the uuid, via parse_meta)\n            \"slug\": {\"S\": \"\"},                                               # ---> empty, for the human\n            \"slug_ai_draft\": {\"S\": slug_draft},                              # ---> computed locally from Bedrock's title + year\n            \"weight\": {\"S\": \"\"},                                             # ---> empty, for the human\n            \"event\": {\"S\": event or \"\"},                                     # ---> from S3 key (via parse_meta)\n            \"year\": {\"S\": str(year or \"\")},                                  # ---> from S3 key (via parse_meta)\n            \"uploader\": {\"S\": uploader or \"\"},                               # ---> from S3 object metadata (typed by the visitor)\n            \"category\": {\"S\": \"collectibles\"},                               # ---> hardcoded constant\n            \"subcategory\": {\"S\": \"\"},                                        # ---> empty, for the human\n            \"title\": {\"S\": \"\"},                                              # ---> empty, for the human\n            \"title_ai_draft\": {\"S\": fields.get(\"title\", \"\")},                # ---> from Bedrock\n            \"effect\": {\"S\": \"\"},                                             # ---> empty, for the human\n            \"effect_ai_draft\": {\"S\": fields.get(\"effect\", \"\")},              # ---> from Bedrock\n            \"description\": {\"S\": \"\"},                                        # ---> empty, for the human\n            \"description_ai_draft\": {\"S\": fields.get(\"description\", \"\")},    # ---> from Bedrock\n            \"rawKey\": {\"S\": raw_key},                                        # ---> from the S3 event (the uploaded object's key)\n            \"finishedKey\": {\"S\": fin_key},                                   # ---> computed locally (images/finished/<cardId>.png)\n            \"status\": {\"S\": \"pending\"},                                      # ---> hardcoded constant - the review gate\n            \"createdAt\": {\"S\": now},                                         # ---> computed locally (UTC timestamp)\n            \"updatedAt\": {\"S\": now},                                         # ---> computed locally (same timestamp)\n        },\n    )\n```\n\nThis is what is actually written in the DB. Some values are intentionally left empty and will be filled by next lambda - `review-editor`\n\n, while some of the empties have to be manually filled by me. More on that in **ARTICLE 5 - TBD**\n\nIt is tempting to create a separate lambda function for `Bedrock`\n\nand `DynamoDB`\n\ncalls, which would be completly isolated from `image-processing`\n\nlambda.\n\nHowever, I found that as not a good idea, mainly because how the `image-processor`\n\nwork and what it sends to the `Bedrock`\n\n. Lambda **does not send the finished card picture**, from `images/finished`\n\nto the text extraction. It sends the ** png bytes**, it stores in its own memory. The split would mean a second function has to download the finished image from\n\n`S3`\n\n, which brings extra latency, another GET, IAM role, etc...The one argument that would justify splitting, is wide IAM permission current current `image-processor`\n\nholds. Having image processing part along with API calls to `Bedrock`\n\nand `Dynamo DB`\n\nrequires permissions for `S3`\n\n, `Bedrock`\n\n, `DynamoDB`\n\nand `sns`\n\nin one role. That doesn't go really well with lest privilege concept I am applying where possible in this project, but here I made an exception.\n\nEach of the arguments have pros and cons and me personally I was 50:50 on it if to split or keep as one, but I decided to keep it this time.\n\nThis part of the pipeline is pretty simple, the longest part to test and tune was the prompt. I started on couple of lines, and after endless tests I ended up on almost 100 lines.\n\nThe result is **extracted text** written in `DynamoDB`\n\nas particular cards' metadata.\n\nNext step is just manual - me as an admin visually verify the card against the extracted text and approve. Right after that the deployment process starts, which is described in next **ARTICLE 5 - TBD**\n\n[From anonymous photo to a published page: An event-driven, AI, image processing pipeline on AWS](https://dev.tolink)\n\n[Is this even a valid card? Zero-shot image classification model in a lambda container](https://dev.tolink)\n\n[Where exactly is card in this photo? Image segmentation model inside a maxed-out lambda container](https://dev.tolink)\n\nWho reviews the reviewer? Building the human step in an AI pipeline -**TBD**", "url": "https://wpnews.pro/news/what-does-the-card-say-text-extraction-using-amazon-nova-2-lite", "canonical_source": "https://dev.to/aws-builders/what-does-the-card-say-text-extraction-using-amazon-nova-2-lite-1538", "published_at": "2026-08-13 05:41:50+00:00", "updated_at": "2026-08-13 05:45:38.504867+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "generative-ai", "computer-vision", "ai-products"], "entities": ["Amazon Nova 2 Lite", "Amazon Bedrock", "Amazon DynamoDB", "Amazon Textract", "Amazon Rekognition", "AWS Builder Card"], "alternates": {"html": "https://wpnews.pro/news/what-does-the-card-say-text-extraction-using-amazon-nova-2-lite", "markdown": "https://wpnews.pro/news/what-does-the-card-say-text-extraction-using-amazon-nova-2-lite.md", "text": "https://wpnews.pro/news/what-does-the-card-say-text-extraction-using-amazon-nova-2-lite.txt", "jsonld": "https://wpnews.pro/news/what-does-the-card-say-text-extraction-using-amazon-nova-2-lite.jsonld"}}