Princeaka commited on
Commit
769f383
·
verified ·
1 Parent(s): 5a4f66f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +234 -200
app.py CHANGED
@@ -1,16 +1,20 @@
1
  # JusticeAI Backend — Upgraded & Integrated (Backend-only; does NOT create or overwrite frontend)
2
  #
3
- # Summary of recent changes applied in this file:
4
- # - Use the user's local emojis.py when available (falls back only if missing).
5
- # - Removed langdetect import and rely on a conservative heuristic (detect_language_safe).
6
- # - Prefer a local language model (language.py or language.bin) for translations; fall back to Helsinki-NLP via transformers if needed.
7
- # - Ensure /chat and all retrieval/refinement operate strictly within the resolved topic (no cross-topic lookup/updates).
8
- # - Stop joining sentences into run-on paragraphs. dedupe_sentences preserves sentences as separate lines.
9
- # - Enhance emoji understanding: detect emojis in user input, adjust mood detection using emojis, and apply safer rules for when to append or echo emojis in replies.
10
- # - Minor safety: never append emojis to replies that already contain them or when moderation flags high toxicity.
11
  #
12
- # Notes: keep testing with your local emojis.py and language.* model. If your language module exposes specific function names
13
- # (translate, translate_to_en, translate_from_en) the loader will auto-detect them. If the API differs, tell me the function signatures and I'll wire them exactly.
 
 
 
 
 
 
 
 
 
14
 
15
  from sqlalchemy.pool import NullPool
16
  import os
@@ -49,9 +53,6 @@ os.environ["SENTENCE_TRANSFORMERS_HOME"] = HF_CACHE_DIR
49
  # ----- Optional helpers (soft fallbacks) -----
50
  # Prefer user's emojis.py
51
  try:
52
- # emojis.py is expected to provide at least:
53
- # - get_emoji(category: str, intensity: float=0.5) -> str
54
- # - get_category_for_mood(mood: str) -> str
55
  from emojis import get_emoji, get_category_for_mood # type: ignore
56
  logger.info("[JusticeAI] Using local emojis.py")
57
  except Exception:
@@ -85,37 +86,53 @@ except Exception:
85
  AutoModelForCausalLM = None
86
  hf_pipeline = None
87
 
88
- # ----- Local language model loader (user-provided language.py or language.bin) -----
89
- language_model = None
90
 
91
- def load_local_language_model():
92
- global language_model
93
- # Try to import a local language module first (language.py with translate API)
 
 
 
 
 
 
 
 
 
 
94
  try:
95
- import language as local_language_module # type: ignore
96
- language_model = local_language_module
97
- logger.info("[JusticeAI] Loaded local language module (language.py)")
98
  return
99
  except Exception:
100
  pass
101
- # If language.bin exists, try to load with torch or pickle
102
- try:
103
- bin_path = Path("language.bin")
104
- if bin_path.exists():
105
  try:
106
- language_model = torch.load(str(bin_path), map_location="cpu")
107
- logger.info("[JusticeAI] Loaded local language model from language.bin via torch.load")
108
- except Exception:
109
- import pickle
110
- with open(bin_path, "rb") as f:
111
- language_model = pickle.load(f)
112
- logger.info("[JusticeAI] Loaded local language model from language.bin via pickle")
113
- except Exception as e:
114
- language_model = None
115
- logger.warning(f"[JusticeAI] Failed to load local language model: {e}")
 
 
 
 
 
 
116
 
117
- # Attempt early load
118
- load_local_language_model()
119
 
120
  # ----- Config (env) -----
121
  ADMIN_KEY = os.environ.get("ADMIN_KEY")
@@ -288,7 +305,7 @@ class JusticeBrain:
288
  self.capabilities["embed_available"] = embed_model is not None
289
  self.capabilities["moderator"] = moderator is not None
290
  self.capabilities["llm_reflect"] = llm_model is not None and llm_tokenizer is not None
291
- self.capabilities["translation"] = (AutoTokenizer is not None and AutoModelForSeq2SeqLM is not None) or (language_model is not None)
292
  self.capabilities["ann"] = False # FAISS not wired yet (scaffold)
293
  logger.info(f"[JusticeBrain] Capabilities: {self.capabilities}")
294
 
@@ -360,17 +377,18 @@ def sanitize_knowledge_text(t: Any) -> str:
360
  s = s[1:-1].strip()
361
  return " ".join(s.split())
362
 
363
- def dedupe_sentences(text):
364
  """
365
- Split text into sentences (respecting newlines) and dedupe while preserving order.
366
- Return with sentences separated by newlines (no joining into run-ons).
367
  """
368
  if not text:
369
  return text
370
  sentences = []
371
  seen = set()
372
- # Respect explicit newlines first
373
  for chunk in re.split(r'\n+', text):
 
374
  parts = re.split(r'(?<=[.?!])\s+', chunk)
375
  for sent in parts:
376
  s = sent.strip()
@@ -382,8 +400,7 @@ def dedupe_sentences(text):
382
  sentences.append(s)
383
  return "\n".join(sentences)
384
 
