Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,424 +1,30 @@
|
|
1 |
-
|
2 |
-
from
|
3 |
-
from openai import OpenAI
|
4 |
-
from io import BytesIO
|
5 |
-
import PyPDF2
|
6 |
-
from pdfminer.high_level import extract_text
|
7 |
-
from docx import Document
|
8 |
-
import os
|
9 |
-
import re
|
10 |
-
import uuid
|
11 |
-
from typing import Tuple
|
12 |
-
import pdfplumber
|
13 |
|
14 |
-
|
15 |
-
|
16 |
-
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
17 |
-
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB限制
|
18 |
|
19 |
-
#
|
20 |
-
|
21 |
-
|
22 |
-
api_key="sk-bc73223a36d240758af12bf4a197a3be"
|
23 |
-
)
|
24 |
|
25 |
-
|
26 |
-
|
27 |
-
filename = re.sub(r'[^\w\u4e00-\u9fff\-\.]', '', filename.strip())
|
28 |
-
name, ext = os.path.splitext(filename)
|
29 |
-
random_str = uuid.uuid4().hex[:6]
|
30 |
-
return f"{name}_{random_str}{ext}"
|
31 |
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
# 处理中文重复(包括全角标点)
|
37 |
-
text = re.sub(r'([\u4e00-\u9fff])\1+', r'\1', text)
|
38 |
-
# 处理常见标点符号重复(包括全角和半角)
|
39 |
-
text = re.sub(r'([,。、;:"“”‘’\'\"\(\)\[\]\{\}\<\>])\1+', r'\1', text)
|
40 |
-
# 处理特殊重复模式(如"(("变成"(")
|
41 |
-
text = re.sub(r'(()\1+', r'\1', text)
|
42 |
-
text = re.sub(r'())\1+', r'\1', text)
|
43 |
-
return text
|
44 |
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
file_stream.seek(0)
|
49 |
-
with pdfplumber.open(file_stream) as pdf:
|
50 |
-
text = "\n".join([
|
51 |
-
page.extract_text(x_tolerance=2, y_tolerance=2)
|
52 |
-
for page in pdf.pages
|
53 |
-
if page.extract_text()
|
54 |
-
])
|
55 |
-
|
56 |
-
print("========= pdfplumber 原始提取内容 =========")
|
57 |
-
print(text)
|
58 |
-
|
59 |
-
text = process_duplicates(text)
|
60 |
-
|
61 |
-
print("========= 去重处理后内容 =========")
|
62 |
-
print(text)
|
63 |
-
return text.strip()
|
64 |
-
|
65 |
-
except Exception as e:
|
66 |
-
print(f"pdfplumber提取失败,尝试PyPDF2: {str(e)}")
|
67 |
-
# 备用方案:PyPDF2+去重
|
68 |
-
file_stream.seek(0)
|
69 |
-
reader = PyPDF2.PdfReader(file_stream)
|
70 |
-
text = '\n'.join({
|
71 |
-
line.strip()
|
72 |
-
for page in reader.pages
|
73 |
-
for line in (page.extract_text() or "").split('\n')
|
74 |
-
if line.strip()
|
75 |
-
})
|
76 |
-
|
77 |
-
print("========= PyPDF2 原始提取内容 =========")
|
78 |
-
print(text)
|
79 |
-
|
80 |
-
text = process_duplicates(text)
|
81 |
-
|
82 |
-
print("========= 去重处理后内容 =========")
|
83 |
-
print(text)
|
84 |
-
return text
|
85 |
-
|
86 |
-
except Exception as e:
|
87 |
-
raise ValueError(f"PDF解析失败: {str(e)}")
|
88 |
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
doc = Document(file_stream)
|
94 |
-
return "\n".join([para.text for para in doc.paragraphs if para.text])
|
95 |
-
except Exception as e:
|
96 |
-
raise ValueError(f"Word解析失败: {str(e)}")
|
97 |
|
98 |
-
|
99 |
-
""
|
100 |
-
|
101 |
-
return extract_text_from_pdf(file_stream)
|
102 |
-
elif filename.lower().endswith(('.doc', '.docx')):
|
103 |
-
return extract_text_from_word(file_stream)
|
104 |
-
elif filename.lower().endswith('.txt'):
|
105 |
-
file_stream.seek(0)
|
106 |
-
return file_stream.read().decode('utf-8', errors='ignore')
|
107 |
-
else:
|
108 |
-
raise ValueError("不支持的文件格式")
|
109 |
-
|
110 |
-
def analyze_resume_with_ai(text: str, job_position: str = None) -> Tuple[list, int]:
|
111 |
-
"""使用OpenAI分析简历文本并评分"""
|
112 |
-
MAX_TOKENS = 120000
|
113 |
-
if len(text) > MAX_TOKENS * 3.5:
|
114 |
-
return ["简历内容过长,请简化内容"], 0
|
115 |
-
|
116 |
-
# 根据岗位生成针对性提示
|
117 |
-
job_specific_prompt = ""
|
118 |
-
if job_position:
|
119 |
-
job_specific_prompt = f"""
|
120 |
-
[岗位针对性分析]
|
121 |
-
目标岗位: {job_position}
|
122 |
-
请特别关注以下与目标岗位相关的评估维度:
|
123 |
-
1. 专业技能匹配度: 检查简历中是否包含该岗位的核心技能关键词
|
124 |
-
2. 项目经验相关性: 评估项目经验与目标岗位的匹配程度
|
125 |
-
3. 行业术语使用: 检查是否使用了该岗位领域的专业术语
|
126 |
-
4. 成就量化标准: 根据该岗位特点评估成就描述的量化程度
|
127 |
-
|
128 |
-
"""
|
129 |
-
prompt = f"""请严格按照以下四部分分析简历,严格遵循格式:
|
130 |
-
|
131 |
-
{job_specific_prompt if job_specific_prompt else ""}
|
132 |
-
[总扣分]
|
133 |
-
总扣分: XX分 # 必须单独一行明确写出总扣分值
|
134 |
-
|
135 |
-
[扣分项]
|
136 |
-
请列出简历的所有扣分项,每一项必须明确指出扣分项在简历中的位置,扣分数量,并给出具体改进建议,将扣分项和建议放在【缺点】中输出给用户。
|
137 |
-
请严格遵循以下评分标准中的扣分规则,最后在第一行,计算总扣分量,格式为"总扣分: XX分"。
|
138 |
-
|
139 |
-
[整体总结]
|
140 |
-
用一段话来整体概括这篇简历的优缺点,特别关注与目标岗位的匹配度。
|
141 |
-
|
142 |
-
[优点]
|
143 |
-
• 优点1 (特别标注与目标岗位相关的优势)
|
144 |
-
• 优点2
|
145 |
-
|
146 |
-
[缺点]
|
147 |
-
• 具体位置(简历第几行或哪个部分): 具体问题 (具体改进建议) (-X分)
|
148 |
-
• 具体位置(简历第几行或哪个部分): 具体问题 (具体改进建议) (-X分)
|
149 |
-
确保在[缺点]部分之后不输出任何其他内容
|
150 |
-
|
151 |
-
评分标准:
|
152 |
-
高质量简历评分标准(基于STAR法则和岗位匹配度)
|
153 |
-
一、基础信息完整性(满分15分)
|
154 |
-
必备信息要求:
|
155 |
-
姓名
|
156 |
-
联系方式(电话、邮箱)
|
157 |
-
住址信息(至少提供省份或城市)
|
158 |
-
评分细则:
|
159 |
-
每项必备信息均完整且正确:得满分15分。
|
160 |
-
缺失任一项:扣5分;如缺失两项及以上,累计扣分,但最低分为0分。
|
161 |
-
|
162 |
-
二、内容结构与逻辑性(满分25分)
|
163 |
-
结构要求:
|
164 |
-
简历需清晰划分区域,如个人信息、教育经历、工作经历、技能、项目经验等。
|
165 |
-
每一区块内容需符合逻辑,信息层次分明。
|
166 |
-
评分细则:
|
167 |
-
每个必备区域(至少5个区域)均明确标识并合理排序:每个区域得5分,区域缺失或模糊者扣5分。
|
168 |
-
在每个区域内,要求描述具备逻辑性和条理性,出现明显逻辑混乱(如叙述前后矛盾或顺序混乱)者,每处扣2分,累计扣分不超过该区域分值。
|
169 |
-
|
170 |
-
三、专业技能及关键词匹配(满分30分)
|
171 |
-
匹配要求:
|
172 |
-
简历中必须明确列出与目标职位直接相关的核心技能或关键词(建议不少于3项,最多5项计分)。
|
173 |
-
评分细则:
|
174 |
-
每列出一项与目标职位高度匹配的技能或关键词,得6分(最多计5项得分)。
|
175 |
-
如未列出任何相关技能或关键词,直接扣30分。
|
176 |
-
若关键词存在但与目标职位匹配度较低或描述不清晰,依据实际情况酌情扣分(每项扣分范围为2-4分)。
|
177 |
-
【新增】岗位相关关键词匹配度额外评分:
|
178 |
-
• 完全匹配目标岗位核心技能:每项+2分(最高+10分)
|
179 |
-
• 部分匹配目标岗位次要技能:每项+1分(最高+5分)
|
180 |
-
|
181 |
-
四、工作成就与项目描述(满分20分,必须遵循STAR法则)
|
182 |
-
要求说明:
|
183 |
-
每段工作经历或项目描述必须完整包含:
|
184 |
-
Situation(情境): 说明工作/项目背景与挑战。
|
185 |
-
Task(任务): 说明你在该情境下需要完成的任务。
|
186 |
-
Action(行动): 描述为解决问题所采取的具体措施。
|
187 |
-
Result(结果): 列出取得的成果和影响(最好附量化指标)。
|
188 |
-
评分细则:
|
189 |
-
每完整描述一项工作或项目经历且具备STAR所有要素:得5分,最多计4项得分。
|
190 |
-
若工作或项目描述存在缺失或不清晰(例如缺少关键STAR元素),则每项扣2-5分(依据缺失程度和信息模糊程度)。
|
191 |
-
如果简历完全没有相关描述,直接扣20分。
|
192 |
-
【新增】岗位相关项目经验额外评分:
|
193 |
-
• 高度相关项目:每项+3分(最高+9分)
|
194 |
-
• 部分相关项目:每项+1分(最高+3分)
|
195 |
-
|
196 |
-
五、语言表达及排版质量(满分10分)
|
197 |
-
表达与排版要求:
|
198 |
-
整体语言表达准确、专业,无明显错别字或语法错误。
|
199 |
-
排版整洁、格式统一,避免混乱或信息堆砌。
|
200 |
-
评分细则:
|
201 |
-
排版格式符合要求,得5分;若出现明显格式错误或杂乱,每项错误扣1至5分,累计扣分最高5分。
|
202 |
-
语言表达无错别字或语法错误,得5分;每出现一处错别字或语法错误扣1分(最多扣5分)。
|
203 |
-
|
204 |
-
简历内容:
|
205 |
-
{text[:30000]}{'...' if len(text) > 30000 else ''}"""
|
206 |
-
|
207 |
-
try:
|
208 |
-
response = client.chat.completions.create(
|
209 |
-
model="deepseek-chat",
|
210 |
-
messages=[
|
211 |
-
{"role": "system", "content": "你是一位严格的简历评估专家。你必须严格按照评分标准进行评分和扣分,并明确指出每个缺点在简历中的具体位置。总分为100分,最终分数 = 100 - 总扣分。"},
|
212 |
-
{"role": "user", "content": prompt}
|
213 |
-
],
|
214 |
-
temperature=1,
|
215 |
-
stream=False
|
216 |
-
)
|
217 |
-
|
218 |
-
content = response.choices[0].message.content
|
219 |
-
|
220 |
-
# 输出原始 AI 响应以便调试
|
221 |
-
print("======== RAW AI RESPONSE ========")
|
222 |
-
print(content)
|
223 |
-
print("=================================")
|
224 |
-
|
225 |
-
# 解析响应内容
|
226 |
-
lines = [line.strip() for line in content.split('\n') if line.strip()]
|
227 |
-
suggestions = []
|
228 |
-
deduction_points = 0
|
229 |
-
current_section = None
|
230 |
-
|
231 |
-
# 首先查找显式的总扣分声明
|
232 |
-
total_deduction_match = None
|
233 |
-
for line in lines:
|
234 |
-
total_deduction_match = re.search(r'总扣分[::]\s*(\d+)分', line)
|
235 |
-
if total_deduction_match:
|
236 |
-
deduction_points = int(total_deduction_match.group(1))
|
237 |
-
break
|
238 |
-
|
239 |
-
# 如果没有找到显式总扣分,则尝试从缺点部分累加
|
240 |
-
if total_deduction_match is None:
|
241 |
-
in_cons_section = False
|
242 |
-
for line in lines:
|
243 |
-
if re.match(r'^\[?缺点\]?', line, re.IGNORECASE):
|
244 |
-
in_cons_section = True
|
245 |
-
continue
|
246 |
-
if in_cons_section:
|
247 |
-
deduction_match = re.search(r'\(-(\d+)分\)', line)
|
248 |
-
if deduction_match:
|
249 |
-
deduction_points += int(deduction_match.group(1))
|
250 |
-
|
251 |
-
# 确保扣分值在合理范围内
|
252 |
-
deduction_points = max(0, min(100, deduction_points))
|
253 |
-
|
254 |
-
# 计算最终分数
|
255 |
-
score = max(0, min(100, 100 - deduction_points))
|
256 |
-
current_section = None
|
257 |
-
|
258 |
-
# 更严格的章节检测
|
259 |
-
for line in lines:
|
260 |
-
# 检测章节标题
|
261 |
-
if re.match(r'^\[?整体总结\]?', line, re.IGNORECASE):
|
262 |
-
current_section = "summary"
|
263 |
-
suggestions.append(line)
|
264 |
-
continue
|
265 |
-
elif re.match(r'^\[?优点\]?', line, re.IGNORECASE):
|
266 |
-
current_section = "pros"
|
267 |
-
suggestions.append(line)
|
268 |
-
continue
|
269 |
-
elif re.match(r'^\[?缺点\]?', line, re.IGNORECASE):
|
270 |
-
current_section = "cons"
|
271 |
-
suggestions.append(line)
|
272 |
-
continue
|
273 |
-
elif re.match(r'^\[?扣分项\]?', line, re.IGNORECASE):
|
274 |
-
current_section = "deduction"
|
275 |
-
continue
|
276 |
-
|
277 |
-
# 只保留当前章节的内容
|
278 |
-
if current_section in ["summary", "pros", "cons"]:
|
279 |
-
suggestions.append(line)
|
280 |
-
|
281 |
-
return suggestions, score
|
282 |
-
|
283 |
-
except Exception as e:
|
284 |
-
print(f"AI分析错误: {str(e)}")
|
285 |
-
return [f"AI分析时发生错误: {str(e)}"], 0
|
286 |
-
|
287 |
-
@app.route('/')
|
288 |
-
def index():
|
289 |
-
return render_template('index.html')
|
290 |
-
|
291 |
-
@app.route('/upload', methods=['POST'])
|
292 |
-
def upload_file():
|
293 |
-
if 'resume' not in request.files:
|
294 |
-
return jsonify({'error': '请选择文件上传'}), 400
|
295 |
-
|
296 |
-
file = request.files['resume']
|
297 |
-
if file.filename == '':
|
298 |
-
return jsonify({'error': '未选择文件'}), 400
|
299 |
-
|
300 |
-
try:
|
301 |
-
# 从form获取job_position
|
302 |
-
job_position = request.form.get('job_position')
|
303 |
-
if not job_position:
|
304 |
-
return jsonify({'error': '请选择目标岗位'}), 400
|
305 |
-
|
306 |
-
filename = safe_filename(file.filename)
|
307 |
-
file_stream = BytesIO(file.read())
|
308 |
-
|
309 |
-
text = extract_text_from_file(file_stream, file.filename)
|
310 |
-
if not text.strip():
|
311 |
-
return jsonify({'error': '文件内容为空或无法解析'}), 400
|
312 |
-
|
313 |
-
suggestions, score = analyze_resume_with_ai(text, job_position)
|
314 |
-
|
315 |
-
return jsonify({
|
316 |
-
'message': '分析成功',
|
317 |
-
'suggestions': suggestions,
|
318 |
-
'score': score,
|
319 |
-
'filename': filename,
|
320 |
-
'job_position': job_position
|
321 |
-
})
|
322 |
-
except ValueError as e:
|
323 |
-
return jsonify({'error': str(e)}), 400
|
324 |
-
except Exception as e:
|
325 |
-
print(f"上传处理错误: {str(e)}")
|
326 |
-
return jsonify({'error': f'处理失败: {str(e)}'}), 500
|
327 |
-
|
328 |
-
@app.route('/generate_cover_letter', methods=['POST'])
|
329 |
-
def generate_cover_letter():
|
330 |
-
try:
|
331 |
-
data = request.json
|
332 |
-
required_fields = ['company_name', 'position', 'resume_text', 'job_description']
|
333 |
-
|
334 |
-
# 验证必填字段
|
335 |
-
for field in required_fields:
|
336 |
-
if not data.get(field):
|
337 |
-
return jsonify({'error': f'缺少必填字段: {field}'}), 400
|
338 |
-
|
339 |
-
# 构建AI提示词 - 优化版
|
340 |
-
prompt = f"""你是一位专业的职业顾问,需要根据申请人提供的简历内容撰写求职信。请严格遵守以下规则:
|
341 |
-
|
342 |
-
1. 信息真实性原则:
|
343 |
-
- 只能使用简��中明确列出的教育背景、工作经历、项目经验和技能
|
344 |
-
- 绝对禁止添加、编造或推断简历中没有的信息
|
345 |
-
- 如果某项要求(如特定技能)在简历中未体现,不要在求职信中提及
|
346 |
-
|
347 |
-
2. 内容要求:
|
348 |
-
[必须包含的格式要素]
|
349 |
-
- 正式商务信函格式(日期、称呼、正文、结尾敬语)
|
350 |
-
- 称呼使用"尊敬的招聘经理"(如不知道具体姓名)
|
351 |
-
- 结尾要有明确的行动号召(如期待面试机会)
|
352 |
-
|
353 |
-
[内容结构]
|
354 |
-
第一段:明确申请职位和动机(30-50字)
|
355 |
-
第二段:从简历中提取与职位最相关的2-3个核心优势(80-120字)
|
356 |
-
第三段:结合公司文化和职位要求的具体匹配点(80-120字)
|
357 |
-
第四段:礼貌结尾和行动号召(30-50字)
|
358 |
-
|
359 |
-
3. 写作规范:
|
360 |
-
- 语言简洁专业,总字数严格控制在300-400字
|
361 |
-
- 使用主动语态和积极措辞
|
362 |
-
- 量化成果时只能使用简历中提供的数据
|
363 |
-
- 避免使用夸张或主观的描述词
|
364 |
-
|
365 |
-
4. 特别注意:
|
366 |
-
- 如果简历中没有公司要求的关键技能或经验,不要在信中编造
|
367 |
-
- 不要假设任何简历中没有的工作职责或成就
|
368 |
-
- 不要添加简历中未列出的证书、奖项或培训经历
|
369 |
-
|
370 |
-
[申请人简历内容]
|
371 |
-
{data['resume_text'][:10000]}
|
372 |
-
|
373 |
-
[目标公司信息]
|
374 |
-
公司名称: {data['company_name']}
|
375 |
-
公司介绍: {data.get('company_info', '未提供')}
|
376 |
-
|
377 |
-
[申请职位]
|
378 |
-
{data['position']}
|
379 |
-
|
380 |
-
[职位描述及要求]
|
381 |
-
{data['job_description']}
|
382 |
-
|
383 |
-
[申请动机]
|
384 |
-
{data.get('motivation', '未提供')}
|
385 |
-
|
386 |
-
请现在开始撰写求职信,严格遵循以上所有要求。"""
|
387 |
-
|
388 |
-
# 调用AI生成推荐信
|
389 |
-
response = client.chat.completions.create(
|
390 |
-
model="deepseek-chat",
|
391 |
-
messages=[
|
392 |
-
{
|
393 |
-
"role": "system",
|
394 |
-
"content": """你是一位严谨的职业顾问,专门帮助求职者撰写基于事实的求职信。
|
395 |
-
你必须:
|
396 |
-
1. 只使用申请人简历中明确提供的信息
|
397 |
-
2. 绝不添加、推断或编造任何简历中没有的内容
|
398 |
-
3. 如果简历缺少职位要求的关键资质,如实呈现而不虚构
|
399 |
-
4. 所有成就描述必须有简历中的具体数据支持"""
|
400 |
-
},
|
401 |
-
{"role": "user", "content": prompt}
|
402 |
-
],
|
403 |
-
temperature=0.5, # 降低创造性,提高准确性
|
404 |
-
max_tokens=2000
|
405 |
-
)
|
406 |
-
|
407 |
-
content = response.choices[0].message.content
|
408 |
-
|
409 |
-
# 后处理检查
|
410 |
-
if "简历中未提及" in content or "根据我的了解" in content:
|
411 |
-
raise ValueError("AI尝试添加简历外信息")
|
412 |
-
|
413 |
-
return jsonify({
|
414 |
-
'success': True,
|
415 |
-
'cover_letter': content,
|
416 |
-
'word_count': len(content.split())
|
417 |
-
})
|
418 |
-
|
419 |
-
except Exception as e:
|
420 |
-
print(f"推荐信生成错误: {str(e)}")
|
421 |
-
return jsonify({'error': f'生成失败: {str(e)}'}), 500
|
422 |
-
|
423 |
-
if __name__ == '__main__':
|
424 |
-
app.run(debug=True)
|
|
|
1 |
+
import streamlit as st
|
2 |
+
from transformers import pipeline
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
+
# Load the text classification model pipeline
|
5 |
+
classifier = pipeline("text-classification",model='isom5240ust/bert-base-uncased-emotion', return_all_scores=True)
|
|
|
|
|
6 |
|
7 |
+
# Streamlit application title
|
8 |
+
st.title("Text Classification for you")
|
9 |
+
st.write("Classification for 6 emotions: sadness, joy, love, anger, fear, surprise")
|
|
|
|
|
10 |
|
11 |
+
# Text input for user to enter the text to classify
|
12 |
+
text = st.text_area("Enter the text to classify", "")
|
|
|
|
|
|
|
|
|
13 |
|
14 |
+
# Perform text classification when the user clicks the "Classify" button
|
15 |
+
if st.button("Classify"):
|
16 |
+
# Perform text classification on the input text
|
17 |
+
results = classifier(text)[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
|
19 |
+
# Display the classification result
|
20 |
+
max_score = float('-inf')
|
21 |
+
max_label = ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
+
for result in results:
|
24 |
+
if result['score'] > max_score:
|
25 |
+
max_score = result['score']
|
26 |
+
max_label = result['label']
|
|
|
|
|
|
|
|
|
27 |
|
28 |
+
st.write("Text:", text)
|
29 |
+
st.write("Label:", max_label)
|
30 |
+
st.write("Score:", max_score)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|