20241305 2025-2026-2 《Python程序设计》综合实践报告

课程:《Python程序设计》
班级: 2413
姓名: 姚航
学号:20241305
实验教师:王志强
实验日期:2026年5月25日
必修/选修: 公选课

一、实验内容

1.1 实验题目及介绍

题目

职选智库 Pro - 智能简历解析与岗位匹配系统(CareerWiser)

系统介绍

CareerWiser 是一个基于 Python 和 Streamlit 开发的智能招聘辅助工具,旨在解决简历筛选效率低、信息提取不准确、岗位匹配主观性强等问题。系统支持上传图片、PDF、Word、TXT 等多种格式的简历文件,通过 OCR 文字识别和 AI 大模型解析,自动提取学号、姓名、专业、联系方式、优势特点等结构化信息。同时提供岗位管理、词云可视化、智能岗位匹配分析以及 Excel 报表导出等功能,为招聘工作提供数据支持和决策参考。

1.2 实验要求

Python综合应用:爬虫、数据处理、可视化、机器学习、神经网络、游戏、网络安全等。
课代表和各小组负责人收集作业(源代码、视频、综合实践报告)

Python综合应用:爬虫、数据处理、可视化、机器学习、神经网络、游戏、网络安全等。
例如:编写从社交网络爬取数据,实现可视化舆情监控或者情感分析。
例如:利用公开数据集,开展图像分类、恶意软件检测等
例如:利用Python库,基于OCR技术实现自动化提取图片中数据,并填入excel中。
例如:爬取天气数据,实现自动化微信提醒
例如:利用爬虫,实现自动化下载网站视频、文件等。
例如:编写小游戏:坦克大战、贪吃蛇、扫雷等等
注:在Windows/Linux系统上使用VIM、PDB、IDLE、Pycharm等工具编程实现。

本程序主要覆盖了以下技术要求:

  • 数据处理:使用 Pandas 对简历数据进行清洗、去重和统计分析
  • 可视化:使用 WordCloud 生成优势特点词云图
  • OCR 技术:集成 Tesseract 和 AI 模型实现图片文字识别
  • AI 大模型:调用智谱 GLM-4-Flash 和 GLM-4V-Flash 实现简历解析和岗位匹配
  • 自动化填表:将解析结果自动导出为 Excel 文件

1.3 程序功能列表