385
- # Emoji utilities: detect, extract and classify basic sentiment from emojis.
386
- # This is a conservative heuristic and will be used to inform mood detection and emoji decisions.
387
  _EMOJI_PATTERN = re.compile(
388
  "["
389
  "\U0001F600-\U0001F64F" # emoticons
@@ -395,31 +412,21 @@ _EMOJI_PATTERN = re.compile(
395
  "]+", flags=re.UNICODE
396
  )
397
 
398
- _POS_EMOJI_RANGES = [
399
- ("\U0001F600", "\U0001F606"), # grinning, smiling
400
- ("\U0001F60A", "\U0001F60F"), # smiling variants
401
- ("\U0001F642", "\U0001F60D")
402
- ]
403
- _NEG_EMOJI_RANGES = [
404
- ("\U0001F61E", "\U0001F626"), # sad/concerned faces
405
- ("\U0001F62A", "\U0001F626")
406
- ]
407
-
408
  def extract_emojis(text: str) -> List[str]:
409
  if not text:
410
  return []
411
  return _EMOJI_PATTERN.findall(text)
412
 
413
  def emoji_sentiment_score(emojis: List[str]) -> float:
414
- # Returns score in [-1.0, 1.0], positive -> positive sentiment
415
  if not emojis:
416
  return 0.0
417
  score = 0.0
 
418
  for e in "".join(emojis):
419
  ord_val = ord(e)
420
- # quick heuristics: common smiley ranges
421
- if 0x1F600 <= ord_val <= 0x1F64F: # emoticons
422
- # smiles ~ positive; frowns closer to 0x1F61E negative
423
  if ord_val in range(0x1F600, 0x1F607) or ord_val in range(0x1F60A, 0x1F60F):
424
  score += 1.0
425
  elif ord_val in range(0x1F61E, 0x1F626):
@@ -430,55 +437,112 @@ def emoji_sentiment_score(emojis: List[str]) -> float:
430
  score += 0.1
431
  else:
432
  score += 0.0
433
- return max(-1.0, min(1.0, score / max(1, len(emojis))))
434
 
435
  def detect_language_safe(text: str) -> str:
436
  """
437
- Heuristic language detection only (langdetect removed). Returns an ISO-ish short code or 'und'.
438
- Conservative bias toward English for short ASCII phrases.
439
  """
440
  text = (text or "").strip()
441
  if not text:
442
  return "en"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  lower = text.lower()
444
- short_ascii = bool(re.fullmatch(r"[\x20-\x7E]+", text))
445
- common_en = {"hi", "hello", "hey", "how are you", "ok", "okay", "thanks", "thank you", "yes", "no", "please", "help"}
446
- if len(lower.split()) <= 4 and short_ascii:
447
- for w in common_en:
448
- if w in lower:
449
- return "en"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  letters = re.findall(r'[A-Za-z]', text)
451
  if len(letters) >= max(1, len(text) / 4):
452
  return "en"
 
 
453
  return "und"
454
 
455
  def translate_to_english(text: str, src_lang: str) -> str:
456
  """
457
- Prefer a local language model if available (language.py or language.bin).
458
- If not available, fall back to Helsinki-NLP models via transformers.
459
  """
460
  if not text:
461
  return text
462
  src = (src_lang.split('-')[0].lower() if src_lang else "und")
463
  if src in ("en", "eng", "", "und"):
464
  return text
465
- # Use local language_model if present
466
- if language_model is not None:
467
- try:
468
- # Common possible APIs: translate_to_en(text, src) or translate(text, src, tgt)
469
- if hasattr(language_model, "translate_to_en"):
470
- return language_model.translate_to_en(text, src)
471
- if hasattr(language_model, "translate"):
 
 
 
472
  try:
473
- return language_model.translate(text, src, "en")
474
  except TypeError:
475
- return language_model.translate(text)
476
- if isinstance(language_model, dict):
477
- key = (src, "en")
478
- return language_model.get(key, text)
479
- except Exception as e:
480
- logger.warning(f"Local language model translation failed: {e}")
481
- # Validate potential model id shape
 
 
 
 
 
 
482
  if not re.fullmatch(r"[a-z]{2,3}", src):
483
  return text
484
  try:
@@ -495,7 +559,7 @@ def translate_to_english(text: str, src_lang: str) -> str:
495
  model_name = f"Helsinki-NLP/opus-mt-{src}-en"
496
  tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
497
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
498
- _translation_model_cache[f"{src}-en"] = (tokenizer, model)
499
  inputs = tokenizer([text], return_tensors="pt", truncation=True)
500
  outputs = model.generate(**inputs, max_length=1024)
501
  return tokenizer.batch_decode(outputs, skip_special_tokens=True)[0]
@@ -514,27 +578,37 @@ def translate_to_english(text: str, src_lang: str) -> str:
514
 
515
  def translate_from_english(text: str, tgt_lang: str) -> str:
516
  """
517
- Prefer a local language model if available. Else use Helsinki models.
518
  """
519
  if not text:
520
  return text
521
  tgt = (tgt_lang.split('-')[0].lower() if tgt_lang else "und")
522
  if tgt in ("en", "eng", "", "und"):
523
  return text
524
- if language_model is not None:
525
- try:
526
- if hasattr(language_model, "translate_from_en"):
527
- return language_model.translate_from_en(text, tgt)
528
- if hasattr(language_model, "translate"):
529
  try:
530
- return language_model.translate(text, "en", tgt)
 
 
 
 
 
531
  except TypeError:
532
- return language_model.translate(text)
533
- if isinstance(language_model, dict):
534
- key = ("en", tgt)
535
- return language_model.get(key, text)
536
- except Exception as e:
537
- logger.warning(f"Local language model (en->tgt) translation failed: {e}")
 
 
 
 
 
 
538
  if not re.fullmatch(r"[a-z]{2,3}", tgt):
539
  return text
540
  try:
@@ -551,7 +625,7 @@ def translate_from_english(text: str, tgt_lang: str) -> str:
551
  model_name = f"Helsinki-NLP/opus-mt-en-{tgt}"
552
  tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
553
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
554
- _translation_model_cache[f"en-{tgt}"] = (tokenizer, model)
555
  inputs = tokenizer([text], return_tensors="pt", truncation=True)
556
  outputs = model.generate(**inputs, max_length=1024)
557
  return tokenizer.batch_decode(outputs, skip_special_tokens=True)[0]
@@ -597,7 +671,7 @@ def is_boilerplate_candidate(s: str) -> bool:
597
  def generate_creative_reply(matches: List[str]) -> str:
598
  """
599
  Combine up to three matches into a concise reply.
600
- Preserve sentences as separate lines (no joining into run-ons).
601
  """
602
  clean = []
603
  seen = set()
@@ -606,14 +680,12 @@ def generate_creative_reply(matches: List[str]) -> str:
606
  if not s or s in seen or is_boilerplate_candidate(s):
607
  continue
608
  seen.add(s)
609
- # Keep answer sentences separate
610
  s = dedupe_sentences(s)
611
  clean.append(s)
612
  if not clean:
613
  return "I’m not sure yet."
614
  if len(clean) == 1:
615
  return clean[0]
616
- # Return as separate lines; preserve sentence boundaries inside each match
617
  return "\n\n".join(clean[:3])
618
 
619
  def infer_topic_from_message(msg: str, known_topics=None) -> str:
@@ -636,8 +708,7 @@ def infer_topic_from_message(msg: str, known_topics=None) -> str:
636
 
637
  def refine_or_update(matches, new_text, new_reply, confidence, topic="general"):
638
  """
639
- Decide whether to update existing knowledge or insert a new entry based on similarity.
640
- This function ONLY touches rows in the provided topic (enforced).
641
  """
642
  try:
643
  if embed_model is None:
@@ -661,7 +732,6 @@ def refine_or_update(matches, new_text, new_reply, confidence, topic="general"):
661
  best_score = float(sims[best_idx])
662
  if best_score > 0.75:
663
  kid = ids[best_idx]
664
- # Check manual flag and prevent contradictory overwrite within topic
665
  with engine.begin() as conn:
666
  row = conn.execute(sql_text("SELECT meta FROM knowledge WHERE id = :id"), {"id": kid}).fetchone()
667
  is_manual = False
@@ -672,7 +742,6 @@ def refine_or_update(matches, new_text, new_reply, confidence, topic="general"):
672
  except Exception:
673
  is_manual = False
674
  if is_manual and confidence < 0.85:
675
- # Do not overwrite manual high-confidence entries unless very confident; append refined reply note
676
  with engine.begin() as conn:
677
  conn.execute(
678
  sql_text("UPDATE knowledge SET reply = :r, updated_at = CURRENT_TIMESTAMP WHERE id = :id"),
@@ -691,7 +760,6 @@ def refine_or_update(matches, new_text, new_reply, confidence, topic="general"):
691
  sql_text("INSERT INTO knowledge (text, reply, language, embedding, category, topic, confidence, meta) VALUES (:t, :r, :lang, :e, 'learned', :topic, :conf, :meta)"),
692
  {"t": new_text, "r": new_reply or "", "lang": "en", "e": emb, "topic": topic, "conf": min(0.7, float(confidence)), "meta": json.dumps({"refined": True})}
693
  )
694
- # bump version on update
695
  global knowledge_version
696
  knowledge_version += 1
697
  except Exception as e:
@@ -699,13 +767,11 @@ def refine_or_update(matches, new_text, new_reply, confidence, topic="general"):
699
 
700
  def detect_mood(text: str) -> str:
701
  """
702
- Detect mood from text and emojis. Return 'positive', 'negative', or 'neutral'.
703
- This integrates emoji sentiment heuristics.
704
  """
705
  lower = (text or "").lower()
706
  positive = ["great", "thanks", "awesome", "happy", "love", "excellent", "cool", "yes", "good", "success", "helpful", "useful", "thank you"]
707
- negative = ["sad", "bad", "problem", "angry", "hate", "fail", "no", "error", "not working", "disadvantage", "issue", "problem"]
708
- # emoji influence
709
  emojis = extract_emojis(text)
710
  e_score = emoji_sentiment_score(emojis)
711
  if any(w in lower for w in positive) or e_score > 0.3:
@@ -717,35 +783,25 @@ def detect_mood(text: str) -> str:
717
  def should_append_emoji(user_text: str, reply_text: str, mood: str, flags: Dict[str, Any]) -> str:
718
  """
719
  Decide whether to append/echo an emoji and which one.
720
- Rules:
721
- - Do not append if moderation flagged toxic.
722
- - Do not append if the reply already contains emoji.
723
- - Prefer echoing user's emoji if present (amplify or acknowledge).
724
- - Only append for short replies and when mood non-neutral or user used emojis.
725
- - Use get_emoji/get_category_for_mood (from emojis.py) if available.
726
- Returns the emoji string to append or empty string.
727
  """
728
  if flags.get("toxic"):
729
  return ""
730
- # If reply already has emoji, do not add
731
  if extract_emojis(reply_text):
732
  return ""
733
  user_emojis = extract_emojis(user_text)
734
  if user_emojis:
735
- # Echo the first user emoji if it's positive-ish, otherwise map mood
736
  user_score = emoji_sentiment_score(user_emojis)
737
  if user_score >= 0.2:
738
- # echo a positive emoji
739
  try:
740
  cat = get_category_for_mood("positive")
741
  return get_emoji(cat, intensity=min(1.0, 0.5 + user_score))
742
  except Exception:
743
  return user_emojis[0] if user_emojis else ""
744
  elif user_score <= -0.2:
745
- # avoid adding negative emoji; reflect neutrally
746
  return ""
747
  else:
748
- # for neutral user emoji, optionally add a small positive emoji for short replies
749
  if len(reply_text) < 200:
750
  try:
751
  cat = get_category_for_mood("neutral")
@@ -753,13 +809,10 @@ def should_append_emoji(user_text: str, reply_text: str, mood: str, flags: Dict[
753
  except Exception:
754
  return ""
755
  return ""
756
- # No user emoji: use mood and reply constraints
757
  if mood == "neutral":
758
  return ""
759
- # do not add emoji for long or formal replies
760
  if len(reply_text) > 400:
761
  return ""
762
- # avoid adding when reply contains code-like chars
763
  if re.search(r'[\{\}\[\]\(\)]', reply_text):
764
  return ""
765
  try:
@@ -770,9 +823,8 @@ def should_append_emoji(user_text: str, reply_text: str, mood: str, flags: Dict[
770
 
771
  def synthesize_final_reply(en_msg: str, matches: List[str], llm_suggestion: str, intent: str, detected_lang: str) -> str:
772
  """
773
- Combine matches and llm_suggestion but JusticeAI always decides final text.
774
- - Preserve sentence boundaries (no run-ons).
775
- - Return reply in English (later translated if needed).
776
  """
777
  pieces = []
778
  for m in matches:
@@ -785,9 +837,7 @@ def synthesize_final_reply(en_msg: str, matches: List[str], llm_suggestion: str,
785
  pieces.append(sent)
786
  if not pieces:
787
  return "I don't have enough context — could you add more details or add knowledge with /add?"
788
- # Compose using up to 3 pieces; keep them separated by blank lines
789
  reply = "\n\n".join(pieces[:3])
790
- # Intent-specific formatting: solutions -> bullets; others keep lines
791
  if intent == "solution":
792
  bullets = []
793
  for p in re.split(r'\n+', reply):
@@ -806,7 +856,7 @@ def synthesize_final_reply(en_msg: str, matches: List[str], llm_suggestion: str,
806
  # ----- Startup: load models & background loops -----
807
  @app.on_event("startup")
808
  async def startup_event():
809
- global embed_model, spell, moderator, llm_tokenizer, llm_model, startup_time
810
  t0 = time.time()
811
  logger.info("[JusticeAI] Starting component loading...")
812
 
@@ -875,8 +925,18 @@ async def startup_event():
875
  model_progress["llm"]["status"] = "error"
876
  logger.warning(f"[JusticeAI] Could not load local LLM: {e}")
877
 
878
- # reload language model in case startup changed cwd
879
- load_local_language_model()
 
 
 
 
 
 
 
 
 
 
880
 
881
  startup_time = round(time.time() - t0, 2)
882
  logger.info(f"[JusticeAI] Startup completed in {startup_time}s")
@@ -903,12 +963,9 @@ async def startup_event():
903
  def background_learning_loop():
904
  while True:
905
  try:
906
- # Collect recent user interactions for learning
907
  with engine.begin() as conn:
908
  mem_rows = conn.execute(sql_text("SELECT text, reply, topic, confidence FROM user_memory ORDER BY created_at DESC LIMIT 200")).fetchall()
909
  knowledge_rows = conn.execute(sql_text("SELECT text, reply, topic FROM knowledge ORDER BY created_at DESC LIMIT 200")).fetchall()
910
-
911
- # Use LLM for suggestions on each memory (if available)
912
  if llm_model and llm_tokenizer and mem_rows:
913
  for mem in mem_rows:
914
  user_text = mem[0] or ""
@@ -919,27 +976,29 @@ async def startup_event():
919
  inputs = llm_tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512)
920
  outputs = llm_model.generate(**inputs, max_length=256, do_sample=True, temperature=0.7)
921
  suggestion = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)
922
- # Use suggestion to refine knowledge in a conservative way
923
  conf = float(mem[3] or 0)
924
  if suggestion and conf >= 0.2:
925
- # refine/update is topic-scoped
926
  refine_or_update([], user_text, suggestion, conf, topic=topic)
927
  logger.debug(f"[Background AGI] Refined knowledge for topic={topic}")
928
  except Exception as e:
929
  logger.debug(f"[Background AGI] LLM suggestion error for memory: {e}")
930
- # Mark learning event
931
  record_learn_event()
932
  except Exception as e:
933
  logger.warning(f"[Background AGI] Learning loop error: {e}")
934
- time.sleep(60) # run every minute
935
-
936
  threading.Thread(target=background_learning_loop, daemon=True).start()
937
 
938
  # ----- Endpoints -----
939
  @app.get("/model-status")
940
  async def model_status():
941
  response_progress = {k: dict(v) for k, v in model_progress.items()}
942
- return {"model_loaded": embed_model is not None, "model_progress": response_progress, "model_load_times": model_load_times, "startup_time_s": startup_time}
 
 
 
 
 
 
943
 
944
  @app.get("/health")
945
  async def health_check():
@@ -1048,7 +1107,7 @@ async def add_knowledge(data: dict = Body(...)):
1048
  return JSONResponse(status_code=400, content={"error": "Text is required"})
1049
  detected = detect_language_safe(text_data)
1050
  if detected and detected.split("-")[0].lower() not in ("en", "eng", "und"):
1051
- if AutoTokenizer is not None and AutoModelForSeq2SeqLM is not None or language_model is not None:
1052
  try:
1053
  text_data = translate_to_english(text_data, detected)
1054
  detected = "en"
@@ -1081,7 +1140,6 @@ async def add_knowledge(data: dict = Body(...)):
1081
  sql_text("INSERT INTO knowledge (text, reply, language, category, topic, confidence, meta) VALUES (:t, :r, :lang, 'general', :topic, :conf, :meta)"),
1082
  {"t": text_data, "r": reply, "lang": "en", "topic": topic, "conf": 0.9, "meta": json.dumps({"manual": True})}
1083
  )
1084
- # bump version for caches and indexes
1085
  global knowledge_version
1086
  knowledge_version += 1
1087
  record_learn_event()
@@ -1136,7 +1194,7 @@ async def add_bulk(data: List[dict] = Body(...)):
1136
  errors.append({"index": i, "error": str(e)})
1137
  return {"added": added, "errors": errors}
1138
 
1139
- # Justice Brain Backend — /chat endpoint (multilingual, internal chain reasoning, LLM only for self-improvement)
1140
  @app.post("/chat")
1141
  async def chat(request: Request, data: dict = Body(...)):
1142
  t0 = time.time()
@@ -1146,12 +1204,11 @@ async def chat(request: Request, data: dict = Body(...)):
1146
  user_id = hashlib.sha256(f"{user_ip}-{username}".encode()).hexdigest()
1147
  topic_hint = str(data.get("topic", "") or "").strip()
1148
  detected_lang = detect_language_safe(raw_msg)
1149
- # Force reply language conservative: if likely English, reply in English
1150
- likely_en = (detected_lang in ("en", "eng", "") or (len((raw_msg or "").split()) <= 4 and re.fullmatch(r"[\x20-\x7E]+", raw_msg or "") is not None))
1151
- reply_lang = "en" if (detected_lang in ("und", "") or likely_en) else detected_lang
1152
  user_force_save = bool(data.get("save_memory", False))
1153
 
1154
- # Spell correction (optional)
1155
  if spell is not None:
1156
  try:
1157
  words = raw_msg.split()
@@ -1165,7 +1222,7 @@ async def chat(request: Request, data: dict = Body(...)):
1165
  else:
1166
  msg_corrected = raw_msg
1167
 
1168
- # Simple intent classifier
1169
  def classify_intent_local(text: str) -> str:
1170
  t = text.lower()
1171
  if any(k in t for k in ["why", "para qué", "por qué"]):
@@ -1192,7 +1249,7 @@ async def chat(request: Request, data: dict = Body(...)):
1192
  else:
1193
  topic = topic_hint
1194
 
1195
- # Load knowledge only for the resolved topic (topic-scoped enforcement)
1196
  try:
1197
  with engine.begin() as conn:
1198
  rows = conn.execute(sql_text("SELECT id, text, reply, language, embedding, topic FROM knowledge WHERE topic = :topic ORDER BY created_at DESC"), {"topic": topic}).fetchall()
@@ -1205,12 +1262,12 @@ async def chat(request: Request, data: dict = Body(...)):
1205
  knowledge_langs = [r[3] or "en" for r in rows]
1206
  knowledge_topics = [r[5] or "general" for r in rows]
1207
 
1208
- # Translate the user message to English if needed
1209
  en_msg = msg_corrected
1210
  if detected_lang and detected_lang.split("-")[0].lower() not in ("en", "eng", "", "und"):
1211
  en_msg = translate_to_english(msg_corrected, detected_lang)
1212
 
1213
- # Embedding-based retrieval, limited to this topic only
1214
  matches = []
1215
  confidence = 0.0
1216
  knowledge_embeddings = None
@@ -1242,7 +1299,7 @@ async def chat(request: Request, data: dict = Body(...)):
1242
  matches = [c for _, _, c in filtered]
1243
  confidence = filtered[0][1] if filtered else 0.0
1244
  else:
1245
- # fallback simple substring matching within topic texts only
1246
  for idx, ktext in enumerate(knowledge_texts):
1247
  ktext_lang = detect_language_safe(ktext)
1248
  ktext_en = translate_to_english(ktext, ktext_lang) if ktext_lang != "en" else ktext
@@ -1254,7 +1311,7 @@ async def chat(request: Request, data: dict = Body(...)):
1254
  matches = knowledge_replies[:3] if knowledge_replies else []
1255
  confidence = 0.0
1256
 
1257
- # Internal reasoning scratchpad: build structured notes from matches and message
1258
  def build_reasoning_scratchpad(question_en: str, facts_en: List[str]) -> Dict[str, Any]:
1259
  scratch = {
1260
  "question": question_en,
@@ -1290,17 +1347,12 @@ async def chat(request: Request, data: dict = Body(...)):
1290
  return "Solutions:\n- " + "\n- ".join(steps[:5])
1291
  if intent_label == "why":
1292
  return base + " It is useful because it provides direct access to relevant information and supports faster decision-making."
1293
- if intent_label == "advantage":
1294
- return base
1295
- if intent_label == "disadvantage":
1296
- return base
1297
  return base
1298
 
1299
- # Build scratchpad and synthesize answer (LLM not used for user reply)
1300
  scratchpad = build_reasoning_scratchpad(en_msg, matches)
1301
  reply_en = synthesize_from_scratchpad(scratchpad, intent)
1302
 
1303
- # Optional: ask local LLM for reflection to improve internal knowledge only (not for direct reply)
1304
  llm_suggestion = ""
1305
  try:
1306
  if llm_model and llm_tokenizer and matches:
@@ -1318,17 +1370,7 @@ async def chat(request: Request, data: dict = Body(...)):
1318
  logger.debug(f"LLM reflection error: {e}")
1319
  llm_suggestion = ""
1320
 
1321
- # Decision trace (not returned by default)
1322
- steps = []
1323
- if matches and confidence >= 0.6:
1324
- steps.append(f"Direct match with confidence={confidence:.2f}")
1325
- elif matches and confidence >= 0.35:
1326
- steps.append(f"Synthesized from top matches with confidence ~{confidence:.2f}")
1327
- else:
1328
- steps.append("Scratchpad synthesis")
1329
-
1330
- # Compose final reply using Justice Brain's internal synthesis logic
1331
- reply_en = ""
1332
  steps = []
1333
  if matches and confidence >= 0.6:
1334
  reply_en = matches[0]
@@ -1341,22 +1383,14 @@ async def chat(request: Request, data: dict = Body(...)):
1341
  if matches or llm_suggestion:
1342
  reply_en = synthesize_final_reply(en_msg, matches, llm_suggestion, intent, "en")
1343
  else:
1344
- if embed_model is not None and knowledge_embeddings is not None:
1345
- try:
1346
- reply_en = synthesize_final_reply(en_msg, [], llm_suggestion, intent, "en")
1347
- steps.append("Synthesized from limited items")
1348
- except Exception:
1349
- reply_en = "I don't have enough context yet — can you give more details?"
1350
- steps.append("Fallback related-items failure")
1351
- else:
1352
- reply_en = "I don't have enough context yet — can you give more details?"
1353
- steps.append("No embedding model available")
1354
  except Exception as e:
1355
  logger.warning(f"Synthesis error: {e}")
1356
  reply_en = "I don't have enough context yet — can you give more details?"
1357
  steps.append("Synthesis fallback")
1358
 
1359
- # Postprocess for intent (English-only; final translation happens once at the end)
1360
  def postprocess_for_intent_en(reply_text: str, intent_label: str) -> str:
1361
  if intent_label == "why":
1362
  suf = " It is useful because it provides direct access to relevant information and supports faster decision-making."
@@ -1384,12 +1418,9 @@ async def chat(request: Request, data: dict = Body(...)):
1384
  reply_en = postprocess_for_intent_en(reply_en, intent)
1385
  reply_en = dedupe_sentences(reply_en)
1386
 
1387
- # Mood & emoji handling
1388
- mood = detect_mood(raw_msg + " " + reply_en)
1389
- emoji = ""
1390
  flags = {}
1391
  try:
1392
- # Moderation (prevent toxic content from being saved)
1393
  if moderator is not None:
1394
  mod_result = moderator(raw_msg[:1024])
1395
  if isinstance(mod_result, list) and len(mod_result) > 0:
@@ -1400,11 +1431,12 @@ async def chat(request: Request, data: dict = Body(...)):
1400
  except Exception:
1401
  pass
1402
 
1403
- # Decide whether to append or echo emoji
 
 
1404
  try:
1405
  chosen_emoji = should_append_emoji(raw_msg, reply_en, mood, flags)
1406
  if chosen_emoji:
1407
- # Append safely (space separator) and ensure length constraint
1408
  if len(reply_en) + len(chosen_emoji) < 1200:
1409
  reply_en = reply_en + " " + chosen_emoji
1410
  emoji = chosen_emoji
@@ -1433,7 +1465,6 @@ async def chat(request: Request, data: dict = Body(...)):
1433
  "topic": topic,
1434
  }
1435
  )
1436
- # Keep recent / high-confidence per topic
1437
  conn.execute(
1438
  sql_text("""
1439
  DELETE FROM user_memory
@@ -1450,18 +1481,21 @@ async def chat(request: Request, data: dict = Body(...)):
1450
  except Exception as e:
1451
  logger.warning(f"user_memory persist error: {e}")
1452
 
1453
- # OPTIONAL: include steps for debugging only if requested (default: False)
 
 
 
 
 
 
 
 
 
 
1454
  include_steps = bool(data.get("include_steps", False))
1455
  if include_steps and steps:
1456
  reasoning_text = " | ".join(str(s) for s in steps)
1457
- reply_en = f"{reply_en}\n\n[Reasoning steps: {reasoning_text}]"
1458
-
1459
- # Always translate once at the end to the fixed user language target
1460
- if reply_lang and reply_lang.split("-")[0].lower() not in ("en", "eng"):
1461
- reply_final = translate_from_english(reply_en, reply_lang)
1462
- reply_final = dedupe_sentences(reply_final)
1463
- else:
1464
- reply_final = reply_en
1465
 
1466
  duration = time.time() - t0
1467
  record_request(duration)
 
1
  # JusticeAI Backend — Upgraded & Integrated (Backend-only; does NOT create or overwrite frontend)
2
  #
3
+ # This is the updated app.py requested: it prefers a local language model (language.py or language.bin),
4
+ # enforces strict topic scoping, preserves sentence boundaries (no run-on joining), understands and
5
+ # reasons about emojis, and uses the provided emojis.py when present.
 
 
 
 
 
6
  #
7
+ # Key behaviors:
8
+ # - Loads language.py if present; otherwise attempts to load language.bin (torch.load then pickle).
9
+ # - If the language module exposes translate/translate_to_en/translate_from_en/detect, those are used.
10
+ # - detect_language_safe will consult the language module for detection if available, then fall back to heuristics.
11
+ # - All knowledge retrieval and refinement in /chat is strictly within the resolved topic.
12
+ # - dedupe_sentences preserves sentences as separate lines and avoids turning them into run-ons.
13
+ # - Emoji extraction and a small emoji-sentiment heuristic are used to decide when to append/echo emojis.
14
+ # - Moderation prevents saving toxic memory and prevents adding emojis to responses flagged toxic.
15
+ #
16
+ # Place language.bin and/or language.py and emojis.py in the same folder as this file.
17
+ # Restart the app after placing those files.
18
 
19
  from sqlalchemy.pool import NullPool
20
  import os
 
53
  # ----- Optional helpers (soft fallbacks) -----
54
  # Prefer user's emojis.py
55
  try:
 
 
 
56
  from emojis import get_emoji, get_category_for_mood # type: ignore
57
  logger.info("[JusticeAI] Using local emojis.py")
58
  except Exception:
 
86
  AutoModelForCausalLM = None
87
  hf_pipeline = None
88
 
89
+ # ----- Local language loader (language.py or language.bin) -----
90
+ language_module = None
91
 
92
+ def load_local_language_module():
93
+ """
94
+ Attempt to import language.py first. If not present, attempt to load language.bin
95
+ via torch.load or pickle. The resulting object is stored in `language_module`.
96
+ The module/object should ideally expose:
97
+ - translate(text, src, tgt)
98
+ - translate_to_en(text, src)
99
+ - translate_from_en(text, tgt)
100
+ - detect(text) or detect_language(text)
101
+ - model_info() (optional)
102
+ """
103
+ global language_module
104
+ # Try language.py module import
105
  try:
106
+ import language as lm # type: ignore
107
+ language_module = lm
108
+ logger.info("[JusticeAI] Loaded language.py module")
109
  return
110
  except Exception:
111
  pass
112
+ # Try language.bin next (torch.load then pickle)
113
+ bin_path = Path("language.bin")
114
+ if bin_path.exists():
115
+ try:
116
  try:
117
+ language_module = torch.load(str(bin_path), map_location="cpu")
118
+ logger.info("[JusticeAI] Loaded language.bin via torch.load")
119
+ return
120
+ except Exception as e:
121
+ logger.info(f"[JusticeAI] torch.load failed for language.bin: {e}")
122
+ # fallback to pickle
123
+ import pickle
124
+ with open(bin_path, "rb") as f:
125
+ language_module = pickle.load(f)
126
+ logger.info("[JusticeAI] Loaded language.bin via pickle")
127
+ return
128
+ except Exception as e:
129
+ language_module = None
130
+ logger.warning(f"[JusticeAI] Failed to load language.bin: {e}")
131
+ else:
132
+ logger.info("[JusticeAI] No language.py or language.bin found in cwd")
133
 
134
+ # attempt early load
135
+ load_local_language_module()
136
 
137
  # ----- Config (env) -----
138
  ADMIN_KEY = os.environ.get("ADMIN_KEY")
 
305
  self.capabilities["embed_available"] = embed_model is not None
306
  self.capabilities["moderator"] = moderator is not None
307
  self.capabilities["llm_reflect"] = llm_model is not None and llm_tokenizer is not None
308
+ self.capabilities["translation"] = (AutoTokenizer is not None and AutoModelForSeq2SeqLM is not None) or (language_module is not None)
309
  self.capabilities["ann"] = False # FAISS not wired yet (scaffold)
310
  logger.info(f"[JusticeBrain] Capabilities: {self.capabilities}")
311
 
 
377
  s = s[1:-1].strip()
378
  return " ".join(s.split())
379
 
380
+ def dedupe_sentences(text: str) -> str:
381
  """
382
+ Split text into sentences (respecting existing newlines) and dedupe while preserving order.
383
+ Return a string where sentences are separated by single newlines (no joining into run-on paragraphs).
384
  """
385
  if not text:
386
  return text
387
  sentences = []
388
  seen = set()
389
+ # Respect explicit newlines
390
  for chunk in re.split(r'\n+', text):
391
+ # Split on punctuation boundaries but keep them
392
  parts = re.split(r'(?<=[.?!])\s+', chunk)
393
  for sent in parts:
394
  s = sent.strip()
 
400
  sentences.append(s)
401
  return "\n".join(sentences)
402
 
403
+ # Emoji detection and heuristics
 
404
  _EMOJI_PATTERN = re.compile(
405
  "["
406
  "\U0001F600-\U0001F64F" # emoticons
 
412
  "]+", flags=re.UNICODE
413
  )
414
 
 
 
 
 
 
 
 
 
 
 
415
  def extract_emojis(text: str) -> List[str]:
416
  if not text:
417
  return []
418
  return _EMOJI_PATTERN.findall(text)
419
 
420
  def emoji_sentiment_score(emojis: List[str]) -> float:
 
421
  if not emojis:
422
  return 0.0
423
  score = 0.0
424
+ total = 0
425
  for e in "".join(emojis):
426
  ord_val = ord(e)
427
+ total += 1
428
+ if 0x1F600 <= ord_val <= 0x1F64F:
429
+ # smiles a bit positive, frowns negative
430
  if ord_val in range(0x1F600, 0x1F607) or ord_val in range(0x1F60A, 0x1F60F):
431
  score += 1.0
432
  elif ord_val in range(0x1F61E, 0x1F626):
 
437
  score += 0.1
438
  else:
439
  score += 0.0
440
+ return max(-1.0, min(1.0, score / max(1, total)))
441
 
442
  def detect_language_safe(text: str) -> str:
443
  """
444
+ Prefer the local language module detection if available (language.detect or language.detect_language).
445
+ Then use greeting heuristics and Unicode ranges to detect CJK/JP. Conservative fallback is 'en'.
446
  """
447
  text = (text or "").strip()
448
  if not text:
449
  return "en"
450
+ # 1) local language module detection
451
+ try:
452
+ global language_module
453
+ if language_module is not None:
454
+ # Prefer explicit detect functions if provided
455
+ if hasattr(language_module, "detect_language"):
456
+ try:
457
+ lang = language_module.detect_language(text)
458
+ if lang:
459
+ return lang
460
+ except Exception:
461
+ pass
462
+ if hasattr(language_module, "detect"):
463
+ try:
464
+ lang = language_module.detect(text)
465
+ if lang:
466
+ return lang
467
+ except Exception:
468
+ pass
469
+ # Some wrappers expose model_info with detection capability indication
470
+ if hasattr(language_module, "model_info"):
471
+ try:
472
+ info = language_module.model_info()
473
+ # no rigid rule; if model_info exposes a 'detect' attribute we could try it
474
+ except Exception:
475
+ pass
476
+ except Exception:
477
+ pass
478
+
479
+ # 2) greeting/keyword heuristics
480
  lower = text.lower()
481
+ greeting_map = {
482
+ "hola": "es", "gracias": "es", "adios": "es",
483
+ "bonjour": "fr", "salut": "fr",
484
+ "hallo": "de", "guten morgen": "de",
485
+ "ciao": "it", "buongiorno": "it",
486
+ "olá": "pt", "obrigado": "pt",
487
+ "привет": "ru", "здравствуйте": "ru",
488
+ "こんにちは": "ja", "こんばんは": "ja",
489
+ "你好": "zh", "谢谢": "zh", "안녕하세요": "ko"
490
+ }
491
+ for k, v in greeting_map.items():
492
+ if k in lower:
493
+ return v
494
+
495
+ # 3) Unicode heuristics: Hiragana/Katakana -> Japanese, CJK -> Chinese, Hangul -> Korean
496
+ if re.search(r'[\u3040-\u30ff]', text):
497
+ return "ja"
498
+ if re.search(r'[\u4e00-\u9fff]', text):
499
+ return "zh"
500
+ if re.search(r'[\uac00-\ud7af]', text):
501
+ return "ko"
502
+
503
+ # 4) ASCII fallback: if text contains mostly ASCII letters and common english words, treat as 'en'
504
  letters = re.findall(r'[A-Za-z]', text)
505
  if len(letters) >= max(1, len(text) / 4):
506
  return "en"
507
+
508
+ # Conservative default
509
  return "und"
510
 
511
  def translate_to_english(text: str, src_lang: str) -> str:
512
  """
513
+ Use the local language module (language_module) if present. Otherwise fall back to Helsinki models.
 
514
  """
515
  if not text:
516
  return text
517
  src = (src_lang.split('-')[0].lower() if src_lang else "und")
518
  if src in ("en", "eng", "", "und"):
519
  return text
520
+ # prefer language_module
521
+ try:
522
+ global language_module
523
+ if language_module is not None:
524
+ if hasattr(language_module, "translate_to_en"):
525
+ try:
526
+ return language_module.translate_to_en(text, src)
527
+ except Exception:
528
+ pass
529
+ if hasattr(language_module, "translate"):
530
  try:
531
+ return language_module.translate(text, src, "en")
532
  except TypeError:
533
+ try:
534
+ return language_module.translate(text)
535
+ except Exception:
536
+ pass
537
+ # If language_module is an object with callable method
538
+ if hasattr(language_module, "__call__") and callable(language_module):
539
+ try:
540
+ return language_module(text, src, "en")
541
+ except Exception:
542
+ pass
543
+ except Exception as e:
544
+ logger.debug(f"Local language_module translate attempt failed: {e}")
545
+ # fallback to Helsinki/transformers if available
546
  if not re.fullmatch(r"[a-z]{2,3}", src):
547
  return text
548
  try:
 
559
  model_name = f"Helsinki-NLP/opus-mt-{src}-en"
560
  tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
561
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
562
+ _translation_model_cache[cache_key] = (tokenizer, model)
563
  inputs = tokenizer([text], return_tensors="pt", truncation=True)
564
  outputs = model.generate(**inputs, max_length=1024)
565
  return tokenizer.batch_decode(outputs, skip_special_tokens=True)[0]
 
578
 
579
  def translate_from_english(text: str, tgt_lang: str) -> str:
580
  """
581
+ Use the local language module if available; otherwise fall back to Helsinki/transformers.
582
  """
583
  if not text:
584
  return text
585
  tgt = (tgt_lang.split('-')[0].lower() if tgt_lang else "und")
586
  if tgt in ("en", "eng", "", "und"):
587
  return text
588
+ try:
589
+ global language_module
590
+ if language_module is not None:
591
+ if hasattr(language_module, "translate_from_en"):
 
592
  try:
593
+ return language_module.translate_from_en(text, tgt)
594
+ except Exception:
595
+ pass
596
+ if hasattr(language_module, "translate"):
597
+ try:
598
+ return language_module.translate(text, "en", tgt)
599
  except TypeError:
600
+ try:
601
+ return language_module.translate(text)
602
+ except Exception:
603
+ pass
604
+ if hasattr(language_module, "__call__") and callable(language_module):
605
+ try:
606
+ return language_module(text, "en", tgt)
607
+ except Exception:
608
+ pass
609
+ except Exception as e:
610
+ logger.debug(f"Local language_module translate_from_en attempt failed: {e}")
611
+ # fallback to Helsinki/transformers
612
  if not re.fullmatch(r"[a-z]{2,3}", tgt):
613
  return text
614
  try:
 
625
  model_name = f"Helsinki-NLP/opus-mt-en-{tgt}"
626
  tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
627
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name, cache_dir=TRANSLATION_CACHE_DIR)
628
+ _translation_model_cache[cache_key] = (tokenizer, model)
629
  inputs = tokenizer([text], return_tensors="pt", truncation=True)
630
  outputs = model.generate(**inputs, max_length=1024)
631
  return tokenizer.batch_decode(outputs, skip_special_tokens=True)[0]
 
671
  def generate_creative_reply(matches: List[str]) -> str:
672
  """
673
  Combine up to three matches into a concise reply.
674
+ Preserve sentence lines (no joining into run-ons).
675
  """
676
  clean = []
677
  seen = set()
 
680
  if not s or s in seen or is_boilerplate_candidate(s):
681
  continue
682
  seen.add(s)
 
683
  s = dedupe_sentences(s)
684
  clean.append(s)
685
  if not clean:
686
  return "I’m not sure yet."
687
  if len(clean) == 1:
688
  return clean[0]
 
689
  return "\n\n".join(clean[:3])
690
 
691
  def infer_topic_from_message(msg: str, known_topics=None) -> str:
 
708
 
709
  def refine_or_update(matches, new_text, new_reply, confidence, topic="general"):
710
  """
711
+ Update or insert knowledge but ONLY inside the given topic.
 
712
  """
713
  try:
714
  if embed_model is None:
 
732
  best_score = float(sims[best_idx])
733
  if best_score > 0.75:
734
  kid = ids[best_idx]
 
735
  with engine.begin() as conn:
736
  row = conn.execute(sql_text("SELECT meta FROM knowledge WHERE id = :id"), {"id": kid}).fetchone()
737
  is_manual = False
 
742
  except Exception:
743
  is_manual = False
744
  if is_manual and confidence < 0.85:
 
745
  with engine.begin() as conn:
746
  conn.execute(
747
  sql_text("UPDATE knowledge SET reply = :r, updated_at = CURRENT_TIMESTAMP WHERE id = :id"),
 
760
  sql_text("INSERT INTO knowledge (text, reply, language, embedding, category, topic, confidence, meta) VALUES (:t, :r, :lang, :e, 'learned', :topic, :conf, :meta)"),
761
  {"t": new_text, "r": new_reply or "", "lang": "en", "e": emb, "topic": topic, "conf": min(0.7, float(confidence)), "meta": json.dumps({"refined": True})}
762
  )
 
763
  global knowledge_version
764
  knowledge_version += 1
765
  except Exception as e:
 
767
 
768
  def detect_mood(text: str) -> str:
769
  """
770
+ Detect mood using words and emoji heuristics.
 
771
  """
772
  lower = (text or "").lower()
773
  positive = ["great", "thanks", "awesome", "happy", "love", "excellent", "cool", "yes", "good", "success", "helpful", "useful", "thank you"]
774
+ negative = ["sad", "bad", "problem", "angry", "hate", "fail", "no", "error", "not working", "disadvantage", "issue"]
 
775
  emojis = extract_emojis(text)
776
  e_score = emoji_sentiment_score(emojis)
777
  if any(w in lower for w in positive) or e_score > 0.3:
 
783
  def should_append_emoji(user_text: str, reply_text: str, mood: str, flags: Dict[str, Any]) -> str:
784
  """
785
  Decide whether to append/echo an emoji and which one.
786
+ Conservative rules to avoid inappropriate emoji use.
 
 
 
 
 
 
787
  """
788
  if flags.get("toxic"):
789
  return ""
790
+ # If reply already contains emoji, do not add
791
  if extract_emojis(reply_text):
792
  return ""
793
  user_emojis = extract_emojis(user_text)
794
  if user_emojis:
 
795
  user_score = emoji_sentiment_score(user_emojis)
796
  if user_score >= 0.2:
 
797
  try:
798
  cat = get_category_for_mood("positive")
799
  return get_emoji(cat, intensity=min(1.0, 0.5 + user_score))
800
  except Exception:
801
  return user_emojis[0] if user_emojis else ""
802
  elif user_score <= -0.2:
 
803
  return ""
804
  else:
 
805
  if len(reply_text) < 200:
806
  try:
807
  cat = get_category_for_mood("neutral")
 
809
  except Exception:
810
  return ""
811
  return ""
 
812
  if mood == "neutral":
813
  return ""
 
814
  if len(reply_text) > 400:
815
  return ""
 
816
  if re.search(r'[\{\}\[\]\(\)]', reply_text):
817
  return ""
818
  try:
 
823
 
824
  def synthesize_final_reply(en_msg: str, matches: List[str], llm_suggestion: str, intent: str, detected_lang: str) -> str:
825
  """
826
+ Combine knowledge matches and optional LLM suggestion into a final English reply.
827
+ Preserve lines, do not join sentences into run-ons.
 
828
  """
829
  pieces = []
830
  for m in matches:
 
837
  pieces.append(sent)
838
  if not pieces:
839
  return "I don't have enough context — could you add more details or add knowledge with /add?"
 
840
  reply = "\n\n".join(pieces[:3])
 
841
  if intent == "solution":
842
  bullets = []
843
  for p in re.split(r'\n+', reply):
 
856
  # ----- Startup: load models & background loops -----
857
  @app.on_event("startup")
858
  async def startup_event():
859
+ global embed_model, spell, moderator, llm_tokenizer, llm_model, startup_time, language_module
860
  t0 = time.time()
861
  logger.info("[JusticeAI] Starting component loading...")
862
 
 
925
  model_progress["llm"]["status"] = "error"
926
  logger.warning(f"[JusticeAI] Could not load local LLM: {e}")
927
 
928
+ # reload language module in case files were placed before startup
929
+ load_local_language_module()
930
+ if language_module is not None:
931
+ try:
932
+ if hasattr(language_module, "model_info"):
933
+ info = language_module.model_info()
934
+ logger.info(f"[JusticeAI] language module info: {info}")
935
+ else:
936
+ # attempt a small introspection
937
+ logger.info(f"[JusticeAI] language module type: {type(language_module)}")
938
+ except Exception as e:
939
+ logger.debug(f"[JusticeAI] language module introspect failed: {e}")
940
 
941
  startup_time = round(time.time() - t0, 2)
942
  logger.info(f"[JusticeAI] Startup completed in {startup_time}s")
 
963
  def background_learning_loop():
964
  while True:
965
  try:
 
966
  with engine.begin() as conn:
967
  mem_rows = conn.execute(sql_text("SELECT text, reply, topic, confidence FROM user_memory ORDER BY created_at DESC LIMIT 200")).fetchall()
968
  knowledge_rows = conn.execute(sql_text("SELECT text, reply, topic FROM knowledge ORDER BY created_at DESC LIMIT 200")).fetchall()
 
 
969
  if llm_model and llm_tokenizer and mem_rows:
970
  for mem in mem_rows:
971
  user_text = mem[0] or ""
 
976
  inputs = llm_tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512)
977
  outputs = llm_model.generate(**inputs, max_length=256, do_sample=True, temperature=0.7)
978
  suggestion = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)
 
979
  conf = float(mem[3] or 0)
980
  if suggestion and conf >= 0.2:
 
981
  refine_or_update([], user_text, suggestion, conf, topic=topic)
982
  logger.debug(f"[Background AGI] Refined knowledge for topic={topic}")
983
  except Exception as e:
984
  logger.debug(f"[Background AGI] LLM suggestion error for memory: {e}")
 
985
  record_learn_event()
986
  except Exception as e:
987
  logger.warning(f"[Background AGI] Learning loop error: {e}")
988
+ time.sleep(60)
 
989
  threading.Thread(target=background_learning_loop, daemon=True).start()
990
 
991
  # ----- Endpoints -----
992
  @app.get("/model-status")
993
  async def model_status():
994
  response_progress = {k: dict(v) for k, v in model_progress.items()}
995
+ lang_info = None
996
+ try:
997
+ if language_module is not None and hasattr(language_module, "model_info"):
998
+ lang_info = language_module.model_info()
999
+ except Exception:
1000
+ lang_info = {"info": "unavailable"}
1001
+ return {"model_loaded": embed_model is not None, "model_progress": response_progress, "model_load_times": model_load_times, "startup_time_s": startup_time, "language_module": lang_info}
1002
 
1003
  @app.get("/health")
1004
  async def health_check():
 
1107
  return JSONResponse(status_code=400, content={"error": "Text is required"})
1108
  detected = detect_language_safe(text_data)
1109
  if detected and detected.split("-")[0].lower() not in ("en", "eng", "und"):
1110
+ if AutoTokenizer is not None and AutoModelForSeq2SeqLM is not None or language_module is not None:
1111
  try:
1112
  text_data = translate_to_english(text_data, detected)
1113
  detected = "en"
 
1140
  sql_text("INSERT INTO knowledge (text, reply, language, category, topic, confidence, meta) VALUES (:t, :r, :lang, 'general', :topic, :conf, :meta)"),
1141
  {"t": text_data, "r": reply, "lang": "en", "topic": topic, "conf": 0.9, "meta": json.dumps({"manual": True})}
1142
  )
 
1143
  global knowledge_version
1144
  knowledge_version += 1
1145
  record_learn_event()
 
1194
  errors.append({"index": i, "error": str(e)})
1195
  return {"added": added, "errors": errors}
1196
 
1197
+ # ----- /chat endpoint -----
1198
  @app.post("/chat")
1199
  async def chat(request: Request, data: dict = Body(...)):
1200
  t0 = time.time()
 
1204
  user_id = hashlib.sha256(f"{user_ip}-{username}".encode()).hexdigest()
1205
  topic_hint = str(data.get("topic", "") or "").strip()
1206
  detected_lang = detect_language_safe(raw_msg)
1207
+ # If detection returns 'und', keep und; otherwise set reply_lang to detected language.
1208
+ reply_lang = detected_lang if detected_lang and detected_lang != "und" else "en"
 
1209
  user_force_save = bool(data.get("save_memory", False))
1210
 
1211
+ # Optional spell correction
1212
  if spell is not None:
1213
  try:
1214
  words = raw_msg.split()
 
1222
  else:
1223
  msg_corrected = raw_msg
1224
 
1225
+ # Intent classifier
1226
  def classify_intent_local(text: str) -> str:
1227
  t = text.lower()
1228
  if any(k in t for k in ["why", "para qué", "por qué"]):
 
1249
  else:
1250
  topic = topic_hint
1251
 
1252
+ # Load knowledge strictly for this topic only
1253
  try:
1254
  with engine.begin() as conn:
1255
  rows = conn.execute(sql_text("SELECT id, text, reply, language, embedding, topic FROM knowledge WHERE topic = :topic ORDER BY created_at DESC"), {"topic": topic}).fetchall()
 
1262
  knowledge_langs = [r[3] or "en" for r in rows]
1263
  knowledge_topics = [r[5] or "general" for r in rows]
1264
 
1265
+ # Translate the user message to English if needed (for retrieval/synthesis)
1266
  en_msg = msg_corrected
1267
  if detected_lang and detected_lang.split("-")[0].lower() not in ("en", "eng", "", "und"):
1268
  en_msg = translate_to_english(msg_corrected, detected_lang)
1269
 
1270
+ # Embedding-based retrieval (topic-scoped)
1271
  matches = []
1272
  confidence = 0.0
1273
  knowledge_embeddings = None
 
1299
  matches = [c for _, _, c in filtered]
1300
  confidence = filtered[0][1] if filtered else 0.0
1301
  else:
1302
+ # fallback: substring search inside topic texts
1303
  for idx, ktext in enumerate(knowledge_texts):
1304
  ktext_lang = detect_language_safe(ktext)
1305
  ktext_en = translate_to_english(ktext, ktext_lang) if ktext_lang != "en" else ktext
 
1311
  matches = knowledge_replies[:3] if knowledge_replies else []
1312
  confidence = 0.0
1313
 
1314
+ # Build scratchpad and synthesize
1315
  def build_reasoning_scratchpad(question_en: str, facts_en: List[str]) -> Dict[str, Any]:
1316
  scratch = {
1317
  "question": question_en,
 
1347
  return "Solutions:\n- " + "\n- ".join(steps[:5])
1348
  if intent_label == "why":
1349
  return base + " It is useful because it provides direct access to relevant information and supports faster decision-making."
 
 
 
 
1350
  return base
1351
 
 
1352
  scratchpad = build_reasoning_scratchpad(en_msg, matches)
1353
  reply_en = synthesize_from_scratchpad(scratchpad, intent)
1354
 
1355
+ # Optional LLM reflection for knowledge refinement (not for user reply)
1356
  llm_suggestion = ""
1357
  try:
1358
  if llm_model and llm_tokenizer and matches:
 
1370
  logger.debug(f"LLM reflection error: {e}")
1371
  llm_suggestion = ""
1372
 
1373
+ # Compose final reply (knowledge-first, topic-scoped)
 
 
 
 
 
 
 
 
 
 
1374
  steps = []
1375
  if matches and confidence >= 0.6:
1376
  reply_en = matches[0]
 
1383
  if matches or llm_suggestion:
1384
  reply_en = synthesize_final_reply(en_msg, matches, llm_suggestion, intent, "en")
1385
  else:
1386
+ reply_en = "I don't have enough context yet can you give more details?"
1387
+ steps.append("No relevant matches")
 
 
 
 
 
 
 
 
1388
  except Exception as e:
1389
  logger.warning(f"Synthesis error: {e}")
1390
  reply_en = "I don't have enough context yet — can you give more details?"
1391
  steps.append("Synthesis fallback")
1392
 
1393
+ # Postprocess for intent
1394
  def postprocess_for_intent_en(reply_text: str, intent_label: str) -> str:
1395
  if intent_label == "why":
1396
  suf = " It is useful because it provides direct access to relevant information and supports faster decision-making."
 
1418
  reply_en = postprocess_for_intent_en(reply_en, intent)
1419
  reply_en = dedupe_sentences(reply_en)
1420
 
1421
+ # Moderation check for user message (prevent saving toxic memory)
 
 
1422
  flags = {}
1423
  try:
 
1424
  if moderator is not None:
1425
  mod_result = moderator(raw_msg[:1024])
1426
  if isinstance(mod_result, list) and len(mod_result) > 0:
 
1431
  except Exception:
1432
  pass
1433
 
1434
+ # Mood & emoji: detect mood from user message and reply, then decide emoji
1435
+ mood = detect_mood(raw_msg + " " + reply_en)
1436
+ emoji = ""
1437
  try:
1438
  chosen_emoji = should_append_emoji(raw_msg, reply_en, mood, flags)
1439
  if chosen_emoji:
 
1440
  if len(reply_en) + len(chosen_emoji) < 1200:
1441
  reply_en = reply_en + " " + chosen_emoji
1442
  emoji = chosen_emoji
 
1465
  "topic": topic,
1466
  }
1467
  )
 
1468
  conn.execute(
1469
  sql_text("""
1470
  DELETE FROM user_memory
 
1481
  except Exception as e:
1482
  logger.warning(f"user_memory persist error: {e}")
1483
 
1484
+ # Translate final reply into user's language if needed (use language_module if available)
1485
+ reply_final = reply_en
1486
+ try:
1487
+ if reply_lang and reply_lang.split("-")[0].lower() not in ("en", "eng", "", "und"):
1488
+ reply_final = translate_from_english(reply_en, reply_lang)
1489
+ reply_final = dedupe_sentences(reply_final)
1490
+ except Exception as e:
1491
+ logger.debug(f"Final translation error: {e}")
1492
+ reply_final = reply_en
1493
+
1494
+ # Optional debug steps
1495
  include_steps = bool(data.get("include_steps", False))
1496
  if include_steps and steps:
1497
  reasoning_text = " | ".join(str(s) for s in steps)
1498
+ reply_final = f"{reply_final}\n\n[Reasoning steps: {reasoning_text}]"
 
 
 
 
 
 
 
1499
 
1500
  duration = time.time() - t0
1501
  record_request(duration)