abinazebinoy commited on
Commit
a080734
·
1 Parent(s): a4e1c4c

feat(ai): complete — NLP pipeline all 4 modules confirmed working

Browse files

Confirmed test results:
- nlp_extractor: spaCy en_core_web_sm loaded, 11 entities from CAG sample
2 PERSON, 3 ORG, 2 MONEY, 2 GPE — all via spacy_ner
- benfords_analyzer: Test 1 chi=4.30 NORMAL, Test 2 chi=4170 ANOMALY
Logarithmic distribution correctly follows Benford expectation
- shadow_draft_detector: all-MiniLM-L6-v2 loaded via sentence-transformers
Test 1 semantic alignment 93.35 percent FLAGGED, Test 2 0.0 percent clear
- multilingual_ner: switched from gated ai4bharat/IndicNER to public
Davlan/bert-base-multilingual-cased-ner-hrl. Model extracted Hindi names
via real NER: PER raajesh kumar, PER priyaa sharmaa confirmed
English text correctly deferred to nlp_extractor spaCy pipeline

ai/benfords_analyzer.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ import re
6
+ import math
7
+ from datetime import datetime
8
+ from loguru import logger
9
+
10
+
11
+ BENFORD_DISTRIBUTION = {
12
+ 1: 0.30103,
13
+ 2: 0.17609,
14
+ 3: 0.12494,
15
+ 4: 0.09691,
16
+ 5: 0.07918,
17
+ 6: 0.06695,
18
+ 7: 0.05799,
19
+ 8: 0.05115,
20
+ 9: 0.04576,
21
+ }
22
+
23
+ SIGNIFICANCE_THRESHOLD = 15.507
24
+
25
+
26
+ class BenfordsAnalyzer:
27
+
28
+ def __init__(self):
29
+ self.benford = BENFORD_DISTRIBUTION
30
+
31
+ def extract_numeric_values(self, text: str) -> list:
32
+ pattern = re.compile(
33
+ r"(?:Rs\.?|INR)?\s*([\d,]+(?:\.\d+)?)\s*"
34
+ r"(?:crore|lakh|cr|L|thousand)?",
35
+ re.IGNORECASE,
36
+ )
37
+ values = []
38
+ for match in pattern.finditer(text):
39
+ raw = match.group(1).replace(",", "")
40
+ try:
41
+ val = float(raw)
42
+ if val >= 1:
43
+ values.append(val)
44
+ except ValueError:
45
+ continue
46
+ return values
47
+
48
+ def get_first_digit(self, value: float) -> int:
49
+ if value <= 0:
50
+ return 0
51
+ s = str(abs(value)).lstrip("0").replace(".", "")
52
+ return int(s[0]) if s else 0
53
+
54
+ def analyze(self, values: list) -> dict:
55
+ if len(values) < 20:
56
+ return {
57
+ "status": "insufficient_data",
58
+ "sample_size": len(values),
59
+ "minimum_required": 20,
60
+ "anomaly_detected": False,
61
+ "analyzed_at": datetime.now().isoformat(),
62
+ }
63
+
64
+ digit_counts = {d: 0 for d in range(1, 10)}
65
+ for val in values:
66
+ d = self.get_first_digit(val)
67
+ if d in digit_counts:
68
+ digit_counts[d] += 1
69
+
70
+ n = sum(digit_counts.values())
71
+ chi_squared = 0.0
72
+ observed_dist = {}
73
+ for digit in range(1, 10):
74
+ observed = digit_counts[digit]
75
+ expected = self.benford[digit] * n
76
+ observed_pct = observed / n if n > 0 else 0
77
+ observed_dist[digit] = round(observed_pct, 5)
78
+ if expected > 0:
79
+ chi_squared += ((observed - expected) ** 2) / expected
80
+
81
+ anomalous_digits = []
82
+ for digit in range(1, 10):
83
+ observed_pct = observed_dist[digit]
84
+ expected_pct = self.benford[digit]
85
+ deviation = abs(observed_pct - expected_pct)
86
+ if deviation > 0.05:
87
+ anomalous_digits.append({
88
+ "digit": digit,
89
+ "observed_pct": round(observed_pct * 100, 2),
90
+ "expected_pct": round(expected_pct * 100, 2),
91
+ "deviation_pct":round(deviation * 100, 2),
92
+ })
93
+
94
+ anomaly_detected = chi_squared > SIGNIFICANCE_THRESHOLD
95
+
96
+ if anomaly_detected:
97
+ logger.warning(
98
+ f"[Benford] ANOMALY: chi-squared={chi_squared:.2f} "
99
+ f"(threshold={SIGNIFICANCE_THRESHOLD}) n={n}"
100
+ )
101
+ else:
102
+ logger.info(
103
+ f"[Benford] NORMAL: chi-squared={chi_squared:.2f} n={n}"
104
+ )
105
+
106
+ return {
107
+ "status": "completed",
108
+ "sample_size": n,
109
+ "chi_squared": round(chi_squared, 4),
110
+ "significance_threshold": SIGNIFICANCE_THRESHOLD,
111
+ "anomaly_detected": anomaly_detected,
112
+ "confidence": "high" if n >= 100 else "moderate" if n >= 30 else "low",
113
+ "observed_distribution": observed_dist,
114
+ "expected_distribution": {d: round(v, 5) for d, v in self.benford.items()},
115
+ "anomalous_digits": anomalous_digits,
116
+ "interpretation": (
117
+ "First-digit distribution deviates significantly from Benford's Law. "
118
+ "This is a statistical anomaly indicator warranting further review. "
119
+ "Possible causes include rounding of declared figures, threshold avoidance, "
120
+ "or systematic manipulation of values."
121
+ if anomaly_detected else
122
+ "First-digit distribution is consistent with Benford's Law. "
123
+ "No statistical anomaly detected in this dataset."
124
+ ),
125
+ "analyzed_at": datetime.now().isoformat(),
126
+ }
127
+
128
+ def analyze_affidavit_assets(self, asset_records: list) -> dict:
129
+ values = []
130
+ for record in asset_records:
131
+ raw = str(record.get("total_assets", "") or record.get("assets", ""))
132
+ extracted = self.extract_numeric_values(raw)
133
+ values.extend(extracted)
134
+
135
+ logger.info(
136
+ f"[Benford] Analyzing {len(values)} numeric values "
137
+ f"from {len(asset_records)} affidavit records"
138
+ )
139
+ result = self.analyze(values)
140
+ result["source"] = "Election Commission of India candidate affidavits"
141
+ result["record_count"]= len(asset_records)
142
+ return result
143
+
144
+
145
+ if __name__ == "__main__":
146
+ import random
147
+ print("=" * 55)
148
+ print("BharatGraph - Benford's Law Analyzer Test")
149
+ print("=" * 55)
150
+
151
+ analyzer = BenfordsAnalyzer()
152
+
153
+ print("\n Test 1: Naturally distributed values (should pass)")
154
+ natural = []
155
+ for _ in range(200):
156
+ magnitude = random.uniform(0, 7)
157
+ val = 10 ** magnitude
158
+ natural.append(val)
159
+ result1 = analyzer.analyze(natural)
160
+ print(f" Chi-squared: {result1['chi_squared']}")
161
+ print(f" Anomaly: {result1['anomaly_detected']}")
162
+
163
+ print("\n Test 2: Manipulated values (clustered near thresholds)")
164
+ manipulated = []
165
+ for _ in range(100):
166
+ manipulated.append(random.uniform(990000, 999999))
167
+ for _ in range(100):
168
+ manipulated.append(random.uniform(9800000, 9999999))
169
+ result2 = analyzer.analyze(manipulated)
170
+ print(f" Chi-squared: {result2['chi_squared']}")
171
+ print(f" Anomaly: {result2['anomaly_detected']}")
172
+
173
+ print("\n Test 3: Affidavit asset analysis")
174
+ sample_records = [
175
+ {"name": "Candidate A", "total_assets": "Rs 45 lakh"},
176
+ {"name": "Candidate B", "total_assets": "Rs 1.2 crore"},
177
+ {"name": "Candidate C", "total_assets": "Rs 99 lakh"},
178
+ ]
179
+ result3 = analyzer.analyze_affidavit_assets(sample_records)
180
+ print(f" Sample size: {result3['sample_size']}")
181
+ print(f" Status: {result3['status']}")
182
+
183
+ print("\nDone!")
ai/multilingual_ner.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ import re
6
+ from datetime import datetime
7
+ from loguru import logger
8
+
9
+
10
+ SUPPORTED_LANGUAGES = {
11
+ "hi": "Hindi",
12
+ "ta": "Tamil",
13
+ "te": "Telugu",
14
+ "kn": "Kannada",
15
+ "ml": "Malayalam",
16
+ "mr": "Marathi",
17
+ "bn": "Bengali",
18
+ "gu": "Gujarati",
19
+ "pa": "Punjabi",
20
+ }
21
+
22
+ INDICNER_MODEL = "Davlan/bert-base-multilingual-cased-ner-hrl"
23
+
24
+ HINDI_TITLE_WORDS = [
25
+ "मंत्री", "सचिव", "मुख्यमंत्री", "राज्यपाल", "सांसद", "विधायक",
26
+ "अधिकारी", "निदेशक", "आयुक्त", "कलेक्टर",
27
+ ]
28
+
29
+
30
+ class MultilingualNER:
31
+
32
+ def __init__(self):
33
+ self._pipeline = None
34
+ self._lang_detect = None
35
+ self._load_models()
36
+
37
+ def _load_models(self):
38
+ try:
39
+ from transformers import pipeline as hf_pipeline
40
+ self._pipeline = hf_pipeline(
41
+ "token-classification",
42
+ model=INDICNER_MODEL,
43
+ aggregation_strategy="simple",
44
+ )
45
+ logger.success(f"[MultilingualNER] Loaded {INDICNER_MODEL}")
46
+ except Exception as e:
47
+ logger.warning(f"[MultilingualNER] HuggingFace model not available: {e}")
48
+ logger.warning("[MultilingualNER] Using pattern-based fallback for Hindi")
49
+ self._pipeline = None
50
+
51
+ def detect_language(self, text: str) -> str:
52
+ devanagari = len(re.findall(r'[\u0900-\u097F]', text))
53
+ tamil_chars = len(re.findall(r'[\u0B80-\u0BFF]', text))
54
+ telugu_chars = len(re.findall(r'[\u0C00-\u0C7F]', text))
55
+
56
+ if devanagari > 5:
57
+ return "hi"
58
+ if tamil_chars > 5:
59
+ return "ta"
60
+ if telugu_chars > 5:
61
+ return "te"
62
+ return "en"
63
+
64
+ def extract_entities(self, text: str, language: str = None) -> list:
65
+ if not text or not text.strip():
66
+ return []
67
+
68
+ detected_lang = language or self.detect_language(text)
69
+ logger.info(
70
+ f"[MultilingualNER] Extracting from "
71
+ f"{SUPPORTED_LANGUAGES.get(detected_lang, detected_lang)} text"
72
+ )
73
+
74
+ if self._pipeline and detected_lang != "en":
75
+ return self._extract_with_model(text, detected_lang)
76
+ return self._extract_with_patterns(text, detected_lang)
77
+
78
+ def _extract_with_model(self, text: str, language: str) -> list:
79
+ try:
80
+ results = self._pipeline(text[:512])
81
+ entities = []
82
+ for r in results:
83
+ entities.append({
84
+ "text": r.get("word", ""),
85
+ "label": r.get("entity_group", ""),
86
+ "score": round(r.get("score", 0), 4),
87
+ "language": language,
88
+ "model": INDICNER_MODEL,
89
+ "extracted_at":datetime.now().isoformat(),
90
+ })
91
+ logger.success(f"[MultilingualNER] Model extracted {len(entities)} entities")
92
+ return entities
93
+ except Exception as e:
94
+ logger.warning(f"[MultilingualNER] Model inference failed: {e}")
95
+ return self._extract_with_patterns(text, language)
96
+
97
+ def _extract_with_patterns(self, text: str, language: str) -> list:
98
+ entities = []
99
+
100
+ if language == "hi":
101
+ for title in HINDI_TITLE_WORDS:
102
+ pattern = re.compile(
103
+ title + r"\s+([^\s।\n]{2,20}(?:\s+[^\s।\n]{2,20})?)"
104
+ )
105
+ for match in pattern.finditer(text):
106
+ entities.append({
107
+ "text": match.group(1).strip(),
108
+ "label": "PERSON",
109
+ "score": 0.7,
110
+ "language": language,
111
+ "model": "pattern_fallback",
112
+ "extracted_at":datetime.now().isoformat(),
113
+ })
114
+
115
+ amount_pattern = re.compile(
116
+ r"([\d,]+(?:\.\d+)?\s*(?:करोड़|लाख|हजार))"
117
+ )
118
+ for match in amount_pattern.finditer(text):
119
+ entities.append({
120
+ "text": match.group(1),
121
+ "label": "MONEY",
122
+ "score": 0.9,
123
+ "language": language,
124
+ "model": "pattern_fallback",
125
+ "extracted_at":datetime.now().isoformat(),
126
+ })
127
+
128
+ seen = set()
129
+ unique = []
130
+ for e in entities:
131
+ key = (e["text"].strip().lower(), e["label"])
132
+ if key not in seen and e["text"].strip():
133
+ seen.add(key)
134
+ unique.append(e)
135
+
136
+ logger.info(f"[MultilingualNER] Pattern fallback extracted {len(unique)} entities")
137
+ return unique
138
+
139
+
140
+ if __name__ == "__main__":
141
+ print("=" * 55)
142
+ print("BharatGraph - Multilingual NER Test")
143
+ print("=" * 55)
144
+
145
+ ner = MultilingualNER()
146
+
147
+ samples = [
148
+ ("Hindi", "hi",
149
+ "मंत्री राजेश कुमार और सचिव प्रिया शर्मा ने 45 करोड़ रुपये की "
150
+ "अनियमितता की जांच का आदेश दिया।"),
151
+ ("English", "en",
152
+ "Minister Rajesh Kumar approved a contract worth Rs 45 crore "
153
+ "for ABC Infrastructure Private Limited."),
154
+ ]
155
+
156
+ for lang_name, lang_code, text in samples:
157
+ print(f"\n [{lang_name}]")
158
+ print(f" Text: {text[:80]}...")
159
+ entities = ner.extract_entities(text, lang_code)
160
+ print(f" Extracted: {len(entities)} entities")
161
+ for e in entities[:4]:
162
+ print(f" {e['label']}: {e['text']} (model={e['model']})")
163
+
164
+ print("\nDone!")
ai/nlp_extractor.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ import re
6
+ from datetime import datetime
7
+ from loguru import logger
8
+
9
+
10
+ ENTITY_LABELS = {"PERSON", "ORG", "GPE", "MONEY", "DATE", "LAW"}
11
+
12
+ MONETARY_PATTERN = re.compile(
13
+ r"(?:Rs\.?|INR|rupees?)\s*[\d,]+(?:\.\d+)?\s*(?:crore|lakh|thousand|cr|L)?",
14
+ re.IGNORECASE,
15
+ )
16
+
17
+ HONORIFICS = re.compile(
18
+ r"\b(?:Shri|Smt|Dr|Prof|Sri|Mr|Mrs|Ms|Hon|Adv|Er)\.?\s+",
19
+ re.IGNORECASE,
20
+ )
21
+
22
+
23
+ class NLPExtractor:
24
+
25
+ def __init__(self):
26
+ self._nlp = None
27
+ self._load_model()
28
+
29
+ def _load_model(self):
30
+ try:
31
+ import spacy
32
+ self._nlp = spacy.load("en_core_web_sm")
33
+ logger.success("[NLP] spaCy en_core_web_sm loaded")
34
+ except Exception as e:
35
+ logger.warning(f"[NLP] spaCy model not available: {e}")
36
+ logger.warning("[NLP] Run: python -m spacy download en_core_web_sm")
37
+ self._nlp = None
38
+
39
+ def extract_entities(self, text: str, source_document: str = "") -> list:
40
+ if not text or not text.strip():
41
+ return []
42
+
43
+ entities = []
44
+
45
+ monetary = MONETARY_PATTERN.findall(text)
46
+ for m in monetary:
47
+ entities.append({
48
+ "text": m.strip(),
49
+ "label": "MONEY",
50
+ "confidence": "pattern_match",
51
+ "source_doc": source_document,
52
+ "extracted_at":datetime.now().isoformat(),
53
+ })
54
+
55
+ if self._nlp:
56
+ doc = self._nlp(text[:100000])
57
+ for ent in doc.ents:
58
+ if ent.label_ not in ENTITY_LABELS:
59
+ continue
60
+ clean_text = HONORIFICS.sub("", ent.text).strip()
61
+ if len(clean_text) < 3:
62
+ continue
63
+ entities.append({
64
+ "text": clean_text,
65
+ "label": ent.label_,
66
+ "confidence": "spacy_ner",
67
+ "source_doc": source_document,
68
+ "extracted_at":datetime.now().isoformat(),
69
+ })
70
+ else:
71
+ person_pattern = re.compile(
72
+ r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b"
73
+ )
74
+ for match in person_pattern.finditer(text):
75
+ name = match.group(1)
76
+ if len(name.split()) >= 2:
77
+ entities.append({
78
+ "text": name,
79
+ "label": "PERSON",
80
+ "confidence": "regex_fallback",
81
+ "source_doc": source_document,
82
+ "extracted_at":datetime.now().isoformat(),
83
+ })
84
+
85
+ seen = set()
86
+ unique = []
87
+ for e in entities:
88
+ key = (e["text"].lower(), e["label"])
89
+ if key not in seen:
90
+ seen.add(key)
91
+ unique.append(e)
92
+
93
+ logger.info(
94
+ f"[NLP] Extracted {len(unique)} entities from "
95
+ f"'{source_document or 'text'}' "
96
+ f"({len([e for e in unique if e['label']=='PERSON'])} persons, "
97
+ f"{len([e for e in unique if e['label']=='ORG'])} orgs, "
98
+ f"{len([e for e in unique if e['label']=='MONEY'])} amounts)"
99
+ )
100
+ return unique
101
+
102
+ def extract_from_cag_report(self, report_text: str,
103
+ report_title: str = "") -> dict:
104
+ entities = self.extract_entities(report_text, report_title)
105
+ amounts = [e for e in entities if e["label"] == "MONEY"]
106
+ persons = [e for e in entities if e["label"] == "PERSON"]
107
+ orgs = [e for e in entities if e["label"] == "ORG"]
108
+ locations= [e for e in entities if e["label"] == "GPE"]
109
+
110
+ return {
111
+ "report_title": report_title,
112
+ "total_entities":len(entities),
113
+ "persons": persons,
114
+ "organisations": orgs,
115
+ "locations": locations,
116
+ "monetary": amounts,
117
+ "all_entities": entities,
118
+ "extracted_at": datetime.now().isoformat(),
119
+ }
120
+
121
+ def resolve_against_graph(self, entities: list, driver) -> list:
122
+ resolved = []
123
+ if not driver:
124
+ return entities
125
+
126
+ with driver.session() as session:
127
+ for entity in entities:
128
+ if entity["label"] not in ("PERSON", "ORG"):
129
+ resolved.append({**entity, "graph_match": None})
130
+ continue
131
+
132
+ label = "Politician" if entity["label"] == "PERSON" else "Company"
133
+ row = session.run(
134
+ f"""
135
+ MATCH (n:{label})
136
+ WHERE toLower(n.name) CONTAINS toLower($name)
137
+ RETURN n.id AS id, n.name AS name
138
+ LIMIT 1
139
+ """,
140
+ name=entity["text"]
141
+ ).single()
142
+
143
+ resolved.append({
144
+ **entity,
145
+ "graph_match": {
146
+ "id": row["id"],
147
+ "name": row["name"],
148
+ } if row else None,
149
+ })
150
+ return resolved
151
+
152
+
153
+ if __name__ == "__main__":
154
+ print("=" * 55)
155
+ print("BharatGraph - NLP Extractor Test")
156
+ print("=" * 55)
157
+
158
+ extractor = NLPExtractor()
159
+
160
+ sample_text = """
161
+ The Comptroller and Auditor General of India has flagged irregularities
162
+ in the implementation of MGNREGA scheme in Karnataka. Minister Rajesh Kumar
163
+ and senior IAS officer Priya Sharma were found responsible for the diversion
164
+ of Rs 45.6 crore from the Rural Development Ministry. ABC Infrastructure
165
+ Private Limited received contracts worth Rs 120 crore without proper tender.
166
+ The funds were disbursed between January 2021 and March 2023.
167
+ """
168
+
169
+ result = extractor.extract_from_cag_report(sample_text, "CAG Report Sample")
170
+ print(f"\n Total entities extracted: {result['total_entities']}")
171
+ print(f" Persons: {len(result['persons'])}")
172
+ print(f" Organisations: {len(result['organisations'])}")
173
+ print(f" Monetary: {len(result['monetary'])}")
174
+ print(f" Locations: {len(result['locations'])}")
175
+
176
+ if result["persons"]:
177
+ print(f"\n Sample persons:")
178
+ for p in result["persons"][:3]:
179
+ print(f" {p['text']} ({p['confidence']})")
180
+ if result["monetary"]:
181
+ print(f"\n Amounts found:")
182
+ for m in result["monetary"][:3]:
183
+ print(f" {m['text']}")
184
+ print("\nDone!")
ai/shadow_draft_detector.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ import re
6
+ from datetime import datetime
7
+ from loguru import logger
8
+
9
+
10
+ ALIGNMENT_THRESHOLD = 65.0
11
+
12
+ STOPWORDS = {
13
+ "the","a","an","and","or","but","in","on","at","to","for","of","with",
14
+ "by","from","is","are","was","were","be","been","being","have","has",
15
+ "had","do","does","did","will","would","could","should","may","might",
16
+ "shall","can","that","this","these","those","it","its","their","which",
17
+ }
18
+
19
+
20
+ class ShadowDraftDetector:
21
+
22
+ def __init__(self):
23
+ self._model = None
24
+ self._load_model()
25
+
26
+ def _load_model(self):
27
+ try:
28
+ from sentence_transformers import SentenceTransformer, util
29
+ self._model = SentenceTransformer("all-MiniLM-L6-v2")
30
+ self._util = util
31
+ logger.success("[ShadowDraft] sentence-transformers loaded: all-MiniLM-L6-v2")
32
+ except Exception as e:
33
+ logger.warning(f"[ShadowDraft] sentence-transformers not available: {e}")
34
+ logger.warning("[ShadowDraft] Using token overlap fallback")
35
+ self._model = None
36
+
37
+ def _tokenize(self, text: str) -> set:
38
+ tokens = re.findall(r"\b[a-z]{3,}\b", text.lower())
39
+ return {t for t in tokens if t not in STOPWORDS}
40
+
41
+ def _jaccard_similarity(self, text_a: str, text_b: str) -> float:
42
+ tokens_a = self._tokenize(text_a)
43
+ tokens_b = self._tokenize(text_b)
44
+ if not tokens_a or not tokens_b:
45
+ return 0.0
46
+ intersection = len(tokens_a & tokens_b)
47
+ union = len(tokens_a | tokens_b)
48
+ return round((intersection / union) * 100, 2) if union > 0 else 0.0
49
+
50
+ def _semantic_similarity(self, text_a: str, text_b: str) -> float:
51
+ try:
52
+ import torch
53
+ emb_a = self._model.encode(text_a, convert_to_tensor=True)
54
+ emb_b = self._model.encode(text_b, convert_to_tensor=True)
55
+ score = self._util.cos_sim(emb_a, emb_b).item()
56
+ return round(score * 100, 2)
57
+ except Exception as e:
58
+ logger.warning(f"[ShadowDraft] Semantic similarity failed: {e}")
59
+ return self._jaccard_similarity(text_a, text_b)
60
+
61
+ def split_into_sections(self, text: str, max_length: int = 500) -> list:
62
+ sentences = re.split(r"(?<=[.!?])\s+", text)
63
+ sections = []
64
+ current = ""
65
+ for sentence in sentences:
66
+ if len(current) + len(sentence) <= max_length:
67
+ current += " " + sentence
68
+ else:
69
+ if current.strip():
70
+ sections.append(current.strip())
71
+ current = sentence
72
+ if current.strip():
73
+ sections.append(current.strip())
74
+ return sections
75
+
76
+ def compare(self, submission_text: str, bill_text: str,
77
+ submission_name: str = "Submission",
78
+ bill_name: str = "Bill") -> dict:
79
+ logger.info(
80
+ f"[ShadowDraft] Comparing '{submission_name}' "
81
+ f"against '{bill_name}'"
82
+ )
83
+
84
+ submission_sections = self.split_into_sections(submission_text)
85
+ bill_sections = self.split_into_sections(bill_text)
86
+
87
+ if not submission_sections or not bill_sections:
88
+ return {
89
+ "status": "insufficient_text",
90
+ "alignment_score": 0.0,
91
+ "flagged": False,
92
+ }
93
+
94
+ matched_pairs = []
95
+ for sub_sec in submission_sections:
96
+ if len(sub_sec.split()) < 5:
97
+ continue
98
+ best_score = 0.0
99
+ best_bill = ""
100
+ for bill_sec in bill_sections:
101
+ if len(bill_sec.split()) < 5:
102
+ continue
103
+ if self._model:
104
+ score = self._semantic_similarity(sub_sec, bill_sec)
105
+ else:
106
+ score = self._jaccard_similarity(sub_sec, bill_sec)
107
+ if score > best_score:
108
+ best_score = score
109
+ best_bill = bill_sec
110
+ if best_score >= 40.0:
111
+ matched_pairs.append({
112
+ "submission_section": sub_sec[:200],
113
+ "bill_section": best_bill[:200],
114
+ "similarity_score": best_score,
115
+ "method": "semantic" if self._model else "token_overlap",
116
+ })
117
+
118
+ matched_pairs.sort(key=lambda x: x["similarity_score"], reverse=True)
119
+
120
+ if matched_pairs:
121
+ top_scores = [p["similarity_score"] for p in matched_pairs[:5]]
122
+ alignment_score = round(sum(top_scores) / len(top_scores), 2)
123
+ else:
124
+ alignment_score = 0.0
125
+
126
+ effective_threshold = (
127
+ ALIGNMENT_THRESHOLD if self._model
128
+ else ALIGNMENT_THRESHOLD * 0.6
129
+ )
130
+ flagged = alignment_score >= effective_threshold
131
+
132
+ if flagged:
133
+ logger.warning(
134
+ f"[ShadowDraft] HIGH ALIGNMENT: {alignment_score:.1f}% "
135
+ f"between '{submission_name}' and '{bill_name}'"
136
+ )
137
+ else:
138
+ logger.info(
139
+ f"[ShadowDraft] Alignment: {alignment_score:.1f}% "
140
+ f"(threshold={ALIGNMENT_THRESHOLD}%)"
141
+ )
142
+
143
+ return {
144
+ "submission_name": submission_name,
145
+ "bill_name": bill_name,
146
+ "alignment_score": alignment_score,
147
+ "threshold": ALIGNMENT_THRESHOLD,
148
+ "flagged": flagged,
149
+ "matched_sections": len(matched_pairs),
150
+ "top_matches": matched_pairs[:5],
151
+ "interpretation": (
152
+ f"High semantic alignment ({alignment_score:.1f}%) detected between "
153
+ "the corporate submission and the legislative text. This is a structural "
154
+ "indicator that the submission's language may have influenced the final "
155
+ "bill text. This is an analytical observation, not a legal finding."
156
+ if flagged else
157
+ f"Alignment score ({alignment_score:.1f}%) is below the threshold "
158
+ f"({ALIGNMENT_THRESHOLD}%). No significant semantic overlap detected."
159
+ ),
160
+ "analyzed_at": datetime.now().isoformat(),
161
+ }
162
+
163
+
164
+ if __name__ == "__main__":
165
+ print("=" * 55)
166
+ print("BharatGraph - Shadow Draft Detector Test")
167
+ print("=" * 55)
168
+
169
+ detector = ShadowDraftDetector()
170
+
171
+ corporate_submission = """
172
+ We propose that all digital payment service providers should be exempted
173
+ from the transaction levy when the transaction value is below fifty thousand
174
+ rupees. Further, the regulatory authority should provide a grace period of
175
+ eighteen months for existing operators to achieve compliance with the new
176
+ data localisation requirements.
177
+ """
178
+
179
+ bill_text_similar = """
180
+ Digital payment service providers shall be exempt from transaction levy
181
+ for amounts below fifty thousand rupees. Existing operators shall have
182
+ eighteen months to achieve compliance with data localisation requirements
183
+ as specified under this Act.
184
+ """
185
+
186
+ bill_text_different = """
187
+ The government shall establish a committee to review taxation policy for
188
+ agricultural produce. All farmers with land holdings below two hectares
189
+ shall receive a subsidy on crop insurance premiums.
190
+ """
191
+
192
+ print("\n Test 1: High alignment expected")
193
+ result1 = detector.compare(
194
+ corporate_submission, bill_text_similar,
195
+ "Industry Body Submission", "Payment Regulation Bill"
196
+ )
197
+ print(f" Score: {result1['alignment_score']}%")
198
+ print(f" Flagged: {result1['flagged']}")
199
+
200
+ print("\n Test 2: Low alignment expected")
201
+ result2 = detector.compare(
202
+ corporate_submission, bill_text_different,
203
+ "Industry Body Submission", "Agriculture Bill"
204
+ )
205
+ print(f" Score: {result2['alignment_score']}%")
206
+ print(f" Flagged: {result2['flagged']}")
207
+
208
+ print("\nDone!")
requirements.txt CHANGED
@@ -11,3 +11,5 @@ schedule>=1.2.0
11
  fastapi>=0.110.0
12
  uvicorn>=0.27.0
13
  neo4j>=5.14.0
 
 
 
11
  fastapi>=0.110.0
12
  uvicorn>=0.27.0
13
  neo4j>=5.14.0
14
+ spacy>=3.7.0
15
+ sentence-transformers>=2.6.0