Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [v1.2.5] — 2026-03-25

### Added

- **Global Voice Card System (`utils/persona_utils.py`, `src/normal_day.py`)**: Migrated individual persona logic to a centralized `get_voice_card` utility. This provides context-aware character sheets (e.g., `async`, `design`, `collision`) that inject tenure, expertise, mood, and "anti-patterns" into LLM backstories to prevent generic corporate drift.
- **Robust JSON Recovery (`requirements.txt`, `src/flow.py`, `src/normal_day.py`)**: Integrated `json-repair` across the simulation pipeline. This allows the engine to "salvage" malformed LLM responses in ticket generation and Slack conversations, significantly reducing "failed to parse" fallbacks.

### Changed

- **Persona History Filtering (`src/memory.py`)**: Enhanced `persona_history` to filter out "noisy" macro-events (like sprint planning summaries or standups). This ensures agents focus on personal agency and direct interactions when building their local context.
- **Incident Recurrence Logic (`src/causal_chain_handler.py`)**: Refined the `RecurrenceDetector` to prioritize the earliest incident in a chain (anti-daisy-chaining). This ensures new incidents link back to the original root cause rather than just the most recent duplicate.
- **Streamlined codebase (`Across all files`)**: Conducted a major cleanup of legacy comments, "ASCII art" section dividers, and redundant docstrings to improve readability and reduce token overhead during development.

### Fixed

- **PR Causal Linking (`src/flow.py`)**: Fixed a bug where PR IDs were missing from the persistent ticket record. PRs are now immediately appended to the `CausalChainHandler` and saved to MongoDB upon creation.
- **Department Signal Noise (`src/day_planner.py`)**: Non-engineering departments (Sales, HR) now only receive "direct" relevance signals, preventing them from being overwhelmed by technical incident data that doesn't impact their planning.

---

## [v1.2.4] — 2026-03-25

### Added
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ rich>=13.0.0
ollama>=0.1.7
boto3==1.42.63
scipy>=1.17.1
json-repair>=0.25.3
json-repair>=0.58.6
71 changes: 3 additions & 68 deletions src/causal_chain_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,38 +51,23 @@

logger = logging.getLogger("orgforge.causal_chain")

# ─────────────────────────────────────────────────────────────────────────────
# CONSTANTS
# ─────────────────────────────────────────────────────────────────────────────

# Reciprocal Rank Fusion damping constant — 60 is the standard value from
# the original RRF paper. Higher = less aggressive rank boosting.
_RRF_K = 60

# Default fusion weights — vector wins for semantic similarity since our
# Ollama embedding model is local and essentially free to call
_TEXT_WEIGHT = 0.35
_VECTOR_WEIGHT = 0.65

# Minimum scores to accept a match — tune these after reviewing
# the recurrence_matches collection after a sim run

_MIN_VECTOR_SCORE = 0.72
_MIN_TEXT_SCORE = 0.40

# How many candidates to retrieve from each source before fusion

_RETRIEVAL_LIMIT = 10

ARTIFACT_KEY_JIRA = "jira"
ARTIFACT_KEY_CONFLUENCE = "confluence"
ARTIFACT_KEY_SLACK = "slack"
ARTIFACT_KEY_SLACK_THREAD = "slack_thread"

# ─────────────────────────────────────────────────────────────────────────────
# CAUSAL CHAIN HANDLER
# Tracks the growing set of artifact IDs that causally produced an incident.
# Lives on an ActiveIncident — one instance per open incident.
# ─────────────────────────────────────────────────────────────────────────────


