RDS777 commited on
Commit
4d1f2be
·
verified ·
1 Parent(s): d9db648

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +126 -311
main.py CHANGED
@@ -1,8 +1,7 @@
1
- # app.py — ADN NEXUS V10.3.2 AGENTIC IDE (SECURED WS + LOGIN SCREEN + FEW-SHOT AGENT)
2
- # Serveur FastAPI + Client IDE embarque + Agent + Tool-calling
3
- #
4
- # requirements.txt doit inclure "pytest>=7.4.0" pour que le bouton "Tests"
5
- # (qui delegue a l'agent l'execution de `pytest` via run_python) fonctionne.
6
  from __future__ import annotations
7
 
8
  import os
@@ -32,7 +31,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depe
32
  from fastapi.middleware.cors import CORSMiddleware
33
  from fastapi.responses import HTMLResponse, JSONResponse
34
  import numpy as np
35
- from sentence_transformers import SentenceTransformer, CrossEncoder
36
  import feedparser
37
  from bs4 import BeautifulSoup
38
 
@@ -43,20 +42,10 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
43
  logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(message)s")
44
  logger = logging.getLogger("nexus_v103")
45
 
46
- # Detection materielle centralisee, calculee une seule fois au demarrage.
47
- # Tous les composants (modele de generation, encodeur, reranker) lisent
48
- # cette meme valeur plutot que de deviner chacun de leur cote — evite
49
- # qu'un composant charge sur GPU pendant qu'un autre reste sur CPU par
50
- # defaut faute de parametre explicite, et donne un log clair et unique
51
- # de ce qui a ete detecte.
52
  _DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
53
  logger.info(f"Materiel detecte : {_DEVICE.upper()}" + (f" ({torch.cuda.get_device_name(0)})" if _DEVICE == "cuda" else " — pas de GPU disponible, bascule automatique sur CPU"))
54
 
55
-
56
  def _load_token(env_name: str) -> str:
57
- """Charge un token depuis les secrets HF Spaces (variable d'env).
58
- Si absent, genere un token aleatoire fort et l'affiche UNE FOIS dans les
59
- logs au demarrage — jamais de valeur par defaut previsible en dur."""
60
  val = os.getenv(env_name)
61
  if val:
62
  return val
@@ -69,15 +58,12 @@ def _load_token(env_name: str) -> str:
69
  )
70
  return generated
71
 
72
-
73
  class Config:
74
  PORT = int(os.getenv("PORT", 7860))
75
  HF_TOKEN = os.getenv("HF_TOKEN")
76
  DEVICE = _DEVICE
77
- # Tokens charges depuis les secrets HF Spaces — plus de valeur par defaut
78
- # devinable ("nexus_secret_12345", "123456") codee en dur dans le code.
79
  API_TOKEN = _load_token("API_TOKEN")
80
- LEGACY_TOKEN = os.getenv("LEGACY_TOKEN") # optionnel, retro-compat uniquement si defini explicitement
81
  MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-1.5B-Instruct")
82
  TIMEOUT = int(os.getenv("TIMEOUT", 180))
83
  WORKSPACE_ROOT = Path("/tmp/workspace")
@@ -87,7 +73,6 @@ class Config:
87
  WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
88
  CACHE_TTL = 300
89
  QUEUE_MAXSIZE = 10000
90
- NUM_SCRAPE_WORKERS = 2
91
  MAX_CONTENT_LENGTH = 5000
92
  CHUNK_SIZE = 1000
93
  CHUNK_OVERLAP = 200
@@ -95,30 +80,17 @@ class Config:
95
  RATE_LIMIT_WINDOW = 60
96
  MAX_TOKENS = 512
97
  TEMPERATURE = 0.2
98
- # Raisonnement multi-candidats (optionnel, desactive par defaut) : genere
99
- # plusieurs reponses avec un vrai echantillonnage stochastique (do_sample
100
- # + temperature) pour la premiere etape d'une tache libre, puis choisit
101
- # la meilleure par heuristique avant de poursuivre la boucle normale.
102
- # Double le cout de calcul pour cette etape — a activer au cas par cas.
103
  SAMPLING_CANDIDATES = int(os.getenv("SAMPLING_CANDIDATES", 2))
104
  REASONING_TEMPERATURE = float(os.getenv("REASONING_TEMPERATURE", 0.7))
105
- # Gestion de la fenetre de contexte pour les sessions longues : au-dela
106
- # de ce nombre de tokens dans l'historique de la tache, les anciens
107
- # resultats d'outils sont tronques (nettoyage cible deterministe, pas
108
- # un resume genere par le modele — voir _compress_old_tool_results).
109
- # Marge sous max_length=2048 utilise dans generate() pour laisser de
110
- # la place a la nouvelle generation.
111
  MAX_CONTEXT_TOKENS = int(os.getenv("MAX_CONTEXT_TOKENS", 1400))
112
  TOOL_RESULT_KEEP_CHARS = int(os.getenv("TOOL_RESULT_KEEP_CHARS", 300))
113
 
114
-
115
  def _valid_tokens() -> tuple:
116
  toks = [Config.API_TOKEN]
117
  if Config.LEGACY_TOKEN:
118
  toks.append(Config.LEGACY_TOKEN)
119
  return tuple(toks)
120
 
121
-
122
  # =================== RATE LIMITER ===================
123
  class RateLimiter:
124
  def __init__(self, max_requests: int, window: int):
@@ -138,10 +110,8 @@ class RateLimiter:
138
  self.requests[key].append(now)
139
  return True
140
 
141
-
142
  rate_limiter = RateLimiter(Config.RATE_LIMIT_REQUESTS, Config.RATE_LIMIT_WINDOW)
143
 
144
-
145
  # =================== BASE DE DONNEES (POOL ASYNC) ===================
146
  @dataclass
147
  class DBConnectionPool:
@@ -186,10 +156,6 @@ class DBConnectionPool:
186
  def _init_db(self):
187
  conn = sqlite3.connect(self.db_path)
188
  c = conn.cursor()
189
- # Mode WAL : permet des lectures concurrentes pendant une ecriture
190
- # (utile ici car plusieurs coroutines partagent le meme fichier
191
- # SQLite via le pool de connexions) au lieu du mode par defaut qui
192
- # verrouille toute la base pendant une transaction d'ecriture.
193
  c.execute("PRAGMA journal_mode=WAL;")
194
  c.execute("PRAGMA synchronous=NORMAL;")
