AI通讯录源码解析:Python中文信息抽取、去重与检索流水线
简介:这份毕业设计资料包围绕Python与人工智能技术构建智能联系人管理系统,面向需要完成软件类毕设、学习桌面应用开发与AI功能集成的学生及初学者。核心功能覆盖联系人增删改查、按姓名电话邮箱检索分类,并借助拼音工具、云端Key配置等实现智能化查询与推荐,配套开发文档、使用说明与可执行程序,便于在非开发环境中直接体验。压缩包共50个文件,约141.21MB,以py源码、pyc缓存、ui界面文件、html页面与xml配置为主,另含xlsx数据表、txt与dat数据文件、doc和pdf说明文档及jpg、png界面素材和exe可执行程序,结构上区分源程序、可执行程序与资源目录。已有614人学习,适合对照源码理解界面布局、业务逻辑与数据存储方式,并参考文档完成环境配置、功能扩展与答辩展示。
1. 一套能跑的 AI 通讯录源码:先看清它到底替你干了哪几件事
解压一份「毕业设计 Python 源代码 + 开发文档」之后,多数人的动作是找入口文件、装依赖、点开首页看界面能不能渲染。真正卡人的地方往往不在界面:数据库没初始化、导入的 Excel 是 GBK 编码、一段微信名片式的文本贴进去,姓名栏被填成了「某某科技有限公司」的前四个字。这份 AI 智能联系人管理程序的源码,价值不在界面有多花哨,而在于它把「脏文本 → 结构化联系人 → 可检索、可去重、可回滚」这条链路拆成了几个能单独跑、单独改的模块,属于 Python 阵营里比较典型的规则 + 轻量人工智能组合,人工智能部分落在信息抽取、相似度判重和检索排序上,不依赖 GPU,也不要求你懂模型训练。
它适合三类人:拿它当毕业设计底座、需要在文档和代码之间对得上号的;手上有一堆历史通讯录和聊天记录要批量清洗的;以及想练一遍中文规则工程(正则、分词、拼音、模糊匹配)怎么串成流水线的。不适配的场景也说清楚——它不是企业级 CRM,没有权限体系、审批流和分布式部署,别指望直接上线跑生产。
2. 环境与骨架:Flask + SQLite 跑通前的四件事
先把技术栈摊开:后端 Flask + SQLAlchemy,数据库默认 SQLite,前端是 Jinja2 模板加少量原生 JS,中文处理靠 jieba,模糊匹配靠 rapidfuzz 与 pypinyin。这个选型对毕设场景几乎是默认答案——零外部服务、单文件数据库、拷到别人机器上python app.py就能起。代价是并发写入弱、全文检索能力一般,后面第 5 章会专门说这块怎么翻车。
2.1 依赖清单与虚拟环境
先把版本钉死,别用系统 Python 全局装。源码包里的requirements.txt一般只写了大版本,实际操作时按下面这份改,Flask 2.x 与 SQLAlchemy 1.4/2.x 的 API 差异会直接影响模型能不能建表:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt如果你要手动补,核心几行是这些:
# 版本不必完全一致,但 Flask 与 SQLAlchemy 要成对 Flask==2.3.3 Flask-SQLAlchemy==3.0.5 SQLAlchemy==2.0.20 jieba==0.42.1 rapidfuzz==3.4.0 # 比 fuzzywuzzy 快一个量级,且不依赖 python-Levenshtein 编译 pypinyin==0.50.0 openpyxl==3.1.2 # 导入导出 xlsx pytest==7.4.2rapidfuzz替换fuzzywuzzy是这套代码里最值得保留的一个决定:后者在 Windows 上装python-Levenshtein十有八九要 C 编译器,答辩前一天装不上依赖是经典事故。openpyxl负责 xlsx,如果历史数据是.xls,另装xlrd==1.2.0,注意 2.x 版本已经不支持 xls 了。
2.2 目录结构与启动入口
源码包的目录大致是这个形状,先扫一遍知道每块归谁管,后面要改哪一块不用满仓库找:
contact_ai/ ├── app.py # 应用工厂:建 app、注册蓝图、初始化 db ├── config.py # 数据库 URI、上传目录、去重阈值、分页大小 ├── models/ │ ├── contact.py # 联系人主表 + 电话/邮箱子表 │ └── merge_log.py # 合并日志,记录被合并掉的字段,用于回滚 ├── services/ │ ├── extractor.py # 文本 -> 结构化字段 │ ├── dedupe.py # 归一化 + 相似度 + 合并策略 │ └── search.py # 多字段加权检索 ├── views/ # 蓝图:CRUD、导入导出、检索、合并确认 ├── static/ templates/ # 前端资源与模板 ├── data/userdict.txt # jieba 自定义词典:公司名、职位词、复姓 └── tests/ # 抽取与去重的回归用例第一次启动前记得确认config.py里数据库路径是相对路径转绝对路径,sqlite:///contacts.db在不同工作目录下会生成不同的库文件,这是新手最常遇到的「我明明建表了怎么查不到」:
# config.py import os BASE_DIR = os.path.abspath(os.path.dirname(__file__)) class Config: SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(BASE_DIR, "contacts.db") SQLALCHEMY_TRACK_MODIFICATIONS = False UPLOAD_DIR = os.path.join(BASE_DIR, "uploads") # 导入的 Excel 落盘位置 DEDUPE_THRESHOLD = 0.86 # 判重阈值,第 3 章讲怎么调 PAGE_SIZE = 20建表靠一段一次性脚本,别在app.py里裸调db.create_all(),否则每次重启都会覆盖你在命令行里手改的数据:
export FLASK_APP=app.py flask shell >>> from app import db >>> db.create_all() >>> exit() python app.py # 默认 127.0.0.1:50002.3 数据模型:联系人表怎么设计才不返工
模型设计是这套源码里最值得抄的部分。通讯录的两个现实问题是「一个人有多个手机号」和「合并错了要能退回去」,所以电话必须拆子表,主表必须留软删标记和拼音冗余列:
# models/contact.py from datetime import datetime from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index from sqlalchemy.orm import relationship from app import db class Contact(db.Model): __tablename__ = "contact" id = Column(Integer, primary_key=True) name = Column(String(64), nullable=False, index=True) name_pinyin = Column(String(128), index=True) # 全拼冗余列,支撑拼音搜索 company = Column(String(128), default="") title = Column(String(64), default="") remark = Column(String(512), default="") deleted = Column(Integer, default=0, index=True) # 软删除,误合并的后悔药 created_at = Column(DateTime, default=datetime.utcnow) phones = relationship("Phone", backref="contact", cascade="all, delete-orphan") __table_args__ = (Index("idx_name_company", "name", "company"),) class Phone(db.Model): __tablename__ = "phone" id = Column(Integer, primary_key=True) contact_id = Column(Integer, ForeignKey("contact.id"), index=True) number = Column(String(20), index=True) # 归一化后的纯数字,用于判重 raw = Column(String(40)) # 原始写法,+86、空格、横线都留着 is_primary = Column(Integer, default=0)几个参数值得单独解释。name_pinyin是空间换时间:写库时算一次lazy_pinyin,查的时候直接LIKE 'zhangwei%',比每条记录现算快得多,代价是改名时要同步更新,代码里用一个@validates("name")钩子处理。Phone.raw的意义在于可追溯——归一化会把+86 138-0000-0000变成13800000000,出了问题得能看出原始输入长什么样。deleted配合MergeLog(记录 source_id、target_id、字段快照、操作时间),构成一个最小的回滚机制,去重跑批之后发现误合并,按日志反向恢复即可,不用重导数据。索引方面,idx_name_company覆盖「按姓名 + 公司」的联合查询,比单列索引省一次回表;数据量在十万级以下,SQLite 完全扛得住,别急着上 MySQL。
3. 把脏文本变结构化联系人:抽取、去重、检索三段流水线
这一章是整套代码的实质内容。流水线是三段:extractor.py负责从一段自由文本里抠出字段,dedupe.py负责判断两条记录是不是同一个人并决定怎么合,search.py负责让人能用「老张」「zhangwei」「某科技」三种方式都搜得到。三段之间用字典传递,不互相依赖数据库,这是它方便单测的关键。
3.1 信息抽取:正则兜底加中文分词的两段式
抽取的常见误区是一上来就上 NER 模型。中文姓名、手机号、邮箱这三类在名片文本里其实高度模式化,正则的准确率远高于小模型,只有在公司名和职位上才需要分词和词典。所以这里的顺序是:先扫强模式(手机号、邮箱、URL),把命中的片段从文本里挖掉,剩下的部分再交给 jieba 切词做字段归属。
# services/extractor.py import re import jieba from pypinyin import lazy_pinyin # 前后加负向断言,避免从 18 位身份证号里抠出 11 位手机号 PHONE_RE = re.compile(r"(?<!\d)(?:\+?86[-\s]?)?(1[3-9]\d{9})(?!\d)") EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") URL_RE = re.compile(r"https?://[^\s,。;]+") # 按长度倒序,保证「总经理」先于「经理」命中 TITLE_WORDS = sorted( ("总经理", "副总经理", "总监", "经理", "主管", "工程师", "负责人", "CEO", "CTO"), key=len, reverse=True) jieba.load_userdict("data/userdict.txt") # 公司全称、复姓、行业词 def extract(text: str) -> dict: text = text.replace("\u3000", " ").strip() phones = list(dict.fromkeys(PHONE_RE.findall(text))) # 保序去重 emails = list(dict.fromkeys(EMAIL_RE.findall(text))) urls = URL_RE.findall(text) # 挖掉强模式片段,减少后续分词干扰 rest = PHONE_RE.sub(" ", text) rest = EMAIL_RE.sub(" ", rest) tokens = jieba.lcut(rest) title = next((w for w in TITLE_WORDS if w in rest), "") company = next((t for t in tokens if t.endswith(("公司", "集团", "研究院", "事务所"))), "") return {"name": "", "phones": phones, "emails": emails, "company": company, "title": title, "urls": urls, "raw": text}逻辑上有三处细节值得说。findall在带分组的正则里只返回括号内那一组,所以PHONE_RE外层的前缀和断言不会污染结果,拿到的永远是干净的 11 位。(?<!\d)和(?!\d)是必须的,没有它们,一串订单号或身份证号会被误切成手机号,这类错误在批量导入时能产生几百条垃圾记录。TITLE_WORDS排序那行看着不起眼,实际是血泪经验:不排序的话"经理" in "总经理"会先命中短词,所有总经理都被记成经理。
姓名这一栏故意留空,因为从自由文本里判断哪个词是人名,规则很难做稳,代码里交给两个信号兜底:一是文本开头 2~4 个字符且不在词典里,二是复姓词表(欧阳、司马、上官等)优先匹配。宁可空着让人工补,也别乱填——空字段用户会补,错字段用户不会看。
data/userdict.txt一行一个词,格式是词 词频 词性,词频可省略。加公司简称、行业术语、常见英文品牌,抽取准确率能明显上一档。
3.2 去重合并:编辑距离加拼音加手机号归一化
判重是这套代码里最需要调参的部分。直接用fuzz.ratio(name)做全表两两比较有两个问题:中文同音字(张伟 / 张玮)算出来相似度很低,而「张伟」和「张伟明」算出来又很高,一刀切必然误判。这里的做法是分层:手机号相同直接判重,这是硬信号,准确率接近 100%;没有手机号或号码不同时,才走加权打分。
# services/dedupe.py import re from rapidfuzz import fuzz from pypinyin import lazy_pinyin def norm_phone(n: str) -> str: digits = re.sub(r"[^\d]", "", n) # +86 / 0086 前缀统一掉,只留后 11 位 if digits.startswith("0086"): digits = digits[4:] elif digits.startswith("86") and len(digits) == 13: digits = digits[2:] return digits def name_score(a: str, b: str) -> float: pa, pb = "".join(lazy_pinyin(a)), "".join(lazy_pinyin(b)) # 汉字相似度与拼音相似度取较大值,兼顾同音字与形近字 return max(fuzz.ratio(a, b), fuzz.ratio(pa, pb)) / 100 def is_duplicate(x: dict, y: dict, thr: float = 0.86) -> bool: if set(map(norm_phone, x["phones"])) & set(map(norm_phone, y["phones"])): return True s = 0.6 * name_score(x["name"], y["name"]) \ + 0.4 * fuzz.ratio(x.get("company", ""), y.get("company", "")) / 100 return s >= thr参数怎么定:姓名权重 0.6、公司权重 0.4,是因为通讯录里同名不同司的情况远多于同司不同名;阈值 0.86 是拿 20 条人工标注过的样本试出来的经验值,往下调到 0.70 会把「张伟」和「张伟明」合成一条,往上抬到 0.95 又会让「张玮」和「张伟」漏判。真正做批量导入时,不要直接执行合并,先跑一遍产出候选对,人工点确认,代码里views/merge.py就是干这个的。
合并策略是字段级覆盖而不是整条替换:目标记录的非空字段保留,源记录的非空字段只填目标为空的坑,冲突字段进MergeLog等人工裁决;phones走并集并给最早的号码标记is_primary=1。这样做的好处是合并永远不丢信息,最坏情况是字段冗余,而冗余可以事后清理,丢了就找不回来。
3.3 智能检索:多字段加权与拼音冗余列
检索的设计目标很朴素:输入「老张」「zhangwei」「某科技」都要能命中。SQLite 自带的LIKE '%kw%'在前缀通配符下用不上索引,几万条还行,几十万条就明显拖。代码里给了两条路,按数据量选。
数据量小的直接用加权打分,把命中字段按重要性累加,再排序:
# services/search.py WEIGHTS = {"name": 5, "name_pinyin": 4, "company": 2, "title": 2, "remark": 1} def score(row, kw: str) -> int: kw = kw.lower() return sum(w for field, w in WEIGHTS.items() if kw in (getattr(row, field) or "").lower())数据量大就上 FTS5 虚拟表,用触发器同步:
-- 四列进全文索引,拼音列一起塞进去,中文按字切分够用 CREATE VIRTUAL TABLE contact_fts USING fts5( name, name_pinyin, company, remark, content='contact', content_rowid='id', tokenize='unicode61' ); CREATE TRIGGER contact_ai AFTER INSERT ON contact BEGIN INSERT INTO contact_fts(rowid, name, name_pinyin, company, remark) VALUES (new.id, new.name, new.name_pinyin, new.company, new.remark); END;提醒一句tokenize='unicode61':它对中文是按单字切分的,搜「科技」会变成「科」AND「技」,召回率高但精度一般。中文场景更稳的办法是老老实实查name_pinyin列,或者在写库时把公司名也切一份拼音存进去,代价是存储翻倍。索引建好后记得把已有数据补进去,FTS5 不会自动回填历史行,这个坑在第 5 章会展开。
4. 开发文档怎么读:从用例编号一路对到源码文件和接口
配套的开发文档说明不是拿来通读的,它是用来做对齐的。答辩被问「你这个去重功能怎么实现的」,答不上来往往不是不会,是文档里的用例编号、模块名和源码文件名三者没对上。正确用法是拿文档当索引,反查代码。
4.1 需求用例到源码模块的映射表
文档的需求分析章节一般有一张用例表,把它抄成下面这种三列对照,答辩前扫一遍就够了:
| 用例编号 | 功能点 | 对应源码位置 |
|---|---|---|
| UC-01 | 联系人增删改查 | views/contact.py+models/contact.py |
| UC-02 | 批量导入 Excel | views/import_.py+services/extractor.py |
| UC-03 | 重复联系人识别 | services/dedupe.py: is_duplicate |
| UC-04 | 合并与回滚 | views/merge.py+models/merge_log.py |
| UC-05 | 关键字与拼音检索 | services/search.py |
| UC-06 | 标签分类统计 | views/stats.py |
这张表还有个实际用处:改代码时先看它落在哪个用例下,改完顺手更新表里的行号,文档和代码不会越走越远。功能点编号一旦定下来就别改,测试用例、接口文档、答辩 PPT 都引用同一套编号,对不上就是自找麻烦。
4.2 接口文档与数据库设计的对应关系
文档里的接口说明通常长这样,一个接口列出请求方法、路径、入参和返回示例:
| 项 | 内容 |
|---|---|
| 方法/路径 | POST /api/contact/import |
| 入参 | file(xlsx,≤5MB)、dry_run(0/1) |
| 返回 | {"total": 120, "inserted": 98, "dup_candidates": 22} |
| 落库表 | contact、phone、import_batch |
| 异常码 | 4001 文件格式错、4002 缺表头、4003 超行数上限 |
看接口要看三样东西:入参的类型和上限、返回里有没有dry_run这类预演开关、异常码和数据库约束是否一致。比如phone.number有唯一索引,导入时就必须在服务层先做一次归一化去重,否则接口会抛数据库层的IntegrityError,前端拿到 500 一脸懵。dry_run这个设计要保留,它是整份代码里最实用的一个开关,批量操作先预演再落库,出问题只损失一次查询时间。
数据库设计章节的 ER 图与第 2 章的模型代码是一一对应的,看的时候重点核对外键的ON DELETE行为:phone表用cascade="all, delete-orphan",删联系人连带删电话;但merge_log里的source_id不能加级联,否则回滚日志会被一起清掉,这个细节文档里不一定写,得自己看代码确认。
5. 避坑与排查:五个能让演示当场翻车的地方
这一章是我自己踩过的,按现象、原因、解决三段写,遇到对得上的直接照做。
5.1 中文姓名被抽成公司名的前几个字
现象:批量导入后,一堆联系人的姓名是「北京某某科技」或「某某网络」,人工改到崩溃。原因是抽取顺序错了——先做分词再剔除公司名后缀,jieba.lcut会把长公司名切成若干片段,取首片段当姓名就必然出错。解决办法是调整优先级:先把endswith(("公司","集团","研究院"))的整段挖掉,再在剩余文本里找姓名候选,同时加一条硬规则——姓名长度限定 2~4 字且不含「市」「省」「区」「科技」「网络」等字。改完再跑一遍那批脏数据,错误率能从三成降到个位数。
5.2 批量导入到一半报 database is locked
现象:Excel 导入 500 条左右必崩,日志里是sqlite3.OperationalError: database is locked。原因是 SQLite 默认的写锁粒度是整个库文件,代码里又对每条记录单独db.session.commit(),写入频率过高,加上 Flask 开发服务器默认多线程,两个请求撞上就锁死。解决办法有三层:把逐条提交改成每 200 条批量commit();连接串加?timeout=15;导入接口加一把进程内的threading.Lock,保证同一时刻只有一个人在导。生产环境另说,但毕设演示这样足够稳。
5.3 去重阈值调低之后,两个真人被合成一个
现象:把阈值从 0.86 调到 0.70 想让召回率高一点,结果「张伟」和「张伟明」被判成同一人,合并后电话还串了。原因是加权分数里公司权重占 0.4,两个人同属一家公司时,即使姓名相似度只有 0.75,加权后也能过线。解决办法是加两条否决规则:姓名编辑距离低于 0.6 直接判不重复;姓名长度差大于等于 1 且短名是长名前缀时,强制要求手机号相同才算重复。阈值本身别乱动,要提召回就补规则,不要动全局参数。
5.4 jieba 自定义词典明明加了却不生效
现象:userdict.txt里写了「某某研究院」,切出来还是散的。原因通常是两个:一是load_userdict的路径是相对路径,启动目录不同就加载不到,且失败时只打一行 warning 不抛异常;二是加载顺序在jieba.lcut之后,第一次调用已经初始化了词典。解决办法是把路径改成基于__file__的绝对路径,把load_userdict提到模块顶层第一行,并在启动时打印一次len(jieba.dt.FREQ)确认词典条目数变了——这行打印是排查这类问题的黑匣子。
5.5 FTS5 索引建好了,老数据搜不到
现象:新建contact_fts之后,新加的联系人能搜到,之前导入的几万条一条都搜不出来。原因很直接:FTS5 是外部内容表,content='contact'只是声明数据来源,不会自动回填历史行,触发器也只对之后的增删改生效。解决办法是建完索引立刻执行一次重建:
INSERT INTO contact_fts(contact_fts) VALUES('rebuild');这条命令会按content指定的表全量重扫一遍。以后每次改表结构或者批量导入完,都补一句rebuild,别指望触发器兜底。
6. 加一条回归用例:让 AI 抽取不再靠玄学
规则工程最怕的是「改一处、坏三处」,而且坏得不明显——你调了职位词表,姓名抽取的准确率悄悄掉了五个点,等到答辩演示才发现。所以这套代码里最有长期价值的东西不是抽取逻辑本身,而是tests/目录。我的做法是维护一小份金标准集:20 条左右脏文本,覆盖名片、聊天记录、Excel 备注、带英文的签名档,每条人工标注好期望的手机号、公司、职位,然后写成参数化用例。
# tests/test_extractor.py import pytest from services.extractor import extract CASES = [ # (输入文本, 期望手机号列表, 期望公司) ("张伟 总经理 某某科技有限公司 13800000000", ["13800000000"], "某某科技有限公司"), ("李工,+86 139-0000-0000,某研究院算法组", ["13900000000"], "某研究院"), ("联系我 13800000000 转 8012,邮件 a@b.com", ["13800000000"], ""), ] @pytest.mark.parametrize("text,phones,company", CASES) def test_extract(text, phones, company): r = extract(text) assert r["phones"] == phones # 顺序与去重同时校验 assert r["company"] == company跑法是pytest tests/ -q,几十毫秒结束。关键是这套用例的用法:每次改TITLE_WORDS、userdict.txt或者正则,先跑一遍,全绿再继续;出现红的那一刻,你就知道自己改动了哪条边界,而不是等两周后从导入结果里倒推。规范化写法上有个小技巧,断言用列表而不是集合,能顺带校验「保序去重」这个行为有没有被破坏。
金标准集要持续加,但别贪多。我的习惯是每发现一次线上抽取错误,先把那条原文加进CASES并标上期望值,确认用例是红的,再去改代码让它变绿——顺序反了就等于没加。这套流程唯一一次让我后悔药吃够的,是某次图省事直接改了正则没跑测试,把(?<!\d)的负向断言删了,结果导入 3000 条订单数据,从订单号里抠出 800 多个假手机号,清理花的时间是写测试的二十倍。从那以后我每次动抽取相关的代码,都强制先跑一遍pytest tests/ -q,绿灯才允许提交。希望帮到你。
本文还有配套的精品资源,点击获取