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
3 changes: 2 additions & 1 deletion backend/api/routes/modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,8 @@ async def create_custom_mode(body: dict, request: Request, user_id: int = Depend

body["mode_id"] = mode_id
registry = get_registry()
if registry.is_builtin(mode_id):
existing_info = registry.get_mode_info(mode_id)
if existing_info and existing_info.source != "custom":
return JSONResponse(
{"error": f"Cannot override builtin mode: {mode_id}"},
status_code=409,
Expand Down
31 changes: 30 additions & 1 deletion backend/core/json_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,35 @@ def _normalize_almanac_chongsha(value: Any) -> str:
return text.replace("(", "(").replace(")", ")")


def _rule_based_almanac_health_tip(result: dict[str, Any], fallback_tip: str) -> str:
yi = _normalize_almanac_list(result.get("fitness"), limit=3)
ji = _normalize_almanac_list(result.get("taboo"), limit=3)
solar_term = str(result.get("jieqi") or "").strip()

def has_any(text: str, words: tuple[str, ...]) -> bool:
return any(word in text for word in words)

if has_any(ji, ("动土", "修造", "破土", "安葬")):
return "少动土木,保持安稳"
if has_any(ji, ("出行", "远行", "赴任")):
return "行程从简,早些休息"
if has_any(ji, ("嫁娶", "订盟", "纳采")):
return "人情事务,宜缓不急"
if has_any(yi, ("沐浴", "扫舍", "除服")):
return "整理清洁,身心轻快"
if has_any(yi, ("祭祀", "祈福", "会友")):
return "静心处事,少些匆忙"
if solar_term in ("小暑", "大暑", "立夏", "夏至"):
return "暑热时节,清淡补水"
if solar_term in ("立冬", "小雪", "大雪", "冬至", "小寒", "大寒"):
return "寒时护暖,早睡养神"
if solar_term in ("立春", "雨水", "惊蛰", "春分", "清明", "谷雨"):
return "顺应春生,舒展身心"
if solar_term in ("立秋", "处暑", "白露", "秋分", "寒露", "霜降"):
return "秋燥渐起,润养作息"
return str(fallback_tip or "").strip() or "起居有常,饮食清淡"


def _summarize_almanac_payload(result: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
solar_term = str(result.get("jieqi") or "").strip() or str(fallback.get("solar_term") or "").strip()
yi = _normalize_almanac_list(result.get("fitness"), limit=3) or str(fallback.get("yi") or "").strip()
Expand Down Expand Up @@ -300,7 +329,7 @@ async def _generate_almanac_health_tip(result: dict[str, Any], fallback_tip: str
return cleaned[:20]
except (LLMKeyMissingError, httpx.HTTPError, HTTPStatusError, OpenAIError, OSError, TypeError, ValueError):
logger.warning("[ALMANAC] Failed to generate daily health tip", exc_info=True)
return fallback_tip
return _rule_based_almanac_health_tip(result, fallback_tip)


def _make_aliyun_mt_client() -> AlimtClient | None:
Expand Down
7 changes: 6 additions & 1 deletion backend/core/mode_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ class CatalogItem:
zh=CatalogText(name="课程表", tip="按周显示课程安排"),
en=CatalogText(name="Timetable", tip="Weekly class schedule display"),
),
CatalogItem(
mode_id="MOYU",
category="more",
zh=CatalogText(name="摸鱼热榜", tip="全网时间线热榜"),
en=CatalogText(name="Moyu Hot List", tip="Timeline hot list across the web"),
),
CatalogItem(
mode_id="MY_ADAPTIVE",
category="custom",
Expand All @@ -190,4 +196,3 @@ class CatalogItem:

def builtin_catalog_map() -> dict[str, CatalogItem]:
return {item.mode_id.upper(): item for item in BUILTIN_CATALOG}

12 changes: 12 additions & 0 deletions backend/core/mode_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ def load_json_mode(self, path: str, *, source: str = "custom") -> Optional[str]:
f"[Registry] JSON mode {mode_id} shadows builtin — skipped"
)
return None
existing = self._json_modes.get(mode_id)
if source == "custom" and existing and existing.info.source != "custom":
logger.warning(
f"[Registry] Custom JSON mode {mode_id} shadows {existing.info.source} — skipped"
)
return None

info = ModeInfo(
mode_id=mode_id,
Expand Down Expand Up @@ -243,6 +249,12 @@ def load_custom_mode_from_dict(self, mode_id: str, definition: dict, *, source:
f"[Registry] Custom mode {mode_id} shadows builtin — skipped"
)
return None
existing = self._json_modes.get(mode_id)
if source == "custom" and existing and existing.info.source != "custom":
logger.warning(
f"[Registry] Custom mode {mode_id} shadows {existing.info.source} — skipped"
)
return None

info = ModeInfo(
mode_id=mode_id,
Expand Down
2 changes: 1 addition & 1 deletion backend/core/modes/builtin/almanac.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"xingsu": "南方星日马",
"suisha": "岁煞北",
"chongsha": "狗日冲龙",
"health_tip": "春捂秋冻,适当增衣"
"health_tip": "起居有常,饮食清淡"
}
}
],
Expand Down
28 changes: 28 additions & 0 deletions backend/core/modes/builtin/en/moyu.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"mode_id": "MOYU",
"display_name": "Moyu Hot List",
"icon": "global",
"cacheable": false,
"description": "Timeline hot list across the web",
"content": {
"type": "static",
"static_data": {
"image_url": "https://moyu.110x.de/eink.png?w=400&h=234"
}
},
"layout": {
"status_bar": {
"style": "minimal"
},
"body": [
{
"type": "image",
"field": "image_url",
"width": 400,
"height": 234,
"x": 0,
"fit": "fill"
}
]
}
}
28 changes: 28 additions & 0 deletions backend/core/modes/builtin/moyu.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"mode_id": "MOYU",
"display_name": "摸鱼热榜",
"icon": "global",
"cacheable": false,
"description": "全网时间线热榜",
"content": {
"type": "static",
"static_data": {
"image_url": "https://moyu.110x.de/eink.png?w=400&h=234"
}
},
"layout": {
"status_bar": {
"style": "minimal"
},
"body": [
{
"type": "image",
"field": "image_url",
"width": 400,
"height": 234,
"x": 0,
"fit": "fill"
}
]
}
}
35 changes: 27 additions & 8 deletions backend/core/vocab_store.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
Expand All @@ -21,42 +22,60 @@
_DATA_DIR = Path(__file__).resolve().parent / "vocab_data"


