284 lines
12 KiB
Python
284 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from .shared import *
|
|
|
|
class PKGGuardrailsMixin:
|
|
@staticmethod
|
|
def _routing_rule_candidate_paths() -> list[Path]:
|
|
candidates: list[Path] = []
|
|
configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip()
|
|
if configured:
|
|
candidates.append(Path(configured).expanduser())
|
|
|
|
backend_root = Path(__file__).resolve().parents[2]
|
|
project_root = Path(__file__).resolve().parents[3]
|
|
candidates.extend(
|
|
[
|
|
project_root / ".openclaw" / "pmflow-routing-rules.json",
|
|
backend_root / ".openclaw" / "pmflow-routing-rules.json",
|
|
]
|
|
)
|
|
|
|
deduped: list[Path] = []
|
|
seen: set[str] = set()
|
|
for path in candidates:
|
|
key = str(path)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
deduped.append(path)
|
|
return deduped
|
|
|
|
@classmethod
|
|
def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]:
|
|
specs: dict[str, dict[str, str]] = {
|
|
"low": {
|
|
"tier": "low",
|
|
"provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(),
|
|
"model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(),
|
|
"api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(),
|
|
"source": "env_default",
|
|
"source_path": "",
|
|
"rule_name": "",
|
|
},
|
|
"high": {
|
|
"tier": "high",
|
|
"provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(),
|
|
"model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(),
|
|
"api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(),
|
|
"source": "env_default",
|
|
"source_path": "",
|
|
"rule_name": "",
|
|
},
|
|
}
|
|
|
|
for path in cls._routing_rule_candidate_paths():
|
|
try:
|
|
content = path.read_text(encoding="utf-8")
|
|
payload = json.loads(content)
|
|
except Exception:
|
|
continue
|
|
rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else []
|
|
if not rows:
|
|
continue
|
|
enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))]
|
|
enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
|
|
resolved_any = False
|
|
for tier in ("low", "high"):
|
|
for row in enabled_rows:
|
|
if str(row.get("tier") or "").strip().lower() != tier:
|
|
continue
|
|
model = str(row.get("model") or "").strip()
|
|
if not model:
|
|
continue
|
|
specs[tier] = {
|
|
"tier": tier,
|
|
"provider": str(row.get("provider") or specs[tier]["provider"]).strip(),
|
|
"model": model,
|
|
"api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(),
|
|
"source": "routing_rule",
|
|
"source_path": str(path),
|
|
"rule_name": str(row.get("name") or "").strip(),
|
|
}
|
|
resolved_any = True
|
|
break
|
|
if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")):
|
|
break
|
|
return specs
|
|
|
|
async def _sync_model_guardrail_entities(self) -> dict[str, Any]:
|
|
counters = {"models": 0, "policies": 0, "interfaces": 0, "relations": 0, "evidences": 0}
|
|
model_specs = self._resolve_guardrail_target_models()
|
|
|
|
policy_entity = await self.upsert_entity(
|
|
entity_type="governance_policy",
|
|
entity_key=DDL_GUARDRAIL_POLICY_KEY,
|
|
display_name=DDL_GUARDRAIL_POLICY_NAME,
|
|
payload={
|
|
"policy_code": "ddl_via_interface_only",
|
|
"enforcement_level": "hard_block",
|
|
"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES,
|
|
"required_execution_path": "api_or_migration_interface",
|
|
"enforced_by": [
|
|
"backend/app/database.py",
|
|
"backend/app/services/llm.py",
|
|
"backend/app/config/ai_prompts.py",
|
|
],
|
|
},
|
|
source_type="governance",
|
|
source_ref="backend/app/database.py",
|
|
)
|
|
counters["policies"] += 1
|
|
await self.add_evidence(
|
|
evidence_type="policy_guardrail",
|
|
content_excerpt="Runtime DDL is blocked; model planning must route DDL via interface/migration only.",
|
|
entity_id=int(policy_entity.id),
|
|
source_type="governance",
|
|
source_ref="backend/app/database.py",
|
|
metadata={"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES},
|
|
)
|
|
counters["evidences"] += 1
|
|
|
|
interface_specs = [
|
|
{
|
|
"entity_key": "interface:api:/ai-steward/command/dispatch",
|
|
"display_name": "POST /ai-steward/command/dispatch",
|
|
"payload": {"path": "/ai-steward/command/dispatch", "kind": "api"},
|
|
"source_ref": "backend/app/api/ai_steward.py",
|
|
},
|
|
{
|
|
"entity_key": "interface:migration:alembic",
|
|
"display_name": "Alembic Migration Interface",
|
|
"payload": {"path": "alembic", "kind": "migration"},
|
|
"source_ref": "backend/alembic/versions",
|
|
},
|
|
]
|
|
interface_ids: list[int] = []
|
|
for item in interface_specs:
|
|
interface_entity = await self.upsert_entity(
|
|
entity_type="interface",
|
|
entity_key=str(item["entity_key"]),
|
|
display_name=str(item["display_name"]),
|
|
payload=dict(item["payload"]),
|
|
source_type="governance",
|
|
source_ref=str(item["source_ref"]),
|
|
)
|
|
counters["interfaces"] += 1
|
|
interface_ids.append(int(interface_entity.id))
|
|
rel = await self.upsert_relation(
|
|
relation_type="policy_enforced_by_interface",
|
|
subject_entity_id=int(policy_entity.id),
|
|
object_entity_id=int(interface_entity.id),
|
|
source_type="governance",
|
|
source_ref=str(item["source_ref"]),
|
|
)
|
|
counters["relations"] += 1
|
|
await self.add_evidence(
|
|
evidence_type="policy_guardrail",
|
|
content_excerpt=f"{DDL_GUARDRAIL_POLICY_NAME} -> {item['display_name']}",
|
|
relation_id=int(rel.id),
|
|
source_type="governance",
|
|
source_ref=str(item["source_ref"]),
|
|
metadata=dict(item["payload"]),
|
|
)
|
|
counters["evidences"] += 1
|
|
|
|
for tier in ("low", "high"):
|
|
spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {}
|
|
model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5")
|
|
provider = str(spec.get("provider") or "").strip()
|
|
api_env = str(spec.get("api_env") or "").strip()
|
|
source = str(spec.get("source") or "env_default").strip()
|
|
source_ref = str(spec.get("source_path") or spec.get("rule_name") or "llm_routing_env").strip()[:255]
|
|
entity = await self.upsert_entity(
|
|
entity_type="ai_model",
|
|
entity_key=f"ai_model:{tier}:{model_name.lower()[:160]}",
|
|
display_name=model_name[:255],
|
|
payload={
|
|
"tier": tier,
|
|
"provider": provider,
|
|
"model": model_name,
|
|
"api_env": api_env,
|
|
"source": source,
|
|
"routing_rule": str(spec.get("rule_name") or "").strip(),
|
|
},
|
|
source_type="model_routing",
|
|
source_ref=source_ref or None,
|
|
)
|
|
counters["models"] += 1
|
|
rel = await self.upsert_relation(
|
|
relation_type="model_must_follow_policy",
|
|
subject_entity_id=int(entity.id),
|
|
object_entity_id=int(policy_entity.id),
|
|
source_type="governance",
|
|
source_ref=source_ref or None,
|
|
payload={"tier": tier, "policy_code": "ddl_via_interface_only"},
|
|
)
|
|
counters["relations"] += 1
|
|
await self.add_evidence(
|
|
evidence_type="policy_guardrail",
|
|
content_excerpt=f"{tier} model {model_name} must follow {DDL_GUARDRAIL_POLICY_KEY}",
|
|
relation_id=int(rel.id),
|
|
source_type="governance",
|
|
source_ref=source_ref or None,
|
|
metadata={"tier": tier, "provider": provider, "model": model_name},
|
|
)
|
|
counters["evidences"] += 1
|
|
|
|
for interface_id in interface_ids:
|
|
rel = await self.upsert_relation(
|
|
relation_type="model_executes_via_interface",
|
|
subject_entity_id=int(entity.id),
|
|
object_entity_id=int(interface_id),
|
|
source_type="governance",
|
|
source_ref=source_ref or None,
|
|
payload={"tier": tier},
|
|
)
|
|
counters["relations"] += 1
|
|
await self.add_evidence(
|
|
evidence_type="policy_guardrail",
|
|
content_excerpt=f"{tier} model {model_name} executes via controlled interface",
|
|
relation_id=int(rel.id),
|
|
source_type="governance",
|
|
source_ref=source_ref or None,
|
|
metadata={"tier": tier},
|
|
)
|
|
counters["evidences"] += 1
|
|
|
|
return counters
|
|
|
|
async def sync_model_guardrails_if_due(
|
|
self,
|
|
*,
|
|
interval_hours: int = 6,
|
|
) -> dict[str, Any]:
|
|
hours = max(1, int(interval_hours))
|
|
offset_raw = await self.get_ingest_offset(channel="model_guardrail", offset_key="last_model_guardrail_sync_at")
|
|
last_sync_at = self._parse_iso_dt(offset_raw)
|
|
now = _utc_now_naive()
|
|
if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours):
|
|
return {
|
|
"executed": False,
|
|
"reason": "not_due",
|
|
"last_sync_at": last_sync_at.isoformat(),
|
|
"next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(),
|
|
}
|
|
|
|
payload = await self._sync_model_guardrail_entities()
|
|
await self.enqueue_ingest_event(
|
|
channel="model_guardrail",
|
|
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
|
|
payload=payload,
|
|
status="done",
|
|
)
|
|
await self.set_ingest_offset(
|
|
channel="model_guardrail",
|
|
offset_key="last_model_guardrail_sync_at",
|
|
offset_value=_utc_now_naive().isoformat(),
|
|
payload=payload,
|
|
)
|
|
await self.db.commit()
|
|
return {
|
|
"executed": True,
|
|
"reason": "due",
|
|
"payload": payload,
|
|
}
|
|
|
|
async def sync_model_guardrails_now(self) -> dict[str, Any]:
|
|
payload = await self._sync_model_guardrail_entities()
|
|
await self.enqueue_ingest_event(
|
|
channel="model_guardrail",
|
|
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
|
|
payload=payload,
|
|
status="done",
|
|
)
|
|
await self.set_ingest_offset(
|
|
channel="model_guardrail",
|
|
offset_key="last_model_guardrail_sync_at",
|
|
offset_value=_utc_now_naive().isoformat(),
|
|
payload=payload,
|
|
)
|
|
await self.db.commit()
|
|
return payload
|
|
|