diff --git a/journal/ai/analysis.py b/journal/ai/analysis.py index 72bf820..bf5aac5 100644 --- a/journal/ai/analysis.py +++ b/journal/ai/analysis.py @@ -20,6 +20,14 @@ from journal.core.config import ( MODEL_CONTEXT_TOKENS, CHUNK_TOKEN_BUDGET, ) +from journal.ai.api_compat import ( + build_text_payload, + detect_text_endpoint_kind, + extract_embedding_response, + extract_text_response, + normalize_embedding_url, + normalize_endpoint_url, +) _BACKEND_AUTO = "auto" _BACKEND_SPACY = "spacy" @@ -137,7 +145,8 @@ def llama_cpp_generate( temperature: float = 0.7, max_tokens: int = 2048, ) -> str: - llama_url = os.getenv("JOURNAL_LLAMA_CPP_URL", LLAMA_CPP_URL).strip() or LLAMA_CPP_URL + raw_llama_url = os.getenv("JOURNAL_LLAMA_CPP_URL", LLAMA_CPP_URL).strip() or LLAMA_CPP_URL + llama_url = normalize_endpoint_url(raw_llama_url, "/v1/completions") llama_model = model or os.getenv("JOURNAL_LLAMA_CPP_MODEL", LLAMA_CPP_MODEL).strip() or LLAMA_CPP_MODEL timeout_raw = os.getenv("JOURNAL_LLAMA_CPP_TIMEOUT", str(LLAMA_CPP_TIMEOUT)).strip() try: @@ -147,28 +156,27 @@ def llama_cpp_generate( if llama_timeout <= 0: llama_timeout = LLAMA_CPP_TIMEOUT - payload = { - "model": llama_model, - "prompt": prompt, - "max_tokens": max_tokens, - "temperature": temperature, - "stop": [], - "stream": False, - } + endpoint_kind = detect_text_endpoint_kind(llama_url) + payload = build_text_payload( + prompt, + llama_model, + endpoint_kind, + temperature=temperature, + max_tokens=max_tokens, + ) try: response = requests.post(llama_url, json=payload, timeout=llama_timeout) response.raise_for_status() data = response.json() - # llama.cpp returns choices array with text field - if "choices" in data and len(data["choices"]) > 0: - result = data["choices"][0]["text"].strip() - print(f"DEBUG: Generated {len(result)} characters") # Debug output - if len(result) < 10: # If very short response + result = extract_text_response(data) + if result: + print(f"DEBUG: Generated {len(result)} characters") + if len(result) < 10: print(f"DEBUG: Short response: '{result}'") return result - else: - print("DEBUG: No choices in response") - return "No response generated." + + print("DEBUG: No parsable text in response payload") + return "No response generated." except Exception as e: print(f"DEBUG: Exception occurred: {e}") return f"Error communicating with llama.cpp server: {e}" @@ -178,7 +186,8 @@ def generate_embedding(text: str) -> list[float]: """ Generates an embedding for the given text using the configured embedding model. """ - embedding_url = os.getenv("JOURNAL_EMBEDDING_API_URL", EMBEDDING_API_URL).strip() or EMBEDDING_API_URL + raw_embedding_url = os.getenv("JOURNAL_EMBEDDING_API_URL", EMBEDDING_API_URL).strip() or EMBEDDING_API_URL + embedding_url = normalize_embedding_url(raw_embedding_url) embedding_model = ( os.getenv("JOURNAL_EMBEDDING_MODEL_NAME", EMBEDDING_MODEL_NAME).strip() or EMBEDDING_MODEL_NAME @@ -201,11 +210,11 @@ def generate_embedding(text: str) -> list[float]: ) # Reusing LLAMA_CPP_TIMEOUT for now response.raise_for_status() data = response.json() - if "data" in data and len(data["data"]) > 0 and "embedding" in data["data"][0]: - return data["data"][0]["embedding"] - else: - print("DEBUG: No embedding data in response") - return [] + embedding = extract_embedding_response(data) + if embedding: + return embedding + print("DEBUG: No embedding data in response") + return [] except Exception as e: print(f"DEBUG: Exception occurred during embedding generation: {e}") return [] diff --git a/journal/ai/api_compat.py b/journal/ai/api_compat.py new file mode 100644 index 0000000..7d44f17 --- /dev/null +++ b/journal/ai/api_compat.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from typing import Any, Literal +from urllib.parse import urlparse, urlunparse + +EndpointKind = Literal[ + "chat_completions", + "responses", + "lmstudio_chat", + "completions", + "legacy_prompt", +] + + +def _ensure_scheme(url: str) -> str: + value = url.strip() + if not value: + return value + if value.startswith("http://") or value.startswith("https://"): + return value + return f"http://{value}" + + +def normalize_endpoint_url(raw_url: str, default_path: str) -> str: + value = _ensure_scheme(raw_url) + if not value: + return value + parsed = urlparse(value) + path = parsed.path or "" + if path in {"", "/"}: + path = default_path + elif not path.startswith("/"): + path = f"/{path}" + return urlunparse(parsed._replace(path=path)) + + +def detect_text_endpoint_kind(url: str) -> EndpointKind: + path = urlparse(url).path.lower() + if path.endswith("/v1/chat/completions"): + return "chat_completions" + if path.endswith("/v1/responses"): + return "responses" + if path.endswith("/api/v1/chat"): + return "lmstudio_chat" + if path.endswith("/v1/completions"): + return "completions" + return "legacy_prompt" + + +def build_text_payload( + prompt: str, + model: str, + endpoint_kind: EndpointKind, + temperature: float | None = None, + max_tokens: int | None = None, +) -> dict[str, Any]: + if endpoint_kind in {"chat_completions", "lmstudio_chat"}: + payload: dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + } + if temperature is not None: + payload["temperature"] = temperature + if max_tokens is not None: + payload["max_tokens"] = max_tokens + return payload + + if endpoint_kind == "responses": + payload = {"model": model, "input": prompt} + if temperature is not None: + payload["temperature"] = temperature + if max_tokens is not None: + payload["max_output_tokens"] = max_tokens + return payload + + if endpoint_kind == "completions": + payload = { + "model": model, + "prompt": prompt, + "stream": False, + } + if temperature is not None: + payload["temperature"] = temperature + if max_tokens is not None: + payload["max_tokens"] = max_tokens + return payload + + payload = {"prompt": prompt} + if model: + payload["model"] = model + if temperature is not None: + payload["temperature"] = temperature + if max_tokens is not None: + payload["max_tokens"] = max_tokens + return payload + + +def _content_to_text(content: Any) -> str: + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(part.strip() for part in parts if part.strip()).strip() + return "" + + +def extract_text_response(data: Any) -> str: + if isinstance(data, str): + return data.strip() + if not isinstance(data, dict): + return "" + + response = data.get("response") + if isinstance(response, str) and response.strip(): + return response.strip() + + output_text = data.get("output_text") + if isinstance(output_text, str) and output_text.strip(): + return output_text.strip() + + message = data.get("message") + if isinstance(message, dict): + content = _content_to_text(message.get("content")) + if content: + return content + + choices = data.get("choices") + if isinstance(choices, list) and choices: + first = choices[0] + if isinstance(first, dict): + text = first.get("text") + if isinstance(text, str) and text.strip(): + return text.strip() + msg = first.get("message") + if isinstance(msg, dict): + content = _content_to_text(msg.get("content")) + if content: + return content + delta = first.get("delta") + if isinstance(delta, dict): + content = _content_to_text(delta.get("content")) + if content: + return content + + output = data.get("output") + if isinstance(output, list): + parts: list[str] = [] + for item in output: + if not isinstance(item, dict): + continue + content_list = item.get("content") + if not isinstance(content_list, list): + continue + for content_item in content_list: + if not isinstance(content_item, dict): + continue + text = content_item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + if parts: + return "\n".join(parts) + + return "" + + +def normalize_embedding_url(raw_url: str) -> str: + return normalize_endpoint_url(raw_url, "/v1/embeddings") + + +def extract_embedding_response(data: Any) -> list[float]: + if not isinstance(data, dict): + return [] + embedding = data.get("embedding") + if isinstance(embedding, list): + return [float(value) for value in embedding] + entries = data.get("data") + if isinstance(entries, list) and entries: + first = entries[0] + if isinstance(first, dict): + values = first.get("embedding") + if isinstance(values, list): + return [float(value) for value in values] + return [] diff --git a/journal/ai/chat.py b/journal/ai/chat.py index 4d1c351..2c6c1f2 100644 --- a/journal/ai/chat.py +++ b/journal/ai/chat.py @@ -1,14 +1,44 @@ +import os + import requests -import os -from journal.core.config import CLOUDAI_API_KEY, CLOUDAI_API_URL, CLOUDAI_TIMEOUT + +from journal.ai.api_compat import ( + build_text_payload, + detect_text_endpoint_kind, + extract_text_response, + normalize_endpoint_url, +) +from journal.core.config import ( + CLOUDAI_API_KEY, + CLOUDAI_API_URL, + CLOUDAI_CONNECT_TIMEOUT, + CLOUDAI_TIMEOUT, + LLAMA_CPP_MODEL, + LLAMA_CPP_URL, +) def get_cloud_ai_response(prompt: str) -> str: """ - Gets a response from the cloud AI service. + Gets a response from the configured AI endpoint. + + Supports: + - OpenAI-compatible chat/completions and responses APIs + - LM Studio native /api/v1/chat + - Legacy prompt APIs returning {"response": ...} """ api_key = os.getenv("JOURNAL_CLOUDAI_API_KEY", CLOUDAI_API_KEY).strip() - api_url = os.getenv("JOURNAL_CLOUDAI_API_URL", CLOUDAI_API_URL).strip() + raw_api_url = ( + os.getenv("JOURNAL_CLOUDAI_API_URL", CLOUDAI_API_URL).strip() + or os.getenv("JOURNAL_LLAMA_CPP_URL", "").strip() + or LLAMA_CPP_URL + ) + api_url = normalize_endpoint_url(raw_api_url, "/v1/chat/completions") + model = ( + os.getenv("JOURNAL_CLOUDAI_MODEL", "").strip() + or os.getenv("JOURNAL_LLAMA_CPP_MODEL", "").strip() + or LLAMA_CPP_MODEL + ) timeout_raw = os.getenv("JOURNAL_CLOUDAI_TIMEOUT", str(CLOUDAI_TIMEOUT)).strip() try: timeout_seconds = int(timeout_raw) @@ -17,14 +47,33 @@ def get_cloud_ai_response(prompt: str) -> str: if timeout_seconds <= 0: timeout_seconds = CLOUDAI_TIMEOUT - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - payload = {"prompt": prompt} + connect_timeout_raw = os.getenv( + "JOURNAL_CLOUDAI_CONNECT_TIMEOUT", str(CLOUDAI_CONNECT_TIMEOUT) + ).strip() try: - response = requests.post(api_url, headers=headers, json=payload, timeout=timeout_seconds) + connect_timeout_seconds = int(connect_timeout_raw) + except ValueError: + connect_timeout_seconds = CLOUDAI_CONNECT_TIMEOUT + if connect_timeout_seconds <= 0: + connect_timeout_seconds = CLOUDAI_CONNECT_TIMEOUT + + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + endpoint_kind = detect_text_endpoint_kind(api_url) + payload = build_text_payload(prompt, model, endpoint_kind) + + try: + response = requests.post( + api_url, + headers=headers, + json=payload, + timeout=(connect_timeout_seconds, timeout_seconds), + ) response.raise_for_status() - return response.json().get("response", "No response from AI.") + body = response.json() + text = extract_text_response(body) + return text or "No response from AI." except requests.exceptions.RequestException as e: - return f"Error communicating with Cloud AI: {e}" + return f"Error communicating with Cloud AI ({api_url}): {e}" diff --git a/journal/cli/main.py b/journal/cli/main.py index b356b4a..d971e9b 100644 --- a/journal/cli/main.py +++ b/journal/cli/main.py @@ -15,6 +15,14 @@ sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) from journal.core.storage import rebuild_all_vaults, load_all_vaults from journal.core.parser import parse_journal_file from journal.core.models import JournalEntry +from journal.core.fragments import ( + list_fragments, + create_fragment, + get_fragment, + update_fragment, + delete_fragment, + search_fragments, +) from journal.core.config import DATA_DIR, PID_FILE, PROJECT_ROOT, BACKEND_MODE from journal.core.csharp_sidecar import call_sidecar_action @@ -330,29 +338,23 @@ def main(): try: if args.fragments_action == "list": - result = call_sidecar_action("fragments.list") + result = list_fragments() _print_fragments(result) return if args.fragments_action == "create": - payload: dict[str, object] = { - "type": args.fragment_type or "", - "description": args.fragment_description or "", - } - if args.fragment_time: - payload["time"] = args.fragment_time - if args.fragment_tags: - payload["tags"] = args.fragment_tags - created = call_sidecar_action("fragments.create", payload=payload) + created = create_fragment( + type_=args.fragment_type or "", + description=args.fragment_description or "", + time=args.fragment_time, + tags=args.fragment_tags, + ) print("Fragment created.") _print_json(created) return if args.fragments_action == "get": - fragment = call_sidecar_action( - "fragments.get", - command_fields={"id": args.fragment_id}, - ) + fragment = get_fragment(args.fragment_id or "") if fragment is None: print("Fragment not found.") return @@ -360,26 +362,13 @@ def main(): return if args.fragments_action == "update": - payload: dict[str, object] = {} - if args.fragment_type is not None: - payload["type"] = args.fragment_type - if args.fragment_description is not None: - payload["description"] = args.fragment_description - if args.fragment_time is not None: - payload["time"] = args.fragment_time - if args.fragment_clear_tags: - payload["tags"] = [] - elif args.fragment_tags is not None: - payload["tags"] = args.fragment_tags - - if not payload: - print("No update fields provided. Use --type/--description/--time/--tag/--clear-tags.") - return - - updated = call_sidecar_action( - "fragments.update", - payload=payload, - command_fields={"id": args.fragment_id}, + updated = update_fragment( + args.fragment_id or "", + type_=args.fragment_type, + description=args.fragment_description, + time=args.fragment_time, + tags=args.fragment_tags, + clear_tags=args.fragment_clear_tags, ) if updated: print("Fragment updated.") @@ -388,10 +377,7 @@ def main(): return if args.fragments_action == "delete": - deleted = call_sidecar_action( - "fragments.delete", - command_fields={"id": args.fragment_id}, - ) + deleted = delete_fragment(args.fragment_id or "") if deleted: print("Fragment deleted.") else: @@ -399,15 +385,15 @@ def main(): return if args.fragments_action == "search": - results = call_sidecar_action( - "fragments.search", - command_fields={ - "type": args.fragment_type, - "tag": args.fragment_tag, - }, + results = search_fragments( + type_=args.fragment_type, + tag=args.fragment_tag, ) _print_fragments(results) return + except ValueError as e: + print(str(e)) + return except Exception as e: print(f"Fragment command failed: {e}") return diff --git a/journal/core/config.py b/journal/core/config.py index 667e43e..0598287 100644 --- a/journal/core/config.py +++ b/journal/core/config.py @@ -46,9 +46,17 @@ MONTHLY_VAULT_FORMAT = "%Y-%m.vault" # e.g., 2025-07.vault # --- AI Configuration --- CLOUDAI_API_KEY = os.getenv("JOURNAL_CLOUDAI_API_KEY", "").strip() CLOUDAI_API_URL = os.getenv("JOURNAL_CLOUDAI_API_URL", "").strip() -CLOUDAI_TIMEOUT = int(os.getenv("JOURNAL_CLOUDAI_TIMEOUT", "30").strip() or "30") +# Read timeout (seconds). Keep high by default for slow local inference. +CLOUDAI_TIMEOUT = int(os.getenv("JOURNAL_CLOUDAI_TIMEOUT", "7200").strip() or "7200") if CLOUDAI_TIMEOUT <= 0: - CLOUDAI_TIMEOUT = 30 + CLOUDAI_TIMEOUT = 7200 + +# Connection timeout (seconds) should stay short; only inference read can be long. +CLOUDAI_CONNECT_TIMEOUT = int( + os.getenv("JOURNAL_CLOUDAI_CONNECT_TIMEOUT", "10").strip() or "10" +) +if CLOUDAI_CONNECT_TIMEOUT <= 0: + CLOUDAI_CONNECT_TIMEOUT = 10 LLAMA_CPP_URL = ( os.getenv("JOURNAL_LLAMA_CPP_URL", "http://127.0.0.1:8085/v1/completions").strip() diff --git a/journal/core/fragments.py b/journal/core/fragments.py index 27f8470..4c778b3 100644 --- a/journal/core/fragments.py +++ b/journal/core/fragments.py @@ -1,10 +1,105 @@ -from .models import Fragment -from datetime import datetime +from __future__ import annotations + +from typing import Any + +from .config import BACKEND_MODE +from .csharp_sidecar import call_sidecar_action + + +def _require_hybrid_mode() -> None: + if BACKEND_MODE != "csharp-hybrid": + raise RuntimeError("Fragments backend requires JOURNAL_BACKEND_MODE=csharp-hybrid.") + + +def list_fragments() -> list[dict[str, Any]]: + _require_hybrid_mode() + result = call_sidecar_action("fragments.list") + if not isinstance(result, list): + return [] + return [item for item in result if isinstance(item, dict)] def create_fragment( - type_: str, description: str, tags: list[str], time: str | None = None -): - if not time: - time = datetime.now().strftime("%H:%M") - return Fragment(type=type_, time=time, tags=tags, description=description) + type_: str, + description: str, + tags: list[str] | None = None, + time: str | None = None, +) -> dict[str, Any]: + _require_hybrid_mode() + payload: dict[str, Any] = { + "type": type_, + "description": description, + } + if time: + payload["time"] = time + if tags: + payload["tags"] = tags + result = call_sidecar_action("fragments.create", payload=payload) + return result if isinstance(result, dict) else {"result": result} + + +def get_fragment(fragment_id: str) -> dict[str, Any] | None: + _require_hybrid_mode() + result = call_sidecar_action( + "fragments.get", + command_fields={"id": fragment_id}, + ) + return result if isinstance(result, dict) else None + + +def update_fragment( + fragment_id: str, + *, + type_: str | None = None, + description: str | None = None, + time: str | None = None, + tags: list[str] | None = None, + clear_tags: bool = False, +) -> bool: + _require_hybrid_mode() + payload: dict[str, Any] = {} + if type_ is not None: + payload["type"] = type_ + if description is not None: + payload["description"] = description + if time is not None: + payload["time"] = time + if clear_tags: + payload["tags"] = [] + elif tags is not None: + payload["tags"] = tags + + if not payload: + raise ValueError( + "No update fields provided. Use --type/--description/--time/--tag/--clear-tags." + ) + + result = call_sidecar_action( + "fragments.update", + payload=payload, + command_fields={"id": fragment_id}, + ) + return bool(result) + + +def delete_fragment(fragment_id: str) -> bool: + _require_hybrid_mode() + result = call_sidecar_action( + "fragments.delete", + command_fields={"id": fragment_id}, + ) + return bool(result) + + +def search_fragments(type_: str | None = None, tag: str | None = None) -> list[dict[str, Any]]: + _require_hybrid_mode() + result = call_sidecar_action( + "fragments.search", + command_fields={ + "type": type_, + "tag": tag, + }, + ) + if not isinstance(result, list): + return [] + return [item for item in result if isinstance(item, dict)] diff --git a/journal/ui/components/settings.py b/journal/ui/components/settings.py index 7f62d2c..31e9f8a 100644 --- a/journal/ui/components/settings.py +++ b/journal/ui/components/settings.py @@ -9,6 +9,7 @@ from journal.core.config import ( CHUNK_TOKEN_BUDGET, CLOUDAI_API_KEY, CLOUDAI_API_URL, + CLOUDAI_CONNECT_TIMEOUT, CLOUDAI_TIMEOUT, CSHARP_SIDECAR_PATH, EMBEDDING_API_URL, @@ -41,6 +42,7 @@ _DEFAULT_SETTINGS: dict[str, Any] = { "cloudai_api_url": CLOUDAI_API_URL, "cloudai_api_key": CLOUDAI_API_KEY, "cloudai_timeout": CLOUDAI_TIMEOUT, + "cloudai_connect_timeout": CLOUDAI_CONNECT_TIMEOUT, "model_context_tokens": MODEL_CONTEXT_TOKENS, "chunk_token_budget": CHUNK_TOKEN_BUDGET, } @@ -61,6 +63,7 @@ _ENV_KEYS: dict[str, str] = { "cloudai_api_url": "JOURNAL_CLOUDAI_API_URL", "cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY", "cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT", + "cloudai_connect_timeout": "JOURNAL_CLOUDAI_CONNECT_TIMEOUT", "model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS", "chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET", } @@ -179,7 +182,7 @@ def settings_dialog(): _ = ui.label("AI: Local Model Endpoints").classes("text-lg font-medium") _ = ( ui.input( - label="Llama.cpp URL", + label="AI Text URL (analysis)", value=cast(str, app.storage.user["llama_cpp_url"]), on_change=_on_text_change( "llama_cpp_url", "JOURNAL_LLAMA_CPP_URL", "Llama.cpp URL" @@ -201,7 +204,7 @@ def settings_dialog(): ) _ = ( ui.number( - label="Llama.cpp Timeout (ms)", + label="Llama.cpp Timeout (s)", value=float(cast(str, app.storage.user["llama_cpp_timeout"])), on_change=_on_int_change( "llama_cpp_timeout", "JOURNAL_LLAMA_CPP_TIMEOUT", "Llama timeout" @@ -265,7 +268,7 @@ def settings_dialog(): _ = ui.label("AI: Cloud Endpoint").classes("text-lg font-medium") _ = ( ui.input( - label="Cloud AI URL", + label="AI Chat URL", value=cast(str, app.storage.user["cloudai_api_url"]), on_change=_on_text_change( "cloudai_api_url", "JOURNAL_CLOUDAI_API_URL", "Cloud AI URL" @@ -298,6 +301,19 @@ def settings_dialog(): .bind_value(app.storage.user, "cloudai_timeout") .classes("w-full") ) + _ = ( + ui.number( + label="Cloud AI Connect Timeout (s)", + value=float(cast(str, app.storage.user["cloudai_connect_timeout"])), + on_change=_on_int_change( + "cloudai_connect_timeout", + "JOURNAL_CLOUDAI_CONNECT_TIMEOUT", + "Cloud AI connect timeout", + ), + ) + .bind_value(app.storage.user, "cloudai_connect_timeout") + .classes("w-full") + ) _ = ui.separator().classes("my-2") _ = ui.label("NLP + Speech").classes("text-lg font-medium") diff --git a/journal/ui/main.py b/journal/ui/main.py index a8d4341..9bce0c2 100644 --- a/journal/ui/main.py +++ b/journal/ui/main.py @@ -55,6 +55,7 @@ from journal.core.config import ( EMBEDDING_MODEL_NAME, CLOUDAI_API_URL, CLOUDAI_API_KEY, + CLOUDAI_CONNECT_TIMEOUT, CLOUDAI_TIMEOUT, MODEL_CONTEXT_TOKENS, CHUNK_TOKEN_BUDGET, @@ -219,6 +220,7 @@ _USER_SETTINGS_DEFAULTS: dict[str, str] = { "cloudai_api_url": CLOUDAI_API_URL, "cloudai_api_key": CLOUDAI_API_KEY, "cloudai_timeout": str(CLOUDAI_TIMEOUT), + "cloudai_connect_timeout": str(CLOUDAI_CONNECT_TIMEOUT), "model_context_tokens": str(MODEL_CONTEXT_TOKENS), "chunk_token_budget": str(CHUNK_TOKEN_BUDGET), } @@ -239,6 +241,7 @@ _USER_SETTINGS_ENV_KEYS: dict[str, str] = { "cloudai_api_url": "JOURNAL_CLOUDAI_API_URL", "cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY", "cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT", + "cloudai_connect_timeout": "JOURNAL_CLOUDAI_CONNECT_TIMEOUT", "model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS", "chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET", } diff --git a/tests/test_cli_fragments_hybrid.py b/tests/test_cli_fragments_hybrid.py index cf3b46b..d984eac 100644 --- a/tests/test_cli_fragments_hybrid.py +++ b/tests/test_cli_fragments_hybrid.py @@ -19,14 +19,14 @@ class CliFragmentsHybridTests(unittest.TestCase): patch.object(sys, "argv", ["journal", "fragments", "list"]), patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"), patch( - "journal.cli.main.call_sidecar_action", + "journal.cli.main.list_fragments", return_value=[{"Id": "1", "Type": "!NOTE", "Description": "desc", "Tags": []}], ) as mock_call, redirect_stdout(io.StringIO()) as stdout, ): cli_main.main() - mock_call.assert_called_once_with("fragments.list") + mock_call.assert_called_once_with() self.assertIn("!NOTE", stdout.getvalue()) def test_fragments_create_calls_sidecar(self): @@ -47,14 +47,16 @@ class CliFragmentsHybridTests(unittest.TestCase): ], ), patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"), - patch("journal.cli.main.call_sidecar_action", return_value={"Id": "1"}) as mock_call, + patch("journal.cli.main.create_fragment", return_value={"Id": "1"}) as mock_call, redirect_stdout(io.StringIO()) as stdout, ): cli_main.main() mock_call.assert_called_once_with( - "fragments.create", - payload={"type": "!TRIGGER", "description": "flashback", "tags": ["stress"]}, + type_="!TRIGGER", + description="flashback", + time=None, + tags=["stress"], ) self.assertIn("Fragment created.", stdout.getvalue()) @@ -66,14 +68,14 @@ class CliFragmentsHybridTests(unittest.TestCase): ["journal", "fragments", "search", "--type", "!NOTE", "--tag", "daily"], ), patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"), - patch("journal.cli.main.call_sidecar_action", return_value=[]) as mock_call, + patch("journal.cli.main.search_fragments", return_value=[]) as mock_call, redirect_stdout(io.StringIO()) as stdout, ): cli_main.main() mock_call.assert_called_once_with( - "fragments.search", - command_fields={"type": "!NOTE", "tag": "daily"}, + type_="!NOTE", + tag="daily", ) self.assertIn("No fragments found.", stdout.getvalue()) @@ -81,7 +83,7 @@ class CliFragmentsHybridTests(unittest.TestCase): with ( patch.object(sys, "argv", ["journal", "fragments", "list"]), patch("journal.cli.main.BACKEND_MODE", "python"), - patch("journal.cli.main.call_sidecar_action") as mock_call, + patch("journal.cli.main.list_fragments") as mock_call, redirect_stdout(io.StringIO()) as stdout, ): cli_main.main()