diff --git a/backend/requirements.txt b/backend/requirements.txt index 066f63b..0d968b5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,7 @@ cryptography>=42.0.0 python-docx>=1.1.0 openpyxl>=3.1.0 pandas>=2.1.0 +xlrd>=2.0.1 httpx>=0.27.0 pydantic>=2.5.0 pydantic-settings>=2.1.0 @@ -15,3 +16,4 @@ aiofiles>=23.2.0 sse-starlette>=2.0.0 minio>=7.2.0 # MinIO 对象存储 SDK alembic>=1.13.0 +pypdf>=5.0.0 diff --git a/backend/routers/generate.py b/backend/routers/generate.py index dc1a3b2..576a3b4 100644 --- a/backend/routers/generate.py +++ b/backend/routers/generate.py @@ -1,3 +1,4 @@ +import asyncio import json import os import uuid @@ -17,6 +18,7 @@ from models.paragraph import Paragraph from models.template import Template from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response from services.ai_service import call_ai +from services.file_summary import summarize_minio_files from services.generation_runtime import ( build_mock_content, generation_progress, @@ -48,6 +50,8 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge paragraph = await db.get(Paragraph, body.paragraph_id) if paragraph is None or paragraph.template_id != body.template_id: raise HTTPException(status_code=404, detail="段落不存在") + if body.prompt_text: + paragraph.prompt_text = body.prompt_text model = None if body.model_id: @@ -55,11 +59,12 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge elif paragraph.model_id: model = await db.get(AiModel, paragraph.model_id) + file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else [] if model is None or model.status != "enabled": content = build_mock_content(paragraph) message = "当前未找到可用模型,返回本地模拟生成结果。" else: - result = await call_ai(paragraph, model) + result = await call_ai(paragraph, model, file_summaries) content = result.content message = f"已通过模型 {result.used_model} 生成。" return Response( @@ -67,6 +72,7 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge "paragraph_id": paragraph.id, "content": content, "message": message, + "file_summaries": file_summaries, } ) diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py index 5e612ac..2487010 100644 --- a/backend/services/ai_service.py +++ b/backend/services/ai_service.py @@ -43,7 +43,7 @@ def _ensure_json_content(text: str) -> dict: return {"content": [{"type": "text", "text": stripped}]} -def _build_prompt(paragraph: Paragraph) -> tuple[str, str]: +def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]: system_prompt = ( "你是一个企业文档撰写助手。" "请严格输出 JSON,不要输出 JSON 之外的说明。" @@ -55,6 +55,11 @@ def _build_prompt(paragraph: Paragraph) -> tuple[str, str]: user_parts.append(f"模板上下文:{paragraph.content}") if paragraph.need_prompt and paragraph.prompt_text: user_parts.append(f"附加要求:{paragraph.prompt_text}") + if file_summaries: + file_blocks = [] + for item in file_summaries: + file_blocks.append(f"文件名:{item['file_name']}\n文件摘要:{item['summary']}") + user_parts.append("参考文件内容:\n" + "\n\n".join(file_blocks)) user_parts.append(f"输出格式:{paragraph.output_format}") return system_prompt, "\n\n".join(user_parts) @@ -157,8 +162,8 @@ async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name) -async def call_ai(paragraph: Paragraph, model: AiModel) -> AiCallResult: - system_prompt, user_prompt = _build_prompt(paragraph) +async def call_ai(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None) -> AiCallResult: + system_prompt, user_prompt = _build_prompt(paragraph, file_summaries) if model.api_format == "anthropic": return await _call_anthropic(model, system_prompt, user_prompt) return await _call_openai_compatible(model, system_prompt, user_prompt) diff --git a/backend/services/file_summary.py b/backend/services/file_summary.py new file mode 100644 index 0000000..1086b87 --- /dev/null +++ b/backend/services/file_summary.py @@ -0,0 +1,88 @@ +import csv +import io +import json +from pathlib import Path + +import pandas as pd +from docx import Document + +from services.minio_client import download_object_bytes, split_bucket_path + +try: + from pypdf import PdfReader +except Exception: # pragma: no cover + PdfReader = None + + +def _decode_text(content: bytes) -> str: + for encoding in ("utf-8", "utf-8-sig", "gbk", "gb18030"): + try: + return content.decode(encoding) + except Exception: + continue + return content.decode("utf-8", errors="ignore") + + +def _summarize_docx(content: bytes) -> str: + doc = Document(io.BytesIO(content)) + texts = [paragraph.text.strip() for paragraph in doc.paragraphs if paragraph.text.strip()] + return "\n".join(texts[:40])[:4000] + + +def _summarize_csv(content: bytes) -> str: + text = _decode_text(content) + reader = csv.reader(io.StringIO(text)) + rows = list(reader[:20]) + return "\n".join([" | ".join(row) for row in rows])[:4000] + + +def _summarize_excel(content: bytes, suffix: str) -> str: + excel_buffer = io.BytesIO(content) + sheet_map = pd.read_excel(excel_buffer, sheet_name=None) if suffix == ".xlsx" else pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd") + parts: list[str] = [] + for sheet_name, dataframe in list(sheet_map.items())[:5]: + preview = dataframe.head(10).fillna("").astype(str) + parts.append(f"[工作表] {sheet_name}") + parts.append(preview.to_csv(index=False).strip()) + return "\n".join(parts)[:5000] + + +def _summarize_pdf(content: bytes) -> str: + if PdfReader is None: + return "当前环境未安装 PDF 文本解析依赖,无法提取 PDF 正文。" + reader = PdfReader(io.BytesIO(content)) + texts: list[str] = [] + for page in reader.pages[:10]: + texts.append((page.extract_text() or "").strip()) + return "\n".join(filter(None, texts))[:4000] + + +def summarize_file_bytes(file_name: str, content: bytes) -> str: + suffix = Path(file_name).suffix.lower() + if suffix in {".txt", ".md", ".json"}: + return _decode_text(content)[:4000] + if suffix == ".csv": + return _summarize_csv(content) + if suffix in {".xlsx", ".xls"}: + return _summarize_excel(content, suffix) + if suffix == ".docx": + return _summarize_docx(content) + if suffix == ".pdf": + return _summarize_pdf(content) + return f"暂不支持解析该文件内容:{file_name}" + + +def summarize_minio_files(file_paths: list[str]) -> list[dict]: + summaries: list[dict] = [] + for file_path in file_paths: + bucket, object_name = split_bucket_path(file_path) + content = download_object_bytes(bucket, object_name) + file_name = Path(object_name).name + summaries.append( + { + "file_name": file_name, + "file_path": file_path, + "summary": summarize_file_bytes(file_name, content), + } + ) + return summaries diff --git a/docs/tasks/task_detail_2026_07_02.md b/docs/tasks/task_detail_2026_07_02.md index fb4536a..00bc025 100644 --- a/docs/tasks/task_detail_2026_07_02.md +++ b/docs/tasks/task_detail_2026_07_02.md @@ -94,3 +94,15 @@ 5. 重写执行生成页,使其更接近原型中的左侧模板概览 + 右侧文件配置 + 左下状态区布局,并保留真实 SSE 进度与取消生成能力。 6. 执行前端类型检查,确认本轮页面重构稳定。 - **执行结果**: 现有三大主页面已开始按原型稿收口,整体信息层级和布局结构明显向原型靠齐,同时保留了当前已完成的真实业务能力。 + +## 会话 ID: local-20260702161641 +- [2026-07-02 16:16:41] +- **执行原因**: 用户指出模板编辑页尚未对齐原型编辑态,且“立即测试”未弹出文件选择并完成多文件测试链路。 +- **执行过程**: + 1. 重新核对模板管理原型中的三栏编辑态和段落测试三步弹窗交互。 + 2. 新增文件摘要服务,支持从 MinIO 下载并解析多种参考文件内容,包括 txt、md、csv、xlsx、xls、docx、pdf。 + 3. 调整 AI 调用服务,在请求模型时拼接“模板上下文 + 段落提示词 + 文件摘要”作为输入。 + 4. 更新段落测试接口,支持接收多文件路径、解析文件内容并把解析摘要返回前端展示。 + 5. 重写模板编辑页,使其更接近原型的三栏编辑结构,并实现“立即测试”三步弹窗、多文件上传、文件摘要展示和测试结果预览。 + 6. 增补 PDF 与老式 Excel 解析依赖,并执行后端语法检查与前端类型检查。 +- **执行结果**: 模板编辑页已更接近原型编辑态,“立即测试”现支持多文件上传、文件内容解析、带提示词调用 AI 模型并返回结果。 diff --git a/web/src/views/TemplateEditor.vue b/web/src/views/TemplateEditor.vue index 8594e62..c3c2c47 100644 --- a/web/src/views/TemplateEditor.vue +++ b/web/src/views/TemplateEditor.vue @@ -1,97 +1,226 @@