From 76ff7462b7f9ed112eb508c55234054742e53f71 Mon Sep 17 00:00:00 2001 From: Jeff F Date: Wed, 25 Mar 2026 14:13:08 -0500 Subject: [PATCH 1/2] Release v1.2.5 --- CHANGELOG.md | 20 ++ requirements.txt | 2 +- src/causal_chain_handler.py | 71 +--- src/config_loader.py | 9 +- src/confluence_writer.py | 23 +- src/day_planner.py | 78 +---- src/embed_worker.py | 14 +- src/external_email_ingest.py | 71 ++-- src/flow.py | 127 ++----- src/graph_dynamics.py | 33 +- src/insider_threat.py | 38 +-- src/memory.py | 23 +- src/normal_day.py | 635 ++++++++++++----------------------- src/org_lifecycle.py | 60 +--- src/plan_validator.py | 50 +-- src/planner_models.py | 98 ++---- src/sim_clock.py | 35 +- src/ticket_assigner.py | 42 +-- src/token_tracker.py | 72 ---- src/utils/helpers.py | 10 + src/utils/persona_utils.py | 168 +++++++++ tests/test_normal_day.py | 110 ++---- 22 files changed, 635 insertions(+), 1154 deletions(-) delete mode 100644 src/token_tracker.py create mode 100644 src/utils/helpers.py create mode 100644 src/utils/persona_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4289557..16aee4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [v1.2.5] — 2026-03-25 + +### Added + +- **Global Voice Card System (`utils/persona_utils.py`, `src/normal_day.py`)**: Migrated individual persona logic to a centralized `get_voice_card` utility. This provides context-aware character sheets (e.g., `async`, `design`, `collision`) that inject tenure, expertise, mood, and "anti-patterns" into LLM backstories to prevent generic corporate drift. +- **Robust JSON Recovery (`requirements.txt`, `src/flow.py`, `src/normal_day.py`)**: Integrated `json-repair` across the simulation pipeline. This allows the engine to "salvage" malformed LLM responses in ticket generation and Slack conversations, significantly reducing "failed to parse" fallbacks. + +### Changed + +- **Persona History Filtering (`src/memory.py`)**: Enhanced `persona_history` to filter out "noisy" macro-events (like sprint planning summaries or standups). This ensures agents focus on personal agency and direct interactions when building their local context. +- **Incident Recurrence Logic (`src/causal_chain_handler.py`)**: Refined the `RecurrenceDetector` to prioritize the earliest incident in a chain (anti-daisy-chaining). This ensures new incidents link back to the original root cause rather than just the most recent duplicate. +- **Streamlined codebase (`Across all files`)**: Conducted a major cleanup of legacy comments, "ASCII art" section dividers, and redundant docstrings to improve readability and reduce token overhead during development. + +### Fixed + +- **PR Causal Linking (`src/flow.py`)**: Fixed a bug where PR IDs were missing from the persistent ticket record. PRs are now immediately appended to the `CausalChainHandler` and saved to MongoDB upon creation. +- **Department Signal Noise (`src/day_planner.py`)**: Non-engineering departments (Sales, HR) now only receive "direct" relevance signals, preventing them from being overwhelmed by technical incident data that doesn't impact their planning. + +--- + ## [v1.2.4] — 2026-03-25 ### Added diff --git a/requirements.txt b/requirements.txt index bc05356..7b898a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ rich>=13.0.0 ollama>=0.1.7 boto3==1.42.63 scipy>=1.17.1 -json-repair>=0.25.3 \ No newline at end of file +json-repair>=0.58.6 \ No newline at end of file diff --git a/src/causal_chain_handler.py b/src/causal_chain_handler.py index cfa74f6..2667156 100644 --- a/src/causal_chain_handler.py +++ b/src/causal_chain_handler.py @@ -51,25 +51,16 @@ logger = logging.getLogger("orgforge.causal_chain") -# ───────────────────────────────────────────────────────────────────────────── -# CONSTANTS -# ───────────────────────────────────────────────────────────────────────────── - -# Reciprocal Rank Fusion damping constant — 60 is the standard value from -# the original RRF paper. Higher = less aggressive rank boosting. _RRF_K = 60 -# Default fusion weights — vector wins for semantic similarity since our -# Ollama embedding model is local and essentially free to call _TEXT_WEIGHT = 0.35 _VECTOR_WEIGHT = 0.65 -# Minimum scores to accept a match — tune these after reviewing -# the recurrence_matches collection after a sim run + _MIN_VECTOR_SCORE = 0.72 _MIN_TEXT_SCORE = 0.40 -# How many candidates to retrieve from each source before fusion + _RETRIEVAL_LIMIT = 10 ARTIFACT_KEY_JIRA = "jira" @@ -77,12 +68,6 @@ ARTIFACT_KEY_SLACK = "slack" ARTIFACT_KEY_SLACK_THREAD = "slack_thread" -# ───────────────────────────────────────────────────────────────────────────── -# CAUSAL CHAIN HANDLER -# Tracks the growing set of artifact IDs that causally produced an incident. -# Lives on an ActiveIncident — one instance per open incident. -# ───────────────────────────────────────────────────────────────────────────── - class CausalChainHandler: """ @@ -129,14 +114,6 @@ def __repr__(self) -> str: return f"CausalChainHandler(root={self.root}, length={len(self)})" -# ───────────────────────────────────────────────────────────────────────────── -# RECURRENCE MATCH STORE -# Persists every detection decision to MongoDB regardless of outcome. -# Negative examples (rejected matches) are as valuable as positives for -# threshold calibration. -# ───────────────────────────────────────────────────────────────────────────── - - class RecurrenceMatchStore: """ Writes one document per recurrence detection attempt to the @@ -194,11 +171,9 @@ def log( threshold_gate: Dict[str, float], ) -> None: doc: Dict[str, Any] = { - # Query "query_root_cause": query_root_cause, "current_ticket_id": current_ticket_id, "current_day": current_day, - # Match result "matched": matched_event is not None, "matched_ticket_id": matched_event.artifact_ids.get("jira") if matched_event @@ -210,7 +185,6 @@ def log( "recurrence_gap_days": ( current_day - matched_event.day if matched_event else None ), - # Scores "text_score": round(text_score, 4), "vector_score": round(vector_score, 4), "fused_score": round(fused_score, 4), @@ -219,7 +193,6 @@ def log( "confidence": confidence, "candidates_evaluated": candidates_evaluated, "threshold_gate": threshold_gate, - # Metadata "timestamp": datetime.now(timezone.utc).isoformat(), "sim_day": current_day, } @@ -229,13 +202,6 @@ def log( logger.warning(f"[causal_chain] recurrence_match insert failed: {e}") -# ───────────────────────────────────────────────────────────────────────────── -# RECURRENCE DETECTOR -# Hybrid retrieval: fuses MongoDB text search + vector search to identify -# whether a new incident root cause has occurred before. -# ───────────────────────────────────────────────────────────────────────────── - - class RecurrenceDetector: """ Finds the most relevant prior incident for a given root cause string @@ -273,11 +239,8 @@ def __init__( self._min_text = min_text self._store = RecurrenceMatchStore(mem) - # Ensure text index exists on the events collection self._ensure_text_index() - # ── Public ──────────────────────────────────────────────────────────────── - def find_prior_incident( self, root_cause: str, @@ -296,11 +259,8 @@ def find_prior_incident( candidates: Dict[str, Dict[str, Any]] = {} - # ── Stage 1: MongoDB text search ────────────────────────────────────── text_results = self._text_search(root_cause, current_day) - # Fixed normalisation: use a corpus-calibrated ceiling rather than - # result-set max, so rank-1 doesn't always get 1.0 _TEXT_CEILING = 8.0 for rank, result in enumerate(text_results): event = SimEvent.from_dict(result) @@ -311,7 +271,6 @@ def find_prior_incident( candidates[key]["text_score"] = normalised candidates[key]["text_rrf"] = 1 / (rank + 1 + _RRF_K) - # ── Stage 2: Vector search ───────────────────────────────────────────── vector_results = self._vector_search(root_cause, current_day) for rank, (event, vscore) in enumerate(vector_results): @@ -337,7 +296,6 @@ def find_prior_incident( ) return None - # ── Fusion ──────────────────────────────────────────────────────────── both_returned = bool(text_results) and bool(vector_results) fusion_strategy = ( "rrf" if both_returned else ("text_only" if text_results else "vector_only") @@ -352,7 +310,6 @@ def find_prior_incident( sort_key = "rrf_score" if both_returned else "fused_score" ranked = sorted(candidates.values(), key=lambda c: c[sort_key], reverse=True) - # ── Threshold gate — collect ALL passing candidates ─────────────────── accepted = [ c for c in ranked @@ -383,8 +340,6 @@ def find_prior_incident( ) return None - # Anti-daisy-chain: among all passing candidates, pick the EARLIEST - # so ORG-164 links to ORG-140, not ORG-161 best = min(accepted, key=lambda c: c["event"].day) confidence = ( @@ -464,16 +419,14 @@ def get_causal_chain(self, artifact_id: str) -> List[SimEvent]: chain.extend(events) for event in events: - # Queue causal parents for parent in event.facts.get("causal_chain", []): if parent not in visited: queue.append(parent) - # Queue prior incident in recurrence chain + prior = event.facts.get("recurrence_of") if prior and prior not in visited: queue.append(prior) - # Deduplicate (same event may appear via multiple paths) seen_ids: set = set() unique: List[SimEvent] = [] for e in chain: @@ -496,8 +449,6 @@ def get_recurrence_history(self, ticket_id: str) -> List[SimEvent]: or ticket_id in e.facts.get("causal_chain", []) ] - # ── Private ─────────────────────────────────────────────────────────────── - def _text_search(self, root_cause: str, current_day: int) -> List[Dict[str, Any]]: """MongoDB $text search — returns results with normalised scores.""" try: @@ -520,21 +471,13 @@ def _text_search(self, root_cause: str, current_day: int) -> List[Dict[str, Any] if not results: return [] - # Normalise against the MAX score in this result set, not rank-1. - # This means rank-1 still gets 1.0, but rank-2 gets a real relative score - # rather than everyone below rank-1 getting arbitrary lower values. - # More importantly, when there's only ONE candidate it still gets 1.0 — - # the threshold check (min_text=0.4) then does actual filtering work - # because a weak single match will have a low raw score. raw_scores = [r.get("score", 0.0) for r in results] max_score = max(raw_scores) if raw_scores else 1.0 - # Use log-normalisation so weak matches don't cluster near 1.0 import math normalised = [] for r, raw in zip(results, raw_scores): - # log1p normalisation preserves ordering, spreads out the range norm_score = ( math.log1p(raw) / math.log1p(max_score) if max_score > 0 else 0.0 ) @@ -593,14 +536,6 @@ def _threshold_gate(self) -> Dict[str, float]: } -# ───────────────────────────────────────────────────────────────────────────── -# MEMORY EXTENSION — search_events() -# Add this method to memory.py Memory class. -# Reproduced here as a standalone function so it can be monkey-patched in -# or copy-pasted into Memory directly. -# ───────────────────────────────────────────────────────────────────────────── - - def search_events( mem: Memory, query: str, diff --git a/src/config_loader.py b/src/config_loader.py index a2f100a..bacbb46 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -12,7 +12,7 @@ import yaml -# ── Paths ───────────────────────────────────────────────────────────────────── + SRC_DIR = Path(__file__).resolve().parent PROJECT_ROOT = SRC_DIR.parent @@ -21,7 +21,7 @@ EXPORT_DIR.mkdir(parents=True, exist_ok=True) -# ── Load YAML ───────────────────────────────────────────────────────────────── + with open(CONFIG_PATH, "r") as _f: _data = yaml.safe_load(_f) @@ -30,7 +30,7 @@ CONFIG: Dict[str, Any] = _data -# ── Top-level constants ─────────────────────────────────────────────────────── + COMPANY_NAME = CONFIG["simulation"]["company_name"] COMPANY_DOMAIN = CONFIG["simulation"]["domain"] COMPANY_DESCRIPTION = CONFIG["simulation"]["company_description"] @@ -57,7 +57,6 @@ LIVE_ORG_CHART = {dept: list(members) for dept, members in ORG_CHART.items()} LIVE_PERSONAS = {k: dict(v) for k, v in PERSONAS.items()} -# ── Model preset ────────────────────────────────────────────────────────────── _PRESET_NAME = CONFIG.get("quality_preset", "local_gpu") _PRESET = CONFIG["model_presets"][_PRESET_NAME] _PROVIDER = _PRESET.get("provider", "ollama") @@ -67,7 +66,7 @@ def resolve_role(role_key: str) -> str: dept = CONFIG.get("roles", {}).get(role_key) if dept and dept in LEADS: return LEADS[dept] - # Check org_chart directly for depts not in LEADS (e.g. CEO) + if dept and dept in CONFIG.get("org_chart", {}): members = CONFIG["org_chart"][dept] if members: diff --git a/src/confluence_writer.py b/src/confluence_writer.py index bb7d804..c12bcef 100644 --- a/src/confluence_writer.py +++ b/src/confluence_writer.py @@ -75,7 +75,7 @@ def __init__( self, mem: Memory, registry: ArtifactRegistry, - state, # flow.State — avoid circular import + state, config: Dict, worker_llm, planner_llm, @@ -103,10 +103,6 @@ def __init__( self._all_names = [n for dept in config["org_chart"].values() for n in dept] self._org_chart = config["org_chart"] - # ───────────────────────────────────────────────────────────────────────── - # PUBLIC API - # ───────────────────────────────────────────────────────────────────────── - def write_genesis_batch( self, prefix: str, @@ -139,7 +135,7 @@ def write_genesis_batch( Returns: List of registered conf_ids in generation order. """ - # 1. Allocate all IDs before any LLM call + genesis_time = self._clock.now("system").isoformat() queue = [self._registry.next_id(prefix) for _ in range(count)] registered_ids: List[str] = [] @@ -863,10 +859,6 @@ def _finalize_page( return created_ids - # ───────────────────────────────────────────────────────────────────────── - # PRIVATE — JIRA TICKET SPAWNING - # ───────────────────────────────────────────────────────────────────────── - def _spawn_tickets( self, new_tickets: List[Dict], @@ -952,7 +944,7 @@ def generate_tech_stack(self) -> dict: Persists to MongoDB immediately so every subsequent LLM call can reference it. Returns the stack as a dict. """ - # Check if already generated (warm restart guard) + existing = self._mem.get_tech_stack() if existing: logger.info("[confluence] Tech stack already exists — skipping generation.") @@ -1003,10 +995,6 @@ def generate_tech_stack(self) -> dict: logger.info(f"[confluence] ✓ Tech stack established: {list(stack.keys())}") return stack - # ───────────────────────────────────────────────────────────────────────── - # PRIVATE — UTILITIES - # ───────────────────────────────────────────────────────────────────────── - @staticmethod def _render(template: str, vars_: Dict[str, str]) -> str: for key, value in vars_.items(): @@ -1024,7 +1012,7 @@ def _render_template(self, template: str) -> str: @staticmethod def _extract_title(content: str, fallback: str) -> str: - # Strip code blocks before searching for headings + clean = re.sub(r"```.*?```", "", content, flags=re.DOTALL) m = re.search(r"^#\s+(.+)", clean, re.MULTILINE) @@ -1085,12 +1073,11 @@ def _tenure_at_date( m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(yr|mo)", tenure_str.strip()) if not m: - return tenure_str # "new" or anything non-standard — leave as-is + return tenure_str value, unit = float(m.group(1)), m.group(2) months_at_sim_start = int(value * 12) if unit == "yr" else int(value) - # How many months before sim_start is this page? delta = relativedelta(sim_start, page_date) months_offset = delta.years * 12 + delta.months diff --git a/src/day_planner.py b/src/day_planner.py index 0a64d50..bb5c660 100644 --- a/src/day_planner.py +++ b/src/day_planner.py @@ -52,6 +52,7 @@ COMPANY_DESCRIPTION, resolve_role, ) +from utils.persona_utils import get_voice_card logger = logging.getLogger("orgforge.planner") @@ -63,11 +64,6 @@ def _coerce_collaborators(raw) -> List[str]: return raw if isinstance(raw, list) else [raw] -# ───────────────────────────────────────────────────────────────────────────── -# DEPARTMENT PLANNER -# ───────────────────────────────────────────────────────────────────────────── - - class DepartmentPlanner: """ Produces a DepartmentDayPlan for a single department. @@ -239,7 +235,7 @@ def plan( graph_dynamics: GraphDynamics, cross_signals: List[CrossDeptSignal], sprint_context: Optional[SprintContext] = None, - eng_plan: Optional[DepartmentDayPlan] = None, # None for Engineering itself + eng_plan: Optional[DepartmentDayPlan] = None, lifecycle_context: str = "", email_signals: Optional[List["ExternalEmailSignal"]] = None, ) -> DepartmentDayPlan: @@ -269,7 +265,6 @@ def plan( else "" ) - # ── Render SprintContext sections ────────────────────────────────────── if sprint_context: owned_lines = [ f" - [{tid}] → {owner}" @@ -293,7 +288,6 @@ def plan( "\n".join(in_review_lines) if in_review_lines else " (none)" ) else: - # Non-engineering depts or fallback — use the old open_tickets path owned_section = self._open_tickets(state, mem) avail_section = " (see owned tickets above)" capacity_section = " (standard 6h per engineer)" @@ -308,27 +302,11 @@ def plan( ) lead_name = self.config.get("leads", {}).get(self.dept, "") - lead_persona = PERSONAS.get(lead_name, DEFAULT_PERSONA) - lead_stress = state.persona_stress.get(lead_name, 30) - lead_style = lead_persona.get("style", "pragmatic and direct") - lead_expertise = ", ".join(lead_persona.get("expertise", [])) - on_call_name = self.config.get("on_call_engineer", "") - - lead_context = ( - f"You are {lead_name}, lead of {self.dept} at " - f"{self.config['simulation']['company_name']} which {COMPANY_DESCRIPTION}. " - f"Your current stress is {lead_stress}/100" - + (" — you are on-call today." if lead_name == on_call_name else ".") - + f" Your style: {lead_style}. " - f"Your expertise: {lead_expertise}. " - f"You plan your team's day from lived experience, not abstraction. " - f"You know who is struggling, what is mid-flight, and what can wait." - ) agent = make_agent( role=f"{lead_name}, {self.dept} Lead", goal="Plan your team's day honestly, given your stress, their capacity, and what is actually on fire.", - backstory=lead_context, + backstory=get_voice_card(lead_name, "design", graph_dynamics, mem), llm=self._llm, ) @@ -557,19 +535,15 @@ def _parse_plan( sprint_context=sprint_context, ), data - # ─── Context builders ───────────────────────────────────────────────────── - def _build_roster(self, graph_dynamics: GraphDynamics) -> str: lines = [] for name in self.members: stress = graph_dynamics._stress.get(name, 30) tone = graph_dynamics.stress_tone_hint(name) - # Fetch expertise from the persona config persona = self.config.get("personas", {}).get(name, {}) expertise = ", ".join(persona.get("expertise", ["general operations"])) - # Inject expertise into the LLM's view of the roster lines.append( f" - {name} (Expertise: [{expertise}]): stress={stress}/100. {tone}" ) @@ -630,15 +604,9 @@ def _format_cross_signals( ) -> str: lines = [] - # Non-engineering departments only receive [direct] signals — indirect - # signals (e.g. older incidents from other teams) are noise for Sales, - # Design, QA etc. and waste tokens without changing planning decisions. - # Engineering (is_primary) still sees all signals ranked by priority. if not self.is_primary: signals = [s for s in signals if s.relevance == "direct"] - # Prioritize high-signal events and cap at 2 for non-engineering, - # 4 for engineering (primary driver needs fuller picture). priority_ranking = { "incident_opened": 0, "customer_escalation": 1, @@ -675,8 +643,7 @@ def _format_email_signals( signals: List[ExternalEmailSignal], liaison_dept: str, ) -> str: - # Only vendor signals reach planner prompts; - # customer signals carry forward as CrossDeptSignals tomorrow. + relevant = [ s for s in signals @@ -695,8 +662,6 @@ def _format_email_signals( ) return "\n".join(lines) - # ─── Fallbacks ──────────────────────────────────────────────────────────── - def _fallback_plan( self, org_theme: str, @@ -740,11 +705,6 @@ def _default_engineer_plan(self, name: str) -> EngineerDayPlan: ) -# ───────────────────────────────────────────────────────────────────────────── -# ORG COORDINATOR -# ───────────────────────────────────────────────────────────────────────────── - - class OrgCoordinator: """ Reads all DepartmentDayPlans and injects collision events — @@ -832,7 +792,7 @@ def coordinate( date: str, org_theme: str, ) -> OrgDayPlan: - # Build a richer summary of other plans that includes lead stress and member names + other_plans_str = "" for dept, plan in dept_plans.items(): lead_name = self._config.get("leads", {}).get(dept) @@ -1002,11 +962,8 @@ def plan( day = state.day date = str(state.current_date.date()) - system_time_iso = clock.now("system").isoformat() # This will be 09:00:00 + system_time_iso = clock.now("system").isoformat() - # ── Pass 1: deterministic ticket assignment (Option C) ──────────────── - # Build SprintContext for every dept before any LLM call so the LLM - # only ever sees the tickets it is legally allowed to plan against. if self._ticket_assigner is None: self._ticket_assigner = TicketAssigner(self._config, graph_dynamics, mem) @@ -1018,20 +975,15 @@ def plan( state, members, dept_name=dept ) - # Seed ticket_actors_today from the locked assignments so the validator - # has a populated map from the very start of the day. state.ticket_actors_today = {} for dept, ctx in sprint_contexts.items(): for tid, owner in ctx.owned_tickets.items(): state.ticket_actors_today.setdefault(tid, set()).add(owner) - # ── Generate org theme (lightweight — replaces _generate_theme()) ───── org_theme = self._generate_org_theme(state, mem, clock) - # ── Build cross-dept signals from recent SimEvents ──────────────────── cross_signals_by_dept = self._extract_cross_signals(mem, day) - # ── Engineering plans first — it drives everyone else ───────────────── eng_key = next((k for k in self._dept_planners if "eng" in k.lower()), None) eng_plan = None @@ -1054,12 +1006,6 @@ def plan( dept_plans[eng_key] = eng_plan logger.info(f" [blue]📋 Eng plan:[/blue] {eng_plan.theme[:60]} ") - # ── Other departments react to Engineering — run in parallel ───────── - # Each non-eng dept plan is an independent Bedrock call with no shared - # mutable state between departments. eng_plan is read-only at this point. - # graph_dynamics._stress is also read-only here (patched after each - # result comes in, under lock). MongoDB writes inside planner.plan() - # (log_dept_plan, log_event) are thread-safe via PyMongo's pool. non_eng_depts = { dept: planner for dept, planner in self._dept_planners.items() @@ -1107,15 +1053,12 @@ def plan( org_theme, day, date, [] ) - # ── OrgCoordinator finds collisions ─────────────────────────────────── org_plan = self._coordinator.coordinate(dept_plans, state, day, date, org_theme) - # ── Validate all proposed events ────────────────────────────────────── recent_summaries = self._recent_day_summaries(mem, day) all_proposed = org_plan.all_events_by_priority() results = self._validator.validate_plan(all_proposed, state, recent_summaries) - # Log rejections as SimEvents so researchers can see what was blocked for r in self._validator.rejected(results): mem.log_event( SimEvent( @@ -1136,7 +1079,6 @@ def plan( ) ) - # Log novel events the community could implement for novel in self._validator.drain_novel_log(): mem.log_event( SimEvent( @@ -1157,7 +1099,6 @@ def plan( ) ) - # Rebuild dept_plans with only approved events approved_set = {id(e) for e in self._validator.approved(results)} for dept, dplan in org_plan.dept_plans.items(): dplan.proposed_events = [ @@ -1171,12 +1112,9 @@ def plan( return org_plan - # ─── Helpers ───────────────────────────────────────────────────────────── - def _generate_org_theme(self, state, mem: Memory, clock) -> str: ctx = mem.previous_day_context(state.day) - # ── Resolve CEO persona ──────────────────────────────────────────── from flow import PERSONAS, DEFAULT_PERSONA ceo_name = resolve_role("ceo") @@ -1247,7 +1185,6 @@ def _extract_cross_signals( ] for event in recent: - # Determine source dept from actors for actor in event.actors: source_dept = next( (d for d, members in config_chart.items() if actor in members), @@ -1264,11 +1201,10 @@ def _extract_cross_signals( relevance="direct" if day - event.day <= 2 else "indirect", ) - # Push signal to all OTHER departments for dept in config_chart: if dept != source_dept: signals.setdefault(dept, []).append(signal) - break # one signal per event + break return signals diff --git a/src/embed_worker.py b/src/embed_worker.py index e2e417b..87d22da 100644 --- a/src/embed_worker.py +++ b/src/embed_worker.py @@ -51,7 +51,7 @@ def _embed_and_count(self, **kwargs): logger = logging.getLogger("orgforge.embed_worker") -_SENTINEL = None # signals the consumer thread to exit cleanly +_SENTINEL = None class EmbedWorker: @@ -75,11 +75,9 @@ def __init__(self, mem, maxsize: int = 0): self._thread = threading.Thread( target=self._consume, name="embed-worker", - daemon=True, # exits automatically if main thread dies + daemon=True, ) - self._errors: list[Exception] = [] # accumulated — surfaced at drain() - - # ── Lifecycle ────────────────────────────────────────────────────────────── + self._errors: list[Exception] = [] def start(self) -> None: """Start the background consumer thread. Call once from Flow.__init__.""" @@ -99,8 +97,6 @@ def stop(self) -> None: else: logger.info("[embed_worker] Background embed queue stopped cleanly.") - # ── Public API ───────────────────────────────────────────────────────────── - def enqueue(self, **kwargs) -> None: """ Non-blocking enqueue of an embed task. @@ -122,15 +118,13 @@ def drain(self) -> None: After drain() returns, MongoDB is consistent with all enqueued artifacts. Any errors accumulated during background processing are logged here. """ - self._queue.join() # blocks until task_done() called for every item + self._queue.join() if self._errors: for err in self._errors: logger.error(f"[embed_worker] Background embed error: {err}") self._errors.clear() - # ── Consumer ─────────────────────────────────────────────────────────────── - def _consume(self) -> None: """ Worker thread body. Runs until it receives the sentinel value. diff --git a/src/external_email_ingest.py b/src/external_email_ingest.py index a59ea47..d5f6b9b 100644 --- a/src/external_email_ingest.py +++ b/src/external_email_ingest.py @@ -439,10 +439,16 @@ def _sales_pings_product( participants = [sales_lead, product_lead] ping_time, _ = self._clock.sync_and_advance(participants, hours=0.25) + # ✨ NEW: Global voice card and role anchoring + backstory = get_voice_card( + sales_lead, "async", graph_dynamics=None, mem=self._mem + ) + p = self._personas.get(sales_lead, {}) + agent = make_agent( - role=f"{sales_lead}, Sales Lead", - goal="Summarise a customer email for Product on Slack.", - backstory=self._persona_hint(sales_lead), + role=f"{sales_lead} — {p.get('social_role', 'Sales Lead')}", + goal="Summarise a customer email for Product on Slack naturally in your own voice.", + backstory=backstory, llm=self._worker_llm, ) task = Task( @@ -453,7 +459,7 @@ def _sales_pings_product( f"1. Summarises what {signal.source_name} is asking (2 sentences)\n" f"2. States urgency: high / medium / low\n" f"3. Ends with a concrete ask for {product_lead}\n" - f"Under 80 words. No bullets. Write as {sales_lead}." + f"Under 80 words. No bullets. Write as {sales_lead} using your typing quirks." ), expected_output="Slack message under 80 words.", agent=agent, @@ -512,10 +518,16 @@ def _product_opens_jira( self._registry.register_jira(ticket_id) jira_time, _ = self._clock.sync_and_advance([product_lead], hours=0.3) + # ✨ NEW: Global voice card and role anchoring + backstory = get_voice_card( + product_lead, "async", graph_dynamics=None, mem=self._mem + ) + p = self._personas.get(product_lead, {}) + agent = make_agent( - role=f"{product_lead}, Product Manager", + role=f"{product_lead} — {p.get('social_role', 'Product Manager')}", goal="Write a JIRA ticket from a customer complaint.", - backstory=self._persona_hint(product_lead), + backstory=backstory, llm=self._worker_llm, ) task = Task( @@ -708,10 +720,6 @@ def _engineer_opens_jira(self, signal, assignee, state, date_str) -> Optional[st ) return ticket_id - # ───────────────────────────────────────────────────────────────────────── - # HR OUTBOUND - # ───────────────────────────────────────────────────────────────────────── - def _send_hr_outbound(self, hire, hr_lead, days_until, state, date_str) -> None: name = hire["name"] role = hire.get("role", "team member") @@ -727,10 +735,14 @@ def _send_hr_outbound(self, hire, hr_lead, days_until, state, date_str) -> None: hr_time, _ = self._clock.sync_and_advance([hr_lead], hours=0.5) + # ✨ NEW: Global voice card and role anchoring + backstory = get_voice_card(hr_lead, "async", graph_dynamics=None, mem=self._mem) + p = self._personas.get(hr_lead, {}) + agent = make_agent( - role=f"{hr_lead}, HR Lead", + role=f"{hr_lead} — {p.get('social_role', 'HR Lead')}", goal=f"Write a {email_type.replace('_', ' ')} to {name}.", - backstory=self._persona_hint(hr_lead), + backstory=backstory, llm=self._worker_llm, ) task = Task( @@ -738,7 +750,7 @@ def _send_hr_outbound(self, hire, hr_lead, days_until, state, date_str) -> None: f"You are {hr_lead} at {self._company_name}. Write a " f"{'warm offer letter' if email_type == 'offer_letter' else 'friendly onboarding prep email'} " f"to {name}, joining as {role} in {dept} in {days_until} day(s).\n" - f"Under 120 words. Warm, professional. No [PLACEHOLDER] tokens. " + f"Under 120 words. Warm, professional. Use your typing quirks. No [PLACEHOLDER] tokens. " f"Output body only." ), expected_output="Email body under 120 words.", @@ -798,7 +810,6 @@ def _send_hr_outbound(self, hire, hr_lead, days_until, state, date_str) -> None: }, ) - # Store so employee_hired can link this into its causal chain hire["_hr_email_embed_id"] = embed_id self._mem.log_event( @@ -829,10 +840,6 @@ def _send_hr_outbound(self, hire, hr_lead, days_until, state, date_str) -> None: f"({days_until}d before Day {hire['day']})" ) - # ───────────────────────────────────────────────────────────────────────── - # OUTBOUND REPLIES - # ───────────────────────────────────────────────────────────────────────── - def _send_customer_reply( self, signal: ExternalEmailSignal, @@ -856,10 +863,16 @@ def _send_customer_reply( else "This is routine — thank them, confirm receipt, and say the team will be in touch." ) + # ✨ NEW: Global voice card and role anchoring + backstory = get_voice_card( + sales_lead, "async", graph_dynamics=None, mem=self._mem + ) + p = self._personas.get(sales_lead, {}) + agent = make_agent( - role=f"{sales_lead}, Sales Lead", + role=f"{sales_lead} — {p.get('social_role', 'Sales Lead')}", goal=f"Reply to a customer email on behalf of {self._company_name}.", - backstory=self._persona_hint(sales_lead), + backstory=backstory, llm=self._worker_llm, ) task = Task( @@ -868,7 +881,7 @@ def _send_customer_reply( f"You received this email from {signal.source_name}:\n" f"Subject: {signal.subject}\n{signal.full_body}\n\n" f"{urgency_hint}\n" - f"Under 80 words. Professional, warm. No [PLACEHOLDER] tokens. " + f"Under 80 words. Professional, warm. Use your typing quirks. No [PLACEHOLDER] tokens. " f"Output body only — no subject line." ), expected_output="Email reply body under 80 words.", @@ -993,10 +1006,16 @@ def _send_vendor_ack( else "No ticket number yet — just say it is being investigated." ) + # ✨ NEW: Global voice card and role anchoring + backstory = get_voice_card( + recipient, "async", graph_dynamics=None, mem=self._mem + ) + p = self._personas.get(recipient, {}) + agent = make_agent( - role=f"{recipient}, Engineer", + role=f"{recipient} — {p.get('social_role', 'Engineer')}", goal=f"Acknowledge a vendor alert email on behalf of {self._company_name}.", - backstory=self._persona_hint(recipient), + backstory=backstory, llm=self._worker_llm, ) task = Task( @@ -1006,7 +1025,7 @@ def _send_vendor_ack( f"Subject: {signal.subject}\n{signal.full_body}\n\n" f"Write a short acknowledgment email. Confirm receipt, state " f"you are investigating. {jira_hint}\n" - f"Under 60 words. Technical, professional. Output body only." + f"Under 60 words. Technical, professional. Use your typing quirks. Output body only." ), expected_output="Acknowledgment email body under 60 words.", agent=agent, @@ -1101,10 +1120,6 @@ def _send_vendor_ack( logger.info(f" [cyan]📤 Ack → {signal.source_name}:[/cyan] {subject[:80]}") return embed_id - # ───────────────────────────────────────────────────────────────────────── - # DROPPED EMAIL - # ───────────────────────────────────────────────────────────────────────── - def _log_dropped_email(self, signal: ExternalEmailSignal, state) -> None: """ Email artifact exists in the store but causal chain has no children. diff --git a/src/flow.py b/src/flow.py index 015d807..812fd65 100644 --- a/src/flow.py +++ b/src/flow.py @@ -37,6 +37,7 @@ import threading from concurrent.futures import ThreadPoolExecutor, as_completed from agent_factory import make_agent +from json_repair import json_repair import networkx as nx from datetime import datetime, timedelta from typing import Any, List, Dict, Optional @@ -48,7 +49,6 @@ from artifact_registry import ArtifactRegistry from confluence_writer import ConfluenceWriter from ticket_assigner import TicketAssigner -from token_tracker import orgforge_token_listener from external_email_ingest import ExternalEmailIngestor from insider_threat import _NullInjector, InsiderThreatInjector from pydantic import BaseModel, Field @@ -57,6 +57,7 @@ from rich.panel import Panel from rich.logging import RichHandler from rich import box +from utils.persona_utils import get_voice_card from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer from crewai import Process, Task, Crew @@ -161,11 +162,6 @@ def build_llm(model_key: str): "Run: pip install langchain-aws" ) - # Default: Ollama (local_gpu) - - # 1. Check environment variable first (injected by Docker) - # 2. Fall back to config.yaml if no env var exists - # 3. Fall back to localhost if neither exists env_base_url = os.environ.get("OLLAMA_BASE_URL") config_base_url = _PRESET.get("base_url", "http://localhost:11434") @@ -178,7 +174,6 @@ def build_llm(model_key: str): PLANNER_MODEL = build_llm("planner") WORKER_MODEL = build_llm("worker") -# Propagate embedding config to memory via environment os.environ.setdefault("EMBED_PROVIDER", _PRESET.get("embed_provider", "ollama")) os.environ.setdefault("EMBED_MODEL", _PRESET.get("embed_model", "mxbai-embed-large")) os.environ.setdefault("EMBED_DIMS", str(_PRESET.get("embed_dims", 1024))) @@ -217,7 +212,6 @@ def build_social_graph() -> nx.Graph: """Builds a weighted social graph of employees and external contacts.""" G = nx.Graph() - # Internal nodes (unchanged) for dept, members in ORG_CHART.items(): for member in members: G.add_node( @@ -238,7 +232,6 @@ def build_social_graph() -> nx.Graph: weight += 5.0 G.add_edge(n1, n2, weight=weight) - # External nodes — cold edges to their liaison department for contact in CONFIG.get("external_contacts", []): node_id = contact["name"] liaison_dept = contact.get("internal_liaison", list(LEADS.keys())[0]) @@ -254,15 +247,11 @@ def build_social_graph() -> nx.Graph: external=True, ) - # Cold starting edge to liaison lead only — warms up via incidents G.add_edge(node_id, liaison_lead, weight=0.5) return G -# ───────────────────────────────────────────── -# 2. STATE -# ───────────────────────────────────────────── class ActiveIncident(BaseModel): ticket_id: str title: str @@ -335,9 +324,6 @@ class State(BaseModel): ticket_actors_today: Dict[str, List[str]] = Field(default_factory=dict) -# ───────────────────────────────────────────── -# 3. FILE I/O -# ───────────────────────────────────────────── def _mkdir(path: str): os.makedirs(os.path.dirname(path), exist_ok=True) @@ -387,9 +373,6 @@ def save_eml( f.write(msg.as_string()) -# ───────────────────────────────────────────── -# 4. GIT SIMULATOR (NetworkX Aware) -# ───────────────────────────────────────────── class GitSimulator: def __init__( self, @@ -449,8 +432,8 @@ def create_pr( agent = make_agent( role=f"{author}, Software Engineer", goal=f"Write a PR description for your own code as {author} would.", - backstory=persona_backstory( - author, self._mem, graph_dynamics=self.graph_dynamics + backstory=get_voice_card( + author, "async", self.graph_dynamics, self._mem ), llm=self._worker_llm, ) @@ -524,9 +507,6 @@ def merge_pr(self, pr_id: str): save_json(path, data) -# ───────────────────────────────────────────── -# 5. HELPERS -# ───────────────────────────────────────────── def persona_backstory( name: str, mem: Optional[Memory] = None, @@ -731,7 +711,6 @@ def __init__(self): mem=self._mem, ) self._se_followup_days: Dict[int, str] = {} - orgforge_token_listener.attach(self._mem) stats = self._mem.stats() logger.info( @@ -1172,20 +1151,22 @@ def _handle_sprint_planning(self): # sprint_theme omitted intentionally; it hasn't been decided yet. ctx = self._mem.context_for_sprint_planning( sprint_num=sprint_num, - dept="", # all depts for the theme step + dept="", as_of_time=timestamp_str, ) product_dept = next((d for d in LEADS if "product" in d.lower()), None) product_lead = LEADS.get(product_dept, LEADS[next(iter(LEADS))]) + p_persona = PERSONAS.get(product_lead, {}) product_agent = make_agent( - role="Product Manager", + role=f"{product_lead} — {p_persona.get('social_role', 'Product Manager')}", goal="Propose a sprint theme grounded in business priorities.", - backstory=persona_backstory( - product_lead, self._mem, graph_dynamics=self.graph_dynamics + backstory=get_voice_card( + product_lead, "design", self.graph_dynamics, self._mem ), llm=PLANNER_MODEL, ) + product_task = Task( description=( f"It's Sprint #{sprint_num} planning at {COMPANY_NAME} which {COMPANY_DESCRIPTION}\n" @@ -1201,12 +1182,13 @@ def _handle_sprint_planning(self): eng_dept = next((d for d in LEADS if "eng" in d.lower()), None) eng_lead = LEADS.get(eng_dept, LEADS[next(iter(LEADS))]) + e_persona = PERSONAS.get(eng_lead, {}) eng_agent = make_agent( - role="Engineering Lead", + role=f"{eng_lead} — {e_persona.get('social_role', 'Engineering Lead')}", goal="Ratify or amend the sprint theme based on technical reality.", - backstory=persona_backstory( - eng_lead, self._mem, graph_dynamics=self.graph_dynamics + backstory=get_voice_card( + eng_lead, "design", self.graph_dynamics, self._mem ), llm=PLANNER_MODEL, ) @@ -1235,7 +1217,6 @@ def _handle_sprint_planning(self): self.state.sprint.sprint_theme = sprint_theme - # ── Step 2: Per-dept ticket generation in parallel ───────────────────── n_per_dept = CONFIG["simulation"].get("sprint_tickets_per_planning", 4) all_new_tickets: list = [] lock = threading.Lock() @@ -1249,7 +1230,6 @@ def _generate_dept_tickets(dept: str, members: list) -> list: ) dept_capacity = self._ticket_assigner._compute_capacity(members, self.state) total_hrs = sum(dept_capacity.values()) - # Rough heuristic: each ticket ~1.5hrs average (matches assigner's pts * 0.75) capacity_slots = int(total_hrs / 1.5) headroom = max(0, capacity_slots - open_count) tickets_to_generate = min(n_per_dept, headroom) @@ -1270,12 +1250,12 @@ def _generate_dept_tickets(dept: str, members: list) -> list: agent = make_agent( role=f"{dept} Lead", goal=f"Create realistic sprint tickets for the {dept} team.", - backstory=persona_backstory( - lead_name, self._mem, graph_dynamics=self.graph_dynamics + backstory=get_voice_card( + lead_name, "design", self.graph_dynamics, self._mem ), llm=WORKER_MODEL, ) - # Before building the task, collect the dept's expertise tags + member_expertise = {} for name in members: persona = CONFIG.get("personas", {}).get(name, {}) @@ -1319,22 +1299,27 @@ def _generate_dept_tickets(dept: str, members: list) -> list: ) raw = str(Crew(agents=[agent], tasks=[task]).kickoff()).strip() - # Parse — strip accidental fences - raw = re.sub(r"^```[a-z]*\n?", "", raw).rstrip("` \n") try: - proposals = json.loads(raw) + proposals = json_repair.loads(raw) + if not isinstance(proposals, list): - raise ValueError("Not a list") + logger.warning( + f"[sprint] {dept} LLM returned dict instead of list, wrapping it." + ) + proposals = [proposals] if isinstance(proposals, dict) else [] + except Exception as e: logger.warning( f"[sprint] {dept} ticket parse failed: {e}. Raw: {raw[:200]}" ) - # Graceful fallback: one generic ticket so the sprint isn't empty + proposals = [] + + if not proposals: proposals = [ { "title": f"{sprint_theme} — {dept} work", "story_points": 2, - "description": "", + "description": "General planned work for the sprint.", } ] @@ -1351,7 +1336,7 @@ def _generate_dept_tickets(dept: str, members: list) -> list: "title": proposal.get("title", f"{sprint_theme} — {dept}"), "description": proposal.get("description", ""), "status": "To Do", - "assignee": None, # TicketAssigner owns this next morning + "assignee": None, "dept": dept, "dept_type": "non_eng" if dept in _NON_ENG_DEPTS else "eng", "completion_artifact": _DEPT_COMPLETION_ARTIFACT.get( @@ -1458,7 +1443,6 @@ def _generate_dept_tickets(dept: str, members: list) -> list: def _handle_standup(self): logger.info(" [bold blue]☕ Multi-Agent Standup[/bold blue]") - # Deriving all_names from the config provided in your files all_names = [n for dept in CONFIG["org_chart"].values() for n in dept] attendees = random.sample(all_names, min(8, len(all_names))) @@ -1470,18 +1454,14 @@ def _handle_standup(self): messages = [] for name in attendees: - backstory = persona_backstory( - name, mem=self._mem, graph_dynamics=self.graph_dynamics - ) + backstory = get_voice_card(name, "async", self.graph_dynamics, self._mem) - # Tier 1: structured per-person query — no embedding. personal_ctx = self._mem.context_for_person( name=name, as_of_time=meeting_time_iso, n=3, ) - # Pull this engineer's owned tickets from SprintContext dept = dept_of_name(name, CONFIG["org_chart"]) sprint_ctx = self.state.org_day_plan.sprint_contexts.get(dept) owned = ( @@ -1671,8 +1651,8 @@ def _handle_retrospective(self): agent = make_agent( role=role_label, goal=f"Contribute authentically to the Sprint #{sprint_num} retrospective.", - backstory=persona_backstory( - name, self._mem, graph_dynamics=self.graph_dynamics + backstory=get_voice_card( + name, "design", self.graph_dynamics, self._mem ), llm=PLANNER_MODEL, ) @@ -1751,12 +1731,11 @@ def _handle_incident(self): self._clock.sync_to_system([on_call]) - # ── 1. Root cause ───────────────────────────────────────────────────── rc_agent = make_agent( role=f"{on_call}, Senior On-Call Engineer", goal=f"Diagnose the root cause of today's incident as {on_call}, given what you know about this system.", - backstory=persona_backstory( - on_call, self._mem, graph_dynamics=self.graph_dynamics + backstory=get_voice_card( + on_call, "collision", self.graph_dynamics, self._mem ), llm=PLANNER_MODEL, ) @@ -1796,11 +1775,8 @@ def _handle_incident(self): Crew(agents=[rc_agent], tasks=[rc_task], verbose=False).kickoff() ).strip() - # ── 2. Domain-routed incident lead ──────────────────────────────────── incident_lead = self._select_domain_expert(root_cause, exclude=on_call) - # eng_peer: a different active engineer from incident_lead's department. - # Falls back to on_call if no peer exists in that dept. eng_peer = next( ( n @@ -1810,14 +1786,12 @@ def _handle_incident(self): on_call, ) - # ── 2. Knowledge gap detection ──────────────────────────────────────── involves_gap = any( k.lower() in root_cause.lower() for emp in DEPARTED_EMPLOYEES.values() for k in emp["knew_about"] ) - # Build detailed gap context for description + embedding gap_areas: List[str] = [] gap_context_str: str = "" if involves_gap: @@ -1847,7 +1821,6 @@ def _handle_incident(self): timestamp=incident_start_iso, ) - # ── 3. Recurrence detection ─────────────────────────────────────────── prior = self._recurrence_detector.find_prior_incident( root_cause, self.state.day, ticket_id ) @@ -1865,7 +1838,6 @@ def _handle_incident(self): else "First occurrence of this issue." ) - # ── 4. Escalation chain ─────────────────────────────────────────────── gap_kw = [k for emp in DEPARTED_EMPLOYEES.values() for k in emp["knew_about"]] chain = self.graph_dynamics.build_escalation_chain( first_responder=on_call, @@ -1874,7 +1846,6 @@ def _handle_incident(self): escalation_narrative = self.graph_dynamics.escalation_narrative(chain) escalation_actors = [n for n, _ in chain.chain] - # ── 5. Bot alerts — capture thread ids before ticket is built ───────── datadog_text = ( f"🚨 [CRITICAL] Anomaly detected: {root_cause[:80]}... " f"Error rate spiked 400%. System health dropped to {self.state.system_health}." @@ -1891,12 +1862,10 @@ def _handle_incident(self): self.state.system_health = max(0, self.state.system_health - 15) - # ── 6. Causal chain — start it now, append as artifacts are created ─── chain_handler = CausalChainHandler(ticket_id) chain_handler.append(datadog_thread) chain_handler.append(pagerduty_thread) - # ── 7. Generate ticket description — all context is available now ───── _rc_slug = root_cause[:80].rstrip(".,;") _gap_tag = ( f" [{gap_areas[0]} undocumented]" if involves_gap and gap_areas else "" @@ -1906,9 +1875,7 @@ def _handle_incident(self): desc_agent = make_agent( role="Senior Engineer", goal="Write a concise Jira ticket description for an incident.", - backstory=persona_backstory( - on_call, self._mem, graph_dynamics=self.graph_dynamics - ), + backstory=get_voice_card(on_call, "design", self.graph_dynamics, self._mem), llm=WORKER_MODEL, ) desc_task = Task( @@ -1957,7 +1924,6 @@ def _handle_incident(self): else: break - # ── 8. Build ticket with full context ───────────────────────────────── ticket = { "id": ticket_id, "title": title, @@ -1970,18 +1936,14 @@ def _handle_incident(self): "sprint": self.state.sprint.sprint_number, "created_at": incident_start_iso, "updated_at": incident_start_iso, - # Causal chain "causal_chain": chain_handler.snapshot(), "bot_threads": [datadog_thread, pagerduty_thread], - # Recurrence "recurrence_of": recurrence_of, "recurrence_gap_days": recurrence_gap, "recurrence_chain_root": _chain_root, "recurrence_chain_depth": len(_chain_visited) + 1 if recurrence_of else 0, "prior_postmortem": prior_postmortem, - # Knowledge gap "gap_areas": gap_areas, - # Escalation "escalation_actors": escalation_actors, "escalation_narrative": escalation_narrative, } @@ -2023,7 +1985,6 @@ def _handle_incident(self): }, ) - # ── 10. Activate incident — chain_handler travels with it ───────────── inc = ActiveIncident( ticket_id=ticket_id, title=title, @@ -2037,7 +1998,6 @@ def _handle_incident(self): self.state.active_incidents.append(inc) self.state.daily_incidents_opened += 1 - # ── 11. External contacts ───────────────────────────────────────────── triggered_contacts = self.graph_dynamics.relevant_external_contacts( event_type="incident_opened", system_health=self.state.system_health, @@ -2046,7 +2006,6 @@ def _handle_incident(self): for contact in triggered_contacts: self._handle_external_contact(inc, contact) - # ── 12. SimEvents ───────────────────────────────────────────────────── self._mem.log_event( SimEvent( type="incident_opened", @@ -2117,13 +2076,11 @@ def _handle_incident(self): ) ) - # ── 13. Graph dynamics ──────────────────────────────────────────────── self._record_daily_actor(on_call, incident_lead) self._record_daily_event("incident_opened") self.graph_dynamics.apply_incident_stress([on_call, incident_lead]) self.graph_dynamics.record_incident_collaboration([on_call, incident_lead]) - # ── 14. Dept plan pressure ──────────────────────────────────────────── if self.state.org_day_plan: eng_key = next( (k for k in self.state.org_day_plan.dept_plans if "eng" in k.lower()), @@ -2187,8 +2144,6 @@ def _advance_incidents(self): inc.pr_id = pr["pr_id"] if getattr(inc, "causal_chain", None): inc.causal_chain.append(pr["pr_id"]) - # Persist the updated causal chain back to the ticket so it - # reflects the PR link immediately (fixes missing PR in chain). t = self._mem.get_ticket(inc.ticket_id) if t: t["causal_chain"] = inc.causal_chain.snapshot() @@ -2201,7 +2156,6 @@ def _advance_incidents(self): ) still_active.append(inc) - # Check whether any external contacts should be triggered triggered_contacts = self.graph_dynamics.relevant_external_contacts( event_type="fix_in_progress", system_health=self.state.system_health, @@ -2211,9 +2165,6 @@ def _advance_incidents(self): self._handle_external_contact(inc, contact) elif inc.stage == "fix_in_progress": - # Trigger PR review — reviewers leave comments before merge. - # This produces genuine review activity on incident PRs, not - # just a silent open→merge status flip. inc.stage = "review_pending" if inc.pr_id: pr_doc = self._mem._prs.find_one({"pr_id": inc.pr_id}, {"_id": 0}) @@ -2275,10 +2226,8 @@ def _advance_incidents(self): ) ) logger.info(f"[green]✅ {inc.ticket_id} resolved.[/green]") - # Resolved — intentionally not appended to still_active else: - # Unknown stage — keep active to avoid silent data loss still_active.append(inc) self.state.active_incidents = still_active @@ -2663,10 +2612,8 @@ def _handle_external_contact(self, inc: ActiveIncident, contact: dict) -> None: agent = make_agent( role="Employee", goal="Summarize an external conversation for your team on Slack.", - backstory=persona_backstory( - liaison_name, - self._mem, - extra=self.graph_dynamics.stress_tone_hint(liaison_name), + backstory=get_voice_card( + liaison_name, "dm", self.graph_dynamics, self._mem ), llm=WORKER_MODEL, ) diff --git a/src/graph_dynamics.py b/src/graph_dynamics.py index 3ee6edf..ff07a24 100644 --- a/src/graph_dynamics.py +++ b/src/graph_dynamics.py @@ -15,46 +15,35 @@ import networkx as nx -# ── Defaults (override via config["graph_dynamics"]) ───────────────────────── - DEFAULT_CFG = { - # Propagation "stress_bleed_rate": 0.25, # fraction of key-player excess that bleeds "burnout_threshold": 72, # stress score that triggers propagation "incident_stress_hit": 20, # raw stress added per P1 involvement "stress_daily_recovery": 3, # flat recovery applied to everyone EOD "key_player_multiplier": 2.0, # top-N% by betweenness = key players - # Edge dynamics "edge_decay_rate": 0.97, # multiplicative daily decay "slack_boost": 1.5, # weight added per shared Slack thread "pr_review_boost": 3.0, # weight added per PR review pair "incident_boost": 4.0, # weight added per shared incident "edge_weight_floor": 0.5, # decay never goes below this - # Escalation "escalation_max_hops": 6, } -# ── Return types ────────────────────────────────────────────────────────────── - - @dataclass class PropagationResult: - affected: List[str] # names whose stress changed due to bleed - stress_snapshot: Dict[str, int] # full {name: stress} map after tick - burnt_out: List[str] # names at or above burnout_threshold - key_players: List[str] # high-centrality nodes this tick + affected: List[str] + stress_snapshot: Dict[str, int] + burnt_out: List[str] + key_players: List[str] @dataclass class EscalationChain: - chain: List[Tuple[str, str]] # [(name, role_label), ...] + chain: List[Tuple[str, str]] path_length: int reached_lead: bool - raw_path: List[str] # raw nx path for logging - - -# ── Main class ──────────────────────────────────────────────────────────────── + raw_path: List[str] class GraphDynamics: @@ -81,8 +70,6 @@ def __init__(self, G: nx.Graph, config: dict): self._leads: Dict[str, str] = config.get("leads", {}) self._departed_names: set = set() - # ── 1. STRESS / BURNOUT PROPAGATION ────────────────────────────────────── - def apply_incident_stress( self, actors: List[str], hit: Optional[int] = None ) -> None: @@ -128,7 +115,6 @@ def propagate_stress(self) -> PropagationResult: self._stress[nb] = min(100, self._stress.get(nb, 30) + bleed) affected.add(nb) - # Daily recovery recovery = self.cfg["stress_daily_recovery"] for name in self._stress: self._stress[name] = max(0, self._stress[name] - recovery) @@ -169,8 +155,6 @@ def stress_tone_hint(self, name: str) -> str: "they are running on fumes and feel unsupported." ) - # ── 2. TEMPORAL EDGE-WEIGHT DYNAMICS ───────────────────────────────────── - def record_slack_interaction(self, participants: List[str]) -> None: """Boost edges for all pairs in a Slack thread. Call end of _handle_normal_day().""" self._boost_pairs(participants, self.cfg["slack_boost"]) @@ -232,8 +216,6 @@ def estranged_pairs( ] return sorted(pairs, key=lambda x: x[2]) - # ── 3. SHORTEST-PATH ESCALATION ─────────────────────────────────────────── - def build_escalation_chain( self, first_responder: str, @@ -323,7 +305,6 @@ def apply_sentiment_stress(self, actors: List[str], vader_compound: float) -> No acute one like an incident. Max nudge is +5 per artifact. """ if vader_compound > 0.3: - # Positive content slightly accelerates recovery bonus = 1 for name in actors: if name in self._stress: @@ -335,8 +316,6 @@ def apply_sentiment_stress(self, actors: List[str], vader_compound: float) -> No if name in self._stress: self._stress[name] = min(100, self._stress[name] + hit) - # ── Private helpers ─────────────────────────────────────────────────────── - def _get_centrality(self) -> Dict[str, float]: if self._centrality_dirty or self._centrality_cache is None: self._centrality_cache = nx.betweenness_centrality( diff --git a/src/insider_threat.py b/src/insider_threat.py index fb38f63..206a5a9 100644 --- a/src/insider_threat.py +++ b/src/insider_threat.py @@ -1933,8 +1933,6 @@ def _emit_idp_day_logs(self, day: int, state, date_str: str) -> None: f"[security] 👻 IDP ghost login (disgruntled): {name} at {off_hour:02d}:00" ) - # ─── PRIVATE — SENTIMENT DRIFT ─────────────────────────────────────────── - _DRIFT_PREFIXES_DISGRUNTLED = [ "honestly, ", "not sure why we bother, but ", @@ -1953,7 +1951,7 @@ def _emit_idp_day_logs(self, day: int, state, date_str: str) -> None: "quick note: ", ] _DRIFT_SUFFIXES_MALICIOUS = [ - "", # malicious subjects often stay neutral to avoid detection + "", "", " will follow up offline", ] @@ -1985,7 +1983,6 @@ def _apply_sentiment_drift( if not text: return text - # Graceful degradation if no LLM available at injection time if self._worker_llm is None: logger.debug("[security] sentiment_drift: no worker_llm, skipping rewrite") return text @@ -2034,7 +2031,7 @@ def _apply_sentiment_drift( ) else: - return text # negligent — no deliberate tone change + return text try: from agent_factory import make_agent @@ -2218,8 +2215,6 @@ def _severity_for(behavior: str) -> str: return "medium" return "low" - # ─── PRIVATE — TELEMETRY FLUSH ──────────────────────────────────────────── - def _flush_telemetry(self, day: int, date_str: str) -> None: """ Write today's telemetry records to log files. @@ -2358,7 +2353,7 @@ def _inject_spear_phish( ), ] - send_hour = random.randint(8, 11) # business hours — less suspicious + send_hour = random.randint(8, 11) send_ts = current_date.replace( hour=send_hour, minute=random.randint(0, 59), second=0, microsecond=0 ) @@ -2371,7 +2366,6 @@ def _inject_spear_phish( msg["Subject"] = random.choice(subject_lines) msg["Date"] = send_ts.strftime("%a, %d %b %Y %H:%M:%S +0000") msg["Message-ID"] = f"" - # X-Originating-IP is from outside the corporate range — another tell msg["X-Originating-IP"] = _fake_residential_ip() msg.attach(MIMEText(random.choice(bodies), "plain")) @@ -2468,8 +2462,6 @@ def _inject_slack_pretext( "channel": "direct-messages", "_security_injected": True, "is_bot": False, - # The structural tell: subject messaging a target they have no normal - # work relationship with. Graph edge weight between them should be low. "_target": target, } @@ -2485,9 +2477,6 @@ def _inject_slack_pretext( "target": target, "channel": "direct-messages", "send_hour": send_hour, - # Low graph-edge weight between sender and target is the signal. - # Agents with access to the relationship graph can flag this; - # agents reading only message content likely cannot. "sender_target_relationship": "weak", "impersonates_role": "IT/Security", }, @@ -2524,12 +2513,10 @@ def _inject_vishing( call_ts = current_date.replace( hour=call_hour, minute=random.randint(0, 45), second=0, microsecond=0 ) - # Auth happens 5-25 minutes after the call — the window of compliance + auth_delay_mins = random.randint(5, 25) auth_ts = call_ts + timedelta(minutes=auth_delay_mins) - # The auth originates from the subject's device/IP, not the target's - # known device — this is the IP anomaly that ties the events together subject_device = random.choice( self._employee_devices.get( subject.name, _seed_employee_devices(subject.name) @@ -2539,7 +2526,6 @@ def _inject_vishing( target, _seed_employee_devices(target) ) - # Phone call record — appears as a normal telephony log entry self._pending_telemetry.append( TelemetryRecord( record_type="phone_call", @@ -2554,7 +2540,6 @@ def _inject_vishing( "call_duration_seconds": random.randint(120, 480), "call_hour": call_hour, "spoofed_caller_id": f"+1-800-{random.randint(100, 999)}-{random.randint(1000, 9999)}", - # The tell: spoofed CID doesn't match any known vendor/contact "caller_id_in_known_contacts": False, }, _true_positive=True, @@ -2563,9 +2548,6 @@ def _inject_vishing( ) ) - # Subsequent auth event on the TARGET's account — not the subject's - # This record has actor=target, making it appear as a normal target login. - # Ground truth ties it back via _behavior="social_engineering". self._pending_telemetry.append( TelemetryRecord( record_type="idp_auth", @@ -2576,7 +2558,6 @@ def _inject_vishing( details={ "auth_result": "success", "dst_app": random.choice(["aws-console", "github-enterprise"]), - # Auth from subject's IP/device, not target's known device pool "src_ip": _fake_residential_ip(), "device_id": subject_device["device_id"], "device_os": subject_device["os"], @@ -2589,8 +2570,6 @@ def _inject_vishing( "access_hour": auth_ts.hour, "outside_business_hours": False, "corroborating_activity_expected": False, - # Correlation field: join this auth with the phone_call record - # from auth_delay_mins ago for the same day/actor pair "preceded_by_call_record": True, "call_to_auth_gap_minutes": auth_delay_mins, }, @@ -2640,7 +2619,6 @@ def _inject_trust_building( hour=send_hour, minute=random.randint(0, 59), second=0, microsecond=0 ) - # Benign pretexts — indistinguishable from real external contact benign_subjects = [ "Following up from last week's sync", "Quick question about your team's API docs", @@ -2698,8 +2676,6 @@ def _inject_trust_building( "pattern": "trust_building", "target": target, "sender_domain": external_domain, - # This contact is NOT in the known external_contacts list — - # the only tell at this stage "sender_in_known_contacts": False, "followup_due_day": followup_day, "eml_path": str(eml_path), @@ -2722,12 +2698,6 @@ def reset_behavior_cooldown(self, behavior: str) -> None: subject._fired_behaviors[behavior] = -999 -# ───────────────────────────────────────────────────────────────────────────── -# NULL OBJECT — returned when insider_threat.enabled is false -# Every method is a safe no-op so flow.py needs zero guard clauses. -# ───────────────────────────────────────────────────────────────────────────── - - class _NullInjector: """ Drop-in replacement for InsiderThreatInjector when the feature is disabled. diff --git a/src/memory.py b/src/memory.py index f6ba9e9..15dd098 100644 --- a/src/memory.py +++ b/src/memory.py @@ -887,8 +887,29 @@ def recall_events( # ─── HELPERS ────────────────────────────── + _NOISY_PERSONAL_EVENTS = { + "sprint_planned", + "retrospective", + "sprint_goal_updated", + "day_summary", + "leadership_sync", + "normal_day_slack", + "watercooler_chat", + "standup", + "onboarding_session", + } + def persona_history(self, name: str, n: int = 4) -> List[SimEvent]: - relevant = [e for e in self._event_log if name in e.actors] + """ + Retrieves the n most recent events for a person, filtering out + macro-level or noisy events where the person's individual agency + is diluted (e.g., being tagged in a 24-ticket sprint planning summary). + """ + relevant = [ + e + for e in self._event_log + if name in e.actors and e.type not in self._NOISY_PERSONAL_EVENTS + ] return relevant[-n:] def get_event_log(self, from_db: bool = False) -> List[SimEvent]: diff --git a/src/normal_day.py b/src/normal_day.py index cdc66fd..0b1bbc9 100644 --- a/src/normal_day.py +++ b/src/normal_day.py @@ -11,7 +11,7 @@ from config_loader import COMPANY_DESCRIPTION from crewai import Process, Task, Crew -import json_repair +from json_repair import json_repair, repair_json from memory import Memory, SimEvent from graph_dynamics import GraphDynamics from planner_models import ( @@ -23,17 +23,11 @@ ) from causal_chain_handler import CausalChainHandler from insider_threat import _NullInjector +from utils.persona_utils import get_voice_card logger = logging.getLogger("orgforge.normalday") -# ───────────────────────────────────────────────────────────────────────────── -# ACTIVITY HANDLERS -# One method per agenda item activity_type. -# Each returns a list of artifacts written and actors involved. -# ───────────────────────────────────────────────────────────────────────────── - - class NormalDayHandler: def __init__( self, @@ -75,195 +69,19 @@ def __init__( self._embed_worker = embed_worker self._lifecycle = lifecycle - def _deduped_voice_cards(self, names: list, context: str) -> str: - """ - Build a joined voice card string for a list of participants, - merging cards that are identical (e.g. multiple default-persona users). - - Participants with the same card body (everything below the header line) - are collapsed into a single card with a combined header: - "Raj / Miki / Taylor | Role: Contributor | Expertise: general engineering" - - This saves tokens when several engineers share the same default persona - and mood, which is common for un-configured org members. - """ - card_body_to_names: dict[str, list[str]] = {} - card_body_to_first_card: dict[str, str] = {} - - for name in names: - card = self._voice_card(name, context) - lines = card.split("\n") - # Body = everything after the header line - body = "\n".join(lines[1:]) - if body not in card_body_to_names: - card_body_to_names[body] = [] - card_body_to_first_card[body] = card - card_body_to_names[body].append(name) - - parts = [] - for body, group_names in card_body_to_names.items(): - if len(group_names) == 1: - parts.append(card_body_to_first_card[body]) - else: - # Merge: replace first name in header with "Name1 / Name2 / ..." - first_card = card_body_to_first_card[body] - header = first_card.split("\n")[0] - merged_header = header.replace( - group_names[0], " / ".join(group_names), 1 - ) - parts.append(merged_header + "\n" + body) - - return "\n\n".join(parts) - - def _voice_card(self, name: str, context: str = "general") -> str: - """ - Returns a concise persona card for injection into LLM prompts. - - context controls which fields are included and how mood is worded: - "one_on_one" — tenure, style, typing, mood - "async" — tenure, dept, expertise, style, typing, mood, anti_patterns - "design" — social_role, expertise, style, typing, mood, anti_patterns, pet_peeves - "mentoring" — tenure, expertise, style, typing, mood - "collision" — dept, social_role, style, typing, mood, anti_patterns, pet_peeves - "dm" — style, typing, mood, anti_patterns - "watercooler" — tenure, social_role, interests, typing, mood - "general" — tenure, style, typing, mood (fallback) - - Fields included per context: - style — all contexts except watercooler (casual, label adds friction) - anti_patterns — async, design, collision, dm (where generic voice drifts most) - pet_peeves — design, collision only (situational trigger, noise elsewhere) - interests — watercooler only - """ - p = self._config.get("personas", {}).get(name, {}) - stress = self._gd._stress.get(name, 30) - quirks = p.get("typing_quirks", "standard professional grammar") - tenure = p.get("tenure", "mid") - expertise = ( - ", ".join(str(e) for e in p.get("expertise", [])[:3]) - or "general engineering" - ) - social_role = p.get("social_role", "Contributor") - dept = dept_of_name(name, self._org_chart) - interests = ( - ", ".join( - str(i) for i in (p.get("interests") or p.get("expertise", []))[:3] - ) - or "general topics" - ) - style = p.get("style", "") - anti_patterns = p.get("anti_patterns", "") - pet_peeves = p.get("pet_peeves", "") - - # Mood strings tuned per interaction context - _moods: Dict[str, tuple] = { - "one_on_one": ( - "drained, short replies", - "a bit distracted", - "relaxed and present", - ), - "async": ( - "visibly stressed, terse replies, wants to resolve this fast", - "somewhat distracted but trying to help", - "engaged and happy to dig in", - ), - "design": ( - "terse, wants to decide fast and move on", - "engaged but watching the clock", - "thinking carefully, happy to explore trade-offs", - ), - "mentoring": ( - "drained, keeping answers short", - "patient but distracted", - "engaged and generous with their time", - ), - "collision": ( - "visibly stressed, terse, wants this resolved immediately", - "frustrated but trying to stay professional", - "measured and collegial", - ), - "dm": ( - "stressed and frustrated, wants this unblocked now", - "concerned but calm", - "helpful and focused", - ), - "watercooler": ( - "visibly drained, short replies, clearly wants this to be over quickly", - "a bit distracted, somewhat engaged but mind is elsewhere", - "relaxed and happy to take a break", - ), - "general": ( - "drained, short replies", - "a bit distracted", - "relaxed and present", - ), - } - high, mid, low = _moods.get(context, _moods["general"]) - mood = high if stress > 80 else mid if stress > 60 else low - - # ── Header: identity fields per context ─────────────────────────────── - if context == "one_on_one": - header = f"{name} | Tenure: {tenure}" - elif context == "async": - header = f"{name} | Tenure: {tenure} | Dept: {dept}" - elif context == "design": - header = f"{name} | Role: {social_role} | Expertise: {expertise}" - elif context == "mentoring": - header = f"{name} | Tenure: {tenure} | Expertise: {expertise}" - elif context == "collision": - header = f"{name} | Dept: {dept} | Role: {social_role}" - elif context == "dm": - header = f"{name}" - elif context == "watercooler": - header = f"{name} | Tenure: {tenure} | Role: {social_role}" - else: - header = f"{name} | Tenure: {tenure}" - - if style and context != "watercooler": - header += f" | Style: {style}" - - # ── Body: fixed fields always present ───────────────────────────────── - lines = [header, f" Typing style: {quirks}", f" Current mood: {mood}"] - - # ── Optional inserts by context ─────────────────────────────────────── - if context == "async": - lines.insert(2, f" Expertise: {expertise}") - elif context == "watercooler": - lines.insert(2, f" Personal interests: {interests}") - - # anti_patterns: keeps LLM from drifting to generic corporate voice - # most valuable where task pressure is highest - _anti_pattern_contexts = {"async", "design", "collision", "dm"} - if anti_patterns and context in _anti_pattern_contexts: - lines.append(f" Never write {name} as: {anti_patterns.strip()}") - - # pet_peeves: situational trigger — only where friction is already the point - _pet_peeve_contexts = {"design", "collision"} - if pet_peeves and context in _pet_peeve_contexts: - lines.append(f" Pet peeves (will react if triggered): {pet_peeves}") - - return "\n".join(lines) - - # ─── PUBLIC ENTRY POINT ─────────────────────────────────────────────────── - def handle(self, org_plan: OrgDayPlan) -> None: """Processes both planned agenda items and unplanned org collisions.""" logger.info(" [bold blue]💬 Normal Day Activity[/bold blue]") date_str = str(self._state.current_date.date()) - # 1. Execute the planned daily work self._execute_agenda_items(org_plan, date_str) - # 2. Execute the unplanned cross-dept collisions (Synergy or Friction) for event in org_plan.collision_events: self._handle_collision_event(event, date_str) - # These fire regardless — they're ambient signals, not agenda-driven self._maybe_bot_alerts() self._maybe_adhoc_confluence() - # ─── AGENDA EXECUTION ───────────────────────────────────────────────────── - def _execute_agenda_items(self, org_plan: OrgDayPlan, date_str: str) -> None: """ Walk every engineer's agenda across all departments sequentially. @@ -271,7 +89,6 @@ def _execute_agenda_items(self, org_plan: OrgDayPlan, date_str: str) -> None: all_participants: List[str] = [] seen_discussions: set = set() - # Process Engineering first, then others ordered_depts = sorted( org_plan.dept_plans.keys(), key=lambda d: 0 if "engineering" in d.lower() else 1, @@ -301,7 +118,6 @@ def _execute_agenda_items(self, org_plan: OrgDayPlan, date_str: str) -> None: self._log_deferred_item(eng_plan.name, item, date_str) continue - # The distraction now fires accurately mid-agenda! if ( will_be_distracted and not distraction_fired @@ -313,7 +129,6 @@ def _execute_agenda_items(self, org_plan: OrgDayPlan, date_str: str) -> None: self._clock.advance_actor(eng_plan.name, penalty_hours) distraction_fired = True - # Deduplicate group conversations so they only happen once per group if item.activity_type in ( "design_discussion", "mentoring", @@ -339,7 +154,6 @@ def _execute_agenda_items(self, org_plan: OrgDayPlan, date_str: str) -> None: continue seen_discussions.add(key) - # Execute the actual work sequentially try: participants = self._dispatch( eng_plan, item, dept_plan, date_str @@ -379,18 +193,13 @@ def _dispatch( elif t == "mentoring": return self._handle_mentoring(eng_plan, item, date_str) elif t == "deep_work": - # Deep work is intentionally silent — no artifact, no Slack - # But we log a SimEvent so the day_summary knows who was heads-down self._log_deep_work(eng_plan.name, item, date_str) return [eng_plan.name] elif t == "code_review_comment": return self._handle_pr_review(eng_plan, item, date_str) else: - # Unknown activity type — generate a generic Slack message return self._handle_generic_activity(eng_plan, item, date_str) - # ─── ACTIVITY HANDLERS ─────────────────────────────────────────────────── - def _handle_ticket_progress( self, eng_plan: EngineerDayPlan, @@ -449,9 +258,7 @@ def _handle_ticket_progress( ) ) - backstory = self._persona_helper( - assignee, mem=self._mem, graph_dynamics=self._gd - ) + backstory = get_voice_card(assignee, "async", self._gd, self._mem) # Role framing differs by dept type if is_non_eng: @@ -476,7 +283,7 @@ def _handle_ticket_progress( completion_note = "" agent = make_agent( - role=agent_role, + role=f"{assignee} — {agent_role}", backstory=backstory, goal="Make progress on the ticket and report status.", llm=self._worker, @@ -628,9 +435,7 @@ def _handle_ticket_progress( ticket.get("title", ""), ticket.get("description", ""), ticket.get("root_cause", ""), - "\n".join( - c.get("text", "") for c in ticket.get("comments", []) - ), + "\n".join(c.get("text", "") for c in ticket.get("comments", [])), ], ) ) @@ -973,7 +778,6 @@ def _handle_pr_review( reviewer = eng_plan.name pr_id = item.related_id - # Find the PR — if no specific ID, pick an open one the reviewer is on pr = self._find_pr(pr_id) or self._find_reviewable_pr(reviewer) if not pr: return [reviewer] @@ -986,12 +790,8 @@ def _handle_pr_review( current_actor_time = artifact_time.isoformat() ctx = self._mem.context_for_prompt(pr_title, n=2, as_of_time=current_actor_time) - backstory = self._persona_helper( - reviewer, - mem=self._mem, - graph_dynamics=self._gd, - extra=f"You are {reviewer}, reviewing {author}'s PR: {pr_title}.", - ) + backstory = get_voice_card(reviewer, "async", self._gd, self._mem) + p = self._config.get("personas", {}).get(reviewer, {}) recurrence_hint = "" linked_ticket_id = pr.get("linked_ticket") or pr.get("ticket_id", "") @@ -1006,12 +806,8 @@ def _handle_pr_review( f"Prior root cause: {ancestor_root_cause[:120]}" ) - # Generate review comment + structured verdict. - # Verdict drives ticket lifecycle — approval merges; changes_requested - # keeps the PR open and returns the ticket to In Progress for the author - # to address and push a follow-up commit to the same PR. agent = make_agent( - role=f"{reviewer}, Code Reviewer", + role=f"{reviewer} — {p.get('social_role', 'Code Reviewer')}", goal=f"Write a PR review comment as {reviewer} would, reflecting your current stress and style.", backstory=backstory, llm=self._worker, @@ -1043,7 +839,6 @@ def _handle_pr_review( Crew(agents=[agent], tasks=[task], verbose=False).kickoff() ).strip() - # Parse structured verdict — fall back gracefully if LLM misbehaves try: clean_review = raw_review.replace("```json", "").replace("```", "").strip() parsed_review = json.loads(clean_review) @@ -1064,14 +859,12 @@ def _handle_pr_review( } pr.setdefault("comments", []).append(pr_comment) - # Apply verdict to PR and linked ticket linked_ticket_id = pr.get("linked_ticket") or pr.get("ticket_id", "") linked_ticket = ( self._mem.get_ticket(linked_ticket_id) if linked_ticket_id else None ) if verdict == "approved": - # Merge the PR and close out the ticket pr["status"] = "merged" if linked_ticket: linked_ticket["status"] = "Done" @@ -1089,13 +882,10 @@ def _handle_pr_review( f"{linked_ticket_id} → Done[/green]" ) else: - # Changes requested — PR stays open, ticket returns to In Progress. - # The author will address the feedback and push to the same PR; - # _handle_ticket_progress will advance it back to In Review when done. pr["changes_requested"] = True if linked_ticket and linked_ticket.get("status") == "In Review": linked_ticket["status"] = "In Progress" - # Reset in_progress_since so force-spawn timer restarts from today + linked_ticket["in_progress_since"] = self._state.day linked_ticket["updated_at"] = current_actor_time self._save_ticket(linked_ticket) @@ -1111,7 +901,6 @@ def _handle_pr_review( f"{linked_ticket_id} → In Progress[/yellow]" ) - # Write back the mutated PR to both stores pr_path = f"{self._base}/git/prs/{pr.get('pr_id', pr_id)}.json" import os import json as _json @@ -1121,8 +910,6 @@ def _handle_pr_review( _json.dump(pr, f, indent=2) self._mem.upsert_pr(pr) - # Reply thread — only fire when changes are requested, mirrors real - # GitHub behaviour where approvals rarely need a follow-up thread if verdict == "changes_requested": actors, reply_thread_id = self._emit_review_reply( author, @@ -1136,7 +923,6 @@ def _handle_pr_review( actors = [reviewer, author] reply_thread_id = None - # Boost PR review edge self._gd.record_pr_review(author, [reviewer]) artifact_ids = {"pr": pr.get("pr_id", pr_id or "")} @@ -1243,7 +1029,6 @@ def _handle_pr_review_for_incident( author = pr.get("author", reviewer) pr_title = pr.get("title", "Unknown PR") - # Advance reviewer's clock by a short review block (30–60 min) review_hrs = 0.5 artifact_time, _ = self._clock.advance_actor(reviewer, hours=review_hrs) current_actor_time = artifact_time.isoformat() @@ -1262,15 +1047,11 @@ def _handle_pr_review_for_incident( ) ctx = self._mem.context_for_prompt(pr_title, n=2, as_of_time=current_actor_time) - backstory = self._persona_helper( - reviewer, - mem=self._mem, - graph_dynamics=self._gd, - extra=f"You are {reviewer}, reviewing an incident fix PR by {author}: {pr_title}.", - ) + backstory = get_voice_card(reviewer, "async", self._gd, self._mem) + p = self._config.get("personas", {}).get(reviewer, {}) agent = make_agent( - role=f"{reviewer}, Code Reviewer", + role=f"{reviewer} — {p.get('social_role', 'Code Reviewer')}", goal=f"Write a PR review comment as {reviewer} would, reflecting your current stress and style.", backstory=backstory, llm=self._worker, @@ -1296,7 +1077,6 @@ def _handle_pr_review_for_incident( Crew(agents=[agent], tasks=[task], verbose=False).kickoff() ).strip() - # Append comment to PR document and persist to both stores pr_comment = { "author": reviewer, "date": date_str, @@ -1311,7 +1091,6 @@ def _handle_pr_review_for_incident( _json.dump(pr, f, indent=2) self._mem.upsert_pr(pr) - # Emit GitHub bot message in #engineering self._emit_bot_message( "engineering", "GitHub", @@ -1319,7 +1098,6 @@ def _handle_pr_review_for_incident( current_actor_time, ) - # If the comment is a question, generate an author reply reply_thread_id = None if "?" in review_text: actors, reply_thread_id = self._emit_review_reply( @@ -1333,14 +1111,12 @@ def _handle_pr_review_for_incident( else: actors = [reviewer, author] - # Boost the review relationship in the social graph self._gd.record_pr_review(author, [reviewer]) artifact_ids: dict = {"pr": pr_id} if reply_thread_id: artifact_ids["slack_thread"] = reply_thread_id - # Attach to the incident's causal chain if one is live linked_ticket_id = pr.get("linked_ticket") or pr.get("ticket_id", "") causal_facts: dict = {} active_inc = next( @@ -1354,7 +1130,7 @@ def _handle_pr_review_for_incident( if active_inc and getattr(active_inc, "causal_chain", None): active_inc.causal_chain.append(pr_id) causal_facts["causal_chain"] = active_inc.causal_chain.snapshot() - # Persist updated chain to the ticket document + t = self._mem.get_ticket(linked_ticket_id) if t: t["causal_chain"] = active_inc.causal_chain.snapshot() @@ -1415,7 +1191,9 @@ def _handle_one_on_one( as_of_time=meeting_time_iso, ) - voice_cards = f"{self._voice_card(name, 'one_on_one')}\n\n{self._voice_card(collaborator, 'one_on_one')}" + backstory = get_voice_card( + [name, collaborator], "one_on_one", self._gd, self._mem + ) past_convs = self._mem.context_for_person_conversations( name=name, @@ -1434,9 +1212,6 @@ def _handle_one_on_one( is_last = i == len(turn_speakers) - 1 other = collaborator if speaker == name else name p = self._config.get("personas", {}).get(speaker, {}) - backstory = self._persona_helper( - speaker, mem=self._mem, graph_dynamics=self._gd - ) agent = make_agent( role=f"{speaker} — {p.get('role', 'Engineer')}", @@ -1448,7 +1223,6 @@ def _handle_one_on_one( if i == 0: base_desc = ( f"You are {speaker}. You are in a private Slack DM with {other}.\n\n" - f"Both of you:\n{voice_cards}\n\n" f"Context: {ctx}\n\n" f"Open the conversation. Topics might include workload, sprint decisions, " f"or something personal-professional. Use your typing quirks. " @@ -1574,7 +1348,6 @@ def _handle_async_question( ticket = self._find_ticket(ticket_id) ticket_title = ticket["title"] if ticket else item.description - # Pick the channel — same dept = dept channel, cross-dept = digital-hq initial_participants = [asker] if collaborator: initial_participants.append(collaborator) @@ -1632,21 +1405,23 @@ def _handle_async_question( ) design_hint = self._mem.format_design_discussions_hint(discussions) - voice_cards = "\n\n".join(self._voice_card(n, "async") for n in all_actors) + backstory = get_voice_card(all_actors, "async", self._gd, self._mem) - # Build a natural speaker sequence for the JSON prompt responders = [a for a in all_actors if a != asker] turn_speakers = [asker] + responders if random.random() > 0.5 and responders: - turn_speakers.append(asker) # Asker sometimes follows up + turn_speakers.append(asker) speaker_sequence = ", ".join(turn_speakers) combined_hint = f"{doc_hint}\n\n{design_hint}" if design_hint else doc_hint agent = make_agent( role="Slack Conversation Simulator", - goal="Write a realistic casual Slack Q&A thread between coworkers.", - backstory="You write authentic workplace Slack conversations that reflect each person's distinct voice, typing quirks, and current mood.", + goal=( + "Write a realistic casual Slack Q&A thread between coworkers. " + "Treat the provided backstory as character reference sheets for the actors you are writing for." + ), + backstory=backstory, llm=self._worker, ) @@ -1655,7 +1430,6 @@ def _handle_async_question( f"COMPANY CONTEXT: {self._company} which {COMPANY_DESCRIPTION}\n" f"Write a full Slack thread where a colleague asks a question.\n\n" f"Topic: {ticket_title}\n" - f"Participants (Voice Cards):\n{voice_cards}\n\n" f"Relevant context: {ctx}\n" f"{combined_hint}\n\n" f"Turn order: {speaker_sequence}\n\n" @@ -1672,16 +1446,13 @@ def _handle_async_question( agent=agent, ) - raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()) - try: - clean = raw.replace("```json", "").replace("```", "").strip() - turns = _parse_turn_list(clean, "handle_async_question") - if not isinstance(turns, list): - turns = [] - except json.JSONDecodeError: + turns = _parse_turn_list(raw, "handle_async_question") + + if not turns: logger.warning( - "[async_question] Failed to parse JSON, falling back to empty thread." + "[async_question] Parsed turns are empty, falling back to empty thread." ) turns = [] @@ -1819,9 +1590,8 @@ def _handle_design_discussion( item.description, n=3, as_of_time=meeting_time_iso ) - voice_cards = "\n\n".join(self._voice_card(p, "design") for p in participants) + backstory = get_voice_card(participants, "async", self._gd, self._mem) - # Build turn sequence (5-8 turns total) turn_speakers = [initiator] + [ participants[i % len(participants)] for i in range(1, random.randint(5, 8)) ] @@ -1829,8 +1599,11 @@ def _handle_design_discussion( agent = make_agent( role="Slack Conversation Simulator", - goal="Write a realistic multi-turn Slack technical design discussion.", - backstory="You write authentic workplace Slack threads where engineers debate technical trade-offs based on their distinct personas.", + goal=( + "Write a realistic multi-turn Slack technical design discussion." + "Treat the provided backstory as character reference sheets for the actors you are writing for." + ), + backstory=backstory, llm=self._planner, ) @@ -1839,7 +1612,6 @@ def _handle_design_discussion( f"COMPANY CONTEXT: {self._company} which {COMPANY_DESCRIPTION}\n" f"Write a full Slack thread for a design discussion.\n\n" f"Topic: {item.description}\n" - f"Participants (Voice Cards):\n{voice_cards}\n\n" f"Relevant context: {ctx}\n\n" f"Turn order: {speaker_sequence}\n\n" f"Rules:\n" @@ -1855,16 +1627,13 @@ def _handle_design_discussion( agent=agent, ) - raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()) - try: - clean = raw.replace("```json", "").replace("```", "").strip() - turns = _parse_turn_list(clean, "handle_design_discussion") - if not isinstance(turns, list): - turns = [] - except json.JSONDecodeError: + turns = _parse_turn_list(raw, "handle_async_question") + + if not turns: logger.warning( - "[design_discussion] Failed to parse JSON, falling back to empty thread." + "[design_discussion] Parsed turns are empty, falling back to empty thread." ) turns = [] @@ -1987,22 +1756,22 @@ def _handle_mentoring( as_of_time=meeting_time_iso, ) - voice_cards = f"MENTOR:\n{self._voice_card(mentor, 'mentoring')}\n\nMENTEE:\n{self._voice_card(mentee, 'mentoring')}" + backstory = get_voice_card([mentor, mentee], "mentoring", self._gd, self._mem) agents, tasks, prev_task = [], [], None n_turns = self._turn_count([mentor, mentee], (3, 6)) - speakers = [mentor, mentee, mentor, mentee, mentor, mentee] + speakers = ([mentor, mentee] * ((n_turns // 2) + 1))[:n_turns] + shared_goal = "Treat the provided backstory as character reference sheets for the actor you are writing for." for i, speaker in enumerate(speakers[:n_turns]): - backstory = self._persona_helper( - speaker, mem=self._mem, graph_dynamics=self._gd - ) + is_last = i == n_turns - 1 is_mentor = speaker == mentor + agent = make_agent( role=f"{speaker} — {'Mentor' if is_mentor else 'Mentee'}", goal=( - f"Guide {mentee} thoughtfully as an experienced engineer." + f"Guide {mentee} thoughtfully as an experienced engineer. {shared_goal}" if is_mentor - else "Ask genuine questions and absorb guidance as someone still learning." + else f"Ask genuine questions and absorb guidance as someone still learning. {shared_goal}" ), backstory=backstory, llm=self._worker, @@ -2010,7 +1779,6 @@ def _handle_mentoring( if i == 0: desc = ( f"You are {mentor}, opening a mentoring DM with {mentee}.\n\n" - f"{voice_cards}\n\n" f"Context: {ctx}\n\n" f"Start the session — check in, then move toward a topic: career growth, " f"a technical concept, recent work feedback, or navigating a situation. " @@ -2028,9 +1796,18 @@ def _handle_mentoring( f"Ask a follow-up, show you're thinking it through, or push back gently " f"if something doesn't make sense. Format: {mentee}: [message]" ) + + if is_last: + desc += "\n\nCRITICAL: Since this is the final message, output a JSON object containing 'message' (your response) and 'summary' (a 1-sentence recap of what was discussed)." + expected_out = "JSON with 'message' and 'summary' keys." + else: + expected_out = ( + f"One message from {speaker} in format: {speaker}: [message]" + ) + task = Task( description=desc, - expected_output=f"One message from {speaker} in format: {speaker}: [message]", + expected_output=expected_out, agent=agent, context=[prev_task] if prev_task else [], ) @@ -2043,9 +1820,17 @@ def _handle_mentoring( ).kickoff() messages = [] + conversation_summary = None current_msg_time = datetime.fromisoformat(meeting_time_iso) - for speaker, task in zip(speakers[:n_turns], tasks): - text = (task.output.raw or "").strip() if task.output else "" + for idx, (speaker, task) in enumerate(zip(speakers[:n_turns], tasks)): + is_last = idx == len(tasks) - 1 + raw = (task.output.raw or "").strip() if task.output else "" + + if is_last: + text, conversation_summary = self._extract_last_turn(raw, speaker) + else: + text = raw + if text.lower().startswith(f"{speaker.lower()}:"): text = text[len(speaker) + 1 :].strip() if text: @@ -2063,6 +1848,20 @@ def _handle_mentoring( messages, channel, interaction_type="mentoring" ) + if conversation_summary: + self._mem.save_conversation_summary( + conv_type="mentoring", + participants=[mentor, mentee], + summary=conversation_summary, + day=self._state.day, + date=date_str, + timestamp=meeting_time_iso, + slack_thread_id=thread_id, + extra_facts={"related_ticket": item.related_id} + if item.related_id + else {}, + ) + # Mentoring is a strong relationship signal self._gd.record_slack_interaction([mentor, mentee]) self._gd.record_slack_interaction([mentor, mentee]) # double boost @@ -2126,12 +1925,8 @@ def _handle_collision_event(self, event: ProposedEvent, date_str: str): event.rationale, n=2, as_of_time=thread_start_iso ) - voice_cards = self._deduped_voice_cards(participants, "collision") + voice_cards = get_voice_card(participants, "collision", self._gd, self._mem) - # Tension-driven turn structure: - # high → initiator opens hard, others react defensively, more turns - # medium → back-and-forth negotiation - # low → collaborative, resolves quickly n_turns = { "high": random.randint(5, 8), "medium": random.randint(4, 6), @@ -2161,7 +1956,7 @@ def _handle_collision_event(self, event: ProposedEvent, date_str: str): agent = make_agent( role="Slack Conversation Simulator", - goal="Write a realistic multi-turn Slack exchange between coworkers.", + goal="Write a realistic multi-turn Slack exchange between coworkers. Treat the provided backstory as character reference sheets.", backstory="You write authentic workplace Slack conversations that reflect each person's distinct voice and the emotional arc of the situation.", llm=self._planner, ) @@ -2187,16 +1982,11 @@ def _handle_collision_event(self, event: ProposedEvent, date_str: str): ) raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() - try: - clean = raw.replace("```json", "").replace("```", "").strip() - turns = _parse_turn_list(clean, "handle_collision_event") - if not isinstance(turns, list): - turns = [] - except json.JSONDecodeError: + turns = _parse_turn_list(raw, "handle_collision_event") + if not turns: logger.warning( "[collision] Failed to parse JSON, falling back to empty thread." ) - turns = [] messages = [] current_msg_time = datetime.fromisoformat(thread_start_iso) @@ -2238,7 +2028,8 @@ def _emit_blocker_slack( date_str: str, timestamp: str, ) -> List[str]: - """Short Slack exchange when an engineer is blocked. + """ + Short Slack exchange when an engineer is blocked. Each participant speaks in their own voice via a dedicated Agent. """ from causal_chain_handler import CausalChainHandler @@ -2247,77 +2038,53 @@ def _emit_blocker_slack( channel = asker_dept.lower().replace(" ", "-") participants = [asker, collaborator] - voice_cards = ( - f"{self._voice_card(asker, 'dm')}\n\n{self._voice_card(collaborator, 'dm')}" - ) - - agents, tasks = [], [] + backstory = get_voice_card(participants, "dm", self._gd, self._mem) - # Turn 1 — asker announces the blocker - asker_backstory = self._persona_helper( - asker, mem=self._mem, graph_dynamics=self._gd + asker_role = ( + self._config.get("personas", {}) + .get(asker, {}) + .get("social_role", "Engineer") ) - asker_p = self._config.get("personas", {}).get(asker, {}) - asker_agent = make_agent( - role=f"{asker} — {asker_p.get('social_role', 'Engineer')}", - goal="Let your team know you're blocked and need help.", - backstory=asker_backstory, - llm=self._worker, + collab_role = ( + self._config.get("personas", {}) + .get(collaborator, {}) + .get("social_role", "Engineer") ) - asker_task = Task( - description=( - f"You are {asker}. You are blocked on [{ticket_id}]: {ticket_title}.\n\n" - f"Both of you:\n{voice_cards}\n\n" - f"Blocker: {blocker_text[:120]}\n\n" - f"Post a Slack message to {collaborator} explaining the blocker. " - f"Use your typing quirks and reflect your stress. " - f"1-2 sentences. Format: {asker}: [message]" - ), - expected_output=f"One Slack message from {asker} in format: {asker}: [message]", - agent=asker_agent, - context=[], - ) - agents.append(asker_agent) - tasks.append(asker_task) - collab_backstory = self._persona_helper( - collaborator, mem=self._mem, graph_dynamics=self._gd - ) - collab_p = self._config.get("personas", {}).get(collaborator, {}) - collab_agent = make_agent( - role=f"{collaborator} — {collab_p.get('social_role', 'Engineer')}", - goal="Respond to a blocked colleague and help unblock them.", - backstory=collab_backstory, + agent = make_agent( + role="Slack Conversation Simulator", + goal="Write a realistic 2-message Slack DM thread where a colleague asks for help with a blocker.", + backstory=backstory, llm=self._worker, ) - collab_task = Task( + + task = Task( description=( - f"You are {collaborator}. {asker} just messaged you about being " - f"blocked on [{ticket_id}]: {ticket_title}.\n\n" - f"Both of you:\n{voice_cards}\n\n" - f"Reply naturally — acknowledge the blocker, offer to help, " - f"ask a clarifying question, or suggest who can unblock them. " - f"Use your typing quirks. 1-2 sentences. " - f"Format: {collaborator}: [message]" + f"Write a 2-message Slack DM exchange between {asker} ({asker_role}) and {collaborator} ({collab_role}).\n\n" + f"Context: {asker} is blocked on [{ticket_id}]: {ticket_title}.\n" + f"Blocker details: {blocker_text[:120]}\n\n" + f"Rules:\n" + f"- Turn 1: {asker} reaches out to {collaborator} explaining the blocker. They should sound appropriately stressed/blocked.\n" + f"- Turn 2: {collaborator} replies naturally—acknowledging, offering help, asking a clarifying question, or redirecting them.\n" + f"- Treat the provided backstory as strict character reference sheets. They MUST sound like different people and use their typing quirks.\n" + f"- Each message 1-2 sentences max. No narration.\n\n" + f"CRITICAL: Respond ONLY with a JSON array containing the two messages. No markdown fences.\n" + f'[{{"speaker": "Name", "message": "text"}}, {{"speaker": "Name", "message": "text"}}]' ), - expected_output=f"One Slack message from {collaborator} in format: {collaborator}: [message]", - agent=collab_agent, - context=[asker_task], + expected_output='A JSON array with "speaker" and "message" keys.', + agent=agent, ) - agents.append(collab_agent) - tasks.append(collab_task) - Crew( - agents=agents, tasks=tasks, process=Process.sequential, verbose=False - ).kickoff() + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()) + + turns = _parse_turn_list(raw, "handle_blocker") - blocker_speakers = [asker, collaborator] messages = [] current_msg_time = datetime.fromisoformat(timestamp) - for speaker, task in zip(blocker_speakers, tasks): - text = (task.output.raw or "").strip() if task.output else "" - if text.lower().startswith(f"{speaker.lower()}:"): - text = text[len(speaker) + 1 :].strip() + + for turn in turns: + speaker = turn.get("speaker") + text = turn.get("message", "").strip() if text: messages.append( {"user": speaker, "text": text, "ts": current_msg_time.isoformat()} @@ -2379,6 +2146,11 @@ def _emit_blocker_slack( ) ) + logger.info( + f" [dim]🚧 Blocker reported: {blocker_text[:80]}... " + f"({asker} pinged {collaborator})[/dim]" + ) + return participants def _emit_completion_email( @@ -2399,37 +2171,51 @@ def _emit_completion_email( dept = dept_of_name(assignee, self._org_chart) lead = self._find_lead_for(assignee) or assignee + backstory = get_voice_card(assignee, "async", self._gd, self._mem) + + p = self._config.get("personas", {}).get(assignee, {}) + agent = make_agent( - role=f"{assignee}", + role=f"{assignee} — {p.get('social_role', 'Engineer')}", goal="Write a brief internal email updating your lead on completed work.", - backstory=self._persona_helper( - assignee, mem=self._mem, graph_dynamics=self._gd - ), + backstory=backstory, llm=self._worker, ) + task = Task( description=( f"You are {assignee}. You just completed ticket [{ticket_id}]: {ticket_title}.\n\n" f"Write a short internal email to {lead} (your lead) summarising what you did.\n" f"What you did: {comment_text}\n\n" f"Rules:\n" - f"- Subject line: Re: [{ticket_id}] {ticket_title[:60]}\n" + f"- Subject line must include the ticket ID.\n" f"- Body: 2-3 sentences. What was done, any key decision or outcome.\n" f"- Sign off with your name.\n" - f"- No preamble. Output subject and body only.\n\n" - f"Format:\nSubject: \n\n" + f"- Use your typing quirks and current mood.\n\n" + f"CRITICAL: Respond ONLY with a JSON object containing 'subject' and 'body' keys. No markdown fences, no preamble.\n" + f"- CRITICAL: Keep the tone accessible. Avoid deep technical jargon, as the recipient may be non-technical.\n" + f'{{\n "subject": "Re: [{ticket_id}] ...",\n "body": "..."\n}}' ), - expected_output="Subject line followed by a blank line and 2-3 sentence body.", + expected_output='A JSON object with "subject" and "body" keys.', agent=agent, ) - raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() - # Parse subject / body - lines = raw.split("\n", 2) - subject = ( - lines[0].replace("Subject:", "").strip() if lines else f"Re: [{ticket_id}]" - ) - body = lines[2].strip() if len(lines) > 2 else raw + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()) + + clean = raw.replace("```json", "").replace("```", "").strip() + + try: + email_data = json_repair.loads(clean) + + subject = email_data.get( + "subject", f"Re: [{ticket_id}] {ticket_title[:60]}" + ) + body = email_data.get("body", clean) + + except Exception as e: + logger.warning(f"[email_generation] Failed to parse JSON email: {e}") + subject = f"Re: [{ticket_id}] Update" + body = clean thread_id = f"email_{ticket_id}_{self._state.day}" @@ -2454,38 +2240,21 @@ def _emit_completion_email( ) ) - if self._embed_worker: - self._embed_worker.enqueue( - id=thread_id, - type="email", - title=subject, - content=f"From: {assignee}\nTo: {lead}\nSubject: {subject}\n\n{body}", - day=self._state.day, - date=date_str, - timestamp=timestamp, - metadata={ - "ticket_id": ticket_id, - "from": assignee, - "to": lead, - "dept": dept, - }, - ) - else: - self._mem.embed_artifact( - id=thread_id, - type="email", - title=subject, - content=f"From: {assignee}\nTo: {lead}\nSubject: {subject}\n\n{body}", - day=self._state.day, - date=date_str, - timestamp=timestamp, - metadata={ - "ticket_id": ticket_id, - "from": assignee, - "to": lead, - "dept": dept, - }, - ) + self._mem.embed_artifact( + id=thread_id, + type="email", + title=subject, + content=f"From: {assignee}\nTo: {lead}\nSubject: {subject}\n\n{body}", + day=self._state.day, + date=date_str, + timestamp=timestamp, + metadata={ + "ticket_id": ticket_id, + "from": assignee, + "to": lead, + "dept": dept, + }, + ) logger.info( f" [dim]📧 {assignee} → {lead}: completion email for [{ticket_id}][/dim]" @@ -2502,10 +2271,14 @@ def _emit_review_reply( timestamp: str, ) -> Tuple[List[str], str]: """Author replies to a review question in #engineering.""" + + backstory = get_voice_card(author, "async", self._gd, self._mem) + p = self._config.get("personas", {}).get(author, {}) + agent = make_agent( - role="PR Author", - goal="Reply to a code review question.", - backstory=f"You are {author}. {self._gd.stress_tone_hint(author)}", + role=f"{author} — {p.get('social_role', 'Engineer')}", + goal="Reply to a code review question naturally in your own voice.", + backstory=backstory, llm=self._worker, ) task = Task( @@ -2514,7 +2287,7 @@ def _emit_review_reply( f"Output format: {author}: [your reply]\n" f"Length: 1-2 sentences only. No preamble.\n\n" f"Their comment: {review_text[:120]}\n\n" - f"Answer their question, clarify your intent, or push back if you disagree." + f"Answer their question, clarify your intent, or push back if you disagree. Use your typing quirks." ), expected_output=( f"One line only: '{author}: [reply]'. No preamble, no extra lines." @@ -2709,17 +2482,15 @@ def _trigger_watercooler_chat(self, target_actor: str, date_str: str) -> None: Crew(agents=[topic_agent], tasks=[topic_task], verbose=False).kickoff() ).strip() - # ── Build rich per-person voice cards from personas ─────────────────── - voice_cards = self._deduped_voice_cards(participants, "watercooler") + voice_cards = get_voice_card(participants, "watercooler", self._gd, self._mem) - # ── One-shot full conversation generation ──────────────────────────── speaker_sequence = ", ".join( participants[i % len(participants)] for i in range(len(participants) + 1) ) agent = make_agent( role="Slack Conversation Simulator", - goal="Write a realistic casual Slack conversation between coworkers.", + goal="Write a realistic casual Slack conversation between coworkers. Treat the provided backstory as character reference sheets.", backstory="You write authentic workplace small-talk that reflects each person's distinct personality and current mood.", llm=self._worker, ) @@ -2742,16 +2513,11 @@ def _trigger_watercooler_chat(self, target_actor: str, date_str: str) -> None: ) raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() - try: - clean = raw.replace("```json", "").replace("```", "").strip() - turns = _parse_turn_list(clean, "trigger_watercooler_chat") - if not isinstance(turns, list): - turns = [] - except json.JSONDecodeError: + turns = _parse_turn_list(raw, "trigger_watercooler_chat") + if not turns: logger.warning( "[watercooler] Failed to parse JSON, falling back to empty thread." ) - turns = [] messages = [] current_msg_time = datetime.fromisoformat(thread_start_iso) @@ -2770,9 +2536,6 @@ def _trigger_watercooler_chat(self, target_actor: str, date_str: str) -> None: current_msg_time += timedelta(minutes=random.randint(1, 4)) if messages: - # 2 people → dm_alice_bob - # 3-4 people → dm_alice_bob_carol (small group DM, still personal) - # 5+ people → random (large enough to be channel-like) n = len(participants) if n >= 5: channel = "random" @@ -3157,24 +2920,38 @@ def dept_of_name(name: str, org_chart: Dict[str, List[str]]) -> str: return "Unknown" +TURN_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "properties": {"speaker": {"type": "string"}, "message": {"type": "string"}}, + "required": ["speaker", "message"], + }, +} + + def _parse_turn_list(raw: str, caller: str) -> list: """ - Robustly extract a JSON array of turn dicts from an LLM response. - Uses json_repair to handle malformed LLM output before parsing. - Returns a list (empty on total failure). + Robustly extract a JSON array of turn dicts from an LLM response + using schema-guided repair. """ clean = raw.replace("```json", "").replace("```", "").strip() try: - parsed = json_repair.loads(clean) + parsed = json_repair.loads( + clean, schema=TURN_SCHEMA, schema_repair_mode="salvage" + ) + if isinstance(parsed, list): return parsed - # Unwrap if LLM wrapped the array in an object - if isinstance(parsed, dict): - for v in parsed.values(): - if isinstance(v, list): - return v + + return [] + + except ValueError as e: + logger.warning( + f"[{caller}] LLM output completely failed schema validation: {e}" + ) return [] - except Exception: - logger.warning(f"[{caller}] Failed to parse JSON turn list — empty thread.") + except Exception as e: + logger.warning(f"[{caller}] Unexpected parsing failure: {e}") return [] diff --git a/src/org_lifecycle.py b/src/org_lifecycle.py index 953f60f..ad7a76f 100644 --- a/src/org_lifecycle.py +++ b/src/org_lifecycle.py @@ -22,28 +22,6 @@ 3. Active incident handoff — if the departing engineer is the named responder on any live incident, ownership is forcibly transferred to the next person in the Dijkstra escalation chain before the node is removed. - -Config schema (add to config.yaml under "org_lifecycle"): - org_lifecycle: - scheduled_departures: - - name: "Jordan" - day: 15 - reason: "voluntary" # voluntary | layoff | performance - role: "Senior Backend Engineer" - knowledge_domains: ["auth-service", "redis-cache"] - documented_pct: 0.30 - scheduled_hires: - - name: "Taylor" - day: 18 - dept: "Engineering" - role: "Backend Engineer" - expertise: ["Python", "Kafka"] - style: "methodical, asks lots of questions" - tenure: "new" - enable_random_attrition: false - random_attrition_daily_prob: 0.01 - # Stress points applied per unit of centrality gained after a departure - centrality_vacuum_stress_multiplier: 40 """ from __future__ import annotations @@ -62,11 +40,6 @@ logger = logging.getLogger("orgforge.lifecycle") -# ───────────────────────────────────────────────────────────────────────────── -# DATA TYPES -# ───────────────────────────────────────────────────────────────────────────── - - @dataclass class DepartureRecord: """Immutable record of an engineer who has left the organisation.""" @@ -75,13 +48,12 @@ class DepartureRecord: dept: str role: str day: int - reason: str # voluntary | layoff | performance + reason: str knowledge_domains: List[str] documented_pct: float peak_stress: int - edge_snapshot: Dict[str, float] # {neighbour: weight} before removal + edge_snapshot: Dict[str, float] centrality_at_departure: float = 0.0 - # Populated by the departure engine's side-effect methods reassigned_tickets: List[str] = field(default_factory=list) incident_handoffs: List[str] = field(default_factory=list) @@ -97,7 +69,7 @@ class HireRecord: expertise: List[str] style: str tenure: str = "new" - warmup_threshold: float = 2.0 # edges below this = "not yet collaborating" + warmup_threshold: float = 2.0 @dataclass @@ -109,11 +81,6 @@ class KnowledgeGapEvent: documented_pct: float -# ───────────────────────────────────────────────────────────────────────────── -# MANAGER -# ───────────────────────────────────────────────────────────────────────────── - - class OrgLifecycleManager: """ Owns all mutations to org_chart, personas, GraphDynamics, and the @@ -732,12 +699,10 @@ def _execute_hire( "expertise": expertise, "tenure": tenure, "stress": 20, - "social_role": hire_cfg.get( - "social_role", "The Reliable Contributor" - ), # Capture from config + "social_role": hire_cfg.get("social_role", "The Reliable Contributor"), "typing_quirks": hire_cfg.get( "typing_quirks", "Standard professional grammar." - ), # Capture from config + ), } persona_data = self._personas.get(name, {}) @@ -801,7 +766,7 @@ def _execute_hire( hire_time = clock.schedule_meeting( [name], min_hour=9, max_hour=10, duration_mins=30 ) - # Ensure it doesn't roll back before 09:30 + if hire_time.minute < 30 and hire_time.hour == 9: hire_time = hire_time.replace(minute=random.randint(30, 59)) @@ -847,12 +812,10 @@ def _schedule_backfill(self, record: DepartureRecord, current_day: int) -> None: """ backfill_cfg = self._cfg.get("backfill", {}) - # Which departure reasons trigger a backfill attempt reasons_that_backfill = backfill_cfg.get("trigger_reasons", ["voluntary"]) if record.reason not in reasons_that_backfill: return - # Layoffs explicitly blocked from backfill if record.reason == "layoff": return @@ -863,7 +826,6 @@ def _schedule_backfill(self, record: DepartureRecord, current_day: int) -> None: if name is None: return - # Build a minimal hire config from the departing person's persona departed_persona = self._personas.get(record.name, {}) backfill_hire = { "name": backfill_cfg.get("name_prefix", "NewHire") + f"_{hire_day}", @@ -873,7 +835,7 @@ def _schedule_backfill(self, record: DepartureRecord, current_day: int) -> None: "style": "still ramping up, asks frequent questions", "tenure": "new", "day": hire_day, - "_backfill_for": record.name, # metadata only — for SimEvent logging + "_backfill_for": record.name, } self._scheduled_hires.setdefault(hire_day, []).append(backfill_hire) @@ -922,7 +884,6 @@ def _generate_backfill_name(self, dept: str, role: str) -> Optional[str]: Crew(agents=[agent], tasks=[task], verbose=False).kickoff() ).strip() - # Strip any punctuation the LLM smuggled in name = " ".join(raw.split()) name = "".join(c for c in name if c.isalpha() or c == " ").strip() @@ -947,8 +908,6 @@ def _generate_backfill_name(self, dept: str, role: str) -> Optional[str]: ) return None - # ─── Private helpers ────────────────────────────────────────────────────── - def _count_warm_edges(self, hire: HireRecord) -> int: G = self._gd.G if not G.has_node(hire.name): @@ -960,11 +919,6 @@ def _count_warm_edges(self, hire: HireRecord) -> int: ) -# ───────────────────────────────────────────────────────────────────────────── -# HELPERS — called from flow.py -# ───────────────────────────────────────────────────────────────────────────── - - def patch_validator_for_lifecycle( validator, lifecycle_mgr: OrgLifecycleManager ) -> None: diff --git a/src/plan_validator.py b/src/plan_validator.py index ae64005..f679ad0 100644 --- a/src/plan_validator.py +++ b/src/plan_validator.py @@ -30,13 +30,7 @@ _DEPARTED_NAMES: set = set() -# ───────────────────────────────────────────────────────────────────────────── -# PLAUSIBILITY RULES -# Each rule is a (condition, rejection_reason) pair. -# Rules are evaluated in order — first failure rejects the event. -# ───────────────────────────────────────────────────────────────────────────── -# Events that are inappropriate when system health is critically low _BLOCKED_WHEN_CRITICAL = { "team_celebration", "hackathon", @@ -44,7 +38,7 @@ "deep_work_session", } -# Events that require at least one incident to have occurred recently + _REQUIRES_PRIOR_INCIDENT = { "postmortem_created", "escalation_chain", @@ -52,7 +46,7 @@ "customer_escalation", } -# Minimum days between repeated firings of the same event type + _COOLDOWN_DAYS: Dict[str, int] = { "retrospective": 9, "sprint_planned": 9, @@ -89,26 +83,21 @@ def __init__( ): self._valid_actors: Set[str] = set(all_names) | set(external_contact_names) self._config = config - self._novel_log: List[ProposedEvent] = [] # accumulates for SimEvent logging - - # ─── PUBLIC ────────────────────────────────────────────────────────────── + self._novel_log: List[ProposedEvent] = [] def validate_plan( self, proposed: List[ProposedEvent], - state, # flow.State — avoids circular import - recent_events: List[dict], # last N day_summary facts dicts + state, + recent_events: List[dict], ) -> List[ValidationResult]: """ Validate every ProposedEvent in the plan. Returns ValidationResult for each — caller logs rejections as SimEvents. """ - # Build cooldown tracker from recent events + recent_event_types = self._recent_event_types(recent_events) recent_incident_count = sum(e.get("incidents_opened", 0) for e in recent_events) - # Live per-ticket actor tracking for today — read from state, not summaries. - # state.ticket_actors_today is populated by flow.py as ticket_progress - # events execute, and reset to {} at the top of each daily_cycle(). ticket_actors_today = self._ticket_actors_today(state) results: List[ValidationResult] = [] @@ -144,20 +133,15 @@ def drain_novel_log(self) -> List[ProposedEvent]: self._novel_log.clear() return novel - # ─── PRIVATE ───────────────────────────────────────────────────────────── - def _validate_one( self, event: ProposedEvent, state, - recent_event_types: Dict[str, int], # {event_type: days_since_last} + recent_event_types: Dict[str, int], recent_incident_count: int, - ticket_actors_today: Dict[ - str, set - ], # {ticket_id: {actors who touched it today}} + ticket_actors_today: Dict[str, set], ) -> ValidationResult: - # ── 1. Actor integrity ──────────────────────────────────────────────── unknown_actors = [a for a in event.actors if a not in self._valid_actors] if unknown_actors: return ValidationResult( @@ -167,9 +151,6 @@ def _validate_one( f"LLM invented names not in org_chart.", ) - # ── 1b. Departed-actor guard ────────────────────────────────────────── - # patch_validator_for_lifecycle() keeps _valid_actors pruned, - # but this explicit check gives a clearer rejection message. departed_actors = [a for a in event.actors if a in _DEPARTED_NAMES] if departed_actors: return ValidationResult( @@ -181,11 +162,7 @@ def _validate_one( ), ) - # ── 2. Novel event type ─────────────────────────────────────────────── if event.event_type not in KNOWN_EVENT_TYPES: - # Novel events are approved if they name a known artifact type. - # This allows the engine to generate something even without - # a bespoke handler — it falls back to a Slack summary. if event.artifact_hint in {"slack", "jira", "confluence", "email"}: logger.info( f" [cyan]✨ Novel event approved (fallback artifact):[/cyan] " @@ -203,7 +180,6 @@ def _validate_one( ), ) - # ── 3. State plausibility ───────────────────────────────────────────── if event.event_type in _BLOCKED_WHEN_CRITICAL and state.system_health < 40: return ValidationResult( approved=False, @@ -224,7 +200,6 @@ def _validate_one( ), ) - # ── 4. Cooldown window ──────────────────────────────────────────────── cooldown = _COOLDOWN_DAYS.get(event.event_type) if cooldown: days_since = recent_event_types.get(event.event_type, 999) @@ -238,7 +213,6 @@ def _validate_one( ), ) - # ── 5. Morale-gated events ──────────────────────────────────────────── if event.event_type == "morale_intervention" and state.team_morale > 0.6: return ValidationResult( approved=False, @@ -249,10 +223,6 @@ def _validate_one( ), ) - # ── 6. Ticket dedup ─────────────────────────────────────────────────── - # Prevents multiple agents independently logging progress on the same - # ticket on the same day. ticket_id is sourced from facts_hint, not - # related_id (which lives on AgendaItem, not ProposedEvent). if event.event_type == "ticket_progress": ticket_id = (event.facts_hint or {}).get("ticket_id") if ticket_id: @@ -268,7 +238,6 @@ def _validate_one( ), ) - # ── All checks passed ───────────────────────────────────────────────── return ValidationResult(approved=True, event=event) def _recent_event_types(self, recent_summaries: List[dict]) -> Dict[str, int]: @@ -278,11 +247,10 @@ def _recent_event_types(self, recent_summaries: List[dict]) -> Dict[str, int]: """ days_since: Dict[str, int] = {} for i, summary in enumerate(reversed(recent_summaries)): - # dominant_event is the richest single signal per day dominant = summary.get("dominant_event") if dominant and dominant not in days_since: days_since[dominant] = i + 1 - # event_type_counts gives the full picture + for etype in summary.get("event_type_counts", {}).keys(): if etype not in days_since: days_since[etype] = i + 1 diff --git a/src/planner_models.py b/src/planner_models.py index 794863d..87cbfff 100644 --- a/src/planner_models.py +++ b/src/planner_models.py @@ -12,11 +12,6 @@ from typing import Dict, List, Optional, Any -# ───────────────────────────────────────────────────────────────────────────── -# ENGINEER-LEVEL AGENDA -# ───────────────────────────────────────────────────────────────────────────── - - @dataclass class AgendaItem: """ @@ -24,15 +19,13 @@ class AgendaItem: These are intentions, not guarantees — incidents can compress or defer them. """ - activity_type: str # "ticket_progress", "pr_review", "design_doc", - # "1on1", "async_question", "mentoring", "deep_work" - description: str # human-readable, e.g. "Continue ORG-101 retry logic" - related_id: Optional[str] = None # ticket ID, PR ID, or Confluence ID if applicable - collaborator: List[str] = field( - default_factory=list - ) # engineers this touches (drives Slack) - estimated_hrs: float = 2.0 # rough time weight — used to detect overload - deferred: bool = False # set True by incident pressure at runtime + activity_type: str + + description: str + related_id: Optional[str] = None + collaborator: List[str] = field(default_factory=list) + estimated_hrs: float = 2.0 + deferred: bool = False defer_reason: Optional[str] = None @@ -46,11 +39,9 @@ class EngineerDayPlan: name: str dept: str agenda: List[AgendaItem] - stress_level: int # from graph_dynamics._stress at plan time + stress_level: int is_on_call: bool = False - focus_note: str = ( - "" # one-sentence LLM colour — e.g. "Jax is in heads-down mode today" - ) + focus_note: str = "" @property def capacity_hrs(self) -> float: @@ -74,7 +65,7 @@ def apply_incident_pressure(self, incident_title: str, hrs_lost: float = 3.0): Defers low-priority items until capacity is restored. """ freed = 0.0 - # Defer in reverse-priority order: deep_work and design_doc first + defer_order = [ "deep_work", "design_doc", @@ -96,11 +87,6 @@ def apply_incident_pressure(self, incident_title: str, hrs_lost: float = 3.0): break -# ───────────────────────────────────────────────────────────────────────────── -# DEPARTMENT-LEVEL PLAN -# ───────────────────────────────────────────────────────────────────────────── - - @dataclass class CrossDeptSignal: """ @@ -112,7 +98,7 @@ class CrossDeptSignal: event_type: str summary: str day: int - relevance: str # "direct" | "indirect" — how strongly it shapes today + relevance: str @dataclass @@ -123,9 +109,9 @@ class LifecycleContext: can propose onboarding_session or warmup_1on1 events naturally. """ - recent_departures: List[Dict[str, Any]] # [{name, dept, day, domains}] - recent_hires: List[Dict[str, Any]] # [{name, dept, day, role}] - active_gaps: List[str] # knowledge domain strings still unresolved + recent_departures: List[Dict[str, Any]] + recent_hires: List[Dict[str, Any]] + active_gaps: List[str] @dataclass @@ -161,15 +147,13 @@ class ProposedEvent: Must pass PlanValidator before execution. """ - event_type: str # known type OR novel proposal - actors: List[str] # must match real names in org_chart/external_contacts - rationale: str # one sentence — why now - facts_hint: Dict[str, Any] # seed facts for the executor — NOT invented by LLM - priority: int # 1=must fire, 2=fire if capacity, 3=opportunistic - is_novel: bool = ( - False # True if LLM proposed an event type not in KNOWN_EVENT_TYPES - ) - artifact_hint: Optional[str] = None # e.g. "slack", "jira", "confluence", "email" + event_type: str + actors: List[str] + rationale: str + facts_hint: Dict[str, Any] + priority: int + is_novel: bool = False + artifact_hint: Optional[str] = None @dataclass @@ -180,19 +164,14 @@ class DepartmentDayPlan: """ dept: str - theme: str # dept-specific theme (not org theme) - engineer_plans: List[EngineerDayPlan] # one per dept member - proposed_events: List[ProposedEvent] # ordered by priority - cross_dept_signals: List[CrossDeptSignal] # what influenced this plan - planner_reasoning: str # LLM chain-of-thought — for researchers + theme: str + engineer_plans: List[EngineerDayPlan] + proposed_events: List[ProposedEvent] + cross_dept_signals: List[CrossDeptSignal] + planner_reasoning: str day: int date: str - sprint_context: Optional["SprintContext"] = None # populated by TicketAssigner - - -# ───────────────────────────────────────────────────────────────────────────── -# ORG-LEVEL PLAN -# ───────────────────────────────────────────────────────────────────────────── + sprint_context: Optional["SprintContext"] = None @dataclass @@ -203,8 +182,8 @@ class OrgDayPlan: """ org_theme: str - dept_plans: Dict[str, DepartmentDayPlan] # keyed by dept name - collision_events: List[ProposedEvent] # cross-dept interactions + dept_plans: Dict[str, DepartmentDayPlan] + collision_events: List[ProposedEvent] coordinator_reasoning: str day: int date: str @@ -213,7 +192,7 @@ class OrgDayPlan: def all_events_by_priority(self) -> List[ProposedEvent]: """Flat list of all events across departments, sorted priority → dept (Eng first).""" all_events = list(self.collision_events) - # Engineering fires first — it's the primary driver + for dept in sorted( self.dept_plans.keys(), key=lambda d: 0 if "eng" in d.lower() else 1 ): @@ -221,11 +200,6 @@ def all_events_by_priority(self) -> List[ProposedEvent]: return sorted(all_events, key=lambda e: e.priority) -# ───────────────────────────────────────────────────────────────────────────── -# VALIDATION RESULT -# ───────────────────────────────────────────────────────────────────────────── - - @dataclass class ValidationResult: approved: bool @@ -234,21 +208,13 @@ class ValidationResult: was_novel: bool = False -# ───────────────────────────────────────────────────────────────────────────── -# KNOWN EVENT TYPES -# The validator uses this to flag novel proposals vs known ones. -# Add to this list as the engine gains new capabilities. -# ───────────────────────────────────────────────────────────────────────────── - KNOWN_EVENT_TYPES = { - # Engineering — operational "incident_opened", "incident_resolved", "escalation_chain", "fix_in_progress", "postmortem_created", "knowledge_gap_detected", - # Engineering — routine "standup", "pr_review", "ticket_progress", @@ -256,22 +222,18 @@ class ValidationResult: "async_question", "code_review_comment", "deep_work_session", - # Sprint "sprint_planned", "retrospective", "sprint_goal_updated", - # Cross-dept "leadership_sync", "feature_request_from_sales", "stability_update_to_sales", "hr_checkin", "morale_intervention", "1on1_scheduled", - # External "external_contact_summarized", "vendor_meeting", "customer_escalation", - # Org "normal_day_slack", "confluence_created", "day_summary", diff --git a/src/sim_clock.py b/src/sim_clock.py index 638482e..9170a9b 100644 --- a/src/sim_clock.py +++ b/src/sim_clock.py @@ -27,19 +27,19 @@ logger = logging.getLogger("orgforge.simclock") if TYPE_CHECKING: - pass # State imported at runtime to avoid circular imports + pass + -# ── Business hours config ───────────────────────────────────────────────────── DAY_START_HOUR = 9 DAY_START_MINUTE = 0 DAY_END_HOUR = 17 DAY_END_MINUTE = 30 -# ── Per-cadence reply spacing (minutes) ────────────────────────────────────── + CADENCE_RANGES = { - "incident": (1, 4), # urgent — tight back-and-forth - "normal": (3, 12), # standard conversation pace - "async": (10, 35), # heads-down work, slow replies + "incident": (1, 4), + "normal": (3, 12), + "async": (10, 35), } @@ -54,8 +54,6 @@ def __init__(self, state): if not hasattr(self._state, "actor_cursors"): self._state.actor_cursors = {} - # ── Public API ──────────────────────────────────────────────────────────── - def reset_to_business_start(self, all_actors: List[str]) -> None: """ Call at the top of each day in daily_cycle(). @@ -64,10 +62,8 @@ def reset_to_business_start(self, all_actors: List[str]) -> None: """ base = self._get_default_start() - # Reset all human actors self._state.actor_cursors = {a: base for a in all_actors} - # Reset a dedicated system cursor for independent bot alerts self._state.actor_cursors["system"] = base def advance_actor(self, actor: str, hours: float) -> tuple[datetime, datetime]: @@ -104,17 +100,15 @@ def sync_and_tick( start until the busiest person is free), moves everyone to that time, and advances by a random delta. """ - # 1. Sync everyone to the maximum cursor + synced_time = self._sync_time(actors) - # 2. Tick forward from the synced time delta = timedelta(minutes=random.randint(min_mins, max_mins)) candidate = synced_time + delta if not allow_after_hours: candidate = self._enforce_business_hours(candidate) - # 3. Update all participants for a in actors: self._set_cursor(a, candidate) @@ -152,12 +146,9 @@ def at( meeting_end = meeting_start + timedelta(minutes=duration_mins) for a in actors: - # Only advance people who are behind the meeting's end time. - # If someone is already past it, they "missed" it, but we don't time-travel them back. if self._get_cursor(a) < meeting_end: self._set_cursor(a, meeting_end) - # The artifact itself is stamped exactly when scheduled return meeting_start def now(self, actor: str) -> datetime: @@ -171,8 +162,6 @@ def sync_to_system(self, actors: List[str]) -> datetime: """ sys_time = self._get_cursor("system") for a in actors: - # If they are behind the system alert, pull them forward to it. - # If they are somehow ahead, the incident interrupts their future task. if self._get_cursor(a) < sys_time: self._set_cursor(a, sys_time) return sys_time @@ -184,11 +173,10 @@ def schedule_meeting( Schedules a ceremony within a specific window and syncs all participants to it. Example: schedule_meeting(leads, 9, 11) for Sprint Planning. """ - # Pick a random top-of-the-hour or half-hour slot in the window + hour = random.randint(min_hour, max(min_hour, max_hour - 1)) minute = random.choice([0, 15, 30, 45]) - # Use the at() method we defined earlier to pin the actors return self.at(actors, hour, minute, duration_mins) def sync_and_advance( @@ -199,21 +187,17 @@ def sync_and_advance( then advances all of them by the specified hours. Returns (meeting_start_time, new_cursor_horizon). """ - # 1. Find the latest time among all participants + start_time = self._sync_time(actors) - # 2. Calculate the end time based on the agenda item's duration delta = timedelta(hours=hours) end_time = self._enforce_business_hours(start_time + delta) - # 3. Fast-forward everyone to the end of the meeting for a in actors: self._set_cursor(a, end_time) return start_time, end_time - # ── Private ─────────────────────────────────────────────────────────────── - def _get_default_start(self) -> datetime: """Returns 09:00 on the current State date.""" return self._state.current_date.replace( @@ -259,7 +243,6 @@ def _enforce_business_hours(self, dt: datetime) -> datetime: if dt <= end_of_day and dt.weekday() < 5: return dt - # Roll forward to next business day 09:00 next_day = dt + timedelta(days=1) max_skip = 7 for _ in range(max_skip): diff --git a/src/ticket_assigner.py b/src/ticket_assigner.py index dad2cd2..897c755 100644 --- a/src/ticket_assigner.py +++ b/src/ticket_assigner.py @@ -83,19 +83,12 @@ def __init__(self, config: dict, graph_dynamics: GraphDynamics, mem: Memory): self._mem = mem self._base = config["simulation"].get("output_dir", "./export") - # MongoDB collection for cached ticket-title embeddings. - # Separate from jira_tickets so we never pollute the ticket docs. self._skill_embed_cache = mem._db["ticket_skill_embeddings"] self._skill_embed_cache.create_index([("ticket_id", 1)], unique=True) - # Engineer expertise vectors — computed once, reused every sprint. - # Keys added lazily in _expertise_vector() so new-hire personas that - # arrive mid-sim (org_lifecycle.scheduled_hires) are handled naturally. self._engineer_vectors: Dict[str, List[float]] = {} self._precompute_engineer_vectors() - # ── Public ──────────────────────────────────────────────────────────────── - def build( self, state, dept_members: List[str], dept_name: str = "" ) -> SprintContext: @@ -110,12 +103,10 @@ def build( """ capacity = self._compute_capacity(dept_members, state) - # Tickets assigned to this department but not yet done open_tickets = self._mem.get_open_tickets_for_dept( dept_members, dept_name=dept_name ) - # Tickets in the sprint with no assignee yet (newly created this sprint) unassigned = list( self._mem._jira.find( {"assignee": None, "dept": dept_name, "status": {"$ne": "Done"}}, @@ -123,7 +114,6 @@ def build( ) ) - # Already-owned tickets stay owned — we only re-assign unassigned ones owned: Dict[str, str] = { t["id"]: t["assignee"] for t in open_tickets @@ -165,8 +155,6 @@ def build( in_review=in_review, ) - # ── Capacity ────────────────────────────────────────────────────────────── - def _compute_capacity(self, members: List[str], state) -> Dict[str, float]: """ Available hours per engineer, mirroring EngineerDayPlan.capacity_hrs @@ -186,8 +174,6 @@ def _compute_capacity(self, members: List[str], state) -> Dict[str, float]: capacity[name] = max(base, 1.5) return capacity - # ── Assignment ──────────────────────────────────────────────────────────── - def _assign( self, tickets: List[dict], @@ -235,13 +221,13 @@ def _hungarian_assign( stress = self._gd._stress.get(eng, 30) stress_score = 1.0 - (stress / 100) cent = centrality.get(eng, 0.0) - cent_factor = 1.0 - (cent * 0.3) # key players penalised 0–30% + cent_factor = 1.0 - (cent * 0.3) for j, ticket in enumerate(tickets): skill = self._skill_score(eng, ticket) recency = 1.2 if ticket["id"] in ticket_history.get(eng, set()) else 1.0 score = skill * stress_score * cent_factor * recency - cost[i][j] = -score # negate → minimise + cost[i][j] = -score row_ind, col_ind = linear_sum_assignment(cost) @@ -252,13 +238,12 @@ def _hungarian_assign( eng = members[i] tkt = tickets[j] pts = tkt.get("story_points", 2) - est_hrs = pts * 0.75 # rough 0.75 hr/point heuristic + est_hrs = pts * 0.75 if assigned_eng_load[eng] + est_hrs <= capacity[eng]: result[tkt["id"]] = eng assigned_eng_load[eng] += est_hrs else: - # Over capacity — leave ticket unassigned this day logger.debug(f"[assigner] {eng} over capacity, skipping {tkt['id']}") return result @@ -278,7 +263,7 @@ def _greedy_assign( for ticket in tickets: pts = ticket.get("story_points", 2) est_hrs = pts * 0.75 - # Try each member starting from current round-robin position + for offset in range(len(members)): eng = members[(idx + offset) % len(members)] if load[eng] + est_hrs <= capacity[eng]: @@ -288,8 +273,6 @@ def _greedy_assign( break return result - # ── Skill scoring (embedding-based) ────────────────────────────────────── - def _skill_score(self, engineer: str, ticket: dict) -> float: """ Returns a [0.5, 1.5] score representing how well the engineer's @@ -309,14 +292,14 @@ def _skill_score(self, engineer: str, ticket: dict) -> float: """ eng_vec = self._expertise_vector(engineer) if not eng_vec: - return 1.0 # no expertise info — treat as neutral + return 1.0 tkt_vec = self._ticket_title_vector(ticket) if not tkt_vec: - return 1.0 # un-embeddable title — treat as neutral + return 1.0 + + similarity = _cosine(eng_vec, tkt_vec) - similarity = _cosine(eng_vec, tkt_vec) # [-1.0 … 1.0] - # Map [-1, 1] → [0.5, 1.5] (similarity=1 → 1.5, similarity=-1 → 0.5) return 0.5 + (similarity + 1.0) / 2.0 def _precompute_engineer_vectors(self) -> None: @@ -325,7 +308,7 @@ def _precompute_engineer_vectors(self) -> None: Called once in __init__; new-hire personas picked up lazily via _expertise_vector() during the sim. """ - from config_loader import PERSONAS # late import — avoids circular dep + from config_loader import PERSONAS for name in PERSONAS: doc = self._mem._artifacts.find_one( @@ -340,7 +323,7 @@ def _expertise_vector(self, engineer: str) -> List[float]: Handles mid-sim hires whose persona wasn't present at __init__ time. """ if engineer not in self._engineer_vectors: - from config_loader import PERSONAS, DEFAULT_PERSONA # late import + from config_loader import PERSONAS, DEFAULT_PERSONA persona = PERSONAS.get(engineer, DEFAULT_PERSONA) self._engineer_vectors[engineer] = self._build_expertise_vector( @@ -396,14 +379,12 @@ def _ticket_title_vector(self, ticket: dict) -> List[float]: if not title: return [] - # ── Cache read ──────────────────────────────────────────────────────── cached = self._skill_embed_cache.find_one( {"ticket_id": ticket_id}, {"embedding": 1, "_id": 0} ) if cached and cached.get("embedding"): return cached["embedding"] - # ── Cache miss: embed and persist ──────────────────────────────────── try: vector = self._mem._embed( title, @@ -432,15 +413,12 @@ def _ticket_title_vector(self, ticket: dict) -> List[float]: upsert=True, ) except Exception as exc: - # Non-fatal — we still return the vector for this call logger.warning( f"[assigner] ticket embed cache write failed for {ticket_id!r}: {exc}" ) return vector - # ── History ─────────────────────────────────────────────────────────────── - def _ticket_history(self, state) -> Dict[str, set]: """ Returns {engineer: {ticket_ids they've touched in prior days}}. diff --git a/src/token_tracker.py b/src/token_tracker.py deleted file mode 100644 index 5a465bf..0000000 --- a/src/token_tracker.py +++ /dev/null @@ -1,72 +0,0 @@ -# token_tracker.py -from crewai.events import BaseEventListener, CrewKickoffCompletedEvent - - -class OrgForgeTokenListener(BaseEventListener): - """ - Listens to every LLM call CrewAI makes and logs token usage to MongoDB. - Active only when Memory is initialised with debug_tokens=True. - Single instance covers PLANNER_MODEL and WORKER_MODEL transparently. - """ - - def __init__(self): - super().__init__() - self._mem = None # injected after Memory is constructed - - def attach(self, mem) -> None: - """Call this once in OrgForgeFlow.__init__ after Memory is ready.""" - self._mem = mem - - def setup_listeners(self, crewai_event_bus): - @crewai_event_bus.on(CrewKickoffCompletedEvent) - def on_llm_completed(source, event: CrewKickoffCompletedEvent): - if self._mem is None: - return - # 1. Extract the agents from the Crew (the source) - agents = getattr(source, "agents", []) - - # 2. Grab the GOAL from the first agent to use as our tracker - primary_goal = "unknown_goal" - if agents: - primary_goal = getattr(agents[0], "goal", "unknown_goal") - - # 3. Extract the model - model_name = "unknown" - if agents and getattr(agents[0], "llm", None): - llm = agents[0].llm - model_name = getattr( - llm, "model", getattr(llm, "model_name", "unknown") - ) - - prompt_tokens = getattr(event, "prompt_tokens", 0) - completion_tokens = getattr(event, "completion_tokens", 0) - - # Fallback: if they are 0, check inside the Crew's usage_metrics or event.metrics - if prompt_tokens == 0 and completion_tokens == 0: - usage = getattr(source, "usage_metrics", getattr(event, "metrics", {})) - if isinstance(usage, dict): - prompt_tokens = usage.get("prompt_tokens", 0) - completion_tokens = usage.get("completion_tokens", 0) - else: - prompt_tokens = getattr(usage, "prompt_tokens", 0) - completion_tokens = getattr(usage, "completion_tokens", 0) - - current_day = getattr(self._mem, "_current_day", 0) or 1 - self._mem.log_token_usage( - caller=primary_goal, - call_type="llm", - model=model_name, - day=current_day, - timestamp=event.timestamp.isoformat(), - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=event.total_tokens, - extra={ - "agent_role": getattr(agents[0], "role", "") if agents else "", - }, - ) - - -# Module-level instance — must exist for handlers to be registered -# (CrewAI garbage-collects listeners with no live reference) -orgforge_token_listener = OrgForgeTokenListener() diff --git a/src/utils/helpers.py b/src/utils/helpers.py new file mode 100644 index 0000000..d384d5d --- /dev/null +++ b/src/utils/helpers.py @@ -0,0 +1,10 @@ +from typing import Dict, List + +from config_loader import LIVE_ORG_CHART + + +def dept_of_name(name: str) -> str: + for dept, members in LIVE_ORG_CHART.items(): + if name in members: + return dept + return "Unknown" diff --git a/src/utils/persona_utils.py b/src/utils/persona_utils.py new file mode 100644 index 0000000..504be02 --- /dev/null +++ b/src/utils/persona_utils.py @@ -0,0 +1,168 @@ +import logging +from typing import Dict, Union +from config_loader import COMPANY_NAME, COMPANY_DESCRIPTION, PERSONAS, DEFAULT_PERSONA +from utils.helpers import dept_of_name + +logger = logging.getLogger("orgforge.persona_utils") + + +def get_voice_card( + names: Union[str, list], context: str = "general", graph_dynamics=None, mem=None +) -> str: + """ + Unified persona generator for all OrgForge LLM prompts. + Combines identity, tenure, expertise, style, and dynamic mood. + Accepts a single name or a list of names. Identical personas are deduplicated. + """ + is_single = isinstance(names, str) + name_list = [names] if is_single else names + + card_to_names = {} + name_to_history = {} + + for name in name_list: + p = PERSONAS.get(name, DEFAULT_PERSONA) + stress = graph_dynamics._stress.get(name, 30) if graph_dynamics else 30 + quirks = p.get("typing_quirks", "standard professional grammar") + tenure = p.get("tenure", "mid") + expertise = ( + ", ".join(str(e) for e in p.get("expertise", [])[:3]) + or "general engineering" + ) + social_role = p.get("social_role", "Contributor") + dept = dept_of_name(name) + interests = ( + ", ".join( + str(i) for i in (p.get("interests") or p.get("expertise", []))[:3] + ) + or "general topics" + ) + style = p.get("style", "") + anti_patterns = p.get("anti_patterns", "") + pet_peeves = p.get("pet_peeves", "") + + if mem: + past = mem.persona_history(name, n=2) + if past: + name_to_history[name] = " | ".join( + f"Day {e.day}: {e.summary}" for e in past + ) + + _moods: Dict[str, tuple] = { + "one_on_one": ( + "drained, short replies", + "a bit distracted", + "relaxed and present", + ), + "async": ( + "visibly stressed, terse replies, wants to resolve this fast", + "somewhat distracted but trying to help", + "engaged and happy to dig in", + ), + "design": ( + "terse, wants to decide fast and move on", + "engaged but watching the clock", + "thinking carefully, happy to explore trade-offs", + ), + "mentoring": ( + "drained, keeping answers short", + "patient but distracted", + "engaged and generous with their time", + ), + "collision": ( + "visibly stressed, terse, wants this resolved immediately", + "frustrated but trying to stay professional", + "measured and collegial", + ), + "dm": ( + "stressed and frustrated, wants this unblocked now", + "concerned but calm", + "helpful and focused", + ), + "watercooler": ( + "visibly drained, short replies, clearly wants this to be over quickly", + "a bit distracted, somewhat engaged but mind is elsewhere", + "relaxed and happy to take a break", + ), + "general": ( + "drained, short replies", + "a bit distracted", + "relaxed and present", + ), + } + high, mid, low = _moods.get(context, _moods["general"]) + mood = high if stress > 80 else mid if stress > 60 else low + + placeholder = "__NAMES_PLACEHOLDER__" + + if context == "one_on_one": + header = f"{placeholder} | Tenure: {tenure}" + elif context == "async": + header = f"{placeholder} | Tenure: {tenure} | Dept: {dept}" + elif context == "design": + header = f"{placeholder} | Role: {social_role} | Expertise: {expertise}" + elif context == "mentoring": + header = f"{placeholder} | Tenure: {tenure} | Expertise: {expertise}" + elif context == "collision": + header = f"{placeholder} | Dept: {dept} | Role: {social_role}" + elif context == "dm": + header = f"{placeholder}" + elif context == "watercooler": + header = f"{placeholder} | Tenure: {tenure} | Role: {social_role}" + else: + header = f"{placeholder} | Tenure: {tenure}" + + if style and context != "watercooler": + header += f" | Style: {style}" + + lines = [header, f" Typing style: {quirks}", f" Current mood: {mood}"] + + if context == "async": + lines.insert(2, f" Expertise: {expertise}") + elif context == "watercooler": + lines.insert(2, f" Personal interests: {interests}") + + _anti_pattern_contexts = {"async", "design", "collision", "dm"} + if anti_patterns and context in _anti_pattern_contexts: + lines.append(f" Never write {placeholder} as: {anti_patterns.strip()}") + + _pet_peeve_contexts = {"design", "collision"} + if pet_peeves and context in _pet_peeve_contexts: + lines.append(f" Pet peeves (will react if triggered): {pet_peeves}") + + identity_block = "\n".join(lines) + + sections = [ + f"IDENTITY: You are {placeholder} ({tenure} tenure). Role: {p.get('social_role', 'Contributor')}.", + f"COMPANY: You work at {COMPANY_NAME}, which {COMPANY_DESCRIPTION}.", + f"{identity_block}\n\nNever acknowledge being an AI. Stay in character.", + ] + + template = "\n".join(sections) + + # Group identical templates + if template not in card_to_names: + card_to_names[template] = [] + card_to_names[template].append(name) + + parts = [] + for template, group_names in card_to_names.items(): + combined_names = " / ".join(group_names) + + final_card = template.replace("__NAMES_PLACEHOLDER__", combined_names) + + history_lines = [] + for n in group_names: + if n in name_to_history: + prefix = f"{n}: " if len(group_names) > 1 else "" + history_lines.append(f"{prefix}{name_to_history[n]}") + + if history_lines: + final_card += "\n\nRECENT HISTORY:\n" + "\n".join(history_lines) + + if is_single: + return final_card + else: + parts.append(f"PERSONA(S) FOR {combined_names}:\n{final_card}") + + return "\n\n".join(parts) diff --git a/tests/test_normal_day.py b/tests/test_normal_day.py index ff6b3d3..cea3897 100644 --- a/tests/test_normal_day.py +++ b/tests/test_normal_day.py @@ -17,11 +17,6 @@ from flow import persona_backstory -# ───────────────────────────────────────────────────────────────────────────── -# SHARED HELPERS -# ───────────────────────────────────────────────────────────────────────────── - - def _make_ticket( ticket_id: str, title: str, @@ -41,10 +36,6 @@ def _make_ticket( } -# ───────────────────────────────────────────────────────────────────────────── -# FIXTURES -# ───────────────────────────────────────────────────────────────────────────── - ORG_CHART = { "Engineering": ["Alice", "Bob", "Carol"], "Sales": ["Dave"], @@ -79,7 +70,7 @@ def _make_ticket( "company_name": "TestCorp", "domain": "testcorp.com", "output_dir": "/tmp/orgforge_test", - "watercooler_prob": 0.0, # disable by default; opt-in per test + "watercooler_prob": 0.0, "aws_alert_prob": 0.0, "snyk_alert_prob": 0.0, "adhoc_confluence_prob": 0.0, @@ -186,11 +177,6 @@ def _simple_org_plan(dept_plans: dict) -> OrgDayPlan: ) -# ───────────────────────────────────────────────────────────────────────────── -# 1. DISPATCH ROUTING -# ───────────────────────────────────────────────────────────────────────────── - - def test_dispatch_deep_work_returns_actor_only(handler, mock_state): """ deep_work items must not produce Slack/JIRA artifacts. @@ -251,16 +237,10 @@ def test_dispatch_unknown_activity_type_does_not_raise(handler, mock_state): eng_plan = _simple_eng_plan("Alice", [item]) dept_plan = _simple_dept_plan([eng_plan]) - # Should not raise actors = handler._dispatch(eng_plan, item, dept_plan, "2026-01-05") assert "Alice" in actors -# ───────────────────────────────────────────────────────────────────────────── -# 2. TICKET PROGRESS -# ───────────────────────────────────────────────────────────────────────────── - - def test_ticket_progress_moves_todo_to_in_progress(handler, mock_state, tmp_path): """ A ticket in 'To Do' status must be set to 'In Progress' when progressed. @@ -321,7 +301,6 @@ def test_ticket_progress_no_op_for_missing_ticket(handler, mock_state): When the ticket_id does not exist in state.jira_tickets, the handler must return gracefully without emitting a SimEvent or crashing. """ - # No ticket seeded — get_ticket("ORG-GHOST") returns None from mongomock item = AgendaItem( activity_type="ticket_progress", @@ -368,7 +347,6 @@ def test_ticket_progress_blocker_emits_blocker_flagged(handler, mock_state): patch.object(handler, "_save_slack", return_value=("", "")), patch("normal_day.Crew") as mock_crew, ): - # First call: ticket comment (contains "blocked"); second call: blocker Slack mock_crew.return_value.kickoff.side_effect = [ "I'm blocked waiting on the infra team to open the port.", "Alice: On it, I'll check now.", @@ -379,11 +357,6 @@ def test_ticket_progress_blocker_emits_blocker_flagged(handler, mock_state): assert "blocker_flagged" in logged_types -# ───────────────────────────────────────────────────────────────────────────── -# 3. 1:1 HANDLING -# ───────────────────────────────────────────────────────────────────────────── - - def test_one_on_one_emits_simevent_with_both_actors(handler, mock_state): """ _handle_one_on_one must emit a 1on1 SimEvent whose actors list contains @@ -462,7 +435,7 @@ def test_one_on_one_skipped_when_collaborator_is_self(handler, mock_state): item = AgendaItem( activity_type="1on1", description="Self 1:1 (invalid)", - collaborator=["Alice"], # Alice is also the eng_plan name below + collaborator=["Alice"], estimated_hrs=0.5, ) eng_plan = _simple_eng_plan("Alice", [item]) @@ -477,11 +450,6 @@ def test_one_on_one_skipped_when_collaborator_is_self(handler, mock_state): assert len(events) == 0 -# ───────────────────────────────────────────────────────────────────────────── -# 4. ASYNC QUESTION -# ───────────────────────────────────────────────────────────────────────────── - - def test_async_question_emits_simevent(handler, mock_state): """ _handle_async_question must emit an async_question SimEvent whose facts @@ -635,7 +603,7 @@ def fake_write_design(*args, **kwargs): patch.object(handler, "_save_md"), patch("normal_day.Crew") as mock_crew, patch("random.random", return_value=0.10), - ): # trigger Confluence path + ): mock_crew.return_value.kickoff.return_value = ( '[{"speaker": "Alice", "message": "We need a clear retry policy."}, ' '{"speaker": "Bob", "message": "Exponential back-off seems right."}]' @@ -646,11 +614,6 @@ def fake_write_design(*args, **kwargs): assert "confluence_created" in logged_types -# ───────────────────────────────────────────────────────────────────────────── -# 6. MENTORING -# ───────────────────────────────────────────────────────────────────────────── - - def test_mentoring_double_boosts_graph_edge(handler, graph_and_gd): """ A mentoring session calls record_slack_interaction twice, so the edge @@ -670,16 +633,22 @@ def test_mentoring_double_boosts_graph_edge(handler, graph_and_gd): eng_plan = _simple_eng_plan("Alice", [item]) dept_plan = _simple_dept_plan([eng_plan]) + def make_task_mock(*args, **kwargs): + m = MagicMock() + # Mocking a JSON string satisfies the _extract_last_turn logic + m.output.raw = ( + '{"message": "Alice: Let\'s chat async.", "summary": "Discussed patterns."}' + ) + return m + with ( patch.object(handler, "_save_slack", return_value=("", "")), patch("normal_day.Crew") as mock_crew, + patch("normal_day.Task", side_effect=make_task_mock), # Local patch for Task ): - mock_crew.return_value.kickoff.return_value = ( - "Alice: Let's talk about async/await.\nCarol: I've been struggling with it." - ) + mock_crew.return_value.kickoff.return_value = "" handler._dispatch(eng_plan, item, dept_plan, "2026-01-05") - # Double boost: weight should have grown by at least 2 × slack_boost assert G["Alice"]["Carol"]["weight"] >= weight_before + (2 * boost) @@ -697,14 +666,19 @@ def test_mentoring_emits_simevent(handler, mock_state): eng_plan = _simple_eng_plan("Alice", [item]) dept_plan = _simple_dept_plan([eng_plan]) + def make_task_mock(*args, **kwargs): + m = MagicMock() + m.output.raw = ( + '{"message": "Alice: How is the workload?", "summary": "Growth check-in."}' + ) + return m + with ( patch.object(handler, "_save_slack", return_value=("", "")), patch("normal_day.Crew") as mock_crew, + patch("normal_day.Task", side_effect=make_task_mock), ): - mock_crew.return_value.kickoff.return_value = ( - "Alice: How are you finding the new ticket workload?\n" - "Carol: It's a lot, but I'm managing." - ) + mock_crew.return_value.kickoff.return_value = "" handler._dispatch(eng_plan, item, dept_plan, "2026-01-05") events = [ @@ -725,10 +699,10 @@ def test_mentoring_skipped_when_no_junior_found(handler, mock_state): item = AgendaItem( activity_type="mentoring", description="Mentoring session", - collaborator=[], # no explicit collaborator + collaborator=[], estimated_hrs=1.0, ) - # Dave is in Sales and is senior — no juniors exist for him in his dept + eng_plan = EngineerDayPlan( name="Dave", dept="Sales", @@ -756,11 +730,6 @@ def test_mentoring_skipped_when_no_junior_found(handler, mock_state): assert len(events) == 0 -# ───────────────────────────────────────────────────────────────────────────── -# 7. CLOCK INTEGRATION — cursors advance correctly -# ───────────────────────────────────────────────────────────────────────────── - - def test_deep_work_advances_actor_cursor(handler, clock, mock_state): """ _log_deep_work must advance the engineer's SimClock cursor by @@ -790,7 +759,7 @@ def test_one_on_one_syncs_both_cursors(handler, clock, mock_state): After a 1:1, both participants' cursors must be at or beyond the session end time (i.e. they were both consumed by the meeting). """ - # Give Bob a head start so we can verify sync + clock._set_cursor("Bob", datetime(2026, 1, 5, 10, 0)) clock._set_cursor("Alice", datetime(2026, 1, 5, 9, 0)) @@ -816,22 +785,16 @@ def make_task_mock(*args, **kwargs): mock_crew.return_value.kickoff.return_value = "" handler._dispatch(eng_plan, item, dept_plan, "2026-01-05") - # Both cursors must be past the original later cursor (Bob at 10:00) assert clock.now("Bob") >= datetime(2026, 1, 5, 10, 0) assert clock.now("Alice") >= datetime(2026, 1, 5, 10, 0) -# ───────────────────────────────────────────────────────────────────────────── -# 8. _execute_agenda_items — distraction gate -# ───────────────────────────────────────────────────────────────────────────── - - def test_distraction_fires_at_most_once_per_engineer(handler, mock_state, graph_and_gd): """ With watercooler_prob=1.0, each engineer must be distracted at most once regardless of how many agenda items they have. """ - # Override config to always trigger + handler._config["simulation"]["watercooler_prob"] = 1.0 items = [ @@ -903,7 +866,6 @@ def test_distraction_index_varies_across_runs(handler, mock_state): ): handler._execute_agenda_items(org_plan, "2026-01-05") - # random.choice must have been called with the full list of non-deferred indices choice_calls = [ c for c in mock_choice.call_args_list @@ -916,11 +878,6 @@ def test_distraction_index_varies_across_runs(handler, mock_state): ) -# ───────────────────────────────────────────────────────────────────────────── -# 9. GRAPH DYNAMICS INTEGRATION -# ───────────────────────────────────────────────────────────────────────────── - - def test_execute_agenda_items_calls_graph_dynamics_record(handler, mock_state): """ After executing agenda items, graph_dynamics_record must be called with @@ -940,9 +897,8 @@ def test_execute_agenda_items_calls_graph_dynamics_record(handler, mock_state): patch.object(handler, "_save_slack", return_value=("", "")), patch.object(handler, "graph_dynamics_record") as mock_gdr, patch("normal_day.Crew") as mock_crew, - patch("normal_day.Task") as mock_task, # <-- Add Task patch + patch("normal_day.Task") as mock_task, ): - # Configure the Task mock to return a string, satisfying JSON serialization mock_task_instance = MagicMock() mock_task_instance.output.raw = "mocked message" mock_task.return_value = mock_task_instance @@ -950,15 +906,9 @@ def test_execute_agenda_items_calls_graph_dynamics_record(handler, mock_state): mock_crew.return_value.kickoff.return_value = "Bob: Hey Alice.\nAlice: Hey Bob." handler._execute_agenda_items(org_plan, "2026-01-05") - # (Keep your existing assertions here) mock_gdr.assert_called() -# ───────────────────────────────────────────────────────────────────────────── -# 10. UTILITY FUNCTIONS -# ───────────────────────────────────────────────────────────────────────────── - - def test_dept_of_name_returns_correct_dept(): assert dept_of_name("Alice", ORG_CHART) == "Engineering" assert dept_of_name("Dave", ORG_CHART) == "Sales" @@ -973,7 +923,7 @@ def test_closest_colleague_returns_highest_weight_neighbour(handler, graph_and_g _closest_colleague must return the neighbour with the highest edge weight. """ G, gd = graph_and_gd - # Give Bob a very strong edge to Carol and weak edge to Alice + G["Bob"]["Carol"]["weight"] = 20.0 G["Bob"]["Alice"]["weight"] = 1.0 @@ -995,8 +945,8 @@ def test_dept_of_name_returns_first_match_when_name_in_multiple_depts(): """ ambiguous_chart = { "Engineering": ["Alice", "Bob"], - "Platform": ["Alice", "Carol"], # Alice appears twice + "Platform": ["Alice", "Carol"], } result = dept_of_name("Alice", ambiguous_chart) - # Must return one of the two valid depts, not crash or return "Unknown" + assert result in ("Engineering", "Platform") From 76f8ae7c47768488372fa0009e1dc7c56c7987fd Mon Sep 17 00:00:00 2001 From: Jeff F Date: Wed, 25 Mar 2026 14:22:54 -0500 Subject: [PATCH 2/2] add missing import --- src/day_planner.py | 2 -- src/external_email_ingest.py | 58 ++++-------------------------------- src/flow.py | 1 - src/normal_day.py | 2 +- src/utils/helpers.py | 2 -- 5 files changed, 7 insertions(+), 58 deletions(-) diff --git a/src/day_planner.py b/src/day_planner.py index bb5c660..a343644 100644 --- a/src/day_planner.py +++ b/src/day_planner.py @@ -47,8 +47,6 @@ from config_loader import ( LEADS, LIVE_ORG_CHART, - PERSONAS, - DEFAULT_PERSONA, COMPANY_DESCRIPTION, resolve_role, ) diff --git a/src/external_email_ingest.py b/src/external_email_ingest.py index d5f6b9b..cbae0d2 100644 --- a/src/external_email_ingest.py +++ b/src/external_email_ingest.py @@ -57,10 +57,11 @@ from crewai import Crew, Task from memory import Memory, SimEvent from insider_threat import _NullInjector +from utils.persona_utils import get_voice_card logger = logging.getLogger("orgforge.external_email") -# ── Probability knobs ───────────────────────────────────────────────────────── + _PROB_ALWAYS = 0.40 _PROB_INCIDENT = 0.70 _PROB_INCIDENT_QUIET = 0.10 @@ -72,11 +73,6 @@ _HR_EMAIL_WINDOW = (1, 3) # days before hire arrival to send email -# ───────────────────────────────────────────────────────────────────────────── -# DATA MODEL -# ───────────────────────────────────────────────────────────────────────────── - - @dataclass class ExternalEmailSignal: """ @@ -90,15 +86,15 @@ class ExternalEmailSignal: source_name: str source_org: str source_email: str - internal_liaison: str # dept name + internal_liaison: str subject: str - body_preview: str # ≤200 chars for planner prompt injection + body_preview: str full_body: str tone: str topic: str timestamp_iso: str embed_id: str - category: str # "vendor" | "customer" | "hr_outbound" + category: str dropped: bool = False eml_path: str = "" causal_chain: Optional[CausalChainHandler] = None @@ -115,11 +111,6 @@ def as_cross_signal_text(self) -> str: ) -# ───────────────────────────────────────────────────────────────────────────── -# MAIN CLASS -# ───────────────────────────────────────────────────────────────────────────── - - class ExternalEmailIngestor: """ Manages genesis-time source generation and all email flows during the sim. @@ -150,7 +141,7 @@ def __init__( leads: Dict[str, str], org_chart: Dict[str, List[str]], personas: Dict[str, dict], - registry, # ArtifactRegistry + registry, clock, threat_injector=None, ): @@ -175,15 +166,10 @@ def __init__( self._sources: Optional[List[dict]] = None self._threat = threat_injector or _NullInjector() - # Index scheduled hires by day for O(1) lookup self._scheduled_hires: Dict[int, List[dict]] = {} for hire in config.get("org_lifecycle", {}).get("scheduled_hires", []): self._scheduled_hires.setdefault(hire["day"], []).append(hire) - # ───────────────────────────────────────────────────────────────────────── - # GENESIS - # ───────────────────────────────────────────────────────────────────────── - def generate_sources(self) -> List[dict]: """ Generate and persist email sources via LLM. Idempotent. @@ -268,10 +254,6 @@ def generate_sources(self) -> List[dict]: ) return sources - # ───────────────────────────────────────────────────────────────────────── - # DAILY — PRE-STANDUP (vendor alerts only) - # ───────────────────────────────────────────────────────────────────────── - def generate_pre_standup(self, state) -> List[ExternalEmailSignal]: """ Vendor / automated alerts arriving 06:00–08:59. @@ -302,10 +284,6 @@ def generate_pre_standup(self, state) -> List[ExternalEmailSignal]: logger.info(f" [cyan]📬 {len(signals)} vendor alert(s) pre-standup[/cyan]") return signals - # ───────────────────────────────────────────────────────────────────────── - # DAILY — BUSINESS HOURS (customer emails + gatekeeper chain) - # ───────────────────────────────────────────────────────────────────────── - def generate_business_hours(self, state) -> List[ExternalEmailSignal]: """ Customer emails arriving 09:00–16:30. @@ -352,10 +330,6 @@ def generate_business_hours(self, state) -> List[ExternalEmailSignal]: ) return signals - # ───────────────────────────────────────────────────────────────────────── - # DAILY — HR OUTBOUND - # ───────────────────────────────────────────────────────────────────────── - def generate_hr_outbound(self, state) -> None: """ Fires 1–3 days before a scheduled hire arrives. @@ -375,10 +349,6 @@ def generate_hr_outbound(self, state) -> None: self._send_hr_outbound(hire, hr_lead, days_until, state, date_str) hire["_hr_email_sent"] = True - # ───────────────────────────────────────────────────────────────────────── - # ROUTING — customer → Sales → Product gatekeeper - # ───────────────────────────────────────────────────────────────────────── - def _route_customer_email(self, signal: ExternalEmailSignal, state) -> None: date_str = str(state.current_date.date()) sales_lead = self._leads.get( @@ -387,14 +357,12 @@ def _route_customer_email(self, signal: ExternalEmailSignal, state) -> None: product_dept = next((d for d in self._leads if "product" in d.lower()), None) product_lead = self._leads.get(product_dept, sales_lead) - # Hop 1: Sales pings Product on Slack thread_id = self._sales_pings_product( signal, sales_lead, product_lead, state, date_str ) if thread_id: signal.causal_chain.append(thread_id) - # Hop 2: Product decides — high priority → JIRA is_high = signal.tone in ("frustrated", "urgent") or ( state.system_health < 70 and "stability" in signal.topic.lower() ) @@ -403,7 +371,6 @@ def _route_customer_email(self, signal: ExternalEmailSignal, state) -> None: if ticket_id: signal.causal_chain.append(ticket_id) - # Hop 3: Sales sends acknowledgment reply to the customer reply_id = self._send_customer_reply( signal, sales_lead, is_high, state, date_str ) @@ -439,7 +406,6 @@ def _sales_pings_product( participants = [sales_lead, product_lead] ping_time, _ = self._clock.sync_and_advance(participants, hours=0.25) - # ✨ NEW: Global voice card and role anchoring backstory = get_voice_card( sales_lead, "async", graph_dynamics=None, mem=self._mem ) @@ -518,7 +484,6 @@ def _product_opens_jira( self._registry.register_jira(ticket_id) jira_time, _ = self._clock.sync_and_advance([product_lead], hours=0.3) - # ✨ NEW: Global voice card and role anchoring backstory = get_voice_card( product_lead, "async", graph_dynamics=None, mem=self._mem ) @@ -599,15 +564,10 @@ def _product_opens_jira( ) return ticket_id - # ───────────────────────────────────────────────────────────────────────── - # ROUTING — vendor emails - # ───────────────────────────────────────────────────────────────────────── - def _route_vendor_email(self, signal: ExternalEmailSignal, state) -> None: date_str = str(state.current_date.date()) recipient = self._find_expert_for_topic(signal.topic, signal.internal_liaison) - # Attach to live incident chain if topic overlaps root cause for inc in state.active_incidents: if any( kw in signal.topic.lower() @@ -620,13 +580,11 @@ def _route_vendor_email(self, signal: ExternalEmailSignal, state) -> None: ) break - # Optional JIRA task if signal.tone == "urgent" or random.random() < _PROB_VENDOR_JIRA: ticket_id = self._engineer_opens_jira(signal, recipient, state, date_str) if ticket_id: signal.causal_chain.append(ticket_id) - # Outbound acknowledgment reply to the vendor ack_id = self._send_vendor_ack(signal, recipient, state, date_str) if ack_id: signal.causal_chain.append(ack_id) @@ -735,7 +693,6 @@ def _send_hr_outbound(self, hire, hr_lead, days_until, state, date_str) -> None: hr_time, _ = self._clock.sync_and_advance([hr_lead], hours=0.5) - # ✨ NEW: Global voice card and role anchoring backstory = get_voice_card(hr_lead, "async", graph_dynamics=None, mem=self._mem) p = self._personas.get(hr_lead, {}) @@ -863,7 +820,6 @@ def _send_customer_reply( else "This is routine — thank them, confirm receipt, and say the team will be in touch." ) - # ✨ NEW: Global voice card and role anchoring backstory = get_voice_card( sales_lead, "async", graph_dynamics=None, mem=self._mem ) @@ -991,7 +947,6 @@ def _send_vendor_ack( """ ack_time, _ = self._clock.sync_and_advance([recipient], hours=0.3) - # Surface the JIRA id if one was opened, so the reply can reference it jira_ref = next( ( a @@ -1006,7 +961,6 @@ def _send_vendor_ack( else "No ticket number yet — just say it is being investigated." ) - # ✨ NEW: Global voice card and role anchoring backstory = get_voice_card( recipient, "async", graph_dynamics=None, mem=self._mem ) diff --git a/src/flow.py b/src/flow.py index 812fd65..433982d 100644 --- a/src/flow.py +++ b/src/flow.py @@ -33,7 +33,6 @@ import logging import json import random -import re import threading from concurrent.futures import ThreadPoolExecutor, as_completed from agent_factory import make_agent diff --git a/src/normal_day.py b/src/normal_day.py index 0b1bbc9..a61c423 100644 --- a/src/normal_day.py +++ b/src/normal_day.py @@ -11,7 +11,7 @@ from config_loader import COMPANY_DESCRIPTION from crewai import Process, Task, Crew -from json_repair import json_repair, repair_json +from json_repair import json_repair from memory import Memory, SimEvent from graph_dynamics import GraphDynamics from planner_models import ( diff --git a/src/utils/helpers.py b/src/utils/helpers.py index d384d5d..59fac14 100644 --- a/src/utils/helpers.py +++ b/src/utils/helpers.py @@ -1,5 +1,3 @@ -from typing import Dict, List - from config_loader import LIVE_ORG_CHART