- 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.
222 lines
7.5 KiB
Python
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)
|