山东大学软件学院-项目实训-个人开发日志(十):材料问答链路开发——文档解析、OCR兜底与持续追问完善
2026/6/15 12:34:49 网站建设 项目流程

引言

前几周我已经把BabyMind的统一问答入口、RAG知识库、多Agent流式问答、语音输入输出等核心链路逐步打通。本周我的工作重点是把上传材料后直接提问等相关功能实现完整。

在育儿场景中,家长很多时候并不是只输入一句自然语言问题,而是会直接拿出化验单、检查单、辅食配料表、体检材料来问。例如“这张血常规怎么看”“这份配料表适不适合宝宝吃”“这张检查报告需要马上去医院吗”。如果系统只能做普通文本问答,实际使用价值会打折扣。因此,这一周我主要围绕“材料问答”补齐了从文件上传、文档解析、上下文注入、流式回答清洗到前端持续追问体验的一整条链路。

一、本周开发目标:让问答真正接住“材料场景”

这一阶段我给自己定的目标很明确,不是简单增加一个附件按钮,而是把材料问答做成完整能力:

1、支持图片、PDF、TXT、DOC、DOCX等常见材料格式上传;

2、后端能够稳定提取材料正文,对扫描版文档提供OCR兜底;

3、问答请求不再把材料内容当作临时拼接文本,而是作为正式字段进入后端处理链路;

4、材料在首轮提问之后仍然保留,支持围绕同一份材料继续追问;

5、流式回答中去掉内部调度痕迹,保证家长看到的是自然、干净、可直接理解的结论;

6、修复材料问答下“猜你想问”丢失的问题,保证问答体验连续。

二、先解决基础问题:让后端真正能“读懂文件”

这周我先从后端入手,新增了材料解析接口/qa/attachments/parse。它的作用不是直接给出问答结果,而是先把用户上传的文档转成可用文本,再交给后续问答链路使用。

当前这条解析链路支持以下几类文件:

1、TXT文本文件;
2、PDF文件;
3、DOC文件;
4、DOCX文件。

为了避免用户上传超大文件拖垮服务,我在解析服务里加了大小和文本长度控制。当前限制为最大10MB,正文提取后最多保留30000字符,同时额外生成一份预览摘要,供后续检索和上下文拼接使用。

在具体实现上,我没有只做单一路径,而是分成了“本地解析 + 远程兜底”两层。

1、TXT文件采用多编码兼容解析,避免用户上传GBK、UTF-16等文本时直接失败;
2、PDF优先用本地解析方式提取正文;
3、DOCX直接读取压缩包中的word/document.xml并解析正文内容;
4、对于扫描版PDF、传统DOC这类本地提取能力不稳定的文件,再接入SophNet文档解析/OCR作为兜底。

后端这段兜底逻辑的核心代码如下:

if not normalized_text and file_type in _REMOTE_PARSE_FILE_TYPES: remote_text, remote_failure_reason = await _try_remote_document_parse( raw_bytes=raw_bytes, filename=filename, media_type=response_media_type, ) if remote_text: normalized_text = _normalize_text(remote_text) if normalized_text: warnings.append(_remote_parse_warning(file_type))

这段代码解决了一个很现实的问题:很多家长手里的材料并不是标准电子文档,而是截图、扫描件、医院导出的图片型PDF。如果没有这层兜底,系统表面上支持上传,实际上拿不到有效文本,问答能力就会失效。

三、把材料从临时上下文改成正式问答字段

如果只是把解析后的文本拼接到用户问题后面,虽然也能勉强工作,但会带来两个问题:

1、前后端协议不清晰,后续维护时很容易混乱;
2、不同Agent链路、不同问答入口不一定都能稳定识别这份材料。

所以这周我把材料上下文从“临时拼接文本”升级成了正式请求字段。现在前后端的问答请求结构里,已经新增了document字段,专门承载上传材料的信息,包括文件名、文件类型、正文内容、摘要、字数和解析提示。

后端请求模型中的定义如下:

