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 49ab0cd..7cbc83d 100644 --- a/backend/app/api/endpoints/ai_analyze.py +++ b/backend/app/api/endpoints/ai_analyze.py @@ -10,6 +10,7 @@ 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.word_ai_service import word_ai_service logger = logging.getLogger(__name__) @@ -329,3 +330,130 @@ async def get_markdown_outline( except Exception as e: logger.error(f"获取 Markdown 大纲失败: {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() + + # 保存到临时文件 + with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx', delete=False) as tmp: + tmp.write(content) + tmp_path = tmp.name + + try: + logger.info(f"开始 AI 解析 Word 文件: {file.filename}") + + result = await word_ai_service.parse_word_with_ai( + file_path=tmp_path, + user_hint=user_hint + ) + + logger.info(f"Word AI 解析完成: {file.filename}, success={result.get('success')}") + + return result + + 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"AI 解析失败: {str(e)}") + + +@router.post("/analyze/word/fill-template") +async def fill_template_with_word_ai( + file: UploadFile = File(...), + template_fields: str = Query("", description="模板字段,JSON字符串"), + user_hint: str = Query("", description="用户提示词") +): + """ + 使用 AI 解析 Word 文档并填写模板 + + 前端调用此接口即可完成:AI解析 + 填表 + + Args: + file: 上传的 Word 文件 + template_fields: 模板字段 JSON 字符串 + 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: + import json as json_module + fields = json_module.loads(template_fields) if template_fields else [] + except json_module.JSONDecodeError: + raise HTTPException(status_code=400, detail="template_fields 格式错误,应为 JSON 数组") + + try: + content = await file.read() + + # 保存到临时文件 + with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx', delete=False) as tmp: + tmp.write(content) + tmp_path = tmp.name + + try: + logger.info(f"开始 AI 填表(Word): {file.filename}, 字段数: {len(fields)}") + + result = await word_ai_service.fill_template_with_ai( + file_path=tmp_path, + template_fields=fields, + user_hint=user_hint + ) + + logger.info(f"Word AI 填表完成: {file.filename}, success={result.get('success')}") + + return result + + 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"AI 填表失败: {str(e)}") diff --git a/backend/app/api/endpoints/documents.py b/backend/app/api/endpoints/documents.py index 848a582..82d8551 100644 --- a/backend/app/api/endpoints/documents.py +++ b/backend/app/api/endpoints/documents.py @@ -189,6 +189,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 redis_db.set_task_status( diff --git a/backend/app/api/endpoints/templates.py b/backend/app/api/endpoints/templates.py index 8d2ebee..3803196 100644 --- a/backend/app/api/endpoints/templates.py +++ b/backend/app/api/endpoints/templates.py @@ -50,6 +50,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") @@ -531,3 +538,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 e8481ec..01626bc 100644 --- a/backend/app/core/database/mongodb.py +++ b/backend/app/core/database/mongodb.py @@ -94,6 +94,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/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 e744d09..cbaaea1 100644 --- a/backend/app/services/template_fill_service.py +++ b/backend/app/services/template_fill_service.py @@ -11,6 +11,7 @@ from app.core.database import mongodb 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.word_ai_service import word_ai_service logger = logging.getLogger(__name__) @@ -173,16 +174,106 @@ class TemplateFillService: if source_file_paths: for file_path in source_file_paths: try: + file_ext = file_path.lower().split('.')[-1] + + # 对于 Word 文档,优先使用 AI 解析 + if file_ext == 'docx': + # 使用 AI 深度解析 Word 文档 + ai_result = await word_ai_service.parse_word_with_ai( + file_path=file_path, + user_hint="请提取文档中的所有结构化数据,包括表格、键值对等" + ) + + if ai_result.get("success"): + # AI 解析成功,转换为 SourceDocument 格式 + # 注意:word_ai_service 返回的是顶层数据,不是 {"data": {...}} 包装 + parse_type = ai_result.get("type", "unknown") + + # 构建 structured_data + doc_structured = { + "ai_parsed": True, + "parse_type": parse_type, + "tables": [], + "key_values": ai_result.get("key_values", {}) if "key_values" in ai_result else {}, + "list_items": ai_result.get("list_items", []) if "list_items" in ai_result else [], + "summary": ai_result.get("summary", "") if "summary" in ai_result else "" + } + + # 如果 AI 返回了表格数据 + if parse_type == "table_data": + headers = ai_result.get("headers", []) + rows = ai_result.get("rows", []) + if headers and rows: + doc_structured["tables"] = [{ + "headers": headers, + "rows": rows + }] + doc_structured["columns"] = headers + doc_structured["rows"] = rows + logger.info(f"AI 表格数据: {len(headers)} 列, {len(rows)} 行") + elif parse_type == "structured_text": + tables = ai_result.get("tables", []) + if tables: + doc_structured["tables"] = tables + logger.info(f"AI 结构化文本提取到 {len(tables)} 个表格") + + # 获取摘要内容 + content_text = doc_structured.get("summary", "") or ai_result.get("description", "") + + source_docs.append(SourceDocument( + doc_id=file_path, + filename=file_path.split("/")[-1] if "/" in file_path else file_path.split("\\")[-1], + doc_type="docx", + content=content_text, + structured_data=doc_structured + )) + logger.info(f"AI 解析 Word 文档: {file_path}, type={parse_type}, tables={len(doc_structured.get('tables', []))}") + continue # 跳过后续的基础解析 + + # 基础解析(Excel 或非 AI 解析的 Word) parser = ParserFactory.get_parser(file_path) result = parser.parse(file_path) if result.success: # result.data 的结构取决于解析器类型: # - Excel 单 sheet: {columns: [...], rows: [...], row_count, column_count} # - Excel 多 sheet: {sheets: {sheet_name: {columns, rows, ...}}} - # - Word/TXT: {content: "...", structured_data: {...}} + # - Word: {content: "...", paragraphs: [...], tables: [...], structured_data: {...}} doc_data = result.data if result.data else {} doc_content = doc_data.get("content", "") if isinstance(doc_data, dict) else "" - doc_structured = doc_data if isinstance(doc_data, dict) and "rows" in doc_data or isinstance(doc_data, dict) and "sheets" in doc_data else {} + + # 根据文档类型确定 structured_data 的内容 + if "sheets" in doc_data: + # Excel 多 sheet 格式 + doc_structured = doc_data + elif "rows" in doc_data: + # Excel 单 sheet 格式 + doc_structured = doc_data + elif "tables" in doc_data: + # Word 文档格式(已有表格) + doc_structured = doc_data + elif "paragraphs" in doc_data: + # Word 文档只有段落,没有表格 - 尝试 AI 二次解析 + unstructured = doc_data + ai_result = await word_ai_service.parse_word_with_ai( + file_path=file_path, + user_hint="请提取文档中的所有结构化信息" + ) + if ai_result.get("success"): + parse_type = ai_result.get("type", "text") + doc_structured = { + "ai_parsed": True, + "parse_type": parse_type, + "tables": ai_result.get("tables", []) if "tables" in ai_result else [], + "key_values": ai_result.get("key_values", {}) if "key_values" in ai_result else {}, + "list_items": ai_result.get("list_items", []) if "list_items" in ai_result else [], + "summary": ai_result.get("summary", "") if "summary" in ai_result else "", + "content": doc_content + } + logger.info(f"AI 二次解析 Word 段落文档: type={parse_type}") + else: + doc_structured = unstructured + else: + doc_structured = {} source_docs.append(SourceDocument( doc_id=file_path, @@ -321,11 +412,13 @@ class TemplateFillService: # ========== 步骤3: 尝试解析 JSON ========== # 3a. 尝试直接解析整个字符串 + parsed_confidence = 0.5 # 默认置信度 try: result = json.loads(json_text) - extracted_values = self._extract_values_from_json(result) + extracted_values, parsed_confidence = self._extract_values_from_json(result) if extracted_values: - logger.info(f"✅ 直接解析成功,得到 {len(extracted_values)} 个值") + confidence = parsed_confidence if parsed_confidence > 0 else 0.8 # 成功提取,提高置信度 + logger.info(f"✅ 直接解析成功,得到 {len(extracted_values)} 个值,置信度: {confidence}") else: logger.warning(f"直接解析成功但未提取到值") except json.JSONDecodeError as e: @@ -337,9 +430,10 @@ class TemplateFillService: if fixed_json: try: result = json.loads(fixed_json) - extracted_values = self._extract_values_from_json(result) + extracted_values, parsed_confidence = self._extract_values_from_json(result) if extracted_values: - logger.info(f"✅ 修复后解析成功,得到 {len(extracted_values)} 个值") + confidence = parsed_confidence if parsed_confidence > 0 else 0.7 + logger.info(f"✅ 修复后解析成功,得到 {len(extracted_values)} 个值,置信度: {confidence}") except json.JSONDecodeError as e2: logger.warning(f"修复后仍然失败: {e2}") @@ -347,10 +441,15 @@ class TemplateFillService: if not extracted_values: extracted_values = self._extract_values_by_regex(cleaned) if extracted_values: - logger.info(f"✅ 正则提取成功,得到 {len(extracted_values)} 个值") + confidence = 0.6 # 正则提取置信度 + logger.info(f"✅ 正则提取成功,得到 {len(extracted_values)} 个值,置信度: {confidence}") else: # 最后的备选:使用旧的文本提取 extracted_values = self._extract_values_from_text(cleaned, field.name) + if extracted_values: + confidence = 0.5 + else: + confidence = 0.3 # 最后备选 # 如果仍然没有提取到值 if not extracted_values: @@ -483,30 +582,27 @@ class TemplateFillService: doc_content += " | ".join(str(cell) for cell in row) + "\n" row_count += 1 elif doc.structured_data and doc.structured_data.get("tables"): - # Markdown 表格格式: {tables: [{headers: [...], rows: [...]}]} + # Word 文档的表格格式 - 直接输出完整表格,让 LLM 理解 tables = doc.structured_data.get("tables", []) - for table in tables: + for table_idx, table in enumerate(tables): if isinstance(table, dict): - headers = table.get("headers", []) - rows = table.get("rows", []) - if rows and headers: - doc_content += f"\n【文档: {doc.filename} - 表格】\n" - doc_content += " | ".join(str(h) for h in headers) + "\n" - for row in rows: + table_rows = table.get("rows", []) + if table_rows: + doc_content += f"\n【文档: {doc.filename} - 表格{table_idx + 1},共 {len(table_rows)} 行】\n" + # 输出表头 + if table_rows and isinstance(table_rows[0], list): + doc_content += "表头: " + " | ".join(str(cell) for cell in table_rows[0]) + "\n" + # 输出所有数据行 + for row_idx, row in enumerate(table_rows[1:], start=1): # 跳过表头 if isinstance(row, list): - doc_content += " | ".join(str(cell) for cell in row) + "\n" - row_count += 1 - # 如果有标题结构,也添加上下文 - if doc.structured_data.get("titles"): - titles = doc.structured_data.get("titles", []) - doc_content += f"\n【文档章节结构】\n" - for title in titles[:20]: # 限制前20个标题 - doc_content += f"{'#' * title.get('level', 1)} {title.get('text', '')}\n" - # 如果没有提取到表格内容,使用纯文本 - if not doc_content.strip(): - doc_content = doc.content[:5000] if doc.content else "" + doc_content += "行" + str(row_idx) + ": " + " | ".join(str(cell) for cell in row) + "\n" + row_count += 1 elif doc.content: - doc_content = doc.content[:5000] + # 普通文本内容(Word 段落、纯文本等) + content_preview = doc.content[:8000] if doc.content else "" + if content_preview: + doc_content = f"\n【文档: {doc.filename} ({doc.doc_type})】\n{content_preview}" + row_count = len(content_preview.split('\n')) if doc_content: doc_context = f"【文档: {doc.filename} ({doc.doc_type})】\n{doc_content}" @@ -614,8 +710,20 @@ class TemplateFillService: logger.info(f"读取 Excel 表头: {df.shape}, 列: {list(df.columns)[:10]}") - # 如果 DataFrame 列为空或只有默认索引,尝试其他方式 - if len(df.columns) == 0 or (len(df.columns) == 1 and df.columns[0] == 0): + # 如果 DataFrame 列为空或只有默认索引(0, 1, 2... 或 Unnamed: 0, Unnamed: 1...),尝试其他方式 + needs_reparse = len(df.columns) == 0 + if not needs_reparse: + # 检查是否所有列都是自动生成的(0, 1, 2... 或 Unnamed: 0, Unnamed: 1...) + auto_generated_count = 0 + for col in df.columns: + col_str = str(col) + if col_str in ['0', '1', '2'] or col_str.startswith('Unnamed'): + auto_generated_count += 1 + # 如果超过50%的列是自动生成的,认为表头解析失败 + if auto_generated_count >= len(df.columns) * 0.5: + needs_reparse = True + + if needs_reparse: logger.warning(f"表头解析结果异常,重新解析: {df.columns}") # 尝试读取整个文件获取列信息 df_full = pd.read_excel(file_path, header=None) @@ -656,16 +764,26 @@ class TemplateFillService: doc = Document(file_path) for table_idx, table in enumerate(doc.tables): - for row_idx, row in enumerate(table.rows): + rows = list(table.rows) + if len(rows) < 2: + continue # 跳过少于2行的表格(需要表头+数据) + + # 第一行是表头,用于识别字段位置 + header_cells = [cell.text.strip() for cell in rows[0].cells] + logger.info(f"Word 表格 {table_idx} 表头: {header_cells}") + + # 从第二行开始是数据行(比赛模板格式:字段名 | 提示词 | 填写值) + for row_idx, row in enumerate(rows[1:], start=1): cells = [cell.text.strip() for cell in row.cells] - # 假设第一列是字段名 + # 第一列是字段名 if cells and cells[0]: field_name = cells[0] + # 第二列是提示词 hint = cells[1] if len(cells) > 1 else "" - # 跳过空行或标题行 - if field_name and field_name not in ["", "字段名", "名称", "项目"]: + # 跳过空行或明显是表头的行 + if field_name and field_name not in ["", "字段名", "名称", "项目", "序号", "编号"]: fields.append(TemplateField( cell=f"T{table_idx}R{row_idx}", name=field_name, @@ -673,9 +791,10 @@ class TemplateFillService: required=True, hint=hint )) + logger.info(f" 提取字段: {field_name}, hint: {hint}") except Exception as e: - logger.error(f"从Word提取字段失败: {str(e)}") + logger.error(f"从Word提取字段失败: {str(e)}", exc_info=True) return fields @@ -783,23 +902,101 @@ class TemplateFillService: logger.info(f"从文档 {doc.filename} 提取到 {len(values)} 个值") break - # 处理 Markdown 表格格式: {tables: [{headers: [...], rows: [...]}]} + # 处理 Word 文档表格格式: {tables: [{headers: [...], rows: [[]], ...}]} elif structured.get("tables"): tables = structured.get("tables", []) for table in tables: if isinstance(table, dict): - headers = table.get("headers", []) - rows = table.get("rows", []) - values = self._extract_column_values(rows, headers, field_name) - if values: - all_values.extend(values) - logger.info(f"从 Markdown 表格提取到 {len(values)} 个值") - break + # AI 返回格式: {headers: [...], rows: [[row1], [row2], ...]} + # 原始 Word 格式: {rows: [[header], [row1], [row2], ...]} + table_rows = table.get("rows", []) + headers = table.get("headers", []) # AI 返回的 headers + if not headers and table_rows: + # 原始格式:第一个元素是表头 + headers = table_rows[0] if table_rows else [] + data_rows = table_rows[1:] if len(table_rows) > 1 else [] + else: + # AI 返回格式:headers 和 rows 分开 + data_rows = table_rows + + if headers and data_rows: + values = self._extract_values_from_docx_table(data_rows, headers, field_name) + if values: + all_values.extend(values) + logger.info(f"从 Word 表格提取到 {len(values)} 个值") + break if all_values: break return all_values + def _extract_values_from_docx_table(self, table_rows: List, header: List, field_name: str) -> List[str]: + """ + 从 Word 文档表格中提取指定列的值 + + Args: + table_rows: 表格所有行(包括表头) + header: 表头行(可能是真正的表头,也可能是第一行数据) + field_name: 要提取的字段名 + + Returns: + 值列表 + """ + if not table_rows or len(table_rows) < 2: + return [] + + # 第一步:尝试在 header(第一行)中查找匹配列 + target_col_idx = None + for col_idx, col_name in enumerate(header): + col_str = str(col_name).strip() + if field_name.lower() in col_str.lower() or col_str.lower() in field_name.lower(): + target_col_idx = col_idx + break + + # 如果 header 中没找到,尝试在 table_rows[1](第二行)中查找 + # 这是因为有时第一行是数据而不是表头 + if target_col_idx is None and len(table_rows) > 1: + second_row = table_rows[1] + if isinstance(second_row, list): + for col_idx, col_name in enumerate(second_row): + col_str = str(col_name).strip() + if field_name.lower() in col_str.lower() or col_str.lower() in field_name.lower(): + target_col_idx = col_idx + logger.info(f"在第二行找到匹配列: {field_name} @ 列{col_idx}, header={header}") + break + + if target_col_idx is None: + logger.warning(f"未找到匹配列: {field_name}, 表头: {header}") + return [] + + # 确定从哪一行开始提取数据 + # 如果 header 是表头(包含 field_name),则从 table_rows[1] 开始提取 + # 如果 header 是数据(不包含 field_name),则从 table_rows[2] 开始提取 + header_contains_field = any( + field_name.lower() in str(col).strip().lower() or str(col).strip().lower() in field_name.lower() + for col in header + ) + + if header_contains_field: + # header 是表头,从第二行开始提取 + data_start_idx = 1 + else: + # header 是数据,从第三行开始提取(跳过表头和第一行数据) + data_start_idx = 2 + + # 提取值 + values = [] + for row_idx, row in enumerate(table_rows[data_start_idx:], start=data_start_idx): + if isinstance(row, list) and target_col_idx < len(row): + val = str(row[target_col_idx]).strip() if row[target_col_idx] else "" + values.append(val) + elif isinstance(row, dict): + val = str(row.get(target_col_idx, "")).strip() + values.append(val) + + logger.info(f"从 Word 表格列 {target_col_idx} 提取到 {len(values)} 个值: {values[:3]}") + return values + def _extract_column_values(self, rows: List, columns: List, field_name: str) -> List[str]: """ 从 rows 和 columns 中提取指定列的值 @@ -839,27 +1036,37 @@ class TemplateFillService: return values - def _extract_values_from_json(self, result) -> List[str]: + def _extract_values_from_json(self, result) -> tuple: """ - 从解析后的 JSON 对象/数组中提取值数组 + 从解析后的 JSON 对象/数组中提取值数组和置信度 Args: result: json.loads() 返回的对象 Returns: - 值列表 + (值列表, 置信度) 元组 """ + # 提取置信度 + confidence = 0.5 + if isinstance(result, dict) and "confidence" in result: + try: + conf = float(result["confidence"]) + if 0 <= conf <= 1: + confidence = conf + except (ValueError, TypeError): + pass + if isinstance(result, dict): # 优先找 values 数组 if "values" in result and isinstance(result["values"], list): vals = [str(v).strip() for v in result["values"] if v and str(v).strip()] if vals: - return vals + return vals, confidence # 尝试找 value 字段 if "value" in result: val = str(result["value"]).strip() if val: - return [val] + return [val], confidence # 尝试找任何数组类型的键 for key in result.keys(): val = result[key] @@ -867,14 +1074,14 @@ class TemplateFillService: if all(isinstance(v, (str, int, float, bool)) or v is None for v in val): vals = [str(v).strip() for v in val if v is not None and str(v).strip()] if vals: - return vals + return vals, confidence elif isinstance(val, (str, int, float, bool)): - return [str(val).strip()] + return [str(val).strip()], confidence elif isinstance(result, list): vals = [str(v).strip() for v in result if v is not None and str(v).strip()] if vals: - return vals - return [] + return vals, confidence + return [], confidence def _fix_json(self, json_text: str) -> str: """ @@ -1189,14 +1396,15 @@ class TemplateFillService: json_text = cleaned[json_start:] try: result = json.loads(json_text) - values = self._extract_values_from_json(result) + values, parsed_conf = self._extract_values_from_json(result) if values: + conf = result.get("confidence", parsed_conf) if isinstance(result, dict) else parsed_conf return FillResult( field=field.name, values=values, value=values[0] if values else "", source=f"AI分析: {doc.filename}", - confidence=result.get("confidence", 0.8) + confidence=max(conf, 0.8) # 最低0.8 ) except json.JSONDecodeError: # 尝试修复 JSON @@ -1204,14 +1412,15 @@ class TemplateFillService: if fixed: try: result = json.loads(fixed) - values = self._extract_values_from_json(result) + values, parsed_conf = self._extract_values_from_json(result) if values: + conf = result.get("confidence", parsed_conf) if isinstance(result, dict) else parsed_conf return FillResult( field=field.name, values=values, value=values[0] if values else "", source=f"AI分析: {doc.filename}", - confidence=result.get("confidence", 0.8) + confidence=max(conf, 0.8) # 最低0.8 ) except json.JSONDecodeError: pass @@ -1242,6 +1451,8 @@ class TemplateFillService: try: import pandas as pd + content_sample = "" + # 读取 Excel 内容检查是否为空 if file_type in ["xlsx", "xls"]: df = pd.read_excel(file_path, header=None) @@ -1265,9 +1476,35 @@ class TemplateFillService: content_sample = df.iloc[:10].to_string() if len(df) >= 10 else df.to_string() else: content_sample = df.to_string() - else: + + elif file_type == "docx": + # Word 文档:尝试使用 docx_parser 提取内容 + try: + from docx import Document + doc = Document(file_path) + + # 提取段落文本 + paragraphs = [p.text.strip() for p in doc.paragraphs if p.text.strip()] + tables_text = "" + + # 提取表格 + if doc.tables: + for table in doc.tables: + for row in table.rows: + row_text = " | ".join(cell.text.strip() for cell in row.cells) + tables_text += row_text + "\n" + + content_sample = f"【段落】\n{' '.join(paragraphs[:20])}\n\n【表格】\n{tables_text}" + logger.info(f"Word 文档内容预览: {len(content_sample)} 字符") + + except Exception as e: + logger.warning(f"读取 Word 文档失败: {str(e)}") content_sample = "" + else: + logger.warning(f"不支持的文件类型进行 AI 表头生成: {file_type}") + return None + # 调用 AI 生成表头 prompt = f"""你是一个专业的表格设计助手。请为以下空白表格生成合适的表头字段。 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 d26e1a8..6f218dd 100644 --- a/frontend/src/db/backend-api.ts +++ b/frontend/src/db/backend-api.ts @@ -764,6 +764,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 专用接口 (保留兼容) ==================== /** @@ -1259,4 +1294,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 d3e57c9..4a94638 100644 --- a/frontend/src/pages/TemplateFill.tsx +++ b/frontend/src/pages/TemplateFill.tsx @@ -152,13 +152,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'); @@ -168,6 +182,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 +```