序号 功能模块 功能说明
1 Web 交互界面 基于 Streamlit 框架开发,纯 Python 实现,无需 HTML/CSS。支持页面配置、侧边栏、文件上传、数据表格编辑、进度条、指标卡片等组件,具有热重载和状态持久化特性
2 岗位信息管理 手动输入岗位编号(固定编号,不会因加载而改变)、名称、职责要求,保存后自动记录到 JSON 文件,历史岗位支持一键加载
3 多格式文件上传 支持 jpg/png/bmp 图片、txt 文本、doc/docx Word 文档、pdf 文件,自动识别文件类型并调用相应解析方法
4 双模式图片识别 用户可自由选择:① Tesseract OCR(本地运行,免费,适合英文和简单排版);② AI 视觉模型 GLM-4V-Flash(云端调用,准确率更高,能处理手写体和复杂排版,还有一个原因是智谱AI有新用户的免费额度
5 多份简历分割 一个文件中包含多份简历时,按“姓名:”关键词自动分割,每份独立解析
6 AI 简历解析 调用智谱 GLM-4-Flash 模型,提取学号、姓名、出生年月、籍贯、联系方式、专业、综合评定成绩、优势特点等结构化信息
7 自动去重 基于学号判断重复(自动清理 OCR 识别中的字母混淆,如 O→0、l→1),重复上传时自动跳过并提示
8 手动编辑 开启编辑模式后,表格变为可编辑状态,双击单元格可直接修改解析结果(适用于 OCR 识别错误时的快速校正)
9 词云可视化 基于所有简历的“优势特点”字段,使用 WordCloud 库生成词云图,直观展示候选人核心能力分布
10 智能岗位匹配 AI 分析候选人与岗位的匹配度,输出对比分析表(匹配点/不足点)、匹配度评分(0-100分)、智能推荐排序
11 Excel 报表导出 将解析后的简历数据(学号、姓名、专业、联系方式、优势特点等)导出为 Excel 文件,支持一键下载

注:各功能的代码实现细节将在“实验过程及结果”部分详细介绍。

1.4 实验环境

项目 配置
操作系统 Windows 11
Python 版本 3.13
开发工具 PyCharm 2024.3
前端框架 Streamlit 1.58.0
AI 模型 智谱 GLM-4-Flash(文本解析)、GLM-4V-Flash(图片识别)
OCR 引擎 Tesseract 5.3.3




4a26d0b824ed1825fdb0626c4872a26d

722cce625c708de3e3bd190f82c62712


库名 版本 用途
streamlit 1.58.0 Web 应用框架
pandas 3.0.3 数据处理与 Excel 导出
pytesseract 0.3.10 Tesseract OCR 接口
pillow 12.2.0 图像处理
pdfplumber 0.11.9 PDF 文字提取
python-docx 1.2.0 Word 文档解析
wordcloud 1.9.6 词云生成
zhipuai 2.1.5 智谱 AI 接口调用

安装命令

pip install streamlit pandas pillow pdfplumber python-docx wordcloud zhipuai pytesseract

检查库是否已安装成功
8a059516966085f0f2c3571ecd0b3c3f


检查Tesseract(本体)是否安装成功
image


二、实验过程及结果

2.1 系统设计

2.1.1 整体流程

用户打开程序后,首先在左侧边栏录入岗位信息(编号、名称、职责要求),保存后岗位记录会写入 job_history.json 文件,同时侧边栏的“历史岗位”列表中会出现该岗位。后续使用时,点击历史岗位即可自动填入编号和名称(职责要求需根据实际情况手动补充,避免覆盖已有内容)。

简历录入支持三种方式:上传文件(图片、PDF、Word、TXT)、粘贴文本、或两者结合。上传图片时,用户可选择 Tesseract OCR(本地免费)或 AI 视觉模型(云端高精度)两种识别方式。系统会自动识别文件类型,调用对应的解析方法。

提取出的原始文本会按“姓名:”关键词自动分割——如果一个文件中包含多份简历,每份会被独立处理。分割后的文本交给智谱 GLM-4-Flash 模型解析,输出学号、姓名、专业、联系方式、优势特点等结构化信息。

解析完成后,系统会基于学号进行去重:如果新解析的学号与已有数据中的学号重复,则自动跳过并提示用户;无学号的简历不会参与去重(因为无法唯一标识)。用户也可以通过“手动编辑模式”直接在表格中修改识别错误的内容。

所有简历数据会保存在 st.session_state.resume_data 中(Streamlit 的会话状态,页面交互时数据不丢失)。用户可以生成优势特点词云、让 AI 分析岗位匹配度,最后将数据导出为 Excel 文件,存放在 data/ 目录下,文件名包含岗位编号和导出时间戳。

2.1.1 系统模块结构

职选智库 Pro(CareerWiser)系统架构图
职选智库 Pro(CareerWiser)
|
├── main.py(主程序入口,Streamlit 页面编排)
│
├── 岗位管理模块
│ ├── 岗位录入(编号、名称、要求)
│ ├── job_history.json(岗位持久化存储)
│ ├── saved_jobs.txt(岗位可读备份)
│ └── 历史岗位加载/删除/清空
│
├── 文件处理模块
│ ├── 图片文件(jpg/png/bmp)
│ │ ├── Tesseract OCR(本地识别,含预处理:灰度、对比度、放大)
│ │ └── AI视觉模型(GLM-4V-Flash,云端识别)
│ ├── PDF文件(pdfplumber 文字提取)
│ ├── Word文件(python-docx 文字提取)
│ └── TXT文件 / 粘贴文本(直接读取)
│
├── 文本处理模块
│ ├── 多份简历分割(按“姓名:”关键词)
│ ├── AI 结构化解析(GLM-4-Flash)
│ ├── 提取学号、姓名、专业、联系方式等
│ ├── 学号清理(字母转数字、去空格)
│ └── 优势特点概括(不超过50字)
│
├── 数据管理模块
│ ├── st.session_state.resume_data(会话状态存储)
│ ├── 基于学号的自动去重
│ ├── 手动编辑模式(st.data_editor 表格编辑)
│ └── 词云生成(WordCloud)
│
├── 分析与导出模块
│ ├── 岗位匹配分析(AI 输出匹配度评分、对比表、推荐)
│ └── Excel 报表导出(data/ 目录,文件名含时间戳)
│
└── 辅助模块
    ├── 进度条反馈(st.progress)
    ├── 状态提示(st.success / st.warning / st.error)
    └── 下载按钮(st.download_button)

2.1.2 数据存储说明

文件 用途 格式
job_history.json 存储岗位信息(编号、名称、要求、保存时间) JSON
saved_jobs.txt 岗位信息的人类可读备份,与 JSON 同步 纯文本
data/岗位编号_岗位名称_时间戳.xlsx 导出的简历数据 Excel
job_history.json 同目录 程序运行时自动生成,无需手动创建 -

2.1.3 用户操作流程

  1. 录入岗位:在侧边栏填写岗位编号(如 JOB-001)、名称、要求,点击保存。
  2. 上传简历:选择文件或粘贴文本,选择识别方式,点击解析。
  3. 查看结果:解析结果自动显示在表格中,可开启编辑模式修改。
  4. 数据分析:点击“开始对比分析”查看 AI 匹配建议,查看词云图。
  5. 导出报表:点击导出按钮,下载 Excel 文件。

2.2 核心功能实现

注:为减少篇幅,也是为了整洁精炼,此部分代码实现环节的代码只是体现了代码逻辑,不是完整版的代码,完整版代码将在仓库展示。

2.2.1 Web 交互界面(Streamlit)

功能说明:整个系统基于 Streamlit 框架构建前端界面,这是本程序的一大技术亮点。Streamlit 是一个专门为数据科学和机器学习应用设计的 Python 框架,最大的特点是纯 Python 开发,无需编写 HTML、CSS、JavaScript。开发者只需调用 st.xxx() 系列函数,就能快速生成交互式 Web 页面,大大降低了 Web 开发的门槛。

本程序中使用的主要 Streamlit 组件包括:

组件 代码 用途
页面配置 st.set_page_config() 设置页面标题、图标、布局模式
侧边栏 with st.sidebar: 放置岗位录入和历史记录区域
文本输入 st.text_input() 输入岗位编号、名称
文本区域 st.text_area() 输入岗位职责要求、粘贴简历文本
文件上传 st.file_uploader() 支持多文件批量上传
单选按钮 st.radio() 切换 Tesseract / AI 视觉模型
按钮 st.button() 触发保存、解析、导出等操作
数据表格 st.dataframe() 只读展示解析结果
可编辑表格 st.data_editor() 手动编辑模式下的表格
进度条 st.progress() 显示文件解析进度
指标卡片 st.metric() 展示简历总数、专业种类等统计
图片显示 st.image() 显示词云图
下载按钮 st.download_button() 导出 Excel 文件
状态提示 st.success()/warning()/error() 反馈操作结果

状态持久化:Streamlit 的脚本在每次交互时都会重新运行,普通变量会被重置。为了解决这个问题,系统使用 st.session_state 来保存跨页面交互的数据,如简历列表、岗位信息、比较结果等。

2.2.2 岗位信息管理

功能说明:用户手动输入岗位编号(如 JOB-001)、名称和职责要求,保存时检查编号是否重复。数据同时写入 job_history.json(程序读取)和 saved_jobs.txt(人工备份)。侧边栏显示历史岗位,点击即可自动填入编号和名称(职责要求需手动补充,避免覆盖)。删除岗位时同步更新两个文件。

代码实现

📄 点击展开/收起代码
if st.button("💾 保存岗位", use_container_width=True):
    if job_id_input and job_title and job_description:
        existing_ids = [job['id'] for job in st.session_state.job_history]
        if job_id_input in existing_ids:
            st.error(f"❌ 岗位编号 {job_id_input} 已存在")
        else:
            new_job = {
                'id': job_id_input,
                'title': job_title,
                'description': job_description,
                'timestamp': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            }
            st.session_state.job_history.append(new_job)
            save_history(st.session_state.job_history)
            st.success(f"✅ 岗位 {job_id_input} 已保存")

2.2.3 多格式文件上传与解析

功能说明:系统根据文件 MIME 类型自动分流:图片走 OCR(Tesseract 或 AI 视觉模型),PDF 用 pdfplumber,Word 用 python-docx,TXT 和粘贴文本直接读取。用户无需预处理,直接上传即可。

代码实现

📄 点击展开/收起代码
def extract_text_from_file(file, ocr_method):
    file_type = file.type
    if file_type == 'text/plain':
        text = extract_text_from_txt(file)
    elif file_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
        text = extract_text_from_docx(file)
    elif file_type == 'application/pdf':
        text = extract_text_from_pdf(file)
    else:
        if ocr_method == "Tesseract OCR":
            text = extract_text_from_image(file)
        else:
            text = extract_with_ai_vision(file)
    return text, method

2.2.4 图片 OCR 双模式识别

功能说明:用户可选两种识别方式。Tesseract 本地免费但易混淆数字字母,代码加入灰度、对比度增强、放大三步预处理提升准确率。AI 视觉模型调用 GLM-4V-Flash,能处理模糊图和手写体,但需联网且消耗 tokens。

代码实现

📄 点击展开/收起代码
def extract_text_from_image(file):
    image = Image.open(file)
    gray = image.convert('L')
    enhancer = ImageEnhance.Contrast(gray)
    gray = enhancer.enhance(2.0)
    if width < 1000 or height < 1000:
        scale = max(1000 / width, 1000 / height)
        gray = gray.resize((int(width * scale), int(height * scale)))
    return pytesseract.image_to_string(gray, lang='chi_sim')

def extract_with_ai_vision(file):
    base64_image = base64.b64encode(file.read()).decode('utf-8')
    response = client.chat.completions.create(
        model="glm-4v-flash",
        messages=[{"role": "user", "content": [
            {"type": "text", "text": prompt},
            {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
        ]}]
    )
    return response.choices[0].message.content

2.2.5 多份简历分割

功能说明:一个文件可能包含多份简历。系统按“姓名:”关键词分割,每份独立解析。遍历文本行,遇到“姓名:”且当前缓存非空时,将缓存存入列表,然后清空继续。

代码实现

📄 点击展开/收起代码

def split_resumes_by_keywords(text):
    lines = text.split('\n')
    parts, current = [], []
    for line in lines:
        if "姓名:" in line and current:
            parts.append("\n".join(current))
            current = []
        current.append(line)
    if current:
        parts.append("\n".join(current))
    return [p.strip() for p in parts if len(p.strip()) > 50]

2.2.6 AI 简历解析

功能说明:调用 GLM-4-Flash 提取结构化信息。prompt 规定输出 8 个字段,优势特点要求不超过 50 字概括。返回的“字段:值”文本逐行解析存入字典,学号送清理函数处理,缺失字段填“未提供”。

代码实现

📄 点击展开/收起代码

def parse_resume_with_ai(text):
    prompt = f"""
    从以下简历提取:学号、姓名、出生年月、籍贯、联系方式、专业、综合评定成绩、优势特点。
    优势特点用不超过50字概括,没有则输出"未提供"。
    简历文本:{text[:4000]}
    """
    response = client.chat.completions.create(
        model="glm-4-flash",
        messages=[{"role": "user", "content": prompt}]
    )
    info = {}
    for line in response.choices[0].message.content.split('\n'):
        if ':' in line:
            k, v = line.split(':', 1)
            info[k.strip()] = v.strip()
    if '学号' in info:
        info['学号'] = clean_student_id(info['学号'])
    if '优势特点' not in info or not info['优势特点']:
        info['优势特点'] = "未提供"
    return info

2.2.7 学号清理与自动去重

功能说明:OCR 常把 0 识别成 O、1 识别成 l。clean_student_id 建立字母→数字映射,去空格后只留数字。去重只比较学号(唯一标识),无学号的简历不参与去重,避免误拦。

代码实现

📄 点击展开/收起代码
def clean_student_id(raw_id):
    if not raw_id:
        return ""
    raw_id = re.sub(r'\s+', '', str(raw_id).strip())
    corrections = {'O':'0','o':'0','l':'1','I':'1','Z':'2','S':'5','B':'8'}
    raw_id = ''.join([corrections.get(c, c) for c in raw_id])
    return ''.join([c for c in raw_id if c.isdigit()])

def is_duplicate_resume(student_id, name):
    student_id_clean = clean_student_id(student_id)
    if not student_id_clean:
        return False
    for existing in st.session_state.resume_data:
        if clean_student_id(existing.get('学号', '')) == student_id_clean:
            return True
    return False

2.2.8 手动编辑模式

功能说明:开启编辑模式后,表格变为可编辑,双击单元格修改内容。保存时遍历编辑后表格,更新原有数据;新增行单独处理并标记来源。

代码实现

📄 点击展开/收起代码
if st.session_state.edit_mode:
    edited_df = st.data_editor(display_df, num_rows="dynamic")
    if st.button("💾 保存修改"):
       
        new_resume_data = []
        for idx in range(len(edited_df)):
            row = edited_df.iloc[idx]
            student_id = str(row.get('学号', '')).strip() if pd.notna(row.get('学号')) else ''
            
            # 尝试匹配原数据中的条目
            matched = None
            for original in st.session_state.resume_data:
                if student_id and original.get('学号') == student_id:
                    matched = original
                    break
            
            if matched:
                for col in available_cols:
                    new_val = row.get(col, '')
                    matched[col] = str(new_val).strip() if pd.notna(new_val) else ''
                new_resume_data.append(matched)
            else:
                new_item = {'识别方式': '手动添加', 'source_file': '手动编辑'}
                for col in available_cols:
                    val = row.get(col, '')
                    new_item[col] = str(val).strip() if pd.notna(val) else ''
                new_resume_data.append(new_item)
        
        st.session_state.resume_data = new_resume_data
        st.success("✅ 修改已保存")
        st.rerun()

2.2.9 词云可视化

功能说明:收集所有简历的“优势特点”字段,用 WordCloud 生成词云图。词频越高显示越大,直观展示候选人核心能力分布。指定中文字体路径避免乱码。

代码实现

📄 点击展开/收起代码
def generate_wordcloud(text):
    if not text:
        return None
    wc = WordCloud(width=800, height=400, background_color='white',
                   max_words=100, colormap='Blues', font_path='C:/Windows/Fonts/simhei.ttf')
    wc.generate(text)
    return wc

all_text = " ".join([str(x.get('优势特点', '')) for x in st.session_state.resume_data])
if all_text:
    wc = generate_wordcloud(all_text)
    st.image(wc.to_array(), use_container_width=True)

2.2.10 智能岗位匹配分析

功能说明:将岗位要求和候选人信息(姓名+专业+优势特点)发送给 AI,返回对比分析表、匹配度评分(0-100)和智能推荐排序。输出直接展示,无需二次解析。

代码实现

📄 点击展开/收起代码
def compare_with_ai(job_desc, resumes):
    prompt = f"岗位:{job_desc}\n候选人:\n"
    for i, r in enumerate(resumes):
        prompt += f"{i+1}. {r}\n"
    prompt += "\n输出:对比分析表、匹配度评分(0-100)、智能推荐排序"
    response = client.chat.completions.create(
        model="glm-4-flash",
        messages=[{"role": "user", "content": prompt}]
    )
    return response.choices[0].message.content

2.2.11 Excel 报表导出

功能说明:将解析结果导出为 Excel 文件,文件名包含岗位编号和时间戳。保存在 data/ 目录,页面提供下载按钮。

代码实现

📄 点击展开/收起代码
def save_to_excel(data, filename):
    os.makedirs('data', exist_ok=True)
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    filepath = f"data/{filename}_{timestamp}.xlsx"
    df = pd.DataFrame([{
        '学号': item.get('学号', '未提供'),
        '姓名': item.get('姓名', '未提供'),
        '专业': item.get('专业', '未提供'),
        '联系方式': item.get('联系方式', '未提供'),
        '优势特点': item.get('优势特点', '未提供')
    } for item in data])
    df.to_excel(filepath, index=False)
    return filepath

if st.button("📎 导出 Excel 报表"):
    filepath = save_to_excel(st.session_state.resume_data, f"{current_id}_{current_title}")
    with open(filepath, 'rb') as f:
        st.download_button("下载", f, file_name=os.path.basename(filepath))

2.3 程序的健壮性强化

为了让系统更稳定、更人性化,减少用户操作出错的可能性,在开发过程中加入了多个健壮性设计。

2.3.1 OCR 识别的预处理优化

Tesseract 对低质量图片的识别率不稳定,尤其是小字号、低对比度或倾斜的文字。通过在识别前对图片进行预处理,显著提升了识别准确率。

优化措施

  • 转换为灰度图,减少颜色干扰
  • 增强对比度,拉大明暗差异
  • 图片尺寸过小时等比例放大
def extract_text_from_image(file):
    gray = image.convert('L')
    enhancer = ImageEnhance.Contrast(gray)
    gray = enhancer.enhance(2.0)
    if width < 1000 or height < 1000:
        scale = max(1000 / width, 1000 / height)
        gray = gray.resize((int(width * scale), int(height * scale)))

2.3.2 手动编辑兜底

即使做了优化,OCR 和 AI 仍可能出错。开启编辑模式后,用户可以直接在表格中修改错误内容,无需删除重传。新增行也会被正确处理并标记来源。
根据编辑后的表格完全重建数据列表,删除的行自动丢弃,新增行正确添加,通过学号匹配复用原数据以保留识别方式等字段。

2.3.3 数据持久化的双保险

岗位信息同时保存到 job_history.json(程序读取)和 saved_jobs.txt(人工可读)。删除或清空岗位时,两个文件同步更新,避免数据不一致。

2.3.4用户反馈与进度提示

上传多个文件时显示进度条,解析完成或出现重复时给出明确的状态提示(成功/警告/错误),让用户清楚当前操作的结果。

📄 点击展开/收起代码
progress_bar = st.progress(0)
st.success(f"✅ 成功解析 {total_added} 份新简历")
st.warning(f"⚠️ 跳过重复:{sname}(学号:{sid})")
st.error(f"解析 {file.name} 失败:{info['error']}")

2.4 程序运行结果

2.4.1 运行命令

进入项目目录后,在终端执行以下命令启动程序:

streamlit run main.py

Streamlit 会启动一个本地 Web 服务器,默认地址为 http://localhost:8501


为什么用 streamlit run 而不是 python main.py?


Streamlit 不是普通的 Python 脚本,它包含了一套完整的 Web 服务器和前端渲染机制。streamlit run 命令会启动这个服务器,处理页面交互、状态管理和热重载。如果直接用 python 执行,程序会报错或无法正常交互。

2.4.2 界面展示

输入命令

image

主界面截图(只展示运行时基本界面,并非运行,程序演示视频在后面)
image

2.4.3全部代码展示

📄 点击展开/收起代码

import streamlit as st
import pandas as pd
import pytesseract
from PIL import Image
import pdfplumber
from docx import Document
from wordcloud import WordCloud
import datetime
import os
import json
import base64
import re
from zhipuai import ZhipuAI

# ==================== 页面配置 ====================
st.set_page_config(
    page_title="📄 职选智库 - 智能就业助手",
    page_icon="📄",
    layout="wide",
    initial_sidebar_state="expanded"
)

# ==================== 样式美化 ====================
st.markdown("""
<style>
    .main-header {
        font-size: 2.5rem;
        color: #1E88E5;
        text-align: center;
        margin-bottom: 20px;
    }
    .sub-header {
        font-size: 1.2rem;
        color: #666;
        text-align: center;
        margin-bottom: 30px;
    }
    .section-title {
        font-size: 1.5rem;
        font-weight: bold;
        color: #1E88E5;
        margin-top: 30px;
        margin-bottom: 15px;
    }
    .section-divider {
        border: 0;
        height: 2px;
        background: linear-gradient(to right, #1E88E5, transparent);
        margin: 20px 0;
    }
</style>
""", unsafe_allow_html=True)

# ==================== 配置 ====================
ZHIPU_API_KEY = "f1b6b9ee42d1491197f4dc37a1282d97.xiwVjzB18rONSxD5"
pytesseract.pytesseract.tesseract_cmd = r'D:\OCR\tesseract.exe'


# ==================== 学号清理函数 ====================
def clean_student_id(raw_id):
    """清理学号:去除空格、字母转数字、只保留数字"""
    if not raw_id:
        return ""
    raw_id = str(raw_id).strip()
    raw_id = re.sub(r'\s+', '', raw_id)
    corrections = {
        'O': '0', 'o': '0', 'D': '0', 'Q': '0',
        'l': '1', 'I': '1', '|': '1',
        'Z': '2', 'z': '2',
        'S': '5', 's': '5',
        'B': '8',
        'g': '9', 'q': '9', 'G': '9',
    }
    raw_id = ''.join([corrections.get(c, c) for c in raw_id])
    raw_id = ''.join([c for c in raw_id if c.isdigit()])
    return raw_id


# ==================== 去重检查函数 ====================
def is_duplicate_resume(student_id, name):
    """只检查学号是否重复,姓名不参与去重"""
    student_id_clean = clean_student_id(student_id)

    if not student_id_clean:
        return False  # 没有学号,认为不重复(允许添加)

    for existing in st.session_state.resume_data:
        existing_id = clean_student_id(existing.get('学号', ''))
        if existing_id and student_id_clean == existing_id:
            return True  # 学号重复,拒绝添加

    return False


# ==================== 优化后的OCR识别 ====================
def extract_text_from_image(file):
    """从图片提取文字(使用OCR),自动预处理,减少错误"""
    try:
        # 使用PIL读取图片
        image = Image.open(file)

        # 转换为RGB(确保格式统一)
        if image.mode != 'RGB':
            image = image.convert('RGB')

        # 转换为灰度图
        gray = image.convert('L')

        # 增强对比度(简单阈值处理)
        from PIL import ImageEnhance
        enhancer = ImageEnhance.Contrast(gray)
        gray = enhancer.enhance(2.0)  # 提高对比度

        # 放大图片(提高小字识别率)
        width, height = gray.size
        if width < 1000 or height < 1000:
            scale = max(1000 / width, 1000 / height)
            new_size = (int(width * scale), int(height * scale))
            gray = gray.resize(new_size, Image.Resampling.LANCZOS)

        # 使用Tesseract识别(自适应参数)
        custom_config = r'--oem 3 --psm 6'  # 统一文本块模式
        text = pytesseract.image_to_string(gray, lang='chi_sim', config=custom_config)
        return text.strip() if text.strip() else "未识别到文字"
    except Exception as e:
        return f"OCR 识别失败:{str(e)}"


# ==================== 备用:AI视觉识别 ====================
def extract_with_ai_vision(file):
    """AI视觉模型识别(备用方案)"""
    try:
        client = ZhipuAI(api_key=ZHIPU_API_KEY)
        image_data = file.read()
        base64_image = base64.b64encode(image_data).decode('utf-8')

        prompt = """请仔细识别这张图片中的所有文字内容,按照从上到下、从左到右的顺序输出。
如果是简历,请特别关注:姓名、学号、专业、联系方式、出生年月、籍贯、综合评定成绩、优势特点等信息。
直接输出识别到的文字内容,不要添加额外解释。"""

        response = client.chat.completions.create(
            model="glm-4v-flash",
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": prompt},
                    {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
                ]
            }]
        )
        return response.choices[0].message.content.strip()
    except Exception as e:
        return f"AI 视觉识别失败:{str(e)}"


