Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions deeptutor/api/routers/kids_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ async def list_available_interactive_books() -> dict:
"id": book.id,
"title": book.title or b_id,
"description": book.description,
"status": book.status.value if hasattr(book.status, "value") else str(book.status),
"status": book.status.value
if hasattr(book.status, "value")
else str(book.status),
"page_count": book.page_count,
"chapter_count": book.chapter_count,
"language": book.language,
Expand All @@ -229,7 +231,10 @@ async def list_available_interactive_books() -> dict:
b["assigned_profile_ids"] = [
a.profile_id
for a in assignments
if (a.book_id == b["id"] or (a.content_type == "interactive_book" and a.document_id == b["id"]))
if (
a.book_id == b["id"]
or (a.content_type == "interactive_book" and a.document_id == b["id"])
)
and a.status == "active"
]

Expand All @@ -243,9 +248,7 @@ class AssignInteractiveBookRequest(BaseModel):


@router.post("/profiles/{profile_id}/interactive-books")
async def assign_interactive_book(
profile_id: str, request: AssignInteractiveBookRequest
) -> dict:
async def assign_interactive_book(profile_id: str, request: AssignInteractiveBookRequest) -> dict:
"""Assign an interactive book from BookEngine to a child profile."""
manager = get_kids_manager()
if manager.get_profile(profile_id) is None:
Expand Down
20 changes: 15 additions & 5 deletions deeptutor/immersive_reading/kids_word_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,12 @@ def _generate_thinking_clue(word: str, definition: str) -> str:
if normalized_definition.startswith(("to ", "to\t")):
return f"Think about an action! Can you guess what someone does when they {word}?"
if normalized_definition.startswith(("a ", "an ", "the ", "something ")):
return f"Picture what this could be in the world! Can you guess what \"{word}\" is?"
if normalized_definition.startswith(("very ", "not ", "feeling ", "having ", "showing ", "full of ")):
return f'Picture what this could be in the world! Can you guess what "{word}" is?'
if normalized_definition.startswith(
("very ", "not ", "feeling ", "having ", "showing ", "full of ")
):
return "Think about describing something! Can you guess what quality this word shows?"
return f"Look closely at the story clues! Can you guess what \"{word}\" means here?"
return f'Look closely at the story clues! Can you guess what "{word}" means here?'


@dataclass(frozen=True)
Expand Down Expand Up @@ -248,7 +250,11 @@ def _concise_chinese(value: str) -> str:
if not value:
return ""
normalized = value.replace("\\n", "\n").replace("\r", "")
lines = [line.strip() for line in normalized.splitlines() if line.strip() and not line.strip().startswith("[")]
lines = [
line.strip()
for line in normalized.splitlines()
if line.strip() and not line.strip().startswith("[")
]
if not lines:
return ""
preferred = lines[0]
Expand All @@ -257,7 +263,11 @@ def _concise_chinese(value: str) -> str:
preferred = line
break
cleaned = _POS_PREFIX_RE.sub("", preferred).strip()
parts = [p.strip() for p in re.split(r"[;,,;]", cleaned) if p.strip() and not p.strip().startswith(("(", "("))]
parts = [
p.strip()
for p in re.split(r"[;,,;]", cleaned)
if p.strip() and not p.strip().startswith(("(", "("))
]
if parts:
return ",".join(parts[:3])
return cleaned
Expand Down
3 changes: 3 additions & 0 deletions deeptutor/services/path_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,9 @@ def get_book_pages_dir(self, book_id: str) -> Path:
def get_book_page_file(self, book_id: str, page_id: str) -> Path:
return self.get_book_pages_dir(book_id) / f"{page_id}.json"

def get_book_learning_captures_file(self, book_id: str) -> Path:
return self.get_book_root(book_id) / "learning_captures.json"

def get_book_assets_dir(self, book_id: str) -> Path:
return self.get_book_root(book_id) / "assets"

Expand Down
12 changes: 3 additions & 9 deletions scripts/build_kids_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,7 @@ def write_database(
"""
)
connection.executemany(
"INSERT INTO entries(word, definition, part_of_speech, phonetic) "
"VALUES (?, ?, ?, ?)",
"INSERT INTO entries(word, definition, part_of_speech, phonetic) VALUES (?, ?, ?, ?)",
(
(item.word, item.definition, item.part_of_speech, item.phonetic)
for item in definitions
Expand Down Expand Up @@ -231,9 +230,7 @@ def main(argv: Sequence[str] | None = None) -> int:
parser.add_argument(
"--output",
type=Path,
default=Path(
"data/user/workspace/immersive_reading/dictionaries/kids_simple.db"
),
default=Path("data/user/workspace/immersive_reading/dictionaries/kids_simple.db"),
)
parser.add_argument(
"--dump",
Expand All @@ -257,10 +254,7 @@ def main(argv: Sequence[str] | None = None) -> int:
if missing:
preview = ", ".join(missing[:30])
suffix = "" if len(missing) <= 30 else ", ..."
print(
f"Skipped {len(missing)} YLE words without a usable definition: "
f"{preview}{suffix}"
)
print(f"Skipped {len(missing)} YLE words without a usable definition: {preview}{suffix}")
return 0


Expand Down
57 changes: 43 additions & 14 deletions tests/immersive_reading/test_kids_interactive_books.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Unit and integration tests for Kids Interactive Books (Math and Digital Books)."""

import json

import time
import pytest

from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest

import deeptutor.api.routers.kids as kids_router_module
import deeptutor.api.routers.kids_admin as kids_admin_router_module
from deeptutor.book.models import (
Block,
BlockType,
Expand All @@ -24,8 +26,6 @@
KidsProfile,
)
from deeptutor.immersive_reading.service import get_kids_manager
import deeptutor.api.routers.kids as kids_router_module
import deeptutor.api.routers.kids_admin as kids_admin_router_module


@pytest.fixture
Expand All @@ -51,8 +51,18 @@ def mock_interactive_book(tmp_path, monkeypatch) -> Book:
book_dir = tmp_path / "book_workspace"
book_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(storage.path_service, "get_book_dir", lambda: book_dir)
monkeypatch.setattr(storage.path_service, "get_book_root", lambda b_id: book_dir / f"book_{b_id}")
monkeypatch.setattr(storage.path_service, "ensure_book_root", lambda b_id: (book_dir / f"book_{b_id}") if (book_dir / f"book_{b_id}").mkdir(parents=True, exist_ok=True) is None else (book_dir / f"book_{b_id}"))
monkeypatch.setattr(
storage.path_service, "get_book_root", lambda b_id: book_dir / f"book_{b_id}"
)
monkeypatch.setattr(
storage.path_service,
"ensure_book_root",
lambda b_id: (
(book_dir / f"book_{b_id}")
if (book_dir / f"book_{b_id}").mkdir(parents=True, exist_ok=True) is None
else (book_dir / f"book_{b_id}")
),
)

book_id = "math_fun_01"
book = Book(
Expand Down Expand Up @@ -111,7 +121,10 @@ def mock_interactive_book(tmp_path, monkeypatch) -> Book:
id="blk_anim_1",
type=BlockType.ANIMATION,
title="图形变变变",
payload={"video_url": f"book_{book_id}/assets/triangles.mp4", "caption": "看三角形怎么拼成正方形"},
payload={
"video_url": f"book_{book_id}/assets/triangles.mp4",
"caption": "看三角形怎么拼成正方形",
},
),
Block(
id="blk_quiz_1",
Expand Down Expand Up @@ -162,7 +175,9 @@ def kids_client(clean_kids_manager, sample_profile, mock_interactive_book) -> Te
return TestClient(app)


def test_kids_manager_assign_and_progress(clean_kids_manager, sample_profile, mock_interactive_book):
def test_kids_manager_assign_and_progress(
clean_kids_manager, sample_profile, mock_interactive_book
):
"""Verify assignment and progress tracking on KidsManager layer."""
assignment = clean_kids_manager.assign_interactive_book(
sample_profile.id,
Expand Down Expand Up @@ -230,7 +245,9 @@ def test_kids_manager_assign_and_progress(clean_kids_manager, sample_profile, mo
assert prog.total_stars == 3


def test_child_interactive_book_endpoints(kids_client, sample_profile, clean_kids_manager, mock_interactive_book):
def test_child_interactive_book_endpoints(
kids_client, sample_profile, clean_kids_manager, mock_interactive_book
):
"""Verify child endpoints: book manifest, sanitized page, quiz grading."""
# 1. Assign book to profile
clean_kids_manager.assign_interactive_book(
Expand All @@ -240,7 +257,9 @@ def test_child_interactive_book_endpoints(kids_client, sample_profile, clean_kid
)

# 2. Get device token for child
auth_resp = kids_client.post("/api/v1/kids/select-profile", json={"profile_id": sample_profile.id})
auth_resp = kids_client.post(
"/api/v1/kids/select-profile", json={"profile_id": sample_profile.id}
)
assert auth_resp.status_code == 200
token = auth_resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
Expand All @@ -254,13 +273,17 @@ def test_child_interactive_book_endpoints(kids_client, sample_profile, clean_kid
assert library[0]["book"]["title"] == "趣味数学:数与图形"

# 4. Get interactive book detail
book_resp = kids_client.get(f"/api/v1/kids/interactive-books/{mock_interactive_book.id}", headers=headers)
book_resp = kids_client.get(
f"/api/v1/kids/interactive-books/{mock_interactive_book.id}", headers=headers
)
assert book_resp.status_code == 200
assert book_resp.json()["book"]["title"] == "趣味数学:数与图形"
assert len(book_resp.json()["spine"]["chapters"]) == 1

# 5. Get page content — answers MUST be stripped from quiz block for child safety
page_resp = kids_client.get(f"/api/v1/kids/interactive-books/{mock_interactive_book.id}/pages/pg_01", headers=headers)
page_resp = kids_client.get(
f"/api/v1/kids/interactive-books/{mock_interactive_book.id}/pages/pg_01", headers=headers
)
assert page_resp.status_code == 200
page_data = page_resp.json()["page"]
quiz_blk = next(b for b in page_data["blocks"] if b["type"] == "quiz")
Expand Down Expand Up @@ -290,7 +313,9 @@ def test_child_interactive_book_endpoints(kids_client, sample_profile, clean_kid
assert "三角形有3条边" in grade["per_question"][0]["explanation"]


def test_parent_admin_interactive_books(kids_client, sample_profile, clean_kids_manager, mock_interactive_book):
def test_parent_admin_interactive_books(
kids_client, sample_profile, clean_kids_manager, mock_interactive_book
):
"""Verify parent management endpoints for listing and assigning interactive books."""
# List available books in BookEngine
list_resp = kids_client.get("/api/v1/kids-admin/available-books")
Expand All @@ -302,7 +327,11 @@ def test_parent_admin_interactive_books(kids_client, sample_profile, clean_kids_
# Assign to profile
assign_resp = kids_client.post(
f"/api/v1/kids-admin/profiles/{sample_profile.id}/interactive-books",
json={"book_id": mock_interactive_book.id, "title": "自定义数学书名", "available_through_page_order": 0},
json={
"book_id": mock_interactive_book.id,
"title": "自定义数学书名",
"available_through_page_order": 0,
},
)
assert assign_resp.status_code == 200
assignment = assign_resp.json()["assignment"]
Expand Down
3 changes: 1 addition & 2 deletions tests/immersive_reading/test_kids_word_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ def test_simple_wiktionary_parser_extracts_child_readable_definition():

assert parsed is not None
assert parsed.definition == (
"A picture is marks on a flat object, like paper, that show shapes, "
"people, or things."
"A picture is marks on a flat object, like paper, that show shapes, people, or things."
)
assert parsed.part_of_speech == "noun"
assert parsed.phonetic == "/ˈpɪktʃɚ/"
Expand Down
Loading
Loading