feat: add ImageFind application and release pipelines
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from imagefind.config import Settings
|
||||
from imagefind.main import create_app
|
||||
from imagefind.models import ModelManager
|
||||
from imagefind.speech import SpeechService
|
||||
|
||||
|
||||
def _bundle(*, unsafe: bool = False) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=output, mode="w:gz") as archive:
|
||||
files = {
|
||||
"manifest.json": json.dumps({"version": "manual-test"}).encode(),
|
||||
"visual/image/modules.json": json.dumps([{"idx": 0, "path": "", "type": "Transformer"}]).encode(),
|
||||
"visual/text/modules.json": json.dumps([{"idx": 0, "path": "", "type": "Transformer"}]).encode(),
|
||||
}
|
||||
if unsafe:
|
||||
files["../outside"] = b"unsafe"
|
||||
for name, content in files.items():
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(content)
|
||||
archive.addfile(member, io.BytesIO(content))
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _app(tmp_path: Path):
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
embedding_backend="hash",
|
||||
upload_reserve_gb=0,
|
||||
model_upload_gb=1,
|
||||
)
|
||||
settings.prepare()
|
||||
app = create_app(settings)
|
||||
_, token = app.state.services.auth.create_api_token("models")
|
||||
return app, {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _write_audio_variant(root: Path) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for name in (
|
||||
"config.json",
|
||||
"preprocessor_config.json",
|
||||
"tokenizer_config.json",
|
||||
"tokenizer.json",
|
||||
"openvino_encoder_model.xml",
|
||||
"openvino_encoder_model.bin",
|
||||
"openvino_decoder_model.xml",
|
||||
"openvino_decoder_model.bin",
|
||||
):
|
||||
(root / name).write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
def test_manual_model_bundle_upload_and_atomic_install(tmp_path: Path):
|
||||
app, headers = _app(tmp_path)
|
||||
bundle = _bundle()
|
||||
|
||||
async def scenario():
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/models/upload",
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Model-Filename": "imagefind-models.tar.gz",
|
||||
},
|
||||
content=bundle,
|
||||
)
|
||||
assert response.status_code == 202
|
||||
payload = response.json()
|
||||
assert payload["size_bytes"] == len(bundle)
|
||||
assert payload["sha256"] == hashlib.sha256(bundle).hexdigest()
|
||||
|
||||
with app.state.services.db.read() as conn:
|
||||
row = conn.execute("SELECT payload_json FROM jobs WHERE id=?", (payload["job_id"],)).fetchone()
|
||||
job_payload = json.loads(row["payload_json"])
|
||||
archive_path = Path(job_payload["archive_path"])
|
||||
result = app.state.services.models.install(
|
||||
archive_path=archive_path,
|
||||
expected_sha256=job_payload["sha256"],
|
||||
)
|
||||
assert result["manifest"]["version"] == "manual-test"
|
||||
assert (app.state.services.settings.models_dir / "visual" / "image" / "modules.json").is_file()
|
||||
assert (app.state.services.settings.models_dir / "visual" / "text" / "modules.json").is_file()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_medium_audio_variant_uses_isolated_verification(tmp_path: Path, monkeypatch):
|
||||
app, _headers = _app(tmp_path)
|
||||
service = app.state.services
|
||||
_write_audio_variant(service.settings.models_dir / "audio" / "small")
|
||||
_write_audio_variant(service.settings.models_dir / "audio" / "medium")
|
||||
service.settings.audio_model_variant = "small"
|
||||
service.speech.accelerator.gpu_device = "GPU.0"
|
||||
called: list[str] = []
|
||||
|
||||
def forbidden_main_process_verify():
|
||||
raise AssertionError("Medium verification must not run in the API process")
|
||||
|
||||
def isolated_verify(variant):
|
||||
called.append(f"{variant}:{service.settings.audio_model_variant}")
|
||||
service.speech.accelerator.mark_ready("audio", "GPU.0", ["GPU.0"])
|
||||
return service.speech.accelerator.status()["components"]["audio"]
|
||||
|
||||
monkeypatch.setattr(service.speech, "verify_acceleration", forbidden_main_process_verify)
|
||||
monkeypatch.setattr(service.speech, "verify_variant_acceleration_isolated", isolated_verify)
|
||||
|
||||
selected = service.models.set_audio_variant("medium")
|
||||
|
||||
assert selected["active"] is True
|
||||
assert called == ["medium:small"]
|
||||
assert service.settings.audio_model_variant == "medium"
|
||||
|
||||
|
||||
def test_medium_audio_variant_verification_failure_rolls_back(tmp_path: Path, monkeypatch):
|
||||
app, _headers = _app(tmp_path)
|
||||
service = app.state.services
|
||||
_write_audio_variant(service.settings.models_dir / "audio" / "small")
|
||||
_write_audio_variant(service.settings.models_dir / "audio" / "medium")
|
||||
service.settings.audio_model_variant = "small"
|
||||
service.speech.accelerator.gpu_device = "GPU.0"
|
||||
|
||||
def isolated_verify(_variant):
|
||||
raise RuntimeError("worker crashed")
|
||||
|
||||
monkeypatch.setattr(service.speech, "verify_variant_acceleration_isolated", isolated_verify)
|
||||
|
||||
with pytest.raises(RuntimeError, match="worker crashed"):
|
||||
service.models.set_audio_variant("medium")
|
||||
|
||||
assert service.settings.audio_model_variant == "small"
|
||||
|
||||
|
||||
def test_speech_config_medium_failure_returns_conflict_and_keeps_small(tmp_path: Path, monkeypatch):
|
||||
app, headers = _app(tmp_path)
|
||||
service = app.state.services
|
||||
_write_audio_variant(service.settings.models_dir / "audio" / "small")
|
||||
_write_audio_variant(service.settings.models_dir / "audio" / "medium")
|
||||
service.settings.audio_model_variant = "small"
|
||||
service.speech.accelerator.gpu_device = "GPU.0"
|
||||
|
||||
def isolated_verify(_variant):
|
||||
raise RuntimeError("worker crashed")
|
||||
|
||||
monkeypatch.setattr(service.speech, "verify_variant_acceleration_isolated", isolated_verify)
|
||||
|
||||
async def scenario():
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/speech/config",
|
||||
headers=headers,
|
||||
json={"model_variant": "medium"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
config = await client.get("/api/v1/speech/config", headers=headers)
|
||||
assert config.json()["model_variant"] == "small"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_old_medium_fp16_variant_requires_int8_update(tmp_path: Path):
|
||||
settings = Settings(data_dir=tmp_path / "data")
|
||||
settings.prepare()
|
||||
medium = settings.models_dir / "audio" / "medium"
|
||||
_write_audio_variant(medium)
|
||||
manifest = {
|
||||
"components": {
|
||||
"audio": {
|
||||
"variants": {
|
||||
"medium": {
|
||||
"version": "OpenVINO/whisper-medium-fp16-ov",
|
||||
"source": "https://hf-mirror.com/OpenVINO/whisper-medium-fp16-ov",
|
||||
}
|
||||
},
|
||||
"active_variant": "small",
|
||||
}
|
||||
}
|
||||
}
|
||||
(settings.models_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
manager = ModelManager.__new__(ModelManager)
|
||||
manager.settings = settings
|
||||
manager.speech = SpeechService(settings)
|
||||
manager._manifest_cached_mtime_ns = -1
|
||||
manager._manifest_cached = {}
|
||||
|
||||
variant = manager.audio_variants()["medium"]
|
||||
|
||||
assert variant["installed"] is False
|
||||
assert variant["needs_update"] is True
|
||||
assert variant["expected_version"] == "OpenVINO/whisper-medium-int8-ov"
|
||||
assert variant["size_bytes"] > 0
|
||||
|
||||
|
||||
def test_model_status_defaults_to_light_manifest_and_allows_full_manifest(tmp_path: Path):
|
||||
app, headers = _app(tmp_path)
|
||||
app.state.services.settings.models_dir.mkdir(parents=True, exist_ok=True)
|
||||
(app.state.services.settings.models_dir / "manifest.json").write_text(
|
||||
json.dumps({"version": "test", "components": {}, "files": {"audio/model.bin": "hash"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
light = await client.get("/api/v1/models", headers=headers)
|
||||
assert light.status_code == 200
|
||||
assert "files" not in light.json()["manifest"]
|
||||
|
||||
full = await client.get("/api/v1/models?summary=false", headers=headers)
|
||||
assert full.status_code == 200
|
||||
assert full.json()["manifest"]["files"] == {"audio/model.bin": "hash"}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_manual_model_bundle_rejects_unsafe_archive(tmp_path: Path):
|
||||
app, _ = _app(tmp_path)
|
||||
archive_path = tmp_path / "unsafe.tar.gz"
|
||||
archive_path.write_bytes(_bundle(unsafe=True))
|
||||
|
||||
try:
|
||||
app.state.services.models.install(archive_path=archive_path)
|
||||
except ValueError as exc:
|
||||
assert "不安全路径" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unsafe archive must be rejected")
|
||||
assert not (tmp_path / "outside").exists()
|
||||
|
||||
|
||||
def test_default_model_install_uses_official_repositories(tmp_path: Path, monkeypatch):
|
||||
app, _ = _app(tmp_path)
|
||||
manager = app.state.services.models
|
||||
rapid_root = tmp_path / "rapidocr"
|
||||
rapid_models = rapid_root / "models"
|
||||
rapid_models.mkdir(parents=True)
|
||||
for name in (
|
||||
"ch_PP-OCRv4_det_infer.onnx",
|
||||
"ch_PP-OCRv4_rec_infer.onnx",
|
||||
"ch_ppocr_mobile_v2.0_cls_infer.onnx",
|
||||
):
|
||||
(rapid_models / name).write_bytes(name.encode())
|
||||
rapid_module = types.ModuleType("rapidocr_onnxruntime")
|
||||
rapid_module.__file__ = str(rapid_root / "__init__.py")
|
||||
hub_module = types.ModuleType("huggingface_hub")
|
||||
requests_module = types.ModuleType("requests")
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.trust_env = True
|
||||
self.proxies = {}
|
||||
|
||||
def snapshot_download(repo_id, *, local_dir, **_):
|
||||
target = Path(local_dir)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / "modules.json").write_text(
|
||||
json.dumps([{"idx": 0, "path": "", "type": "Transformer", "repo": repo_id}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(target)
|
||||
|
||||
hub_module.snapshot_download = snapshot_download
|
||||
hub_module.configure_http_backend = lambda **_: None
|
||||
requests_module.Session = Session
|
||||
monkeypatch.setitem(sys.modules, "rapidocr_onnxruntime", rapid_module)
|
||||
monkeypatch.setitem(sys.modules, "huggingface_hub", hub_module)
|
||||
monkeypatch.setitem(sys.modules, "requests", requests_module)
|
||||
|
||||
def download_file(url, destination):
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.suffix == ".xml":
|
||||
destination.write_text("<net><layers/></net>", encoding="utf-8")
|
||||
else:
|
||||
destination.write_bytes(b"\0" * (64 * 1024))
|
||||
|
||||
monkeypatch.setattr(manager, "_download_file", download_file)
|
||||
|
||||
def export_audio_model(_source, destination):
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for name in (
|
||||
"config.json",
|
||||
"preprocessor_config.json",
|
||||
"tokenizer_config.json",
|
||||
"vocab.json",
|
||||
"openvino_encoder_model.xml",
|
||||
"openvino_encoder_model.bin",
|
||||
"openvino_decoder_model.xml",
|
||||
"openvino_decoder_model.bin",
|
||||
"openvino_decoder_with_past_model.xml",
|
||||
"openvino_decoder_with_past_model.bin",
|
||||
):
|
||||
(destination / name).write_text("{}" if name.endswith(".json") else "model")
|
||||
|
||||
monkeypatch.setattr(manager, "_export_audio_model", export_audio_model)
|
||||
monkeypatch.setattr(manager, "repair_component", lambda *_args, **_kwargs: {})
|
||||
progress = []
|
||||
|
||||
result = manager.install(progress=lambda value, message: progress.append((value, message)))
|
||||
|
||||
assert result["manifest"]["source"] == "official-repositories"
|
||||
assert (manager.settings.models_dir / "ocr" / "det.onnx").is_file()
|
||||
assert (manager.settings.models_dir / "faces" / "detector.xml").is_file()
|
||||
assert (manager.settings.models_dir / "audio" / "openvino_encoder_model.xml").is_file()
|
||||
assert any("多语言文本模型" in message for _, message in progress)
|
||||
|
||||
|
||||
def test_component_install_preserves_other_components_and_manifest(tmp_path: Path, monkeypatch):
|
||||
app, _ = _app(tmp_path)
|
||||
manager = app.state.services.models
|
||||
root = manager.settings.models_dir
|
||||
(root / "faces").mkdir(parents=True)
|
||||
(root / "faces" / "sentinel.bin").write_bytes(b"existing-face-index-runtime")
|
||||
(root / "manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"format_version": 2,
|
||||
"version": "existing",
|
||||
"components": {"faces": {"version": "faces-existing"}},
|
||||
"files": {"faces/sentinel.bin": hashlib.sha256(b"existing-face-index-runtime").hexdigest()},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
rapid_root = tmp_path / "component-rapidocr"
|
||||
rapid_models = rapid_root / "models"
|
||||
rapid_models.mkdir(parents=True)
|
||||
for name in (
|
||||
"ch_PP-OCRv4_det_infer.onnx",
|
||||
"ch_PP-OCRv4_rec_infer.onnx",
|
||||
"ch_ppocr_mobile_v2.0_cls_infer.onnx",
|
||||
):
|
||||
(rapid_models / name).write_bytes(name.encode())
|
||||
rapid_module = types.ModuleType("rapidocr_onnxruntime")
|
||||
rapid_module.__file__ = str(rapid_root / "__init__.py")
|
||||
monkeypatch.setitem(sys.modules, "rapidocr_onnxruntime", rapid_module)
|
||||
|
||||
progress: list[tuple[float, str]] = []
|
||||
result = manager.install(component="ocr", progress=lambda value, message: progress.append((value, message)))
|
||||
|
||||
assert result["component"] == "ocr"
|
||||
assert (root / "faces" / "sentinel.bin").read_bytes() == b"existing-face-index-runtime"
|
||||
assert (root / "ocr" / "det.onnx").is_file()
|
||||
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["components"]["faces"]["version"] == "faces-existing"
|
||||
assert manifest["components"]["ocr"]["version"] == "RapidOCR-PP-OCRv4"
|
||||
assert "faces/sentinel.bin" in manifest["files"]
|
||||
assert "ocr/det.onnx" in manifest["files"]
|
||||
assert any("启用" in message for _, message in progress)
|
||||
|
||||
|
||||
def test_component_activation_rolls_back_directory_and_manifest(tmp_path: Path, monkeypatch):
|
||||
app, _ = _app(tmp_path)
|
||||
manager = app.state.services.models
|
||||
root = manager.settings.models_dir
|
||||
old = root / "ocr"
|
||||
old.mkdir(parents=True)
|
||||
(old / "sentinel.bin").write_bytes(b"old-component")
|
||||
original_manifest = {"version": "old", "components": {"ocr": {"version": "old-ocr"}}}
|
||||
(root / "manifest.json").write_text(json.dumps(original_manifest), encoding="utf-8")
|
||||
staged = tmp_path / "staged-ocr"
|
||||
staged.mkdir()
|
||||
for name in ("det.onnx", "rec.onnx", "cls.onnx"):
|
||||
(staged / name).write_bytes(b"new")
|
||||
|
||||
monkeypatch.setattr(manager, "_write_manifest", lambda _manifest: (_ for _ in ()).throw(OSError("disk")))
|
||||
with pytest.raises(OSError, match="disk"):
|
||||
manager._activate_component("ocr", staged, {"version": "new-ocr"})
|
||||
|
||||
assert (old / "sentinel.bin").read_bytes() == b"old-component"
|
||||
assert json.loads((root / "manifest.json").read_text(encoding="utf-8")) == original_manifest
|
||||
|
||||
|
||||
def test_component_install_api_queues_missing_models_individually_and_reports_progress(tmp_path: Path):
|
||||
app, headers = _app(tmp_path)
|
||||
stale_audio = app.state.services.jobs.enqueue("transcribe_audio", {"video_id": "stale"})
|
||||
|
||||
async def scenario():
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/api/v1/models/install", headers=headers, json={"component": "all"})
|
||||
assert response.status_code == 202
|
||||
job_ids = response.json()["job_ids"]
|
||||
assert response.json()["cancelled_audio_jobs"] == 1
|
||||
# The deterministic hash visual backend is already ready in tests.
|
||||
assert list(job_ids) == ["ocr", "faces", "audio"]
|
||||
assert response.json()["job_id"] == job_ids["ocr"]
|
||||
duplicate = await client.post("/api/v1/models/install", headers=headers, json={"component": "ocr"})
|
||||
assert duplicate.json()["job_ids"]["ocr"] == job_ids["ocr"]
|
||||
with app.state.services.db.transaction() as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET status='running',progress=.42,message='正在复制 OCR 权重' WHERE id=?",
|
||||
(job_ids["ocr"],),
|
||||
)
|
||||
status = (await client.get("/api/v1/models", headers=headers)).json()
|
||||
assert status["installations"]["ocr"] == {
|
||||
"job_id": job_ids["ocr"],
|
||||
"status": "running",
|
||||
"progress": 0.42,
|
||||
"message": "正在复制 OCR 权重",
|
||||
"error": None,
|
||||
}
|
||||
custom = await client.post(
|
||||
"/api/v1/models/install",
|
||||
headers=headers,
|
||||
json={"component": "faces", "url": "https://example.invalid/models.tar.gz"},
|
||||
)
|
||||
assert custom.status_code == 400
|
||||
with app.state.services.db.read() as conn:
|
||||
audio = conn.execute("SELECT status FROM jobs WHERE id=?", (stale_audio,)).fetchone()
|
||||
assert audio["status"] == "cancelled"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_audio_medium_install_is_independent_and_does_not_cancel_active_audio_work(tmp_path: Path):
|
||||
app, headers = _app(tmp_path)
|
||||
active_audio = app.state.services.jobs.enqueue("transcribe_audio", {"video_id": "active"})
|
||||
|
||||
async def scenario():
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/models/install",
|
||||
headers=headers,
|
||||
json={"component": "audio", "variant": "medium"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.json()["cancelled_audio_jobs"] == 0
|
||||
job_id = response.json()["job_ids"]["audio:medium"]
|
||||
with app.state.services.db.read() as conn:
|
||||
job = conn.execute("SELECT payload_json,dedupe_key FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
active = conn.execute("SELECT status FROM jobs WHERE id=?", (active_audio,)).fetchone()
|
||||
assert json.loads(job["payload_json"]) == {"component": "audio", "variant": "medium"}
|
||||
assert job["dedupe_key"] == "install-model:audio:medium"
|
||||
assert active["status"] == "queued"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_pending_audio_install_cancels_stale_audio_jobs_on_startup(tmp_path: Path):
|
||||
app, _ = _app(tmp_path)
|
||||
stale_audio = app.state.services.jobs.enqueue("transcribe_audio", {"video_id": "stale"})
|
||||
app.state.services.jobs.enqueue("install_models", {"component": "audio"}, dedupe_key="install-model:audio")
|
||||
|
||||
assert app.state.services._cancel_audio_for_pending_model_install() == 1
|
||||
|
||||
with app.state.services.db.read() as conn:
|
||||
row = conn.execute("SELECT status,message FROM jobs WHERE id=?", (stale_audio,)).fetchone()
|
||||
assert row["status"] == "cancelled"
|
||||
assert "模型安装优先" in row["message"]
|
||||
|
||||
|
||||
def test_component_install_repairs_ready_runtime_without_redownloading_model(tmp_path: Path, monkeypatch):
|
||||
app, headers = _app(tmp_path)
|
||||
manager = app.state.services.models
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"status",
|
||||
lambda: {
|
||||
"visual_ready": True,
|
||||
"ocr_ready": False,
|
||||
"faces_ready": False,
|
||||
"audio_ready": False,
|
||||
"operational_components": {"visual": False},
|
||||
},
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/models/install",
|
||||
headers=headers,
|
||||
json={"component": "visual"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
job_id = response.json()["job_ids"]["visual"]
|
||||
with app.state.services.db.read() as connection:
|
||||
job = connection.execute(
|
||||
"SELECT kind,payload_json,dedupe_key FROM jobs WHERE id=?",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
assert job["kind"] == "prepare_ai_runtime"
|
||||
assert json.loads(job["payload_json"]) == {"components": ["visual"]}
|
||||
assert job["dedupe_key"] == "prepare-ai-runtime:visual"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_outdated_audio_repair_queues_real_model_reinstall(tmp_path: Path, monkeypatch):
|
||||
app, headers = _app(tmp_path)
|
||||
manager = app.state.services.models
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"status",
|
||||
lambda: {
|
||||
"visual_ready": False,
|
||||
"ocr_ready": False,
|
||||
"faces_ready": False,
|
||||
"audio_ready": True,
|
||||
"operational_components": {"audio": False},
|
||||
"component_health": {"audio": {"state": "outdated", "error": "需要转换为 FP16 模型"}},
|
||||
},
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/models/install",
|
||||
headers=headers,
|
||||
json={"component": "audio"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
job_id = response.json()["job_ids"]["audio"]
|
||||
with app.state.services.db.read() as connection:
|
||||
job = connection.execute("SELECT kind,payload_json,dedupe_key FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
assert job["kind"] == "install_models"
|
||||
assert json.loads(job["payload_json"]) == {"component": "audio"}
|
||||
assert job["dedupe_key"] == "install-model:audio"
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user