diff --git a/.gitignore b/.gitignore index 84abf25..abd3743 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ backend/.env # 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交) backend/data/*.db* backend/data/credentials/ +# 运行期导出的 HTML/PDF/DOCX 产物(不提交) +backend/data/exports/ # 阶段验收笔记(验收用,不提交) backend/data/vault/验收/ # 本机 MCP 配置、授权状态及服务器工作目录不得提交。 diff --git a/README.md b/README.md index 60ca8e2..2e38266 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。 -> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1(stdio、Streamable HTTP 与旧 SSE 兼容)。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。 +> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1(stdio、Streamable HTTP 与旧 SSE 兼容)、RAG Benchmark 与 Markdown → HTML 导出。真实音频、Provider 协议增强、Agent Benchmark、PDF/DOCX 导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。 ## 当前目录 @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 218 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 467 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/backend/app/config.py b/backend/app/config.py index 00334c9..3d3d753 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,6 +25,7 @@ class Settings: vault_path: Path attachments_path: Path benchmark_datasets_path: Path + exports_path: Path @lru_cache @@ -45,4 +46,5 @@ def get_settings() -> Settings: benchmark_datasets_path=Path( os.getenv("APP_BENCHMARK_DATASETS_PATH", str(data_dir / "benchmarks")) ), + exports_path=Path(os.getenv("APP_EXPORTS_PATH", str(data_dir / "exports"))), ) diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 2cebba3..9440c5f 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -2,7 +2,14 @@ from datetime import datetime from enum import Enum from typing import Annotated, Any, Literal -from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SecretStr, + field_validator, + model_validator, +) class Contract(BaseModel): @@ -1162,3 +1169,88 @@ class BenchmarkReport(Contract): cases: list[RAGCaseResult] = Field(default_factory=list) error: str | None = None error_code: str | None = None + + +# Export(多格式文档导出) +class ExportStatus(str, Enum): + queued = "queued" + running = "running" + completed = "completed" + failed = "failed" + cancelled = "cancelled" + + +class ExportFormat(str, Enum): + html = "html" + pdf = "pdf" + docx = "docx" + + +class ExportSourceType(str, Enum): + note = "note" + markdown = "markdown" + + +class ExportSource(Contract): + """导出源:note 引用已索引笔记,markdown 用于未保存预览(不持久化)。""" + + type: ExportSourceType + note_id: str | None = None + markdown: str | None = None + + @model_validator(mode="after") + def _validate_source(self) -> "ExportSource": + if self.type == ExportSourceType.note and not self.note_id: + raise ValueError("note source requires note_id") + if self.type == ExportSourceType.markdown and not self.markdown: + raise ValueError("markdown source requires markdown") + return self + + +class ExportOptions(Contract): + theme_id: str = "light" + include_title: bool = True + include_metadata: bool = False + page_size: str = "A4" + code_theme: str = "github-light" + + +class ExportRequest(Contract): + source: ExportSource + format: ExportFormat + options: ExportOptions = Field(default_factory=ExportOptions) + + +class ExportProgress(Contract): + phase: str + current: int + total: int + percent: float | None = None + message: str | None = None + + +class ExportFile(Contract): + file_name: str + mime_type: str + size: int + sha256: str + expires_at: datetime + + +class ExportJob(Contract): + job_id: str + status: ExportStatus + format: ExportFormat + progress: ExportProgress | None = None + file: ExportFile | None = None + warnings: list[str] = Field(default_factory=list) + error: str | None = None + error_code: str | None = None + created_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None + + +class ExportJobListResponse(Contract): + items: list[ExportJob] = Field(default_factory=list) + page: PageMeta = Field(default_factory=PageMeta) diff --git a/backend/app/export/__init__.py b/backend/app/export/__init__.py new file mode 100644 index 0000000..d5a6faa --- /dev/null +++ b/backend/app/export/__init__.py @@ -0,0 +1,8 @@ +"""Export Service:多格式文档导出(首批 HTML)。 + +模块划分: +- document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult +- markdown.py mistune → Document AST 解析 +- exporters/html.py HtmlExporter(Document AST → HTML5) +- service.py 导出任务注册表、后台执行、取消与文件生命周期 +""" diff --git a/backend/app/export/document.py b/backend/app/export/document.py new file mode 100644 index 0000000..e09f5cb --- /dev/null +++ b/backend/app/export/document.py @@ -0,0 +1,46 @@ +"""Document AST:导出器的内部中间表示(Internal Protocol,不放入 contracts.py)。 + +契约 §10.3 规定节点用稳定判别字段 node_id / type / attributes / children / text, +类型专有信息统一放 attributes(如 heading 的 level、link 的 href、image 的 src)。 +导出器据此递归渲染,对无法表示的节点记 warning,不静默丢弃。 +""" + +from __future__ import annotations + +from typing import Any, Protocol + +from pydantic import BaseModel, ConfigDict, Field + +from app.contracts import ExportOptions + + +class DocumentNode(BaseModel): + """递归文档节点;type 取契约 §10.3 首批 node type 之一。""" + + model_config = ConfigDict(extra="forbid") + + type: str + node_id: str + attributes: dict[str, Any] = Field(default_factory=dict) + children: list["DocumentNode"] = Field(default_factory=list) + text: str = "" + + +class Document(DocumentNode): + """根节点,type 固定为 document。""" + + type: str = "document" + + +class DocumentExporter(Protocol): + """导出器协议(契约 §10.3):把 Document AST 渲染为指定格式的产物。""" + + async def export(self, document: Document, options: ExportOptions) -> "ExportResult": ... + + +class ExportResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + content: bytes + mime_type: str + warnings: list[str] = Field(default_factory=list) diff --git a/backend/app/export/exporters/__init__.py b/backend/app/export/exporters/__init__.py new file mode 100644 index 0000000..018a8ab --- /dev/null +++ b/backend/app/export/exporters/__init__.py @@ -0,0 +1 @@ +"""Export 渲染器:Document AST → 具体格式产物。""" diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py new file mode 100644 index 0000000..3e3c74a --- /dev/null +++ b/backend/app/export/exporters/html.py @@ -0,0 +1,269 @@ +"""HtmlExporter:Document AST → 完整 HTML5 文档(内嵌基础 CSS)。 + +mermaid 等无法静态表达的节点渲染为占位代码块并记 warning,不静默丢失;function_plot +解析为静态 SVG 内嵌(解析失败回退占位并转诊断);严重内容缺失由 service 层以 +EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。 +""" + +from __future__ import annotations + +import html +from datetime import datetime +from urllib.parse import urlparse + +from app.contracts import ExportOptions +from app.export.document import Document, DocumentNode, ExportResult +from app.plot.parser import parse_source +from app.plot.render import render_svg + +_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块" +_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留" + +# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级 +_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"}) + + +def _safe_url(url: str) -> str | None: + """校验 URL 协议;安全返回原串,不安全返回 None。""" + url = url.strip() + if not url: + return None + scheme = urlparse(url).scheme.lower() + if scheme and scheme not in _ALLOWED_URL_SCHEMES: + return None + return url + +_BASE_CSS = """ +body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; } +article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: #fff; } +article.theme-dark { background: #0d1117; color: #c9d1d9; } +h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; } +h1.title { margin-top: 0; } +p { margin: 0.6em 0; } +a { color: #0969da; } +code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: #f0f1f3; padding: 0.15em 0.35em; border-radius: 3px; } +pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: auto; } +pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; } +pre code { background: none; padding: 0; } +pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; } +figure.function-plot { margin: 1em 0; text-align: center; } +figure.function-plot svg { max-width: 100%; height: auto; } +blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; } +img { max-width: 100%; } +table { border-collapse: collapse; margin: 0.8em 0; } +th, td { border: 1px solid #d0d7de; padding: 6px 12px; } +th { background: #f6f8fa; } +dl.metadata { font-size: 0.85em; color: #57606a; border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; padding: 0.6em 0; } +dl.metadata dt { display: inline; font-weight: 600; margin-right: 0.4em; } +dl.metadata dd { display: inline; margin: 0 1.2em 0 0; } +.math, .math-block { overflow-x: auto; padding: 0.4em 0; } +.task-list-item { list-style: none; } +.task-list-item input { margin-right: 0.4em; } +hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; } +""".strip() + + +class HtmlExporter: + """实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。""" + + def render(self, document: Document, options: ExportOptions) -> ExportResult: + """同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。""" + self._options = options + warnings: list[str] = [] + body = self._render_children(document.children, warnings) + content = self._assemble(document, options, body, warnings) + return ExportResult( + content=content.encode("utf-8"), mime_type="text/html", warnings=warnings + ) + + async def export(self, document: Document, options: ExportOptions) -> ExportResult: + """契约要求的 async 接口;渲染本身同步,直接转发到 render。""" + return self.render(document, options) + + def _assemble( + self, document: Document, options: ExportOptions, body: str, warnings: list[str] + ) -> str: + title = str(document.attributes.get("title") or "") + parts = [ + "", + '', + "", + '', + '', + ] + if title: + parts.append(f"{html.escape(title)}") + parts.append(f"") + parts.append("") + parts.append("") + parts.append(f'
') + if options.include_title and title: + parts.append(f'

