Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Compatibility package for running commands from the backend directory."""

from __future__ import annotations

from pathlib import Path
Expand Down
28 changes: 18 additions & 10 deletions backend/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os
from dotenv import load_dotenv
from pathlib import Path

from dotenv import load_dotenv

load_dotenv()


def _normalize_database_url(url: str) -> str:
if url.startswith("postgres://"):
return url.replace("postgres://", "postgresql+asyncpg://", 1)
Expand Down Expand Up @@ -36,17 +38,23 @@ def _cors_origins(environment: str, raw_origins: str | None) -> list[str]:
return _parse_csv(DEFAULT_LOCAL_CORS_ORIGINS)


ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "")
DATABASE_URL = _normalize_database_url(os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./commitiq.db"))
REPO_STORAGE_PATH = Path(os.getenv("REPO_STORAGE_PATH", "/tmp/commitiq_repos"))
MAX_COMMITS = int(os.getenv("MAX_COMMITS_PER_INGESTION", os.getenv("MAX_COMMITS", "500")))
LLM_MAX_CALLS = int(os.getenv("LLM_BUDGET_PER_REPO", os.getenv("LLM_MAX_CALLS_PER_REPO", os.getenv("LLM_MAX_CALLS", "25"))))
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "")
DATABASE_URL = _normalize_database_url(
os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./commitiq.db")
)
REPO_STORAGE_PATH = Path(os.getenv("REPO_STORAGE_PATH", "/tmp/commitiq_repos"))
MAX_COMMITS = int(os.getenv("MAX_COMMITS_PER_INGESTION", os.getenv("MAX_COMMITS", "500")))
LLM_MAX_CALLS = int(
os.getenv(
"LLM_BUDGET_PER_REPO", os.getenv("LLM_MAX_CALLS_PER_REPO", os.getenv("LLM_MAX_CALLS", "25"))
)
)
LLM_BUDGET_PER_REPO_USD = float(os.getenv("LLM_BUDGET_PER_REPO_USD", "0.50"))
ENABLE_SEMANTIC_ANALYSIS = _parse_bool(os.getenv("ENABLE_SEMANTIC_ANALYSIS"), default=True)
ENABLE_GRAPHCODEBERT = _parse_bool(os.getenv("ENABLE_GRAPHCODEBERT"), default=False)
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
CORS_ORIGINS = _cors_origins(ENVIRONMENT, os.getenv("CORS_ORIGINS"))
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
CORS_ORIGINS = _cors_origins(ENVIRONMENT, os.getenv("CORS_ORIGINS"))

REPO_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
9 changes: 2 additions & 7 deletions backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,9 @@ async def _execute_statement(conn, statement: str, *, is_sqlite: bool = _IS_SQLI
def _migration_statements(migration_sql: str) -> list[str]:
migration_lines = migration_sql.splitlines()
uncommented_sql = "\n".join(
line for line in migration_lines
if not line.lstrip().startswith("--")
line for line in migration_lines if not line.lstrip().startswith("--")
)
return [
statement.strip()
for statement in uncommented_sql.split(";")
if statement.strip()
]
return [statement.strip() for statement in uncommented_sql.split(";") if statement.strip()]


async def _ensure_migration_table(conn) -> None:
Expand Down
67 changes: 38 additions & 29 deletions backend/features/llm_analysis/claude_client.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
"""LLM call wrapper for on-demand CommitIQ narratives."""

from typing import AsyncGenerator

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from backend.shared.models import LLMNarrative, Commit, HealthSnapshot
from backend.config import ANTHROPIC_API_KEY
from backend.features.llm_analysis.cache import make_cache_key
from backend.features.llm_analysis.cost_guard import check_budget
from backend.features.llm_analysis.cost_guard import estimate_cost_usd
from backend.features.llm_analysis.cost_guard import check_budget, estimate_cost_usd
from backend.features.llm_analysis.llm_router import get_narrative_non_streaming, model_for_provider
from backend.features.llm_analysis.prompt_builder import (
EXPLAIN_DROP_SYSTEM, PREDICT_MERGE_SYSTEM,
build_explain_prompt, build_predict_prompt
EXPLAIN_DROP_SYSTEM,
PREDICT_MERGE_SYSTEM,
build_explain_prompt,
build_predict_prompt,
)
from backend.config import ANTHROPIC_API_KEY, GEMINI_API_KEY
from backend.shared.models import Commit, HealthSnapshot, LLMNarrative

# Sonnet pricing (as of 2025):
# Input: $3.00 per 1M tokens = $0.000003 per token
# Output: $15.00 per 1M tokens = $0.000015 per token
INPUT_COST_PER_TOKEN = 0.000003
INPUT_COST_PER_TOKEN = 0.000003
OUTPUT_COST_PER_TOKEN = 0.000015
CLAUDE_MODEL = "claude-3-5-sonnet-20241022"


def _get_anthropic():
try:
import anthropic

return anthropic
except ImportError:
return None
Expand All @@ -34,6 +37,7 @@ def _get_anthropic():
def _get_genai():
try:
import google.generativeai as genai

return genai
except ImportError:
return None
Expand Down Expand Up @@ -67,7 +71,7 @@ async def stream_narrative(prompt: str) -> AsyncGenerator[str, None]:
"You are a code health analyst. Given metric deltas for a Git commit, "
"explain in plain English why the health score changed. Be specific about "
"which files and metrics drove the change. Use bullet points. Max 4 sentences. "
"Always end with: \"Estimated impact: [Low/Medium/High] risk to next sprint.\" "
'Always end with: "Estimated impact: [Low/Medium/High] risk to next sprint." '
"Never mention token costs or that you're an AI."
),
) as stream:
Expand All @@ -93,10 +97,12 @@ async def get_or_create_narrative(
Checks cache → checks budget → fires API → stores result.
"""
commit_result = await db.execute(
select(Commit).where(
select(Commit)
.where(
Commit.repo_id == repo_id,
Commit.sha == commit_sha[:12],
).limit(1)
)
.limit(1)
)
commit = commit_result.scalar_one_or_none()
if not commit:
Expand All @@ -116,11 +122,11 @@ async def get_or_create_narrative(
"prompt_type": prompt_type,
"explanation": cached.response_text,
"tokens_used": cached.tokens_input + cached.tokens_output,
"cost_usd": cached.cost_usd,
"cached": True,
"model": cached.model_used,
"provider": "cache",
"demo_mode": False,
"cost_usd": cached.cost_usd,
"cached": True,
"model": cached.model_used,
"provider": "cache",
"demo_mode": False,
}

