Files
group_xinghuo_jinrong/scripts/eval/sandbox.py
T
zhanghongyu_0626 a0f550646e feat(analyst): Add analyze endpoint and chart specification validation
- Introduced a new `/analyze` endpoint in the analyst API to process analysis requests, allowing users to receive textual interpretations and chart specifications based on provided prompts.
- Enhanced `analyst_schemas.py` with `AnalyzeRequest` and `ChartSpec` models to structure analysis requests and validate chart specifications.
- Implemented chart validation logic in a new `analyst_chart.py` service, ensuring that chart types and fields are correctly specified and conform to allowed values.
- Updated `AnalystAgent` to handle analysis requests, integrating the new logic for generating responses based on user prompts and data availability.
- Added unit tests to verify the functionality of the new endpoint and validation mechanisms, ensuring robustness and reliability.

This update significantly enhances the analytical capabilities of the application, providing users with improved tools for data interpretation and visualization.
2026-09-12 12:33:37 +08:00

222 lines
7.5 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
class SandboxSafetyError(RuntimeError):
pass
@dataclass(frozen=True)
class SandboxConfig:
run_id: str
root: Path
live: bool = False
keep: bool = False
env_name: str = "offline"
resources: dict[str, str] = field(default_factory=dict)
@property
def artifact_dir(self) -> Path:
return self.root / "artifacts" / "eval" / self.run_id
@property
def ledger_path(self) -> Path:
return self.artifact_dir / "ledger.json"
def safe_environment(self) -> dict[str, Any]:
return {
"env_name": self.env_name,
"live": self.live,
"run_id": self.run_id,
"resources": {
key: _redact_resource(value) for key, value in self.resources.items()
},
}
@dataclass
class ResourceLedger:
config: SandboxConfig
resources: list[dict[str, Any]] = field(default_factory=list)
def add(self, kind: str, identifier: str, cleanup: str) -> None:
self.resources.append(
{"kind": kind, "identifier": identifier, "cleanup": cleanup, "cleaned": False}
)
self.save()
def mark_cleaned(self, identifier: str) -> None:
for resource in self.resources:
if resource["identifier"] == identifier:
resource["cleaned"] = True
self.save()
def save(self) -> None:
self.config.artifact_dir.mkdir(parents=True, exist_ok=True)
self.config.ledger_path.write_text(
json.dumps(
{
"run_id": self.config.run_id,
"environment": self.config.safe_environment(),
"resources": self.resources,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def cleanup(self) -> dict[str, Any]:
if self.config.keep:
self.save()
return {"status": "kept", "remaining": self.resources}
failures: list[dict[str, Any]] = []
for resource in reversed(self.resources):
try:
_cleanup_resource(resource)
resource["cleaned"] = True
except Exception as exc: # noqa: BLE001
failures.append(
{
"identifier": resource["identifier"],
"error": repr(exc),
}
)
self.save()
return {
"status": "failed" if failures else "cleaned",
"remaining": [r for r in self.resources if not r["cleaned"]],
"failures": failures,
}
def new_run_id(prefix: str = "eval") -> str:
return f"{prefix}-{uuid.uuid4().hex[:12]}"
def build_config(
root: Path,
*,
run_id: str | None = None,
live: bool = False,
sandbox: bool = False,
keep: bool = False,
resources: dict[str, str] | None = None,
) -> SandboxConfig:
resolved_root = root.resolve()
rid = run_id or new_run_id()
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{2,63}", rid):
raise SandboxSafetyError("run_id must contain only letters, digits, '_' or '-'")
if live and not sandbox:
raise SandboxSafetyError("live evaluation requires --sandbox")
if live and os.getenv("JINRONG_EVAL") != "1":
raise SandboxSafetyError("live evaluation requires JINRONG_EVAL=1")
if live:
validate_live_resources(resources or {})
return SandboxConfig(
run_id=rid,
root=resolved_root,
live=live,
keep=keep,
env_name="sandbox-live" if live else "offline",
resources=dict(resources or {}),
)
def validate_live_resources(resources: dict[str, str]) -> None:
required = ("mysql_database", "mysql_core_database", "redis_url", "milvus_uri")
missing = [key for key in required if not resources.get(key)]
if missing:
raise SandboxSafetyError(f"missing explicit sandbox resources: {', '.join(missing)}")
forbidden_names = {"jinrong_agent", "jinrong_core", "production", "prod", "development", "dev"}
for key in ("mysql_database", "mysql_core_database"):
value = resources[key].lower()
if value in forbidden_names or not re.search(r"(?:eval|sandbox)", value):
raise SandboxSafetyError(f"{key} must contain eval or sandbox and not be a default database")
redis_url = resources["redis_url"].lower()
if "/0" in redis_url and "eval" not in redis_url and "sandbox" not in redis_url:
raise SandboxSafetyError("redis_url must identify a dedicated eval/sandbox database")
milvus_uri = Path(resources["milvus_uri"]).resolve()
if "eval" not in str(milvus_uri).lower() and "sandbox" not in str(milvus_uri).lower():
raise SandboxSafetyError("milvus_uri must point to an eval/sandbox path")
def write_json(config: SandboxConfig, name: str, payload: Any) -> Path:
if Path(name).name != name or not name.endswith(".json"):
raise ValueError("artifact name must be a flat .json filename")
config.artifact_dir.mkdir(parents=True, exist_ok=True)
path = config.artifact_dir / name
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
return path
def write_jsonl(config: SandboxConfig, name: str, rows: list[dict[str, Any]]) -> Path:
if Path(name).name != name or not name.endswith(".jsonl"):
raise ValueError("artifact name must be a flat .jsonl filename")
config.artifact_dir.mkdir(parents=True, exist_ok=True)
path = config.artifact_dir / name
with path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
return path
def hash_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
def redact_input(value: str, limit: int = 160) -> dict[str, Any]:
text = value or ""
return {"sha256_16": hash_text(text), "length": len(text), "preview": text[:limit]}
def _redact_resource(value: str) -> str:
text = str(value)
if "@" in text and "://" in text:
prefix, suffix = text.split("@", 1)
if "://" in prefix:
scheme, auth = prefix.split("://", 1)
if ":" in auth:
user = auth.split(":", 1)[0]
return f"{scheme}://{user}:***@{suffix}"
return text
def _cleanup_resource(resource: dict[str, Any]) -> None:
kind = resource.get("kind")
identifier = resource.get("identifier", "")
if kind == "directory":
path = Path(identifier).resolve()
if path.name.startswith("eval-") and path.exists():
shutil.rmtree(path)
return
if kind == "file":
path = Path(identifier).resolve()
if path.name.startswith("eval-") and path.exists():
path.unlink()
return
if kind in {"mysql_database", "redis_key", "milvus_collection"}:
raise SandboxSafetyError(f"resource cleanup adapter not configured for {kind}: {identifier}")
raise SandboxSafetyError(f"unknown resource kind: {kind}")
def cleanup_artifact_dir(config: SandboxConfig) -> None:
if config.keep or not config.artifact_dir.exists():
return
if config.artifact_dir.name != config.run_id:
raise SandboxSafetyError("refusing to remove an unexpected artifact directory")
shutil.rmtree(config.artifact_dir)