feat:LLM 分析文件并生成结构化建议

This commit is contained in:
2026-10-10 06:07:56 -07:00
parent b90842237f
commit e3699d1a30
22 changed files with 457 additions and 327 deletions
+19 -10
View File
@@ -7,7 +7,7 @@ apply_actions 里的 $query 也通过 DataContext.query 执行,见 tools.py
上下文类型因 agent 而异,所以写 ToolRuntime[Any](不写类型参数时按 None 处理,序列化会告警) 上下文类型因 agent 而异,所以写 ToolRuntime[Any](不写类型参数时按 None 处理,序列化会告警)
""" """
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
@@ -17,7 +17,8 @@ from pydantic import Field
from app.config import settings from app.config import settings
from app.datafiles.query import MAX_ROWS, QueryError, QueryResult, run_query from app.datafiles.query import MAX_ROWS, QueryError, QueryResult, run_query
from app.schemas.data_file import RequestDataFile, RequestDataTable from app.datafiles.storage import table_path
from app.schemas.data_file import DataFileRecord, DataTable
# 列超过这么多时,不指定 columns 只返回列名和类型,避免一次塞进太多概况 # 列超过这么多时,不指定 columns 只返回列名和类型,避免一次塞进太多概况
BRIEF_COLUMNS = 50 BRIEF_COLUMNS = 50
@@ -25,9 +26,12 @@ BRIEF_COLUMNS = 50
@dataclass(frozen=True) @dataclass(frozen=True)
class DataContext: class DataContext:
files: tuple[RequestDataFile, ...] = () """本次运行可用的数据文件;root 是数据文件的根目录,Parquet 路径由它和文件记录算出"""
def find(self, name: str) -> tuple[RequestDataFile, RequestDataTable] | None: files: tuple[DataFileRecord, ...] = ()
root: Path = field(default_factory=lambda: settings.data_file_dir)
def find(self, name: str) -> tuple[DataFileRecord, DataTable] | None:
for file in self.files: for file in self.files:
for table in file.tables: for table in file.tables:
if table.table == name: if table.table == name:
@@ -36,7 +40,7 @@ class DataContext:
def paths(self) -> dict[str, Path]: def paths(self) -> dict[str, Path]:
return { return {
table.table: Path(table.path) table.table: table_path(self.root, file.file_id, table)
for file in self.files for file in self.files
for table in file.tables for table in file.tables
} }
@@ -86,7 +90,7 @@ def describe_data(
if found is None: if found is None:
return _error(runtime, f"没有表 {table},可用的表:{_table_list(data)}") return _error(runtime, f"没有表 {table},可用的表:{_table_list(data)}")
file, item = found file, item = found
profiled: list[dict[str, Any]] = item.profile["columns"] profiled = item.profile.columns
result: dict[str, Any] = { result: dict[str, Any] = {
"table": item.table, "table": item.table,
"label": item.label, "label": item.label,
@@ -94,20 +98,25 @@ def describe_data(
} }
if columns is None and len(profiled) > BRIEF_COLUMNS: if columns is None and len(profiled) > BRIEF_COLUMNS:
result["columns"] = [ result["columns"] = [
{key: column[key] for key in ("name", "type", "format") if key in column} column.model_dump(
mode="json", include={"name", "type", "format"}, exclude_none=True
)
for column in profiled for column in profiled
] ]
result["note"] = ( result["note"] = (
f"共 {len(profiled)} 列,只列出列名和类型;用 columns 指定列查看详细概况和样例" f"共 {len(profiled)} 列,只列出列名和类型;用 columns 指定列查看详细概况和样例"
) )
else: else:
index = {column["name"]: position for position, column in enumerate(profiled)} index = {column.name: position for position, column in enumerate(profiled)}
names = list(index) if columns is None else list(dict.fromkeys(columns)) names = list(index) if columns is None else list(dict.fromkeys(columns))
positions = [index[name] for name in names if name in index] positions = [index[name] for name in names if name in index]
result["columns"] = [profiled[position] for position in positions] result["columns"] = [
profiled[position].model_dump(mode="json", exclude_none=True)
for position in positions
]
# 样例行的值和 columns 一一对应 # 样例行的值和 columns 一一对应
result["sample_rows"] = [ result["sample_rows"] = [
[row[position] for position in positions] for row in item.profile["sample"] [row[position] for position in positions] for row in item.profile.sample
] ]
if missing := [name for name in names if name not in index]: if missing := [name for name in names if name not in index]:
result["missing_columns"] = missing result["missing_columns"] = missing
+45 -4
View File
@@ -3,13 +3,17 @@
from typing import Annotated, Literal, get_args from typing import Annotated, Literal, get_args
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from app.common.response import ApiResponse, success_response from app.common.response import ApiResponse, error_content, success_response
from app.core.auth import get_current_user from app.core.auth import get_current_user
from app.datafiles.base import MAX_TABLE_ROWS
from app.datafiles.service import ( from app.datafiles.service import (
MAX_FILES_PER_CONVERSATION, MAX_FILES_PER_CONVERSATION,
PARSE_CONCURRENCY, PARSE_CONCURRENCY,
DataFileService, DataFileService,
file_too_large,
) )
from app.schemas.base import NonBlankString from app.schemas.base import NonBlankString
from app.schemas.data_file import ( from app.schemas.data_file import (
@@ -31,18 +35,55 @@ from app.schemas.user import CurrentUser
limits_router = APIRouter(prefix="/files", tags=["DataFile"]) limits_router = APIRouter(prefix="/files", tags=["DataFile"])
router = APIRouter(prefix="/files", tags=["DataFile"]) router = APIRouter(prefix="/files", tags=["DataFile"])
# 文件选择框的过滤条件;后端按文件内容识别格式,不看扩展名 # 翻页取表数据:单页上限,offset 不超过一张表最多的行数
# 翻页取表数据:单页上限,和一张表最多的行数(100 万)
MAX_PAGE_ROWS = 500 MAX_PAGE_ROWS = 500
MAX_OFFSET = 1_000_000 MAX_OFFSET = MAX_TABLE_ROWS
# 文件选择框的过滤条件;后端按文件内容识别格式,不看扩展名
ACCEPT = ".csv,.tsv,.txt,.xlsx,.xls,.xlsb,.json,.jsonl,.ndjson,.parquet" ACCEPT = ".csv,.tsv,.txt,.xlsx,.xls,.xlsb,.json,.jsonl,.ndjson,.parquet"
# 上传的请求体比文件本身多出 multipart 的分隔行、字段名和会话 ID
UPLOAD_OVERHEAD_BYTES = 64 * 1024
def _service(request: Request) -> DataFileService: def _service(request: Request) -> DataFileService:
return request.app.state.data_file_service return request.app.state.data_file_service
class UploadSizeLimit:
"""上传接口在读取请求体之前按 Content-Length 拒绝过大的请求
FastAPI 会先把整个 multipart 请求体收完、写进临时文件,再调用接口函数;
只在 DataFileService 里边写边查大小的话,超大的上传已经占满了带宽和磁盘
"""
def __init__(self, app: ASGIApp, path: str):
self.app = app
self.path = path
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] == "http"
and scope["method"] == "POST"
and scope["path"] == self.path
and (rejection := self._reject(scope)) is not None
):
await rejection(scope, receive, send)
return
await self.app(scope, receive, send)
@staticmethod
def _reject(scope: Scope) -> JSONResponse | None:
max_mb = scope["app"].state.data_file_service.max_mb
length = dict(scope["headers"]).get(b"content-length")
if length is None or not length.isdigit():
return JSONResponse(error_content(411, "缺少 Content-Length"), 411)
if int(length) > max_mb * 1024 * 1024 + UPLOAD_OVERHEAD_BYTES:
error = file_too_large(max_mb)
return JSONResponse(error_content(error.code, error.msg), error.code)
return None
def _detail(record: DataFileRecord) -> DataFileDetail: def _detail(record: DataFileRecord) -> DataFileDetail:
# 按接口模型重新校验,去掉 owner_id、sha256、列概况等只在后端用的字段 # 按接口模型重新校验,去掉 owner_id、sha256、列概况等只在后端用的字段
return DataFileDetail.model_validate(record.model_dump()) return DataFileDetail.model_validate(record.model_dump())
+33 -3
View File
@@ -3,9 +3,18 @@
列名、别名一律通过 quote_identifier 引用,请求里的字符串不会拼进 SQL 的其他位置 列名、别名一律通过 quote_identifier 引用,请求里的字符串不会拼进 SQL 的其他位置
""" """
from pathlib import Path
from app.config import settings
from app.core.exceptions import BizException from app.core.exceptions import BizException
from app.datafiles.query import quote_identifier from app.datafiles.query import quote_identifier, run_query
from app.schemas.data_file import AggregateRequest, DataColumn, GroupBy, Metric from app.schemas.data_file import (
AggregateRequest,
AggregateResult,
DataColumn,
GroupBy,
Metric,
)
# 只能用于数值列的聚合;其余聚合对列类型没有要求 # 只能用于数值列的聚合;其余聚合对列类型没有要求
NUMBER_ONLY = {"sum", "avg"} NUMBER_ONLY = {"sum", "avg"}
@@ -20,7 +29,28 @@ TEMPLATES = {
TIME_TYPES = {"date", "datetime"} TIME_TYPES = {"date", "datetime"}
def build_aggregate_sql( def run_aggregate(
path: Path, request: AggregateRequest, columns: list[DataColumn]
) -> AggregateResult:
"""对一张表的 Parquet 执行汇总;接口和上传后的分析共用
请求不合法时抛 422;执行失败(包括文件已被删除)抛 QueryError
"""
sql, names = _build_sql(request, columns)
result = run_query(
sql,
{"t": path},
timeout=settings.data_query_timeout,
max_rows=request.limit,
)
return AggregateResult(
columns=names,
rows=[dict(zip(names, row)) for row in result.rows],
truncated=result.truncated,
)
def _build_sql(
request: AggregateRequest, columns: list[DataColumn] request: AggregateRequest, columns: list[DataColumn]
) -> tuple[str, list[str]]: ) -> tuple[str, list[str]]:
"""返回 SQL(表名固定为 t)和结果列名;请求不合法时抛 422 """返回 SQL(表名固定为 t)和结果列名;请求不合法时抛 422
+8 -1
View File
@@ -1,4 +1,6 @@
"""数据文件各环节共用的异常和上限""" """数据文件各环节共用的异常、上限和警告文案的写法"""
from collections.abc import Sequence
# 每张表的数据行数上限 # 每张表的数据行数上限
MAX_TABLE_ROWS = 1_000_000 MAX_TABLE_ROWS = 1_000_000
@@ -26,6 +28,11 @@ def where(sheet: str | None) -> str:
return f"{sheet}:" if sheet else "" return f"{sheet}:" if sheet else ""
def brief(items: Sequence[str], sep: str = "、", limit: int = 5) -> str:
"""警告里列举的内容最多写 limit 个,多出的用“等”概括"""
return sep.join(items[:limit]) + ("等" if len(items) > limit else "")
def rows_exceeded(sheet: str | None) -> DataFileError: def rows_exceeded(sheet: str | None) -> DataFileError:
return DataFileError( return DataFileError(
422, f"{where(sheet)}超过 {MAX_TABLE_ROWS // 10_000} 万行,请拆分后上传" 422, f"{where(sheet)}超过 {MAX_TABLE_ROWS // 10_000} 万行,请拆分后上传"
+23 -29
View File
@@ -15,7 +15,7 @@ from typing import Any
import pyarrow as pa import pyarrow as pa
from app.datafiles.base import where from app.datafiles.base import brief, where
from app.schemas.data_file import DataColumn, ParseWarning from app.schemas.data_file import DataColumn, ParseWarning
NULL_TOKENS = {"", "-", "--", "—", "——", "N/A", "n/a", "NULL", "null", "#N/A"} NULL_TOKENS = {"", "-", "--", "—", "——", "N/A", "n/a", "NULL", "null", "#N/A"}
@@ -63,7 +63,7 @@ def clean_names(
"""去掉空白和换行;空列名按位置命名,重复列名加后缀""" """去掉空白和换行;空列名按位置命名,重复列名加后缀"""
names, changes, seen = [], [], set() names, changes, seen = [], [], set()
for position, value in enumerate(raw, start=1): for position, value in enumerate(raw, start=1):
name = re.sub(r"\s*[\r\n]+\s*", " ", label_text(value)).strip() name = re.sub(r"\s*[\r\n]+\s*", " ", as_text(value)).strip()
if not name: if not name:
name = f"列{position}" name = f"列{position}"
changes.append(f"第 {position} 列没有列名,命名为「{name}」") changes.append(f"第 {position} 列没有列名,命名为「{name}」")
@@ -77,19 +77,9 @@ def clean_names(
names.append(name) names.append(name)
if not changes: if not changes:
return names, None return names, None
shown = ";".join(changes[:5]) + ("等" if len(changes) > 5 else "") return names, ParseWarning(
return names, ParseWarning(code="column_renamed", message=f"{where(sheet)}{shown}") code="column_renamed", message=f"{where(sheet)}{brief(changes, ';')}"
)
def label_text(value: Any) -> str:
"""表头单元格转成列名;Excel 里年份等数字表头读出来是 2025.0"""
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
if isinstance(value, (date, time)):
return value.isoformat()
return str(value)
def build_columns( def build_columns(
@@ -257,29 +247,29 @@ def classify(value: Any) -> tuple[str, Any]:
def parse_number(text: str) -> int | float | None: def parse_number(text: str) -> int | float | None:
"""千分位和货币符号可以有;有前导零或 16 位以上的数字串是编号,不算数值""" """千分位和货币符号可以有;有前导零或 16 位以上的数字串是编号,不算数值
# 最常见的纯数字走快速路径,不用正则
if text.isdigit(): 判断数字一律用 isdecimal:isdigit 对 ²、① 也返回 True,但 int() 解析不了它们
if len(text) >= MIN_ID_DIGITS or (len(text) > 1 and text[0] == "0"): """
return None
return int(text)
if text and text[0] in CURRENCY_SYMBOLS: if text and text[0] in CURRENCY_SYMBOLS:
text = text[1:].lstrip() text = text[1:].lstrip()
if not NUMBER.match(text): # 最常见的纯数字不用正则
plain = text.isdecimal()
if not plain and not NUMBER.match(text):
return None return None
digits = text.lstrip("+-") digits = text.lstrip("+-")
if len(digits) > 1 and digits[0] == "0" and digits[1].isdigit(): if len(digits) > 1 and digits[0] == "0" and digits[1].isdecimal():
return None return None
if digits.isdigit() and len(digits) >= MIN_ID_DIGITS: if digits.isdecimal() and len(digits) >= MIN_ID_DIGITS:
return None return None
text = text.replace(",", "") if plain:
if INTEGER.match(text):
return int(text) return int(text)
return float(text) text = text.replace(",", "")
return int(text) if INTEGER.match(text) else float(text)
def parse_datetime(text: str) -> date | datetime | None: def parse_datetime(text: str) -> date | datetime | None:
if len(text) < 6 or not text[:4].isdigit(): if len(text) < 6 or not text[:4].isdecimal():
return None return None
# 标准 ISO 写法先用 fromisoformat,比 strptime 快一个数量级 # 标准 ISO 写法先用 fromisoformat,比 strptime 快一个数量级
if ":" in text: if ":" in text:
@@ -307,6 +297,9 @@ def parse_datetime(text: str) -> date | datetime | None:
def as_text(value: Any) -> str: def as_text(value: Any) -> str:
"""单元格转成文本:列名、保留为文本的值、警告里引用的内容都用它;Excel 里年份等数字读出来是 2025.0"""
if value is None:
return ""
if isinstance(value, str): if isinstance(value, str):
return value.strip() return value.strip()
if isinstance(value, bool): if isinstance(value, bool):
@@ -318,7 +311,8 @@ def as_text(value: Any) -> str:
if isinstance(value, (date, time)): if isinstance(value, (date, time)):
return value.isoformat() return value.isoformat()
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False) # Parquet 的嵌套值里可能有日期、Decimal、bytes,JSON 表示不了的写成字符串
return json.dumps(value, ensure_ascii=False, default=str)
return str(value) return str(value)
+7 -2
View File
@@ -3,6 +3,7 @@
import codecs import codecs
import csv import csv
import json import json
import re
import zipfile import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -29,6 +30,10 @@ DELIMITERS = (",", "\t", ";", "|")
# 判断分隔符只看开头这么多行 # 判断分隔符只看开头这么多行
SNIFF_LINES = 50 SNIFF_LINES = 50
# 开头像 JSON 才按 JSON 处理:对象的 { 后面只能是 " 或 },数组的 [ 后面是一个值的开头;
# CSV 的列名也可能以 [ 开头,例如 [日期],[销售额]
JSON_START = re.compile(r'\{\s*["}]|\[\s*(?:[\[{"\]\d-]|(?:true|false|null)\s*[,\]])')
@dataclass(frozen=True) @dataclass(frozen=True)
class Detected: class Detected:
@@ -56,7 +61,7 @@ def detect(path: Path) -> Detected:
stripped = text.lstrip() stripped = text.lstrip()
if not stripped: if not stripped:
raise DataFileError(422, "文件是空的") raise DataFileError(422, "文件是空的")
if stripped[0] in "[{": if JSON_START.match(stripped):
return _json_format(text, encoding) return _json_format(text, encoding)
delimiter = sniff_delimiter(text) delimiter = sniff_delimiter(text)
return Detected("tsv" if delimiter == "\t" else "csv", encoding, text, delimiter) return Detected("tsv" if delimiter == "\t" else "csv", encoding, text, delimiter)
@@ -115,7 +120,7 @@ def _json_format(text: str, encoding: str) -> Detected:
return Detected("jsonl", encoding, text) return Detected("jsonl", encoding, text)
except ValueError: except ValueError:
pass pass
# 以 [ 或 { 开头却不是合法 JSON,按 JSON 报错比当成 CSV 解析出一堆乱码更有用 # 开头像 JSON 却不是合法 JSON,按 JSON 报错比当成 CSV 解析出一堆乱码更有用
line = getattr(json_error, "lineno", None) line = getattr(json_error, "lineno", None)
position = f"第 {line} 行" if line else "" position = f"第 {line} 行" if line else ""
raise DataFileError( raise DataFileError(
+13 -28
View File
@@ -9,7 +9,6 @@ import json
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from pathlib import Path from pathlib import Path
import duckdb
from langchain_core.exceptions import OutputParserException from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage, SystemMessage
from loguru import logger from loguru import logger
@@ -18,9 +17,9 @@ from pydantic import BaseModel, Field, ValidationError
from app.clients.llm import get_llm from app.clients.llm import get_llm
from app.config import settings from app.config import settings
from app.core.exceptions import BizException from app.core.exceptions import BizException
from app.datafiles.aggregate import build_aggregate_sql from app.datafiles.aggregate import run_aggregate
from app.datafiles.query import QueryError, run_query from app.datafiles.query import QueryError
from app.datafiles.storage import file_dir from app.datafiles.storage import table_path
from app.prompt.insights import INSIGHTS_SYSTEM_PROMPT from app.prompt.insights import INSIGHTS_SYSTEM_PROMPT
from app.schemas.data_file import ( from app.schemas.data_file import (
MAX_SUGGESTIONS, MAX_SUGGESTIONS,
@@ -106,21 +105,16 @@ def build_input(record: DataFileRecord) -> dict:
def _table_input(table: DataTable) -> dict: def _table_input(table: DataTable) -> dict:
columns = table.profile.get("columns", [])[:MAX_COLUMNS]
keep = {column["name"] for column in columns}
positions = [
position for position, column in enumerate(table.columns) if column.name in keep
]
return { return {
"table": table.table, "table": table.table,
"label": table.label, "label": table.label,
"rows": table.rows, "rows": table.rows,
"columns": columns, "columns": [
# 样例行只保留上面那些列,顺序与 columns 一致 column.model_dump(mode="json", exclude_none=True)
"sample": [ for column in table.profile.columns[:MAX_COLUMNS]
[row[position] for position in positions]
for row in table.profile.get("sample", [])
], ],
# 样例行和 columns 都按表的列顺序排列,截掉同样的列
"sample": [row[:MAX_COLUMNS] for row in table.profile.sample],
} }
@@ -140,7 +134,7 @@ def check_draft(
if not title or table is None or key in seen: if not title or table is None or key in seen:
continue continue
if not _fits_chart(item) or not _runs( if not _fits_chart(item) or not _runs(
item, table, file_dir(root, record.file_id) item, table, table_path(root, record.file_id, table)
): ):
logger.info("丢弃不可用的图表推荐:{} {}", record.file_id, item.title) logger.info("丢弃不可用的图表推荐:{} {}", record.file_id, item.title)
continue continue
@@ -162,20 +156,11 @@ def _fits_chart(item: ChartSuggestion) -> bool:
return len(request.metrics) == 1 or item.chart not in ("pie", "funnel") return len(request.metrics) == 1 or item.chart not in ("pie", "funnel")
def _runs(item: ChartSuggestion, table: DataTable, directory: Path) -> bool: def _runs(item: ChartSuggestion, table: DataTable, path: Path) -> bool:
"""真实跑一遍汇总:能编译、能执行,并且有足够的行数画图""" """真实跑一遍汇总:能编译、能执行(文件已被删除也算不能),并且有足够的行数画图"""
path = directory / table.parquet
if not path.is_file():
return False
try: try:
sql, _ = build_aggregate_sql(item.request, table.columns) result = run_aggregate(path, item.request, table.columns)
result = run_query( except (BizException, QueryError):
sql,
{"t": path},
timeout=settings.data_query_timeout,
max_rows=item.request.limit,
)
except (BizException, QueryError, duckdb.Error):
return False return False
return len(result.rows) >= MIN_RESULT_ROWS return len(result.rows) >= MIN_RESULT_ROWS
+39 -44
View File
@@ -3,7 +3,6 @@
只读写本地文件,不碰数据库;上传流程在子进程里调用(worker.py),超时直接终止子进程 只读写本地文件,不碰数据库;上传流程在子进程里调用(worker.py),超时直接终止子进程
""" """
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -29,7 +28,7 @@ from app.datafiles.readers import (
read_parquet, read_parquet,
) )
from app.datafiles.tables import split_tables from app.datafiles.tables import split_tables
from app.schemas.data_file import DataColumn, DataFormat, ParseWarning from app.schemas.data_file import DataColumn, DataFormat, ParseWarning, TableProfile
# 子进程把解析结果转成 JSON 交回上传流程,所以用 pydantic 模型 # 子进程把解析结果转成 JSON 交回上传流程,所以用 pydantic 模型
@@ -39,8 +38,9 @@ class ParsedTable(BaseModel):
part: int | None part: int | None
rows: int rows: int
columns: list[DataColumn] columns: list[DataColumn]
path: Path # out_dir 下的 Parquet 文件名
profile: dict[str, Any] parquet: str
profile: TableProfile
preview: list[list[Any]] preview: list[list[Any]]
warnings: list[ParseWarning] warnings: list[ParseWarning]
@@ -53,40 +53,53 @@ class ParsedFile(BaseModel):
warnings: list[ParseWarning] warnings: list[ParseWarning]
@dataclass
class _Built:
sheet: str | None
part: int | None
typed: TypedColumns
warnings: list[ParseWarning]
def parse_file(path: Path, out_dir: Path) -> ParsedFile: def parse_file(path: Path, out_dir: Path) -> ParsedFile:
"""解析 path,Parquet 写到 out_dir/1.parquet、2.parquet……""" """解析 path,每张表在 out_dir 下写一个 Parquet:1.parquet、2.parquet……"""
detected = detect(path) detected = detect(path)
out_dir.mkdir(parents=True, exist_ok=True)
warnings: list[ParseWarning] = [] warnings: list[ParseWarning] = []
built: list[_Built] = [] tables: list[ParsedTable] = []
def add_table(
sheet: str | None,
part: int | None,
typed: TypedColumns,
table_warnings: list[ParseWarning],
) -> None:
target = out_dir / f"{len(tables) + 1}.parquet"
pq.write_table(typed.table, target)
profile, preview = profile_table(target, typed.columns, typed.notes)
tables.append(
ParsedTable(
sheet=sheet,
part=part,
rows=typed.table.num_rows,
columns=typed.columns,
parquet=target.name,
profile=profile,
preview=preview,
warnings=table_warnings,
)
)
def add_grid(grid: Grid) -> None: def add_grid(grid: Grid) -> None:
tables, dropped = split_tables(grid) found, dropped = split_tables(grid)
warnings.extend(dropped) warnings.extend(dropped)
for table in tables: for table in found:
names, renamed = clean_names(table.header, table.sheet) names, renamed = clean_names(table.header, table.sheet)
typed, kept = build_columns(names, list(zip(*table.rows)), table.sheet) typed, kept = build_columns(names, list(zip(*table.rows)), table.sheet)
built.append( add_table(
_Built( table.sheet,
table.sheet, table.part,
table.part, typed,
typed, [*table.warnings, *_present(renamed, kept)],
[*table.warnings, *_present(renamed, kept)],
)
) )
def add_records(records: Records) -> None: def add_records(records: Records) -> None:
warnings.extend(records.warnings) warnings.extend(records.warnings)
names, renamed = clean_names(records.names, None) names, renamed = clean_names(records.names, None)
typed, kept = build_columns(names, records.columns, None) typed, kept = build_columns(names, records.columns, None)
built.append(_Built(None, None, typed, _present(renamed, kept))) add_table(None, None, typed, _present(renamed, kept))
match detected.format: match detected.format:
case "csv" | "tsv": case "csv" | "tsv":
@@ -107,28 +120,10 @@ def parse_file(path: Path, out_dir: Path) -> ParsedFile:
case "parquet": case "parquet":
typed, renamed = arrow_columns(read_parquet(path)) typed, renamed = arrow_columns(read_parquet(path))
if typed.table.num_rows: if typed.table.num_rows:
built.append(_Built(None, None, typed, _present(renamed))) add_table(None, None, typed, _present(renamed))
if not built: if not tables:
raise DataFileError(422, "文件里没有找到数据") raise DataFileError(422, "文件里没有找到数据")
out_dir.mkdir(parents=True, exist_ok=True)
tables = []
for index, item in enumerate(built, start=1):
target = out_dir / f"{index}.parquet"
pq.write_table(item.typed.table, target)
profile, preview = profile_table(target, item.typed.columns, item.typed.notes)
tables.append(
ParsedTable(
sheet=item.sheet,
part=item.part,
rows=item.typed.table.num_rows,
columns=item.typed.columns,
path=target,
profile=profile,
preview=preview,
warnings=item.warnings,
)
)
return ParsedFile( return ParsedFile(
format=detected.format, format=detected.format,
encoding=detected.encoding, encoding=detected.encoding,
+29 -30
View File
@@ -1,4 +1,4 @@
"""列概况和预览:上传时在 Parquet 上用 DuckDB 计算,存进文件记录,供 describe_data 和接口返回""" """列概况和预览:上传时在 Parquet 上用 DuckDB 计算,存进文件记录,供 describe_data、上传后的分析和接口返回"""
import math import math
from pathlib import Path from pathlib import Path
@@ -6,8 +6,9 @@ from typing import Any
import duckdb import duckdb
from app.datafiles.columns import EXACT_FLOAT_INT
from app.datafiles.query import connect, json_value, quote_identifier from app.datafiles.query import connect, json_value, quote_identifier
from app.schemas.data_file import DataColumn from app.schemas.data_file import ColumnProfile, DataColumn, TableProfile, TopValue
PREVIEW_ROWS = 20 PREVIEW_ROWS = 20
SAMPLE_ROWS = 5 SAMPLE_ROWS = 5
@@ -23,11 +24,10 @@ PREVIEW_CELL_CHARS = 200
def profile_table( def profile_table(
path: Path, columns: list[DataColumn], notes: dict[str, str] path: Path, columns: list[DataColumn], notes: dict[str, str]
) -> tuple[dict[str, Any], list[list[Any]]]: ) -> tuple[TableProfile, list[list[Any]]]:
"""返回列概况和接口用的预览(前 PREVIEW_ROWS 行)""" """返回列概况和接口用的预览(前 PREVIEW_ROWS 行)"""
connection = connect({"t": path}) connection = connect({"t": path})
try: try:
rows = connection.execute("SELECT count(*) FROM t").fetchone()[0]
connection.execute("SUMMARIZE t") connection.execute("SUMMARIZE t")
fields = [column[0] for column in connection.description] fields = [column[0] for column in connection.description]
summary = {row[0]: dict(zip(fields, row)) for row in connection.fetchall()} summary = {row[0]: dict(zip(fields, row)) for row in connection.fetchall()}
@@ -48,13 +48,12 @@ def profile_table(
).fetchall() ).fetchall()
finally: finally:
connection.close() connection.close()
profile = { profile = TableProfile(
"rows": rows, columns=profiled,
"columns": profiled, sample=[
"sample": [
[json_value(value, PROFILE_CELL_CHARS) for value in row] for row in sample [json_value(value, PROFILE_CELL_CHARS) for value in row] for row in sample
], ],
} )
preview = [ preview = [
[json_value(value, PREVIEW_CELL_CHARS) for value in row] [json_value(value, PREVIEW_CELL_CHARS) for value in row]
for row in head[:PREVIEW_ROWS] for row in head[:PREVIEW_ROWS]
@@ -92,25 +91,19 @@ def _profile_column(
head: list[tuple], head: list[tuple],
position: int, position: int,
note: str | None, note: str | None,
) -> dict[str, Any]: ) -> ColumnProfile:
item: dict[str, Any] = {"name": column.name, "type": column.type} item: dict[str, Any] = {}
if column.format:
item["format"] = column.format
null_ratio = float(stats["null_percentage"]) / 100
item["null_ratio"] = round(null_ratio, 4)
# approx_unique 是估算值,小表上可能比行数还大
item["distinct"] = min(
stats["approx_unique"], round(stats["count"] * (1 - null_ratio))
)
if column.type == "number": if column.type == "number":
for key in ("min", "max", "avg", "q25", "q50", "q75"): item = {
item[key] = _number(stats[key]) key: _number(stats[key])
for key in ("min", "max", "avg", "q25", "q50", "q75")
}
elif column.type in ("date", "datetime"): elif column.type in ("date", "datetime"):
# SUMMARIZE 的时间用空格分隔,和预览里的 ISO 写法统一 # SUMMARIZE 的时间用空格分隔,和预览里的 ISO 写法统一
item["min"], item["max"] = ( item = {
None if stats[key] is None else stats[key].replace(" ", "T", 1) key: None if stats[key] is None else stats[key].replace(" ", "T", 1)
for key in ("min", "max") for key in ("min", "max")
) }
if ( if (
column.type in ("text", "boolean") column.type in ("text", "boolean")
and stats["approx_unique"] <= TOP_VALUES_MAX_DISTINCT and stats["approx_unique"] <= TOP_VALUES_MAX_DISTINCT
@@ -121,7 +114,7 @@ def _profile_column(
f"GROUP BY 1 ORDER BY n DESC, 1 LIMIT {TOP_VALUES}" f"GROUP BY 1 ORDER BY n DESC, 1 LIMIT {TOP_VALUES}"
).fetchall() ).fetchall()
item["top_values"] = [ item["top_values"] = [
{"value": json_value(value, PROFILE_CELL_CHARS), "count": count} TopValue(value=json_value(value, PROFILE_CELL_CHARS), count=count)
for value, count in counts for value, count in counts
] ]
examples: list[Any] = [] examples: list[Any] = []
@@ -131,10 +124,16 @@ def _profile_column(
examples.append(value) examples.append(value)
if len(examples) == EXAMPLES: if len(examples) == EXAMPLES:
break break
item["examples"] = examples null_ratio = float(stats["null_percentage"]) / 100
if note: return ColumnProfile(
item["note"] = note **column.model_dump(),
return item null_ratio=round(null_ratio, 4),
# approx_unique 是估算值,小表上可能比行数还大
distinct=min(stats["approx_unique"], round(stats["count"] * (1 - null_ratio))),
examples=examples,
note=note,
**item,
)
def _number(text: str | None) -> int | float | None: def _number(text: str | None) -> int | float | None:
@@ -144,6 +143,6 @@ def _number(text: str | None) -> int | float | None:
value = float(text) value = float(text)
if not math.isfinite(value): if not math.isfinite(value):
return None return None
if value.is_integer() and abs(value) < 2**53: if value.is_integer() and abs(value) < EXACT_FLOAT_INT:
return int(value) return int(value)
return round(value, 6) return round(value, 6)
+5 -10
View File
@@ -19,7 +19,8 @@ from python_calamine import (
SheetVisibleEnum, SheetVisibleEnum,
) )
from app.datafiles.base import MAX_TABLE_ROWS, DataFileError, rows_exceeded from app.datafiles.base import MAX_TABLE_ROWS, DataFileError, brief, rows_exceeded
from app.datafiles.columns import as_text
from app.schemas.data_file import ParseWarning from app.schemas.data_file import ParseWarning
# 原始网格比数据多出的标题、表头、空行,读取时先粗略拦住明显超限的文件,精确的行数在拆表后检查 # 原始网格比数据多出的标题、表头、空行,读取时先粗略拦住明显超限的文件,精确的行数在拆表后检查
@@ -153,7 +154,7 @@ def read_jsonl(text: str) -> Records:
result.warnings.append( result.warnings.append(
ParseWarning( ParseWarning(
code="bad_lines_skipped", code="bad_lines_skipped",
message=f"跳过了 {len(bad)} 行无法解析的内容:{_line_list(bad)}", message=f"跳过了 {len(bad)} 行无法解析的内容:{brief([f'第 {n} 行' for n in bad])}",
) )
) )
return result return result
@@ -230,11 +231,5 @@ def _flatten(record: dict, prefix: str, level: int, out: dict[str, Any]) -> None
def _scalar(value: Any) -> Any: def _scalar(value: Any) -> Any:
if isinstance(value, (dict, list)): # 嵌套的对象、数组存成 JSON 字符串,其余原样交给类型推断
return json.dumps(value, ensure_ascii=False) return as_text(value) if isinstance(value, (dict, list)) else value
return value
def _line_list(numbers: list[int]) -> str:
shown = "、".join(f"第 {number} 行" for number in numbers[:5])
return shown + ("等" if len(numbers) > 5 else "")
+66 -112
View File
@@ -14,12 +14,11 @@ from pathlib import Path
from typing import BinaryIO from typing import BinaryIO
from uuid import uuid4 from uuid import uuid4
import duckdb
from loguru import logger from loguru import logger
from app.config import PROJECT_ROOT, settings from app.config import PROJECT_ROOT, settings
from app.core.exceptions import BizException from app.core.exceptions import BizException
from app.datafiles.aggregate import build_aggregate_sql from app.datafiles.aggregate import run_aggregate
from app.datafiles.insights import ( from app.datafiles.insights import (
INSIGHTS_CONCURRENCY, INSIGHTS_CONCURRENCY,
INSIGHTS_TIMEOUT, INSIGHTS_TIMEOUT,
@@ -27,9 +26,8 @@ from app.datafiles.insights import (
) )
from app.datafiles.parse import ParsedFile, ParsedTable from app.datafiles.parse import ParsedFile, ParsedTable
from app.datafiles.profile import read_rows from app.datafiles.profile import read_rows
from app.datafiles.query import QueryError, run_query from app.datafiles.query import QueryError
from app.datafiles.storage import SOURCE_NAME, DataFileStore, file_dir from app.datafiles.storage import SOURCE_NAME, DataFileStore, table_path
from app.schemas.base import JsonObject
from app.schemas.data_file import ( from app.schemas.data_file import (
AggregateRequest, AggregateRequest,
AggregateResult, AggregateResult,
@@ -37,8 +35,6 @@ from app.schemas.data_file import (
DataTable, DataTable,
DataTableRows, DataTableRows,
FileInsights, FileInsights,
RequestDataFile,
RequestDataTable,
) )
from app.services.conversation import ConversationStore from app.services.conversation import ConversationStore
@@ -75,14 +71,18 @@ class DataFileService:
self._conversations = conversations self._conversations = conversations
self._ttl = timedelta(seconds=ttl or settings.data_file_ttl) self._ttl = timedelta(seconds=ttl or settings.data_file_ttl)
self._parsing = asyncio.Semaphore(PARSE_CONCURRENCY) self._parsing = asyncio.Semaphore(PARSE_CONCURRENCY)
# 文件数检查、分配表名和写入记录要一起完成,并发上传时才不会超限或重名;单实例部署,进程内的锁就够了 # 文件数检查和写入记录要一起完成,并发上传时才不会超限;单实例部署,进程内的锁就够了
self._saving = asyncio.Lock() self._saving = asyncio.Lock()
# 上传后的 LLM 分析;为空表示不分析,文件的 insights 为空 # 上传后的 LLM 分析;为空表示不分析,文件的 insights 为空
self._insights = insights self._insights = insights
self._insight_slots = asyncio.Semaphore(INSIGHTS_CONCURRENCY) self._insight_slots = asyncio.Semaphore(INSIGHTS_CONCURRENCY)
# 正在本进程里分析的文件。记录里是 pending 却不在这里,说明进程重启过、任务已丢失 # 本进程里正在分析的文件,用来拒绝重复的重新分析
self._analysing: set[str] = set() self._analysing: set[str] = set()
self._tasks: set[asyncio.Task] = 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( async def upload(
self, user_id: int, conversation_id: str, name: str | None, source: BinaryIO self, user_id: int, conversation_id: str, name: str | None, source: BinaryIO
@@ -91,14 +91,16 @@ class DataFileService:
# 先粗查一次,满了就不必上传和解析;写入前在锁里再查一次 # 先粗查一次,满了就不必上传和解析;写入前在锁里再查一次
_check_count(await self.store.find(conversation_id)) _check_count(await self.store.find(conversation_id))
file_id = f"f_{uuid4().hex}" file_id = f"f_{uuid4().hex}"
directory = file_dir(self.root, file_id) directory = self.root / file_id
try: try:
size, sha256 = await asyncio.to_thread(self._save, source, directory) size, sha256 = await asyncio.to_thread(self._save, source, directory)
async with self._parsing: async with self._parsing:
parsed = await _parse(directory) parsed = await _parse(directory)
async with self._saving: async with self._saving:
existing = await self.store.find(conversation_id, include_deleted=True) _check_count(await self.store.find(conversation_id))
_check_count([record for record in existing if not record.deleted]) first_number = await self.store.reserve_table_numbers(
conversation_id, len(parsed.tables)
)
record = self._record( record = self._record(
file_id, file_id,
user_id, user_id,
@@ -107,18 +109,11 @@ class DataFileService:
size, size,
sha256, sha256,
parsed, parsed,
_next_table_number(existing), first_number,
) )
if self._insights is not None:
# 先登记再写入,别的请求读到 pending 时不会把它当成丢失的任务
self._analysing.add(file_id)
record = record.model_copy(
update={"insights": FileInsights(status="pending")}
)
await self.store.insert(record) await self.store.insert(record)
except BaseException: except BaseException:
# 失败的上传不留文件,会话内的文件数不变 # 失败的上传不留文件,会话内的文件数不变
self._analysing.discard(file_id)
await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True)
raise raise
if self._insights is not None: if self._insights is not None:
@@ -137,9 +132,7 @@ class DataFileService:
await self._check_conversation(conversation_id, user_id) await self._check_conversation(conversation_id, user_id)
records = await self.store.find(conversation_id) records = await self.store.find(conversation_id)
return [ return [
await self._settle(record) self._settle(record) for record in records if record.owner_id == user_id
for record in records
if record.owner_id == user_id
] ]
async def get_file(self, user_id: int, file_id: str) -> DataFileRecord: async def get_file(self, user_id: int, file_id: str) -> DataFileRecord:
@@ -147,7 +140,7 @@ class DataFileService:
# 他人的文件与不存在同样返回 404 # 他人的文件与不存在同样返回 404
if record is None or record.owner_id != user_id: if record is None or record.owner_id != user_id:
raise BizException(404, NOT_FOUND) raise BizException(404, NOT_FOUND)
return await self._settle(record) return self._settle(record)
async def regenerate_insights(self, user_id: int, file_id: str) -> DataFileRecord: async def regenerate_insights(self, user_id: int, file_id: str) -> DataFileRecord:
"""重新分析,用于上次失败之后;正在分析时 409""" """重新分析,用于上次失败之后;正在分析时 409"""
@@ -157,7 +150,7 @@ class DataFileService:
if file_id in self._analysing: if file_id in self._analysing:
raise BizException(409, "正在分析,请稍后") raise BizException(409, "正在分析,请稍后")
self._analysing.add(file_id) self._analysing.add(file_id)
pending = FileInsights(status="pending") pending = FileInsights(status="pending", started_at=datetime.now(UTC))
try: try:
await self.store.update_insights(file_id, pending) await self.store.update_insights(file_id, pending)
except BaseException: except BaseException:
@@ -179,19 +172,12 @@ class DataFileService:
) -> DataTableRows: ) -> DataTableRows:
"""表的一页数据;表或排序列不存在时 404 / 422,文件已被清理时同样 404""" """表的一页数据;表或排序列不存在时 404 / 422,文件已被清理时同样 404"""
record = await self.get_file(user_id, file_id) record = await self.get_file(user_id, file_id)
table = next((item for item in record.tables if item.table == table_name), None) table, path = self._table(record, table_name)
if table is None:
raise BizException(404, "数据表不存在")
if sort_by is not None and sort_by not in {c.name for c in table.columns}: if sort_by is not None and sort_by not in {c.name for c in table.columns}:
raise BizException(422, f"排序列不存在:{sort_by}") raise BizException(422, f"排序列不存在:{sort_by}")
path = file_dir(self.root, file_id) / table.parquet rows = await asyncio.to_thread(
try: read_rows, path, offset, limit, sort_by, descending
rows = await asyncio.to_thread( )
read_rows, path, offset, limit, sort_by, descending
)
except duckdb.IOException:
# 记录还在但 Parquet 已被删除(过期清理、删除时目录没删净)
raise BizException(404, NOT_FOUND) from None
return DataTableRows(total=table.rows, offset=offset, rows=rows) return DataTableRows(total=table.rows, offset=offset, rows=rows)
async def aggregate( async def aggregate(
@@ -199,81 +185,36 @@ class DataFileService:
) -> AggregateResult: ) -> AggregateResult:
"""对一张表分组汇总,供前端做图表数据;和 agent 的 query_data 同一套只读沙箱""" """对一张表分组汇总,供前端做图表数据;和 agent 的 query_data 同一套只读沙箱"""
record = await self.get_file(user_id, file_id) record = await self.get_file(user_id, file_id)
table = next((item for item in record.tables if item.table == table_name), None) table, path = self._table(record, table_name)
if table is None:
raise BizException(404, "数据表不存在")
sql, names = build_aggregate_sql(request, table.columns)
path = file_dir(self.root, file_id) / table.parquet
if not path.is_file():
raise BizException(404, NOT_FOUND)
try: try:
result = await asyncio.to_thread( return await asyncio.to_thread(run_aggregate, path, request, table.columns)
run_query,
sql,
{"t": path},
timeout=settings.data_query_timeout,
max_rows=request.limit,
)
except QueryError as error: except QueryError as error:
raise BizException(422, str(error)) from None raise BizException(422, str(error)) from None
return AggregateResult(
columns=names,
rows=[dict(zip(names, row)) for row in result.rows],
truncated=result.truncated,
)
async def delete_file(self, user_id: int, file_id: str) -> None: async def delete_file(self, user_id: int, file_id: str) -> None:
await self.get_file(user_id, file_id) await self.get_file(user_id, file_id)
await self.store.mark_deleted(file_id) await self.store.mark_deleted(file_id)
# Windows 上正在查询的 Parquet 删不掉,留给定时清理 # Windows 上正在查询的 Parquet 删不掉,留给定时清理
await asyncio.to_thread( await asyncio.to_thread(shutil.rmtree, self.root / file_id, ignore_errors=True)
shutil.rmtree, file_dir(self.root, file_id), ignore_errors=True
)
async def request_files( async def request_files(
self, user_id: int, conversation_id: str, attachments: list[JsonObject] self, user_id: int, conversation_id: str, attached: set[str]
) -> list[RequestDataFile]: ) -> list[DataFileRecord]:
"""本次请求可用的文件,调用方已校验会话归属 """本次请求可用的文件:当前用户在本会话里未过期的文件,调用方已校验会话归属
attachments 里 kind 为 data_file 的引用必须是本会话未过期的文件,否则 404;其他种类不处理 attached 是本轮附件引用的文件,必须都在其中,否则 404
""" """
records = [ records = [
record record
for record in await self.store.find(conversation_id) for record in await self.store.find(conversation_id)
if record.owner_id == user_id if record.owner_id == user_id
] ]
attached = set()
for item in attachments:
if item.get("kind") != "data_file":
continue
if not isinstance(file_id := item.get("file_id"), str):
raise BizException(422, "data_file 附件缺少 file_id")
attached.add(file_id)
if attached - {record.file_id for record in records}: if attached - {record.file_id for record in records}:
raise BizException(404, NOT_FOUND) raise BizException(404, NOT_FOUND)
return [ return records
RequestDataFile(
file_id=record.file_id,
name=record.name,
attached_this_turn=record.file_id in attached,
tables=[
RequestDataTable.model_validate(
{
**table.model_dump(),
"path": str(
file_dir(self.root, record.file_id) / table.parquet
),
}
)
for table in record.tables
],
warnings=record.warnings,
)
for record in records
]
async def aclose(self) -> None: async def aclose(self) -> None:
"""应用退出时取消还在进行的分析,记录里留下的 pending 下次读取时会记为失败""" """应用退出时取消还在进行的分析,记录里留下的 pending 下次启动后读取时按失败返回"""
for task in list(self._tasks): for task in list(self._tasks):
task.cancel() task.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True) await asyncio.gather(*self._tasks, return_exceptions=True)
@@ -293,6 +234,7 @@ class DataFileService:
await asyncio.sleep(SWEEP_INTERVAL) await asyncio.sleep(SWEEP_INTERVAL)
def _start_insights(self, record: DataFileRecord) -> None: def _start_insights(self, record: DataFileRecord) -> None:
self._analysing.add(record.file_id)
task = asyncio.create_task(self._analyse(record)) task = asyncio.create_task(self._analyse(record))
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(self._tasks.discard) task.add_done_callback(self._tasks.discard)
@@ -323,18 +265,32 @@ class DataFileService:
finally: finally:
self._analysing.discard(record.file_id) self._analysing.discard(record.file_id)
async def _settle(self, record: DataFileRecord) -> DataFileRecord: def _settle(self, record: DataFileRecord) -> DataFileRecord:
"""pending 但本进程没有在分析(重启丢了任务),记为失败,免得前端一直等""" """服务启动前开始、仍是 pending 的分析已随重启丢失,按失败返回,免得前端一直等
只看记录本身,不写回存储:读到的记录可能已经过时,写回会覆盖刚完成的结果
"""
insights = record.insights insights = record.insights
if ( if (
insights is None insights is not None
or insights.status != "pending" and insights.status == "pending"
or record.file_id in self._analysing and (insights.started_at is None or insights.started_at < self._started_at)
): ):
return record return record.model_copy(update={"insights": FileInsights(status="failed")})
failed = FileInsights(status="failed") return record
await self.store.update_insights(record.file_id, failed)
return record.model_copy(update={"insights": failed}) 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: async def _check_conversation(self, conversation_id: str, user_id: int) -> None:
if not await self._conversations.exists(conversation_id, user_id): if not await self._conversations.exists(conversation_id, user_id):
@@ -348,7 +304,7 @@ class DataFileService:
while chunk := source.read(CHUNK_BYTES): while chunk := source.read(CHUNK_BYTES):
size += len(chunk) size += len(chunk)
if size > self.max_mb * 1024 * 1024: if size > self.max_mb * 1024 * 1024:
raise BizException(413, f"文件不能超过 {self.max_mb} MB") raise file_too_large(self.max_mb)
digest.update(chunk) digest.update(chunk)
target.write(chunk) target.write(chunk)
return size, digest.hexdigest() return size, digest.hexdigest()
@@ -371,12 +327,7 @@ class DataFileService:
DataTable( DataTable(
table=table_name, table=table_name,
label=_label(name, table), label=_label(name, table),
sheet=table.sheet, **table.model_dump(exclude={"part", "warnings"}),
rows=table.rows,
columns=table.columns,
preview=table.preview,
profile=table.profile,
parquet=table.path.name,
) )
) )
warnings.extend( warnings.extend(
@@ -397,6 +348,11 @@ class DataFileService:
expires_at=now + self._ttl, expires_at=now + self._ttl,
tables=tables, tables=tables,
warnings=warnings, warnings=warnings,
insights=(
None
if self._insights is None
else FileInsights(status="pending", started_at=now)
),
) )
def _remove_orphans(self, live: set[str]) -> int: def _remove_orphans(self, live: set[str]) -> int:
@@ -466,6 +422,10 @@ def _python() -> tuple[str, dict[str, str] | None]:
return sys.executable, None 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: def _check_count(records: list[DataFileRecord]) -> None:
if len(records) >= MAX_FILES_PER_CONVERSATION: if len(records) >= MAX_FILES_PER_CONVERSATION:
raise BizException( raise BizException(
@@ -473,12 +433,6 @@ def _check_count(records: list[DataFileRecord]) -> None:
) )
def _next_table_number(records: list[DataFileRecord]) -> int:
"""表名在会话内递增,已删除文件的表名也不再使用"""
used = (int(table.table[1:]) for record in records for table in record.tables)
return max(used, default=0) + 1
def _clean_name(name: str | None) -> str: def _clean_name(name: str | None) -> str:
# 有的浏览器会带上本地路径,只保留文件名 # 有的浏览器会带上本地路径,只保留文件名
base = re.split(r"[\\/]", name or "")[-1].strip() base = re.split(r"[\\/]", name or "")[-1].strip()
+34 -19
View File
@@ -2,6 +2,7 @@
目录结构是 DATA_FILE_DIR/<file_id>/,里面有原文件 source 和每张表的 1.parquet、2.parquet…… 目录结构是 DATA_FILE_DIR/<file_id>/,里面有原文件 source 和每张表的 1.parquet、2.parquet……
DuckDB 只能读本地文件,所以文件不进 MongoDB;单实例部署,本地磁盘就够了 DuckDB 只能读本地文件,所以文件不进 MongoDB;单实例部署,本地磁盘就够了
会话内的表名 t1、t2……由每个会话一个的计数器分配,文件删除、过期后表名也不再使用
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -9,16 +10,16 @@ from pathlib import Path
from typing import Any, Protocol from typing import Any, Protocol
from bson.codec_options import CodecOptions from bson.codec_options import CodecOptions
from pymongo import ASCENDING from pymongo import ASCENDING, ReturnDocument
from app.schemas.data_file import DataFileRecord, FileInsights from app.schemas.data_file import DataFileRecord, DataTable, FileInsights
# 原文件不带扩展名:calamine 见到 .xls 等扩展名会按扩展名打开,用户的扩展名和内容不符时会失败 # 原文件不带扩展名:calamine 见到 .xls 等扩展名会按扩展名打开,用户的扩展名和内容不符时会失败
SOURCE_NAME = "source" SOURCE_NAME = "source"
def file_dir(root: Path, file_id: str) -> Path: def table_path(root: Path, file_id: str, table: DataTable) -> Path:
return root / file_id return root / file_id / table.parquet
def _now() -> datetime: def _now() -> datetime:
@@ -34,10 +35,12 @@ class DataFileStore(Protocol):
"""未过期且未删除的记录""" """未过期且未删除的记录"""
... ...
async def find( async def find(self, conversation_id: str) -> list[DataFileRecord]:
self, conversation_id: str, *, include_deleted: bool = False """会话内未过期且未删除的记录,按上传时间排列"""
) -> list[DataFileRecord]: ...
"""会话内未过期的记录,按上传时间排列"""
async def reserve_table_numbers(self, conversation_id: str, count: int) -> int:
"""为会话预留 count 个连续的表号,返回第一个;表号从 1 开始,不会重复分配"""
... ...
async def mark_deleted(self, file_id: str) -> None: ... async def mark_deleted(self, file_id: str) -> None: ...
@@ -54,6 +57,8 @@ class DataFileStore(Protocol):
class InMemoryDataFileStore: class InMemoryDataFileStore:
def __init__(self) -> None: def __init__(self) -> None:
self._records: dict[str, DataFileRecord] = {} self._records: dict[str, DataFileRecord] = {}
# 会话 → 已分配的最大表号
self._table_numbers: dict[str, int] = {}
def _purge_expired(self) -> None: def _purge_expired(self) -> None:
now = _now() now = _now()
@@ -71,18 +76,20 @@ class InMemoryDataFileStore:
record = self._records.get(file_id) record = self._records.get(file_id)
return None if record is None or record.deleted else record return None if record is None or record.deleted else record
async def find( async def find(self, conversation_id: str) -> list[DataFileRecord]:
self, conversation_id: str, *, include_deleted: bool = False
) -> list[DataFileRecord]:
self._purge_expired() self._purge_expired()
records = [ records = [
record record
for record in self._records.values() for record in self._records.values()
if record.conversation_id == conversation_id if record.conversation_id == conversation_id and not record.deleted
and (include_deleted or not record.deleted)
] ]
return sorted(records, key=lambda record: record.created_at) return sorted(records, key=lambda record: record.created_at)
async def reserve_table_numbers(self, conversation_id: str, count: int) -> int:
first = self._table_numbers.get(conversation_id, 0) + 1
self._table_numbers[conversation_id] = first + count - 1
return first
async def mark_deleted(self, file_id: str) -> None: async def mark_deleted(self, file_id: str) -> None:
if (record := self._records.get(file_id)) is not None: if (record := self._records.get(file_id)) is not None:
self._records[file_id] = record.model_copy(update={"deleted": True}) self._records[file_id] = record.model_copy(update={"deleted": True})
@@ -101,6 +108,8 @@ class MongoDataFileStore:
# 读回的时间统一带 UTC 时区;集合名前缀用于测试隔离 # 读回的时间统一带 UTC 时区;集合名前缀用于测试隔离
options = CodecOptions(tz_aware=True, tzinfo=UTC) options = CodecOptions(tz_aware=True, tzinfo=UTC)
self._files = db.get_collection(f"{prefix}data_files", codec_options=options) self._files = db.get_collection(f"{prefix}data_files", codec_options=options)
# 每个会话一条:_id 是会话 ID,tables 是已分配的最大表号;会话一直保留,计数器也不过期
self._table_numbers = db.get_collection(f"{prefix}data_file_table_numbers")
async def ensure_indexes(self) -> None: async def ensure_indexes(self) -> None:
# 过期记录由 TTL 索引删除;TTL 每分钟才扫一次,查询时仍按 expires_at 过滤 # 过期记录由 TTL 索引删除;TTL 每分钟才扫一次,查询时仍按 expires_at 过滤
@@ -118,15 +127,12 @@ class MongoDataFileStore:
) )
return None if document is None else _record(document) return None if document is None else _record(document)
async def find( async def find(self, conversation_id: str) -> list[DataFileRecord]:
self, conversation_id: str, *, include_deleted: bool = False query = {
) -> list[DataFileRecord]:
query: dict[str, Any] = {
"conversation_id": conversation_id, "conversation_id": conversation_id,
"deleted": False,
"expires_at": {"$gt": _now()}, "expires_at": {"$gt": _now()},
} }
if not include_deleted:
query["deleted"] = False
documents = await ( documents = await (
self._files.find(query) self._files.find(query)
.sort([("created_at", ASCENDING), ("_id", ASCENDING)]) .sort([("created_at", ASCENDING), ("_id", ASCENDING)])
@@ -134,6 +140,15 @@ class MongoDataFileStore:
) )
return [_record(document) for document in documents] return [_record(document) for document in documents]
async def reserve_table_numbers(self, conversation_id: str, count: int) -> int:
document = await self._table_numbers.find_one_and_update(
{"_id": conversation_id},
{"$inc": {"tables": count}},
upsert=True,
return_document=ReturnDocument.AFTER,
)
return document["tables"] - count + 1
async def mark_deleted(self, file_id: str) -> None: async def mark_deleted(self, file_id: str) -> None:
await self._files.update_one({"_id": file_id}, {"$set": {"deleted": True}}) await self._files.update_one({"_id": file_id}, {"$set": {"deleted": True}})
+5 -7
View File
@@ -8,8 +8,8 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from app.datafiles.base import MAX_TABLE_ROWS, rows_exceeded, where from app.datafiles.base import MAX_TABLE_ROWS, brief, rows_exceeded, where
from app.datafiles.columns import classify, label_text from app.datafiles.columns import as_text, classify
from app.datafiles.readers import Grid from app.datafiles.readers import Grid
from app.schemas.data_file import ParseWarning from app.schemas.data_file import ParseWarning
@@ -209,9 +209,7 @@ def _finish(rows: list[list[Any]], piece: _Piece, sheet: str | None) -> RawTable
totals = [index for index, row in data if _is_total(row)] totals = [index for index, row in data if _is_total(row)]
if totals: if totals:
data = [(index, row) for index, row in data if not _is_total(row)] data = [(index, row) for index, row in data if not _is_total(row)]
listed = "、".join(str(index + 1) for index in totals[:5]) + ( listed = brief([str(index + 1) for index in totals])
"等" if len(totals) > 5 else ""
)
warnings.append( warnings.append(
ParseWarning( ParseWarning(
code="total_rows_removed", code="total_rows_removed",
@@ -250,7 +248,7 @@ def _merge_header(top: list[Any], sub: list[Any]) -> list[str]:
for upper, lower in zip(top, sub): for upper, lower in zip(top, sub):
if upper is not None: if upper is not None:
carry = upper carry = upper
parts = [label_text(part) for part in (carry, lower) if part is not None] parts = [as_text(part) for part in (carry, lower) if part is not None]
merged.append("-".join(parts)) merged.append("-".join(parts))
return merged return merged
@@ -280,7 +278,7 @@ def _quote(row: list[Any]) -> str:
value = next((value for value in row if value is not None), None) value = next((value for value in row if value is not None), None)
if value is None: if value is None:
return "" return ""
text = label_text(value) text = as_text(value)
if len(text) > QUOTE_CHARS: if len(text) > QUOTE_CHARS:
text = text[:QUOTE_CHARS] + "…" text = text[:QUOTE_CHARS] + "…"
return f"「{text}」" return f"「{text}」"
+3
View File
@@ -8,6 +8,7 @@ from loguru import logger
from app.api.agent import router from app.api.agent import router
from app.api.conversations import router as conversation_router from app.api.conversations import router as conversation_router
from app.api.files import UploadSizeLimit
from app.api.files import limits_router as file_limits_router from app.api.files import limits_router as file_limits_router
from app.api.files import router as file_router from app.api.files import router as file_router
from app.api.materials import router as material_router from app.api.materials import router as material_router
@@ -98,6 +99,8 @@ def create_app() -> FastAPI:
# 上传限制无需登录;要先于 /files/{file_id} 注册,否则会被当成文件 ID # 上传限制无需登录;要先于 /files/{file_id} 注册,否则会被当成文件 ID
application.include_router(file_limits_router, prefix="/api") application.include_router(file_limits_router, prefix="/api")
application.include_router(file_router, prefix="/api", dependencies=authenticated) application.include_router(file_router, prefix="/api", dependencies=authenticated)
# 上传在读请求体之前先按 Content-Length 检查大小
application.add_middleware(UploadSizeLimit, path=f"/api{file_router.prefix}")
application.include_router(material_router, prefix="/api") application.include_router(material_router, prefix="/api")
return application return application
+10 -3
View File
@@ -55,14 +55,21 @@ def flush_tracing() -> None:
logger.opt(exception=exc).warning("Langfuse flush 失败") logger.opt(exception=exc).warning("Langfuse flush 失败")
async def astream_screen(graph, payload, *, thread_id, session_id=None, user_id=None): async def astream_screen(
"""同一轮流保持同一个追踪上下文,关闭流时结束追踪""" graph, payload, *, thread_id, session_id=None, user_id=None, context=None
):
"""同一轮流保持同一个追踪上下文,关闭流时结束追踪;context 是图的运行上下文,不进检查点"""
from contextlib import aclosing from contextlib import aclosing
result = {} result = {}
with _screen_trace(payload, thread_id, session_id, user_id) as (config, root): with _screen_trace(payload, thread_id, session_id, user_id) as (config, root):
async with aclosing( async with aclosing(
graph.astream(payload, config=config, stream_mode=["custom", "values"]) graph.astream(
payload,
config=config,
context=context,
stream_mode=["custom", "values"],
)
) as events: ) as events:
async for mode, data in events: async for mode, data in events:
if mode == "values": if mode == "values":
+2 -2
View File
@@ -11,7 +11,7 @@ from app.processor.nodes.intent_node import IntentNode
from app.processor.nodes.qa_node import QANode from app.processor.nodes.qa_node import QANode
from app.processor.nodes.screen_agent_node import ScreenAgentNode from app.processor.nodes.screen_agent_node import ScreenAgentNode
from app.processor.nodes.wait_for_user_node import WaitForUserNode from app.processor.nodes.wait_for_user_node import WaitForUserNode
from app.processor.state import QueryState from app.processor.state import QueryState, RunContext
def build_checkpoint_serde() -> JsonPlusSerializer: def build_checkpoint_serde() -> JsonPlusSerializer:
@@ -47,7 +47,7 @@ def route_after_wait(state: QueryState) -> str:
def build_graph(checkpointer=None): def build_graph(checkpointer=None):
workflow = StateGraph(QueryState) workflow = StateGraph(QueryState, context_schema=RunContext)
nodes = { nodes = {
"input_node": InputNode(), "input_node": InputNode(),
"intent_node": IntentNode(), "intent_node": IntentNode(),
+2 -3
View File
@@ -10,11 +10,10 @@ from langchain_core.messages import AIMessageChunk, HumanMessage
from langchain_core.output_parsers import StrOutputParser from langchain_core.output_parsers import StrOutputParser
from langgraph.config import get_stream_writer from langgraph.config import get_stream_writer
from app.agent.data_tools import DataContext
from app.agent.qa_agent import QAContext, get_qa_agent from app.agent.qa_agent import QAContext, get_qa_agent
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.history import history_messages from app.processor.history import history_messages
from app.processor.state import QueryState from app.processor.state import QueryState, run_data
from app.processor.summary import data_file_summary from app.processor.summary import data_file_summary
# 模型在一次回答里分几次输出文字(例如查询前后各说一段)时,段落之间空一行 # 模型在一次回答里分几次输出文字(例如查询前后各说一段)时,段落之间空一行
@@ -56,7 +55,7 @@ class QANode(BaseNode):
HumanMessage(content=json.dumps(payload, ensure_ascii=False)), HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
] ]
}, },
context=QAContext(data=DataContext(tuple(context.data_files))), context=QAContext(data=run_data()),
stream_mode="messages", stream_mode="messages",
): ):
# 工具结果、工具调用和中间件插入的消息都不推送 # 工具结果、工具调用和中间件插入的消息都不推送
+2 -3
View File
@@ -7,12 +7,11 @@ import json
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
from app.agent.data_tools import DataContext
from app.agent.screen_agent import AgentReply, get_screen_agent from app.agent.screen_agent import AgentReply, get_screen_agent
from app.agent.tools import ScreenAgentContext from app.agent.tools import ScreenAgentContext
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.history import history_messages from app.processor.history import history_messages
from app.processor.state import QueryState from app.processor.state import QueryState, run_data
from app.processor.summary import request_summary from app.processor.summary import request_summary
from app.resources.component import get_catalog_cache from app.resources.component import get_catalog_cache
@@ -68,7 +67,7 @@ class ScreenAgentNode(BaseNode):
context=ScreenAgentContext( context=ScreenAgentContext(
screen=context.screen, screen=context.screen,
catalog=catalog, catalog=catalog,
data=DataContext(tuple(context.data_files)), data=run_data(),
), ),
) )
if result.get("structured_response") is None: if result.get("structured_response") is None:
+22
View File
@@ -1,5 +1,9 @@
from dataclasses import dataclass, field
from typing import Literal, NotRequired, TypedDict from typing import Literal, NotRequired, TypedDict
from langgraph.runtime import get_runtime
from app.agent.data_tools import DataContext
from app.schemas.actions import Action from app.schemas.actions import Action
from app.schemas.base import JsonObject from app.schemas.base import JsonObject
from app.schemas.clarification import Clarification from app.schemas.clarification import Clarification
@@ -25,3 +29,21 @@ class QueryState(TypedDict):
status: NotRequired[ status: NotRequired[
Literal["running", "waiting_for_input", "completed", "cancelled", "failed"] Literal["running", "waiting_for_input", "completed", "cancelled", "failed"]
] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行 ] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行
@dataclass(frozen=True)
class RunContext:
"""图的运行上下文:本次运行要用、但不进检查点的依赖,每次启动和恢复时由调用方传入"""
# 本会话的数据文件;状态里的 input_context.data_files 只有摘要,列概况和路径在这里
data: DataContext = field(default_factory=DataContext)
def run_data() -> DataContext:
"""当前运行可用的数据表;单独调用节点(不在图里执行)或调用方没传 context 时为空"""
try:
runtime = get_runtime(RunContext)
except RuntimeError:
return DataContext()
context = runtime.context if runtime is not None else None
return context.data if context is not None else DataContext()
+43 -12
View File
@@ -44,6 +44,41 @@ class ParseWarning(BaseModel):
message: str message: str
class TopValue(BaseModel):
value: JsonValue
count: int
class ColumnProfile(DataColumn):
"""一列的概况,上传时在全表上算出;不适用的统计为空,交给模型时省略"""
null_ratio: float
# 去重值个数,是估算值
distinct: int
# 数值列是数字,日期、日期时间列是 ISO 写法的字符串
min: int | float | str | None = None
max: int | float | str | None = None
# 只有数值列有
avg: int | float | None = None
q25: int | float | None = None
q50: int | float | None = None
q75: int | float | None = None
# 去重值不多的文本、布尔列才有,按出现次数从多到少
top_values: list[TopValue] | None = None
# 开头几行里不同的取值
examples: list[JsonValue]
# 保留为文本的原因等说明
note: str | None = None
class TableProfile(BaseModel):
"""表的列概况,给 describe_data 和上传后的分析用"""
columns: list[ColumnProfile]
# 随机抽的几行,顺序与 columns 一致
sample: list[list[JsonValue]]
class DataTableInfo(BaseModel): class DataTableInfo(BaseModel):
# 会话内的表名 t1、t2……,模型写 SQL 时用 # 会话内的表名 t1、t2……,模型写 SQL 时用
table: str table: str
@@ -60,8 +95,7 @@ class DataTableDetail(DataTableInfo):
class DataTable(DataTableDetail): class DataTable(DataTableDetail):
# 列概况,给 describe_data 用 profile: TableProfile
profile: dict[str, JsonValue]
# 文件目录下的 Parquet 文件名 # 文件目录下的 Parquet 文件名
parquet: str parquet: str
@@ -162,6 +196,8 @@ class FileInsights(BaseModel):
version: int = 1 version: int = 1
# pending:正在分析;ready:已完成(推荐可能为空);failed:分析失败,文件本身可用 # pending:正在分析;ready:已完成(推荐可能为空);failed:分析失败,文件本身可用
status: Literal["pending", "ready", "failed"] status: Literal["pending", "ready", "failed"]
# pending 时是开始分析的时间;服务启动前开始、至今仍是 pending 的分析已随重启丢失,读取时按失败返回
started_at: datetime | None = None
summary: str = "" summary: str = ""
suggestions: list[ChartSuggestion] = Field(default_factory=list) suggestions: list[ChartSuggestion] = Field(default_factory=list)
columns: list[ColumnInsight] = Field(default_factory=list) columns: list[ColumnInsight] = Field(default_factory=list)
@@ -224,19 +260,14 @@ class DataFileLimits(BaseModel):
aggregate_max_rows: int aggregate_max_rows: int
class RequestDataTable(DataTableInfo):
"""本次请求可用的表:多出工具要用的列概况和 Parquet 路径,不带预览"""
profile: dict[str, JsonValue]
path: str
class RequestDataFile(BaseModel): class RequestDataFile(BaseModel):
"""本次请求可用的文件:服务端按会话读取后放进请求,前端传的同名字段会被覆盖""" """本次请求可用的文件:服务端按会话读取后放进请求,前端传的同名字段会被覆盖
请求会进检查点,所以只放表名、行数和列;列概况和文件路径在图的运行上下文里,见 processor/state.py
"""
file_id: str file_id: str
name: str name: str
# 本轮消息的 attachments 里带了这个文件,通常是刚上传的 # 本轮消息的 attachments 里带了这个文件,通常是刚上传的
attached_this_turn: bool attached_this_turn: bool
tables: list[RequestDataTable] tables: list[DataTableInfo]
warnings: list[ParseWarning]
+20 -1
View File
@@ -1,6 +1,6 @@
"""前端设计请求:原始需求、当前大屏及可选补充上下文""" """前端设计请求:原始需求、当前大屏及可选补充上下文"""
from pydantic import Field from pydantic import Field, field_validator
from .base import ExtensibleModel, JsonObject, NonBlankString from .base import ExtensibleModel, JsonObject, NonBlankString
from .screen import Screen from .screen import Screen
@@ -20,3 +20,22 @@ class DesignRequest(ExtensibleModel):
# 本条消息附带的材料;{"kind": "data_file", "file_id": "..."} 引用本会话上传的数据文件, # 本条消息附带的材料;{"kind": "data_file", "file_id": "..."} 引用本会话上传的数据文件,
# 其他种类只原样进入摘要,不读取文件或远程资源 # 其他种类只原样进入摘要,不读取文件或远程资源
attachments: list[JsonObject] = Field(default_factory=list) attachments: list[JsonObject] = Field(default_factory=list)
@field_validator("attachments")
@classmethod
def _data_files_have_ids(cls, value: list[JsonObject]) -> list[JsonObject]:
for item in value:
if item.get("kind") == "data_file" and not isinstance(
item.get("file_id"), str
):
raise ValueError("data_file 附件缺少 file_id")
return value
@property
def data_file_ids(self) -> set[str]:
"""本条消息附带的数据文件"""
return {
str(item["file_id"])
for item in self.attachments
if item.get("kind") == "data_file"
}
+27 -4
View File
@@ -7,13 +7,16 @@ from uuid import uuid4
from langgraph.types import Command from langgraph.types import Command
from loguru import logger from loguru import logger
from app.agent.data_tools import DataContext
from app.config import settings from app.config import settings
from app.core.exceptions import BizException from app.core.exceptions import BizException
from app.datafiles.service import DataFileService from app.datafiles.service import DataFileService
from app.observability.tracing import astream_screen from app.observability.tracing import astream_screen
from app.processor.history import trim_history from app.processor.history import trim_history
from app.processor.state import RunContext
from app.schemas.agent import AgentResult, ResumeRequest from app.schemas.agent import AgentResult, ResumeRequest
from app.schemas.conversation import Turn from app.schemas.conversation import Turn
from app.schemas.data_file import DataFileRecord, RequestDataFile
from app.schemas.request import DesignRequest from app.schemas.request import DesignRequest
from app.services.conversation import ConversationStore from app.services.conversation import ConversationStore
@@ -43,8 +46,9 @@ class AgentService:
conversation_id, settings.conversation_history_turns conversation_id, settings.conversation_history_turns
) )
# 附件引用的文件不属于本会话时在流开始前返回 404 # 附件引用的文件不属于本会话时在流开始前返回 404
data_files = await self.data_files.request_files( attached = request.data_file_ids
user_id, conversation_id, request.attachments records = await self.data_files.request_files(
user_id, conversation_id, attached
) )
except BaseException: except BaseException:
self.release(thread_id) self.release(thread_id)
@@ -64,7 +68,16 @@ class AgentService:
settings.history_max_tokens, settings.history_max_tokens,
) )
# 同样只信任服务端读取的结果;会话内的文件每一轮都可用,不附带也能查询 # 同样只信任服务端读取的结果;会话内的文件每一轮都可用,不附带也能查询
payload["data_files"] = [item.model_dump(mode="json") for item in data_files] # 请求会进检查点,只放摘要;工具要用的列概况和路径通过运行上下文传给图
payload["data_files"] = [
RequestDataFile(
file_id=record.file_id,
name=record.name,
attached_this_turn=record.file_id in attached,
tables=record.tables,
).model_dump(mode="json")
for record in records
]
return ( return (
thread_id, thread_id,
@@ -76,6 +89,7 @@ class AgentService:
}, },
conversation_id, conversation_id,
user_id, user_id,
self._run_context(records),
) )
async def prepare_resume(self, request: ResumeRequest, user_id: int): async def prepare_resume(self, request: ResumeRequest, user_id: int):
@@ -117,6 +131,10 @@ class AgentService:
) )
} }
) )
# 运行上下文不在检查点里,恢复时按会话重新读取;期间删除或过期的文件工具查不到
records = await self.data_files.request_files(
user_id, conversation_id, set()
)
# 每次有效补充或取消生成新的输入 ID,任务和会话保持不变 # 每次有效补充或取消生成新的输入 ID,任务和会话保持不变
return ( return (
@@ -127,11 +145,15 @@ class AgentService:
), ),
conversation_id, conversation_id,
user_id, user_id,
self._run_context(records),
) )
except BaseException: except BaseException:
self.release(thread_id) self.release(thread_id)
raise raise
def _run_context(self, records: list[DataFileRecord]) -> RunContext:
return RunContext(data=DataContext(tuple(records), self.data_files.root))
def release(self, thread_id): def release(self, thread_id):
self._active.discard(thread_id) self._active.discard(thread_id)
self._conversation_of.pop(thread_id, None) self._conversation_of.pop(thread_id, None)
@@ -199,7 +221,7 @@ class AgentService:
clarification=state.get("clarification") if waiting else None, clarification=state.get("clarification") if waiting else None,
) )
async def stream(self, thread_id, payload, session_id, user_id=None): async def stream(self, thread_id, payload, session_id, user_id=None, context=None):
"""消费图事件,完整状态只用于整理最终结果,不直接暴露给客户端""" """消费图事件,完整状态只用于整理最终结果,不直接暴露给客户端"""
state = None state = None
with logger.contextualize(thread_id=thread_id): with logger.contextualize(thread_id=thread_id):
@@ -210,6 +232,7 @@ class AgentService:
thread_id=thread_id, thread_id=thread_id,
session_id=session_id, session_id=session_id,
user_id=user_id, user_id=user_id,
context=context,
) )
) as events: ) as events:
async for mode, data in events: async for mode, data in events: