ha-addons/connectd/matchd/lost.py
2025-12-15 11:06:57 -06:00

199 lines
6.7 KiB
Python

"""
matchd/lost.py - lost builder matching
lost builders don't get matched to each other (both need energy).
they get matched to ACTIVE builders who can inspire them.
the goal: show them someone like them who made it.
"""
import json
from .overlap import find_overlap, is_same_person
def find_inspiring_builder(lost_user, active_builders, db=None):
"""
find an active builder who could inspire a lost builder.
criteria:
- shared interests (they need to relate to this person)
- active builder has shipped real work (proof it's possible)
- similar background signals if possible
- NOT the same person across platforms
"""
if not active_builders:
return None, "no active builders available"
# parse lost user data
lost_signals = lost_user.get('signals', [])
if isinstance(lost_signals, str):
lost_signals = json.loads(lost_signals) if lost_signals else []
lost_extra = lost_user.get('extra', {})
if isinstance(lost_extra, str):
lost_extra = json.loads(lost_extra) if lost_extra else {}
# lost user interests
lost_interests = set()
lost_interests.update(lost_signals)
lost_interests.update(lost_extra.get('topics', []))
lost_interests.update(lost_extra.get('aligned_topics', []))
# also include subreddits if from reddit (shows interests)
subreddits = lost_user.get('subreddits', [])
if isinstance(subreddits, str):
subreddits = json.loads(subreddits) if subreddits else []
lost_interests.update(subreddits)
# score each active builder
candidates = []
for builder in active_builders:
# skip if same person (cross-platform)
if is_same_person(lost_user, builder):
continue
# get builder signals
builder_signals = builder.get('signals', [])
if isinstance(builder_signals, str):
builder_signals = json.loads(builder_signals) if builder_signals else []
builder_extra = builder.get('extra', {})
if isinstance(builder_extra, str):
builder_extra = json.loads(builder_extra) if builder_extra else {}
# builder interests
builder_interests = set()
builder_interests.update(builder_signals)
builder_interests.update(builder_extra.get('topics', []))
builder_interests.update(builder_extra.get('aligned_topics', []))
# calculate match score
shared_interests = lost_interests & builder_interests
match_score = len(shared_interests) * 10
# bonus for high-value shared signals
high_value_signals = ['privacy', 'selfhosted', 'home_automation', 'foss',
'solarpunk', 'cooperative', 'decentralized', 'queer']
for signal in shared_interests:
if signal in high_value_signals:
match_score += 15
# bonus if builder has shipped real work (proof it's possible)
repos = builder_extra.get('top_repos', [])
if len(repos) >= 5:
match_score += 20 # they've built things
elif len(repos) >= 2:
match_score += 10
# bonus for high stars (visible success)
total_stars = sum(r.get('stars', 0) for r in repos) if repos else 0
if total_stars >= 100:
match_score += 15
elif total_stars >= 20:
match_score += 5
# bonus for similar location (relatable)
lost_loc = (lost_user.get('location') or '').lower()
builder_loc = (builder.get('location') or '').lower()
if lost_loc and builder_loc:
pnw_keywords = ['seattle', 'portland', 'washington', 'oregon', 'pnw']
if any(k in lost_loc for k in pnw_keywords) and any(k in builder_loc for k in pnw_keywords):
match_score += 10
# minimum threshold - need SOMETHING in common
if match_score < 10:
continue
candidates.append({
'builder': builder,
'match_score': match_score,
'shared_interests': list(shared_interests)[:5],
'repos_count': len(repos),
'total_stars': total_stars,
})
if not candidates:
return None, "no matching active builders found"
# sort by match score, return best
candidates.sort(key=lambda x: x['match_score'], reverse=True)
best = candidates[0]
return best, None
def find_matches_for_lost_builders(db, min_lost_score=40, min_values_score=20, limit=10):
"""
find inspiring builder matches for all lost builders ready for outreach.
returns list of (lost_user, inspiring_builder, match_data)
"""
# get lost builders ready for outreach
lost_builders = db.get_lost_builders_for_outreach(
min_lost_score=min_lost_score,
min_values_score=min_values_score,
limit=limit
)
if not lost_builders:
return [], "no lost builders ready for outreach"
# get active builders who can inspire
active_builders = db.get_active_builders(min_score=50, limit=200)
if not active_builders:
return [], "no active builders available"
matches = []
for lost_user in lost_builders:
best_match, error = find_inspiring_builder(lost_user, active_builders, db)
if best_match:
matches.append({
'lost_user': lost_user,
'inspiring_builder': best_match['builder'],
'match_score': best_match['match_score'],
'shared_interests': best_match['shared_interests'],
'builder_repos': best_match['repos_count'],
'builder_stars': best_match['total_stars'],
})
return matches, None
def get_lost_match_summary(match_data):
"""
get a human-readable summary of a lost builder match.
"""
lost = match_data['lost_user']
builder = match_data['inspiring_builder']
lost_name = lost.get('name') or lost.get('username', 'someone')
builder_name = builder.get('name') or builder.get('username', 'a builder')
lost_signals = match_data.get('lost_signals', [])
if isinstance(lost_signals, str):
lost_signals = json.loads(lost_signals) if lost_signals else []
shared = match_data.get('shared_interests', [])
summary = f"""
lost builder: {lost_name} ({lost.get('platform')})
lost score: {lost.get('lost_potential_score', 0)}
values score: {lost.get('score', 0)}
url: {lost.get('url')}
inspiring builder: {builder_name} ({builder.get('platform')})
score: {builder.get('score', 0)}
repos: {match_data.get('builder_repos', 0)}
stars: {match_data.get('builder_stars', 0)}
url: {builder.get('url')}
match score: {match_data.get('match_score', 0)}
shared interests: {', '.join(shared) if shared else 'values alignment'}
this lost builder needs to see that someone like them made it.
"""
return summary.strip()