Coverage for integrations / social / portrait_service.py: 98.3%
58 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - Portrait auto-arrange service (closes #400).
4Picks an ordered subset of a user's local gallery for surfaces that
5need a small portrait set (BLE encounter post-match profile + map
6overlay, social-media share batches, etc.).
8Design contract (project_encounter_icebreaker.md §10):
10 * INPUT: a list of local gallery references (anything path-shaped
11 that supports `Path(p).stat().st_mtime` and `.name`). No upload,
12 no remote.
13 * OUTPUT: an ordered subset, length ≤ max_picks.
14 * Scoring: pluggable `scorer` callable — when None, falls back to
15 a deterministic naive-recency heuristic. The design doc points
16 at a future CLIP-aesthetic plug-in; that plugs in here as the
17 `scorer` argument without touching callers.
18 * Diversity: avoid consecutive items whose filename ROOT matches
19 (e.g., "selfie_001.jpg, selfie_002.jpg" → only the first ranks
20 in adjacent positions). This is a weak proxy for the design
21 doc's "don't pick 5 selfies in a row" rule that the CLIP plugin
22 will refine.
23 * face_visible_consent: when False, drop entries whose filename
24 contains face-only hints ("selfie", "face", "portrait"). Weak
25 but honors the consent signal even on the no-CLIP path. When
26 True, no filename filter.
28The service is pure (no I/O beyond stat()); callers own the gallery
29discovery and any post-arrangement upload/copy.
30"""
31from __future__ import annotations
33import logging
34import os
35from dataclasses import dataclass
36from typing import Callable, Iterable, Optional, Sequence
38logger = logging.getLogger('hevolve_social')
41# Filename hints we consider face-centric for the no-CLIP fallback.
42# Lowercased substring match; intentionally narrow so it doesn't
43# accidentally drop landscape shots whose filename happens to contain
44# 'face' as part of e.g. 'lacefactory'.
45_FACE_HINT_TOKENS: tuple[str, ...] = (
46 'selfie', 'portrait', 'mugshot',
47)
49# Filename ROOT extractor: drop trailing digits + extension so that
50# "selfie_001.jpg" and "selfie_002.jpg" share a root.
51_DIGIT_TAIL = '0123456789_-'
54def _filename_root(name: str) -> str:
55 base = os.path.splitext(os.path.basename(name))[0].lower()
56 return base.rstrip(_DIGIT_TAIL) or base
59def _looks_face_only(name: str) -> bool:
60 base = os.path.basename(name).lower()
61 return any(tok in base for tok in _FACE_HINT_TOKENS)
64@dataclass(frozen=True)
65class PortraitChoice:
66 """One picked portrait — caller-friendly result row."""
67 path: str
68 score: float
69 reason: str
72def _naive_recency_score(path: str) -> float:
73 """Default scorer. Higher mtime → higher score."""
74 try:
75 return float(os.path.getmtime(path))
76 except OSError:
77 return 0.0
80def arrange_portraits(
81 gallery: Sequence[str],
82 *,
83 face_visible_consent: bool = False,
84 max_picks: int = 6,
85 scorer: Optional[Callable[[str], float]] = None,
86) -> list[PortraitChoice]:
87 """Return up to `max_picks` portraits, ordered by descending score
88 with adjacent-similar-filename suppression.
90 Args:
91 gallery: iterable of local paths (anything Path-coercible).
92 face_visible_consent: when False, drop face-only filenames.
93 max_picks: hard cap on returned portraits.
94 scorer: pluggable callable taking a path → float score.
95 Defaults to naive-recency (st_mtime). Higher is
96 more preferred. Errors in scorer fall back to 0.0
97 for that path so a single bad file can't poison the
98 whole batch.
100 Returns:
101 list[PortraitChoice], length ≤ max_picks, ordered by
102 (score desc, then path asc) with adjacent-root suppression.
103 """
104 if max_picks <= 0:
105 return []
106 score_fn = scorer or _naive_recency_score
107 candidates: list[tuple[float, str, str]] = []
108 for raw in gallery or ():
109 # Drop falsy entries BEFORE str() — `str(None)` is the
110 # truthy 'None', which would otherwise leak through as a
111 # bogus path.
112 if not raw:
113 continue
114 path = str(raw)
115 if not face_visible_consent and _looks_face_only(path):
116 continue
117 try:
118 score = float(score_fn(path))
119 except Exception as exc: # noqa: BLE001
120 logger.debug(
121 'portrait_service: scorer raised on %s: %s', path, exc,
122 )
123 score = 0.0
124 candidates.append((score, path, _filename_root(path)))
126 # Stable sort: highest score first, ties broken by path so the
127 # output is reproducible across runs / OSes.
128 candidates.sort(key=lambda x: (-x[0], x[1]))
130 out: list[PortraitChoice] = []
131 last_root: Optional[str] = None
132 deferred: list[tuple[float, str, str]] = []
133 for score, path, root in candidates:
134 if root == last_root:
135 # Adjacent-similar suppression — push this one to the end
136 # of the queue and pick the next non-similar candidate
137 # first. If we run out of variety we fall back to the
138 # deferred items (so we don't return fewer than asked
139 # when the gallery is largely homogeneous).
140 deferred.append((score, path, root))
141 continue
142 out.append(PortraitChoice(
143 path=path, score=score, reason='primary',
144 ))
145 last_root = root
146 if len(out) >= max_picks:
147 break
149 # Top-up from deferred if we couldn't fill max_picks variety-first.
150 if len(out) < max_picks and deferred:
151 for score, path, root in deferred:
152 out.append(PortraitChoice(
153 path=path, score=score, reason='deferred-similar',
154 ))
155 if len(out) >= max_picks:
156 break
158 return out
161def arrange_portraits_paths(
162 gallery: Iterable[str],
163 *,
164 face_visible_consent: bool = False,
165 max_picks: int = 6,
166 scorer: Optional[Callable[[str], float]] = None,
167) -> list[str]:
168 """Convenience wrapper returning just the path strings — for
169 callers that don't care about score / reason."""
170 return [
171 c.path for c in arrange_portraits(
172 list(gallery),
173 face_visible_consent=face_visible_consent,
174 max_picks=max_picks,
175 scorer=scorer,
176 )
177 ]