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
+1 -1
View File
@@ -33,7 +33,7 @@ def get_settings() -> Settings:
data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data")))
return Settings(
name=os.getenv("APP_NAME", "OpenNexus AI Core"),
version=os.getenv("APP_VERSION", "0.1.0"),
version=os.getenv("APP_VERSION", "0.5.0"),
environment=os.getenv("APP_ENVIRONMENT", "development"),
host=os.getenv("APP_HOST", "127.0.0.1"),
port=int(os.getenv("APP_PORT", "8000")),
+6
View File
@@ -1250,6 +1250,12 @@ class TranscriptNoteRequest(Contract):
include_speakers: bool = True
class TranscriptArtifactsRequest(TranscriptNoteRequest):
provider_id: str = Field(min_length=1, max_length=128)
model: str = Field(min_length=1, max_length=256)
knowledge_title: str | None = Field(default=None, min_length=1, max_length=200)
class IndexStatus(Contract):
running_jobs: int = 0
active_searches: int = 0
+5 -2
View File
@@ -5,11 +5,14 @@ import os
import shutil
import subprocess
from app.config import BACKEND_DIR
from app.config import BACKEND_DIR, get_settings
from app.errors import ApiError
from app.local_models.process import ThreadedProcess
ROOT = BACKEND_DIR / '.venv-models-cuda'
# 模型运行环境会在安装与升级时写入大量文件,必须位于应用数据目录,
# 不能写入受完整性清单保护的 Core 发布目录。
DEFAULT_ROOT = get_settings().data_dir / 'model-runtime'
ROOT = DEFAULT_ROOT
state = {'status': 'unchecked', 'stage': '', 'cuda_available': None}
task = None
+7 -1
View File
@@ -11,7 +11,7 @@ from uuid import uuid4
from fastapi import APIRouter, Header, Query, Request
from fastapi.responses import FileResponse, StreamingResponse
from app.contracts import TranscriptEditRequest, TranscriptNoteRequest, TranscriptionJob
from app.contracts import TranscriptArtifactsRequest, TranscriptEditRequest, TranscriptNoteRequest, TranscriptionJob
from app.database.db import connect, transaction
from app.errors import ApiError
from app.services import transcription_service as jobs
@@ -160,6 +160,12 @@ async def create_note(job_id: str, request: TranscriptNoteRequest):
return await create_transcript_note(job_id, request)
@router.post("/transcriptions/{job_id}/artifacts", status_code=201)
async def create_artifacts(job_id: str, request: TranscriptArtifactsRequest):
from app.services.media_notes import create_transcript_artifacts
return await create_transcript_artifacts(job_id, request)
@router.get("/attachments/{attachment_id}/cleanup-impact")
async def cleanup_impact(attachment_id: str):
attachment_path(attachment_id)
+1 -1
View File
@@ -41,7 +41,7 @@ async def execute(call, request):
run = await container.agent.create_run(AgentRunCreateRequest(
input=task, provider_id=request.provider_id, model=request.model,
skill_id=skill_id,
allowed_tools=ALLOWED_TOOLS, max_steps=10, token_budget=16000,
allowed_tools=ALLOWED_TOOLS, max_steps=10, token_budget=None,
allow_network=False, metadata={'source': 'chat', 'conversation_id': request.conversation_id},
))
elif call.name == 'agent.status':
+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=["转写"])
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "notes-agent-backend"
version = "0.1.0"
version = "0.5.0"
description = "Notes Agent 的 FastAPI 基础壳子"
readme = "README.md"
requires-python = ">=3.11"
+12
View File
@@ -96,6 +96,18 @@ def test_terminology_export_and_privacy_cleanup():
first = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
again = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
assert first['note_id'] == again['note_id']
artifacts = client.post(f'/api/media/transcriptions/{job_id}/artifacts', json={
'title': '课程', 'knowledge_title': '课程知识点',
'provider_id': 'mock', 'model': 'mock-1',
})
assert artifacts.status_code == 201
assert artifacts.json()['transcript']['note_id'] == first['note_id']
assert artifacts.json()['knowledge_note']['note_id'] != first['note_id']
repeated = client.post(f'/api/media/transcriptions/{job_id}/artifacts', json={
'title': '课程', 'knowledge_title': '课程知识点',
'provider_id': 'mock', 'model': 'mock-1',
})
assert repeated.json()['knowledge_note']['note_id'] == artifacts.json()['knowledge_note']['note_id']
response = client.delete('/api/media/attachments/lecture.txt')
assert first['note_id'] in response.json()['retained_note_ids']
cleaned = client.get(f'/api/media/transcriptions/{job_id}').json()
-2
View File
@@ -13,8 +13,6 @@ def isolate(monkeypatch, tmp_path):
monkeypatch.setattr(components, 'ROOT', tmp_path / 'cuda')
monkeypatch.setattr(components, 'state', {'status': 'unchecked', 'stage': '', 'cuda_available': None})
monkeypatch.setattr(components, 'task', None)
def test_status_checks_without_installing_and_detects_existing_cuda(monkeypatch):
python = components.ROOT / 'Scripts/python.exe'
python.parent.mkdir(parents=True)
+1 -1
View File
@@ -1108,7 +1108,7 @@ wheels = [
[[package]]
name = "notes-agent-backend"
version = "0.1.0"
version = "0.5.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },