Spaces:
Running
Running
Commit ·
0445ac9
1
Parent(s): 2c6e6b0
Add LLM inference API endpoints for farmer chat assistant
Browse files- Expose stateless LLM inference via /api/v1/llm/ endpoints
- Chat endpoint with optional farm context grounding
- Context validation endpoint to fail fast on invalid metadata
- Batch chat endpoint for bulk analysis (max 10 requests)
- Health check endpoint for monitoring backend status
- Comprehensive test suite and API documentation
Endpoints:
- GET /api/v1/llm/health - Check LLM backend status
- POST /api/v1/llm/chat - Single message inference
- POST /api/v1/llm/validate-context - Validate farm context
- POST /api/v1/llm/chat/batch - Bulk requests (max 10)
Design: Stateless API layer. Conversation history and session
management are handled by external service (web UI, mobile app).
Co-Authored-By: Oz <oz-agent@warp.dev>
- LLM_API_DOCS.md +1 -0
- app.py +2 -0
- llm_api.py +384 -0
- test_llm_api.py +244 -0
LLM_API_DOCS.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Farm GPT LLM Inference API\n\nREST API for the farmer chat assistant. Stateless endpoints for LLM inference powered by HuggingFace.\n\n## Overview\n\nThe LLM API is exposed at `/api/v1/llm/` and provides stateless inference endpoints. Your external service (web UI, mobile app, etc.) is responsible for maintaining conversation state and user sessions.\n\n**Key features:**\n- Stateless chat inference\n- Farm context grounding (optional)\n- Context validation before inference\n- Batch requests for bulk analysis\n- Health monitoring\n\n---\n\n## Endpoints\n\n### 1. Health Check\n\n```http\nGET /api/v1/llm/health\n```\n\nVerify LLM backend accessibility and get latency info.\n\n**Response (200 OK):**\n```json\n{\n \"status\": \"ok\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"latency_ms\": 12.5,\n \"message\": \"LLM backend is reachable\"\n}\n```\n\n**Response (503 Service Unavailable):**\n```json\n{\n \"status\": \"unavailable\",\n \"message\": \"LLM backend unavailable: HF token not found...\"\n}\n```\n\n**Use case:** Check backend health before sending requests to `/chat`.\n\n---\n\n### 2. Chat Inference\n\n```http\nPOST /api/v1/llm/chat\n```\n\nSend a message to the farmer assistant and get a response.\n\n**Request:**\n```json\n{\n \"content\": \"How much water should tomatoes get weekly?\",\n \"conversation_history\": [\n {\"role\": \"user\", \"content\": \"What crops grow here?\"},\n {\"role\": \"assistant\", \"content\": \"Tomatoes, peppers, lettuce...\"}\n ],\n \"farm_context\": {\n \"farm_name\": \"Johnson Farm\",\n \"crop\": \"tomato\",\n \"area_ha\": 2.5,\n \"design_summary\": {}\n },\n \"max_tokens\": 256,\n \"temperature\": 0.7\n}\n```\n\n**Request fields:**\n\n| Field | Type | Required | Default | Notes |\n|-------|------|----------|---------|-------|\n| `content` | string | ✓ | — | User message (1–2000 chars) |\n| `conversation_history` | array | — | null | Prior messages in `{\"role\": \"user\"|\"assistant\", \"content\": \"...\"}` format |\n| `farm_context` | object | — | null | Farm metadata (see below) |\n| `max_tokens` | integer | — | 256 | Max response length (10–1024) |\n| `temperature` | float | — | 0.7 | Creativity (0=deterministic, 2=very creative) |\n\n**Farm context fields (all optional):**\n- `farm_name`: string — Name of the farm\n- `crop`: string — Crop type (tomato, pepper, lettuce, cucumber, orchard, generic)\n- `area_ha`: number — Farm area in hectares\n- `design_summary`: object — Design metadata from `/rest/v1/design`\n\n**Response (200 OK):**\n```json\n{\n \"content\": \"For tomatoes, apply 25-40mm of water per week...\",\n \"timestamp\": \"2026-06-18T12:30:00+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 145,\n \"latency_ms\": 2450,\n \"metadata\": {\n \"farm_context_provided\": true,\n \"conversation_history_length\": 2\n }\n}\n```\n\n**Response (422 Validation Error):**\n```json\n{\n \"detail\": {\n \"code\": \"invalid_farm_context\",\n \"message\": \"Farm context validation failed\",\n \"errors\": [\"'area_ha' must be a number\"]\n }\n}\n```\n\n**Response (500 Inference Error):**\n```json\n{\n \"detail\": \"LLM inference failed: Request timed out after 30s\"\n}\n```\n\n**Use case:** Core endpoint for multi-turn conversations. Client maintains history and passes it with each request.\n\n---\n\n### 3. Validate Context\n\n```http\nPOST /api/v1/llm/validate-context\n```\n\nValidate farm context before using it in chat. Catch issues early without spending LLM tokens.\n\n**Request:**\n```json\n{\n \"farm_context\": {\n \"farm_name\": \"Smith Farm\",\n \"crop\": \"lettuce\",\n \"area_ha\": 0.5\n }\n}\n```\n\n**Response (200 OK):**\n```json\n{\n \"valid\": true,\n \"warnings\": [],\n \"errors\": []\n}\n```\n\n**Response with warnings:**\n```json\n{\n \"valid\": true,\n \"warnings\": [\n \"Unknown crop 'sugarcanr'. Expected one of: tomato, pepper, lettuce, cucumber, orchard, generic\"\n ],\n \"errors\": []\n}\n```\n\n**Response with errors:**\n```json\n{\n \"valid\": false,\n \"warnings\": [],\n \"errors\": [\"'area_ha' must be a number\"]\n}\n```\n\n**Validation rules:**\n- Required (fail): `area_ha` is a number if present\n- Recommended: `farm_name`, `crop`, `area_ha`\n- Optional warnings: Unknown crop, missing recommended fields\n\n**Use case:** Pre-validate context before `/chat` to avoid wasting LLM tokens on invalid requests.\n\n---\n\n### 4. Batch Chat\n\n```http\nPOST /api/v1/llm/chat/batch\n```\n\nSend multiple messages in a single request (up to 10).\n\n**Request:**\n```json\n[\n {\n \"content\": \"What is drip irrigation?\",\n \"max_tokens\": 100\n },\n {\n \"content\": \"How do I install valves?\",\n \"max_tokens\": 100\n },\n {\n \"content\": \"What is emitter spacing?\",\n \"max_tokens\": 100,\n \"farm_context\": {\n \"crop\": \"tomato\",\n \"area_ha\": 1.0\n }\n }\n]\n```\n\n**Response (200 OK):**\n```json\n[\n {\n \"content\": \"Drip irrigation is a method of watering plants...\",\n \"timestamp\": \"2026-06-18T12:30:00+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 120,\n \"latency_ms\": 2100,\n \"metadata\": {\"farm_context_provided\": false}\n },\n {\n \"content\": \"To install valves: 1) Plan your zones...\",\n \"timestamp\": \"2026-06-18T12:30:02+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 135,\n \"latency_ms\": 1950,\n \"metadata\": {\"farm_context_provided\": false}\n },\n {\n \"content\": \"Emitter spacing depends on soil type...\",\n \"timestamp\": \"2026-06-18T12:30:04+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 110,\n \"latency_ms\": 2050,\n \"metadata\": {\"farm_context_provided\": true}\n }\n]\n```\n\n**Constraints:**\n- Max 10 requests per batch\n- Each request is independent (no conversation history carried between items)\n- Returns response array in same order as request\n\n**Use case:** Bulk analysis, FAQ generation, or multi-question surveys.\n\n---\n\n## Error Handling\n\n### HTTP Status Codes\n\n| Code | Meaning | Example |\n|------|---------|----------|\n| 200 | Success | Chat response generated |\n| 422 | Validation Error | Invalid farm context or oversized content |\n| 500 | Inference Error | LLM backend failure or timeout |\n| 503 | Service Unavailable | HF token not configured |\n\n### Common Error Scenarios\n\n**Missing HF token:**\n```bash\ncurl http://localhost:7860/api/v1/llm/health\n# → 503 Service Unavailable\n```\n\n**Oversized content (>2000 chars):**\n```bash\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\"content\": \"'\"'\"'x{3000}'\"'\"'\"}'\n# → 422 Validation Error\n```\n\n**Invalid farm context:**\n```bash\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\"content\": \"...\", \"farm_context\": {\"area_ha\": \"not a number\"}}'\n# → 422 Validation Error with error details\n```\n\n---\n\n## Integration Examples\n\n### JavaScript/Web UI\n\n```javascript\n// Simple chat\nconst response = await fetch('http://localhost:7860/api/v1/llm/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n content: 'How often should I water?',\n farm_context: {\n farm_name: 'My Farm',\n crop: 'tomato',\n area_ha: 1.5\n },\n max_tokens: 200,\n temperature: 0.7\n })\n});\n\nconst { content, latency_ms } = await response.json();\nconsole.log(`Response (${latency_ms}ms): ${content}`);\n\n// Multi-turn conversation\nconst messages = [];\n\nfunction addMessage(role, content) {\n messages.push({ role, content });\n}\n\nasync function chat(userMessage) {\n addMessage('user', userMessage);\n const response = await fetch('http://localhost:7860/api/v1/llm/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n content: userMessage,\n conversation_history: messages.slice(0, -1), // Exclude current user message\n farm_context: { crop: 'tomato', area_ha: 2.0 }\n })\n });\n const { content } = await response.json();\n addMessage('assistant', content);\n return content;\n}\n\nawait chat('What is drip irrigation?');\nawait chat('Can I use it for peppers?');\n```\n\n### Python Client\n\n```python\nimport requests\n\nclass FarmerChatClient:\n def __init__(self, base_url='http://localhost:7860'):\n self.base_url = base_url\n self.history = []\n self.farm_context = {}\n \n def set_farm_context(self, **kwargs):\n \"\"\"Set farm metadata (crop, area_ha, farm_name, etc.)\"\"\"\n self.farm_context.update(kwargs)\n \n def chat(self, message: str, max_tokens: int = 256) -> str:\n \"\"\"Send a message and get a response.\"\"\"\n response = requests.post(\n f'{self.base_url}/api/v1/llm/chat',\n json={\n 'content': message,\n 'conversation_history': self.history,\n 'farm_context': self.farm_context,\n 'max_tokens': max_tokens,\n 'temperature': 0.7,\n }\n )\n response.raise_for_status()\n \n data = response.json()\n assistant_message = data['content']\n \n # Add to conversation history\n self.history.append({'role': 'user', 'content': message})\n self.history.append({'role': 'assistant', 'content': assistant_message})\n \n return assistant_message\n\n# Usage\nclient = FarmerChatClient()\nclient.set_farm_context(farm_name='Johnson Farm', crop='tomato', area_ha=2.5)\n\nprint(client.chat('How often should I water tomatoes?'))\nprint(client.chat('What about in dry seasons?')) # Uses conversation history\n```\n\n### cURL Examples\n\n```bash\n# Health check\ncurl http://localhost:7860/api/v1/llm/health | jq\n\n# Simple chat\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"content\": \"What is drip irrigation?\",\n \"max_tokens\": 150\n }' | jq '.content'\n\n# Chat with farm context\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"content\": \"How much water should I apply?\",\n \"farm_context\": {\n \"farm_name\": \"Smith Farm\",\n \"crop\": \"tomato\",\n \"area_ha\": 1.5\n },\n \"max_tokens\": 200\n }' | jq '.content'\n\n# Validate context before chat\ncurl -X POST http://localhost:7860/api/v1/llm/validate-context \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"farm_context\": {\n \"crop\": \"invalid_crop\",\n \"area_ha\": 0.5\n }\n }' | jq\n\n# Batch requests\ncurl -X POST http://localhost:7860/api/v1/llm/chat/batch \\\n -H \"Content-Type: application/json\" \\\n -d '[\n {\"content\": \"What is drip irrigation?\", \"max_tokens\": 100},\n {\"content\": \"How do I install valves?\", \"max_tokens\": 100}\n ]' | jq\n```\n\n---\n\n## Performance Considerations\n\n### Latency\n- Typical: 1.5–3 seconds for 100–200 token responses\n- First request: May take 5–10s if model is loading\n- Use `max_tokens` to control response length and latency\n\n### Throughput\n- HuggingFace Inference API has request rate limits\n- Use batch endpoint for multiple questions (more efficient)\n- Implement client-side request queuing if needed\n\n### Token Estimation\n- Roughly 1 token ≈ 4 characters\n- Response `tokens_used` includes both input and output\n\n---\n\n## Configuration\n\n### Environment Setup\n\nThe API requires a HuggingFace API token:\n\n```bash\n# Option 1: Environment variable\nexport HF_TOKEN=hf_your_token_here\npython app.py\n\n# Option 2: secret.txt file (same directory as app.py)\necho \"hf_your_token_here\" > secret.txt\npython app.py\n\n# Option 3: Passed to FarmerAssistant directly (in code)\n# See llm_chat.py for details\n```\n\n### Model Selection\n\nDefault model: `mistralai/Mistral-7B-Instruct-v0.1`\n\nTo use a different model, edit `llm_chat.py`:\n\n```python\nassistant = FarmerAssistant(\n model_id=\"HuggingFace/ModelName\",\n api_token=\"hf_your_token_here\"\n)\n```\n\n---\n\n## Testing\n\nRun the test suite to verify all endpoints:\n\n```bash\n# Terminal 1: Start the server\npython app.py\n\n# Terminal 2: Run tests\npython test_llm_api.py\n```\n\nThis tests:\n- Health check\n- Simple and contextual chat\n- Multi-turn conversations\n- Context validation\n- Batch requests\n- Error handling\n\n---\n\n## OpenAPI Documentation\n\nOnce the server is running, view interactive API docs:\n\n```\nhttp://localhost:7860/docs\n```\n\nThis page (auto-generated by FastAPI) shows all endpoints, request/response schemas, and try-it-out forms.\n"
|
app.py
CHANGED
|
@@ -645,10 +645,12 @@ from fastapi import FastAPI, Request # noqa: E402
|
|
| 645 |
from fastapi.exceptions import RequestValidationError # noqa: E402
|
| 646 |
from fastapi.responses import JSONResponse # noqa: E402
|
| 647 |
from rest_api import build_router, _format_validation_error # noqa: E402
|
|
|
|
| 648 |
|
| 649 |
# Create the FastAPI app BEFORE mounting Gradio so exception handlers are registered
|
| 650 |
api = FastAPI(title="Farm Layout Model API")
|
| 651 |
api.include_router(build_router())
|
|
|
|
| 652 |
|
| 653 |
# Add custom validation error handler for user-friendly messages
|
| 654 |
# This must be done BEFORE mounting Gradio
|
|
|
|
| 645 |
from fastapi.exceptions import RequestValidationError # noqa: E402
|
| 646 |
from fastapi.responses import JSONResponse # noqa: E402
|
| 647 |
from rest_api import build_router, _format_validation_error # noqa: E402
|
| 648 |
+
from llm_api import build_router as build_llm_router # noqa: E402
|
| 649 |
|
| 650 |
# Create the FastAPI app BEFORE mounting Gradio so exception handlers are registered
|
| 651 |
api = FastAPI(title="Farm Layout Model API")
|
| 652 |
api.include_router(build_router())
|
| 653 |
+
api.include_router(build_llm_router())
|
| 654 |
|
| 655 |
# Add custom validation error handler for user-friendly messages
|
| 656 |
# This must be done BEFORE mounting Gradio
|
llm_api.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
REST API layer for LLM inference — farmer chat assistant.
|
| 3 |
+
|
| 4 |
+
Exposes HuggingFace Inference API through standardized endpoints.
|
| 5 |
+
Conversation state management is handled by the client/external service.
|
| 6 |
+
|
| 7 |
+
Why this exists:
|
| 8 |
+
The FarmerAssistant class only wraps HTTP calls to HuggingFace.
|
| 9 |
+
This module translates that into REST endpoints so external clients
|
| 10 |
+
(web UIs, mobile apps, third-party services) can interact with the LLM
|
| 11 |
+
without importing Python modules.
|
| 12 |
+
|
| 13 |
+
The endpoints here are stateless — conversation history, user sessions,
|
| 14 |
+
and context persistence are the caller's responsibility.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import time
|
| 19 |
+
from typing import Any, Dict, List, Optional
|
| 20 |
+
|
| 21 |
+
from fastapi import APIRouter, HTTPException
|
| 22 |
+
from pydantic import BaseModel, Field
|
| 23 |
+
|
| 24 |
+
from llm_chat import FarmerAssistant
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 28 |
+
# Request/Response Models
|
| 29 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ChatRequest(BaseModel):
|
| 33 |
+
"""Single LLM inference request.
|
| 34 |
+
|
| 35 |
+
The caller provides the user's message. Conversation history and context
|
| 36 |
+
are optional — the client maintains state.
|
| 37 |
+
"""
|
| 38 |
+
content: str = Field(
|
| 39 |
+
...,
|
| 40 |
+
min_length=1,
|
| 41 |
+
max_length=2000,
|
| 42 |
+
description="User's question or statement for the farmer assistant"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
conversation_history: Optional[List[Dict[str, str]]] = Field(
|
| 46 |
+
default=None,
|
| 47 |
+
description=(
|
| 48 |
+
"Previous messages in [{'role': 'user', 'content': '...'}, "
|
| 49 |
+
"{'role': 'assistant', 'content': '...'}] format. "
|
| 50 |
+
"Used to ground the response in prior context."
|
| 51 |
+
)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
farm_context: Optional[Dict[str, Any]] = Field(
|
| 55 |
+
default=None,
|
| 56 |
+
description=(
|
| 57 |
+
"Optional farm metadata to include in the system context. "
|
| 58 |
+
"Example: {'farm_name': 'Johnson Farm', 'crop': 'tomato', 'area_ha': 2.5}"
|
| 59 |
+
)
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
max_tokens: int = Field(
|
| 63 |
+
default=256,
|
| 64 |
+
ge=10,
|
| 65 |
+
le=1024,
|
| 66 |
+
description="Maximum tokens in the response"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
temperature: float = Field(
|
| 70 |
+
default=0.7,
|
| 71 |
+
ge=0.0,
|
| 72 |
+
le=2.0,
|
| 73 |
+
description="Creativity level (0=deterministic, 1=creative, 2=very creative)"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class ChatResponse(BaseModel):
|
| 78 |
+
"""LLM inference response."""
|
| 79 |
+
content: str = Field(..., description="Assistant's response")
|
| 80 |
+
timestamp: str = Field(..., description="ISO8601 timestamp when response was generated")
|
| 81 |
+
model: str = Field(..., description="Model ID used (e.g., 'mistral-7b-instruct')")
|
| 82 |
+
tokens_used: Optional[int] = Field(
|
| 83 |
+
default=None,
|
| 84 |
+
description="Approximate tokens consumed by request (input + output)"
|
| 85 |
+
)
|
| 86 |
+
latency_ms: float = Field(..., description="Response time in milliseconds")
|
| 87 |
+
metadata: Optional[Dict[str, Any]] = Field(
|
| 88 |
+
default=None,
|
| 89 |
+
description="Additional metadata (e.g., warning flags, model-specific info)"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class HealthResponse(BaseModel):
|
| 94 |
+
"""LLM backend health status."""
|
| 95 |
+
status: str = Field(..., description="'ok' or 'unavailable'")
|
| 96 |
+
model: str = Field(..., description="Active model ID")
|
| 97 |
+
latency_ms: Optional[float] = Field(
|
| 98 |
+
default=None,
|
| 99 |
+
description="Milliseconds to reach the LLM backend"
|
| 100 |
+
)
|
| 101 |
+
message: Optional[str] = Field(
|
| 102 |
+
default=None,
|
| 103 |
+
description="Human-readable status message"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class ContextValidationRequest(BaseModel):
|
| 108 |
+
"""Validate farm context before using in chat."""
|
| 109 |
+
farm_context: Dict[str, Any] = Field(
|
| 110 |
+
...,
|
| 111 |
+
description="Farm metadata to validate (e.g., design_summary, crop, area)"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class ContextValidationResponse(BaseModel):
|
| 116 |
+
"""Result of context validation."""
|
| 117 |
+
valid: bool = Field(..., description="Whether the context is valid")
|
| 118 |
+
warnings: List[str] = Field(
|
| 119 |
+
default_factory=list,
|
| 120 |
+
description="Non-fatal issues (e.g., missing recommended fields)"
|
| 121 |
+
)
|
| 122 |
+
errors: List[str] = Field(
|
| 123 |
+
default_factory=list,
|
| 124 |
+
description="Fatal issues that should be resolved before use"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 129 |
+
# Helper: Lazy-load FarmerAssistant singleton
|
| 130 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 131 |
+
|
| 132 |
+
_ASSISTANT = None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def get_assistant() -> FarmerAssistant:
|
| 136 |
+
"""Lazy-load the FarmerAssistant on first use.
|
| 137 |
+
|
| 138 |
+
Raises HTTPException(503) if the token is not configured.
|
| 139 |
+
"""
|
| 140 |
+
global _ASSISTANT
|
| 141 |
+
if _ASSISTANT is None:
|
| 142 |
+
try:
|
| 143 |
+
_ASSISTANT = FarmerAssistant()
|
| 144 |
+
except ValueError as e:
|
| 145 |
+
raise HTTPException(
|
| 146 |
+
status_code=503,
|
| 147 |
+
detail=f"LLM backend unavailable: {str(e)}",
|
| 148 |
+
)
|
| 149 |
+
return _ASSISTANT
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 153 |
+
# Context Validation
|
| 154 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _validate_farm_context(context: Dict[str, Any]) -> tuple[bool, List[str], List[str]]:
|
| 158 |
+
"""
|
| 159 |
+
Validate farm context for use in chat.
|
| 160 |
+
|
| 161 |
+
Returns: (is_valid, warnings, errors)
|
| 162 |
+
"""
|
| 163 |
+
warnings = []
|
| 164 |
+
errors = []
|
| 165 |
+
|
| 166 |
+
# Optional but recommended fields
|
| 167 |
+
recommended = ["farm_name", "crop", "area_ha"]
|
| 168 |
+
present = set(context.keys())
|
| 169 |
+
missing_recommended = [f for f in recommended if f not in present]
|
| 170 |
+
if missing_recommended:
|
| 171 |
+
warnings.append(f"Missing recommended fields: {', '.join(missing_recommended)}")
|
| 172 |
+
|
| 173 |
+
# Validate field types if present
|
| 174 |
+
if "area_ha" in context:
|
| 175 |
+
try:
|
| 176 |
+
float(context["area_ha"])
|
| 177 |
+
except (TypeError, ValueError):
|
| 178 |
+
errors.append("'area_ha' must be a number")
|
| 179 |
+
|
| 180 |
+
if "crop" in context:
|
| 181 |
+
valid_crops = ["tomato", "pepper", "lettuce", "cucumber", "orchard", "generic"]
|
| 182 |
+
if str(context["crop"]).lower() not in valid_crops:
|
| 183 |
+
warnings.append(
|
| 184 |
+
f"Unknown crop '{context['crop']}'. "
|
| 185 |
+
f"Expected one of: {', '.join(valid_crops)}"
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# design_summary can be complex; just check it exists if referenced
|
| 189 |
+
if "design_summary" in context and not isinstance(context["design_summary"], dict):
|
| 190 |
+
warnings.append("'design_summary' should be a dict")
|
| 191 |
+
|
| 192 |
+
is_valid = len(errors) == 0
|
| 193 |
+
return is_valid, warnings, errors
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 197 |
+
# Router
|
| 198 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def build_router() -> APIRouter:
|
| 202 |
+
"""Build the LLM inference API router."""
|
| 203 |
+
router = APIRouter(prefix="/api/v1/llm", tags=["chat"])
|
| 204 |
+
|
| 205 |
+
@router.get("/health")
|
| 206 |
+
def health() -> HealthResponse:
|
| 207 |
+
"""
|
| 208 |
+
Check if the LLM backend is accessible.
|
| 209 |
+
|
| 210 |
+
Returns latency to help clients decide whether to retry or fallback.
|
| 211 |
+
"""
|
| 212 |
+
try:
|
| 213 |
+
assistant = get_assistant()
|
| 214 |
+
start = time.perf_counter()
|
| 215 |
+
# Simple test: check that we can reach the API
|
| 216 |
+
# (Without actually sending tokens to the model)
|
| 217 |
+
_ = assistant.api_token is not None
|
| 218 |
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
| 219 |
+
|
| 220 |
+
return HealthResponse(
|
| 221 |
+
status="ok",
|
| 222 |
+
model=assistant.model_id,
|
| 223 |
+
latency_ms=elapsed_ms,
|
| 224 |
+
message="LLM backend is reachable",
|
| 225 |
+
)
|
| 226 |
+
except HTTPException as e:
|
| 227 |
+
# Token not configured
|
| 228 |
+
raise e
|
| 229 |
+
except Exception as e:
|
| 230 |
+
raise HTTPException(
|
| 231 |
+
status_code=503,
|
| 232 |
+
detail=f"LLM health check failed: {str(e)}",
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
@router.post("/chat")
|
| 236 |
+
def chat(req: ChatRequest) -> ChatResponse:
|
| 237 |
+
"""
|
| 238 |
+
Send a message to the farmer assistant and get a response.
|
| 239 |
+
|
| 240 |
+
The caller is responsible for maintaining conversation history.
|
| 241 |
+
Pass prior messages in `conversation_history` if you want context.
|
| 242 |
+
|
| 243 |
+
Optional `farm_context` grounds the response in your farm data
|
| 244 |
+
(e.g., crop, area, design summary).
|
| 245 |
+
"""
|
| 246 |
+
try:
|
| 247 |
+
assistant = get_assistant()
|
| 248 |
+
except HTTPException:
|
| 249 |
+
raise
|
| 250 |
+
|
| 251 |
+
# Enrich the message with farm context if provided
|
| 252 |
+
farm_context_str = ""
|
| 253 |
+
if req.farm_context:
|
| 254 |
+
# Validate context first
|
| 255 |
+
is_valid, warnings, errors = _validate_farm_context(req.farm_context)
|
| 256 |
+
if errors:
|
| 257 |
+
raise HTTPException(
|
| 258 |
+
status_code=422,
|
| 259 |
+
detail={
|
| 260 |
+
"code": "invalid_farm_context",
|
| 261 |
+
"message": "Farm context validation failed",
|
| 262 |
+
"errors": errors,
|
| 263 |
+
},
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# Build a brief context string to prepend to the user message.
|
| 267 |
+
farm_name = req.farm_context.get("farm_name", "Your farm")
|
| 268 |
+
crop = req.farm_context.get("crop", "unknown crop")
|
| 269 |
+
area = req.farm_context.get("area_ha", "unknown area")
|
| 270 |
+
farm_context_str = f"Farm context: {farm_name}, growing {crop}, {area} ha. "
|
| 271 |
+
|
| 272 |
+
# Send to LLM
|
| 273 |
+
start = time.perf_counter()
|
| 274 |
+
try:
|
| 275 |
+
response_text = assistant.chat(
|
| 276 |
+
user_message=f"{farm_context_str}{req.content}",
|
| 277 |
+
conversation_history=req.conversation_history,
|
| 278 |
+
max_tokens=req.max_tokens,
|
| 279 |
+
temperature=req.temperature,
|
| 280 |
+
)
|
| 281 |
+
except Exception as e:
|
| 282 |
+
raise HTTPException(
|
| 283 |
+
status_code=500,
|
| 284 |
+
detail=f"LLM inference failed: {str(e)}",
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
| 288 |
+
|
| 289 |
+
# Rough token estimation (1 token ≈ 4 chars)
|
| 290 |
+
estimated_tokens = (
|
| 291 |
+
len(req.content) + len(response_text) + len(farm_context_str)
|
| 292 |
+
) // 4
|
| 293 |
+
|
| 294 |
+
from datetime import datetime, timezone
|
| 295 |
+
timestamp = datetime.now(timezone.utc).isoformat()
|
| 296 |
+
|
| 297 |
+
return ChatResponse(
|
| 298 |
+
content=response_text,
|
| 299 |
+
timestamp=timestamp,
|
| 300 |
+
model=assistant.model_id,
|
| 301 |
+
tokens_used=estimated_tokens,
|
| 302 |
+
latency_ms=elapsed_ms,
|
| 303 |
+
metadata={
|
| 304 |
+
"farm_context_provided": req.farm_context is not None,
|
| 305 |
+
"conversation_history_length": len(req.conversation_history or []),
|
| 306 |
+
},
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
@router.post("/validate-context")
|
| 310 |
+
def validate_context(req: ContextValidationRequest) -> ContextValidationResponse:
|
| 311 |
+
"""
|
| 312 |
+
Validate farm context before using it in chat requests.
|
| 313 |
+
|
| 314 |
+
Helps clients catch issues early without spending LLM tokens.
|
| 315 |
+
"""
|
| 316 |
+
is_valid, warnings, errors = _validate_farm_context(req.farm_context)
|
| 317 |
+
|
| 318 |
+
return ContextValidationResponse(
|
| 319 |
+
valid=is_valid,
|
| 320 |
+
warnings=warnings,
|
| 321 |
+
errors=errors,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
@router.post("/chat/batch")
|
| 325 |
+
def chat_batch(requests: List[ChatRequest]) -> List[ChatResponse]:
|
| 326 |
+
"""
|
| 327 |
+
Send multiple messages in a batch (useful for bulk analysis).
|
| 328 |
+
|
| 329 |
+
Note: Each request is independent — no conversation history carried
|
| 330 |
+
between items. For multi-turn conversations, use POST /api/v1/llm/chat
|
| 331 |
+
with explicit history in each call.
|
| 332 |
+
|
| 333 |
+
Rate-limited: max 10 requests per batch.
|
| 334 |
+
"""
|
| 335 |
+
if len(requests) > 10:
|
| 336 |
+
raise HTTPException(
|
| 337 |
+
status_code=422,
|
| 338 |
+
detail="Batch size limited to 10 requests",
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
try:
|
| 342 |
+
assistant = get_assistant()
|
| 343 |
+
except HTTPException:
|
| 344 |
+
raise
|
| 345 |
+
|
| 346 |
+
responses = []
|
| 347 |
+
for req in requests:
|
| 348 |
+
try:
|
| 349 |
+
start = time.perf_counter()
|
| 350 |
+
response_text = assistant.chat(
|
| 351 |
+
user_message=req.content,
|
| 352 |
+
conversation_history=req.conversation_history,
|
| 353 |
+
max_tokens=req.max_tokens,
|
| 354 |
+
temperature=req.temperature,
|
| 355 |
+
)
|
| 356 |
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
| 357 |
+
estimated_tokens = (len(req.content) + len(response_text)) // 4
|
| 358 |
+
|
| 359 |
+
from datetime import datetime, timezone
|
| 360 |
+
timestamp = datetime.now(timezone.utc).isoformat()
|
| 361 |
+
|
| 362 |
+
responses.append(ChatResponse(
|
| 363 |
+
content=response_text,
|
| 364 |
+
timestamp=timestamp,
|
| 365 |
+
model=assistant.model_id,
|
| 366 |
+
tokens_used=estimated_tokens,
|
| 367 |
+
latency_ms=elapsed_ms,
|
| 368 |
+
metadata={
|
| 369 |
+
"farm_context_provided": req.farm_context is not None,
|
| 370 |
+
},
|
| 371 |
+
))
|
| 372 |
+
except Exception as e:
|
| 373 |
+
# Return error in same list position
|
| 374 |
+
responses.append(ChatResponse(
|
| 375 |
+
content=f"Error: {str(e)}",
|
| 376 |
+
timestamp="",
|
| 377 |
+
model="",
|
| 378 |
+
latency_ms=0,
|
| 379 |
+
metadata={"error": True},
|
| 380 |
+
))
|
| 381 |
+
|
| 382 |
+
return responses
|
| 383 |
+
|
| 384 |
+
return router
|
test_llm_api.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test script for LLM inference API endpoints.
|
| 3 |
+
|
| 4 |
+
Run the server first:
|
| 5 |
+
python app.py
|
| 6 |
+
|
| 7 |
+
Then in another terminal:
|
| 8 |
+
python test_llm_api.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
import json
|
| 13 |
+
from typing import Dict, Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
BASE_URL = "http://localhost:7860"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_health() -> None:
|
| 20 |
+
"""Test the health endpoint."""
|
| 21 |
+
print("\n=== Testing GET /api/v1/llm/health ===")
|
| 22 |
+
response = requests.get(f"{BASE_URL}/api/v1/llm/health")
|
| 23 |
+
print(f"Status: {response.status_code}")
|
| 24 |
+
print(f"Response: {json.dumps(response.json(), indent=2)}")
|
| 25 |
+
assert response.status_code == 200 or response.status_code == 503
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_chat_simple() -> None:
|
| 29 |
+
"""Test basic chat without context."""
|
| 30 |
+
print("\n=== Testing POST /api/v1/llm/chat (simple) ===")
|
| 31 |
+
payload = {
|
| 32 |
+
"content": "What is drip irrigation?",
|
| 33 |
+
"max_tokens": 150,
|
| 34 |
+
"temperature": 0.7,
|
| 35 |
+
}
|
| 36 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
|
| 37 |
+
print(f"Status: {response.status_code}")
|
| 38 |
+
data = response.json()
|
| 39 |
+
print(f"Model: {data.get('model')}")
|
| 40 |
+
print(f"Content: {data.get('content')}")
|
| 41 |
+
print(f"Latency (ms): {data.get('latency_ms')}")
|
| 42 |
+
assert response.status_code == 200 or response.status_code == 500
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_chat_with_context() -> None:
|
| 46 |
+
"""Test chat with farm context."""
|
| 47 |
+
print("\n=== Testing POST /api/v1/llm/chat (with farm context) ===")
|
| 48 |
+
payload = {
|
| 49 |
+
"content": "How much water should I apply weekly?",
|
| 50 |
+
"farm_context": {
|
| 51 |
+
"farm_name": "Johnson Farm",
|
| 52 |
+
"crop": "tomato",
|
| 53 |
+
"area_ha": 2.5,
|
| 54 |
+
},
|
| 55 |
+
"max_tokens": 200,
|
| 56 |
+
"temperature": 0.5,
|
| 57 |
+
}
|
| 58 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
|
| 59 |
+
print(f"Status: {response.status_code}")
|
| 60 |
+
data = response.json()
|
| 61 |
+
print(f"Model: {data.get('model')}")
|
| 62 |
+
print(f"Content: {data.get('content')}")
|
| 63 |
+
print(f"Latency (ms): {data.get('latency_ms')}")
|
| 64 |
+
print(f"Metadata: {json.dumps(data.get('metadata'), indent=2)}")
|
| 65 |
+
assert response.status_code == 200 or response.status_code == 500
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_chat_with_history() -> None:
|
| 69 |
+
"""Test chat with conversation history."""
|
| 70 |
+
print("\n=== Testing POST /api/v1/llm/chat (with history) ===")
|
| 71 |
+
payload = {
|
| 72 |
+
"content": "What about pepper crops?",
|
| 73 |
+
"conversation_history": [
|
| 74 |
+
{"role": "user", "content": "How much water should I apply weekly?"},
|
| 75 |
+
{"role": "assistant", "content": "For tomatoes, 25-40mm per week is typical."},
|
| 76 |
+
],
|
| 77 |
+
"farm_context": {
|
| 78 |
+
"farm_name": "Johnson Farm",
|
| 79 |
+
"crop": "pepper",
|
| 80 |
+
"area_ha": 1.5,
|
| 81 |
+
},
|
| 82 |
+
"max_tokens": 200,
|
| 83 |
+
}
|
| 84 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
|
| 85 |
+
print(f"Status: {response.status_code}")
|
| 86 |
+
data = response.json()
|
| 87 |
+
print(f"Content: {data.get('content')}")
|
| 88 |
+
print(f"Conversation history length: {data.get('metadata', {}).get('conversation_history_length')}")
|
| 89 |
+
assert response.status_code == 200 or response.status_code == 500
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_validate_context_valid() -> None:
|
| 93 |
+
"""Test context validation with valid data."""
|
| 94 |
+
print("\n=== Testing POST /api/v1/llm/validate-context (valid) ===")
|
| 95 |
+
payload = {
|
| 96 |
+
"farm_context": {
|
| 97 |
+
"farm_name": "Smith Farm",
|
| 98 |
+
"crop": "lettuce",
|
| 99 |
+
"area_ha": 0.5,
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/validate-context", json=payload)
|
| 103 |
+
print(f"Status: {response.status_code}")
|
| 104 |
+
data = response.json()
|
| 105 |
+
print(f"Valid: {data.get('valid')}")
|
| 106 |
+
print(f"Warnings: {data.get('warnings')}")
|
| 107 |
+
print(f"Errors: {data.get('errors')}")
|
| 108 |
+
assert response.status_code == 200
|
| 109 |
+
assert data.get('valid') is True
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_validate_context_invalid_crop() -> None:
|
| 113 |
+
"""Test context validation with invalid crop."""
|
| 114 |
+
print("\n=== Testing POST /api/v1/llm/validate-context (invalid crop) ===")
|
| 115 |
+
payload = {
|
| 116 |
+
"farm_context": {
|
| 117 |
+
"farm_name": "Smith Farm",
|
| 118 |
+
"crop": "invalid_crop",
|
| 119 |
+
"area_ha": 0.5,
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/validate-context", json=payload)
|
| 123 |
+
print(f"Status: {response.status_code}")
|
| 124 |
+
data = response.json()
|
| 125 |
+
print(f"Valid: {data.get('valid')}")
|
| 126 |
+
print(f"Warnings: {data.get('warnings')}")
|
| 127 |
+
assert response.status_code == 200
|
| 128 |
+
# Should still be valid (warnings don't fail validation)
|
| 129 |
+
assert data.get('valid') is True
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_validate_context_invalid_area() -> None:
|
| 133 |
+
"""Test context validation with invalid area type."""
|
| 134 |
+
print("\n=== Testing POST /api/v1/llm/validate-context (invalid area) ===")
|
| 135 |
+
payload = {
|
| 136 |
+
"farm_context": {
|
| 137 |
+
"farm_name": "Smith Farm",
|
| 138 |
+
"crop": "tomato",
|
| 139 |
+
"area_ha": "not a number",
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/validate-context", json=payload)
|
| 143 |
+
print(f"Status: {response.status_code}")
|
| 144 |
+
data = response.json()
|
| 145 |
+
print(f"Valid: {data.get('valid')}")
|
| 146 |
+
print(f"Errors: {data.get('errors')}")
|
| 147 |
+
assert response.status_code == 200
|
| 148 |
+
assert data.get('valid') is False
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_batch_chat() -> None:
|
| 152 |
+
"""Test batch chat endpoint."""
|
| 153 |
+
print("\n=== Testing POST /api/v1/llm/chat/batch ===")
|
| 154 |
+
payload = [
|
| 155 |
+
{
|
| 156 |
+
"content": "What is drip irrigation?",
|
| 157 |
+
"max_tokens": 100,
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"content": "How do I install valves?",
|
| 161 |
+
"max_tokens": 100,
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"content": "What is emitter spacing?",
|
| 165 |
+
"max_tokens": 100,
|
| 166 |
+
"farm_context": {
|
| 167 |
+
"crop": "tomato",
|
| 168 |
+
"area_ha": 1.0,
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
]
|
| 172 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/chat/batch", json=payload)
|
| 173 |
+
print(f"Status: {response.status_code}")
|
| 174 |
+
if response.status_code == 200:
|
| 175 |
+
data = response.json()
|
| 176 |
+
print(f"Number of responses: {len(data)}")
|
| 177 |
+
for i, resp in enumerate(data):
|
| 178 |
+
print(f"\nResponse {i+1}:")
|
| 179 |
+
print(f" Content: {resp.get('content')[:100]}...")
|
| 180 |
+
print(f" Latency: {resp.get('latency_ms'):.1f}ms")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_chat_invalid_request() -> None:
|
| 184 |
+
"""Test chat with invalid request (missing required field)."""
|
| 185 |
+
print("\n=== Testing POST /api/v1/llm/chat (invalid request) ===")
|
| 186 |
+
payload = {
|
| 187 |
+
"max_tokens": 100,
|
| 188 |
+
# Missing required 'content' field
|
| 189 |
+
}
|
| 190 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
|
| 191 |
+
print(f"Status: {response.status_code}")
|
| 192 |
+
print(f"Response: {json.dumps(response.json(), indent=2)}")
|
| 193 |
+
assert response.status_code == 422
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def test_chat_oversized_request() -> None:
|
| 197 |
+
"""Test chat with oversized content."""
|
| 198 |
+
print("\n=== Testing POST /api/v1/llm/chat (oversized content) ===")
|
| 199 |
+
payload = {
|
| 200 |
+
"content": "x" * 3000, # Exceeds max_length of 2000
|
| 201 |
+
}
|
| 202 |
+
response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
|
| 203 |
+
print(f"Status: {response.status_code}")
|
| 204 |
+
print(f"Response: {json.dumps(response.json(), indent=2)}")
|
| 205 |
+
assert response.status_code == 422
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
print("=" * 70)
|
| 210 |
+
print("LLM Inference API Test Suite")
|
| 211 |
+
print("=" * 70)
|
| 212 |
+
|
| 213 |
+
try:
|
| 214 |
+
# Health check first
|
| 215 |
+
test_health()
|
| 216 |
+
|
| 217 |
+
# Basic tests
|
| 218 |
+
test_chat_simple()
|
| 219 |
+
test_chat_with_context()
|
| 220 |
+
test_chat_with_history()
|
| 221 |
+
|
| 222 |
+
# Context validation tests
|
| 223 |
+
test_validate_context_valid()
|
| 224 |
+
test_validate_context_invalid_crop()
|
| 225 |
+
test_validate_context_invalid_area()
|
| 226 |
+
|
| 227 |
+
# Batch test
|
| 228 |
+
test_batch_chat()
|
| 229 |
+
|
| 230 |
+
# Error handling tests
|
| 231 |
+
test_chat_invalid_request()
|
| 232 |
+
test_chat_oversized_request()
|
| 233 |
+
|
| 234 |
+
print("\n" + "=" * 70)
|
| 235 |
+
print("✅ All tests completed!")
|
| 236 |
+
print("=" * 70)
|
| 237 |
+
|
| 238 |
+
except requests.exceptions.ConnectionError:
|
| 239 |
+
print("\n❌ Error: Could not connect to server.")
|
| 240 |
+
print(" Make sure the server is running: python app.py")
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f"\n❌ Unexpected error: {e}")
|
| 243 |
+
import traceback
|
| 244 |
+
traceback.print_exc()
|