Skip to content

Commit c0cab0d

Browse files
committed
feat(accent): regex reading-override layer + URL/non-JP preprocessing
Add api/accent/reading_overrides.py — a context-blind correction layer sitting between Yahoo Furigana and OJAD alignment. Each override is a regex on the concatenated surface text plus the replacement tokens that should appear instead. Covers: - 曜日 brackets: (月)/(月)→ げつ, (土) → ど, etc. for all 7 weekdays. - All 31 day-of-month readings: 1日 → ついたち (atamadaka), 5日 → いつか, 14日 → じゅうよっか, 20日 → はつか, etc. - N日間 durations 1-31: 1日間 → いちにちかん (NOT ついたちかん since the 1st-of-month reading is impossible for a duration), 7日間 → しちにちかん (modern technical writing preference over なのかかん). - 20歳 / 二十歳 / 20才 → はたち (the only irregular age reading). Patterns accept arabic / full-width / kanji numeral variants of the same N so `3月5日(土)` / `3月5日(土)` / `三月五日(土)` all trigger the same overrides. Order-of-overrides matters: duration list precedes date list so `N日間` wins over `N日` at the same start (longer match breaks ties in _collect_matches). apply_furigana_overrides runs BEFORE align_accent so merged spans like `5日→いつか` reach OJAD as a single token whose furigana matches OJAD's phrase reading (the numeric-anchor logic in align_accent otherwise cascades-fails because numeric tokens lack any Yahoo furigana). apply_accent_overrides runs AFTER align to re-stamp both furigana and accent on the same matched spans, so the response is consistent. Adds URL preprocessing: each https?:// is swapped for the placeholder "URLPLACEHOLDER" before the pipeline runs (Yahoo fragments URLs across several alphabet tokens; OJAD's phrasing scraper produces noise for Latin punctuation runs — both drag alignment off-rail). Placeholders are walked back to the originals in order after alignment. URL body stops at whitespace, any Japanese char, or `,()<>[]"'` so embedded URLs strip cleanly. Adds a non-Japanese short-circuit: if (after URL stripping) the chunk contains no hiragana / katakana / CJK ideograph, skip Yahoo + OJAD entirely and echo the chunk back as a single token. Lets pure-URL / pure-English lines stream through cheaply. Also adds stream_accent_chunks() to pipeline.py as a helper used by the streaming endpoint added in the next commit. Splits the input on \n then on full-width sentence terminators (。!?.) — long paragraphs degrade OJAD's phrasing predictor and parallelising across sentences caps the latency. In-flight work is bounded by a semaphore (concurrency=4) because OJAD's u-tokyo backend falls over with 30+ parallel scrapes. main.py docstring updated to reflect /MarkAccent/stream/. Refs #47.
1 parent 561d420 commit c0cab0d

3 files changed

Lines changed: 681 additions & 13 deletions

File tree

api/accent/pipeline.py

Lines changed: 221 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,179 @@
11
"""MarkAccent orchestrator.
22
3-
Threads the three data-layer modules together:
3+
Threads the data-layer modules together and applies the surface-level
4+
regex override layer on either side of the OJAD alignment:
5+
46
1. `furigana.fetch_furigana` — tokenise + read with Yahoo Furigana
5-
2. `ojad.get_ojad_result` — pull per-mora pitch contour from OJAD
6-
3. `align.align_accent` — match tokens ↔ OJAD spans → WordAccentResult
7+
2. `reading_overrides.apply_furigana_overrides` — fix date / weekday-
8+
bracket readings BEFORE alignment so OJAD's numeric-anchor logic
9+
doesn't cascade-fail on overridden spans
10+
3. `ojad.get_ojad_result` — pull per-mora pitch contour from OJAD
11+
4. `align.align_accent` — DP-match tokens ↔ OJAD spans
12+
5. `reading_overrides.apply_accent_overrides` — re-apply overrides
13+
over (furigana, accent) so the final response stays consistent
14+
15+
Plus pre-/post-processing helpers for URL stripping, non-Japanese
16+
short-circuit, sentence splitting (used by the streaming endpoint),
17+
and the streaming chunk-fanout itself.
718
8-
The route handler in `routes.py` wraps this with FastAPI request handling.
19+
The route handlers in `routes.py` wrap this with FastAPI request handling.
920
"""
1021