# ==================== 文件处理函数 ====================
def extract_text_from_txt(file):
    try:
        return file.read().decode('utf-8').strip()
    except UnicodeDecodeError:
        try:
            return file.read().decode('gbk').strip()
        except:
            return "文本文件读取失败"


def extract_text_from_docx(file):
    try:
        doc = Document(file)
        text = '\n'.join([paragraph.text for paragraph in doc.paragraphs])
        return text.strip()
    except Exception as e:
        return f"Word 文档读取失败:{str(e)}"


def extract_text_from_pdf(file):
    try:
        text = ""
        with pdfplumber.open(file) as pdf:
            for page in pdf.pages:
                page_text = page.extract_text()
                if page_text:
                    text += page_text + "\n"
        return text.strip()
    except Exception as e:
        return f"PDF 读取失败:{str(e)}"


def extract_text_from_file(file, ocr_method):
    file_type = file.type

    if file_type == 'text/plain':
        text = extract_text_from_txt(file)
        method = "文本读取"
    elif file_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
        text = extract_text_from_docx(file)
        method = "Word解析"
    elif file_type == 'application/pdf':
        text = extract_text_from_pdf(file)
        method = "PDF解析"
    else:
        if ocr_method == "Tesseract OCR":
            text = extract_text_from_image(file)
            method = "Tesseract OCR"
        else:
            text = extract_with_ai_vision(file)
            method = "AI视觉模型"

    return text, method


