Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 67 additions & 46 deletions app/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
GET /health
POST /datasets multipart:files(一个或多个 CSV,可含 *.dict.csv 字典)、name
GET /datasets 列表
GET /datasets/{id} 详情(含文件与每张表的列)
GET /datasets/{id}/preview?rows=20 每张表前 N 行(1–100),单元格一律字符串或 null(阶段 5 F1)
GET /datasets/{id}?rows=20 详情(文件、每张表的列与概要);带 rows(1–100)时加每张表前 N 行预览,
单元格一律字符串或 null(阶段 5 F1.1:预览与详情合并,每个文件只读一遍)
DELETE /datasets/{id}
POST /sessions {"dataset_id", "agent": "workflow"|"react", "title"?}
GET /sessions 列表(按更新时间倒序)
Expand Down Expand Up @@ -36,7 +36,7 @@
import sys
from typing import Literal, Optional

from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
Expand All @@ -50,11 +50,16 @@
SETTING_KEYS = ("model", "base_url", "timeout")
TIMEOUT_RANGE = (5.0, 600.0)
PREVIEW_MAX_ROWS = 100
PREVIEW_DEFAULT_ROWS = 20 # 上传后随响应带回的预览行数
PREVIEW_CELL_MAX = 200 # 单元格超长截断(按文本渲染,T9)
CHART_NAME = re.compile(r"^chart_[0-9_]+_[0-9a-f]{6}\.png$")
FEEDBACK_NOTE_MAX = 1000
# 去掉控制字符(保留 \t \n)和双向文本控制符:note 会原样回显到前端(THREAT_MODEL T9)
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]")
# 方向控制符(LRM/RLM、嵌入 / 覆盖、隔离):数据里没有合法用途,却能让后面的文本视觉重排(THREAT_MODEL §2.3)
_BIDI = "\u200e\u200f\u202a-\u202e\u2066-\u2069"
_BIDI_CONTROLS = re.compile(f"[{_BIDI}]")
# 标题、note:C0/C1 控制字符(保留 \t \n)+ 方向控制符。预览只用 _BIDI_CONTROLS(数据里 \r \t \n 合法)
_CONTROL_CHARS = re.compile(f"[\x00-\x08\x0b-\x1f\x7f-\x9f{_BIDI}]")
# index.html 的内容安全策略(阶段 5 方案 §1)。构建产物里不能有内联 <script>(CI 检查),所以 script-src 只要 'self';
# ECharts / React 会写行内 style,故 style-src 带 'unsafe-inline';图表导出用 data: / blob:。
CSP = ("default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; "
Expand Down Expand Up @@ -183,18 +188,47 @@ def public_result(r: dict) -> dict:
r.pop("dataset", None) # 本地绝对路径不外发
return r

def dataset_out(ds: Dataset, detail=False):
def dataset_out(ds: Dataset, detail=False, rows: int | None = None):
"""detail:读每张表,给出列名、概要;rows 不为 None 时再带每张表前 rows 行预览。
表的枚举直接用分析路径的 table_paths(表名、排序、字典过滤、重名检查与 load_data 结构性一致),
但逐表 try:一张读坏只在该表报 error,不影响其他表的列名和预览。每个文件只读一遍。"""
out = {"id": ds.id, "name": ds.name, "created_at": ds.created_at,
"files": [{"filename": f.filename, "size": f.size, "sha256": f.sha256} for f in ds.files]}
if detail:
from app.tools.dataset import load_data
if not detail:
return out
from app.tools.csv_io import read_csv_with_encoding
from app.tools.dataset import is_dict_file, table_paths
target = dataset_target(ds)
try:
tp = table_paths(target)
except Exception as e: # noqa: BLE001 —— 目录空、表名重复:分析同样会失败
out["error"] = f"{type(e).__name__}: {e}"
return out
multi = tp is not None
cols, parts, errors, previews = {}, [], [], []
for name, path in tp or [("df", target)]:
filename = os.path.basename(path)
try:
data, desc = load_data(dataset_target(ds))
tables = data if isinstance(data, dict) else {"df": data}
out["tables"] = {k: [str(c) for c in v.columns] for k, v in tables.items()}
out["summary"] = desc
df, enc = read_csv_with_encoding(path)
except Exception as e: # noqa: BLE001
out["error"] = f"{type(e).__name__}: {e}"
msg = f"{type(e).__name__}: {e}"
errors.append(f"{filename}:{msg}")
parts.append(f"{name} 读取失败")
previews.append({"name": name, "filename": filename, "error": msg})
continue
cols[name] = [str(c) for c in df.columns]
parts.append(f"{name} {len(df)}×{len(df.columns)}({enc})" if multi
else f"{len(df)} 行 × {len(df.columns)} 列,编码 {enc}")
if rows is not None:
previews.append(preview_table(name, filename, df, enc, rows))
del df
out["tables"] = cols
out["summary"] = (f"{len(parts)} 张表:" + ",".join(parts)) if multi else parts[0]
if errors: # 任一张读坏,分析(load_data)整体会失败,所以在顶层也报
out["error"] = ";".join(errors)
if rows is not None:
out["preview"] = {"rows": rows, "tables": previews,
"dicts": sorted(f.filename for f in ds.files if is_dict_file(f.filename))}
return out

def dataset_target(ds: Dataset) -> str:
Expand Down Expand Up @@ -235,16 +269,29 @@ def llm_config():
over.pop("timeout") # 库里的坏值不让分析整个起不来,退回环境变量 / 默认
return LLMConfig.from_env(**over)

def preview_cell(v):
"""预览单元格:缺失 → None;其余转字符串并截断。前端按文本渲染(THREAT_MODEL T9)。"""
def preview_cell(v, stats):
"""预览单元格:缺失 → None;其余转字符串,去掉方向控制符(只影响显示,模型拿到的仍是原数据),
超长截断。计数记进 stats,前端在提示行告诉用户。前端按文本渲染(THREAT_MODEL T9)。"""
import pandas as pd
try:
if pd.isna(v):
return None
except (TypeError, ValueError): # 列表之类的非标量
pass
s = str(v)
return s if len(s) <= PREVIEW_CELL_MAX else s[:PREVIEW_CELL_MAX] + "…"
s, n = _BIDI_CONTROLS.subn("", str(v))
stats["bidi_stripped"] += n
if len(s) > PREVIEW_CELL_MAX:
stats["truncated_cells"] += 1
s = s[:PREVIEW_CELL_MAX] + "…"
return s

def preview_table(name, filename, df, enc, rows):
stats = {"bidi_stripped": 0, "truncated_cells": 0}
columns = [preview_cell(c, stats) or "" for c in df.columns]
body = [[preview_cell(v, stats) for v in r] for r in df.head(rows).itertuples(index=False, name=None)]
return {"name": name, "filename": filename, "encoding": enc,
"total_rows": int(len(df)), "total_columns": int(len(df.columns)),
"columns": columns, "dtypes": [str(t) for t in df.dtypes], "rows": body, **stats}

# ------------------------------------------------------------------ 路由
@app.get("/health")
Expand Down Expand Up @@ -286,42 +333,16 @@ def create_dataset(files: list[UploadFile] = File(...), name: str = Form(""), db
db.rollback()
shutil.rmtree(folder, ignore_errors=True)
raise
return dataset_out(ds, detail=True)
return dataset_out(ds, detail=True, rows=PREVIEW_DEFAULT_ROWS)

@app.get("/datasets", dependencies=[Depends(auth)])
def list_datasets(db=Depends(db_dep)):
return [dataset_out(d) for d in db.scalars(select(Dataset).order_by(Dataset.created_at.desc()))]

@app.get("/datasets/{dataset_id}", dependencies=[Depends(auth)])
def get_dataset(dataset_id: str, db=Depends(db_dep)):
return dataset_out(get_or_404(db, Dataset, dataset_id), detail=True)

@app.get("/datasets/{dataset_id}/preview", dependencies=[Depends(auth)])
def preview_dataset(dataset_id: str, rows: int = 20, db=Depends(db_dep)):
"""每张表前 rows 行。读法与分析时一致(同一个编码探测和 read_csv),用户看到的就是模型拿到的。"""
from app.tools.csv_io import read_csv_with_encoding
from app.tools.dataset import is_dict_file
if not 1 <= rows <= PREVIEW_MAX_ROWS:
raise HTTPException(400, f"rows 需在 1–{PREVIEW_MAX_ROWS}")
ds = get_or_404(db, Dataset, dataset_id)
tables, dicts = [], []
for f in sorted(ds.files, key=lambda f: f.filename.lower()):
if is_dict_file(f.filename):
dicts.append(f.filename)
continue
name = os.path.splitext(f.filename)[0]
try:
df, enc = read_csv_with_encoding(f.path)
except Exception as e: # noqa: BLE001 —— 一张表读坏不影响其他表的预览
tables.append({"name": name, "filename": f.filename, "error": f"{type(e).__name__}: {e}"})
continue
head = df.head(rows)
tables.append({"name": name, "filename": f.filename, "encoding": enc,
"total_rows": int(len(df)), "total_columns": int(len(df.columns)),
"columns": [preview_cell(c) or "" for c in df.columns],
"dtypes": [str(t) for t in df.dtypes],
"rows": [[preview_cell(v) for v in r] for r in head.itertuples(index=False, name=None)]})
return {"dataset_id": ds.id, "rows": rows, "tables": tables, "dicts": dicts}
def get_dataset(dataset_id: str, rows: int | None = Query(None, ge=1, le=PREVIEW_MAX_ROWS), db=Depends(db_dep)):
"""rows:带上就附每张表前 rows 行预览(越界、非整数都是 422)。"""
return dataset_out(get_or_404(db, Dataset, dataset_id), detail=True, rows=rows)

@app.delete("/datasets/{dataset_id}", dependencies=[Depends(auth)])
def delete_dataset(dataset_id: str, db=Depends(db_dep)):
Expand Down
3 changes: 3 additions & 0 deletions app/server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class ChatSession(Base):
agent: Mapped[str] = mapped_column(String(16))
title: Mapped[str] = mapped_column(String(255), default="")
created_at: Mapped[str] = mapped_column(String(40), default=now)
# 语义是"最后一次提问时间":只在 post_message 里更新,会话列表按它倒序(界面写明"按最近分析时间排序")。
# 改名(PATCH /sessions/{id})刻意不更新——否则改个名就顶到最前,"最近分析"排序失真。
# 故意没有 onupdate;改字段名要迁移,先用这条注释钉住(tests/test_web_f1.py 有回归测试)。
updated_at: Mapped[str] = mapped_column(String(40), default=now)
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan",
order_by="Message.created_at")
Expand Down
32 changes: 31 additions & 1 deletion docs/DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1180,7 +1180,7 @@ S 决定(10-14):跳过"当前 master 先重跑一次基线",直接改提

未做 / 下一步:F1 的 `GET /datasets/{id}/preview`;F2 的 cancel(按 v2.1:CAS + finally CAS + `done_seen` + 分流 cancelled)和 `gen_ui_types.py`。A1 自动复跑规则仍按计划在下一次闸门 PR 落地。

## [32] 2026-10-15 · 阶段 5 F1:数据集预览、会话、设置 · commit <待提交>
## [32] 2026-10-15 · 阶段 5 F1:数据集预览、会话、设置 · commit 7f0cdcd(PR #33)

当前阶段:**阶段 5 前端,F1 完成,下一步 F2**(SSE、事件渲染、重放合成 done、cancel、👍👎、gen_ui_types)。[31] 的 SHA 已回填 4beb924(PR #32,CI tests 38694679388 含 web job、sidecar 38694679138 均绿)。

Expand All @@ -1207,3 +1207,33 @@ S 决定(10-14):跳过"当前 master 先重跑一次基线",直接改提
验证:pytest 594 passed(3.12;587 + test_web_f1 7);vitest 17;Playwright 6(沙箱本地);lint、build(check-dist 0 内联)通过。

未做 / 下一步:F2。A1 自动复跑规则仍按计划在下一次闸门 PR 落地。

## [33] 2026-10-15 · 阶段 5 F1.1:F1 评审收尾(预览并入详情、方向控制符、截断计数、token 先存后抹) · commit <待提交>

当前阶段:**阶段 5 前端,F1.1 完成,下一步 F2**(SSE、事件渲染、重放合成 done、cancel、👍👎、gen_ui_types)。[32] 的 SHA 已回填 7f0cdcd(PR #33,CI tests 38699875135、sidecar 38699875296 均绿)。

起因(S 评审 F1):
- 预览读整表的理由要写对:`detect_encoding` 对**整个文件**做严格解码;只读头部,前面全是 ASCII 的 GBK 文件会被判成 utf-8,整表读坏,而不只是 dtype 不一致。所以不改成 nrows。真正的代价不是"慢":`DatasetView` 并发请求详情和 `/preview`,同一个 CSV 被整表读两遍,每遍 bytes / str / DataFrame 三份并存,1 GB 文件峰值 4–5 GB。
- 详情原来用 `load_data` 整体 try:多表任一张坏,整个详情都没有 `tables`;"一张坏、其余照常"只在 `/preview` 层成立。
- 表名、排序两条路径不一致:分析用 `table_paths`(`splitext(basename)`、按表名排、大小写敏感、`os.listdir`),`/preview` 按文件名小写排、用 DB 的 `ds.files`。`B.csv + a.csv`:前者 [B, a],后者 [a, B]。

后端(`app/server/app.py`):
1. **预览并入 `GET /datasets/{id}?rows=N`**:`rows: int | None = Query(None, ge=1, le=100)`;不传就不带预览,越界和非整数都是 422(原来越界 400、`abc` 422)。删掉 `/preview`(只在 F1 前端用过,未对外)。`POST /datasets` 的响应带 20 行预览(`create_dataset` 本来就返回 detail),前端上传后不用再请求。
2. **详情逐表读、每个文件只读一遍**:直接用分析路径的 `table_paths(dataset_target(ds))` 枚举(多表 → [(表名, 路径)];单表 None → `[("df", target)]`),表名、排序、字典过滤、重名检查与 `load_data` 结构性一致;每张表单独 try,一次 `read_csv_with_encoding` 同时出列名、概要、预览。坏表在 preview 里只带 `error`,好表的 `tables` 列名照常;任一张坏时顶层也给 `error`(分析时 `load_data` 会整体失败),概要写"x 读取失败"。单表表名沿用 `"df"`(prompt 里单表变量就叫 df;前端单表不显示 tab)。`table_paths` 本身抛(目录空、表名重复)→ 顶层 error。
3. **方向控制符**:拆成 `_BIDI_CONTROLS`(U+200E/200F LRM/RLM、U+202A–202E、U+2066–2069)和 `_CONTROL_CHARS`(C0/C1 除 \t \n + 上面这些;标题、note 用,字符集与 F1 完全相同)。预览的单元格与表头只清 `_BIDI_CONTROLS`(数据里 \r \t \n 合法)。只影响显示,模型拿到的仍是原数据,所以每张表返回 `bidi_stripped` 计数,提示行写明。
4. **截断计数**:每张表返回 `truncated_cells`,提示行写"n 个单元格超 200 字,已截断(末尾带 …)";用户能区分原值本来就以 … 结尾的情况。
5. `db.py`:`ChatSession.updated_at` 加注释——语义是"最后一次提问时间",只在 post_message 更新,改名刻意不更新,故意没有 onupdate。

前端(`ui/web/src`):
- `client.dataset(id, rows=20)` 一个请求拿详情 + 预览;删 `client.preview`;`types.ts` 的 `DatasetDetail.preview`、`bidi_stripped`、`truncated_cells`。
- `DatasetView`:单请求;上传后直接用响应(Shell 传 `initial`),不再请求。不再单列 `summary`:单表时与预览提示行重复;多表的跨表概览并进 tab 标签(`orders 100×3 · gbk`、坏表"读取失败")。顶层 error 显示"有表读取失败,分析会失败"。
- `PreviewTable` 提示行加截断数与方向控制符数。
- 左栏会话标题旁写"按最近分析时间排序"。
- `token.ts`:先 `setItem`(try/catch),再无论成败都 `replaceState` 抹地址栏,然后返回 token;存失败时 token 只在内存,本页可用,刷新进"会话已失效"页(T7':token 不进历史)。原实现先抹后存、注释写反了,`setItem` 一抛会冒到 `main.tsx` 顶层同步调用,导致白屏。

测试:
- `tests/test_web_f1.py`(10 条):单表预览与 `read_csv` 逐格一致、名为 df;上传响应带预览且与详情一致、不传 rows 无预览、列表无预览;rows 0 / -1 / 101 / abc / 1.5 均 422、401、404、旧 `/preview` 404;表头与单元格清方向控制符(计数 5)、注入串原样、截断计数 1;GBK;多表一张空文件:好表列名与预览照常、坏表只有 error、顶层 error 与概要;`B.csv + a.csv` 表名顺序为 [B, a],且与 `load_data` 的表名、列名一致(回归网);改名清 LRM/RLO/BEL,**改名不动 updated_at**。
- vitest 21(+4):提示行的截断数与方向控制符数;详情只发一个请求、上传后不再请求;多表 tab 标签带行列数和编码、坏表与顶层 error 的提示;token 存储抛异常仍返回 token 且地址栏已抹、replaceState 时 token 已在存储里。
- `docs/THREAT_MODEL.md` 预览一行改为:清方向控制符并计数、截断计数。

未做 / 下一步:F2。`read_csv_with_encoding` 内部 bytes / str / DataFrame 三份并存的峰值内存(分析路径也一样,属于读取函数本身,不在 F1.1 顺手改;需要时改成解码后释放 bytes、或按块解码)。A1 自动复跑规则仍按计划在下一次闸门 PR 落地。
2 changes: 1 addition & 1 deletion docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ keyring 对逃逸不是完全没用:逃逸后偷 Key 的路径从"读任意文
| 自然语言结论 | 同上;若结论支持 Markdown,Markdown 渲染器关闭原始 HTML | 生效(第 5 步实现) | 生效 |
| 日志、导出 | 日志是纯文本文件;导出 HTML 报告(如有)时同样在写入处转义 | 生效(第 5 步实现) | 生效 |
| 👍👎 的 note(用户输入,阶段 5 F0) | 后端清控制字符和双向文本控制符、限 1000 字;前端按纯文本渲染 | 生效 | 生效 |
| 数据集预览单元格与表头(F1) | 后端一律转字符串原样返回(超 200 字截断),缺失为 null;前端按纯文本渲染 | tests/test_web_f1.py、views.test.tsx(注入串不成元素)、e2e/f1.spec.ts(托管 dist 上 tbody 无 img、无 CSP 违规) | 生效 |
| 数据集预览单元格与表头(F1 / F1.1) | 后端一律转字符串,去掉方向控制符(LRM/RLM、U+202A–202E、U+2066–2069,防视觉重排;\t \n \r 保留),超 200 字截断,两者都按表计数(`bidi_stripped` / `truncated_cells`),提示行告诉用户;只影响显示,模型拿到原数据;缺失为 null;前端按纯文本渲染 | tests/test_web_f1.py、views.test.tsx(注入串不成元素)、e2e/f1.spec.ts(托管 dist 上 tbody 无 img、无 CSP 违规) | 生效 |
| Markdown 里的 `img` / 链接 | 链接 `rel="noreferrer noopener"`;`javascript:` 由渲染器过滤(测试钉版本);外链图片由 CSP `img-src 'self' data: blob:` 拦下 | F2 实现 | 生效 |

**白名单只校验 builder 的输出(以及 v2 的 patch),最终渲染产物永不回验。** 前端在校验之后才加 `tooltip.renderMode = 'richText'`,而 `renderMode` 同时在 `BANNED_KEYS` 里,所以最终交给 ECharts 的 option 必然不过白名单。这是有意的:`BANNED_KEYS` 禁的是"由后端 / 模型决定这个键",不是"渲染时不许出现"。将来任何把前端 option 回灌后端的流程("保存我的风格"、服务端重绘导出 PNG 等)必须:
Expand Down
Loading
Loading