Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ RUN pip install --no-cache-dir .

EXPOSE 8000

CMD ["sh", "-c", "uvicorn src.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
CMD ["sh", "-c", "NEO4J_URI=${NEO4J_URI:-bolt://localhost:7687} NEO4J_USERNAME=${NEO4J_USERNAME:-neo4j} NEO4J_PASSWORD=${NEO4J_PASSWORD:-test} QDRANT_URL=${QDRANT_URL:-http://localhost:6333} REDIS_URL=${REDIS_URL:-redis://localhost:6379} uvicorn src.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
58 changes: 36 additions & 22 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,12 @@
from pydantic import BaseModel, Field
from typing import Any, Callable, cast

from ingestion.docs_loader import DocsLoader
from ingestion.github_loader import GitHubGraphLoader

from config import RAW_DOCS_DIR

from agents.support_agent import SupportAgent
from database.redis_store import get_redis_store
from middleware.rate_limit import check_rate_limit, decrement_rate_limit
from middleware.feedback import FeedbackStore
from middleware.auth import get_current_user
from middleware.webhook_handler import handle_push, handle_issue_event, handle_pr_event
from utils.logging_config import setup_logging
from utils.validators import validate_session_id
from settings import settings
Expand Down Expand Up @@ -95,13 +90,22 @@ def _release_session_lock(session_id: str) -> None:
with _session_locks_lock:
_session_locks.pop(session_id, None)

logger.info("Initializing SupportAgent...")
try:
agent: Any = SupportAgent()
logger.info("SupportAgent initialized successfully.")
except Exception as e:
logger.warning("SupportAgent init failed (external services unavailable): %s", e)
agent = None
_agent: Any = None


def _get_agent() -> Any:
"""Lazy-initialize SupportAgent on first use."""
global _agent
if _agent is None:
from agents.support_agent import SupportAgent
logger.info("Initializing SupportAgent...")
try:
_agent = SupportAgent()
logger.info("SupportAgent initialized successfully.")
except Exception as e:
logger.warning("SupportAgent init failed (external services unavailable): %s", e)
raise
return _agent

# Startup security check
env = os.getenv("ENV", "development").lower()
Expand Down Expand Up @@ -186,6 +190,8 @@ async def github_webhook(

logger.info("Webhook received", extra={"event": event, "session_id": session_id})

from middleware.webhook_handler import handle_push, handle_issue_event, handle_pr_event

if event == "push":
background_tasks.add_task(handle_push, payload, session_id)
elif event == "issues":
Expand All @@ -204,7 +210,9 @@ async def solve_ticket(
user: dict = Depends(get_current_user),
x_session_id: str | None = Header(default=None, alias="X-Session-Id"),
):
if agent is None:
try:
current_agent = _get_agent()
except Exception:
raise HTTPException(status_code=503, detail="Agent not initialized — external services unavailable")
try:
# Running our LangGraph State Machine
Expand All @@ -228,7 +236,7 @@ async def solve_ticket(
# If a session_id is provided, require that the repo was prepared (docs collection exists).
if session_id:
try:
ready = agent.retriever.vector_store.has_session_collection(session_id)
ready = current_agent.retriever.vector_store.has_session_collection(session_id)
except Exception:
ready = False
if not ready:
Expand All @@ -255,9 +263,9 @@ async def solve_ticket(
"allow_web_search": bool(request.allow_web_search),
}
config = {"configurable": {"thread_id": session_id or "default"}}
result = cast(Any, agent.app).invoke(initial_state, config=config)
result = cast(Any, current_agent.app).invoke(initial_state, config=config)

agent.prune_history(session_id or "default")
current_agent.prune_history(session_id or "default")

return {
"status": "success",
Expand Down Expand Up @@ -394,6 +402,9 @@ def _prepare_repo_task(
return

try:
from ingestion.docs_loader import DocsLoader
from ingestion.github_loader import GitHubGraphLoader

# Get or initialize completed phases list
if completed_phases is None:
completed_phases = []
Expand Down Expand Up @@ -511,7 +522,7 @@ def _run_index_code():
if code_chunks:
_index_code_chunks(
code_chunks, session_id, report,
vector_store=agent.retriever.vector_store,
vector_store=_get_agent().retriever.vector_store,
)
completed_phases.append("indexing_code")
redis_store.mark_phase_complete_sync(session_id, "indexing_code")
Expand Down Expand Up @@ -645,13 +656,15 @@ async def _cleanup_session_data(session_id: str) -> None:

# 2. Delete Qdrant collections
try:
agent.retriever.vector_store.cleanup_session(session_id)
current_agent = _get_agent()
current_agent.retriever.vector_store.cleanup_session(session_id)
except Exception as e:
logger.warning("Qdrant cleanup failed: %s", e)

# 3. Delete Neo4j session nodes
try:
agent.retriever.graph_store.cleanup_session(session_id)
current_agent = _get_agent()
current_agent.retriever.graph_store.cleanup_session(session_id)
except Exception as e:
logger.warning("Neo4j cleanup failed: %s", e)

Expand Down Expand Up @@ -759,8 +772,9 @@ async def _cleanup_orphaned_sessions() -> int:
Returns the number of orphaned sessions cleaned."""
cleaned = 0
try:
current_agent = _get_agent()
# Scan Qdrant collections for docs_{sid} and code_{sid} patterns
collections = agent.retriever.vector_store.client.get_collections().collections
collections = current_agent.retriever.vector_store.client.get_collections().collections
for col in collections:
name = col.name
sid = None
Expand All @@ -771,12 +785,12 @@ async def _cleanup_orphaned_sessions() -> int:
if sid and not await redis_store.get_session(sid):
logger.info("Cleaning orphaned Qdrant collection", extra={"collection": name, "session_id": sid})
try:
agent.retriever.vector_store.client.delete_collection(name)
current_agent.retriever.vector_store.client.delete_collection(name)
except Exception as e:
logger.warning("Failed to delete orphaned Qdrant collection %s: %s", name, e)
# Also clean Neo4j and disk for this session
try:
agent.retriever.graph_store.cleanup_session(sid)
current_agent.retriever.graph_store.cleanup_session(sid)
except Exception as e:
logger.warning("Failed to clean orphaned Neo4j data for %s: %s", sid, e)
repo_path = settings.RAW_DOCS_DIR / sid
Expand Down
19 changes: 12 additions & 7 deletions backend/src/middleware/webhook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@
import os
import re
import requests
from typing import Any

from langchain_text_splitters import MarkdownHeaderTextSplitter

from database.vector_store import VectorStore
from database.graph_store import GraphStore
from utils.logging_config import setup_logging

from qdrant_client.models import PointStruct

logger = setup_logging(__name__)

FEATURE_NAME_PATTERN = re.compile(r"^[\w\s-]{1,128}$")
Expand Down Expand Up @@ -59,6 +54,8 @@ def _download_raw_file(owner: str, repo: str, branch: str, file_path: str) -> st

def _split_markdown(content: str, source_label: str) -> list[dict]:
"""Split markdown content into chunks with metadata."""
from langchain_text_splitters import MarkdownHeaderTextSplitter

headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
Expand All @@ -79,11 +76,13 @@ def _split_markdown(content: str, source_label: str) -> list[dict]:


def _embed_and_upsert(
vector_store: VectorStore,
vector_store: Any,
chunks: list[dict],
session_id: str | None,
) -> None:
"""Embed text chunks and upsert into Qdrant."""
from qdrant_client.models import PointStruct

if not chunks:
return
texts = [c["text"] for c in chunks]
Expand Down Expand Up @@ -129,6 +128,8 @@ def _embed_and_upsert(

def handle_push(payload: dict, session_id: str | None = None) -> None:
"""Re-index changed .md files from a push event."""
from database.vector_store import VectorStore

owner, repo_name, branch, full_name = _parse_repo(payload)
commits = payload.get("commits", [])
changed_md_files: set[str] = set()
Expand Down Expand Up @@ -160,6 +161,8 @@ def handle_push(payload: dict, session_id: str | None = None) -> None:

def handle_issue_event(payload: dict, session_id: str | None = None) -> None:
"""Update Neo4j graph when a GitHub issue is opened/closed/reopened/edited."""
from database.graph_store import GraphStore

issue = payload.get("issue", {})
if not issue:
logger.warning("Issue event missing 'issue' field")
Expand Down Expand Up @@ -219,6 +222,8 @@ def _link_pr_to_issues(graph_store, pr_number: int, body: str, session_id: str |

def handle_pr_event(payload: dict, session_id: str | None = None) -> None:
"""Update Neo4j graph when a PR is opened/closed/edited."""
from database.graph_store import GraphStore

pr = payload.get("pull_request", {})
if not pr:
logger.warning("PR event missing 'pull_request' field")
Expand Down
12 changes: 6 additions & 6 deletions backend/tests/test_webhook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def test_handle_push_extracts_md_files():
}

with (
patch("middleware.webhook_handler.VectorStore") as mock_vs,
patch("database.vector_store.VectorStore") as mock_vs,
patch("middleware.webhook_handler._download_raw_file") as mock_download,
patch("middleware.webhook_handler._split_markdown") as mock_split,
patch("middleware.webhook_handler._embed_and_upsert"),
Expand Down Expand Up @@ -64,7 +64,7 @@ def test_handle_push_no_md_files():
}

with (
patch("middleware.webhook_handler.VectorStore") as mock_vs,
patch("database.vector_store.VectorStore") as mock_vs,
patch("middleware.webhook_handler._download_raw_file") as mock_download,
):
handle_push(payload, session_id="test-session")
Expand All @@ -87,7 +87,7 @@ def test_handle_issue_event_upserts_issue():
},
}

with patch("middleware.webhook_handler.GraphStore") as mock_gs:
with patch("database.graph_store.GraphStore") as mock_gs:
handle_issue_event(payload, session_id="test-session")

mock_gs.return_value.upsert_issue.assert_called_once()
Expand All @@ -108,7 +108,7 @@ def test_handle_issue_event_deleted_skipped():
"issue": {"number": 999, "title": "Spam issue"},
}

with patch("middleware.webhook_handler.GraphStore") as mock_gs:
with patch("database.graph_store.GraphStore") as mock_gs:
handle_issue_event(payload, session_id="test-session")
mock_gs.return_value.upsert_issue.assert_not_called()

Expand All @@ -134,7 +134,7 @@ def test_handle_pr_event_merged():
}

with (
patch("middleware.webhook_handler.GraphStore") as mock_gs,
patch("database.graph_store.GraphStore") as mock_gs,
patch("middleware.webhook_handler.requests.get"),
):
handle_pr_event(payload, session_id="test-session")
Expand Down Expand Up @@ -164,6 +164,6 @@ def test_handle_pr_event_not_merged_skipped():
},
}

with patch("middleware.webhook_handler.GraphStore") as mock_gs:
with patch("database.graph_store.GraphStore") as mock_gs:
handle_pr_event(payload, session_id="test-session")
mock_gs.return_value.upsert_pr.assert_not_called()
Loading