class QAAskRequest(BaseModel): question: QuestionText ... document: QADocumentContext | None = None image_base64: str | None = None

这样做之后,材料不再是某一条链路里的临时特殊处理,而是成为问答系统的一等输入。无论是普通问答、流式问答,还是路由后的健康、时间轴、营养模块,都可以统一读取这份材料。

四、材料不仅要传进去,还要真正参与推理

把材料作为正式字段传给后端,只是第一步。真正关键的是,模型在回答时要优先参考材料,而不是把材料丢在一边继续泛泛而谈。

因此,我又补了一层材料上下文构造逻辑。在后端的document_context_service.py里,我专门做了材料提示块构造,把材料信息按固定结构注入用户消息中,包括:

1、明确告诉模型“已附带一份用户上传材料,请优先基于材料内容回答”;
2、如果材料不足以支撑结论,要明确说明,再补充通用建议;
3、禁止向家长暴露任何内部流程,例如Agent、工具、数据库、同步动作等;
4、如果识别到材料像化验单、检查报告、表格数据,就要求模型按“总体判断—关键异常项—下一步建议”的方式组织回答。

这部分代码如下:

return ( "[上传材料]\n" f"{build_document_prompt_block(document)}\n\n" "[家长当前问题]\n" f"{normalized_question}" ).strip()

此外,我还让这份材料同时参与检索查询改写。也就是说,系统不仅在生成回答时看材料,在RAG检索阶段也会参考材料摘要,提升检索结果和材料本身的相关性。

这样一来,问答链路从“问一句答一句”升级成了“用户问题 + 上传材料 + 检索知识库”三者共同驱动的回答方式。

五、前端交互也一起重做:支持直接传材料发问

后端打通之后,前端交互也不能停留在“上传完再自己输入一句话”的原始状态。这周我继续把材料问答的发送逻辑和界面体验补完整。

首先,在问答页面里,我把文件选择器扩展成支持以下类型:

1、图片;
2、PDF;
3、TXT;
4、DOC;
5、DOCX。

前端文件类型配置如下:

fileLauncher.launch( arrayOf( "image/*", "application/pdf", "text/plain", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ), )

其次,我新增了“默认材料提问语句”的机制。也就是说,用户如果只上传材料、不手动输入问题,也可以直接发送,系统会自动补上一句默认提问:“请帮我分析一下这份材料。”

对应代码如下:

private fun buildOutgoingQuestion(question: String, attachment: QAAttachment?): PreparedQuestion? { val normalized = question.trim() if (normalized.length >= 2) { return PreparedQuestion(requestText = normalized, displayText = normalized) } if (normalized.isBlank()) { val fallback = attachment?.defaultQuestion ?: return null return PreparedQuestion( requestText = fallback, displayText = "请帮我分析这份材料", ) } return null }

这里我还专门区分了requestTextdisplayText。请求真正发给后端的是完整提问句,而聊天界面上显示的是更自然的“请帮我分析这份材料”,避免把内部兜底逻辑直接暴露给用户。

六、把“材料持续追问”做成真正可用的体验

如果上传材料后只能问一轮,那这个功能还是不够实用。家长真实的使用方式往往是先问一句“这张报告怎么看”,然后继续追问“白细胞高是不是感染”“这种情况要不要去医院”“明天能不能打疫苗”。

因此,这周我把材料状态保留机制也补齐了。当前实现中,文档解析成功后,前端会保留当前材料,并提示用户“当前材料会继续用于后续追问”。只要用户不主动替换或清空材料,后续问题都会默认带着这份文档上下文继续发送。

对应的状态文案也已经写进ViewModel里:

!extractedText.isNullOrBlank() -> "已提取 ${charCount ?: extractedText.length} 字,当前材料会继续用于后续追问"

前端发送请求时,也会持续把这份材料重新组装进问答请求:

document = buildDocumentRequest(pendingAttachment),

这个改动的意义很直接:问答不再是一轮一轮割裂的,而是围绕同一份材料形成连续对话,更接近实际的咨询过程。

