11require 'aws-sdk-bedrockruntime'
22require 'json'
33require 'base64'
4+ require "mini_magick"
5+ require "tempfile"
46
57module BedrockDocScreener
68 MODEL_ID = 'us.anthropic.claude-haiku-4-5-20251001-v1:0' . freeze
79 REGION = 'us-east-1' . freeze
810 SUPPORTED_MEDIA_TYPES = %w[
911 image/png
1012 image/jpeg
13+ application/pdf
1114 ] . freeze
12- # since the payload is `type: "image"` it won't work for application/pdf
13- # can convert the pdf to image or use another bedrock flow for docs
1415
15- # Update the prompt version if you are updating the prompt
1616 PROMPT_VERSION = "v1" . freeze
17+
1718 def self . prompt_for ( document_type :)
1819 <<~PROMPT
19- You are validating an uploaded client document.
20-
21- Document type: #{ document_type }
20+ Clients are uploading documents and you need to verify the validity of the document using these rules:
21+ 1) If the photo is a poor quality image (poorly lit, blurry, cropped & missing information, pixelated screen etc.)
22+ so much so that it renders the document illegible,
23+ then set reason="unreadable" and set verdict="fail".
24+ 2) If the document does not fit any of the doc types in the available-doc-types list,
25+ then set reason="no_doc_type_match" and set verdict="fail".
26+ 3) If it does not appear to match the stated doc type (in this case #{ document_type } )
27+ but does match another type in the available-doc-types list,
28+ then set reason="wrong_document_type", verdict="fail"
29+ and include the doc-types that might be match in the explanation field by their label name.
30+ 4) If the document is expired, then set reason="expired" and verdict="fail".
31+ 5) If the document is fake (for example if it is labeled as a 'sample'),
32+ then set reason="fake" and verdict="fail".
33+ 6) If there is another reason that the document is not valid,
34+ then set reason="other" and verdict="fail".
35+ 7) If the document seems to be a valid document, readable and the selected document type matches,
36+ then set reason="" and verdict="pass".
37+ 8) "confidence" must be between 0.0 and 1.0.
38+ 9) Do not include any keys other than "verdict", "reason", "explanation" and "confidence"
39+ 10) verdict should only be "pass" or "fail"
40+
41+ available-doc-types: #{ available_doc_types }
42+
43+ Selected document type: #{ document_type }
2244
2345 Return ONLY valid JSON with this exact schema:
24-
2546 {
26- "verdict": "pass" | "fail" | "needs_review",
27- "reasons": [string, brief explanation],
47+ "verdict": "pass" | "fail",
48+ "reason": "unreadable" | "no_doc_type_match" | "wrong_document_type" | expired" | "fake" | "other",
49+ "explanation": [Brief 1-2 sentence explanation of reason. Explain why valid/invalid.],
2850 "confidence": number between 0.0-1.0,
2951 }
30-
31- Rules:
32- - If the document is unreadable, set verdict="needs_review" and include reason "unreadable".
33- - If it does not appear to match the stated document type, verdict="fail" and include reason "wrong_document_type".
34- - If it appears valid and readable, verdict="pass".
35- - "confidence" must be between 0.0 and 1.0.
36- - Do not include any keys other than verdict, reasons and confidence
3752 PROMPT
3853 end
3954
55+ def self . available_doc_types
56+ # matches @doc_type_options in document controller
57+ available_doc_types = [ DocumentTypes ::Identity , DocumentTypes ::SsnItin ] + ( DocumentTypes ::ALL_TYPES - DocumentTypes ::IDENTITY_TYPES - DocumentTypes ::SECONDARY_IDENTITY_TYPES )
58+ available_doc_types . map { |d | { key : d . key , label : d . label } }
59+ end
60+
4061 def self . screen_document! ( document :)
4162 raise "Document has no upload attached" unless document . upload . attached?
4263
4364 media_type = document . upload . content_type
4465 raise "Unsupported media type: #{ media_type } " unless SUPPORTED_MEDIA_TYPES . include? ( media_type )
4566
46- base64_data = Base64 . strict_encode64 ( document . upload . download )
67+ input = if media_type == "application/pdf"
68+ pdf_to_png_base64 ( document . upload )
69+ else
70+ [ {
71+ media_type : media_type ,
72+ base64_data : Base64 . strict_encode64 ( document . upload . download )
73+ } ]
74+ end
4775
4876 body_hash = construct_bedrock_payload (
49- base64_data : base64_data ,
50- media_type : media_type ,
77+ images : input ,
5178 user_prompt : prompt_for ( document_type : document . document_type )
5279 )
5380
@@ -61,15 +88,17 @@ def self.screen_document!(document:)
6188 [ result_json , raw_response_json ]
6289 end
6390
64- def self . construct_bedrock_payload ( base64_data : , media_type :, user_prompt :)
91+ def self . construct_bedrock_payload ( images :, user_prompt :)
6592 {
66- anthropic_version : ' bedrock-2023-05-31' ,
93+ anthropic_version : " bedrock-2023-05-31" ,
6794 max_tokens : 250 ,
6895 messages : [ {
69- role : ' user' ,
96+ role : " user" ,
7097 content : [
71- { type : 'image' , source : { type : 'base64' , media_type : media_type , data : base64_data } } ,
72- { type : 'text' , text : user_prompt }
98+ *images . map do |img |
99+ { type : "image" , source : { type : "base64" , media_type : img [ :media_type ] , data : img [ :base64_data ] } }
100+ end ,
101+ { type : "text" , text : user_prompt }
73102 ]
74103 } ]
75104 }
@@ -89,17 +118,58 @@ def self.extract_text_from_response(response)
89118 Array ( response [ 'content' ] )
90119 . select { |content | content [ 'type' ] == 'text' }
91120 . map { |content | content [ 'text' ] }
92- . join ( '\n' )
121+ . join ( " \n " )
93122 . strip
94123 end
95124
96125 def self . parse_strict_json! ( text )
97- cleaned = text . to_s . strip
126+ s = text . to_s
98127
99- cleaned = cleaned . sub ( /\A ```(?:json)?\s */i , "" ) . sub ( /\s *```\z / , "" ) . strip
128+ blocks = s . scan ( /```(?:json)?\s *(\{ .*?\} )\s *```/m )
129+ if blocks . any?
130+ json_str = blocks . last . first
131+ return JSON . parse ( json_str )
132+ end
100133
134+ cleaned = s . strip
135+ cleaned = cleaned . sub ( /\A ```(?:json)?\s */i , "" ) . sub ( /\s *```\z / , "" ) . strip
101136 JSON . parse ( cleaned )
102137 rescue JSON ::ParserError => e
103- raise "Model did not return valid JSON. Error=#{ e . message } . Output=#{ text . inspect } "
138+ raise "Bedrock did not return valid JSON. \n Error: #{ e . message } \n Output: #{ text . inspect } "
139+ end
140+
141+
142+ def self . pdf_to_png_base64 ( upload )
143+ images = [ ]
144+
145+ Tempfile . create ( [ "upload" , ".pdf" ] ) do |pdf |
146+ pdf . binmode
147+ pdf . write ( upload . download )
148+ pdf . flush
149+
150+ MiniMagick ::Image . open ( pdf . path ) . pages . each_with_index do |page , index |
151+ Tempfile . create ( [ "pdf_page_#{ index } " , ".png" ] ) do |png |
152+ MiniMagick ::Tool ::Convert . new do |convert |
153+ convert . density ( 200 )
154+ convert . quality ( 90 )
155+ convert << "#{ pdf . path } [#{ index } ]"
156+ convert << png . path
157+ end
158+
159+ data = File . binread ( png . path )
160+
161+ images << {
162+ media_type : "image/png" ,
163+ base64_data : Base64 . strict_encode64 ( data )
164+ }
165+ end
166+ end
167+ end
168+
169+ raise "pdf produced no pages" if images . empty?
170+ images
171+ rescue MiniMagick ::Error , MiniMagick ::Invalid => e
172+ raise "failed to convert pdf pages to images (perhaps minimagick or ghostscript issue). #{ e . class } : #{ e . message } "
104173 end
174+
105175end
0 commit comments