307 lines
9.8 KiB
Python
307 lines
9.8 KiB
Python
"""数据文件的接口模型:文件记录、表、列和解析警告,见 docs/screen-agent/data-files.md 5.4、6.6、7
|
|
|
|
文件和表各有三层,后一层在前一层上加字段:列表接口返回的 Info、单个文件返回的 Detail、存储用的完整记录
|
|
接口返回时用对应的模型重新校验存储记录,多出的字段自然丢掉
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
|
|
DataFormat = Literal["csv", "tsv", "xlsx", "xls", "xlsb", "json", "jsonl", "parquet"]
|
|
|
|
ColumnType = Literal["text", "number", "date", "datetime", "boolean"]
|
|
|
|
WarningCode = Literal[
|
|
"title_skipped",
|
|
"note_skipped",
|
|
"block_dropped",
|
|
"header_merged",
|
|
"header_uncertain",
|
|
"total_rows_removed",
|
|
"column_renamed",
|
|
"kept_as_text",
|
|
"bad_lines_skipped",
|
|
"json_path_selected",
|
|
"empty_sheet_skipped",
|
|
"hidden_sheet_skipped",
|
|
]
|
|
|
|
|
|
class DataColumn(BaseModel):
|
|
name: str
|
|
type: ColumnType
|
|
# 原文是百分比时为 percent,值已存为小数
|
|
format: Literal["percent"] | None = None
|
|
|
|
|
|
class ParseWarning(BaseModel):
|
|
code: WarningCode
|
|
# 表名;为空表示针对整个文件。解析时还没有分配表名,由上传流程填写
|
|
table: str | None = None
|
|
# 可以直接展示的中文说明
|
|
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):
|
|
# 会话内的表名 t1、t2……,模型写 SQL 时用
|
|
table: str
|
|
# 文件名 / sheet,同一 sheet 切出多张表时再加序号
|
|
label: str
|
|
sheet: str | None
|
|
rows: int
|
|
columns: list[DataColumn]
|
|
|
|
|
|
class DataTableDetail(DataTableInfo):
|
|
# 前 20 行,顺序与 columns 一致
|
|
preview: list[list[JsonValue]]
|
|
|
|
|
|
class DataTable(DataTableDetail):
|
|
profile: TableProfile
|
|
# 文件目录下的 Parquet 文件名
|
|
parquet: str
|
|
|
|
|
|
Aggregation = Literal["sum", "avg", "min", "max", "count", "count_distinct"]
|
|
|
|
# 日期列按粒度分组,结果是字符串:年 2025,季度 2025-Q1,月 2025-01,日 2025-01-31
|
|
Grain = Literal["year", "quarter", "month", "day"]
|
|
|
|
MAX_GROUP_BY = 2
|
|
MAX_METRICS = 5
|
|
MAX_AGGREGATE_ROWS = 1000
|
|
|
|
|
|
class GroupBy(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
column: str
|
|
# 只有日期、日期时间列可以指定
|
|
grain: Grain | None = None
|
|
|
|
|
|
class Metric(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
agg: Aggregation
|
|
# count 可以不写,数整张表的行数;其他聚合必填。sum、avg 只能用于数值列
|
|
column: str | None = None
|
|
# 结果列名,默认沿用列名(计数类加后缀),按需指定以避免重名
|
|
alias: str | None = Field(default=None, min_length=1, max_length=100)
|
|
|
|
|
|
class OrderBy(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
# 必须是结果里的列名(分组列或指标的别名)
|
|
by: str
|
|
direction: Literal["asc", "desc"] = "asc"
|
|
|
|
|
|
class AggregateRequest(BaseModel):
|
|
"""对一张表分组汇总:group_by 为空时整表汇总成一行"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
group_by: list[GroupBy] = Field(default_factory=list, max_length=MAX_GROUP_BY)
|
|
metrics: list[Metric] = Field(min_length=1, max_length=MAX_METRICS)
|
|
# 不写时有分组按第一个分组列升序
|
|
order_by: OrderBy | None = None
|
|
limit: int = Field(default=100, ge=1, le=MAX_AGGREGATE_ROWS)
|
|
|
|
|
|
class AggregateResult(BaseModel):
|
|
# 结果列名,按分组列、指标的顺序
|
|
columns: list[str]
|
|
# 对象数组,键是列名,可直接作为图表物料的 dataset.source
|
|
rows: list[dict[str, JsonValue]]
|
|
# 结果超过 limit 行,只返回了前 limit 行
|
|
truncated: bool
|
|
|
|
|
|
# 前端图表物料支持绑定汇总数据的系列类型,推荐只会给出这几种
|
|
ChartKind = Literal["bar", "line", "pie", "funnel", "pictorialBar"]
|
|
|
|
ColumnRole = Literal["dimension", "measure", "time", "id", "other"]
|
|
|
|
MAX_SUGGESTIONS = 3
|
|
|
|
|
|
class ChartSuggestion(BaseModel):
|
|
"""一条图表推荐:一个可以直接回答的问题,加上能直接跑的汇总请求"""
|
|
|
|
# 给用户看的问题,如“各区域销售额对比”,也作为图表标题
|
|
title: str
|
|
# 针对哪张表,是文件里的表名(t1、t2……)
|
|
table: str
|
|
chart: ChartKind
|
|
request: AggregateRequest
|
|
|
|
|
|
class ColumnInsight(BaseModel):
|
|
table: str
|
|
# 原始列名,与 columns[].name 对应
|
|
name: str
|
|
# 中文含义,列名已经够清楚时与列名相同
|
|
label: str
|
|
role: ColumnRole
|
|
# 单位,如“元”“%”“件”;没有或不确定时为空
|
|
unit: str | None = None
|
|
|
|
|
|
class FileInsights(BaseModel):
|
|
"""上传后由 LLM 一次分析出的文件洞察:一句话说明、图表推荐、列的含义
|
|
|
|
后续要加的分析结果都往这里加带默认值的字段,version 随字段含义变化递增
|
|
"""
|
|
|
|
version: int = 1
|
|
# pending:正在分析;ready:已完成(推荐可能为空);failed:分析失败,文件本身可用
|
|
status: Literal["pending", "ready", "failed"]
|
|
# pending 时是开始分析的时间;服务启动前开始、至今仍是 pending 的分析已随重启丢失,读取时按失败返回
|
|
started_at: datetime | None = None
|
|
summary: str = ""
|
|
suggestions: list[ChartSuggestion] = Field(default_factory=list)
|
|
columns: list[ColumnInsight] = Field(default_factory=list)
|
|
|
|
|
|
class DataFileInfo(BaseModel):
|
|
file_id: str
|
|
conversation_id: str
|
|
name: str
|
|
format: DataFormat
|
|
# 文本文件识别出的编码,二进制格式为空
|
|
encoding: str | None
|
|
size: int
|
|
created_at: datetime
|
|
expires_at: datetime
|
|
tables: list[DataTableInfo]
|
|
warnings: list[ParseWarning]
|
|
# 未启用智能分析,或这是功能上线前上传的文件时为空
|
|
insights: FileInsights | None = None
|
|
|
|
|
|
class DataFileDetail(DataFileInfo):
|
|
tables: list[DataTableDetail]
|
|
|
|
|
|
class DataFileRecord(DataFileDetail):
|
|
owner_id: int
|
|
sha256: str
|
|
tables: list[DataTable]
|
|
# 删除只做标记,记录留到过期,会话内的表名不会被新文件重复使用
|
|
deleted: bool = False
|
|
|
|
|
|
UploadStatus = Literal["queued", "parsing", "ready", "failed"]
|
|
|
|
UploadStage = Literal["detecting", "reading", "building"]
|
|
|
|
|
|
class UploadError(BaseModel):
|
|
# 与同步接口失败时的 HTTP 状态码和 msg 一致,如 422 + 解析失败的原因
|
|
code: int
|
|
msg: str
|
|
|
|
|
|
class UploadProgress(BaseModel):
|
|
"""一次上传的解析进度;上传接口、进度接口和文件列表里的 uploads 都是这个结构"""
|
|
|
|
file_id: str
|
|
name: str
|
|
# queued 排队中;parsing 解析中;ready 已完成,文件记录已可查;failed 失败,原因在 error
|
|
status: UploadStatus
|
|
# 解析完成的比例,0 到 1,只增不减;ready 时为 1,解析中不会到 1
|
|
progress: float
|
|
# 解析中所处的环节;其他状态为空
|
|
stage: UploadStage | None
|
|
# 预计还要多少秒;排队中、刚开始解析、已结束时为空
|
|
eta_seconds: float | None
|
|
# 排队中时,前面还有几个文件在排队;其他状态为 0
|
|
queued_ahead: int
|
|
error: UploadError | None
|
|
|
|
|
|
class DataFileList(BaseModel):
|
|
files: list[DataFileInfo]
|
|
# 本会话里排队、解析中和刚失败的上传,刷新页面后据此恢复轮询;完成的已在 files 里
|
|
uploads: list[UploadProgress] = Field(default_factory=list)
|
|
|
|
|
|
class DataTableRows(BaseModel):
|
|
"""一张表的一页数据,用于预览时翻页看全表"""
|
|
|
|
# 表的总行数(与文件记录里的 rows 一致),前端据此决定滚动长度
|
|
total: int
|
|
offset: int
|
|
# 这一页的行,顺序与 columns 一致;到末尾时可能少于请求的 limit
|
|
rows: list[list[JsonValue]]
|
|
|
|
|
|
class DataFileLimits(BaseModel):
|
|
max_size_mb: int
|
|
max_files_per_conversation: int
|
|
formats: list[DataFormat]
|
|
# 文件选择框的 accept,只用于前端过滤;后端按文件内容识别格式
|
|
accept: str
|
|
# 同时解析的文件数,其余排队;前端据此决定并发上传几个
|
|
parse_concurrency: int
|
|
# 翻页取表数据时单页行数上限
|
|
rows_max_page: int
|
|
# 分组汇总的指标个数上限、返回行数上限
|
|
aggregate_max_metrics: int
|
|
aggregate_max_rows: int
|
|
|
|
|
|
class RequestDataFile(BaseModel):
|
|
"""本次请求可用的文件:服务端按会话读取后放进请求,前端传的同名字段会被覆盖
|
|
|
|
请求会进检查点,所以只放表名、行数和列;列概况和文件路径在图的运行上下文里,见 processor/state.py
|
|
"""
|
|
|
|
file_id: str
|
|
name: str
|
|
# 本轮消息的 attachments 里带了这个文件,通常是刚上传的
|
|
attached_this_turn: bool
|
|
tables: list[DataTableInfo]
|
|
# 上传后的分析结果,只有 ready 的才有;给模型一句说明和列的含义,没有时和以前完全一样
|
|
insights: FileInsights | None = None
|