Klaus04 commited on
Commit
5932a69
·
1 Parent(s): 5d2f8e4

add application file

Browse files
Files changed (8) hide show
  1. .gitignore +1 -0
  2. Dockerfile +24 -0
  3. app.py +193 -0
  4. requirements.txt +18 -0
  5. static/ats image.png +0 -0
  6. static/script.js +153 -0
  7. static/styles.css +114 -0
  8. templates/index.html +53 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ config.env
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.11-slim
3
+
4
+ # Install Tesseract OCR
5
+ RUN apt-get update && \
6
+ apt-get install -y tesseract-ocr && \
7
+ rm -rf /var/lib/apt/lists/*
8
+
9
+ # Set the working directory
10
+ WORKDIR /app
11
+
12
+ # Copy the requirements file into the container
13
+ COPY requirements.txt .
14
+
15
+ # Install any needed packages specified in requirements.txt
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy the rest of the application code into the container
19
+ COPY . .
20
+
21
+ EXPOSE 8000
22
+
23
+ # Specify the command to run on container start
24
+ CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
app.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, render_template, jsonify, Response
2
+ import os
3
+ import fitz
4
+ from PIL import Image
5
+ import pytesseract
6
+ import shutil
7
+ import spacy
8
+ from sklearn.feature_extraction.text import TfidfVectorizer
9
+ from sklearn.metrics.pairwise import cosine_similarity
10
+ from langchain_groq import ChatGroq
11
+ from langchain_core.prompts import ChatPromptTemplate
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv(dotenv_path="config.env")
15
+ app = Flask(__name__)
16
+ app.config['RESUME_UPLOAD_FOLDER'] = 'uploads/resume/'
17
+ app.config['JD_UPLOAD_FOLDER'] = 'uploads/desc/'
18
+ app.config["RESUME_IMAGE_FOLDER"] = 'images/resumes/'
19
+ app.config['JD_IMAGE_FOLDER']= 'images/descriptions/'
20
+
21
+ os.makedirs(app.config['RESUME_UPLOAD_FOLDER'], exist_ok=True)
22
+ os.makedirs(app.config['JD_UPLOAD_FOLDER'], exist_ok=True)
23
+
24
+ nlp = spacy.load('en_core_web_sm')
25
+
26
+ llm = ChatGroq(model="mixtral-8x7b-32768",temperature=0)
27
+
28
+ def clear_upload_folders():
29
+ for file in [app.config['RESUME_UPLOAD_FOLDER'], app.config['JD_UPLOAD_FOLDER'],app.config["RESUME_IMAGE_FOLDER"], app.config['JD_IMAGE_FOLDER']]:
30
+ if os.path.exists(file):
31
+ shutil.rmtree(file)
32
+ os.makedirs(file)
33
+
34
+
35
+ def preprocess(text):
36
+ doc = nlp(text)
37
+ tokens = [token.lemma_ for token in doc if not token.is_stop and token.is_alpha]
38
+ return ' '.join(tokens)
39
+
40
+ def calculate_match_percentage(doc1, doc2):
41
+ doc1 = preprocess(doc1)
42
+ doc2 = preprocess(doc2)
43
+ vectorizer = TfidfVectorizer()
44
+ vectors = vectorizer.fit_transform([doc1,doc2])
45
+ print(vectors.shape)
46
+ print(vectors[0].shape)
47
+ print(vectors[1].shape)
48
+ cosine_sim = cosine_similarity(vectors[0],vectors[1])
49
+ return round(cosine_sim[0][0],6)
50
+
51
+ def relevant_text_res(raw_dict, fields):
52
+ rel_dict = {}
53
+ count = 1
54
+ for path,text in raw_dict.items():
55
+ file_name = os.path.basename(path).split('.')[0]
56
+ result=""
57
+ system = "You are a helpful assistant which takes in input a resume and retrieve the required fields from it. Take everything from the context. dont make things on your own."
58
+ human = "{text}"
59
+ prompt = ChatPromptTemplate.from_messages([("system", system), ("human", human)])
60
+ chain = prompt | llm
61
+ ans = chain.invoke({"text":f"Resume:{text}\nRequired fields are:{fields}"})
62
+ result+=f"Start of Resume {count}:\n"
63
+ result += ans.content
64
+ result+=f"End of Resume {count}:\n\n"
65
+ rel_dict[file_name]=(result)
66
+ count+=1
67
+ return rel_dict
68
+
69
+ def relevant_text_desc(raw_text, fields):
70
+ rel_text = ''
71
+ system = "You are a helpful assistant which takes in input a job description and retrieve the required fields from it. Take everything from the context. dont make things on your own."
72
+ human = "{text}"
73
+ prompt = ChatPromptTemplate.from_messages([("system", system), ("human", human)])
74
+ chain = prompt | llm
75
+ ans = chain.invoke({"text":f"Job description:{raw_text}\nRequired fields are:{fields}"})
76
+ rel_text+=ans.content
77
+ return rel_text
78
+
79
+ def matching(resume, job_desc):
80
+ system = "You are helpful assistant which matches a list of resumes and a job description given to you and tells which of the given resume matches the best to the job description and why (give details). Give only important details. Also give the match percentage for every resume.Take everything from the context. Dont make things on your own."
81
+ human = "{text}"
82
+ res_count = len(resume)
83
+ prompt = ChatPromptTemplate.from_messages([("system", system), ("human", human)])
84
+ chain = prompt | llm
85
+ ans = chain.invoke({"text":f"Job description:{job_desc}\nThere are total {res_count} resumes.\nResume list:{resume}"})
86
+ return ans.content
87
+
88
+ def pdf_to_images(pdf_path, type):
89
+ pdf_document = fitz.open(pdf_path)
90
+ if type == 'resume':
91
+ count = 1
92
+ dir = os.path.join(os.getcwd(),"images/resumes")
93
+ while True:
94
+ folder_name = f"resume{count}"
95
+ if not os.path.exists(os.path.join(dir,folder_name)):
96
+ os.mkdir(os.path.join(dir,folder_name))
97
+ break
98
+ else: count+=1
99
+ paths = []
100
+ for page_num in range(pdf_document.page_count):
101
+ page = pdf_document.load_page(page_num)
102
+ pix = page.get_pixmap()
103
+ image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
104
+
105
+ output_folder = os.path.join(dir,folder_name)
106
+ image_path = f"{output_folder}\page_{page_num + 1}.png"
107
+ image.save(image_path)
108
+ paths.append(f"{image_path}")
109
+ elif type == "desc":
110
+ count = 1
111
+ dir = os.path.join(os.getcwd(),"images/descriptions")
112
+ while True:
113
+ folder_name = f"desc{count}"
114
+ if not os.path.exists(os.path.join(dir,folder_name)):
115
+ os.mkdir(os.path.join(dir,folder_name))
116
+ break
117
+ else: count+=1
118
+ paths = []
119
+ for page_num in range(pdf_document.page_count):
120
+ page = pdf_document.load_page(page_num)
121
+ pix = page.get_pixmap()
122
+ image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
123
+
124
+ output_folder = os.path.join(dir,folder_name)
125
+ image_path = f"{output_folder}\page_{page_num + 1}.png"
126
+ image.save(image_path)
127
+ paths.append(f"{image_path}")
128
+
129
+ return paths
130
+
131
+ def extract_text_from_pdf(pdf_path, type):
132
+ paths = pdf_to_images(pdf_path,type)
133
+ full_text = ""
134
+ for path in paths :
135
+ image = Image.open(path)
136
+ image = image.convert("L")
137
+ full_text += pytesseract.image_to_string(image)
138
+ full_text+="\n"
139
+ return full_text
140
+
141
+ @app.route('/')
142
+ def index():
143
+ return render_template('index.html')
144
+
145
+ @app.route('/upload_resume', methods=['POST'])
146
+ def upload_resume():
147
+ if 'resume' in request.files:
148
+ resume_files = request.files.getlist('resume')
149
+ for resume in resume_files:
150
+ resume.save(os.path.join(app.config['RESUME_UPLOAD_FOLDER'], resume.filename))
151
+ return jsonify(message="Resumes uploaded successfully")
152
+
153
+ @app.route('/upload_jd', methods=['POST'])
154
+ def upload_jd():
155
+ if 'job_description' in request.files:
156
+ job_description = request.files['job_description']
157
+ job_description.save(os.path.join(app.config['JD_UPLOAD_FOLDER'], job_description.filename))
158
+ return jsonify(message="Job description uploaded successfully")
159
+
160
+ @app.route('/check_files', methods=['POST'])
161
+ def check_files():
162
+ resume_files = os.listdir(app.config['RESUME_UPLOAD_FOLDER'])
163
+ jd_files = os.listdir(app.config['JD_UPLOAD_FOLDER'])
164
+
165
+ if not resume_files:
166
+ return jsonify(message="No resumes uploaded. Please upload resumes before processing.")
167
+
168
+ if not jd_files:
169
+ return jsonify(message="No job description uploaded. Please upload a job description before processing.")
170
+ return jsonify(message="Files are uploaded. Processing will start now.")
171
+
172
+ @app.route('/process', methods=['POST'])
173
+ def process():
174
+ res_dict = {}
175
+ desc_text = ""
176
+ for file_path in os.listdir(app.config['RESUME_UPLOAD_FOLDER']):
177
+ res_dict[file_path]=(extract_text_from_pdf("uploads/resume/"+file_path,type="resume"))
178
+
179
+ desc_text+=extract_text_from_pdf("uploads/desc/"+(os.listdir(app.config['JD_UPLOAD_FOLDER'])[0]), type = "desc")
180
+ relevant_text_resume = relevant_text_res(res_dict, fields=["Skills", "Experience", "Project"])
181
+ relevant_desc = relevant_text_desc(desc_text,fields= ["Required Skills", "Required Qualifications"])
182
+ result = matching(relevant_text_resume, relevant_desc)
183
+ result+="\n\nCosine Similarity scores:\n"
184
+ count = 1
185
+ for file_path,res in res_dict.items():
186
+ percent =( calculate_match_percentage(res,desc_text))*100
187
+ result+=f"Resume {count}: {percent}%\n"
188
+ count+=1
189
+ clear_upload_folders()
190
+ return Response(result, content_type='text/plain')
191
+
192
+ if __name__ == '__main__':
193
+ app.run(debug=True)
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ langchain==0.2.11
2
+ langchain-community==0.2.10
3
+ langchain-core==0.2.27
4
+ langchain-groq==0.1.9
5
+ langchain-huggingface
6
+ PyMuPDF==1.24.9
7
+ PyMuPDFb==1.24.9
8
+ pyparsing==3.0.9
9
+ pypdf==4.3.1
10
+ pytesseract==0.3.10
11
+ scikit-learn==1.3.1
12
+ spacy==3.7.5
13
+ en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.7.1/en_core_web_sm-3.7.1-py3-none-any.whl#sha256=86cc141f63942d4b2c5fcee06630fd6f904788d2f0ab005cce45aadb8fb73889
14
+ Flask==2.3.2
15
+ groq==0.9.0
16
+ Pillow==9.5.0
17
+ gunicorn
18
+ python-dotenv
static/ats image.png ADDED
static/script.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const resumeInput = document.getElementById('resume-input');
3
+ const resumeList = document.getElementById('resume-list');
4
+ const jdInput = document.getElementById('jd-input');
5
+ const jdList = document.getElementById('jd-list');
6
+ function updateFileList(input, list) {
7
+ list.innerHTML = '';
8
+ Array.from(input.files).forEach((file, index) => {
9
+ const listItem = document.createElement('li');
10
+ const fileName = document.createElement('span');
11
+ fileName.textContent = file.name;
12
+ const removeButton = document.createElement('button');
13
+ removeButton.textContent = '×';
14
+ removeButton.addEventListener('click', () => {
15
+ const dt = new DataTransfer();
16
+ const files = Array.from(input.files).filter((_, i) => i !== index);
17
+ files.forEach(file => dt.items.add(file));
18
+ input.files = dt.files;
19
+ updateFileList(input, list);
20
+ });
21
+ listItem.appendChild(fileName);
22
+ listItem.appendChild(removeButton);
23
+ list.appendChild(listItem);
24
+ });
25
+ }
26
+
27
+ resumeInput.addEventListener('change', () => updateFileList(resumeInput, resumeList));
28
+ jdInput.addEventListener('change', () => updateFileList(jdInput, jdList));
29
+
30
+ document.getElementById('resume-form').addEventListener('submit', function(event) {
31
+ event.preventDefault();
32
+ const formData = new FormData(this);
33
+ fetch('/upload_resume', {
34
+ method: 'POST',
35
+ body: formData
36
+ })
37
+ .then(response => response.json())
38
+ .then(data => {
39
+ var modal = document.getElementById("resume-modal");
40
+ var modalMessage = document.getElementById("resume-modalMessage");
41
+ modalMessage.textContent = data.message;
42
+ modal.style.display = "block";
43
+ modal.querySelector(".close").onclick = function() {
44
+ modal.style.display = "none";
45
+ };
46
+ window.onclick = function(event) {
47
+ if (event.target == modal) {
48
+ modal.style.display = "none";
49
+ }
50
+ };
51
+ })
52
+ .catch(error => {
53
+ console.error('Error:', error);
54
+ });
55
+ });
56
+ document.getElementById('jd-form').addEventListener('submit', function(event) {
57
+ event.preventDefault();
58
+ const formData = new FormData(this);
59
+ fetch('/upload_jd', {
60
+ method: 'POST',
61
+ body: formData
62
+ })
63
+ .then(response => response.json())
64
+ .then(data => {
65
+ var modal = document.getElementById("jd-modal");
66
+ var modalMessage = document.getElementById("jd-modalMessage");
67
+ modalMessage.textContent = data.message;
68
+ modal.style.display = "block";
69
+ modal.querySelector(".close").onclick = function() {
70
+ modal.style.display = "none";
71
+ };
72
+ window.onclick = function(event) {
73
+ if (event.target == modal) {
74
+ modal.style.display = "none";
75
+ }
76
+ };
77
+ })
78
+ .catch(error => {
79
+ console.error('Error:', error);
80
+ });
81
+ });
82
+ document.getElementById('process-button').addEventListener('click', function() {
83
+ const output = document.getElementById('output');
84
+ output.textContent = '';
85
+ const startTime = Date.now();
86
+ let waitingTime = 0;
87
+ output.textContent = `Waiting time: ${waitingTime} seconds`;
88
+ const intervalId = setInterval(() => {
89
+ waitingTime = ((Date.now() - startTime) / 1000).toFixed(0);
90
+ output.textContent = `Waiting time: ${waitingTime} seconds`;
91
+ }, 1000);
92
+ fetch('/check_files',{
93
+ method: 'POST'
94
+ })
95
+ .then(response => response.json())
96
+ .then(data => {
97
+ if (data.message === "Files are uploaded. Processing will start now.") {
98
+ var modal = document.getElementById("process-modal");
99
+ var modalMessage = document.getElementById("process-modalMessage");
100
+ modalMessage.textContent = "Processing, please wait...";
101
+ modal.style.display = "block";
102
+ modal.querySelector(".close").onclick = function() {
103
+ modal.style.display = "none";
104
+ };
105
+ window.onclick = function(event) {
106
+ if (event.target == modal) {
107
+ modal.style.display = "none";
108
+ }
109
+ };
110
+ fetch('/process', {
111
+ method: 'POST'
112
+ })
113
+ .then(response => response.text())
114
+ .then(text => {
115
+ clearInterval(intervalId);
116
+ output.textContent = text;
117
+ const finalWaitingTime = ((Date.now() - startTime) / 1000).toFixed(2);
118
+ output.textContent += `\n\nTotal waiting time: ${finalWaitingTime} seconds`;
119
+
120
+ })
121
+ .catch(error => {
122
+ console.error('Error:', error);
123
+ modalMessage.textContent = "An error occurred during processing. Please try again.";
124
+ modal.querySelector(".close").onclick = function() {
125
+ modal.style.display = "none";
126
+ };
127
+ window.onclick = function(event) {
128
+ if (event.target == modal) {
129
+ modal.style.display = "none";
130
+ }
131
+ };
132
+ });
133
+ } else{
134
+ var modal = document.getElementById("process-modal");
135
+ var modalMessage = document.getElementById("process-modalMessage");
136
+ modalMessage.textContent = data.message;
137
+ modal.style.display = "block";
138
+ modal.querySelector(".close").onclick = function() {
139
+ modal.style.display = "none";
140
+ };
141
+ window.onclick = function(event) {
142
+ if (event.target == modal) {
143
+ modal.style.display = "none";
144
+ }
145
+ };
146
+ }
147
+ })
148
+ .catch(error=>{
149
+ console.error('Error:', error);
150
+ alert("An error occurred while checking files.");
151
+ });
152
+ });
153
+ });
static/styles.css ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: Arial, Helvetica, sans-serif;
3
+ margin: 0;
4
+ padding: 0;
5
+ display: flex;
6
+ flex-direction: column;
7
+ justify-content: center;
8
+ align-items: center;
9
+ height: 100vh;
10
+ background-color: #f5f5f5;
11
+ background-image: url("ats image.png");
12
+ background: linear-gradient(
13
+ rgba(163, 148, 148, 0.3),
14
+ rgba(0, 0, 0, 0.6)
15
+ ), url('ats image.png');
16
+
17
+ }
18
+ .heading{
19
+ font-size: 200%;
20
+ text-align: center;
21
+
22
+ }
23
+
24
+ .container {
25
+ display: flex;
26
+ width: 80%;
27
+ height: 80%;
28
+ box-shadow: 0 0 10px rgba(254, 253, 253, 0.959);
29
+ background-color: rgb(255, 255, 255,0.1);
30
+ }
31
+
32
+ .left-section, .right-section {
33
+ width: 50%;
34
+ padding: 20px;
35
+ box-sizing: border-box;
36
+ }
37
+
38
+ .left-section {
39
+ border-right: 1px solid #ddd;
40
+ }
41
+
42
+ ul {
43
+ list-style: none;
44
+ padding: 0;
45
+ }
46
+
47
+ ul li {
48
+ display: flex;
49
+ justify-content: space-between;
50
+ align-items: center;
51
+ padding: 5px 0;
52
+ }
53
+
54
+ ul li span {
55
+ flex-grow: 1;
56
+ }
57
+
58
+ ul li button {
59
+ background-color: #ff0000;
60
+ color: #fff;
61
+ border: none;
62
+ padding: 5px;
63
+ cursor: pointer;
64
+ }
65
+ textarea {
66
+ width: 100%;
67
+ height: calc(100% - 50px);
68
+ padding: 10px;
69
+ box-sizing: border-box;
70
+ }
71
+
72
+ #process-button{
73
+ margin-top: 100px;
74
+ font-size: 125%;
75
+ padding: 10px;
76
+ }
77
+
78
+ .modal {
79
+ display: none;
80
+ position: fixed;
81
+ z-index: 1;
82
+ left: 0;
83
+ top: 0;
84
+ width: 100%;
85
+ height: 100%;
86
+ overflow: auto;
87
+ background-color: rgb(0,0,0);
88
+ background-color: rgba(0,0,0,0.4);
89
+ padding-top: 60px;
90
+ }
91
+
92
+ /* Modal Content */
93
+ .modal-content {
94
+ background-color: #fefefe;
95
+ margin: 5% auto;
96
+ padding: 20px;
97
+ border: 1px solid #888;
98
+ width: 50%;
99
+ }
100
+
101
+ /* The Close Button */
102
+ .close {
103
+ color: #aaa;
104
+ float: right;
105
+ font-size: 28px;
106
+ font-weight: bold;
107
+ }
108
+
109
+ .close:hover,
110
+ .close:focus {
111
+ color: black;
112
+ text-decoration: none;
113
+ cursor: pointer;
114
+ }
templates/index.html ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Resume Matcher</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
8
+ </head>
9
+ <body>
10
+ <h2 class="heading"> Applicant Tracking System</h2>
11
+ <div class="container">
12
+ <div class="left-section">
13
+ <h2>Upload Resumes (Max 3)</h2>
14
+ <form id="resume-form" enctype="multipart/form-data">
15
+ <input type="file" id="resume-input" name="resume" multiple accept="application/pdf">
16
+ <ul id="resume-list"></ul>
17
+ <button type="submit">Upload Resumes</button>
18
+ <div id="resume-modal" class="modal">
19
+ <div class="modal-content">
20
+ <span class="close">&times;</span>
21
+ <p id="resume-modalMessage"></p>
22
+ </div>
23
+ </div>
24
+ </form>
25
+ <h2>Upload Job Description</h2>
26
+ <form id="jd-form" enctype="multipart/form-data">
27
+ <input type="file" id="jd-input" name="job_description" accept="application/pdf">
28
+ <ul id="jd-list"></ul>
29
+ <button type="submit">Upload Job Description</button>
30
+ <div id="jd-modal" class="modal">
31
+ <div class="modal-content">
32
+ <span class="close">&times;</span>
33
+ <p id="jd-modalMessage"></p>
34
+ </div>
35
+ </div>
36
+ </form>
37
+ <button id="process-button">Process</button>
38
+ <div id="process-modal" class="modal">
39
+ <div class="modal-content">
40
+ <span class="close">&times;</span>
41
+ <p id="process-modalMessage"></p>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ <div class="right-section">
46
+ <h2>Output</h2>
47
+ <textarea id="output" readonly></textarea>
48
+ </div>
49
+ </div>
50
+
51
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
52
+ </body>
53
+ </html>