195
  c.executescript('''
@@ -213,10 +179,6 @@ class DBConnectionPool:
213
  prompt TEXT, answer TEXT, status TEXT, feedback TEXT,
214
  chunks_used TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP
215
  );
216
- CREATE TABLE IF NOT EXISTS scrape_queue (
217
- url TEXT PRIMARY KEY, priority INTEGER DEFAULT 0,
218
- added_at TEXT DEFAULT CURRENT_TIMESTAMP, status TEXT DEFAULT 'pending'
219
- );
220
  CREATE TABLE IF NOT EXISTS task_tracker (
221
  task_id TEXT PRIMARY KEY, task_type TEXT, payload TEXT,
222
  status TEXT DEFAULT 'pending', created_at TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -257,10 +219,8 @@ class DBConnectionPool:
257
  conn.close()
258
  logger.info(f"DB initialisee: {self.db_path}")
259
 
260
-
261
  db_pool = DBConnectionPool(Config.DB_PATH)
262
 
263
-
264
  # =================== AUTH (REST) ===================
265
  async def verify_any_token(authorization: str = Header(...)):
266
  if not authorization.startswith("Bearer "):
@@ -272,7 +232,6 @@ async def verify_any_token(authorization: str = Header(...)):
272
  raise HTTPException(429, detail="Rate limit exceeded")
273
  return token
274
 
275
-
276
  # =================== VECTOR STORE ===================
277
  class VectorStore:
278
  def __init__(self):
@@ -352,10 +311,7 @@ class VectorStore:
352
  scored.sort(reverse=True)
353
  return [c.copy() for _, c in scored[:top_k]]
354
 
355
-
356
  vector_store = VectorStore()
357
- quarantine = QuarantineManager(Config.DATA_DIR / "quarantine")
358
-
359
 
360
  # =================== CHUNKING ===================
361
  class TextChunker:
@@ -377,36 +333,60 @@ class TextChunker:
377
  chunks.append(" ".join(current_chunk))
378
  return chunks
379
 
 
 
 
 
 
380
 
381
- # =================== RERANKER ===================
382
- class Reranker:
383
- def __init__(self):
384
- self.model = None
385
- try:
386
- self.model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2', device=Config.DEVICE)
387
- logger.info(f"Cross-Encoder charge sur {Config.DEVICE}")
388
- except Exception as e:
389
- logger.warning(f"Cross-Encoder desactive: {e}")
 
 
 
 
 
 
 
390
 
391
- async def rerank(self, query, chunks, top_k=5):
392
- if not chunks or self.model is None:
393
- return chunks[:top_k]
394
- pairs = [(query, c.get("content", "")) for c in chunks]
395
- try:
396
- loop = asyncio.get_running_loop()
397
- scores = await loop.run_in_executor(None, self.model.predict, pairs)
398
- for i, c in enumerate(chunks):
399
- c["rerank_score"] = float(scores[i])
400
- return sorted(chunks, key=lambda x: x.get("rerank_score", 0), reverse=True)[:top_k]
401
- except Exception as e:
402
- logger.error(f"Reranking echoue: {e}")
403
- return chunks[:top_k]
404
 
 
 
 
 
 
 
 
 
405
 
406
- reranker = Reranker()
 
 
 
 
 
407
 
 
408
 
409
- # =================== INFÉRENCE QWEN (1.5B, GPU) ===================
410
  class LocalInference:
411
  def __init__(self):
412
  self.model = None
@@ -421,7 +401,7 @@ class LocalInference:
421
  self.model = AutoModelForCausalLM.from_pretrained(
422
  Config.MODEL_NAME,
423
  token=Config.HF_TOKEN,
424
- dtype=torch.float16 if device == "cuda" else torch.float32,
425
  low_cpu_mem_usage=True,
426
  )
427
  self.model.to(device)
@@ -477,10 +457,9 @@ class LocalInference:
477
  parts.append("<|im_start|>assistant\n")
478
  return "".join(parts)
479
 
480
-
481
  inference = LocalInference()
482
 
483
- # =================== TOOLS ET AGENT ===================
484
  TOOLS = [
485
  {"name": "read_file", "description": "Read file content", "parameters": {"path": {"type": "string"}}},
486
  {"name": "write_file", "description": "Write file", "parameters": {"path": {"type": "string"}, "content": {"type": "string"}}},
@@ -488,15 +467,8 @@ TOOLS = [
488
  {"name": "run_python", "description": "Run Python code", "parameters": {"code": {"type": "string"}}}
489
  ]
490
 
491
-
492
  class AgentV103:
493
- # Nombre max de tentatives de "relance" quand le modele repond en texte
494
- # libre (confusion / refus / hallucination d'un outil manquant) au lieu
495
- # d'emettre un JSON d'appel d'outil valide, alors que la tache l'exige.
496
  MAX_NUDGES_PER_TASK = 2
497
-
498
- # Signaux textuels typiques d'un petit modele qui "raconte" au lieu
499
- # d'agir : hallucine un outil absent, s'excuse, ou explique sans agir.
500
  _CONFUSION_MARKERS = (
501
  "tool was not found", "tool not found", "no such tool",
502
  "i don't have", "i do not have", "unable to", "cannot find",
@@ -506,10 +478,6 @@ class AgentV103:
506
  def __init__(self, inference_client, tools):
507
  self.inference = inference_client
508
  self.tools = tools
509
- # Le system prompt inclut desormais un exemple few-shot complet
510
- # (tache -> appel d'outil -> resultat -> reponse finale). Un modele
511
- # causal de cette taille (1.5B) suit un exemple concret beaucoup
512
- # plus fiablement qu'une regle enoncee seule.
513
  self.system_prompt = """You are an expert development agent in ADN NEXUS IDE.
514
  You have access to these tools:
515
  - read_file(path) -> reads file content
@@ -548,9 +516,6 @@ Your next response, now that you have the output:
548
  return any(marker in lowered for marker in self._CONFUSION_MARKERS)
549
 
550
  def _score_candidate(self, tool_call: Optional[dict], result: Optional[dict], text: str) -> float:
551
- """Heuristique simple pour departager plusieurs candidats generes en
552
- parallele (mode raisonnement). Favorise un appel d'outil reussi et
553
- propre plutot qu'une reponse textuelle vague ou confuse."""
554
  score = 0.0
555
  if tool_call:
556
  score += 0.4
@@ -565,17 +530,9 @@ Your next response, now that you have the output:
565
  return score
566
 
567
  async def run_task_with_reasoning(self, session_id, task, websocket, max_candidates: Optional[int] = None):
568
- """Variante optionnelle de run_task : genere plusieurs candidats avec
569
- un vrai echantillonnage stochastique (do_sample=True, temperature)
570
- pour la toute premiere reponse de la tache, execute chacun, les
571
- note, et poursuit la boucle normale (deterministe, avec le meme
572
- few-shot + retry que run_task) a partir du meilleur. Diversifier
573
- uniquement la premiere etape borne le cout — diversifier chaque
574
- etape d'une boucle a 5 iterations multiplierait le temps de calcul
575
- sans gain proportionnel sur un modele de cette taille."""
576
  n = max_candidates or Config.SAMPLING_CANDIDATES
577
  base_messages = [{"role": "system", "content": self.system_prompt},
578
- {"role": "user", "content": task}]
579
  candidates = []
580
  for i in range(n):
581
  response = await self.inference.generate(
@@ -612,14 +569,9 @@ Your next response, now that you have the output:
612
  await websocket.send_json({"type": "complete", "result": best["response"]})
613
  return {"success": True, "result": best["response"], "best_score": best["score"]}
614
 
615
- # Poursuite de la tache avec la boucle standard (deterministe,
616
- # few-shot + retry deja valides) a partir de l'etat construit.
617
  return await self._continue_task(messages, websocket, start_iteration=1)
618
 
619
  def _estimate_tokens(self, messages: list) -> int:
620
- """Compte le nombre reel de tokens que produirait ces messages une
621
- fois formates pour Qwen, via le tokenizer deja charge — pas une
622
- approximation par nombre de caracteres."""
623
  tok = getattr(self.inference, "tokenizer", None)
624
  if tok is None:
625
  return 0
@@ -627,16 +579,9 @@ Your next response, now that you have the output:
627
  return len(tok(prompt)["input_ids"])
628
 
629
  def _compress_old_tool_results(self, messages: list) -> list:
630
- """Nettoyage cible deterministe : tronque le contenu des anciens
631
- messages de role 'tool' (garde les plus recents intacts) au lieu
632
- de les envoyer en entier. Ne touche jamais le system prompt ni le
633
- premier message utilisateur (la tache d'origine), et laisse
634
- toujours les 2 derniers echanges (assistant+tool) intacts pour que
635
- le modele garde le contexte immediat necessaire a la suite."""
636
- # Compte les indices des messages 'tool' du plus recent au plus ancien
637
  tool_indices = [i for i, m in enumerate(messages) if m.get("role") == "tool"]
638
  if len(tool_indices) <= 2:
639
- return messages # rien a compresser, peu d'historique
640
  keep_recent = set(tool_indices[-2:])
641
 
642
  compressed = []
@@ -654,9 +599,6 @@ Your next response, now that you have the output:
654
  return compressed
655
 
656
  def _enforce_context_budget(self, messages: list) -> list:
657
- """Applique le nettoyage cible si et seulement si l'historique
658
- depasse le seuil configure — evite de degrader inutilement une
659
- tache courte."""
660
  if self._estimate_tokens(messages) <= Config.MAX_CONTEXT_TOKENS:
661
  return messages
662
  compressed = self._compress_old_tool_results(messages)
@@ -739,121 +681,7 @@ Your next response, now that you have the output:
739
  result = {"success": False, "error": f"Tool {name} unknown"}
740
  return result
741
 
742
-
743
- # =================== SCRAPING ===================
744
- scrape_queue = asyncio.Queue(maxsize=Config.QUEUE_MAXSIZE)
745
- scrape_worker_running = True
746
- scrape_workers = []
747
-
748
-
749
- async def scrape_worker(worker_id):
750
- logger.info(f"Worker {worker_id} demarre")
751
- while scrape_worker_running:
752
- try:
753
- url_data = await asyncio.wait_for(scrape_queue.get(), timeout=5.0)
754
- url = url_data.get("url")
755
- logger.info(f"Worker {worker_id}: {url}")
756
- success, content, title = await _scrape_url(url)
757
- if success:
758
- await vector_store.add(content=content, metadata={"title": title, "url": url}, source_url=url)
759
- await db_pool.execute("UPDATE source_states SET last_scraped = ?, status = ? WHERE url = ?",
760
- (datetime.utcnow().isoformat(), "ok", url))
761
- logger.info(f"Worker {worker_id}: {url} -> OK")
762
- else:
763
- await db_pool.execute("UPDATE source_states SET status = ? WHERE url = ?", (f"error_{content}", url))
764
- logger.warning(f"Worker {worker_id}: echec {url}")
765
- scrape_queue.task_done()
766
- except asyncio.TimeoutError:
767
- continue
768
- except Exception as e:
769
- logger.error(f"Worker {worker_id} erreur: {e}")
770
- try:
771
- scrape_queue.task_done()
772
- except Exception:
773
- pass
774
- logger.info(f"Worker {worker_id} arrete")
775
-
776
-
777
- async def _scrape_url(url):
778
- headers = {"User-Agent": "Mozilla/5.0"}
779
- try:
780
- resp = requests.get(url, headers=headers, timeout=15)
781
- if resp.status_code != 200:
782
- return False, str(resp.status_code), "Error"
783
- soup = BeautifulSoup(resp.text, 'html.parser')
784
- title = soup.title.string if soup.title else "No title"
785
- for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
786
- tag.decompose()
787
- paragraphs = [p.get_text(strip=True) for p in soup.find_all('p') if len(p.get_text(strip=True)) > 20]
788
- content = "\n".join(paragraphs)
789
- if len(content) < 100:
790
- article = soup.find('article') or soup.find('main') or soup.body
791
- if article:
792
- content = article.get_text(separator='\n', strip=True)
793
- content = content[:Config.MAX_CONTENT_LENGTH]
794
- return True, content, title
795
- except Exception as e:
796
- return False, str(e), "Error"
797
-
798
-
799
  # =================== FILE MANAGER ===================
800
- class QuarantineManager:
801
- """Zone de quarantaine pour le contenu extrait avant indexation.
802
- Stockage fichier (JSON), pas de nouvelle base de donnees — coherent
803
- avec la contrainte CPU/pas-de-budget : aucune dependance ajoutee,
804
- aucun processus en arriere-plan (extraction a la demande uniquement,
805
- contrairement aux anciens workers de scraping continus)."""
806
-
807
- def __init__(self, quarantine_dir: Path):
808
- self.dir = quarantine_dir
809
- self.dir.mkdir(parents=True, exist_ok=True)
810
-
811
- def list_pending(self) -> List[Dict]:
812
- items = []
813
- for f in sorted(self.dir.glob("*.json")):
814
- try:
815
- data = json.loads(f.read_text(encoding="utf-8"))
816
- items.append({
817
- "id": f.stem,
818
- "source": data.get("source_url", "inconnue"),
819
- "title": data.get("title", ""),
820
- "summary": (data.get("content", "") or "")[:200],
821
- "timestamp": data.get("timestamp"),
822
- })
823
- except Exception:
824
- continue
825
- items.sort(key=lambda x: x.get("timestamp") or "", reverse=True)
826
- return items
827
-
828
- def add(self, content: str, source_url: str = "", title: str = "") -> str:
829
- doc_id = uuid.uuid4().hex[:8]
830
- data = {
831
- "id": doc_id,
832
- "content": content,
833
- "source_url": source_url,
834
- "title": title,
835
- "timestamp": datetime.utcnow().isoformat(),
836
- }
837
- (self.dir / f"{doc_id}.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
838
- return doc_id
839
-
840
- def get(self, doc_id: str) -> Optional[Dict]:
841
- f = self.dir / f"{doc_id}.json"
842
- if not f.exists():
843
- return None
844
- try:
845
- return json.loads(f.read_text(encoding="utf-8"))
846
- except Exception:
847
- return None
848
-
849
- def remove(self, doc_id: str) -> bool:
850
- f = self.dir / f"{doc_id}.json"
851
- if f.exists():
852
- f.unlink()
853
- return True
854
- return False
855
-
856
-
857
  class FileManager:
858
  ROOT = Config.WORKSPACE_ROOT
859
 
@@ -892,10 +720,8 @@ class FileManager:
892
  except Exception as e:
893
  return {"success": False, "error": str(e)}
894
 
895
-
896
  file_mgr = FileManager()
897
 
898
-
899
  # =================== SANDBOX ===================
900
  class Sandbox:
901
  async def run_python(self, code, timeout=30):
@@ -921,9 +747,6 @@ class Sandbox:
921
  Path(temp_path).unlink(missing_ok=True)
922
 
923
  async def run_command(self, args: list, timeout=30):
924
- """Execute une commande arbitraire (liste d'arguments, pas de shell=True)
925
- dans le workspace. Utilise par les actions rapides deterministes
926
- (Tests, Fix) qui n'ont pas besoin de passer par la decision du LLM."""
927
  try:
928
  proc = await asyncio.create_subprocess_exec(
929
  *args,
@@ -940,10 +763,8 @@ class Sandbox:
940
  except Exception as e:
941
  return {"success": False, "error": str(e), "stdout": "", "stderr": ""}
942
 
943
-
944
  sandbox = Sandbox()
945
 
946
-
947
  # =================== TELEMETRY ===================
948
  class RuntimeTelemetry:
949
  def __init__(self, workspace):
@@ -966,26 +787,29 @@ class RuntimeTelemetry:
966
  finally:
967
  Path(temp_path).unlink(missing_ok=True)
968
 
969
-
970
  telemetry = RuntimeTelemetry(Config.WORKSPACE_ROOT)
971
 
972
-
973
- # =================== API CONTRACT PARSER ===================
974
- class APIContractParser:
975
- @staticmethod
976
- def parse_openapi(path):
977
- content = Path(path).read_text()
978
- spec = yaml.safe_load(content) if path.suffix in ('.yaml', '.yml') else json.loads(content)
979
- endpoints = []
980
- for p, methods in spec.get('paths', {}).items():
981
- for m, d in methods.items():
982
- if m in ('get', 'post', 'put', 'patch', 'delete'):
983
- endpoints.append({"path": p, "method": m.upper(), "operation_id": d.get('operationId')})
984
- return {"title": spec.get('info', {}).get('title'), "version": spec.get('info', {}).get('version'), "endpoints": endpoints}
985
-
986
-
987
- contract_parser = APIContractParser()
988
-
 
 
 
 
989
 
990
  # =================== GIT ANALYZER ===================
991
  class GitGraphAnalyzer:
@@ -1007,7 +831,6 @@ class GitGraphAnalyzer:
1007
  except Exception:
1008
  return []
1009
 
1010
-
1011
  git_analyzer = None
1012
  try:
1013
  git_dir = Config.WORKSPACE_ROOT / '.git'
@@ -1019,8 +842,22 @@ try:
1019
  except Exception as e:
1020
  logger.warning(f"Git analyzer non disponible: {e}")
1021
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022
 
1023
- # =================== KNOWLEDGE GRAPH ===================
1024
  class KnowledgeGraphLoader:
1025
  def __init__(self, db_path="/tmp/kg.db"):
1026
  self.db_path = db_path
@@ -1042,10 +879,8 @@ class KnowledgeGraphLoader:
1042
  conn.close()
1043
  return [dict(r) for r in rows]
1044
 
1045
-
1046
  kg_loader = KnowledgeGraphLoader()
1047
 
1048
-
1049
  # =================== KNOWLEDGE UNIFIER ===================
1050
  class KnowledgeUnifier:
1051
  def __init__(self):
@@ -1064,13 +899,12 @@ class KnowledgeUnifier:
1064
  parts.append(f"- {v.get('name', '')} ({v.get('type', '')})")
1065
  return "\n".join(parts) if parts else "No context available"
1066
 
1067
-
1068
  unifier = KnowledgeUnifier()
1069
 
1070
  # =================== AGENT INSTANCIATION ===================
1071
  agent = AgentV103(inference, TOOLS)
1072
 
1073
- # =================== CLIENT HTML (login screen — pas de token en dur) ===================
1074
  IDE_HTML = """<!DOCTYPE html>
1075
  <html lang="fr">
1076
  <head>
@@ -1180,9 +1014,6 @@ document.addEventListener('DOMContentLoaded', () => {
1180
  });
1181
 
1182
  function connectWs(){
1183
- // Le token n'est plus jamais mis dans l'URL (query string) : il est
1184
- // envoye comme premier message JSON juste apres l'ouverture, pour ne
1185
- // laisser aucune trace du secret dans les logs d'acces du proxy.
1186
  const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws';
1187
  ws = new WebSocket(WS_URL);
1188
  let authenticated = false;
@@ -1266,10 +1097,6 @@ function sendTask(){
1266
  ws.send(JSON.stringify({action:'agent', task:t, use_reasoning: useReasoning}));
1267
  }
1268
  function clearChat(){ document.getElementById('chat').innerHTML = ''; }
1269
- // Les boutons rapides (Tests/Fix/Explain) sont des actions deterministes :
1270
- // on sait deja quoi executer, donc on ne passe pas par la boucle agentique
1271
- // (qui laisse le LLM decider quel outil appeler). Cela evite les esquives
1272
- // que peut produire un petit modele face a une decision d'outil.
1273
  function qTest(){ addMsg('u','Run all tests and report failures'); ws.send(JSON.stringify({action:'quick_test'})); }
1274
  function qFix(){ addMsg('u','Scan for bugs and fix them'); ws.send(JSON.stringify({action:'quick_fix'})); }
1275
  function qExp(){
@@ -1277,9 +1104,6 @@ function qExp(){
1277
  addMsg('u','Explain '+curFile);
1278
  ws.send(JSON.stringify({action:'quick_explain', path:curFile}));
1279
  }
1280
- // La zone de texte libre ("Describe what to build...") reste sur le
1281
- // pipeline agentique complet : c'est la seule ou un vrai raisonnement
1282
- // multi-etapes (choix d'outil inclus) est necessaire.
1283
  function sendDirect(t){ addMsg('u',t); ws.send(JSON.stringify({action:'agent', task:t, use_reasoning:false})); }
1284
  function runTelemetry(){
1285
  if (!curFile){ alert('No file open'); return; }
@@ -1304,6 +1128,36 @@ function log(msg, type='out'){
1304
  function accDiff(){ document.getElementById('diff').classList.remove('show'); }
1305
  function rejDiff(){ document.getElementById('diff').classList.remove('show'); }
1306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1307
  window.addEventListener('keydown', e => {
1308
  if (e.ctrlKey && e.key === 's') { e.preventDefault(); saveFile(); }
1309
  });
@@ -1320,86 +1174,56 @@ async def lifespan(app: FastAPI):
1320
  db_pool._init_db()
1321
  await db_pool.initialize()
1322
  await vector_store.load_from_db()
1323
- for i in range(Config.NUM_SCRAPE_WORKERS):
1324
- worker = asyncio.create_task(scrape_worker(i))
1325
- scrape_workers.append(worker)
1326
  logger.info("=" * 50)
1327
  logger.info("ADN NEXUS V10.3.2 Agentic IDE demarre")
1328
  logger.info(f"Workspace: {Config.WORKSPACE_ROOT}")
1329
- logger.info(f"Workers: {Config.NUM_SCRAPE_WORKERS}")
1330
  logger.info("=" * 50)
1331
  yield
1332
- logger.info("Arret en cours...")
1333
- global scrape_worker_running
1334
- scrape_worker_running = False
1335
- for w in scrape_workers:
1336
- w.cancel()
1337
- try:
1338
- await w
1339
- except asyncio.CancelledError:
1340
- pass
1341
  logger.info("Arret complet")
1342
 
1343
-
1344
  app = FastAPI(title="ADN NEXUS V10.3.2", version="10.3.2", lifespan=lifespan,
1345
  docs_url=None, redoc_url=None, openapi_url=None)
1346
- # credentials=False : l'auth se fait par token applicatif (Bearer / query param),
1347
- # pas par cookie de session — combiner allow_origins="*" avec allow_credentials=True
1348
- # n'a pas de sens ici et est deconseille par la spec CORS.
1349
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"])
1350
 
1351
-
1352
  # =================== ROUTES ===================
1353
  @app.get("/")
1354
  async def root():
1355
  return HTMLResponse(content=IDE_HTML)
1356
 
1357
-
1358
  @app.get("/health")
1359
  async def health():
1360
  return {"status": "ok", "version": "10.3.2", "gpu": "T4"}
1361
 
1362
-
1363
  @app.get("/workspace")
1364
  async def list_workspace(token: str = Depends(verify_any_token)):
1365
  return file_mgr.list_dir("")
1366
 
1367
-
1368
  @app.get("/file")
1369
  async def read_file(path: str, token: str = Depends(verify_any_token)):
1370
  return file_mgr.read(path)
1371
 
1372
-
1373
  @app.post("/file")
1374
  async def write_file(data: dict, token: str = Depends(verify_any_token)):
1375
  return file_mgr.write(data.get("path"), data.get("content"))
1376
 
1377
-
1378
  @app.post("/run")
1379
  async def run_code(data: dict, token: str = Depends(verify_any_token)):
1380
  return await sandbox.run_python(data.get("code", ""))
1381
 
1382
-
1383
  @app.post("/agent")
1384
  async def agent_task(data: dict, token: str = Depends(verify_any_token)):
1385
  session_id = str(uuid.uuid4())
1386
  result = await agent.run_task(session_id, data.get("task", ""), DummyWebSocket())
1387
  return result
1388
 
1389
-
1390
  class DummyWebSocket:
1391
  async def send_json(self, data):
1392
  logger.info(f"Agent: {data}")
1393
 
1394
-
1395
  # =================== WEBSOCKET AVEC AUTH ===================
1396
  @app.websocket("/ws")
1397
  async def websocket_endpoint(websocket: WebSocket):
1398
- # Authentification par PREMIER MESSAGE plutot que par query string.
1399
- # Le token en query param finit dans les logs d'acces du proxy (HF
1400
- # Spaces, reverse proxies) meme apres rotation — un risque residuel
1401
- # qu'on ferme ici. On accepte la connexion TCP/WS puis on exige un
1402
- # premier message JSON {"token": "..."} avant tout traitement.
1403
  client_host = websocket.client.host if websocket.client else "unknown"
1404
  if not await rate_limiter.is_allowed(f"ws-preauth:{client_host}"):
1405
  await websocket.close(code=1013, reason="Rate limit exceeded")
@@ -1451,11 +1275,6 @@ async def websocket_endpoint(websocket: WebSocket):
1451
  result = await agent.run_task(session_id, task, websocket)
1452
  await websocket.send_json({"type": "agent_result", "data": result})
1453
  elif action == "quick_test":
1454
- # Action deterministe : on sait deja quelle commande executer,
1455
- # inutile de laisser un petit modele "decider" d'appeler un
1456
- # outil — on l'execute directement et on ne sollicite le LLM
1457
- # que pour resumer un resultat deja connu (pas de JSON a
1458
- # produire, donc pas de risque d'esquive).
1459
  result = await sandbox.run_command([sys.executable, "-m", "pytest", "-q"])
1460
  summary = await inference.generate([
1461
  {"role": "system", "content": "You are a coding assistant. Summarize this pytest output concisely in plain text for a developer: how many passed/failed, and the key error if any. No JSON, no tool calls."},
@@ -1487,9 +1306,6 @@ async def websocket_endpoint(websocket: WebSocket):
1487
  ], max_tokens=400, do_sample=False)
1488
  await websocket.send_json({"type": "agent_result", "data": {"success": True, "result": summary}})
1489
  elif action == "extract_url":
1490
- # Extraction A LA DEMANDE (pas de worker en arriere-plan) :
1491
- # une seule requete HTTP, resultat mis en quarantaine — rien
1492
- # n'est indexe tant que l'utilisateur n'a pas approuve.
1493
  url = msg.get("url", "").strip()
1494
  if not url:
1495
  await websocket.send_json({"type": "quarantine_result", "data": {"success": False, "error": "URL manquante."}})
@@ -1553,7 +1369,6 @@ async def websocket_endpoint(websocket: WebSocket):
1553
  except Exception:
1554
  pass
1555
 
1556
-
1557
  # =================== LANCEMENT ===================
1558
  if __name__ == "__main__":
1559
  import uvicorn
 
1
+ # main.py — ADN NEXUS V10.3.2 AGENTIC IDE (SECURED WS + LOGIN SCREEN + FEW-SHOT AGENT)
2
+ # Version nettoyée : plus de workers de scraping permanents, extraction à la demande uniquement
3
+ # (Les branches git_log, api_contract, impact_analysis sont conservées car les objets sont définis)
4
+
 
5
  from __future__ import annotations
6
 
7
  import os
 
31
  from fastapi.middleware.cors import CORSMiddleware
32
  from fastapi.responses import HTMLResponse, JSONResponse
33
  import numpy as np
34
+ from sentence_transformers import SentenceTransformer
35
  import feedparser
36
  from bs4 import BeautifulSoup
37
 
 
42
  logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(message)s")
43
  logger = logging.getLogger("nexus_v103")
44
 
 
 
 
 
 
 
45
  _DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
46
  logger.info(f"Materiel detecte : {_DEVICE.upper()}" + (f" ({torch.cuda.get_device_name(0)})" if _DEVICE == "cuda" else " — pas de GPU disponible, bascule automatique sur CPU"))
47
 
 
48
  def _load_token(env_name: str) -> str:
 
 
 
49
  val = os.getenv(env_name)
50
  if val:
51
  return val
 
58
  )
59
  return generated
60
 
 
61
  class Config:
62
  PORT = int(os.getenv("PORT", 7860))
63
  HF_TOKEN = os.getenv("HF_TOKEN")
64
  DEVICE = _DEVICE
 
 
65
  API_TOKEN = _load_token("API_TOKEN")
66
+ LEGACY_TOKEN = os.getenv("LEGACY_TOKEN")
67
  MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-1.5B-Instruct")
68
  TIMEOUT = int(os.getenv("TIMEOUT", 180))
69
  WORKSPACE_ROOT = Path("/tmp/workspace")
 
73
  WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
74
  CACHE_TTL = 300
75
  QUEUE_MAXSIZE = 10000
 
76
  MAX_CONTENT_LENGTH = 5000
77
  CHUNK_SIZE = 1000
78
  CHUNK_OVERLAP = 200
 
80
  RATE_LIMIT_WINDOW = 60
81
  MAX_TOKENS = 512
82
  TEMPERATURE = 0.2
 
 
 
 
 
83
  SAMPLING_CANDIDATES = int(os.getenv("SAMPLING_CANDIDATES", 2))
84
  REASONING_TEMPERATURE = float(os.getenv("REASONING_TEMPERATURE", 0.7))
 
 
 
 
 
 
85
  MAX_CONTEXT_TOKENS = int(os.getenv("MAX_CONTEXT_TOKENS", 1400))
86
  TOOL_RESULT_KEEP_CHARS = int(os.getenv("TOOL_RESULT_KEEP_CHARS", 300))
87
 
 
88
  def _valid_tokens() -> tuple:
89
  toks = [Config.API_TOKEN]
90
  if Config.LEGACY_TOKEN:
91
  toks.append(Config.LEGACY_TOKEN)
92
  return tuple(toks)
93
 
 
94
  # =================== RATE LIMITER ===================
95
  class RateLimiter:
96
  def __init__(self, max_requests: int, window: int):
 
110
  self.requests[key].append(now)
111
  return True
112
 
 
113
  rate_limiter = RateLimiter(Config.RATE_LIMIT_REQUESTS, Config.RATE_LIMIT_WINDOW)
114
 
 
115
  # =================== BASE DE DONNEES (POOL ASYNC) ===================
116
  @dataclass
117
  class DBConnectionPool:
 
156
  def _init_db(self):
157
  conn = sqlite3.connect(self.db_path)
158
  c = conn.cursor()
 
 
 
 
159
  c.execute("PRAGMA journal_mode=WAL;")
160
  c.execute("PRAGMA synchronous=NORMAL;")
161
  c.executescript('''
 
179
  prompt TEXT, answer TEXT, status TEXT, feedback TEXT,
180
  chunks_used TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP
181
  );
 
 
 
 
