antonypamo commited on
Commit
4f990cf
·
verified ·
1 Parent(s): 11c4398

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +342 -352
main.py CHANGED
@@ -1,114 +1,61 @@
1
  import os
2
- import time
3
- import logging
4
  from typing import Optional, Dict, Any, List
5
 
6
  import numpy as np
7
  from numpy.linalg import norm
8
  from scipy.linalg import expm
 
 
 
 
9
  from sentence_transformers import SentenceTransformer
10
  from huggingface_hub import hf_hub_download
 
11
  import joblib
12
 
13
- from fastapi import FastAPI, Depends, Header, HTTPException, status, Request
14
- from fastapi.responses import JSONResponse
15
- from pydantic import BaseModel
16
-
17
-
18
- # ============================================================
19
- # 0. LOGGING BÁSICO
20
- # ============================================================
21
-
22
- logging.basicConfig(
23
- level=logging.INFO,
24
- format="%(asctime)s [%(levelname)s] %(message)s",
25
- )
26
- logger = logging.getLogger("savant-api")
27
-
28
-
29
- # ============================================================
30
- # 1. CONFIGURACIÓN Y API KEYS
31
- # ============================================================
32
 
33
- # HF token
34
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
35
- os.environ["HF_TOKEN"] = HF_TOKEN
36
-
37
- # API keys (muy simple para MVP)
38
- # - SAVANT_API_KEY: una sola API key
39
- # - SAVANT_API_KEYS: lista separada por comas ("key1,key2,...")
40
- single_key = os.environ.get("SAVANT_API_KEY", "").strip()
41
- multi_keys = os.environ.get("SAVANT_API_KEYS", "")
42
- allowed_keys = set(k.strip() for k in multi_keys.split(",") if k.strip())
43
- if single_key:
44
- allowed_keys.add(single_key)
45
-
46
- if not allowed_keys:
47
- logger.warning("⚠️ No hay API keys configuradas. La API aceptará TODO tráfico (MODO ABIERTO).")
48
- else:
49
- logger.info(f"🔐 API Keys configuradas: {len(allowed_keys)}")
50
-
51
-
52
- def api_key_dependency(
53
- x_api_key: Optional[str] = Header(default=None, alias="x-api-key"),
54
- authorization: Optional[str] = Header(default=None),
55
- ):
56
- """
57
- Dependencia FastAPI para proteger endpoints con API key.
58
- Acepta:
59
- - Header: x-api-key: <KEY>
60
- - Header: Authorization: Bearer <KEY>
61
- """
62
- if not allowed_keys:
63
- # Modo abierto: no validamos nada (útil para testing / dev).
64
- return
65
-
66
- candidate = None
67
- if x_api_key:
68
- candidate = x_api_key.strip()
69
- elif authorization and authorization.lower().startswith("bearer "):
70
- candidate = authorization.split(" ", 1)[1].strip()
71
-
72
- if not candidate or candidate not in allowed_keys:
73
- raise HTTPException(
74
- status_code=status.HTTP_401_UNAUTHORIZED,
75
- detail="Invalid or missing API key",
76
- )
77
 
78
-
79
- # ============================================================
80
- # 2. CARGA DE MODELOS (ENCODER + META-LOGIT)
81
- # ============================================================
82
-
83
- ENCODER_MODEL_ID = "antonypamo/RRFSAVANTMADE"
84
- META_LOGIT_REPO = "antonypamo/RRFSavantMetaLogit"
85
- META_LOGIT_FILENAME = "logreg_rrf_savant_15.joblib" # versión 15-features
86
-
87
- logger.info("===== Application Startup =====")
88
- logger.info("🔄 [Startup] Cargando encoder RRFSAVANTMADE...")
89
-
90
- encoder = SentenceTransformer(ENCODER_MODEL_ID)
91
-
92
- logger.info("✅ [Startup] Encoder cargado.")
93
- logger.info("🔄 [Startup] Descargando meta-logit desde HF Hub...")
94
-
95
- meta_logit_path = hf_hub_download(
96
- repo_id=META_LOGIT_REPO,
97
- filename=META_LOGIT_FILENAME,
98
- token=HF_TOKEN if HF_TOKEN else None,
99
- )
100
-
101
- logger.info(f"🔄 [Startup] Cargando modelo meta-logit '{META_LOGIT_FILENAME}'...")
102
- meta_logit = joblib.load(meta_logit_path)
103
-
104
- n_features_expected = getattr(meta_logit, "n_features_in_", None)
105
- logger.info(f"🔎 [Startup] Meta-logit espera {n_features_expected} features.")
106
- logger.info(" [Startup] Meta-logit cargado.")
 
107
 
108
 
109
- # ============================================================
110
- # 3. GEOMETRÍA ICOSAÉDRICA RRF
111
- # ============================================================
112
 
113
  phi = (1 + np.sqrt(5)) / 2
