OpenSpace/openspace/cloud/skill_quality_reporter.py
Ayush7614 a7bfd6cc26 fix: scan dashboard workflows from data-home logs too
Installed dashboards could not see recordings under site-packages logs.
Include get_data_home()/logs paths and stop using PROJECT_ROOT as the
default cloud reporter workspace root after pip install.
2026-07-29 02:55:43 +05:30

292 lines
11 KiB
Python

"""Best-effort analyzer skill quality telemetry reporter."""
from __future__ import annotations
import asyncio
from concurrent.futures import Future as ConcurrentFuture
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from openspace.cloud.client import OpenSpaceClient
from openspace.cloud.config import (
load_cloud_config,
load_cloud_skill_quality_reporting_enabled,
)
from openspace.cloud.local_mapping import CloudLocalMappingStore
from openspace.cloud.redaction import REDACTION_POLICY_VERSION
from openspace.cloud.telemetry_outbox import CloudTelemetryOutbox
from openspace.cloud.telemetry_payloads import (
build_skill_use_report_payload,
short_cloud_request_id,
)
from openspace.config.constants import get_data_home
from openspace.skill_engine.types import ExecutionAnalysis, SkillJudgment
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
QUALITY_EVENT_KIND = "skill_judgment"
QUALITY_SCHEMA_VERSION = "skill_quality_v1"
QUALITY_DENOMINATOR = "analyzer_judged_skill_use"
EXECUTOR_POLL_INTERVAL_SECONDS = 0.01
class CloudSkillQualityReporter:
"""Report persisted analyzer judgments as skill-use telemetry."""
def __init__(
self,
*,
client: OpenSpaceClient | None = None,
mapping_store: CloudLocalMappingStore | None = None,
outbox: CloudTelemetryOutbox | None = None,
workspace_root: str | Path | None = None,
) -> None:
self._client = client
self._mapping_store = mapping_store
self._outbox = outbox
if workspace_root is not None:
self._workspace_root = Path(workspace_root).resolve()
elif Path.cwd().exists():
self._workspace_root = Path.cwd().resolve()
else:
self._workspace_root = get_data_home(create=False)
async def maybe_report_analysis(
self,
analysis: ExecutionAnalysis,
*,
session_id: str | None = None,
) -> dict[str, Any]:
executor: ThreadPoolExecutor | None = None
result_future: ConcurrentFuture[dict[str, Any]] | None = None
loop_future: asyncio.Future[Any] | None = None
try:
if not load_cloud_skill_quality_reporting_enabled():
return _skill_quality_reporting_disabled_result()
executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="openspace-skill-quality-reporter",
)
result_future = ConcurrentFuture()
def run_sync_report() -> None:
if not result_future.set_running_or_notify_cancel():
return
try:
result_future.set_result(
self._maybe_report_analysis_sync(
analysis,
session_id=session_id,
)
)
except BaseException as exc:
result_future.set_exception(exc)
loop = asyncio.get_running_loop()
loop_future = loop.run_in_executor(executor, run_sync_report)
while not result_future.done():
await asyncio.sleep(EXECUTOR_POLL_INTERVAL_SECONDS)
if not loop_future.done():
loop_future.cancel()
return result_future.result()
except Exception as exc:
logger.warning(
"Skill quality telemetry reporter failed for task %s: %s",
getattr(analysis, "task_id", ""),
exc,
)
return {
"status": "skipped",
"reason": "reporter_error",
"error": type(exc).__name__,
}
finally:
if loop_future is not None and not loop_future.done():
loop_future.cancel()
if executor is not None:
work_finished = result_future is not None and result_future.done()
executor.shutdown(wait=work_finished, cancel_futures=True)
def _maybe_report_analysis_sync(
self,
analysis: ExecutionAnalysis,
*,
session_id: str | None = None,
) -> dict[str, Any]:
cfg = load_cloud_config()
if not cfg.enabled or cfg.telemetry_mode != "outbox" or not cfg.api_key:
return {"status": "skipped", "reason": "cloud_telemetry_disabled"}
client = self._client or OpenSpaceClient(cfg, mapping_store=self._mapping_store)
mapping_store = self._mapping_store or client._local_mapping_store()
outbox = self._outbox or CloudTelemetryOutbox(mapping_store.db_path)
outcomes: list[dict[str, Any]] = []
for judgment in list(getattr(analysis, "skill_judgments", []) or []):
local_skill_id = str(getattr(judgment, "skill_id", "") or "").strip()
if not local_skill_id:
outcomes.append({"status": "skipped", "reason": "missing_local_skill_id"})
continue
try:
binding = mapping_store.get_binding_by_local(local_skill_id)
except Exception as exc:
outcomes.append(
{
"status": "failed",
"reason": "mapping_lookup_failed",
"local_skill_id": local_skill_id,
"error": type(exc).__name__,
}
)
continue
cloud_skill_id = str(getattr(binding, "cloud_skill_id", "") or "").strip()
if not cloud_skill_id:
outcomes.append(
{
"status": "skipped",
"reason": "local_only",
"local_skill_id": local_skill_id,
}
)
continue
payload = build_skill_quality_judgment_payload(
analysis,
judgment,
cloud_skill_id=cloud_skill_id,
session_id=session_id,
)
try:
ack = client.report_telemetry("skill-use-reported", payload)
outcomes.append(
{
"status": "reported",
"local_skill_id": local_skill_id,
"cloud_skill_id": cloud_skill_id,
"request_id": payload["request_id"],
"ack": ack,
}
)
except Exception as exc:
try:
row = outbox.enqueue(
endpoint="/api/v2/telemetry/skill-use-reported",
payload=payload,
workspace_root=self._workspace_root,
)
outbox.mark_failed(
row.request_id,
row.payload_hash,
error=str(exc),
)
outcomes.append(
{
"status": "queued",
"reason": "report_failed",
"local_skill_id": local_skill_id,
"cloud_skill_id": cloud_skill_id,
"request_id": row.request_id,
"payload_hash": row.payload_hash,
"error": type(exc).__name__,
}
)
except Exception as outbox_exc:
outcomes.append(
{
"status": "failed",
"reason": "outbox_enqueue_failed",
"local_skill_id": local_skill_id,
"cloud_skill_id": cloud_skill_id,
"request_id": payload["request_id"],
"error": type(outbox_exc).__name__,
}
)
return _summarize_outcomes(outcomes)
def build_skill_quality_judgment_payload(
analysis: ExecutionAnalysis,
judgment: SkillJudgment,
*,
cloud_skill_id: str,
session_id: str | None = None,
) -> dict[str, Any]:
local_skill_id = str(judgment.skill_id)
skill_phase_failed_ids = set(getattr(analysis, "skill_phase_failed_skill_ids", []) or [])
skill_applied = bool(judgment.skill_applied)
task_completed = bool(analysis.task_completed)
skill_phase_failed = local_skill_id in skill_phase_failed_ids
completed = skill_applied and task_completed and not skill_phase_failed
fallback = skill_phase_failed or not task_completed
status = "success" if completed and not fallback else "failed"
return build_skill_use_report_payload(
request_id=short_cloud_request_id(
"skill-quality-judgment",
analysis.task_id,
local_skill_id,
cloud_skill_id,
),
occurred_at=_analysis_timestamp_iso(analysis),
status=status,
task_id=analysis.task_id,
cloud_skill_id=cloud_skill_id,
session_id=session_id,
local_skill_id=local_skill_id,
duration_ms=None,
failure_reason=None if status == "success" else "unknown",
redaction_level="abstract_only",
redaction_performed_by="client",
redaction_policy_version=REDACTION_POLICY_VERSION,
quality_event_kind=QUALITY_EVENT_KIND,
quality_schema_version=QUALITY_SCHEMA_VERSION,
denominator=QUALITY_DENOMINATOR,
skill_applied=skill_applied,
task_completed=task_completed,
skill_phase_failed=skill_phase_failed,
completed=completed,
fallback=fallback,
)
def _analysis_timestamp_iso(analysis: ExecutionAnalysis) -> str:
timestamp = getattr(analysis, "timestamp", "")
if hasattr(timestamp, "isoformat"):
return timestamp.isoformat()
return str(timestamp)
def _skill_quality_reporting_disabled_result() -> dict[str, Any]:
return {"status": "skipped", "reason": "skill_quality_reporting_disabled"}
def _summarize_outcomes(outcomes: list[dict[str, Any]]) -> dict[str, Any]:
if any(item.get("status") == "reported" for item in outcomes):
status = "reported"
elif any(item.get("status") == "queued" for item in outcomes):
status = "queued"
elif any(item.get("status") == "failed" for item in outcomes):
status = "failed"
else:
status = "skipped"
reason = None if outcomes else "no_skill_judgments"
if status == "skipped" and outcomes:
reason = "no_cloud_bound_skill_judgments"
result: dict[str, Any] = {
"status": status,
"reported_count": sum(1 for item in outcomes if item.get("status") == "reported"),
"queued_count": sum(1 for item in outcomes if item.get("status") == "queued"),
"skipped_count": sum(1 for item in outcomes if item.get("status") == "skipped"),
"failed_count": sum(1 for item in outcomes if item.get("status") == "failed"),
"outcomes": outcomes,
}
if reason:
result["reason"] = reason
return result