updated AI backend to support LM Studio's new methods.

This commit is contained in:
stan44 2026-02-23 23:50:32 -06:00
parent b3b27a99e9
commit 70d15499eb
9 changed files with 460 additions and 100 deletions

View File

@ -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,27 +156,26 @@ 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")
print("DEBUG: No parsable text in response payload")
return "No response generated."
except Exception as e:
print(f"DEBUG: Exception occurred: {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,9 +210,9 @@ 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:
embedding = extract_embedding_response(data)
if embedding:
return embedding
print("DEBUG: No embedding data in response")
return []
except Exception as e:

192
journal/ai/api_compat.py Normal file
View File

@ -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 []

View File

@ -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}"

View File

@ -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

View File

@ -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()

View File

@ -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)]

View File

@ -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")

View File

@ -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",
}

View File

@ -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()