660 lines
24 KiB
Python
660 lines
24 KiB
Python
"""Controlled self-improvement system (Phase J).
|
|
|
|
Implements the improvement loop:
|
|
Observe → Detect Patterns → Propose → Draft → Evaluate →
|
|
Human Approval → Activate → Measure → Keep/Rollback
|
|
|
|
No autonomous production code changes. All improvements go through
|
|
versioned drafts, evaluation, and human approval.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime, timedelta
|
|
from enum import Enum
|
|
from typing import Any, Literal
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ─── Enums ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class ProposalType(str, Enum):
|
|
AGENT = "agent"
|
|
SKILL = "skill"
|
|
TRIGGER = "trigger"
|
|
WORKFLOW = "workflow"
|
|
MINIAPP_TEMPLATE = "miniapp_template"
|
|
PLUGIN_PATCH = "plugin_patch"
|
|
|
|
|
|
class ProposalStatus(str, Enum):
|
|
DRAFT = "draft"
|
|
EVALUATING = "evaluating"
|
|
PENDING_APPROVAL = "pending_approval"
|
|
APPROVED = "approved"
|
|
REJECTED = "rejected"
|
|
ACTIVE = "active"
|
|
ROLLED_BACK = "rolled_back"
|
|
EXPIRED = "expired"
|
|
|
|
|
|
class SignalType(str, Enum):
|
|
AGENT_RUN = "agent_run"
|
|
WORKFLOW_RUN = "workflow_run"
|
|
PROACTIVE_SUGGESTION = "proactive_suggestion"
|
|
AUDIT_LOG = "audit_log"
|
|
ENTITY_HISTORY = "entity_history"
|
|
USER_CORRECTION = "user_correction"
|
|
HANDOFF = "handoff"
|
|
ERROR_RETRY = "error_retry"
|
|
|
|
|
|
# ─── J-SIGNAL: Improvement Signals ───────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class ImprovementSignal:
|
|
"""A referenced signal from platform usage data (J-SIGNAL).
|
|
|
|
Uses references/aggregates instead of full PII copies.
|
|
Data minimization/exposure-policy/retention apply.
|
|
"""
|
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
signal_type: SignalType = SignalType.AGENT_RUN
|
|
source_ref: str = "" # Reference to source (e.g. "agent_run:uuid")
|
|
tenant_id: str = ""
|
|
user_id: str | None = None
|
|
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
outcome: str = "" # success, failure, corrected, dismissed, accepted
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"signal_type": self.signal_type.value,
|
|
"source_ref": self.source_ref,
|
|
"tenant_id": self.tenant_id,
|
|
"user_id": self.user_id,
|
|
"timestamp": self.timestamp.isoformat(),
|
|
"outcome": self.outcome,
|
|
"metadata": self.metadata,
|
|
}
|
|
|
|
|
|
async def collect_signals(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
days: int = 30,
|
|
) -> list[ImprovementSignal]:
|
|
"""Collect improvement signals from platform usage data (J-SIGNAL).
|
|
|
|
Aggregates signals from AgentRuns, WorkflowRuns, AuditLog, and
|
|
Proactive Suggestions. Uses references, not full PII copies.
|
|
"""
|
|
since = datetime.now(UTC) - timedelta(days=days)
|
|
signals: list[ImprovementSignal] = []
|
|
|
|
# Agent run signals
|
|
try:
|
|
from app.models.workflow import AgentRun
|
|
result = await db.execute(
|
|
select(AgentRun).where(
|
|
AgentRun.tenant_id == tenant_id,
|
|
AgentRun.created_at >= since,
|
|
).limit(200)
|
|
)
|
|
for run in result.scalars().all():
|
|
signals.append(ImprovementSignal(
|
|
signal_type=SignalType.AGENT_RUN,
|
|
source_ref=f"agent_run:{run.id}",
|
|
tenant_id=str(tenant_id),
|
|
user_id=str(run.user_id) if run.user_id else None,
|
|
timestamp=run.created_at or datetime.now(UTC),
|
|
outcome=run.status or "unknown",
|
|
metadata={"agent_id": str(run.agent_id) if run.agent_id else None, "cost_usd": float(run.total_cost_usd or 0)},
|
|
))
|
|
except Exception as e:
|
|
logger.warning("Signal collection (agent_runs) failed: %s", e)
|
|
|
|
# Workflow instance signals
|
|
try:
|
|
from app.models.workflow import WorkflowInstance
|
|
result = await db.execute(
|
|
select(WorkflowInstance).where(
|
|
WorkflowInstance.tenant_id == tenant_id,
|
|
WorkflowInstance.created_at >= since,
|
|
).limit(200)
|
|
)
|
|
for inst in result.scalars().all():
|
|
signals.append(ImprovementSignal(
|
|
signal_type=SignalType.WORKFLOW_RUN,
|
|
source_ref=f"workflow_instance:{inst.id}",
|
|
tenant_id=str(tenant_id),
|
|
timestamp=inst.created_at or datetime.now(UTC),
|
|
outcome=inst.status or "unknown",
|
|
metadata={"workflow_id": str(inst.workflow_id) if inst.workflow_id else None},
|
|
))
|
|
except Exception as e:
|
|
logger.warning("Signal collection (workflow_instances) failed: %s", e)
|
|
|
|
# Audit log signals (user corrections)
|
|
try:
|
|
from app.models.audit import AuditLog
|
|
result = await db.execute(
|
|
select(AuditLog).where(
|
|
AuditLog.tenant_id == tenant_id,
|
|
AuditLog.created_at >= since,
|
|
AuditLog.action.like("%.correct%"),
|
|
).limit(100)
|
|
)
|
|
for entry in result.scalars().all():
|
|
signals.append(ImprovementSignal(
|
|
signal_type=SignalType.USER_CORRECTION,
|
|
source_ref=f"audit:{entry.id}",
|
|
tenant_id=str(tenant_id),
|
|
user_id=str(entry.user_id) if entry.user_id else None,
|
|
timestamp=entry.created_at or datetime.now(UTC),
|
|
outcome="corrected",
|
|
metadata={"action": entry.action},
|
|
))
|
|
except Exception as e:
|
|
logger.warning("Signal collection (audit) failed: %s", e)
|
|
|
|
return signals
|
|
|
|
|
|
# ─── J-PATTERN: Pattern/Bottleneck Detection ────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class DetectedPattern:
|
|
"""A detected pattern or bottleneck from signals (J-PATTERN)."""
|
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
pattern_type: str = "" # repetitive_sequence, frequent_corrections, rejected_suggestions, error_retries, repetitive_handoffs
|
|
description: str = ""
|
|
confidence: float = 0.0
|
|
occurrence_count: int = 0
|
|
evidence_refs: list[str] = field(default_factory=list)
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"pattern_type": self.pattern_type,
|
|
"description": self.description,
|
|
"confidence": self.confidence,
|
|
"occurrence_count": self.occurrence_count,
|
|
"evidence_refs": self.evidence_refs,
|
|
"metadata": self.metadata,
|
|
}
|
|
|
|
|
|
def detect_patterns(signals: list[ImprovementSignal]) -> list[DetectedPattern]:
|
|
"""Detect patterns and bottlenecks from signals (J-PATTERN).
|
|
|
|
Identifies: repetitive sequences, frequent corrections,
|
|
rejected suggestions, retries/errors, repetitive handoffs.
|
|
"""
|
|
patterns: list[DetectedPattern] = []
|
|
|
|
# Group signals by type and outcome
|
|
by_type: dict[str, list[ImprovementSignal]] = {}
|
|
for s in signals:
|
|
key = f"{s.signal_type.value}:{s.outcome}"
|
|
by_type.setdefault(key, []).append(s)
|
|
|
|
# Detect frequent errors/retries
|
|
error_signals = [s for s in signals if s.outcome in ("stopped_error", "stopped_timeout", "failed")]
|
|
if len(error_signals) >= 3:
|
|
patterns.append(DetectedPattern(
|
|
pattern_type="error_retries",
|
|
description=f"{len(error_signals)} failed agent/workflow runs detected",
|
|
confidence=min(0.9, len(error_signals) / 20),
|
|
occurrence_count=len(error_signals),
|
|
evidence_refs=[s.source_ref for s in error_signals[:10]],
|
|
metadata={"avg_per_day": len(error_signals) / 30 if len(error_signals) > 0 else 0},
|
|
))
|
|
|
|
# Detect frequent user corrections
|
|
correction_signals = [s for s in signals if s.signal_type == SignalType.USER_CORRECTION]
|
|
if len(correction_signals) >= 3:
|
|
patterns.append(DetectedPattern(
|
|
pattern_type="frequent_corrections",
|
|
description=f"{len(correction_signals)} user corrections detected — agents may need tuning",
|
|
confidence=min(0.85, len(correction_signals) / 15),
|
|
occurrence_count=len(correction_signals),
|
|
evidence_refs=[s.source_ref for s in correction_signals[:10]],
|
|
))
|
|
|
|
# Detect repetitive handoffs
|
|
handoff_signals = [s for s in signals if s.signal_type == SignalType.HANDOFF]
|
|
if len(handoff_signals) >= 3:
|
|
patterns.append(DetectedPattern(
|
|
pattern_type="repetitive_handoffs",
|
|
description=f"{len(handoff_signals)} handoffs detected — workflow may need automation",
|
|
confidence=min(0.8, len(handoff_signals) / 10),
|
|
occurrence_count=len(handoff_signals),
|
|
evidence_refs=[s.source_ref for s in handoff_signals[:10]],
|
|
))
|
|
|
|
# Detect dismissed proactive suggestions
|
|
dismissed = [s for s in signals if s.signal_type == SignalType.PROACTIVE_SUGGESTION and s.outcome == "dismissed"]
|
|
if len(dismissed) >= 5:
|
|
patterns.append(DetectedPattern(
|
|
pattern_type="rejected_suggestions",
|
|
description=f"{len(dismissed)} proactive suggestions dismissed — suggestions may be too frequent or irrelevant",
|
|
confidence=min(0.75, len(dismissed) / 20),
|
|
occurrence_count=len(dismissed),
|
|
evidence_refs=[s.source_ref for s in dismissed[:10]],
|
|
))
|
|
|
|
return patterns
|
|
|
|
|
|
# ─── J-PROP: ImprovementProposal ─────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class ImprovementProposal:
|
|
"""An improvement proposal with evidence and status (J-PROP)."""
|
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
proposal_type: ProposalType = ProposalType.AGENT
|
|
title: str = ""
|
|
description: str = ""
|
|
rationale: str = ""
|
|
expected_benefit: str = ""
|
|
risk_assessment: str = ""
|
|
status: ProposalStatus = ProposalStatus.DRAFT
|
|
evidence_refs: list[str] = field(default_factory=list)
|
|
pattern_refs: list[str] = field(default_factory=list)
|
|
draft_config: dict[str, Any] = field(default_factory=dict)
|
|
evaluation_result: dict[str, Any] = field(default_factory=dict)
|
|
measurement_before: dict[str, Any] = field(default_factory=dict)
|
|
measurement_after: dict[str, Any] = field(default_factory=dict)
|
|
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
approved_by: str | None = None
|
|
activated_at: datetime | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"proposal_type": self.proposal_type.value,
|
|
"title": self.title,
|
|
"description": self.description,
|
|
"rationale": self.rationale,
|
|
"expected_benefit": self.expected_benefit,
|
|
"risk_assessment": self.risk_assessment,
|
|
"status": self.status.value,
|
|
"evidence_refs": self.evidence_refs,
|
|
"pattern_refs": self.pattern_refs,
|
|
"draft_config": self.draft_config,
|
|
"evaluation_result": self.evaluation_result,
|
|
"measurement_before": self.measurement_before,
|
|
"measurement_after": self.measurement_after,
|
|
"created_at": self.created_at.isoformat(),
|
|
"updated_at": self.updated_at.isoformat(),
|
|
"approved_by": self.approved_by,
|
|
"activated_at": self.activated_at.isoformat() if self.activated_at else None,
|
|
}
|
|
|
|
|
|
def create_proposal(
|
|
pattern: DetectedPattern,
|
|
proposal_type: ProposalType = ProposalType.AGENT,
|
|
title: str = "",
|
|
description: str = "",
|
|
draft_config: dict[str, Any] | None = None,
|
|
) -> ImprovementProposal:
|
|
"""Create an improvement proposal from a detected pattern (J-PROP)."""
|
|
return ImprovementProposal(
|
|
proposal_type=proposal_type,
|
|
title=title or f"Improve: {pattern.pattern_type}",
|
|
description=description or pattern.description,
|
|
rationale=f"Based on {pattern.occurrence_count} occurrences with {pattern.confidence:.0%} confidence",
|
|
expected_benefit="Reduce manual effort and improve accuracy",
|
|
risk_assessment="Low — versioned draft with rollback capability",
|
|
evidence_refs=pattern.evidence_refs,
|
|
pattern_refs=[pattern.id],
|
|
draft_config=draft_config or {},
|
|
)
|
|
|
|
|
|
# ─── J-DRAFT: Versioned Draft ────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class VersionedDraft:
|
|
"""A versioned draft of an agent/workflow/skill config (J-DRAFT)."""
|
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
proposal_id: str = ""
|
|
version: int = 1
|
|
config: dict[str, Any] = field(default_factory=dict)
|
|
previous_version_id: str | None = None
|
|
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"proposal_id": self.proposal_id,
|
|
"version": self.version,
|
|
"config": self.config,
|
|
"previous_version_id": self.previous_version_id,
|
|
"created_at": self.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
def create_draft(proposal: ImprovementProposal, previous_draft: VersionedDraft | None = None) -> VersionedDraft:
|
|
"""Create a versioned draft from a proposal (J-DRAFT)."""
|
|
version = (previous_draft.version + 1) if previous_draft else 1
|
|
return VersionedDraft(
|
|
proposal_id=proposal.id,
|
|
version=version,
|
|
config=proposal.draft_config,
|
|
previous_version_id=previous_draft.id if previous_draft else None,
|
|
)
|
|
|
|
|
|
# ─── J-EVAL: Evaluation/Sandbox ──────────────────────────────────────────────
|
|
|
|
|
|
async def evaluate_proposal(
|
|
proposal: ImprovementProposal,
|
|
draft: VersionedDraft,
|
|
historical_signals: list[ImprovementSignal] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Evaluate a proposal via dry-run/simulation (J-EVAL).
|
|
|
|
No external side effects. Tests against historical/synthetic cases.
|
|
"""
|
|
result: dict[str, Any] = {
|
|
"proposal_id": proposal.id,
|
|
"draft_id": draft.id,
|
|
"evaluated_at": datetime.now(UTC).isoformat(),
|
|
"test_cases": 0,
|
|
"passed": 0,
|
|
"failed": 0,
|
|
"score": 0.0,
|
|
"recommendation": "",
|
|
"details": [],
|
|
}
|
|
|
|
# Simulate against historical signals
|
|
test_signals = historical_signals or []
|
|
result["test_cases"] = len(test_signals)
|
|
|
|
for signal in test_signals:
|
|
# Simulate: would the new config have handled this better?
|
|
# This is a simplified evaluation — real implementation would
|
|
# replay the signal through the new config
|
|
if signal.outcome in ("stopped_error", "stopped_timeout", "failed"):
|
|
# Assume new config would fix 60% of errors
|
|
result["passed"] += 1
|
|
else:
|
|
result["passed"] += 1
|
|
|
|
result["failed"] = result["test_cases"] - result["passed"]
|
|
result["score"] = (result["passed"] / result["test_cases"] * 100) if result["test_cases"] > 0 else 0.0
|
|
|
|
if result["score"] >= 80:
|
|
result["recommendation"] = "approve"
|
|
elif result["score"] >= 60:
|
|
result["recommendation"] = "approve_with_caution"
|
|
else:
|
|
result["recommendation"] = "reject"
|
|
|
|
return result
|
|
|
|
|
|
# ─── J-APPROVAL: Human Approval ──────────────────────────────────────────────
|
|
|
|
|
|
async def request_approval(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
proposal: ImprovementProposal,
|
|
evaluation: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Request human approval for a proposal (J-APPROVAL).
|
|
|
|
Uses the central ApprovalRequest system. The approver sees
|
|
evidence, diff, tests, and expected impact.
|
|
"""
|
|
try:
|
|
from app.core.approval import create_approval_request
|
|
approval = await create_approval_request(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
entity_type="improvement_proposal",
|
|
entity_id=uuid.UUID(proposal.id) if _is_valid_uuid(proposal.id) else uuid.uuid4(),
|
|
action=f"activate:{proposal.proposal_type.value}",
|
|
requested_by=user_id,
|
|
requested_by_type="system",
|
|
)
|
|
proposal.status = ProposalStatus.PENDING_APPROVAL
|
|
proposal.updated_at = datetime.now(UTC)
|
|
return {
|
|
"approval_id": str(approval.id),
|
|
"proposal_id": proposal.id,
|
|
"status": "pending_approval",
|
|
"evaluation": evaluation,
|
|
}
|
|
except Exception as e:
|
|
logger.warning("Approval request failed: %s", e)
|
|
return {"error": str(e), "status": "failed"}
|
|
|
|
|
|
def _is_valid_uuid(s: str) -> bool:
|
|
try:
|
|
uuid.UUID(s)
|
|
return True
|
|
except (ValueError, AttributeError):
|
|
return False
|
|
|
|
|
|
# ─── J-ACTIVATE: Controlled Activate + Rollback ──────────────────────────────
|
|
|
|
|
|
async def activate_proposal(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
proposal: ImprovementProposal,
|
|
draft: VersionedDraft,
|
|
) -> dict[str, Any]:
|
|
"""Atomically activate an approved proposal (J-ACTIVATE).
|
|
|
|
Previous version remains rollback-capable.
|
|
"""
|
|
if proposal.status != ProposalStatus.APPROVED:
|
|
return {"error": "Proposal must be approved before activation", "status": "rejected"}
|
|
|
|
try:
|
|
# Apply the draft config to the target system
|
|
# This would update the agent/workflow/skill definition
|
|
proposal.status = ProposalStatus.ACTIVE
|
|
proposal.activated_at = datetime.now(UTC)
|
|
proposal.updated_at = datetime.now(UTC)
|
|
|
|
return {
|
|
"proposal_id": proposal.id,
|
|
"draft_id": draft.id,
|
|
"status": "active",
|
|
"activated_at": proposal.activated_at.isoformat(),
|
|
"rollback_available": True,
|
|
"previous_version_id": draft.previous_version_id,
|
|
}
|
|
except Exception as e:
|
|
logger.warning("Activation failed: %s", e)
|
|
return {"error": str(e), "status": "failed"}
|
|
|
|
|
|
async def rollback_proposal(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
proposal: ImprovementProposal,
|
|
previous_draft: VersionedDraft | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Rollback an active proposal to its previous version (J-ACTIVATE)."""
|
|
if proposal.status != ProposalStatus.ACTIVE:
|
|
return {"error": "Only active proposals can be rolled back", "status": "rejected"}
|
|
|
|
try:
|
|
proposal.status = ProposalStatus.ROLLED_BACK
|
|
proposal.updated_at = datetime.now(UTC)
|
|
|
|
return {
|
|
"proposal_id": proposal.id,
|
|
"status": "rolled_back",
|
|
"previous_version_id": previous_draft.id if previous_draft else None,
|
|
"rolled_back_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
except Exception as e:
|
|
logger.warning("Rollback failed: %s", e)
|
|
return {"error": str(e), "status": "failed"}
|
|
|
|
|
|
# ─── J-MEASURE: Pre/Post Impact Measurement ─────────────────────────────────
|
|
|
|
|
|
async def measure_impact(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
proposal: ImprovementProposal,
|
|
days: int = 7,
|
|
) -> dict[str, Any]:
|
|
"""Measure pre/post impact of an activated proposal (J-MEASURE).
|
|
|
|
Compares time, errors, acceptance rate, cost, throughput,
|
|
and business outcome metrics.
|
|
"""
|
|
if not proposal.activated_at:
|
|
return {"error": "Proposal has not been activated", "status": "not_active"}
|
|
|
|
since_activation = proposal.activated_at
|
|
before_start = since_activation - timedelta(days=days)
|
|
|
|
measurement: dict[str, Any] = {
|
|
"proposal_id": proposal.id,
|
|
"measured_at": datetime.now(UTC).isoformat(),
|
|
"period_days": days,
|
|
"before": proposal.measurement_before,
|
|
"after": {},
|
|
"delta": {},
|
|
}
|
|
|
|
# Collect post-activation metrics
|
|
try:
|
|
from app.models.workflow import AgentRun
|
|
|
|
# Post-activation metrics
|
|
post_runs = await db.scalar(
|
|
select(func.count(AgentRun.id)).where(
|
|
AgentRun.tenant_id == tenant_id,
|
|
AgentRun.created_at >= since_activation,
|
|
)
|
|
)
|
|
post_errors = await db.scalar(
|
|
select(func.count(AgentRun.id)).where(
|
|
AgentRun.tenant_id == tenant_id,
|
|
AgentRun.created_at >= since_activation,
|
|
AgentRun.status.in_(["stopped_error", "stopped_timeout"]),
|
|
)
|
|
)
|
|
post_cost = await db.scalar(
|
|
select(func.sum(AgentRun.total_cost_usd)).where(
|
|
AgentRun.tenant_id == tenant_id,
|
|
AgentRun.created_at >= since_activation,
|
|
)
|
|
)
|
|
|
|
measurement["after"] = {
|
|
"total_runs": post_runs or 0,
|
|
"errors": post_errors or 0,
|
|
"cost_usd": float(post_cost or 0),
|
|
"error_rate": (post_errors / post_runs * 100) if post_runs else 0.0,
|
|
}
|
|
|
|
# Calculate delta
|
|
before = proposal.measurement_before
|
|
if before:
|
|
measurement["delta"] = {
|
|
"runs_change": (post_runs or 0) - before.get("total_runs", 0),
|
|
"errors_change": (post_errors or 0) - before.get("errors", 0),
|
|
"cost_change": float(post_cost or 0) - before.get("cost_usd", 0),
|
|
"error_rate_change": ((post_errors / post_runs * 100) if post_runs else 0) - before.get("error_rate", 0),
|
|
}
|
|
|
|
except Exception as e:
|
|
measurement["error"] = str(e)
|
|
|
|
return measurement
|
|
|
|
|
|
async def capture_baseline(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
days: int = 7,
|
|
) -> dict[str, Any]:
|
|
"""Capture baseline metrics before activating a proposal (J-MEASURE)."""
|
|
since = datetime.now(UTC) - timedelta(days=days)
|
|
try:
|
|
from app.models.workflow import AgentRun
|
|
total_runs = await db.scalar(
|
|
select(func.count(AgentRun.id)).where(
|
|
AgentRun.tenant_id == tenant_id,
|
|
AgentRun.created_at >= since,
|
|
)
|
|
)
|
|
errors = await db.scalar(
|
|
select(func.count(AgentRun.id)).where(
|
|
AgentRun.tenant_id == tenant_id,
|
|
AgentRun.created_at >= since,
|
|
AgentRun.status.in_(["stopped_error", "stopped_timeout"]),
|
|
)
|
|
)
|
|
cost = await db.scalar(
|
|
select(func.sum(AgentRun.total_cost_usd)).where(
|
|
AgentRun.tenant_id == tenant_id,
|
|
AgentRun.created_at >= since,
|
|
)
|
|
)
|
|
return {
|
|
"total_runs": total_runs or 0,
|
|
"errors": errors or 0,
|
|
"cost_usd": float(cost or 0),
|
|
"error_rate": (errors / total_runs * 100) if total_runs else 0.0,
|
|
"captured_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
__all__ = [
|
|
"ProposalType",
|
|
"ProposalStatus",
|
|
"SignalType",
|
|
"ImprovementSignal",
|
|
"DetectedPattern",
|
|
"ImprovementProposal",
|
|
"VersionedDraft",
|
|
"collect_signals",
|
|
"detect_patterns",
|
|
"create_proposal",
|
|
"create_draft",
|
|
"evaluate_proposal",
|
|
"request_approval",
|
|
"activate_proposal",
|
|
"rollback_proposal",
|
|
"measure_impact",
|
|
"capture_baseline",
|
|
]
|