This commit is contained in:
2026-04-24 01:23:56 +03:30
parent 5acee1fa2c
commit 31f4bf5d38
16 changed files with 518 additions and 192 deletions
+173 -106
View File
@@ -1,13 +1,12 @@
"""
چت RAG با استریم — استفاده از دیتای embed شده کاربر و Adapter API (GapGPT / Avalai)
چت RAG برای API چت عمومی — استفاده مستقیم از داده مزرعه بدون retrieval/embedding.
"""
import json
import logging
from pathlib import Path
from .config import load_rag_config, RAGConfig, get_service_config, ServiceConfig
from .api_provider import get_chat_client
from .retrieve import search_with_query
from .user_data import build_user_soil_text, build_user_weather_text
from .config import RAGConfig, ServiceConfig, get_service_config, load_rag_config
logger = logging.getLogger(__name__)
@@ -19,30 +18,12 @@ def _load_tone(config: RAGConfig | None) -> str:
chat_kb = cfg.knowledge_bases.get("chat")
if chat_kb:
tone_path = base / chat_kb.tone_file
logger.debug("Loading default tone from path=%s", tone_path)
if tone_path.exists():
logger.debug("Default tone file found: %s", tone_path)
return tone_path.read_text(encoding="utf-8").strip()
logger.warning("Default tone file not found: %s", tone_path)
return ""
def _load_kb_tone(kb_name: str, config: RAGConfig | None = None) -> str:
"""بارگذاری فایل لحن مخصوص یک پایگاه دانش."""
cfg = config or load_rag_config()
kb_cfg = cfg.knowledge_bases.get(kb_name)
if not kb_cfg:
return ""
base = Path(__file__).resolve().parent.parent
tone_path = base / kb_cfg.tone_file
logger.debug("Loading kb tone for kb=%s path=%s", kb_name, tone_path)
if tone_path.exists():
logger.debug("KB tone file found for kb=%s", kb_name)
return tone_path.read_text(encoding="utf-8").strip()
logger.warning("KB tone file not found for kb=%s path=%s", kb_name, tone_path)
return ""
def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None) -> str:
cfg = config or load_rag_config()
if service.tone_file:
@@ -50,21 +31,84 @@ def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None)
tone_path = base / service.tone_file
if tone_path.exists():
return tone_path.read_text(encoding="utf-8").strip()
return _load_kb_tone(service.knowledge_base, cfg)
logger.warning("Service tone file not found: %s", tone_path)
return _load_tone(cfg)
def _detect_kb_intent(query: str) -> str:
"""تشخیص ساده نوع پایگاه دانش مورد نیاز از روی متن سوال."""
q = query.lower()
irrigation_keywords = {"آبیاری", "آب", "رطوبت", "irrigation", "water", "et0", "بارش", "خشکی"}
fertilization_keywords = {"کود", "کودهی", "fertiliz", "npk", "ازت", "فسفر", "پتاسیم", "nitrogen", "phosphorus", "potassium"}
if any(kw in q for kw in irrigation_keywords):
return "irrigation"
if any(kw in q for kw in fertilization_keywords):
logger.info("Detected KB intent=fertilization")
return "fertilization"
logger.info("Detected KB intent=chat")
return "chat"
def _format_farm_context(farm_uuid: str) -> str:
from farm_data.services import get_farm_details
farm_details = get_farm_details(farm_uuid)
if not farm_details:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
serialized = json.dumps(
farm_details,
ensure_ascii=False,
indent=2,
default=str,
)
return "[اطلاعات کامل مزرعه]\n" + serialized
def _format_farm_context_from_details(farm_details: dict) -> str:
serialized = json.dumps(
farm_details,
ensure_ascii=False,
indent=2,
default=str,
)
return "[اطلاعات کامل مزرعه]\n" + serialized
def _build_system_prompt(
service: ServiceConfig,
query: str,
farm_context: str,
config: RAGConfig | None = None,
) -> str:
tone = _load_service_tone(service, config)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(
"با استفاده از اطلاعات کامل مزرعه که در ادامه آمده به سوال کاربر پاسخ بده. "
"اگر داده‌ای در اطلاعات مزرعه وجود دارد، همان را مبنای پاسخ قرار بده و چیزی حدس نزن. "
"اگر داده کافی نبود، این کمبود را شفاف بگو. "
"پاسخ را به زبان کاربر بنویس."
)
system_parts.append(farm_context)
system_parts.append(f"[سوال کاربر]\n{query}")
return "\n\n".join(part for part in system_parts if part)
def _create_audit_log(
farm_uuid: str,
service_id: str,
model: str,
query: str,
system_prompt: str,
messages: list[dict],
) -> "ChatAuditLog":
from .models import ChatAuditLog
log = ChatAuditLog.objects.create(
farm_uuid=farm_uuid,
service_id=service_id,
model=model,
user_query=query,
system_prompt=system_prompt,
messages=messages,
status=ChatAuditLog.STATUS_STARTED,
)
logger.info(
"Created chat audit log id=%s service_id=%s farm_uuid=%s model=%s",
log.id,
service_id,
farm_uuid,
model,
)
return log
def build_rag_context(
@@ -76,9 +120,12 @@ def build_rag_context(
service_id: str | None = None,
) -> str:
"""
ساخت context برای LLM: دیتای فعلی خاک کاربر + متن‌های مرتبط از RAG.
دیتای کاربر همیشه اول می‌آید تا LLM مقادیر واقعی (مثل pH) را ببیند.
ساخت context برای سرویس‌های توصیه با استفاده از RAG قدیمی.
این تابع برای سازگاری با irrigation/fertilization حفظ شده است.
"""
from .retrieve import search_with_query
from .user_data import build_user_soil_text, build_user_weather_text
logger.info(
"Building RAG context sensor_uuid=%s kb_name=%s limit=%s query_len=%s",
sensor_uuid,
@@ -96,16 +143,10 @@ def build_rag_context(
user_soil = build_user_soil_text(sensor_uuid)
if user_soil and user_soil.strip():
parts.append("[داده‌های فعلی خاک شما]\n" + user_soil.strip())
logger.debug("Included user soil section sensor_uuid=%s", sensor_uuid)
else:
logger.info("No user soil data found sensor_uuid=%s", sensor_uuid)
weather_text = build_user_weather_text(sensor_uuid)
if weather_text and weather_text.strip():
parts.append("[پیش‌بینی هواشناسی]\n" + weather_text.strip())
logger.debug("Included weather section sensor_uuid=%s", sensor_uuid)
else:
logger.info("No weather data found sensor_uuid=%s", sensor_uuid)
results = search_with_query(
query,
@@ -117,50 +158,35 @@ def build_rag_context(
use_user_embeddings=include_user_embeddings,
)
if results:
logger.info("Retrieved RAG results count=%s sensor_uuid=%s", len(results), sensor_uuid)
rag_texts = [r.get("text", "").strip() for r in results if r.get("text")]
if rag_texts:
parts.append("[متن‌های مرجع]\n" + "\n\n---\n\n".join(rag_texts))
logger.debug("Included RAG reference texts count=%s", len(rag_texts))
else:
logger.info("No RAG results found sensor_uuid=%s kb_name=%s", sensor_uuid, kb_name)
return "\n\n---\n\n".join(parts) if parts else ""
def chat_rag_stream(
query: str,
sensor_uuid: str | None = None,
farm_uuid: str,
config: RAGConfig | None = None,
limit: int = 5,
system_override: str | None = None,
kb_name: str | None = None,
service_id: str | None = None,
farm_details: dict | None = None,
):
logger.info(
"chat_rag_stream started sensor_uuid=%s kb_name=%s limit=%s query_len=%s",
sensor_uuid,
kb_name,
limit,
len(query or ""),
)
"""
چت RAG با استریم: دیتای embed شده را بازیابی می‌کند و با LLM جواب می‌دهد.
فقط دیتای همان کاربر (sensor_uuid) قابل دسترسی است.
چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه.
Args:
query: پیام کاربر
sensor_uuid: شناسه سنسور کاربر — اجباری
farm_uuid: شناسه مزرعه
config: تنظیمات RAG
limit: تعداد چانک‌های بازیابی‌شده
system_override: جایگزین system prompt (اختیاری)
Yields:
تک‌تک deltaهای content به‌صورت رشته
chunk های استریم پاسخ مدل
"""
cfg = config or load_rag_config()
resolved_service_id = service_id or kb_name or _detect_kb_intent(query)
service = get_service_config(resolved_service_id, cfg)
service_id = "chat"
service = get_service_config(service_id, cfg)
service_llm_config = service.llm
service_cfg = RAGConfig(
embedding=cfg.embedding,
@@ -173,56 +199,97 @@ def chat_rag_stream(
)
client = get_chat_client(service_cfg)
model = service_llm_config.model
logger.debug("Loaded service config service_id=%s model=%s", resolved_service_id, model)
detected_kb = kb_name or service.knowledge_base
logger.info("Using knowledge base=%s for service_id=%s", detected_kb, resolved_service_id)
context = build_rag_context(
query,
sensor_uuid,
config=cfg,
limit=limit,
kb_name=detected_kb,
service_id=resolved_service_id,
logger.info(
"chat_rag_stream started service_id=%s farm_uuid=%s query_len=%s",
service_id,
farm_uuid,
len(query or ""),
)
if farm_details is None:
farm_context = _format_farm_context(farm_uuid)
else:
farm_context = _format_farm_context_from_details(farm_details)
logger.info(
"Loaded farm context for farm_uuid=%s context_len=%s",
farm_uuid,
len(farm_context),
)
logger.debug("Built context length=%s", len(context))
if system_override is not None:
system_content = system_override
system_prompt = system_override
else:
tone = _load_service_tone(service, cfg)
if not tone:
tone = _load_tone(cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(
"با استفاده از بخش «داده‌های فعلی خاک شما» و «متن‌های مرجع» زیر به سوال کاربر پاسخ بده. "
"برای سوالاتی درباره خاک کاربر (مثل pH، رطوبت، NPK) حتماً از داده‌های فعلی استفاده کن. "
"اطلاعات هواشناسی در بخش «پیش‌بینی هواشناسی» آمده. "
"پاسخ را به زبان کاربر بنویس."
)
if context:
system_parts.append("\n\n" + context)
system_content = "\n".join(system_parts)
system_prompt = _build_system_prompt(service, query, farm_context, cfg)
messages = [
{"role": "system", "content": system_content},
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
logger.info("Prepared messages for model=%s service_id=%s", model, resolved_service_id)
stream = client.chat.completions.create(
model=model,
messages=messages,
stream=True,
logger.info(
"Final prompt prepared service_id=%s farm_uuid=%s model=%s messages_count=%s",
service_id,
farm_uuid,
model,
len(messages),
)
logger.info("Started streaming response from model=%s", model)
logger.info("Final system prompt for farm_uuid=%s:\n%s", farm_uuid, system_prompt)
for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
content = delta.content if delta else ""
if content:
logger.debug("Streaming chunk len=%s", len(content))
yield content
logger.info("chat_rag_stream completed sensor_uuid=%s", sensor_uuid)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=service_id,
model=model,
query=query,
system_prompt=system_prompt,
messages=messages,
)
response_chunks: list[str] = []
try:
stream = client.chat.completions.create(
model=model,
messages=messages,
stream=True,
)
logger.info(
"Started streaming response id=%s service_id=%s farm_uuid=%s",
audit_log.id,
service_id,
farm_uuid,
)
for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
content = delta.content if delta else ""
if content:
response_chunks.append(content)
yield content
full_response = "".join(response_chunks)
audit_log.response_text = full_response
audit_log.status = ChatAuditLog.STATUS_COMPLETED
audit_log.save(update_fields=["response_text", "status", "updated_at"])
logger.info(
"Completed chat response id=%s farm_uuid=%s response_len=%s response=\n%s",
audit_log.id,
farm_uuid,
len(full_response),
full_response,
)
except Exception as exc:
partial_response = "".join(response_chunks)
audit_log.response_text = partial_response
audit_log.error_message = str(exc)
audit_log.status = ChatAuditLog.STATUS_FAILED
audit_log.save(
update_fields=["response_text", "error_message", "status", "updated_at"]
)
logger.exception(
"Chat request failed id=%s service_id=%s farm_uuid=%s partial_response_len=%s",
audit_log.id,
service_id,
farm_uuid,
len(partial_response),
)
raise