Update main.py
Browse files
main.py
CHANGED
|
@@ -1648,8 +1648,294 @@ def image_proxy():
|
|
| 1648 |
return jsonify({"error": "Internal server error processing the image request."}), 500
|
| 1649 |
|
| 1650 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1651 |
# -----------------------------------------------------------------------------
|
| 1652 |
-
#
|
| 1653 |
# -----------------------------------------------------------------------------
|
| 1654 |
|
| 1655 |
if __name__ == "__main__":
|
|
|
|
| 1648 |
return jsonify({"error": "Internal server error processing the image request."}), 500
|
| 1649 |
|
| 1650 |
|
| 1651 |
+
# =============================================================================
|
| 1652 |
+
# 11. LEARNING PATH MODULE (NEW ADDITION)
|
| 1653 |
+
# =============================================================================
|
| 1654 |
+
# This module implements the "Overlay Architecture".
|
| 1655 |
+
# It fetches static structure from the Data API and overlays user progress from Firebase.
|
| 1656 |
+
|
| 1657 |
+
# --- Configuration ---
|
| 1658 |
+
|
| 1659 |
+
DATA_API_BASE_URL = "https://rairo-marka-data-api.hf.space/v1"
|
| 1660 |
+
|
| 1661 |
+
# Registry mapping your App IDs to the Data API Filename IDs (Based on your screenshots)
|
| 1662 |
+
PATH_SUBJECT_MAP = {
|
| 1663 |
+
# --- A Level ---
|
| 1664 |
+
"al_acc": "A_9706", # Accounting
|
| 1665 |
+
"al_bio": "A_9700", # Biology
|
| 1666 |
+
"al_bus": "A_9609", # Business
|
| 1667 |
+
"al_chem": "A_9701", # Chemistry
|
| 1668 |
+
"al_cs": "A_9618", # Computer Science
|
| 1669 |
+
"al_eco": "A_9708", # Economics
|
| 1670 |
+
"al_fmath": "A_9231", # Further Mathematics
|
| 1671 |
+
"al_hist": "A_9489", # History
|
| 1672 |
+
"al_lit": "A_9695", # Literature in English
|
| 1673 |
+
"al_math": "A_9709", # Mathematics
|
| 1674 |
+
"al_phy": "A_9702", # Physics
|
| 1675 |
+
"al_soc": "A_9699", # Sociology
|
| 1676 |
+
"al_travel": "A_9395", # Travel & Tourism
|
| 1677 |
+
|
| 1678 |
+
# --- O Level (IGCSE) ---
|
| 1679 |
+
"ol_acc": "O_0452", # Accounting
|
| 1680 |
+
"ol_bio": "O_0610", # Biology
|
| 1681 |
+
"ol_bus": "O_0450", # Business Studies
|
| 1682 |
+
"ol_chem": "O_0620", # Chemistry
|
| 1683 |
+
"ol_cs": "O_0478", # Computer Science
|
| 1684 |
+
"ol_eng": "O_0500", # English Language
|
| 1685 |
+
"ol_lit": "O_0475", # English Literature
|
| 1686 |
+
"ol_env": "O_0680", # Environmental Management
|
| 1687 |
+
"ol_geo": "O_0460", # Geography
|
| 1688 |
+
"ol_hist": "O_0470", # History
|
| 1689 |
+
"ol_phy": "O_0625", # Physics
|
| 1690 |
+
}
|
| 1691 |
+
|
| 1692 |
+
# --- Helpers ---
|
| 1693 |
+
|
| 1694 |
+
def fetch_remote_syllabus(remote_id):
|
| 1695 |
+
"""Fetches the static JSON tree from the Data API."""
|
| 1696 |
+
try:
|
| 1697 |
+
url = f"{DATA_API_BASE_URL}/structure/{remote_id}"
|
| 1698 |
+
resp = requests.get(url, timeout=10)
|
| 1699 |
+
if resp.status_code == 200:
|
| 1700 |
+
return resp.json()
|
| 1701 |
+
logger.error(f"Data API Error {resp.status_code}: {resp.text}")
|
| 1702 |
+
return None
|
| 1703 |
+
except Exception as e:
|
| 1704 |
+
logger.error(f"Failed to connect to Data API: {e}")
|
| 1705 |
+
return None
|
| 1706 |
+
|
| 1707 |
+
def search_remote_context(query, remote_id):
|
| 1708 |
+
"""Searches the Data API for specific syllabus content to ground the AI."""
|
| 1709 |
+
try:
|
| 1710 |
+
url = f"{DATA_API_BASE_URL}/search"
|
| 1711 |
+
payload = {
|
| 1712 |
+
"query": query,
|
| 1713 |
+
"filter_subject_id": remote_id
|
| 1714 |
+
}
|
| 1715 |
+
resp = requests.post(url, json=payload, timeout=10)
|
| 1716 |
+
if resp.status_code == 200:
|
| 1717 |
+
return resp.json().get("results", [])
|
| 1718 |
+
return []
|
| 1719 |
+
except Exception as e:
|
| 1720 |
+
logger.error(f"Context search failed: {e}")
|
| 1721 |
+
return []
|
| 1722 |
+
|
| 1723 |
+
# --- Endpoints ---
|
| 1724 |
+
|
| 1725 |
+
@app.route("/api/path/tree", methods=["GET"])
|
| 1726 |
+
def get_path_tree():
|
| 1727 |
+
"""
|
| 1728 |
+
Returns the full syllabus tree with 'locked', 'unlocked', or 'completed' status.
|
| 1729 |
+
Query Param: subjectId (e.g., 'al_phy')
|
| 1730 |
+
"""
|
| 1731 |
+
uid = verify_token(request.headers.get("Authorization"))
|
| 1732 |
+
if not uid:
|
| 1733 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 1734 |
+
|
| 1735 |
+
subject_id = request.args.get("subjectId")
|
| 1736 |
+
if not subject_id or subject_id not in PATH_SUBJECT_MAP:
|
| 1737 |
+
return jsonify({"error": "Invalid or unsupported subjectId"}), 400
|
| 1738 |
+
|
| 1739 |
+
remote_id = PATH_SUBJECT_MAP[subject_id]
|
| 1740 |
+
|
| 1741 |
+
# 1. Get Static Map
|
| 1742 |
+
syllabus_data = fetch_remote_syllabus(remote_id)
|
| 1743 |
+
if not syllabus_data:
|
| 1744 |
+
return jsonify({"error": "Syllabus data currently unavailable"}), 503
|
| 1745 |
+
|
| 1746 |
+
# 2. Get User Progress
|
| 1747 |
+
# Stored as: users/{uid}/path_progress/{subject_id}/{node_id} = timestamp
|
| 1748 |
+
progress_ref = db_ref.child(f"users/{uid}/path_progress/{subject_id}")
|
| 1749 |
+
user_progress = progress_ref.get() or {}
|
| 1750 |
+
|
| 1751 |
+
# 3. Recursive Overlay Logic
|
| 1752 |
+
def enrich_node(node, parent_is_completed):
|
| 1753 |
+
node_id = node.get("id")
|
| 1754 |
+
|
| 1755 |
+
# Check completion status
|
| 1756 |
+
completed_timestamp = user_progress.get(node_id)
|
| 1757 |
+
is_completed = completed_timestamp is not None
|
| 1758 |
+
|
| 1759 |
+
# Determine State
|
| 1760 |
+
status = "locked"
|
| 1761 |
+
if is_completed:
|
| 1762 |
+
status = "completed"
|
| 1763 |
+
elif parent_is_completed:
|
| 1764 |
+
# If parent is done (or it's a root node), this is the next step
|
| 1765 |
+
status = "unlocked"
|
| 1766 |
+
|
| 1767 |
+
enriched = {
|
| 1768 |
+
"id": node_id,
|
| 1769 |
+
"title": node.get("title"),
|
| 1770 |
+
"type": node.get("type"),
|
| 1771 |
+
"status": status,
|
| 1772 |
+
"isCompleted": is_completed
|
| 1773 |
+
}
|
| 1774 |
+
|
| 1775 |
+
# Handle Children
|
| 1776 |
+
if "children" in node:
|
| 1777 |
+
# Children are only accessible if THIS node is completed
|
| 1778 |
+
# (Or if it's a container 'topic' that doesn't require work itself, pass parent state)
|
| 1779 |
+
# Strategy: Topics (Folders) are 'completed' if any child is done?
|
| 1780 |
+
# Simple Strategy: Flow down. Children unlock if parent is completed.
|
| 1781 |
+
enriched["children"] = [
|
| 1782 |
+
enrich_node(child, parent_is_completed=is_completed or (node.get("type") == "topic" and parent_is_completed))
|
| 1783 |
+
for child in node["children"]
|
| 1784 |
+
]
|
| 1785 |
+
|
| 1786 |
+
return enriched
|
| 1787 |
+
|
| 1788 |
+
# 4. Process Root
|
| 1789 |
+
# The API returns { meta: {...}, tree: [...] }
|
| 1790 |
+
raw_tree = syllabus_data.get("tree", [])
|
| 1791 |
+
# Root nodes are always unlocked
|
| 1792 |
+
enriched_tree = [enrich_node(root, parent_is_completed=True) for root in raw_tree]
|
| 1793 |
+
|
| 1794 |
+
return jsonify({
|
| 1795 |
+
"subjectId": subject_id,
|
| 1796 |
+
"remoteId": remote_id,
|
| 1797 |
+
"tree": enriched_tree
|
| 1798 |
+
})
|
| 1799 |
+
|
| 1800 |
+
|
| 1801 |
+
@app.route("/api/path/generate-lesson", methods=["POST"])
|
| 1802 |
+
def generate_path_lesson():
|
| 1803 |
+
"""
|
| 1804 |
+
Generates a revision lesson grounded in the specific syllabus point.
|
| 1805 |
+
Body: { subjectId, nodeId, nodeTitle }
|
| 1806 |
+
"""
|
| 1807 |
+
uid = verify_token(request.headers.get("Authorization"))
|
| 1808 |
+
if not uid:
|
| 1809 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 1810 |
+
|
| 1811 |
+
data = request.get_json() or {}
|
| 1812 |
+
subject_id = data.get("subjectId")
|
| 1813 |
+
node_id = data.get("nodeId") # e.g. "A_9702_1.1"
|
| 1814 |
+
node_title = data.get("nodeTitle")
|
| 1815 |
+
|
| 1816 |
+
if not subject_id or not node_title:
|
| 1817 |
+
return jsonify({"error": "Missing fields"}), 400
|
| 1818 |
+
|
| 1819 |
+
remote_id = PATH_SUBJECT_MAP.get(subject_id)
|
| 1820 |
+
|
| 1821 |
+
# 1. Retrieve Context
|
| 1822 |
+
# We search specifically for the title to get the exact syllabus bullet points
|
| 1823 |
+
context_chunks = search_remote_context(node_title, remote_id)
|
| 1824 |
+
context_text = "\n".join([c.get("content", "") for c in context_chunks[:3]])
|
| 1825 |
+
|
| 1826 |
+
# 2. AI Generation
|
| 1827 |
+
prompt = f"""
|
| 1828 |
+
You are an expert tutor. Create a concise revision lesson.
|
| 1829 |
+
|
| 1830 |
+
Topic: {node_title}
|
| 1831 |
+
Syllabus Context (Strictly adhere to this scope):
|
| 1832 |
+
{context_text}
|
| 1833 |
+
|
| 1834 |
+
Structure:
|
| 1835 |
+
1. **Concept Summary**: Simple explanation (2 paragraphs).
|
| 1836 |
+
2. **Key Formulas/Definitions**: If applicable.
|
| 1837 |
+
3. **Worked Example**: A typical exam-style problem with solution.
|
| 1838 |
+
4. **Common Pitfalls**: What do students usually get wrong?
|
| 1839 |
+
|
| 1840 |
+
Format using Markdown.
|
| 1841 |
+
"""
|
| 1842 |
+
|
| 1843 |
+
lesson_text = send_gemini_text(prompt)
|
| 1844 |
+
|
| 1845 |
+
return jsonify({
|
| 1846 |
+
"nodeId": node_id,
|
| 1847 |
+
"lessonContent": lesson_text
|
| 1848 |
+
})
|
| 1849 |
+
|
| 1850 |
+
|
| 1851 |
+
@app.route("/api/path/generate-quiz", methods=["POST"])
|
| 1852 |
+
def generate_path_quiz():
|
| 1853 |
+
"""
|
| 1854 |
+
Generates a quiz to test mastery of a node.
|
| 1855 |
+
Body: { subjectId, nodeId, nodeTitle }
|
| 1856 |
+
"""
|
| 1857 |
+
uid = verify_token(request.headers.get("Authorization"))
|
| 1858 |
+
if not uid:
|
| 1859 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 1860 |
+
|
| 1861 |
+
data = request.get_json() or {}
|
| 1862 |
+
subject_id = data.get("subjectId")
|
| 1863 |
+
node_title = data.get("nodeTitle")
|
| 1864 |
+
remote_id = PATH_SUBJECT_MAP.get(subject_id)
|
| 1865 |
+
|
| 1866 |
+
# 1. Retrieve Context
|
| 1867 |
+
context_chunks = search_remote_context(node_title, remote_id)
|
| 1868 |
+
context_text = "\n".join([c.get("content", "") for c in context_chunks[:3]])
|
| 1869 |
+
|
| 1870 |
+
# 2. AI Generation
|
| 1871 |
+
prompt = f"""
|
| 1872 |
+
Generate 5 Multiple Choice Questions to test: {node_title}
|
| 1873 |
+
Use this syllabus context as ground truth:
|
| 1874 |
+
{context_text}
|
| 1875 |
+
|
| 1876 |
+
Return JSON ONLY:
|
| 1877 |
+
{{
|
| 1878 |
+
"questions": [
|
| 1879 |
+
{{
|
| 1880 |
+
"stem": "Question text...",
|
| 1881 |
+
"options": ["A", "B", "C", "D"],
|
| 1882 |
+
"answer": "Correct Option Text"
|
| 1883 |
+
}}
|
| 1884 |
+
]
|
| 1885 |
+
}}
|
| 1886 |
+
"""
|
| 1887 |
+
|
| 1888 |
+
raw = send_gemini_text(prompt)
|
| 1889 |
+
try:
|
| 1890 |
+
clean = raw.replace("```json", "").replace("```", "").strip()
|
| 1891 |
+
quiz_data = json.loads(clean)
|
| 1892 |
+
except Exception as e:
|
| 1893 |
+
logger.error(f"Failed to parse quiz JSON: {raw}")
|
| 1894 |
+
return jsonify({"error": "Failed to generate quiz"}), 500
|
| 1895 |
+
|
| 1896 |
+
return jsonify(quiz_data)
|
| 1897 |
+
|
| 1898 |
+
|
| 1899 |
+
@app.route("/api/path/submit-node", methods=["POST"])
|
| 1900 |
+
def submit_path_node():
|
| 1901 |
+
"""
|
| 1902 |
+
Records success on a node, unlocking the next step.
|
| 1903 |
+
Body: { subjectId, nodeId, score, total }
|
| 1904 |
+
"""
|
| 1905 |
+
uid = verify_token(request.headers.get("Authorization"))
|
| 1906 |
+
if not uid:
|
| 1907 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 1908 |
+
|
| 1909 |
+
data = request.get_json() or {}
|
| 1910 |
+
subject_id = data.get("subjectId")
|
| 1911 |
+
node_id = data.get("nodeId")
|
| 1912 |
+
score = data.get("score", 0)
|
| 1913 |
+
total = data.get("total", 1)
|
| 1914 |
+
|
| 1915 |
+
if not subject_id or not node_id:
|
| 1916 |
+
return jsonify({"error": "Missing fields"}), 400
|
| 1917 |
+
|
| 1918 |
+
percentage = (score / total) * 100
|
| 1919 |
+
passed = percentage >= 70 # Pass threshold
|
| 1920 |
+
|
| 1921 |
+
if passed:
|
| 1922 |
+
# Mark as complete in Firebase
|
| 1923 |
+
# We record the timestamp of completion
|
| 1924 |
+
db_ref.child(f"users/{uid}/path_progress/{subject_id}/{node_id}").set(int(time.time()))
|
| 1925 |
+
|
| 1926 |
+
return jsonify({
|
| 1927 |
+
"success": True,
|
| 1928 |
+
"passed": True,
|
| 1929 |
+
"message": "Node completed! Next topic unlocked."
|
| 1930 |
+
})
|
| 1931 |
+
else:
|
| 1932 |
+
return jsonify({
|
| 1933 |
+
"success": True,
|
| 1934 |
+
"passed": False,
|
| 1935 |
+
"message": "Score too low. Review the lesson and try again."
|
| 1936 |
+
})
|
| 1937 |
# -----------------------------------------------------------------------------
|
| 1938 |
+
# 12. MAIN
|
| 1939 |
# -----------------------------------------------------------------------------
|
| 1940 |
|
| 1941 |
if __name__ == "__main__":
|