Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,8 @@ jobs:
SYNTHORA_AUTH_MODE: none
SYNTHORA_SECRET_KEY: ci-smoke-secret-key-not-for-prod-32
SYNTHORA_CHECKPOINT_BACKEND: postgres
OPENAI_API_KEY: ""
OLLAMA_BASE_URL: ""
SYNTHORA_EMBEDDINGS: hash
run: bash scripts/smoke.sh
timeout-minutes: 45
1 change: 1 addition & 0 deletions apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"pypdf>=5.0",
"python-docx>=1.1",
"mcp>=1.28.1",
"python-multipart>=0.0.32",
]

[tool.uv.sources]
Expand Down
56 changes: 52 additions & 4 deletions apps/web/src/components/News.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { api, NewsItem, NewsSubscription } from "../api";
export function News() {
const [subs, setSubs] = useState<NewsSubscription[]>([]);
const [items, setItems] = useState<NewsItem[]>([]);
const [filterSubId, setFilterSubId] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [cadence, setCadence] = useState("daily");
const [editingId, setEditingId] = useState<string | null>(null);
Expand All @@ -12,10 +13,12 @@ export function News() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

async function refresh() {
async function refresh(subscriptionId?: string | null) {
const filter =
subscriptionId !== undefined ? subscriptionId : filterSubId;
const [s, i] = await Promise.all([
api.listNewsSubscriptions(),
api.listNewsItems(),
api.listNewsItems(filter || undefined),
]);
setSubs(s);
setItems(i);
Expand All @@ -25,6 +28,11 @@ export function News() {
refresh().catch((e) => setError(String(e)));
}, []);

function subLabel(subscriptionId: string): string {
const match = subs.find((s) => s.id === subscriptionId);
return match?.query || `${subscriptionId.slice(0, 8)}…`;
}

async function createSub() {
if (!query.trim()) return;
setBusy(true);
Expand Down Expand Up @@ -59,7 +67,12 @@ export function News() {
try {
await api.deleteNewsSubscription(id);
if (editingId === id) setEditingId(null);
await refresh();
if (filterSubId === id) {
setFilterSubId(null);
await refresh(null);
} else {
await refresh();
}
} catch (e) {
setError(String(e));
} finally {
Expand Down Expand Up @@ -91,6 +104,18 @@ export function News() {
}
}

async function toggleFilter(sub: NewsSubscription) {
setError(null);
if (filterSubId === sub.id) {
setFilterSubId(null);
await refresh(null);
return;
}
setFilterSubId(sub.id);
await api.getNewsSubscription(sub.id);
await refresh(sub.id);
}

return (
<section className="panel">
<h2>News subscriptions</h2>
Expand Down Expand Up @@ -162,7 +187,14 @@ export function News() {
) : (
<>
<div>
<strong>{s.query}</strong>
<button
type="button"
className="ghost"
aria-pressed={filterSubId === s.id}
onClick={() => toggleFilter(s)}
>
<strong>{s.query}</strong>
</button>
<span className="muted"> · {s.cadence}</span>
</div>
<div className="action-row">
Expand Down Expand Up @@ -198,10 +230,26 @@ export function News() {
</ul>

<h3>Items</h3>
{filterSubId && (
<p className="muted">
Filtered to subscription: <strong>{subLabel(filterSubId)}</strong>{" "}
<button
type="button"
className="ghost"
onClick={() => {
setFilterSubId(null);
refresh(null).catch((e) => setError(String(e)));
}}
>
Show all
</button>
</p>
)}
{items.length === 0 && <p className="muted">No news items yet.</p>}
<ul className="history-list">
{items.map((item) => (
<li key={item.id}>
<span className="muted">{subLabel(item.subscription_id)} · </span>
<a href={item.url} target="_blank" rel="noreferrer">
{item.title || item.url}
</a>
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ services:
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:-}
ports:
- "${SYNTHORA_API_PORT:-8000}:8000"
depends_on:
Expand Down
7 changes: 6 additions & 1 deletion docs/feature-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ See also [parity-audit.md](parity-audit.md).
| LLM provider abstraction + think-tag handling | ✅ | 11 providers |
| Research history + export md/html/pdf | ✅ | API + web buttons; structured HTML/PDF export |
| Docker Compose self-host | ✅ | `docker-compose.yml` |
| Python SDK | ✅ | `packages/sdk` — full REST mirror incl. upload, export download, news GET |
| Python SDK | ✅ | `packages/sdk` — sync + async clients, WebSocket events, full REST mirror |
| Document library + RAG (`collection` engine) | ✅ | documents API + `document_index` |
| Provider settings persistence | ✅ | `/api/v1/settings` + Settings UI; resolvers prefer workspace overlay then env; GET responses redact secrets |
| MCP server exposing Synthora tools | ✅ | `/api/v1/mcp/tools/*` REST + `/mcp` streamable HTTP; optional ``config`` on ``start_research`` |
Expand Down Expand Up @@ -130,6 +130,11 @@ code, blockquotes, ordered lists, tables, rules); PDF via fpdf2 ``write_html``
(preserves structure); web ``health``/``ready``/MCP REST wrappers; History
session drill-down via ``getSession``.

Closed on ``feat/mcp-async-hardening``: outbound MCP HTTP fallback fail-loud +
integration tests; ``AsyncSynthoraClient`` with ``iter_run_events`` WebSocket;
News subscription filter + ``getNewsSubscription`` UX; smoke validates export
formats and document upload.

No known functional gaps remain beyond explicit non-goals below.

Chat remains session-scoped ``fast_research`` with prior-report memory —
Expand Down
3 changes: 3 additions & 0 deletions packages/adapters/src/synthora/adapters/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ def resolve(self, model_id: str) -> EmbeddingModel:

def resolve_default_embeddings() -> EmbeddingModel:
"""Prefer OpenAI when keyed, else Ollama when base URL set, else hash."""
forced = _env("SYNTHORA_EMBEDDINGS", default="").strip().lower()
if forced in ("hash", "offline"):
return HashEmbeddings()
if _env("OPENAI_API_KEY"):
return OpenAIEmbeddings()
if _env("OLLAMA_BASE_URL") or _env("OLLAMA_EMBED_MODEL"):
Expand Down
25 changes: 21 additions & 4 deletions packages/adapters/src/synthora/adapters/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ async def _http_tools_list(
tools = data.get("tools") if isinstance(data, dict) else None
if isinstance(tools, list):
return tools
if resp.status_code not in (404, 405):
resp.raise_for_status()
# Minimal JSON-RPC
resp = await client.post(
base_url,
Expand All @@ -243,12 +245,16 @@ async def _http_tools_list(
if resp.status_code >= 400:
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict) and "error" in data:
err = data["error"]
message = err.get("message") if isinstance(err, dict) else str(err)
raise RuntimeError(f"MCP tools/list failed: {message}")
result = data.get("result") if isinstance(data, dict) else None
if isinstance(result, dict) and isinstance(result.get("tools"), list):
return result["tools"]
if isinstance(result, list):
return result
return []
raise RuntimeError("MCP tools/list returned no tools")


async def _http_tools_call(
Expand All @@ -273,6 +279,8 @@ async def _http_tools_call(
if "result" in data:
return str(data["result"])
return str(data)
if resp.status_code not in (404, 405):
resp.raise_for_status()
resp = await client.post(
base_url,
json={
Expand All @@ -286,8 +294,17 @@ async def _http_tools_call(
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
if "result" in data:
return str(data["result"])
if "error" in data:
return f"error: {data['error']}"
err = data["error"]
message = err.get("message") if isinstance(err, dict) else str(err)
raise RuntimeError(f"MCP tools/call failed: {message}")
if "result" in data:
result = data["result"]
if isinstance(result, dict) and "content" in result:
blocks = result["content"]
if isinstance(blocks, list):
for block in blocks:
if isinstance(block, dict) and block.get("type") == "text":
return str(block.get("text", ""))
return str(result)
return str(data)
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ async def create(
created_at=document.created_at,
)
)
await s.flush()
for chunk in chunks or []:
s.add(
DocumentChunkRow(
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "synthora-sdk"
version = "0.1.0"
description = "Synthora Python client SDK"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27"]
dependencies = ["httpx>=0.27", "websockets>=12.0"]

[build-system]
requires = ["hatchling"]
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/src/synthora/sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Synthora Python SDK (R-LDR-8)."""

from synthora.sdk.async_client import AsyncSynthoraClient
from synthora.sdk.client import SynthoraClient

__all__ = ["SynthoraClient"]
__all__ = ["AsyncSynthoraClient", "SynthoraClient"]
Loading
Loading