From 8a78be22baacb62309ba161208216768599e4e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Br=C3=A4unlein?= Date: Tue, 15 Jul 2025 00:31:20 +0200 Subject: [PATCH 01/19] add local and docker llm because of better local testing --- .env.backup | 15 +++ LOCAL_OLLAMA_TESTING.md | 153 +++++++++++++++++++++++++++++ docker-compose.local-ollama.yml | 99 +++++++++++++++++++ src/services/openproject_client.py | 2 +- test-local-ollama.sh | 118 ++++++++++++++++++++++ test_project_status_report.py | 2 +- 6 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 .env.backup create mode 100644 LOCAL_OLLAMA_TESTING.md create mode 100644 docker-compose.local-ollama.yml create mode 100755 test-local-ollama.sh diff --git a/.env.backup b/.env.backup new file mode 100644 index 0000000..02b16d3 --- /dev/null +++ b/.env.backup @@ -0,0 +1,15 @@ +# Temporary configuration to test local Ollama +# This overrides the default settings to use local Ollama instead of Docker + +# Ollama Configuration - pointing to local instance +OLLAMA_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=mistral:latest + +# Keep other settings from .env.example +LOG_LEVEL=INFO +MODELS_TO_PULL=mistral:latest +REQUIRED_MODELS=mistral:latest +GENERATION_NUM_PREDICT=1000 +GENERATION_TEMPERATURE=0.7 +API_HOST=0.0.0.0 +API_PORT=8000 diff --git a/LOCAL_OLLAMA_TESTING.md b/LOCAL_OLLAMA_TESTING.md new file mode 100644 index 0000000..1d2e910 --- /dev/null +++ b/LOCAL_OLLAMA_TESTING.md @@ -0,0 +1,153 @@ +# Local Ollama Testing Setup + +This document explains how to test your application with your local Ollama instance instead of the Docker container for performance comparison. + +## Quick Start + +### Option 1: Using the Test Script (Recommended) + +```bash +# Test with local Ollama +./test-local-ollama.sh local + +# Test with Docker Ollama +./test-local-ollama.sh docker + +# Restore original configuration +./test-local-ollama.sh restore +``` + +### Option 2: Manual Setup + +#### Test with Local Ollama +```bash +# Make sure your local Ollama is running +ollama serve # if not already running + +# Start only the API service with local Ollama configuration +docker-compose -f docker-compose.local-ollama.yml up --build api +``` + +#### Test with Docker Ollama +```bash +# Temporarily disable local configuration +mv .env .env.backup + +# Start full Docker stack +docker-compose up --build +``` + +## Files Created for Testing + +- **`.env`** - Environment configuration pointing to local Ollama +- **`docker-compose.local-ollama.yml`** - Docker compose without Ollama services +- **`test-local-ollama.sh`** - Helper script for easy switching +- **`LOCAL_OLLAMA_TESTING.md`** - This documentation + +## Configuration Details + +### Local Ollama Configuration +- **Ollama URL**: `http://host.docker.internal:11434` +- **Network**: Uses Docker's `host.docker.internal` to access host machine +- **Dependencies**: Removes Docker Ollama service dependencies + +### Performance Benefits +- **No Docker overhead** for Ollama +- **Direct host access** to your local Ollama instance +- **Faster startup** - no need to initialize Ollama container +- **Resource efficiency** - saves 6-8GB Docker memory allocation + +## Prerequisites + +1. **Local Ollama installed and running**: + ```bash + # Check if Ollama is running + curl http://localhost:11434/api/tags + + # Start Ollama if needed + ollama serve + ``` + +2. **Required models available locally**: + ```bash + # Pull required model if not available + ollama pull mistral:latest + ``` + +## Testing Your Application + +1. **Start with local Ollama**: + ```bash + ./test-local-ollama.sh local + ``` + +2. **Test your API endpoints** (in another terminal): + ```bash + # Health check + curl http://localhost:8000/health + + # Test generation endpoint (adjust as needed) + curl -X POST http://localhost:8000/generate \ + -H "Content-Type: application/json" \ + -d '{"prompt": "Test prompt"}' + ``` + +3. **Compare with Docker Ollama**: + ```bash + # Stop local test + Ctrl+C + + # Test with Docker Ollama + ./test-local-ollama.sh docker + ``` + +4. **Restore original setup**: + ```bash + ./test-local-ollama.sh restore + ``` + +## Troubleshooting + +### Local Ollama Not Accessible +- Ensure Ollama is running: `ollama serve` +- Check port 11434 is available: `lsof -i :11434` +- Verify models are available: `ollama list` + +### Docker Network Issues +- On some systems, use `host.docker.internal` +- On Linux, you might need `172.17.0.1` or `localhost` +- If you get "Failed to resolve 'ollama'" errors, the environment variables aren't being loaded properly + +### Environment Variable Issues +- Ensure the `.env` file exists and contains `OLLAMA_URL=http://host.docker.internal:11434` +- The docker-compose file now includes both `env_file` and explicit `environment` settings +- Check container logs: `docker-compose -f docker-compose.local-ollama.yml logs api` + +### Permission Issues +- Make script executable: `chmod +x test-local-ollama.sh` + +### Connection Issues +- Test local Ollama directly: `curl http://localhost:11434/api/tags` +- Test from container perspective: `docker run --rm curlimages/curl curl http://host.docker.internal:11434/api/tags` + +## Cleanup + +To completely remove the testing setup: + +```bash +# Remove testing files +rm .env docker-compose.local-ollama.yml test-local-ollama.sh LOCAL_OLLAMA_TESTING.md + +# Remove any backup files +rm .env.backup 2>/dev/null || true +``` + +## Performance Comparison + +When testing, pay attention to: +- **Startup time** - Local should be much faster +- **Response latency** - Local should have lower latency +- **Memory usage** - Local uses less Docker memory +- **CPU utilization** - May vary depending on your setup + +The local Ollama setup should provide noticeably better performance, especially for development and testing workflows. diff --git a/docker-compose.local-ollama.yml b/docker-compose.local-ollama.yml new file mode 100644 index 0000000..022a4c8 --- /dev/null +++ b/docker-compose.local-ollama.yml @@ -0,0 +1,99 @@ +# Temporary docker-compose file for testing with local Ollama +# Use this with: docker-compose -f docker-compose.local-ollama.yml up api + +services: + # Ollama services commented out for local testing + # ollama: + # image: ollama/ollama + # ports: + # - "11434:11434" + # volumes: + # - ollama-data:/root/.ollama + # environment: + # - OLLAMA_MAX_LOADED_MODELS=1 + # - OLLAMA_NUM_PARALLEL=4 + # deploy: + # resources: + # limits: + # memory: 8G + # cpus: '4.0' + # reservations: + # memory: 6G + # cpus: '2.0' + # healthcheck: + # test: ["CMD", "/bin/ollama", "list"] + # interval: 30s + # timeout: 10s + # retries: 5 + # start_period: 30s + # networks: + # - haystack-internal + # command: serve + + # ollama-init: + # image: curlimages/curl:latest + # volumes: + # - ./scripts:/scripts:ro + # environment: + # - OLLAMA_HOST=http://ollama:11434 + # - MODELS_TO_PULL=${MODELS_TO_PULL:-mistral:latest} + # depends_on: + # ollama: + # condition: service_healthy + # networks: + # - haystack-internal + # command: ["/bin/sh", "/scripts/init-ollama-models.sh"] + # restart: "no" + + api: + build: + context: . + dockerfile: Dockerfile + volumes: + - ./src:/app/src + - ./config:/app/config + ports: + - "8000:8000" + env_file: + - .env + environment: + - LOG_LEVEL=INFO + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - OLLAMA_URL=http://host.docker.internal:11434 + - OLLAMA_MODEL=mistral:latest + - MODELS_TO_PULL=mistral:latest + - REQUIRED_MODELS=mistral:latest + - GENERATION_NUM_PREDICT=1000 + - GENERATION_TEMPERATURE=0.7 + deploy: + resources: + limits: + memory: 2G + cpus: '2.0' + reservations: + memory: 512M + cpus: '0.5' + # Dependencies removed for local Ollama testing + # depends_on: + # - ollama + # - ollama-init + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + networks: + # - openproject_network + - haystack-internal + command: uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload + + +volumes: + ollama-data: + +networks: + #openproject_network: + # external: true + haystack-internal: + driver: bridge diff --git a/src/services/openproject_client.py b/src/services/openproject_client.py index 5ececba..105aac0 100644 --- a/src/services/openproject_client.py +++ b/src/services/openproject_client.py @@ -180,7 +180,7 @@ async def get_project_info(self, project_id: str) -> Dict[str, Any]: url = f"{self.base_url}/api/v3/projects/{project_id}" try: - async with httpx.AsyncClient(timeout=30.0) as client: + async with httpx.AsyncClient(timeout=300.0) as client: logger.info(f"Fetching project info from: {url}") response = await client.get(url, headers=self.headers) diff --git a/test-local-ollama.sh b/test-local-ollama.sh new file mode 100755 index 0000000..e8f05a0 --- /dev/null +++ b/test-local-ollama.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# Test script for comparing local vs Docker Ollama performance +# This script helps you easily switch between configurations and test performance + +set -e + +echo "🚀 Ollama Performance Testing Script" +echo "====================================" + +# Function to check if local Ollama is running +check_local_ollama() { + echo "🔍 Checking if local Ollama is running..." + if curl -s -f "http://localhost:11434/api/tags" > /dev/null 2>&1; then + echo "✅ Local Ollama is running on port 11434" + return 0 + else + echo "❌ Local Ollama is not running or not accessible on port 11434" + return 1 + fi +} + +# Function to test with local Ollama +test_local_ollama() { + echo "" + echo "🏠 Testing with LOCAL Ollama" + echo "----------------------------" + + if ! check_local_ollama; then + echo "Please start your local Ollama first with: ollama serve" + exit 1 + fi + + echo "📦 Starting API service with local Ollama configuration..." + echo "Using docker-compose.local-ollama.yml and .env file" + echo "Environment variables:" + echo " OLLAMA_URL=http://host.docker.internal:11434" + echo " OLLAMA_MODEL=mistral:latest" + + # Stop any running containers first + docker-compose down 2>/dev/null || true + docker-compose -f docker-compose.local-ollama.yml down 2>/dev/null || true + + # Start with local Ollama configuration + docker-compose -f docker-compose.local-ollama.yml up --build api +} + +# Function to test with Docker Ollama +test_docker_ollama() { + echo "" + echo "🐳 Testing with DOCKER Ollama" + echo "-----------------------------" + + # Temporarily rename .env to disable local configuration + if [ -f ".env" ]; then + mv .env .env.backup + echo "📝 Temporarily disabled .env file (backed up as .env.backup)" + fi + + echo "📦 Starting full Docker stack with Ollama container..." + + # Stop any running containers first + docker-compose -f docker-compose.local-ollama.yml down 2>/dev/null || true + + # Start with original Docker configuration + docker-compose up --build +} + +# Function to restore original configuration +restore_config() { + echo "" + echo "🔄 Restoring original configuration" + echo "----------------------------------" + + # Stop all containers + docker-compose down 2>/dev/null || true + docker-compose -f docker-compose.local-ollama.yml down 2>/dev/null || true + + # Restore .env if it was backed up + if [ -f ".env.backup" ]; then + mv .env.backup .env + echo "✅ Restored .env file from backup" + fi + + echo "✅ Configuration restored to original state" +} + +# Function to show usage +show_usage() { + echo "" + echo "Usage: $0 [local|docker|restore]" + echo "" + echo "Commands:" + echo " local - Test with local Ollama instance" + echo " docker - Test with Docker Ollama container" + echo " restore - Restore original configuration" + echo "" + echo "Examples:" + echo " $0 local # Test local Ollama performance" + echo " $0 docker # Test Docker Ollama performance" + echo " $0 restore # Clean up and restore original setup" +} + +# Main script logic +case "${1:-}" in + "local") + test_local_ollama + ;; + "docker") + test_docker_ollama + ;; + "restore") + restore_config + ;; + *) + show_usage + ;; +esac diff --git a/test_project_status_report.py b/test_project_status_report.py index caa2f8b..45bd11f 100644 --- a/test_project_status_report.py +++ b/test_project_status_report.py @@ -38,7 +38,7 @@ def test_project_status_report(): f"{BASE_URL}/generate-project-status-report", headers=headers, json=payload, - timeout=60 # Longer timeout for report generation + timeout=300 # Longer timeout for report generation ) print(f"Status: {response.status_code}") From e138a5e2e093919998bb228ab076892c82a0a21d Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Wed, 16 Jul 2025 14:43:25 +0200 Subject: [PATCH 02/19] Add simple suggest endpoint --- config/settings.py | 16 +- docker-compose.yml | 46 +++--- src/api/routes.py | 79 ++++++---- src/api/schemas.py | 95 ++++++++++++ src/pipelines/generation.py | 82 +++++----- src/pipelines/suggestion.py | 240 +++++++++++++++++++++++++++++ src/services/openproject_client.py | 128 ++++++++++----- src/templates/report_templates.py | 92 +++++------ 8 files changed, 586 insertions(+), 192 deletions(-) create mode 100644 src/api/schemas.py create mode 100644 src/pipelines/suggestion.py diff --git a/config/settings.py b/config/settings.py index 754ab17..5b9ea8a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -6,26 +6,30 @@ class Settings: """Application settings.""" - + # Logging configuration LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") LOG_FORMAT: Optional[str] = os.getenv("LOG_FORMAT", None) - + # Ollama configuration OLLAMA_URL: str = os.getenv("OLLAMA_URL", "http://ollama:11434") - OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "mistral:latest") - + OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "mistral:7B") + # Model management MODELS_TO_PULL: str = os.getenv("MODELS_TO_PULL", "mistral:latest") REQUIRED_MODELS: list = os.getenv("REQUIRED_MODELS", "mistral:latest").split(",") - + # Generation parameters GENERATION_NUM_PREDICT: int = int(os.getenv("GENERATION_NUM_PREDICT", "1000")) GENERATION_TEMPERATURE: float = float(os.getenv("GENERATION_TEMPERATURE", "0.7")) - + # API configuration API_HOST: str = os.getenv("API_HOST", "0.0.0.0") API_PORT: int = int(os.getenv("API_PORT", "8000")) + # OpenProject configuration + OPENPROJECT_BASE_URL: str = os.getenv("OPENPROJECT_BASE_URL", "") + OPENPROJECT_API_KEY: str = os.getenv("OPENPROJECT_API_KEY", "") + settings = Settings() diff --git a/docker-compose.yml b/docker-compose.yml index 60c5ce8..cfd523a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,14 +8,6 @@ services: environment: - OLLAMA_MAX_LOADED_MODELS=1 - OLLAMA_NUM_PARALLEL=4 - deploy: - resources: - limits: - memory: 8G - cpus: '4.0' - reservations: - memory: 6G - cpus: '2.0' healthcheck: test: ["CMD", "/bin/ollama", "list"] interval: 30s @@ -26,20 +18,20 @@ services: - haystack-internal command: serve - ollama-init: - image: curlimages/curl:latest - volumes: - - ./scripts:/scripts:ro - environment: - - OLLAMA_HOST=http://ollama:11434 - - MODELS_TO_PULL=${MODELS_TO_PULL:-mistral:latest} - depends_on: - ollama: - condition: service_healthy - networks: - - haystack-internal - command: ["/bin/sh", "/scripts/init-ollama-models.sh"] - restart: "no" + # ollama-init: + # image: curlimages/curl:latest + # volumes: + # - ./scripts:/scripts:ro + # environment: + # - OLLAMA_HOST=http://ollama:11434 + # - MODELS_TO_PULL=${MODELS_TO_PULL:-mistral:latest} + # depends_on: + # ollama: + # condition: service_healthy + # networks: + # - haystack-internal + # command: ["/bin/sh", "/scripts/init-ollama-models.sh"] + # restart: "no" api: build: @@ -64,14 +56,14 @@ services: cpus: '0.5' depends_on: - ollama - - ollama-init + # - ollama-init healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 10s + interval: 30s timeout: 5s retries: 5 networks: - # - openproject_network + - openproject_network - haystack-internal command: uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload @@ -80,7 +72,7 @@ volumes: ollama-data: networks: - #openproject_network: - # external: true + openproject_network: + external: true haystack-internal: driver: bridge diff --git a/src/api/routes.py b/src/api/routes.py index 8521463..9980895 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,17 +1,19 @@ """API routes for the Haystack application.""" from fastapi import APIRouter, HTTPException, Header -from src.models.schemas import ( +from src.api.schemas import ( GenerationRequest, GenerationResponse, HealthResponse, ChatCompletionRequest, ChatCompletionResponse, ChatMessage, ChatChoice, Usage, ModelsResponse, ModelInfo, ErrorResponse, ErrorDetail, - ProjectStatusReportRequest, ProjectStatusReportResponse + ProjectStatusReportRequest, ProjectStatusReportResponse, + SuggestRequest, SuggestResponse ) from src.pipelines.generation import generation_pipeline from src.services.openproject_client import OpenProjectClient, OpenProjectAPIError import uuid import logging from typing import Optional +from src.pipelines.suggestion import pipeline logger = logging.getLogger(__name__) router = APIRouter() @@ -26,10 +28,10 @@ def health_check(): @router.post("/generate", response_model=GenerationResponse) def generate_text(request: GenerationRequest): """Generate text from a prompt. - + Args: request: The generation request containing the prompt - + Returns: Generated text response """ @@ -45,10 +47,10 @@ def generate_text(request: GenerationRequest): @router.post("/v1/chat/completions", response_model=ChatCompletionResponse) def create_chat_completion(request: ChatCompletionRequest): """Create a chat completion (OpenAI-compatible endpoint). - + Args: request: Chat completion request with messages and parameters - + Returns: Chat completion response in OpenAI format """ @@ -66,13 +68,13 @@ def create_chat_completion(request: ChatCompletionRequest): } } ) - + # Generate response using the pipeline response_text, usage_info = generation_pipeline.chat_completion(request) - + # Create response in OpenAI format completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}" - + response = ChatCompletionResponse( id=completion_id, model=request.model, @@ -88,9 +90,9 @@ def create_chat_completion(request: ChatCompletionRequest): ], usage=Usage(**usage_info) ) - + return response - + except HTTPException: raise except Exception as e: @@ -115,11 +117,11 @@ async def generate_project_status_report( authorization: Optional[str] = Header(None) ): """Generate a project status report from OpenProject work packages. - + Args: request: Project status report request with project ID and base URL authorization: Bearer token for OpenProject API authentication - + Returns: Generated project status report """ @@ -136,7 +138,7 @@ async def generate_project_status_report( } } ) - + # Extract API key from Bearer token if not authorization.startswith("Bearer "): raise HTTPException( @@ -149,9 +151,9 @@ async def generate_project_status_report( } } ) - + api_key = authorization[7:] # Remove "Bearer " prefix - + if not api_key: raise HTTPException( status_code=401, @@ -163,22 +165,22 @@ async def generate_project_status_report( } } ) - + # Initialize OpenProject client openproject_client = OpenProjectClient( base_url=request.openproject_base_url, api_key=api_key ) - + logger.info(f"Generating project status report for project {request.project_id}") - + # Fetch work packages from OpenProject try: work_packages = await openproject_client.get_work_packages(request.project_id) logger.info(f"Fetched {len(work_packages)} work packages") except OpenProjectAPIError as e: logger.error(f"OpenProject API error: {e.message}") - + # Map OpenProject API errors to appropriate HTTP status codes if e.status_code == 401: raise HTTPException(status_code=401, detail={ @@ -220,7 +222,7 @@ async def generate_project_status_report( "code": "openproject_api_error" } }) - + # Generate project status report using LLM try: report_text, analysis = generation_pipeline.generate_project_status_report( @@ -228,16 +230,16 @@ async def generate_project_status_report( openproject_base_url=request.openproject_base_url, work_packages=work_packages ) - + logger.info(f"Successfully generated project status report for project {request.project_id}") - + return ProjectStatusReportResponse( project_id=request.project_id, report=report_text, work_packages_analyzed=len(work_packages), openproject_base_url=request.openproject_base_url ) - + except Exception as e: logger.error(f"Error generating report: {str(e)}") raise HTTPException( @@ -250,7 +252,7 @@ async def generate_project_status_report( } } ) - + except HTTPException: raise except Exception as e: @@ -270,20 +272,20 @@ async def generate_project_status_report( @router.get("/v1/models", response_model=ModelsResponse) def list_models(): """List available models (OpenAI-compatible endpoint). - + Returns: List of available models in OpenAI format """ try: available_models = generation_pipeline.get_available_models() - + models = [ ModelInfo(id=model_id) for model_id in available_models ] - + return ModelsResponse(data=models) - + except Exception as e: logger.error(f"Error listing models: {str(e)}") raise HTTPException( @@ -303,16 +305,16 @@ def list_models(): @router.get("/v1/models/{model_id}") def get_model(model_id: str): """Get specific model information (OpenAI-compatible endpoint). - + Args: model_id: The model ID to retrieve - + Returns: Model information """ try: available_models = generation_pipeline.get_available_models() - + if model_id not in available_models: raise HTTPException( status_code=404, @@ -325,9 +327,9 @@ def get_model(model_id: str): } } ) - + return ModelInfo(id=model_id) - + except HTTPException: raise except Exception as e: @@ -342,3 +344,12 @@ def get_model(model_id: str): } } ) + + +@router.post("/suggest", response_model=SuggestResponse) +def suggest_endpoint(request: SuggestRequest): + try: + result = pipeline.suggest(request.project_id) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/api/schemas.py b/src/api/schemas.py new file mode 100644 index 0000000..85ae3ce --- /dev/null +++ b/src/api/schemas.py @@ -0,0 +1,95 @@ +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional, Union + +# Suggestion schemas +class CandidateSuggestion(BaseModel): + name: Optional[str] = None + score: Optional[float] = None + project_id: Optional[int] = None + reason: str + +class SuggestRequest(BaseModel): + project_id: int + +class SuggestResponse(BaseModel): + portfolio: Optional[str] = None + candidates: List[CandidateSuggestion] + text: str + +# Generation schemas +class GenerationRequest(BaseModel): + prompt: str + +class GenerationResponse(BaseModel): + response: str + +# Health check +class HealthResponse(BaseModel): + status: str + +# OpenAI-compatible schemas +class ChatMessage(BaseModel): + role: str + content: str + +class ChatCompletionRequest(BaseModel): + model: str + messages: List[ChatMessage] + max_tokens: Optional[int] = 1000 + temperature: Optional[float] = 0.7 + top_p: Optional[float] = 1.0 + stop: Optional[List[str]] = None + +class ChatChoice(BaseModel): + index: int + message: ChatMessage + finish_reason: Optional[str] = None + +class Usage(BaseModel): + prompt_tokens: int + completion_tokens: int + total_tokens: int + +class ChatCompletionResponse(BaseModel): + id: str + model: str + choices: List[ChatChoice] + usage: Usage + +class ModelInfo(BaseModel): + id: str + +class ModelsResponse(BaseModel): + data: List[ModelInfo] + +class ErrorDetail(BaseModel): + message: str + type: str + param: Optional[str] = None + code: Optional[str] = None + +class ErrorResponse(BaseModel): + error: ErrorDetail + +# Project Status Report schemas +class ProjectStatusReportRequest(BaseModel): + project_id: str + openproject_base_url: str + +class ProjectStatusReportResponse(BaseModel): + project_id: str + report: str + work_packages_analyzed: int + openproject_base_url: str + +class WorkPackage(BaseModel): + id: str + subject: str + status: Optional[Dict[str, Any]] = None + priority: Optional[Dict[str, Any]] = None + assignee: Optional[Dict[str, Any]] = None + due_date: Optional[str] = None + done_ratio: Optional[int] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + description: Optional[Dict[str, Any]] = None diff --git a/src/pipelines/generation.py b/src/pipelines/generation.py index e7af59b..5e46389 100644 --- a/src/pipelines/generation.py +++ b/src/pipelines/generation.py @@ -2,7 +2,7 @@ from haystack_integrations.components.generators.ollama import OllamaGenerator from config.settings import settings -from src.models.schemas import ChatMessage, ChatCompletionRequest, WorkPackage +from src.api.schemas import ChatMessage, ChatCompletionRequest, WorkPackage from src.templates.report_templates import ProjectReportAnalyzer, ProjectStatusReportTemplate from typing import List, Tuple, Dict, Any import uuid @@ -15,12 +15,12 @@ class GenerationPipeline: """Pipeline for text generation using Ollama.""" - + def __init__(self): """Initialize the generation pipeline.""" # Validate that required models are available self._validate_models() - + self.generator = OllamaGenerator( model=settings.OLLAMA_MODEL, url=settings.OLLAMA_URL, @@ -29,18 +29,18 @@ def __init__(self): "temperature": settings.GENERATION_TEMPERATURE } ) - + def _validate_models(self): """Validate that required models are available in Ollama.""" try: available_models = self._get_ollama_models() required_models = [model.strip() for model in settings.REQUIRED_MODELS if model.strip()] - + missing_models = [] for model in required_models: if model not in available_models: missing_models.append(model) - + if missing_models: logger.error(f"Missing required models: {missing_models}") logger.error(f"Available models: {available_models}") @@ -49,19 +49,19 @@ def _validate_models(self): f"Available models: {available_models}. " f"Please ensure the ollama-init service has completed successfully." ) - + logger.info(f"All required models are available: {required_models}") - + except requests.exceptions.RequestException as e: logger.error(f"Failed to connect to Ollama service: {e}") raise RuntimeError( f"Cannot connect to Ollama service at {settings.OLLAMA_URL}. " f"Please ensure the Ollama service is running and accessible." ) - + def _get_ollama_models(self) -> List[str]: """Get list of models available in Ollama. - + Returns: List of available model names """ @@ -73,31 +73,31 @@ def _get_ollama_models(self) -> List[str]: except requests.exceptions.RequestException as e: logger.error(f"Failed to fetch models from Ollama: {e}") raise - + def generate(self, prompt: str) -> str: """Generate text from a prompt. - + Args: prompt: The input prompt for generation - + Returns: Generated text response """ result = self.generator.run(prompt) return result["replies"][0] - + def chat_completion(self, request: ChatCompletionRequest) -> Tuple[str, dict]: """Generate chat completion response. - + Args: request: Chat completion request with messages and parameters - + Returns: Tuple of (generated_response, usage_info) """ # Convert messages to a single prompt prompt = self._messages_to_prompt(request.messages) - + # Create generator with request-specific parameters generator = OllamaGenerator( model=request.model, @@ -109,34 +109,34 @@ def chat_completion(self, request: ChatCompletionRequest) -> Tuple[str, dict]: "stop": request.stop or [] } ) - + # Generate response result = generator.run(prompt) response_text = result["replies"][0] - + # Calculate token usage (approximate) prompt_tokens = self._estimate_tokens(prompt) completion_tokens = self._estimate_tokens(response_text) - + usage = { "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens } - + return response_text, usage - + def _messages_to_prompt(self, messages: List[ChatMessage]) -> str: """Convert chat messages to a single prompt string. - + Args: messages: List of chat messages - + Returns: Formatted prompt string """ prompt_parts = [] - + for message in messages: if message.role == "system": prompt_parts.append(f"System: {message.content}") @@ -144,49 +144,49 @@ def _messages_to_prompt(self, messages: List[ChatMessage]) -> str: prompt_parts.append(f"User: {message.content}") elif message.role == "assistant": prompt_parts.append(f"Assistant: {message.content}") - + # Add final prompt for assistant response prompt_parts.append("Assistant:") - + return "\n\n".join(prompt_parts) - + def _estimate_tokens(self, text: str) -> int: """Estimate token count for text. - + This is a rough approximation. For more accurate counting, you might want to use a proper tokenizer. - + Args: text: Text to estimate tokens for - + Returns: Estimated token count """ # Rough approximation: 1 token ≈ 4 characters for English text return max(1, len(text) // 4) - + def generate_project_status_report( - self, + self, project_id: str, openproject_base_url: str, work_packages: List[WorkPackage], template_name: str = "default" ) -> Tuple[str, Dict[str, Any]]: """Generate a project status report from work packages. - + Args: project_id: OpenProject project ID openproject_base_url: Base URL of OpenProject instance work_packages: List of work packages to analyze template_name: Name of the report template to use - + Returns: Tuple of (generated_report, analysis_data) """ # Analyze work packages analyzer = ProjectReportAnalyzer() analysis = analyzer.analyze_work_packages(work_packages) - + # Create report prompt using template template = ProjectStatusReportTemplate() prompt = template.create_report_prompt( @@ -195,7 +195,7 @@ def generate_project_status_report( work_packages=work_packages, analysis=analysis ) - + # Generate report using LLM generator = OllamaGenerator( model=settings.OLLAMA_MODEL, @@ -205,15 +205,15 @@ def generate_project_status_report( "temperature": 0.3, # Lower temperature for more consistent reports } ) - + result = generator.run(prompt) report_text = result["replies"][0] - + return report_text, analysis - + def get_available_models(self) -> List[str]: """Get list of available models. - + Returns: List of available model names """ diff --git a/src/pipelines/suggestion.py b/src/pipelines/suggestion.py new file mode 100644 index 0000000..0e97594 --- /dev/null +++ b/src/pipelines/suggestion.py @@ -0,0 +1,240 @@ +import logging +from typing import List, Dict, Any, Optional +from dataclasses import dataclass +from config.settings import settings +from src.services.openproject_client import OpenProjectClient, OpenProjectAPIError +from src.pipelines.generation import generation_pipeline +import asyncio +import json +import concurrent.futures +import re + +logger = logging.getLogger(__name__) + +@dataclass +class Candidate: + project_id: Any + name: str + score: Optional[float] + reason: str + +class SuggestionPipeline: + """Pipeline for suggesting suitable sub-projects for a portfolio project.""" + def __init__(self, openproject_client: OpenProjectClient): + self.openproject_client = openproject_client + + def suggest(self, project_id: str) -> Dict[str, Any]: + """Main entry point: Suggest suitable sub-projects for a portfolio project.""" + try: + portfolio_project = asyncio.run(self.openproject_client.get_project_info(project_id)) + logger.info(f"Fetched portfolio project: {portfolio_project.get('name', project_id)}") + + if portfolio_project.get("projectType") != "portfolio": + logger.warning("Project is not a portfolio. Returning message.") + return { + "portfolio": portfolio_project.get("name"), + "candidates": [], + "text": "The selected project is not a portfolio project." + } + + sub_projects = self._get_sub_projects_parallel_with_fallback(project_id) + logger.info(f"Fetched {len(sub_projects)} sub-projects for portfolio {project_id}") + + if not portfolio_project or not sub_projects: + logger.warning("No portfolio or sub-project info available. Returning empty candidates.") + return { + "portfolio": portfolio_project.get("name") if portfolio_project else None, + "candidates": [], + "text": "No portfolio or sub-project info available." + } + + candidates, llm_response = self._llm_score_candidates(portfolio_project, sub_projects) + candidate_list = [self._candidate_to_dict(c) for c in candidates] + + return { + "portfolio": portfolio_project.get("name"), + "candidates": candidate_list, + "text": llm_response + } + except OpenProjectAPIError as e: + logger.error(f"OpenProject API error: {e.message}") + raise + except Exception as e: + logger.error(f"Suggestion pipeline error: {e}") + raise + + def _get_sub_projects_parallel_with_fallback(self, project_id: str) -> List[Dict[str, Any]]: + """Fetch sub-projects (children) in parallel, with fallback to all projects if needed.""" + project_info = asyncio.run(self.openproject_client.get_project_info(project_id)) + children_links = project_info.get("_links", {}).get("children", []) + sub_projects = [] + if children_links: + def fetch_child(child): + href = child.get("href") + if href: + child_id = href.rstrip("/").split("/")[-1] + try: + return asyncio.run(self.openproject_client.get_project_info(child_id)) + except Exception as e: + logger.error(f"Failed to fetch sub-project {child_id}: {e}") + return None + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(fetch_child, children_links)) + sub_projects = [sp for sp in results if sp is not None] + else: + logger.info("No children found in _links. Fetching all projects and filtering by parent.") + all_projects = asyncio.run(self.openproject_client.get_all_projects()) + sub_projects = [ + p for p in all_projects + if p.get("_links", {}).get("parent", {}).get("href") == f"/api/v3/projects/{project_id}" + ] + return sub_projects + + def _extract_custom_fields(self, project_info: dict) -> dict: + """Extract custom fields from a project info dict, using only the 'raw' value if present.""" + result = {} + for k, v in project_info.items(): + if k.startswith('customField'): + if isinstance(v, dict) and 'raw' in v: + result[k] = v['raw'] + else: + result[k] = v + return result + + def _get_work_package_names(self, project_id: str) -> List[str]: + """Fetch work package names for a project synchronously.""" + if not project_id: + return [] + try: + work_packages = asyncio.run(self.openproject_client.get_work_packages(str(project_id))) + return [wp.subject for wp in work_packages if hasattr(wp, 'subject')] + except Exception as e: + logger.warning(f"Failed to fetch work packages for project {project_id}: {e}") + return [] + + def _build_suggestion_prompt(self, portfolio_info: dict, sub_projects: List[dict]) -> str: + """Build the LLM prompt with all relevant info.""" + portfolio_custom_fields = self._extract_custom_fields(portfolio_info) + portfolio_wp_names = self._get_work_package_names(str(portfolio_info.get('id', ''))) + prompt = [ + "You are an expert project portfolio advisor.", + "Portfolio project info:", + f"Name: {portfolio_info.get('name')}", + f"Description: {portfolio_info.get('description', {}).get('raw', '')}" + ] + if portfolio_custom_fields: + prompt.append("Custom fields:") + prompt.extend([f" {k}: {v}" for k, v in portfolio_custom_fields.items()]) + if portfolio_wp_names: + prompt.append("Work packages:") + prompt.extend([f" - {wp_name}" for wp_name in portfolio_wp_names]) + prompt.append("\nSub-projects:") + for i, sp in enumerate(sub_projects, 1): + sp_custom_fields = self._extract_custom_fields(sp) + sp_wp_names = self._get_work_package_names(str(sp.get('id', ''))) + prompt.append(f"{i}. Name: {sp.get('name')}") + prompt.append(f" Description: {sp.get('description', {}).get('raw', '')}") + prompt.append(f" ID: {sp.get('id')}") + if sp_custom_fields: + prompt.append(" Custom fields:") + prompt.extend([f" {k}: {v}" for k, v in sp_custom_fields.items()]) + if sp_wp_names: + prompt.append(" Work packages:") + prompt.extend([f" - {wp_name}" for wp_name in sp_wp_names]) + prompt.append( + "For each sub-project, rate its suitability as a portfolio candidate for the above portfolio project " + "on a scale from 0 to 100 (integer), and briefly explain your reasoning. " + "If there is not enough information to make a judgment, honestly say so and do not continue. " + "Return a JSON list of objects with fields: project_id, score, reason." + ) + return '\n'.join(prompt) + + def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict]) -> tuple[List[Candidate], str]: + """Call the LLM and parse the response into Candidate objects.""" + prompt = self._build_suggestion_prompt(portfolio_project, sub_projects) + logger.info(f"LLM prompt for suggestion pipeline:\n{prompt}") + try: + llm_response = generation_pipeline.generate(prompt) + except TypeError: + llm_response = generation_pipeline.generate(prompt) + try: + raw_candidates = json.loads(llm_response) + candidates = [self._dict_to_candidate(c, sub_projects) for c in raw_candidates] + except Exception as e: + logger.error(f"Failed to parse LLM response as JSON: {e}\nResponse: {llm_response}") + candidates = self._parse_candidates_from_text(llm_response, sub_projects) + return candidates, llm_response + + def _dict_to_candidate(self, c: dict, sub_projects: List[dict]) -> Candidate: + """Convert a dict (from LLM JSON) to a Candidate object, matching project_id to sub-projects.""" + sp = next((sp for sp in sub_projects if str(sp.get("id")) == str(c.get("id", c.get("project_id")))), None) + project_id_out = str(sp.get("id")) if sp and sp.get("id") is not None else str(c.get("id", c.get("project_id", ""))) + name = str(sp.get("name")) if sp and sp.get("name") is not None else f"ID {project_id_out}" + score = None + score_val = c.get("score") + if score_val is not None: + try: + score_val_str = str(score_val).strip() + if score_val_str and score_val_str.lower() != 'none': + score = float(score_val_str) + except Exception: + score = None + reason = str(c.get("reason", "")) + return Candidate(project_id=project_id_out, name=name, score=score, reason=reason) + + def _parse_candidates_from_text(self, text: str, sub_projects: List[dict]) -> List[Candidate]: + """Attempt to extract candidate info from a text block if LLM did not return JSON.""" + candidates = [] + pattern = re.compile(r"(?P\d+)\.\s*(?P[^\n]+)\n\s*- project_id: (?P\d+)\n\s*- score: (?P[\d\.]+)\n\s*- reason: (?P.+?)(?=\n\d+\.|$)", re.DOTALL) + for match in pattern.finditer(text): + project_id = str(match.group("project_id")) if match.group("project_id") is not None else "" + parsed_name = str(match.group("name")).strip() if match.group("name") is not None else "Unknown" + sp = next((sp for sp in sub_projects if str(sp.get("id")) == project_id), None) + if sp and sp.get("name") is not None: + name = str(sp.get("name")) + project_id_out = str(sp.get("id")) + else: + # Try fuzzy match by name if project_id fails + name = None + project_id_out = project_id + for s in sub_projects: + s_name = str(s.get("name", "")) + if parsed_name.lower() in s_name.lower() or s_name.lower() in parsed_name.lower(): + name = s_name + project_id_out = str(s.get("id", "")) + break + if not name: + name = parsed_name + logger.warning(f"No sub-project found for project_id {project_id}. Using parsed name '{parsed_name}'.") + score_val = match.group("score") + score = None + try: + if isinstance(score_val, (int, float)): + score = float(score_val) + elif isinstance(score_val, str): + score_val_str = score_val.strip() + if score_val_str and score_val_str.lower() != 'none': + score = float(score_val_str) + elif score_val is not None: + score_val_str = str(score_val).strip() + if score_val_str and score_val_str.lower() != 'none': + score = float(score_val_str) + except Exception: + score = None + reason_val = match.group("reason") + reason = str(reason_val).strip() if reason_val is not None else "" + candidates.append(Candidate(project_id=project_id_out, name=name, score=score, reason=reason)) + candidates.sort(key=lambda x: (x.score is not None, x.score), reverse=True) + return candidates + + def _candidate_to_dict(self, c: Candidate) -> dict: + """Convert a Candidate dataclass to a dict for API response.""" + return { + "project_id": c.project_id, + "name": c.name, + "score": c.score, + "reason": c.reason + } + +# Global pipeline instance for import convenience +pipeline = SuggestionPipeline(OpenProjectClient(settings.OPENPROJECT_BASE_URL, settings.OPENPROJECT_API_KEY)) diff --git a/src/services/openproject_client.py b/src/services/openproject_client.py index 105aac0..ae1bd90 100644 --- a/src/services/openproject_client.py +++ b/src/services/openproject_client.py @@ -4,7 +4,7 @@ import logging import base64 from typing import List, Dict, Any, Optional -from src.models.schemas import WorkPackage +from src.api.schemas import WorkPackage logger = logging.getLogger(__name__) @@ -19,10 +19,10 @@ def __init__(self, message: str, status_code: Optional[int] = None): class OpenProjectClient: """Client for interacting with OpenProject API.""" - + def __init__(self, base_url: str, api_key: str): """Initialize the OpenProject client. - + Args: base_url: Base URL of the OpenProject instance api_key: API key for authentication @@ -38,40 +38,40 @@ def __init__(self, base_url: str, api_key: str): "Accept": "application/hal+json", "Content-Type": "application/json" } - + async def get_work_packages(self, project_id: str) -> List[WorkPackage]: """Fetch all work packages for a specific project. - + Args: project_id: The OpenProject project ID - + Returns: List of WorkPackage objects - + Raises: OpenProjectAPIError: If API request fails """ url = f"{self.base_url}/api/v3/projects/{project_id}/work_packages" - + try: async with httpx.AsyncClient(timeout=300.0) as client: logger.info(f"Fetching work packages from: {url}") - + response = await client.get(url, headers=self.headers) - + if response.status_code == 401: raise OpenProjectAPIError( - "Invalid API key or insufficient permissions", + "Invalid API key or insufficient permissions", status_code=401 ) elif response.status_code == 403: raise OpenProjectAPIError( - "Insufficient permissions to access this project", + "Insufficient permissions to access this project", status_code=403 ) elif response.status_code == 404: raise OpenProjectAPIError( - f"Project with ID '{project_id}' not found", + f"Project with ID '{project_id}' not found", status_code=404 ) elif response.status_code != 200: @@ -79,10 +79,10 @@ async def get_work_packages(self, project_id: str) -> List[WorkPackage]: f"OpenProject API returned status {response.status_code}: {response.text}", status_code=response.status_code ) - + data = response.json() work_packages = [] - + # Parse work packages from the response if "_embedded" in data and "elements" in data["_embedded"]: for wp_data in data["_embedded"]["elements"]: @@ -92,10 +92,10 @@ async def get_work_packages(self, project_id: str) -> List[WorkPackage]: except Exception as e: logger.warning(f"Failed to parse work package {wp_data.get('id', 'unknown')}: {e}") continue - + logger.info(f"Successfully fetched {len(work_packages)} work packages") return work_packages - + except httpx.TimeoutException: raise OpenProjectAPIError("Request to OpenProject API timed out", status_code=408) except httpx.ConnectError: @@ -106,13 +106,13 @@ async def get_work_packages(self, project_id: str) -> List[WorkPackage]: if isinstance(e, OpenProjectAPIError): raise raise OpenProjectAPIError(f"Unexpected error: {str(e)}", status_code=500) - + def _parse_work_package(self, wp_data: Dict[str, Any]) -> WorkPackage: """Parse work package data from OpenProject API response. - + Args: wp_data: Raw work package data from API - + Returns: WorkPackage object """ @@ -124,7 +124,7 @@ def _parse_work_package(self, wp_data: Dict[str, Any]) -> WorkPackage: "name": wp_data["status"].get("name"), "href": wp_data["status"].get("href") } - + # Extract priority information priority = None if "priority" in wp_data and wp_data["priority"]: @@ -133,7 +133,7 @@ def _parse_work_package(self, wp_data: Dict[str, Any]) -> WorkPackage: "name": wp_data["priority"].get("name"), "href": wp_data["priority"].get("href") } - + # Extract assignee information assignee = None if "assignee" in wp_data and wp_data["assignee"]: @@ -142,7 +142,7 @@ def _parse_work_package(self, wp_data: Dict[str, Any]) -> WorkPackage: "name": wp_data["assignee"].get("name"), "href": wp_data["assignee"].get("href") } - + # Extract description description = None if "description" in wp_data and wp_data["description"]: @@ -151,9 +151,9 @@ def _parse_work_package(self, wp_data: Dict[str, Any]) -> WorkPackage: "raw": wp_data["description"].get("raw"), "html": wp_data["description"].get("html") } - + return WorkPackage( - id=wp_data["id"], + id=str(wp_data["id"]), subject=wp_data.get("subject", ""), status=status, priority=priority, @@ -164,40 +164,42 @@ def _parse_work_package(self, wp_data: Dict[str, Any]) -> WorkPackage: updated_at=wp_data.get("updatedAt", ""), description=description ) - + async def get_project_info(self, project_id: str) -> Dict[str, Any]: """Fetch basic project information. - + Args: project_id: The OpenProject project ID - + Returns: Project information dictionary - + Raises: OpenProjectAPIError: If API request fails """ url = f"{self.base_url}/api/v3/projects/{project_id}" - + headers = self.headers.copy() # Use a copy to avoid modifying self.headers + logger.info(f"Sending request to {url} with headers: {headers}") + try: async with httpx.AsyncClient(timeout=300.0) as client: logger.info(f"Fetching project info from: {url}") - - response = await client.get(url, headers=self.headers) - + + response = await client.get(url, headers=headers) + if response.status_code == 401: raise OpenProjectAPIError( - "Invalid API key or insufficient permissions", + "Invalid API key or insufficient permissions", status_code=401 ) elif response.status_code == 403: raise OpenProjectAPIError( - "Insufficient permissions to access this project", + "Insufficient permissions to access this project", status_code=403 ) elif response.status_code == 404: raise OpenProjectAPIError( - f"Project with ID '{project_id}' not found", + f"Project with ID '{project_id}' not found", status_code=404 ) elif response.status_code != 200: @@ -205,9 +207,59 @@ async def get_project_info(self, project_id: str) -> Dict[str, Any]: f"OpenProject API returned status {response.status_code}: {response.text}", status_code=response.status_code ) - + return response.json() - + + except httpx.TimeoutException: + raise OpenProjectAPIError("Request to OpenProject API timed out", status_code=408) + except httpx.ConnectError: + raise OpenProjectAPIError("Could not connect to OpenProject API", status_code=503) + except httpx.HTTPError as e: + raise OpenProjectAPIError(f"HTTP error occurred: {str(e)}", status_code=500) + except Exception as e: + if isinstance(e, OpenProjectAPIError): + raise + raise OpenProjectAPIError(f"Unexpected error: {str(e)}", status_code=500) + + async def get_all_projects(self) -> List[Dict[str, Any]]: + """Fetch all projects from OpenProject, handling pagination.""" + url = f"{self.base_url}/api/v3/projects" + headers = self.headers.copy() + projects = [] + offset = 1 + page_size = 100 # Adjust as needed + + try: + while True: + params = {"offset": offset, "pageSize": page_size} + async with httpx.AsyncClient(timeout=300.0) as client: + logger.info(f"Fetching projects from: {url} (offset={offset})") + response = await client.get(url, headers=headers, params=params) + + if response.status_code == 401: + raise OpenProjectAPIError( + "Invalid API key or insufficient permissions", + status_code=401 + ) + elif response.status_code == 403: + raise OpenProjectAPIError( + "Insufficient permissions to access projects", + status_code=403 + ) + elif response.status_code != 200: + raise OpenProjectAPIError( + f"OpenProject API returned status {response.status_code}: {response.text}", + status_code=response.status_code + ) + + data = response.json() + elements = data.get("_embedded", {}).get("elements", []) + projects.extend(elements) + if len(elements) < page_size: + break + offset += page_size + logger.info(f"Successfully fetched {len(projects)} projects.") + return projects except httpx.TimeoutException: raise OpenProjectAPIError("Request to OpenProject API timed out", status_code=408) except httpx.ConnectError: diff --git a/src/templates/report_templates.py b/src/templates/report_templates.py index b9a975c..cd31770 100644 --- a/src/templates/report_templates.py +++ b/src/templates/report_templates.py @@ -1,21 +1,21 @@ """Report templates for project status report generation.""" from typing import List, Dict, Any -from src.models.schemas import WorkPackage +from src.api.schemas import WorkPackage from datetime import datetime, timedelta import json class ProjectReportAnalyzer: """Analyzer for work package data to extract insights.""" - + @staticmethod def analyze_work_packages(work_packages: List[WorkPackage]) -> Dict[str, Any]: """Analyze work packages and extract key metrics. - + Args: work_packages: List of work packages to analyze - + Returns: Dictionary containing analysis results """ @@ -29,54 +29,54 @@ def analyze_work_packages(work_packages: List[WorkPackage]) -> Dict[str, Any]: "timeline_insights": {}, "key_metrics": {} } - + # Basic counts total_count = len(work_packages) - + # Status distribution status_distribution = {} for wp in work_packages: status_name = wp.status.get("name", "Unknown") if wp.status else "Unknown" status_distribution[status_name] = status_distribution.get(status_name, 0) + 1 - + # Priority distribution priority_distribution = {} for wp in work_packages: priority_name = wp.priority.get("name", "No Priority") if wp.priority else "No Priority" priority_distribution[priority_name] = priority_distribution.get(priority_name, 0) + 1 - + # Completion statistics completion_ratios = [wp.done_ratio for wp in work_packages if wp.done_ratio is not None] avg_completion = sum(completion_ratios) / len(completion_ratios) if completion_ratios else 0 completed_count = sum(1 for wp in work_packages if wp.done_ratio == 100) in_progress_count = sum(1 for wp in work_packages if wp.done_ratio and 0 < wp.done_ratio < 100) not_started_count = sum(1 for wp in work_packages if not wp.done_ratio or wp.done_ratio == 0) - + completion_stats = { "average_completion": round(avg_completion, 1), "completed": completed_count, "in_progress": in_progress_count, "not_started": not_started_count } - + # Assignee workload assignee_workload = {} for wp in work_packages: assignee_name = wp.assignee.get("name", "Unassigned") if wp.assignee else "Unassigned" if assignee_name not in assignee_workload: assignee_workload[assignee_name] = {"total": 0, "completed": 0, "in_progress": 0} - + assignee_workload[assignee_name]["total"] += 1 if wp.done_ratio == 100: assignee_workload[assignee_name]["completed"] += 1 elif wp.done_ratio and wp.done_ratio > 0: assignee_workload[assignee_name]["in_progress"] += 1 - + # Timeline insights now = datetime.now() overdue_count = 0 upcoming_deadlines = 0 - + for wp in work_packages: if wp.due_date: try: @@ -87,19 +87,19 @@ def analyze_work_packages(work_packages: List[WorkPackage]) -> Dict[str, Any]: upcoming_deadlines += 1 except (ValueError, TypeError): continue - + timeline_insights = { "overdue_items": overdue_count, "upcoming_deadlines_7_days": upcoming_deadlines } - + # Key metrics key_metrics = { "completion_rate": round((completed_count / total_count) * 100, 1) if total_count > 0 else 0, "active_work_ratio": round(((in_progress_count + completed_count) / total_count) * 100, 1) if total_count > 0 else 0, "team_members": len([k for k in assignee_workload.keys() if k != "Unassigned"]) } - + return { "total_count": total_count, "status_distribution": status_distribution, @@ -113,11 +113,11 @@ def analyze_work_packages(work_packages: List[WorkPackage]) -> Dict[str, Any]: class ProjectStatusReportTemplate: """Template for generating project status reports.""" - + @staticmethod def get_default_template() -> str: """Get the default project status report template. - + Returns: Template string for LLM prompt """ @@ -172,55 +172,55 @@ def get_default_template() -> str: Format the report in a professional, clear, and actionable manner. Use bullet points and structured sections for easy readability. Focus on insights that would be valuable for project managers and stakeholders. """ - + @staticmethod def format_work_packages_summary(work_packages: List[WorkPackage], limit: int = 10) -> str: """Format work packages into a summary for the report. - + Args: work_packages: List of work packages limit: Maximum number of work packages to include in detail - + Returns: Formatted string summary """ if not work_packages: return "No work packages found for this project." - + summary_lines = [] - + # Show top work packages (by priority or recent updates) sorted_packages = sorted( - work_packages, + work_packages, key=lambda wp: ( wp.priority.get("id", 0) if wp.priority else 0, wp.updated_at - ), + ), reverse=True ) - + summary_lines.append(f"Top {min(limit, len(work_packages))} Work Packages:") - + for i, wp in enumerate(sorted_packages[:limit], 1): status_name = wp.status.get("name", "Unknown") if wp.status else "Unknown" priority_name = wp.priority.get("name", "Normal") if wp.priority else "Normal" assignee_name = wp.assignee.get("name", "Unassigned") if wp.assignee else "Unassigned" completion = wp.done_ratio if wp.done_ratio is not None else 0 - + summary_lines.append( f"{i}. [{wp.id}] {wp.subject}\n" f" Status: {status_name} | Priority: {priority_name} | " f"Assignee: {assignee_name} | Progress: {completion}%" ) - + if wp.due_date: summary_lines.append(f" Due Date: {wp.due_date}") - + if len(work_packages) > limit: summary_lines.append(f"\n... and {len(work_packages) - limit} more work packages") - + return "\n".join(summary_lines) - + @staticmethod def create_report_prompt( project_id: str, @@ -229,24 +229,24 @@ def create_report_prompt( analysis: Dict[str, Any] ) -> str: """Create the complete prompt for LLM report generation. - + Args: project_id: Project identifier openproject_base_url: Base URL of OpenProject instance work_packages: List of work packages analysis: Analysis results from ProjectReportAnalyzer - + Returns: Complete formatted prompt string """ template = ProjectStatusReportTemplate.get_default_template() - + # Format analysis data as JSON for better structure analysis_json = json.dumps(analysis, indent=2, default=str) - + # Create work packages summary work_packages_summary = ProjectStatusReportTemplate.format_work_packages_summary(work_packages) - + return template.format( project_id=project_id, openproject_base_url=openproject_base_url, @@ -255,20 +255,20 @@ def create_report_prompt( analysis_data=analysis_json, work_packages_summary=work_packages_summary ) - + @staticmethod def get_custom_template(template_name: str) -> str: """Get a custom report template by name. - + This method can be extended to support multiple report templates for different use cases or organizations. - + Args: template_name: Name of the template to retrieve - + Returns: Template string - + Raises: ValueError: If template name is not found """ @@ -277,12 +277,12 @@ def get_custom_template(template_name: str) -> str: "executive": ProjectStatusReportTemplate._get_executive_template(), "detailed": ProjectStatusReportTemplate._get_detailed_template() } - + if template_name not in templates: raise ValueError(f"Template '{template_name}' not found. Available templates: {list(templates.keys())}") - + return templates[template_name] - + @staticmethod def _get_executive_template() -> str: """Executive-focused template with high-level insights.""" @@ -303,7 +303,7 @@ def _get_executive_template() -> str: Keep the report concise and focused on decision-making insights. """ - + @staticmethod def _get_detailed_template() -> str: """Detailed template for comprehensive analysis.""" From 094feaf69410226dec03d1bb7e2ba01ac6098130 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Wed, 16 Jul 2025 16:06:55 +0200 Subject: [PATCH 03/19] Update suggestion.py --- src/pipelines/suggestion.py | 74 ++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/src/pipelines/suggestion.py b/src/pipelines/suggestion.py index 0e97594..fe10607 100644 --- a/src/pipelines/suggestion.py +++ b/src/pipelines/suggestion.py @@ -64,31 +64,37 @@ def suggest(self, project_id: str) -> Dict[str, Any]: raise def _get_sub_projects_parallel_with_fallback(self, project_id: str) -> List[Dict[str, Any]]: - """Fetch sub-projects (children) in parallel, with fallback to all projects if needed.""" - project_info = asyncio.run(self.openproject_client.get_project_info(project_id)) - children_links = project_info.get("_links", {}).get("children", []) - sub_projects = [] - if children_links: - def fetch_child(child): - href = child.get("href") - if href: - child_id = href.rstrip("/").split("/")[-1] - try: - return asyncio.run(self.openproject_client.get_project_info(child_id)) - except Exception as e: - logger.error(f"Failed to fetch sub-project {child_id}: {e}") - return None - with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: - results = list(executor.map(fetch_child, children_links)) - sub_projects = [sp for sp in results if sp is not None] - else: - logger.info("No children found in _links. Fetching all projects and filtering by parent.") - all_projects = asyncio.run(self.openproject_client.get_all_projects()) - sub_projects = [ - p for p in all_projects - if p.get("_links", {}).get("parent", {}).get("href") == f"/api/v3/projects/{project_id}" - ] - return sub_projects + """Recursively traverse all descendants of the portfolio, skipping Teilportfolio but traversing through them to reach programs and projects. Collect all programs and projects under the portfolio, regardless of depth, but exclude any with 'Teilportfolio' in the name from the results (but not from traversal).""" + all_projects = asyncio.run(self.openproject_client.get_all_projects()) + for p in all_projects: + logger.debug(f"Project {p.get('id')}: _links = {p.get('_links')}") + id_to_project = {str(p.get('id')): p for p in all_projects} + # Build parent->children map + parent_to_children = {} + for p in all_projects: + parent_href = p.get('_links', {}).get('parent', {}).get('href') + if parent_href: + parent_id = parent_href.rstrip('/').split('/')[-1] + parent_to_children.setdefault(parent_id, []).append(p) + # Recursive traversal + def collect_candidates(current_id): + candidates = [] + children = parent_to_children.get(str(current_id), []) + for child in children: + name = child.get('name') or '' + project_type = child.get('projectType') + if 'Teilportfolio' in name: + # Skip as candidate, but traverse its children + candidates.extend(collect_candidates(child.get('id'))) + elif project_type in ('program', 'project'): + candidates.append(child) + # Also traverse children in case of nested programs + candidates.extend(collect_candidates(child.get('id'))) + else: + # Traverse children for any other type + candidates.extend(collect_candidates(child.get('id'))) + return candidates + return collect_candidates(project_id) def _extract_custom_fields(self, project_info: dict) -> dict: """Extract custom fields from a project info dict, using only the 'raw' value if present.""" @@ -142,15 +148,15 @@ def _build_suggestion_prompt(self, portfolio_info: dict, sub_projects: List[dict prompt.append(" Work packages:") prompt.extend([f" - {wp_name}" for wp_name in sp_wp_names]) prompt.append( - "For each sub-project, rate its suitability as a portfolio candidate for the above portfolio project " - "on a scale from 0 to 100 (integer), and briefly explain your reasoning. " - "If there is not enough information to make a judgment, honestly say so and do not continue. " - "Return a JSON list of objects with fields: project_id, score, reason." + "For all projects and programs, rate its suitability as a portfolio candidate for the above portfolio " + "on a scale from 0 to 100 (integer), and briefly explain your reasoning (1-2 sentences). " + "Only return projects that have a value above 70. " + "Return a JSON list of objects with fields: project_id, score, reason" ) return '\n'.join(prompt) - def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict]) -> tuple[List[Candidate], str]: - """Call the LLM and parse the response into Candidate objects.""" + def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict]) -> tuple[list[Candidate], str]: + """Call the LLM and parse the response into Candidate objects. Only keep candidates with score > 70.""" prompt = self._build_suggestion_prompt(portfolio_project, sub_projects) logger.info(f"LLM prompt for suggestion pipeline:\n{prompt}") try: @@ -163,6 +169,8 @@ def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict except Exception as e: logger.error(f"Failed to parse LLM response as JSON: {e}\nResponse: {llm_response}") candidates = self._parse_candidates_from_text(llm_response, sub_projects) + # Only keep candidates with score > 70 + candidates = [c for c in candidates if c.score is not None and c.score > 70] return candidates, llm_response def _dict_to_candidate(self, c: dict, sub_projects: List[dict]) -> Candidate: @@ -228,11 +236,11 @@ def _parse_candidates_from_text(self, text: str, sub_projects: List[dict]) -> Li return candidates def _candidate_to_dict(self, c: Candidate) -> dict: - """Convert a Candidate dataclass to a dict for API response.""" + """Convert a Candidate dataclass to a dict for API response. Include name, project_id, score, and reason.""" return { - "project_id": c.project_id, "name": c.name, "score": c.score, + "project_id": c.project_id, "reason": c.reason } From 85d52a97e7d86c42f39761ee47fdab042a1840ab Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Wed, 16 Jul 2025 16:19:40 +0200 Subject: [PATCH 04/19] Refactor the code --- src/api/routes.py | 3 +-- src/pipelines/suggestion.py | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index 9980895..e56d703 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -345,8 +345,7 @@ def get_model(model_id: str): } ) - -@router.post("/suggest", response_model=SuggestResponse) +@router.post("/evaluate-projects-similarities", response_model=SuggestResponse) def suggest_endpoint(request: SuggestRequest): try: result = pipeline.suggest(request.project_id) diff --git a/src/pipelines/suggestion.py b/src/pipelines/suggestion.py index fe10607..6c25ea8 100644 --- a/src/pipelines/suggestion.py +++ b/src/pipelines/suggestion.py @@ -164,12 +164,27 @@ def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict except TypeError: llm_response = generation_pipeline.generate(prompt) try: + # Try parsing as a JSON array first raw_candidates = json.loads(llm_response) - candidates = [self._dict_to_candidate(c, sub_projects) for c in raw_candidates] - except Exception as e: - logger.error(f"Failed to parse LLM response as JSON: {e}\nResponse: {llm_response}") - candidates = self._parse_candidates_from_text(llm_response, sub_projects) - # Only keep candidates with score > 70 + if isinstance(raw_candidates, dict): + raw_candidates = [raw_candidates] + except Exception: + # Try parsing as multiple JSON objects separated by newlines + raw_candidates = [] + for line in llm_response.splitlines(): + line = line.strip() + if line: + try: + obj = json.loads(line) + raw_candidates.append(obj) + except Exception: + continue + if not raw_candidates: + logger.error(f"Failed to parse LLM response as JSON: {llm_response}") + candidates = self._parse_candidates_from_text(llm_response, sub_projects) + candidates = [c for c in candidates if c.score is not None and c.score > 70] + return candidates, llm_response + candidates = [self._dict_to_candidate(c, sub_projects) for c in raw_candidates] candidates = [c for c in candidates if c.score is not None and c.score > 70] return candidates, llm_response From ca0a5f6cba27609ec3877f1dd9ba28a8b73b21e2 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Wed, 16 Jul 2025 16:26:23 +0200 Subject: [PATCH 05/19] Refactor the code --- src/pipelines/suggestion.py | 42 ++++++++++++++++++++---------- src/services/openproject_client.py | 2 +- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/pipelines/suggestion.py b/src/pipelines/suggestion.py index 6c25ea8..2b1965c 100644 --- a/src/pipelines/suggestion.py +++ b/src/pipelines/suggestion.py @@ -156,34 +156,48 @@ def _build_suggestion_prompt(self, portfolio_info: dict, sub_projects: List[dict return '\n'.join(prompt) def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict]) -> tuple[list[Candidate], str]: - """Call the LLM and parse the response into Candidate objects. Only keep candidates with score > 70.""" prompt = self._build_suggestion_prompt(portfolio_project, sub_projects) logger.info(f"LLM prompt for suggestion pipeline:\n{prompt}") try: llm_response = generation_pipeline.generate(prompt) except TypeError: llm_response = generation_pipeline.generate(prompt) + + raw_candidates = [] + # 1. Try parsing as a JSON array try: - # Try parsing as a JSON array first raw_candidates = json.loads(llm_response) if isinstance(raw_candidates, dict): raw_candidates = [raw_candidates] except Exception: - # Try parsing as multiple JSON objects separated by newlines - raw_candidates = [] - for line in llm_response.splitlines(): - line = line.strip() - if line: + # 2. Try wrapping in brackets and removing trailing commas + try: + fixed = llm_response.strip() + if not fixed.startswith("["): + fixed = "[" + fixed + if not fixed.endswith("]"): + fixed = fixed + "]" + fixed = re.sub(r",\s*]", "]", fixed) + raw_candidates = json.loads(fixed) + if isinstance(raw_candidates, dict): + raw_candidates = [raw_candidates] + except Exception: + # 3. Use regex to extract all JSON objects + objects = re.findall(r"{[^{}]*}", llm_response, re.DOTALL) + # Ensure raw_candidates is a list + if not isinstance(raw_candidates, list): + raw_candidates = [] + for obj in objects: try: - obj = json.loads(line) - raw_candidates.append(obj) + raw_candidates.append(json.loads(obj)) except Exception: continue - if not raw_candidates: - logger.error(f"Failed to parse LLM response as JSON: {llm_response}") - candidates = self._parse_candidates_from_text(llm_response, sub_projects) - candidates = [c for c in candidates if c.score is not None and c.score > 70] - return candidates, llm_response + if not raw_candidates: + logger.error(f"Failed to parse LLM response as JSON: {llm_response}") + candidates = self._parse_candidates_from_text(llm_response, sub_projects) + candidates = [c for c in candidates if c.score is not None and c.score > 70] + return candidates, llm_response + candidates = [self._dict_to_candidate(c, sub_projects) for c in raw_candidates] candidates = [c for c in candidates if c.score is not None and c.score > 70] return candidates, llm_response diff --git a/src/services/openproject_client.py b/src/services/openproject_client.py index ae1bd90..bad67a8 100644 --- a/src/services/openproject_client.py +++ b/src/services/openproject_client.py @@ -179,7 +179,7 @@ async def get_project_info(self, project_id: str) -> Dict[str, Any]: """ url = f"{self.base_url}/api/v3/projects/{project_id}" headers = self.headers.copy() # Use a copy to avoid modifying self.headers - logger.info(f"Sending request to {url} with headers: {headers}") + logger.info(f"Sending request to {url} with headers: [REDACTED]") try: async with httpx.AsyncClient(timeout=300.0) as client: From 28b124529c0ed40e64b0035df8c736e66d7ebe13 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Wed, 16 Jul 2025 16:32:01 +0200 Subject: [PATCH 06/19] Remove files --- .env.backup | 15 ---- LOCAL_OLLAMA_TESTING.md | 153 ---------------------------------- test-local-ollama.sh | 118 -------------------------- test_project_status_report.py | 135 ------------------------------ 4 files changed, 421 deletions(-) delete mode 100644 .env.backup delete mode 100644 LOCAL_OLLAMA_TESTING.md delete mode 100755 test-local-ollama.sh delete mode 100644 test_project_status_report.py diff --git a/.env.backup b/.env.backup deleted file mode 100644 index 02b16d3..0000000 --- a/.env.backup +++ /dev/null @@ -1,15 +0,0 @@ -# Temporary configuration to test local Ollama -# This overrides the default settings to use local Ollama instead of Docker - -# Ollama Configuration - pointing to local instance -OLLAMA_URL=http://host.docker.internal:11434 -OLLAMA_MODEL=mistral:latest - -# Keep other settings from .env.example -LOG_LEVEL=INFO -MODELS_TO_PULL=mistral:latest -REQUIRED_MODELS=mistral:latest -GENERATION_NUM_PREDICT=1000 -GENERATION_TEMPERATURE=0.7 -API_HOST=0.0.0.0 -API_PORT=8000 diff --git a/LOCAL_OLLAMA_TESTING.md b/LOCAL_OLLAMA_TESTING.md deleted file mode 100644 index 1d2e910..0000000 --- a/LOCAL_OLLAMA_TESTING.md +++ /dev/null @@ -1,153 +0,0 @@ -# Local Ollama Testing Setup - -This document explains how to test your application with your local Ollama instance instead of the Docker container for performance comparison. - -## Quick Start - -### Option 1: Using the Test Script (Recommended) - -```bash -# Test with local Ollama -./test-local-ollama.sh local - -# Test with Docker Ollama -./test-local-ollama.sh docker - -# Restore original configuration -./test-local-ollama.sh restore -``` - -### Option 2: Manual Setup - -#### Test with Local Ollama -```bash -# Make sure your local Ollama is running -ollama serve # if not already running - -# Start only the API service with local Ollama configuration -docker-compose -f docker-compose.local-ollama.yml up --build api -``` - -#### Test with Docker Ollama -```bash -# Temporarily disable local configuration -mv .env .env.backup - -# Start full Docker stack -docker-compose up --build -``` - -## Files Created for Testing - -- **`.env`** - Environment configuration pointing to local Ollama -- **`docker-compose.local-ollama.yml`** - Docker compose without Ollama services -- **`test-local-ollama.sh`** - Helper script for easy switching -- **`LOCAL_OLLAMA_TESTING.md`** - This documentation - -## Configuration Details - -### Local Ollama Configuration -- **Ollama URL**: `http://host.docker.internal:11434` -- **Network**: Uses Docker's `host.docker.internal` to access host machine -- **Dependencies**: Removes Docker Ollama service dependencies - -### Performance Benefits -- **No Docker overhead** for Ollama -- **Direct host access** to your local Ollama instance -- **Faster startup** - no need to initialize Ollama container -- **Resource efficiency** - saves 6-8GB Docker memory allocation - -## Prerequisites - -1. **Local Ollama installed and running**: - ```bash - # Check if Ollama is running - curl http://localhost:11434/api/tags - - # Start Ollama if needed - ollama serve - ``` - -2. **Required models available locally**: - ```bash - # Pull required model if not available - ollama pull mistral:latest - ``` - -## Testing Your Application - -1. **Start with local Ollama**: - ```bash - ./test-local-ollama.sh local - ``` - -2. **Test your API endpoints** (in another terminal): - ```bash - # Health check - curl http://localhost:8000/health - - # Test generation endpoint (adjust as needed) - curl -X POST http://localhost:8000/generate \ - -H "Content-Type: application/json" \ - -d '{"prompt": "Test prompt"}' - ``` - -3. **Compare with Docker Ollama**: - ```bash - # Stop local test - Ctrl+C - - # Test with Docker Ollama - ./test-local-ollama.sh docker - ``` - -4. **Restore original setup**: - ```bash - ./test-local-ollama.sh restore - ``` - -## Troubleshooting - -### Local Ollama Not Accessible -- Ensure Ollama is running: `ollama serve` -- Check port 11434 is available: `lsof -i :11434` -- Verify models are available: `ollama list` - -### Docker Network Issues -- On some systems, use `host.docker.internal` -- On Linux, you might need `172.17.0.1` or `localhost` -- If you get "Failed to resolve 'ollama'" errors, the environment variables aren't being loaded properly - -### Environment Variable Issues -- Ensure the `.env` file exists and contains `OLLAMA_URL=http://host.docker.internal:11434` -- The docker-compose file now includes both `env_file` and explicit `environment` settings -- Check container logs: `docker-compose -f docker-compose.local-ollama.yml logs api` - -### Permission Issues -- Make script executable: `chmod +x test-local-ollama.sh` - -### Connection Issues -- Test local Ollama directly: `curl http://localhost:11434/api/tags` -- Test from container perspective: `docker run --rm curlimages/curl curl http://host.docker.internal:11434/api/tags` - -## Cleanup - -To completely remove the testing setup: - -```bash -# Remove testing files -rm .env docker-compose.local-ollama.yml test-local-ollama.sh LOCAL_OLLAMA_TESTING.md - -# Remove any backup files -rm .env.backup 2>/dev/null || true -``` - -## Performance Comparison - -When testing, pay attention to: -- **Startup time** - Local should be much faster -- **Response latency** - Local should have lower latency -- **Memory usage** - Local uses less Docker memory -- **CPU utilization** - May vary depending on your setup - -The local Ollama setup should provide noticeably better performance, especially for development and testing workflows. diff --git a/test-local-ollama.sh b/test-local-ollama.sh deleted file mode 100755 index e8f05a0..0000000 --- a/test-local-ollama.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/bin/bash - -# Test script for comparing local vs Docker Ollama performance -# This script helps you easily switch between configurations and test performance - -set -e - -echo "🚀 Ollama Performance Testing Script" -echo "====================================" - -# Function to check if local Ollama is running -check_local_ollama() { - echo "🔍 Checking if local Ollama is running..." - if curl -s -f "http://localhost:11434/api/tags" > /dev/null 2>&1; then - echo "✅ Local Ollama is running on port 11434" - return 0 - else - echo "❌ Local Ollama is not running or not accessible on port 11434" - return 1 - fi -} - -# Function to test with local Ollama -test_local_ollama() { - echo "" - echo "🏠 Testing with LOCAL Ollama" - echo "----------------------------" - - if ! check_local_ollama; then - echo "Please start your local Ollama first with: ollama serve" - exit 1 - fi - - echo "📦 Starting API service with local Ollama configuration..." - echo "Using docker-compose.local-ollama.yml and .env file" - echo "Environment variables:" - echo " OLLAMA_URL=http://host.docker.internal:11434" - echo " OLLAMA_MODEL=mistral:latest" - - # Stop any running containers first - docker-compose down 2>/dev/null || true - docker-compose -f docker-compose.local-ollama.yml down 2>/dev/null || true - - # Start with local Ollama configuration - docker-compose -f docker-compose.local-ollama.yml up --build api -} - -# Function to test with Docker Ollama -test_docker_ollama() { - echo "" - echo "🐳 Testing with DOCKER Ollama" - echo "-----------------------------" - - # Temporarily rename .env to disable local configuration - if [ -f ".env" ]; then - mv .env .env.backup - echo "📝 Temporarily disabled .env file (backed up as .env.backup)" - fi - - echo "📦 Starting full Docker stack with Ollama container..." - - # Stop any running containers first - docker-compose -f docker-compose.local-ollama.yml down 2>/dev/null || true - - # Start with original Docker configuration - docker-compose up --build -} - -# Function to restore original configuration -restore_config() { - echo "" - echo "🔄 Restoring original configuration" - echo "----------------------------------" - - # Stop all containers - docker-compose down 2>/dev/null || true - docker-compose -f docker-compose.local-ollama.yml down 2>/dev/null || true - - # Restore .env if it was backed up - if [ -f ".env.backup" ]; then - mv .env.backup .env - echo "✅ Restored .env file from backup" - fi - - echo "✅ Configuration restored to original state" -} - -# Function to show usage -show_usage() { - echo "" - echo "Usage: $0 [local|docker|restore]" - echo "" - echo "Commands:" - echo " local - Test with local Ollama instance" - echo " docker - Test with Docker Ollama container" - echo " restore - Restore original configuration" - echo "" - echo "Examples:" - echo " $0 local # Test local Ollama performance" - echo " $0 docker # Test Docker Ollama performance" - echo " $0 restore # Clean up and restore original setup" -} - -# Main script logic -case "${1:-}" in - "local") - test_local_ollama - ;; - "docker") - test_docker_ollama - ;; - "restore") - restore_config - ;; - *) - show_usage - ;; -esac diff --git a/test_project_status_report.py b/test_project_status_report.py deleted file mode 100644 index 45bd11f..0000000 --- a/test_project_status_report.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the project status report endpoint. -This demonstrates how to use the new endpoint to generate project status reports. -""" - -import requests -import json -import sys - -# Configuration -BASE_URL = "http://localhost:8000" -HEADERS = {"Content-Type": "application/json"} - - -def test_project_status_report(): - """Test the project status report endpoint.""" - print("Testing project status report generation...") - - # Example request data - payload = { - "project_id": "258", # Replace with actual project ID - "openproject_base_url": "https://qa.openproject-stage.com" # Replace with actual URL - } - - # Example API key - replace with actual OpenProject API key - api_key = "0328bb2c3b0d9bd18b579c29eb5ccb90c68c2b79f789760bfeac865fe2147c3f" - headers = { - **HEADERS, - "Authorization": f"Bearer {api_key}" - } - - print(f"Request payload: {json.dumps(payload, indent=2)}") - print(f"Authorization header: Bearer {api_key[:10]}...") - - try: - response = requests.post( - f"{BASE_URL}/generate-project-status-report", - headers=headers, - json=payload, - timeout=300 # Longer timeout for report generation - ) - - print(f"Status: {response.status_code}") - - if response.status_code == 200: - result = response.json() - print("✅ Project status report generated successfully!") - print(f"Project ID: {result['project_id']}") - print(f"Work packages analyzed: {result['work_packages_analyzed']}") - print(f"Generated at: {result['generated_at']}") - print(f"OpenProject URL: {result['openproject_base_url']}") - print("\n" + "="*80) - print("GENERATED REPORT:") - print("="*80) - print(result['report']) - print("="*80) - else: - error_data = response.json() - print(f"❌ Error: {response.status_code}") - print(f"Error details: {json.dumps(error_data, indent=2)}") - - except requests.exceptions.Timeout: - print("❌ Request timed out. Report generation may take longer for large projects.") - except requests.exceptions.ConnectionError: - print("❌ Could not connect to the server.") - print("Make sure the server is running on http://localhost:8000") - except Exception as e: - print(f"❌ Error: {e}") - - -def test_health_check(): - """Test the health check endpoint.""" - print("Testing health check...") - try: - response = requests.get(f"{BASE_URL}/health") - print(f"Status: {response.status_code}") - print(f"Response: {response.json()}") - return response.status_code == 200 - except Exception as e: - print(f"Health check failed: {e}") - return False - - -def show_usage(): - """Show usage instructions.""" - print("=" * 60) - print("Project Status Report API Test") - print("=" * 60) - print() - print("Before running this test, you need to:") - print("1. Have an OpenProject instance running") - print("2. Get an API key from your OpenProject instance") - print("3. Know the project ID you want to analyze") - print("4. Update the configuration in this script:") - print(" - project_id: Your OpenProject project ID") - print(" - openproject_base_url: Your OpenProject instance URL") - print(" - api_key: Your OpenProject API key") - print() - print("Example curl command:") - print('curl -X POST "http://localhost:8000/generate-project-status-report" \\') - print(' -H "Content-Type: application/json" \\') - print(' -H "Authorization: Bearer YOUR_API_KEY" \\') - print(' -d \'{') - print(' "project_id": "1",') - print(' "openproject_base_url": "https://your-openproject-instance.com"') - print(' }\'') - print() - - -def main(): - """Run the tests.""" - show_usage() - - print("Testing server connectivity...") - if not test_health_check(): - print("❌ Server is not responding. Please start the server first.") - sys.exit(1) - - print("\n" + "="*60) - print("IMPORTANT: Update the configuration in this script before running!") - print("="*60) - - # Check if user wants to proceed with example values - user_input = input("\nDo you want to proceed with the example test? (y/N): ") - if user_input.lower() != 'y': - print("Please update the configuration in the script and run again.") - return - - print("\n" + "="*60) - test_project_status_report() - - -if __name__ == "__main__": - main() From fc3cff4fa4840336bdca047c0f766b7f91eabea9 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Wed, 16 Jul 2025 16:37:23 +0200 Subject: [PATCH 07/19] Update docker-compose.yml --- docker-compose.yml | 46 +++++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index cfd523a..60c5ce8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,14 @@ services: environment: - OLLAMA_MAX_LOADED_MODELS=1 - OLLAMA_NUM_PARALLEL=4 + deploy: + resources: + limits: + memory: 8G + cpus: '4.0' + reservations: + memory: 6G + cpus: '2.0' healthcheck: test: ["CMD", "/bin/ollama", "list"] interval: 30s @@ -18,20 +26,20 @@ services: - haystack-internal command: serve - # ollama-init: - # image: curlimages/curl:latest - # volumes: - # - ./scripts:/scripts:ro - # environment: - # - OLLAMA_HOST=http://ollama:11434 - # - MODELS_TO_PULL=${MODELS_TO_PULL:-mistral:latest} - # depends_on: - # ollama: - # condition: service_healthy - # networks: - # - haystack-internal - # command: ["/bin/sh", "/scripts/init-ollama-models.sh"] - # restart: "no" + ollama-init: + image: curlimages/curl:latest + volumes: + - ./scripts:/scripts:ro + environment: + - OLLAMA_HOST=http://ollama:11434 + - MODELS_TO_PULL=${MODELS_TO_PULL:-mistral:latest} + depends_on: + ollama: + condition: service_healthy + networks: + - haystack-internal + command: ["/bin/sh", "/scripts/init-ollama-models.sh"] + restart: "no" api: build: @@ -56,14 +64,14 @@ services: cpus: '0.5' depends_on: - ollama - # - ollama-init + - ollama-init healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s + interval: 10s timeout: 5s retries: 5 networks: - - openproject_network + # - openproject_network - haystack-internal command: uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload @@ -72,7 +80,7 @@ volumes: ollama-data: networks: - openproject_network: - external: true + #openproject_network: + # external: true haystack-internal: driver: bridge From 020180535e93dac52cbc17e21568946fa01bc734 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 10:18:14 +0200 Subject: [PATCH 08/19] Address PR comments --- src/api/routes.py | 2 +- src/pipelines/suggestion.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index e56d703..28561b9 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -348,7 +348,7 @@ def get_model(model_id: str): @router.post("/evaluate-projects-similarities", response_model=SuggestResponse) def suggest_endpoint(request: SuggestRequest): try: - result = pipeline.suggest(request.project_id) + result = pipeline.suggest(str(request.project_id)) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/pipelines/suggestion.py b/src/pipelines/suggestion.py index 2b1965c..ff86b2f 100644 --- a/src/pipelines/suggestion.py +++ b/src/pipelines/suggestion.py @@ -156,13 +156,13 @@ def _build_suggestion_prompt(self, portfolio_info: dict, sub_projects: List[dict return '\n'.join(prompt) def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict]) -> tuple[list[Candidate], str]: + """ + Parse LLM response and return a list of suitable candidate projects. + Returns: (List[Candidate], str) -- candidates and raw LLM response. + """ prompt = self._build_suggestion_prompt(portfolio_project, sub_projects) logger.info(f"LLM prompt for suggestion pipeline:\n{prompt}") - try: - llm_response = generation_pipeline.generate(prompt) - except TypeError: - llm_response = generation_pipeline.generate(prompt) - + llm_response = generation_pipeline.generate(prompt) raw_candidates = [] # 1. Try parsing as a JSON array try: @@ -183,8 +183,8 @@ def _llm_score_candidates(self, portfolio_project: dict, sub_projects: List[dict raw_candidates = [raw_candidates] except Exception: # 3. Use regex to extract all JSON objects + # This regex extracts all top-level JSON objects from the LLM response. objects = re.findall(r"{[^{}]*}", llm_response, re.DOTALL) - # Ensure raw_candidates is a list if not isinstance(raw_candidates, list): raw_candidates = [] for obj in objects: From e4b995126c378112c804e29745dcf716773ecd5f Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 11:10:39 +0200 Subject: [PATCH 09/19] PR comments address --- src/pipelines/suggestion.py | 4 ++-- src/services/openproject_client.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/pipelines/suggestion.py b/src/pipelines/suggestion.py index ff86b2f..d31e8a9 100644 --- a/src/pipelines/suggestion.py +++ b/src/pipelines/suggestion.py @@ -1,5 +1,5 @@ import logging -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Union from dataclasses import dataclass from config.settings import settings from src.services.openproject_client import OpenProjectClient, OpenProjectAPIError @@ -13,7 +13,7 @@ @dataclass class Candidate: - project_id: Any + project_id: Union[str, int] name: str score: Optional[float] reason: str diff --git a/src/services/openproject_client.py b/src/services/openproject_client.py index bad67a8..60bb054 100644 --- a/src/services/openproject_client.py +++ b/src/services/openproject_client.py @@ -52,12 +52,11 @@ async def get_work_packages(self, project_id: str) -> List[WorkPackage]: OpenProjectAPIError: If API request fails """ url = f"{self.base_url}/api/v3/projects/{project_id}/work_packages" - + headers = self.headers.copy() # Use a copy to avoid modifying self.headers try: async with httpx.AsyncClient(timeout=300.0) as client: logger.info(f"Fetching work packages from: {url}") - - response = await client.get(url, headers=self.headers) + response = await client.get(url, headers=headers) if response.status_code == 401: raise OpenProjectAPIError( From 7e624eadeffd503a154742531c3bd8c55ef9b197 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 11:38:23 +0200 Subject: [PATCH 10/19] Update report_templates.py --- src/templates/report_templates.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/templates/report_templates.py b/src/templates/report_templates.py index 02fb71f..7a92966 100644 --- a/src/templates/report_templates.py +++ b/src/templates/report_templates.py @@ -1,7 +1,7 @@ """Report templates for project status report generation.""" from typing import List, Dict, Any -from src.api.schemas import WorkPackage +from src.models.schemas import WorkPackage from datetime import datetime, timedelta import json @@ -266,7 +266,7 @@ def create_enhanced_report_prompt( pmflex_context: str ) -> str: """Create an enhanced prompt with PMFlex RAG context. - + Args: project_id: Project identifier project_type: Type of project (portfolio, program, project) @@ -274,18 +274,18 @@ def create_enhanced_report_prompt( work_packages: List of work packages analysis: Analysis results from ProjectReportAnalyzer pmflex_context: PMFlex context from RAG system - + Returns: Complete formatted prompt string with RAG enhancement """ template = ProjectStatusReportTemplate.get_enhanced_template() - + # Format analysis data as JSON for better structure analysis_json = json.dumps(analysis, indent=2, default=str) - + # Create work packages summary work_packages_summary = ProjectStatusReportTemplate.format_work_packages_summary(work_packages) - + return template.format( project_id=project_id, project_type=project_type, @@ -296,11 +296,11 @@ def create_enhanced_report_prompt( work_packages_summary=work_packages_summary, pmflex_context=pmflex_context or "No PMFlex context available." ) - + @staticmethod def get_enhanced_template() -> str: """Get the enhanced project status report template with PMFlex context. - + Returns: Template string for LLM prompt with RAG enhancement """ @@ -381,7 +381,7 @@ def get_enhanced_template() -> str: The report should reflect PMFlex principles of transparency, accountability, and systematic project management approach used in German federal administration. Prioritize clarity and actionable information for project stakeholders and governance bodies. """ - + @staticmethod def get_custom_template(template_name: str) -> str: """Get a custom report template by name. From f37e877394fcf3a6779ab7aa258ec360ae2ce581 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 15:30:39 +0200 Subject: [PATCH 11/19] Remove api.schemas --- src/api/schemas.py | 95 ------------------------------ src/models/schemas.py | 63 +++++++++----------- src/services/openproject_client.py | 2 +- 3 files changed, 28 insertions(+), 132 deletions(-) delete mode 100644 src/api/schemas.py diff --git a/src/api/schemas.py b/src/api/schemas.py deleted file mode 100644 index 85ae3ce..0000000 --- a/src/api/schemas.py +++ /dev/null @@ -1,95 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Dict, Any, Optional, Union - -# Suggestion schemas -class CandidateSuggestion(BaseModel): - name: Optional[str] = None - score: Optional[float] = None - project_id: Optional[int] = None - reason: str - -class SuggestRequest(BaseModel): - project_id: int - -class SuggestResponse(BaseModel): - portfolio: Optional[str] = None - candidates: List[CandidateSuggestion] - text: str - -# Generation schemas -class GenerationRequest(BaseModel): - prompt: str - -class GenerationResponse(BaseModel): - response: str - -# Health check -class HealthResponse(BaseModel): - status: str - -# OpenAI-compatible schemas -class ChatMessage(BaseModel): - role: str - content: str - -class ChatCompletionRequest(BaseModel): - model: str - messages: List[ChatMessage] - max_tokens: Optional[int] = 1000 - temperature: Optional[float] = 0.7 - top_p: Optional[float] = 1.0 - stop: Optional[List[str]] = None - -class ChatChoice(BaseModel): - index: int - message: ChatMessage - finish_reason: Optional[str] = None - -class Usage(BaseModel): - prompt_tokens: int - completion_tokens: int - total_tokens: int - -class ChatCompletionResponse(BaseModel): - id: str - model: str - choices: List[ChatChoice] - usage: Usage - -class ModelInfo(BaseModel): - id: str - -class ModelsResponse(BaseModel): - data: List[ModelInfo] - -class ErrorDetail(BaseModel): - message: str - type: str - param: Optional[str] = None - code: Optional[str] = None - -class ErrorResponse(BaseModel): - error: ErrorDetail - -# Project Status Report schemas -class ProjectStatusReportRequest(BaseModel): - project_id: str - openproject_base_url: str - -class ProjectStatusReportResponse(BaseModel): - project_id: str - report: str - work_packages_analyzed: int - openproject_base_url: str - -class WorkPackage(BaseModel): - id: str - subject: str - status: Optional[Dict[str, Any]] = None - priority: Optional[Dict[str, Any]] = None - assignee: Optional[Dict[str, Any]] = None - due_date: Optional[str] = None - done_ratio: Optional[int] = None - created_at: Optional[str] = None - updated_at: Optional[str] = None - description: Optional[Dict[str, Any]] = None diff --git a/src/models/schemas.py b/src/models/schemas.py index c45bda4..363ea09 100644 --- a/src/models/schemas.py +++ b/src/models/schemas.py @@ -1,34 +1,45 @@ """Pydantic models for API request/response schemas.""" from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any, Literal +from typing import List, Optional, Dict, Any, Literal, Union from datetime import datetime import time +# --- Suggestion Schemas --- +class CandidateSuggestion(BaseModel): + name: Optional[str] = None + score: Optional[float] = None + project_id: Optional[Union[int, str]] = None + reason: str +class SuggestRequest(BaseModel): + project_id: Union[int, str] + +class SuggestResponse(BaseModel): + portfolio: Optional[str] = None + candidates: List[CandidateSuggestion] + text: str + +# --- Generation Schemas --- class GenerationRequest(BaseModel): """Request model for text generation.""" prompt: str - class GenerationResponse(BaseModel): """Response model for text generation.""" response: str - +# --- Health Check --- class HealthResponse(BaseModel): """Response model for health check.""" status: str - -# OpenAI Chat Completion Compatible Models - +# --- OpenAI Chat Completion Compatible Models --- class ChatMessage(BaseModel): """A chat message with role and content.""" - role: Literal["system", "user", "assistant"] + role: str # Accept any string for compatibility content: str - class ChatCompletionRequest(BaseModel): """OpenAI-compatible chat completion request.""" model: str = "mistral:latest" @@ -41,45 +52,33 @@ class ChatCompletionRequest(BaseModel): stop: Optional[List[str]] = None stream: Optional[bool] = False - class Usage(BaseModel): """Token usage information.""" prompt_tokens: int completion_tokens: int total_tokens: int - class ChatChoice(BaseModel): """A chat completion choice.""" index: int message: ChatMessage - finish_reason: Literal["stop", "length", "content_filter"] = "stop" - + finish_reason: Optional[str] = None class ChatCompletionResponse(BaseModel): """OpenAI-compatible chat completion response.""" id: str - object: str = "chat.completion" - created: int = Field(default_factory=lambda: int(time.time())) model: str choices: List[ChatChoice] usage: Usage - class ModelInfo(BaseModel): """Model information.""" id: str - object: str = "model" - created: int = Field(default_factory=lambda: int(time.time())) - owned_by: str = "local" - class ModelsResponse(BaseModel): """Response for models endpoint.""" - object: str = "list" data: List[ModelInfo] - class ErrorDetail(BaseModel): """Error detail information.""" message: str @@ -87,51 +86,43 @@ class ErrorDetail(BaseModel): param: Optional[str] = None code: Optional[str] = None - class ErrorResponse(BaseModel): """OpenAI-compatible error response.""" error: ErrorDetail - -# Project Status Report Models - +# --- Project Status Report Models --- class ProjectInfo(BaseModel): """Project information model.""" - id: int = Field(..., description="OpenProject project ID") + id: Union[int, str] = Field(..., description="OpenProject project ID") type: str = Field(..., description="Project type (e.g., 'portfolio')") - class OpenProjectInfo(BaseModel): """OpenProject instance information model.""" base_url: str = Field(..., description="Base URL of OpenProject instance") user_token: str = Field(..., description="OpenProject user API token") - class ProjectStatusReportRequest(BaseModel): """Request model for project status report generation.""" project: ProjectInfo = Field(..., description="Project information") openproject: OpenProjectInfo = Field(..., description="OpenProject instance information") - class WorkPackage(BaseModel): """Model for OpenProject work package data.""" - id: int + id: Union[int, str] subject: str - status: Dict[str, Any] + status: Optional[Dict[str, Any]] = None priority: Optional[Dict[str, Any]] = None assignee: Optional[Dict[str, Any]] = None due_date: Optional[str] = None done_ratio: Optional[int] = None - created_at: str - updated_at: str + created_at: Optional[str] = None + updated_at: Optional[str] = None description: Optional[Dict[str, Any]] = None - class ProjectStatusReportResponse(BaseModel): """Response model for project status report.""" - project_id: int + project_id: Union[int, str] project_type: str report: str - generated_at: str = Field(default_factory=lambda: datetime.now().isoformat()) work_packages_analyzed: int openproject_base_url: str diff --git a/src/services/openproject_client.py b/src/services/openproject_client.py index 60bb054..e339fbe 100644 --- a/src/services/openproject_client.py +++ b/src/services/openproject_client.py @@ -4,7 +4,7 @@ import logging import base64 from typing import List, Dict, Any, Optional -from src.api.schemas import WorkPackage +from src.models.schemas import WorkPackage logger = logging.getLogger(__name__) From 40552f6bdf978f27886cd4f6a948cd071ad3485e Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 15:51:16 +0200 Subject: [PATCH 12/19] Refactor --- README.md | 36 +++++++++++++++++++++++++++++++ config/settings.py | 9 ++------ src/api/routes.py | 10 +++++++-- src/templates/report_templates.py | 2 +- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6de70c8..f7e75be 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,42 @@ This ensures that your application always has the required models available and ### Project Management Endpoints - `POST /generate-project-status-report` - Generate project status report from OpenProject data +### Evaluate Projects Similarities + +**POST** `/evaluate-projects-similarities` + +**Request Body:** +```json +{ + "project": { + "id": "YOUR_PORTFOLIO_PROJECT_ID" + }, + "openproject": { + "base_url": "https://your-openproject-instance.com", + "user_token": "YOUR_OPENPROJECT_API_KEY" + } +} +``` + +**Response Example:** +```json +{ + "portfolio": "Portfolio Project Name", + "candidates": [ + { + "name": "Candidate Project 1", + "score": 85.0, + "project_id": "123", + "reason": "Strong alignment with portfolio goals." + } + ], + "text": "...LLM explanation..." +} +``` + +**Note:** +- The `project` object only requires an `id` field. The `type` field is no longer used. +- The `openproject` object must be provided with each request and contains the OpenProject instance URL and user API token. ## OpenAI API Compatibility diff --git a/config/settings.py b/config/settings.py index a123080..27764a4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -13,7 +13,7 @@ class Settings: # Ollama configuration OLLAMA_URL: str = os.getenv("OLLAMA_URL", "http://ollama:11434") - OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "mistral:7B") + OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "gemma:2b") # Model management MODELS_TO_PULL: str = os.getenv("MODELS_TO_PULL", "mistral:latest") @@ -26,7 +26,7 @@ class Settings: # API configuration API_HOST: str = os.getenv("API_HOST", "0.0.0.0") API_PORT: int = int(os.getenv("API_PORT", "8000")) - + # RAG Configuration DOCUMENTS_PATH: str = os.getenv("DOCUMENTS_PATH", "documents") VECTOR_STORE_PATH: str = os.getenv("VECTOR_STORE_PATH", "vector_store") @@ -36,9 +36,4 @@ class Settings: CHUNK_OVERLAP: int = int(os.getenv("CHUNK_OVERLAP", "100")) MAX_RETRIEVED_DOCS: int = int(os.getenv("MAX_RETRIEVED_DOCS", "5")) - # OpenProject configuration - OPENPROJECT_BASE_URL: str = os.getenv("OPENPROJECT_BASE_URL", "") - OPENPROJECT_API_KEY: str = os.getenv("OPENPROJECT_API_KEY", "") - - settings = Settings() diff --git a/src/api/routes.py b/src/api/routes.py index 1bcc456..c5880e0 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -12,7 +12,6 @@ from src.services.openproject_client import OpenProjectClient, OpenProjectAPIError import uuid import logging -from src.pipelines.suggestion import pipeline logger = logging.getLogger(__name__) router = APIRouter() @@ -458,7 +457,14 @@ def get_model(model_id: str): @router.post("/evaluate-projects-similarities", response_model=SuggestResponse) def suggest_endpoint(request: SuggestRequest): try: - result = pipeline.suggest(str(request.project_id)) + # Use OpenProject info from the request, not from config + openproject_client = OpenProjectClient( + base_url=request.openproject.base_url, + api_key=request.openproject.user_token + ) + from src.pipelines.suggestion import SuggestionPipeline + suggestion_pipeline = SuggestionPipeline(openproject_client) + result = suggestion_pipeline.suggest(str(request.project.id)) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/templates/report_templates.py b/src/templates/report_templates.py index 7a92966..58df439 100644 --- a/src/templates/report_templates.py +++ b/src/templates/report_templates.py @@ -263,7 +263,7 @@ def create_enhanced_report_prompt( openproject_base_url: str, work_packages: List[WorkPackage], analysis: Dict[str, Any], - pmflex_context: str + pmflex_context: str = "" ) -> str: """Create an enhanced prompt with PMFlex RAG context. From 0de451c1bd86e0f047a4c905b68de216126cc542 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 16:03:47 +0200 Subject: [PATCH 13/19] Refactor --- src/api/routes.py | 2 +- src/models/schemas.py | 5 ++++- src/pipelines/suggestion.py | 3 --- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index c5880e0..45b0f7b 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -455,7 +455,7 @@ def get_model(model_id: str): ) @router.post("/evaluate-projects-similarities", response_model=SuggestResponse) -def suggest_endpoint(request: SuggestRequest): +def suggest_endpoint(request: ProjectSimilarityRequest): try: # Use OpenProject info from the request, not from config openproject_client = OpenProjectClient( diff --git a/src/models/schemas.py b/src/models/schemas.py index 363ea09..439efa0 100644 --- a/src/models/schemas.py +++ b/src/models/schemas.py @@ -94,7 +94,6 @@ class ErrorResponse(BaseModel): class ProjectInfo(BaseModel): """Project information model.""" id: Union[int, str] = Field(..., description="OpenProject project ID") - type: str = Field(..., description="Project type (e.g., 'portfolio')") class OpenProjectInfo(BaseModel): """OpenProject instance information model.""" @@ -106,6 +105,10 @@ class ProjectStatusReportRequest(BaseModel): project: ProjectInfo = Field(..., description="Project information") openproject: OpenProjectInfo = Field(..., description="OpenProject instance information") +class ProjectSimilarityRequest(BaseModel): + project: ProjectInfo + openproject: OpenProjectInfo + class WorkPackage(BaseModel): """Model for OpenProject work package data.""" id: Union[int, str] diff --git a/src/pipelines/suggestion.py b/src/pipelines/suggestion.py index d31e8a9..279657a 100644 --- a/src/pipelines/suggestion.py +++ b/src/pipelines/suggestion.py @@ -272,6 +272,3 @@ def _candidate_to_dict(self, c: Candidate) -> dict: "project_id": c.project_id, "reason": c.reason } - -# Global pipeline instance for import convenience -pipeline = SuggestionPipeline(OpenProjectClient(settings.OPENPROJECT_BASE_URL, settings.OPENPROJECT_API_KEY)) From f2aa2ba34d7dd9bc36eca93dd04ccc817dbe5089 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 16:08:40 +0200 Subject: [PATCH 14/19] Update routes.py --- src/api/routes.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index 45b0f7b..a75772e 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -6,7 +6,7 @@ ChatCompletionRequest, ChatCompletionResponse, ChatMessage, ChatChoice, Usage, ModelsResponse, ModelInfo, ErrorResponse, ErrorDetail, ProjectStatusReportRequest, ProjectStatusReportResponse, - SuggestRequest, SuggestResponse + SuggestRequest, SuggestResponse, ProjectSimilarityRequest ) from src.pipelines.generation import generation_pipeline from src.services.openproject_client import OpenProjectClient, OpenProjectAPIError @@ -256,7 +256,6 @@ async def generate_project_status_report( try: # Extract values from the new request structure project_id = request.project.id - project_type = request.project.type base_url = request.openproject.base_url user_token = request.openproject.user_token @@ -279,7 +278,7 @@ async def generate_project_status_report( api_key=user_token ) - logger.info(f"Generating project status report for project {project_id} (type: {project_type})") + logger.info(f"Generating project status report for project {project_id}") # Fetch work packages from OpenProject try: @@ -334,7 +333,6 @@ async def generate_project_status_report( try: report_text, analysis = generation_pipeline.generate_project_status_report( project_id=str(project_id), - project_type=project_type, openproject_base_url=base_url, work_packages=work_packages ) @@ -343,7 +341,7 @@ async def generate_project_status_report( return ProjectStatusReportResponse( project_id=project_id, - project_type=project_type, + project_type="", report=report_text, work_packages_analyzed=len(work_packages), openproject_base_url=base_url From 479762d88ae107ed051329a5cc7fb6a18ad5e2e5 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 16:09:33 +0200 Subject: [PATCH 15/19] Update generation.py --- src/pipelines/generation.py | 89 ++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/src/pipelines/generation.py b/src/pipelines/generation.py index 88c062c..1468c1a 100644 --- a/src/pipelines/generation.py +++ b/src/pipelines/generation.py @@ -1,6 +1,6 @@ """Haystack pipeline for text generation.""" -from haystack_integrations.components.generators.ollama import OllamaGenerator # type: ignore # May not resolve in linter, but needed at runtime +from haystack_integrations.components.generators.ollama import OllamaGenerator from config.settings import settings from src.models.schemas import ChatMessage, ChatCompletionRequest, WorkPackage from src.templates.report_templates import ProjectReportAnalyzer, ProjectStatusReportTemplate @@ -10,20 +10,17 @@ import requests import logging -# Ensure ProjectReportAnalyzer and ProjectStatusReportTemplate use src.models.schemas.WorkPackage -# (If needed, update their imports in src/templates/report_templates.py) - logger = logging.getLogger(__name__) class GenerationPipeline: """Pipeline for text generation using Ollama.""" - + def __init__(self): """Initialize the generation pipeline.""" # Validate that required models are available self._validate_models() - + self.generator = OllamaGenerator( model=settings.OLLAMA_MODEL, url=settings.OLLAMA_URL, @@ -32,18 +29,18 @@ def __init__(self): "temperature": settings.GENERATION_TEMPERATURE } ) - + def _validate_models(self): """Validate that required models are available in Ollama.""" try: available_models = self._get_ollama_models() required_models = [model.strip() for model in settings.REQUIRED_MODELS if model.strip()] - + missing_models = [] for model in required_models: if model not in available_models: missing_models.append(model) - + if missing_models: logger.error(f"Missing required models: {missing_models}") logger.error(f"Available models: {available_models}") @@ -52,19 +49,19 @@ def _validate_models(self): f"Available models: {available_models}. " f"Please ensure the ollama-init service has completed successfully." ) - + logger.info(f"All required models are available: {required_models}") - + except requests.exceptions.RequestException as e: logger.error(f"Failed to connect to Ollama service: {e}") raise RuntimeError( f"Cannot connect to Ollama service at {settings.OLLAMA_URL}. " f"Please ensure the Ollama service is running and accessible." ) - + def _get_ollama_models(self) -> List[str]: """Get list of models available in Ollama. - + Returns: List of available model names """ @@ -76,31 +73,31 @@ def _get_ollama_models(self) -> List[str]: except requests.exceptions.RequestException as e: logger.error(f"Failed to fetch models from Ollama: {e}") raise - + def generate(self, prompt: str) -> str: """Generate text from a prompt. - + Args: prompt: The input prompt for generation - + Returns: Generated text response """ result = self.generator.run(prompt) return result["replies"][0] - + def chat_completion(self, request: ChatCompletionRequest) -> Tuple[str, dict]: """Generate chat completion response. - + Args: request: Chat completion request with messages and parameters - + Returns: Tuple of (generated_response, usage_info) """ # Convert messages to a single prompt prompt = self._messages_to_prompt(request.messages) - + # Create generator with request-specific parameters generator = OllamaGenerator( model=request.model, @@ -112,34 +109,34 @@ def chat_completion(self, request: ChatCompletionRequest) -> Tuple[str, dict]: "stop": request.stop or [] } ) - + # Generate response result = generator.run(prompt) response_text = result["replies"][0] - + # Calculate token usage (approximate) prompt_tokens = self._estimate_tokens(prompt) completion_tokens = self._estimate_tokens(response_text) - + usage = { "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens } - + return response_text, usage - + def _messages_to_prompt(self, messages: List[ChatMessage]) -> str: """Convert chat messages to a single prompt string. - + Args: messages: List of chat messages - + Returns: Formatted prompt string """ prompt_parts = [] - + for message in messages: if message.role == "system": prompt_parts.append(f"System: {message.content}") @@ -147,29 +144,29 @@ def _messages_to_prompt(self, messages: List[ChatMessage]) -> str: prompt_parts.append(f"User: {message.content}") elif message.role == "assistant": prompt_parts.append(f"Assistant: {message.content}") - + # Add final prompt for assistant response prompt_parts.append("Assistant:") - + return "\n\n".join(prompt_parts) - + def _estimate_tokens(self, text: str) -> int: """Estimate token count for text. - + This is a rough approximation. For more accurate counting, you might want to use a proper tokenizer. - + Args: text: Text to estimate tokens for - + Returns: Estimated token count """ # Rough approximation: 1 token ≈ 4 characters for English text return max(1, len(text) // 4) - + def generate_project_status_report( - self, + self, project_id: str, project_type: str, openproject_base_url: str, @@ -177,21 +174,21 @@ def generate_project_status_report( template_name: str = "default" ) -> Tuple[str, Dict[str, Any]]: """Generate a project status report from work packages with RAG enhancement. - + Args: project_id: OpenProject project ID project_type: Type of project (portfolio, program, project) openproject_base_url: Base URL of OpenProject instance work_packages: List of work packages to analyze template_name: Name of the report template to use - + Returns: Tuple of (generated_report, analysis_data) """ # Analyze work packages analyzer = ProjectReportAnalyzer() analysis = analyzer.analyze_work_packages(work_packages) - + # Enhance with RAG context try: from src.pipelines.rag_pipeline import rag_pipeline @@ -205,7 +202,7 @@ def generate_project_status_report( except Exception as e: logger.warning(f"Could not enhance with RAG context: {e}") rag_context = {'pmflex_context': ''} - + # Create report prompt using template with RAG enhancement template = ProjectStatusReportTemplate() prompt = template.create_enhanced_report_prompt( @@ -216,7 +213,7 @@ def generate_project_status_report( analysis=analysis, pmflex_context=rag_context.get('pmflex_context', '') ) - + # Generate report using LLM generator = OllamaGenerator( model=settings.OLLAMA_MODEL, @@ -226,18 +223,18 @@ def generate_project_status_report( "temperature": 0.3, # Lower temperature for more consistent reports } ) - + result = generator.run(prompt) report_text = result["replies"][0] - + # Add RAG context info to analysis analysis['rag_context'] = rag_context - + return report_text, analysis - + def get_available_models(self) -> List[str]: """Get list of available models. - + Returns: List of available model names """ From 178e82ac5fd18f451e1c8cfbdacf50114bb173e2 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 16:16:12 +0200 Subject: [PATCH 16/19] Update schemas.py --- src/models/schemas.py | 82 ++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/src/models/schemas.py b/src/models/schemas.py index 439efa0..c5b9c48 100644 --- a/src/models/schemas.py +++ b/src/models/schemas.py @@ -1,45 +1,49 @@ """Pydantic models for API request/response schemas.""" from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any, Literal, Union +from typing import List, Optional, Dict, Any, Literal from datetime import datetime import time + +class GenerationRequest(BaseModel): + """Request model for text generation.""" + prompt: str + + +class GenerationResponse(BaseModel): + """Response model for text generation.""" + response: str + + +class HealthResponse(BaseModel): + """Response model for health check.""" + status: str + + # --- Suggestion Schemas --- class CandidateSuggestion(BaseModel): name: Optional[str] = None score: Optional[float] = None - project_id: Optional[Union[int, str]] = None + project_id: int reason: str class SuggestRequest(BaseModel): - project_id: Union[int, str] + project_id: int class SuggestResponse(BaseModel): portfolio: Optional[str] = None candidates: List[CandidateSuggestion] text: str -# --- Generation Schemas --- -class GenerationRequest(BaseModel): - """Request model for text generation.""" - prompt: str - -class GenerationResponse(BaseModel): - """Response model for text generation.""" - response: str - -# --- Health Check --- -class HealthResponse(BaseModel): - """Response model for health check.""" - status: str +# OpenAI Chat Completion Compatible Models -# --- OpenAI Chat Completion Compatible Models --- class ChatMessage(BaseModel): """A chat message with role and content.""" - role: str # Accept any string for compatibility + role: Literal["system", "user", "assistant"] content: str + class ChatCompletionRequest(BaseModel): """OpenAI-compatible chat completion request.""" model: str = "mistral:latest" @@ -52,33 +56,45 @@ class ChatCompletionRequest(BaseModel): stop: Optional[List[str]] = None stream: Optional[bool] = False + class Usage(BaseModel): """Token usage information.""" prompt_tokens: int completion_tokens: int total_tokens: int + class ChatChoice(BaseModel): """A chat completion choice.""" index: int message: ChatMessage - finish_reason: Optional[str] = None + finish_reason: Literal["stop", "length", "content_filter"] = "stop" + class ChatCompletionResponse(BaseModel): """OpenAI-compatible chat completion response.""" id: str + object: str = "chat.completion" + created: int = Field(default_factory=lambda: int(time.time())) model: str choices: List[ChatChoice] usage: Usage + class ModelInfo(BaseModel): """Model information.""" id: str + object: str = "model" + created: int = Field(default_factory=lambda: int(time.time())) + owned_by: str = "local" + class ModelsResponse(BaseModel): """Response for models endpoint.""" + object: str = "list" data: List[ModelInfo] + class ErrorDetail(BaseModel): """Error detail information.""" message: str @@ -86,46 +102,54 @@ class ErrorDetail(BaseModel): param: Optional[str] = None code: Optional[str] = None + class ErrorResponse(BaseModel): """OpenAI-compatible error response.""" error: ErrorDetail -# --- Project Status Report Models --- + +# Project Status Report Models + class ProjectInfo(BaseModel): """Project information model.""" - id: Union[int, str] = Field(..., description="OpenProject project ID") + id: int = Field(..., description="OpenProject project ID") + type: str = Field(..., description="Project type (e.g., 'portfolio')") + class OpenProjectInfo(BaseModel): """OpenProject instance information model.""" base_url: str = Field(..., description="Base URL of OpenProject instance") user_token: str = Field(..., description="OpenProject user API token") + class ProjectStatusReportRequest(BaseModel): """Request model for project status report generation.""" project: ProjectInfo = Field(..., description="Project information") openproject: OpenProjectInfo = Field(..., description="OpenProject instance information") -class ProjectSimilarityRequest(BaseModel): - project: ProjectInfo - openproject: OpenProjectInfo class WorkPackage(BaseModel): """Model for OpenProject work package data.""" - id: Union[int, str] + id: int subject: str - status: Optional[Dict[str, Any]] = None + status: Dict[str, Any] priority: Optional[Dict[str, Any]] = None assignee: Optional[Dict[str, Any]] = None due_date: Optional[str] = None done_ratio: Optional[int] = None - created_at: Optional[str] = None - updated_at: Optional[str] = None + created_at: str + updated_at: str description: Optional[Dict[str, Any]] = None +class ProjectSimilarityRequest(BaseModel): + project: ProjectInfo + openproject: OpenProjectInfo + class ProjectStatusReportResponse(BaseModel): """Response model for project status report.""" - project_id: Union[int, str] + project_id: int project_type: str report: str + generated_at: str = Field(default_factory=lambda: datetime.now().isoformat()) work_packages_analyzed: int openproject_base_url: str From d9ae83d99cf1b3717a304f38a8c725038ca51278 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 16:24:42 +0200 Subject: [PATCH 17/19] Update routes.py --- src/api/routes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/routes.py b/src/api/routes.py index a75772e..112be1d 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -256,6 +256,7 @@ async def generate_project_status_report( try: # Extract values from the new request structure project_id = request.project.id + project_type = request.project.type base_url = request.openproject.base_url user_token = request.openproject.user_token @@ -333,6 +334,7 @@ async def generate_project_status_report( try: report_text, analysis = generation_pipeline.generate_project_status_report( project_id=str(project_id), + project_type=project_type, openproject_base_url=base_url, work_packages=work_packages ) @@ -341,7 +343,7 @@ async def generate_project_status_report( return ProjectStatusReportResponse( project_id=project_id, - project_type="", + project_type=project_type, report=report_text, work_packages_analyzed=len(work_packages), openproject_base_url=base_url From fd04aafa80fcdd3cde72021b24f5e030c0ad53e4 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 16:30:15 +0200 Subject: [PATCH 18/19] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7e75be..de2bdea 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ This ensures that your application always has the required models available and ```json { "project": { - "id": "YOUR_PORTFOLIO_PROJECT_ID" + "id": "YOUR_PORTFOLIO_PROJECT_ID", + "type": "portfolio" # should be portfolio }, "openproject": { "base_url": "https://your-openproject-instance.com", From cb01c9d7f48e0fa80d38230d2e35017f1e4348d2 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 17 Jul 2025 16:42:35 +0200 Subject: [PATCH 19/19] Update routes.py --- src/api/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/routes.py b/src/api/routes.py index 112be1d..f9f2b0c 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -279,7 +279,7 @@ async def generate_project_status_report( api_key=user_token ) - logger.info(f"Generating project status report for project {project_id}") + logger.info(f"Generating project status report for project {project_id} (type: {project_type})") # Fetch work packages from OpenProject try: