274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""Persistent company employee registry helpers.
|
|
|
|
Employees are runtime/company assets, while organization configs describe the
|
|
role graph and policies. This module keeps employee records outside org yaml
|
|
and normalizes template-backed employees to a canonical template id.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from opc.core.config import EmployeeConfig, validate_organization_id
|
|
|
|
EMPLOYEE_REGISTRY_SCHEMA_VERSION = 1
|
|
EMPLOYEE_REGISTRY_KIND = "company_employee"
|
|
|
|
_COUNT_KEYS = {
|
|
"successes",
|
|
"partial_successes",
|
|
"failures",
|
|
"reflection_count",
|
|
}
|
|
|
|
|
|
def _slugify(value: str) -> str:
|
|
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip().lower()).strip("-")
|
|
return slug or "employee"
|
|
|
|
|
|
def employee_registry_dir(opc_home: Path, organization_id: Any) -> Path:
|
|
org_id = validate_organization_id(organization_id)
|
|
return Path(opc_home) / "company_state" / org_id / "employees"
|
|
|
|
|
|
def employee_registry_path(opc_home: Path, organization_id: Any, employee_id: str) -> Path:
|
|
filename = f"{_slugify(employee_id)}.yaml"
|
|
return employee_registry_dir(opc_home, organization_id) / filename
|
|
|
|
|
|
def is_placeholder_employee(employee: EmployeeConfig | dict[str, Any]) -> bool:
|
|
metadata = dict(employee.metadata if isinstance(employee, EmployeeConfig) else employee.get("metadata") or {})
|
|
return bool(metadata.get("is_default_employee") or metadata.get("is_fallback_employee"))
|
|
|
|
|
|
def _employee_from_payload(raw: Any) -> EmployeeConfig | None:
|
|
if isinstance(raw, EmployeeConfig):
|
|
return raw.model_copy(deep=True)
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
payload = raw.get("employee") if isinstance(raw.get("employee"), dict) else raw
|
|
try:
|
|
return EmployeeConfig.model_validate(payload)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _append_unique(items: list[Any], additions: list[Any]) -> list[Any]:
|
|
result = list(items or [])
|
|
for item in list(additions or []):
|
|
if item not in result:
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _merge_metadata(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
|
merged = dict(base or {})
|
|
for key, value in dict(incoming or {}).items():
|
|
if key == "legacy_employee_ids":
|
|
merged[key] = _append_unique(list(merged.get(key, []) or []), list(value or []))
|
|
elif key in {"home_role_ids", "staffed_role_ids"}:
|
|
merged[key] = _append_unique(list(merged.get(key, []) or []), list(value or []))
|
|
elif key not in merged or _is_empty_value(merged.get(key)):
|
|
merged[key] = value
|
|
elif isinstance(merged.get(key), dict) and isinstance(value, dict):
|
|
nested = dict(merged[key])
|
|
nested.update(value)
|
|
merged[key] = nested
|
|
return merged
|
|
|
|
|
|
def _canonicalize_employee(employee: EmployeeConfig) -> tuple[EmployeeConfig, dict[str, str]]:
|
|
if is_placeholder_employee(employee):
|
|
return employee.model_copy(deep=True), {}
|
|
|
|
old_id = str(employee.employee_id or "").strip()
|
|
template_id = str(employee.template_id or "").strip()
|
|
canonical_id = template_id or old_id
|
|
metadata = dict(employee.metadata or {})
|
|
aliases: dict[str, str] = {}
|
|
|
|
legacy_ids = [str(item).strip() for item in list(metadata.get("legacy_employee_ids", []) or []) if str(item).strip()]
|
|
if old_id and old_id != canonical_id and old_id not in legacy_ids:
|
|
legacy_ids.append(old_id)
|
|
if legacy_ids:
|
|
metadata["legacy_employee_ids"] = legacy_ids
|
|
for legacy_id in legacy_ids:
|
|
aliases[legacy_id] = canonical_id
|
|
|
|
role_id = str(employee.role_id or "").strip()
|
|
if role_id:
|
|
metadata.setdefault("home_role_id", role_id)
|
|
metadata["home_role_ids"] = _append_unique(list(metadata.get("home_role_ids", []) or []), [role_id])
|
|
metadata["staffed_role_ids"] = _append_unique(list(metadata.get("staffed_role_ids", []) or []), [role_id])
|
|
if template_id:
|
|
metadata.setdefault("canonical_employee_id", canonical_id)
|
|
|
|
return employee.model_copy(update={"employee_id": canonical_id, "metadata": metadata}), aliases
|
|
|
|
|
|
def _merge_employee(base: EmployeeConfig, incoming: EmployeeConfig) -> EmployeeConfig:
|
|
merged = base.model_dump()
|
|
other = incoming.model_dump()
|
|
for field in ("domains", "tags", "prompt_refs", "skill_refs"):
|
|
merged[field] = _append_unique(list(merged.get(field, []) or []), list(other.get(field, []) or []))
|
|
for field in ("name", "template_id", "description", "category", "preferred_external_agent", "seniority", "status"):
|
|
if not merged.get(field) and other.get(field):
|
|
merged[field] = other[field]
|
|
elif field == "description" and len(str(other.get(field, ""))) > len(str(merged.get(field, ""))):
|
|
merged[field] = other[field]
|
|
if not merged.get("role_id") and other.get("role_id"):
|
|
merged["role_id"] = other["role_id"]
|
|
merged["metadata"] = _merge_metadata(dict(merged.get("metadata", {}) or {}), dict(other.get("metadata", {}) or {}))
|
|
return EmployeeConfig.model_validate(merged)
|
|
|
|
|
|
def normalize_employee_records(employees: list[Any]) -> tuple[list[EmployeeConfig], dict[str, str]]:
|
|
real_by_id: dict[str, EmployeeConfig] = {}
|
|
placeholders: list[EmployeeConfig] = []
|
|
aliases: dict[str, str] = {}
|
|
for raw in list(employees or []):
|
|
employee = _employee_from_payload(raw)
|
|
if employee is None:
|
|
continue
|
|
if is_placeholder_employee(employee):
|
|
placeholders.append(employee)
|
|
continue
|
|
canonical, employee_aliases = _canonicalize_employee(employee)
|
|
aliases.update(employee_aliases)
|
|
existing = real_by_id.get(canonical.employee_id)
|
|
real_by_id[canonical.employee_id] = _merge_employee(existing, canonical) if existing else canonical
|
|
real = sorted(real_by_id.values(), key=lambda item: (item.category, item.name.lower(), item.employee_id))
|
|
return [*real, *placeholders], aliases
|
|
|
|
|
|
def load_employee_registry(opc_home: Path, organization_id: Any) -> list[EmployeeConfig]:
|
|
directory = employee_registry_dir(opc_home, organization_id)
|
|
if not directory.is_dir():
|
|
return []
|
|
employees: list[EmployeeConfig] = []
|
|
for path in sorted(directory.glob("*.yaml")):
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
except Exception:
|
|
continue
|
|
employee = _employee_from_payload(data)
|
|
if employee is not None:
|
|
employees.append(employee)
|
|
return employees
|
|
|
|
|
|
def load_company_employees(
|
|
opc_home: Path,
|
|
organization_id: Any,
|
|
legacy_employees: list[Any],
|
|
) -> list[EmployeeConfig]:
|
|
registry_employees = load_employee_registry(opc_home, organization_id)
|
|
employees, aliases = normalize_employee_records([*registry_employees, *list(legacy_employees or [])])
|
|
migrate_evolution_employee_ids(opc_home, aliases)
|
|
return employees
|
|
|
|
|
|
def write_employee_registry(
|
|
opc_home: Path,
|
|
organization_id: Any,
|
|
employees: list[Any],
|
|
) -> tuple[list[EmployeeConfig], dict[str, str]]:
|
|
normalized, aliases = normalize_employee_records(employees)
|
|
real_employees = [employee for employee in normalized if not is_placeholder_employee(employee)]
|
|
directory = employee_registry_dir(opc_home, organization_id)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
expected_paths: set[Path] = set()
|
|
for employee in real_employees:
|
|
path = employee_registry_path(opc_home, organization_id, employee.employee_id)
|
|
expected_paths.add(path)
|
|
payload = {
|
|
"schema_version": EMPLOYEE_REGISTRY_SCHEMA_VERSION,
|
|
"kind": EMPLOYEE_REGISTRY_KIND,
|
|
"organization_id": validate_organization_id(organization_id),
|
|
"employee": employee.model_dump(),
|
|
}
|
|
_atomic_write_text(
|
|
path,
|
|
yaml.dump(payload, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
|
)
|
|
for path in directory.glob("*.yaml"):
|
|
if path not in expected_paths:
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
migrate_evolution_employee_ids(opc_home, aliases)
|
|
return normalized, aliases
|
|
|
|
|
|
def migrate_evolution_employee_ids(opc_home: Path, aliases: dict[str, str]) -> None:
|
|
canonical_aliases = {
|
|
str(old).strip(): str(new).strip()
|
|
for old, new in dict(aliases or {}).items()
|
|
if str(old).strip() and str(new).strip() and str(old).strip() != str(new).strip()
|
|
}
|
|
if not canonical_aliases:
|
|
return
|
|
paths: list[Path] = [Path(opc_home) / "evolution" / "employees.json"]
|
|
projects_dir = Path(opc_home) / "projects"
|
|
if projects_dir.is_dir():
|
|
paths.extend(sorted(projects_dir.glob("*/employee_evolution.json")))
|
|
for path in paths:
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
profile = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
if not isinstance(profile, dict):
|
|
continue
|
|
employees = profile.get("employees")
|
|
if not isinstance(employees, dict):
|
|
continue
|
|
changed = False
|
|
for legacy_id, canonical_id in canonical_aliases.items():
|
|
if legacy_id not in employees:
|
|
continue
|
|
legacy_record = employees.pop(legacy_id)
|
|
current = employees.get(canonical_id)
|
|
employees[canonical_id] = _merge_evolution_records(current, legacy_record)
|
|
changed = True
|
|
if changed:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(profile, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _merge_evolution_records(base: Any, incoming: Any) -> Any:
|
|
if isinstance(base, dict) and isinstance(incoming, dict):
|
|
merged = dict(base)
|
|
for key, value in incoming.items():
|
|
if key in _COUNT_KEYS and isinstance(value, (int, float)):
|
|
merged[key] = int(merged.get(key, 0) or 0) + int(value)
|
|
elif isinstance(value, list):
|
|
merged[key] = _append_unique(list(merged.get(key, []) or []), value)
|
|
elif isinstance(value, dict):
|
|
merged[key] = _merge_evolution_records(merged.get(key, {}), value)
|
|
elif key not in merged or _is_empty_value(merged.get(key)):
|
|
merged[key] = value
|
|
return merged
|
|
if isinstance(base, list) and isinstance(incoming, list):
|
|
return _append_unique(base, incoming)
|
|
return base if not _is_empty_value(base) else incoming
|
|
|
|
|
|
def _is_empty_value(value: Any) -> bool:
|
|
return value is None or value == "" or value == [] or value == {}
|
|
|
|
|
|
def _atomic_write_text(path: Path, content: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(content, encoding="utf-8")
|
|
tmp.replace(path)
|