fix: complete 0.5.1 demo workflows
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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