小菜救急:用Gradio的gr.State给王大姐搭了个古籍搜索引擎
小菜接到了一个让他头疼的新任务:王大姐打电话来,说她们馆刚用文渊慧典跑完了整部康熙《某县志》,生成了几万字的文本文件。可读者想来查资料,她只能用Ctrl+F在Word里一页一页地翻,眼睛都快花了。“小菜呀,你能不能给做个简单的检索页面?就像百度那样,输一个字,所有带这个字的句子都跳出来。最好还能看到原书的图,不然我心里没底。”
小菜挠着头把这事儿告诉了大胖老师。大胖老师听完,端起保温杯:“这就是典型的全文检索加图文对照。你不是刚学了Gradio的Blocks和状态管理吗?正好,咱们就来做两个真正‘有脑子’的进阶案例——一个古籍检索器,一个在线校对工具。搞完这两个,你Gradio的水平就能从‘能搭界面’升级到‘能搭系统’了。”
二黑推了推眼镜,从抽屉里抽出一张写着“gr.State”的便签:“这次要动真格的了。你之前用的Interface和简单Blocks,每次交互都是无状态的——函数跑完就忘。但检索器需要记住上次搜了啥,校对工具需要在页面之间跳转。所以今天的主角是gr.State和动态组件更新。”
一、案例一:古籍全文检索器——让故纸堆秒变搜索引擎
1.1 故事:王大姐的“百度一下”
大胖老师给小菜下了一个需求:做一个Web页面,左边是检索区,输入关键词,点击搜索;右边上半部分显示所有包含关键词的句子列表,下半部分显示对应古籍原图。点击某条句子,原图自动滚动到那个字的位置。
“这可比以前那些静态界面难多了。”小菜看着白板上的草图,有点发怵。二黑给他拆解:“其实就几个关键点。第一,检索函数需要一个持久化的数据源,我们用gr.State在内存里存一份古籍文本和图片列表。第二,搜索结果要能点击,用gr.Radio或者gr.DataFrame展示,点一下触发另一个函数,更新图片显示。第三,图片上的高亮可以用PIL动态画框,但这个对小白太复杂,我们先做一个简化版——点句子,直接切到对应的原图页码。”
“思政点我们也融入进去,”大胖老师补充,“这个工具就是让县图书馆的读者,用最低的成本享受到国家级工程级的检索体验,这就是‘数字中国’在最基层的实践。”
1.2 准备工作:造一份“模拟古籍数据”
因为是教学演示,二黑现场手写了一个make_dummy_data函数,生成10页“古籍文本”和10张纯色模拟图片,免得还要真的跑OCR。文本里故意塞了一些重复的关键词,比如“康熙”、“县志”、“水利”等,以便检索。
import gradio as gr import random from PIL import Image, ImageDraw # ========== 模拟古籍数据(实际项目中替换为OCR结果) ========== def make_dummy_data(): """生成10页模拟古籍文本和对应图片,用于演示全文检索""" texts = [] words_pool = ["康熙", "县志", "水利", "田赋", "学宫", "灾异", "人物", "烈女", "城池", "关隘"] for i in range(10): # 每页随机生成3-5句话,每句都包含一些关键词 sentences = [] for _ in range(random.randint(3, 5)): keyword = random.choice(words_pool) sentences.append(f"第{i+1}页:关于{keyword}的记载……{keyword}之事,详见于卷{i+1}。") texts.append("\n".join(sentences)) # 生成纯色模拟图片(实际项目中替换为古籍扫描件) images = [] for i in range(10): img = Image.new("RGB", (400, 300), color=(240, 235, 220)) # 仿古纸色 draw = ImageDraw.Draw(img) draw.text((20, 20), f"第{i+1}页", fill=(100, 80, 60)) images.append(img) return texts, images # 初始化数据 dummy_texts, dummy_images = make_dummy_data()小菜问:“为什么用ImageDraw画字?”大胖老师说:“实际部署时,这里的images就是文渊慧典导出的双层PDF切出来的单页图片。现在演示,先拿假数据把流程跑通。这就是工程思维——先验证逻辑,再替换真实数据。”
1.3 核心检索函数:从文本到句子列表
“检索的本质是字符串匹配。我们先把每页文本按句号或换行拆成句子,然后过滤出包含关键词的句子。函数返回两个东西:一个格式化的选择列表(给gr.Radio用),一个字典存储每条句子对应的图片索引和关键词位置,供点击跳转用。”二黑快速敲出了检索函数。
def search_keyword(keyword, texts, images): """在模拟古籍文本中检索关键词,返回句子列表和映射表""" if not keyword.strip(): return [], {}, None results = [] mapping = {} # 句子标签 -> {"page":页码, "text":原文, "image":图片} keyword_lower = keyword.strip() for page_idx, page_text in enumerate(texts): # 简单按句号分句(实际可更精细) sentences = page_text.replace("\n", "").split("。") for sent in sentences: if keyword_lower in sent: if not sent.strip(): continue # 生成显示标签(截断过长句子) label = f"【第{page_idx+1}页】{sent[:40]}{'...' if len(sent)>40 else ''}" results.append(label) mapping[label] = { "page": page_idx, "text": sent, "image": images[page_idx] } if not results: return ["未找到相关记载"], {}, None # 默认选中第一条,展示第一张图 first_label = results[0] first_image = mapping[first_label]["image"] return results, mapping, first_image“这个函数返回三个值:句子列表、映射字典、第一张图片。Gradio的outputs可以绑定多个组件,刚好对应。”大胖老师指着屏幕,“注意我们用了gr.State()来存储texts和images,这样每次检索就不需要重新生成数据,而且多个用户同时访问时,State是会话隔离的,互不干扰。这对图书馆场景很重要——王大姐和读者同时用,不会串数据。”
1.4 界面搭建与事件绑定:点击句子换图片
界面用Blocks布局:左边是检索区,右边是结果区。右边再分上下两栏,上面显示句子单选列表,下面显示对应图片。关键点:点击gr.Radio的选项时,触发一个更新图片的函数。
with gr.Blocks(title="📜 古籍全文检索器") as demo_search: gr.Markdown(""" # 📜 古籍全文检索器 > 输入关键词,快速定位所有相关记载,点击条目即可查看原书图片。 > **思政融入**:让沉睡在库房里的古籍变成可检索的数字资产,助力基层图书馆实现“数字中国”文化普惠。 """) # 用 State 保存数据 state_texts = gr.State(dummy_texts) state_images = gr.State(dummy_images) state_mapping = gr.State({}) # 存储当前检索结果的映射表 with gr.Row(): with gr.Column(scale=2): keyword_input = gr.Textbox(label="请输入关键词", placeholder="例如:康熙、水利、田赋...") search_btn = gr.Button("🔍 检索", variant="primary") # 单选列表显示检索结果 result_radio = gr.Radio(label="📋 检索结果(点击条目查看原图)", choices=[], interactive=True) with gr.Column(scale=3): page_image = gr.Image(label="📄 对应原书页面", type="pil", interactive=False) # 事件:点击检索按钮 search_btn.click( fn=search_keyword, inputs=[keyword_input, state_texts, state_images], outputs=[result_radio, state_mapping, page_image] )小菜试着运行,在输入框里敲“水利”,点击检索,底下立刻跳出三条记录:“【第1页】关于水利的记载……”、“【第4页】水利之事……”、“【第7页】……水利工程”。他点了一下第二行,右边的图片瞬间切到了一张写着“第4页”的模拟原书页面。王大姐后来在群里看到演示,直接发语音:“这个太方便了!以后读者来查家谱,我都不用翻书了,搜一下名字全出来了!”
大胖老师补充:“这就叫‘数据多跑路,群众少跑腿’。以前查一个名字可能翻一天,现在几秒钟。而且这是纯本地运行,数据不出馆,安全又可靠,完全符合咱们的‘三不原则’。”
二、案例二:在线校对标注工具——让王大姐成为“众包校对员”
2.1 故事:一个人校不完的书
检索器做完后,王大姐又提出了新需求:“小菜呀,那个OCR认出来的字不是有些标黄了吗?我每天校对着黄字,但有些页实在太多,我一个人眼睛都看花了。能不能让志愿者也帮忙?最好有个网页,大家打开就能看到需要校对的句子,点一下就能改错字。”
二黑一听,拍了下桌子:“这就是众包校对!刚好可以展示Gradio的gr.Textbox动态更新和gr.State保持会话进度。”大胖老师点点头:“而且这非常契合思政主题——发动群众力量参与文化遗产保护,是‘共建共享’理念的生动实践。”
2.2 数据模型:待校对句子队列
他们设计了一个简单的数据结构:一份全局的“待校对句子列表”,每个句子包含原文、OCR识别结果、可能的错误标记。用户每次随机获取一条未校对过的句子,修改后提交,系统保存修改并更新校对进度。
import json import random # 模拟待校对数据(实际来自OCR置信度低的句子) def load_correction_tasks(): """加载待校对任务,实际项目中从OCR结果导出""" tasks = [ {"id": 1, "original_text": "康熙五十年,邑令某率民食观音土,便秘几死。", "ocr_text": "康熙五十年,邑令某率民食觀音土,便秘幾死。", "wrong_char": None}, {"id": 2, "original_text": "水利者,农田之命脉也。", "ocr_text": "水利者,农用之命脉也。", # OCR把“田”误认成“用” "wrong_char": "用"}, {"id": 3, "original_text": "学宫建于城东,祀至圣先师。", "ocr_text": "学宫建于城东,祀至聖先师。", # 漏了“至”字? "wrong_char": "至"}, {"id": 4, "original_text": "灾异:是年蝗,禾尽。", "ocr_text": "炎異:是年蝗,禾尽。", # OCR把“灾”认成“炎” "wrong_char": "炎"}, {"id": 5, "original_text": "烈女传:张氏,年十九,夫亡守节。", "ocr_text": "烈女传:张氏,年十九,夫亡守節。", # 繁体正确 "wrong_char": None}, ] return tasks # 初始化 tasks_db = load_correction_tasks() # 用State存储已校对数量、当前任务索引等2.3 校对界面:一步一确认
界面设计:左上展示任务编号和校对进度,中间是OCR原文和校对编辑框,下面是“提交校对”和“跳过”按钮。提交后自动跳转下一条。还会显示一个总的统计。
with gr.Blocks(title="📝 古籍OCR校对助手") as demo_correction: gr.Markdown(""" # 📝 古籍OCR校对助手 > 修改OCR识别错误的文字,提交校对结果。众人拾柴火焰高,一起让古籍更准确! > **思政融入**:文化传承,人人有责。你的每一次校对,都在为守护中华文脉添砖加瓦。 """) # 状态变量 state_tasks = gr.State(tasks_db.copy()) state_current_idx = gr.State(0) # 当前处理的任务索引 state_corrected = gr.State(0) # 已校对数量 with gr.Row(): with gr.Column(scale=1): progress_text = gr.Markdown("### 📊 校对进度:0 / 5") task_id_display = gr.Textbox(label="任务编号", value="", interactive=False) ocr_original = gr.Textbox(label="📜 OCR识别原文", value="", lines=3, interactive=False) corrected_text = gr.Textbox(label="✏️ 请修改错误文字(可直接编辑)", lines=3, placeholder="在此修改...") with gr.Row(): submit_btn = gr.Button("✅ 提交校对", variant="primary") skip_btn = gr.Button("⏭️ 跳过") feedback = gr.Textbox(label="操作反馈", value="", interactive=False) with gr.Column(scale=1): gr.Markdown("### 🏆 个人贡献") stats_text = gr.Markdown("您已校对:0 条") # 显示一个简单的排行榜或感谢语 thanks_text = gr.Markdown("---\n### 🌟 感谢每一位校对者\n您的每一字修改,都在让古籍离读者更近一步。")2.4 核心逻辑:加载任务、提交、跳过
需要定义三个函数:加载当前任务、提交校对结果、跳过当前任务。提交后记录修改,更新进度。
def load_task(tasks, idx): """根据索引加载一条校对任务""" if not tasks or idx >= len(tasks): return "", "", "", f"所有任务已完成!", "🎉 您已完成所有校对,感谢您的贡献!" task = tasks[idx] return ( f"### 📊 校对进度:{idx+1} / {len(tasks)}", f"任务{task['id']}", task["ocr_text"], "", # 清空修改框 f"当前校对第{idx+1}条,共{len(tasks)}条" ) def submit_correction(tasks, idx, corrected, corrected_text_input): """提交校对结果,将修改后的文本存入task['corrected']""" if idx >= len(tasks): return tasks, idx, corrected, "⚠️ 没有任务了", stats_text if not corrected_text_input.strip(): return tasks, idx, corrected, "⚠️ 修改内容不能为空", "" # 记录修改 tasks[idx]["corrected_text"] = corrected_text_input corrected += 1 idx += 1 # 加载下一条 if idx < len(tasks): next_task = tasks[idx] return tasks, idx, corrected, f"✅ 已提交,自动跳转第{idx+1}条", f"您已校对:{corrected} 条" else: return tasks, idx, corrected, "🎉 全部校对完成!谢谢您!", f"您总共校对了 {corrected} 条!功德无量!" def skip_task(tasks, idx, corrected): """跳过当前任务""" idx += 1 if idx < len(tasks): return tasks, idx, corrected, f"⏭️ 已跳过,进入第{idx+1}条", "" else: return tasks, idx, corrected, "📭 已是最后一条", ""2.5 事件绑定与多输出更新
由于每次操作后需要同时更新多个组件(进度、任务ID、OCR原文、修改框、反馈、统计),二黑演示了如何用gr.update或者直接返回多个值来批量更新。Gradio的outputs列表会按顺序匹配组件。
# 初始化加载第一条 demo_correction.load( fn=lambda tasks: load_task(tasks, 0), inputs=[state_tasks], outputs=[progress_text, task_id_display, ocr_original, corrected_text, feedback] ) # 提交按钮 submit_btn.click( fn=submit_correction, inputs=[state_tasks, state_current_idx, state_corrected, corrected_text], outputs=[state_tasks, state_current_idx, state_corrected, feedback, stats_text] ).then( fn=lambda tasks, idx: load_task(tasks, idx), inputs=[state_tasks, state_current_idx], outputs=[progress_text, task_id_display, ocr_original, corrected_text] ) # 跳过按钮 skip_btn.click( fn=skip_task, inputs=[state_tasks, state_current_idx, state_corrected], outputs=[state_tasks, state_current_idx, state_corrected, feedback] ).then( fn=lambda tasks, idx: load_task(tasks, idx), inputs=[state_tasks, state_current_idx], outputs=[progress_text, task_id_display, ocr_original, corrected_text] )小菜注意到.then()的链式调用:“这是干啥的?”二黑解释:“submit_btn.click执行完后,then再执行一个函数去加载下一条任务。这样就能实现‘提交→保存→自动翻页’的连贯操作,用户体验很丝滑。”
2.6 理念升华:每个人都是文化守护者
大胖老师让小菜把这个校对工具链接发到文渊慧典的用户群里。不到半天,就有十几位馆员和志愿者登录,校对了上百条OCR存疑的文字。王大姐在群里感叹:“以前我一个人对着屏幕看一天,眼睛都快瞎了,现在大家一人几条,一会儿就弄完了。这感觉就像以前乡里修桥,每家出一份工。”
大胖老师点点头:“这就是‘共建共享’的力量。技术本身是中性的,但当我们把它设计成能让普通人参与文化传承的桥梁时,它就有了温度。Gradio让搭建这样的桥梁变得极其简单,而我们选择把它用在古籍校对这种‘慢工细活’上,本身就是一种价值取向——科技向善,技术为民。”
三、总结:从小工具到数字基础设施
合上笔记本,小菜已经能独立搭建带状态管理的多页面Web应用了。从纯界面的Interface,到AI驱动的Blocks,再到今天的gr.State会话保持、多组件联动、众包协作流程,Gradio的进阶能力逐步展现。
大胖老师把两个案例的代码保存到一个叫“古籍服务工具箱”的文件夹里,对小菜说:“这两个工具,加上咱们之前的文渊慧典核心流水线,就构成了一个县级图书馆古籍数字化的完整方案:流水线负责批量识别,检索器负责全文利用,校对工具负责质量提升。所有代码全开源,全本地运行,不需要任何商业授权。这,就是我们给全国两千八百个县图书馆准备的‘数字中国’基石。”
窗外,太阳西斜,王大姐正戴着老花镜,在检索器里输入“水利”,为一位研究当地农业史的研究生查找史料。几百年前先民治水的智慧,正通过一行行代码,流淌进新一代人的论文里。而那个搭建这一切的熊猫图标,安静地待在屏幕角落,仿佛在说:让技术扎根泥土,让文明长向云霄。