1122
from __future__ import annotations
1223

24+
import asyncio
25+
import json
1326
import logging
27+
import re
28+
from typing import Any, AsyncIterator
1429

1530
import httpx
1631
import neologdn
1732

1833
from api.accent.align import align_accent
1934
from api.accent.furigana import fetch_furigana
20-
from api.accent.models import AccentResponse, ErrorInfo
35+
from api.accent.models import (
36+
AccentResponse,
37+
ErrorInfo,
38+
Request,
39+
WordAccentResult,
40+
)
2141
from api.accent.ojad import get_ojad_result
42+
from api.accent.reading_overrides import (
43+
apply_accent_overrides,
44+
apply_furigana_overrides,
45+
)
2246

2347
logger = logging.getLogger("api")
2448

2549

50+
# Hiragana / katakana / CJK Unified Ideographs (incl. Extension A). A
51+
# chunk with no chars in this set is treated as pure English / code /
52+
# markdown / URL — pipeline is skipped entirely and the line is echoed
53+
# back verbatim so document reconstruction still works.
54+
_CJK_RE = re.compile(
55+
"["
56+
"぀-ゟ" # Hiragana
57+
"゠-ヿ" # Katakana
58+
"㐀-䶿" # CJK Unified Ideographs Extension A
59+
"一-鿿" # CJK Unified Ideographs
60+
"]"
61+
)
62+
63+
# Sentence terminators that close a Japanese clause: kuten (。), full-width
64+
# question (?), full-width exclamation (!), and full-width period (.).
65+
# ASCII `.!?` are intentionally excluded — they appear in abbreviations,
66+
# decimals, and code/identifier fragments that we don't want to split on.
67+
# A zero-width split (lookbehind) keeps the terminator attached to the
68+
# preceding sentence so accent prediction still sees the clause boundary.
69+
_SENTENCE_SPLIT_RE = re.compile("(?<=[。!?.])")
70+
71+
# URLs are stripped before the pipeline runs. OJAD's phrasing scraper
72+
# produces only noise for Latin punctuation runs, and Yahoo's tokenizer
73+
# can fragment a URL across several alphabet/symbol tokens — both drag
74+
# the alignment DP off-rail for the surrounding Japanese. We swap each
75+
# URL for one fixed-string placeholder (which Yahoo keeps as a single
76+
# "alphabet" word), run the pipeline, then walk the result and restore
77+
# the originals in order.
78+
# URL body stops at whitespace, any Japanese char (so `…はhttps://x.jp/aです`
79+
# strips just the URL, leaving `です` to be processed), or common quoting
80+
# punctuation `,()<>[]"'` (so `(https://x.jp)` strips just the URL).
81+
_URL_RE = re.compile(r"https?://[^\s -鿿,()<>\[\]\"']+")
82+
_URL_PLACEHOLDER = "URLPLACEHOLDER"
83+
84+
85+
def _has_japanese(text: str) -> bool:
86+
"""True if `text` contains any hiragana, katakana, or CJK ideograph."""
87+
return bool(_CJK_RE.search(text))
88+
89+
90+
def _split_sentences(line: str) -> list[str]:
91+
"""Split a line into sentence-sized chunks for parallel processing.
92+
93+
OJAD's phrasing module degrades badly on long inputs (a single
94+
misaligned mora can cascade across the whole paragraph), and the
95+
streaming endpoint can't parallelise within a `\\n`-delimited chunk.
96+
Splitting on full-width sentence terminators fixes both: each sentence
97+
is short enough for OJAD to handle reliably, and they fan out across
98+
the in-flight Semaphore.
99+
"""
100+
return [s for s in _SENTENCE_SPLIT_RE.split(line) if s.strip()]
101+
102+
103+
def _strip_urls(text: str) -> tuple[str, list[str]]:
104+
"""Replace each URL with `_URL_PLACEHOLDER`, returning URLs in order."""
105+
urls: list[str] = []
106+
107+
def repl(m: re.Match[str]) -> str:
108+
urls.append(m.group(0))
109+
return _URL_PLACEHOLDER
110+
111+
return _URL_RE.sub(repl, text), urls
112+
113+
114+
def _restore_urls(
115+
result: list[WordAccentResult], urls: list[str]
116+
) -> list[WordAccentResult]:
117+
"""Swap placeholder tokens in `result` back to their original URLs."""
118+
if not urls:
119+
return result
120+
it = iter(urls)
121+
out: list[WordAccentResult] = []
122+
for w in result:
123+
if w.surface == _URL_PLACEHOLDER:
124+
url = next(it, None)
125+
if url is None:
126+
# Placeholder count exceeded URL count: leave the token
127+
# untouched. Indicates a Yahoo tokenization surprise; the
128+
# output is still readable.
129+
out.append(w)
130+
continue
131+
out.append(
132+
WordAccentResult(surface=url, furigana=url, accent=[], subword=[])
133+
)
134+
else:
135+
out.append(w)
136+
return out
137+
138+
26139
async def process_accent_chunk(text: str, client: httpx.AsyncClient) -> AccentResponse:
27-
"""Run the full MarkAccent pipeline on a single chunk of text."""
140+
"""Run the full MarkAccent pipeline on a single chunk of text.
141+
142+
Shared by `/api/MarkAccent/` (whole input as one chunk) and
143+
`/api/MarkAccent/stream/` (one call per `\\n`/sentence-split piece).
144+
"""
28145
try:
29146
query_text = neologdn.normalize(text, tilde="normalize")
30147