{html.escape(title)}

') + if options.include_metadata: + metadata = document.attributes.get("metadata") + if metadata: + parts.append(self._render_metadata(metadata)) + parts.append(body) + parts.append("
") + parts.append("") + parts.append("") + return "\n".join(parts) + "\n" + + def _render_metadata(self, metadata: dict) -> str: + entries = ["'] + for key, value in metadata.items(): + entries.append(f"
{html.escape(str(key))}
") + entries.append(f"
{html.escape(self._fmt_meta_value(value))}
") + entries.append("") + return "".join(entries) + + @staticmethod + def _fmt_meta_value(value: object) -> str: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, list): + return ", ".join(str(item) for item in value) + return str(value) + + def _render_children(self, children: list[DocumentNode], warnings: list[str]) -> str: + return "".join(self._render_node(child, warnings) for child in children) + + def _render_node(self, node: DocumentNode, warnings: list[str]) -> str: + handler = getattr(self, f"_render_{node.type}", None) + if handler is not None: + return handler(node, warnings) + warnings.append(f"无法表示的节点类型已跳过:{node.type}") + return "" + + # --- 块级 --- + def _render_heading(self, node: DocumentNode, warnings: list[str]) -> str: + level = max(1, min(6, int(node.attributes.get("level", 1)))) + return f"{self._render_children(node.children, warnings)}" + + def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str: + return f"

{self._render_children(node.children, warnings)}

