rairo commited on
Commit
7e6f692
·
verified ·
1 Parent(s): e7e789e

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +797 -2
main.py CHANGED
@@ -68,8 +68,8 @@ except Exception as e:
68
  logger.error(f"FATAL GenAI init: {e}")
69
  raise SystemExit(1)
70
 
71
- TEXT_MODEL = "gemini-2.5-flash"
72
- MULTIMODAL_MODEL = "gemini-2.5-flash"
73
 
74
  DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY")
75
  ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
@@ -1803,6 +1803,801 @@ def debug_data_api():
1803
 
1804
  return jsonify(results)
1805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1806
  # -----------------------------------------------------------------------------
1807
  # 17. MAIN
1808
  # -----------------------------------------------------------------------------
 
68
  logger.error(f"FATAL GenAI init: {e}")
69
  raise SystemExit(1)
70
 
71
+ TEXT_MODEL = "gemini-3.1-flash-lite-preview"
72
+ MULTIMODAL_MODEL = "gemini-3.1-flash-lite-preview"
73
 
74
  DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY")
75
  ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
 
1803
 
1804
  return jsonify(results)
1805
 
1806
+
1807
+ # =============================================================================
1808
+ # 18. FEEDBACK SYSTEM
1809
+ # Any role can submit feedback on any screen. Admins read it all.
1810
+ # =============================================================================
1811
+
1812
+ @app.route("/api/feedback/submit", methods=["POST"])
1813
+ def submit_feedback():
1814
+ """
1815
+ Submit feedback from any screen.
1816
+ Body: { screen, rating (1-5), message, role, context (optional) }
1817
+ """
1818
+ uid = verify_token(request.headers.get("Authorization"))
1819
+ if not uid:
1820
+ return jsonify({"error": "Unauthorized"}), 401
1821
+
1822
+ data = request.get_json() or {}
1823
+ screen = data.get("screen", "unknown")
1824
+ rating = data.get("rating") # 1-5 stars
1825
+ message = data.get("message", "")
1826
+ context = data.get("context", {}) # e.g. { subjectId, nodeId, quizId }
1827
+
1828
+ user = get_user(uid)
1829
+ role = user.get("role", "unknown")
1830
+
1831
+ if not message and not rating:
1832
+ return jsonify({"error": "rating or message required"}), 400
1833
+
1834
+ fid = uuid.uuid4().hex
1835
+ doc = {
1836
+ "feedbackId": fid,
1837
+ "userId": uid,
1838
+ "displayName": user.get("displayName"),
1839
+ "email": user.get("email"),
1840
+ "role": role,
1841
+ "screen": screen,
1842
+ "rating": rating,
1843
+ "message": message,
1844
+ "context": context,
1845
+ "createdAt": datetime.utcnow().isoformat(),
1846
+ "status": "unread",
1847
+ }
1848
+ db_ref.child(f"feedback/{fid}").set(doc)
1849
+ # Also index by screen for easy admin queries
1850
+ db_ref.child(f"feedback_by_screen/{screen}/{fid}").set(True)
1851
+ return jsonify({"success": True, "feedbackId": fid}), 201
1852
+
1853
+
1854
+ @app.route("/api/admin/feedback", methods=["GET"])
1855
+ def admin_list_feedback():
1856
+ """
1857
+ Admin reads all feedback.
1858
+ Query: screen (filter), role (filter), status (unread/read/all), limit (default 50)
1859
+ """
1860
+ uid = verify_token(request.headers.get("Authorization"))
1861
+ if not uid:
1862
+ return jsonify({"error": "Unauthorized"}), 401
1863
+ try:
1864
+ require_role(uid, ["admin"])
1865
+ except PermissionError as e:
1866
+ return jsonify({"error": str(e)}), 403
1867
+
1868
+ screen_f = request.args.get("screen")
1869
+ role_f = request.args.get("role")
1870
+ status_f = request.args.get("status", "all")
1871
+ limit = int(request.args.get("limit", 50))
1872
+
1873
+ all_fb = db_ref.child("feedback").get() or {}
1874
+ result = []
1875
+ for fid, item in all_fb.items():
1876
+ if not item: continue
1877
+ if screen_f and item.get("screen") != screen_f: continue
1878
+ if role_f and item.get("role") != role_f: continue
1879
+ if status_f != "all" and item.get("status") != status_f: continue
1880
+ result.append(item)
1881
+
1882
+ result.sort(key=lambda x: x.get("createdAt", ""), reverse=True)
1883
+ return jsonify(result[:limit])
1884
+
1885
+
1886
+ @app.route("/api/admin/feedback/<feedback_id>/mark-read", methods=["POST"])
1887
+ def mark_feedback_read(feedback_id):
1888
+ uid = verify_token(request.headers.get("Authorization"))
1889
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
1890
+ try: require_role(uid, ["admin"])
1891
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
1892
+ db_ref.child(f"feedback/{feedback_id}").update({"status": "read", "readBy": uid,
1893
+ "readAt": datetime.utcnow().isoformat()})
1894
+ return jsonify({"success": True})
1895
+
1896
+
1897
+ # =============================================================================
1898
+ # 19. REPORTING — LEARNER DASHBOARD
1899
+ # =============================================================================
1900
+
1901
+ @app.route("/api/reports/learner/summary", methods=["GET"])
1902
+ def learner_report_summary():
1903
+ """
1904
+ Full personal report for the logged-in learner.
1905
+ Returns structured data + AI-generated narrative insight.
1906
+ """
1907
+ uid = verify_token(request.headers.get("Authorization"))
1908
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
1909
+
1910
+ attempts = db_ref.child("attempts").get() or {}
1911
+ my_atts = sorted([a for a in attempts.values() if a.get("userId") == uid],
1912
+ key=lambda x: x.get("createdAt", ""))
1913
+ sessions = db_ref.child("revision_sessions").get() or {}
1914
+ my_sess = [s for s in sessions.values() if s.get("userId") == uid]
1915
+ subjects = db_ref.child("subjects").get() or {}
1916
+ topics = db_ref.child("topics").get() or {}
1917
+
1918
+ # Progress trend: last 14 attempts
1919
+ trend = [{"date": a.get("createdAt","")[:10], "percentage": round(a.get("percentage",0),1),
1920
+ "subject": subjects.get(a.get("subjectId",""),{}).get("name","?")}
1921
+ for a in my_atts[-14:]]
1922
+
1923
+ # Aggregate per subject
1924
+ subj_stats, topic_stats = _aggregate_attempts(my_atts)
1925
+ strong_topics = sorted(
1926
+ [{"topicId": t, "name": topics.get(t,{}).get("name",t),
1927
+ "percentage": round((s["correct"]/s["total"])*100,1)}
1928
+ for t, s in topic_stats.items() if s["total"] >= 3],
1929
+ key=lambda x: -x["percentage"]
1930
+ )[:5]
1931
+ weak_topics = sorted(
1932
+ [{"topicId": t, "name": topics.get(t,{}).get("name",t),
1933
+ "percentage": round((s["correct"]/s["total"])*100,1)}
1934
+ for t, s in topic_stats.items() if s["total"] >= 3],
1935
+ key=lambda x: x["percentage"]
1936
+ )[:5]
1937
+
1938
+ # Learning path completion per subject
1939
+ path_progress = db_ref.child(f"users/{uid}/path_progress").get() or {}
1940
+ path_summary = {sid: len(nodes) for sid, nodes in path_progress.items()
1941
+ if isinstance(nodes, dict)}
1942
+
1943
+ # AI narrative
1944
+ payload = {
1945
+ "totalAttempts": len(my_atts),
1946
+ "recentTrend": trend,
1947
+ "strongTopics": strong_topics,
1948
+ "weakTopics": weak_topics,
1949
+ "revisionSessions": len(my_sess),
1950
+ "pathProgress": path_summary,
1951
+ }
1952
+ ai_prompt = f"""
1953
+ You are a supportive academic coach for a Zimbabwean secondary school student.
1954
+
1955
+ Here is the student's learning data (JSON):
1956
+ {json.dumps(payload, ensure_ascii=False)[:4000]}
1957
+
1958
+ Write a personalised progress report in a warm, encouraging tone. Structure:
1959
+ 1. HEADLINE (one sentence — their overall trajectory)
1960
+ 2. STRENGTHS (what they are doing well, specific topics)
1961
+ 3. FOCUS AREAS (where to spend time next, constructive)
1962
+ 4. RECOMMENDED NEXT STEPS (2-3 specific, actionable suggestions)
1963
+ 5. MOTIVATIONAL CLOSE (one sentence)
1964
+
1965
+ Keep it concise, honest, and motivating. Address the student directly as "you".
1966
+ """
1967
+ narrative = send_gemini_text(ai_prompt)
1968
+
1969
+ return jsonify({
1970
+ "totalAttempts": len(my_atts),
1971
+ "revisionSessions": len(my_sess),
1972
+ "recentTrend": trend,
1973
+ "strongTopics": strong_topics,
1974
+ "weakTopics": weak_topics,
1975
+ "pathProgress": path_summary,
1976
+ "aiNarrative": narrative,
1977
+ "generatedAt": datetime.utcnow().isoformat(),
1978
+ })
1979
+
1980
+
1981
+ # =============================================================================
1982
+ # 20. REPORTING — TEACHER DASHBOARD
1983
+ # =============================================================================
1984
+
1985
+ @app.route("/api/reports/teacher/class/<class_id>", methods=["GET"])
1986
+ def teacher_class_report(class_id):
1987
+ """
1988
+ Full class report for a teacher — analytics + AI narrative insights.
1989
+ """
1990
+ uid = verify_token(request.headers.get("Authorization"))
1991
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
1992
+ try: require_role(uid, ["teacher","admin"])
1993
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
1994
+ try: require_approved(uid)
1995
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
1996
+
1997
+ class_doc = db_ref.child(f"classes/{class_id}").get()
1998
+ if not class_doc or class_doc.get("teacherId") != uid:
1999
+ return jsonify({"error": "Class not found or access denied"}), 404
2000
+
2001
+ learner_ids = set(class_doc.get("learnerIds") or [])
2002
+ attempts = db_ref.child("attempts").get() or {}
2003
+ class_atts = [a for a in attempts.values() if a.get("userId") in learner_ids]
2004
+
2005
+ subjects = db_ref.child("subjects").get() or {}
2006
+ topics = db_ref.child("topics").get() or {}
2007
+ users = db_ref.child("users").get() or {}
2008
+
2009
+ # Per-learner summary
2010
+ learner_stats = {}
2011
+ for a in class_atts:
2012
+ lid = a.get("userId")
2013
+ if not lid: continue
2014
+ learner_stats.setdefault(lid, {"attempts": 0, "scoreSum": 0, "totalSum": 0, "wrongQ": []})
2015
+ learner_stats[lid]["attempts"] += 1
2016
+ learner_stats[lid]["scoreSum"] += a.get("score", 0)
2017
+ learner_stats[lid]["totalSum"] += a.get("total", 0)
2018
+ learner_stats[lid]["wrongQ"] += a.get("wrongQuestions", [])
2019
+
2020
+ learner_rows = []
2021
+ for lid, s in learner_stats.items():
2022
+ avg = round((s["scoreSum"]/s["totalSum"])*100, 1) if s["totalSum"] else 0
2023
+ u = users.get(lid, {})
2024
+ learner_rows.append({
2025
+ "uid": lid, "name": u.get("displayName","?"), "email": u.get("email",""),
2026
+ "attempts": s["attempts"], "averagePercentage": avg,
2027
+ })
2028
+ learner_rows.sort(key=lambda x: -x["averagePercentage"])
2029
+
2030
+ top_learners = learner_rows[:5]
2031
+ struggling = [l for l in learner_rows if l["averagePercentage"] < 50]
2032
+
2033
+ # Topic weakness map
2034
+ _, topic_stats = _aggregate_attempts(class_atts)
2035
+ weak_class_topics= sorted(
2036
+ [{"topicId": t, "name": topics.get(t,{}).get("name",t),
2037
+ "percentage": round((s["correct"]/s["total"])*100,1), "attempts": s["total"]}
2038
+ for t, s in topic_stats.items() if s["total"] >= 5],
2039
+ key=lambda x: x["percentage"]
2040
+ )[:8]
2041
+
2042
+ # Subject breakdown
2043
+ subj_stats, _ = _aggregate_attempts(class_atts)
2044
+ subj_rows = [{"subjectId": sid, "name": subjects.get(sid,{}).get("name",sid),
2045
+ "attempts": s["attempts"],
2046
+ "averagePercentage": round((s["scoreSum"]/s["totalSum"])*100,1) if s["totalSum"] else 0}
2047
+ for sid, s in subj_stats.items()]
2048
+
2049
+ payload = {
2050
+ "className": class_doc.get("name"),
2051
+ "totalLearners": len(learner_ids),
2052
+ "activeLearners": len(learner_stats),
2053
+ "totalAttempts": len(class_atts),
2054
+ "topLearners": top_learners,
2055
+ "strugglingLearners": struggling[:5],
2056
+ "weakClassTopics": weak_class_topics,
2057
+ "subjectBreakdown": subj_rows,
2058
+ }
2059
+
2060
+ ai_prompt = f"""
2061
+ You are an expert education analyst advising a teacher in Zimbabwe.
2062
+
2063
+ Here is the class performance data (JSON):
2064
+ {json.dumps(payload, ensure_ascii=False)[:5000]}
2065
+
2066
+ Write a professional class performance report. Structure:
2067
+ 1. CLASS OVERVIEW (overall performance level, engagement rate)
2068
+ 2. TOP PERFORMERS (names and what they are doing well)
2069
+ 3. STUDENTS NEEDING SUPPORT (constructive, non-stigmatising)
2070
+ 4. CURRICULUM GAPS (which topics are most problematic across the class)
2071
+ 5. TEACHING RECOMMENDATIONS (3-4 specific pedagogical suggestions)
2072
+ 6. PRIORITISED ACTION LIST (bullet points, most urgent first)
2073
+
2074
+ Tone: professional, data-driven, constructive. Address the teacher as "your class".
2075
+ """
2076
+ narrative = send_gemini_text(ai_prompt)
2077
+
2078
+ return jsonify({
2079
+ **payload,
2080
+ "aiNarrative": narrative,
2081
+ "generatedAt": datetime.utcnow().isoformat(),
2082
+ })
2083
+
2084
+
2085
+ @app.route("/api/reports/teacher/summary", methods=["GET"])
2086
+ def teacher_summary_report():
2087
+ """
2088
+ Aggregate report across ALL classes a teacher owns.
2089
+ """
2090
+ uid = verify_token(request.headers.get("Authorization"))
2091
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
2092
+ try: require_role(uid, ["teacher","admin"])
2093
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2094
+ try: require_approved(uid)
2095
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2096
+
2097
+ classes = db_ref.child("classes").get() or {}
2098
+ my_classes = [c for c in classes.values() if c.get("teacherId") == uid]
2099
+ all_lids = set()
2100
+ for c in my_classes:
2101
+ all_lids.update(c.get("learnerIds") or [])
2102
+
2103
+ attempts = db_ref.child("attempts").get() or {}
2104
+ my_atts = [a for a in attempts.values() if a.get("userId") in all_lids]
2105
+ topics = db_ref.child("topics").get() or {}
2106
+ subjects = db_ref.child("subjects").get() or {}
2107
+
2108
+ _, topic_stats = _aggregate_attempts(my_atts)
2109
+ subj_stats, _ = _aggregate_attempts(my_atts)
2110
+
2111
+ weak_topics = sorted(
2112
+ [{"name": topics.get(t,{}).get("name",t),
2113
+ "percentage": round((s["correct"]/s["total"])*100,1)}
2114
+ for t, s in topic_stats.items() if s["total"] >= 5],
2115
+ key=lambda x: x["percentage"]
2116
+ )[:10]
2117
+
2118
+ summary = {
2119
+ "totalClasses": len(my_classes),
2120
+ "totalLearners": len(all_lids),
2121
+ "totalAttempts": len(my_atts),
2122
+ "weakTopics": weak_topics,
2123
+ "subjectBreakdown":[{"name": subjects.get(sid,{}).get("name",sid),
2124
+ "averagePercentage": round((s["scoreSum"]/s["totalSum"])*100,1) if s["totalSum"] else 0}
2125
+ for sid, s in subj_stats.items()],
2126
+ }
2127
+
2128
+ ai_prompt = f"""
2129
+ You are an education coach reviewing a teacher's overall performance data across all their classes.
2130
+
2131
+ Data: {json.dumps(summary, ensure_ascii=False)[:4000]}
2132
+
2133
+ Write a concise teaching effectiveness summary:
2134
+ 1. OVERALL IMPACT (breadth and quality of student engagement)
2135
+ 2. CURRICULUM COVERAGE ANALYSIS
2136
+ 3. AREAS FOR PROFESSIONAL DEVELOPMENT
2137
+ 4. COMMENDATIONS
2138
+
2139
+ Professional tone. Address teacher directly.
2140
+ """
2141
+ return jsonify({**summary, "aiNarrative": send_gemini_text(ai_prompt),
2142
+ "generatedAt": datetime.utcnow().isoformat()})
2143
+
2144
+
2145
+ # =============================================================================
2146
+ # 21. REPORTING — PARENT DASHBOARD
2147
+ # =============================================================================
2148
+
2149
+ @app.route("/api/reports/parent/learner/<learner_id>", methods=["GET"])
2150
+ def parent_learner_report(learner_id):
2151
+ """
2152
+ Full parent-facing report on a linked learner — AI narrative included.
2153
+ """
2154
+ uid = verify_token(request.headers.get("Authorization"))
2155
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
2156
+ try: require_role(uid, ["parent","admin"])
2157
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2158
+ try: require_approved(uid)
2159
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2160
+
2161
+ parent = get_user(uid)
2162
+ if learner_id not in (parent.get("linkedLearnerIds") or []) and parent.get("role") != "admin":
2163
+ return jsonify({"error": "Learner not linked to your account"}), 403
2164
+
2165
+ learner = get_user(learner_id)
2166
+ attempts = db_ref.child("attempts").get() or {}
2167
+ l_atts = sorted([a for a in attempts.values() if a.get("userId") == learner_id],
2168
+ key=lambda x: x.get("createdAt",""))
2169
+ subjects = db_ref.child("subjects").get() or {}
2170
+ topics = db_ref.child("topics").get() or {}
2171
+
2172
+ subj_stats, topic_stats = _aggregate_attempts(l_atts)
2173
+ trend = [{"date": a.get("createdAt","")[:10],
2174
+ "percentage": round(a.get("percentage",0),1)}
2175
+ for a in l_atts[-14:]]
2176
+
2177
+ strong = sorted(
2178
+ [{"name": topics.get(t,{}).get("name",t),
2179
+ "percentage": round((s["correct"]/s["total"])*100,1)}
2180
+ for t, s in topic_stats.items() if s["total"] >= 3],
2181
+ key=lambda x: -x["percentage"]
2182
+ )[:5]
2183
+ weak = sorted(
2184
+ [{"name": topics.get(t,{}).get("name",t),
2185
+ "percentage": round((s["correct"]/s["total"])*100,1)}
2186
+ for t, s in topic_stats.items() if s["total"] >= 3],
2187
+ key=lambda x: x["percentage"]
2188
+ )[:5]
2189
+
2190
+ recent_activity = [
2191
+ {"date": a.get("createdAt","")[:10],
2192
+ "subject": subjects.get(a.get("subjectId",""),{}).get("name","?"),
2193
+ "score": f"{a.get('score',0)}/{a.get('total',0)}",
2194
+ "percentage": round(a.get("percentage",0),1)}
2195
+ for a in l_atts[-7:]
2196
+ ]
2197
+
2198
+ payload = {
2199
+ "learnerName": learner.get("displayName","?"),
2200
+ "grade": learner.get("grade","?"),
2201
+ "totalAttempts": len(l_atts),
2202
+ "recentTrend": trend,
2203
+ "strongTopics": strong,
2204
+ "weakTopics": weak,
2205
+ "recentActivity": recent_activity,
2206
+ }
2207
+
2208
+ ai_prompt = f"""
2209
+ You are writing a learning progress report for a parent of a Zimbabwean secondary school student.
2210
+
2211
+ Student data (JSON):
2212
+ {json.dumps(payload, ensure_ascii=False)[:4000]}
2213
+
2214
+ Write a clear, parent-friendly progress report:
2215
+ 1. OVERALL PROGRESS (plain language, no jargon)
2216
+ 2. WHAT YOUR CHILD IS DOING WELL
2217
+ 3. AREAS THAT NEED ATTENTION AT HOME
2218
+ 4. HOW TO SUPPORT YOUR CHILD (practical, specific suggestions for a parent)
2219
+ 5. LOOKING AHEAD (encouragement and next milestones)
2220
+
2221
+ Tone: warm, accessible, no technical jargon. Address the parent as "your child".
2222
+ """
2223
+ return jsonify({
2224
+ **payload,
2225
+ "aiNarrative": send_gemini_text(ai_prompt),
2226
+ "generatedAt": datetime.utcnow().isoformat(),
2227
+ })
2228
+
2229
+
2230
+ # =============================================================================
2231
+ # 22. REPORTING — ADMIN GLOBAL DASHBOARD
2232
+ # =============================================================================
2233
+
2234
+ def _compute_global_stats():
2235
+ """
2236
+ Compute platform-wide statistics. Cached as a heavy operation.
2237
+ Called by both the stats endpoint and the AI report endpoint.
2238
+ """
2239
+ attempts = db_ref.child("attempts").get() or {}
2240
+ users = db_ref.child("users").get() or {}
2241
+ subjects = db_ref.child("subjects").get() or {}
2242
+ topics = db_ref.child("topics").get() or {}
2243
+ classes = db_ref.child("classes").get() or {}
2244
+ sessions = db_ref.child("revision_sessions").get() or {}
2245
+ feedback = db_ref.child("feedback").get() or {}
2246
+
2247
+ all_atts = [a for a in attempts.values() if a]
2248
+ total_att = len(all_atts)
2249
+ avg_score = round(sum(a.get("percentage",0) for a in all_atts) / total_att, 1) if total_att else 0
2250
+
2251
+ # Role breakdown
2252
+ role_counts = {}
2253
+ for u in users.values():
2254
+ r = u.get("role","unknown")
2255
+ role_counts[r] = role_counts.get(r, 0) + 1
2256
+
2257
+ # Per-learner aggregates for leaderboard
2258
+ learner_agg = {}
2259
+ for a in all_atts:
2260
+ lid = a.get("userId")
2261
+ if not lid: continue
2262
+ if users.get(lid,{}).get("role") != "learner": continue
2263
+ learner_agg.setdefault(lid, {"scoreSum":0,"totalSum":0,"attempts":0})
2264
+ learner_agg[lid]["scoreSum"] += a.get("score",0)
2265
+ learner_agg[lid]["totalSum"] += a.get("total",0)
2266
+ learner_agg[lid]["attempts"] += 1
2267
+
2268
+ top_learners = sorted(
2269
+ [{"uid": lid, "name": users.get(lid,{}).get("displayName","?"),
2270
+ "school": users.get(lid,{}).get("school","?"),
2271
+ "attempts": s["attempts"],
2272
+ "averagePercentage": round((s["scoreSum"]/s["totalSum"])*100,1) if s["totalSum"] else 0}
2273
+ for lid, s in learner_agg.items() if s["attempts"] >= 3],
2274
+ key=lambda x: -x["averagePercentage"]
2275
+ )[:10]
2276
+
2277
+ # Per-teacher: how many students are improving
2278
+ teacher_impact = {}
2279
+ for c in classes.values():
2280
+ tid = c.get("teacherId")
2281
+ lids = set(c.get("learnerIds") or [])
2282
+ if not tid: continue
2283
+ teacher_impact.setdefault(tid, {"learners":set(),"attempts":0,"scoreSum":0,"totalSum":0})
2284
+ teacher_impact[tid]["learners"].update(lids)
2285
+ for a in all_atts:
2286
+ if a.get("userId") in lids:
2287
+ teacher_impact[tid]["attempts"] += 1
2288
+ teacher_impact[tid]["scoreSum"] += a.get("score",0)
2289
+ teacher_impact[tid]["totalSum"] += a.get("total",0)
2290
+
2291
+ top_teachers = sorted(
2292
+ [{"uid": tid,
2293
+ "name": users.get(tid,{}).get("displayName","?"),
2294
+ "school": users.get(tid,{}).get("school","?"),
2295
+ "totalLearners": len(s["learners"]),
2296
+ "totalAttempts": s["attempts"],
2297
+ "avgLearnerScore": round((s["scoreSum"]/s["totalSum"])*100,1) if s["totalSum"] else 0}
2298
+ for tid, s in teacher_impact.items() if s["attempts"] >= 10],
2299
+ key=lambda x: -x["avgLearnerScore"]
2300
+ )[:10]
2301
+
2302
+ # Subject performance
2303
+ subj_stats, topic_stats = _aggregate_attempts(all_atts)
2304
+ subject_performance = sorted(
2305
+ [{"subjectId": sid, "name": subjects.get(sid,{}).get("name",sid),
2306
+ "attempts": s["attempts"],
2307
+ "averagePercentage": round((s["scoreSum"]/s["totalSum"])*100,1) if s["totalSum"] else 0}
2308
+ for sid, s in subj_stats.items()],
2309
+ key=lambda x: -x["averagePercentage"]
2310
+ )
2311
+
2312
+ # Top struggling topics (most attempted, lowest scores)
2313
+ top_issues = sorted(
2314
+ [{"topicId": t, "name": topics.get(t,{}).get("name",t),
2315
+ "attempts": s["total"],
2316
+ "percentage": round((s["correct"]/s["total"])*100,1)}
2317
+ for t, s in topic_stats.items() if s["total"] >= 10],
2318
+ key=lambda x: (x["percentage"], -x["attempts"])
2319
+ )[:15]
2320
+
2321
+ # Activity timeline — attempts per day for last 30 days
2322
+ from collections import defaultdict
2323
+ daily = defaultdict(int)
2324
+ for a in all_atts:
2325
+ day = a.get("createdAt","")[:10]
2326
+ if day: daily[day] += 1
2327
+ timeline = [{"date": d, "attempts": c}
2328
+ for d, c in sorted(daily.items())[-30:]]
2329
+
2330
+ # Feedback summary
2331
+ feedback_items = list((feedback or {}).values())
2332
+ avg_rating = None
2333
+ if feedback_items:
2334
+ rated = [f.get("rating") for f in feedback_items if f.get("rating")]
2335
+ avg_rating = round(sum(rated)/len(rated),1) if rated else None
2336
+
2337
+ return {
2338
+ "totalUsers": len(users),
2339
+ "roleCounts": role_counts,
2340
+ "totalAttempts": total_att,
2341
+ "platformAvgScore": avg_score,
2342
+ "totalRevSessions": len(sessions),
2343
+ "totalFeedback": len(feedback_items),
2344
+ "avgFeedbackRating": avg_rating,
2345
+ "topLearners": top_learners,
2346
+ "topTeachers": top_teachers,
2347
+ "subjectPerformance":subject_performance,
2348
+ "topIssues": top_issues,
2349
+ "activityTimeline": timeline,
2350
+ }
2351
+
2352
+
2353
+ @app.route("/api/admin/reports/global-stats", methods=["GET"])
2354
+ def admin_global_stats():
2355
+ """
2356
+ Raw global statistics — no AI generation, fast.
2357
+ """
2358
+ uid = verify_token(request.headers.get("Authorization"))
2359
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
2360
+ try: require_role(uid, ["admin"])
2361
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2362
+
2363
+ stats = _compute_global_stats()
2364
+ stats["generatedAt"] = datetime.utcnow().isoformat()
2365
+ return jsonify(stats)
2366
+
2367
+
2368
+ @app.route("/api/admin/reports/ai-insight", methods=["POST"])
2369
+ def admin_ai_insight():
2370
+ """
2371
+ Generate a deep AI narrative report for stakeholders (investors, AU, UNICEF, etc.).
2372
+ Body: { reportType: "investor" | "curriculum" | "equity" | "progress" | "full" }
2373
+ This is a heavier call — may take 10-20 seconds.
2374
+ """
2375
+ uid = verify_token(request.headers.get("Authorization"))
2376
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
2377
+ try: require_role(uid, ["admin"])
2378
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2379
+
2380
+ data = request.get_json() or {}
2381
+ report_type = data.get("reportType", "full")
2382
+
2383
+ stats = _compute_global_stats()
2384
+
2385
+ prompts = {
2386
+ "investor": f"""
2387
+ You are a senior analyst preparing an investor briefing for Marka, an AI-powered EdTech platform
2388
+ serving secondary school students in Zimbabwe aligned with ZIMSEC and Cambridge curricula.
2389
+
2390
+ Platform data (JSON):
2391
+ {json.dumps(stats, ensure_ascii=False)[:6000]}
2392
+
2393
+ Write a compelling 600-word investor briefing covering:
2394
+ 1. PLATFORM TRACTION (key metrics, growth signals)
2395
+ 2. LEARNING OUTCOMES (measurable evidence of student improvement)
2396
+ 3. TEACHER ADOPTION & IMPACT
2397
+ 4. CURRICULUM COVERAGE & DEPTH
2398
+ 5. CHALLENGES BEING ADDRESSED (equity, access, quality)
2399
+ 6. INVESTMENT OPPORTUNITY (scale potential, monetisation signals)
2400
+
2401
+ Tone: confident, data-driven, appropriate for institutional investors and development finance.
2402
+ Use specific numbers. Highlight Zimbabwe-specific context.
2403
+ """,
2404
+ "curriculum": f"""
2405
+ You are a curriculum specialist reviewing learning outcomes data from Marka, an AI EdTech platform
2406
+ in Zimbabwe serving Grade 7, O-Level, and A-Level learners.
2407
+
2408
+ Platform data (JSON):
2409
+ {json.dumps(stats, ensure_ascii=False)[:6000]}
2410
+
2411
+ Write a curriculum analysis report:
2412
+ 1. LEARNING OUTCOMES OVERVIEW (what students are mastering vs struggling with)
2413
+ 2. SUBJECT-BY-SUBJECT ANALYSIS (strengths and gaps per subject)
2414
+ 3. CRITICAL KNOWLEDGE GAPS (topics with consistently low scores)
2415
+ 4. PATTERNS IN LEARNING STALLS (what is blocking progress)
2416
+ 5. CURRICULUM ALIGNMENT FINDINGS (how well platform content matches exam demands)
2417
+ 6. RECOMMENDATIONS FOR CURRICULUM DEVELOPERS AND EXAM BOARDS
2418
+
2419
+ Tone: academic, evidence-based, suitable for sharing with ZIMSEC, Cambridge, or ministry officials.
2420
+ """,
2421
+ "equity": f"""
2422
+ You are an education equity researcher reviewing data from Marka, an AI learning platform
2423
+ reaching students across Zimbabwe regardless of school quality or location.
2424
+
2425
+ Platform data (JSON):
2426
+ {json.dumps(stats, ensure_ascii=False)[:6000]}
2427
+
2428
+ Write an equity and access impact report for funders such as UNICEF, the AU, or the Gates Foundation:
2429
+ 1. REACH & INCLUSION (who is using the platform, school diversity)
2430
+ 2. LEARNING EQUITY SIGNALS (are lower-resourced students improving at scale)
2431
+ 3. GENDER AND GEOGRAPHIC OBSERVATIONS (where data permits)
2432
+ 4. BARRIERS TO PROGRESS (what data suggests is still blocking equitable outcomes)
2433
+ 5. SOCIAL IMPACT MEASUREMENT (how to quantify this for grant reporting)
2434
+ 6. RECOMMENDATIONS FOR SCALING WITH EQUITY AT CENTRE
2435
+
2436
+ Tone: mission-driven, rigorous, suitable for development sector grant reports and impact assessments.
2437
+ """,
2438
+ "progress": f"""
2439
+ You are an education data scientist synthesising learning progress data from Marka.
2440
+
2441
+ Platform data (JSON):
2442
+ {json.dumps(stats, ensure_ascii=False)[:6000]}
2443
+
2444
+ Write a learning progress synthesis report:
2445
+ 1. OVERALL PLATFORM LEARNING VELOCITY (how fast are students improving)
2446
+ 2. TOP PERFORMING COHORTS (what distinguishes them)
2447
+ 3. STAGNATION PATTERNS (who is not progressing, and possible reasons)
2448
+ 4. SUBJECT DIFFICULTY LANDSCAPE (which subjects are hardest, which easiest)
2449
+ 5. AI INTERVENTION EFFECTIVENESS (revision sessions, explanations)
2450
+ 6. PREDICTIVE OBSERVATIONS (based on current trends, what outcomes can be expected)
2451
+
2452
+ Tone: analytical, precise, suitable for academic reporting and programme evaluation.
2453
+ """,
2454
+ "full": f"""
2455
+ You are the Chief Learning Officer of Marka writing a comprehensive quarterly platform report
2456
+ for the founding team, board, and key stakeholders.
2457
+
2458
+ Platform data (JSON):
2459
+ {json.dumps(stats, ensure_ascii=False)[:6000]}
2460
+
2461
+ Write a comprehensive report with these sections:
2462
+ 1. EXECUTIVE SUMMARY (5 key numbers, 3 key findings)
2463
+ 2. USER GROWTH & ENGAGEMENT
2464
+ 3. LEARNING OUTCOMES — WHAT IS WORKING
2465
+ 4. LEARNING OUTCOMES — WHAT NEEDS ATTENTION
2466
+ 5. TEACHER EFFECTIVENESS ANALYSIS
2467
+ 6. TOP CURRICULUM ISSUES AND GAPS
2468
+ 7. PLATFORM HEALTH (feedback signals, engagement quality)
2469
+ 8. STRATEGIC OBSERVATIONS (what this data tells us about our product decisions)
2470
+ 9. RECOMMENDED ACTIONS FOR NEXT QUARTER
2471
+
2472
+ Tone: executive-level, data-driven, honest about gaps as well as wins. Suitable for board presentation.
2473
+ """
2474
+ }
2475
+
2476
+ prompt = prompts.get(report_type, prompts["full"])
2477
+ narrative = send_gemini_text(prompt)
2478
+
2479
+ return jsonify({
2480
+ "reportType": report_type,
2481
+ "stats": stats,
2482
+ "narrative": narrative,
2483
+ "generatedAt": datetime.utcnow().isoformat(),
2484
+ })
2485
+
2486
+
2487
+ @app.route("/api/admin/reports/subject/<subject_id>", methods=["GET"])
2488
+ def admin_subject_deep_dive(subject_id):
2489
+ """
2490
+ Deep dive into one subject — performance, topics, learner cohort, AI analysis.
2491
+ """
2492
+ uid = verify_token(request.headers.get("Authorization"))
2493
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
2494
+ try: require_role(uid, ["admin"])
2495
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2496
+
2497
+ attempts = db_ref.child("attempts").get() or {}
2498
+ subj_atts = [a for a in attempts.values() if a.get("subjectId") == subject_id]
2499
+ subjects = db_ref.child("subjects").get() or {}
2500
+ topics = db_ref.child("topics").get() or {}
2501
+ users = db_ref.child("users").get() or {}
2502
+
2503
+ subj_name = subjects.get(subject_id, {}).get("name", subject_id)
2504
+ _, t_stats = _aggregate_attempts(subj_atts)
2505
+
2506
+ topic_breakdown = sorted(
2507
+ [{"topicId": t, "name": topics.get(t,{}).get("name",t),
2508
+ "attempts": s["total"],
2509
+ "percentage": round((s["correct"]/s["total"])*100,1)}
2510
+ for t, s in t_stats.items() if s["total"] >= 3],
2511
+ key=lambda x: x["percentage"]
2512
+ )
2513
+
2514
+ avg = round(sum(a.get("percentage",0) for a in subj_atts)/len(subj_atts),1) if subj_atts else 0
2515
+
2516
+ payload = {
2517
+ "subjectId": subject_id,
2518
+ "subjectName": subj_name,
2519
+ "totalAttempts": len(subj_atts),
2520
+ "uniqueLearners": len(set(a.get("userId") for a in subj_atts)),
2521
+ "averageScore": avg,
2522
+ "topicBreakdown": topic_breakdown,
2523
+ "hardestTopics": topic_breakdown[:5],
2524
+ "easiestTopics": list(reversed(topic_breakdown))[:5],
2525
+ }
2526
+
2527
+ ai_prompt = f"""
2528
+ You are a subject specialist analysing student performance in {subj_name} on the Marka platform.
2529
+
2530
+ Performance data (JSON):
2531
+ {json.dumps(payload, ensure_ascii=False)[:4000]}
2532
+
2533
+ Write a subject performance analysis:
2534
+ 1. SUBJECT OVERVIEW (participation, average performance)
2535
+ 2. TOPIC MASTERY MAP (which topics are well understood vs poorly understood)
2536
+ 3. IDENTIFIED LEARNING BARRIERS (what conceptual gaps underlie the weak topics)
2537
+ 4. TEACHING RECOMMENDATIONS (for teachers of this subject)
2538
+ 5. EXAM READINESS ASSESSMENT (based on current performance, are learners ready)
2539
+
2540
+ Tone: subject-expert, practical, evidence-based.
2541
+ """
2542
+ return jsonify({**payload, "aiNarrative": send_gemini_text(ai_prompt),
2543
+ "generatedAt": datetime.utcnow().isoformat()})
2544
+
2545
+
2546
+ @app.route("/api/admin/reports/feedback-analysis", methods=["GET"])
2547
+ def admin_feedback_analysis():
2548
+ """
2549
+ AI synthesis of all feedback across the platform — themes, issues, sentiment.
2550
+ """
2551
+ uid = verify_token(request.headers.get("Authorization"))
2552
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
2553
+ try: require_role(uid, ["admin"])
2554
+ except PermissionError as e: return jsonify({"error": str(e)}), 403
2555
+
2556
+ feedback = db_ref.child("feedback").get() or {}
2557
+ fb_list = [f for f in feedback.values() if f and f.get("message")]
2558
+ if not fb_list:
2559
+ return jsonify({"message": "No feedback submitted yet.", "items": []}), 200
2560
+
2561
+ # Sample up to 100 most recent for AI analysis
2562
+ fb_sample = sorted(fb_list, key=lambda x: x.get("createdAt",""), reverse=True)[:100]
2563
+ fb_texts = [{"role": f.get("role","?"), "screen": f.get("screen","?"),
2564
+ "rating": f.get("rating"), "message": f.get("message","")}
2565
+ for f in fb_sample]
2566
+
2567
+ # Rating stats
2568
+ rated = [f.get("rating") for f in fb_list if f.get("rating")]
2569
+ avg_rating = round(sum(rated)/len(rated),1) if rated else None
2570
+ by_screen = {}
2571
+ for f in fb_list:
2572
+ s = f.get("screen","unknown")
2573
+ by_screen.setdefault(s, 0)
2574
+ by_screen[s] += 1
2575
+
2576
+ ai_prompt = f"""
2577
+ You are a UX researcher analysing user feedback for Marka, an EdTech platform.
2578
+
2579
+ Feedback sample ({len(fb_texts)} items, JSON):
2580
+ {json.dumps(fb_texts, ensure_ascii=False)[:5000]}
2581
+
2582
+ Write a structured feedback analysis:
2583
+ 1. OVERALL SENTIMENT (positive / neutral / negative ratio, key themes)
2584
+ 2. TOP PRAISED FEATURES (what users love)
2585
+ 3. TOP PAIN POINTS (what frustrates users, with frequency)
2586
+ 4. SCREEN-BY-SCREEN ISSUES (any screens generating disproportionate negative feedback)
2587
+ 5. ROLE-SPECIFIC PATTERNS (do learners, teachers, parents have different issues)
2588
+ 6. PRIORITISED PRODUCT IMPROVEMENTS (ranked by impact and frequency)
2589
+
2590
+ Tone: product team brief. Direct, specific, actionable.
2591
+ """
2592
+ return jsonify({
2593
+ "totalFeedback": len(fb_list),
2594
+ "avgRating": avg_rating,
2595
+ "byScreen": by_screen,
2596
+ "aiNarrative": send_gemini_text(ai_prompt),
2597
+ "recentFeedback": fb_sample[:10],
2598
+ "generatedAt": datetime.utcnow().isoformat(),
2599
+ })
2600
+
2601
  # -----------------------------------------------------------------------------
2602
  # 17. MAIN
2603
  # -----------------------------------------------------------------------------