fix: complete 0.5.1 demo workflows
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
"""幂等转录本导出,无需覆盖已编辑的笔记。"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
from contextlib import closing
|
||||
import re
|
||||
from contextlib import closing, contextmanager
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
|
||||
from app import host_bridge
|
||||
from app.config import get_settings
|
||||
from app.contracts import Message, MessageRole, ModelRequest, TranscriptNoteRequest
|
||||
from app.agent.tools import ToolExecutionContext
|
||||
from app.contracts import Message, MessageRole, ModelRequest, ToolCall, TranscriptNoteRequest
|
||||
from app.database.db import connect, transaction
|
||||
from app.errors import ApiError
|
||||
from app.providers.base import ProviderError
|
||||
@@ -15,6 +19,19 @@ from app.services.transcription_service import require_job
|
||||
_locks = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _artifact_operation(label: str):
|
||||
"""Give each Host mutation in a multi-artifact request its own operation id."""
|
||||
parent = host_bridge.operation_id.get() or str(uuid4())
|
||||
token = host_bridge.operation_id.set(str(uuid5(
|
||||
NAMESPACE_URL, f"opennexus:media-artifact:{parent}:{label}",
|
||||
)))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
host_bridge.operation_id.reset(token)
|
||||
|
||||
|
||||
async def create_transcript_note(job_id, options):
|
||||
identity = (str(get_settings().db_path), job_id)
|
||||
lock = _locks.setdefault(identity, asyncio.Lock())
|
||||
@@ -29,7 +46,9 @@ async def create_transcript_note(job_id, options):
|
||||
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:
|
||||
return await note_service.get_note(row[0])
|
||||
existing = await note_service.get_note(row[0])
|
||||
if existing is not None:
|
||||
return existing
|
||||
marker = f"<!-- transcription:{job_id}:{job.revision}:{options_hash} -->"
|
||||
title = f"{options.title} · {job_id[-8:]}-r{job.revision}-{options_hash[:6]}"
|
||||
lines = [marker, f"# {options.title}", "", f"[源音频](/#/media?job={job_id})", ""]
|
||||
@@ -64,7 +83,9 @@ async def create_transcript_note(job_id, options):
|
||||
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))
|
||||
# 媒体任务历史是全局的,而桌面笔记属于当前 Vault。旧关联可能
|
||||
# 指向另一个 Vault 的 file_id;当前 Vault 恢复/创建后应接管关联。
|
||||
conn.execute("INSERT OR REPLACE 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
|
||||
|
||||
@@ -127,6 +148,66 @@ async def _complete(provider_id: str, model: str, system: str, content: str) ->
|
||||
return turn.text.strip().removeprefix("```markdown").removeprefix("```").removesuffix("```").strip()
|
||||
|
||||
|
||||
_COURSE_FENCE = re.compile(r"```([\w+-]+)[ \t]*\n(.*?)\n```", re.DOTALL)
|
||||
|
||||
|
||||
def _function_plot_arguments(source: str) -> dict:
|
||||
arguments: dict = {"expressions": []}
|
||||
for raw in source.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key, separator, value = line.partition(":")
|
||||
if separator and key.strip().lower() in {"domain", "range", "xlabel", "ylabel", "grid"}:
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
if key in {"domain", "range"}:
|
||||
pair = [float(item.strip()) for item in value.split(",", 1)]
|
||||
arguments["domain" if key == "domain" else "y_range"] = pair
|
||||
elif key == "grid":
|
||||
arguments["grid"] = value.lower() not in {"false", "0", "no"}
|
||||
else:
|
||||
arguments[key] = value
|
||||
continue
|
||||
expression = line[4:].strip() if line.lower().startswith("y = ") else line
|
||||
arguments["expressions"].append(expression)
|
||||
return arguments
|
||||
|
||||
|
||||
async def _compose_course_blocks(markdown: str, job_id: str) -> str:
|
||||
"""Recompose supported generated blocks through the same tools exposed to Agents."""
|
||||
from app.container import container
|
||||
|
||||
rendered: list[str] = []
|
||||
cursor = 0
|
||||
for index, match in enumerate(_COURSE_FENCE.finditer(markdown), 1):
|
||||
rendered.append(markdown[cursor:match.start()])
|
||||
language, source = match.group(1).lower(), match.group(2)
|
||||
if language in {"function-plot", "function_plot", "functionplot"}:
|
||||
name = "function_plot.compose"
|
||||
try:
|
||||
arguments = _function_plot_arguments(source)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ApiError(502, "KNOWLEDGE_NOTE_VISUAL_INVALID", "模型生成的函数图参数无效。") from exc
|
||||
else:
|
||||
name = "markdown.compose"
|
||||
arguments = {
|
||||
"format": "mermaid" if language == "mermaid" else "code-block",
|
||||
"text": source,
|
||||
"language": "" if language == "mermaid" else language,
|
||||
}
|
||||
result = await container.tools.execute(
|
||||
ToolCall(tool_call_id=f"course-block-{index}", name=name, arguments=arguments),
|
||||
ToolExecutionContext(run_id=f"media-note-{job_id}"),
|
||||
)
|
||||
if not result.success or not isinstance(result.output, dict) or not result.output.get("markdown"):
|
||||
raise ApiError(502, "KNOWLEDGE_NOTE_VISUAL_INVALID", result.error_message or "课程笔记图表校验失败。")
|
||||
rendered.append(result.output["markdown"])
|
||||
cursor = match.end()
|
||||
rendered.append(markdown[cursor:])
|
||||
return "".join(rendered)
|
||||
|
||||
|
||||
async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> str:
|
||||
transcript = _transcript_text(job)
|
||||
if not transcript.strip():
|
||||
@@ -134,7 +215,11 @@ async def _knowledge_markdown(job, provider_id: str, model: str, title: str) ->
|
||||
system = (
|
||||
"你是一名严谨的课程笔记整理助手。只能依据提供的转录内容整理,不补写未出现的事实。"
|
||||
"输出中文 Markdown 正文,使用清晰的二级、三级标题;包含课程主题、核心概念、关键论证或步骤、"
|
||||
"重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。不要使用代码围栏,也不要写处理说明。"
|
||||
"重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。"
|
||||
"只有在确实帮助理解时才补充可由转录推导出的材料:算法或程序课可给出带语言标记的简洁代码块;"
|
||||
"流程、状态或关系适合可视化时可给出 mermaid 代码块;课程涉及函数曲线且画图有助理解时可给出 "
|
||||
"function-plot 代码块(第一行可写 domain: -10, 10,表达式逐行写成 y = ...)。"
|
||||
"不要为了展示而强行添加图表,也不要输出上述三类以外的特殊围栏或处理说明。"
|
||||
)
|
||||
parts = _chunks(transcript)
|
||||
summaries: list[str] = []
|
||||
@@ -151,6 +236,7 @@ async def _knowledge_markdown(job, provider_id: str, model: str, title: str) ->
|
||||
"请将以下分段知识点合并成一篇完整课程笔记,消除重复并保持逻辑顺序:\n\n"
|
||||
+ "\n\n".join(f"### 分段 {index}\n{summary}" for index, summary in enumerate(summaries, 1)),
|
||||
)
|
||||
body = await _compose_course_blocks(body, job.job_id)
|
||||
return "\n".join([
|
||||
f"<!-- knowledge-note:{job.job_id}:{job.revision}:{provider_id}:{model} -->",
|
||||
f"# {title}", "", f"[查看完整转录稿](/#/media?job={job.job_id})", "", body,
|
||||
@@ -166,7 +252,11 @@ async def create_transcript_artifacts(job_id, options):
|
||||
include_timestamps=options.include_timestamps,
|
||||
include_speakers=options.include_speakers,
|
||||
)
|
||||
transcript_note = await create_transcript_note(job_id, transcript_options)
|
||||
# The Rust Host treats an operation id as one immutable mutation. Creating
|
||||
# two notes under the request operation id makes the second write look like
|
||||
# an idempotency payload conflict, so derive one child id per artifact.
|
||||
with _artifact_operation("transcript"):
|
||||
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")
|
||||
@@ -188,11 +278,12 @@ async def create_transcript_artifacts(job_id, options):
|
||||
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:]}"
|
||||
marker = markdown.splitlines()[0]
|
||||
knowledge_note = await _create_note(
|
||||
note_title, markdown, options, marker, tags=["课程笔记", "知识点"]
|
||||
)
|
||||
with _artifact_operation("knowledge"):
|
||||
knowledge_note = await _create_note(
|
||||
note_title, markdown, options, marker, tags=["课程笔记", "知识点"]
|
||||
)
|
||||
with closing(connect()) as conn, transaction(conn):
|
||||
conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)",
|
||||
conn.execute("INSERT OR REPLACE INTO media_notes VALUES (?,?,?,?)",
|
||||
(job_id, job.revision, signature, knowledge_note.note_id))
|
||||
return {"transcript": transcript_note, "knowledge_note": knowledge_note}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user