deploy: sync backend (real general answers, casual short-circuit, classifier, chat resilience)
Browse files
backend/app/services/learn_lesson_builder.py
CHANGED
|
@@ -36,10 +36,11 @@ 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.
|
| 40 |
-
#
|
| 41 |
-
# not
|
| 42 |
-
#
|
|
|
|
| 43 |
PUBLIC_ROOT = BACKEND_DIR / "generated" / "learn-anything"
|
| 44 |
|
| 45 |
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
@@ -761,25 +762,49 @@ def _lesson_lock_for(key: str) -> threading.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 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
try:
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
except OSError as exc:
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
|
|
|
|
|
|
|
|
|
| 783 |
|
| 784 |
|
| 785 |
def build_lesson(
|
|
@@ -835,9 +860,26 @@ def build_lesson(
|
|
| 835 |
try:
|
| 836 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 837 |
except OSError as exc:
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."
|
|
|
|
| 36 |
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
| 39 |
+
# Writable cache for lesson manifests + audio.
|
| 40 |
+
# NEVER use PROJECT_ROOT/"public" β on HF Docker WORKDIR=/app, that resolves to
|
| 41 |
+
# /public which is not creatable (Permission denied β 500).
|
| 42 |
+
# Prefer: LEARN_LESSON_CACHE_DIR β /app/generated/... β backend/generated/...
|
| 43 |
+
# Tests may monkeypatch PUBLIC_ROOT.
|
| 44 |
PUBLIC_ROOT = BACKEND_DIR / "generated" / "learn-anything"
|
| 45 |
|
| 46 |
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
|
|
| 762 |
|
| 763 |
|
| 764 |
def ensure_public_root() -> Path:
|
| 765 |
+
"""Ensure the lesson cache directory exists and is writable on HF + local."""
|
| 766 |
+
candidates: list[Path] = []
|
| 767 |
env = _env("LEARN_LESSON_CACHE_DIR")
|
| 768 |
+
if env:
|
| 769 |
+
candidates.append(Path(env))
|
| 770 |
+
# HF Docker WORKDIR is /app (backend tree).
|
| 771 |
+
candidates.append(Path("/app/generated/learn-anything"))
|
| 772 |
+
# Monorepo / local backend package root.
|
| 773 |
+
candidates.append(BACKEND_DIR / "generated" / "learn-anything")
|
| 774 |
+
# Tests monkeypatch PUBLIC_ROOT β honour it if already set to a temp path.
|
| 775 |
+
if PUBLIC_ROOT not in candidates:
|
| 776 |
+
candidates.insert(0, PUBLIC_ROOT)
|
| 777 |
+
# Last-resort temp (always writable for reading-mode fallbacks).
|
| 778 |
+
candidates.append(
|
| 779 |
+
Path(os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp")
|
| 780 |
+
/ "docdoe-learn-lessons"
|
| 781 |
+
)
|
| 782 |
+
|
| 783 |
+
errors: list[str] = []
|
| 784 |
+
for root in candidates:
|
| 785 |
+
# Never attempt to create absolute /public (HF permission bomb).
|
| 786 |
try:
|
| 787 |
+
resolved = root.resolve()
|
| 788 |
+
except OSError:
|
| 789 |
+
resolved = root
|
| 790 |
+
if str(resolved).startswith("/public") or str(resolved) == "/public":
|
| 791 |
+
errors.append(f"skip forbidden path {root}")
|
| 792 |
+
continue
|
| 793 |
+
try:
|
| 794 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 795 |
+
probe = root / ".write_probe"
|
| 796 |
+
probe.write_text("ok", encoding="utf-8")
|
| 797 |
+
probe.unlink(missing_ok=True)
|
| 798 |
+
if root != PUBLIC_ROOT:
|
| 799 |
+
logger.info("Lesson cache using %s", root)
|
| 800 |
+
return root
|
| 801 |
except OSError as exc:
|
| 802 |
+
errors.append(f"{root}: {exc}")
|
| 803 |
+
continue
|
| 804 |
+
|
| 805 |
+
raise LessonBuildError(
|
| 806 |
+
"Lesson cache directory is not writable. Tried: " + "; ".join(errors)
|
| 807 |
+
)
|
| 808 |
|
| 809 |
|
| 810 |
def build_lesson(
|
|
|
|
| 860 |
try:
|
| 861 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 862 |
except OSError as exc:
|
| 863 |
+
# Hard fallback: rebuild under /tmp so students still get a class.
|
| 864 |
+
emergency = (
|
| 865 |
+
Path(os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp")
|
| 866 |
+
/ "docdoe-learn-lessons"
|
| 867 |
+
/ key
|
| 868 |
+
)
|
| 869 |
+
try:
|
| 870 |
+
emergency.mkdir(parents=True, exist_ok=True)
|
| 871 |
+
out_dir = emergency
|
| 872 |
+
manifest_path = out_dir / "lesson.json"
|
| 873 |
+
logger.warning(
|
| 874 |
+
"Lesson cache mkdir failed (%s); using emergency path %s",
|
| 875 |
+
exc,
|
| 876 |
+
out_dir,
|
| 877 |
+
)
|
| 878 |
+
except OSError as exc2:
|
| 879 |
+
raise LessonBuildError(
|
| 880 |
+
f"Could not create lesson cache folder ({out_dir}): {exc}; "
|
| 881 |
+
f"emergency also failed: {exc2}"
|
| 882 |
+
) from exc2
|
| 883 |
delivery_mode = "reading" if mock_mode else "audio"
|
| 884 |
delivery_notice = (
|
| 885 |
"Local preview: this complete starter class is available in reading mode; no generated voice is being presented as real audio."
|