Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/data/case_history_cache.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"ticket_id": "t-001",
"text": "I cannot connect to WiFi"
},
{
"ticket_id": "t-002",
"text": "VPN authentication fails repeatedly"
},
{
"ticket_id": "t-003",
"text": "VPN connection keeps dropping"
}
]
95 changes: 51 additions & 44 deletions backend/services/duplicate_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import uuid
import os
import threading
import tempfile
import torch
from sentence_transformers import SentenceTransformer, util

SIMILARITY_THRESHOLD = 0.70
Expand All @@ -19,6 +22,8 @@ def __init__(self):
self._tickets: list[tuple[str, object, str]] = []
self.storage_file = os.path.join(os.path.dirname(__file__), "..", "data", "case_history_cache.json")
os.makedirs(os.path.dirname(self.storage_file), exist_ok=True)
self._lock = threading.Lock()
self._file_lock = threading.Lock()

def is_available(self) -> bool:
"""Check if the model is available for duplicate detection."""
Expand Down Expand Up @@ -47,10 +52,11 @@ def load(self):
try:
with open(self.storage_file, "r") as f:
data = json.load(f)
for item in data:
text = item["text"]
embedding = self.model.encode(text, convert_to_tensor=True)
self._tickets.append((item["ticket_id"], embedding, text))
with self._lock:
for item in data:
text = item["text"]
embedding = self.model.encode(text, convert_to_tensor=True)
self._tickets.append((item["ticket_id"], embedding, text))
print(f"[DuplicateService] Loaded {len(self._tickets)} tickets.")
except Exception as e:
print(f"[DuplicateService] Error loading storage: {e}")
Expand All @@ -68,24 +74,28 @@ def load(self):
def save_to_disk(self, ticket_id: str, text: str):
"""Append a new ticket to the JSON storage."""
import json
data = []
try:
os.makedirs(os.path.dirname(self.storage_file), exist_ok=True)
with self._file_lock:
data = []
if os.path.exists(self.storage_file):
with open(self.storage_file, "r") as f:
try:
data = json.load(f)
if not isinstance(data, list):
data = []
except:
except json.JSONDecodeError:
data = []

data.append({"ticket_id": ticket_id, "text": text})
with open(self.storage_file, "w") as f:
json.dump(data, f, indent=2)
print(f"[DuplicateService] Indexed ticket {ticket_id} to case history.")
except Exception as e:
print(f"[DuplicateService] Failed to save to disk: {e}")
tmp_fd, tmp_path = tempfile.mkstemp(
dir=os.path.dirname(self.storage_file), suffix=".tmp"
)
try:
with os.fdopen(tmp_fd, "w") as tf:
json.dump(data, tf, indent=2)
os.replace(tmp_path, self.storage_file)
print(f"[DuplicateService] Indexed ticket {ticket_id} to case history.")
except Exception as e:
os.unlink(tmp_path)
print(f"[DuplicateService] Failed to save to disk: {e}")
raise

def add_ticket(self, ticket_id: str, text: str):
"""Add a ticket to the in-memory store and persist to disk."""
Expand All @@ -94,7 +104,8 @@ def add_ticket(self, ticket_id: str, text: str):
print(f"[DuplicateService] DEGRADED: Skipping embedding for ticket {ticket_id} (model not available)")
return
embedding = self.model.encode(text, convert_to_tensor=True)
self._tickets.append((ticket_id, embedding, text))
with self._lock:
self._tickets.append((ticket_id, embedding, text))
self.save_to_disk(ticket_id, text)

