updated AI backend to support LM Studio's new methods.
This commit is contained in:
parent
b3b27a99e9
commit
70d15499eb
@ -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 []
|
||||
|
||||
192
journal/ai/api_compat.py
Normal file
192
journal/ai/api_compat.py
Normal 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 []
|
||||
@ -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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user