七、修复一个很实际的问题:流式回答里不要出现内部系统话术

材料问答链路打通后,我在联调中发现一个问题:由于后端走了多Agent和工具调用链路,流式回答在某些情况下会把内部过程片段带出来,比如“我先检索”“通知时间轴Agent”“数据库已更新”之类的话。

这种内容对开发调试有价值,但对普通家长来说完全是噪音,甚至会破坏产品可信度。因此这周我专门增加了用户可见答案清洗逻辑。

answer_cleanup.py里,我统一处理了这类内部表达,包括:

1、替换时间轴Agent营养AgentSupervisor等内部词汇;
2、删除“我先查询”“工具调用”“数据库操作”“路由决策”等句子;
3、清理回答尾部未输出完整的内部短语残留;
4、保留真正需要展示给用户的正文和“猜你想问”部分。

例如,下面这组规则:

_DROP_PATTERNS: tuple[re.Pattern[str], ...] = ( re.compile(r"[^。!?\n]*(我先[^。!?\n]*(检索|查询|调用)[^。!?\n]*)[。!?]?", re.IGNORECASE), re.compile(r"[^。!?\n]*(创建健康记录|健康记录已创建|未触发疫苗延期|触发疫苗延期)[^。!?\n]*[。!?]?", re.IGNORECASE), re.compile(r"[^。!?\n]*(Agent|Supervisor|工具调用|路由决策|数据库操作)[^。!?\n]*[。!?]?", re.IGNORECASE), )

这个清洗模块加上之后,材料问答的最终输出明显干净了很多,回答会更像一个面向家长的产品,而不是后端调试窗口。

八、继续补体验细节:修复材料问答下“猜你想问”丢失

这周还有一个前端细节问题也被我一起处理了。之前在流式问答场景下,部分材料问题虽然主回答能够正常出来,但回答下方的“猜你想问”推荐有时会丢失,影响连续追问体验。

我检查后发现,问题不在模型没生成,而是在前端SSE完成事件和原始流文本解析之间没有统一好优先级。因此我把完成事件里的suggestions显式接进来,优先用后端返回的推荐问题;如果后端这一轮没有带,就再从流式文本中兜底解析。

对应代码如下:

_suggestedQuestions.value = event.suggestions.takeIf { it.isNotEmpty() } ?: parseSuggestions(streamingRawText)

九、为材料问答准备测试样本,验证不同格式链路

为了验证这一套能力,我还补了一批测试材料,覆盖了不同格式和不同场景。目前仓库中的测试材料包括:

1、发热血常规测试报告PDF;
2、发热血常规测试报告DOC;
3、发热血常规测试报告DOCX;
4、发热血常规测试报告TXT;
5、配料表测试图片;

十、本周总结

这周我完成的工作,表面上看是在问答模块里增加了材料上传能力,但本质上做的是一次完整的输入链路升级:让BabyMind从“用户输入一句话,我给一句回答”,进一步走向“用户带着真实材料来问,系统围绕材料持续给出结构化建议”。

具体来说,这一周我主要完成了以下几件事:

1、后端新增文档解析接口,支持TXT、PDF、DOC、DOCX等格式;
2、为扫描版PDF和传统DOC补上OCR/远程解析兜底;
3、把材料上下文升级为正式请求字段,统一接入问答链路;
4、让RAG检索和生成回答都能够参考上传材料;
5、前端支持直接选择文档,并允许“只传材料不输入问题”直接发问;
6、保留当前材料,用于后续连续追问;
7、清洗流式回答中的内部系统话术;
8、修复材料问答下“猜你想问”丢失的问题。

到这一阶段,BabyMind的问答能力已经不再局限于普通聊天,而是开始具备接住真实育儿材料的能力。后续我准备继续围绕材料理解做进一步细化,例如更稳定地识别检查报告中的关键指标、区分正常项与异常项、优化面向家长的解释方式,并继续补齐更多真实场景测试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询