Coverage for src/lilbee/cli/tui/screens/catalog_grouping.py: 100%

93 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

1"""Row-grouping helpers and the GridSection container for CatalogScreen.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import cast 

7 

8from lilbee.catalog.types import ModelCompat, ModelTask 

9from lilbee.cli.tui import messages as msg 

10from lilbee.cli.tui.screens.catalog_utils import ( 

11 CatalogRow, 

12 CatalogRowKind, 

13 FrontierCatalogRow, 

14 LocalCatalogRow, 

15) 

16from lilbee.cli.tui.widgets.model_list import ModelListSection 

17from lilbee.core.config import cfg 

18from lilbee.runtime.hardware import FIT_RANK, FitLevel 

19 

20 

21@dataclass 

22class GridSection: 

23 """A named group of rows for the grid view.""" 

24 

25 heading: str 

26 rows: list[CatalogRow] 

27 

28 

29TASK_BUCKET_ORDER = (ModelTask.CHAT, ModelTask.EMBEDDING, ModelTask.VISION, ModelTask.RERANK) 

30PICKS_SECTION_HEADING = "★ Picks" 

31 

32 

33def row_cache_signature(row: CatalogRow) -> tuple[str, bool]: 

34 """Pair (name, installed-flag) for the per-tab cache key. 

35 

36 Frontier rows don't carry an ``installed`` field; they're keyed as 

37 if installed=False since each frontier entry is provider-managed 

38 rather than on-disk. 

39 """ 

40 if row.kind == CatalogRowKind.FRONTIER: 

41 return (row.name, False) 

42 return (row.name, row.installed) 

43 

44 

45def _is_runnable_pick(row: LocalCatalogRow) -> bool: 

46 """Whether the row is a recommendation: supported, runnable, and either clean or opted in.""" 

47 return ( 

48 row.compat is ModelCompat.SUPPORTED 

49 and (cfg.include_uncensored or not row.safety_stripped) 

50 and (row.fit is None or row.fit.level is not FitLevel.WONT_RUN) 

51 ) 

52 

53 

54def _unknown_fit_rank(row: LocalCatalogRow) -> int: 

55 """0 for a row with a fit chip, 1 for a row without one, so measured rows lead.""" 

56 return 0 if row.fit is not None else 1 

57 

58 

59def _backfill_sort_key(row: LocalCatalogRow) -> tuple[int, int, str]: 

60 """Rank a backfilled Discover pick: known fit first, most downloaded, then alphabetical.""" 

61 return (_unknown_fit_rank(row), -row.sort_downloads, row.name.lower()) 

62 

63 

64def _failed_probe_sort_key(row: LocalCatalogRow) -> tuple[float, str]: 

65 """Rank an unmeasured Discover pick: smallest first, then alphabetical.""" 

66 return (row.sort_size, row.name.lower()) 

67 

68 

69def _pick_for_task(candidates: list[LocalCatalogRow]) -> LocalCatalogRow: 

70 """One Discover pick for a role: smallest when nothing is measured, else featured-leads.""" 

71 if all(r.fit is None for r in candidates): 

72 return min(candidates, key=_failed_probe_sort_key) 

73 featured = [r for r in candidates if r.featured] 

74 if featured: 

75 return min(featured, key=for_you_sort_key) 

76 return min(candidates, key=_backfill_sort_key) 

77 

78 

79def for_you_by_role(rows: list[LocalCatalogRow]) -> list[LocalCatalogRow]: 

80 """Runnable picks grouped by role: chat, embedding, vision, rerank. 

81 

82 Featured rows lead, known fit first. When no row carries a fit chip 

83 the smallest row wins across featured and non-featured rows. A role 

84 whose featured rows cannot run backfills with the most downloaded row 

85 the host cannot rule out, so a card that does not fit is replaced 

86 rather than dropped. A role with nothing runnable yields no pick. 

87 """ 

88 runnable = [r for r in rows if _is_runnable_pick(r)] 

89 out: list[LocalCatalogRow] = [] 

90 for task in TASK_BUCKET_ORDER: 

91 candidates = [r for r in runnable if r.task == task] 

92 if candidates: 

93 out.append(_pick_for_task(candidates)) 

94 return out 

95 

96 

97def for_you_sort_key(row: LocalCatalogRow) -> tuple[int, int, str]: 

98 """Rank Discover 'For You' rows: known fit first, best fit next, then alphabetical.""" 

99 level_rank = 0 if row.fit is None else FIT_RANK[row.fit.level] 

100 return (_unknown_fit_rank(row), level_rank, row.name.lower()) 

101 

102 

103def group_frontier_rows( 

104 frontier_rows: list[FrontierCatalogRow], 

105) -> list[ModelListSection]: 

106 """Group frontier rows into provider-headed sections. 

