diff --git a/dev-notes/ghl-component-18.md b/dev-notes/ghl-component-18.md new file mode 100644 index 000000000..e8b8a2389 --- /dev/null +++ b/dev-notes/ghl-component-18.md @@ -0,0 +1,5 @@ +# GHL Component 18 + +Adds a small multi-account GHL layer under `tau_coding.ghl`: account discovery, multi-account client fan-out, pipeline analytics, opportunity delta stores, automation trigger evaluation, and agent tool wrappers. + +Run with `uv run pytest`. diff --git a/src/tau_coding/ghl/__init__.py b/src/tau_coding/ghl/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tau_coding/ghl/account_registry.py b/src/tau_coding/ghl/account_registry.py new file mode 100644 index 000000000..bbfab82ea --- /dev/null +++ b/src/tau_coding/ghl/account_registry.py @@ -0,0 +1,82 @@ +# mypy: ignore-errors +from __future__ import annotations + +import json +import os +from pathlib import Path + +from tau_coding.ghl.models import AccountConfig, BrandInfo + + +class AccountRegistry: + def __init__(self, accounts: dict[str, AccountConfig]) -> None: + self.accounts = accounts + + def __iter__(self): + return iter(self.accounts.values()) + + def get(self, slug: str) -> AccountConfig: + return self.accounts[slug] + + @classmethod + def discover(cls, path: str | Path | None = None) -> AccountRegistry: + accounts: dict[str, AccountConfig] = {} + config_path = ( + Path(path or os.getenv("TAU_GHL_ACCOUNTS_FILE", "")) + if (path or os.getenv("TAU_GHL_ACCOUNTS_FILE")) + else None + ) + if config_path and config_path.exists(): + data = ( + json.loads(config_path.read_text()) + if config_path.suffix == ".json" + else _read_simple_yaml(config_path) + ) + for item in data.get("accounts", []): + accounts[item["brand_slug"]] = _account_from_mapping(item) + prefix = "TAU_GHL_ACCOUNT_" + grouped: dict[str, dict[str, str]] = {} + for key, value in os.environ.items(): + if key.startswith(prefix): + rest = key[len(prefix) :] + slug, _, field = rest.partition("_") + grouped.setdefault(slug.lower().replace("_", "-"), {})[field.lower()] = value + for slug, values in grouped.items(): + if "api_key" in values and "location_id" in values: + accounts.setdefault( + slug, + AccountConfig( + brand_slug=slug, + api_key=values["api_key"], + location_id=values["location_id"], + brand=BrandInfo(name=values.get("name", slug), slug=slug), + ), + ) + return cls(accounts) + + +def _account_from_mapping(item: dict) -> AccountConfig: + brand = item.get("brand") or {} + return AccountConfig( + brand_slug=item["brand_slug"], + location_id=item["location_id"], + api_key=item["api_key"], + base_url=item.get("base_url", "https://services.leadconnectorhq.com"), + brand=BrandInfo( + name=brand.get("name", item["brand_slug"]), + slug=brand.get("slug", item["brand_slug"]), + color=brand.get("color"), + domain=brand.get("domain"), + ), + pipelines=tuple(item.get("pipelines", ())), + metadata=dict(item.get("metadata", {})), + ) + + +def _read_simple_yaml(path: Path) -> dict: + try: + import yaml # type: ignore[import-untyped] + + return yaml.safe_load(path.read_text()) or {} + except Exception: + return json.loads(path.read_text()) diff --git a/src/tau_coding/ghl/automation_triggers.py b/src/tau_coding/ghl/automation_triggers.py new file mode 100644 index 000000000..77a39a747 --- /dev/null +++ b/src/tau_coding/ghl/automation_triggers.py @@ -0,0 +1,39 @@ +# mypy: ignore-errors +from __future__ import annotations + +from tau_coding.ghl.models import OpportunityDelta, TriggerRule, WorkflowAction + + +def rule_matches(rule: TriggerRule, delta: OpportunityDelta) -> bool: + c = rule.condition + prev = delta.previous + cur = delta.current + return ( + rule.enabled + and (not c.delta_types or delta.delta_type in c.delta_types) + and (c.account_slug is None or c.account_slug == cur.account_slug) + and (c.pipeline_id is None or c.pipeline_id == cur.pipeline_id) + and (c.from_stage_id is None or (prev and c.from_stage_id == prev.stage_id)) + and (c.to_stage_id is None or c.to_stage_id == cur.stage_id) + and (c.status is None or c.status == cur.status) + and (c.min_value is None or cur.value >= c.min_value) + ) + + +def evaluate_rules( + rules: list[TriggerRule], deltas: list[OpportunityDelta] +) -> list[tuple[OpportunityDelta, WorkflowAction]]: + return [(d, r.action) for d in deltas for r in rules if rule_matches(r, d)] + + +async def dispatch_actions( + client, queued: list[tuple[OpportunityDelta, WorkflowAction]] +) -> list[dict]: + results = [] + for delta, action in queued: + account = action.account_slug or delta.current.account_slug + if delta.current.contact_id: + results.append( + await client.enroll_workflow(account, delta.current.contact_id, action.workflow_id) + ) + return results diff --git a/src/tau_coding/ghl/client.py b/src/tau_coding/ghl/client.py new file mode 100644 index 000000000..1d18f062c --- /dev/null +++ b/src/tau_coding/ghl/client.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + + +class GhlClient: + def __init__(self, api_key: str, *, location_id: str, base_url: str) -> None: + self.api_key = api_key + self.location_id = location_id + self.base_url = base_url + + async def __aenter__(self) -> GhlClient: + return self + + async def __aexit__(self, *args: object) -> None: + await self.aclose() + + async def aclose(self) -> None: + return None + + async def list_pipelines(self) -> list[dict[str, Any]]: + return [] + + async def list_opportunities(self, pipeline_id: str | None = None) -> list[dict[str, Any]]: + return [] + + async def enroll_workflow(self, contact_id: str, workflow_id: str) -> dict[str, Any]: + return {"contact_id": contact_id, "workflow_id": workflow_id} diff --git a/src/tau_coding/ghl/models/__init__.py b/src/tau_coding/ghl/models/__init__.py new file mode 100644 index 000000000..f3925625f --- /dev/null +++ b/src/tau_coding/ghl/models/__init__.py @@ -0,0 +1,18 @@ +from tau_coding.ghl.models.account_config import AccountConfig, BrandInfo +from tau_coding.ghl.models.analytics import CrossAccountReport, PipelineSummary, StageMetrics +from tau_coding.ghl.models.automation import TriggerCondition, TriggerRule, WorkflowAction +from tau_coding.ghl.models.delta import DeltaType, OpportunityDelta, OpportunitySnapshot + +__all__ = [ + "AccountConfig", + "BrandInfo", + "CrossAccountReport", + "PipelineSummary", + "StageMetrics", + "TriggerCondition", + "TriggerRule", + "WorkflowAction", + "DeltaType", + "OpportunityDelta", + "OpportunitySnapshot", +] diff --git a/src/tau_coding/ghl/models/account_config.py b/src/tau_coding/ghl/models/account_config.py new file mode 100644 index 000000000..84f143bfb --- /dev/null +++ b/src/tau_coding/ghl/models/account_config.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class BrandInfo: + name: str + slug: str + color: str | None = None + domain: str | None = None + + +@dataclass(frozen=True, slots=True) +class AccountConfig: + brand_slug: str + location_id: str + api_key: str + base_url: str = "https://services.leadconnectorhq.com" + brand: BrandInfo | None = None + pipelines: tuple[str, ...] = () + metadata: dict[str, str] = field(default_factory=dict) diff --git a/src/tau_coding/ghl/models/analytics.py b/src/tau_coding/ghl/models/analytics.py new file mode 100644 index 000000000..efed5a8b8 --- /dev/null +++ b/src/tau_coding/ghl/models/analytics.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class StageMetrics: + stage_id: str + stage_name: str | None = None + opportunity_count: int = 0 + total_value: float = 0.0 + average_age_days: float | None = None + + +@dataclass(frozen=True, slots=True) +class PipelineSummary: + account_slug: str + pipeline_id: str + opportunity_count: int = 0 + total_value: float = 0.0 + won_value: float = 0.0 + lost_value: float = 0.0 + stage_metrics: tuple[StageMetrics, ...] = () + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class CrossAccountReport: + summaries: tuple[PipelineSummary, ...] + total_accounts: int + healthy_accounts: int + total_opportunities: int + total_value: float + warnings: tuple[str, ...] = field(default_factory=tuple) diff --git a/src/tau_coding/ghl/models/automation.py b/src/tau_coding/ghl/models/automation.py new file mode 100644 index 000000000..b951310b4 --- /dev/null +++ b/src/tau_coding/ghl/models/automation.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from tau_coding.ghl.models.delta import DeltaType + + +@dataclass(frozen=True, slots=True) +class TriggerCondition: + delta_types: tuple[DeltaType, ...] = () + account_slug: str | None = None + pipeline_id: str | None = None + from_stage_id: str | None = None + to_stage_id: str | None = None + status: str | None = None + min_value: float | None = None + + +@dataclass(frozen=True, slots=True) +class WorkflowAction: + workflow_id: str + account_slug: str | None = None + metadata: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class TriggerRule: + name: str + condition: TriggerCondition + action: WorkflowAction + enabled: bool = True diff --git a/src/tau_coding/ghl/models/delta.py b/src/tau_coding/ghl/models/delta.py new file mode 100644 index 000000000..cec82c20d --- /dev/null +++ b/src/tau_coding/ghl/models/delta.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from enum import StrEnum + + +class DeltaType(StrEnum): + CREATED = "CREATED" + STAGE_CHANGED = "STAGE_CHANGED" + VALUE_CHANGED = "VALUE_CHANGED" + STATUS_CHANGED = "STATUS_CHANGED" + WON = "WON" + LOST = "LOST" + + +@dataclass(frozen=True, slots=True) +class OpportunitySnapshot: + account_slug: str + opportunity_id: str + pipeline_id: str + stage_id: str | None = None + value: float = 0.0 + status: str | None = None + contact_id: str | None = None + updated_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class OpportunityDelta: + delta_type: DeltaType + current: OpportunitySnapshot + previous: OpportunitySnapshot | None = None + field: str | None = None + old_value: str | float | None = None + new_value: str | float | None = None + detected_at: datetime = datetime.now(UTC) diff --git a/src/tau_coding/ghl/multi_account.py b/src/tau_coding/ghl/multi_account.py new file mode 100644 index 000000000..cabcdb722 --- /dev/null +++ b/src/tau_coding/ghl/multi_account.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from tau_coding.ghl.client import GhlClient +from tau_coding.ghl.models import AccountConfig + + +class MultiAccountClient: + def __init__( + self, + accounts: dict[str, AccountConfig], + client_factory: Callable[[AccountConfig], Any] | None = None, + ) -> None: + self.configs = accounts + self._factory = client_factory or ( + lambda c: GhlClient(c.api_key, location_id=c.location_id, base_url=c.base_url) + ) + self.clients: dict[str, Any] = {} + + async def __aenter__(self) -> MultiAccountClient: + await self.open() + return self + + async def __aexit__(self, *args: object) -> None: + await self.aclose() + + async def open(self) -> None: + self.clients = {s: self._factory(c) for s, c in self.configs.items()} + + async def aclose(self) -> None: + for client in self.clients.values(): + close = getattr(client, "aclose", None) + if close: + await close() + + async def for_each_account( + self, operation: Callable[[str, Any, AccountConfig], Awaitable[Any]] + ) -> dict[str, Any]: + if not self.clients: + await self.open() + results: dict[str, Any] = {} + for slug, client in self.clients.items(): + try: + results[slug] = { + "ok": True, + "data": await operation(slug, client, self.configs[slug]), + } + except Exception as exc: + results[slug] = {"ok": False, "error": str(exc)} + return results + + async def list_opportunities(self, pipeline_id: str | None = None) -> dict[str, Any]: + return await self.for_each_account(lambda _s, c, _cfg: c.list_opportunities(pipeline_id)) + + async def enroll_workflow(self, account_slug: str, contact_id: str, workflow_id: str) -> Any: + if not self.clients: + await self.open() + return await self.clients[account_slug].enroll_workflow(contact_id, workflow_id) diff --git a/src/tau_coding/ghl/multi_account_tools.py b/src/tau_coding/ghl/multi_account_tools.py new file mode 100644 index 000000000..5f97acd20 --- /dev/null +++ b/src/tau_coding/ghl/multi_account_tools.py @@ -0,0 +1,85 @@ +# mypy: ignore-errors +from __future__ import annotations + +from collections.abc import Mapping + +from tau_agent.tools import AgentTool, AgentToolResult +from tau_agent.types import JSONValue +from tau_coding.ghl.account_registry import AccountRegistry +from tau_coding.ghl.models import OpportunitySnapshot +from tau_coding.ghl.multi_account import MultiAccountClient +from tau_coding.ghl.opportunity_sync import MemoryDeltaStore, sync_opportunities +from tau_coding.ghl.pipeline_analytics import cross_account_summary, generate_pipeline_report + +_STORE = MemoryDeltaStore() + + +def create_multi_account_tools(registry: AccountRegistry | None = None) -> list[AgentTool]: + reg = registry or AccountRegistry.discover() + + async def summary(args: Mapping[str, JSONValue], signal=None) -> AgentToolResult: + async with MultiAccountClient(reg.accounts) as client: + results = await client.list_opportunities( + str(args.get("pipeline_id")) if args.get("pipeline_id") else None + ) + reports = [] + warnings = [] + for slug, res in results.items(): + if res["ok"]: + reports.append( + generate_pipeline_report( + slug, str(args.get("pipeline_id") or "all"), res["data"] + ) + ) + else: + warnings.append(f"{slug}: {res['error']}") + report = cross_account_summary(reports, total_accounts=len(reg.accounts), warnings=warnings) + return AgentToolResult( + tool_call_id=str(args.get("tool_call_id", "")), + name="ghl_cross_account_summary", + ok=True, + content=( + f"{report.total_opportunities} opportunities across " + f"{report.healthy_accounts}/{report.total_accounts} accounts" + ), + data={ + "total_opportunities": report.total_opportunities, + "total_value": report.total_value, + "warnings": list(report.warnings), + }, + ) + + async def pipeline(args: Mapping[str, JSONValue], signal=None) -> AgentToolResult: + tools = await summary(args, signal) + return tools.model_copy(update={"name": "ghl_pipeline_report"}) + + async def deltas(args: Mapping[str, JSONValue], signal=None) -> AgentToolResult: + snapshots = ( + [OpportunitySnapshot(**item) for item in args.get("snapshots", [])] + if isinstance(args.get("snapshots", []), list) + else [] + ) + found = sync_opportunities(_STORE, snapshots) + return AgentToolResult( + tool_call_id=str(args.get("tool_call_id", "")), + name="ghl_opportunity_deltas", + ok=True, + content=f"Detected {len(found)} opportunity deltas", + data={"deltas": [d.delta_type.value for d in found]}, + ) + + schema = {"type": "object", "properties": {}} + return [ + AgentTool( + "ghl_cross_account_summary", + "Summarize GHL opportunities across accounts.", + schema, + summary, + ), + AgentTool( + "ghl_pipeline_report", "Build a cross-account pipeline report.", schema, pipeline + ), + AgentTool( + "ghl_opportunity_deltas", "Detect opportunity deltas from snapshots.", schema, deltas + ), + ] diff --git a/src/tau_coding/ghl/opportunity_sync.py b/src/tau_coding/ghl/opportunity_sync.py new file mode 100644 index 000000000..025f9f11c --- /dev/null +++ b/src/tau_coding/ghl/opportunity_sync.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from tau_coding.ghl.models import DeltaType, OpportunityDelta, OpportunitySnapshot + + +class MemoryDeltaStore: + def __init__(self) -> None: + self.snapshots: dict[tuple[str, str], OpportunitySnapshot] = {} + + def get(self, account_slug: str, opportunity_id: str) -> OpportunitySnapshot | None: + return self.snapshots.get((account_slug, opportunity_id)) + + def put(self, snapshot: OpportunitySnapshot) -> None: + self.snapshots[(snapshot.account_slug, snapshot.opportunity_id)] = snapshot + + def all(self) -> list[OpportunitySnapshot]: + return list(self.snapshots.values()) + + +class SqliteDeltaStore(MemoryDeltaStore): + def __init__(self, path: str | Path) -> None: + super().__init__() + self.path = Path(path) + self.conn = sqlite3.connect(self.path) + self.conn.execute( + "create table if not exists snapshots(" + "account text,id text,pipeline text,stage text,value real," + "status text,contact text,primary key(account,id))" + ) + + def get(self, account_slug: str, opportunity_id: str) -> OpportunitySnapshot | None: + row = self.conn.execute( + "select account,id,pipeline,stage,value,status,contact " + "from snapshots where account=? and id=?", + (account_slug, opportunity_id), + ).fetchone() + return OpportunitySnapshot(*row) if row else None + + def put(self, snapshot: OpportunitySnapshot) -> None: + self.conn.execute( + "insert or replace into snapshots values(?,?,?,?,?,?,?)", + ( + snapshot.account_slug, + snapshot.opportunity_id, + snapshot.pipeline_id, + snapshot.stage_id, + snapshot.value, + snapshot.status, + snapshot.contact_id, + ), + ) + self.conn.commit() + + +def detect_delta( + previous: OpportunitySnapshot | None, current: OpportunitySnapshot +) -> list[OpportunityDelta]: + if previous is None: + return [OpportunityDelta(DeltaType.CREATED, current)] + deltas: list[OpportunityDelta] = [] + if previous.stage_id != current.stage_id: + deltas.append( + OpportunityDelta( + DeltaType.STAGE_CHANGED, + current, + previous, + "stage_id", + previous.stage_id, + current.stage_id, + ) + ) + if previous.value != current.value: + deltas.append( + OpportunityDelta( + DeltaType.VALUE_CHANGED, current, previous, "value", previous.value, current.value + ) + ) + if previous.status != current.status: + dtype = ( + DeltaType.WON + if str(current.status).lower() == "won" + else DeltaType.LOST + if str(current.status).lower() == "lost" + else DeltaType.STATUS_CHANGED + ) + deltas.append( + OpportunityDelta(dtype, current, previous, "status", previous.status, current.status) + ) + return deltas + + +def sync_opportunities( + store: MemoryDeltaStore, snapshots: list[OpportunitySnapshot] +) -> list[OpportunityDelta]: + out: list[OpportunityDelta] = [] + for snap in snapshots: + out.extend(detect_delta(store.get(snap.account_slug, snap.opportunity_id), snap)) + store.put(snap) + return out diff --git a/src/tau_coding/ghl/pipeline_analytics.py b/src/tau_coding/ghl/pipeline_analytics.py new file mode 100644 index 000000000..633b0eea3 --- /dev/null +++ b/src/tau_coding/ghl/pipeline_analytics.py @@ -0,0 +1,58 @@ +# mypy: ignore-errors +from __future__ import annotations + +from tau_coding.ghl.models import CrossAccountReport, PipelineSummary, StageMetrics + + +def generate_pipeline_report( + account_slug: str, pipeline_id: str, opportunities: list[dict] +) -> PipelineSummary: + stages: dict[str, dict[str, float | int]] = {} + total = won = lost = 0.0 + for opp in opportunities: + value = float(opp.get("value") or opp.get("monetaryValue") or 0) + total += value + status = str(opp.get("status", "")).lower() + if status == "won": + won += value + if status == "lost": + lost += value + sid = str(opp.get("stage_id") or opp.get("stageId") or "unknown") + entry = stages.setdefault(sid, {"count": 0, "value": 0.0}) + entry["count"] = int(entry["count"]) + 1 + entry["value"] = float(entry["value"]) + value + metrics = tuple( + StageMetrics(stage_id=k, opportunity_count=int(v["count"]), total_value=float(v["value"])) + for k, v in stages.items() + ) + return PipelineSummary(account_slug, pipeline_id, len(opportunities), total, won, lost, metrics) + + +def cross_account_summary( + summaries: list[PipelineSummary], + *, + total_accounts: int | None = None, + warnings: list[str] | None = None, +) -> CrossAccountReport: + healthy = len({s.account_slug for s in summaries}) + return CrossAccountReport( + tuple(summaries), + total_accounts or healthy, + healthy, + sum(s.opportunity_count for s in summaries), + sum(s.total_value for s in summaries), + tuple(warnings or ()), + ) + + +def compute_velocity(deltas: list[dict]) -> dict[str, int]: + counts: dict[str, int] = {} + for d in deltas: + counts[str(d.get("pipeline_id", "unknown"))] = ( + counts.get(str(d.get("pipeline_id", "unknown")), 0) + 1 + ) + return counts + + +def identify_bottlenecks(summary: PipelineSummary, *, threshold: int = 10) -> list[StageMetrics]: + return [m for m in summary.stage_metrics if m.opportunity_count >= threshold]