add sentence service

This commit is contained in:
2026-08-14 19:06:04 +08:00
parent f8a6bf24e4
commit 69d2ba986f
19 changed files with 2379 additions and 109 deletions

92
sentence_api/README.md Normal file
View File

@@ -0,0 +1,92 @@
# Sentence Boundary API
This service looks up pre-generated sentence boundaries by the SHA-256 hash of
the exact video bytes. It does not analyze media during an API request.
## Install
From the repository root:
```bash
python3 -m venv .venv-sentence-api
. .venv-sentence-api/bin/activate
python -m pip install -r sentence_api/requirements.txt
```
## Generate The Index
Generate boundaries with the same silence detector used by the desktop player:
```bash
python -m sentence_api.generate_boundaries \
"/path/to/lesson.mp4" \
--index sentence_api/data/sentence_boundaries.json
```
The command calculates the SHA-256 hash, detects boundaries, converts seconds
to milliseconds, infers each `end_ms` from the next sentence start, and writes
the result atomically into the JSON index. The last sentence ends at the media
duration.
For a large course library, run this command in an ingestion worker and store
the same document in a database or object storage instead of committing the
JSON file to the application image.
## Run
```bash
SENTENCE_BOUNDARIES_FILE=sentence_api/data/sentence_boundaries.json \
python -m uvicorn sentence_api.main:app --host 0.0.0.0 --port 8000
```
The interactive API documentation is available at `/docs`.
## Request
```http
GET /api/v1/videos/{sha256}/sentence-boundaries
```
Example using the demo record in the checked-in index:
```bash
curl http://127.0.0.1:8000/api/v1/videos/468a4d064f6ec49942b45e25ab93c500d31870f978c4f28ff8b3b408852326e0/sentence-boundaries
```
The MP4 used during development is also indexed. Its hash is
`b6631d5cf48f37fed0ecc623563dd48b7ed660689b4d25d2ebac7fecab807ddc`, and its
generated index contains 244 boundaries.
The response is:
```json
{
"video_hash": "...",
"duration_ms": 16000,
"algorithm_version": "silence-rms-v1",
"sentences": [
{
"index": 0,
"start_ms": 0,
"end_ms": 4230,
"text": null
}
]
}
```
Unknown hashes return `404`. A hash must be a 64-character hexadecimal
SHA-256 digest; malformed values return `422`.
When testing from a physical Android phone, replace `127.0.0.1` with the
computer's LAN IP address. `127.0.0.1` on the phone refers to the phone itself.
For production, expose the API over HTTPS.
## Android Request Flow
The mobile app should calculate the hash from the selected `content://` URI in
streaming chunks, request the endpoint, map the returned `sentences` to
`SentenceBoundary`, and then call `controller.loadItem`. The hash must be
calculated from the exact bytes of the same video served to the player. For
HTTPS course videos, the course manifest can carry the hash and avoid hashing
the entire remote file on every device.