31-
furigana_response = await fetch_furigana(query_text, client)
148+
# Strip URLs first so a pure-URL line is detected as non-Japanese
149+
# by the language check below and short-circuits the pipeline.
150+
stripped_text, urls = _strip_urls(query_text)
151+
152+
# No hiragana/katakana/kanji outside URLs — passthrough the line
153+
# as a single token. Callers reconstructing the document still
154+
# see the chunk in the stream; we just skip the Yahoo + OJAD
155+
# round-trips entirely.
156+
if not _has_japanese(stripped_text):
157+
return AccentResponse(
158+
status=200,
159+
result=[
160+
WordAccentResult(
161+
surface=query_text,
162+
furigana=query_text,
163+
accent=[],
164+
subword=[],
165+
)
166+
],
167+
error=None,
168+
)
169+
170+
# Apply furigana overrides BEFORE alignment: many of the overrides
171+
# (e.g. "4日"→"よっか", "27日"→"にじゅうしちにち") merge a numeric
172+
# surface with the counter into one token whose furigana matches what
173+
# OJAD reads as a single phrase. align_accent's numeric-anchor logic
174+
# otherwise cascades-fails on these inputs because numeric tokens lack
175+
# any Yahoo furigana for OJAD to align against.
176+
furigana_response = await fetch_furigana(stripped_text, client)
32177

33178
# Check yahoo furigana response
34179
if furigana_response.status != 200 or not furigana_response.result:
@@ -39,19 +184,85 @@ async def process_accent_chunk(text: str, client: httpx.AsyncClient) -> AccentRe
39184
error=furigana_response.error,
40185
)
41186

42-
furigana_results = furigana_response.result
187+
furigana_results = apply_furigana_overrides(furigana_response.result)
43188
logger.debug(f"Yahoo Results Count: {len(furigana_results)}")
44189