def _auto_seed_decks() -> set[str] | None:
raw = os.getenv("VOCAB_AUTO_SEED_DECKS", "all").strip()
if raw.lower() == "all":
return None
decks = {part.strip() for part in raw.split(",") if part.strip()}
return decks or {DEFAULT_DECK_ID}


async def seed_builtin_vocab() -> None:
if not _DATA_DIR.exists():
return

now = datetime.now().isoformat()
db = await get_main_db()
allowed_decks = _auto_seed_decks()
for path in sorted(_DATA_DIR.glob("*.json")):
deck_id = path.stem
if allowed_decks is not None and deck_id not in allowed_decks:
continue
try:
items = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(items, list):
continue
rows: list[tuple[str, str, str, str, str, int, str]] = []
for item in items:
if not isinstance(item, dict):
continue
word = str(item.get("word") or "").strip()
definition = str(item.get("definition") or "").strip()
if not word or not definition:
continue
await db.execute(
"""
INSERT OR IGNORE INTO vocab_items
(deck_id, word, phonetic, definition, example, difficulty, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
rows.append(
(
str(item.get("deck_id") or DEFAULT_DECK_ID),
str(item.get("deck_id") or deck_id or DEFAULT_DECK_ID),
word,
str(item.get("phonetic") or ""),
definition,
str(item.get("example") or ""),
int(item.get("difficulty") or 1),
now,
),
)
)
if not rows:
continue
await db.executemany(
"""
INSERT OR IGNORE INTO vocab_items
(deck_id, word, phonetic, definition, example, difficulty, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
await db.commit()


Expand Down
Loading