Accepted for the MVP API 2 boundary.
SEOJing will add TTS, article Q&A, and RAG features that are easier to run in Python than inside the public Node/Fastify API. The public product boundary should still stay simple:
- public clients call the Node API only;
- the Python process is an internal worker dependency;
- no Python worker port is exposed through Cloudflare Tunnel or a public reverse proxy.
Use a localhost-only HTTP worker contract for the MVP.
Alternatives considered:
| Option | Decision | Why |
|---|---|---|
| Subprocess per request | Rejected for MVP | Simple to wire, but slow for TTS/RAG warm state and weak for cancellation/health. |
| Queue first | Deferred | Better for long-running jobs, but needs persistence, job status, and retries before the feature contract is known. |
| Localhost HTTP worker | Accepted | Gives a small health/readiness contract, request cancellation via AbortSignal, and clean route/service tests while keeping the public port surface on Node. |
The default worker URL is http://127.0.0.1:4037; PythonWorkerClient rejects non-loopback hosts so misconfiguration cannot accidentally point the backend at a public Python URL.
| Variable | Default | Notes |
|---|---|---|
PYTHON_WORKER_ENABLED |
false |
Readiness checks include the worker only when enabled. |
PYTHON_WORKER_BASE_URL |
http://127.0.0.1:4037 |
Must be loopback (127.0.0.1, localhost, or ::1). |
PYTHON_WORKER_TIMEOUT_MS |
10000 |
Per-request Node-side timeout. |
PYTHON_WORKER_RETRY_ATTEMPTS |
1 |
Retry count for idempotent worker health/readiness checks. Task invocation is not retried by default. |
TTS_AUDIO_ROOT / SEOJING_TTS_AUDIO_DIR |
.seojing-worker/tts-audio |
Node will stream only worker-returned audio paths contained under this root. Keep it local and non-public. |
Response:
{
"status": "ok",
"worker": "seojing-python-worker",
"version": "0.1.0",
"capabilities": ["tts", "qa", "rag"]
}status: "degraded" is allowed when the process is alive but a capability is unavailable.
kind is one of tts, qa, or rag.
Request:
{
"requestId": "optional-correlation-id",
"payload": {}
}Response:
{
"requestId": "optional-correlation-id",
"result": {}
}Node owns the public TTS API and job lifecycle. The Python worker owns only local synthesis/cache work behind the loopback boundary.
Public Node endpoints:
GET /tts/summary— summary/counts and defaults, no local paths;POST /tts/jobs— create a job, returns202, supportsidempotency_keyorIdempotency-Key;GET /tts/jobs— list jobs with optionalstatusandlimit;GET /tts/jobs/:jobId— read status;GET /tts/audio/:jobId— Node streams generated audio withAccept-Ranges: bytes.
Worker request payload for POST /v1/tasks/tts:
{
"requestId": "TTS-abc123",
"payload": {
"operation": "synthesize",
"jobId": "TTS-abc123",
"text": "Korean narration text",
"articleId": "optional-article-id",
"voice": "ko-KR-SunHiNeural",
"rate": "+0%",
"metadata": {},
"idempotencyKey": "optional-client-key"
}
}Worker response result:
{
"requestId": "TTS-abc123",
"result": {
"audioPath": "/loopback/local/cache/TTS-abc123.mp3",
"mimeType": "audio/mpeg",
"byteLength": 12345,
"durationMs": 1000
}
}audioPath is an internal Node↔Python handoff field only. Public job responses expose /tts/audio/:jobId, never local file paths or text. Node resolves audioPath and rejects values outside TTS_AUDIO_ROOT/SEOJING_TTS_AUDIO_DIR before streaming, so a worker bug cannot turn the public audio route into arbitrary file disclosure. Routes convert worker timeouts/errors to stable public TTS job/error states instead of leaking Python stack traces.
Node owns the public article, section, question, and session policy. Python receives only the already-selected published article context chunks and returns generation/retrieval output.
Public Node endpoint:
POST /articles/:slug/qa— ask a source-backed question about one published article. Body:question, optionalsection_id/sectionId, optional opaquesession_id/sessionId.
Node validates:
- the slug resolves to a
PUBLISHEDarticle throughArticleService.getPublicArticleBySlug; - optional
section_idexists in the current article revision and scopes context to that heading section; - question/session identifiers are bounded strings;
- responses expose source excerpts and public slugs only, not local paths, worker URLs, raw stack traces, ops routes, or private data.
Worker request payload for POST /v1/tasks/qa:
{
"requestId": "qa-correlation-id",
"payload": {
"article": {
"slug": "study/js-closure",
"title": "JavaScript Closure Study",
"sectionId": "closure"
},
"question": "How does closure lexical scope work?",
"sessionId": "optional-opaque-reader-session",
"context": [
{
"blockId": "p-closure",
"sectionId": "closure",
"heading": null,
"text": "A closure keeps lexical scope after the outer function returns.",
"excerpt": "A closure keeps lexical scope after the outer function returns.",
"score": 3
}
]
}
}Worker response result:
{
"requestId": "qa-correlation-id",
"result": {
"status": "answered",
"answer": "Source-backed answer text",
"sources": [
{
"articleSlug": "study/js-closure",
"blockId": "p-closure",
"sectionId": "closure",
"heading": null,
"excerpt": "A closure keeps lexical scope after the outer function returns.",
"score": 3
}
],
"related": [
{ "slug": "study/js-closure", "title": "JavaScript Closure Study" }
]
}
}If the worker times out, returns invalid output, or is not configured, Node returns a deterministic source-backed fallback. If no chunk has enough evidence, the public response uses status: "insufficient_context" rather than guessing.
The included workers/seojing_python_worker.py is the loopback MVP worker. Provision it with python3 -m pip install -r workers/requirements.txt inside the worker virtualenv; it uses edge-tts for real synthesis and a deterministic source-citation Q&A fallback for the initial QA/RAG contract. It refuses non-loopback binds by default.
- Node wraps worker calls in
AbortSignal.timeout(PYTHON_WORKER_TIMEOUT_MS). - Caller cancellation is composed with the timeout via
AbortSignal.any. - Readiness/health can retry transient
timeout,unavailable, andupstream_errorfailures. - Task invocation is not automatically retried because TTS/RAG side effects may be non-idempotent until a future job model exists.
PythonWorkerClient maps worker failures to stable internal codes:
| Code | Meaning |
|---|---|
invalid_config |
Worker URL is not loopback or protocol is invalid. |
timeout |
Node-side timeout expired. |
cancelled |
Upstream caller cancelled the request. |
unavailable |
Fetch/network failed. |
upstream_error |
Worker returned 5xx. |
bad_response |
Worker returned non-2xx client error or invalid JSON. |
Routes should convert these codes into public API errors per feature surface rather than leaking Python stack traces.
- Send correlation through
x-request-idwhen available. - Log worker status/error code and request id, not full prompts, article text, generated audio bytes, API keys, local file paths, or OAuth/session secrets.
- Python worker logs should follow the same rule: structured metadata is okay; content payloads are redacted by default.
GET /health remains the lightweight Node process health check.
GET /health/ready reports dependency readiness:
- worker disabled:
200,pythonWorker.status = "not_configured",required = false; - worker enabled and healthy:
200, status/capabilities from worker; - worker enabled and degraded:
200, top-levelstatus = "degraded", status/capabilities from worker; - worker enabled and failing:
503,pythonWorker.status = "unavailable".
Only the Node/Fastify API should be exposed through Cloudflare Tunnel or public routing. The Python worker listens on loopback only. If TTS/Q&A/RAG later need long-running async jobs, add a queue/job-status layer behind Node rather than exposing Python directly.