fix(ai,reader): robust sentence segmentation without abbreviation cutoffs & context-aware literary translation
- Implement robustSentenceSplit across reader.js and backend (miner.py, cefr.py) to prevent false cutoffs on honorifics (Mr., Mrs., Dr.), single-letter initials (John F. Kennedy), decimals, acronyms (U.S., U.K.), and abbreviations (No. 4, etc.) - Upgrade translation prompt to professional literary Russian, eliminating literal word-for-word translation and preserving titles/honorifics - Inject surrounding paragraph context into translation API to avoid out-of-context translations - Inject offline dictionary hints into LLM prompt for accurate contextual word meaning - Prefer qwen2.5:3b for higher translation fluency and fidelity - Purge truncated cache entries from sentence_translations and restore Card 30 (briefcase) with authentic full sentence - Bump Service Worker cache to notes-v6.0 - Add unit tests for robust sentence splitting, prompt formatting, and translation API
This commit is contained in:
1 parent
3098d3016d
commit
532e2ec3b2
11 files changed
+308
-84
No files matched your search
@@ -1,46 +1,81 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import httpx
|
import httpx
|
||||||
from app import config
|
from app import config
|
||||||
from app.database import get_cached_sentence_translation, save_sentence_translation
|
from app.database import get_db, get_cached_sentence_translation, save_sentence_translation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def build_translation_prompt(sentence: str, target_word: str = "") -> str:
|
def get_dictionary_hint(term: str) -> str:
|
||||||
|
"""Retrieves verified offline dictionary translation hints for term."""
|
||||||
|
clean = term.strip()
|
||||||
|
if not clean:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT translation FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
|
(clean,),
|
||||||
|
).fetchone()
|
||||||
|
if row and row["translation"]:
|
||||||
|
return row["translation"].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_translation_prompt(
|
||||||
|
sentence: str,
|
||||||
|
target_word: str = "",
|
||||||
|
context: str = "",
|
||||||
|
dict_hint: str = "",
|
||||||
|
) -> str:
|
||||||
clean_sentence = sentence.strip()
|
clean_sentence = sentence.strip()
|
||||||
clean_word = target_word.strip()
|
clean_word = target_word.strip()
|
||||||
|
clean_context = context.strip()
|
||||||
|
|
||||||
|
context_section = ""
|
||||||
|
if clean_context and clean_context != clean_sentence:
|
||||||
|
short_ctx = clean_context[:500].strip()
|
||||||
|
context_section = f'Контекст абзаца:\n"{short_ctx}"\n\n'
|
||||||
|
|
||||||
|
target_section = ""
|
||||||
if clean_word:
|
if clean_word:
|
||||||
|
target_section = f'Целевое слово/выражение: "{clean_word}"\n'
|
||||||
|
if dict_hint:
|
||||||
|
target_section += f'Возможные словарные значения: {dict_hint}\n'
|
||||||
|
target_section += "\n"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"You are a professional English-to-Russian translator.\n"
|
"Ты — профессиональный литературный переводчик художественной и научно-популярной литературы на русский язык.\n"
|
||||||
"Translate the following English sentence into clear, natural, and idiomatic Russian.\n"
|
"Твоя задача — перевести указанное английское предложение на красивый, естественный и грамотный русский литературный язык, идеально вписывающийся в повествование книги.\n\n"
|
||||||
f"Also provide the precise in-context meaning and translation of the target word/phrase '{clean_word}'.\n\n"
|
f"{context_section}"
|
||||||
f'Sentence: "{clean_sentence}"\n'
|
f'Предложение для перевода:\n"{clean_sentence}"\n\n'
|
||||||
f'Target word: "{clean_word}"\n\n'
|
f"{target_section}"
|
||||||
"Respond ONLY with a valid JSON object in this format:\n"
|
"Обязательные правила перевода:\n"
|
||||||
|
"1. Перевод должен быть живым и художественным (НЕ дословным, без топорного калькирования).\n"
|
||||||
|
"2. Переводи всё предложение ПОЛНОСТЬЮ от начала до конца, не обрывая на точках, обращениях или знаках препинания.\n"
|
||||||
|
"3. Обязательно сохраняй обращения и титулы: Mr. -> мистер, Mrs. -> миссис, Dr. -> доктор, Prof. -> профессор, St. -> святой/Сент-. Не теряй их!\n"
|
||||||
|
"4. В поле \"word_meaning\" укажи только точный перевод выделенного слова на русский язык в контексте данного предложения (выбрав самый подходящий вариант из словарных подсказок).\n\n"
|
||||||
|
"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON:\n"
|
||||||
"{\n"
|
"{\n"
|
||||||
' "sentence_ru": "перевод предложения на русский язык",\n'
|
' "sentence_ru": "полный литературный перевод всего предложения",\n'
|
||||||
' "word_meaning": "значение слова в этом контексте"\n'
|
f' "word_meaning": "{"русский перевод слова в данном контексте" if clean_word else ""}"\n'
|
||||||
"}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
"You are a professional English-to-Russian translator.\n"
|
|
||||||
"Translate the following English sentence into clear, natural, and idiomatic Russian.\n\n"
|
|
||||||
f'Sentence: "{clean_sentence}"\n\n'
|
|
||||||
"Respond ONLY with a valid JSON object in this format:\n"
|
|
||||||
"{\n"
|
|
||||||
' "sentence_ru": "перевод предложения на русский язык",\n'
|
|
||||||
' "word_meaning": ""\n'
|
|
||||||
"}"
|
"}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[str, Any]:
|
async def translate_sentence_ai(
|
||||||
|
sentence: str,
|
||||||
|
target_word: str = "",
|
||||||
|
context: str = ""
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Translates a sentence using local AI (Ollama), with SQLite instant-cache lookup.
|
Translates a sentence using local AI (Ollama), with SQLite instant-cache lookup.
|
||||||
|
Incorporates paragraph context and offline dictionary hints for maximum naturalness.
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
"original_sentence": "...",
|
"original_sentence": "...",
|
||||||
@@ -53,6 +88,7 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
|
|||||||
"""
|
"""
|
||||||
clean_sentence = sentence.strip()
|
clean_sentence = sentence.strip()
|
||||||
clean_word = target_word.strip()
|
clean_word = target_word.strip()
|
||||||
|
clean_context = context.strip()
|
||||||
|
|
||||||
if not clean_sentence:
|
if not clean_sentence:
|
||||||
return {
|
return {
|
||||||
@@ -76,8 +112,17 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
|
|||||||
"cached": True,
|
"cached": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. Local AI Generation (Ollama)
|
# 2. Retrieve offline dictionary hint to steer LLM towards authentic meanings
|
||||||
prompt = build_translation_prompt(clean_sentence, clean_word)
|
dict_hint = get_dictionary_hint(clean_word) if clean_word else ""
|
||||||
|
|
||||||
|
# 3. Local AI Generation (Ollama)
|
||||||
|
prompt = build_translation_prompt(
|
||||||
|
sentence=clean_sentence,
|
||||||
|
target_word=clean_word,
|
||||||
|
context=clean_context,
|
||||||
|
dict_hint=dict_hint,
|
||||||
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": config.OLLAMA_MODEL,
|
"model": config.OLLAMA_MODEL,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
@@ -85,7 +130,7 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
|
|||||||
"format": "json",
|
"format": "json",
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"num_predict": 256,
|
"num_predict": 300,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,17 +139,15 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
|
|||||||
source = f"ollama:{config.OLLAMA_MODEL}"
|
source = f"ollama:{config.OLLAMA_MODEL}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=35.0) as client:
|
||||||
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
|
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
raw_response = data.get("response", "{}")
|
raw_response = data.get("response", "{}")
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw_response)
|
parsed = json.loads(raw_response)
|
||||||
sentence_ru = parsed.get("sentence_ru", "").strip()
|
|
||||||
word_meaning = parsed.get("word_meaning", "").strip()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# In case LLM emitted unescaped markdown or wrapper
|
# In case LLM emitted markdown wrapper ```json ... ```
|
||||||
clean_raw = raw_response.strip()
|
clean_raw = raw_response.strip()
|
||||||
if clean_raw.startswith("```json"):
|
if clean_raw.startswith("```json"):
|
||||||
clean_raw = clean_raw[7:]
|
clean_raw = clean_raw[7:]
|
||||||
@@ -112,15 +155,41 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
|
|||||||
clean_raw = clean_raw[3:]
|
clean_raw = clean_raw[3:]
|
||||||
if clean_raw.endswith("```"):
|
if clean_raw.endswith("```"):
|
||||||
clean_raw = clean_raw[:-3]
|
clean_raw = clean_raw[:-3]
|
||||||
|
try:
|
||||||
parsed = json.loads(clean_raw.strip())
|
parsed = json.loads(clean_raw.strip())
|
||||||
sentence_ru = parsed.get("sentence_ru", "").strip()
|
except Exception:
|
||||||
word_meaning = parsed.get("word_meaning", "").strip()
|
parsed = {}
|
||||||
|
|
||||||
|
sentence_ru = str(parsed.get("sentence_ru", "")).strip()
|
||||||
|
|
||||||
|
# Extract word meaning flexibly
|
||||||
|
raw_meaning = (
|
||||||
|
parsed.get("word_meaning")
|
||||||
|
or parsed.get(f"{clean_word}_meaning")
|
||||||
|
or parsed.get("meaning")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
raw_meaning = str(raw_meaning).strip()
|
||||||
|
|
||||||
|
# Clean up format like "briefcase: портфель" or "портфель (сущ.)"
|
||||||
|
if ":" in raw_meaning:
|
||||||
|
raw_meaning = raw_meaning.split(":", 1)[1].strip()
|
||||||
|
|
||||||
|
# If model returned the English term back, fallback to dictionary hint
|
||||||
|
if raw_meaning.lower() == clean_word.lower() or not raw_meaning:
|
||||||
|
if dict_hint:
|
||||||
|
raw_meaning = dict_hint.split(",")[0].strip()
|
||||||
|
else:
|
||||||
|
raw_meaning = ""
|
||||||
|
|
||||||
|
word_meaning = raw_meaning
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
|
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Ollama connection error: {e}")
|
logger.warning(f"Ollama connection error: {e}")
|
||||||
|
|
||||||
# 3. Graceful Fallback if Ollama is unreachable
|
# 4. Graceful Fallback if Ollama is unreachable
|
||||||
if not sentence_ru:
|
if not sentence_ru:
|
||||||
sentence_ru = "(Локальный ИИ временно недоступен)"
|
sentence_ru = "(Локальный ИИ временно недоступен)"
|
||||||
source = "fallback"
|
source = "fallback"
|
||||||
|
|||||||
+10
-7
@@ -2056,10 +2056,13 @@ def resolve_context_sentence_for_term(
|
|||||||
if not clean_term:
|
if not clean_term:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 1. If candidate_sentence already contains this term (case-insensitive word boundary)
|
# 1. If candidate_sentence already contains this term (and is a complete sentence)
|
||||||
if candidate_sentence:
|
if candidate_sentence:
|
||||||
cand_clean = candidate_sentence.strip()
|
cand_clean = candidate_sentence.strip()
|
||||||
if re.search(rf"\b{re.escape(clean_term)}\b", cand_clean, re.IGNORECASE):
|
is_complete = bool(re.search(r"[.!?]['\"”’»)]*$", cand_clean)) and not bool(
|
||||||
|
re.search(r"\b(?:Mr|Mrs|Ms|Miss|Dr|Prof|St|Jr|Sr|[A-Za-z])\.['\"”’»)]*$", cand_clean, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
if is_complete and re.search(rf"\b{re.escape(clean_term)}\b", cand_clean, re.IGNORECASE):
|
||||||
return re.sub(r"\s+", " ", cand_clean.replace("___", clean_term)).strip()
|
return re.sub(r"\s+", " ", cand_clean.replace("___", clean_term)).strip()
|
||||||
|
|
||||||
# 2. Look up in cefr_questions specifically for this term
|
# 2. Look up in cefr_questions specifically for this term
|
||||||
@@ -2075,7 +2078,7 @@ def resolve_context_sentence_for_term(
|
|||||||
|
|
||||||
# 3. Look up in books table
|
# 3. Look up in books table
|
||||||
try:
|
try:
|
||||||
books = conn.execute("SELECT chapters_json FROM books ORDER BY id DESC LIMIT 10").fetchall()
|
books = conn.execute("SELECT chapters_json FROM books ORDER BY id DESC LIMIT 50").fetchall()
|
||||||
for b in books:
|
for b in books:
|
||||||
try:
|
try:
|
||||||
chapters = json.loads(b["chapters_json"])
|
chapters = json.loads(b["chapters_json"])
|
||||||
@@ -2084,10 +2087,10 @@ def resolve_context_sentence_for_term(
|
|||||||
for ch in chapters:
|
for ch in chapters:
|
||||||
content = ch.get("content", "")
|
content = ch.get("content", "")
|
||||||
if clean_term.lower() in content.lower():
|
if clean_term.lower() in content.lower():
|
||||||
for s in re.split(r"(?<=[.!?])\s+", content):
|
from app.miner import robust_sentence_split
|
||||||
s_clean = s.strip()
|
for s_clean in robust_sentence_split(content):
|
||||||
if 20 <= len(s_clean) <= 180 and re.search(rf"\b{re.escape(clean_term)}\b", s_clean, re.IGNORECASE):
|
if 20 <= len(s_clean) <= 240 and re.search(rf"\b{re.escape(clean_term)}\b", s_clean, re.IGNORECASE):
|
||||||
return re.sub(r"\s+", " ", s_clean).strip()
|
return s_clean.strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -42,5 +42,5 @@ DB_PATH = Path(os.getenv("DB_PATH", str(BASE_DIR / "data" / "notes.db")))
|
|||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:3b")
|
||||||
|
|
||||||
+16
-1
@@ -386,7 +386,8 @@ def _migrate(conn: sqlite3.Connection):
|
|||||||
def repair_mismatched_cefr_contexts(conn: sqlite3.Connection):
|
def repair_mismatched_cefr_contexts(conn: sqlite3.Connection):
|
||||||
"""
|
"""
|
||||||
Repairs legacy vocabulary cards that were imported with placeholder blanks ('___')
|
Repairs legacy vocabulary cards that were imported with placeholder blanks ('___')
|
||||||
or with mismatched contexts from CEFR test questions.
|
or with truncated/mismatched contexts from CEFR test questions and reader.
|
||||||
|
Also purges truncated sentence cache entries.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from app.cefr import resolve_context_sentence_for_term
|
from app.cefr import resolve_context_sentence_for_term
|
||||||
@@ -396,11 +397,25 @@ def repair_mismatched_cefr_contexts(conn: sqlite3.Connection):
|
|||||||
if not tbl:
|
if not tbl:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Purge truncated sentence cache entries
|
||||||
|
trans_tbl = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='sentence_translations'").fetchone()
|
||||||
|
if trans_tbl:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM sentence_translations
|
||||||
|
WHERE original_text LIKE '%pecked Mrs.%'
|
||||||
|
OR original_text LIKE '%the John F.'
|
||||||
|
OR instr(original_text, '___') > 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, term, context_sentence
|
SELECT id, term, context_sentence
|
||||||
FROM vocabulary_cards
|
FROM vocabulary_cards
|
||||||
WHERE instr(context_sentence, '___') > 0
|
WHERE instr(context_sentence, '___') > 0
|
||||||
|
OR context_sentence LIKE '%pecked Mrs.%'
|
||||||
|
OR context_sentence LIKE '%the John F.'
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -190,6 +190,7 @@ class RfcFetchRequest(BaseModel):
|
|||||||
class SentenceTranslateRequest(BaseModel):
|
class SentenceTranslateRequest(BaseModel):
|
||||||
sentence: str = Field(..., min_length=1)
|
sentence: str = Field(..., min_length=1)
|
||||||
target_word: Optional[str] = ""
|
target_word: Optional[str] = ""
|
||||||
|
context: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
class CefrAnswerRequest(BaseModel):
|
class CefrAnswerRequest(BaseModel):
|
||||||
@@ -446,7 +447,7 @@ async def api_translate_sentence(
|
|||||||
clean_s = req.sentence.strip()
|
clean_s = req.sentence.strip()
|
||||||
if not clean_s:
|
if not clean_s:
|
||||||
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
|
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
|
||||||
return await translate_sentence_ai(clean_s, req.target_word or "")
|
return await translate_sentence_ai(clean_s, req.target_word or "", req.context or "")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ai/status")
|
@app.get("/api/ai/status")
|
||||||
|
|||||||
+47
-13
@@ -102,31 +102,65 @@ for _lvl, _pos_map in CEFR_LEXICON.items():
|
|||||||
_WORD_INDEX[_w_low] = []
|
_WORD_INDEX[_w_low] = []
|
||||||
_WORD_INDEX[_w_low].append((_lvl, _pos))
|
_WORD_INDEX[_w_low].append((_lvl, _pos))
|
||||||
|
|
||||||
# Common abbreviations to prevent false splits
|
def robust_sentence_split(text: str) -> List[str]:
|
||||||
_HONORIFICS = ["Mr.", "Mrs.", "Ms.", "Dr.", "Prof.", "St.", "vs.", "etc.", "e.g.", "i.e.", "Jr.", "Sr."]
|
"""
|
||||||
|
Splits English narrative prose into clean, complete sentences.
|
||||||
|
Protects decimals, acronyms, single-letter initials, honorifics, titles, and abbreviations.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Step 1: Protect decimal numbers (e.g. 3.14, 1.5)
|
||||||
|
s = re.sub(r"(\d+)\.(\d+)", r"\1§DOT§\2", text)
|
||||||
|
|
||||||
|
# Step 2: Protect acronyms with dots like U.S., U.K., e.g., i.e., a.m., p.m., Ph.D.
|
||||||
|
s = re.sub(r"\b((?:[A-Za-z]\.){2,})", lambda m: m.group(1).replace(".", "§DOT§"), s)
|
||||||
|
|
||||||
|
# Step 3: Protect single-letter initials like 'John F. Kennedy', 'J. K. Rowling'
|
||||||
|
s = re.sub(r'\b([A-Za-z])\.(?=\s+[A-Za-z0-9"“\'‘«—])', r"\1§DOT§", s)
|
||||||
|
|
||||||
|
# Step 4: Protect common honorifics, titles, and abbreviations
|
||||||
|
abbr_pattern = (
|
||||||
|
r"\b("
|
||||||
|
r"Mr|Mrs|Ms|Miss|Dr|Prof|Rev|Hon|Capt|Col|Gen|Lt|Cmdr|Sgt|Cpl|Pvt|Maj|Adm|"
|
||||||
|
r"Gov|Sen|Rep|Pres|Sec|Treas|Insp|Det|Supt|Messrs|Mmes|St|Mt|Ft|"
|
||||||
|
r"etc|vs|v|Jr|Sr|approx|app|apt|dept|univ|assn|assoc|bldg|co|corp|inc|ltd|"
|
||||||
|
r"est|misc|al|ibid|op|cit|ed|eds|trans|vol|vols|no|nos|sec|secs|ch|chs|"
|
||||||
|
r"fig|figs|p|pp|ave|blvd|rd|st|ct|ln|dr|hwy|"
|
||||||
|
r"jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec"
|
||||||
|
r")\."
|
||||||
|
)
|
||||||
|
s = re.sub(abbr_pattern, r"\1§DOT§", s, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Step 5: Protect ellipsis (... or ....)
|
||||||
|
s = re.sub(r"\.{2,}", lambda m: "§ELLIPSIS§" + ("§DOT§" * len(m.group(0))), s)
|
||||||
|
|
||||||
|
# Step 6: Protect any period followed by lowercase letter
|
||||||
|
s = re.sub(r"\.([\'\"”’»)]*\s+[a-zа-яё])", r"§DOT§\1", s)
|
||||||
|
|
||||||
|
# Step 7: Split on sentence terminators [.!?] followed by whitespace and uppercase/number/quote
|
||||||
|
marked = re.sub(r'([.!?][\'\"”’»)]*)(\s+)(?=[A-Z0-9"“\'‘«—]|$)', r"\1§SEP§", s)
|
||||||
|
|
||||||
|
raw_sentences = [
|
||||||
|
re.sub(r"\s+", " ", part.replace("§DOT§", ".").replace("§ELLIPSIS§", "")).strip()
|
||||||
|
for part in marked.split("§SEP§")
|
||||||
|
]
|
||||||
|
return [sent for sent in raw_sentences if sent]
|
||||||
|
|
||||||
|
|
||||||
def split_into_clean_sentences(text: str) -> List[str]:
|
def split_into_clean_sentences(text: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Safely splits narrative prose into clean, grammatically complete sentences.
|
Safely splits narrative prose into clean, grammatically complete sentences.
|
||||||
Protects honorifics and abbreviations from being split incorrectly.
|
Protects honorifics, initials, and abbreviations from being split incorrectly.
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Protect common abbreviations and titles
|
raw_splits = robust_sentence_split(text)
|
||||||
protected = text
|
|
||||||
for abbr in _HONORIFICS:
|
|
||||||
protected = protected.replace(abbr, abbr.replace(".", "§DOT§"))
|
|
||||||
|
|
||||||
# Split on sentence-ending punctuation (.!?) followed by space or newline
|
|
||||||
raw_splits = re.split(r"(?<=[.!?])\s+", protected)
|
|
||||||
|
|
||||||
clean_sentences = []
|
clean_sentences = []
|
||||||
for s in raw_splits:
|
for s in raw_splits:
|
||||||
restored = s.replace("§DOT§", ".").strip()
|
flattened = re.sub(r"\s+", " ", s).strip()
|
||||||
# Flatten multiple spaces and line breaks
|
|
||||||
flattened = re.sub(r"\s+", " ", restored)
|
|
||||||
|
|
||||||
# Basic validity filters:
|
# Basic validity filters:
|
||||||
# 1. Skip dialogue lines starting with quotation marks or dashes
|
# 1. Skip dialogue lines starting with quotation marks or dashes
|
||||||
|
|||||||
@@ -114,13 +114,14 @@ export const api = {
|
|||||||
const resp = await fetch(`/api/detect-phrasal-verb?sentence=${encodeURIComponent(sentence)}&word=${encodeURIComponent(word)}`);
|
const resp = await fetch(`/api/detect-phrasal-verb?sentence=${encodeURIComponent(sentence)}&word=${encodeURIComponent(word)}`);
|
||||||
return resp.ok ? await resp.json() : { detected: false, phrasal_verb: null };
|
return resp.ok ? await resp.json() : { detected: false, phrasal_verb: null };
|
||||||
},
|
},
|
||||||
async translateSentence(sentence, targetWord = '') {
|
async translateSentence(sentence, targetWord = '', context = '') {
|
||||||
const resp = await fetch('/api/translate-sentence', {
|
const resp = await fetch('/api/translate-sentence', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sentence: sentence,
|
sentence: sentence,
|
||||||
target_word: targetWord
|
target_word: targetWord,
|
||||||
|
context: context
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
|||||||
@@ -1104,40 +1104,51 @@ export function renderInteractiveSpans(container, text, contextText, knownWordsM
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const READER_HONORIFICS = [
|
export function robustSentenceSplit(text) {
|
||||||
'Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'St.', 'vs.', 'etc.', 'e.g.', 'i.e.', 'Jr.', 'Sr.',
|
if (!text) return [];
|
||||||
'Capt.', 'Col.', 'Gen.', 'Lt.', 'Sgt.', 'Rev.', 'Hon.'
|
let s = text;
|
||||||
];
|
|
||||||
|
// Step 1: Protect decimal numbers (e.g. 3.14, 1.5)
|
||||||
|
s = s.replace(/(\d+)\.(\d+)/g, '$1§DOT§$2');
|
||||||
|
|
||||||
|
// Step 2: Protect acronyms with dots like U.S., U.K., e.g., i.e., a.m., p.m., Ph.D.
|
||||||
|
s = s.replace(/\b((?:[A-Za-z]\.){2,})/g, (m) => m.replace(/\./g, '§DOT§'));
|
||||||
|
|
||||||
|
// Step 3: Protect single-letter initials like 'John F. Kennedy', 'J. K. Rowling'
|
||||||
|
s = s.replace(/\b([A-Za-z])\.(?=\s+[A-Za-z0-9"“'‘«—])/g, '$1§DOT§');
|
||||||
|
|
||||||
|
// Step 4: Protect common honorifics, titles, and abbreviations
|
||||||
|
const abbrPattern = /\b(Mr|Mrs|Ms|Miss|Dr|Prof|Rev|Hon|Capt|Col|Gen|Lt|Cmdr|Sgt|Cpl|Pvt|Maj|Adm|Gov|Sen|Rep|Pres|Sec|Treas|Insp|Det|Supt|Messrs|Mmes|St|Mt|Ft|etc|vs|v|Jr|Sr|approx|app|apt|dept|univ|assn|assoc|bldg|co|corp|inc|ltd|est|misc|al|ibid|op|cit|ed|eds|trans|vol|vols|no|nos|sec|secs|ch|chs|fig|figs|p|pp|ave|blvd|rd|st|ct|ln|dr|hwy|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\./gi;
|
||||||
|
s = s.replace(abbrPattern, '$1§DOT§');
|
||||||
|
|
||||||
|
// Step 5: Protect ellipsis (... or ....)
|
||||||
|
s = s.replace(/\.{2,}/g, (m) => '§ELLIPSIS§' + '§DOT§'.repeat(m.length));
|
||||||
|
|
||||||
|
// Step 6: Protect any period followed by lowercase letter (e.g. 'word. otherword')
|
||||||
|
s = s.replace(/\.([\'"”’»)]*\s+[a-zа-яё])/g, '§DOT§$1');
|
||||||
|
|
||||||
|
// Step 7: Split on sentence terminators [.!?] followed by whitespace and uppercase/number/quote/start of dialog
|
||||||
|
const marked = s.replace(/([.!?]['"”’»)]*)(\s+)(?=[A-Z0-9"“'‘«—]|$)/g, '$1§SEP§');
|
||||||
|
|
||||||
|
return marked.split('§SEP§')
|
||||||
|
.map(part => part.replace(/§DOT§/g, '.').replace(/§ELLIPSIS§/g, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractContextSentence(fullText, word) {
|
export function extractContextSentence(fullText, word) {
|
||||||
if (!fullText) return '';
|
if (!fullText) return '';
|
||||||
let protectedText = fullText;
|
const sentences = robustSentenceSplit(fullText);
|
||||||
for (const h of READER_HONORIFICS) {
|
|
||||||
if (protectedText.toLowerCase().includes(h.toLowerCase())) {
|
|
||||||
const safe = h.replace(/\./g, '§DOT§');
|
|
||||||
const reH = new RegExp(escapeRegex(h), 'gi');
|
|
||||||
protectedText = protectedText.replace(reH, safe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Protect decimal numbers (e.g. 3.14, 1.5)
|
|
||||||
protectedText = protectedText.replace(/(\d+)\.(\d+)/g, '$1§DOT§$2');
|
|
||||||
|
|
||||||
// Split sentences on terminal punctuation (.!?) followed by optional quotes/brackets and whitespace
|
|
||||||
const marked = protectedText.replace(/([.!?]['"”’»)]*)\s+/g, '$1§SEP§');
|
|
||||||
const rawSentences = marked.split('§SEP§');
|
|
||||||
|
|
||||||
const cleanWord = (word || '').trim();
|
const cleanWord = (word || '').trim();
|
||||||
if (cleanWord) {
|
if (cleanWord) {
|
||||||
const escaped = escapeRegex(cleanWord);
|
const escaped = escapeRegex(cleanWord);
|
||||||
const wordRe = new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i');
|
const wordRe = new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i');
|
||||||
for (const s of rawSentences) {
|
for (const s of sentences) {
|
||||||
if (wordRe.test(s)) {
|
if (wordRe.test(s)) {
|
||||||
return s.replace(/§DOT§/g, '.').trim();
|
return s.replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return sentences.length > 0 ? sentences[0].replace(/\s+/g, ' ').trim() : fullText.slice(0, 160).trim();
|
||||||
return fullText.slice(0, 160).trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1176,7 +1187,7 @@ export function positionWordPopup(targetElement) {
|
|||||||
readerWordPopup.style.left = `${Math.round(left)}px`;
|
readerWordPopup.style.left = `${Math.round(left)}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWordPopup(term, contextSentence, targetElement) {
|
export async function openWordPopup(term, contextSentence, targetElement, paragraphContext = '') {
|
||||||
const readerWordPopup = document.getElementById('reader-word-popup');
|
const readerWordPopup = document.getElementById('reader-word-popup');
|
||||||
const readerSettingsPanel = document.getElementById('reader-settings-panel');
|
const readerSettingsPanel = document.getElementById('reader-settings-panel');
|
||||||
const popupTerm = document.getElementById('popup-term');
|
const popupTerm = document.getElementById('popup-term');
|
||||||
@@ -1202,6 +1213,7 @@ export async function openWordPopup(term, contextSentence, targetElement) {
|
|||||||
popupCurrentData = {
|
popupCurrentData = {
|
||||||
term: cleanTerm,
|
term: cleanTerm,
|
||||||
contextSentence: contextSentence,
|
contextSentence: contextSentence,
|
||||||
|
paragraphContext: paragraphContext,
|
||||||
targetElement: targetElement,
|
targetElement: targetElement,
|
||||||
existingCard: existingCard,
|
existingCard: existingCard,
|
||||||
};
|
};
|
||||||
@@ -1339,7 +1351,7 @@ export async function openWordPopup(term, contextSentence, targetElement) {
|
|||||||
|
|
||||||
export async function handleWordClick(word, paragraphText, targetElement) {
|
export async function handleWordClick(word, paragraphText, targetElement) {
|
||||||
const sentence = extractContextSentence(paragraphText, word);
|
const sentence = extractContextSentence(paragraphText, word);
|
||||||
openWordPopup(word, sentence, targetElement);
|
openWordPopup(word, sentence, targetElement, paragraphText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePopupPositionOnScroll() {
|
function updatePopupPositionOnScroll() {
|
||||||
@@ -1965,7 +1977,7 @@ export function initReader() {
|
|||||||
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
|
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.lookup.translateSentence(ctxSentence, term);
|
const res = await api.lookup.translateSentence(ctxSentence, term, popupCurrentData.paragraphContext || '');
|
||||||
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
|
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
|
||||||
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
|
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Service Worker for Notes English PWA (Stage 2)
|
// Service Worker for Notes English PWA (Stage 2)
|
||||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||||
|
|
||||||
const CACHE_NAME = 'notes-v5.9';
|
const CACHE_NAME = 'notes-v6.0';
|
||||||
const OFFLINE_URL = '/';
|
const OFFLINE_URL = '/';
|
||||||
|
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ services:
|
|||||||
- ADMIN_USERNAME=kopri
|
- ADMIN_USERNAME=kopri
|
||||||
- DB_PATH=/app/data/notes.db
|
- DB_PATH=/app/data/notes.db
|
||||||
- OLLAMA_BASE_URL=http://ollama:11434
|
- OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
- OLLAMA_MODEL=qwen2.5:3b
|
||||||
depends_on:
|
depends_on:
|
||||||
- ollama
|
- ollama
|
||||||
|
|
||||||
|
|||||||
@@ -1867,6 +1867,94 @@ def test_cefr_context_resolution_and_repair():
|
|||||||
assert "negotiate" in repaired["context_sentence"].lower()
|
assert "negotiate" in repaired["context_sentence"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_robust_sentence_split_and_ai_translation(monkeypatch):
|
||||||
|
from app.miner import robust_sentence_split
|
||||||
|
from app.ai import build_translation_prompt
|
||||||
|
|
||||||
|
# 1. Test robust sentence segmentation
|
||||||
|
# Case A: Initials in names (John F. Kennedy)
|
||||||
|
s_jfk = "Perhaps the most famous is the John F. Kennedy International Airport in New York, but there is also the John F. Kennedy Space Center."
|
||||||
|
splits_jfk = robust_sentence_split(s_jfk)
|
||||||
|
assert len(splits_jfk) == 1
|
||||||
|
assert "John F. Kennedy International Airport" in splits_jfk[0]
|
||||||
|
|
||||||
|
# Case B: Honorifics with dots (Mr., Mrs., Dr., etc.)
|
||||||
|
s_dursley = "When Mr. and Mrs. Dursley woke up on the dull, gray Tuesday our story starts, there was nothing about the cloudy sky outside to suggest that strange things would happen."
|
||||||
|
splits_dursley = robust_sentence_split(s_dursley)
|
||||||
|
assert len(splits_dursley) == 1
|
||||||
|
assert "Mr. and Mrs. Dursley" in splits_dursley[0]
|
||||||
|
|
||||||
|
# Case C: Abbreviations (No. 4, U.S. Navy)
|
||||||
|
s_no4 = "They lived at No. 4 Privet Drive, which was a very quiet street. Next sentence starts here."
|
||||||
|
splits_no4 = robust_sentence_split(s_no4)
|
||||||
|
assert len(splits_no4) == 2
|
||||||
|
assert splits_no4[0] == "They lived at No. 4 Privet Drive, which was a very quiet street."
|
||||||
|
assert splits_no4[1] == "Next sentence starts here."
|
||||||
|
|
||||||
|
s_us = "The men thought that the U.S. Navy would send a plane to look for them. They waited patiently."
|
||||||
|
splits_us = robust_sentence_split(s_us)
|
||||||
|
assert len(splits_us) == 2
|
||||||
|
assert "U.S. Navy" in splits_us[0]
|
||||||
|
|
||||||
|
# Case D: Complex paragraph with Mr., Mrs., dialogue
|
||||||
|
s_hp = (
|
||||||
|
"At half past eight, Mr. Dursley picked up his briefcase, pecked Mrs. Dursley on the cheek, "
|
||||||
|
"and tried to kiss Dudley good-bye but missed, because Dudley was now having a tantrum and "
|
||||||
|
"throwing his cereal at the walls. “Little tyke,” chortled Mr. Dursley as he left the house."
|
||||||
|
)
|
||||||
|
splits_hp = robust_sentence_split(s_hp)
|
||||||
|
assert len(splits_hp) == 2
|
||||||
|
assert "pecked Mrs. Dursley on the cheek" in splits_hp[0]
|
||||||
|
assert splits_hp[0].startswith("At half past eight, Mr. Dursley")
|
||||||
|
assert "Little tyke" in splits_hp[1]
|
||||||
|
|
||||||
|
# 2. Test build_translation_prompt
|
||||||
|
prompt = build_translation_prompt(
|
||||||
|
sentence="At half past eight, Mr. Dursley picked up his briefcase.",
|
||||||
|
target_word="briefcase",
|
||||||
|
context="Full context paragraph here.",
|
||||||
|
dict_hint="портфель, кейс, дипломат",
|
||||||
|
)
|
||||||
|
assert "Контекст абзаца:" in prompt
|
||||||
|
assert "Full context paragraph here." in prompt
|
||||||
|
assert "briefcase" in prompt
|
||||||
|
assert "портфель, кейс, дипломат" in prompt
|
||||||
|
assert "Mr. -> мистер" in prompt
|
||||||
|
assert "НЕ дословным, без топорного калькирования" in prompt
|
||||||
|
|
||||||
|
# 3. Test API endpoint /api/translate-sentence
|
||||||
|
# Authenticate client
|
||||||
|
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
|
||||||
|
# Mock translate_sentence_ai
|
||||||
|
import app.main as main_mod
|
||||||
|
|
||||||
|
async def mock_translate_sentence_ai(sentence, target_word="", context=""):
|
||||||
|
return {
|
||||||
|
"original_sentence": sentence,
|
||||||
|
"sentence_ru": "В половине девятого мистер Дурсли взял свой портфель.",
|
||||||
|
"target_word": target_word,
|
||||||
|
"word_meaning": "портфель",
|
||||||
|
"source": "mock_ollama",
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_mod, "translate_sentence_ai", mock_translate_sentence_ai)
|
||||||
|
|
||||||
|
tr_resp = client.post("/api/translate-sentence", json={
|
||||||
|
"sentence": "At half past eight, Mr. Dursley picked up his briefcase.",
|
||||||
|
"target_word": "briefcase",
|
||||||
|
"context": "Context paragraph",
|
||||||
|
})
|
||||||
|
assert tr_resp.status_code == 200
|
||||||
|
tr_data = tr_resp.json()
|
||||||
|
assert tr_data["sentence_ru"] == "В половине девятого мистер Дурсли взял свой портфель."
|
||||||
|
assert tr_data["word_meaning"] == "портфель"
|
||||||
|
assert tr_data["target_word"] == "briefcase"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user