前端适配

This commit is contained in:
Miyamizu-MitsuhaSang 2025-11-07 17:17:30 +08:00
parent 1c8036cb8d
commit cf64bc71b3
3 changed files with 308 additions and 148 deletions

View File

@ -362,28 +362,73 @@ Authorization: Bearer <your_jwt_token>
#### 2.3 单词联想建议 #### 2.3 单词联想建议
- **接口**: `POST /api/search/list/word` - **接口**: `POST /api/search/list/word`
- **描述**: 根据用户输入返回单词联想列表,含前缀匹配与包含匹配 - **描述**: 返回智能联想候选列表。后端会根据 `language`(当前词典)与用户输入自动切换检索策略,综合“前缀匹配”和“释义反查”两种来源,并对结果去重合并释义
- **需要认证**: 是 - **需要认证**: 是
- **请求体**: - **请求体**:
```json ```json
{ {
"query": "bon", "query": "bon",
"language": "fr", "language": "fr"
"sort": "relevance",
"order": "des"
} }
``` ```
- **响应示例**: - **检索规则**:
- `language = "fr"`:
- 法语/拉丁字符输入:优先使用 `WordlistFr` 做前缀 + 包含匹配。
- 中文输入:回退到法语释义的中文字段做反查。
- 英文输入:会优先使用英文释义字段做反查,方便“英文 → 法语”场景。
- `language = "jp"`:
- 假名或日文汉字:直接在 `WordlistJp` 做前缀 + 包含匹配,同时返回假名字段。
- 中文输入:优先用中文释义反查;若该中文词条存在汉字映射,则并行检索对应的日语原词并放在结果前列。
- **响应字段**:
- `word`: 词条原文(法语或日语)
- `hiragana`: 仅日语结果携带;法语为 `null`
- `meanings`: 中文释义去重数组(当结果来自释义反查时才会出现)
- `english`: 英文释义去重数组(仅法语词典且按英文释义反查时出现)
- **响应示例(法语)**:
```json ```json
{ {
"list": ["bonjour", "bonsoir", "bonheur"] "list": [
{
"word": "bonjour",
"hiragana": null,
"meanings": ["你好", "问候语"],
"english": ["hello"]
},
{
"word": "bonsoir",
"hiragana": null,
"meanings": [],
"english": []
}
]
} }
``` ```
> **说明**: `language = "jp"` 时返回形如 `[["愛", "あい"], ["愛する", "あいする"]]` 的二维数组,第二列为假名读音。 - **响应示例(日语,中文反查)**:
```json
{
"list": [
{
"word": "愛",
"hiragana": "あい",
"meanings": ["爱;爱意"],
"english": []
},
{
"word": "愛する",
"hiragana": "あいする",
"meanings": ["热爱;深爱"],
"english": []
}
]
}
```
- **状态码**: - **状态码**:
- `200`: 查询成功 - `200`: 查询成功

View File

