From bedf1af9c007878f0b41361404e3107b25f45993 Mon Sep 17 00:00:00 2001 From: zzz <860559943@qq.com> Date: Fri, 10 Apr 2026 09:48:57 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20Word=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=20AI=20=E8=A7=A3=E6=9E=90=E5=92=8C=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=A1=AB=E5=85=85=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 11 +- backend/app/api/endpoints/ai_analyze.py | 128 ++++ backend/app/api/endpoints/documents.py | 44 ++ backend/app/api/endpoints/templates.py | 431 ++++++++++++ backend/app/core/database/mongodb.py | 22 + .../app/core/document_parser/docx_parser.py | 148 +++- backend/app/services/llm_service.py | 160 ++++- backend/app/services/template_fill_service.py | 349 ++++++++-- backend/app/services/word_ai_service.py | 637 ++++++++++++++++++ backend/requirements.txt | 2 +- frontend/src/db/backend-api.ts | 115 ++++ frontend/src/pages/TemplateFill.tsx | 23 +- 比赛备赛规划.md | 354 ++++++++-- 13 files changed, 2285 insertions(+), 139 deletions(-) create mode 100644 backend/app/services/word_ai_service.py 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 +``` From ed66aa346d5b14f0b85dbe714cb60fb0de4f0080 Mon Sep 17 00:00:00 2001 From: tl <1655185665@qq.com> Date: Fri, 10 Apr 2026 10:24:52 +0800 Subject: [PATCH 2/4] tl --- backend/app/api/endpoints/ai_analyze.py | 72 +++ backend/app/services/llm_service.py | 8 + backend/app/services/template_fill_service.py | 431 ++++++++++++++++-- frontend/src/db/backend-api.ts | 42 ++ 4 files changed, 527 insertions(+), 26 deletions(-) diff --git a/backend/app/api/endpoints/ai_analyze.py b/backend/app/api/endpoints/ai_analyze.py index 49ab0cd..9ecf26e 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.template_fill_service import template_fill_service logger = logging.getLogger(__name__) @@ -329,3 +330,74 @@ 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)}") diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 8878deb..53f42c2 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -55,12 +55,20 @@ class LLMService: payload.update(kwargs) try: + logger.info(f"LLM API 请求: model={self.model_name}, temperature={temperature}, max_tokens={max_tokens}") + logger.info(f"消息数量: {len(messages)}") + for i, msg in enumerate(messages): + logger.info(f"消息[{i}]: role={msg.get('role')}, content长度={len(msg.get('content', ''))}") + async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post( f"{self.base_url}/chat/completions", headers=headers, json=payload ) + logger.info(f"LLM API 响应状态: {response.status_code}") + if response.status_code != 200: + logger.error(f"LLM API 响应内容: {response.text}") response.raise_for_status() return response.json() diff --git a/backend/app/services/template_fill_service.py b/backend/app/services/template_fill_service.py index 71976a6..c10f4bb 100644 --- a/backend/app/services/template_fill_service.py +++ b/backend/app/services/template_fill_service.py @@ -5,6 +5,7 @@ """ import logging from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Dict, List, Optional from app.core.database import mongodb @@ -32,6 +33,7 @@ class SourceDocument: doc_type: str content: str = "" structured_data: Dict[str, Any] = field(default_factory=dict) + ai_structured_data: Optional[Dict[str, Any]] = None # AI 结构化分析结果缓存 @dataclass @@ -76,12 +78,14 @@ class TemplateFillService: filled_data = {} fill_details = [] - logger.info(f"开始填表: {len(template_fields)} 个字段, {len(source_doc_ids or [])} 个源文档") + logger.info(f"开始填表: {len(template_fields)} 个字段, {len(source_doc_ids or [])} 个源文档, {len(source_file_paths or [])} 个文件路径") # 1. 加载源文档内容 source_docs = await self._load_source_documents(source_doc_ids, source_file_paths) logger.info(f"加载了 {len(source_docs)} 个源文档") + for doc in source_docs: + logger.info(f" - 文档: {doc.filename}, 类型: {doc.doc_type}, 内容长度: {len(doc.content)}, AI分析: {bool(doc.ai_structured_data)}") if not source_docs: logger.warning("没有找到源文档,填表结果将全部为空") @@ -140,7 +144,7 @@ class TemplateFillService: source_file_paths: Optional[List[str]] = None ) -> List[SourceDocument]: """ - 加载源文档内容 + 加载源文档内容,并对 TXT 文件进行 AI 结构化分析 Args: source_doc_ids: MongoDB 文档 ID 列表 @@ -157,12 +161,23 @@ class TemplateFillService: try: doc = await mongodb.get_document(doc_id) if doc: + doc_type = doc.get("doc_type", "unknown") + content = doc.get("content", "") + + # 对 TXT 文档进行 AI 结构化分析 + ai_structured = None + if doc_type == "txt" and content: + logger.info(f"MongoDB TXT 文档需要 AI 分析: {doc_id}, 内容长度: {len(content)}") + ai_structured = await self._analyze_txt_once(content, doc.get("metadata", {}).get("original_filename", "unknown")) + logger.info(f"AI 分析结果: has_data={ai_structured is not None}") + source_docs.append(SourceDocument( doc_id=doc_id, filename=doc.get("metadata", {}).get("original_filename", "unknown"), - doc_type=doc.get("doc_type", "unknown"), - content=doc.get("content", ""), - structured_data=doc.get("structured_data", {}) + doc_type=doc_type, + content=content, + structured_data=doc.get("structured_data", {}), + ai_structured_data=ai_structured )) logger.info(f"从MongoDB加载文档: {doc_id}") except Exception as e: @@ -170,10 +185,13 @@ class TemplateFillService: # 2. 从文件路径加载文档 if source_file_paths: + logger.info(f"开始从文件路径加载 {len(source_file_paths)} 个文档") for file_path in source_file_paths: try: + logger.info(f" 加载文件: {file_path}") parser = ParserFactory.get_parser(file_path) result = parser.parse(file_path) + logger.info(f" 解析结果: success={result.success}, error={result.error}") if result.success: # result.data 的结构取决于解析器类型: # - Excel 单 sheet: {columns: [...], rows: [...], row_count, column_count} @@ -182,20 +200,149 @@ class TemplateFillService: 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 {} + doc_type = result.metadata.get("extension", "unknown").replace(".", "").lower() + logger.info(f" 文件类型: {doc_type}, 内容长度: {len(doc_content)}") + + # 对 TXT 文件进行 AI 结构化分析 + ai_structured = None + if doc_type == "txt" and doc_content: + logger.info(f" 检测到 TXT 文件,内容前100字: {doc_content[:100]}") + ai_structured = await self._analyze_txt_once(doc_content, result.metadata.get("filename", Path(file_path).name)) + logger.info(f" AI 分析完成: has_result={ai_structured is not None}") + if ai_structured: + logger.info(f" AI 结果 keys: {list(ai_structured.keys())}") + if "table" in ai_structured: + table = ai_structured.get("table", {}) + logger.info(f" AI 表格: {len(table.get('columns', []))} 列, {len(table.get('rows', []))} 行") source_docs.append(SourceDocument( doc_id=file_path, - filename=result.metadata.get("filename", file_path.split("/")[-1]), - doc_type=result.metadata.get("extension", "unknown").replace(".", ""), + filename=result.metadata.get("filename", Path(file_path).name), + doc_type=doc_type, content=doc_content, - structured_data=doc_structured + structured_data=doc_structured, + ai_structured_data=ai_structured )) - logger.info(f"从文件加载文档: {file_path}, content长度: {len(doc_content)}, structured数据: {bool(doc_structured)}") + else: + logger.warning(f"文档解析失败 {file_path}: {result.error}") except Exception as e: - logger.error(f"从文件加载文档失败 {file_path}: {str(e)}") + logger.error(f"从文件加载文档失败 {file_path}: {str(e)}", exc_info=True) return source_docs + async def _analyze_txt_once(self, content: str, filename: str) -> Optional[Dict[str, Any]]: + """ + 对 TXT 内容进行一次性 AI 分析,提取保持行结构的表格数据 + + Args: + content: 原始文本内容 + filename: 文件名 + + Returns: + 分析结果字典,包含表格数据 + """ + # 确保 content 是字符串 + if isinstance(content, bytes): + try: + content = content.decode('utf-8') + except: + content = content.decode('gbk', errors='replace') + + if not content or len(str(content).strip()) < 10: + logger.warning(f"TXT 内容过短或为空: {filename}, 类型: {type(content)}") + return None + + content = str(content) + + # 限制内容长度,避免 token 超限 + max_chars = 8000 + truncated_content = content[:max_chars] if len(content) > max_chars else content + + prompt = f"""你是一个专业的数据提取助手。请从以下文本内容中提取表格数据。 + +文件名:{filename} + +文本内容: +{truncated_content} + +请仔细分析文本中的表格数据,提取所有行。每行是一个完整的数据记录。 + +请严格按以下 JSON 格式输出,不要添加任何解释: +{{ + "table": {{ + "columns": ["列1", "列2", "列3", ...], + "rows": [ + ["值1", "值2", "值3", ...], + ["值1", "值2", "值3", ...] + ] + }}, + "summary": "简要说明数据内容" +}}""" + + messages = [ + {"role": "system", "content": "你是一个专业的数据提取助手。请严格按JSON格式输出,只输出纯JSON。"}, + {"role": "user", "content": prompt} + ] + + try: + logger.info(f"开始 AI 分析 TXT 文件: {filename}, 内容长度: {len(truncated_content)}") + response = await self.llm.chat( + messages=messages, + temperature=0.1, + max_tokens=2000 + ) + + ai_content = self.llm.extract_message_content(response) + logger.info(f"LLM 返回内容长度: {len(ai_content)}, 内容前200字: {ai_content[:200]}") + + # 解析 JSON + import json + import re + + cleaned = ai_content.strip() + cleaned = re.sub(r'^```json\s*', '', cleaned, flags=re.MULTILINE) + cleaned = re.sub(r'^```\s*', '', cleaned, flags=re.MULTILINE) + cleaned = cleaned.strip() + + logger.info(f"清理后内容前200字: {cleaned[:200]}") + + # 查找 JSON + json_start = cleaned.find('{') + json_end = cleaned.rfind('}') + 1 + + if json_start >= 0 and json_end > json_start: + json_str = cleaned[json_start:json_end] + logger.info(f"提取的JSON字符串: {json_str[:200]}") + try: + result = json.loads(json_str) + # 兼容不同格式的返回 + if "table" in result: + table = result["table"] + elif "data" in result: + table = result["data"] + elif "rows" in result: + table = {"columns": result.get("columns", []), "rows": result.get("rows", [])} + else: + # 尝试直接使用根级别的数据 + table = result + + if isinstance(table, dict) and ("columns" in table or "rows" in table): + columns = table.get("columns", []) + rows = table.get("rows", []) + logger.info(f"TXT AI 分析成功: {filename}, 列数: {len(columns)}, 行数: {len(rows)}") + return {"table": {"columns": columns, "rows": rows}, "summary": result.get("summary", "")} + else: + logger.warning(f"JSON 中没有找到有效的表格数据: {filename}, result keys: {list(result.keys())}") + except json.JSONDecodeError as e: + logger.warning(f"JSON 解析失败: {e}, json_str: {json_str[:200]}") + + logger.warning(f"无法解析 AI 返回的 JSON: {filename}, ai_content: {ai_content[:500]}") + return None + + except Exception as e: + logger.error(f"AI 分析 TXT 失败: {str(e)}, 文件: {filename}", exc_info=True) + return None + async def _extract_field_value( self, field: TemplateField, @@ -237,27 +384,25 @@ class TemplateFillService: logger.info(f"字段 {field.name} 无法直接从结构化数据提取,使用 LLM...") # 构建上下文文本 - 传入字段名,只提取该列数据 - context_text = self._build_context_text(source_docs, field_name=field.name, max_length=200000) + context_text = await self._build_context_text(source_docs, field_name=field.name, max_length=6000) # 构建提示词 hint_text = field.hint if field.hint else f"请提取{field.name}的信息" if user_hint: hint_text = f"{user_hint}。{hint_text}" - prompt = f"""你是一个专业的数据提取专家。请从以下文档内容中提取"{field.name}"字段的所有行数据。 + prompt = f"""你是一个专业的数据提取专家。请从以下文档内容中提取"{field.name}"字段的值。 -参考文档内容(已提取" {field.name}"列的数据): +参考文档内容: {context_text} -请提取上述所有行的" {field.name}"值,存入数组。每一行对应数组中的一个元素。 -如果某行该字段为空,请用空字符串""占位。 +请仔细阅读上述内容,找到所有与"{field.name}"相关的值。 +如果内容是表格格式,请找到对应的列,提取该列所有行的值。 +每一行对应数组中的一个元素,保持行与行的对应关系。 +如果找不到对应的值,返回空数组。 -请严格按照以下 JSON 格式输出,不要添加任何解释: -{{ - "values": ["第1行的值", "第2行的值", "第3行的值", ...], - "source": "数据来源的文档描述", - "confidence": 0.0到1.0之间的置信度 -}} +请严格按以下JSON格式输出(只输出纯JSON,不要任何解释): +{{"values": ["值1", "值2", "值3", ...], "source": "来源说明", "confidence": 0.9}} """ # 调用 LLM @@ -270,7 +415,7 @@ class TemplateFillService: response = await self.llm.chat( messages=messages, temperature=0.1, - max_tokens=50000 + max_tokens=2000 ) content = self.llm.extract_message_content(response) @@ -280,7 +425,6 @@ class TemplateFillService: import re extracted_values = [] - extracted_value = "" extracted_source = "LLM生成" confidence = 0.5 @@ -368,7 +512,7 @@ class TemplateFillService: confidence=0.0 ) - def _build_context_text(self, source_docs: List[SourceDocument], field_name: str = None, max_length: int = 8000) -> str: + async def _build_context_text(self, source_docs: List[SourceDocument], field_name: str = None, max_length: int = 8000) -> str: """ 构建上下文文本 @@ -474,7 +618,54 @@ class TemplateFillService: doc_content += " | ".join(str(cell) for cell in row) + "\n" row_count += 1 elif doc.content: - doc_content = doc.content[:5000] + # TXT 文件优先使用 AI 分析后的结构化数据 + if doc.doc_type == "txt" and doc.ai_structured_data: + # 使用 AI 结构化分析结果 + ai_table = doc.ai_structured_data.get("table", {}) + columns = ai_table.get("columns", []) + rows = ai_table.get("rows", []) + + logger.info(f"TXT AI 结构化数据: doc_type={doc.doc_type}, has_ai_data={doc.ai_structured_data is not None}, columns={columns}, rows={len(rows) if rows else 0}") + + if columns and rows: + doc_content += f"\n【文档: {doc.filename} - AI 结构化表格,共 {len(rows)} 行】\n" + if field_name: + # 查找匹配的列 + target_col = None + for col in columns: + if field_name.lower() in str(col).lower() or str(col).lower() in field_name.lower(): + target_col = col + break + if target_col: + doc_content += f"列名: {target_col}\n" + for row_idx, row in enumerate(rows): + if isinstance(row, list) and target_col in columns: + val = row[columns.index(target_col)] + else: + val = str(row.get(target_col, "")) if isinstance(row, dict) else "" + doc_content += f"行{row_idx+1}: {val}\n" + row_count += 1 + else: + # 输出表格 + doc_content += " | ".join(str(col) for col in columns) + "\n" + for row in rows: + if isinstance(row, list): + doc_content += " | ".join(str(cell) for cell in row) + "\n" + else: + doc_content += " | ".join(str(row.get(col, "")) for col in columns) + "\n" + row_count += 1 + logger.info(f"使用 TXT AI 结构化表格: {doc.filename}, {len(columns)} 列, {len(rows)} 行") + else: + # AI 结果无表格,回退到原始内容 + doc_content = doc.content[:8000] + logger.warning(f"TXT AI 结果无表格: {doc.filename}, 使用原始内容") + elif doc.doc_type == "txt" and doc.content: + # 没有 AI 分析结果,直接使用原始内容 + doc_content = doc.content[:8000] + logger.info(f"使用 TXT 原始内容: {doc.filename}, 长度: {len(doc_content)}") + else: + # 其他文档类型直接使用内容 + doc_content = doc.content[:5000] if doc_content: doc_context = f"【文档: {doc.filename} ({doc.doc_type})】\n{doc_content}" @@ -494,6 +685,182 @@ class TemplateFillService: logger.info(f"最终上下文长度: {len(result)}") return result + async def analyze_txt_with_ai(self, content: str, filename: str = "") -> Dict[str, Any]: + """ + 使用 AI 分析 TXT 文本内容,提取结构化数据 + + Args: + content: 原始文本内容 + filename: 文件名(用于日志) + + Returns: + 结构化数据,包含: + - key_value_pairs: 键值对列表 + - tables: 表格数据列表 + - numeric_data: 数值数据列表 + - text_summary: 文本摘要 + """ + if not content or len(content.strip()) < 10: + logger.warning(f"TXT 内容过短或为空,跳过 AI 分析: {filename}") + return {} + + # 截断过长的文本,避免 token 超限 + max_chars = 15000 + truncated_content = content[:max_chars] if len(content) > max_chars else content + + system_prompt = """你是一个专业的数据提取专家。请分析提供的文本内容,提取其中包含的结构化信息。 + +请提取以下类型的数据: + +1. **键值对信息**:从文本中提取的名词-值对,如"姓名: 张三"、"年龄: 25"等 +2. **表格数据**:如果文本中包含表格或列表形式的数据,提取出来 +3. **数值数据**:包含数值、金额、百分比、统计数字等 +4. **关键描述**:文本的核心内容摘要 + +请严格按照以下 JSON 格式输出,不要添加任何 Markdown 标记或解释: +{ + "key_value_pairs": [ + {"key": "键名1", "value": "值1"}, + {"key": "键名2", "value": "值2"} + ], + "tables": [ + { + "description": "表格描述", + "columns": ["列1", "列2"], + "rows": [["值1", "值2"], ["值3", "值4"]] + } + ], + "numeric_data": [ + {"name": "数据项名称", "value": 123.45, "unit": "单位"} + ], + "text_summary": "一段简洁的文本摘要,不超过200字" +}""" + + user_message = f"""请分析以下文本内容,提取结构化数据: + +文件名:{filename} + +文本内容: +{truncated_content} + +请严格按 JSON 格式输出。""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message} + ] + + try: + logger.info(f"开始 AI 分析 TXT 文件: {filename}, 内容长度: {len(truncated_content)}") + response = await self.llm.chat( + messages=messages, + temperature=0.1, + max_tokens=2000 + ) + + ai_content = self.llm.extract_message_content(response) + logger.info(f"AI 返回内容长度: {len(ai_content)}") + + # 解析 JSON + import json + import re + + # 清理 markdown 格式 + cleaned = ai_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 >= 0: + brace_count = 0 + json_end = -1 + for i in range(json_start, len(cleaned)): + if cleaned[i] == '{': + brace_count += 1 + elif cleaned[i] == '}': + brace_count -= 1 + if brace_count == 0: + json_end = i + 1 + break + + if json_end > json_start: + json_str = cleaned[json_start:json_end] + result = json.loads(json_str) + logger.info(f"TXT AI 分析成功: {filename}, 提取到 {len(result.get('key_value_pairs', []))} 个键值对") + return result + + logger.warning(f"无法从 AI 返回中解析 JSON: {filename}") + return {} + + except json.JSONDecodeError as e: + logger.error(f"JSON 解析失败: {str(e)}, 文件: {filename}") + return {} + except Exception as e: + logger.error(f"AI 分析 TXT 失败: {str(e)}, 文件: {filename}", exc_info=True) + return {} + + def _format_structured_for_context(self, structured_data: Dict[str, Any], filename: str) -> str: + """ + 将结构化数据格式化为上下文文本 + + Args: + structured_data: AI 分析返回的结构化数据 + filename: 文件名 + + Returns: + 格式化的文本上下文 + """ + parts = [] + + # 添加标题 + parts.append(f"【文档: {filename} - AI 结构化分析结果】") + + # 格式化键值对 + key_value_pairs = structured_data.get("key_value_pairs", []) + if key_value_pairs: + parts.append("\n## 关键信息:") + for kv in key_value_pairs[:20]: # 最多 20 个 + parts.append(f"- {kv.get('key', '')}: {kv.get('value', '')}") + + # 格式化表格数据 + tables = structured_data.get("tables", []) + if tables: + parts.append("\n## 表格数据:") + for i, table in enumerate(tables[:5]): # 最多 5 个表格 + desc = table.get("description", f"表格{i+1}") + columns = table.get("columns", []) + rows = table.get("rows", []) + if columns and rows: + parts.append(f"\n### {desc}") + parts.append("| " + " | ".join(str(c) for c in columns) + " |") + parts.append("| " + " | ".join(["---"] * len(columns)) + " |") + for row in rows[:10]: # 每个表格最多 10 行 + parts.append("| " + " | ".join(str(cell) for cell in row) + " |") + + # 格式化数值数据 + numeric_data = structured_data.get("numeric_data", []) + if numeric_data: + parts.append("\n## 数值数据:") + for num in numeric_data[:15]: # 最多 15 个 + name = num.get("name", "") + value = num.get("value", "") + unit = num.get("unit", "") + parts.append(f"- {name}: {value} {unit}") + + # 添加文本摘要 + text_summary = structured_data.get("text_summary", "") + if text_summary: + parts.append(f"\n## 内容摘要:\n{text_summary}") + + return "\n".join(parts) + async def get_template_fields_from_file( self, file_path: str, @@ -675,7 +1042,7 @@ class TemplateFillService: def _extract_values_from_structured_data(self, source_docs: List[SourceDocument], field_name: str) -> List[str]: """ - 从结构化数据(Excel rows)中直接提取指定列的值 + 从结构化数据(Excel rows)或 AI 结构化分析结果中直接提取指定列的值 适用于有 rows 结构的文档数据,无需 LLM 即可提取 @@ -689,6 +1056,18 @@ class TemplateFillService: all_values = [] for doc in source_docs: + # 优先从 AI 结构化数据中提取(适用于 TXT 文件) + if doc.ai_structured_data: + ai_table = doc.ai_structured_data.get("table", {}) + columns = ai_table.get("columns", []) + rows = ai_table.get("rows", []) + if columns and rows: + values = self._extract_column_values(rows, columns, field_name) + if values: + all_values.extend(values) + logger.info(f"从 TXT AI 结构化数据提取到 {len(values)} 个值: {doc.filename}") + break + # 尝试从 structured_data 中提取 structured = doc.structured_data diff --git a/frontend/src/db/backend-api.ts b/frontend/src/db/backend-api.ts index d26e1a8..7c7591d 100644 --- a/frontend/src/db/backend-api.ts +++ b/frontend/src/db/backend-api.ts @@ -1161,6 +1161,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; + } + }, + /** * 生成统计信息和图表 */ From 4a53be7eeb242a338762072ae16157f1c5c7823a Mon Sep 17 00:00:00 2001 From: tl <1655185665@qq.com> Date: Tue, 14 Apr 2026 14:58:14 +0800 Subject: [PATCH 3/4] TL --- backend/app/main.py | 7 + backend/app/services/llm_service.py | 81 +- backend/app/services/template_fill_service.py | 1809 +++-------------- 3 files changed, 382 insertions(+), 1515 deletions(-) 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 53f42c2..6905dc5 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -42,41 +42,86 @@ class LLMService: "Content-Type": "application/json" } + # DeepSeek API temperature 范围: (0, 2] + if temperature < 0.01: + temperature = 0.01 + elif temperature > 2.0: + temperature = 2.0 + payload = { "model": self.model_name, "messages": messages, "temperature": temperature } + # DeepSeek API 限制 max_tokens 范围 if max_tokens: + if max_tokens > 8192: + max_tokens = 8192 payload["max_tokens"] = max_tokens + # 移除不兼容的参数 + for key in ["stream", "stop", "presence_penalty", "frequency_penalty", "logit_bias"]: + kwargs.pop(key, None) + # 添加其他参数 payload.update(kwargs) - try: - logger.info(f"LLM API 请求: model={self.model_name}, temperature={temperature}, max_tokens={max_tokens}") - logger.info(f"消息数量: {len(messages)}") - for i, msg in enumerate(messages): - logger.info(f"消息[{i}]: role={msg.get('role')}, content长度={len(msg.get('content', ''))}") + # 验证消息格式 + validated_messages = [] + for i, msg in enumerate(messages): + role = msg.get("role", "") + content = msg.get("content", "") - async with httpx.AsyncClient(timeout=60.0) as client: + # 确保 content 是字符串 + if not isinstance(content, str): + logger.warning(f"消息[{i}] content 不是字符串类型: {type(content)},转换为字符串") + content = str(content) + + # 确保 role 有效 + if role not in ["system", "user", "assistant"]: + logger.warning(f"消息[{i}] role 无效: {role},跳过") + continue + + validated_messages.append({"role": role, "content": content}) + + payload["messages"] = validated_messages + logger.info(f"验证后消息数量: {len(validated_messages)}") + + try: + logger.info(f"LLM API 请求: model={self.model_name}, base_url={self.base_url}, temperature={temperature}, max_tokens={max_tokens}") + logger.info(f"消息数量: {len(messages)}") + total_content_len = sum(len(msg.get('content', '')) for msg in messages) + logger.info(f"总内容长度: {total_content_len}") + + async with httpx.AsyncClient(timeout=120.0) as client: response = await client.post( f"{self.base_url}/chat/completions", headers=headers, json=payload ) + logger.info(f"LLM API 响应状态: {response.status_code}") + if response.status_code != 200: - logger.error(f"LLM API 响应内容: {response.text}") + error_text = response.text + logger.error(f"LLM API 错误响应: {error_text}") + # 尝试解析错误详情 + try: + error_json = response.json() + error_msg = error_json.get("error", {}).get("message", error_text) + logger.error(f"错误详情: {error_msg}") + except: + pass + response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: - logger.error(f"LLM API 请求失败: {e.response.status_code} - {e.response.text}") + logger.error(f"LLM API HTTP 错误: {e.response.status_code} - {e.response.text}") raise except Exception as e: - logger.error(f"LLM API 调用异常: {str(e)}") + logger.error(f"LLM API 调用异常: {str(e)}", exc_info=True) raise def extract_message_content(self, response: Dict[str, Any]) -> str: @@ -119,6 +164,10 @@ class LLMService: "Content-Type": "application/json" } + # DeepSeek API 限制 + if max_tokens and max_tokens > 8192: + max_tokens = 8192 + payload = { "model": self.model_name, "messages": messages, @@ -129,9 +178,14 @@ class LLMService: if max_tokens: payload["max_tokens"] = max_tokens + # 移除不兼容的参数 + for key in ["stop", "presence_penalty", "frequency_penalty", "logit_bias"]: + kwargs.pop(key, None) payload.update(kwargs) try: + logger.info(f"LLM 流式 API 请求: model={self.model_name}, max_tokens={max_tokens}") + async with httpx.AsyncClient(timeout=120.0) as client: async with client.stream( "POST", @@ -139,9 +193,14 @@ class LLMService: headers=headers, json=payload ) as response: + if response.status_code != 200: + error_text = await response.aread() + logger.error(f"LLM 流式 API 错误: {response.status_code} - {error_text}") + response.raise_for_status() + async for line in response.aiter_lines(): if line.startswith("data: "): - data = line[6:] # Remove "data: " prefix + data = line[6:] if data == "[DONE]": break try: @@ -157,7 +216,7 @@ class LLMService: logger.error(f"LLM 流式 API 请求失败: {e.response.status_code}") raise except Exception as e: - logger.error(f"LLM 流式 API 调用异常: {str(e)}") + logger.error(f"LLM 流式 API 调用异常: {str(e)}", exc_info=True) raise async def analyze_excel_data( diff --git a/backend/app/services/template_fill_service.py b/backend/app/services/template_fill_service.py index 94bd235..1d0fb57 100644 --- a/backend/app/services/template_fill_service.py +++ b/backend/app/services/template_fill_service.py @@ -11,7 +11,6 @@ from typing import Any, Dict, List, Optional 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 logger = logging.getLogger(__name__) @@ -62,10 +61,7 @@ class TemplateFillService: template_fields: List[TemplateField], source_doc_ids: Optional[List[str]] = None, source_file_paths: Optional[List[str]] = None, - user_hint: Optional[str] = None, - template_id: Optional[str] = None, - template_file_type: Optional[str] = "xlsx", - task_id: Optional[str] = None + user_hint: Optional[str] = None ) -> Dict[str, Any]: """ 填写表格模板 @@ -75,9 +71,6 @@ class TemplateFillService: source_doc_ids: 源文档 MongoDB ID 列表 source_file_paths: 源文档文件路径列表 user_hint: 用户提示(如"请从合同文档中提取") - template_id: 模板文件路径(用于重新生成表头) - template_file_type: 模板文件类型 - task_id: 可选的任务ID,用于任务进度跟踪 Returns: 填写结果 @@ -85,13 +78,7 @@ class TemplateFillService: filled_data = {} fill_details = [] -<<<<<<< HEAD logger.info(f"开始填表: {len(template_fields)} 个字段, {len(source_doc_ids or [])} 个源文档, {len(source_file_paths or [])} 个文件路径") -======= - logger.info(f"开始填表: {len(template_fields)} 个字段, {len(source_doc_ids or [])} 个源文档") - logger.info(f"source_doc_ids: {source_doc_ids}") - logger.info(f"source_file_paths: {source_file_paths}") ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 # 1. 加载源文档内容 source_docs = await self._load_source_documents(source_doc_ids, source_file_paths) @@ -100,86 +87,9 @@ class TemplateFillService: for doc in source_docs: logger.info(f" - 文档: {doc.filename}, 类型: {doc.doc_type}, 内容长度: {len(doc.content)}, AI分析: {bool(doc.ai_structured_data)}") - # 打印每个加载的文档的详细信息 - for i, doc in enumerate(source_docs): - logger.info(f" 文档[{i}]: id={doc.doc_id}, filename={doc.filename}, doc_type={doc.doc_type}") - logger.info(f" content长度: {len(doc.content)}, structured_data keys: {list(doc.structured_data.keys()) if doc.structured_data else 'None'}") - if not source_docs: logger.warning("没有找到源文档,填表结果将全部为空") - # 3. 检查是否需要使用源文档重新生成表头 - # 条件:源文档已加载 AND 现有字段看起来是自动生成的(如"字段1"、"字段2") - needs_regenerate_headers = ( - len(source_docs) > 0 and - len(template_fields) > 0 and - all(self._is_auto_generated_field(f.name) for f in template_fields) - ) - - if needs_regenerate_headers: - logger.info(f"检测到自动生成表头,尝试使用源文档重新生成... (当前字段: {[f.name for f in template_fields]})") - - # 将 SourceDocument 转换为 source_contents 格式 - source_contents = [] - for doc in source_docs: - structured = doc.structured_data if doc.structured_data else {} - - # 获取标题 - titles = structured.get("titles", []) - if not titles: - titles = [] - - # 获取表格 - tables = structured.get("tables", []) - tables_count = len(tables) if tables else 0 - - # 生成表格摘要 - tables_summary = "" - if tables: - tables_summary = "\n【文档中的表格】:\n" - for idx, table in enumerate(tables[:5]): - if isinstance(table, dict): - headers = table.get("headers", []) - rows = table.get("rows", []) - if headers: - tables_summary += f"表格{idx+1}表头: {', '.join(str(h) for h in headers)}\n" - if rows: - tables_summary += f"表格{idx+1}前3行: " - for row_idx, row in enumerate(rows[:3]): - if isinstance(row, list): - tables_summary += " | ".join(str(c) for c in row) + "; " - elif isinstance(row, dict): - tables_summary += " | ".join(str(row.get(h, "")) for h in headers if headers) + "; " - tables_summary += "\n" - - source_contents.append({ - "filename": doc.filename, - "doc_type": doc.doc_type, - "content": doc.content[:5000] if doc.content else "", - "titles": titles[:10] if titles else [], - "tables_count": tables_count, - "tables_summary": tables_summary - }) - - # 使用源文档内容重新生成表头 - if template_id and template_file_type: - logger.info(f"使用源文档重新生成表头: template_id={template_id}, template_file_type={template_file_type}") - new_fields = await self.get_template_fields_from_file( - template_id, - template_file_type, - source_contents=source_contents - ) - if new_fields and len(new_fields) > 0: - logger.info(f"成功重新生成表头: {[f.name for f in new_fields]}") - template_fields = new_fields - else: - logger.warning("重新生成表头返回空结果,使用原始字段") - else: - logger.warning("无法重新生成表头:缺少 template_id 或 template_file_type") - else: - if source_docs and template_fields: - logger.info(f"表头看起来正常(非自动生成),无需重新生成: {[f.name for f in template_fields[:5]]}") - # 2. 对每个字段进行提取 for idx, field in enumerate(template_fields): try: @@ -191,22 +101,6 @@ class TemplateFillService: user_hint=user_hint ) - # AI审核:验证提取的值是否合理 - if result.values and result.values[0]: - logger.info(f"字段 {field.name} 进入AI审核阶段...") - verified_result = await self._verify_field_value( - field=field, - extracted_values=result.values, - source_docs=source_docs, - user_hint=user_hint - ) - if verified_result: - # 审核给出了修正结果 - result = verified_result - logger.info(f"字段 {field.name} 审核后修正值: {result.values[:3]}") - else: - logger.info(f"字段 {field.name} 审核通过,使用原提取结果") - # 存储结果 - 使用 values 数组 filled_data[field.name] = result.values if result.values else [""] fill_details.append({ @@ -267,7 +161,6 @@ class TemplateFillService: try: doc = await mongodb.get_document(doc_id) if doc: -<<<<<<< HEAD doc_type = doc.get("doc_type", "unknown") content = doc.get("content", "") @@ -277,58 +170,16 @@ class TemplateFillService: logger.info(f"MongoDB TXT 文档需要 AI 分析: {doc_id}, 内容长度: {len(content)}") ai_structured = await self._analyze_txt_once(content, doc.get("metadata", {}).get("original_filename", "unknown")) logger.info(f"AI 分析结果: has_data={ai_structured is not None}") -======= - sd = doc.get("structured_data", {}) - sd_keys = list(sd.keys()) if sd else [] - logger.info(f"从MongoDB加载文档: {doc_id}, doc_type={doc.get('doc_type')}, structured_data keys={sd_keys}") - - # 如果 structured_data 为空,但有 file_path,尝试重新解析文件 - doc_content = doc.get("content", "") - if not sd or (not sd.get("tables") and not sd.get("headers") and not sd.get("rows")): - file_path = doc.get("metadata", {}).get("file_path") - if file_path: - logger.info(f" structured_data 为空,尝试重新解析文件: {file_path}") - try: - parser = ParserFactory.get_parser(file_path) - result = parser.parse(file_path) - if result.success and result.data: - if result.data.get("structured_data"): - sd = result.data.get("structured_data") - logger.info(f" 重新解析成功,structured_data keys: {list(sd.keys())}") - elif result.data.get("tables"): - sd = {"tables": result.data.get("tables", [])} - logger.info(f" 使用 data.tables,tables数量: {len(sd.get('tables', []))}") - elif result.data.get("rows"): - sd = result.data - logger.info(f" 使用 data.rows 格式") - if result.data.get("content"): - doc_content = result.data.get("content", "") - else: - logger.warning(f" 重新解析失败: {result.error if result else 'unknown'}") - except Exception as parse_err: - logger.error(f" 重新解析文件异常: {str(parse_err)}") - - if sd.get("tables"): - logger.info(f" tables数量: {len(sd.get('tables', []))}") - if sd["tables"]: - first_table = sd["tables"][0] - logger.info(f" 第一表格: headers={first_table.get('headers', [])[:3]}..., rows数量={len(first_table.get('rows', []))}") ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 source_docs.append(SourceDocument( doc_id=doc_id, filename=doc.get("metadata", {}).get("original_filename", "unknown"), -<<<<<<< HEAD doc_type=doc_type, content=content, structured_data=doc.get("structured_data", {}), ai_structured_data=ai_structured -======= - doc_type=doc.get("doc_type", "unknown"), - content=doc_content, - structured_data=sd ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 )) + logger.info(f"从MongoDB加载文档: {doc_id}") except Exception as e: logger.error(f"从MongoDB加载文档失败 {doc_id}: {str(e)}") @@ -342,15 +193,35 @@ class TemplateFillService: result = parser.parse(file_path) logger.info(f" 解析结果: success={result.success}, error={result.error}") if result.success: - # result.data 的结构取决于解析器类型: - # - Excel 单 sheet: {columns: [...], rows: [...], row_count, column_count} - # - Excel 多 sheet: {sheets: {sheet_name: {columns, rows, ...}}} - # - Markdown: {content: "...", tables: [...], structured_data: {tables: [...]}} - # - Word/TXT: {content: "...", structured_data: {...}} doc_data = result.data if result.data else {} doc_content = doc_data.get("content", "") if isinstance(doc_data, dict) else "" -<<<<<<< HEAD - 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 + doc_structured = {} + if isinstance(doc_data, dict): + # Excel 多 sheet + if "sheets" in doc_data: + doc_structured = doc_data + # Excel 单 sheet 或有 rows 的格式 + elif "rows" in doc_data: + doc_structured = doc_data + # Markdown 格式 + elif "tables" in doc_data and doc_data["tables"]: + tables = doc_data["tables"] + first_table = tables[0] + doc_structured = { + "headers": first_table.get("headers", []), + "rows": first_table.get("rows", []) + } + elif "structured_data" in doc_data and isinstance(doc_data["structured_data"], dict): + tables = doc_data["structured_data"].get("tables", []) + if tables: + first_table = tables[0] + doc_structured = { + "headers": first_table.get("headers", []), + "rows": first_table.get("rows", []) + } + doc_type = result.metadata.get("extension", "unknown").replace(".", "").lower() logger.info(f" 文件类型: {doc_type}, 内容长度: {len(doc_content)}") @@ -365,46 +236,6 @@ class TemplateFillService: if "table" in ai_structured: table = ai_structured.get("table", {}) logger.info(f" AI 表格: {len(table.get('columns', []))} 列, {len(table.get('rows', []))} 行") -======= - - # 检查并提取 structured_data - doc_structured = {} - if isinstance(doc_data, dict): - logger.info(f"文档 {file_path} doc_data keys: {list(doc_data.keys())}") - - # Excel 多 sheet - if "sheets" in doc_data: - doc_structured = doc_data - logger.info(f" -> 使用 Excel 多 sheet 格式") - # Excel 单 sheet 或有 rows 的格式 - elif "rows" in doc_data: - doc_structured = doc_data - logger.info(f" -> 使用 rows 格式,列数: {len(doc_data.get('columns', []))}") - # Markdown 格式:tables 可能直接在 doc_data.tables 或在 structured_data.tables 中 - elif "tables" in doc_data and doc_data["tables"]: - # Markdown: tables 直接在 doc_data 中 - tables = doc_data["tables"] - first_table = tables[0] - doc_structured = { - "headers": first_table.get("headers", []), - "rows": first_table.get("rows", []) - } - logger.info(f" -> 使用 doc_data.tables 格式,表头: {doc_structured.get('headers', [])[:5]}") - elif "structured_data" in doc_data and isinstance(doc_data["structured_data"], dict): - # Markdown: tables 在 structured_data 中 - tables = doc_data["structured_data"].get("tables", []) - if tables: - first_table = tables[0] - doc_structured = { - "headers": first_table.get("headers", []), - "rows": first_table.get("rows", []) - } - logger.info(f" -> 使用 structured_data.tables 格式,表头: {doc_structured.get('headers', [])[:5]}") - else: - logger.warning(f" -> structured_data.tables 为空") - else: - logger.warning(f" -> 未识别的文档格式,无 structured_data") ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 source_docs.append(SourceDocument( doc_id=file_path, @@ -507,6 +338,7 @@ class TemplateFillService: try: result = json.loads(json_str) # 兼容不同格式的返回 + table = None if "table" in result: table = result["table"] elif "data" in result: @@ -514,7 +346,6 @@ class TemplateFillService: elif "rows" in result: table = {"columns": result.get("columns", []), "rows": result.get("rows", [])} else: - # 尝试直接使用根级别的数据 table = result if isinstance(table, dict) and ("columns" in table or "rows" in table): @@ -571,12 +402,6 @@ class TemplateFillService: confidence=1.0 ) - # 无法直接从结构化数据提取,尝试 AI 分析非结构化文档 - ai_structured = await self._analyze_unstructured_docs_for_fields(source_docs, field, user_hint) - if ai_structured: - logger.info(f"✅ 字段 {field.name} 通过 AI 分析结构化提取到数据") - return ai_structured - # 无法从结构化数据提取,使用 LLM logger.info(f"字段 {field.name} 无法直接从结构化数据提取,使用 LLM...") @@ -588,7 +413,6 @@ class TemplateFillService: if user_hint: hint_text = f"{user_hint}。{hint_text}" -<<<<<<< HEAD prompt = f"""你是一个专业的数据提取专家。请从以下文档内容中提取"{field.name}"字段的值。 参考文档内容: @@ -601,24 +425,6 @@ class TemplateFillService: 请严格按以下JSON格式输出(只输出纯JSON,不要任何解释): {{"values": ["值1", "值2", "值3", ...], "source": "来源说明", "confidence": 0.9}} -======= - prompt = f"""你是一个专业的数据提取专家。请从以下文档内容中提取与"{field.name}"相关的所有信息。 - -提示词: {hint_text} - -文档内容: -{context_text} - -请分析文档结构(可能包含表格、标题段落等),找出所有与"{field.name}"相关的数据。 -如果找到表格数据,返回多行值;如果是非表格段落,提取关键信息。 - -请严格按照以下 JSON 格式输出: -{{ - "values": ["第1行的值", "第2行的值", ...], - "source": "数据来源描述", - "confidence": 0.0到1.0之间的置信度 -}} ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 """ # 调用 LLM @@ -631,11 +437,7 @@ class TemplateFillService: response = await self.llm.chat( messages=messages, temperature=0.1, -<<<<<<< HEAD max_tokens=2000 -======= - max_tokens=4000 ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 ) content = self.llm.extract_message_content(response) @@ -651,7 +453,6 @@ class TemplateFillService: logger.info(f"原始 LLM 返回: {content[:500]}") # ========== 步骤1: 彻底清理 markdown 和各种格式问题 ========== - # 移除 ```json 和 ``` 标记 cleaned = content.strip() cleaned = re.sub(r'^```json\s*', '', cleaned, flags=re.MULTILINE) cleaned = re.sub(r'^```\s*', '', cleaned, flags=re.MULTILINE) @@ -661,7 +462,6 @@ class TemplateFillService: # ========== 步骤2: 定位 JSON 开始位置 ========== json_start = -1 - # 找到第一个 { 或 [ for i, c in enumerate(cleaned): if c == '{' or c == '[': json_start = i @@ -675,7 +475,6 @@ class TemplateFillService: logger.info(f"JSON 开始位置: {json_start}, 内容: {json_text[:200]}") # ========== 步骤3: 尝试解析 JSON ========== - # 3a. 尝试直接解析整个字符串 try: result = json.loads(json_text) extracted_values = self._extract_values_from_json(result) @@ -686,8 +485,6 @@ class TemplateFillService: except json.JSONDecodeError as e: logger.warning(f"直接解析失败: {e}, 尝试修复...") - # 3b. 尝试修复常见的 JSON 问题 - # 尝试1: 找到配对的闭合括号 fixed_json = self._fix_json(json_text) if fixed_json: try: @@ -698,16 +495,13 @@ class TemplateFillService: except json.JSONDecodeError as e2: logger.warning(f"修复后仍然失败: {e2}") - # 3c. 如果以上都失败,使用正则直接从文本提取 values 数组 if not extracted_values: extracted_values = self._extract_values_by_regex(cleaned) if extracted_values: logger.info(f"✅ 正则提取成功,得到 {len(extracted_values)} 个值") else: - # 最后的备选:使用旧的文本提取 extracted_values = self._extract_values_from_text(cleaned, field.name) - # 如果仍然没有提取到值 if not extracted_values: extracted_values = [""] logger.warning(f"❌ 字段 {field.name} 没有提取到值") @@ -732,142 +526,7 @@ class TemplateFillService: confidence=0.0 ) -<<<<<<< HEAD async def _build_context_text(self, source_docs: List[SourceDocument], field_name: str = None, max_length: int = 8000) -> str: -======= - async def _verify_field_value( - self, - field: TemplateField, - extracted_values: List[str], - source_docs: List[SourceDocument], - user_hint: Optional[str] = None - ) -> Optional[FillResult]: - """ - 验证并修正提取的字段值 - - Args: - field: 字段定义 - extracted_values: 已提取的值 - source_docs: 源文档列表 - user_hint: 用户提示 - - Returns: - 验证后的结果,如果验证通过返回None(使用原结果) - """ - if not extracted_values or not extracted_values[0]: - return None - - if not source_docs: - return None - - try: - # 构建验证上下文 - context_text = self._build_context_text(source_docs, field_name=field.name, max_length=15000) - - hint_text = field.hint if field.hint else f"请理解{field.name}字段的含义" - if user_hint: - hint_text = f"{user_hint}。{hint_text}" - - prompt = f"""你是一个数据质量审核专家。请审核以下提取的数据是否合理。 - -【待审核字段】 -字段名:{field.name} -字段说明:{hint_text} - -【已提取的值】 -{extracted_values[:10]} # 最多审核前10个值 - -【源文档上下文】 -{context_text[:8000]} - -【审核要求】 -1. 这些值是否符合字段的含义? -2. 值在原文中的原始含义是什么?检查是否有误解或误提取 -3. 是否存在明显错误、空值或不合理的数据? -4. 如果表格有多个列,请确认提取的是正确的列 - -请严格按照以下 JSON 格式输出(只需输出 JSON,不要其他内容): -{{ - "is_valid": true或false, - "corrected_values": ["修正后的值列表"] 或 null(如果无需修正), - "reason": "审核说明,解释判断理由", - "original_meaning": "值在原文中的原始含义描述" -}} -""" - - messages = [ - {"role": "system", "content": "你是一个严格的数据质量审核专家。请仔细核对原文和提取的值是否匹配。"}, - {"role": "user", "content": prompt} - ] - - response = await self.llm.chat( - messages=messages, - temperature=0.2, - max_tokens=3000 - ) - - content = self.llm.extract_message_content(response) - logger.info(f"字段 {field.name} 审核返回: {content[:300]}") - - # 解析 JSON - import json - import re - - 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_start = -1 - for i, c in enumerate(cleaned): - if c == '{': - json_start = i - break - - if json_start == -1: - logger.warning(f"字段 {field.name} 审核:无法找到 JSON") - return None - - json_text = cleaned[json_start:] - result = json.loads(json_text) - - is_valid = result.get("is_valid", True) - corrected_values = result.get("corrected_values") - reason = result.get("reason", "") - original_meaning = result.get("original_meaning", "") - - logger.info(f"字段 {field.name} 审核结果: is_valid={is_valid}, reason={reason[:100]}") - - if not is_valid and corrected_values: - # 值有问题且有修正建议,使用修正后的值 - logger.info(f"字段 {field.name} 使用修正后的值: {corrected_values[:5]}") - return FillResult( - field=field.name, - values=corrected_values, - value=corrected_values[0] if corrected_values else "", - source=f"AI审核修正: {reason[:100]}", - confidence=0.7 - ) - elif not is_valid and original_meaning: - # 值有问题但无修正,记录原始含义供用户参考 - logger.info(f"字段 {field.name} 审核发现问题: {original_meaning}") - return FillResult( - field=field.name, - values=extracted_values, - value=extracted_values[0] if extracted_values else "", - source=f"AI审核疑问: {original_meaning[:100]}", - confidence=0.5 - ) - - # 验证通过,返回 None 表示使用原结果 - return None - - except Exception as e: - logger.error(f"字段 {field.name} 审核失败: {str(e)}") - return None - - def _build_context_text(self, source_docs: List[SourceDocument], field_name: str = None, max_length: int = 8000) -> str: ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 """ 构建上下文文本 @@ -883,12 +542,11 @@ class TemplateFillService: total_length = 0 for doc in source_docs: - # 优先使用结构化数据(表格),其次使用文本内容 doc_content = "" row_count = 0 + # Excel 多 sheet 格式 if doc.structured_data and doc.structured_data.get("sheets"): - # parse_all_sheets 格式: {sheets: {sheet_name: {columns, rows}}} sheets = doc.structured_data.get("sheets", {}) for sheet_name, sheet_data in sheets.items(): if isinstance(sheet_data, dict): @@ -896,27 +554,20 @@ class TemplateFillService: rows = sheet_data.get("rows", []) if rows and columns: doc_content += f"\n【文档: {doc.filename} - {sheet_name},共 {len(rows)} 行】\n" - # 如果指定了字段名,只提取该列数据 if field_name: - # 查找匹配的列(模糊匹配) - target_col = None - for col in columns: - if field_name.lower() in str(col).lower() or str(col).lower() in field_name.lower(): - target_col = col - break + target_col = self._find_best_matching_column(columns, field_name) if target_col: - doc_content += f"列名: {target_col}\n" + doc_content += f"列名: {columns[target_col]}\n" for row_idx, row in enumerate(rows): if isinstance(row, dict): - val = row.get(target_col, "") - elif isinstance(row, list) and target_col in columns: - val = row[columns.index(target_col)] + val = row.get(columns[target_col], "") + elif isinstance(row, list) and target_col < len(row): + val = row[target_col] else: val = "" doc_content += f"行{row_idx+1}: {val}\n" row_count += 1 else: - # 列名不匹配,输出所有列(但只输出关键列) doc_content += " | ".join(str(col) for col in columns) + "\n" for row in rows: if isinstance(row, dict): @@ -925,7 +576,6 @@ class TemplateFillService: doc_content += " | ".join(str(cell) for cell in row) + "\n" row_count += 1 else: - # 输出所有列和行 doc_content += " | ".join(str(col) for col in columns) + "\n" for row in rows: if isinstance(row, dict): @@ -933,25 +583,22 @@ class TemplateFillService: elif isinstance(row, list): doc_content += " | ".join(str(cell) for cell in row) + "\n" row_count += 1 + + # Excel 单 sheet 格式 elif doc.structured_data and doc.structured_data.get("rows"): - # Excel 单 sheet 格式: {columns: [...], rows: [...], ...} columns = doc.structured_data.get("columns", []) rows = doc.structured_data.get("rows", []) if rows and columns: doc_content += f"\n【文档: {doc.filename},共 {len(rows)} 行】\n" if field_name: - target_col = None - for col in columns: - if field_name.lower() in str(col).lower() or str(col).lower() in field_name.lower(): - target_col = col - break + target_col = self._find_best_matching_column(columns, field_name) if target_col: - doc_content += f"列名: {target_col}\n" + doc_content += f"列名: {columns[target_col]}\n" for row_idx, row in enumerate(rows): if isinstance(row, dict): - val = row.get(target_col, "") - elif isinstance(row, list) and target_col in columns: - val = row[columns.index(target_col)] + val = row.get(columns[target_col], "") + elif isinstance(row, list) and target_col < len(row): + val = row[target_col] else: val = "" doc_content += f"行{row_idx+1}: {val}\n" @@ -972,8 +619,9 @@ class TemplateFillService: elif isinstance(row, list): doc_content += " | ".join(str(cell) for cell in row) + "\n" row_count += 1 + + # Markdown 表格格式 elif doc.structured_data and doc.structured_data.get("tables"): - # Markdown 表格格式: {tables: [{headers: [...], rows: [...]}]} tables = doc.structured_data.get("tables", []) for table in tables: if isinstance(table, dict): @@ -986,45 +634,30 @@ class TemplateFillService: 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 "" + elif doc.content: # TXT 文件优先使用 AI 分析后的结构化数据 if doc.doc_type == "txt" and doc.ai_structured_data: - # 使用 AI 结构化分析结果 ai_table = doc.ai_structured_data.get("table", {}) columns = ai_table.get("columns", []) rows = ai_table.get("rows", []) - logger.info(f"TXT AI 结构化数据: doc_type={doc.doc_type}, has_ai_data={doc.ai_structured_data is not None}, columns={columns}, rows={len(rows) if rows else 0}") + logger.info(f"TXT AI 结构化数据: columns={columns}, rows={len(rows) if rows else 0}") if columns and rows: doc_content += f"\n【文档: {doc.filename} - AI 结构化表格,共 {len(rows)} 行】\n" if field_name: - # 查找匹配的列 - target_col = None - for col in columns: - if field_name.lower() in str(col).lower() or str(col).lower() in field_name.lower(): - target_col = col - break + target_col = self._find_best_matching_column(columns, field_name) if target_col: - doc_content += f"列名: {target_col}\n" + doc_content += f"列名: {columns[target_col]}\n" for row_idx, row in enumerate(rows): - if isinstance(row, list) and target_col in columns: - val = row[columns.index(target_col)] + if isinstance(row, list) and target_col < len(row): + val = row[target_col] else: - val = str(row.get(target_col, "")) if isinstance(row, dict) else "" + val = str(row.get(columns[target_col], "")) if isinstance(row, dict) else "" doc_content += f"行{row_idx+1}: {val}\n" row_count += 1 else: - # 输出表格 doc_content += " | ".join(str(col) for col in columns) + "\n" for row in rows: if isinstance(row, list): @@ -1034,15 +667,12 @@ class TemplateFillService: row_count += 1 logger.info(f"使用 TXT AI 结构化表格: {doc.filename}, {len(columns)} 列, {len(rows)} 行") else: - # AI 结果无表格,回退到原始内容 doc_content = doc.content[:8000] - logger.warning(f"TXT AI 结果无表格: {doc.filename}, 使用原始内容") - elif doc.doc_type == "txt" and doc.content: - # 没有 AI 分析结果,直接使用原始内容 + logger.warning(f"TXT AI 结果无表格,使用原始内容") + elif doc.doc_type == "txt": doc_content = doc.content[:8000] logger.info(f"使用 TXT 原始内容: {doc.filename}, 长度: {len(doc_content)}") else: - # 其他文档类型直接使用内容 doc_content = doc.content[:5000] if doc_content: @@ -1056,288 +686,321 @@ class TemplateFillService: if remaining > 100: doc_context = doc_context[:remaining] + f"\n...(内容被截断)" contexts.append(doc_context) - logger.warning(f"上下文被截断: {doc.filename}, 总长度: {total_length + len(doc_context)}") break result = "\n\n".join(contexts) if contexts else "(源文档内容为空)" logger.info(f"最终上下文长度: {len(result)}") return result - async def analyze_txt_with_ai(self, content: str, filename: str = "") -> Dict[str, Any]: - """ - 使用 AI 分析 TXT 文本内容,提取结构化数据 + def _find_best_matching_column(self, headers: List, field_name: str) -> Optional[int]: + """查找最佳匹配的列索引""" + field_lower = field_name.lower().strip() + field_keywords = set(field_lower.replace(" ", "").split()) - Args: - content: 原始文本内容 - filename: 文件名(用于日志) + best_match_idx = None + best_match_score = 0 - Returns: - 结构化数据,包含: - - key_value_pairs: 键值对列表 - - tables: 表格数据列表 - - numeric_data: 数值数据列表 - - text_summary: 文本摘要 - """ - if not content or len(content.strip()) < 10: - logger.warning(f"TXT 内容过短或为空,跳过 AI 分析: {filename}") - return {} + for idx, header in enumerate(headers): + header_str = str(header).strip() + header_lower = header_str.lower() - # 截断过长的文本,避免 token 超限 - max_chars = 15000 - truncated_content = content[:max_chars] if len(content) > max_chars else content + # 精确匹配 + if header_lower == field_lower: + return idx - system_prompt = """你是一个专业的数据提取专家。请分析提供的文本内容,提取其中包含的结构化信息。 + # 子字符串匹配 + if field_lower in header_lower or header_lower in field_lower: + score = max(len(field_lower), len(header_lower)) / min(len(field_lower) + 1, len(header_lower) + 1) + if score > best_match_score: + best_match_score = score + best_match_idx = idx + continue -请提取以下类型的数据: + # 关键词重叠匹配 + header_keywords = set(header_lower.replace(" ", "").split()) + overlap = field_keywords & header_keywords + if overlap and len(overlap) > 0: + score = len(overlap) / max(len(field_keywords), len(header_keywords), 1) + if score > best_match_score: + best_match_score = score + best_match_idx = idx -1. **键值对信息**:从文本中提取的名词-值对,如"姓名: 张三"、"年龄: 25"等 -2. **表格数据**:如果文本中包含表格或列表形式的数据,提取出来 -3. **数值数据**:包含数值、金额、百分比、统计数字等 -4. **关键描述**:文本的核心内容摘要 + if best_match_score >= 0.3: + return best_match_idx -请严格按照以下 JSON 格式输出,不要添加任何 Markdown 标记或解释: -{ - "key_value_pairs": [ - {"key": "键名1", "value": "值1"}, - {"key": "键名2", "value": "值2"} - ], - "tables": [ - { - "description": "表格描述", - "columns": ["列1", "列2"], - "rows": [["值1", "值2"], ["值3", "值4"]] - } - ], - "numeric_data": [ - {"name": "数据项名称", "value": 123.45, "unit": "单位"} - ], - "text_summary": "一段简洁的文本摘要,不超过200字" -}""" + return None - user_message = f"""请分析以下文本内容,提取结构化数据: + def _extract_values_from_structured_data(self, source_docs: List[SourceDocument], field_name: str) -> List[str]: + """从结构化数据或 AI 结构化分析结果中直接提取指定列的值""" + all_values = [] + logger.info(f"[_extract_values_from_structured_data] 开始提取字段: {field_name}, 文档数: {len(source_docs)}") -文件名:{filename} - -文本内容: -{truncated_content} - -请严格按 JSON 格式输出。""" - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message} - ] - - try: - logger.info(f"开始 AI 分析 TXT 文件: {filename}, 内容长度: {len(truncated_content)}") - response = await self.llm.chat( - messages=messages, - temperature=0.1, - max_tokens=2000 - ) - - ai_content = self.llm.extract_message_content(response) - logger.info(f"AI 返回内容长度: {len(ai_content)}") - - # 解析 JSON - import json - import re - - # 清理 markdown 格式 - cleaned = ai_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 >= 0: - brace_count = 0 - json_end = -1 - for i in range(json_start, len(cleaned)): - if cleaned[i] == '{': - brace_count += 1 - elif cleaned[i] == '}': - brace_count -= 1 - if brace_count == 0: - json_end = i + 1 + for doc in source_docs: + # 优先从 AI 结构化数据中提取(适用于 TXT 文件) + if doc.ai_structured_data: + ai_table = doc.ai_structured_data.get("table", {}) + columns = ai_table.get("columns", []) + rows = ai_table.get("rows", []) + if columns and rows: + target_idx = self._find_best_matching_column(columns, field_name) + if target_idx is not None: + values = [] + for row in rows: + if isinstance(row, list) and target_idx < len(row): + val = row[target_idx] + elif isinstance(row, dict): + val = row.get(columns[target_idx], "") + else: + val = "" + if val: + values.append(str(val).strip()) + if values: + all_values.extend(values) + logger.info(f"从 TXT AI 结构化数据提取到 {len(values)} 个值: {doc.filename}") break - if json_end > json_start: - json_str = cleaned[json_start:json_end] - result = json.loads(json_str) - logger.info(f"TXT AI 分析成功: {filename}, 提取到 {len(result.get('key_value_pairs', []))} 个键值对") - return result + # 从 structured_data 中提取 + structured = doc.structured_data + if not structured: + continue - logger.warning(f"无法从 AI 返回中解析 JSON: {filename}") - return {} + # 多 sheet 格式 + if structured.get("sheets"): + sheets = structured.get("sheets", {}) + for sheet_name, sheet_data in sheets.items(): + if isinstance(sheet_data, dict): + columns = sheet_data.get("columns", []) + rows = sheet_data.get("rows", []) + if rows and columns: + values = self._extract_column_values(rows, columns, field_name) + if values: + all_values.extend(values) + logger.info(f"从 sheet {sheet_name} 提取到 {len(values)} 个值") + return all_values - except json.JSONDecodeError as e: - logger.error(f"JSON 解析失败: {str(e)}, 文件: {filename}") - return {} - except Exception as e: - logger.error(f"AI 分析 TXT 失败: {str(e)}, 文件: {filename}", exc_info=True) - return {} + # Markdown 表格格式 + elif structured.get("headers") and structured.get("rows"): + headers = structured.get("headers", []) + rows = structured.get("rows", []) + values = self._extract_column_values(rows, headers, field_name) + if values: + all_values.extend(values) + logger.info(f"从 Markdown 文档提取到 {len(values)} 个值") + return all_values - def _format_structured_for_context(self, structured_data: Dict[str, Any], filename: str) -> str: - """ - 将结构化数据格式化为上下文文本 + # 单 sheet 格式 + elif structured.get("rows"): + columns = structured.get("columns", []) + rows = structured.get("rows", []) + values = self._extract_column_values(rows, columns, field_name) + if values: + all_values.extend(values) + logger.info(f"从文档 {doc.filename} 提取到 {len(values)} 个值") + return all_values - Args: - structured_data: AI 分析返回的结构化数据 - filename: 文件名 + return all_values - Returns: - 格式化的文本上下文 - """ - parts = [] + def _extract_column_values(self, rows: List, columns: List, field_name: str) -> List[str]: + """从 rows 和 columns 中提取指定列的值""" + if not rows or not columns: + return [] - # 添加标题 - parts.append(f"【文档: {filename} - AI 结构化分析结果】") + target_idx = self._find_best_matching_column(columns, field_name) + if target_idx is None: + logger.warning(f"未找到匹配列: {field_name}, 可用列: {columns}") + return [] - # 格式化键值对 - key_value_pairs = structured_data.get("key_value_pairs", []) - if key_value_pairs: - parts.append("\n## 关键信息:") - for kv in key_value_pairs[:20]: # 最多 20 个 - parts.append(f"- {kv.get('key', '')}: {kv.get('value', '')}") + target_col = columns[target_idx] + logger.info(f"列匹配成功: {field_name} -> {target_col} (索引: {target_idx})") - # 格式化表格数据 - tables = structured_data.get("tables", []) - if tables: - parts.append("\n## 表格数据:") - for i, table in enumerate(tables[:5]): # 最多 5 个表格 - desc = table.get("description", f"表格{i+1}") - columns = table.get("columns", []) - rows = table.get("rows", []) - if columns and rows: - parts.append(f"\n### {desc}") - parts.append("| " + " | ".join(str(c) for c in columns) + " |") - parts.append("| " + " | ".join(["---"] * len(columns)) + " |") - for row in rows[:10]: # 每个表格最多 10 行 - parts.append("| " + " | ".join(str(cell) for cell in row) + " |") + values = [] + for row in rows: + if isinstance(row, dict): + val = row.get(target_col, "") + elif isinstance(row, list) and target_idx < len(row): + val = row[target_idx] + else: + val = "" + if val is not None and str(val).strip(): + values.append(str(val).strip()) - # 格式化数值数据 - numeric_data = structured_data.get("numeric_data", []) - if numeric_data: - parts.append("\n## 数值数据:") - for num in numeric_data[:15]: # 最多 15 个 - name = num.get("name", "") - value = num.get("value", "") - unit = num.get("unit", "") - parts.append(f"- {name}: {value} {unit}") + return values - # 添加文本摘要 - text_summary = structured_data.get("text_summary", "") - if text_summary: - parts.append(f"\n## 内容摘要:\n{text_summary}") + def _extract_values_from_json(self, result) -> List[str]: + """从解析后的 JSON 对象/数组中提取值数组""" + if isinstance(result, dict): + 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 + if "value" in result: + val = str(result["value"]).strip() + if val: + return [val] + for key in result.keys(): + val = result[key] + if isinstance(val, list) and len(val) > 0: + 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 + elif isinstance(val, (str, int, float, bool)): + return [str(val).strip()] + elif isinstance(result, list): + vals = [str(v).strip() for v in result if v and str(v).strip()] + if vals: + return vals + return [] - return "\n".join(parts) + def _fix_json(self, json_text: str) -> str: + """尝试修复损坏的 JSON 字符串""" + import re + + if json_text.startswith('{'): + 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: + return json_text[:end_pos] + + fixed = re.sub(r',\s*([}\]])', r'\1', json_text) + fixed = fixed.strip() + if fixed and not fixed.endswith('}') and not fixed.endswith(']'): + if fixed.startswith('{') and not fixed.endswith('}'): + fixed = fixed + '}' + elif fixed.startswith('[') and not fixed.endswith(']'): + fixed = fixed + ']' + return fixed + + elif json_text.startswith('['): + 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: + return json_text[:end_pos] + + return "" + + def _extract_values_by_regex(self, text: str) -> List[str]: + """使用正则从文本中提取 values 数组""" + import re + + values_start = re.search(r'"values"\s*:\s*\[', text) + if values_start: + start_pos = values_start.end() + remaining = text[start_pos:] + values = re.findall(r'"([^"]+)"', remaining) + if values: + filtered = [v.strip() for v in values if v.strip() and len(v) > 1] + if filtered: + logger.info(f"正则提取到 {len(filtered)} 个值") + return filtered + + return [] + + def _extract_values_from_text(self, text: str, field_name: str) -> List[str]: + """从非 JSON 文本中提取字段值""" + import re + import json + + cleaned_text = text.strip().replace('```json', '').replace('```', '').strip() + + try: + parsed = json.loads(cleaned_text) + if isinstance(parsed, dict): + if "values" in parsed and isinstance(parsed["values"], list): + return [str(v).strip() for v in parsed["values"] if v and str(v).strip()] + for key in ["values", "value", "data", "result"]: + if key in parsed and isinstance(parsed[key], list): + return [str(v).strip() for v in parsed[key] if v and str(v).strip()] + elif key in parsed: + return [str(parsed[key]).strip()] + elif isinstance(parsed, list): + return [str(v).strip() for v in parsed if v and str(v).strip()] + except (json.JSONDecodeError, TypeError): + pass + + # 尝试用分号分割 + if ';' in text or ';' in text: + separator = ';' if ';' in text else ';' + parts = [p.strip() for p in text.split(separator) if p.strip() and len(p.strip()) < 500] + if parts: + return parts + + # 尝试正则匹配 + patterns = [ + rf'{re.escape(field_name)}[::]\s*(.+?)(?:\n|$)', + rf'"value"\s*:\s*"([^"]+)"', + ] + + for pattern in patterns: + match = re.search(pattern, text, re.DOTALL) + if match: + value = match.group(1).strip() + if value and len(value) < 1000: + return [value] + + content = text.strip()[:500] if text.strip() else "" + return [content] if content else [] async def get_template_fields_from_file( self, file_path: str, - file_type: str = "xlsx", - source_contents: List[dict] = None + file_type: str = "xlsx" ) -> List[TemplateField]: - """ - 从模板文件提取字段定义 - - Args: - file_path: 模板文件路径 - file_type: 文件类型 (xlsx/xls/docx) - source_contents: 源文档内容列表(用于 AI 生成表头) - - Returns: - 字段列表 - """ + """从模板文件提取字段定义""" fields = [] - if source_contents is None: - source_contents = [] try: if file_type in ["xlsx", "xls"]: - fields = await self._get_template_fields_from_excel(file_type, file_path) + fields = await self._get_template_fields_from_excel(file_path) elif file_type == "docx": fields = await self._get_template_fields_from_docx(file_path) - # 检查是否需要 AI 生成表头 - # 条件:没有字段 OR 所有字段都是自动命名的(如"字段1"、"列1"、"Unnamed"开头) - needs_ai_generation = ( - len(fields) == 0 or - all(self._is_auto_generated_field(f.name) for f in fields) - ) - - if needs_ai_generation: - logger.info(f"模板表头为空或自动生成,尝试 AI 生成表头... (fields={len(fields)}, source_docs={len(source_contents)})") - ai_fields = await self._generate_fields_with_ai(file_path, file_type, source_contents) - if ai_fields: - fields = ai_fields - logger.info(f"AI 生成表头成功: {len(fields)} 个字段") - except Exception as e: logger.error(f"提取模板字段失败: {str(e)}") return fields - def _is_auto_generated_field(self, name: str) -> bool: - """检查字段名是否是自动生成的(无效表头)""" - import re - if not name: - return True - name_str = str(name).strip() - # 匹配 "字段1", "列1", "Field1", "Column1" 等自动生成的名字 - # 或 "Unnamed: 0" 等 Excel 默认名字 - if name_str.startswith('Unnamed'): - return True - if re.match(r'^[列字段ColumnField]+\d+$', name_str, re.IGNORECASE): - return True - if name_str in ['0', '1', '2'] or name_str.startswith('0.') or name_str.startswith('1.'): - # 纯数字或类似 "0.1" 的列名 - return True - return False - - async def _get_template_fields_from_excel(self, file_type: str, file_path: str) -> List[TemplateField]: + async def _get_template_fields_from_excel(self, file_path: str) -> List[TemplateField]: """从 Excel 模板提取字段""" fields = [] try: import pandas as pd - # 尝试读取 Excel 文件 try: - # header=0 表示第一行是表头 df = pd.read_excel(file_path, header=0, nrows=5) except Exception as e: - logger.warning(f"pandas 读取 Excel 表头失败,尝试无表头模式: {e}") - # 如果失败,尝试不使用表头模式 + logger.warning(f"pandas 读取 Excel 表头失败: {e}") df = pd.read_excel(file_path, header=None, nrows=5) - # 如果没有表头,使用列索引作为列名 if df.shape[1] > 0: - # 检查第一行是否可以作为表头 first_row = df.iloc[0].tolist() if all(pd.notna(v) and str(v).strip() != '' for v in first_row): - # 第一行有内容,作为表头 df.columns = [str(v) if pd.notna(v) else f"列{i}" for i, v in enumerate(first_row)] - df = df.iloc[1:] # 移除表头行 + df = df.iloc[1:] else: - # 第一行不是有效表头,使用默认列名 df.columns = [f"列{i}" for i in range(df.shape[1])] - 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): - logger.warning(f"表头解析结果异常,重新解析: {df.columns}") - # 尝试读取整个文件获取列信息 df_full = pd.read_excel(file_path, header=None) if df_full.shape[1] > 0: - # 使用第一行作为列名 df = df_full df.columns = [str(v) if pd.notna(v) and str(v).strip() else f"列{i}" for i, v in enumerate(df.iloc[0])] df = df.iloc[1:] @@ -1376,12 +1039,10 @@ class TemplateFillService: for row_idx, row in enumerate(table.rows): 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 ["", "字段名", "名称", "项目"]: fields.append(TemplateField( cell=f"T{table_idx}R{row_idx}", @@ -1398,13 +1059,12 @@ class TemplateFillService: def _infer_field_type_from_hint(self, hint: str) -> str: """从提示词推断字段类型""" - hint_lower = hint.lower() - date_keywords = ["年", "月", "日", "日期", "时间", "出生"] if any(kw in hint for kw in date_keywords): return "date" number_keywords = ["数量", "金额", "人数", "面积", "增长", "比率", "%", "率", "总计", "合计"] + hint_lower = hint.lower() if any(kw in hint_lower for kw in number_keywords): return "number" @@ -1417,12 +1077,10 @@ class TemplateFillService: value_str = str(value) - # 检查日期模式 import re if re.search(r'\d{4}[年/-]\d{1,2}[月/-]\d{1,2}', value_str): return "date" - # 检查数值 try: float(value_str.replace(',', '').replace('%', '')) return "number" @@ -1439,863 +1097,6 @@ class TemplateFillService: col_idx = col_idx // 26 - 1 return result - def _extract_value_from_text(self, text: str, field_name: str) -> str: - """ - 从非 JSON 文本中提取字段值(单值版本) - - Args: - text: 原始文本 - field_name: 字段名称 - - Returns: - 提取的值 - """ - values = self._extract_values_from_text(text, field_name) - return values[0] if values else "" - - def _extract_values_from_structured_data(self, source_docs: List[SourceDocument], field_name: str) -> List[str]: - """ -<<<<<<< HEAD - 从结构化数据(Excel rows)或 AI 结构化分析结果中直接提取指定列的值 -======= - 从结构化数据(Excel rows 或 Markdown tables)中直接提取指定列的值 ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 - - 适用于有 rows 结构的文档数据,无需 LLM 即可提取 - - Args: - source_docs: 源文档列表 - field_name: 字段名称 - - Returns: - 值列表,如果无法提取则返回空列表 - """ - all_values = [] - logger.info(f"[_extract_values_from_structured_data] 开始提取字段: {field_name}") - logger.info(f" source_docs 数量: {len(source_docs)}") - -<<<<<<< HEAD - for doc in source_docs: - # 优先从 AI 结构化数据中提取(适用于 TXT 文件) - if doc.ai_structured_data: - ai_table = doc.ai_structured_data.get("table", {}) - columns = ai_table.get("columns", []) - rows = ai_table.get("rows", []) - if columns and rows: - values = self._extract_column_values(rows, columns, field_name) - if values: - all_values.extend(values) - logger.info(f"从 TXT AI 结构化数据提取到 {len(values)} 个值: {doc.filename}") - break - -======= - for doc_idx, doc in enumerate(source_docs): ->>>>>>> 5fca4eb094416fc1f64c83ea86df1cb7c3855453 - # 尝试从 structured_data 中提取 - structured = doc.structured_data - logger.info(f" 文档[{doc_idx}]: {doc.filename}, structured类型: {type(structured)}, 是否为空: {not bool(structured)}") - if structured: - logger.info(f" structured_data keys: {list(structured.keys())}") - - if not structured: - continue - - # 处理多 sheet 格式: {sheets: {sheet_name: {columns, rows}}} - if structured.get("sheets"): - sheets = structured.get("sheets", {}) - for sheet_name, sheet_data in sheets.items(): - if isinstance(sheet_data, dict): - columns = sheet_data.get("columns", []) - rows = sheet_data.get("rows", []) - values = self._extract_column_values(rows, columns, field_name) - if values: - all_values.extend(values) - logger.info(f"从 sheet {sheet_name} 提取到 {len(values)} 个值") - break # 只用第一个匹配的 sheet - if all_values: - break - - # 处理 Markdown 表格格式: {headers: [...], rows: [...], ...} - elif structured.get("headers") and structured.get("rows"): - headers = structured.get("headers", []) - rows = structured.get("rows", []) - values = self._extract_values_from_markdown_table(headers, rows, field_name) - if values: - all_values.extend(values) - logger.info(f"从 Markdown 文档 {doc.filename} 提取到 {len(values)} 个值") - break - - # 处理 MongoDB 存储的 tables 格式: {tables: [{headers, rows, ...}, ...]} - elif structured.get("tables") and isinstance(structured.get("tables"), list): - tables = structured.get("tables", []) - logger.info(f" 检测到 tables 格式,共 {len(tables)} 个表") - for table_idx, table in enumerate(tables): - if isinstance(table, dict): - headers = table.get("headers", []) - rows = table.get("rows", []) - logger.info(f" 表格[{table_idx}]: headers={headers[:3]}..., rows数量={len(rows)}") - values = self._extract_values_from_markdown_table(headers, rows, field_name) - if values: - all_values.extend(values) - logger.info(f"从表格[{table_idx}] 提取到 {len(values)} 个值") - break - if all_values: - break - - # 处理单 sheet 格式: {columns: [...], rows: [...]} - elif structured.get("rows"): - columns = structured.get("columns", []) - rows = structured.get("rows", []) - values = self._extract_column_values(rows, columns, field_name) - if values: - all_values.extend(values) - logger.info(f"从文档 {doc.filename} 提取到 {len(values)} 个值") - break - - # 处理 Markdown 表格格式: {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 - if all_values: - break - - return all_values - - def _extract_values_from_markdown_table(self, headers: List, rows: List, field_name: str) -> List[str]: - """ - 从 Markdown 表格中提取指定列的值 - - Markdown 表格格式: - - headers: ["col1", "col2", ...] - - rows: [["val1", "val2", ...], ...] - - Args: - headers: 表头列表 - rows: 数据行列表 - field_name: 要提取的字段名 - - Returns: - 值列表 - """ - if not rows or not headers: - logger.warning(f"Markdown 表格为空: headers={headers}, rows={len(rows) if rows else 0}") - return [] - - # 查找匹配的列索引 - 使用增强的匹配算法 - target_idx = self._find_best_matching_column(headers, field_name) - - if target_idx is None: - logger.warning(f"未找到匹配列: {field_name}, 可用表头: {headers}") - return [] - - logger.info(f"列匹配成功: {field_name} -> {headers[target_idx]} (索引: {target_idx})") - - values = [] - for row in rows: - if isinstance(row, list) and target_idx < len(row): - val = row[target_idx] - else: - val = "" - values.append(self._format_value(val)) - - return values - - def _find_best_matching_column(self, headers: List, field_name: str) -> Optional[int]: - """ - 查找最佳匹配的列索引 - - 使用多层匹配策略: - 1. 精确匹配(忽略大小写) - 2. 子字符串匹配(字段名在表头中,或表头在字段名中) - 3. 关键词重叠匹配(中文字符串分割后比对) - - Args: - headers: 表头列表 - field_name: 要匹配的字段名 - - Returns: - 匹配的列索引,找不到返回 None - """ - field_lower = field_name.lower().strip() - field_keywords = set(field_lower.replace(" ", "").split()) - - best_match_idx = None - best_match_score = 0 - - for idx, header in enumerate(headers): - header_str = str(header).strip() - header_lower = header_str.lower() - - # 策略1: 精确匹配(忽略大小写) - if header_lower == field_lower: - return idx - - # 策略2: 子字符串匹配 - if field_lower in header_lower or header_lower in field_lower: - # 计算匹配分数(较长匹配更优先) - score = max(len(field_lower), len(header_lower)) / min(len(field_lower) + 1, len(header_lower) + 1) - if score > best_match_score: - best_match_score = score - best_match_idx = idx - continue - - # 策略3: 关键词重叠匹配(适用于中文) - header_keywords = set(header_lower.replace(" ", "").split()) - overlap = field_keywords & header_keywords - if overlap and len(overlap) > 0: - score = len(overlap) / max(len(field_keywords), len(header_keywords), 1) - if score > best_match_score: - best_match_score = score - best_match_idx = idx - - # 只有当匹配分数超过阈值时才返回 - if best_match_score >= 0.3: - logger.info(f"模糊匹配: {field_name} -> {headers[best_match_idx]} (分数: {best_match_score:.2f})") - return best_match_idx - - return None - - def _extract_column_values(self, rows: List, columns: List, field_name: str) -> List[str]: - """ - 从 rows 和 columns 中提取指定列的值 - - Args: - rows: 行数据列表 - columns: 列名列表 - field_name: 要提取的字段名 - - Returns: - 值列表 - """ - if not rows or not columns: - return [] - - # 使用增强的匹配算法查找最佳匹配的列索引 - target_idx = self._find_best_matching_column(columns, field_name) - - if target_idx is None: - logger.warning(f"未找到匹配列: {field_name}, 可用列: {columns}") - return [] - - target_col = columns[target_idx] - logger.info(f"列匹配成功: {field_name} -> {target_col} (索引: {target_idx})") - - values = [] - for row in rows: - if isinstance(row, dict): - val = row.get(target_col, "") - elif isinstance(row, list) and target_idx < len(row): - val = row[target_idx] - else: - val = "" - values.append(self._format_value(val)) - - return values - - def _format_value(self, val: Any) -> str: - """ - 格式化值为字符串,保持原始格式 - - - 如果是浮点数但实际上等于整数,返回整数格式(如 3.0 -> "3") - - 如果是浮点数且有小数部分,保留小数(如 3.5 -> "3.5") - - 如果是整数,直接返回(如 3 -> "3") - - 其他类型直接转为字符串 - - Args: - val: 原始值 - - Returns: - 格式化后的字符串 - """ - if val is None: - return "" - - # 如果已经是字符串 - if isinstance(val, str): - return val.strip() - - # 如果是布尔值 - if isinstance(val, bool): - return "true" if val else "false" - - # 如果是数字 - if isinstance(val, (int, float)): - # 检查是否是浮点数但等于整数 - if isinstance(val, float): - # 检查是否是小数部分为0 - if val == int(val): - return str(int(val)) - else: - # 去除尾部多余的0,但保留必要的小数位 - formatted = f"{val:.10f}".rstrip('0').rstrip('.') - return formatted - else: - return str(val) - - return str(val) - - def _extract_values_from_json(self, result) -> List[str]: - """ - 从解析后的 JSON 对象/数组中提取值数组 - - Args: - result: json.loads() 返回的对象 - - Returns: - 值列表 - """ - if isinstance(result, dict): - # 优先找 values 数组 - if "values" in result and isinstance(result["values"], list): - vals = [self._format_value(v).strip() for v in result["values"] if self._format_value(v).strip()] - if vals: - return vals - # 尝试找 value 字段 - if "value" in result: - val = self._format_value(result["value"]).strip() - if val: - return [val] - # 尝试找任何数组类型的键 - for key in result.keys(): - val = result[key] - if isinstance(val, list) and len(val) > 0: - if all(isinstance(v, (str, int, float, bool)) or v is None for v in val): - vals = [self._format_value(v).strip() for v in val if v is not None and self._format_value(v).strip()] - if vals: - return vals - elif isinstance(val, (str, int, float, bool)): - return [self._format_value(val).strip()] - elif isinstance(result, list): - vals = [self._format_value(v).strip() for v in result if v is not None and self._format_value(v).strip()] - if vals: - return vals - return [] - - def _fix_json(self, json_text: str) -> str: - """ - 尝试修复损坏的 JSON 字符串 - - Args: - json_text: 原始 JSON 文本 - - Returns: - 修复后的 JSON 文本,如果无法修复则返回空字符串 - """ - import re - - # 如果以 { 开头,尝试找到配对的 } - if json_text.startswith('{'): - # 统计括号深度 - 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] - logger.info(f"修复 JSON (配对括号): {fixed[:200]}") - return fixed - - # 如果找不到配对,尝试移除 trailing comma 和其他问题 - # 移除末尾多余的逗号 - fixed = re.sub(r',\s*([}\]])', r'\1', json_text) - # 确保以 } 结尾 - fixed = fixed.strip() - if fixed and not fixed.endswith('}') and not fixed.endswith(']'): - # 尝试补全 - if fixed.startswith('{') and not fixed.endswith('}'): - fixed = fixed + '}' - elif fixed.startswith('[') and not fixed.endswith(']'): - fixed = fixed + ']' - logger.info(f"修复 JSON (正则): {fixed[:200]}") - return fixed - - # 如果以 [ 开头 - elif json_text.startswith('['): - 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] - logger.info(f"修复 JSON (数组配对): {fixed[:200]}") - return fixed - - return "" - - def _extract_values_by_regex(self, text: str) -> List[str]: - """ - 使用正则从损坏/不完整的 JSON 文本中提取 values 数组 - - 即使 JSON 被截断,只要能看到 "values": [...] 就能提取 - - Args: - text: 原始文本 - - Returns: - 值列表 - """ - import re - - # 方法1: 查找 "values": [ 开始的位置 - values_start = re.search(r'"values"\s*:\s*\[', text) - if values_start: - # 从 [ 之后开始提取内容 - start_pos = values_start.end() - remaining = text[start_pos:] - - # 提取所有被双引号包裹的字符串值 - # 使用简单正则:匹配 "..." 捕获引号内的内容 - values = re.findall(r'"([^"]+)"', remaining) - - if values: - # 过滤掉空字符串和很短的(可能是键名) - filtered = [v.strip() for v in values if v.strip() and len(v) > 1] - if filtered: - logger.info(f"正则提取到 {len(filtered)} 个值: {filtered[:3]}") - return filtered - - # 方法2: 备选 - 直接查找所有 : "value" 格式的值 - all_strings = re.findall(r':\s*"([^"]{1,200})"', text) - if all_strings: - filtered = [s for s in all_strings if s and len(s) < 500] - if filtered: - logger.info(f"备选正则提取到 {len(filtered)} 个值: {filtered[:3]}") - return filtered - - return [] - - def _extract_values_from_text(self, text: str, field_name: str) -> List[str]: - """ - 从非 JSON 文本中提取多个字段值 - - Args: - text: 原始文本 - field_name: 字段名称 - - Returns: - 提取的值列表 - """ - import re - import json - - # 先尝试解析整个文本为 JSON,检查是否包含嵌套的 values 数组 - cleaned_text = text.strip() - # 移除可能的 markdown 代码块标记 - cleaned_text = cleaned_text.replace('```json', '').replace('```', '').strip() - - try: - # 尝试解析整个文本为 JSON - parsed = json.loads(cleaned_text) - if isinstance(parsed, dict): - # 如果是 {"values": [...]} 格式,提取 values - if "values" in parsed and isinstance(parsed["values"], list): - return [self._format_value(v).strip() for v in parsed["values"] if self._format_value(v).strip()] - # 如果是其他 dict 格式,尝试找 values 键 - for key in ["values", "value", "data", "result"]: - if key in parsed and isinstance(parsed[key], list): - return [self._format_value(v).strip() for v in parsed[key] if self._format_value(v).strip()] - elif key in parsed: - return [self._format_value(parsed[key]).strip()] - elif isinstance(parsed, list): - return [self._format_value(v).strip() for v in parsed if self._format_value(v).strip()] - except (json.JSONDecodeError, TypeError): - pass - - # 尝试匹配 JSON 数组格式 - array_match = re.search(r'\[[\s\S]*?\]', text) - if array_match: - try: - arr = json.loads(array_match.group()) - if isinstance(arr, list): - # 检查数组元素是否是 {"values": [...]} 结构 - if arr and isinstance(arr[0], dict) and "values" in arr[0]: - # 提取嵌套的 values - result = [] - for item in arr: - if isinstance(item, dict) and "values" in item and isinstance(item["values"], list): - result.extend([self._format_value(v).strip() for v in item["values"] if self._format_value(v).strip()]) - elif isinstance(item, dict): - result.append(str(item)) - else: - result.append(self._format_value(item)) - if result: - return result - return [self._format_value(v).strip() for v in arr if self._format_value(v).strip()] - except: - pass - - # 尝试用分号分割(如果文本中有分号分隔的多个值) - if ';' in text or ';' in text: - separator = ';' if ';' in text else ';' - parts = text.split(separator) - values = [] - for part in parts: - part = part.strip() - if part and len(part) < 500: - # 清理 Markdown 格式 - part = re.sub(r'^\*\*|\*\*$', '', part) - part = re.sub(r'^\*|\*$', '', part) - values.append(part.strip()) - if values: - return values - - # 尝试多种模式匹配 - patterns = [ - # "字段名: 值" 或 "字段名:值" 格式 - rf'{re.escape(field_name)}[::]\s*(.+?)(?:\n|$)', - # "值" 在引号中 - rf'"value"\s*:\s*"([^"]+)"', - # "值" 在单引号中 - rf"['\"]?value['\"]?\s*:\s*['\"]([^'\"]+)['\"]", - ] - - for pattern in patterns: - match = re.search(pattern, text, re.DOTALL) - if match: - value = match.group(1).strip() - # 清理 Markdown 格式 - value = re.sub(r'^\*\*|\*\*$', '', value) - value = re.sub(r'^\*|\*$', '', value) - value = value.strip() - if value and len(value) < 1000: - return [value] - - # 如果无法匹配,返回原始内容 - content = text.strip()[:500] if text.strip() else "" - return [content] if content else [] - - async def _analyze_unstructured_docs_for_fields( - self, - source_docs: List[SourceDocument], - field: TemplateField, - user_hint: Optional[str] = None - ) -> Optional[FillResult]: - """ - 对非结构化文档进行 AI 分析,尝试提取结构化数据 - - 适用于 Markdown 等没有表格格式的文档,通过 AI 分析提取结构化信息 - - Args: - source_docs: 源文档列表 - field: 字段定义 - user_hint: 用户提示 - - Returns: - FillResult 如果提取成功,否则返回 None - """ - # 找出非结构化的 Markdown/TXT 文档(没有表格的) - unstructured_docs = [] - for doc in source_docs: - if doc.doc_type in ["md", "txt", "markdown"]: - # 检查是否有表格 - has_tables = ( - doc.structured_data and - doc.structured_data.get("tables") and - len(doc.structured_data.get("tables", [])) > 0 - ) - if not has_tables: - unstructured_docs.append(doc) - - if not unstructured_docs: - return None - - logger.info(f"发现 {len(unstructured_docs)} 个非结构化文档,尝试 AI 分析...") - - # 对每个非结构化文档进行 AI 分析 - for doc in unstructured_docs: - try: - # 使用 markdown_ai_service 的 statistics 分析类型 - # 这种类型专门用于政府统计公报等包含数据的文档 - hint_text = field.hint if field.hint else f"请提取{field.name}的信息" - if user_hint: - hint_text = f"{user_hint}。{hint_text}" - - # 构建针对字段提取的提示词 - prompt = f"""你是一个专业的数据提取专家。请从以下文档内容中提取与"{field.name}"完全匹配的数据。 - -【重要】字段名: "{field.name}" -【重要】字段提示: {hint_text} - -请严格按照以下步骤操作: -1. 在文档中搜索与"{field.name}"完全相同或高度相关的关键词 -2. 找到后,提取该关键词后的数值(注意:只要数值,不要单位) -3. 如果是表格中的数据,直接提取该单元格的数值 -4. 如果是段落描述,在关键词附近找数值 - -【重要】返回值规则: -- 只返回纯数值,不要单位(如 "4.9" 而不是 "4.9万亿元") -- 如果原文是"4.9万亿元",返回 "4.9" -- 如果原文是"144000万册",返回 "144000" -- 如果是百分比如"增长7.7%",返回 "7.7" -- 如果没有找到完全匹配的数据,返回空数组 - -文档内容: -{doc.content[:10000] if doc.content else ""} - -请用严格的 JSON 格式返回: -{{ - "values": ["值1", "值2", ...], // 只填数值,不要单位 - "source": "数据来源说明", - "confidence": 0.0到1.0之间的置信度 -}} - -示例: -- 如果字段是"图书馆总藏量(万册)"且文档说"图书总藏量14.4亿册",返回 values: ["144000"] -- 如果字段是"国内旅游收入(亿元)"且文档说"国内旅游收入4.9万亿元",返回 values: ["49000"]""" - - messages = [ - {"role": "system", "content": "你是一个专业的数据提取助手,擅长从政府统计公报等文档中提取数据。请严格按JSON格式输出。"}, - {"role": "user", "content": prompt} - ] - - response = await self.llm.chat( - messages=messages, - temperature=0.1, - max_tokens=4000 - ) - - content = self.llm.extract_message_content(response) - logger.info(f"AI 分析返回: {content[:500]}") - - # 解析 JSON - import 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 == '{' or c == '[': - json_start = i - break - - if json_start == -1: - continue - - json_text = cleaned[json_start:] - try: - result = json.loads(json_text) - values = self._extract_values_from_json(result) - if values: - return FillResult( - field=field.name, - values=values, - value=values[0] if values else "", - source=f"AI分析: {doc.filename}", - confidence=result.get("confidence", 0.8) - ) - except json.JSONDecodeError: - # 尝试修复 JSON - fixed = self._fix_json(json_text) - if fixed: - try: - result = json.loads(fixed) - values = self._extract_values_from_json(result) - if values: - return FillResult( - field=field.name, - values=values, - value=values[0] if values else "", - source=f"AI分析: {doc.filename}", - confidence=result.get("confidence", 0.8) - ) - except json.JSONDecodeError: - pass - - except Exception as e: - logger.warning(f"AI 分析文档 {doc.filename} 失败: {str(e)}") - continue - - return None - - async def _generate_fields_with_ai( - self, - file_path: str, - file_type: str, - source_contents: List[dict] = None - ) -> Optional[List[TemplateField]]: - """ - 使用 AI 为空表生成表头字段 - - 当模板文件为空或没有表头时,调用 AI 分析并生成合适的字段名 - - Args: - file_path: 模板文件路径 - file_type: 文件类型 - - Returns: - 生成的字段列表,如果失败返回 None - """ - try: - import pandas as pd - - # 读取 Excel 内容检查是否为空 - content_sample = "" - if file_type in ["xlsx", "xls"]: - df = pd.read_excel(file_path, header=None) - if df.shape[0] == 0 or df.shape[1] == 0: - logger.info("Excel 表格为空") - # 即使 Excel 为空,如果有源文档,仍然尝试使用 AI 生成表头 - if not source_contents: - logger.info("Excel 为空且没有源文档,使用默认字段名") - return [TemplateField( - cell=self._column_to_cell(i), - name=f"字段{i+1}", - field_type="text", - required=False, - hint="请填写此字段" - ) for i in range(5)] - # 有源文档,继续调用 AI 生成表头 - logger.info("Excel 为空但有源文档,使用源文档内容生成表头...") - else: - # 表格有数据但没有表头 - if df.shape[1] > 0: - # 读取第一行作为参考,看是否为空 - first_row = df.iloc[0].tolist() if len(df) > 0 else [] - if not any(pd.notna(v) and str(v).strip() != '' for v in first_row): - # 第一行为空,AI 生成表头 - content_sample = df.iloc[:10].to_string() if len(df) >= 10 else df.to_string() - else: - content_sample = df.to_string() - else: - content_sample = "" - - # 调用 AI 生成表头 - # 根据源文档内容生成表头 - source_info = "" - logger.info(f"[DEBUG] _generate_fields_with_ai received source_contents: {len(source_contents) if source_contents else 0} items") - if source_contents: - for sc in source_contents: - logger.info(f"[DEBUG] source doc: filename={sc.get('filename')}, content_len={len(sc.get('content', ''))}, titles={len(sc.get('titles', []))}, tables_count={sc.get('tables_count', 0)}, has_tables_summary={bool(sc.get('tables_summary'))}") - source_info = "\n\n【源文档内容摘要】(根据以下文档内容生成表头):\n" - for idx, src in enumerate(source_contents[:5]): # 最多5个源文档 - filename = src.get("filename", f"文档{idx+1}") - doc_type = src.get("doc_type", "unknown") - content = src.get("content", "")[:3000] # 限制内容长度 - titles = src.get("titles", [])[:10] # 最多10个标题 - tables_count = src.get("tables_count", 0) - tables_summary = src.get("tables_summary", "") - - source_info += f"\n--- 文档 {idx+1}: {filename} ({doc_type}) ---\n" - # 处理 titles(可能是字符串列表或字典列表) - if titles: - title_texts = [] - for t in titles[:5]: - if isinstance(t, dict): - title_texts.append(t.get('text', '')) - else: - title_texts.append(str(t)) - if title_texts: - source_info += f"【章节标题】: {', '.join(title_texts)}\n" - if tables_count > 0: - source_info += f"【包含表格数】: {tables_count}\n" - if tables_summary: - source_info += f"{tables_summary}\n" - elif content: - source_info += f"【内容预览】: {content[:1500]}...\n" - - prompt = f"""你是一个专业的表格设计助手。请根据源文档内容生成合适的表格表头字段。 - -任务:用户有一些源文档(包含表格数据),需要填写到空白表格模板中。源文档中的表格如下: - -{source_info} - -【重要要求】 -1. 请仔细阅读上面的源文档表格,找出所有不同的列名(如"产品名称"、"1995年产量"、"按资产总额计算(%)"等) -2. 直接使用这些实际的列名作为表头字段名,不要生成新的或同义词 -3. 如果一个源文档有多个表格,请为每个表格选择合适的列名 -4. 生成3-8个表头字段,优先选择数据量大的表格的列 - -请严格按照以下 JSON 格式输出(只需输出 JSON,不要其他内容): -{{ - "fields": [ - {{"name": "实际列名1", "hint": "对该列的说明"}}, - {{"name": "实际列名2", "hint": "对该列的说明"}} - ] -}} -""" - messages = [ - {"role": "system", "content": "你是一个专业的表格设计助手。请严格按JSON格式输出。"}, - {"role": "user", "content": prompt} - ] - - response = await self.llm.chat( - messages=messages, - temperature=0.3, - max_tokens=2000 - ) - - content = self.llm.extract_message_content(response) - logger.info(f"AI 生成表头返回: {content[:500]}") - - # 解析 JSON - import 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:] - result = json.loads(json_text) - - if result and "fields" in result: - fields = [] - for idx, f in enumerate(result["fields"]): - fields.append(TemplateField( - cell=self._column_to_cell(idx), - name=f.get("name", f"字段{idx+1}"), - field_type="text", - required=False, - hint=f.get("hint", "") - )) - return fields - - except Exception as e: - logger.error(f"AI 生成表头失败: {str(e)}") - - return None - # ==================== 全局单例 ==================== From 902c28166bc4606ccd9c5423987b9d5539f6e30c Mon Sep 17 00:00:00 2001 From: tl <1655185665@qq.com> Date: Tue, 14 Apr 2026 15:18:50 +0800 Subject: [PATCH 4/4] tl --- backend/app/services/template_fill_service.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/app/services/template_fill_service.py b/backend/app/services/template_fill_service.py index 1d0fb57..8ed4509 100644 --- a/backend/app/services/template_fill_service.py +++ b/backend/app/services/template_fill_service.py @@ -61,7 +61,10 @@ class TemplateFillService: template_fields: List[TemplateField], source_doc_ids: Optional[List[str]] = None, source_file_paths: Optional[List[str]] = None, - user_hint: Optional[str] = None + user_hint: Optional[str] = None, + template_id: Optional[str] = None, + template_file_type: Optional[str] = "xlsx", + task_id: Optional[str] = None ) -> Dict[str, Any]: """ 填写表格模板 @@ -71,6 +74,9 @@ class TemplateFillService: source_doc_ids: 源文档 MongoDB ID 列表 source_file_paths: 源文档文件路径列表 user_hint: 用户提示(如"请从合同文档中提取") + template_id: 模板ID(用于日志) + template_file_type: 模板文件类型 + task_id: 任务ID(用于日志) Returns: 填写结果 @@ -78,7 +84,7 @@ class TemplateFillService: filled_data = {} fill_details = [] - logger.info(f"开始填表: {len(template_fields)} 个字段, {len(source_doc_ids or [])} 个源文档, {len(source_file_paths or [])} 个文件路径") + logger.info(f"开始填表: task_id={task_id}, template_id={template_id}, {len(template_fields)} 个字段, {len(source_doc_ids or [])} 个源文档, {len(source_file_paths or [])} 个文件路径") # 1. 加载源文档内容 source_docs = await self._load_source_documents(source_doc_ids, source_file_paths) @@ -962,9 +968,20 @@ class TemplateFillService: async def get_template_fields_from_file( self, file_path: str, - file_type: str = "xlsx" + file_type: str = "xlsx", + source_contents: Optional[List[Dict[str, Any]]] = None ) -> List[TemplateField]: - """从模板文件提取字段定义""" + """ + 从模板文件提取字段定义 + + Args: + file_path: 模板文件路径 + file_type: 文件类型 (xlsx/xls/docx) + source_contents: 可选的源文档内容列表,用于 AI 辅助生成表头 + + Returns: + 字段列表 + """ fields = [] try: