"""People and organization facts using existing HRIS identity semantics.""" from __future__ import annotations from collections.abc import Callable, Sequence from datetime import UTC, datetime, timedelta from decimal import Decimal from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.identity.person_models import IdentityOrgMembership, IdentityPerson from app.intelligence.privacy import EvidenceObject from app.intelligence.snapshot_service import ( MetricObservation, ProviderResult, ProviderUnavailable, SnapshotSubject, ) from app.scheduled_jobs.models import ScheduledJob _EPOCH = datetime(1970, 1, 1, tzinfo=UTC) _EVIDENCE_LIMIT = 50 def _utc(value: datetime | None) -> datetime: if value is None: return _EPOCH return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) def _pct(covered: int, total: int) -> Decimal: return (Decimal(covered) * 100 / Decimal(total)).quantize(Decimal("0.01")) class PeopleOrgFactProvider: dimension = "people_org" def __init__(self, *, now: Callable[[], datetime] | None = None) -> None: self._now = now or (lambda: datetime.now(UTC)) @staticmethod def _validate_subject(subject: SnapshotSubject) -> None: if subject.subject_type == "platform" and subject.subject_id == "platform": return if subject.subject_type == "domain" and subject.subject_id == "people_org": return raise ValueError("People facts only support platform or people_org domain snapshots") async def collect(self, db: AsyncSession, subject: SnapshotSubject) -> ProviderResult: self._validate_subject(subject) try: return await self._collect(db) except SQLAlchemyError as exc: raise ProviderUnavailable("people_org datastore unavailable") from exc async def _collect(self, db: AsyncSession) -> ProviderResult: active_rows = list( ( await db.execute( select( IdentityPerson.id, IdentityPerson.job_title, IdentityPerson.work_location_code, IdentityPerson.work_location_name, IdentityPerson.manager_person_id, IdentityPerson.updated_at, ).where( IdentityPerson.deleted_at.is_(None), IdentityPerson.employment_status == "active", ) ) ).all() ) active_ids = {row.id for row in active_rows} report_manager_ids = set( ( await db.execute( select(IdentityPerson.manager_person_id) .where( IdentityPerson.deleted_at.is_(None), IdentityPerson.employment_status == "active", IdentityPerson.manager_person_id.is_not(None), ) .distinct() ) ).scalars() ) membership_rows = list( ( await db.execute( select( IdentityOrgMembership.person_id, IdentityOrgMembership.last_synced_at, ).where( IdentityOrgMembership.person_id.in_(active_ids), IdentityOrgMembership.is_primary.is_(True), IdentityOrgMembership.membership_status == "active", IdentityOrgMembership.deleted_at.is_(None), ) ) ).all() ) if active_ids else [] org_ids = {row.person_id for row in membership_rows} total = len(active_rows) org_gap = sorted(active_ids - org_ids, key=str) manager_eligible = [ row for row in active_rows if row.job_title is not None and row.id not in report_manager_ids ] manager_gap = sorted( (row.id for row in manager_eligible if row.manager_person_id is None), key=str, ) prediction_gap = sorted( ( row.id for row in active_rows if not row.job_title or not (row.work_location_code or row.work_location_name) or row.id not in org_ids ), key=str, ) identity_as_of = max((_utc(row.updated_at) for row in active_rows), default=_EPOCH) org_as_of = max((_utc(row.last_synced_at) for row in membership_rows), default=identity_as_of) job = ( await db.execute( select(ScheduledJob).where(ScheduledJob.name == "hris.incremental_sync").limit(1) ) ).scalar_one_or_none() missed_cycles, freshness_confidence, freshness_state, freshness_as_of = self._freshness(job) participating_times = [] if total: participating_times.append(identity_as_of) if membership_rows: participating_times.append(org_as_of) if freshness_confidence: participating_times.append(freshness_as_of) common_as_of = min(participating_times) if participating_times else None def evidence(ids: Sequence[object]) -> tuple[EvidenceObject, ...]: return tuple(EvidenceObject("person", str(value)) for value in ids[:_EVIDENCE_LIMIT]) org_covered = total - len(org_gap) manager_total = len(manager_eligible) manager_covered = manager_total - len(manager_gap) prediction_covered = total - len(prediction_gap) return ProviderResult( dimension=self.dimension, metrics=( MetricObservation( "people_org.hris_freshness", missed_cycles, confidence=freshness_confidence, evidence=(EvidenceObject("job", str(job.id)),) if job else (), data_as_of=freshness_as_of, ), MetricObservation( "people_org.org_coverage", _pct(org_covered, total) if total else 0, confidence=100 if total else 0, evidence=evidence(org_gap), data_as_of=org_as_of, ), MetricObservation( "people_org.manager_coverage", _pct(manager_covered, manager_total) if manager_total else 0, confidence=100 if manager_total else 0, evidence=evidence(manager_gap), data_as_of=identity_as_of, ), MetricObservation( "people_org.prediction_data_coverage", _pct(prediction_covered, total) if total else 0, confidence=100 if total else 0, evidence=evidence(prediction_gap), data_as_of=min(identity_as_of, org_as_of), ), ), aggregate_facts={ "active_people_count": total, "org_covered_count": org_covered, "org_gap_count": len(org_gap), "manager_eligible_count": manager_total, "manager_covered_count": manager_covered, "manager_gap_count": len(manager_gap), "prediction_ready_count": prediction_covered, "prediction_gap_count": len(prediction_gap), "hris_freshness_state": freshness_state, "hris_missed_cycles": missed_cycles, }, data_as_of=common_as_of, ) def _freshness( self, job: ScheduledJob | None, ) -> tuple[int, int, str, datetime]: if job is None: return 0, 0, "job_missing", _EPOCH as_of = _utc(job.last_run_at or job.updated_at) if not job.enabled: return 3, 100, "disabled", as_of if job.last_run_at is None: return 0, 0, "never_run", as_of interval = max(1, job.interval_seconds or 14_400) # ``scheduled_jobs`` stores no next-run time (migration 620 — the # column existed but was never written), so the due instant is # derived: one interval after the last run. due = _utc(job.last_run_at) + timedelta(seconds=interval) now = _utc(self._now()) missed = 0 if now <= due else int((now - due).total_seconds() // interval) + 1 if job.last_status == "failed": missed = max(1, missed) return missed, 100, "fresh" if missed <= 1 else "stale", as_of