360 lines
14 KiB
Python
360 lines
14 KiB
Python
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app import database
|
|
|
|
app = FastAPI(
|
|
title="SovereignAI Starter",
|
|
description="واجهة محلية تعليمية لمساعد ذكاء اصطناعي قابل للتوسع.",
|
|
version="0.1.0",
|
|
)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
allow_headers=["Content-Type", "X-User-ID"],
|
|
)
|
|
|
|
|
|
class Message(BaseModel):
|
|
role: str = Field(description="system أو user أو assistant")
|
|
content: str
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
model: str | None = Field(default=None, description="اسم النموذج المحلي؛ اتركه فارغًا لاستخدام النموذج الافتراضي")
|
|
messages: list[Message]
|
|
|
|
model_config = {
|
|
"json_schema_extra": {
|
|
"example": {
|
|
"model": "qwen2.5:1.5b-instruct-q4_K_M",
|
|
"messages": [{"role": "user", "content": "مرحبا، كيف حالك؟"}],
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class AgentRequest(BaseModel):
|
|
task: str = Field(description="مهمة قصيرة للوكيل المحلي")
|
|
model: str | None = Field(
|
|
default=None,
|
|
description="اسم نموذج Ollama؛ اتركه فارغًا لاستخدام النموذج الافتراضي",
|
|
)
|
|
|
|
|
|
class StoredMessage(BaseModel):
|
|
role: str = Field(pattern="^(user|assistant)$")
|
|
content: str
|
|
|
|
|
|
class ConversationWrite(BaseModel):
|
|
title: str = Field(min_length=1, max_length=160)
|
|
messages: list[StoredMessage] = Field(min_length=1, max_length=2000)
|
|
|
|
|
|
def validate_user_id(value: str) -> str:
|
|
try:
|
|
return str(UUID(value))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="X-User-ID must be a UUID.") from exc
|
|
|
|
|
|
def validate_conversation_id(value: str) -> str:
|
|
try:
|
|
return str(UUID(value))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="Conversation ID must be a UUID.") from exc
|
|
|
|
|
|
async def get_completion(payload: dict[str, Any], base_url: str) -> dict[str, Any]:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
|
response = await client.post(f"{base_url}/chat/completions", json=payload)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as exc:
|
|
detail = exc.response.text[:400] or "رفض خادم النموذج الطلب."
|
|
raise HTTPException(status_code=502, detail=f"خطأ من خادم النموذج المحلي: {detail}") from exc
|
|
except httpx.RequestError as exc:
|
|
raise HTTPException(status_code=503, detail="تعذر الاتصال بـ Ollama المحلي على العنوان المضبوط.") from exc
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, str]:
|
|
return {
|
|
"status": "ok",
|
|
"model": os.getenv("LOCAL_MODEL", "qwen2.5:1.5b-instruct-q4_K_M"),
|
|
"backend": os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1"),
|
|
"groq_transcription": "configured" if os.getenv("GROQ_API_KEY") else "not_configured",
|
|
"conversation_database": "sqlite",
|
|
}
|
|
|
|
|
|
@app.get("/v1/local-user")
|
|
def get_local_user() -> dict[str, str]:
|
|
"""Return the single local development profile; authentication comes later."""
|
|
database.ensure_user(database.LOCAL_USER_ID)
|
|
return {"user_id": database.LOCAL_USER_ID, "mode": "local-development"}
|
|
|
|
|
|
def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]:
|
|
model = request.model or os.getenv("LOCAL_MODEL", "qwen2.5:1.5b-instruct-q4_K_M")
|
|
payload: dict[str, Any] = {
|
|
"model": model,
|
|
"messages": (
|
|
[message.model_dump() for message in request.messages]
|
|
if any(message.role == "system" for message in request.messages)
|
|
else [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"أنت مساعد ذكاء اصطناعي محلي يعمل عبر Ollama على جهاز المستخدم. "
|
|
"أجب بالعربية الواضحة وباختصار مناسب. إذا سُئلت أين أنت، أجب بهذه الصياغة: "
|
|
"أنا مساعد ذكاء اصطناعي يعمل على جهازك، ولا أملك وجودًا جسديًا أو موقع GPS. "
|
|
"ولا تدّع معرفة "
|
|
"موقع المستخدم أو حالة أي مكان. لا تدّع أنك زرت موقعًا أو اتصلت بالإنترنت "
|
|
"أو نفذت إجراءً ما لم يحدث ذلك فعلًا. إذا لم تعرف، قل ذلك بوضوح. "
|
|
"عند كتابة كود، ضعه في كتلة Markdown بثلاث علامات backtick "
|
|
"واكتب اسم اللغة بعد علامات البداية، مثل python أو dart."
|
|
),
|
|
},
|
|
*[message.model_dump() for message in request.messages],
|
|
]
|
|
),
|
|
"stream": stream,
|
|
}
|
|
# Disable Gemma 4 thinking so Ollama places the answer in `content` for clients.
|
|
if model.lower().startswith("gemma4"):
|
|
payload["reasoning_effort"] = "none"
|
|
return payload
|
|
|
|
|
|
@app.post("/v1/chat/completions")
|
|
async def chat(request: ChatRequest) -> dict[str, Any]:
|
|
"""ترحيل طلب المحادثة إلى النموذج المحلي."""
|
|
base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/")
|
|
payload = chat_payload(request, stream=False)
|
|
return await get_completion(payload, base_url)
|
|
|
|
|
|
@app.post("/v1/chat/stream")
|
|
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
|
"""Pass Ollama token deltas to clients as newline-delimited JSON."""
|
|
base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/")
|
|
payload = chat_payload(request, stream=True)
|
|
|
|
async def events():
|
|
try:
|
|
timeout = httpx.Timeout(connect=15.0, read=None, write=30.0, pool=30.0)
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
async with client.stream(
|
|
"POST", f"{base_url}/chat/completions", json=payload
|
|
) as response:
|
|
if response.status_code >= 400:
|
|
detail = (await response.aread()).decode("utf-8", "replace")[:400]
|
|
yield json.dumps({"error": f"Ollama {response.status_code}: {detail}"}) + "\n"
|
|
return
|
|
async for line in response.aiter_lines():
|
|
if not line.startswith("data:"):
|
|
continue
|
|
raw = line[5:].strip()
|
|
if raw == "[DONE]":
|
|
yield '{"done":true}\n'
|
|
return
|
|
try:
|
|
event = json.loads(raw)
|
|
delta = event["choices"][0].get("delta", {}).get("content")
|
|
except (ValueError, KeyError, IndexError, TypeError):
|
|
continue
|
|
if delta:
|
|
yield json.dumps({"delta": delta}, ensure_ascii=False) + "\n"
|
|
yield '{"done":true}\n'
|
|
except httpx.RequestError:
|
|
yield json.dumps({"error": "تعذر الاتصال بـ Ollama المحلي."}, ensure_ascii=False) + "\n"
|
|
|
|
return StreamingResponse(
|
|
events(),
|
|
media_type="application/x-ndjson",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
|
|
@app.get("/v1/conversations")
|
|
def list_user_conversations(
|
|
x_user_id: str = Header(alias="X-User-ID"),
|
|
) -> list[dict[str, Any]]:
|
|
user_id = validate_user_id(x_user_id)
|
|
database.ensure_user(user_id)
|
|
return database.list_conversations(user_id)
|
|
|
|
|
|
@app.get("/v1/conversations/{conversation_id}")
|
|
def read_user_conversation(
|
|
conversation_id: str,
|
|
x_user_id: str = Header(alias="X-User-ID"),
|
|
) -> dict[str, Any]:
|
|
user_id = validate_user_id(x_user_id)
|
|
result = database.get_conversation(
|
|
user_id, validate_conversation_id(conversation_id)
|
|
)
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="Conversation not found.")
|
|
return result
|
|
|
|
|
|
@app.put("/v1/conversations/{conversation_id}")
|
|
def write_user_conversation(
|
|
conversation_id: str,
|
|
request: ConversationWrite,
|
|
x_user_id: str = Header(alias="X-User-ID"),
|
|
) -> dict[str, str]:
|
|
user_id = validate_user_id(x_user_id)
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
try:
|
|
database.save_conversation(
|
|
user_id,
|
|
validate_conversation_id(conversation_id),
|
|
request.title.strip() or "محادثة جديدة",
|
|
[message.model_dump() for message in request.messages],
|
|
timestamp,
|
|
)
|
|
except PermissionError as exc:
|
|
raise HTTPException(status_code=404, detail="Conversation not found.") from exc
|
|
return {"status": "saved", "id": conversation_id}
|
|
|
|
|
|
@app.delete("/v1/conversations/{conversation_id}")
|
|
def remove_user_conversation(
|
|
conversation_id: str,
|
|
x_user_id: str = Header(alias="X-User-ID"),
|
|
) -> dict[str, str]:
|
|
user_id = validate_user_id(x_user_id)
|
|
deleted = database.delete_conversation(
|
|
user_id, validate_conversation_id(conversation_id)
|
|
)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Conversation not found.")
|
|
return {"status": "deleted", "id": conversation_id}
|
|
|
|
|
|
def safe_arithmetic(expression: str) -> float:
|
|
"""حساب تعبيرات رقمية بسيطة دون eval أو تنفيذ تعليمات عامة."""
|
|
allowed = set("0123456789+-*/(). %")
|
|
if not expression or any(char not in allowed for char in expression):
|
|
raise ValueError("مسموح بالأرقام والعمليات الحسابية الأساسية فقط.")
|
|
# Parser محدود يدعم الأرقام والأقواس والعمليات الأساسية فقط.
|
|
import ast
|
|
import operator
|
|
|
|
operations = {
|
|
ast.Add: operator.add,
|
|
ast.Sub: operator.sub,
|
|
ast.Mult: operator.mul,
|
|
ast.Div: operator.truediv,
|
|
ast.Mod: operator.mod,
|
|
ast.USub: operator.neg,
|
|
ast.UAdd: operator.pos,
|
|
}
|
|
|
|
def evaluate(node: ast.AST) -> float:
|
|
if isinstance(node, ast.Expression):
|
|
return evaluate(node.body)
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
|
return float(node.value)
|
|
if isinstance(node, ast.BinOp) and type(node.op) in operations:
|
|
return operations[type(node.op)](evaluate(node.left), evaluate(node.right))
|
|
if isinstance(node, ast.UnaryOp) and type(node.op) in operations:
|
|
return operations[type(node.op)](evaluate(node.operand))
|
|
raise ValueError("التعبير غير مدعوم.")
|
|
|
|
return evaluate(ast.parse(expression, mode="eval"))
|
|
|
|
|
|
@app.post("/v1/agent/run")
|
|
async def run_agent(request: AgentRequest) -> dict[str, Any]:
|
|
"""وكيل صغير: يحسب التعبير الرياضي محليًا، ويرسل المهام النصية للنموذج."""
|
|
try:
|
|
result = safe_arithmetic(request.task)
|
|
return {"task": request.task, "tool": "calculator", "result": result}
|
|
except (ValueError, SyntaxError, ZeroDivisionError):
|
|
pass
|
|
|
|
base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/")
|
|
model = request.model or os.getenv("LOCAL_MODEL", "qwen2.5:1.5b-instruct-q4_K_M")
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": "أنت وكيل مساعد محلي. أجب بالعربية وباختصار شديد. إذا كانت المهمة حسابًا فاستعمل الآلة الحاسبة."},
|
|
{"role": "user", "content": request.task},
|
|
],
|
|
"stream": False,
|
|
}
|
|
if model.lower().startswith("gemma4"):
|
|
payload["reasoning_effort"] = "none"
|
|
completion = await get_completion(payload, base_url)
|
|
return {
|
|
"task": request.task,
|
|
"tool": "local-llm",
|
|
"model": model,
|
|
"result": completion["choices"][0]["message"]["content"],
|
|
}
|
|
|
|
|
|
@app.post("/v1/audio/transcriptions")
|
|
async def transcribe_audio(
|
|
file: UploadFile = File(...),
|
|
language: str | None = Form(default=None),
|
|
prompt: str | None = Form(default=None),
|
|
) -> dict[str, Any]:
|
|
"""Proxy microphone audio to Groq without exposing its key to the client."""
|
|
api_key = os.getenv("GROQ_API_KEY")
|
|
if not api_key:
|
|
raise HTTPException(status_code=503, detail="GROQ_API_KEY is not set in the server environment.")
|
|
|
|
audio = await file.read(25 * 1024 * 1024 + 1)
|
|
if not audio:
|
|
raise HTTPException(status_code=400, detail="Audio file is empty.")
|
|
if len(audio) > 25 * 1024 * 1024:
|
|
raise HTTPException(status_code=413, detail="Audio exceeds the 25 MB upload limit.")
|
|
|
|
form = {
|
|
"model": "whisper-large-v3-turbo",
|
|
"temperature": "0",
|
|
"response_format": "verbose_json",
|
|
}
|
|
if language:
|
|
form["language"] = language
|
|
if prompt:
|
|
form["prompt"] = prompt
|
|
files = {
|
|
"file": (file.filename or "recording.wav", audio, file.content_type or "audio/wav"),
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
|
response = await client.post(
|
|
"https://api.groq.com/openai/v1/audio/transcriptions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
data=form,
|
|
files=files,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Groq transcription failed ({exc.response.status_code}): {exc.response.text[:400]}",
|
|
) from exc
|
|
except httpx.RequestError as exc:
|
|
raise HTTPException(status_code=502, detail="Could not reach Groq transcription service.") from exc
|