ming commited on
Commit
9024ad9
·
0 Parent(s):

chore: initialize FastAPI backend project structure and testing setup

Browse files
.gitignore ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # Virtual environments
25
+ .env
26
+ .venv
27
+ env/
28
+ venv/
29
+ ENV/
30
+ env.bak/
31
+ venv.bak/
32
+
33
+ # IDE
34
+ .vscode/
35
+ .idea/
36
+ *.swp
37
+ *.swo
38
+ *~
39
+
40
+ # Testing
41
+ .pytest_cache/
42
+ .coverage
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+
47
+ # Logs
48
+ *.log
49
+ logs/
50
+
51
+ # OS
52
+ .DS_Store
53
+ .DS_Store?
54
+ ._*
55
+ .Spotlight-V100
56
+ .Trashes
57
+ ehthumbs.db
58
+ Thumbs.db
59
+
60
+ # Docker
61
+ .dockerignore
BACKEND_PLAN.md ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Text Summarizer Backend - Development Plan
2
+
3
+ ## Overview
4
+ A minimal FastAPI backend for text summarization using local Ollama, designed to be callable from an Android app and extensible for cloud hosting.
5
+
6
+ ## Architecture Goals
7
+ - **Local-first**: Use Ollama running locally for privacy and cost control
8
+ - **Cloud-ready**: Structure code to easily deploy to cloud later
9
+ - **Minimal v1**: Focus on core summarization functionality
10
+ - **Android-friendly**: RESTful API optimized for mobile app consumption
11
+
12
+ ## Technology Stack
13
+ - **Backend**: FastAPI + Python
14
+ - **LLM**: Ollama (local)
15
+ - **Server**: Uvicorn
16
+ - **Validation**: Pydantic
17
+ - **Testing**: Pytest + pytest-asyncio + httpx (for async testing)
18
+ - **Containerization**: Docker (for cloud deployment)
19
+
20
+ ## Project Structure
21
+ ```
22
+ app/
23
+ ├── main.py # FastAPI app entry point
24
+ ├── api/
25
+ │ └── v1/
26
+ │ ├── routes.py # API route definitions
27
+ │ └── schemas.py # Pydantic models
28
+ ├── services/
29
+ │ └── summarizer.py # Ollama integration
30
+ ├── core/
31
+ │ ├── config.py # Configuration management
32
+ │ └── logging.py # Logging setup
33
+ tests/
34
+ ├── test_api.py # API endpoint tests
35
+ ├── test_services.py # Service layer tests
36
+ ├── test_schemas.py # Pydantic model tests
37
+ ├── test_config.py # Configuration tests
38
+ └── conftest.py # Test configuration and fixtures
39
+ requirements.txt
40
+ Dockerfile
41
+ docker-compose.yml
42
+ README.md
43
+ ```
44
+
45
+ ## API Contract (v1)
46
+
47
+ ### POST /api/v1/summarize
48
+ **Request:**
49
+ ```json
50
+ {
51
+ "text": "string (required)",
52
+ "max_tokens": 256,
53
+ "prompt": "Summarize concisely."
54
+ }
55
+ ```
56
+
57
+ **Response:**
58
+ ```json
59
+ {
60
+ "summary": "string",
61
+ "model": "llama3.1:8b",
62
+ "tokens_used": 512,
63
+ "latency_ms": 1234
64
+ }
65
+ ```
66
+
67
+ ### GET /health
68
+ **Response:**
69
+ ```json
70
+ {
71
+ "status": "ok",
72
+ "ollama": "reachable"
73
+ }
74
+ ```
75
+
76
+ ## Development Phases
77
+
78
+ ### Phase 1: Foundation
79
+ - [ ] Project scaffold and directory structure
80
+ - [ ] Core dependencies and requirements.txt (including test dependencies)
81
+ - [ ] Basic FastAPI app setup
82
+ - [ ] Configuration management with environment variables
83
+ - [ ] Logging setup
84
+ - [ ] Health check endpoint
85
+ - [ ] Basic test setup and configuration
86
+
87
+ ### Phase 2: Core Feature
88
+ - [ ] Pydantic schemas for request/response
89
+ - [ ] Unit tests for schemas (validation, serialization)
90
+ - [ ] Ollama service integration
91
+ - [ ] Unit tests for Ollama service (mocked)
92
+ - [ ] Summarization endpoint implementation
93
+ - [ ] Integration tests for API endpoints
94
+ - [ ] Input validation and error handling
95
+ - [ ] Basic request/response logging
96
+
97
+ ### Phase 3: Quality & DX
98
+ - [ ] Error handling middleware
99
+ - [ ] Request ID middleware
100
+ - [ ] Input size limits and validation
101
+ - [ ] Rate limiting (optional for v1)
102
+ - [ ] Test coverage analysis and improvement
103
+ - [ ] Performance tests for summarization endpoint
104
+
105
+ ### Phase 4: Cloud-Ready Structure
106
+ - [ ] Dockerfile for containerization
107
+ - [ ] docker-compose.yml for local development
108
+ - [ ] Environment-based configuration
109
+ - [ ] CORS configuration for Android app
110
+ - [ ] Security headers and API key support (optional)
111
+ - [ ] Metrics endpoint (optional)
112
+
113
+ ### Phase 5: Documentation & Examples
114
+ - [ ] Comprehensive README with setup instructions
115
+ - [ ] API documentation (FastAPI auto-docs)
116
+ - [ ] Example curl commands
117
+ - [ ] Android client integration examples
118
+ - [ ] Deployment guide for cloud hosting
119
+
120
+ ## Configuration
121
+
122
+ ### Environment Variables
123
+ ```bash
124
+ # Ollama Configuration
125
+ OLLAMA_MODEL=llama3.1:8b
126
+ OLLAMA_HOST=http://127.0.0.1:11434
127
+ OLLAMA_TIMEOUT=30
128
+
129
+ # Server Configuration
130
+ SERVER_HOST=127.0.0.1
131
+ SERVER_PORT=8000
132
+ LOG_LEVEL=INFO
133
+
134
+ # Optional: API Security
135
+ API_KEY_ENABLED=false
136
+ API_KEY=your-secret-key
137
+
138
+ # Optional: Rate Limiting
139
+ RATE_LIMIT_ENABLED=false
140
+ RATE_LIMIT_REQUESTS=60
141
+ RATE_LIMIT_WINDOW=60
142
+ ```
143
+
144
+ ## Local Development Setup
145
+
146
+ ### Prerequisites
147
+ 1. Install Ollama:
148
+ ```bash
149
+ # macOS
150
+ brew install ollama
151
+
152
+ # Or download from https://ollama.ai
153
+ ```
154
+
155
+ 2. Start Ollama service:
156
+ ```bash
157
+ ollama serve
158
+ ```
159
+
160
+ 3. Pull a model:
161
+ ```bash
162
+ ollama pull llama3.1:8b
163
+ # or
164
+ ollama pull mistral
165
+ ```
166
+
167
+ ### Running the API
168
+ ```bash
169
+ # Create virtual environment
170
+ python -m venv .venv
171
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
172
+
173
+ # Install dependencies
174
+ pip install -r requirements.txt
175
+
176
+ # Set environment variables
177
+ export OLLAMA_MODEL=llama3.1:8b
178
+
179
+ # Run the server
180
+ uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
181
+ ```
182
+
183
+ ### Testing the API
184
+ ```bash
185
+ # Health check
186
+ curl http://127.0.0.1:8000/health
187
+
188
+ # Summarize text
189
+ curl -X POST http://127.0.0.1:8000/api/v1/summarize \
190
+ -H "Content-Type: application/json" \
191
+ -d '{"text": "Your long text to summarize here..."}'
192
+ ```
193
+
194
+ ### Running Tests
195
+ ```bash
196
+ # Run all tests
197
+ pytest
198
+
199
+ # Run tests with coverage
200
+ pytest --cov=app --cov-report=html --cov-report=term
201
+
202
+ # Run specific test file
203
+ pytest tests/test_api.py
204
+
205
+ # Run tests with verbose output
206
+ pytest -v
207
+
208
+ # Run tests and stop on first failure
209
+ pytest -x
210
+ ```
211
+
212
+ ## Testing Strategy
213
+
214
+ ### Test Types
215
+ 1. **Unit Tests**
216
+ - Pydantic model validation
217
+ - Service layer logic (with mocked Ollama)
218
+ - Configuration loading
219
+ - Utility functions
220
+
221
+ 2. **Integration Tests**
222
+ - API endpoint testing with TestClient
223
+ - End-to-end summarization flow
224
+ - Error handling scenarios
225
+ - Health check functionality
226
+
227
+ 3. **Mock Strategy**
228
+ - Mock Ollama HTTP calls using `httpx` or `responses`
229
+ - Mock external dependencies
230
+ - Use fixtures for common test data
231
+
232
+ ### Test Coverage Goals
233
+ - **Minimum 90% code coverage**
234
+ - **100% coverage for critical paths** (API endpoints, error handling)
235
+ - **All edge cases tested** (empty input, large input, network failures)
236
+
237
+ ### Test Data
238
+ ```python
239
+ # Example test fixtures
240
+ SAMPLE_TEXT = "This is a long text that needs to be summarized..."
241
+ SAMPLE_SUMMARY = "This text discusses summarization."
242
+ MOCK_OLLAMA_RESPONSE = {
243
+ "model": "llama3.1:8b",
244
+ "response": SAMPLE_SUMMARY,
245
+ "done": True
246
+ }
247
+ ```
248
+
249
+ ### Continuous Testing
250
+ - Tests run on every code change
251
+ - Pre-commit hooks for test execution
252
+ - CI/CD pipeline integration ready
253
+
254
+ ## Android Integration
255
+
256
+ ### Example Android HTTP Client
257
+ ```kotlin
258
+ // Using Retrofit or OkHttp
259
+ data class SummarizeRequest(
260
+ val text: String,
261
+ val max_tokens: Int = 256,
262
+ val prompt: String = "Summarize concisely."
263
+ )
264
+
265
+ data class SummarizeResponse(
266
+ val summary: String,
267
+ val model: String,
268
+ val tokens_used: Int,
269
+ val latency_ms: Int
270
+ )
271
+
272
+ // API call
273
+ @POST("api/v1/summarize")
274
+ suspend fun summarize(@Body request: SummarizeRequest): SummarizeResponse
275
+ ```
276
+
277
+ ## Cloud Deployment Considerations
278
+
279
+ ### Future Extensions
280
+ - **Authentication**: API key or OAuth2
281
+ - **Rate Limiting**: Redis-based distributed rate limiting
282
+ - **Monitoring**: Prometheus metrics, health checks
283
+ - **Scaling**: Multiple replicas, load balancing
284
+ - **Database**: Usage tracking, user management
285
+ - **Caching**: Redis for response caching
286
+ - **Security**: HTTPS, input sanitization, CORS policies
287
+
288
+ ### Deployment Options
289
+ - **Docker**: Containerized deployment
290
+ - **Cloud Platforms**: AWS, GCP, Azure, Railway, Render
291
+ - **Serverless**: AWS Lambda, Vercel Functions (with Ollama API)
292
+ - **VPS**: DigitalOcean, Linode with Docker
293
+
294
+ ## Success Criteria
295
+ - [ ] API responds to health checks
296
+ - [ ] Successfully summarizes text via Ollama
297
+ - [ ] Handles errors gracefully
298
+ - [ ] Works with Android app
299
+ - [ ] Can be containerized
300
+ - [ ] **All tests pass with >90% coverage**
301
+ - [ ] Documentation is complete
302
+
303
+ ## Future Enhancements (Post-v1)
304
+ - [ ] Streaming responses
305
+ - [ ] Batch summarization
306
+ - [ ] Multiple model support
307
+ - [ ] Prompt templates and presets
308
+ - [ ] Usage analytics
309
+ - [ ] Multi-language support
310
+ - [ ] Advanced rate limiting
311
+ - [ ] User authentication and authorization
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Text Summarizer Backend API
app/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # API package
app/api/v1/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # API v1 package
app/api/v1/routes.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API v1 routes for the text summarizer backend.
3
+ """
4
+ from fastapi import APIRouter
5
+
6
+ # Create API router
7
+ api_router = APIRouter()
8
+
9
+ # Import and include route modules here
10
+ # from .endpoints import summarize, health
11
+
12
+ # api_router.include_router(summarize.router, prefix="/summarize", tags=["summarize"])
13
+ # api_router.include_router(health.router, prefix="/health", tags=["health"])
app/api/v1/schemas.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic schemas for API request/response models.
3
+ """
4
+ from typing import Optional
5
+ from pydantic import BaseModel, Field, validator
6
+
7
+
8
+ class SummarizeRequest(BaseModel):
9
+ """Request schema for text summarization."""
10
+
11
+ text: str = Field(..., min_length=1, max_length=32000, description="Text to summarize")
12
+ max_tokens: Optional[int] = Field(default=256, ge=1, le=2048, description="Maximum tokens for summary")
13
+ prompt: Optional[str] = Field(
14
+ default="Summarize the following text concisely:",
15
+ max_length=500,
16
+ description="Custom prompt for summarization"
17
+ )
18
+
19
+ @validator('text')
20
+ def validate_text(cls, v):
21
+ """Validate text input."""
22
+ if not v.strip():
23
+ raise ValueError("Text cannot be empty or only whitespace")
24
+ return v.strip()
25
+
26
+
27
+ class SummarizeResponse(BaseModel):
28
+ """Response schema for text summarization."""
29
+
30
+ summary: str = Field(..., description="Generated summary")
31
+ model: str = Field(..., description="Model used for summarization")
32
+ tokens_used: Optional[int] = Field(None, description="Number of tokens used")
33
+ latency_ms: Optional[float] = Field(None, description="Processing time in milliseconds")
34
+
35
+
36
+ class HealthResponse(BaseModel):
37
+ """Response schema for health check."""
38
+
39
+ status: str = Field(..., description="Service status")
40
+ service: str = Field(..., description="Service name")
41
+ version: str = Field(..., description="Service version")
42
+ ollama: Optional[str] = Field(None, description="Ollama service status")
43
+
44
+
45
+ class ErrorResponse(BaseModel):
46
+ """Error response schema."""
47
+
48
+ detail: str = Field(..., description="Error message")
49
+ code: Optional[str] = Field(None, description="Error code")
50
+ request_id: Optional[str] = Field(None, description="Request ID for tracking")
app/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Core package
app/core/config.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration management for the text summarizer backend.
3
+ """
4
+ import os
5
+ from typing import Optional
6
+ from pydantic import BaseSettings, Field
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ """Application settings loaded from environment variables."""
11
+
12
+ # Ollama Configuration
13
+ ollama_model: str = Field(default="llama3.1:8b", env="OLLAMA_MODEL")
14
+ ollama_host: str = Field(default="http://127.0.0.1:11434", env="OLLAMA_HOST")
15
+ ollama_timeout: int = Field(default=30, env="OLLAMA_TIMEOUT")
16
+
17
+ # Server Configuration
18
+ server_host: str = Field(default="127.0.0.1", env="SERVER_HOST")
19
+ server_port: int = Field(default=8000, env="SERVER_PORT")
20
+ log_level: str = Field(default="INFO", env="LOG_LEVEL")
21
+
22
+ # Optional: API Security
23
+ api_key_enabled: bool = Field(default=False, env="API_KEY_ENABLED")
24
+ api_key: Optional[str] = Field(default=None, env="API_KEY")
25
+
26
+ # Optional: Rate Limiting
27
+ rate_limit_enabled: bool = Field(default=False, env="RATE_LIMIT_ENABLED")
28
+ rate_limit_requests: int = Field(default=60, env="RATE_LIMIT_REQUESTS")
29
+ rate_limit_window: int = Field(default=60, env="RATE_LIMIT_WINDOW")
30
+
31
+ # Input validation
32
+ max_text_length: int = Field(default=32000, env="MAX_TEXT_LENGTH") # ~32KB
33
+ max_tokens_default: int = Field(default=256, env="MAX_TOKENS_DEFAULT")
34
+
35
+ class Config:
36
+ env_file = ".env"
37
+ case_sensitive = False
38
+
39
+
40
+ # Global settings instance
41
+ settings = Settings()
app/core/logging.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Logging configuration for the text summarizer backend.
3
+ """
4
+ import logging
5
+ import sys
6
+ from typing import Any, Dict
7
+ from app.core.config import settings
8
+
9
+
10
+ def setup_logging() -> None:
11
+ """Set up logging configuration."""
12
+ logging.basicConfig(
13
+ level=getattr(logging, settings.log_level.upper()),
14
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
15
+ handlers=[
16
+ logging.StreamHandler(sys.stdout),
17
+ ]
18
+ )
19
+
20
+
21
+ def get_logger(name: str) -> logging.Logger:
22
+ """Get a logger instance."""
23
+ return logging.getLogger(name)
24
+
25
+
26
+ class RequestLogger:
27
+ """Logger for request/response logging."""
28
+
29
+ def __init__(self, logger: logging.Logger):
30
+ self.logger = logger
31
+
32
+ def log_request(self, method: str, path: str, request_id: str, **kwargs: Any) -> None:
33
+ """Log incoming request."""
34
+ self.logger.info(
35
+ f"Request {request_id}: {method} {path}",
36
+ extra={"request_id": request_id, "method": method, "path": path, **kwargs}
37
+ )
38
+
39
+ def log_response(self, request_id: str, status_code: int, duration_ms: float, **kwargs: Any) -> None:
40
+ """Log response."""
41
+ self.logger.info(
42
+ f"Response {request_id}: {status_code} ({duration_ms:.2f}ms)",
43
+ extra={"request_id": request_id, "status_code": status_code, "duration_ms": duration_ms, **kwargs}
44
+ )
45
+
46
+ def log_error(self, request_id: str, error: str, **kwargs: Any) -> None:
47
+ """Log error."""
48
+ self.logger.error(
49
+ f"Error {request_id}: {error}",
50
+ extra={"request_id": request_id, "error": error, **kwargs}
51
+ )
app/main.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main FastAPI application for text summarizer backend.
3
+ """
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+
7
+ from app.core.config import settings
8
+ from app.core.logging import setup_logging, get_logger
9
+ from app.api.v1.routes import api_router
10
+
11
+ # Set up logging
12
+ setup_logging()
13
+ logger = get_logger(__name__)
14
+
15
+ # Create FastAPI app
16
+ app = FastAPI(
17
+ title="Text Summarizer API",
18
+ description="A FastAPI backend for text summarization using Ollama",
19
+ version="1.0.0",
20
+ docs_url="/docs",
21
+ redoc_url="/redoc",
22
+ )
23
+
24
+ # Add CORS middleware
25
+ app.add_middleware(
26
+ CORSMiddleware,
27
+ allow_origins=["*"], # Configure appropriately for production
28
+ allow_credentials=True,
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+ # Include API routes
34
+ app.include_router(api_router, prefix="/api/v1")
35
+
36
+
37
+ @app.on_event("startup")
38
+ async def startup_event():
39
+ """Application startup event."""
40
+ logger.info("Starting Text Summarizer API")
41
+ logger.info(f"Ollama host: {settings.ollama_host}")
42
+ logger.info(f"Ollama model: {settings.ollama_model}")
43
+
44
+
45
+ @app.on_event("shutdown")
46
+ async def shutdown_event():
47
+ """Application shutdown event."""
48
+ logger.info("Shutting down Text Summarizer API")
49
+
50
+
51
+ @app.get("/")
52
+ async def root():
53
+ """Root endpoint."""
54
+ return {
55
+ "message": "Text Summarizer API",
56
+ "version": "1.0.0",
57
+ "docs": "/docs"
58
+ }
59
+
60
+
61
+ @app.get("/health")
62
+ async def health_check():
63
+ """Health check endpoint."""
64
+ return {
65
+ "status": "ok",
66
+ "service": "text-summarizer-api",
67
+ "version": "1.0.0"
68
+ }
app/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Services package
app/services/summarizer.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ollama service integration for text summarization.
3
+ """
4
+ import time
5
+ from typing import Dict, Any, Optional
6
+ import httpx
7
+ from app.core.config import settings
8
+ from app.core.logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class OllamaService:
14
+ """Service for interacting with Ollama API."""
15
+
16
+ def __init__(self):
17
+ self.base_url = settings.ollama_host
18
+ self.model = settings.ollama_model
19
+ self.timeout = settings.ollama_timeout
20
+
21
+ async def summarize_text(
22
+ self,
23
+ text: str,
24
+ max_tokens: int = 256,
25
+ prompt: str = "Summarize the following text concisely:"
26
+ ) -> Dict[str, Any]:
27
+ """
28
+ Summarize text using Ollama.
29
+
30
+ Args:
31
+ text: Text to summarize
32
+ max_tokens: Maximum tokens for summary
33
+ prompt: Custom prompt for summarization
34
+
35
+ Returns:
36
+ Dictionary containing summary and metadata
37
+
38
+ Raises:
39
+ httpx.HTTPError: If Ollama API call fails
40
+ """
41
+ start_time = time.time()
42
+
43
+ # Prepare the full prompt
44
+ full_prompt = f"{prompt}\n\n{text}"
45
+
46
+ # Prepare request payload
47
+ payload = {
48
+ "model": self.model,
49
+ "prompt": full_prompt,
50
+ "stream": False,
51
+ "options": {
52
+ "num_predict": max_tokens,
53
+ "temperature": 0.3, # Lower temperature for more consistent summaries
54
+ }
55
+ }
56
+
57
+ try:
58
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
59
+ response = await client.post(
60
+ f"{self.base_url}/api/generate",
61
+ json=payload
62
+ )
63
+ response.raise_for_status()
64
+
65
+ result = response.json()
66
+
67
+ # Calculate processing time
68
+ latency_ms = (time.time() - start_time) * 1000
69
+
70
+ return {
71
+ "summary": result.get("response", "").strip(),
72
+ "model": self.model,
73
+ "tokens_used": result.get("eval_count", 0),
74
+ "latency_ms": round(latency_ms, 2)
75
+ }
76
+
77
+ except httpx.TimeoutException:
78
+ logger.error(f"Timeout calling Ollama API after {self.timeout}s")
79
+ raise httpx.HTTPError("Ollama API timeout")
80
+ except httpx.HTTPError as e:
81
+ logger.error(f"HTTP error calling Ollama API: {e}")
82
+ raise
83
+ except Exception as e:
84
+ logger.error(f"Unexpected error calling Ollama API: {e}")
85
+ raise httpx.HTTPError(f"Ollama API error: {str(e)}")
86
+
87
+ async def check_health(self) -> bool:
88
+ """
89
+ Check if Ollama service is available.
90
+
91
+ Returns:
92
+ True if Ollama is reachable, False otherwise
93
+ """
94
+ try:
95
+ async with httpx.AsyncClient(timeout=5) as client:
96
+ response = await client.get(f"{self.base_url}/api/tags")
97
+ return response.status_code == 200
98
+ except Exception as e:
99
+ logger.warning(f"Ollama health check failed: {e}")
100
+ return False
101
+
102
+
103
+ # Global service instance
104
+ ollama_service = OllamaService()
pytest.ini ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool:pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ python_classes = Test*
5
+ python_functions = test_*
6
+ addopts =
7
+ -v
8
+ --tb=short
9
+ --strict-markers
10
+ --disable-warnings
11
+ --cov=app
12
+ --cov-report=term-missing
13
+ --cov-report=html:htmlcov
14
+ --cov-fail-under=90
15
+ markers =
16
+ unit: Unit tests
17
+ integration: Integration tests
18
+ slow: Slow running tests
19
+ ollama: Tests that require Ollama service
20
+ asyncio_mode = auto
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI and server
2
+ fastapi>=0.95.0,<0.100.0
3
+ uvicorn[standard]>=0.20.0,<0.25.0
4
+
5
+ # HTTP client for Ollama
6
+ httpx>=0.24.0,<0.26.0
7
+
8
+ # Data validation
9
+ pydantic>=1.10.0,<2.0.0
10
+
11
+ # Environment management
12
+ python-dotenv>=0.19.0,<1.0.0
13
+
14
+ # Testing
15
+ pytest>=7.0.0,<8.0.0
16
+ pytest-asyncio>=0.20.0,<0.22.0
17
+ pytest-cov>=4.0.0,<5.0.0
18
+ pytest-mock>=3.10.0,<4.0.0
19
+
20
+ # Development tools
21
+ black>=22.0.0,<24.0.0
22
+ isort>=5.10.0,<6.0.0
23
+ flake8>=5.0.0,<7.0.0
24
+
25
+ # Optional: for better performance
26
+ uvloop>=0.17.0,<0.20.0
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Tests package
tests/conftest.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test configuration and fixtures for the text summarizer backend.
3
+ """
4
+ import pytest
5
+ import asyncio
6
+ from typing import AsyncGenerator, Generator
7
+ from httpx import AsyncClient
8
+ from fastapi.testclient import TestClient
9
+
10
+ from app.main import app
11
+
12
+
13
+ @pytest.fixture(scope="session")
14
+ def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
15
+ """Create an instance of the default event loop for the test session."""
16
+ loop = asyncio.get_event_loop_policy().new_event_loop()
17
+ yield loop
18
+ loop.close()
19
+
20
+
21
+ @pytest.fixture
22
+ def client() -> TestClient:
23
+ """Create a test client for FastAPI app."""
24
+ return TestClient(app)
25
+
26
+
27
+ @pytest.fixture
28
+ async def async_client() -> AsyncGenerator[AsyncClient, None]:
29
+ """Create an async test client for FastAPI app."""
30
+ async with AsyncClient(app=app, base_url="http://test") as ac:
31
+ yield ac
32
+
33
+
34
+ # Test data fixtures
35
+ @pytest.fixture
36
+ def sample_text() -> str:
37
+ """Sample text for testing summarization."""
38
+ return """
39
+ Artificial intelligence (AI) is intelligence demonstrated by machines,
40
+ in contrast to the natural intelligence displayed by humans and animals.
41
+ Leading AI textbooks define the field as the study of "intelligent agents":
42
+ any device that perceives its environment and takes actions that maximize
43
+ its chance of successfully achieving its goals. The term "artificial intelligence"
44
+ is often used to describe machines that mimic "cognitive" functions that humans
45
+ associate with the human mind, such as "learning" and "problem solving".
46
+ """
47
+
48
+
49
+ @pytest.fixture
50
+ def sample_summary() -> str:
51
+ """Expected summary for sample text."""
52
+ return "AI is machine intelligence that mimics human cognitive functions like learning and problem-solving."
53
+
54
+
55
+ @pytest.fixture
56
+ def mock_ollama_response() -> dict:
57
+ """Mock response from Ollama API."""
58
+ return {
59
+ "model": "llama3.1:8b",
60
+ "response": "AI is machine intelligence that mimics human cognitive functions like learning and problem-solving.",
61
+ "done": True,
62
+ "context": [],
63
+ "total_duration": 1234567890,
64
+ "load_duration": 123456789,
65
+ "prompt_eval_count": 50,
66
+ "prompt_eval_duration": 123456789,
67
+ "eval_count": 20,
68
+ "eval_duration": 123456789
69
+ }
70
+
71
+
72
+ @pytest.fixture
73
+ def empty_text() -> str:
74
+ """Empty text for testing validation."""
75
+ return ""
76
+
77
+
78
+ @pytest.fixture
79
+ def very_long_text() -> str:
80
+ """Very long text for testing size limits."""
81
+ return "This is a test. " * 1000 # ~15KB of text
82
+
83
+
84
+ # Environment fixtures
85
+ @pytest.fixture
86
+ def test_env_vars(monkeypatch):
87
+ """Set test environment variables."""
88
+ monkeypatch.setenv("OLLAMA_MODEL", "llama3.1:8b")
89
+ monkeypatch.setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
90
+ monkeypatch.setenv("OLLAMA_TIMEOUT", "30")
91
+ monkeypatch.setenv("SERVER_HOST", "127.0.0.1")
92
+ monkeypatch.setenv("SERVER_PORT", "8000")
93
+ monkeypatch.setenv("LOG_LEVEL", "INFO")
tests/test_config.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for configuration management.
3
+ """
4
+ import pytest
5
+ from app.core.config import Settings, settings
6
+
7
+
8
+ class TestSettings:
9
+ """Test configuration settings."""
10
+
11
+ def test_default_settings(self):
12
+ """Test default configuration values."""
13
+ test_settings = Settings()
14
+
15
+ assert test_settings.ollama_model == "llama3.1:8b"
16
+ assert test_settings.ollama_host == "http://127.0.0.1:11434"
17
+ assert test_settings.ollama_timeout == 30
18
+ assert test_settings.server_host == "127.0.0.1"
19
+ assert test_settings.server_port == 8000
20
+ assert test_settings.log_level == "INFO"
21
+ assert test_settings.api_key_enabled is False
22
+ assert test_settings.rate_limit_enabled is False
23
+ assert test_settings.max_text_length == 32000
24
+ assert test_settings.max_tokens_default == 256
25
+
26
+ def test_environment_override(self, test_env_vars):
27
+ """Test that environment variables override defaults."""
28
+ test_settings = Settings()
29
+
30
+ assert test_settings.ollama_model == "llama3.1:8b"
31
+ assert test_settings.ollama_host == "http://127.0.0.1:11434"
32
+ assert test_settings.ollama_timeout == 30
33
+ assert test_settings.server_host == "127.0.0.1"
34
+ assert test_settings.server_port == 8000
35
+ assert test_settings.log_level == "INFO"
36
+
37
+ def test_global_settings_instance(self):
38
+ """Test that global settings instance exists."""
39
+ assert settings is not None
40
+ assert isinstance(settings, Settings)
tests/test_main.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for main FastAPI application.
3
+ """
4
+ import pytest
5
+ from fastapi.testclient import TestClient
6
+ from app.main import app
7
+
8
+
9
+ class TestMainApp:
10
+ """Test main FastAPI application."""
11
+
12
+ def test_root_endpoint(self, client):
13
+ """Test root endpoint."""
14
+ response = client.get("/")
15
+
16
+ assert response.status_code == 200
17
+ data = response.json()
18
+ assert data["message"] == "Text Summarizer API"
19
+ assert data["version"] == "1.0.0"
20
+ assert data["docs"] == "/docs"
21
+
22
+ def test_health_endpoint(self, client):
23
+ """Test health check endpoint."""
24
+ response = client.get("/health")
25
+
26
+ assert response.status_code == 200
27
+ data = response.json()
28
+ assert data["status"] == "ok"
29
+ assert data["service"] == "text-summarizer-api"
30
+ assert data["version"] == "1.0.0"
31
+
32
+ def test_docs_endpoint(self, client):
33
+ """Test that docs endpoint is accessible."""
34
+ response = client.get("/docs")
35
+ assert response.status_code == 200
36
+
37
+ def test_redoc_endpoint(self, client):
38
+ """Test that redoc endpoint is accessible."""
39
+ response = client.get("/redoc")
40
+ assert response.status_code == 200
tests/test_schemas.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Pydantic schemas.
3
+ """
4
+ import pytest
5
+ from pydantic import ValidationError
6
+ from app.api.v1.schemas import SummarizeRequest, SummarizeResponse, HealthResponse, ErrorResponse
7
+
8
+
9
+ class TestSummarizeRequest:
10
+ """Test SummarizeRequest schema."""
11
+
12
+ def test_valid_request(self, sample_text):
13
+ """Test valid request creation."""
14
+ request = SummarizeRequest(text=sample_text)
15
+
16
+ assert request.text == sample_text.strip()
17
+ assert request.max_tokens == 256
18
+ assert request.prompt == "Summarize the following text concisely:"
19
+
20
+ def test_custom_parameters(self):
21
+ """Test request with custom parameters."""
22
+ text = "Test text"
23
+ request = SummarizeRequest(
24
+ text=text,
25
+ max_tokens=512,
26
+ prompt="Custom prompt"
27
+ )
28
+
29
+ assert request.text == text
30
+ assert request.max_tokens == 512
31
+ assert request.prompt == "Custom prompt"
32
+
33
+ def test_empty_text_validation(self):
34
+ """Test validation of empty text."""
35
+ with pytest.raises(ValidationError) as exc_info:
36
+ SummarizeRequest(text="")
37
+
38
+ # Check that validation error occurs (Pydantic v1 uses different error messages)
39
+ assert "ensure this value has at least 1 characters" in str(exc_info.value)
40
+
41
+ def test_whitespace_only_text_validation(self):
42
+ """Test validation of whitespace-only text."""
43
+ with pytest.raises(ValidationError) as exc_info:
44
+ SummarizeRequest(text=" \n\t ")
45
+
46
+ assert "Text cannot be empty" in str(exc_info.value)
47
+
48
+ def test_text_stripping(self):
49
+ """Test that text is stripped of leading/trailing whitespace."""
50
+ text = " Test text "
51
+ request = SummarizeRequest(text=text)
52
+
53
+ assert request.text == "Test text"
54
+
55
+ def test_max_tokens_validation(self):
56
+ """Test max_tokens validation."""
57
+ # Valid range
58
+ request = SummarizeRequest(text="test", max_tokens=1)
59
+ assert request.max_tokens == 1
60
+
61
+ request = SummarizeRequest(text="test", max_tokens=2048)
62
+ assert request.max_tokens == 2048
63
+
64
+ # Invalid range
65
+ with pytest.raises(ValidationError):
66
+ SummarizeRequest(text="test", max_tokens=0)
67
+
68
+ with pytest.raises(ValidationError):
69
+ SummarizeRequest(text="test", max_tokens=2049)
70
+
71
+ def test_prompt_length_validation(self):
72
+ """Test prompt length validation."""
73
+ long_prompt = "x" * 501
74
+ with pytest.raises(ValidationError):
75
+ SummarizeRequest(text="test", prompt=long_prompt)
76
+
77
+
78
+ class TestSummarizeResponse:
79
+ """Test SummarizeResponse schema."""
80
+
81
+ def test_valid_response(self, sample_summary):
82
+ """Test valid response creation."""
83
+ response = SummarizeResponse(
84
+ summary=sample_summary,
85
+ model="llama3.1:8b",
86
+ tokens_used=50,
87
+ latency_ms=1234.5
88
+ )
89
+
90
+ assert response.summary == sample_summary
91
+ assert response.model == "llama3.1:8b"
92
+ assert response.tokens_used == 50
93
+ assert response.latency_ms == 1234.5
94
+
95
+ def test_minimal_response(self):
96
+ """Test response with minimal required fields."""
97
+ response = SummarizeResponse(
98
+ summary="Test summary",
99
+ model="test-model"
100
+ )
101
+
102
+ assert response.summary == "Test summary"
103
+ assert response.model == "test-model"
104
+ assert response.tokens_used is None
105
+ assert response.latency_ms is None
106
+
107
+
108
+ class TestHealthResponse:
109
+ """Test HealthResponse schema."""
110
+
111
+ def test_valid_health_response(self):
112
+ """Test valid health response creation."""
113
+ response = HealthResponse(
114
+ status="ok",
115
+ service="text-summarizer-api",
116
+ version="1.0.0",
117
+ ollama="reachable"
118
+ )
119
+
120
+ assert response.status == "ok"
121
+ assert response.service == "text-summarizer-api"
122
+ assert response.version == "1.0.0"
123
+ assert response.ollama == "reachable"
124
+
125
+
126
+ class TestErrorResponse:
127
+ """Test ErrorResponse schema."""
128
+
129
+ def test_valid_error_response(self):
130
+ """Test valid error response creation."""
131
+ response = ErrorResponse(
132
+ detail="Something went wrong",
133
+ code="INTERNAL_ERROR",
134
+ request_id="req-123"
135
+ )
136
+
137
+ assert response.detail == "Something went wrong"
138
+ assert response.code == "INTERNAL_ERROR"
139
+ assert response.request_id == "req-123"
140
+
141
+ def test_minimal_error_response(self):
142
+ """Test error response with minimal fields."""
143
+ response = ErrorResponse(detail="Error occurred")
144
+
145
+ assert response.detail == "Error occurred"
146
+ assert response.code is None
147
+ assert response.request_id is None
tests/test_services.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for service layer.
3
+ """
4
+ import pytest
5
+ from unittest.mock import patch, MagicMock
6
+ import httpx
7
+ from app.services.summarizer import OllamaService
8
+
9
+
10
+ class StubAsyncResponse:
11
+ """A minimal stub of an httpx.Response-like object for testing."""
12
+
13
+ def __init__(self, json_data=None, status_code=200, raise_for_status_exc=None):
14
+ self._json_data = json_data or {}
15
+ self.status_code = status_code
16
+ self._raise_for_status_exc = raise_for_status_exc
17
+
18
+ def json(self):
19
+ return self._json_data
20
+
21
+ def raise_for_status(self):
22
+ if self._raise_for_status_exc is not None:
23
+ raise self._raise_for_status_exc
24
+
25
+
26
+ class StubAsyncClient:
27
+ """An async context manager stub that mimics httpx.AsyncClient for tests."""
28
+
29
+ def __init__(self, post_result=None, post_exc=None, get_result=None, get_exc=None, *args, **kwargs):
30
+ self._post_result = post_result
31
+ self._post_exc = post_exc
32
+ self._get_result = get_result
33
+ self._get_exc = get_exc
34
+
35
+ async def __aenter__(self):
36
+ return self
37
+
38
+ async def __aexit__(self, exc_type, exc, tb):
39
+ return False
40
+
41
+ async def post(self, *args, **kwargs):
42
+ if self._post_exc is not None:
43
+ raise self._post_exc
44
+ return self._post_result or StubAsyncResponse()
45
+
46
+ async def get(self, *args, **kwargs):
47
+ if self._get_exc is not None:
48
+ raise self._get_exc
49
+ return self._get_result or StubAsyncResponse(status_code=200)
50
+
51
+
52
+ class TestOllamaService:
53
+ """Test Ollama service."""
54
+
55
+ @pytest.fixture
56
+ def ollama_service(self):
57
+ """Create Ollama service instance."""
58
+ return OllamaService()
59
+
60
+ def test_service_initialization(self, ollama_service):
61
+ """Test service initialization."""
62
+ assert ollama_service.base_url == "http://127.0.0.1:11434"
63
+ assert ollama_service.model == "llama3.1:8b"
64
+ assert ollama_service.timeout == 30
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_summarize_text_success(self, ollama_service, mock_ollama_response):
68
+ """Test successful text summarization."""
69
+ stub_response = StubAsyncResponse(json_data=mock_ollama_response)
70
+ with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_result=stub_response)):
71
+ result = await ollama_service.summarize_text("Test text")
72
+
73
+ assert result["summary"] == mock_ollama_response["response"]
74
+ assert result["model"] == "llama3.1:8b"
75
+ assert result["tokens_used"] == mock_ollama_response["eval_count"]
76
+ assert "latency_ms" in result
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_summarize_text_with_custom_params(self, ollama_service, mock_ollama_response):
80
+ """Test summarization with custom parameters."""
81
+ stub_response = StubAsyncResponse(json_data=mock_ollama_response)
82
+ # Patch with a factory to capture payload for assertion
83
+ captured = {}
84
+
85
+ class CapturePostClient(StubAsyncClient):
86
+ async def post(self, *args, **kwargs):
87
+ captured['json'] = kwargs.get('json')
88
+ return await super().post(*args, **kwargs)
89
+
90
+ with patch('httpx.AsyncClient', return_value=CapturePostClient(post_result=stub_response)):
91
+ result = await ollama_service.summarize_text(
92
+ "Test text",
93
+ max_tokens=512,
94
+ prompt="Custom prompt"
95
+ )
96
+
97
+ assert result["summary"] == mock_ollama_response["response"]
98
+ # Verify captured payload
99
+ payload = captured['json']
100
+ assert payload["options"]["num_predict"] == 512
101
+ assert "Custom prompt" in payload["prompt"]
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_summarize_text_timeout(self, ollama_service):
105
+ """Test timeout handling."""
106
+ with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_exc=httpx.TimeoutException("Timeout"))):
107
+ with pytest.raises(httpx.HTTPError, match="Ollama API timeout"):
108
+ await ollama_service.summarize_text("Test text")
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_summarize_text_http_error(self, ollama_service):
112
+ """Test HTTP error handling."""
113
+ http_error = httpx.HTTPStatusError("Bad Request", request=MagicMock(), response=MagicMock())
114
+ stub_response = StubAsyncResponse(raise_for_status_exc=http_error)
115
+ with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_result=stub_response)):
116
+ with pytest.raises(httpx.HTTPError):
117
+ await ollama_service.summarize_text("Test text")
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_check_health_success(self, ollama_service):
121
+ """Test successful health check."""
122
+ stub_response = StubAsyncResponse(status_code=200)
123
+ with patch('httpx.AsyncClient', return_value=StubAsyncClient(get_result=stub_response)):
124
+ result = await ollama_service.check_health()
125
+ assert result is True
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_check_health_failure(self, ollama_service):
129
+ """Test health check failure."""
130
+ with patch('httpx.AsyncClient', return_value=StubAsyncClient(get_exc=httpx.HTTPError("Connection failed"))):
131
+ result = await ollama_service.check_health()
132
+ assert result is False