Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Frontend/src/admin/components/TicketTable.jsx +13 -3
- Frontend/src/admin/pages/AdminTicketDetail.jsx +21 -1
- Frontend/src/admin/pages/AdminTickets.jsx +5 -0
- Frontend/src/user/components/RecentTickets.jsx +5 -0
- Frontend/src/user/pages/MyTickets.jsx +13 -0
- Frontend/src/user/pages/TicketDetail.jsx +26 -0
- backend/main.py +113 -1
- backend/services/gemini_service.py +63 -0
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 |
-
<
|
| 133 |
-
{
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"{
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|