add sentence service
This commit is contained in:
92
sentence_api/README.md
Normal file
92
sentence_api/README.md
Normal 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
2
sentence_api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Sentence boundary lookup service."""
|
||||
|
||||
1498
sentence_api/data/sentence_boundaries.json
Normal file
1498
sentence_api/data/sentence_boundaries.json
Normal file
File diff suppressed because it is too large
Load Diff
116
sentence_api/generate_boundaries.py
Normal file
116
sentence_api/generate_boundaries.py
Normal 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
62
sentence_api/main.py
Normal 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
48
sentence_api/models.py
Normal 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
|
||||
7
sentence_api/requirements.txt
Normal file
7
sentence_api/requirements.txt
Normal 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
54
sentence_api/store.py
Normal 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)
|
||||
55
sentence_api/tests/test_api.py
Normal file
55
sentence_api/tests/test_api.py
Normal 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
|
||||
Reference in New Issue
Block a user