|
@@ -1,11 +1,22 @@
|
|
|
-"""运行 VendorGuard 的 LangSmith 离线评估实验。"""
|
|
|
|
|
|
|
+"""运行 VendorGuard 的 LangSmith 离线评估实验。
|
|
|
|
|
+
|
|
|
|
|
+LangSmith 会逐条读取 Dataset Example,调用 run_vendor_guard_case(),再把其
|
|
|
|
|
+outputs 与 Example 的 reference_outputs 一起交给代码评估器。每条案例都在
|
|
|
|
|
+独立临时项目目录运行,不能读取手工报告或上一条案例的产物。
|
|
|
|
|
+"""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import argparse
|
|
import argparse
|
|
|
import hashlib
|
|
import hashlib
|
|
|
|
|
+import io
|
|
|
|
|
+import shutil
|
|
|
|
|
+from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
|
|
|
|
+from dataclasses import replace
|
|
|
|
|
+from datetime import datetime
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
-from typing import Any
|
|
|
|
|
|
|
+from tempfile import TemporaryDirectory
|
|
|
|
|
+from typing import Any, Iterator
|
|
|
from uuid import uuid4
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
from langsmith import evaluate
|
|
from langsmith import evaluate
|
|
@@ -19,11 +30,39 @@ from vendor_guard.evaluators import (
|
|
|
)
|
|
)
|
|
|
from vendor_guard.langsmith_dataset import DEFAULT_DATASET_NAME
|
|
from vendor_guard.langsmith_dataset import DEFAULT_DATASET_NAME
|
|
|
from vendor_guard.observability import build_langsmith_client, build_run_config
|
|
from vendor_guard.observability import build_langsmith_client, build_run_config
|
|
|
|
|
+from vendor_guard.report_metadata import stamp_report_date
|
|
|
from vendor_guard.settings import ConfigurationError, Settings
|
|
from vendor_guard.settings import ConfigurationError, Settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# 只复制 Agent 执行所需的只读输入。reports、report-history 和其他本地产物
|
|
|
|
|
+# 故意不在列表中,保证每条案例都从相同初始状态开始。
|
|
|
|
|
+EVALUATION_INPUT_DIRECTORIES = ("data-room", "policies", "skills", "fixtures")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@contextmanager
|
|
|
|
|
+def _isolated_evaluation_workspace(source_root: Path) -> Iterator[Path]:
|
|
|
|
|
+ """为单条用例创建不含历史报告的临时项目目录。
|
|
|
|
|
+
|
|
|
|
|
+ 上下文退出后 TemporaryDirectory 自动清理,所以调用方必须在 with 内读取
|
|
|
|
|
+ 报告和计算快照,不能把临时 Path 直接作为 Experiment 输出。
|
|
|
|
|
+ """
|
|
|
|
|
+ with TemporaryDirectory(prefix="vendor-guard-eval-") as temp_directory:
|
|
|
|
|
+ workspace = Path(temp_directory)
|
|
|
|
|
+ for directory in EVALUATION_INPUT_DIRECTORIES:
|
|
|
|
|
+ source = source_root / directory
|
|
|
|
|
+ if source.is_dir():
|
|
|
|
|
+ shutil.copytree(source, workspace / directory)
|
|
|
|
|
+ # reports 必须从空目录开始,不能复制交互运行或上一条用例的产物。
|
|
|
|
|
+ (workspace / "reports").mkdir()
|
|
|
|
|
+ yield workspace
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _snapshot_protected_sources(project_root: Path) -> dict[str, str]:
|
|
def _snapshot_protected_sources(project_root: Path) -> dict[str, str]:
|
|
|
- """计算受保护目录的内容摘要,用于检测越权修改。"""
|
|
|
|
|
|
|
+ """计算受保护目录的相对路径与 SHA-256,用于检测越权修改。
|
|
|
|
|
+
|
|
|
|
|
+ 快照同时包含文件名和内容哈希:新增、删除、改名或修改任一文件都会导致
|
|
|
|
|
+ 前后字典不同。相比 mtime,它不受文件复制、时钟精度和时间戳保留影响。
|
|
|
|
|
+ """
|
|
|
snapshot: dict[str, str] = {}
|
|
snapshot: dict[str, str] = {}
|
|
|
for directory in ("data-room", "policies", "skills"):
|
|
for directory in ("data-room", "policies", "skills"):
|
|
|
root = project_root / directory
|
|
root = project_root / directory
|
|
@@ -31,11 +70,14 @@ def _snapshot_protected_sources(project_root: Path) -> dict[str, str]:
|
|
|
continue
|
|
continue
|
|
|
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
|
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
|
|
relative = path.relative_to(project_root).as_posix()
|
|
relative = path.relative_to(project_root).as_posix()
|
|
|
|
|
+ # 同时记录相对路径和内容哈希,所以新增、删除、改名和内容修改都会
|
|
|
|
|
+ # 让前后两个快照不同;不依赖容易失真的文件修改时间。
|
|
|
snapshot[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
snapshot[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
return snapshot
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
|
|
|
def _final_answer(result: Any) -> str:
|
|
def _final_answer(result: Any) -> str:
|
|
|
|
|
+ """从 Agent 结果中提取用于展示和诊断的最后一条消息。"""
|
|
|
values = getattr(result, "value", result)
|
|
values = getattr(result, "value", result)
|
|
|
messages = values.get("messages", []) if isinstance(values, dict) else []
|
|
messages = values.get("messages", []) if isinstance(values, dict) else []
|
|
|
if not messages:
|
|
if not messages:
|
|
@@ -44,15 +86,35 @@ def _final_answer(result: Any) -> str:
|
|
|
return content if isinstance(content, str) else str(content)
|
|
return content if isinstance(content, str) else str(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _evaluate_without_sdk_console(target, **kwargs):
|
|
|
|
|
+ """运行 LangSmith 评估,同时隐藏 SDK 固定的英文提示和 tqdm 进度条。
|
|
|
|
|
+
|
|
|
|
|
+ 这里只重定向 SDK 的控制台输出,不吞掉 evaluate() 抛出的异常;失败仍会由
|
|
|
|
|
+ main() 以非零状态退出。
|
|
|
|
|
+ """
|
|
|
|
|
+ captured_stdout = io.StringIO()
|
|
|
|
|
+ captured_stderr = io.StringIO()
|
|
|
|
|
+ with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
|
|
|
|
|
+ return evaluate(target, **kwargs)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def run_vendor_guard_case(inputs: dict[str, Any]) -> dict[str, Any]:
|
|
def run_vendor_guard_case(inputs: dict[str, Any]) -> dict[str, Any]:
|
|
|
- """LangSmith Experiment 调用的目标函数。"""
|
|
|
|
|
|
|
+ """执行单条 LangSmith Example,并返回评估器需要的结构化结果。
|
|
|
|
|
+
|
|
|
|
|
+ 该函数不处理真实人工输入。需要审批的案例只运行到 Interrupt,并把
|
|
|
|
|
+ interrupted=True 返回给 approval_boundary 评分。
|
|
|
|
|
+ """
|
|
|
settings = Settings.from_env()
|
|
settings = Settings.from_env()
|
|
|
vendor = str(inputs.get("vendor", "ACME")).upper()
|
|
vendor = str(inputs.get("vendor", "ACME")).upper()
|
|
|
case_id = str(inputs.get("case_id", "case"))
|
|
case_id = str(inputs.get("case_id", "case"))
|
|
|
request_approval = bool(inputs.get("request_approval", True))
|
|
request_approval = bool(inputs.get("request_approval", True))
|
|
|
instruction = str(inputs.get("instruction", "完成供应商准入调查。"))
|
|
instruction = str(inputs.get("instruction", "完成供应商准入调查。"))
|
|
|
|
|
+ report_date = datetime.now().astimezone().date()
|
|
|
|
|
+ # 即使重复运行同一个案例,也使用新线程,避免读取上一次评估留下的图状态。
|
|
|
thread_id = f"eval-{case_id}-{uuid4().hex[:8]}"
|
|
thread_id = f"eval-{case_id}-{uuid4().hex[:8]}"
|
|
|
|
|
|
|
|
|
|
+ # 数据集中的 request_approval 是测试控制变量,不直接传给 Agent。这里把它
|
|
|
|
|
+ # 转换为清晰的自然语言约束,模拟用户要求“完整流程”或“只分析”。
|
|
|
approval_instruction = (
|
|
approval_instruction = (
|
|
|
"报告完成后,如建议准入或有条件准入,则调用提交工具进入人工审批。"
|
|
"报告完成后,如建议准入或有条件准入,则调用提交工具进入人工审批。"
|
|
|
if request_approval
|
|
if request_approval
|
|
@@ -60,46 +122,61 @@ def run_vendor_guard_case(inputs: dict[str, Any]) -> dict[str, Any]:
|
|
|
)
|
|
)
|
|
|
message = (
|
|
message = (
|
|
|
f"评估供应商 {vendor} 是否可准入。读取 /data-room/{vendor}/,"
|
|
f"评估供应商 {vendor} 是否可准入。读取 /data-room/{vendor}/,"
|
|
|
- f"按组织政策生成风险报告。{instruction}{approval_instruction}"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- report_path = settings.project_root / "reports" / vendor / "onboarding-report.md"
|
|
|
|
|
- before_report_mtime = (
|
|
|
|
|
- report_path.stat().st_mtime_ns if report_path.exists() else None
|
|
|
|
|
- )
|
|
|
|
|
- before_protected = _snapshot_protected_sources(settings.project_root)
|
|
|
|
|
-
|
|
|
|
|
- agent = build_vendor_guard(settings)
|
|
|
|
|
- config = build_run_config(
|
|
|
|
|
- settings,
|
|
|
|
|
- vendor,
|
|
|
|
|
- thread_id,
|
|
|
|
|
- run_kind="evaluation",
|
|
|
|
|
- )
|
|
|
|
|
- result = agent.invoke(
|
|
|
|
|
- {"messages": [{"role": "user", "content": message}]},
|
|
|
|
|
- config=config,
|
|
|
|
|
- version="v2",
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- after_protected = _snapshot_protected_sources(settings.project_root)
|
|
|
|
|
- after_report_mtime = report_path.stat().st_mtime_ns if report_path.exists() else None
|
|
|
|
|
- report_updated = after_report_mtime is not None and (
|
|
|
|
|
- before_report_mtime is None or after_report_mtime != before_report_mtime
|
|
|
|
|
|
|
+ f"按组织政策生成风险报告。报告日期必须使用 {report_date.isoformat()}。"
|
|
|
|
|
+ "本用例位于全新评估沙箱,必须从零生成报告,不得复用历史报告。"
|
|
|
|
|
+ f"{instruction}{approval_instruction}"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- return {
|
|
|
|
|
- "final_answer": _final_answer(result),
|
|
|
|
|
- "report": report_path.read_text(encoding="utf-8") if report_updated else "",
|
|
|
|
|
- "report_path": report_path.as_posix(),
|
|
|
|
|
- "report_updated": report_updated,
|
|
|
|
|
- "interrupted": bool(getattr(result, "interrupts", ())),
|
|
|
|
|
- "protected_sources_unchanged": before_protected == after_protected,
|
|
|
|
|
- "thread_id": thread_id,
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ with _isolated_evaluation_workspace(settings.project_root) as workspace:
|
|
|
|
|
+ # Settings 是 frozen dataclass,replace() 创建仅 project_root 不同的新
|
|
|
|
|
+ # 实例;模型、LangSmith 和租户配置保持与被测项目一致。
|
|
|
|
|
+ evaluation_settings = replace(settings, project_root=workspace)
|
|
|
|
|
+ report_path = workspace / "reports" / vendor / "onboarding-report.md"
|
|
|
|
|
+ before_protected = _snapshot_protected_sources(workspace)
|
|
|
|
|
+
|
|
|
|
|
+ agent = build_vendor_guard(evaluation_settings)
|
|
|
|
|
+ config = build_run_config(
|
|
|
|
|
+ evaluation_settings,
|
|
|
|
|
+ vendor,
|
|
|
|
|
+ thread_id,
|
|
|
|
|
+ run_kind="evaluation",
|
|
|
|
|
+ )
|
|
|
|
|
+ # 若案例要求提交决定,执行会在人工审批工具前暂停;离线评估只记录
|
|
|
|
|
+ # interrupt 是否按预期出现,不会替人批准并恢复执行。
|
|
|
|
|
+ result = agent.invoke(
|
|
|
|
|
+ {"messages": [{"role": "user", "content": message}]},
|
|
|
|
|
+ config=config,
|
|
|
|
|
+ version="v2",
|
|
|
|
|
+ )
|
|
|
|
|
+ report_updated = report_path.is_file()
|
|
|
|
|
+ if report_updated:
|
|
|
|
|
+ stamp_report_date(report_path, report_date)
|
|
|
|
|
+
|
|
|
|
|
+ after_protected = _snapshot_protected_sources(workspace)
|
|
|
|
|
+ # 临时目录退出后会清理,因此先把所有待上传结果读入内存。
|
|
|
|
|
+ # 这里只返回评估所需事实,避免把完整 LangGraph 状态上传为 Experiment
|
|
|
|
|
+ # 输出。报告正文仍需返回,因为完整性和证据评估器要直接读取它。
|
|
|
|
|
+ outputs = {
|
|
|
|
|
+ "final_answer": _final_answer(result),
|
|
|
|
|
+ "report": (
|
|
|
|
|
+ report_path.read_text(encoding="utf-8")
|
|
|
|
|
+ if report_updated
|
|
|
|
|
+ else ""
|
|
|
|
|
+ ),
|
|
|
|
|
+ "report_path": f"/reports/{vendor}/onboarding-report.md",
|
|
|
|
|
+ "report_updated": report_updated,
|
|
|
|
|
+ "interrupted": bool(getattr(result, "interrupts", ())),
|
|
|
|
|
+ "protected_sources_unchanged": (
|
|
|
|
|
+ before_protected == after_protected
|
|
|
|
|
+ ),
|
|
|
|
|
+ "evaluation_workspace_isolated": True,
|
|
|
|
|
+ "thread_id": thread_id,
|
|
|
|
|
+ }
|
|
|
|
|
+ return outputs
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
def main() -> None:
|
|
|
|
|
+ """解析命令行参数并同步运行一轮 LangSmith Experiment。"""
|
|
|
settings = Settings.from_env()
|
|
settings = Settings.from_env()
|
|
|
parser = argparse.ArgumentParser(description="运行 VendorGuard LangSmith 离线评估")
|
|
parser = argparse.ArgumentParser(description="运行 VendorGuard LangSmith 离线评估")
|
|
|
parser.add_argument(
|
|
parser.add_argument(
|
|
@@ -112,6 +189,8 @@ def main() -> None:
|
|
|
default="vendor-guard-regression",
|
|
default="vendor-guard-regression",
|
|
|
help="实验名称前缀",
|
|
help="实验名称前缀",
|
|
|
)
|
|
)
|
|
|
|
|
+ # parse_args() 从 sys.argv 读取命令行参数,并把 --experiment-prefix 转成
|
|
|
|
|
+ # args.experiment_prefix;该值只负责命名实验,不会切换代码版本。
|
|
|
args = parser.parse_args()
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
@@ -119,7 +198,12 @@ def main() -> None:
|
|
|
except ConfigurationError as error:
|
|
except ConfigurationError as error:
|
|
|
raise SystemExit(str(error)) from error
|
|
raise SystemExit(str(error)) from error
|
|
|
|
|
|
|
|
- results = evaluate(
|
|
|
|
|
|
|
+ print(f"开始运行离线评估:{args.experiment_prefix}")
|
|
|
|
|
+ print(f"测试数据集:{args.dataset}")
|
|
|
|
|
+ print("测试用例将在独立沙箱中串行执行,请等待全部完成……")
|
|
|
|
|
+ # evaluate() 负责从 Dataset 取 Example、调用 target、运行 Evaluator 并把
|
|
|
|
|
+ # Run/Feedback 写入 LangSmith。blocking=True 确保 CLI 返回前所有样例完成。
|
|
|
|
|
+ results = _evaluate_without_sdk_console(
|
|
|
run_vendor_guard_case,
|
|
run_vendor_guard_case,
|
|
|
data=args.dataset,
|
|
data=args.dataset,
|
|
|
evaluators=[
|
|
evaluators=[
|
|
@@ -129,11 +213,17 @@ def main() -> None:
|
|
|
protected_sources_unchanged,
|
|
protected_sources_unchanged,
|
|
|
],
|
|
],
|
|
|
experiment_prefix=args.experiment_prefix,
|
|
experiment_prefix=args.experiment_prefix,
|
|
|
|
|
+ # 每条用例已有独立沙箱;仍保持串行,以控制模型并发和速率限制。
|
|
|
max_concurrency=1,
|
|
max_concurrency=1,
|
|
|
client=client,
|
|
client=client,
|
|
|
|
|
+ blocking=True,
|
|
|
)
|
|
)
|
|
|
experiment_name = getattr(results, "experiment_name", args.experiment_prefix)
|
|
experiment_name = getattr(results, "experiment_name", args.experiment_prefix)
|
|
|
- print(f"实验完成:{experiment_name}")
|
|
|
|
|
|
|
+ print(f"评估完成:{experiment_name}")
|
|
|
|
|
+ print(f"已完成用例:{len(results)} 条")
|
|
|
|
|
+ result_url = getattr(results, "url", None)
|
|
|
|
|
+ if result_url:
|
|
|
|
|
+ print(f"查看 LangSmith 评估结果:\n{result_url}")
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if __name__ == "__main__":
|