mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M3 implementada e backlog atualizado
This commit is contained in:
0
RI/src/emcp_bus/profile_intelligence/__init__.py
Normal file
0
RI/src/emcp_bus/profile_intelligence/__init__.py
Normal file
135
RI/src/emcp_bus/profile_intelligence/governance.py
Normal file
135
RI/src/emcp_bus/profile_intelligence/governance.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Data-governance guardrails for Profile Intelligence (EP-09-T03, README.md
|
||||
§6.10, Threat: Sensitive attribute leakage §28).
|
||||
|
||||
Three requirements from README §6.10's governance list, made concrete and
|
||||
testable: pseudonymized subject identifiers, a lineage record per resolution
|
||||
(features/version/window), and a drift-monitor *stub* -- explicitly not real
|
||||
statistical drift detection (P7: no real dataset to detect drift against in
|
||||
this RI), just the extension point a production fork would wire a real one
|
||||
into.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from emcp_bus.profile_intelligence.models import ProfileView
|
||||
|
||||
_SENSITIVE_ATTRIBUTE_KEY_MARKERS = (
|
||||
"email",
|
||||
"cpf",
|
||||
"ssn",
|
||||
"phone",
|
||||
"name",
|
||||
"address",
|
||||
"birthdate",
|
||||
"document",
|
||||
)
|
||||
|
||||
|
||||
def pseudonymize_subject(raw_identifier: str) -> str:
|
||||
"""A raw identifier (customer id, email, msisdn, ...) is never carried
|
||||
into a `ProfileView` -- only this deterministic pseudonym is."""
|
||||
digest = hashlib.sha256(raw_identifier.encode("utf-8")).hexdigest()
|
||||
return f"subject:{digest[:6]}"
|
||||
|
||||
|
||||
class SensitiveAttributeLeakageError(Exception):
|
||||
"""A `ProfileView` carries a raw (non-pseudonymized) subject reference,
|
||||
or an `attributes` key that looks like direct PII -- README.md §28's
|
||||
"Sensitive attribute leakage" threat, made a hard failure rather than a
|
||||
lint warning."""
|
||||
|
||||
|
||||
def assert_no_raw_pii(profile_view: ProfileView) -> None:
|
||||
if not profile_view.subject_ref.startswith("subject:"):
|
||||
raise SensitiveAttributeLeakageError(
|
||||
f"ProfileView.subject_ref {profile_view.subject_ref!r} is not pseudonymized "
|
||||
"(expected 'subject:<hash>', see pseudonymize_subject)"
|
||||
)
|
||||
for key in profile_view.attributes:
|
||||
lowered = key.lower()
|
||||
if any(marker in lowered for marker in _SENSITIVE_ATTRIBUTE_KEY_MARKERS):
|
||||
raise SensitiveAttributeLeakageError(
|
||||
f"ProfileView.attributes key {key!r} looks like direct PII, not a "
|
||||
"derived/synthetic attribute (README.md §28)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureLineageRecord:
|
||||
"""Documents *why* a ProfileView looks the way it does -- README.md
|
||||
§6.10: "documentar features, finalidade, janela temporal e qualidade"."""
|
||||
|
||||
subject_ref: str
|
||||
profile_version: str
|
||||
features: tuple[str, ...]
|
||||
purpose: str
|
||||
time_window: str
|
||||
resolved_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class FeatureLineageLog:
|
||||
"""In-memory append-only lineage log -- a JSONL/DB-backed sink is a
|
||||
natural production extension (same shape as `emcp_bus.audit.sink`), not
|
||||
needed for this RI's demonstration scope."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: list[FeatureLineageRecord] = []
|
||||
|
||||
def record(
|
||||
self,
|
||||
profile_view: ProfileView,
|
||||
*,
|
||||
features: tuple[str, ...],
|
||||
purpose: str,
|
||||
time_window: str,
|
||||
) -> FeatureLineageRecord:
|
||||
entry = FeatureLineageRecord(
|
||||
subject_ref=profile_view.subject_ref,
|
||||
profile_version=profile_view.profile_version,
|
||||
features=features,
|
||||
purpose=purpose,
|
||||
time_window=time_window,
|
||||
)
|
||||
self.records.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DriftReport:
|
||||
segment_id: str
|
||||
baseline_score: float
|
||||
observed_score: float
|
||||
drifted: bool
|
||||
|
||||
|
||||
class DriftMonitor:
|
||||
"""Stub (README.md §6.10: "monitorar drift, estabilidade e impacto
|
||||
desigual") -- flags a segment whose score moved further than
|
||||
`threshold` from a caller-supplied baseline. Not real statistical drift
|
||||
detection (no population-level distribution tracking); a production
|
||||
fork with real telemetry would replace this, not extend it.
|
||||
"""
|
||||
|
||||
def __init__(self, *, threshold: float = 0.3) -> None:
|
||||
self._threshold = threshold
|
||||
|
||||
def check(self, profile_view: ProfileView, baseline: dict[str, float]) -> list[DriftReport]:
|
||||
reports = []
|
||||
for segment in profile_view.segments:
|
||||
baseline_score = baseline.get(segment.id)
|
||||
if baseline_score is None:
|
||||
continue
|
||||
delta = abs(segment.score - baseline_score)
|
||||
reports.append(
|
||||
DriftReport(
|
||||
segment_id=segment.id,
|
||||
baseline_score=baseline_score,
|
||||
observed_score=segment.score,
|
||||
drifted=delta > self._threshold,
|
||||
)
|
||||
)
|
||||
return reports
|
||||
38
RI/src/emcp_bus/profile_intelligence/models.py
Normal file
38
RI/src/emcp_bus/profile_intelligence/models.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""ProfileView contract (EP-09-T01, README.md §6.10, Axiom 11).
|
||||
|
||||
Field names and shape follow the README §6.10 example exactly (`subjectRef`,
|
||||
`profileVersion`, `segments`, `attributes`, `reasonCodes`, `expiresAt`) --
|
||||
"Os nomes acima são ilustrativos. A arquitetura não prescreve taxonomia,
|
||||
algoritmo, número de clusters ou atributos específicos", so this model is
|
||||
deliberately generic: any `ProfileIntelligenceProvider` implementation
|
||||
(rule-based here, EP-09-T02; a real clustering/ML model in a production
|
||||
fork) produces the same contract. A `ProfileView` only ever *orders or
|
||||
reduces* candidates for the Offering Filter (EP-10) -- it is never consulted
|
||||
by the PDP (EP-03) and can never grant a capability outside
|
||||
MaximumEntitlement (Axiom 11): nothing in this module, or anything that
|
||||
consumes it, is wired into the authorization path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Segment(BaseModel):
|
||||
id: str
|
||||
score: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class ProfileView(BaseModel):
|
||||
subject_ref: str = Field(
|
||||
description="Pseudonymized subject identifier, e.g. 'subject:7f31c2' -- "
|
||||
"never a raw PII identifier (README.md §6.10 governance requirements, "
|
||||
"EP-09-T03's guardrail)."
|
||||
)
|
||||
profile_version: str
|
||||
segments: list[Segment] = Field(default_factory=list)
|
||||
attributes: dict[str, str] = Field(default_factory=dict)
|
||||
reason_codes: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime
|
||||
77
RI/src/emcp_bus/profile_intelligence/rule_based_provider.py
Normal file
77
RI/src/emcp_bus/profile_intelligence/rule_based_provider.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Reference `ProfileIntelligenceProvider` (EP-09-T02, README.md §6.10, P7).
|
||||
|
||||
A deterministic, rules-over-synthetic-attributes stub -- not clustering, not
|
||||
a trained model, not a proprietary taxonomy (P7: this RI does not attempt
|
||||
"real" clustering without a real dataset, see docs/RI-PLANNING.md's gap
|
||||
resolution). `ProfileIntelligenceProvider` is a `Protocol` so a production
|
||||
fork can swap in a real ML-backed implementation without touching any
|
||||
consumer (the Offering Filter, EP-10) -- the contract is `ProfileView` in,
|
||||
`ProfileView` out.
|
||||
|
||||
Determinism (EP-09-T02 acceptance criterion: same subject_ref + same time
|
||||
window -> same profileVersion) comes from hashing `(subject_ref, window)`
|
||||
rather than anything time-of-call-dependent -- two resolutions in the same
|
||||
ISO week for the same subject always agree, which is what a demo needs to
|
||||
look "sensible" across repeated calls without a real model or a database of
|
||||
prior scores.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Protocol
|
||||
|
||||
from emcp_bus.profile_intelligence.models import ProfileView, Segment
|
||||
|
||||
_ENGAGEMENT_BANDS = ("low", "medium", "high")
|
||||
_CHANNEL_POOLS = (["app"], ["app", "email"], ["email", "sms"], ["app", "sms"])
|
||||
|
||||
|
||||
class ProfileIntelligenceProvider(Protocol):
|
||||
def resolve(self, subject_ref: str, *, as_of: datetime | None = None) -> ProfileView: ...
|
||||
|
||||
|
||||
class RuleBasedProfileProvider:
|
||||
"""Reference implementation: `Segment` scores and `attributes` are
|
||||
derived purely from a hash of `(subject_ref, iso_week)` -- no external
|
||||
data source, no learned parameters. Good enough to demonstrate the
|
||||
contract (EP-10's Offering Filter, EP-11's DecisionContext) without
|
||||
claiming any real personalization intelligence.
|
||||
"""
|
||||
|
||||
def __init__(self, *, ttl: timedelta = timedelta(hours=24)) -> None:
|
||||
self._ttl = ttl
|
||||
|
||||
def resolve(self, subject_ref: str, *, as_of: datetime | None = None) -> ProfileView:
|
||||
as_of = as_of or datetime.now(UTC)
|
||||
iso_year, iso_week, _ = as_of.isocalendar()
|
||||
window = f"{iso_year}-W{iso_week:02d}"
|
||||
digest = hashlib.sha256(f"{subject_ref}:{window}".encode()).hexdigest()
|
||||
|
||||
engagement_band = _ENGAGEMENT_BANDS[int(digest[0], 16) % len(_ENGAGEMENT_BANDS)]
|
||||
preferred_channels = _CHANNEL_POOLS[int(digest[1], 16) % len(_CHANNEL_POOLS)]
|
||||
low_engagement_score = int(digest[2:4], 16) / 255
|
||||
digital_preference_score = int(digest[4:6], 16) / 255
|
||||
|
||||
reason_codes = []
|
||||
if low_engagement_score > 0.5:
|
||||
reason_codes.append("LOW_RECENCY")
|
||||
if "app" in preferred_channels:
|
||||
reason_codes.append("APP_AFFINITY")
|
||||
|
||||
return ProfileView(
|
||||
subject_ref=subject_ref,
|
||||
profile_version=f"rule-based-{window}-{digest[:8]}",
|
||||
segments=[
|
||||
Segment(id="low-engagement", score=round(low_engagement_score, 2)),
|
||||
Segment(id="digital-preference", score=round(digital_preference_score, 2)),
|
||||
],
|
||||
attributes={
|
||||
"relationshipStage": "activation",
|
||||
"preferredChannels": ",".join(preferred_channels),
|
||||
"engagementBand": engagement_band,
|
||||
},
|
||||
reason_codes=reason_codes,
|
||||
expires_at=as_of + self._ttl,
|
||||
)
|
||||
Reference in New Issue
Block a user