release: OpenNexus 0.5.0

This commit is contained in:
2026-09-17 20:59:32 +08:00
parent e86809b238
commit f7d441bd92
34 changed files with 509 additions and 60 deletions
+133
View File
@@ -4,8 +4,11 @@ import hashlib
from contextlib import closing
from app.config import get_settings
from app.contracts import Message, MessageRole, ModelRequest, TranscriptNoteRequest
from app.database.db import connect, transaction
from app.errors import ApiError
from app.providers.base import ProviderError
from app.providers.registry import ProviderNotFoundError
from app.services import note_service
from app.services.transcription_service import require_job
@@ -66,6 +69,136 @@ async def create_transcript_note(job_id, options):
return note
def _transcript_text(job) -> str:
if job.segments:
rows = []
for segment in job.segments:
speaker = job.speaker_names.get(segment.speaker, segment.speaker) if segment.speaker else ""
stamp = f"{int(segment.start_time // 60):02}:{int(segment.start_time % 60):02}"
rows.append(f"[{stamp}] {speaker}{segment.text}" if speaker else f"[{stamp}] {segment.text}")
return "\n".join(rows)
return job.text or ""
def _chunks(text: str, limit: int = 12000) -> list[str]:
"""按段落切分长转录,避免在中间截断句子。"""
paragraphs = [part.strip() for part in text.splitlines() if part.strip()]
if not paragraphs:
return []
chunks: list[str] = []
current: list[str] = []
size = 0
for paragraph in paragraphs:
if current and size + len(paragraph) + 1 > limit:
chunks.append("\n".join(current))
current, size = [], 0
if len(paragraph) > limit:
if current:
chunks.append("\n".join(current))
current, size = [], 0
chunks.extend(paragraph[index:index + limit] for index in range(0, len(paragraph), limit))
continue
current.append(paragraph)
size += len(paragraph) + 1
if current:
chunks.append("\n".join(current))
return chunks
async def _complete(provider_id: str, model: str, system: str, content: str) -> str:
from app.container import container
try:
provider = container.providers.get(provider_id).adapter
except ProviderNotFoundError as exc:
raise ApiError(404, "PROVIDER_NOT_FOUND", "所选模型提供商不存在或未启用。",
{"provider_id": provider_id}) from exc
try:
turn = await provider.complete(ModelRequest(
provider_id=provider_id,
model=model,
system=system,
messages=[Message(role=MessageRole.user, content=content)],
temperature=0.2,
))
except ProviderError as exc:
raise ApiError(502, exc.code, exc.message, {"provider_id": provider_id}) from exc
if not turn.text or not turn.text.strip():
raise ApiError(502, "KNOWLEDGE_NOTE_EMPTY", "模型没有返回知识点笔记。")
return turn.text.strip().removeprefix("```markdown").removeprefix("```").removesuffix("```").strip()
async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> str:
transcript = _transcript_text(job)
if not transcript.strip():
raise ApiError(409, "TRANSCRIPT_EMPTY", "转录内容为空,无法提取知识点。")
system = (
"你是一名严谨的课程笔记整理助手。只能依据提供的转录内容整理,不补写未出现的事实。"
"输出中文 Markdown 正文,使用清晰的二级、三级标题;包含课程主题、核心概念、关键论证或步骤、"
"重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。不要使用代码围栏,也不要写处理说明。"
)
parts = _chunks(transcript)
summaries: list[str] = []
for index, part in enumerate(parts, 1):
summaries.append(await _complete(
provider_id, model, system,
f"这是课程转录的第 {index}/{len(parts)} 部分。请提取可供最终整合的知识点:\n\n{part}",
))
if len(summaries) == 1:
body = summaries[0]
else:
body = await _complete(
provider_id, model, system,
"请将以下分段知识点合并成一篇完整课程笔记,消除重复并保持逻辑顺序:\n\n"
+ "\n\n".join(f"### 分段 {index}\n{summary}" for index, summary in enumerate(summaries, 1)),
)
return "\n".join([
f"<!-- knowledge-note:{job.job_id}:{job.revision}:{provider_id}:{model} -->",
f"# {title}", "", f"[查看完整转录稿](/#/media?job={job.job_id})", "", body,
])
async def create_transcript_artifacts(job_id, options):
"""为完成的转录生成可回听的全文和模型整理的知识点笔记。"""
transcript_options = TranscriptNoteRequest(
title=options.title,
folder=options.folder,
update_existing=options.update_existing,
include_timestamps=options.include_timestamps,
include_speakers=options.include_speakers,
)
transcript_note = await create_transcript_note(job_id, transcript_options)
job = require_job(job_id)
knowledge_title = options.knowledge_title or f"{options.title} · 知识点"
identity = (str(get_settings().db_path), job_id, "knowledge")
lock = _locks.setdefault(identity, asyncio.Lock())
async with lock:
signature = "knowledge:" + hashlib.sha256(options.model_copy(update={
"update_existing": False,
"knowledge_title": knowledge_title,
}).model_dump_json(exclude={"update_existing"}).encode()).hexdigest()
with closing(connect()) as conn:
row = conn.execute(
"SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?",
(job_id, job.revision, signature),
).fetchone()
if row:
knowledge_note = await note_service.get_note(row[0])
if knowledge_note is not None:
return {"transcript": transcript_note, "knowledge_note": knowledge_note}
markdown = await _knowledge_markdown(job, options.provider_id, options.model, knowledge_title)
note_title = f"{knowledge_title} · {job_id[-8:]}-r{job.revision}-{signature[-6:]}"
knowledge_note = await note_service.create_note(
title=note_title,
markdown=markdown,
folder=options.folder,
tags=["课程笔记", "知识点"],
)
with closing(connect()) as conn, transaction(conn):
conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)",
(job_id, job.revision, signature, knowledge_note.note_id))
return {"transcript": transcript_note, "knowledge_note": knowledge_note}
async def _create_note(title, markdown, options, marker):
try:
note = await note_service.create_note(title=title, markdown=markdown, folder=options.folder, tags=["转写"])