deploy: sync backend (real general answers, casual short-circuit, classifier, chat resilience)
Browse files- backend/Dockerfile +2 -1
- backend/app/main.py +12 -0
- backend/app/routes/learning_engine.py +17 -0
- backend/app/services/learn_lesson_builder.py +36 -5
backend/Dockerfile
CHANGED
|
@@ -5,7 +5,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 5 |
UPLOAD_DIR=/app/uploads \
|
| 6 |
TTS_OUTPUT_DIR=/app/generated/audio \
|
| 7 |
GENERATED_VIDEO_JOBS_DIR=/app/generated-video-jobs \
|
| 8 |
-
GENERATED_VIDEO_OUTPUT_DIR=/app/generated-videos
|
|
|
|
| 9 |
|
| 10 |
WORKDIR /app
|
| 11 |
|
|
|
|
| 5 |
UPLOAD_DIR=/app/uploads \
|
| 6 |
TTS_OUTPUT_DIR=/app/generated/audio \
|
| 7 |
GENERATED_VIDEO_JOBS_DIR=/app/generated-video-jobs \
|
| 8 |
+
GENERATED_VIDEO_OUTPUT_DIR=/app/generated-videos \
|
| 9 |
+
LEARN_LESSON_CACHE_DIR=/app/generated/learn-anything
|
| 10 |
|
| 11 |
WORKDIR /app
|
| 12 |
|
backend/app/main.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from collections.abc import AsyncIterator
|
| 2 |
from contextlib import asynccontextmanager
|
|
|
|
| 3 |
|
| 4 |
import logging
|
| 5 |
import time
|
|
@@ -778,3 +779,14 @@ app.mount(
|
|
| 778 |
StaticFiles(directory=settings.resolved_generated_video_output_dir, check_dir=False),
|
| 779 |
name="generated-videos",
|
| 780 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from collections.abc import AsyncIterator
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
+
from pathlib import Path
|
| 4 |
|
| 5 |
import logging
|
| 6 |
import time
|
|
|
|
| 779 |
StaticFiles(directory=settings.resolved_generated_video_output_dir, check_dir=False),
|
| 780 |
name="generated-videos",
|
| 781 |
)
|
| 782 |
+
# Learn Anything lesson cache (manifests + per-beat audio). Must live under a
|
| 783 |
+
# writable path on Hugging Face (/app/generated/...), not monorepo public/.
|
| 784 |
+
_learn_lesson_static = (
|
| 785 |
+
Path(__file__).resolve().parents[1] / "generated" / "learn-anything"
|
| 786 |
+
)
|
| 787 |
+
_learn_lesson_static.mkdir(parents=True, exist_ok=True)
|
| 788 |
+
app.mount(
|
| 789 |
+
"/generated/learn-anything",
|
| 790 |
+
StaticFiles(directory=_learn_lesson_static, check_dir=False),
|
| 791 |
+
name="generated-learn-lessons",
|
| 792 |
+
)
|
backend/app/routes/learning_engine.py
CHANGED
|
@@ -196,3 +196,20 @@ def generate_learn_lesson(
|
|
| 196 |
)
|
| 197 |
except LessonBuildError as exc:
|
| 198 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
)
|
| 197 |
except LessonBuildError as exc:
|
| 198 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
| 199 |
+
except Exception as exc:
|
| 200 |
+
# Surface a clear student-safe message instead of the global 500
|
| 201 |
+
# "internal error" envelope (common when cache paths fail on HF).
|
| 202 |
+
import logging
|
| 203 |
+
|
| 204 |
+
logging.getLogger(__name__).exception(
|
| 205 |
+
"Unhandled learn-lesson failure for topic=%s lesson=%s",
|
| 206 |
+
payload.topic,
|
| 207 |
+
payload.lesson_title,
|
| 208 |
+
)
|
| 209 |
+
raise HTTPException(
|
| 210 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 211 |
+
detail=(
|
| 212 |
+
f"Could not prepare this class ({type(exc).__name__}). "
|
| 213 |
+
"Your plan is still saved — retry in a moment."
|
| 214 |
+
),
|
| 215 |
+
) from exc
|
backend/app/services/learn_lesson_builder.py
CHANGED
|
@@ -32,12 +32,15 @@ from dataclasses import dataclass, field
|
|
| 32 |
from pathlib import Path
|
| 33 |
from typing import Any
|
| 34 |
|
| 35 |
-
from app.core.config import PROJECT_ROOT, get_settings
|
| 36 |
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
| 39 |
-
#
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 43 |
# llama-4-scout was retired from Groq; 3.3-70b is the strongest current chat model.
|
|
@@ -757,6 +760,28 @@ def _lesson_lock_for(key: str) -> threading.Lock:
|
|
| 757 |
return lock
|
| 758 |
|
| 759 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
def build_lesson(
|
| 761 |
*,
|
| 762 |
topic: str,
|
|
@@ -783,7 +808,8 @@ def build_lesson(
|
|
| 783 |
else (_env("DOCDOE_TTS_MODEL") or "aura-luna-en")
|
| 784 |
)
|
| 785 |
key = lesson_hash(topic, lesson_title, level, medium, voice)
|
| 786 |
-
|
|
|
|
| 787 |
manifest_path = out_dir / "lesson.json"
|
| 788 |
|
| 789 |
if manifest_path.exists() and not force:
|
|
@@ -806,7 +832,12 @@ def build_lesson(
|
|
| 806 |
context=context,
|
| 807 |
)
|
| 808 |
)
|
| 809 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 810 |
delivery_mode = "reading" if mock_mode else "audio"
|
| 811 |
delivery_notice = (
|
| 812 |
"Local preview: this complete starter class is available in reading mode; no generated voice is being presented as real audio."
|
|
|
|
| 32 |
from pathlib import Path
|
| 33 |
from typing import Any
|
| 34 |
|
| 35 |
+
from app.core.config import BACKEND_DIR, PROJECT_ROOT, get_settings
|
| 36 |
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
| 39 |
+
# Writable cache for lesson manifests + audio. On Hugging Face Docker the
|
| 40 |
+
# backend tree is /app, so PROJECT_ROOT/public is outside the container and
|
| 41 |
+
# not writable (PermissionError → opaque 500). Cache under BACKEND_DIR.
|
| 42 |
+
# Tests may monkeypatch this path.
|
| 43 |
+
PUBLIC_ROOT = BACKEND_DIR / "generated" / "learn-anything"
|
| 44 |
|
| 45 |
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 46 |
# llama-4-scout was retired from Groq; 3.3-70b is the strongest current chat model.
|
|
|
|
| 760 |
return lock
|
| 761 |
|
| 762 |
|
| 763 |
+
def ensure_public_root() -> Path:
|
| 764 |
+
"""Ensure the lesson cache directory exists and is writable."""
|
| 765 |
+
env = _env("LEARN_LESSON_CACHE_DIR")
|
| 766 |
+
root = Path(env) if env else PUBLIC_ROOT
|
| 767 |
+
try:
|
| 768 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 769 |
+
probe = root / ".write_probe"
|
| 770 |
+
probe.write_text("ok", encoding="utf-8")
|
| 771 |
+
probe.unlink(missing_ok=True)
|
| 772 |
+
return root
|
| 773 |
+
except OSError:
|
| 774 |
+
fallback = Path(os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp") / "docdoe-learn-lessons"
|
| 775 |
+
try:
|
| 776 |
+
fallback.mkdir(parents=True, exist_ok=True)
|
| 777 |
+
logger.warning("Lesson cache fell back to %s", fallback)
|
| 778 |
+
return fallback
|
| 779 |
+
except OSError as exc:
|
| 780 |
+
raise LessonBuildError(
|
| 781 |
+
f"Lesson cache directory is not writable ({root}): {exc}"
|
| 782 |
+
) from exc
|
| 783 |
+
|
| 784 |
+
|
| 785 |
def build_lesson(
|
| 786 |
*,
|
| 787 |
topic: str,
|
|
|
|
| 808 |
else (_env("DOCDOE_TTS_MODEL") or "aura-luna-en")
|
| 809 |
)
|
| 810 |
key = lesson_hash(topic, lesson_title, level, medium, voice)
|
| 811 |
+
cache_root = ensure_public_root()
|
| 812 |
+
out_dir = cache_root / key
|
| 813 |
manifest_path = out_dir / "lesson.json"
|
| 814 |
|
| 815 |
if manifest_path.exists() and not force:
|
|
|
|
| 832 |
context=context,
|
| 833 |
)
|
| 834 |
)
|
| 835 |
+
try:
|
| 836 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 837 |
+
except OSError as exc:
|
| 838 |
+
raise LessonBuildError(
|
| 839 |
+
f"Could not create lesson cache folder ({out_dir}): {exc}"
|
| 840 |
+
) from exc
|
| 841 |
delivery_mode = "reading" if mock_mode else "audio"
|
| 842 |
delivery_notice = (
|
| 843 |
"Local preview: this complete starter class is available in reading mode; no generated voice is being presented as real audio."
|