609 lines
23 KiB
Python
609 lines
23 KiB
Python
"""数据合成服务 - 核心业务逻辑层"""
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from app.db.models.base_entity import LineageNode, LineageEdge
|
|
from app.db.models.data_synthesis import (
|
|
DataSynthInstance,
|
|
DataSynthesisFileInstance,
|
|
DataSynthesisChunkInstance,
|
|
SynthesisData,
|
|
)
|
|
from app.db.models.dataset_management import DatasetFiles, Dataset
|
|
from app.db.session import logger, AsyncSessionLocal
|
|
from app.module.generation.schema.generation import Config, SyntheConfig
|
|
from app.module.generation.service.chunk_processor import ChunkProcessor
|
|
from app.module.generation.service.qa_generator import QAGenerator
|
|
from app.module.generation.service.qa_generator import _filter_docs_by_size
|
|
from app.module.generation.service.task_executor import run_in_thread
|
|
from app.module.shared.common.document_loaders import load_documents
|
|
from app.module.shared.common.text_split import DocumentSplitter
|
|
from app.module.shared.llm import LLMFactory
|
|
from app.module.system.service.common_service import get_model_by_id
|
|
from app.module.shared.common.lineage import LineageService
|
|
from app.module.shared.schema import NodeType, EdgeType
|
|
|
|
|
|
class GenerationService:
|
|
"""数据合成服务 - 使用模块化架构"""
|
|
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
self.chunk_processor = ChunkProcessor(db)
|
|
self.qa_generator = QAGenerator(db)
|
|
|
|
@asynccontextmanager
|
|
async def _session_scope(self):
|
|
"""
|
|
创建独立的数据库会话上下文管理器
|
|
用于长时间运行的任务,避免长时间占用连接
|
|
"""
|
|
session = AsyncSessionLocal()
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
finally:
|
|
await session.close()
|
|
|
|
def _load_and_split_sync(
|
|
self, file_path: str, chunk_size: int, chunk_overlap: int
|
|
):
|
|
"""
|
|
同步方法:加载并切分文件
|
|
在线程池中执行,不阻塞事件循环
|
|
"""
|
|
try:
|
|
docs = load_documents(file_path)
|
|
split_docs = DocumentSplitter.auto_split(docs, chunk_size, chunk_overlap)
|
|
return _filter_docs_by_size(split_docs, chunk_size)
|
|
except Exception as e:
|
|
logger.error(f"Error loading or splitting file {file_path}: {e}")
|
|
raise
|
|
|
|
async def process_task(self, task_id: str):
|
|
"""
|
|
处理数据合成任务入口 - 使用独立会话和线程池
|
|
|
|
Args:
|
|
task_id: 合成任务ID
|
|
"""
|
|
# 使用独立的数据库会话,不与API请求共享
|
|
async with self._session_scope() as session:
|
|
synth_task: DataSynthInstance | None = await session.get(
|
|
DataSynthInstance, task_id
|
|
)
|
|
if not synth_task:
|
|
logger.error(f"Synthesis task {task_id} not found, abort processing")
|
|
return
|
|
|
|
logger.info(f"Start processing synthesis task {task_id}")
|
|
|
|
# 更新任务状态为处理中
|
|
synth_task.status = "processing"
|
|
await session.commit()
|
|
|
|
# 从 synth_config 中读取 max_qa_pairs
|
|
max_qa_pairs = self._parse_max_qa_pairs(synth_task)
|
|
|
|
# 获取任务关联的文件ID列表
|
|
file_ids = await self._get_file_ids_for_task(session, task_id)
|
|
if not file_ids:
|
|
logger.warning(
|
|
f"No files associated with task {task_id}, abort processing"
|
|
)
|
|
await self._mark_task_failed(session, task_id, "no_files_associated")
|
|
return
|
|
|
|
# 逐个文件处理
|
|
processed_count = 0
|
|
total_task_chunks = 0
|
|
processed_task_chunks = 0
|
|
for file_id in file_ids:
|
|
try:
|
|
file_task = await self._get_or_create_file_instance(
|
|
session, str(synth_task.id), file_id
|
|
)
|
|
success = await self._process_single_file(
|
|
session, synth_task, file_id, max_qa_pairs=max_qa_pairs
|
|
)
|
|
# 累加任务级别的切片统计
|
|
if file_task:
|
|
total_task_chunks += file_task.total_chunks or 0
|
|
processed_task_chunks += file_task.processed_chunks or 0
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"Unexpected error when processing file {file_id} for task {task_id}: {e}"
|
|
)
|
|
await self._mark_file_failed(
|
|
session, str(synth_task.id), file_id, str(e)
|
|
)
|
|
success = False
|
|
|
|
if success:
|
|
processed_count += 1
|
|
synth_task.processed_files = processed_count
|
|
# 更新任务级别的切片统计
|
|
synth_task.total_chunks = total_task_chunks
|
|
synth_task.processed_chunks = processed_task_chunks
|
|
await session.commit()
|
|
|
|
# 更新最终任务状态
|
|
if processed_count == len(file_ids):
|
|
synth_task.status = "completed"
|
|
else:
|
|
synth_task.status = "partially_completed"
|
|
# 确保任务完成时切片统计准确
|
|
if synth_task.status in ("completed", "partially_completed"):
|
|
synth_task.total_chunks = total_task_chunks
|
|
synth_task.processed_chunks = processed_task_chunks
|
|
await session.commit()
|
|
|
|
logger.info(f"Finished processing synthesis task {synth_task.id}")
|
|
|
|
def _parse_max_qa_pairs(self, synth_task: DataSynthInstance) -> int | None:
|
|
"""解析最大QA对数量配置"""
|
|
try:
|
|
cfg = Config(**(synth_task.synth_config or {}))
|
|
max_qa_pairs = (
|
|
cfg.max_qa_pairs
|
|
if (cfg and cfg.max_qa_pairs and cfg.max_qa_pairs > 0)
|
|
else None
|
|
)
|
|
except Exception:
|
|
max_qa_pairs = None
|
|
return max_qa_pairs
|
|
|
|
async def _process_single_file(
|
|
self,
|
|
session: AsyncSession,
|
|
synth_task: DataSynthInstance,
|
|
file_id: str,
|
|
max_qa_pairs: int | None = None,
|
|
) -> bool:
|
|
"""
|
|
处理单个源文件 - 使用线程池执行阻塞操作
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
synth_task: 合成任务实例
|
|
file_id: 源文件ID
|
|
max_qa_pairs: 最大QA对数量限制
|
|
|
|
Returns:
|
|
处理是否成功
|
|
"""
|
|
# 解析文件路径
|
|
file_path = await self._resolve_file_path(session, file_id)
|
|
if not file_path:
|
|
logger.warning(f"File path not found for file_id={file_id}, skip")
|
|
await self._mark_file_failed(
|
|
session, str(synth_task.id), file_id, "file_path_not_found"
|
|
)
|
|
return False
|
|
|
|
logger.info(f"Processing file_id={file_id}, path={file_path}")
|
|
|
|
# 解析配置
|
|
try:
|
|
config = Config(**(synth_task.synth_config or {}))
|
|
except Exception as e:
|
|
logger.error(f"Invalid synth_config for task={synth_task.id}: {e}")
|
|
await self._mark_file_failed(
|
|
session, str(synth_task.id), file_id, "invalid_synth_config"
|
|
)
|
|
return False
|
|
|
|
# 1. 加载并切片 - 在线程池中执行阻塞操作
|
|
try:
|
|
chunks = await run_in_thread(
|
|
self._load_and_split_sync,
|
|
file_path,
|
|
config.text_split_config.chunk_size,
|
|
config.text_split_config.chunk_overlap,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load and split file {file_id}: {e}")
|
|
await self._mark_file_failed(
|
|
session, str(synth_task.id), file_id, f"load_split_failed: {e}"
|
|
)
|
|
return False
|
|
|
|
if not chunks:
|
|
logger.warning(f"No chunks generated for file_id={file_id}")
|
|
await self._mark_file_failed(
|
|
session, str(synth_task.id), file_id, "no_chunks_generated"
|
|
)
|
|
return False
|
|
|
|
logger.info(f"File {file_id} split into {len(chunks)} chunks")
|
|
|
|
# 2. 获取文件实例并持久化切片
|
|
file_task = await self._get_or_create_file_instance(
|
|
session,
|
|
synthesis_task_id=str(synth_task.id),
|
|
source_file_id=file_id,
|
|
)
|
|
if not file_task:
|
|
logger.error(
|
|
f"DataSynthesisFileInstance not found for task={synth_task.id}, file_id={file_id}"
|
|
)
|
|
await self._mark_file_failed(
|
|
session, str(synth_task.id), file_id, "file_instance_not_found"
|
|
)
|
|
return False
|
|
|
|
# 使用 ChunkProcessor 持久化切片
|
|
chunk_processor = ChunkProcessor(session)
|
|
await chunk_processor.persist_chunks(synth_task, file_task, file_id, chunks)
|
|
total_chunks = len(chunks)
|
|
del chunks # 释放内存
|
|
|
|
# 3. 验证问答配置
|
|
question_cfg: SyntheConfig | None = config.question_synth_config
|
|
answer_cfg: SyntheConfig | None = config.answer_synth_config
|
|
if not question_cfg or not answer_cfg:
|
|
logger.error(
|
|
f"Question/Answer synth config missing for task={synth_task.id}, file={file_id}"
|
|
)
|
|
await self._mark_file_failed(
|
|
session, str(synth_task.id), file_id, "qa_config_missing"
|
|
)
|
|
return False
|
|
|
|
logger.info(
|
|
f"Start QA generation for task={synth_task.id}, file={file_id}, total_chunks={total_chunks}"
|
|
)
|
|
|
|
# 4. 创建模型客户端
|
|
question_model = await get_model_by_id(session, question_cfg.model_id)
|
|
answer_model = await get_model_by_id(session, answer_cfg.model_id)
|
|
question_chat = LLMFactory.create_chat(
|
|
question_model.model_name, question_model.base_url, question_model.api_key
|
|
)
|
|
answer_chat = LLMFactory.create_chat(
|
|
answer_model.model_name, answer_model.base_url, answer_model.api_key
|
|
)
|
|
|
|
# 5. 分批次处理切片
|
|
await self._process_chunks_in_batches(
|
|
session=session,
|
|
file_task=file_task,
|
|
total_chunks=total_chunks,
|
|
question_cfg=question_cfg,
|
|
answer_cfg=answer_cfg,
|
|
question_chat=question_chat,
|
|
answer_chat=answer_chat,
|
|
synth_task_id=str(synth_task.id),
|
|
max_qa_pairs=max_qa_pairs,
|
|
)
|
|
|
|
# 6. 标记文件任务完成
|
|
file_task.status = "completed"
|
|
await session.commit()
|
|
await session.refresh(file_task)
|
|
return True
|
|
|
|
async def _process_chunks_in_batches(
|
|
self,
|
|
session: AsyncSession,
|
|
file_task: DataSynthesisFileInstance,
|
|
total_chunks: int,
|
|
question_cfg: SyntheConfig,
|
|
answer_cfg: SyntheConfig,
|
|
question_chat,
|
|
answer_chat,
|
|
synth_task_id: str,
|
|
max_qa_pairs: int | None,
|
|
) -> None:
|
|
"""分批次处理切片
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
file_task: 文件任务实例
|
|
total_chunks: 总切片数
|
|
question_cfg: 问题生成配置
|
|
answer_cfg: 答案生成配置
|
|
question_chat: 问题生成模型
|
|
answer_chat: 答案生成模型
|
|
synth_task_id: 合成任务ID
|
|
max_qa_pairs: 最大QA对数量限制
|
|
"""
|
|
batch_size = 50 # 减小批次大小,降低内存占用
|
|
current_index = 1
|
|
|
|
chunk_processor = ChunkProcessor(session)
|
|
qa_generator = QAGenerator(session)
|
|
|
|
while current_index <= total_chunks:
|
|
end_index = min(current_index + batch_size - 1, total_chunks)
|
|
|
|
# 使用 ChunkProcessor 加载切片批次
|
|
chunk_batch = await chunk_processor.load_chunk_batch(
|
|
file_task_id=file_task.id,
|
|
start_index=current_index,
|
|
end_index=end_index,
|
|
)
|
|
|
|
if not chunk_batch:
|
|
logger.warning(
|
|
f"Empty chunk batch loaded for file={file_task.id}, range=[{current_index}, {end_index}]"
|
|
)
|
|
current_index = end_index + 1
|
|
continue
|
|
|
|
# 顺序处理每个切片,避免并发过高
|
|
for chunk in chunk_batch:
|
|
try:
|
|
success = await self._process_single_chunk(
|
|
session=session,
|
|
file_task=file_task,
|
|
chunk=chunk,
|
|
question_cfg=question_cfg,
|
|
answer_cfg=answer_cfg,
|
|
question_chat=question_chat,
|
|
answer_chat=answer_chat,
|
|
synth_task_id=synth_task_id,
|
|
max_qa_pairs=max_qa_pairs,
|
|
qa_generator=qa_generator,
|
|
)
|
|
if success:
|
|
# 更新已处理的切片计数
|
|
file_task.processed_chunks = (file_task.processed_chunks or 0) + 1
|
|
# 实时刷新进度到数据库
|
|
await session.commit()
|
|
await session.refresh(file_task)
|
|
logger.info(f"File {file_task.id} progress: {file_task.processed_chunks}/{file_task.total_chunks} chunks processed")
|
|
except Exception as e:
|
|
logger.error(f"Error processing chunk {chunk.id}: {e}")
|
|
# 即使处理失败,也要更新进度
|
|
file_task.processed_chunks = (file_task.processed_chunks or 0) + 1
|
|
await session.commit()
|
|
await session.refresh(file_task)
|
|
|
|
# 短暂释放控制权,让出事件循环,处理其他请求
|
|
await asyncio.sleep(0)
|
|
|
|
current_index = end_index + 1
|
|
|
|
async def _process_single_chunk(
|
|
self,
|
|
session: AsyncSession,
|
|
file_task: DataSynthesisFileInstance,
|
|
chunk: DataSynthesisChunkInstance,
|
|
question_cfg: SyntheConfig,
|
|
answer_cfg: SyntheConfig,
|
|
question_chat,
|
|
answer_chat,
|
|
synth_task_id: str,
|
|
max_qa_pairs: int | None,
|
|
qa_generator: QAGenerator,
|
|
) -> bool:
|
|
"""处理单个切片
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
file_task: 文件任务实例
|
|
chunk: 切片实例
|
|
question_cfg: 问题生成配置
|
|
answer_cfg: 答案生成配置
|
|
question_chat: 问题生成模型
|
|
answer_chat: 答案生成模型
|
|
synth_task_id: 合成任务ID
|
|
max_qa_pairs: 最大QA对数量限制
|
|
qa_generator: QA生成器
|
|
|
|
Returns:
|
|
处理是否成功
|
|
"""
|
|
# 检查QA对数量上限
|
|
if max_qa_pairs and max_qa_pairs > 0:
|
|
if await qa_generator.check_qa_limit(synth_task_id, max_qa_pairs):
|
|
logger.info(f"max_qa_pairs reached, skipping chunk {chunk.chunk_index}")
|
|
file_task.status = "completed"
|
|
if file_task.total_chunks is not None:
|
|
file_task.processed_chunks = file_task.total_chunks
|
|
await session.commit()
|
|
await session.refresh(file_task)
|
|
return False
|
|
|
|
# 使用 QAGenerator 处理问答生成
|
|
success_count = await qa_generator.process_chunk_qa(
|
|
file_task=file_task,
|
|
chunk=chunk,
|
|
question_cfg=question_cfg,
|
|
answer_cfg=answer_cfg,
|
|
question_chat=question_chat,
|
|
answer_chat=answer_chat,
|
|
)
|
|
|
|
return success_count > 0
|
|
|
|
async def _resolve_file_path(self, session: AsyncSession, file_id: str) -> str | None:
|
|
"""根据文件ID获取文件路径
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
file_id: 文件ID
|
|
|
|
Returns:
|
|
文件路径
|
|
"""
|
|
result = await session.execute(
|
|
select(DatasetFiles).where(DatasetFiles.id == file_id)
|
|
)
|
|
file_obj = result.scalar_one_or_none()
|
|
if not file_obj:
|
|
return None
|
|
return file_obj.file_path
|
|
|
|
async def _get_or_create_file_instance(
|
|
self,
|
|
session: AsyncSession,
|
|
synthesis_task_id: str,
|
|
source_file_id: str,
|
|
) -> DataSynthesisFileInstance | None:
|
|
"""获取或创建文件任务实例
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
synthesis_task_id: 合成任务ID
|
|
source_file_id: 源文件ID
|
|
|
|
Returns:
|
|
文件任务实例
|
|
"""
|
|
result = await session.execute(
|
|
select(DataSynthesisFileInstance).where(
|
|
DataSynthesisFileInstance.synthesis_instance_id == synthesis_task_id,
|
|
DataSynthesisFileInstance.source_file_id == source_file_id,
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def _mark_file_failed(
|
|
self,
|
|
session: AsyncSession,
|
|
synth_task_id: str,
|
|
file_id: str,
|
|
reason: str | None = None,
|
|
) -> None:
|
|
"""标记文件任务失败
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
synth_task_id: 合成任务ID
|
|
file_id: 文件ID
|
|
reason: 失败原因
|
|
"""
|
|
try:
|
|
result = await session.execute(
|
|
select(DataSynthesisFileInstance).where(
|
|
DataSynthesisFileInstance.synthesis_instance_id == synth_task_id,
|
|
DataSynthesisFileInstance.source_file_id == file_id,
|
|
)
|
|
)
|
|
file_task = result.scalar_one_or_none()
|
|
if not file_task:
|
|
logger.warning(
|
|
f"Failed to mark file as failed: no DataSynthesisFileInstance found for task={synth_task_id}, file_id={file_id}, reason={reason}"
|
|
)
|
|
return
|
|
|
|
file_task.status = "failed"
|
|
await session.commit()
|
|
await session.refresh(file_task)
|
|
logger.info(
|
|
f"Marked file task as failed for task={synth_task_id}, file_id={file_id}, reason={reason}"
|
|
)
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"Unexpected error when marking file failed for task={synth_task_id}, file_id={file_id}, original_reason={reason}, error={e}"
|
|
)
|
|
|
|
async def _get_file_ids_for_task(
|
|
self, session: AsyncSession, synth_task_id: str
|
|
):
|
|
"""获取任务关联的文件ID列表
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
synth_task_id: 合成任务ID
|
|
|
|
Returns:
|
|
文件ID列表
|
|
"""
|
|
result = await session.execute(
|
|
select(DataSynthesisFileInstance.source_file_id).where(
|
|
DataSynthesisFileInstance.synthesis_instance_id == synth_task_id
|
|
)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
async def _mark_task_failed(
|
|
self,
|
|
session: AsyncSession,
|
|
task_id: str,
|
|
reason: str | None = None,
|
|
) -> None:
|
|
"""标记任务失败
|
|
|
|
Args:
|
|
session: 数据库会话
|
|
task_id: 任务ID
|
|
reason: 失败原因
|
|
"""
|
|
try:
|
|
task = await session.get(DataSynthInstance, task_id)
|
|
if task:
|
|
task.status = "failed"
|
|
await session.commit()
|
|
logger.info(
|
|
f"Marked task {task_id} as failed, reason={reason}"
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Error marking task {task_id} as failed: {e}")
|
|
|
|
async def add_synthesis_to_graph(
|
|
self, db: AsyncSession, task_id: str, dest_dataset_id: str
|
|
) -> None:
|
|
"""记录数据合成血缘关系
|
|
|
|
Args:
|
|
db: 数据库会话
|
|
task_id: 任务ID
|
|
dest_dataset_id: 目标数据集ID
|
|
"""
|
|
try:
|
|
task = await db.get(DataSynthInstance, task_id)
|
|
src_dataset_result = await db.execute(
|
|
select(DatasetFiles.dataset_id)
|
|
.join(
|
|
DataSynthesisFileInstance,
|
|
DatasetFiles.id == DataSynthesisFileInstance.source_file_id,
|
|
)
|
|
.where(DataSynthesisFileInstance.synthesis_instance_id == task_id)
|
|
.limit(1)
|
|
)
|
|
src_dataset_id = src_dataset_result.scalar_one_or_none()
|
|
src_dataset = await db.get(Dataset, src_dataset_id)
|
|
dst_dataset = await db.get(Dataset, dest_dataset_id)
|
|
|
|
if not task or not dst_dataset:
|
|
logger.warning("Missing task or destination dataset for lineage graph")
|
|
return
|
|
|
|
src_node = LineageNode(
|
|
id=src_dataset.id,
|
|
node_type=NodeType.DATASET.value,
|
|
name=src_dataset.name,
|
|
description=src_dataset.description,
|
|
)
|
|
dest_node = LineageNode(
|
|
id=dst_dataset.id,
|
|
node_type=NodeType.DATASET.value,
|
|
name=dst_dataset.name,
|
|
description=dst_dataset.description,
|
|
)
|
|
synthesis_edge = LineageEdge(
|
|
process_id=task_id,
|
|
name=task.name,
|
|
edge_type=EdgeType.DATA_SYNTHESIS.value,
|
|
description=task.description,
|
|
from_node_id=src_node.id,
|
|
to_node_id=dst_dataset.id,
|
|
)
|
|
|
|
lineage_service = LineageService(db=db)
|
|
await lineage_service.generate_graph(src_node, synthesis_edge, dest_node)
|
|
await db.commit()
|
|
|
|
logger.info(f"Added synthesis lineage: {src_node.name} -> {dest_dataset.name}")
|
|
except Exception as exc:
|
|
logger.error(f"Failed to add synthesis lineage: {exc}")
|