feat(multimodal): 完成阶段F运行管理与收尾验收
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user