Update main.py
Browse files
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-
|
| 72 |
-
MULTIMODAL_MODEL = "gemini-
|
| 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 |
# -----------------------------------------------------------------------------
|