45-
_ojad_surface, ojad_results = await get_ojad_result(query_text, client)
190+
_ojad_surface, ojad_results = await get_ojad_result(stripped_text, client)
46191

47192
final_results = await align_accent(furigana_results, ojad_results)
193+
final_results = apply_accent_overrides(final_results)
194+
final_results = _restore_urls(final_results, urls)
48195

49196
return AccentResponse(status=200, result=final_results)
50197

51198
except Exception as e:
52199
logger.exception(f"Unexpected error occurred: {text}")
200+
# Some httpx exceptions (PoolTimeout, ReadTimeout) have empty
201+
# str(); fall back to the type name so the client sees something.
202+
detail = str(e) or repr(e) or type(e).__name__
53203
return AccentResponse(
54204
status=500,
55205
result=None,
56-
error=ErrorInfo(code=500, message=f"Error: {e}"),
206+
error=ErrorInfo(code=500, message=f"Error: {detail}"),
57207
)
208+
209+
210+
# Streaming endpoint: OJAD's u-tokyo backend and (to a lesser extent) Yahoo's
211+
# furigana API both fall over when hit with 30+ parallel scrapes — the symptom
212+
# was most chunks of a long document returning empty-string httpx errors. Cap
213+
# in-flight work so well-behaved inputs still parallelise (a 4-chunk
214+
# paragraph fans out fully) without hammering the upstream services.
215+
_STREAM_CONCURRENCY = 4
216+
217+
218+
async def stream_accent_chunks(
219+
request: Request, client: httpx.AsyncClient
220+
) -> AsyncIterator[bytes]:
221+
"""Yield one NDJSON line per (line_idx, sub_idx) chunk in input order.
222+
223+
Each emitted object carries `{"chunk": line_idx, "subchunk": sub_idx}`:
224+
`line_idx` is the original `\\n`-split index (blank lines are dropped from
225+
the stream); `sub_idx` distinguishes sentences inside one line. A line
226+
with no terminator yields one subchunk with `sub_idx=0`.
227+
"""
228+
# (line_idx, sub_idx, text). Long paragraphs are split into sentence-
229+
# sized chunks because OJAD's phrasing predictor degrades on long
230+
# inputs and a single misalignment used to cascade across the whole
231+
# paragraph. Splitting also fans the work out under the semaphore.
232+
chunks: list[tuple[int, int, str]] = []
233+
for line_idx, line in enumerate(request.text.split("\n")):
234+
if not line.strip():
235+
continue
236+
for sub_idx, sentence in enumerate(_split_sentences(line)):
237+
chunks.append((line_idx, sub_idx, sentence))
238+
239+
if not chunks:
240+
return
241+
242+
semaphore = asyncio.Semaphore(_STREAM_CONCURRENCY)
243+
244+
async def run_chunk(line: str) -> AccentResponse:
245+
async with semaphore:
246+
return await process_accent_chunk(line, client)
247+
248+
tasks = [asyncio.create_task(run_chunk(text)) for _, _, text in chunks]
249+
# Yield in input order so the client renders chunks monotonically.
250+
for (chunk_idx, sub_idx, _text), task in zip(chunks, tasks):
251+
try:
252+
resp = await task
253+
payload: dict[str, Any] = {
254+
"chunk": chunk_idx,
255+
"subchunk": sub_idx,
256+
**resp.model_dump(),
257+
}
258+
except Exception as exc:
259+
logger.exception(f"Streaming chunk {chunk_idx}.{sub_idx} failed")
260+
detail = str(exc) or repr(exc) or type(exc).__name__
261+
payload = {
262+
"chunk": chunk_idx,
263+
"subchunk": sub_idx,
264+
"status": 500,
265+
"result": None,
266+
"error": {"code": 500, "message": f"Error: {detail}"},
267+
}
268+
yield (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")

0 commit comments

Comments
 (0)