Skip to content

Commit 2120020

Browse files
committed
save
1 parent 623ce92 commit 2120020

File tree

15 files changed

+282
-184
lines changed

15 files changed

+282
-184
lines changed

src/backend/core/api/viewsets.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,23 @@ class DocumentViewSet(
338338
9. **Media Auth**: Authorize access to document media.
339339
Example: GET /documents/media-auth/
340340
341-
10. **AI Proxy**: Proxy an AI request to an external AI service.
341+
10. **AI Transform**: Apply a transformation action on a piece of text with AI.
342+
Example: POST /documents/{id}/ai-transform/
343+
Expected data:
344+
- text (str): The input text.
345+
- action (str): The transformation type, one of [prompt, correct, rephrase, summarize].
346+
Returns: JSON response with the processed text.
347+
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
348+
349+
11. **AI Translate**: Translate a piece of text with AI.
350+
Example: POST /documents/{id}/ai-translate/
351+
Expected data:
352+
- text (str): The input text.
353+
- language (str): The target language, chosen from settings.LANGUAGES.
354+
Returns: JSON response with the translated text.
355+
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
356+
357+
12. **AI Proxy**: Proxy an AI request to an external AI service.
342358
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy
343359
344360
### Ordering: created_at, updated_at, is_favorite, title
@@ -1634,7 +1650,7 @@ def media_check(self, request, *args, **kwargs):
16341650
methods=["post"],
16351651
name="Proxy AI requests to the AI provider",
16361652
url_path="ai-proxy",
1637-
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
1653+
# throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
16381654
)
16391655
def ai_proxy(self, request, *args, **kwargs):
16401656
"""
@@ -1648,23 +1664,26 @@ def ai_proxy(self, request, *args, **kwargs):
16481664
if not settings.AI_FEATURE_ENABLED:
16491665
raise ValidationError("AI feature is not enabled.")
16501666

1651-
serializer = serializers.AIProxySerializer(data=request.data)
1652-
serializer.is_valid(raise_exception=True)
1653-
16541667
ai_service = AIService()
16551668

16561669
if settings.AI_STREAM:
1657-
return StreamingHttpResponse(
1658-
ai_service.stream(request.data),
1670+
stream_gen = ai_service.stream_proxy(
1671+
url=settings.AI_BASE_URL.rstrip("/") + "/chat/completions",
1672+
method="POST",
1673+
headers={"Content-Type": "application/json"},
1674+
body=json.dumps(request.data, ensure_ascii=False).encode("utf-8"),
1675+
)
1676+
1677+
resp = StreamingHttpResponse(
1678+
streaming_content=stream_gen,
16591679
content_type="text/event-stream",
1660-
status=drf.status.HTTP_200_OK,
1680+
status=200,
16611681
)
1682+
resp["X-Accel-Buffering"] = "no"
1683+
resp["Cache-Control"] = "no-cache"
1684+
return resp
1685+
16621686

1663-
ai_response = ai_service.proxy(request.data)
1664-
return drf.response.Response(
1665-
ai_response.model_dump(),
1666-
status=drf.status.HTTP_200_OK,
1667-
)
16681687

16691688
@drf.decorators.action(
16701689
detail=True,

src/backend/core/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,8 @@ def get_abilities(self, user):
784784
"accesses_manage": is_owner_or_admin,
785785
"accesses_view": has_access_role,
786786
"ai_proxy": ai_access,
787+
"ai_transform": ai_access,
788+
"ai_translate": ai_access,
787789
"attachment_upload": can_update,
788790
"media_check": can_get,
789791
"can_edit": can_update,

src/backend/core/services/ai_services.py

Lines changed: 138 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"""AI services."""
2+
from __future__ import annotations
23

3-
import logging
4-
from typing import Generator
4+
import json
5+
from typing import Any, Dict, Generator
56

7+
import httpx
68
from django.conf import settings
79
from django.core.exceptions import ImproperlyConfigured
810

911
from core import enums
1012

13+
import logging
14+
1115
if settings.LANGFUSE_PUBLIC_KEY:
1216
from langfuse.openai import OpenAI
1317
else:
@@ -16,6 +20,45 @@
1620
log = logging.getLogger(__name__)
1721

1822

23+
BLOCKNOTE_TOOL_STRICT_PROMPT = """You are editing a BlockNote document via the tool applyDocumentOperations.
24+
25+
You MUST respond ONLY by calling applyDocumentOperations.
26+
The tool input MUST be valid JSON:
27+
{ "operations": [ ... ] }
28+
29+
Each operation MUST include "type" and it MUST be one of:
30+
- "update" (requires: id, block)
31+
- "add" (requires: referenceId, position, blocks)
32+
- "delete" (requires: id)
33+
34+
VALID SHAPES (FOLLOW EXACTLY):
35+
36+
Update:
37+
{ "type":"update", "id":"<id$>", "block":"<p>...</p>" }
38+
IMPORTANT: "block" MUST be a STRING containing a SINGLE valid HTML element.
39+
40+
Add:
41+
{ "type":"add", "referenceId":"<id$>", "position":"before|after", "blocks":["<p>...</p>"] }
42+
IMPORTANT: "blocks" MUST be an ARRAY OF STRINGS.
43+
Each item MUST be a STRING containing a SINGLE valid HTML element.
44+
45+
Delete:
46+
{ "type":"delete", "id":"<id$>" }
47+
48+
IDs ALWAYS end with "$". Use ids EXACTLY as provided.
49+
50+
Return ONLY the JSON tool input. No prose, no markdown.
51+
"""
52+
53+
54+
def _drop_nones(obj: Any) -> Any:
55+
if isinstance(obj, dict):
56+
return {k: _drop_nones(v) for k, v in obj.items() if v is not None}
57+
if isinstance(obj, list):
58+
return [_drop_nones(v) for v in obj]
59+
return obj
60+
61+
1962
AI_ACTIONS = {
2063
"prompt": (
2164
"Answer the prompt using markdown formatting for structure and emphasis. "
@@ -72,7 +115,8 @@ def __init__(self):
72115
or settings.AI_MODEL is None
73116
):
74117
raise ImproperlyConfigured("AI configuration not set")
75-
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
118+
self.api_key = settings.AI_API_KEY
119+
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=self.api_key)
76120

77121
def call_ai_api(self, system_content, text):
78122
"""Helper method to call the OpenAI API and process the response."""
@@ -102,18 +146,95 @@ def translate(self, text, language):
102146
system_content = AI_TRANSLATE.format(language=language_display)
103147
return self.call_ai_api(system_content, text)
104148

105-
def proxy(self, data: dict, stream: bool = False) -> Generator[str, None, None]:
106-
"""Proxy AI API requests to the configured AI provider."""
107-
data["stream"] = stream
108-
try:
109-
return self.client.chat.completions.create(**data)
110-
except OpenAIError as e:
111-
raise RuntimeError(f"Failed to proxy AI request: {e}") from e
112-
113-
def stream(self, data: dict) -> Generator[str, None, None]:
114-
"""Stream AI API requests to the configured AI provider."""
115-
stream = self.proxy(data, stream=True)
116-
for chunk in stream:
117-
yield f"data: {chunk.model_dump_json()}\n\n"
118149

119-
yield "data: [DONE]\n\n"
150+
def _filtered_headers(self, incoming_headers: Dict[str, str]) -> Dict[str, str]:
151+
hop_by_hop = {"host", "connection", "content-length", "accept-encoding"}
152+
out: Dict[str, str] = {}
153+
for k, v in incoming_headers.items():
154+
lk = k.lower()
155+
if lk in hop_by_hop:
156+
continue
157+
if lk == "authorization":
158+
# Client auth is for Django only, not upstream
159+
continue
160+
out[k] = v
161+
162+
out["Authorization"] = f"Bearer {self.api_key}"
163+
return out
164+
165+
def _normalize_tools(self, tools: list) -> list:
166+
normalized = []
167+
for tool in tools:
168+
if isinstance(tool, dict) and tool.get("type") == "function":
169+
fn = tool.get("function") or {}
170+
if isinstance(fn, dict) and not fn.get("description"):
171+
fn["description"] = f"Tool {fn.get('name', 'unknown')}."
172+
tool["function"] = fn
173+
normalized.append(_drop_nones(tool))
174+
return normalized
175+
176+
def _harden_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
177+
payload = dict(payload)
178+
179+
# Enforce server model (important with Albert routing)
180+
if getattr(settings, "AI_MODEL", None):
181+
payload["model"] = settings.AI_MODEL
182+
183+
# Compliance
184+
payload["temperature"] = 0
185+
186+
# Tools normalization
187+
if isinstance(payload.get("tools"), list):
188+
payload["tools"] = self._normalize_tools(payload["tools"])
189+
190+
# Force tool call if tools exist
191+
if payload.get("tools"):
192+
payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}}
193+
194+
# Convert non-standard "required"
195+
if payload.get("tool_choice") == "required":
196+
payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}}
197+
198+
# Inject strict system prompt once
199+
msgs = payload.get("messages")
200+
if isinstance(msgs, list):
201+
need = True
202+
if msgs and isinstance(msgs[0], dict) and msgs[0].get("role") == "system":
203+
c = msgs[0].get("content") or ""
204+
if isinstance(c, str) and "applyDocumentOperations" in c and "blocks" in c:
205+
need = False
206+
if need:
207+
payload["messages"] = [{"role": "system", "content": BLOCKNOTE_TOOL_STRICT_PROMPT}] + msgs
208+
209+
return _drop_nones(payload)
210+
211+
def _maybe_harden_json_body(self, body: bytes, headers: Dict[str, str]) -> bytes:
212+
ct = (headers.get("Content-Type") or headers.get("content-type") or "").lower()
213+
if "application/json" not in ct:
214+
return body
215+
try:
216+
payload = json.loads(body.decode("utf-8"))
217+
except Exception:
218+
return body
219+
if isinstance(payload, dict):
220+
payload = self._harden_payload(payload)
221+
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
222+
return body
223+
224+
def stream_proxy(
225+
self,
226+
*,
227+
url: str,
228+
method: str,
229+
headers: Dict[str, str],
230+
body: bytes,
231+
) -> Generator[bytes, None, None]:
232+
req_headers = self._filtered_headers(dict(headers))
233+
req_body = self._maybe_harden_json_body(body, req_headers)
234+
235+
timeout = httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=10.0)
236+
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
237+
with client.stream(method.upper(), url, headers=req_headers, content=req_body) as r:
238+
for chunk in r.iter_bytes():
239+
if chunk:
240+
yield chunk

src/frontend/apps/impress/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@ag-media/react-pdf-table": "2.0.3",
22-
"@ai-sdk/openai-compatible": "0.2.14",
22+
"@ai-sdk/openai": "3.0.19",
2323
"@blocknote/code-block": "0.46.2",
2424
"@blocknote/core": "0.46.2",
2525
"@blocknote/mantine": "0.46.2",
@@ -46,7 +46,7 @@
4646
"@sentry/nextjs": "10.34.0",
4747
"@tanstack/react-query": "5.90.18",
4848
"@tiptap/extensions": "*",
49-
"ai": "4.3.16",
49+
"ai": "6.0.49",
5050
"canvg": "4.0.3",
5151
"clsx": "2.1.1",
5252
"cmdk": "1.1.1",

0 commit comments

Comments
 (0)