File size: 8,035 Bytes
4a4df15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
"""Internal JSON-lines worker. Never import spaces here; GPU visibility is disabled."""
from __future__ import annotations
import os
os.environ["CUDA_VISIBLE_DEVICES"] = ""
os.environ["NVIDIA_VISIBLE_DEVICES"] = ""
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")

from contextlib import redirect_stdout
from dataclasses import asdict
import json
from pathlib import Path
import sys
import numpy as np

from .config import SETTINGS

PIPE = sys.stdout
EMBEDDER = None
WHISPER = None
CPU_LLM = None


def send(message: dict) -> None:
    PIPE.write(json.dumps(message, ensure_ascii=False) + "\n")
    PIPE.flush()


def private_path(value: str) -> Path:
    p = Path(value).resolve()
    if not p.is_relative_to(SETTINGS.root) or not p.is_file() or p.is_symlink():
        raise ValueError("The temporary input file is unavailable.")
    return p


def validate_waveform(audio: np.ndarray, max_seconds: int = SETTINGS.max_audio_seconds) -> np.ndarray:
    audio = np.asarray(audio, dtype="float32")
    if audio.ndim != 1 or len(audio) < 1600:
        raise ValueError("The recording is empty or too short.")
    if len(audio) > max_seconds * 16000:
        raise ValueError(f"Record no more than {max_seconds} seconds per answer.")
    if not np.isfinite(audio).all():
        raise ValueError("Invalid audio samples.")
    if float(np.sqrt(np.mean(audio * audio))) < 0.00001:
        raise ValueError("No audible speech was detected. Your existing answer has been kept.")
    return audio


def decode_audio(path: Path) -> np.ndarray:
    """Bounded PyAV decode; do not allocate an unbounded waveform before duration validation."""
    import av
    if path.stat().st_size > SETTINGS.max_audio_bytes:
        raise ValueError("The recording is too large.")
    result = []
    samples = 0
    limit = SETTINGS.max_audio_seconds * 16000
    with av.open(str(path)) as container:
        if not container.streams.audio:
            raise ValueError("No audio track was found.")
        stream = container.streams.audio[0]
        resampler = av.audio.resampler.AudioResampler(format="fltp", layout="mono", rate=16000)
        for frame in container.decode(stream):
            for output in resampler.resample(frame):
                x = output.to_ndarray().reshape(-1).astype("float32")
                samples += len(x)
                if samples > limit:
                    raise ValueError(f"Record no more than {SETTINGS.max_audio_seconds} seconds per answer.")
                result.append(x)
        for output in resampler.resample(None):
            x = output.to_ndarray().reshape(-1).astype("float32")
            samples += len(x)
            if samples > limit:
                raise ValueError("The recording exceeds the duration limit.")
            result.append(x)
    if samples < 1600:
        raise ValueError("The recording is empty or too short.")
    return validate_waveform(np.concatenate(result))


def process(kind: str, operation: str, data: dict, request_id: str) -> dict:
    global EMBEDDER, WHISPER, CPU_LLM
    def stage(text: str) -> None:
        send({"id": request_id, "stage": text})

    if kind == "llm" and operation == "infer":
        from .rpc import validate_payload
        validated = validate_payload(data["task"], json.dumps(data["payload"]))
        if CPU_LLM is None:
            from .llm import LocalCPUBackend
            stage("Loading the local CPU language model; first use may download weights...")
            CPU_LLM = LocalCPUBackend()
        stage("Running language-model inference on CPU; this can take several minutes...")
        return json.loads(CPU_LLM._dispatch(data["task"], json.dumps(validated)))

    if kind == "documents" and operation == "parse":
        from .ingestion import parse_documents
        inputs = [(private_path(p), name, role) for p, name, role in data["files"]]
        corpus = parse_documents(inputs, data.get("warnings", []))
        return asdict(corpus)

    if kind == "documents" and operation == "embed":
        texts = data.get("texts", [])
        if not isinstance(texts, list) or not 1 <= len(texts) <= 32 or any(not isinstance(t, str) or len(t) > 15000 for t in texts):
            raise ValueError("Invalid embedding batch.")
        if EMBEDDER is None:
            stage("Loading the embedding model on CPU (first use only)...")
            from sentence_transformers import SentenceTransformer
            EMBEDDER = SentenceTransformer(SETTINGS.embedding_model, device="cpu", trust_remote_code=False, token=False)
            EMBEDDER.max_seq_length = min(getattr(EMBEDDER, "max_seq_length", 512), 512)
            print("[CPU worker] embedding model ready", file=sys.stderr, flush=True)
        values = EMBEDDER.encode(texts, batch_size=16, normalize_embeddings=True,
                                 convert_to_numpy=True, show_progress_bar=False)
        print(f"[CPU worker] embedded {len(texts)} passages", file=sys.stderr, flush=True)
        return {"vectors": values.astype("float32").tolist()}

    if kind == "speech" and operation == "transcribe":
        path = private_path(data["path"])
        stage("Decoding the recording and checking its duration...")
        audio = decode_audio(path)
        if WHISPER is None:
            stage("Loading the speech model on CPU (first use only)...")
            from faster_whisper import WhisperModel
            WHISPER = WhisperModel(SETTINGS.speech_model, device="cpu", compute_type="int8", cpu_threads=2, num_workers=1)
            print("[CPU worker] speech model ready", file=sys.stderr, flush=True)
        language = data.get("language", "en")
        allowed = {"en", "it", "fr", "de", "es", "pt", "nl", "ar", "zh", "ja", "auto"}
        if language not in allowed:
            raise ValueError("Unsupported language selection.")
        # Hint contains ONLY visible topic/optional learner vocabulary. Never
        # seed ASR with a model answer or the hidden omission rubric.
        prompt = str(data.get("vocabulary", ""))[:500].strip()
        segments, info = WHISPER.transcribe(audio, language=None if language == "auto" else language,
                                          task="transcribe", beam_size=3, vad_filter=True,
                                          condition_on_previous_text=False,
                                          initial_prompt=prompt or None)
        transcript = []
        duration = len(audio) / 16000
        for segment in segments:
            transcript.append(segment.text.strip())
            stage(f"Transcribing: {min(duration, segment.end):.0f} / {duration:.0f} seconds processed...")
        text = " ".join(t for t in transcript if t).strip()
        if not text:
            raise ValueError("No speech was detected. Your existing answer has been kept.")
        return {"text": text, "language": info.language, "seconds": round(duration, 1)}
    raise ValueError("Unsupported CPU operation.")


def main() -> None:
    kind = sys.argv[1]
    for line in sys.stdin:
        request_id = ""
        try:
            if len(line) > 2_000_000:
                raise ValueError("Request size limit exceeded.")
            request = json.loads(line)
            request_id = request["id"]
            # Third-party messages must not pollute the machine-readable pipe.
            with redirect_stdout(sys.stderr):
                result = process(kind, request["op"], request["data"], request_id)
            send({"id": request_id, "result": result})
        except Exception as exc:
            # Never write document text, answer text, or transcripts to public logs.
            from .ingestion import IngestionError
            message = str(exc) if isinstance(exc, (ValueError, IngestionError)) else f"{type(exc).__name__} in CPU worker. Check dependencies/model availability and retry."
            send({"id": request_id, "error": message[:500]})
            print(f"[CPU worker] operation failed: {type(exc).__name__}", file=sys.stderr, flush=True)


if __name__ == "__main__":
    main()