""" REST API layer for LLM inference — farmer chat assistant. Exposes HuggingFace Inference API through standardized endpoints. Conversation state management is handled by the client/external service. Why this exists: The FarmerAssistant class only wraps HTTP calls to HuggingFace. This module translates that into REST endpoints so external clients (web UIs, mobile apps, third-party services) can interact with the LLM without importing Python modules. The endpoints here are stateless — conversation history, user sessions, and context persistence are the caller's responsibility. """ from __future__ import annotations import time from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from llm_chat import FarmerAssistant # ────────────────────────────────────────────────────────────────────────────── # Request/Response Models # ────────────────────────────────────────────────────────────────────────────── class ChatRequest(BaseModel): """Single LLM inference request. The caller provides the user's message. Conversation history and context are optional — the client maintains state. """ content: str = Field( ..., min_length=1, max_length=2000, description="User's question or statement for the farmer assistant" ) conversation_history: Optional[List[Dict[str, str]]] = Field( default=None, description=( "Previous messages in [{'role': 'user', 'content': '...'}, " "{'role': 'assistant', 'content': '...'}] format. " "Used to ground the response in prior context." ) ) farm_context: Optional[Dict[str, Any]] = Field( default=None, description=( "Optional farm metadata to include in the system context. " "Example: {'farm_name': 'Johnson Farm', 'crop': 'tomato', 'area_ha': 2.5}" ) ) max_tokens: int = Field( default=256, ge=10, le=1024, description="Maximum tokens in the response" ) temperature: float = Field( default=0.7, ge=0.0, le=2.0, description="Creativity level (0=deterministic, 1=creative, 2=very creative)" ) class ChatResponse(BaseModel): """LLM inference response.""" content: str = Field(..., description="Assistant's response") timestamp: str = Field(..., description="ISO8601 timestamp when response was generated") model: str = Field(..., description="Model ID used (e.g., 'mistral-7b-instruct')") tokens_used: Optional[int] = Field( default=None, description="Approximate tokens consumed by request (input + output)" ) latency_ms: float = Field(..., description="Response time in milliseconds") metadata: Optional[Dict[str, Any]] = Field( default=None, description="Additional metadata (e.g., warning flags, model-specific info)" ) class HealthResponse(BaseModel): """LLM backend health status.""" status: str = Field(..., description="'ok' or 'unavailable'") model: str = Field(..., description="Active model ID") latency_ms: Optional[float] = Field( default=None, description="Milliseconds to reach the LLM backend" ) message: Optional[str] = Field( default=None, description="Human-readable status message" ) class ContextValidationRequest(BaseModel): """Validate farm context before using in chat.""" farm_context: Dict[str, Any] = Field( ..., description="Farm metadata to validate (e.g., design_summary, crop, area)" ) class ContextValidationResponse(BaseModel): """Result of context validation.""" valid: bool = Field(..., description="Whether the context is valid") warnings: List[str] = Field( default_factory=list, description="Non-fatal issues (e.g., missing recommended fields)" ) errors: List[str] = Field( default_factory=list, description="Fatal issues that should be resolved before use" ) # ────────────────────────────────────────────────────────────────────────────── # Helper: Lazy-load FarmerAssistant singleton # ────────────────────────────────────────────────────────────────────────────── _ASSISTANT = None def get_assistant() -> FarmerAssistant: """Lazy-load the FarmerAssistant on first use. Raises HTTPException(503) if the token is not configured. """ global _ASSISTANT if _ASSISTANT is None: try: _ASSISTANT = FarmerAssistant() except ValueError as e: raise HTTPException( status_code=503, detail=f"LLM backend unavailable: {str(e)}", ) return _ASSISTANT # ────────────────────────────────────────────────────────────────────────────── # Context Validation # ────────────────────────────────────────────────────────────────────────────── def _validate_farm_context(context: Dict[str, Any]) -> tuple[bool, List[str], List[str]]: """ Validate farm context for use in chat. Returns: (is_valid, warnings, errors) """ warnings = [] errors = [] # Optional but recommended fields recommended = ["farm_name", "crop", "area_ha"] present = set(context.keys()) missing_recommended = [f for f in recommended if f not in present] if missing_recommended: warnings.append(f"Missing recommended fields: {', '.join(missing_recommended)}") # Validate field types if present if "area_ha" in context: try: float(context["area_ha"]) except (TypeError, ValueError): errors.append("'area_ha' must be a number") if "crop" in context: valid_crops = ["tomato", "pepper", "lettuce", "cucumber", "orchard", "generic"] if str(context["crop"]).lower() not in valid_crops: warnings.append( f"Unknown crop '{context['crop']}'. " f"Expected one of: {', '.join(valid_crops)}" ) # design_summary can be complex; just check it exists if referenced if "design_summary" in context and not isinstance(context["design_summary"], dict): warnings.append("'design_summary' should be a dict") is_valid = len(errors) == 0 return is_valid, warnings, errors # ────────────────────────────────────────────────────────────────────────────── # Router # ────────────────────────────────────────────────────────────────────────────── def build_router() -> APIRouter: """Build the LLM inference API router.""" router = APIRouter(prefix="/api/v1/llm", tags=["chat"]) @router.get("/health") def health() -> HealthResponse: """ Check if the LLM backend is accessible. Returns latency to help clients decide whether to retry or fallback. """ try: assistant = get_assistant() start = time.perf_counter() # Simple test: check that we can reach the API # (Without actually sending tokens to the model) _ = assistant.api_token is not None elapsed_ms = (time.perf_counter() - start) * 1000 return HealthResponse( status="ok", model=assistant.model_id, latency_ms=elapsed_ms, message="LLM backend is reachable", ) except HTTPException as e: # Token not configured raise e except Exception as e: raise HTTPException( status_code=503, detail=f"LLM health check failed: {str(e)}", ) @router.post("/chat") def chat(req: ChatRequest) -> ChatResponse: """ Send a message to the farmer assistant and get a response. The caller is responsible for maintaining conversation history. Pass prior messages in `conversation_history` if you want context. Optional `farm_context` grounds the response in your farm data (e.g., crop, area, design summary). """ try: assistant = get_assistant() except HTTPException: raise # Enrich the message with farm context if provided farm_context_str = "" if req.farm_context: # Validate context first is_valid, warnings, errors = _validate_farm_context(req.farm_context) if errors: raise HTTPException( status_code=422, detail={ "code": "invalid_farm_context", "message": "Farm context validation failed", "errors": errors, }, ) # Build a brief context string to prepend to the user message. farm_name = req.farm_context.get("farm_name", "Your farm") crop = req.farm_context.get("crop", "unknown crop") area = req.farm_context.get("area_ha", "unknown area") farm_context_str = f"Farm context: {farm_name}, growing {crop}, {area} ha. " # Send to LLM start = time.perf_counter() try: response_text = assistant.chat( user_message=f"{farm_context_str}{req.content}", conversation_history=req.conversation_history, max_tokens=req.max_tokens, temperature=req.temperature, ) except Exception as e: raise HTTPException( status_code=500, detail=f"LLM inference failed: {str(e)}", ) elapsed_ms = (time.perf_counter() - start) * 1000 # Rough token estimation (1 token ≈ 4 chars) estimated_tokens = ( len(req.content) + len(response_text) + len(farm_context_str) ) // 4 from datetime import datetime, timezone timestamp = datetime.now(timezone.utc).isoformat() return ChatResponse( content=response_text, timestamp=timestamp, model=assistant.model_id, tokens_used=estimated_tokens, latency_ms=elapsed_ms, metadata={ "farm_context_provided": req.farm_context is not None, "conversation_history_length": len(req.conversation_history or []), }, ) @router.post("/validate-context") def validate_context(req: ContextValidationRequest) -> ContextValidationResponse: """ Validate farm context before using it in chat requests. Helps clients catch issues early without spending LLM tokens. """ is_valid, warnings, errors = _validate_farm_context(req.farm_context) return ContextValidationResponse( valid=is_valid, warnings=warnings, errors=errors, ) @router.post("/chat/batch") def chat_batch(requests: List[ChatRequest]) -> List[ChatResponse]: """ Send multiple messages in a batch (useful for bulk analysis). Note: Each request is independent — no conversation history carried between items. For multi-turn conversations, use POST /api/v1/llm/chat with explicit history in each call. Rate-limited: max 10 requests per batch. """ if len(requests) > 10: raise HTTPException( status_code=422, detail="Batch size limited to 10 requests", ) try: assistant = get_assistant() except HTTPException: raise responses = [] for req in requests: try: start = time.perf_counter() response_text = assistant.chat( user_message=req.content, conversation_history=req.conversation_history, max_tokens=req.max_tokens, temperature=req.temperature, ) elapsed_ms = (time.perf_counter() - start) * 1000 estimated_tokens = (len(req.content) + len(response_text)) // 4 from datetime import datetime, timezone timestamp = datetime.now(timezone.utc).isoformat() responses.append(ChatResponse( content=response_text, timestamp=timestamp, model=assistant.model_id, tokens_used=estimated_tokens, latency_ms=elapsed_ms, metadata={ "farm_context_provided": req.farm_context is not None, }, )) except Exception as e: # Return error in same list position responses.append(ChatResponse( content=f"Error: {str(e)}", timestamp="", model="", latency_ms=0, metadata={"error": True}, )) return responses return router