Files
mini-openclaw/backend/api/operations_center.py
T

211 lines
10 KiB
Python

"""Read-only scoped monitoring and admin-only isolated demonstration actions."""
import json
import threading
import time
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import Field
from api.risk_agent_access import authorize, check_project
from api.project_assistant import Input, enabled, role, translate
from services.project_assistant import ProjectAssistantService
from services.operations_demo import DemoService, PROJECT, regression
from services.assistant_store import get, put, listing, event, now
from services.assistant_model import configuration
from services.assistant_settings import flags
from services.assistant_worker import LEASE_SECONDS
router = APIRouter(prefix="/api/xingbang/risk-agent/project-assistant/operations", dependencies=[Depends(authorize), Depends(enabled)], tags=["运行中心"])
EVALUATION_LOCK = threading.Lock()
SUBMIT_LOCK = threading.Lock()
def current_service():
return ProjectAssistantService()
def demo_service():
return DemoService()
def lab_guard(request, write=False):
if not flags()["demo"]:
raise HTTPException(403, "演示数据域已禁用")
check_project(request, PROJECT)
if write:
role(request, ("admin",))
def selected_service(request, domain):
if domain == "lab":
lab_guard(request)
return demo_service()
return current_service()
def visible(project, identity):
return ("*" in identity.get("projects", []) or project["id"] in identity.get("projects", [])) and (flags()["demo"] or project.get("data_domain") != "demo")
def seconds(start, end=None):
if not start:
return None
return round(max(0, (datetime.fromisoformat(end or now()) - datetime.fromisoformat(start)).total_seconds()), 3)
def summarize(run, job=None):
result = {k: run.get(k) for k in ("id", "project_id", "status", "created_at", "started_at", "ended_at", "attempt", "summary", "error", "superseded", "input_snapshot_id")}
result["elapsed_seconds"] = seconds(run.get("started_at"), run.get("ended_at"))
result["queue_seconds"] = seconds(run.get("created_at"), run.get("started_at") or run.get("ended_at"))
active = next((s for s in run["steps"] if s["status"] == "running"), None)
result["step"] = active["name"] if active else (run["steps"][-1]["name"] if run["steps"] else "等待工作进程领取")
result["lease_expired"] = bool(job and job["state"] == "running" and job["lease_until"] < time.time())
result["heartbeat_at"] = datetime.fromtimestamp(job["lease_until"] - LEASE_SECONDS).astimezone().isoformat() if job and job["state"] == "running" else None
return result
@router.get("/overview")
def overview(request: Request, domain: Literal["current", "lab"] = "current"):
service = selected_service(request, domain)
with service.connect() as db:
projects = [p for p in listing(db, "project") if visible(p, request.state.identity)]
ids = {p["id"] for p in projects}
runs = [r for r in listing(db, "run") if r["project_id"] in ids]
jobs = {r["id"]: dict(r) for r in db.execute("SELECT id,state,lease_until FROM assistant_jobs") if r["id"] in {x["id"] for x in runs}}
findings = [f for f in listing(db, "finding") if f["project_id"] in ids and f["status"] not in ("已解决", "不适用")]
stale = [r for r in listing(db, "report") if r["project_id"] in ids and r.get("status") == "stale"]
counts = {key: sum(r["status"] == key for r in runs) for key in ("queued", "running", "completed", "partial", "failed", "cancelled")}
data = {"domain": domain, "time": now(), "identity": request.state.identity, "counts": counts,
"projects": [{"id": p["id"], "name": p["name"], "data_revision": p["data_revision"]} for p in projects],
"runs": [summarize(r, jobs.get(r["id"])) for r in runs[:100]], "total_runs": len(runs),
"todo_count": len(findings), "stale_report_count": len(stale),
"todos": [{k: f.get(k) for k in ("id", "project_id", "title", "status", "stale", "suggested_action", "last_seen_run_id")} for f in findings[:50]],
"stale_reports": [{k: r.get(k) for k in ("id", "project_id", "status")} for r in stale[:30]],
"evaluations": [e for e in listing(db, "operations_evaluation") if e["project_id"] in ids][:10]}
return data
@router.get("/runs/{run_id}")
def run_detail(run_id: str, request: Request, domain: Literal["current", "lab"] = "current"):
service = selected_service(request, domain)
run = translate(lambda: service.resource("run", run_id))
check_project(request, run["project_id"])
project = service.resource("project", run["project_id"])
if not visible(project, request.state.identity):
raise HTTPException(403, "无权读取该数据域")
with service.connect() as db:
release = {r["id"]: r["version_id"] for r in listing(db, "release")}
components = run["components"]
applied = []
for component in [components["plan"]] + components["skills"] + components["rules"] + [components["template"]]:
applied.append({k: component.get(k) for k in ("id", "name", "kind", "version", "content_hash", "engine", "inputs", "conditions")})
applied[-1]["current_version"] = release.get(component["id"], "旧配置目录")
applied[-1]["changed"] = component["id"] in release and release[component["id"]] != component["id"] + "@" + component["version"]
snapshot = get(db, "input_snapshot", run["input_snapshot_id"])
events = [dict(seq=r[0], **json.loads(r[1])) for r in db.execute("SELECT seq,body FROM assistant_events WHERE project_id=? AND run_id=? ORDER BY seq", (run["project_id"], run_id))]
result = service.public_run(run)
result["steps"] = [{**{k: v for k, v in s.items() if k != "trace"}, "elapsed_seconds": seconds(s.get("started_at"), s.get("ended_at"))} for s in run["steps"]]
return {"run": result, "components": applied, "events": events, "source_revision": snapshot.get("lab_source_revision"),
"source_hash": snapshot.get("lab_source_hash"), "data_revision": snapshot["data_revision"], "values": snapshot["values"]}
@router.get("/lab/source")
def source(request: Request):
lab_guard(request)
lab = demo_service()
with lab.connect() as db:
release = get(db, "release", "lab.coverage")
rule = get(db, "component_version", release["version_id"])
project = get(db, "project", PROJECT)
return {**lab.source(), "release": release, "threshold": rule["conditions"][0]["threshold"],
"synced_revision": project.get("lab_source_revision"), "database": "operations_demo.sqlite3", "isolated": True}
class SourceInput(Input):
revision: int = Field(ge=1)
amount: str = Field(max_length=40)
liabilities: str = Field(max_length=40)
dd_liabilities: str = Field(max_length=40)
cashflow: str | None = Field(default=None, max_length=40)
duplicate: bool = True
enabled: bool = True
@router.post("/lab/source")
def edit_source(payload: SourceInput, request: Request):
lab_guard(request, True)
values = payload.model_dump()
revision = values.pop("revision")
return translate(lambda: demo_service().edit_source(request.state.identity["id"], revision, **values))
class SubmitInput(Input):
source_revision: int = Field(ge=1)
model: bool = False
requirement: str = Field(default="", max_length=2000)
@router.post("/lab/runs", status_code=202)
def submit(payload: SubmitInput, request: Request):
lab_guard(request, True)
key = request.headers.get("idempotency-key", "")
if not key or len(key) > 160:
raise HTTPException(422, "请提供不超过160字符的幂等键")
if payload.requirement.strip() and not payload.model:
raise HTTPException(422, "自然语言要求需要启用真实模型步骤,确定性模式不会解释提示词")
def perform():
with SUBMIT_LOCK:
lab = demo_service()
project = lab.ingest(request.state.identity["id"], payload.source_revision)
return lab.enqueue(PROJECT, request.state.identity, payload.requirement, "lab.model" if payload.model else "lab.deterministic", key, project["revision"])
return translate(perform)
class RuleInput(Input):
version: Literal["1.0.0", "2.0.0"]
revision: int = Field(ge=1)
@router.post("/lab/rule")
def switch_rule(payload: RuleInput, request: Request):
lab_guard(request, True)
return translate(lambda: demo_service().switch_rule(request.state.identity["id"], payload.version, payload.revision))
@router.post("/lab/runs/{run_id}/cancel")
def cancel(run_id: str, request: Request):
lab_guard(request, True)
return translate(lambda: demo_service().cancel(PROJECT, run_id, request.state.identity["id"]))
@router.get("/health")
def health(request: Request):
role(request, ("admin",))
service = current_service()
with service.connect() as db:
integrity = db.execute("PRAGMA quick_check").fetchone()[0]
try:
model = configuration()
except ValueError:
model = {"available": False}
return {"time": now(), "checks": [
{"name": "FastAPI / Next.js 代理", "status": "ok", "detail": "本请求已成功到达后端;不代表所有业务链路均可用"},
{"name": "项目 SQLite 完整性", "status": "ok" if integrity == "ok" else "error", "detail": "PRAGMA quick_check: " + integrity},
{"name": "业务模型", "status": "unverified" if model.get("available") else "unavailable", "detail": "已配置,尚未执行连通性及质量测试" if model.get("available") else "未配置可用内网模型;确定性演示可独立运行"},
{"name": "验证边界", "status": "unverified", "detail": "本页不验证 OCR、Word 版式或 ERP;不展示虚构 Token、成本或通过率"}]}
@router.post("/lab/evaluate")
def evaluate(request: Request):
lab_guard(request, True)
if not EVALUATION_LOCK.acquire(blocking=False):
raise HTTPException(409, "已有隔离验收在执行,请稍后查看")
try:
result = translate(lambda: regression(request.state.identity))
with demo_service().connect() as db:
put(db, "operations_evaluation", result, immutable=True)
event(db, PROJECT, "lab.evaluated", request.state.identity["id"], {"evaluation_id": result["id"], "passed": result["passed"]})
return result
finally:
EVALUATION_LOCK.release()