" + + def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str: + return f"
{self._render_children(node.children, warnings)}
" + + def _render_list(self, node: DocumentNode, warnings: list[str]) -> str: + tag = "ol" if node.attributes.get("ordered") else "ul" + return f"<{tag}>{self._render_children(node.children, warnings)}" + + def _render_list_item(self, node: DocumentNode, warnings: list[str]) -> str: + inner = self._render_children(node.children, warnings) + if node.attributes.get("task"): + checked = " checked" if node.attributes.get("checked") else "" + return ( + '
  • ' + f'{inner}
  • ' + ) + return f"
  • {inner}
  • " + + def _render_table(self, node: DocumentNode, warnings: list[str]) -> str: + rows = node.children + head_rows = [r for r in rows if r.attributes.get("head")] + body_rows = [r for r in rows if not r.attributes.get("head")] + parts = [""] + if head_rows: + parts.append("") + parts.extend(self._render_node(r, warnings) for r in head_rows) + parts.append("") + if body_rows: + parts.append("") + parts.extend(self._render_node(r, warnings) for r in body_rows) + parts.append("") + parts.append("
    ") + return "".join(parts) + + def _render_table_row(self, node: DocumentNode, warnings: list[str]) -> str: + return f"{self._render_children(node.children, warnings)}" + + def _render_table_cell(self, node: DocumentNode, warnings: list[str]) -> str: + tag = "th" if node.attributes.get("head") else "td" + return f"<{tag}>{self._render_children(node.children, warnings)}" + + def _render_code_block(self, node: DocumentNode, warnings: list[str]) -> str: + lang = str(node.attributes.get("language") or "") + code = html.escape(node.text) + lang_cls = f' class="language-{html.escape(lang)}"' if lang else "" + theme = html.escape(self._options.code_theme) + return f'
    {code}
    ' + + def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str: + return "
    " + + def _render_mermaid(self, node: DocumentNode, warnings: list[str]) -> str: + warnings.append(_MERMAID_WARNING) + return f'
    {html.escape(node.text)}
    ' + + @staticmethod + def _format_plot_diagnostic(diag) -> str: + loc = f"(第 {diag.line} 行)" if diag.line else "" + return f"函数图像:{diag.message}{loc}" + + def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str: + # 解析 fenced 源码:有合法 plot 且无 error → 内嵌静态 SVG;否则回退占位并转诊断 + parsed = parse_source(node.text) + for diag in parsed.diagnostics: + warnings.append(self._format_plot_diagnostic(diag)) + if parsed.plot is None: + return f'
    {html.escape(node.text)}
    ' + rendered = render_svg(parsed.plot) + warnings.extend(rendered.warnings) + return f'
    {rendered.content}
    ' + + def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str: + return f'
    $${html.escape(node.text)}$$
    ' + + def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str: + # 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险 + warnings.append(_RAW_HTML_WARNING) + return f'
    {html.escape(node.text)}
    ' + + # --- 行内 --- + def _render_text(self, node: DocumentNode, warnings: list[str]) -> str: + return html.escape(node.text) + + def _render_emphasis(self, node: DocumentNode, warnings: list[str]) -> str: + return f"{self._render_children(node.children, warnings)}" + + def _render_strong(self, node: DocumentNode, warnings: list[str]) -> str: + return f"{self._render_children(node.children, warnings)}" + + def _render_link(self, node: DocumentNode, warnings: list[str]) -> str: + inner = self._render_children(node.children, warnings) + href = str(node.attributes.get("href") or "") + safe_href = _safe_url(href) + if safe_href is None: + # 危险协议(如 javascript:)降级为纯文本,不输出可点击链接 + warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}") + return inner + title = str(node.attributes.get("title") or "") + attrs = [f'href="{html.escape(safe_href)}"'] + if title: + attrs.append(f'title="{html.escape(title)}"') + return f"{inner}" + + def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str: + return f"{html.escape(node.text)}" + + def _render_image(self, node: DocumentNode, warnings: list[str]) -> str: + src = str(node.attributes.get("src") or "") + alt = str(node.attributes.get("alt") or "") + safe_src = _safe_url(src) + if safe_src is None: + # 危险协议(如 data:/javascript:)跳过图片,仅输出 alt 文本 + warnings.append(f"图片地址不安全,已跳过:{src!r}") + return html.escape(alt) if alt else "" + title = str(node.attributes.get("title") or "") + attrs = [f'src="{html.escape(safe_src)}"', f'alt="{html.escape(alt)}"'] + if title: + attrs.append(f'title="{html.escape(title)}"') + return f"" + + def _render_math_inline(self, node: DocumentNode, warnings: list[str]) -> str: + return f"\\({html.escape(node.text)}\\)" + + def _render_linebreak(self, node: DocumentNode, warnings: list[str]) -> str: + return "
    " diff --git a/backend/app/export/markdown.py b/backend/app/export/markdown.py new file mode 100644 index 0000000..bf9934d --- /dev/null +++ b/backend/app/export/markdown.py @@ -0,0 +1,229 @@ +"""Markdown → Document AST:用 mistune 的 ast renderer 产出通用 token,再映射为内部节点。 + +选用 mistune 内置 'ast' renderer 而非自写 BaseRenderer,是因为 mistune 的行内渲染按 +字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 children/attrs/raw 的 token +树,映射层只做 token → DocumentNode 的搬运,不掺入任何 HTML。 +""" + +from __future__ import annotations + +import mistune + +from app.export.document import Document, DocumentNode + +_PLUGINS = ["table", "math", "url", "task_lists"] + +# fenced code 语言分流:命中则转为专用节点,其余按普通代码块 +_MERMAID_LANG = "mermaid" +_FUNCTION_PLOT_LANGS = {"function-plot", "function_plot", "functionplot"} + + +def parse_document(markdown: str) -> Document: + """把 Markdown 文本解析为 Document AST 根节点。""" + renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS) + tokens = renderer(markdown) + mapper = _AstMapper() + return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens)) + + +class _AstMapper: + """token 树 → DocumentNode 树的映射器;node_id 按遍历顺序递增,无需跨请求稳定。""" + + def __init__(self) -> None: + self._seq = 0 + + def next_id(self) -> str: + self._seq += 1 + return f"node_{self._seq:03d}" + + def map_blocks(self, tokens: list[dict]) -> list[DocumentNode]: + nodes: list[DocumentNode] = [] + for token in tokens: + node = self.map_block(token) + if node is not None: + nodes.append(node) + return nodes + + def map_block(self, token: dict) -> DocumentNode | None: + kind = token["type"] + if kind == "heading": + return DocumentNode( + type="heading", + node_id=self.next_id(), + attributes={"level": token["attrs"]["level"]}, + children=self.map_inline(token.get("children", [])), + ) + if kind in ("paragraph", "block_text"): + # block_text 是列表项内的段落块,仍按 paragraph 表达,由 list_item 包裹 + return DocumentNode( + type="paragraph", + node_id=self.next_id(), + children=self.map_inline(token.get("children", [])), + ) + if kind == "list": + return DocumentNode( + type="list", + node_id=self.next_id(), + attributes={"ordered": bool(token.get("attrs", {}).get("ordered"))}, + children=[self.map_list_item(child) for child in token.get("children", [])], + ) + if kind == "block_code": + return self._map_code(token) + if kind == "block_quote": + return DocumentNode( + type="blockquote", + node_id=self.next_id(), + children=self.map_blocks(token.get("children", [])), + ) + if kind == "table": + return self._map_table(token) + if kind == "block_math": + return DocumentNode( + type="math_block", node_id=self.next_id(), text=token.get("raw", "") + ) + if kind == "thematic_break": + return DocumentNode(type="thematic_break", node_id=self.next_id()) + if kind == "blank_line": + return None + if kind == "block_html": + # 原始 HTML 块降级为纯文本节点,由 HtmlExporter 转义并记 warning,避免静默丢失正文 + return DocumentNode( + type="html_block", node_id=self.next_id(), text=token.get("raw", "") + ) + # 未知块级 token 保守保留原文;映射为带 text 子节点的 paragraph,避免被渲染层丢弃 + raw = token.get("raw", "") + if raw: + return DocumentNode( + type="paragraph", + node_id=self.next_id(), + children=[DocumentNode(type="text", node_id=self.next_id(), text=raw)], + ) + return None + + def map_list_item(self, token: dict) -> DocumentNode: + """列表项:block_text 展平为行内子节点,嵌套 list 保留为子节点。""" + attributes: dict = {} + if token["type"] == "task_list_item": + attributes = {"task": True, "checked": bool(token.get("attrs", {}).get("checked"))} + children: list[DocumentNode] = [] + for child in token.get("children", []): + if child["type"] == "block_text": + children.extend(self.map_inline(child.get("children", []))) + elif child["type"] == "list": + children.append(self.map_block(child)) + else: + node = self.map_block(child) + if node is not None: + children.append(node) + return DocumentNode( + type="list_item", node_id=self.next_id(), attributes=attributes, children=children + ) + + def map_inline(self, tokens: list[dict]) -> list[DocumentNode]: + nodes: list[DocumentNode] = [] + for token in tokens: + node = self.map_inline_token(token) + if node is not None: + nodes.append(node) + return nodes + + def map_inline_token(self, token: dict) -> DocumentNode | None: + kind = token["type"] + if kind == "text": + return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", "")) + if kind == "strong": + return DocumentNode( + type="strong", node_id=self.next_id(), + children=self.map_inline(token.get("children", [])), + ) + if kind == "emphasis": + return DocumentNode( + type="emphasis", node_id=self.next_id(), + children=self.map_inline(token.get("children", [])), + ) + if kind == "link": + attrs = token.get("attrs", {}) + attributes = {"href": attrs.get("url", "")} + if attrs.get("title"): + attributes["title"] = attrs["title"] + return DocumentNode( + type="link", node_id=self.next_id(), attributes=attributes, + children=self.map_inline(token.get("children", [])), + ) + if kind == "codespan": + return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", "")) + if kind == "image": + # mistune 图片 token:src 在 attrs.url,alt 来自 children 的文本,title 在 attrs.title + attrs = token.get("attrs", {}) + alt = "".join( + child.get("raw", "") + for child in token.get("children", []) + if child.get("type") == "text" + ) + attributes = {"src": attrs.get("url", "")} + if alt: + attributes["alt"] = alt + if attrs.get("title"): + attributes["title"] = attrs["title"] + return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes) + if kind == "inline_math": + return DocumentNode( + type="math_inline", node_id=self.next_id(), text=token.get("raw", "") + ) + if kind == "softbreak": + # HTML 中换行会折叠为空白,软换行按空格表达 + return DocumentNode(type="text", node_id=self.next_id(), text=" ") + if kind == "linebreak": + return DocumentNode(type="linebreak", node_id=self.next_id()) + # 未知行内 token 保守保留原文 + raw = token.get("raw", "") + if raw: + return DocumentNode(type="text", node_id=self.next_id(), text=raw) + return None + + def _map_code(self, token: dict) -> DocumentNode: + info = (token.get("attrs", {}).get("info") or "").strip() + lang = info.split()[0].lower() if info else "" + code = token.get("raw", "").rstrip("\n") + if lang == _MERMAID_LANG: + return DocumentNode(type="mermaid", node_id=self.next_id(), text=code) + if lang in _FUNCTION_PLOT_LANGS: + return DocumentNode(type="function_plot", node_id=self.next_id(), text=code) + attributes = {"language": lang} if lang else {} + return DocumentNode( + type="code_block", node_id=self.next_id(), attributes=attributes, text=code + ) + + def _map_table(self, token: dict) -> DocumentNode: + rows: list[DocumentNode] = [] + for child in token.get("children", []): + if child["type"] == "table_head": + rows.append(self._map_table_row(child, head=True)) + elif child["type"] == "table_body": + for row in child.get("children", []): + if row["type"] == "table_row": + rows.append(self._map_table_row(row, head=False)) + elif child["type"] == "table_row": + rows.append(self._map_table_row(child, head=False)) + return DocumentNode(type="table", node_id=self.next_id(), children=rows) + + def _map_table_row(self, token: dict, *, head: bool) -> DocumentNode: + cells: list[DocumentNode] = [] + for cell in token.get("children", []): + if cell["type"] != "table_cell": + continue + attrs = cell.get("attrs", {}) + cell_attributes = {"head": bool(attrs.get("head", head))} + if attrs.get("align"): + cell_attributes["align"] = attrs["align"] + cells.append( + DocumentNode( + type="table_cell", + node_id=self.next_id(), + attributes=cell_attributes, + children=self.map_inline(cell.get("children", [])), + ) + ) + return DocumentNode( + type="table_row", node_id=self.next_id(), attributes={"head": head}, children=cells + ) diff --git a/backend/app/export/service.py b/backend/app/export/service.py new file mode 100644 index 0000000..6730246 --- /dev/null +++ b/backend/app/export/service.py @@ -0,0 +1,308 @@ +"""Export 服务:任务注册表、后台渲染、取消与产物生命周期。 + +与 Benchmark 一致采用「创建即返回 queued、后台 Task 异步执行」的内存模型:任务与产物 +暂存内存与 exports 目录,不持久化到 SQLite。导出是单阶段渲染,无 SSE 事件流,取消主要 +在渲染前/后让出执行权的边界生效;产物带 24h 过期时间,过期后不可下载。 +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import re +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + +from app.config import get_settings +from app.contracts import ( + ExportFile, + ExportFormat, + ExportJob, + ExportOptions, + ExportProgress, + ExportRequest, + ExportSource, + ExportSourceType, + ExportStatus, +) +from app.errors import ApiError +from app.export.document import Document, ExportResult +from app.export.exporters.html import HtmlExporter +from app.export.markdown import parse_document +from app.services import note_service + +logger = logging.getLogger(__name__) + +_jobs: dict[str, ExportJob] = {} +_tasks: dict[str, asyncio.Task] = {} +_cancel_flags: dict[str, asyncio.Event] = {} +MAX_JOBS = 100 +# markdown 源大小上限,防止未保存预览塞爆内存/产物 +MAX_MARKDOWN_CHARS = 200_000 +# 产物有效期 +FILE_TTL = timedelta(hours=24) + +_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]') + + +class ExportCancelled(Exception): + """导出在渲染前被取消时抛出,用于标记 cancelled。""" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _safe_download_name(title: str) -> str: + """清洗标题得到安全的下载文件名;空标题回退到 export。""" + name = _INVALID_FILE_CHARS.sub("_", title).strip() or "export" + return name[:80] + + +def _export_path(job_id: str) -> Path: + return get_settings().exports_path / f"{job_id}.html" + + +def _delete_file(job_id: str) -> None: + """删除导出产物文件;文件不存在时忽略。""" + try: + _export_path(job_id).unlink(missing_ok=True) + except OSError: + logger.warning("Failed to delete export file: %s", job_id) + + +def cleanup_orphan_files() -> int: + """清理 exports 目录下无对应内存任务的孤立产物(服务重启后调用)。""" + exports_dir = get_settings().exports_path + if not exports_dir.is_dir(): + return 0 + removed = 0 + for path in exports_dir.glob("*.html"): + if path.stem not in _jobs: + try: + path.unlink() + removed += 1 + except OSError: + logger.warning("Failed to delete orphan export file: %s", path) + return removed + + +def _render_document(document: Document, options: ExportOptions) -> ExportResult: + """同步渲染辅助,供 asyncio.to_thread 调用;每次新建实例避免跨线程复用。""" + return HtmlExporter().render(document, options) + + +def _forget(job_id: str) -> None: + _jobs.pop(job_id, None) + _tasks.pop(job_id, None) + _cancel_flags.pop(job_id, None) + _delete_file(job_id) + + +def _evict_terminal() -> bool: + """超过容量时淘汰最旧的终态任务;全为活动任务无法淘汰时返回 False。""" + terminal = (ExportStatus.completed, ExportStatus.failed, ExportStatus.cancelled) + while len(_jobs) >= MAX_JOBS: + victim = next((jid for jid, job in _jobs.items() if job.status in terminal), None) + if victim is None: + return False + _forget(victim) + return True + + +async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]: + """把导出源解析为 (markdown, title, metadata);metadata 仅 note 源提供。""" + if source.type == ExportSourceType.note: + note = await note_service.get_note(source.note_id) + if note is None: + raise ApiError( + 404, + "EXPORT_SOURCE_NOT_FOUND", + "note not found", + {"note_id": source.note_id}, + ) + metadata = { + "file_path": note.file_path, + "tags": note.tags, + "created_at": note.created_at, + "updated_at": note.updated_at, + } + return note.markdown, note.title, metadata + + markdown = source.markdown or "" + if not markdown.strip(): + raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty") + if len(markdown) > MAX_MARKDOWN_CHARS: + raise ApiError( + 400, + "EXPORT_OPTIONS_INVALID", + f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters", + {"size": len(markdown), "limit": MAX_MARKDOWN_CHARS}, + ) + return markdown, "", None + + +async def create_export(request: ExportRequest) -> ExportJob: + """创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。""" + if request.format != ExportFormat.html: + raise ApiError( + 400, + "EXPORT_FORMAT_UNSUPPORTED", + "PDF/DOCX 暂未实现,当前仅支持 HTML", + {"format": request.format.value}, + ) + markdown, title, metadata = await _resolve_source(request.source) + + if not _evict_terminal(): + raise ApiError( + 429, + "EXPORT_CAPACITY_EXCEEDED", + "Export capacity exceeded; wait for active jobs to finish.", + {}, + ) + + job_id = "export_" + uuid4().hex[:12] + job = ExportJob( + job_id=job_id, + status=ExportStatus.queued, + format=request.format, + created_at=_now(), + ) + _jobs[job_id] = job + _cancel_flags[job_id] = asyncio.Event() + _tasks[job_id] = asyncio.create_task( + _execute(job_id, markdown, title, metadata, request.options) + ) + return job + + +async def _execute( + job_id: str, + markdown: str, + title: str, + metadata: dict | None, + options: ExportOptions, +) -> None: + """后台渲染:解析 → 导出 → 写文件 → 挂载产物元信息。""" + cancel_event = _cancel_flags[job_id] + _jobs[job_id] = _jobs[job_id].model_copy( + update={ + "status": ExportStatus.running, + "started_at": _now(), + "progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0), + } + ) + try: + # 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled + await asyncio.sleep(0) + if cancel_event.is_set(): + raise ExportCancelled() + + # 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环, + # 使运行中的取消能在渲染边界生效;写文件前再次检查取消。 + document = await asyncio.to_thread(parse_document, markdown) + document.attributes["title"] = title + if metadata: + document.attributes["metadata"] = metadata + + result = await asyncio.to_thread(_render_document, document, options) + if cancel_event.is_set(): + raise ExportCancelled() + + out_dir = get_settings().exports_path + out_dir.mkdir(parents=True, exist_ok=True) + path = _export_path(job_id) + path.write_bytes(result.content) + + completed_at = _now() + _jobs[job_id] = _jobs[job_id].model_copy( + update={ + "status": ExportStatus.completed, + "progress": ExportProgress( + phase="completed", current=1, total=1, percent=1.0 + ), + "file": ExportFile( + file_name=f"{_safe_download_name(title)}.html", + mime_type=result.mime_type, + size=len(result.content), + sha256=hashlib.sha256(result.content).hexdigest(), + expires_at=completed_at + FILE_TTL, + ), + "warnings": result.warnings, + "completed_at": completed_at, + } + ) + except ExportCancelled: + _jobs[job_id] = _jobs[job_id].model_copy( + update={ + "status": ExportStatus.cancelled, + "completed_at": _now(), + } + ) + except Exception as exc: # 渲染失败不拖垮服务,只记日志与项目错误码 + logger.exception("Export failed: job_id=%s", job_id) + _jobs[job_id] = _jobs[job_id].model_copy( + update={ + "status": ExportStatus.failed, + "error": "Export render failed.", + "error_code": "EXPORT_RENDER_FAILED", + "completed_at": _now(), + } + ) + finally: + _cancel_flags.pop(job_id, None) + + +def list_exports( + status: ExportStatus | None = None, + format: ExportFormat | None = None, + limit: int = 50, + offset: int = 0, +) -> tuple[list[ExportJob], int]: + jobs = list(_jobs.values()) + if status is not None: + jobs = [j for j in jobs if j.status == status] + if format is not None: + jobs = [j for j in jobs if j.format == format] + jobs.sort(key=lambda j: j.created_at, reverse=True) + total = len(jobs) + return jobs[offset : offset + limit], total + + +def get_export(job_id: str) -> ExportJob | None: + return _jobs.get(job_id) + + +def cancel_export(job_id: str) -> ExportJob | None: + """取消导出:仅 queued/running 可取消,后台 Task 在让出边界标记 cancelled。""" + job = _jobs.get(job_id) + if job is None: + return None + if job.status in (ExportStatus.queued, ExportStatus.running): + _cancel_flags[job_id].set() + return job + + +def get_export_file(job_id: str) -> Path: + """返回可下载产物的存储路径;未完成返回 404、过期返回 410。""" + job = _jobs.get(job_id) + if job is None: + raise ApiError(404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id}) + if job.status != ExportStatus.completed or job.file is None: + raise ApiError( + 404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id} + ) + if job.file.expires_at <= _now(): + _forget(job_id) # 过期即清理内存记录与产物文件 + raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id}) + return _export_path(job_id) + + +async def wait_for_export(job_id: str) -> ExportJob | None: + """等待后台任务结束(测试/轮询用);无任务时直接返回当前状态。""" + task = _tasks.get(job_id) + if task is not None: + await task + return _jobs.get(job_id) diff --git a/backend/app/main.py b/backend/app/main.py index bfaaf91..0ed0bba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ from starlette.exceptions import HTTPException as StarletteHttpException from app.config import get_settings from app.container import container from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler +from app.export import service as export_service from app.routes import router as api_router from app.schemas import HealthResponse, ServiceStatusResponse @@ -16,6 +17,8 @@ settings = get_settings() @asynccontextmanager async def lifespan(_: FastAPI): + # 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。 + export_service.cleanup_orphan_files() yield # 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。 container.plugins.shutdown() diff --git a/backend/app/plot/__init__.py b/backend/app/plot/__init__.py new file mode 100644 index 0000000..2bf1044 --- /dev/null +++ b/backend/app/plot/__init__.py @@ -0,0 +1,7 @@ +"""Function Plot:函数图像的白名单表达式解析与静态 SVG 渲染。 + +模块划分: +- model.py FunctionPlot 等内部数据模型(不进 contracts.py,同 Document AST) +- parser.py function-plot 源码与表达式解析(ast 白名单,绝不 eval/exec) +- render.py 把 FunctionPlot 渲染为内嵌 SVG(纯几何 + ,无脚本) +""" diff --git a/backend/app/plot/model.py b/backend/app/plot/model.py new file mode 100644 index 0000000..979577e --- /dev/null +++ b/backend/app/plot/model.py @@ -0,0 +1,55 @@ +"""Function Plot 内部数据模型。 + +契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部 +流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py。 +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class FunctionPlotExpression(BaseModel): + """单条函数表达式;expression 为数学表达式文本(不含 ``y =`` 前缀)。""" + + expression: str + label: str | None = None + color: str | None = None + + +class PlotAxes(BaseModel): + xlabel: str | None = None + ylabel: str | None = None + grid: bool = True + + +class FunctionPlot(BaseModel): + version: int = 1 + expressions: list[FunctionPlotExpression] + domain: tuple[float, float] = (-10.0, 10.0) + range: tuple[float, float] | None = None + axes: PlotAxes = Field(default_factory=PlotAxes) + + +class PlotDiagnostic(BaseModel): + severity: Literal["warning", "error"] + code: str + message: str + line: int | None = None + + +class FunctionPlotParseResult(BaseModel): + """解析结果:任一表达式 error 时 plot 为 None(整块回退占位),仅 warning 时 plot 有效。""" + + plot: FunctionPlot | None = None + diagnostics: list[PlotDiagnostic] = Field(default_factory=list) + + +class StaticRenderResult(BaseModel): + content: str + mime_type: str = "image/svg+xml" + width: int + height: int + warnings: list[str] = Field(default_factory=list) diff --git a/backend/app/plot/parser.py b/backend/app/plot/parser.py new file mode 100644 index 0000000..fc74f9e --- /dev/null +++ b/backend/app/plot/parser.py @@ -0,0 +1,359 @@ +"""Function Plot 表达式解析:白名单数学语法,绝不执行 eval / 函数构造器 / 属性访问。 + +安全模型:先用 ``ast.parse(mode='eval')`` 把表达式变成纯 AST(这一步不执行任何代码), +再逐节点白名单校验(只允许数字、变量 ``x``、常量 ``pi/e``、白名单函数调用与四则/幂 +运算),最后用递归解释器直接计算数值——全程不 ``compile``/``exec`` 字符串。 +""" + +from __future__ import annotations + +import ast +import math +import re +from typing import NoReturn + +from app.plot.model import ( + FunctionPlot, + FunctionPlotExpression, + FunctionPlotParseResult, + PlotAxes, + PlotDiagnostic, +) + +# 白名单函数(ln 是 log 的别名);abs 用内置函数,其余映射到 math +_FUNCTION_IMPL: dict[str, object] = { + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "asin": math.asin, + "acos": math.acos, + "atan": math.atan, + "sinh": math.sinh, + "cosh": math.cosh, + "tanh": math.tanh, + "exp": math.exp, + "log": math.log, + "ln": math.log, + "log10": math.log10, + "log2": math.log2, + "sqrt": math.sqrt, + "abs": abs, +} +_FUNCTIONS = frozenset(_FUNCTION_IMPL) +_CONSTANTS: dict[str, float] = {"pi": math.pi, "e": math.e} + +_ALLOWED_BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow) +_ALLOWED_UNARY = (ast.UAdd, ast.USub) +_DIRECTIVE_KEYS = frozenset({"domain", "range", "xlabel", "ylabel", "grid"}) +_NUMBER_RE = re.compile(r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") + + +class PlotParseError(Exception): + """表达式解析/校验失败,携带可定位诊断。""" + + def __init__(self, diagnostic: PlotDiagnostic) -> None: + super().__init__(diagnostic.message) + self.diagnostic = diagnostic + + +def _unsafe(message: str) -> NoReturn: + raise PlotParseError( + PlotDiagnostic(severity="error", code="FUNCTION_PLOT_EXPRESSION_UNSAFE", message=message) + ) + + +def _is_number(tok: str) -> bool: + return bool(_NUMBER_RE.match(tok)) + + +def _tokenize(s: str) -> list[str]: + """把预处理后的表达式切成数字/标识符/运算符/括号 token。""" + tokens: list[str] = [] + i = 0 + n = len(s) + while i < n: + ch = s[i] + if ch.isspace(): + i += 1 + continue + if ch.isdigit() or ch == ".": + j = i + while j < n and (s[j].isdigit() or s[j] == "."): + j += 1 + # 科学计数法:数字后紧跟 e/E[+-]数字 视为同一数字 + if j < n and s[j] in "eE": + k = j + 1 + if k < n and s[k] in "+-": + k += 1 + if k < n and s[k].isdigit(): + while k < n and s[k].isdigit(): + k += 1 + j = k + tokens.append(s[i:j]) + i = j + continue + if ch.isalpha() or ch == "_": + j = i + while j < n and (s[j].isalnum() or s[j] == "_"): + j += 1 + tokens.append(s[i:j]) + i = j + continue + if ch == "*" and i + 1 < n and s[i + 1] == "*": + tokens.append("**") + i += 2 + continue + tokens.append(ch) + i += 1 + return tokens + + +def _is_value_end(tok: str) -> bool: + """该 token 之后允许补乘号(数字/右括号/变量 x/常量)。""" + return tok == ")" or _is_number(tok) or tok == "x" or tok in _CONSTANTS + + +def _is_value_start(tok: str) -> bool: + """该 token 可作为乘号右侧起点(左括号/数字/任意标识符,含函数名)。""" + return tok == "(" or _is_number(tok) or (tok and (tok[0].isalpha() or tok[0] == "_")) + + +def _insert_implicit_multiplication(s: str) -> str: + """补隐式乘法:2x、2(x+1)、(x+1)(x-1)、x sin(x) 等;函数名后的 ``(`` 是调用不补。""" + tokens = _tokenize(s) + out: list[str] = [] + prev: str | None = None + for tok in tokens: + if prev is not None and _is_value_end(prev) and _is_value_start(tok): + out.append("*") + out.append(tok) + prev = tok + return "".join(out) + + +def _preprocess(expr: str) -> str: + """``^`` 视为幂,补隐式乘法后再交给 ast.parse。""" + return _insert_implicit_multiplication(expr.replace("^", "**")) + + +def _check_node(node: ast.AST) -> None: + """白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。""" + if isinstance(node, ast.Constant): + if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): + _unsafe(f"不支持的常量 {node.value!r}") + return + if isinstance(node, ast.Name): + if node.id == "x" or node.id in _CONSTANTS: + return + _unsafe(f"未知标识符 {node.id!r}") + if isinstance(node, ast.BinOp): + if not isinstance(node.op, _ALLOWED_BINOPS): + _unsafe(f"不支持的运算符 {type(node.op).__name__}") + _check_node(node.left) + _check_node(node.right) + return + if isinstance(node, ast.UnaryOp): + if not isinstance(node.op, _ALLOWED_UNARY): + _unsafe(f"不支持的运算符 {type(node.op).__name__}") + _check_node(node.operand) + return + if isinstance(node, ast.Call): + if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS: + _unsafe(f"不支持的函数调用 {ast.dump(node.func)!r}") + if node.keywords: + _unsafe("函数调用不支持关键字参数") + for arg in node.args: + _check_node(arg) + return + _unsafe(f"不支持的语法 {type(node).__name__}") + + +def parse_expression(expr: str) -> ast.Expression: + """把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。""" + preprocessed = _preprocess(expr) + try: + tree = ast.parse(preprocessed, mode="eval") + except SyntaxError as exc: + raise PlotParseError( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"表达式语法错误:{exc.msg}", + ) + ) from exc + _check_node(tree.body) + return tree + + +def evaluate(expr_ast: ast.Expression, x: float) -> float: + """递归解释已校验 AST 得到数值,全程不编译/执行代码。""" + return _eval_node(expr_ast.body, x) + + +def _eval_node(node: ast.AST, x: float) -> float: + if isinstance(node, ast.Constant): + return float(node.value) + if isinstance(node, ast.Name): + return x if node.id == "x" else _CONSTANTS[node.id] + if isinstance(node, ast.BinOp): + left = _eval_node(node.left, x) + right = _eval_node(node.right, x) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + if isinstance(node.op, ast.Div): + return left / right + return left**right + if isinstance(node, ast.UnaryOp): + value = _eval_node(node.operand, x) + return -value if isinstance(node.op, ast.USub) else value + if isinstance(node, ast.Call): + args = [_eval_node(arg, x) for arg in node.args] + return _FUNCTION_IMPL[node.func.id](*args) # type: ignore[operator] + raise ValueError("unreachable node") + + +def _strip_comment(line: str) -> str: + return line.split("#", 1)[0].strip() + + +def _parse_pair(value: str) -> tuple[float, float]: + """解析 ``min, max`` / ``min max`` 数值对。""" + parts = [p for p in re.split(r"[,,\s]+", value.strip()) if p] + if len(parts) != 2: + raise ValueError("需要两个数值") + return float(parts[0]), float(parts[1]) + + +def _parse_directive(line: str) -> tuple[str, str] | None: + """指令行形如 ``key: value``(表达式不含冒号,冒号是可靠判别)。""" + if ":" not in line or "=" in line: + return None + key, _, value = line.partition(":") + key = key.strip().lower() + if not key or " " in key: + return None + return key, value.strip() + + +def parse_source(source: str) -> FunctionPlotParseResult: + """把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。""" + diagnostics: list[PlotDiagnostic] = [] + expressions: list[FunctionPlotExpression] = [] + domain: tuple[float, float] = (-10.0, 10.0) + range_: tuple[float, float] | None = None + xlabel: str | None = None + ylabel: str | None = None + grid: bool = True + has_error = False + + for lineno, raw_line in enumerate(source.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + directive = _parse_directive(line) + if directive is not None: + key, value = directive + if key == "domain": + try: + domain = _parse_pair(value) + except ValueError: + diagnostics.append( + PlotDiagnostic( + severity="warning", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"domain 需要两个数值,已忽略:{value!r}", + line=lineno, + ) + ) + elif key == "range": + try: + range_ = _parse_pair(value) + except ValueError: + diagnostics.append( + PlotDiagnostic( + severity="warning", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"range 需要两个数值,已忽略:{value!r}", + line=lineno, + ) + ) + elif key == "xlabel": + xlabel = value or None + elif key == "ylabel": + ylabel = value or None + elif key == "grid": + grid = value.lower() in ("true", "1", "yes", "on") + else: + diagnostics.append( + PlotDiagnostic( + severity="warning", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"未知指令 {key!r} 已忽略", + line=lineno, + ) + ) + continue + + # 表达式行:y = 或裸 + expr_text = _strip_comment(line) + if not expr_text: + continue + if "=" in expr_text: + lhs, _, rhs = expr_text.partition("=") + if lhs.strip().lower() not in ("y", ""): + diagnostics.append( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message="表达式应形如 'y = '", + line=lineno, + ) + ) + has_error = True + continue + expr_text = rhs.strip() + if not expr_text: + diagnostics.append( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message="表达式为空", + line=lineno, + ) + ) + has_error = True + continue + + try: + parse_expression(expr_text) + except PlotParseError as exc: + exc.diagnostic.line = lineno + diagnostics.append(exc.diagnostic) + has_error = True + continue + expressions.append(FunctionPlotExpression(expression=expr_text)) + + if has_error: + return FunctionPlotParseResult(plot=None, diagnostics=diagnostics) + if not expressions: + diagnostics.append( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message="没有找到任何函数表达式", + ) + ) + return FunctionPlotParseResult(plot=None, diagnostics=diagnostics) + + plot = FunctionPlot( + expressions=expressions, + domain=domain, + range=range_, + axes=PlotAxes(xlabel=xlabel, ylabel=ylabel, grid=grid), + ) + return FunctionPlotParseResult(plot=plot, diagnostics=diagnostics) diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py new file mode 100644 index 0000000..4c3dd78 --- /dev/null +++ b/backend/app/plot/render.py @@ -0,0 +1,223 @@ +"""Function Plot → 静态 SVG 渲染。 + +只输出纯几何与 的 SVG(无 script/foreignObject/内联事件),可安全内嵌 HTML。 +所有文本与颜色都经过转义/校验,不把用户输入直接拼进标记。 +""" + +from __future__ import annotations + +import html +import math +import re +from typing import Callable + +from app.plot.model import FunctionPlot, StaticRenderResult +from app.plot.parser import PlotParseError, evaluate, parse_expression + +_WIDTH = 640 +_HEIGHT = 480 +_MARGIN = 52 # 四周留白,放轴刻度与标签 +_SAMPLES = 400 +_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"] +_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$") + + +def _safe_color(color: str | None, fallback: str) -> str: + return color.strip() if color and _COLOR_RE.match(color.strip()) else fallback + + +def _fmt_num(v: float) -> str: + if v == 0: + return "0" + if abs(v) >= 1e6 or abs(v) < 1e-6: + return f"{v:.2e}" + return f"{v:.6g}" + + +def _nice_step(span: float, target_ticks: int = 6) -> float: + raw = abs(span) / target_ticks + mag = 10 ** math.floor(math.log10(raw)) + for m in (1, 2, 5, 10): + if raw <= m * mag: + return m * mag + return 10 * mag + + +def _ticks(lo: float, hi: float, step: float) -> list[float]: + first = math.ceil(lo / step) * step + values: list[float] = [] + v = first + while v <= hi + step * 1e-9: + values.append(v) + v += step + return values + + +def _compute_range( + plot: FunctionPlot, + fns: list[tuple[object, object]], + xmin: float, + xmax: float, +) -> tuple[float, float]: + """采样确定 y 范围;指定 range 则优先,否则取有限样本的 min/max 加 5% 余量。""" + if plot.range is not None: + return float(plot.range[0]), float(plot.range[1]) + + ys: list[float] = [] + for _expr, tree in fns: + for i in range(_SAMPLES + 1): + x = xmin + (xmax - xmin) * i / _SAMPLES + try: + y = evaluate(tree, x) # type: ignore[arg-type] + except (ValueError, ZeroDivisionError, OverflowError): + continue + if math.isfinite(y): + ys.append(y) + + if not ys: + return -10.0, 10.0 + lo, hi = min(ys), max(ys) + if lo == hi: + lo -= 1.0 + hi += 1.0 + pad = (hi - lo) * 0.05 + return lo - pad, hi + pad + + +def _polyline( + tree: object, + xmin: float, + xmax: float, + sx: Callable[[float], float], + sy: Callable[[float], float], + color: str, +) -> str: + """采样并把非有限点处断开成多段 polyline,避免画穿渐近线。""" + segments: list[str] = [] + points: list[str] = [] + for i in range(_SAMPLES + 1): + x = xmin + (xmax - xmin) * i / _SAMPLES + try: + y = evaluate(tree, x) # type: ignore[arg-type] + except (ValueError, ZeroDivisionError, OverflowError): + y = math.nan + if not math.isfinite(y): + if points: + segments.append(f'') + points = [] + continue + px = sx(x) + py = sy(y) + points.append(f"{px:.2f},{py:.2f}") + if points: + segments.append(f'') + return "".join(segments) + + +def _grid( + xmin: float, + xmax: float, + ymin: float, + ymax: float, + sx: Callable[[float], float], + sy: Callable[[float], float], +) -> str: + parts: list[str] = [] + for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)): + parts.append(f'') + for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)): + parts.append(f'') + return "".join(parts) + + +def _axes( + xmin: float, + xmax: float, + ymin: float, + ymax: float, + sx: Callable[[float], float], + sy: Callable[[float], float], +) -> str: + parts: list[str] = [] + # 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系 + x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin + y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin + parts.append( + f'' + ) + parts.append( + f'' + ) + # x 轴刻度数字(画在轴下方) + for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)): + parts.append( + f'{html.escape(_fmt_num(x))}' + ) + # y 轴刻度数字(画在轴左侧) + for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)): + parts.append( + f'{html.escape(_fmt_num(y))}' + ) + return "".join(parts) + + +def _labels(plot: FunctionPlot, sx: Callable[[float], float], sy: Callable[[float], float]) -> str: + parts: list[str] = [] + if plot.axes.xlabel: + parts.append( + f'{html.escape(plot.axes.xlabel)}' + ) + if plot.axes.ylabel: + parts.append( + f'{html.escape(plot.axes.ylabel)}' + ) + return "".join(parts) + + +def render_svg(plot: FunctionPlot) -> StaticRenderResult: + """把已解析的 FunctionPlot 渲染为内嵌 SVG。""" + warnings: list[str] = [] + xmin, xmax = plot.domain + if xmin >= xmax: + warnings.append("domain 无效,回退到 [-10, 10]") + xmin, xmax = -10.0, 10.0 + + # 重新解析并编译表达式(parse_source 已校验,这里异常只在模型被绕过时触发) + fns: list[tuple[object, object]] = [] + for expr in plot.expressions: + try: + tree = parse_expression(expr.expression) + except PlotParseError as exc: + warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})") + continue + fns.append((expr, tree)) + + ymin, ymax = _compute_range(plot, fns, xmin, xmax) + if plot.range is not None and plot.range[0] >= plot.range[1]: + warnings.append("range 无效,改用自动范围") + ymin, ymax = _compute_range(plot, fns, xmin, xmax) + + def sx(x: float) -> float: + return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN) + + def sy(y: float) -> float: + return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN) + + parts: list[str] = [ + f'' + ] + if plot.axes.grid: + parts.append(_grid(xmin, xmax, ymin, ymax, sx, sy)) + parts.append(_axes(xmin, xmax, ymin, ymax, sx, sy)) + for i, (expr, tree) in enumerate(fns): + color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)]) + parts.append(_polyline(tree, xmin, xmax, sx, sy, color)) + parts.append(_labels(plot, sx, sy)) + parts.append("") + + return StaticRenderResult( + content="".join(parts), + width=_WIDTH, + height=_HEIGHT, + warnings=warnings, + ) diff --git a/backend/app/routes.py b/backend/app/routes.py index ef9f8d3..785b470 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from uuid import uuid4 from fastapi import APIRouter, Header, Query -from fastapi.responses import StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse from app.agent import AgentCapacityError, AgentRunNotFoundError from app.container import container @@ -48,6 +48,11 @@ from app.contracts import ( ModelRoutingResponse, SpeakerMatchRequest, SpeakerMatchResult, + ExportFormat, + ExportJob, + ExportJobListResponse, + ExportRequest, + ExportStatus, Note, NoteCreateRequest, NoteListResponse, @@ -96,8 +101,10 @@ from app.contracts import ( from app.agent import AgentCapacityError, AgentRunNotFoundError from app.benchmarks import datasets as benchmark_datasets from app.benchmarks import service as benchmark_service +from app.config import get_settings from app.container import container from app.errors import ApiError +from app.export import service as export_service from app.extensions import ExtensionError from app.extensions.mcp_registry import McpRegistryError from app.providers.base import ProviderError @@ -1317,3 +1324,80 @@ async def get_benchmark_report(run_id: str) -> BenchmarkReport: 404, "BENCHMARK_RUN_NOT_FOUND", "benchmark report not found", {"run_id": run_id} ) return report + + +@router.post( + "/exports", + response_model=ExportJob, + status_code=202, + tags=["Export"], +) +async def create_export(request: ExportRequest) -> ExportJob: + return await export_service.create_export(request) + + +@router.get( + "/exports", + response_model=ExportJobListResponse, + tags=["Export"], +) +async def list_exports( + status: ExportStatus | None = Query(default=None), + format: ExportFormat | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> ExportJobListResponse: + items, total = export_service.list_exports( + status=status, format=format, limit=limit, offset=offset + ) + return ExportJobListResponse( + items=items, page=PageMeta(total=total, limit=limit, offset=offset) + ) + + +@router.get( + "/exports/{job_id}", + response_model=ExportJob, + tags=["Export"], +) +async def get_export(job_id: str) -> ExportJob: + job = export_service.get_export(job_id) + if job is None: + raise ApiError( + 404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id} + ) + return job + + +@router.get( + "/exports/{job_id}/file", + tags=["Export"], +) +async def get_export_file(job_id: str) -> FileResponse: + path = export_service.get_export_file(job_id) # 未完成/过期分别抛 404/410 + job = export_service.get_export(job_id) + if job is None or job.file is None: + raise ApiError( + 404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id} + ) + return FileResponse( + path=path, + media_type=job.file.mime_type, + filename=job.file.file_name, + ) + + +@router.post( + "/exports/{job_id}/cancel", + response_model=OperationResponse, + tags=["Export"], +) +async def cancel_export(job_id: str) -> OperationResponse: + job = export_service.cancel_export(job_id) + if job is None: + raise ApiError( + 404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id} + ) + return OperationResponse( + status="accepted", resource_id=job_id, message="Export cancellation accepted." + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6c3d4c0..6862d00 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "fastapi>=0.116,<1.0", "httpx>=0.28,<1.0", "jsonschema>=4.25,<5.0", + "mistune>=3.0,<4.0", "pyyaml>=6.0,<7.0", "referencing>=0.36,<1.0", "sqlite-vec>=0.1.9", diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py new file mode 100644 index 0000000..38fda15 --- /dev/null +++ b/backend/tests/test_export.py @@ -0,0 +1,414 @@ +"""Export Service 的单元与端到端测试。 + +沿用 conftest 隔离机制:APP_DATA_DIR / DB / Vault / exports 目录都落在临时目录, +不读写真实数据。导出采用「创建即 queued + 后台 Task 执行」的异步模型,测试在同一 +事件循环内创建并等待后台任务结束,得到终态 ExportJob 后再断言。 +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError + +from app.config import get_settings +from app.contracts import ( + ExportFormat, + ExportJob, + ExportOptions, + ExportRequest, + ExportSource, + ExportSourceType, + ExportStatus, +) +from app.errors import ApiError +from app.export import service as export_service +from app.export.exporters.html import HtmlExporter +from app.export.markdown import parse_document + +MD = """# 进程调度 + +一些 **加粗** 和 *斜体*,[链接](https://a.b) 与 `code`。 + +- 项目一 +- 项目二 + +```python +print(1) +``` + +```mermaid +graph LR +``` + +```function_plot +y = x +``` + +| a | b | +|---|---| +| 1 | 2 | + +行内 $x^2$ 与块级 +$$ +y = mx + b +$$ +""" + + +@pytest.fixture(autouse=True) +def _reset_export_state(): + """清空内存注册表,避免跨用例的任务/取消标志互相污染。""" + export_service._jobs.clear() + export_service._tasks.clear() + export_service._cancel_flags.clear() + yield + export_service._jobs.clear() + export_service._tasks.clear() + export_service._cancel_flags.clear() + + +def _create_and_wait(request: ExportRequest) -> object: + """创建导出并在同一事件循环内等待后台任务结束,返回终态 ExportJob。""" + + async def _execute(): + job = await export_service.create_export(request) + return await export_service.wait_for_export(job.job_id) + + return asyncio.run(_execute()) + + +# --------------------------------------------------------------------------- # +# markdown → Document AST +# --------------------------------------------------------------------------- # +def _types(nodes) -> list[str]: + return [n.type for n in nodes] + + +def test_parse_document_heading_and_inline() -> None: + doc = parse_document("# 标题\n\n一段 **加粗** 和 [链接](https://a.b)。") + + assert doc.type == "document" + heading = doc.children[0] + assert heading.type == "heading" + assert heading.attributes["level"] == 1 + + para = doc.children[1] + assert para.type == "paragraph" + kinds = _types(para.children) + assert "text" in kinds + assert "strong" in kinds + assert "link" in kinds + + link = next(c for c in para.children if c.type == "link") + assert link.attributes["href"] == "https://a.b" + + +def test_parse_document_list_and_code_fencing() -> None: + doc = parse_document("- a\n- b\n\n```mermaid\ngraph LR\n```\n\n```function_plot\ny=x\n```\n\n```python\nx\n```") + + kinds = [c.type for c in doc.children] + assert kinds[0] == "list" + assert kinds[1] == "mermaid" + assert kinds[2] == "function_plot" + assert kinds[3] == "code_block" + + code = doc.children[3] + assert code.attributes["language"] == "python" + assert code.text == "x" + + +def test_parse_document_table_and_math() -> None: + doc = parse_document("| a | b |\n|---|---|\n| 1 | 2 |\n\n$x^2$\n\n$$\ny=mx\n$$") + + table = doc.children[0] + assert table.type == "table" + assert table.children[0].type == "table_row" + assert table.children[0].children[0].attributes["head"] is True + + # 表格后是「行内数学所在段落」与「块级数学」 + kinds = [c.type for c in doc.children[1:]] + assert "paragraph" in kinds + assert "math_block" in kinds + + +def test_parse_document_image_maps_src_alt_title() -> None: + doc = parse_document('![替代文本](https://a.b/img.png "标题")') + img = doc.children[0].children[0] + assert img.type == "image" + assert img.attributes["src"] == "https://a.b/img.png" + assert img.attributes["alt"] == "替代文本" + assert img.attributes["title"] == "标题" + + +def test_parse_document_function_plot_dash_alias() -> None: + doc = parse_document("```function-plot\ny = x^2\n```") + assert doc.children[0].type == "function_plot" + assert doc.children[0].text == "y = x^2" + + +# --------------------------------------------------------------------------- # +# HtmlExporter +# --------------------------------------------------------------------------- # +async def _render(markdown: str, *, title: str = "") -> str: + doc = parse_document(markdown) + doc.attributes["title"] = title + result = await HtmlExporter().export(doc, ExportOptions()) + return result.content.decode("utf-8") + + +def test_html_exporter_renders_basic_nodes_and_escapes() -> None: + html = asyncio.run(_render("# 标题\n\n**加粗** [链接](https://a.b) 与 原始。")) + + assert "

    标题

    " in html + assert "加粗" in html + assert '链接' in html + # 原始 HTML 必须被转义,不能注入文档 + assert "<b>原始</b>" in html + assert "原始" not in html + + +def test_html_exporter_marks_mermaid_and_function_plot() -> None: + result = asyncio.run(HtmlExporter().export(parse_document("```mermaid\ngraph LR\n```"), ExportOptions())) + + html = result.content.decode("utf-8") + assert '
    graph LR
    ' in html + assert any("mermaid" in w for w in result.warnings) + + +def test_html_exporter_rejects_unsafe_link_protocol() -> None: + result = asyncio.run( + HtmlExporter().export(parse_document("[点我](javascript:alert(1))"), ExportOptions()) + ) + html = result.content.decode("utf-8") + assert "javascript:" not in html + assert "点我" in html + assert any("不安全" in w for w in result.warnings) + + +def test_html_exporter_rejects_unsafe_image_protocol() -> None: + result = asyncio.run( + HtmlExporter().export(parse_document("![alt](data:text/html,