107 

108 Section order follows :data:`PROVIDER_KEYS` (the canonical display 

109 order); providers absent from PROVIDER_KEYS land at the tail in 

110 alphabetical order. Rows within each section are alphabetical. 

111 """ 

112 if not frontier_rows: 

113 return [] 

114 from lilbee.providers.sdk_backend import PROVIDER_KEYS 

115 

116 per_provider: dict[str, list[FrontierCatalogRow]] = {} 

117 for row in frontier_rows: 

118 per_provider.setdefault(row.provider, []).append(row) 

119 canonical_order = [label for _, _, _, label in PROVIDER_KEYS] 

120 ordered = [p for p in canonical_order if p in per_provider] 

121 extras = sorted(set(per_provider) - set(canonical_order)) 

122 sections: list[ModelListSection] = [] 

123 for provider in [*ordered, *extras]: 

124 rows = sorted(per_provider[provider], key=lambda r: r.name.lower()) 

125 sections.append(ModelListSection(heading=provider, rows=list(rows))) 

126 return sections 

127 

128 

129def group_task_rows_with_picks( 

130 task_rows: list[LocalCatalogRow], task_label: str 

131) -> list[GridSection]: 

132 """Per-tab grouping: ★ Picks pinned, then Installed, then the rest. 

133 

134 Lifts featured rows out of their task bucket into a dedicated pinned 

135 section at the top of the tab. Today's behavior interleaved them at 

136 the top of the task bucket; the redesign treats curation as its own 

137 layer so the eye lands on Picks first instead of having to scan past 

138 them to find non-featured rows. 

139 

140 Pre-condition: caller has already filtered ``task_rows`` to a single 

141 task (the active per-task tab). 

142 """ 

143 picks: list[CatalogRow] = [] 

144 installed: list[CatalogRow] = [] 

145 others: list[CatalogRow] = [] 

146 for row in task_rows: 

147 if row.featured: 

148 picks.append(row) 

149 elif row.installed: 

150 installed.append(row) 

151 else: 

152 others.append(row) 

153 return [ 

154 GridSection(PICKS_SECTION_HEADING, picks), 

155 GridSection(msg.HEADING_INSTALLED, installed), 

156 GridSection(task_label, others), 

157 ] 

158 

159 

160def flatten_sections(sections: list[GridSection], heading: str) -> list[GridSection]: 

161 """Collapse *sections* into a single section, preserving row order. 

162 

163 Used while a search filter is active: every mounted section costs a heading 

164 plus a whole card row even when it holds one match. 

165 """ 

166 rows = [row for section in sections for row in section.rows] 

167 if not rows: 

168 return [] 

169 return [GridSection(heading, rows)] 

170 

171 

172def group_rows_for_grid(local_rows: list[LocalCatalogRow]) -> list[GridSection]: 

173 """Group local rows into sections for the grid view. 

174 

175 Layout: Installed first, then one section per task. Featured rows live 

176 at the top of their task section (recognizable by the ``pick`` pill); 

177 no separate "Our picks" bucket so the catalog reads as a single 

178 task-organized list. 

179 """ 

180 installed: list[CatalogRow] = [] 

181 by_task: dict[str, list[CatalogRow]] = {task: [] for task in TASK_BUCKET_ORDER} 

182 extras: dict[str, list[CatalogRow]] = {} 

183 for row in local_rows: 

184 if row.installed: 

185 installed.append(row) 

186 continue 

187 bucket = by_task.get(row.task) 

188 if bucket is not None: 

189 bucket.append(row) 

190 else: 

191 extras.setdefault(row.task, []).append(row) 

192 # Within each task bucket: featured first (preserving their input order), 

193 # then the rest in their incoming order. Stable so HF rank from the API 

194 # is preserved among non-featured rows. 

195 for bucket in by_task.values(): 

196 bucket.sort(key=lambda r: not cast("LocalCatalogRow", r).featured) 

197 for bucket in extras.values(): 

198 bucket.sort(key=lambda r: not cast("LocalCatalogRow", r).featured) 

199 return [ 

200 GridSection(msg.HEADING_INSTALLED, installed), 

201 *[GridSection(task.capitalize(), by_task[task]) for task in TASK_BUCKET_ORDER], 

202 *[GridSection(task.capitalize(), extras[task]) for task in extras], 

203 ]