114
  nodes = np.array([
@@ -138,11 +85,11 @@ def geodesic_kernel(nodes, sigma=0.618, alpha_log=0.10):
138
  diff = nodes[:, None, :] - nodes[None, :, :]
139
  dist = norm(diff, axis=-1)
140
 
141
- W = np.exp(-(dist**2) / (sigma**2))
142
  np.fill_diagonal(W, 0.0)
143
 
144
  if alpha_log > 0.0:
145
- corr = 1.0 + alpha_log * np.log1p(dist**2)
146
  corr[range(N), range(N)] = 1.0
147
  W = W / corr
148
 
@@ -171,7 +118,8 @@ def build_dirac_hamiltonian(
171
  W = geodesic_kernel(nodes, sigma=sigma, alpha_log=alpha_log)
172
 
173
  if gauge_scale != 0.0 and any(flux_vector):
174
- theta = u1_edge_phases(nodes, flux_vector=flux_vector, q=q, gauge_scale=gauge_scale)
 
175
  U = np.exp(1j * theta)
176
  else:
177
  U = np.ones((N, N), dtype=complex)
@@ -200,7 +148,7 @@ def site_probs(psi):
200
  N2 = psi.shape[0]
201
  n = N2 // 2
202
  psi_mat = psi.reshape(n, 2)
203
- return np.sum(np.abs(psi_mat)**2, axis=1).real
204
 
205
 
206
  def chirality(psi):
@@ -217,7 +165,7 @@ def spatial_entropy(p):
217
  return float(-np.sum(p * np.log(p)).real)
218
 
219
 
220
- def evolve_dirac_shell(psi0, H, dt=0.05, steps=200, record_every=20):
221
  U = expm(-1j * dt * H)
222
  psi = psi0.copy()
223
 
@@ -247,9 +195,9 @@ def evolve_dirac_shell(psi0, H, dt=0.05, steps=200, record_every=20):
247
  }
248
 
249
 
250
- # ============================================================
251
- # 4. FEATURES RRF + META-LOGIT (QUALITY)
252
- # ============================================================
253
 
254
  def get_embedding(text: str) -> np.ndarray:
255
  emb = encoder.encode([text], convert_to_numpy=True, normalize_embeddings=True)
@@ -257,14 +205,16 @@ def get_embedding(text: str) -> np.ndarray:
257
 
258
 
259
  def compute_rrf_features(prompt: str, answer: str) -> Dict[str, float]:
 
260
  e_p = get_embedding(prompt)
261
  e_a = get_embedding(answer)
262
 
263
  cosine_pa = float(np.dot(e_p, e_a))
264
  len_ratio = len(answer) / (len(prompt) + 1.0)
265
 
266
- rng = np.random.default_rng(abs(hash(prompt + answer)) % (2**32))
267
- vec = rng.normal(0, 1, (2*N,)) + 1j * rng.normal(0, 1, (2*N,))
 
268
  vec /= np.sqrt(np.vdot(vec, vec))
269
  psi0 = vec
270
 
@@ -272,23 +222,23 @@ def compute_rrf_features(prompt: str, answer: str) -> Dict[str, float]:
272
  m=0.25, v=1.0, sigma=0.618,
273
  alpha_log=0.10, q=1.0,
274
  flux_vector=(0.0, 0.0, 0.0),
275
- gauge_scale=0.0
276
  )
277
 
278
- out = evolve_dirac_shell(psi0, H, dt=0.05, steps=200, record_every=20)
279
 
 
280
  energy = out["energy"]
281
  chir = out["chirality"]
282
- entropy = out["entropy"]
283
 
284
- S_initial = float(entropy[0])
285
  S_final = float(entropy[-1])
 
286
  S_delta = S_final - S_initial
287
  C_final = float(chir[-1])
288
  E_mean = float(np.mean(energy))
289
  E_std = float(np.std(energy))
290
 
291
- return {
292
  "cosine_pa": cosine_pa,
293
  "len_ratio": len_ratio,
294
  "dirac_entropy_final": S_final,
@@ -298,12 +248,22 @@ def compute_rrf_features(prompt: str, answer: str) -> Dict[str, float]:
298
  "dirac_energy_std": E_std,
299
  }
300
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
- def features_to_vector(feats: dict, meta_logit_model) -> np.ndarray:
303
- """
304
- Adapta las features RRF al nº de features que espera el meta-logit.
305
- """
306
- base_keys = [
307
  "cosine_pa",
308
  "len_ratio",
309
  "dirac_entropy_final",
@@ -311,33 +271,21 @@ def features_to_vector(feats: dict, meta_logit_model) -> np.ndarray:
311
  "dirac_chirality_final",
312
  "dirac_energy_mean",
313
  "dirac_energy_std",
 
 
 
 
 
 
 
 
314
  ]
315
- x_base = np.array([feats[k] for k in base_keys], dtype=float)
316
-
317
- n_expected = getattr(meta_logit_model, "n_features_in_", x_base.shape[0])
318
 
319
- if n_expected == x_base.shape[0]:
320
- return x_base
321
 
322
- x_full = np.zeros((n_expected,), dtype=float)
323
-
324
- if hasattr(meta_logit_model, "feature_names_in_"):
325
- feature_names = list(meta_logit_model.feature_names_in_)
326
- for i, name in enumerate(feature_names):
327
- if name in feats:
328
- x_full[i] = float(feats[name])
329
- else:
330
- x_full[i] = 0.0
331
- else:
332
- n_copy = min(n_expected, x_base.shape[0])
333
- x_full[:n_copy] = x_base[:n_copy]
334
-
335
- return x_full
336
-
337
-
338
- def compute_scores_srff_crrf_ephi(prompt: str, answer: str):
339
  feats = compute_rrf_features(prompt, answer)
340
- x = features_to_vector(feats, meta_logit).reshape(1, -1)
341
 
342
  proba = meta_logit.predict_proba(x)[0]
343
  p_good = float(proba[1])
@@ -345,9 +293,8 @@ def compute_scores_srff_crrf_ephi(prompt: str, answer: str):
345
  SRRF = p_good
346
  CRRF = p_good * feats["cosine_pa"]
347
 
348
- S_final = feats["dirac_entropy_final"]
349
- S_max = np.log(N)
350
- norm_entropy = float(S_final / S_max)
351
  E_phi = 0.5 * (SRRF + norm_entropy)
352
 
353
  scores = {
@@ -360,7 +307,99 @@ def compute_scores_srff_crrf_ephi(prompt: str, answer: str):
360
 
361
 
362
  # ============================
363
- # FastAPI app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  # ============================
365
 
366
  class EvaluateRequest(BaseModel):
@@ -368,41 +407,43 @@ class EvaluateRequest(BaseModel):
368
  answer: str
369
  model_label: Optional[str] = None
370
 
 
 
 
371
 
372
  class EvaluateResponse(BaseModel):
373
  scores: Dict[str, float]
374
  features: Dict[str, float]
375
  sim_summary: Dict[str, Any]
 
376
 
377
 
378
- # Para poder reutilizar EvaluateRequest en /quality_remote
379
  class QualityRemoteRequest(EvaluateRequest):
380
- """Mismo schema que EvaluateRequest, usado para el alias /quality_remote."""
381
  pass
382
 
383
 
384
- app = FastAPI(
385
- title="Savant RRF Φ12.0 API",
386
- description="Dirac-Resonant conceptual quality layer for LLM-generated text.",
387
- version="1.0.0",
388
- )
 
 
389
 
390
 
391
  class RerankRequest(BaseModel):
392
- """
393
- Petición para /v1/rerank
394
- """
395
  query: str = Field(..., description="Query de búsqueda o pregunta del usuario.")
396
  documents: List[str] = Field(..., description="Lista de documentos candidatos a rerankear.")
397
  alpha: float = Field(
398
  0.2,
399
- description="Peso de la corrección log_rdf en el score_final. 0 = solo cosine, 1 = solo log_rdf."
400
  )
401
  query_embedding_norm: bool = Field(
402
  True,
403
- description="Si True, normaliza el embedding de query (útil para cosine)."
404
  )
405
 
 
406
  class RerankDocumentResult(BaseModel):
407
  id: int = Field(..., description="Índice del documento en la lista de entrada.")
408
  score_cosine: float
@@ -410,6 +451,7 @@ class RerankDocumentResult(BaseModel):
410
  score_final: float
411
  rank: int
412
 
 
413
  class RerankResponse(BaseModel):
414
  model_id: str
415
  alpha: float
@@ -417,14 +459,41 @@ class RerankResponse(BaseModel):
417
  results: List[RerankDocumentResult]
418
 
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  def _compute_rerank_scores(query: str, docs: List[str], alpha: float, norm_query: bool) -> List[RerankDocumentResult]:
421
- """
422
- Lógica base de reranking usando encoder RRF.
423
- - score_cosine: similitud coseno query-doc
424
- - score_log_rdf: pequeña corrección logarítmica basada en score_cosine
425
- - score_final: mezcla convexa de ambos
426
- """
427
- # Embedding de query
428
  q_emb = encoder.encode([query], convert_to_numpy=True, normalize_embeddings=norm_query)[0]
429
 
430
  results = []
@@ -432,7 +501,6 @@ def _compute_rerank_scores(query: str, docs: List[str], alpha: float, norm_query
432
  d_emb = encoder.encode([text], convert_to_numpy=True, normalize_embeddings=True)[0]
433
  score_cosine = float(np.dot(q_emb, d_emb))
434
 
435
- # Corrección log_rdf sencilla y estable (solo para cosenos positivos)
436
  val = max(score_cosine, 0.0) + 1e-6
437
  score_log_rdf = float(np.log1p(val))
438
 
@@ -447,7 +515,6 @@ def _compute_rerank_scores(query: str, docs: List[str], alpha: float, norm_query
447
  }
448
  )
449
 
450
- # Ordenar por score_final descendente y asignar rank
451
  results_sorted = sorted(results, key=lambda r: r["score_final"], reverse=True)
452
  reranked = []
453
  for rank, r in enumerate(results_sorted, start=1):
@@ -463,32 +530,58 @@ def _compute_rerank_scores(query: str, docs: List[str], alpha: float, norm_query
463
  return reranked
464
 
465
 
466
- @app.post("/v1/rerank", response_model=RerankResponse)
467
- def rerank_endpoint(req: RerankRequest):
468
- """
469
- Endpoint Savant Seek style:
470
- POST /v1/rerank
471
- {
472
- "query": "...",
473
- "documents": ["doc1", "doc2", ...],
474
- "alpha": 0.2,
475
- "query_embedding_norm": true
476
- }
477
- """
478
- results = _compute_rerank_scores(
479
- query=req.query,
480
- docs=req.documents,
481
- alpha=req.alpha,
482
- norm_query=req.query_embedding_norm,
483
- )
484
 
485
- return RerankResponse(
486
- model_id=ENCODER_MODEL_ID,
487
- alpha=req.alpha,
488
- query_embedding_norm=req.query_embedding_norm,
489
- results=results,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  )
491
-
 
 
 
 
 
 
492
  @app.get("/")
493
  def root():
494
  return {"message": "Savant RRF Φ12.0 API running", "docs": "/docs"}
@@ -496,22 +589,38 @@ def root():
496
 
497
  @app.get("/health")
498
  def health():
499
- return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
 
501
 
502
  @app.post("/evaluate", response_model=EvaluateResponse)
503
  def evaluate(req: EvaluateRequest):
504
  try:
505
  scores, feats = compute_scores_srff_crff_ephi(req.prompt, req.answer)
 
506
 
507
- # resumen de una simulación adicional (fresca) solo para info
508
  H = build_dirac_hamiltonian(
509
  m=0.25, v=1.0, sigma=0.618,
510
  alpha_log=0.10, q=1.0,
511
  flux_vector=(0.0, 0.0, 0.0),
512
  gauge_scale=0.0,
513
  )
514
- rng = np.random.default_rng(abs(hash(req.prompt + req.answer + "sim")) % (2 ** 32))
 
 
515
  vec = rng.normal(0, 1, (2 * N,)) + 1j * rng.normal(0, 1, (2 * N,))
516
  vec /= np.sqrt(np.vdot(vec, vec))
517
  psi0 = vec
@@ -531,180 +640,25 @@ def evaluate(req: EvaluateRequest):
531
  scores=scores,
532
  features=feats,
533
  sim_summary=sim_summary,
 
534
  )
535
  except Exception as e:
536
  print(f"❌ [Runtime] Error en /evaluate: {e}", file=sys.stderr, flush=True)
537
  raise HTTPException(status_code=500, detail="Internal server error")
538
 
539
 
540
- # === SAVANT QUALITY_REMOTE PATCH (alias local de /evaluate) ===
541
  @app.post("/quality_remote", response_model=EvaluateResponse)
542
  def quality_remote(req: QualityRemoteRequest):
543
- """
544
- Alias de /evaluate para exponer la calidad RRF como /quality_remote.
545
- Entrada:
546
- {
547
- "prompt": "...",
548
- "answer": "...",
549
- "model_label": "..." # opcional
550
- }
551
- Salida:
552
- El mismo JSON que /evaluate:
553
- {
554
- "scores": {...},
555
- "features": {...},
556
- "sim_summary": {...}
557
- }
558
- """
559
- # Aquí simplemente reutilizamos la misma lógica de evaluate
560
  return evaluate(req)
561
 
562
 
563
- # ----------------- MODELOS Pydantic -----------------
564
-
565
- class EvaluateRequest(BaseModel):
566
- prompt: str
567
- answer: str
568
- model_label: Optional[str] = None
569
-
570
-
571
- class EvaluateResponse(BaseModel):
572
- scores: Dict[str, float]
573
- features: Dict[str, float]
574
- sim_summary: Dict[str, Any]
575
-
576
-
577
- class RerankRequest(BaseModel):
578
- query: str
579
- documents: List[str]
580
- alpha: float = 0.2
581
- query_embedding_norm: bool = True
582
-
583
-
584
- class RerankDocumentResult(BaseModel):
585
- id: int
586
- score_cosine: float
587
- score_log_rdf: float
588
- score_final: float
589
- rank: int
590
-
591
-
592
- class RerankResponse(BaseModel):
593
- model_id: str
594
- alpha: float
595
- query_embedding_norm: bool
596
- results: List[RerankDocumentResult]
597
-
598
-
599
- # ============================================================
600
- # 6. ENDPOINTS
601
- # ============================================================
602
-
603
- @app.middleware("http")
604
- async def log_requests(request: Request, call_next):
605
- start_time = time.time()
606
- response = None
607
- try:
608
- response = await call_next(request)
609
- return response
610
- finally:
611
- process_time = (time.time() - start_time) * 1000
612
- logger.info(
613
- f"[Request] {request.method} {request.url.path} "
614
- f"status={response.status_code if response else 'ERR'} "
615
- f"time_ms={process_time:.2f}"
616
- )
617
-
618
-
619
- @app.get("/health")
620
- def health_check():
621
- return {
622
- "status": "ok",
623
- "encoder_model_id": ENCODER_MODEL_ID,
624
- "meta_logit_filename": META_LOGIT_FILENAME,
625
- "meta_logit_n_features": n_features_expected,
626
- "N_sites": N,
627
- }
628
-
629
-
630
- @app.post("/evaluate", response_model=EvaluateResponse, dependencies=[Depends(api_key_dependency)])
631
- def evaluate_endpoint(req: EvaluateRequest):
632
- scores, feats = compute_scores_srff_crrf_ephi(req.prompt, req.answer)
633
-
634
- H = build_dirac_hamiltonian(
635
- m=0.25, v=1.0, sigma=0.618,
636
- alpha_log=0.10, q=1.0,
637
- flux_vector=(0.0, 0.0, 0.0),
638
- gauge_scale=0.0
639
- )
640
- rng = np.random.default_rng(abs(hash(req.prompt + req.answer)) % (2**32))
641
- vec = rng.normal(0, 1, (2*N,)) + 1j * rng.normal(0, 1, (2*N,))
642
- vec /= np.sqrt(np.vdot(vec, vec))
643
- psi0 = vec
644
-
645
- sim = evolve_dirac_shell(psi0, H, dt=0.05, steps=100, record_every=25)
646
-
647
- sim_summary = {
648
- "entropy_initial": float(sim["entropy"][0]),
649
- "entropy_final": float(sim["entropy"][-1]),
650
- "chirality_initial": float(sim["chirality"][0]),
651
- "chirality_final": float(sim["chirality"][-1]),
652
- "energy_mean": float(np.mean(sim["energy"])),
653
- "energy_std": float(np.std(sim["energy"])),
654
- "N_sites": int(N),
655
- }
656
-
657
- return EvaluateResponse(
658
- scores=scores,
659
- features=feats,
660
- sim_summary=sim_summary,
661
- )
662
-
663
-
664
- @app.post("/v1/quality", response_model=EvaluateResponse, dependencies=[Depends(api_key_dependency)])
665
- def quality_v1_endpoint(req: EvaluateRequest):
666
- # Alias directo de /evaluate
667
- return evaluate_endpoint(req)
668
-
669
-
670
- def _compute_rerank_scores(query: str, docs: List[str], alpha: float, norm_query: bool) -> List[RerankDocumentResult]:
671
- q_emb = encoder.encode([query], convert_to_numpy=True, normalize_embeddings=norm_query)[0]
672
-
673
- results = []
674
- for idx, text in enumerate(docs):
675
- d_emb = encoder.encode([text], convert_to_numpy=True, normalize_embeddings=True)[0]
676
- score_cosine = float(np.dot(q_emb, d_emb))
677
-
678
- val = max(score_cosine, 0.0) + 1e-6
679
- score_log_rdf = float(np.log1p(val))
680
-
681
- score_final = (1.0 - alpha) * score_cosine + alpha * score_log_rdf
682
-
683
- results.append(
684
- {
685
- "id": idx,
686
- "score_cosine": score_cosine,
687
- "score_log_rdf": score_log_rdf,
688
- "score_final": score_final,
689
- }
690
- )
691
-
692
- results_sorted = sorted(results, key=lambda r: r["score_final"], reverse=True)
693
- reranked = []
694
- for rank, r in enumerate(results_sorted, start=1):
695
- reranked.append(
696
- RerankDocumentResult(
697
- id=r["id"],
698
- score_cosine=r["score_cosine"],
699
- score_log_rdf=r["score_log_rdf"],
700
- score_final=r["score_final"],
701
- rank=rank,
702
- )
703
- )
704
- return reranked
705
 
706
 
707
- @app.post("/v1/rerank", response_model=RerankResponse, dependencies=[Depends(api_key_dependency)])
708
  def rerank_endpoint(req: RerankRequest):
709
  results = _compute_rerank_scores(
710
  query=req.query,
@@ -719,3 +673,39 @@ def rerank_endpoint(req: RerankRequest):
719
  query_embedding_norm=req.query_embedding_norm,
720
  results=results,
721
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import sys
3
+ import math
4
  from typing import Optional, Dict, Any, List
5
 
6
  import numpy as np
7
  from numpy.linalg import norm
8
  from scipy.linalg import expm
9
+
10
+ from fastapi import FastAPI, HTTPException
11
+ from pydantic import BaseModel, Field
12
+
13
  from sentence_transformers import SentenceTransformer
14
  from huggingface_hub import hf_hub_download
15
+ from datasets import load_dataset
16
  import joblib
17
 
18
+ # ============================
19
+ # Configuración de modelos
20
+ # ============================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
 
22
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ ENCODER_MODEL_ID = "antonypamo/RRFSAVANTMADE"
25
+ META_LOGIT_REPO = "antonypamo/RRFSavantMetaLogit"
26
+ META_LOGIT_FILENAME = "logreg_rrf_savant_15.joblib"
27
+ RRF_TUTOR_DATASET_ID = "antonypamo/savant_rrf1"
28
+
29
+ print("🔄 [Startup] Cargando encoder RRFSAVANTMADE...", flush=True)
30
+ try:
31
+ encoder = SentenceTransformer(ENCODER_MODEL_ID)
32
+ print("✅ [Startup] Encoder cargado.", flush=True)
33
+ except Exception as e:
34
+ print(f" [Startup] Error al cargar encoder: {e}", file=sys.stderr, flush=True)
35
+ raise
36
+
37
+ print("🔄 [Startup] Descargando meta-logit desde HF Hub...", flush=True)
38
+ try:
39
+ meta_logit_path = hf_hub_download(
40
+ repo_id=META_LOGIT_REPO,
41
+ filename=META_LOGIT_FILENAME,
42
+ token=HF_TOKEN if HF_TOKEN else None,
43
+ )
44
+ print(f"🔄 [Startup] Cargando modelo meta-logit '{META_LOGIT_FILENAME}'...", flush=True)
45
+ meta_logit = joblib.load(meta_logit_path)
46
+ try:
47
+ print(f"🔎 [Startup] Meta-logit espera {meta_logit.n_features_in_} features.", flush=True)
48
+ except Exception:
49
+ print("⚠️ [Startup] No se pudo leer n_features_in_.", flush=True)
50
+ print("✅ [Startup] Meta-logit cargado.", flush=True)
51
+ except Exception as e:
52
+ print(f" [Startup] Error al cargar meta-logit: {e}", file=sys.stderr, flush=True)
53
+ raise
54
 
55
 
56
+ # ============================
57
+ # Geometría icosaédrica Φ12.0
58
+ # ============================
59
 
60
  phi = (1 + np.sqrt(5)) / 2
61
  nodes = np.array([
 
85
  diff = nodes[:, None, :] - nodes[None, :, :]
86
  dist = norm(diff, axis=-1)
87
 
88
+ W = np.exp(-(dist ** 2) / (sigma ** 2))
89
  np.fill_diagonal(W, 0.0)
90
 
91
  if alpha_log > 0.0:
92
+ corr = 1.0 + alpha_log * np.log1p(dist ** 2)
93
  corr[range(N), range(N)] = 1.0
94
  W = W / corr
95
 
 
118
  W = geodesic_kernel(nodes, sigma=sigma, alpha_log=alpha_log)
119
 
120
  if gauge_scale != 0.0 and any(flux_vector):
121
+ theta = u1_edge_phases(nodes, flux_vector=flux_vector,
122
+ q=q, gauge_scale=gauge_scale)
123
  U = np.exp(1j * theta)
124
  else:
125
  U = np.ones((N, N), dtype=complex)
 
148
  N2 = psi.shape[0]
149
  n = N2 // 2
150
  psi_mat = psi.reshape(n, 2)
151
+ return np.sum(np.abs(psi_mat) ** 2, axis=1).real
152
 
153
 
154
  def chirality(psi):
 
165
  return float(-np.sum(p * np.log(p)).real)
166
 
167
 
168
+ def evolve_dirac_shell(psi0, H, dt=0.05, steps=100, record_every=25):
169
  U = expm(-1j * dt * H)
170
  psi = psi0.copy()
171
 
 
195
  }
196
 
197
 
198
+ # ============================
199
+ # Core RRF: embeddings + features + scores
200
+ # ============================
201
 
202
  def get_embedding(text: str) -> np.ndarray:
203
  emb = encoder.encode([text], convert_to_numpy=True, normalize_embeddings=True)
 
205
 
206
 
207
  def compute_rrf_features(prompt: str, answer: str) -> Dict[str, float]:
208
+ # Embeddings
209
  e_p = get_embedding(prompt)
210
  e_a = get_embedding(answer)
211
 
212
  cosine_pa = float(np.dot(e_p, e_a))
213
  len_ratio = len(answer) / (len(prompt) + 1.0)
214
 
215
+ # Simulación Dirac shell determinista (semilla por prompt+answer)
216
+ rng = np.random.default_rng(abs(hash(prompt + answer)) % (2 ** 32))
217
+ vec = rng.normal(0, 1, (2 * N,)) + 1j * rng.normal(0, 1, (2 * N,))
218
  vec /= np.sqrt(np.vdot(vec, vec))
219
  psi0 = vec
220
 
 
222
  m=0.25, v=1.0, sigma=0.618,
223
  alpha_log=0.10, q=1.0,
224
  flux_vector=(0.0, 0.0, 0.0),
225
+ gauge_scale=0.0,
226
  )
227
 
228
+ out = evolve_dirac_shell(psi0, H, dt=0.05, steps=100, record_every=25)
229
 
230
+ entropy = out["entropy"]
231
  energy = out["energy"]
232
  chir = out["chirality"]
 
233
 
 
234
  S_final = float(entropy[-1])
235
+ S_initial = float(entropy[0])
236
  S_delta = S_final - S_initial
237
  C_final = float(chir[-1])
238
  E_mean = float(np.mean(energy))
239
  E_std = float(np.std(energy))
240
 
241
+ feats: Dict[str, float] = {
242
  "cosine_pa": cosine_pa,
243
  "len_ratio": len_ratio,
244
  "dirac_entropy_final": S_final,
 
248
  "dirac_energy_std": E_std,
249
  }
250
 
251
+ # Derivadas para llegar a 15 (igual que en tu CSV/meta-logit)
252
+ S_max = math.log(N)
253
+ feats["entropy_norm"] = feats["dirac_entropy_final"] / S_max
254
+ feats["entropy_abs_delta"] = abs(feats["dirac_entropy_delta"])
255
+ feats["chirality_abs"] = abs(feats["dirac_chirality_final"])
256
+ feats["energy_abs_mean"] = abs(feats["dirac_energy_mean"])
257
+ feats["energy_std_sq"] = feats["dirac_energy_std"] ** 2
258
+ feats["cosine_sq"] = feats["cosine_pa"] ** 2
259
+ feats["len_log"] = math.log1p(feats["len_ratio"])
260
+ feats["len_inv"] = 1.0 / (1.0 + feats["len_ratio"])
261
+
262
+ return feats
263
 
264
+
265
+ def features_to_vector(feats: Dict[str, float]) -> np.ndarray:
266
+ keys = [
 
 
267
  "cosine_pa",
268
  "len_ratio",
269
  "dirac_entropy_final",
 
271
  "dirac_chirality_final",
272
  "dirac_energy_mean",
273
  "dirac_energy_std",
274
+ "entropy_norm",
275
+ "entropy_abs_delta",
276
+ "chirality_abs",
277
+ "energy_abs_mean",
278
+ "energy_std_sq",
279
+ "cosine_sq",
280
+ "len_log",
281
+ "len_inv",
282
  ]
283
+ return np.array([feats[k] for k in keys], dtype=float)
 
 
284
 
 
 
285
 
286
+ def compute_scores_srff_crff_ephi(prompt: str, answer: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  feats = compute_rrf_features(prompt, answer)
288
+ x = features_to_vector(feats).reshape(1, -1)
289
 
290
  proba = meta_logit.predict_proba(x)[0]
291
  p_good = float(proba[1])
 
293
  SRRF = p_good
294
  CRRF = p_good * feats["cosine_pa"]
295
 
296
+ S_max = math.log(N)
297
+ norm_entropy = float(feats["dirac_entropy_final"] / S_max)
 
298
  E_phi = 0.5 * (SRRF + norm_entropy)
299
 
300
  scores = {
 
307
 
308
 
309
  # ============================
310
+ # Role profiles
311
+ # ============================
312
+
313
+ ROLE_PROFILES: Dict[str, Dict[str, float]] = {
314
+ "default": {
315
+ "SRRF": 1.0,
316
+ "CRRF": 1.0,
317
+ "E_phi": 1.0,
318
+ },
319
+ "creative": {
320
+ "SRRF": 0.5,
321
+ "CRRF": 0.5,
322
+ "E_phi": 1.5,
323
+ },
324
+ "precise": {
325
+ "SRRF": 1.0,
326
+ "CRRF": 1.8,
327
+ "E_phi": 0.4,
328
+ },
329
+ }
330
+
331
+
332
+ def apply_role_profile(
333
+ scores: Dict[str, float],
334
+ role_name: Optional[str],
335
+ ) -> Dict[str, Any]:
336
+ if not role_name:
337
+ role_name = "default"
338
+
339
+ profile = ROLE_PROFILES.get(role_name, ROLE_PROFILES["default"])
340
+
341
+ composite = 0.0
342
+ weight_sum = 0.0
343
+ for key, w in profile.items():
344
+ if key in scores:
345
+ composite += w * scores[key]
346
+ weight_sum += abs(w)
347
+
348
+ if weight_sum > 0.0:
349
+ composite /= weight_sum
350
+
351
+ return {
352
+ "role": role_name,
353
+ "weights": profile,
354
+ "composite_score": composite,
355
+ }
356
+
357
+
358
+ # ============================
359
+ # RRF Tutor: carga de dataset savant_rrf1
360
+ # ============================
361
+
362
+ print(f"🔄 [Startup] Cargando dataset para RRF Tutor: {RRF_TUTOR_DATASET_ID}...", flush=True)
363
+ try:
364
+ ds_rrf = load_dataset(RRF_TUTOR_DATASET_ID, split="train")
365
+ ds_rrf = ds_rrf.filter(
366
+ lambda ex: ex.get("prompt") is not None and ex.get("completion") is not None
367
+ )
368
+ print(f"✅ Dataset RRF Tutor cargado. Ejemplos útiles: {len(ds_rrf)}", flush=True)
369
+ except Exception as e:
370
+ print(f"❌ Error cargando dataset RRF Tutor: {e}", file=sys.stderr, flush=True)
371
+ ds_rrf = None
372
+
373
+ if ds_rrf is not None:
374
+ print("🔄 [Startup] Construyendo textos y embeddings para RRF Tutor...", flush=True)
375
+ rrf_corpus_texts: List[str] = []
376
+ rrf_corpus_prompts: List[str] = []
377
+ rrf_corpus_completions: List[str] = []
378
+
379
+ for ex in ds_rrf:
380
+ p = ex["prompt"]
381
+ c = ex["completion"]
382
+ rrf_corpus_prompts.append(p)
383
+ rrf_corpus_completions.append(c)
384
+ rrf_corpus_texts.append(p + "\n\n" + c)
385
+
386
+ rrf_corpus_embeds = encoder.encode(
387
+ rrf_corpus_texts,
388
+ convert_to_numpy=True,
389
+ show_progress_bar=True,
390
+ normalize_embeddings=True,
391
+ )
392
+ print("✅ [RRF Tutor] Embeddings construidos.", flush=True)
393
+ else:
394
+ rrf_corpus_texts = []
395
+ rrf_corpus_prompts = []
396
+ rrf_corpus_completions = []
397
+ rrf_corpus_embeds = np.zeros((0, 384), dtype=np.float32)
398
+ print("⚠️ [RRF Tutor] Dataset no disponible, el endpoint devolverá error si se usa.", flush=True)
399
+
400
+
401
+ # ============================
402
+ # FastAPI app & modelos
403
  # ============================
404
 
405
  class EvaluateRequest(BaseModel):
 
407
  answer: str
408
  model_label: Optional[str] = None
409
 
410
+ class Config:
411
+ protected_namespaces = () # evitar warning por model_label
412
+
413
 
414
  class EvaluateResponse(BaseModel):
415
  scores: Dict[str, float]
416
  features: Dict[str, float]
417
  sim_summary: Dict[str, Any]
418
+ role_profile: Optional[Dict[str, Any]] = None
419
 
420
 
 
421
  class QualityRemoteRequest(EvaluateRequest):
 
422
  pass
423
 
424
 
425
+ class RoleProfileInfo(BaseModel):
426
+ name: str
427
+ weights: Dict[str, float]
428
+
429
+
430
+ class RoleProfilesResponse(BaseModel):
431
+ roles: List[RoleProfileInfo]
432
 
433
 
434
  class RerankRequest(BaseModel):
 
 
 
435
  query: str = Field(..., description="Query de búsqueda o pregunta del usuario.")
436
  documents: List[str] = Field(..., description="Lista de documentos candidatos a rerankear.")
437
  alpha: float = Field(
438
  0.2,
439
+ description="Peso de la corrección log_rdf en el score_final. 0 = solo cosine, 1 = solo log_rdf.",
440
  )
441
  query_embedding_norm: bool = Field(
442
  True,
443
+ description="Si True, normaliza el embedding de query (útil para cosine).",
444
  )
445
 
446
+
447
  class RerankDocumentResult(BaseModel):
448
  id: int = Field(..., description="Índice del documento en la lista de entrada.")
449
  score_cosine: float
 
451
  score_final: float
452
  rank: int
453
 
454
+
455
  class RerankResponse(BaseModel):
456
  model_id: str
457
  alpha: float
 
459
  results: List[RerankDocumentResult]
460
 
461
 
462
+ class RRFTutorRequest(BaseModel):
463
+ query: str = Field(..., description="Pregunta o fragmento de ecuación/idea RRF.")
464
+ max_examples: int = Field(
465
+ 3, ge=1, le=8,
466
+ description="Número de ejemplos de savant_rrf1 a recuperar (1-8)."
467
+ )
468
+ include_raw_context: bool = Field(
469
+ False,
470
+ description="Si es true, devuelve los ejemplos recuperados."
471
+ )
472
+
473
+
474
+ class RetrievedExample(BaseModel):
475
+ prompt: str
476
+ completion: str
477
+ score: float
478
+
479
+
480
+ class RRFTutorResponse(BaseModel):
481
+ answer: str
482
+ retrieved: Optional[List[RetrievedExample]] = None
483
+
484
+
485
+ app = FastAPI(
486
+ title="Savant RRF Φ12.0 API",
487
+ description="Dirac-Resonant conceptual quality layer + reranking + RRF Tutor.",
488
+ version="1.1.0",
489
+ )
490
+
491
+
492
+ # ============================
493
+ # Utilidades /v1/rerank
494
+ # ============================
495
+
496
  def _compute_rerank_scores(query: str, docs: List[str], alpha: float, norm_query: bool) -> List[RerankDocumentResult]:
 
 
 
 
 
 
 
497
  q_emb = encoder.encode([query], convert_to_numpy=True, normalize_embeddings=norm_query)[0]
498
 
499
  results = []
 
501
  d_emb = encoder.encode([text], convert_to_numpy=True, normalize_embeddings=True)[0]
502
  score_cosine = float(np.dot(q_emb, d_emb))
503
 
 
504
  val = max(score_cosine, 0.0) + 1e-6
505
  score_log_rdf = float(np.log1p(val))
506
 
 
515
  }
516
  )
517
 
 
518
  results_sorted = sorted(results, key=lambda r: r["score_final"], reverse=True)
519
  reranked = []
520
  for rank, r in enumerate(results_sorted, start=1):
 
530
  return reranked
531
 
532
 
533
+ # ============================
534
+ # Utilidades /v1/rrf_tutor
535
+ # ============================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
 
537
+ def rrf_tutor_retrieve_examples(query: str, top_k: int = 3):
538
+ if rrf_corpus_embeds is None or len(rrf_corpus_embeds) == 0:
539
+ raise RuntimeError("Embeddings de RRF Tutor no están disponibles.")
540
+
541
+ q_emb = encoder.encode([query], convert_to_numpy=True, normalize_embeddings=True)[0]
542
+ sims = np.dot(rrf_corpus_embeds, q_emb)
543
+
544
+ top_k = min(top_k, len(rrf_corpus_embeds))
545
+ top_idx = np.argsort(-sims)[:top_k]
546
+
547
+ results = []
548
+ for idx in top_idx:
549
+ results.append(
550
+ {
551
+ "idx": int(idx),
552
+ "score": float(sims[idx]),
553
+ "prompt": rrf_corpus_prompts[idx],
554
+ "completion": rrf_corpus_completions[idx],
555
+ }
556
+ )
557
+ return results
558
+
559
+
560
+ def rrf_tutor_build_answer(query: str, retrieved_examples):
561
+ if not retrieved_examples:
562
+ return (
563
+ "No encontré ejemplos relevantes en el dataset RRF Tutor para tu consulta. "
564
+ "Intenta reformular la pregunta o revisar la configuración del dataset."
565
+ )
566
+
567
+ best = retrieved_examples[0]
568
+ base_completion = best["completion"]
569
+
570
+ answer = (
571
+ "🔎 Respuesta basada en el ejemplo más cercano del corpus RRF:\n\n"
572
+ f"{base_completion}\n\n"
573
+ "💡 Nota: Esta es una versión mínima que reutiliza directamente la 'completion' "
574
+ "del ejemplo más similar en savant_rrf1. En una versión extendida, aquí se "
575
+ "conectaría un LLM pequeño (TinyLlama, etc.) que use varios ejemplos como "
576
+ "contexto para generar una explicación personalizada a tu `query`."
577
  )
578
+ return answer
579
+
580
+
581
+ # ============================
582
+ # Endpoints
583
+ # ============================
584
+
585
  @app.get("/")
586
  def root():
587
  return {"message": "Savant RRF Φ12.0 API running", "docs": "/docs"}
 
589
 
590
  @app.get("/health")
591
  def health():
592
+ return {
593
+ "status": "ok",
594
+ "encoder_model_id": ENCODER_MODEL_ID,
595
+ "meta_logit_filename": META_LOGIT_FILENAME,
596
+ "N_sites": N,
597
+ }
598
+
599
+
600
+ @app.get("/roles", response_model=RoleProfilesResponse)
601
+ def list_roles():
602
+ roles = [
603
+ RoleProfileInfo(name=name, weights=weights)
604
+ for name, weights in ROLE_PROFILES.items()
605
+ ]
606
+ return RoleProfilesResponse(roles=roles)
607
 
608
 
609
  @app.post("/evaluate", response_model=EvaluateResponse)
610
  def evaluate(req: EvaluateRequest):
611
  try:
612
  scores, feats = compute_scores_srff_crff_ephi(req.prompt, req.answer)
613
+ role_profile = apply_role_profile(scores, req.model_label)
614
 
 
615
  H = build_dirac_hamiltonian(
616
  m=0.25, v=1.0, sigma=0.618,
617
  alpha_log=0.10, q=1.0,
618
  flux_vector=(0.0, 0.0, 0.0),
619
  gauge_scale=0.0,
620
  )
621
+ rng = np.random.default_rng(
622
+ abs(hash(req.prompt + req.answer + "sim")) % (2 ** 32)
623
+ )
624
  vec = rng.normal(0, 1, (2 * N,)) + 1j * rng.normal(0, 1, (2 * N,))
625
  vec /= np.sqrt(np.vdot(vec, vec))
626
  psi0 = vec
 
640
  scores=scores,
641
  features=feats,
642
  sim_summary=sim_summary,
643
+ role_profile=role_profile,
644
  )
645
  except Exception as e:
646
  print(f"❌ [Runtime] Error en /evaluate: {e}", file=sys.stderr, flush=True)
647
  raise HTTPException(status_code=500, detail="Internal server error")
648
 
649
 
 
650
  @app.post("/quality_remote", response_model=EvaluateResponse)
651
  def quality_remote(req: QualityRemoteRequest):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  return evaluate(req)
653
 
654
 
655
+ @app.post("/quality", response_model=EvaluateResponse)
656
+ def quality_alias(req: QualityRemoteRequest):
657
+ """Alias de /evaluate para compatibilidad con clientes anteriores."""
658
+ return evaluate(req)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
 
660
 
661
+ @app.post("/v1/rerank", response_model=RerankResponse)
662
  def rerank_endpoint(req: RerankRequest):
663
  results = _compute_rerank_scores(
664
  query=req.query,
 
673
  query_embedding_norm=req.query_embedding_norm,
674
  results=results,
675
  )
676
+
677
+
678
+ @app.post("/v1/rrf_tutor", response_model=RRFTutorResponse)
679
+ def rrf_tutor_endpoint(body: RRFTutorRequest):
680
+ if not body.query or not body.query.strip():
681
+ raise HTTPException(status_code=400, detail="El campo 'query' no puede estar vacío.")
682
+
683
+ if ds_rrf is None or rrf_corpus_embeds is None or len(rrf_corpus_embeds) == 0:
684
+ raise HTTPException(
685
+ status_code=500,
686
+ detail="El dataset/embeddings de RRF Tutor no están disponibles en este momento.",
687
+ )
688
+
689
+ try:
690
+ retrieved = rrf_tutor_retrieve_examples(body.query, top_k=body.max_examples)
691
+ except Exception as e:
692
+ raise HTTPException(
693
+ status_code=500,
694
+ detail=f"Error interno recuperando ejemplos RRF Tutor: {e}",
695
+ )
696
+
697
+ answer = rrf_tutor_build_answer(body.query, retrieved)
698
+
699
+ resp = RRFTutorResponse(answer=answer)
700
+
701
+ if body.include_raw_context:
702
+ resp.retrieved = [
703
+ RetrievedExample(
704
+ prompt=ex["prompt"],
705
+ completion=ex["completion"],
706
+ score=ex["score"],
707
+ )
708
+ for ex in retrieved
709
+ ]
710
+
711
+ return resp