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,
|
MODEL_CONTEXT_TOKENS,
|
||||||
CHUNK_TOKEN_BUDGET,
|
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_AUTO = "auto"
|
||||||
_BACKEND_SPACY = "spacy"
|
_BACKEND_SPACY = "spacy"
|
||||||
@ -137,7 +145,8 @@ def llama_cpp_generate(
|
|||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
max_tokens: int = 2048,
|
max_tokens: int = 2048,
|
||||||
) -> str:
|
) -> 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
|
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()
|
timeout_raw = os.getenv("JOURNAL_LLAMA_CPP_TIMEOUT", str(LLAMA_CPP_TIMEOUT)).strip()
|
||||||
try:
|
try:
|
||||||
@ -147,28 +156,27 @@ def llama_cpp_generate(
|
|||||||
if llama_timeout <= 0:
|
if llama_timeout <= 0:
|
||||||
llama_timeout = LLAMA_CPP_TIMEOUT
|
llama_timeout = LLAMA_CPP_TIMEOUT
|
||||||
|
|
||||||
payload = {
|
endpoint_kind = detect_text_endpoint_kind(llama_url)
|
||||||
"model": llama_model,
|
payload = build_text_payload(
|
||||||
"prompt": prompt,
|
prompt,
|
||||||
"max_tokens": max_tokens,
|
llama_model,
|
||||||
"temperature": temperature,
|
endpoint_kind,
|
||||||
"stop": [],
|
temperature=temperature,
|
||||||
"stream": False,
|
max_tokens=max_tokens,
|
||||||
}
|
)
|
||||||
try:
|
try:
|
||||||
response = requests.post(llama_url, json=payload, timeout=llama_timeout)
|
response = requests.post(llama_url, json=payload, timeout=llama_timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
# llama.cpp returns choices array with text field
|
result = extract_text_response(data)
|
||||||
if "choices" in data and len(data["choices"]) > 0:
|
if result:
|
||||||
result = data["choices"][0]["text"].strip()
|
print(f"DEBUG: Generated {len(result)} characters")
|
||||||
print(f"DEBUG: Generated {len(result)} characters") # Debug output
|
if len(result) < 10:
|
||||||
if len(result) < 10: # If very short response
|
|
||||||
print(f"DEBUG: Short response: '{result}'")
|
print(f"DEBUG: Short response: '{result}'")
|
||||||
return result
|
return result
|
||||||
else:
|
|
||||||
print("DEBUG: No choices in response")
|
print("DEBUG: No parsable text in response payload")
|
||||||
return "No response generated."
|
return "No response generated."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"DEBUG: Exception occurred: {e}")
|
print(f"DEBUG: Exception occurred: {e}")
|
||||||
return f"Error communicating with llama.cpp server: {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.
|
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 = (
|
embedding_model = (
|
||||||
os.getenv("JOURNAL_EMBEDDING_MODEL_NAME", EMBEDDING_MODEL_NAME).strip()
|
os.getenv("JOURNAL_EMBEDDING_MODEL_NAME", EMBEDDING_MODEL_NAME).strip()
|
||||||
or EMBEDDING_MODEL_NAME
|
or EMBEDDING_MODEL_NAME
|
||||||
@ -201,11 +210,11 @@ def generate_embedding(text: str) -> list[float]:
|
|||||||
) # Reusing LLAMA_CPP_TIMEOUT for now
|
) # Reusing LLAMA_CPP_TIMEOUT for now
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if "data" in data and len(data["data"]) > 0 and "embedding" in data["data"][0]:
|
embedding = extract_embedding_response(data)
|
||||||
return data["data"][0]["embedding"]
|
if embedding:
|
||||||
else:
|
return embedding
|
||||||
print("DEBUG: No embedding data in response")
|
print("DEBUG: No embedding data in response")
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"DEBUG: Exception occurred during embedding generation: {e}")
|
print(f"DEBUG: Exception occurred during embedding generation: {e}")
|
||||||
return []
|
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 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:
|
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_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()
|
timeout_raw = os.getenv("JOURNAL_CLOUDAI_TIMEOUT", str(CLOUDAI_TIMEOUT)).strip()
|
||||||
try:
|
try:
|
||||||
timeout_seconds = int(timeout_raw)
|
timeout_seconds = int(timeout_raw)
|
||||||
@ -17,14 +47,33 @@ def get_cloud_ai_response(prompt: str) -> str:
|
|||||||
if timeout_seconds <= 0:
|
if timeout_seconds <= 0:
|
||||||
timeout_seconds = CLOUDAI_TIMEOUT
|
timeout_seconds = CLOUDAI_TIMEOUT
|
||||||
|
|
||||||
headers = {
|
connect_timeout_raw = os.getenv(
|
||||||
"Authorization": f"Bearer {api_key}",
|
"JOURNAL_CLOUDAI_CONNECT_TIMEOUT", str(CLOUDAI_CONNECT_TIMEOUT)
|
||||||
"Content-Type": "application/json",
|
).strip()
|
||||||
}
|
|
||||||
payload = {"prompt": prompt}
|
|
||||||
try:
|
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()
|
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:
|
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.storage import rebuild_all_vaults, load_all_vaults
|
||||||
from journal.core.parser import parse_journal_file
|
from journal.core.parser import parse_journal_file
|
||||||
from journal.core.models import JournalEntry
|
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.config import DATA_DIR, PID_FILE, PROJECT_ROOT, BACKEND_MODE
|
||||||
from journal.core.csharp_sidecar import call_sidecar_action
|
from journal.core.csharp_sidecar import call_sidecar_action
|
||||||
|
|
||||||
@ -330,29 +338,23 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if args.fragments_action == "list":
|
if args.fragments_action == "list":
|
||||||
result = call_sidecar_action("fragments.list")
|
result = list_fragments()
|
||||||
_print_fragments(result)
|
_print_fragments(result)
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.fragments_action == "create":
|
if args.fragments_action == "create":
|
||||||
payload: dict[str, object] = {
|
created = create_fragment(
|
||||||
"type": args.fragment_type or "",
|
type_=args.fragment_type or "",
|
||||||
"description": args.fragment_description or "",
|
description=args.fragment_description or "",
|
||||||
}
|
time=args.fragment_time,
|
||||||
if args.fragment_time:
|
tags=args.fragment_tags,
|
||||||
payload["time"] = args.fragment_time
|
)
|
||||||
if args.fragment_tags:
|
|
||||||
payload["tags"] = args.fragment_tags
|
|
||||||
created = call_sidecar_action("fragments.create", payload=payload)
|
|
||||||
print("Fragment created.")
|
print("Fragment created.")
|
||||||
_print_json(created)
|
_print_json(created)
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.fragments_action == "get":
|
if args.fragments_action == "get":
|
||||||
fragment = call_sidecar_action(
|
fragment = get_fragment(args.fragment_id or "")
|
||||||
"fragments.get",
|
|
||||||
command_fields={"id": args.fragment_id},
|
|
||||||
)
|
|
||||||
if fragment is None:
|
if fragment is None:
|
||||||
print("Fragment not found.")
|
print("Fragment not found.")
|
||||||
return
|
return
|
||||||
@ -360,26 +362,13 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
if args.fragments_action == "update":
|
if args.fragments_action == "update":
|
||||||
payload: dict[str, object] = {}
|
updated = update_fragment(
|
||||||
if args.fragment_type is not None:
|
args.fragment_id or "",
|
||||||
payload["type"] = args.fragment_type
|
type_=args.fragment_type,
|
||||||
if args.fragment_description is not None:
|
description=args.fragment_description,
|
||||||
payload["description"] = args.fragment_description
|
time=args.fragment_time,
|
||||||
if args.fragment_time is not None:
|
tags=args.fragment_tags,
|
||||||
payload["time"] = args.fragment_time
|
clear_tags=args.fragment_clear_tags,
|
||||||
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},
|
|
||||||
)
|
)
|
||||||
if updated:
|
if updated:
|
||||||
print("Fragment updated.")
|
print("Fragment updated.")
|
||||||
@ -388,10 +377,7 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
if args.fragments_action == "delete":
|
if args.fragments_action == "delete":
|
||||||
deleted = call_sidecar_action(
|
deleted = delete_fragment(args.fragment_id or "")
|
||||||
"fragments.delete",
|
|
||||||
command_fields={"id": args.fragment_id},
|
|
||||||
)
|
|
||||||
if deleted:
|
if deleted:
|
||||||
print("Fragment deleted.")
|
print("Fragment deleted.")
|
||||||
else:
|
else:
|
||||||
@ -399,15 +385,15 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
if args.fragments_action == "search":
|
if args.fragments_action == "search":
|
||||||
results = call_sidecar_action(
|
results = search_fragments(
|
||||||
"fragments.search",
|
type_=args.fragment_type,
|
||||||
command_fields={
|
tag=args.fragment_tag,
|
||||||
"type": args.fragment_type,
|
|
||||||
"tag": args.fragment_tag,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
_print_fragments(results)
|
_print_fragments(results)
|
||||||
return
|
return
|
||||||
|
except ValueError as e:
|
||||||
|
print(str(e))
|
||||||
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Fragment command failed: {e}")
|
print(f"Fragment command failed: {e}")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -46,9 +46,17 @@ MONTHLY_VAULT_FORMAT = "%Y-%m.vault" # e.g., 2025-07.vault
|
|||||||
# --- AI Configuration ---
|
# --- AI Configuration ---
|
||||||
CLOUDAI_API_KEY = os.getenv("JOURNAL_CLOUDAI_API_KEY", "").strip()
|
CLOUDAI_API_KEY = os.getenv("JOURNAL_CLOUDAI_API_KEY", "").strip()
|
||||||
CLOUDAI_API_URL = os.getenv("JOURNAL_CLOUDAI_API_URL", "").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:
|
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 = (
|
LLAMA_CPP_URL = (
|
||||||
os.getenv("JOURNAL_LLAMA_CPP_URL", "http://127.0.0.1:8085/v1/completions").strip()
|
os.getenv("JOURNAL_LLAMA_CPP_URL", "http://127.0.0.1:8085/v1/completions").strip()
|
||||||
|
|||||||
@ -1,10 +1,105 @@
|
|||||||
from .models import Fragment
|
from __future__ import annotations
|
||||||
from datetime import datetime
|
|
||||||
|
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(
|
def create_fragment(
|
||||||
type_: str, description: str, tags: list[str], time: str | None = None
|
type_: str,
|
||||||
):
|
description: str,
|
||||||
if not time:
|
tags: list[str] | None = None,
|
||||||
time = datetime.now().strftime("%H:%M")
|
time: str | None = None,
|
||||||
return Fragment(type=type_, time=time, tags=tags, description=description)
|
) -> 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,
|
CHUNK_TOKEN_BUDGET,
|
||||||
CLOUDAI_API_KEY,
|
CLOUDAI_API_KEY,
|
||||||
CLOUDAI_API_URL,
|
CLOUDAI_API_URL,
|
||||||
|
CLOUDAI_CONNECT_TIMEOUT,
|
||||||
CLOUDAI_TIMEOUT,
|
CLOUDAI_TIMEOUT,
|
||||||
CSHARP_SIDECAR_PATH,
|
CSHARP_SIDECAR_PATH,
|
||||||
EMBEDDING_API_URL,
|
EMBEDDING_API_URL,
|
||||||
@ -41,6 +42,7 @@ _DEFAULT_SETTINGS: dict[str, Any] = {
|
|||||||
"cloudai_api_url": CLOUDAI_API_URL,
|
"cloudai_api_url": CLOUDAI_API_URL,
|
||||||
"cloudai_api_key": CLOUDAI_API_KEY,
|
"cloudai_api_key": CLOUDAI_API_KEY,
|
||||||
"cloudai_timeout": CLOUDAI_TIMEOUT,
|
"cloudai_timeout": CLOUDAI_TIMEOUT,
|
||||||
|
"cloudai_connect_timeout": CLOUDAI_CONNECT_TIMEOUT,
|
||||||
"model_context_tokens": MODEL_CONTEXT_TOKENS,
|
"model_context_tokens": MODEL_CONTEXT_TOKENS,
|
||||||
"chunk_token_budget": CHUNK_TOKEN_BUDGET,
|
"chunk_token_budget": CHUNK_TOKEN_BUDGET,
|
||||||
}
|
}
|
||||||
@ -61,6 +63,7 @@ _ENV_KEYS: dict[str, str] = {
|
|||||||
"cloudai_api_url": "JOURNAL_CLOUDAI_API_URL",
|
"cloudai_api_url": "JOURNAL_CLOUDAI_API_URL",
|
||||||
"cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY",
|
"cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY",
|
||||||
"cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT",
|
"cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT",
|
||||||
|
"cloudai_connect_timeout": "JOURNAL_CLOUDAI_CONNECT_TIMEOUT",
|
||||||
"model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS",
|
"model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS",
|
||||||
"chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET",
|
"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.label("AI: Local Model Endpoints").classes("text-lg font-medium")
|
||||||
_ = (
|
_ = (
|
||||||
ui.input(
|
ui.input(
|
||||||
label="Llama.cpp URL",
|
label="AI Text URL (analysis)",
|
||||||
value=cast(str, app.storage.user["llama_cpp_url"]),
|
value=cast(str, app.storage.user["llama_cpp_url"]),
|
||||||
on_change=_on_text_change(
|
on_change=_on_text_change(
|
||||||
"llama_cpp_url", "JOURNAL_LLAMA_CPP_URL", "Llama.cpp URL"
|
"llama_cpp_url", "JOURNAL_LLAMA_CPP_URL", "Llama.cpp URL"
|
||||||
@ -201,7 +204,7 @@ def settings_dialog():
|
|||||||
)
|
)
|
||||||
_ = (
|
_ = (
|
||||||
ui.number(
|
ui.number(
|
||||||
label="Llama.cpp Timeout (ms)",
|
label="Llama.cpp Timeout (s)",
|
||||||
value=float(cast(str, app.storage.user["llama_cpp_timeout"])),
|
value=float(cast(str, app.storage.user["llama_cpp_timeout"])),
|
||||||
on_change=_on_int_change(
|
on_change=_on_int_change(
|
||||||
"llama_cpp_timeout", "JOURNAL_LLAMA_CPP_TIMEOUT", "Llama timeout"
|
"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.label("AI: Cloud Endpoint").classes("text-lg font-medium")
|
||||||
_ = (
|
_ = (
|
||||||
ui.input(
|
ui.input(
|
||||||
label="Cloud AI URL",
|
label="AI Chat URL",
|
||||||
value=cast(str, app.storage.user["cloudai_api_url"]),
|
value=cast(str, app.storage.user["cloudai_api_url"]),
|
||||||
on_change=_on_text_change(
|
on_change=_on_text_change(
|
||||||
"cloudai_api_url", "JOURNAL_CLOUDAI_API_URL", "Cloud AI URL"
|
"cloudai_api_url", "JOURNAL_CLOUDAI_API_URL", "Cloud AI URL"
|
||||||
@ -298,6 +301,19 @@ def settings_dialog():
|
|||||||
.bind_value(app.storage.user, "cloudai_timeout")
|
.bind_value(app.storage.user, "cloudai_timeout")
|
||||||
.classes("w-full")
|
.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.separator().classes("my-2")
|
||||||
_ = ui.label("NLP + Speech").classes("text-lg font-medium")
|
_ = ui.label("NLP + Speech").classes("text-lg font-medium")
|
||||||
|
|||||||
@ -55,6 +55,7 @@ from journal.core.config import (
|
|||||||
EMBEDDING_MODEL_NAME,
|
EMBEDDING_MODEL_NAME,
|
||||||
CLOUDAI_API_URL,
|
CLOUDAI_API_URL,
|
||||||
CLOUDAI_API_KEY,
|
CLOUDAI_API_KEY,
|
||||||
|
CLOUDAI_CONNECT_TIMEOUT,
|
||||||
CLOUDAI_TIMEOUT,
|
CLOUDAI_TIMEOUT,
|
||||||
MODEL_CONTEXT_TOKENS,
|
MODEL_CONTEXT_TOKENS,
|
||||||
CHUNK_TOKEN_BUDGET,
|
CHUNK_TOKEN_BUDGET,
|
||||||
@ -219,6 +220,7 @@ _USER_SETTINGS_DEFAULTS: dict[str, str] = {
|
|||||||
"cloudai_api_url": CLOUDAI_API_URL,
|
"cloudai_api_url": CLOUDAI_API_URL,
|
||||||
"cloudai_api_key": CLOUDAI_API_KEY,
|
"cloudai_api_key": CLOUDAI_API_KEY,
|
||||||
"cloudai_timeout": str(CLOUDAI_TIMEOUT),
|
"cloudai_timeout": str(CLOUDAI_TIMEOUT),
|
||||||
|
"cloudai_connect_timeout": str(CLOUDAI_CONNECT_TIMEOUT),
|
||||||
"model_context_tokens": str(MODEL_CONTEXT_TOKENS),
|
"model_context_tokens": str(MODEL_CONTEXT_TOKENS),
|
||||||
"chunk_token_budget": str(CHUNK_TOKEN_BUDGET),
|
"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_url": "JOURNAL_CLOUDAI_API_URL",
|
||||||
"cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY",
|
"cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY",
|
||||||
"cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT",
|
"cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT",
|
||||||
|
"cloudai_connect_timeout": "JOURNAL_CLOUDAI_CONNECT_TIMEOUT",
|
||||||
"model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS",
|
"model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS",
|
||||||
"chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET",
|
"chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,14 +19,14 @@ class CliFragmentsHybridTests(unittest.TestCase):
|
|||||||
patch.object(sys, "argv", ["journal", "fragments", "list"]),
|
patch.object(sys, "argv", ["journal", "fragments", "list"]),
|
||||||
patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"),
|
patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"),
|
||||||
patch(
|
patch(
|
||||||
"journal.cli.main.call_sidecar_action",
|
"journal.cli.main.list_fragments",
|
||||||
return_value=[{"Id": "1", "Type": "!NOTE", "Description": "desc", "Tags": []}],
|
return_value=[{"Id": "1", "Type": "!NOTE", "Description": "desc", "Tags": []}],
|
||||||
) as mock_call,
|
) as mock_call,
|
||||||
redirect_stdout(io.StringIO()) as stdout,
|
redirect_stdout(io.StringIO()) as stdout,
|
||||||
):
|
):
|
||||||
cli_main.main()
|
cli_main.main()
|
||||||
|
|
||||||
mock_call.assert_called_once_with("fragments.list")
|
mock_call.assert_called_once_with()
|
||||||
self.assertIn("!NOTE", stdout.getvalue())
|
self.assertIn("!NOTE", stdout.getvalue())
|
||||||
|
|
||||||
def test_fragments_create_calls_sidecar(self):
|
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.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,
|
redirect_stdout(io.StringIO()) as stdout,
|
||||||
):
|
):
|
||||||
cli_main.main()
|
cli_main.main()
|
||||||
|
|
||||||
mock_call.assert_called_once_with(
|
mock_call.assert_called_once_with(
|
||||||
"fragments.create",
|
type_="!TRIGGER",
|
||||||
payload={"type": "!TRIGGER", "description": "flashback", "tags": ["stress"]},
|
description="flashback",
|
||||||
|
time=None,
|
||||||
|
tags=["stress"],
|
||||||
)
|
)
|
||||||
self.assertIn("Fragment created.", stdout.getvalue())
|
self.assertIn("Fragment created.", stdout.getvalue())
|
||||||
|
|
||||||
@ -66,14 +68,14 @@ class CliFragmentsHybridTests(unittest.TestCase):
|
|||||||
["journal", "fragments", "search", "--type", "!NOTE", "--tag", "daily"],
|
["journal", "fragments", "search", "--type", "!NOTE", "--tag", "daily"],
|
||||||
),
|
),
|
||||||
patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"),
|
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,
|
redirect_stdout(io.StringIO()) as stdout,
|
||||||
):
|
):
|
||||||
cli_main.main()
|
cli_main.main()
|
||||||
|
|
||||||
mock_call.assert_called_once_with(
|
mock_call.assert_called_once_with(
|
||||||
"fragments.search",
|
type_="!NOTE",
|
||||||
command_fields={"type": "!NOTE", "tag": "daily"},
|
tag="daily",
|
||||||
)
|
)
|
||||||
self.assertIn("No fragments found.", stdout.getvalue())
|
self.assertIn("No fragments found.", stdout.getvalue())
|
||||||
|
|
||||||
@ -81,7 +83,7 @@ class CliFragmentsHybridTests(unittest.TestCase):
|
|||||||
with (
|
with (
|
||||||
patch.object(sys, "argv", ["journal", "fragments", "list"]),
|
patch.object(sys, "argv", ["journal", "fragments", "list"]),
|
||||||
patch("journal.cli.main.BACKEND_MODE", "python"),
|
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,
|
redirect_stdout(io.StringIO()) as stdout,
|
||||||
):
|
):
|
||||||
cli_main.main()
|
cli_main.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user