182
  CREATE TABLE IF NOT EXISTS task_tracker (
183
  task_id TEXT PRIMARY KEY, task_type TEXT, payload TEXT,
184
  status TEXT DEFAULT 'pending', created_at TEXT DEFAULT CURRENT_TIMESTAMP,
 
219
  conn.close()
220
  logger.info(f"DB initialisee: {self.db_path}")
221
 
 
222
  db_pool = DBConnectionPool(Config.DB_PATH)
223
 
 
224
  # =================== AUTH (REST) ===================
225
  async def verify_any_token(authorization: str = Header(...)):
226
  if not authorization.startswith("Bearer "):
 
232
  raise HTTPException(429, detail="Rate limit exceeded")
233
  return token
234
 
 
235
  # =================== VECTOR STORE ===================
236
  class VectorStore:
237
  def __init__(self):
 
311
  scored.sort(reverse=True)
312
  return [c.copy() for _, c in scored[:top_k]]
313
 
 
314
  vector_store = VectorStore()
 
 
315
 
316
  # =================== CHUNKING ===================
317
  class TextChunker:
 
333
  chunks.append(" ".join(current_chunk))
334
  return chunks
335
 
336
+ # =================== QUARANTINE MANAGER ===================
337
+ class QuarantineManager:
338
+ def __init__(self, quarantine_dir: Path):
339
+ self.dir = quarantine_dir
340
+ self.dir.mkdir(parents=True, exist_ok=True)
341
 
342
+ def list_pending(self) -> List[Dict]:
343
+ items = []
344
+ for f in sorted(self.dir.glob("*.json")):
345
+ try:
346
+ data = json.loads(f.read_text(encoding="utf-8"))
347
+ items.append({
348
+ "id": f.stem,
349
+ "source": data.get("source_url", "inconnue"),
350
+ "title": data.get("title", ""),
351
+ "summary": (data.get("content", "") or "")[:200],
352
+ "timestamp": data.get("timestamp"),
353
+ })
354
+ except Exception:
355
+ continue
356
+ items.sort(key=lambda x: x.get("timestamp") or "", reverse=True)
357
+ return items
358
 
359
+ def add(self, content: str, source_url: str = "", title: str = "") -> str:
360
+ doc_id = uuid.uuid4().hex[:8]
361
+ data = {
362
+ "id": doc_id,
363
+ "content": content,
364
+ "source_url": source_url,
365
+ "title": title,
366
+ "timestamp": datetime.utcnow().isoformat(),
367
+ }
368
+ (self.dir / f"{doc_id}.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
369
+ return doc_id
 
 
370
 
371
+ def get(self, doc_id: str) -> Optional[Dict]:
372
+ f = self.dir / f"{doc_id}.json"
373
+ if not f.exists():
374
+ return None
375
+ try:
376
+ return json.loads(f.read_text(encoding="utf-8"))
377
+ except Exception:
378
+ return None
379
 
380
+ def remove(self, doc_id: str) -> bool:
381
+ f = self.dir / f"{doc_id}.json"
382
+ if f.exists():
383
+ f.unlink()
384
+ return True
385
+ return False
386
 
387
+ quarantine = QuarantineManager(Config.DATA_DIR / "quarantine")
388
 
389
+ # =================== INFÉRENCE QWEN ===================
390
  class LocalInference:
391
  def __init__(self):
392
  self.model = None
 
401
  self.model = AutoModelForCausalLM.from_pretrained(
402
  Config.MODEL_NAME,
403
  token=Config.HF_TOKEN,
404
+ torch_dtype=torch.float16 if device == "cuda" else torch.float32,
405
  low_cpu_mem_usage=True,
406
  )
407
  self.model.to(device)
 
457
  parts.append("<|im_start|>assistant\n")
458
  return "".join(parts)
459
 
 
460
  inference = LocalInference()
461
 
462
+ # =================== AGENT ===================
463
  TOOLS = [
464
  {"name": "read_file", "description": "Read file content", "parameters": {"path": {"type": "string"}}},
465
  {"name": "write_file", "description": "Write file", "parameters": {"path": {"type": "string"}, "content": {"type": "string"}}},
 
467
  {"name": "run_python", "description": "Run Python code", "parameters": {"code": {"type": "string"}}}
468
  ]
469
 
 
470
  class AgentV103:
 
 
 
471
  MAX_NUDGES_PER_TASK = 2
 
 
 
472
  _CONFUSION_MARKERS = (
473
  "tool was not found", "tool not found", "no such tool",
474
  "i don't have", "i do not have", "unable to", "cannot find",
 
478
  def __init__(self, inference_client, tools):
479
  self.inference = inference_client
480
  self.tools = tools
 
 
 
 
481
  self.system_prompt = """You are an expert development agent in ADN NEXUS IDE.
482
  You have access to these tools:
483
  - read_file(path) -> reads file content
 
516
  return any(marker in lowered for marker in self._CONFUSION_MARKERS)
517
 
518
  def _score_candidate(self, tool_call: Optional[dict], result: Optional[dict], text: str) -> float:
 
 
 
519
  score = 0.0
520
  if tool_call:
521
  score += 0.4
 
530
  return score
531
 
532
  async def run_task_with_reasoning(self, session_id, task, websocket, max_candidates: Optional[int] = None):
 
 
 
 
 
 
 
 
533
  n = max_candidates or Config.SAMPLING_CANDIDATES
534
  base_messages = [{"role": "system", "content": self.system_prompt},
535
+ {"role": "user", "content": task}]
536
  candidates = []
537
  for i in range(n):
538
  response = await self.inference.generate(
 
569
  await websocket.send_json({"type": "complete", "result": best["response"]})
570
  return {"success": True, "result": best["response"], "best_score": best["score"]}
571
 
 
 
572
  return await self._continue_task(messages, websocket, start_iteration=1)
573
 
574
  def _estimate_tokens(self, messages: list) -> int:
 
 
 
575
  tok = getattr(self.inference, "tokenizer", None)
576
  if tok is None:
577
  return 0
 
579
  return len(tok(prompt)["input_ids"])
580
 
581
  def _compress_old_tool_results(self, messages: list) -> list:
 
 
 
 
 
 
 
582
  tool_indices = [i for i, m in enumerate(messages) if m.get("role") == "tool"]
583
  if len(tool_indices) <= 2:
584
+ return messages
585
  keep_recent = set(tool_indices[-2:])
586
 
587
  compressed = []
 
599
  return compressed
600
 
601
  def _enforce_context_budget(self, messages: list) -> list:
 
 
 
602
  if self._estimate_tokens(messages) <= Config.MAX_CONTEXT_TOKENS:
603
  return messages
604
  compressed = self._compress_old_tool_results(messages)
 
681
  result = {"success": False, "error": f"Tool {name} unknown"}
682
  return result
683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  # =================== FILE MANAGER ===================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  class FileManager:
686
  ROOT = Config.WORKSPACE_ROOT
687
 
 
720
  except Exception as e:
721
  return {"success": False, "error": str(e)}
722
 
 
723
  file_mgr = FileManager()
724
 
 
725
  # =================== SANDBOX ===================
726
  class Sandbox:
727
  async def run_python(self, code, timeout=30):
 
747
  Path(temp_path).unlink(missing_ok=True)
748
 
749
  async def run_command(self, args: list, timeout=30):
 
 
 
750
  try:
751
  proc = await asyncio.create_subprocess_exec(
752
  *args,
 
763
  except Exception as e:
764
  return {"success": False, "error": str(e), "stdout": "", "stderr": ""}
765
 
 
766
  sandbox = Sandbox()
767
 
 
768
  # =================== TELEMETRY ===================
769
  class RuntimeTelemetry:
770
  def __init__(self, workspace):
 
787
  finally:
788
  Path(temp_path).unlink(missing_ok=True)
789
 
 
790
  telemetry = RuntimeTelemetry(Config.WORKSPACE_ROOT)
791
 
792
+ # =================== SCRAPING (extraction à la demande uniquement) ===================
793
+ async def _scrape_url(url):
794
+ headers = {"User-Agent": "Mozilla/5.0"}
795
+ try:
796
+ resp = requests.get(url, headers=headers, timeout=15)
797
+ if resp.status_code != 200:
798
+ return False, str(resp.status_code), "Error"
799
+ soup = BeautifulSoup(resp.text, 'html.parser')
800
+ title = soup.title.string if soup.title else "No title"
801
+ for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
802
+ tag.decompose()
803
+ paragraphs = [p.get_text(strip=True) for p in soup.find_all('p') if len(p.get_text(strip=True)) > 20]
804
+ content = "\n".join(paragraphs)
805
+ if len(content) < 100:
806
+ article = soup.find('article') or soup.find('main') or soup.body
807
+ if article:
808
+ content = article.get_text(separator='\n', strip=True)
809
+ content = content[:Config.MAX_CONTENT_LENGTH]
810
+ return True, content, title
811
+ except Exception as e:
812
+ return False, str(e), "Error"
813
 
814
  # =================== GIT ANALYZER ===================
815
  class GitGraphAnalyzer:
 
831
  except Exception:
832
  return []
833
 
 
834
  git_analyzer = None
835
  try:
836
  git_dir = Config.WORKSPACE_ROOT / '.git'
 
842
  except Exception as e:
843
  logger.warning(f"Git analyzer non disponible: {e}")
844
 
845
+ # =================== API CONTRACT PARSER ===================
846
+ class APIContractParser:
847
+ @staticmethod
848
+ def parse_openapi(path):
849
+ content = Path(path).read_text()
850
+ spec = yaml.safe_load(content) if path.suffix in ('.yaml', '.yml') else json.loads(content)
851
+ endpoints = []
852
+ for p, methods in spec.get('paths', {}).items():
853
+ for m, d in methods.items():
854
+ if m in ('get', 'post', 'put', 'patch', 'delete'):
855
+ endpoints.append({"path": p, "method": m.upper(), "operation_id": d.get('operationId')})
856
+ return {"title": spec.get('info', {}).get('title'), "version": spec.get('info', {}).get('version'), "endpoints": endpoints}
857
+
858
+ contract_parser = APIContractParser()
859
 
860
+ # =================== KNOWLEDGE GRAPH LOADER ===================
861
  class KnowledgeGraphLoader:
862
  def __init__(self, db_path="/tmp/kg.db"):
863
  self.db_path = db_path
 
879
  conn.close()
880
  return [dict(r) for r in rows]
881
 
 
882
  kg_loader = KnowledgeGraphLoader()
883
 
 
884
  # =================== KNOWLEDGE UNIFIER ===================
885
  class KnowledgeUnifier:
886
  def __init__(self):
 
899
  parts.append(f"- {v.get('name', '')} ({v.get('type', '')})")
900
  return "\n".join(parts) if parts else "No context available"
901
 
 
902
  unifier = KnowledgeUnifier()
903
 
904
  # =================== AGENT INSTANCIATION ===================
905
  agent = AgentV103(inference, TOOLS)
906
 
907
+ # =================== CLIENT HTML ===================
908
  IDE_HTML = """<!DOCTYPE html>
909
  <html lang="fr">
910
  <head>
 
1014
  });
1015
 
1016
  function connectWs(){
 
 
 
1017
  const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws';
1018
  ws = new WebSocket(WS_URL);
1019
  let authenticated = false;
 
1097
  ws.send(JSON.stringify({action:'agent', task:t, use_reasoning: useReasoning}));
1098
  }
1099
  function clearChat(){ document.getElementById('chat').innerHTML = ''; }
 
 
 
 
1100
  function qTest(){ addMsg('u','Run all tests and report failures'); ws.send(JSON.stringify({action:'quick_test'})); }
1101
  function qFix(){ addMsg('u','Scan for bugs and fix them'); ws.send(JSON.stringify({action:'quick_fix'})); }
1102
  function qExp(){
 
1104
  addMsg('u','Explain '+curFile);
1105
  ws.send(JSON.stringify({action:'quick_explain', path:curFile}));
1106
  }
 
 
 
1107
  function sendDirect(t){ addMsg('u',t); ws.send(JSON.stringify({action:'agent', task:t, use_reasoning:false})); }
1108
  function runTelemetry(){
1109
  if (!curFile){ alert('No file open'); return; }
 
1128
  function accDiff(){ document.getElementById('diff').classList.remove('show'); }
1129
  function rejDiff(){ document.getElementById('diff').classList.remove('show'); }
1130
 
1131
+ function extractUrl(){
1132
+ const url = document.getElementById('qurl').value.trim();
1133
+ if(!url){ alert('Entrez une URL valide.'); return; }
1134
+ ws.send(JSON.stringify({action:'extract_url', url}));
1135
+ }
1136
+ function renderQuarantine(data){
1137
+ const container = document.getElementById('qlist');
1138
+ if(!data || !data.length){
1139
+ container.innerHTML = '<div style="color:var(--text2);font-size:11px;">Aucun document en attente.</div>';
1140
+ return;
1141
+ }
1142
+ let html = '';
1143
+ data.forEach(item => {
1144
+ html += `<div style="background:var(--bg2);padding:6px 8px;border-radius:4px;font-size:11px;display:flex;justify-content:space-between;align-items:center;border-left:2px solid var(--yellow);">
1145
+ <div><strong style="color:var(--accent)">${item.id}</strong> — ${item.title||'Sans titre'} (${item.source||'source inconnue'})</div>
1146
+ <div>
1147
+ <button style="background:var(--green);border:none;color:#000;padding:2px 6px;border-radius:4px;cursor:pointer;font-size:10px;" onclick="approveDoc('${item.id}')">Approuver</button>
1148
+ <button style="background:var(--red);border:none;color:#fff;padding:2px 6px;border-radius:4px;cursor:pointer;font-size:10px;" onclick="rejectDoc('${item.id}')">Rejeter</button>
1149
+ </div>
1150
+ </div>`;
1151
+ });
1152
+ container.innerHTML = html;
1153
+ }
1154
+ function approveDoc(id){
1155
+ ws.send(JSON.stringify({action:'quarantine_approve', id}));
1156
+ }
1157
+ function rejectDoc(id){
1158
+ ws.send(JSON.stringify({action:'quarantine_reject', id}));
1159
+ }
1160
+
1161
  window.addEventListener('keydown', e => {
1162
  if (e.ctrlKey && e.key === 's') { e.preventDefault(); saveFile(); }
1163
  });
 
1174
  db_pool._init_db()
1175
  await db_pool.initialize()
1176
  await vector_store.load_from_db()
 
 
 
1177
  logger.info("=" * 50)
1178
  logger.info("ADN NEXUS V10.3.2 Agentic IDE demarre")
1179
  logger.info(f"Workspace: {Config.WORKSPACE_ROOT}")
1180
+ logger.info(f"Extraction web : a la demande uniquement (aucun worker permanent)")
1181
  logger.info("=" * 50)
1182
  yield
 
 
 
 
 
 
 
 
 
1183
  logger.info("Arret complet")
1184
 
 
1185
  app = FastAPI(title="ADN NEXUS V10.3.2", version="10.3.2", lifespan=lifespan,
1186
  docs_url=None, redoc_url=None, openapi_url=None)
 
 
 
1187
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"])
1188
 
 
1189
  # =================== ROUTES ===================
1190
  @app.get("/")
1191
  async def root():
1192
  return HTMLResponse(content=IDE_HTML)
1193
 
 
1194
  @app.get("/health")
1195
  async def health():
1196
  return {"status": "ok", "version": "10.3.2", "gpu": "T4"}
1197
 
 
1198
  @app.get("/workspace")
1199
  async def list_workspace(token: str = Depends(verify_any_token)):
1200
  return file_mgr.list_dir("")
1201
 
 
1202
  @app.get("/file")
1203
  async def read_file(path: str, token: str = Depends(verify_any_token)):
1204
  return file_mgr.read(path)
1205
 
 
1206
  @app.post("/file")
1207
  async def write_file(data: dict, token: str = Depends(verify_any_token)):
1208
  return file_mgr.write(data.get("path"), data.get("content"))
1209
 
 
1210
  @app.post("/run")
1211
  async def run_code(data: dict, token: str = Depends(verify_any_token)):
1212
  return await sandbox.run_python(data.get("code", ""))
1213
 
 
1214
  @app.post("/agent")
1215
  async def agent_task(data: dict, token: str = Depends(verify_any_token)):
1216
  session_id = str(uuid.uuid4())
1217
  result = await agent.run_task(session_id, data.get("task", ""), DummyWebSocket())
1218
  return result
1219
 
 
1220
  class DummyWebSocket:
1221
  async def send_json(self, data):
1222
  logger.info(f"Agent: {data}")
1223
 
 
1224
  # =================== WEBSOCKET AVEC AUTH ===================
1225
  @app.websocket("/ws")
1226
  async def websocket_endpoint(websocket: WebSocket):
 
 
 
 
 
1227
  client_host = websocket.client.host if websocket.client else "unknown"
1228
  if not await rate_limiter.is_allowed(f"ws-preauth:{client_host}"):
1229
  await websocket.close(code=1013, reason="Rate limit exceeded")
 
1275
  result = await agent.run_task(session_id, task, websocket)
1276
  await websocket.send_json({"type": "agent_result", "data": result})
1277
  elif action == "quick_test":
 
 
 
 
 
1278
  result = await sandbox.run_command([sys.executable, "-m", "pytest", "-q"])
1279
  summary = await inference.generate([
1280
  {"role": "system", "content": "You are a coding assistant. Summarize this pytest output concisely in plain text for a developer: how many passed/failed, and the key error if any. No JSON, no tool calls."},
 
1306
  ], max_tokens=400, do_sample=False)
1307
  await websocket.send_json({"type": "agent_result", "data": {"success": True, "result": summary}})
1308
  elif action == "extract_url":
 
 
 
1309
  url = msg.get("url", "").strip()
1310
  if not url:
1311
  await websocket.send_json({"type": "quarantine_result", "data": {"success": False, "error": "URL manquante."}})
 
1369
  except Exception:
1370
  pass
1371
 
 
1372
  # =================== LANCEMENT ===================
1373
  if __name__ == "__main__":
1374
  import uvicorn