winsweb commited on
Commit
d229285
·
1 Parent(s): 81a6f86
telecom_anomaly/api.py CHANGED
@@ -10,15 +10,22 @@ from telecom_anomaly.core.detector import TelecomAnomalyDetector
10
  from telecom_anomaly.core.models import TelecomConfig
11
  from config.settings import SERVICE_NUMBERS
12
  from telecom_anomaly.utils.pdf_processor import (
13
- extract_pdf_to_lines,
14
- process_telecom_data_df,
15
- strict_clean_df,
 
 
 
 
16
  )
17
 
18
  # Configure logging
19
  logging.basicConfig(level=logging.INFO)
20
  logger = logging.getLogger(__name__)
21
 
 
 
 
22
  app = FastAPI(
23
  title="Telecom Anomaly Detection API",
24
  description="API for detecting anomalies in telecom Call Data Records (CDR)",
@@ -46,9 +53,10 @@ async def analyze_cdr(
46
  cluster_km: float = 1.0,
47
  ):
48
  """
49
- Analyze a PDF or CSV file for telecom anomalies. No files are written
50
- to disk; the upload is processed in a temp file that is deleted before
51
- the response returns.
 
52
  """
53
  filename = file.filename.lower()
54
  is_pdf = filename.endswith('.pdf')
@@ -57,11 +65,15 @@ async def analyze_cdr(
57
  if not (is_pdf or is_csv):
58
  raise HTTPException(status_code=400, detail="Only PDF and CSV files are supported")
59
 
60
- # Stage the upload to a temp file (tabula needs a real path; CSV reader takes a path too)
61
  with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
62
  shutil.copyfileobj(file.file, tmp)
63
  tmp_path = tmp.name
64
 
 
 
 
 
 
65
  try:
66
  config = TelecomConfig()
67
  config.service_numbers = SERVICE_NUMBERS
@@ -73,21 +85,20 @@ async def analyze_cdr(
73
 
74
  if is_pdf:
75
  logger.info(f"Processing PDF: {file.filename}")
76
- base_name = os.path.splitext(file.filename)[0]
77
 
78
- raw_lines = extract_pdf_to_lines(tmp_path)
79
- if not raw_lines:
80
  raise HTTPException(status_code=500, detail="Failed to extract tables from PDF")
81
 
82
- df_processed = process_telecom_data_df(raw_lines, base_name)
83
- if df_processed is None:
84
- raise HTTPException(status_code=500, detail="Failed to process extracted telecom data")
85
 
86
- df_final = strict_clean_df(df_processed)
87
- if df_final is None or df_final.empty:
88
- raise HTTPException(status_code=500, detail="No valid records found after cleaning")
89
 
90
- detector.load_dataframe(df_final, filter_movements=filter_movements)
91
  else:
92
  logger.info(f"Processing CSV: {file.filename}")
93
  detector.load_data([tmp_path], filter_movements=filter_movements)
@@ -137,12 +148,26 @@ async def analyze_cdr(
137
  raise HTTPException(status_code=500, detail=str(e))
138
 
139
  finally:
 
140
  if os.path.exists(tmp_path):
141
  try:
142
  os.remove(tmp_path)
143
  except OSError:
144
  pass
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  if __name__ == "__main__":
148
  import uvicorn
 
10
  from telecom_anomaly.core.models import TelecomConfig
11
  from config.settings import SERVICE_NUMBERS
12
  from telecom_anomaly.utils.pdf_processor import (
13
+ setup_folders,
14
+ extract_pdf_to_csv,
15
+ process_telecom_data_complete,
16
+ strict_clean_data,
17
+ RAW_CSV_DIR,
18
+ PROCESS_OUTPUT_DIR,
19
+ STRICT_CLEAN_DIR,
20
  )
21
 
22
  # Configure logging
23
  logging.basicConfig(level=logging.INFO)
24
  logger = logging.getLogger(__name__)
25
 
26
+ # Ensure intermediate-CSV directories exist (matches telecom_anomaly_detection)
27
+ setup_folders()
28
+
29
  app = FastAPI(
30
  title="Telecom Anomaly Detection API",
31
  description="API for detecting anomalies in telecom Call Data Records (CDR)",
 
53
  cluster_km: float = 1.0,
54
  ):
55
  """
56
+ Analyze a PDF or CSV file for telecom anomalies. PDFs go through the
57
+ 3-stage disk-based pipeline (extract -> repair -> strict-clean) so the
58
+ detectors see exactly the same normalized inputs as telecom_anomaly_detection.
59
+ All intermediate CSVs are removed before the response returns.
60
  """
61
  filename = file.filename.lower()
62
  is_pdf = filename.endswith('.pdf')
 
65
  if not (is_pdf or is_csv):
66
  raise HTTPException(status_code=400, detail="Only PDF and CSV files are supported")
67
 
 
68
  with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
69
  shutil.copyfileobj(file.file, tmp)
70
  tmp_path = tmp.name
71
 
72
+ base_name = os.path.splitext(os.path.basename(file.filename))[0]
73
+ raw_csv = None
74
+ interim_csv = None
75
+ final_csv = None
76
+
77
  try:
78
  config = TelecomConfig()
79
  config.service_numbers = SERVICE_NUMBERS
 
85
 
86
  if is_pdf:
87
  logger.info(f"Processing PDF: {file.filename}")
 
88
 
89
+ raw_csv = extract_pdf_to_csv(tmp_path, base_name)
90
+ if not raw_csv:
91
  raise HTTPException(status_code=500, detail="Failed to extract tables from PDF")
92
 
93
+ interim_csv = process_telecom_data_complete(raw_csv, base_name)
94
+ if not interim_csv:
95
+ raise HTTPException(status_code=500, detail="Failed to repair fragmented rows in CSV")
96
 
97
+ final_csv = strict_clean_data(interim_csv, base_name)
98
+ if not final_csv or not os.path.exists(final_csv):
99
+ raise HTTPException(status_code=500, detail="Strict-clean stage produced no output")
100
 
101
+ detector.load_data([final_csv], filter_movements=filter_movements)
102
  else:
103
  logger.info(f"Processing CSV: {file.filename}")
104
  detector.load_data([tmp_path], filter_movements=filter_movements)
 
148
  raise HTTPException(status_code=500, detail=str(e))
149
 
150
  finally:
151
+ # Remove the uploaded temp file
152
  if os.path.exists(tmp_path):
153
  try:
154
  os.remove(tmp_path)
155
  except OSError:
156
  pass
157
 
158
+ # Remove intermediate CSVs from each pipeline stage so the deploy stays stateless
159
+ for path in (
160
+ raw_csv,
161
+ interim_csv,
162
+ final_csv,
163
+ os.path.join(PROCESS_OUTPUT_DIR, f"{base_name}_customer_info.csv"),
164
+ ):
165
+ if path and os.path.exists(path):
166
+ try:
167
+ os.remove(path)
168
+ except OSError:
169
+ pass
170
+
171
 
172
  if __name__ == "__main__":
173
  import uvicorn
telecom_anomaly/utils/pdf_processor.py CHANGED
@@ -3,28 +3,45 @@ import pandas as pd
3
  import io
4
  import re
5
  import os
6
- import tempfile
7
 
 
 
 
 
 
 
 
 
8
 
9
- def extract_pdf_to_lines(pdf_file_path):
10
- """Stage 1 (In-Memory): Run tabula on the PDF and return raw CSV lines."""
11
- with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
12
- tmp_path = tmp.name
 
 
 
 
 
 
 
13
  try:
14
- tabula.convert_into(pdf_file_path, tmp_path, output_format="csv", pages="all", lattice=True)
15
- with open(tmp_path, 'r', encoding='utf-8') as f:
16
- lines = f.readlines()
17
- return lines
18
- finally:
19
- if os.path.exists(tmp_path):
20
- try:
21
- os.remove(tmp_path)
22
- except OSError:
23
- pass
24
 
 
 
 
 
 
 
 
 
25
 
26
- def process_telecom_data_df(lines, base_name):
27
- """Stage 2 (In-Memory): Parse raw CSV lines and repair fragmented rows. Returns a DataFrame."""
28
  header_idx = -1
29
  for i, line in enumerate(lines):
30
  if "event_date_time" in line:
@@ -34,14 +51,24 @@ def process_telecom_data_df(lines, base_name):
34
  if header_idx == -1:
35
  return None
36
 
 
 
 
 
 
 
 
 
37
  call_text = "".join(lines[header_idx:])
38
  df_raw = pd.read_csv(io.StringIO(call_text))
39
  df_raw.columns = [c.strip() for c in df_raw.columns]
40
  main_col = df_raw.columns[0]
 
41
  df_raw = df_raw[df_raw[main_col].astype(str).str.contains("event_date_time") == False]
42
 
43
  fixed_rows = []
44
  current_row = None
 
45
  for _, row in df_raw.iterrows():
46
  val = str(row[main_col]).strip()
47
  if re.match(r'^\d{4}-\d{2}-\d{2}', val):
@@ -60,6 +87,7 @@ def process_telecom_data_df(lines, base_name):
60
  fixed_rows.append(current_row)
61
 
62
  df_calls = pd.DataFrame(fixed_rows)
 
63
  df_calls[main_col] = df_calls[main_col].str.replace(r'[\r\n]+', '', regex=True).str.replace(r'\s+', ' ', regex=True).str.strip()
64
  dt_series = pd.to_datetime(df_calls[main_col], errors='coerce')
65
  df_calls['event_date'] = dt_series.dt.date
@@ -80,29 +108,25 @@ def process_telecom_data_df(lines, base_name):
80
 
81
  ordered_cols = ['event_date', 'event_time'] + [c for c in df_calls.columns if c not in ['event_date', 'event_time']]
82
  df_calls = df_calls[ordered_cols]
83
- return df_calls
 
 
 
84
 
85
 
86
- def strict_clean_df(df):
87
- """
88
- Stage 3 (In-Memory): Final strict cleaning. Returns a DataFrame whose
89
- event_date and event_time are plain strings ('YYYY-MM-DD' and 'HH:MM:SS'),
90
- matching exactly what the reference project produces after a CSV roundtrip.
91
- Keeping these as strings ensures the downstream reader / validator behave
92
- identically to the CSV-only path used by telecom_anomaly_detection.
93
- """
94
  if 'duration' in df.columns and df['duration'].dtype == 'object':
95
  df['duration'] = df['duration'].str.replace(',', '.').astype(float)
96
 
97
  df['event_date'] = pd.to_datetime(df['event_date'], errors='coerce')
98
- df['event_time_check'] = pd.to_datetime(df['event_time'].astype(str), format='%H:%M:%S', errors='coerce')
99
 
100
  df_cleaned = df.dropna().copy()
101
  df_cleaned = df_cleaned.drop(columns=['event_time_check'])
102
 
103
- # Serialize date/time as strings so the in-memory path matches the
104
- # reference project's CSV-roundtrip path byte-for-byte.
105
- df_cleaned['event_date'] = df_cleaned['event_date'].dt.strftime('%Y-%m-%d')
106
- df_cleaned['event_time'] = df_cleaned['event_time'].astype(str)
107
-
108
- return df_cleaned
 
3
  import io
4
  import re
5
  import os
6
+ from pathlib import Path
7
 
8
+ # --- CONFIGURATION ---
9
+ # Match telecom_anomaly_detection's pipeline exactly: every stage writes a CSV
10
+ # and the next stage reads it back. The CSV roundtrip normalizes dtypes and
11
+ # whitespace, which is what produces the anomaly counts the user expects.
12
+ DATA_BASE = Path("data")
13
+ RAW_CSV_DIR = DATA_BASE / "raw_csv_temp"
14
+ PROCESS_OUTPUT_DIR = DATA_BASE / "output_process_data"
15
+ STRICT_CLEAN_DIR = DATA_BASE / "strictly_cleaned_data"
16
 
17
+
18
+ def setup_folders():
19
+ """Ensure all necessary output directories exist."""
20
+ for folder in [RAW_CSV_DIR, PROCESS_OUTPUT_DIR, STRICT_CLEAN_DIR]:
21
+ if not os.path.exists(folder):
22
+ os.makedirs(folder)
23
+
24
+
25
+ def extract_pdf_to_csv(pdf_path, base_name):
26
+ """Stage 1: Convert PDF tables to a raw CSV file."""
27
+ csv_path = os.path.join(RAW_CSV_DIR, f"{base_name}_raw.csv")
28
  try:
29
+ tabula.convert_into(pdf_path, csv_path, output_format="csv", pages="all", lattice=True)
30
+ return csv_path
31
+ except Exception as e:
32
+ print(f"Error extracting {pdf_path}: {e}")
33
+ return None
34
+
 
 
 
 
35
 
36
+ def process_telecom_data_complete(input_path, base_name):
37
+ """Stage 2: Parse raw CSV and repair fragmented rows."""
38
+ try:
39
+ with open(input_path, 'r', encoding='utf-8') as f:
40
+ lines = f.readlines()
41
+ except Exception as e:
42
+ print(f"Error reading {input_path}: {e}")
43
+ return None
44
 
 
 
45
  header_idx = -1
46
  for i, line in enumerate(lines):
47
  if "event_date_time" in line:
 
51
  if header_idx == -1:
52
  return None
53
 
54
+ # Customer info section (before the call-records header)
55
+ customer_text = "".join(lines[:header_idx])
56
+ df_customer = pd.read_csv(io.StringIO(customer_text))
57
+ df_customer.columns = [re.sub(r'[\r\n\s]+', ' ', c).strip() for c in df_customer.columns]
58
+ cust_path = os.path.join(PROCESS_OUTPUT_DIR, f'{base_name}_customer_info.csv')
59
+ df_customer.to_csv(cust_path, index=False)
60
+
61
+ # Call records
62
  call_text = "".join(lines[header_idx:])
63
  df_raw = pd.read_csv(io.StringIO(call_text))
64
  df_raw.columns = [c.strip() for c in df_raw.columns]
65
  main_col = df_raw.columns[0]
66
+
67
  df_raw = df_raw[df_raw[main_col].astype(str).str.contains("event_date_time") == False]
68
 
69
  fixed_rows = []
70
  current_row = None
71
+
72
  for _, row in df_raw.iterrows():
73
  val = str(row[main_col]).strip()
74
  if re.match(r'^\d{4}-\d{2}-\d{2}', val):
 
87
  fixed_rows.append(current_row)
88
 
89
  df_calls = pd.DataFrame(fixed_rows)
90
+
91
  df_calls[main_col] = df_calls[main_col].str.replace(r'[\r\n]+', '', regex=True).str.replace(r'\s+', ' ', regex=True).str.strip()
92
  dt_series = pd.to_datetime(df_calls[main_col], errors='coerce')
93
  df_calls['event_date'] = dt_series.dt.date
 
108
 
109
  ordered_cols = ['event_date', 'event_time'] + [c for c in df_calls.columns if c not in ['event_date', 'event_time']]
110
  df_calls = df_calls[ordered_cols]
111
+
112
+ interim_path = os.path.join(PROCESS_OUTPUT_DIR, f'{base_name}_call_records_final.csv')
113
+ df_calls.to_csv(interim_path, index=False)
114
+ return interim_path
115
 
116
 
117
+ def strict_clean_data(file_path, base_name):
118
+ """Stage 3: Final strict cleaning."""
119
+ df = pd.read_csv(file_path)
120
+
 
 
 
 
121
  if 'duration' in df.columns and df['duration'].dtype == 'object':
122
  df['duration'] = df['duration'].str.replace(',', '.').astype(float)
123
 
124
  df['event_date'] = pd.to_datetime(df['event_date'], errors='coerce')
125
+ df['event_time_check'] = pd.to_datetime(df['event_time'], format='%H:%M:%S', errors='coerce')
126
 
127
  df_cleaned = df.dropna().copy()
128
  df_cleaned = df_cleaned.drop(columns=['event_time_check'])
129
 
130
+ final_path = os.path.join(STRICT_CLEAN_DIR, f'{base_name}_strictly_cleaned.csv')
131
+ df_cleaned.to_csv(final_path, index=False)
132
+ return final_path