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
38 changes: 38 additions & 0 deletions app/core/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,38 @@ def __init__(self, message: str, *, kind: str, attempts: int, cause: BaseExcepti
self.cause = cause


_API_ERROR_KEYS = {"message", "error", "detail", "msg", "code", "status", "type", "param", "request_id"}


def api_error_text(text) -> Optional[str]:
"""B45:网关有时以 200 返回一段错误 JSON 作为"回复内容",例如
{"message":"prompt: A user's message must contain at least one image or a PDF or audio."}。
整段内容是一个 JSON 对象、键全是错误字段(message/error/detail…)时返回错误说明,否则 None。
正常结论(中文文字、代码、含其他键的 JSON)不受影响。"""
import json

if not isinstance(text, str):
return None
t = text.strip()
if not (t.startswith("{") and t.endswith("}")):
return None
try:
obj = json.loads(t)
except Exception:
return None
if not isinstance(obj, dict) or not obj or not set(obj) <= _API_ERROR_KEYS:
return None
err = obj.get("error")
if isinstance(err, dict):
err = err.get("message") or json.dumps(err, ensure_ascii=False)
msg = obj.get("message") or err or obj.get("detail") or obj.get("msg")
return str(msg) if msg else None


class GatewayErrorContent(Exception):
"""模型接口返回了错误 JSON 而不是回复(B45)。按短暂故障处理,可重试。"""


def classify_error(exc: BaseException) -> tuple[str, bool]:
"""返回 (kind, 是否可重试)。只重试短暂性故障;鉴权/参数错误重试无意义。"""
try:
Expand All @@ -139,6 +171,8 @@ def classify_error(exc: BaseException) -> tuple[str, bool]:
if isinstance(exc, openai.APIStatusError):
code = getattr(exc, "status_code", 0) or 0
return ("server", True) if code >= 500 else ("http_%d" % code, False)
if isinstance(exc, GatewayErrorContent):
return "gateway_error", True
if isinstance(exc, TimeoutError) or "timeout" in type(exc).__name__.lower():
return "timeout", True
if isinstance(exc, ConnectionError):
Expand Down Expand Up @@ -186,6 +220,10 @@ def invoke_message(self, prompt):
attempts += 1
try:
msg = self.model.invoke(prompt)
bad = api_error_text(getattr(msg, "content", None))
if bad is not None:
self.tracker.add_message(msg)
raise GatewayErrorContent(f"接口返回错误内容:{bad}")
except Exception as e: # noqa: BLE001
kind, retryable = classify_error(e)
if not retryable or attempts > self.config.max_retries:
Expand Down
9 changes: 8 additions & 1 deletion app/core/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from collections import defaultdict
from functools import lru_cache

from app.core.llm import (LLMClient, LLMConfig, LLMError, UsageCallbackHandler,
from app.core.llm import (api_error_text, LLMClient, LLMConfig, LLMError, UsageCallbackHandler,
UsageTracker, build_chat_model, classify_error)
from app.core.result import AnalysisResult, Step, format_result, refusal_from_answer

Expand Down Expand Up @@ -236,6 +236,13 @@ def _run_react(res, tracker, cfg, chat_model, df, notes_text, question, dataset,
elif isinstance(m, AIMessage):
res.steps.append(Step("answer", content=str(m.content)))
res.answer = str(m.content)
bad = api_error_text(res.answer)
if bad is not None:
# 网关以错误 JSON 充当回复(B45):不是答案,按 LLM 基础设施失败记
res.answer = ""
if res.error is None:
res.error = f"LLM 调用失败(gateway_error):接口返回错误内容:{bad}"
res.error_kind = "llm"
if res.answer.strip().startswith("Sorry, need more steps"):
# langgraph 剩余步数不足时不抛 GraphRecursionError,而是塞一条英文占位答复(B39)
res.answer = ""
Expand Down
4 changes: 2 additions & 2 deletions app/eval/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
),
EvalCase(
id="agg_015", category="aggregation",
question="平均每个地区的销售额是多少?",
question="各地区总销售额的平均值是多少?", # B47:原题"平均每个地区的销售额"可读成各地区的单笔平均
ground_truth="result = (df['units'] * df['price']).groupby(df['region']).sum().mean()",
keywords=["groupby", "sum", "mean"],
),
Expand Down Expand Up @@ -229,7 +229,7 @@
),
EvalCase(
id="corr_007", category="correlation",
question="先按产品和地区汇总销量,再求每个产品在各地区之间销量的标准差,判断分布是否均匀。",
question="先按产品和地区汇总销量,再求每个产品在各地区之间销量的样本标准差(保留两位小数),判断分布是否均匀。", # B47:原题未说明样本/总体口径与精度
ground_truth="result = df.groupby(['product', 'region'])['units'].sum().groupby(level=0).std().round(2)",
keywords=["groupby", "std"],
),
Expand Down
5 changes: 4 additions & 1 deletion app/eval/cases_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ def _refuse(reason: str) -> str:
question="复购率是多少?",
ground_truth=_D + "n = d.groupby('user_id').size(); result = round((n >= 2).mean() * 100, 2)",
alt_ground_truths=[_DONE + "n = d.groupby('user_id').size(); result = round((n >= 2).mean() * 100, 2)",
_D + "n = d.groupby('user_id').size(); result = round(n[n >= 2].sum() / n.sum() * 100, 2)"]),
_D + "n = d.groupby('user_id').size(); result = round(n[n >= 2].sum() / n.sum() * 100, 2)",
# B47:排除已退款的有效订单口径
_D + "d = d[d['status'] != '已退款']; n = d.groupby('user_id').size(); "
"result = round((n >= 2).mean() * 100, 2)"]),
]


