PMFlow/backend/app/services/project_knowledge_graph_ser.../mixin_guardrails.py

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