Coverage for app \ services \ report_service.py: 62%

104 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 20:58 -0400

1""" 

2Report service for expert-scoped parental reports. 

3Reads quiz result JSON files and computes summary stats per child. 

4""" 

5 

6import json 

7from collections import defaultdict 

8from pathlib import Path 

9from typing import Any, Dict, List, Optional 

10 

11 

12def _get_downloads_dir() -> Path: 

13 from app.settings import DOWNLOADS_DIR 

14 return DOWNLOADS_DIR 

15 

16 

17def _get_video_meta(video_id: str, downloads_dir: Path) -> Dict[str, Any]: 

18 meta_path = downloads_dir / video_id / "meta.json" 

19 if meta_path.exists(): 

20 try: 

21 meta = json.loads(meta_path.read_text(encoding="utf-8")) 

22 duration_seconds = meta.get("duration", 0) or 0 

23 return { 

24 "title": meta.get("title") or video_id, 

25 "duration_minutes": round(duration_seconds / 60, 1), 

26 } 

27 except Exception: 

28 pass 

29 return {"title": video_id, "duration_minutes": 0} 

30 

31 

32def _get_video_title(video_id: str, downloads_dir: Path) -> str: 

33 return _get_video_meta(video_id, downloads_dir)["title"] 

34 

35 

36def _load_attempts(child_id: str, downloads_dir: Path) -> List[Dict[str, Any]]: 

37 results_file = downloads_dir / "quiz_results" / f"{child_id}_results.json" 

38 if not results_file.exists(): 

39 return [] 

40 try: 

41 data = json.loads(results_file.read_text(encoding="utf-8")) 

42 return data.get("attempts", []) 

43 except Exception: 

44 return [] 

45 

46 

47def _compute_top_categories(attempts: List[Dict[str, Any]], window: int = 10) -> List[Dict[str, Any]]: 

48 """ 

49 Compute top 3 question-type categories from the last `window` attempts. 

50 Per-answer points: correct=1, almost=0.5, wrong=0. 

51 Category score: round((points_sum / answer_count) * 100). 

52 Rank by score desc, then answer_count desc. 

53 """ 

54 recent = attempts[-window:] 

55 category_points: Dict[str, float] = defaultdict(float) 

56 category_counts: Dict[str, int] = defaultdict(int) 

57 

58 for attempt in recent: 

59 for detail in attempt.get("details", []): 

60 q_type = detail.get("question_type") 

61 if not q_type: 

62 continue 

63 status = detail.get("status", "wrong") 

64 if status == "correct": 

65 points = 1.0 

66 elif status == "almost": 

67 points = 0.5 

68 else: 

69 points = 0.0 

70 category_points[q_type] += points 

71 category_counts[q_type] += 1 

72 

73 if not category_counts: 

74 return [] 

75 

76 categories = [] 

77 for q_type, count in category_counts.items(): 

78 score = round((category_points[q_type] / count) * 100) 

79 categories.append({ 

80 "type": q_type, 

81 "score": score, 

82 "answer_count": count, 

83 }) 

84 

85 categories.sort(key=lambda c: (-c["score"], -c["answer_count"])) 

86 return categories[:3] 

87 

88 

89def get_child_report_scoped( 

90 child_id: str, 

91 video_id: Optional[str] = None, 

92 mode: Optional[str] = None, 

93) -> Dict[str, Any]: 

94 """ 

95 Return a report filtered by optional video_id and/or interaction_mode. 

96 mode='all' (or None) means no mode filter. 

97 """ 

98 downloads_dir = _get_downloads_dir() 

99 all_attempts = _load_attempts(child_id, downloads_dir) 

100 

101 # Filter by video 

102 if video_id: 

103 all_attempts = [a for a in all_attempts if a.get("video_id") == video_id] 

104 

105 # Filter by mode (skip filter when mode is None or 'all') 

106 if mode and mode != "all": 

107 all_attempts = [a for a in all_attempts if a.get("interaction_mode") == mode] 

108 

109 if not all_attempts: 

110 return { 

111 "success": True, 

112 "child_id": child_id, 

113 "overall_score": 0, 

114 "total_attempts": 0, 

115 "total_retries": 0, 

116 "avg_retries_per_question": 0.0, 

117 "top_categories": [], 

118 "recent_videos": [], 

119 "videos_watched": 0, 

120 "total_watch_minutes": 0, 

121 } 

122 

123 enriched = [ 

124 {**a, "video_title": _get_video_title(a.get("video_id", ""), downloads_dir)} 

125 for a in all_attempts 

126 ] 

127 

128 percentages = [a.get("percentage", 0) for a in enriched] 

129 overall_score = round(sum(percentages) / len(percentages)) if percentages else 0 

130 total_retries = sum(a.get("total_retries", 0) for a in enriched) 

131 total_questions_answered = sum(a.get("total", 0) for a in enriched) 

