fixed a bug:translate maybe timeout
This commit is contained in:
@@ -375,8 +375,12 @@ class MainActivity : Activity() {
|
|||||||
screenState.chineseTranslationError = ""
|
screenState.chineseTranslationError = ""
|
||||||
applyChineseSubtitle(controller.currentSentence())
|
applyChineseSubtitle(controller.currentSentence())
|
||||||
translationRequest?.cancel()
|
translationRequest?.cancel()
|
||||||
|
val expectedIndexes = sentences.mapNotNull { sentence ->
|
||||||
|
sentence.index.takeIf { !sentence.text.isNullOrBlank() }
|
||||||
|
}
|
||||||
translationRequest = translationApi.translate(
|
translationRequest = translationApi.translate(
|
||||||
item.id,
|
item.id,
|
||||||
|
expectedIndexes,
|
||||||
object : SubtitleTranslationCallback {
|
object : SubtitleTranslationCallback {
|
||||||
override fun onSuccess(result: SubtitleTranslationResult) {
|
override fun onSuccess(result: SubtitleTranslationResult) {
|
||||||
if (!screenState.isCurrentTranslationRequest(generation, item.id)) return
|
if (!screenState.isCurrentTranslationRequest(generation, item.id)) return
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ internal class SubtitleTranslationApi(
|
|||||||
|
|
||||||
fun translate(
|
fun translate(
|
||||||
videoHash: String,
|
videoHash: String,
|
||||||
|
expectedIndexes: Collection<Int> = emptyList(),
|
||||||
callback: SubtitleTranslationCallback,
|
callback: SubtitleTranslationCallback,
|
||||||
): CancellableTranslationRequest {
|
): CancellableTranslationRequest {
|
||||||
val future = executor.submit {
|
val future = executor.submit {
|
||||||
try {
|
try {
|
||||||
val result = requestBlocking(videoHash)
|
val result = loadBlocking(videoHash, expectedIndexes)
|
||||||
if (!Thread.currentThread().isInterrupted) {
|
if (!Thread.currentThread().isInterrupted) {
|
||||||
mainHandler.post { callback.onSuccess(result) }
|
mainHandler.post { callback.onSuccess(result) }
|
||||||
}
|
}
|
||||||
@@ -62,13 +63,31 @@ internal class SubtitleTranslationApi(
|
|||||||
executor.shutdownNow()
|
executor.shutdownNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestBlocking(videoHash: String): SubtitleTranslationResult {
|
private fun loadBlocking(
|
||||||
|
videoHash: String,
|
||||||
|
expectedIndexes: Collection<Int>,
|
||||||
|
): SubtitleTranslationResult {
|
||||||
|
val stored = runCatching {
|
||||||
|
requestBlocking(videoHash, method = "GET", readTimeoutMs = 15_000)
|
||||||
|
}.getOrNull()
|
||||||
|
val expected = expectedIndexes.toSet()
|
||||||
|
if (stored != null && (expected.isEmpty() || expected.all { stored.translations.containsKey(it) })) {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
return requestBlocking(videoHash, method = "POST", readTimeoutMs = 180_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestBlocking(
|
||||||
|
videoHash: String,
|
||||||
|
method: String,
|
||||||
|
readTimeoutMs: Int,
|
||||||
|
): SubtitleTranslationResult {
|
||||||
val endpoint = baseUrl.trimEnd('/') + "/api/v1/videos/$videoHash/subtitle-translation"
|
val endpoint = baseUrl.trimEnd('/') + "/api/v1/videos/$videoHash/subtitle-translation"
|
||||||
val connection = URL(endpoint).openConnection() as HttpURLConnection
|
val connection = URL(endpoint).openConnection() as HttpURLConnection
|
||||||
try {
|
try {
|
||||||
connection.requestMethod = "POST"
|
connection.requestMethod = method
|
||||||
connection.connectTimeout = 15_000
|
connection.connectTimeout = 15_000
|
||||||
connection.readTimeout = 120_000
|
connection.readTimeout = readTimeoutMs
|
||||||
connection.doOutput = false
|
connection.doOutput = false
|
||||||
connection.useCaches = false
|
connection.useCaches = false
|
||||||
connection.setRequestProperty("Accept", "application/json")
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ enum AppConfig {
|
|||||||
static let connectTimeout: TimeInterval = 15
|
static let connectTimeout: TimeInterval = 15
|
||||||
static let readTimeout: TimeInterval = 30
|
static let readTimeout: TimeInterval = 30
|
||||||
static let assessmentTimeout: TimeInterval = 180
|
static let assessmentTimeout: TimeInterval = 180
|
||||||
static let translationTimeout: TimeInterval = 120
|
static let translationTimeout: TimeInterval = 180
|
||||||
|
|
||||||
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
|
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,29 @@ extension SentenceBoundaryAPI {
|
|||||||
.explanation
|
.explanation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func fetchStoredSubtitles(videoHash: String) async throws -> [Int: String] {
|
||||||
|
try await requestSubtitles(videoHash: videoHash, method: "GET", timeout: AppConfig.readTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
static func translateSubtitles(videoHash: String) async throws -> [Int: String] {
|
static func translateSubtitles(videoHash: String) async throws -> [Int: String] {
|
||||||
|
try await requestSubtitles(videoHash: videoHash, method: "POST", timeout: AppConfig.translationTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadSubtitles(videoHash: String, expectedIndexes: [Int]) async throws -> [Int: String] {
|
||||||
|
if let stored = try? await fetchStoredSubtitles(videoHash: videoHash) {
|
||||||
|
let expected = Set(expectedIndexes)
|
||||||
|
if expected.isEmpty || expected.isSubset(of: Set(stored.keys)) {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await translateSubtitles(videoHash: videoHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestSubtitles(
|
||||||
|
videoHash: String,
|
||||||
|
method: String,
|
||||||
|
timeout: TimeInterval
|
||||||
|
) async throws -> [Int: String] {
|
||||||
let hash = videoHash.lowercased()
|
let hash = videoHash.lowercased()
|
||||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||||
throw SentenceBoundaryAPIError.invalidHash
|
throw SentenceBoundaryAPIError.invalidHash
|
||||||
@@ -112,8 +134,8 @@ extension SentenceBoundaryAPI {
|
|||||||
let result = try await HTTPClient.execute(
|
let result = try await HTTPClient.execute(
|
||||||
baseURL: AppConfig.serverBaseURL,
|
baseURL: AppConfig.serverBaseURL,
|
||||||
path: "api/v1/videos/\(hash)/subtitle-translation",
|
path: "api/v1/videos/\(hash)/subtitle-translation",
|
||||||
method: "POST",
|
method: method,
|
||||||
timeout: AppConfig.translationTimeout,
|
timeout: timeout,
|
||||||
headers: headers
|
headers: headers
|
||||||
)
|
)
|
||||||
guard (200...299).contains(result.statusCode) else {
|
guard (200...299).contains(result.statusCode) else {
|
||||||
|
|||||||
@@ -649,7 +649,14 @@ struct RootView: View {
|
|||||||
let mediaId = item.id
|
let mediaId = item.id
|
||||||
chineseTranslationTask = Task {
|
chineseTranslationTask = Task {
|
||||||
do {
|
do {
|
||||||
let map = try await SentenceBoundaryAPI.translateSubtitles(videoHash: mediaId)
|
let expectedIndexes = item.sentences.compactMap { sentence -> Int? in
|
||||||
|
let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return text.isEmpty ? nil : sentence.index
|
||||||
|
}
|
||||||
|
let map = try await SentenceBoundaryAPI.loadSubtitles(
|
||||||
|
videoHash: mediaId,
|
||||||
|
expectedIndexes: expectedIndexes
|
||||||
|
)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard controller.currentItem?.id == mediaId else { return }
|
guard controller.currentItem?.id == mediaId else { return }
|
||||||
|
|||||||
@@ -100,10 +100,13 @@ The endpoint requires a signed-in user and accepts only the sentence and course
|
|||||||
context. `AI_API_URL` and `AI_API_KEY` remain on the server and are never sent
|
context. `AI_API_URL` and `AI_API_KEY` remain on the server and are never sent
|
||||||
to the mobile clients.
|
to the mobile clients.
|
||||||
|
|
||||||
`POST /api/v1/videos/{sha256}/subtitle-translation` translates the full English
|
Chinese captions are stored on the course in the server database and shared by
|
||||||
transcript in one AI request, then stores one Chinese caption per sentence.
|
every user. `GET /api/v1/videos/{sha256}/subtitle-translation` returns the saved
|
||||||
Later requests reuse the cached Chinese captions until the English sentences
|
captions without calling the AI. `POST` generates any missing captions in small
|
||||||
change.
|
windows with neighboring context, runs several windows in parallel, retries and
|
||||||
|
splits a window if the model response is invalid, and writes each finished
|
||||||
|
window immediately so later viewers can reuse it. English sentence edits clear
|
||||||
|
the saved Chinese captions for that course.
|
||||||
|
|
||||||
## Assessment
|
## Assessment
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""AI-backed sentence explanations and subtitle translation."""
|
"""AI-backed sentence explanations and subtitle translation."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -12,8 +14,14 @@ import requests
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
|
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
|
||||||
_NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[\.、::]\s*(.+?)\s*$")
|
_NUMBERED_LINE_RE = re.compile(r"^\s*[\[(]?(\d+)[\])]?[\.、::\)]\s*(.+?)\s*$")
|
||||||
_TRANSLATE_BATCH_CHARS = 12_000
|
_TRANSLATE_BATCH_SENTENCES = 12
|
||||||
|
_TRANSLATE_BATCH_CHARS = 2_200
|
||||||
|
_TRANSLATE_CONTEXT_BEFORE = 8
|
||||||
|
_TRANSLATE_CONTEXT_AFTER = 4
|
||||||
|
_TRANSLATE_WORKERS = 4
|
||||||
|
_TRANSLATE_TIMEOUT_SECONDS = 45.0
|
||||||
|
_TRANSLATE_SPLIT_THRESHOLD = 4
|
||||||
|
|
||||||
|
|
||||||
class AITutorError(RuntimeError):
|
class AITutorError(RuntimeError):
|
||||||
@@ -73,68 +81,210 @@ class AITutor:
|
|||||||
sentences: list[tuple[int, str]],
|
sentences: list[tuple[int, str]],
|
||||||
*,
|
*,
|
||||||
course_title: str = "",
|
course_title: str = "",
|
||||||
|
existing: dict[int, str] | None = None,
|
||||||
|
persist=None,
|
||||||
) -> dict[int, str]:
|
) -> dict[int, str]:
|
||||||
if not self.available:
|
if not self.available:
|
||||||
raise AITutorError("AI translation is not configured.")
|
raise AITutorError("AI translation is not configured.")
|
||||||
if not sentences:
|
if not sentences:
|
||||||
raise AITutorError("No sentences were provided for translation.")
|
raise AITutorError("No sentences were provided for translation.")
|
||||||
|
|
||||||
translations: dict[int, str] = {}
|
ordered = [(index, text.strip()) for index, text in sentences if text.strip()]
|
||||||
numbered = [(index, text.strip()) for index, text in sentences if text.strip()]
|
if not ordered:
|
||||||
if not numbered:
|
|
||||||
raise AITutorError("No sentence text was provided for translation.")
|
raise AITutorError("No sentence text was provided for translation.")
|
||||||
|
|
||||||
for batch, context_rows in _translation_batches(numbered):
|
translations = {
|
||||||
translations.update(
|
index: text.strip()
|
||||||
self._translate_batch(
|
for index, text in (existing or {}).items()
|
||||||
|
if isinstance(text, str) and text.strip()
|
||||||
|
}
|
||||||
|
pending = [(index, text) for index, text in ordered if index not in translations]
|
||||||
|
if not pending:
|
||||||
|
return translations
|
||||||
|
|
||||||
|
persist_lock = threading.Lock()
|
||||||
|
|
||||||
|
def save_partial(partial: dict[int, str]) -> None:
|
||||||
|
if not partial:
|
||||||
|
return
|
||||||
|
translations.update(partial)
|
||||||
|
if persist is None:
|
||||||
|
return
|
||||||
|
with persist_lock:
|
||||||
|
persist(partial)
|
||||||
|
|
||||||
|
batches = _translation_batches(pending, ordered)
|
||||||
|
workers = min(_TRANSLATE_WORKERS, len(batches)) or 1
|
||||||
|
failures: list[tuple[list[tuple[int, str]], Exception]] = []
|
||||||
|
|
||||||
|
def run_batch(batch: list[tuple[int, str]]) -> dict[int, str]:
|
||||||
|
return self._translate_batch_resilient(
|
||||||
batch,
|
batch,
|
||||||
context_rows=context_rows,
|
all_sentences=ordered,
|
||||||
|
existing=translations,
|
||||||
course_title=course_title,
|
course_title=course_title,
|
||||||
total_count=len(numbered),
|
)
|
||||||
|
|
||||||
|
if workers == 1:
|
||||||
|
for batch in batches:
|
||||||
|
try:
|
||||||
|
save_partial(run_batch(batch))
|
||||||
|
except Exception as exc: # noqa: BLE001 - collect and retry below
|
||||||
|
failures.append((batch, exc))
|
||||||
|
else:
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
futures = {pool.submit(run_batch, batch): batch for batch in batches}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
batch = futures[future]
|
||||||
|
try:
|
||||||
|
save_partial(future.result())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
failures.append((batch, exc))
|
||||||
|
|
||||||
|
for batch, error in failures:
|
||||||
|
logger.warning(
|
||||||
|
"Translation batch failed, retrying with smaller windows: size=%s error=%s",
|
||||||
|
len(batch),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
save_partial(
|
||||||
|
self._translate_batch_resilient(
|
||||||
|
batch,
|
||||||
|
all_sentences=ordered,
|
||||||
|
existing=translations,
|
||||||
|
course_title=course_title,
|
||||||
|
force_split=len(batch) > 1,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
missing = [index for index, _ in numbered if not translations.get(index, "").strip()]
|
|
||||||
|
missing = [index for index, _ in pending if not translations.get(index, "").strip()]
|
||||||
if missing:
|
if missing:
|
||||||
raise AITutorError("AI translation did not cover every sentence.")
|
raise AITutorError("AI translation did not cover every sentence.")
|
||||||
return translations
|
return translations
|
||||||
|
|
||||||
|
def _translate_batch_resilient(
|
||||||
|
self,
|
||||||
|
batch: list[tuple[int, str]],
|
||||||
|
*,
|
||||||
|
all_sentences: list[tuple[int, str]],
|
||||||
|
existing: dict[int, str],
|
||||||
|
course_title: str,
|
||||||
|
force_split: bool = False,
|
||||||
|
) -> dict[int, str]:
|
||||||
|
if not batch:
|
||||||
|
return {}
|
||||||
|
if force_split and len(batch) > 1:
|
||||||
|
return self._translate_split_batch(
|
||||||
|
batch,
|
||||||
|
all_sentences=all_sentences,
|
||||||
|
existing=existing,
|
||||||
|
course_title=course_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
attempts = 2 if len(batch) <= _TRANSLATE_SPLIT_THRESHOLD else 1
|
||||||
|
for _ in range(attempts):
|
||||||
|
try:
|
||||||
|
return self._translate_batch(
|
||||||
|
batch,
|
||||||
|
all_sentences=all_sentences,
|
||||||
|
existing=existing,
|
||||||
|
course_title=course_title,
|
||||||
|
)
|
||||||
|
except AITutorError as exc:
|
||||||
|
last_error = exc
|
||||||
|
logger.warning(
|
||||||
|
"Translation batch attempt failed: size=%s indexes=%s error=%s",
|
||||||
|
len(batch),
|
||||||
|
[index for index, _ in batch],
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(batch) > 1:
|
||||||
|
return self._translate_split_batch(
|
||||||
|
batch,
|
||||||
|
all_sentences=all_sentences,
|
||||||
|
existing=existing,
|
||||||
|
course_title=course_title,
|
||||||
|
)
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise AITutorError("AI translation did not cover every sentence.")
|
||||||
|
|
||||||
|
def _translate_split_batch(
|
||||||
|
self,
|
||||||
|
batch: list[tuple[int, str]],
|
||||||
|
*,
|
||||||
|
all_sentences: list[tuple[int, str]],
|
||||||
|
existing: dict[int, str],
|
||||||
|
course_title: str,
|
||||||
|
) -> dict[int, str]:
|
||||||
|
mid = max(1, len(batch) // 2)
|
||||||
|
merged: dict[int, str] = {}
|
||||||
|
for part in (batch[:mid], batch[mid:]):
|
||||||
|
merged.update(
|
||||||
|
self._translate_batch_resilient(
|
||||||
|
part,
|
||||||
|
all_sentences=all_sentences,
|
||||||
|
existing=existing,
|
||||||
|
course_title=course_title,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return merged
|
||||||
|
|
||||||
def _translate_batch(
|
def _translate_batch(
|
||||||
self,
|
self,
|
||||||
batch: list[tuple[int, str]],
|
batch: list[tuple[int, str]],
|
||||||
*,
|
*,
|
||||||
context_rows: list[tuple[int, str]],
|
all_sentences: list[tuple[int, str]],
|
||||||
|
existing: dict[int, str],
|
||||||
course_title: str,
|
course_title: str,
|
||||||
total_count: int,
|
|
||||||
) -> dict[int, str]:
|
) -> dict[int, str]:
|
||||||
context = course_title.strip() or "口语训练课程"
|
context = course_title.strip() or "口语训练课程"
|
||||||
display_offset = {index: position for position, (index, _) in enumerate(batch, start=1)}
|
existing = dict(existing)
|
||||||
|
before, after = _neighbor_context(batch, all_sentences)
|
||||||
|
expected_indexes = [index for index, _ in batch]
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
"你是专业的影视字幕翻译。请结合全文语境,把指定的外语句子翻译成自然、口语化的简体中文。"
|
"你是专业的影视字幕翻译。请结合前后文语境,把指定编号的外语句子翻译成自然、口语化的简体中文。"
|
||||||
"必须一句对一句,不能合并、拆分、跳过或改写编号。"
|
"必须一句对一句,不能合并、拆分、跳过或改写编号。"
|
||||||
"只输出 JSON 数组,数组长度必须等于待翻译句子数,第 i 项对应第 i 句中文。"
|
"只输出 JSON 对象,键是句子编号,值是对应中文,不要输出解释或 markdown。"
|
||||||
"不要输出解释、markdown 或编号。"
|
|
||||||
)
|
)
|
||||||
lines = [f"{display_offset[index]}. {text}" for index, text in batch]
|
|
||||||
user_parts = [
|
user_parts = [
|
||||||
f"课程:{context}",
|
f"课程:{context}",
|
||||||
f"全片共 {total_count} 句,本次需要翻译 {len(batch)} 句。",
|
f"本次需要翻译 {len(batch)} 句,编号必须与输入完全一致。",
|
||||||
]
|
]
|
||||||
if context_rows:
|
glossary = [
|
||||||
|
(index, source, existing[index])
|
||||||
|
for index, source in before
|
||||||
|
if existing.get(index, "").strip()
|
||||||
|
]
|
||||||
|
if glossary:
|
||||||
|
user_parts.append("已有译文(供用词一致,不要重复输出):")
|
||||||
|
user_parts.extend(
|
||||||
|
f"[{index}] {source} => {zh}" for index, source, zh in glossary[-6:]
|
||||||
|
)
|
||||||
|
elif before:
|
||||||
user_parts.append("前文(只供理解语境,不要翻译):")
|
user_parts.append("前文(只供理解语境,不要翻译):")
|
||||||
user_parts.extend(f"{index + 1}. {text}" for index, text in context_rows)
|
user_parts.extend(f"[{index}] {text}" for index, text in before)
|
||||||
|
if after:
|
||||||
|
user_parts.append("后文(只供理解语境,不要翻译):")
|
||||||
|
user_parts.extend(f"[{index}] {text}" for index, text in after)
|
||||||
user_parts.append("待翻译句子:")
|
user_parts.append("待翻译句子:")
|
||||||
user_parts.extend(lines)
|
user_parts.extend(f"[{index}] {text}" for index, text in batch)
|
||||||
|
user_parts.append(
|
||||||
|
"输出示例:{" + ",".join(f'"{index}":"中文"' for index in expected_indexes[:2]) + "}"
|
||||||
|
)
|
||||||
content = self._complete(
|
content = self._complete(
|
||||||
system_prompt,
|
system_prompt,
|
||||||
"\n".join(user_parts),
|
"\n".join(user_parts),
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
timeout_seconds=max(self.timeout_seconds, 90.0),
|
timeout_seconds=max(self.timeout_seconds, _TRANSLATE_TIMEOUT_SECONDS),
|
||||||
)
|
)
|
||||||
zh_rows = _parse_translation_payload(content, len(batch))
|
parsed = _parse_translation_payload(content, expected_indexes)
|
||||||
return {
|
missing = [index for index in expected_indexes if not parsed.get(index, "").strip()]
|
||||||
index: zh_rows[offset - 1]
|
if missing:
|
||||||
for index, offset in display_offset.items()
|
raise AITutorError("AI translation returned an unexpected response.")
|
||||||
}
|
return {index: parsed[index] for index in expected_indexes}
|
||||||
|
|
||||||
def _complete(
|
def _complete(
|
||||||
self,
|
self,
|
||||||
@@ -201,67 +351,164 @@ class AITutor:
|
|||||||
|
|
||||||
|
|
||||||
def _translation_batches(
|
def _translation_batches(
|
||||||
sentences: list[tuple[int, str]],
|
pending: list[tuple[int, str]],
|
||||||
) -> list[tuple[list[tuple[int, str]], list[tuple[int, str]]]]:
|
all_sentences: list[tuple[int, str]],
|
||||||
batches: list[list[tuple[int, str]]] = []
|
) -> list[list[tuple[int, str]]]:
|
||||||
|
position = {index: offset for offset, (index, _) in enumerate(all_sentences)}
|
||||||
|
ordered_pending = sorted(pending, key=lambda item: position[item[0]])
|
||||||
|
runs: list[list[tuple[int, str]]] = []
|
||||||
current: list[tuple[int, str]] = []
|
current: list[tuple[int, str]] = []
|
||||||
current_chars = 0
|
last_pos: int | None = None
|
||||||
for item in sentences:
|
for item in ordered_pending:
|
||||||
extra = len(item[1]) + 8
|
pos = position[item[0]]
|
||||||
if current and current_chars + extra > _TRANSLATE_BATCH_CHARS:
|
if current and last_pos is not None and pos != last_pos + 1:
|
||||||
batches.append(current)
|
runs.append(current)
|
||||||
current = []
|
current = []
|
||||||
current_chars = 0
|
|
||||||
current.append(item)
|
current.append(item)
|
||||||
current_chars += extra
|
last_pos = pos
|
||||||
if current:
|
if current:
|
||||||
batches.append(current)
|
runs.append(current)
|
||||||
result: list[tuple[list[tuple[int, str]], list[tuple[int, str]]]] = []
|
|
||||||
consumed = 0
|
batches: list[list[tuple[int, str]]] = []
|
||||||
for batch in batches:
|
for run in runs:
|
||||||
context_rows = sentences[max(0, consumed - 8):consumed]
|
batch: list[tuple[int, str]] = []
|
||||||
result.append((batch, context_rows))
|
batch_chars = 0
|
||||||
consumed += len(batch)
|
for item in run:
|
||||||
return result
|
extra = len(item[1]) + 8
|
||||||
|
too_many = len(batch) >= _TRANSLATE_BATCH_SENTENCES
|
||||||
|
too_long = batch and batch_chars + extra > _TRANSLATE_BATCH_CHARS
|
||||||
|
if too_many or too_long:
|
||||||
|
batches.append(batch)
|
||||||
|
batch = []
|
||||||
|
batch_chars = 0
|
||||||
|
batch.append(item)
|
||||||
|
batch_chars += extra
|
||||||
|
if batch:
|
||||||
|
batches.append(batch)
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
def _parse_translation_payload(content: str, expected_count: int) -> list[str]:
|
def _neighbor_context(
|
||||||
|
batch: list[tuple[int, str]],
|
||||||
|
all_sentences: list[tuple[int, str]],
|
||||||
|
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
|
||||||
|
position = {index: offset for offset, (index, _) in enumerate(all_sentences)}
|
||||||
|
first = position[batch[0][0]]
|
||||||
|
last = position[batch[-1][0]]
|
||||||
|
before = all_sentences[max(0, first - _TRANSLATE_CONTEXT_BEFORE):first]
|
||||||
|
after = all_sentences[last + 1:last + 1 + _TRANSLATE_CONTEXT_AFTER]
|
||||||
|
return before, after
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_translation_payload(content: str, expected_indexes: list[int] | int) -> dict[int, str]:
|
||||||
|
if isinstance(expected_indexes, int):
|
||||||
|
expected = list(range(expected_indexes))
|
||||||
|
else:
|
||||||
|
expected = list(expected_indexes)
|
||||||
text = _JSON_FENCE_RE.sub("", content.strip()).strip()
|
text = _JSON_FENCE_RE.sub("", content.strip()).strip()
|
||||||
|
for candidate in (text, *_json_snippets(text)):
|
||||||
try:
|
try:
|
||||||
data = json.loads(text)
|
parsed = _translations_from_json(json.loads(candidate), expected)
|
||||||
parsed = _translations_from_json(data, expected_count)
|
|
||||||
if parsed is not None:
|
|
||||||
return parsed
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
parsed = None
|
||||||
|
if parsed:
|
||||||
|
return parsed
|
||||||
|
|
||||||
numbered: dict[int, str] = {}
|
numbered: dict[int, str] = {}
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
match = _NUMBERED_LINE_RE.match(line)
|
match = _NUMBERED_LINE_RE.match(line)
|
||||||
if match is None:
|
if match is None:
|
||||||
continue
|
continue
|
||||||
numbered[int(match.group(1))] = match.group(2).strip()
|
numbered[int(match.group(1))] = match.group(2).strip()
|
||||||
if len(numbered) == expected_count and all(
|
by_original = {
|
||||||
index in numbered and numbered[index] for index in range(1, expected_count + 1)
|
index: numbered[index]
|
||||||
):
|
for index in expected
|
||||||
return [numbered[index] for index in range(1, expected_count + 1)]
|
if numbered.get(index, "").strip()
|
||||||
|
}
|
||||||
|
if len(by_original) == len(expected):
|
||||||
|
return by_original
|
||||||
|
by_order = {
|
||||||
|
expected[offset]: numbered[offset + 1]
|
||||||
|
for offset in range(len(expected))
|
||||||
|
if numbered.get(offset + 1, "").strip()
|
||||||
|
}
|
||||||
|
if len(by_order) == len(expected):
|
||||||
|
return by_order
|
||||||
raise AITutorError("AI translation returned an unexpected response.")
|
raise AITutorError("AI translation returned an unexpected response.")
|
||||||
|
|
||||||
|
|
||||||
def _translations_from_json(data: object, expected_count: int) -> list[str] | None:
|
def _json_snippets(text: str) -> list[str]:
|
||||||
|
snippets: list[str] = []
|
||||||
|
for opener, closer in (("{", "}"), ("[", "]")):
|
||||||
|
start = text.find(opener)
|
||||||
|
while start != -1:
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escape = False
|
||||||
|
for offset, char in enumerate(text[start:], start=start):
|
||||||
|
if in_string:
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
elif char == "\\":
|
||||||
|
escape = True
|
||||||
|
elif char == '"':
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
if char == '"':
|
||||||
|
in_string = True
|
||||||
|
elif char == opener:
|
||||||
|
depth += 1
|
||||||
|
elif char == closer:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
snippets.append(text[start:offset + 1])
|
||||||
|
break
|
||||||
|
start = text.find(opener, start + 1)
|
||||||
|
return snippets
|
||||||
|
|
||||||
|
|
||||||
|
def _translations_from_json(data: object, expected: list[int]) -> dict[int, str] | None:
|
||||||
|
expected_set = set(expected)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
nested = None
|
||||||
for key in ("translations", "sentences", "zh", "items"):
|
for key in ("translations", "sentences", "zh", "items"):
|
||||||
if key in data:
|
if key in data:
|
||||||
data = data[key]
|
nested = data[key]
|
||||||
break
|
break
|
||||||
if isinstance(data, list) and len(data) == expected_count:
|
if nested is not None:
|
||||||
if all(isinstance(item, str) and item.strip() for item in data):
|
parsed = _translations_from_json(nested, expected)
|
||||||
return [item.strip() for item in data]
|
if parsed:
|
||||||
if all(isinstance(item, dict) for item in data):
|
return parsed
|
||||||
values: list[str] = []
|
values: dict[int, str] = {}
|
||||||
for item in data:
|
for raw_key, raw_value in data.items():
|
||||||
zh = item.get("zh_text") or item.get("zh") or item.get("text")
|
try:
|
||||||
if not isinstance(zh, str) or not zh.strip():
|
index = int(raw_key)
|
||||||
return None
|
except (TypeError, ValueError):
|
||||||
values.append(zh.strip())
|
continue
|
||||||
|
if index not in expected_set or not isinstance(raw_value, str) or not raw_value.strip():
|
||||||
|
continue
|
||||||
|
values[index] = raw_value.strip()
|
||||||
|
if len(values) == len(expected):
|
||||||
|
return values
|
||||||
|
return None
|
||||||
|
if isinstance(data, list) and len(data) == len(expected):
|
||||||
|
if all(isinstance(item, str) and item.strip() for item in data):
|
||||||
|
return {
|
||||||
|
index: item.strip()
|
||||||
|
for index, item in zip(expected, data)
|
||||||
|
}
|
||||||
|
if all(isinstance(item, dict) for item in data):
|
||||||
|
values = {}
|
||||||
|
for item in data:
|
||||||
|
raw_index = item.get("index")
|
||||||
|
zh = item.get("zh_text") or item.get("zh") or item.get("text")
|
||||||
|
try:
|
||||||
|
index = int(raw_index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if index not in expected_set or not isinstance(zh, str) or not zh.strip():
|
||||||
|
continue
|
||||||
|
values[index] = zh.strip()
|
||||||
|
if len(values) == len(expected):
|
||||||
return values
|
return values
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -335,6 +335,32 @@ def create_app(
|
|||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
return SentenceExplanationResponse(explanation=explanation)
|
return SentenceExplanationResponse(explanation=explanation)
|
||||||
|
|
||||||
|
def stored_subtitle_translations(
|
||||||
|
document: SentenceBoundaryDocument,
|
||||||
|
) -> SubtitleTranslationResponse:
|
||||||
|
return SubtitleTranslationResponse(
|
||||||
|
video_hash=document.video_hash,
|
||||||
|
translations=[
|
||||||
|
SubtitleTranslationItem(index=sentence.index, zh_text=sentence.zh_text)
|
||||||
|
for sentence in document.sentences
|
||||||
|
if sentence.zh_text and sentence.zh_text.strip()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@application.get(
|
||||||
|
"/api/v1/videos/{video_hash}/subtitle-translation",
|
||||||
|
response_model=SubtitleTranslationResponse,
|
||||||
|
)
|
||||||
|
def get_subtitle_translation(
|
||||||
|
video_hash: str = SHA256_PATH,
|
||||||
|
user: AuthenticatedUser = Depends(current_user),
|
||||||
|
) -> SubtitleTranslationResponse:
|
||||||
|
del user
|
||||||
|
document = video_repository.get_document(video_hash)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Course is not available.")
|
||||||
|
return stored_subtitle_translations(document)
|
||||||
|
|
||||||
@application.post(
|
@application.post(
|
||||||
"/api/v1/videos/{video_hash}/subtitle-translation",
|
"/api/v1/videos/{video_hash}/subtitle-translation",
|
||||||
response_model=SubtitleTranslationResponse,
|
response_model=SubtitleTranslationResponse,
|
||||||
@@ -354,26 +380,28 @@ def create_app(
|
|||||||
]
|
]
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(status_code=422, detail="No sentence text is available to translate.")
|
raise HTTPException(status_code=422, detail="No sentence text is available to translate.")
|
||||||
cached = [
|
cached = stored_subtitle_translations(document).translations
|
||||||
SubtitleTranslationItem(index=sentence.index, zh_text=sentence.zh_text)
|
|
||||||
for sentence in document.sentences
|
|
||||||
if sentence.zh_text and sentence.zh_text.strip()
|
|
||||||
]
|
|
||||||
complete = len(cached) == len(payload) and all(
|
complete = len(cached) == len(payload) and all(
|
||||||
(sentence.zh_text or "").strip()
|
(sentence.zh_text or "").strip()
|
||||||
for sentence in document.sentences
|
for sentence in document.sentences
|
||||||
if (sentence.text or "").strip()
|
if (sentence.text or "").strip()
|
||||||
)
|
)
|
||||||
if complete:
|
if complete:
|
||||||
return SubtitleTranslationResponse(
|
return stored_subtitle_translations(document)
|
||||||
video_hash=document.video_hash,
|
|
||||||
translations=cached,
|
|
||||||
)
|
|
||||||
video = video_repository.get_video(video_hash)
|
video = video_repository.get_video(video_hash)
|
||||||
|
existing = {
|
||||||
|
item.index: item.zh_text
|
||||||
|
for item in cached
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
translated = application.state.ai_tutor.translate_sentences(
|
translated = application.state.ai_tutor.translate_sentences(
|
||||||
payload,
|
payload,
|
||||||
course_title=(video["title"] if video is not None else "") or "",
|
course_title=(video["title"] if video is not None else "") or "",
|
||||||
|
existing=existing,
|
||||||
|
persist=lambda part: video_repository.save_sentence_translations(
|
||||||
|
video_hash,
|
||||||
|
part,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except AITutorError as exc:
|
except AITutorError as exc:
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sentence_api.ai_tutor import AITutor, AITutorError
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from sentence_api.ai_tutor import AITutor, AITutorError, _parse_translation_payload
|
||||||
|
|
||||||
|
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
@@ -73,15 +77,13 @@ def test_ai_tutor_rejects_unexpected_response(monkeypatch):
|
|||||||
tutor.explain("Hello.")
|
tutor.explain("Hello.")
|
||||||
|
|
||||||
|
|
||||||
from sentence_api.ai_tutor import _parse_translation_payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_ai_tutor_translates_full_document(monkeypatch):
|
def test_ai_tutor_translates_full_document(monkeypatch):
|
||||||
requests_payloads = []
|
requests_payloads = []
|
||||||
|
|
||||||
def fake_post(endpoint, headers, json, timeout):
|
def fake_post(endpoint, headers, json, timeout):
|
||||||
requests_payloads.append((endpoint, headers, json, timeout))
|
requests_payloads.append((endpoint, headers, json, timeout))
|
||||||
return FakeResponse(body={"choices": [{"message": {"content": '["你好。", "见到你很高兴。"]'}}]})
|
return FakeResponse(body={"choices": [{"message": {"content": '{"0":"你好。","1":"见到你很高兴。"}'}}]})
|
||||||
|
|
||||||
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
||||||
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1.5)
|
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1.5)
|
||||||
@@ -94,11 +96,87 @@ def test_ai_tutor_translates_full_document(monkeypatch):
|
|||||||
assert translated == {0: "你好。", 1: "见到你很高兴。"}
|
assert translated == {0: "你好。", 1: "见到你很高兴。"}
|
||||||
_endpoint, _headers, payload, timeout = requests_payloads[0]
|
_endpoint, _headers, payload, timeout = requests_payloads[0]
|
||||||
user_prompt = payload["messages"][1]["content"]
|
user_prompt = payload["messages"][1]["content"]
|
||||||
assert "Hello." in user_prompt
|
assert "[0] Hello." in user_prompt
|
||||||
assert "Nice to meet you." in user_prompt
|
assert "[1] Nice to meet you." in user_prompt
|
||||||
assert timeout == 90.0
|
assert timeout == 45.0
|
||||||
|
|
||||||
|
|
||||||
|
def _translation_response_from_prompt(payload):
|
||||||
|
user_prompt = payload["messages"][1]["content"]
|
||||||
|
section = user_prompt.split("待翻译句子:", 1)[1]
|
||||||
|
indexes = [int(match) for match in re.findall(r"^\[(\d+)\]", section, re.M)]
|
||||||
|
body = {str(index): f"中文{index}" for index in indexes}
|
||||||
|
return FakeResponse(
|
||||||
|
body={"choices": [{"message": {"content": json.dumps(body, ensure_ascii=False)}}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_tutor_translates_long_input_in_batches(monkeypatch):
|
||||||
|
requests_payloads = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def fake_post(endpoint, headers, json, timeout):
|
||||||
|
with lock:
|
||||||
|
requests_payloads.append(json)
|
||||||
|
return _translation_response_from_prompt(json)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
||||||
|
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
|
||||||
|
sentences = [(index, f"This is sentence {index} with enough text.") for index in range(30)]
|
||||||
|
|
||||||
|
translated = tutor.translate_sentences(sentences, course_title="Long talk")
|
||||||
|
|
||||||
|
assert translated == {index: f"中文{index}" for index in range(30)}
|
||||||
|
assert len(requests_payloads) >= 3
|
||||||
|
first_prompt = requests_payloads[0]["messages"][1]["content"]
|
||||||
|
assert "前文" in first_prompt or "后文" in first_prompt or "待翻译句子:" in first_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_tutor_skips_cached_sentences_and_persists_windows(monkeypatch):
|
||||||
|
persisted = []
|
||||||
|
|
||||||
|
def fake_post(endpoint, headers, json, timeout):
|
||||||
|
return _translation_response_from_prompt(json)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
||||||
|
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
|
||||||
|
translated = tutor.translate_sentences(
|
||||||
|
[(0, "Hello."), (1, "Thanks."), (2, "Bye.")],
|
||||||
|
existing={0: "你好。"},
|
||||||
|
persist=persisted.append,
|
||||||
|
course_title="Greetings",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert translated[0] == "你好。"
|
||||||
|
assert translated[1] == "中文1"
|
||||||
|
assert translated[2] == "中文2"
|
||||||
|
assert persisted
|
||||||
|
assert 0 not in persisted[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_tutor_splits_invalid_windows_and_retries(monkeypatch):
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
def fake_post(endpoint, headers, json, timeout):
|
||||||
|
calls["count"] += 1
|
||||||
|
if calls["count"] == 1:
|
||||||
|
return FakeResponse(body={"choices": [{"message": {"content": "not-json"}}]})
|
||||||
|
return _translation_response_from_prompt(json)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
||||||
|
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
|
||||||
|
sentences = [(index, f"This is sentence {index} with enough text.") for index in range(12)]
|
||||||
|
|
||||||
|
translated = tutor.translate_sentences(sentences)
|
||||||
|
|
||||||
|
assert translated == {index: f"中文{index}" for index in range(12)}
|
||||||
|
assert calls["count"] >= 3
|
||||||
|
|
||||||
|
|
||||||
def test_parse_translation_payload_accepts_fenced_json_and_numbered_lines():
|
def test_parse_translation_payload_accepts_fenced_json_and_numbered_lines():
|
||||||
assert _parse_translation_payload('```json\n["一", "二"]\n```', 2) == ["一", "二"]
|
assert _parse_translation_payload('```json\n["一", "二"]\n```', 2) == {0: "一", 1: "二"}
|
||||||
assert _parse_translation_payload("1. 一\n2. 二", 2) == ["一", "二"]
|
assert _parse_translation_payload("1. 一\n2. 二", 2) == {0: "一", 1: "二"}
|
||||||
|
assert _parse_translation_payload('prefix {"12":"甲","13":"乙"} suffix', [12, 13]) == {
|
||||||
|
12: "甲",
|
||||||
|
13: "乙",
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,6 +268,53 @@ def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeyp
|
|||||||
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_subtitle_translation_is_shared_with_other_users(tmp_path, monkeypatch):
|
||||||
|
client = make_client(tmp_path)
|
||||||
|
_ready_video(client.app.state.video_repository)
|
||||||
|
requested = []
|
||||||
|
|
||||||
|
def translate_sentences(sentences, **kwargs):
|
||||||
|
requested.append((sentences, kwargs))
|
||||||
|
return {index: f"中文{index}" for index, _text in sentences}
|
||||||
|
|
||||||
|
class StubAITutor:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stub = StubAITutor()
|
||||||
|
stub.translate_sentences = translate_sentences
|
||||||
|
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
|
||||||
|
|
||||||
|
first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
|
||||||
|
stored = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||||
|
assert stored.status_code == 200
|
||||||
|
assert stored.json() == first.json()
|
||||||
|
|
||||||
|
other = TestClient(client.app)
|
||||||
|
signup = other.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"username": "viewer2", "password": "password-123"},
|
||||||
|
)
|
||||||
|
assert signup.status_code == 201, signup.text
|
||||||
|
other.headers.update({"Authorization": f"Bearer {signup.json()['token']}"})
|
||||||
|
|
||||||
|
shared = other.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||||
|
assert shared.status_code == 200
|
||||||
|
assert shared.json()["translations"] == [
|
||||||
|
{"index": 0, "zh_text": "中文0"},
|
||||||
|
{"index": 1, "zh_text": "中文1"},
|
||||||
|
]
|
||||||
|
boundaries = other.get(f"/api/v1/videos/{VIDEO_HASH}/sentence-boundaries")
|
||||||
|
assert boundaries.status_code == 200
|
||||||
|
assert [item["zh_text"] for item in boundaries.json()["sentences"]] == ["中文0", "中文1"]
|
||||||
|
|
||||||
|
reused = other.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||||
|
assert reused.status_code == 200
|
||||||
|
assert reused.json() == first.json()
|
||||||
|
assert len(requested) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_oss_video_url_is_used_for_catalog_and_content(tmp_path):
|
def test_oss_video_url_is_used_for_catalog_and_content(tmp_path):
|
||||||
index_path = tmp_path / "boundaries.json"
|
index_path = tmp_path / "boundaries.json"
|
||||||
index_path.write_text("{}", encoding="utf-8")
|
index_path.write_text("{}", encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user