From 7f67fa89de3894b23425f1554d79705937a6bf4d Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Thu, 9 Apr 2026 22:15:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0AI=E7=94=9F=E6=88=90=E8=A1=A8?= =?UTF-8?q?=E5=A4=B4=E5=8A=9F=E8=83=BD=E5=B9=B6=E9=87=8D=E6=9E=84=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:实现AI生成表头逻辑,当模板为空或字段为自动生成时调用AI分析并生成合适字段 - 后端:添加_is_auto_generated_field方法识别自动生成的无效表头字段 - 后端:修改_get_template_fields_from_excel方法支持文件类型参数 - 前端:创建TemplateFillContext提供全局状态管理 - 前端:将TemplateFill页面状态迁移到Context中统一管理 - 前端:移除页面内重复的状态定义和方法实现 --- backend/app/services/template_fill_service.py | 155 +++++++++++++++++- frontend/src/App.tsx | 7 +- frontend/src/context/TemplateFillContext.tsx | 114 +++++++++++++ frontend/src/pages/TemplateFill.tsx | 89 ++-------- 4 files changed, 288 insertions(+), 77 deletions(-) create mode 100644 frontend/src/context/TemplateFillContext.tsx diff --git a/backend/app/services/template_fill_service.py b/backend/app/services/template_fill_service.py index dfea7f8..e744d09 100644 --- a/backend/app/services/template_fill_service.py +++ b/backend/app/services/template_fill_service.py @@ -545,16 +545,47 @@ class TemplateFillService: try: if file_type in ["xlsx", "xls"]: - fields = await self._get_template_fields_from_excel(file_path) + fields = await self._get_template_fields_from_excel(file_type, 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)})") + ai_fields = await self._generate_fields_with_ai(file_path, file_type) + if ai_fields: + fields = ai_fields + logger.info(f"AI 生成表头成功: {len(fields)} 个字段") + except Exception as e: logger.error(f"提取模板字段失败: {str(e)}") return fields - async def _get_template_fields_from_excel(self, file_path: str) -> List[TemplateField]: + 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]: """从 Excel 模板提取字段""" fields = [] @@ -1191,6 +1222,126 @@ class TemplateFillService: return None + async def _generate_fields_with_ai( + self, + file_path: str, + file_type: str + ) -> Optional[List[TemplateField]]: + """ + 使用 AI 为空表生成表头字段 + + 当模板文件为空或没有表头时,调用 AI 分析并生成合适的字段名 + + Args: + file_path: 模板文件路径 + file_type: 文件类型 + + Returns: + 生成的字段列表,如果失败返回 None + """ + try: + import pandas as pd + + # 读取 Excel 内容检查是否为空 + 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 表格为空") + # 生成默认字段 + return [TemplateField( + cell=self._column_to_cell(i), + name=f"字段{i+1}", + field_type="text", + required=False, + hint="请填写此字段" + ) for i in range(5)] + + # 表格有数据但没有表头 + 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 生成表头 + prompt = f"""你是一个专业的表格设计助手。请为以下空白表格生成合适的表头字段。 + +表格内容预览: +{content_sample[:2000] if content_sample else "空白表格"} + +请生成5-10个简洁的表头字段名,这些字段应该: +1. 简洁明了,易于理解 +2. 适合作为表格列标题 +3. 之间有明显的区分度 + +请严格按照以下 JSON 格式输出(只需输出 JSON,不要其他内容): +{{ + "fields": [ + {{"name": "字段名1", "hint": "字段说明提示1"}}, + {{"name": "字段名2", "hint": "字段说明提示2"}} + ] +}} +""" + 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 + # ==================== 全局单例 ==================== diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 877c55f..e764335 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,16 @@ import { RouterProvider } from 'react-router-dom'; import { AuthProvider } from '@/context/AuthContext'; +import { TemplateFillProvider } from '@/context/TemplateFillContext'; import { router } from '@/routes'; import { Toaster } from 'sonner'; function App() { return ( - - + + + + ); } diff --git a/frontend/src/context/TemplateFillContext.tsx b/frontend/src/context/TemplateFillContext.tsx new file mode 100644 index 0000000..76ba073 --- /dev/null +++ b/frontend/src/context/TemplateFillContext.tsx @@ -0,0 +1,114 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +type SourceFile = { + file: File; + preview?: string; +}; + +type TemplateField = { + cell: string; + name: string; + field_type: string; + required: boolean; + hint?: string; +}; + +type Step = 'upload' | 'filling' | 'preview'; + +interface TemplateFillState { + step: Step; + templateFile: File | null; + templateFields: TemplateField[]; + sourceFiles: SourceFile[]; + sourceFilePaths: string[]; + templateId: string; + filledResult: any; + setStep: (step: Step) => void; + setTemplateFile: (file: File | null) => void; + setTemplateFields: (fields: TemplateField[]) => void; + setSourceFiles: (files: SourceFile[]) => void; + addSourceFiles: (files: SourceFile[]) => void; + removeSourceFile: (index: number) => void; + setSourceFilePaths: (paths: string[]) => void; + setTemplateId: (id: string) => void; + setFilledResult: (result: any) => void; + reset: () => void; +} + +const initialState = { + step: 'upload' as Step, + templateFile: null, + templateFields: [], + sourceFiles: [], + sourceFilePaths: [], + templateId: '', + filledResult: null, + setStep: () => {}, + setTemplateFile: () => {}, + setTemplateFields: () => {}, + setSourceFiles: () => {}, + addSourceFiles: () => {}, + removeSourceFile: () => {}, + setSourceFilePaths: () => {}, + setTemplateId: () => {}, + setFilledResult: () => {}, + reset: () => {}, +}; + +const TemplateFillContext = createContext(initialState); + +export const TemplateFillProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [step, setStep] = useState('upload'); + const [templateFile, setTemplateFile] = useState(null); + const [templateFields, setTemplateFields] = useState([]); + const [sourceFiles, setSourceFiles] = useState([]); + const [sourceFilePaths, setSourceFilePaths] = useState([]); + const [templateId, setTemplateId] = useState(''); + const [filledResult, setFilledResult] = useState(null); + + const addSourceFiles = (files: SourceFile[]) => { + setSourceFiles(prev => [...prev, ...files]); + }; + + const removeSourceFile = (index: number) => { + setSourceFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const reset = () => { + setStep('upload'); + setTemplateFile(null); + setTemplateFields([]); + setSourceFiles([]); + setSourceFilePaths([]); + setTemplateId(''); + setFilledResult(null); + }; + + return ( + + {children} + + ); +}; + +export const useTemplateFill = () => useContext(TemplateFillContext); diff --git a/frontend/src/pages/TemplateFill.tsx b/frontend/src/pages/TemplateFill.tsx index 1fa7c99..d3e57c9 100644 --- a/frontend/src/pages/TemplateFill.tsx +++ b/frontend/src/pages/TemplateFill.tsx @@ -37,6 +37,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { ScrollArea } from '@/components/ui/scroll-area'; +import { useTemplateFill } from '@/context/TemplateFillContext'; type DocumentItem = { doc_id: string; @@ -52,29 +53,19 @@ type DocumentItem = { }; }; -type SourceFile = { - file: File; - preview?: string; -}; - -type TemplateField = { - cell: string; - name: string; - field_type: string; - required: boolean; - hint?: string; -}; - const TemplateFill: React.FC = () => { - const [step, setStep] = useState<'upload' | 'filling' | 'preview'>('upload'); - const [templateFile, setTemplateFile] = useState(null); - const [templateFields, setTemplateFields] = useState([]); - const [sourceFiles, setSourceFiles] = useState([]); - const [sourceFilePaths, setSourceFilePaths] = useState([]); - const [templateId, setTemplateId] = useState(''); + const { + step, setStep, + templateFile, setTemplateFile, + templateFields, setTemplateFields, + sourceFiles, setSourceFiles, addSourceFiles, removeSourceFile, + sourceFilePaths, setSourceFilePaths, + templateId, setTemplateId, + filledResult, setFilledResult, + reset + } = useTemplateFill(); + const [loading, setLoading] = useState(false); - const [filling, setFilling] = useState(false); - const [filledResult, setFilledResult] = useState(null); const [previewDoc, setPreviewDoc] = useState<{ name: string; content: string } | null>(null); const [previewOpen, setPreviewOpen] = useState(false); @@ -103,8 +94,8 @@ const TemplateFill: React.FC = () => { file: f, preview: f.type.startsWith('text/') || f.name.endsWith('.md') ? undefined : undefined })); - setSourceFiles(prev => [...prev, ...newFiles]); - }, []); + addSourceFiles(newFiles); + }, [addSourceFiles]); const { getRootProps: getSourceProps, getInputProps: getSourceInputProps, isDragActive: isSourceDragActive } = useDropzone({ onDrop: onSourceDrop, @@ -118,10 +109,6 @@ const TemplateFill: React.FC = () => { multiple: true }); - const removeSourceFile = (index: number) => { - setSourceFiles(prev => prev.filter((_, i) => i !== index)); - }; - const handleJointUploadAndFill = async () => { if (!templateFile) { toast.error('请先上传模板文件'); @@ -164,40 +151,6 @@ const TemplateFill: React.FC = () => { } }; - // 传统方式:先上传源文档再填表(兼容已有文档库的场景) - const handleFillWithExistingDocs = async (selectedDocIds: string[]) => { - if (!templateFile || selectedDocIds.length === 0) { - toast.error('请选择数据源文档'); - return; - } - - setLoading(true); - setStep('filling'); - - try { - // 先上传模板获取template_id - const uploadResult = await backendApi.uploadTemplate(templateFile); - - const fillResult = await backendApi.fillTemplate( - uploadResult.template_id, - uploadResult.fields || [], - selectedDocIds, - [], - '请从以下文档中提取相关信息填写表格' - ); - - setTemplateFields(uploadResult.fields || []); - setTemplateId(uploadResult.template_id); - setFilledResult(fillResult); - setStep('preview'); - toast.success('表格填写完成'); - } catch (err: any) { - toast.error('填表失败: ' + (err.message || '未知错误')); - } finally { - setLoading(false); - } - }; - const handleExport = async () => { if (!templateFile || !filledResult) return; @@ -219,16 +172,6 @@ const TemplateFill: React.FC = () => { } }; - const resetFlow = () => { - setStep('upload'); - setTemplateFile(null); - setTemplateFields([]); - setSourceFiles([]); - setSourceFilePaths([]); - setTemplateId(''); - setFilledResult(null); - }; - const getFileIcon = (filename: string) => { const ext = filename.split('.').pop()?.toLowerCase(); if (['xlsx', 'xls'].includes(ext || '')) { @@ -253,7 +196,7 @@ const TemplateFill: React.FC = () => {

{step !== 'upload' && ( - @@ -451,7 +394,7 @@ const TemplateFill: React.FC = () => { {/* Action Buttons */}
-