Upload 4 files
Browse files- app/config.py +38 -0
- app/llama_client.py +75 -0
- app/main.py +147 -0
- app/translation.py +102 -0
app/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def _int(name: str, default: int) -> int:
|
| 5 |
+
return int(os.environ.get(name, default))
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _float(name: str, default: float) -> float:
|
| 9 |
+
return float(os.environ.get(name, default))
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
MODEL_REPO = os.environ.get("MODEL_REPO", "tencent/Hy-MT2-7B-GGUF")
|
| 13 |
+
MODEL_FILE = os.environ.get("MODEL_FILE", "Hy-MT2-7B-Q4_K_M.gguf")
|
| 14 |
+
MODEL_DIR = os.environ.get("MODEL_DIR", "/app/models")
|
| 15 |
+
|
| 16 |
+
LLAMA_SERVER_HOST = os.environ.get("LLAMA_SERVER_HOST", "127.0.0.1")
|
| 17 |
+
LLAMA_SERVER_PORT = _int("LLAMA_SERVER_PORT", 8080)
|
| 18 |
+
LLAMA_SERVER_URL = f"http://{LLAMA_SERVER_HOST}:{LLAMA_SERVER_PORT}"
|
| 19 |
+
|
| 20 |
+
# Matches Hy-MT2 1.8B/7B recommended sampling params from the model card.
|
| 21 |
+
TEMPERATURE = _float("TEMPERATURE", 0.7)
|
| 22 |
+
TOP_P = _float("TOP_P", 0.6)
|
| 23 |
+
TOP_K = _int("TOP_K", 20)
|
| 24 |
+
REPEAT_PENALTY = _float("REPEAT_PENALTY", 1.05)
|
| 25 |
+
MAX_TOKENS = _int("MAX_TOKENS", 512)
|
| 26 |
+
|
| 27 |
+
# --parallel value the llama-server was started with; also used to bound
|
| 28 |
+
# how many translation requests this gateway keeps in flight at once so
|
| 29 |
+
# requests queue instead of overwhelming the server's slots.
|
| 30 |
+
PARALLEL_SLOTS = _int("PARALLEL_SLOTS", 8)
|
| 31 |
+
|
| 32 |
+
MAX_BATCH_SIZE = _int("MAX_BATCH_SIZE", 200)
|
| 33 |
+
REQUEST_TIMEOUT_SECONDS = _float("REQUEST_TIMEOUT_SECONDS", 120.0)
|
| 34 |
+
|
| 35 |
+
# Optional shared-secret auth for the public gateway. Empty = disabled.
|
| 36 |
+
API_KEY = os.environ.get("API_KEY", "")
|
| 37 |
+
|
| 38 |
+
PORT = _int("PORT", 7860)
|
app/llama_client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin async client for the internal llama-server instance.
|
| 2 |
+
|
| 3 |
+
llama-server is started with --parallel N --cont-batching (see entrypoint.sh),
|
| 4 |
+
which makes it batch multiple in-flight requests' forward passes together
|
| 5 |
+
instead of running them one at a time. This is where the actual CPU
|
| 6 |
+
efficiency gain for many short strings comes from: we just need to keep
|
| 7 |
+
enough requests in flight concurrently, and llama.cpp does the batching.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
from . import config
|
| 15 |
+
|
| 16 |
+
_client: httpx.AsyncClient | None = None
|
| 17 |
+
# Bounds how many requests we hand to llama-server at once. Slightly above
|
| 18 |
+
# PARALLEL_SLOTS would over-queue on the server side instead of here; we
|
| 19 |
+
# keep it equal so backpressure is visible to callers via queueing time.
|
| 20 |
+
_semaphore = asyncio.Semaphore(config.PARALLEL_SLOTS)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def get_client() -> httpx.AsyncClient:
|
| 24 |
+
global _client
|
| 25 |
+
if _client is None:
|
| 26 |
+
_client = httpx.AsyncClient(
|
| 27 |
+
base_url=config.LLAMA_SERVER_URL,
|
| 28 |
+
timeout=httpx.Timeout(config.REQUEST_TIMEOUT_SECONDS),
|
| 29 |
+
)
|
| 30 |
+
return _client
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def aclose() -> None:
|
| 34 |
+
global _client
|
| 35 |
+
if _client is not None:
|
| 36 |
+
await _client.aclose()
|
| 37 |
+
_client = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class LlamaServerError(RuntimeError):
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def chat_complete(prompt: str) -> str:
|
| 45 |
+
client = get_client()
|
| 46 |
+
payload = {
|
| 47 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 48 |
+
"temperature": config.TEMPERATURE,
|
| 49 |
+
"top_p": config.TOP_P,
|
| 50 |
+
"top_k": config.TOP_K,
|
| 51 |
+
"repeat_penalty": config.REPEAT_PENALTY,
|
| 52 |
+
"max_tokens": config.MAX_TOKENS,
|
| 53 |
+
"stream": False,
|
| 54 |
+
}
|
| 55 |
+
async with _semaphore:
|
| 56 |
+
try:
|
| 57 |
+
resp = await client.post("/v1/chat/completions", json=payload)
|
| 58 |
+
resp.raise_for_status()
|
| 59 |
+
except httpx.HTTPError as exc:
|
| 60 |
+
raise LlamaServerError(str(exc)) from exc
|
| 61 |
+
|
| 62 |
+
data = resp.json()
|
| 63 |
+
try:
|
| 64 |
+
return data["choices"][0]["message"]["content"].strip()
|
| 65 |
+
except (KeyError, IndexError) as exc:
|
| 66 |
+
raise LlamaServerError(f"Unexpected llama-server response: {data}") from exc
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def health() -> bool:
|
| 70 |
+
client = get_client()
|
| 71 |
+
try:
|
| 72 |
+
resp = await client.get("/health", timeout=5.0)
|
| 73 |
+
return resp.status_code == 200
|
| 74 |
+
except httpx.HTTPError:
|
| 75 |
+
return False
|
app/main.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
from . import config, llama_client
|
| 9 |
+
from .translation import LANGUAGES, build_prompt
|
| 10 |
+
|
| 11 |
+
app = FastAPI(title="Hy-MT2 Translation API", version="1.0.0")
|
| 12 |
+
|
| 13 |
+
app.add_middleware(
|
| 14 |
+
CORSMiddleware,
|
| 15 |
+
allow_origins=["*"],
|
| 16 |
+
allow_methods=["*"],
|
| 17 |
+
allow_headers=["*"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def verify_api_key(x_api_key: str | None = Header(default=None)) -> None:
|
| 22 |
+
if config.API_KEY and x_api_key != config.API_KEY:
|
| 23 |
+
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key header")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TranslateRequest(BaseModel):
|
| 27 |
+
text: str
|
| 28 |
+
target_lang: str
|
| 29 |
+
source_lang: str | None = None
|
| 30 |
+
style: str | None = None
|
| 31 |
+
glossary: dict[str, str] | None = None
|
| 32 |
+
preserve_placeholders: bool = True
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class BatchTranslateRequest(BaseModel):
|
| 36 |
+
texts: list[str] = Field(..., min_length=1)
|
| 37 |
+
target_lang: str
|
| 38 |
+
source_lang: str | None = None
|
| 39 |
+
style: str | None = None
|
| 40 |
+
glossary: dict[str, str] | None = None
|
| 41 |
+
preserve_placeholders: bool = True
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class TranslateResponse(BaseModel):
|
| 45 |
+
translation: str
|
| 46 |
+
elapsed_seconds: float
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class BatchItem(BaseModel):
|
| 50 |
+
original: str
|
| 51 |
+
translation: str | None
|
| 52 |
+
error: str | None = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class BatchTranslateResponse(BaseModel):
|
| 56 |
+
translations: list[BatchItem]
|
| 57 |
+
count: int
|
| 58 |
+
elapsed_seconds: float
|
| 59 |
+
items_per_second: float | None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.get("/health")
|
| 63 |
+
async def health_check():
|
| 64 |
+
backend_ok = await llama_client.health()
|
| 65 |
+
return {
|
| 66 |
+
"status": "ok" if backend_ok else "starting",
|
| 67 |
+
"llama_server": backend_ok,
|
| 68 |
+
"parallel_slots": config.PARALLEL_SLOTS,
|
| 69 |
+
"model_file": config.MODEL_FILE,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.get("/languages")
|
| 74 |
+
async def languages():
|
| 75 |
+
return LANGUAGES
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@app.post("/translate", response_model=TranslateResponse, dependencies=[Depends(verify_api_key)])
|
| 79 |
+
async def translate(req: TranslateRequest):
|
| 80 |
+
prompt = build_prompt(
|
| 81 |
+
req.text,
|
| 82 |
+
req.target_lang,
|
| 83 |
+
req.source_lang,
|
| 84 |
+
req.style,
|
| 85 |
+
req.glossary,
|
| 86 |
+
req.preserve_placeholders,
|
| 87 |
+
)
|
| 88 |
+
start = time.perf_counter()
|
| 89 |
+
try:
|
| 90 |
+
translation = await llama_client.chat_complete(prompt)
|
| 91 |
+
except llama_client.LlamaServerError as exc:
|
| 92 |
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
| 93 |
+
elapsed = time.perf_counter() - start
|
| 94 |
+
return TranslateResponse(translation=translation, elapsed_seconds=round(elapsed, 4))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.post(
|
| 98 |
+
"/translate/batch",
|
| 99 |
+
response_model=BatchTranslateResponse,
|
| 100 |
+
dependencies=[Depends(verify_api_key)],
|
| 101 |
+
)
|
| 102 |
+
async def translate_batch(req: BatchTranslateRequest):
|
| 103 |
+
if len(req.texts) > config.MAX_BATCH_SIZE:
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=400,
|
| 106 |
+
detail=(
|
| 107 |
+
f"Batch too large ({len(req.texts)} items). "
|
| 108 |
+
f"Split into chunks of at most {config.MAX_BATCH_SIZE} "
|
| 109 |
+
"(50-100 is a good size for many short strings)."
|
| 110 |
+
),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
async def _translate_one(text: str):
|
| 114 |
+
prompt = build_prompt(
|
| 115 |
+
text,
|
| 116 |
+
req.target_lang,
|
| 117 |
+
req.source_lang,
|
| 118 |
+
req.style,
|
| 119 |
+
req.glossary,
|
| 120 |
+
req.preserve_placeholders,
|
| 121 |
+
)
|
| 122 |
+
return await llama_client.chat_complete(prompt)
|
| 123 |
+
|
| 124 |
+
start = time.perf_counter()
|
| 125 |
+
results = await asyncio.gather(
|
| 126 |
+
*(_translate_one(t) for t in req.texts), return_exceptions=True
|
| 127 |
+
)
|
| 128 |
+
elapsed = time.perf_counter() - start
|
| 129 |
+
|
| 130 |
+
items: list[BatchItem] = []
|
| 131 |
+
for original, result in zip(req.texts, results):
|
| 132 |
+
if isinstance(result, Exception):
|
| 133 |
+
items.append(BatchItem(original=original, translation=None, error=str(result)))
|
| 134 |
+
else:
|
| 135 |
+
items.append(BatchItem(original=original, translation=result))
|
| 136 |
+
|
| 137 |
+
return BatchTranslateResponse(
|
| 138 |
+
translations=items,
|
| 139 |
+
count=len(req.texts),
|
| 140 |
+
elapsed_seconds=round(elapsed, 4),
|
| 141 |
+
items_per_second=round(len(req.texts) / elapsed, 2) if elapsed > 0 else None,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.on_event("shutdown")
|
| 146 |
+
async def shutdown():
|
| 147 |
+
await llama_client.aclose()
|
app/translation.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt construction for Hy-MT2, following the instruction formats documented
|
| 2 |
+
on https://huggingface.co/tencent/Hy-MT2-7B-GGUF ("Default Translation",
|
| 3 |
+
"Terminology", "Style" and "Structured Data" instruction variants).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# The 33 languages Hy-MT2 is documented to support, code -> English name.
|
| 7 |
+
LANGUAGES = {
|
| 8 |
+
"zh": "Chinese",
|
| 9 |
+
"zh-hant": "Traditional Chinese",
|
| 10 |
+
"yue": "Cantonese",
|
| 11 |
+
"en": "English",
|
| 12 |
+
"fr": "French",
|
| 13 |
+
"pt": "Portuguese",
|
| 14 |
+
"es": "Spanish",
|
| 15 |
+
"ja": "Japanese",
|
| 16 |
+
"tr": "Turkish",
|
| 17 |
+
"ru": "Russian",
|
| 18 |
+
"ar": "Arabic",
|
| 19 |
+
"ko": "Korean",
|
| 20 |
+
"th": "Thai",
|
| 21 |
+
"it": "Italian",
|
| 22 |
+
"de": "German",
|
| 23 |
+
"vi": "Vietnamese",
|
| 24 |
+
"ms": "Malay",
|
| 25 |
+
"id": "Indonesian",
|
| 26 |
+
"tl": "Filipino",
|
| 27 |
+
"hi": "Hindi",
|
| 28 |
+
"pl": "Polish",
|
| 29 |
+
"cs": "Czech",
|
| 30 |
+
"nl": "Dutch",
|
| 31 |
+
"km": "Khmer",
|
| 32 |
+
"my": "Burmese",
|
| 33 |
+
"fa": "Persian",
|
| 34 |
+
"gu": "Gujarati",
|
| 35 |
+
"ur": "Urdu",
|
| 36 |
+
"te": "Telugu",
|
| 37 |
+
"mr": "Marathi",
|
| 38 |
+
"he": "Hebrew",
|
| 39 |
+
"bn": "Bengali",
|
| 40 |
+
"ta": "Tamil",
|
| 41 |
+
"uk": "Ukrainian",
|
| 42 |
+
"bo": "Tibetan",
|
| 43 |
+
"kk": "Kazakh",
|
| 44 |
+
"mn": "Mongolian",
|
| 45 |
+
"ug": "Uyghur",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# Placeholder styles commonly found in game strings (RPG Maker control codes,
|
| 49 |
+
# printf-style format specifiers, ICU/handlebars-style variables) that the
|
| 50 |
+
# model must copy through untouched rather than translate.
|
| 51 |
+
_PLACEHOLDER_HINT = (
|
| 52 |
+
"You must NEVER translate or alter placeholders and control codes such as "
|
| 53 |
+
r"\N[..], \V[..], \C[..], \I[..], {variable}, ${variable}, %s, %d, %1, {{var}} "
|
| 54 |
+
"or similar tokens. Keep them exactly as they appear, in the same position."
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def resolve_language(code_or_name: str) -> str:
|
| 59 |
+
"""Map an ISO-ish code to its English name; pass through unknown values
|
| 60 |
+
(so callers can already supply a plain language name)."""
|
| 61 |
+
return LANGUAGES.get(code_or_name.strip().lower(), code_or_name)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build_prompt(
|
| 65 |
+
text: str,
|
| 66 |
+
target_lang: str,
|
| 67 |
+
source_lang: str | None = None,
|
| 68 |
+
style: str | None = None,
|
| 69 |
+
glossary: dict[str, str] | None = None,
|
| 70 |
+
preserve_placeholders: bool = True,
|
| 71 |
+
) -> str:
|
| 72 |
+
target_name = resolve_language(target_lang)
|
| 73 |
+
|
| 74 |
+
header = (
|
| 75 |
+
f"Translate the following text into {target_name}. "
|
| 76 |
+
"Note that you should only output the translated result "
|
| 77 |
+
"without any additional explanation"
|
| 78 |
+
)
|
| 79 |
+
if source_lang:
|
| 80 |
+
source_name = resolve_language(source_lang)
|
| 81 |
+
header += f". The source text is in {source_name}"
|
| 82 |
+
header += "."
|
| 83 |
+
|
| 84 |
+
parts = [header]
|
| 85 |
+
|
| 86 |
+
if style:
|
| 87 |
+
parts.append(
|
| 88 |
+
f"Note that the translation style must strictly conform to [{style}]."
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
if glossary:
|
| 92 |
+
ref_lines = "\n".join(f"{k} translates to {v}" for k, v in glossary.items())
|
| 93 |
+
parts.insert(
|
| 94 |
+
0,
|
| 95 |
+
"Reference the following translations:\n" + ref_lines,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
if preserve_placeholders:
|
| 99 |
+
parts.append(_PLACEHOLDER_HINT)
|
| 100 |
+
|
| 101 |
+
instruction = "\n".join(parts)
|
| 102 |
+
return f"{instruction}\n\n{text}"
|