mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-16 18:02:02 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26bc7efb09 | ||
|
|
f3954e087a | ||
|
|
d865b4bed4 | ||
|
|
c686517cc7 | ||
|
|
904133cb25 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "137,874",
|
||||
"message": "139,590",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 137874,
|
||||
"last_updated": "2026-06-30T07:21:14Z",
|
||||
"total_clones": 139590,
|
||||
"last_updated": "2026-07-01T07:33:10Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -95,6 +95,8 @@
|
||||
"2026-06-25": 1640,
|
||||
"2026-06-26": 1338,
|
||||
"2026-06-27": 1338,
|
||||
"2026-06-28": 1028
|
||||
"2026-06-28": 1028,
|
||||
"2026-06-29": 765,
|
||||
"2026-06-30": 951
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ What it does:
|
||||
4. Installs `uv` (https://astral.sh/uv) if absent.
|
||||
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
|
||||
(override with `$env:OPENJARVIS_HOME`).
|
||||
6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are
|
||||
importable.
|
||||
6. Runs `uv sync --extra desktop --group desktop-native` so the FastAPI server,
|
||||
speech backend, and native extension are importable.
|
||||
7. Optionally prompts to register a scheduled task that auto-starts the
|
||||
server at logon.
|
||||
|
||||
@@ -105,7 +105,7 @@ To pull the latest:
|
||||
```powershell
|
||||
cd "$env:LOCALAPPDATA\OpenJarvis\src"
|
||||
git pull --ff-only
|
||||
uv sync --extra desktop
|
||||
uv sync --extra desktop --group desktop-native
|
||||
```
|
||||
|
||||
Or re-run the installer with `-Force`:
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
4. Install uv (https://astral.sh/uv) if absent.
|
||||
5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis
|
||||
(override with $env:OPENJARVIS_HOME).
|
||||
6. Run `uv sync --extra desktop` so the FastAPI server and speech
|
||||
backend are importable.
|
||||
6. Run `uv sync --extra desktop --group desktop-native` so the FastAPI
|
||||
server, speech backend, and native extension are importable.
|
||||
7. Optionally register the scheduled-task service (see
|
||||
deploy/windows/jarvis-service.ps1).
|
||||
|
||||
@@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. uv sync --extra desktop
|
||||
# 6. uv sync --extra desktop --group desktop-native
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
|
||||
Write-Info "Running 'uv sync --extra desktop --group desktop-native' in $srcDir (this can take a few minutes)..."
|
||||
Push-Location $srcDir
|
||||
try {
|
||||
& $uvExe sync --extra desktop
|
||||
& $uvExe sync --extra desktop --group desktop-native
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ avoid a Linux VM; WSL2 remains the smoother experience for most users.
|
||||
## What you get
|
||||
|
||||
- A PowerShell installer that probes prerequisites, installs `uv`,
|
||||
clones the repo, and runs `uv sync --extra desktop`.
|
||||
clones the repo, and runs `uv sync --extra desktop --group desktop-native`.
|
||||
- An optional Windows scheduled-task service equivalent to the systemd
|
||||
unit and launchd plist.
|
||||
- Loopback default — the service binds `127.0.0.1` so no API key is
|
||||
@@ -38,7 +38,7 @@ The installer will:
|
||||
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
|
||||
installer).
|
||||
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
|
||||
6. Run `uv sync --extra desktop`.
|
||||
6. Run `uv sync --extra desktop --group desktop-native`.
|
||||
7. Prompt to register the scheduled-task service (skip with
|
||||
`-SkipService`).
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ use tokio::sync::Mutex;
|
||||
|
||||
const OLLAMA_PORT: u16 = 11434;
|
||||
const JARVIS_PORT: u16 = 8000;
|
||||
const DESKTOP_UV_SYNC_COMMAND: &str =
|
||||
"uv sync --extra desktop --extra inference-cloud --extra inference-google --group desktop-native";
|
||||
|
||||
/// Small, fast model used when startup needs a default Ollama tag.
|
||||
const STARTUP_MODEL: &str = "qwen3.5:4b";
|
||||
@@ -740,10 +742,11 @@ fn format_uv_sync_failure(
|
||||
format!(
|
||||
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
|
||||
Try opening a terminal in that directory and running \
|
||||
`uv sync --extra desktop` manually for the full output.{}",
|
||||
`{}` manually for the full output.{}",
|
||||
root.display(),
|
||||
code,
|
||||
tail,
|
||||
DESKTOP_UV_SYNC_COMMAND,
|
||||
rust_hint,
|
||||
)
|
||||
}
|
||||
@@ -829,7 +832,7 @@ fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> Stri
|
||||
"`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\
|
||||
Run these manually for the full build log:\n\n\
|
||||
cd {}\n\
|
||||
uv sync --extra desktop\n\
|
||||
{}\n\
|
||||
uv run python -c \"import openjarvis_rust\"",
|
||||
if tail.is_empty() {
|
||||
"(no stderr output)"
|
||||
@@ -837,6 +840,7 @@ fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> Stri
|
||||
&tail
|
||||
},
|
||||
root.display(),
|
||||
DESKTOP_UV_SYNC_COMMAND,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1350,6 +1354,9 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
"--extra", "desktop",
|
||||
"--extra", "inference-cloud",
|
||||
"--extra", "inference-google",
|
||||
// openjarvis_rust lives in a uv dependency group (not the published
|
||||
// `desktop` extra) so pip installs from PyPI don't require it (#584).
|
||||
"--group", "desktop-native",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
@@ -2866,7 +2873,7 @@ mod tests {
|
||||
format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host,
|
||||
parse_inference_config, parse_ollama_model_names, preferred_installed_model,
|
||||
should_persist_resolved_model, startup_installed_model, upsert_engine_host,
|
||||
uv_sync_stderr_tail, InferenceConfig, SourceKind,
|
||||
uv_sync_stderr_tail, InferenceConfig, SourceKind, DESKTOP_UV_SYNC_COMMAND,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -2910,7 +2917,7 @@ mod tests {
|
||||
assert!(msg.contains("exit 2"));
|
||||
assert!(msg.contains("/home/u/.openjarvis/src"));
|
||||
assert!(msg.contains("failed to resolve numpy==2.1.3"));
|
||||
assert!(msg.contains("uv sync --extra desktop")); // actionable next step
|
||||
assert!(msg.contains(DESKTOP_UV_SYNC_COMMAND)); // actionable next step
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2962,7 +2969,7 @@ mod tests {
|
||||
"ModuleNotFoundError: No module named 'openjarvis_rust'",
|
||||
);
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains("uv sync --extra desktop"));
|
||||
assert!(msg.contains(DESKTOP_UV_SYNC_COMMAND));
|
||||
assert!(msg.contains("uv run python -c \"import openjarvis_rust\""));
|
||||
assert!(msg.contains("ModuleNotFoundError"));
|
||||
}
|
||||
|
||||
@@ -243,7 +243,11 @@ export function InputArea() {
|
||||
|
||||
try {
|
||||
if (deepResearch) {
|
||||
for await (const ev of streamResearch(content, controller.signal)) {
|
||||
for await (const ev of streamResearch(
|
||||
content,
|
||||
selectedModel,
|
||||
controller.signal,
|
||||
)) {
|
||||
if (ev.type === 'search_call') {
|
||||
const trace: ResearchSearchTrace = {
|
||||
id: generateId(),
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function* streamChat(
|
||||
|
||||
export async function* streamResearch(
|
||||
query: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ResearchEvent> {
|
||||
// /api/research is mounted at the server root — strip any trailing /v1
|
||||
@@ -68,7 +69,7 @@ export async function* streamResearch(
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query }),
|
||||
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -106,4 +107,3 @@ export async function* streamResearch(
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -91,7 +91,6 @@ desktop = [
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"faster-whisper>=1.0",
|
||||
"openjarvis-rust",
|
||||
]
|
||||
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
|
||||
gpu-metrics = ["pynvml>=12.0"]
|
||||
@@ -239,3 +238,12 @@ select = ["E", "F", "I", "W"]
|
||||
dev = [
|
||||
"maturin>=1.12.6",
|
||||
]
|
||||
# openjarvis_rust is the native PyO3 extension, built from the local Rust
|
||||
# workspace. It lives in a uv dependency group (PEP 735) — not the published
|
||||
# `desktop` extra — so `uv sync --group desktop-native` builds it from source
|
||||
# for the desktop app, while `pip install openjarvis[desktop]` from PyPI does
|
||||
# NOT try to resolve openjarvis-rust from PyPI, where it isn't published
|
||||
# (dependency groups are excluded from wheel metadata). See #584 / #615.
|
||||
desktop-native = [
|
||||
"openjarvis-rust",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
A small, self-contained planner-executor loop:
|
||||
|
||||
* the planner is a local Ollama chat model (default ``gemma4:31b``),
|
||||
* the planner is supplied by the caller (the web endpoint resolves it from
|
||||
config, falling back to ``gemma4:31b`` on Ollama for legacy installs),
|
||||
* the only tool it can call is :meth:`HybridSearch.search`,
|
||||
* it gets up to ``max_iterations`` tool calls,
|
||||
* tool results are trimmed before re-entering the context window, and
|
||||
@@ -142,10 +143,11 @@ Strategy:
|
||||
3. The `time_range` argument is a JSON object: `{{"start": "<ISO 8601>", "end": "<ISO 8601>"}}`. Either bound may be omitted, but pass at least one whenever the user gave you a temporal cue.
|
||||
4. When the user names a specific data source — "my Granola notes", "in Slack", "from my email" — you MUST pass `sources=[...]` with the matching connector ID. Only use IDs that appear in the connected-sources list above; do NOT invent or assume sources that are not connected. Common synonyms: "meeting notes"/"meetings"/"transcripts" → granola; "email"/"inbox" → gmail; "DMs"/"channels" → slack. Without this filter the search returns mail/messages ABOUT a tool instead of records FROM that tool.
|
||||
4a. Never apologize about sources that aren't in the connected-sources list — if the user asks about "Notion" but Notion isn't connected, just say "Notion isn't connected, but here's what I found in {available_sources}" and answer from what is available.
|
||||
5. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
6. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
7. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Never send an empty query or a query with no parameters — extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
8. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
5. When the user asks for "next", "upcoming", "future", or "soon" calendar events/meetings/appointments, use `sources=["gcalendar"]` if gcalendar is connected, set `time_range={{"start": "{today}"}}`, and use `query=""` unless the user gave a specific topic such as "dentist" or "music lesson". This returns the nearest upcoming calendar items across calendars instead of keyword-matching only birthdays or event titles.
|
||||
6. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
7. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
8. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Only use an empty query when structured filters carry the request; never send a search with no concrete parameters. Extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
9. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
|
||||
Synthesis rules:
|
||||
- Cite sources as individual numbers in square brackets. Always separate — write [4] [7] [20], never [4, 7, 20]. Never format citations as markdown links. Just the number in brackets: [1]. The `ref` field on each hit is the citation number.
|
||||
|
||||
@@ -213,12 +213,13 @@ def _parse_event_timestamp(event: Dict[str, Any]) -> datetime:
|
||||
"""
|
||||
start = event.get("start", {})
|
||||
date_time_str: str = start.get("dateTime", "")
|
||||
if not date_time_str:
|
||||
date_str: str = start.get("date", "")
|
||||
if not date_time_str and not date_str:
|
||||
return datetime.now()
|
||||
try:
|
||||
# RFC3339 — Python 3.11+ fromisoformat handles the trailing 'Z'.
|
||||
# For older versions we replace 'Z' with '+00:00'.
|
||||
normalized = date_time_str.replace("Z", "+00:00")
|
||||
normalized = (date_time_str or date_str).replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
except (ValueError, TypeError):
|
||||
return datetime.now()
|
||||
|
||||
@@ -20,8 +20,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# numpy imported lazily inside _vector_recall (see embeddings.py) so importing
|
||||
@@ -32,6 +33,61 @@ from openjarvis.connectors.store import KnowledgeStore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_UPCOMING_TERMS = {
|
||||
"next",
|
||||
"upcoming",
|
||||
"future",
|
||||
"forthcoming",
|
||||
"coming",
|
||||
"soon",
|
||||
}
|
||||
_CALENDAR_TERMS = {
|
||||
"calendar",
|
||||
"calendars",
|
||||
"event",
|
||||
"events",
|
||||
}
|
||||
_CALENDAR_REQUEST_TERMS = _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_GCALENDAR_GENERIC_TERMS = _UPCOMING_TERMS | _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_QUERY_STOPWORDS = {
|
||||
"a",
|
||||
"all",
|
||||
"am",
|
||||
"are",
|
||||
"do",
|
||||
"for",
|
||||
"have",
|
||||
"i",
|
||||
"in",
|
||||
"is",
|
||||
"list",
|
||||
"me",
|
||||
"my",
|
||||
"on",
|
||||
"s",
|
||||
"show",
|
||||
"tell",
|
||||
"the",
|
||||
"there",
|
||||
"to",
|
||||
"what",
|
||||
"whats",
|
||||
"when",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,6 +176,101 @@ def _snippet(content: str, max_chars: int = 500) -> str:
|
||||
return flat[:max_chars].rstrip() + "…"
|
||||
|
||||
|
||||
def _query_tokens(query: str) -> set[str]:
|
||||
return set(re.findall(r"[a-z0-9_]+", query.lower()))
|
||||
|
||||
|
||||
def _sources_include_gcalendar(sources: Optional[Sequence[str]]) -> bool:
|
||||
return any(str(source).lower() == "gcalendar" for source in sources or [])
|
||||
|
||||
|
||||
def _has_upcoming_calendar_intent(
|
||||
query: str,
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens or not (tokens & _UPCOMING_TERMS):
|
||||
return False
|
||||
if _sources_include_gcalendar(sources):
|
||||
return True
|
||||
if sources:
|
||||
return False
|
||||
return bool(tokens & _CALENDAR_REQUEST_TERMS)
|
||||
|
||||
|
||||
def _is_generic_calendar_timeline_query(query: str) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens:
|
||||
return True
|
||||
topic_tokens = tokens - _GCALENDAR_GENERIC_TERMS - _QUERY_STOPWORDS
|
||||
return not topic_tokens
|
||||
|
||||
|
||||
def _start_is_nowish_or_future(start: Optional[datetime]) -> bool:
|
||||
if start is None:
|
||||
return False
|
||||
now = datetime.now(tz=start.tzinfo) if start.tzinfo else datetime.now()
|
||||
return start >= now - timedelta(days=1)
|
||||
|
||||
|
||||
def _start_of_day(ts: datetime) -> datetime:
|
||||
return ts.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _as_utc(ts: Optional[datetime]) -> Optional[datetime]:
|
||||
if ts is None:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=timezone.utc)
|
||||
return ts.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_timestamp_for_timeline(
|
||||
raw: Any,
|
||||
) -> Tuple[Optional[datetime], Optional[date]]:
|
||||
if raw is None:
|
||||
return None, None
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None, None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None, None
|
||||
is_naive_midnight = (
|
||||
parsed.tzinfo is None
|
||||
and parsed.hour == 0
|
||||
and parsed.minute == 0
|
||||
and parsed.second == 0
|
||||
and parsed.microsecond == 0
|
||||
)
|
||||
return _as_utc(parsed), parsed.date() if is_naive_midnight else None
|
||||
|
||||
|
||||
def _timestamp_in_range(
|
||||
timestamp: Optional[datetime],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
*,
|
||||
all_day_date: Optional[date] = None,
|
||||
) -> bool:
|
||||
if timestamp is None or time_range is None:
|
||||
return timestamp is not None
|
||||
start, end = time_range
|
||||
if all_day_date is not None:
|
||||
if start is not None and all_day_date < start.date():
|
||||
return False
|
||||
if end is not None and all_day_date > end.date():
|
||||
return False
|
||||
return True
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
if start_utc is not None and timestamp < start_utc:
|
||||
return False
|
||||
if end_utc is not None and timestamp > end_utc:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HybridSearch
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -377,6 +528,128 @@ class HybridSearch:
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def _normalise_calendar_timeline_scope(
|
||||
self,
|
||||
query: str,
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> Tuple[
|
||||
Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
Optional[Sequence[str]],
|
||||
bool,
|
||||
bool,
|
||||
]:
|
||||
"""Fill in structured filters for generic upcoming-calendar requests.
|
||||
|
||||
Queries like "what are my next calendar events?" often have no useful
|
||||
lexical terms in the stored event text, so BM25/vector ranking can miss
|
||||
nearby events. Treat that shape as a source-filtered timeline request.
|
||||
"""
|
||||
scoped_sources = list(sources) if sources else None
|
||||
has_upcoming_intent = _has_upcoming_calendar_intent(query, scoped_sources)
|
||||
|
||||
if has_upcoming_intent and (
|
||||
scoped_sources is None or _sources_include_gcalendar(scoped_sources)
|
||||
):
|
||||
scoped_sources = ["gcalendar"]
|
||||
|
||||
if not _sources_include_gcalendar(scoped_sources):
|
||||
return time_range, scoped_sources, False, False
|
||||
|
||||
if has_upcoming_intent:
|
||||
if time_range is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), None)
|
||||
else:
|
||||
start, end = time_range
|
||||
if start is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), end)
|
||||
else:
|
||||
time_range = (_start_of_day(start), end)
|
||||
|
||||
chronological = has_upcoming_intent or (
|
||||
time_range is not None
|
||||
and time_range[1] is None
|
||||
and _start_is_nowish_or_future(time_range[0])
|
||||
)
|
||||
metadata_only = chronological and _is_generic_calendar_timeline_query(query)
|
||||
return time_range, scoped_sources, chronological, metadata_only
|
||||
|
||||
def _calendar_timeline_ids(
|
||||
self,
|
||||
*,
|
||||
person: Optional[str],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
limit: int,
|
||||
) -> List[str]:
|
||||
"""Return gcalendar rows sorted by normalized event start time."""
|
||||
filter_sql, filter_params = self._build_filters(
|
||||
person=person,
|
||||
time_range=None,
|
||||
sources=sources,
|
||||
)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp, created_at
|
||||
FROM knowledge_chunks
|
||||
WHERE {filter_sql}
|
||||
""",
|
||||
filter_params,
|
||||
).fetchall()
|
||||
|
||||
candidates: List[Tuple[str, datetime, float]] = []
|
||||
for row in rows:
|
||||
timestamp, all_day_date = _parse_timestamp_for_timeline(row["timestamp"])
|
||||
if not _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
):
|
||||
continue
|
||||
candidates.append(
|
||||
(
|
||||
row["id"],
|
||||
timestamp or datetime.max.replace(tzinfo=timezone.utc),
|
||||
float(row["created_at"] or 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda item: (item[1], item[2]))
|
||||
return [chunk_id for chunk_id, *_ in candidates[:limit]]
|
||||
|
||||
def _filter_calendar_timeline_fused(
|
||||
self,
|
||||
fused: List[Tuple[str, float, float, float]],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
) -> List[Tuple[str, float, float, float]]:
|
||||
"""Apply normalized timestamp filtering to ranked calendar candidates."""
|
||||
if not fused:
|
||||
return fused
|
||||
ids = [chunk_id for chunk_id, *_ in fused]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp
|
||||
FROM knowledge_chunks
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
ids,
|
||||
).fetchall()
|
||||
timestamps = {
|
||||
row["id"]: _parse_timestamp_for_timeline(row["timestamp"])
|
||||
for row in rows
|
||||
}
|
||||
|
||||
def _keeps_item(item: Tuple[str, float, float, float]) -> bool:
|
||||
timestamp, all_day_date = timestamps.get(item[0], (None, None))
|
||||
return _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
)
|
||||
|
||||
return [item for item in fused if _keeps_item(item)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
@@ -396,42 +669,65 @@ class HybridSearch:
|
||||
when callers want a pure metadata filter (e.g. "all mail from X in
|
||||
May") — in that case only the vector leg runs (and only if an
|
||||
embedder is configured); if neither leg yields anything the
|
||||
structured filter is applied directly and the most recent rows are
|
||||
returned.
|
||||
structured filter is applied directly. Upcoming calendar timelines are
|
||||
returned nearest-first; other fallbacks return the most recent rows.
|
||||
"""
|
||||
time_range, sources, chronological_order, metadata_only = (
|
||||
self._normalise_calendar_timeline_scope(query, time_range, sources)
|
||||
)
|
||||
rank_query = "" if metadata_only else query
|
||||
calendar_timeline = chronological_order and _sources_include_gcalendar(sources)
|
||||
recall_time_range = None if calendar_timeline else time_range
|
||||
|
||||
bm25_filter_sql, bm25_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources, alias="kc"
|
||||
person=person, time_range=recall_time_range, sources=sources, alias="kc"
|
||||
)
|
||||
unaliased_filter_sql, unaliased_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources
|
||||
person=person, time_range=recall_time_range, sources=sources
|
||||
)
|
||||
|
||||
bm25 = (
|
||||
self._bm25_recall(query, bm25_filter_sql, bm25_filter_params)
|
||||
if query.strip()
|
||||
self._bm25_recall(rank_query, bm25_filter_sql, bm25_filter_params)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
vector = (
|
||||
self._vector_recall(query, unaliased_filter_sql, unaliased_filter_params)
|
||||
if query.strip()
|
||||
self._vector_recall(
|
||||
rank_query,
|
||||
unaliased_filter_sql,
|
||||
unaliased_filter_params,
|
||||
)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
fused = self._fuse(bm25, vector)
|
||||
if calendar_timeline:
|
||||
fused = self._filter_calendar_timeline_fused(fused, time_range)
|
||||
|
||||
# Metadata-only fallback: empty query, or both legs produced nothing
|
||||
# despite a non-empty query. Return the most recent rows matching the
|
||||
# filter so the agent still gets a useful corpus snapshot.
|
||||
# despite a non-empty query. Calendar timeline requests use start-time
|
||||
# ascending; other searches use recency so the agent still gets a
|
||||
# useful corpus snapshot.
|
||||
if not fused:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
if calendar_timeline:
|
||||
chunk_ids = self._calendar_timeline_ids(
|
||||
person=person,
|
||||
time_range=time_range,
|
||||
sources=sources,
|
||||
limit=limit,
|
||||
)
|
||||
fused = [(chunk_id, 0.0, 0.0, 0.0) for chunk_id in chunk_ids]
|
||||
else:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
|
||||
# Materialise the top-N rows in one IN-clause round trip.
|
||||
top = fused[:limit]
|
||||
|
||||
@@ -593,6 +593,14 @@ class IntelligenceConfig:
|
||||
stop_sequences: str = "" # Comma-separated stop strings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeepResearchConfig:
|
||||
"""Planner settings for the web Deep Research endpoint."""
|
||||
|
||||
engine: str = "" # Empty means use the active chat engine.
|
||||
model: str = "" # Empty means use the active chat model.
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RoutingLearningConfig:
|
||||
"""Routing sub-policy config within Learning."""
|
||||
@@ -1578,6 +1586,7 @@ class JarvisConfig:
|
||||
hardware: HardwareInfo = field(default_factory=HardwareInfo)
|
||||
engine: EngineConfig = field(default_factory=EngineConfig)
|
||||
intelligence: IntelligenceConfig = field(default_factory=IntelligenceConfig)
|
||||
deep_research: DeepResearchConfig = field(default_factory=DeepResearchConfig)
|
||||
learning: LearningConfig = field(default_factory=LearningConfig)
|
||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||
agent: AgentConfig = field(default_factory=AgentConfig)
|
||||
@@ -1839,6 +1848,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
top_sections = (
|
||||
"engine",
|
||||
"intelligence",
|
||||
"deep_research",
|
||||
"learning",
|
||||
"agent",
|
||||
"server",
|
||||
@@ -2007,6 +2017,10 @@ max_tokens = 1024
|
||||
# repetition_penalty = 1.0
|
||||
# stop_sequences = ""
|
||||
|
||||
# [deep_research]
|
||||
# engine = "" # empty = use [engine].default
|
||||
# model = "" # empty = use [intelligence].default_model
|
||||
|
||||
[agent]
|
||||
default_agent = "simple"
|
||||
max_turns = 10
|
||||
@@ -2177,6 +2191,7 @@ __all__ = [
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
"DiscordChannelConfig",
|
||||
"DeepResearchConfig",
|
||||
"get_cache_dir",
|
||||
"get_config_dir",
|
||||
"get_config_path",
|
||||
|
||||
@@ -2263,14 +2263,14 @@ def create_agent_manager_router(
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
"https://api.sendblue.co/api/lines",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
"https://api.sendblue.co/api/lines",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
@@ -2290,12 +2290,16 @@ def create_agent_manager_router(
|
||||
)
|
||||
numbers = []
|
||||
for line in lines:
|
||||
num = (
|
||||
line.get("number")
|
||||
or line.get("phone_number")
|
||||
or line.get("from_number")
|
||||
or (line if isinstance(line, str) else "")
|
||||
)
|
||||
if isinstance(line, str):
|
||||
num = line
|
||||
elif isinstance(line, dict):
|
||||
num = (
|
||||
line.get("number")
|
||||
or line.get("phone_number")
|
||||
or line.get("from_number")
|
||||
)
|
||||
else:
|
||||
num = None
|
||||
if num:
|
||||
numbers.append(num)
|
||||
return {
|
||||
@@ -2327,18 +2331,18 @@ def create_agent_manager_router(
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
"https://api.sendblue.co/api/account/webhooks",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"receive": webhook_url,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.sendblue.co/api/account/webhooks",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"receive": webhook_url,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"registered": resp.status_code < 300,
|
||||
"status": resp.status_code,
|
||||
@@ -2378,16 +2382,16 @@ def create_agent_manager_router(
|
||||
if from_number:
|
||||
payload["from_number"] = from_number
|
||||
|
||||
resp = httpx.post(
|
||||
"https://api.sendblue.co/api/send-message",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=15.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.sendblue.co/api/send-message",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
return {
|
||||
"sent": resp.status_code < 300,
|
||||
"status": resp.status_code,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@@ -894,7 +895,12 @@ async def transcribe_speech(request: Request):
|
||||
ext = filename.rsplit(".", 1)[-1] if "." in filename else "wav"
|
||||
|
||||
try:
|
||||
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
|
||||
result = await asyncio.to_thread(
|
||||
backend.transcribe,
|
||||
audio_bytes,
|
||||
format=ext,
|
||||
language=language or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Speech transcription failed")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -27,7 +27,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -38,9 +38,10 @@ from openjarvis.agents.research_loop import (
|
||||
from openjarvis.connectors.embeddings import OllamaEmbedder
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR, JarvisConfig, load_config
|
||||
from openjarvis.core.types import TelemetryRecord
|
||||
from openjarvis.engine.ollama import OllamaEngine
|
||||
from openjarvis.engine._base import InferenceEngine
|
||||
from openjarvis.engine._discovery import get_engine
|
||||
from openjarvis.telemetry.store import TelemetryStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,13 +49,99 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["research"])
|
||||
|
||||
_WEB_CLARIFY_RESPONSE = "no clarification available in web session"
|
||||
_LEGACY_PLANNER_ENGINE = "ollama"
|
||||
|
||||
# Sentinel placed on the queue when the agent thread terminates.
|
||||
_DONE = object()
|
||||
|
||||
|
||||
def _first_nonempty(*values: str) -> str:
|
||||
for value in values:
|
||||
stripped = value.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_planner_config(
|
||||
config: JarvisConfig,
|
||||
*,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve the planner engine/model for web Deep Research.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. explicit ``[deep_research]`` overrides,
|
||||
2. the active chat engine/request model,
|
||||
3. server/config defaults,
|
||||
4. legacy Ollama/gemma4 fallback for unconfigured installs.
|
||||
"""
|
||||
engine_key = _first_nonempty(
|
||||
config.deep_research.engine,
|
||||
active_engine_key,
|
||||
config.engine.default,
|
||||
_LEGACY_PLANNER_ENGINE,
|
||||
)
|
||||
model = _first_nonempty(
|
||||
config.deep_research.model,
|
||||
request_model,
|
||||
active_model,
|
||||
config.server.model,
|
||||
config.intelligence.default_model,
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
return engine_key, model
|
||||
|
||||
|
||||
def _build_planner_engine(
|
||||
config: JarvisConfig,
|
||||
*,
|
||||
active_engine: InferenceEngine | None = None,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> tuple[str, InferenceEngine, str]:
|
||||
"""Instantiate the exact configured planner engine.
|
||||
|
||||
``get_engine`` intentionally falls back to any healthy engine for general
|
||||
chat routing. Deep Research must not do that here: if the configured chat
|
||||
engine is LM Studio but unavailable, silently falling back to Ollama would
|
||||
recreate the issue this endpoint is fixing.
|
||||
"""
|
||||
engine_key, model = _resolve_planner_config(
|
||||
config,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=request_model,
|
||||
)
|
||||
if active_engine is not None and not config.deep_research.engine.strip():
|
||||
if model and not active_engine.can_serve(model):
|
||||
raise RuntimeError(
|
||||
"Deep Research planner engine "
|
||||
f"{engine_key!r} cannot serve model {model!r}. "
|
||||
"Choose a compatible model or set [deep_research] engine/model "
|
||||
"in config.toml."
|
||||
)
|
||||
return engine_key, active_engine, model
|
||||
|
||||
resolved = get_engine(config, engine_key=engine_key, model=model)
|
||||
if resolved is None or resolved[0] != engine_key:
|
||||
raise RuntimeError(
|
||||
"Deep Research planner engine "
|
||||
f"{engine_key!r} is unavailable or cannot serve model {model!r}. "
|
||||
"Start the configured engine, load the configured model, or set "
|
||||
"[deep_research] engine/model in config.toml."
|
||||
)
|
||||
resolved_key, engine = resolved
|
||||
return resolved_key, engine, model
|
||||
|
||||
|
||||
def _record_research_telemetry(
|
||||
*,
|
||||
engine_key: str,
|
||||
model: str,
|
||||
usage: Dict[str, int],
|
||||
latency_seconds: float,
|
||||
@@ -86,7 +173,7 @@ def _record_research_telemetry(
|
||||
rec = TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id=model,
|
||||
engine="ollama",
|
||||
engine=engine_key,
|
||||
agent="research",
|
||||
prompt_tokens=int(usage.get("prompt_tokens", 0)),
|
||||
prompt_tokens_evaluated=int(usage.get("prompt_tokens", 0)),
|
||||
@@ -244,12 +331,11 @@ class _LiveGPUSampler:
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
query: str = Field(..., description="Natural-language question to research.")
|
||||
# Deep Research has its own model requirements (function-calling support,
|
||||
# sufficient reasoning capability) that the chat-model selector should not
|
||||
# override. We accept the field for forward-compat with older clients but
|
||||
# ignore it — the planner always runs on DEFAULT_PLANNER_MODEL.
|
||||
# Preferred planner model from the active chat selector. Server-side
|
||||
# [deep_research] config can still override it when a dedicated planner is
|
||||
# desired.
|
||||
model: Optional[str] = Field(
|
||||
default=None, description="Ignored; retained for client compatibility."
|
||||
default=None, description="Preferred planner model for this request."
|
||||
)
|
||||
|
||||
|
||||
@@ -290,7 +376,14 @@ def _chunk_synthesis(text: str, window_chars: int = 40) -> list[str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
async def _stream_research(
|
||||
query: str,
|
||||
*,
|
||||
active_engine: InferenceEngine | None = None,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Drive ResearchAgent on a worker thread; yield SSE frames as they land.
|
||||
|
||||
Three error envelopes — setup, worker, consumer — all funnel into the
|
||||
@@ -298,7 +391,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
``{"type": "done", "usage": {...}}``. The client can rely on always
|
||||
seeing a ``done`` frame, even when the agent never started.
|
||||
"""
|
||||
# Phase 1: setup. Failures here (Ollama daemon down, DB locked, etc.)
|
||||
# Phase 1: setup. Failures here (planner engine down, DB locked, etc.)
|
||||
# yield error + done and return — nothing has been emitted yet so the
|
||||
# client gets a clean two-frame stream instead of a dangling connection.
|
||||
try:
|
||||
@@ -309,6 +402,15 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
# Called from the agent's worker thread; bounce onto the event loop.
|
||||
loop.call_soon_threadsafe(queue.put_nowait, event)
|
||||
|
||||
config = load_config()
|
||||
engine_key, engine, model = _build_planner_engine(
|
||||
config,
|
||||
active_engine=active_engine,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=request_model,
|
||||
)
|
||||
|
||||
# Each request gets its own thin set of connectors. Constructing them
|
||||
# is cheap (SQLite open + HTTP keepalive) and avoids state leaks
|
||||
# between concurrent requests.
|
||||
@@ -320,7 +422,6 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
)
|
||||
embedder = None
|
||||
|
||||
engine = OllamaEngine()
|
||||
agent = ResearchAgent(
|
||||
engine=engine,
|
||||
search=HybridSearch(store, embedder),
|
||||
@@ -367,6 +468,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
# rolls research into the same Power/Energy numbers as chat —
|
||||
# this is what the launch-video System panel reads.
|
||||
_record_research_telemetry(
|
||||
engine_key=engine_key,
|
||||
model=model,
|
||||
usage=usage_dict,
|
||||
latency_seconds=time.time() - t0,
|
||||
@@ -472,7 +574,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
|
||||
|
||||
@router.post("/research")
|
||||
async def research(req: ResearchRequest) -> StreamingResponse:
|
||||
async def research(req: ResearchRequest, request: Request) -> StreamingResponse:
|
||||
"""Run a research query and stream the agent's trace + synthesis via SSE.
|
||||
|
||||
Response is ``text/event-stream`` with one JSON event per frame. See the
|
||||
@@ -480,14 +582,19 @@ async def research(req: ResearchRequest) -> StreamingResponse:
|
||||
terminates the stream so clients can detect end-of-response without
|
||||
parsing the underlying ``[DONE]`` sentinel used by OpenAI-style routes.
|
||||
"""
|
||||
if req.model and req.model != DEFAULT_PLANNER_MODEL:
|
||||
logger.info(
|
||||
"research: ignoring client model=%r; using DEFAULT_PLANNER_MODEL=%r",
|
||||
req.model,
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
active_engine = getattr(request.app.state, "engine", None)
|
||||
active_model = str(getattr(request.app.state, "model", "") or "")
|
||||
active_engine_key = str(getattr(request.app.state, "engine_name", "") or "")
|
||||
if active_engine is not None and not active_engine_key:
|
||||
active_engine_key = str(getattr(active_engine, "engine_id", "") or "")
|
||||
return StreamingResponse(
|
||||
_stream_research(req.query, DEFAULT_PLANNER_MODEL),
|
||||
_stream_research(
|
||||
req.query,
|
||||
active_engine=active_engine,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=req.model or "",
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
@@ -835,7 +836,7 @@ async def list_models(request: Request) -> ModelListResponse:
|
||||
# Filter out any cloud model IDs that may appear via MultiEngine.
|
||||
# Fall back to direct Ollama query only when the engine returns nothing.
|
||||
engine = request.app.state.engine
|
||||
all_ids = engine.list_models()
|
||||
all_ids = await asyncio.to_thread(engine.list_models)
|
||||
model_ids = [m for m in all_ids if not is_cloud_model(m)]
|
||||
if not model_ids:
|
||||
model_ids = await list_local_models()
|
||||
@@ -865,12 +866,12 @@ async def pull_model(request: Request):
|
||||
import httpx as _httpx
|
||||
|
||||
host = getattr(engine, "_host", "http://localhost:11434")
|
||||
client = _httpx.Client(base_url=host, timeout=600.0)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/pull",
|
||||
json={"name": model_name, "stream": False},
|
||||
)
|
||||
async with _httpx.AsyncClient(base_url=host, timeout=600.0) as client:
|
||||
resp = await client.post(
|
||||
"/api/pull",
|
||||
json={"name": model_name, "stream": False},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except (_httpx.ConnectError, _httpx.TimeoutException) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {exc}")
|
||||
@@ -879,8 +880,6 @@ async def pull_model(request: Request):
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Ollama error: {exc.response.text[:300]}",
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return {"status": "ok", "model": model_name}
|
||||
|
||||
@@ -896,13 +895,13 @@ async def delete_model(model_name: str, request: Request):
|
||||
import httpx as _httpx
|
||||
|
||||
host = getattr(engine, "_host", "http://localhost:11434")
|
||||
client = _httpx.Client(base_url=host, timeout=30.0)
|
||||
try:
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/delete",
|
||||
json={"name": model_name},
|
||||
)
|
||||
async with _httpx.AsyncClient(base_url=host, timeout=30.0) as client:
|
||||
resp = await client.request(
|
||||
"DELETE",
|
||||
"/api/delete",
|
||||
json={"name": model_name},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except (_httpx.ConnectError, _httpx.TimeoutException) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {exc}")
|
||||
@@ -911,8 +910,6 @@ async def delete_model(model_name: str, request: Request):
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Ollama error: {exc.response.text[:300]}",
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return {"status": "deleted", "model": model_name}
|
||||
|
||||
|
||||
@@ -90,6 +90,9 @@ class TelemetryAggregator:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self._db_path = str(db_path)
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
|
||||
def _time_filter(
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -148,6 +149,10 @@ class TelemetryStore:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self._db_path = str(db_path)
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._lock = threading.Lock()
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.execute(_CREATE_TABLE)
|
||||
self._conn.execute(_CREATE_MINING_STATS_TABLE)
|
||||
self._conn.commit()
|
||||
@@ -166,53 +171,54 @@ class TelemetryStore:
|
||||
|
||||
def record(self, rec: TelemetryRecord) -> None:
|
||||
"""Persist a single telemetry record."""
|
||||
self._conn.execute(
|
||||
_INSERT,
|
||||
(
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
_INSERT,
|
||||
(
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def record_mining_stats(self, stats: Any) -> None:
|
||||
"""Persist one mining stats snapshot.
|
||||
@@ -220,28 +226,29 @@ class TelemetryStore:
|
||||
``stats`` is duck-typed to keep telemetry usable without importing the
|
||||
optional mining package at module import time.
|
||||
"""
|
||||
self._conn.execute(
|
||||
"""\
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""\
|
||||
INSERT INTO mining_stats (
|
||||
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
|
||||
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
(
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent telemetry rows as dictionaries."""
|
||||
|
||||
@@ -395,6 +395,13 @@ def test_system_prompt_mandates_sources_extraction() -> None:
|
||||
assert "{available_sources}" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_routes_upcoming_calendar_as_structured_search() -> None:
|
||||
"""Upcoming calendar requests need source/time filters, not just keywords."""
|
||||
assert 'sources=["gcalendar"]' in SYSTEM_PROMPT
|
||||
assert 'time_range={{"start": "{today}"}}' in SYSTEM_PROMPT
|
||||
assert 'query=""' in SYSTEM_PROMPT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic available_sources — only list what the user actually has connected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,7 @@ All Calendar API calls are mocked; no network access is required.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from unittest.mock import patch
|
||||
@@ -134,6 +135,15 @@ def test_sync_yields_events(
|
||||
mock_events.assert_called_once()
|
||||
|
||||
|
||||
def test_parse_event_timestamp_handles_all_day_events() -> None:
|
||||
"""All-day events use their calendar date, not the current wall clock."""
|
||||
from openjarvis.connectors.gcalendar import _parse_event_timestamp # noqa: PLC0415
|
||||
|
||||
timestamp = _parse_event_timestamp({"start": {"date": "2024-05-26"}})
|
||||
|
||||
assert timestamp == datetime(2024, 5, 26)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — disconnect removes the credentials file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for source-aware HybridSearch behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
|
||||
|
||||
def _store_doc(
|
||||
store: KnowledgeStore,
|
||||
*,
|
||||
title: str,
|
||||
source: str,
|
||||
timestamp: datetime | str,
|
||||
) -> None:
|
||||
timestamp_text = (
|
||||
timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp
|
||||
)
|
||||
store.store(
|
||||
content=f"Title: {title}\nWhen: {timestamp_text}",
|
||||
source=source,
|
||||
doc_type="event" if source == "gcalendar" else "email",
|
||||
doc_id=f"{source}:{title.lower().replace(' ', '-')}",
|
||||
title=title,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def test_next_calendar_events_returns_nearest_gcalendar_rows() -> None:
|
||||
"""Generic upcoming-calendar queries should be chronological timelines."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Calendar Digest Email",
|
||||
source="gmail",
|
||||
timestamp=datetime(2999, 1, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Birthday Reminder",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 12, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Music Lesson",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 5, 26, 18, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Team Sync",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 5, 27, 10, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
search = HybridSearch(store)
|
||||
hits = search.search("what are my next calendar events?", limit=2)
|
||||
contraction_hits = search.search("what's next on my calendar?", limit=2)
|
||||
meetings_hits = search.search("what are my next meetings?", limit=2)
|
||||
mixed_source_hits = search.search(
|
||||
"what are my next calendar events?",
|
||||
sources=["gmail", "gcalendar"],
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in hits)
|
||||
assert [hit.title for hit in contraction_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in contraction_hits)
|
||||
assert [hit.title for hit in meetings_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in meetings_hits)
|
||||
assert [hit.title for hit in mixed_source_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in mixed_source_hits)
|
||||
|
||||
|
||||
def test_empty_upcoming_calendar_filter_uses_ascending_start_time() -> None:
|
||||
"""Planner-emitted structured calendar searches return nearest first."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Later Event",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 8, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Sooner Event",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 7, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
hits = HybridSearch(store).search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 1, 1, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Sooner Event", "Later Event"]
|
||||
|
||||
|
||||
def test_upcoming_calendar_timeline_normalizes_timestamp_offsets() -> None:
|
||||
"""Timeline filtering and ordering should compare instants, not ISO text."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Offset Earlier",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-01T00:30:00+02:00",
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="UTC Later",
|
||||
source="gcalendar",
|
||||
timestamp="2999-06-30T23:15:00+00:00",
|
||||
)
|
||||
|
||||
search = HybridSearch(store)
|
||||
hits = search.search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 6, 30, 22, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
later_hits = search.search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 6, 30, 23, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Offset Earlier", "UTC Later"]
|
||||
assert [hit.title for hit in later_hits] == ["UTC Later"]
|
||||
|
||||
|
||||
def test_upcoming_calendar_includes_today_all_day_events() -> None:
|
||||
"""Upcoming calendar intent starts at the day boundary for all-day events."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="All Day Today",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-01T00:00:00",
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Morning Tomorrow",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-02T09:00:00+00:00",
|
||||
)
|
||||
|
||||
hits = HybridSearch(store).search(
|
||||
"next calendar events",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 7, 1, 12, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
local_tz_hits = HybridSearch(store).search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(
|
||||
datetime(
|
||||
2999,
|
||||
7,
|
||||
1,
|
||||
12,
|
||||
tzinfo=timezone(timedelta(hours=-7)),
|
||||
),
|
||||
None,
|
||||
),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["All Day Today", "Morning Tomorrow"]
|
||||
assert [hit.title for hit in local_tz_hits] == [
|
||||
"All Day Today",
|
||||
"Morning Tomorrow",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for Deep Research planner configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.config import (
|
||||
DeepResearchConfig,
|
||||
HardwareInfo,
|
||||
JarvisConfig,
|
||||
generate_default_toml,
|
||||
load_config,
|
||||
validate_config_key,
|
||||
)
|
||||
|
||||
|
||||
def test_deep_research_config_defaults_to_chat_selection() -> None:
|
||||
cfg = JarvisConfig()
|
||||
|
||||
assert isinstance(cfg.deep_research, DeepResearchConfig)
|
||||
assert cfg.deep_research.engine == ""
|
||||
assert cfg.deep_research.model == ""
|
||||
|
||||
|
||||
def test_loads_deep_research_overrides(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[deep_research]",
|
||||
'engine = "lmstudio"',
|
||||
'model = "qwen/qwen3-14b"',
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
cfg = load_config(config_file)
|
||||
|
||||
assert cfg.deep_research.engine == "lmstudio"
|
||||
assert cfg.deep_research.model == "qwen/qwen3-14b"
|
||||
|
||||
|
||||
def test_deep_research_keys_are_settable() -> None:
|
||||
assert validate_config_key("deep_research.engine") is str
|
||||
assert validate_config_key("deep_research.model") is str
|
||||
|
||||
|
||||
def test_default_toml_documents_deep_research_override() -> None:
|
||||
toml = generate_default_toml(HardwareInfo())
|
||||
|
||||
assert "# [deep_research]" in toml
|
||||
assert '# engine = ""' in toml
|
||||
assert '# model = ""' in toml
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Guards for the openjarvis-rust packaging split (#584 / #615).
|
||||
|
||||
``openjarvis_rust`` is the native PyO3 extension. It is NOT published to PyPI,
|
||||
so it must not appear in the published ``desktop`` extra — listing it there
|
||||
breaks ``pip install openjarvis[desktop]`` at install time. It lives in the uv
|
||||
``desktop-native`` dependency group instead (excluded from wheel metadata),
|
||||
which the desktop app installs from source via
|
||||
``uv sync --group desktop-native``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
PYPROJECT = ROOT / "pyproject.toml"
|
||||
DESKTOP_LIB_RS = ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
|
||||
WINDOWS_INSTALL_PS1 = ROOT / "deploy" / "windows" / "install.ps1"
|
||||
|
||||
|
||||
def _pyproject() -> dict:
|
||||
return tomllib.loads(PYPROJECT.read_text())
|
||||
|
||||
|
||||
def test_openjarvis_rust_not_in_published_desktop_extra() -> None:
|
||||
desktop = _pyproject()["project"]["optional-dependencies"]["desktop"]
|
||||
assert not any("openjarvis-rust" in dep for dep in desktop), (
|
||||
"openjarvis-rust must not be in the published `desktop` extra — it is "
|
||||
"not on PyPI, so it breaks `pip install openjarvis[desktop]`."
|
||||
)
|
||||
|
||||
|
||||
def test_openjarvis_rust_lives_in_uv_dependency_group() -> None:
|
||||
group = _pyproject()["dependency-groups"]["desktop-native"]
|
||||
assert any("openjarvis-rust" in dep for dep in group)
|
||||
|
||||
|
||||
def test_openjarvis_rust_has_local_uv_path_source() -> None:
|
||||
src = _pyproject()["tool"]["uv"]["sources"]["openjarvis-rust"]
|
||||
assert src["path"] == "rust/crates/openjarvis-python"
|
||||
|
||||
|
||||
def test_desktop_app_syncs_the_native_group() -> None:
|
||||
# Otherwise the group's openjarvis_rust is never installed for the app.
|
||||
assert '"desktop-native"' in DESKTOP_LIB_RS.read_text(), (
|
||||
"the desktop app must `uv sync --group desktop-native` so the native "
|
||||
"extension is built at launch."
|
||||
)
|
||||
|
||||
|
||||
def test_windows_installer_syncs_the_native_group() -> None:
|
||||
# The Windows source installer does not run maturin separately.
|
||||
assert (
|
||||
"& $uvExe sync --extra desktop --group desktop-native"
|
||||
in WINDOWS_INSTALL_PS1.read_text()
|
||||
), (
|
||||
"the Windows installer must include `--group desktop-native` so "
|
||||
"openjarvis_rust is built during source install."
|
||||
)
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -48,6 +48,71 @@ class TestAgentManagerRoutes:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["agents"] == []
|
||||
|
||||
def test_sendblue_verify_uses_async_http_client(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = [
|
||||
"+15551234567",
|
||||
{"phone_number": "+15557654321"},
|
||||
None,
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
instance = mock_client_cls.return_value.__aenter__.return_value
|
||||
instance.get = AsyncMock(return_value=mock_resp)
|
||||
resp = client.post(
|
||||
"/v1/channels/sendblue/verify",
|
||||
json={"api_key_id": "key-id", "api_secret_key": "secret"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["valid"] is True
|
||||
assert resp.json()["numbers"] == ["+15551234567", "+15557654321"]
|
||||
instance.get.assert_awaited_once()
|
||||
|
||||
def test_sendblue_register_webhook_uses_async_http_client(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
instance = mock_client_cls.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
resp = client.post(
|
||||
"/v1/channels/sendblue/register-webhook",
|
||||
json={
|
||||
"api_key_id": "key-id",
|
||||
"api_secret_key": "secret",
|
||||
"webhook_url": "https://example.com/webhooks/sendblue",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["registered"] is True
|
||||
instance.post.assert_awaited_once()
|
||||
|
||||
def test_sendblue_test_message_uses_async_http_client(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
instance = mock_client_cls.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
resp = client.post(
|
||||
"/v1/channels/sendblue/test",
|
||||
json={
|
||||
"api_key_id": "key-id",
|
||||
"api_secret_key": "secret",
|
||||
"from_number": "+15550000000",
|
||||
"to_number": "+15551234567",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sent"] is True
|
||||
instance.post.assert_awaited_once()
|
||||
|
||||
def test_create_agent(self, client):
|
||||
resp = client.post(
|
||||
"/v1/managed-agents",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -75,10 +75,9 @@ class TestModelPull:
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("httpx.Client") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.post.return_value = mock_resp
|
||||
instance.close = MagicMock()
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
resp = client.post("/v1/models/pull", json={"model": "qwen3.5:4b"})
|
||||
|
||||
@@ -86,6 +85,10 @@ class TestModelPull:
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["model"] == "qwen3.5:4b"
|
||||
instance.post.assert_awaited_once_with(
|
||||
"/api/pull",
|
||||
json={"name": "qwen3.5:4b", "stream": False},
|
||||
)
|
||||
|
||||
def test_pull_ollama_unreachable(self):
|
||||
engine = _make_ollama_engine()
|
||||
@@ -93,10 +96,9 @@ class TestModelPull:
|
||||
|
||||
import httpx
|
||||
|
||||
with patch("httpx.Client") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.post.side_effect = httpx.ConnectError("refused")
|
||||
instance.close = MagicMock()
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
||||
|
||||
resp = client.post("/v1/models/pull", json={"model": "foo"})
|
||||
|
||||
@@ -123,10 +125,9 @@ class TestModelDelete:
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("httpx.Client") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.request.return_value = mock_resp
|
||||
instance.close = MagicMock()
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
resp = client.delete("/v1/models/qwen3:0.6b")
|
||||
|
||||
@@ -134,6 +135,11 @@ class TestModelDelete:
|
||||
data = resp.json()
|
||||
assert data["status"] == "deleted"
|
||||
assert data["model"] == "qwen3:0.6b"
|
||||
instance.request.assert_awaited_once_with(
|
||||
"DELETE",
|
||||
"/api/delete",
|
||||
json={"name": "qwen3:0.6b"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -268,3 +274,19 @@ class TestModelsEndpointExtended:
|
||||
assert resp.status_code == 200
|
||||
# The endpoint returns whatever list_models() gives
|
||||
assert resp.json()["object"] == "list"
|
||||
|
||||
def test_models_list_offloads_engine_list_models(self):
|
||||
engine = _make_engine(models=["qwen3.5:4b"])
|
||||
app = create_app(engine, "qwen3.5:4b")
|
||||
client = TestClient(app)
|
||||
|
||||
with patch(
|
||||
"openjarvis.server.routes.asyncio.to_thread",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_to_thread:
|
||||
mock_to_thread.return_value = ["qwen3.5:4b"]
|
||||
resp = client.get("/v1/models")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert [m["id"] for m in resp.json()["data"]] == ["qwen3.5:4b"]
|
||||
mock_to_thread.assert_awaited_once_with(engine.list_models)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Tests for web Deep Research planner engine selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.agents.research_loop import DEFAULT_PLANNER_MODEL
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.server import research_router
|
||||
|
||||
|
||||
class _DummyEngine:
|
||||
def __init__(self, servable: bool = True) -> None:
|
||||
self.servable = servable
|
||||
|
||||
def can_serve(self, model: str) -> bool:
|
||||
return self.servable
|
||||
|
||||
|
||||
def test_resolve_planner_config_uses_chat_defaults() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"lmstudio",
|
||||
"local-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_prefers_active_chat_runtime() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
|
||||
assert research_router._resolve_planner_config(
|
||||
cfg,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="server-model",
|
||||
request_model="selected-model",
|
||||
) == (
|
||||
"lmstudio",
|
||||
"selected-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_uses_server_model_before_legacy_default() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
cfg.server.model = "serve-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"ollama",
|
||||
"serve-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_deep_research_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.engine = "vllm"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"vllm",
|
||||
"planner-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_partial_model_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"lmstudio",
|
||||
"planner-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_partial_engine_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.engine = "vllm"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"vllm",
|
||||
"chat-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_keeps_legacy_fallback_when_unconfigured() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = ""
|
||||
cfg.intelligence.default_model = ""
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"ollama",
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def test_build_planner_engine_uses_configured_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
engine = _DummyEngine()
|
||||
calls: list[tuple[str | None, str | None]] = []
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
calls.append((engine_key, model))
|
||||
return "lmstudio", engine
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(cfg)
|
||||
|
||||
assert calls == [("lmstudio", "local-model")]
|
||||
assert engine_key == "lmstudio"
|
||||
assert resolved_engine is engine
|
||||
assert model == "local-model"
|
||||
|
||||
|
||||
def test_build_planner_engine_uses_active_engine_without_config_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
active_engine = _DummyEngine()
|
||||
|
||||
def fail_get_engine(*args: object, **kwargs: object) -> None:
|
||||
raise AssertionError("should use the live app engine")
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fail_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=active_engine,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="server-model",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
assert engine_key == "lmstudio"
|
||||
assert resolved_engine is active_engine
|
||||
assert model == "selected-model"
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_active_engine_that_cannot_serve_model() -> None:
|
||||
cfg = JarvisConfig()
|
||||
|
||||
with pytest.raises(RuntimeError, match="selected-model"):
|
||||
research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=_DummyEngine(servable=False),
|
||||
active_engine_key="cloud",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
|
||||
def test_build_planner_engine_honors_explicit_deep_research_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.deep_research.engine = "vllm"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
active_engine = _DummyEngine()
|
||||
planner_engine = _DummyEngine()
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
assert engine_key == "vllm"
|
||||
assert model == "planner-model"
|
||||
return "vllm", planner_engine
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=active_engine,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="chat-model",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
assert engine_key == "vllm"
|
||||
assert resolved_engine is planner_engine
|
||||
assert model == "planner-model"
|
||||
|
||||
|
||||
def test_research_route_passes_live_engine_and_selected_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
active_engine = _DummyEngine()
|
||||
|
||||
def fake_stream(query: str, **kwargs: object):
|
||||
captured["query"] = query
|
||||
captured.update(kwargs)
|
||||
|
||||
async def gen():
|
||||
yield "data: {\"type\":\"done\",\"usage\":{}}\n\n"
|
||||
|
||||
return gen()
|
||||
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
engine=active_engine,
|
||||
engine_name="lmstudio",
|
||||
model="server-model",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(research_router, "_stream_research", fake_stream)
|
||||
|
||||
response = asyncio.run(
|
||||
research_router.research(
|
||||
research_router.ResearchRequest(
|
||||
query="find notes",
|
||||
model="selected-model",
|
||||
),
|
||||
request, # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
|
||||
assert response.media_type == "text/event-stream"
|
||||
assert captured == {
|
||||
"query": "find notes",
|
||||
"active_engine": active_engine,
|
||||
"active_engine_key": "lmstudio",
|
||||
"active_model": "server-model",
|
||||
"request_model": "selected-model",
|
||||
}
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_fallback_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
return "ollama", _DummyEngine()
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
with pytest.raises(RuntimeError, match="lmstudio"):
|
||||
research_router._build_planner_engine(cfg)
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_unavailable_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", lambda *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="local-model"):
|
||||
research_router._build_planner_engine(cfg)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for speech API endpoints."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -56,6 +56,33 @@ def test_transcribe_endpoint(client, mock_speech_backend):
|
||||
assert data["duration_seconds"] == 1.5
|
||||
|
||||
|
||||
def test_transcribe_endpoint_offloads_backend_work(client, mock_speech_backend):
|
||||
expected = TranscriptionResult(
|
||||
text="Offloaded",
|
||||
language="en",
|
||||
confidence=0.9,
|
||||
duration_seconds=1.0,
|
||||
segments=[],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openjarvis.server.api_routes.asyncio.to_thread",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_to_thread:
|
||||
mock_to_thread.return_value = expected
|
||||
response = client.post(
|
||||
"/v1/speech/transcribe",
|
||||
files={"file": ("test.wav", b"fake audio data", "audio/wav")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_to_thread.assert_awaited_once()
|
||||
args, kwargs = mock_to_thread.await_args
|
||||
assert args == (mock_speech_backend.transcribe, b"fake audio data")
|
||||
assert kwargs == {"format": "wav", "language": None}
|
||||
assert response.json()["text"] == "Offloaded"
|
||||
|
||||
|
||||
def test_transcribe_endpoint_surfaces_backend_error(client, mock_speech_backend):
|
||||
mock_speech_backend.transcribe.side_effect = RuntimeError("missing cublas64_12.dll")
|
||||
|
||||
|
||||
@@ -49,6 +49,21 @@ def _setup(tmp_path: Path, records: list[TelemetryRecord] | None = None):
|
||||
|
||||
|
||||
class TestTelemetryAggregator:
|
||||
def test_uses_wal_with_normal_synchronous_and_busy_timeout(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agg = _setup(tmp_path)
|
||||
|
||||
journal_mode = agg._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
synchronous = agg._conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
busy_timeout = agg._conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||
|
||||
assert journal_mode.lower() == "wal"
|
||||
assert synchronous == 1
|
||||
assert busy_timeout == 5000
|
||||
agg.close()
|
||||
|
||||
def test_empty_db_summary(self, tmp_path: Path) -> None:
|
||||
agg = _setup(tmp_path)
|
||||
s = agg.summary()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
@@ -17,6 +18,35 @@ class TestTelemetryStore:
|
||||
assert rows == []
|
||||
store.close()
|
||||
|
||||
def test_uses_wal_with_normal_synchronous(self, tmp_path: Path) -> None:
|
||||
store = TelemetryStore(tmp_path / "test.db")
|
||||
journal_mode = store._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
synchronous = store._conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
busy_timeout = store._conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||
|
||||
assert journal_mode.lower() == "wal"
|
||||
assert synchronous == 1
|
||||
assert busy_timeout == 5000
|
||||
store.close()
|
||||
|
||||
def test_concurrent_record_writes_are_serialized(self, tmp_path: Path) -> None:
|
||||
store = TelemetryStore(tmp_path / "test.db")
|
||||
|
||||
def write_one(i: int) -> None:
|
||||
store.record(
|
||||
TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id=f"model-{i}",
|
||||
engine="test",
|
||||
)
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
list(pool.map(write_one, range(32)))
|
||||
|
||||
assert len(store._fetchall()) == 32
|
||||
store.close()
|
||||
|
||||
def test_record_values(self, tmp_path: Path) -> None:
|
||||
store = TelemetryStore(tmp_path / "test.db")
|
||||
rec = TelemetryRecord(
|
||||
|
||||
@@ -4206,7 +4206,6 @@ dashboard = [
|
||||
desktop = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "openjarvis-rust" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn" },
|
||||
@@ -4339,6 +4338,9 @@ tools-search = [
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
desktop-native = [
|
||||
{ name = "openjarvis-rust" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "maturin" },
|
||||
]
|
||||
@@ -4391,7 +4393,6 @@ requires-dist = [
|
||||
{ name = "openai", marker = "extra == 'inference-cloud'", specifier = ">=1.30" },
|
||||
{ name = "openai", marker = "extra == 'media'", specifier = ">=1.30" },
|
||||
{ name = "openhands-sdk", marker = "python_full_version >= '3.12' and extra == 'openhands'", specifier = ">=1.0" },
|
||||
{ name = "openjarvis-rust", marker = "extra == 'desktop'", directory = "rust/crates/openjarvis-python" },
|
||||
{ name = "pdfplumber", marker = "extra == 'memory-pdf'", specifier = ">=0.10" },
|
||||
{ name = "pdfplumber", marker = "extra == 'pdf'", specifier = ">=0.10" },
|
||||
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" },
|
||||
@@ -4445,6 +4446,7 @@ requires-dist = [
|
||||
provides-extras = ["browser", "channel-discord", "channel-gmail", "channel-line", "channel-mastodon", "channel-messenger", "channel-nostr", "channel-reddit", "channel-rocketchat", "channel-slack", "channel-telegram", "channel-twilio", "channel-twitch", "channel-twitter", "channel-viber", "channel-xmpp", "channel-zulip", "dashboard", "desktop", "dev", "docs", "energy-all", "energy-amd", "energy-apple", "eval-sheets", "eval-wandb", "framework-comparison", "gpu-metrics", "inference-cloud", "inference-gemma", "inference-google", "inference-litellm", "inference-mlx", "inference-vllm", "learning-dspy", "learning-gepa", "media", "memory-bm25", "memory-colbert", "memory-faiss", "memory-pdf", "mining-pearl-cpu", "mining-pearl-vllm", "openhands", "orchestrator-training", "pdf", "sandbox-docker", "sandbox-wasm", "scheduler", "security-signing", "server", "speech", "speech-deepgram", "tools-search"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
desktop-native = [{ name = "openjarvis-rust", directory = "rust/crates/openjarvis-python" }]
|
||||
dev = [{ name = "maturin", specifier = ">=1.12.6" }]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user