Complete phase two benchmarks, plot previews and static export workflow
This commit is contained in:
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from app.contracts import ToolDefinition
|
||||
from app.services import note_service
|
||||
|
||||
Format = Literal['heading', 'paragraph', 'bold', 'italic', 'strikethrough', 'inline-code', 'bullet-list', 'ordered-list', 'task-list', 'blockquote', 'callout', 'code-block', 'mermaid', 'inline-math', 'math-block', 'link', 'image', 'table', 'horizontal-rule', 'hard-break', 'reference-link', 'html', 'metadata']
|
||||
Format = Literal['heading', 'paragraph', 'bold', 'italic', 'strikethrough', 'inline-code', 'bullet-list', 'ordered-list', 'task-list', 'blockquote', 'callout', 'code-block', 'mermaid', 'function-plot', 'inline-math', 'math-block', 'link', 'image', 'table', 'horizontal-rule', 'hard-break', 'reference-link', 'html', 'metadata']
|
||||
CALLOUTS = ['note', 'abstract', 'summary', 'tldr', 'info', 'todo', 'tip', 'hint', 'important', 'success', 'check', 'done', 'question', 'help', 'faq', 'warning', 'caution', 'attention', 'failure', 'fail', 'missing', 'danger', 'error', 'bug', 'example', 'quote', 'cite']
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def compose(arguments: ComposeArguments, _):
|
||||
elif kind == 'inline-code':
|
||||
marker = '`' * (max([0, *(len(m[0]) for m in re.finditer(r'`+', text))]) + 1)
|
||||
result = marker + ' ' + text.replace('\n', ' ') + ' ' + marker
|
||||
elif kind in ('code-block', 'mermaid'): result = fenced(text, 'mermaid' if kind == 'mermaid' else a.language)
|
||||
elif kind in ('code-block', 'mermaid', 'function-plot'): result = fenced(text, kind if kind != 'code-block' else a.language)
|
||||
elif kind in ('bullet-list', 'ordered-list', 'task-list'):
|
||||
result = '\n'.join((f'{i + 1}. ' if kind == 'ordered-list' else '- [ ] ' if kind == 'task-list' else '- ') + item.replace('\n', '\n ') for i, item in enumerate(a.items))
|
||||
elif kind == 'blockquote': result = '\n'.join('> ' + line for line in text.split('\n'))
|
||||
@@ -90,7 +90,8 @@ def catalog(_, __):
|
||||
from typing import get_args
|
||||
return {'formats': list(get_args(Format)), 'callouts': CALLOUTS,
|
||||
'workflow': 'Use markdown.compose, then notes.create or notes.patch_markdown to persist. Read notes.read.content_hash before patching. metadata composition replaces the frontmatter only when you explicitly patch it; do not prepend duplicate frontmatter.',
|
||||
'rendering': 'Math, Mermaid, callouts and auto-links depend on editor preferences. HTML is sanitized; scripts are not supported. Heading folding, font size, undo and redo are UI state, not Markdown document syntax. Callout collapsed=null is static, true is folded, false is expanded.'}
|
||||
'function_plot': 'Use a function-plot fenced block: domain: -4, 4 followed by y = x^2 and y = sin(x). At most 16 expressions per block, 16 plots and 8000 total AST nodes per exported document. No arbitrary code execution.',
|
||||
'rendering': 'Function plots, Math, Mermaid, callouts and auto-links depend on editor preferences. HTML is sanitized; scripts are not supported. Heading folding, font size, undo and redo are UI state, not Markdown document syntax. Callout collapsed=null is static, true is folded, false is expanded.'}
|
||||
|
||||
|
||||
async def patch(arguments: PatchArguments, _):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Standard task evaluation over AgentRuntime, never a scripted substitute runner."""
|
||||
import asyncio
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
from app.contracts import (AgentBenchmarkRequest, AgentCaseResult, AgentRunCreateRequest,
|
||||
BenchmarkRun, BenchmarkReport, BenchmarkKind, BenchmarkStatus, BenchmarkEvent, BenchmarkEventType)
|
||||
from app.benchmarks import datasets, service
|
||||
from app.errors import ApiError
|
||||
|
||||
INVALID = {'TOOL_NOT_FOUND', 'TOOL_NOT_ALLOWED', 'TOOL_ARGUMENT_INVALID', 'TOOL_VALIDATION_ERROR'}
|
||||
|
||||
def score(case, run, events, latency, repeat):
|
||||
calls = [e.data for e in events if e.event.value == 'ToolCall']
|
||||
unmatched = list(calls)
|
||||
selected = accurate = 0
|
||||
for expected in case.expected_tools:
|
||||
candidates = [c for c in unmatched if c.get('name') == expected.name]
|
||||
if not candidates:
|
||||
continue
|
||||
exact = next((c for c in candidates if all(k in c.get('arguments', {}) and c['arguments'][k] == v for k,v in expected.arguments.items())), None)
|
||||
chosen = exact or candidates[0]
|
||||
unmatched.remove(chosen); selected += 1; accurate += int(exact is not None)
|
||||
results = run.tool_results
|
||||
checks = {
|
||||
'completed': run.status.value == 'completed',
|
||||
'tools_selected': selected == len(case.expected_tools),
|
||||
'tool_arguments': accurate == len(case.expected_tools),
|
||||
'no_extra_calls': len(calls) <= len(case.expected_tools),
|
||||
'tool_results': all(r.success for r in results),
|
||||
'output': all(text.casefold() in (run.output or '').casefold() for text in case.output_contains),
|
||||
'citation': not case.citation_required or bool(run.citations),
|
||||
'tasks_created': case.tasks_created is None or sum(r.success and r.name == 'tasks.create' for r in results) == case.tasks_created,
|
||||
}
|
||||
return AgentCaseResult(case_id=case.case_id, repeat=repeat, agent_run_id=run.run_id,
|
||||
success=all(checks.values()), tool_calls=len(calls), expected_calls=len(case.expected_tools),
|
||||
selected_calls=selected, accurate_calls=accurate, invalid_calls=sum(r.error_code in INVALID for r in results),
|
||||
steps=run.current_step, latency_ms=latency, token_usage=run.token_usage, checks=checks, error_code=run.error_code)
|
||||
|
||||
def aggregate(cases, planned_total=None):
|
||||
total = len(cases) if planned_total is None else planned_total
|
||||
calls = sum(c.tool_calls for c in cases)
|
||||
expected = sum(c.expected_calls for c in cases)
|
||||
# Micro accuracy penalizes omitted AND unnecessary calls; no-call cases are N/A.
|
||||
denominator = max(calls, expected)
|
||||
return {'total_cases': total, 'evaluated_cases': len(cases), 'task_success_rate': sum(c.success for c in cases)/total if total else 0,
|
||||
'tool_selection_accuracy': sum(c.selected_calls for c in cases)/denominator if denominator else None,
|
||||
'tool_argument_accuracy': sum(c.accurate_calls for c in cases)/denominator if denominator else None,
|
||||
'invalid_tool_call_rate': sum(c.invalid_calls for c in cases)/calls if calls else None,
|
||||
'average_steps': sum(c.steps for c in cases)/total if total else 0,
|
||||
'average_latency_ms': sum(c.latency_ms for c in cases)/total if total else 0,
|
||||
'token_usage': sum(c.token_usage for c in cases), 'tool_calls': calls, 'expected_calls': expected}
|
||||
|
||||
async def create_run(request: AgentBenchmarkRequest):
|
||||
from app.container import container
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
try:
|
||||
provider = container.providers.get(request.provider_id)
|
||||
except ProviderNotFoundError as exc:
|
||||
raise ApiError(404, 'PROVIDER_NOT_FOUND', 'Provider not found or disabled.') from exc
|
||||
is_mock = provider.config.provider_type.value == 'mock'
|
||||
if request.offline and not is_mock:
|
||||
raise ApiError(422, 'BENCHMARK_OFFLINE_PROVIDER_REQUIRED', 'Offline regression only accepts a mock provider.')
|
||||
if is_mock and not request.offline:
|
||||
raise ApiError(422, 'BENCHMARK_REAL_PROVIDER_REQUIRED', 'Select a real provider or explicitly mark offline regression.')
|
||||
dataset = datasets.load_dataset(request.dataset_id, BenchmarkKind.agent)
|
||||
if not service._evict_terminal():
|
||||
raise ApiError(429, 'BENCHMARK_CAPACITY_EXCEEDED', 'Benchmark capacity exceeded.')
|
||||
run_id = 'benchmark_' + uuid4().hex[:12]
|
||||
snapshot = {**request.model_dump(), 'dataset_hash': dataset.content_hash,
|
||||
'dataset_version': dataset.version, 'execution': 'offline' if request.offline else 'real_agent_runtime',
|
||||
'provider_type': provider.config.provider_type, 'scoring_version': '1.0', 'permission_policy': 'runtime_user_decision'}
|
||||
run = BenchmarkRun(run_id=run_id, kind=BenchmarkKind.agent, dataset_id=dataset.dataset_id,
|
||||
dataset_hash=dataset.content_hash, status=BenchmarkStatus.queued, created_at=service._now(), config_snapshot=snapshot)
|
||||
service._runs[run_id] = run
|
||||
service._events[run_id] = []
|
||||
service._subscribers[run_id] = []
|
||||
service._cancel_flags[run_id] = asyncio.Event()
|
||||
service._tasks[run_id] = asyncio.create_task(execute(run_id, request, dataset, container.agent))
|
||||
return run
|
||||
|
||||
async def execute(run_id, request, dataset, runtime):
|
||||
flag = service._cancel_flags[run_id]
|
||||
results = []; active = None
|
||||
def emit(kind, data):
|
||||
event = BenchmarkEvent(event=kind, run_id=run_id, sequence=len(service._events[run_id]), data=data, timestamp=service._now())
|
||||
service._events[run_id].append(event)
|
||||
for queue in service._subscribers.get(run_id, []): queue.put_nowait(event)
|
||||
status = BenchmarkStatus.completed
|
||||
error = None
|
||||
try:
|
||||
service._runs[run_id] = service._runs[run_id].model_copy(update={'status': BenchmarkStatus.running, 'started_at': service._now()})
|
||||
emit(BenchmarkEventType.run_started, {'dataset_id': dataset.dataset_id})
|
||||
for case in dataset.cases:
|
||||
for repeat in range(request.repeat):
|
||||
if flag.is_set():
|
||||
status = BenchmarkStatus.cancelled; break
|
||||
started = perf_counter()
|
||||
active = await runtime.create_run(AgentRunCreateRequest(input=case.prompt, provider_id=request.provider_id,
|
||||
model=request.model, allowed_tools=case.allowed_tools, max_steps=request.max_steps,
|
||||
token_budget=request.token_budget, run_timeout_seconds=request.timeout_seconds,
|
||||
tool_timeout_seconds=min(30, request.timeout_seconds), allow_network=request.allow_network,
|
||||
metadata={'benchmark_run_id': run_id, 'case_id': case.case_id}))
|
||||
# Surface the real Trace/permission entry while the case is still executing.
|
||||
service._runs[run_id].config_snapshot['active_agent_run_id'] = active.run_id
|
||||
wait = asyncio.create_task(runtime.wait(active.run_id))
|
||||
cancel = asyncio.create_task(flag.wait())
|
||||
try:
|
||||
done, _ = await asyncio.wait([wait, cancel], return_when=asyncio.FIRST_COMPLETED)
|
||||
if cancel in done:
|
||||
await runtime.cancel(active.run_id)
|
||||
status = BenchmarkStatus.cancelled
|
||||
finished = await wait
|
||||
finally:
|
||||
cancel.cancel(); await asyncio.gather(cancel, return_exceptions=True)
|
||||
events = [event async for event in runtime.events(active.run_id)]
|
||||
result = score(case, finished, events, (perf_counter()-started)*1000, repeat)
|
||||
results.append(result); active = None
|
||||
service._runs[run_id].progress = len(results)/(len(dataset.cases)*request.repeat)
|
||||
emit(BenchmarkEventType.case_completed, result.model_dump(mode='json'))
|
||||
if status == BenchmarkStatus.cancelled: break
|
||||
except asyncio.CancelledError:
|
||||
status = BenchmarkStatus.cancelled
|
||||
except Exception:
|
||||
status = BenchmarkStatus.failed; error = 'BENCHMARK_RUN_FAILED'
|
||||
finally:
|
||||
if active:
|
||||
await runtime.cancel(active.run_id)
|
||||
await runtime.wait(active.run_id)
|
||||
metrics = aggregate(results, len(dataset.cases)*request.repeat)
|
||||
run = service._runs[run_id]
|
||||
service._runs[run_id] = run.model_copy(update={'status':status, 'metrics':metrics, 'completed_at':service._now(), 'error_code':error})
|
||||
service._reports[run_id] = BenchmarkReport(run_id=run_id, kind=BenchmarkKind.agent,
|
||||
dataset_id=dataset.dataset_id, dataset_hash=dataset.content_hash, status=status,
|
||||
config_snapshot=run.config_snapshot, cases=results, metrics=metrics, error_code=error)
|
||||
emit({BenchmarkStatus.completed: BenchmarkEventType.run_completed, BenchmarkStatus.failed: BenchmarkEventType.run_failed,
|
||||
BenchmarkStatus.cancelled: BenchmarkEventType.run_cancelled}[status], {'metrics':metrics, 'error_code':error})
|
||||
service._cancel_flags.pop(run_id, None); service._subscribers.pop(run_id, None)
|
||||
@@ -17,20 +17,20 @@ from app.config import get_settings
|
||||
from app.contracts import (
|
||||
BenchmarkDatasetInfo,
|
||||
BenchmarkKind,
|
||||
RAGDatasetCase,
|
||||
RAGDatasetCase, AgentDatasetCase,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
|
||||
|
||||
@dataclass
|
||||
class RAGDataset:
|
||||
"""内存中的 RAG 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
|
||||
"""内存中的 RAG / Agent 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
|
||||
|
||||
dataset_id: str
|
||||
kind: BenchmarkKind
|
||||
version: str
|
||||
description: str
|
||||
cases: list[RAGDatasetCase] = field(default_factory=list)
|
||||
cases: list[RAGDatasetCase | AgentDatasetCase] = field(default_factory=list)
|
||||
content_hash: str = ""
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
|
||||
{"dataset_id": dataset_id},
|
||||
)
|
||||
|
||||
cases: list[RAGDatasetCase] = []
|
||||
cases: list[RAGDatasetCase | AgentDatasetCase] = []
|
||||
for index, case in enumerate(raw_cases):
|
||||
try:
|
||||
parsed = RAGDatasetCase.model_validate(case)
|
||||
parsed = (AgentDatasetCase if kind == BenchmarkKind.agent else RAGDatasetCase).model_validate(case)
|
||||
except ValidationError as exc:
|
||||
raise ApiError(
|
||||
422,
|
||||
@@ -115,6 +115,13 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
|
||||
f"Dataset case #{index} is invalid.",
|
||||
{"dataset_id": dataset_id, "case_index": index, "errors": exc.errors()},
|
||||
) from exc
|
||||
if kind == BenchmarkKind.agent:
|
||||
if not (parsed.expected_tools or parsed.output_contains or parsed.citation_required or parsed.tasks_created is not None):
|
||||
raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Agent case requires objective expectations.')
|
||||
if any(tool.name not in parsed.allowed_tools for tool in parsed.expected_tools):
|
||||
raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Expected tools must be allowed.')
|
||||
cases.append(parsed)
|
||||
continue
|
||||
# 每个 Case 至少要声明一个期望 id,否则无法计算命中/召回
|
||||
if not parsed.expected_note_ids and not parsed.expected_block_ids:
|
||||
raise ApiError(
|
||||
@@ -133,6 +140,8 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
|
||||
)
|
||||
cases.append(parsed)
|
||||
|
||||
if len(cases) > 100 or len({c.case_id for c in cases}) != len(cases):
|
||||
raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Dataset case IDs must be unique; maximum 100 cases.')
|
||||
return RAGDataset(
|
||||
dataset_id=dataset_id,
|
||||
kind=kind,
|
||||
|
||||
@@ -86,6 +86,7 @@ async def _evaluate_one(
|
||||
limit=request.retrieval.top_k,
|
||||
include_snippet=False,
|
||||
rrf_k=request.retrieval.rrf_k,
|
||||
fusion=request.retrieval.fusion,
|
||||
rerank=request.retrieval.rerank,
|
||||
rerank_candidates=request.retrieval.rerank_candidates,
|
||||
score_threshold=request.retrieval.score_threshold,
|
||||
|
||||
@@ -352,3 +352,12 @@ async def wait_for_run(run_id: str) -> BenchmarkRun:
|
||||
if task is not None:
|
||||
await task
|
||||
return _runs.get(run_id)
|
||||
|
||||
|
||||
async def shutdown():
|
||||
loop = asyncio.get_running_loop()
|
||||
active = {rid: task for rid, task in _tasks.items() if not task.done() and task.get_loop() is loop}
|
||||
for rid in active:
|
||||
flag = _cancel_flags.get(rid)
|
||||
if flag: flag.set()
|
||||
await asyncio.gather(*active.values(), return_exceptions=True)
|
||||
|
||||
@@ -155,6 +155,7 @@ class SearchRequest(Contract):
|
||||
include_snippet: bool = True
|
||||
# 检索调优参数(Benchmark 与 Skill 共用):控制 RRF / 精排 / 候选池 / 分数阈值。
|
||||
# rerank_candidates=None 表示对全部候选精排(保留原有行为),Benchmark 传显式值。
|
||||
fusion: Literal['rrf', 'weighted'] = 'rrf'
|
||||
rrf_k: int = Field(default=60, ge=1)
|
||||
rerank: bool = True
|
||||
rerank_candidates: int | None = Field(default=None, ge=1)
|
||||
@@ -1221,6 +1222,7 @@ class RAGRetrievalConfig(Contract):
|
||||
其余参数透传到 SearchRequest,由检索引擎实际执行。"""
|
||||
|
||||
top_k: int = Field(default=10, ge=1, le=100)
|
||||
fusion: Literal['rrf', 'weighted'] = 'rrf'
|
||||
rrf_k: int = Field(default=60, ge=1)
|
||||
rerank: bool = True
|
||||
rerank_candidates: int = Field(default=20, ge=1)
|
||||
@@ -1329,6 +1331,51 @@ class RAGCaseResult(Contract):
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
class ExpectedToolCall(Contract):
|
||||
name: str = Field(min_length=1)
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentDatasetCase(Contract):
|
||||
case_id: str = Field(min_length=1)
|
||||
prompt: str = Field(min_length=1, max_length=20000)
|
||||
allowed_tools: list[str] = Field(default_factory=list, max_length=30)
|
||||
expected_tools: list[ExpectedToolCall] = Field(default_factory=list, max_length=30)
|
||||
output_contains: list[str] = Field(default_factory=list)
|
||||
citation_required: bool = False
|
||||
tasks_created: int | None = Field(default=None, ge=0, le=20)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentBenchmarkRequest(Contract):
|
||||
dataset_id: str = Field(min_length=1)
|
||||
provider_id: str
|
||||
model: str = Field(min_length=1)
|
||||
max_steps: int = Field(default=6, ge=1, le=20)
|
||||
timeout_seconds: int = Field(default=90, ge=1, le=300)
|
||||
token_budget: int = Field(default=6000, ge=1, le=30000)
|
||||
repeat: int = Field(default=1, ge=1, le=3)
|
||||
allow_network: bool = False
|
||||
offline: bool = False
|
||||
|
||||
|
||||
class AgentCaseResult(Contract):
|
||||
case_id: str
|
||||
repeat: int
|
||||
agent_run_id: str | None = None
|
||||
success: bool = False
|
||||
tool_calls: int = 0
|
||||
expected_calls: int = 0
|
||||
selected_calls: int = 0
|
||||
accurate_calls: int = 0
|
||||
invalid_calls: int = 0
|
||||
steps: int = 0
|
||||
latency_ms: float = 0
|
||||
token_usage: int = 0
|
||||
checks: dict[str, bool] = Field(default_factory=dict)
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
class BenchmarkReport(Contract):
|
||||
run_id: str
|
||||
kind: BenchmarkKind
|
||||
@@ -1337,7 +1384,7 @@ class BenchmarkReport(Contract):
|
||||
status: BenchmarkStatus
|
||||
config_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
metrics: dict[str, Any] = Field(default_factory=dict)
|
||||
cases: list[RAGCaseResult] = Field(default_factory=list)
|
||||
cases: list[RAGCaseResult | AgentCaseResult] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
error_code: str | None = None
|
||||
|
||||
@@ -1366,6 +1413,7 @@ class ExportSource(Contract):
|
||||
"""导出源:note 引用已索引笔记,markdown 用于未保存预览(不持久化)。"""
|
||||
|
||||
type: ExportSourceType
|
||||
file_path: str | None = Field(default=None, max_length=1024)
|
||||
note_id: str | None = None
|
||||
markdown: str | None = None
|
||||
|
||||
@@ -1386,7 +1434,15 @@ class ExportOptions(Contract):
|
||||
code_theme: str = "github-light"
|
||||
|
||||
|
||||
class ExportAsset(Contract):
|
||||
kind: Literal['mermaid', 'math_block', 'math_inline', 'image']
|
||||
source_hash: str = Field(pattern=r'^[a-f0-9]{64}$')
|
||||
png_base64: str = Field(max_length=2800000)
|
||||
|
||||
|
||||
class ExportRequest(Contract):
|
||||
assets: list[ExportAsset] = Field(default_factory=list, max_length=64)
|
||||
title: str = Field(default="", max_length=200)
|
||||
source: ExportSource
|
||||
format: ExportFormat
|
||||
options: ExportOptions = Field(default_factory=ExportOptions)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Bounded raster-only resource boundary. No URLs, XML or filesystem paths accepted."""
|
||||
import base64
|
||||
import hashlib
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from app.errors import ApiError
|
||||
|
||||
_math_lock = threading.Lock()
|
||||
|
||||
def enrich_document(document, file_path=None):
|
||||
"""Embed local vault images and bounded MathText. Unsupported TeX stays explicit."""
|
||||
from app.config import get_settings
|
||||
from urllib.parse import unquote, urlsplit
|
||||
vault = get_settings().vault_path.resolve()
|
||||
base = (vault / (file_path or '')).parent if file_path else vault
|
||||
warnings = []
|
||||
count = total = pixels = 0
|
||||
def visit(node):
|
||||
nonlocal count, total, pixels
|
||||
if node.type in {'image','math_block','math_inline'} or node.attributes.get('static_png'):
|
||||
count += 1
|
||||
try:
|
||||
if count > 64: raise ValueError('resource count')
|
||||
if node.attributes.get('static_png'):
|
||||
raw = node.attributes['static_png']
|
||||
elif node.type == 'image':
|
||||
src = str(node.attributes.get('src',''))
|
||||
if urlsplit(src).scheme or src.startswith('//'): raise ValueError('remote image')
|
||||
path = (base / unquote(src)).resolve()
|
||||
if not path.is_relative_to(vault) or path.suffix.lower() not in {'.png','.jpg','.jpeg','.webp'} or path.stat().st_size > 2_000_000:
|
||||
raise ValueError('image path or budget')
|
||||
raw = path.read_bytes()
|
||||
else:
|
||||
source = node.text
|
||||
depth = 0
|
||||
for char in source:
|
||||
depth += (char == '{') - (char == '}')
|
||||
if depth > 20: raise ValueError('math depth')
|
||||
if len(source) > 512 or depth != 0: raise ValueError('math budget')
|
||||
from matplotlib.mathtext import math_to_image
|
||||
with _math_lock:
|
||||
out = BytesIO()
|
||||
math_to_image('$'+source+'$', out, dpi=180, format='png', color='black')
|
||||
raw = out.getvalue()
|
||||
with Image.open(BytesIO(raw)) as image:
|
||||
pixels += image.width * image.height
|
||||
if pixels > 16_000_000: raise ValueError('document pixels')
|
||||
if image.width * image.height > 4_000_000: raise ValueError('image dimensions')
|
||||
out = BytesIO()
|
||||
# Flatten alpha on white for portable print/Word output.
|
||||
rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,'white')
|
||||
background.alpha_composite(rgba); background.convert('RGB').save(out,'PNG')
|
||||
png=out.getvalue();total += len(png)
|
||||
if total > 8_000_000: raise ValueError('resource bytes')
|
||||
node.attributes['static_png']=png
|
||||
except Exception:
|
||||
node.attributes.pop('static_png', None)
|
||||
warnings.append('图片无法内嵌(仅支持 Vault 内 PNG/JPEG/WebP),已保留替代文字' if node.type=='image'
|
||||
else '公式超出 MathText 语法或资源预算,已保留源码' if node.type.startswith('math')
|
||||
else '静态图表超过文档资源预算,已保留源码')
|
||||
for child in node.children: visit(child)
|
||||
for child in document.children: visit(child)
|
||||
return warnings
|
||||
|
||||
def source_hash(source):
|
||||
return hashlib.sha256(source.strip().encode()).hexdigest()
|
||||
|
||||
def validate_assets(assets):
|
||||
result = {}
|
||||
total = pixels = 0
|
||||
for asset in assets:
|
||||
try:
|
||||
raw = base64.b64decode(asset.png_base64, validate=True)
|
||||
total += len(raw)
|
||||
if total > 8 * 1024 * 1024:
|
||||
raise ValueError('asset budget')
|
||||
with Image.open(BytesIO(raw)) as image:
|
||||
pixels += image.width * image.height
|
||||
if pixels > 16_000_000: raise ValueError('document pixel budget')
|
||||
if image.format != 'PNG' or image.width * image.height > 4_000_000:
|
||||
raise ValueError('image budget')
|
||||
image.load()
|
||||
out = BytesIO()
|
||||
rgba = image.convert('RGBA')
|
||||
background = Image.new('RGBA', rgba.size, 'white')
|
||||
background.alpha_composite(rgba)
|
||||
background.convert('RGB').save(out, 'PNG')
|
||||
key = (asset.kind, asset.source_hash)
|
||||
if key in result:
|
||||
raise ValueError('duplicate asset')
|
||||
result[key] = out.getvalue()
|
||||
except Exception as exc:
|
||||
raise ApiError(422, 'EXPORT_ASSET_INVALID', 'Invalid PNG or resource budget exceeded.') from exc
|
||||
return result
|
||||
|
||||
def attach_assets(document, assets):
|
||||
def visit(node):
|
||||
source = node.attributes.get('src', '') if node.type == 'image' else node.text
|
||||
key = (node.type, source_hash(source))
|
||||
if key in assets:
|
||||
node.attributes['static_png'] = assets[key]
|
||||
for child in node.children:
|
||||
visit(child)
|
||||
for child in document.children:
|
||||
visit(child)
|
||||
|
||||
def plot_png(plot):
|
||||
"""DOCX consumes the same clipped geometry as SVG/PDF, rendered at 2x."""
|
||||
from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
|
||||
from PIL import ImageDraw, ImageFont
|
||||
geo = compute_geometry(plot)
|
||||
image = Image.new('RGB', (geo.width * 2, (geo.height + ((len(plot.expressions)+1)//2)*24) * 2), 'white')
|
||||
draw = ImageDraw.Draw(image)
|
||||
from app.export.fonts import FONT_PATH
|
||||
font = ImageFont.truetype(str(FONT_PATH), 20) if FONT_PATH else ImageFont.load_default(size=20)
|
||||
def line(points, color, width=2):
|
||||
draw.line([(x * 2, y * 2) for x, y in points], fill=color, width=width)
|
||||
sx = lambda x: _sx(x, geo.xmin, geo.xmax)
|
||||
sy = lambda y: _sy(y, geo.ymin, geo.ymax)
|
||||
for x in geo.xticks:
|
||||
if geo.grid: line([(sx(x),52),(sx(x),428)], '#d0d7de')
|
||||
draw.text((sx(x)*2, sy(geo.x_axis_y)*2+8), _fmt_num(x), fill='#57606a', font=font)
|
||||
for y in geo.yticks:
|
||||
if geo.grid: line([(52,sy(y)),(588,sy(y))], '#d0d7de')
|
||||
draw.text((max(0,sx(geo.y_axis_x)*2-75),sy(y)*2), _fmt_num(y), fill='#57606a', font=font)
|
||||
line([(52,sy(geo.x_axis_y)),(588,sy(geo.x_axis_y))], '#57606a')
|
||||
line([(sx(geo.y_axis_x),52),(sx(geo.y_axis_x),428)], '#57606a')
|
||||
for segments, color in zip(geo.polylines,geo.colors):
|
||||
for segment in segments:
|
||||
if len(segment)>1: line(segment,color,3)
|
||||
if geo.xlabel:
|
||||
draw.text((geo.width, (geo.height - 18)*2), geo.xlabel, fill='#1f2328', font=font, anchor='mm')
|
||||
if geo.ylabel:
|
||||
# Horizontal at the upper-left margin keeps CJK labels readable in Word.
|
||||
draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
|
||||
for index, expression in enumerate(plot.expressions):
|
||||
draw.text((48+(index%2)*620,geo.height*2+index//2*48),expression.label or 'y = '+expression.expression,fill=geo.colors[index],font=font)
|
||||
out=BytesIO(); image.save(out,'PNG')
|
||||
return out.getvalue(), geo.warnings
|
||||
@@ -1,7 +1,7 @@
|
||||
"""DocxExporter:Document AST → DOCX(python-docx)。
|
||||
|
||||
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
|
||||
function_plot 与 mermaid 保留源码占位并记 warning。中文字体通过 Normal 样式挂载
|
||||
标题、段落、列表、表格等使用原生 Word 元素;函数图、已准备的 Mermaid、
|
||||
受支持的公式与 Vault 图片使用静态图片,无法表示的资源保留源码并记 warning。中文字体通过 Normal 样式挂载
|
||||
w:eastAsia=宋体,保证 Word 打开时中文正常显示;bold/italic 由 Word 原生渲染。
|
||||
"""
|
||||
|
||||
@@ -50,6 +50,8 @@ class DocxExporter:
|
||||
|
||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
from app.export.exporters._common import FunctionPlotBudget
|
||||
self._plot_budget = FunctionPlotBudget()
|
||||
self._doc = DocxDocument()
|
||||
self._configure_normal_style()
|
||||
self._configure_page(options)
|
||||
@@ -109,6 +111,13 @@ class DocxExporter:
|
||||
self._render_block(child, warnings)
|
||||
|
||||
def _render_block(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
if node.attributes.get('static_png'):
|
||||
from PIL import Image
|
||||
png = node.attributes['static_png']
|
||||
with Image.open(BytesIO(png)) as image:
|
||||
width = min(5.8, image.width / (180 if node.type == 'math_block' else 96))
|
||||
self._doc.add_picture(BytesIO(png), width=Inches(width))
|
||||
return
|
||||
handler = getattr(self, f"_block_{node.type}", None)
|
||||
if handler is not None:
|
||||
handler(node, warnings)
|
||||
@@ -270,7 +279,20 @@ class DocxExporter:
|
||||
self._block_code_block(node, warnings)
|
||||
|
||||
def _block_function_plot(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
warnings.append(PLOT_PLACEHOLDER_WARNING)
|
||||
from app.plot.parser import parse_source
|
||||
from app.export.assets import plot_png
|
||||
over = self._plot_budget.check_count()
|
||||
if not over:
|
||||
parsed = parse_source(node.text)
|
||||
warnings.extend(d.message for d in parsed.diagnostics)
|
||||
if parsed.plot:
|
||||
over = self._plot_budget.check_nodes(parsed.plot.node_count)
|
||||
if not over:
|
||||
png, messages = plot_png(parsed.plot)
|
||||
warnings.extend(messages)
|
||||
self._doc.add_picture(BytesIO(png), width=Inches(5.8))
|
||||
return
|
||||
warnings.append(over or '函数图像无法绘制,已保留源码')
|
||||
self._block_code_block(node, warnings)
|
||||
|
||||
def _block_math_block(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
@@ -303,6 +325,12 @@ class DocxExporter:
|
||||
bold: bool = False,
|
||||
italic: bool = False,
|
||||
) -> None:
|
||||
if node.attributes.get('static_png'):
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(node.attributes['static_png'])) as image:
|
||||
width = min(5.8, image.width / (180 if node.type.startswith('math') else 96))
|
||||
paragraph.add_run().add_picture(BytesIO(node.attributes['static_png']), width=Inches(width))
|
||||
return
|
||||
t = node.type
|
||||
if t == "text":
|
||||
self._add_run(paragraph, node.text, bold=bold, italic=italic)
|
||||
|
||||
@@ -151,6 +151,16 @@ class HtmlExporter:
|
||||
return "".join(self._render_node(child, warnings) for child in children)
|
||||
|
||||
def _render_node(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
if node.attributes.get('static_png'):
|
||||
import base64
|
||||
data = base64.b64encode(node.attributes['static_png']).decode()
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
width = ''
|
||||
if node.type.startswith('math'):
|
||||
with Image.open(BytesIO(node.attributes['static_png'])) as image:
|
||||
width = f'width:{image.width*96/180:.1f}px;vertical-align:middle;'
|
||||
return f'<img alt="{html.escape(node.text or node.type)}" src="data:image/png;base64,{data}" style="{width}max-width:100%">'
|
||||
handler = getattr(self, f"_render_{node.type}", None)
|
||||
if handler is not None:
|
||||
return handler(node, warnings)
|
||||
@@ -257,6 +267,8 @@ class HtmlExporter:
|
||||
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
warnings.extend(rendered.warnings)
|
||||
from app.plot.render import theme_svg
|
||||
rendered.content = theme_svg(rendered.content, self._options.theme_id)
|
||||
return f'<figure class="function-plot">{rendered.content}</figure>'
|
||||
|
||||
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
|
||||
@@ -42,8 +42,7 @@ from app.export.exporters._common import (
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
|
||||
|
||||
_FONT = "STSong-Light"
|
||||
pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
|
||||
from app.export.fonts import FONT as _FONT
|
||||
|
||||
_MIME = "application/pdf"
|
||||
|
||||
@@ -122,6 +121,7 @@ class PdfExporter:
|
||||
self._styles = _make_styles()
|
||||
warnings: list[str] = []
|
||||
print_theme_warning(options, warnings, "PDF")
|
||||
if _FONT == "STSong-Light": warnings.append("PDF 使用 CID 字体,阅读器需提供中文字体;可配置 APP_EXPORT_FONT 嵌入 TrueType 字体")
|
||||
|
||||
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
|
||||
self._options = options
|
||||
@@ -169,6 +169,14 @@ class PdfExporter:
|
||||
self._render_block(child, story, warnings)
|
||||
|
||||
def _render_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
if node.attributes.get('static_png'):
|
||||
from reportlab.platypus import Image
|
||||
image = Image(BytesIO(node.attributes['static_png']))
|
||||
scale = min(1, self._plot_width / image.imageWidth, 600 / image.imageHeight)
|
||||
image.drawWidth = image.imageWidth * scale
|
||||
image.drawHeight = image.imageHeight * scale
|
||||
story.append(image)
|
||||
return
|
||||
handler = getattr(self, f"_block_{node.type}", None)
|
||||
if handler is not None:
|
||||
handler(node, story, warnings)
|
||||
@@ -362,6 +370,15 @@ class PdfExporter:
|
||||
return "".join(self._render_inline_node(child, warnings) for child in children)
|
||||
|
||||
def _render_inline_node(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
if node.attributes.get('static_png'):
|
||||
import base64
|
||||
from PIL import Image as PILImage
|
||||
raw = node.attributes['static_png']
|
||||
with PILImage.open(BytesIO(raw)) as image:
|
||||
scale = min(.4 if node.type.startswith('math') else 1, 350/image.width, 160/image.height)
|
||||
width, height = image.width*scale, image.height*scale
|
||||
data = base64.b64encode(raw).decode()
|
||||
return f'<img src="data:image/png;base64,{data}" width="{width}" height="{height}" valign="middle"/>'
|
||||
t = node.type
|
||||
if t == "text":
|
||||
return _html.escape(node.text)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Embed an available CJK TrueType font; retain the portable CID fallback."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
|
||||
def register_font():
|
||||
candidates = [os.getenv('APP_EXPORT_FONT',''),
|
||||
str(Path(os.getenv('WINDIR','C:/Windows'))/'Fonts/simsun.ttc'),
|
||||
'/usr/share/fonts/truetype/arphic/uming.ttc']
|
||||
for candidate in candidates:
|
||||
if candidate and Path(candidate).is_file():
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont('NotesExportCJK',candidate,subfontIndex=0))
|
||||
return 'NotesExportCJK', Path(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light'))
|
||||
return 'STSong-Light', None
|
||||
|
||||
FONT, FONT_PATH = register_font()
|
||||
@@ -182,12 +182,15 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
||||
f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters",
|
||||
{"size": len(markdown), "limit": MAX_MARKDOWN_CHARS},
|
||||
)
|
||||
return markdown, "", None
|
||||
return markdown, "", {"file_path": source.file_path} if source.file_path else None
|
||||
|
||||
|
||||
async def create_export(request: ExportRequest) -> ExportJob:
|
||||
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
|
||||
markdown, title, metadata = await _resolve_source(request.source)
|
||||
title = request.title or title
|
||||
from app.export.assets import validate_assets
|
||||
assets = await asyncio.to_thread(validate_assets, request.assets)
|
||||
|
||||
if not _evict_terminal():
|
||||
raise ApiError(
|
||||
@@ -207,7 +210,7 @@ async def create_export(request: ExportRequest) -> ExportJob:
|
||||
_jobs[job_id] = job
|
||||
_cancel_flags[job_id] = asyncio.Event()
|
||||
_tasks[job_id] = asyncio.create_task(
|
||||
_execute(job_id, request.format, markdown, title, metadata, request.options)
|
||||
_execute(job_id, request.format, markdown, title, metadata, request.options, assets)
|
||||
)
|
||||
return job
|
||||
|
||||
@@ -245,6 +248,7 @@ async def _execute(
|
||||
title: str,
|
||||
metadata: dict | None,
|
||||
options: ExportOptions,
|
||||
assets: dict | None = None,
|
||||
) -> None:
|
||||
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
|
||||
cancel_event = _cancel_flags[job_id]
|
||||
@@ -273,10 +277,15 @@ async def _execute(
|
||||
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
|
||||
document = await asyncio.to_thread(parse_document, markdown)
|
||||
document.attributes["title"] = title
|
||||
from app.export.assets import attach_assets
|
||||
attach_assets(document, assets or {})
|
||||
if metadata:
|
||||
document.attributes["metadata"] = metadata
|
||||
|
||||
from app.export.assets import enrich_document
|
||||
resource_warnings = await asyncio.to_thread(enrich_document, document, (metadata or {}).get('file_path'))
|
||||
result = await asyncio.to_thread(_render_document, document, options, format)
|
||||
result.warnings[:0] = resource_warnings
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
if len(result.content) > MAX_EXPORT_BYTES:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Export palettes are fixed data; arbitrary theme CSS is never executed."""
|
||||
PALETTES = {
|
||||
'ocean-blue': ('#edf5fa','#ffffff','#183a50','#46667a','#e6f1f8','#a6c5d9','#086b9c'),
|
||||
'light': ('#f6f7f9','#ffffff','#1f2328','#57606a','#eaeef2','#d0d7de','#0969da'),
|
||||
'dark': ('#010409','#0d1117','#e6edf3','#b1bac4','#21262d','#57606a','#79c0ff'),
|
||||
'sepia': ('#eee5d2','#faf4e6','#463b2d','#6b5943','#eae0cd','#b5a58b','#80532a'),
|
||||
|
||||
@@ -5,6 +5,8 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from contextlib import closing
|
||||
from contextvars import ContextVar
|
||||
from functools import wraps
|
||||
@@ -239,6 +241,12 @@ class Runtime:
|
||||
|
||||
runtime = Runtime()
|
||||
|
||||
# Bounded in-memory reuse of deterministic single-text local embeddings. The key
|
||||
# includes the data/model location, immutable model revision and frozen runtime
|
||||
# configuration. No remote API response or unavailable-model fallback is cached.
|
||||
_embedding_cache = OrderedDict()
|
||||
_EMBEDDING_CACHE_TTL = 600
|
||||
|
||||
|
||||
class LocalEmbedding:
|
||||
dim = 384
|
||||
@@ -264,9 +272,24 @@ class LocalEmbedding:
|
||||
|
||||
async def embed_documents(self, texts):
|
||||
config = (self._config or configuration()).model_copy(deep=True)
|
||||
from app.retrieval.provenance import record_embedding
|
||||
cache_key = None
|
||||
if len(texts) == 1 and read_state(config.embedding_model)['status'] == 'installed' and interpreter(config).is_file():
|
||||
cache_key = (str(model_path(config.embedding_model).resolve()), config.model_dump_json(),
|
||||
hashlib.sha256(texts[0].encode()).hexdigest())
|
||||
cached = _embedding_cache.get(cache_key)
|
||||
if cached and time.monotonic() - cached[0] < _EMBEDDING_CACHE_TTL:
|
||||
_embedding_cache.move_to_end(cache_key)
|
||||
record_embedding(query_embedding_cache='hit')
|
||||
return [list(cached[1])]
|
||||
record_embedding(query_embedding_cache='miss')
|
||||
token = runtime_context.set(config)
|
||||
try:
|
||||
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
|
||||
vectors = await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
|
||||
if cache_key and len(vectors) == 1:
|
||||
_embedding_cache[cache_key] = (time.monotonic(), tuple(vectors[0]))
|
||||
while len(_embedding_cache) > 128: _embedding_cache.popitem(last=False)
|
||||
return vectors
|
||||
finally:
|
||||
runtime_context.reset(token)
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ async def lifespan(_: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
from app.benchmarks import service as benchmark_service
|
||||
await benchmark_service.shutdown()
|
||||
await container.agent.shutdown()
|
||||
from app.services import index_service
|
||||
await index_service.shutdown()
|
||||
@@ -75,6 +77,8 @@ app.include_router(local_model_router)
|
||||
app.include_router(usage_router)
|
||||
app.include_router(provider_preview_router)
|
||||
app.include_router(log_router)
|
||||
from app.plot_routes import router as plot_router
|
||||
app.include_router(plot_router)
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Function Plot 内部数据模型。
|
||||
|
||||
契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部
|
||||
流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py。
|
||||
FunctionPlot 供预览和导出共享;StaticRenderResult 同时是交互预览端点的响应内容。
|
||||
模型保留在独立包内,由 plot_routes 中的请求与响应类型注册 OpenAPI。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
+31
-10
@@ -413,12 +413,12 @@ def _grid_svg(geo: PlotGeometry) -> str:
|
||||
for x in geo.xticks:
|
||||
parts.append(
|
||||
f'<line x1="{sx(x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(x):.2f}" '
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2"/>'
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2" class="plot-grid"/>'
|
||||
)
|
||||
for y in geo.yticks:
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(geo.xmax):.2f}" '
|
||||
f'y2="{sy(y):.2f}" stroke="#eaeef2"/>'
|
||||
f'y2="{sy(y):.2f}" stroke="#eaeef2" class="plot-grid"/>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
@@ -430,11 +430,11 @@ def _axes_svg(geo: PlotGeometry) -> str:
|
||||
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.xmin):.2f}" y1="{sy(geo.x_axis_y):.2f}" x2="{sx(geo.xmax):.2f}" '
|
||||
f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a"/>'
|
||||
f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a" class="plot-axis"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.y_axis_x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(geo.y_axis_x):.2f}" '
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#57606a"/>'
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#57606a" class="plot-axis"/>'
|
||||
)
|
||||
# x 轴刻度数字(画在轴下方)
|
||||
for x in geo.xticks:
|
||||
@@ -453,10 +453,10 @@ def _axes_svg(geo: PlotGeometry) -> str:
|
||||
|
||||
def _polylines_svg(geo: PlotGeometry) -> str:
|
||||
parts: list[str] = []
|
||||
for segments, color in zip(geo.polylines, geo.colors):
|
||||
for index, (segments, color) in enumerate(zip(geo.polylines, geo.colors)):
|
||||
for seg in segments:
|
||||
points = " ".join(f"{px:.2f},{py:.2f}" for px, py in seg)
|
||||
parts.append(f'<polyline points="{points}" fill="none" stroke="{color}"/>')
|
||||
parts.append(f'<polyline points="{points}" fill="none" stroke="{color}" class="plot-curve-{index % 6}"/>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@@ -476,22 +476,43 @@ def _labels_svg(geo: PlotGeometry) -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def render_svg(plot: FunctionPlot) -> StaticRenderResult:
|
||||
def render_svg(plot: FunctionPlot, theme_id: str = 'light') -> StaticRenderResult:
|
||||
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
|
||||
geo = compute_geometry(plot)
|
||||
legend_height = ((len(plot.expressions) + 1) // 2) * 24
|
||||
height = geo.height + legend_height
|
||||
parts: list[str] = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {geo.width} {geo.height}" role="img">'
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {geo.width} {height}" role="img" class="function-plot-svg">'
|
||||
]
|
||||
if geo.grid:
|
||||
parts.append(_grid_svg(geo))
|
||||
parts.append(_axes_svg(geo))
|
||||
parts.append(_polylines_svg(geo))
|
||||
parts.append(_labels_svg(geo))
|
||||
for index, expression in enumerate(plot.expressions):
|
||||
x = 24 + (index % 2) * 310
|
||||
y = geo.height + 18 + (index // 2) * 24
|
||||
label = html.escape(expression.label or ('y = ' + expression.expression))
|
||||
parts.append(f'<text x="{x}" y="{y}" font-size="12" fill="{geo.colors[index]}" class="plot-legend-{index % 6}">{label}</text>')
|
||||
parts.append("</svg>")
|
||||
|
||||
return StaticRenderResult(
|
||||
content="".join(parts),
|
||||
content=theme_svg("".join(parts), theme_id),
|
||||
width=geo.width,
|
||||
height=geo.height,
|
||||
height=height,
|
||||
warnings=geo.warnings,
|
||||
)
|
||||
|
||||
|
||||
def theme_svg(svg: str, theme_id: str) -> str:
|
||||
from app.export.themes import PALETTES
|
||||
palette = PALETTES.get(theme_id, PALETTES['light'])
|
||||
for source, target in [('#eaeef2', palette[5]), ('#57606a', palette[3]), ('#1f2328', palette[2])]:
|
||||
svg = svg.replace(source, target)
|
||||
if theme_id in {'dark', 'midnight-purple'}:
|
||||
for source, target in zip(_PALETTE, ['#79c0ff','#ff9b9b','#7ee787','#d2a8ff','#f2cc60','#ffa657']):
|
||||
svg = svg.replace(source, target)
|
||||
background = '<rect width="100%" height="100%" fill="' + palette[1] + '"/>'
|
||||
if re.search(r'<rect width="100%" height="100%" fill="[^"]*"/>', svg):
|
||||
return re.sub(r'<rect width="100%" height="100%" fill="[^"]*"/>', background, svg, count=1)
|
||||
return svg.replace('role="img" class="function-plot-svg">', 'role="img" class="function-plot-svg">' + background)
|
||||
|
||||
@@ -17,9 +17,7 @@ from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
from app.plot.model import FunctionPlot
|
||||
from app.plot.render import PlotGeometry, _fmt_num, _sx, _sy, compute_geometry
|
||||
|
||||
_FONT = "STSong-Light"
|
||||
if _FONT not in pdfmetrics.getRegisteredFontNames():
|
||||
pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
|
||||
from app.export.fonts import FONT as _FONT
|
||||
|
||||
_GRID_COLOR = HexColor("#eaeef2")
|
||||
_AXIS_COLOR = HexColor("#57606a")
|
||||
@@ -115,6 +113,11 @@ def render_drawing(plot: FunctionPlot, width: float | None = None) -> Drawing:
|
||||
"""
|
||||
geo = compute_geometry(plot)
|
||||
drawing = _build_drawing(geo)
|
||||
legend_height = ((len(plot.expressions)+1)//2)*24
|
||||
drawing.height += legend_height
|
||||
for index, expression in enumerate(plot.expressions):
|
||||
drawing.add(String(24+(index%2)*310,geo.height+legend_height-18-(index//2)*24,
|
||||
expression.label or 'y = '+expression.expression,fontName=_FONT,fontSize=12,fillColor=HexColor(geo.colors[index])))
|
||||
if width is not None and width > 0:
|
||||
drawing.renderScale = min(1.0, width / geo.width)
|
||||
return drawing
|
||||
|
||||
@@ -47,7 +47,7 @@ class FunctionPlotStaticRenderer:
|
||||
parsed = self.parse(request)
|
||||
if parsed.plot is None:
|
||||
raise ValueError("function-plot source has no valid plot")
|
||||
return self.render_plot(parsed.plot)
|
||||
return render_svg(parsed.plot, request.theme or 'light')
|
||||
|
||||
def render_plot(self, plot: FunctionPlot) -> StaticRenderResult:
|
||||
return render_svg(plot)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Interactive previews use the same bounded parser and geometry as exports."""
|
||||
import asyncio
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from app.plot.parser import parse_source
|
||||
from app.plot.render import render_svg
|
||||
from app.plot.model import PlotDiagnostic, StaticRenderResult
|
||||
|
||||
router = APIRouter(prefix='/api/plots', tags=['Function Plot'])
|
||||
_slots = asyncio.Semaphore(2)
|
||||
|
||||
class PlotRequest(BaseModel):
|
||||
source: str = Field(max_length=20000)
|
||||
theme_id: str = Field(default='light', max_length=100)
|
||||
|
||||
class PlotResponse(BaseModel):
|
||||
result: StaticRenderResult | None = None
|
||||
diagnostics: list[PlotDiagnostic] = Field(default_factory=list)
|
||||
node_count: int = 0
|
||||
|
||||
def preview(request):
|
||||
parsed = parse_source(request.source)
|
||||
if parsed.plot is None:
|
||||
return PlotResponse(diagnostics=parsed.diagnostics)
|
||||
if parsed.plot.node_count > 8000:
|
||||
return PlotResponse(node_count=parsed.plot.node_count, diagnostics=[PlotDiagnostic(
|
||||
severity='error', code='PLOT_BUDGET_EXCEEDED', message='图表累计表达式节点超过 8000 上限')])
|
||||
return PlotResponse(result=render_svg(parsed.plot, request.theme_id),
|
||||
diagnostics=parsed.diagnostics, node_count=parsed.plot.node_count)
|
||||
|
||||
@router.post('/function', response_model=PlotResponse)
|
||||
async def render_function(request: PlotRequest):
|
||||
async with _slots:
|
||||
return await asyncio.to_thread(preview, request)
|
||||
@@ -114,7 +114,13 @@ class RetrievalEngine:
|
||||
elif request.mode == SearchMode.vector:
|
||||
candidate_scores = vec_scores
|
||||
else: # hybrid:RRF 融合
|
||||
candidate_scores = rrf_fuse([fts_ranked, vec_ranked], k=request.rrf_k)
|
||||
if request.fusion == 'weighted':
|
||||
fts_normal = dict(normalize_scores(list(fts_scores.items())))
|
||||
vec_normal = dict(normalize_scores(list(vec_scores.items())))
|
||||
candidate_scores = {bid: .5 * fts_normal.get(bid, 0) + .5 * vec_normal.get(bid, 0)
|
||||
for bid in dict.fromkeys(fts_ranked + vec_ranked)}
|
||||
else:
|
||||
candidate_scores = rrf_fuse([fts_ranked, vec_ranked], k=request.rrf_k)
|
||||
|
||||
if not candidate_scores:
|
||||
return self._empty(request)
|
||||
|
||||
+12
-4
@@ -562,7 +562,7 @@ async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun:
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def get_agent_run(run_id: str) -> AgentRun:
|
||||
return agent_run_or_404(run_id)
|
||||
return await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -571,7 +571,7 @@ async def get_agent_run(run_id: str) -> AgentRun:
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def cancel_agent_run(run_id: str) -> OperationResponse:
|
||||
agent_run_or_404(run_id)
|
||||
await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
run = await container.agent.cancel(run_id)
|
||||
return OperationResponse(
|
||||
status="completed",
|
||||
@@ -596,7 +596,7 @@ async def agent_events(
|
||||
after_sequence: int | None = Query(default=None, ge=-1),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
) -> StreamingResponse:
|
||||
agent_run_or_404(run_id)
|
||||
await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
cursor = after_sequence
|
||||
if cursor is None and last_event_id is not None:
|
||||
try:
|
||||
@@ -658,7 +658,7 @@ async def get_agent_trace(
|
||||
async def decide_agent_permission(
|
||||
run_id: str, request_id: str, request: PermissionDecisionRequest
|
||||
) -> OperationResponse:
|
||||
agent_run_or_404(run_id)
|
||||
await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
if not await container.agent.resolve_permission(run_id, request_id, request.decision):
|
||||
raise ApiError(
|
||||
404,
|
||||
@@ -1622,3 +1622,11 @@ async def get_global_persona():
|
||||
@router.put("/settings/persona", response_model=PersonaSettings, tags=["Settings"])
|
||||
async def put_global_persona(request: PersonaSettings):
|
||||
return save_persona(request)
|
||||
|
||||
|
||||
from app.contracts import AgentBenchmarkRequest
|
||||
from app.benchmarks import agent as agent_benchmark
|
||||
|
||||
@router.post('/benchmarks/agent/runs', response_model=BenchmarkRun, status_code=202, tags=['Benchmark'])
|
||||
async def create_agent_benchmark(request: AgentBenchmarkRequest):
|
||||
return await agent_benchmark.create_run(request)
|
||||
|
||||
Reference in New Issue
Block a user