Initial commit
This commit is contained in:
@@ -0,0 +1,998 @@
|
||||
"""Employee experience tracking, project reflections, and learned-skill distillation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import validate_organization_id
|
||||
from opc.core.models import TaskStatus
|
||||
from opc.layer2_organization.work_item_identity import projection_id_for_task
|
||||
from opc.layer5_memory.preference import PreferenceManager
|
||||
from opc.layer5_memory.skill_library import Skill, SkillLibrary
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
class EmployeeEvolutionManager:
|
||||
"""Tracks employee outcomes, project reflections, and learned skills."""
|
||||
|
||||
LEARNED_SKILL_THRESHOLD = 2
|
||||
EMPLOYEE_EXPERIENCE_SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self, opc_home) -> None:
|
||||
self.opc_home = Path(opc_home)
|
||||
self.preferences = PreferenceManager(self.opc_home)
|
||||
self.skills = SkillLibrary(self.opc_home)
|
||||
self.skills.load_all()
|
||||
|
||||
def evolution_profile_path(self, project_id: str | None = None) -> Path:
|
||||
"""Return the structured company-evolution state path.
|
||||
|
||||
Employee evolution is runtime/company state, not user/project durable
|
||||
memory. Keeping it outside ``memory/*.md`` lets company mode retain
|
||||
experience scoring and learned playbooks without reintroducing hidden
|
||||
user preference writes.
|
||||
"""
|
||||
project = str(project_id or "").strip()
|
||||
if project:
|
||||
return self.opc_home / "projects" / project / "employee_evolution.json"
|
||||
return self.opc_home / "evolution" / "employees.json"
|
||||
|
||||
def load_evolution_profile(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
path = self.evolution_profile_path(project_id)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save_evolution_profile(self, profile: dict[str, Any], project_id: str | None = None) -> None:
|
||||
path = self.evolution_profile_path(project_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(dict(profile or {}), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def employee_experience_dir(self, organization_id: str) -> Path:
|
||||
org_id = validate_organization_id(organization_id)
|
||||
return self.opc_home / "company_state" / org_id / "employee_experience"
|
||||
|
||||
def employee_experience_path(self, organization_id: str, employee_id: str) -> Path:
|
||||
safe_id = self._safe_employee_filename(employee_id)
|
||||
return self.employee_experience_dir(organization_id) / f"{safe_id}.json"
|
||||
|
||||
def load_employee_experience(self, organization_id: str, employee_id: str) -> dict[str, Any]:
|
||||
path = self.employee_experience_path(organization_id, employee_id)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save_employee_experience(self, organization_id: str, employee_id: str, profile: dict[str, Any]) -> None:
|
||||
path = self.employee_experience_path(organization_id, employee_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(dict(profile or {}), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_employee_profile(self, employee_id: str, project_id: str | None = None) -> dict[str, Any]:
|
||||
global_profile = self.load_evolution_profile()
|
||||
project_profile = self.load_evolution_profile(project_id) if project_id else {}
|
||||
global_data = dict(global_profile.get("employees", {}).get(employee_id, {}))
|
||||
project_data = dict(project_profile.get("employees", {}).get(employee_id, {}))
|
||||
return self._deep_merge(global_data, project_data)
|
||||
|
||||
def get_learned_skill_refs(self, employee_id: str, project_id: str | None = None) -> list[str]:
|
||||
profile = self.get_employee_profile(employee_id, project_id)
|
||||
refs = list(profile.get("learned_skill_refs", []))
|
||||
unique: list[str] = []
|
||||
for ref in refs:
|
||||
if ref and ref not in unique:
|
||||
unique.append(ref)
|
||||
return unique
|
||||
|
||||
def build_employee_delta_context(
|
||||
self,
|
||||
employee_id: str,
|
||||
project_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> str:
|
||||
profile = self.get_employee_profile(employee_id, project_id)
|
||||
delta = dict(profile.get("delta_profile", {}))
|
||||
experience_profile: dict[str, Any] = {}
|
||||
experience_delta: dict[str, Any] = {}
|
||||
if organization_id:
|
||||
experience_profile = self.load_employee_experience(organization_id, employee_id)
|
||||
experience_delta = dict(experience_profile.get("delta_profile", {}) or {})
|
||||
if not delta and not experience_delta:
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
evolution_count = int(experience_profile.get("evolution_count", 0) or len(experience_profile.get("events", []) or []))
|
||||
if evolution_count:
|
||||
parts.append(f"Self-evolution reviews: {evolution_count}")
|
||||
projects_reflected = int(profile.get("projects_reflected", 0))
|
||||
if projects_reflected:
|
||||
parts.append(f"Reflected projects: {projects_reflected}")
|
||||
|
||||
for title, key in (
|
||||
("Self-Evolved Strengths", "strengths"),
|
||||
("Self-Evolved Adjustments", "adjustments"),
|
||||
("Self-Evolved Watchouts", "avoid_next_time"),
|
||||
("Self-Evolved Routing Notes", "routing_notes"),
|
||||
):
|
||||
values = [str(item).strip() for item in list(experience_delta.get(key, []) or []) if str(item).strip()]
|
||||
if values:
|
||||
parts.append(f"## {title}\n" + "\n".join(f"- {item}" for item in values[:6]))
|
||||
|
||||
for title, key in (
|
||||
("Working Patterns", "working_patterns"),
|
||||
("Default Checklists", "default_checklists"),
|
||||
("Reviewer Preferences", "reviewer_preferences"),
|
||||
("Risk Watchouts", "risk_watchouts"),
|
||||
("Tool Preferences", "tool_preferences"),
|
||||
("Fit Domains", "fit_domains"),
|
||||
("Avoid Domains", "avoid_domains"),
|
||||
):
|
||||
values = [str(item).strip() for item in delta.get(key, []) if str(item).strip()]
|
||||
if values:
|
||||
parts.append(f"## {title}\n" + "\n".join(f"- {item}" for item in values[:6]))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def get_experience_score(
|
||||
self,
|
||||
employee_id: str,
|
||||
role_id: str,
|
||||
domains: list[str] | None = None,
|
||||
project_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> float:
|
||||
profile = self.get_employee_profile(employee_id, project_id)
|
||||
total_successes = int(profile.get("successes", 0))
|
||||
total_partials = int(profile.get("partial_successes", 0))
|
||||
total_failures = int(profile.get("failures", 0))
|
||||
role_successes = int(profile.get("roles", {}).get(role_id, {}).get("successes", 0))
|
||||
role_partials = int(profile.get("roles", {}).get(role_id, {}).get("partial_successes", 0))
|
||||
role_failures = int(profile.get("roles", {}).get(role_id, {}).get("failures", 0))
|
||||
learned_bonus = 2 * len(profile.get("learned_skill_refs", []))
|
||||
reflection_bonus = min(4, int(profile.get("projects_reflected", 0)))
|
||||
domain_bonus = 0
|
||||
for domain in domains or []:
|
||||
domain_record = profile.get("domains", {}).get(domain, {})
|
||||
domain_bonus += int(domain_record.get("successes", 0))
|
||||
domain_bonus += 0.5 * int(domain_record.get("partial_successes", 0))
|
||||
domain_bonus -= 0.25 * int(domain_record.get("failures", 0))
|
||||
score = (
|
||||
total_successes
|
||||
+ (0.5 * total_partials)
|
||||
- (0.25 * total_failures)
|
||||
+ (2 * role_successes)
|
||||
+ role_partials
|
||||
- (0.5 * role_failures)
|
||||
+ domain_bonus
|
||||
+ learned_bonus
|
||||
+ reflection_bonus
|
||||
)
|
||||
if organization_id:
|
||||
experience_profile = self.load_employee_experience(organization_id, employee_id)
|
||||
events = [item for item in list(experience_profile.get("events", []) or []) if isinstance(item, dict)]
|
||||
score += min(8.0, float(len(events)))
|
||||
for event in events[-8:]:
|
||||
if str(event.get("role_id", "") or "").strip() == role_id:
|
||||
score += 0.5
|
||||
return float(max(0.0, score))
|
||||
|
||||
def apply_employee_evolution_patch(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
patch: dict[str, Any],
|
||||
source: dict[str, Any] | None = None,
|
||||
allowed_employee_ids: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
org_id = validate_organization_id(organization_id)
|
||||
source_payload = dict(source or {})
|
||||
raw_events = list(patch.get("patches", []) or [])
|
||||
allowed = {str(item).strip() for item in (allowed_employee_ids or set()) if str(item).strip()}
|
||||
recorded: list[dict[str, Any]] = []
|
||||
|
||||
for raw_event in raw_events:
|
||||
if not isinstance(raw_event, dict):
|
||||
continue
|
||||
employee_id = str(raw_event.get("employee_id", "") or "").strip()
|
||||
if not employee_id or (allowed and employee_id not in allowed):
|
||||
continue
|
||||
event = self._normalize_self_evolution_event(raw_event, source_payload)
|
||||
if not event:
|
||||
continue
|
||||
|
||||
profile = self.load_employee_experience(org_id, employee_id)
|
||||
if not profile:
|
||||
profile = {
|
||||
"schema_version": self.EMPLOYEE_EXPERIENCE_SCHEMA_VERSION,
|
||||
"kind": "company_employee_experience",
|
||||
"organization_id": org_id,
|
||||
"employee_id": employee_id,
|
||||
"events": [],
|
||||
"delta_profile": {},
|
||||
"evolution_count": 0,
|
||||
}
|
||||
profile["schema_version"] = self.EMPLOYEE_EXPERIENCE_SCHEMA_VERSION
|
||||
profile["kind"] = "company_employee_experience"
|
||||
profile["organization_id"] = org_id
|
||||
profile["employee_id"] = employee_id
|
||||
events = [item for item in list(profile.get("events", []) or []) if isinstance(item, dict)]
|
||||
event_id = str(event.get("event_id", "") or "").strip()
|
||||
if event_id and any(str(item.get("event_id", "") or "").strip() == event_id for item in events):
|
||||
continue
|
||||
events.append(event)
|
||||
profile["events"] = events[-100:]
|
||||
profile["evolution_count"] = int(profile.get("evolution_count", 0) or 0) + 1
|
||||
profile["updated_at"] = _utc_now()
|
||||
profile["delta_profile"] = self._build_self_evolution_delta(profile["events"])
|
||||
self.save_employee_experience(org_id, employee_id, profile)
|
||||
recorded.append({
|
||||
"employee_id": employee_id,
|
||||
"event_id": event_id,
|
||||
"status": "recorded",
|
||||
"path": str(self.employee_experience_path(org_id, employee_id)),
|
||||
})
|
||||
|
||||
return recorded
|
||||
|
||||
def record_work_item_completion(
|
||||
self,
|
||||
task: Any,
|
||||
result_content: str,
|
||||
*,
|
||||
outcome: str = "success",
|
||||
feedback_summary: str = "",
|
||||
strengths: list[str] | None = None,
|
||||
weaknesses: list[str] | None = None,
|
||||
rationale: str = "",
|
||||
) -> dict[str, Any]:
|
||||
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
|
||||
employee_id = str(assignment.get("employee_id", "")).strip()
|
||||
if not employee_id:
|
||||
return {}
|
||||
|
||||
role_id = str(
|
||||
assignment.get("role_id")
|
||||
or getattr(task, "assigned_to", "")
|
||||
or getattr(task, "metadata", {}).get("work_item_role_id", "")
|
||||
).strip()
|
||||
domains = list(assignment.get("domains") or getattr(task, "tags", []) or [])
|
||||
project_id = getattr(task, "project_id", None) or None
|
||||
summary = str(getattr(task, "metadata", {}).get("work_item_summary_for_downstream", "") or result_content).strip()
|
||||
pattern_key = self._pattern_key(role_id, domains)
|
||||
normalized_outcome = self._normalize_outcome(outcome)
|
||||
base_payload = {
|
||||
"employee_id": employee_id,
|
||||
"employee_name": assignment.get("name", ""),
|
||||
"role_id": role_id,
|
||||
"template_id": assignment.get("template_id", ""),
|
||||
"category": assignment.get("category", ""),
|
||||
}
|
||||
|
||||
global_profile = self.load_evolution_profile()
|
||||
self._record_in_profile(
|
||||
global_profile,
|
||||
base_payload=base_payload,
|
||||
role_id=role_id,
|
||||
domains=domains,
|
||||
pattern_key=pattern_key,
|
||||
summary=summary,
|
||||
outcome=normalized_outcome,
|
||||
feedback_summary=feedback_summary,
|
||||
strengths=list(strengths or []),
|
||||
weaknesses=list(weaknesses or []),
|
||||
rationale=rationale,
|
||||
)
|
||||
self.save_evolution_profile(global_profile)
|
||||
|
||||
if project_id:
|
||||
project_profile = self.load_evolution_profile(project_id)
|
||||
self._record_in_profile(
|
||||
project_profile,
|
||||
base_payload=base_payload,
|
||||
role_id=role_id,
|
||||
domains=domains,
|
||||
pattern_key=pattern_key,
|
||||
summary=summary,
|
||||
outcome=normalized_outcome,
|
||||
feedback_summary=feedback_summary,
|
||||
strengths=list(strengths or []),
|
||||
weaknesses=list(weaknesses or []),
|
||||
rationale=rationale,
|
||||
)
|
||||
self.save_evolution_profile(project_profile, project_id)
|
||||
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
"project_id": project_id or "",
|
||||
"pattern_key": pattern_key,
|
||||
"outcome": normalized_outcome,
|
||||
}
|
||||
|
||||
def record_project_reflections(
|
||||
self,
|
||||
delivery_task: Any,
|
||||
work_item_tasks: list[Any],
|
||||
partial: bool = False,
|
||||
*,
|
||||
feedback: dict[str, Any] | None = None,
|
||||
evaluation: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
project_id = str(getattr(delivery_task, "project_id", "") or "").strip()
|
||||
delivery_task_id = str(getattr(delivery_task, "id", "") or "").strip()
|
||||
if not project_id or not delivery_task_id:
|
||||
return []
|
||||
|
||||
tasks = self._normalize_work_item_tasks(work_item_tasks, delivery_task)
|
||||
terminal = {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
|
||||
employee_groups: dict[str, list[Any]] = {}
|
||||
for task in tasks:
|
||||
if getattr(task, "status", None) not in terminal:
|
||||
continue
|
||||
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
|
||||
employee_id = str(assignment.get("employee_id", "")).strip()
|
||||
if not employee_id:
|
||||
continue
|
||||
employee_groups.setdefault(employee_id, []).append(task)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for employee_id, employee_tasks in employee_groups.items():
|
||||
reflection = self._build_project_reflection(
|
||||
delivery_task=delivery_task,
|
||||
employee_tasks=employee_tasks,
|
||||
project_id=project_id,
|
||||
partial=partial,
|
||||
feedback=feedback,
|
||||
evaluation=evaluation,
|
||||
)
|
||||
reflection_path = self.evolution_profile_path(project_id)
|
||||
|
||||
global_profile = self.load_evolution_profile()
|
||||
self._record_project_reflection_in_profile(
|
||||
global_profile,
|
||||
reflection=reflection,
|
||||
reflection_path=reflection_path,
|
||||
)
|
||||
learned_skill_ref = self._maybe_promote_reflection_skill(global_profile, reflection, project_id=project_id)
|
||||
self.save_evolution_profile(global_profile)
|
||||
|
||||
project_profile = self.load_evolution_profile(project_id)
|
||||
self._record_project_reflection_in_profile(
|
||||
project_profile,
|
||||
reflection=reflection,
|
||||
reflection_path=reflection_path,
|
||||
)
|
||||
if learned_skill_ref:
|
||||
self._attach_learned_skill(
|
||||
project_profile,
|
||||
reflection["employee_id"],
|
||||
reflection["pattern_key"],
|
||||
learned_skill_ref,
|
||||
)
|
||||
else:
|
||||
learned_skill_ref = self._maybe_promote_reflection_skill(project_profile, reflection, project_id=project_id)
|
||||
self.save_evolution_profile(project_profile, project_id)
|
||||
|
||||
results.append({
|
||||
"employee_id": employee_id,
|
||||
"reflection_path": str(reflection_path),
|
||||
"learned_skill_ref": learned_skill_ref,
|
||||
"status": "recorded",
|
||||
"reflection": reflection,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def _record_in_profile(
|
||||
self,
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
base_payload: dict[str, Any],
|
||||
role_id: str,
|
||||
domains: list[str],
|
||||
pattern_key: str,
|
||||
summary: str,
|
||||
outcome: str,
|
||||
feedback_summary: str,
|
||||
strengths: list[str],
|
||||
weaknesses: list[str],
|
||||
rationale: str,
|
||||
) -> None:
|
||||
employees = profile.setdefault("employees", {})
|
||||
record = employees.setdefault(base_payload["employee_id"], {})
|
||||
record.update({
|
||||
"employee_name": base_payload.get("employee_name", ""),
|
||||
"role_id": role_id,
|
||||
"template_id": base_payload.get("template_id", ""),
|
||||
"category": base_payload.get("category", ""),
|
||||
"updated_at": _utc_now(),
|
||||
})
|
||||
self._increment_outcome_counts(record, outcome)
|
||||
record["last_outcome"] = outcome
|
||||
if feedback_summary:
|
||||
record["last_feedback_summary"] = feedback_summary
|
||||
if rationale:
|
||||
record["last_feedback_rationale"] = rationale
|
||||
roles = record.setdefault("roles", {})
|
||||
role_record = roles.setdefault(role_id, {"successes": 0, "partial_successes": 0, "failures": 0})
|
||||
self._increment_outcome_counts(role_record, outcome)
|
||||
role_record["last_outcome"] = outcome
|
||||
domain_records = record.setdefault("domains", {})
|
||||
for domain in domains:
|
||||
domain_record = domain_records.setdefault(domain, {"successes": 0, "partial_successes": 0, "failures": 0})
|
||||
self._increment_outcome_counts(domain_record, outcome)
|
||||
domain_record["last_outcome"] = outcome
|
||||
patterns = record.setdefault("patterns", {})
|
||||
pattern = patterns.setdefault(pattern_key, self._base_pattern_record(role_id, domains))
|
||||
self._increment_outcome_counts(pattern, outcome)
|
||||
pattern["last_summary"] = summary
|
||||
pattern["last_outcome"] = outcome
|
||||
pattern["last_feedback_summary"] = feedback_summary
|
||||
pattern["last_feedback_rationale"] = rationale
|
||||
if outcome == "success":
|
||||
pattern["last_success_at"] = _utc_now()
|
||||
elif outcome == "partial_success":
|
||||
pattern["last_partial_success_at"] = _utc_now()
|
||||
else:
|
||||
pattern["last_failure_at"] = _utc_now()
|
||||
self._increment_counts(record, "working_pattern_counts", strengths)
|
||||
self._increment_counts(record, "risk_watchout_counts", weaknesses)
|
||||
self._increment_counts(pattern, "working_pattern_counts", strengths)
|
||||
self._increment_counts(pattern, "risk_watchout_counts", weaknesses)
|
||||
record["delta_profile"] = self._build_delta_profile(record)
|
||||
record.setdefault("learned_skill_refs", [])
|
||||
|
||||
def _record_project_reflection_in_profile(
|
||||
self,
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
reflection: dict[str, Any],
|
||||
reflection_path: Path,
|
||||
) -> None:
|
||||
employees = profile.setdefault("employees", {})
|
||||
employee_id = str(reflection.get("employee_id", "")).strip()
|
||||
if not employee_id:
|
||||
return
|
||||
record = employees.setdefault(employee_id, {})
|
||||
record.update({
|
||||
"employee_name": reflection.get("employee_name", ""),
|
||||
"role_id": reflection.get("role_id", ""),
|
||||
"template_id": reflection.get("template_id", ""),
|
||||
"category": reflection.get("category", ""),
|
||||
"updated_at": _utc_now(),
|
||||
"last_reflection_at": _utc_now(),
|
||||
})
|
||||
reflection_ids = record.setdefault("project_reflection_ids", [])
|
||||
if reflection["delivery_task_id"] in reflection_ids:
|
||||
return
|
||||
reflection_ids.append(reflection["delivery_task_id"])
|
||||
reflection_paths = record.setdefault("reflection_paths", [])
|
||||
reflection_paths.append(str(reflection_path))
|
||||
project_ids = record.setdefault("project_ids", [])
|
||||
if reflection["project_id"] not in project_ids:
|
||||
project_ids.append(reflection["project_id"])
|
||||
record["projects_reflected"] = len(project_ids)
|
||||
|
||||
patterns = record.setdefault("patterns", {})
|
||||
pattern_key = str(reflection.get("pattern_key", ""))
|
||||
pattern = patterns.setdefault(
|
||||
pattern_key,
|
||||
self._base_pattern_record(
|
||||
str(reflection.get("role_id", "")).strip(),
|
||||
list(reflection.get("domains", [])),
|
||||
),
|
||||
)
|
||||
pattern["role_id"] = reflection.get("role_id", "")
|
||||
pattern["domains"] = list(reflection.get("domains", []))
|
||||
pattern["reflection_count"] = int(pattern.get("reflection_count", 0)) + 1
|
||||
pattern["latest_project_summary"] = str(reflection.get("project_summary", ""))
|
||||
pattern["last_reflection_at"] = _utc_now()
|
||||
project_reflection_paths = pattern.setdefault("reflection_paths", [])
|
||||
project_reflection_paths.append(str(reflection_path))
|
||||
|
||||
self._increment_counts(pattern, "working_pattern_counts", reflection.get("what_worked", []))
|
||||
self._increment_counts(pattern, "checklist_counts", reflection.get("reusable_checklist", []))
|
||||
self._increment_counts(pattern, "reviewer_preference_counts", reflection.get("reviewer_preferences", []))
|
||||
self._increment_counts(pattern, "tool_preference_counts", reflection.get("tool_preferences", []))
|
||||
self._increment_counts(pattern, "risk_watchout_counts", reflection.get("mistakes_to_avoid", []))
|
||||
self._increment_counts(pattern, "fit_domain_counts", reflection.get("suitable_for", []))
|
||||
self._increment_counts(pattern, "avoid_domain_counts", reflection.get("avoid_for", []))
|
||||
|
||||
self._increment_counts(record, "working_pattern_counts", reflection.get("what_worked", []))
|
||||
self._increment_counts(record, "checklist_counts", reflection.get("reusable_checklist", []))
|
||||
self._increment_counts(record, "reviewer_preference_counts", reflection.get("reviewer_preferences", []))
|
||||
self._increment_counts(record, "tool_preference_counts", reflection.get("tool_preferences", []))
|
||||
self._increment_counts(record, "risk_watchout_counts", reflection.get("mistakes_to_avoid", []))
|
||||
self._increment_counts(record, "fit_domain_counts", reflection.get("suitable_for", []))
|
||||
self._increment_counts(record, "avoid_domain_counts", reflection.get("avoid_for", []))
|
||||
record["delta_profile"] = self._build_delta_profile(record)
|
||||
record.setdefault("learned_skill_refs", [])
|
||||
|
||||
def _build_project_reflection(
|
||||
self,
|
||||
*,
|
||||
delivery_task: Any,
|
||||
employee_tasks: list[Any],
|
||||
project_id: str,
|
||||
partial: bool = False,
|
||||
feedback: dict[str, Any] | None = None,
|
||||
evaluation: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
first_assignment = dict(employee_tasks[0].metadata.get("employee_assignment", {}) or {})
|
||||
employee_id = str(first_assignment.get("employee_id", "")).strip()
|
||||
role_id = str(first_assignment.get("role_id") or getattr(employee_tasks[0], "assigned_to", "") or "").strip()
|
||||
domains = self._collect_domains(employee_tasks, fallback=list(first_assignment.get("domains", [])))
|
||||
pattern_key = self._pattern_key(role_id, domains)
|
||||
task_summaries = self._collect_task_summaries(employee_tasks)
|
||||
artifacts = self._collect_items(employee_tasks, "artifacts")
|
||||
decisions = self._collect_items(employee_tasks, "decisions")
|
||||
risks = self._collect_items(employee_tasks, "risks")
|
||||
open_questions = self._collect_items(employee_tasks, "open_questions")
|
||||
preferred_agents = self._collect_preferred_agents(employee_tasks, first_assignment)
|
||||
employee_feedback = self._find_employee_feedback(employee_id, evaluation)
|
||||
historical_context = self.build_employee_delta_context(employee_id, project_id=project_id)
|
||||
feedback_summary = str((evaluation or {}).get("summary", "") or (feedback or {}).get("raw_feedback", "")).strip()
|
||||
|
||||
what_worked = [
|
||||
"Break work into explicit deliverables and leave concise handoff summaries.",
|
||||
"Preserve reviewer-friendly artifacts so downstream validation is faster.",
|
||||
]
|
||||
if decisions:
|
||||
what_worked.append("Capture explicit implementation decisions that downstream reviewers can verify.")
|
||||
if artifacts:
|
||||
what_worked.append("Reference exact artifact paths or outputs in every handoff.")
|
||||
|
||||
reusable_checklist = [
|
||||
"State the objective and completion summary explicitly in the handoff.",
|
||||
"Leave reviewer-friendly artifacts for the next work item.",
|
||||
]
|
||||
if decisions:
|
||||
reusable_checklist.append("Record key decisions that affect downstream execution.")
|
||||
if artifacts:
|
||||
reusable_checklist.append("List concrete artifact references for changed outputs.")
|
||||
if any(domain in {"coding", "api", "backend", "frontend", "devops"} for domain in domains):
|
||||
reusable_checklist.append("Include validation or test evidence before requesting review.")
|
||||
|
||||
reviewer_preferences = [
|
||||
"Make decisions, risks, and artifact references explicit for review.",
|
||||
]
|
||||
if risks or open_questions:
|
||||
reviewer_preferences.append("Flag unresolved risks and open questions before requesting approval.")
|
||||
if artifacts:
|
||||
reviewer_preferences.append("Point reviewers to exact changed files or deliverables.")
|
||||
|
||||
tool_preferences = [
|
||||
f"Prefer external agent `{agent}` for similar `{role_id}` work."
|
||||
for agent in preferred_agents
|
||||
]
|
||||
|
||||
mistakes_to_avoid = list(risks[:4])
|
||||
if open_questions:
|
||||
mistakes_to_avoid.extend(item for item in open_questions[:2] if item not in mistakes_to_avoid)
|
||||
if not mistakes_to_avoid:
|
||||
mistakes_to_avoid.append("Avoid handoffs that omit concrete artifacts, risks, or validation notes.")
|
||||
what_worked.extend(str(item).strip() for item in list(employee_feedback.get("strengths", [])) if str(item).strip())
|
||||
mistakes_to_avoid.extend(str(item).strip() for item in list(employee_feedback.get("weaknesses", [])) if str(item).strip())
|
||||
if feedback_summary:
|
||||
reviewer_preferences.append(f"Carry forward user feedback themes: {feedback_summary}")
|
||||
|
||||
suitable_for = list(domains or [role_id])
|
||||
avoid_for = self._infer_avoid_domains(mistakes_to_avoid)
|
||||
confidence = round(min(0.95, 0.55 + (0.08 * len(employee_tasks))), 2)
|
||||
|
||||
failed_tasks = [
|
||||
t for t in employee_tasks
|
||||
if getattr(t, "status", None) != TaskStatus.DONE
|
||||
]
|
||||
failures: list[dict[str, str]] = []
|
||||
if failed_tasks:
|
||||
for t in failed_tasks:
|
||||
reason = str(getattr(t, "metadata", {}).get("failure_reason", ""))
|
||||
failures.append({
|
||||
"task": getattr(t, "title", ""),
|
||||
"status": str(getattr(t, "status", "")),
|
||||
"reason": reason,
|
||||
})
|
||||
if reason:
|
||||
mistakes_to_avoid.append(reason)
|
||||
|
||||
if partial:
|
||||
confidence = round(min(confidence, 0.4), 2)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"employee_id": employee_id,
|
||||
"employee_name": first_assignment.get("name", ""),
|
||||
"template_id": first_assignment.get("template_id", ""),
|
||||
"category": first_assignment.get("category", ""),
|
||||
"project_id": project_id,
|
||||
"delivery_task_id": str(getattr(delivery_task, "id", "") or ""),
|
||||
"delivery_projection_id": projection_id_for_task(delivery_task),
|
||||
"role_id": role_id,
|
||||
"domains": domains,
|
||||
"pattern_key": pattern_key,
|
||||
"project_summary": " ".join(task_summaries[:3]),
|
||||
"what_worked": self._dedupe_preserve_order(what_worked),
|
||||
"mistakes_to_avoid": self._dedupe_preserve_order(mistakes_to_avoid),
|
||||
"reusable_checklist": self._dedupe_preserve_order(reusable_checklist),
|
||||
"reviewer_preferences": self._dedupe_preserve_order(reviewer_preferences),
|
||||
"tool_preferences": self._dedupe_preserve_order(tool_preferences),
|
||||
"suitable_for": self._dedupe_preserve_order(suitable_for),
|
||||
"avoid_for": self._dedupe_preserve_order(avoid_for),
|
||||
"confidence": confidence,
|
||||
"source_task_ids": [str(getattr(task, "id", "")) for task in employee_tasks],
|
||||
"created_at": _utc_now(),
|
||||
"employee_outcome": self._normalize_outcome(str(employee_feedback.get("outcome", "partial_success") or "partial_success")),
|
||||
"feedback_summary": feedback_summary,
|
||||
"feedback_rationale": str(employee_feedback.get("reason", "")).strip(),
|
||||
}
|
||||
if historical_context:
|
||||
result["historical_context"] = historical_context
|
||||
if feedback:
|
||||
result["user_feedback"] = {
|
||||
"label": str(feedback.get("label", "")).strip(),
|
||||
"raw_feedback": str(feedback.get("raw_feedback", "")).strip(),
|
||||
"scope": str(feedback.get("scope", "")).strip(),
|
||||
}
|
||||
if evaluation:
|
||||
result["runtime_feedback_evaluation"] = {
|
||||
"overall_outcome": str(evaluation.get("overall_outcome", "")).strip(),
|
||||
"summary": str(evaluation.get("summary", "")).strip(),
|
||||
"strengths": [str(item).strip() for item in list(evaluation.get("strengths", [])) if str(item).strip()][:6],
|
||||
"weaknesses": [str(item).strip() for item in list(evaluation.get("weaknesses", [])) if str(item).strip()][:6],
|
||||
}
|
||||
if failures:
|
||||
result["failures"] = failures
|
||||
result["partial"] = True
|
||||
return result
|
||||
|
||||
def _maybe_promote_reflection_skill(self, profile: dict[str, Any], reflection: dict[str, Any], project_id: str | None = None) -> str:
|
||||
employees = profile.setdefault("employees", {})
|
||||
employee_id = str(reflection.get("employee_id", "")).strip()
|
||||
record = employees.get(employee_id)
|
||||
if not record:
|
||||
return ""
|
||||
pattern_key = str(reflection.get("pattern_key", "")).strip()
|
||||
pattern = dict(record.get("patterns", {}).get(pattern_key, {}))
|
||||
if not pattern or pattern.get("learned_skill_ref"):
|
||||
return str(pattern.get("learned_skill_ref", ""))
|
||||
if int(pattern.get("reflection_count", 0)) < self.LEARNED_SKILL_THRESHOLD:
|
||||
return ""
|
||||
|
||||
repeated_working = self._repeated_items(pattern.get("working_pattern_counts", {}))
|
||||
repeated_checklists = self._repeated_items(pattern.get("checklist_counts", {}))
|
||||
repeated_reviewer = self._repeated_items(pattern.get("reviewer_preference_counts", {}))
|
||||
repeated_tools = self._repeated_items(pattern.get("tool_preference_counts", {}))
|
||||
repeated_risks = self._repeated_items(pattern.get("risk_watchout_counts", {}))
|
||||
if not any((repeated_working, repeated_checklists, repeated_reviewer, repeated_tools, repeated_risks)):
|
||||
return ""
|
||||
|
||||
employee_name = str(reflection.get("employee_name", "Employee")).strip() or "Employee"
|
||||
role_id = str(reflection.get("role_id", "")).strip() or record.get("role_id", "general")
|
||||
domains = list(reflection.get("domains", [])) or list(pattern.get("domains", [])) or [role_id]
|
||||
primary_domain = domains[0] if domains else role_id
|
||||
display_name = f"{employee_name} {role_id} {primary_domain} playbook"
|
||||
skill_name = self._normalize_skill_name(display_name)
|
||||
reflection_count = int(pattern.get("reflection_count", 0))
|
||||
sections = [
|
||||
f"# {display_name}",
|
||||
"",
|
||||
f"Learned playbook for **{employee_name}** in role `{role_id}` across {', '.join(domains)}.",
|
||||
f"Distilled from {reflection_count} project reflections.",
|
||||
]
|
||||
if repeated_working:
|
||||
sections.extend(["", "## Successful Behaviors", *[f"- {item}" for item in repeated_working]])
|
||||
if repeated_checklists:
|
||||
sections.extend(["", "## Default Checklist", *[f"- {item}" for item in repeated_checklists]])
|
||||
if repeated_reviewer:
|
||||
sections.extend(["", "## Reviewer Preferences", *[f"- {item}" for item in repeated_reviewer]])
|
||||
if repeated_tools:
|
||||
sections.extend(["", "## Tool Preferences", *[f"- {item}" for item in repeated_tools]])
|
||||
if repeated_risks:
|
||||
sections.extend(["", "## Risk Watchouts", *[f"- {item}" for item in repeated_risks]])
|
||||
avoid_for = self._rank_items(record.get("avoid_domain_counts", {}), limit=4)
|
||||
if avoid_for:
|
||||
sections.extend(["", "## Avoid For", *[f"- {item}" for item in avoid_for]])
|
||||
|
||||
skill = Skill(
|
||||
name=skill_name,
|
||||
description=(
|
||||
f"Learned playbook for `{role_id}` work ({', '.join(domains)}) by {employee_name}. "
|
||||
f"Use when assigning similar {role_id} tasks in these domains."
|
||||
),
|
||||
metadata={
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name,
|
||||
"role_id": role_id,
|
||||
"template_id": reflection.get("template_id", ""),
|
||||
"pattern_key": pattern_key,
|
||||
"built_from": "project_reflections",
|
||||
},
|
||||
content="\n".join(sections).strip() + "\n",
|
||||
)
|
||||
self.skills.save_skill(skill, project_id=project_id)
|
||||
self._attach_learned_skill(profile, employee_id, pattern_key, skill.name)
|
||||
return skill.name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_skill_name(raw: str) -> str:
|
||||
"""Normalize to lowercase hyphen-case, matching skill-creator conventions."""
|
||||
normalized = raw.strip().lower()
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
||||
normalized = normalized.strip("-")
|
||||
normalized = re.sub(r"-{2,}", "-", normalized)
|
||||
return normalized[:64]
|
||||
|
||||
def _attach_learned_skill(
|
||||
self,
|
||||
profile: dict[str, Any],
|
||||
employee_id: str,
|
||||
pattern_key: str,
|
||||
skill_name: str,
|
||||
) -> None:
|
||||
employees = profile.setdefault("employees", {})
|
||||
record = employees.setdefault(employee_id, {})
|
||||
refs = record.setdefault("learned_skill_refs", [])
|
||||
if skill_name not in refs:
|
||||
refs.append(skill_name)
|
||||
patterns = record.setdefault("patterns", {})
|
||||
pattern = patterns.setdefault(pattern_key, {})
|
||||
pattern["learned_skill_ref"] = skill_name
|
||||
record["updated_at"] = _utc_now()
|
||||
record.setdefault("delta_profile", {})
|
||||
|
||||
def _reflection_path(self, project_id: str, employee_id: str, delivery_task_id: str) -> Path:
|
||||
return self.opc_home / "projects" / project_id / "employees" / employee_id / "reflections" / f"{delivery_task_id}.yaml"
|
||||
|
||||
def _base_pattern_record(self, role_id: str, domains: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"role_id": role_id,
|
||||
"domains": list(domains),
|
||||
"successes": 0,
|
||||
"partial_successes": 0,
|
||||
"failures": 0,
|
||||
"learned_skill_ref": "",
|
||||
"reflection_count": 0,
|
||||
}
|
||||
|
||||
def _normalize_work_item_tasks(self, work_item_tasks: list[Any], delivery_task: Any) -> list[Any]:
|
||||
tasks_by_id: dict[str, Any] = {}
|
||||
for task in work_item_tasks:
|
||||
task_id = str(getattr(task, "id", "") or "").strip()
|
||||
if task_id:
|
||||
tasks_by_id[task_id] = task
|
||||
delivery_task_id = str(getattr(delivery_task, "id", "") or "").strip()
|
||||
if delivery_task_id:
|
||||
tasks_by_id[delivery_task_id] = delivery_task
|
||||
return list(tasks_by_id.values())
|
||||
|
||||
def _collect_domains(self, tasks: list[Any], fallback: list[str]) -> list[str]:
|
||||
collected: list[str] = []
|
||||
for task in tasks:
|
||||
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
|
||||
for value in list(assignment.get("domains", [])) + list(getattr(task, "tags", []) or []):
|
||||
item = str(value).strip()
|
||||
if item and item not in collected:
|
||||
collected.append(item)
|
||||
for value in fallback:
|
||||
item = str(value).strip()
|
||||
if item and item not in collected:
|
||||
collected.append(item)
|
||||
return collected
|
||||
|
||||
def _collect_task_summaries(self, tasks: list[Any]) -> list[str]:
|
||||
summaries: list[str] = []
|
||||
for task in tasks:
|
||||
for candidate in (
|
||||
str(getattr(task, "metadata", {}).get("work_item_summary_for_downstream", "") or "").strip(),
|
||||
str(getattr(task, "result", {}).get("content", "") or "").strip(),
|
||||
str(getattr(task, "title", "") or "").strip(),
|
||||
):
|
||||
if candidate and candidate not in summaries:
|
||||
summaries.append(candidate)
|
||||
break
|
||||
return summaries
|
||||
|
||||
def _collect_items(self, tasks: list[Any], key: str) -> list[str]:
|
||||
items: list[str] = []
|
||||
for task in tasks:
|
||||
for value in list(getattr(task, "metadata", {}).get(key, []) or []):
|
||||
text = str(value).strip()
|
||||
if text and text not in items:
|
||||
items.append(text)
|
||||
return items
|
||||
|
||||
def _collect_preferred_agents(self, tasks: list[Any], assignment: dict[str, Any]) -> list[str]:
|
||||
agents: list[str] = []
|
||||
assignment_agent = str(assignment.get("preferred_external_agent", "") or "").strip()
|
||||
if assignment_agent:
|
||||
agents.append(assignment_agent)
|
||||
for task in tasks:
|
||||
value = str(getattr(task, "assigned_external_agent", "") or "").strip()
|
||||
if value and value not in agents:
|
||||
agents.append(value)
|
||||
return agents
|
||||
|
||||
def _infer_avoid_domains(self, risks: list[str]) -> list[str]:
|
||||
avoid: list[str] = []
|
||||
joined = " ".join(risks).lower()
|
||||
for token in ("security", "compliance", "billing", "finance", "production", "deployment"):
|
||||
if token in joined and token not in avoid:
|
||||
avoid.append(token)
|
||||
return avoid
|
||||
|
||||
def _find_employee_feedback(self, employee_id: str, evaluation: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not evaluation:
|
||||
return {}
|
||||
for item in list(evaluation.get("employees", [])):
|
||||
if str(item.get("employee_id", "")).strip() == employee_id:
|
||||
return dict(item)
|
||||
return {}
|
||||
|
||||
def _increment_outcome_counts(self, container: dict[str, Any], outcome: str) -> None:
|
||||
normalized = self._normalize_outcome(outcome)
|
||||
if normalized == "success":
|
||||
container["successes"] = int(container.get("successes", 0)) + 1
|
||||
elif normalized == "failure":
|
||||
container["failures"] = int(container.get("failures", 0)) + 1
|
||||
else:
|
||||
container["partial_successes"] = int(container.get("partial_successes", 0)) + 1
|
||||
|
||||
def _normalize_outcome(self, outcome: str) -> str:
|
||||
normalized = str(outcome or "").strip().lower()
|
||||
if normalized in {"success", "approved", "fully_approved", "complete_success"}:
|
||||
return "success"
|
||||
if normalized in {"failure", "failed", "rejected", "fully_rejected"}:
|
||||
return "failure"
|
||||
return "partial_success"
|
||||
|
||||
def _increment_counts(self, container: dict[str, Any], key: str, items: list[str]) -> None:
|
||||
counts = container.setdefault(key, {})
|
||||
for item in self._dedupe_preserve_order(items):
|
||||
counts[item] = int(counts.get(item, 0)) + 1
|
||||
|
||||
def _build_delta_profile(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"working_patterns": self._rank_items(record.get("working_pattern_counts", {}), limit=5),
|
||||
"default_checklists": self._rank_items(record.get("checklist_counts", {}), limit=6),
|
||||
"reviewer_preferences": self._rank_items(record.get("reviewer_preference_counts", {}), limit=5),
|
||||
"risk_watchouts": self._rank_items(record.get("risk_watchout_counts", {}), limit=5),
|
||||
"tool_preferences": self._rank_items(record.get("tool_preference_counts", {}), limit=4),
|
||||
"fit_domains": self._rank_items(record.get("fit_domain_counts", {}), limit=4),
|
||||
"avoid_domains": self._rank_items(record.get("avoid_domain_counts", {}), limit=4),
|
||||
}
|
||||
|
||||
def _normalize_self_evolution_event(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
source: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
employee_id = str(event.get("employee_id", "") or "").strip()
|
||||
if not employee_id:
|
||||
return {}
|
||||
event_id = str(event.get("event_id", "") or "").strip()
|
||||
if not event_id:
|
||||
checkpoint_id = str(source.get("checkpoint_id", "") or "").strip()
|
||||
role_id = str(event.get("role_id", "") or "").strip()
|
||||
nonce = json.dumps(event, ensure_ascii=False, sort_keys=True)
|
||||
digest = hashlib.sha256(nonce.encode("utf-8")).hexdigest()[:12]
|
||||
event_id = self._safe_employee_filename(f"{checkpoint_id or _utc_now()}-{employee_id}-{role_id}-{digest}")
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"employee_id": employee_id,
|
||||
"role_id": str(event.get("role_id", "") or "").strip(),
|
||||
"summary": str(event.get("summary", "") or "").strip(),
|
||||
"strengths": self._string_list(event.get("strengths", []), limit=8),
|
||||
"adjustments": self._string_list(event.get("adjustments", event.get("adjust", [])), limit=8),
|
||||
"avoid_next_time": self._string_list(event.get("avoid_next_time", event.get("avoid", [])), limit=8),
|
||||
"routing_notes": self._string_list(event.get("routing_notes", []), limit=8),
|
||||
"evidence_task_ids": self._string_list(event.get("evidence_task_ids", []), limit=12),
|
||||
"confidence": self._bounded_float(event.get("confidence", 0.7), default=0.7),
|
||||
"source": dict(source or {}),
|
||||
"created_at": _utc_now(),
|
||||
}
|
||||
|
||||
def _build_self_evolution_delta(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
counts: dict[str, dict[str, int]] = {
|
||||
"strengths": {},
|
||||
"adjustments": {},
|
||||
"avoid_next_time": {},
|
||||
"routing_notes": {},
|
||||
}
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
for key in counts:
|
||||
for item in self._string_list(event.get(key, []), limit=20):
|
||||
counts[key][item] = int(counts[key].get(item, 0)) + 1
|
||||
return {
|
||||
"strengths": self._rank_items(counts["strengths"], limit=8),
|
||||
"adjustments": self._rank_items(counts["adjustments"], limit=8),
|
||||
"avoid_next_time": self._rank_items(counts["avoid_next_time"], limit=8),
|
||||
"routing_notes": self._rank_items(counts["routing_notes"], limit=8),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_employee_filename(value: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value or "").strip()).strip("-")
|
||||
return safe or "employee"
|
||||
|
||||
@staticmethod
|
||||
def _bounded_float(value: Any, *, default: float = 0.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
number = default
|
||||
return max(0.0, min(1.0, number))
|
||||
|
||||
@staticmethod
|
||||
def _string_list(value: Any, *, limit: int = 8) -> list[str]:
|
||||
items = value if isinstance(value, list) else [value]
|
||||
result: list[str] = []
|
||||
for item in items:
|
||||
text = str(item or "").strip()
|
||||
if text and text not in result:
|
||||
result.append(text)
|
||||
if len(result) >= limit:
|
||||
break
|
||||
return result
|
||||
|
||||
def _rank_items(self, counts: dict[str, Any], *, limit: int = 6) -> list[str]:
|
||||
ranked = sorted(
|
||||
((str(item).strip(), int(count)) for item, count in dict(counts).items() if str(item).strip()),
|
||||
key=lambda pair: (-pair[1], pair[0].lower()),
|
||||
)
|
||||
return [item for item, _count in ranked[:limit]]
|
||||
|
||||
def _repeated_items(self, counts: dict[str, Any], *, minimum: int = 2) -> list[str]:
|
||||
ranked = sorted(
|
||||
((str(item).strip(), int(count)) for item, count in dict(counts).items() if int(count) >= minimum and str(item).strip()),
|
||||
key=lambda pair: (-pair[1], pair[0].lower()),
|
||||
)
|
||||
return [item for item, _count in ranked[:6]]
|
||||
|
||||
def _pattern_key(self, role_id: str, domains: list[str]) -> str:
|
||||
domain_key = ",".join(sorted({domain.strip().lower() for domain in domains if domain.strip()}))
|
||||
return f"{role_id}|{domain_key or 'general'}"
|
||||
|
||||
def _dedupe_preserve_order(self, items: list[str]) -> list[str]:
|
||||
unique: list[str] = []
|
||||
for item in items:
|
||||
text = str(item).strip()
|
||||
if text and text not in unique:
|
||||
unique.append(text)
|
||||
return unique
|
||||
|
||||
def _deep_merge(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = dict(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = self._deep_merge(merged[key], value)
|
||||
elif isinstance(value, list) and isinstance(merged.get(key), list):
|
||||
existing = list(merged[key])
|
||||
for item in value:
|
||||
if item not in existing:
|
||||
existing.append(item)
|
||||
merged[key] = existing
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
Reference in New Issue
Block a user