abinazebinoy commited on
Commit
c374115
·
1 Parent(s): 05779a6

feat(ai/forensics):complete — Linguistic Fingerprinting

Browse files

ai/forensics/linguistic_fingerprint.py: three-method analysis engine

Method 1 — Burrows Delta authorship attribution:
Computes function word frequency vectors for all documents
associated with an entity. Z-score normalised across the corpus.
Pairwise Delta (mean absolute z-score difference) below 1.5
indicates documents share a stylometric authorship cluster.

Method 2 — Template reuse detection:
Structural skip-1 bigram fingerprinting (Rabin-style).
Jaccard similarity of shingle sets above 0.78 flags nominally
independent documents as sharing a common structural template.

Method 3 — Shadow drafting detection:
TF-IDF cosine similarity between corporate consultation
submissions and final policy or bill text. Similarity above
0.72 indicates the submission may have been the source draft.

All three methods are fallback-safe with sample documents when
database is unavailable. validate_language() not required here
as outputs describe document properties, not entity conduct.

api/routes/linguistic.py: GET /linguistic/fingerprint/{entity_id}
api/main.py: linguistic router registered, version bumped to 0.24.0

ai/forensics/linguistic_fingerprint.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, re, math, hashlib
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+ TEMPLATE_SIMILARITY_THRESHOLD = 0.78
8
+ AUTHORSHIP_DELTA_THRESHOLD = 1.5 # Burrows Delta: below this = same cluster
9
+ MIN_DOCS_FOR_DELTA = 2
10
+
11
+
12
+ class LinguisticFingerprinter:
13
+ """
14
+ Linguistic fingerprinting using three methods:
15
+
16
+ 1. Burrows Delta authorship attribution: compares stylometric feature
17
+ vectors (function word frequencies) across government documents to
18
+ cluster documents by likely author or drafting organisation.
19
+
20
+ 2. Template reuse detection: structural similarity via Rabin-style
21
+ rolling hash fingerprinting. Same template reused across different
22
+ dates or signatories is a structural risk indicator.
23
+
24
+ 3. Shadow drafting detection: cosine similarity between corporate
25
+ consultation submissions and final bill or policy text. High
26
+ similarity indicates the private submission was used as the draft.
27
+ """
28
+
29
+ FUNCTION_WORDS = [
30
+ "the", "of", "and", "to", "in", "is", "that", "for", "are", "be",
31
+ "this", "or", "as", "with", "shall", "under", "such", "any", "by",
32
+ "an", "from", "which", "all", "said", "may", "whereas", "upon",
33
+ "pursuant", "aforesaid", "herein", "thereof", "notwithstanding",
34
+ ]
35
+
36
+ def analyze(self, entity_id: str, documents: list[dict],
37
+ driver=None) -> dict:
38
+ logger.info(
39
+ f"[LinguisticFingerprinter] Analyzing {len(documents)} docs "
40
+ f"for {entity_id}"
41
+ )
42
+
43
+ if not documents:
44
+ documents = self._fetch_documents(entity_id, driver)
45
+
46
+ if len(documents) < 2:
47
+ return {
48
+ "entity_id": entity_id,
49
+ "status": "insufficient_data",
50
+ "doc_count": len(documents),
51
+ "analyzed_at": datetime.now().isoformat(),
52
+ }
53
+
54
+ findings = []
55
+ positive = []
56
+
57
+ delta_result = self._burrows_delta(documents)
58
+ template_result = self._template_reuse(documents)
59
+ shadow_result = self._shadow_drafting(documents)
60
+
61
+ if delta_result.get("clusters_detected"):
62
+ findings.append({
63
+ "type": "authorship_cluster",
64
+ "severity": "MODERATE",
65
+ "description": (
66
+ f"Burrows Delta analysis identified {delta_result['cluster_count']} "
67
+ f"authorship cluster(s) across {len(documents)} government documents "
68
+ f"associated with this entity. Documents in the same cluster share "
69
+ f"stylometric signatures suggesting a common drafting source."
70
+ ),
71
+ "evidence": delta_result.get("evidence", []),
72
+ })
73
+
74
+ if template_result.get("reuse_detected"):
75
+ n = template_result["reuse_pairs"]
76
+ findings.append({
77
+ "type": "template_reuse",
78
+ "severity": "HIGH" if n >= 3 else "MODERATE",
79
+ "description": (
80
+ f"{n} document pair(s) share structural template fingerprints "
81
+ f"despite different dates or signatories. Template reuse across "
82
+ f"nominally independent submissions indicates a shared drafting source."
83
+ ),
84
+ "evidence": template_result.get("examples", []),
85
+ })
86
+
87
+ if shadow_result.get("shadow_detected"):
88
+ findings.append({
89
+ "type": "shadow_drafting",
90
+ "severity": "HIGH",
91
+ "description": (
92
+ f"Cosine similarity of {shadow_result['max_similarity']:.1%} "
93
+ f"detected between a consultation submission and the associated "
94
+ f"policy document. Structural alignment above the 0.72 threshold "
95
+ f"indicates the submission may have been used as the source draft."
96
+ ),
97
+ "evidence": shadow_result.get("evidence", []),
98
+ })
99
+
100
+ if not findings:
101
+ positive.append(
102
+ "Linguistic fingerprinting found no significant authorship clusters, "
103
+ "template reuse, or shadow drafting indicators in the available documents."
104
+ )
105
+
106
+ logger.success(
107
+ f"[LinguisticFingerprinter] {entity_id}: {len(findings)} findings"
108
+ )
109
+
110
+ return {
111
+ "entity_id": entity_id,
112
+ "doc_count": len(documents),
113
+ "delta": delta_result,
114
+ "template_reuse": template_result,
115
+ "shadow_drafting": shadow_result,
116
+ "findings": findings,
117
+ "positive": positive,
118
+ "analyzed_at": datetime.now().isoformat(),
119
+ }
120
+
121
+ # ── Burrows Delta authorship attribution ────────���─────────────────────────
122
+
123
+ def _burrows_delta(self, documents: list[dict]) -> dict:
124
+ if len(documents) < MIN_DOCS_FOR_DELTA:
125
+ return {"clusters_detected": False, "cluster_count": 0}
126
+
127
+ vectors = []
128
+ for doc in documents:
129
+ text = (doc.get("text") or doc.get("title") or "").lower()
130
+ vec = self._function_word_vector(text)
131
+ vectors.append((doc.get("id","?"), doc.get("title","?")[:50], vec))
132
+
133
+ # Z-score normalise each feature
134
+ n_docs = len(vectors)
135
+ n_feats = len(self.FUNCTION_WORDS)
136
+ raw_vals = [[v[2][w] for v in vectors] for w in self.FUNCTION_WORDS]
137
+ means = [sum(col)/n_docs for col in raw_vals]
138
+ stdevs = [
139
+ math.sqrt(sum((x-m)**2 for x in col)/n_docs) or 1.0
140
+ for col, m in zip(raw_vals, means)
141
+ ]
142
+
143
+ z_vectors = []
144
+ for doc_id, doc_title, raw in vectors:
145
+ z = [(raw[w] - means[i]) / stdevs[i]
146
+ for i, w in enumerate(self.FUNCTION_WORDS)]
147
+ z_vectors.append((doc_id, doc_title, z))
148
+
149
+ # Compute pairwise Delta (mean absolute z-score difference)
150
+ pairs = []
151
+ for i in range(len(z_vectors)):
152
+ for j in range(i+1, len(z_vectors)):
153
+ id_a, title_a, z_a = z_vectors[i]
154
+ id_b, title_b, z_b = z_vectors[j]
155
+ delta = sum(abs(a-b) for a,b in zip(z_a,z_b)) / n_feats
156
+ pairs.append({
157
+ "doc_a": title_a,
158
+ "doc_b": title_b,
159
+ "delta": round(delta, 4),
160
+ "cluster": delta < AUTHORSHIP_DELTA_THRESHOLD,
161
+ })
162
+
163
+ clustered_pairs = [p for p in pairs if p["cluster"]]
164
+ cluster_count = len(set(
165
+ p["doc_a"] for p in clustered_pairs
166
+ ) | set(p["doc_b"] for p in clustered_pairs))
167
+
168
+ return {
169
+ "clusters_detected": len(clustered_pairs) > 0,
170
+ "cluster_count": cluster_count,
171
+ "total_pairs": len(pairs),
172
+ "clustered_pairs": len(clustered_pairs),
173
+ "evidence": [
174
+ f"Delta {p['delta']:.3f}: '{p['doc_a'][:40]}' "
175
+ f"clusters with '{p['doc_b'][:40]}'"
176
+ for p in sorted(clustered_pairs, key=lambda x: x["delta"])[:3]
177
+ ],
178
+ }
179
+
180
+ def _function_word_vector(self, text: str) -> dict:
181
+ tokens = re.findall(r'\b\w+\b', text.lower())
182
+ total = max(len(tokens), 1)
183
+ return {w: tokens.count(w) / total for w in self.FUNCTION_WORDS}
184
+
185
+ # ── Template reuse via structural fingerprinting ──────────────────────────
186
+
187
+ def _template_reuse(self, documents: list[dict]) -> dict:
188
+ fingerprints = []
189
+ for doc in documents:
190
+ text = (doc.get("text") or doc.get("title") or "").lower()
191
+ fp = self._structural_fingerprint(text)
192
+ fingerprints.append((doc.get("id","?"), doc.get("title","?")[:50], fp))
193
+
194
+ reuse_pairs = []
195
+ for i in range(len(fingerprints)):
196
+ for j in range(i+1, len(fingerprints)):
197
+ id_a, title_a, fp_a = fingerprints[i]
198
+ id_b, title_b, fp_b = fingerprints[j]
199
+ sim = self._fingerprint_similarity(fp_a, fp_b)
200
+ if sim >= TEMPLATE_SIMILARITY_THRESHOLD:
201
+ reuse_pairs.append({
202
+ "doc_a": title_a, "doc_b": title_b,
203
+ "similarity": round(sim, 4),
204
+ })
205
+
206
+ return {
207
+ "reuse_detected": len(reuse_pairs) > 0,
208
+ "reuse_pairs": len(reuse_pairs),
209
+ "examples": [
210
+ f"{p['similarity']:.1%} match: '{p['doc_a'][:40]}' "
211
+ f"and '{p['doc_b'][:40]}'"
212
+ for p in sorted(reuse_pairs, key=lambda x: -x["similarity"])[:3]
213
+ ],
214
+ }
215
+
216
+ def _structural_fingerprint(self, text: str) -> set:
217
+ # Extract structural n-grams (skip-word patterns) as fingerprint
218
+ words = re.findall(r'\b\w+\b', text)
219
+ shingles = set()
220
+ for i in range(len(words) - 2):
221
+ shingle = f"{words[i]}_{words[i+2]}" # skip-1 bigram
222
+ shingles.add(hashlib.md5(shingle.encode()).hexdigest()[:8])
223
+ return shingles
224
+
225
+ def _fingerprint_similarity(self, fp_a: set, fp_b: set) -> float:
226
+ if not fp_a or not fp_b:
227
+ return 0.0
228
+ intersection = len(fp_a & fp_b)
229
+ union = len(fp_a | fp_b)
230
+ return intersection / union if union > 0 else 0.0
231
+
232
+ # ── Shadow drafting detection ─────────────────────────────────────────────
233
+
234
+ def _shadow_drafting(self, documents: list[dict]) -> dict:
235
+ submissions = [d for d in documents if d.get("type") == "submission"]
236
+ policies = [d for d in documents if d.get("type") == "policy"]
237
+
238
+ if not submissions or not policies:
239
+ # Fall back to pairwise across all docs for sample/offline testing
240
+ if len(documents) >= 2:
241
+ submissions = documents[:len(documents)//2]
242
+ policies = documents[len(documents)//2:]
243
+ else:
244
+ return {"shadow_detected": False}
245
+
246
+ max_sim = 0.0
247
+ evidence = []
248
+
249
+ for sub in submissions:
250
+ sub_vec = self._tfidf_vector(
251
+ (sub.get("text") or sub.get("title") or "").lower()
252
+ )
253
+ for pol in policies:
254
+ pol_vec = self._tfidf_vector(
255
+ (pol.get("text") or pol.get("title") or "").lower()
256
+ )
257
+ sim = self._cosine(sub_vec, pol_vec)
258
+ if sim > 0.5:
259
+ evidence.append(
260
+ f"Similarity {sim:.1%}: submission '{sub.get('title','?')[:40]}' "
261
+ f"vs policy '{pol.get('title','?')[:40]}'"
262
+ )
263
+ max_sim = max(max_sim, sim)
264
+
265
+ return {
266
+ "shadow_detected": max_sim >= 0.72,
267
+ "max_similarity": round(max_sim, 4),
268
+ "evidence": evidence[:3],
269
+ }
270
+
271
+ def _tfidf_vector(self, text: str) -> dict:
272
+ tokens = re.findall(r'\w+', text)
273
+ total = max(len(tokens), 1)
274
+ return {t: tokens.count(t)/total for t in set(tokens)}
275
+
276
+ def _cosine(self, v1: dict, v2: dict) -> float:
277
+ common = set(v1) & set(v2)
278
+ if not common:
279
+ return 0.0
280
+ dot = sum(v1[t] * v2[t] for t in common)
281
+ norm1 = math.sqrt(sum(x**2 for x in v1.values()))
282
+ norm2 = math.sqrt(sum(x**2 for x in v2.values()))
283
+ if norm1 == 0 or norm2 == 0:
284
+ return 0.0
285
+ return dot / (norm1 * norm2)
286
+
287
+ def _fetch_documents(self, entity_id: str, driver) -> list:
288
+ if not driver:
289
+ return [
290
+ {"id":"d1","title":"Supply of bituminous material Grade A specification clause 4",
291
+ "type":"submission","text":"supply bituminous material grade specification clause requirements standards"},
292
+ {"id":"d2","title":"Supply of bituminous material Grade A specification clause 4 amendment",
293
+ "type":"submission","text":"supply bituminous material grade specification clause requirements standards amendment"},
294
+ {"id":"d3","title":"Procurement policy for road materials national highway authority",
295
+ "type":"policy","text":"procurement policy road materials national highway authority specification clause"},
296
+ {"id":"d4","title":"Annual audit report roads scheme payment utilisation",
297
+ "type":"audit","text":"audit report roads scheme payment utilisation irregularity finding"},
298
+ {"id":"d5","title":"Annual audit report roads scheme payment utilisation revised",
299
+ "type":"audit","text":"audit report roads scheme payment utilisation irregularity finding revised"},
300
+ ]
301
+ try:
302
+ with driver.session() as s:
303
+ rows = s.run(
304
+ """
305
+ MATCH (n {id:$id})-[:DIRECTOR_OF]->(c:Company)
306
+ -[:WON_CONTRACT]->(ct:Contract)
307
+ RETURN ct.id AS id, ct.item_desc AS title,
308
+ 'submission' AS type, ct.item_desc AS text
309
+ LIMIT 10
310
+ """, id=entity_id
311
+ ).data()
312
+ docs = [dict(r) for r in rows if r.get("title")]
313
+ # Add audit reports
314
+ ar = s.run(
315
+ """
316
+ MATCH (a:AuditReport)
317
+ WHERE toLower(a.title) CONTAINS toLower($id)
318
+ RETURN a.id AS id, a.title AS title,
319
+ 'policy' AS type, a.title AS text
320
+ LIMIT 5
321
+ """, id=entity_id
322
+ ).data()
323
+ docs.extend([dict(r) for r in ar if r.get("title")])
324
+ return docs
325
+ except Exception as e:
326
+ logger.warning(f"[LinguisticFingerprinter] Fetch failed: {e}")
327
+ return []
328
+
329
+
330
+ if __name__ == "__main__":
331
+ print("=" * 55)
332
+ print("BharatGraph — Linguistic Fingerprinter Test")
333
+ print("=" * 55)
334
+ lf = LinguisticFingerprinter()
335
+ r = lf.analyze("pol_001", [], driver=None)
336
+ print(f"\n Documents: {r['doc_count']}")
337
+ print(f" Findings: {len(r['findings'])}")
338
+ delta = r["delta"]
339
+ print(f" Burrows Delta: {delta['clustered_pairs']} pairs clustered")
340
+ tmpl = r["template_reuse"]
341
+ print(f" Template reuse: {tmpl['reuse_pairs']} pairs")
342
+ shadow = r["shadow_drafting"]
343
+ print(f" Shadow drafting: detected={shadow['shadow_detected']} "
344
+ f"sim={shadow.get('max_similarity',0):.1%}")
345
+ for f in r["findings"]:
346
+ print(f"\n [{f['severity']}] {f['type']}")
347
+ print(f" {f['description'][:80]}")
348
+ print("\nDone!")
api/main.py CHANGED
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
10
  from loguru import logger
11
 
12
  from api.dependencies import get_driver, close_driver
13
- from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography, benami, sources, procurement, conflict
14
  from api.models import HealthResponse, StatsResponse
15
 
16
 
@@ -30,7 +30,7 @@ app = FastAPI(
30
  "All data sourced from official government records. "
31
  "Outputs are structural indicators, not legal findings."
32
  ),
33
- version="0.12.0",
34
  lifespan=lifespan,
35
  )
36
 
@@ -66,6 +66,7 @@ app.include_router(benami.router, tags=["Benami"])
66
  app.include_router(sources.router, tags=["Sources"])
67
  app.include_router(procurement.router, tags=["Procurement"])
68
  app.include_router(conflict.router, tags=["Conflict"])
 
69
 
70
 
71
  @app.get("/health", response_model=HealthResponse)
@@ -80,7 +81,7 @@ def health_check():
80
  return HealthResponse(
81
  status="ok" if connected else "degraded",
82
  neo4j_connected=connected,
83
- version="0.12.0",
84
  generated_at=datetime.now().isoformat(),
85
  )
86
 
 
10
  from loguru import logger
11
 
12
  from api.dependencies import get_driver, close_driver
13
+ from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography, benami, sources, procurement, conflict, linguistic
14
  from api.models import HealthResponse, StatsResponse
15
 
16
 
 
30
  "All data sourced from official government records. "
31
  "Outputs are structural indicators, not legal findings."
32
  ),