# ==================== 多份简历分割函数 ====================
def split_resumes_by_keywords(text):
    lines = text.split('\n')
    parts = []
    current_part = []

    name_count = text.count("姓名:")
    id_count = text.count("学号:")

    if name_count <= 1 and id_count > 1:
        for line in lines:
            if "学号:" in line and current_part:
                parts.append("\n".join(current_part))
                current_part = []
            current_part.append(line)
        if current_part:
            parts.append("\n".join(current_part))
        return [part.strip() for part in parts if len(part.strip()) > 50]

    if name_count <= 1 and id_count <= 1:
        return [text.strip()]

    for line in lines:
        if "姓名:" in line and current_part:
            parts.append("\n".join(current_part))
            current_part = []
        current_part.append(line)

    if current_part:
        parts.append("\n".join(current_part))

    return [part.strip() for part in parts if len(part.strip()) > 50]


# ==================== AI 解析简历 ====================
def parse_resume_with_ai(text):
    if not text or len(text) < 10:
        return {"error": "文本内容过短,无法有效解析"}

    try:
        client = ZhipuAI(api_key=ZHIPU_API_KEY)

        prompt = f"""
        你是一个专业的简历解析助手。请从以下简历文本中提取关键信息,并按以下格式输出:

        学号:xxxx
        姓名:xxx
        出生年月:xxxx年xx月
        籍贯:xx省xx市
        联系方式:1xx-xxxx-xxxx(请保持3-4-4格式)
        专业:xxx
        综合评定成绩:xx分
        优势特点:xxx

        规则:
        1. 如果简历中有"优势特点"字段,请**用不超过50个字**进行概括总结,不要原文照抄。
        2. 如果简历中没有"优势特点"字段,请直接输出"未提供"。
        3. 优势特点必须**简洁、概括、精炼**,不要写过长。

        简历文本:
        {text[:4000]}
        """

        response = client.chat.completions.create(
            model="glm-4-flash",
            messages=[{"role": "user", "content": prompt}]
        )

        result_text = response.choices[0].message.content

        info = {}
        lines = result_text.split('\n')
        for line in lines:
            if ':' in line:
                key, value = line.split(':', 1)
                info[key.strip()] = value.strip()

        if '学号' in info:
            info['学号'] = clean_student_id(info['学号'])

        # 如果优势特点字段不存在或为空,直接设为"未提供"
        if '优势特点' not in info or not info['优势特点']:
            info['优势特点'] = "未提供"

        return info
    except Exception as e:
        return {"error": f"AI 解析失败:{str(e)}"}


# ==================== 岗位匹配分析 ====================
def compare_with_ai(job_desc, resumes):
    try:
        client = ZhipuAI(api_key=ZHIPU_API_KEY)

        prompt = f"""
        你是一个智能招聘助手。请分析以下岗位和候选人简历,输出匹配度分析。

        **岗位要求:**
        {job_desc}

        **候选人信息(共{len(resumes)}人):**
        """
        for i, resume in enumerate(resumes):
            prompt += f"\n候选人{i + 1}:\n{resume}\n"

        prompt += """
        请按以下格式输出分析结果:

        1. **对比分析表**:
        - 候选人1:匹配点/不足点
        - 候选人2:匹配点/不足点
        - ...

        2. **匹配度评分**(0-100分):
        - 候选人1:xx分
        - 候选人2:xx分
        - ...

        3. **智能推荐**:按匹配度排序,推荐最合适的人选。
        """

        response = client.chat.completions.create(
            model="glm-4-flash",
            messages=[{"role": "user", "content": prompt}]
        )

        return response.choices[0].message.content
    except Exception as e:
        return f"AI 对比失败:{str(e)}"


# ==================== 数据导出 ====================
def save_to_excel(data, filename):
    if not os.path.exists('data'):
        os.makedirs('data')

    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    filepath = f"data/{filename}_{timestamp}.xlsx"

    clean_data = []
    for item in data:
        clean_item = {
            '学号': item.get('学号', '未提供'),
            '姓名': item.get('姓名', '未提供'),
            '出生年月': item.get('出生年月', '未提供'),
            '籍贯': item.get('籍贯', '未提供'),
            '联系方式': item.get('联系方式', '未提供'),
            '专业': item.get('专业', '未提供'),
            '综合评定成绩': item.get('综合评定成绩', '未提供'),
            '优势特点': item.get('优势特点', '未提供')
        }
        clean_data.append(clean_item)

    df = pd.DataFrame(clean_data)
    df.to_excel(filepath, index=False)
    return filepath


# ==================== 词云生成 ====================
def generate_wordcloud(text):
    if not text:
        return None
    try:
        font_path = "C:/Windows/Fonts/simhei.ttf"
        wc = WordCloud(
            width=800,
            height=400,
            background_color='white',
            max_words=100,
            colormap='Blues',
            font_path=font_path
        )
        wc.generate(text)
        return wc
    except:
        return None


# ==================== 添加简历 ====================
def add_resume_with_dedup(info):
    """添加简历,只检查学号是否重复"""
    student_id = info.get('学号', '')
    student_name = info.get('姓名', '')

    if is_duplicate_resume(student_id, student_name):
        return False, student_id, student_name

    st.session_state.resume_data.append(info)
    return True, student_id, student_name


