999 lines
46 KiB
Python
999 lines
46 KiB
Python
"""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
|