ritesh19180 commited on
Commit
1ff1213
·
verified ·
1 Parent(s): 49658bb

Upload folder using huggingface_hub

Browse files
Frontend/src/admin/components/TicketTable.jsx CHANGED
@@ -73,6 +73,9 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
73
  ? ticket.assigned_team
74
  : (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
75
  const statusSt = getStatusStyle(ticket.status);
 
 
 
76
 
77
  // Truncated subject
78
  const subject = ticket.subject || ticket.summary || 'Untitled ticket';
@@ -129,9 +132,16 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
129
  <span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
130
  {truncSubject}
131
  </span>
132
- <span style={{ fontSize: '11px', color: '#6b7280' }}>
133
- {effectiveCategory || 'General'}
134
- </span>
 
 
 
 
 
 
 
135
  </div>
136
  </div>
137
  </td>
 
73
  ? ticket.assigned_team
74
  : (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
75
  const statusSt = getStatusStyle(ticket.status);
76
+ const translationMeta = ticket?.metadata?.translation;
77
+ const isTranslated = Boolean(translationMeta?.translated);
78
+ const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
79
 
80
  // Truncated subject
81
  const subject = ticket.subject || ticket.summary || 'Untitled ticket';
 
132
  <span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
133
  {truncSubject}
134
  </span>
135
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
136
+ <span style={{ fontSize: '11px', color: '#6b7280' }}>
137
+ {effectiveCategory || 'General'}
138
+ </span>
139
+ {isTranslated && (
140
+ <span style={{ fontSize: '10px', color: '#0369a1' }}>
141
+ Translated from {sourceLanguageName}
142
+ </span>
143
+ )}
144
+ </div>
145
  </div>
146
  </div>
147
  </td>
Frontend/src/admin/pages/AdminTicketDetail.jsx CHANGED
@@ -35,6 +35,7 @@ const AdminTicketDetail = () => {
35
  const [imageUrl, setImageUrl] = useState(null);
36
  const [isUpdating, setIsUpdating] = useState(null);
37
  const [isLive, setIsLive] = useState(false);
 
38
 
39
  const [correctionForm, setCorrectionForm] = useState({
40
  category: '',
@@ -198,6 +199,11 @@ const AdminTicketDetail = () => {
198
  const displayPriority = ticket.priority || 'Medium';
199
  const displaySummary = ticket.summary || ticket.subject || 'No Summary';
200
  const displayText = ticket.description || ticket.text || displaySummary;
 
 
 
 
 
201
 
202
  return (
203
  <div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
@@ -313,8 +319,22 @@ const AdminTicketDetail = () => {
313
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
314
  </div>
315
  <div style={{ padding: '28px' }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
317
- "{displayText}"
318
  </div>
319
 
320
  {imageUrl && (
 
35
  const [imageUrl, setImageUrl] = useState(null);
36
  const [isUpdating, setIsUpdating] = useState(null);
37
  const [isLive, setIsLive] = useState(false);
38
+ const [showOriginalText, setShowOriginalText] = useState(false);
39
 
40
  const [correctionForm, setCorrectionForm] = useState({
41
  category: '',
 
199
  const displayPriority = ticket.priority || 'Medium';
200
  const displaySummary = ticket.summary || ticket.subject || 'No Summary';
201
  const displayText = ticket.description || ticket.text || displaySummary;
202
+ const translationMeta = ticket.metadata?.translation;
203
+ const originalTextMeta = ticket.metadata?.original_text;
204
+ const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
205
+ const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
206
+ const renderedText = showOriginalText && isTranslated ? originalTextMeta.description : displayText;
207
 
208
  return (
209
  <div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
 
319
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
320
  </div>
321
  <div style={{ padding: '28px' }}>
322
+ {isTranslated && (
323
+ <div style={{ marginBottom: '16px', border: '1px solid #bae6fd', background: '#f0f9ff', borderRadius: '12px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
324
+ <span style={{ fontSize: '12px', fontWeight: 600, color: '#0c4a6e' }}>
325
+ Translated from {sourceLanguageName}
326
+ </span>
327
+ <button
328
+ type="button"
329
+ onClick={() => setShowOriginalText(prev => !prev)}
330
+ style={{ fontSize: '11px', fontWeight: 700, color: '#0369a1', background: 'transparent', border: 'none', cursor: 'pointer' }}
331
+ >
332
+ {showOriginalText ? 'View English' : 'View Original'}
333
+ </button>
334
+ </div>
335
+ )}
336
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
337
+ "{renderedText}"
338
  </div>
339
 
340
  {imageUrl && (
Frontend/src/admin/pages/AdminTickets.jsx CHANGED
@@ -340,6 +340,11 @@ const AdminTickets = () => {
340
  {ticket.category}
341
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
342
  </span>
 
 
 
 
 
343
  </div>
344
  </td>
345
 
 
340
  {ticket.category}
341
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
342
  </span>
343
+ {ticket?.metadata?.translation?.translated && (
344
+ <span className="text-[10px] text-sky-700 mt-1">
345
+ Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
346
+ </span>
347
+ )}
348
  </div>
349
  </td>
350
 
Frontend/src/user/components/RecentTickets.jsx CHANGED
@@ -150,6 +150,11 @@ const RecentTickets = () => {
150
  <p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
151
  {ticket.summary || ticket.subject || ticket.description || "No description provided"}
152
  </p>
 
 
 
 
 
153
  </td>
154
  <td style={{ padding: '16px 28px' }}>
155
  {getStatusBadge(ticket.status)}
 
150
  <p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
151
  {ticket.summary || ticket.subject || ticket.description || "No description provided"}
152
  </p>
153
+ {ticket?.metadata?.translation?.translated && (
154
+ <p style={{ fontSize: '11px', color: '#0369a1', margin: '4px 0 0' }}>
155
+ Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
156
+ </p>
157
+ )}
158
  </td>
159
  <td style={{ padding: '16px 28px' }}>
160
  {getStatusBadge(ticket.status)}
Frontend/src/user/pages/MyTickets.jsx CHANGED
@@ -120,6 +120,14 @@ function MyTickets() {
120
  return 'text-gray-600';
121
  };
122
 
 
 
 
 
 
 
 
 
123
  return (
124
  <main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
125
  {/* Header section */}
@@ -317,6 +325,11 @@ function MyTickets() {
317
  <p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
318
  {ticket.summary || ticket.subject || ticket.description || "No subject"}
319
  </p>
 
 
 
 
 
320
  </td>
321
  <td className="px-6 py-4">
322
  <span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
 
120
  return 'text-gray-600';
121
  };
122
 
123
+ const getTranslationInfo = (ticket) => {
124
+ const t = ticket?.metadata?.translation;
125
+ if (!t?.translated) return null;
126
+ return {
127
+ sourceLanguageName: t.source_language_name || t.source_language || 'Unknown',
128
+ };
129
+ };
130
+
131
  return (
132
  <main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
133
  {/* Header section */}
 
325
  <p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
326
  {ticket.summary || ticket.subject || ticket.description || "No subject"}
327
  </p>
328
+ {getTranslationInfo(ticket) && (
329
+ <p className="text-[10px] text-slate-500 mt-1">
330
+ Translated from {getTranslationInfo(ticket).sourceLanguageName}
331
+ </p>
332
+ )}
333
  </td>
334
  <td className="px-6 py-4">
335
  <span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
Frontend/src/user/pages/TicketDetail.jsx CHANGED
@@ -22,6 +22,7 @@ const TicketDetail = () => {
22
  const [isReopening, setIsReopening] = useState(false);
23
  const [showCsat, setShowCsat] = useState(false);
24
  const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
 
25
 
26
  useEffect(() => {
27
  window.scrollTo(0, 0);
@@ -122,6 +123,10 @@ const TicketDetail = () => {
122
  const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
123
  const isAutoResolved = ticket.auto_resolve === true;
124
  const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
 
 
 
 
125
 
126
 
127
  const handleReopen = async () => {
@@ -184,6 +189,27 @@ const TicketDetail = () => {
184
 
185
  {/* LEFT SIDE (Main Content) */}
186
  <div className="lg:col-span-2 flex flex-col gap-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  {/* Card 1: Ticket Timeline */}
189
  <Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
 
22
  const [isReopening, setIsReopening] = useState(false);
23
  const [showCsat, setShowCsat] = useState(false);
24
  const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
25
+ const [showOriginalText, setShowOriginalText] = useState(false);
26
 
27
  useEffect(() => {
28
  window.scrollTo(0, 0);
 
123
  const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
124
  const isAutoResolved = ticket.auto_resolve === true;
125
  const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
126
+ const translationMeta = ticket.metadata?.translation;
127
+ const originalTextMeta = ticket.metadata?.original_text;
128
+ const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
129
+ const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
130
 
131
 
132
  const handleReopen = async () => {
 
189
 
190
  {/* LEFT SIDE (Main Content) */}
191
  <div className="lg:col-span-2 flex flex-col gap-6">
192
+ {isTranslated && (
193
+ <Card className="p-4 rounded-2xl border border-sky-100 bg-sky-50/70 shadow-sm">
194
+ <div className="flex items-center justify-between gap-3">
195
+ <p className="text-sm font-semibold text-sky-900">
196
+ Translated from {sourceLanguageName}
197
+ </p>
198
+ <button
199
+ type="button"
200
+ onClick={() => setShowOriginalText(prev => !prev)}
201
+ className="text-xs font-bold text-sky-700 hover:text-sky-900"
202
+ >
203
+ {showOriginalText ? "View English" : "View Original"}
204
+ </button>
205
+ </div>
206
+ {showOriginalText && (
207
+ <p className="mt-3 text-sm text-slate-700 bg-white border border-sky-100 rounded-lg px-3 py-2">
208
+ {originalTextMeta?.description}
209
+ </p>
210
+ )}
211
+ </Card>
212
+ )}
213
 
214
  {/* Card 1: Ticket Timeline */}
215
  <Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
backend/main.py CHANGED
@@ -8,6 +8,7 @@ import os
8
  import sys
9
  import uuid
10
  import json
 
11
  import datetime
12
  import traceback
13
  import warnings
@@ -220,6 +221,11 @@ class TicketResponse(BaseModel):
220
  highlights: list[str] = []
221
  timeline: dict = {} # Map of step_name: timestamp
222
  env_metadata: dict = {} # IP, Hostname, Browser/OS
 
 
 
 
 
223
  version: str = "2.1.0-Neural-Diagnostic"
224
 
225
 
@@ -302,6 +308,78 @@ try:
302
  except ImportError:
303
  ocr_service = None
304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
  # ---------------------------------------------------------------------------
307
  # Lifespan (startup / shutdown)
@@ -650,6 +728,28 @@ async def save_ticket(request_body: TicketSaveRequest):
650
 
651
  logger = logging.getLogger(__name__)
652
  final_data = request_body.model_dump()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
 
654
  # Resolve tenant linkage from user profile with authorization validation.
655
  profile = {}
@@ -891,6 +991,8 @@ async def analyze_only(request_body: TicketRequest):
891
  and duplicate check before committing to a ticket creation.
892
  """
893
  text = request_body.text
 
 
894
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
895
  settings = get_system_settings(request_body.company)
896
  confidence_threshold = settings["ai_confidence_threshold"]
@@ -935,6 +1037,10 @@ async def analyze_only(request_body: TicketRequest):
935
  is_potential_duplicate=False,
936
  parent_ticket_id=None,
937
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
 
 
 
 
938
  )
939
 
940
  # --- Context & Environment ---
@@ -1043,7 +1149,13 @@ async def analyze_only(request_body: TicketRequest):
1043
  highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1044
  timeline=timeline,
1045
  env_metadata=env_metadata,
1046
- sla_breach_at=sla_breach_dt.isoformat() + "Z"
 
 
 
 
 
 
1047
  )
1048
 
1049
  @app.post("/ai/analyze_stream")
 
8
  import sys
9
  import uuid
10
  import json
11
+ import re
12
  import datetime
13
  import traceback
14
  import warnings
 
221
  highlights: list[str] = []
222
  timeline: dict = {} # Map of step_name: timestamp
223
  env_metadata: dict = {} # IP, Hostname, Browser/OS
224
+ sla_breach_at: str | None = None
225
+ original_text: str | None = None
226
+ source_language: str = "en"
227
+ source_language_name: str = "English"
228
+ was_translated: bool = False
229
  version: str = "2.1.0-Neural-Diagnostic"
230
 
231
 
 
308
  except ImportError:
309
  ocr_service = None
310
 
311
+ LANGUAGE_NAMES = {
312
+ "en": "English",
313
+ "es": "Spanish",
314
+ "de": "German",
315
+ "hi": "Hindi",
316
+ "fr": "French",
317
+ "it": "Italian",
318
+ "pt": "Portuguese",
319
+ "ja": "Japanese",
320
+ "ko": "Korean",
321
+ "zh": "Chinese",
322
+ "ar": "Arabic",
323
+ "ru": "Russian",
324
+ }
325
+
326
+ def _heuristic_language_detection(text: str) -> dict:
327
+ sample = (text or "").strip()
328
+ if not sample:
329
+ return {"code": "en", "name": "English"}
330
+ ascii_chars = sum(1 for c in sample if ord(c) < 128)
331
+ ratio = ascii_chars / max(len(sample), 1)
332
+ if ratio > 0.97:
333
+ return {"code": "en", "name": "English"}
334
+ return {"code": "unknown", "name": "Unknown"}
335
+
336
+ def detect_and_translate_ticket_text(text: str) -> dict:
337
+ original_text = (text or "").strip()
338
+ if not original_text:
339
+ return {
340
+ "text_for_analysis": text or "",
341
+ "source_language": "en",
342
+ "source_language_name": "English",
343
+ "was_translated": False,
344
+ "original_text": "",
345
+ }
346
+
347
+ detected = _heuristic_language_detection(original_text)
348
+ if gemini_service and getattr(gemini_service, "_initialized", False):
349
+ detected = gemini_service.detect_language(original_text)
350
+
351
+ source_code = str(detected.get("code", "en")).lower()
352
+ source_name = detected.get("name") or LANGUAGE_NAMES.get(source_code, source_code.upper())
353
+ if source_code in ("en", "eng"):
354
+ return {
355
+ "text_for_analysis": original_text,
356
+ "source_language": "en",
357
+ "source_language_name": "English",
358
+ "was_translated": False,
359
+ "original_text": original_text,
360
+ }
361
+
362
+ translated_text = original_text
363
+ if gemini_service and getattr(gemini_service, "_initialized", False):
364
+ translated_text = gemini_service.translate_to_english(original_text, source_name)
365
+
366
+ if not translated_text or translated_text.strip() == original_text:
367
+ return {
368
+ "text_for_analysis": original_text,
369
+ "source_language": source_code,
370
+ "source_language_name": source_name,
371
+ "was_translated": False,
372
+ "original_text": original_text,
373
+ }
374
+
375
+ return {
376
+ "text_for_analysis": translated_text.strip(),
377
+ "source_language": source_code,
378
+ "source_language_name": source_name,
379
+ "was_translated": True,
380
+ "original_text": original_text,
381
+ }
382
+
383
 
384
  # ---------------------------------------------------------------------------
385
  # Lifespan (startup / shutdown)
 
728
 
729
  logger = logging.getLogger(__name__)
730
  final_data = request_body.model_dump()
731
+ original_subject = final_data.get("subject", "") or ""
732
+ original_description = final_data.get("description", "") or ""
733
+
734
+ # Detect language and translate subject/description into English before downstream routing/indexing.
735
+ translation_probe_text = (original_description.strip() or original_subject.strip())
736
+ translation_ctx = detect_and_translate_ticket_text(translation_probe_text)
737
+ metadata = final_data.get("metadata") or {}
738
+ if translation_ctx["was_translated"]:
739
+ translated_subject = gemini_service.translate_to_english(original_subject, translation_ctx["source_language_name"]) if original_subject else original_subject
740
+ translated_description = gemini_service.translate_to_english(original_description, translation_ctx["source_language_name"]) if original_description else original_description
741
+ final_data["subject"] = translated_subject or original_subject
742
+ final_data["description"] = translated_description or original_description
743
+ metadata["original_text"] = {
744
+ "subject": original_subject,
745
+ "description": original_description,
746
+ }
747
+ metadata["translation"] = {
748
+ "translated": bool(translation_ctx["was_translated"]),
749
+ "source_language": translation_ctx["source_language"],
750
+ "source_language_name": translation_ctx["source_language_name"],
751
+ }
752
+ final_data["metadata"] = metadata
753
 
754
  # Resolve tenant linkage from user profile with authorization validation.
755
  profile = {}
 
991
  and duplicate check before committing to a ticket creation.
992
  """
993
  text = request_body.text
994
+ translation_ctx = detect_and_translate_ticket_text(text)
995
+ text = translation_ctx["text_for_analysis"]
996
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
997
  settings = get_system_settings(request_body.company)
998
  confidence_threshold = settings["ai_confidence_threshold"]
 
1037
  is_potential_duplicate=False,
1038
  parent_ticket_id=None,
1039
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
1040
+ original_text=request_body.text,
1041
+ source_language=translation_ctx["source_language"],
1042
+ source_language_name=translation_ctx["source_language_name"],
1043
+ was_translated=translation_ctx["was_translated"],
1044
  )
1045
 
1046
  # --- Context & Environment ---
 
1149
  highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1150
  timeline=timeline,
1151
  env_metadata=env_metadata,
1152
+ is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1153
+ parent_ticket_id=dup_result.get("parent_ticket_id"),
1154
+ sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
1155
+ original_text=translation_ctx["original_text"],
1156
+ source_language=translation_ctx["source_language"],
1157
+ source_language_name=translation_ctx["source_language_name"],
1158
+ was_translated=translation_ctx["was_translated"],
1159
  )
1160
 
1161
  @app.post("/ai/analyze_stream")
backend/services/gemini_service.py CHANGED
@@ -2,6 +2,7 @@ import os
2
  import base64
3
  import io
4
  import re
 
5
  from PIL import Image
6
  from google import genai
7
  from dotenv import load_dotenv
@@ -222,3 +223,65 @@ class GeminiService:
222
  except Exception as e:
223
  print(f"[GeminiService] Bug Analysis Error: {e}")
224
  return f"Diagnostic analysis failed: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import base64
3
  import io
4
  import re
5
+ import json
6
  from PIL import Image
7
  from google import genai
8
  from dotenv import load_dotenv
 
223
  except Exception as e:
224
  print(f"[GeminiService] Bug Analysis Error: {e}")
225
  return f"Diagnostic analysis failed: {str(e)}"
226
+
227
+ def detect_language(self, text: str) -> dict:
228
+ """
229
+ Detect language for the given text. Returns ISO-ish language code and English language name.
230
+ """
231
+ if not text or not text.strip():
232
+ return {"code": "en", "name": "English"}
233
+ if not self._initialized:
234
+ return {"code": "en", "name": "English"}
235
+
236
+ try:
237
+ prompt = (
238
+ "Detect the natural language of the following user message. "
239
+ "Return strict JSON only with keys: code, name. "
240
+ "Example: {\"code\":\"es\",\"name\":\"Spanish\"}.\n\n"
241
+ f"Text:\n{text}"
242
+ )
243
+ response = self.client.models.generate_content(
244
+ model=self.model_name,
245
+ contents=prompt
246
+ )
247
+ raw = (response.text or "").strip()
248
+ match = re.search(r"\{.*\}", raw, re.DOTALL)
249
+ parsed = json.loads(match.group(0) if match else raw)
250
+ code = str(parsed.get("code", "en")).lower()
251
+ name = str(parsed.get("name", "English"))
252
+ if not code:
253
+ code = "en"
254
+ if not name:
255
+ name = "English"
256
+ return {"code": code, "name": name}
257
+ except Exception as e:
258
+ print(f"[GeminiService] Language detection error: {e}")
259
+ return {"code": "en", "name": "English"}
260
+
261
+ def translate_to_english(self, text: str, source_language: str | None = None) -> str:
262
+ """
263
+ Translate user text to English while preserving technical terms.
264
+ """
265
+ if not text or not text.strip():
266
+ return text
267
+ if not self._initialized:
268
+ return text
269
+
270
+ try:
271
+ lang_hint = f"Source language: {source_language}. " if source_language else ""
272
+ prompt = (
273
+ "Translate the following support ticket text to natural, concise English. "
274
+ "Preserve technical terms, error codes, product names, and formatting. "
275
+ "Return only translated text with no prefix or explanation. "
276
+ f"{lang_hint}\n\n"
277
+ f"Text:\n{text}"
278
+ )
279
+ response = self.client.models.generate_content(
280
+ model=self.model_name,
281
+ contents=prompt
282
+ )
283
+ translated = (response.text or "").strip()
284
+ return translated or text
285
+ except Exception as e:
286
+ print(f"[GeminiService] Translation error: {e}")
287
+ return text