class CausalChainHandler:
"""
Expand Down Expand Up @@ -129,14 +114,6 @@ def __repr__(self) -> str:
return f"CausalChainHandler(root={self.root}, length={len(self)})"


# ─────────────────────────────────────────────────────────────────────────────
# RECURRENCE MATCH STORE
# Persists every detection decision to MongoDB regardless of outcome.
# Negative examples (rejected matches) are as valuable as positives for
# threshold calibration.
# ─────────────────────────────────────────────────────────────────────────────


class RecurrenceMatchStore:
"""
Writes one document per recurrence detection attempt to the
Expand Down Expand Up @@ -194,11 +171,9 @@ def log(
threshold_gate: Dict[str, float],
) -> None:
doc: Dict[str, Any] = {
# Query
"query_root_cause": query_root_cause,
"current_ticket_id": current_ticket_id,
"current_day": current_day,
# Match result
"matched": matched_event is not None,
"matched_ticket_id": matched_event.artifact_ids.get("jira")
if matched_event
Expand All @@ -210,7 +185,6 @@ def log(
"recurrence_gap_days": (
current_day - matched_event.day if matched_event else None
),
# Scores
"text_score": round(text_score, 4),
"vector_score": round(vector_score, 4),
"fused_score": round(fused_score, 4),
Expand All @@ -219,7 +193,6 @@ def log(
"confidence": confidence,
"candidates_evaluated": candidates_evaluated,
"threshold_gate": threshold_gate,
# Metadata
"timestamp": datetime.now(timezone.utc).isoformat(),
"sim_day": current_day,
}
Expand All @@ -229,13 +202,6 @@ def log(
logger.warning(f"[causal_chain] recurrence_match insert failed: {e}")


# ─────────────────────────────────────────────────────────────────────────────
# RECURRENCE DETECTOR
# Hybrid retrieval: fuses MongoDB text search + vector search to identify
# whether a new incident root cause has occurred before.
# ─────────────────────────────────────────────────────────────────────────────


class RecurrenceDetector:
"""
Finds the most relevant prior incident for a given root cause string
Expand Down Expand Up @@ -273,11 +239,8 @@ def __init__(
self._min_text = min_text
self._store = RecurrenceMatchStore(mem)

# Ensure text index exists on the events collection
self._ensure_text_index()

# ── Public ────────────────────────────────────────────────────────────────

def find_prior_incident(
self,
root_cause: str,
Expand All @@ -296,11 +259,8 @@ def find_prior_incident(

candidates: Dict[str, Dict[str, Any]] = {}

# ── Stage 1: MongoDB text search ──────────────────────────────────────
text_results = self._text_search(root_cause, current_day)

# Fixed normalisation: use a corpus-calibrated ceiling rather than
# result-set max, so rank-1 doesn't always get 1.0
_TEXT_CEILING = 8.0
for rank, result in enumerate(text_results):
event = SimEvent.from_dict(result)
Expand All @@ -311,7 +271,6 @@ def find_prior_incident(
candidates[key]["text_score"] = normalised
candidates[key]["text_rrf"] = 1 / (rank + 1 + _RRF_K)

# ── Stage 2: Vector search ─────────────────────────────────────────────
vector_results = self._vector_search(root_cause, current_day)

for rank, (event, vscore) in enumerate(vector_results):
Expand All @@ -337,7 +296,6 @@ def find_prior_incident(
)
return None

# ── Fusion ────────────────────────────────────────────────────────────
both_returned = bool(text_results) and bool(vector_results)
fusion_strategy = (
"rrf" if both_returned else ("text_only" if text_results else "vector_only")
Expand All @@ -352,7 +310,6 @@ def find_prior_incident(
sort_key = "rrf_score" if both_returned else "fused_score"
ranked = sorted(candidates.values(), key=lambda c: c[sort_key], reverse=True)

# ── Threshold gate — collect ALL passing candidates ───────────────────
accepted = [
c
for c in ranked
Expand Down Expand Up @@ -383,8 +340,6 @@ def find_prior_incident(
)
return None

# Anti-daisy-chain: among all passing candidates, pick the EARLIEST
# so ORG-164 links to ORG-140, not ORG-161
best = min(accepted, key=lambda c: c["event"].day)

confidence = (
Expand Down Expand Up @@ -464,16 +419,14 @@ def get_causal_chain(self, artifact_id: str) -> List[SimEvent]:
chain.extend(events)

for event in events:
# Queue causal parents
for parent in event.facts.get("causal_chain", []):
if parent not in visited:
queue.append(parent)
# Queue prior incident in recurrence chain

prior = event.facts.get("recurrence_of")
if prior and prior not in visited:
queue.append(prior)

# Deduplicate (same event may appear via multiple paths)
seen_ids: set = set()
unique: List[SimEvent] = []
for e in chain:
Expand All @@ -496,8 +449,6 @@ def get_recurrence_history(self, ticket_id: str) -> List[SimEvent]:
or ticket_id in e.facts.get("causal_chain", [])
]

# ── Private ───────────────────────────────────────────────────────────────

def _text_search(self, root_cause: str, current_day: int) -> List[Dict[str, Any]]:
"""MongoDB $text search — returns results with normalised scores."""
try:
Expand All @@ -520,21 +471,13 @@ def _text_search(self, root_cause: str, current_day: int) -> List[Dict[str, Any]
if not results:
return []

# Normalise against the MAX score in this result set, not rank-1.
# This means rank-1 still gets 1.0, but rank-2 gets a real relative score
# rather than everyone below rank-1 getting arbitrary lower values.
# More importantly, when there's only ONE candidate it still gets 1.0 —
# the threshold check (min_text=0.4) then does actual filtering work
# because a weak single match will have a low raw score.
raw_scores = [r.get("score", 0.0) for r in results]
max_score = max(raw_scores) if raw_scores else 1.0

# Use log-normalisation so weak matches don't cluster near 1.0
import math

normalised = []
for r, raw in zip(results, raw_scores):
# log1p normalisation preserves ordering, spreads out the range
norm_score = (
math.log1p(raw) / math.log1p(max_score) if max_score > 0 else 0.0
)
Expand Down Expand Up @@ -593,14 +536,6 @@ def _threshold_gate(self) -> Dict[str, float]:
}


# ─────────────────────────────────────────────────────────────────────────────
# MEMORY EXTENSION — search_events()
# Add this method to memory.py Memory class.
# Reproduced here as a standalone function so it can be monkey-patched in
# or copy-pasted into Memory directly.
# ─────────────────────────────────────────────────────────────────────────────


def search_events(
mem: Memory,
query: str,
Expand Down
9 changes: 4 additions & 5 deletions src/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import yaml

# ── Paths ─────────────────────────────────────────────────────────────────────

SRC_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SRC_DIR.parent

Expand All @@ -21,7 +21,7 @@

EXPORT_DIR.mkdir(parents=True, exist_ok=True)

# ── Load YAML ─────────────────────────────────────────────────────────────────

with open(CONFIG_PATH, "r") as _f:
_data = yaml.safe_load(_f)

Expand All @@ -30,7 +30,7 @@

CONFIG: Dict[str, Any] = _data

# ── Top-level constants ───────────────────────────────────────────────────────

COMPANY_NAME = CONFIG["simulation"]["company_name"]
COMPANY_DOMAIN = CONFIG["simulation"]["domain"]
COMPANY_DESCRIPTION = CONFIG["simulation"]["company_description"]
Expand All @@ -57,7 +57,6 @@
LIVE_ORG_CHART = {dept: list(members) for dept, members in ORG_CHART.items()}
LIVE_PERSONAS = {k: dict(v) for k, v in PERSONAS.items()}

# ── Model preset ──────────────────────────────────────────────────────────────
_PRESET_NAME = CONFIG.get("quality_preset", "local_gpu")
_PRESET = CONFIG["model_presets"][_PRESET_NAME]
_PROVIDER = _PRESET.get("provider", "ollama")
Expand All @@ -67,7 +66,7 @@ def resolve_role(role_key: str) -> str:
dept = CONFIG.get("roles", {}).get(role_key)
if dept and dept in LEADS:
return LEADS[dept]
# Check org_chart directly for depts not in LEADS (e.g. CEO)

if dept and dept in CONFIG.get("org_chart", {}):
members = CONFIG["org_chart"][dept]
if members:
Expand Down
23 changes: 5 additions & 18 deletions src/confluence_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def __init__(
self,
mem: Memory,
registry: ArtifactRegistry,
state, # flow.State — avoid circular import
state,
config: Dict,
worker_llm,
planner_llm,
Expand Down Expand Up @@ -103,10 +103,6 @@ def __init__(
self._all_names = [n for dept in config["org_chart"].values() for n in dept]
self._org_chart = config["org_chart"]

# ─────────────────────────────────────────────────────────────────────────
# PUBLIC API
# ─────────────────────────────────────────────────────────────────────────

def write_genesis_batch(
self,
prefix: str,
Expand Down Expand Up @@ -139,7 +135,7 @@ def write_genesis_batch(
Returns:
List of registered conf_ids in generation order.
"""
# 1. Allocate all IDs before any LLM call

genesis_time = self._clock.now("system").isoformat()
queue = [self._registry.next_id(prefix) for _ in range(count)]
registered_ids: List[str] = []
Expand Down Expand Up @@ -863,10 +859,6 @@ def _finalize_page(

return created_ids

# ─────────────────────────────────────────────────────────────────────────
# PRIVATE — JIRA TICKET SPAWNING
# ─────────────────────────────────────────────────────────────────────────

def _spawn_tickets(
self,
new_tickets: List[Dict],
Expand Down Expand Up @@ -952,7 +944,7 @@ def generate_tech_stack(self) -> dict:
Persists to MongoDB immediately so every subsequent LLM call can reference it.
Returns the stack as a dict.
"""
# Check if already generated (warm restart guard)

existing = self._mem.get_tech_stack()
if existing:
logger.info("[confluence] Tech stack already exists — skipping generation.")
Expand Down Expand Up @@ -1003,10 +995,6 @@ def generate_tech_stack(self) -> dict:
logger.info(f"[confluence] ✓ Tech stack established: {list(stack.keys())}")
return stack

# ─────────────────────────────────────────────────────────────────────────
# PRIVATE — UTILITIES
# ─────────────────────────────────────────────────────────────────────────

@staticmethod
def _render(template: str, vars_: Dict[str, str]) -> str:
for key, value in vars_.items():
Expand All @@ -1024,7 +1012,7 @@ def _render_template(self, template: str) -> str:

@staticmethod
def _extract_title(content: str, fallback: str) -> str:
# Strip code blocks before searching for headings

clean = re.sub(r"```.*?```", "", content, flags=re.DOTALL)

m = re.search(r"^#\s+(.+)", clean, re.MULTILINE)
Expand Down Expand Up @@ -1085,12 +1073,11 @@ def _tenure_at_date(

m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(yr|mo)", tenure_str.strip())
if not m:
return tenure_str # "new" or anything non-standard — leave as-is
return tenure_str

value, unit = float(m.group(1)), m.group(2)
months_at_sim_start = int(value * 12) if unit == "yr" else int(value)

# How many months before sim_start is this page?
delta = relativedelta(sim_start, page_date)
months_offset = delta.years * 12 + delta.months

Expand Down
Loading
Loading