Files
2026-06-08 18:14:59 +08:00

1841 lines
70 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文件管理服务
"""
import base64
import mimetypes
import os
from typing import Optional, List, Tuple
from sqlalchemy import select, func, update, case, literal_column, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from utils.context import get_current_user_id_from_context
from core.file_manager.model import FileManager
from core.file_manager.schema import FileManagerCreate, FileManagerUpdate
from core.file_manager.storage_backends import get_storage_backend, LocalStorageBackend, MinioStorageBackend
class FileManagerService(BaseService[FileManager, FileManagerCreate, FileManagerUpdate]):
"""文件管理服务"""
model = FileManager
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
parent_id: Optional[str] = None,
name: Optional[str] = None,
type: Optional[str] = None,
storage_type: Optional[str] = None,
file_ext: Optional[str] = None,
is_public: Optional[bool] = None,
creator_id: Optional[str] = None,
is_superuser: bool = False,
) -> Tuple[List[FileManager], int]:
"""获取文件列表
权限规则:
- 超管(is_superuser=True):看到所有文件
- 普通用户:只看到自己创建的 + 公共的 + 系统文件夹
"""
# 构建查询条件
conditions = [cls.model.is_deleted == False] # noqa: E712
# 父文件夹过滤
if parent_id is None:
conditions.append(cls.model.parent_id == None) # noqa: E711
else:
conditions.append(cls.model.parent_id == parent_id)
if name:
conditions.append(cls.model.name.ilike(f"%{name}%"))
if type:
conditions.append(cls.model.type == type)
if storage_type:
conditions.append(cls.model.storage_type == storage_type)
if file_ext:
conditions.append(cls.model.file_ext == file_ext)
if is_public is not None:
conditions.append(cls.model.is_public == is_public)
# 权限过滤:普通用户只看自己的 + 公共的 + 系统文件夹
if not is_superuser and creator_id:
conditions.append(
or_(
cls.model.sys_creator_id == creator_id,
cls.model.is_public == True, # noqa: E712
cls.model.is_system == True, # noqa: E712
)
)
# 查询总数
count_query = select(func.count(cls.model.id)).where(*conditions)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 查询数据(文件夹排在前面,新建的排最前)
offset = (page - 1) * page_size
type_order = case(
(cls.model.type == 'folder', 0),
else_=1
)
query = (
select(cls.model)
.where(*conditions)
.order_by(type_order, cls.model.sys_create_datetime.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(query)
items = result.scalars().all()
return items, total
@classmethod
async def get_folder_tree(
cls,
db: AsyncSession,
creator_id: Optional[str] = None,
is_superuser: bool = False,
) -> List[FileManager]:
"""获取文件夹树结构
权限规则:
- 超管:看到所有文件夹
- 普通用户:只看到自己创建的 + 公共的 + 系统文件夹
"""
conditions = [
cls.model.type == 'folder',
cls.model.is_deleted == False, # noqa: E712
]
if not is_superuser and creator_id:
conditions.append(
or_(
cls.model.sys_creator_id == creator_id,
cls.model.is_public == True, # noqa: E712
cls.model.is_system == True, # noqa: E712
)
)
query = (
select(cls.model)
.where(*conditions)
.order_by(cls.model.name)
)
result = await db.execute(query)
return result.scalars().all()
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tiff', '.heic', '.heif'}
@classmethod
async def get_recent_images(
cls,
db: AsyncSession,
creator_id: str,
limit: int = 20,
) -> List[FileManager]:
"""获取当前用户最近上传的图片文件(跨所有文件夹,按时间倒序)"""
query = (
select(cls.model)
.where(
cls.model.type == 'file',
cls.model.is_deleted == False, # noqa: E712
cls.model.file_ext.in_(cls.IMAGE_EXTENSIONS),
cls.model.sys_creator_id == creator_id,
)
.order_by(cls.model.sys_create_datetime.desc())
.limit(limit)
)
result = await db.execute(query)
return list(result.scalars().all())
@classmethod
async def get_recent_files(
cls,
db: AsyncSession,
creator_id: str,
limit: int = 20,
) -> List[FileManager]:
"""获取当前用户最近上传的文件(跨所有文件夹,按时间倒序)"""
query = (
select(cls.model)
.where(
cls.model.type == 'file',
cls.model.is_deleted == False, # noqa: E712
cls.model.sys_creator_id == creator_id,
)
.order_by(cls.model.sys_create_datetime.desc())
.limit(limit)
)
result = await db.execute(query)
return list(result.scalars().all())
@classmethod
async def create_folder(
cls,
db: AsyncSession,
name: str,
parent_id: Optional[str] = None,
creator_id: Optional[str] = None,
) -> Optional[FileManager]:
"""创建文件夹"""
if not creator_id:
creator_id = get_current_user_id_from_context()
# 获取父文件夹路径
parent_path = ''
if parent_id:
parent = await cls.get_by_id(db, parent_id)
if parent and parent.type == 'folder':
parent_path = parent.path
# 构建文件夹路径
folder_path = os.path.join(parent_path, name).replace('\\', '/') if parent_path else name
# 检查同名文件夹
existing = await db.execute(
select(cls.model).where(
cls.model.parent_id == parent_id,
cls.model.name == name,
cls.model.type == 'folder',
cls.model.is_deleted == False # noqa: E712
)
)
if existing.scalar_one_or_none():
return None # 同名文件夹已存在
# 创建文件夹
folder = FileManager(
name=name,
type='folder',
parent_id=parent_id,
path=folder_path,
storage_path='',
sys_creator_id=creator_id,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
return folder
# 来源模块 → 系统文件夹名称映射
SOURCE_LABELS = {
'announcement': '公告附件',
'workflow': '工作流附件',
'chat': '聊天文件',
'form': '表单附件',
'avatar': '头像',
'ai_platform': 'AI平台文件',
'screen_design': '大屏设计素材',
}
SYSTEM_ROOT_NAME = 'SystemFile'
@classmethod
async def get_or_create_source_folder(
cls,
db: AsyncSession,
source: str,
) -> str:
"""根据 source 自动获取或创建 SystemFile/模块文件夹/年-月 三级文件夹,返回日期子文件夹 ID"""
from datetime import datetime
folder_name = cls.SOURCE_LABELS.get(source, source)
# 1. 获取或创建根级 SystemFile 文件夹
result = await db.execute(
select(cls.model).where(
cls.model.parent_id == None, # noqa: E711
cls.model.name == cls.SYSTEM_ROOT_NAME,
cls.model.type == 'folder',
cls.model.is_system == True, # noqa: E712
cls.model.is_deleted == False, # noqa: E712
)
)
system_root = result.scalar_one_or_none()
if not system_root:
system_root = FileManager(
name=cls.SYSTEM_ROOT_NAME,
type='folder',
parent_id=None,
path=cls.SYSTEM_ROOT_NAME,
storage_path='',
is_system=True,
source='system',
)
db.add(system_root)
await db.flush()
# 2. 获取或创建模块级文件夹(SystemFile 下)
module_path = f"{cls.SYSTEM_ROOT_NAME}/{folder_name}"
result = await db.execute(
select(cls.model).where(
cls.model.parent_id == system_root.id,
cls.model.name == folder_name,
cls.model.type == 'folder',
cls.model.is_system == True, # noqa: E712
cls.model.is_deleted == False, # noqa: E712
)
)
module_folder = result.scalar_one_or_none()
if not module_folder:
module_folder = FileManager(
name=folder_name,
type='folder',
parent_id=system_root.id,
path=module_path,
storage_path='',
is_system=True,
source=source,
)
db.add(module_folder)
await db.flush()
# 3. 获取或创建日期子文件夹(年-月)
date_name = datetime.now().strftime('%Y-%m')
date_path = f"{module_path}/{date_name}"
result = await db.execute(
select(cls.model).where(
cls.model.parent_id == module_folder.id,
cls.model.name == date_name,
cls.model.type == 'folder',
cls.model.is_deleted == False, # noqa: E712
)
)
date_folder = result.scalar_one_or_none()
if not date_folder:
date_folder = FileManager(
name=date_name,
type='folder',
parent_id=module_folder.id,
path=date_path,
storage_path='',
is_system=True,
source=source,
)
db.add(date_folder)
await db.flush()
return date_folder.id
@classmethod
async def upload_file(
cls,
db: AsyncSession,
file_content: bytes,
filename: str,
file_size: int,
parent_id: Optional[str] = None,
is_public: bool = False,
creator_id: Optional[str] = None,
source: Optional[str] = None,
) -> FileManager:
"""上传文件"""
if not creator_id:
creator_id = get_current_user_id_from_context()
# 如果有 source 且没有指定 parent_id,自动归入系统文件夹
if source and not parent_id:
parent_id = await cls.get_or_create_source_folder(db, source)
# 获取父文件夹路径
folder_path = ''
if parent_id:
parent = await cls.get_by_id(db, parent_id)
if parent and parent.type == 'folder':
folder_path = parent.path
# 获取存储后端
storage = get_storage_backend()
# 计算文件信息
file_ext = os.path.splitext(filename)[1].lower()
mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
# 创建文件对象用于保存
import io
file_obj = io.BytesIO(file_content)
# 计算MD5
md5 = storage.calculate_md5(file_obj)
file_obj.seek(0)
# 检查是否已存在相同文件(MD5 + 大小双重校验)
existing = await db.execute(
select(cls.model).where(
cls.model.md5 == md5,
cls.model.size == file_size,
cls.model.type == 'file',
cls.model.is_deleted == False, # noqa: E712
).limit(1)
)
existing_file = existing.scalar_one_or_none()
if existing_file:
# 复用已有文件的存储路径,不重复保存
storage_path = existing_file.storage_path
url = existing_file.url
import logging
logging.getLogger(__name__).info(f"文件 MD5 重复,复用已有存储路径: {storage_path}")
else:
# 新文件,保存到存储后端
storage_path, url = storage.save(file_obj, filename, folder_path)
import logging
logging.getLogger(__name__).info(f"新文件保存到: {storage_path}")
# 构建完整路径
full_path = os.path.join(folder_path, filename).replace('\\', '/') if folder_path else filename
# 创建数据库记录(即使文件内容相同,也创建独立记录,可能在不同文件夹、不同文件名)
file_record = FileManager(
name=filename,
type='file',
parent_id=parent_id,
path=full_path,
size=file_size,
file_ext=file_ext,
mime_type=mime_type,
storage_type=storage.__class__.__name__.replace('StorageBackend', '').lower(),
storage_path=storage_path,
url=url,
md5=md5,
is_public=is_public,
source=source,
sys_creator_id=creator_id,
)
db.add(file_record)
await db.commit()
await db.refresh(file_record)
return file_record
@classmethod
async def rename_item(
cls,
db: AsyncSession,
item_id: str,
new_name: str,
creator_id: Optional[str] = None,
is_superuser: bool = False,
) -> Optional[FileManager]:
"""重命名文件/文件夹
权限规则:普通用户只能重命名自己创建的文件/文件夹
"""
item = await cls.get_by_id(db, item_id)
if not item:
return None
# 系统文件夹不允许重命名
if item.is_system:
return None
# 权限校验:普通用户只能操作自己的文件
if not is_superuser and creator_id and item.sys_creator_id != creator_id:
return None
# 检查同级目录下是否有同名文件
existing = await db.execute(
select(cls.model).where(
cls.model.parent_id == item.parent_id,
cls.model.name == new_name,
cls.model.type == item.type,
cls.model.id != item_id,
cls.model.is_deleted == False # noqa: E712
)
)
if existing.scalar_one_or_none():
return None # 同名文件/文件夹已存在
# 更新名称和路径
old_path = item.path
if item.parent_id:
parent = await cls.get_by_id(db, item.parent_id)
new_path = os.path.join(parent.path, new_name).replace('\\', '/') if parent else new_name
else:
new_path = new_name
item.name = new_name
item.path = new_path
modifier_id = get_current_user_id_from_context()
if modifier_id:
item.sys_modifier_id = modifier_id
await db.commit()
await db.refresh(item)
# 如果是文件夹,递归更新子项路径
if item.type == 'folder':
await cls._update_children_paths(db, item.id, old_path, new_path)
return item
@classmethod
async def move_items(
cls,
db: AsyncSession,
item_ids: List[str],
target_folder_id: Optional[str] = None,
creator_id: Optional[str] = None,
is_superuser: bool = False,
) -> bool:
"""移动文件/文件夹
权限规则:普通用户只能移动自己创建的文件/文件夹
"""
# 获取目标文件夹
target_path = ''
if target_folder_id:
target_folder = await cls.get_by_id(db, target_folder_id)
if not target_folder or target_folder.type != 'folder':
return False
target_path = target_folder.path
for item_id in item_ids:
item = await cls.get_by_id(db, item_id)
if not item:
continue
# 权限校验:普通用户只能操作自己的文件
if not is_superuser and creator_id and item.sys_creator_id != creator_id:
continue
# 不能移动到自己或子文件夹
if item.type == 'folder' and target_folder_id:
if await cls._is_subfolder(db, target_folder_id, item.id):
continue
# 检查目标文件夹是否有同名文件
existing = await db.execute(
select(cls.model).where(
cls.model.parent_id == target_folder_id,
cls.model.name == item.name,
cls.model.type == item.type,
cls.model.id != item_id,
cls.model.is_deleted == False # noqa: E712
)
)
if existing.scalar_one_or_none():
continue
# 更新父文件夹和路径
old_path = item.path
item.parent_id = target_folder_id
item.path = os.path.join(target_path, item.name).replace('\\', '/') if target_path else item.name
modifier_id = get_current_user_id_from_context()
if modifier_id:
item.sys_modifier_id = modifier_id
# 如果是文件夹,递归更新子项路径
if item.type == 'folder':
await cls._update_children_paths(db, item.id, old_path, item.path)
await db.commit()
return True
@classmethod
async def delete_item(
cls,
db: AsyncSession,
item_id: str,
hard: bool = False,
creator_id: Optional[str] = None,
is_superuser: bool = False,
) -> bool:
"""删除文件/文件夹(默认软删除)
权限规则:普通用户只能删除自己创建的文件/文件夹
"""
item = await cls.get_by_id(db, item_id)
if not item:
return False
# 系统文件夹不允许删除
if item.is_system:
return False
# 权限校验:普通用户只能操作自己的文件
if not is_superuser and creator_id and item.sys_creator_id != creator_id:
return False
# 如果是文件,检查是否有其他记录引用同一存储路径,没有才删除物理文件
if item.type == 'file' and item.storage_path:
ref_count = await db.execute(
select(func.count(cls.model.id)).where(
cls.model.storage_path == item.storage_path,
cls.model.id != item.id,
cls.model.type == 'file',
cls.model.is_deleted == False, # noqa: E712
)
)
if (ref_count.scalar() or 0) == 0:
storage = get_storage_backend()
storage.delete(item.storage_path)
# 递归删除子项
if item.type == 'folder':
await cls._delete_children(db, item.id, hard)
# 删除数据库记录
if hard:
await db.delete(item)
else:
item.is_deleted = True
await db.commit()
return True
@classmethod
async def batch_delete(
cls,
db: AsyncSession,
item_ids: List[str],
hard: bool = False,
creator_id: Optional[str] = None,
is_superuser: bool = False,
) -> int:
"""批量删除文件/文件夹"""
deleted_count = 0
for item_id in item_ids:
if await cls.delete_item(db, item_id, hard, creator_id=creator_id, is_superuser=is_superuser):
deleted_count += 1
return deleted_count
@classmethod
async def get_by_storage_path(
cls,
db: AsyncSession,
storage_path: str,
) -> Optional[FileManager]:
"""通过存储路径获取文件"""
result = await db.execute(
select(cls.model).where(
cls.model.storage_path == storage_path,
cls.model.type == 'file',
cls.model.is_deleted == False # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def increment_download_count(
cls,
db: AsyncSession,
item_id: str,
) -> None:
"""增加下载次数"""
item = await cls.get_by_id(db, item_id)
if item:
item.download_count += 1
await db.commit()
@classmethod
async def get_by_md5(
cls,
db: AsyncSession,
md5: str,
size: int,
) -> Optional[FileManager]:
"""通过MD5和大小查找文件(用于秒传)"""
result = await db.execute(
select(cls.model).where(
cls.model.md5 == md5,
cls.model.size == size,
cls.model.is_deleted == False # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def has_children(cls, db: AsyncSession, folder_id: str) -> bool:
"""检查文件夹是否有子项"""
result = await db.execute(
select(func.count(cls.model.id)).where(
cls.model.parent_id == folder_id,
cls.model.is_deleted == False # noqa: E712
)
)
count = result.scalar() or 0
return count > 0
@classmethod
async def batch_has_children(cls, db: AsyncSession, folder_ids: List[str]) -> dict:
"""批量检查文件夹是否有子项,返回 {folder_id: bool}"""
if not folder_ids:
return {}
result = await db.execute(
select(
cls.model.parent_id,
func.count(cls.model.id).label('cnt')
).where(
cls.model.parent_id.in_(folder_ids),
cls.model.is_deleted == False # noqa: E712
).group_by(cls.model.parent_id)
)
counts = {row[0]: row[1] > 0 for row in result.all()}
return {fid: counts.get(fid, False) for fid in folder_ids}
@classmethod
async def batch_has_sub_folders(cls, db: AsyncSession, folder_ids: List[str]) -> dict:
"""批量检查文件夹是否有子文件夹(不含文件),返回 {folder_id: bool}"""
if not folder_ids:
return {}
result = await db.execute(
select(
cls.model.parent_id,
func.count(cls.model.id).label('cnt')
).where(
cls.model.parent_id.in_(folder_ids),
cls.model.type == 'folder',
cls.model.is_deleted == False # noqa: E712
).group_by(cls.model.parent_id)
)
counts = {row[0]: row[1] > 0 for row in result.all()}
return {fid: counts.get(fid, False) for fid in folder_ids}
@classmethod
async def batch_get_names(cls, db: AsyncSession, item_ids: List[str]) -> dict:
"""批量获取文件/文件夹名称,返回 {id: name}"""
if not item_ids:
return {}
result = await db.execute(
select(cls.model.id, cls.model.name).where(
cls.model.id.in_(item_ids),
cls.model.is_deleted == False # noqa: E712
)
)
return {row[0]: row[1] for row in result.all()}
@classmethod
async def get_parent(cls, db: AsyncSession, item_id: str) -> Optional[FileManager]:
"""获取父文件夹"""
item = await cls.get_by_id(db, item_id)
if item and item.parent_id:
return await cls.get_by_id(db, item.parent_id)
return None
@classmethod
async def _is_subfolder(cls, db: AsyncSession, folder_id: str, potential_parent_id: str) -> bool:
"""检查folder是否是potential_parent的子文件夹(使用path前缀匹配)"""
folder = await cls.get_by_id(db, folder_id)
parent = await cls.get_by_id(db, potential_parent_id)
if not folder or not parent:
return False
# 如果目标文件夹的path以潜在父文件夹的path为前缀,则是子文件夹
parent_prefix = parent.path + '/'
return folder.path.startswith(parent_prefix) or folder.id == potential_parent_id
@classmethod
async def _update_children_paths(cls, db: AsyncSession, folder_id: str, old_path: str, new_path: str) -> None:
"""批量更新所有后代路径(使用path前缀匹配,一条SQL搞定)"""
old_prefix = old_path + '/'
new_prefix = new_path + '/'
# 使用 LIKE 前缀匹配找到所有后代,批量替换路径前缀
stmt = (
update(cls.model)
.where(
cls.model.path.like(f"{old_prefix}%"),
cls.model.is_deleted == False # noqa: E712
)
.values(
path=func.concat(new_prefix, func.substr(cls.model.path, len(old_prefix) + 1))
)
)
await db.execute(stmt)
@classmethod
async def _delete_children(cls, db: AsyncSession, folder_id: str, hard: bool = True) -> None:
"""批量删除所有后代(使用path前缀匹配查找所有后代)"""
# 先获取当前文件夹的path
folder = await cls.get_by_id(db, folder_id)
if not folder:
return
folder_prefix = folder.path + '/'
# 一次性查出所有后代文件(用于删除存储文件)
result = await db.execute(
select(cls.model).where(
cls.model.path.like(f"{folder_prefix}%"),
cls.model.type == 'file',
cls.model.is_deleted == False # noqa: E712
)
)
file_children = result.scalars().all()
# 删除存储文件
if file_children:
storage = get_storage_backend()
for child in file_children:
storage.delete(child.storage_path)
# 批量更新/删除所有后代的数据库记录
if hard:
from sqlalchemy import delete as sql_delete
stmt = sql_delete(cls.model).where(
cls.model.path.like(f"{folder_prefix}%"),
cls.model.is_deleted == False # noqa: E712
)
await db.execute(stmt)
else:
stmt = (
update(cls.model)
.where(
cls.model.path.like(f"{folder_prefix}%"),
cls.model.is_deleted == False # noqa: E712
)
.values(is_deleted=True)
)
await db.execute(stmt)
# 默认最大允许全量读取的文件大小(200MB)
MAX_FILE_CONTENT_SIZE = 200 * 1024 * 1024
@classmethod
async def get_file_content(cls, db: AsyncSession, file_id: str, max_size: int = None) -> Optional[bytes]:
"""
获取文件内容(字节)
Args:
db: 数据库会话
file_id: 文件ID
max_size: 最大允许读取的文件大小(字节),超过则返回 None,默认200MB
Returns:
文件内容字节,如果文件不存在或超过大小限制则返回 None
"""
file_obj = await cls.get_by_id(db, file_id)
if not file_obj or file_obj.type != 'file':
return None
# 大文件保护
limit = max_size or cls.MAX_FILE_CONTENT_SIZE
if file_obj.size and file_obj.size > limit:
import logging
logging.warning(f"File {file_id} ({file_obj.name}) size {file_obj.size} exceeds max_size {limit}, skipping full read")
return None
storage = get_storage_backend()
try:
if isinstance(storage, LocalStorageBackend):
# 本地存储:直接读取文件
full_path = storage.get_full_path(file_obj.storage_path)
if os.path.exists(full_path):
with open(full_path, 'rb') as f:
return f.read()
elif isinstance(storage, MinioStorageBackend):
# Minio 存储:通过 API 获取
response = storage.get_file_content(file_obj.storage_path)
content = response.read()
response.close()
response.release_conn()
return content
else:
# 其他存储后端(OSS、Azure):通过 URL 下载
# 这里可以根据需要扩展
import httpx
url = file_obj.url
if url:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
if resp.status_code == 200:
return resp.content
except Exception as e:
import logging
logging.error(f"Failed to get file content for {file_id}: {e}")
return None
@classmethod
async def get_file_as_base64(cls, db: AsyncSession, file_id: str) -> Optional[str]:
"""
获取文件内容的 base64 编码
Args:
db: 数据库会话
file_id: 文件ID
Returns:
base64 编码的文件内容,如果文件不存在则返回 None
"""
content = await cls.get_file_content(db, file_id)
if content:
return base64.b64encode(content).decode('utf-8')
return None
@classmethod
async def get_file_as_data_url(cls, db: AsyncSession, file_id: str) -> Optional[str]:
"""
获取文件的 Data URL(用于 LLM 多模态输入)
格式: data:<mime_type>;base64,<base64_content>
Args:
db: 数据库会话
file_id: 文件ID
Returns:
Data URL 格式的文件内容,如果文件不存在则返回 None
"""
file_obj = await cls.get_by_id(db, file_id)
if not file_obj or file_obj.type != 'file':
return None
content = await cls.get_file_content(db, file_id)
if content:
base64_content = base64.b64encode(content).decode('utf-8')
mime_type = file_obj.mime_type or 'application/octet-stream'
return f"data:{mime_type};base64,{base64_content}"
return None
# 支持提取文本内容的文件扩展名
TEXT_EXTRACTABLE_EXTENSIONS = {
# 纯文本
'.txt', '.md', '.markdown', '.rst', '.log',
# 代码文件
'.py', '.js', '.ts', '.jsx', '.tsx', '.vue', '.html', '.css', '.scss', '.less',
'.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.go', '.rs', '.rb', '.php',
'.swift', '.kt', '.scala', '.r', '.sql', '.sh', '.bash', '.zsh', '.ps1',
'.yaml', '.yml', '.json', '.xml', '.toml', '.ini', '.cfg', '.conf',
# 数据文件
'.csv',
}
@classmethod
async def get_file_text_content(
cls,
db: AsyncSession,
file_id: str,
max_size: int = 100 * 1024, # 默认最大 100KB
enable_ocr: bool = False, # 是否启用 OCR(图片识别、扫描版 PDF)
) -> Optional[str]:
"""
获取文件的文本内容(用于 LLM 处理)
支持的文件类型:
- 纯文本文件(txt, md, log 等)
- 代码文件(py, js, ts, java 等)
- 配置文件(json, yaml, xml 等)
- CSV 文件
- PDF 文件(需要 pypdf 库)
- Word 文档(需要 python-docx 库)
- 图片文件(需要 enable_ocr=True,通过视觉模型 OCR 识别)
- 扫描版 PDF(需要 enable_ocr=True,自动 fallback 到 OCR
Args:
db: 数据库会话
file_id: 文件ID
max_size: 最大读取大小(字节),超过则截断
enable_ocr: 是否启用 OCR 识别(图片和扫描版 PDF)
Returns:
文件文本内容,如果无法提取则返回 None
"""
file_obj = await cls.get_by_id(db, file_id)
if not file_obj or file_obj.type != 'file':
return None
file_ext = (file_obj.file_ext or '').lower()
mime_type = file_obj.mime_type or ''
# 获取文件内容
content = await cls.get_file_content(db, file_id)
if not content:
return None
try:
# 纯文本和代码文件
if file_ext in cls.TEXT_EXTRACTABLE_EXTENSIONS or mime_type.startswith('text/'):
text = content.decode('utf-8', errors='ignore')
if len(text) > max_size:
text = text[:max_size] + f"\n\n... [内容已截断,原文件大小: {len(content)} 字节]"
return text
# PDF 文件
if file_ext == '.pdf' or mime_type == 'application/pdf':
try:
import io
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
text_parts = []
total_length = 0
for page in reader.pages:
page_text = page.extract_text() or ''
if total_length + len(page_text) > max_size:
text_parts.append(page_text[:max_size - total_length])
text_parts.append(f"\n\n... [内容已截断,共 {len(reader.pages)} 页]")
break
text_parts.append(page_text)
total_length += len(page_text)
extracted_text = '\n'.join(text_parts).strip()
# 检查是否为扫描版 PDF(提取的文本为空或几乎为空)
if len(extracted_text) < 10:
if enable_ocr:
# 扫描版 PDF:通过 OCR 识别
import logging
logging.info(f"PDF OCR: 扫描版 PDF {file_obj.name},尝试 OCR 识别 ({len(reader.pages)} 页)")
ocr_text = await cls._ocr_pdf_pages(db, content, file_obj.name, max_size)
if ocr_text:
return ocr_text
return f"[PDF 文件: {file_obj.name},共 {len(reader.pages)} 页]\n\n注意:该 PDF 文件可能是扫描版(图片格式),无法直接提取文本内容。"
return extracted_text
except ImportError:
# 返回提示信息,告知需要安装库
return f"[PDF 文件: {file_obj.name}]\n注意:需要安装 pypdf 库才能提取 PDF 内容。\n安装命令: pip install pypdf"
except Exception as e:
# 返回错误信息
import logging
logging.error(f"Failed to extract PDF content from {file_obj.name}: {e}")
return f"[PDF 文件: {file_obj.name}]\n提取内容时出错: {str(e)}"
# Word 文档
if file_ext in ('.docx', '.doc') or mime_type in (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword'
):
try:
import io
from docx import Document
doc = Document(io.BytesIO(content))
text_parts = []
total_length = 0
for para in doc.paragraphs:
para_text = para.text
if total_length + len(para_text) > max_size:
text_parts.append(para_text[:max_size - total_length])
text_parts.append("\n\n... [内容已截断]")
break
text_parts.append(para_text)
total_length += len(para_text)
return '\n'.join(text_parts)
except ImportError:
return f"[Word 文档: {file_obj.name},需要安装 python-docx 库才能提取内容]"
except Exception as e:
return f"[Word 文档: {file_obj.name},提取内容失败: {str(e)}]"
# Excel 文件
if file_ext in ('.xlsx', '.xls') or mime_type in (
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel'
):
try:
import io
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
ws = wb.active
text_parts = []
total_length = 0
for row in ws.iter_rows(values_only=True):
row_text = ','.join([str(cell) if cell is not None else '' for cell in row])
if total_length + len(row_text) > max_size:
text_parts.append(row_text[:max_size - total_length])
text_parts.append(f"\n\n... [内容已截断,原表格共 {ws.max_row} 行]")
break
text_parts.append(row_text)
total_length += len(row_text)
wb.close()
return '\n'.join(text_parts)
except ImportError:
return f"[Excel 文件: {file_obj.name}]\n注意:需要安装 openpyxl 库才能提取 Excel 内容。\n安装命令: pip install openpyxl"
except Exception as e:
import logging
logging.error(f"Failed to extract Excel content from {file_obj.name}: {e}")
return f"[Excel 文件: {file_obj.name}]\n提取内容时出错: {str(e)}"
# PowerPoint 文件
if file_ext in ('.pptx', '.ppt') or mime_type in (
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-powerpoint'
):
try:
import io
from pptx import Presentation
prs = Presentation(io.BytesIO(content))
text_parts = []
total_length = 0
for slide_num, slide in enumerate(prs.slides, 1):
slide_text = f"--- 幻灯片 {slide_num} ---\n"
for shape in slide.shapes:
if hasattr(shape, "text") and shape.text:
slide_text += shape.text + "\n"
if total_length + len(slide_text) > max_size:
text_parts.append(slide_text[:max_size - total_length])
text_parts.append(f"\n\n... [内容已截断,共 {len(prs.slides)} 页幻灯片]")
break
text_parts.append(slide_text)
total_length += len(slide_text)
return '\n'.join(text_parts)
except ImportError:
return f"[PowerPoint 文件: {file_obj.name}]\n注意:需要安装 python-pptx 库才能提取 PPT 内容。\n安装命令: pip install python-pptx"
except Exception as e:
import logging
logging.error(f"Failed to extract PowerPoint content from {file_obj.name}: {e}")
return f"[PowerPoint 文件: {file_obj.name}]\n提取内容时出错: {str(e)}"
# 图片文件:通过 OCR 识别
if enable_ocr and (file_ext in cls.IMAGE_EXTENSIONS or mime_type.startswith('image/')):
import logging
logging.info(f"Image OCR: 识别图片 {file_obj.name}")
ocr_text = await cls._ocr_from_image_bytes(db, content, mime_type or 'image/png')
return ocr_text
# 不支持的文件类型
return None
except Exception as e:
import logging
logging.error(f"Failed to extract text from file {file_id}: {e}")
return None
@classmethod
async def recognize_image_with_ocr(
cls,
db: AsyncSession,
file_id: str,
prompt: str = "请识别并提取图片中的所有文字内容,保持原有格式和结构。",
) -> Optional[str]:
"""
使用阿里云 qwen-vl-ocr 模型识别图片中的文字内容
用于非多模态模型处理图片附件时,先通过 OCR 提取图片中的文字
Args:
db: 数据库会话
file_id: 文件ID
prompt: OCR 识别的提示词
Returns:
识别出的文字内容,如果识别失败则返回 None
"""
import logging
import os
# 获取文件信息
file_obj = await cls.get_by_id(db, file_id)
if not file_obj or file_obj.type != 'file':
logging.warning(f"OCR: File not found or not a file: {file_id}")
return None
# 检查是否为图片
mime_type = file_obj.mime_type or ''
if not mime_type.startswith('image/'):
logging.warning(f"OCR: File is not an image: {file_obj.name}, mime_type={mime_type}")
return None
# 获取文件内容并转换为 base64
content = await cls.get_file_content(db, file_id)
if not content:
logging.warning(f"OCR: Failed to get file content for file: {file_id}")
return None
# 转换为 base64 字符串(qwen-vl-ocr 需要 data URL 格式)
import base64
base64_str = base64.b64encode(content).decode('utf-8')
mime_type = file_obj.mime_type or 'image/png'
base64_data_url = f"data:{mime_type};base64,{base64_str}"
try:
from openai import AsyncOpenAI
# 通过统一辅助方法获取 API 配置(数据库 qwen provider 优先,env 兜底)
ocr_config = await cls._get_ocr_client(db)
if not ocr_config:
return f"[图片: {file_obj.name}]\n注意:未配置阿里云 API Key,无法进行 OCR 识别。"
api_key, api_base, _ = ocr_config
client = AsyncOpenAI(
api_key=api_key,
base_url=api_base,
)
logging.info(f"OCR: Recognizing image {file_obj.name} with qwen-vl-ocr")
response = await client.chat.completions.create(
model="qwen-vl-ocr-latest",
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": base64_data_url},
},
{"type": "text", "text": prompt}
]
}
]
)
if response.choices and response.choices[0].message.content:
ocr_text = response.choices[0].message.content
logging.info(f"OCR: Successfully recognized {len(ocr_text)} characters from {file_obj.name}")
return ocr_text
else:
logging.warning(f"OCR: No content in response for {file_obj.name}")
return None
except ImportError:
logging.error("OCR: openai library not installed")
return f"[图片: {file_obj.name}]\n注意:需要安装 openai 库才能进行 OCR 识别。"
except Exception as e:
logging.error(f"OCR: Failed to recognize image {file_obj.name}: {e}")
return f"[图片: {file_obj.name}]\nOCR 识别失败: {str(e)}"
@classmethod
async def _get_ocr_client(cls, db: AsyncSession):
"""
获取 OCR 所需的 API 客户端配置
优先级:
1. 数据库中 qwen 类型提供商的 API Key
2. 环境变量 / settings 中的 DASHSCOPE_API_KEY(兜底)
Returns:
(api_key, api_base, model_name) 或 None
"""
import logging
from app.config import settings
api_key = None
api_base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
model_name = "qwen-vl-ocr-latest"
# 1. 优先从数据库获取 qwen 提供商的 API Key
try:
from sqlalchemy import select as sa_select
from ai_platform.models.provider import LLMProvider
provider_result = await db.execute(
sa_select(LLMProvider).where(
LLMProvider.provider_type == 'qwen',
LLMProvider.is_active == True,
LLMProvider.is_deleted == False
)
)
provider = provider_result.scalar_one_or_none()
if provider and provider.api_key:
api_key = provider.api_key
if provider.api_base:
api_base = provider.api_base
logging.info(f"OCR: Using API key from database provider: {provider.name}")
except Exception as e:
logging.debug(f"OCR: Failed to get provider from database: {e}")
# 2. 兜底:从 settings 获取(settings 已自动从环境变量读取)
if not api_key:
api_key = getattr(settings, 'DASHSCOPE_API_KEY', None)
if not api_key:
logging.warning("OCR: No API key available for OCR")
return None
return api_key, api_base, model_name
@classmethod
async def _ocr_from_image_bytes(
cls,
db: AsyncSession,
image_bytes: bytes,
mime_type: str = 'image/png',
prompt: str = "请识别并提取图片中的所有文字内容,保持原有格式和结构。",
) -> Optional[str]:
"""
从图片字节数据中 OCR 提取文字
Args:
db: 数据库会话
image_bytes: 图片文件字节
mime_type: 图片 MIME 类型
prompt: OCR 提示词
Returns:
识别出的文字内容
"""
import logging
config = await cls._get_ocr_client(db)
if not config:
return None
api_key, api_base, model_name = config
try:
from openai import AsyncOpenAI
base64_str = base64.b64encode(image_bytes).decode('utf-8')
data_url = f"data:{mime_type};base64,{base64_str}"
client = AsyncOpenAI(api_key=api_key, base_url=api_base)
response = await client.chat.completions.create(
model=model_name,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
]
}]
)
if response.choices and response.choices[0].message.content:
text = response.choices[0].message.content
logging.info(f"OCR: 识别到 {len(text)} 个字符")
return text
return None
except ImportError:
logging.error("OCR: openai library not installed")
return None
except Exception as e:
logging.error(f"OCR: 图片识别失败: {e}")
return None
@classmethod
async def _ocr_pdf_pages(
cls,
db: AsyncSession,
pdf_bytes: bytes,
filename: str,
max_size: int = 100 * 1024,
max_pages: int = 20,
) -> Optional[str]:
"""
对扫描版 PDF 逐页进行 OCR 识别
尝试两种策略:
1. 使用 pypdf 提取页面中嵌入的图片
2. 使用 pdf2image 将页面渲染为图片(需要 poppler)
Args:
db: 数据库会话
pdf_bytes: PDF 文件字节
filename: 文件名(用于日志)
max_size: 最大文本大小
max_pages: 最大处理页数
Returns:
OCR 识别的文本内容
"""
import io
import logging
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(pdf_bytes))
total_pages = len(reader.pages)
pages_to_process = min(total_pages, max_pages)
text_parts = []
total_length = 0
for page_idx in range(pages_to_process):
page = reader.pages[page_idx]
# 尝试从页面提取嵌入图片
page_image = cls._extract_largest_image_from_page(page)
if not page_image:
continue
img_bytes, img_mime = page_image
ocr_text = await cls._ocr_from_image_bytes(
db, img_bytes, img_mime,
prompt=f"请识别并提取这张图片(PDF第{page_idx + 1}页)中的所有文字内容,保持原有格式和结构。",
)
if ocr_text:
page_header = f"--- 第 {page_idx + 1} 页 ---\n"
page_content = page_header + ocr_text
if total_length + len(page_content) > max_size:
text_parts.append(page_content[:max_size - total_length])
text_parts.append(f"\n\n... [内容已截断,共 {total_pages} 页,已处理 {page_idx + 1} 页]")
break
text_parts.append(page_content)
total_length += len(page_content)
if text_parts:
result = '\n\n'.join(text_parts)
logging.info(f"PDF OCR: {filename} 识别完成,{len(text_parts)} 页,{len(result)} 字符")
return result
# 如果 pypdf 无法提取图片,尝试 pdf2image
return await cls._ocr_pdf_with_pdf2image(db, pdf_bytes, filename, max_size, max_pages)
except Exception as e:
logging.error(f"PDF OCR: {filename} 处理失败: {e}")
return None
@classmethod
def _extract_largest_image_from_page(cls, page) -> Optional[tuple]:
"""
从 PDF 页面中提取最大的嵌入图片
Returns:
(image_bytes, mime_type) 或 None
"""
try:
if not hasattr(page, 'images') or not page.images:
return None
largest = None
largest_size = 0
for img in page.images:
img_data = img.data
if len(img_data) > largest_size:
largest_size = len(img_data)
# 根据图片名称推断 MIME 类型
name = (img.name or '').lower()
if name.endswith('.png'):
mime = 'image/png'
elif name.endswith('.jpg') or name.endswith('.jpeg'):
mime = 'image/jpeg'
else:
mime = 'image/png'
largest = (img_data, mime)
return largest
except Exception:
return None
@classmethod
async def _ocr_pdf_with_pdf2image(
cls,
db: AsyncSession,
pdf_bytes: bytes,
filename: str,
max_size: int = 100 * 1024,
max_pages: int = 20,
) -> Optional[str]:
"""
使用 pdf2image 将 PDF 页面渲染为图片后 OCR(需要 poppler
"""
import io
import logging
try:
from pdf2image import convert_from_bytes
except ImportError:
logging.warning("PDF OCR: pdf2image 未安装,无法渲染扫描版 PDF。安装命令: pip install pdf2image")
return None
try:
images = convert_from_bytes(
pdf_bytes,
first_page=1,
last_page=max_pages,
dpi=200,
)
text_parts = []
total_length = 0
for idx, img in enumerate(images):
# 将 PIL Image 转为 PNG 字节
buf = io.BytesIO()
img.save(buf, format='PNG')
img_bytes = buf.getvalue()
ocr_text = await cls._ocr_from_image_bytes(
db, img_bytes, 'image/png',
prompt=f"请识别并提取这张图片(PDF第{idx + 1}页)中的所有文字内容,保持原有格式和结构。",
)
if ocr_text:
page_header = f"--- 第 {idx + 1} 页 ---\n"
page_content = page_header + ocr_text
if total_length + len(page_content) > max_size:
text_parts.append(page_content[:max_size - total_length])
text_parts.append(f"\n\n... [内容已截断,共 {len(images)} 页渲染]")
break
text_parts.append(page_content)
total_length += len(page_content)
if text_parts:
result = '\n\n'.join(text_parts)
logging.info(f"PDF OCR (pdf2image): {filename} 识别完成,{len(text_parts)} 页,{len(result)} 字符")
return result
return None
except Exception as e:
logging.error(f"PDF OCR (pdf2image): {filename} 渲染失败: {e}")
return None
@classmethod
def _build_function_config_from_schema(cls, output_schema: list) -> dict:
"""
将前端传递的 output_schema 转换为 Function Calling 配置
Args:
output_schema: 前端传递的结构化输出字段定义列表
Returns:
Function Calling 配置字典
"""
def build_properties(fields: list) -> tuple:
"""递归构建 properties 和 required 列表"""
properties = {}
required = []
for field in fields:
if not field.get("name"):
continue
prop = {
"type": field.get("type", "string"),
"description": field.get("description", ""),
}
# 处理枚举值
if field.get("enum"):
prop["enum"] = field["enum"]
# 处理对象类型
if field.get("type") == "object" and field.get("properties"):
nested_props, nested_required = build_properties(field["properties"])
prop["properties"] = nested_props
if nested_required:
prop["required"] = nested_required
# 处理数组类型
elif field.get("type") == "array" and field.get("items"):
items = field["items"]
if items.get("type") == "object" and items.get("properties"):
nested_props, nested_required = build_properties(items["properties"])
prop["items"] = {
"type": "object",
"properties": nested_props,
}
if nested_required:
prop["items"]["required"] = nested_required
else:
prop["items"] = {"type": items.get("type", "string")}
properties[field["name"]] = prop
if field.get("required"):
required.append(field["name"])
return properties, required
properties, required = build_properties(output_schema)
return {
"name": "extract_structured_data",
"description": "从文件内容中提取结构化数据",
"parameters": {
"type": "object",
"properties": properties,
"required": required if required else None,
}
}
@classmethod
async def recognize_file_with_function_calling(
cls,
db: AsyncSession,
file_id: str,
output_schema: list = None,
custom_prompt: str = None,
) -> dict:
"""
使用 AI 识别文件内容并通过 Function Calling 提取结构化数据
支持的文件类型:
- 图片文件:使用 qwen-vl-ocr 模型进行 OCR 识别
- 文本文件:直接提取文本内容
- PDF 文件:提取 PDF 文本内容
- Word 文档:提取 Word 文本内容
- Excel 文件:提取 Excel 内容
Args:
db: 数据库会话
file_id: 文件ID
output_schema: 结构化输出字段定义(前端传递)
custom_prompt: 自定义提示词
Returns:
{
"success": bool,
"raw_text": str, # 原始识别/提取的文字
"extracted_data": dict, # 提取的结构化数据
"error": str # 错误信息
}
"""
import logging
import os
import json
# 如果有 output_schema,将其转换为 function_config
function_config = None
if output_schema:
function_config = cls._build_function_config_from_schema(output_schema)
# 获取文件信息
file_obj = await cls.get_by_id(db, file_id)
if not file_obj or file_obj.type != 'file':
return {"success": False, "error": "文件不存在", "raw_text": None, "extracted_data": None}
# 检查文件类型
mime_type = file_obj.mime_type or ''
file_ext = (file_obj.file_ext or '').lower()
is_image = mime_type.startswith('image/')
# 非图片文件:使用文本提取方法
if not is_image:
return await cls._recognize_text_file_with_function_calling(
db, file_id, file_obj, function_config, custom_prompt
)
# 获取文件内容并转换为 base64
content = await cls.get_file_content(db, file_id)
if not content:
return {"success": False, "error": "无法读取文件内容", "raw_text": None, "extracted_data": None}
import base64
base64_str = base64.b64encode(content).decode('utf-8')
mime_type = file_obj.mime_type or 'image/png'
base64_data_url = f"data:{mime_type};base64,{base64_str}"
try:
from openai import AsyncOpenAI
# 通过统一辅助方法获取 API 配置(数据库 qwen provider 优先,env 兜底)
ocr_config = await cls._get_ocr_client(db)
if not ocr_config:
return {"success": False, "error": "未配置阿里云 API Key", "raw_text": None, "extracted_data": None}
api_key, api_base, _ = ocr_config
client = AsyncOpenAI(api_key=api_key, base_url=api_base)
# 使用自定义提示词或默认提示词
prompt = custom_prompt or "请识别这个文件中的所有内容。"
# 如果没有 function 配置,只做普通 OCR
if not function_config:
response = await client.chat.completions.create(
model="qwen-vl-ocr-latest",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": base64_data_url}},
{"type": "text", "text": prompt}
]
}]
)
if response.choices and response.choices[0].message.content:
return {
"success": True,
"raw_text": response.choices[0].message.content,
"extracted_data": None,
"error": None
}
return {"success": False, "error": "识别结果为空", "raw_text": None, "extracted_data": None}
# 使用 Function Calling
tools = [{
"type": "function",
"function": {
"name": function_config["name"],
"description": function_config["description"],
"parameters": function_config["parameters"]
}
}]
logging.info(f"OCR with Function Calling: {file_obj.name}")
response = await client.chat.completions.create(
model="qwen-vl-ocr-latest",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": base64_data_url}},
{"type": "text", "text": f"{prompt}\n请调用 {function_config['name']} 函数提取结构化信息。"}
]
}],
tools=tools,
tool_choice="auto"
)
if not response.choices:
return {"success": False, "error": "API 返回为空", "raw_text": None, "extracted_data": None}
message = response.choices[0].message
raw_text = message.content or ""
extracted_data = None
# 检查是否有 tool_calls
if message.tool_calls:
for tool_call in message.tool_calls:
if tool_call.function.name == function_config["name"]:
try:
extracted_data = json.loads(tool_call.function.arguments)
logging.info(f"OCR extracted data: {extracted_data}")
except json.JSONDecodeError as e:
logging.error(f"Failed to parse function arguments: {e}")
break
return {
"success": True,
"raw_text": raw_text,
"extracted_data": extracted_data,
"error": None
}
except ImportError:
return {"success": False, "error": "需要安装 openai 库", "raw_text": None, "extracted_data": None}
except Exception as e:
logging.error(f"OCR with Function Calling failed: {e}")
return {"success": False, "error": str(e), "raw_text": None, "extracted_data": None}
@classmethod
async def _recognize_text_file_with_function_calling(
cls,
db: AsyncSession,
file_id: str,
file_obj,
function_config: dict,
custom_prompt: str,
) -> dict:
"""
处理非图片文件的识别(文本、PDF、Word等)
使用 LLM 进行内容理解和结构化提取
"""
import logging
import os
import json
# 提取文件文本内容
text_content = await cls.get_file_text_content(db, file_id)
if not text_content:
return {"success": False, "error": "无法提取文件内容", "raw_text": None, "extracted_data": None}
# 如果没有 function_config,直接返回文本内容
if not function_config:
return {
"success": True,
"raw_text": text_content,
"extracted_data": None,
"error": None
}
# 使用 LLM 进行结构化提取
try:
from openai import AsyncOpenAI
from app.config import settings
api_key = None
api_base = None
model = "gpt-4o-mini"
# 1. 优先从数据库获取任意激活的 LLM 提供商
try:
from sqlalchemy import select
from ai_platform.models.provider import LLMProvider
provider_result = await db.execute(
select(LLMProvider).where(
LLMProvider.is_active == True,
LLMProvider.is_deleted == False
).order_by(LLMProvider.sort.asc())
)
provider = provider_result.scalar_one_or_none()
if provider and provider.api_key:
api_key = provider.api_key
api_base = provider.api_base
if provider.provider_type == 'qwen':
model = "qwen-plus"
if not api_base:
api_base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
elif provider.provider_type == 'openai':
model = "gpt-4o-mini"
else:
model = "gpt-4o-mini"
except Exception:
pass
# 2. 兜底:从 settings/环境变量获取
if not api_key:
api_key = getattr(settings, 'OPENAI_API_KEY', None) or os.getenv("OPENAI_API_KEY")
api_base = getattr(settings, 'OPENAI_API_BASE', None) or os.getenv("OPENAI_API_BASE")
model = getattr(settings, 'OPENAI_MODEL', None) or os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
if not api_key:
api_key = getattr(settings, 'DASHSCOPE_API_KEY', None)
if api_key:
api_base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
model = "qwen-plus"
if not api_key:
return {"success": False, "error": "未配置 LLM API Key", "raw_text": text_content, "extracted_data": None}
client = AsyncOpenAI(api_key=api_key, base_url=api_base)
# 构建提示词
prompt = custom_prompt or "请从以下文件内容中提取结构化信息。"
tools = [{
"type": "function",
"function": {
"name": function_config["name"],
"description": function_config["description"],
"parameters": function_config["parameters"]
}
}]
logging.info(f"Text file extraction with Function Calling: {file_obj.name}")
response = await client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": "你是一个专业的文档信息提取助手。请仔细阅读用户提供的文件内容,并使用提供的函数提取结构化信息。"
},
{
"role": "user",
"content": f"{prompt}\n\n文件内容:\n{text_content}"
}
],
tools=tools,
tool_choice="auto"
)
if not response.choices:
return {"success": False, "error": "API 返回为空", "raw_text": text_content, "extracted_data": None}
message = response.choices[0].message
extracted_data = None
# 检查是否有 tool_calls
if message.tool_calls:
for tool_call in message.tool_calls:
if tool_call.function.name == function_config["name"]:
try:
extracted_data = json.loads(tool_call.function.arguments)
logging.info(f"Text file extracted data: {extracted_data}")
except json.JSONDecodeError as e:
logging.error(f"Failed to parse function arguments: {e}")
break
return {
"success": True,
"raw_text": text_content,
"extracted_data": extracted_data,
"error": None
}
except ImportError:
return {"success": False, "error": "需要安装 openai 库", "raw_text": text_content, "extracted_data": None}
except Exception as e:
logging.error(f"Text file extraction with Function Calling failed: {e}")
return {"success": False, "error": str(e), "raw_text": text_content, "extracted_data": None}
# 保留旧方法名作为别名,保持向后兼容
recognize_image_with_function_calling = recognize_file_with_function_calling