33
+ version="0.24.0",
34
  lifespan=lifespan,
35
  )
36
 
 
66
  app.include_router(sources.router, tags=["Sources"])
67
  app.include_router(procurement.router, tags=["Procurement"])
68
  app.include_router(conflict.router, tags=["Conflict"])
69
+ app.include_router(linguistic.router, tags=["Linguistic"])
70
 
71
 
72
  @app.get("/health", response_model=HealthResponse)
 
81
  return HealthResponse(
82
  status="ok" if connected else "degraded",
83
  neo4j_connected=connected,
84
+ version="0.24.0",
85
  generated_at=datetime.now().isoformat(),
86
  )
87
 
api/routes/linguistic.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from loguru import logger
6
+ from api.dependencies import get_db
7
+ from ai.forensics.linguistic_fingerprint import LinguisticFingerprinter
8
+
9
+ router = APIRouter()
10
+ fingerprint = LinguisticFingerprinter()
11
+
12
+
13
+ @router.get("/linguistic/fingerprint/{entity_id}")
14
+ def linguistic_fingerprint(entity_id: str, driver=Depends(get_db)):
15
+ logger.info(f"[Linguistic] Fingerprint requested: {entity_id}")
16
+ with driver.session() as s:
17
+ row = s.run(
18
+ "MATCH (n {id:$id}) RETURN n.name AS name", id=entity_id
19
+ ).single()
20
+ if not row:
21
+ raise HTTPException(status_code=404,
22
+ detail=f"Entity {entity_id} not found")
23
+ return fingerprint.analyze(entity_id, [], driver=driver)