diff --git a/Dockerfile b/Dockerfile index 0509829..15b1a36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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}"] diff --git a/backend/src/main.py b/backend/src/main.py index 2508284..2868824 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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 @@ -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() @@ -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": @@ -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 @@ -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: @@ -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", @@ -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 = [] @@ -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") @@ -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) @@ -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 @@ -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 diff --git a/backend/src/middleware/webhook_handler.py b/backend/src/middleware/webhook_handler.py index 5751f96..84d101e 100644 --- a/backend/src/middleware/webhook_handler.py +++ b/backend/src/middleware/webhook_handler.py @@ -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}$") @@ -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"), @@ -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] @@ -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() @@ -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") @@ -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") diff --git a/backend/tests/test_webhook_handler.py b/backend/tests/test_webhook_handler.py index 1d6523f..19f76ed 100644 --- a/backend/tests/test_webhook_handler.py +++ b/backend/tests/test_webhook_handler.py @@ -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"), @@ -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") @@ -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() @@ -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() @@ -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") @@ -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()