# ==================== 岗位文本文件同步 ====================
def sync_jobs_to_txt():
    txt_file = "saved_jobs.txt"
    with open(txt_file, 'w', encoding='utf-8') as f:
        if st.session_state.job_history:
            for job in st.session_state.job_history:
                f.write(f"\n{'=' * 60}\n")
                f.write(f"岗位编号:{job['id']}\n")
                f.write(f"岗位名称:{job['title']}\n")
                f.write(f"岗位要求:\n{job['description']}\n")
                f.write(f"保存时间:{job['timestamp']}\n")
                f.write(f"{'=' * 60}\n")
        else:
            f.write("")


# ==================== 历史记录持久化 ====================
def load_history():
    history_file = "job_history.json"
    if os.path.exists(history_file):
        try:
            with open(history_file, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            return []
    return []


def save_history(history):
    history_file = "job_history.json"
    with open(history_file, 'w', encoding='utf-8') as f:
        json.dump(history, f, ensure_ascii=False, indent=2)
    sync_jobs_to_txt()


# ==================== 初始化 Session State ====================
if 'resume_data' not in st.session_state:
    st.session_state.resume_data = []
if 'job_description' not in st.session_state:
    st.session_state.job_description = ""
if 'comparison_result' not in st.session_state:
    st.session_state.comparison_result = None
if 'current_job_id' not in st.session_state:
    st.session_state.current_job_id = None
if 'current_job_title' not in st.session_state:
    st.session_state.current_job_title = None
if 'last_exported_filename' not in st.session_state:
    st.session_state.last_exported_filename = None
if 'job_history' not in st.session_state:
    st.session_state.job_history = load_history()
if 'job_id_input' not in st.session_state:
    st.session_state.job_id_input = ""
if 'job_title_input' not in st.session_state:
    st.session_state.job_title_input = ""
if 'job_desc_input' not in st.session_state:
    st.session_state.job_desc_input = ""
if 'edit_mode' not in st.session_state:
    st.session_state.edit_mode = False

# 启动时同步
if st.session_state.job_history:
    sync_jobs_to_txt()
else:
    if os.path.exists("saved_jobs.txt"):
        open("saved_jobs.txt", 'w', encoding='utf-8').close()

# ==================== 界面 ====================
st.markdown('<div class="main-header">📄 职选智库 Pro</div>', unsafe_allow_html=True)
st.markdown('<div class="sub-header">智能简历解析与岗位匹配系统 - 支持两种识别方式对比</div>', unsafe_allow_html=True)

# ==================== 侧边栏:岗位信息录入 ====================
with st.sidebar:
    st.markdown("### 📋 岗位信息录入")
    st.markdown("---")

    # 使用 st.session_state 的默认值
    job_id_input = st.text_input(
        "🔢 岗位编号",
        placeholder="例如:JOB-001",
        value=st.session_state.get('job_id_input', '')
    )
    job_title = st.text_input(
        "🏢 岗位名称",
        placeholder="例如:Java开发工程师",
        value=st.session_state.get('job_title_input', '')
    )
    job_description = st.text_area(
        "📝 岗位职责与要求",
        height=200,
        placeholder="请输入岗位的详细要求...",
        value=st.session_state.get('job_desc_input', '')
    )

    # 同步用户输入到 session_state
    st.session_state.job_id_input = job_id_input
    st.session_state.job_title_input = job_title
    st.session_state.job_desc_input = job_description

    if st.button("💾 保存岗位", use_container_width=True):
        if job_id_input and job_title and job_description:
            existing_ids = [job['id'] for job in st.session_state.job_history]
            if job_id_input in existing_ids:
                st.error(f"❌ 岗位编号 {job_id_input} 已存在,请使用不同编号")
            else:
                new_job = {
                    'id': job_id_input,
                    'title': job_title,
                    'description': job_description,
                    'exported_filename': None,
                    'timestamp': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                }
                st.session_state.job_history.append(new_job)
                save_history(st.session_state.job_history)
                st.session_state.current_job_id = job_id_input
                st.session_state.current_job_title = job_title
                st.session_state.job_description = job_description
                st.session_state.job_id_input = job_id_input
                st.session_state.job_title_input = job_title
                st.session_state.job_desc_input = job_description
                st.success(f"✅ 岗位 {job_id_input} 已保存")
        else:
            st.error("请完整填写岗位编号、名称和要求")

    st.markdown("---")
    st.markdown("### 📋 历史岗位")

    if st.session_state.job_history:
        for job in reversed(st.session_state.job_history):
            col1, col2 = st.columns([4, 1])
            with col1:
                if st.button(f"📄 [{job['id']}] {job['title']}", key=f"load_{job['id']}"):
                    st.session_state.current_job_id = job['id']
                    st.session_state.current_job_title = job['title']
                    st.session_state.job_description = job['description']
                    st.session_state.job_id_input = job['id']
                    st.session_state.job_title_input = job['title']
                    st.session_state.job_desc_input = job['description']
                    st.rerun()
            with col2:
                if st.button("🗑️", key=f"delete_{job['id']}"):
                    st.session_state.job_history = [j for j in st.session_state.job_history if j['id'] != job['id']]
                    save_history(st.session_state.job_history)
                    if st.session_state.current_job_id == job['id']:
                        st.session_state.current_job_id = None
                        st.session_state.current_job_title = None
                        st.session_state.job_description = ""
                    st.rerun()

        if st.button("🗑️ 清空所有历史", use_container_width=True):
            st.session_state.job_history = []
            save_history(st.session_state.job_history)
            st.session_state.current_job_id = None
            st.session_state.current_job_title = None
            st.session_state.job_description = ""
            st.rerun()
    else:
        st.info("暂无历史岗位")

# ==================== 主界面:简历信息录入 ====================
st.markdown('<div class="section-title">📥 简历信息录入</div>', unsafe_allow_html=True)
st.markdown('<div class="section-divider"></div>', unsafe_allow_html=True)

ocr_method = st.radio(
    "🔍 选择图片文字识别方式(仅对图片文件生效,txt/doc/pdf直接读取)",
    ["Tesseract OCR", "AI视觉模型"],
    help="Tesseract:传统OCR,本地运行,免费 | AI视觉模型:多模态大模型,理解能力强,需联网",
    horizontal=True
)

if ocr_method == "Tesseract OCR":
    st.info("📌 当前使用:Tesseract OCR - 传统开源OCR引擎,本地运行,免费(已优化预处理)")
else:
    st.warning("📌 当前使用:AI视觉模型 (glm-4v-flash) - 多模态大模型,能真正理解图片内容")

col_upload, col_paste = st.columns(2)

with col_upload:
    st.markdown("### 📤 上传文件")
    st.markdown("支持格式:图片 (jpg/png/bmp) | 文本 (txt) | Word (doc/docx) | PDF")
    st.info("📌 **提示**:Word 和 PDF 文件仅支持文字内容,文件中的图片无法识别。图片文件将使用上述选择的识别方式。")

    uploaded_files = st.file_uploader(
        "选择文件上传",
        type=['jpg', 'jpeg', 'png', 'bmp', 'txt', 'doc', 'docx', 'pdf'],
        accept_multiple_files=True,
        key="file_uploader"
    )

    if uploaded_files:
        st.markdown(f"已上传 **{len(uploaded_files)}** 个文件")

        file_info_df = pd.DataFrame([
            {"文件名": f.name, "类型": f.type, "大小": f"{f.size / 1024:.1f} KB"}
            for f in uploaded_files
        ])
        st.dataframe(file_info_df, use_container_width=True, hide_index=True)

        if st.button("🔍 解析所有文件", type="primary", use_container_width=True):
            progress_bar = st.progress(0)
            status_text = st.empty()

            with st.spinner("正在解析文件,请稍候..."):
                total_added = 0
                duplicate_count = 0

                for i, file in enumerate(uploaded_files):
                    status_text.text(f"正在解析第 {i + 1}/{len(uploaded_files)} 个文件:{file.name}")

                    text, method = extract_text_from_file(file, ocr_method)

                    if len(text) > 10:
                        resume_parts = split_resumes_by_keywords(text)
                        for part in resume_parts:
                            info = parse_resume_with_ai(part)
                            if info and "error" not in info:
                                info['source_file'] = file.name
                                info['识别方式'] = method

                                added, sid, sname = add_resume_with_dedup(info)
                                if added:
                                    total_added += 1
                                else:
                                    duplicate_count += 1
                                    st.warning(f"⚠️ 跳过重复:{sname}(学号:{sid})")
                            elif info and "error" in info:
                                st.error(f"解析 {file.name} 失败:{info['error']}")
                    else:
                        st.warning(f"{file.name} 提取到的文字过短,可能无法有效解析")

                    progress_bar.progress((i + 1) / len(uploaded_files))

                status_text.text("解析完成!")
                progress_bar.empty()

                if total_added > 0:
                    st.success(f"✅ 成功解析 **{total_added}** 份新简历")
                    if duplicate_count > 0:
                        st.info(f"📊 已自动跳过 {duplicate_count} 份重复简历")
                else:
                    st.warning("未添加新简历(所有文件都已存在或解析失败)")

with col_paste:
    st.markdown("### 📋 或直接粘贴文本内容")
    pasted_text = st.text_area(
        "在此处粘贴简历文本内容(支持任意文本格式)",
        placeholder="请粘贴简历内容...",
        height=200
    )

    if pasted_text:
        st.info(f"已接收到文本内容,长度:{len(pasted_text)} 字符")
        if st.button("🔍 解析粘贴的文本", key="parse_pasted"):
            with st.spinner("正在解析粘贴的文本..."):
                resume_parts = split_resumes_by_keywords(pasted_text)
                total_added = 0
                duplicate_count = 0

                for part in resume_parts:
                    info = parse_resume_with_ai(part)
                    if info and "error" not in info:
                        info['source_file'] = "粘贴文本"
                        info['识别方式'] = "文本粘贴"

                        added, sid, sname = add_resume_with_dedup(info)
                        if added:
                            total_added += 1
                        else:
                            duplicate_count += 1
                            st.warning(f"⚠️ 跳过重复:{sname}(学号:{sid})")

                if total_added > 0:
                    st.success(f"✅ 成功解析 **{total_added}** 份新简历")
                    if duplicate_count > 0:
                        st.info(f"📊 已自动跳过 {duplicate_count} 份重复简历")
                else:
                    st.warning("未添加新简历(所有内容都已存在或解析失败)")

# ==================== 第二层:解析结果 + 手动编辑 + 词云分析 ====================
if st.session_state.resume_data:
    st.markdown('<div class="section-title">📊 解析结果</div>', unsafe_allow_html=True)
    st.markdown('<div class="section-divider"></div>', unsafe_allow_html=True)

    col_edit, col_empty = st.columns([1, 3])
    with col_edit:
        edit_mode = st.toggle("✏️ 手动编辑模式", value=st.session_state.edit_mode)
        if edit_mode != st.session_state.edit_mode:
            st.session_state.edit_mode = edit_mode
            st.rerun()

    if st.session_state.edit_mode:
        st.info("📝 **编辑模式已开启**:双击表格中的单元格可直接修改内容,修改后点击下方【保存修改】按钮")

    df = pd.DataFrame(st.session_state.resume_data)
    display_columns = ['姓名', '学号', '专业', '联系方式', '优势特点']
    available_cols = [col for col in display_columns if col in df.columns]
    display_df = df[available_cols].copy()
    display_df.index = display_df.index + 1

    if st.session_state.edit_mode:
        edited_df = st.data_editor(
            display_df,
            use_container_width=True,
            num_rows="dynamic",
            key="resume_editor"
        )

        col_save_edit, _ = st.columns([1, 5])
        with col_save_edit:
            if st.button("💾 保存修改", type="primary", use_container_width=True):
                # 直接更新原数据
                for idx in range(min(len(edited_df), len(st.session_state.resume_data))):
                    for col in available_cols:
                        new_val = edited_df.iloc[idx][col]
                        if pd.notna(new_val) and str(new_val).strip():
                            st.session_state.resume_data[idx][col] = str(new_val).strip()
                        else:
                            st.session_state.resume_data[idx][col] = ''

                # 处理新增行
                if len(edited_df) > len(st.session_state.resume_data):
                    for idx in range(len(st.session_state.resume_data), len(edited_df)):
                        new_item = {'识别方式': '手动添加', 'source_file': '手动编辑'}
                        for col in available_cols:
                            val = edited_df.iloc[idx][col]
                            new_item[col] = str(val).strip() if pd.notna(val) else ''
                        st.session_state.resume_data.append(new_item)

                st.success("✅ 修改已保存")
                st.rerun()
    else:
        st.dataframe(display_df, use_container_width=True)

    col_stat1, col_stat2, col_stat3 = st.columns(3)
    with col_stat1:
        st.metric("📊 总简历数", len(st.session_state.resume_data))
    with col_stat2:
        if '专业' in df.columns:
            st.metric("📚 专业种类", df['专业'].nunique())
    with col_stat3:
        if '识别方式' in df.columns:
            methods = df['识别方式'].value_counts()
            st.metric("🔍 识别方式", len(methods))

    st.markdown('<div class="section-title">☁️ 词云分析</div>', unsafe_allow_html=True)
    st.markdown('<div class="section-divider"></div>', unsafe_allow_html=True)

    all_text = " ".join([str(x.get('优势特点', '')) for x in st.session_state.resume_data if x.get('优势特点')])
    if all_text:
        wc = generate_wordcloud(all_text)
        if wc:
            st.image(wc.to_array(), use_container_width=True)
    else:
        st.info("暂无足够数据生成词云")

# ==================== 第三层:智能对比分析 ====================
if st.session_state.resume_data and st.session_state.job_description:
    st.markdown('<div class="section-title">🤖 智能对比分析</div>', unsafe_allow_html=True)
    st.markdown('<div class="section-divider"></div>', unsafe_allow_html=True)

    if st.button("🚀 开始对比分析", type="primary", use_container_width=True):
        if not st.session_state.job_description:
            st.error("请先在左侧录入岗位信息")
        else:
            with st.spinner("AI 正在分析匹配度..."):
                resume_texts = []
                for info in st.session_state.resume_data:
                    resume_text = f"姓名:{info.get('姓名', '未提供')}\n"
                    resume_text += f"专业:{info.get('专业', '未提供')}\n"
                    resume_text += f"优势特点:{info.get('优势特点', '未提供')}"
                    resume_texts.append(resume_text)

                result = compare_with_ai(st.session_state.job_description, resume_texts)
                st.session_state.comparison_result = result
                st.success("✅ 对比分析完成")

    if st.session_state.comparison_result:
        st.markdown("### ⚖️ 对比分析结果")
        st.markdown(st.session_state.comparison_result)

# ==================== 第四层:导出 Excel 文件 ====================
if st.session_state.resume_data:
    st.markdown('<div class="section-title">📥 导出数据</div>', unsafe_allow_html=True)
    st.markdown('<div class="section-divider"></div>', unsafe_allow_html=True)

    current_id = st.session_state.get('current_job_id', '未指定')
    current_title = st.session_state.get('current_job_title', '未命名岗位')
    st.write(f"当前岗位:**{current_id} - {current_title}**")

    if st.button("📎 导出 Excel 报表", use_container_width=True):
        safe_id = current_id.replace('/', '_').replace('\\', '_')
        filepath = save_to_excel(st.session_state.resume_data, f"{safe_id}_{current_title}")
        st.session_state.last_exported_filename = filepath

        for job in st.session_state.job_history:
            if job['id'] == current_id:
                job['exported_filename'] = filepath
                break
        save_history(st.session_state.job_history)

        st.success(f"✅ 报表已保存到 `{filepath}`")
        with open(filepath, 'rb') as f:
            st.download_button(
                label="📥 点击下载 Excel 文件",
                data=f,
                file_name=os.path.basename(filepath),
                mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
            )

# ==================== 页脚 ====================
st.markdown("---")
st.markdown("""
<div style="text-align:center; color:#888; font-size:0.8rem;">
    📄 职选智库 Pro - 智能简历解析系统 | OCR引擎:Tesseract | AI引擎:智谱 GLM-4-Flash / GLM-4V-Flash
</div>
""", unsafe_allow_html=True)

2.4.4代码优缺点分析

CareerWiser 优缺点分析

一、优点

1. 功能完整,覆盖多个技术点

从岗位管理、文件上传、OCR识别、AI解析、去重、词云、岗位匹配到Excel导出,几乎每个模块都做出来了。

2. 界面用心,交互体验好

Streamlit用得还是很好的,侧边栏、主区域分栏、进度条、状态提示都用上了,还自己写了CSS样式。不是黑乎乎的命令行界面,演示的时候看着舒服。

3. 设计了两种OCR方式的对比

Tesseract和AI视觉模型可以切换,作为实验对比展示亮点。

4. 数据持久化做得比较完善

岗位信息同时存了JSON和TXT,删除、清空、加载都能同步更新。Excel导出时文件名带时间戳,不会覆盖。小细节考虑周到。


二、不足

1. 代码有冗余,可读性可提升

去重函数里同时比较学号和姓名,其实学号就够了;session_state初始化写了一大串,可以封装成函数。代码能跑,但可读性和维护性还有提升空间。

2. 对OCR识别错误的依赖较重

虽然加了学号清理,但Tesseract本身对模糊图片、手写体基本没办法。在实际使用使用中如果为了准确,用户还是得依赖AI视觉模型。代码没有做成本提示或识别失败后的降级处理。

3. 异常处理不够精细

很多地方是try...except然后直接返回错误信息,但没有区分是网络问题、还是图片格式等问题。看到报错也不知道是什么原因,解决起来相对麻烦。

4. 缺少缓存机制

每次上传文件都要重新调用AI解析,就比如上传10份简历,就要调用10次API。没有做本地缓存或批量处理,比较费tokens,运行速度也慢。

三、实验过程中遇到的问题和解决过程


注:这部分我在实验过程中感触还是挺深的,因为总是出错,多选一些问题讲一讲。

问题一:重复上传相同简历导致数据重复

上传了同一份简历两次,或者一个文件中包含多份相同的简历。解析结果表格中会出现多条完全相同的记录,影响统计和岗位匹配的准确性。

解决方案
实现基于学号的自动去重机制。每次添加完新简历,就检查当前数据中是否已存在相同学号的记录。如果有则跳过添加并给出提示,没有就正常添加。
设计去重逻辑我考虑到的是只比较学号,不比较姓名,因为学号是每个人独有的,而姓名可能重名。

问题二:岗位编号自动递增导致编号混乱

程序最初的设计使用自动递增的数字作为岗位编号(1、2、3...)。但点击某条历史记录加载岗位时,这条历史记录的岗位编号会变成最新数字,导致原本的编号丢失。例如,先保存了岗位1,再保存岗位2,点击岗位1后编号却显示为2,造成混淆,影响判断。

解决方案
改为手动输入固定编号(如 JOB-001),每个岗位的编号由用户自定义,同时保存时检查编号是否有重复。这样每个岗位有固定的编号,不会因为加载操作而改变。编号形式也更加灵活,用户可以按自己的规则命名。
同时我为了更符合一个程序开发的人性化设置,后来也加入了点击历史记录会在对应的区域加载出对应的信息,我认为这样会有更好的用户体验。

问题三:删除岗位后文本文件未同步更新

系统同时使用 job_history.json(程序读取)和 saved_jobs.txt(备份文本文档存储岗位信息)。但我最初的设计中,只有保存岗位时会同步更新文本文件,删除岗位或清空所有历史时只更新了 JSON 文件,导致两个文件内容不一致。

解决方案
将文本文件的同步逻辑统一放到 save_history() 函数中。每次调用 save_history() 保存 JSON 文件后,自动调用 sync_jobs_to_txt() 重新生成文本文件。这样无论增、删、改、清空操作,两个文件始终保持同步。

问题四:AI 解析的优势特点字段过于冗长

最开始在prompt提示词设置没有写太完善,AI 解析简历时,有时会把原文中的一大段描述直接复制到"优势特点"字段,导致表格显示过长,影响阅读体验。

解决方案
在 AI 解析的 prompt 中明确要求对优势特点进行概括总结,限制在 50 字以内。同时添加规则:不要原文照抄,要简洁、概括、精炼。这样 AI 会主动压缩信息,提取最核心的关键词。(但是这里也让我觉得AI的文本概括能力确实值得去优化,真的需要改许多提示词才能让它概括个差不多)

问题五:OCR 识别数字时字母与数字混淆

问题描述
Tesseract 识别图片中的数字时,经常把 0 识别成 O、1 识别成 l、5 识别成 S、8 识别成 B,或者经常把一个数字提取成其它数字,导致学号提取错误。

解决方案
这其实是传统 OCR 引擎的固有问题——它本质上是“模式匹配”,只能根据像素形状判断字符,无法理解语义。当图片中的数字字体与字母形状相似,或图片质量不够清晰时,出现混淆是正常现象。
1. 图片预处理:识别前对图片进行灰度化、对比度增强、放大处理,从源头提升图像质量,尽可能让数字更清晰、更标准,减少混淆的发生。

2. 学号后处理清理:针对混淆问题,建立字母→数字映射表(O→0、l→1、S→5、B→8),在 OCR 识别完成后自动对结果进行校正,统一转为纯数字。这是一种“容错”设计——既然无法完全避免错误,就在错误发生后进行补救。

注:1.和2.所讲的方法代码通过AI生成和我自己理解的基础上优化,实践发现,确实比较有效,但我觉得它也不能做到高准确度,于是我想起可以再调用智谱AI的视觉模型进行提取,准度比OCR高,还相当于做了个对比小实验。

3. 引入 AI 视觉模型作为备选
考虑到 Tesseract 的局限性,系统增加了 GLM-4V-Flash 作为第二种图片识别方式。该模型基于深度学习,能真正“理解”图片中的文字内容,而不是简单的像素匹配,因此几乎不会出现数字字母混淆的问题。用户可根据图片质量自行选择使用哪种方式。

传统 OCR 的识别错误是正常现象,无法彻底根除。通过预处理、后处理和提供备选方案这三层优化,既提升了 Tesseract 的表现,也给用户提供了更优的选择。

问题六:编辑模式下删除行后数据残留

用户在编辑模式下删除某条简历后保存,被删除的记录仍然存在于数据中,导致表格中出现重复数据。原edited_df 的行数变少,但原 resume_data 中后面的数据没有被清除,导致数据残留。(也就是删了几行,最后的那行数据就会被添加几遍)

解决方案
将逻辑改为根据编辑后的表格完全重建 st.session_state.resume_data。遍历编辑后的每一行,通过学号匹配原数据中的对应条目,匹配不上的作为新增行处理。删除的行在重建过程中会被自动丢弃。




代码托管到Gitee——仓库地址


实验演示视频

四、华为云部署与运行

4.1 部署方案概述

为了满足作业中“在华为云上运行”的要求,本程序最终部署到华为云弹性云服务器(ECS)上。

整体方案是:在华为云购买一台 Ubuntu 24.04 系统的 ECS 服务器,绑定弹性公网 IP。服务器上创建 Python 虚拟环境,安装 Streamlit 及相关依赖库。代码上传后,通过 Streamlit 启动 Web 服务,并开放 8501 端口供外网访问。

图片识别方面,程序继续调用智谱 AI 的 GLM-4V-Flash 视觉模型(因为用华为云大模型感觉有点麻烦没那个必要)

岗位管理、数据存储、Excel 导出等核心功能保持不变,仅简化了文件处理逻辑(只保留图片格式),删除了 Tesseract OCR识别和文本粘贴功能,使程序更聚焦于华为云环境下的稳定运行。


4.2 部署流程

4.2.1 购买弹性云服务器

  1. 登录华为云官网,进入“弹性云服务器 ECS”
  2. 购买配置:2核CPU、4GB内存、40GB系统盘
  3. 操作系统:Ubuntu 24.04
  4. 绑定弹性公网IP:1.92.94.120
  5. 安全组开放端口:22(SSH)、8501(Streamlit)

4.2.2 实验过程

实验过程部分截图及解释如下

在华为云控制台确认服务器已创建成功。
03d31206920d542376253e72ceefabee

在本地电脑的命令提示符(CMD)中执行 ssh root@1.92.94.120,输入密码后成功登录服务器。
df5397955569a92932671f2c79871e90

在华为云控制台的安全组中添加入方向规则,允许 TCP 协议的 8501 端口被外网访问。源地址设置为 0.0.0.0/0,表示允许所有 IP 访问。这是 Streamlit 应用的默认端口,不开放则无法通过浏览器访问。
80945ba7adef874afdc683d8fc031c17

在服务器终端中执行 python3 -m venv /root/myenv 创建虚拟环境,然后执行 source /root/myenv/bin/activate 激活。激活后命令行前面出现 (myenv) 提示符,表示当前处于虚拟环境中。虚拟环境可以隔离项目依赖,避免与系统 Python 环境冲突。
f8457fa656a5d4d4c2c690f258da587f

在虚拟环境中执行 pip install 安装所有需要的 Python 库,包括 streamlit、pandas、pillow、wordcloud、openpyxl、zhipuai 等。安装成功后,执行 streamlit --version 验证,显示版本号为 1.58.0,说明安装成功。
85176db232aae17364fbb3e28e0de01b

执行启动命令,用浏览器打开 External URL 即可访问应用。
image

4.3 遇到的问题及解决方案

问题一:服务器Python环境受保护,无法直接pip安装

解决方案
Ubuntu 24.04默认开启了PEP 668保护机制,非虚拟环境下直接使用pip install会报错
创建Python虚拟环境,所有依赖在虚拟环境中安装:

python3 -m venv /root/myenv
source /root/myenv/bin/activate
pip install streamlit pandas pillow wordcloud openpyxl zhipuai

问题二:zhipuai库依赖缺失,运行程序时报错ModuleNotFoundError: No module named 'sniffio'。

解决方案
手动安装缺失的依赖:
pip install sniffio anyio httpcore httpx

问题三:词云无法显示中文
虚拟服务器上没有中文字体,词云生成的图片中中文显示为方框,只显示英文。

解决方案
是可以在服务器终端执行命令实现的,但时间紧、任务重,而且词云功能在源代码也展示出来了,华为云上部署运行就不展示了哈(明白可以安装对我来说就够了)。

4.4华为云部署演示视频

注:华为云上运行我是用的是源代码简略版的(也就是省去一些功能,主要就保留了AI视觉模型识别,还是岗位信息的有关功能,主要是为了快点做、熟悉华为云部署和终端执行这些操作,功能上就简化了,希望老师理解哈。

华为云上运行视频

五、课程总结与感想

5.1 课程知识点总结

第一章:Python 语言概述

Python 是一种跨平台、解释型、面向对象、动态数据类型的高级语言。跨平台意味着同一份代码可以在各种平台上直接运行。解释型是指代码先执行后编译(老师上课蛋炒饭、盖浇饭举例说明还挺形象的)。动态数据类型是指变量不用提前声明类型,赋值时解释器自动判断。Python 的应用领域广,Web 开发、人工智能、数据分析、爬虫等都能用到。


第二章:Python 基本语法

Python 用缩进(通常是 4 个空格)来区分代码块,不用花括号。变量赋值直接用 a = 10,不需要写类型。注释单行用 #,多行用 """。基本数据类型有整数(int)、浮点数(float)、布尔值(bool)、字符串(str)。类型转换用 int()float()str() 等。算术运算符有 + - * / // % **,比较运算符有 == != < > <= >=,逻辑运算符有 and or not


第三章:条件与循环

条件语句用 if-elif-else 结构。循环有两种:for 遍历可迭代对象(列表、字符串、range() 等),while 基于条件重复执行。break 跳出整个循环,continue 跳过本次循环进入下一次。range() 生成整数序列,range(10) 生成 0-9,range(1,10,2) 生成 1,3,5,7,9。


第四章:序列

列表[],可变、有序、元素可重复。常用操作:append()remove()pop()sort()。列表推导式 [表达式 for 变量 in 可迭代对象] 可快速生成列表。

元组(),不可变、有序、元素可重复。创建后不能修改,访问速度比列表快。

字典{},存储键值对,可变、无序、键不可重复。通过键快速查找值,常用 dict[key] = value 赋值,dict.items() 遍历。

集合也用 {},但元素不可重复,可变、无序。主要用于去重和集合运算(交集 &、并集 |、差集 -)。

序列通用操作:索引、切片 [start:end:step]len()in 判断存在。


第五章:字符串与正则表达式

字符串常用方法:split() 分割、join() 拼接、find() 查找、strip() 去首尾空白、lower()/upper() 大小写转换。格式化字符串推荐 f-string,如 f"姓名:{name}"

正则表达式用于模式匹配,Python 用 re 模块。常用元字符:^ 开头、$ 结尾、\d 数字、\w 字母数字下划线、* 0次或多次、+ 1次或多次、? 0次或1次、{n} 恰好n次、[] 字符集合、| 或、() 分组。常用函数:re.match() 从头匹配、re.search() 搜索第一个、re.findall() 找所有、re.sub() 替换、re.split() 分割。


第六章:函数

函数用 def 定义,把重复代码封装起来。参数类型:位置参数(按顺序传)、关键字参数(指定参数名)、默认参数(调用时可省略)、可变参数(*args 接收多个位置参数,**kwargs 接收多个关键字参数)。函数用 return 返回值,没有则返回 None。变量作用域分局部和全局,修改全局变量需 global 声明。lambda 是匿名函数,适合简单的一次性使用场景。


第七章:面向对象

类是对象的蓝图,对象是类的实例。用 class 类名: 定义类,__init__() 是构造方法,创建实例时自动执行,第一个参数必须是 self。访问限制:单下划线 _name 表示 protected,双下划线 __name 表示 private。

封装:将数据和方法包装在类内部,对外只暴露必要接口。

继承:子类用 class 子类(父类): 继承父类属性和方法,可重写父类方法,用 super() 调用父类方法。Python 支持多重继承。

多态:同一方法名在不同类中有不同实现。

面向对象三要素:封装、继承、多态。


第八章:模块与异常处理

模块是 .py 文件,用于组织代码。导入方式:import 模块名import 模块名 as 别名from 模块名 import 函数名if __name__ == '__main__': 让测试代码只在直接运行时执行,被导入时不跑。包是包含多个模块的文件夹,必须有 __init__.py

异常处理用 try-except-else-finallytry 放可能出错的代码,except 捕获异常,else 没出错时执行,finally 无论是否出错都执行。常见异常:ValueErrorTypeErrorFileNotFoundErrorKeyError。可用 raise 手动抛出异常。


第九章:文件及目录操作

文件操作流程:open() 打开 → 读/写 → close() 关闭。模式:'r' 只读、'w' 覆盖写、'a' 追加、'b' 二进制。推荐用 with open() as f:,自动关闭文件。读方法:read()readline()readlines(),写方法:write()writelines()seek() 移动文件指针。

目录操作用 os 模块:os.getcwd() 当前目录、os.listdir() 列出内容、os.mkdir() 创建目录、os.path.exists() 判断存在、os.path.join() 拼接路径、os.walk() 遍历目录树。


第十章:网络爬虫技术

爬虫是自动从网上抓取数据的程序。流程:发 HTTP 请求 → 获取响应 → 解析 HTML → 提取数据 → 存储。Python 常用 requests 库(requests.get()requests.post())。

反爬虫应对:加 User-Agent 伪装浏览器、用代理 IP、加请求延时。解析 HTML 常用 BeautifulSoupfind()find_all()、CSS 选择器)或 lxml(XPath)。robots.txt 是网站的爬虫协议,应遵守。爬虫有法律风险,不要爬取隐私数据或对网站造成压力。Scrapy 是流行的爬虫框架。

5.2 课程感想

刚开始接触Python的时候,还是一个编程小白(能力一般),C语言的代码大部分都要依靠AI完成,随着课程的循序渐进,我也懂得了编程的魅力和Python的高级。认真对待每次的实验报告和最后的实践作业,做实践作业的过程中也让我明白了许多点,这里讲几条感触深的:
1:AI的使用还是要依靠人的意志,不能将自身完全依附AI,它能解答问题,却没有像人的创造力,最后的实践作业思路可以说是我独立相处的(也看了几篇CSDN文章找了找灵感,但没照抄或者按照它们的思路),只是对OCR识别好奇加上胡思乱想,做这么一个系统的雏形吧,AI能帮到我的就是什么呢,我想做Web界面,问它怎末开发等等,糊里糊涂就做完了,有些瑕疵但也算满意吧,确实好了好长时间调试
2.多去尝试未知,这句话我会带入未来的大学生活中并践行的,大学试错是没有成本的(或者挺低的),就是编程一样,错了就再来,从哪跌倒从哪爬起来。
3.多多总结问题,错了要多去思考,为什么会错,下次能不能注意到这个点,心里反复默念。
4.Python这个语言的学习和C语言学习带给我的感觉是不一样的,但是学C更多是学语法和背程序,挺枯燥的反正,Python给我的感觉主要是两个字——高级,几句话能顶好长一段C,也能在学习中感受到乐趣,比如游戏开发、做爬虫、程序调用AI模型,这都是以前在C中没尝试过的。
祝王老师身体健康、万事如意!

5.3课程建议

1.课程还是挺看重华为云的实操的(虽然是选做),我最后也是实践了下,加深了感受,建议以后老师的课程能带着大家把这个流程过一遍,别光说哈。
2.老师敲代码过程中可以加入讲解,敲到哪觉得重要就讲两句,不然底下只是一味照抄。
3.加分的环节可以多点,我暂时也想不到,老师自己可以试着开发开发。
4.老师的讲课风格还挺喜欢的,有带入感,建议如果改课程某些任务环节后但不要改语言的风格哈。

六、参考资料

Streamlit 官方文档
智谱AI开放平台文档
华为云弹性云服务器 ECS 文档
码云 Gitee
Tesseract-OCR中文识别全解析
《Python程序设计基础》学习指导

posted @ 2026-06-15 21:36  南极熊2  阅读(9)  评论(0)    收藏  举报