def check_duplicate(self, text: str, threshold: float = None) -> dict:
Expand All @@ -109,45 +120,41 @@ def check_duplicate(self, text: str, threshold: float = None) -> dict:
{
"is_duplicate": bool,
"duplicate_ticket_id": str | None,
"similarity": float
}
"similarity_score": float
} or None if unavailable/empty or below threshold
"""
self.load()

# If model is not available, return no duplicate found
if not self.is_available():
print("[DuplicateService] DEGRADED: Duplicate check skipped (model not available)")
return {
"is_duplicate": False,
"duplicate_ticket_id": None,
"similarity": 0.0,
}
return None

# Use provided threshold or default to global constant
active_threshold = threshold if threshold is not None else SIMILARITY_THRESHOLD

if not self._tickets:
return {
"is_duplicate": False,
"duplicate_ticket_id": None,
"similarity": 0.0,
}

query_embedding = self.model.encode(text, convert_to_tensor=True)
with self._lock:
if not self._tickets:
return None
tickets_snapshot = list(self._tickets)

best_score = 0.0
best_id = None
new_emb = self.model.encode(text, convert_to_tensor=True)
embeddings = [e for _, e, _ in tickets_snapshot]
stacked = torch.stack(embeddings)

# util.pytorch_cos_sim returns an N x M matrix
scores = util.pytorch_cos_sim(new_emb, stacked)[0]

best_score_val, best_idx = torch.max(scores, dim=0)
best_score = best_score_val.item()

for ticket_id, stored_emb, _ in self._tickets:
score = util.cos_sim(query_embedding, stored_emb).item()
if score > best_score:
best_score = score
best_id = ticket_id
if best_score >= active_threshold:
return {
"is_duplicate": True,
"duplicate_ticket_id": tickets_snapshot[best_idx.item()][0],
"similarity_score": round(best_score, 4),
}

is_dup = best_score >= active_threshold
return None

return {
"is_duplicate": is_dup,
"duplicate_ticket_id": best_id if is_dup else None,
"similarity": round(best_score, 4),
}
duplicate_service = DuplicateService()
1 change: 1 addition & 0 deletions backend/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Empty init file
33 changes: 33 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import sys
from unittest.mock import MagicMock, patch
import torch
import pytest

# Mock sentence_transformers at module level
_mock_st = MagicMock()
_mock_model = MagicMock()

def _fake_encode(text, convert_to_tensor=False):
import hashlib, torch
if 'VPN' in text:
seed = 42
else:
seed = sum(ord(c) for c in text)
Comment on lines +12 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The encoder mock creates tie scores, so “best-match” behavior is not truly validated.

Line 12-15 maps every VPN-containing text to the same seed/embedding. That makes multiple VPN tickets indistinguishable and can hide regressions in ranking logic.

💡 Suggested fix
 def _fake_encode(text, convert_to_tensor=False):
-    import hashlib, torch
-    if 'VPN' in text:
-        seed = 42
-    else:
-        seed = sum(ord(c) for c in text)
+    import hashlib, torch
+    digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
+    seed = int(digest[:8], 16)
     torch.manual_seed(seed)
     return torch.nn.functional.normalize(torch.randn(1, 384), dim=1).squeeze(0)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if 'VPN' in text:
seed = 42
else:
seed = sum(ord(c) for c in text)
def _fake_encode(text, convert_to_tensor=False):
import hashlib, torch
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
seed = int(digest[:8], 16)
torch.manual_seed(seed)
return torch.nn.functional.normalize(torch.randn(1, 384), dim=1).squeeze(0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/conftest.py` around lines 12 - 15, The current special-case
branch "if 'VPN' in text" assigns the same seed value and therefore identical
embeddings for all VPN-containing inputs; change the seed derivation so it's
still deterministic but varies per input (e.g., incorporate the full text or a
hash of the text instead of a fixed 42, or combine 42 with sum(ord(c) for c in
text) or the ticket id) so different VPN tickets produce different embeddings;
update the code around the "if 'VPN' in text'" branch and the variable "seed"
used by the mock encoder so embeddings remain stable yet distinct for different
texts.

torch.manual_seed(seed)
return torch.nn.functional.normalize(torch.randn(1, 384), dim=1).squeeze(0)

_mock_model.encode.side_effect = _fake_encode
_mock_st.SentenceTransformer.return_value = _mock_model
_mock_st.util.pytorch_cos_sim = lambda a, b: torch.nn.functional.cosine_similarity(
a.unsqueeze(0), b, dim=1
).unsqueeze(0)
sys.modules["sentence_transformers"] = _mock_st

@pytest.fixture
def dup_svc():
from backend.services.duplicate_service import DuplicateService
svc = DuplicateService()
svc._loaded = True
svc._load_failed = False
svc.model = _mock_model
return svc
61 changes: 61 additions & 0 deletions backend/tests/test_duplicate_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import pytest
import torch
from unittest.mock import MagicMock, patch

# ─── 1. Empty store returns None ────────────────────────────────────────────
def test_check_duplicate_empty_store_returns_none(dup_svc):
result = dup_svc.check_duplicate("VPN is not working")
assert result is None, "Empty store must return None, not raise RuntimeError"

# ─── 2. Exact duplicate detected ────────────────────────────────────────────
def test_exact_duplicate_detected(dup_svc, tmp_path):
dup_svc.storage_file = str(tmp_path / "cache.json")
dup_svc.add_ticket("t-001", "VPN is not working")
result = dup_svc.check_duplicate("VPN is not working")
assert result is not None
assert result["duplicate_ticket_id"] == "t-001"
assert result["similarity_score"] >= 0.99

# ─── 3. Below-threshold text returns None ───────────────────────────────────
def test_below_threshold_returns_none(dup_svc, tmp_path):
dup_svc.storage_file = str(tmp_path / "cache.json")
dup_svc.add_ticket("t-001", "VPN is not working")
result = dup_svc.check_duplicate("The printer has no paper")
assert result is None

# ─── 4. Picks highest similarity among multiple tickets ──────────────────────
def test_picks_best_match_among_multiple(dup_svc, tmp_path):
dup_svc.storage_file = str(tmp_path / "cache.json")
dup_svc.add_ticket("t-001", "I cannot connect to WiFi")
dup_svc.add_ticket("t-002", "VPN authentication fails repeatedly")
dup_svc.add_ticket("t-003", "VPN connection keeps dropping")
result = dup_svc.check_duplicate("VPN keeps disconnecting")
assert result is not None
assert result["duplicate_ticket_id"] in ("t-002", "t-003")

# ─── 5. Unavailable service returns None ────────────────────────────────────
def test_unavailable_returns_none(dup_svc):
dup_svc._loaded = False
result = dup_svc.check_duplicate("test")
assert result is None

# ─── 6. Thread safety — concurrent add+check does not raise ─────────────────
def test_concurrent_add_check_no_exception(dup_svc, tmp_path):
import threading
dup_svc.storage_file = str(tmp_path / "cache.json")
errors = []
def add(i):
try:
dup_svc.add_ticket(f"t-{i}", f"ticket text {i}")
except Exception as e:
errors.append(e)
def check():
try:
dup_svc.check_duplicate("ticket text 5")
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=add, args=(i,)) for i in range(20)]
threads += [threading.Thread(target=check) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Split one-line for statements to satisfy Ruff E701.

Line 59 and Line 60 currently use multiple statements on one line; this violates configured lint rules and can fail CI.

💡 Suggested fix
-    for t in threads: t.start()
-    for t in threads: t.join()
+    for t in threads:
+        t.start()
+    for t in threads:
+        t.join()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for t in threads: t.start()
for t in threads: t.join()
for t in threads:
t.start()
for t in threads:
t.join()
🧰 Tools
🪛 Ruff (0.15.15)

[error] 59-59: Multiple statements on one line (colon)

(E701)


[error] 60-60: Multiple statements on one line (colon)

(E701)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_duplicate_service.py` around lines 59 - 60, The two
one-line for loops using the variable threads (for t in threads: t.start() and
for t in threads: t.join()) should be expanded to multi-line for statements to
satisfy Ruff E701; replace each single-line form with a normal block (for t in
threads: then newline and an indented t.start()) and do the same for t.join() so
the loops are on separate lines and no multiple statements appear on one line.

Source: Linters/SAST tools

assert not errors, f"Thread safety violation: {errors}"
Loading