Вроде работает
This commit is contained in:
1
backend/git_repos/12
Submodule
1
backend/git_repos/12
Submodule
Submodule backend/git_repos/12 added at 57ab7e8679
Binary file not shown.
@@ -50,4 +50,7 @@ psycopg2-binary
|
||||
openpyxl
|
||||
GitPython==3.1.44
|
||||
itsdangerous
|
||||
email-validator
|
||||
email-validator
|
||||
openai
|
||||
playwright
|
||||
tenacity
|
||||
@@ -397,4 +397,59 @@ async def get_repository_diff(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# [/DEF:get_repository_diff:Function]
|
||||
|
||||
# [DEF:generate_commit_message:Function]
|
||||
# @PURPOSE: Generate a suggested commit message using LLM.
|
||||
# @PRE: Repository for `dashboard_id` is initialized.
|
||||
# @POST: Returns a suggested commit message string.
|
||||
@router.post("/repositories/{dashboard_id}/generate-message")
|
||||
async def generate_commit_message(
|
||||
dashboard_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("generate_commit_message"):
|
||||
try:
|
||||
# 1. Get Diff
|
||||
diff = git_service.get_diff(dashboard_id, staged=True)
|
||||
if not diff:
|
||||
diff = git_service.get_diff(dashboard_id, staged=False)
|
||||
|
||||
if not diff:
|
||||
return {"message": "No changes detected"}
|
||||
|
||||
# 2. Get History
|
||||
history_objs = git_service.get_commit_history(dashboard_id, limit=5)
|
||||
history = [h.message for h in history_objs if hasattr(h, 'message')]
|
||||
|
||||
# 3. Get LLM Client
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...plugins.llm_analysis.service import LLMClient
|
||||
from ...plugins.llm_analysis.models import LLMProviderType
|
||||
|
||||
llm_service = LLMProviderService(db)
|
||||
providers = llm_service.get_all_providers()
|
||||
provider = next((p for p in providers if p.is_active), None)
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="No active LLM provider found")
|
||||
|
||||
api_key = llm_service.get_decrypted_api_key(provider.id)
|
||||
client = LLMClient(
|
||||
provider_type=LLMProviderType(provider.provider_type),
|
||||
api_key=api_key,
|
||||
base_url=provider.base_url,
|
||||
default_model=provider.default_model
|
||||
)
|
||||
|
||||
# 4. Generate Message
|
||||
from ...plugins.git.llm_extension import GitLLMExtension
|
||||
extension = GitLLMExtension(client)
|
||||
message = await extension.suggest_commit_message(diff, history)
|
||||
|
||||
return {"message": message}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# [/DEF:generate_commit_message:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.git:Module]
|
||||
191
backend/src/api/routes/llm.py
Normal file
191
backend/src/api/routes/llm.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# [DEF:backend/src/api/routes/llm.py:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: api, routes, llm
|
||||
# @PURPOSE: API routes for LLM provider configuration and management.
|
||||
# @LAYER: UI (API)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
from ...core.logger import logger
|
||||
from ...schemas.auth import User
|
||||
from ...dependencies import get_current_user as get_current_active_user
|
||||
from ...plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...core.database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# [DEF:router:Global]
|
||||
# @PURPOSE: APIRouter instance for LLM routes.
|
||||
router = APIRouter(prefix="/api/llm", tags=["LLM"])
|
||||
# [/DEF:router:Global]
|
||||
|
||||
# [DEF:get_providers:Function]
|
||||
# @PURPOSE: Retrieve all LLM provider configurations.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns list of LLMProviderConfig.
|
||||
@router.get("/providers", response_model=List[LLMProviderConfig])
|
||||
async def get_providers(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all LLM provider configurations.
|
||||
"""
|
||||
logger.info(f"[llm_routes][get_providers][Action] Fetching providers for user: {current_user.username}")
|
||||
service = LLMProviderService(db)
|
||||
providers = service.get_all_providers()
|
||||
return [
|
||||
LLMProviderConfig(
|
||||
id=p.id,
|
||||
provider_type=LLMProviderType(p.provider_type),
|
||||
name=p.name,
|
||||
base_url=p.base_url,
|
||||
api_key="********",
|
||||
default_model=p.default_model,
|
||||
is_active=p.is_active
|
||||
) for p in providers
|
||||
]
|
||||
# [/DEF:get_providers:Function]
|
||||
|
||||
# [DEF:create_provider:Function]
|
||||
# @PURPOSE: Create a new LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
# @POST: Returns the created LLMProviderConfig.
|
||||
@router.post("/providers", response_model=LLMProviderConfig, status_code=status.HTTP_201_CREATED)
|
||||
async def create_provider(
|
||||
config: LLMProviderConfig,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new LLM provider configuration.
|
||||
"""
|
||||
service = LLMProviderService(db)
|
||||
provider = service.create_provider(config)
|
||||
return LLMProviderConfig(
|
||||
id=provider.id,
|
||||
provider_type=LLMProviderType(provider.provider_type),
|
||||
name=provider.name,
|
||||
base_url=provider.base_url,
|
||||
api_key="********",
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active
|
||||
)
|
||||
# [/DEF:create_provider:Function]
|
||||
|
||||
# [DEF:update_provider:Function]
|
||||
# @PURPOSE: Update an existing LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
# @POST: Returns the updated LLMProviderConfig.
|
||||
@router.put("/providers/{provider_id}", response_model=LLMProviderConfig)
|
||||
async def update_provider(
|
||||
provider_id: str,
|
||||
config: LLMProviderConfig,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update an existing LLM provider configuration.
|
||||
"""
|
||||
service = LLMProviderService(db)
|
||||
provider = service.update_provider(provider_id, config)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
return LLMProviderConfig(
|
||||
id=provider.id,
|
||||
provider_type=LLMProviderType(provider.provider_type),
|
||||
name=provider.name,
|
||||
base_url=provider.base_url,
|
||||
api_key="********",
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active
|
||||
)
|
||||
# [/DEF:update_provider:Function]
|
||||
|
||||
# [DEF:delete_provider:Function]
|
||||
# @PURPOSE: Delete an LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
# @POST: Returns success status.
|
||||
@router.delete("/providers/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_provider(
|
||||
provider_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete an LLM provider configuration.
|
||||
"""
|
||||
service = LLMProviderService(db)
|
||||
if not service.delete_provider(provider_id):
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return
|
||||
# [/DEF:delete_provider:Function]
|
||||
|
||||
# [DEF:test_connection:Function]
|
||||
# @PURPOSE: Test connection to an LLM provider.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns success status and message.
|
||||
@router.post("/providers/{provider_id}/test")
|
||||
async def test_connection(
|
||||
provider_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
logger.info(f"[llm_routes][test_connection][Action] Testing connection for provider_id: {provider_id}")
|
||||
"""
|
||||
Test connection to an LLM provider.
|
||||
"""
|
||||
from ...plugins.llm_analysis.service import LLMClient
|
||||
service = LLMProviderService(db)
|
||||
db_provider = service.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
api_key = service.get_decrypted_api_key(provider_id)
|
||||
client = LLMClient(
|
||||
provider_type=LLMProviderType(db_provider.provider_type),
|
||||
api_key=api_key,
|
||||
base_url=db_provider.base_url,
|
||||
default_model=db_provider.default_model
|
||||
)
|
||||
|
||||
try:
|
||||
# Simple test call
|
||||
await client.client.models.list()
|
||||
return {"success": True, "message": "Connection successful"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
# [/DEF:test_connection:Function]
|
||||
|
||||
# [DEF:test_provider_config:Function]
|
||||
# @PURPOSE: Test connection with a provided configuration (not yet saved).
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns success status and message.
|
||||
@router.post("/providers/test")
|
||||
async def test_provider_config(
|
||||
config: LLMProviderConfig,
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Test connection with a provided configuration.
|
||||
"""
|
||||
from ...plugins.llm_analysis.service import LLMClient
|
||||
logger.info(f"[llm_routes][test_provider_config][Action] Testing config for {config.name}")
|
||||
|
||||
client = LLMClient(
|
||||
provider_type=config.provider_type,
|
||||
api_key=config.api_key,
|
||||
base_url=config.base_url,
|
||||
default_model=config.default_model
|
||||
)
|
||||
|
||||
try:
|
||||
# Simple test call
|
||||
await client.client.models.list()
|
||||
return {"success": True, "message": "Connection successful"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
# [/DEF:test_provider_config:Function]
|
||||
|
||||
# [/DEF:backend/src/api/routes/llm.py]
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
|
||||
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||
from ...dependencies import get_task_manager, has_permission
|
||||
from ...dependencies import get_task_manager, has_permission, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -34,13 +34,30 @@ class ResumeTaskRequest(BaseModel):
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(lambda req: has_permission(f"plugin:{req.plugin_id}", "EXECUTE"))
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
# Dynamic permission check based on plugin_id
|
||||
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
|
||||
"""
|
||||
Create and start a new task for a given plugin.
|
||||
"""
|
||||
with belief_scope("create_task"):
|
||||
try:
|
||||
# Special handling for validation task to include provider config
|
||||
if request.plugin_id == "llm_dashboard_validation":
|
||||
from ...core.database import SessionLocal
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
db = SessionLocal()
|
||||
try:
|
||||
llm_service = LLMProviderService(db)
|
||||
provider_id = request.params.get("provider_id")
|
||||
if provider_id:
|
||||
db_provider = llm_service.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
task = await task_manager.create_task(
|
||||
plugin_id=request.plugin_id,
|
||||
params=request.params
|
||||
|
||||
@@ -19,7 +19,7 @@ import os
|
||||
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm
|
||||
from .api import auth
|
||||
from .core.database import init_db
|
||||
|
||||
@@ -97,6 +97,7 @@ app.include_router(environments.router, prefix="/api/environments", tags=["Envir
|
||||
app.include_router(mappings.router)
|
||||
app.include_router(migration.router)
|
||||
app.include_router(git.router)
|
||||
app.include_router(llm.router)
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
|
||||
# [DEF:websocket_endpoint:Function]
|
||||
|
||||
@@ -21,7 +21,7 @@ class AuthConfig(BaseSettings):
|
||||
# JWT Settings
|
||||
SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY")
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Database Settings
|
||||
|
||||
@@ -18,24 +18,37 @@ from ..models.task import TaskRecord
|
||||
from ..models.connection import ConnectionConfig
|
||||
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
|
||||
from ..models.auth import User, Role, Permission, ADGroupMapping
|
||||
from ..models.llm import LLMProvider, ValidationRecord
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
from pathlib import Path
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:BASE_DIR:Variable]
|
||||
# @PURPOSE: Base directory for the backend (where .db files should reside).
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
# [/DEF:BASE_DIR:Variable]
|
||||
|
||||
# [DEF:DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the main mappings database.
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/mappings.db")
|
||||
# [/DEF:DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:TASKS_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the tasks execution database.
|
||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
|
||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", f"sqlite:///{BASE_DIR}/tasks.db")
|
||||
# [/DEF:TASKS_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:AUTH_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the authentication database.
|
||||
AUTH_DATABASE_URL = auth_config.AUTH_DATABASE_URL
|
||||
AUTH_DATABASE_URL = os.getenv("AUTH_DATABASE_URL", auth_config.AUTH_DATABASE_URL)
|
||||
# If it's a relative sqlite path starting with ./backend/, fix it to be absolute or relative to BASE_DIR
|
||||
if AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
|
||||
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./backend/", f"sqlite:///{BASE_DIR}/")
|
||||
elif AUTH_DATABASE_URL.startswith("sqlite:///./") and not AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
|
||||
# If it's just ./ but we are in backend, it's fine, but let's make it absolute for robustness
|
||||
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./", f"sqlite:///{BASE_DIR}/")
|
||||
# [/DEF:AUTH_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:engine:Variable]
|
||||
|
||||
46
backend/src/models/llm.py
Normal file
46
backend/src/models/llm.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# [DEF:backend.src.models.llm:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: llm, models, sqlalchemy, persistence
|
||||
# @PURPOSE: SQLAlchemy models for LLM provider configuration and validation results.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Enum, Text
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from .mapping import Base
|
||||
|
||||
def generate_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
# [DEF:LLMProvider:Class]
|
||||
# @PURPOSE: SQLAlchemy model for LLM provider configuration.
|
||||
class LLMProvider(Base):
|
||||
__tablename__ = "llm_providers"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
provider_type = Column(String, nullable=False) # openai, openrouter, kilo
|
||||
name = Column(String, nullable=False)
|
||||
base_url = Column(String, nullable=False)
|
||||
api_key = Column(String, nullable=False) # Should be encrypted
|
||||
default_model = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
# [/DEF:LLMProvider:Class]
|
||||
|
||||
# [DEF:ValidationRecord:Class]
|
||||
# @PURPOSE: SQLAlchemy model for dashboard validation history.
|
||||
class ValidationRecord(Base):
|
||||
__tablename__ = "llm_validation_results"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
dashboard_id = Column(String, nullable=False, index=True)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
status = Column(String, nullable=False) # PASS, WARN, FAIL
|
||||
screenshot_path = Column(String, nullable=True)
|
||||
issues = Column(JSON, nullable=False)
|
||||
summary = Column(Text, nullable=False)
|
||||
raw_response = Column(Text, nullable=True)
|
||||
# [/DEF:ValidationRecord:Class]
|
||||
|
||||
# [/DEF:backend.src.models.llm:Module]
|
||||
66
backend/src/plugins/git/llm_extension.py
Normal file
66
backend/src/plugins/git/llm_extension.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# [DEF:backend/src/plugins/git/llm_extension:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: git, llm, commit
|
||||
# @PURPOSE: LLM-based extensions for the Git plugin, specifically for commit message generation.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.plugins.llm_analysis.service.LLMClient
|
||||
|
||||
from typing import List, Optional
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from ..llm_analysis.service import LLMClient
|
||||
from ..llm_analysis.models import LLMProviderType
|
||||
from ...core.logger import belief_scope, logger
|
||||
|
||||
# [DEF:GitLLMExtension:Class]
|
||||
# @PURPOSE: Provides LLM capabilities to the Git plugin.
|
||||
class GitLLMExtension:
|
||||
def __init__(self, client: LLMClient):
|
||||
self.client = client
|
||||
|
||||
# [DEF:suggest_commit_message:Function]
|
||||
# @PURPOSE: Generates a suggested commit message based on a diff and history.
|
||||
# @PARAM: diff (str) - The git diff of staged changes.
|
||||
# @PARAM: history (List[str]) - Recent commit messages for context.
|
||||
# @RETURN: str - The suggested commit message.
|
||||
@retry(
|
||||
stop=stop_after_attempt(2),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
reraise=True
|
||||
)
|
||||
async def suggest_commit_message(self, diff: str, history: List[str]) -> str:
|
||||
with belief_scope("suggest_commit_message"):
|
||||
history_text = "\n".join(history)
|
||||
prompt = f"""
|
||||
Generate a concise and professional git commit message based on the following diff and recent history.
|
||||
Use Conventional Commits format (e.g., feat: ..., fix: ..., docs: ...).
|
||||
|
||||
Recent History:
|
||||
{history_text}
|
||||
|
||||
Diff:
|
||||
{diff}
|
||||
|
||||
Commit Message:
|
||||
"""
|
||||
|
||||
logger.debug(f"[suggest_commit_message] Calling LLM with model: {self.client.default_model}")
|
||||
response = await self.client.client.chat.completions.create(
|
||||
model=self.client.default_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
logger.debug(f"[suggest_commit_message] LLM Response: {response}")
|
||||
|
||||
if not response or not hasattr(response, 'choices') or not response.choices:
|
||||
error_info = getattr(response, 'error', 'No choices in response')
|
||||
logger.error(f"[suggest_commit_message] Invalid LLM response. Error info: {error_info}")
|
||||
|
||||
# If it's a timeout/provider error, we might want to throw to trigger retry if decorated
|
||||
# but for now we return a safe fallback to avoid UI crash
|
||||
return "Update dashboard configurations (LLM generation failed)"
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
# [/DEF:GitLLMExtension:Class]
|
||||
|
||||
# [/DEF:backend/src/plugins/git/llm_extension:Module]
|
||||
11
backend/src/plugins/llm_analysis/__init__.py
Normal file
11
backend/src/plugins/llm_analysis/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# [DEF:backend/src/plugins/llm_analysis/__init__.py:Module]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Initialize the LLM Analysis plugin package.
|
||||
|
||||
"""
|
||||
LLM Analysis Plugin for automated dashboard validation and dataset documentation.
|
||||
"""
|
||||
|
||||
from .plugin import DashboardValidationPlugin, DocumentationPlugin
|
||||
|
||||
# [/DEF:backend/src/plugins/llm_analysis/__init__.py]
|
||||
61
backend/src/plugins/llm_analysis/models.py
Normal file
61
backend/src/plugins/llm_analysis/models.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# [DEF:backend/src/plugins/llm_analysis/models.py:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: pydantic, models, llm
|
||||
# @PURPOSE: Define Pydantic models for LLM Analysis plugin.
|
||||
# @LAYER: Domain
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
# [DEF:LLMProviderType:Class]
|
||||
# @PURPOSE: Enum for supported LLM providers.
|
||||
class LLMProviderType(str, Enum):
|
||||
OPENAI = "openai"
|
||||
OPENROUTER = "openrouter"
|
||||
KILO = "kilo"
|
||||
# [/DEF:LLMProviderType:Class]
|
||||
|
||||
# [DEF:LLMProviderConfig:Class]
|
||||
# @PURPOSE: Configuration for an LLM provider.
|
||||
class LLMProviderConfig(BaseModel):
|
||||
id: Optional[str] = None
|
||||
provider_type: LLMProviderType
|
||||
name: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
default_model: str
|
||||
is_active: bool = True
|
||||
# [/DEF:LLMProviderConfig:Class]
|
||||
|
||||
# [DEF:ValidationStatus:Class]
|
||||
# @PURPOSE: Enum for dashboard validation status.
|
||||
class ValidationStatus(str, Enum):
|
||||
PASS = "PASS"
|
||||
WARN = "WARN"
|
||||
FAIL = "FAIL"
|
||||
# [/DEF:ValidationStatus:Class]
|
||||
|
||||
# [DEF:DetectedIssue:Class]
|
||||
# @PURPOSE: Model for a single issue detected during validation.
|
||||
class DetectedIssue(BaseModel):
|
||||
severity: ValidationStatus
|
||||
message: str
|
||||
location: Optional[str] = None
|
||||
# [/DEF:DetectedIssue:Class]
|
||||
|
||||
# [DEF:ValidationResult:Class]
|
||||
# @PURPOSE: Model for dashboard validation result.
|
||||
class ValidationResult(BaseModel):
|
||||
id: Optional[str] = None
|
||||
dashboard_id: str
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
status: ValidationStatus
|
||||
screenshot_path: Optional[str] = None
|
||||
issues: List[DetectedIssue]
|
||||
summary: str
|
||||
raw_response: Optional[str] = None
|
||||
# [/DEF:ValidationResult:Class]
|
||||
|
||||
# [/DEF:backend/src/plugins/llm_analysis/models.py]
|
||||
272
backend/src/plugins/llm_analysis/plugin.py
Normal file
272
backend/src/plugins/llm_analysis/plugin.py
Normal file
@@ -0,0 +1,272 @@
|
||||
# [DEF:backend.src.plugins.llm_analysis.plugin:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: plugin, llm, analysis, documentation
|
||||
# @PURPOSE: Implements DashboardValidationPlugin and DocumentationPlugin.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.core.plugin_base.PluginBase
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.database import SessionLocal
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from .service import ScreenshotService, LLMClient
|
||||
from .models import LLMProviderType, ValidationStatus, ValidationResult, DetectedIssue
|
||||
from ...models.llm import ValidationRecord
|
||||
|
||||
# [DEF:DashboardValidationPlugin:Class]
|
||||
# @PURPOSE: Plugin for automated dashboard health analysis using LLMs.
|
||||
class DashboardValidationPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "llm_dashboard_validation"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Dashboard LLM Validation"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Automated dashboard health analysis using multimodal LLMs."
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dashboard_id": {"type": "string", "title": "Dashboard ID"},
|
||||
"environment_id": {"type": "string", "title": "Environment ID"},
|
||||
"provider_id": {"type": "string", "title": "LLM Provider ID"}
|
||||
},
|
||||
"required": ["dashboard_id", "environment_id", "provider_id"]
|
||||
}
|
||||
|
||||
async def execute(self, params: Dict[str, Any]):
|
||||
with belief_scope("execute", f"plugin_id={self.id}"):
|
||||
logger.info(f"Executing {self.name} with params: {params}")
|
||||
|
||||
dashboard_id = params.get("dashboard_id")
|
||||
env_id = params.get("environment_id")
|
||||
provider_id = params.get("provider_id")
|
||||
task_id = params.get("_task_id")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 1. Get Environment
|
||||
from ...dependencies import get_config_manager
|
||||
config_mgr = get_config_manager()
|
||||
env = config_mgr.get_environment(env_id)
|
||||
if not env:
|
||||
raise ValueError(f"Environment {env_id} not found")
|
||||
|
||||
# 2. Get LLM Provider
|
||||
llm_service = LLMProviderService(db)
|
||||
db_provider = llm_service.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
|
||||
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||
|
||||
# 3. Capture Screenshot
|
||||
screenshot_service = ScreenshotService(env)
|
||||
os.makedirs("ss-tools-storage/screenshots", exist_ok=True)
|
||||
screenshot_path = f"ss-tools-storage/screenshots/{dashboard_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
|
||||
await screenshot_service.capture_dashboard(dashboard_id, screenshot_path)
|
||||
|
||||
# 4. Fetch Logs (Last 100 lines from backend.log)
|
||||
logs = []
|
||||
log_file = "backend.log"
|
||||
if os.path.exists(log_file):
|
||||
with open(log_file, "r") as f:
|
||||
# Read last 100 lines
|
||||
all_lines = f.readlines()
|
||||
logs = all_lines[-100:]
|
||||
|
||||
if not logs:
|
||||
logs = ["No logs found in backend.log"]
|
||||
|
||||
# 5. Analyze with LLM
|
||||
llm_client = LLMClient(
|
||||
provider_type=LLMProviderType(db_provider.provider_type),
|
||||
api_key=api_key,
|
||||
base_url=db_provider.base_url,
|
||||
default_model=db_provider.default_model
|
||||
)
|
||||
|
||||
analysis = await llm_client.analyze_dashboard(screenshot_path, logs)
|
||||
|
||||
# 6. Persist Result
|
||||
validation_result = ValidationResult(
|
||||
dashboard_id=dashboard_id,
|
||||
status=ValidationStatus(analysis["status"]),
|
||||
summary=analysis["summary"],
|
||||
issues=[DetectedIssue(**issue) for issue in analysis["issues"]],
|
||||
screenshot_path=screenshot_path,
|
||||
raw_response=str(analysis)
|
||||
)
|
||||
|
||||
db_record = ValidationRecord(
|
||||
dashboard_id=validation_result.dashboard_id,
|
||||
status=validation_result.status.value,
|
||||
summary=validation_result.summary,
|
||||
issues=[issue.dict() for issue in validation_result.issues],
|
||||
screenshot_path=validation_result.screenshot_path,
|
||||
raw_response=validation_result.raw_response
|
||||
)
|
||||
db.add(db_record)
|
||||
db.commit()
|
||||
|
||||
# 7. Notification on failure (US1 / FR-015)
|
||||
if validation_result.status == ValidationStatus.FAIL:
|
||||
logger.warning(f"Dashboard {dashboard_id} validation FAILED. Summary: {validation_result.summary}")
|
||||
# Placeholder for Email/Pulse notification dispatch
|
||||
# In a real implementation, we would call a NotificationService here
|
||||
# with a payload containing the summary and a link to the report.
|
||||
|
||||
return validation_result.dict()
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:DashboardValidationPlugin:Class]
|
||||
|
||||
# [DEF:DocumentationPlugin:Class]
|
||||
# @PURPOSE: Plugin for automated dataset documentation using LLMs.
|
||||
class DocumentationPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "llm_documentation"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Dataset LLM Documentation"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Automated dataset and column documentation using LLMs."
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataset_id": {"type": "string", "title": "Dataset ID"},
|
||||
"environment_id": {"type": "string", "title": "Environment ID"},
|
||||
"provider_id": {"type": "string", "title": "LLM Provider ID"}
|
||||
},
|
||||
"required": ["dataset_id", "environment_id", "provider_id"]
|
||||
}
|
||||
|
||||
async def execute(self, params: Dict[str, Any]):
|
||||
with belief_scope("execute", f"plugin_id={self.id}"):
|
||||
logger.info(f"Executing {self.name} with params: {params}")
|
||||
|
||||
dataset_id = params.get("dataset_id")
|
||||
env_id = params.get("environment_id")
|
||||
provider_id = params.get("provider_id")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 1. Get Environment
|
||||
from ...dependencies import get_config_manager
|
||||
config_mgr = get_config_manager()
|
||||
env = config_mgr.get_environment(env_id)
|
||||
if not env:
|
||||
raise ValueError(f"Environment {env_id} not found")
|
||||
|
||||
# 2. Get LLM Provider
|
||||
llm_service = LLMProviderService(db)
|
||||
db_provider = llm_service.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
|
||||
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||
|
||||
# 3. Fetch Metadata (US2 / T024)
|
||||
from ...core.superset_client import SupersetClient
|
||||
client = SupersetClient(env)
|
||||
|
||||
# Optimistic locking check (T045)
|
||||
dataset = client.get_dataset(int(dataset_id))
|
||||
# dataset structure might vary, ensure we get the right field
|
||||
original_changed_on = dataset.get("changed_on_utc") or dataset.get("result", {}).get("changed_on_utc")
|
||||
|
||||
# Extract columns and existing descriptions
|
||||
columns_data = []
|
||||
for col in dataset.get("columns", []):
|
||||
columns_data.append({
|
||||
"name": col.get("column_name"),
|
||||
"type": col.get("type"),
|
||||
"description": col.get("description")
|
||||
})
|
||||
|
||||
# 4. Construct Prompt & Analyze (US2 / T025)
|
||||
llm_client = LLMClient(
|
||||
provider_type=LLMProviderType(db_provider.provider_type),
|
||||
api_key=api_key,
|
||||
base_url=db_provider.base_url,
|
||||
default_model=db_provider.default_model
|
||||
)
|
||||
|
||||
prompt = f"""
|
||||
Generate professional documentation for the following dataset and its columns.
|
||||
Dataset: {dataset.get('table_name')}
|
||||
Columns: {columns_data}
|
||||
|
||||
Provide the documentation in JSON format:
|
||||
{{
|
||||
"dataset_description": "General description of the dataset",
|
||||
"column_descriptions": [
|
||||
{{
|
||||
"name": "column_name",
|
||||
"description": "Generated description"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
# Using a generic chat completion for text-only US2
|
||||
response = await llm_client.client.chat.completions.create(
|
||||
model=db_provider.default_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
import json
|
||||
doc_result = json.loads(response.choices[0].message.content)
|
||||
|
||||
# 5. Update Metadata (US2 / T026)
|
||||
# This part normally goes to mapping_service, but we implement the logic here for the plugin flow
|
||||
# We'll update the dataset in Superset
|
||||
update_payload = {
|
||||
"description": doc_result["dataset_description"],
|
||||
"columns": []
|
||||
}
|
||||
|
||||
# Map generated descriptions back to column IDs
|
||||
for col_doc in doc_result["column_descriptions"]:
|
||||
for col in dataset.get("columns", []):
|
||||
if col.get("column_name") == col_doc["name"]:
|
||||
update_payload["columns"].append({
|
||||
"id": col.get("id"),
|
||||
"description": col_doc["description"]
|
||||
})
|
||||
|
||||
client.update_dataset(int(dataset_id), update_payload)
|
||||
|
||||
return doc_result
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:DocumentationPlugin:Class]
|
||||
|
||||
# [/DEF:backend.src.plugins.llm_analysis.plugin:Module]
|
||||
56
backend/src/plugins/llm_analysis/scheduler.py
Normal file
56
backend/src/plugins/llm_analysis/scheduler.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# [DEF:backend/src/plugins/llm_analysis/scheduler.py:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: scheduler, task, automation
|
||||
# @PURPOSE: Provides helper functions to schedule LLM-based validation tasks.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.scheduler
|
||||
|
||||
from typing import Dict, Any
|
||||
from ...dependencies import get_task_manager, get_scheduler_service
|
||||
from ...core.logger import belief_scope, logger
|
||||
|
||||
# [DEF:schedule_dashboard_validation:Function]
|
||||
# @PURPOSE: Schedules a recurring dashboard validation task.
|
||||
# @PARAM: dashboard_id (str) - ID of the dashboard to validate.
|
||||
# @PARAM: cron_expression (str) - Standard cron expression for scheduling.
|
||||
# @PARAM: params (Dict[str, Any]) - Task parameters (environment_id, provider_id).
|
||||
def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, params: Dict[str, Any]):
|
||||
with belief_scope("schedule_dashboard_validation", f"dashboard_id={dashboard_id}"):
|
||||
scheduler = get_scheduler_service()
|
||||
task_manager = get_task_manager()
|
||||
|
||||
job_id = f"llm_val_{dashboard_id}"
|
||||
|
||||
async def job_func():
|
||||
await task_manager.create_task(
|
||||
plugin_id="llm_dashboard_validation",
|
||||
params={
|
||||
"dashboard_id": dashboard_id,
|
||||
**params
|
||||
}
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
job_func,
|
||||
"cron",
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
**_parse_cron(cron_expression)
|
||||
)
|
||||
logger.info(f"Scheduled validation for dashboard {dashboard_id} with cron {cron_expression}")
|
||||
|
||||
def _parse_cron(cron: str) -> Dict[str, str]:
|
||||
# Basic cron parser placeholder
|
||||
parts = cron.split()
|
||||
if len(parts) != 5:
|
||||
return {}
|
||||
return {
|
||||
"minute": parts[0],
|
||||
"hour": parts[1],
|
||||
"day": parts[2],
|
||||
"month": parts[3],
|
||||
"day_of_week": parts[4]
|
||||
}
|
||||
# [/DEF:schedule_dashboard_validation:Function]
|
||||
|
||||
# [/DEF:backend/src/plugins/llm_analysis/scheduler.py]
|
||||
224
backend/src/plugins/llm_analysis/service.py
Normal file
224
backend/src/plugins/llm_analysis/service.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# [DEF:backend.src.plugins.llm_analysis.service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: service, llm, screenshot, playwright, openai
|
||||
# @PURPOSE: Services for LLM interaction and dashboard screenshots.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> playwright
|
||||
# @RELATION: DEPENDS_ON -> openai
|
||||
# @RELATION: DEPENDS_ON -> tenacity
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional, Dict, Any
|
||||
from playwright.async_api import async_playwright
|
||||
from openai import AsyncOpenAI, RateLimitError
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
from .models import LLMProviderType, ValidationResult, ValidationStatus, DetectedIssue
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.config_models import Environment
|
||||
|
||||
# [DEF:ScreenshotService:Class]
|
||||
# @PURPOSE: Handles capturing screenshots of Superset dashboards.
|
||||
class ScreenshotService:
|
||||
# @PRE: env is a valid Environment object.
|
||||
def __init__(self, env: Environment):
|
||||
self.env = env
|
||||
|
||||
# [DEF:capture_dashboard:Function]
|
||||
# @PURPOSE: Captures a screenshot of a dashboard using Playwright.
|
||||
# @PARAM: dashboard_id (str) - ID of the dashboard.
|
||||
# @PARAM: output_path (str) - Path to save the screenshot.
|
||||
# @RETURN: bool - True if successful.
|
||||
async def capture_dashboard(self, dashboard_id: str, output_path: str) -> bool:
|
||||
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
|
||||
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context(viewport={'width': 1280, 'height': 720})
|
||||
page = await context.new_page()
|
||||
|
||||
# 1. Authenticate via API to get tokens
|
||||
from ...core.superset_client import SupersetClient
|
||||
client = SupersetClient(self.env)
|
||||
try:
|
||||
tokens = client.authenticate()
|
||||
access_token = tokens.get("access_token")
|
||||
|
||||
# Set JWT in localStorage if possible, or use as cookie
|
||||
# Superset UI uses session cookies, but we can try to set the Authorization header
|
||||
# or inject the token into the session.
|
||||
# For now, we'll use the token to set a cookie if we can determine the name,
|
||||
# but the most reliable way for Playwright is often still the UI login
|
||||
# UNLESS we use the API to set a session cookie.
|
||||
logger.info("API Authentication successful")
|
||||
except Exception as e:
|
||||
logger.warning(f"API Authentication failed: {e}. Falling back to UI login.")
|
||||
|
||||
# 2. Navigate to dashboard
|
||||
dashboard_url = f"{self.env.url}/superset/dashboard/{dashboard_id}/"
|
||||
logger.info(f"Navigating to {dashboard_url}")
|
||||
|
||||
# We still go to the URL first
|
||||
await page.goto(dashboard_url)
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
# 3. Check if we are redirected to login
|
||||
if "/login" in page.url:
|
||||
logger.info(f"Redirected to login: {page.url}. Filling credentials from Environment.")
|
||||
|
||||
# More exhaustive list of selectors for various Superset versions/themes
|
||||
selectors = {
|
||||
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]'],
|
||||
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]'],
|
||||
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary']
|
||||
}
|
||||
|
||||
try:
|
||||
# Find and fill username
|
||||
u_selector = None
|
||||
for s in selectors["username"]:
|
||||
if await page.locator(s).count() > 0:
|
||||
u_selector = s
|
||||
break
|
||||
|
||||
if not u_selector:
|
||||
raise RuntimeError("Could not find username input field")
|
||||
|
||||
await page.fill(u_selector, self.env.username)
|
||||
|
||||
# Find and fill password
|
||||
p_selector = None
|
||||
for s in selectors["password"]:
|
||||
if await page.locator(s).count() > 0:
|
||||
p_selector = s
|
||||
break
|
||||
|
||||
if not p_selector:
|
||||
raise RuntimeError("Could not find password input field")
|
||||
|
||||
await page.fill(p_selector, self.env.password)
|
||||
|
||||
# Click submit
|
||||
s_selector = selectors["submit"][0]
|
||||
for s in selectors["submit"]:
|
||||
if await page.locator(s).count() > 0:
|
||||
s_selector = s
|
||||
break
|
||||
|
||||
await page.click(s_selector)
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
# Re-verify we are at the dashboard
|
||||
if "/login" in page.url:
|
||||
# Check for error messages on page
|
||||
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
|
||||
raise RuntimeError(f"Login failed after submission: {error_msg}")
|
||||
|
||||
if "/superset/dashboard" not in page.url:
|
||||
logger.info(f"Redirecting back to dashboard after login: {dashboard_url}")
|
||||
await page.goto(dashboard_url)
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
except Exception as e:
|
||||
page_title = await page.title()
|
||||
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {str(e)}")
|
||||
debug_path = output_path.replace(".png", "_debug_failed_login.png")
|
||||
await page.screenshot(path=debug_path)
|
||||
raise RuntimeError(f"Login failed: {str(e)}. Debug screenshot saved to {debug_path}")
|
||||
# Wait a bit more for charts to render
|
||||
await asyncio.sleep(5)
|
||||
|
||||
await page.screenshot(path=output_path, full_page=True)
|
||||
await browser.close()
|
||||
logger.info(f"Screenshot saved to {output_path}")
|
||||
return True
|
||||
# [/DEF:ScreenshotService:Class]
|
||||
|
||||
# [DEF:LLMClient:Class]
|
||||
# @PURPOSE: Wrapper for LLM provider APIs.
|
||||
class LLMClient:
|
||||
def __init__(self, provider_type: LLMProviderType, api_key: str, base_url: str, default_model: str):
|
||||
self.provider_type = provider_type
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.default_model = default_model
|
||||
self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
|
||||
# [DEF:analyze_dashboard:Function]
|
||||
# @PURPOSE: Sends dashboard data to LLM for analysis.
|
||||
@retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=2, min=5, max=60),
|
||||
retry=retry_if_exception_type((Exception, RateLimitError))
|
||||
)
|
||||
async def analyze_dashboard(self, screenshot_path: str, logs: List[str]) -> Dict[str, Any]:
|
||||
with belief_scope("analyze_dashboard"):
|
||||
import base64
|
||||
with open(screenshot_path, "rb") as image_file:
|
||||
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
log_text = "\n".join(logs)
|
||||
prompt = f"""
|
||||
Analyze the attached dashboard screenshot and the following execution logs for health and visual issues.
|
||||
|
||||
Logs:
|
||||
{log_text}
|
||||
|
||||
Provide the analysis in JSON format with the following structure:
|
||||
{{
|
||||
"status": "PASS" | "WARN" | "FAIL",
|
||||
"summary": "Short summary of findings",
|
||||
"issues": [
|
||||
{{
|
||||
"severity": "WARN" | "FAIL",
|
||||
"message": "Description of the issue",
|
||||
"location": "Optional location info (e.g. chart name)"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
logger.debug(f"[analyze_dashboard] Calling LLM with model: {self.default_model}")
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.default_model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
logger.debug(f"[analyze_dashboard] LLM Response: {response}")
|
||||
except RateLimitError as e:
|
||||
logger.warning(f"[analyze_dashboard] Rate limit hit: {str(e)}")
|
||||
raise # tenacity will handle retry
|
||||
except Exception as e:
|
||||
logger.error(f"[analyze_dashboard] LLM call failed: {str(e)}")
|
||||
raise
|
||||
|
||||
if not response or not hasattr(response, 'choices') or not response.choices:
|
||||
error_info = getattr(response, 'error', 'No choices in response')
|
||||
logger.error(f"[analyze_dashboard] Invalid LLM response. Error info: {error_info}")
|
||||
return {
|
||||
"status": "FAIL",
|
||||
"summary": f"Failed to get response from LLM: {error_info}",
|
||||
"issues": [{"severity": "FAIL", "message": "LLM provider returned empty or invalid response"}]
|
||||
}
|
||||
|
||||
import json
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return result
|
||||
# [/DEF:analyze_dashboard:Function]
|
||||
|
||||
# [/DEF:LLMClient:Class]
|
||||
|
||||
# [/DEF:backend.src.plugins.llm_analysis.service:Module]
|
||||
117
backend/src/services/llm_provider.py
Normal file
117
backend/src/services/llm_provider.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# [DEF:backend.src.services.llm_provider:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: service, llm, provider, encryption
|
||||
# @PURPOSE: Service for managing LLM provider configurations with encrypted API keys.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.database
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.llm
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models.llm import LLMProvider
|
||||
from ..plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
||||
from ..core.logger import belief_scope, logger
|
||||
from cryptography.fernet import Fernet
|
||||
import os
|
||||
|
||||
# [DEF:EncryptionManager:Class]
|
||||
# @PURPOSE: Handles encryption and decryption of sensitive data like API keys.
|
||||
class EncryptionManager:
|
||||
# @INVARIANT: Uses a secret key from environment or a default one (fallback only for dev).
|
||||
def __init__(self):
|
||||
self.key = os.getenv("ENCRYPTION_KEY", "7_u-l7-B-j9f5_V5z-5-5-5-5-5-5-5-5-5-5-5-5-5=").encode()
|
||||
self.fernet = Fernet(self.key)
|
||||
|
||||
def encrypt(self, data: str) -> str:
|
||||
return self.fernet.encrypt(data.encode()).decode()
|
||||
|
||||
def decrypt(self, encrypted_data: str) -> str:
|
||||
return self.fernet.decrypt(encrypted_data.encode()).decode()
|
||||
# [/DEF:EncryptionManager:Class]
|
||||
|
||||
# [DEF:LLMProviderService:Class]
|
||||
# @PURPOSE: Service to manage LLM provider lifecycle.
|
||||
class LLMProviderService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.encryption = EncryptionManager()
|
||||
|
||||
# [DEF:get_all_providers:Function]
|
||||
# @PURPOSE: Returns all configured LLM providers.
|
||||
def get_all_providers(self) -> List[LLMProvider]:
|
||||
with belief_scope("get_all_providers"):
|
||||
return self.db.query(LLMProvider).all()
|
||||
# [/DEF:get_all_providers:Function]
|
||||
|
||||
# [DEF:get_provider:Function]
|
||||
# @PURPOSE: Returns a single LLM provider by ID.
|
||||
def get_provider(self, provider_id: str) -> Optional[LLMProvider]:
|
||||
with belief_scope("get_provider"):
|
||||
return self.db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
|
||||
# [/DEF:get_provider:Function]
|
||||
|
||||
# [DEF:create_provider:Function]
|
||||
# @PURPOSE: Creates a new LLM provider with encrypted API key.
|
||||
def create_provider(self, config: LLMProviderConfig) -> LLMProvider:
|
||||
with belief_scope("create_provider"):
|
||||
encrypted_key = self.encryption.encrypt(config.api_key)
|
||||
db_provider = LLMProvider(
|
||||
provider_type=config.provider_type.value,
|
||||
name=config.name,
|
||||
base_url=config.base_url,
|
||||
api_key=encrypted_key,
|
||||
default_model=config.default_model,
|
||||
is_active=config.is_active
|
||||
)
|
||||
self.db.add(db_provider)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_provider)
|
||||
return db_provider
|
||||
# [/DEF:create_provider:Function]
|
||||
|
||||
# [DEF:update_provider:Function]
|
||||
# @PURPOSE: Updates an existing LLM provider.
|
||||
def update_provider(self, provider_id: str, config: LLMProviderConfig) -> Optional[LLMProvider]:
|
||||
with belief_scope("update_provider"):
|
||||
db_provider = self.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
return None
|
||||
|
||||
db_provider.provider_type = config.provider_type.value
|
||||
db_provider.name = config.name
|
||||
db_provider.base_url = config.base_url
|
||||
if config.api_key != "********":
|
||||
db_provider.api_key = self.encryption.encrypt(config.api_key)
|
||||
db_provider.default_model = config.default_model
|
||||
db_provider.is_active = config.is_active
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_provider)
|
||||
return db_provider
|
||||
# [/DEF:update_provider:Function]
|
||||
|
||||
# [DEF:delete_provider:Function]
|
||||
# @PURPOSE: Deletes an LLM provider.
|
||||
def delete_provider(self, provider_id: str) -> bool:
|
||||
with belief_scope("delete_provider"):
|
||||
db_provider = self.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
return False
|
||||
self.db.delete(db_provider)
|
||||
self.db.commit()
|
||||
return True
|
||||
# [/DEF:delete_provider:Function]
|
||||
|
||||
# [DEF:get_decrypted_api_key:Function]
|
||||
# @PURPOSE: Returns the decrypted API key for a provider.
|
||||
def get_decrypted_api_key(self, provider_id: str) -> Optional[str]:
|
||||
with belief_scope("get_decrypted_api_key"):
|
||||
db_provider = self.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
return None
|
||||
return self.encryption.decrypt(db_provider.api_key)
|
||||
# [/DEF:get_decrypted_api_key:Function]
|
||||
|
||||
# [/DEF:LLMProviderService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.llm_provider:Module]
|
||||
BIN
backend/ss-tools-storage/screenshots/4_20260129_174429.png
Normal file
BIN
backend/ss-tools-storage/screenshots/4_20260129_174429.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
Reference in New Issue
Block a user