# 3. Check budget — hard limit
Expand All @@ -135,23 +141,26 @@ async def get_or_create_narrative(
raise ValueError(f"No health snapshot for commit {commit_sha}")

after_dict = {
"health_score": snapshot.health_score,
"avg_complexity": snapshot.avg_complexity,
"churn_rate": snapshot.churn_rate,
"health_score": snapshot.health_score,
"avg_complexity": snapshot.avg_complexity,
"churn_rate": snapshot.churn_rate,
"num_files_changed": snapshot.num_files_changed,
"bus_factor_min": snapshot.bus_factor_min,
"top_files_json": snapshot.top_files_json,
"bus_factor_min": snapshot.bus_factor_min,
"top_files_json": snapshot.top_files_json,
"avg_semantic_drift": snapshot.avg_semantic_drift,
"semantic_health_score": snapshot.semantic_health_score,
"high_drift_files": snapshot.high_drift_files,
"semantic_drift_method": snapshot.semantic_drift_method,
}

prev_commit_result = await db.execute(
select(Commit).where(
select(Commit)
.where(
Commit.repo_id == repo_id,
Commit.committed_at < commit.committed_at,
).order_by(Commit.committed_at.desc()).limit(1)
)
.order_by(Commit.committed_at.desc())
.limit(1)
)
prev_commit = prev_commit_result.scalar_one_or_none()

Expand All @@ -161,7 +170,7 @@ async def get_or_create_narrative(
)
prev_snap = prev_snap_result.scalar_one_or_none()
before_dict = {
"health_score": prev_snap.health_score if prev_snap else 0,
"health_score": prev_snap.health_score if prev_snap else 0,
"avg_complexity": prev_snap.avg_complexity if prev_snap else 0,
"bus_factor_min": prev_snap.bus_factor_min if prev_snap else 1,
}
Expand Down Expand Up @@ -227,9 +236,9 @@ async def get_or_create_narrative(
"prompt_type": prompt_type,
"explanation": response_text,
"tokens_used": tokens_in + tokens_out,
"cost_usd": round(cost, 5),
"cached": False,
"model": model_used,
"provider": provider_used,
"demo_mode": demo_mode,
"cost_usd": round(cost, 5),
"cached": False,
"model": model_used,
"provider": provider_used,
"demo_mode": demo_mode,
}
18 changes: 12 additions & 6 deletions backend/features/llm_analysis/cost_guard.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.shared.models import LLMNarrative
from sqlalchemy.ext.asyncio import AsyncSession

from backend.config import LLM_BUDGET_PER_REPO_USD, LLM_MAX_CALLS
from backend.features.llm_analysis.llm_router import provider_from_model

from backend.shared.models import LLMNarrative

PROVIDER_COSTS_PER_1K_OUTPUT = {
"anthropic": 0.015,
Expand Down Expand Up @@ -44,8 +44,12 @@ async def get_usage_summary(repo_id: int, db: AsyncSession) -> dict:
cached_rows = [row for row in rows if row.is_pre_cached]
total_tokens = sum(row.tokens_input + row.tokens_output for row in provider_rows)
total_cost = sum(float(row.cost_usd or 0.0) for row in provider_rows)
anthropic_calls = sum(1 for row in provider_rows if provider_from_model(row.model_used) == "anthropic")
gemini_calls = sum(1 for row in provider_rows if provider_from_model(row.model_used) == "gemini")
anthropic_calls = sum(
1 for row in provider_rows if provider_from_model(row.model_used) == "anthropic"
)
gemini_calls = sum(
1 for row in provider_rows if provider_from_model(row.model_used) == "gemini"
)
cached_tokens = sum(row.tokens_input + row.tokens_output for row in cached_rows)
cache_hits = len(cached_rows)
return {
Expand All @@ -56,7 +60,9 @@ async def get_usage_summary(repo_id: int, db: AsyncSession) -> dict:
"gemini_calls": gemini_calls,
"total_tokens": total_tokens,
"total_cost_usd": round(float(total_cost or 0.0), 6),
"cache_savings_usd": round((cached_tokens / 1000.0) * PROVIDER_COSTS_PER_1K_OUTPUT["anthropic"], 6),
"cache_savings_usd": round(
(cached_tokens / 1000.0) * PROVIDER_COSTS_PER_1K_OUTPUT["anthropic"], 6
),
"budget_remaining": max(0, LLM_MAX_CALLS - len(provider_rows)),
"max_calls": LLM_MAX_CALLS,
}
9 changes: 7 additions & 2 deletions backend/features/llm_analysis/llm_router.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Multi-provider LLM routing for CommitIQ narratives."""

from __future__ import annotations

import asyncio
Expand Down Expand Up @@ -76,7 +77,9 @@ async def stream_narrative(
except Exception as exc:
logger.error("Gemini fallback failed: %s", exc)

raise RuntimeError("All LLM providers unavailable. Configure ANTHROPIC_API_KEY or GEMINI_API_KEY.")
raise RuntimeError(
"All LLM providers unavailable. Configure ANTHROPIC_API_KEY or GEMINI_API_KEY."
)


async def _stream_anthropic(prompt: str, max_tokens: int) -> AsyncGenerator[str, None]:
Expand Down Expand Up @@ -127,7 +130,9 @@ def _sync_stream():
yield text


async def get_narrative_non_streaming(prompt: str, max_tokens: int = 600) -> tuple[str, LLMProvider]:
async def get_narrative_non_streaming(
prompt: str, max_tokens: int = 600
) -> tuple[str, LLMProvider]:
full_text: list[str] = []
provider_used = LLMProvider.NONE
async for token, provider in stream_narrative(prompt, max_tokens):
Expand Down
34 changes: 16 additions & 18 deletions backend/features/llm_analysis/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
Input is always a JSON diff of metric values.
Target: under 700 input tokens.
"""
import json

import json

EXPLAIN_DROP_SYSTEM = """You are a senior engineering analyst reviewing codebase health data.
Given two commit health snapshots and their metric diff, explain in 3-4 sentences why the
Expand All @@ -31,21 +31,19 @@ def build_explain_prompt(
Total cost: ~$0.003–0.004 per call.
"""
diff = {
"commit_message": commit_msg[:120],
"health_before": before.get("health_score", 0),
"health_after": after.get("health_score", 0),
"health_delta": round(
after.get("health_score", 0) - before.get("health_score", 0), 2
),
"commit_message": commit_msg[:120],
"health_before": before.get("health_score", 0),
"health_after": after.get("health_score", 0),
"health_delta": round(after.get("health_score", 0) - before.get("health_score", 0), 2),
"complexity_before": before.get("avg_complexity", 0),
"complexity_after": after.get("avg_complexity", 0),
"complexity_delta": round(
"complexity_after": after.get("avg_complexity", 0),
"complexity_delta": round(
after.get("avg_complexity", 0) - before.get("avg_complexity", 0), 2
),
"churn_rate": after.get("churn_rate", 0),
"files_changed": after.get("num_files_changed", 0),
"churn_rate": after.get("churn_rate", 0),
"files_changed": after.get("num_files_changed", 0),
"bus_factor_before": before.get("bus_factor_min", 1),
"bus_factor_after": after.get("bus_factor_min", 1),
"bus_factor_after": after.get("bus_factor_min", 1),
"avg_semantic_drift": after.get("avg_semantic_drift", 0),
"semantic_health_score": after.get("semantic_health_score", 100),
"high_drift_files": after.get("high_drift_files", 0),
Expand All @@ -62,15 +60,15 @@ def build_predict_prompt(branch_metrics: dict, main_metrics: dict) -> str:
Cost: ~$0.002–0.003 per call.
"""
comparison = {
"main_health": main_metrics.get("health_score", 0),
"branch_health": branch_metrics.get("health_score", 0),
"health_delta": round(
"main_health": main_metrics.get("health_score", 0),
"branch_health": branch_metrics.get("health_score", 0),
"health_delta": round(
branch_metrics.get("health_score", 0) - main_metrics.get("health_score", 0), 2
),
"complexity_main": main_metrics.get("avg_complexity", 0),
"complexity_main": main_metrics.get("avg_complexity", 0),
"complexity_branch": branch_metrics.get("avg_complexity", 0),
"churn_branch": branch_metrics.get("churn_rate", 0),
"churn_branch": branch_metrics.get("churn_rate", 0),
"bus_factor_branch": branch_metrics.get("bus_factor_min", 1),
"files_to_merge": branch_metrics.get("num_files_changed", 0),
"files_to_merge": branch_metrics.get("num_files_changed", 0),
}
return f"Predict health impact of merging this branch:\n{json.dumps(comparison, indent=2)}"
Loading