Ship native iOS app, Wiki/TTS backend, and skeleton loading UX.

Replace the WebView shell with SwiftUI screens, add account-scoped Wiki and TTS APIs with adaptive review and photo scan support, and keep web/iOS pages usable while data loads asynchronously.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-08 15:13:59 +08:00
parent db5465c646
commit 05e173b293
115 changed files with 12709 additions and 1212 deletions
@@ -0,0 +1,347 @@
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.adaptive_review_service import (
adaptive_review_service,
response_quality,
retrievability,
utc_iso,
)
def make_word(
word_id: int,
*,
stability_hours: float = 24.0,
difficulty: float = 5.0,
elapsed_hours: float = 24.0,
next_review_offset_hours: float = 0.0,
status: str = "learning",
wrong_count: int = 0,
correct_count: int = 0,
mastery_score: int = 50,
train_count: int = 0,
error_bank_member: int = 0,
error_reinforce_streak: int = 0,
):
now = datetime(2026, 6, 6, 12, tzinfo=timezone.utc)
return SimpleNamespace(
id=word_id,
stability_hours=stability_hours,
difficulty=difficulty,
last_reviewed_at=utc_iso(now - timedelta(hours=elapsed_hours)),
created_at=utc_iso(now - timedelta(days=10)),
next_review_at=utc_iso(now + timedelta(hours=next_review_offset_hours)),
review_due_date=now.strftime("%Y-%m-%d"),
status=status,
wrong_count=wrong_count,
correct_count=correct_count,
mastery_score=mastery_score,
train_count=train_count,
error_bank_member=error_bank_member,
error_reinforce_streak=error_reinforce_streak,
)
class AdaptiveReviewServiceTest(unittest.TestCase):
def setUp(self):
self.now = datetime(2026, 6, 6, 12, tzinfo=timezone.utc)
def test_fast_unassisted_recall_extends_interval(self):
word = make_word(1)
update = adaptive_review_service.update_word(
word,
is_correct=True,
attempts=1,
hints_used=0,
duration_seconds=6,
now=self.now,
)
self.assertEqual(update.quality, 1.0)
self.assertGreater(update.stability_hours, 70)
self.assertGreater(update.interval_hours, 10)
self.assertLess(update.difficulty, 5.0)
def test_hints_and_slow_response_reduce_growth(self):
fast_word = make_word(1)
helped_word = make_word(2)
fast = adaptive_review_service.update_word(
fast_word,
is_correct=True,
attempts=1,
hints_used=0,
duration_seconds=6,
now=self.now,
)
helped = adaptive_review_service.update_word(
helped_word,
is_correct=True,
attempts=3,
hints_used=2,
duration_seconds=75,
now=self.now,
)
self.assertLess(
response_quality(
is_correct=True,
attempts=3,
hints_used=2,
duration_seconds=75,
),
fast.quality,
)
self.assertLess(helped.stability_hours, fast.stability_hours)
self.assertLess(helped.interval_hours, fast.interval_hours)
def test_failed_recall_returns_soon(self):
word = make_word(1, stability_hours=72)
update = adaptive_review_service.update_word(
word,
is_correct=False,
attempts=4,
hints_used=4,
duration_seconds=30,
now=self.now,
)
self.assertEqual(update.interval_hours, 0.25)
self.assertGreater(update.difficulty, 5.0)
self.assertLess(update.stability_hours, 72)
def test_overdue_weak_word_is_selected_before_future_mastered_word(self):
weak = make_word(
1,
difficulty=8,
elapsed_hours=72,
next_review_offset_hours=-24,
status="weak",
)
mastered = make_word(
2,
stability_hours=240,
difficulty=2,
elapsed_hours=2,
next_review_offset_hours=72,
status="mastered",
)
selected = adaptive_review_service.select_words(
[mastered, weak], limit=2, now=self.now
)
self.assertEqual([word.id for word in selected], [1, 2])
self.assertLess(retrievability(weak, self.now), retrievability(mastered, self.now))
def test_zero_correct_wrong_word_outranks_recent_mastered(self):
never_correct = make_word(
1,
status="weak",
wrong_count=3,
correct_count=0,
mastery_score=0,
next_review_offset_hours=-1,
)
recent_ok = make_word(
2,
status="mastered",
wrong_count=0,
correct_count=2,
mastery_score=100,
elapsed_hours=1,
stability_hours=240,
next_review_offset_hours=48,
)
selected = adaptive_review_service.select_words(
[recent_ok, never_correct], limit=2, now=self.now
)
self.assertEqual([word.id for word in selected], [1, 2])
def test_is_error_priority_covers_weak_and_wrong_heavy(self):
self.assertTrue(
adaptive_review_service.is_error_priority(
make_word(1, status="weak", wrong_count=1, correct_count=0)
)
)
self.assertTrue(
adaptive_review_service.is_error_priority(
make_word(2, status="mastered", wrong_count=2, correct_count=1)
)
)
self.assertFalse(
adaptive_review_service.is_error_priority(
make_word(
3,
status="learning",
wrong_count=0,
correct_count=2,
mastery_score=100,
train_count=2,
)
)
)
def test_plan_completed_excludes_clean_success_from_practice_plan(self):
completed = make_word(
1,
status="learning",
wrong_count=0,
correct_count=2,
mastery_score=100,
train_count=2,
)
pending = make_word(
2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0
)
weak = make_word(
3,
status="weak",
wrong_count=2,
correct_count=0,
mastery_score=0,
train_count=2,
error_bank_member=1,
)
self.assertTrue(adaptive_review_service.is_plan_completed(completed))
self.assertFalse(adaptive_review_service.is_plan_eligible(completed))
self.assertTrue(adaptive_review_service.is_plan_eligible(pending))
self.assertTrue(adaptive_review_service.is_plan_eligible(weak))
selected = adaptive_review_service.select_practice_plan_words(
[completed, pending, weak], limit=3, now=self.now
)
self.assertEqual([word.id for word in selected], [2, 3])
def test_untrained_excludes_error_bank_members_without_train_count(self):
"""错题库成员不应出现在未练词池,即使 train_count 仍为 0。"""
pending = make_word(
1, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0
)
migrated_error = make_word(
2,
status="weak",
wrong_count=1,
correct_count=0,
mastery_score=0,
train_count=0,
error_bank_member=1,
error_reinforce_streak=0,
)
untrained, reinforce = adaptive_review_service.split_plan_pools(
[pending, migrated_error]
)
self.assertEqual([w.id for w in untrained], [1])
self.assertEqual([w.id for w in reinforce], [2])
selected = adaptive_review_service.select_untrained_words(
[pending, migrated_error], limit=5, now=self.now
)
self.assertEqual([word.id for word in selected], [1])
def test_select_untrained_words_only_returns_new_words(self):
completed = make_word(
1,
status="learning",
wrong_count=0,
correct_count=2,
mastery_score=100,
train_count=2,
)
pending = make_word(
2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0
)
weak = make_word(
3,
status="weak",
wrong_count=2,
correct_count=0,
mastery_score=0,
train_count=2,
error_bank_member=1,
)
selected = adaptive_review_service.select_untrained_words(
[completed, pending, weak], limit=5, now=self.now
)
self.assertEqual([word.id for word in selected], [2])
def test_select_error_words_only_returns_trained_plan_words(self):
completed = make_word(
1,
status="learning",
wrong_count=0,
correct_count=2,
mastery_score=100,
train_count=2,
)
pending = make_word(
2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0
)
weak = make_word(
3,
status="weak",
wrong_count=2,
correct_count=0,
mastery_score=0,
train_count=2,
error_bank_member=1,
error_reinforce_streak=0,
)
selected = adaptive_review_service.select_error_words(
[completed, pending, weak], limit=5, now=self.now
)
self.assertEqual([word.id for word in selected], [3])
def test_count_plan_pools(self):
completed = make_word(
1,
status="learning",
wrong_count=0,
correct_count=2,
mastery_score=100,
train_count=2,
)
pending = make_word(
2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0
)
weak = make_word(
3,
status="weak",
wrong_count=2,
correct_count=0,
mastery_score=0,
train_count=2,
error_bank_member=1,
error_reinforce_streak=0,
)
untrained, errors, bank_due, bank_total = adaptive_review_service.count_plan_pools(
[completed, pending, weak]
)
self.assertEqual(untrained, 1)
self.assertEqual(errors, 1)
self.assertEqual(bank_total, 1)
def test_is_review_due_false_without_schedule(self):
word = make_word(1, error_bank_member=1, error_reinforce_streak=2)
word.next_review_at = None
word.review_due_date = None
self.assertFalse(adaptive_review_service.is_review_due(word, self.now))
def test_cooling_error_bank_word_not_selectable_for_review(self):
cooling = make_word(
1,
wrong_count=2,
train_count=3,
error_bank_member=1,
error_reinforce_streak=2,
next_review_offset_hours=48,
)
selected = adaptive_review_service.select_error_bank_words(
[cooling], limit=5, now=self.now, clear_count=2
)
self.assertEqual(selected, [])
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
import json
import os
import unittest
from unittest.mock import MagicMock, patch
from services.llm_service import LlmServiceError, llm_service
class _FakeResponse:
def __init__(self, payload: dict):
self._payload = json.dumps(payload).encode("utf-8")
def read(self):
return self._payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class LlmServiceTest(unittest.TestCase):
def tearDown(self):
get_settings = __import__("config", fromlist=["get_settings"]).get_settings
get_settings.cache_clear()
def test_not_configured_without_api_key(self):
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
self.assertFalse(llm_service.is_configured())
@patch("services.llm_service.urllib.request.urlopen")
def test_enhance_wiki_page_returns_model_content(self, urlopen_mock: MagicMock):
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "test-key"}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
urlopen_mock.return_value = _FakeResponse(
{
"choices": [
{"message": {"content": "# demo\n\n整理后的知识页。"}},
]
}
)
content = llm_service.enhance_wiki_page(title="demo", draft_markdown="# draft")
self.assertIn("整理后的知识页", content)
def test_missing_api_key_raises(self):
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
with self.assertRaises(LlmServiceError):
llm_service.enhance_wiki_page(title="demo", draft_markdown="# draft")
@patch("services.llm_service.urllib.request.urlopen")
def test_parse_word_pairs_from_text(self, urlopen_mock: MagicMock):
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "test-key"}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
urlopen_mock.return_value = _FakeResponse(
{
"choices": [
{
"message": {
"content": (
'{"items":[{"en":"apple","zh":"苹果"},'
'{"en":"banana","zh":"香蕉"}]}'
)
}
},
]
}
)
pairs = llm_service.parse_word_pairs_from_text("apple 苹果\nbanana 香蕉")
self.assertEqual(len(pairs), 2)
self.assertEqual(pairs[0]["en"], "apple")
self.assertEqual(pairs[0]["zh"], "苹果")
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -0,0 +1,11 @@
from services.text_utils import en_answers_match, normalize_en_answer
def test_normalize_en_answer_collapses_whitespace():
assert normalize_en_answer(" A Lot Of ") == "a lot of"
def test_en_answers_match_phrase_with_extra_spaces():
assert en_answers_match("a lot of", "a lot of")
assert en_answers_match("A Lot Of", "a lot of")
assert not en_answers_match("a lot", "a lot of")
+115
View File
@@ -0,0 +1,115 @@
import base64
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from services.tts_service import TtsService, TtsServiceError, tts_service
class _FakeResponse:
def __init__(self, body: bytes, content_type: str = "application/json"):
self._body = body
self.headers = MagicMock()
self.headers.get_content_type.return_value = content_type
def read(self):
return self._body
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class TtsServiceTest(unittest.TestCase):
def tearDown(self):
get_settings = __import__("config", fromlist=["get_settings"]).get_settings
get_settings.cache_clear()
def test_not_configured_when_disabled(self):
with patch.dict(
os.environ,
{"TKMIND_TTS_ENABLED": "false", "TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200"},
clear=False,
):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
self.assertFalse(tts_service.is_configured())
@patch("services.tts_service._open_tts_request")
def test_request_includes_user_agent(self, urlopen_mock: MagicMock):
wav = b"RIFFxxxx"
payload = {"code": 0, "data": base64.b64encode(wav).decode("ascii")}
urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8"))
with patch.dict(
os.environ,
{"TKMIND_TTS_BASE_URL": "https://asr.tkmind.cn", "TKMIND_TTS_ENABLED": "true"},
clear=False,
):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
with tempfile.TemporaryDirectory() as tmp:
with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)):
tts_service.synthesize("apple")
request = urlopen_mock.call_args.args[0]
self.assertEqual(request.get_header("User-agent"), "WordLoop-TTS/1.0")
@patch("services.tts_service._open_tts_request")
def test_synthesize_parses_json_base64(self, urlopen_mock: MagicMock):
wav = b"RIFFxxxx"
payload = {"code": 0, "message": "ok", "data": base64.b64encode(wav).decode("ascii")}
urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8"))
with patch.dict(
os.environ,
{"TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200", "TKMIND_TTS_ENABLED": "true"},
clear=False,
):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
with tempfile.TemporaryDirectory() as tmp:
with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)):
audio, content_type = tts_service.synthesize("apple")
self.assertEqual(audio, wav)
self.assertEqual(content_type, "audio/wav")
@patch("services.tts_service._open_tts_request")
def test_synthesize_uses_cache_on_second_call(self, urlopen_mock: MagicMock):
wav = b"RIFFcached"
payload = {"code": 0, "data": base64.b64encode(wav).decode("ascii")}
urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8"))
with patch.dict(
os.environ,
{"TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200", "TKMIND_TTS_ENABLED": "true"},
clear=False,
):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
with tempfile.TemporaryDirectory() as tmp:
with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)):
first, _ = tts_service.synthesize("apple")
second, _ = tts_service.synthesize("apple")
self.assertEqual(first, second)
self.assertEqual(urlopen_mock.call_count, 1)
@patch("services.tts_service._open_tts_request")
def test_upstream_error_message(self, urlopen_mock: MagicMock):
payload = {"code": 500, "message": "TTS 未启用(ENABLE_TTS=false", "data": None}
urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8"))
with patch.dict(
os.environ,
{"TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200", "TKMIND_TTS_ENABLED": "true"},
clear=False,
):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
with tempfile.TemporaryDirectory() as tmp:
with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)):
with self.assertRaises(TtsServiceError) as ctx:
tts_service.synthesize("apple")
self.assertIn("TTS 未启用", str(ctx.exception))
def test_extract_audio_base64_from_dict(self):
value = TtsService._extract_audio_base64({"audio": "abc"})
self.assertEqual(value, "abc")
@@ -0,0 +1,60 @@
import os
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Base
from models import User, WikiLlmUsage
from services.wiki_llm_quota_service import WikiLlmQuotaExceeded, wiki_llm_quota_service
class WikiLlmQuotaServiceTest(unittest.TestCase):
def setUp(self):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
self.db = sessionmaker(bind=engine)()
self.user = User(
username="alice",
password_hash="test",
created_at="2026-06-06T00:00:00Z",
)
self.db.add(self.user)
self.db.commit()
def tearDown(self):
self.db.close()
get_settings = __import__("config", fromlist=["get_settings"]).get_settings
get_settings.cache_clear()
def test_consume_until_limit(self):
with patch.dict(os.environ, {"AI_WIKI_DAILY_LLM_LIMIT": "2"}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
for _ in range(2):
wiki_llm_quota_service.consume(self.db, self.user)
self.db.commit()
with self.assertRaises(WikiLlmQuotaExceeded):
wiki_llm_quota_service.consume(self.db, self.user)
def test_quota_dict_tracks_usage(self):
with patch.dict(os.environ, {"AI_WIKI_DAILY_LLM_LIMIT": "20"}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
wiki_llm_quota_service.consume(self.db, self.user)
self.db.commit()
quota = wiki_llm_quota_service.quota_dict(self.db, self.user)
self.assertEqual(quota["used_today"], 1)
self.assertEqual(quota["remaining_today"], 19)
self.assertFalse(quota["quota_exceeded"])
def test_usage_row_is_unique_per_day(self):
row = wiki_llm_quota_service.get_usage_row(self.db, self.user, "2026-06-06")
row.call_count = 3
self.db.commit()
rows = self.db.query(WikiLlmUsage).filter(WikiLlmUsage.user_id == self.user.id).all()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].call_count, 3)
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Base
from models import QuizRecord, User, UserSettings, WikiPage, WikiSource, Word
from services.wiki_service import wiki_service
class WikiServiceTest(unittest.TestCase):
def setUp(self):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
self.db = sessionmaker(bind=engine)()
def tearDown(self):
self.db.close()
def create_user(self, username: str) -> User:
user = User(
username=username,
password_hash="test",
created_at="2026-06-06T00:00:00Z",
)
self.db.add(user)
self.db.flush()
self.db.add(
UserSettings(
user_id=user.id,
ai_wiki_enabled=1,
ai_wiki_auto_organize=1,
)
)
self.db.commit()
return user
def create_word(self, user: User, text: str) -> Word:
word = Word(
user_id=user.id,
book_id=0,
source_text=text,
target_text="测试",
source_lang="en",
target_lang="zh",
status="learning",
correct_count=1,
wrong_count=0,
consecutive_correct_count=1,
mastery_score=100,
train_count=1,
total_train_seconds=3,
difficulty=5.0,
stability_hours=24.0,
created_at="2026-06-06T00:00:00Z",
)
self.db.add(word)
self.db.commit()
return word
def test_ingest_builds_private_markdown_page(self):
user = self.create_user("alice")
word = self.create_word(user, "resilient")
wiki_service.ingest_word(self.db, user, word)
page = (
self.db.query(WikiPage)
.filter(WikiPage.user_id == user.id, WikiPage.category == "word")
.one()
)
self.assertIn("# resilient", page.content)
self.assertIn("WordLoop 原始学习事件 1 条", page.content)
self.assertEqual(
self.db.query(WikiSource).filter(WikiSource.user_id == user.id).count(),
1,
)
def test_quiz_ingest_updates_only_own_wiki(self):
alice = self.create_user("alice")
bob = self.create_user("bob")
word = self.create_word(alice, "resilient")
wiki_service.ingest_word(self.db, alice, word)
record = QuizRecord(
user_id=alice.id,
word_id=word.id,
question_type="spell",
user_answer="resilient",
correct_answer="resilient",
is_correct=1,
duration_seconds=2,
created_at="2026-06-06T00:01:00Z",
)
self.db.add(record)
self.db.commit()
wiki_service.ingest_quiz(self.db, alice, word, record)
alice_status = wiki_service.status(self.db, alice)
bob_status = wiki_service.status(self.db, bob)
self.assertGreater(alice_status["page_count"], 0)
self.assertEqual(alice_status["latest_page"]["source_count"], 2)
self.assertEqual(bob_status["page_count"], 0)
self.assertIsNone(bob_status["latest_page"])
def test_recompile_prunes_orphan_status_collection(self):
user = self.create_user("alice")
word = self.create_word(user, "resilient")
word.status = "new"
self.db.commit()
wiki_service.ingest_word(self.db, user, word)
word.status = "learning"
self.db.commit()
wiki_service.rebuild(self.db, user)
slugs = {
page.slug
for page in self.db.query(WikiPage).filter(WikiPage.user_id == user.id).all()
}
self.assertNotIn("learning/new", slugs)
self.assertIn("learning/learning", slugs)
def test_graph_returns_wiki_pages_and_links(self):
user = self.create_user("alice")
word = self.create_word(user, "resilient")
wiki_service.ingest_word(self.db, user, word)
graph = wiki_service.graph(self.db, user)
node_slugs = {node["slug"] for node in graph["nodes"]}
relations = {link["relation"] for link in graph["links"]}
self.assertIn("index", node_slugs)
self.assertIn(f"vocabulary/{word.id}-resilient", node_slugs)
self.assertIn("learning_status", relations)
self.assertIn("collection", relations)
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
import os
import unittest
from unittest.mock import patch
from fastapi import HTTPException
from services.word_scan_service import word_scan_service
class WordScanServiceTest(unittest.TestCase):
def tearDown(self):
get_settings = __import__("config", fromlist=["get_settings"]).get_settings
get_settings.cache_clear()
def test_heuristic_parse_tab_separated_pairs(self):
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
items = word_scan_service.parse_scan_text("apple\t苹果\nbanana\t香蕉")
self.assertEqual(len(items), 2)
self.assertEqual(items[0]["source_text"], "apple")
self.assertEqual(items[0]["target_text"], "苹果")
def test_empty_text_raises(self):
with self.assertRaises(HTTPException) as ctx:
word_scan_service.parse_scan_text(" ")
self.assertEqual(ctx.exception.status_code, 400)
def test_unparseable_text_raises(self):
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False):
__import__("config", fromlist=["get_settings"]).get_settings.cache_clear()
with self.assertRaises(HTTPException) as ctx:
word_scan_service.parse_scan_text("hello world only english")
self.assertEqual(ctx.exception.status_code, 422)
if __name__ == "__main__":
unittest.main()