Expand Down
85 changes: 85 additions & 0 deletions app/eval/comparator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
19. 百分数字符串('7.00%'、Series/dict 里的 '50.58%')按数值比较;dict 的键里含季度/月份
('第一季度销售总额')且期望是序号索引时按序号对齐,dict 里的非数值项('哪个季度更高')忽略;
问排名时给名次 1..n 的 Series,与期望数值从大到小的次序一致即算对(无并列时)。
阶段 3c(B46,来自 eval-3b 的 ReAct 复核;只在上面的规则都不匹配时才用,且要求"唯一"以免奖励罗列多种口径):
20. a) 期望是标签、实际是按值排好序的数值 Series(榜首不并列)→ 榜首标签即答案;
b) 实际是带附加信息的 dict(≤8 项、键是文字而不是月份):
期望数值时,第一层数值项里恰好一个等于期望;期望标签时,第一层恰好一个标签值且等于期望;
期望 Series/表时,dict 里恰好一个子对象(Series/表/dict)等于期望;
期望 Series 且索引是文字标签时,每个标签恰好对应一个"含该标签的键"且数值相等('工作日平均销量' ↔ '工作日');
嵌套 dict 里的标量不参与({'口径A': {'复购率': …}, '口径B': {…}} 这种罗列口径的不算对);
c) 实际是一句话字符串、期望数值:句中恰好一个百分数(没有百分数时恰好一个数字)且等于期望。
阶段 3:应拒答题由 is_refusal() 判定("无法回答:<原因>",原因至少 2 个字),不经过上面的规则。
"""
import math
Expand Down Expand Up @@ -86,11 +94,88 @@ def results_equal(actual, expected, float_tol=1e-4, *, ordered=None, percent_equ
try:
if _eq(v, e, float_tol, ordered):
return True
except Exception:
pass
try:
if _extras_eq(v, e, float_tol, ordered):
return True
except Exception:
continue
return False


# ------------------------------------------------------------------ 规则 20(B46)
_NUM_TOKEN_RE = re.compile(r"(?<![A-Za-z0-9_.])[-+]?\d+(?:,\d{3})*(?:\.\d+)?(?![A-Za-z0-9_.])")
_PCT_TOKEN_RE = re.compile(r"([-+]?\d+(?:\.\d+)?)\s*%")
_EXTRAS_MAX = 8
_DATE_KEY_RE = re.compile(r"^\d{4}-\d{2}(?:-\d{2})?$")


def _is_label_value(v) -> bool:
return isinstance(v, (pd.Timestamp, pd.Period)) or (isinstance(v, str) and not _is_num(v) and _pct(v) is None)


def _extras_eq(a, e, tol, ordered) -> bool:
"""规则 20:答案对、但外面裹了附加信息的形态。见模块说明。"""
# a) 期望标签,实际是排好序的数值 Series
if _is_label_value(e) and isinstance(a, pd.Series) and len(a) > 1 \
and pd.api.types.is_numeric_dtype(a) and not isinstance(a.index, pd.MultiIndex):
if (a.is_monotonic_decreasing or a.is_monotonic_increasing) and a.iloc[0] != a.iloc[1]:
return _scalar_eq(_py(a.index[0]), e, tol)
return False
# c) 一句话字符串
if isinstance(a, str) and _is_num(e):
pcts = _PCT_TOKEN_RE.findall(a)
if pcts:
return len(pcts) == 1 and _scalar_eq(float(pcts[0]), e, tol)
nums = _NUM_TOKEN_RE.findall(a)
return len(nums) == 1 and _scalar_eq(float(nums[0].replace(",", "")), e, tol)
# b) 带附加信息的 dict
if not isinstance(a, dict) or not (1 < len(a) <= _EXTRAS_MAX):
return False
if not all(isinstance(k, str) and not _DATE_KEY_RE.match(k.strip()) for k in a):
return False # 键本身是日期/月份 → 是时间序列,不是"答案 + 附加信息"
vals = {k: _py(v) for k, v in a.items()}
if _is_num(e):
leaves = [v for v in vals.values() if _is_num(v) or _pct(v) is not None]
if len(leaves) == len(vals) and max(len(k.strip()) for k in vals) <= 4:
return False # {'华东': 1, '华北': 2, …}:是分组汇总表,碰巧含期望值不算(同规则 10)
return sum(1 for v in leaves if _scalar_eq(v, e, tol)) == 1
if _is_label_value(e):
labels = [v for v in vals.values() if _is_label_value(v)]
return len(labels) == 1 and _scalar_eq(labels[0], e, tol)
if isinstance(e, (pd.Series, pd.DataFrame)):
subs = [v for v in vals.values() if isinstance(v, (pd.Series, pd.DataFrame, dict))]
hits = 0
for v in subs:
try:
hits += bool(_eq(v, e, tol, ordered))
except Exception:
pass
if hits == 1:
return True
if isinstance(e, pd.Series) and not ordered:
return _keys_contain_labels(vals, e, tol)
return False


def _keys_contain_labels(vals: dict, e: pd.Series, tol) -> bool:
"""规则 20 b:{'工作日平均销量': 14.85, '周末平均销量': 19.72, '工作日记录数': 1320} 对 Series(周末, 工作日)。"""
labels = [_py(x) for x in e.index]
if len(labels) < 2 or not all(isinstance(x, str) and x for x in labels):
return False
used = set()
for lab, ev in zip(labels, e.values):
others = [o for o in labels if o != lab and lab in o] # '周末' 也是 '非周末' 的一部分时
keys = [k for k, v in vals.items()
if lab in k and not any(o in k for o in others)
and (_is_num(v) or _pct(v) is not None) and _scalar_eq(v, _py(ev), tol)]
if len(keys) != 1 or keys[0] in used:
return False
used.add(keys[0])
return True


def _scale(v, f):
"""把结果里的数值乘以 f;没有可缩放的数值时返回 None。"""
try:
Expand Down
41 changes: 39 additions & 2 deletions app/eval/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import json
import os
import platform
import shutil
import subprocess
from datetime import datetime

Expand All @@ -20,8 +21,39 @@
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


_WIN_GIT = (r"C:\Program Files\Git\cmd\git.exe", r"C:\Program Files (x86)\Git\cmd\git.exe")
_git_problem = None # B48:最近一次没能调用 git 的原因,报告里写进 git_sha_note


def _git_exe() -> str:
"""git 可执行文件:DATACHAT_GIT 环境变量 > PATH > Windows 默认安装位置。"""
exe = os.environ.get("DATACHAT_GIT") or shutil.which("git")
if exe:
return exe
for p in _WIN_GIT:
if os.path.exists(p):
return p
return "git"


def _git(*args):
return subprocess.run(["git", "-C", _REPO, *args], capture_output=True, text=True, timeout=5)
global _git_problem
try:
r = subprocess.run([_git_exe(), "-C", _REPO, *args], capture_output=True, text=True, timeout=5)
except FileNotFoundError:
_git_problem = "找不到 git(不在 PATH 上;可设环境变量 DATACHAT_GIT 指向 git.exe)"
raise
except Exception as e:
_git_problem = f"调用 git 失败:{type(e).__name__}: {e}"
raise
if r.returncode != 0:
_git_problem = f"git {args[0]} 退出码 {r.returncode}:{(r.stderr or '').strip()[:200]}"
return r


def git_sha_note():
"""git_sha 带 "?" 时说明原因(如 safe.directory 拒绝、git 不在 PATH);正常时为 None。"""
return _git_problem


def _sha_from_files(short: bool):
Expand Down Expand Up @@ -49,6 +81,8 @@ def _sha_from_files(short: bool):

def git_sha(short: bool = True) -> str:
"""当前 commit;工作区有改动时加 -dirty。git 不可用时退回读 .git 文件,并在末尾加 "?"。"""
global _git_problem
_git_problem = None
try:
r = _git("rev-parse", *(["--short"] if short else []), "HEAD")
sha = r.stdout.strip()
Expand Down Expand Up @@ -239,7 +273,8 @@ def block(items):
agents = sorted({r.get("agent", "workflow") for r in self.results})
return {
"time": datetime.now().isoformat(timespec="seconds"),
"git_sha": git_sha(),
"git_sha": (sha := git_sha()),
**({"git_sha_note": git_sha_note()} if sha.endswith("?") or sha == "unknown" else {}),
"agent": agents[0] if len(agents) == 1 else agents,
**self.meta,
"overall": block(self.results),
Expand Down Expand Up @@ -280,6 +315,8 @@ def print_summary(self):
print("=" * 60)
print(" DataChat 评测报告")
print(f" 时间: {s['time']} commit: {s['git_sha']} agent: {s['agent']}")
if s.get("git_sha_note"):
print(f" ⚠ commit 后的 ? 表示没能确认工作区是否干净:{s['git_sha_note']}")
if s.get("model"):
print(f" 模型: {s['model']} temperature: {s.get('temperature')} 数据指纹: {s.get('data_sha256')}")
print(f" 总用例: {self.total}")
Expand Down
7 changes: 5 additions & 2 deletions app/eval/rescore.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
import json

from app.core.llm import api_error_text
from app.core.result import refusal_from_answer
from app.eval.cases import ALL_CASES
from app.eval.report import load_obj
Expand All @@ -17,9 +18,11 @@ def rescore(report_path: str, data_path: str | None = None) -> dict:
data = json.load(open(report_path, encoding="utf-8"))
cases = {c.id: c for c in ALL_CASES}
runner = EvalRunner(data_path or data.get("data")) # 阶段 3:按题目的 dataset 取表
rows, changed, skipped = [], [], []
rows, changed, skipped, infra = [], [], [], []
for row in data["cases"]:
case = cases.get(row["id"])
if api_error_text(row.get("answer")) is not None:
infra.append(row["id"]) # B45:旧报告里网关错误 JSON 被当成了结论,按基础设施失败记
refusal = refusal_from_answer(row.get("answer"))
if case is not None and refusal is not None and not row.get("actual_obj"):
# B40:ReAct 没调工具、直接在结论里按契约拒答,旧报告里 result 为空
Expand Down Expand Up @@ -64,4 +67,4 @@ def rescore(report_path: str, data_path: str | None = None) -> dict:
"correctness_rate": correct / n * 100 if n else None,
"by_category": {k: {"correct": c, "n": t} for k, (c, t) in by_cat.items()},
"by_dataset": {k: {"correct": c, "n": t} for k, (c, t) in by_ds.items()},
"changed": changed, "skipped_no_obj": skipped} # 无结构化结果或还原失败的题,沿用原判定
"changed": changed, "skipped_no_obj": skipped, "infra_errors": infra} # 无结构化结果或还原失败的题,沿用原判定
2 changes: 2 additions & 0 deletions app/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def run_python_on_data(code: str) -> str:
"问\"哪个\"时 result 只放标签本身(如 '华中',不要拼接说明文字或数值),问\"多少\"给数值,"
"问\"各…/每…\"给以分组键为索引的 Series 或表,问\"哪些/哪条记录\"给筛选出的行(DataFrame),"
"问题含多个小问时 result 用 dict 把每个小问都答上;数值、说明放在最终中文结论里。"
"只有一个问题时不要把答案装进 dict,也不要在 result 里附带样本数、计数、明细、中间统计或其他口径的结果"
"(这些写进最终结论);口径有歧义时选一种最合理的作为 result,其他口径在结论里说明。"
"不要用中间表或整张排序表收尾。"
"如果数据回答不了(表里没有所需的字段或信息),不要编造或用别的字段凑:"
"仍然调用一次 run_python_on_data,代码为 result = '无法回答:<一句话原因>'(原因说明缺什么,"
Expand Down
Loading
Loading