Files
sovereign_ai/SovereignAI-Starter/app/main.py
T

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