132 avg_retries_per_question = round( 

133 total_retries / total_questions_answered, 2 

134 ) if total_questions_answered > 0 else 0.0 

135 

136 recent_videos = [] 

137 for a in reversed(enriched[-6:]): 

138 vid_id = a.get("video_id", "") 

139 meta = _get_video_meta(vid_id, downloads_dir) 

140 watch_min = round(a.get("watch_minutes", 0), 1) 

141 dur_min = meta["duration_minutes"] 

142 finished = dur_min > 0 and watch_min >= dur_min * 0.9 

143 recent_videos.append({ 

144 "video_id": vid_id, 

145 "video_title": a.get("video_title"), 

146 "percentage": a.get("percentage", 0), 

147 "timestamp": a.get("timestamp"), 

148 "interaction_mode": a.get("interaction_mode"), 

149 "watch_minutes": watch_min, 

150 "duration_minutes": dur_min, 

151 "finished": finished, 

152 "manual_pauses": a.get("manual_pauses", 0), 

153 }) 

154 

155 return { 

156 "success": True, 

157 "child_id": child_id, 

158 "overall_score": overall_score, 

159 "total_attempts": len(enriched), 

160 "total_retries": total_retries, 

161 "avg_retries_per_question": avg_retries_per_question, 

162 "top_categories": _compute_top_categories(all_attempts), 

163 "recent_videos": recent_videos, 

164 "videos_watched": len(enriched), 

165 "total_watch_minutes": sum(round(a.get("watch_minutes", 0)) for a in enriched), 

166 } 

167 

168 

169def get_child_report(child_id: str, limit: int = 10) -> Dict[str, Any]: 

170 """ 

171 Return a full report payload for one child. 

172 """ 

173 downloads_dir = _get_downloads_dir() 

174 attempts = _load_attempts(child_id, downloads_dir) 

175 

176 if not attempts: 

177 return { 

178 "success": True, 

179 "child_id": child_id, 

180 "overall_score": 0, 

181 "total_attempts": 0, 

182 "total_retries": 0, 

183 "avg_retries_per_question": 0.0, 

184 "top_categories": [], 

185 "recent_videos": [], 

186 } 

187 

188 # Enrich each attempt with video title 

189 enriched = [] 

190 for attempt in attempts: 

191 video_id = attempt.get("video_id", "") 

192 enriched.append({ 

193 **attempt, 

194 "video_title": _get_video_title(video_id, downloads_dir), 

195 }) 

196 

197 # Overall score: average percentage across all attempts 

198 percentages = [a.get("percentage", 0) for a in enriched] 

199 overall_score = round(sum(percentages) / len(percentages)) if percentages else 0 

200 

201 # Total attempts 

202 total_attempts = len(enriched) 

203 # Passive metrics 

204 videos_watched = len(enriched) 

205 total_watch_minutes = sum( 

206 round(a.get("watch_minutes", 0)) for a in enriched 

207 ) 

208 

209 # Aggregate retry metrics across all attempts 

210 total_retries = sum(a.get("total_retries", 0) for a in enriched) 

211 total_questions_answered = sum(a.get("total", 0) for a in enriched) 

212 avg_retries_per_question = round( 

213 total_retries / total_questions_answered, 2 

214 ) if total_questions_answered > 0 else 0.0 

215 

216 # Top categories 

217 top_categories = _compute_top_categories(attempts) 

218 

219 # Recent videos: latest 4, newest first 

220 recent_videos = [] 

221 for a in reversed(enriched[-4:]): 

222 vid_id = a.get("video_id", "") 

223 meta = _get_video_meta(vid_id, downloads_dir) 

224 watch_min = round(a.get("watch_minutes", 0), 1) 

225 dur_min = meta["duration_minutes"] 

226 finished = dur_min > 0 and watch_min >= dur_min * 0.9 

227 recent_videos.append({ 

228 "video_id": vid_id, 

229 "video_title": a.get("video_title"), 

230 "percentage": a.get("percentage", 0), 

231 "timestamp": a.get("timestamp"), 

232 "watch_minutes": watch_min, 

233 "duration_minutes": dur_min, 

234 "finished": finished, 

235 "manual_pauses": a.get("manual_pauses", 0), 

236 }) 

237 

238 return { 

239 "success": True, 

240 "child_id": child_id, 

241 "overall_score": overall_score, 

242 "total_attempts": total_attempts, 

243 "total_correct": sum(a.get("questions_correct", 0) for a in enriched), 

244 "total_wrong": sum(a.get("questions_wrong", 0) for a in enriched), 

245 "total_questions_answered": sum(a.get("total", 0) for a in enriched), 

246 "total_retries": total_retries, 

247 "avg_retries_per_question": avg_retries_per_question, 

248 "top_categories": top_categories, 

249 "recent_videos": recent_videos, 

250 "videos_watched": videos_watched, 

251 "total_watch_minutes": total_watch_minutes, 

252 }