Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/ai/chat.py
2026-08-21 14:57:55 +00:00

3382 lines
128 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from typing import Dict, Any, List, Optional
from fastapi import APIRouter, Depends, Request, Body, Path, Query, Response, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
import json
import logging
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.responses import success_response, ApiError, format_datetime_fields
from app.services.ai.ai_service import AIService
from app.services.ai.chat_message_builder import (
build_llm_messages_from_history,
parse_json_field,
serialize_function_metadata,
)
from adapters.db.repositories.ai_chat_repository import AIChatSessionRepository, AIChatMessageRepository
from adapters.db.models.ai_chat_session import AIChatSession
from adapters.db.models.ai_chat_message import AIChatMessage, MessageRole
from app.services.ai.ai_execution_policy import (
exploration_mode_for_execution,
resolve_execution_mode,
)
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ai/chat", tags=["هوش مصنوعی"])
DEFAULT_CHAT_TITLE = "گفت‌وگوی جدید"
from app.services.ai.ai_constants import AI_OPERATION_CHAT
from app.services.ai.ai_tool_intent import estimate_query_complexity
from app.services.ai.ai_usage_accumulate import estimate_chat_tokens, merge_usage
def _prepare_ai_service_model(ai_service: AIService, model: Optional[str]) -> None:
if model:
ai_service.set_request_model(model)
ai_service._validate_request_model_if_set()
def _ensure_chat_availability(
db,
ctx: AuthContext,
business_id: Optional[int],
*,
user_text: str,
model: Optional[str] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""قبل از هر فراخوانی LLM روی مسیر چت، اشتراک/سهمیه را اجباری چک کن."""
ai_service = AIService(db, ctx, business_id)
_prepare_ai_service_model(ai_service, model)
ai_service.ensure_availability_or_raise(
estimated_tokens=estimate_chat_tokens(user_text),
model=model,
user_query=user_text,
history_messages=messages,
)
def _apply_chat_routing_context(
ai_service: AIService,
*,
user_query: Optional[str],
messages: Optional[List[Dict[str, Any]]] = None,
use_function_calling: bool = True,
) -> None:
needs_tools = False
if use_function_calling and user_query:
complexity = estimate_query_complexity(user_query, messages)
needs_tools = complexity in ("medium", "complex")
ai_service.set_routing_context(
operation=AI_OPERATION_CHAT,
user_query=user_query,
history_messages=messages,
needs_tools=needs_tools,
)
def _usage_provider_and_model(ai_service: AIService) -> tuple[str, str]:
try:
return ai_service.get_effective_provider_type(), ai_service.get_effective_model_code()
except ApiError:
provider = ai_service.config.provider if ai_service.config else "openai"
model_name = ai_service.config.model_name if ai_service.config else "gpt-4"
return provider, model_name
def _usage_log_context(
ai_service: AIService,
usage: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
from app.services.ai.ai_prompt_cache import LLMUsageDetails, usage_context_fields
ctx: Dict[str, Any] = {}
try:
requested = ai_service.get_requested_model_code()
resolved = ai_service.get_effective_model_code()
if requested != resolved:
ctx["requested_model"] = requested
ctx["resolved_model"] = resolved
except Exception:
pass
if usage:
details = LLMUsageDetails(
input_tokens=int(usage.get("input_tokens", 0) or 0),
output_tokens=int(usage.get("output_tokens", 0) or 0),
total_tokens=int(usage.get("total_tokens", 0) or 0),
cached_tokens=int(usage.get("cached_tokens", 0) or 0),
cache_creation_tokens=int(usage.get("cache_creation_input_tokens", 0) or 0),
cache_read_tokens=int(usage.get("cache_read_input_tokens", 0) or 0),
)
ctx.update(usage_context_fields(details))
return ctx or None
def _prepare_assistant_persist_fields(
raw_content: str,
function_calls: Optional[List[Dict[str, Any]]],
function_results: Optional[Dict[str, Any]],
*,
user_query: Optional[str] = None,
history_messages: Optional[List[Dict[str, Any]]] = None,
) -> tuple[str, Optional[List[Dict[str, Any]]], Optional[Dict[str, Any]]]:
"""پاک‌سازی content و نهایی‌سازی trace قبل از ذخیره assistant."""
from app.services.ai.ai_content_sanitize import (
extract_leaked_function_calls,
sanitize_assistant_content,
)
from app.services.ai.ai_deliverable_answer import gate_ungrounded_assistant_content
from app.services.ai.ai_trace import (
extract_trace_from_function_results,
merge_trace_into_function_results,
)
content = sanitize_assistant_content(raw_content or "")
calls = function_calls
if not calls:
leaked = extract_leaked_function_calls(f"{raw_content or ''}\n{content}")
if leaked:
calls = leaked
trace = extract_trace_from_function_results(function_results)
merged_results = function_results
if trace:
merged_results = merge_trace_into_function_results(function_results, trace)
if user_query:
content = gate_ungrounded_assistant_content(
content,
user_query=user_query,
history_messages=history_messages,
function_calls=calls,
function_results=merged_results,
)
return content, calls, merged_results
def _warn_if_premature_status_answer(
*,
content: str,
message_content: str,
messages: List[Dict[str, Any]],
function_calls: Optional[List[Dict[str, Any]]],
function_results: Optional[Dict[str, Any]],
session_id: int,
) -> None:
"""هشدار نرم (فقط log) اگر محتوای در حال ذخیره تحویلی نباشد
درحالی‌که سوال نیاز به ابزار داشته و evidence ابزاری موجود است.
"""
try:
from app.services.ai.ai_deliverable_answer import is_deliverable_answer
from app.services.ai.ai_tool_intent import query_expects_tool_use
if not query_expects_tool_use(message_content, messages):
return
has_tool_evidence = bool(function_calls) or any(
not str(key).startswith("_") for key in (function_results or {})
)
if not has_tool_evidence:
return
if is_deliverable_answer(
content,
needs_tools=True,
has_tool_evidence=True,
):
return
logger.warning(
"[AI Premature Answer][session=%s] content is not deliverable "
"while needs_tools=True and tool evidence exists: %r",
session_id,
(content or "")[:160],
)
except Exception:
# هشدار نرم — هرگز نباید persist را مختل کند
logger.debug("premature-answer check failed", exc_info=True)
def _session_needs_title(session: AIChatSession) -> bool:
title = (session.title or "").strip()
return not title or title == DEFAULT_CHAT_TITLE
async def _maybe_generate_session_title(
db: Session,
session: AIChatSession,
ctx: AuthContext,
business_id: Optional[int],
message_content: str,
) -> None:
if not _session_needs_title(session):
return
content = (message_content or "").strip()
if not content:
return
try:
ai_service = AIService(db, ctx, business_id or session.business_id)
generated_title = await ai_service.generate_chat_title(content)
if not generated_title:
logger.warning(
"Chat title generation returned empty for session %s",
session.id,
)
return
session.title = generated_title.strip()[:80]
except Exception as exc:
logger.warning(
"Chat title generation failed for session %s: %s",
session.id,
exc,
)
def _schedule_session_title_generation(
session_id: int,
message_content: str,
ctx: AuthContext,
business_id: int,
) -> None:
"""تولید عنوان گفت‌وگو در پس‌زمینه تا رویداد done مسدود نشود."""
import asyncio
async def _task() -> None:
try:
from adapters.db.session import get_db_session
from adapters.db.repositories.ai_chat_repository import AIChatSessionRepository
with get_db_session() as db:
session_repo = AIChatSessionRepository(db)
updated_session = session_repo.get_by_id(session_id)
if not updated_session:
return
if updated_session.title and updated_session.title != DEFAULT_CHAT_TITLE:
return
ai_service = AIService(db, ctx, business_id)
generated_title = await ai_service.generate_chat_title(message_content)
if not generated_title:
return
updated_session.title = generated_title[:80]
from datetime import datetime
updated_session.updated_at = datetime.utcnow()
db.commit()
except Exception as exc:
logger.warning(
"Background chat title generation failed for session %s: %s",
session_id,
exc,
)
asyncio.create_task(_task())
def _approval_from_result_entry(entry: Any) -> Optional[Dict[str, Any]]:
if isinstance(entry, dict) and isinstance(entry.get("result"), dict):
entry = entry["result"]
if not isinstance(entry, dict):
return None
if entry.get("error") != "APPROVAL_REQUIRED":
return None
function_name = entry.get("function")
arguments = entry.get("arguments")
if not isinstance(function_name, str) or not isinstance(arguments, dict):
return None
payload = {"function": function_name, "arguments": arguments}
approval_id = entry.get("approval_id")
if isinstance(approval_id, str) and approval_id.strip():
payload["approval_id"] = approval_id.strip()
return payload
def _extract_pending_write_approvals(messages: List[AIChatMessage]) -> List[Dict[str, Any]]:
"""فقط approvalهای آخرین پاسخ assistant را معتبر می‌داند."""
from app.services.ai.ai_write_guard import extract_pending_approval_ops
for msg in reversed(messages):
role = msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", str(msg.role))
if role != MessageRole.ASSISTANT.value:
continue
return extract_pending_approval_ops(parse_json_field(msg.function_results))
return []
def _session_execution_mode(session: AIChatSession) -> str:
return resolve_execution_mode(getattr(session, "execution_mode", None))
def _resolve_request_execution_mode(
session: AIChatSession,
request_mode: Optional[str],
) -> str:
if request_mode and str(request_mode).strip():
return resolve_execution_mode(request_mode)
return _session_execution_mode(session)
def _resolve_exploration_mode(
query_mode: Optional[str],
body_mode: Optional[str],
execution_mode: str,
) -> str:
explicit = (query_mode or body_mode or "").strip().lower()
if explicit:
return explicit
return exploration_mode_for_execution(execution_mode)
def _session_response_dict(session: AIChatSession) -> Dict[str, Any]:
return {
"id": session.id,
"user_id": session.user_id,
"title": session.title,
"business_id": session.business_id,
"execution_mode": _session_execution_mode(session),
"created_at": session.created_at.isoformat() if session.created_at else None,
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
}
class ChatMessageRequest(BaseModel):
content: str = ""
session_id: Optional[int] = None
approve_writes: bool = False
silent: bool = False
mode: Optional[str] = None # explore | auto | off
execution_mode: Optional[str] = None # analyzer | supervised | autonomous
model: Optional[str] = None
class CheckAvailabilityRequest(BaseModel):
business_id: Optional[int] = None
estimated_tokens: int = 1000
model: Optional[str] = None
user_query: Optional[str] = None
class AIMemoryUpdateRequest(BaseModel):
business_id: Optional[int] = None
content: str = ""
instructions: Optional[str] = None
class AIMemoryItemUpdateRequest(BaseModel):
business_id: Optional[int] = None
content: str
class AIMemoryPinRequest(BaseModel):
business_id: Optional[int] = None
content: str
kind: Optional[str] = "context"
class AIKnowledgeCreateRequest(BaseModel):
business_id: Optional[int] = None
title: str
content: str
class ChatSessionTodoUpdateRequest(BaseModel):
status: str
class ChatContinueRunRequest(BaseModel):
approve_writes: bool = False
execution_mode: Optional[str] = None
model: Optional[str] = None
last_event_id: Optional[int] = None
class ChatEditMessageRequest(BaseModel):
content: str
approve_writes: bool = False
regenerate_after: bool = True
mode: Optional[str] = None
execution_mode: Optional[str] = None
model: Optional[str] = None
class ChatSessionUpdateRequest(BaseModel):
execution_mode: Optional[str] = None
title: Optional[str] = None
class AIConnectorCreateRequest(BaseModel):
business_id: Optional[int] = None
name: Optional[str] = None
title: str
description: Optional[str] = None
http_method: str = "GET"
url: str
headers: Optional[Dict[str, str]] = None
body_template: Optional[str] = None
class MessageFeedbackRequest(BaseModel):
rating: int
comment: Optional[str] = None
@router.post("/check-availability", summary="بررسی امکان استفاده از AI")
async def check_ai_availability(
request: Request,
params: CheckAvailabilityRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""
بررسی اینکه آیا کاربر می‌تواند از AI استفاده کند
(چک پیشگیرانه قبل از ارسال پیام)
"""
business_id = params.business_id or ctx.business_id
if not business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
ai_service = AIService(db, ctx, business_id)
availability = ai_service.check_availability(
estimated_tokens=params.estimated_tokens,
model=params.model,
user_query=params.user_query,
)
return success_response(availability, request)
@router.get("/insights", summary="بینش لحظه‌ای کسب‌وکار برای AI")
async def get_business_insights(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_insight_service import get_business_insights as load_insights
data = load_insights(db, int(effective_business_id), ctx)
return success_response(data, request)
@router.get("/suggestions", summary="پیشنهادهای پویا برای شروع گفتگو")
async def get_chat_suggestions(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_insight_service import get_dynamic_suggestions
from app.services.ai.ai_memory_proactive_service import get_memory_proactive_suggestions
bid = int(effective_business_id)
items = get_dynamic_suggestions(db, bid, ctx)
memory_items = get_memory_proactive_suggestions(db, bid, ctx)
seen = {i.get("label") for i in items}
for m in memory_items:
if m.get("label") not in seen:
items.insert(0, m)
seen.add(m.get("label"))
if len(items) >= 8:
break
return success_response(items[:8], request)
@router.get("/alerts", summary="هشدارهای پیشگیرانه کسب‌وکار")
async def get_proactive_alerts(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_insight_service import get_proactive_alerts as load_alerts
from app.services.ai.ai_memory_proactive_service import get_memory_enriched_alerts
bid = int(effective_business_id)
base_alerts = load_alerts(db, bid, ctx)
alerts = get_memory_enriched_alerts(db, bid, ctx, base_alerts=base_alerts)
return success_response({"alerts": alerts}, request)
@router.get("/feedback/analytics", summary="تحلیل بازخورد پیام‌های دستیار")
async def get_feedback_analytics(
request: Request,
business_id: Optional[int] = None,
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_feedback_analytics_service import get_feedback_analytics as load_analytics
data = load_analytics(db, business_id=int(effective_business_id), days=days)
return success_response(data, request)
@router.get("/memory", summary="حافظه دستیار برای کسب‌وکار")
async def get_ai_memory(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_memory_service import get_memory_payload
return success_response(
get_memory_payload(db, int(effective_business_id), ctx.get_user_id()),
request,
)
@router.delete("/memory", summary="پاک کردن حافظه دستیار")
async def delete_ai_memory(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_memory_service import clear_memory, get_memory_payload
clear_memory(db, int(effective_business_id), ctx.get_user_id())
return success_response(
get_memory_payload(db, int(effective_business_id), ctx.get_user_id()),
request,
"حافظه پاک شد",
)
@router.put("/memory", summary="ذخیره دستورات همیشگی دستیار")
async def update_ai_memory(
request: Request,
params: AIMemoryUpdateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = params.business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_memory_service import get_memory_payload, upsert_memory
text = params.instructions if params.instructions is not None else params.content
upsert_memory(
db,
int(effective_business_id),
ctx.get_user_id(),
text or "",
)
return success_response(
get_memory_payload(db, int(effective_business_id), ctx.get_user_id()),
request,
"حافظه ذخیره شد",
)
@router.get("/memory/digest", summary="خلاصهٔ خواندنی حافظه دستیار")
async def get_ai_memory_digest(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_memory_service import get_memory_digest
digest = get_memory_digest(db, int(effective_business_id), ctx.get_user_id())
return success_response(digest, request)
@router.put("/memory/items/{item_id}", summary="ویرایش یک حقیقت یادگرفته‌شده")
async def update_ai_memory_item(
request: Request,
item_id: int,
params: AIMemoryItemUpdateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = params.business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_memory_item_service import (
memory_item_to_dict,
update_memory_item_content,
)
row = update_memory_item_content(
db,
int(effective_business_id),
ctx.get_user_id(),
item_id,
params.content,
)
if not row:
raise ApiError("NOT_FOUND", "آیتم حافظه یافت نشد", http_status=404)
return success_response(memory_item_to_dict(row), request, "آیتم به‌روز شد")
@router.delete("/memory/items/{item_id}", summary="حذف یک حقیقت یادگرفته‌شده")
async def delete_ai_memory_item(
request: Request,
item_id: int,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_memory_item_service import soft_delete_memory_item
ok = soft_delete_memory_item(
db,
int(effective_business_id),
ctx.get_user_id(),
item_id=item_id,
)
if not ok:
raise ApiError("NOT_FOUND", "آیتم حافظه یافت نشد", http_status=404)
return success_response({"deleted": True, "id": item_id}, request, "آیتم حذف شد")
@router.post("/memory/entries", summary="افزودن صریح یک مورد به حافظه")
async def pin_ai_memory_entry(
request: Request,
params: AIMemoryPinRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = params.business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_memory_service import pin_user_memory_text
try:
item = pin_user_memory_text(
db,
int(effective_business_id),
ctx.get_user_id(),
params.content,
kind=params.kind or "context",
)
except ValueError as exc:
raise ApiError("INVALID_MEMORY", str(exc), http_status=400) from exc
return success_response(item, request, "به حافظه اضافه شد")
@router.get("/knowledge", summary="لیست اسناد دانشنامه کسب‌وکار")
async def list_knowledge_documents(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_knowledge_service import document_to_dict, index_status_map, list_documents
rows = list_documents(db, int(effective_business_id))
status_by_id = index_status_map(db, [r.id for r in rows])
return success_response(
[document_to_dict(r, index_status=status_by_id.get(r.id)) for r in rows],
request,
)
@router.post("/knowledge", summary="افزودن سند به دانشنامه")
async def create_knowledge_document(
request: Request,
params: AIKnowledgeCreateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = params.business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_knowledge_service import create_document, document_to_dict
try:
row = create_document(
db,
int(effective_business_id),
ctx.get_user_id(),
params.title,
params.content,
)
except ValueError as exc:
raise ApiError("INVALID_CONTENT", str(exc), http_status=400) from exc
return success_response(
document_to_dict(row, include_content=True, db=db),
request,
"سند ذخیره شد",
)
@router.post("/knowledge/upload", summary="آپلود فایل به دانشنامه")
async def upload_knowledge_document(
request: Request,
business_id: Optional[int] = None,
title: Optional[str] = Query(None),
file: UploadFile = File(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_attachment_service import extract_text_from_bytes
from app.services.ai.ai_knowledge_service import create_document, document_to_dict
data = await file.read()
filename = file.filename or "document.txt"
text = extract_text_from_bytes(filename, data, file.content_type)
doc_title = (title or filename).strip()[:512]
row = create_document(
db,
int(effective_business_id),
ctx.get_user_id(),
doc_title,
text,
source_filename=filename,
)
return success_response(
document_to_dict(row, include_content=True, db=db),
request,
"فایل به دانشنامه اضافه شد",
)
@router.delete("/knowledge/{document_id}", summary="حذف سند دانشنامه")
async def delete_knowledge_document(
document_id: int = Path(...),
request: Request = None,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_knowledge_service import delete_document
if not delete_document(db, document_id, int(effective_business_id)):
raise ApiError("NOT_FOUND", "سند یافت نشد", http_status=404)
return success_response({"id": document_id}, request, "سند حذف شد")
@router.post("/knowledge/reindex", summary="بازسازی embedding دانشنامه")
async def reindex_knowledge(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_embedding_service import reindex_business
stats = reindex_business(db, int(effective_business_id))
return success_response(stats, request, "دانشنامه بازنمایه‌سازی شد")
@router.get("/connectors", summary="لیست کانکتورهای HTTP کسب‌وکار")
async def list_connectors(
request: Request,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_connector_service import connector_to_dict, list_connectors as list_conn
rows = list_conn(db, int(effective_business_id))
return success_response([connector_to_dict(r) for r in rows], request)
@router.post("/connectors", summary="افزودن کانکتور HTTP")
async def create_connector(
request: Request,
params: AIConnectorCreateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = params.business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_connector_service import connector_to_dict, create_connector as create_conn
row = create_conn(
db,
int(effective_business_id),
ctx.get_user_id(),
params.model_dump(),
)
return success_response(connector_to_dict(row), request, "کانکتور ایجاد شد")
@router.delete("/connectors/{connector_id}", summary="حذف کانکتور")
async def delete_connector_endpoint(
connector_id: int = Path(...),
request: Request = None,
business_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
from app.services.ai.ai_connector_service import delete_connector
if not delete_connector(db, connector_id, int(effective_business_id)):
raise ApiError("NOT_FOUND", "کانکتور یافت نشد", http_status=404)
return success_response({"id": connector_id}, request, "کانکتور حذف شد")
@router.post(
"/sessions/{session_id}/messages/{message_id}/feedback",
summary="ثبت بازخورد روی پیام",
)
async def post_message_feedback(
session_id: int = Path(...),
message_id: int = Path(...),
request: Request = None,
params: MessageFeedbackRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_feedback_service import feedback_to_dict, upsert_feedback
from app.services.ai.ai_memory_feedback_service import apply_feedback_to_memory
row = upsert_feedback(
db,
message_id,
ctx.get_user_id(),
params.rating,
params.comment,
)
memory_updated = apply_feedback_to_memory(
db,
message_id,
ctx.get_user_id(),
params.rating,
params.comment,
)
payload = feedback_to_dict(row)
payload["memory_updated"] = memory_updated
return success_response(payload, request, "بازخورد ثبت شد")
@router.get("/sessions/{session_id}/attachments", summary="لیست پیوست‌های گفت‌وگو")
async def list_session_attachments(
session_id: int = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_attachment_service import (
list_session_attachments as list_att,
attachment_to_dict,
)
rows = list_att(db, session_id)
return success_response([attachment_to_dict(r) for r in rows], request)
@router.post("/sessions/{session_id}/attachments", summary="آپلود پیوست متنی")
async def upload_session_attachment(
session_id: int = Path(...),
request: Request = None,
file: UploadFile = File(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_attachment_service import create_attachment, attachment_to_dict
data = await file.read()
row = create_attachment(
db,
session_id=session_id,
user_id=ctx.get_user_id(),
filename=file.filename or "file.txt",
file_bytes=data,
mime_type=file.content_type,
)
return success_response(attachment_to_dict(row), request, "پیوست با موفقیت اضافه شد")
@router.delete("/sessions/{session_id}/attachments/{attachment_id}", summary="حذف پیوست")
async def delete_session_attachment(
session_id: int = Path(...),
attachment_id: int = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_attachment_service import delete_attachment
if not delete_attachment(db, attachment_id, ctx.get_user_id()):
raise ApiError("ATTACHMENT_NOT_FOUND", "پیوست یافت نشد", http_status=404)
return success_response({"id": attachment_id}, request, "پیوست حذف شد")
@router.get("/sessions/{session_id}/messages/search", summary="جستجو در پیام‌های گفت‌وگو")
async def search_session_messages(
session_id: int = Path(...),
q: str = Query(..., min_length=1, max_length=200),
limit: int = Query(30, ge=1, le=100),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
message_repo = AIChatMessageRepository(db)
messages = message_repo.get_session_messages(session_id, limit=500)
term = q.strip().lower()
hits = []
for msg in messages:
if term in (msg.content or "").lower():
hits.append({
"id": msg.id,
"role": msg.role,
"content": msg.content,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
})
if len(hits) >= limit:
break
return success_response(hits, request)
@router.get("/sessions", summary="لیست گفت‌وگوها")
async def get_chat_sessions(
request: Request,
business_id: Optional[int] = None,
search: Optional[str] = Query(None, description="جستجو در عنوان گفت‌وگو"),
limit: int = 50,
skip: int = 0,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""دریافت لیست گفت‌وگوهای کاربر"""
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
repo = AIChatSessionRepository(db)
sessions = repo.get_user_sessions(
user_id=ctx.get_user_id(),
business_id=effective_business_id,
limit=limit,
skip=skip,
search=search,
)
result = []
for session in sessions:
result.append(_session_response_dict(session))
return success_response(result, request)
@router.post("/sessions", summary="ایجاد گفت‌وگوی جدید")
async def create_chat_session(
request: Request,
title: Optional[str] = Body(None, embed=True),
business_id: Optional[int] = Body(None, embed=True),
execution_mode: Optional[str] = Body(None, embed=True),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""ایجاد گفت‌وگوی جدید"""
effective_business_id = business_id or ctx.business_id
if not effective_business_id:
raise ApiError("BUSINESS_ID_REQUIRED", "شناسه کسب و کار الزامی است", http_status=400)
if not ctx.can_access_business(int(effective_business_id)):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار مجاز نیست", http_status=403)
session = AIChatSession(
user_id=ctx.get_user_id(),
business_id=effective_business_id,
title=title or DEFAULT_CHAT_TITLE,
execution_mode=resolve_execution_mode(execution_mode),
)
db.add(session)
db.commit()
db.refresh(session)
return success_response(_session_response_dict(session), request, "جلسه چت با موفقیت ایجاد شد")
@router.get("/sessions/{session_id}", summary="دریافت یک گفت‌وگو")
async def get_chat_session(
session_id: int = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
return success_response(_session_response_dict(session), request)
@router.get("/sessions/{session_id}/active-run", summary="اجرای ناتمام یا زندهٔ جلسه")
async def get_active_agent_run(
session_id: int = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
from adapters.db.models.ai_agent_run import AIAgentRunStatus
from app.services.ai.ai_agent_run_store import get_latest_active_run
from app.services.ai.ai_run_hub import agent_run_hub, is_running_row_fresh
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
row = get_latest_active_run(
db, session_id=session_id, user_id=ctx.get_user_id()
)
if row is None:
return success_response(None, request)
live = agent_run_hub.is_live(row.run_id)
can_continue = row.status in AIAgentRunStatus.RESUMABLE or (
row.status == AIAgentRunStatus.RUNNING and not live and not is_running_row_fresh(row.updated_at)
)
stop_message = None
if row.budget_json:
try:
import json
budget = json.loads(row.budget_json)
if isinstance(budget, dict):
stop_message = budget.get("stop_message_fa")
except (TypeError, ValueError):
stop_message = None
return success_response(
{
"run_id": row.run_id,
"status": row.status,
"live": live,
"can_continue": bool(can_continue),
"last_event_id": int(row.last_event_id or 0),
"stop_reason": row.stop_reason,
"stop_message_fa": stop_message,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
},
request,
)
@router.patch("/sessions/{session_id}", summary="به‌روزرسانی گفت‌وگو")
async def update_chat_session(
session_id: int = Path(...),
request: Request = None,
params: ChatSessionUpdateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
if params.title is not None and params.title.strip():
session.title = params.title.strip()[:255]
if params.execution_mode is not None:
session.execution_mode = resolve_execution_mode(params.execution_mode)
from datetime import datetime
session.updated_at = datetime.utcnow()
db.commit()
db.refresh(session)
return success_response(_session_response_dict(session), request, "گفت‌وگو به‌روزرسانی شد")
@router.patch(
"/sessions/{session_id}/todos/{todo_id}",
summary="رد یا تأیید آیتم برنامهٔ کاری جلسه",
)
async def update_session_todo_item(
session_id: int = Path(...),
todo_id: str = Path(..., min_length=4, max_length=16),
request: Request = None,
params: ChatSessionTodoUpdateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_session_todo_service import (
apply_user_todo_status,
build_todo_snapshot_payload,
)
try:
rows = apply_user_todo_status(db, session_id, todo_id, params.status)
except ValueError as exc:
raise ApiError("TODO_UPDATE_REJECTED", str(exc), http_status=400) from exc
db.commit()
return success_response(
build_todo_snapshot_payload(rows),
request,
"برنامهٔ کاری به‌روزرسانی شد",
)
@router.get("/sessions/{session_id}/messages", summary="دریافت پیام‌های جلسه")
async def get_session_messages(
session_id: int = Path(...),
limit: int = 100,
skip: int = 0,
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""دریافت پیام‌های یک گفت‌وگو"""
# بررسی دسترسی
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
message_repo = AIChatMessageRepository(db)
messages = message_repo.get_session_messages(session_id, limit, skip)
result = []
for msg in messages:
function_calls = msg.function_calls
if isinstance(function_calls, str):
try:
import json
function_calls = json.loads(function_calls)
except Exception:
pass
function_results = msg.function_results
if isinstance(function_results, str):
try:
import json
function_results = json.loads(function_results)
except Exception:
pass
result.append({
"id": msg.id,
"session_id": msg.session_id,
"role": msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role),
"content": msg.content,
"function_calls": function_calls,
"function_results": function_results,
"tokens_used": msg.tokens_used,
"created_at": msg.created_at.isoformat() if msg.created_at else None
})
return success_response(result, request)
@router.post("/sessions/{session_id}/messages", summary="ارسال پیام به AI")
async def send_message(
session_id: int = Path(...),
request: Request = None,
message_data: ChatMessageRequest = Body(...),
stream: bool = Query(False, description="استفاده از streaming"),
mode: Optional[str] = Query(
None,
description="حالت agent: explore (تحلیل عمیق), auto (پیش‌فرض), off",
),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""ارسال پیام به AI و دریافت پاسخ (با یا بدون streaming)"""
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
execution_mode = _resolve_request_execution_mode(
session, message_data.execution_mode
)
exploration_mode = _resolve_exploration_mode(
mode, message_data.mode, execution_mode
)
session.execution_mode = execution_mode
from app.services.ai.ai_write_approval_turn import (
SILENT_WRITE_APPROVAL_LLM_TURN,
is_silent_write_approval,
last_real_user_query,
)
approve_writes = bool(message_data.approve_writes)
silent_approval = is_silent_write_approval(
approve_writes=approve_writes,
silent=bool(message_data.silent),
content=message_data.content,
)
if not silent_approval and not (message_data.content or "").strip():
raise ApiError("EMPTY_MESSAGE", "متن پیام خالی است", http_status=400)
# دریافت پیام‌های قبلی
message_repo = AIChatMessageRepository(db)
previous_messages = message_repo.get_session_messages(session_id, limit=50)
approved_write_calls = (
_extract_pending_write_approvals(previous_messages)
if approve_writes
else []
)
if approve_writes and not approved_write_calls:
raise ApiError(
"APPROVAL_NOT_FOUND",
"عملیات در انتظار تأیید یافت نشد. همان گفت‌وگو را باز کنید و دوباره تأیید کنید.",
http_status=400,
)
effective_user_query = (message_data.content or "").strip()
if silent_approval:
effective_user_query = last_real_user_query(previous_messages) or effective_user_query
# پیش‌چک اجباری قبل از ذخیره پیام / فراخوانی provider
_ensure_chat_availability(
db,
ctx,
session.business_id,
user_text=effective_user_query or message_data.content or "",
model=message_data.model,
)
# ساخت messages برای AI (شامل تاریخچه tool)
messages = build_llm_messages_from_history(previous_messages)
messages.append({
"role": "user",
"content": (
SILENT_WRITE_APPROVAL_LLM_TURN if silent_approval else message_data.content
),
})
persist_user_message = not silent_approval
if persist_user_message:
user_message = AIChatMessage(
session_id=session_id,
role=MessageRole.USER.value,
content=message_data.content,
tokens_used=0
)
db.add(user_message)
# اگر streaming درخواست شده باشد
if stream:
# commit کردن پیام کاربر قبل از شروع streaming
# تا اگر خطایی رخ داد، حداقل پیام کاربر ذخیره شده باشد
db.commit()
if persist_user_message:
db.refresh(user_message)
# refresh کردن session تا مطمئن شویم که به‌روزرسانی‌های بعدی کار می‌کنند
db.refresh(session)
# ذخیره business_id قبل از بستن session
business_id = session.business_id
# بستن session اصلی برای جلوگیری از connection leak
# session برای streaming استفاده نمی‌شود (در _stream_message_response از session جدید استفاده می‌شود)
db.close()
return StreamingResponse(
_stream_message_response(
session_id=session_id,
messages=messages,
ctx=ctx,
business_id=business_id,
previous_messages=previous_messages,
message_content=effective_user_query,
approve_writes=approve_writes,
approved_write_calls=approved_write_calls,
exploration_mode=exploration_mode,
execution_mode=execution_mode,
request_model=message_data.model,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # برای nginx
}
)
# برای non-streaming، بعد از ذخیره پیام AI commit می‌کنیم
db.flush()
# ذخیره business_id و session title قبل از بستن session
business_id = session.business_id
session_title = session.title
is_first_message = len(previous_messages) == 0
# بستن session اصلی برای جلوگیری از connection leak در طول async operation
db.close()
# حالت non-streaming (کد قبلی)
# ارسال به AI به صورت async (بدون session)
from adapters.db.session import get_db_session
with get_db_session() as new_db:
new_ai_service = AIService(new_db, ctx, business_id)
_prepare_ai_service_model(new_ai_service, message_data.model)
response = await new_ai_service.chat_completion(
messages,
use_function_calling=True,
session_business_id=business_id,
session_id=session_id,
approve_writes=approve_writes,
approved_write_calls=approved_write_calls,
user_query=effective_user_query,
request_model=message_data.model,
execution_mode=execution_mode,
)
response_content_preview = (response.get("message", {}).get("content") or "")[:500]
logger.info(
"[AI Response][session=%s][stream=%s] preview=%s",
session_id,
False,
response_content_preview
)
# بررسی سهمیه و شارژ و ذخیره پاسخ (با session جدید)
usage = response.get("usage", {})
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
with get_db_session() as commit_db:
# خواندن session از دیتابیس
commit_session_repo = AIChatSessionRepository(commit_db)
commit_session = commit_session_repo.get_by_id(session_id)
if not commit_session:
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
commit_ai_service = AIService(commit_db, ctx, business_id)
_prepare_ai_service_model(commit_ai_service, message_data.model)
_apply_chat_routing_context(
commit_ai_service,
user_query=effective_user_query,
messages=messages,
)
charge_result = commit_ai_service.check_quota_and_charge(
input_tokens, output_tokens, model_code=commit_ai_service.get_effective_model_code()
)
fc_meta = response.get("_function_calls")
fr_meta = response.get("_function_results")
persist_content, persist_calls, persist_results = _prepare_assistant_persist_fields(
response["message"]["content"] or "",
fc_meta,
fr_meta,
user_query=effective_user_query,
history_messages=messages,
)
fc_json, fr_json = serialize_function_metadata(persist_calls, persist_results)
# ذخیره پاسخ AI
assistant_message = AIChatMessage(
session_id=session_id,
role=MessageRole.ASSISTANT.value,
content=persist_content,
function_calls=fc_json,
function_results=fr_json,
tokens_used=input_tokens + output_tokens
)
commit_db.add(assistant_message)
# ثبت لاگ استفاده
provider_name, model_code = _usage_provider_and_model(commit_ai_service)
commit_ai_service.log_usage(
provider=provider_name,
model=model_code,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=charge_result.get("cost", 0),
payment_method=charge_result.get("payment_method", "free"),
wallet_transaction_id=charge_result.get("wallet_transaction_id"),
document_id=charge_result.get("document_id"),
context=_usage_log_context(commit_ai_service, usage),
)
commit_ai_service.clear_routing_context()
# به‌روزرسانی زمان جلسه
from datetime import datetime
commit_session.updated_at = datetime.utcnow()
needs_title = _session_needs_title(commit_session) and is_first_message
if needs_title:
await _maybe_generate_session_title(
commit_db,
commit_session,
ctx,
business_id,
effective_user_query,
)
commit_db.commit()
commit_db.refresh(assistant_message)
message_id = assistant_message.id
if needs_title and _session_needs_title(commit_session):
_schedule_session_title_generation(
session_id,
effective_user_query,
ctx,
business_id,
)
from app.services.ai.ai_memory_hooks import schedule_memory_update_after_chat
schedule_memory_update_after_chat(session_id, business_id, ctx)
return success_response({
"message": {
"id": message_id,
"session_id": session_id,
"role": MessageRole.ASSISTANT.value,
"content": response["message"]["content"],
"tokens_used": input_tokens + output_tokens,
"created_at": None # از دیتابیس خوانده می‌شود
},
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cost": charge_result.get("cost", 0),
"payment_method": charge_result.get("payment_method", "free")
}
}, request)
def _sse_stream_headers() -> Dict[str, str]:
return {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
def _sse_payload(
data: Dict[str, Any],
sequencer: Optional[Any] = None,
) -> str:
from app.services.ai.ai_stream_helpers import format_sse_payload
if sequencer is not None:
return sequencer.format(data)
return format_sse_payload(data)
def _emit_chunk_as_sse(chunk: Dict[str, Any], sequencer: Optional[Any] = None):
"""تبدیل chunk داخلی به payloadهای SSE (generator)."""
from app.services.ai.ai_stream_helpers import chunk_to_sse_data
for data in chunk_to_sse_data(chunk):
yield _sse_payload(data, sequencer)
def _publish_chunk(live: Any, chunk: Dict[str, Any]) -> None:
from app.services.ai.ai_stream_helpers import chunk_to_sse_data
for data in chunk_to_sse_data(chunk):
live.publish(data)
def _extract_content_from_agent_trace(
accumulated_content: str,
agent_trace: Optional[List[Dict[str, Any]]],
) -> str:
"""اگر متن delta خالی بود، از narrative/answer/thought در trace استفاده کن."""
from app.services.ai.ai_trace import merge_accumulated_and_trace_content
return merge_accumulated_and_trace_content(accumulated_content, agent_trace)
def _estimate_stream_usage_if_missing(
final_usage: Optional[Dict[str, Any]],
stream_ai_config: Any,
messages: List[Dict[str, Any]],
accumulated_content: str,
) -> Optional[Dict[str, Any]]:
if final_usage:
return final_usage
if not stream_ai_config:
return None
from adapters.db.session import get_db_session
from app.services.ai.ai_provider import create_provider
from app.services.ai.ai_provider_service import resolve_provider_connection
api_key = None
api_base_url = stream_ai_config.api_base_url
provider_type = (stream_ai_config.provider or "openai").strip().lower()
try:
with get_db_session() as est_db:
_, api_key, api_base_url, _ = resolve_provider_connection(
est_db, provider_type, legacy_config=stream_ai_config
)
except Exception:
api_key = None
if not api_key:
return None
provider = create_provider(
provider_type=provider_type,
api_key=api_key,
api_base_url=api_base_url,
)
input_tokens_estimate = sum(
provider.estimate_tokens(msg.get("content", "")) for msg in messages
)
output_tokens_estimate = provider.estimate_tokens(accumulated_content)
return {
"input_tokens": input_tokens_estimate,
"output_tokens": output_tokens_estimate,
"total_tokens": input_tokens_estimate + output_tokens_estimate,
}
def _merge_agent_run_checkpoint(
function_results: Optional[Dict[str, Any]],
agent_run: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""checkpoint {run_id, phase, iteration, ...} را ذخیره می‌کند."""
if not agent_run:
return function_results
merged = dict(function_results or {})
snap = {
"run_id": agent_run.get("run_id"),
"phase": agent_run.get("phase"),
"iteration": agent_run.get("iteration"),
"needs_tools": agent_run.get("needs_tools"),
"status": agent_run.get("status"),
"stop_reason": agent_run.get("stop_reason"),
"can_continue": bool(agent_run.get("can_continue")),
}
if agent_run.get("tools_called"):
snap["tools_called"] = agent_run.get("tools_called")
merged["_agent_run"] = snap
return merged
def _merge_agent_budget_checkpoint(
function_results: Optional[Dict[str, Any]],
agent_budget: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""بودجهٔ نهایی agent را برای دیباگ در function_results ذخیره می‌کند."""
if not agent_budget:
return function_results
from app.services.ai.ai_budget import AGENT_BUDGET_STORAGE_KEY
merged = dict(function_results or {})
merged[AGENT_BUDGET_STORAGE_KEY] = agent_budget
return merged
def _latest_awaiting_approval_assistant(
db, session_id: int
) -> Optional[AIChatMessage]:
from app.services.ai.ai_write_guard import function_results_await_approval
rows = (
db.query(AIChatMessage)
.filter(
AIChatMessage.session_id == session_id,
AIChatMessage.role == MessageRole.ASSISTANT.value,
)
.order_by(AIChatMessage.created_at.desc(), AIChatMessage.id.desc())
.limit(8)
.all()
)
for row in rows:
if function_results_await_approval(row.function_results):
return row
return None
async def _persist_stream_assistant_message(
*,
session_id: int,
ctx: AuthContext,
business_id: int,
messages: List[Dict[str, Any]],
message_content: str,
previous_messages: List[AIChatMessage],
request_model: Optional[str],
accumulated_content: str,
final_usage: Optional[Dict[str, Any]],
final_function_calls: Optional[List[Dict[str, Any]]],
final_function_results: Optional[Dict[str, Any]],
final_agent_trace: Optional[List[Dict[str, Any]]],
stream_ai_config: Any = None,
final_agent_run: Optional[Dict[str, Any]] = None,
final_agent_budget: Optional[Dict[str, Any]] = None,
awaiting_approval: bool = False,
) -> tuple[Optional[int], Optional[Dict[str, Any]], str]:
"""ذخیره پاسخ assistant و ثبت usage. برمی‌گرداند (message_id, usage, content)."""
from adapters.db.session import get_db_session
from app.services.ai.ai_content_sanitize import (
extract_leaked_function_calls,
prepare_assistant_content_for_persist,
)
from app.services.ai.ai_deliverable_answer import gate_ungrounded_assistant_content
from app.services.ai.ai_trace import finalize_trace_steps_for_persist, merge_trace_into_function_results
finalized_trace = finalize_trace_steps_for_persist(final_agent_trace or [])
content = prepare_assistant_content_for_persist(accumulated_content, finalized_trace)
function_calls = final_function_calls
if not function_calls:
leaked = extract_leaked_function_calls(
f"{accumulated_content or ''}\n{content or ''}"
)
if leaked:
function_calls = leaked
merged_function_results = merge_trace_into_function_results(
final_function_results, finalized_trace
)
merged_function_results = _merge_agent_run_checkpoint(
merged_function_results, final_agent_run
)
merged_function_results = _merge_agent_budget_checkpoint(
merged_function_results, final_agent_budget
)
content = gate_ungrounded_assistant_content(
content,
user_query=message_content,
history_messages=messages,
function_calls=function_calls,
function_results=merged_function_results,
)
_warn_if_premature_status_answer(
content=content,
message_content=message_content,
messages=messages,
function_calls=function_calls,
function_results=merged_function_results,
session_id=session_id,
)
usage = _estimate_stream_usage_if_missing(
final_usage, stream_ai_config, messages, content
)
from app.services.ai.ai_write_guard import mark_function_results_awaiting_approval
merged_function_results = mark_function_results_awaiting_approval(
merged_function_results, awaiting_approval
)
persist_content = content or "خطا در دریافت پاسخ"
input_tokens = int((usage or {}).get("input_tokens", 0) or 0)
output_tokens = int((usage or {}).get("output_tokens", 0) or 0)
with get_db_session() as new_db:
session_repo = AIChatSessionRepository(new_db)
updated_session = session_repo.get_by_id(session_id)
if not updated_session:
logger.error("Session %s not found for commit", session_id)
return None, usage, persist_content
new_ai_service = AIService(new_db, ctx, updated_session.business_id)
_prepare_ai_service_model(new_ai_service, request_model)
_apply_chat_routing_context(
new_ai_service,
user_query=message_content,
messages=messages,
)
if usage:
charge_result = new_ai_service.check_quota_and_charge(
input_tokens,
output_tokens,
model_code=new_ai_service.get_effective_model_code(),
)
provider_name, model_code = _usage_provider_and_model(new_ai_service)
new_ai_service.log_usage(
provider=provider_name,
model=model_code,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=charge_result.get("cost", 0),
payment_method=charge_result.get("payment_method", "free"),
wallet_transaction_id=charge_result.get("wallet_transaction_id"),
document_id=charge_result.get("document_id"),
context=_usage_log_context(new_ai_service, final_usage),
)
new_ai_service.clear_routing_context()
merged_results = merged_function_results or {}
fc_json, fr_json = serialize_function_metadata(
function_calls, merged_results
)
existing_pause = _latest_awaiting_approval_assistant(new_db, session_id)
if existing_pause is not None:
existing_pause.content = persist_content
existing_pause.function_calls = fc_json
existing_pause.function_results = fr_json
existing_pause.tokens_used = max(
int(existing_pause.tokens_used or 0),
input_tokens + output_tokens,
)
assistant_message = existing_pause
else:
assistant_message = AIChatMessage(
session_id=session_id,
role=MessageRole.ASSISTANT.value,
content=persist_content,
function_calls=fc_json,
function_results=fr_json,
tokens_used=input_tokens + output_tokens,
)
new_db.add(assistant_message)
from datetime import datetime
updated_session.updated_at = datetime.utcnow()
needs_title = _session_needs_title(updated_session) and len(
previous_messages
) == 0
if needs_title:
await _maybe_generate_session_title(
new_db,
updated_session,
ctx,
updated_session.business_id or business_id,
message_content,
)
new_db.commit()
new_db.refresh(assistant_message)
message_id = assistant_message.id
if needs_title and _session_needs_title(updated_session):
_schedule_session_title_generation(
session_id,
message_content,
ctx,
updated_session.business_id or business_id,
)
from app.services.ai.ai_memory_hooks import schedule_memory_update_after_chat
schedule_memory_update_after_chat(
session_id,
updated_session.business_id or business_id,
ctx,
)
if final_agent_run and final_agent_run.get("run_id"):
from app.services.ai.ai_agent_run_store import (
persist_agent_run_checkpoint,
tools_called_from_function_results,
)
persist_agent_run_checkpoint(
run_id=str(final_agent_run["run_id"]),
session_id=session_id,
user_id=ctx.get_user_id(),
business_id=updated_session.business_id or business_id,
snapshot=dict(final_agent_run),
status=final_agent_run.get("status"),
stop_reason=final_agent_run.get("stop_reason")
or (
final_agent_budget.get("stop_reason")
if isinstance(final_agent_budget, dict)
else None
),
tools_called=tools_called_from_function_results(
merged_function_results
),
budget=final_agent_budget
if isinstance(final_agent_budget, dict)
else None,
origin_message_id=message_id,
)
return message_id, usage, persist_content
def _schedule_stream_persist_after_disconnect(
*,
session_id: int,
ctx: AuthContext,
business_id: int,
messages: List[Dict[str, Any]],
message_content: str,
previous_messages: List[AIChatMessage],
request_model: Optional[str],
accumulated_content: str,
final_usage: Optional[Dict[str, Any]],
final_function_calls: Optional[List[Dict[str, Any]]],
final_function_results: Optional[Dict[str, Any]],
final_agent_trace: Optional[List[Dict[str, Any]]],
stream_ai_config: Any = None,
final_agent_run: Optional[Dict[str, Any]] = None,
final_agent_budget: Optional[Dict[str, Any]] = None,
awaiting_approval: bool = False,
) -> None:
"""ذخیره پاسخ در پس‌زمینه وقتی کلاینت قبل از done قطع می‌کند."""
import asyncio
has_payload = bool(
(accumulated_content or "").strip()
or final_agent_trace
or final_function_results
)
if not has_payload:
return
async def _task() -> None:
try:
await _persist_stream_assistant_message(
session_id=session_id,
ctx=ctx,
business_id=business_id,
messages=messages,
message_content=message_content,
previous_messages=previous_messages,
request_model=request_model,
accumulated_content=accumulated_content,
final_usage=final_usage,
final_function_calls=final_function_calls,
final_function_results=final_function_results,
final_agent_trace=final_agent_trace,
stream_ai_config=stream_ai_config,
final_agent_run=final_agent_run,
final_agent_budget=final_agent_budget,
awaiting_approval=awaiting_approval,
)
except Exception as exc:
logger.warning(
"Background persist after stream disconnect failed (session=%s): %s",
session_id,
exc,
)
try:
asyncio.get_running_loop().create_task(_task())
except RuntimeError:
pass
async def _iter_run_sse(
*,
run_id: str,
last_event_id: Optional[int],
session_id: int,
user_id: int,
):
"""مشترک شدن به run زنده، یا polling اگر روی worker دیگری باشد."""
import asyncio
import time
from adapters.db.models.ai_agent_run import AIAgentRunStatus
from adapters.db.session import get_db_session
from app.services.ai.ai_agent_run_store import get_owned_agent_run
from app.services.ai.ai_run_hub import agent_run_hub, is_running_row_fresh
from app.services.ai.ai_stream_errors import STREAM_ERROR_IDLE, stream_error_payload
from app.services.ai.ai_stream_helpers import format_sse_payload
if agent_run_hub.is_live(run_id):
async for sse in agent_run_hub.iter_formatted(run_id, int(last_event_id or 0)):
yield sse
return
from app.services.ai.ai_run_hub import replay_buffered_events
cursor = int(last_event_id or 0)
replayed, cursor, terminal = replay_buffered_events(run_id, cursor)
for event_id, payload in replayed:
yield format_sse_payload(payload, event_id=event_id)
if terminal:
return
started = time.monotonic()
last_heartbeat = started
if not replayed:
yield format_sse_payload(
{
"type": "status",
"phase": "connecting",
"run_id": run_id,
"done": False,
}
)
while True:
if agent_run_hub.is_live(run_id):
async for sse in agent_run_hub.iter_formatted(run_id, cursor):
yield sse
return
more, cursor, terminal = replay_buffered_events(run_id, cursor)
for event_id, payload in more:
yield format_sse_payload(payload, event_id=event_id)
if terminal:
return
with get_db_session() as db:
row = get_owned_agent_run(
db, run_id=run_id, session_id=session_id, user_id=user_id
)
if row is None:
yield format_sse_payload(
stream_error_payload(code=STREAM_ERROR_IDLE, run_id=run_id, can_continue=False)
)
return
if row.status != AIAgentRunStatus.RUNNING:
more, cursor, terminal = replay_buffered_events(run_id, cursor)
for event_id, payload in more:
yield format_sse_payload(payload, event_id=event_id)
if terminal:
return
can_continue = row.status in AIAgentRunStatus.RESUMABLE
if can_continue:
yield format_sse_payload(
stream_error_payload(
code=STREAM_ERROR_IDLE,
run_id=run_id,
can_continue=True,
)
)
else:
yield format_sse_payload(
{
"content": "",
"done": True,
"run_id": run_id,
"message_id": row.origin_message_id,
"can_continue": False,
}
)
return
if not is_running_row_fresh(row.updated_at):
yield format_sse_payload(
stream_error_payload(
code=STREAM_ERROR_IDLE, run_id=run_id, can_continue=True
)
)
return
now = time.monotonic()
if now - last_heartbeat >= 2.0:
elapsed_ms = int((now - started) * 1000)
yield format_sse_payload(
{
"type": "heartbeat",
"elapsed_ms": elapsed_ms,
"run_id": run_id,
"done": False,
}
)
last_heartbeat = now
await asyncio.sleep(0.4)
async def _stream_message_response(
session_id: int,
messages: List[Dict[str, Any]],
ctx: AuthContext,
business_id: int,
previous_messages: List[AIChatMessage],
message_content: str,
approve_writes: bool = False,
approved_write_calls: Optional[List[Dict[str, Any]]] = None,
exploration_mode: str = "auto",
execution_mode: str = "analyzer",
request_model: Optional[str] = None,
resume_from_run: Optional[Dict[str, Any]] = None,
last_event_id: Optional[int] = None,
):
"""مشترک SSE به یک run که روی سرور مستقل از اتصال کلاینت اجرا می‌شود."""
import asyncio
from app.services.ai.ai_agent_run import (
AGENT_RUN_STATUS_RUNNING,
new_agent_run_id,
)
from app.services.ai.ai_agent_run_store import persist_agent_run_checkpoint
from app.services.ai.ai_run_hub import agent_run_hub
resume_run_id = None
if isinstance(resume_from_run, dict):
resume_run_id = resume_from_run.get("run_id")
run_id = str(resume_run_id) if resume_run_id else new_agent_run_id()
user_id = ctx.get_user_id()
if agent_run_hub.is_live(run_id):
async for sse in agent_run_hub.iter_formatted(run_id, int(last_event_id or 0)):
yield sse
return
persist_agent_run_checkpoint(
run_id=run_id,
session_id=session_id,
user_id=user_id,
business_id=business_id,
snapshot={
"run_id": run_id,
"status": AGENT_RUN_STATUS_RUNNING,
"phase": "gather_context",
},
status=AGENT_RUN_STATUS_RUNNING,
last_event_id=int(last_event_id or 0),
)
async def producer(live) -> None:
await _produce_agent_stream(
live,
session_id=session_id,
messages=messages,
ctx=ctx,
business_id=business_id,
previous_messages=previous_messages,
message_content=message_content,
approve_writes=approve_writes,
approved_write_calls=approved_write_calls,
exploration_mode=exploration_mode,
execution_mode=execution_mode,
request_model=request_model,
resume_from_run=resume_from_run,
)
start_id = int(last_event_id or 0) if resume_run_id else 0
await agent_run_hub.attach_or_start(
run_id=run_id,
session_id=session_id,
user_id=user_id,
producer=producer,
start_id=start_id,
)
try:
async for sse in agent_run_hub.iter_formatted(run_id, int(last_event_id or 0)):
yield sse
except asyncio.CancelledError:
raise
async def _produce_agent_stream(
live: Any,
*,
session_id: int,
messages: List[Dict[str, Any]],
ctx: AuthContext,
business_id: int,
previous_messages: List[AIChatMessage],
message_content: str,
approve_writes: bool = False,
approved_write_calls: Optional[List[Dict[str, Any]]] = None,
exploration_mode: str = "auto",
execution_mode: str = "analyzer",
request_model: Optional[str] = None,
resume_from_run: Optional[Dict[str, Any]] = None,
) -> None:
"""حلقهٔ ایجنت؛ قطع SSE این تابع را کنسل نمی‌کند — فقط cancel صریح."""
import asyncio
from adapters.db.session import get_db_session
from app.services.ai.ai_agent_run import (
AGENT_RUN_STATUS_INTERRUPTED,
STOP_REASON_DISCONNECT,
)
from app.services.ai.ai_agent_run_store import persist_agent_run_checkpoint
from app.services.ai.ai_stream_errors import stream_error_payload
from app.services.ai.ai_stream_helpers import (
build_chat_done_sse_data,
iter_with_heartbeat,
)
from app.services.ai.ai_tool_keys import status_event
sequencer = live.sequencer
accumulated_content = ""
final_usage = None
final_function_calls: Optional[List[Dict[str, Any]]] = None
final_function_results: Optional[Dict[str, Any]] = None
final_agent_trace: Optional[List[Dict[str, Any]]] = None
final_agent_run: Optional[Dict[str, Any]] = None
final_agent_budget: Optional[Dict[str, Any]] = None
final_awaiting_approval = False
final_citations_context: Optional[str] = None
final_citations: Optional[List[Dict[str, Any]]] = None
final_activated_skills: Optional[List[Dict[str, Any]]] = None
final_requested_model: Optional[str] = None
final_resolved_model: Optional[str] = None
final_execution_mode: Optional[str] = None
stream_ai_config = None
prebuilt_prompt: Optional[str] = None
prebuilt_structured: Optional[Any] = None
resume_run_id = None
if isinstance(resume_from_run, dict):
resume_run_id = resume_from_run.get("run_id")
if resume_run_id:
sequencer.bind_run(str(resume_run_id))
elif live.run_id:
sequencer.bind_run(str(live.run_id))
def _persist_live_run(snapshot: Dict[str, Any], *, interrupted: bool = False) -> None:
run_id = snapshot.get("run_id") or live.run_id
if not run_id:
return
persist_agent_run_checkpoint(
run_id=str(run_id),
session_id=session_id,
user_id=ctx.get_user_id(),
business_id=business_id,
snapshot=snapshot,
status=AGENT_RUN_STATUS_INTERRUPTED if interrupted else snapshot.get("status"),
stop_reason=(
STOP_REASON_DISCONNECT if interrupted else snapshot.get("stop_reason")
),
last_event_id=sequencer.last_id,
function_results=final_function_results,
budget=final_agent_budget if isinstance(final_agent_budget, dict) else None,
)
def _capture_chunk(chunk: Dict[str, Any]) -> None:
nonlocal accumulated_content, final_usage, final_function_calls
nonlocal final_function_results, final_agent_trace, final_agent_run
nonlocal final_agent_budget, final_awaiting_approval
nonlocal final_citations_context, final_citations, final_activated_skills
nonlocal final_requested_model, final_resolved_model, final_execution_mode
delta = chunk.get("delta", {})
content_chunk = delta.get("content", "")
if content_chunk:
accumulated_content += content_chunk
if chunk.get("usage"):
if chunk.get("done"):
final_usage = chunk["usage"]
else:
final_usage = merge_usage(final_usage, chunk["usage"])
if chunk.get("function_calls"):
final_function_calls = chunk["function_calls"]
if chunk.get("function_results"):
final_function_results = chunk["function_results"]
if chunk.get("agent_trace"):
final_agent_trace = chunk["agent_trace"]
live_run = chunk.get("agent_run")
if chunk.get("event") == "agent_run":
live_run = {
"run_id": chunk.get("run_id"),
"phase": chunk.get("phase"),
"iteration": chunk.get("iteration"),
"needs_tools": chunk.get("needs_tools"),
"status": chunk.get("status"),
"stop_reason": chunk.get("stop_reason"),
"can_continue": chunk.get("can_continue"),
}
if isinstance(live_run, dict) and live_run.get("run_id"):
final_agent_run = live_run
sequencer.bind_run(str(live_run["run_id"]))
_persist_live_run(live_run)
if chunk.get("agent_budget"):
final_agent_budget = chunk["agent_budget"]
if chunk.get("done"):
if "awaiting_approval" in chunk:
final_awaiting_approval = bool(chunk.get("awaiting_approval"))
if chunk.get("citations_context") is not None:
final_citations_context = chunk.get("citations_context")
if chunk.get("citations") is not None:
final_citations = chunk.get("citations")
if chunk.get("activated_skills") is not None:
final_activated_skills = chunk.get("activated_skills")
if chunk.get("requested_model"):
final_requested_model = chunk.get("requested_model")
if chunk.get("resolved_model"):
final_resolved_model = chunk.get("resolved_model")
if chunk.get("execution_mode"):
final_execution_mode = chunk.get("execution_mode")
if chunk.get("agent_run"):
final_agent_run = chunk.get("agent_run")
sequencer.bind_run(str(chunk["agent_run"].get("run_id") or ""))
def _schedule_persist_on_disconnect() -> None:
_schedule_stream_persist_after_disconnect(
session_id=session_id,
ctx=ctx,
business_id=business_id,
messages=messages,
message_content=message_content,
previous_messages=previous_messages,
request_model=request_model,
accumulated_content=accumulated_content,
final_usage=final_usage,
final_function_calls=final_function_calls,
final_function_results=final_function_results,
final_agent_trace=final_agent_trace,
stream_ai_config=stream_ai_config,
final_agent_run=final_agent_run,
final_agent_budget=final_agent_budget,
awaiting_approval=final_awaiting_approval,
)
try:
live.publish(
{
"type": "agent_run",
"run_id": live.run_id,
"phase": "gather_context",
"status": "running",
"can_continue": False,
"done": False,
}
)
if resume_run_id:
live.publish(
{
"type": "run_resumed",
"run_id": resume_run_id,
"phase": (resume_from_run or {}).get("phase"),
"iteration": (resume_from_run or {}).get("iteration"),
"done": False,
}
)
live.publish({"type": "status", "phase": "connecting", "done": False})
await asyncio.sleep(0)
with get_db_session() as gate_db:
_ensure_chat_availability(
gate_db,
ctx,
business_id,
user_text=message_content or "",
model=request_model,
messages=messages,
)
live.publish({"type": "status", "phase": "thinking", "done": False})
await asyncio.sleep(0)
with get_db_session() as prep_db:
prep_service = AIService(prep_db, ctx, business_id)
_prepare_ai_service_model(prep_service, request_model)
stream_ai_config = prep_service.config
prebuilt_prompt = ""
async for build_item in prep_service.build_system_prompt_stream(
session_business_id=business_id,
session_id=session_id,
user_query=message_content,
execution_mode=execution_mode,
):
if build_item.get("event") == "prompt_ready":
prebuilt_prompt = build_item.get("prompt") or ""
prebuilt_structured = build_item.get("structured_prompt")
continue
_publish_chunk(live, build_item)
await asyncio.sleep(0)
with get_db_session() as stream_db:
stream_ai_service = AIService(stream_db, ctx, business_id)
_prepare_ai_service_model(stream_ai_service, request_model)
async def _stream_factory():
async for chunk in stream_ai_service.chat_completion_stream(
messages,
use_function_calling=True,
session_business_id=business_id,
session_id=session_id,
approve_writes=approve_writes,
approved_write_calls=approved_write_calls,
exploration_mode=exploration_mode,
execution_mode=execution_mode,
user_query=message_content,
request_model=request_model,
prebuilt_system_prompt=(
prebuilt_structured
if prebuilt_structured is not None
else prebuilt_prompt
),
resume_from_run={
**(resume_from_run or {}),
"run_id": live.run_id,
},
):
yield chunk
async for chunk in iter_with_heartbeat(
_stream_factory,
initial_status=None,
):
event_type = chunk.get("event")
if event_type == "heartbeat":
live.publish({
"type": "heartbeat",
"elapsed_ms": chunk.get("elapsed_ms", 0),
"done": False,
})
if sequencer.last_id % 5 == 0 and (final_agent_run or live.run_id):
_persist_live_run(
dict(final_agent_run or {"run_id": live.run_id, "status": "running"})
)
continue
if event_type == "prompt_ready":
continue
_capture_chunk(chunk)
if chunk.get("done", False):
break
_publish_chunk(live, chunk)
await asyncio.sleep(0)
logger.info(
"[AI Stream][session=%s] final_response_length=%s preview=%s",
session_id,
len(accumulated_content),
(accumulated_content or "")[:500],
)
_publish_chunk(live, status_event("saving"))
message_id, final_usage, persisted_content = await _persist_stream_assistant_message(
session_id=session_id,
ctx=ctx,
business_id=business_id,
messages=messages,
message_content=message_content,
previous_messages=previous_messages,
request_model=request_model,
accumulated_content=accumulated_content,
final_usage=final_usage,
final_function_calls=final_function_calls,
final_function_results=final_function_results,
final_agent_trace=final_agent_trace,
stream_ai_config=stream_ai_config,
final_agent_run=final_agent_run,
final_agent_budget=final_agent_budget,
awaiting_approval=final_awaiting_approval,
)
can_continue = bool((final_agent_run or {}).get("can_continue"))
live.publish(
build_chat_done_sse_data(
message_id=message_id,
usage=final_usage,
function_calls=final_function_calls,
function_results=final_function_results,
extra={
"agent_trace": final_agent_trace,
"agent_budget": final_agent_budget,
"agent_run": final_agent_run,
"awaiting_approval": final_awaiting_approval,
"citations_context": final_citations_context,
"citations": final_citations,
"activated_skills": final_activated_skills,
"requested_model": final_requested_model,
"resolved_model": final_resolved_model,
"execution_mode": final_execution_mode,
"can_continue": can_continue,
"run_id": (final_agent_run or {}).get("run_id") or live.run_id,
"final_content": persisted_content,
"stop_reason": (
(final_agent_budget or {}).get("stop_reason")
if isinstance(final_agent_budget, dict)
else None
),
"stop_message_fa": (
(final_agent_budget or {}).get("stop_message_fa")
if isinstance(final_agent_budget, dict)
else None
),
},
)
)
except asyncio.CancelledError:
user_stop = bool(getattr(live, "cancel_requested", False))
if final_agent_run or live.run_id:
_persist_live_run(
dict(final_agent_run or {"run_id": live.run_id, "status": "interrupted"}),
interrupted=True,
)
_schedule_persist_on_disconnect()
if user_stop:
live.publish(
stream_error_payload(
code="RUN_CANCELLED",
run_id=live.run_id,
can_continue=bool(live.run_id),
)
)
raise
except Exception as e:
if final_agent_run or live.run_id:
_persist_live_run(
dict(final_agent_run or {"run_id": live.run_id, "status": "interrupted"}),
interrupted=True,
)
_schedule_persist_on_disconnect()
logger.error("Error in streaming response: %s", e, exc_info=True)
live.publish(
stream_error_payload(
e,
run_id=(final_agent_run or {}).get("run_id") or live.run_id,
can_continue=bool((final_agent_run or live.run_id)),
)
)
@router.post(
"/sessions/{session_id}/runs/{run_id}/continue",
summary="ادامهٔ اجرای ناقص agent",
)
async def continue_agent_run(
session_id: int = Path(...),
run_id: str = Path(..., min_length=8, max_length=16),
request: Request = None,
body: ChatContinueRunRequest = Body(default_factory=ChatContinueRunRequest),
stream: bool = Query(True, description="استفاده از streaming"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""ادامهٔ همان run پس از قطع اتصال یا سقف بودجه — بدون پیام کاربر جدید."""
from adapters.db.models.ai_agent_run import AIAgentRunStatus
from app.services.ai.ai_agent_run import build_resume_continue_prompt
from app.services.ai.ai_agent_run_store import get_owned_agent_run, run_to_checkpoint
from app.services.ai.ai_run_hub import agent_run_hub, is_running_row_fresh
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
row = get_owned_agent_run(
db,
run_id=run_id,
session_id=session_id,
user_id=ctx.get_user_id(),
)
if row is None:
raise ApiError("RUN_NOT_FOUND", "اجرای قابل ادامه یافت نشد", http_status=404)
header_last_id = None
if request is not None:
raw_header = request.headers.get("last-event-id") or request.headers.get(
"Last-Event-ID"
)
if raw_header:
try:
header_last_id = int(raw_header)
except ValueError:
header_last_id = None
last_event_id = body.last_event_id if body.last_event_id is not None else header_last_id
if agent_run_hub.is_live(run_id) or (
row.status == AIAgentRunStatus.RUNNING and is_running_row_fresh(row.updated_at)
):
db.close()
return StreamingResponse(
_iter_run_sse(
run_id=run_id,
last_event_id=last_event_id,
session_id=session_id,
user_id=ctx.get_user_id(),
),
media_type="text/event-stream",
headers=_sse_stream_headers(),
)
if row.status == AIAgentRunStatus.RUNNING:
row.status = AIAgentRunStatus.INTERRUPTED
db.commit()
db.refresh(row)
if row.status not in AIAgentRunStatus.RESUMABLE:
raise ApiError(
"RUN_NOT_RESUMABLE",
"این اجرا کامل شده یا قابل ادامه نیست",
http_status=409,
)
checkpoint = run_to_checkpoint(row)
message_repo = AIChatMessageRepository(db)
previous_messages = message_repo.get_session_messages(session_id, limit=50)
messages = build_llm_messages_from_history(previous_messages)
messages.append(
{"role": "user", "content": build_resume_continue_prompt(checkpoint)}
)
last_user_text = ""
for msg in reversed(previous_messages):
role = msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role)
if role == MessageRole.USER.value or role == "user":
last_user_text = msg.content or ""
break
message_content = last_user_text or "ادامه تحلیل"
execution_mode = _resolve_request_execution_mode(session, body.execution_mode)
exploration_mode = exploration_mode_for_execution(execution_mode)
session.execution_mode = execution_mode
approve_writes = bool(body.approve_writes)
approved_write_calls = (
_extract_pending_write_approvals(previous_messages) if approve_writes else []
)
_ensure_chat_availability(
db,
ctx,
session.business_id,
user_text=message_content,
model=body.model,
messages=messages,
)
business_id = session.business_id
db.commit()
db.close()
if not stream:
raise ApiError(
"STREAM_REQUIRED",
"ادامهٔ اجرا فقط به‌صورت استریم پشتیبانی می‌شود",
http_status=400,
)
return StreamingResponse(
_stream_message_response(
session_id=session_id,
messages=messages,
ctx=ctx,
business_id=business_id,
previous_messages=previous_messages,
message_content=message_content,
approve_writes=approve_writes,
approved_write_calls=approved_write_calls,
exploration_mode=exploration_mode,
execution_mode=execution_mode,
request_model=body.model,
resume_from_run=checkpoint,
last_event_id=last_event_id,
),
media_type="text/event-stream",
headers=_sse_stream_headers(),
)
@router.post(
"/sessions/{session_id}/runs/{run_id}/events",
summary="اشتراک مجدد به رویدادهای یک اجرای زنده",
)
async def subscribe_agent_run_events(
session_id: int = Path(...),
run_id: str = Path(..., min_length=8, max_length=16),
request: Request = None,
body: ChatContinueRunRequest = Body(default_factory=ChatContinueRunRequest),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""بدون اجرای دوبارهٔ ایجنت؛ اگر زنده نباشد وضعیت/ادامه را اعلام می‌کند."""
from app.services.ai.ai_agent_run_store import get_owned_agent_run
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
row = get_owned_agent_run(
db,
run_id=run_id,
session_id=session_id,
user_id=ctx.get_user_id(),
)
if row is None:
raise ApiError("RUN_NOT_FOUND", "اجرا یافت نشد", http_status=404)
header_last_id = None
if request is not None:
raw_header = request.headers.get("last-event-id") or request.headers.get(
"Last-Event-ID"
)
if raw_header:
try:
header_last_id = int(raw_header)
except ValueError:
header_last_id = None
last_event_id = body.last_event_id if body.last_event_id is not None else header_last_id
db.close()
return StreamingResponse(
_iter_run_sse(
run_id=run_id,
last_event_id=last_event_id,
session_id=session_id,
user_id=ctx.get_user_id(),
),
media_type="text/event-stream",
headers=_sse_stream_headers(),
)
@router.post(
"/sessions/{session_id}/runs/{run_id}/cancel",
summary="توقف صریح اجرای زنده",
)
async def cancel_agent_run(
session_id: int = Path(...),
run_id: str = Path(..., min_length=8, max_length=16),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
from adapters.db.models.ai_agent_run import AIAgentRunStatus
from app.services.ai.ai_agent_run_store import get_owned_agent_run, persist_agent_run_checkpoint
from app.services.ai.ai_run_hub import agent_run_hub
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
row = get_owned_agent_run(
db,
run_id=run_id,
session_id=session_id,
user_id=ctx.get_user_id(),
)
if row is None:
raise ApiError("RUN_NOT_FOUND", "اجرا یافت نشد", http_status=404)
cancelled = agent_run_hub.request_cancel(run_id)
if row.status == AIAgentRunStatus.RUNNING:
persist_agent_run_checkpoint(
run_id=run_id,
session_id=session_id,
user_id=ctx.get_user_id(),
business_id=session.business_id,
snapshot={"run_id": run_id, "status": AIAgentRunStatus.INTERRUPTED},
status=AIAgentRunStatus.INTERRUPTED,
stop_reason="cancel",
)
return success_response(
{"run_id": run_id, "cancelled": cancelled, "status": "interrupted"},
request,
)
@router.post("/sessions/{session_id}/regenerate", summary="تولید مجدد آخرین پاسخ AI")
async def regenerate_last_response(
session_id: int = Path(...),
request: Request = None,
stream: bool = Query(True, description="استفاده از streaming"),
approve_writes: bool = Query(False),
model: Optional[str] = Query(None, description="مدل AI"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""حذف آخرین پاسخ assistant و تولید مجدد بر اساس آخرین پیام کاربر."""
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
message_repo = AIChatMessageRepository(db)
all_messages = message_repo.get_session_messages(session_id, limit=200)
last_user_idx = -1
for i, msg in enumerate(all_messages):
role = msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role)
if role == MessageRole.USER.value or role == "user":
last_user_idx = i
if last_user_idx < 0:
raise ApiError("NO_USER_MESSAGE", "پیام کاربری برای تولید مجدد یافت نشد", http_status=400)
to_delete = all_messages[last_user_idx + 1 :]
for msg in to_delete:
db.delete(msg)
db.commit()
remaining = all_messages[: last_user_idx + 1]
last_user = remaining[-1]
messages = build_llm_messages_from_history(remaining)
business_id = session.business_id
approved_write_calls = _extract_pending_write_approvals(remaining) if approve_writes else []
execution_mode = _session_execution_mode(session)
exploration_mode = exploration_mode_for_execution(execution_mode)
_ensure_chat_availability(
db,
ctx,
business_id,
user_text=last_user.content or "",
model=model,
messages=messages,
)
if stream:
db.close()
return StreamingResponse(
_stream_message_response(
session_id=session_id,
messages=messages,
ctx=ctx,
business_id=business_id,
previous_messages=remaining,
message_content=last_user.content,
approve_writes=approve_writes,
approved_write_calls=approved_write_calls,
exploration_mode=exploration_mode,
execution_mode=execution_mode,
request_model=model,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
db.close()
from adapters.db.session import get_db_session
with get_db_session() as new_db:
new_ai_service = AIService(new_db, ctx, business_id)
_prepare_ai_service_model(new_ai_service, model)
response = await new_ai_service.chat_completion(
messages,
use_function_calling=True,
session_business_id=business_id,
session_id=session_id,
approve_writes=approve_writes,
approved_write_calls=approved_write_calls,
user_query=last_user.content,
request_model=model,
execution_mode=execution_mode,
)
usage = response.get("usage", {})
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
with get_db_session() as commit_db:
commit_session = AIChatSessionRepository(commit_db).get_by_id(session_id)
if not commit_session:
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
commit_ai_service = AIService(commit_db, ctx, business_id)
_prepare_ai_service_model(commit_ai_service, model)
_apply_chat_routing_context(
commit_ai_service,
user_query=last_user.content,
messages=messages,
)
charge_result = commit_ai_service.check_quota_and_charge(
input_tokens, output_tokens, model_code=commit_ai_service.get_effective_model_code()
)
persist_content, persist_calls, persist_results = _prepare_assistant_persist_fields(
response["message"]["content"] or "",
response.get("_function_calls"),
response.get("_function_results"),
user_query=last_user.content,
history_messages=messages,
)
fc_json, fr_json = serialize_function_metadata(persist_calls, persist_results)
assistant_message = AIChatMessage(
session_id=session_id,
role=MessageRole.ASSISTANT.value,
content=persist_content,
function_calls=fc_json,
function_results=fr_json,
tokens_used=input_tokens + output_tokens,
)
commit_db.add(assistant_message)
provider_name, model_code = _usage_provider_and_model(commit_ai_service)
commit_ai_service.log_usage(
provider=provider_name,
model=model_code,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=charge_result.get("cost", 0),
payment_method=charge_result.get("payment_method", "free"),
wallet_transaction_id=charge_result.get("wallet_transaction_id"),
document_id=charge_result.get("document_id"),
context=_usage_log_context(commit_ai_service, usage),
)
commit_ai_service.clear_routing_context()
from datetime import datetime
commit_session.updated_at = datetime.utcnow()
commit_db.commit()
commit_db.refresh(assistant_message)
return success_response(
{
"message": {
"id": assistant_message.id,
"content": response["message"]["content"],
"tokens_used": input_tokens + output_tokens,
},
"usage": usage,
},
request,
)
def _prepare_edit_or_regenerate_context(
db: Session,
session_id: int,
ctx: AuthContext,
*,
message_id: Optional[int] = None,
new_content: Optional[str] = None,
regenerate_after: bool = True,
) -> tuple[AIChatSession, List[AIChatMessage], List[Dict[str, Any]], str, bool]:
"""آماده‌سازی تاریخچه پس از ویرایش پیام؛ خروجی آخر: آیا باید پاسخ جدید استریم شود."""
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
message_repo = AIChatMessageRepository(db)
all_messages = message_repo.get_session_messages(session_id, limit=500)
if message_id is not None:
target_idx = -1
for i, msg in enumerate(all_messages):
if msg.id == message_id:
target_idx = i
break
if target_idx < 0:
raise ApiError("MESSAGE_NOT_FOUND", "پیام یافت نشد", http_status=404)
target = all_messages[target_idx]
role = target.role if isinstance(target.role, str) else getattr(target.role, "value", target.role)
is_user = role in (MessageRole.USER.value, "user")
is_assistant = role in (MessageRole.ASSISTANT.value, "assistant")
if not is_user and not is_assistant:
raise ApiError("INVALID_MESSAGE", "نقش پیام پشتیبانی نمی‌شود", http_status=400)
if new_content is not None:
target.content = new_content.strip()
if not target.content:
raise ApiError("EMPTY_MESSAGE", "متن پیام نمی‌تواند خالی باشد", http_status=400)
if is_assistant and not regenerate_after:
db.commit()
llm_messages = build_llm_messages_from_history(all_messages[: target_idx + 1])
return session, all_messages[: target_idx + 1], llm_messages, target.content, False
if is_assistant:
for msg in all_messages[target_idx:]:
db.delete(msg)
db.commit()
remaining = all_messages[:target_idx]
else:
for msg in all_messages[target_idx + 1 :]:
db.delete(msg)
db.commit()
remaining = all_messages[: target_idx + 1]
user_text = ""
for msg in reversed(remaining):
r = msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role)
if r in (MessageRole.USER.value, "user"):
user_text = msg.content or ""
break
if not user_text:
raise ApiError("NO_USER_MESSAGE", "پیام کاربری برای ادامه یافت نشد", http_status=400)
else:
last_user_idx = -1
for i, msg in enumerate(all_messages):
role = msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role)
if role == MessageRole.USER.value or role == "user":
last_user_idx = i
if last_user_idx < 0:
raise ApiError("NO_USER_MESSAGE", "پیام کاربری یافت نشد", http_status=400)
for msg in all_messages[last_user_idx + 1 :]:
db.delete(msg)
db.commit()
remaining = all_messages[: last_user_idx + 1]
user_text = remaining[-1].content
llm_messages = build_llm_messages_from_history(remaining)
return session, remaining, llm_messages, user_text, True
@router.post("/sessions/{session_id}/messages/{message_id}/edit", summary="ویرایش پیام و تولید پاسخ جدید")
async def edit_user_message(
session_id: int = Path(...),
message_id: int = Path(...),
request: Request = None,
stream: bool = Query(True),
params: ChatEditMessageRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
session, remaining, messages, user_text, should_stream = _prepare_edit_or_regenerate_context(
db,
session_id,
ctx,
message_id=message_id,
new_content=params.content,
regenerate_after=params.regenerate_after,
)
business_id = session.business_id
approved_write_calls = (
_extract_pending_write_approvals(remaining)
if params.approve_writes
else []
)
execution_mode = _resolve_request_execution_mode(session, params.execution_mode)
exploration_mode = _resolve_exploration_mode(None, params.mode, execution_mode)
session.execution_mode = execution_mode
db.commit()
if not should_stream:
return success_response(
{
"message": {
"id": message_id,
"content": params.content,
},
"regenerated": False,
},
request,
"پیام به‌روزرسانی شد",
)
_ensure_chat_availability(
db,
ctx,
business_id,
user_text=user_text or "",
model=params.model,
messages=messages,
)
if stream:
db.close()
return StreamingResponse(
_stream_message_response(
session_id=session_id,
messages=messages,
ctx=ctx,
business_id=business_id,
previous_messages=remaining,
message_content=user_text,
approve_writes=params.approve_writes,
approved_write_calls=approved_write_calls,
exploration_mode=exploration_mode,
execution_mode=execution_mode,
request_model=params.model,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
db.close()
from adapters.db.session import get_db_session
with get_db_session() as new_db:
new_ai_service = AIService(new_db, ctx, business_id)
_prepare_ai_service_model(new_ai_service, params.model)
response = await new_ai_service.chat_completion(
messages,
use_function_calling=True,
session_business_id=business_id,
session_id=session_id,
approve_writes=params.approve_writes,
approved_write_calls=approved_write_calls,
user_query=user_text,
request_model=params.model,
execution_mode=execution_mode,
)
usage = response.get("usage", {})
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
with get_db_session() as commit_db:
commit_session = AIChatSessionRepository(commit_db).get_by_id(session_id)
if not commit_session:
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
commit_ai_service = AIService(commit_db, ctx, business_id)
_prepare_ai_service_model(commit_ai_service, params.model)
_apply_chat_routing_context(
commit_ai_service,
user_query=user_text,
messages=messages,
)
charge_result = commit_ai_service.check_quota_and_charge(
input_tokens, output_tokens, model_code=commit_ai_service.get_effective_model_code()
)
persist_content, persist_calls, persist_results = _prepare_assistant_persist_fields(
response["message"]["content"] or "",
response.get("_function_calls"),
response.get("_function_results"),
user_query=user_text,
history_messages=messages,
)
fc_json, fr_json = serialize_function_metadata(persist_calls, persist_results)
assistant_message = AIChatMessage(
session_id=session_id,
role=MessageRole.ASSISTANT.value,
content=persist_content,
function_calls=fc_json,
function_results=fr_json,
tokens_used=input_tokens + output_tokens,
)
commit_db.add(assistant_message)
provider_name, model_code = _usage_provider_and_model(commit_ai_service)
commit_ai_service.log_usage(
provider=provider_name,
model=model_code,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=charge_result.get("cost", 0),
payment_method=charge_result.get("payment_method", "free"),
wallet_transaction_id=charge_result.get("wallet_transaction_id"),
document_id=charge_result.get("document_id"),
context=_usage_log_context(commit_ai_service, usage),
)
commit_ai_service.clear_routing_context()
from datetime import datetime
commit_session.updated_at = datetime.utcnow()
commit_db.commit()
commit_db.refresh(assistant_message)
return success_response(
{
"message": {
"id": assistant_message.id,
"content": response["message"]["content"],
"tokens_used": input_tokens + output_tokens,
},
"usage": usage,
},
request,
)
@router.get("/sessions/{session_id}/subagents", summary="لیست زیر-ایجنت‌های جلسه")
async def list_chat_subagents(
session_id: int = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_subagent import list_session_subagents
return success_response({"items": list_session_subagents(session_id)}, request)
@router.post(
"/sessions/{session_id}/subagents/{subagent_id}/cancel",
summary="قطع زیر-ایجنت",
)
async def cancel_chat_subagent(
session_id: int = Path(...),
subagent_id: str = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_subagent import cancel_subagent_async
result = await cancel_subagent_async(subagent_id, session_id=session_id)
error = result.get("error")
if error == "SUBAGENT_NOT_FOUND":
raise ApiError("SUBAGENT_NOT_FOUND", "زیر-ایجنت یافت نشد", http_status=404)
if error == "SUBAGENT_SESSION_MISMATCH":
raise ApiError("SUBAGENT_SESSION_MISMATCH", "زیر-ایجنت متعلق به این گفت‌وگو نیست", http_status=403)
return success_response(result, request)
@router.post("/sessions/{session_id}/fork", summary="شاخه‌سازی گفت‌وگو")
async def fork_chat_session(
session_id: int = Path(...),
request: Request = None,
up_to_message_id: Optional[int] = Query(None, description="کپی تا این پیام (شامل)"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
message_repo = AIChatMessageRepository(db)
all_messages = message_repo.get_session_messages(session_id, limit=500)
to_copy = all_messages
if up_to_message_id is not None:
cut = -1
for i, msg in enumerate(all_messages):
if msg.id == up_to_message_id:
cut = i
break
if cut < 0:
raise ApiError("MESSAGE_NOT_FOUND", "پیام یافت نشد", http_status=404)
to_copy = all_messages[: cut + 1]
from datetime import datetime
base_title = (session.title or DEFAULT_CHAT_TITLE)[:200]
fork_title = f"{base_title} (شاخه)"[:255]
new_session = AIChatSession(
user_id=session.user_id,
business_id=session.business_id,
title=fork_title,
execution_mode=_session_execution_mode(session),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(new_session)
db.flush()
for msg in to_copy:
role = msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role)
clone = AIChatMessage(
session_id=new_session.id,
role=role,
content=msg.content,
function_calls=msg.function_calls,
function_results=msg.function_results,
tokens_used=msg.tokens_used or 0,
created_at=msg.created_at or datetime.utcnow(),
)
db.add(clone)
db.commit()
db.refresh(new_session)
return success_response(
{
"session": {
"id": new_session.id,
"title": new_session.title,
"business_id": new_session.business_id,
"execution_mode": _session_execution_mode(new_session),
"created_at": new_session.created_at.isoformat() if new_session.created_at else None,
},
"message_count": len(to_copy),
},
request,
"گفت‌وگوی شاخه ایجاد شد",
)
@router.get("/sessions/{session_id}/export", summary="خروجی Markdown گفت‌وگو")
async def export_chat_session(
session_id: int = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
message_repo = AIChatMessageRepository(db)
messages = message_repo.get_session_messages(session_id, limit=500)
lines = [f"# {session.title or DEFAULT_CHAT_TITLE}", ""]
for msg in messages:
role = msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role)
label = "کاربر" if role in (MessageRole.USER.value, "user") else "دستیار"
ts = msg.created_at.strftime("%Y-%m-%d %H:%M") if msg.created_at else ""
header = f"## {label}"
if ts:
header += f" — {ts}"
lines.extend([header, "", msg.content or "", ""])
markdown = "\n".join(lines).strip()
return success_response(
{"title": session.title, "markdown": markdown, "message_count": len(messages)},
request,
)
@router.delete("/sessions/{session_id}", summary="حذف گفت‌وگو")
async def delete_chat_session(
session_id: int = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""حذف گفت‌وگو"""
session_repo = AIChatSessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.user_id != ctx.get_user_id():
raise ApiError("SESSION_NOT_FOUND", "گفت‌وگو یافت نشد", http_status=404)
from app.services.ai.ai_memory_hooks import schedule_memory_update_on_session_delete
schedule_memory_update_on_session_delete(session.business_id, ctx, session_id)
db.delete(session)
db.commit()
return success_response({"id": session_id}, request, "گفت‌وگو با موفقیت حذف شد")
# ---- Scheduled Tasks ----
@router.get("/scheduled-tasks", summary="لیست task های زمان‌بندی‌شده")
async def list_scheduled_tasks(
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""لیست task های پیش‌فرض زمان‌بندی‌شده."""
from app.services.ai.ai_scheduled_task_service import get_built_in_tasks
tasks = get_built_in_tasks(db)
return success_response({"tasks": tasks}, request)
@router.post("/scheduled-tasks/{task_id}/run", summary="اجرای فوری یک scheduled task")
async def run_scheduled_task_now(
task_id: str = Path(...),
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""اجرای فوری یک scheduled task برای کسب‌وکار فعلی."""
from app.services.ai.ai_scheduled_task_service import get_task_by_id, run_scheduled_task
task = get_task_by_id(task_id, db)
if not task:
raise ApiError("TASK_NOT_FOUND", f"task '{task_id}' یافت نشد", http_status=404)
business_id = ctx.business_id
if not business_id:
raise ApiError("BUSINESS_REQUIRED", "کسب‌وکار مشخص نشده", http_status=400)
ai_service = AIService(db, ctx, business_id)
ai_service.ensure_availability_or_raise(
estimated_tokens=2000,
user_query=(task.get("prompt") or task_id)[:500],
)
result = await run_scheduled_task(db, task, business_id, ai_service)
return success_response(result, request)
@router.get("/cache-stats", summary="آمار کش ابزار (debug)")
async def get_tool_cache_stats(
request: Request = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
"""آمار کش نتایج tool — فقط برای ادمین."""
if not (ctx.is_superadmin() or ctx.can_access_support_operator()):
raise ApiError("FORBIDDEN", "دسترسی محدود", http_status=403)
from app.services.ai.ai_tool_cache import cache_stats
return success_response(cache_stats(), request)