2
sentence_api/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Sentence boundary lookup service."""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
import argparse
import hashlib
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from sentence_analysis import detect_sentence_boundaries
from sentence_api.store import normalize_video_hash
ALGORITHM_VERSION = "silence-rms-v1"
DEFAULT_INDEX_PATH = Path(__file__).resolve().parent / "data" / "sentence_boundaries.json"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as media_file:
for chunk in iter(lambda: media_file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def media_duration_ms(path: Path) -> int:
try:
import av
except ImportError as exc:
raise RuntimeError("PyAV is required to generate sentence boundaries.") from exc
container = av.open(str(path))
try:
if not container.duration or container.duration <= 0:
raise RuntimeError("Could not determine media duration.")
return int(round(container.duration / 1000))
finally:
container.close()
def make_entry(path: Path, video_hash: Optional[str] = None, min_silence: float = 0.30,
min_sentence: float = 0.35) -> Tuple[Dict[str, Any], str]:
duration_ms = media_duration_ms(path)
starts = detect_sentence_boundaries(
path,
min_silence=min_silence,
min_sentence=min_sentence,
)
if not starts:
raise RuntimeError("No sentence boundaries could be detected.")
starts_ms = sorted({max(0, int(round(start * 1000))) for start in starts})
starts_ms = [start for start in starts_ms if start < duration_ms]
sentences: List[Dict[str, Any]] = []
for index, start_ms in enumerate(starts_ms):
end_ms = starts_ms[index + 1] if index + 1 < len(starts_ms) else duration_ms
if end_ms <= start_ms:
continue
sentences.append({
"index": len(sentences),
"start_ms": start_ms,
"end_ms": end_ms,
"text": None,
})
if not sentences:
raise RuntimeError("Detected boundaries do not form valid sentence ranges.")
entry = {
"duration_ms": duration_ms,
"algorithm_version": ALGORITHM_VERSION,
"sentences": sentences,
}
resolved_hash = normalize_video_hash(video_hash) if video_hash else sha256_file(path)
return entry, resolved_hash
def update_index(index_path: Path, video_hash: str, entry: Dict[str, Any]) -> None:
if index_path.exists():
raw = json.loads(index_path.read_text(encoding="utf-8"))
else:
raw = {"videos": {}}
videos = raw.setdefault("videos", {})
if not isinstance(videos, dict):
raise ValueError("index JSON must contain a 'videos' object")
videos[normalize_video_hash(video_hash)] = entry
index_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = index_path.with_suffix(index_path.suffix + ".tmp")
temporary_path.write_text(
json.dumps(raw, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary_path.replace(index_path)
def main() -> None:
parser = argparse.ArgumentParser(description="Generate API sentence boundaries for a video.")
parser.add_argument("video", type=Path)
parser.add_argument("--index", type=Path, default=DEFAULT_INDEX_PATH)
parser.add_argument("--video-hash", help="Override the calculated SHA-256 hash.")
parser.add_argument("--min-silence", type=float, default=0.30)
parser.add_argument("--min-sentence", type=float, default=0.35)
args = parser.parse_args()
if not args.video.is_file():
parser.error(f"Video file does not exist: {args.video}")
entry, video_hash = make_entry(
args.video,
video_hash=args.video_hash,
min_silence=args.min_silence,
min_sentence=args.min_sentence,
)
update_index(args.index, video_hash, entry)
print(f"video_hash={video_hash}")
print(f"sentences={len(entry['sentences'])}")
print(f"index={args.index}")
if __name__ == "__main__":
main()

62
sentence_api/main.py Normal file
View File

@@ -0,0 +1,62 @@
import os
from pathlib import Path
from typing import Any, Dict
from fastapi import Depends, FastAPI, HTTPException, Path as ApiPath
from .models import SentenceBoundaryDocument
from .store import BoundaryStore
DEFAULT_INDEX_PATH = Path(__file__).resolve().parent / "data" / "sentence_boundaries.json"
def create_app(store: BoundaryStore = None) -> FastAPI:
application = FastAPI(
title="Oral Trainer Sentence Boundary API",
version="1.0.0",
description="Looks up pre-generated sentence boundaries by video SHA-256.",
)
index_path = Path(os.getenv("SENTENCE_BOUNDARIES_FILE", str(DEFAULT_INDEX_PATH)))
application.state.boundary_store = store or BoundaryStore(index_path)
def get_store() -> BoundaryStore:
return application.state.boundary_store
@application.get("/healthz")
def healthz(boundary_store: BoundaryStore = Depends(get_store)) -> Dict[str, Any]:
return {"status": "ok", "video_count": boundary_store.count()}
@application.get(
"/api/v1/videos/{video_hash}/sentence-boundaries",
response_model=SentenceBoundaryDocument,
)
@application.get(
"/api/v1/sentence-boundaries/{video_hash}",
response_model=SentenceBoundaryDocument,
include_in_schema=False,
)
def get_sentence_boundaries(
video_hash: str = ApiPath(
min_length=64,
max_length=64,
pattern=r"^[A-Fa-f0-9]{64}$",
description="SHA-256 hex digest of the exact video bytes",
),
boundary_store: BoundaryStore = Depends(get_store),
) -> SentenceBoundaryDocument:
document = boundary_store.get(video_hash)
if document is None:
raise HTTPException(
status_code=404,
detail={
"code": "SENTENCE_BOUNDARIES_NOT_FOUND",
"message": "No sentence boundaries are registered for this video hash.",
},
)
return document
return application
app = create_app()

48
sentence_api/models.py Normal file
View File

@@ -0,0 +1,48 @@
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class SentenceBoundary(BaseModel):
model_config = ConfigDict(extra="forbid")
index: int = Field(ge=0)
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
text: Optional[str] = None
@model_validator(mode="after")
def validate_range(self):
if self.end_ms <= self.start_ms:
raise ValueError("end_ms must be greater than start_ms")
return self
class SentenceBoundaryDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
video_hash: str
duration_ms: int = Field(gt=0)
algorithm_version: str = Field(min_length=1)
sentences: List[SentenceBoundary]
@field_validator("video_hash")
@classmethod
def validate_video_hash(cls, value: str) -> str:
normalized = value.strip().lower()
if len(normalized) != 64 or any(char not in "0123456789abcdef" for char in normalized):
raise ValueError("video_hash must be a SHA-256 hex digest")
return normalized
@model_validator(mode="after")
def validate_sentences(self):
previous_end = 0
for expected_index, sentence in enumerate(self.sentences):
if sentence.index != expected_index:
raise ValueError("sentence indexes must be contiguous and zero-based")
if sentence.start_ms < previous_end:
raise ValueError("sentences must be sorted and non-overlapping")
if sentence.end_ms > self.duration_ms:
raise ValueError("sentence end_ms cannot exceed duration_ms")
previous_end = sentence.end_ms
return self

View File

@@ -0,0 +1,7 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
pydantic>=2.7,<3
pytest>=8,<9
httpx>=0.27,<1
av>=12.0
numpy>=1.26

54
sentence_api/store.py Normal file
View File

@@ -0,0 +1,54 @@
import json
from pathlib import Path
from threading import RLock
from typing import Dict, Optional
from .models import SentenceBoundaryDocument
def normalize_video_hash(video_hash: str) -> str:
normalized = video_hash.strip().lower()
if len(normalized) != 64 or any(char not in "0123456789abcdef" for char in normalized):
raise ValueError("video_hash must be a SHA-256 hex digest")
return normalized
class BoundaryStore:
"""Read-only in-memory index loaded from a JSON file."""
def __init__(self, index_path: Path):
self.index_path = Path(index_path)
self._lock = RLock()
self._documents: Dict[str, SentenceBoundaryDocument] = {}
self.reload()
def reload(self) -> None:
raw = json.loads(self.index_path.read_text(encoding="utf-8"))
entries = raw.get("videos", raw)
if not isinstance(entries, dict):
raise ValueError("index JSON must contain a 'videos' object")
documents: Dict[str, SentenceBoundaryDocument] = {}
for video_hash, entry in entries.items():
normalized_hash = normalize_video_hash(video_hash)
if not isinstance(entry, dict):
raise ValueError(f"entry for {normalized_hash} must be an object")
document = SentenceBoundaryDocument(
video_hash=normalized_hash,
duration_ms=entry.get("duration_ms"),
algorithm_version=entry.get("algorithm_version", "unknown"),
sentences=entry.get("sentences", []),
)
documents[normalized_hash] = document
with self._lock:
self._documents = documents
def get(self, video_hash: str) -> Optional[SentenceBoundaryDocument]:
normalized_hash = normalize_video_hash(video_hash)
with self._lock:
return self._documents.get(normalized_hash)
def count(self) -> int:
with self._lock:
return len(self._documents)

View File

@@ -0,0 +1,55 @@
import json
from fastapi.testclient import TestClient
from sentence_api.main import create_app
from sentence_api.store import BoundaryStore
VIDEO_HASH = "a" * 64
def make_client(tmp_path):
index_path = tmp_path / "boundaries.json"
index_path.write_text(
json.dumps({
"videos": {
VIDEO_HASH: {
"duration_ms": 9000,
"algorithm_version": "test-v1",
"sentences": [
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "One."},
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Two."},
],
}
}
}),
encoding="utf-8",
)
return TestClient(create_app(BoundaryStore(index_path)))
def test_lookup_returns_boundaries(tmp_path):
client = make_client(tmp_path)
response = client.get(f"/api/v1/videos/{VIDEO_HASH}/sentence-boundaries")
assert response.status_code == 200
assert response.json()["sentences"][1]["start_ms"] == 4000
def test_unknown_hash_returns_not_found(tmp_path):
client = make_client(tmp_path)
response = client.get(f"/api/v1/videos/{'b' * 64}/sentence-boundaries")
assert response.status_code == 404
assert response.json()["detail"]["code"] == "SENTENCE_BOUNDARIES_NOT_FOUND"
def test_invalid_hash_is_rejected(tmp_path):
client = make_client(tmp_path)
response = client.get("/api/v1/videos/not-a-sha256/sentence-boundaries")
assert response.status_code == 422