diff --git a/backend/.env.example b/backend/.env.example index 06de962..80afac5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -29,9 +29,14 @@ REDIS_URL="redis://localhost:6379/0" # ==================== LLM AI 配置 ==================== # 大语言模型 API 配置 -LLM_API_KEY="your_api_key_here" -LLM_BASE_URL="" -LLM_MODEL_NAME="" +# 支持 OpenAI 兼容格式 (DeepSeek, 智谱 GLM, 阿里等) +# 智谱 AI (Zhipu AI) GLM 系列: +# - 模型: glm-4-flash (快速文本模型), glm-4 (标准), glm-4-plus (高性能) +# - API: https://open.bigmodel.cn +# - API Key: https://open.bigmodel.cn/usercenter/apikeys +LLM_API_KEY="ca79ad9f96524cd5afc3e43ca97f347d.cpiLLx2oyitGvTeU" +LLM_BASE_URL="https://open.bigmodel.cn/api/paas/v4" +LLM_MODEL_NAME="glm-4v-plus" # ==================== Supabase 配置 ==================== # Supabase 项目配置 diff --git a/backend/app/api/endpoints/ai_analyze.py b/backend/app/api/endpoints/ai_analyze.py index 7bdd930..ae13191 100644 --- a/backend/app/api/endpoints/ai_analyze.py +++ b/backend/app/api/endpoints/ai_analyze.py @@ -10,6 +10,8 @@ import os from app.services.excel_ai_service import excel_ai_service from app.services.markdown_ai_service import markdown_ai_service +from app.services.template_fill_service import template_fill_service +from app.services.word_ai_service import word_ai_service logger = logging.getLogger(__name__) @@ -340,3 +342,144 @@ async def get_markdown_outline( except Exception as e: logger.error(f"获取 Markdown 大纲失败: {str(e)}") raise HTTPException(status_code=500, detail=f"获取大纲失败: {str(e)}") + + +@router.post("/analyze/txt") +async def analyze_txt( + file: UploadFile = File(...), +): + """ + 上传并使用 AI 分析 TXT 文本文件,提取结构化数据 + + 将非结构化文本转换为结构化表格数据,便于后续填表使用 + + Args: + file: 上传的 TXT 文件 + + Returns: + dict: 分析结果,包含结构化表格数据 + """ + if not file.filename: + raise HTTPException(status_code=400, detail="文件名为空") + + file_ext = file.filename.split('.')[-1].lower() + if file_ext not in ['txt', 'text']: + raise HTTPException( + status_code=400, + detail=f"不支持的文件类型: {file_ext},仅支持 .txt" + ) + + try: + # 读取文件内容 + content = await file.read() + + # 保存到临时文件 + with tempfile.NamedTemporaryFile(mode='wb', suffix='.txt', delete=False) as tmp: + tmp.write(content) + tmp_path = tmp.name + + try: + logger.info(f"开始 AI 分析 TXT 文件: {file.filename}") + + # 使用 template_fill_service 的 AI 分析方法 + result = await template_fill_service.analyze_txt_with_ai( + content=content.decode('utf-8', errors='replace'), + filename=file.filename + ) + + if result: + logger.info(f"TXT AI 分析成功: {file.filename}") + return { + "success": True, + "filename": file.filename, + "structured_data": result + } + else: + logger.warning(f"TXT AI 分析返回空结果: {file.filename}") + return { + "success": False, + "filename": file.filename, + "error": "AI 分析未能提取到结构化数据", + "structured_data": None + } + + finally: + # 清理临时文件 + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + except HTTPException: + raise + except Exception as e: + logger.error(f"TXT AI 分析过程中出错: {str(e)}") + raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}") + + +# ==================== Word 文档 AI 解析 ==================== + +@router.post("/analyze/word") +async def analyze_word( + file: UploadFile = File(...), + user_hint: str = Query("", description="用户提示词,如'请提取表格数据'") +): + """ + 使用 AI 解析 Word 文档,提取结构化数据 + + 适用于从非结构化的 Word 文档中提取表格数据、键值对等信息 + + Args: + file: 上传的 Word 文件 + user_hint: 用户提示词 + + Returns: + dict: 包含结构化数据的解析结果 + """ + if not file.filename: + raise HTTPException(status_code=400, detail="文件名为空") + + file_ext = file.filename.split('.')[-1].lower() + if file_ext not in ['docx']: + raise HTTPException( + status_code=400, + detail=f"不支持的文件类型: {file_ext},仅支持 .docx" + ) + + try: + # 保存上传的文件 + content = await file.read() + suffix = f".{file_ext}" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(content) + tmp_path = tmp.name + + try: + # 使用 AI 解析 Word 文档 + result = await word_ai_service.parse_word_with_ai( + file_path=tmp_path, + user_hint=user_hint or "请提取文档中的所有结构化数据,包括表格、键值对等" + ) + + if result.get("success"): + return { + "success": True, + "filename": file.filename, + "result": result + } + else: + return { + "success": False, + "filename": file.filename, + "error": result.get("error", "AI 解析失败"), + "result": None + } + + finally: + # 清理临时文件 + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Word AI 分析过程中出错: {str(e)}") + raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}") diff --git a/backend/app/api/endpoints/documents.py b/backend/app/api/endpoints/documents.py index e8e206a..72a09b6 100644 --- a/backend/app/api/endpoints/documents.py +++ b/backend/app/api/endpoints/documents.py @@ -257,6 +257,50 @@ async def process_document( structured_data=result.data.get("structured_data") ) + # 如果是 Word 文档,使用 AI 深度解析 + if doc_type == "docx": + await redis_db.set_task_status( + task_id, status="processing", + meta={"progress": 40, "message": "正在使用 AI 解析 Word 文档"} + ) + + try: + from app.services.word_ai_service import word_ai_service + + logger.info(f"开始 AI 解析 Word 文档: {original_filename}") + ai_result = await word_ai_service.parse_word_with_ai( + file_path=file_path, + user_hint="请提取文档中的所有结构化数据,包括表格、键值对、列表项等" + ) + + if ai_result.get("success"): + # 更新 MongoDB 文档,添加 AI 解析结果 + ai_data = { + "ai_parsed": True, + "parse_type": ai_result.get("type", "unknown"), + "headers": ai_result.get("headers", []), + "rows": ai_result.get("rows", []), + "tables": ai_result.get("tables", []), + "key_values": ai_result.get("key_values", {}), + "list_items": ai_result.get("list_items", []), + "summary": ai_result.get("summary", ""), + "description": ai_result.get("description", "") + } + + await mongodb.update_document(doc_id, { + "ai_analysis": ai_data, + "structured_data": { + **result.data.get("structured_data", {}), + **ai_data + } + }) + logger.info(f"Word AI 解析成功: {original_filename}, type={ai_result.get('type')}") + else: + logger.warning(f"Word AI 解析返回失败: {ai_result.get('error')}") + + except Exception as e: + logger.error(f"Word AI 解析异常: {str(e)}", exc_info=True) + # 如果是 Excel,存储到 MySQL + AI生成描述 + RAG索引 if doc_type in ["xlsx", "xls"]: await update_task_status( diff --git a/backend/app/api/endpoints/templates.py b/backend/app/api/endpoints/templates.py index 625b274..bdbb3f0 100644 --- a/backend/app/api/endpoints/templates.py +++ b/backend/app/api/endpoints/templates.py @@ -89,6 +89,13 @@ class ExportRequest(BaseModel): format: str = "xlsx" # xlsx 或 docx +class FillAndExportRequest(BaseModel): + """填充并导出请求 - 直接填充原始模板""" + template_path: str # 模板文件路径 + filled_data: dict # 填写数据,格式: {字段名: [值1, 值2, ...]} 或 {字段名: 单个值} + format: str = "xlsx" # xlsx 或 docx + + # ==================== 接口实现 ==================== @router.post("/upload") @@ -692,3 +699,427 @@ async def export_to_word( Word 文件流 """ return await _export_to_word(filled_data, template_id) + + +@router.post("/fill-and-export") +async def fill_and_export_template( + request: FillAndExportRequest, +): + """ + 填充原始模板并导出 + + 直接打开原始模板文件,将数据填入模板的表格中,然后导出 + + Args: + request: 填充并导出请求 + + Returns: + 填充后的模板文件流 + """ + import os + + logger.info(f"=== fill-and-export 请求 ===") + logger.info(f"template_path: {request.template_path}") + logger.info(f"format: {request.format}") + logger.info(f"filled_data: {request.filled_data}") + logger.info(f"filled_data 类型: {type(request.filled_data)}") + logger.info(f"filled_data 键数量: {len(request.filled_data) if request.filled_data else 0}") + logger.info(f"=========================") + + template_path = request.template_path + + # 检查模板文件是否存在 + if not os.path.exists(template_path): + raise HTTPException(status_code=404, detail=f"模板文件不存在: {template_path}") + + file_ext = os.path.splitext(template_path)[1].lower() + + try: + if file_ext in ['.xlsx', '.xls']: + return await _fill_and_export_excel(template_path, request.filled_data) + elif file_ext == '.docx': + return await _fill_and_export_word(template_path, request.filled_data) + else: + raise HTTPException( + status_code=400, + detail=f"不支持的模板格式: {file_ext},仅支持 xlsx/xls/docx" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"填充模板失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"填充模板失败: {str(e)}") + + +async def _fill_and_export_word(template_path: str, filled_data: dict) -> StreamingResponse: + """ + 填充原始 Word 模板 + + 打开原始 Word 模板,找到表格,将数据填入对应单元格 + + Args: + template_path: 模板文件路径 + filled_data: 填写数据 {字段名: [值1, 值2, ...]} + + Returns: + 填充后的 Word 文件流 + """ + from docx import Document + + logger.info(f"填充 Word 模板: {template_path}") + logger.info(f"填写数据字段: {list(filled_data.keys())}") + + # 打开原始模板 + doc = Document(template_path) + + # 找到第一个表格(比赛模板通常是第一个表格) + if not doc.tables: + logger.warning("Word 模板中没有表格,创建新表格") + # 如果没有表格,创建一个 + table = doc.add_table(rows=len(filled_data) + 1, cols=2) + + # 表头 + header_cells = table.rows[0].cells + header_cells[0].text = '字段名' + header_cells[1].text = '填写值' + + # 数据行 + for idx, (field_name, values) in enumerate(filled_data.items()): + row_cells = table.rows[idx + 1].cells + row_cells[0].text = field_name + if isinstance(values, list): + row_cells[1].text = '; '.join(str(v) for v in values if v) + else: + row_cells[1].text = str(values) if values else '' + else: + # 填充第一个表格 + table = doc.tables[0] + logger.info(f"找到表格,行数: {len(table.rows)}, 列数: {len(table.columns)}") + + # 打印表格内容(调试用) + logger.info("=== 表格内容预览 ===") + for row_idx, row in enumerate(table.rows[:5]): # 只打印前5行 + row_texts = [cell.text.strip() for cell in row.cells] + logger.info(f" 行{row_idx}: {row_texts}") + logger.info("========================") + + # 构建字段名到列索引的映射 + field_to_col = {} + if table.rows: + # 假设第一行是表头 + header_row = table.rows[0] + for col_idx, cell in enumerate(header_row.cells): + field_name = cell.text.strip() + if field_name: + field_to_col[field_name] = col_idx + field_to_col[field_name.lower()] = col_idx # 忽略大小写 + + logger.info(f"表头字段映射: {field_to_col}") + logger.info(f"待填充数据字段: {list(filled_data.keys())}") + + # 填充数据 + filled_count = 0 + for field_name, values in filled_data.items(): + # 查找匹配的列 + col_idx = field_to_col.get(field_name) + + if col_idx is None: + # 尝试模糊匹配 + for c_idx in range(len(table.columns)): + header_text = table.rows[0].cells[c_idx].text.strip().lower() + if field_name.lower() in header_text or header_text in field_name.lower(): + col_idx = c_idx + logger.info(f"模糊匹配成功: '{field_name}' -> 列 {col_idx}") + break + else: + col_idx = None + + if col_idx is not None and col_idx < len(table.columns): + # 填充该列的所有数据行 + if isinstance(values, list): + value_str = '; '.join(str(v) for v in values if v) + else: + value_str = str(values) if values else '' + + # 填充每一行(从第二行开始,跳过表头) + for row_idx in range(1, min(len(table.rows), len(values) + 1) if isinstance(values, list) else 2): + try: + cell = table.rows[row_idx].cells[col_idx] + if isinstance(values, list) and row_idx - 1 < len(values): + cell.text = str(values[row_idx - 1]) if values[row_idx - 1] else '' + elif not isinstance(values, list): + if row_idx == 1: + cell.text = str(values) if values else '' + except Exception as e: + logger.warning(f"填充单元格失败 [{row_idx}][{col_idx}]: {e}") + + filled_count += 1 + logger.info(f"✅ 字段 '{field_name}' -> 列 {col_idx}, 值: {value_str[:50]}") + else: + logger.warning(f"❌ 未找到字段 '{field_name}' 对应的列") + + logger.info(f"填充完成: {filled_count}/{len(filled_data)} 个字段") + + # 保存到 BytesIO + output = io.BytesIO() + doc.save(output) + output.seek(0) + + filename = f"filled_template.docx" + + logger.info(f"Word 模板填充完成") + + return StreamingResponse( + io.BytesIO(output.getvalue()), + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +async def _fill_and_export_excel(template_path: str, filled_data: dict) -> StreamingResponse: + """ + 填充原始 Excel 模板 + + 打开原始 Excel 模板,找到对应列,将数据填入 + + Args: + template_path: 模板文件路径 + filled_data: 填写数据 {字段名: [值1, 值2, ...]} + + Returns: + 填充后的 Excel 文件流 + """ + from openpyxl import load_workbook + import os + + logger.info(f"填充 Excel 模板: {template_path}") + logger.info(f"填写数据: {list(filled_data.keys())}") + + # 检查文件是否存在 + if not os.path.exists(template_path): + raise HTTPException(status_code=404, detail=f"模板文件不存在: {template_path}") + + # 打开原始模板 + wb = load_workbook(template_path) + ws = wb.active # 获取当前工作表 + + logger.info(f"工作表: {ws.title}, 行数: {ws.max_row}, 列数: {ws.max_column}") + + # 读取表头行(假设第一行是表头) + header_row = 1 + field_to_col = {} + + for col_idx in range(1, ws.max_column + 1): + cell_value = ws.cell(row=header_row, column=col_idx).value + if cell_value: + field_name = str(cell_value).strip() + field_to_col[field_name] = col_idx + field_to_col[field_name.lower()] = col_idx # 忽略大小写 + + logger.info(f"表头字段映射: {field_to_col}") + + # 计算最大行数 + max_rows = 1 + for values in filled_data.values(): + if isinstance(values, list): + max_rows = max(max_rows, len(values)) + + # 填充数据 + for field_name, values in filled_data.items(): + # 查找匹配的列 + col_idx = field_to_col.get(field_name) + + if col_idx is None: + # 尝试模糊匹配 + for col_idx in range(1, ws.max_column + 1): + header_text = str(ws.cell(row=header_row, column=col_idx).value or '').strip().lower() + if field_name.lower() in header_text or header_text in field_name.lower(): + break + else: + col_idx = None + + if col_idx is not None: + # 填充数据(从第二行开始) + if isinstance(values, list): + for row_idx, value in enumerate(values, start=2): + ws.cell(row=row_idx, column=col_idx, value=value if value else '') + else: + ws.cell(row=2, column=col_idx, value=values if values else '') + + logger.info(f"字段 {field_name} -> 列 {col_idx}, 值数量: {len(values) if isinstance(values, list) else 1}") + else: + logger.warning(f"未找到字段 {field_name} 对应的列") + + # 如果需要扩展行数 + current_max_row = ws.max_row + if max_rows > current_max_row - 1: # -1 是因为表头占一行 + # 扩展样式(简单复制最后一行) + for row_idx in range(current_max_row + 1, max_rows + 2): + for col_idx in range(1, ws.max_column + 1): + source_cell = ws.cell(row=current_max_row, column=col_idx) + target_cell = ws.cell(row=row_idx, column=col_idx) + # 复制值(如果有对应数据) + if isinstance(filled_data.get(str(ws.cell(row=1, column=col_idx).value), []), list): + data_idx = row_idx - 2 + data_list = filled_data.get(str(ws.cell(row=1, column=col_idx).value), []) + if data_idx < len(data_list): + target_cell.value = data_list[data_idx] + + # 保存到 BytesIO + output = io.BytesIO() + wb.save(output) + output.seek(0) + + # 关闭工作簿 + wb.close() + + filename = f"filled_template.xlsx" + + logger.info(f"Excel 模板填充完成") + + return StreamingResponse( + io.BytesIO(output.getvalue()), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +# ==================== Word 文档结构化字段提取接口 ==================== + +@router.post("/parse-word-structure") +async def parse_word_structure( + file: UploadFile = File(...), +): + """ + 上传 Word 文档,提取结构化字段并存入数据库 + + 专门用于比赛场景:从 Word 表格模板中提取字段定义 + (字段名、提示词、字段类型等)并存入 MongoDB + + Args: + file: 上传的 Word 文件 + + Returns: + 提取的结构化字段信息 + """ + if not file.filename: + raise HTTPException(status_code=400, detail="文件名为空") + + file_ext = file.filename.split('.')[-1].lower() + if file_ext != 'docx': + raise HTTPException( + status_code=400, + detail=f"不支持的文件类型: {file_ext},仅支持 docx" + ) + + try: + # 1. 保存文件 + content = await file.read() + saved_path = file_service.save_uploaded_file( + content, + file.filename, + subfolder="word_templates" + ) + logger.info(f"Word 文件已保存: {saved_path}") + + # 2. 解析文档,提取结构化数据 + parser = ParserFactory.get_parser(saved_path) + parse_result = parser.parse(saved_path) + + if not parse_result.success: + raise HTTPException(status_code=400, detail=parse_result.error) + + # 3. 提取表格模板字段 + from app.core.document_parser.docx_parser import DocxParser + docx_parser = DocxParser() + template_fields = docx_parser.extract_template_fields_from_docx(saved_path) + + logger.info(f"从 Word 文档提取到 {len(template_fields)} 个字段") + + # 4. 提取完整的结构化信息 + template_info = docx_parser.parse_tables_for_template(saved_path) + + # 5. 存储到 MongoDB + doc_id = await mongodb.insert_document( + doc_type="docx", + content=parse_result.data.get("content", ""), + metadata={ + **parse_result.metadata, + "original_filename": file.filename, + "file_path": saved_path, + "template_fields": template_fields, + "table_count": len(template_info.get("tables", [])), + "field_count": len(template_fields) + }, + structured_data={ + **parse_result.data.get("structured_data", {}), + "template_fields": template_fields, + "template_info": template_info + } + ) + + logger.info(f"Word 文档结构化信息已存入 MongoDB, doc_id: {doc_id}") + + # 6. 返回结果 + return { + "success": True, + "doc_id": doc_id, + "filename": file.filename, + "file_path": saved_path, + "field_count": len(template_fields), + "fields": template_fields, + "tables": template_info.get("tables", []), + "metadata": { + "paragraph_count": parse_result.metadata.get("paragraph_count", 0), + "table_count": parse_result.metadata.get("table_count", 0), + "word_count": parse_result.metadata.get("word_count", 0), + "has_tables": parse_result.metadata.get("has_tables", False) + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"解析 Word 文档结构失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"解析失败: {str(e)}") + + +@router.get("/word-fields/{doc_id}") +async def get_word_template_fields( + doc_id: str, +): + """ + 根据 doc_id 获取 Word 文档的模板字段信息 + + Args: + doc_id: MongoDB 文档 ID + + Returns: + 模板字段信息 + """ + try: + doc = await mongodb.get_document(doc_id) + + if not doc: + raise HTTPException(status_code=404, detail=f"文档不存在: {doc_id}") + + # 从 structured_data 中提取模板字段信息 + structured_data = doc.get("structured_data", {}) + template_fields = structured_data.get("template_fields", []) + template_info = structured_data.get("template_info", {}) + + return { + "success": True, + "doc_id": doc_id, + "filename": doc.get("metadata", {}).get("original_filename", ""), + "fields": template_fields, + "tables": template_info.get("tables", []), + "field_count": len(template_fields), + "metadata": doc.get("metadata", {}) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取 Word 模板字段失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取失败: {str(e)}") diff --git a/backend/app/core/database/mongodb.py b/backend/app/core/database/mongodb.py index 90ddb44..0a20cd2 100644 --- a/backend/app/core/database/mongodb.py +++ b/backend/app/core/database/mongodb.py @@ -99,6 +99,28 @@ class MongoDB: logger.info(f"✓ 文档已存入MongoDB: [{doc_type}] {filename} | ID: {doc_id}") return doc_id + async def update_document(self, doc_id: str, updates: Dict[str, Any]) -> bool: + """ + 更新文档 + + Args: + doc_id: 文档ID + updates: 要更新的字段字典 + + Returns: + 是否更新成功 + """ + from bson import ObjectId + try: + result = await self.documents.update_one( + {"_id": ObjectId(doc_id)}, + {"$set": updates} + ) + return result.modified_count > 0 + except Exception as e: + logger.error(f"更新文档失败 {doc_id}: {str(e)}") + return False + async def get_document(self, doc_id: str) -> Optional[Dict[str, Any]]: """根据ID获取文档""" from bson import ObjectId diff --git a/backend/app/core/document_parser/docx_parser.py b/backend/app/core/document_parser/docx_parser.py index 03c341d..db79512 100644 --- a/backend/app/core/document_parser/docx_parser.py +++ b/backend/app/core/document_parser/docx_parser.py @@ -59,7 +59,13 @@ class DocxParser(BaseParser): paragraphs = [] for para in doc.paragraphs: if para.text.strip(): - paragraphs.append(para.text) + paragraphs.append({ + "text": para.text, + "style": str(para.style.name) if para.style else "Normal" + }) + + # 提取段落纯文本(用于 AI 解析) + paragraphs_text = [p["text"] for p in paragraphs if p["text"].strip()] # 提取表格内容 tables_data = [] @@ -77,8 +83,25 @@ class DocxParser(BaseParser): "column_count": len(table_rows[0]) if table_rows else 0 }) - # 合并所有文本 - full_text = "\n".join(paragraphs) + # 提取图片/嵌入式对象信息 + images_info = self._extract_images_info(doc, path) + + # 合并所有文本(包括图片描述) + full_text_parts = [] + full_text_parts.append("【文档正文】") + full_text_parts.extend(paragraphs_text) + + if tables_data: + full_text_parts.append("\n【文档表格】") + for idx, table in enumerate(tables_data): + full_text_parts.append(f"--- 表格 {idx + 1} ---") + for row in table["rows"]: + full_text_parts.append(" | ".join(str(cell) for cell in row)) + + if images_info.get("image_count", 0) > 0: + full_text_parts.append(f"\n【文档图片】文档包含 {images_info['image_count']} 张图片/图表") + + full_text = "\n".join(full_text_parts) # 构建元数据 metadata = { @@ -89,7 +112,9 @@ class DocxParser(BaseParser): "table_count": len(tables_data), "word_count": len(full_text), "char_count": len(full_text.replace("\n", "")), - "has_tables": len(tables_data) > 0 + "has_tables": len(tables_data) > 0, + "has_images": images_info.get("image_count", 0) > 0, + "image_count": images_info.get("image_count", 0) } # 返回结果 @@ -97,12 +122,16 @@ class DocxParser(BaseParser): success=True, data={ "content": full_text, - "paragraphs": paragraphs, + "paragraphs": paragraphs_text, + "paragraphs_with_style": paragraphs, "tables": tables_data, + "images": images_info, "word_count": len(full_text), "structured_data": { "paragraphs": paragraphs, - "tables": tables_data + "paragraphs_text": paragraphs_text, + "tables": tables_data, + "images": images_info } }, metadata=metadata @@ -115,6 +144,59 @@ class DocxParser(BaseParser): error=f"解析 Word 文档失败: {str(e)}" ) + def extract_images_as_base64(self, file_path: str) -> List[Dict[str, str]]: + """ + 提取 Word 文档中的所有图片,返回 base64 编码列表 + + Args: + file_path: Word 文件路径 + + Returns: + 图片列表,每项包含 base64 编码和图片类型 + """ + import zipfile + import base64 + from io import BytesIO + + images = [] + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + # 查找 word/media 目录下的图片文件 + for filename in zf.namelist(): + if filename.startswith('word/media/'): + # 获取图片类型 + ext = filename.split('.')[-1].lower() + mime_types = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'bmp': 'image/bmp' + } + mime_type = mime_types.get(ext, 'image/png') + + try: + # 读取图片数据并转为 base64 + image_data = zf.read(filename) + base64_data = base64.b64encode(image_data).decode('utf-8') + + images.append({ + "filename": filename, + "mime_type": mime_type, + "base64": base64_data, + "size": len(image_data) + }) + logger.info(f"提取图片: {filename}, 大小: {len(image_data)} bytes") + except Exception as e: + logger.warning(f"提取图片失败 {filename}: {str(e)}") + + except Exception as e: + logger.error(f"打开 Word 文档提取图片失败: {str(e)}") + + logger.info(f"共提取 {len(images)} 张图片") + return images + def extract_key_sentences(self, text: str, max_sentences: int = 10) -> List[str]: """ 从文本中提取关键句子 @@ -268,6 +350,60 @@ class DocxParser(BaseParser): return fields + def _extract_images_info(self, doc: Document, path: Path) -> Dict[str, Any]: + """ + 提取 Word 文档中的图片/嵌入式对象信息 + + Args: + doc: Document 对象 + path: 文件路径 + + Returns: + 图片信息字典 + """ + import zipfile + from io import BytesIO + + image_count = 0 + image_descriptions = [] + inline_shapes_count = 0 + + try: + # 方法1: 通过 inline shapes 统计图片 + try: + inline_shapes_count = len(doc.inline_shapes) + if inline_shapes_count > 0: + image_count = inline_shapes_count + image_descriptions.append(f"文档包含 {inline_shapes_count} 个嵌入式图形/图片") + except Exception: + pass + + # 方法2: 通过 ZIP 分析 document.xml 获取图片引用 + try: + with zipfile.ZipFile(path, 'r') as zf: + # 查找 word/media 目录下的图片文件 + media_files = [f for f in zf.namelist() if f.startswith('word/media/')] + if media_files and not inline_shapes_count: + image_count = len(media_files) + image_descriptions.append(f"文档包含 {image_count} 个嵌入图片") + + # 检查是否有页眉页脚中的图片 + header_images = [f for f in zf.namelist() if 'header' in f.lower() and f.endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp'))] + if header_images: + image_descriptions.append(f"页眉/页脚包含 {len(header_images)} 个图片") + except Exception: + pass + + except Exception as e: + logger.warning(f"提取图片信息失败: {str(e)}") + + return { + "image_count": image_count, + "inline_shapes_count": inline_shapes_count, + "descriptions": image_descriptions, + "has_images": image_count > 0 + } + def _infer_field_type_from_hint(self, hint: str) -> str: """ 从提示词推断字段类型 diff --git a/backend/app/main.py b/backend/app/main.py index 86c3a9d..6704a58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,13 @@ """ FastAPI 应用主入口 """ +# ========== 压制 MongoDB 疯狂刷屏日志 ========== +import logging +logging.getLogger("pymongo").setLevel(logging.WARNING) +logging.getLogger("pymongo.topology").setLevel(logging.WARNING) +logging.getLogger("urllib3").setLevel(logging.WARNING) +# ============================================== + import logging import logging.handlers import sys diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 8878deb..fac51e3 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -65,7 +65,17 @@ class LLMService: return response.json() except httpx.HTTPStatusError as e: - logger.error(f"LLM API 请求失败: {e.response.status_code} - {e.response.text}") + error_detail = e.response.text + logger.error(f"LLM API 请求失败: {e.response.status_code} - {error_detail}") + # 尝试解析错误信息 + try: + import json + err_json = json.loads(error_detail) + err_code = err_json.get("error", {}).get("code", "unknown") + err_msg = err_json.get("error", {}).get("message", "unknown") + logger.error(f"API 错误码: {err_code}, 错误信息: {err_msg}") + except: + pass raise except Exception as e: logger.error(f"LLM API 调用异常: {str(e)}") @@ -328,6 +338,154 @@ Excel 数据概览: "analysis": None } + async def chat_with_images( + self, + text: str, + images: List[Dict[str, str]], + temperature: float = 0.7, + max_tokens: Optional[int] = None + ) -> Dict[str, Any]: + """ + 调用视觉模型 API(支持图片输入) + + Args: + text: 文本内容 + images: 图片列表,每项包含 base64 编码和 mime_type + 格式: [{"base64": "...", "mime_type": "image/png"}, ...] + temperature: 温度参数 + max_tokens: 最大 token 数 + + Returns: + Dict[str, Any]: API 响应结果 + """ + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + # 构建图片内容 + image_contents = [] + for img in images: + image_contents.append({ + "type": "image_url", + "image_url": { + "url": f"data:{img['mime_type']};base64,{img['base64']}" + } + }) + + # 构建消息 + messages = [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": text + }, + *image_contents + ] + } + ] + + payload = { + "model": self.model_name, + "messages": messages, + "temperature": temperature + } + + if max_tokens: + payload["max_tokens"] = max_tokens + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{self.base_url}/chat/completions", + headers=headers, + json=payload + ) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + error_detail = e.response.text + logger.error(f"视觉模型 API 请求失败: {e.response.status_code} - {error_detail}") + # 尝试解析错误信息 + try: + import json + err_json = json.loads(error_detail) + err_code = err_json.get("error", {}).get("code", "unknown") + err_msg = err_json.get("error", {}).get("message", "unknown") + logger.error(f"API 错误码: {err_code}, 错误信息: {err_msg}") + logger.error(f"请求模型: {self.model_name}, base_url: {self.base_url}") + except: + pass + raise + except Exception as e: + logger.error(f"视觉模型 API 调用异常: {str(e)}") + raise + + async def analyze_images( + self, + images: List[Dict[str, str]], + user_prompt: str = "" + ) -> Dict[str, Any]: + """ + 分析图片内容(使用视觉模型) + + Args: + images: 图片列表,每项包含 base64 编码和 mime_type + user_prompt: 用户提示词 + + Returns: + Dict[str, Any]: 分析结果 + """ + prompt = f"""你是一个专业的视觉分析专家。请分析以下图片内容。 + +{user_prompt if user_prompt else "请详细描述图片中的内容,包括文字、数据、图表、流程等所有可见信息。"} + +请按照以下 JSON 格式输出: +{{ + "description": "图片内容的详细描述", + "text_content": "图片中的文字内容(如有)", + "data_extracted": {{"键": "值"}} // 如果图片中有表格或数据 +}} + +如果图片不包含有用信息,请返回空的描述。""" + + try: + response = await self.chat_with_images( + text=prompt, + images=images, + temperature=0.1, + max_tokens=4000 + ) + + content = self.extract_message_content(response) + + # 解析 JSON + import json + try: + result = json.loads(content) + return { + "success": True, + "analysis": result, + "model": self.model_name + } + except json.JSONDecodeError: + return { + "success": True, + "analysis": {"description": content}, + "model": self.model_name + } + + except Exception as e: + logger.error(f"图片分析失败: {str(e)}") + return { + "success": False, + "error": str(e), + "analysis": None + } + # 全局单例 llm_service = LLMService() diff --git a/backend/app/services/template_fill_service.py b/backend/app/services/template_fill_service.py index 9465d35..35b2292 100644 --- a/backend/app/services/template_fill_service.py +++ b/backend/app/services/template_fill_service.py @@ -13,6 +13,7 @@ from app.services.llm_service import llm_service from app.core.document_parser import ParserFactory from app.services.markdown_ai_service import markdown_ai_service from app.services.rag_service import rag_service +from app.services.word_ai_service import word_ai_service logger = logging.getLogger(__name__) diff --git a/backend/app/services/word_ai_service.py b/backend/app/services/word_ai_service.py new file mode 100644 index 0000000..197b256 --- /dev/null +++ b/backend/app/services/word_ai_service.py @@ -0,0 +1,637 @@ +""" +Word 文档 AI 解析服务 + +使用 LLM (GLM) 对 Word 文档进行深度理解,提取结构化数据 +""" +import logging +from typing import Dict, Any, List, Optional +import json + +from app.services.llm_service import llm_service +from app.core.document_parser.docx_parser import DocxParser + +logger = logging.getLogger(__name__) + + +class WordAIService: + """Word 文档 AI 解析服务""" + + def __init__(self): + self.llm = llm_service + self.parser = DocxParser() + + async def parse_word_with_ai( + self, + file_path: str, + user_hint: str = "" + ) -> Dict[str, Any]: + """ + 使用 AI 解析 Word 文档,提取结构化数据 + + 适用于从非结构化的 Word 文档中提取表格数据、键值对等信息 + + Args: + file_path: Word 文件路径 + user_hint: 用户提示词,指定要提取的内容类型 + + Returns: + Dict: 包含结构化数据的解析结果 + """ + try: + # 1. 先用基础解析器提取原始内容 + parse_result = self.parser.parse(file_path) + + if not parse_result.success: + return { + "success": False, + "error": parse_result.error, + "structured_data": None + } + + # 2. 获取原始数据 + raw_data = parse_result.data + paragraphs = raw_data.get("paragraphs", []) + paragraphs_with_style = raw_data.get("paragraphs_with_style", []) + tables = raw_data.get("tables", []) + content = raw_data.get("content", "") + images_info = raw_data.get("images", {}) + metadata = parse_result.metadata or {} + + image_count = images_info.get("image_count", 0) + image_descriptions = images_info.get("descriptions", []) + + logger.info(f"Word 基础解析完成: {len(paragraphs)} 个段落, {len(tables)} 个表格, {image_count} 张图片") + + # 3. 提取图片数据(用于视觉分析) + images_base64 = [] + if image_count > 0: + try: + images_base64 = self.parser.extract_images_as_base64(file_path) + logger.info(f"提取到 {len(images_base64)} 张图片的 base64 数据") + except Exception as e: + logger.warning(f"提取图片 base64 失败: {str(e)}") + + # 4. 根据内容类型选择 AI 解析策略 + # 如果有图片,先分析图片 + image_analysis = "" + if images_base64: + image_analysis = await self._analyze_images_with_ai(images_base64, user_hint) + logger.info(f"图片 AI 分析完成: {len(image_analysis)} 字符") + + # 优先处理:表格 > (表格+文本) > 纯文本 + if tables and len(tables) > 0: + structured_data = await self._extract_tables_with_ai( + tables, paragraphs, image_count, user_hint, metadata, image_analysis + ) + elif paragraphs and len(paragraphs) > 0: + structured_data = await self._extract_from_text_with_ai( + paragraphs, content, image_count, image_descriptions, user_hint, image_analysis + ) + else: + structured_data = { + "success": True, + "type": "empty", + "message": "文档内容为空" + } + + # 添加图片分析结果 + if image_analysis: + structured_data["image_analysis"] = image_analysis + + return structured_data + + except Exception as e: + logger.error(f"AI 解析 Word 文档失败: {str(e)}") + return { + "success": False, + "error": str(e), + "structured_data": None + } + + async def _extract_tables_with_ai( + self, + tables: List[Dict], + paragraphs: List[str], + image_count: int, + user_hint: str, + metadata: Dict, + image_analysis: str = "" + ) -> Dict[str, Any]: + """ + 使用 AI 从 Word 表格和文本中提取结构化数据 + + Args: + tables: 表格列表 + paragraphs: 段落列表 + image_count: 图片数量 + user_hint: 用户提示 + metadata: 文档元数据 + image_analysis: 图片 AI 分析结果 + + Returns: + 结构化数据 + """ + try: + # 构建表格文本描述 + tables_text = self._build_tables_description(tables) + + # 构建段落描述 + paragraphs_text = "\n".join(paragraphs[:50]) if paragraphs else "(无正文文本)" + if len(paragraphs) > 50: + paragraphs_text += f"\n...(共 {len(paragraphs)} 个段落,仅显示前50个)" + + # 图片提示 + image_hint = f"注意:此文档包含 {image_count} 张图片/图表。" if image_count > 0 else "" + + prompt = f"""你是一个专业的数据提取专家。请从以下 Word 文档的完整内容中提取结构化数据。 + +【用户需求】 +{user_hint if user_hint else "请提取文档中的所有结构化数据,包括表格数据、键值对、列表项等。"} + +【文档正文(段落)】 +{paragraphs_text} + +【文档表格】 +{tables_text} + +【文档图片信息】 +{image_hint} + +请按照以下 JSON 格式输出: +{{ + "type": "table_data", + "headers": ["列1", "列2", ...], + "rows": [["行1列1", "行1列2", ...], ["行2列1", "行2列2", ...], ...], + "key_values": {{"键1": "值1", "键2": "值2", ...}}, + "list_items": ["项1", "项2", ...], + "description": "文档内容描述" +}} + +重点: +- 优先从表格中提取结构化数据 +- 如果表格中有表头,headers 是表头,rows 是数据行 +- 如果文档中有键值对(如 名称: 张三),提取到 key_values 中 +- 如果文档中有列表项,提取到 list_items 中 +- 图片内容无法直接提取,但请在 description 中说明图片的大致主题(如"包含流程图"、"包含数据图表"等) +""" + + messages = [ + {"role": "system", "content": "你是一个专业的数据提取助手。请严格按JSON格式输出。"}, + {"role": "user", "content": prompt} + ] + + response = await self.llm.chat( + messages=messages, + temperature=0.1, + max_tokens=50000 + ) + + content = self.llm.extract_message_content(response) + + # 解析 JSON + result = self._parse_json_response(content) + + if result: + logger.info(f"AI 表格提取成功: {len(result.get('rows', []))} 行数据") + return { + "success": True, + "type": "table_data", + "headers": result.get("headers", []), + "rows": result.get("rows", []), + "description": result.get("description", "") + } + else: + # 如果 AI 返回格式不对,尝试直接解析表格 + return self._fallback_table_parse(tables) + + except Exception as e: + logger.error(f"AI 表格提取失败: {str(e)}") + return self._fallback_table_parse(tables) + + async def _extract_from_text_with_ai( + self, + paragraphs: List[str], + full_text: str, + image_count: int, + image_descriptions: List[str], + user_hint: str, + image_analysis: str = "" + ) -> Dict[str, Any]: + """ + 使用 AI 从 Word 纯文本中提取结构化数据 + + Args: + paragraphs: 段落列表 + full_text: 完整文本 + image_count: 图片数量 + image_descriptions: 图片描述列表 + user_hint: 用户提示 + image_analysis: 图片 AI 分析结果 + + Returns: + 结构化数据 + """ + try: + # 限制文本长度 + text_preview = full_text[:8000] if len(full_text) > 8000 else full_text + + # 图片提示 + image_hint = f"\n【文档图片】此文档包含 {image_count} 张图片/图表。" if image_count > 0 else "" + if image_descriptions: + image_hint += "\n" + "\n".join(image_descriptions) + + prompt = f"""你是一个专业的数据提取专家。请从以下 Word 文档的完整内容中提取结构化数据。 + +【用户需求】 +{user_hint if user_hint else "请识别并提取文档中的关键信息,包括:表格数据、键值对、列表项等。"} + +【文档正文】{image_hint} +{text_preview} + +请按照以下 JSON 格式输出: +{{ + "type": "structured_text", + "tables": [{{"headers": [...], "rows": [...]}}], + "key_values": {{"键1": "值1", "键2": "值2", ...}}, + "list_items": ["项1", "项2", ...], + "summary": "文档内容摘要" +}} + +重点: +- 如果文档包含表格数据,提取到 tables 中 +- 如果文档包含键值对(如 名称: 张三),提取到 key_values 中 +- 如果文档包含列表项,提取到 list_items 中 +- 如果文档包含图片,请根据上下文推断图片内容(如"流程图"、"数据折线图"等)并在 description 中说明 +- 如果无法提取到结构化数据,至少提供一个详细的摘要 +""" + + messages = [ + {"role": "system", "content": "你是一个专业的数据提取助手。请严格按JSON格式输出。"}, + {"role": "user", "content": prompt} + ] + + response = await self.llm.chat( + messages=messages, + temperature=0.1, + max_tokens=50000 + ) + + content = self.llm.extract_message_content(response) + + result = self._parse_json_response(content) + + if result: + logger.info(f"AI 文本提取成功: type={result.get('type')}") + return { + "success": True, + "type": result.get("type", "structured_text"), + "tables": result.get("tables", []), + "key_values": result.get("key_values", {}), + "list_items": result.get("list_items", []), + "summary": result.get("summary", ""), + "raw_text_preview": text_preview[:500] + } + else: + return { + "success": True, + "type": "text", + "summary": text_preview[:500], + "raw_text_preview": text_preview[:500] + } + + except Exception as e: + logger.error(f"AI 文本提取失败: {str(e)}") + return { + "success": False, + "error": str(e) + } + + async def _analyze_images_with_ai( + self, + images: List[Dict[str, str]], + user_hint: str = "" + ) -> str: + """ + 使用视觉模型分析 Word 文档中的图片 + + Args: + images: 图片列表,每项包含 base64 和 mime_type + user_hint: 用户提示 + + Returns: + 图片分析结果文本 + """ + try: + # 调用 LLM 的视觉分析功能 + result = await self.llm.analyze_images( + images=images, + user_prompt=user_hint or "请详细描述图片内容,提取所有文字和数据信息。" + ) + + if result.get("success"): + analysis = result.get("analysis", {}) + if isinstance(analysis, dict): + description = analysis.get("description", "") + text_content = analysis.get("text_content", "") + data_extracted = analysis.get("data_extracted", {}) + + result_text = f"【图片分析结果】\n{description}" + if text_content: + result_text += f"\n\n【图片中的文字】\n{text_content}" + if data_extracted: + result_text += f"\n\n【提取的数据】\n{json.dumps(data_extracted, ensure_ascii=False)}" + return result_text + else: + return str(analysis) + else: + logger.warning(f"图片 AI 分析失败: {result.get('error')}") + return "" + + except Exception as e: + logger.error(f"图片 AI 分析异常: {str(e)}") + return "" + + def _build_tables_description(self, tables: List[Dict]) -> str: + """构建表格的文本描述""" + result = [] + + for idx, table in enumerate(tables): + rows = table.get("rows", []) + if not rows: + continue + + result.append(f"\n--- 表格 {idx + 1} ---") + + for row_idx, row in enumerate(rows[:50]): # 限制每表格最多50行 + if isinstance(row, list): + result.append(" | ".join(str(cell).strip() for cell in row)) + elif isinstance(row, dict): + result.append(str(row)) + + if len(rows) > 50: + result.append(f"...(共 {len(rows)} 行,仅显示前50行)") + + return "\n".join(result) if result else "(无表格内容)" + + def _parse_json_response(self, content: str) -> Optional[Dict]: + """解析 JSON 响应,处理各种格式问题""" + import re + + # 清理 markdown 标记 + cleaned = content.strip() + cleaned = re.sub(r'^```json\s*', '', cleaned, flags=re.MULTILINE) + cleaned = re.sub(r'^```\s*', '', cleaned, flags=re.MULTILINE) + cleaned = cleaned.strip() + + # 找到 JSON 开始位置 + json_start = -1 + for i, c in enumerate(cleaned): + if c == '{': + json_start = i + break + + if json_start == -1: + logger.warning("无法找到 JSON 开始位置") + return None + + json_text = cleaned[json_start:] + + # 尝试直接解析 + try: + return json.loads(json_text) + except json.JSONDecodeError: + pass + + # 尝试修复并解析 + try: + # 找到闭合括号 + depth = 0 + end_pos = -1 + for i, c in enumerate(json_text): + if c == '{': + depth += 1 + elif c == '}': + depth -= 1 + if depth == 0: + end_pos = i + 1 + break + + if end_pos > 0: + fixed = json_text[:end_pos] + # 移除末尾逗号 + fixed = re.sub(r',\s*([}]])', r'\1', fixed) + return json.loads(fixed) + except Exception as e: + logger.warning(f"JSON 修复失败: {e}") + + return None + + def _fallback_table_parse(self, tables: List[Dict]) -> Dict[str, Any]: + """当 AI 解析失败时,直接解析表格""" + if not tables: + return { + "success": True, + "type": "empty", + "data": {}, + "message": "无表格内容" + } + + all_rows = [] + all_headers = None + + for table in tables: + rows = table.get("rows", []) + if not rows: + continue + + # 查找真正的表头行(跳过标题行) + header_row_idx = 0 + for idx, row in enumerate(rows[:5]): # 只检查前5行 + if not isinstance(row, list): + continue + # 如果某一行包含"表"字开头且单元格内容很长,这可能是标题行 + first_cell = str(row[0]) if row else "" + if first_cell.startswith("表") and len(first_cell) > 15: + header_row_idx = idx + 1 + continue + # 如果某一行有超过3个空单元格,可能是无效行 + empty_count = sum(1 for cell in row if not str(cell).strip()) + if empty_count > 3: + header_row_idx = idx + 1 + continue + # 找到第一行看起来像表头的行(短单元格,大部分有内容) + avg_len = sum(len(str(c)) for c in row) / len(row) if row else 0 + if avg_len < 20: # 表头通常比数据行短 + header_row_idx = idx + break + + if header_row_idx >= len(rows): + continue + + # 使用找到的表头行 + if rows and isinstance(rows[header_row_idx], list): + headers = rows[header_row_idx] + if all_headers is None: + all_headers = headers + + # 数据行(从表头之后开始) + for row in rows[header_row_idx + 1:]: + if isinstance(row, list) and len(row) == len(headers): + all_rows.append(row) + + if all_headers and all_rows: + return { + "success": True, + "type": "table_data", + "headers": all_headers, + "rows": all_rows, + "description": "直接从 Word 表格提取" + } + + return { + "success": True, + "type": "raw", + "tables": tables, + "message": "表格数据(未AI处理)" + } + + async def fill_template_with_ai( + self, + file_path: str, + template_fields: List[Dict[str, Any]], + user_hint: str = "" + ) -> Dict[str, Any]: + """ + 使用 AI 解析 Word 文档并填写模板 + + 这是主要入口函数,前端调用此函数即可完成: + 1. AI 解析 Word 文档 + 2. 根据模板字段提取数据 + 3. 返回填写结果 + + Args: + file_path: Word 文件路径 + template_fields: 模板字段列表 [{"name": "字段名", "hint": "提示词"}, ...] + user_hint: 用户提示 + + Returns: + 填写结果 + """ + try: + # 1. AI 解析文档 + parse_result = await self.parse_word_with_ai(file_path, user_hint) + + if not parse_result.get("success"): + return { + "success": False, + "error": parse_result.get("error", "解析失败"), + "filled_data": {}, + "source": "ai_parse_failed" + } + + # 2. 根据字段类型提取数据 + filled_data = {} + extract_details = [] + + parse_type = parse_result.get("type", "") + + if parse_type == "table_data": + # 表格数据:直接匹配列名 + headers = parse_result.get("headers", []) + rows = parse_result.get("rows", []) + + for field in template_fields: + field_name = field.get("name", "") + values = self._extract_field_from_table(headers, rows, field_name) + filled_data[field_name] = values + extract_details.append({ + "field": field_name, + "values": values, + "source": "ai_table_extraction", + "confidence": 0.9 if values else 0.0 + }) + + elif parse_type == "structured_text": + # 结构化文本:尝试从 key_values 和 list_items 提取 + key_values = parse_result.get("key_values", {}) + list_items = parse_result.get("list_items", []) + + for field in template_fields: + field_name = field.get("name", "") + value = key_values.get(field_name, "") + if not value and list_items: + value = list_items[0] if list_items else "" + filled_data[field_name] = [value] if value else [] + extract_details.append({ + "field": field_name, + "values": [value] if value else [], + "source": "ai_text_extraction", + "confidence": 0.7 if value else 0.0 + }) + + else: + # 其他类型:返回原始解析结果供后续处理 + for field in template_fields: + field_name = field.get("name", "") + filled_data[field_name] = [] + extract_details.append({ + "field": field_name, + "values": [], + "source": "no_ai_data", + "confidence": 0.0 + }) + + # 3. 返回结果 + max_rows = max(len(v) for v in filled_data.values()) if filled_data else 1 + + return { + "success": True, + "filled_data": filled_data, + "fill_details": extract_details, + "ai_parse_result": { + "type": parse_type, + "description": parse_result.get("description", "") + }, + "source_doc_count": 1, + "max_rows": max_rows + } + + except Exception as e: + logger.error(f"AI 填表失败: {str(e)}") + return { + "success": False, + "error": str(e), + "filled_data": {}, + "fill_details": [] + } + + def _extract_field_from_table( + self, + headers: List[str], + rows: List[List], + field_name: str + ) -> List[str]: + """从表格中提取指定字段的值""" + # 查找匹配的列 + target_col_idx = None + for col_idx, header in enumerate(headers): + if field_name.lower() in str(header).lower() or str(header).lower() in field_name.lower(): + target_col_idx = col_idx + break + + if target_col_idx is None: + return [] + + # 提取该列所有值 + values = [] + for row in rows: + if isinstance(row, list) and target_col_idx < len(row): + val = str(row[target_col_idx]).strip() + if val: + values.append(val) + + return values + + +# 全局单例 +word_ai_service = WordAIService() diff --git a/backend/requirements.txt b/backend/requirements.txt index 28f756b..c1700bd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -# ============================================================ +# ============================================================ # 基于大语言模型的文档理解与多源数据融合系统 # Python 依赖清单 # ============================================================ diff --git a/frontend/src/db/backend-api.ts b/frontend/src/db/backend-api.ts index db5854d..41286a8 100644 --- a/frontend/src/db/backend-api.ts +++ b/frontend/src/db/backend-api.ts @@ -807,6 +807,41 @@ export const backendApi = { } }, + /** + * 填充原始模板并导出 + * + * 直接打开原始模板文件,将数据填入模板的表格/单元格中,然后导出 + * 适用于比赛场景:保持原始模板格式不变 + */ + async fillAndExportTemplate( + templatePath: string, + filledData: Record, + format: 'xlsx' | 'docx' = 'xlsx' + ): Promise { + const url = `${BACKEND_BASE_URL}/templates/fill-and-export`; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + template_path: templatePath, + filled_data: filledData, + format, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || '填充模板失败'); + } + return await response.blob(); + } catch (error) { + console.error('填充模板失败:', error); + throw error; + } + }, + // ==================== Excel 专用接口 (保留兼容) ==================== /** @@ -1204,6 +1239,48 @@ export const aiApi = { } }, + /** + * 上传并使用 AI 分析 TXT 文本文件,提取结构化数据 + */ + async analyzeTxt( + file: File + ): Promise<{ + success: boolean; + filename?: string; + structured_data?: { + table?: { + columns?: string[]; + rows?: string[][]; + }; + summary?: string; + key_value_pairs?: Array<{ key: string; value: string }>; + numeric_data?: Array<{ name: string; value: number; unit?: string }>; + }; + error?: string; + }> { + const formData = new FormData(); + formData.append('file', file); + + const url = `${BACKEND_BASE_URL}/ai/analyze/txt`; + + try { + const response = await fetch(url, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'TXT AI 分析失败'); + } + + return await response.json(); + } catch (error) { + console.error('TXT AI 分析失败:', error); + throw error; + } + }, + /** * 生成统计信息和图表 */ @@ -1302,4 +1379,84 @@ export const aiApi = { throw error; } }, + + // ==================== Word AI 解析 ==================== + + /** + * 使用 AI 解析 Word 文档,提取结构化数据 + */ + async analyzeWordWithAI( + file: File, + userHint: string = '' + ): Promise<{ + success: boolean; + type?: string; + headers?: string[]; + rows?: string[][]; + key_values?: Record; + list_items?: string[]; + summary?: string; + error?: string; + }> { + const formData = new FormData(); + formData.append('file', file); + if (userHint) { + formData.append('user_hint', userHint); + } + + const url = `${BACKEND_BASE_URL}/ai/analyze/word`; + + try { + const response = await fetch(url, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Word AI 解析失败'); + } + + return await response.json(); + } catch (error) { + console.error('Word AI 解析失败:', error); + throw error; + } + }, + + /** + * 使用 AI 解析 Word 文档并填写模板 + * 一次性完成:AI解析 + 填表 + */ + async fillTemplateFromWordAI( + file: File, + templateFields: TemplateField[], + userHint: string = '' + ): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('template_fields', JSON.stringify(templateFields)); + if (userHint) { + formData.append('user_hint', userHint); + } + + const url = `${BACKEND_BASE_URL}/ai/analyze/word/fill-template`; + + try { + const response = await fetch(url, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Word AI 填表失败'); + } + + return await response.json(); + } catch (error) { + console.error('Word AI 填表失败:', error); + throw error; + } + }, }; diff --git a/frontend/src/pages/TemplateFill.tsx b/frontend/src/pages/TemplateFill.tsx index 633604c..60f53bd 100644 --- a/frontend/src/pages/TemplateFill.tsx +++ b/frontend/src/pages/TemplateFill.tsx @@ -245,13 +245,27 @@ const TemplateFill: React.FC = () => { }; const handleExport = async () => { - if (!templateFile || !filledResult) return; + if (!templateFile || !filledResult) { + console.error('handleExport 失败: templateFile=', templateFile, 'filledResult=', filledResult); + toast.error('数据不完整,无法导出'); + return; + } + + console.log('=== handleExport 调试 ==='); + console.log('templateFile:', templateFile); + console.log('templateId:', templateId); + console.log('filledResult:', filledResult); + console.log('filledResult.filled_data:', filledResult.filled_data); + console.log('========================='); + + const ext = templateFile.name.split('.').pop()?.toLowerCase(); try { - const blob = await backendApi.exportFilledTemplate( - templateId || 'temp', + // 使用新的 fillAndExportTemplate 直接填充原始模板 + const blob = await backendApi.fillAndExportTemplate( + templateId || '', filledResult.filled_data || {}, - 'xlsx' + ext === 'docx' ? 'docx' : 'xlsx' ); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -261,6 +275,7 @@ const TemplateFill: React.FC = () => { URL.revokeObjectURL(url); toast.success('导出成功'); } catch (err: any) { + console.error('导出失败:', err); toast.error('导出失败: ' + (err.message || '未知错误')); } }; diff --git a/比赛备赛规划.md b/比赛备赛规划.md index 440a12d..a81ca47 100644 --- a/比赛备赛规划.md +++ b/比赛备赛规划.md @@ -50,18 +50,18 @@ | `prompt_service.py` | ✅ 已完成 | Prompt 模板管理 | | `text_analysis_service.py` | ✅ 已完成 | 文本分析 | | `chart_generator_service.py` | ✅ 已完成 | 图表生成服务 | -| `template_fill_service.py` | ✅ 已完成 | 模板填写服务,支持直接读取源文档进行填表 | +| `template_fill_service.py` | ✅ 已完成 | 模板填写服务,支持多行提取、直接从结构化数据提取、JSON容错、Word文档表格处理 | ### 2.2 API 接口 (`backend/app/api/endpoints/`) | 接口文件 | 路由 | 功能状态 | |----------|------|----------| -| `upload.py` | `/api/v1/upload/excel` | ✅ Excel 文件上传与解析 | +| `upload.py` | `/api/v1/upload/document` | ✅ 文档上传与解析 | | `documents.py` | `/api/v1/documents/*` | ✅ 文档管理(列表、删除、搜索) | | `ai_analyze.py` | `/api/v1/analyze/*` | ✅ AI 分析(Excel、Markdown、流式) | | `rag.py` | `/api/v1/rag/*` | ⚠️ RAG 检索(当前返回空) | | `tasks.py` | `/api/v1/tasks/*` | ✅ 异步任务状态查询 | -| `templates.py` | `/api/v1/templates/*` | ✅ 模板管理 (含 Word 导出) | +| `templates.py` | `/api/v1/templates/*` | ✅ 模板管理(含多行导出、Word导出、Word结构化字段解析) | | `visualization.py` | `/api/v1/visualization/*` | ✅ 可视化图表 | | `health.py` | `/api/v1/health` | ✅ 健康检查 | @@ -70,71 +70,67 @@ | 页面文件 | 功能 | 状态 | |----------|------|------| | `Documents.tsx` | 主文档管理页面 | ✅ 已完成 | +| `TemplateFill.tsx` | 智能填表页面 | ✅ 已完成 | | `ExcelParse.tsx` | Excel 解析页面 | ✅ 已完成 | ### 2.4 文档解析能力 | 格式 | 解析状态 | 说明 | |------|----------|------| -| Excel (.xlsx/.xls) | ✅ 已完成 | pandas + XML 回退解析 | +| Excel (.xlsx/.xls) | ✅ 已完成 | pandas + XML 回退解析,支持多sheet | | Markdown (.md) | ✅ 已完成 | 正则 + AI 分章节 | | Word (.docx) | ✅ 已完成 | python-docx 解析,支持表格提取和字段识别 | | Text (.txt) | ✅ 已完成 | chardet 编码检测,支持文本清洗和结构化提取 | --- -## 三、待完成功能(核心缺块) +## 三、核心功能实现详情 -### 3.1 模板填写模块(最优先) - -**当前状态**:✅ 已完成 +### 3.1 模板填写模块(✅ 已完成) +**核心流程**: ``` -用户上传模板表格(Word/Excel) +上传模板表格(Word/Excel) ↓ 解析模板,提取需要填写的字段和提示词 ↓ -根据模板指定的源文档列表读取源数据 +根据源文档ID列表读取源数据(MongoDB或文件) ↓ -AI 根据字段提示词从源数据中提取信息 +优先从结构化数据直接提取(Excel rows) ↓ -将提取的数据填入模板对应位置 +无法直接提取时使用 LLM 从文本中提取 ↓ -返回填写完成的表格 +将提取的数据填入原始模板对应位置(保持模板格式) + ↓ +导出填写完成的表格(Excel/Word) ``` -**已完成实现**: -- [x] `template_fill_service.py` - 模板填写核心服务 -- [x] Word 模板解析 (`docx_parser.py` - parse_tables_for_template, extract_template_fields_from_docx) -- [x] Text 模板解析 (`txt_parser.py` - 已完成) -- [x] 模板字段识别与提示词提取 -- [x] 多文档数据聚合与冲突处理 -- [x] 结果导出为 Word/Excel +**关键特性**: +- **原始模板填充**:直接打开原始模板文件,填充数据到原表格/单元格 +- **多行数据支持**:每个字段可提取多个值,导出时自动扩展行数 +- **结构化数据优先**:直接从 Excel rows 提取,无需 LLM +- **JSON 容错**:支持 LLM 返回的损坏/截断 JSON +- **Markdown 清理**:自动清理 LLM 返回的 markdown 格式 -### 3.2 Word 文档解析 - -**当前状态**:✅ 已完成 +### 3.2 Word 文档解析(✅ 已完成) **已实现功能**: -- [x] `docx_parser.py` - Word 文档解析器 -- [x] 提取段落文本 -- [x] 提取表格内容 -- [x] 提取关键信息(标题、列表等) -- [x] 表格模板字段提取 (`parse_tables_for_template`, `extract_template_fields_from_docx`) -- [x] 字段类型推断 (`_infer_field_type_from_hint`) +- `docx_parser.py` - Word 文档解析器 +- 提取段落文本 +- 提取表格内容(支持比赛表格格式:字段名 | 提示词 | 填写值) +- `parse_tables_for_template()` - 解析表格模板,提取字段 +- `extract_template_fields_from_docx()` - 提取模板字段定义 +- `_infer_field_type_from_hint()` - 从提示词推断字段类型 +- **API 端点**:`/api/v1/templates/parse-word-structure` - 上传 Word 文档,提取结构化字段并存入 MongoDB +- **API 端点**:`/api/v1/templates/word-fields/{doc_id}` - 获取已存文档的模板字段信息 -### 3.3 Text 文档解析 - -**当前状态**:✅ 已完成 +### 3.3 Text 文档解析(✅ 已完成) **已实现功能**: -- [x] `txt_parser.py` - 文本文件解析器 -- [x] 编码自动检测 (chardet) -- [x] 文本清洗 - -### 3.4 文档模板匹配(已有框架) - -根据 Q&A,模板已指定数据文件,不需要算法匹配。当前已有上传功能,需确认模板与数据文件的关联逻辑是否完善。 +- `txt_parser.py` - 文本文件解析器 +- 编码自动检测 (chardet) +- 文本清洗(去除控制字符、规范化空白) +- 结构化数据提取(邮箱、URL、电话、日期、金额) --- @@ -192,20 +188,20 @@ docs/test/ ## 六、工作计划(建议) -### 第一优先级:模板填写核心功能 -- 完成 Word 文档解析 -- 完成模板填写服务 -- 端到端测试验证 +### 第一优先级:端到端测试 +- 使用真实测试数据进行准确率测试 +- 验证多行数据导出是否正确 +- 测试 Word 模板解析是否正常 ### 第二优先级:Demo 打包与文档 - 制作项目演示 PPT - 录制演示视频 - 完善 README 部署文档 -### 第三优先级:测试优化 -- 使用真实测试数据进行准确率测试 +### 第三优先级:优化 - 优化响应时间 - 完善错误处理 +- 增加更多测试用例 --- @@ -215,29 +211,32 @@ docs/test/ 2. **数据库**:不强制要求数据库存储,可跳过 3. **部署**:本地部署即可,不需要公网服务器 4. **评测数据**:初赛仅使用目前提供的数据 -5. **RAG 功能**:当前已临时禁用,不影响核心评测功能 +5. **RAG 功能**:当前已临时禁用,不影响核心评测功能(因为使用直接文件读取) --- -*文档版本: v1.1* -*最后更新: 2026-04-08* +*文档版本: v1.5* +*最后更新: 2026-04-09* --- ## 八、技术实现细节 -### 8.1 模板填表流程(已实现) +### 8.1 模板填表流程 #### 流程图 ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ 上传模板 │ ──► │ 选择数据源 │ ──► │ AI 智能填表 │ +│ 上传模板 │ ──► │ 选择数据源 │ ──► │ 智能填表 │ └─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ - ┌─────────────┐ - │ 导出结果 │ - └─────────────┘ + │ + ┌─────────────────────────┼─────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ 结构化数据提取 │ │ LLM 提取 │ │ 导出结果 │ + │ (直接读rows) │ │ (文本理解) │ │ (Excel/Word) │ + └───────────────┘ └───────────────┘ └───────────────┘ ``` #### 核心组件 @@ -247,8 +246,10 @@ docs/test/ | 模板上传 | `templates.py` `/templates/upload` | 接收模板文件,提取字段 | | 字段提取 | `template_fill_service.py` | 从 Word/Excel 表格提取字段定义 | | 文档解析 | `docx_parser.py`, `xlsx_parser.py`, `txt_parser.py` | 解析源文档内容 | -| 智能填表 | `template_fill_service.py` `fill_template()` | 使用 LLM 从源文档提取信息 | -| 结果导出 | `templates.py` `/templates/export` | 导出为 Excel 或 Word | +| 智能填表 | `template_fill_service.py` `fill_template()` | 结构化提取 + LLM 提取 | +| 多行支持 | `template_fill_service.py` `FillResult` | values 数组支持 | +| JSON 容错 | `template_fill_service.py` `_fix_json()` | 修复损坏的 JSON | +| 结果导出 | `templates.py` `/templates/export` | 多行 Excel + Word 导出 | ### 8.2 源文档加载方式 @@ -268,7 +269,9 @@ docs/test/ ```python # 提取表格模板字段 -fields = docx_parser.extract_template_fields_from_docx(file_path) +from docx_parser import DocxParser +parser = DocxParser() +fields = parser.extract_template_fields_from_docx(file_path) # 返回格式 # [ @@ -295,6 +298,24 @@ fields = docx_parser.extract_template_fields_from_docx(file_path) ### 8.5 API 接口 +#### POST `/api/v1/templates/upload` + +上传模板文件,提取字段定义。 + +**响应**: +```json +{ + "success": true, + "template_id": "/path/to/saved/template.docx", + "filename": "模板.docx", + "file_type": "docx", + "fields": [ + {"cell": "A1", "name": "姓名", "field_type": "text", "required": true, "hint": "提取人员姓名"} + ], + "field_count": 1 +} +``` + #### POST `/api/v1/templates/fill` 填写请求: @@ -306,35 +327,232 @@ fields = docx_parser.extract_template_fields_from_docx(file_path) ], "source_doc_ids": ["mongodb_doc_id_1", "mongodb_doc_id_2"], "source_file_paths": [], - "user_hint": "请从合同文档中提取" + "user_hint": "请从xxx文档中提取" } ``` -响应: +**响应(含多行支持)**: ```json { "success": true, - "filled_data": {"姓名": "张三"}, + "filled_data": { + "姓名": ["张三", "李四", "王五"], + "年龄": ["25", "30", "28"] + }, "fill_details": [ { "field": "姓名", "cell": "A1", + "values": ["张三", "李四", "王五"], "value": "张三", - "source": "来自:合同文档.docx", - "confidence": 0.95 + "source": "结构化数据直接提取", + "confidence": 1.0 } ], - "source_doc_count": 2 + "source_doc_count": 2, + "max_rows": 3 } ``` #### POST `/api/v1/templates/export` -导出请求: +导出请求(创建新文件): ```json { "template_id": "模板ID", - "filled_data": {"姓名": "张三", "金额": "10000"}, - "format": "xlsx" // 或 "docx" + "filled_data": {"姓名": ["张三", "李四"], "金额": ["10000", "20000"]}, + "format": "xlsx" } -``` \ No newline at end of file +``` + +#### POST `/api/v1/templates/fill-and-export` + +**填充原始模板并导出**(推荐用于比赛) + +直接打开原始模板文件,将数据填入模板的表格/单元格中,然后导出。**保持原始模板格式不变**。 + +**请求**: +```json +{ + "template_path": "/path/to/original/template.docx", + "filled_data": { + "姓名": ["张三", "李四", "王五"], + "年龄": ["25", "30", "28"] + }, + "format": "docx" +} +``` + +**响应**:填充后的 Word/Excel 文件(文件流) + +**特点**: +- 打开原始模板文件 +- 根据表头行匹配字段名到列索引 +- 将数据填入对应列的单元格 +- 多行数据自动扩展表格行数 +- 保持原始模板格式和样式 + +#### POST `/api/v1/templates/parse-word-structure` + +**上传 Word 文档并提取结构化字段**(比赛专用) + +上传 Word 文档,从表格模板中提取字段定义(字段名、提示词、字段类型)并存入 MongoDB。 + +**请求**:multipart/form-data +- file: Word 文件 + +**响应**: +```json +{ + "success": true, + "doc_id": "mongodb_doc_id", + "filename": "模板.docx", + "file_path": "/path/to/saved/template.docx", + "field_count": 5, + "fields": [ + { + "cell": "T0R1", + "name": "字段名", + "hint": "提示词", + "field_type": "text", + "required": true + } + ], + "tables": [...], + "metadata": { + "paragraph_count": 10, + "table_count": 1, + "word_count": 500, + "has_tables": true + } +} +``` + +#### GET `/api/v1/templates/word-fields/{doc_id}` + +**获取 Word 文档模板字段信息** + +根据 doc_id 获取已上传的 Word 文档的模板字段信息。 + +**响应**: +```json +{ + "success": true, + "doc_id": "mongodb_doc_id", + "filename": "模板.docx", + "fields": [...], + "tables": [...], + "field_count": 5, + "metadata": {...} +} +``` + +### 8.6 多行数据处理 + +**FillResult 数据结构**: +```python +@dataclass +class FillResult: + field: str + values: List[Any] = None # 支持多个值(数组) + value: Any = "" # 保留兼容(第一个值) + source: str = "" # 来源文档 + confidence: float = 1.0 # 置信度 +``` + +**导出逻辑**: +- 计算所有字段的最大行数 +- 遍历每一行,取对应索引的值 +- 不足的行填空字符串 + +### 8.7 JSON 容错处理 + +当 LLM 返回的 JSON 损坏或被截断时,系统会: + +1. 清理 markdown 代码块标记(```json, ```) +2. 尝试配对括号找到完整的 JSON +3. 移除末尾多余的逗号 +4. 使用正则表达式提取 values 数组 +5. 备选方案:直接提取所有引号内的字符串 + +### 8.8 结构化数据优先提取 + +对于 Excel 等有 `rows` 结构的文档,系统会: + +1. 直接从 `structured_data.rows` 中查找匹配列 +2. 使用模糊匹配(字段名包含或被包含) +3. 提取该列的所有行值 +4. 无需调用 LLM,速度更快,准确率更高 + +```python +# 内部逻辑 +if structured.get("rows"): + columns = structured.get("columns", []) + values = _extract_column_values(rows, columns, field_name) +``` + +--- + +## 九、依赖说明 + +### Python 依赖 + +``` +# requirements.txt 中需要包含 +fastapi>=0.104.0 +uvicorn>=0.24.0 +motor>=3.3.0 # MongoDB 异步驱动 +sqlalchemy>=2.0.0 # MySQL ORM +pandas>=2.0.0 # Excel 处理 +openpyxl>=3.1.0 # Excel 写入 +python-docx>=0.8.0 # Word 处理 +chardet>=4.0.0 # 编码检测 +httpx>=0.25.0 # HTTP 客户端 +``` + +### 前端依赖 + +``` +# package.json 中需要包含 +react>=18.0.0 +react-dropzone>=14.0.0 +lucide-react>=0.300.0 +sonner>=1.0.0 # toast 通知 +``` + +--- + +## 十、启动说明 + +### 后端启动 + +```bash +cd backend +.\venv\Scripts\Activate.ps1 # 或 Activate.bat +pip install -r requirements.txt # 确保依赖完整 +.\venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload +``` + +### 前端启动 + +```bash +cd frontend +npm install +npm run dev +``` + +### 环境变量 + +在 `backend/.env` 中配置: +``` +MONGODB_URL=mongodb://localhost:27017 +MONGODB_DB_NAME=document_system +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your_password +MYSQL_DATABASE=document_system +LLM_API_KEY=your_api_key +LLM_BASE_URL=https://api.minimax.chat +LLM_MODEL_NAME=MiniMax-Text-01 +```