Skip to content

Commit a9a8fb8

Browse files
committed
feat(accent): add /MarkAccent/stream/ NDJSON endpoint + dev helpers
Add a streaming variant of /MarkAccent/ that processes the input as a sequence of (line, sentence) chunks and emits one NDJSON object per chunk in input order. Each line carries `{"chunk": line_idx, "subchunk": sub_idx, ...AccentResponse}` so clients can render output incrementally while keeping document position. Underlying chunk-fanout and concurrency limiting live in pipeline.stream_accent_chunks; the route is a thin StreamingResponse wrapper. Streaming benefits compound: OJAD's phrasing predictor degrades on long inputs (a single misaligned mora cascades across the paragraph), so per-sentence chunks both stay short enough for OJAD to handle and fan out under the bounded semaphore. Also adds test.sh — a small bash smoke-test helper that POSTs a sample text to either /MarkAccent/ or /MarkFurigana/ and pretty-prints the per-moji (surface|furigana|accent_marking_type) rows. STREAM=1 switches to the streaming endpoint, ENDPOINT= picks which router. Useful while iterating on overrides; not wired into CI. .gitignore adds data/ and output/ for ad-hoc test fixtures we don't want committed. Refs #47.
1 parent c0cab0d commit a9a8fb8

3 files changed

Lines changed: 127 additions & 4 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,7 @@ __marimo__/
216216
.streamlit/secrets.toml
217217

218218
# Secrest files
219-
secret*
219+
secret*
220+
221+
data/
222+
output/

api/accent/routes.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""FastAPI routers for MarkAccent and MarkFurigana.
22
33
Each endpoint is a thin wrapper around its data layer:
4-
- /MarkFurigana/ → `furigana.fetch_furigana`
5-
- /MarkAccent/ → `pipeline.process_accent_chunk`
4+
- /MarkFurigana/ → `furigana.fetch_furigana`
5+
- /MarkAccent/ → `pipeline.process_accent_chunk`
6+
- /MarkAccent/stream/ → `pipeline.stream_accent_chunks` (NDJSON)
67
78
Two separate routers (rather than one shared one) keep the OpenAPI
89
tagging clean and let main.py register each with its own
@@ -15,10 +16,11 @@
1516

1617
import httpx
1718
from fastapi import APIRouter, Depends
19+
from fastapi.responses import StreamingResponse
1820

1921
from api.accent.furigana import fetch_furigana
2022
from api.accent.models import AccentResponse, FuriganaResponse, Request
21-
from api.accent.pipeline import process_accent_chunk
23+
from api.accent.pipeline import process_accent_chunk, stream_accent_chunks
2224
from api.dependencies import get_http_client
2325

