Commit 841414df authored by Mahmoud Aglan's avatar Mahmoud Aglan

test

parent 37b9873f
......@@ -53,6 +53,20 @@ def _run_migrations():
if table_name not in existing_tables:
print(f" Creating {table_name} table")
if "linked_repos" in existing_tables:
lr_columns = {c["name"] for c in inspector.get_columns("linked_repos")}
with engine.connect() as conn:
if "architecture_map" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN architecture_map TEXT"))
print(" Added linked_repos.architecture_map column")
if "map_status" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_status VARCHAR(20) DEFAULT 'none'"))
print(" Added linked_repos.map_status column")
if "map_generated_at" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_generated_at DATETIME"))
print(" Added linked_repos.map_generated_at column")
conn.commit()
except Exception as e:
print(f" Migration note: {e}")
......
......@@ -151,6 +151,9 @@ class LinkedRepo(Base):
default_branch = Column(String(100), default="main")
web_url = Column(String(500), default="")
description = Column(Text, default="")
architecture_map = Column(Text, nullable=True)
map_status = Column(String(20), default="none")
map_generated_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
actions = relationship("PendingAction", back_populates="repo", cascade="all,delete-orphan")
......
......@@ -3,6 +3,7 @@ GitLab CE integration routes — superadmin only.
Son of Anton v4.0.0
"""
import asyncio
import json
from datetime import datetime
from typing import Optional
......@@ -14,7 +15,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, GitLabSettings, LinkedRepo, PendingAction
from backend.auth import require_superadmin
from backend.services import gitlab_service
from backend.services import gitlab_service, code_analyzer
router = APIRouter()
......@@ -181,10 +182,18 @@ async def link_repo(body: LinkRepoBody, admin: User = Depends(require_superadmin
default_branch=project.get("default_branch", "main"),
web_url=project.get("web_url", ""),
description=project.get("description", ""),
map_status="analyzing",
)
db.add(repo)
db.commit()
db.refresh(repo)
# Start background analysis
asyncio.create_task(_analyze_repo_background(
repo.id, s.gitlab_url, s.gitlab_token,
project["id"], project.get("default_branch", "main"),
))
return _repo_dict(repo)
......@@ -196,6 +205,84 @@ def unlink_repo(repo_id: str, admin: User = Depends(require_superadmin), db: Ses
return {"ok": True}
# ═══════════════════════════════════════════════════
# Architecture Map
# ═══════════════════════════════════════════════════
async def _analyze_repo_background(repo_id: str, gitlab_url: str, gitlab_token: str, project_id: int, branch: str):
"""Background task: load all files and generate architecture map."""
from backend.database import SessionLocal as BgSession
db = BgSession()
try:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
if not repo:
return
repo.map_status = "analyzing"
db.commit()
result = await gitlab_service.load_project_files(
gitlab_url, gitlab_token, project_id, ref=branch,
)
files = result.get("files", [])
if not files:
repo.map_status = "failed"
repo.architecture_map = "[No files could be loaded for analysis]"
db.commit()
return
architecture_map = code_analyzer.analyze_codebase(files)
repo.architecture_map = architecture_map
repo.map_status = "ready"
repo.map_generated_at = datetime.utcnow()
db.commit()
print(f" ✅ Architecture map generated for {repo.name} ({len(architecture_map)} chars)")
except Exception as e:
try:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
if repo:
repo.map_status = "failed"
repo.architecture_map = f"[Analysis failed: {str(e)[:200]}]"
db.commit()
except Exception:
pass
print(f" ❌ Architecture analysis failed for repo {repo_id}: {e}")
finally:
db.close()
@router.post("/repos/{repo_id}/analyze")
async def reanalyze_repo(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
"""Re-generate the architecture map for a linked repo."""
s = _get_settings(db)
repo = _get_repo(repo_id, db)
repo.map_status = "analyzing"
db.commit()
asyncio.create_task(_analyze_repo_background(
repo.id, s.gitlab_url, s.gitlab_token,
repo.gitlab_project_id, repo.default_branch,
))
return {"ok": True, "status": "analyzing"}
@router.get("/repos/{repo_id}/map")
def get_repo_map(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
"""Get the architecture map for a linked repo."""
repo = _get_repo(repo_id, db)
return {
"map_status": repo.map_status or "none",
"map_generated_at": str(repo.map_generated_at) if repo.map_generated_at else None,
"architecture_map": repo.architecture_map or "",
"map_size": len(repo.architecture_map or ""),
}
# ═══════════════════════════════════════════════════
# Repository Operations
# ═══════════════════════════════════════════════════
......@@ -300,23 +387,6 @@ async def create_mr(repo_id: str, body: MergeRequestBody, admin: User = Depends(
raise HTTPException(e.status_code, e.detail)
@router.get("/repos/{repo_id}/analyze")
async def analyze_project(
repo_id: str,
ref: Optional[str] = Query(None),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
branch = ref or repo.default_branch
try:
result = await gitlab_service.load_project_files(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, ref=branch)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
# ═══════════════════════════════════════════════════
# Pending Actions
# ═══════════════════════════════════════════════════
......@@ -419,6 +489,8 @@ def _repo_dict(r: LinkedRepo) -> dict:
"default_branch": r.default_branch,
"web_url": r.web_url,
"description": r.description,
"map_status": r.map_status or "none",
"map_generated_at": str(r.map_generated_at) if r.map_generated_at else None,
"created_at": str(r.created_at),
}
......
This diff is collapsed.
"""
Background generation manager — v4.1.0
Smart codebase loading for massive repos + persistent file context.
Smart codebase loading for massive repos + persistent file context + architecture mindmap.
"""
import asyncio
......@@ -19,11 +19,9 @@ from backend.services import bedrock_service, memory_service, rag_service, attac
# Caches
# ═══════════════════════════════════════════════════
# Tree cache: repo_id:branch → (timestamp, tree_list)
_tree_cache: dict[str, tuple[float, list[dict]]] = {}
TREE_CACHE_TTL = 600 # 10 minutes
TREE_CACHE_TTL = 600
# Tracks which files have been discussed per chat
_chat_file_history: dict[str, set[str]] = {}
......@@ -103,7 +101,6 @@ class GenerationManager:
await asyncio.sleep(0.02)
def invalidate_repo_cache(self, repo_id: str):
"""Call after a commit to force-refresh on next message."""
keys_to_remove = [k for k in _tree_cache if k.startswith(f"{repo_id}:")]
for k in keys_to_remove:
_tree_cache.pop(k, None)
......@@ -115,13 +112,6 @@ class GenerationManager:
async def _build_repo_context(
self, db, chat, user_query: str
) -> Optional[str]:
"""
Build repo context using smart file selection.
For ANY size codebase:
1. Full file tree (paths only) — always included
2. Priority files (configs, entry points) — always loaded
3. Query-relevant files — loaded based on what user asked
"""
if not chat.linked_repo_id:
return None
......@@ -138,7 +128,6 @@ class GenerationManager:
branch = repo.default_branch
try:
# 1. Get tree (cached)
tree = _get_tree_cache(repo.id, branch)
if tree is None:
tree = await gitlab_service.get_tree(
......@@ -147,10 +136,8 @@ class GenerationManager:
)
_set_tree_cache(repo.id, branch, tree)
# 2. Get previously discussed files for this chat
prev_files = _chat_file_history.get(chat.id, set())
# 3. Smart-load files
result = await gitlab_service.load_smart_files(
gl_url, gl_token, repo.gitlab_project_id,
ref=branch, tree=tree,
......@@ -158,7 +145,6 @@ class GenerationManager:
previous_files=prev_files,
)
# 4. Track loaded files for future messages
loaded_paths = set()
for f in result["priority_files"]:
loaded_paths.add(f["path"])
......@@ -168,11 +154,9 @@ class GenerationManager:
_chat_file_history[chat.id] = set()
_chat_file_history[chat.id].update(loaded_paths)
# 5. Format the context
return self._format_smart_context(result, tree, repo)
return self._format_smart_context(result, tree, repo, db)
except Exception as e:
# Fallback: just the tree
try:
tree = await gitlab_service.get_tree(
gl_url, gl_token, repo.gitlab_project_id, ref=branch,
......@@ -182,16 +166,10 @@ class GenerationManager:
return f"[Repository: {repo.name} — error: {str(e)[:200]}]"
def _format_smart_context(
self, result: dict, tree: list[dict], repo
self, result: dict, tree: list[dict], repo, db
) -> str:
"""Format loaded files into prompt context."""
# File tree
files_in_tree = sorted(
[i["path"] for i in tree if i["type"] == "blob"]
)
dirs_in_tree = sorted(
[i["path"] for i in tree if i["type"] == "tree"]
)
files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"])
dirs_in_tree = sorted([i["path"] for i in tree if i["type"] == "tree"])
lines = [
f"Repository: {repo.name}",
......@@ -200,44 +178,45 @@ class GenerationManager:
f"Total files: {len(files_in_tree)} | Directories: {len(dirs_in_tree)}",
f"Files loaded into context: {result['files_loaded']}",
f"Characters loaded: {result['total_characters']:,}",
"",
"═" * 60,
"COMPLETE FILE TREE (all paths):",
"═" * 60,
]
# Architecture map
if repo.architecture_map and repo.map_status == "ready":
lines.append("")
lines.append(repo.architecture_map)
lines.append("")
# File tree
lines.append("═" * 60)
lines.append("COMPLETE FILE TREE:")
lines.append("═" * 60)
for fp in files_in_tree:
lines.append(f" {fp}")
# File contents
lines.append("")
lines.append("═" * 60)
lines.append("LOADED FILE CONTENTS:")
lines.append("═" * 60)
# Priority files
if result["priority_files"]:
lines.append("")
lines.append("── Config & Entry Point Files ──")
lines.append("\n── Config & Entry Point Files ──")
for f in result["priority_files"]:
lines.append(f"\n━━━ {f['path']} ━━━")
lines.append(f["content"])
lines.append(f"━━━ end {f['path']} ━━━")
# Query-relevant files
if result["query_files"]:
lines.append("")
lines.append("── Files Relevant to Current Question ──")
lines.append("\n── Files Relevant to Current Question ──")
for f in result["query_files"]:
lines.append(f"\n━━━ {f['path']} ━━━")
lines.append(f["content"])
lines.append(f"━━━ end {f['path']} ━━━")
# Note about unloaded files
unloaded = len(files_in_tree) - result["files_loaded"]
if unloaded > 0:
lines.append("")
lines.append(f"NOTE: {unloaded} additional files exist in the repository.")
lines.append("If you need to see a specific file, ask the user to mention it by name.")
lines.append("You can see ALL file paths in the tree above.")
lines.append(f"\nNOTE: {unloaded} additional files exist but are not loaded.")
lines.append("Mention specific file names to have them loaded in the next message.")
return "\n".join(lines)
......@@ -266,7 +245,6 @@ class GenerationManager:
db_user = db.query(User).filter(User.id == user_id).first()
# Quota reset
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
......@@ -280,7 +258,6 @@ class GenerationManager:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return
# Process attachments
attachments = []
if attachment_ids:
attachments = (
......@@ -305,7 +282,6 @@ class GenerationManager:
if attachments:
db.commit()
# RAG
kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
......@@ -314,29 +290,22 @@ class GenerationManager:
except Exception:
pass
# ── SMART REPO CONTEXT (query-aware file loading) ──
repo_context = await self._build_repo_context(db, chat, content)
# ── PERSISTENT ATTACHMENT CONTEXT ──
attachment_context = memory_service.gather_attachment_context(chat_id, db)
# Build system prompt
system_prompt = build_full_prompt(
rag_context=rag_context,
repo_context=repo_context,
attachment_context=attachment_context,
)
# Build conversation messages
messages = memory_service.build_messages(chat, db)
# Inject multimodal content blocks for current attachments
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content})
messages[-1]["content"] = content_blocks
# Thinking config
effective_max = max_tokens
thinking_config = None
if reasoning_budget > 0:
......@@ -387,7 +356,6 @@ class GenerationManager:
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
# Save assistant message
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
......@@ -404,7 +372,6 @@ class GenerationManager:
state.message_id = assistant_msg.id
# Auto-title
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
......
......@@ -176,6 +176,8 @@ export const gitlabCommitSingle = (token, repoId, data) => request("POST", `/git
export const gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
export const gitlabAnalyzeProject = (token, repoId, ref) =>
request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabReanalyzeRepo = (token, repoId) => request("POST", `/gitlab/repos/${repoId}/analyze`, token);
export const gitlabGetRepoMap = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/map`, token);
export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token);
export const gitlabCreateAction = (token, data) => request("POST", "/gitlab/actions", token, data);
export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token);
......
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import {
getMessages, downloadZip, listKnowledgeBases, updateChat,
......@@ -108,9 +108,6 @@ export default function ChatView({ chatId }) {
linked_repo_id: selectedRepoId || "",
});
// ── THIS IS THE FIX ──
// Build the full linked_repo object from the local repos list
// so the UI immediately sees the repo banner, commit buttons, etc.
const repoObj = selectedRepoId
? repos.find(r => r.id === selectedRepoId) || null
: null;
......@@ -124,7 +121,7 @@ export default function ChatView({ chatId }) {
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
linked_repo_id: selectedRepoId,
linked_repo: repoObj, // ← was missing
linked_repo: repoObj,
},
});
} catch { }
......@@ -164,7 +161,6 @@ export default function ChatView({ chatId }) {
if (!msg) return;
try {
await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action });
// Refresh repo cache so AI sees updated code
try { await refreshRepoContext(state.token, chatId); } catch { }
} catch (e) { alert(`❌ ${e.message}`); throw e; }
}, [linkedRepo, state.token, chatId]);
......@@ -185,14 +181,31 @@ export default function ChatView({ chatId }) {
{/* Repo banner */}
{linkedRepo && (
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs">
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs flex-wrap">
<GitBranch size={12} className="text-orange-400" />
<span className="text-orange-300 font-medium">{linkedRepo.name}</span>
<span className="text-orange-300/60">({linkedRepo.default_branch})</span>
<span className="text-orange-300/40">Full codebase loaded</span>
<button onClick={handleRefreshRepo} disabled={refreshingRepo} className="ml-auto text-orange-300/60 hover:text-orange-300 transition" title="Refresh repo context">
<RefreshCw size={12} className={refreshingRepo ? "animate-spin" : ""} />
</button>
{linkedRepo.map_status === "ready" && (
<span className="text-green-400/80 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" /> Mindmap ready
</span>
)}
{linkedRepo.map_status === "analyzing" && (
<span className="text-amber-400/80 flex items-center gap-1">
<Loader2 size={10} className="animate-spin" /> Analyzing…
</span>
)}
{linkedRepo.map_status === "failed" && (
<span className="text-red-400/80">Map failed</span>
)}
{(!linkedRepo.map_status || linkedRepo.map_status === "none") && (
<span className="text-orange-300/40">No mindmap</span>
)}
<div className="ml-auto flex items-center gap-2">
<button onClick={handleRefreshRepo} disabled={refreshingRepo} className="text-orange-300/60 hover:text-orange-300 transition" title="Refresh repo cache">
<RefreshCw size={12} className={refreshingRepo ? "animate-spin" : ""} />
</button>
</div>
</div>
)}
......@@ -246,9 +259,9 @@ export default function ChatView({ chatId }) {
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository (AI sees all files)</label>
<select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400">
<option value="">None</option>
{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name} ({r.default_branch})</option>)}
{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name} ({r.default_branch}){r.map_status === "ready" ? " ✅" : r.map_status === "analyzing" ? " ⏳" : ""}</option>)}
</select>
<p className="text-[9px] text-orange-400/60 mt-1">When linked, AI loads the full codebase into context.</p>
<p className="text-[9px] text-orange-400/60 mt-1">When linked, AI loads the full codebase + architecture mindmap into context.</p>
</div>
)}
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment