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

1""" 

2HevolveSocial - Portrait auto-arrange service (closes #400). 

3 

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.). 

7 

8Design contract (project_encounter_icebreaker.md §10): 

9 

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. 

27 

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 

32 

33import logging 

34import os 

35from dataclasses import dataclass 

36from typing import Callable, Iterable, Optional, Sequence 

37 

38logger = logging.getLogger('hevolve_social') 

39 

40 

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) 

48 

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_-' 

52 

53 

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 

57 

58 

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) 

62 

63 

64@dataclass(frozen=True) 

65class PortraitChoice: 

66 """One picked portrait — caller-friendly result row.""" 

67 path: str 

68 score: float 

69 reason: str 

70 

71 

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 

78 

79 

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. 

89 

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. 

99 

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))) 

125 

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])) 

129 

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 

148 

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 

157 

158 return out 

159 

160 

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 ]