diff --git a/.env.example b/.env.example index fafae7b..d6df131 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ SYNTHORA_MCP_ALLOWED_ORIGINS= # e.g. https://app.example.com SYNTHORA_NEWS_POLL=1 # worker background news poller SYNTHORA_NEWS_POLL_INTERVAL=300 SYNTHORA_USE_ALEMBIC=1 # run alembic upgrade head on postgres boot +SYNTHORA_EMBEDDINGS=hash # hash | openai | ollama (compose default: hash) SYNTHORA_API_PORT=8000 SYNTHORA_WEB_PORT=3000 diff --git a/docker-compose.yml b/docker-compose.yml index ed1566e..978358d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,8 +80,8 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} TAVILY_API_KEY: ${TAVILY_API_KEY:-} - OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434} - SYNTHORA_EMBEDDINGS: ${SYNTHORA_EMBEDDINGS:-} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-} + SYNTHORA_EMBEDDINGS: ${SYNTHORA_EMBEDDINGS:-hash} ports: - "${SYNTHORA_API_PORT:-8000}:8000" depends_on: diff --git a/docs/feature-parity.md b/docs/feature-parity.md index 7dfdb57..348f253 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -135,6 +135,11 @@ integration tests; ``AsyncSynthoraClient`` with ``iter_run_events`` WebSocket; News subscription filter + ``getNewsSubscription`` UX; smoke validates export formats and document upload. +Closed on ``feat/compose-embeddings-default``: compose defaults +``SYNTHORA_EMBEDDINGS=hash`` and empty ``OLLAMA_BASE_URL`` (no phantom Ollama +when profile disabled); sync ``SynthoraClient.iter_run_events``; embedding +default tests; parity audit doc sync. + No known functional gaps remain beyond explicit non-goals below. Chat remains session-scoped ``fast_research`` with prior-report memory — diff --git a/docs/parity-audit.md b/docs/parity-audit.md index 0dc7820..358913c 100644 --- a/docs/parity-audit.md +++ b/docs/parity-audit.md @@ -1,7 +1,7 @@ # Synthora parity audit checklist Living checklist against Open Deep Research, STORM/Co-STORM, and Local Deep -Research. Updated on `feat/synthora-full-parity`. +Research. Last verified on `main` after PR #12 (`2537bd6`). ## Open Deep Research @@ -46,10 +46,11 @@ Research. Updated on `feat/synthora-full-parity`. | Settings persistence | done | | Export md/html/pdf | done | | Delete / clear history | done | -| MCP server (outbound tools API) | done | +| MCP server (inbound agent surface + outbound researcher tools) | done | | News / subscriptions | done | | Metrics | done | | Chat / follow-up research | done | +| Python SDK (sync + async + WebSocket iterators) | done | ## Security & isolation (verified by `tests/test_isolation.py`) @@ -63,10 +64,18 @@ Research. Updated on `feat/synthora-full-parity`. | Boot refusal on insecure secret key in session mode | done | | Durable Postgres checkpointer (compose default `postgres`) | done | +## Deployment defaults + +| Item | Status | +|---|---| +| Compose default embeddings (`SYNTHORA_EMBEDDINGS=hash`) without Ollama profile | done (PR #13) | +| Live compose smoke (`scripts/smoke.sh`: research, export, upload) | done | +| Playwright UI e2e | API-mocked (CI speed); not a live-stack browser gate | + ## Residual gaps (deliberate, not silent) -None remaining for in-scope functionality. Compose smoke and Playwright -API-mock e2e are verified. +No in-scope product/API/worker gaps. Inbound MCP exposes four agent tools +(not the full REST catalog) by design. Chat enqueues `fast_research` by design. ## Explicit non-goals @@ -74,3 +83,4 @@ API-mock e2e are verified. - Per-user SQLCipher (shared Postgres + optional auth by design) - STORM Streamlit demo - Paper eval dataset construction pipelines +- Full CommonMark/GFM export (Synthora report subset only) diff --git a/packages/sdk/src/synthora/sdk/client.py b/packages/sdk/src/synthora/sdk/client.py index 68efff6..270aeff 100644 --- a/packages/sdk/src/synthora/sdk/client.py +++ b/packages/sdk/src/synthora/sdk/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from collections.abc import Iterator from typing import Any, Optional import httpx @@ -121,6 +122,25 @@ def events_ws_url(self, run_id: str) -> str: suffix = f"?token={self.token}" if self.token else "" return f"{ws_base}/api/v1/research/{run_id}/events/ws{suffix}" + def iter_run_events(self, run_id: str) -> Iterator[dict]: + """Yield live run events over WebSocket (requires a reachable server).""" + import json + + from websockets.exceptions import ConnectionClosed + from websockets.sync.client import connect + + url = self.events_ws_url(run_id) + headers: list[tuple[str, str]] = [] + if self.token: + headers.append(("Authorization", f"Bearer {self.token}")) + with connect(url, additional_headers=headers) as ws: + while True: + try: + raw = ws.recv() + except ConnectionClosed: + break + yield json.loads(raw) + def download_export(self, run_id: str, fmt: str = "markdown") -> bytes: """Download export bytes with auth (session mode safe).""" resp = self._client.get( diff --git a/tests/test_embeddings_defaults.py b/tests/test_embeddings_defaults.py new file mode 100644 index 0000000..419c6fa --- /dev/null +++ b/tests/test_embeddings_defaults.py @@ -0,0 +1,38 @@ +"""Default embedding resolution for compose and offline dev.""" + +from __future__ import annotations + +from synthora.adapters.embeddings import ( + HashEmbeddings, + OllamaEmbeddings, + OpenAIEmbeddings, + resolve_default_embeddings, +) + + +def test_resolve_default_embeddings_uses_hash_without_keys(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) + monkeypatch.delenv("OLLAMA_EMBED_MODEL", raising=False) + monkeypatch.delenv("SYNTHORA_EMBEDDINGS", raising=False) + assert isinstance(resolve_default_embeddings(), HashEmbeddings) + + +def test_resolve_default_embeddings_forced_hash(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434") + monkeypatch.setenv("SYNTHORA_EMBEDDINGS", "hash") + assert isinstance(resolve_default_embeddings(), HashEmbeddings) + + +def test_resolve_default_embeddings_prefers_openai_when_keyed(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("SYNTHORA_EMBEDDINGS", raising=False) + assert isinstance(resolve_default_embeddings(), OpenAIEmbeddings) + + +def test_resolve_default_embeddings_uses_ollama_when_configured(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("SYNTHORA_EMBEDDINGS", raising=False) + monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434") + assert isinstance(resolve_default_embeddings(), OllamaEmbeddings) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 943822e..4e094b9 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -172,6 +172,43 @@ def test_sdk_health_and_ready(sdk): assert sdk.ready()["status"] == "ready" +def test_sync_events_ws_url(sdk): + sdk.token = "tok" + url = sdk.events_ws_url("run-1") + assert "/api/v1/research/run-1/events/ws" in url + assert "token=tok" in url + + +def test_sync_iter_run_events(monkeypatch): + from websockets.exceptions import ConnectionClosedOK + + payloads = [ + {"type": "status", "message": "queued"}, + {"type": "done", "message": "completed"}, + ] + + class _FakeWS: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def recv(self): + if payloads: + return json.dumps(payloads.pop(0)) + raise ConnectionClosedOK(None, None) + + monkeypatch.setattr( + "websockets.sync.client.connect", + lambda *_a, **_k: _FakeWS(), + ) + client = SynthoraClient("http://localhost:8000") + events = list(client.iter_run_events("run-1")) + assert events[0]["type"] == "status" + assert events[-1]["type"] == "done" + + @pytest.mark.asyncio async def test_async_sdk_health_and_mcp(platform): from httpx import ASGITransport