Files
my-v0-server/app/datafiles/service.py
T

445 lines
17 KiB
Python
Raw 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.
"""上传流程:保存原文件 → 子进程解析(超时终止)→ 分配表名 → 保存记录 → 后台用 LLM 分析;以及查看、删除和过期清理"""
import asyncio
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import BinaryIO
from uuid import uuid4
from loguru import logger
from app.config import PROJECT_ROOT, settings
from app.core.exceptions import BizException
from app.datafiles.aggregate import run_aggregate
from app.datafiles.insights import (
INSIGHTS_CONCURRENCY,
INSIGHTS_TIMEOUT,
InsightsGenerator,
)
from app.datafiles.parse import ParsedFile, ParsedTable
from app.datafiles.profile import read_rows
from app.datafiles.query import QueryError
from app.datafiles.storage import SOURCE_NAME, DataFileStore, table_path
from app.schemas.data_file import (
AggregateRequest,
AggregateResult,
DataFileRecord,
DataTable,
DataTableRows,
FileInsights,
)
from app.services.conversation import ConversationStore
MAX_FILES_PER_CONVERSATION = 10
# 解析超时(秒),超时终止子进程;同时最多解析的文件数,其余排队
PARSE_TIMEOUT = 60
PARSE_CONCURRENCY = 2
# 每小时清理一次目录;一小时内创建的目录不删,避免删掉正在上传的文件
SWEEP_INTERVAL = 3600
SWEEP_GRACE = 3600
# 只清理这个格式的目录,DATA_FILE_DIR 配错时也不会删到别的东西
FILE_ID = re.compile(r"^f_[0-9a-f]{32}$")
MAX_NAME_CHARS = 200
CHUNK_BYTES = 1024 * 1024
NOT_FOUND = "文件不存在或已过期"
PARSE_CRASHED = "解析文件时出错,请检查文件内容,或另存为 CSV 后重新上传"
class DataFileService:
def __init__(
self,
store: DataFileStore,
conversations: ConversationStore,
root: Path | None = None,
*,
max_mb: int | None = None,
ttl: int | None = None,
insights: InsightsGenerator | None = None,
):
self.store = store
self.root = root or settings.data_file_dir
self.max_mb = max_mb or settings.data_file_max_mb
self._conversations = conversations
self._ttl = timedelta(seconds=ttl or settings.data_file_ttl)
self._parsing = asyncio.Semaphore(PARSE_CONCURRENCY)
# 文件数检查和写入记录要一起完成,并发上传时才不会超限;单实例部署,进程内的锁就够了
self._saving = asyncio.Lock()
# 上传后的 LLM 分析;为空表示不分析,文件的 insights 为空
self._insights = insights
self._insight_slots = asyncio.Semaphore(INSIGHTS_CONCURRENCY)
# 本进程里正在分析的文件,用来拒绝重复的重新分析
self._analysing: set[str] = set()
self._tasks: set[asyncio.Task] = set()
# 在这之前开始、记录里仍是 pending 的分析属于上一个进程,已随重启丢失;
# MongoDB 里的时间只精确到毫秒,这里也截到毫秒,刚启动就开始的分析读回后才不会早于它
now = datetime.now(UTC)
self._started_at = now.replace(microsecond=now.microsecond // 1000 * 1000)
async def upload(
self, user_id: int, conversation_id: str, name: str | None, source: BinaryIO
) -> DataFileRecord:
await self._check_conversation(conversation_id, user_id)
# 先粗查一次,满了就不必上传和解析;写入前在锁里再查一次
_check_count(await self.store.find(conversation_id))
file_id = f"f_{uuid4().hex}"
directory = self.root / file_id
try:
size, sha256 = await asyncio.to_thread(self._save, source, directory)
async with self._parsing:
parsed = await _parse(directory)
async with self._saving:
_check_count(await self.store.find(conversation_id))
first_number = await self.store.reserve_table_numbers(
conversation_id, len(parsed.tables)
)
record = self._record(
file_id,
user_id,
conversation_id,
_clean_name(name),
size,
sha256,
parsed,
first_number,
)
await self.store.insert(record)
except BaseException:
# 失败的上传不留文件,会话内的文件数不变
await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True)
raise
if self._insights is not None:
self._start_insights(record)
logger.info(
"数据文件已上传:{} 格式 {},{} 张表",
file_id,
record.format,
len(record.tables),
)
return record
async def list_files(
self, user_id: int, conversation_id: str
) -> list[DataFileRecord]:
await self._check_conversation(conversation_id, user_id)
records = await self.store.find(conversation_id)
return [
self._settle(record) for record in records if record.owner_id == user_id
]
async def get_file(self, user_id: int, file_id: str) -> DataFileRecord:
record = await self.store.get(file_id)
# 他人的文件与不存在同样返回 404
if record is None or record.owner_id != user_id:
raise BizException(404, NOT_FOUND)
return self._settle(record)
async def regenerate_insights(self, user_id: int, file_id: str) -> DataFileRecord:
"""重新分析,用于上次失败之后;正在分析时 409"""
if self._insights is None:
raise BizException(422, "未启用文件分析")
record = await self.get_file(user_id, file_id)
if file_id in self._analysing:
raise BizException(409, "正在分析,请稍后")
self._analysing.add(file_id)
pending = FileInsights(status="pending", started_at=datetime.now(UTC))
try:
await self.store.update_insights(file_id, pending)
except BaseException:
self._analysing.discard(file_id)
raise
record = record.model_copy(update={"insights": pending})
self._start_insights(record)
return record
async def get_rows(
self,
user_id: int,
file_id: str,
table_name: str,
offset: int,
limit: int,
sort_by: str | None = None,
descending: bool = False,
) -> DataTableRows:
"""表的一页数据;表或排序列不存在时 404 / 422,文件已被清理时同样 404"""
record = await self.get_file(user_id, file_id)
table, path = self._table(record, table_name)
if sort_by is not None and sort_by not in {c.name for c in table.columns}:
raise BizException(422, f"排序列不存在:{sort_by}")
rows = await asyncio.to_thread(
read_rows, path, offset, limit, sort_by, descending
)
return DataTableRows(total=table.rows, offset=offset, rows=rows)
async def aggregate(
self, user_id: int, file_id: str, table_name: str, request: AggregateRequest
) -> AggregateResult:
"""对一张表分组汇总,供前端做图表数据;和 agent 的 query_data 同一套只读沙箱"""
record = await self.get_file(user_id, file_id)
table, path = self._table(record, table_name)
try:
return await asyncio.to_thread(run_aggregate, path, request, table.columns)
except QueryError as error:
raise BizException(422, str(error)) from None
async def delete_file(self, user_id: int, file_id: str) -> None:
await self.get_file(user_id, file_id)
await self.store.mark_deleted(file_id)
# Windows 上正在查询的 Parquet 删不掉,留给定时清理
await asyncio.to_thread(shutil.rmtree, self.root / file_id, ignore_errors=True)
async def request_files(
self, user_id: int, conversation_id: str, attached: set[str]
) -> list[DataFileRecord]:
"""本次请求可用的文件:当前用户在本会话里未过期的文件,调用方已校验会话归属
attached 是本轮附件引用的文件,必须都在其中,否则 404
"""
records = [
record
for record in await self.store.find(conversation_id)
if record.owner_id == user_id
]
if attached - {record.file_id for record in records}:
raise BizException(404, NOT_FOUND)
return records
async def aclose(self) -> None:
"""应用退出时取消还在进行的分析,记录里留下的 pending 下次启动后读取时按失败返回"""
for task in list(self._tasks):
task.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
async def sweep(self) -> int:
"""删除没有对应记录(过期、已删除、内存模式重启后丢失)的目录,返回删除的个数"""
live = await self.store.live_ids()
return await asyncio.to_thread(self._remove_orphans, live)
async def run_sweeper(self) -> None:
while True:
try:
if removed := await self.sweep():
logger.info("已清理 {} 个过期或删除的数据文件目录", removed)
except Exception as error:
logger.opt(exception=error).error("清理数据文件目录失败")
await asyncio.sleep(SWEEP_INTERVAL)
def _start_insights(self, record: DataFileRecord) -> None:
self._analysing.add(record.file_id)
task = asyncio.create_task(self._analyse(record))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _analyse(self, record: DataFileRecord) -> None:
"""后台分析并写回记录;任何失败都只记为 failed,不影响文件使用"""
assert self._insights is not None
try:
try:
async with self._insight_slots:
insights = await asyncio.wait_for(
self._insights(record, self.root), INSIGHTS_TIMEOUT
)
except Exception as error:
logger.opt(exception=error).warning(
"数据文件分析失败:{}", record.file_id
)
insights = FileInsights(status="failed")
await self.store.update_insights(record.file_id, insights)
logger.info(
"数据文件分析完成:{} {},{} 条推荐",
record.file_id,
insights.status,
len(insights.suggestions),
)
except Exception as error:
logger.opt(exception=error).error("保存数据文件分析结果失败")
finally:
self._analysing.discard(record.file_id)
def _settle(self, record: DataFileRecord) -> DataFileRecord:
"""服务启动前开始、仍是 pending 的分析已随重启丢失,按失败返回,免得前端一直等
只看记录本身,不写回存储:读到的记录可能已经过时,写回会覆盖刚完成的结果
"""
insights = record.insights
if (
insights is not None
and insights.status == "pending"
and (insights.started_at is None or insights.started_at < self._started_at)
):
return record.model_copy(update={"insights": FileInsights(status="failed")})
return record
def _table(self, record: DataFileRecord, name: str) -> tuple[DataTable, Path]:
"""文件里的一张表和它的 Parquet
表不存在,或 Parquet 已被删除(删除时目录没删净、过期清理)时 404
"""
table = next((item for item in record.tables if item.table == name), None)
if table is None:
raise BizException(404, "数据表不存在")
path = table_path(self.root, record.file_id, table)
if not path.is_file():
raise BizException(404, NOT_FOUND)
return table, path
async def _check_conversation(self, conversation_id: str, user_id: int) -> None:
if not await self._conversations.exists(conversation_id, user_id):
raise BizException(404, "会话不存在或已过期")
def _save(self, source: BinaryIO, directory: Path) -> tuple[int, str]:
"""边写边算大小和 sha256,超过上限立即停止"""
directory.mkdir(parents=True)
digest, size = hashlib.sha256(), 0
with (directory / SOURCE_NAME).open("wb") as target:
while chunk := source.read(CHUNK_BYTES):
size += len(chunk)
if size > self.max_mb * 1024 * 1024:
raise file_too_large(self.max_mb)
digest.update(chunk)
target.write(chunk)
return size, digest.hexdigest()
def _record(
self,
file_id: str,
user_id: int,
conversation_id: str,
name: str,
size: int,
sha256: str,
parsed: ParsedFile,
first_number: int,
) -> DataFileRecord:
tables, warnings = [], list(parsed.warnings)
for number, table in enumerate(parsed.tables, start=first_number):
table_name = f"t{number}"
tables.append(
DataTable(
table=table_name,
label=_label(name, table),
**table.model_dump(exclude={"part", "warnings"}),
)
)
warnings.extend(
warning.model_copy(update={"table": table_name})
for warning in table.warnings
)
now = datetime.now(UTC)
return DataFileRecord(
file_id=file_id,
owner_id=user_id,
conversation_id=conversation_id,
name=name,
format=parsed.format,
encoding=parsed.encoding,
size=size,
sha256=sha256,
created_at=now,
expires_at=now + self._ttl,
tables=tables,
warnings=warnings,
insights=(
None
if self._insights is None
else FileInsights(status="pending", started_at=now)
),
)
def _remove_orphans(self, live: set[str]) -> int:
if not self.root.is_dir():
return 0
cutoff = time.time() - SWEEP_GRACE
removed = 0
for entry in self.root.iterdir():
if (
FILE_ID.match(entry.name)
and entry.name not in live
and entry.is_dir()
and entry.stat().st_mtime < cutoff
):
shutil.rmtree(entry, ignore_errors=True)
removed += 1
return removed
async def _parse(directory: Path) -> ParsedFile:
python, env = _python()
command = [
python,
"-m",
"app.datafiles.worker",
str(directory / SOURCE_NAME),
str(directory),
]
try:
# 超时后 subprocess.run 会终止子进程
completed = await asyncio.to_thread(
subprocess.run,
command,
cwd=PROJECT_ROOT,
env=env,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=PARSE_TIMEOUT,
)
except subprocess.TimeoutExpired:
raise BizException(
422, f"解析超过 {PARSE_TIMEOUT} 秒,文件可能太大或太复杂,请拆分后上传"
) from None
if completed.returncode != 0:
logger.error(
"数据文件解析进程异常退出({}):{}",
completed.returncode,
completed.stderr.decode(errors="replace")[-2000:],
)
raise BizException(422, PARSE_CRASHED)
result = json.loads(completed.stdout)
if error := result.get("error"):
raise BizException(error["status"], error["message"])
return ParsedFile.model_validate(result["file"])
def _python() -> tuple[str, dict[str, str] | None]:
"""Windows 的 venv 里 sys.executable 是启动器,由它再拉起真正的解释器;超时时只杀得掉启动器,
刚启动就被杀时解释器会成为孤儿,一直占着输出管道。和 multiprocessing 一样直接启动基础解释器,
用 __PYVENV_LAUNCHER__ 告诉它所在的 venv
"""
if sys.platform == "win32" and sys._base_executable != sys.executable:
return sys._base_executable, {
**os.environ,
"__PYVENV_LAUNCHER__": sys.executable,
}
return sys.executable, None
def file_too_large(max_mb: int) -> BizException:
return BizException(413, f"文件不能超过 {max_mb} MB")
def _check_count(records: list[DataFileRecord]) -> None:
if len(records) >= MAX_FILES_PER_CONVERSATION:
raise BizException(
409, f"每个会话最多 {MAX_FILES_PER_CONVERSATION} 个文件,请先删除不用的文件"
)
def _clean_name(name: str | None) -> str:
# 有的浏览器会带上本地路径,只保留文件名
base = re.split(r"[\\/]", name or "")[-1].strip()
return base[:MAX_NAME_CHARS] or "未命名文件"
def _label(name: str, table: ParsedTable) -> str:
label = f"{name} / {table.sheet}" if table.sheet else name
return f"{label}({table.part})" if table.part else label