Merge remote-tracking branch 'origin/main' into feat/export-service
# Conflicts: # backend/app/routes.py
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from app.acceptance import score
|
||||
|
||||
|
||||
def segment(text, speaker='A', start=0, end=1):
|
||||
return dict(text=text, speaker=speaker, start=start, end=end)
|
||||
|
||||
|
||||
def test_exact_and_renamed_speakers():
|
||||
result = score([segment('你好 世界')], [segment('你好 世界', 'cluster_4')])
|
||||
assert result['text']['cer']['rate'] == 0
|
||||
assert result['speaker']['der'] == 0
|
||||
assert result['quality_gate'] == 'not_evaluated'
|
||||
|
||||
|
||||
def test_edits_missed_and_false_alarms():
|
||||
result = score([segment('a b')], [segment('a c', start=0, end=2)])
|
||||
assert result['text']['wer']['rate'] == 0.5
|
||||
assert result['speaker']['false_alarm_seconds'] == 1
|
||||
result = score([segment('a')], [])
|
||||
assert result['speaker']['der'] == 1
|
||||
|
||||
|
||||
def test_overlap_and_confusion():
|
||||
result = score([segment('a'), segment('b', 'B')], [segment('a')])
|
||||
assert result['speaker']['der'] == 0.5
|
||||
result = score([segment('a'), segment('b','B',1,2)], [segment('a','X',0,2)])
|
||||
assert result['speaker']['confusion_seconds'] == 1
|
||||
|
||||
|
||||
def test_requires_reference_and_valid_timing():
|
||||
with pytest.raises(ValueError): score([], [])
|
||||
with pytest.raises(ValueError): score([segment('a', end=float('nan'))], [])
|
||||
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import BACKEND_DIR
|
||||
from app.container import build_container
|
||||
from app.contracts import ModelCapability, PluginCommandContext, ToolCall
|
||||
from app.agent.tools import ToolExecutionContext
|
||||
from app.extensions.archive import install_zip
|
||||
|
||||
ROOT = BACKEND_DIR / 'extensions/community'
|
||||
|
||||
|
||||
def load(path):
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_analysis_ignores_metadata_and_code_and_keeps_line_numbers():
|
||||
server = load(ROOT / 'plugins/markdown-workbench/server.py')
|
||||
sample = (ROOT / 'plugins/markdown-workbench/example.md').read_text(encoding='utf-8')
|
||||
report = server.inspect_markdown(sample)
|
||||
assert report['summary']['headings'] == 3
|
||||
assert report['summary']['tasks'] == 2
|
||||
assert report['summary']['open_tasks'] == 1
|
||||
assert [(item['line'], item['code']) for item in report['issues']] == [(7, 'heading_jump'), (11, 'duplicate_heading')]
|
||||
assert report['tasks'][0]['line'] == 8
|
||||
assert server.inspect_markdown('Title\n===\n\nSubtitle\n---')['summary']['headings'] == 2
|
||||
assert server.inspect_markdown('```\n# code')['issues'][0]['code'] == 'unclosed_fence'
|
||||
with pytest.raises(ValueError):
|
||||
server.inspect_markdown('x' * 100001)
|
||||
many = server.inspect_markdown('\n'.join('- [ ] task' for _ in range(205)))
|
||||
assert many['truncated'] and many['summary']['tasks'] == 205 and len(many['tasks']) == 200
|
||||
|
||||
|
||||
def test_zip_install_real_mcp_tool_command_and_skill(tmp_path):
|
||||
builder = load(ROOT / 'build_packages.py')
|
||||
output = tmp_path / 'dist'
|
||||
catalog = builder.build(output)
|
||||
assert builder.build(output) == catalog
|
||||
runtime = build_container()
|
||||
sample = (ROOT / 'plugins/markdown-workbench/example.md').read_text(encoding='utf-8')
|
||||
async def run():
|
||||
plugin = install_zip((output / 'markdown-workbench-1.0.0.zip').read_bytes(), 'plugin', tmp_path / 'installed', runtime.plugins.install)
|
||||
assert not plugin.enabled
|
||||
skill = install_zip((output / 'note-reviewer-1.0.0.zip').read_bytes(), 'skill', tmp_path / 'installed', runtime.skills.install)
|
||||
assert 'markdown-workbench.inspect_markdown' in skill.missing_dependencies
|
||||
assert runtime.plugins.enable('markdown-workbench').status == 'ready'
|
||||
result = await runtime.tools.execute(ToolCall(tool_call_id='community-test', name='markdown-workbench.inspect_markdown', arguments={'text': sample}), ToolExecutionContext(run_id='community-test'))
|
||||
assert result.success, result.error_message
|
||||
assert result.output['summary']['issues'] == 2
|
||||
command = await runtime.plugins.execute_command('markdown-workbench.inspect-selection', {}, PluginCommandContext(selection=sample))
|
||||
assert '1 项未完成任务' in command.effect.payload.message
|
||||
assert runtime.skills.enable('note-reviewer').status == 'ready'
|
||||
config = runtime.skills.build_agent_configuration('note-reviewer', [ModelCapability.chat, ModelCapability.tool_calling])
|
||||
assert 'notes.read' in config.allowed_tools
|
||||
assert '不得改变用户指定的检查范围' in config.system_prompt
|
||||
runtime.plugins.disable('markdown-workbench')
|
||||
assert runtime.skills.get('note-reviewer').status == 'dependency_missing'
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
runtime.plugins.shutdown()
|
||||
@@ -0,0 +1,144 @@
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.contracts import Message, ModelContextPolicy, ModelRequest, ProviderConfig
|
||||
from app.providers.base import ProviderError, ProviderTurn
|
||||
from app.providers.context_budget import prepare_context
|
||||
from app.providers.factory import ProviderFactory
|
||||
|
||||
|
||||
def async_test(fn):
|
||||
@wraps(fn)
|
||||
def run(*args, **kwargs):
|
||||
return asyncio.run(fn(*args, **kwargs))
|
||||
return run
|
||||
|
||||
|
||||
def config(mode="detect", **kwargs):
|
||||
return ProviderConfig(provider_id="p", provider_type="openai_compatible", name="test",
|
||||
context_policies=[ModelContextPolicy(model="test", context_window=8192, output_reserve=512,
|
||||
threshold=0.1, mode=mode, **kwargs)])
|
||||
|
||||
|
||||
def request():
|
||||
return ModelRequest(provider_id="p", model="test", system="Keep this system instruction",
|
||||
messages=[Message(role="user", content="旧文本" * 500), Message(role="assistant", content="历史答复"),
|
||||
Message(role="user", content="继续"), Message(role="assistant", content="近期答复"),
|
||||
Message(role="user", content="最新问题")])
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_threshold_detect_blocks_before_network():
|
||||
complete = AsyncMock()
|
||||
with pytest.raises(ProviderError, match="已达到") as error:
|
||||
await prepare_context(request(), config(), complete)
|
||||
assert error.value.code == "CONTEXT_COMPRESSION_REQUIRED"
|
||||
complete.assert_not_called()
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_compress_preserves_archive_system_and_recent_turns():
|
||||
original = request()
|
||||
copy = original.model_dump()
|
||||
complete = AsyncMock(return_value=ProviderTurn(text="已讨论旧文本。"))
|
||||
prepared = await prepare_context(original, config("compress", prompt="自定义摘要指令"), complete)
|
||||
assert original.model_dump() == copy
|
||||
assert prepared.system == original.system
|
||||
assert prepared.messages[-3:] == original.messages[-3:]
|
||||
assert prepared.max_tokens == 512
|
||||
assert complete.call_args.args[0].system == "自定义摘要指令"
|
||||
assert not complete.call_args.args[0].tools
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_unknown_model_unmodified():
|
||||
original = request().model_copy(update={"model": "other"})
|
||||
complete = AsyncMock()
|
||||
assert await prepare_context(original, config(), complete) is original
|
||||
complete.assert_not_called()
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_single_oversize_turn_is_not_discarded():
|
||||
original = request().model_copy(update={"messages": request().messages[:1]})
|
||||
complete = AsyncMock()
|
||||
with pytest.raises(ProviderError, match="没有可压缩"):
|
||||
await prepare_context(original, config("compress"), complete)
|
||||
complete.assert_not_called()
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_tool_history_is_not_split():
|
||||
original = request()
|
||||
original.messages.insert(2, Message(role="tool", content="result", tool_call_id="call"))
|
||||
complete = AsyncMock()
|
||||
with pytest.raises(ProviderError, match="工具调用历史"):
|
||||
await prepare_context(original, config("compress"), complete)
|
||||
complete.assert_not_called()
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_ineffective_summary_fails_without_mutation():
|
||||
original = request()
|
||||
copy = original.model_dump()
|
||||
with pytest.raises(ProviderError, match="未缩短"):
|
||||
await prepare_context(original, config("compress"), AsyncMock(return_value=ProviderTurn(text="长" * 6000)))
|
||||
assert original.model_dump() == copy
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_override_output_budget_is_counted():
|
||||
settings = config()
|
||||
from app.request_overrides import RequestOverride
|
||||
settings.request_overrides = [RequestOverride(body={"max_completion_tokens": 9000})]
|
||||
with pytest.raises(ProviderError, match="占满"):
|
||||
await prepare_context(request(), settings, AsyncMock())
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_factory_stream_exposes_actionable_error_without_network():
|
||||
adapter = ProviderFactory(None).build(config())
|
||||
events = [event async for event in adapter.stream(request())]
|
||||
assert [e.event.value for e in events] == ["Error", "Done"]
|
||||
assert events[0].data["code"] == "CONTEXT_COMPRESSION_REQUIRED"
|
||||
|
||||
|
||||
def test_invalid_and_duplicate_config_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
ModelContextPolicy(model="test", context_window=1024, output_reserve=1024)
|
||||
settings = config().model_dump()
|
||||
settings["context_policies"] *= 2
|
||||
with pytest.raises(ValidationError, match="同一模型"):
|
||||
ProviderConfig.model_validate(settings)
|
||||
|
||||
|
||||
@async_test
|
||||
async def test_factory_compression_status_and_usage_request_are_separate(monkeypatch):
|
||||
from datetime import datetime, timezone
|
||||
from app.contracts import ModelEvent, ModelEventType
|
||||
from app.services.usage_service import usage_context
|
||||
seen = []
|
||||
|
||||
class Adapter:
|
||||
async def complete(self, req):
|
||||
seen.append((req, usage_context.get()))
|
||||
return ProviderTurn(text="历史摘要。")
|
||||
|
||||
async def stream(self, req):
|
||||
seen.append((req, usage_context.get()))
|
||||
yield ModelEvent(event=ModelEventType.text_delta, timestamp=datetime.now(timezone.utc), data={"text": "回答"})
|
||||
yield ModelEvent(event=ModelEventType.done, timestamp=datetime.now(timezone.utc), data={"status": "completed"})
|
||||
|
||||
factory = ProviderFactory(None)
|
||||
monkeypatch.setattr(factory, "_build", lambda _: Adapter())
|
||||
adapter = factory.build(config("compress"))
|
||||
original = request()
|
||||
events = [event async for event in adapter.stream(original)]
|
||||
assert [e.event.value for e in events] == ["ContextStatus", "TextDelta", "Done"]
|
||||
assert [e.sequence for e in events] == [0, 1, 2]
|
||||
assert seen[0][1]["request_id"] != seen[1][1]["request_id"]
|
||||
assert seen[1][0].messages[-3:] == original.messages[-3:]
|
||||
@@ -0,0 +1,87 @@
|
||||
import asyncio
|
||||
import io
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.errors import ApiError
|
||||
from app.extensions import ExtensionError
|
||||
from app.extensions.archive import install_zip
|
||||
from app.extensions import archive as module
|
||||
|
||||
|
||||
def zipped(files):
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, value in files:
|
||||
if isinstance(name, str) and '\\' in name:
|
||||
entry = zipfile.ZipInfo()
|
||||
entry.filename = name # Keep malicious separators on Windows too.
|
||||
name = entry
|
||||
archive.writestr(name, value)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['skill', 'plugin'])
|
||||
@pytest.mark.parametrize('prefix', ['', 'package/'])
|
||||
def test_install_keeps_package_resources(tmp_path, kind, prefix):
|
||||
data = zipped([(prefix + kind + '.yaml', 'name: test'), (prefix + 'assets/说明.txt', 'hello')])
|
||||
root = install_zip(data, kind, tmp_path, lambda root: root)
|
||||
assert (root / 'assets/说明.txt').read_text() == 'hello'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', ['../outside', '/outside', 'C:/outside', 'a\\b', 'NUL.txt', 'a/../b', 'a./x'])
|
||||
def test_unsafe_paths_rejected_and_cleaned(tmp_path, path):
|
||||
with pytest.raises(ApiError):
|
||||
install_zip(zipped([('skill.yaml', 'name: x'), (path, 'x')]), 'skill', tmp_path, lambda _: pytest.fail('must not install'))
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_links_duplicates_and_size_limits(tmp_path, monkeypatch):
|
||||
link = zipfile.ZipInfo('link')
|
||||
link.create_system = 3
|
||||
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
cases = [zipped([(link, '../outside')]), zipped([('skill.yaml', 'x'), ('SKILL.yaml', 'x')]), b'not a zip']
|
||||
for data in cases:
|
||||
with pytest.raises(ApiError):
|
||||
install_zip(data, 'skill', tmp_path, lambda _: pytest.fail('must not install'))
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
monkeypatch.setattr(module, 'MAX_EXPANDED_BYTES', 3)
|
||||
with pytest.raises(ApiError, match='50 MiB'):
|
||||
install_zip(zipped([('skill.yaml', 'xxxxx')]), 'skill', tmp_path, lambda _: None)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_manifest_validation_failure_preserved_and_cleaned(tmp_path):
|
||||
def reject(_):
|
||||
raise ExtensionError('BAD_MANIFEST', 'invalid manifest')
|
||||
with pytest.raises(ExtensionError, match='invalid manifest'):
|
||||
install_zip(zipped([('plugin.yaml', 'x')]), 'plugin', tmp_path, reject)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
with pytest.raises(ApiError, match='plugin.yaml'):
|
||||
install_zip(zipped([('skill.yaml', 'x')]), 'plugin', tmp_path, reject)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['skill', 'plugin'])
|
||||
def test_upload_route_uses_real_manifest_validation(tmp_path, monkeypatch, kind):
|
||||
from app import routes
|
||||
from app.container import build_container
|
||||
runtime = build_container()
|
||||
monkeypatch.setattr(routes, 'container', runtime)
|
||||
data = zipped([(kind + '.yaml', f'id: zip-example\nname: ZIP example\nversion: 1.0.0\ndescription: test\n')])
|
||||
sent = False
|
||||
async def receive():
|
||||
nonlocal sent
|
||||
assert not sent
|
||||
sent = True
|
||||
return {'type': 'http.request', 'body': data, 'more_body': False}
|
||||
request = Request({'type': 'http', 'method': 'POST', 'headers': []}, receive)
|
||||
try:
|
||||
result = asyncio.run(getattr(routes, f'install_{kind}_zip')(request))
|
||||
assert getattr(result.manifest, kind + '_id') == 'zip-example'
|
||||
assert not result.enabled
|
||||
finally:
|
||||
runtime.plugins.shutdown()
|
||||
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from app.contracts import ModelRequest, Message, ProviderConfig
|
||||
from app.errors import ApiError
|
||||
from app.services.persona_settings import PersonaSettings, DialoguePair, save_persona, load_persona, apply_global_persona
|
||||
|
||||
|
||||
def request():
|
||||
return ModelRequest(provider_id="p", model="test", system="任务要求", messages=[Message(role="user", content="hello")])
|
||||
|
||||
|
||||
def test_global_persona_persists_and_keeps_task_prompt():
|
||||
save_persona(PersonaSettings(name="老师", system_prompt="耐心解释", dialogue_pairs=[DialoguePair(user="问题", assistant="回答"), DialoguePair()]))
|
||||
assert load_persona().version == 1
|
||||
original = request()
|
||||
assembled = apply_global_persona(original)
|
||||
assert assembled.system == "任务要求\n\n全局人设 / Global persona\n耐心解释\n\n预设对话示例 / Example dialogue\nUser: 问题\nAssistant: 回答"
|
||||
assert original.system == "任务要求"
|
||||
with pytest.raises(ApiError):
|
||||
save_persona(PersonaSettings())
|
||||
|
||||
|
||||
def test_empty_persona_omits_all_global_sections():
|
||||
save_persona(PersonaSettings(system_prompt=" ", dialogue_pairs=[DialoguePair(user=" ")]))
|
||||
assert apply_global_persona(request()).system == "任务要求"
|
||||
|
||||
|
||||
def test_existing_provider_reads_latest_global_persona_for_complete_and_stream(monkeypatch):
|
||||
from app.providers.factory import ProviderFactory
|
||||
from app.providers.base import ProviderTurn
|
||||
seen = []
|
||||
class Adapter:
|
||||
async def complete(self, req):
|
||||
seen.append(req.system)
|
||||
return ProviderTurn(text="ok")
|
||||
async def stream(self, req):
|
||||
seen.append(req.system)
|
||||
if False: yield
|
||||
factory = ProviderFactory(None)
|
||||
monkeypatch.setattr(factory, "_build", lambda _: Adapter())
|
||||
adapter = factory.build(ProviderConfig(provider_id="p",name="test",provider_type="openai_compatible"))
|
||||
save_persona(PersonaSettings(system_prompt="全局人设"))
|
||||
async def run():
|
||||
await adapter.complete(request())
|
||||
async for _ in adapter.stream(request()): pass
|
||||
asyncio.run(run())
|
||||
assert len(seen) == 2
|
||||
assert all(text.count("全局人设 / Global persona") == 1 for text in seen)
|
||||
@@ -0,0 +1,68 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from app.agent.tools import ToolRegistry
|
||||
from app.extensions import SkillRuntime
|
||||
from app.extensions.installed import InstalledRuntime
|
||||
|
||||
|
||||
def package(root):
|
||||
root.mkdir(parents=True)
|
||||
(root / 'skill.yaml').write_text('skill_id: audit\nname: Audit\nversion: 1.0.0\npermissions: []\ntools: []\n', encoding='utf-8')
|
||||
return root
|
||||
|
||||
|
||||
def runtime(data):
|
||||
return InstalledRuntime(SkillRuntime(ToolRegistry()), 'skill', data)
|
||||
|
||||
|
||||
def test_restores_enabled_and_disabled_without_deleting_directory_install(tmp_path):
|
||||
root = package(tmp_path / 'user-source')
|
||||
data = tmp_path / 'data'
|
||||
first = runtime(data); first.install(root); first.enable('audit')
|
||||
second = runtime(data); second.restore()
|
||||
assert second.get('audit').enabled
|
||||
second.disable('audit')
|
||||
third = runtime(data); third.restore()
|
||||
assert not third.get('audit').enabled
|
||||
third.uninstall('audit')
|
||||
assert root.exists()
|
||||
fourth = runtime(data); fourth.restore()
|
||||
assert fourth.list() == []
|
||||
|
||||
|
||||
def test_owned_zip_removed_and_changed_packages_not_auto_enabled(tmp_path):
|
||||
data = tmp_path / 'data'
|
||||
owned = data / 'extension-packages/skill-test'
|
||||
root = package(owned / 'nested')
|
||||
first = runtime(data); first.install(root, managed_root=owned); first.enable('audit')
|
||||
(root / 'prompt.md').write_text('changed', encoding='utf-8')
|
||||
first.disable('audit')
|
||||
with pytest.raises(Exception, match='Package changed'):
|
||||
first.enable('audit')
|
||||
second = runtime(data); second.restore()
|
||||
assert second.list() == []
|
||||
assert second.restore_errors[0]['id'] == 'audit'
|
||||
first.uninstall('audit')
|
||||
assert not owned.exists()
|
||||
|
||||
|
||||
def test_rejects_claiming_user_directory_as_managed(tmp_path):
|
||||
root = package(tmp_path / 'source')
|
||||
with pytest.raises(ValueError, match='managed'):
|
||||
runtime(tmp_path / 'data').install(root, managed_root=root)
|
||||
assert root.exists()
|
||||
|
||||
|
||||
def test_builtin_disabled_plugin_does_not_break_startup():
|
||||
from app.container import build_container
|
||||
first = build_container()
|
||||
first.plugins.disable('text-tools')
|
||||
second = build_container()
|
||||
assert not second.plugins.get('text-tools').enabled
|
||||
assert second.skills.get('knowledge-assistant').missing_dependencies
|
||||
second.plugins.enable('text-tools')
|
||||
third = build_container()
|
||||
assert third.plugins.get('text-tools').enabled
|
||||
assert third.skills.get('knowledge-assistant').enabled
|
||||
for container in (first, second, third):
|
||||
container.plugins.shutdown(); container.mcp_servers.shutdown()
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.errors import ApiError
|
||||
from app.providers.routing import ModelRoutingService, MAX_LOCAL_MEDIA_BYTES, MAX_MEDIA_BYTES, RoutedTranscript
|
||||
from app.services import transcription_service as jobs
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def test_large_media_requires_local_only_and_respects_size_limit():
|
||||
path = get_settings().attachments_path / 'large.mp3'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open('wb') as file:
|
||||
file.truncate(MAX_MEDIA_BYTES + 1)
|
||||
with pytest.raises(ApiError):
|
||||
ModelRoutingService._media_file(path)
|
||||
with ModelRoutingService._media_file(path, local_only=True):
|
||||
pass
|
||||
with pytest.raises(ApiError):
|
||||
asyncio.run(jobs.create_transcription('large.mp3', local_only=False))
|
||||
with path.open('wb') as file:
|
||||
file.truncate(MAX_LOCAL_MEDIA_BYTES + 1)
|
||||
with pytest.raises(ApiError):
|
||||
ModelRoutingService._media_file(path, local_only=True)
|
||||
|
||||
|
||||
def test_decode_recovers_one_corrupt_packet_without_shifting_following_audio(monkeypatch):
|
||||
from app.local_models.worker import decode
|
||||
class Samples(list):
|
||||
def reshape(self, *_): return self
|
||||
def astype(self, *_): return self
|
||||
def to_ndarray(self): return self
|
||||
class InvalidDataError(Exception): pass
|
||||
def broken(): raise InvalidDataError()
|
||||
packets = [SimpleNamespace(decode=lambda: [Samples([1] * 3200)]),
|
||||
SimpleNamespace(decode=broken, duration=100, time_base=.001),
|
||||
SimpleNamespace(decode=lambda: [Samples([2] * 3200)])]
|
||||
container = SimpleNamespace(streams=SimpleNamespace(audio=[1]), demux=lambda **_: iter(packets))
|
||||
fake_av = SimpleNamespace(open=lambda *_a, **_kw: nullcontext(container),
|
||||
error=SimpleNamespace(InvalidDataError=InvalidDataError),
|
||||
AudioResampler=lambda **_: SimpleNamespace(resample=lambda frame: [] if frame is None else [frame]))
|
||||
fake_numpy = SimpleNamespace(float32=float, zeros=lambda count, **_: Samples([0] * count),
|
||||
concatenate=lambda frames: Samples(value for frame in frames for value in frame),
|
||||
isfinite=lambda _: SimpleNamespace(all=lambda: True))
|
||||
monkeypatch.setitem(sys.modules, 'av', fake_av)
|
||||
monkeypatch.setitem(sys.modules, 'numpy', fake_numpy)
|
||||
warnings = []
|
||||
output = decode('test.mp3', warnings=warnings)
|
||||
assert output == [1] * 3200 + [0] * 1600 + [2] * 3200
|
||||
assert warnings == ['MEDIA_CORRUPT_PACKETS_SKIPPED:1']
|
||||
with pytest.raises(ValueError, match='one hour'):
|
||||
decode('test.mp3', limit_seconds=.25)
|
||||
|
||||
|
||||
def test_decode_warning_reaches_persisted_job(monkeypatch):
|
||||
from app.container import container
|
||||
path = get_settings().attachments_path / 'audio.mp3'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b'audio')
|
||||
async def transcribe(*_args, **_kwargs):
|
||||
return RoutedTranscript(text='decoded', source='local', warnings=['MEDIA_CORRUPT_PACKETS_SKIPPED:1'])
|
||||
monkeypatch.setattr(container.model_routing, 'transcribe', transcribe)
|
||||
job = asyncio.run(jobs.create_transcription('audio.mp3', local_only=True))
|
||||
assert job.status == 'completed'
|
||||
assert jobs.require_job(job.job_id).warnings == ['MEDIA_CORRUPT_PACKETS_SKIPPED:1']
|
||||
@@ -92,3 +92,41 @@ def test_real_adapter_body_and_usage_persistence():
|
||||
result = summary()
|
||||
assert result["request_count"] == 1 and result["totals"]["input_tokens"] == 10
|
||||
assert result["complete_requests"] == 1
|
||||
|
||||
|
||||
def test_usage_calendar_series_splits_sources_and_preserves_missing_counters():
|
||||
start = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
for source, hour, count in [('local', 15, 0), ('api', 16, 12), ('api', 17, None)]:
|
||||
attempt = UsageAttempt('p', 'm', 'openai_compatible', source=source)
|
||||
attempt.started_at = (start + timedelta(hours=hour)).isoformat()
|
||||
if count is not None:
|
||||
attempt.observe({'usage': {'input_tokens': count}})
|
||||
attempt.persist()
|
||||
result = aggregate(start, start + timedelta(days=2), timezone_offset=480)
|
||||
assert result['series'][0]['local']['totals']['input_tokens'] == 0
|
||||
second = result['series'][1]
|
||||
assert second['date'] == '2026-09-02'
|
||||
assert second['api']['requests'] == 2
|
||||
assert second['api']['totals']['input_tokens'] == 12
|
||||
assert second['api']['coverage']['input_tokens'] == 1
|
||||
assert second['api']['totals']['output_tokens'] is None
|
||||
assert sum(b['api']['requests'] + b['local']['requests'] for b in result['series']) == result['request_count']
|
||||
filtered = aggregate(start, start + timedelta(days=2), source='local', timezone_offset=480)
|
||||
assert all(b['api']['requests'] == 0 for b in filtered['series'])
|
||||
assert len(aggregate(start, start + timedelta(days=3660))['series']) <= 90
|
||||
|
||||
|
||||
def test_model_series_partitions_match_source_totals_and_cache_rate():
|
||||
start = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
for model, count in [('model-a', 100), ('model-b', 200)]:
|
||||
attempt = UsageAttempt('p', model, 'openai_compatible')
|
||||
attempt.started_at = start.isoformat()
|
||||
attempt.observe({'usage': {'prompt_tokens': count, 'completion_tokens': 0, 'prompt_cache_hit_tokens': 20, 'prompt_cache_miss_tokens': count - 20}})
|
||||
attempt.persist()
|
||||
result = aggregate(start, start + timedelta(days=1))
|
||||
api = result['series'][0]['api']
|
||||
assert [part['model'] for part in api['models']] == ['model-a', 'model-b']
|
||||
assert sum(part['totals']['input_tokens'] for part in api['models']) == api['totals']['input_tokens'] == 300
|
||||
assert result['totals']['cache_hit_tokens'] == 40
|
||||
assert result['totals']['cache_miss_tokens'] == 260
|
||||
assert result['cache_hit_rate'] == pytest.approx(40/300)
|
||||
|
||||
@@ -114,3 +114,34 @@ def test_workspace_openapi_paths_are_published() -> None:
|
||||
"/api/workspace/folders/delete",
|
||||
"/api/notes/{note_id}/rename",
|
||||
} <= paths.keys()
|
||||
|
||||
def test_external_files_are_registered_and_removed_without_vector_wait(monkeypatch) -> None:
|
||||
from app.services import index_service
|
||||
scheduled = []
|
||||
monkeypatch.setattr(index_service, 'schedule_workspace_rebuild', lambda: scheduled.append(True))
|
||||
vault = get_settings().vault_path
|
||||
vault.mkdir(parents=True, exist_ok=True)
|
||||
external = vault / 'external.md'
|
||||
external.write_text('# External\n', encoding='utf-8')
|
||||
tree = asyncio.run(get_workspace_tree())
|
||||
assert tree[0].note_id is not None
|
||||
external.rename(vault / 'renamed.md')
|
||||
tree = asyncio.run(get_workspace_tree())
|
||||
assert [item.name for item in tree] == ['renamed.md']
|
||||
(vault / 'renamed.md').unlink()
|
||||
assert asyncio.run(get_workspace_tree()) == []
|
||||
assert len(scheduled) == 3
|
||||
|
||||
|
||||
def test_save_rejects_external_content_change() -> None:
|
||||
import hashlib
|
||||
from app.contracts import NoteUpdateRequest
|
||||
from app.routes import update_note
|
||||
original = '# Original\n'
|
||||
note = asyncio.run(create_note(NoteCreateRequest(title='Conflict', markdown=original)))
|
||||
disk = get_settings().vault_path / note.file_path
|
||||
disk.write_text('# External\n', encoding='utf-8')
|
||||
with pytest.raises(ApiError) as error:
|
||||
asyncio.run(update_note(note.note_id, NoteUpdateRequest(markdown='# Editor\n', expected_content_hash=hashlib.sha256(original.encode()).hexdigest())))
|
||||
assert error.value.code == 'NOTE_CONTENT_CONFLICT'
|
||||
assert disk.read_text(encoding='utf-8') == '# External\n'
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import asyncio
|
||||
|
||||
from app import repository
|
||||
from app.config import get_settings
|
||||
from app.services import index_service, workspace_service
|
||||
|
||||
|
||||
def test_open_returns_before_vectors_and_deduplicates_background(monkeypatch):
|
||||
async def scenario():
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
original = index_service.prepare_note_index
|
||||
calls = 0
|
||||
async def slow(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original(*args, **kwargs)
|
||||
monkeypatch.setattr(index_service, 'prepare_note_index', slow)
|
||||
vault = get_settings().vault_path
|
||||
vault.mkdir(parents=True, exist_ok=True)
|
||||
(vault / 'demo.md').write_text('# Demo\n\nsearchable content', encoding='utf-8')
|
||||
try:
|
||||
snapshot = await asyncio.wait_for(workspace_service.open_workspace(None), 1)
|
||||
assert snapshot.items[0].note_id
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
task = index_service._background_task
|
||||
await asyncio.wait_for(workspace_service.open_workspace(None), 1)
|
||||
assert index_service._background_task is task
|
||||
assert index_service.get_status().status == 'running'
|
||||
# A mutation still completes while the model is waiting.
|
||||
await asyncio.wait_for(workspace_service.create_folder('/', 'new-folder'), 1)
|
||||
assert repository.list_note_locations()[0].note_id == snapshot.items[0].note_id
|
||||
release.set()
|
||||
await asyncio.wait_for(task, 2)
|
||||
assert calls == 1
|
||||
assert not index_service.get_status().vector_refresh_required
|
||||
finally:
|
||||
release.set()
|
||||
await index_service.shutdown()
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_background_retries_changed_snapshot_without_overwriting(monkeypatch):
|
||||
async def scenario():
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
original = index_service.prepare_note_index
|
||||
calls = 0
|
||||
async def slow(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original(*args, **kwargs)
|
||||
monkeypatch.setattr(index_service, 'prepare_note_index', slow)
|
||||
vault = get_settings().vault_path
|
||||
vault.mkdir(parents=True, exist_ok=True)
|
||||
path = vault / 'demo.md'
|
||||
path.write_text('# Before\n\nold', encoding='utf-8')
|
||||
try:
|
||||
await workspace_service.open_workspace(None)
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
path.write_text('# After\n\nnew', encoding='utf-8')
|
||||
release.set()
|
||||
await asyncio.wait_for(index_service._background_task, 4)
|
||||
assert calls == 2
|
||||
assert repository.list_note_locations()[0].title == 'After'
|
||||
assert not index_service.get_status().vector_refresh_required
|
||||
finally:
|
||||
release.set()
|
||||
await index_service.shutdown()
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_save_returns_while_vectors_wait_and_latest_revision_wins(monkeypatch):
|
||||
from app.services import note_service
|
||||
async def scenario():
|
||||
note = await note_service.create_note(title='Draft', markdown='# Draft\n\ninitial', folder=None, tags=[])
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
original = index_service.prepare_note_index
|
||||
calls = 0
|
||||
async def slow(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original(*args, **kwargs)
|
||||
monkeypatch.setattr(index_service, 'prepare_note_index', slow)
|
||||
try:
|
||||
await asyncio.wait_for(note_service.update_note(note.note_id, markdown='# First\n\none', defer_vectors=True), 1)
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
await asyncio.wait_for(note_service.update_note(note.note_id, title='Custom title', tags=['kept'], markdown='# Latest\n\ntwo', defer_vectors=True), 1)
|
||||
assert (await note_service.get_note(note.note_id)).markdown == '# Latest\n\ntwo'
|
||||
assert index_service.get_status().vector_refresh_required
|
||||
release.set()
|
||||
await asyncio.wait_for(index_service._background_task, 3)
|
||||
current = repository.get_note_record(note.note_id)
|
||||
assert current.title == 'Custom title'
|
||||
assert current.tags == ['kept']
|
||||
assert calls == 2
|
||||
assert not index_service.get_status().vector_refresh_required
|
||||
finally:
|
||||
release.set()
|
||||
await index_service.shutdown()
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_failed_vectors_do_not_undo_save_and_pending_work_can_resume(monkeypatch):
|
||||
from app.services import note_service
|
||||
async def scenario():
|
||||
note = await note_service.create_note(title='Draft', markdown='# Draft', folder=None, tags=[])
|
||||
original = index_service.prepare_note_index
|
||||
async def fail(*args, **kwargs):
|
||||
raise RuntimeError('model unavailable')
|
||||
monkeypatch.setattr(index_service, 'prepare_note_index', fail)
|
||||
try:
|
||||
await note_service.update_note(note.note_id, markdown='# Saved', defer_vectors=True)
|
||||
await index_service._background_task
|
||||
assert (await note_service.get_note(note.note_id)).markdown == '# Saved'
|
||||
assert index_service.get_status().status == 'failed'
|
||||
assert index_service.get_status().vector_refresh_required
|
||||
await index_service.shutdown()
|
||||
monkeypatch.setattr(index_service, 'prepare_note_index', original)
|
||||
await workspace_service.open_workspace(None)
|
||||
await index_service._background_task
|
||||
assert not index_service.get_status().vector_refresh_required
|
||||
finally:
|
||||
await index_service.shutdown()
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user