1556 lines
54 KiB
Python
1556 lines
54 KiB
Python
import asyncio
|
||
import re
|
||
import sys
|
||
from typing import Optional, List, Set,Iterable
|
||
|
||
if sys.platform.startswith("win") and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"):
|
||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||
|
||
try:
|
||
import ahocorasick
|
||
except ImportError:
|
||
ahocorasick = None
|
||
from pydantic import BaseModel, Field, model_validator
|
||
from fastapi import FastAPI, File, Path, UploadFile, HTTPException, Form, Request, Header, Body, BackgroundTasks
|
||
from fastapi.responses import StreamingResponse
|
||
try:
|
||
from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate
|
||
except ImportError:
|
||
try:
|
||
from langchain.prompts import FewShotPromptTemplate, PromptTemplate
|
||
except ImportError:
|
||
class PromptTemplate:
|
||
def __init__(self, input_variables=None, template: str = ""):
|
||
self.input_variables = input_variables or []
|
||
self.template = template
|
||
|
||
def format(self, **kwargs):
|
||
return self.template.format(**kwargs)
|
||
|
||
class FewShotPromptTemplate:
|
||
def __init__(
|
||
self,
|
||
examples=None,
|
||
example_prompt=None,
|
||
prefix: str = "",
|
||
suffix: str = "",
|
||
input_variables=None,
|
||
example_separator: str = "\n\n",
|
||
):
|
||
self.examples = examples or []
|
||
self.example_prompt = example_prompt
|
||
self.prefix = prefix
|
||
self.suffix = suffix
|
||
self.input_variables = input_variables or []
|
||
self.example_separator = example_separator
|
||
|
||
def format(self, **kwargs):
|
||
rendered_examples = []
|
||
for example in self.examples:
|
||
rendered_examples.append(self.example_prompt.format(**example))
|
||
parts = [self.prefix]
|
||
if rendered_examples:
|
||
parts.append(self.example_separator.join(rendered_examples))
|
||
parts.append(self.suffix.format(**kwargs))
|
||
return self.example_separator.join(part for part in parts if part)
|
||
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ChatCompletionMessageParam
|
||
from chunk_text import (
|
||
get_chunk_bbox,
|
||
split_text_preserve_sentences,
|
||
data_replace,
|
||
chunk_check,
|
||
merge_short_slices,
|
||
find_and_read_content_list,
|
||
process_pdf_file,
|
||
process_other_file,
|
||
safe_original_filename,
|
||
image_base64_to_data_url,
|
||
extract_image_text,
|
||
prepare_split_image,
|
||
)
|
||
from fastapi.responses import JSONResponse, StreamingResponse
|
||
import requests
|
||
from pathlib import PurePath, Path as PathLib
|
||
import tempfile
|
||
|
||
from modelsAPI import model_api
|
||
from documents_prompt import (
|
||
BEAUTIFY_TYPE_DICT,
|
||
TEMPLATE_DICT,
|
||
GENERATE_NORMAL_OUTLINE_PROMPT,
|
||
GENERATE_MARKDOWN_LIST_OUTLINE_PROMPT,
|
||
GENERATE_METHOD_OUTLINE_PROMPT,
|
||
)
|
||
"""app.py
|
||
FastAPI 主 Agent 接口 - 修复重复执行问题
|
||
提供主脑 Agent 的 HTTP API 服务,支持流式输出
|
||
集成 LangGraph Checkpointer 实现状态持久化
|
||
"""
|
||
import logging
|
||
from time import sleep
|
||
import aiofiles
|
||
|
||
from dotenv import load_dotenv
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import StreamingResponse, FileResponse
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel
|
||
from typing import Optional, Dict, Any, AsyncGenerator,List
|
||
import uvicorn
|
||
import json
|
||
import os
|
||
import uuid
|
||
from main_agent import create_main_agent
|
||
from utils.function_tracker import (
|
||
register_event_callback,
|
||
create_stream_event_handler,
|
||
unregister_event_callback,
|
||
clear_current_stream_handler
|
||
)
|
||
import asyncio
|
||
import importlib
|
||
from doc2pdf import Doc2PDF
|
||
|
||
from workflow_registry import WORKFLOW_CONFIG, VALID_ROUTE_FLAGS
|
||
from main_agent import extract_conversation_title
|
||
from workflows.history_manager import filter_image_urls, preprocess_from_request
|
||
from config import set_request_user_config
|
||
from checkpointer_config import (
|
||
CheckpointerManager,
|
||
checkpointer_manager
|
||
)
|
||
from gw_write import cotprompt,build_review_prompt,length_convert,length_display,build_length_instruction,dedupe_and_filter,filter_positive_word_false_positives,append_dictionary_results,find_keywords,normalize_words,process_rag_prompt
|
||
app = FastAPI(max_request_size=1024 * 1024 * 10)
|
||
|
||
_doc2pdf_converter: Optional[Doc2PDF] = None
|
||
DATA_DIR = "/app/files"
|
||
load_dotenv()
|
||
VLM_SEMAPHORE = asyncio.Semaphore(1)
|
||
|
||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||
logger = logging.getLogger(__name__)
|
||
REWRITE_TYPE_MAP = {
|
||
"quotePolish": "金句润色",
|
||
"formalStyle": "书面化",
|
||
"speechStyle": "讲稿化",
|
||
"formatting": "规整",
|
||
"expand": "扩写",
|
||
"continueWriting": "续写",
|
||
"report": "汇报",
|
||
"quote": "金句",
|
||
"summarize": "精简",
|
||
"condense": "总结",
|
||
}
|
||
# /Review 流式并发配置:块大小 5000,最多 5 块并行
|
||
REVIEW_CHUNK_SIZE = 5000
|
||
REVIEW_MAX_CONCURRENCY = 5
|
||
REVIEW_SYSTEM_PROMPT = (
|
||
"你是一个专业的文本校对专家,能够根据要求进行内容的纠正和改错"
|
||
"请严格按照用户要求的格式输出。"
|
||
"不要输出推理过程、解释性文字、前缀或总结。"
|
||
)
|
||
def get_doc2pdf_converter() -> Doc2PDF:
|
||
global _doc2pdf_converter
|
||
if _doc2pdf_converter is None:
|
||
try:
|
||
_doc2pdf_converter = Doc2PDF()
|
||
except RuntimeError as exc:
|
||
logger.error(f"LibreOffice 初始化失败: {exc}")
|
||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||
return _doc2pdf_converter
|
||
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# app.mount("/picture", StaticFiles(directory="/app/picture"), name="pictures")
|
||
|
||
STREAM_RESPONSE_HEADERS = {
|
||
"Cache-Control": "no-cache",
|
||
"X-Accel-Buffering": "no",
|
||
"X-Content-Type-Options": "nosniff",
|
||
}
|
||
|
||
|
||
def llm_text_stream_response(content) -> StreamingResponse:
|
||
return StreamingResponse(
|
||
content,
|
||
media_type="text/plain; charset=utf-8",
|
||
headers=STREAM_RESPONSE_HEADERS,
|
||
)
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def startup_event():
|
||
"""应用启动时初始化"""
|
||
await checkpointer_manager.setup()
|
||
# 初始化智能体使用统计表
|
||
from tools.agent_usage_statistics import init_agent_usage_table
|
||
await init_agent_usage_table()
|
||
logger.info("应用启动完成,PostgreSQL Checkpointer 已配置")
|
||
|
||
|
||
# =============== PDF 文件目录配置 ===============
|
||
# 创建 PDF 文件目录(用于存储生成的 PDF 等文件)
|
||
PDF_DIR = os.path.join(os.path.dirname(__file__), "created_pdf")
|
||
# 创建 word 文件目录(用于存储生成的 word 等文件)
|
||
WORD_DIR = os.path.join(os.path.dirname(__file__), "created_word")
|
||
os.makedirs(PDF_DIR, exist_ok=True)
|
||
|
||
class RewriteRequest(BaseModel):
|
||
rewrite_type: Optional[str] = None
|
||
content: Optional[str] = None
|
||
require: Optional[str] = None
|
||
sentence: Optional[str] = None
|
||
|
||
|
||
|
||
|
||
def build_rewrite_prompt(request: RewriteRequest) -> str:
|
||
beautify_type = REWRITE_TYPE_MAP.get(request.rewrite_type or "")
|
||
if not beautify_type:
|
||
raise HTTPException(status_code=400, detail=f"不支持的 rewrite_type: {request.rewrite_type}")
|
||
|
||
prompt_template = BEAUTIFY_TYPE_DICT.get(beautify_type)
|
||
if not prompt_template:
|
||
raise HTTPException(status_code=400, detail=f"未找到提示词模板: {beautify_type}")
|
||
|
||
return prompt_template.format(
|
||
require=request.require or "",
|
||
content=request.content or "",
|
||
sentence=request.sentence or request.content or "",
|
||
)
|
||
|
||
|
||
async def stream_llm_content(prompt: str):
|
||
async for chunk in model_api.OpenaiAPI.open_api_chat_stream(
|
||
query=prompt,
|
||
model=None,
|
||
system_prompt="你是专业文稿改写助手,根据要求进行文本改写。",
|
||
messages=[],
|
||
):
|
||
if chunk:
|
||
yield chunk
|
||
|
||
|
||
@app.post("/Rewrite")
|
||
async def rewrite(request: RewriteRequest):
|
||
prompt = build_rewrite_prompt(request)
|
||
|
||
return llm_text_stream_response(stream_llm_content(prompt))
|
||
|
||
|
||
class WriteRequest(BaseModel):
|
||
role: Optional[str] = None
|
||
template: Optional[str] = None
|
||
title: Optional[str] = None
|
||
length: Optional[str] = None
|
||
requirement: Optional[str] = None
|
||
references: Optional[str] = None
|
||
outline: Optional[str] = None
|
||
konwlege: Optional[str] = None
|
||
prompt: Optional[str] = None
|
||
|
||
class OutlineRequest(BaseModel):
|
||
role: Optional[str] = None
|
||
template: Optional[str] = None
|
||
title: Optional[str] = None
|
||
length: Optional[str] = None
|
||
requirement: Optional[str] = None
|
||
references: Optional[str] = None
|
||
konwlege: Optional[str] = None
|
||
prompt: Optional[str] = None
|
||
|
||
|
||
def build_write_prompt(request: WriteRequest) -> str:
|
||
prompt = request.prompt or ""
|
||
template = request.template or ""
|
||
length_instruction = build_length_instruction(request.length)
|
||
content = f"请撰写一篇{template}。{length_instruction}{prompt}"
|
||
prompt_template = TEMPLATE_DICT.get(template)
|
||
if not prompt_template:
|
||
raise HTTPException(status_code=400, detail=f"不支持的写作模板: {template}")
|
||
|
||
title = request.title or ""
|
||
if not title.strip():
|
||
raise HTTPException(status_code=400, detail="title不能为空")
|
||
|
||
return content+prompt_template.format(
|
||
role=request.role or "",
|
||
title=title,
|
||
length=length_display(request.length),
|
||
requirement=request.requirement or "",
|
||
references=request.references or "",
|
||
outline=request.outline or "",
|
||
)
|
||
|
||
|
||
async def stream_write_content(prompt: str):
|
||
async for chunk in model_api.OpenaiAPI.open_api_chat_stream(
|
||
query=prompt,
|
||
model=None,
|
||
system_prompt="你是专业公文写作助手,请直接输出正文内容。",
|
||
messages=[],
|
||
):
|
||
if chunk:
|
||
yield chunk
|
||
|
||
|
||
@app.post("/Write")
|
||
async def write(request: WriteRequest):
|
||
prompt = build_write_prompt(request)
|
||
|
||
return llm_text_stream_response(stream_write_content(prompt))
|
||
|
||
|
||
def build_outline_prompt(request: OutlineRequest) -> str:
|
||
if request.prompt and request.prompt.strip():
|
||
return request.prompt.strip()
|
||
|
||
template = request.template or ""
|
||
title = request.title or ""
|
||
if not title.strip():
|
||
raise HTTPException(status_code=400, detail="title不能为空")
|
||
|
||
markdown_list_template = {"报告", "函", "文书", "工作总结"}
|
||
prompt_template = GENERATE_NORMAL_OUTLINE_PROMPT
|
||
|
||
if template == "方案":
|
||
prompt_template = GENERATE_METHOD_OUTLINE_PROMPT
|
||
elif template in markdown_list_template:
|
||
prompt_template = GENERATE_MARKDOWN_LIST_OUTLINE_PROMPT
|
||
|
||
return prompt_template.format(
|
||
role=request.role or "",
|
||
template=template,
|
||
title=title,
|
||
length=length_display(request.length),
|
||
requirement=request.requirement or "",
|
||
references=request.references or "",
|
||
)
|
||
|
||
|
||
async def stream_outline_content(prompt: str):
|
||
async for chunk in model_api.OpenaiAPI.open_api_chat_stream(
|
||
query=prompt,
|
||
model=None,
|
||
system_prompt="你是专业公文提纲生成助手,请直接输出提纲内容。",
|
||
messages=[],
|
||
):
|
||
if chunk:
|
||
yield chunk
|
||
|
||
|
||
@app.post("/Outline")
|
||
async def outline(request: OutlineRequest):
|
||
prompt = build_outline_prompt(request)
|
||
|
||
return llm_text_stream_response(stream_outline_content(prompt))
|
||
|
||
|
||
class ReviewRequest(BaseModel):
|
||
require: Optional[str] = Field(None, description="额外校对要求")
|
||
content: str = Field(..., description="待校对文本")
|
||
types: List[str] = Field(..., description="检查类型")
|
||
sensitive_words: List[str] = Field(default_factory=list, description="敏感词汇")
|
||
negative_words: List[str] = Field(default_factory=list, description="错误词汇")
|
||
positive_words: List[str] = Field(default_factory=list, description="正词词汇")
|
||
|
||
@model_validator(mode="after")
|
||
def check_content(self):
|
||
if not self.content or not self.content.strip():
|
||
raise ValueError("content不能为空")
|
||
return self
|
||
|
||
|
||
class ReviewItem(BaseModel):
|
||
original: str
|
||
error: str
|
||
suggestion: str
|
||
|
||
|
||
class ReviewResponse(BaseModel):
|
||
data: List[ReviewItem]
|
||
|
||
|
||
|
||
|
||
|
||
_REVIEW_BLOCK_RE = re.compile(
|
||
r"错误[::]\s*(?P<error>.*?)\s*[\r\n]+"
|
||
r"原文[::]\s*(?P<original>.*?)\s*[\r\n]+"
|
||
r"建议[::]\s*(?P<suggestion>.*?)(?=\n\s*\n|$)",
|
||
re.S,
|
||
)
|
||
|
||
def parse_review_output(text: str) -> List[dict]:
|
||
text = text.strip()
|
||
|
||
if text.startswith("###"):
|
||
text = text[3:].strip()
|
||
|
||
if text.endswith("###"):
|
||
text = text[:-3].strip()
|
||
|
||
result = []
|
||
|
||
for match in _REVIEW_BLOCK_RE.finditer(text):
|
||
result.append({
|
||
"error": match.group("error").strip(),
|
||
"original": match.group("original").strip(),
|
||
"suggestion": match.group("suggestion").strip(),
|
||
})
|
||
|
||
return result
|
||
|
||
|
||
|
||
async def stream_review_model(prompt: Iterable[ChatCompletionMessageParam]):
|
||
async for chunk in model_api.OpenaiAPI.open_api_chat_stream(
|
||
model=None,
|
||
json_output=False,
|
||
system_prompt=REVIEW_SYSTEM_PROMPT,
|
||
messages=prompt,
|
||
enable_thinking=False,
|
||
temperature=0.1,
|
||
):
|
||
if chunk:
|
||
yield chunk
|
||
|
||
|
||
async def call_review_model(prompt: Iterable[ChatCompletionMessageParam]) -> str:
|
||
full_text = ""
|
||
|
||
async for chunk in stream_review_model(prompt):
|
||
full_text += chunk
|
||
|
||
return full_text
|
||
|
||
|
||
def build_review_stream_require(
|
||
require: Optional[str],
|
||
sensitive_words: List[str],
|
||
negative_words: List[str],
|
||
positive_words: List[str],
|
||
) -> Optional[str]:
|
||
requirements = []
|
||
|
||
if require and require.strip():
|
||
requirements.append(require.strip())
|
||
|
||
sensitive_text = "、".join(dict.fromkeys(word.strip() for word in sensitive_words if word and word.strip()))
|
||
if sensitive_text:
|
||
requirements.append(f"请重点检查以下敏感词:{sensitive_text}。如果原文出现这些词,错误类型写“敏感词汇”。")
|
||
|
||
negative_text = "、".join(dict.fromkeys(word.strip() for word in negative_words if word and word.strip()))
|
||
if negative_text:
|
||
requirements.append(f"请重点检查以下错误词:{negative_text}。如果原文出现这些词,错误类型写“错误词汇”。")
|
||
|
||
positive_text = "、".join(dict.fromkeys(word.strip() for word in positive_words if word and word.strip()))
|
||
if positive_text:
|
||
requirements.append(f"以下词为正确词,涉及错别字判断时不要误判:{positive_text}。")
|
||
|
||
if not requirements:
|
||
return None
|
||
|
||
return "\n".join(requirements)
|
||
|
||
|
||
|
||
async def stream_review_content(
|
||
content: str,
|
||
types: List[str],
|
||
require: Optional[str],
|
||
sensitive_words: List[str],
|
||
negative_words: List[str],
|
||
positive_words: List[str],
|
||
):
|
||
review_require = build_review_stream_require(
|
||
require=require,
|
||
sensitive_words=sensitive_words,
|
||
negative_words=negative_words,
|
||
positive_words=positive_words,
|
||
)
|
||
|
||
chunks = [
|
||
content[i:i + REVIEW_CHUNK_SIZE]
|
||
for i in range(0, len(content), REVIEW_CHUNK_SIZE)
|
||
]
|
||
if not chunks:
|
||
return
|
||
|
||
semaphore = asyncio.Semaphore(REVIEW_MAX_CONCURRENCY)
|
||
|
||
async def process_chunk(chunk: str) -> str:
|
||
async with semaphore:
|
||
prompt = build_review_prompt(
|
||
content=chunk,
|
||
types=types,
|
||
require=review_require,
|
||
)
|
||
# 累积单块完整输出后再返回:并发块之间不能交错 token,
|
||
# 否则会破坏“错误/原文/建议”块结构,导致后端解析错乱。
|
||
return await call_review_model(prompt)
|
||
|
||
tasks = [asyncio.create_task(process_chunk(chunk)) for chunk in chunks]
|
||
|
||
# 谁先完成谁先产出(块间顺序不保证;校对条目相互独立,前端按原文定位)
|
||
for finished in asyncio.as_completed(tasks):
|
||
chunk_result = await finished
|
||
if chunk_result and chunk_result.strip():
|
||
yield chunk_result
|
||
yield "\n\n" # 块间分隔,避免相邻块内容被后端按 \n\n 粘连
|
||
|
||
|
||
|
||
async def review_chunk(
|
||
content: str,
|
||
types: List[str],
|
||
require: Optional[str],
|
||
) -> List[dict]:
|
||
prompt = build_review_prompt(
|
||
content=content,
|
||
types=types,
|
||
require=require,
|
||
)
|
||
|
||
model_output = await call_review_model(prompt)
|
||
return parse_review_output(model_output)
|
||
|
||
|
||
async def review_text(
|
||
content: str,
|
||
types: List[str],
|
||
require: Optional[str],
|
||
sensitive_words: List[str],
|
||
negative_words: List[str],
|
||
positive_words: List[str],
|
||
) -> List[dict]:
|
||
sensitive_word_set = normalize_words(sensitive_words)
|
||
negative_word_set = normalize_words(negative_words)
|
||
positive_word_set = normalize_words(positive_words)
|
||
|
||
chunk_size = REVIEW_CHUNK_SIZE
|
||
chunks = [
|
||
content[i:i + chunk_size]
|
||
for i in range(0, len(content), chunk_size)
|
||
]
|
||
|
||
all_items = []
|
||
|
||
for chunk in chunks:
|
||
llm_items = await review_chunk(
|
||
content=chunk,
|
||
types=types,
|
||
require=require,
|
||
)
|
||
|
||
llm_items = filter_positive_word_false_positives(
|
||
llm_items,
|
||
positive_word_set,
|
||
)
|
||
|
||
llm_items = append_dictionary_results(
|
||
llm_items,
|
||
content=chunk,
|
||
sensitive_words=sensitive_word_set,
|
||
negative_words=negative_word_set,
|
||
)
|
||
|
||
all_items.extend(llm_items)
|
||
|
||
return dedupe_and_filter(all_items, content)
|
||
|
||
|
||
@app.post("/Review")
|
||
async def review(request: ReviewRequest):
|
||
return llm_text_stream_response(stream_review_content(
|
||
content=request.content,
|
||
types=request.types,
|
||
require=request.require,
|
||
sensitive_words=request.sensitive_words,
|
||
negative_words=request.negative_words,
|
||
positive_words=request.positive_words,
|
||
))
|
||
|
||
|
||
|
||
# =============== 请求模型 ===============
|
||
|
||
|
||
class AgentRequest(BaseModel):
|
||
"""Agent 请求模型"""
|
||
query: Optional[Any] = None # 用户文本输入
|
||
file_text: Optional[str] = None # 文件文本输入
|
||
image: Optional[str] = None # 图片路径
|
||
audio: Optional[str] = None # 音频路径(支持 audio 或 asr)
|
||
|
||
# 路由标志(可选,如果提供则跳过任务分类)
|
||
route_flag: Optional[str] = None # 路由标志
|
||
history_message: Optional[Any] = None # 历史信息(支持字符串或列表格式)
|
||
|
||
# 接收参数
|
||
top_k: Optional[Any] = None # rag 前top_k
|
||
rag_prompt: Optional[Any] = None # rag 提示词模板(支持字符串、列表、字典等多种类型)
|
||
image_kb_id: Optional[str] = None # 图库 知识库id
|
||
image_file_id: Optional[str] = None # 图库 文件id
|
||
text_kb_id: Optional[str] = None # 检索 知识库id
|
||
text_file_id: Optional[str] = None # 检索 文件id
|
||
|
||
# Socket.IO 格式所需字段
|
||
chat_id: Optional[str] = None # 聊天ID
|
||
message_id: Optional[str] = None # 消息ID
|
||
|
||
# 知识库配置
|
||
x_user_id: Optional[str] = "1" # 用户ID
|
||
x_user_name: Optional[str] = "testuser" # 用户名称
|
||
x_role: Optional[str] = "admin" # 角色
|
||
|
||
|
||
class AgentResponse(BaseModel):
|
||
"""Agent 响应模型"""
|
||
success: bool
|
||
message: str
|
||
data: Optional[Dict[str, Any]] = None
|
||
error: Optional[str] = None
|
||
|
||
class FeedbackClassifyRequest(BaseModel):
|
||
"""反馈分类请求模型"""
|
||
feedback_text: str
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def _citation_display_filename(
|
||
value: Any,
|
||
file_id: Any = "",
|
||
) -> Any:
|
||
"""仅转换文本溯源展示名,不修改 file_id 或实际文件路径。"""
|
||
if not isinstance(value, str):
|
||
return value
|
||
if not re.search(r"(?i)\.(?:pptx|ppt)$", value.strip()):
|
||
return value
|
||
normalized_file_id = str(file_id or "").strip()
|
||
if not normalized_file_id:
|
||
return value
|
||
return f"{normalized_file_id}.pdf"
|
||
|
||
|
||
def normalize_source_citation_filenames(
|
||
source_citation: Dict[str, Any],
|
||
) -> Dict[str, Any]:
|
||
"""将最终文本溯源中的 PPT/PPTX 展示后缀统一转换为 PDF。"""
|
||
if not isinstance(source_citation, dict):
|
||
return source_citation
|
||
|
||
normalized: Dict[str, Any] = {}
|
||
for resource_name, items in source_citation.items():
|
||
group_file_id = ""
|
||
if isinstance(items, list):
|
||
group_file_id = next(
|
||
(
|
||
item.get("file_id")
|
||
for item in items
|
||
if isinstance(item, dict) and item.get("file_id")
|
||
),
|
||
"",
|
||
)
|
||
display_resource = _citation_display_filename(
|
||
resource_name,
|
||
group_file_id,
|
||
)
|
||
|
||
if isinstance(items, list):
|
||
normalized_items = []
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
normalized_items.append(item)
|
||
continue
|
||
normalized_item = dict(item)
|
||
item_file_id = normalized_item.get("file_id") or group_file_id
|
||
for field in ("filename", "resource", "title"):
|
||
if field in normalized_item:
|
||
normalized_item[field] = _citation_display_filename(
|
||
normalized_item[field],
|
||
item_file_id,
|
||
)
|
||
normalized_items.append(normalized_item)
|
||
else:
|
||
normalized_items = items
|
||
|
||
# 极少数同名 .ppt/.pptx 会映射到同一 .pdf,保留全部切片。
|
||
if (
|
||
display_resource in normalized
|
||
and isinstance(normalized[display_resource], list)
|
||
and isinstance(normalized_items, list)
|
||
):
|
||
normalized[display_resource].extend(normalized_items)
|
||
else:
|
||
normalized[display_resource] = normalized_items
|
||
|
||
return normalized
|
||
|
||
|
||
def format_stream_event(event_type: str,
|
||
data: Dict[str, Any],
|
||
chat_id: str = "",
|
||
message_id: str = "",
|
||
type: str = "chat:completion",
|
||
) -> str:
|
||
"""格式化流式事件输出为 Socket.IO 格式"""
|
||
# 生成 message_id(如果没有提供)
|
||
if not message_id:
|
||
message_id = str(uuid.uuid4())
|
||
|
||
# 根据事件类型构建不同的数据结构
|
||
if event_type == "stream_content":
|
||
# stream_content 事件使用 chat:completion 格式
|
||
content = data.get("content", "")
|
||
event_data = {
|
||
"chat_id": chat_id,
|
||
"message_id": message_id,
|
||
"data": {
|
||
"type": "chat:completion",
|
||
"data": {
|
||
"content": content
|
||
}
|
||
}
|
||
}
|
||
elif event_type == "function_end":
|
||
# function_end 事件
|
||
event_data = {
|
||
"chat_id": chat_id,
|
||
"message_id": message_id,
|
||
"data": {
|
||
"type": "chat:completion",
|
||
"data": {
|
||
"stepsBlueprint": data
|
||
}
|
||
}
|
||
}
|
||
elif event_type == "execution_complete":
|
||
# execution_complete 事件
|
||
|
||
rag_result = data.get("rag_result", {})
|
||
graph_result = data.get("graph_result", [])
|
||
final_result = data.get("final_result", {})
|
||
title = data.get("title", "")
|
||
|
||
# 判断是否有有效的 RAG 结果(非空 dict)
|
||
has_rag = isinstance(rag_result, dict) and bool(rag_result)
|
||
|
||
# 判断是否有有效的图谱结果(非空 list,且至少一个元素的 nodes 非空)
|
||
has_graph = (
|
||
isinstance(graph_result, list)
|
||
and len(graph_result) > 0
|
||
and any(isinstance(item, dict) and item.get("nodes") for item in graph_result)
|
||
)
|
||
|
||
|
||
# 只有当 RAG 或图谱有有效内容时,才构建 sourceCitation
|
||
source_citation = None
|
||
if has_rag or has_graph:
|
||
source_citation = {}
|
||
if has_rag:
|
||
source_citation.update(
|
||
normalize_source_citation_filenames(rag_result)
|
||
)
|
||
if has_graph:
|
||
source_citation["xxxx.graph"] = graph_result
|
||
|
||
# 构建 event_data(title 由调用方异步计算后传入)
|
||
if final_result:
|
||
content = final_result.get("content", "")
|
||
event_data = {
|
||
"chat_id": chat_id,
|
||
"message_id": message_id,
|
||
"data": {
|
||
"type": "chat:completion",
|
||
"data": {
|
||
"content": content,
|
||
},
|
||
"actions": final_result.get("actions", ""),
|
||
"suggestedReplies": final_result.get("suggestedReplies", "")
|
||
}
|
||
}
|
||
else:
|
||
event_data = {
|
||
"chat_id": chat_id,
|
||
"message_id": message_id,
|
||
"data": {
|
||
"type": "chat:completion",
|
||
"data": {
|
||
"data": {},
|
||
"done": True,
|
||
"title": title or "新对话",
|
||
}
|
||
}
|
||
}
|
||
|
||
# 仅在有引用来源时添加 sourceCitation 字段
|
||
if source_citation is not None:
|
||
event_data["data"]["data"]["sourceCitation"] = source_citation
|
||
|
||
else:
|
||
# 其他事件
|
||
event_data = {
|
||
"chat_id": chat_id,
|
||
"message_id": message_id,
|
||
"type": event_type,
|
||
"data": {
|
||
"type": event_type,
|
||
"data": ""
|
||
}
|
||
}
|
||
|
||
# Socket.IO 格式:["chat-events", {...}]
|
||
socket_io_message = ["chat-events", event_data]
|
||
return f"{json.dumps(socket_io_message, ensure_ascii=False)}\n"
|
||
|
||
|
||
|
||
|
||
async def execute_workflow_chain(
|
||
route_flag: str,
|
||
route_params: Dict[str, Any],
|
||
chat_id: str = "",
|
||
message_id: str = "",
|
||
checkpointer=None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
执行工作流
|
||
|
||
Args:
|
||
route_flag: 路由标志
|
||
route_params: 路由参数
|
||
chat_id: 聊天会话 ID
|
||
message_id: 消息 ID
|
||
checkpointer: LangGraph checkpointer 实例
|
||
|
||
Returns:
|
||
工作流执行结果
|
||
"""
|
||
|
||
print("子工作流标志:", route_flag)
|
||
print("子工作流参数:", route_params)
|
||
|
||
if not route_flag:
|
||
return {}
|
||
|
||
extracted_text = route_params.get("extracted_text", "")
|
||
combined_query = route_params.get("combined_query", "")
|
||
history_message = route_params.get("history_message", "")
|
||
|
||
workflow_info = WORKFLOW_CONFIG.get(route_flag)
|
||
|
||
if not workflow_info:
|
||
logger.warning(f"未知的路由标志: {route_flag},使用默认的qa工作流")
|
||
workflow_info = WORKFLOW_CONFIG.get("qa", WORKFLOW_CONFIG.get(list(WORKFLOW_CONFIG.keys())[0]))
|
||
|
||
try:
|
||
module = importlib.import_module(workflow_info["module"])
|
||
workflow_func = getattr(module, workflow_info["function"])
|
||
except (ImportError, AttributeError) as e:
|
||
print(e)
|
||
logger.error(f"无法导入工作流 {route_flag}: {e}")
|
||
return {}
|
||
|
||
print("zigongggggggggggggggggggggggg")
|
||
|
||
try:
|
||
import inspect
|
||
sig = inspect.signature(workflow_func)
|
||
params = list(sig.parameters.keys())
|
||
|
||
kwargs = {
|
||
"extracted_text": extracted_text,
|
||
"combined_query": combined_query,
|
||
"history_message": history_message,
|
||
"route_flag": route_flag,
|
||
}
|
||
|
||
if "chat_id" in params:
|
||
kwargs["chat_id"] = chat_id
|
||
if "message_id" in params:
|
||
kwargs["message_id"] = message_id
|
||
if "checkpointer" in params:
|
||
kwargs["checkpointer"] = checkpointer
|
||
|
||
result = await workflow_func(**kwargs)
|
||
except Exception as e:
|
||
print(e)
|
||
logger.error(f"工作流执行失败: {e}")
|
||
return {}
|
||
|
||
return result or {}
|
||
|
||
|
||
async def stream_main_agent_execution(
|
||
raw_input: Dict[str, Any],
|
||
route_flag: str = "",
|
||
rag_prompt: str = "",
|
||
chat_id: str = "",
|
||
message_id: str = ""
|
||
) -> AsyncGenerator[str, None]:
|
||
"""
|
||
流式执行主Agent并输出函数调用信息(使用装饰器事件)
|
||
|
||
Args:
|
||
raw_input: 原始输入数据
|
||
route_flag: 路由标志
|
||
rag_prompt: RAG 提示词
|
||
chat_id: 聊天会话 ID
|
||
message_id: 消息 ID
|
||
|
||
Yields:
|
||
格式化的流式事件字符串
|
||
"""
|
||
if not chat_id:
|
||
chat_id = str(uuid.uuid4())
|
||
if not message_id:
|
||
message_id = str(uuid.uuid4())
|
||
|
||
stream_handler, event_queue = create_stream_event_handler()
|
||
register_event_callback(stream_handler)
|
||
|
||
try:
|
||
execution_done = asyncio.Event()
|
||
execution_error = None
|
||
final_result = None
|
||
checkpointer = None
|
||
|
||
async def run_agent():
|
||
nonlocal execution_error, final_result, checkpointer
|
||
route_flag_value: str = ""
|
||
try:
|
||
from checkpointer_config import checkpointer_manager
|
||
checkpointer = await checkpointer_manager.get_async_checkpointer()
|
||
|
||
main_agent = create_main_agent()
|
||
|
||
initial_route_flag: str = route_flag if (route_flag and route_flag in VALID_ROUTE_FLAGS) else ""
|
||
|
||
initial_state = {
|
||
"raw_input": raw_input,
|
||
"input_type": "",
|
||
"has_query":False,
|
||
"has_image": False,
|
||
"has_audio": False,
|
||
"has_file_text": False,
|
||
"history_message":raw_input.get("history_message", ""),
|
||
"extracted_text": "",
|
||
"image_description": "",
|
||
"asr_text": "",
|
||
"file_text": raw_input.get("file_text", ""),
|
||
"combined_query": "",
|
||
"task_type": "",
|
||
"task_classification_result": None,
|
||
"workflow_result": None,
|
||
"final_response": "",
|
||
"error_message": "",
|
||
"route_flag": initial_route_flag,
|
||
"route_params": {}
|
||
}
|
||
|
||
main_result = await main_agent.ainvoke(initial_state)
|
||
|
||
route_flag_value = (main_result.get("route_flag")or main_result.get("task_type")or "")
|
||
|
||
route_params = main_result.get("route_params") or {"combined_query": main_result.get("combined_query", "")
|
||
or main_result.get("extracted_text", ""),"extracted_text": main_result.get("extracted_text", "")}
|
||
|
||
if "file_text" in raw_input:
|
||
route_params["file_text"] = raw_input["file_text"]
|
||
|
||
if "history_message" not in route_params and "history_message" in raw_input:
|
||
route_params["history_message"] = filter_image_urls(raw_input["history_message"])
|
||
|
||
print("历史记录",route_params["history_message"])
|
||
print("用户问题", route_params["combined_query"])
|
||
print("ttttttt", route_params["extracted_text"])
|
||
|
||
sub_result = await execute_workflow_chain(
|
||
route_flag=route_flag_value,
|
||
route_params=route_params,
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
checkpointer=checkpointer
|
||
)
|
||
|
||
processed_content = ""
|
||
if sub_result.get("response"):
|
||
processed_content = sub_result.get("response", "")
|
||
|
||
else:
|
||
logger.warning("子agent没有返回response")
|
||
processed_content = ""
|
||
|
||
final_result = {
|
||
"content": processed_content,
|
||
"actions": sub_result.get("actions", []),
|
||
"result_tag": sub_result.get("result_tag", ""),
|
||
"suggestedReplies": sub_result.get("suggestedReplies", [])
|
||
}
|
||
except Exception as e:
|
||
execution_error = e
|
||
finally:
|
||
execution_done.set()
|
||
await event_queue.put({"type": "execution_complete"})
|
||
|
||
agent_task = asyncio.create_task(run_agent())
|
||
|
||
execution_complete_received = False
|
||
has_streamed_content = False
|
||
rag_result = []
|
||
graph_result = []
|
||
while True:
|
||
try:
|
||
event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
|
||
|
||
if event.get("type") == "execution_complete":
|
||
execution_complete_received = True
|
||
continue
|
||
|
||
event_type = event.get("type")
|
||
title = event.get("title", "")
|
||
details = event.get("details", "")
|
||
result = event.get("result")
|
||
|
||
if event_type == "function_execution":
|
||
logger.info(f"function_execution event - title: {title}, has_result: {result is not None}, result_type: {type(result)}")
|
||
# 装饰器产生的无标题事件属于内部入参/返回值,不应在前端展示。
|
||
# 前端会把它们自动编号成“步骤 2”“步骤 5”,从而泄露检索原文
|
||
# 和未整理的回答草稿。
|
||
if not title.strip():
|
||
continue
|
||
if title == "知识库搜索工具" and result and isinstance(result, dict):
|
||
print("===============================", result)
|
||
rag_result_raw = result.get("sourceCitation", {})
|
||
rag_result = {}
|
||
for doc_name, chunks in rag_result_raw.items():
|
||
if isinstance(chunks, list):
|
||
rag_result[doc_name] = [
|
||
{k: v for k, v in item.items() if k != "text"}
|
||
if isinstance(item, dict) else item
|
||
for item in chunks
|
||
]
|
||
else:
|
||
rag_result[doc_name] = chunks
|
||
elif title == "图谱检索工具" and result and isinstance(result, dict):
|
||
print("===============================", result)
|
||
graph_result = result.get("xxxx.graph", [])
|
||
else:
|
||
yield format_stream_event("function_end", {
|
||
"title": title,
|
||
"details": details
|
||
}, chat_id=chat_id, message_id=message_id)
|
||
|
||
elif event_type == "function_error":
|
||
yield format_stream_event("function_error", {
|
||
"title": title,
|
||
"error": details,
|
||
"details": details
|
||
}, chat_id=chat_id, message_id=message_id)
|
||
|
||
elif event_type == "stream_content":
|
||
content = event.get("content", "")
|
||
|
||
if content:
|
||
has_streamed_content = True
|
||
yield format_stream_event("stream_content", {
|
||
"content": content
|
||
}, chat_id=chat_id, message_id=message_id)
|
||
|
||
|
||
except asyncio.TimeoutError:
|
||
if execution_complete_received:
|
||
break
|
||
if execution_done.is_set() and event_queue.empty():
|
||
break
|
||
continue
|
||
|
||
await agent_task
|
||
|
||
if execution_error:
|
||
yield format_stream_event("error", {
|
||
"error": str(execution_error)
|
||
}, chat_id=chat_id, message_id=message_id)
|
||
else:
|
||
title = await extract_conversation_title(final_result.get("content", "") if final_result else "")
|
||
frontend_final_result = final_result
|
||
if has_streamed_content and final_result:
|
||
# 正文已按增量流式发送,完成事件只补充 actions 等元数据。
|
||
# 再携带完整 content 会被前端追加为第二份重复正文。
|
||
frontend_final_result = {**final_result, "content": ""}
|
||
yield format_stream_event("execution_complete", {
|
||
"final_result": frontend_final_result
|
||
}, chat_id=chat_id, message_id=message_id)
|
||
|
||
yield format_stream_event("execution_complete", {
|
||
"rag_result": rag_result,
|
||
"graph_result": graph_result,
|
||
"title": title
|
||
}, chat_id=chat_id, message_id=message_id)
|
||
|
||
except Exception as e:
|
||
yield format_stream_event("error", {
|
||
"error": str(e)
|
||
}, chat_id=chat_id, message_id=message_id)
|
||
finally:
|
||
unregister_event_callback(stream_handler)
|
||
clear_current_stream_handler()
|
||
|
||
# =============== API 接口 ===============
|
||
@app.post("/api/agent/stream_process")
|
||
async def stream_process_request(request: AgentRequest):
|
||
"""
|
||
流式处理 Agent 请求,实时输出节点执行信息
|
||
不保存历史,调用端会自己保存历史对话信息
|
||
每次请求时创建新的主Agent实例
|
||
"""
|
||
|
||
print(f"🔍 [调试] 接收到的原始请求: {request.model_dump_json(indent=2)}")
|
||
|
||
print(f"[DEBUG] 收到请求 chat_id={request.chat_id!r}, message_id={request.message_id!r}, route_flag={request.route_flag!r}, query={str(request.query)[:100]}")
|
||
# 1. 构建当前请求的输入数据
|
||
raw_input = {}
|
||
if request.query:
|
||
if isinstance(request.query, str):
|
||
raw_input["query"] = request.query
|
||
elif isinstance(request.query, list):
|
||
for item in request.query:
|
||
if item.get("type") == "text":
|
||
raw_input["query"] = item.get("text")
|
||
print(f"当前查询: {raw_input['query']}")
|
||
if request.image:
|
||
raw_input["image"] = request.image
|
||
if request.audio:
|
||
# audio 字段支持 audio 或 asr,统一使用 audio 键
|
||
raw_input["audio"] = request.audio
|
||
# [新增] 提取 file_text
|
||
if request.file_text:
|
||
raw_input["file_text"] = request.file_text[:5000]
|
||
|
||
# 历史消息预处理:解析、移除当前用户消息、序列化
|
||
if request.history_message:
|
||
raw_input["history_message"] = preprocess_from_request(request.history_message, exclude_last=True)
|
||
|
||
# 2. 验证至少有一个输入
|
||
if not raw_input:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="至少需要提供一个输入:query、image 或 audio"
|
||
)
|
||
|
||
# 3. 获取路由标志(如果提供,将跳过任务分类步骤)
|
||
# 始终传递 route_flag,默认值为空字符串
|
||
route_flag = request.route_flag or ""
|
||
print("打印agent对应的route_flag:")
|
||
print(route_flag)
|
||
rag_prompt = process_rag_prompt(request.rag_prompt) if request.rag_prompt else ""
|
||
chat_id = request.chat_id or ""
|
||
message_id = request.message_id or ""
|
||
|
||
# 记录智能体使用情况
|
||
if route_flag and chat_id:
|
||
from tools.agent_usage_statistics import record_agent_usage
|
||
asyncio.create_task(record_agent_usage(
|
||
chat_id=chat_id,
|
||
route_flag=route_flag,
|
||
message_id=message_id
|
||
))
|
||
logger.info(f"记录智能体使用: chat_id={chat_id}, route_flag={route_flag}")
|
||
|
||
|
||
|
||
# 4. 设置当前请求的用户配置和知识库参数(线程安全,支持多用户并发)
|
||
# 如果请求中提供了这些参数,则使用请求中的值;否则使用默认值
|
||
set_request_user_config(
|
||
x_user_id=request.x_user_id,
|
||
x_user_name=request.x_user_name,
|
||
x_role=request.x_role,
|
||
text_kb_id=request.text_kb_id,
|
||
text_file_id=request.text_file_id
|
||
)
|
||
|
||
#5. 执行主Agent
|
||
async def event_generator():
|
||
async for event in stream_main_agent_execution(
|
||
raw_input=raw_input,
|
||
route_flag=route_flag,
|
||
rag_prompt=rag_prompt,
|
||
chat_id=chat_id,
|
||
message_id=message_id
|
||
):
|
||
yield event
|
||
|
||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||
|
||
|
||
@app.get("/api/download/{file_name}")
|
||
async def download_file(file_name: str):
|
||
"""
|
||
文件下载接口
|
||
|
||
Args:
|
||
file_name: 文件名
|
||
|
||
Returns:
|
||
文件下载响应
|
||
"""
|
||
pdf_path = os.path.join(PDF_DIR, file_name)
|
||
word_path = os.path.join(WORD_DIR, file_name)
|
||
file_path = pdf_path if os.path.exists(pdf_path) else word_path
|
||
print(f"下载文件:{file_path}")
|
||
# 检查文件是否存在
|
||
ext = os.path.splitext(file_name)[1].lower()
|
||
media_type = {
|
||
".pdf": "application/pdf",
|
||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
".doc": "application/msword",
|
||
}.get(ext, "application/octet-stream")
|
||
|
||
# 返回文件
|
||
return FileResponse(
|
||
path=file_path,
|
||
filename=file_name,
|
||
media_type=media_type
|
||
)
|
||
|
||
|
||
|
||
|
||
@app.post("/api/v1/feedback/classify")
|
||
async def feedback_classify(request: FeedbackClassifyRequest):
|
||
"""
|
||
反馈内容分类接口
|
||
|
||
功能:
|
||
1. 从反馈文本中提取设备名称
|
||
2. 根据设备名称反推系统名称
|
||
3. 对反馈内容进行分类
|
||
|
||
Args:
|
||
request: 包含 feedback_text 字段的请求体
|
||
|
||
Returns:
|
||
{
|
||
"success": true,
|
||
"system": "系统名称",
|
||
"category": "分类结果"
|
||
}
|
||
|
||
分类类别:
|
||
- 信息过时:反映文档或手册中的信息已经过时,不再适用
|
||
- 步骤错误:反映操作步骤或流程存在错误
|
||
- 步骤缺失:反映缺少必要的操作步骤或流程说明
|
||
- 备件信息有误:反映备件型号、规格、数量等信息有误
|
||
- 安全提示不足:反映缺少必要的安全警示或注意事项
|
||
- 图示不清:反映图片、示意图不清晰或难以理解
|
||
- 其他:不属于以上类别
|
||
"""
|
||
from tools.fault_statistics import analyze_feedback_and_classify
|
||
|
||
result = await analyze_feedback_and_classify(request.feedback_text)
|
||
|
||
if not result.get("success", False):
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=result.get("error", "分类失败")
|
||
)
|
||
|
||
return result
|
||
|
||
class PdfContentItem(BaseModel):
|
||
page_idx: int
|
||
bbox: Optional[str] = None
|
||
text: Optional[str] = None
|
||
text_level: Optional[int] = None
|
||
img_path: Optional[str] = None
|
||
table_body: Optional[str] = None
|
||
|
||
|
||
class RequestWrapper(BaseModel):
|
||
pdf_contents: List[PdfContentItem]
|
||
|
||
|
||
class Base64ImageRequest(BaseModel):
|
||
content: Optional[str] = Field(
|
||
None,
|
||
description="Optional text around the image, for example: 图3.1 船舶主发动机组成图(images/test.jpg)",
|
||
)
|
||
filename: str = Field(..., description="Original image filename, for example test.png")
|
||
image_base64: str = Field(..., description="Base64 image content, with or without data:image/... prefix")
|
||
|
||
|
||
async def analyze_image_with_vlm(image_bytes: bytes, suffix: str) -> str:
|
||
image_url = image_base64_to_data_url(image_bytes, suffix)
|
||
async with VLM_SEMAPHORE:
|
||
return await model_api.OpenaiAPI.open_api_vl_without_thinking(image_url)
|
||
|
||
|
||
@app.post("/split_image")
|
||
async def split_image(request_data: Base64ImageRequest):
|
||
filename = safe_original_filename(request_data.filename)
|
||
suffix = PathLib(filename).suffix.lower()
|
||
if suffix not in {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif", ".tif", ".tiff"}:
|
||
raise HTTPException(status_code=400, detail="Unsupported image type")
|
||
|
||
image_bytes, processed_suffix = prepare_split_image(request_data.image_base64, suffix)
|
||
if not image_bytes:
|
||
raise HTTPException(status_code=400, detail="decoded image is empty")
|
||
|
||
temp_path = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=processed_suffix) as tmp:
|
||
tmp.write(image_bytes)
|
||
temp_path = PathLib(tmp.name)
|
||
|
||
ocr_data = await extract_image_text(temp_path)
|
||
try:
|
||
temp_path.unlink()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to delete temp image {temp_path}: {e}")
|
||
finally:
|
||
temp_path = None
|
||
|
||
content = (request_data.content or "").strip()
|
||
ocr_full_text = (ocr_data.get("full_text") or "").strip()
|
||
|
||
if not content and not ocr_full_text:
|
||
vlm_text = await analyze_image_with_vlm(image_bytes, processed_suffix)
|
||
image_content = "图片文本描述为:" + (vlm_text or "").strip()
|
||
elif content and ocr_full_text:
|
||
image_content = f"图片上下文内容为:{content}, {ocr_full_text}"
|
||
elif content:
|
||
image_content = "图片上下文内容为:" + content
|
||
else:
|
||
image_content = ocr_full_text
|
||
|
||
image_content = "图片文件名为:" + filename + ",图片相关内容为:" + image_content
|
||
return JSONResponse(
|
||
{
|
||
"code": 200,
|
||
"message": "ok",
|
||
"data": {
|
||
"image_content": image_content,
|
||
"filename": filename,
|
||
},
|
||
}
|
||
)
|
||
finally:
|
||
if temp_path and temp_path.exists():
|
||
try:
|
||
temp_path.unlink()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to delete temp image {temp_path}: {e}")
|
||
|
||
|
||
|
||
|
||
@app.post("/split_content_list")
|
||
async def split_content_list(request_data: RequestWrapper):
|
||
pdf_contents = request_data.pdf_contents
|
||
processed_list = []
|
||
|
||
for item in pdf_contents:
|
||
# 将 Pydantic 模型转换为字典,方便修改
|
||
item_dict = item.model_dump()
|
||
|
||
# --- 核心逻辑:判断并添加 type 字段 ---
|
||
item_type = None
|
||
|
||
# 1. 如果 text 非空 -> type: "text"
|
||
if item.text:
|
||
item_type = "text"
|
||
|
||
# 2. 如果 table_body 非空 -> type: "table"
|
||
# 注意:如果 text 和 table_body 都有,根据代码顺序,table 会覆盖 text。
|
||
# 如果你的业务逻辑是互斥的或需要优先级,请调整此处顺序。
|
||
elif item.table_body:
|
||
item_type = "table"
|
||
|
||
# 3. 如果 img_path 非空 且 table_body 为空 -> type: "image"
|
||
# 注意:上面用了 elif 判断 table_body,所以到这里 table_body 肯定为空,
|
||
# 但为了逻辑清晰,显式写出条件。
|
||
elif item.img_path and not item.table_body:
|
||
item_type = "image"
|
||
|
||
# 如果匹配到了类型,写入字典
|
||
if item_type:
|
||
item_dict["type"] = item_type
|
||
|
||
processed_list.append(item_dict)
|
||
slices = get_chunk_bbox(processed_list)
|
||
slices_check = chunk_check(slices, 8000)
|
||
|
||
return {"code": 200, "message": "ok", "data": {"slices": slices_check}}
|
||
|
||
|
||
@app.post("/split_result")
|
||
async def split_result(
|
||
file: UploadFile = File(...),
|
||
image_prefix: str = Form("/api/v1/knowledge/files/images/"),
|
||
):
|
||
if not file.filename:
|
||
raise HTTPException(status_code=400, detail="File name is missing")
|
||
|
||
chunk_size = 1024
|
||
chunk_overlap = 100
|
||
|
||
filename = file.filename
|
||
suffix = PathLib(filename).suffix.lower() # 获取文件后缀
|
||
temp_input_path: Optional[PathLib] = None
|
||
converted_pdf_path: Optional[PathLib] = None
|
||
logger.info("调用知识切分接口")
|
||
|
||
try:
|
||
# ? 创建临时文件时指定后缀名
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_in:
|
||
content = await file.read()
|
||
logger.info(f"文件大小:{len(content)}")
|
||
logger.info(f"前10字节:{content[:10]}")
|
||
# if not content.startwith(b"%PDF"):
|
||
# logger.error("不是合法PDF")
|
||
# raise HTTPException(status_code=400,detail="Uploaded file is not a valid PDF")
|
||
tmp_in.write(content)
|
||
temp_input_path = PathLib(tmp_in.name)
|
||
# 2. 根据文件类型处理
|
||
if suffix == ".pdf":
|
||
logger.info("调用pdf切分方式")
|
||
|
||
result_data = await process_pdf_file(temp_input_path, image_prefix,filename)
|
||
if result_data is None:
|
||
raise HTTPException(status_code=502, detail="PDF processing failed")
|
||
elif suffix == ".docx":
|
||
logger.info("调用docx切分方式")
|
||
logger.info(f"转换 DOCX -> PDF: {filename}")
|
||
|
||
# 执行转换
|
||
get_doc2pdf_converter().convert(str(temp_input_path), output_dir=str(DATA_DIR))
|
||
|
||
# ? 使用临时文件的基础名查找 PDF
|
||
temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn
|
||
converted_pdf_path = PathLib(DATA_DIR) / f"{temp_base_name}.pdf"
|
||
|
||
if not converted_pdf_path.exists():
|
||
raise HTTPException(status_code=500, detail=f"PDF 转换失败: {converted_pdf_path}")
|
||
|
||
result_data = await process_pdf_file(converted_pdf_path, image_prefix)
|
||
if result_data is None:
|
||
raise HTTPException(status_code=502, detail="DOCX to PDF processing failed")
|
||
|
||
elif suffix in (".txt", ".md"):
|
||
logger.info("调用txt切分方式")
|
||
|
||
# 直接读取文本内容
|
||
async with aiofiles.open(temp_input_path, "r", encoding="utf-8", errors="ignore") as f:
|
||
text_content = await f.read()
|
||
contents = split_text_preserve_sentences(text_content)
|
||
# 构造统一结构(无 images)
|
||
slices = []
|
||
for ins in contents:
|
||
slices.append({"content": ins, "positions": []})
|
||
result_data = {"slices": slices, "images": {}}
|
||
elif suffix in [".xlsx"]:
|
||
# converter.convert(str(temp_input_path), output_dir=str(DATA_DIR))
|
||
# temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn
|
||
# converted_pdf_path = PathLib(DATA_DIR) / f"{temp_base_name}.pdf"
|
||
# if not converted_pdf_path.exists():
|
||
# raise HTTPException(status_code=500, detail=f"PDF 转换失败: {converted_pdf_path}")
|
||
logger.info("调用xlsx切分方式")
|
||
|
||
result_data = await process_other_file(temp_input_path, image_prefix,filename)
|
||
if result_data is None:
|
||
raise HTTPException(status_code=502, detail="DOCX parse failed")
|
||
elif suffix in [".ppt", ".pptx"]:
|
||
logger.info("调用 PPT/PPTX 切分方式")
|
||
|
||
# 1. 判断是否为旧版 .ppt 格式,如果是,则先进行转换
|
||
if suffix == ".ppt":
|
||
logger.info(f"检测到旧版 PPT 文件,正在转换为 PPTX: {temp_input_path}")
|
||
try:
|
||
# 定义 LibreOffice 转换命令
|
||
command = [
|
||
'libreoffice',
|
||
'--headless', # 无头模式,不弹出图形界面
|
||
'--convert-to', 'pptx',
|
||
'--outdir', os.path.dirname(temp_input_path),
|
||
temp_input_path
|
||
]
|
||
|
||
# 2. 使用 run_in_executor 将阻塞的同步转换操作放入线程池,避免阻塞异步事件循环
|
||
loop = asyncio.get_event_loop()
|
||
result = await loop.run_in_executor(
|
||
None,
|
||
lambda: subprocess.run(command, capture_output=True, text=True)
|
||
)
|
||
|
||
# 3. 检查转换是否成功
|
||
if result.returncode != 0:
|
||
logger.error(f"PPT 转换失败: {result.stderr}")
|
||
raise HTTPException(status_code=500, detail="PPT to PPTX conversion failed")
|
||
|
||
# 4. 转换成功后,更新输入路径为新生成的 .pptx 文件路径
|
||
temp_input_path = os.path.splitext(temp_input_path)[0] + ".pptx"
|
||
logger.info(f"PPT 转换成功,新文件路径: {temp_input_path}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"PPT 转换过程发生异常: {e}")
|
||
raise HTTPException(status_code=500, detail=f"PPT conversion error: {str(e)}")
|
||
|
||
# 5. 无论是原本就是 .pptx,还是刚刚转换完成的 .pptx,都统一走后续处理逻辑
|
||
result_data = await process_other_file(temp_input_path, image_prefix, filename)
|
||
if result_data is None:
|
||
raise HTTPException(status_code=502, detail="PPTX parse failed")
|
||
else:
|
||
raise HTTPException(
|
||
status_code=400, detail="Unsupported file type. Only PDF, DOCX, TXT, and MD are supported."
|
||
)
|
||
|
||
return JSONResponse({"code": 200, "message": "ok", "data": result_data})
|
||
|
||
except requests.RequestException as e:
|
||
logger.error(f"Split service request failed: {e}")
|
||
raise HTTPException(status_code=502, detail=f"Split service error: {str(e)}")
|
||
except Exception as e:
|
||
logger.error(f"Internal error: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||
finally:
|
||
# 清理临时文件
|
||
for path in [temp_input_path, converted_pdf_path]:
|
||
if path and path.exists():
|
||
try:
|
||
path.unlink()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to delete temp file {path}: {e}")
|
||
|
||
@app.get("/api/health")
|
||
async def health_check():
|
||
"""健康检查接口"""
|
||
return {
|
||
"status": "healthy",
|
||
"message": "服务正常运行,每次请求时创建新的Agent实例"
|
||
}
|
||
|
||
@app.get("/")
|
||
async def root():
|
||
"""根路径"""
|
||
return {
|
||
"message": "Agent API 服务",
|
||
"docs": "/docs"
|
||
}
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
|
||
uvicorn.run(app, host="0.0.0.0", port=9090)
|