fix: complete 0.5.1 demo workflows

This commit is contained in:
2026-09-18 09:11:53 +08:00
parent 1cee7b892f
commit 32bc2ddccb
13 changed files with 376 additions and 33 deletions
+84 -1
View File
@@ -248,7 +248,7 @@ class DeclarativeToolSpec(BaseModel):
description: str
parameters: dict[str, Any] = Field(default_factory=dict)
permission: str | None = None
handler: Literal["echo", "uppercase", "execution_policy"]
handler: Literal["echo", "uppercase", "execution_policy", "inspect_markdown"]
class DeclarativePluginHost:
@@ -270,6 +270,89 @@ class DeclarativePluginHost:
'requires_permission_policy':True,'completion_requires_verification':True}
if handler == "uppercase":
return {"text": str(values.get("text", "")).upper()}
if handler == "inspect_markdown":
text = str(values.get("text", ""))
if len(text) > 100_000:
raise ExtensionError(
"PLUGIN_ARGUMENT_INVALID", "Markdown text exceeds 100000 characters"
)
headings: list[dict[str, Any]] = []
tasks: list[dict[str, Any]] = []
issues: list[dict[str, Any]] = []
seen: dict[str, int] = {}
previous_level = 0
fence_marker: str | None = None
fence_line = 0
for line_number, line in enumerate(text.splitlines(), start=1):
stripped = line.lstrip()
marker = stripped[:3]
if marker in {"```", "~~~"}:
if fence_marker is None:
fence_marker, fence_line = marker, line_number
elif marker == fence_marker:
fence_marker = None
continue
if fence_marker is not None:
continue
task_match = re.match(r"^\s*[-*+]\s+\[([ xX])\]\s+(.*)$", line)
if task_match:
tasks.append(
{
"line": line_number,
"completed": task_match.group(1).lower() == "x",
"text": task_match.group(2).strip(),
}
)
heading_match = re.match(r"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$", line)
if not heading_match:
continue
level = len(heading_match.group(1))
title = heading_match.group(2).strip()
headings.append({"line": line_number, "level": level, "title": title})
if previous_level and level > previous_level + 1:
issues.append(
{
"line": line_number,
"type": "heading_level_jump",
"message": f"标题从 H{previous_level} 跳到 H{level}",
}
)
normalized = title.casefold()
if normalized in seen:
issues.append(
{
"line": line_number,
"type": "duplicate_heading",
"message": f"标题与第 {seen[normalized]} 行重复",
}
)
else:
seen[normalized] = line_number
previous_level = level
if fence_marker is not None:
issues.append(
{
"line": fence_line,
"type": "unclosed_code_fence",
"message": "代码围栏未闭合",
}
)
open_tasks = sum(not item["completed"] for item in tasks)
return {
"summary": {
"lines": len(text.splitlines()),
"characters": len(text),
"headings": len(headings),
"tasks": len(tasks),
"open_tasks": open_tasks,
"issues": len(issues),
},
"headings": headings[:200],
"tasks": tasks[:200],
"issues": issues[:200],
"truncated": any(len(items) > 200 for items in (headings, tasks, issues)),
"method": "line-based Markdown checks; line numbers refer to the supplied text",
}
raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}")
async def execute_command(
+101 -10
View File
@@ -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}