From f072e4f2f1730ab285d4255320b63a8d01c35249 Mon Sep 17 00:00:00 2001 From: Jeff F Date: Fri, 27 Mar 2026 17:46:06 -0500 Subject: [PATCH 1/2] Add additional export artifacts --- CHANGELOG.md | 21 + README.md | 74 +- config/config.yaml | 18 +- src/confluence_writer.py | 37 +- src/crm_system.py | 1029 +++++++++++++++++++++++++++ src/day_planner.py | 41 +- src/external_email_ingest.py | 170 ++--- src/flow.py | 145 ++-- src/genesis.py | 286 ++++++++ src/graph_dynamics.py | 28 +- src/memory.py | 168 ++--- src/normal_day.py | 380 +++++++--- src/org_lifecycle.py | 10 + src/plan_validator.py | 20 +- src/planner_models.py | 2 + src/post_sim_artifacts.py | 1059 ++++++++++++++++++++++++++++ tests/conftest.py | 1 + tests/test_causal_chain_handler.py | 128 ++++ tests/test_crm_system.py | 164 +++++ tests/test_day_planner.py | 6 +- tests/test_flow.py | 52 +- tests/test_integration.py | 298 +++++++- tests/test_lifecycle.py | 17 +- tests/test_normal_day.py | 8 +- tests/test_post_sim_artifacts.py | 253 +++++++ 25 files changed, 3916 insertions(+), 499 deletions(-) create mode 100644 src/crm_system.py create mode 100644 src/genesis.py create mode 100644 src/post_sim_artifacts.py create mode 100644 tests/test_causal_chain_handler.py create mode 100644 tests/test_crm_system.py create mode 100644 tests/test_post_sim_artifacts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de627b..f0c5a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [v1.2.8] — 2026-03-27 + +### Added + +- **Multi-Channel CRM Simulation (`src/crm_system.py`, `src/external_email_ingest.py`)**: introduced native integrations for **Salesforce** and **Zendesk**. The simulation now tracks CRM account ownership, flags "at-risk" opportunities during incidents, and automatically escalates customer support tickets based on system health. +- **High-Fidelity Meeting Transcripts (`src/normal_day.py`, `src/day_planner.py`)**: Added **Zoom** as a meeting medium for design discussions. Unlike Slack threads, Zoom produces verbatim Markdown transcripts that capture undocumented verbal decisions, creating intentional "knowledge gaps" for RAG evaluation. +- **Synthetic Business Telemetry (`src/post_sim_artifacts.py`)**: Introduced a post-processing suite that generates deterministic **Datadog** metrics (latency/error spikes), **NPS surveys**, and **customer invoices** featuring SLA credits calculated directly from simulation incident duration. + +### Changed + +- **State Machine Grounding (`src/flow.py`, `src/memory.py`)**: Expanded the `SimEvent` bus to include CRM and meeting events. LLMs now receive `crm_summary` context during daily planning to ensure outbound sales emails and support responses align with the global organizational state. +- **Advanced Social Graph Dynamics (`src/org_lifecycle.py`)**: Enhanced the employee departure logic. When an engineer leaves, the system now deterministically handles **CRM ownership lapses** alongside ticket reassignment and Dijkstra-based incident handoffs. +- **Simulation Parameters (`config/config.yaml`)**: Updated default simulation length to 30 days and adjusted hire schedules for Janice (Day 7) and Reese (Day 20) to better stress-test mid-sprint transitions. + +### Fixed + +- **Email Signal Accuracy (`src/external_email_ingest.py`)**: Improved the external contact generator to pull from a larger pool of 14 sources (up from 7) and corrected account name resolution to favor active Salesforce records over static config defaults. +- **PR Review Context (`src/memory.py`)**: Fixed a lack of historical depth in code reviews by implementing `context_for_pr_review`, which deterministically pulls prior comments and linked Jira updates into the LLM prompt. + +--- + ## [v1.2.7] — 2026-03-25 ### Added diff --git a/README.md b/README.md index 1188b5a..b69e245 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ ### A deterministic corporate simulator for generating ground-truth ecosystems and evaluating enterprise AI agents -OrgForge simulates weeks of realistic enterprise activity — Confluence pages, JIRA tickets, Slack threads, Git PRs, emails, and server logs — grounded in an event-driven state machine so LLMs can't hallucinate facts out of sequence. +OrgForge simulates weeks of realistic enterprise activity — Confluence pages, JIRA tickets, Slack threads, Git PRs, Zoom transcripts, Zendesk tickets, Salesforce records, emails, and server telemetry — grounded in an event-driven state machine so LLMs can't hallucinate facts out of sequence. -The dataset is the exhaust of a living simulation. Engineers leave mid-sprint, forcing deterministic incident handoffs and ticket reassignments. Knowledge gaps surface when under-documented systems break. New hires build their internal network through simulated collaboration. Stress propagates through a live, weighted social graph. Every artifact reflects the exact state of the org at the moment it was written. +The dataset is the exhaust of a living simulation. Engineers leave mid-sprint, forcing deterministic incident handoffs, ticket reassignments, and CRM ownership lapses. Knowledge gaps surface when under-documented systems break. New hires build their internal network through simulated collaboration. Stress propagates through a live, weighted social graph. Every artifact reflects the exact state of the org at the moment it was written. --- @@ -48,7 +48,7 @@ The dataset is the exhaust of a living simulation. Engineers leave mid-sprint, f When building AI agents that reason over institutional knowledge, you need a realistic corpus to test against. The only widely-used corporate dataset is the Enron email corpus — 25 years old, legally sensitive, and covering one company in crisis. -OrgForge generates that corpus from scratch, parameterized to any company, industry, or org structure. LLMs write the prose, but the facts — who was on-call, which ticket was open, when the incident resolved, who just left the team — are strictly controlled by the state machine. +OrgForge generates that corpus from scratch, parameterized to any company, industry, or org structure. LLMs write the prose, but the facts — who was on-call, which ticket was open, when the incident resolved, who just left the team, and which customer SLA was breached — are strictly controlled by the state machine. **The central design bet:** grounding LLM output in a deterministic event log makes the dataset actually useful for evaluating retrieval systems. You have ground truth about what happened, when, who was involved, and what the org's state was — so you can measure whether an agent surfaces the right context, not just plausible-sounding context. @@ -85,9 +85,11 @@ Here's what a slice of a real simulation produces. An incident fires on Day 8: **`confluence/postmortems/IT-108.md`** — written the next day, linking the same root cause and PR: -> _This incident was triggered by connection pool exhaustion under sustained load, first surfaced in IT-108. The fix landed in PR #47 (merged by Sarah). A prior knowledge gap in TitanDB connection management — stemming from Jordan's departure on Day 12 — contributed to the delayed diagnosis._ +> _This incident was triggered by connection pool exhaustion under sustained load, first surfaced in IT-108. The fix landed in PR \#47 (merged by Sarah). A prior knowledge gap in TitanDB connection management — stemming from Jordan's departure on Day 12 — contributed to the delayed diagnosis._ -The postmortem references the same root cause as the ticket. The sales email that week mentions platform instability. The sprint retro records the velocity hit. None of this is coincidence — it all traces back to one SimEvent that every downstream artifact reads from. +Meanwhile, the `datadog/metrics.jsonl` time-series data reflects the exact latency spike, Zendesk support tickets from affected customers are automatically escalated to 'Urgent', Salesforce opportunities are flagged as 'at-risk', and end-of-month customer invoices (`invoices/`) automatically apply SLA credits based on the incident's duration. + +None of this is coincidence — it all traces back to one SimEvent that every downstream artifact reads from. --- @@ -97,15 +99,17 @@ A default 22-day simulation produces: | Artifact | Description | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `confluence/archives/` | Seed documents: technical specs, campaign briefs, OKR docs | -| `confluence/general/` | Ad-hoc pages written during the simulation | -| `confluence/postmortems/` | Post-incident write-ups grounded in actual root causes | -| `confluence/retros/` | Sprint retrospectives referencing real velocity and incidents | +| `confluence/` | Seed documents, ad-hoc wikis, and post-incident write-ups grounded in actual root causes | | `jira/` | Sprint tickets, P1 incident tickets with linked PRs | | `slack/channels/` | Standup transcripts, incident alerts, engineering chatter, bot messages | | `git/prs/` | Pull requests with reviewers, merge status, linked tickets | -| `emails/inbound/` | External emails received by the org — customer complaints, vendor messages, inbound escalations | -| `emails/outbound/` | External emails sent by org members — HR communications, leadership syncs, sales updates | +| `zoom/` | Verbatim meeting transcripts from sync design discussions, capturing undocumented verbal decisions | +| `salesforce/` | CRM accounts and active sales opportunities, including risk flags propagated from active incidents | +| `zendesk/` | Customer support tickets and comments, automatically escalated during system outages | +| `emails/` | External inbound/outbound emails — customer complaints, vendor messages, HR communications, sales updates | +| `datadog/` | Time-series system metrics (`metrics.jsonl`) and alert payloads (`alerts.jsonl`) reflecting incident degradation & recovery | +| `nps/` | Post-simulation customer satisfaction surveys, scored deterministically based on SLA breaches and support ticket resolution | +| `invoices/` | End-of-month customer invoices featuring SLA credit line items calculated directly from incident duration | | `simulation_snapshot.json` | Full state: incidents, morale curve, system health, relationship graph, departed employees, new hires, knowledge gap events | | `simulation.log` | Complete chronological system and debug logs for the entire run | @@ -123,11 +127,11 @@ OrgForge is not an LLM wrapper. Four interlocking systems enforce correctness. The most complex behaviour in the simulation. When an engineer departs mid-sprint, the following fires in order before that day's planning runs: -1. **Incident handoff** — active incidents assigned to the departing engineer are rerouted via Dijkstra escalation routing (while the node is still in the graph) to the next available person in the chain. -2. **Ticket reassignment** — orphaned JIRA tickets go to the dept lead. `In Progress` tickets without a linked PR reset to `To Do` so the new owner starts fresh; tickets with a PR keep their status so the review/merge flow closes them naturally. -3. **Graph recompute** — betweenness centrality is recalculated on the smaller graph. Engineers absorbing the departed node's bridging load receive a proportional stress hit. -4. **Knowledge gap propagation** — if the departed engineer owned undocumented domains (configured via `documented_pct`), those gaps are registered in the SimEvent log and surface in subsequent incidents as contributing factors. -5. **`employee_departed` SimEvent** — emitted with edge snapshot, centrality at departure, reassigned tickets, and incident handoffs. Full ground truth for retrieval evaluation. +1. **Incident handoff** — active incidents assigned to the departing engineer are rerouted via Dijkstra escalation routing (while the node is still in the graph) to the next available person in the chain. +2. **Ticket & CRM reassignment** — orphaned JIRA tickets go to the dept lead. Salesforce accounts and open opportunities owned by the departed employee are flagged for reassignment, maintaining cross-domain ground truth. +3. **Graph recompute** — betweenness centrality is recalculated on the smaller graph. Engineers absorbing the departed node's bridging load receive a proportional stress hit. +4. **Knowledge gap propagation** — if the departed engineer owned undocumented domains (configured via `documented_pct`), those gaps are registered in the SimEvent log and surface in subsequent incidents as contributing factors. +5. **`employee_departed` SimEvent** — emitted with edge snapshot, centrality at departure, reassigned tickets, and incident handoffs. Full ground truth for retrieval evaluation. So when Jordan leaves on Day 12, the postmortem on Day 9's incident doesn't mention her. But the postmortem on Day 15 might: _"A prior knowledge gap in auth-service, stemming from a recent departure, contributed to the delayed diagnosis."_ That sentence is grounded in a real SimEvent, not LLM inference. @@ -165,12 +169,13 @@ cd orgforge docker compose up ``` -First run pulls models automatically (~5–8 min depending on your connection). Subsequent runs start in seconds — models are cached in a named volume. +First run pulls models automatically (\~5–8 min depending on your connection). Subsequent runs start in seconds — models are cached in a named volume. -When the simulation finishes, run the email generator: +When the simulation finishes, run the post-processing artifact generators: ```bash python email_gen.py +python post_sim_artifacts.py ``` Output lands in `./export/`. @@ -216,11 +221,11 @@ docker compose up mongodb orgforge A `t3.small` works fine — the cloud APIs do all the heavy lifting. -1. Launch an EC2 instance (Ubuntu or Amazon Linux) and install Docker -2. `git clone https://github.com/aeriesec/orgforge.git && cd orgforge` -3. `cp .env.example .env` and fill in your credentials -4. Set `quality_preset: "cloud"` in `config/config.yaml` -5. `docker compose up --build -d mongodb orgforge` +1. Launch an EC2 instance (Ubuntu or Amazon Linux) and install Docker +2. `git clone https://github.com/aeriesec/orgforge.git && cd orgforge` +3. `cp .env.example .env` and fill in your credentials +4. Set `quality_preset: "cloud"` in `config/config.yaml` +5. `docker compose up --build -d mongodb orgforge` **GPU Instance + 70B Local Models** @@ -250,6 +255,7 @@ quality_preset: "local_gpu" # local_gpu | cloud | `company_name` | Injected into all generated prose | | `simulation_days` | Length of the simulation (default: 22) | | `legacy_system` | The unstable system referenced in incidents, tickets, and docs | +| `crm` | Enable/disable Salesforce and Zendesk simulation integrations | | `sprint_ticket_themes` | Pool of ticket titles drawn during sprint planning | | `adhoc_confluence_topics` | Spontaneous wiki pages generated on normal days | | `knowledge_gaps` | Static departed employees whose absence creates documentation gaps from day one | @@ -339,10 +345,10 @@ This is what makes the dataset useful for RAG evaluation: you have ground truth | Preset | RAM Required | Notes | | ----------- | ------------ | ---------------------------------------- | -| `local_gpu` | ~48 GB VRAM | Llama 3.3 70B — requires A100 or 2× A10G | -| `cloud` | ~500 MB | Only MongoDB + Python run locally | +| `local_gpu` | \~48 GB VRAM | Llama 3.3 70B — requires A100 or 2× A10G | +| `cloud` | \~500 MB | Only MongoDB + Python run locally | -For `local_gpu` on AWS, a `g5.2xlarge` (A10G 24GB) runs 70B at q4 quantization. At ~$0.50/hour spot pricing a full 22-day simulation costs roughly $3–5. +For `local_gpu` on AWS, a `g5.2xlarge` (A10G 24GB) runs 70B at q4 quantization. At \~$0.50/hour spot pricing a full 22-day simulation costs roughly $3–5. --- @@ -355,12 +361,14 @@ orgforge/ │ ├── flow.py # State machine and simulation engine │ ├── day_planner.py # LLM-driven per-department daily planning │ ├── normal_day.py # Agenda dispatcher — produces typed artifacts per activity +│ ├── crm_system.py # Salesforce & Zendesk integration and propagation rules │ ├── planner_models.py # Dataclasses for plans, events, and validation results │ ├── plan_validator.py # Integrity boundary between LLM proposals and execution │ ├── org_lifecycle.py # Dynamic hiring, firing, and knowledge gap propagation │ ├── graph_dynamics.py # Social graph: stress propagation, edge decay, escalation │ ├── memory.py # Vector DB and SimEvent bus -│ └── email_gen.py # Reflective post-processing artifacts +│ ├── email_gen.py # Reflective post-processing email artifacts +│ └── post_sim_artifacts.py # Deterministic post-processing (NPS, invoices, Datadog) ├── config/ # YAML configurations ├── tests/ # Pytest suite ├── scripts/ # Entrypoint and helper scripts @@ -380,13 +388,14 @@ OrgForge includes a full-stack evaluation harness to measure how well AI agents - **Multi-Hop Reasoning**: Test agents on causal, temporal, and gap-detection questions. - **End-to-End Testing**: Use `eval_e2e.py` to run full RAG pipelines against providers like AWS Bedrock, OpenAI, and Cohere. -For detailed instructions on generating eval sets, running benchmarks, and interpreting scores, see **[EVAL.md](EVAL.md)**. +For detailed instructions on generating eval sets, running benchmarks, and interpreting scores, see **[EVAL.md](#EVAL.md)**. --- ## Roadmap -- [ ] Plugin architecture for community artifact types (Zoom, Zendesk, PagerDuty, Salesforce) +- [x] Native integrations for Zoom, Zendesk, and Salesforce CRM +- [ ] Plugin architecture for additional integrations (PagerDuty, Workday, etc.) - [ ] Domain packs — pre-configured `config.yaml` templates for healthcare, fintech, legal - [x] Export to HuggingFace dataset format - [x] Evaluation harness — benchmark RAG retrieval against SimEvent ground truth @@ -395,9 +404,8 @@ For detailed instructions on generating eval sets, running benchmarks, and inter ## Adding a New Artifact Type -1. Add an event emission in `flow.py` when the triggering condition occurs -2. Write a handler that reads from the SimEvent log and generates the artifact -3. Call it from `email_gen.py`'s `run()` method or as a new post-processing script +1. Add an event emission in `flow.py` when the triggering condition occurs +2. Write a handler that reads from the SimEvent log and generates the artifact A formal plugin architecture is on the roadmap. Open an issue before starting so we can align on the interface. @@ -426,4 +434,4 @@ If you use this work, please cite: ## License -MIT — see **[LICENSE](LICENSE)**. +MIT — see **[LICENSE](https://www.google.com/search?q=LICENSE)**. diff --git a/config/config.yaml b/config/config.yaml index 0a0aa45..b8ccf43 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -47,10 +47,10 @@ simulation: domain: "apexathletics.io" output_dir: "./export" db_name: "orgforge" - max_days: 60 + max_days: 30 start_date: "2026-03-02" - adhoc_confluence_prob: 0.3 + adhoc_confluence_prob: 0.2 aws_alert_prob: 0.4 snyk_alert_prob: 0.2 sprint_tickets_per_planning: 4 @@ -174,7 +174,7 @@ org_lifecycle: # events naturally once the roster context shows them as "still warming up". scheduled_hires: - name: "Janice" - day: 15 + day: 7 dept: "Engineering_Backend" role: "Backend Engineer" expertise: ["Python", "FastAPI", "PostgreSQL", "TitanDB"] @@ -187,7 +187,7 @@ org_lifecycle: tenure: "new" - name: "Reese" - day: 35 + day: 20 dept: "Engineering_Backend" role: "DevOps Engineer" expertise: ["Kubernetes", "Terraform", "AWS", "Infrastructure"] @@ -508,6 +508,16 @@ external_contacts: internal_liaison: "Sales" summary_tone: "concerned about uptime, asking for ETA and root cause" +# ── CRM ─────────────────────────────────────────────────────── + +crm: + salesforce: + enabled: true + seed_accounts: true + zendesk: + enabled: true + link_to_incidents: true + # ── INSIDER THREAT ──────────────────────────────────────────── insider_threat: diff --git a/src/confluence_writer.py b/src/confluence_writer.py index c12bcef..1ef421d 100644 --- a/src/confluence_writer.py +++ b/src/confluence_writer.py @@ -53,6 +53,7 @@ from crewai import Task, Crew from memory import Memory, SimEvent from artifact_registry import ArtifactRegistry, ConfluencePage +from utils.persona_utils import get_voice_card if TYPE_CHECKING: from graph_dynamics import GraphDynamics @@ -295,7 +296,7 @@ def write_postmortem( artifact_time, _ = self._clock.advance_actor(on_call, hours=pm_hours) timestamp = artifact_time.isoformat() - backstory = self._persona(on_call, mem=self._mem, graph_dynamics=self._gd) + backstory = get_voice_card(on_call, "design", mem=self._mem, graph_dynamics=self._gd) related = self._registry.related_context(topic=root_cause, n=3) _qa_lead = next( (name for name, p in PERSONAS.items() if "QA" in p.get("expertise", [])), @@ -393,16 +394,12 @@ def write_design_doc( chat_log = "\n".join(f"{m['user']}: {m['text']}" for m in slack_transcript) ctx = self._mem.recall_with_rewrite(raw_query=topic, n=3, as_of_time=timestamp) related = self._registry.related_context(topic=topic, n=3) + backstory = get_voice_card(author, "design", mem=self._mem, graph_dynamics=self._gd) agent = make_agent( role="Technical Lead", goal="Document technical decisions and extract an actionable ticket.", - backstory=self._persona( - author, - mem=self._mem, - graph_dynamics=self._gd, - extra="You just finished a Slack discussion and need to document decisions and assign follow-up work.", - ), + backstory=backstory, llm=self._planner, ) task = Task( @@ -596,28 +593,17 @@ def write_adhoc_page( expertise_str = ", ".join(expertise_list) seed_query = f"{resolved_author} {expertise_str} {daily_theme}" - # Tier 3: LLM-generated seed query benefits from HyDE rewrite. - # recall_with_rewrite degrades to context_for_prompt if no llm_callable - # is passed, so this is safe to land before the callable is wired in. topic_ctx = self._mem.recall_with_rewrite( raw_query=seed_query, n=3, as_of_time=self._clock.now(resolved_author).isoformat(), ) + backstory = get_voice_card(resolved_author, "design", mem=self._mem, graph_dynamics=self._gd) topic_agent = make_agent( role="Content Planner", goal="Identify a unique documentation gap based on your expertise and org history.", - backstory=self._persona( - resolved_author, - mem=self._mem, - graph_dynamics=self._gd, - extra=( - f"You are {resolved_author} from the {dept} department. " - f"Your expertise includes: {expertise_str}. " - f"You are deciding what to document today to provide the most value to the team." - ), - ), + backstory=backstory, llm=self._worker, ) @@ -662,19 +648,12 @@ def write_adhoc_page( ctx = self._mem.context_for_prompt(title, n=3, as_of_time=timestamp) related = self._registry.related_context(topic=title, n=4) + backstory = get_voice_card(resolved_author, "design", mem=self._mem, graph_dynamics=self._gd) writer_agent = make_agent( role="Corporate Writer", goal=f"Draft a {title} Confluence page.", - backstory=self._persona( - resolved_author, - mem=self._mem, - graph_dynamics=self._gd, - extra=( - f"You are {resolved_author} from the {dept} department. " - f"Your expertise includes: {expertise_str}. " - ), - ), + backstory=backstory, llm=self._planner, ) task = Task( diff --git a/src/crm_system.py b/src/crm_system.py new file mode 100644 index 0000000..1ae36b7 --- /dev/null +++ b/src/crm_system.py @@ -0,0 +1,1029 @@ +""" +crm_system.py +============= +Active CRM state machine for OrgForge. + +Salesforce and Zendesk records are live simulation state, not post-processing. +They live in MongoDB alongside JIRA tickets and are embedded into the vector +store so the DayPlanner can read open tickets during planning — exactly as it +reads open JIRA tickets. + +Design principles (mirrors insider_threat.py) +---------------------------------------------- +* NullCRMSystem is the default — completely inert when disabled, no callers + need to guard with ``if crm is not None``. +* All writes go to MongoDB first, then export to disk. Disk layout mirrors + the existing export/jira/ and export/slack/ conventions. +* Every state change emits a SimEvent. Ground truth is always in the event + log — not inferred from disk files. +* The planner_context() method returns a compact string that DayPlannerOrchestrator + can inject into its planning prompt alongside email_signals, so Product and + Engineering planners "see" open support tickets and at-risk deals. + +MongoDB collections +------------------- + zd_tickets — one doc per Zendesk ticket (mirrors _jira collection schema) + sf_accounts — one doc per Salesforce account + sf_opps — one doc per Salesforce opportunity + +Config schema (config.yaml) +--------------------------- + crm: + salesforce: + enabled: true + seed_accounts: true # generate from contacts at sim start + zendesk: + enabled: true + link_to_incidents: true # escalate open ZD tickets when incident fires + +Public API (called from flow.py) +--------------------------------- + crm = CRMSystem.from_config(config, export_base, mem) + + # Sim start — seeds SF accounts from contacts + crm.initialize_salesforce_accounts(contacts) + + # Pre-standup — called before DayPlannerOrchestrator.plan() + crm_signals = crm.planner_context() # injected alongside email_signals + + # Inbound email handling — called from ExternalEmailIngestor + zd_id = crm.handle_inbound_complaint(event_facts, timestamp, date_str, day) + + # Incident lifecycle — called from _handle_incident() and _advance_incidents() + crm.handle_incident_opened(incident_id, component, health, timestamp, date_str, day) + crm.handle_incident_resolved(incident_id, postmortem_link, timestamp, date_str, day) + + # Outbound email classification — called from email_gen.py / HR outbound path + crm.process_outbound_email(email_data, timestamp, date_str, day) + + # Org lifecycle — called from OrgLifecycleManager departure handler + crm.handle_employee_departure(employee_name, role, date_str, day) +""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timedelta +from pathlib import Path +import random +from typing import Any, Dict, List, Optional + +from config_loader import CONFIG + +logger = logging.getLogger("orgforge.crm") + +_ZD_TYPES = ["question", "incident", "problem", "task"] +_ZD_TYPE_WEIGHTS = [0.6, 0.2, 0.1, 0.1] +_ZD_PRIORITIES = ["Low", "Normal", "High", "Urgent"] +_ZD_PRIORITY_WEIGHTS = [0.2, 0.6, 0.15, 0.05] +_ZD_CHANNELS = ["email", "web_widget", "api"] +_ZD_CHANNEL_WEIGHTS = [0.7, 0.2, 0.1] + + +_SF_TYPES = ["New Business", "Renewal", "Upsell/Cross-sell"] +_SF_TYPE_WEIGHTS = [0.6, 0.25, 0.15] +_SF_LEAD_SOURCES = ["Inbound Email", "Outbound", "Event", "Referral"] +_SF_LEAD_SOURCE_WEIGHTS = [0.4, 0.3, 0.2, 0.1] + +_STAGE_PROBABILITIES = { + "Prospecting": 10, + "Value Proposition": 25, + "Proposal/Price Quote": 50, + "Negotiation/Review": 75, + "Closed Won": 100, + "Closed Lost": 0, +} + + +class NullCRMSystem: + """ + Drop-in replacement when CRM is disabled. + Every method is a no-op or returns an empty/None value so callers + never need to check ``if crm is not None``. + """ + + def initialize_salesforce_accounts(self) -> None: + pass + + def planner_context(self) -> str: + return "" + + def handle_inbound_complaint( + self, + event_facts: Dict, + timestamp: str, + date_str: str, + day: int, + ) -> Optional[str]: + return None + + def handle_incident_opened( + self, + incident_id: str, + component: str, + health: int, + timestamp: str, + date_str: str, + day: int, + ) -> None: + pass + + def handle_incident_resolved( + self, + incident_id: str, + postmortem_link: str, + timestamp: str, + date_str: str, + day: int, + ) -> None: + pass + + def process_outbound_email( + self, + email_data: Dict, + timestamp: str, + date_str: str, + day: int, + ) -> Optional[Dict]: + return None + + def handle_employee_departure( + self, + employee_name: str, + role: str, + date_str: str, + day: int, + ) -> None: + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# LIVE SYSTEM +# ───────────────────────────────────────────────────────────────────────────── + + +class CRMSystem: + """ + Active CRM state machine. Instantiated once in OrgForgeSimulation.__init__ + and passed as a dependency to NormalDayHandler, ExternalEmailIngestor, and + the incident handlers in flow.py. + """ + + # Sales-intent keywords for outbound email classification. + # Kept narrow to avoid false positives on engineering or HR mail. + _SALES_KEYWORDS = { + "contract", + "renewal", + "proposal", + "following up", + "pricing", + "quote", + "demo", + "partnership", + "commercial", + "subscription", + "onboarding", + "account review", + } + + def __init__(self, config: Dict, export_base: Path, mem, planner_llm=None): + crm_cfg = config.get("crm", {}) + self._sf_cfg = crm_cfg.get("salesforce", {}) + self._zd_cfg = crm_cfg.get("zendesk", {}) + self._sf_on = self._sf_cfg.get("enabled", False) + self._zd_on = self._zd_cfg.get("enabled", False) + self._base = Path(export_base) + self._mem = mem + self._llm = planner_llm + + # Shorthand collections (created lazily below) + self._zd = mem._db["zd_tickets"] + self._sf_a = mem._db["sf_accounts"] + self._sf_o = mem._db["sf_opps"] + + # Monotonic counters — restored from Mongo on resume + self._zd_counter = ( + max( + (d.get("_seq", 0) for d in self._zd.find({}, {"_seq": 1})), + default=100, + ) + + 1 + ) + self._opp_counter = ( + max( + (d.get("_seq", 0) for d in self._sf_o.find({}, {"_seq": 1})), + default=1000, + ) + + 1 + ) + + self._ensure_dirs() + logger.info( + f"[crm] CRMSystem online — SF={'on' if self._sf_on else 'off'} " + f"ZD={'on' if self._zd_on else 'off'}" + ) + + # ── Factory ────────────────────────────────────────────────────────────── + + @classmethod + def from_config( + cls, config: Dict, export_base: Path, mem, planner_llm=None + ) -> "CRMSystem | NullCRMSystem": + """ + Returns a live CRMSystem if either SF or ZD is enabled, + otherwise a NullCRMSystem. Same pattern as InsiderThreatInjector.from_config. + """ + crm_cfg = config.get("crm", {}) + sf_on = crm_cfg.get("salesforce", {}).get("enabled", False) + zd_on = crm_cfg.get("zendesk", {}).get("enabled", False) + if sf_on or zd_on: + return cls(config, export_base, mem, planner_llm) + return NullCRMSystem() + + + + def _ensure_dirs(self): + for sub in [ + "salesforce/accounts", + "salesforce/opportunities", + "zendesk/tickets", + "zendesk/comments", + ]: + (self._base / sub).mkdir(parents=True, exist_ok=True) + + def _write(self, rel_path: str, data: Dict) -> None: + path = self._base / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as fh: + json.dump(data, fh, indent=2) + + def _embed( + self, + *, + id: str, + artifact_type: str, + title: str, + content: str, + day: int, + date: str, + timestamp: str, + metadata: Optional[Dict] = None, + ) -> None: + """Enqueue an embedding so the artifact is searchable by the DayPlanner.""" + self._mem.embed_artifact( + id=id, + type=artifact_type, + title=title, + content=content, + day=day, + date=date, + timestamp=timestamp, + metadata=metadata or {}, + ) + + def _emit(self, event) -> None: + """Log a SimEvent via the shared memory bus.""" + self._mem.log_event(event) + + def planner_context(self) -> str: + """ + Returns a compact, human-readable string summarising live CRM state. + Called once per day before DayPlannerOrchestrator.plan() so Product and + Engineering planners see open support tickets and at-risk deals the same + way they see vendor email signals. + + Kept to ~150 tokens so it doesn't crowd out the sprint/incident context. + """ + lines: List[str] = [] + + if self._zd_on: + open_tickets = list( + self._zd.find({"status": {"$in": ["Open", "Pending"]}}, {"_id": 0}) + ) + if open_tickets: + lines.append(f"OPEN SUPPORT TICKETS ({len(open_tickets)}):") + for t in open_tickets[:5]: # cap at 5 to control prompt size + urgency = " [URGENT]" if t.get("priority") == "Urgent" else "" + t_type = t.get("type", "ticket").upper() + lines.append( + f" [{t['ticket_id']}]{urgency} {t_type}: {t['subject']} " + f"— {t.get('org_name', 'Unknown')} " + f"(linked incident: {t.get('related_incident', 'none')})" + ) + if len(open_tickets) > 5: + lines.append(f" ... and {len(open_tickets) - 5} more.") + + if self._sf_on: + at_risk = list( + self._sf_o.find( + { + "stage": {"$nin": ["Closed Won", "Closed Lost"]}, + "risk_notes": {"$not": {"$size": 0}}, + }, + {"_id": 0}, + ) + ) + if at_risk: + lines.append(f"AT-RISK DEALS ({len(at_risk)}):") + for opp in at_risk[:3]: + amt = opp.get("amount", 0) + prob = opp.get("probability", 0) + lines.append( + f" [{opp['opportunity_id']}] {opp['account_name']} (${amt:,} / {prob}% prob) " + f"— stage: {opp['stage']} — " + f"risk: {opp['risk_notes'][-1][:80]}" + ) + + active_pipeline = list( + self._sf_o.find( + { + "stage": {"$nin": ["Closed Won", "Closed Lost"]}, + "risk_notes": {"$size": 0}, + }, + {"_id": 0}, + ).sort("_seq", -1) + ) + + if active_pipeline: + lines.append( + f"ACTIVE SALES PIPELINE (Target these for proactive outreach!):" + ) + + for stage in [ + "Negotiation/Review", + "Proposal/Price Quote", + "Value Proposition", + "Prospecting", + ]: + stage_deals = [ + opp for opp in active_pipeline if opp.get("stage") == stage + ] + for opp in stage_deals[:2]: + lines.append( + f" [{opp['opportunity_id']}] {opp['account_name']} " + f"— Stage: {stage} — Owner: {opp.get('owner', 'Unassigned')}" + ) + + return "\n".join(lines) if lines else "" + + def initialize_salesforce_accounts(self) -> None: + """ + Runs once during genesis_phase(), before the daily loop starts. + Reads customer contacts from MongoDB and seeds SF accounts. + """ + if not self._sf_on or not self._sf_cfg.get("seed_accounts", True): + return + + contacts = list( + self._mem._db["sim_config"].find( + {"_id": "inbound_email_sources", "category": "customer"}, {"_id": 0} + ) + ) + + start_dt = datetime.strptime(CONFIG["simulation"]["start_date"], "%Y-%m-%d") + + for contact in contacts: + org_name = contact.get("org", "Unknown") + safe_id = org_name.upper().replace(" ", "").replace("-", "") + account_id = f"ACC-{safe_id}" + + if self._sf_a.find_one({"account_id": account_id}): + continue + + days_ago = random.randint(30, 730) + hours_ago = random.randint(0, 23) + mins_ago = random.randint(0, 59) + created_dt = start_dt - timedelta( + days=days_ago, hours=hours_ago, minutes=mins_ago + ) + + account = { + "account_id": account_id, + "name": org_name, + "primary_contact": contact.get("name", "Unknown Contact"), + "type": "Customer", + "industry": contact.get("industry", "Technology"), + "tier": contact.get( + "tier", + random.choices( + ["Enterprise", "Mid-Market", "SMB"], weights=[0.2, 0.5, 0.3] + )[0], + ), + "website": f"https://www.{org_name.lower().replace(' ', '')}.com", + "billing_region": contact.get( + "billing_region", + random.choices(["NA", "EMEA", "APAC"], weights=[0.6, 0.3, 0.1])[0], + ), + "arr": contact.get( + "arr", random.choice([50000, 100000, 250000, 500000]) + ), + "owner": contact.get("internal_liaison", "Unassigned"), + "created_at": created_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + "risk_flag": False, + } + self._sf_a.insert_one({**account, "_seq": 0}) + self._write(f"salesforce/accounts/{account_id}.json", account) + logger.info(f"[crm] SF account seeded: {account_id} ({org_name})") + + def handle_inbound_complaint( + self, + event_facts: Dict, + timestamp: str, + date_str: str, + day: int, + ) -> Optional[str]: + """ + Called by ExternalEmailIngestor when it processes a customer_complaint + email. Creates a ZD ticket in MongoDB + disk and embeds it so Product + planners see it the next morning. + + Returns the new ticket_id (e.g. 'ZD-101') or None if ZD is disabled. + """ + if not self._zd_on: + return None + + from memory import SimEvent + + seq = self._zd_counter + self._zd_counter += 1 + ticket_id = f"ZD-{seq}" + + ticket = { + "ticket_id": ticket_id, + "type": "incident", + "status": "Open", + "priority": "Normal", + "description": event_facts.get("body", "(See email body.)"), + "requester": { + "name": event_facts.get("sender_name", "Customer"), + "email": event_facts.get("sender", "customer@unknown.com"), + "org_name": event_facts.get("sender_org", "Unknown"), + }, + "subject": event_facts.get("subject", "Customer complaint"), + "org_name": event_facts.get("sender_org", "Unknown"), + "channel": "email", + "tags": ["support", "inbound", "needs_triage"], + "satisfaction_rating": {"score": "unoffered"}, + "created_at": timestamp, + "updated_at": timestamp, + "related_incident": None, + "comments": [ + { + "author": "Customer", + "text": event_facts.get("body", "(See email body.)"), + "timestamp": timestamp, + } + ], + "_seq": seq, + } + + self._zd.insert_one(ticket) + self._write( + f"zendesk/tickets/{ticket_id}.json", + {k: v for k, v in ticket.items() if k != "_id"}, + ) + self._write_zd_comment(ticket_id, ticket["comments"][0]) + + self._embed( + id=ticket_id, + artifact_type="zd_ticket", + title=f"[{ticket_id}] {ticket['subject']}", + content=f"Customer: {ticket['org_name']}\n{ticket['comments'][0]['text']}", + day=day, + date=date_str, + timestamp=timestamp, + metadata={ + "ticket_id": ticket_id, + "org_name": ticket["org_name"], + "status": "Open", + }, + ) + + self._emit( + SimEvent( + type="zd_ticket_opened", + timestamp=timestamp, + day=day, + date=date_str, + actors=[], + artifact_ids={"zd_ticket": ticket_id}, + facts={ + "ticket_id": ticket_id, + "subject": ticket["subject"], + "org_name": ticket["org_name"], + "channel": "email", + }, + summary=f"Zendesk ticket {ticket_id} opened: {ticket['subject']} ({ticket['org_name']})", + tags=["zendesk", "support", "customer_complaint"], + ) + ) + + logger.info(f"[crm] ZD ticket opened: {ticket_id} ({ticket['org_name']})") + return ticket_id + + def _write_zd_comment(self, ticket_id: str, comment: Dict) -> None: + """Write a single comment to disk under zendesk/comments/{ticket_id}/.""" + comment_dir = self._base / "zendesk" / "comments" / ticket_id + comment_dir.mkdir(parents=True, exist_ok=True) + + safe_ts = comment["timestamp"].replace(":", "-").replace(".", "-") + path = comment_dir / f"{safe_ts}.json" + + if path.exists(): + path = comment_dir / f"{safe_ts}_{id(comment)}.json" + with open(path, "w") as fh: + json.dump(comment, fh, indent=2) + + def _add_zd_comment( + self, + ticket_id: str, + text: str, + author: str, + timestamp: str, + ) -> None: + """ + Append a comment to a live ZD ticket in Mongo + disk. + Used by incident escalation and resolution handlers. + """ + comment = {"author": author, "text": text, "timestamp": timestamp} + self._zd.update_one( + {"ticket_id": ticket_id}, + {"$push": {"comments": comment}, "$set": {"updated_at": timestamp}}, + ) + + doc = self._zd.find_one({"ticket_id": ticket_id}, {"_id": 0, "_seq": 0}) + if doc: + self._write(f"zendesk/tickets/{ticket_id}.json", doc) + self._write_zd_comment(ticket_id, comment) + + def handle_incident_opened( + self, + incident_id: str, + component: str, + health: int, + timestamp: str, + date_str: str, + day: int, + ) -> None: + """ + Called immediately after _handle_incident() logs the incident_opened + SimEvent in flow.py. + + ZD path: escalates all currently-open tickets to Urgent and links them + to the incident. These escalations will appear in planner_context() the + next morning. + + SF path: when health < 60, appends a risk note to every open opportunity + so Sales planners see the risk in their daily context. + """ + from memory import SimEvent + + if self._zd_on and self._zd_cfg.get("link_to_incidents", True): + open_tickets = list(self._zd.find({"status": "Open"}, {"_id": 0})) + escalated_ids = [] + + for t in open_tickets: + tid = t["ticket_id"] + self._zd.update_one( + {"ticket_id": tid}, + { + "$set": { + "priority": "Urgent", + "related_incident": incident_id, + "updated_at": timestamp, + }, + "$addToSet": { + "tags": {"$each": ["escalated", "incident_linked"]} + }, + }, + ) + + doc = self._zd.find_one({"ticket_id": tid}, {"_id": 0, "_seq": 0}) + if doc: + self._write(f"zendesk/tickets/{tid}.json", doc) + + self._add_zd_comment( + tid, + f"SYSTEM: Escalated — active infrastructure incident " + f"{incident_id} (component: {component}) may be related.", + "ZD Bot", + timestamp, + ) + escalated_ids.append(tid) + + if escalated_ids: + self._emit( + SimEvent( + type="zd_tickets_escalated", + timestamp=timestamp, + day=day, + date=date_str, + actors=[], + artifact_ids={"jira": incident_id, "zd_tickets": escalated_ids}, + facts={ + "incident_id": incident_id, + "component": component, + "escalated_count": len(escalated_ids), + "ticket_ids": escalated_ids, + }, + summary=( + f"{len(escalated_ids)} ZD ticket(s) escalated to Urgent " + f"due to incident {incident_id}." + ), + tags=["zendesk", "escalation", "incident"], + ) + ) + logger.info( + f"[crm] {len(escalated_ids)} ZD ticket(s) escalated → {incident_id}" + ) + + if self._sf_on and health < 60: + open_opps = list( + self._sf_o.find( + {"stage": {"$nin": ["Closed Won", "Closed Lost"]}}, + {"_id": 0}, + ) + ) + risk_note = ( + f"Active SEV on {component} ({incident_id}) — " + f"system health {health}/100 — potential SLA impact." + ) + flagged_ids = [] + flagged_orgs = [] + + for opp in open_opps: + oid = opp["opportunity_id"] + org = opp.get("account_name") + + self._sf_o.update_one( + {"opportunity_id": oid}, + { + "$push": {"risk_notes": risk_note}, + "$set": {"updated_at": timestamp}, + }, + ) + doc = self._sf_o.find_one( + {"opportunity_id": oid}, {"_id": 0, "_seq": 0} + ) + if doc: + self._write(f"salesforce/opportunities/{oid}.json", doc) + + flagged_ids.append(oid) + + if org and org not in flagged_orgs: + flagged_orgs.append(org) + + if flagged_ids: + self._emit( + SimEvent( + type="sf_deals_risk_flagged", + timestamp=timestamp, + day=day, + date=date_str, + actors=[], + artifact_ids={"jira": incident_id, "sf_opps": flagged_ids}, + facts={ + "incident_id": incident_id, + "health": health, + "flagged_count": len(flagged_ids), + "opp_ids": flagged_ids, + "account_names": flagged_orgs, + "risk_note": risk_note, + }, + summary=( + f"{len(flagged_ids)} SF deal(s) flagged at-risk " + f"due to incident {incident_id} (health={health})." + ), + tags=["salesforce", "risk", "incident"], + ) + ) + logger.info( + f"[crm] {len(flagged_ids)} SF deal(s) flagged at-risk " + f"→ {incident_id}" + ) + + def handle_incident_resolved( + self, + incident_id: str, + postmortem_link: str, + timestamp: str, + date_str: str, + day: int, + ) -> None: + """ + Called from _advance_incidents() when inc.stage transitions to + 'resolved'. Closes any ZD tickets that were linked to this incident. + """ + if not self._zd_on: + return + + from memory import SimEvent + + linked = list(self._zd.find({"related_incident": incident_id}, {"_id": 0})) + resolved_ids = [] + + for t in linked: + tid = t["ticket_id"] + self._zd.update_one( + {"ticket_id": tid}, + {"$set": {"status": "Solved", "updated_at": timestamp}}, + ) + doc = self._zd.find_one({"ticket_id": tid}, {"_id": 0, "_seq": 0}) + if doc: + self._write(f"zendesk/tickets/{tid}.json", doc) + + self._add_zd_comment( + tid, + ( + f"The underlying engineering issue has been resolved. " + f"Postmortem: {postmortem_link}. " + f"Thank you for your patience." + ), + "Support Agent", + timestamp, + ) + resolved_ids.append(tid) + + if resolved_ids: + self._emit( + SimEvent( + type="zd_tickets_resolved", + timestamp=timestamp, + day=day, + date=date_str, + actors=[], + artifact_ids={"jira": incident_id, "zd_tickets": resolved_ids}, + facts={ + "incident_id": incident_id, + "postmortem_link": postmortem_link, + "resolved_count": len(resolved_ids), + "ticket_ids": resolved_ids, + }, + summary=( + f"{len(resolved_ids)} ZD ticket(s) resolved after " + f"incident {incident_id} closed." + ), + tags=["zendesk", "resolved", "incident"], + ) + ) + logger.info( + f"[crm] {len(resolved_ids)} ZD ticket(s) resolved → {incident_id}" + ) + + def process_outbound_email( + self, + email_data: Dict, + timestamp: str, + date_str: str, + day: int, + ) -> Optional[Dict]: + """ + After each outbound email is emitted. If the subject line contains + sales-intent keywords, creates or advances a Salesforce opportunity + and emits a crm_touchpoint SimEvent. + + Returns the crm_touchpoint event facts dict (or None) so the caller + can optionally log additional context. + """ + if not self._sf_on: + return None + + from memory import SimEvent + + sender = email_data.get("sender", "") + recipient = email_data.get("recipient", "") + sender_org = email_data.get("sender_org", "") + recip_org = email_data.get("recipient_org", email_data.get("to_org", "Unknown")) + + stage = email_data.get("stage", "Prospecting") + + safe_org = recip_org.upper().replace(" ", "").replace("-", "") + existing = self._sf_o.find_one( + { + "account_name": recip_org, + "stage": {"$nin": ["Closed Won", "Closed Lost"]}, + }, + {"_id": 0}, + ) + + if existing: + oid = existing["opportunity_id"] + _STAGE_RANK = { + "Prospecting": 1, + "Value Proposition": 2, + "Proposal/Price Quote": 3, + "Negotiation/Review": 4, + "Closed Won": 5, + "Closed Lost": 0, + } + if _STAGE_RANK.get(stage, 1) > _STAGE_RANK.get( + existing.get("stage", "Prospecting"), 1 + ): + self._sf_o.update_one( + {"opportunity_id": oid}, + { + "$set": { + "stage": stage, + "probability": _STAGE_PROBABILITIES.get(stage, 10), + "updated_at": timestamp, + } + }, + ) + + self._sf_o.update_one( + {"opportunity_id": oid}, + { + "$push": { + "touchpoints": { + "sender": sender, + "subject": email_data.get("subject", ""), + "timestamp": timestamp, + } + } + }, + ) + doc = self._sf_o.find_one({"opportunity_id": oid}, {"_id": 0, "_seq": 0}) + if doc: + self._write(f"salesforce/opportunities/{oid}.json", doc) + else: + seq = self._opp_counter + self._opp_counter += 1 + oid = f"OPP-{seq}" + + try: + ts_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError: + ts_dt = datetime.utcnow() + close_date = (ts_dt + timedelta(days=random.randint(30, 90))).strftime( + "%Y-%m-%d" + ) + + opp = { + "opportunity_id": oid, + "account_name": recip_org, + "type": random.choices(_SF_TYPES, weights=_SF_TYPE_WEIGHTS)[0], + "stage": stage, + "probability": _STAGE_PROBABILITIES.get(stage, 10), + "amount": random.choice([15000, 35000, 50000, 85000, 120000]), + "close_date": close_date, + "owner": sender, + "lead_source": random.choices( + _SF_LEAD_SOURCES, weights=_SF_LEAD_SOURCE_WEIGHTS + )[0], + "next_step": "Awaiting customer response", + "created_at": timestamp, + "updated_at": timestamp, + "risk_notes": [], + "touchpoints": [ + { + "sender": sender, + "subject": email_data.get("subject", ""), + "timestamp": timestamp, + } + ], + "_seq": seq, + } + self._sf_o.insert_one(opp) + self._write( + f"salesforce/opportunities/{oid}.json", + {k: v for k, v in opp.items() if k not in ("_id", "_seq")}, + ) + + + self._embed( + id=oid, + artifact_type="sf_opportunity", + title=f"[{oid}] {recip_org} — {stage}", + content=( + f"Account: {recip_org}\nStage: {stage}\n" + f"Owner: {sender}\nLast touchpoint: {email_data.get('subject', '')}" + ), + day=day, + date=date_str, + timestamp=timestamp, + metadata={ + "opportunity_id": oid, + "account_name": recip_org, + "stage": stage, + }, + ) + logger.info(f"[crm] SF opportunity created: {oid} ({recip_org}, {stage})") + + touchpoint_facts = { + "opportunity_id": oid, + "account_name": recip_org, + "stage": stage, + "sender": sender, + "subject": email_data.get("subject", ""), + } + + self._emit( + SimEvent( + type="crm_touchpoint", + timestamp=timestamp, + day=day, + date=date_str, + actors=[sender], + artifact_ids={"sf_opp": oid}, + facts=touchpoint_facts, + summary=f"CRM touchpoint: {sender} → {recip_org} ({stage})", + tags=["salesforce", "touchpoint", "sales"], + ) + ) + + return touchpoint_facts + + def handle_employee_departure( + self, + employee_name: str, + role: str, + date_str: str, + day: int, + ) -> None: + """ + Called from OrgLifecycleManager when an employee departs. Mirrors the + JIRA ticket reassignment cascade: if the departed employee owned any + SF accounts or open opportunities, they are flagged for reassignment. + + This extends the departure cascade ground truth into the CRM layer + so eval agents can answer cross-domain questions like: + "Which customer accounts are currently without an owner?" + """ + if not self._sf_on: + return + + from memory import SimEvent + + reassigned_accounts = [] + reassigned_opps = [] + + # Accounts + for acc in self._sf_a.find({"owner": employee_name}, {"_id": 0}): + aid = acc["account_id"] + self._sf_a.update_one( + {"account_id": aid}, + {"$set": {"owner": "Pending Reassignment", "risk_flag": True}}, + ) + doc = self._sf_a.find_one({"account_id": aid}, {"_id": 0, "_seq": 0}) + if doc: + self._write(f"salesforce/accounts/{aid}.json", doc) + reassigned_accounts.append(aid) + + for opp in self._sf_o.find( + { + "owner": employee_name, + "stage": {"$nin": ["Closed Won", "Closed Lost"]}, + }, + {"_id": 0}, + ): + oid = opp["opportunity_id"] + risk_note = ( + f"Owner {employee_name} ({role}) departed on {date_str}. " + f"Deal requires immediate reassignment." + ) + self._sf_o.update_one( + {"opportunity_id": oid}, + { + "$set": {"owner": "Pending Reassignment"}, + "$push": {"risk_notes": risk_note}, + }, + ) + doc = self._sf_o.find_one({"opportunity_id": oid}, {"_id": 0, "_seq": 0}) + if doc: + self._write(f"salesforce/opportunities/{oid}.json", doc) + reassigned_opps.append(oid) + + if reassigned_accounts or reassigned_opps: + ts = datetime.utcnow().isoformat() + self._emit( + SimEvent( + type="sf_ownership_lapsed", + timestamp=ts, + day=day, + date=date_str, + actors=[employee_name], + artifact_ids={ + "sf_accounts": reassigned_accounts, + "sf_opps": reassigned_opps, + }, + facts={ + "departed_employee": employee_name, + "role": role, + "accounts_lapsed": reassigned_accounts, + "opportunities_lapsed": reassigned_opps, + }, + summary=( + f"SF ownership lapsed after {employee_name} departure: " + f"{len(reassigned_accounts)} account(s), " + f"{len(reassigned_opps)} open deal(s) pending reassignment." + ), + tags=["salesforce", "lifecycle", "employee_departed"], + ) + ) + logger.info( + f"[crm] SF ownership lapsed: {employee_name} → " + f"{len(reassigned_accounts)} accounts, " + f"{len(reassigned_opps)} open opps" + ) diff --git a/src/day_planner.py b/src/day_planner.py index a270094..c6aa264 100644 --- a/src/day_planner.py +++ b/src/day_planner.py @@ -143,6 +143,14 @@ class DepartmentPlanner: [ ] No collaborative meeting appears in more than one engineer's agenda [ ] All related_ids are null or from that engineer's own owned ticket list [ ] No engineer's estimated_hrs total exceeds their listed capacity + ## MEETING MEDIUM RULE (design_discussion only) + Choose "zoom" when ALL of the following apply: + - 2 or more collaborators + - Topic is architectural, cross-cutting, or requires real-time decision-making + - Team morale is not critically low (i.e., not "very low") + Otherwise default to "slack". + Aim for roughly 35-45% zoom across the day's design discussions. + For all other activity types, omit meeting_medium or set it to "slack". Only output the JSON after confirming all three. @@ -165,7 +173,8 @@ class DepartmentPlanner: "description": "string — max 6 words", "related_id": "string — for ticket_progress: MUST be from owned tickets or null. For pr_review: use the PR ID (e.g. PR-107) from the IN REVIEW TICKETS section above.", "collaborator": ["string"], - "estimated_hrs": float + "estimated_hrs": float, + "meeting_medium": "slack or zoom — for design_discussion only; omit or use slack for all other types" }} ] }} @@ -241,6 +250,7 @@ def plan( eng_plan: Optional[DepartmentDayPlan] = None, lifecycle_context: str = "", email_signals: Optional[List["ExternalEmailSignal"]] = None, + crm_summary: str = "", ) -> DepartmentDayPlan: """ Produce a DepartmentDayPlan. eng_plan is provided to non-Engineering @@ -252,8 +262,12 @@ def plan( dept_history = self._dept_history(mem, day) cross_str = self._format_cross_signals(cross_signals, eng_plan) email_str = self._format_email_signals(email_signals or [], self.dept) + combined_context = [cross_str] if email_str: - cross_str = cross_str + "\n\n" + email_str + combined_context.append(email_str) + if crm_summary: + combined_context.append(f"### CRM STATE SUMMARY\n{crm_summary}") + cross_str = "\n\n".join(combined_context) known_str = ", ".join(sorted(KNOWN_EVENT_TYPES)) morale_label = ( "low" @@ -450,7 +464,13 @@ def _parse_plan( raw_collabs = _coerce_collaborators(a.get("collaborator")) valid_collabs = [c for c in raw_collabs if c in all_valid_names] - + raw_medium = a.get("meeting_medium", "slack") + meeting_medium = ( + raw_medium + if activity_type == "design_discussion" + and raw_medium in ("slack", "zoom") + else "slack" + ) agenda.append( AgendaItem( activity_type=activity_type, @@ -458,6 +478,7 @@ def _parse_plan( related_id=related_id, collaborator=valid_collabs, estimated_hrs=float(a.get("estimated_hrs", 2.0)), + meeting_medium=meeting_medium, ) ) @@ -930,10 +951,9 @@ def __init__(self, config: dict, worker_llm, planner_llm, clock): self._coordinator = OrgCoordinator(config, planner_llm) all_names = [n for members in LIVE_ORG_CHART.values() for n in members] - external_names = [c["name"] for c in config.get("external_contacts", [])] self._validator = PlanValidator( all_names=all_names, - external_contact_names=external_names, + external_contact_names=[], config=config, ) @@ -947,12 +967,18 @@ def plan( clock, lifecycle_context: str = "", email_signals: Optional[List["ExternalEmailSignal"]] = None, + crm_summary: str = "", ) -> OrgDayPlan: """ Full planning pass for one day. Returns an OrgDayPlan the day loop executes against. """ + doc = mem._db["sim_config"].find_one({"_id": "inbound_email_sources"}) + if doc and "sources" in doc: + external_names = [s["name"] for s in doc["sources"]] + self._validator.external_contact_names = external_names + day = state.day date = str(state.current_date.date()) system_time_iso = clock.now("system").isoformat() @@ -994,6 +1020,7 @@ def plan( sprint_context=sprint_contexts.get(eng_key), eng_plan=None, email_signals=email_signals, + crm_summary=crm_summary, ) self._patch_stress_levels(eng_plan, graph_dynamics) dept_plans[eng_key] = eng_plan @@ -1169,6 +1196,10 @@ def _extract_cross_signals( "hr_checkin", "customer_email_routed", "customer_escalation", + "zd_tickets_escalated", + "zd_tickets_resolved", + "sf_deals_risk_flagged", + "sf_ownership_lapsed", } recent = [ diff --git a/src/external_email_ingest.py b/src/external_email_ingest.py index cbae0d2..5db720e 100644 --- a/src/external_email_ingest.py +++ b/src/external_email_ingest.py @@ -54,7 +54,9 @@ from agent_factory import make_agent from causal_chain_handler import CausalChainHandler from config_loader import COMPANY_DESCRIPTION +from crm_system import NullCRMSystem from crewai import Crew, Task +import json_repair from memory import Memory, SimEvent from insider_threat import _NullInjector from utils.persona_utils import get_voice_card @@ -69,7 +71,6 @@ _PROB_EMAIL_DROPPED = 0.15 # customer emails dropped with no action _PROB_CUSTOMER_JIRA = 0.55 # high-priority customer complaint → JIRA _PROB_VENDOR_JIRA = 0.45 # vendor alert → JIRA task -_DEFAULT_SOURCE_COUNT = 7 _HR_EMAIL_WINDOW = (1, 3) # days before hire arrival to send email @@ -114,19 +115,6 @@ def as_cross_signal_text(self) -> str: class ExternalEmailIngestor: """ Manages genesis-time source generation and all email flows during the sim. - - Call order in flow.py - --------------------- - Genesis: - ingestor.generate_sources() # after generate_tech_stack() - - daily_cycle(), before day_planner.plan(): - vendor_signals = ingestor.generate_pre_standup(state) - # vendor_signals injected into DepartmentPlanner prompts - - daily_cycle(), after normal_day / incidents: - ingestor.generate_business_hours(state) # customer routing chains - ingestor.generate_hr_outbound(state) # pre-hire emails """ _MONGO_KEY = "inbound_email_sources" @@ -144,6 +132,7 @@ def __init__( registry, clock, threat_injector=None, + crm=None, ): self._config = config self._mem = mem @@ -163,96 +152,19 @@ def __init__( self._company_desc: str = config.get("simulation", {}).get( "company_description", f"a {self._industry} company" ) - self._sources: Optional[List[dict]] = None self._threat = threat_injector or _NullInjector() + self._crm = crm or NullCRMSystem() 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) - def generate_sources(self) -> List[dict]: - """ - Generate and persist email sources via LLM. Idempotent. - Call after generate_tech_stack() so vendor choices are grounded - in the company's actual tech stack. - """ - existing = self._mem.get_inbound_email_sources() - if existing: - logger.info( - f"[bold green]⏩ Email sources: {len(existing)} persisted " - f"(skipping LLM generation).[/bold green]" - ) - self._sources = existing - return existing + @property + def _sources(self): + """Lazy lookup: pull sources from MongoDB only when needed.""" + doc = self._mem._db["sim_config"].find_one({"_id": "inbound_email_sources"}) + return doc.get("sources", []) if doc else [] - logger.info("[cyan]🌐 Generating inbound email sources...[/cyan]") - - tech_stack = self._mem.tech_stack_for_prompt() - dept_str = ", ".join(self._leads.keys()) - accounts = self._config.get("sales_accounts", []) - accounts_str = ", ".join(accounts[:3]) if accounts else "enterprise customers" - - agent = make_agent( - role="Enterprise IT Architect", - goal=f"Design the realistic external email ecosystem for {self._company_name} which {COMPANY_DESCRIPTION}.", - backstory=( - f"You are an experienced enterprise architect who understands " - f"communication patterns between a {self._industry} company and its " - f"vendors, customers, and partners." - ), - llm=self._planner_llm, - ) - task = Task( - description=( - f"Generate {_DEFAULT_SOURCE_COUNT} realistic inbound email sources" - f"TECH STACK: {tech_stack}\n" - f"DEPARTMENTS: {dept_str}\n" - f"KNOWN CUSTOMERS: {accounts_str}\n\n" - f"DEPARTMENTAL LIAISON LOGIC (Assign Liaisons Based on These Rules):\n" - f" - Engineering_Backend: Responsible for Infrastructure (AWS), Databases (TitanDB), Source Control (GitHub), and Monitoring.\n" - f" - Engineering_Mobile: Responsible for React Native and mobile platform issues.\n" - f" - Product: Responsible for project management (Jira) and feature roadmaps.\n" - f" - Sales_Marketing: Responsible for payment/data vendors (e.g., Stripe) and Customer communication.\n" - f" - QA_Support: Responsible for CI/CD (Jenkins) and testing tool alerts.\n" - f" - HR_Ops: Responsible for legal, compliance, and payroll vendors.\n\n" - f"Rules:\n" - f" - ADHERENCE: Use ONLY vendors that appear in the TECH STACK above. If Jira is listed, never use Trello.\n" - f" - CUSTOMERS: All category:'customer' entries must be from the KNOWN CUSTOMERS list.\n" - f" - TOPICS: Provide 3-5 hyper-specific topics (e.g., 'GitHub Actions Runner Timeout' or 'Stripe API 402 Payment Required').\n" - f" - CATEGORY: exactly 'vendor' or 'customer'.\n" - f" - TRIGGER_ON: array of 'always', 'incident', 'low_health'.\n" - f" - TONE: formal | technical | frustrated | urgent | friendly.\n\n" - f"Raw JSON array only — no preamble, no markdown fences:\n" - f'[{{"name":"GitHub","org":"GitHub Inc.","email":"support@github.com",' - f'"category":"vendor","internal_liaison":"Engineering_Backend",' - f'"trigger_on":["incident"],"tone":"technical",' - f'"topics":["Webhooks failing with 5xx","Pull Request comment API latency"]}}]' - ), - expected_output=f"Raw JSON array of {_DEFAULT_SOURCE_COUNT} source objects.", - agent=agent, - ) - - raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() - sources = self._parse_sources(raw) - - if not sources: - logger.warning( - "[yellow]⚠ Source generation failed — using fallback.[/yellow]" - ) - sources = self._fallback_sources() - - self._mem.save_inbound_email_sources(sources) - self._sources = sources - - logger.info( - f"[green]✓ {len(sources)} email sources generated and persisted.[/green]" - ) - for s in sources: - logger.info( - f" [dim]→ [{s['category']}] {s['name']} " - f"({s['internal_liaison']}) triggers={s['trigger_on']}[/dim]" - ) - return sources def generate_pre_standup(self, state) -> List[ExternalEmailSignal]: """ @@ -837,16 +749,37 @@ 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. Use your typing quirks. No [PLACEHOLDER] tokens. " - f"Output body only — no subject line." + f"Under 80 words. Professional, warm. Use your typing quirks.\n\n" + f"Respond ONLY with a JSON object in this exact format:\n" + f"{{\n" + f' "body": "",\n' + f' "crm_stage": "Choose EXACTLY ONE: Prospecting | Value Proposition | Proposal/Price Quote | Negotiation/Review"\n' + f"}}" ), - expected_output="Email reply body under 80 words.", + expected_output="Valid JSON object with 'body' and 'crm_stage' keys.", agent=agent, ) try: - body = str( + raw = str( Crew(agents=[agent], tasks=[task], verbose=False).kickoff() ).strip() + parsed = json_repair.loads(raw) + + if isinstance(parsed, dict): + data = parsed + elif ( + isinstance(parsed, list) + and len(parsed) > 0 + and isinstance(parsed[0], dict) + ): + data = parsed[0] + else: + data = {} + + body = data.get( + "body", "Thank you for reaching out. We will be in touch shortly." + ) + crm_stage = data.get("crm_stage", "Prospecting") except Exception as exc: logger.warning(f"[external_email] Customer reply LLM failed: {exc}") return None @@ -868,6 +801,20 @@ def _send_customer_reply( direction="outbound", ) + self._crm.process_outbound_email( + email_data={ + "sender": sales_lead, + "recipient": signal.source_name, + "sender_org": self._company_name, + "recipient_org": signal.source_org, + "subject": subject, + "stage": crm_stage, + }, + timestamp=reply_time.isoformat(), + date_str=date_str, + day=state.day, + ) + _exfil_path = self._threat.inject_email( eml_path=str(eml_path), sender=sales_lead, @@ -1243,6 +1190,27 @@ def _generate_email( ) ) + zd_ticket_id = None + if category == "customer": + zd_ticket_id = self._crm.handle_inbound_complaint( + event_facts={ + "subject": subject, + "body": body[:500], + "sender_org": source_org, + }, + timestamp=email_ts.isoformat(), + date_str=date_str, + day=state.day, + ) + if zd_ticket_id: + logger.info( + f" [dim]🔗 ZD ticket {zd_ticket_id} linked to inbound complaint[/dim]" + ) + + artifact_ids = {"email": embed_id, "eml_path": str(eml_path)} + if zd_ticket_id: + artifact_ids["zd_ticket"] = zd_ticket_id + body_preview = body[:200].rstrip() + ("…" if len(body) > 200 else "") return ExternalEmailSignal( diff --git a/src/flow.py b/src/flow.py index 433982d..fb8e194 100644 --- a/src/flow.py +++ b/src/flow.py @@ -5,6 +5,8 @@ Uses NetworkX for social graphs. Uses MongoDB for vector/artifact storage. """ +from pathlib import Path + from config_loader import ( COMPANY_DESCRIPTION, EXPORT_DIR, @@ -36,6 +38,8 @@ import threading from concurrent.futures import ThreadPoolExecutor, as_completed from agent_factory import make_agent +from crm_system import CRMSystem +import genesis from json_repair import json_repair import networkx as nx from datetime import datetime, timedelta @@ -48,6 +52,7 @@ from artifact_registry import ArtifactRegistry from confluence_writer import ConfluenceWriter from ticket_assigner import TicketAssigner +from post_sim_artifacts import run as run_post_sim from external_email_ingest import ExternalEmailIngestor from insider_threat import _NullInjector, InsiderThreatInjector from pydantic import BaseModel, Field @@ -207,7 +212,7 @@ def email_of(name: str) -> str: return f"{name.lower()}@{COMPANY_DOMAIN}" -def build_social_graph() -> nx.Graph: +def build_social_graph(mem: Memory) -> nx.Graph: """Builds a weighted social graph of employees and external contacts.""" G = nx.Graph() @@ -231,7 +236,10 @@ def build_social_graph() -> nx.Graph: weight += 5.0 G.add_edge(n1, n2, weight=weight) - for contact in CONFIG.get("external_contacts", []): + doc = mem._db["sim_config"].find_one({"_id": "inbound_email_sources"}) # + sources = doc.get("sources", []) if doc else [] + + for contact in sources: node_id = contact["name"] liaison_dept = contact.get("internal_liaison", list(LEADS.keys())[0]) liaison_lead = LEADS.get(liaison_dept, next(iter(LEADS.values()))) @@ -240,7 +248,7 @@ def build_social_graph() -> nx.Graph: node_id, dept="External", org=contact.get("org", "External"), - role=contact.get("role", "Contact"), + role=contact.get("category", "Contact"), display_name=contact.get("display_name", node_id), is_lead=False, external=True, @@ -385,7 +393,9 @@ def __init__( self._mem = mem self._graph = social_graph self._worker_llm = worker_llm - self.graph_dynamics = GraphDynamics(build_social_graph(), CONFIG) + self.graph_dynamics = GraphDynamics( + build_social_graph(self._mem), CONFIG, self._mem + ) self._threat = threat_injector or _NullInjector() def create_pr( @@ -583,8 +593,6 @@ def next_jira_id(state, registry=None, dept: str = "") -> str: raise RuntimeError("next_jira_id()") -# Departments that work on action-item tickets rather than code. -# ticket_progress for these depts routes to a completion artifact, never a PR. _NON_ENG_DEPTS = { "HR_Ops", "Sales_Marketing", @@ -593,8 +601,7 @@ def next_jira_id(state, registry=None, dept: str = "") -> str: "Product", } -# Maps non-eng dept to the preferred completion artifact type. -# Used when stamping new tickets and when _handle_ticket_progress branches. + _DEPT_COMPLETION_ARTIFACT: dict[str, str] = { "HR_Ops": "confluence", "Sales_Marketing": "email", @@ -614,22 +621,18 @@ def score_sentiment(messages: List[Dict]) -> float: return round((sum(scores) / len(scores) + 1) / 2, 3) -# ───────────────────────────────────────────── -# 6. SIMULATION -# ───────────────────────────────────────────── class OrgForgeSimulation: - def __init__(self): + def __init__(self, mem: Optional[Memory] = None): self.state = State() - self._mem = Memory() + self._mem = mem if mem is not None else Memory() - # Background embed queue — decouples Stella/Ollama inference from LLM - # generation so both run concurrently rather than sequentially. - # drain() is called before any vector search and at end-of-day. self._embed_worker = EmbedWorker(self._mem) self._embed_worker.start() self._mem.set_embed_worker(self._embed_worker) - self.graph_dynamics = GraphDynamics(build_social_graph(), CONFIG) + self.graph_dynamics = GraphDynamics( + build_social_graph(self._mem), CONFIG, self._mem + ) self.social_graph = self.graph_dynamics.G self._threat = InsiderThreatInjector.from_config( config=CONFIG, @@ -649,6 +652,12 @@ def __init__(self): self._day_planner = DayPlannerOrchestrator( CONFIG, WORKER_MODEL, PLANNER_MODEL, clock=self._clock ) + self._crm = CRMSystem.from_config( + config=CONFIG, + export_base=BASE, + mem=self._mem, + planner_llm=PLANNER_MODEL, + ) self._lifecycle = OrgLifecycleManager( config=CONFIG, graph_dynamics=self.graph_dynamics, @@ -659,6 +668,7 @@ def __init__(self): leads=LEADS, worker_llm=WORKER_MODEL, base_export_dir=BASE, + crm=self._crm, ) self._registry = ArtifactRegistry(self._mem, base_export_dir=BASE) self._confluence = ConfluenceWriter( @@ -685,6 +695,7 @@ def __init__(self): registry=self._registry, clock=self._clock, threat_injector=self._threat, + crm=self._crm, ) self._normal_day = NormalDayHandler( config=CONFIG, @@ -764,16 +775,6 @@ def run(self): # ─── GENESIS ───────────────────────────── def genesis_phase(self): - if self._mem.has_genesis_artifacts(): - logger.info( - "[bold green]⏩ Genesis Guard: Corporate history exists. Skipping LLM generation.[/bold green]" - ) - # The Registry seeds itself from Mongo in __init__, so IDs are already synced. - # Email sources are idempotent — load them even on resume so daily - # generate_pre_standup / generate_business_hours have something to fire against. - self._email_ingestor.generate_sources() - return - logger.info( Panel.fit( f"[bold cyan]{COMPANY_NAME.upper()} — ORGFORGE SIMULATION[/bold cyan]\n" @@ -799,10 +800,6 @@ def genesis_phase(self): self._confluence.generate_tech_stack() tech_context = self._mem.tech_stack_for_prompt() - # ── Persona embedding + email source generation run in parallel ─────── - # Persona embeds are all independent (one per employee, no ordering dep). - # Email source generation depends on tech_stack (done above) but is - # independent of personas and confluence pages. persona_items = [ (dept, name, PERSONAS.get(name, DEFAULT_PERSONA)) for dept, members in ORG_CHART.items() @@ -820,23 +817,15 @@ def _embed_persona(args): persona_futures = { ex.submit(_embed_persona, item): item[1] for item in persona_items } - # Run email source generation concurrently with persona embedding - email_future = ex.submit(self._email_ingestor.generate_sources) - - for future in as_completed(list(persona_futures) + [email_future]): - if future is email_future: - try: - future.result() - except Exception as e: - logger.error(f"[genesis] Email source generation failed: {e}") - else: - name = persona_futures[future] - try: - future.result() - except Exception as e: - logger.error(f"[genesis] Persona embed failed for {name}: {e}") - - # ── Genesis: log pre-sim employee departures from config ───────────────── + + for future in as_completed(persona_futures): + name = persona_futures[future] + try: + future.result() + except Exception as e: + logger.error(f"[genesis] Persona embed failed for {name}: {e}") + + sim_start = datetime.strptime(CONFIG["simulation"]["start_date"], "%Y-%m-%d") for gap in CONFIG.get("knowledge_gaps", []): name = gap["name"] @@ -873,10 +862,6 @@ def _embed_persona(args): f"[genesis] Logged pre-sim departure: {name} (Day {departure_day})" ) - # ── Confluence genesis batches — ENG and MKT run in parallel ───────── - # Pages within each batch are sequential (related_pages dependency). - # The two batches have no cross-references so they're safe to run - # concurrently — each is an independent series of Bedrock calls. self._confluence.write_genesis_batches_parallel( [ { @@ -1003,6 +988,8 @@ def daily_cycle(self): if self.state.day > 1: self._embed_worker.drain() + crm_signals = self._crm.planner_context() + org_plan = self._day_planner.plan( self.state, self._mem, @@ -1010,6 +997,7 @@ def daily_cycle(self): lifecycle_context=self._lifecycle.get_roster_context(), clock=self._clock, email_signals=vendor_signals, + crm_summary=crm_signals, ) if org_plan is None: logger.error( @@ -1122,8 +1110,8 @@ def daily_cycle(self): self._embed_worker.stop() self._print_final_report() + run_post_sim(export_dir=Path(BASE), use_llm=True) - # ─── SPRINT PLANNING ────────────────────── def _handle_sprint_planning(self): sprint_num = self.state.sprint.sprint_number @@ -2000,7 +1988,6 @@ def _handle_incident(self): triggered_contacts = self.graph_dynamics.relevant_external_contacts( event_type="incident_opened", system_health=self.state.system_health, - config=CONFIG, ) for contact in triggered_contacts: self._handle_external_contact(inc, contact) @@ -2038,6 +2025,15 @@ def _handle_incident(self): ) ) + self._crm.handle_incident_opened( + incident_id=ticket_id, + component=root_cause[:80], + health=self.state.system_health, + timestamp=incident_start_iso, + date_str=date_str, + day=self.state.day, + ) + self._mem.log_event( SimEvent( type="escalation_chain", @@ -2158,7 +2154,6 @@ def _advance_incidents(self): triggered_contacts = self.graph_dynamics.relevant_external_contacts( event_type="fix_in_progress", system_health=self.state.system_health, - config=CONFIG, ) for contact in triggered_contacts: self._handle_external_contact(inc, contact) @@ -2203,6 +2198,18 @@ def _advance_incidents(self): ) self.state.system_health = min(100, self.state.system_health + 20) self._write_postmortem(inc) + postmortem_id = getattr(inc, "causal_chain", None) + postmortem_link = ( + postmortem_id.snapshot()[-1] if postmortem_id else inc.ticket_id + ) + self._crm.handle_incident_resolved( + incident_id=inc.ticket_id, + postmortem_link=postmortem_link, + timestamp=cron_time_iso, + date_str=str(self.state.current_date.date()), + day=self.state.day, + ) + self.state.resolved_incidents.append(inc.ticket_id) self.state.daily_incidents_resolved += 1 self._mem.log_event( @@ -2252,6 +2259,13 @@ def _write_postmortem(self, inc: ActiveIncident): if conf_id and getattr(inc, "causal_chain", None): inc.causal_chain.append(conf_id) + + t = self._mem.get_ticket(inc.ticket_id) + if t: + t["causal_chain"] = inc.causal_chain.snapshot() + self._mem.upsert_ticket(t) + save_json(f"{BASE}/jira/{inc.ticket_id}.json", t) + self._mem.log_event( SimEvent( type="postmortem_created", @@ -2349,12 +2363,8 @@ def _end_of_day(self): ) ) - # ── Derive enrichment fields from accumulated daily state ───────────────── - - # Deduplicated actors seen in any event today, ordered by frequency unique_actors = list(dict.fromkeys(self.state.daily_active_actors)) - # Dominant event type fired most often today (e.g. "incident_opened") event_counts = self.state.daily_event_type_counts dominant_event = ( max(event_counts, key=event_counts.get) if event_counts else "normal_day" @@ -2365,15 +2375,12 @@ def _end_of_day(self): {dept_of(name) for name in unique_actors if dept_of(name) != "Unknown"} ) - # Still-open incidents at EOD (not yet resolved) open_incident_ids = [inc.ticket_id for inc in self.state.active_incidents] - # Stress snapshot for today's active actors only — keeps the summary tight stress_today = dict( {name: self.graph_dynamics._stress.get(name, 0) for name in unique_actors} ) - # ── Enriched day_summary SimEvent ──────────────────────────────────────── self._mem.log_event( SimEvent( type="day_summary", @@ -2383,7 +2390,6 @@ def _end_of_day(self): actors=unique_actors, # populated — was always [] artifact_ids={}, facts={ - # ── Original numeric fields (unchanged) ── "incidents_opened": self.state.daily_incidents_opened, "incidents_resolved": self.state.daily_incidents_resolved, "artifacts_created": self.state.daily_artifacts_created, @@ -2391,14 +2397,12 @@ def _end_of_day(self): "morale": self.state.team_morale, "system_health": self.state.system_health, "theme": self.state.daily_theme, - # ── New enrichment fields ── "active_actors": unique_actors, "dominant_event": dominant_event, "event_type_counts": dict(self.state.daily_event_type_counts), "departments_involved": departments_involved, "open_incidents": open_incident_ids, "stress_snapshot": stress_today, - # Trajectory signal — gives DayPlanner a one-glance health picture "health_trend": ( "declining" if self.state.system_health < 60 @@ -2430,7 +2434,6 @@ def _end_of_day(self): ) ) - # ── Reset all daily counters ────────────────────────────────────────────── self.state.daily_incidents_opened = 0 self.state.daily_incidents_resolved = 0 self.state.daily_artifacts_created = 0 @@ -2441,8 +2444,8 @@ def _end_of_day(self): date_str = str(self.state.current_date.date()) for dep in self._lifecycle._departed: if dep.day != self.state.day: - continue # only process today's departures - # Pick the on-call engineer or first dept member as first responder + continue + dept_members = [ n for n in LIVE_ORG_CHART.get(dep.dept, []) if n != dep.name ] @@ -2796,9 +2799,7 @@ def _generate_root_cause(self) -> str: ) args = parser.parse_args() - if args.reset: - mem = Memory() - mem.reset(export_dir=BASE) + mem = genesis.initialize(config=CONFIG, planner_llm=PLANNER_MODEL, reset=args.reset) - sim = OrgForgeSimulation() + sim = OrgForgeSimulation(mem=mem) sim.run() diff --git a/src/genesis.py b/src/genesis.py new file mode 100644 index 0000000..a4cb9fc --- /dev/null +++ b/src/genesis.py @@ -0,0 +1,286 @@ +# genesis.py +from datetime import datetime, timedelta +import json +import logging +import random +import re +from typing import Dict, List +from config_loader import ( + BASE, + COMPANY_DESCRIPTION, + COMPANY_NAME, + CONFIG, + INDUSTRY, + LEADS, + LEGACY, +) +from memory import Memory +from agent_factory import make_agent +from crewai import Task, Crew + +logger = logging.getLogger("orgforge.genesis") + +_DEFAULT_SOURCE_COUNT = 15 +_MAX_RETRIES = 3 + + +def initialize(config, planner_llm, reset=False): + """ + The 'Valet' function: handles setup, reset, and seeding in one go. + Returns the initialized Memory object. + """ + from memory import Memory + + mem = Memory() + + if reset: + mem.reset(export_dir=config.get("base_dir", "export")) + logger.info("[genesis] 🧹 Database and exports wiped.") + + logger.info("[genesis] 🚀 Seeding corporate ground truth...") + seed_tech_stack(mem, planner_llm) + seed_external_sources(mem, planner_llm) + seed_crm_accounts(mem) + logger.info("[genesis] ✅ Seeding complete.") + + return mem + + +def seed_external_sources(mem: Memory, planner_llm): + """Generates the 15 external vendors/customers and saves to MongoDB.""" + if mem.get_inbound_email_sources(): + return + + logger.info("[cyan]🌐 Generating inbound email sources...[/cyan]") + + tech_stack = mem.tech_stack_for_prompt() + dept_str = ", ".join(LEADS.keys()) + db_accounts = list(mem._db["sf_accounts"].find({"type": "Customer"}, {"name": 1})) + + accounts = [doc["name"] for doc in db_accounts] + random.shuffle(accounts) + + agent = make_agent( + role="Enterprise IT Architect", + goal=f"Design the realistic external email ecosystem for {COMPANY_NAME} which {COMPANY_DESCRIPTION}.", + backstory=( + f"You are an experienced enterprise architect who understands " + f"communication patterns between a {INDUSTRY} company and its " + f"vendors, customers, and partners." + ), + llm=planner_llm, + ) + task = Task( + description=( + f"Generate 15 realistic inbound email sources. EXACTLY 8 must be 'customer' category, and 7 must be 'vendor' category.\n" + f"TECH STACK: {tech_stack}\n" + f"DEPARTMENTS: {dept_str}\n" + f"DEPARTMENTAL LIAISON LOGIC (Assign Liaisons Based on These Rules):\n" + f" - Engineering_Backend: Responsible for Infrastructure (AWS), Databases (TitanDB), Source Control (GitHub), and Monitoring.\n" + f" - Engineering_Mobile: Responsible for React Native and mobile platform issues.\n" + f" - Product: Responsible for project management (Jira) and feature roadmaps.\n" + f" - Sales_Marketing: Responsible for payment/data vendors (e.g., Stripe) and Customer communication.\n" + f" - QA_Support: Responsible for CI/CD (Jenkins) and testing tool alerts.\n" + f" - HR_Ops: Responsible for legal, compliance, and payroll vendors.\n\n" + f"Rules:\n" + f" - ADHERENCE: Use ONLY vendors that appear in the TECH STACK above. If Jira is listed, never use Trello.\n" + f" - CUSTOMERS: All category:'customer' entries must be from the KNOWN CUSTOMERS list.\n" + f" - FIRMOGRAPHICS (Customers ONLY): For customer entries, include 'industry' (e.g. Financial Services, Healthcare), 'tier' (Enterprise, Mid-Market, or SMB), 'billing_region' (NA, EMEA, APAC), and 'arr' (realistic numeric annual revenue like 50000, 120000, 350000).\n" + f" - HEALTH SENSITIVITY: Include 'trigger_health_threshold' (int 0-100). Scale: Infrastructure/Enterprise (85-98), SMB/Standard Vendors (70-85).\n" # New Rule + f" - TOPICS: Provide 3-5 hyper-specific topics (e.g., 'GitHub Actions Runner Timeout' or 'Stripe API 402 Payment Required').\n" + f" - CATEGORY: exactly 'vendor' or 'customer'.\n" + f" - TRIGGER_ON: array of 'always', 'incident', 'low_health'.\n" + f" - TONE: formal | technical | frustrated | urgent | friendly.\n\n" + f"Raw JSON array only — no preamble, no markdown fences:\n" + f"[\n" + f' {{"name":"GitHub","org":"GitHub Inc.","email":"support@github.com",' + f'"category":"vendor","internal_liaison":"Engineering_Backend",' + f'"trigger_on":["incident", "low_health"],"trigger_health_threshold":95,"tone":"technical",' + f'"topics":["Webhooks failing with 5xx","Pull Request comment API latency"]}},\n' + f' {{"name":"GlobalFinance","org":"GlobalFinance Corp","email":"cto@globalfinance.com",' + f'"category":"customer","internal_liaison":"Sales_Marketing",' + f'"trigger_on":["always","incident"],"trigger_health_threshold":90,"tone":"formal",' + f'"topics":["SLA reporting","Contract renewal"],"industry":"Financial Services",' + f'"tier":"Enterprise","billing_region":"NA","arr":250000}}\n' + f"]" + ), + expected_output=f"Raw JSON array of {_DEFAULT_SOURCE_COUNT} source objects.", + agent=agent, + ) + + for attempt in range(1, _MAX_RETRIES + 1): + try: + logger.info( + f"[genesis] Generating external sources (Attempt {attempt}/{_MAX_RETRIES})..." + ) + result = str(Crew(agents=[agent], tasks=[task]).kickoff()).strip() + + sources = _parse_sources(result) + + if isinstance(sources, list) and len(sources) >= 10: + mem.save_inbound_email_sources(sources) + + logger.info(f"[genesis] ✅ Successfully seeded {len(sources)} sources.") + for s in sources: + logger.info( + f" [dim]→ [{s['category']}] {s['name']} " + f"({s['internal_liaison']}) triggers={s['trigger_on']}[/dim]" + ) + return + + raise ValueError("Incomplete or malformed list returned.") + + except Exception as e: + logger.warning(f"[genesis] ⚠ Attempt {attempt} failed: {e}") + if attempt == _MAX_RETRIES: + logger.error( + "[genesis] ❌ All retries failed. Simulation cannot start without ground truth." + ) + raise SystemExit(1) + + pass + + +def seed_tech_stack(mem: Memory, planner_llm): + """Generates the tech stack ground truth and saves to Confluence.""" + if mem._artifacts.find_one({"type": "tech_stack"}): + return + + logger.info("[genesis] Generating tech stack...") + + agent = make_agent( + role="Principal Engineer", + goal="Define the canonical technology stack for this company.", + backstory=( + f"You are a principal engineer at {COMPANY_NAME}, " + f"a {INDUSTRY} company. You are documenting the actual " + f"technologies in use — not aspirational, not greenfield. " + f"This is a company with years of history and legacy decisions." + ), + llm=planner_llm, + ) + task = Task( + description=( + f"Define the canonical tech stack for {COMPANY_NAME} " + f"which {COMPANY_DESCRIPTION}\n\n" + f"The legacy system is called '{LEGACY['name']}' " + f"({LEGACY['description']}).\n\n" + f"Respond ONLY with a JSON object with these keys:\n" + f" database, backend_language, frontend_language, mobile, " + f" infra, message_queue, source_control, ci_cd, " + f" monitoring, notable_quirks\n\n" + f"Each value is a short string (1-2 sentences max). " + f"Include at least one legacy wart or technical debt item. " + f"No preamble, no markdown fences." + ), + expected_output="A single JSON object. No preamble.", + agent=agent, + ) + + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() + + try: + stack = json.loads(raw.replace("```json", "").replace("```", "").strip()) + except json.JSONDecodeError: + logger.warning( + "[confluence] Tech stack JSON parse failed — using minimal fallback." + ) + stack = { + "notable_quirks": "Stack unknown — legacy system predates documentation." + } + + mem.save_tech_stack(stack) + logger.info(f"[confluence] ✓ Tech stack established: {list(stack.keys())}") + + pass + + +def seed_crm_accounts(mem: Memory): + """Seeds Salesforce accounts from the external sources in MongoDB.""" + doc = mem._db["sim_config"].find_one({"_id": "inbound_email_sources"}) + if not doc: + return + + if ( + not CONFIG["crm"]["salesforce"]["enabled"] + or not CONFIG["crm"]["salesforce"]["seed_accounts"] + ): + return + + contacts = list( + mem._db["sim_config"].find( + {"_id": "inbound_email_sources", "category": "customer"}, {"_id": 0} + ) + ) + + start_dt = datetime.strptime(CONFIG["simulation"]["start_date"], "%Y-%m-%d") + + for contact in contacts: + org_name = contact.get("org", "Unknown") + safe_id = org_name.upper().replace(" ", "").replace("-", "") + account_id = f"ACC-{safe_id}" + + if mem._db["sf_accounts"].find_one({"account_id": account_id}): + continue + + days_ago = random.randint(30, 730) + hours_ago = random.randint(0, 23) + mins_ago = random.randint(0, 59) + created_dt = start_dt - timedelta( + days=days_ago, hours=hours_ago, minutes=mins_ago + ) + + account = { + "account_id": account_id, + "name": org_name, + "primary_contact": contact.get("name", "Unknown Contact"), + "type": "Customer", + "industry": contact.get("industry", "Technology"), + "tier": contact.get( + "tier", + random.choices( + ["Enterprise", "Mid-Market", "SMB"], weights=[0.2, 0.5, 0.3] + )[0], + ), + "website": f"https://www.{org_name.lower().replace(' ', '')}.com", + "billing_region": contact.get( + "billing_region", + random.choices(["NA", "EMEA", "APAC"], weights=[0.6, 0.3, 0.1])[0], + ), + "arr": contact.get("arr", random.choice([50000, 100000, 250000, 500000])), + "owner": contact.get("internal_liaison", "Unassigned"), + "created_at": created_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + "risk_flag": False, + } + mem._db["sf_accounts"].insert_one({**account, "_seq": 0}) + + path = BASE / "salesforce/accounts/{account_id}.json" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as fh: + json.dump(account, fh, indent=2) + + logger.info(f"[crm] SF account seeded: {account_id} ({org_name})") + + pass + + +@staticmethod +def _parse_sources(raw: str) -> List[dict]: + cleaned = re.sub(r"^```[a-z]*\n?", "", raw.strip()).rstrip("` \n") + try: + parsed = json.loads(cleaned) + if not isinstance(parsed, list): + raise ValueError("not a list") + required = { + "name", + "org", + "email", + "category", + "internal_liaison", + "trigger_on", + "topics", + } + return [s for s in parsed if required.issubset(s.keys())] + except Exception as exc: + logger.warning(f"[external_email] Source parse failed: {exc}") + return [] diff --git a/src/graph_dynamics.py b/src/graph_dynamics.py index ff07a24..6826d84 100644 --- a/src/graph_dynamics.py +++ b/src/graph_dynamics.py @@ -12,6 +12,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Dict, List, Optional, Tuple +from memory import Memory import networkx as nx @@ -53,9 +54,10 @@ class GraphDynamics: that holds a reference to the graph sees live data automatically. """ - def __init__(self, G: nx.Graph, config: dict): + def __init__(self, G: nx.Graph, config: dict, mem: Memory): self.G = G self.cfg: Dict = {**DEFAULT_CFG, **config.get("graph_dynamics", {})} + self._mem = mem personas = config.get("personas", {}) self._stress: Dict[str, int] = { @@ -278,7 +280,6 @@ def relevant_external_contacts( self, event_type: str, system_health: int, - config: dict, ) -> List[dict]: """ Returns external contact config entries that should be triggered @@ -286,13 +287,28 @@ def relevant_external_contacts( Called from _advance_incidents() to decide whether to generate an external contact summary. """ + + doc = self._mem._db["sim_config"].find_one({"_id": "inbound_email_sources"}) + if not doc or "sources" not in doc: + return [] + triggered = [] - for contact in config.get("external_contacts", []): - if event_type not in contact.get("trigger_events", []): + for contact in doc["sources"]: + triggers = contact.get("trigger_on", []) + + if "always" in triggers: + triggered.append(contact) continue - threshold = contact.get("trigger_health_threshold", 100) - if system_health <= threshold: + + if "incident" in triggers and ( + "incident" in event_type or "fix" in event_type + ): triggered.append(contact) + continue + + if "low_health" in triggers and system_health < 80: + triggered.append(contact) + return triggered def apply_sentiment_stress(self, actors: List[str], vader_compound: float) -> None: diff --git a/src/memory.py b/src/memory.py index 2f81118..5e52f7c 100644 --- a/src/memory.py +++ b/src/memory.py @@ -46,13 +46,6 @@ EMBED_MODEL = os.environ.get("EMBED_MODEL", "mxbai-embed-large") EMBED_DIMS = int(os.environ.get("EMBED_DIMS", "1024")) -# ───────────────────────────────────────────── -# TOKEN USAGE TRACKING (debug mode only) -# Set DEBUG_TOKEN_TRACKING=true in environment -# or pass debug_tokens=True to Memory.__init__ -# ───────────────────────────────────────────── - -DEBUG_TOKEN_TRACKING = os.environ.get("DEBUG_TOKEN_TRACKING", "false").lower() == "true" _SKIP_EMBED_TYPES = { "jira_ticket_created", @@ -196,8 +189,6 @@ def embed(self, text: str, input_type: str = "search_document") -> List[float]: r.raise_for_status() - self._prompt_embed_tokens = r.json()["prompt_eval_count"] - return r.json()["embeddings"][0] except requests.exceptions.HTTPError as e: # 2. Attempt to pull the specific error message from Ollama's JSON @@ -326,9 +317,6 @@ def embed(self, text: str, input_type: str = "search_document") -> List[float]: result = self._json.loads(raw_body) vector = result["embeddings"]["float"][0] headers = resp.get("ResponseMetadata", {}).get("HTTPHeaders", {}) - self._prompt_embed_tokens = int( - headers.get("x-amzn-bedrock-input-token-count", 0) - ) return vector except self._client.exceptions.ThrottlingException: wait = 6.2 * (attempt + 1) # backoff: 6.2, 12.4, 18.6 @@ -376,7 +364,6 @@ class Memory: def __init__( self, mongo_uri: str = MONGO_URI, - debug_tokens: bool = False, mongo_client=None, # inject mongomock.MongoClient() in tests ): self._embedder = build_embedder() @@ -389,8 +376,6 @@ def __init__( self._artifacts = self._db["artifacts"] self._events = self._db["events"] - self._debug_tokens = debug_tokens or DEBUG_TOKEN_TRACKING - self._token_usage = self._db["token_usage"] if self._debug_tokens else None self._artifacts = self._db["artifacts"] self._events = self._db["events"] self._jira = self._db["jira_tickets"] @@ -439,20 +424,7 @@ def _embed( logging is guaranteed regardless of which path triggers the embed. """ vector = self._embedder.embed(text, input_type=input_type) - self.log_token_usage( - caller=caller, - call_type="embed", - model=EMBED_MODEL, - day=getattr(self, "_current_day", 0), - timestamp=datetime.now(timezone.utc).isoformat(), - total_tokens=getattr(self._embedder, "_prompt_embed_tokens", 0), - prompt_tokens=getattr(self._embedder, "_prompt_embed_tokens", 0), - extra={ - "doc_id": doc_id, - "doc_type": doc_type, - "text_len": len(text), - }, - ) + return vector def _init_vector_indexes(self): @@ -1020,10 +992,8 @@ def previous_day_context(self, current_day: int) -> str: sort=[("timestamp", -1)], ) header = "" - date_str = "" if summary_doc: f = summary_doc.get("facts", {}) - date_str = summary_doc.get("date", "") header = ( f"Yesterday (Day {prev_day}): " f"system health {f.get('system_health', '?')}, " @@ -1050,6 +1020,11 @@ def previous_day_context(self, current_day: int) -> str: "morale_intervention", "employee_departed", "employee_hired", + "zd_ticket_opened", + "zd_tickets_escalated", + "zd_tickets_resolved", + "sf_deals_risk_flagged", + "sf_ownership_lapsed", } docs = list( @@ -1569,8 +1544,6 @@ def reset(self, export_dir: Optional[str] = None): self._slack = self._db["slack_messages"] self._plans = self._db["dept_plans"] self._conversation_summaries = self._db["conversation_summaries"] - if self._debug_tokens: - self._token_usage = self._db["token_usage"] self._event_log = [] self._init_vector_indexes() @@ -1597,82 +1570,11 @@ def reset(self, export_dir: Optional[str] = None): root_logger.addHandler(new_handler) logger.info(f"[memory] 🗑️ Export directory cleared: {export_path}") - def log_token_usage( - self, - caller: str, # e.g. "write_adhoc_page", "_handle_async_question" - call_type: str, # "llm" | "embed" - model: str, # full model string, e.g. "bedrock/claude-sonnet-4-6" - day: int, - timestamp: str, - prompt_tokens: int = 0, - completion_tokens: int = 0, - total_tokens: int = 0, - extra: Optional[Dict] = None, # any caller-specific metadata - ) -> None: - """ - Log a single LLM or embed call's token usage to MongoDB. - No-op when debug_tokens is False so production runs are unaffected. - """ - if not self._debug_tokens or self._token_usage is None: - return - - doc = { - "day": day, - "timestamp": timestamp, - "call_type": call_type, # "llm" or "embed" - "caller": caller, - "model": model, - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, - **(extra or {}), - } - try: - self._token_usage.insert_one(doc) - except Exception as e: - logger.warning(f"[memory] token_usage insert failed: {e}") - - def token_usage_summary(self) -> Dict: - """ - Aggregate token usage by call_type and caller. - Returns a dict suitable for logging at end of simulation. - Only meaningful when debug_tokens=True. - """ - if not self._debug_tokens or self._token_usage is None: - return {"debug_tokens": False} - - pipeline = [ - { - "$group": { - "_id": { - "call_type": "$call_type", - "caller": "$caller", - "model": "$model", - }, - "total_tokens": {"$sum": "$total_tokens"}, - "prompt_tokens": {"$sum": "$prompt_tokens"}, - "completion_tokens": {"$sum": "$completion_tokens"}, - "call_count": {"$sum": 1}, - } - }, - {"$sort": {"total_tokens": -1}}, - ] - - rows = list(self._token_usage.aggregate(pipeline)) - grand_total = sum(r["total_tokens"] for r in rows) - - return { - "debug_tokens": True, - "grand_total": grand_total, - "by_caller": rows, - } - def has_genesis_artifacts(self) -> bool: """ Returns True if Genesis artifacts already exist. This prevents re-running expensive LLM Genesis calls. """ - # We check the events collection for any 'genesis' tag return self._events.count_documents({"tags": "genesis"}) > 0 def save_checkpoint( @@ -1871,6 +1773,51 @@ def get_inbound_email_sources(self) -> Optional[list]: doc = self._db["sim_config"].find_one({"_id": "inbound_email_sources"}) return doc["sources"] if doc else None + def context_for_pr_review( + self, + pr_id: str, + ticket_id: Optional[str] = None, + as_of_time: Optional[Any] = None, + n: int = 2, + ) -> str: + """ + Builds deterministic reviewer context from the PR and its linked ticket. + Combines: recent PR comments and author's ticket updates. + """ + lines = [] + iso = self._to_iso(as_of_time) + + pr = self._prs.find_one({"pr_id": pr_id}, {"_id": 0}) + if pr: + pr_comments = pr.get("comments", []) + if iso: + pr_comments = [c for c in pr_comments if c.get("timestamp", "") <= iso] + if pr_comments: + lines.append("=== PR REVIEW HISTORY ===") + for c in pr_comments[-3:]: + lines.append( + f" [{c['date']}] {c['author']} [{c.get('verdict', '?')}]: {c['text'][:200]}" + ) + + if ticket_id: + ticket = self._jira.find_one({"id": ticket_id}, {"_id": 0, "comments": 1}) + if ticket: + t_comments = ticket.get("comments", []) + if iso: + t_comments = [c for c in t_comments if c.get("created", "") <= iso] + author = pr.get("author") if pr else None + author_updates = [ + c for c in t_comments if not author or c.get("author") == author + ] + if author_updates: + lines.append("=== AUTHOR TICKET UPDATES ===") + for c in author_updates[-3:]: + lines.append( + f" [{c['date']}] {c['author']}: {c['text'][:200]}" + ) + + return "\n".join(lines) if lines else "No prior context found." + def context_for_ticket( self, ticket_id: str, @@ -2163,13 +2110,6 @@ def context_for_ticket_progress( return "\n".join(lines) - # ───────────────────────────────────────────────────────────────────────────── - # CONVERSATION SUMMARY STORE - # Used by 1on1 and mentoring — summary is extracted from the last turn's output - # by the LLM itself (no extra call). Call save_conversation_summary() at write - # time, context_for_person_conversations() at read time. - # ───────────────────────────────────────────────────────────────────────────── - def save_conversation_summary( self, conv_type: str, # "1on1" | "mentoring" @@ -2269,12 +2209,6 @@ def context_for_person_conversations( return "\n".join(lines) - # ───────────────────────────────────────────────────────────────────────────── - # DESIGN DISCUSSION LINKAGE FOR ASYNC QUESTIONS - # Finds design_discussion events where the involved actors overlap with - # the ticket's known participants, and returns their slack thread IDs. - # ───────────────────────────────────────────────────────────────────────────── - def design_discussions_for_ticket( self, ticket_id: str, diff --git a/src/normal_day.py b/src/normal_day.py index b2f499c..593ca1e 100644 --- a/src/normal_day.py +++ b/src/normal_day.py @@ -1530,9 +1530,16 @@ def _handle_design_discussion( date_str: str, ) -> List[str]: """ - Small group design discussion — typically 2-3 engineers. - Generates: Slack thread + optional Confluence stub. - Uses a single-shot JSON generation to save output tokens while preserving personas. + Small group design discussion — 2-3 engineers. + + Routes on item.meeting_medium: + "slack" → Slack thread (original path) + optional Confluence stub + "zoom" → Zoom transcript (.md) + optional Confluence stub + The transcript captures decisions verbatim — the knowledge + gap evals want content that never surfaced in Jira/Confluence. + + Both paths share participant resolution, clock advance, and SimEvent + structure so downstream evals see a uniform event schema. """ initiator = eng_plan.name collaborators = item.collaborator or ( @@ -1540,7 +1547,7 @@ def _handle_design_discussion( ) participants = list({initiator} | set(collaborators)) - chat_duration_mins = random.randint(5, 45) + chat_duration_mins = random.randint(15, 50) chat_duration_hours = chat_duration_mins / 60.0 provisional_start, _ = self._clock.sync_and_advance( participants, hours=chat_duration_hours @@ -1554,102 +1561,53 @@ def _handle_design_discussion( max_extras=1, ) - meeting_start, meeting_end = self._clock.sync_and_advance( - participants, - hours=0, - ) + meeting_start, _ = self._clock.sync_and_advance(participants, hours=0) meeting_time_iso = meeting_start.isoformat() ctx = self._mem.context_for_prompt( item.description, n=3, as_of_time=meeting_time_iso ) - backstory = get_voice_card(participants, "async", self._gd, self._mem) + medium = getattr(item, "meeting_medium", "slack") - turn_speakers = [initiator] + [ - participants[i % len(participants)] for i in range(1, random.randint(5, 8)) - ] - speaker_sequence = ", ".join(turn_speakers) - - agent = make_agent( - role="Slack Conversation Simulator", - 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, - ) - - task = Task( - description=( - 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"Relevant context: {ctx}\n\n" - f"Turn order: {speaker_sequence}\n\n" - f"Rules:\n" - f"- {initiator} opens by framing the problem, constraints, or trade-off they are wrestling with.\n" - f"- Others react as engineers working through it — raise a trade-off, push back, or propose a next step. Do not just agree.\n" - f"- CRITICAL: DO NOT use generic corporate openers like 'Hey team, let's discuss...' or 'Could you clarify...'. Start naturally.\n" - f"- Each message must sound distinctly like that person based on their voice card and mood.\n" - f"- Each message 1-3 sentences max. Do not add narration.\n\n" - f"Respond ONLY with a JSON array. No preamble, no markdown fences.\n" - f'[{{"speaker": "Name", "message": "text"}}, ...]' - ), - expected_output='A JSON array of objects with "speaker" and "message" keys.', - agent=agent, - ) - - raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()) - - turns = _parse_turn_list(raw, "handle_async_question") - - if not turns: - logger.warning( - "[design_discussion] Parsed turns are empty, falling back to empty thread." + if medium == "zoom": + artifact_path, artifact_id, tags = self._run_zoom_design_discussion( + initiator=initiator, + participants=participants, + topic=item.description, + ctx=ctx, + meeting_time_iso=meeting_time_iso, + date_str=date_str, ) - turns = [] - - messages = [] - current_msg_time = datetime.fromisoformat(meeting_time_iso) - for turn in turns: - speaker = turn.get("speaker", "").strip() - text = turn.get("message", "").strip() - if speaker and text: - messages.append( - {"user": speaker, "text": text, "ts": current_msg_time.isoformat()} - ) - current_msg_time += timedelta(minutes=random.randint(1, 8)) - - depts = {dept_of_name(p, self._org_chart) for p in participants} - if len(depts) > 1: - dept_channel = "digital-hq" + medium_key = "zoom_transcript" else: - dept_channel = ( - dept_of_name(initiator, self._org_chart).lower().replace(" ", "-") + artifact_path, artifact_id, tags = self._run_slack_design_discussion( + initiator=initiator, + participants=participants, + topic=item.description, + ctx=ctx, + meeting_time_iso=meeting_time_iso, + date_str=date_str, ) - - slack_path, thread_id = self._save_slack( - messages, dept_channel, interaction_type="design" - ) + medium_key = "slack_thread" conf_id = None - if random.random() < 0.30 and messages: + if random.random() < 0.30 and artifact_id: + stub_messages = [{"user": initiator, "text": item.description}] conf_id = self._create_design_doc_stub( - initiator, participants, item.description, ctx, date_str, messages + initiator, participants, item.description, ctx, date_str, stub_messages ) facts = { "topic": item.description, "participants": participants, "spawned_doc": conf_id is not None, - "message_count": len(messages), + "medium": medium, } artifact_ids = { - "slack_path": slack_path, - "slack_thread": thread_id, + medium_key: artifact_id, + "artifact_path": artifact_path, "confluence": conf_id or "", } @@ -1666,9 +1624,9 @@ def _handle_design_discussion( ) ticket_chain = CausalChainHandler(related_ticket_id) if prior: - for artifact_id in prior.get("facts", {}).get("causal_chain", []): - ticket_chain.append(artifact_id) - ticket_chain.append(thread_id) + for aid in prior.get("facts", {}).get("causal_chain", []): + ticket_chain.append(aid) + ticket_chain.append(artifact_id) if conf_id: ticket_chain.append(conf_id) facts["causal_chain"] = ticket_chain.snapshot() @@ -1684,16 +1642,18 @@ def _handle_design_discussion( artifact_ids=artifact_ids, facts=facts, summary=( - f"{initiator} led design discussion on '{item.description[:80]}' " + f"{initiator} led {'Zoom' if medium == 'zoom' else 'Slack'} " + f"design discussion on '{item.description[:80]}' " f"with {', '.join(p for p in participants if p != initiator)}." ), - tags=["design_discussion", "slack"], + tags=tags + (["confluence"] if conf_id else []), ) ) self._gd.record_slack_interaction(participants) logger.info( - f" [dim]🏗️ Design discussion: {item.description[:80]} " + f" [dim]{'📹' if medium == 'zoom' else '🏗️ '} Design discussion " + f"[{medium}]: {item.description[:80]} " f"({len(participants)} engineers)[/dim]" ) return participants @@ -2673,6 +2633,258 @@ def _save_slack( return slack_path, thread_id + def _run_slack_design_discussion( + self, + initiator: str, + participants: List[str], + topic: str, + ctx: str, + meeting_time_iso: str, + date_str: str, + ) -> Tuple[str, str, List[str]]: + """ + Original Slack-thread path extracted from _handle_design_discussion. + Returns (slack_path, thread_id, tags). + """ + from utils.persona_utils import get_voice_card + + backstory = get_voice_card(participants, "async", self._gd, self._mem) + + turn_speakers = [initiator] + [ + participants[i % len(participants)] for i in range(1, random.randint(5, 8)) + ] + speaker_sequence = ", ".join(turn_speakers) + + agent = make_agent( + role="Slack Conversation Simulator", + goal=( + "Write a realistic multi-turn Slack technical design discussion. " + "Treat the provided backstory as character reference sheets." + ), + backstory=backstory, + llm=self._planner, + ) + + task = Task( + description=( + f"COMPANY CONTEXT: {self._company} which {COMPANY_DESCRIPTION}\n" + f"Write a full Slack thread for a design discussion.\n\n" + f"Topic: {topic}\n" + f"Relevant context: {ctx}\n\n" + f"Turn order: {speaker_sequence}\n\n" + f"Rules:\n" + f"- {initiator} opens by framing the problem or trade-off.\n" + f"- Others raise trade-offs, push back, or propose a next step. Do not just agree.\n" + f"- CRITICAL: DO NOT use generic openers like 'Hey team, let's discuss...'\n" + f"- Each message 1-3 sentences max. No narration.\n\n" + f"Respond ONLY with a JSON array. No preamble, no markdown fences.\n" + f'[{{"speaker": "Name", "message": "text"}}, ...]' + ), + expected_output='JSON array of {{"speaker", "message"}} objects.', + agent=agent, + ) + + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()) + turns = _parse_turn_list(raw, "_run_slack_design_discussion") + + messages = [] + current_msg_time = datetime.fromisoformat(meeting_time_iso) + for turn in turns: + speaker = turn.get("speaker", "").strip() + text = turn.get("message", "").strip() + if speaker and text: + messages.append( + {"user": speaker, "text": text, "ts": current_msg_time.isoformat()} + ) + current_msg_time += timedelta(minutes=random.randint(1, 8)) + + depts = {dept_of_name(p, self._org_chart) for p in participants} + channel = ( + "digital-hq" + if len(depts) > 1 + else dept_of_name(initiator, self._org_chart).lower().replace(" ", "-") + ) + + slack_path, thread_id = self._save_slack( + messages, channel, interaction_type="design" + ) + return slack_path, thread_id, ["design_discussion", "slack"] + + def _run_zoom_design_discussion( + self, + initiator: str, + participants: List[str], + topic: str, + ctx: str, + meeting_time_iso: str, + date_str: str, + ) -> Tuple[str, str, List[str]]: + """ + Zoom-transcript path for design discussions. + + The LLM writes a realistic meeting transcript — attendees speak in full + sentences, decisions are stated explicitly, action items are called out. + This is the knowledge-gap surface: decisions made verbally that won't + appear in Jira or Confluence unless someone writes them up afterward. + + Returns (file_path, transcript_id, tags). + """ + from utils.persona_utils import get_voice_card + + backstory = get_voice_card(participants, "sync", self._gd, self._mem) + + agent = make_agent( + role="Meeting Transcript Generator", + goal=( + "Write a realistic Zoom meeting transcript for a technical design session. " + "Treat the provided backstory as character reference sheets." + ), + backstory=backstory, + llm=self._planner, + ) + + attendee_list = ", ".join(participants) + + task = Task( + description=( + f"COMPANY CONTEXT: {self._company} which {COMPANY_DESCRIPTION}\n\n" + f"Write a realistic Zoom meeting transcript for a live design discussion.\n\n" + f"Topic: {topic}\n" + f"Attendees: {attendee_list}\n" + f"Host/initiator: {initiator}\n" + f"Relevant context: {ctx}\n\n" + f"## Format rules\n" + f"- Output a JSON array. Each element is one speaker turn.\n" + f"- Schema: [{{'speaker': 'Name', 'message': 'text'}}]\n" + f"- Turns should be 1-4 sentences. People interrupt, trail off, agree, disagree.\n" + f"- {initiator} opens by stating the meeting goal clearly.\n" + f"- The group must reach at least one explicit decision or action item before the meeting ends.\n" + f"- Include a brief wrap-up turn from {initiator} that states the decision and who owns the follow-up.\n" + f"- DO NOT use filler like 'Great point!' or 'Absolutely!' as standalone turns.\n" + f"- Speak naturally — 'gonna', contractions, occasional 'um' are fine.\n" + f"- NO narration, NO stage directions, NO markdown in message text.\n\n" + f"Respond ONLY with the JSON array. No preamble, no markdown fences." + ), + expected_output='JSON array of {{"speaker", "message"}} objects only.', + agent=agent, + ) + + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()) + turns = _parse_turn_list(raw, "_run_zoom_design_discussion") + + if not turns: + logger.warning( + "[zoom_design_discussion] Empty transcript — falling back to Slack path." + ) + return self._run_slack_design_discussion( + initiator, participants, topic, ctx, meeting_time_iso, date_str + ) + + transcript_path, transcript_id = self._save_zoom_transcript( + turns=turns, + participants=participants, + topic=topic, + meeting_time_iso=meeting_time_iso, + date_str=date_str, + ) + return ( + transcript_path, + transcript_id, + ["design_discussion", "zoom", "transcript"], + ) + + def _save_zoom_transcript( + self, + turns: List[dict], + participants: List[str], + topic: str, + meeting_time_iso: str, + date_str: str, + ) -> Tuple[str, str]: + """ + Persists a Zoom-style meeting transcript. + + Storage: + - Disk: {output_dir}/zoom/{date}/zoom_{id}.md (human-readable) + - MongoDB: embed_artifact type="zoom_transcript" + (same RAG surface as slack_thread / confluence) + + Returns (file_path, transcript_id). + """ + import os + import uuid + + transcript_id = f"zoom_{date_str}_{uuid.uuid4().hex[:8]}" + + lines = [ + f"# Zoom Meeting Transcript", + f"**Date:** {date_str}", + f"**Topic:** {topic}", + f"**Attendees:** {', '.join(participants)}", + f"", + f"---", + f"", + ] + + current_ts = datetime.fromisoformat(meeting_time_iso) + full_text_parts = [] + + for turn in turns: + speaker = turn.get("speaker", "").strip() + message = turn.get("message", "").strip() + if not (speaker and message): + continue + ts_label = current_ts.strftime("%H:%M:%S") + lines.append(f"**[{ts_label}] {speaker}:** {message}") + lines.append("") + full_text_parts.append(f"{speaker}: {message}") + current_ts += timedelta(minutes=random.randint(1, 4)) + + md_content = "\n".join(lines) + full_text = "\n".join(full_text_parts) + + zoom_dir = os.path.join(self._base, "zoom", date_str) + os.makedirs(zoom_dir, exist_ok=True) + file_path = os.path.join(zoom_dir, f"{transcript_id}.md") + with open(file_path, "w", encoding="utf-8") as f: + f.write(md_content) + + embed_metadata = { + "participants": participants, + "topic": topic, + "turn_count": len(turns), + "medium": "zoom", + } + + if self._embed_worker: + self._embed_worker.enqueue( + id=transcript_id, + type="zoom_transcript", + title=f"Zoom: {topic[:80]}", + content=full_text, + day=self._state.day, + date=date_str, + timestamp=meeting_time_iso, + metadata=embed_metadata, + ) + else: + self._mem.embed_artifact( + id=transcript_id, + type="zoom_transcript", + title=f"Zoom: {topic[:80]}", + content=full_text, + day=self._state.day, + date=date_str, + timestamp=meeting_time_iso, + metadata=embed_metadata, + ) + + logger.info( + f" [dim]📹 Zoom transcript saved: {transcript_id} " + f"({len(turns)} turns, {len(participants)} attendees)[/dim]" + ) + return file_path, transcript_id + def _save_md(self, path: str, content: str) -> None: import os diff --git a/src/org_lifecycle.py b/src/org_lifecycle.py index ad7a76f..283c131 100644 --- a/src/org_lifecycle.py +++ b/src/org_lifecycle.py @@ -34,6 +34,7 @@ from agent_factory import make_agent +from crm_system import NullCRMSystem from memory import Memory, SimEvent from graph_dynamics import GraphDynamics @@ -103,6 +104,7 @@ def __init__( leads: Dict[str, str], worker_llm=None, base_export_dir: str = "", + crm=None, ): self._cfg = config.get("org_lifecycle", {}) self._gd = graph_dynamics @@ -113,6 +115,7 @@ def __init__( self._leads = leads self._llm = worker_llm self._base = base_export_dir + self._crm = crm or NullCRMSystem() self._departed: List[DepartureRecord] = [] self._hired: List[HireRecord] = [] @@ -442,6 +445,13 @@ def _execute_departure( ) ) + self._crm.handle_employee_departure( + employee_name=name, + role=actual_role, + date_str=date_str, + day=day, + ) + logger.info( f" [red]👋 Departure:[/red] {name} ({dept}) [{record.reason}]. " f"{len(edge_snapshot)} edges severed. Centrality was {departing_centrality:.3f}." diff --git a/src/plan_validator.py b/src/plan_validator.py index f679ad0..6c8bbe4 100644 --- a/src/plan_validator.py +++ b/src/plan_validator.py @@ -81,10 +81,28 @@ def __init__( external_contact_names: List[str], config: dict, ): - self._valid_actors: Set[str] = set(all_names) | set(external_contact_names) + self._internal_names: Set[str] = set(all_names) + self._external_names: Set[str] = set(external_contact_names) + + self._valid_actors: Set[str] = self._internal_names | self._external_names self._config = config self._novel_log: List[ProposedEvent] = [] + @property + def external_contact_names(self) -> List[str]: + """Returns the current list of valid external names.""" + return list(self._external_names) + + @external_contact_names.setter + def external_contact_names(self, names: List[str]): + """ + Refresh the whitelist of valid external actors. + Called daily by the DayPlannerOrchestrator. + """ + self._external_names = set(names) + # Recalculate the union set used by _validate_one + self._valid_actors = self._internal_names | self._external_names + def validate_plan( self, proposed: List[ProposedEvent], diff --git a/src/planner_models.py b/src/planner_models.py index 87cbfff..cc3354a 100644 --- a/src/planner_models.py +++ b/src/planner_models.py @@ -27,6 +27,7 @@ class AgendaItem: estimated_hrs: float = 2.0 deferred: bool = False defer_reason: Optional[str] = None + meeting_medium: str = "slack" @dataclass @@ -251,4 +252,5 @@ class ValidationResult: "email_dropped", "dlp_alert", "secret_detected", + "zoom_meeting", } diff --git a/src/post_sim_artifacts.py b/src/post_sim_artifacts.py new file mode 100644 index 0000000..9afd05d --- /dev/null +++ b/src/post_sim_artifacts.py @@ -0,0 +1,1059 @@ +""" +post_sim_artifacts.py +===================== +Post-simulation artifact generator for OrgForge. + +Produces three artifact sets that are fully derivable from the SimEvent log +without running the daily simulation loop again: + + export/nps/ + responses/{org_name}_{day}.json — per-customer NPS survey response + summary.json — aggregate score, detractor/promoter counts + + export/invoices/ + {invoice_id}.json — per-customer invoice with SLA credit line items + + export/datadog/ + metrics.jsonl — time-series health + latency metrics (Prometheus-compatible) + alerts.jsonl — fired alert records linked to incidents + +""" + +from __future__ import annotations + +import argparse +import json +import logging +import math +import os +import random +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from config_loader import ( + BASE, + COMPANY_DESCRIPTION, + COMPANY_DOMAIN, + COMPANY_NAME, + CONFIG, + EXPORT_DIR, + INDUSTRY, +) +from memory import Memory, SimEvent +from agent_factory import make_agent +from crewai import Task, Crew + +logger = logging.getLogger("orgforge.post_sim") + + +SLA_BREACH_THRESHOLD_DAYS = 1 + + +SLA_CREDIT_RATE = 0.02 + + +DEFAULT_CONTRACT_VALUE = 50_000 + + +INVOICE_PAYMENT_TERMS_DAYS = 30 + + +METRIC_INTERVAL_MINS = 15 + + +BASE_LATENCY_MS = 120 + + +MAX_LATENCY_MULTIPLIER = 8.0 + + +NPS_RESPONSE_RATE = 0.85 + + +def _write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as fh: + json.dump(data, fh, indent=2) + + +def _append_jsonl(path: Path, record: Dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a") as fh: + fh.write(json.dumps(record) + "\n") + + +def _safe_float(v: Any, default: float = 0.0) -> float: + try: + return float(v) + except (TypeError, ValueError): + return default + + +def _sim_date(day: int, start_date: datetime) -> datetime: + """Convert a sim day number to a real calendar date, skipping weekends.""" + current = start_date + biz_day = 1 + while biz_day < day: + current += timedelta(days=1) + if current.weekday() < 5: + biz_day += 1 + return current + + +def _iso(dt: datetime) -> str: + return dt.isoformat() + + +class EventIndex: + """ + Walks the SimEvent log once and builds every lookup structure the three + artifact writers need. Keeps all downstream logic O(1) per query. + """ + + def __init__(self, events: List[SimEvent], start_date: datetime): + self.start_date = start_date + + self.health_by_day: Dict[int, int] = {} + + self.incidents: Dict[str, Dict] = {} + + self.customer_tickets: Dict[str, List[Dict]] = {} + + self.contract_values: Dict[str, float] = {} + + self.customer_risk_flags: Dict[str, List[str]] = {} + + self.incident_root_causes: Dict[str, str] = {} + + self.daily_sentiment: Dict[int, List[float]] = {} + + self._index(events) + + def _index(self, events: List[SimEvent]) -> None: + + zd_open: Dict[str, Dict] = {} + zd_linked: Dict[str, str] = {} + + for e in events: + t = e.type + f = e.facts + + if t == "day_summary": + h = f.get("system_health") + if h is not None: + self.health_by_day[e.day] = int(h) + + elif t == "incident_opened": + iid = e.artifact_ids.get("jira", "") + if iid: + self.incidents[iid] = { + "incident_id": iid, + "open_day": e.day, + "open_ts": e.timestamp, + "resolve_day": None, + "resolve_ts": None, + "root_cause": f.get("root_cause", ""), + "component": f.get("root_cause", "")[:60], + "duration_days": None, + } + self.incident_root_causes[iid] = f.get("root_cause", "") + + elif t == "incident_resolved": + iid = e.artifact_ids.get("jira", "") + if iid and iid in self.incidents: + self.incidents[iid]["resolve_day"] = e.day + self.incidents[iid]["resolve_ts"] = e.timestamp + dur = e.day - self.incidents[iid]["open_day"] + self.incidents[iid]["duration_days"] = dur + + # ── Zendesk ticket lifecycle ────────────────────────────────────── + elif t == "zd_ticket_opened": + tid = f.get("ticket_id", "") + if tid: + zd_open[tid] = { + "ticket_id": tid, + "subject": f.get("subject", ""), + "org_name": f.get("org_name", "Unknown"), + "opened_day": e.day, + "resolved_day": None, + "escalated": False, + } + + elif t == "zd_tickets_escalated": + for tid in f.get("ticket_ids", []): + if tid in zd_open: + zd_open[tid]["escalated"] = True + zd_linked[tid] = f.get("incident_id", "") + + elif t == "zd_tickets_resolved": + for tid in f.get("ticket_ids", []): + if tid in zd_open: + zd_open[tid]["resolved_day"] = e.day + + elif t == "sf_deals_risk_flagged": + iid = f.get("incident_id", "") + for org in f.get("account_names", []): + self.customer_risk_flags.setdefault(org, []) + if iid and iid not in self.customer_risk_flags[org]: + self.customer_risk_flags[org].append(iid) + + elif t == "crm_touchpoint": + org = f.get("account_name", "") + + if org and org not in self.contract_values: + self.contract_values[org] = DEFAULT_CONTRACT_VALUE + + elif t == "customer_complaint": + sentiment = f.get("sentiment_score") + if sentiment is not None: + self.daily_sentiment.setdefault(e.day, []).append( + _safe_float(sentiment) + ) + + for tid, rec in zd_open.items(): + org = rec["org_name"] + if org not in self.customer_tickets: + self.customer_tickets[org] = [] + self.customer_tickets[org].append(rec) + + for tid, iid in zd_linked.items(): + if tid in zd_open: + org = zd_open[tid]["org_name"] + self.customer_risk_flags.setdefault(org, []) + if iid and iid not in self.customer_risk_flags[org]: + self.customer_risk_flags[org].append(iid) + + +class NPSWriter: + """ + Generates realistic NPS survey responses for each customer org. + + Score derivation (deterministic, no LLM): + Base score: 9 + -3 if any ZD ticket was escalated to a P1 incident + -2 per unresolved ZD ticket at sim end + -1 per SLA breach day (incident duration > threshold) + +1 if all tickets resolved quickly (< 1 day each) + Clamped to [0, 10]. + + NPS classification: + 9-10 → Promoter + 7-8 → Passive + 0-6 → Detractor + + Optional LLM call enriches the `verbatim_comment` field. + """ + + _RESPONSE_DELAY_DAYS = 3 + + def __init__( + self, + index: EventIndex, + export_dir: Path, + start_date: datetime, + sim_end_day: int, + ): + self._idx = index + self._export = export_dir / "nps" + self._start = start_date + self._end_day = sim_end_day + + def _score(self, org: str) -> Tuple[int, Dict]: + """Return (nps_score, scoring_detail) for an org.""" + tickets = self._idx.customer_tickets.get(org, []) + risk_ids = self._idx.customer_risk_flags.get(org, []) + + score = 9 + detail: Dict[str, Any] = { + "escalated_tickets": 0, + "unresolved_tickets": 0, + "sla_breach_days": 0, + "quick_resolutions": 0, + } + + for t in tickets: + if t["escalated"]: + score -= 3 + detail["escalated_tickets"] += 1 + + resolved_day = t.get("resolved_day") + if resolved_day is None: + score -= 2 + detail["unresolved_tickets"] += 1 + else: + age = resolved_day - t["opened_day"] + if age <= 1: + score += 1 + detail["quick_resolutions"] += 1 + + for iid in risk_ids: + inc = self._idx.incidents.get(iid, {}) + dur = inc.get("duration_days") or 0 + breach_days = max(0, dur - SLA_BREACH_THRESHOLD_DAYS) + score -= breach_days + detail["sla_breach_days"] += breach_days + + score = max(0, min(10, score)) + return score, detail + + def _classify(self, score: int) -> str: + if score >= 9: + return "promoter" + if score >= 7: + return "passive" + return "detractor" + + def _response_date(self, org: str) -> str: + """Survey response date = last ticket resolution + delay, or end of sim.""" + tickets = self._idx.customer_tickets.get(org, []) + resolved_days = [ + t["resolved_day"] for t in tickets if t.get("resolved_day") is not None + ] + base_day = max(resolved_days) if resolved_days else self._end_day + response_day = min(base_day + self._response_delay_days, self._end_day + 5) + return _iso(_sim_date(response_day, self._start)) + + def build_responses(self) -> List[Dict]: + """Build one response record per customer org. No LLM call here.""" + responses = [] + orgs = list(self._idx.customer_tickets.keys()) + + # Also include orgs that had risk flags but no tickets (SF-only customers) + for org in self._idx.customer_risk_flags: + if org not in orgs: + orgs.append(org) + + for org in orgs: + if random.random() > NPS_RESPONSE_RATE: + continue # simulate non-response + + score, detail = self._score(org) + record = { + "response_id": f"NPS-{uuid.uuid4().hex[:8].upper()}", + "org_name": org, + "respondent_email": f"feedback@{org.lower().replace(' ', '')}.com", + "submitted_at": self._response_date(org), + "survey_type": "NPS", + "score": score, + "classification": self._classify(score), + "verbatim_comment": None, # filled by LLM batch or placeholder + "scoring_detail": detail, + "metadata": { + "triggered_by": "post_sim_survey", + "sim_company": COMPANY_NAME, + }, + } + responses.append(record) + + return responses + + def write(self, responses: List[Dict]) -> None: + self._export.mkdir(parents=True, exist_ok=True) + for r in responses: + safe_org = r["org_name"].lower().replace(" ", "_") + path = self._export / "responses" / f"{safe_org}.json" + _write_json(path, r) + logger.info( + f" [nps] {r['org_name']}: score={r['score']} " + f"({r['classification']}) → {path.name}" + ) + + scores = [r["score"] for r in responses] + promoters = sum(1 for r in responses if r["classification"] == "promoter") + passives = sum(1 for r in responses if r["classification"] == "passive") + detractors = sum(1 for r in responses if r["classification"] == "detractor") + n = len(scores) + nps_score = round(((promoters - detractors) / n) * 100) if n else 0 + + summary_date = _sim_date(self._end_day + 5, self._start) + + summary = { + "generated_at": _iso(summary_date), + "company": COMPANY_NAME, + "response_count": n, + "nps_score": nps_score, + "avg_score": round(sum(scores) / n, 2) if n else 0, + "promoters": promoters, + "passives": passives, + "detractors": detractors, + "promoter_pct": round(promoters / n * 100, 1) if n else 0, + "detractor_pct": round(detractors / n * 100, 1) if n else 0, + } + _write_json(self._export / "summary.json", summary) + logger.info( + f" [nps] Summary: NPS={nps_score}, " + f"promoters={promoters}, passives={passives}, detractors={detractors}" + ) + + +class InvoiceWriter: + """ + Generates one invoice per customer org per billing period (monthly, derived + from sim length). Each invoice has standard line items plus conditional + SLA credit line items when incident duration exceeded the threshold. + + All arithmetic — no LLM calls. + + Invoice schema matches what a real SaaS billing system exports (Stripe, + Chargebee, Zuora). Fields chosen for eval utility: an agent asked "what + credits were issued due to the TitanDB incident?" has a deterministic answer. + """ + + def __init__( + self, + index: EventIndex, + export_dir: Path, + start_date: datetime, + sim_end_day: int, + mem: Memory, + ): + self._idx = index + self._export = export_dir / "invoices" + self._start = start_date + self._end_day = sim_end_day + self._mem = mem + + self._contract_values = dict(index.contract_values) + self._load_contract_values_from_mongo() + + def _load_contract_values_from_mongo(self) -> None: + """Override defaults with real opportunity amounts if SF is enabled.""" + try: + for acc in self._mem._db["sf_accounts"].find({}, {"_id": 0}): + org = acc.get("name", "") + arr = acc.get("arr") + + if org and arr: + self._contract_values[org] = float(arr) + except Exception: + pass + + def _annual_value(self, org: str) -> float: + return self._contract_values.get(org, DEFAULT_CONTRACT_VALUE) + + def _monthly_value(self, org: str) -> float: + return round(self._annual_value(org) / 12, 2) + + def _sla_credits(self, org: str) -> List[Dict]: + """Return credit line items for every SLA breach affecting this customer.""" + credits = [] + risk_ids = self._idx.customer_risk_flags.get(org, []) + + for iid in risk_ids: + inc = self._idx.incidents.get(iid, {}) + dur = inc.get("duration_days") or 0 + breach_days = max(0, dur - SLA_BREACH_THRESHOLD_DAYS) + if breach_days <= 0: + continue + + credit_amount = round( + self._monthly_value(org) * SLA_CREDIT_RATE * breach_days, 2 + ) + credits.append( + { + "line_item_type": "sla_credit", + "description": ( + f"SLA credit — incident {iid} exceeded " + f"{SLA_BREACH_THRESHOLD_DAYS}d SLA by {breach_days}d. " + f"Root cause: {inc.get('component', 'system failure')[:80]}." + ), + "incident_id": iid, + "breach_days": breach_days, + "credit_rate": SLA_CREDIT_RATE, + "amount": -credit_amount, # negative = credit + "currency": "USD", + } + ) + + return credits + + def build_invoices(self) -> List[Dict]: + invoices = [] + + # Billing period: one invoice covers the entire sim duration + period_start = _iso(self._start) + period_end = _iso(_sim_date(self._end_day, self._start)) + due_date = _iso( + _sim_date(self._end_day, self._start) + + timedelta(days=INVOICE_PAYMENT_TERMS_DAYS) + ) + + orgs = set(self._idx.customer_tickets.keys()) | set( + self._idx.customer_risk_flags.keys() + ) + # Also include any orgs known purely from contract/SF data + for org in self._contract_values: + orgs.add(org) + + for org in sorted(orgs): + monthly_fee = self._monthly_value(org) + credits = self._sla_credits(org) + credit_total = sum(c["amount"] for c in credits) + subtotal = round(monthly_fee + credit_total, 2) + tax = round(subtotal * 0.08, 2) # 8% tax — typical US SaaS + total = round(subtotal + tax, 2) + + line_items = [ + { + "line_item_type": "subscription", + "description": f"{COMPANY_NAME} Platform — monthly subscription", + "quantity": 1, + "unit_price": monthly_fee, + "amount": monthly_fee, + "currency": "USD", + }, + *credits, + { + "line_item_type": "tax", + "description": "Sales tax (8%)", + "amount": tax, + "currency": "USD", + }, + ] + + invoice = { + "invoice_id": f"INV-{uuid.uuid4().hex[:8].upper()}", + "invoice_date": period_end, + "due_date": due_date, + "status": "open", + "billing_period": { + "start": period_start, + "end": period_end, + }, + "customer": { + "org_name": org, + "billing_email": f"billing@{org.lower().replace(' ', '')}.com", + }, + "vendor": { + "company": COMPANY_NAME, + "domain": COMPANY_DOMAIN, + }, + "line_items": line_items, + "subtotal": round(monthly_fee + credit_total, 2), + "tax": tax, + "total_due": total, + "currency": "USD", + "payment_terms": f"Net {INVOICE_PAYMENT_TERMS_DAYS}", + "notes": ( + f"SLA credits applied for {len(credits)} incident(s)." + if credits + else "" + ), + "metadata": { + "sla_credits_count": len(credits), + "credit_total": credit_total, + "generated_by": "orgforge_post_sim", + }, + } + invoices.append(invoice) + + return invoices + + def write(self, invoices: List[Dict]) -> None: + for inv in invoices: + fname = f"{inv['invoice_id']}.json" + _write_json(self._export / fname, inv) + credit_note = ( + f" | credits={inv['metadata']['credit_total']}" + if inv["metadata"]["sla_credits_count"] + else "" + ) + logger.info( + f" [invoice] {inv['customer']['org_name']}: " + f"total={inv['total_due']}{credit_note} → {fname}" + ) + + +class DatadogWriter: + """ + Synthesises two outputs that together constitute a realistic Datadog export: + + metrics.jsonl — time-series samples at METRIC_INTERVAL_MINS resolution. + Metrics emitted per sample: + system.health (gauge, 0-100) + app.request.latency.p99 (gauge, ms) + app.request.latency.p50 (gauge, ms) + app.error.rate (gauge, errors/min) + app.throughput (gauge, req/min) + + Interpolation rules: + Between incident_open → incident_resolve: linear degradation from + pre-incident health to floor value, then sharp recovery on resolve. + Outside incidents: gentle daily drift ±2 points around checkpoint health. + + alerts.jsonl — one record per incident, formatted as a Datadog alert event. + Fields match the Datadog Events API schema so these can be replayed + against a real Datadog org if desired. + + Optional LLM call generates realistic monitor names (e.g. + "High p99 latency on /api/ingest") from the root cause string. + """ + + _HEALTH_FLOOR = 15 + + def __init__( + self, + index: EventIndex, + export_dir: Path, + start_date: datetime, + sim_end_day: int, + ): + self._idx = index + self._export = export_dir / "datadog" + self._start = start_date + self._end_day = sim_end_day + + self._monitor_names: Dict[str, str] = {} + + def _health_at(self, day: int, minute_offset: int = 0) -> float: + """ + Return interpolated system health at a given day + intra-day minute offset. + Incidents drag health down linearly from open to nadir, then step-recover. + """ + + base = _safe_float( + self._idx.health_by_day.get(day) + or self._idx.health_by_day.get(day - 1) + or 85, + default=85.0, + ) + + # Check if we're inside an active incident window + for inc in self._idx.incidents.values(): + open_day = inc.get("open_day", 0) + resolve_day = inc.get("resolve_day") or (open_day + 4) + + if not (open_day <= day <= resolve_day): + continue + + duration_days = max(1, resolve_day - open_day) + minutes_into_incident = (day - open_day) * 24 * 60 + minute_offset + total_incident_minutes = duration_days * 24 * 60 + + # Progress 0→1 through the incident window + progress = min(1.0, minutes_into_incident / total_incident_minutes) + + if progress < 0.15: + # Rapid degradation in the first 15% of the window + drop_factor = progress / 0.15 + floor = base - (base - self._HEALTH_FLOOR) * drop_factor + elif progress < 0.85: + # Hold at nadir + floor = self._HEALTH_FLOOR + random.uniform(-3, 3) + else: + # Recovery ramp in the final 15% + ramp = (progress - 0.85) / 0.15 + floor = self._HEALTH_FLOOR + (base - self._HEALTH_FLOOR) * ramp + + base = max(self._HEALTH_FLOOR, min(base, floor)) + + # Small intra-day jitter (±2) to avoid perfectly flat lines + return max(0.0, min(100.0, base + random.uniform(-2, 2))) + + def _latency_p99(self, health: float) -> float: + """p99 latency rises as health falls. Exponential to match real degradation.""" + degradation = max(0.0, 1.0 - health / 100.0) + multiplier = 1.0 + (MAX_LATENCY_MULTIPLIER - 1.0) * (degradation**1.8) + jitter = random.uniform(0.85, 1.15) + return round(BASE_LATENCY_MS * multiplier * jitter, 1) + + def _latency_p50(self, p99: float) -> float: + """p50 is roughly p99/3.5 under normal conditions, tighter during incidents.""" + ratio = random.uniform(2.8, 4.2) + return round(p99 / ratio, 1) + + def _error_rate(self, health: float) -> float: + """Errors per minute. Near-zero when healthy, spikes during incidents.""" + if health > 80: + return round(random.uniform(0.0, 0.8), 2) + if health > 50: + return round(random.uniform(1.0, 5.0), 2) + return round(random.uniform(8.0, 40.0), 2) + + def _throughput(self, health: float) -> float: + """Requests per minute. Drops under incident pressure.""" + base_rps = random.uniform(180, 220) + pressure = max(0.2, health / 100.0) + return round(base_rps * pressure, 1) + + # ── Sample generation ───────────────────────────────────────────────────── + + def build_metrics(self) -> int: + """Write metrics.jsonl. Returns total sample count.""" + path = self._export / "metrics.jsonl" + sample_count = 0 + + for day in range(1, self._end_day + 1): + day_dt = _sim_date(day, self._start) + minutes_per_day = 24 * 60 + samples_per_day = minutes_per_day // METRIC_INTERVAL_MINS + + for i in range(samples_per_day): + minute_offset = i * METRIC_INTERVAL_MINS + ts_dt = day_dt + timedelta(minutes=minute_offset) + ts_unix = int(ts_dt.timestamp()) + health = self._health_at(day, minute_offset) + p99 = self._latency_p99(health) + p50 = self._latency_p50(p99) + + tags = [ + f"env:production", + f"service:{COMPANY_NAME.lower().replace(' ', '-')}", + f"sim_day:{day}", + ] + + for metric, value, mtype in [ + ("system.health", round(health, 2), "gauge"), + ("app.request.latency.p99", p99, "gauge"), + ("app.request.latency.p50", p50, "gauge"), + ("app.error.rate", self._error_rate(health), "gauge"), + ("app.throughput", self._throughput(health), "gauge"), + ]: + _append_jsonl( + path, + { + "metric": metric, + "type": mtype, + "value": value, + "timestamp": ts_unix, + "timestamp_iso": ts_dt.isoformat(), + "tags": tags, + "host": f"prod-web-{(i % 3) + 1:02d}", + }, + ) + sample_count += 1 + + logger.info(f" [datadog] metrics.jsonl: {sample_count:,} samples written") + return sample_count + + def build_alerts( + self, monitor_names: Optional[Dict[str, str]] = None + ) -> List[Dict]: + """ + Build one alert record per incident. Returns records; caller writes them. + monitor_names: incident_id → human-readable monitor name (from LLM batch). + """ + alerts = [] + + for iid, inc in sorted( + self._idx.incidents.items(), key=lambda x: x[1]["open_day"] + ): + monitor_name = (monitor_names or {}).get( + iid, + f"Anomaly detected — {inc.get('component', 'system')[:60]}", + ) + resolve_ts = inc.get("resolve_ts") + + alert = { + # Datadog Events API schema fields + "id": f"evt_{uuid.uuid4().hex[:12]}", + "title": f"[P1] {monitor_name}", + "text": ( + f"## Alert\n\n" + f"**Monitor:** {monitor_name}\n" + f"**Severity:** Critical\n" + f"**Root cause:** {inc.get('root_cause', '')}\n\n" + f"System health dropped to degraded levels. " + f"On-call paged. See linked JIRA: {iid}." + ), + "alert_type": "error", + "priority": "normal", + "source_type_name": "Datadog", + "date_happened": int(datetime.fromisoformat(inc["open_ts"]).timestamp()) + if inc.get("open_ts") + else 0, + "date_resolved": int(datetime.fromisoformat(resolve_ts).timestamp()) + if resolve_ts + else None, + "tags": [ + "severity:critical", + f"incident:{iid}", + f"env:production", + f"sim_day:{inc['open_day']}", + ], + "attributes": { + "jira_ticket": iid, + "root_cause": inc.get("root_cause", ""), + "open_day": inc["open_day"], + "resolve_day": inc.get("resolve_day"), + "duration_days": inc.get("duration_days"), + "monitor_name": monitor_name, + }, + } + alerts.append(alert) + + return alerts + + def write_alerts(self, alerts: List[Dict]) -> None: + path = self._export / "alerts.jsonl" + for alert in alerts: + _append_jsonl(path, alert) + logger.info(f" [datadog] alerts.jsonl: {len(alerts)} alert(s) written") + + +def _batch_nps_comments( + responses: List[Dict], + worker_llm, +) -> Dict[str, str]: + """ + Single LLM call. Returns {response_id → verbatim_comment}. + + We send all NPS responses in one prompt and ask for a JSON array back, + then zip the results. One call regardless of customer count. + """ + if not responses: + return {} + + summaries = [] + for r in responses: + detail = r.get("scoring_detail", {}) + summaries.append( + f"- id={r['response_id']} org={r['org_name']} score={r['score']} " + f"({r['classification']}) " + f"escalated_tickets={detail.get('escalated_tickets', 0)} " + f"unresolved={detail.get('unresolved_tickets', 0)} " + f"sla_breaches={detail.get('sla_breach_days', 0)}d" + ) + + prompt_body = "\n".join(summaries) + + agent = make_agent( + role="Customer Research Analyst", + goal="Write realistic NPS survey verbatim comments from customer data.", + backstory=( + f"You work at a B2B SaaS company called {COMPANY_NAME} in the " + f"{INDUSTRY} space. You are reviewing NPS survey data and writing " + f"the open-ended verbatim comment each customer left." + ), + llm=worker_llm, + ) + task = Task( + description=( + f"Each line below is a customer NPS response summary for {COMPANY_NAME}, " + f"which {COMPANY_DESCRIPTION}.\n\n" + f"{prompt_body}\n\n" + f"Write one realistic verbatim comment for each customer. The comment " + f"should sound like something a real B2B buyer would type: 1-3 sentences, " + f"specific to their experience (support issues, uptime, sales contact), " + f"matching the score and classification.\n\n" + f"Respond ONLY with a JSON array in this exact order (same order as the " + f"input list above):\n" + f'[{{"id": "response_id", "comment": "verbatim text"}}, ...]\n' + f"No preamble, no markdown fences. Raw JSON only." + ), + expected_output="JSON array of {id, comment} objects, same order as input.", + agent=agent, + ) + + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() + + try: + import json_repair + + parsed = json_repair.loads(raw) + if not isinstance(parsed, list): + parsed = [] + except Exception: + parsed = [] + + result: Dict[str, str] = {} + for item in parsed: + if isinstance(item, dict) and "id" in item and "comment" in item: + result[item["id"]] = item["comment"] + + return result + + +def _batch_alert_names( + incidents: Dict[str, Dict], + worker_llm, +) -> Dict[str, str]: + """ + Single LLM call. Returns {incident_id → monitor_name}. + + Monitor names should look like real Datadog monitor titles: + "High p99 latency on /api/ingest", "Connection pool exhaustion — TitanDB". + """ + if not incidents: + return {} + + lines = [ + f"- {iid}: {inc.get('root_cause', '')[:120]}" for iid, inc in incidents.items() + ] + prompt_body = "\n".join(lines) + + agent = make_agent( + role="Site Reliability Engineer", + goal="Name Datadog monitors based on incident root causes.", + backstory=( + f"You are an SRE at {COMPANY_NAME} which {COMPANY_DESCRIPTION}. You maintain the Datadog " + f"observability stack and write monitor names that are concise, " + f"specific, and follow the convention: " + f"'' or ' on '." + ), + llm=worker_llm, + ) + task = Task( + description=( + f"Each line is an incident ID and its root cause for {COMPANY_NAME}:\n\n" + f"{prompt_body}\n\n" + f"Write a short Datadog monitor name for each incident (5-12 words). " + f"Examples: 'High p99 latency — /api/search', " + f"'Redis connection pool exhaustion under load', " + f"'Auth token cache stampede'.\n\n" + f"Respond ONLY with a JSON array:\n" + f'[{{"id": "incident_id", "monitor_name": "short name"}}, ...]\n' + f"Same order as input. No preamble, no markdown fences. Raw JSON only." + ), + expected_output="JSON array of {id, monitor_name} objects.", + agent=agent, + ) + + raw = str(Crew(agents=[agent], tasks=[task], verbose=False).kickoff()).strip() + + try: + import json_repair + + parsed = json_repair.loads(raw) + if not isinstance(parsed, list): + parsed = [] + except Exception: + parsed = [] + + result: Dict[str, str] = {} + for item in parsed: + if isinstance(item, dict) and "id" in item and "monitor_name" in item: + result[item["id"]] = item["monitor_name"] + + return result + + +def run(export_dir: Path, use_llm: bool = True) -> None: + logger.info("[post_sim] Starting post-simulation artifact generation...") + + mem = Memory() + events = mem.get_event_log(from_db=True) + start_date = datetime.strptime(CONFIG["simulation"]["start_date"], "%Y-%m-%d") + max_days = CONFIG["simulation"]["max_days"] + + logger.info(f"[post_sim] Loaded {len(events)} events from MongoDB.") + + idx = EventIndex(events, start_date) + logger.info( + f"[post_sim] Index built: " + f"{len(idx.incidents)} incidents, " + f"{len(idx.customer_tickets)} customer orgs, " + f"{len(idx.health_by_day)} health snapshots." + ) + + logger.info("[post_sim] → NPS surveys") + nps_writer = NPSWriter(idx, export_dir, start_date, max_days) + responses = nps_writer.build_responses() + + logger.info("[post_sim] → Invoices") + inv_writer = InvoiceWriter(idx, export_dir, start_date, max_days, mem) + invoices = inv_writer.build_invoices() + + logger.info("[post_sim] → Datadog metrics") + dd_writer = DatadogWriter(idx, export_dir, start_date, max_days) + dd_writer.build_metrics() + alerts = dd_writer.build_alerts() + + if use_llm and (responses or idx.incidents): + logger.info("[post_sim] → LLM batch 1/2: NPS verbatim comments") + try: + from flow import WORKER_MODEL + + nps_comments = _batch_nps_comments(responses, WORKER_MODEL) + for r in responses: + r["verbatim_comment"] = nps_comments.get( + r["response_id"], + _nps_placeholder(r), + ) + except Exception as e: + logger.warning(f"[post_sim] NPS LLM call failed ({e}) — using placeholders") + for r in responses: + r["verbatim_comment"] = _nps_placeholder(r) + + logger.info("[post_sim] → LLM batch 2/2: Datadog alert monitor names") + try: + from flow import WORKER_MODEL + + monitor_names = _batch_alert_names(idx.incidents, WORKER_MODEL) + alerts = dd_writer.build_alerts(monitor_names) + except Exception as e: + logger.warning( + f"[post_sim] Alert names LLM call failed ({e}) — using root causes" + ) + else: + if not use_llm: + logger.info("[post_sim] LLM calls skipped (--no-llm).") + for r in responses: + r["verbatim_comment"] = _nps_placeholder(r) + + nps_writer.write(responses) + inv_writer.write(invoices) + dd_writer.write_alerts(alerts) + + logger.info( + f"[post_sim] Done. " + f"NPS={len(responses)}, " + f"invoices={len(invoices)}, " + f"alerts={len(alerts)}." + ) + + +def _nps_placeholder(r: Dict) -> str: + """Deterministic fallback comment when LLM is skipped.""" + classification = r["classification"] + detail = r.get("scoring_detail", {}) + org = r["org_name"] + + if classification == "promoter": + return ( + f"Really happy with {COMPANY_NAME}. The team is responsive and " + f"the platform has been rock solid for us." + ) + if classification == "passive": + return ( + f"Generally good but there have been a couple of hiccups. " + f"Would be a 10 if the reliability was more consistent." + ) + + if detail.get("escalated_tickets", 0): + return ( + f"We had a support ticket escalate into a full incident and it " + f"took longer than expected to resolve. Impacted our team significantly." + ) + return ( + f"Some reliability issues during our contract period that affected our " + f"operations. Hoping to see improvement before renewal." + ) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + parser = argparse.ArgumentParser( + description="Generate post-simulation artifacts: NPS, invoices, Datadog metrics." + ) + parser.add_argument( + "--export-dir", + type=Path, + default=BASE, + help="Root export directory (default: from config.yaml)", + ) + parser.add_argument( + "--no-llm", + action="store_true", + help="Skip the two optional LLM calls and use deterministic placeholders.", + ) + args = parser.parse_args() + + run(export_dir=args.export_dir, use_llm=not args.no_llm) diff --git a/tests/conftest.py b/tests/conftest.py index 397dcbf..2bc7c8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from config_loader import ALL_NAMES import pytest import mongomock import builtins diff --git a/tests/test_causal_chain_handler.py b/tests/test_causal_chain_handler.py new file mode 100644 index 0000000..9a125bc --- /dev/null +++ b/tests/test_causal_chain_handler.py @@ -0,0 +1,128 @@ +""" +test_causal_chain_handler.py +============================ +Unit tests for causal chain construction and hybrid Recurrence Detection. +""" + +import pytest +from unittest.mock import patch, MagicMock +from causal_chain_handler import ( + CausalChainHandler, + RecurrenceDetector, + RecurrenceMatchStore, +) +from memory import SimEvent + + +def test_causal_chain_handler_deduplicates_and_orders(): + """Causal chains must be append-only, ordered, and strictly deduplicated.""" + chain = CausalChainHandler("ENG-100") + + chain.append("slack_thread_01") + chain.append("PR-202") + chain.append("slack_thread_01") + chain.append("CONF-99") + + snap = chain.snapshot() + assert len(snap) == 4 + assert snap == ["ENG-100", "slack_thread_01", "PR-202", "CONF-99"] + assert chain.root == "ENG-100" + + +@pytest.fixture +def detector(make_test_memory): + return RecurrenceDetector(mem=make_test_memory) + + +def _mock_sim_event(jira_id: str, day: int) -> SimEvent: + return SimEvent( + type="incident_opened", + day=day, + date="2026-01-01", + timestamp="T0", + actors=[], + artifact_ids={"jira": jira_id}, + facts={"root_cause": "test"}, + summary="", + ) + + +@patch.object(RecurrenceDetector, "_text_search") +@patch.object(RecurrenceDetector, "_vector_search") +def test_recurrence_detector_rejects_below_thresholds( + mock_vec, mock_text, detector, make_test_memory +): + """ + If the best match falls below BOTH the text threshold (0.40) and the + vector threshold (0.72), it must be rejected to prevent false positives. + """ + + mock_text.return_value = [{"score": 2.0}] # Normalises to ~0.35 (below 0.40) + mock_vec.return_value = [(_mock_sim_event("ENG-10", 1), 0.65)] # Below 0.72 + + match = detector.find_prior_incident( + "weak issue", current_day=5, current_ticket_id="ENG-20" + ) + + assert match is None + + store = make_test_memory._db[RecurrenceMatchStore.COLLECTION] + record = store.find_one({"current_ticket_id": "ENG-20"}) + assert record is not None + assert record["matched"] is False + assert record["confidence"] == "rejected" + + +@patch.object(RecurrenceDetector, "_text_search") +@patch.object(RecurrenceDetector, "_vector_search") +def test_recurrence_detector_accepts_strong_vector_only_match( + mock_vec, mock_text, detector, make_test_memory +): + """ + If the text search yields nothing (e.g. completely paraphrased root cause), + but the vector similarity is very high, it must confidently match. + """ + mock_text.return_value = [] + expected_match = _mock_sim_event("ENG-11", 2) + mock_vec.return_value = [(expected_match, 0.85)] # Well above 0.72 + + match = detector.find_prior_incident( + "paraphrased issue", current_day=5, current_ticket_id="ENG-21" + ) + + assert match is not None + assert match.artifact_ids["jira"] == "ENG-11" + + record = make_test_memory._db[RecurrenceMatchStore.COLLECTION].find_one( + {"current_ticket_id": "ENG-21"} + ) + assert record["fusion_strategy"] == "vector_only" + assert record["matched"] is True + + +@patch.object(RecurrenceDetector, "_text_search") +@patch.object(RecurrenceDetector, "_vector_search") +def test_rrf_fusion_favors_earliest_incident_on_tie(mock_vec, mock_text, detector): + """ + If multiple incidents breach the threshold with identical scores (e.g., a recurring + issue that has happened 3 times), the detector MUST return the earliest one + to anchor the recurrence depth properly and avoid daisy-chaining. + """ + + match_day_1 = _mock_sim_event("ENG-01", 1) + match_day_3 = _mock_sim_event("ENG-03", 3) + match_day_5 = _mock_sim_event("ENG-05", 5) + + mock_text.return_value = [] + + mock_vec.return_value = [ + (match_day_5, 0.90), + (match_day_3, 0.90), + (match_day_1, 0.90), + ] + + match = detector.find_prior_incident( + "recurring issue", current_day=10, current_ticket_id="ENG-99" + ) + + assert match.artifact_ids["jira"] == "ENG-01" diff --git a/tests/test_crm_system.py b/tests/test_crm_system.py new file mode 100644 index 0000000..d83dd42 --- /dev/null +++ b/tests/test_crm_system.py @@ -0,0 +1,164 @@ +""" +test_crm_system.py +================== +Unit tests for the CRM state machine. +Validates MongoDB writes, JSON exports, and SimEvent emissions. +""" + +import pytest +from datetime import datetime +from crm_system import CRMSystem, NullCRMSystem + + +@pytest.fixture +def crm_config(): + return { + "crm": { + "salesforce": {"enabled": True, "seed_accounts": True}, + "zendesk": {"enabled": True, "link_to_incidents": True}, + } + } + + +@pytest.fixture +def crm(crm_config, tmp_path, make_test_memory): + return CRMSystem.from_config(crm_config, tmp_path, make_test_memory) + + +def test_factory_returns_null_when_disabled(tmp_path, make_test_memory): + system = CRMSystem.from_config({}, tmp_path, make_test_memory) + assert isinstance(system, NullCRMSystem) + + +def test_handle_inbound_complaint_creates_zd_ticket(crm, make_test_memory): + facts = {"subject": "System is down", "sender_org": "Globex", "body": "Help!"} + ticket_id = crm.handle_inbound_complaint( + facts, "2026-03-09T10:00:00", "2026-03-09", 1 + ) + + assert ticket_id.startswith("ZD-") + ticket = make_test_memory._db["zd_tickets"].find_one({"ticket_id": ticket_id}) + assert ticket["status"] == "Open" + assert ticket["org_name"] == "Globex" + + events = list(make_test_memory._events.find({"type": "zd_ticket_opened"})) + assert len(events) == 1 + assert events[0]["facts"]["ticket_id"] == ticket_id + + +def test_incident_escalates_zd_and_flags_sf(crm, make_test_memory): + + crm.handle_inbound_complaint( + {"subject": "Broken", "sender_org": "Initech"}, + "2026-03-09T10:00:00", + "2026-03-09", + 1, + ) + crm.process_outbound_email( + { + "recipient_org": "Initech", + "subject": "Quote", + "stage": "Proposal/Price Quote", + }, + "2026-03-09T10:00:00", + "2026-03-09", + 1, + ) + + crm.handle_incident_opened( + "ENG-999", "Database", 40, "2026-03-09T12:00:00", "2026-03-09", 1 + ) + + zd_ticket = make_test_memory._db["zd_tickets"].find_one({"org_name": "Initech"}) + assert zd_ticket["priority"] == "Urgent" + assert zd_ticket["related_incident"] == "ENG-999" + + sf_opp = make_test_memory._db["sf_opps"].find_one({"account_name": "Initech"}) + assert len(sf_opp["risk_notes"]) == 1 + assert "ENG-999" in sf_opp["risk_notes"][0] + + context = crm.planner_context() + assert "OPEN SUPPORT TICKETS" in context + assert "[URGENT]" in context + assert "AT-RISK DEALS" in context + + +def test_employee_departure_reassigns_crm_assets(crm, make_test_memory): + + crm.process_outbound_email( + {"sender": "Bob", "recipient_org": "Stark Ind", "stage": "Negotiation/Review"}, + "2026-03-09T10:00:00", + "2026-03-09", + 1, + ) + + crm.handle_employee_departure("Bob", "Sales Exec", "2026-03-10", 2) + + opp = make_test_memory._db["sf_opps"].find_one({"account_name": "Stark Ind"}) + assert opp["owner"] == "Pending Reassignment" + assert any("departed" in note.lower() for note in opp["risk_notes"]) + + +def test_partial_enablement_does_not_crash(tmp_path, make_test_memory): + """If ZD is off but SF is on, ZD methods must silently no-op without crashing.""" + cfg = {"crm": {"salesforce": {"enabled": True}, "zendesk": {"enabled": False}}} + crm = CRMSystem.from_config(cfg, tmp_path, make_test_memory) + + ticket_id = crm.handle_inbound_complaint({"subject": "Help"}, "T0", "D0", 1) + assert ticket_id is None + assert make_test_memory._db["zd_tickets"].count_documents({}) == 0 + + + +def test_process_outbound_email_never_downgrades_stage(crm, make_test_memory): + """ + If an account is in 'Negotiation', a new 'Prospecting' email shouldn't + downgrade the opportunity stage, but should still log the touchpoint. + """ + + crm.process_outbound_email( + {"recipient_org": "Acme", "stage": "Negotiation/Review", "subject": "Contract"}, + "T0", + "D0", + 1, + ) + + crm.process_outbound_email( + {"recipient_org": "Acme", "stage": "Prospecting", "subject": "Checking in"}, + "T1", + "D0", + 1, + ) + + opp = make_test_memory._db["sf_opps"].find_one({"account_name": "Acme"}) + assert opp["stage"] == "Negotiation/Review", "Stage was improperly downgraded" + assert len(opp["touchpoints"]) == 2, "Touchpoint was not recorded" + + +def test_planner_context_enforces_token_limits(crm, make_test_memory): + """Ensure the context string doesn't blow up the LLM prompt if there are 100 open tickets.""" + + for i in range(10): + crm.handle_inbound_complaint( + {"subject": f"Issue {i}", "sender_org": "Org"}, "T0", "D0", 1 + ) + + context = crm.planner_context() + + assert "OPEN SUPPORT TICKETS (10)" in context + assert context.count("ZD-") == 5, "Context must cap at 5 displayed tickets" + assert "... and 5 more." in context + + +def test_incident_resolved_closes_linked_zd_tickets(crm, make_test_memory): + """When an incident is resolved, linked ZD tickets must transition to 'Solved'.""" + t_id = crm.handle_inbound_complaint( + {"subject": "Down", "sender_org": "Org"}, "T0", "D0", 1 + ) + crm.handle_incident_opened("ENG-1", "DB", 40, "T1", "D0", 1) # Links the ticket + + crm.handle_incident_resolved("ENG-1", "http://postmortem", "T2", "D0", 1) + + ticket = make_test_memory._db["zd_tickets"].find_one({"ticket_id": t_id}) + assert ticket["status"] == "Solved" + assert "resolved" in ticket["comments"][-1]["text"].lower() diff --git a/tests/test_day_planner.py b/tests/test_day_planner.py index 553178b..7e030d6 100644 --- a/tests/test_day_planner.py +++ b/tests/test_day_planner.py @@ -988,9 +988,9 @@ def test_external_contacts_included_in_validator(self): ): DayPlannerOrchestrator(cfg, MagicMock(), MagicMock(), MagicMock()) call_kwargs = mock_validator_cls.call_args - ext_names = ( - call_kwargs.kwargs.get("external_contact_names") or call_kwargs.args[1] - ) + ext_names = call_kwargs.kwargs.get("external_contact_names") + if ext_names is None and len(call_kwargs.args) > 1: + ext_names = call_kwargs.args[1] assert "VendorPat" in ext_names diff --git a/tests/test_flow.py b/tests/test_flow.py index bef10b2..7ce0367 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -229,34 +229,26 @@ def test_git_simulator_reviewer_selection(mock_flow): def test_relevant_external_contacts(mock_flow): """Verifies external contacts are triggered by correct events and health thresholds.""" - config = { - "external_contacts": [ - { - "name": "AWS", - "trigger_events": ["incident_opened"], - "trigger_health_threshold": 80, - }, - { - "name": "Vendor", - "trigger_events": ["fix_in_progress"], - "trigger_health_threshold": 50, - }, - ] - } + contacts = [ + { + "name": "AWS", + "trigger_on": ["incident"], + "trigger_health_threshold": 80, + } + ] + + + mock_flow._mem._db["sim_config"].insert_one({ + "_id": "inbound_email_sources", + "sources": contacts + }) - # Health is 75. It's below AWS's 80 threshold, so they should be triggered. triggered_aws = mock_flow.graph_dynamics.relevant_external_contacts( - "incident_opened", 75, config + "incident_opened", 75 ) assert len(triggered_aws) == 1 assert triggered_aws[0]["name"] == "AWS" - # Health is 90. Higher than AWS's 80 threshold, so nobody should be triggered. - triggered_none = mock_flow.graph_dynamics.relevant_external_contacts( - "incident_opened", 90, config - ) - assert len(triggered_none) == 0 - def test_end_of_day_resets_and_morale(mock_flow): """Verifies EOD correctly decays morale and resets daily metric counters.""" @@ -462,6 +454,9 @@ def test_postmortem_artifact_timestamp_within_actor_work_block( inc.root_cause = "OOM on worker node" inc.days_active = 2 inc.pr_id = None + inc.causal_chain = MagicMock() + inc.causal_chain.snapshot.return_value = ["ORG-999"] + mock_flow._mem.get_ticket.return_value = {"id": "ORG-999"} mock_flow._write_postmortem(inc) @@ -600,16 +595,17 @@ def test_score_sentiment(): assert score_sentiment([]) == 0.5 -def test_build_social_graph(): +def test_build_social_graph(make_test_memory): + make_test_memory._db["sim_config"].insert_one({ + "_id": "inbound_email_sources", + "sources": [{"name": "VendorA", "internal_liaison": "Eng"}] + }) + with ( patch("flow.ORG_CHART", {"Eng": ["Alice", "Bob"], "Sales": ["Charlie"]}), patch("flow.LEADS", {"Eng": "Alice", "Sales": "Charlie"}), - patch( - "flow.CONFIG", - {"external_contacts": [{"name": "VendorA", "internal_liaison": "Eng"}]}, - ), ): - G = build_social_graph() + G = build_social_graph(make_test_memory) assert G.has_node("Alice") assert G.has_node("VendorA") assert G.nodes["Alice"]["is_lead"] is True diff --git a/tests/test_integration.py b/tests/test_integration.py index c6cb368..8e98e2d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -74,6 +74,12 @@ def sim(make_test_memory): s._mem.log_slack_messages = MagicMock(return_value=("slack/path", "thread-001")) s._mem.has_genesis_artifacts = MagicMock(return_value=True) s._mem.load_latest_checkpoint = MagicMock(return_value=None) + sources = [{"name": "Vendor", "trigger_on": ["incident"], "internal_liaison": "Engineering"}] + s._mem._db["sim_config"].insert_one({ + "_id": "inbound_email_sources", + "sources": sources + }) + s._email_ingestor._sources = sources s._normal_day._confluence = MagicMock() @@ -466,7 +472,9 @@ def test_stale_pr_force_merged_after_5_days( ) ticket = sim._mem.get_ticket("ENG-103") - assert ticket["status"] == "Done", "Ticket must be marked Done when PR is force-merged." + assert ticket["status"] == "Done", ( + "Ticket must be marked Done when PR is force-merged." + ) @patch("normal_day.Crew") @patch("normal_day.Task") @@ -695,6 +703,9 @@ def test_in_review_section_bare_ticket_id_is_insufficient(self, sim): class TestDailyLoop: + @patch("flow.run_post_sim") + @patch("external_email_ingest.Crew") + @patch("external_email_ingest.Task") @patch("normal_day.Crew") @patch("normal_day.Task") @patch("confluence_writer.Crew") @@ -703,7 +714,18 @@ class TestDailyLoop: @patch("flow.Task") @patch("agent_factory.Agent") def test_5_day_cycle_completes_and_advances_day( - self, mock_agent, mock_ft, mock_fc, mock_cwt, mock_cwc, mock_ndt, mock_ndc, sim + self, + mock_agent, + mock_ft, + mock_fc, + mock_cwt, + mock_cwc, + mock_ndt, + mock_ndc, + mock_eet, + mock_eec, + mock_run_post_sim, + sim, ): """ Smoke test: daily_cycle() runs to completion over 5 days without @@ -713,6 +735,7 @@ def test_5_day_cycle_completes_and_advances_day( mock_fc.return_value = crew_inst mock_cwc.return_value = crew_inst mock_ndc.return_value = crew_inst + mock_eec.return_value = crew_inst author = ALL_NAMES[0] _seed_ticket(sim._mem, "ENG-300", author) @@ -747,6 +770,9 @@ def dynamic_plan(*args, **kwargs): assert sim.state.day == 6 + @patch("flow.run_post_sim") + @patch("external_email_ingest.Crew") + @patch("external_email_ingest.Task") @patch("normal_day.Crew") @patch("normal_day.Task") @patch("confluence_writer.Crew") @@ -755,12 +781,24 @@ def dynamic_plan(*args, **kwargs): @patch("flow.Task") @patch("agent_factory.Agent") def test_incident_probability_branch_opens_incident( - self, mock_agent, mock_ft, mock_fc, mock_cwt, mock_cwc, mock_ndt, mock_ndc, sim + self, + mock_agent, + mock_ft, + mock_fc, + mock_cwt, + mock_cwc, + mock_ndt, + mock_ndc, + mock_eet, + mock_eec, + mock_run_post_sim, + sim, ): crew_inst = _crew_returning({"comment": "Done.", "is_code_complete": False}) mock_fc.return_value = crew_inst mock_cwc.return_value = crew_inst mock_ndc.return_value = crew_inst + mock_eec.return_value = crew_inst def dynamic_plan(*args, **kwargs): date_str = str(sim.state.current_date.date()) @@ -949,3 +987,257 @@ def test_watercooler_distraction_applies_time_penalty( events = list(sim._mem._events.find({"type": "watercooler_chat"})) assert len(events) >= 1 + + +class TestCRMSimulationIntegration: + """ + Validates that CRM hooks propagate correctly when standard OrgForge + mechanics (incidents, departures) fire during the daily cycle. + """ + + @pytest.fixture + def live_crm_sim(self, sim, tmp_path): + """Overrides the NullCRMSystem with a live one for these tests.""" + from crm_system import CRMSystem + + cfg = { + "crm": { + "salesforce": {"enabled": True}, + "zendesk": {"enabled": True, "link_to_incidents": True}, + } + } + sim._crm = CRMSystem(cfg, tmp_path, sim._mem) + + sim._lifecycle._crm = sim._crm + return sim + + def test_incident_escalates_zendesk_tickets(self, live_crm_sim): + live_crm_sim._lifecycle.scan_for_knowledge_gaps = MagicMock() + with ( + patch( + "flow.OrgForgeSimulation._select_domain_expert", return_value="Alice" + ), + patch("flow.Task"), + patch("flow.Crew") as mock_crew, + patch("agent_factory.Agent"), + ): + mock_crew.return_value.kickoff.return_value = "API Gateway is down" + live_crm_sim.state.system_health = 40 + live_crm_sim._handle_incident() + + live_crm_sim._crm.handle_inbound_complaint( + {"subject": "Can't login", "sender_org": "Wayne Ent"}, + timestamp="2026-03-09T09:00:00", + date_str="2026-03-09", + day=1, + ) + + with ( + patch( + "flow.OrgForgeSimulation._select_domain_expert", return_value="Alice" + ), + patch("flow.Task"), + patch("flow.Crew") as mock_crew, + patch("agent_factory.Agent"), + ): + mock_crew.return_value.kickoff.return_value = "API Gateway is down" + + live_crm_sim.state.system_health = 40 + live_crm_sim._handle_incident() + + zd_ticket = live_crm_sim._mem._db["zd_tickets"].find_one( + {"org_name": "Wayne Ent"} + ) + assert zd_ticket["priority"] == "Urgent" + assert "ENG-" in zd_ticket["related_incident"] + + events = list(live_crm_sim._mem._events.find({"type": "zd_tickets_escalated"})) + assert len(events) == 1 + assert zd_ticket["ticket_id"] in events[0]["facts"]["ticket_ids"] + + def test_departure_reassigns_crm_assets(self, live_crm_sim): + """ + When a sales rep departs, OrgLifecycleManager must cascade the departure + into the CRM to flag open accounts and deals for reassignment. + """ + + live_crm_sim._crm.process_outbound_email( + { + "sender": "Charlie", + "recipient_org": "Stark Ind", + "stage": "Negotiation/Review", + }, + timestamp="2026-03-09T10:00:00", + date_str="2026-03-09", + day=1, + ) + + live_crm_sim._crm.handle_employee_departure( + "Charlie", "Account Executive", "2026-03-10", 2 + ) + + opp = live_crm_sim._mem._db["sf_opps"].find_one({"account_name": "Stark Ind"}) + assert opp["owner"] == "Pending Reassignment" + assert any("Charlie" in note for note in opp["risk_notes"]) + + events = list(live_crm_sim._mem._events.find({"type": "sf_ownership_lapsed"})) + assert len(events) == 1 + assert "Stark Ind" not in events[0]["facts"]["accounts_lapsed"] + assert opp["opportunity_id"] in events[0]["facts"]["opportunities_lapsed"] + + @patch("normal_day.Crew") + @patch("normal_day.Task") + @patch("agent_factory.Agent") + def test_non_eng_ticket_completion_triggers_crm_progression( + self, mock_agent, mock_task, mock_crew, live_crm_sim + ): + """ + Tests the full causal path: + 1. Sales rep works on a non-eng Jira ticket (MKT-100). + 2. NormalDayHandler recognizes it's non-eng and routes completion to Email. + 3. Email emission triggers CRMSystem.process_outbound_email. + 4. Salesforce opportunity advances. + """ + rep_name = "Alice" + + live_crm_sim._mem._jira.insert_one( + { + "id": "MKT-100", + "title": "Send Proposal to Stark", + "status": "To Do", + "assignee": rep_name, + "dept": "Sales", + "dept_type": "non_eng", + "completion_artifact": "email", + "sprint": 1, + "in_progress_since": 1, + "description": "", + "story_points": 3, + "comments": [], + } + ) + + live_crm_sim._crm.process_outbound_email( + { + "sender": rep_name, + "recipient_org": "Stark Ind", + "stage": "Prospecting", + "subject": "Intro", + }, + "2026-03-08T10:00:00", + "2026-03-08", + 1, + ) + + mock_crew_inst = MagicMock() + mock_crew_inst.kickoff.return_value = '{"subject": "Proposal enclosed", "body": "Here are the terms.", "crm_stage": "Proposal/Price Quote", "recipient_org": "Stark Ind", "is_task_complete": true}' + mock_crew.return_value = mock_crew_inst + + org_plan = _make_org_plan( + live_crm_sim, + { + "Sales": [ + EngineerDayPlan( + name=rep_name, + dept="Sales", + agenda=[ + AgendaItem( + activity_type="ticket_progress", + description="Finish MKT-100", + related_id="MKT-100", + estimated_hrs=2.0, + ) + ], + stress_level=20, + ) + ] + }, + ) + live_crm_sim._normal_day.handle(org_plan) + + ticket = live_crm_sim._mem.get_ticket("MKT-100") + assert ticket["status"] == "Done" + + email_events = list( + live_crm_sim._mem._events.find({"type": "ticket_completion_email"}) + ) + assert len(email_events) == 1 + + opp = live_crm_sim._mem._db["sf_opps"].find_one({"account_name": "Stark Ind"}) + assert opp["stage"] == "Proposal/Price Quote" + assert len(opp["touchpoints"]) == 2 + + @patch("flow.Crew") + @patch("flow.Task") + @patch("confluence_writer.Task") + @patch("confluence_writer.Crew") + @patch("agent_factory.Agent") + def test_incident_causal_chain_preserves_full_lifecycle( + self, + mock_agent, + mock_cw_crew, + mock_cw_task, + mock_flow_task, + mock_flow_crew, + sim, + ): + """ + Validates that an incident traversing the OrgForge state machine successfully + accumulates every generated artifact into a single unbroken Causal Chain. + """ + + sim.graph_dynamics.relevant_external_contacts = MagicMock(return_value=[]) + sim._mem.log_slack_messages.side_effect = [ + ("slack/path", "slack-thread-001"), + ("slack/path", "slack-thread-002"), + ("slack/path", "slack-thread-003"), + ("slack/path", "slack-thread-004"), + ("slack/path", "slack-thread-005"), + ] + + sim._mem.log_slack_messages.side_effect = [ + ("slack/path", f"slack-thread-{i:03d}") for i in range(1, 20) + ] + + mock_flow_inst = MagicMock() + mock_flow_inst.kickoff.return_value = "Memory leak in auth service." + mock_flow_crew.return_value = mock_flow_inst + + mock_cw_inst = MagicMock() + mock_cw_inst.kickoff.return_value = "## Postmortem\nFixed the leak." + mock_cw_crew.return_value = mock_cw_inst + + sim.state.system_health = 30 + sim._handle_incident() + + inc = sim.state.active_incidents[0] + ticket_id = inc.ticket_id + + chain_snap = inc.causal_chain.snapshot() + assert len(chain_snap) == 3 + assert chain_snap[0] == ticket_id + assert "slack" in chain_snap[1] + assert "slack" in chain_snap[2] + + sim._advance_incidents() + sim._advance_incidents() + + chain_snap = inc.causal_chain.snapshot() + assert len(chain_snap) == 4 + assert "PR-" in chain_snap[3] + pr_id = chain_snap[3] + + sim._advance_incidents() + sim._advance_incidents() + + final_ticket = sim._mem.get_ticket(ticket_id) + final_chain = final_ticket.get("causal_chain", []) + + assert len(final_chain) == 5 + assert final_chain[0] == ticket_id + assert pr_id in final_chain + assert str(final_chain[-1]).startswith("CONF-") + + pm_events = list(sim._mem._events.find({"type": "postmortem_created"})) + assert len(pm_events) == 1 + assert pm_events[0]["facts"]["causal_chain"] == final_chain diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 24a2f3c..8964787 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -80,7 +80,7 @@ def lifecycle(mock_sim): "org_chart": org_chart, "leads": leads, } - gd = GraphDynamics(G, config) + gd = GraphDynamics(G, config, mock_sim._mem) mgr = OrgLifecycleManager( config=config, @@ -285,12 +285,9 @@ def test_handoff_emits_escalation_chain_simevent(lifecycle, mock_clock): assert escalation_event.facts["departed"] == "Carol" -# ───────────────────────────────────────────────────────────────────────────── -# 3. CENTRALITY VACUUM -# ───────────────────────────────────────────────────────────────────────────── -def test_centrality_vacuum_stresses_neighbours(lifecycle, mock_clock): +def test_centrality_vacuum_stresses_neighbors(lifecycle, mock_clock, mock_sim): """ Removing a bridge shortcut node should increase stress on remaining nodes that absorb its rerouted traffic. @@ -327,7 +324,7 @@ def test_centrality_vacuum_stresses_neighbours(lifecycle, mock_clock): "org_chart": {"Engineering": ring_nodes}, "leads": {"Engineering": "Alice"}, } - gd_ring = GraphDynamics(G, config) + gd_ring = GraphDynamics(G, config, mock_sim._mem) for name in ring_nodes: gd_ring._stress[name] = 25 @@ -1004,7 +1001,7 @@ def test_departure_and_hire_same_day_timestamps_are_in_business_hours(lifecycle) assert (ts.hour, ts.minute) <= (17, 30), f"{label} timestamp after 17:30: {ts}" -def test_hire_simevent_timestamp_not_before_0930(lifecycle, make_test_memory): +def test_hire_simevent_timestamp_not_before_0930(lifecycle, make_test_memory, mock_sim): """ _execute_hire post-corrects the hire timestamp to be ≥ 09:30 when schedule_meeting returns a minute < 30 at 09:xx. @@ -1037,7 +1034,7 @@ def test_hire_simevent_timestamp_not_before_0930(lifecycle, make_test_memory): } from org_lifecycle import OrgLifecycleManager - gd = GraphDynamics(G, config) + gd = GraphDynamics(G, config, mock_sim._mem) mgr = OrgLifecycleManager( config=config, graph_dynamics=gd, @@ -1082,7 +1079,7 @@ def test_hire_simevent_timestamp_not_before_0930(lifecycle, make_test_memory): ) -def test_centrality_vacuum_simevent_timestamp_is_valid_iso_string(lifecycle): +def test_centrality_vacuum_simevent_timestamp_is_valid_iso_string(lifecycle, mock_sim): """ _apply_centrality_vacuum receives `timestamp_iso` (a pre-formatted string) via a parameter misleadingly named `clock`. The resulting SimEvent's @@ -1119,7 +1116,7 @@ def test_centrality_vacuum_simevent_timestamp_is_valid_iso_string(lifecycle): "org_chart": {"Engineering": list(nodes)}, "leads": {"Engineering": "Alice"}, } - gd = GraphDynamics(G, config) + gd = GraphDynamics(G, config, mock_sim._mem) mem = MagicMock() mgr = OrgLifecycleManager( diff --git a/tests/test_normal_day.py b/tests/test_normal_day.py index 9fea35b..6859c20 100644 --- a/tests/test_normal_day.py +++ b/tests/test_normal_day.py @@ -83,7 +83,7 @@ def _make_ticket( @pytest.fixture -def graph_and_gd(): +def graph_and_gd(make_test_memory): # 1. Inject the memory fixture here """Real NetworkX graph + GraphDynamics wired to CONFIG.""" G = nx.Graph() for name in ALL_NAMES: @@ -91,7 +91,9 @@ def graph_and_gd(): for i, a in enumerate(ALL_NAMES): for b in ALL_NAMES[i + 1 :]: G.add_edge(a, b, weight=5.0) - gd = GraphDynamics(G, CONFIG) + + # 2. Pass the memory instance as the third argument + gd = GraphDynamics(G, CONFIG, make_test_memory) return G, gd @@ -599,7 +601,7 @@ def fake_write_design(*args, **kwargs): handler._confluence = mock_cw with ( - patch.object(handler, "_save_slack", return_value=("", "")), + patch.object(handler, "_save_slack", return_value=("", "thread-123")), patch.object(handler, "_save_md"), patch("normal_day.Crew") as mock_crew, patch("random.random", return_value=0.10), diff --git a/tests/test_post_sim_artifacts.py b/tests/test_post_sim_artifacts.py new file mode 100644 index 0000000..589c220 --- /dev/null +++ b/tests/test_post_sim_artifacts.py @@ -0,0 +1,253 @@ +""" +test_post_sim_artifacts.py +========================== +Unit tests for post-simulation artifact generation. +Validates SLA credits, NPS scoring math, and Datadog interpolations. +""" + +from unittest.mock import patch + +import pytest +from datetime import datetime +from memory import SimEvent +from post_sim_artifacts import DatadogWriter, EventIndex, NPSWriter, InvoiceWriter + + +@pytest.fixture +def sim_start(): + return datetime(2026, 1, 1) + + +@pytest.fixture +def event_index(sim_start): + events = [ + SimEvent( + type="crm_touchpoint", + day=1, + date="2026-01-01", + timestamp="T00", + actors=[], + artifact_ids={}, + facts={"account_name": "Acme Corp"}, + summary="", + ), + SimEvent( + type="zd_ticket_opened", + day=1, + date="2026-01-01", + timestamp="T00", + actors=[], + artifact_ids={}, + facts={"ticket_id": "ZD-1", "org_name": "Acme Corp"}, + summary="", + ), + SimEvent( + type="incident_opened", + day=2, + date="2026-01-02", + timestamp="T01", + actors=[], + artifact_ids={"jira": "ENG-100"}, + facts={"root_cause": "OOM"}, + summary="", + ), + SimEvent( + type="zd_tickets_escalated", + day=2, + date="2026-01-02", + timestamp="T01", + actors=[], + artifact_ids={}, + facts={"ticket_ids": ["ZD-1"], "incident_id": "ENG-100"}, + summary="", + ), + SimEvent( + type="sf_deals_risk_flagged", + day=2, + date="2026-01-02", + timestamp="T01", + actors=[], + artifact_ids={}, + facts={"account_names": ["Acme Corp"], "incident_id": "ENG-100"}, + summary="", + ), + SimEvent( + type="incident_resolved", + day=5, + date="2026-01-05", + timestamp="T02", + actors=[], + artifact_ids={"jira": "ENG-100"}, + facts={}, + summary="", + ), + ] + return EventIndex(events, sim_start) + + +def test_nps_scoring_logic_penalizes_escalations_and_breaches( + event_index, tmp_path, sim_start +): + writer = NPSWriter(event_index, tmp_path, sim_start, sim_end_day=10) + score, detail = writer._score("Acme Corp") + + assert detail["escalated_tickets"] == 1 + assert detail["unresolved_tickets"] == 1 + assert detail["sla_breach_days"] == 2 + assert score == 2 + assert writer._classify(score) == "detractor" + + +def test_invoice_writer_applies_sla_credits( + event_index, tmp_path, sim_start, make_test_memory +): + writer = InvoiceWriter( + event_index, tmp_path, sim_start, sim_end_day=30, mem=make_test_memory + ) + + invoices = writer.build_invoices() + assert len(invoices) == 1 + acme_inv = invoices[0] + + assert acme_inv["customer"]["org_name"] == "Acme Corp" + + line_items = acme_inv["line_items"] + sla_lines = [item for item in line_items if item["line_item_type"] == "sla_credit"] + + assert len(sla_lines) == 1 + assert sla_lines[0]["incident_id"] == "ENG-100" + assert sla_lines[0]["breach_days"] == 2 + assert sla_lines[0]["amount"] < 0 # Credits must be negative + + +def test_event_index_links_incidents_to_customers_via_sf_flags(event_index): + + assert "ENG-100" in event_index.customer_risk_flags["Acme Corp"] + assert event_index.incidents["ENG-100"]["duration_days"] == 3 + + +def test_invoice_writer_exact_sla_threshold_yields_no_credit( + tmp_path, make_test_memory +): + """If the threshold is 1 day, a 1-day incident should result in 0 credits.""" + from post_sim_artifacts import SLA_BREACH_THRESHOLD_DAYS + + events = [ + SimEvent( + type="crm_touchpoint", + day=1, + date="D1", + timestamp="T1", + actors=[], + artifact_ids={}, + facts={"account_name": "Acme"}, + summary="", + ), + SimEvent( + type="sf_deals_risk_flagged", + day=1, + date="D1", + timestamp="T1", + actors=[], + artifact_ids={}, + facts={"account_names": ["Acme"], "incident_id": "ENG-1"}, + summary="", + ), + # Incident lasts EXACTLY the threshold + SimEvent( + type="incident_opened", + day=1, + date="D1", + timestamp="T1", + actors=[], + artifact_ids={"jira": "ENG-1"}, + facts={"root_cause": "OOM"}, + summary="", + ), + SimEvent( + type="incident_resolved", + day=1 + SLA_BREACH_THRESHOLD_DAYS, + date="D2", + timestamp="T2", + actors=[], + artifact_ids={"jira": "ENG-1"}, + facts={}, + summary="", + ), + ] + idx = EventIndex(events, datetime(2026, 1, 1)) + writer = InvoiceWriter(idx, tmp_path, datetime(2026, 1, 1), 30, make_test_memory) + + invoices = writer.build_invoices() + acme_inv = invoices[0] + + sla_lines = [ + item + for item in acme_inv["line_items"] + if item["line_item_type"] == "sla_credit" + ] + assert len(sla_lines) == 0, "Exact threshold duration should not trigger a credit" + + +def test_datadog_health_interpolation_bounds(tmp_path): + """Health must strictly bound between the floor (15) and ceiling (100) during incidents.""" + events = [ + SimEvent( + type="day_summary", + day=1, + date="D1", + timestamp="T1", + actors=[], + artifact_ids={}, + facts={"system_health": 100}, + summary="", + ), + SimEvent( + type="incident_opened", + day=2, + date="D2", + timestamp="T2", + actors=[], + artifact_ids={"jira": "ENG-1"}, + facts={"root_cause": "OOM"}, + summary="", + ), + SimEvent( + type="incident_resolved", + day=4, + date="D4", + timestamp="T4", + actors=[], + artifact_ids={"jira": "ENG-1"}, + facts={}, + summary="", + ), + ] + idx = EventIndex(events, datetime(2026, 1, 1)) + writer = DatadogWriter(idx, tmp_path, datetime(2026, 1, 1), 5) + + health_d3 = writer._health_at(day=3, minute_offset=120) + assert 12 <= health_d3 <= 20, ( + f"Health {health_d3} did not hit the expected floor near 15" + ) + + health_d5 = writer._health_at(day=5, minute_offset=0) + assert health_d5 > 80, "Health did not recover after incident resolution" + + +@patch("post_sim_artifacts._write_json") +def test_post_sim_survives_empty_simulation( + mock_write_json, tmp_path, make_test_memory +): + """If the sim did nothing (no events), post_sim should output empty summaries gracefully.""" + idx = EventIndex([], datetime(2026, 1, 1)) + + nps_writer = NPSWriter(idx, tmp_path, datetime(2026, 1, 1), 5) + responses = nps_writer.build_responses() + assert len(responses) == 0 + nps_writer.write(responses) # Should write a summary.json with 0s + + assert mock_write_json.called + + summary_data = mock_write_json.call_args[0][1] + assert summary_data["response_count"] == 0 From 0c94490673e727b36a1fd6a646ce5c1eb8dc81e5 Mon Sep 17 00:00:00 2001 From: Jeff F Date: Fri, 27 Mar 2026 18:47:21 -0500 Subject: [PATCH 2/2] Release v1.3.0 --- config/config.yaml | 21 --- src/confluence_writer.py | 16 +- src/crm_system.py | 72 +++++++- src/day_planner.py | 1 + src/external_email_ingest.py | 12 +- src/flow.py | 2 +- src/normal_day.py | 343 ++++++++++++++++++++++++++++++++++- src/planner_models.py | 2 + tests/test_crm_system.py | 9 +- tests/test_day_planner.py | 31 +++- tests/test_flow.py | 30 ++- tests/test_integration.py | 28 ++- tests/test_lifecycle.py | 2 - 13 files changed, 480 insertions(+), 89 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index b8ccf43..c69318c 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -487,27 +487,6 @@ default_persona: opinions. Do not let it develop a strong voice or override the tone of named personas in the same scene. -# ── EXTERNAL PERSONAS ───────────────────────────────────────── - -external_contacts: - - name: "AWS_Support" - display_name: "AWS Support" - org: "AWS" - role: "Support Engineer" - trigger_events: ["incident_opened", "fix_in_progress"] - trigger_health_threshold: 70 # only contacts AWS if system_health drops below this - internal_liaison: "Engineering" # which dept's lead gets the summary - summary_tone: "technical and reassuring, but noncommittal about SLA" - - - name: "CustomerSuccess_Acme" - display_name: "Acme Corp (Customer)" - org: "Acme Corp" - role: "IT Director" - trigger_events: ["incident_opened"] - trigger_health_threshold: 60 # only surfaces for severe incidents - internal_liaison: "Sales" - summary_tone: "concerned about uptime, asking for ETA and root cause" - # ── CRM ─────────────────────────────────────────────────────── crm: diff --git a/src/confluence_writer.py b/src/confluence_writer.py index 1ef421d..e68af31 100644 --- a/src/confluence_writer.py +++ b/src/confluence_writer.py @@ -296,7 +296,9 @@ def write_postmortem( artifact_time, _ = self._clock.advance_actor(on_call, hours=pm_hours) timestamp = artifact_time.isoformat() - backstory = get_voice_card(on_call, "design", mem=self._mem, graph_dynamics=self._gd) + backstory = get_voice_card( + on_call, "design", mem=self._mem, graph_dynamics=self._gd + ) related = self._registry.related_context(topic=root_cause, n=3) _qa_lead = next( (name for name, p in PERSONAS.items() if "QA" in p.get("expertise", [])), @@ -394,7 +396,9 @@ def write_design_doc( chat_log = "\n".join(f"{m['user']}: {m['text']}" for m in slack_transcript) ctx = self._mem.recall_with_rewrite(raw_query=topic, n=3, as_of_time=timestamp) related = self._registry.related_context(topic=topic, n=3) - backstory = get_voice_card(author, "design", mem=self._mem, graph_dynamics=self._gd) + backstory = get_voice_card( + author, "design", mem=self._mem, graph_dynamics=self._gd + ) agent = make_agent( role="Technical Lead", @@ -598,7 +602,9 @@ def write_adhoc_page( n=3, as_of_time=self._clock.now(resolved_author).isoformat(), ) - backstory = get_voice_card(resolved_author, "design", mem=self._mem, graph_dynamics=self._gd) + backstory = get_voice_card( + resolved_author, "design", mem=self._mem, graph_dynamics=self._gd + ) topic_agent = make_agent( role="Content Planner", @@ -648,7 +654,9 @@ def write_adhoc_page( ctx = self._mem.context_for_prompt(title, n=3, as_of_time=timestamp) related = self._registry.related_context(topic=title, n=4) - backstory = get_voice_card(resolved_author, "design", mem=self._mem, graph_dynamics=self._gd) + backstory = get_voice_card( + resolved_author, "design", mem=self._mem, graph_dynamics=self._gd + ) writer_agent = make_agent( role="Corporate Writer", diff --git a/src/crm_system.py b/src/crm_system.py index 1ae36b7..94ea945 100644 --- a/src/crm_system.py +++ b/src/crm_system.py @@ -158,6 +158,9 @@ def handle_employee_departure( ) -> None: pass + def get_best_open_opportunity(self, owner: str) -> Optional[Dict]: + return None + # ───────────────────────────────────────────────────────────────────────────── # LIVE SYSTEM @@ -225,8 +228,6 @@ def __init__(self, config: Dict, export_base: Path, mem, planner_llm=None): f"ZD={'on' if self._zd_on else 'off'}" ) - # ── Factory ────────────────────────────────────────────────────────────── - @classmethod def from_config( cls, config: Dict, export_base: Path, mem, planner_llm=None @@ -242,8 +243,6 @@ def from_config( return cls(config, export_base, mem, planner_llm) return NullCRMSystem() - - def _ensure_dirs(self): for sub in [ "salesforce/accounts", @@ -271,7 +270,6 @@ def _embed( timestamp: str, metadata: Optional[Dict] = None, ) -> None: - """Enqueue an embedding so the artifact is searchable by the DayPlanner.""" self._mem.embed_artifact( id=id, type=artifact_type, @@ -855,7 +853,7 @@ def process_outbound_email( try: ts_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) except ValueError: - ts_dt = datetime.utcnow() + ts_dt = datetime.strptime(date_str, "%Y-%m-%d") close_date = (ts_dt + timedelta(days=random.randint(30, 90))).strftime( "%Y-%m-%d" ) @@ -891,7 +889,6 @@ def process_outbound_email( {k: v for k, v in opp.items() if k not in ("_id", "_seq")}, ) - self._embed( id=oid, artifact_type="sf_opportunity", @@ -935,6 +932,64 @@ def process_outbound_email( return touchpoint_facts + def get_best_open_opportunity(self, owner: str) -> Optional[Dict]: + """ + Return the highest-priority open SF opportunity for a given owner, + with the primary_contact and primary_contact_email resolved from + sf_accounts. Priority order: Negotiation/Review → Proposal/Price Quote + → Value Proposition → Prospecting. + + Returns None if SF is disabled or no open opp exists for this owner. + """ + if not self._sf_on: + return None + + stage_rank = { + "Negotiation/Review": 0, + "Proposal/Price Quote": 1, + "Value Proposition": 2, + "Prospecting": 3, + } + + opps = list( + self._sf_o.find( + { + "owner": owner, + "stage": {"$nin": ["Closed Won", "Closed Lost"]}, + }, + {"_id": 0, "_seq": 0}, + ) + ) + if not opps: + # Fall back to any open opp — useful when owner field is stale + opps = list( + self._sf_o.find( + {"stage": {"$nin": ["Closed Won", "Closed Lost"]}}, + {"_id": 0, "_seq": 0}, + ) + .sort("_seq", -1) + .limit(5) + ) + + if not opps: + return None + + best = min(opps, key=lambda o: stage_rank.get(o.get("stage", ""), 99)) + + acc = self._sf_a.find_one({"name": best["account_name"]}, {"_id": 0, "_seq": 0}) + if acc: + best["primary_contact"] = acc.get("primary_contact", best["account_name"]) + + best["primary_contact_email"] = acc.get( + "primary_contact_email", + ( + f"{acc.get('primary_contact', best['account_name']).lower().replace(' ', '.')}" + f"@{best['account_name'].lower().replace(' ', '')}.com" + ), + ) + + return best + def handle_employee_departure( self, employee_name: str, @@ -959,7 +1014,6 @@ def handle_employee_departure( reassigned_accounts = [] reassigned_opps = [] - # Accounts for acc in self._sf_a.find({"owner": employee_name}, {"_id": 0}): aid = acc["account_id"] self._sf_a.update_one( @@ -996,7 +1050,7 @@ def handle_employee_departure( reassigned_opps.append(oid) if reassigned_accounts or reassigned_opps: - ts = datetime.utcnow().isoformat() + ts = f"{date_str}T09:00:00+00:00" self._emit( SimEvent( type="sf_ownership_lapsed", diff --git a/src/day_planner.py b/src/day_planner.py index c6aa264..7213844 100644 --- a/src/day_planner.py +++ b/src/day_planner.py @@ -1052,6 +1052,7 @@ def plan( eng_plan=eng_plan, lifecycle_context=lifecycle_context, email_signals=email_signals, + crm_summary=crm_summary, ): dept for dept, planner in non_eng_depts.items() } diff --git a/src/external_email_ingest.py b/src/external_email_ingest.py index 5db720e..87b32d2 100644 --- a/src/external_email_ingest.py +++ b/src/external_email_ingest.py @@ -154,18 +154,12 @@ def __init__( ) self._threat = threat_injector or _NullInjector() self._crm = crm or NullCRMSystem() + self._sources = None 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) - @property - def _sources(self): - """Lazy lookup: pull sources from MongoDB only when needed.""" - doc = self._mem._db["sim_config"].find_one({"_id": "inbound_email_sources"}) - return doc.get("sources", []) if doc else [] - - def generate_pre_standup(self, state) -> List[ExternalEmailSignal]: """ Vendor / automated alerts arriving 06:00–08:59. @@ -1231,10 +1225,6 @@ def _generate_email( facts={"subject": subject, "topic": topic, "org": source_org}, ) - # ───────────────────────────────────────────────────────────────────────── - # HELPERS - # ───────────────────────────────────────────────────────────────────────── - def _ensure_sources_loaded(self) -> None: if self._sources is None: self._sources = self._mem.get_inbound_email_sources() or [] diff --git a/src/flow.py b/src/flow.py index fb8e194..4af531f 100644 --- a/src/flow.py +++ b/src/flow.py @@ -713,6 +713,7 @@ def __init__(self, mem: Optional[Memory] = None): threat_injector=self._threat, embed_worker=self._embed_worker, lifecycle=self._lifecycle, + crm=self._crm, ) self._recurrence_detector = RecurrenceDetector(self._mem) self._ticket_assigner = TicketAssigner( @@ -825,7 +826,6 @@ def _embed_persona(args): except Exception as e: logger.error(f"[genesis] Persona embed failed for {name}: {e}") - sim_start = datetime.strptime(CONFIG["simulation"]["start_date"], "%Y-%m-%d") for gap in CONFIG.get("knowledge_gaps", []): name = gap["name"] diff --git a/src/normal_day.py b/src/normal_day.py index 593ca1e..ea1a7d2 100644 --- a/src/normal_day.py +++ b/src/normal_day.py @@ -11,6 +11,7 @@ from config_loader import COMPANY_DESCRIPTION from crewai import Process, Task, Crew +from crm_system import NullCRMSystem from json_repair import json_repair from memory import Memory, SimEvent from graph_dynamics import GraphDynamics @@ -46,6 +47,7 @@ def __init__( threat_injector=None, embed_worker=None, lifecycle=None, + crm=None, ): self._config = config self._mem = mem @@ -68,6 +70,7 @@ def __init__( self._threat = threat_injector or _NullInjector() self._embed_worker = embed_worker self._lifecycle = lifecycle + self._crm = crm or NullCRMSystem() def handle(self, org_plan: OrgDayPlan) -> None: """Processes both planned agenda items and unplanned org collisions.""" @@ -79,6 +82,8 @@ def handle(self, org_plan: OrgDayPlan) -> None: for event in org_plan.collision_events: self._handle_collision_event(event, date_str) + self._fire_sales_outreach(date_str) + self._maybe_bot_alerts() self._maybe_adhoc_confluence() @@ -655,13 +660,25 @@ def _complete_non_eng_ticket( ) elif completion_artifact == "email": - completion_id = self._emit_completion_email( - assignee=assignee, - ticket=ticket, - comment_text=comment_text, - date_str=date_str, - timestamp=timestamp_iso, - ) + dept = dept_of_name(assignee, self._org_chart) + is_sales = "sales" in dept.lower() + if is_sales and self._crm: + completion_id = self._emit_sales_outbound_email( + assignee=assignee, + ticket=ticket, + comment_text=comment_text, + date_str=date_str, + timestamp=timestamp_iso, + chain=chain, + ) + else: + completion_id = self._emit_completion_email( + assignee=assignee, + ticket=ticket, + comment_text=comment_text, + date_str=date_str, + timestamp=timestamp_iso, + ) if completion_id: chain.append(completion_id) @@ -2195,6 +2212,318 @@ def _emit_completion_email( ) return thread_id + def _emit_sales_outbound_email( + self, + assignee: str, + ticket: dict, + comment_text: str, + date_str: str, + timestamp: str, + chain: "CausalChainHandler", + opportunity_id: Optional[str] = None, + account_name: Optional[str] = None, + contact_name: Optional[str] = None, + contact_email: Optional[str] = None, + ) -> Optional[str]: + """ + Generate a customer-facing outbound email for a Sales ticket completion + or a planner-proposed proactive outreach event. + + Looks up the best matching open SF opportunity for the assignee's account + if one isn't provided directly. Writes a real .eml, embeds it, calls + crm.process_outbound_email() to advance the SF opportunity, and logs a + SimEvent so the causal chain is complete. + + Returns the embed_id (thread_id) or None on failure. + """ + ticket_id = ticket.get("id", "") + ticket_title = ticket.get("title", ticket_id) + + if not (account_name and contact_name and contact_email): + opp = self._crm.get_best_open_opportunity(owner=assignee) + if opp: + opportunity_id = opp.get("opportunity_id", opportunity_id) + account_name = opp.get("account_name", account_name or "the customer") + contact_name = opp.get("primary_contact", contact_name or account_name) + contact_email = opp.get( + "primary_contact_email", + f"{contact_name.lower().replace(' ', '.')}@{account_name.lower().replace(' ', '')}.com", + ) + else: + logger.debug( + f"[normal_day] No open SF opp for {assignee} — " + f"falling back to internal completion email for {ticket_id}" + ) + return self._emit_completion_email( + assignee=assignee, + ticket=ticket, + comment_text=comment_text, + date_str=date_str, + timestamp=timestamp, + ) + + p = self._config.get("personas", {}).get(assignee, {}) + backstory = get_voice_card(assignee, "async", self._gd, self._mem) + stage = opportunity_id and self._crm._sf_o.find_one( + {"opportunity_id": opportunity_id}, {"stage": 1, "_id": 0} + ) + stage_label = ( + (stage or {}).get("stage", "active discussion") + if stage + else "active discussion" + ) + + agent = make_agent( + role=f"{assignee} — {p.get('social_role', 'Account Executive')}", + goal=f"Write a professional outbound email to a customer at {account_name}.", + backstory=backstory, + llm=self._worker, + ) + task = Task( + description=( + f"You are {assignee} at {self._company}. You have just completed work on " + f"ticket [{ticket_id}]: {ticket_title}.\n\n" + f"What you did: {comment_text}\n\n" + f"Write a short, professional outbound email to {contact_name} at {account_name}. " + f"The deal is currently at stage: {stage_label}.\n\n" + f"The email should naturally reflect the ticket work — e.g. attaching a proposal, " + f"confirming a renewal quote, following up on a demo, or sharing an update. " + f"Do NOT mention internal ticket IDs or internal tooling.\n\n" + f"Rules:\n" + f"- Subject: relevant, professional, no ticket IDs\n" + f"- Body: 3-5 sentences. Warm but professional. Sign off with your name and title.\n" + f"- Match your typing quirks from your backstory.\n\n" + f"CRITICAL: Respond ONLY with a JSON object with 'subject' and 'body' keys. " + f"No markdown fences, no preamble.\n" + f'{{"subject": "...", "body": "..."}}' + ), + expected_output='A JSON object with "subject" and "body" keys.', + agent=agent, + ) + 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"Following up — {account_name}") + body = email_data.get("body", clean) + new_stage = email_data.get("crm_stage", stage_label) + except Exception as exc: + logger.warning(f"[sales_email] JSON parse failed for {ticket_id}: {exc}") + subject = f"Following up — {account_name}" + body = clean + + sender_addr = f"{assignee.lower().replace(' ', '.')}@{self._domain}" + out_dir = Path(self._base) / "emails" / "outbound" / date_str + out_dir.mkdir(parents=True, exist_ok=True) + safe_sender = assignee.lower().replace(" ", "_") + safe_org = account_name.lower().replace(" ", "_") + eml_path = out_dir / f"{safe_sender}_to_{safe_org}_{ticket_id}.eml" + + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + + msg = MIMEMultipart("alternative") + msg["From"] = f"{assignee} <{sender_addr}>" + msg["To"] = f"{contact_name} <{contact_email}>" + msg["Subject"] = subject + msg["Date"] = timestamp + msg["X-OrgForge-Direction"] = "outbound" + msg.attach(MIMEText(body, "plain")) + with open(eml_path, "w") as fh: + fh.write(msg.as_string()) + + self._crm.process_outbound_email( + email_data={ + "sender": assignee, + "recipient": contact_name, + "sender_org": self._company, + "recipient_org": account_name, + "subject": subject, + "stage": new_stage, + }, + timestamp=timestamp, + date_str=date_str, + day=self._state.day, + ) + + thread_id = f"sales_email_{ticket_id}_{self._state.day}" + + chain.append(thread_id) + + self._mem.embed_artifact( + id=thread_id, + type="email", + title=subject, + content=f"From: {assignee}\nTo: {contact_name} ({account_name})\nSubject: {subject}\n\n{body}", + day=self._state.day, + date=date_str, + timestamp=timestamp, + metadata={ + "ticket_id": ticket_id, + "from": assignee, + "to": contact_name, + "account": account_name, + "opportunity_id": opportunity_id, + "direction": "outbound", + }, + ) + + self._mem.log_event( + SimEvent( + type="sales_outbound_email", + timestamp=timestamp, + day=self._state.day, + date=date_str, + actors=[assignee, contact_name], + artifact_ids={ + "jira": ticket_id, + "email_thread": thread_id, + "eml_path": str(eml_path), + "sf_opp": opportunity_id or "", + }, + facts={ + "ticket_id": ticket_id, + "subject": subject, + "from": assignee, + "to": contact_name, + "account": account_name, + "opportunity_id": opportunity_id, + "stage": stage_label, + "direction": "outbound", + "causal_chain": chain.snapshot(), + }, + summary=f'{assignee} sent outbound email to {contact_name} ({account_name}): "{subject[:80]}"', + tags=["email", "outbound", "sales", "customer"], + ) + ) + + logger.info( + f" [cyan]📤 {assignee} → {contact_name} ({account_name}):[/cyan] {subject[:70]}" + ) + return thread_id + + def _fire_sales_outreach(self, date_str: str) -> None: + """ + Deterministic proactive outreach — called once per day after agenda items. + + For each Sales team member, finds their highest-priority open SF opportunity + that hasn't already been touched by a ticket completion email today, and sends + one outbound customer email. Fires with ~60% probability per member to avoid + saturating the corpus every single day. + + No-op if CRM is disabled or the org has no Sales department. + """ + if not self._crm: + return + + _OUTREACH_PROB = 0.6 + + sales_members = [ + name + for dept, members in self._org_chart.items() + if "sales" in dept.lower() + for name in members + ] + if not sales_members: + return + + already_touched: set = set() + cooldown_days = 4 + + for events in self._mem.get_event_log(): + event_day = getattr(events, "day", -1) + if (self._state.day - event_day) <= cooldown_days and getattr( + events, "type", None + ) == "sales_outbound_email": + acct = (events.facts or {}).get("account") + if acct: + already_touched.add(acct) + + for sender in sales_members: + if random.random() > _OUTREACH_PROB: + continue + + opp = self._crm.get_best_open_opportunity(owner=sender) + if not opp: + continue + + account_name = opp.get("account_name", "") + if account_name in already_touched: + continue + + acc = self._crm._sf_a.find_one( + {"name": account_name}, {"_id": 0, "_seq": 0} + ) + contact_name = (acc or {}).get("primary_contact", account_name) + contact_email = (acc or {}).get( + "primary_contact_email", + f"{contact_name.lower().replace(' ', '.')}@{account_name.lower().replace(' ', '')}.com", + ) + + synthetic_ticket = { + "id": f"OUTREACH-{self._state.day}-{sender.split()[0].lower()}", + "title": f"Proactive outreach to {account_name}", + "status": "Done", + "completion_artifact": "email", + } + + from causal_chain_handler import CausalChainHandler + + chain = CausalChainHandler(root_id=synthetic_ticket["id"]) + + opp_updated_str = opp.get("updated_at") + actor_time = self._clock.now(sender) + + if opp_updated_str: + opp_updated_time = datetime.fromisoformat( + opp_updated_str.replace("Z", "+00:00") + ) + + if actor_time < opp_updated_time: + diff_hours = ( + opp_updated_time - actor_time + ).total_seconds() / 3600.0 + self._clock.advance_actor(sender, hours=(diff_hours + 0.25)) + else: + self._clock.advance_actor(sender, hours=0.25) + else: + self._clock.advance_actor(sender, hours=0.25) + + timestamp = self._clock.now(sender).isoformat() + + self._emit_sales_outbound_email( + assignee=sender, + ticket=synthetic_ticket, + comment_text=f"Proactive follow-up on open opportunity with {account_name}.", + date_str=date_str, + timestamp=timestamp, + chain=chain, + opportunity_id=opp.get("opportunity_id"), + account_name=account_name, + contact_name=contact_name, + contact_email=contact_email, + ) + already_touched.add(account_name) + + self._mem.log_event( + SimEvent( + type="proactive_outreach_initiated", + timestamp=self._clock.now(sender).isoformat(), + day=self._state.day, + date=date_str, + actors=[sender], + artifact_ids={"synthetic_ticket": synthetic_ticket["id"]}, + facts={ + "account": account_name, + "opportunity_id": opp.get("opportunity_id"), + "causal_chain": chain.snapshot(), + }, + summary=f"{sender} initiated proactive outreach to {account_name}.", + tags=["sales", "outreach", "proactive"], + ) + ) + def _emit_review_reply( self, author: str, diff --git a/src/planner_models.py b/src/planner_models.py index cc3354a..e9d4696 100644 --- a/src/planner_models.py +++ b/src/planner_models.py @@ -253,4 +253,6 @@ class ValidationResult: "dlp_alert", "secret_detected", "zoom_meeting", + "sales_outbound_email", + "proactive_outreach_initiated" } diff --git a/tests/test_crm_system.py b/tests/test_crm_system.py index d83dd42..1922f01 100644 --- a/tests/test_crm_system.py +++ b/tests/test_crm_system.py @@ -109,7 +109,6 @@ def test_partial_enablement_does_not_crash(tmp_path, make_test_memory): assert make_test_memory._db["zd_tickets"].count_documents({}) == 0 - def test_process_outbound_email_never_downgrades_stage(crm, make_test_memory): """ If an account is in 'Negotiation', a new 'Prospecting' email shouldn't @@ -118,15 +117,15 @@ def test_process_outbound_email_never_downgrades_stage(crm, make_test_memory): crm.process_outbound_email( {"recipient_org": "Acme", "stage": "Negotiation/Review", "subject": "Contract"}, - "T0", - "D0", + "2026-03-08T10:00:00Z", + "2026-03-08", 1, ) crm.process_outbound_email( {"recipient_org": "Acme", "stage": "Prospecting", "subject": "Checking in"}, - "T1", - "D0", + "2026-03-08T10:00:00Z", + "2026-03-08", 1, ) diff --git a/tests/test_day_planner.py b/tests/test_day_planner.py index 7e030d6..976f162 100644 --- a/tests/test_day_planner.py +++ b/tests/test_day_planner.py @@ -980,18 +980,33 @@ def test_ticket_assigner_starts_as_none(self): def test_external_contacts_included_in_validator(self): cfg = dict(CONFIG) - cfg["external_contacts"] = [{"name": "VendorPat"}] + mock_mem = MagicMock() + + mock_mem._db.__getitem__.return_value.find_one.return_value = { + "sources": [{"name": "VendorPat"}] + } + with ( - patch("day_planner.PlanValidator") as mock_validator_cls, + patch("day_planner.PlanValidator"), patch("day_planner.OrgCoordinator"), + patch("day_planner.TicketAssigner"), patch("day_planner.LIVE_ORG_CHART", ORG_CHART), + patch.object( + DayPlannerOrchestrator, "_generate_org_theme", return_value="Theme" + ), ): - DayPlannerOrchestrator(cfg, MagicMock(), MagicMock(), MagicMock()) - call_kwargs = mock_validator_cls.call_args - ext_names = call_kwargs.kwargs.get("external_contact_names") - if ext_names is None and len(call_kwargs.args) > 1: - ext_names = call_kwargs.args[1] - assert "VendorPat" in ext_names + orch = DayPlannerOrchestrator(cfg, MagicMock(), MagicMock(), MagicMock()) + + for p in orch._dept_planners.values(): + p.plan = MagicMock( + return_value=MagicMock( + theme="Fake", engineer_plans=[], proposed_events=[] + ) + ) + + orch.plan(MagicMock(), mock_mem, MagicMock(), MagicMock()) + + assert "VendorPat" in orch._validator.external_contact_names class TestDayPlannerOrchestratorPlan: diff --git a/tests/test_flow.py b/tests/test_flow.py index 7ce0367..0f14d04 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -223,7 +223,6 @@ def test_git_simulator_reviewer_selection(mock_flow): timestamp="2026-03-05T13:33:51.027Z", ) - # The simulator should automatically pick Bob as the primary reviewer assert pr["reviewers"][0] == "Bob" @@ -236,12 +235,14 @@ def test_relevant_external_contacts(mock_flow): "trigger_health_threshold": 80, } ] - - mock_flow._mem._db["sim_config"].insert_one({ - "_id": "inbound_email_sources", + mock_flow._mem._db["sim_config"].insert_one( + {"_id": "inbound_email_sources", "sources": contacts} + ) + mock_flow._mem.get_inbound_email_sources.return_value = contacts + mock_flow._mem._db.__getitem__.return_value.find_one.return_value = { "sources": contacts - }) + } triggered_aws = mock_flow.graph_dynamics.relevant_external_contacts( "incident_opened", 75 @@ -254,26 +255,21 @@ def test_end_of_day_resets_and_morale(mock_flow): """Verifies EOD correctly decays morale and resets daily metric counters.""" import flow - # 1. Dynamically read whatever config values are currently loaded decay = flow.CONFIG["morale"]["daily_decay"] recovery = flow.CONFIG["morale"]["good_day_recovery"] - # 2. Set up the initial state start_morale = 0.8 mock_flow.state.daily_incidents_opened = 3 mock_flow.state.daily_artifacts_created = 5 mock_flow.state.team_morale = start_morale - mock_flow.state.active_incidents = [] # No active incidents = good day bonus! + mock_flow.state.active_incidents = [] - # 3. Trigger the end of day logic mock_flow._end_of_day() - # 4. Check resets assert mock_flow.state.daily_incidents_opened == 0 assert mock_flow.state.daily_artifacts_created == 0 assert len(mock_flow.state.morale_history) == 1 - # 5. Dynamically calculate the expected morale based on the real config expected_morale = round(min(1.0, (start_morale * decay) + recovery), 3) assert mock_flow.state.team_morale == expected_morale @@ -596,11 +592,13 @@ def test_score_sentiment(): def test_build_social_graph(make_test_memory): - make_test_memory._db["sim_config"].insert_one({ - "_id": "inbound_email_sources", - "sources": [{"name": "VendorA", "internal_liaison": "Eng"}] - }) - + make_test_memory._db["sim_config"].insert_one( + { + "_id": "inbound_email_sources", + "sources": [{"name": "VendorA", "internal_liaison": "Eng"}], + } + ) + with ( patch("flow.ORG_CHART", {"Eng": ["Alice", "Bob"], "Sales": ["Charlie"]}), patch("flow.LEADS", {"Eng": "Alice", "Sales": "Charlie"}), diff --git a/tests/test_integration.py b/tests/test_integration.py index 8e98e2d..1822dcd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -74,11 +74,16 @@ def sim(make_test_memory): s._mem.log_slack_messages = MagicMock(return_value=("slack/path", "thread-001")) s._mem.has_genesis_artifacts = MagicMock(return_value=True) s._mem.load_latest_checkpoint = MagicMock(return_value=None) - sources = [{"name": "Vendor", "trigger_on": ["incident"], "internal_liaison": "Engineering"}] - s._mem._db["sim_config"].insert_one({ - "_id": "inbound_email_sources", - "sources": sources - }) + sources = [ + { + "name": "Vendor", + "trigger_on": ["incident"], + "internal_liaison": "Engineering", + } + ] + s._mem._db["sim_config"].insert_one( + {"_id": "inbound_email_sources", "sources": sources} + ) s._email_ingestor._sources = sources s._normal_day._confluence = MagicMock() @@ -1153,6 +1158,19 @@ def test_non_eng_ticket_completion_triggers_crm_progression( ] }, ) + + live_crm_sim._mem._db["sf_opps"].insert_one( + { + "opportunity_id": "OPP-999", + "account_name": "Stark Ind", + "stage": "Prospecting", + "owner": rep_name, # "Alice" + "primary_contact": "Tony Stark", + "primary_contact_email": "tony@stark.com", + "touchpoints": [], + } + ) + live_crm_sim._normal_day.handle(org_plan) ticket = live_crm_sim._mem.get_ticket("MKT-100") diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 8964787..f23e394 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -285,8 +285,6 @@ def test_handoff_emits_escalation_chain_simevent(lifecycle, mock_clock): assert escalation_event.facts["departed"] == "Carol" - - def test_centrality_vacuum_stresses_neighbors(lifecycle, mock_clock, mock_sim): """ Removing a bridge shortcut node should increase stress on remaining nodes