2024-09-20 22:42:46 +02:00
|
|
|
"""AI services."""
|
|
|
|
|
|
2025-06-12 11:08:23 +02:00
|
|
|
import json
|
2025-06-05 16:06:30 +02:00
|
|
|
import logging
|
2026-02-02 15:43:00 +01:00
|
|
|
from typing import Any, Dict, Generator
|
2025-06-05 16:06:30 +02:00
|
|
|
|
2024-09-20 22:42:46 +02:00
|
|
|
from django.conf import settings
|
|
|
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
|
|
|
|
2025-06-12 11:08:23 +02:00
|
|
|
from openai import OpenAIError
|
|
|
|
|
|
2024-09-20 22:42:46 +02:00
|
|
|
from core import enums
|
|
|
|
|
|
2026-01-09 15:38:56 +01:00
|
|
|
if settings.LANGFUSE_PUBLIC_KEY:
|
|
|
|
|
from langfuse.openai import OpenAI
|
|
|
|
|
else:
|
2026-02-02 15:43:00 +01:00
|
|
|
from openai import OpenAI
|
|
|
|
|
|
2025-06-05 16:06:30 +02:00
|
|
|
log = logging.getLogger(__name__)
|
2026-01-09 15:38:56 +01:00
|
|
|
|
|
|
|
|
|
2026-02-02 15:43:00 +01:00
|
|
|
BLOCKNOTE_TOOL_STRICT_PROMPT = """
|
|
|
|
|
You are editing a BlockNote document via the tool applyDocumentOperations.
|
|
|
|
|
|
|
|
|
|
You MUST respond ONLY by calling applyDocumentOperations.
|
|
|
|
|
The tool input MUST be valid JSON:
|
|
|
|
|
{ "operations": [ ... ] }
|
|
|
|
|
|
|
|
|
|
Each operation MUST include "type" and it MUST be one of:
|
|
|
|
|
- "update" (requires: id, block)
|
|
|
|
|
- "add" (requires: referenceId, position, blocks)
|
|
|
|
|
- "delete" (requires: id)
|
|
|
|
|
|
|
|
|
|
VALID SHAPES (FOLLOW EXACTLY):
|
|
|
|
|
|
|
|
|
|
Update:
|
|
|
|
|
{ "type":"update", "id":"<id$>", "block":"<p>...</p>" }
|
|
|
|
|
IMPORTANT: "block" MUST be a STRING containing a SINGLE valid HTML element.
|
|
|
|
|
|
|
|
|
|
Add:
|
|
|
|
|
{ "type":"add", "referenceId":"<id$>", "position":"before|after", "blocks":["<p>...</p>"] }
|
|
|
|
|
IMPORTANT: "blocks" MUST be an ARRAY OF STRINGS.
|
|
|
|
|
Each item MUST be a STRING containing a SINGLE valid HTML element.
|
|
|
|
|
|
|
|
|
|
Delete:
|
|
|
|
|
{ "type":"delete", "id":"<id$>" }
|
|
|
|
|
|
|
|
|
|
IDs ALWAYS end with "$". Use ids EXACTLY as provided.
|
|
|
|
|
|
|
|
|
|
Return ONLY the JSON tool input. No prose, no markdown.
|
|
|
|
|
"""
|
|
|
|
|
|
2024-09-20 22:42:46 +02:00
|
|
|
AI_ACTIONS = {
|
|
|
|
|
"prompt": (
|
2025-07-16 16:24:33 +02:00
|
|
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
|
|
|
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
2025-03-12 10:14:47 +01:00
|
|
|
"Preserve the language and markdown formatting. "
|
|
|
|
|
"Do not provide any other information. "
|
|
|
|
|
"Preserve the language."
|
2024-09-20 22:42:46 +02:00
|
|
|
),
|
|
|
|
|
"correct": (
|
|
|
|
|
"Correct grammar and spelling of the markdown text, "
|
|
|
|
|
"preserving language and markdown formatting. "
|
2025-03-12 10:14:47 +01:00
|
|
|
"Do not provide any other information. "
|
|
|
|
|
"Preserve the language."
|
2024-09-20 22:42:46 +02:00
|
|
|
),
|
|
|
|
|
"rephrase": (
|
|
|
|
|
"Rephrase the given markdown text, "
|
|
|
|
|
"preserving language and markdown formatting. "
|
2025-03-12 10:14:47 +01:00
|
|
|
"Do not provide any other information. "
|
|
|
|
|
"Preserve the language."
|
2024-09-20 22:42:46 +02:00
|
|
|
),
|
|
|
|
|
"summarize": (
|
|
|
|
|
"Summarize the markdown text, preserving language and markdown formatting. "
|
2025-03-12 10:14:47 +01:00
|
|
|
"Do not provide any other information. "
|
|
|
|
|
"Preserve the language."
|
2024-09-20 22:42:46 +02:00
|
|
|
),
|
2025-03-12 11:44:58 +01:00
|
|
|
"beautify": (
|
|
|
|
|
"Add formatting to the text to make it more readable. "
|
2025-03-13 11:29:03 +01:00
|
|
|
"Do not provide any other information. "
|
|
|
|
|
"Preserve the language."
|
|
|
|
|
),
|
|
|
|
|
"emojify": (
|
|
|
|
|
"Add emojis to the important parts of the text. "
|
|
|
|
|
"Do not provide any other information. "
|
2025-03-12 11:44:58 +01:00
|
|
|
"Preserve the language."
|
|
|
|
|
),
|
2024-09-20 22:42:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AI_TRANSLATE = (
|
2025-05-13 16:00:12 +02:00
|
|
|
"Keep the same html structure and formatting. "
|
2025-03-12 10:14:47 +01:00
|
|
|
"Translate the content in the html to the specified language {language:s}. "
|
|
|
|
|
"Check the translation for accuracy and make any necessary corrections. "
|
2024-09-20 22:42:46 +02:00
|
|
|
"Do not provide any other information."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AIService:
|
|
|
|
|
"""Service class for AI-related operations."""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
"""Ensure that the AI configuration is set properly."""
|
|
|
|
|
if (
|
|
|
|
|
settings.AI_BASE_URL is None
|
|
|
|
|
or settings.AI_API_KEY is None
|
|
|
|
|
or settings.AI_MODEL is None
|
|
|
|
|
):
|
|
|
|
|
raise ImproperlyConfigured("AI configuration not set")
|
|
|
|
|
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
|
|
|
|
|
|
|
|
|
|
def call_ai_api(self, system_content, text):
|
|
|
|
|
"""Helper method to call the OpenAI API and process the response."""
|
|
|
|
|
response = self.client.chat.completions.create(
|
|
|
|
|
model=settings.AI_MODEL,
|
|
|
|
|
messages=[
|
|
|
|
|
{"role": "system", "content": system_content},
|
2025-03-12 10:14:47 +01:00
|
|
|
{"role": "user", "content": text},
|
2024-09-20 22:42:46 +02:00
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
content = response.choices[0].message.content
|
|
|
|
|
|
2025-03-12 10:14:47 +01:00
|
|
|
if not content:
|
2024-09-20 22:42:46 +02:00
|
|
|
raise RuntimeError("AI response does not contain an answer")
|
|
|
|
|
|
2025-03-12 10:14:47 +01:00
|
|
|
return {"answer": content}
|
2024-09-20 22:42:46 +02:00
|
|
|
|
|
|
|
|
def transform(self, text, action):
|
|
|
|
|
"""Transform text based on specified action."""
|
|
|
|
|
system_content = AI_ACTIONS[action]
|
|
|
|
|
return self.call_ai_api(system_content, text)
|
|
|
|
|
|
|
|
|
|
def translate(self, text, language):
|
|
|
|
|
"""Translate text to a specified language."""
|
|
|
|
|
language_display = enums.ALL_LANGUAGES.get(language, language)
|
|
|
|
|
system_content = AI_TRANSLATE.format(language=language_display)
|
|
|
|
|
return self.call_ai_api(system_content, text)
|
2025-06-05 16:06:30 +02:00
|
|
|
|
2026-02-02 15:43:00 +01:00
|
|
|
def _normalize_tools(self, tools: list) -> list:
|
|
|
|
|
"""
|
|
|
|
|
Normalize tool definitions to ensure they have required fields.
|
|
|
|
|
"""
|
|
|
|
|
normalized = []
|
|
|
|
|
for tool in tools:
|
|
|
|
|
if isinstance(tool, dict) and tool.get("type") == "function":
|
|
|
|
|
fn = tool.get("function") or {}
|
|
|
|
|
if isinstance(fn, dict) and not fn.get("description"):
|
|
|
|
|
fn["description"] = f"Tool {fn.get('name', 'unknown')}."
|
|
|
|
|
tool["function"] = fn
|
|
|
|
|
normalized.append(tool)
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
def _harden_payload(
|
|
|
|
|
self, payload: Dict[str, Any], stream: bool = False
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""Harden the AI API payload to enforce compliance and tool usage."""
|
|
|
|
|
payload["stream"] = stream
|
|
|
|
|
|
|
|
|
|
# Remove stream_options if stream is False
|
|
|
|
|
if not stream and "stream_options" in payload:
|
|
|
|
|
payload.pop("stream_options")
|
|
|
|
|
|
|
|
|
|
# Tools normalization
|
|
|
|
|
if isinstance(payload.get("tools"), list):
|
|
|
|
|
payload["tools"] = self._normalize_tools(payload["tools"])
|
|
|
|
|
|
|
|
|
|
# Inject strict system prompt once
|
|
|
|
|
msgs = payload.get("messages")
|
|
|
|
|
if isinstance(msgs, list):
|
|
|
|
|
need = True
|
|
|
|
|
if msgs and isinstance(msgs[0], dict) and msgs[0].get("role") == "system":
|
|
|
|
|
c = msgs[0].get("content") or ""
|
|
|
|
|
if (
|
|
|
|
|
isinstance(c, str)
|
|
|
|
|
and "applyDocumentOperations" in c
|
|
|
|
|
and "blocks" in c
|
|
|
|
|
):
|
|
|
|
|
need = False
|
|
|
|
|
if need:
|
|
|
|
|
payload["messages"] = [
|
|
|
|
|
{"role": "system", "content": BLOCKNOTE_TOOL_STRICT_PROMPT}
|
|
|
|
|
] + msgs
|
|
|
|
|
|
|
|
|
|
return payload
|
|
|
|
|
|
2025-06-12 11:08:23 +02:00
|
|
|
def proxy(self, data: dict, stream: bool = False) -> Generator[str, None, None]:
|
2025-06-05 16:06:30 +02:00
|
|
|
"""Proxy AI API requests to the configured AI provider."""
|
2026-02-02 15:43:00 +01:00
|
|
|
payload = self._harden_payload(data, stream=stream)
|
2025-06-12 11:08:23 +02:00
|
|
|
try:
|
2026-02-02 15:43:00 +01:00
|
|
|
return self.client.chat.completions.create(**payload)
|
2025-06-12 11:08:23 +02:00
|
|
|
except OpenAIError as e:
|
|
|
|
|
raise RuntimeError(f"Failed to proxy AI request: {e}") from e
|
|
|
|
|
|
|
|
|
|
def stream(self, data: dict) -> Generator[str, None, None]:
|
|
|
|
|
"""Stream AI API requests to the configured AI provider."""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
stream = self.proxy(data, stream=True)
|
|
|
|
|
for chunk in stream:
|
|
|
|
|
try:
|
|
|
|
|
chunk_dict = (
|
|
|
|
|
chunk.model_dump() if hasattr(chunk, "model_dump") else chunk
|
|
|
|
|
)
|
|
|
|
|
chunk_json = json.dumps(chunk_dict)
|
|
|
|
|
yield f"data: {chunk_json}\n\n"
|
|
|
|
|
except (AttributeError, TypeError) as e:
|
|
|
|
|
log.error("Error serializing chunk: %s, chunk: %s", e, chunk)
|
|
|
|
|
continue
|
|
|
|
|
except (OpenAIError, RuntimeError, OSError, ValueError) as e:
|
|
|
|
|
log.error("Streaming error: %s", e)
|
|
|
|
|
|
|
|
|
|
yield "data: [DONE]\n\n"
|