2426
logger = logging.getLogger("api")
@@ -57,3 +59,19 @@ async def mark_accent(
5759
"""Receive POST request, return an AccentResponse object."""
5860
logger.info(f"[API] Received Request Text: {request.text}")
5961
return await process_accent_chunk(request.text, client)
62+
63+
64+
@accent_router.post("/MarkAccent/stream/", tags=["MarkAccent"])
65+
async def mark_accent_stream(
66+
request: Request,
67+
client: httpx.AsyncClient = Depends(get_http_client),
68+
) -> StreamingResponse:
69+
"""Split the input on `\\n` (line) and then on full-width sentence
70+
terminators (sub-chunk), process each piece in parallel under a bounded
71+
semaphore, and stream one NDJSON line per piece in input order.
72+
"""
73+
logger.info(f"[API] Received streaming request: {request.text!r}")
74+
return StreamingResponse(
75+
stream_accent_chunks(request, client),
76+
media_type="application/x-ndjson",
77+
)

test.sh

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/bin/bash
2+
# Send a test text to the local API and print one
3+
# (surface|furigana|accent_marking_type) line per moji.
4+
#
5+
# Usage:
6+
# ./test.sh # default text on MarkAccent
7+
# ./test.sh "三月五日(土)" # custom text
8+
# PORT=8000 ./test.sh # different port
9+
# ENDPOINT=MarkFurigana ./test.sh # furigana endpoint (accent="-")
10+
# STREAM=1 ./test.sh $'first\nsecond' # streaming endpoint, NDJSON
11+
12+
set -euo pipefail
13+
14+
TEXT="${1:-3月5日(土)}"
15+
PORT="${PORT:-8000}"
16+
ENDPOINT="${ENDPOINT:-MarkAccent}"
17+
STREAM="${STREAM:-0}"
18+
19+
if [[ "$STREAM" == "1" ]]; then
20+
URL="http://127.0.0.1:${PORT}/api/${ENDPOINT}/stream/"
21+
else
22+
URL="http://127.0.0.1:${PORT}/api/${ENDPOINT}/"
23+
fi
24+
25+
PAYLOAD=$(uv run python -c \
26+
'import json, sys; print(json.dumps({"text": sys.argv[1]}))' "$TEXT")
27+
28+
if [[ "$STREAM" == "1" ]]; then
29+
# Streaming mode: pipe NDJSON straight to a per-line viewer.
30+
read -r -d '' STREAM_VIEWER <<'PY' || true
31+
import sys, json
32+
33+
seen = 0
34+
for raw in sys.stdin:
35+
raw = raw.strip()
36+
if not raw:
37+
continue
38+
seen += 1
39+
d = json.loads(raw)
40+
chunk = d["chunk"]
41+
sub = d.get("subchunk", 0)
42+
status = d["status"]
43+
err = d.get("error")
44+
result = d.get("result") or []
45+
print(f"--- chunk {chunk}.{sub} status={status} words={len(result)} ---")
46+
if err:
47+
print(f" ERROR: {err}")
48+
continue
49+
for w in result:
50+
surface = w["surface"]
51+
accents = w.get("accent") or []
52+
if not accents:
53+
print(f" ({surface}|{w['furigana']}|-)")
54+
continue
55+
for a in accents:
56+
moji = a["furigana"]
57+
t = a["accent_marking_type"]
58+
print(f" ({surface}|{moji}|{t})")
59+
if seen == 0:
60+
print("(empty stream — no non-blank input lines)")
61+
PY
62+
63+
# -N disables curl's output buffering so each NDJSON line lands in the
64+
# viewer as soon as the server flushes it.
65+
curl -sN -X POST "$URL" \
66+
-H 'Content-Type: application/json' \
67+
--data-raw "$PAYLOAD" \
68+
| uv run python -c "$STREAM_VIEWER"
69+
exit 0
70+
fi
71+
72+
# Non-streaming mode (original behaviour).
73+
HTTP_STATUS=$(curl -s -o /tmp/test_sh_body.$$ -w '%{http_code}' \
74+
-X POST "$URL" -H 'Content-Type: application/json' --data-raw "$PAYLOAD" \
75+
|| true)
76+
if [[ "$HTTP_STATUS" != "200" || ! -s /tmp/test_sh_body.$$ ]]; then
77+
echo "Request to $URL failed (HTTP ${HTTP_STATUS:-no-response})." >&2
78+
echo "Is the server running? Try: uv run uvicorn main:app --host 127.0.0.1 --port ${PORT}" >&2
79+
rm -f /tmp/test_sh_body.$$
80+
exit 1
81+
fi
82+
83+
read -r -d '' FORMAT_SCRIPT <<'PY' || true
84+
import json, sys
85+
86+
data = json.load(sys.stdin)
87+
if data.get("status") != 200 or not data.get("result"):
88+
print("ERROR:", data.get("error") or data)
89+
sys.exit(1)
90+
91+
for w in data["result"]:
92+
surface = w["surface"]
93+
accents = w.get("accent") or []
94+
if not accents:
95+
print(f"({surface}|{w['furigana']}|-)")
96+
continue
97+
for a in accents:
98+
print(f"({surface}|{a['furigana']}|{a['accent_marking_type']})")
99+
PY
100+
101+
uv run python -c "$FORMAT_SCRIPT" < /tmp/test_sh_body.$$
102+
rm -f /tmp/test_sh_body.$$

0 commit comments

Comments
 (0)