chore: establish Sovereign AI reference
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""SovereignAI Starter API package."""
|
||||
@@ -0,0 +1,166 @@
|
||||
"""SQLite storage for local users, conversations, and messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from uuid import UUID
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _database_path() -> Path:
|
||||
configured = os.getenv("SOVEREIGNAI_DATA_DIR")
|
||||
if configured:
|
||||
data_dir = Path(configured).expanduser()
|
||||
else:
|
||||
local_app_data = os.getenv("LOCALAPPDATA")
|
||||
base_dir = Path(local_app_data) if local_app_data else Path.home() / ".local" / "share"
|
||||
data_dir = base_dir / "SovereignAI" / "data"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
return data_dir / "sovereign_ai.sqlite3"
|
||||
|
||||
|
||||
DATABASE_PATH = _database_path()
|
||||
LOCAL_USER_ID = str(
|
||||
UUID(os.getenv("SOVEREIGNAI_LOCAL_USER_ID", "00000000-0000-4000-8000-000000000001"))
|
||||
)
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(DATABASE_PATH, timeout=10)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
with _connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_subject TEXT NOT NULL,
|
||||
email TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(provider, provider_subject)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_user_updated
|
||||
ON conversations(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation
|
||||
ON messages(conversation_id, id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def ensure_user(user_id: str) -> None:
|
||||
with _connect() as connection:
|
||||
connection.execute("INSERT OR IGNORE INTO users(id) VALUES (?)", (user_id,))
|
||||
|
||||
|
||||
def list_conversations(user_id: str) -> list[dict[str, Any]]:
|
||||
with _connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT c.id, c.title, c.created_at, c.updated_at,
|
||||
COUNT(m.id) AS message_count
|
||||
FROM conversations AS c
|
||||
LEFT JOIN messages AS m ON m.conversation_id = c.id
|
||||
WHERE c.user_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY c.updated_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_conversation(user_id: str, conversation_id: str) -> dict[str, Any] | None:
|
||||
with _connect() as connection:
|
||||
conversation = connection.execute(
|
||||
"""
|
||||
SELECT id, title, created_at, updated_at
|
||||
FROM conversations WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(conversation_id, user_id),
|
||||
).fetchone()
|
||||
if conversation is None:
|
||||
return None
|
||||
messages = connection.execute(
|
||||
"SELECT role, content, created_at FROM messages "
|
||||
"WHERE conversation_id = ? ORDER BY id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {**dict(conversation), "messages": [dict(row) for row in messages]}
|
||||
|
||||
|
||||
def save_conversation(
|
||||
user_id: str,
|
||||
conversation_id: str,
|
||||
title: str,
|
||||
messages: list[dict[str, str]],
|
||||
timestamp: str,
|
||||
) -> None:
|
||||
with _connect() as connection:
|
||||
connection.execute("INSERT OR IGNORE INTO users(id) VALUES (?)", (user_id,))
|
||||
owner = connection.execute(
|
||||
"SELECT user_id FROM conversations WHERE id = ?", (conversation_id,)
|
||||
).fetchone()
|
||||
if owner is not None and owner["user_id"] != user_id:
|
||||
raise PermissionError("Conversation does not belong to this user.")
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO conversations(id, user_id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET title = excluded.title,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(conversation_id, user_id, title, timestamp, timestamp),
|
||||
)
|
||||
connection.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
|
||||
connection.executemany(
|
||||
"INSERT INTO messages(conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
(conversation_id, message["role"], message["content"], timestamp)
|
||||
for message in messages
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def delete_conversation(user_id: str, conversation_id: str) -> bool:
|
||||
with _connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM conversations WHERE id = ? AND user_id = ?",
|
||||
(conversation_id, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
initialize_database()
|
||||
@@ -0,0 +1,349 @@
|
||||
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="مهمة قصيرة للوكيل المحلي")
|
||||
|
||||
|
||||
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")
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@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 = 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,
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user