feat(multimodal): 补齐阶段F运行管理与收尾验收

This commit is contained in:
2026-09-05 01:06:29 +08:00
parent 6bdba2c7f9
commit 64f63ff1bd
29 changed files with 838 additions and 84 deletions
+1
View File
@@ -1050,6 +1050,7 @@ class TranscriptEditRequest(Contract):
class TranscriptNoteRequest(Contract):
update_existing: bool = False
title: str = Field(min_length=1, max_length=200)
folder: str | None = None
include_timestamps: bool = True
+6 -3
View File
@@ -1,4 +1,6 @@
import asyncio
from fastapi import APIRouter
from app.services import model_diagnostics
from app.local_models import manager
from app.local_models.runtime import RuntimeConfig, configuration, configure, interpreter, runtime
@@ -7,9 +9,10 @@ router = APIRouter(prefix="/api/local-models", tags=["Local models"])
@router.get("")
async def list_models():
return {**manager.describe(), "runtime_installed": interpreter().is_file(), "config": configuration(),
items, diagnostics = await asyncio.gather(asyncio.to_thread(manager.describe), asyncio.to_thread(model_diagnostics.recent))
return {**items, "runtime_installed": interpreter().is_file(), "config": configuration(),
"active_models": list(runtime.active.values()), "queued_requests": len(runtime.waiters),
"last_inference": runtime.diagnostics[-1] if runtime.diagnostics else None}
"last_inference": diagnostics[-1] if diagnostics else None}
@router.put("/config")
@@ -34,5 +37,5 @@ async def delete(key: str):
@router.get("/diagnostics")
async def diagnostics():
return {"items": runtime.diagnostics, "config": configuration(), "scope": "current_process",
return {"items": await asyncio.to_thread(model_diagnostics.recent), "config": configuration(), "scope": "application_last_200_attempts",
"contains": "model_revision_device_timing_resources_only"}
+13 -1
View File
@@ -49,8 +49,20 @@ def task_key(key):
return str(model_path(key)), key
def disk_bytes(key):
total = 0
try:
root = model_path(key).resolve()
for path in root.rglob("*"):
if not path.is_symlink() and path.is_file() and path.resolve().is_relative_to(root):
total += path.stat().st_size
except OSError:
return None
return total
def describe():
return {"items": [{**spec.public(), **read_state(key)} for key, spec in CATALOG.items()]}
return {"items": [{**spec.public(), **read_state(key), "disk_bytes": disk_bytes(key)} for key, spec in CATALOG.items()]}
async def download(key):
+84 -26
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
import asyncio
import json
import os
import time
from contextlib import closing
from contextvars import ContextVar
from functools import wraps
from pathlib import Path
from typing import Literal
@@ -31,6 +33,18 @@ class RuntimeConfig(BaseModel):
runtime_context = ContextVar("runtime_config", default=None)
runtime_progress = ContextVar("runtime_progress", default=None)
embedding_priority = ContextVar("embedding_priority", default=0)
def background_embeddings(operation):
@wraps(operation)
async def wrapped(*args, **kwargs):
token = embedding_priority.set(20)
try:
return await operation(*args, **kwargs)
finally:
embedding_priority.reset(token)
return wrapped
def configuration():
@@ -75,28 +89,81 @@ class Runtime:
return any(target in paths for paths in self.active_files.values())
async def infer(self, key, operation, payload, *, priority=10):
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先在模型配置中下载本地模型。")
if not interpreter().is_file():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先运行本地模型 CPU/CUDA 安装脚本。")
config = configuration()
from app.services import model_diagnostics
config = configuration().model_copy(deep=True)
self.counter += 1
ticket = (priority, self.counter)
self.waiters.append(ticket)
process = None
attempt = None
queued_at = time.monotonic()
reason = None
from app.services.usage_service import usage_context
from uuid import uuid4
context = dict(usage_context.get() or {})
context.setdefault("request_id", uuid4().hex)
usage_token = usage_context.set(context)
try:
# One resident model at a time prevents overlapping CPU/GPU allocations.
while self.active or ticket != min(self.waiters):
await asyncio.sleep(0.05)
self.waiters.remove(ticket)
self.active[ticket] = key
self.active_files[ticket] = {str(Path(payload[name]).resolve()) for name in ("source", "reference") if payload.get(name)}
# Deletion may have occurred while this request was queued.
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "模型文件已被删除。")
from app.services.usage_service import UsageAttempt
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
queue_seconds = time.monotonic() - queued_at
# Keep the reservation while replacing a failed CUDA process with CPU.
for device in (["cuda", "cpu"] if config.device == "cuda" else ["cpu"]):
started = time.monotonic()
diagnostics = dict(model=CATALOG[key].repository, revision=CATALOG[key].revision,
operation=operation, source="local", requested_device=config.device,
attempted_device=device, queue_seconds=queue_seconds, fallback_reason=reason, request_id=context["request_id"])
try:
result = await self._execute(key, operation, payload, config.model_copy(update={"device": device}), diagnostics)
diagnostics.update(result.get("diagnostics", {}))
diagnostics.update(requested_device=config.device, status="completed")
if reason:
diagnostics["fallback_reason"] = reason
return result["result"]
except asyncio.CancelledError:
diagnostics.update(status="cancelled", error_code="LOCAL_MODEL_CANCELLED")
raise
except ProviderError as exc:
diagnostics.update(status="failed", error_code=exc.code)
if device == "cuda" and exc.code in {"LOCAL_CUDA_INIT_FAILED", "LOCAL_CUDA_OOM"}:
reason = exc.code
callback = runtime_progress.get()
if callback:
callback({"reset": True, "progress": 0})
continue
raise
except Exception:
diagnostics.update(status="failed", error_code="LOCAL_MODEL_INVALID_RESPONSE")
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型返回无效数据。") from None
finally:
diagnostics["requested_device"] = config.device
diagnostics["elapsed_seconds"] = time.monotonic() - started
self.diagnostics.append(model_diagnostics.record(**diagnostics))
self.diagnostics = self.diagnostics[-100:]
except asyncio.CancelledError:
if ticket not in self.active:
model_diagnostics.record(model=CATALOG[key].repository, operation=operation,
source="local", status="cancelled", error_code="LOCAL_QUEUE_CANCELLED",
requested_device=config.device, queue_seconds=time.monotonic() - queued_at)
raise
finally:
if ticket in self.waiters:
self.waiters.remove(ticket)
self.active.pop(ticket, None)
self.active_files.pop(ticket, None)
usage_context.reset(usage_token)
async def _execute(self, key, operation, payload, config, diagnostics):
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先下载本地模型。")
if not interpreter().is_file():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先安装本地模型运行环境。")
from app.services.usage_service import UsageAttempt
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
diagnostics.update(attempt_id=attempt.attempt_id, request_id=attempt.request_id)
process = None
try:
env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
"HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads),
"PYTHONIOENCODING": "utf-8"}
@@ -118,8 +185,6 @@ class Runtime:
process.stdin.close()
final = None
while line := await process.stdout.readline():
if len(line) > 16 * 1024 * 1024:
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型输出超限。")
message = json.loads(line)
if "progress" in message:
callback = runtime_progress.get()
@@ -137,26 +202,19 @@ class Runtime:
raise ProviderError("LOCAL_MODEL_PROCESS_FAILED", "本地模型进程退出,请检查依赖与资源预算。")
if not isinstance(result, dict):
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型进程未返回有效结果。")
diagnostics.update(result.get("diagnostics", {}))
if "error_code" in result:
raise ProviderError(result["error_code"], result.get("message", "本地推理失败。"))
attempt.observe(result)
attempt.completed = True
self.diagnostics.append({"model": CATALOG[key].repository, "revision": CATALOG[key].revision,
**result.get("diagnostics", {})})
self.diagnostics = self.diagnostics[-100:]
return result["result"]
return result
finally:
if ticket in self.waiters:
self.waiters.remove(ticket)
if process is not None and process.returncode is None:
process.kill()
await process.wait()
if process is not None and hasattr(process, "close"):
await process.close()
self.active.pop(ticket, None)
self.active_files.pop(ticket, None)
if attempt:
attempt.persist()
attempt.persist()
runtime = Runtime()
@@ -188,7 +246,7 @@ class LocalEmbedding:
config = (self._config or configuration()).model_copy(deep=True)
token = runtime_context.set(config)
try:
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=0)
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
finally:
runtime_context.reset(token)
+28 -7
View File
@@ -76,16 +76,25 @@ def voice_embedding(model, audio, device):
return torch.nn.functional.normalize(vector, dim=0)
class CudaInitializationError(RuntimeError):
pass
def run(request):
import torch
import psutil
config, payload = request["config"], request["payload"]
torch.set_num_threads(config["cpu_threads"])
requested = config["device"]
device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu"
if device != "cpu":
total = torch.cuda.get_device_properties(0).total_memory
torch.cuda.set_per_process_memory_fraction(min(1.0, config["gpu_memory_limit_mb"] * 1024 ** 2 / total))
try:
device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu"
if device != "cpu":
torch.cuda.init()
total = torch.cuda.get_device_properties(0).total_memory
torch.cuda.set_per_process_memory_fraction(min(1.0, config["gpu_memory_limit_mb"] * 1024 ** 2 / total))
except Exception as exc:
raise CudaInitializationError() from exc
request["_actual_device"] = device
process = psutil.Process()
peak = [0]
stop = threading.Event()
@@ -102,6 +111,7 @@ def run(request):
path, operation = request["model_path"], request["operation"]
try:
usage = {}
audio_seconds = None
if operation == "embedding":
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(path, device=device, local_files_only=True, trust_remote_code=False,
@@ -116,6 +126,7 @@ def run(request):
device_map=device, attn_implementation="sdpa", max_inference_batch_size=1, max_new_tokens=512)
loaded = time.monotonic()
audio = decode(payload["source"])
audio_seconds = len(audio) / 16000
regions = speech_regions(audio)
language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language"))
segments = []
@@ -154,7 +165,7 @@ def run(request):
result = {"speakers": speakers}
else:
raise ValueError("Unknown inference operation")
return {"result": result, "usage": usage, "diagnostics": {"requested_device": requested, "actual_device": device,
return {"result": result, "usage": usage, "audio_seconds": audio_seconds, "diagnostics": {"requested_device": requested, "actual_device": device,
"fallback_reason": "CUDA_UNAVAILABLE" if requested == "cuda" and device == "cpu" else None,
"load_seconds": loaded - started, "inference_seconds": time.monotonic() - loaded,
"peak_memory_bytes": max(peak[0], process.memory_info().rss), "operation": operation}}
@@ -170,6 +181,16 @@ if __name__ == "__main__":
response = run(request)
except (ImportError, ModuleNotFoundError):
response = {"error_code": "LOCAL_RUNTIME_DEPENDENCY_MISSING", "message": "本地模型运行依赖不完整,请重新运行安装脚本。"}
except Exception:
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
except Exception as exc:
# Only device failures allow the host to retry once in a fresh CPU process.
import torch
cuda_failure = isinstance(exc, CudaInitializationError)
cuda_oom = request.get("_actual_device") == "cuda:0" and isinstance(exc, torch.cuda.OutOfMemoryError)
if cuda_failure or cuda_oom:
response = {"error_code": "LOCAL_CUDA_OOM" if cuda_oom else "LOCAL_CUDA_INIT_FAILED",
"message": "CUDA 运行失败,将释放进程并重试 CPU。"}
else:
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
if "error_code" in response:
response["diagnostics"] = {"requested_device": request["config"]["device"], "actual_device": request.get("_actual_device", "unknown")}
sys.stdout.buffer.write((json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8"))
+13 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import json
import hashlib
from contextlib import closing
from pathlib import Path
from uuid import uuid4
@@ -22,14 +23,17 @@ MEDIA_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".mp4", ".webm", ".tx
@router.post("/attachments", status_code=201)
async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255)):
async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255),
idempotency_key: str | None = Header(None, min_length=16, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")):
suffix = Path(filename).suffix.lower()
if suffix not in MEDIA_SUFFIXES:
raise ApiError(422, "UNSUPPORTED_MEDIA", "Unsupported attachment extension.")
attachment_id = f"media_{uuid4().hex}{suffix}"
identity = hashlib.sha256(idempotency_key.encode()).hexdigest() if idempotency_key else uuid4().hex
attachment_id = f"media_{identity}{suffix}"
destination = attachment_path(attachment_id)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".upload")
temporary = destination.with_suffix(destination.suffix + f".{uuid4().hex}.upload")
digest = hashlib.sha256()
size = 0
try:
with temporary.open("xb") as stream:
@@ -37,10 +41,15 @@ async def upload_attachment(request: Request, filename: str = Query(min_length=1
size += len(chunk)
if size > MAX_UPLOAD_BYTES:
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.")
digest.update(chunk)
stream.write(chunk)
if not size:
raise ApiError(422, "EMPTY_ATTACHMENT", "Attachment is empty.")
temporary.replace(destination)
if destination.exists():
if hashlib.sha256(destination.read_bytes()).digest() != digest.digest():
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
else:
temporary.replace(destination)
finally:
temporary.unlink(missing_ok=True)
return {"attachment_id": attachment_id, "filename": Path(filename).name, "size": size}
+57 -2
View File
@@ -1,12 +1,67 @@
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.contracts import ProviderCreateRequest, ProviderConfig, ModelRequest, Message, MessageRole
from app.providers.factory import ProviderFactory
from app.request_overrides import apply_overrides
from app.request_overrides import RequestOverride, apply_overrides
router = APIRouter(prefix="/api/providers", tags=["Providers"])
class RulesTransfer(BaseModel):
version: int = Field(default=1, ge=1, le=1)
request_overrides: list[RequestOverride] = Field(max_length=100)
@router.post("/request-rules/validate")
async def validate_rules(request: RulesTransfer):
return request
class ProbeRequest(BaseModel):
provider: ProviderCreateRequest
stream: bool = True
@router.post("/request-probe")
async def probe(request: ProbeRequest):
"""Explicit user-triggered inference; no vault context, tools or media uploads."""
import asyncio
from contextlib import aclosing
from app.container import container
from app.errors import ApiError
from app.providers.base import ProviderError
from app.providers.factory import UnsupportedProviderError
config = ProviderConfig(provider_id="request-probe", **request.provider.model_dump())
if not config.default_model:
raise ApiError(422, "MODEL_REQUIRED", "请填写要验证的模型 ID。")
try:
adapter = container.provider_factory.build(config)
model_request = ModelRequest(provider_id=config.provider_id, model=config.default_model,
messages=[Message(role=MessageRole.user, content="Reply with OK.")], max_tokens=32)
received = False
async with asyncio.timeout(45):
if request.stream:
async with aclosing(adapter.stream(model_request)) as events:
async for event in events:
if event.event.value in {"TextDelta", "ThinkingDelta"}:
received = received or bool(str(event.data.get("text") or "").strip())
if event.event.value == "Error":
raise ProviderError("PROVIDER_PROBE_FAILED", "模型返回了错误事件。")
else:
response = await adapter.complete(model_request)
received = bool(response.text and response.text.strip())
if not received:
raise ApiError(422, "PROVIDER_EMPTY_RESPONSE", "请求未返回有效文本,不能标记验证通过。")
except ProviderError as exc:
raise ApiError(502, exc.code, "推理验证失败,请检查模型、凭据和自定义参数。") from exc
except TimeoutError as exc:
raise ApiError(504, "PROVIDER_TIMEOUT", "推理验证超时。") from exc
except UnsupportedProviderError as exc:
raise ApiError(422, "PROVIDER_TYPE_UNSUPPORTED", "该协议不支持推理验证。") from exc
return {"success": True, "stream": request.stream, "model": config.default_model,
"message": "当前请求配置已通过实际推理验证。"}
class PreviewRequest(BaseModel):
provider: ProviderCreateRequest
stream: bool = True
+17
View File
@@ -6,6 +6,8 @@ available only for explicitly injected tests and protocol fixtures.
from __future__ import annotations
import hashlib
import asyncio
import time
import json
import math
from dataclasses import dataclass, field, replace
@@ -179,6 +181,7 @@ class ModelRoutingService:
payload = apply_overrides(kwargs.get(field, {}), provider.request_overrides, capability)
kwargs[field] = payload if field == "json" else {key: json.dumps(value) if isinstance(value, (dict, list, bool)) or value is None else value for key, value in payload.items()}
attempt = UsageAttempt(binding.provider_id, binding.model, provider.provider_type.value, capability)
started = time.monotonic()
try:
async with httpx.AsyncClient(timeout=30, transport=self.transport) as client:
async with client.stream("POST", url, headers=headers, **kwargs) as response:
@@ -202,6 +205,11 @@ class ModelRoutingService:
raise invalid_response() from exc
finally:
attempt.persist()
from app.services.model_diagnostics import record
task = asyncio.current_task()
status = "completed" if attempt.completed else ("cancelled" if task and task.cancelling() else "failed")
record(model=binding.model, operation=capability, source="api", status=status,
attempt_id=attempt.attempt_id, request_id=attempt.request_id, elapsed_seconds=time.monotonic() - started)
if not isinstance(data, dict) or data.get("error"):
raise invalid_response()
return data, url
@@ -255,6 +263,9 @@ class ModelRoutingService:
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
except ProviderError as exc:
reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
from app.local_models.runtime import LocalEmbedding
local_embedding = self.local_embedding.snapshot() if isinstance(self.local_embedding, LocalEmbedding) else self.local_embedding
try:
@@ -314,6 +325,9 @@ class ModelRoutingService:
return RoutedTranscript(text=text, source="api", segments=segments)
except ProviderError as exc:
reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
try:
text = await self.local_speech.transcribe(source, language)
if isinstance(text, RoutedTranscript):
@@ -346,6 +360,9 @@ class ModelRoutingService:
return SpeakerMatchResult(score=score, source="api")
except ProviderError as exc:
reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
try:
score = await self.local_speech.match(source, reference)
if not finite_number(score) or not 0 <= score <= 1:
+31 -10
View File
@@ -19,8 +19,10 @@ async def create_transcript_note(job_id, options):
job = require_job(job_id)
if job.status != "completed":
raise ApiError(409, "TRANSCRIPT_NOT_READY", "Only completed transcripts can become notes.")
options_hash = hashlib.sha256(options.model_dump_json().encode()).hexdigest()
options_hash = hashlib.sha256(options.model_copy(update={"update_existing": False}).model_dump_json(exclude={"update_existing"}).encode()).hexdigest()
with closing(connect()) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS media_note_baselines (note_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL)")
previous = conn.execute("SELECT m.note_id,b.content_hash FROM media_notes m LEFT JOIN media_note_baselines b ON b.note_id=m.note_id WHERE m.job_id=? AND m.options_hash=? ORDER BY m.revision DESC LIMIT 1", (job_id, options_hash)).fetchone()
row = conn.execute("SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?",
(job_id, job.revision, options_hash)).fetchone()
if row:
@@ -44,15 +46,34 @@ async def create_transcript_note(job_id, options):
if job.local_only:
# Persist the indexing policy in the Vault, including later rebuilds.
lines = ["---", "embedding_local_only: true", "---", "", *lines]
try:
note = await note_service.create_note(title=title, markdown="\n".join(lines), folder=options.folder, tags=["转写"])
except ApiError as exc:
if exc.code != "RESOURCE_CONFLICT" or "note_id" not in exc.details:
raise
# Recover a crash between successful note creation and linking the job.
note = await note_service.get_note(exc.details["note_id"])
if note is None or marker not in note.markdown:
raise
markdown = "\n".join(lines)
if options.update_existing:
if previous is None or previous[1] is None:
raise ApiError(409, "NOTE_UPDATE_BASELINE_MISSING", "没有可安全更新的导出记录,请先创建新笔记。")
current = await note_service.get_note(previous[0])
if current is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "已导出笔记不存在。")
# Recover a successful update if linking failed after the Vault write.
if current.markdown == markdown:
note = current
else:
note = await note_service.update_note(previous[0], markdown=markdown, expected_content_hash=previous[1])
else:
note = await _create_note(title, markdown, options, marker)
with closing(connect()) as conn, transaction(conn):
conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)", (job_id, job.revision, options_hash, note.note_id))
conn.execute("INSERT OR REPLACE INTO media_note_baselines VALUES (?,?)", (note.note_id, hashlib.sha256(markdown.encode()).hexdigest()))
return note
async def _create_note(title, markdown, options, marker):
try:
note = await note_service.create_note(title=title, markdown=markdown, folder=options.folder, tags=["转写"])
except ApiError as exc:
if exc.code != "RESOURCE_CONFLICT" or "note_id" not in exc.details:
raise
# Recover a crash between successful note creation and linking the job.
note = await note_service.get_note(exc.details["note_id"])
if note is None or marker not in note.markdown:
raise
return note
+37
View File
@@ -0,0 +1,37 @@
"""Bounded, durable diagnostics. No payloads, paths, exception text or credentials."""
import json
import logging
import math
from contextlib import closing
from datetime import datetime, timezone
from app.database.db import connect, transaction
TEXT = {"model", "revision", "operation", "source", "requested_device", "actual_device",
"attempted_device", "fallback_reason", "error_code", "status", "request_id", "attempt_id"}
NUMBERS = {"load_seconds", "inference_seconds", "elapsed_seconds", "peak_memory_bytes", "queue_seconds"}
def connection():
conn = connect()
conn.execute("CREATE TABLE IF NOT EXISTS model_diagnostics (id INTEGER PRIMARY KEY AUTOINCREMENT, record_json TEXT NOT NULL)")
return conn
def record(**values):
safe = {key: value[:240] for key, value in values.items() if key in TEXT and isinstance(value, str)}
safe.update({key: value for key, value in values.items()
if key in NUMBERS and type(value) in (float, int) and math.isfinite(value) and value >= 0})
safe["timestamp"] = datetime.now(timezone.utc).isoformat()
try:
with closing(connection()) as conn, transaction(conn):
conn.execute("INSERT INTO model_diagnostics(record_json) VALUES (?)", (json.dumps(safe),))
conn.execute("DELETE FROM model_diagnostics WHERE id NOT IN (SELECT id FROM model_diagnostics ORDER BY id DESC LIMIT 200)")
except Exception:
logging.getLogger(__name__).warning("Model diagnostic persistence failed")
return safe
def recent():
with closing(connection()) as conn:
return [json.loads(row[0]) for row in conn.execute("SELECT record_json FROM model_diagnostics ORDER BY id")]
+8 -2
View File
@@ -17,7 +17,7 @@ from app.contracts import Note, NoteBlock, NoteSummary
from app.database.db import connect, transaction
from app.errors import ApiError
from app.knowledge.parser import ParsedNote, parse_note
from app.local_models.runtime import LocalEmbedding
from app.local_models.runtime import LocalEmbedding, background_embeddings
from app.retrieval import routed_vectors
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
from app.services.coordination import serialized_vault_mutation
@@ -77,6 +77,7 @@ def _delete_markdown(rel_path: str) -> None:
PreparedIndex = tuple[list[list[float]], routed_vectors.RemoteEmbeddings | None]
@background_embeddings
async def prepare_note_index(parsed: ParsedNote, *, strict=False) -> PreparedIndex:
"""Compute vectors before opening a write transaction (including API I/O)."""
texts = [block.content for block in parsed.blocks]
@@ -180,13 +181,18 @@ async def get_note(note_id: str) -> Note | None:
@serialized_vault_mutation
async def update_note(
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None, expected_content_hash: str | None = None
) -> Note:
record = repository.get_note_record(note_id)
if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
old_md = _read_markdown(record.file_path)
if expected_content_hash is not None:
import hashlib
if hashlib.sha256(old_md.encode()).hexdigest() != expected_content_hash:
raise ApiError(409, "NOTE_CONTENT_CONFLICT", "笔记已被编辑,请保留现有内容或导出为新笔记。")
new_md = old_md if markdown is None else markdown
# PATCH 语义:tags=None 保持原标签;[] 清空;非空列表替换(区别于 create 的 frontmatter 推导)
effective_tags = record.tags if tags is None else tags
@@ -134,6 +134,10 @@ async def _execute(job_id, request, routing=None):
from app.contracts import TranscriptSegment
token = runtime_context.set(RuntimeConfig.model_validate(job.model_snapshot.get("local_runtime", {})))
def progress(message):
if message.get("reset"):
job.segments = []; job.progress = 0
save(job, "AttemptRestarted")
return
job.progress = max(0.0, min(0.99, message["progress"]))
job.segments.append(TranscriptSegment.model_validate(message["segment"]))
save(job, "SegmentReady")
+14 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
import math
from contextlib import closing
from contextvars import ContextVar
from datetime import datetime, timezone
@@ -53,6 +54,7 @@ class UsageAttempt:
self.capability, self.source = capability, source
self.started_at = datetime.now(timezone.utc).isoformat()
self.raw = {}
self.audio_seconds = None
self.completed = False
context = usage_context.get() or {}
self.request_id = context.get("request_id") or uuid4().hex
@@ -61,6 +63,9 @@ class UsageAttempt:
def observe(self, data):
if not isinstance(data, dict):
return
duration = data.get("audio_seconds", data.get("duration"))
if self.capability in {"transcription", "speaker_matching"} and type(duration) in (int, float) and math.isfinite(duration) and 0 <= duration <= 7200:
self.audio_seconds = max(self.audio_seconds or 0, duration)
values = [data.get("usage"), (data.get("message") or {}).get("usage") if isinstance(data.get("message"), dict) else None,
(data.get("response") or {}).get("usage") if isinstance(data.get("response"), dict) else None]
if self.protocol == "ollama":
@@ -87,7 +92,7 @@ class UsageAttempt:
miss = inputs - hit
if hit is not None and inputs is not None and hit > inputs:
hit, miss = None, None
return dict(input_tokens=inputs, output_tokens=outputs,
return dict(audio_seconds=self.audio_seconds, input_tokens=inputs, output_tokens=outputs,
total_tokens=inputs + outputs if inputs is not None and outputs is not None else first("total_tokens"),
cache_hit_tokens=hit, cache_miss_tokens=miss, cache_write_tokens=write,
reasoning_tokens=first("output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"))
@@ -103,7 +108,7 @@ class UsageAttempt:
def aggregate(start, end, provider_id=None, model=None, source=None):
query = "SELECT counters_json,completed FROM model_usage WHERE started_at>=? AND started_at<?"
query = "SELECT counters_json,completed,capability FROM model_usage WHERE started_at>=? AND started_at<?"
args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()]
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
if value:
@@ -115,8 +120,14 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
totals = {key: None for key in METRICS}
coverage = {key: 0 for key in METRICS}
hits, eligible_input, cache_requests = 0, 0, 0
audio_requests, audio_covered, audio_seconds = 0, 0, None
for row in rows:
if row[2] in {"transcription", "speaker_matching"}:
audio_requests += 1
counts = json.loads(row[0])
if counts.get("audio_seconds") is not None:
audio_covered += 1
audio_seconds = (audio_seconds or 0) + counts["audio_seconds"]
for key in METRICS:
if counts.get(key) is not None:
totals[key] = (totals[key] or 0) + counts[key]
@@ -125,7 +136,7 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
hits += counts["cache_hit_tokens"]
eligible_input += counts["input_tokens"] if counts.get("input_tokens") is not None else counts["cache_hit_tokens"] + counts["cache_miss_tokens"]
cache_requests += 1
return {"totals": totals, "coverage": coverage, "request_count": len(rows),
return {"audio_request_count": audio_requests, "audio_seconds": audio_seconds, "audio_covered_requests": audio_covered, "totals": totals, "coverage": coverage, "request_count": len(rows),
"complete_requests": sum(row[1] for row in rows), "cache_covered_requests": cache_requests,
"cache_hit_rate": hits / eligible_input if eligible_input else None,
"options": [dict(row) for row in options], "start": start, "end": end,
+6 -3
View File
@@ -1,9 +1,10 @@
param(
[ValidateSet('cpu', 'cuda')][string]$Device = 'cpu'
[ValidateSet('cpu', 'cuda')][string]$Device = 'cpu',
[string]$RuntimeDirectory = ''
)
$ErrorActionPreference = 'Stop'
$backendRoot = Split-Path $PSScriptRoot -Parent
$runtimeRoot = Join-Path $backendRoot '.venv-models'
$runtimeRoot = if ($RuntimeDirectory) { [IO.Path]::GetFullPath($RuntimeDirectory) } else { Join-Path $backendRoot '.venv-models' }
$runtimePython = Join-Path $runtimeRoot 'Scripts/python.exe'
if (!(Test-Path -LiteralPath $runtimePython)) {
& uv venv --python 3.12 $runtimeRoot
@@ -11,7 +12,9 @@ if (!(Test-Path -LiteralPath $runtimePython)) {
}
# CPU is the default. CUDA wheels include the runtime, not the NVIDIA driver.
$torchIndex = if ($Device -eq 'cuda') { 'https://download.pytorch.org/whl/cu128' } else { 'https://download.pytorch.org/whl/cpu' }
& uv pip install --python $runtimePython --index-url $torchIndex 'torch==2.9.1' 'torchaudio==2.9.1'
$wheelVariant = if ($Device -eq 'cuda') { 'cu128' } else { 'cpu' }
# Pin the local version too: ==2.9.1 alone also accepts an already-installed CPU wheel.
& uv pip install --python $runtimePython --index-url $torchIndex "torch==2.9.1+$wheelVariant" "torchaudio==2.9.1+$wheelVariant"
if ($LASTEXITCODE -ne 0) { throw 'PyTorch 安装失败' }
& uv pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt')
if ($LASTEXITCODE -ne 0) { throw '模型依赖安装失败' }
@@ -0,0 +1,221 @@
"""Finalization regressions: device recovery, durable facts and guarded writes."""
import asyncio
import json
import sys
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.errors import ApiError
from app.providers.base import ProviderError
@pytest.mark.parametrize('code,retries', [('LOCAL_CUDA_OOM', True), ('LOCAL_CUDA_INIT_FAILED', True),
('LOCAL_INFERENCE_FAILED', False), ('LOCAL_RUNTIME_DEPENDENCY_MISSING', False)])
def test_cuda_retries_only_device_failures_in_reaped_process(monkeypatch, code, retries):
import app.local_models.runtime as module
from app.services import model_diagnostics
from app.services.usage_service import connection
monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda'))
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable))
events = []
class Process:
def __init__(self):
from types import SimpleNamespace
self.stdin = SimpleNamespace(write=self.write, drain=self.drain, close=lambda: None)
self.stdout = asyncio.StreamReader()
self.returncode = None
self.device = None
def write(self, raw):
self.device = json.loads(raw)['config']['device']
events.append('start-' + self.device)
result = {'error_code': code} if self.device == 'cuda' else {'result': [[1, 0]], 'usage': {'input_tokens': 2}, 'diagnostics': {'actual_device': 'cpu'}}
self.stdout.feed_data((json.dumps(result) + '\n').encode())
self.stdout.feed_eof()
async def drain(self):
pass
async def close(self):
pass
async def wait(self):
self.returncode = 0
events.append('reaped-' + self.device)
def kill(self):
self.returncode = -9
async def spawn(*args, **kwargs):
if events:
assert events[-1] == 'reaped-cuda'
return Process()
monkeypatch.setattr(module.asyncio, 'create_subprocess_exec', spawn)
async def scenario():
runtime = module.Runtime()
if retries:
assert await runtime.infer('bekko', 'embedding', {'texts': ['private text']}) == [[1, 0]]
else:
with pytest.raises(ProviderError) as error:
await runtime.infer('bekko', 'embedding', {'texts': ['private text']})
assert error.value.code == code
assert not runtime.active and not runtime.waiters
asyncio.run(scenario())
assert events == (['start-cuda', 'reaped-cuda', 'start-cpu', 'reaped-cpu'] if retries else ['start-cuda', 'reaped-cuda'])
records = model_diagnostics.recent()
assert records[0]['error_code'] == code
assert 'private text' not in json.dumps(records)
if retries:
assert records[-1]['requested_device'] == 'cuda' and records[-1]['actual_device'] == 'cpu'
assert records[-1]['fallback_reason'] == code
assert records[0]['request_id'] == records[1]['request_id']
assert records[0]['attempt_id'] != records[1]['attempt_id']
with closing(connection()) as conn:
assert conn.execute('SELECT COUNT(*) FROM model_usage').fetchone()[0] == (2 if retries else 1)
def test_cpu_failure_does_not_loop_and_interactive_precedes_index(monkeypatch):
import app.local_models.runtime as module
async def scenario():
runtime = module.Runtime()
entered, release = asyncio.Event(), asyncio.Event()
order = []
async def execute(key, operation, payload, config, diagnostics):
order.append(payload['name'])
if payload['name'] == 'running':
entered.set()
await release.wait()
return {'result': []}
monkeypatch.setattr(runtime, '_execute', execute)
first = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'running'}))
await entered.wait()
background = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'index'}, priority=20))
query = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'query'}, priority=0))
await asyncio.sleep(0)
release.set()
await asyncio.gather(first, background, query)
assert order == ['running', 'query', 'index']
calls = []
async def failed(key, operation, payload, config, diagnostics):
calls.append(config.device)
raise ProviderError('LOCAL_CUDA_OOM', 'simulated')
monkeypatch.setattr(runtime, '_execute', failed)
monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda'))
with pytest.raises(ProviderError):
await runtime.infer('bekko', 'embedding', {})
assert calls == ['cuda', 'cpu'] and not runtime.active
asyncio.run(scenario())
def test_durable_diagnostics_are_bounded_and_disk_size_is_real():
from app.services import model_diagnostics
from app.local_models import manager
for index in range(205):
model_diagnostics.record(model='bekko', status='failed', error_code='TEST', payload='secret', elapsed_seconds=index)
records = model_diagnostics.recent()
assert len(records) == 200 and records[0]['elapsed_seconds'] == 5
assert 'secret' not in json.dumps(records)
path = manager.model_path('bekko')
path.mkdir(parents=True)
(path / 'weights.partial').write_bytes(b'1234567')
assert manager.disk_bytes('bekko') == 7
def test_upload_key_replay_and_content_conflict():
from app.main import app
with TestClient(app) as client:
headers = {'Idempotency-Key': 'stable-upload-123456'}
first = client.post('/api/media/attachments?filename=lecture.txt', content=b'original', headers=headers)
again = client.post('/api/media/attachments?filename=lecture.txt', content=b'original', headers=headers)
assert first.status_code == again.status_code == 201
assert first.json()['attachment_id'] == again.json()['attachment_id']
assert client.post('/api/media/attachments?filename=lecture.txt', content=b'changed', headers=headers).status_code == 409
assert client.get('/api/media/attachments/' + first.json()['attachment_id']).content == b'original'
def test_updated_transcript_note_keeps_identity_and_rejects_user_edits():
from app.contracts import TranscriptNoteRequest, TranscriptEditRequest, IndexRebuildRequest
from app.services import transcription_service as jobs, note_service, index_service
from app.services.media_notes import create_transcript_note
from app.services.attachment_service import attachment_path
path = attachment_path('lecture.txt')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('original', encoding='utf-8')
async def scenario():
job = await jobs.create_transcription('lecture.txt', local_only=True)
options = TranscriptNoteRequest(title='Lecture')
first = await create_transcript_note(job.job_id, options)
await index_service.rebuild(IndexRebuildRequest())
jobs.edit(job.job_id, TranscriptEditRequest(revision=1, text='revised'))
update = options.model_copy(update={'update_existing': True})
second = await create_transcript_note(job.job_id, update)
assert first.note_id == second.note_id and 'revised' in second.markdown
assert 'embedding_local_only: true' in second.markdown
again = await create_transcript_note(job.job_id, update)
assert again.note_id == first.note_id
await note_service.update_note(first.note_id, markdown='User edits')
jobs.edit(job.job_id, TranscriptEditRequest(revision=2, text='third revision'))
with pytest.raises(ApiError) as error:
await create_transcript_note(job.job_id, update)
assert error.value.code == 'NOTE_CONTENT_CONFLICT'
assert (await note_service.get_note(first.note_id)).markdown == 'User edits'
copy = await create_transcript_note(job.job_id, options)
assert copy.note_id != first.note_id
asyncio.run(scenario())
def test_audio_usage_is_separate_and_unknown_durations_stay_null():
from app.services.usage_service import UsageAttempt, aggregate
now = datetime.now(timezone.utc)
first = UsageAttempt('local', 'asr', 'local', 'transcription', source='local')
first.observe({'audio_seconds': 2.25, 'usage': {}})
first.persist(); first.persist()
unknown = UsageAttempt('remote', 'asr', 'openai_compatible', 'transcription')
unknown.persist()
result = aggregate(now - timedelta(days=1), now + timedelta(days=1))
assert result['audio_request_count'] == 2 and result['audio_covered_requests'] == 1
assert result['audio_seconds'] == 2.25 and result['totals']['input_tokens'] is None
remote = aggregate(now - timedelta(days=1), now + timedelta(days=1), source='api')
assert remote['audio_seconds'] is None
def test_request_rule_import_rejects_credentials_and_host_fields():
from app.main import app
with TestClient(app) as client:
path = '/api/providers/request-rules/validate'
body = {'version': 1, 'request_overrides': [{'body': {'enable_thinking': False}}]}
assert client.post(path, json=body).status_code == 200
for bad in ({'api_key': 'secret'}, {'nested': {'authorization': 'secret'}}, {'stream': False}):
body['request_overrides'][0]['body'] = bad
assert client.post(path, json=body).status_code == 422
@pytest.mark.parametrize('stream', [False, True])
def test_inference_probe_uses_adapter_body_and_no_vault_context(monkeypatch, stream):
import httpx
from app.container import container
from app.main import app
original = container.provider_factory.build
requests = []
def respond(request):
data = json.loads(request.content)
requests.append(data)
assert data['enable_thinking'] is False and data['stream'] == stream
assert data['messages'] == [{'role': 'user', 'content': 'Reply with OK.'}]
assert not data.get('tools')
if stream:
return httpx.Response(200, text='data: {"choices":[{"delta":{"content":"OK"},"finish_reason":null}]}\n\ndata: [DONE]\n\n')
return httpx.Response(200, json={'choices': [{'message': {'role': 'assistant', 'content': 'OK'}, 'finish_reason': 'stop'}]})
def build(config):
adapter = original(config)
adapter.transport = httpx.MockTransport(respond)
return adapter
monkeypatch.setattr(container.provider_factory, 'build', build)
with TestClient(app) as client:
response = client.post('/api/providers/request-probe', json={'stream': stream, 'provider': {
'name': 'Probe', 'provider_type': 'openai_compatible', 'base_url': 'https://fixture.invalid/v1',
'default_model': 'test', 'request_overrides': [{'body': {'enable_thinking': False}}]}})
assert response.status_code == 200, response.text
assert len(requests) == 1