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

570 lines
22 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 tempfile
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
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.datafiles.uploads import UploadBoard, UploadJob
from app.schemas.data_file import (
AggregateRequest,
AggregateResult,
DataFileRecord,
DataTable,
DataTableRows,
FileInsights,
UploadProgress,
UploadStage,
)
from app.services.conversation import ConversationStore
MAX_FILES_PER_CONVERSATION = 10
# 解析超时(秒),超时终止子进程;同时最多解析的文件数,其余排队
WORKER_MODULE = "app.datafiles.worker"
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 = "文件不存在或已过期"
UPLOAD_NOT_FOUND = "上传任务不存在,可能已中断,请重新上传"
SAVE_FAILED = "保存文件失败,请稍后重试"
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._uploads = UploadBoard()
# 上传后的 LLM 分析;为空表示不分析,文件的 insights 为空
self._insights = insights
self._insight_slots = asyncio.Semaphore(INSIGHTS_CONCURRENCY)
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
) -> UploadProgress:
"""保存原文件并登记解析任务,解析在后台进行,用 get_progress 查进度
原文件必须在这里写完:请求结束后 source 就被关闭了
"""
await self._check_conversation(conversation_id, user_id)
records = await self.store.find(conversation_id)
# 排队和解析中的任务也占名额;检查和登记之间没有 await,并发上传时才不会超限
_check_count(len(records) + self._uploads.active_count(conversation_id))
file_id = f"f_{uuid4().hex}"
job = self._uploads.create(file_id, user_id, conversation_id, _clean_name(name))
directory = self.root / file_id
try:
size, sha256 = await asyncio.to_thread(self._save, source, directory)
except BaseException:
# 失败的上传不留文件,也不占名额
self._uploads.remove(job)
await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True)
raise
task = asyncio.create_task(self._process(job, directory, size, sha256))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return self._uploads.view(job)
async def get_progress(self, user_id: int, file_id: str) -> UploadProgress:
"""进行中或刚失败的任务看内存里的进度;成功的任务内存里已清掉,按文件记录返回 ready"""
job = self._uploads.get(file_id)
if job is not None:
if job.owner_id != user_id:
raise BizException(404, UPLOAD_NOT_FOUND)
return self._uploads.view(job)
record = await self.store.get(file_id)
if record is None or record.owner_id != user_id:
raise BizException(404, UPLOAD_NOT_FOUND)
return UploadProgress(
file_id=file_id,
name=record.name,
status="ready",
progress=1.0,
stage=None,
eta_seconds=None,
queued_ahead=0,
error=None,
)
def list_uploads(self, user_id: int, conversation_id: str) -> list[UploadProgress]:
"""会话里排队、解析中和刚失败的上传,调用方已校验会话归属"""
return [
self._uploads.view(job)
for job in self._uploads.for_conversation(user_id, conversation_id)
]
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 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, datetime.now(UTC) + self._ttl)
# 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 sync_screen_files(
self, user_id: int, screen_id: str, file_ids: set[str]
) -> None:
"""大屏保存后调用:它引用的文件固定住,不再引用的放开(重新按保留时长计时)"""
current = await self.store.pinned_ids(screen_id)
if added := file_ids - current:
await self.store.pin(added, user_id, screen_id)
if removed := current - file_ids:
await self.store.unpin(screen_id, removed, datetime.now(UTC) + self._ttl)
async def release_screen_files(self, screen_id: str) -> None:
"""大屏进回收站或被彻底删除:它固定的文件全部放开"""
if current := await self.store.pinned_ids(screen_id):
await self.store.unpin(screen_id, current, datetime.now(UTC) + self._ttl)
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)
async def _process(
self, job: UploadJob, directory: Path, size: int, sha256: str
) -> None:
"""后台解析并入库;失败只记在任务上,由进度接口告诉前端"""
loop = asyncio.get_running_loop()
def on_progress(stage: UploadStage, value: float) -> None:
# 解析在线程里读子进程的输出,转回事件循环再改任务,状态只在一个线程里变
loop.call_soon_threadsafe(self._uploads.report, job, stage, value)
try:
async with self._parsing:
self._uploads.start(job)
parsed = await _parse(directory, on_progress)
first_number = await self.store.reserve_table_numbers(
job.conversation_id, len(parsed.tables)
)
record = self._record(
job.file_id,
job.owner_id,
job.conversation_id,
job.name,
size,
sha256,
parsed,
first_number,
)
await self.store.insert(record)
except BaseException as error:
# 失败的上传不留文件,会话内的文件数不变
await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True)
if not isinstance(error, Exception):
# 应用退出时被取消,任务随进程丢失
self._uploads.remove(job)
raise
if isinstance(error, BizException):
self._uploads.fail(job, error.code, error.msg)
else:
logger.opt(exception=error).error("数据文件入库失败:{}", job.file_id)
self._uploads.fail(job, 500, SAVE_FAILED)
return
self._uploads.remove(job)
if self._insights is not None:
self._start_insights(record)
logger.info(
"数据文件已上传:{} 格式 {},{} 张表",
job.file_id,
record.format,
len(record.tables),
)
def _start_insights(self, record: DataFileRecord) -> None:
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("保存数据文件分析结果失败")
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
ProgressCallback = Callable[[UploadStage, float], None]
async def _parse(directory: Path, on_progress: ProgressCallback) -> ParsedFile:
python, env = _python()
command = [
python,
"-m",
WORKER_MODULE,
str(directory / SOURCE_NAME),
str(directory),
]
processes: list[subprocess.Popen] = []
try:
outcome = await asyncio.to_thread(
_run_worker, command, env, on_progress, processes
)
except asyncio.CancelledError:
# 线程取消不了,杀掉子进程它才会结束
for process in processes:
process.kill()
raise
if outcome.timed_out:
raise BizException(
422, f"解析超过 {PARSE_TIMEOUT} 秒,文件可能太大或太复杂,请拆分后上传"
)
if outcome.returncode != 0 or outcome.result is None:
logger.error(
"数据文件解析进程异常退出({}):{}",
outcome.returncode,
outcome.stderr.decode(errors="replace")[-2000:],
)
raise BizException(422, PARSE_CRASHED)
if error := outcome.result.get("error"):
raise BizException(error["status"], error["message"])
return ParsedFile.model_validate(outcome.result["file"])
@dataclass
class _WorkerOutcome:
returncode: int
# 子进程输出的最后一行;没输出结果就退出时为空
result: dict | None
stderr: bytes
timed_out: bool
def _run_worker(
command: list[str],
env: dict[str, str] | None,
on_progress: ProgressCallback,
processes: list[subprocess.Popen],
) -> _WorkerOutcome:
"""运行解析子进程,逐行读它的输出:进度交给 on_progress,最后一行是结果
超时由定时器终止子进程,读取随之结束;stderr 写进临时文件,写多了也不会堵住子进程
"""
with tempfile.TemporaryFile() as stderr:
process = subprocess.Popen(
command,
cwd=PROJECT_ROOT,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=stderr,
)
processes.append(process)
timed_out = threading.Event()
def expire() -> None:
timed_out.set()
process.kill()
timer = threading.Timer(PARSE_TIMEOUT, expire)
timer.start()
result = None
try:
for line in process.stdout:
try:
message = json.loads(line)
except ValueError:
continue
if "progress" in message:
progress = message["progress"]
on_progress(progress["stage"], progress["value"])
else:
result = message
returncode = process.wait()
finally:
timer.cancel()
process.kill()
process.stdout.close()
stderr.seek(0)
return _WorkerOutcome(returncode, result, stderr.read(), timed_out.is_set())
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(count: int) -> None:
if count >= 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