A Practical Record of AI-Assisted Code Duplication in Monorepos and the Defense Methodology
作者: 林慕空日期: 2026年7月性质: 工作备忘录(以论文形式组织)
作者注: 本文不是传统学术论文,而是一份工作日志。它记录了我们在开发 ppt-bot-v2(一个10包单体仓库的PDF智能处理系统)过程中,与AI编程助手(Codex CLI)协作时遭遇的代码重复问题、排查过程、修复方案,以及最终建立的多层防护体系。所有代码示例、git提交记录和错误日志均来自真实项目。
摘要
大语言模型(LLM)驱动的AI编程助手在提升开发效率的同时,也引入了一个隐蔽但严重的问题:AI代理倾向于为每个子任务独立实现功能逻辑,而非复用项目中已有的共享组件。本文记录了一个真实案例——在一天内(2026年7月5日至7日),AI助手为一个10包单体仓库生成了超过13000行代码,其中包含了至少6类、约300行的跨包重复代码。我们通过三个阶段的排查和修复,最终建立了包含文档约束、行为规则、自动化检测和架构设计四个层次的防护体系,将跨包重复率从67%降至接近0%。
关键词: AI编程助手;代码重复;单体仓库;Codex;代码质量
1 问题是怎么发生的
1.1 项目背景
ppt-bot-v2 是一个PDF智能处理系统,核心功能是将中文PDF教材自动转换为PowerPoint演示文稿,并提取结构化知识(术语、关系、思维导图、学习路径等)。
2026年7月5日,我们决定将原本集中在 backend/apps/pipelines/services.py 中的1179行单体服务代码,拆分为10个独立的Python包:
1 2 3 4 5 6 7 8 9 10 pdf-chapter-splitter # 章节检测与拆分pdf-image-extractor # 图像提取ppt-generator # PPT生成rag-indexer # 语义搜索索引summary-generator # 摘要生成knowledge-base # 知识提取(术语、关系、卡片)knowledge-graph # 知识图谱mindmap-generator # 思维导图study-route # 学习路径规划ppt-common # 共享工具库 这个拆包过程由AI助手(Codex CLI)主导完成。git记录显示,单次提交 4d7c5b1 就新增了106个文件、13428行代码。
1.2 重复代码的种子
问题出在拆包的方式上。AI助手在处理每个包时,采用的是"自包含"策略——为每个包独立实现它所需的所有功能,而不是先检查其他包是否已经有了可以复用的组件。
这就像让一个人同时为10个房间装修,他没有先去看看客厅有没有现成的工具,而是为每个房间都买了一套新工具。
以下是git历史中可以清晰看到的重复模式:
2 六类重复代码的发现与修复
2.1 案例一:CJK字符检测——同一段代码出现了6次
发现过程:
在拆包完成后的第二天(7月6日),我们在测试PPT生成时发现中文字符显示为乱码。排查后发现是CJK字符检测函数出了问题——它没有正确识别CJK标点符号。
当我们去修复这个问题时,才发现这个函数在6个不同的包里被独立实现了6次,而且每个版本都不一样:
knowledge-base 的版本 (knowledge_base/extractor.py):
1 2 3 4 def _is_cjk_pair(a: str, b: str) -> bool: def is_cjk(c: str) -> bool: return '\u4e00' <= c <= '\u9fff' or c in ',。!?、;:""''()《》' return is_cjk(a) or is_cjk(b) rag-indexer 的版本 (rag_indexer/indexer.py):
1 2 3 4 5 def _is_cjk_pair(a: str, b: str) -> bool: """Check if two adjacent characters are both CJK.""" def is_cjk(c: str) -> bool: return '\u4e00' <= c <= '\u9fff' or c in ',。!?、;:""''()《》' return is_cjk(a) or is_cjk(b) mindmap-generator 的版本 (mindmap_generator/text_extractor.py):
1 2 3 4 def _is_cjk_pair(a: str, b: str) -> bool: def is_cjk(c: str) -> bool: return '\u4e00' <= c <= '\u9fff' or c in ',。!?、;:""''()《》' return is_cjk(a) or is_cjk(b) ppt-generator 的版本 (ppt_generator/text_analysis.py):
1 2 3 4 5 6 def _is_cjk(text: str) -> bool: """Detect if text is predominantly CJK characters.""" cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff') latin_count = sum(1 for c in text if c.isascii() and c.isalpha()) return cjk_count > latin_count ppt-generator/llm_service 的版本:
1 2 3 4 5 6 @staticmethoddef _is_cjk(text: str) -> bool: cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff') latin = sum(1 for c in text if c.isascii() and c.isalpha()) return cjk > latin study-route 的版本 (study_route/llm_analyzer.py):
1 2 3 4 5 def _is_cjk(text: str) -> bool: """Check if text is predominantly CJK.""" cjk = sum(1 for c in text if "\u4e00" <= c <= "\u9fff") latin = sum(1 for c in text if c.isascii() and c.isalpha()) return cjk > latin 问题分析:
注意 study-route 的版本——它漏掉了 '\u3040' <= c <= '\u30ff'(日文平假名和片假名)的检测。这意味着如果教材中包含日文内容,study-route 会错误地将其判定为非CJK文本,从而生成英文而非中文的学习路径标题。
更隐蔽的问题是:这些函数虽然逻辑相似,但命名不同(_is_cjk、_is_cjk_pair、is_cjk),参数签名不同(有的接受单字符,有的接受字符串),返回值含义不同(有的检测单个字符,有的检测文本整体)。这使得简单的全局搜索很难发现它们。
修复方案:
我们将所有CJK检测逻辑统一到 ppt-common 包中,提供三个层次的API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # ppt_common/text.pydef is_cjk_char(c: str) -> bool: """单个字符是否是CJK字符(含标点、日文假名)""" return ( ("\u4e00" <= c <= "\u9fff") # CJK统一汉字 or c in _CJK_PUNCT # 全角CJK标点 or ("\u3040" <= c <= "\u30ff") # 日文平假名+片假名 )def is_cjk(text: str) -> bool: """文本是否以CJK字符为主""" cjk = sum(1 for c in text if ("\u4e00" <= c <= "\u9fff") or ("\u3040" <= c <= "\u30ff")) latin = sum(1 for c in text if c.isascii() and c.isalpha()) return cjk > latindef is_cjk_pair(a: str, b: str) -> bool: """相邻字符对中是否有CJK字符(用于PDF断行合并)""" return is_cjk_char(a) or is_cjk_char(b) 然后各包改为导入:
1 from ppt_common.text import is_cjk, is_cjk_pair, is_cjk_char git提交记录:e6aed61 — “refactor: eliminate code duplication across packages”
2.2 案例二:PDF断行合并——"same logic as rag-indexer"的注释暴露了一切
发现过程:
在 knowledge-base/knowledge_base/extractor.py 的原始代码中,有这样一行注释:
1 2 def _merge_lines(lines: list[str]) -> str: """Merge PDF lines into proper paragraphs (same logic as rag-indexer).""" 这句注释直接告诉我们:这段代码是从 rag-indexer 复制过来的。AI助手在创建 knowledge-base 包时,发现它需要PDF断行合并功能,于是直接把 rag-indexer 中的实现复制了过来——甚至保留了"和rag-indexer一样的逻辑"这个注释。
以下是三个包中 _merge_lines() 的对比(它们几乎完全相同):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 # knowledge-base/extractor.pydef _merge_lines(lines: list[str]) -> str: """Merge PDF lines into proper paragraphs (same logic as rag-indexer).""" paragraphs: list[str] = [] current = "" for line in lines: line = line.replace('\x08', '').replace('\u200b', '').strip() if not line or _GARBAGE_RE.match(line): if current: paragraphs.append(current) current = "" continue if _HEADER_RE.match(line): if current: paragraphs.append(current) paragraphs.append(line) current = "" continue if not current: current = line continue prev_ends_sentence = current[-1] in '。!?.!?""))》' if prev_ends_sentence: paragraphs.append(current) current = line else: if _is_cjk_pair(current[-1], line[0]): current += line else: current += ' ' + line if current: paragraphs.append(current) return '\n'.join(paragraphs) 这段约40行的函数在 knowledge-base、rag-indexer、mindmap-generator 中各出现了一次,总计约120行重复代码。
修复方案:
统一为 ppt_common.text.merge_pdf_lines(),各包直接导入使用。knowledge-base 的修复diff如下:
1 2 3 4 5 6 7 8 9 -from ppt_common.text import merge_pdf_lines+from ppt_common.text import merge_pdf_lines, HEADER_PATTERN, GARBAGE_PATTERN, is_cjk_pair-# Section header patterns-HEADER_PATTERN = re.compile(...)-GARBAGE_PATTERN = re.compile(...)-def _is_cjk_pair(a, b): ...-def _merge_lines(lines): ...- # 约70行重复代码被删除 2.3 案例三:页面清洗——“修一个bug,制造更多重复”
发现过程:
这个案例特别有意思,因为它展示了修复bug的过程中如何产生新的重复。
7月7日上午,我们发现 mindmap-generator 生成的思维导图包含了PDF提取的乱码文本(如 ԍ㑂䃽Ⱊ 这类Unicode垃圾字符)。原因是 mindmap-generator 的 _merge_lines() 函数没有做页面清洗就直接合并文本。
AI助手的修复方案是:在 mindmap-generator 中添加一个 _clean_page_lines() 函数。但这个函数的实现,几乎是 ppt-generator 中 TextCleaner.clean_page() 方法的逐行复制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # mindmap-generator 中新增的 _clean_page_lines()def _clean_page_lines(lines, page_num, book_title="", chapter_title=""): clean = [] for line in lines: line = line.strip() if not line: continue if re.match(r'^\d{1,4}$', line): # 跳过页码 continue if re.match(r'^[a-z]$', line): # 跳过脚注标记 continue if book_title and line == book_title: # 跳过书名页眉 continue if chapter_title and line == chapter_title: continue if _CJK_CHAPTER_PATTERN.match(line) and len(line) < 20: continue # ... 更多清洗逻辑,与 ppt-generator 完全相同 git提交 39c3033 的message写道:“Replace custom _merge_lines with ppt_common.text.merge_pdf_lines for proper CJK handling”——它确实用共享组件替换了 _merge_lines,但同时又新增了一个 _clean_page_lines,把重复从一处转移到了另一处。
这是一个典型的"打地鼠"问题:修了一个重复,又在另一个地方制造了新的重复。
修复方案:
在后续的提交 e6aed61 中,我们将 clean_page() 也提取到了 ppt-common:
1 2 3 4 5 6 7 # ppt_common/text.pydef clean_page(text, page_num, book_title="", chapter_title=""): """清洗PDF页面文本""" # ... 统一的清洗逻辑# mindmap-generator 改为委托调用from ppt_common.text import clean_page ppt-generator 中的 TextCleaner 也改为委托模式:
1 2 3 4 5 6 7 class TextCleaner: def clean_page(self, text, page_num, book_title="", chapter_title=""): """Delegates to ppt_common.text.clean_page.""" from ppt_common.text import clean_page as _clean_page cleaned_text = _clean_page(text, page_num, book_title, chapter_title) clean_lines = cleaned_text.split('\n') if cleaned_text else [] return CleanPage(page_num=page_num, lines=clean_lines, raw_text=text) 2.4 案例四:LLM客户端——4个包各自配置了一遍API
发现过程:
在排查PPT生成质量低下的问题时(见 output_report/PPT_QUALITY_ANALYSIS.md),我们发现了以下错误日志:
1 2 LLM analysis failed: cannot access local variable 'is_cjk' where it is not associated with a value 这个错误的根因是 ppt-generator/ppt_generator/llm_service.py 中的 LLMContentAnalyzer 类有一个 _is_cjk 静态方法,但在某处代码中,一个局部变量 is_cjk 遮蔽了导入的函数。由于LLM分析失败,PPT生成器退化到了纯文本模式,生成的幻灯片质量大幅下降:
标题变成了 “Section 4”、“Key Concept” 这样的通用标题 内容变成了原始段落的直接复制 没有定义卡片、引用卡片、对比布局等丰富结构
而这个LLM客户端的初始化代码,在4个包中各自实现了一遍:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # ppt-generator/llm_service.pyclass LLMContentAnalyzer: def __init__(self, api_key="", base_url="", model=""): import httpx from openai import OpenAI self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY", "") # 代理清除逻辑... saved_proxy = {} for key in ['ALL_PROXY', 'HTTPS_PROXY', 'HTTP_PROXY']: if key in os.environ: saved_proxy[key] = os.environ.pop(key) try: http_client = httpx.Client(base_url=self.base_url, ...) self.client = OpenAI(api_key=self.api_key, ...) finally: os.environ.update(saved_proxy) 1 2 # study-route/llm_analyzer.py — 几乎一模一样的初始化代码# mindmap-generator/llm_enhancer.py — 又是一遍 甚至连JSON响应解析函数也重复了:
1 2 3 4 5 6 7 8 9 10 11 # study-route/llm_analyzer.pydef _parse_llm_response(raw: str) -> dict | None: """Parse LLM JSON response, stripping code fences.""" text = raw.strip() if text.startswith("```"): text = text.split("\n", 1)[1] if "\n" in text else text[3:] if text.endswith("```"): text = text[:-3] if text.startswith("json"): text = text[4:] # ... 修复方案:
统一为 ppt_common.llm.LLMClient 和 parse_llm_json():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # ppt_common/llm.pyclass LLMClient: """懒初始化的OpenAI兼容客户端,含DashScope代理处理""" def __init__(self, api_key="", base_url="", model="", timeout=60): self._api_key = api_key or _first_env(_API_KEY_VARS) self._client = None # 懒初始化 @property def client(self): if self._client is not None: return self._client # ... 初始化逻辑(代理清除等) def chat(self, user_prompt, *, system="", ...): """发送聊天请求""" def chat_json(self, user_prompt, *, system="", ...): """发送请求并解析JSON响应""" 2.5 案例五:正则表达式——“长得一样但变量名不同”
发现过程:
在排查知识提取不准确的问题时,我们对比了各包中使用的正则表达式。发现以下两个正则在多个包中被重复定义,但变量名各不相同:
垃圾行过滤正则:
1 2 3 4 # knowledge-base: GARBAGE_PATTERN# rag-indexer: _GARBAGE_RE# mindmap-generator: _GARBAGE_RE# summary-generator: _GARBAGE_RE 章节标题检测正则:
1 2 3 # knowledge-base: HEADER_PATTERN# rag-indexer: _HEADER_RE# mindmap-generator: _SECTION_L1 + _SECTION_L2 + _SECTION_L3(拆成了三个) 更微妙的是,这些正则的内容也不完全一致。例如 knowledge-base 的 HEADER_PATTERN:
1 2 3 4 5 6 HEADER_PATTERN = re.compile( r'^(第[一二三四五六七八九十\d]+[章节篇部]|' r'\d+\.\d+|' # ← 只有两级编号 r'[A-Z]\.\s|Chapter\s|Part\s|Section\s)', re.I) 而 ppt-common 的统一版本:
1 2 3 4 5 6 7 8 9 10 _HEADER_RE = re.compile( r'^(' r'第[一二三四五六七八九十百千零\d]+[章节篇部]' r'|\d+\.\d+(?:\.\d+)?\s+\S' # ← 支持三级编号 r'|[A-Z]\.\s' r'|Chapter\s|Part\s|Section\s' r'|([一二三四五六七八九十]+)' # ← 额外的中文括号编号 r')', re.I,) 统一版本比任何单个包的版本都更加完善——它支持三级编号(如 1.2.3 概念名)和中文括号编号(如 (一)传播的定义)。但由于这些正则分散在各包中,每个版本都只覆盖了部分场景。
2.6 案例六:main.py 中的"临时"清洗函数
发现过程:
在 ppt-generator/main.py 中,我们发现了一个约30行的 clean_text() 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def clean_text(text: str) -> str: """Remove garbage characters and filter meaningless content.""" cleaned = re.sub(r'[^\x20-\x7E\u4e00-\u9fff\u3000-\u303f\uff00-\uffef\n\r\t]', '', text) lines = cleaned.split('\n') filtered_lines = [] for line in lines: line = line.strip() if not line: continue if len(line) < 3: continue non_alnum = sum(1 for c in line if not c.isalnum() and not c.isspace()) if len(line) > 5 and non_alnum / len(line) > 0.7: continue filtered_lines.append(line) return '\n'.join(filtered_lines) 这个函数是AI助手在实现 main.py 的CLI入口时"随手"写的——它需要一个简单的文本清洗功能,于是直接内联了一个实现,而没有去检查项目中是否已经有 TextCleaner.clean_page() 可以使用。
同样的情况还出现在图像过滤的硬编码阈值中:
1 2 3 # main.py 中的硬编码if d['width'] < 100 or d['height'] < 100 or area < 10000: # 过滤小图 而 pdf-image-extractor 包中已经有 ImageFilterConfig 类定义了这些阈值。
修复方案(commit 1986ecc):
1 2 3 4 5 6 7 8 9 10 11 12 - def clean_text(text: str) -> str:- """Remove garbage characters..."""- # 30行代码...+ cleaner = TextCleaner()+ for page_num, page in enumerate(doc):+ clean_page = cleaner.clean_page(page.get_text(), page_num + 1)+ text_content.append("\n".join(clean_page.lines))- if d['width'] < 100 or d['height'] < 100 or area < 10000:+ from pdf_image_extractor import ImageFilterConfig+ img_config = ImageFilterConfig()+ if d['width'] < img_config.min_width or d['height'] < img_config.min_height: 这次修复删除了154行重复代码。
3 重复代码导致的真实Bug
重复代码不仅仅是"代码不整洁"的问题——它直接导致了多个影响功能的Bug。
3.1 Bug 1:PPT质量断崖式下降
现象: PPT生成的幻灯片质量突然下降,标题变成 “Section 4”、“Key Concept” 这样的通用文本,内容变成原始段落的直接复制。
根因:ppt-generator/ppt_generator/llm_service.py 中的 LLMContentAnalyzer 类有一个 _is_cjk 静态方法。在某次重构中,一个局部变量 is_cjk 遮蔽了该方法,导致:
1 2 LLM analysis failed: cannot access local variable 'is_cjk' where it is not associated with a value LLM分析失败后,生成器退化到纯文本模式,失去了:
智能标题提取 关键点提炼 定义卡片和引用卡片 对比布局 内容摘要
教训: 如果 _is_cjk 不是在各包中重复实现,而是统一从 ppt-common 导入,这个变量遮蔽问题就不会发生。
3.2 Bug 2:思维导图包含乱码
现象: mindmap-generator 生成的思维导图节点标题包含 ԍ㑂䃽Ⱊ 等Unicode垃圾字符。
根因: mindmap-generator 的 _merge_lines() 函数没有做页面清洗就直接合并文本。PDF中的图表区域会产生大量非文本Unicode字符,这些字符被当成正文合并到了段落中。
修复过程:
第一次修复( 39c3033):在 mindmap-generator 中新增_clean_page_lines()—— 但这是从 ppt-generator 复制过来的,制造了新的重复第二次修复( e6aed61):将clean_page()提取到 ppt-common,mindmap-generator 改为导入
3.3 Bug 3:章节内容错位
现象: PPT中"小结"幻灯片显示了其他章节的内容。
根因:ppt-generator/main.py 中使用 str.find() 进行子串匹配来定位章节内容:
1 2 start_idx = full_text.find(section_title)end_idx = full_text.find(next_title, start_idx + len(section_title)) 当教材中有多个同名章节(如多个"小结")时,find() 总是返回第一个匹配,导致后面的章节内容被错误地分配到前面的章节。
修复(8f18ba4): 用前向游标(forward cursor)匹配替代 find():
1 2 3 4 5 6 7 8 cursor = 0for i, (section_title, _level) in enumerate(sections): start = cursor while cursor < len(paragraphs) and section_title not in paragraphs[cursor]: cursor += 1 if cursor < len(paragraphs): start = cursor + 1 cursor = start 3.4 Bug 4:study-route 的 Python 3.14 作用域问题
现象: study-route 在 Python 3.14 下运行时报错:
1 cannot access local variable 'is_cjk' where it is not associated with a value 根因:study_route/planner.py 中在条件块内部导入了 is_cjk:
1 2 3 if terms: from ppt_common.text import is_cjk title = f"{term_name} 学习路线" if is_cjk(term_name) else ... Python 3.14 的作用域规则变化导致这个延迟导入在某些路径下无法正确解析。
4 防护体系的建立
在经历了上述排查和修复之后,我们逐步建立了一个四层的防护体系。
4.1 第一层:文档约束——让AI"知道"有什么可以复用
我们在项目根目录创建了 AGENTS.md,其中包含一个共享组件查找表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 文本处理from ppt_common.text import ( clean_page, # PDF页面清洗 merge_pdf_lines, # PDF断行合并(CJK感知) is_section_header, # 章节标题检测 is_cjk, # CJK文本检测 is_cjk_pair, # CJK字符对检测 GARBAGE_PATTERN, # 垃圾行过滤正则 HEADER_PATTERN, # 章节标题检测正则)# LLM集成from ppt_common.llm import ( LLMClient, # 统一OpenAI兼容客户端 parse_llm_json, # LLM JSON响应解析) 以及一个四步决策清单:
1 2 3 4 Step 1: 该功能是否在查找表中? YES → 导入 NO → Step 2Step 2: 这是通用的文本/PDF/LLM处理? YES → 加到ppt-common NO → Step 3Step 3: 这是某个包的领域特有逻辑? YES → 在该包实现 NO → 应该在ppt-commonStep 4: 我是否即将从另一个包复制代码? YES → 停止!改为导入 4.2 第二层:行为规则——让AI"应该"去复用
我们为AI助手定义了行为规则,核心是一个门禁函数(Gate Function):
1 2 3 4 5 6 7 8 9 在实现任何新函数之前,必须执行:1. 识别:这个函数的用途是什么?2. 搜索:查找表中是否已有实现?3. 验证:运行重复检测器4. 决策: - 找到了 → 导入 - 接近但不完全匹配 → 组合使用 - 确实是新的 → 加到共享包,然后导入5. 然后才能写代码 以及一组红旗标志——当AI助手的编码行为触发以下标志时,应立即停止:
正在写 def is_cjk(而没有先检查查找表在非共享包中创建 def clean_page(使用 '\u4e00' <= c <= '\u9fff'实现CJK检测认为"这只是一个小辅助函数,直接写就好"
4.3 第三层:自动化检测——让重复无处藏身
我们编写了 check_duplication.py,一个基于正则表达式的重复检测器:
1 2 3 4 5 6 7 8 9 FORBIDDEN_PATTERNS = [ (r'def\s+_?is_cjk_pair\s*\(', 'use ppt_common.text.is_cjk_pair'), (r'def\s+_?is_cjk_char\s*\(', 'use ppt_common.text.is_cjk_char'), (r'def\s+_?is_cjk\s*\(', 'use ppt_common.text.is_cjk'), (r'def\s+_?merge_pdf_lines\s*\(', 'use ppt_common.text.merge_pdf_lines'), (r'def\s+_?clean_page\s*\(', 'use ppt_common.text.clean_page'), (r'^_GARBAGE_RE\s*=\s*re\.compile', 'use ppt_common.text.GARBAGE_PATTERN'), (r'["\']\u4e00["\'].*<=.*["\']\u9fff["\']', 'use ppt_common.text.is_cjk_char'),] 检测器还实现了误报过滤——如果文件中包含 "Delegates to ppt_common" 注释或正确的导入语句,则跳过相关的匹配:
1 2 3 4 # 检查文件是否包含委托模式has_delegation = "Delegates to ppt_common" in sourceif has_delegation and "clean_page" in description: continue # 合法的委托代码 实际运行效果:
1 2 3 4 5 6 7 8 9 $ python3 check_duplication.py🔍 Scanning for code duplication...📦 knowledge-base ❌ knowledge-base/knowledge_base/terms.py Line 166: CJK range check (use ppt_common.text.is_cjk_char) if not all('\u4e00' <= c <= '\u9fff' for c in ngram):❌ Found 1 duplication(s) 即使在完成大规模重构后,检测器仍然发现了1处遗漏——knowledge-base/terms.py 中的CJK范围检查。这种细粒度的检测是人工代码审查很难做到的。
4.4 第四层:架构设计——从根源上减少重复
我们定义了共享包-领域包分离的架构:
1 2 3 4 5 6 7 8 9 10 ppt-common (共享工具包) text.py → CJK检测、PDF行合并、页面清洗、正则模式 llm.py → 统一LLM客户端、JSON解析领域包 (各自只包含领域特有逻辑) pdf-chapter-splitter → TOC检测、章节边界提取 pdf-image-extractor → 图像过滤、图表提取 ppt-generator → PowerPoint构建、幻灯片布局 rag-indexer → 语义分块、FAISS索引 ... 核心原则:
- 单一实现原则: 每个功能在整个代码库中只有一个实现
- 领域纯净原则: 每个领域包只包含其领域特有的逻辑
- 共享下沉原则: 当多个包需要相同功能时,该功能应下沉到共享包
5 修复效果
5.1 量化指标
5.2 git提交时间线
整个排查和修复过程发生在2026年7月7日一天内:
1 2 3 4 5 6 15:33 8f18ba4 fix: 修复章节内容错位 + 消除重复PDF解析15:49 1986ecc refactor: 去重ppt-generator的文本/图像处理 (-154行)16:11 6d615fd feat: 添加全包集成测试 (10包, 199个测试)16:29 39c3033 fix: 修复思维导图乱码 (但引入了新的重复!)16:57 e6aed61 refactor: 消除跨包重复 (-300行, 统一ppt-common)17:11 9fdb611 feat: 实现三层防护体系 (AGENTS.md + 检测器 + 行为规则) 5.3 一个有意思的发现
在修复过程中,我们发现AI助手有一个有趣的"自我纠正"模式:
在 commit 39c3033中,AI助手修复了思维导图乱码问题,但方式是复制 ppt-generator 的clean_page逻辑到 mindmap-generator在 commit e6aed61中,同一个AI助手(在同一天的后续会话中)又将这个复制的代码提取到了 ppt-common
这说明AI助手并不是"不知道"代码复用的原则——它只是在单个任务的上下文中倾向于快速解决问题,而不是花时间去寻找已有的共享组件。当我们在后续会话中明确指出"请先检查 ppt-common"时,它就能够正确地使用共享组件。
6 经验总结
6.1 AI编程助手产生重复代码的根因
6.2 关键教训
教训一:重复代码的危害不仅仅是"不整洁"
在我们的案例中,重复代码直接导致了:
PPT质量断崖式下降(变量遮蔽bug) 思维导图包含乱码(清洗逻辑不一致) 章节内容错位(字符串匹配在重复标题上失败)
教训二:修复bug时要警惕"转移重复"
commit 39c3033 的教训:修复一个重复问题时,不要把重复代码搬到另一个地方。应该问自己:“这个功能在项目中是否已经有实现?”
教训三:自动化检测是最后一道防线
即使有文档和行为规则,AI助手仍然可能产生重复。check_duplication.py 在大规模重构后仍然发现了1处遗漏,证明了自动化检测的价值。
教训四:共享组件的API设计要"好用"
ppt-common 的成功在于它提供了多层次的API:
高层: clean_page()— 一行代码完成页面清洗中层: merge_pdf_lines()— 一行代码完成断行合并低层: is_cjk_char(),GARBAGE_PATTERN— 细粒度组件供组合使用
如果共享组件只提供了一个"大而全"的API,各包可能会因为"不完全匹配需求"而选择自己实现。
7 附录
7.1 各包对共享组件的导入使用情况
1 2 3 4 5 6 7 mindmap-generator → merge_pdf_lines, clean_page, is_cjk, is_section_header, GARBAGE_PATTERNstudy-route → is_cjk, LLMClient, parse_llm_jsonppt-generator → is_cjk, is_cjk_char, clean_page, merge_pdf_lines, LLMClientsummary-generator → is_section_header, clean_page, GARBAGE_PATTERNrag-indexer → merge_pdf_lines, clean_page, HEADER_PATTERN, GARBAGE_PATTERNknowledge-base → merge_pdf_lines, clean_page, HEADER_PATTERN, GARBAGE_PATTERN, is_cjk_pair总计:23个导入点 7.2 自动化检测器的完整禁止模式列表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 FORBIDDEN_PATTERNS = [ # 函数定义重复 (r'def\s+_?is_cjk_pair\s*\(', 'is_cjk_pair'), (r'def\s+_?is_cjk_char\s*\(', 'is_cjk_char'), (r'def\s+_?is_cjk\s*\(', 'is_cjk'), (r'def\s+_?merge_pdf_lines\s*\(', 'merge_pdf_lines'), (r'def\s+_?clean_page\s*\(', 'clean_page'), (r'def\s+_?is_section_header\s*\(', 'is_section_header'), # 正则表达式重复 (r'^_GARBAGE_RE\s*=\s*re\.compile', 'GARBAGE_RE'), (r'^GARBAGE_PATTERN\s*=\s*re\.compile.*u4e00', 'GARBAGE_PATTERN'), (r'^HEADER_PATTERN\s*=\s*re\.compile.*章节', 'HEADER_PATTERN'), (r'^_HEADER_RE\s*=\s*re\.compile', 'HEADER_RE'), # CJK范围检查重复 (r'["\']\u4e00["\'].*<=.*["\']\u9fff["\']', 'CJK range check'),] 7.3 集成测试结果(修复后)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Package Status Time Details--------------------------------------------------------------------------------✅ ppt-common PASS 0.10s merged_paragraphs: 5✅ pdf-chapter-splitter PASS 0.64s chapters: 1, toc_pages: 0✅ pdf-image-extractor PASS 0.75s images_found: 29, kept: 1✅ ppt-generator PASS 36.45s paragraphs: 242, sections: 25✅ rag-indexer PASS 6.55s indexed: True✅ summary-generator PASS 0.24s summaries: 9, key_points: 45✅ knowledge-base PASS 0.32s terms: 19, relationships: 27✅ knowledge-graph PASS 0.09s nodes: 2, edges: 1✅ mindmap-generator PASS 0.23s root_nodes: 7✅ study-route PASS 0.00s steps: 0================================================================================Total: 10 passed, 0 failed in 45.37s 本文涉及的10个通用Python包已独立开源,代码可在 https://github.com/back1992/ppt-bot-v2-packages 获取。
邮箱:linmk@tup.tsinghua.edu.cn
夜雨聆风