metaspn-entities 0.1.5__py3-none-any.whl → 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metaspn_entities/__init__.py +3 -1
- metaspn_entities/context.py +123 -0
- metaspn_entities/resolver.py +7 -1
- {metaspn_entities-0.1.5.dist-info → metaspn_entities-0.1.6.dist-info}/METADATA +15 -1
- metaspn_entities-0.1.6.dist-info/RECORD +13 -0
- metaspn_entities-0.1.5.dist-info/RECORD +0 -13
- {metaspn_entities-0.1.5.dist-info → metaspn_entities-0.1.6.dist-info}/WHEEL +0 -0
- {metaspn_entities-0.1.5.dist-info → metaspn_entities-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {metaspn_entities-0.1.5.dist-info → metaspn_entities-0.1.6.dist-info}/top_level.txt +0 -0
metaspn_entities/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from .adapter import SignalResolutionResult, resolve_normalized_social_signal
|
|
2
|
-
from .context import EntityContext, build_confidence_summary
|
|
2
|
+
from .context import RecommendationContext, EntityContext, build_confidence_summary, build_recommendation_context
|
|
3
3
|
from .events import EmittedEvent
|
|
4
4
|
from .models import EntityResolution
|
|
5
5
|
from .resolver import EntityResolver
|
|
@@ -9,7 +9,9 @@ __all__ = [
|
|
|
9
9
|
"resolve_normalized_social_signal",
|
|
10
10
|
"SignalResolutionResult",
|
|
11
11
|
"EntityContext",
|
|
12
|
+
"RecommendationContext",
|
|
12
13
|
"build_confidence_summary",
|
|
14
|
+
"build_recommendation_context",
|
|
13
15
|
"EntityResolver",
|
|
14
16
|
"EntityResolution",
|
|
15
17
|
"EmittedEvent",
|
metaspn_entities/context.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
4
5
|
from typing import Any, Dict, List
|
|
5
6
|
|
|
6
7
|
|
|
@@ -13,6 +14,17 @@ class EntityContext:
|
|
|
13
14
|
confidence_summary: Dict[str, Any]
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RecommendationContext:
|
|
19
|
+
entity_id: str
|
|
20
|
+
identity_confidence: float
|
|
21
|
+
activity_recency_days: float
|
|
22
|
+
interaction_history_summary: Dict[str, Any]
|
|
23
|
+
preferred_channel_hint: str
|
|
24
|
+
relationship_stage_hint: str
|
|
25
|
+
continuity: Dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
16
28
|
def build_confidence_summary(
|
|
17
29
|
aliases: List[Dict[str, Any]],
|
|
18
30
|
identifiers: List[Dict[str, Any]],
|
|
@@ -66,3 +78,114 @@ def _rollup_by_identifier_type(identifiers: List[Dict[str, Any]]) -> Dict[str, D
|
|
|
66
78
|
"max_confidence": round(max(values), 6),
|
|
67
79
|
}
|
|
68
80
|
return rollup
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_recommendation_context(
|
|
84
|
+
entity_id: str,
|
|
85
|
+
aliases: List[Dict[str, Any]],
|
|
86
|
+
identifiers: List[Dict[str, Any]],
|
|
87
|
+
*,
|
|
88
|
+
now: datetime | None = None,
|
|
89
|
+
) -> RecommendationContext:
|
|
90
|
+
current_now = now or datetime.now(timezone.utc)
|
|
91
|
+
evidence_count = len(identifiers)
|
|
92
|
+
recent_seen = _latest_seen(identifiers)
|
|
93
|
+
activity_recency_days = _recency_days(recent_seen, current_now)
|
|
94
|
+
|
|
95
|
+
summary = build_confidence_summary(aliases, identifiers, identifiers)
|
|
96
|
+
preferred_channel = _preferred_channel_hint(identifiers)
|
|
97
|
+
relationship_stage = _relationship_stage_hint(
|
|
98
|
+
evidence_count=evidence_count,
|
|
99
|
+
recency_days=activity_recency_days,
|
|
100
|
+
confidence=summary["overall_confidence"],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
provenance_counts: Dict[str, int] = {}
|
|
104
|
+
for item in identifiers:
|
|
105
|
+
provenance = str(item.get("provenance") or "unknown")
|
|
106
|
+
provenance_counts[provenance] = provenance_counts.get(provenance, 0) + 1
|
|
107
|
+
|
|
108
|
+
interaction_history_summary = {
|
|
109
|
+
"evidence_count": evidence_count,
|
|
110
|
+
"distinct_sources": len(provenance_counts),
|
|
111
|
+
"sources": {k: provenance_counts[k] for k in sorted(provenance_counts)},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
continuity = {
|
|
115
|
+
"canonical_entity_id": entity_id,
|
|
116
|
+
"alias_count": len(aliases),
|
|
117
|
+
"identifier_count": len(identifiers),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return RecommendationContext(
|
|
121
|
+
entity_id=entity_id,
|
|
122
|
+
identity_confidence=float(summary["overall_confidence"]),
|
|
123
|
+
activity_recency_days=activity_recency_days,
|
|
124
|
+
interaction_history_summary=interaction_history_summary,
|
|
125
|
+
preferred_channel_hint=preferred_channel,
|
|
126
|
+
relationship_stage_hint=relationship_stage,
|
|
127
|
+
continuity=continuity,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _latest_seen(identifiers: List[Dict[str, Any]]) -> datetime | None:
|
|
132
|
+
timestamps = [
|
|
133
|
+
_parse_iso(str(item.get("last_seen_at")))
|
|
134
|
+
for item in identifiers
|
|
135
|
+
if item.get("last_seen_at")
|
|
136
|
+
]
|
|
137
|
+
clean = [ts for ts in timestamps if ts is not None]
|
|
138
|
+
if not clean:
|
|
139
|
+
return None
|
|
140
|
+
return max(clean)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_iso(raw: str) -> datetime | None:
|
|
144
|
+
text = raw.strip()
|
|
145
|
+
if not text:
|
|
146
|
+
return None
|
|
147
|
+
if text.endswith("Z"):
|
|
148
|
+
text = text[:-1] + "+00:00"
|
|
149
|
+
try:
|
|
150
|
+
dt = datetime.fromisoformat(text)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
153
|
+
if dt.tzinfo is None:
|
|
154
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
155
|
+
return dt.astimezone(timezone.utc)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _recency_days(last_seen: datetime | None, now: datetime) -> float:
|
|
159
|
+
if last_seen is None:
|
|
160
|
+
return float("inf")
|
|
161
|
+
delta = now - last_seen
|
|
162
|
+
seconds = max(0.0, delta.total_seconds())
|
|
163
|
+
return round(seconds / 86400.0, 6)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _preferred_channel_hint(identifiers: List[Dict[str, Any]]) -> str:
|
|
167
|
+
weights = {
|
|
168
|
+
"email": 5,
|
|
169
|
+
"linkedin_handle": 4,
|
|
170
|
+
"twitter_handle": 3,
|
|
171
|
+
"github_handle": 3,
|
|
172
|
+
"canonical_url": 2,
|
|
173
|
+
"domain": 1,
|
|
174
|
+
"name": 0,
|
|
175
|
+
}
|
|
176
|
+
scores: Dict[str, int] = {}
|
|
177
|
+
for item in identifiers:
|
|
178
|
+
id_type = str(item["identifier_type"])
|
|
179
|
+
score = weights.get(id_type, 1)
|
|
180
|
+
scores[id_type] = scores.get(id_type, 0) + score
|
|
181
|
+
if not scores:
|
|
182
|
+
return "unknown"
|
|
183
|
+
return sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _relationship_stage_hint(*, evidence_count: int, recency_days: float, confidence: float) -> str:
|
|
187
|
+
if evidence_count >= 6 and recency_days <= 30 and confidence >= 0.8:
|
|
188
|
+
return "engaged"
|
|
189
|
+
if evidence_count >= 3 and recency_days <= 90 and confidence >= 0.65:
|
|
190
|
+
return "warm"
|
|
191
|
+
return "cold"
|
metaspn_entities/resolver.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any, Dict, List, Optional
|
|
4
4
|
|
|
5
|
-
from .context import EntityContext, build_confidence_summary
|
|
5
|
+
from .context import RecommendationContext, EntityContext, build_confidence_summary, build_recommendation_context
|
|
6
6
|
from .events import EmittedEvent, EventFactory
|
|
7
7
|
from .models import (
|
|
8
8
|
DEFAULT_MATCH_CONFIDENCE,
|
|
@@ -160,6 +160,12 @@ class EntityResolver:
|
|
|
160
160
|
confidence_summary=summary,
|
|
161
161
|
)
|
|
162
162
|
|
|
163
|
+
def recommendation_context(self, entity_id: str) -> RecommendationContext:
|
|
164
|
+
canonical_id = self.store.canonical_entity_id(entity_id)
|
|
165
|
+
aliases = self.store.list_aliases_for_entity(canonical_id)
|
|
166
|
+
identifiers = self.store.list_identifier_records_for_entity(canonical_id)
|
|
167
|
+
return build_recommendation_context(canonical_id, aliases, identifiers)
|
|
168
|
+
|
|
163
169
|
def export_snapshot(self, output_path: str) -> None:
|
|
164
170
|
self.store.export_snapshot(output_path)
|
|
165
171
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metaspn-entities
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Canonical entity resolution, aliasing, and merges for MetaSPN systems
|
|
5
5
|
Author: MetaSPN Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -108,3 +108,17 @@ Profiler/router workers can read consolidated context using:
|
|
|
108
108
|
- `resolver.confidence_summary(entity_id)`
|
|
109
109
|
|
|
110
110
|
Both APIs resolve canonical redirects first, so merged IDs return coherent context.
|
|
111
|
+
|
|
112
|
+
## M2 Recommendation Context API
|
|
113
|
+
|
|
114
|
+
Recommendation and drafter workers can consume:
|
|
115
|
+
|
|
116
|
+
- `resolver.recommendation_context(entity_id)`
|
|
117
|
+
|
|
118
|
+
The recommendation context includes:
|
|
119
|
+
- identity confidence
|
|
120
|
+
- activity recency (days)
|
|
121
|
+
- interaction history summary (evidence count + source distribution)
|
|
122
|
+
- preferred channel hint
|
|
123
|
+
- relationship stage hint (`cold` / `warm` / `engaged`)
|
|
124
|
+
- merge-safe continuity fields keyed to canonical entity IDs
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
metaspn_entities/__init__.py,sha256=aija_xk40mk4JreYPiKKIpKwt6eQdUQRlrriwNkgZB0,639
|
|
2
|
+
metaspn_entities/adapter.py,sha256=eNB5kr1tinav85WPA4YCldRDJBgb6uYe3ZWCRVjdOms,4654
|
|
3
|
+
metaspn_entities/context.py,sha256=sUpW50Z99R0iQ5ryVR9uk7WqUaiKCxMyTQlvuV3DvVk,6054
|
|
4
|
+
metaspn_entities/events.py,sha256=Hkc3gy5_vRTSR0MKUvF24dTqNqOkG423_PTUe7csUfw,2066
|
|
5
|
+
metaspn_entities/models.py,sha256=b2EFsc1EIT9Ao_bKA2I52-5W_0fTwhsyO6VFRG8gZg8,1377
|
|
6
|
+
metaspn_entities/normalize.py,sha256=nPAHRfipgS6zHy2x70ZFd5HB1W4FKmeTF8Kd4TYz5tI,1125
|
|
7
|
+
metaspn_entities/resolver.py,sha256=lmjQj5W2ny1_FnzNB-_cywReL_5ScOOHohV_TcY4usM,8345
|
|
8
|
+
metaspn_entities/sqlite_backend.py,sha256=Ed0CGAfDGlzKuD-v3xkxumGf8My4WByMQRtrO9JHK84,11790
|
|
9
|
+
metaspn_entities-0.1.6.dist-info/licenses/LICENSE,sha256=tvVpto97dUnh1-KVYPs1rCr5dzyX8jUyNmT7F7ZPVAM,1077
|
|
10
|
+
metaspn_entities-0.1.6.dist-info/METADATA,sha256=Lp2AXFXLhnY46SlExYN4pnqora7_DH9wcwF5IAMuOD0,4130
|
|
11
|
+
metaspn_entities-0.1.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
+
metaspn_entities-0.1.6.dist-info/top_level.txt,sha256=YP2V8Z1Statrs3YAI-tGvyC73vLjPHr9Vkal4yqXkhs,17
|
|
13
|
+
metaspn_entities-0.1.6.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
metaspn_entities/__init__.py,sha256=g8Ud46-ETag_QgCbl7ePE1ymg-9bKAD1tE8GQ79wffM,521
|
|
2
|
-
metaspn_entities/adapter.py,sha256=eNB5kr1tinav85WPA4YCldRDJBgb6uYe3ZWCRVjdOms,4654
|
|
3
|
-
metaspn_entities/context.py,sha256=xRmHHLa6SEyLfXGhdEn0I5DnNz8vr5XyZC8TuqVNT7U,2181
|
|
4
|
-
metaspn_entities/events.py,sha256=Hkc3gy5_vRTSR0MKUvF24dTqNqOkG423_PTUe7csUfw,2066
|
|
5
|
-
metaspn_entities/models.py,sha256=b2EFsc1EIT9Ao_bKA2I52-5W_0fTwhsyO6VFRG8gZg8,1377
|
|
6
|
-
metaspn_entities/normalize.py,sha256=nPAHRfipgS6zHy2x70ZFd5HB1W4FKmeTF8Kd4TYz5tI,1125
|
|
7
|
-
metaspn_entities/resolver.py,sha256=ya0boPOoxKO-fz9r3HOs_i0WRa8bC46k2si33QmkAmI,7918
|
|
8
|
-
metaspn_entities/sqlite_backend.py,sha256=Ed0CGAfDGlzKuD-v3xkxumGf8My4WByMQRtrO9JHK84,11790
|
|
9
|
-
metaspn_entities-0.1.5.dist-info/licenses/LICENSE,sha256=tvVpto97dUnh1-KVYPs1rCr5dzyX8jUyNmT7F7ZPVAM,1077
|
|
10
|
-
metaspn_entities-0.1.5.dist-info/METADATA,sha256=123fAcWNdLfi1898A51EQ8iuuCePd-kGpz91A0jha8c,3702
|
|
11
|
-
metaspn_entities-0.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
-
metaspn_entities-0.1.5.dist-info/top_level.txt,sha256=YP2V8Z1Statrs3YAI-tGvyC73vLjPHr9Vkal4yqXkhs,17
|
|
13
|
-
metaspn_entities-0.1.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|