from datetime import datetime from difflib import SequenceMatcher from pathlib import Path import re import sys import time import unicodedata import psycopg2 from psycopg2.extras import DictCursor, Json ROOT_DIR = Path(__file__).resolve().parents[1] CONN_DIR = ROOT_DIR / "conn" if str(CONN_DIR) not in sys.path: sys.path.insert(0, str(CONN_DIR)) import config import mysql5 from ai_index import clean_text, ensure_ai_schema, extract_codes, refresh_feedback_summary MIN_SCORE = 0.07 CONFIDENCE_MIN_SCORE = { "bassa": MIN_SCORE, "media": 0.18, "alta": 0.35, } DEFAULT_LIMIT = 5 DIVERSITY_POOL_MULTIPLIER = 8 MAX_SOLUTION_TOKEN_SIMILARITY = 0.62 MAX_SOLUTION_TOKEN_OVERLAP = 0.65 MAX_SOLUTION_TEXT_SIMILARITY = 0.70 _SCHEMA_READY = False TOKEN_RE = re.compile(r"\b\w{3,}\b", re.IGNORECASE) SIGNATURE_SPACE_RE = re.compile(r"\s+") def get_pg_connection(): return psycopg2.connect(**config.POSTGRES_CONFIG) def ensure_runtime_schema(): global _SCHEMA_READY if not _SCHEMA_READY: ensure_ai_schema() _SCHEMA_READY = True def _id_value(value): if value is None or value == "": return None try: return int(value) except (TypeError, ValueError): return None def _normalize_int_list(value): if value is None: return [] if not isinstance(value, (list, tuple, set)): value = [value] values = [] for item in value: parsed = _id_value(item) if parsed is not None and parsed not in values: values.append(parsed) return values def _query_text(ticket): parts = [ ticket.get("subject"), ticket.get("problem"), ticket.get("product"), ticket.get("area"), ticket.get("subarea"), ticket.get("competence"), ticket.get("program"), ticket.get("program_type"), ] codes = extract_codes(*parts) cleaned = clean_text(" ".join(str(p) for p in parts if p)) return " ".join(part for part in (cleaned, " ".join(codes)) if part) def _confidence(score): if score >= CONFIDENCE_MIN_SCORE["alta"]: return "Alta" if score >= CONFIDENCE_MIN_SCORE["media"]: return "Media" return "Bassa" def _min_score_from_filters(filters): filters = filters or {} if filters.get("min_score") is not None: try: return float(filters["min_score"]) except (TypeError, ValueError): return MIN_SCORE confidence = str(filters.get("min_confidence") or "").lower() return CONFIDENCE_MIN_SCORE.get(confidence, MIN_SCORE) def _solution_tokens(solution): text = clean_text(solution).lower() return set(TOKEN_RE.findall(text)) def _solution_signature(solution): text = clean_text(solution).lower() text = "".join( char for char in unicodedata.normalize("NFKD", text) if not unicodedata.combining(char) ) text = re.sub(r"\b(gentile|buongiorno|ciao|salve)\b", " ", text) text = re.sub(r"\b(cliente|mauro|martina|lisa|cinzia|nadia)\b", " ", text) return SIGNATURE_SPACE_RE.sub(" ", text).strip() def _jaccard_similarity(left, right): if not left or not right: return 0 return len(left & right) / len(left | right) def _overlap_similarity(left, right): if not left or not right: return 0 return len(left & right) / min(len(left), len(right)) def _matches_filter(row, filters, key, row_key): values = _normalize_int_list((filters or {}).get(key)) return values and row.get(row_key) in values def _passes_strict_metadata_filters(row, filters): filters = filters or {} filter_columns = ( ("product_ids", "product_id"), ("area_ids", "area_id"), ("subarea_ids", "subarea_id"), ("competence_ids", "competence_id"), ("client_ids", "client_id"), ) for key, column in filter_columns: values = _normalize_int_list(filters.get(key)) if not values: values = _normalize_int_list(filters.get(column)) if values and row.get(column) not in values: return False excluded_clients = _normalize_int_list(filters.get("exclude_client_ids")) if not excluded_clients: excluded_clients = _normalize_int_list(filters.get("exclude_client_id")) if excluded_clients and row.get("client_id") in excluded_clients: return False return True def _diverse_suggestions(rows, query_ticket, limit, filters=None): selected = [] selected_solution_tokens = [] selected_solution_signatures = [] duplicates_skipped = 0 min_score = _min_score_from_filters(filters) for row in rows: row_dict = dict(row) if not _passes_strict_metadata_filters(row_dict, filters): continue if row_dict["total_score"] < min_score: continue solution_tokens = _solution_tokens(row_dict.get("solution_preview")) solution_signature = _solution_signature(row_dict.get("solution_preview")) is_duplicate = any( _jaccard_similarity(solution_tokens, existing_tokens) >= MAX_SOLUTION_TOKEN_SIMILARITY or _overlap_similarity(solution_tokens, existing_tokens) >= MAX_SOLUTION_TOKEN_OVERLAP or SequenceMatcher(None, solution_signature, existing_signature).ratio() >= MAX_SOLUTION_TEXT_SIMILARITY for existing_tokens, existing_signature in zip(selected_solution_tokens, selected_solution_signatures) ) if is_duplicate: duplicates_skipped += 1 continue row_dict["confidence"] = _confidence(row_dict["total_score"]) row_dict["reasons"] = _reasons(row_dict, query_ticket, filters) row_dict["duplicates_skipped_before"] = duplicates_skipped selected.append(row_dict) selected_solution_tokens.append(solution_tokens) selected_solution_signatures.append(solution_signature) if len(selected) >= limit: break return selected def _reasons(row, query_ticket=None, filters=None): query_ticket = query_ticket or {} filters = filters or {} reasons = [] if row["lexical_score"] >= 0.08: reasons.append("termini esatti") if row["trigram_score"] >= 0.12: reasons.append("testo simile") if row["code_score"] > 0: reasons.append("stesso codice") if _matches_filter(row, filters, "product_ids", "product_id"): reasons.append("filtro prodotto") elif row["product_id"] is not None and row["product_id"] == _id_value(query_ticket.get("product_id")): reasons.append("stesso prodotto") if _matches_filter(row, filters, "subarea_ids", "subarea_id"): reasons.append("filtro sottoarea") elif row["subarea_id"] is not None and row["subarea_id"] == _id_value(query_ticket.get("subarea_id")): reasons.append("stessa sottoarea") elif _matches_filter(row, filters, "area_ids", "area_id"): reasons.append("filtro area") elif row["area_id"] is not None and row["area_id"] == _id_value(query_ticket.get("area_id")): reasons.append("stessa area") if _matches_filter(row, filters, "competence_ids", "competence_id"): reasons.append("filtro competenza") elif row["competence_id"] is not None and row["competence_id"] == _id_value(query_ticket.get("competence_id")): reasons.append("stessa competenza") if _matches_filter(row, filters, "client_ids", "client_id"): reasons.append("stesso cliente") if row["feedback_score"] > 0: reasons.append("feedback positivo") if filters.get("recent_months"): reasons.append("periodo recente") if not reasons: reasons.append("somiglianza generale") return reasons[:5] def _format_date(value): if not value: return "-" if hasattr(value, "strftime"): return value.strftime("%Y-%m-%d") return str(value) def _format_suggestion(query_ticket_id, suggestion, rank): ticket_id = suggestion["ticket_id"] link = f"https://tsnew.sanmarcoweb.com/it/ticket/index/index/operation/view/id/{ticket_id}" product = suggestion.get("product") or "-" area = suggestion.get("area") or "-" subarea = suggestion.get("subarea") or "-" competence = suggestion.get("competence") or "-" client = suggestion.get("client") or "-" confidence = suggestion["confidence"] reasons = ", ".join(suggestion["reasons"]) solution = suggestion.get("solution_preview") or "Soluzione non disponibile" diversity_note = "" if suggestion.get("duplicates_skipped_before"): diversity_note = f"Alternative simili saltate: {suggestion['duplicates_skipped_before']}\n" feedback_line = "" if query_ticket_id: feedback_line = f"Feedback: /utile_{query_ticket_id}_{ticket_id} /non_utile_{query_ticket_id}_{ticket_id}\n" return ( f"\n\n----- {rank}. Confidenza {confidence} -----\n" f"[ /Ticket_dettaglio_{ticket_id} ]\n" f"{link}\n" f"Cliente: {client}\n" f"Prodotto: {product} | Competenza: {competence}\n" f"Area: {area} | Sottoarea: {subarea}\n" f"Apertura: {_format_date(suggestion.get('opened_at'))}\n" f"Perche: {reasons}\n" f"{diversity_note}" f"Score: {suggestion['total_score']:.3f}\n" f"SOLUZIONE: {solution}\n" f"{feedback_line}" ) def _has_metadata_filters(filters): filters = filters or {} return any( _normalize_int_list(filters.get(key)) for key in ("product_ids", "area_ids", "subarea_ids", "competence_ids", "client_ids") ) def _metadata_candidate_clause(ticket, filters=None): clauses = [] params = {} for field in ("product_id", "area_id", "subarea_id", "competence_id"): value = _id_value((ticket or {}).get(field)) params[field] = value if value is not None: clauses.append(f"i.{field} = %({field})s") if _has_metadata_filters(filters): clauses.append("TRUE") if not clauses: clauses.append("FALSE") return " OR ".join(clauses), params def _strict_filter_clause(filters): filters = filters or {} clauses = [] params = {} filter_columns = ( ("product_ids", "product_id"), ("area_ids", "area_id"), ("subarea_ids", "subarea_id"), ("competence_ids", "competence_id"), ("client_ids", "client_id"), ) for key, column in filter_columns: values = _normalize_int_list(filters.get(key)) if not values: values = _normalize_int_list(filters.get(column)) if values: clauses.append(f"i.{column} = ANY(%({key})s::int[])") params[key] = values excluded_clients = _normalize_int_list(filters.get("exclude_client_ids")) if not excluded_clients: excluded_clients = _normalize_int_list(filters.get("exclude_client_id")) if excluded_clients: clauses.append("NOT (i.client_id = ANY(%(exclude_client_ids)s::int[]))") params["exclude_client_ids"] = excluded_clients if filters.get("require_code_match"): clauses.append("i.codes && %(query_codes)s::text[]") recent_months = _id_value(filters.get("recent_months")) if recent_months is not None and recent_months > 0: clauses.append("i.opened_at >= now() - (%(recent_days)s::int * interval '1 day')") params["recent_days"] = recent_months * 31 return " AND ".join(clauses) if clauses else "TRUE", params def _safe_filters(filters): safe = {} for key, value in (filters or {}).items(): if value is None or value is False or value == []: continue if isinstance(value, (list, tuple, set)): safe[key] = list(value) else: safe[key] = value return safe def _search(query_kind, query_text, query_ticket_id=None, query_ticket=None, user_id=None, limit=DEFAULT_LIMIT, offset=0, filters=None, log_results=True): ensure_runtime_schema() query_text = clean_text(query_text) if not query_text: return [] query_ticket = query_ticket or {} filters = filters or {} limit = max(int(limit or DEFAULT_LIMIT), 1) offset = max(int(offset or 0), 0) pool_target = offset + limit query_codes = extract_codes(query_text) metadata_clause, metadata_params = _metadata_candidate_clause(query_ticket, filters) strict_clause, strict_params = _strict_filter_clause(filters) params = { "query_ticket_id": int(query_ticket_id) if query_ticket_id is not None else None, "query_text": query_text, "problem_text": clean_text(query_ticket.get("problem") or query_text), "subject_text": clean_text(query_ticket.get("subject") or query_text), "candidate_limit": max(pool_target * 50, 250), "final_limit": max(pool_target * DIVERSITY_POOL_MULTIPLIER * 2, 40), "query_product_id": _id_value(query_ticket.get("product_id")), "query_area_id": _id_value(query_ticket.get("area_id")), "query_subarea_id": _id_value(query_ticket.get("subarea_id")), "query_competence_id": _id_value(query_ticket.get("competence_id")), "query_codes": query_codes, } params.update(metadata_params) params.update(strict_params) sql = f""" WITH q AS ( SELECT websearch_to_tsquery('simple', %(query_text)s) AS tsq ), lexical_candidates AS ( SELECT i.ticket_id FROM ticket_ai_index i, q WHERE (%(query_ticket_id)s IS NULL OR i.ticket_id <> %(query_ticket_id)s) AND ({strict_clause}) AND i.search_vector @@ q.tsq ORDER BY ts_rank_cd(i.search_vector, q.tsq) DESC LIMIT %(candidate_limit)s ), metadata_candidates AS ( SELECT i.ticket_id FROM ticket_ai_index i WHERE (%(query_ticket_id)s IS NULL OR i.ticket_id <> %(query_ticket_id)s) AND ({strict_clause}) AND ({metadata_clause}) ORDER BY i.opened_at DESC NULLS LAST LIMIT %(candidate_limit)s ), code_candidates AS ( SELECT i.ticket_id FROM ticket_ai_index i WHERE (%(query_ticket_id)s IS NULL OR i.ticket_id <> %(query_ticket_id)s) AND ({strict_clause}) AND i.codes && %(query_codes)s::text[] ORDER BY i.opened_at DESC NULLS LAST LIMIT %(candidate_limit)s ), candidate_ids AS ( SELECT ticket_id FROM lexical_candidates UNION SELECT ticket_id FROM code_candidates UNION SELECT ticket_id FROM metadata_candidates ), scored AS ( SELECT i.ticket_id, i.subject_text, i.problem_text, i.solution_preview, i.competence_id, i.competence, i.product_id, i.product, i.area_id, i.area, i.subarea_id, i.subarea, i.client_id, i.client, i.opened_at, ts_rank_cd(i.search_vector, q.tsq) AS lexical_score, GREATEST( similarity(i.problem_text, %(problem_text)s), similarity(i.subject_text, %(subject_text)s) ) AS trigram_score, CASE WHEN i.codes && %(query_codes)s::text[] THEN 0.18 ELSE 0 END AS code_score, ( CASE WHEN i.product_id IS NOT DISTINCT FROM %(query_product_id)s THEN 0.045 ELSE 0 END + CASE WHEN i.subarea_id IS NOT DISTINCT FROM %(query_subarea_id)s THEN 0.035 ELSE 0 END + CASE WHEN i.area_id IS NOT DISTINCT FROM %(query_area_id)s THEN 0.020 ELSE 0 END + CASE WHEN i.competence_id IS NOT DISTINCT FROM %(query_competence_id)s THEN 0.015 ELSE 0 END ) AS metadata_score, LEAST( GREATEST(COALESCE(fs.positive_count, 0) - COALESCE(fs.negative_count, 0), 0) * 0.015, 0.12 ) AS feedback_score FROM candidate_ids c JOIN ticket_ai_index i ON i.ticket_id = c.ticket_id CROSS JOIN q LEFT JOIN ticket_ai_feedback_summary fs ON fs.ticket_id = i.ticket_id ) SELECT *, ( lexical_score * 0.62 + trigram_score * 0.28 + code_score + metadata_score + feedback_score ) AS total_score FROM scored ORDER BY total_score DESC, lexical_score DESC, opened_at DESC NULLS LAST LIMIT %(final_limit)s """ started_at = time.perf_counter() connection = get_pg_connection() cursor = connection.cursor(cursor_factory=DictCursor) cursor.execute(sql, params) rows = cursor.fetchall() all_suggestions = _diverse_suggestions(rows, query_ticket, pool_target, filters) suggestions = all_suggestions[offset:offset + limit] elapsed_ms = int((time.perf_counter() - started_at) * 1000) if log_results: _log_search(cursor, user_id, query_kind, query_ticket_id, query_text, filters, len(all_suggestions), elapsed_ms) if query_kind == "ticket" and query_ticket_id is not None and suggestions: _log_suggestions(cursor, user_id, int(query_ticket_id), query_text, suggestions, rank_offset=offset) connection.commit() cursor.close() connection.close() return suggestions def search_similar_tickets(query_ticket_id, user_id=None, limit=DEFAULT_LIMIT, offset=0, filters=None, log_results=True): query_ticket = mysql5.get_ticket_context(query_ticket_id) if not query_ticket: return [] query_text = _query_text(query_ticket) return _search( "ticket", query_text, query_ticket_id=int(query_ticket_id), query_ticket=query_ticket, user_id=user_id, limit=limit, offset=offset, filters=filters, log_results=log_results, ) def search_similar_text(query_text, user_id=None, limit=DEFAULT_LIMIT, offset=0, filters=None, log_results=True): query_text = clean_text(query_text) if not query_text: return [] return _search( "text", query_text, query_ticket_id=None, query_ticket={}, user_id=user_id, limit=limit, offset=offset, filters=filters, log_results=log_results, ) def _log_search(cursor, user_id, query_kind, query_ticket_id, query_text, filters, result_count, elapsed_ms): cursor.execute( """ INSERT INTO ticket_ai_search_log ( user_id, query_kind, query_ticket_id, query_text, filters, result_count, elapsed_ms ) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( user_id, query_kind, int(query_ticket_id) if query_ticket_id is not None else None, query_text, Json(_safe_filters(filters)), int(result_count), int(elapsed_ms), ), ) def _log_suggestions(cursor, user_id, query_ticket_id, query_text, suggestions, rank_offset=0): for rank, suggestion in enumerate(suggestions, start=1 + rank_offset): cursor.execute( """ INSERT INTO ticket_ai_query_log ( user_id, query_ticket_id, suggested_ticket_id, rank, total_score, lexical_score, trigram_score, code_score, metadata_score, feedback_score, confidence, query_text, reasons ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( user_id, query_ticket_id, suggestion["ticket_id"], rank, float(suggestion["total_score"]), float(suggestion["lexical_score"]), float(suggestion["trigram_score"]), float(suggestion["code_score"]), float(suggestion["metadata_score"]), float(suggestion["feedback_score"]), suggestion["confidence"], query_text, suggestion["reasons"], ), ) def record_feedback(user_id, query_ticket_id, suggested_ticket_id, feedback, note=None): ensure_runtime_schema() if feedback not in ("useful", "not_useful", "favorite", "opened"): raise ValueError("feedback must be useful, not_useful, favorite, or opened") connection = get_pg_connection() cursor = connection.cursor() cursor.execute( """ INSERT INTO ticket_ai_feedback ( user_id, query_ticket_id, suggested_ticket_id, feedback, note ) VALUES (%s, %s, %s, %s, %s) """, (user_id, int(query_ticket_id), int(suggested_ticket_id), feedback, note), ) connection.commit() cursor.close() connection.close() refresh_feedback_summary() return True def record_favorite(user_id, ticket_id, query_ticket_id=None, note=None): ensure_runtime_schema() ticket_id = int(ticket_id) query_ticket_id = int(query_ticket_id) if query_ticket_id is not None else ticket_id connection = get_pg_connection() cursor = connection.cursor() cursor.execute( """ SELECT 1 FROM ticket_ai_feedback WHERE user_id = %s AND suggested_ticket_id = %s AND feedback = 'favorite' LIMIT 1 """, (user_id, ticket_id), ) if cursor.fetchone(): cursor.close() connection.close() return False cursor.execute( """ INSERT INTO ticket_ai_feedback ( user_id, query_ticket_id, suggested_ticket_id, feedback, note ) VALUES (%s, %s, %s, 'favorite', %s) """, (user_id, query_ticket_id, ticket_id, note), ) connection.commit() cursor.close() connection.close() refresh_feedback_summary() return True def get_user_favorites(user_id, limit=20): ensure_runtime_schema() connection = get_pg_connection() cursor = connection.cursor(cursor_factory=DictCursor) cursor.execute( """ SELECT * FROM ( SELECT DISTINCT ON (f.suggested_ticket_id) f.suggested_ticket_id AS ticket_id, f.query_ticket_id, f.created_at, i.product, i.area, i.subject_text FROM ticket_ai_feedback f LEFT JOIN ticket_ai_index i ON i.ticket_id = f.suggested_ticket_id WHERE f.user_id = %s AND f.feedback = 'favorite' ORDER BY f.suggested_ticket_id, f.created_at DESC ) favorites ORDER BY created_at DESC LIMIT %s """, (user_id, int(limit)), ) rows = [dict(row) for row in cursor.fetchall()] cursor.close() connection.close() return rows def get_user_bot_settings(user_id): ensure_runtime_schema() connection = get_pg_connection() cursor = connection.cursor(cursor_factory=DictCursor) cursor.execute( """ INSERT INTO ticket_bot_user_settings (user_id) VALUES (%s) ON CONFLICT (user_id) DO NOTHING """, (int(user_id),), ) cursor.execute( """ SELECT * FROM ticket_bot_user_settings WHERE user_id = %s """, (int(user_id),), ) settings = dict(cursor.fetchone()) connection.commit() cursor.close() connection.close() return settings def update_user_bot_settings(user_id, **updates): allowed = { "notifications_enabled", "quiet_start_hour", "quiet_end_hour", "digest_minutes", "high_gravity_only", "last_notification_at", } clean_updates = {key: value for key, value in updates.items() if key in allowed} if not clean_updates: return get_user_bot_settings(user_id) get_user_bot_settings(user_id) assignments = [f"{key} = %s" for key in clean_updates] values = list(clean_updates.values()) values.append(int(user_id)) connection = get_pg_connection() cursor = connection.cursor() cursor.execute( f""" UPDATE ticket_bot_user_settings SET {", ".join(assignments)}, updated_at = now() WHERE user_id = %s """, values, ) connection.commit() cursor.close() connection.close() return get_user_bot_settings(user_id) def set_notifications_enabled(user_id, enabled): return update_user_bot_settings(user_id, notifications_enabled=bool(enabled)) def set_quiet_hours(user_id, start_hour, end_hour): return update_user_bot_settings( user_id, quiet_start_hour=max(0, min(23, int(start_hour))), quiet_end_hour=max(0, min(23, int(end_hour))), ) def clear_quiet_hours(user_id): return update_user_bot_settings(user_id, quiet_start_hour=None, quiet_end_hour=None) def set_digest_minutes(user_id, minutes): return update_user_bot_settings(user_id, digest_minutes=max(0, int(minutes))) def set_high_gravity_only(user_id, enabled): return update_user_bot_settings(user_id, high_gravity_only=bool(enabled)) def _is_quiet_now(settings, now=None): start = settings.get("quiet_start_hour") end = settings.get("quiet_end_hour") if start is None or end is None or start == end: return False hour = (now or datetime.now()).hour if start < end: return start <= hour < end return hour >= start or hour < end def should_send_notifications(user_id, now=None): settings = get_user_bot_settings(user_id) now = now or datetime.now() if not settings.get("notifications_enabled", True): return False if _is_quiet_now(settings, now=now): return False digest_minutes = int(settings.get("digest_minutes") or 0) last_notification_at = settings.get("last_notification_at") if digest_minutes > 0 and last_notification_at: elapsed_seconds = (now - last_notification_at).total_seconds() if elapsed_seconds < digest_minutes * 60: return False return True def mark_notification_sent(user_id): return update_user_bot_settings(user_id, last_notification_at=datetime.now()) def get_ai_stats(): ensure_runtime_schema() connection = get_pg_connection() cursor = connection.cursor(cursor_factory=DictCursor) cursor.execute("SELECT COUNT(*) AS indexed_count, MAX(indexed_at) AS last_indexed_at FROM ticket_ai_index") index_stats = dict(cursor.fetchone()) cursor.execute( """ SELECT COUNT(*) AS search_count, COALESCE(AVG(elapsed_ms), 0)::integer AS avg_elapsed_ms, COALESCE(SUM(CASE WHEN result_count = 0 THEN 1 ELSE 0 END), 0) AS no_result_count FROM ticket_ai_search_log """ ) search_stats = dict(cursor.fetchone()) cursor.execute("SELECT feedback, COUNT(*) AS count FROM ticket_ai_feedback GROUP BY feedback") feedback_counts = {row["feedback"]: row["count"] for row in cursor.fetchall()} cursor.close() connection.close() return { **index_stats, **search_stats, "feedback_counts": feedback_counts, } def get_no_result_searches(limit=10): ensure_runtime_schema() connection = get_pg_connection() cursor = connection.cursor(cursor_factory=DictCursor) cursor.execute( """ SELECT created_at, user_id, query_kind, query_ticket_id, query_text, filters, elapsed_ms FROM ticket_ai_search_log WHERE result_count = 0 ORDER BY created_at DESC LIMIT %s """, (int(limit),), ) rows = [dict(row) for row in cursor.fetchall()] cursor.close() connection.close() return rows def get_negative_feedback(limit=10): ensure_runtime_schema() connection = get_pg_connection() cursor = connection.cursor(cursor_factory=DictCursor) cursor.execute( """ SELECT f.created_at, f.user_id, f.query_ticket_id, f.suggested_ticket_id, i.product, i.area, i.subject_text FROM ticket_ai_feedback f LEFT JOIN ticket_ai_index i ON i.ticket_id = f.suggested_ticket_id WHERE f.feedback = 'not_useful' ORDER BY f.created_at DESC LIMIT %s """, (int(limit),), ) rows = [dict(row) for row in cursor.fetchall()] cursor.close() connection.close() return rows def get_top_useful(limit=10): ensure_runtime_schema() connection = get_pg_connection() cursor = connection.cursor(cursor_factory=DictCursor) cursor.execute( """ SELECT fs.ticket_id, fs.positive_count, fs.negative_count, i.product, i.area, i.subject_text FROM ticket_ai_feedback_summary fs LEFT JOIN ticket_ai_index i ON i.ticket_id = fs.ticket_id ORDER BY fs.positive_count DESC, fs.negative_count ASC LIMIT %s """, (int(limit),), ) rows = [dict(row) for row in cursor.fetchall()] cursor.close() connection.close() return rows def start_guessing_manual(ticket, user_id=None, filters=None, limit=DEFAULT_LIMIT, offset=0): suggestions = search_similar_tickets(ticket, user_id=user_id, limit=limit, offset=offset, filters=filters) return [_format_suggestion(int(ticket), suggestion, rank + offset) for rank, suggestion in enumerate(suggestions, start=1)]