@ -6,11 +6,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Form
from app.api.search_dict import service from app.api.search_dict import service
from app.api.search_dict.search_schemas import SearchRequest, WordSearchResponse, SearchItemFr, SearchItemJp, \ from app.api.search_dict.search_schemas import SearchRequest, WordSearchResponse, SearchItemFr, SearchItemJp, \
ProverbSearchRequest ProverbSearchRequest
from app.api.search_dict.service import suggest_autocomplete
from app.api.word_comment.word_comment_schemas import CommentSet from app.api.word_comment.word_comment_schemas import CommentSet
from app.models import DefinitionJp, CommentFr, CommentJp from app.models import DefinitionJp, CommentFr, CommentJp, WordlistFr
from app.models.fr import DefinitionFr, ProverbFr from app.models.fr import DefinitionFr, ProverbFr
from app.models.jp import IdiomJp from app.models.jp import IdiomJp, WordlistJp
from app.utils.all_kana import all_in_kana from app.utils.all_kana import all_in_kana
from app.utils.security import get_current_user from app.utils.security import get_current_user
from app.utils.textnorm import normalize_text from app.utils.textnorm import normalize_text
@ -158,8 +157,61 @@ async def search_word_list(query_word: SearchRequest, user=Depends(get_current_u
:return: 待选列表 :return: 待选列表
""" """
# print(query_word.query, query_word.language, query_word.sort, query_word.order) # print(query_word.query, query_word.language, query_word.sort, query_word.order)
word_contents = await suggest_autocomplete(query=query_word) query = query_word.query
return {"list": word_contents} lang = query_word.language
query, search_lang, transable = await service.detect_language(text=query)
word_contents = []
if lang == "fr":
if search_lang == "fr":
word_contents = await service.suggest_autocomplete(
query=query,
dict_lang="fr",
model=WordlistFr,
)
if not transable:
word_contents.extend(
await service.search_definition_by_meaning(
query=query,
model=DefinitionFr,
lang="en",
)
)
else:
word_contents = await service.search_definition_by_meaning(
query=query_word.query,
model=DefinitionFr,
lang="zh",
)
else:
if search_lang == "jp":
word_contents = await service.suggest_autocomplete(
query=query,
dict_lang="jp",
model=WordlistJp,
)
elif search_lang == "zh":
word_contents = []
if transable:
word_contents = await service.suggest_autocomplete(
query=query,
dict_lang="jp",
model=WordlistJp,
)
word_contents.extend(
await service.search_definition_by_meaning(
query=query_word.query,
model=DefinitionJp,
lang="zh",
)
)
else:
word_contents = await service.suggest_autocomplete(
query=query,
dict_lang="jp",
model=WordlistJp,
)
suggest_list = service.merge_word_results(word_contents)
return {"list": suggest_list}
@dict_search.post("/search/list/proverb") @dict_search.post("/search/list/proverb")
@ -177,7 +229,8 @@ async def search_proverb_list(query_word: ProverbSearchRequest, user=Depends(get
@dict_search.post("/search/proverb") @dict_search.post("/search/proverb")
async def search_proverb(proverb_id: int = Form(...), user=Depends(get_current_user)): async def search_proverb(proverb_id: int = Form(...), user=Depends(get_current_user)):
result = await service.accurate_idiom_proverb(search_id=proverb_id, model=ProverbFr, only_fields=["text", "chi_exp"]) result = await service.accurate_idiom_proverb(search_id=proverb_id, model=ProverbFr,
only_fields=["text", "chi_exp"])
return {"result": result} return {"result": result}
@ -225,5 +278,6 @@ async def search_idiom_list(query_idiom: ProverbSearchRequest, user=Depends(get_
@dict_search.post("/search/idiom") @dict_search.post("/search/idiom")
async def search_idiom(query_id: int, user=Depends(get_current_user)): async def search_idiom(query_id: int, user=Depends(get_current_user)):
result = await service.accurate_idiom_proverb(search_id=query_id, model=IdiomJp, only_fields=["id", "text", "search_text", "chi_exp", "example"]) result = await service.accurate_idiom_proverb(search_id=query_id, model=IdiomJp,
only_fields=["id", "text", "search_text", "chi_exp", "example"])
return {"result": result} return {"result": result}

View File

@ -1,15 +1,13 @@
import re import re
from typing import List, Tuple, Dict, Literal, Type from typing import List, Tuple, Dict, Literal, Type, Any
from fastapi import HTTPException from fastapi import HTTPException
from redis.asyncio import Redis from redis.asyncio import Redis
from tortoise import Tortoise, Model from tortoise import Tortoise, Model
from tortoise.expressions import Q from tortoise.expressions import Q
from app.api.search_dict.search_schemas import SearchRequest, ProverbSearchRequest from app.models import KangjiMapping
from app.models import WordlistFr, WordlistJp, KangjiMapping
from app.utils.all_kana import all_in_kana from app.utils.all_kana import all_in_kana
from app.utils.textnorm import normalize_text
from settings import TORTOISE_ORM from settings import TORTOISE_ORM
@ -57,8 +55,12 @@ async def detect_language(text: str) -> Tuple[str, str, bool]:
return text, "zh", False return text, "zh", False
# ✅ Step 3: 拉丁字母检测(如法语) # ✅ Step 3: 拉丁字母检测(如法语)
if re.search(r"[a-zA-ZÀ-ÿ]", text): if re.search(r"[À-ÿ]", text):
return text, "fr", False return text, "fr", True # True → 含拉丁扩展(非英语)
# 全部为纯英文字符
elif re.fullmatch(r"[a-zA-Z]+", text):
return text, "fr", False # False → 英语单词
# ✅ Step 4: 其他情况(符号、空格等) # ✅ Step 4: 其他情况(符号、空格等)
return text, "other", False return text, "other", False
@ -75,171 +77,230 @@ async def accurate_idiom_proverb(search_id: int, model: Type[Model], only_fields
return result return result
async def suggest_proverb( async def suggest_autocomplete(
query: str, query: str,
lang: Literal["fr", "zh", "jp"], dict_lang: Literal["fr", "jp"],
model: Type[Model], model: Type[Model],
search_field: str = "search_text", search_field: str = "search_text",
target_field: str = "text", text_field: str = "text",
chi_exp_field: str = "chi_exp", hira_field: str = "hiragana",
freq_field: str = "freq",
english_field: str = "eng_explanation",
limit: int = 10, limit: int = 10,
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
"""
通用自动补全建议接口增强版
- 法语: search_text / text 搜索同时反查 DefinitionFr 的英文释义
- 日语: 先按原文 text 匹配再按假名 search_text 匹配
统一返回结构
[
{
"word": "étudier",
"hiragana": None,
"meanings": [],
"english": ["to study", "to learn"]
}
]
"""
keyword = query.strip() keyword = query.strip()
if not keyword: if not keyword:
return [] return []
# ✅ 搜索条件:中文时双字段联合匹配 # ========== 法语 ==========
if lang == "zh": if dict_lang == "fr":
start_condition = Q(**{f"{chi_exp_field}__istartswith": keyword}) | Q( start_condition = Q(**{f"{search_field}__istartswith": keyword}) | Q(**{f"{text_field}__istartswith": keyword})
**{f"{search_field}__istartswith": keyword}) contain_condition = Q(**{f"{search_field}__icontains": keyword}) | Q(**{f"{text_field}__icontains": keyword})
contain_condition = Q(**{f"{chi_exp_field}__icontains": keyword}) | Q(**{f"{search_field}__icontains": keyword}) value_fields = ["id", text_field, freq_field, search_field]
# ========== 日语 ==========
elif dict_lang == "jp":
kana_word = all_in_kana(keyword)
start_condition = Q(**{f"{text_field}__istartswith": keyword})
contain_condition = Q(**{f"{text_field}__icontains": keyword})
kana_start = Q(**{f"{hira_field}__istartswith": kana_word})
kana_contain = Q(**{f"{hira_field}__icontains": kana_word})
start_condition |= kana_start
contain_condition |= kana_contain
value_fields = ["id", text_field, hira_field, freq_field]
else: else:
start_condition = Q(**{f"{search_field}__istartswith": keyword}) return []
contain_condition = Q(**{f"{search_field}__icontains": keyword})
# ✅ 1. 开头匹配 # ✅ 1. 开头匹配
start_matches = await ( start_matches = await (
model.filter(start_condition) model.filter(start_condition)
.order_by("-freq", "id") .order_by(f"-{freq_field}", "id")
.limit(limit) .limit(limit)
.values("id", target_field, chi_exp_field, "search_text") .values(*value_fields)
) )
# ✅ 2. 包含匹配(但不是开头) # ✅ 2. 包含匹配
contain_matches = await ( contain_matches = await (
model.filter(contain_condition & ~start_condition) model.filter(contain_condition & ~start_condition)
.order_by("-freq", "id") .order_by(f"-{freq_field}", "id")
.limit(limit) .limit(limit)
.values("id", target_field, chi_exp_field, "search_text") .values(*value_fields)
) )
# ✅ 3. 合并去重保持顺序 # ✅ 3. 合并去重
results = [] results = []
seen_ids = set() seen_ids = set()
for row in start_matches + contain_matches: for row in start_matches + contain_matches:
if row["id"] not in seen_ids: if row["id"] in seen_ids:
seen_ids.add(row["id"]) continue
results.append({ seen_ids.add(row["id"])
"id": row["id"],
"proverb": row[target_field], result = {
"search_text": row["search_text"], "word": row[text_field],
"chi_exp": row[chi_exp_field], "hiragana": row.get(hira_field) if dict_lang == "jp" else None,
}) "meanings": [],
"english": [],
}
# ✅ 若为法语,则反查 DefinitionFr 的英文释义
if dict_lang == "fr":
# 获取关联的 definitions
word_obj = await model.get(id=row["id"]).prefetch_related("definitions")
english_list = [
d.eng_explanation.strip()
for d in word_obj.definitions
if d.eng_explanation and d.eng_explanation.strip()
]
result["english"] = list(set(english_list))
results.append(result)
# ✅ 截断最终返回数量
return results[:limit] return results[:limit]
async def suggest_autocomplete(query: SearchRequest, limit: int = 10): # ===================================================
# ✅ 释义反查接口(返回统一结构)
# ===================================================
async def search_definition_by_meaning(
query: str,
model: Type[Model],
meaning_field: str = "meaning",
eng_field: str = "eng_explanation",
hira_field: str = "hiragana",
limit: int = 20,
lang: Literal["zh", "en"] = "zh",
) -> List[Dict[str, str]]:
"""
双语释义反查接口中文/英文
统一返回结构
[
{
"word": "étudier",
"hiragana": None,
"meanings": ["学习", "研究"],
"english": ["to study"]
}
]
""" """
:param query: 当前用户输入的内容 keyword = query.strip()
:param limit: 返回列表限制长度 if not keyword:
:return: 联想的单词列表非完整信息单纯单词 return []
"""
if query.language == 'fr':
query_word = normalize_text(query.query)
exact = await (
WordlistFr
.get_or_none(search_text=query.query)
.values("text", "freq")
)
if exact:
exact_word = [(exact.get("text"), exact.get("freq"))]
else:
exact_word = []
qs_prefix = (
WordlistFr
.filter(Q(search_text__startswith=query_word) | Q(text__startswith=query.query))
.exclude(search_text=query.query)
.only("text", "freq")
)
prefix_objs = await qs_prefix[:limit]
prefix: List[Tuple[str, int]] = [(o.text, o.freq) for o in prefix_objs]
need = max(0, limit - len(prefix))
contains: List[Tuple[str, int]] = []
if need > 0:
qs_contain = (
WordlistFr
.filter(Q(search_text__icontains=query_word) | Q(text__icontains=query.query))
.exclude(Q(search_text__startswith=query_word) | Q(text__startswith=query.query) | Q(text=query.query))
.only("text", "freq")
.only("text", "freq")
)
contains_objs = await qs_contain[: need * 2]
contains = [(o.text, o.freq) for o in contains_objs]
seen_text, out = set(), []
for text, freq in list(exact_word) + list(prefix) + list(contains):
key = text
if key not in seen_text:
seen_text.add(key)
out.append((text, freq))
if len(out) >= limit:
break
out = sorted(out, key=lambda w: (-w[2], len(w[0]), w[0]))
return [text for text, _ in out]
if lang == "zh":
search_field = meaning_field
elif lang == "en":
search_field = eng_field
else: else:
query_word = all_in_kana(query.query) raise ValueError("lang 参数必须为 'zh''en'")
exact = await (
WordlistJp
.get_or_none(
text=query.query
)
.only("text", "hiragana", "freq")
)
if exact:
exact_word = [(exact.text, exact.hiragana, exact.freq)]
else:
exact_word = []
qs_prefix = ( contain_condition = Q(**{f"{search_field}__icontains": keyword})
WordlistJp
.filter(Q(hiragana__startswith=query_word) | Q(text__startswith=query.query))
.exclude(text=query.query)
.only("text", "hiragana", "freq")
)
prefix_objs = await qs_prefix[:limit]
prefix: List[Tuple[str, str, int]] = [(o.text, o.hiragana, o.freq) for o in prefix_objs]
need = max(0, limit - len(prefix)) matches = (
contains: List[Tuple[str, str, int]] = [] await model.filter(contain_condition)
.prefetch_related("word")
if need > 0: .order_by("id")
qs_contain = await (
WordlistJp
.filter(Q(hiragana__icontains=query_word) | Q(text__icontains=query.query))
.exclude(Q(hiragana__startswith=query_word) | Q(text__startswith=query.query) | Q(text=query.query))
.only("text", "hiragana", "freq")
)
contains_objs = qs_contain[:need * 2]
contains: List[Tuple[str, str, int]] = [(o.text, o.hiragana, o.freq) for o in contains_objs]
seen_text, out = set(), []
for text, hiragana, freq in list(exact_word) + list(prefix) + list(contains):
key = (text, hiragana)
if key not in seen_text:
seen_text.add(key)
out.append((text, hiragana, freq))
if len(out) >= limit:
break
out = sorted(out, key=lambda w: (-w[2], len(w[0]), w[0]))
return [(text, hiragana) for text, hiragana, _ in out]
async def __test():
query_word: str = '棋逢'
return await (
suggest_proverb(
query=ProverbSearchRequest(query=query_word),
lang='zh'
)
) )
word_to_data: Dict[str, Dict[str, List[str] | str | None]] = {}
for entry in matches:
word_obj = await entry.word
word_text = getattr(word_obj, "text", None)
if not word_text:
continue
chi_mean = getattr(entry, meaning_field, "").strip() or None
eng_mean = getattr(entry, eng_field, "").strip() or None
hira_text = getattr(word_obj, hira_field, None) if hasattr(word_obj, hira_field) else None
if word_text not in word_to_data:
word_to_data[word_text] = {"hiragana": hira_text, "meanings": [], "english": []}
if chi_mean:
word_to_data[word_text]["meanings"].append(chi_mean)
if eng_mean:
word_to_data[word_text]["english"].append(eng_mean)
results = []
for word, data in word_to_data.items():
results.append({
"word": word,
"hiragana": data["hiragana"],
"meanings": list(set(data["meanings"])),
"english": list(set(data["english"]))
})
return results[:limit]
def merge_word_results(*lists: List[Dict[str, Any]]) -> List[Dict[str, object]]:
"""
合并多个结果列表并去重
- 依据 word+ hiragana唯一性去重
- meanings / english 合并去重
- 保留最早出现的顺序
"""
merged: Dict[str, Dict[str, Any]] = {}
order: List[str] = []
for lst in lists:
for item in lst:
word = item.get("word")
hira = item.get("hiragana")
key = f"{word}:{hira or ''}" # 以 word+hiragana 作为唯一标识
if key not in merged:
# 初次出现,加入结果集
merged[key] = {
"word": word,
"hiragana": hira,
"meanings": list(item.get("meanings", [])),
"english": list(item.get("english", []))
}
order.append(key)
else:
# 已存在 → 合并释义和英文解释
merged[key]["meanings"] = list(set(
list(merged[key].get("meanings", [])) +
list(item.get("meanings", []) or [])
))
merged[key]["english"] = list(set(
list(merged[key].get("english", [])) +
list(item.get("english", []) or [])
))
# 保持插入顺序输出
return [merged[k] for k in order]
# async def __test():
# query_word: str = '棋逢'
# return await (
# suggest_proverb(
# query=ProverbSearchRequest(query=query_word),
# lang='zh'
# )
# )
async def __main(): async def __main():
await Tortoise.init(config=TORTOISE_ORM) await Tortoise.init(config=TORTOISE_ORM)