satyaki-mitra commited on
Commit
69256da
·
1 Parent(s): fead05e

Updated UI

Browse files
.env.example CHANGED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Configuration
2
+ HOST=0.0.0.0
3
+ PORT=7860
4
+ DEBUG=False
5
+ ENVIRONMENT=production
6
+ WORKERS=2
7
+
8
+ # Model Configuration
9
+ HF_TOKEN=your_huggingface_token_here
10
+ OFFLINE_MODE=False
11
+
12
+ # CORS
13
+ CORS_ORIGINS=*
14
+
15
+ # Logging
16
+ LOG_LEVEL=INFO
.gitignore CHANGED
@@ -21,6 +21,10 @@ wheels/
21
  .installed.cfg
22
  *.egg
23
 
 
 
 
 
24
  # Virtual environments
25
  venv/
26
  env/
@@ -36,14 +40,7 @@ ENV/
36
  .DS_Store
37
  Thumbs.db
38
 
39
- # Logs
40
- logs/
41
- *.log
42
-
43
- # Data files (if you have large datasets)
44
- data/
45
- models/cache/
46
 
47
  # Environment variables
48
  .env
49
- .env.local
 
21
  .installed.cfg
22
  *.egg
23
 
24
+ # models
25
+ models/cache
26
+
27
+
28
  # Virtual environments
29
  venv/
30
  env/
 
40
  .DS_Store
41
  Thumbs.db
42
 
 
 
 
 
 
 
 
43
 
44
  # Environment variables
45
  .env
46
+ .env.localrailway.toml
.spacesignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ *.so
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .git/
12
+ .env
13
+ .venv
14
+ venv/
15
+ *.log
16
+ .DS_Store
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+ data/uploads/*
22
+ data/reports/*
23
+ logs/*
Dockerfile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # install system dependencies
7
+ RUN apt-get update && apt-get install -y \
8
+ build-essential \
9
+ curl \
10
+ git \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+
14
+ # Set environment variables
15
+ ENV PYTHONUNBUFFERED=1 \
16
+ PYTHONDONTWRITEBYTECODE=1 \
17
+ HF_HOME=/tmp/huggingface \
18
+ TRANSFORMERS_CACHE=/tmp/transformers \
19
+ HF_DATASETS_CACHE=/tmp/datasets \
20
+ TOKENIZERS_PARALLELISM=false
21
+
22
+
23
+ # Create necessary directories
24
+ RUN mkdir -p /tmp/huggingface /tmp/transformers /tmp/datasets /app/data/reports /app/data/uploads /app/models/cache /app/logs
25
+
26
+ # Copy requirements first for better caching
27
+ COPY requirements.txt .
28
+
29
+ # Install python dependencies
30
+ RUN pip install --no-cache-dir -r requirements.txt
31
+
32
+ # Copy application code
33
+ COPY . .
34
+
35
+ # Clear any incompatible cached models
36
+ RUN rm -rf /tmp/huggingface/* /tmp/transformers/* /app/models/cache/*
37
+
38
+ # Expose port 7860 (hugging Face Spaces Standard)
39
+ EXPOSE 7860
40
+
41
+ # Health check
42
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=5 \
43
+ CMD curl -f http://localhost:7860/health || exit 1
44
+
45
+
46
+ # Run the application
47
+ CMD ["uvicorn", "text_auth_app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,3 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  <div align="center">
2
 
3
  # 🔍 AI Text Authentication Platform
@@ -61,13 +73,51 @@ This README is research‑grade (detailed math, methodology, and benchmarks) whi
61
 
62
  | Feature | Description | Impact |
63
  |---|---:|---|
64
- | **Domain‑Aware Detection** | Per‑domain thresholding and weight tuning (academic, technical, creative, social) | ↑15–20% accuracy vs generic detectors |
65
  | **6‑Metric Ensemble** | Orthogonal signals across statistical, syntactic and semantic dimensions | Low false positives (≈2–3%) |
66
  | **Explainability** | Sentence‑level scoring, highlights, and human‑readable reasoning | Trust & auditability |
67
  | **Model Attribution** | Likely model identification (GPT‑4, Claude, Gemini, LLaMA, etc.) | Forensic insights |
68
  | **Auto Model Fetch** | First‑run download from Hugging Face, local cache, offline fallback | Lightweight repo & reproducible runs |
69
  | **Extensible Design** | Plug‑in metrics, model registry, and retraining pipeline hooks | Easy research iteration |
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  ---
72
 
73
  ## 🏗️ System Architecture
 
1
+ ---
2
+ title: Text Authentication Platform
3
+ emoji: 🔍
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ sdk_version: "4.36.0"
8
+ app_file: text_auth_app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
  <div align="center">
14
 
15
  # 🔍 AI Text Authentication Platform
 
73
 
74
  | Feature | Description | Impact |
75
  |---|---:|---|
76
+ | **Domain‑Aware Detection** | Calibrated thresholds and metric weights for 16 content types (Academic, Technical, Creative, Social Media, etc.) | ↑15–20% accuracy vs generic detectors |
77
  | **6‑Metric Ensemble** | Orthogonal signals across statistical, syntactic and semantic dimensions | Low false positives (≈2–3%) |
78
  | **Explainability** | Sentence‑level scoring, highlights, and human‑readable reasoning | Trust & auditability |
79
  | **Model Attribution** | Likely model identification (GPT‑4, Claude, Gemini, LLaMA, etc.) | Forensic insights |
80
  | **Auto Model Fetch** | First‑run download from Hugging Face, local cache, offline fallback | Lightweight repo & reproducible runs |
81
  | **Extensible Design** | Plug‑in metrics, model registry, and retraining pipeline hooks | Easy research iteration |
82
 
83
+ ### 📊 Supported Domains & Threshold Configuration
84
+
85
+ The platform supports detection tailored to the following 16 domains, each with specific AI/Human probability thresholds and metric weights defined in `config/threshold_config.py`. These configurations are used by the ensemble classifier to adapt its decision-making process.
86
+
87
+ **Domains:**
88
+
89
+ * `general` (Default fallback)
90
+ * `academic`
91
+ * `creative`
92
+ * `ai_ml`
93
+ * `software_dev`
94
+ * `technical_doc`
95
+ * `engineering`
96
+ * `science`
97
+ * `business`
98
+ * `legal`
99
+ * `medical`
100
+ * `journalism`
101
+ * `marketing`
102
+ * `social_media`
103
+ * `blog_personal`
104
+ * `tutorial`
105
+
106
+ **Threshold Configuration Details (`config/threshold_config.py`):**
107
+
108
+ Each domain is configured with specific thresholds for the six detection metrics and an ensemble threshold. The weights determine the relative importance of each metric's output during the ensemble aggregation phase.
109
+
110
+ * **AI Threshold:** If a metric's AI probability exceeds this value, it leans towards an "AI" classification for that metric.
111
+ * **Human Threshold:** If a metric's AI probability falls below this value, it leans towards a "Human" classification for that metric.
112
+ * **Weight:** The relative weight assigned to the metric's result during ensemble combination (normalized internally to sum to 1.0 for active metrics).
113
+
114
+ ### Confidence-Calibrated Aggregation (High Level)
115
+
116
+ 1. Start with domain-specific base weights (defined in `config/threshold_config.py`).
117
+ 2. Adjust these weights dynamically based on each metric's individual confidence score using a scaling function.
118
+ 3. Normalize the adjusted weights.
119
+ 4. Compute the final weighted aggregate probability.
120
+
121
  ---
122
 
123
  ## 🏗️ System Architecture
config/model_config.py CHANGED
@@ -20,6 +20,8 @@ class ModelType(Enum):
20
  EMBEDDING = "embedding"
21
  RULE_BASED = "rule_based"
22
  SEQUENCE_CLASSIFICATION = "sequence_classification"
 
 
23
 
24
 
25
  @dataclass
@@ -99,7 +101,7 @@ MODEL_REGISTRY : Dict[str, ModelConfig] = {"perplexity_gpt2" : ModelC
99
  quantizable = True,
100
  ),
101
  "multi_perturbation_base" : ModelConfig(model_id = "gpt2",
102
- model_type = ModelType.GPTMASK,
103
  description = "MultiPerturbationStability model (reuses gpt2)",
104
  size_mb = 0,
105
  required = True,
@@ -108,7 +110,7 @@ MODEL_REGISTRY : Dict[str, ModelConfig] = {"perplexity_gpt2" : ModelC
108
  batch_size = 4,
109
  ),
110
  "multi_perturbation_mask" : ModelConfig(model_id = "distilroberta-base",
111
- model_type = ModelType.TRANSFORMER,
112
  description = "Masked LM for text perturbation",
113
  size_mb = 330,
114
  required = True,
 
20
  EMBEDDING = "embedding"
21
  RULE_BASED = "rule_based"
22
  SEQUENCE_CLASSIFICATION = "sequence_classification"
23
+ CAUSAL_LM = "causal_lm"
24
+ MASKED_LM = "masked_lm"
25
 
26
 
27
  @dataclass
 
101
  quantizable = True,
102
  ),
103
  "multi_perturbation_base" : ModelConfig(model_id = "gpt2",
104
+ model_type = ModelType.CAUSAL_LM,
105
  description = "MultiPerturbationStability model (reuses gpt2)",
106
  size_mb = 0,
107
  required = True,
 
110
  batch_size = 4,
111
  ),
112
  "multi_perturbation_mask" : ModelConfig(model_id = "distilroberta-base",
113
+ model_type = ModelType.MASKED_LM,
114
  description = "Masked LM for text perturbation",
115
  size_mb = 330,
116
  required = True,
data/reports/file_1762499114477_20251107_123724.pdf ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %PDF-1.4
2
+ %���� ReportLab Generated PDF document http://www.reportlab.com
3
+ 1 0 obj
4
+ <<
5
+ /F1 2 0 R /F2 3 0 R /F3 4 0 R
6
+ >>
7
+ endobj
8
+ 2 0 obj
9
+ <<
10
+ /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
11
+ >>
12
+ endobj
13
+ 3 0 obj
14
+ <<
15
+ /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
16
+ >>
17
+ endobj
18
+ 4 0 obj
19
+ <<
20
+ /BaseFont /Helvetica-BoldOblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
21
+ >>
22
+ endobj
23
+ 5 0 obj
24
+ <<
25
+ /Contents 12 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
26
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
27
+ >> /Rotate 0 /Trans <<
28
+
29
+ >>
30
+ /Type /Page
31
+ >>
32
+ endobj
33
+ 6 0 obj
34
+ <<
35
+ /Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
36
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
37
+ >> /Rotate 0 /Trans <<
38
+
39
+ >>
40
+ /Type /Page
41
+ >>
42
+ endobj
43
+ 7 0 obj
44
+ <<
45
+ /Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
46
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
47
+ >> /Rotate 0 /Trans <<
48
+
49
+ >>
50
+ /Type /Page
51
+ >>
52
+ endobj
53
+ 8 0 obj
54
+ <<
55
+ /Contents 15 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
56
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
57
+ >> /Rotate 0 /Trans <<
58
+
59
+ >>
60
+ /Type /Page
61
+ >>
62
+ endobj
63
+ 9 0 obj
64
+ <<
65
+ /PageMode /UseNone /Pages 11 0 R /Type /Catalog
66
+ >>
67
+ endobj
68
+ 10 0 obj
69
+ <<
70
+ /Author (\(anonymous\)) /CreationDate (D:20251107123724-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20251107123724-05'00') /Producer (ReportLab PDF Library - www.reportlab.com)
71
+ /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
72
+ >>
73
+ endobj
74
+ 11 0 obj
75
+ <<
76
+ /Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
77
+ >>
78
+ endobj
79
+ 12 0 obj
80
+ <<
81
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 1165
82
+ >>
83
+ stream
84
+ Gatm;D,\q<&H9tYfItGk,;i'AVWAa(U(/St<Bi7P?9$s:"kjr:s5%U]T"UmN;ir"ACj)G!ZItR0J`)<PT1K7V2M*mV.8U5r%8cJ3Lb@U:g:Q7;<n$Y+:l`[H_ek0aSC.D-O:%Tf.$o::5rZ"RPT_:S7VYK6C;:s6GeFtHi8EZ@7.O3mWRN]NWlX-EO4/6J.ek@ZMGh3@kSHXBE7D-Z7UP\l`QA]n_ho;a!"h>_+[QX#K6*I(1=gIHkkK5h+LAU0JV_q!Q['-X<'5=->[=cpH<pEd0d558]2jrc,!LG^E/9Jiobaj'd,]&V0ZGup^sMuuSCoC8ic73'L,'j\TGtA6?lfjKH33)\XSs4of;m`i[6EFajW'<G74I3XE%?6h7jhR*X[CYEl5I5=mhK.Hr/g#,-2P-hX\G]Z%-irab4e#\V2eQi$h+Q2l5F_;UoUega)dLq]q3Nd_Ka!BqpQC8-FXIKXKjg8&I/^9%ho.(QC$kK;u%^R/C8JCBNlF)H,+_u;g;C)2ejN+8gdQA8CK#lE''-Q)J2Q<:1msc.%/@(0U?t[9'4[(Jd_Ase&_`E^'NM`;Vu1Cg_2i1LGSHbg(#/kb?SsHnn?Wp)0sqca,c,:@Vrc:UXLF)HeSkY<aZjE81(gV9&fL>Zq1&$lgAP1)pI8om(us?l8sBC#aRF*l=WsH&9O3+2S:Z6eJ1GR3+D1ORB2IAaCk%E4rlbkfuEkaA6#UV$'EA"ceH5qPZH*VRS,c`mn#A`XSep:G(q<,bu=Fc[ae\Ig3T;FmOtEN/JcBtA:SlDWohP>i+0ALjYF"%m&4jJH`'$%5s'6[3OtXB<Qh@@$2g$&H)jG-b\&%bBCt:s7_7me$N_=4Tq1DS[dHi]E+G7i4%B^Wrk)\7L26H#dN-$Wg)ooTRTc<IEg>i':5oj8<qg;mm^/.SG0b27(3f(Q/?:]6jYpat--No"52mD"7^K+)BY5p;Hlcsd>kd=R%]HcP"(b3k?=8o!<2IVfbGpSrM<<AjWE.VqC:oP:.>-rS[L\4'GgSaV."&o#?T'.CS`:g,2[Pq@'WkFcb`%cg);117N_ZqXe<48]=XgLWC$8\C/m=\4XUH#J%**!*_aQ(s""V:.`_4rF$!TUCDtm.Y_l'DNqHcsuE4u;D?U:bkCg=E&$oC`3qZDrk.l%~>endstream
85
+ endobj
86
+ 13 0 obj
87
+ <<
88
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 1372
89
+ >>
90
+ stream
91
+ Gat=*gN)%,&:N/3lq:t*9o8q`bAXPQ2JM!8-;p$DcZC9BU6(P2c&4I%o]*O`6&Dm<7]BseoO;f`JN\>["r';Gr/GcJT@WMo(C&*7obNob4BULYg\'oI`iQ'[5j12kN&GEeANI_Cm"g=9F$t#/-lQU0Wll`lS!%Ef@/U8[$Out65::-QN$*)m-+6Q_Bs8SH:G3jQj6DHl:SLNn>i52CHYs8`Y`?3]LKstp\CL][/;jtpa@t0?#sdeu\A\>S%+AK#T6F#TeWm@^MAqXr'\5"n4MNDr<[L/P@KOmPL(7@Hiq^UUXf=e'aTQu,1>P[lAa<+um3=f_MLIO_9>K=5k^oc*<9!_Uin<:kW1Xd'f#r]g&kKZi9W5>T_'?.!;6&n=E0]?@Y@U_WQtNW<>^KMH<5Z*@jT*ba=PQ^<B2N`<M$3ehFXcA9As1AE&.Og`)CI>9OkJDG^rZNPo]Z%+B8m`Y.PB-<q)O./[u^8qo>Z@KS;:!&"rhm.AQ:,0_qI+m!nImCfmYQrjI;7^h^9QdKCn@^e#OUn^I+AEg3?Y8MO8=NN0acOafcp(d#cgCS61$:,4!.AJ9CCC;",'r>`;%Yrp1V%;su[CI[Le?Z=Ui0jn%N934tk4NPh'DK>Rc^QGL^GWO:,0$gmbsUR*bSFZM%eFk4XC7XXH:fFk:q?TO\&X:8MtS*O2r^)Wo$?'*FeR-0lboWu_R5_?Y!'%;^kmB3i]bm4dJ-Nlc&*b,cnju`?]TaP2-$][7!Y_f,M(IX3>_pSit'hYKJ8pf@7+Tp0>;kIGg'j]hZF5N0qY%=o%(#\Oi+]VdG3,:#>//AV<R)c,R!OPfj]HWne6+&6:OPq"H:h6W(!hL2XKqqpG[fWR>'%_>T'0I*(Y"1#DIj&]dHDp*29(K=0DD598L;7Ym=0[Gjp=O\YU;@TVHDe,>s.'iYk*QiC!W]*VO\b6@\2WT38Bu7Co/&$fb0<EE,p!)13o\7Ug"Jm(2f5-sZ49/kJcT2f*c(0#GK9Mb*8nNI9*h_ZEq8^>SI"[b"+dH#g:o<1kn\6nq5NQf[_m3O&c.3G7@NaKMT+TC"hCk+s'g\/XQ]Fe8+&F4*W>Gb]dS5=LJhqE,Th?nBBNVp_(]E6gP/L7`K:NPa7BP^s*Q'?VEbK\mK>rb)s!P<1T:Mq4'qIpLWgJ6anrlj&D.q6"'$qFG0%7D5/sYh[W=&Z4n_rs;2T8!mA.28HHg"_jMd,CZ8S:MS,m4]Ce=WH[Cb=kf2.)gh[%-4+_hQQnfubob]p%0Ynq\j\A:Nf<H=#"Tk_4$d'',qf4>['b%]C:[8EtO94/u@7W@Rr7$1)8c-E@&o>[_Z^(Ko]Onp04&3aU_F`9.&o7I6F66tPW+(4da4-<5^~>endstream
92
+ endobj
93
+ 14 0 obj
94
+ <<
95
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 791
96
+ >>
97
+ stream
98
+ GatU1?#Q2d'Re<2\1a+&UbI:,j1_AW2Jq`dI^$:Q'*5S;-.!:)Y5`qe@o?tA>.1(,kAWEDg72q,&3Qoi:"B:l&\S-7JMN%!J-oXKF:KCeg#J#PAfsCe&BTb(7,g^f:UHc#qD;S>K'IN66?K8f.VmOFW+^=k7WlHC$t%3-jKP`O.U,'d=]rCikRJf?Z+S$8eWqrs;K1g"'ao[!?\a;Meso+IK>tAc[cD*I@"IL`^l:8t<huk?Q9f=)bY>6AX+Z![`Qg+dP@dC'd^I0U?=DY.NJ^Q^E&+>:r03WR?[R3Fg:Yt'/qdr>cs%JL/W6)L2+:saY/dM5?Q:(e7VM6P/(\cA_"Wg[&%6?j,*VpSZ6[_FSs;lCimBsRb@/75%t'kO[lDdLb?k4:^*\iI8`SP.gSD53];WkP]ZTfHIY!^*`:n!A4:sQp5_N?'li^\*P\[:p:gMd'R]nK!"k>5C[(3jd2a+mdPh:lJ$AT#DOVhS4>&Oa2nhApBatMhh=9=96=S?SP>PZ+_IT"XY6c&)o-qlcVl1iD6UQQg7QR)H6=7O5'31fcmW"a+f%HT^5f.lc7.E@Vc'3/lk)"XsdjrR_]2:]40EdHe[&9S=en9a>if[VAIo=PO[g;^'/j`#'6i\3,Q8<;5+h7Z_l%`CYg.HrN+FBG1UL4]urTu1ir@@1nEU-!!;>A$'Bc?hTTVuU%7\!)Nm/UgeLpNOM*'5u#;.rIk(\FJPMYC)(of_btS$9W7a.ghS2SA/mGNTX"Sdsj9IP3)Oik4@`;k'>!$[M:XE0`(qk%b,@~>endstream
99
+ endobj
100
+ 15 0 obj
101
+ <<
102
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 695
103
+ >>
104
+ stream
105
+ GatUp:NOu=&B4,6'RQU"MQok7Uu;HQ"_K^oj5.9odO#Qk-)3\=4i<%f-+=n`.6`u@iacTEgY[A7'n_ArQLCOMGi-C(9*VJPFpi+mcCt1<([^S@N8<l+1pCL@g.Z9ne^.q8AT]nDZrjV&:&i#rAgG;9Zq7)=h3o%amOG3-Hq.VW%ZTO8=lWjUq\.[-<Vo8;UPd<r*\19:m9$":'d2uq(T:8`2^PM-_J=)Jj9JmATdW+(h&`:&kQ4g4$m<(#U0,;n&L`JGLoJus=Fn'b%d;DCM+e0qL:rin[(8`2Nbaa!Ms,S8BH?`j$M4iuWa&Y0&[PZ(-mq_IP:#Un4H@-.WH^\52aj[fJI,:p4i.__`C7jDn?s_)as1jM'CLUF^@G;HPh?)p.':??brC2*@o8aDY1ips%fr5NPWFj>XaB#8_o;;ofkBFqgc-#]fO"A]h/Jg^]a?K,8Ue*YbW7s_>X+kU6:Ps%Mt`GP(]/l670uS^`,f-BoMGi(go'?bF?d3lV[c<g.9IP,.*t&p"2_[r&3Wd0=9"Vo1=8Q%Gi3nl.tLXTqbZfJY8obchCGR\[q&tb25^K5>&E&.+nda_[&H^96=8Y;6u&Xa-<Vm83]IEEGeL)rFZMMu'g$1J*<6#ujC#4gbN25N8:W*s1F,V1lDbBjIF')VlEZ$!n9#_jA@5DXY(;js*?R$jqZH9o@.+~>endstream
106
+ endobj
107
+ xref
108
+ 0 16
109
+ 0000000000 65535 f
110
+ 0000000073 00000 n
111
+ 0000000124 00000 n
112
+ 0000000231 00000 n
113
+ 0000000343 00000 n
114
+ 0000000462 00000 n
115
+ 0000000657 00000 n
116
+ 0000000852 00000 n
117
+ 0000001047 00000 n
118
+ 0000001242 00000 n
119
+ 0000001311 00000 n
120
+ 0000001595 00000 n
121
+ 0000001673 00000 n
122
+ 0000002930 00000 n
123
+ 0000004394 00000 n
124
+ 0000005276 00000 n
125
+ trailer
126
+ <<
127
+ /ID
128
+ [<d0b9f46517359c81a4251c675bcb194d><d0b9f46517359c81a4251c675bcb194d>]
129
+ % ReportLab generated PDF document -- digest (http://www.reportlab.com)
130
+
131
+ /Info 10 0 R
132
+ /Root 9 0 R
133
+ /Size 16
134
+ >>
135
+ startxref
136
+ 6062
137
+ %%EOF
detector/attribution.py CHANGED
@@ -77,7 +77,7 @@ class ModelAttributor:
77
  - Confidence-weighted aggregation
78
  - Explainable reasoning
79
  """
80
- # DOCUMENT-ALIGNED: Metric weights from technical specification
81
  METRIC_WEIGHTS = {"perplexity" : 0.25,
82
  "structural" : 0.15,
83
  "semantic_analysis" : 0.15,
@@ -86,7 +86,7 @@ class ModelAttributor:
86
  "multi_perturbation_stability" : 0.10,
87
  }
88
 
89
- # DOMAIN-AWARE model patterns for ALL 16 DOMAINS
90
  DOMAIN_MODEL_PREFERENCES = {Domain.GENERAL : [AIModel.GPT_4, AIModel.CLAUDE_3_SONNET, AIModel.GEMINI_PRO, AIModel.GPT_3_5],
91
  Domain.ACADEMIC : [AIModel.GPT_4, AIModel.CLAUDE_3_OPUS, AIModel.GEMINI_ULTRA, AIModel.GPT_4_TURBO],
92
  Domain.TECHNICAL_DOC : [AIModel.GPT_4_TURBO, AIModel.CLAUDE_3_SONNET, AIModel.LLAMA_3, AIModel.GPT_4],
@@ -105,7 +105,7 @@ class ModelAttributor:
105
  Domain.TUTORIAL : [AIModel.GPT_4, AIModel.CLAUDE_3_SONNET, AIModel.GEMINI_PRO, AIModel.GPT_4_TURBO],
106
  }
107
 
108
- # Enhanced Model-specific fingerprints with comprehensive patterns
109
  MODEL_FINGERPRINTS = {AIModel.GPT_3_5 : {"phrases" : ["as an ai language model",
110
  "i don't have personal opinions",
111
  "it's important to note that",
@@ -460,13 +460,13 @@ class ModelAttributor:
460
  domain = domain,
461
  )
462
 
463
- # Domain-aware prediction - FIXED: Always show the actual highest probability model
464
  predicted_model, confidence = self._make_domain_aware_prediction(combined_scores = combined_scores,
465
  domain = domain,
466
  domain_preferences = domain_preferences,
467
  )
468
 
469
- # Reasoning with domain context - FIXED
470
  reasoning = self._generate_detailed_reasoning(predicted_model = predicted_model,
471
  confidence = confidence,
472
  domain = domain,
@@ -490,7 +490,7 @@ class ModelAttributor:
490
 
491
  def _calculate_fingerprint_scores(self, text: str, domain: Domain) -> Dict[AIModel, float]:
492
  """
493
- Calculate fingerprint match scores with DOMAIN CALIBRATION - FIXED for all domains
494
  """
495
  scores = {model: 0.0 for model in AIModel if model not in [AIModel.HUMAN, AIModel.UNKNOWN]}
496
 
@@ -812,7 +812,7 @@ class ModelAttributor:
812
 
813
  def _make_domain_aware_prediction(self, combined_scores: Dict[str, float], domain: Domain, domain_preferences: List[AIModel]) -> Tuple[AIModel, float]:
814
  """
815
- Domain aware prediction that considers domain-specific model preferences - FIXED
816
  """
817
  if not combined_scores:
818
  return AIModel.UNKNOWN, 0.0
@@ -825,109 +825,103 @@ class ModelAttributor:
825
 
826
  best_model_name, best_score = sorted_models[0]
827
 
828
- # FIXED: Only return UNKNOWN if the best score is very low
829
- # Use a more reasonable threshold for attribution
830
- if best_score < 0.05: # Changed from 0.08 to 0.05 to be less restrictive
831
  return AIModel.UNKNOWN, best_score
832
 
833
- # FIXED: Don't override with domain preferences if there's a clear winner
834
- # Only consider domain preferences if scores are very close
835
- if len(sorted_models) > 1:
836
- second_model_name, second_score = sorted_models[1]
837
- score_difference = best_score - second_score
838
-
839
- # If scores are very close (within 3%) and second is domain-preferred, consider it
840
- if score_difference < 0.03:
841
- try:
842
- best_model = AIModel(best_model_name)
843
- second_model = AIModel(second_model_name)
844
-
845
- # If second model is domain-preferred and first is not, prefer second
846
- if (second_model in domain_preferences and
847
- best_model not in domain_preferences):
848
- best_model_name = second_model_name
849
- best_score = second_score
850
- except ValueError:
851
- pass
852
-
853
  try:
854
  best_model = AIModel(best_model_name)
 
855
  except ValueError:
856
  best_model = AIModel.UNKNOWN
857
 
858
- # Calculate confidence based on score dominance
859
- if len(sorted_models) > 1:
860
  second_score = sorted_models[1][1]
861
- margin = best_score - second_score
862
- # Confidence based on both absolute score and margin
863
- confidence = min(1.0, best_score * 0.6 + margin * 2.0)
 
864
  else:
865
- confidence = best_score * 0.7
866
 
867
- # FIXED: Don't downgrade to UNKNOWN based on confidence alone
868
- # If we have a model with reasonable probability, show it even with low confidence
869
- return best_model, confidence
870
 
871
 
872
  def _generate_detailed_reasoning(self, predicted_model: AIModel, confidence: float, domain: Domain, metric_contributions: Dict[str, float],
873
  combined_scores: Dict[str, float]) -> List[str]:
874
  """
875
- Generate Explainable reasoning - FIXED to show proper formatting
876
  """
877
  reasoning = []
878
 
879
  reasoning.append("**AI Model Attribution Analysis**")
880
  reasoning.append("")
881
- reasoning.append(f"**Domain**: {domain.value.replace('_', ' ').title()}")
882
- reasoning.append("")
883
 
884
  # Show prediction with confidence
885
  if (predicted_model == AIModel.UNKNOWN):
886
  reasoning.append("**Most Likely**: Unable to determine with high confidence")
887
- reasoning.append("")
888
- reasoning.append("**Top Candidates:**")
889
-
890
  else:
891
  model_name = predicted_model.value.replace("-", " ").replace("_", " ").title()
892
  reasoning.append(f"**Predicted Model**: {model_name}")
893
  reasoning.append(f"**Confidence**: {confidence*100:.1f}%")
894
- reasoning.append("")
895
- reasoning.append("**Model Probability Distribution:**")
896
 
 
 
 
 
 
897
  reasoning.append("")
898
 
899
- # Show top candidates in proper format
900
  if combined_scores:
901
  sorted_models = sorted(combined_scores.items(), key = lambda x: x[1], reverse = True)
902
 
903
  for i, (model_name, score) in enumerate(sorted_models[:6]):
904
- # Skip very low probability models
905
  if (score < 0.01):
906
  continue
907
-
908
  display_name = model_name.replace("-", " ").replace("_", " ").title()
909
  percentage = score * 100
910
 
911
- # Single line format: "• Model Name: XX.X%"
912
  reasoning.append(f"• **{display_name}**: {percentage:.1f}%")
913
 
914
  reasoning.append("")
915
 
916
- # Domain-specific insights - FIXED: Removed duplicate header
917
  reasoning.append("**Analysis Notes:**")
918
- reasoning.append(f"• Calibrated for {domain.value.replace('_', ' ')} domain")
919
-
920
- if (domain in [Domain.ACADEMIC, Domain.TECHNICAL_DOC, Domain.AI_ML, Domain.SOFTWARE_DEV, Domain.ENGINEERING, Domain.SCIENCE]):
921
- reasoning.append("• Higher weight on structural coherence and technical patterns")
922
 
923
- elif (domain in [Domain.CREATIVE, Domain.MARKETING, Domain.SOCIAL_MEDIA, Domain.BLOG_PERSONAL]):
924
- reasoning.append("• Emphasis on linguistic diversity and stylistic variation")
925
-
926
- elif (domain in [Domain.LEGAL, Domain.MEDICAL]):
927
- reasoning.append("• Focus on formal language and specialized terminology")
928
-
929
- elif (domain in [Domain.BUSINESS, Domain.JOURNALISM, Domain.TUTORIAL]):
930
- reasoning.append("• Balanced analysis across multiple attribution factors")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931
 
932
  return reasoning
933
 
 
77
  - Confidence-weighted aggregation
78
  - Explainable reasoning
79
  """
80
+ # Metric weights from technical specification
81
  METRIC_WEIGHTS = {"perplexity" : 0.25,
82
  "structural" : 0.15,
83
  "semantic_analysis" : 0.15,
 
86
  "multi_perturbation_stability" : 0.10,
87
  }
88
 
89
+ # Domain-aware model patterns for ALL 16 DOMAINS
90
  DOMAIN_MODEL_PREFERENCES = {Domain.GENERAL : [AIModel.GPT_4, AIModel.CLAUDE_3_SONNET, AIModel.GEMINI_PRO, AIModel.GPT_3_5],
91
  Domain.ACADEMIC : [AIModel.GPT_4, AIModel.CLAUDE_3_OPUS, AIModel.GEMINI_ULTRA, AIModel.GPT_4_TURBO],
92
  Domain.TECHNICAL_DOC : [AIModel.GPT_4_TURBO, AIModel.CLAUDE_3_SONNET, AIModel.LLAMA_3, AIModel.GPT_4],
 
105
  Domain.TUTORIAL : [AIModel.GPT_4, AIModel.CLAUDE_3_SONNET, AIModel.GEMINI_PRO, AIModel.GPT_4_TURBO],
106
  }
107
 
108
+ # Model-specific fingerprints with comprehensive patterns
109
  MODEL_FINGERPRINTS = {AIModel.GPT_3_5 : {"phrases" : ["as an ai language model",
110
  "i don't have personal opinions",
111
  "it's important to note that",
 
460
  domain = domain,
461
  )
462
 
463
+ # Domain-aware prediction : Always show the actual highest probability model
464
  predicted_model, confidence = self._make_domain_aware_prediction(combined_scores = combined_scores,
465
  domain = domain,
466
  domain_preferences = domain_preferences,
467
  )
468
 
469
+ # Reasoning with domain context
470
  reasoning = self._generate_detailed_reasoning(predicted_model = predicted_model,
471
  confidence = confidence,
472
  domain = domain,
 
490
 
491
  def _calculate_fingerprint_scores(self, text: str, domain: Domain) -> Dict[AIModel, float]:
492
  """
493
+ Calculate fingerprint match scores with domain calibration - for all domains
494
  """
495
  scores = {model: 0.0 for model in AIModel if model not in [AIModel.HUMAN, AIModel.UNKNOWN]}
496
 
 
812
 
813
  def _make_domain_aware_prediction(self, combined_scores: Dict[str, float], domain: Domain, domain_preferences: List[AIModel]) -> Tuple[AIModel, float]:
814
  """
815
+ Domain aware prediction that considers domain-specific model preferences
816
  """
817
  if not combined_scores:
818
  return AIModel.UNKNOWN, 0.0
 
825
 
826
  best_model_name, best_score = sorted_models[0]
827
 
828
+ # Thresholding to show model only if confidence is sufficient
829
+ if (best_score < 0.01):
 
830
  return AIModel.UNKNOWN, best_score
831
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
  try:
833
  best_model = AIModel(best_model_name)
834
+
835
  except ValueError:
836
  best_model = AIModel.UNKNOWN
837
 
838
+ # Calculate confidence - be more generous
839
+ if (len(sorted_models) > 1):
840
  second_score = sorted_models[1][1]
841
+ margin = best_score - second_score
842
+ # More generous confidence calculation
843
+ confidence = min(1.0, best_score * 0.8 + margin * 1.5)
844
+
845
  else:
846
+ confidence = best_score * 0.9
847
 
848
+ # Always return the actual best model, never downgrade to UNKNOWN
849
+ return best_model, max(0.05, confidence)
 
850
 
851
 
852
  def _generate_detailed_reasoning(self, predicted_model: AIModel, confidence: float, domain: Domain, metric_contributions: Dict[str, float],
853
  combined_scores: Dict[str, float]) -> List[str]:
854
  """
855
+ Generate Explainable reasoning - ENHANCED version
856
  """
857
  reasoning = []
858
 
859
  reasoning.append("**AI Model Attribution Analysis**")
860
  reasoning.append("")
 
 
861
 
862
  # Show prediction with confidence
863
  if (predicted_model == AIModel.UNKNOWN):
864
  reasoning.append("**Most Likely**: Unable to determine with high confidence")
865
+
 
 
866
  else:
867
  model_name = predicted_model.value.replace("-", " ").replace("_", " ").title()
868
  reasoning.append(f"**Predicted Model**: {model_name}")
869
  reasoning.append(f"**Confidence**: {confidence*100:.1f}%")
 
 
870
 
871
+ reasoning.append(f"**Domain**: {domain.value.replace('_', ' ').title()}")
872
+ reasoning.append("")
873
+
874
+ # Show model probability distribution
875
+ reasoning.append("**Model Probability Distribution:**")
876
  reasoning.append("")
877
 
 
878
  if combined_scores:
879
  sorted_models = sorted(combined_scores.items(), key = lambda x: x[1], reverse = True)
880
 
881
  for i, (model_name, score) in enumerate(sorted_models[:6]):
882
+ # Skip very low probabilities
883
  if (score < 0.01):
884
  continue
885
+
886
  display_name = model_name.replace("-", " ").replace("_", " ").title()
887
  percentage = score * 100
888
 
889
+ # Use proper markdown formatting
890
  reasoning.append(f"• **{display_name}**: {percentage:.1f}%")
891
 
892
  reasoning.append("")
893
 
894
+ # Add analysis insights
895
  reasoning.append("**Analysis Notes:**")
 
 
 
 
896
 
897
+ if (confidence < 0.3):
898
+ reasoning.append("• Low confidence attribution - text patterns are ambiguous")
899
+ reasoning.append("• May be human-written or from multiple AI sources")
900
+
901
+ else:
902
+ reasoning.append(f"• Calibrated for {domain.value.replace('_', ' ')} domain")
903
+
904
+ # Domain-specific insights
905
+ domain_insights = {Domain.ACADEMIC : "Academic writing patterns analyzed",
906
+ Domain.TECHNICAL_DOC : "Technical coherence and structure weighted",
907
+ Domain.CREATIVE : "Stylistic and linguistic diversity emphasized",
908
+ Domain.SOCIAL_MEDIA : "Casual language and engagement patterns considered",
909
+ Domain.AI_ML : "Technical terminology and analytical patterns emphasized",
910
+ Domain.SOFTWARE_DEV : "Code-like structures and technical precision weighted",
911
+ Domain.ENGINEERING : "Technical specifications and formal language analyzed",
912
+ Domain.SCIENCE : "Scientific terminology and methodological patterns considered",
913
+ Domain.BUSINESS : "Professional communication and strategic language weighted",
914
+ Domain.LEGAL : "Formal language and legal terminology emphasized",
915
+ Domain.MEDICAL : "Medical terminology and clinical language analyzed",
916
+ Domain.JOURNALISM : "News reporting style and factual presentation weighted",
917
+ Domain.MARKETING : "Persuasive language and engagement patterns considered",
918
+ Domain.BLOG_PERSONAL : "Personal voice and conversational style analyzed",
919
+ Domain.TUTORIAL : "Instructional clarity and step-by-step structure weighted",
920
+ }
921
+
922
+ insight = domain_insights.get(domain, "Multiple attribution factors analyzed")
923
+
924
+ reasoning.append(f"• {insight}")
925
 
926
  return reasoning
927
 
detector/highlighter.py CHANGED
@@ -48,14 +48,14 @@ class TextHighlighter:
48
  - Explainable tooltips
49
  - Highlighting metrics calculation
50
  """
51
- # Color thresholds with mixed content support
52
  COLOR_THRESHOLDS = [(0.00, 0.10, "very-high-human", "#dcfce7", "Very likely human-written"),
53
  (0.10, 0.25, "high-human", "#bbf7d0", "Likely human-written"),
54
  (0.25, 0.40, "medium-human", "#86efac", "Possibly human-written"),
55
  (0.40, 0.60, "uncertain", "#fef9c3", "Uncertain"),
56
  (0.60, 0.75, "medium-ai", "#fde68a", "Possibly AI-generated"),
57
  (0.75, 0.90, "high-ai", "#fed7aa", "Likely AI-generated"),
58
- (0.90, 1.01, "very-high-ai", "#fecaca", "Very likely AI-generated"),
59
  ]
60
 
61
  # Mixed content pattern
@@ -86,11 +86,23 @@ class TextHighlighter:
86
  self.text_processor = TextProcessor()
87
  self.domain = domain
88
  self.domain_thresholds = get_threshold_for_domain(domain)
89
- self.ensemble = ensemble_classifier or EnsembleClassifier(primary_method = "confidence_calibrated",
90
- fallback_method = "domain_weighted",
91
- )
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def generate_highlights(self, text: str, metric_results: Dict[str, MetricResult], ensemble_result: Optional[EnsembleResult] = None,
95
  enabled_metrics: Optional[Dict[str, bool]] = None, use_sentence_level: bool = True) -> List[HighlightedSentence]:
96
  """
@@ -112,80 +124,197 @@ class TextHighlighter:
112
  --------
113
  { list } : List of HighlightedSentence objects
114
  """
115
- # Get domain-appropriate weights for enabled metrics
116
- if enabled_metrics is None:
117
- enabled_metrics = {name: True for name in metric_results.keys()}
118
-
119
- weights = get_active_metric_weights(self.domain, enabled_metrics)
120
-
121
- # Split text into sentences
122
- sentences = self._split_sentences(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
- if not sentences:
125
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- # Calculate probabilities for each sentence using ENSEMBLE METHODS
128
- highlighted_sentences = list()
129
-
130
- for idx, sentence in enumerate(sentences):
131
- if use_sentence_level:
132
- # Use ENSEMBLE for sentence-level analysis
133
- ai_prob, human_prob, mixed_prob, confidence, breakdown = self._calculate_sentence_ensemble_probability(sentence = sentence,
134
- metric_results = metric_results,
135
- weights = weights,
136
- ensemble_result = ensemble_result,
137
- )
138
- else:
139
- # Use document-level ensemble probabilities
140
- ai_prob, human_prob, mixed_prob, confidence, breakdown = self._get_document_ensemble_probability(ensemble_result = ensemble_result,
141
- metric_results = metric_results,
142
- weights = weights,
143
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- # Apply domain-specific adjustments
146
- ai_prob = self._apply_domain_specific_adjustments(sentence, ai_prob, len(sentence.split()))
147
 
148
- # Determine if this is mixed content
149
- is_mixed_content = (mixed_prob > self.MIXED_THRESHOLD)
 
150
 
151
- # Get confidence level
152
- confidence_level = get_confidence_level(confidence)
153
 
154
- # Get color class (consider mixed content)
155
- color_class, color_hex, tooltip_base = self._get_color_for_probability(probability = ai_prob,
156
- is_mixed_content = is_mixed_content,
157
- mixed_prob = mixed_prob,
158
- )
159
 
160
- # Generate enhanced tooltip
161
- tooltip = self._generate_ensemble_tooltip(sentence = sentence,
162
- ai_prob = ai_prob,
163
- human_prob = human_prob,
164
- mixed_prob = mixed_prob,
165
- confidence = confidence,
166
- confidence_level = confidence_level,
167
- tooltip_base = tooltip_base,
168
- breakdown = breakdown,
169
- is_mixed_content = is_mixed_content,
170
- )
171
 
172
- highlighted_sentences.append(HighlightedSentence(text = sentence,
173
- ai_probability = ai_prob,
174
- human_probability = human_prob,
175
- mixed_probability = mixed_prob,
176
- confidence = confidence,
177
- confidence_level = confidence_level,
178
- color_class = color_class,
179
- tooltip = tooltip,
180
- index = idx,
181
- is_mixed_content = is_mixed_content,
182
- metric_breakdown = breakdown,
183
- )
184
- )
185
-
186
- return highlighted_sentences
187
 
188
-
189
  def _calculate_sentence_ensemble_probability(self, sentence: str, metric_results: Dict[str, MetricResult], weights: Dict[str, float],
190
  ensemble_result: Optional[EnsembleResult] = None) -> Tuple[float, float, float, float, Dict[str, float]]:
191
  """
@@ -193,10 +322,24 @@ class TextHighlighter:
193
  """
194
  sentence_length = len(sentence.split())
195
 
196
- # IMPROVED: Better handling of short sentences
197
  if (sentence_length < 3):
198
- # Return neutral probability for very short sentences with low confidence
199
- return 0.5, 0.5, 0.0, 0.3, {"short_sentence": 0.5}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  # Calculate sentence-level metric results
202
  sentence_metric_results = dict()
@@ -204,20 +347,27 @@ class TextHighlighter:
204
 
205
  for name, doc_result in metric_results.items():
206
  if doc_result.error is None:
207
- # Compute sentence-level probability for this metric
208
- sentence_prob = self._compute_sentence_metric(metric_name = name,
209
- sentence = sentence,
210
- result = doc_result,
211
- weight = weights.get(name, 0.0),
212
- )
213
-
214
- # Create sentence-level MetricResult
215
- sentence_metric_results[name] = self._create_sentence_metric_result(metric_name = name,
216
- ai_prob = sentence_prob,
217
- doc_result = doc_result,
218
- )
 
 
 
 
219
 
220
- breakdown[name] = sentence_prob
 
 
 
221
 
222
  # Use ensemble to combine sentence-level metrics
223
  if sentence_metric_results:
@@ -226,8 +376,11 @@ class TextHighlighter:
226
  domain = self.domain,
227
  )
228
 
229
- return (ensemble_sentence_result.ai_probability, ensemble_sentence_result.human_probability, ensemble_sentence_result.mixed_probability,
230
- ensemble_sentence_result.overall_confidence, breakdown)
 
 
 
231
 
232
  except Exception as e:
233
  logger.warning(f"Sentence ensemble failed: {e}")
@@ -262,12 +415,12 @@ class TextHighlighter:
262
  return adjusted_prob
263
 
264
 
265
- def _create_sentence_metric_result(self, metric_name: str, ai_prob: float, doc_result: MetricResult) -> MetricResult:
266
  """
267
  Create sentence-level MetricResult from document-level result
268
  """
269
- # Adjust confidence based on sentence characteristics
270
- sentence_confidence = self._calculate_sentence_confidence(doc_result.confidence)
271
 
272
  return MetricResult(metric_name = metric_name,
273
  ai_probability = ai_prob,
@@ -279,12 +432,15 @@ class TextHighlighter:
279
  )
280
 
281
 
282
- def _calculate_sentence_confidence(self, doc_confidence: float) -> float:
283
  """
284
- Calculate confidence for sentence-level analysis
285
  """
286
- # Sentence-level analysis typically has lower confidence
287
- return max(0.1, doc_confidence * 0.8)
 
 
 
288
 
289
 
290
  def _calculate_weighted_probability(self, metric_results: Dict[str, MetricResult], weights: Dict[str, float], breakdown: Dict[str, float]) -> Tuple[float, float, float, float, Dict[str, float]]:
@@ -306,8 +462,8 @@ class TextHighlighter:
306
  confidences.append(result.confidence)
307
  total_weight += weight
308
 
309
- if not weighted_ai_probs or total_weight == 0:
310
- return 0.5, 0.5, 0.0, 0.5, {}
311
 
312
  ai_prob = sum(weighted_ai_probs) / total_weight
313
  human_prob = sum(weighted_human_probs) / total_weight
@@ -331,84 +487,94 @@ class TextHighlighter:
331
  else:
332
  # Calculate from metrics
333
  return self._calculate_weighted_probability(metric_results, weights, {})
334
-
335
 
336
  def _apply_domain_specific_adjustments(self, sentence: str, ai_prob: float, sentence_length: int) -> float:
337
  """
338
- Apply domain-specific adjustments to AI probability - UPDATED FOR ALL DOMAINS
339
  """
 
 
340
  sentence_lower = sentence.lower()
341
 
342
  # Technical & AI/ML domains
343
- if self.domain in [Domain.AI_ML, Domain.SOFTWARE_DEV, Domain.TECHNICAL_DOC, Domain.ENGINEERING, Domain.SCIENCE]:
344
  if self._has_technical_terms(sentence_lower):
345
- # Technical terms more common in AI
346
- ai_prob *= 1.1
347
 
348
  elif self._has_code_like_patterns(sentence):
349
- ai_prob *= 1.15
350
 
351
- elif sentence_length > 35:
352
- ai_prob *= 1.05
353
 
354
  # Creative & informal domains
355
- elif self.domain in [Domain.CREATIVE, Domain.SOCIAL_MEDIA, Domain.BLOG_PERSONAL]:
356
  if self._has_informal_language(sentence_lower):
357
- # Informal language more human-like
358
- ai_prob *= 0.7
359
 
360
  elif self._has_emotional_language(sentence):
361
- ai_prob *= 0.8
362
 
363
  elif (sentence_length < 10):
364
- ai_prob *= 0.8
365
 
366
  # Academic & formal domains
367
- elif self.domain in [Domain.ACADEMIC, Domain.LEGAL, Domain.MEDICAL]:
368
  if self._has_citation_patterns(sentence):
369
- # Citations more human-like
370
- ai_prob *= 0.8
371
 
372
  elif self._has_technical_terms(sentence_lower):
373
- ai_prob *= 1.1
374
 
375
  elif (sentence_length > 40):
376
- ai_prob *= 1.1
377
 
378
  # Business & professional domains
379
- elif self.domain in [Domain.BUSINESS, Domain.MARKETING, Domain.JOURNALISM]:
380
  if self._has_business_jargon(sentence_lower):
381
- # Jargon can be AI-like
382
- ai_prob *= 1.05
383
 
384
  elif self._has_ambiguous_phrasing(sentence_lower):
385
- # Ambiguity more human
386
- ai_prob *= 0.9
387
 
388
  elif (15 <= sentence_length <= 25):
389
- ai_prob *= 0.9
390
 
391
  # Tutorial & educational domains
392
  elif (self.domain == Domain.TUTORIAL):
393
  if self._has_instructional_language(sentence_lower):
394
- # Instructional tone more human
395
- ai_prob *= 0.85
396
 
397
  elif self._has_step_by_step_pattern(sentence):
398
- ai_prob *= 0.8
399
 
400
  elif self._has_examples(sentence):
401
- ai_prob *= 0.9
402
 
403
  # General domain - minimal adjustments
404
- elif self.domain == Domain.GENERAL:
405
  if self._has_complex_structure(sentence):
406
- ai_prob *= 0.9
407
 
408
  elif self._has_repetition(sentence):
409
- ai_prob *= 1.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
 
411
- return max(0.0, min(1.0, ai_prob))
412
 
413
 
414
  def _apply_metric_specific_adjustments(self, metric_name: str, sentence: str, base_prob: float, sentence_length: int, thresholds: MetricThresholds) -> float:
@@ -466,8 +632,12 @@ class TextHighlighter:
466
 
467
  def _get_color_for_probability(self, probability: float, is_mixed_content: bool = False, mixed_prob: float = 0.0) -> Tuple[str, str, str]:
468
  """
469
- Get color class with mixed content support
470
  """
 
 
 
 
471
  # Check mixed content first
472
  if (is_mixed_content and (mixed_prob > self.MIXED_THRESHOLD)):
473
  return "mixed-content", "#e9d5ff", f"Mixed AI/Human content ({mixed_prob:.1%} mixed)"
@@ -477,12 +647,12 @@ class TextHighlighter:
477
  if (min_thresh <= probability < max_thresh):
478
  return color_class, color_hex, tooltip
479
 
480
- # Fallback
481
- return "uncertain", "#fef9c3", "Uncertain"
482
-
483
 
484
  def _generate_ensemble_tooltip(self, sentence: str, ai_prob: float, human_prob: float, mixed_prob: float, confidence: float, confidence_level: ConfidenceLevel,
485
- tooltip_base: str, breakdown: Optional[Dict[str, float]] = None, is_mixed_content: bool = False) -> str:
486
  """
487
  Generate enhanced tooltip with ENSEMBLE information
488
  """
@@ -504,7 +674,7 @@ class TextHighlighter:
504
  for metric, prob in list(breakdown.items())[:4]:
505
  tooltip += f"\n• {metric}: {prob:.1%}"
506
 
507
- tooltip += f"\n\nEnsemble Method: {self.ensemble.primary_method}"
508
 
509
  return tooltip
510
 
@@ -619,7 +789,7 @@ class TextHighlighter:
619
  Analyze sentence complexity (0 = simple, 1 = complex)
620
  """
621
  words = sentence.split()
622
- if len(words) < 5:
623
  return 0.2
624
 
625
  complexity_indicators = ['although', 'because', 'while', 'when', 'if', 'since', 'unless', 'until', 'which', 'that', 'who', 'whom', 'whose', 'and', 'but', 'or', 'yet', 'so', 'however', 'therefore', 'moreover', 'furthermore', 'nevertheless', ',', ';', ':', '—']
@@ -637,7 +807,7 @@ class TextHighlighter:
637
 
638
  clause_indicators = [',', ';', 'and', 'but', 'or', 'because', 'although']
639
  clause_count = sum(1 for indicator in clause_indicators if indicator in sentence.lower())
640
- score += min(0.2, clause_count * 0.05)
641
 
642
  return min(1.0, score)
643
 
@@ -671,7 +841,7 @@ class TextHighlighter:
671
  for sentence in sentences:
672
  clean_sentence = sentence.strip()
673
 
674
- if (len(clean_sentence) >= 10):
675
  filtered_sentences.append(clean_sentence)
676
 
677
  return filtered_sentences
@@ -1002,7 +1172,7 @@ class TextHighlighter:
1002
  total_sentences = len(highlighted_sentences)
1003
 
1004
  # Calculate weighted risk score
1005
- weighted_risk = 0.0
1006
 
1007
  for sent in highlighted_sentences:
1008
  weight = self.RISK_WEIGHTS.get(sent.color_class, 0.4)
 
48
  - Explainable tooltips
49
  - Highlighting metrics calculation
50
  """
51
+ # Color thresholds with mixed content support - FIXED: No gaps
52
  COLOR_THRESHOLDS = [(0.00, 0.10, "very-high-human", "#dcfce7", "Very likely human-written"),
53
  (0.10, 0.25, "high-human", "#bbf7d0", "Likely human-written"),
54
  (0.25, 0.40, "medium-human", "#86efac", "Possibly human-written"),
55
  (0.40, 0.60, "uncertain", "#fef9c3", "Uncertain"),
56
  (0.60, 0.75, "medium-ai", "#fde68a", "Possibly AI-generated"),
57
  (0.75, 0.90, "high-ai", "#fed7aa", "Likely AI-generated"),
58
+ (0.90, 1.00, "very-high-ai", "#fecaca", "Very likely AI-generated"),
59
  ]
60
 
61
  # Mixed content pattern
 
86
  self.text_processor = TextProcessor()
87
  self.domain = domain
88
  self.domain_thresholds = get_threshold_for_domain(domain)
89
+ self.ensemble = ensemble_classifier or self._create_default_ensemble()
 
 
90
 
91
 
92
+ def _create_default_ensemble(self) -> EnsembleClassifier:
93
+ """
94
+ Create default ensemble classifier with proper error handling
95
+ """
96
+ try:
97
+ return EnsembleClassifier(primary_method = "confidence_calibrated",
98
+ fallback_method = "domain_weighted",
99
+ )
100
+ except Exception as e:
101
+ logger.warning(f"Failed to create default ensemble: {e}. Using fallback mode.")
102
+ # Return a minimal ensemble or raise based on requirements
103
+ return EnsembleClassifier(primary_method = "weighted_average")
104
+
105
+
106
  def generate_highlights(self, text: str, metric_results: Dict[str, MetricResult], ensemble_result: Optional[EnsembleResult] = None,
107
  enabled_metrics: Optional[Dict[str, bool]] = None, use_sentence_level: bool = True) -> List[HighlightedSentence]:
108
  """
 
124
  --------
125
  { list } : List of HighlightedSentence objects
126
  """
127
+ try:
128
+ # Validate inputs
129
+ if not text or not text.strip():
130
+ return self._handle_empty_text(text, metric_results, ensemble_result)
131
+
132
+ # Get domain-appropriate weights for enabled metrics
133
+ if enabled_metrics is None:
134
+ enabled_metrics = {name: True for name in metric_results.keys()}
135
+
136
+ weights = get_active_metric_weights(self.domain, enabled_metrics)
137
+
138
+ # Split text into sentences with error handling
139
+ sentences = self._split_sentences_with_fallback(text)
140
+
141
+ if not sentences:
142
+ return self._handle_no_sentences(text, metric_results, ensemble_result)
143
+
144
+ # Calculate probabilities for each sentence using ENSEMBLE METHODS
145
+ highlighted_sentences = list()
146
+
147
+ for idx, sentence in enumerate(sentences):
148
+ try:
149
+ if use_sentence_level:
150
+ # Use ENSEMBLE for sentence-level analysis
151
+ ai_prob, human_prob, mixed_prob, confidence, breakdown = self._calculate_sentence_ensemble_probability(sentence = sentence,
152
+ metric_results = metric_results,
153
+ weights = weights,
154
+ ensemble_result = ensemble_result,
155
+ )
156
+ else:
157
+ # Use document-level ensemble probabilities
158
+ ai_prob, human_prob, mixed_prob, confidence, breakdown = self._get_document_ensemble_probability(ensemble_result = ensemble_result,
159
+ metric_results = metric_results,
160
+ weights = weights,
161
+ )
162
+
163
+ # Apply domain-specific adjustments with limits
164
+ ai_prob = self._apply_domain_specific_adjustments(sentence = sentence,
165
+ ai_prob = ai_prob,
166
+ sentence_length = len(sentence.split()),
167
+ )
168
+
169
+ # Determine if this is mixed content
170
+ is_mixed_content = (mixed_prob > self.MIXED_THRESHOLD)
171
+
172
+ # Get confidence level
173
+ confidence_level = get_confidence_level(confidence)
174
+
175
+ # Get color class (consider mixed content)
176
+ color_class, color_hex, tooltip_base = self._get_color_for_probability(probability = ai_prob,
177
+ is_mixed_content = is_mixed_content,
178
+ mixed_prob = mixed_prob,
179
+ )
180
+
181
+ # Generate enhanced tooltip
182
+ tooltip = self._generate_ensemble_tooltip(sentence = sentence,
183
+ ai_prob = ai_prob,
184
+ human_prob = human_prob,
185
+ mixed_prob = mixed_prob,
186
+ confidence = confidence,
187
+ confidence_level = confidence_level,
188
+ tooltip_base = tooltip_base,
189
+ breakdown = breakdown,
190
+ is_mixed_content = is_mixed_content,
191
+ )
192
+
193
+ highlighted_sentences.append(HighlightedSentence(text = sentence,
194
+ ai_probability = ai_prob,
195
+ human_probability = human_prob,
196
+ mixed_probability = mixed_prob,
197
+ confidence = confidence,
198
+ confidence_level = confidence_level,
199
+ color_class = color_class,
200
+ tooltip = tooltip,
201
+ index = idx,
202
+ is_mixed_content = is_mixed_content,
203
+ metric_breakdown = breakdown,
204
+ )
205
+ )
206
+
207
+ except Exception as e:
208
+ logger.warning(f"Failed to process sentence {idx}: {e}")
209
+ # Add fallback sentence
210
+ highlighted_sentences.append(self._create_fallback_sentence(sentence, idx))
211
+
212
+ return highlighted_sentences
213
 
214
+ except Exception as e:
215
+ logger.error(f"Highlight generation failed: {e}")
216
+ return self._create_error_fallback(text, metric_results)
217
+
218
+
219
+ def _handle_empty_text(self, text: str, metric_results: Dict[str, MetricResult], ensemble_result: Optional[EnsembleResult]) -> List[HighlightedSentence]:
220
+ """
221
+ Handle empty input text
222
+ """
223
+ if ensemble_result:
224
+ return [self._create_fallback_sentence(text = "No text content",
225
+ index = 0,
226
+ ai_prob = ensemble_result.ai_probability,
227
+ human_prob = ensemble_result.human_probability,
228
+ )
229
+ ]
230
+
231
+ return [self._create_fallback_sentence("No text content", 0)]
232
+
233
+
234
+ def _handle_no_sentences(self, text: str, metric_results: Dict[str, MetricResult], ensemble_result: Optional[EnsembleResult]) -> List[HighlightedSentence]:
235
+ """
236
+ Handle case where no sentences could be extracted
237
+ """
238
+ if (text and (len(text.strip()) > 0)):
239
+ # Treat entire text as one sentence
240
+ return [self._create_fallback_sentence(text.strip(), 0)]
241
+
242
+ return [self._create_fallback_sentence("No processable content", 0)]
243
+
244
+
245
+ def _create_fallback_sentence(self, text: str, index: int, ai_prob: float = 0.5, human_prob: float = 0.5) -> HighlightedSentence:
246
+ """
247
+ Create a fallback sentence when processing fails
248
+ """
249
+ confidence_level = get_confidence_level(0.3)
250
+ color_class, _, tooltip_base = self._get_color_for_probability(probability = ai_prob,
251
+ is_mixed_content = False,
252
+ mixed_prob = 0.0,
253
+ )
254
 
255
+ return HighlightedSentence(text = text,
256
+ ai_probability = ai_prob,
257
+ human_probability = human_prob,
258
+ mixed_probability = 0.0,
259
+ confidence = 0.3,
260
+ confidence_level = confidence_level,
261
+ color_class = color_class,
262
+ tooltip = f"Fallback: {tooltip_base}\nProcessing failed for this sentence",
263
+ index = index,
264
+ is_mixed_content = False,
265
+ metric_breakdown = {"fallback": ai_prob},
266
+ )
267
+
268
+
269
+ def _create_error_fallback(self, text: str, metric_results: Dict[str, MetricResult]) -> List[HighlightedSentence]:
270
+ """
271
+ Create fallback when entire processing fails
272
+ """
273
+ return [HighlightedSentence(text = text[:100] + "..." if len(text) > 100 else text,
274
+ ai_probability = 0.5,
275
+ human_probability = 0.5,
276
+ mixed_probability = 0.0,
277
+ confidence = 0.1,
278
+ confidence_level = get_confidence_level(0.1),
279
+ color_class = "uncertain",
280
+ tooltip = "Error in text processing",
281
+ index = 0,
282
+ is_mixed_content = False,
283
+ metric_breakdown = {"error": 0.5},
284
+ )
285
+ ]
286
+
287
+
288
+ def _split_sentences_with_fallback(self, text: str) -> List[str]:
289
+ """
290
+ Split text into sentences with comprehensive fallback handling
291
+ """
292
+ try:
293
+ sentences = self.text_processor.split_sentences(text)
294
+ filtered_sentences = [s.strip() for s in sentences if len(s.strip()) >= 3]
295
 
296
+ if filtered_sentences:
297
+ return filtered_sentences
298
 
299
+ # Fallback: split by common sentence endings
300
+ fallback_sentences = re.split(r'[.!?]+', text)
301
+ fallback_sentences = [s.strip() for s in fallback_sentences if len(s.strip()) >= 3]
302
 
303
+ if fallback_sentences:
304
+ return fallback_sentences
305
 
306
+ # Ultimate fallback: treat as single sentence if meaningful
307
+ if text.strip():
308
+ return [text.strip()]
 
 
309
 
310
+ return []
 
 
 
 
 
 
 
 
 
 
311
 
312
+ except Exception as e:
313
+ logger.warning(f"Sentence splitting failed, using fallback: {e}")
314
+ # Return text as single sentence
315
+ return [text] if text.strip() else []
316
+
 
 
 
 
 
 
 
 
 
 
317
 
 
318
  def _calculate_sentence_ensemble_probability(self, sentence: str, metric_results: Dict[str, MetricResult], weights: Dict[str, float],
319
  ensemble_result: Optional[EnsembleResult] = None) -> Tuple[float, float, float, float, Dict[str, float]]:
320
  """
 
322
  """
323
  sentence_length = len(sentence.split())
324
 
325
+ # Handling short sentences - don't force neutral
326
  if (sentence_length < 3):
327
+ # Return probabilities with lower confidence for very short sentences
328
+ base_ai_prob = 0.5
329
+
330
+ # Low confidence for very short sentences
331
+ base_confidence = 0.2
332
+
333
+ breakdown = {"short_sentence" : base_ai_prob}
334
+
335
+ # Try to get some signal from available metrics
336
+ for name, result in metric_results.items():
337
+ if ((result.error is None) and (weights.get(name, 0) > 0)):
338
+ base_ai_prob = result.ai_probability
339
+ breakdown[name] = base_ai_prob
340
+ break
341
+
342
+ return base_ai_prob, 1.0 - base_ai_prob, 0.0, base_confidence, breakdown
343
 
344
  # Calculate sentence-level metric results
345
  sentence_metric_results = dict()
 
347
 
348
  for name, doc_result in metric_results.items():
349
  if doc_result.error is None:
350
+ try:
351
+ # Compute sentence-level probability for this metric
352
+ sentence_prob = self._compute_sentence_metric(metric_name = name,
353
+ sentence = sentence,
354
+ result = doc_result,
355
+ weight = weights.get(name, 0.0),
356
+ )
357
+
358
+ # Create sentence-level MetricResult
359
+ sentence_metric_results[name] = self._create_sentence_metric_result(metric_name = name,
360
+ ai_prob = sentence_prob,
361
+ doc_result = doc_result,
362
+ sentence_length = sentence_length,
363
+ )
364
+
365
+ breakdown[name] = sentence_prob
366
 
367
+ except Exception as e:
368
+ logger.warning(f"Metric {name} failed for sentence: {e}")
369
+ # Use document probability as fallback
370
+ breakdown[name] = doc_result.ai_probability
371
 
372
  # Use ensemble to combine sentence-level metrics
373
  if sentence_metric_results:
 
376
  domain = self.domain,
377
  )
378
 
379
+ return (ensemble_sentence_result.ai_probability,
380
+ ensemble_sentence_result.human_probability,
381
+ ensemble_sentence_result.mixed_probability,
382
+ ensemble_sentence_result.overall_confidence,
383
+ breakdown)
384
 
385
  except Exception as e:
386
  logger.warning(f"Sentence ensemble failed: {e}")
 
415
  return adjusted_prob
416
 
417
 
418
+ def _create_sentence_metric_result(self, metric_name: str, ai_prob: float, doc_result: MetricResult, sentence_length: int) -> MetricResult:
419
  """
420
  Create sentence-level MetricResult from document-level result
421
  """
422
+ # IMPROVED: Calculate confidence based on sentence characteristics
423
+ sentence_confidence = self._calculate_sentence_confidence(doc_result.confidence, sentence_length)
424
 
425
  return MetricResult(metric_name = metric_name,
426
  ai_probability = ai_prob,
 
432
  )
433
 
434
 
435
+ def _calculate_sentence_confidence(self, doc_confidence: float, sentence_length: int) -> float:
436
  """
437
+ IMPROVED: Calculate confidence for sentence-level analysis with length consideration
438
  """
439
+ base_reduction = 0.8
440
+ # Scale confidence penalty with sentence length
441
+ length_penalty = max(0.3, min(1.0, sentence_length / 12.0)) # Normalize around 12 words
442
+
443
+ return max(0.1, doc_confidence * base_reduction * length_penalty)
444
 
445
 
446
  def _calculate_weighted_probability(self, metric_results: Dict[str, MetricResult], weights: Dict[str, float], breakdown: Dict[str, float]) -> Tuple[float, float, float, float, Dict[str, float]]:
 
462
  confidences.append(result.confidence)
463
  total_weight += weight
464
 
465
+ if ((not weighted_ai_probs) or (total_weight == 0)):
466
+ return 0.5, 0.5, 0.0, 0.5, breakdown or {}
467
 
468
  ai_prob = sum(weighted_ai_probs) / total_weight
469
  human_prob = sum(weighted_human_probs) / total_weight
 
487
  else:
488
  # Calculate from metrics
489
  return self._calculate_weighted_probability(metric_results, weights, {})
490
+
491
 
492
  def _apply_domain_specific_adjustments(self, sentence: str, ai_prob: float, sentence_length: int) -> float:
493
  """
494
+ Apply domain-specific adjustments to AI probability with limits
495
  """
496
+ original_prob = ai_prob
497
+ adjustments = list()
498
  sentence_lower = sentence.lower()
499
 
500
  # Technical & AI/ML domains
501
+ if (self.domain in [Domain.AI_ML, Domain.SOFTWARE_DEV, Domain.TECHNICAL_DOC, Domain.ENGINEERING, Domain.SCIENCE]):
502
  if self._has_technical_terms(sentence_lower):
503
+ adjustments.append(1.1)
 
504
 
505
  elif self._has_code_like_patterns(sentence):
506
+ adjustments.append(1.15)
507
 
508
+ elif (sentence_length > 35):
509
+ adjustments.append(1.05)
510
 
511
  # Creative & informal domains
512
+ elif (self.domain in [Domain.CREATIVE, Domain.SOCIAL_MEDIA, Domain.BLOG_PERSONAL]):
513
  if self._has_informal_language(sentence_lower):
514
+ adjustments.append(0.7)
 
515
 
516
  elif self._has_emotional_language(sentence):
517
+ adjustments.append(0.8)
518
 
519
  elif (sentence_length < 10):
520
+ adjustments.append(0.8)
521
 
522
  # Academic & formal domains
523
+ elif (self.domain in [Domain.ACADEMIC, Domain.LEGAL, Domain.MEDICAL]):
524
  if self._has_citation_patterns(sentence):
525
+ adjustments.append(0.8)
 
526
 
527
  elif self._has_technical_terms(sentence_lower):
528
+ adjustments.append(1.1)
529
 
530
  elif (sentence_length > 40):
531
+ adjustments.append(1.1)
532
 
533
  # Business & professional domains
534
+ elif (self.domain in [Domain.BUSINESS, Domain.MARKETING, Domain.JOURNALISM]):
535
  if self._has_business_jargon(sentence_lower):
536
+ adjustments.append(1.05)
 
537
 
538
  elif self._has_ambiguous_phrasing(sentence_lower):
539
+ adjustments.append(0.9)
 
540
 
541
  elif (15 <= sentence_length <= 25):
542
+ adjustments.append(0.9)
543
 
544
  # Tutorial & educational domains
545
  elif (self.domain == Domain.TUTORIAL):
546
  if self._has_instructional_language(sentence_lower):
547
+ adjustments.append(0.85)
 
548
 
549
  elif self._has_step_by_step_pattern(sentence):
550
+ adjustments.append(0.8)
551
 
552
  elif self._has_examples(sentence):
553
+ adjustments.append(0.9)
554
 
555
  # General domain - minimal adjustments
556
+ elif (self.domain == Domain.GENERAL):
557
  if self._has_complex_structure(sentence):
558
+ adjustments.append(0.9)
559
 
560
  elif self._has_repetition(sentence):
561
+ adjustments.append(1.1)
562
+
563
+ # Apply adjustments with limits - take strongest 2 adjustments maximum
564
+ if adjustments:
565
+ # Sort by impact (farthest from 1.0)
566
+ adjustments.sort(key = lambda x: abs(x - 1.0), reverse = True)
567
+ # Limit to 2 strongest
568
+ strongest_adjustments = adjustments[:2]
569
+
570
+ for adjustment in strongest_adjustments:
571
+ ai_prob *= adjustment
572
+
573
+ # Ensure probability stays within bounds and doesn't change too drastically : Maximum 30% change from original
574
+ max_change = 0.3
575
+ bounded_prob = max(original_prob - max_change, min(original_prob + max_change, ai_prob))
576
 
577
+ return max(0.0, min(1.0, bounded_prob))
578
 
579
 
580
  def _apply_metric_specific_adjustments(self, metric_name: str, sentence: str, base_prob: float, sentence_length: int, thresholds: MetricThresholds) -> float:
 
632
 
633
  def _get_color_for_probability(self, probability: float, is_mixed_content: bool = False, mixed_prob: float = 0.0) -> Tuple[str, str, str]:
634
  """
635
+ Get color class with mixed content support and no threshold gaps
636
  """
637
+ # Handle probability = 1.0 explicitly
638
+ if (probability >= 1.0):
639
+ return "very-high-ai", "#fecaca", "Very likely AI-generated (100%)"
640
+
641
  # Check mixed content first
642
  if (is_mixed_content and (mixed_prob > self.MIXED_THRESHOLD)):
643
  return "mixed-content", "#e9d5ff", f"Mixed AI/Human content ({mixed_prob:.1%} mixed)"
 
647
  if (min_thresh <= probability < max_thresh):
648
  return color_class, color_hex, tooltip
649
 
650
+ # Fallback for probability = 1.0 (should be caught above, but just in case)
651
+ return "very-high-ai", "#fecaca", "Very likely AI-generated"
652
+
653
 
654
  def _generate_ensemble_tooltip(self, sentence: str, ai_prob: float, human_prob: float, mixed_prob: float, confidence: float, confidence_level: ConfidenceLevel,
655
+ tooltip_base: str, breakdown: Optional[Dict[str, float]] = None, is_mixed_content: bool = False) -> str:
656
  """
657
  Generate enhanced tooltip with ENSEMBLE information
658
  """
 
674
  for metric, prob in list(breakdown.items())[:4]:
675
  tooltip += f"\n• {metric}: {prob:.1%}"
676
 
677
+ tooltip += f"\n\nEnsemble Method: {getattr(self.ensemble, 'primary_method', 'fallback')}"
678
 
679
  return tooltip
680
 
 
789
  Analyze sentence complexity (0 = simple, 1 = complex)
790
  """
791
  words = sentence.split()
792
+ if (len(words) < 5):
793
  return 0.2
794
 
795
  complexity_indicators = ['although', 'because', 'while', 'when', 'if', 'since', 'unless', 'until', 'which', 'that', 'who', 'whom', 'whose', 'and', 'but', 'or', 'yet', 'so', 'however', 'therefore', 'moreover', 'furthermore', 'nevertheless', ',', ';', ':', '—']
 
807
 
808
  clause_indicators = [',', ';', 'and', 'but', 'or', 'because', 'although']
809
  clause_count = sum(1 for indicator in clause_indicators if indicator in sentence.lower())
810
+ score += min(0.2, clause_count * 0.05)
811
 
812
  return min(1.0, score)
813
 
 
841
  for sentence in sentences:
842
  clean_sentence = sentence.strip()
843
 
844
+ if (len(clean_sentence) >= 3):
845
  filtered_sentences.append(clean_sentence)
846
 
847
  return filtered_sentences
 
1172
  total_sentences = len(highlighted_sentences)
1173
 
1174
  # Calculate weighted risk score
1175
+ weighted_risk = 0.0
1176
 
1177
  for sent in highlighted_sentences:
1178
  weight = self.RISK_WEIGHTS.get(sent.color_class, 0.4)
logs/application/app_2025-11-07.log ADDED
The diff for this file is too large to render. See raw diff
 
metrics/multi_perturbation_stability.py CHANGED
@@ -59,6 +59,7 @@ class MultiPerturbationStabilityMetric(BaseMetric):
59
  self.gpt_model, self.gpt_tokenizer = gpt_result
60
  # Move model to appropriate device
61
  self.gpt_model.to(self.device)
 
62
 
63
  else:
64
  logger.error("Failed to load GPT-2 model for MultiPerturbationStability")
@@ -76,9 +77,20 @@ class MultiPerturbationStabilityMetric(BaseMetric):
76
  if (self.mask_tokenizer.pad_token is None):
77
  self.mask_tokenizer.pad_token = self.mask_tokenizer.eos_token or '[PAD]'
78
 
 
 
 
 
 
 
79
  else:
80
  logger.warning("Failed to load mask model, using GPT-2 only")
81
 
 
 
 
 
 
82
  self.is_initialized = True
83
 
84
  logger.success("MultiPerturbationStability metric initialized successfully")
@@ -89,12 +101,51 @@ class MultiPerturbationStabilityMetric(BaseMetric):
89
  return False
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def compute(self, text: str, **kwargs) -> MetricResult:
93
  """
94
  Compute MultiPerturbationStability analysis with FULL DOMAIN THRESHOLD INTEGRATION
95
  """
96
  try:
97
- if ((not text) or (len(text.strip()) < 100)):
98
  return MetricResult(metric_name = self.name,
99
  ai_probability = 0.5,
100
  human_probability = 0.5,
@@ -121,13 +172,16 @@ class MultiPerturbationStabilityMetric(BaseMetric):
121
  )
122
 
123
  # Calculate MultiPerturbationStability features
124
- features = self._calculate_stability_features(text)
125
 
126
  # Calculate raw MultiPerturbationStability score (0-1 scale)
127
- raw_stability_score, confidence = self._analyze_stability_patterns(features)
128
 
129
  # Apply domain-specific thresholds to convert raw score to probabilities
130
- ai_prob, human_prob, mixed_prob = self._apply_domain_thresholds(raw_stability_score, multi_perturbation_stability_thresholds, features)
 
 
 
131
 
132
  # Apply confidence multiplier from domain thresholds
133
  confidence *= multi_perturbation_stability_thresholds.confidence_multiplier
@@ -211,54 +265,75 @@ class MultiPerturbationStabilityMetric(BaseMetric):
211
 
212
  def _calculate_stability_features(self, text: str) -> Dict[str, Any]:
213
  """
214
- Calculate comprehensive MultiPerturbationStability features
215
  """
216
  if not self.gpt_model or not self.gpt_tokenizer:
217
  return self._get_default_features()
218
 
219
  try:
220
  # Preprocess text for better analysis
221
- processed_text = self._preprocess_text_for_analysis(text)
222
 
223
  # Calculate original text likelihood
224
- original_likelihood = self._calculate_likelihood(processed_text)
 
225
 
226
  # Generate perturbations and calculate perturbed likelihoods
227
- perturbations = self._generate_perturbations(processed_text, num_perturbations = 5)
 
 
 
 
228
  perturbed_likelihoods = list()
229
 
230
- for perturbed_text in perturbations:
231
  if (perturbed_text and (perturbed_text != processed_text)):
232
- likelihood = self._calculate_likelihood(perturbed_text)
233
 
234
  if (likelihood > 0):
235
  perturbed_likelihoods.append(likelihood)
 
 
 
236
 
237
  # Calculate stability metrics
238
  if perturbed_likelihoods:
239
- stability_score = self._calculate_stability_score(original_likelihood, perturbed_likelihoods)
240
- curvature_score = self._calculate_curvature_score(original_likelihood, perturbed_likelihoods)
241
- variance_score = np.var(perturbed_likelihoods) if len(perturbed_likelihoods) > 1 else 0.0
 
 
 
 
 
 
242
  avg_perturbed_likelihood = np.mean(perturbed_likelihoods)
 
 
243
 
244
  else:
245
- stability_score = 0.5
246
- curvature_score = 0.5
247
- variance_score = 0.1
248
- avg_perturbed_likelihood = original_likelihood
 
 
249
 
250
  # Calculate likelihood ratio
251
- likelihood_ratio = original_likelihood / avg_perturbed_likelihood if avg_perturbed_likelihood > 0 else 1.0
252
 
253
  # Chunk-based analysis for whole-text understanding
254
- chunk_stabilities = self._calculate_chunk_stability(processed_text, chunk_size=150)
255
- stability_variance = np.var(chunk_stabilities) if chunk_stabilities else 0.0
256
- avg_chunk_stability = np.mean(chunk_stabilities) if chunk_stabilities else stability_score
 
 
 
257
 
258
- # Normalize scores to 0-1 range
259
- normalized_stability = min(1.0, max(0.0, stability_score))
260
- normalized_curvature = min(1.0, max(0.0, curvature_score))
261
- normalized_likelihood_ratio = min(2.0, likelihood_ratio) / 2.0 # Normalize to 0-1
262
 
263
  return {"original_likelihood" : round(original_likelihood, 4),
264
  "avg_perturbed_likelihood" : round(avg_perturbed_likelihood, 4),
@@ -281,59 +356,87 @@ class MultiPerturbationStabilityMetric(BaseMetric):
281
 
282
  def _calculate_likelihood(self, text: str) -> float:
283
  """
284
- Calculate log-likelihood of text using GPT-2 with robust error handling
 
285
  """
286
  try:
287
  # Check text length before tokenization
288
  if (len(text.strip()) < 10):
289
- return 0.0
 
 
 
 
290
 
291
- # Configure tokenizer for proper padding
292
- tokenizer = self._configure_tokenizer_padding(self.gpt_tokenizer)
 
293
 
294
  # Tokenize text with proper settings
295
- encodings = tokenizer(text,
296
- return_tensors = 'pt',
297
- truncation = True,
298
- max_length = 512,
299
- padding = True,
300
- return_attention_mask = True,
301
- )
302
 
303
  input_ids = encodings.input_ids.to(self.device)
304
  attention_mask = encodings.attention_mask.to(self.device)
305
 
306
  # Minimum tokens for meaningful analysis
307
- if ((input_ids.numel() == 0) or (input_ids.size(1) < 5)):
308
- return 0.0
309
 
310
- # Calculate negative log likelihood
311
  with torch.no_grad():
312
- outputs = self.gpt_model(input_ids,
313
- attention_mask = attention_mask,
314
- labels = input_ids,
315
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
- loss = outputs.loss
 
318
 
319
- # Convert to positive log likelihood (higher = more likely)
320
- log_likelihood = -loss.item()
321
-
322
- # Reasonable range check (typical values are between -10 and 10)
323
- if (abs(log_likelihood) > 100):
324
- logger.warning(f"Extreme likelihood value detected: {log_likelihood}")
325
- return 0.0
326
 
327
- return log_likelihood
328
 
329
  except Exception as e:
330
  logger.warning(f"Likelihood calculation failed: {repr(e)}")
331
- return 0.0
332
 
333
 
334
  def _generate_perturbations(self, text: str, num_perturbations: int = 5) -> List[str]:
335
  """
336
- Generate perturbed versions of the text with robust error handling
 
 
 
 
337
  """
338
  perturbations = list()
339
 
@@ -383,33 +486,37 @@ class MultiPerturbationStabilityMetric(BaseMetric):
383
  logger.debug(f"Word swapping perturbation failed: {e}")
384
  continue
385
 
386
- # Method 3: RoBERTa-specific masked word replacement
387
  if (self.mask_model and self.mask_tokenizer and (len(words) > 4) and len(perturbations) < num_perturbations):
388
 
389
  try:
390
- roberta_perturbations = self._generate_roberta_masked_perturbations(processed_text,
391
- words,
392
- num_perturbations - len(perturbations))
 
393
  perturbations.extend(roberta_perturbations)
394
 
395
  except Exception as e:
396
- logger.warning(f"RoBERTa masked perturbation failed: {repr(e)}")
397
 
398
  # Method 4: Synonym replacement as fallback
399
  if (len(perturbations) < num_perturbations):
400
  try:
401
- synonym_perturbations = self._generate_synonym_perturbations(processed_text,
402
- words,
403
- num_perturbations - len(perturbations))
 
404
  perturbations.extend(synonym_perturbations)
405
 
406
  except Exception as e:
407
- logger.debug(f"Synonym replacement failed: {e}")
408
 
409
  # Ensure we have at least some perturbations
410
  if not perturbations:
411
  # Fallback: create simple variations
412
- fallback_perturbations = self._generate_fallback_perturbations(processed_text, words)
 
 
413
  perturbations.extend(fallback_perturbations)
414
 
415
  # Remove duplicates and ensure we don't exceed requested number
@@ -423,19 +530,23 @@ class MultiPerturbationStabilityMetric(BaseMetric):
423
 
424
  except Exception as e:
425
  logger.warning(f"Perturbation generation failed: {repr(e)}")
426
- # Return at least the original text as fallback
427
- return [text]
428
 
429
 
430
  def _generate_roberta_masked_perturbations(self, text: str, words: List[str], max_perturbations: int) -> List[str]:
431
  """
432
- Generate perturbations using RoBERTa mask filling
 
433
  """
434
  perturbations = list()
435
 
436
  try:
437
- # RoBERTa uses <mask> token
438
- roberta_mask_token = "<mask>"
 
 
 
 
439
 
440
  # Select words to mask (avoid very short words and punctuation)
441
  candidate_positions = [i for i, word in enumerate(words) if (len(word) > 3) and word.isalpha() and word.lower() not in ['the', 'and', 'but', 'for', 'with']]
@@ -448,7 +559,7 @@ class MultiPerturbationStabilityMetric(BaseMetric):
448
 
449
  # Try multiple mask positions
450
  attempts = min(max_perturbations * 2, len(candidate_positions))
451
- positions_to_try = np.random.choice(candidate_positions, min(attempts, len(candidate_positions)), replace=False)
452
 
453
  for pos in positions_to_try:
454
  if (len(perturbations) >= max_perturbations):
@@ -461,15 +572,15 @@ class MultiPerturbationStabilityMetric(BaseMetric):
461
  masked_words[pos] = roberta_mask_token
462
  masked_text = ' '.join(masked_words)
463
 
464
- # RoBERTa works better with proper sentence structure
465
  if not masked_text.endswith(('.', '!', '?')):
466
  masked_text += '.'
467
 
468
- # Tokenize with RoBERTa-specific settings
469
  inputs = self.mask_tokenizer(masked_text,
470
  return_tensors = "pt",
471
  truncation = True,
472
- max_length = min(128, self.mask_tokenizer.model_max_length), # Conservative length
473
  padding = True,
474
  )
475
 
@@ -508,15 +619,14 @@ class MultiPerturbationStabilityMetric(BaseMetric):
508
 
509
  if (self._is_valid_perturbation(new_text, text)):
510
  perturbations.append(new_text)
511
- # Use first valid prediction
512
- break
513
 
514
  except Exception as e:
515
- logger.debug(f"RoBERTa mask filling failed for position {pos}: {e}")
516
  continue
517
 
518
  except Exception as e:
519
- logger.warning(f"RoBERTa masked perturbations failed: {e}")
520
 
521
  return perturbations
522
 
@@ -559,7 +669,7 @@ class MultiPerturbationStabilityMetric(BaseMetric):
559
  perturbations.append(new_text)
560
 
561
  except Exception as e:
562
- logger.debug(f"Synonym replacement failed: {e}")
563
 
564
  return perturbations
565
 
@@ -585,41 +695,72 @@ class MultiPerturbationStabilityMetric(BaseMetric):
585
  perturbations.append(text.capitalize())
586
 
587
  except Exception as e:
588
- logger.debug(f"Fallback perturbation failed: {e}")
589
 
590
  return [p for p in perturbations if p and p != text][:3]
591
 
592
 
593
  def _calculate_stability_score(self, original_likelihood: float, perturbed_likelihoods: List[float]) -> float:
594
  """
595
- Calculate text stability score under perturbations : AI text tends to be less stable (larger likelihood drops)
596
  """
597
  if ((not perturbed_likelihoods) or (original_likelihood <= 0)):
598
- return 0.5
 
599
 
600
- # Calculate average likelihood drop
601
- likelihood_drops = [(original_likelihood - pl) / original_likelihood for pl in perturbed_likelihoods]
602
- avg_drop = np.mean(likelihood_drops) if likelihood_drops else 0.0
603
 
604
- # Higher drop = less stable = more AI-like : Normalize to 0-1 scale (assume max drop of 50%)
605
- stability_score = min(1.0, avg_drop / 0.5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
 
607
- return stability_score
 
 
 
 
 
 
 
 
 
 
 
 
608
 
609
 
610
  def _calculate_curvature_score(self, original_likelihood: float, perturbed_likelihoods: List[float]) -> float:
611
  """
612
- Calculate likelihood curvature score : AI text often has different curvature properties
613
  """
614
  if ((not perturbed_likelihoods) or (original_likelihood <= 0)):
615
- return 0.5
616
 
617
  # Calculate variance of likelihood changes
618
  likelihood_changes = [abs(original_likelihood - pl) for pl in perturbed_likelihoods]
619
- change_variance = np.var(likelihood_changes) if len(likelihood_changes) > 1 else 0.0
620
 
621
- # Higher variance = more curvature = potentially more AI-like : Normalize based on typical variance ranges
622
- curvature_score = min(1.0, change_variance * 10.0) # Adjust scaling factor as needed
 
 
 
 
 
623
 
624
  return curvature_score
625
 
@@ -637,7 +778,7 @@ class MultiPerturbationStabilityMetric(BaseMetric):
637
 
638
  if (len(chunk) > 50):
639
  try:
640
- chunk_likelihood = self._calculate_likelihood(chunk)
641
 
642
  if (chunk_likelihood > 0):
643
  # Generate a simple perturbation for this chunk
@@ -649,11 +790,12 @@ class MultiPerturbationStabilityMetric(BaseMetric):
649
  indices_to_keep = np.random.choice(len(chunk_words), len(chunk_words) - delete_count, replace=False)
650
  perturbed_chunk = ' '.join([chunk_words[i] for i in sorted(indices_to_keep)])
651
 
652
- perturbed_likelihood = self._calculate_likelihood(perturbed_chunk)
653
 
654
  if (perturbed_likelihood > 0):
655
  stability = (chunk_likelihood - perturbed_likelihood) / chunk_likelihood
656
  stabilities.append(min(1.0, max(0.0, stability)))
 
657
  except Exception:
658
  continue
659
 
@@ -662,7 +804,7 @@ class MultiPerturbationStabilityMetric(BaseMetric):
662
 
663
  def _analyze_stability_patterns(self, features: Dict[str, Any]) -> tuple:
664
  """
665
- Analyze MultiPerturbationStability patterns to determine RAW MultiPerturbationStability score (0-1 scale) : Higher score = more AI-like
666
  """
667
  # Check feature validity first
668
  required_features = ['stability_score', 'curvature_score', 'normalized_likelihood_ratio', 'stability_variance', 'perturbation_variance']
@@ -675,61 +817,76 @@ class MultiPerturbationStabilityMetric(BaseMetric):
675
 
676
 
677
  # Initialize ai_indicator list
678
- ai_indicators = list()
 
 
 
 
 
 
679
 
680
  # High stability score suggests AI (larger likelihood drops)
681
- if (features['stability_score'] > 0.6):
682
- ai_indicators.append(0.8)
683
-
684
- elif (features['stability_score'] > 0.3):
685
- ai_indicators.append(0.5)
 
 
 
 
686
 
687
  else:
688
- ai_indicators.append(0.2)
689
 
690
  # High curvature score suggests AI
691
- if (features['curvature_score'] > 0.7):
692
- ai_indicators.append(0.7)
693
-
694
- elif (features['curvature_score'] > 0.4):
695
- ai_indicators.append(0.4)
696
-
 
 
 
 
697
  else:
698
- ai_indicators.append(0.2)
699
 
700
  # High likelihood ratio suggests AI (original much more likely than perturbations)
701
- if (features['normalized_likelihood_ratio'] > 0.8):
702
- ai_indicators.append(0.9)
 
 
 
 
 
 
 
703
 
704
- elif (features['normalized_likelihood_ratio'] > 0.6):
705
- ai_indicators.append(0.6)
706
-
707
  else:
708
- ai_indicators.append(0.3)
709
 
710
  # Low stability variance suggests AI (consistent across chunks)
711
- if (features['stability_variance'] < 0.05):
712
- ai_indicators.append(0.7)
713
-
714
- elif (features['stability_variance'] < 0.1):
715
- ai_indicators.append(0.4)
716
-
717
- else:
718
- ai_indicators.append(0.2)
719
 
720
- # High perturbation variance suggests AI
721
- if (features['perturbation_variance'] > 0.1):
722
- ai_indicators.append(0.6)
723
-
724
- elif (features['perturbation_variance'] > 0.05):
725
- ai_indicators.append(0.4)
726
 
727
  else:
728
- ai_indicators.append(0.2)
729
 
730
  # Calculate raw score and confidence
731
- raw_score = np.mean(ai_indicators) if ai_indicators else 0.5
732
- confidence = 1.0 - (np.std(ai_indicators) / 0.5) if ai_indicators else 0.5
 
 
 
 
 
 
733
  confidence = max(0.1, min(0.9, confidence))
734
 
735
  return raw_score, confidence
@@ -770,16 +927,16 @@ class MultiPerturbationStabilityMetric(BaseMetric):
770
 
771
  def _get_default_features(self) -> Dict[str, Any]:
772
  """
773
- Return default features when analysis is not possible
774
  """
775
  return {"original_likelihood" : 2.0,
776
  "avg_perturbed_likelihood" : 1.8,
777
  "likelihood_ratio" : 1.1,
778
  "normalized_likelihood_ratio" : 0.55,
779
- "stability_score" : 0.5,
780
- "curvature_score" : 0.5,
781
  "perturbation_variance" : 0.05,
782
- "avg_chunk_stability" : 0.5,
783
  "stability_variance" : 0.1,
784
  "num_perturbations" : 0,
785
  "num_valid_perturbations" : 0,
@@ -814,14 +971,14 @@ class MultiPerturbationStabilityMetric(BaseMetric):
814
  # Normalize whitespace
815
  text = ' '.join(text.split())
816
 
817
- # RoBERTa works better with proper punctuation
818
  if not text.endswith(('.', '!', '?')):
819
  text += '.'
820
 
821
  # Truncate to safe length
822
  if (len(text) > 1000):
823
  sentences = text.split('. ')
824
- if len(sentences) > 1:
825
  # Keep first few sentences
826
  text = '. '.join(sentences[:3]) + '.'
827
 
@@ -831,50 +988,54 @@ class MultiPerturbationStabilityMetric(BaseMetric):
831
  return text
832
 
833
 
834
- def _configure_tokenizer_padding(self, tokenizer) -> Any:
835
- """
836
- Configure tokenizer for proper padding
837
- """
838
- if tokenizer.pad_token is None:
839
- if tokenizer.eos_token is not None:
840
- tokenizer.pad_token = tokenizer.eos_token
841
-
842
- else:
843
- tokenizer.add_special_tokens({'pad_token': '[PAD]'})
844
-
845
- tokenizer.padding_side = "left"
846
-
847
- return tokenizer
848
-
849
-
850
  def _clean_roberta_token(self, token: str) -> str:
851
  """
852
- Clean tokens from RoBERTa tokenizer
853
  """
854
  if not token:
855
  return ""
856
 
857
- # Remove RoBERTa-specific artifacts
858
  token = token.replace('Ġ', ' ') # RoBERTa space marker
859
  token = token.replace('</s>', '')
860
  token = token.replace('<s>', '')
861
  token = token.replace('<pad>', '')
 
862
 
863
- # Remove leading/trailing whitespace and punctuation
864
- token = token.strip(' .,!?;:"\'')
865
 
866
- return token
 
 
 
 
 
 
 
 
867
 
868
 
869
  def _is_valid_perturbation(self, perturbed_text: str, original_text: str) -> bool:
870
  """
871
- Check if a perturbation is valid
872
  """
873
- # Not too short
874
- return (perturbed_text and
875
- len(perturbed_text.strip()) > 10 and
876
- perturbed_text != original_text and
877
- len(perturbed_text) > len(original_text) * 0.5)
 
 
 
 
 
 
 
 
 
 
 
878
 
879
 
880
  def cleanup(self):
 
59
  self.gpt_model, self.gpt_tokenizer = gpt_result
60
  # Move model to appropriate device
61
  self.gpt_model.to(self.device)
62
+ logger.success("✓ GPT-2 model loaded for MultiPerturbationStability")
63
 
64
  else:
65
  logger.error("Failed to load GPT-2 model for MultiPerturbationStability")
 
77
  if (self.mask_tokenizer.pad_token is None):
78
  self.mask_tokenizer.pad_token = self.mask_tokenizer.eos_token or '[PAD]'
79
 
80
+ # Ensure tokenizer has mask token
81
+ if not hasattr(self.mask_tokenizer, 'mask_token') or self.mask_tokenizer.mask_token is None:
82
+ self.mask_tokenizer.mask_token = "<mask>"
83
+
84
+ logger.success("✓ DistilRoBERTa model loaded for MultiPerturbationStability")
85
+
86
  else:
87
  logger.warning("Failed to load mask model, using GPT-2 only")
88
 
89
+ # Verify model loading
90
+ if not self._verify_model_loading():
91
+ logger.error("Model verification failed")
92
+ return False
93
+
94
  self.is_initialized = True
95
 
96
  logger.success("MultiPerturbationStability metric initialized successfully")
 
101
  return False
102
 
103
 
104
+ def _verify_model_loading(self) -> bool:
105
+ """
106
+ Verify that models are properly loaded and working
107
+ """
108
+ try:
109
+ test_text = "This is a test sentence for model verification."
110
+
111
+ # Test GPT-2 model
112
+ if self.gpt_model and self.gpt_tokenizer:
113
+ gpt_likelihood = self._calculate_likelihood(text = test_text)
114
+ logger.info(f"GPT-2 test - Likelihood: {gpt_likelihood:.4f}")
115
+
116
+ else:
117
+ logger.error("GPT-2 model not loaded")
118
+ return False
119
+
120
+ # Test DistilRoBERTa model if available
121
+ if self.mask_model and self.mask_tokenizer:
122
+ # Test mask token
123
+ if hasattr(self.mask_tokenizer, 'mask_token') and self.mask_tokenizer.mask_token:
124
+ logger.info(f"DistilRoBERTa mask token: '{self.mask_tokenizer.mask_token}'")
125
+
126
+ # Test basic tokenization
127
+ inputs = self.mask_tokenizer(test_text, return_tensors = "pt")
128
+ logger.info(f"DistilRoBERTa tokenization test - Input shape: {inputs['input_ids'].shape}")
129
+
130
+ else:
131
+ logger.warning("DistilRoBERTa mask token not available")
132
+
133
+ else:
134
+ logger.warning("DistilRoBERTa model not loaded")
135
+
136
+ return True
137
+
138
+ except Exception as e:
139
+ logger.error(f"Model verification failed: {e}")
140
+ return False
141
+
142
+
143
  def compute(self, text: str, **kwargs) -> MetricResult:
144
  """
145
  Compute MultiPerturbationStability analysis with FULL DOMAIN THRESHOLD INTEGRATION
146
  """
147
  try:
148
+ if ((not text) or (len(text.strip()) < 50)):
149
  return MetricResult(metric_name = self.name,
150
  ai_probability = 0.5,
151
  human_probability = 0.5,
 
172
  )
173
 
174
  # Calculate MultiPerturbationStability features
175
+ features = self._calculate_stability_features(text = text)
176
 
177
  # Calculate raw MultiPerturbationStability score (0-1 scale)
178
+ raw_stability_score, confidence = self._analyze_stability_patterns(features = features)
179
 
180
  # Apply domain-specific thresholds to convert raw score to probabilities
181
+ ai_prob, human_prob, mixed_prob = self._apply_domain_thresholds(raw_score = raw_stability_score,
182
+ thresholds = multi_perturbation_stability_thresholds,
183
+ features = features,
184
+ )
185
 
186
  # Apply confidence multiplier from domain thresholds
187
  confidence *= multi_perturbation_stability_thresholds.confidence_multiplier
 
265
 
266
  def _calculate_stability_features(self, text: str) -> Dict[str, Any]:
267
  """
268
+ Calculate comprehensive MultiPerturbationStability features with diagnostic logging
269
  """
270
  if not self.gpt_model or not self.gpt_tokenizer:
271
  return self._get_default_features()
272
 
273
  try:
274
  # Preprocess text for better analysis
275
+ processed_text = self._preprocess_text_for_analysis(text = text)
276
 
277
  # Calculate original text likelihood
278
+ original_likelihood = self._calculate_likelihood(text = processed_text)
279
+ logger.debug(f"Original likelihood: {original_likelihood:.4f}")
280
 
281
  # Generate perturbations and calculate perturbed likelihoods
282
+ perturbations = self._generate_perturbations(text = processed_text,
283
+ num_perturbations = 10,
284
+ )
285
+ logger.debug(f"Generated {len(perturbations)} perturbations")
286
+
287
  perturbed_likelihoods = list()
288
 
289
+ for idx, perturbed_text in enumerate(perturbations):
290
  if (perturbed_text and (perturbed_text != processed_text)):
291
+ likelihood = self._calculate_likelihood(text = perturbed_text)
292
 
293
  if (likelihood > 0):
294
  perturbed_likelihoods.append(likelihood)
295
+ logger.debug(f"Perturbation {idx}: likelihood={likelihood:.4f}")
296
+
297
+ logger.info(f"Valid perturbations: {len(perturbed_likelihoods)}/{len(perturbations)}")
298
 
299
  # Calculate stability metrics
300
  if perturbed_likelihoods:
301
+ stability_score = self._calculate_stability_score(original_likelihood = original_likelihood,
302
+ perturbed_likelihoods = perturbed_likelihoods,
303
+ )
304
+
305
+ curvature_score = self._calculate_curvature_score(original_likelihood = original_likelihood,
306
+ perturbed_likelihoods = perturbed_likelihoods,
307
+ )
308
+
309
+ variance_score = np.var(perturbed_likelihoods) if (len(perturbed_likelihoods) > 1) else 0.0
310
  avg_perturbed_likelihood = np.mean(perturbed_likelihoods)
311
+
312
+ logger.info(f"Stability: {stability_score:.3f}, Curvature: {curvature_score:.3f}")
313
 
314
  else:
315
+ # Use meaningful defaults when perturbations fail
316
+ stability_score = 0.3 # Assume more human-like when no perturbations work
317
+ curvature_score = 0.3
318
+ variance_score = 0.05
319
+ avg_perturbed_likelihood = original_likelihood * 0.9 # Assume some drop
320
+ logger.warning("No valid perturbations, using fallback values")
321
 
322
  # Calculate likelihood ratio
323
+ likelihood_ratio = original_likelihood / avg_perturbed_likelihood if avg_perturbed_likelihood > 0 else 1.0
324
 
325
  # Chunk-based analysis for whole-text understanding
326
+ chunk_stabilities = self._calculate_chunk_stability(text = processed_text,
327
+ chunk_size = 150,
328
+ )
329
+
330
+ stability_variance = np.var(chunk_stabilities) if chunk_stabilities else 0.1
331
+ avg_chunk_stability = np.mean(chunk_stabilities) if chunk_stabilities else stability_score
332
 
333
+ # Better normalization to prevent extreme values
334
+ normalized_stability = min(1.0, max(0.0, stability_score))
335
+ normalized_curvature = min(1.0, max(0.0, curvature_score))
336
+ normalized_likelihood_ratio = min(3.0, max(0.33, likelihood_ratio)) / 3.0
337
 
338
  return {"original_likelihood" : round(original_likelihood, 4),
339
  "avg_perturbed_likelihood" : round(avg_perturbed_likelihood, 4),
 
356
 
357
  def _calculate_likelihood(self, text: str) -> float:
358
  """
359
+ Calculate proper log-likelihood using token probabilities
360
+ Inspired by DetectGPT's likelihood calculation approach
361
  """
362
  try:
363
  # Check text length before tokenization
364
  if (len(text.strip()) < 10):
365
+ return 2.0 # Return reasonable baseline
366
+
367
+ if not self.gpt_model or not self.gpt_tokenizer:
368
+ logger.warning("GPT model not available for likelihood calculation")
369
+ return 2.0
370
 
371
+ # Ensure tokenizer has pad token
372
+ if self.gpt_tokenizer.pad_token is None:
373
+ self.gpt_tokenizer.pad_token = self.gpt_tokenizer.eos_token
374
 
375
  # Tokenize text with proper settings
376
+ encodings = self.gpt_tokenizer(text,
377
+ return_tensors = 'pt',
378
+ truncation = True,
379
+ max_length = 256,
380
+ padding = True,
381
+ return_attention_mask = True,
382
+ )
383
 
384
  input_ids = encodings.input_ids.to(self.device)
385
  attention_mask = encodings.attention_mask.to(self.device)
386
 
387
  # Minimum tokens for meaningful analysis
388
+ if ((input_ids.numel() == 0) or (input_ids.size(1) < 3)):
389
+ return 2.0
390
 
391
+ # Calculate proper log-likelihood using token probabilities
392
  with torch.no_grad():
393
+ outputs = self.gpt_model(input_ids,
394
+ attention_mask = attention_mask,
395
+ )
396
+
397
+ logits = outputs.logits
398
+
399
+ # Calculate log probabilities for each token
400
+ log_probs = torch.nn.functional.log_softmax(logits, dim = -1)
401
+
402
+ # Get the log probability of each actual token
403
+ log_likelihood = 0.0
404
+ token_count = 0
405
+
406
+ for i in range(input_ids.size(1) - 1):
407
+ # Only consider non-padding tokens
408
+ if (attention_mask[0, i] == 1):
409
+ token_id = input_ids[0, i + 1] # Next token prediction
410
+ log_prob = log_probs[0, i, token_id]
411
+ log_likelihood += log_prob.item()
412
+ token_count += 1
413
+
414
+ # Normalize by token count to get average log likelihood per token
415
+ if (token_count > 0):
416
+ avg_log_likelihood = log_likelihood / token_count
417
 
418
+ else:
419
+ avg_log_likelihood = 0.0
420
 
421
+ # Convert to positive scale and normalize
422
+ # Typical GPT-2 log probabilities range from ~-10 to ~-2
423
+ # Higher normalized value = more likely text
424
+ normalized_likelihood = max(0.5, min(10.0, -avg_log_likelihood))
 
 
 
425
 
426
+ return normalized_likelihood
427
 
428
  except Exception as e:
429
  logger.warning(f"Likelihood calculation failed: {repr(e)}")
430
+ return 2.0 # Return reasonable baseline on error
431
 
432
 
433
  def _generate_perturbations(self, text: str, num_perturbations: int = 5) -> List[str]:
434
  """
435
+ Generate perturbed versions of the text using multiple techniques:
436
+ 1. Word deletion (simple but effective)
437
+ 2. Word swapping (preserve meaning)
438
+ 3. DistilRoBERTa masked prediction (DetectGPT-inspired, using lighter model than T5)
439
+ 4. Synonym replacement (fallback)
440
  """
441
  perturbations = list()
442
 
 
486
  logger.debug(f"Word swapping perturbation failed: {e}")
487
  continue
488
 
489
+ # Method 3: DistilRoBERTa-based masked word replacement (DetectGPT-inspired)
490
  if (self.mask_model and self.mask_tokenizer and (len(words) > 4) and len(perturbations) < num_perturbations):
491
 
492
  try:
493
+ roberta_perturbations = self._generate_roberta_masked_perturbations(text = processed_text,
494
+ words = words,
495
+ max_perturbations = num_perturbations - len(perturbations),
496
+ )
497
  perturbations.extend(roberta_perturbations)
498
 
499
  except Exception as e:
500
+ logger.warning(f"DistilRoBERTa masked perturbation failed: {repr(e)}")
501
 
502
  # Method 4: Synonym replacement as fallback
503
  if (len(perturbations) < num_perturbations):
504
  try:
505
+ synonym_perturbations = self._generate_synonym_perturbations(text = processed_text,
506
+ words = words,
507
+ max_perturbations = num_perturbations - len(perturbations),
508
+ )
509
  perturbations.extend(synonym_perturbations)
510
 
511
  except Exception as e:
512
+ logger.debug(f"Synonym replacement failed: {repr(e)}")
513
 
514
  # Ensure we have at least some perturbations
515
  if not perturbations:
516
  # Fallback: create simple variations
517
+ fallback_perturbations = self._generate_fallback_perturbations(text = processed_text,
518
+ words = words,
519
+ )
520
  perturbations.extend(fallback_perturbations)
521
 
522
  # Remove duplicates and ensure we don't exceed requested number
 
530
 
531
  except Exception as e:
532
  logger.warning(f"Perturbation generation failed: {repr(e)}")
533
+ return [text] # Return at least the original text as fallback
 
534
 
535
 
536
  def _generate_roberta_masked_perturbations(self, text: str, words: List[str], max_perturbations: int) -> List[str]:
537
  """
538
+ Generate perturbations using DistilRoBERTa mask filling
539
+ This is inspired by DetectGPT but uses a lighter model (DistilRoBERTa instead of T5)
540
  """
541
  perturbations = list()
542
 
543
  try:
544
+ # Use the proper DistilRoBERTa mask token from tokenizer
545
+ if hasattr(self.mask_tokenizer, 'mask_token') and self.mask_tokenizer.mask_token:
546
+ roberta_mask_token = self.mask_tokenizer.mask_token
547
+
548
+ else:
549
+ roberta_mask_token = "<mask>" # Fallback
550
 
551
  # Select words to mask (avoid very short words and punctuation)
552
  candidate_positions = [i for i, word in enumerate(words) if (len(word) > 3) and word.isalpha() and word.lower() not in ['the', 'and', 'but', 'for', 'with']]
 
559
 
560
  # Try multiple mask positions
561
  attempts = min(max_perturbations * 2, len(candidate_positions))
562
+ positions_to_try = np.random.choice(candidate_positions, min(attempts, len(candidate_positions)), replace = False)
563
 
564
  for pos in positions_to_try:
565
  if (len(perturbations) >= max_perturbations):
 
572
  masked_words[pos] = roberta_mask_token
573
  masked_text = ' '.join(masked_words)
574
 
575
+ # DistilRoBERTa works better with proper sentence structure
576
  if not masked_text.endswith(('.', '!', '?')):
577
  masked_text += '.'
578
 
579
+ # Tokenize with DistilRoBERTa-specific settings
580
  inputs = self.mask_tokenizer(masked_text,
581
  return_tensors = "pt",
582
  truncation = True,
583
+ max_length = min(128, self.mask_tokenizer.model_max_length),
584
  padding = True,
585
  )
586
 
 
619
 
620
  if (self._is_valid_perturbation(new_text, text)):
621
  perturbations.append(new_text)
622
+ break # Use first valid prediction
 
623
 
624
  except Exception as e:
625
+ logger.debug(f"DistilRoBERTa mask filling failed for position {pos}: {e}")
626
  continue
627
 
628
  except Exception as e:
629
+ logger.warning(f"DistilRoBERTa masked perturbations failed: {e}")
630
 
631
  return perturbations
632
 
 
669
  perturbations.append(new_text)
670
 
671
  except Exception as e:
672
+ logger.debug(f"Synonym replacement failed: {repr(e)}")
673
 
674
  return perturbations
675
 
 
695
  perturbations.append(text.capitalize())
696
 
697
  except Exception as e:
698
+ logger.debug(f"Fallback perturbation failed: {repr(e)}")
699
 
700
  return [p for p in perturbations if p and p != text][:3]
701
 
702
 
703
  def _calculate_stability_score(self, original_likelihood: float, perturbed_likelihoods: List[float]) -> float:
704
  """
705
+ Calculate text stability score with improved normalization : AI text typically shows higher stability (larger drops) than human text
706
  """
707
  if ((not perturbed_likelihoods) or (original_likelihood <= 0)):
708
+ # Assume more human-like when no data
709
+ return 0.3
710
 
711
+ # Calculate relative likelihood drops
712
+ relative_drops = list()
 
713
 
714
+ for pl in perturbed_likelihoods:
715
+ if (pl > 0):
716
+ # Use relative drop to handle scale differences
717
+ relative_drop = (original_likelihood - pl) / original_likelihood
718
+
719
+ # Clamp to [0, 1]
720
+ relative_drops.append(max(0.0, min(1.0, relative_drop)))
721
+
722
+ if not relative_drops:
723
+ return 0.3
724
+
725
+ avg_relative_drop = np.mean(relative_drops)
726
+
727
+ # Normalization based on empirical observations : AI text typically shows 20-60% drops, human text shows 10-30% drops
728
+ if (avg_relative_drop > 0.5):
729
+ # Strong AI indicator
730
+ stability_score = 0.9
731
 
732
+ elif (avg_relative_drop > 0.3):
733
+ # 0.6 to 0.9
734
+ stability_score = 0.6 + (avg_relative_drop - 0.3) * 1.5
735
+
736
+ elif (avg_relative_drop > 0.15):
737
+ # 0.3 to 0.6
738
+ stability_score = 0.3 + (avg_relative_drop - 0.15) * 2.0
739
+
740
+ else:
741
+ # 0.0 to 0.3
742
+ stability_score = avg_relative_drop * 2.0
743
+
744
+ return min(1.0, max(0.0, stability_score))
745
 
746
 
747
  def _calculate_curvature_score(self, original_likelihood: float, perturbed_likelihoods: List[float]) -> float:
748
  """
749
+ Calculate likelihood curvature score with better scaling : Measures how "curved" the likelihood surface is around the text
750
  """
751
  if ((not perturbed_likelihoods) or (original_likelihood <= 0)):
752
+ return 0.3
753
 
754
  # Calculate variance of likelihood changes
755
  likelihood_changes = [abs(original_likelihood - pl) for pl in perturbed_likelihoods]
 
756
 
757
+ if (len(likelihood_changes) < 2):
758
+ return 0.3
759
+
760
+ change_variance = np.var(likelihood_changes)
761
+
762
+ # Typical variance for meaningful analysis is around 0.1-0.5 : Adjusted scaling
763
+ curvature_score = min(1.0, change_variance * 3.0)
764
 
765
  return curvature_score
766
 
 
778
 
779
  if (len(chunk) > 50):
780
  try:
781
+ chunk_likelihood = self._calculate_likelihood(text = chunk)
782
 
783
  if (chunk_likelihood > 0):
784
  # Generate a simple perturbation for this chunk
 
790
  indices_to_keep = np.random.choice(len(chunk_words), len(chunk_words) - delete_count, replace=False)
791
  perturbed_chunk = ' '.join([chunk_words[i] for i in sorted(indices_to_keep)])
792
 
793
+ perturbed_likelihood = self._calculate_likelihood(text = perturbed_chunk)
794
 
795
  if (perturbed_likelihood > 0):
796
  stability = (chunk_likelihood - perturbed_likelihood) / chunk_likelihood
797
  stabilities.append(min(1.0, max(0.0, stability)))
798
+
799
  except Exception:
800
  continue
801
 
 
804
 
805
  def _analyze_stability_patterns(self, features: Dict[str, Any]) -> tuple:
806
  """
807
+ Analyze MultiPerturbationStability patterns with better feature weighting
808
  """
809
  # Check feature validity first
810
  required_features = ['stability_score', 'curvature_score', 'normalized_likelihood_ratio', 'stability_variance', 'perturbation_variance']
 
817
 
818
 
819
  # Initialize ai_indicator list
820
+ ai_indicators = list()
821
+
822
+ # Better weighting based on feature reliability
823
+ stability_weight = 0.3
824
+ curvature_weight = 0.25
825
+ ratio_weight = 0.25
826
+ variance_weight = 0.2
827
 
828
  # High stability score suggests AI (larger likelihood drops)
829
+ stability = features['stability_score']
830
+ if (stability > 0.7):
831
+ ai_indicators.append(0.9 * stability_weight)
832
+
833
+ elif (stability > 0.5):
834
+ ai_indicators.append(0.7 * stability_weight)
835
+
836
+ elif (stability > 0.3):
837
+ ai_indicators.append(0.5 * stability_weight)
838
 
839
  else:
840
+ ai_indicators.append(0.2 * stability_weight)
841
 
842
  # High curvature score suggests AI
843
+ curvature = features['curvature_score']
844
+ if (curvature > 0.7):
845
+ ai_indicators.append(0.8 * curvature_weight)
846
+
847
+ elif (curvature > 0.5):
848
+ ai_indicators.append(0.6 * curvature_weight)
849
+
850
+ elif (curvature > 0.3):
851
+ ai_indicators.append(0.4 * curvature_weight)
852
+
853
  else:
854
+ ai_indicators.append(0.2 * curvature_weight)
855
 
856
  # High likelihood ratio suggests AI (original much more likely than perturbations)
857
+ ratio = features['normalized_likelihood_ratio']
858
+ if (ratio > 0.8):
859
+ ai_indicators.append(0.9 * ratio_weight)
860
+
861
+ elif (ratio > 0.6):
862
+ ai_indicators.append(0.7 * ratio_weight)
863
+
864
+ elif (ratio > 0.4):
865
+ ai_indicators.append(0.5 * ratio_weight)
866
 
 
 
 
867
  else:
868
+ ai_indicators.append(0.3 * ratio_weight)
869
 
870
  # Low stability variance suggests AI (consistent across chunks)
871
+ stability_var = features['stability_variance']
872
+ if (stability_var < 0.05):
873
+ ai_indicators.append(0.8 * variance_weight)
 
 
 
 
 
874
 
875
+ elif (stability_var < 0.1):
876
+ ai_indicators.append(0.5 * variance_weight)
 
 
 
 
877
 
878
  else:
879
+ ai_indicators.append(0.2 * variance_weight)
880
 
881
  # Calculate raw score and confidence
882
+ if ai_indicators:
883
+ raw_score = sum(ai_indicators)
884
+ confidence = 0.5 + (0.5 * (1.0 - (np.std([x / (weights := [stability_weight, curvature_weight, ratio_weight, variance_weight])[i] for i, x in enumerate(ai_indicators)]) if len(ai_indicators) > 1 else 0.5)))
885
+
886
+ else:
887
+ raw_score = 0.5
888
+ confidence = 0.3
889
+
890
  confidence = max(0.1, min(0.9, confidence))
891
 
892
  return raw_score, confidence
 
927
 
928
  def _get_default_features(self) -> Dict[str, Any]:
929
  """
930
+ Return more meaningful default features
931
  """
932
  return {"original_likelihood" : 2.0,
933
  "avg_perturbed_likelihood" : 1.8,
934
  "likelihood_ratio" : 1.1,
935
  "normalized_likelihood_ratio" : 0.55,
936
+ "stability_score" : 0.3,
937
+ "curvature_score" : 0.3,
938
  "perturbation_variance" : 0.05,
939
+ "avg_chunk_stability" : 0.3,
940
  "stability_variance" : 0.1,
941
  "num_perturbations" : 0,
942
  "num_valid_perturbations" : 0,
 
971
  # Normalize whitespace
972
  text = ' '.join(text.split())
973
 
974
+ # DistilRoBERTa works better with proper punctuation
975
  if not text.endswith(('.', '!', '?')):
976
  text += '.'
977
 
978
  # Truncate to safe length
979
  if (len(text) > 1000):
980
  sentences = text.split('. ')
981
+ if (len(sentences) > 1):
982
  # Keep first few sentences
983
  text = '. '.join(sentences[:3]) + '.'
984
 
 
988
  return text
989
 
990
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
991
  def _clean_roberta_token(self, token: str) -> str:
992
  """
993
+ Clean tokens from DistilRoBERTa tokenizer
994
  """
995
  if not token:
996
  return ""
997
 
998
+ # Remove DistilRoBERTa-specific artifacts
999
  token = token.replace('Ġ', ' ') # RoBERTa space marker
1000
  token = token.replace('</s>', '')
1001
  token = token.replace('<s>', '')
1002
  token = token.replace('<pad>', '')
1003
+ token = token.replace('<mask>', '')
1004
 
1005
+ # Remove leading/trailing whitespace
1006
+ token = token.strip()
1007
 
1008
+ # Only remove punctuation if token is ONLY punctuation
1009
+ if token and not token.replace('.', '').replace(',', '').replace('!', '').replace('?', '').strip():
1010
+ return ""
1011
+
1012
+ # Keep the token if it has at least 2 alphanumeric characters
1013
+ if sum(c.isalnum() for c in token) >= 2:
1014
+ return token
1015
+
1016
+ return ""
1017
 
1018
 
1019
  def _is_valid_perturbation(self, perturbed_text: str, original_text: str) -> bool:
1020
  """
1021
+ Check if a perturbation is valid (more lenient validation)
1022
  """
1023
+ if (not perturbed_text or not perturbed_text.strip()):
1024
+ return False
1025
+
1026
+ # Must be different from original
1027
+ if (perturbed_text == original_text):
1028
+ return False
1029
+
1030
+ # Lenient length check
1031
+ if (len(perturbed_text) < len(original_text) * 0.3):
1032
+ return False
1033
+
1034
+ # Must have some actual content
1035
+ if len(perturbed_text.strip()) < 5:
1036
+ return False
1037
+
1038
+ return True
1039
 
1040
 
1041
  def cleanup(self):
models/model_manager.py CHANGED
@@ -21,6 +21,7 @@ from transformers import AutoTokenizer
21
  from transformers import GPT2LMHeadModel
22
  from config.model_config import ModelType
23
  from config.model_config import ModelConfig
 
24
  from transformers import AutoModelForMaskedLM
25
  from config.model_config import MODEL_REGISTRY
26
  from config.model_config import get_model_config
@@ -237,6 +238,12 @@ class ModelManager:
237
  elif (model_config.model_type == ModelType.TRANSFORMER):
238
  model = self._load_transformer(config = model_config)
239
 
 
 
 
 
 
 
240
  elif (model_config.model_type == ModelType.RULE_BASED):
241
  # Check if it's a spaCy model
242
  if model_config.additional_params.get("is_spacy_model", False):
@@ -288,7 +295,13 @@ class ModelManager:
288
  logger.info(f"Loading tokenizer for: {model_name}")
289
 
290
  try:
291
- if (model_config.model_type in [ModelType.GPT, ModelType.CLASSIFIER, ModelType.SEQUENCE_CLASSIFICATION, ModelType.TRANSFORMER]):
 
 
 
 
 
 
292
  tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path = model_config.model_id,
293
  cache_dir = str(self.cache_dir),
294
  )
@@ -339,6 +352,54 @@ class ModelManager:
339
  return (model, tokenizer)
340
 
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  def _load_classifier(self, config: ModelConfig) -> Any:
343
  """
344
  Load classification model (for zero-shot, etc.)
@@ -483,7 +544,7 @@ class ModelManager:
483
  logger.info(f"Downloading model: {model_name} ({model_config.model_id})")
484
 
485
  try:
486
- if model_config.model_type == ModelType.SENTENCE_TRANSFORMER:
487
  SentenceTransformer(model_name_or_path = model_config.model_id,
488
  cache_folder = str(self.cache_dir),
489
  )
@@ -506,6 +567,24 @@ class ModelManager:
506
  cache_dir = str(self.cache_dir),
507
  )
508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  elif (model_config.model_type == ModelType.RULE_BASED):
510
  if model_config.additional_params.get("is_spacy_model", False):
511
  subprocess.run(["python", "-m", "spacy", "download", model_config.model_id], check = True)
 
21
  from transformers import GPT2LMHeadModel
22
  from config.model_config import ModelType
23
  from config.model_config import ModelConfig
24
+ from transformers import AutoModelForCausalLM
25
  from transformers import AutoModelForMaskedLM
26
  from config.model_config import MODEL_REGISTRY
27
  from config.model_config import get_model_config
 
238
  elif (model_config.model_type == ModelType.TRANSFORMER):
239
  model = self._load_transformer(config = model_config)
240
 
241
+ elif (model_config.model_type == ModelType.CAUSAL_LM):
242
+ model = self._load_causal_lm(config = model_config)
243
+
244
+ elif (model_config.model_type == ModelType.MASKED_LM):
245
+ model = self._load_masked_lm(config = model_config)
246
+
247
  elif (model_config.model_type == ModelType.RULE_BASED):
248
  # Check if it's a spaCy model
249
  if model_config.additional_params.get("is_spacy_model", False):
 
295
  logger.info(f"Loading tokenizer for: {model_name}")
296
 
297
  try:
298
+ if (model_config.model_type in [ModelType.GPT,
299
+ ModelType.CLASSIFIER,
300
+ ModelType.SEQUENCE_CLASSIFICATION,
301
+ ModelType.TRANSFORMER,
302
+ ModelType.CAUSAL_LM,
303
+ ModelType.MASKED_LM]):
304
+
305
  tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path = model_config.model_id,
306
  cache_dir = str(self.cache_dir),
307
  )
 
352
  return (model, tokenizer)
353
 
354
 
355
+ def _load_causal_lm(self, config: ModelConfig) -> tuple:
356
+ """
357
+ Load causal language model (like GPT-2) for text generation
358
+ """
359
+ model = AutoModelForCausalLM.from_pretrained(pretrained_model_name_or_path = config.model_id,
360
+ cache_dir = str(self.cache_dir),
361
+ )
362
+
363
+ tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path = config.model_id,
364
+ cache_dir = str(self.cache_dir),
365
+ )
366
+
367
+ # Move to device
368
+ model = model.to(self.device)
369
+
370
+ model.eval()
371
+
372
+ # Apply quantization if enabled
373
+ if (settings.USE_QUANTIZATION and config.quantizable):
374
+ model = self._quantize_model(model = model)
375
+
376
+ return (model, tokenizer)
377
+
378
+
379
+ def _load_masked_lm(self, config: ModelConfig) -> tuple:
380
+ """
381
+ Load masked language model (like RoBERTa) for fill-mask tasks
382
+ """
383
+ model = AutoModelForMaskedLM.from_pretrained(pretrained_model_name_or_path = config.model_id,
384
+ cache_dir = str(self.cache_dir),
385
+ )
386
+
387
+ tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path = config.model_id,
388
+ cache_dir = str(self.cache_dir),
389
+ )
390
+
391
+ # Move to device
392
+ model = model.to(self.device)
393
+
394
+ model.eval()
395
+
396
+ # Apply quantization if enabled
397
+ if (settings.USE_QUANTIZATION and config.quantizable):
398
+ model = self._quantize_model(model = model)
399
+
400
+ return (model, tokenizer)
401
+
402
+
403
  def _load_classifier(self, config: ModelConfig) -> Any:
404
  """
405
  Load classification model (for zero-shot, etc.)
 
544
  logger.info(f"Downloading model: {model_name} ({model_config.model_id})")
545
 
546
  try:
547
+ if (model_config.model_type == ModelType.SENTENCE_TRANSFORMER):
548
  SentenceTransformer(model_name_or_path = model_config.model_id,
549
  cache_folder = str(self.cache_dir),
550
  )
 
567
  cache_dir = str(self.cache_dir),
568
  )
569
 
570
+ elif (model_config.model_type == ModelType.CAUSAL_LM):
571
+ AutoModelForCausalLM.from_pretrained(pretrained_model_name_or_path = model_config.model_id,
572
+ cache_dir = str(self.cache_dir),
573
+ )
574
+
575
+ AutoTokenizer.from_pretrained(pretrained_model_name_or_path = model_config.model_id,
576
+ cache_dir = str(self.cache_dir),
577
+ )
578
+
579
+ elif (model_config.model_type == ModelType.MASKED_LM):
580
+ AutoModelForMaskedLM.from_pretrained(pretrained_model_name_or_path = model_config.model_id,
581
+ cache_dir = str(self.cache_dir),
582
+ )
583
+
584
+ AutoTokenizer.from_pretrained(pretrained_model_name_or_path = model_config.model_id,
585
+ cache_dir = str(self.cache_dir),
586
+ )
587
+
588
  elif (model_config.model_type == ModelType.RULE_BASED):
589
  if model_config.additional_params.get("is_spacy_model", False):
590
  subprocess.run(["python", "-m", "spacy", "download", model_config.model_id], check = True)
reporter/report_generator.py CHANGED
@@ -79,6 +79,9 @@ class ReportGenerator:
79
  --------
80
  { dict } : Dictionary mapping format to filepath
81
  """
 
 
 
82
  # Generate detailed reasoning
83
  reasoning = self.reasoning_generator.generate(ensemble_result = detection_result.ensemble_result,
84
  metric_results = detection_result.metric_results,
@@ -88,7 +91,7 @@ class ReportGenerator:
88
  )
89
 
90
  # Extract detailed metrics from ACTUAL detection results
91
- detailed_metrics = self._extract_detailed_metrics(detection_result)
92
 
93
  # Timestamp for filenames
94
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -97,7 +100,7 @@ class ReportGenerator:
97
 
98
  # Generate requested formats
99
  if ("json" in formats):
100
- json_path = self._generate_json_report(detection_result = detection_result,
101
  reasoning = reasoning,
102
  detailed_metrics = detailed_metrics,
103
  attribution_result = attribution_result,
@@ -108,7 +111,7 @@ class ReportGenerator:
108
 
109
  if ("pdf" in formats):
110
  try:
111
- pdf_path = self._generate_pdf_report(detection_result = detection_result,
112
  reasoning = reasoning,
113
  detailed_metrics = detailed_metrics,
114
  attribution_result = attribution_result,
@@ -126,26 +129,29 @@ class ReportGenerator:
126
  return generated_files
127
 
128
 
129
- def _extract_detailed_metrics(self, detection_result: DetectionResult) -> List[DetailedMetric]:
130
  """
131
  Extract detailed metrics with sub-metrics from ACTUAL detection result
132
  """
133
  detailed_metrics = list()
134
- metric_results = detection_result.metric_results
135
- ensemble_result = detection_result.ensemble_result
136
 
137
  # Get actual metric weights from ensemble
138
- metric_weights = getattr(ensemble_result, 'metric_weights', {})
139
 
140
  # Extract actual metric data
141
- for metric_name, metric_result in metric_results.items():
142
- if metric_result.error is not None:
 
 
 
143
  continue
144
 
145
  # Get actual probabilities and confidence
146
- ai_prob = metric_result.ai_probability * 100
147
- human_prob = metric_result.human_probability * 100
148
- confidence = metric_result.confidence * 100
149
 
150
  # Determine verdict based on actual probability
151
  if (ai_prob >= 60):
@@ -158,7 +164,9 @@ class ReportGenerator:
158
  verdict = "MIXED (AI + HUMAN)"
159
 
160
  # Get actual weight or use default
161
- weight = metric_weights.get(metric_name, 0.0) * 100
 
 
162
 
163
  # Extract actual detailed metrics from metric result
164
  detailed_metrics_data = self._extract_metric_details(metric_name = metric_name,
@@ -182,22 +190,22 @@ class ReportGenerator:
182
  return detailed_metrics
183
 
184
 
185
- def _extract_metric_details(self, metric_name: str, metric_result) -> Dict[str, float]:
186
  """
187
  Extract detailed sub-metrics from metric result
188
  """
189
  details = dict()
190
 
191
  # Try to get details from metric result
192
- if ((hasattr(metric_result, 'details')) and metric_result.details):
193
- details = metric_result.details.copy()
194
 
195
  # If no details available, provide basic calculated values
196
  if not details:
197
- details = {"ai_probability" : metric_result.ai_probability * 100,
198
- "human_probability" : metric_result.human_probability * 100,
199
- "confidence" : metric_result.confidence * 100,
200
- "score" : getattr(metric_result, 'score', 0.0) * 100,
201
  }
202
 
203
  return details
@@ -218,7 +226,7 @@ class ReportGenerator:
218
  return descriptions.get(metric_name, "Advanced text analysis metric.")
219
 
220
 
221
- def _generate_json_report(self, detection_result: DetectionResult, reasoning: DetailedReasoning, detailed_metrics: List[DetailedMetric],
222
  attribution_result: Optional[AttributionResult], highlighted_sentences: Optional[List] = None, filename: str = None) -> Path:
223
  """
224
  Generate JSON format report with detailed metrics
@@ -251,7 +259,7 @@ class ReportGenerator:
251
  "index" : sent.index,
252
  })
253
 
254
- # Attribution data - use attribution_result
255
  attribution_data = None
256
 
257
  if attribution_result:
@@ -264,30 +272,32 @@ class ReportGenerator:
264
  "metric_contributions": attribution_result.metric_contributions,
265
  }
266
 
267
- # Use ACTUAL detection results with ensemble integration
268
- ensemble_result = detection_result.ensemble_result
 
 
 
269
 
270
  report_data = {"report_metadata" : {"generated_at" : datetime.now().isoformat(),
271
  "version" : "1.0.0",
272
  "format" : "json",
273
  "report_id" : filename.replace('.json', ''),
274
  },
275
- "overall_results" : {"final_verdict" : ensemble_result.final_verdict,
276
- "ai_probability" : round(ensemble_result.ai_probability, 4),
277
- "human_probability" : round(ensemble_result.human_probability, 4),
278
- "mixed_probability" : round(ensemble_result.mixed_probability, 4),
279
- "overall_confidence" : round(ensemble_result.overall_confidence, 4),
280
- "uncertainty_score" : round(ensemble_result.uncertainty_score, 4),
281
- "consensus_level" : round(ensemble_result.consensus_level, 4),
282
- "domain" : detection_result.domain_prediction.primary_domain.value,
283
- "domain_confidence" : round(detection_result.domain_prediction.confidence, 4),
284
- "text_length" : detection_result.processed_text.word_count,
285
- "sentence_count" : detection_result.processed_text.sentence_count,
286
  },
287
  "ensemble_analysis" : {"method_used" : "confidence_calibrated",
288
- "metric_weights" : {name: round(weight, 4) for name, weight in ensemble_result.metric_weights.items()},
289
- "weighted_scores" : {name: round(score, 4) for name, score in ensemble_result.weighted_scores.items()},
290
- "reasoning" : ensemble_result.reasoning,
291
  },
292
  "detailed_metrics" : metrics_data,
293
  "detection_reasoning" : {"summary" : reasoning.summary,
@@ -303,10 +313,10 @@ class ReportGenerator:
303
  },
304
  "highlighted_text" : highlighted_data,
305
  "model_attribution" : attribution_data,
306
- "performance_metrics" : {"total_processing_time" : round(detection_result.processing_time, 3),
307
- "metrics_execution_time" : {name: round(time, 3) for name, time in detection_result.metrics_execution_time.items()},
308
- "warnings" : detection_result.warnings,
309
- "errors" : detection_result.errors,
310
  }
311
  }
312
 
@@ -323,7 +333,7 @@ class ReportGenerator:
323
  return output_path
324
 
325
 
326
- def _generate_pdf_report(self, detection_result: DetectionResult, reasoning: DetailedReasoning, detailed_metrics: List[DetailedMetric],
327
  attribution_result: Optional[AttributionResult], highlighted_sentences: Optional[List] = None, filename: str = None) -> Path:
328
  """
329
  Generate PDF format report with detailed metrics
@@ -378,8 +388,9 @@ class ReportGenerator:
378
  spaceAfter = 8,
379
  )
380
 
381
- # Use detection results with ensemble integration
382
- ensemble_result = detection_result.ensemble_result
 
383
 
384
  # Title and main sections
385
  elements.append(Paragraph("AI Text Detection Analysis Report", title_style))
@@ -388,13 +399,13 @@ class ReportGenerator:
388
 
389
  # Verdict section with ensemble metrics
390
  elements.append(Paragraph("Detection Summary", heading_style))
391
- verdict_data = [['Final Verdict:', ensemble_result.final_verdict],
392
- ['AI Probability:', f"{ensemble_result.ai_probability:.1%}"],
393
- ['Human Probability:', f"{ensemble_result.human_probability:.1%}"],
394
- ['Mixed Probability:', f"{ensemble_result.mixed_probability:.1%}"],
395
- ['Overall Confidence:', f"{ensemble_result.overall_confidence:.1%}"],
396
- ['Uncertainty Score:', f"{ensemble_result.uncertainty_score:.1%}"],
397
- ['Consensus Level:', f"{ensemble_result.consensus_level:.1%}"],
398
  ]
399
 
400
  verdict_table = Table(verdict_data, colWidths=[2*inch, 3*inch])
@@ -410,11 +421,11 @@ class ReportGenerator:
410
 
411
  # Content analysis
412
  elements.append(Paragraph("Content Analysis", heading_style))
413
- content_data = [['Content Domain:', detection_result.domain_prediction.primary_domain.value.title()],
414
- ['Domain Confidence:', f"{detection_result.domain_prediction.confidence:.1%}"],
415
- ['Word Count:', str(detection_result.processed_text.word_count)],
416
- ['Sentence Count:', str(detection_result.processed_text.sentence_count)],
417
- ['Processing Time:', f"{detection_result.processing_time:.2f}s"],
418
  ]
419
 
420
  content_table = Table(content_data, colWidths=[2*inch, 3*inch])
@@ -428,14 +439,16 @@ class ReportGenerator:
428
 
429
  # Ensemble Analysis
430
  elements.append(Paragraph("Ensemble Analysis", heading_style))
431
- elements.append(Paragraph(f"Method: Confidence Calibrated Aggregation", styles['Normal']))
432
  elements.append(Spacer(1, 0.1*inch))
433
 
434
  # Metric weights table
435
- if hasattr(ensemble_result, 'metric_weights') and ensemble_result.metric_weights:
 
436
  elements.append(Paragraph("Metric Weights", styles['Heading3']))
437
  weight_data = [['Metric', 'Weight']]
438
- for metric, weight in ensemble_result.metric_weights.items():
 
439
  weight_data.append([metric.title(), f"{weight:.1%}"])
440
 
441
  weight_table = Table(weight_data, colWidths=[3*inch, 1*inch])
@@ -578,8 +591,8 @@ class ReportGenerator:
578
 
579
  # Footer
580
  elements.append(Spacer(1, 0.3*inch))
581
- elements.append(Paragraph(f"Generated by AI Text Detector v2.0 | Processing Time: {detection_result.processing_time:.2f}s",
582
- ParagraphStyle('Footer', parent=styles['Normal'], fontSize=8, textColor=colors.gray)))
583
 
584
  # Build PDF
585
  doc.build(elements)
 
79
  --------
80
  { dict } : Dictionary mapping format to filepath
81
  """
82
+ # Convert DetectionResult to dict for consistent access
83
+ detection_dict = detection_result.to_dict() if hasattr(detection_result, 'to_dict') else detection_result
84
+
85
  # Generate detailed reasoning
86
  reasoning = self.reasoning_generator.generate(ensemble_result = detection_result.ensemble_result,
87
  metric_results = detection_result.metric_results,
 
91
  )
92
 
93
  # Extract detailed metrics from ACTUAL detection results
94
+ detailed_metrics = self._extract_detailed_metrics(detection_dict)
95
 
96
  # Timestamp for filenames
97
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
 
100
 
101
  # Generate requested formats
102
  if ("json" in formats):
103
+ json_path = self._generate_json_report(detection_dict = detection_dict,
104
  reasoning = reasoning,
105
  detailed_metrics = detailed_metrics,
106
  attribution_result = attribution_result,
 
111
 
112
  if ("pdf" in formats):
113
  try:
114
+ pdf_path = self._generate_pdf_report(detection_dict = detection_dict,
115
  reasoning = reasoning,
116
  detailed_metrics = detailed_metrics,
117
  attribution_result = attribution_result,
 
129
  return generated_files
130
 
131
 
132
+ def _extract_detailed_metrics(self, detection_dict: Dict) -> List[DetailedMetric]:
133
  """
134
  Extract detailed metrics with sub-metrics from ACTUAL detection result
135
  """
136
  detailed_metrics = list()
137
+ metrics_data = detection_dict.get("metrics", {})
138
+ ensemble_data = detection_dict.get("ensemble", {})
139
 
140
  # Get actual metric weights from ensemble
141
+ metric_weights = ensemble_data.get("metric_contributions", {})
142
 
143
  # Extract actual metric data
144
+ for metric_name, metric_result in metrics_data.items():
145
+ if not isinstance(metric_result, dict):
146
+ continue
147
+
148
+ if metric_result.get("error") is not None:
149
  continue
150
 
151
  # Get actual probabilities and confidence
152
+ ai_prob = metric_result.get("ai_probability", 0) * 100
153
+ human_prob = metric_result.get("human_probability", 0) * 100
154
+ confidence = metric_result.get("confidence", 0) * 100
155
 
156
  # Determine verdict based on actual probability
157
  if (ai_prob >= 60):
 
164
  verdict = "MIXED (AI + HUMAN)"
165
 
166
  # Get actual weight or use default
167
+ weight = 0.0
168
+ if metric_name in metric_weights:
169
+ weight = metric_weights[metric_name].get("weight", 0.0) * 100
170
 
171
  # Extract actual detailed metrics from metric result
172
  detailed_metrics_data = self._extract_metric_details(metric_name = metric_name,
 
190
  return detailed_metrics
191
 
192
 
193
+ def _extract_metric_details(self, metric_name: str, metric_result: Dict) -> Dict[str, float]:
194
  """
195
  Extract detailed sub-metrics from metric result
196
  """
197
  details = dict()
198
 
199
  # Try to get details from metric result
200
+ if metric_result.get("details"):
201
+ details = metric_result["details"].copy()
202
 
203
  # If no details available, provide basic calculated values
204
  if not details:
205
+ details = {"ai_probability" : metric_result.get("ai_probability", 0) * 100,
206
+ "human_probability" : metric_result.get("human_probability", 0) * 100,
207
+ "confidence" : metric_result.get("confidence", 0) * 100,
208
+ "score" : metric_result.get("score", 0) * 100,
209
  }
210
 
211
  return details
 
226
  return descriptions.get(metric_name, "Advanced text analysis metric.")
227
 
228
 
229
+ def _generate_json_report(self, detection_dict: Dict, reasoning: DetailedReasoning, detailed_metrics: List[DetailedMetric],
230
  attribution_result: Optional[AttributionResult], highlighted_sentences: Optional[List] = None, filename: str = None) -> Path:
231
  """
232
  Generate JSON format report with detailed metrics
 
259
  "index" : sent.index,
260
  })
261
 
262
+ # Attribution data
263
  attribution_data = None
264
 
265
  if attribution_result:
 
272
  "metric_contributions": attribution_result.metric_contributions,
273
  }
274
 
275
+ # Use ACTUAL detection results from dictionary
276
+ ensemble_data = detection_dict.get("ensemble", {})
277
+ analysis_data = detection_dict.get("analysis", {})
278
+ metrics_data_dict = detection_dict.get("metrics", {})
279
+ performance_data = detection_dict.get("performance", {})
280
 
281
  report_data = {"report_metadata" : {"generated_at" : datetime.now().isoformat(),
282
  "version" : "1.0.0",
283
  "format" : "json",
284
  "report_id" : filename.replace('.json', ''),
285
  },
286
+ "overall_results" : {"final_verdict" : ensemble_data.get("final_verdict", "Unknown"),
287
+ "ai_probability" : ensemble_data.get("ai_probability", 0),
288
+ "human_probability" : ensemble_data.get("human_probability", 0),
289
+ "mixed_probability" : ensemble_data.get("mixed_probability", 0),
290
+ "overall_confidence" : ensemble_data.get("overall_confidence", 0),
291
+ "uncertainty_score" : ensemble_data.get("uncertainty_score", 0),
292
+ "consensus_level" : ensemble_data.get("consensus_level", 0),
293
+ "domain" : analysis_data.get("domain", "general"),
294
+ "domain_confidence" : analysis_data.get("domain_confidence", 0),
295
+ "text_length" : analysis_data.get("text_length", 0),
296
+ "sentence_count" : analysis_data.get("sentence_count", 0),
297
  },
298
  "ensemble_analysis" : {"method_used" : "confidence_calibrated",
299
+ "metric_weights" : ensemble_data.get("metric_contributions", {}),
300
+ "reasoning" : ensemble_data.get("reasoning", []),
 
301
  },
302
  "detailed_metrics" : metrics_data,
303
  "detection_reasoning" : {"summary" : reasoning.summary,
 
313
  },
314
  "highlighted_text" : highlighted_data,
315
  "model_attribution" : attribution_data,
316
+ "performance_metrics" : {"total_processing_time" : performance_data.get("total_time", 0),
317
+ "metrics_execution_time" : performance_data.get("metrics_time", {}),
318
+ "warnings" : detection_dict.get("warnings", []),
319
+ "errors" : detection_dict.get("errors", []),
320
  }
321
  }
322
 
 
333
  return output_path
334
 
335
 
336
+ def _generate_pdf_report(self, detection_dict: Dict, reasoning: DetailedReasoning, detailed_metrics: List[DetailedMetric],
337
  attribution_result: Optional[AttributionResult], highlighted_sentences: Optional[List] = None, filename: str = None) -> Path:
338
  """
339
  Generate PDF format report with detailed metrics
 
388
  spaceAfter = 8,
389
  )
390
 
391
+ # Use detection results from dictionary
392
+ ensemble_data = detection_dict.get("ensemble", {})
393
+ analysis_data = detection_dict.get("analysis", {})
394
 
395
  # Title and main sections
396
  elements.append(Paragraph("AI Text Detection Analysis Report", title_style))
 
399
 
400
  # Verdict section with ensemble metrics
401
  elements.append(Paragraph("Detection Summary", heading_style))
402
+ verdict_data = [['Final Verdict:', ensemble_data.get("final_verdict", "Unknown")],
403
+ ['AI Probability:', f"{ensemble_data.get('ai_probability', 0):.1%}"],
404
+ ['Human Probability:', f"{ensemble_data.get('human_probability', 0):.1%}"],
405
+ ['Mixed Probability:', f"{ensemble_data.get('mixed_probability', 0):.1%}"],
406
+ ['Overall Confidence:', f"{ensemble_data.get('overall_confidence', 0):.1%}"],
407
+ ['Uncertainty Score:', f"{ensemble_data.get('uncertainty_score', 0):.1%}"],
408
+ ['Consensus Level:', f"{ensemble_data.get('consensus_level', 0):.1%}"],
409
  ]
410
 
411
  verdict_table = Table(verdict_data, colWidths=[2*inch, 3*inch])
 
421
 
422
  # Content analysis
423
  elements.append(Paragraph("Content Analysis", heading_style))
424
+ content_data = [['Content Domain:', analysis_data.get("domain", "general").title()],
425
+ ['Domain Confidence:', f"{analysis_data.get('domain_confidence', 0):.1%}"],
426
+ ['Word Count:', str(analysis_data.get("text_length", 0))],
427
+ ['Sentence Count:', str(analysis_data.get("sentence_count", 0))],
428
+ ['Processing Time:', f"{detection_dict.get('performance', {}).get('total_time', 0):.2f}s"],
429
  ]
430
 
431
  content_table = Table(content_data, colWidths=[2*inch, 3*inch])
 
439
 
440
  # Ensemble Analysis
441
  elements.append(Paragraph("Ensemble Analysis", heading_style))
442
+ elements.append(Paragraph("Method: Confidence Calibrated Aggregation", styles['Normal']))
443
  elements.append(Spacer(1, 0.1*inch))
444
 
445
  # Metric weights table
446
+ metric_contributions = ensemble_data.get("metric_contributions", {})
447
+ if metric_contributions:
448
  elements.append(Paragraph("Metric Weights", styles['Heading3']))
449
  weight_data = [['Metric', 'Weight']]
450
+ for metric, contribution in metric_contributions.items():
451
+ weight = contribution.get("weight", 0)
452
  weight_data.append([metric.title(), f"{weight:.1%}"])
453
 
454
  weight_table = Table(weight_data, colWidths=[3*inch, 1*inch])
 
591
 
592
  # Footer
593
  elements.append(Spacer(1, 0.3*inch))
594
+ elements.append(Paragraph(f"Generated by AI Text Detector v2.0 | Processing Time: {detection_dict.get('performance', {}).get('total_time', 0):.2f}s",
595
+ ParagraphStyle('Footer', parent=styles['Normal'], fontSize=8, textColor=colors.gray)))
596
 
597
  # Build PDF
598
  doc.build(elements)
requirements.txt CHANGED
@@ -1,98 +1,56 @@
1
  # Core Framework
2
- fastapi==0.104.1
3
- uvicorn[standard]==0.24.0
4
- pydantic==2.5.0
5
- pydantic-settings==2.1.0
6
- python-multipart==0.0.6
7
 
8
  # Machine Learning & Transformers
9
- torch==2.1.0
10
- transformers==4.35.2
11
- sentence-transformers==2.2.2
12
- tokenizers==0.15.0
 
13
 
14
  # NLP Libraries
15
- spacy==3.7.2
16
- #flair==0.13.1
17
- nltk==3.8.1
18
- textstat==0.7.3
19
 
20
  # Scientific Computing
21
- numpy==1.24.3
22
- scipy==1.11.4
23
- scikit-learn==1.3.2
24
- pandas==2.1.3
25
 
26
  # Text Processing
27
- python-docx==1.1.0
28
  PyPDF2==3.0.1
29
- pdfplumber==0.10.3
30
- pymupdf==1.23.8
31
  python-magic==0.4.27
32
 
33
  # Language Detection
34
  langdetect==1.0.9
35
- #fasttext==0.9.2
36
-
37
- # Adversarial & Robustness
38
- #textattack==0.3.8
39
 
40
  # Visualization & Reporting
41
- matplotlib==3.8.2
42
  seaborn==0.13.0
43
- plotly==5.18.0
44
- reportlab==4.0.7
45
- fpdf2==2.7.6
46
 
47
  # Utilities
48
- python-dotenv==1.0.0
49
  aiofiles==23.2.1
50
- httpx==0.25.2
51
- tenacity==8.2.3
52
 
53
  # Logging & Monitoring
54
- loguru==0.7.2
55
- python-json-logger==2.0.7
56
 
57
  # Caching
58
- redis==5.0.1
59
  diskcache==5.6.3
60
 
61
- # Database (Optional)
62
- sqlalchemy==2.0.23
63
- alembic==1.13.0
64
-
65
- # Testing
66
- pytest==7.4.3
67
- pytest-asyncio==0.21.1
68
- pytest-cov==4.1.0
69
-
70
- # Code Quality
71
- black==23.12.0
72
- flake8==6.1.0
73
- mypy==1.7.1
74
-
75
- # Security
76
- cryptography==41.0.7
77
- python-jose[cryptography]==3.3.0
78
-
79
- # Performance
80
- orjson==3.9.10
81
- ujson==5.9.0
82
-
83
- # Additional ML Tools
84
- xgboost==2.0.2
85
- lightgbm==4.1.0
86
-
87
- # Dimensionality Analysis
88
- #scikit-dimension==0.3.5
89
- umap-learn==0.5.5
90
-
91
- # Rate Limiting
92
- slowapi==0.1.9
93
-
94
- # CORS
95
- fastapi-cors==0.0.6
96
-
97
- # File type detection
98
- python-magic-bin==0.4.14
 
1
  # Core Framework
2
+ fastapi==0.115.6
3
+ uvicorn==0.34.0
4
+ pydantic==2.11.4
5
+ pydantic-settings==2.11.0
6
+ python-multipart==0.0.20
7
 
8
  # Machine Learning & Transformers
9
+ torch==2.3.1
10
+ transformers==4.48.0
11
+ sentence-transformers==3.3.1
12
+ tokenizers==0.21.0
13
+ huggingface-hub==0.27.0
14
 
15
  # NLP Libraries
16
+ spacy==3.8.3
17
+ nltk==3.9.1
18
+ textstat==0.7.10
 
19
 
20
  # Scientific Computing
21
+ numpy==1.23.5
22
+ scipy==1.12.0
23
+ scikit-learn==1.6.0
24
+ pandas==2.2.3
25
 
26
  # Text Processing
27
+ python-docx==1.1.2
28
  PyPDF2==3.0.1
29
+ pdfplumber==0.11.5
30
+ pymupdf==1.25.5
31
  python-magic==0.4.27
32
 
33
  # Language Detection
34
  langdetect==1.0.9
 
 
 
 
35
 
36
  # Visualization & Reporting
37
+ matplotlib==3.8.0
38
  seaborn==0.13.0
39
+ reportlab==4.2.2
 
 
40
 
41
  # Utilities
42
+ python-dotenv==1.0.1
43
  aiofiles==23.2.1
44
+ httpx==0.27.0
45
+ tenacity==9.1.2
46
 
47
  # Logging & Monitoring
48
+ loguru==0.7.3
 
49
 
50
  # Caching
 
51
  diskcache==5.6.3
52
 
53
+ # Additional packages from your working environment
54
+ safetensors==0.4.4
55
+ accelerate==1.2.1
56
+ protobuf==4.25.4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
setup.sh ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Post-installation setup script for Hugging Face Spaces
4
+ echo "Starting setup for Text-Authentication Platform ..."
5
+
6
+ # Download Spacy Model
7
+ echo "Downloading SpaCy English model ..."
8
+ python -n spacy download en_core_web_sm
9
+
10
+ # Download NLTK data
11
+ echo "Downloading NLTK data ..."
12
+ python -c "import nltk; nltk.download('punkt'); nltk.download('stopwords'); nltk.download('averaged_perceptron_tagger')"
13
+
14
+ # Create necessary directories
15
+ echo "Creating directories ..."
16
+ mkdir -p data/reports data/uploads
17
+
18
+ # Verify installation
19
+ echo "Verifying installations ..."
20
+ python -c "import transformers; import torch; import spacy; print('All core libraries imported successfully.')"
21
+
22
+ echo "Setup complete !"
text_auth_app.py CHANGED
@@ -1245,8 +1245,6 @@ async def log_requests(request: Request, call_next):
1245
  return response
1246
 
1247
 
1248
-
1249
-
1250
  # ==================== MAIN ====================
1251
  if __name__ == "__main__":
1252
  # Configure logging
 
1245
  return response
1246
 
1247
 
 
 
1248
  # ==================== MAIN ====================
1249
  if __name__ == "__main__":
1250
  # Configure logging
ui/static/index.html CHANGED
@@ -273,7 +273,6 @@ body {
273
  padding: 2rem;
274
  border: 1px solid var(--border);
275
  backdrop-filter: blur(10px);
276
- /* Changed from fixed height to use available space */
277
  height: 850px;
278
  overflow: hidden;
279
  display: flex;
@@ -621,7 +620,7 @@ input[type="checkbox"] {
621
  color: var(--text-secondary);
622
  line-height: 1.7;
623
  }
624
- /* Enhanced Reasoning Styles */
625
  .reasoning-box.enhanced {
626
  background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.95) 100%);
627
  border: 1px solid rgba(71, 85, 105, 0.5);
@@ -703,7 +702,7 @@ input[type="checkbox"] {
703
  .metric-indicator {
704
  display: flex;
705
  justify-content: space-between;
706
- align-items: center;
707
  padding: 0.75rem;
708
  margin-bottom: 0.5rem;
709
  border-radius: 8px;
@@ -714,7 +713,7 @@ input[type="checkbox"] {
714
  transform: translateX(4px);
715
  }
716
  .metric-name {
717
- font-weight: 600;
718
  color: var(--text-primary);
719
  min-width: 140px;
720
  }
@@ -795,6 +794,23 @@ input[type="checkbox"] {
795
  font-weight: 700;
796
  color: var(--primary);
797
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  /* Download Actions */
799
  .download-actions {
800
  display: flex;
@@ -959,21 +975,16 @@ input[type="checkbox"] {
959
  }
960
  .metrics-carousel-content {
961
  flex: 1;
962
- /* Removed padding and centering to allow content to fill space */
963
  padding: 0;
964
- /* Removed align-items: center; justify-content: center; to let content take natural space */
965
  display: flex;
966
  align-items: flex-start;
967
  justify-content: flex-start;
968
  overflow-y: auto;
969
- /* Added some internal spacing for readability */
970
  padding: 1rem;
971
- /* min-height: 600px; */
972
  }
973
  .metric-slide {
974
  display: none;
975
  width: 100%;
976
- /* Reduced padding to make card tighter */
977
  padding: 1rem;
978
  }
979
  .metric-slide.active {
@@ -1011,6 +1022,43 @@ input[type="checkbox"] {
1011
  color: var(--text-secondary);
1012
  font-weight: 600;
1013
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1014
  /* Responsive */
1015
  @media (max-width: 1200px) {
1016
  .interface-grid {
@@ -1222,7 +1270,7 @@ html {
1222
  id="text-input"
1223
  class="text-input"
1224
  placeholder="Paste your text here for analysis...
1225
- The more text you provide (minimum 50 characters), the more accurate the detection will be. Our system analyzes linguistic patterns, statistical features, and semantic structures to determine authenticity."
1226
  ></textarea>
1227
  </div>
1228
  <div id="upload-tab" class="tab-content">
@@ -1351,18 +1399,21 @@ const API_BASE = '';
1351
  let currentAnalysisData = null;
1352
  let currentMetricIndex = 0;
1353
  let totalMetrics = 0;
 
1354
  // Navigation
1355
  function showLanding() {
1356
  document.getElementById('landing-page').style.display = 'block';
1357
  document.getElementById('analysis-interface').style.display = 'none';
1358
  window.scrollTo(0, 0);
1359
  }
 
1360
  function showAnalysis() {
1361
  document.getElementById('landing-page').style.display = 'none';
1362
  document.getElementById('analysis-interface').style.display = 'block';
1363
  window.scrollTo(0, 0);
1364
  resetAnalysisInterface();
1365
  }
 
1366
  // Reset analysis interface
1367
  function resetAnalysisInterface() {
1368
  // Clear text input
@@ -1419,6 +1470,7 @@ function resetAnalysisInterface() {
1419
  currentMetricIndex = 0;
1420
  totalMetrics = 0;
1421
  }
 
1422
  // Input Tab Switching
1423
  document.querySelectorAll('.input-tab').forEach(tab => {
1424
  tab.addEventListener('click', () => {
@@ -1431,6 +1483,7 @@ document.querySelectorAll('.input-tab').forEach(tab => {
1431
  document.getElementById(`${tabName}-tab`).classList.add('active');
1432
  });
1433
  });
 
1434
  // Report Tab Switching
1435
  document.querySelectorAll('.report-tab').forEach(tab => {
1436
  tab.addEventListener('click', () => {
@@ -1443,24 +1496,30 @@ document.querySelectorAll('.report-tab').forEach(tab => {
1443
  document.getElementById(`${reportName}-report`).classList.add('active');
1444
  });
1445
  });
 
1446
  // File Upload Handling
1447
  const fileInput = document.getElementById('file-input');
1448
  const fileUploadArea = document.getElementById('file-upload-area');
1449
  const fileNameDisplay = document.getElementById('file-name-display');
 
1450
  fileUploadArea.addEventListener('click', () => {
1451
  fileInput.click();
1452
  });
 
1453
  fileInput.addEventListener('change', (e) => {
1454
  handleFileSelect(e.target.files[0]);
1455
  });
 
1456
  // Drag and Drop
1457
  fileUploadArea.addEventListener('dragover', (e) => {
1458
  e.preventDefault();
1459
  fileUploadArea.classList.add('drag-over');
1460
  });
 
1461
  fileUploadArea.addEventListener('dragleave', () => {
1462
  fileUploadArea.classList.remove('drag-over');
1463
  });
 
1464
  fileUploadArea.addEventListener('drop', (e) => {
1465
  e.preventDefault();
1466
  fileUploadArea.classList.remove('drag-over');
@@ -1470,6 +1529,7 @@ fileUploadArea.addEventListener('drop', (e) => {
1470
  handleFileSelect(file);
1471
  }
1472
  });
 
1473
  function handleFileSelect(file) {
1474
  if (!file) return;
1475
  const allowedTypes = ['.txt', '.pdf', '.docx', '.doc', '.md'];
@@ -1488,16 +1548,19 @@ function handleFileSelect(file) {
1488
  <span style="color: var(--text-muted);">(${formatFileSize(file.size)})</span>
1489
  `;
1490
  }
 
1491
  function formatFileSize(bytes) {
1492
  if (bytes < 1024) return bytes + ' B';
1493
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
1494
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1495
  }
 
1496
  // Analyze Button
1497
  document.getElementById('analyze-btn').addEventListener('click', async () => {
1498
  const activeTab = document.querySelector('.input-tab.active').dataset.tab;
1499
  const textInput = document.getElementById('text-input').value.trim();
1500
  const fileInput = document.getElementById('file-input').files[0];
 
1501
  if (activeTab === 'paste' && !textInput) {
1502
  alert('Please paste some text to analyze (minimum 50 characters).');
1503
  return;
@@ -1510,21 +1573,26 @@ document.getElementById('analyze-btn').addEventListener('click', async () => {
1510
  alert('Please select a file to upload.');
1511
  return;
1512
  }
 
1513
  await performAnalysis(activeTab, textInput, fileInput);
1514
  });
 
1515
  // Refresh Button - clears everything and shows empty state
1516
  document.getElementById('refresh-btn').addEventListener('click', () => {
1517
  resetAnalysisInterface();
1518
  });
 
1519
  // Try Next Button - same as refresh but keeps the interface ready
1520
  document.getElementById('try-next-btn').addEventListener('click', () => {
1521
  resetAnalysisInterface();
1522
  });
 
1523
  async function performAnalysis(mode, text, file) {
1524
  const analyzeBtn = document.getElementById('analyze-btn');
1525
  analyzeBtn.disabled = true;
1526
  analyzeBtn.innerHTML = '⏳ Analyzing...';
1527
  showLoading();
 
1528
  try {
1529
  let response;
1530
  if (mode === 'paste') {
@@ -1542,12 +1610,14 @@ async function performAnalysis(mode, text, file) {
1542
  analyzeBtn.innerHTML = '🔍 Analyze Text';
1543
  }
1544
  }
 
1545
  async function analyzeText(text) {
1546
  const domain = document.getElementById('domain-select').value || null;
1547
  const enableAttribution = document.getElementById('enable-attribution').checked;
1548
  const enableHighlighting = document.getElementById('enable-highlighting').checked;
1549
  const useSentenceLevel = document.getElementById('use-sentence-level').checked;
1550
  const includeMetricsSummary = document.getElementById('include-metrics-summary').checked;
 
1551
  const response = await fetch(`${API_BASE}/api/analyze`, {
1552
  method: 'POST',
1553
  headers: { 'Content-Type': 'application/json' },
@@ -1561,17 +1631,20 @@ async function analyzeText(text) {
1561
  skip_expensive_metrics: false
1562
  })
1563
  });
 
1564
  if (!response.ok) {
1565
  const error = await response.json();
1566
  throw new Error(error.error || 'Analysis failed');
1567
  }
1568
  return await response.json();
1569
  }
 
1570
  async function analyzeFile(file) {
1571
  const domain = document.getElementById('domain-select').value || null;
1572
  const enableAttribution = document.getElementById('enable-attribution').checked;
1573
  const useSentenceLevel = document.getElementById('use-sentence-level').checked;
1574
  const includeMetricsSummary = document.getElementById('include-metrics-summary').checked;
 
1575
  const formData = new FormData();
1576
  formData.append('file', file);
1577
  if (domain) formData.append('domain', domain);
@@ -1579,16 +1652,19 @@ async function analyzeFile(file) {
1579
  formData.append('use_sentence_level', useSentenceLevel.toString());
1580
  formData.append('include_metrics_summary', includeMetricsSummary.toString());
1581
  formData.append('skip_expensive_metrics', 'false');
 
1582
  const response = await fetch(`${API_BASE}/api/analyze/file`, {
1583
  method: 'POST',
1584
  body: formData
1585
  });
 
1586
  if (!response.ok) {
1587
  const error = await response.json();
1588
  throw new Error(error.error || 'File analysis failed');
1589
  }
1590
  return await response.json();
1591
  }
 
1592
  function showLoading() {
1593
  document.getElementById('summary-report').innerHTML = `
1594
  <div class="loading">
@@ -1600,6 +1676,7 @@ function showLoading() {
1600
  </div>
1601
  `;
1602
  }
 
1603
  function showError(message) {
1604
  document.getElementById('summary-report').innerHTML = `
1605
  <div class="empty-state">
@@ -1609,6 +1686,7 @@ function showError(message) {
1609
  </div>
1610
  `;
1611
  }
 
1612
  function displayResults(data) {
1613
  console.log('Response data:', data);
1614
  // Handle different response structures
@@ -1618,13 +1696,16 @@ function displayResults(data) {
1618
  console.error('Full response:', data);
1619
  return;
1620
  }
 
1621
  // Extract data based on your actual API structure
1622
  const ensemble = detection.ensemble_result || detection.ensemble;
1623
  const prediction = detection.prediction || {};
1624
  const metrics = detection.metric_results || detection.metrics;
1625
  const analysis = detection.analysis || {};
 
1626
  // Display Summary with enhanced reasoning
1627
  displaySummary(ensemble, prediction, analysis, data.attribution, data.reasoning);
 
1628
  // Display Highlighted Text with enhanced features
1629
  if (data.highlighted_html) {
1630
  displayHighlightedText(data.highlighted_html);
@@ -1635,6 +1716,7 @@ function displayResults(data) {
1635
  </div>
1636
  `;
1637
  }
 
1638
  // Display Metrics with carousel
1639
  if (metrics && Object.keys(metrics).length > 0) {
1640
  displayMetricsCarousel(metrics, analysis, ensemble);
@@ -1646,10 +1728,48 @@ function displayResults(data) {
1646
  `;
1647
  }
1648
  }
 
1649
  function displaySummary(ensemble, prediction, analysis, attribution, reasoning) {
1650
- // Use ensemble values from your actual API response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1651
  const aiProbability = ensemble.ai_probability !== undefined ?
1652
  (ensemble.ai_probability * 100).toFixed(0) : '0';
 
 
 
 
 
 
 
1653
  const verdict = ensemble.final_verdict || 'Unknown';
1654
  const confidence = ensemble.overall_confidence !== undefined ?
1655
  (ensemble.overall_confidence * 100).toFixed(1) : '0';
@@ -1657,81 +1777,289 @@ function displaySummary(ensemble, prediction, analysis, attribution, reasoning)
1657
  const isAI = verdict.toLowerCase().includes('ai');
1658
  const gaugeColor = isAI ? 'var(--danger)' : 'var(--success)';
1659
  const gaugeDegree = aiProbability * 3.6;
1660
- const confidenceLevel = parseFloat(confidence) >= 70 ? 'HIGH' :
1661
- parseFloat(confidence) >= 40 ? 'MEDIUM' : 'LOW';
1662
- const confidenceClass = confidenceLevel === 'HIGH' ? 'confidence-high' :
1663
- confidenceLevel === 'MEDIUM' ? 'confidence-medium' : 'confidence-low';
1664
- let attributionHTML = '';
1665
- if (attribution && attribution.predicted_model) {
1666
- const modelName = attribution.predicted_model.replace(/_/g, ' ').replace(/-/g, ' ').toUpperCase();
1667
- const modelConf = attribution.confidence ?
1668
- (attribution.confidence * 100).toFixed(1) : 'N/A';
1669
- let topModels = '';
1670
- if (attribution.model_probabilities) {
1671
- const sorted = Object.entries(attribution.model_probabilities)
1672
- .sort((a, b) => b[1] - a[1])
1673
- .slice(0, 3);
1674
- topModels = sorted.map(([model, prob]) =>
1675
- `<div class="model-match" style="margin-top: 0.5rem;">
1676
- <span class="model-name">${model.replace(/_/g, ' ').replace(/-/g, ' ').toUpperCase()}</span>
1677
- <span class="model-confidence">${(prob * 100).toFixed(1)}%</span>
1678
- </div>`
1679
- ).join('');
1680
- }
1681
- attributionHTML = `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1682
  <div class="attribution-section">
1683
  <div class="attribution-title">🤖 AI Model Attribution</div>
1684
- ${topModels}
1685
- ${attribution.reasoning && attribution.reasoning.length > 0 ?
1686
- `<p style="color: var(--text-secondary); margin-top: 1rem; font-size: 0.9rem;">${attribution.reasoning[0]}</p>` : ''}
 
 
1687
  </div>
1688
  `;
1689
  }
1690
- document.getElementById('summary-report').innerHTML = `
1691
- <div class="result-summary">
1692
- <div class="gauge-container">
1693
- <div class="gauge-circle" style="--gauge-color: ${gaugeColor}; --gauge-degree: ${gaugeDegree}deg;">
1694
- <div class="gauge-inner">
1695
- <div class="gauge-value">${aiProbability}%</div>
1696
- <div class="gauge-label">AI Probability</div>
1697
- </div>
1698
- </div>
 
 
 
 
 
 
 
 
 
 
 
1699
  </div>
1700
- <div class="result-info-grid">
1701
- <div class="info-card">
1702
- <div class="info-label">Verdict</div>
1703
- <div class="info-value" style="font-size: 1.2rem;">${verdict}</div>
1704
- </div>
1705
- <div class="info-card">
1706
- <div class="info-label">Confidence Level</div>
1707
- <div class="info-value">
1708
- <span class="confidence-badge ${confidenceClass}">${confidence}%</span>
1709
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1710
  </div>
1711
- <div class="info-card">
1712
- <div class="info-label">Content Domain</div>
1713
- <div class="info-value" style="font-size: 1.1rem;">${formatDomainName(domain)}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1714
  </div>
1715
  </div>
1716
- ${createEnhancedReasoningHTML(ensemble, analysis, reasoning)}
1717
- ${attributionHTML}
1718
- <div class="download-actions">
1719
- <button class="download-btn" onclick="downloadReport('json')">
1720
- 📄 Download JSON
1721
- </button>
1722
- <button class="download-btn" onclick="downloadReport('pdf')">
1723
- 📑 Download PDF Report
1724
- </button>
1725
  </div>
1726
  </div>
1727
  `;
1728
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1729
  function createEnhancedReasoningHTML(ensemble, analysis, reasoning) {
1730
- // Use actual reasoning data if available
1731
  if (reasoning && reasoning.summary) {
1732
- // Process markdown-style *text* to <strong> tags
1733
- let processedSummary = reasoning.summary;
1734
- processedSummary = processedSummary.replace(/\*([^*]+)\*/g, '<strong>$1</strong>');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1735
  return `
1736
  <div class="reasoning-box enhanced">
1737
  <div class="reasoning-header">
@@ -1745,23 +2073,41 @@ function createEnhancedReasoningHTML(ensemble, analysis, reasoning) {
1745
  <div class="verdict-text">${ensemble.final_verdict}</div>
1746
  <div class="probability">AI Probability: <span class="probability-value">${(ensemble.ai_probability * 100).toFixed(2)}%</span></div>
1747
  </div>
1748
- <div class="reasoning-text-content">
1749
- ${processedSummary}
1750
  </div>
1751
- ${reasoning.key_indicators && reasoning.key_indicators.length > 0 ? `
1752
  <div class="metrics-breakdown">
1753
- <div class="breakdown-header">Key Indicators</div>
1754
- ${reasoning.key_indicators.map(indicator => {
1755
- let processedIndicator = indicator;
1756
- processedIndicator = processedIndicator.replace(/\*([^*]+)\*/g, '<strong>$1</strong>');
1757
- return `
1758
- <div class="metric-indicator">
1759
- <div class="metric-name">${processedIndicator.split(':')[0]}</div>
1760
- <div class="metric-details">
1761
- <span class="reasoning-text-content">${processedIndicator.split(':')[1]}</span>
 
 
 
 
 
 
 
 
 
1762
  </div>
1763
- </div>
1764
- `;
 
 
 
 
 
 
 
 
 
1765
  }).join('')}
1766
  </div>
1767
  ` : ''}
@@ -1778,7 +2124,7 @@ function createEnhancedReasoningHTML(ensemble, analysis, reasoning) {
1778
  return `
1779
  <div class="reasoning-box">
1780
  <div class="reasoning-title">💡 Detection Reasoning</div>
1781
- <p class="reasoning-text">
1782
  Analysis based on 6-metric ensemble with domain-aware calibration.
1783
  The system evaluated linguistic patterns, statistical features, and semantic structures
1784
  to determine content authenticity with ${(ensemble.overall_confidence * 100).toFixed(1)}% confidence.
@@ -1786,6 +2132,58 @@ function createEnhancedReasoningHTML(ensemble, analysis, reasoning) {
1786
  </div>
1787
  `;
1788
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1789
  function displayHighlightedText(html) {
1790
  document.getElementById('highlighted-report').innerHTML = `
1791
  ${createDefaultLegend()}
@@ -1795,6 +2193,7 @@ function displayHighlightedText(html) {
1795
  ${getHighlightStyles()}
1796
  `;
1797
  }
 
1798
  function createDefaultLegend() {
1799
  return `
1800
  <div class="highlight-legend">
@@ -1833,6 +2232,7 @@ function createDefaultLegend() {
1833
  </div>
1834
  `;
1835
  }
 
1836
  function getHighlightStyles() {
1837
  return `
1838
  <style>
@@ -1888,10 +2288,12 @@ function getHighlightStyles() {
1888
  </style>
1889
  `;
1890
  }
 
1891
  function displayMetricsCarousel(metrics, analysis, ensemble) {
1892
  const metricOrder = ['structural', 'perplexity', 'entropy', 'semantic_analysis', 'linguistic', 'multi_perturbation_stability'];
1893
  const availableMetrics = metricOrder.filter(key => metrics[key]);
1894
  totalMetrics = availableMetrics.length;
 
1895
  if (totalMetrics === 0) {
1896
  document.getElementById('metrics-report').innerHTML = `
1897
  <div class="empty-state">
@@ -1900,24 +2302,39 @@ function displayMetricsCarousel(metrics, analysis, ensemble) {
1900
  `;
1901
  return;
1902
  }
 
1903
  let carouselHTML = `
1904
  <div class="metrics-carousel-container">
1905
  <div class="metrics-carousel-content">
1906
  `;
 
1907
  availableMetrics.forEach((metricKey, index) => {
1908
  const metric = metrics[metricKey];
1909
  if (!metric) return;
 
1910
  const aiProb = (metric.ai_probability * 100).toFixed(1);
1911
  const humanProb = (metric.human_probability * 100).toFixed(1);
 
1912
  const confidence = (metric.confidence * 100).toFixed(1);
1913
  const weight = ensemble.metric_contributions && ensemble.metric_contributions[metricKey] ?
1914
- (ensemble.metric_contributions[metricKey].weight * 100).toFixed(1) : '0.0';
1915
- const color = metric.ai_probability >= 0.6 ? 'var(--danger)' :
1916
- metric.ai_probability >= 0.4 ? 'var(--warning)' : 'var(--success)';
1917
- const verdictText = metric.ai_probability >= 0.6 ? 'AI' :
1918
- metric.ai_probability >= 0.4 ? 'UNCERTAIN' : 'HUMAN';
1919
- const verdictClass = verdictText === 'AI' ? 'verdict-ai' :
1920
- verdictText === 'UNCERTAIN' ? 'verdict-uncertain' : 'verdict-human';
 
 
 
 
 
 
 
 
 
 
 
1921
  carouselHTML += `
1922
  <div class="metric-slide ${index === 0 ? 'active' : ''}" data-metric-index="${index}">
1923
  <div class="metric-result-card">
@@ -1927,22 +2344,32 @@ function displayMetricsCarousel(metrics, analysis, ensemble) {
1927
  <div class="metric-description">
1928
  ${getMetricDescription(metricKey)}
1929
  </div>
1930
- <div style="display: flex; gap: 1rem; margin: 1rem 0;">
1931
- <div style="flex: 1;">
 
 
1932
  <div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.25rem;">AI</div>
1933
  <div style="background: rgba(51, 65, 85, 0.5); height: 8px; border-radius: 4px; overflow: hidden;">
1934
  <div style="background: var(--danger); height: 100%; width: ${aiProb}%; transition: width 0.5s;"></div>
1935
  </div>
1936
  <div style="font-size: 0.85rem; font-weight: 600; margin-top: 0.25rem;">${aiProb}%</div>
1937
  </div>
1938
- <div style="flex: 1;">
1939
  <div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.25rem;">Human</div>
1940
  <div style="background: rgba(51, 65, 85, 0.5); height: 8px; border-radius: 4px; overflow: hidden;">
1941
  <div style="background: var(--success); height: 100%; width: ${humanProb}%; transition: width 0.5s;"></div>
1942
  </div>
1943
  <div style="font-size: 0.85rem; font-weight: 600; margin-top: 0.25rem;">${humanProb}%</div>
1944
  </div>
 
 
 
 
 
 
 
1945
  </div>
 
1946
  <div style="display: flex; justify-content: space-between; align-items: center; margin: 0.75rem 0;">
1947
  <span class="metric-verdict ${verdictClass}">${verdictText}</span>
1948
  <span style="font-size: 0.85rem; color: var(--text-secondary);">Confidence: ${confidence}% | Weight: ${weight}%</span>
@@ -1953,6 +2380,7 @@ function displayMetricsCarousel(metrics, analysis, ensemble) {
1953
  </div>
1954
  `;
1955
  });
 
1956
  carouselHTML += `
1957
  </div>
1958
  <div class="metrics-carousel-nav">
@@ -1962,9 +2390,11 @@ function displayMetricsCarousel(metrics, analysis, ensemble) {
1962
  </div>
1963
  </div>
1964
  `;
 
1965
  document.getElementById('metrics-report').innerHTML = carouselHTML;
1966
  updateCarouselButtons();
1967
  }
 
1968
  function navigateMetrics(direction) {
1969
  const newMetricIndex = currentMetricIndex + direction;
1970
  if (newMetricIndex >= 0 && newMetricIndex < totalMetrics) {
@@ -1972,6 +2402,7 @@ function navigateMetrics(direction) {
1972
  updateMetricCarousel();
1973
  }
1974
  }
 
1975
  function updateMetricCarousel() {
1976
  const slides = document.querySelectorAll('.metric-slide');
1977
  slides.forEach((slide, index) => {
@@ -1988,6 +2419,7 @@ function updateMetricCarousel() {
1988
  positionElement.textContent = `${currentMetricIndex + 1} / ${totalMetrics}`;
1989
  }
1990
  }
 
1991
  function updateCarouselButtons() {
1992
  const prevBtn = document.querySelector('.prev-btn');
1993
  const nextBtn = document.querySelector('.next-btn');
@@ -1998,8 +2430,10 @@ function updateCarouselButtons() {
1998
  nextBtn.disabled = currentMetricIndex === totalMetrics - 1;
1999
  }
2000
  }
 
2001
  function renderMetricDetails(metricName, details) {
2002
  if (!details || Object.keys(details).length === 0) return '';
 
2003
  // Key metrics to show for each type
2004
  const importantKeys = {
2005
  'structural': ['burstiness_score', 'length_uniformity', 'avg_sentence_length', 'std_sentence_length'],
@@ -2007,29 +2441,47 @@ function renderMetricDetails(metricName, details) {
2007
  'entropy': ['token_diversity', 'sequence_unpredictability', 'char_entropy'],
2008
  'semantic_analysis': ['coherence_score', 'consistency_score', 'repetition_score'],
2009
  'linguistic': ['pos_diversity', 'syntactic_complexity', 'grammatical_consistency'],
2010
- 'multi_perturbation_stability': ['stability_score', 'curvature_score', 'likelihood_ratio']
2011
  };
 
2012
  const keysToShow = importantKeys[metricName] || Object.keys(details).slice(0, 6);
 
2013
  let detailsHTML = '<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">';
2014
  detailsHTML += '<div style="font-size: 0.9rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.75rem;">📈 Detailed Metrics:</div>';
2015
  detailsHTML += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; font-size: 0.85rem;">';
 
2016
  keysToShow.forEach(key => {
2017
  if (details[key] !== undefined && details[key] !== null) {
2018
- const value = typeof details[key] === 'number' ?
2019
- (details[key] < 1 && details[key] > 0 ? (details[key] * 100).toFixed(2) + '%' : details[key].toFixed(2)) :
2020
- details[key];
 
 
 
 
 
 
 
 
 
 
 
 
 
2021
  const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2022
  detailsHTML += `
2023
  <div style="background: rgba(15, 23, 42, 0.6); padding: 0.5rem; border-radius: 6px;">
2024
  <div style="color: var(--text-muted); font-size: 0.75rem; margin-bottom: 0.25rem;">${label}</div>
2025
- <div style="color: var(--primary); font-weight: 700;">${value}</div>
2026
  </div>
2027
  `;
2028
  }
2029
  });
 
2030
  detailsHTML += '</div></div>';
2031
  return detailsHTML;
2032
  }
 
2033
  function getMetricDescription(metricName) {
2034
  const descriptions = {
2035
  structural: 'Analyzes sentence structure, length patterns, and statistical features.',
@@ -2041,6 +2493,7 @@ function getMetricDescription(metricName) {
2041
  };
2042
  return descriptions[metricName] || 'Metric analysis complete.';
2043
  }
 
2044
  function formatMetricName(name) {
2045
  const names = {
2046
  structural: 'Structural Analysis',
@@ -2052,17 +2505,17 @@ function formatMetricName(name) {
2052
  };
2053
  return names[name] || name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
2054
  }
2055
- function formatDomainName(domain) {
2056
- return domain.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
2057
- }
2058
  async function downloadReport(format) {
2059
  if (!currentAnalysisData) {
2060
  alert('No analysis data available');
2061
  return;
2062
  }
 
2063
  try {
2064
  const analysisId = currentAnalysisData.analysis_id;
2065
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 
2066
  // For JSON, download directly from current data
2067
  if (format === 'json') {
2068
  const data = {
@@ -2077,6 +2530,7 @@ async function downloadReport(format) {
2077
  await downloadBlob(blob, filename);
2078
  return;
2079
  }
 
2080
  // Get the original text for report generation
2081
  const activeTab = document.querySelector('.input-tab.active').dataset.tab;
2082
  let textToSend = '';
@@ -2086,19 +2540,23 @@ async function downloadReport(format) {
2086
  textToSend = currentAnalysisData.detection_result?.processed_text?.text ||
2087
  'Uploaded file content - see analysis for details';
2088
  }
 
2089
  // For PDF, request from server
2090
  const formData = new FormData();
2091
  formData.append('analysis_id', analysisId);
2092
  formData.append('text', textToSend);
2093
  formData.append('formats', format);
2094
  formData.append('include_highlights', document.getElementById('enable-highlighting').checked.toString());
 
2095
  const response = await fetch(`${API_BASE}/api/report/generate`, {
2096
  method: 'POST',
2097
  body: formData
2098
  });
 
2099
  if (!response.ok) {
2100
  throw new Error('Report generation failed');
2101
  }
 
2102
  const result = await response.json();
2103
  if (result.reports && result.reports[format]) {
2104
  const filename = result.reports[format];
@@ -2117,6 +2575,7 @@ async function downloadReport(format) {
2117
  alert('Failed to download report. Please try again.');
2118
  }
2119
  }
 
2120
  async function downloadBlob(blob, filename) {
2121
  try {
2122
  const url = URL.createObjectURL(blob);
@@ -2136,6 +2595,7 @@ async function downloadBlob(blob, filename) {
2136
  alert('Download failed. Please try again.');
2137
  }
2138
  }
 
2139
  function showDownloadSuccess(filename) {
2140
  const notification = document.createElement('div');
2141
  notification.style.cssText = `
@@ -2158,6 +2618,7 @@ function showDownloadSuccess(filename) {
2158
  </div>
2159
  `;
2160
  document.body.appendChild(notification);
 
2161
  if (!document.querySelector('#download-animation')) {
2162
  const style = document.createElement('style');
2163
  style.id = 'download-animation';
@@ -2169,12 +2630,14 @@ function showDownloadSuccess(filename) {
2169
  `;
2170
  document.head.appendChild(style);
2171
  }
 
2172
  setTimeout(() => {
2173
  if (notification.parentNode) {
2174
  notification.parentNode.removeChild(notification);
2175
  }
2176
  }, 3000);
2177
  }
 
2178
  // Smooth scrolling for anchor links
2179
  document.querySelectorAll('a[href^="#"]').forEach(anchor => {
2180
  anchor.addEventListener('click', function (e) {
@@ -2188,6 +2651,7 @@ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
2188
  }
2189
  });
2190
  });
 
2191
  // Initialize - show landing page by default
2192
  showLanding();
2193
  </script>
 
273
  padding: 2rem;
274
  border: 1px solid var(--border);
275
  backdrop-filter: blur(10px);
 
276
  height: 850px;
277
  overflow: hidden;
278
  display: flex;
 
620
  color: var(--text-secondary);
621
  line-height: 1.7;
622
  }
623
+ /* Reasoning Styles */
624
  .reasoning-box.enhanced {
625
  background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.95) 100%);
626
  border: 1px solid rgba(71, 85, 105, 0.5);
 
702
  .metric-indicator {
703
  display: flex;
704
  justify-content: space-between;
705
+ align-items: left;
706
  padding: 0.75rem;
707
  margin-bottom: 0.5rem;
708
  border-radius: 8px;
 
713
  transform: translateX(4px);
714
  }
715
  .metric-name {
716
+ font-weight: 400;
717
  color: var(--text-primary);
718
  min-width: 140px;
719
  }
 
794
  font-weight: 700;
795
  color: var(--primary);
796
  }
797
+ .attribution-confidence {
798
+ margin-top: 0.75rem;
799
+ font-size: 0.85rem;
800
+ color: var(--text-secondary);
801
+ }
802
+ .attribution-uncertain {
803
+ color: var(--text-muted);
804
+ font-style: italic;
805
+ margin-top: 0.5rem;
806
+ font-size: 0.9rem;
807
+ }
808
+ .attribution-reasoning {
809
+ color: var(--text-secondary);
810
+ margin-top: 1rem;
811
+ font-size: 0.9rem;
812
+ line-height: 1.4;
813
+ }
814
  /* Download Actions */
815
  .download-actions {
816
  display: flex;
 
975
  }
976
  .metrics-carousel-content {
977
  flex: 1;
 
978
  padding: 0;
 
979
  display: flex;
980
  align-items: flex-start;
981
  justify-content: flex-start;
982
  overflow-y: auto;
 
983
  padding: 1rem;
 
984
  }
985
  .metric-slide {
986
  display: none;
987
  width: 100%;
 
988
  padding: 1rem;
989
  }
990
  .metric-slide.active {
 
1022
  color: var(--text-secondary);
1023
  font-weight: 600;
1024
  }
1025
+ /* Info Card Text Styles */
1026
+ .verdict-text {
1027
+ font-size: 1.2rem !important;
1028
+ }
1029
+ .domain-text {
1030
+ font-size: 1.1rem !important;
1031
+ }
1032
+
1033
+ .verdict-mixed {
1034
+ background: rgba(168, 85, 247, 0.2);
1035
+ color: #a855f7;
1036
+ border: 1px solid rgba(168, 85, 247, 0.3);
1037
+ }
1038
+
1039
+ /* Reasoning Bullet Points */
1040
+ .reasoning-bullet-points {
1041
+ margin: 1.5rem 0;
1042
+ line-height: 1.6;
1043
+ text-align: left;
1044
+ }
1045
+
1046
+ .bullet-point {
1047
+ margin-bottom: 0.75rem;
1048
+ padding-left: 0.5rem;
1049
+ color: var(--text-secondary);
1050
+ font-size: 0.95rem;
1051
+ text-align: left;
1052
+ }
1053
+
1054
+ .bullet-point:last-child {
1055
+ margin-bottom: 0;
1056
+ }
1057
+
1058
+ .bullet-point strong {
1059
+ color: var(--text-primary);
1060
+ }
1061
+
1062
  /* Responsive */
1063
  @media (max-width: 1200px) {
1064
  .interface-grid {
 
1270
  id="text-input"
1271
  class="text-input"
1272
  placeholder="Paste your text here for analysis...
1273
+ The more text you provide (minimum 50 characters), the more accurate the detection will be."
1274
  ></textarea>
1275
  </div>
1276
  <div id="upload-tab" class="tab-content">
 
1399
  let currentAnalysisData = null;
1400
  let currentMetricIndex = 0;
1401
  let totalMetrics = 0;
1402
+
1403
  // Navigation
1404
  function showLanding() {
1405
  document.getElementById('landing-page').style.display = 'block';
1406
  document.getElementById('analysis-interface').style.display = 'none';
1407
  window.scrollTo(0, 0);
1408
  }
1409
+
1410
  function showAnalysis() {
1411
  document.getElementById('landing-page').style.display = 'none';
1412
  document.getElementById('analysis-interface').style.display = 'block';
1413
  window.scrollTo(0, 0);
1414
  resetAnalysisInterface();
1415
  }
1416
+
1417
  // Reset analysis interface
1418
  function resetAnalysisInterface() {
1419
  // Clear text input
 
1470
  currentMetricIndex = 0;
1471
  totalMetrics = 0;
1472
  }
1473
+
1474
  // Input Tab Switching
1475
  document.querySelectorAll('.input-tab').forEach(tab => {
1476
  tab.addEventListener('click', () => {
 
1483
  document.getElementById(`${tabName}-tab`).classList.add('active');
1484
  });
1485
  });
1486
+
1487
  // Report Tab Switching
1488
  document.querySelectorAll('.report-tab').forEach(tab => {
1489
  tab.addEventListener('click', () => {
 
1496
  document.getElementById(`${reportName}-report`).classList.add('active');
1497
  });
1498
  });
1499
+
1500
  // File Upload Handling
1501
  const fileInput = document.getElementById('file-input');
1502
  const fileUploadArea = document.getElementById('file-upload-area');
1503
  const fileNameDisplay = document.getElementById('file-name-display');
1504
+
1505
  fileUploadArea.addEventListener('click', () => {
1506
  fileInput.click();
1507
  });
1508
+
1509
  fileInput.addEventListener('change', (e) => {
1510
  handleFileSelect(e.target.files[0]);
1511
  });
1512
+
1513
  // Drag and Drop
1514
  fileUploadArea.addEventListener('dragover', (e) => {
1515
  e.preventDefault();
1516
  fileUploadArea.classList.add('drag-over');
1517
  });
1518
+
1519
  fileUploadArea.addEventListener('dragleave', () => {
1520
  fileUploadArea.classList.remove('drag-over');
1521
  });
1522
+
1523
  fileUploadArea.addEventListener('drop', (e) => {
1524
  e.preventDefault();
1525
  fileUploadArea.classList.remove('drag-over');
 
1529
  handleFileSelect(file);
1530
  }
1531
  });
1532
+
1533
  function handleFileSelect(file) {
1534
  if (!file) return;
1535
  const allowedTypes = ['.txt', '.pdf', '.docx', '.doc', '.md'];
 
1548
  <span style="color: var(--text-muted);">(${formatFileSize(file.size)})</span>
1549
  `;
1550
  }
1551
+
1552
  function formatFileSize(bytes) {
1553
  if (bytes < 1024) return bytes + ' B';
1554
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
1555
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1556
  }
1557
+
1558
  // Analyze Button
1559
  document.getElementById('analyze-btn').addEventListener('click', async () => {
1560
  const activeTab = document.querySelector('.input-tab.active').dataset.tab;
1561
  const textInput = document.getElementById('text-input').value.trim();
1562
  const fileInput = document.getElementById('file-input').files[0];
1563
+
1564
  if (activeTab === 'paste' && !textInput) {
1565
  alert('Please paste some text to analyze (minimum 50 characters).');
1566
  return;
 
1573
  alert('Please select a file to upload.');
1574
  return;
1575
  }
1576
+
1577
  await performAnalysis(activeTab, textInput, fileInput);
1578
  });
1579
+
1580
  // Refresh Button - clears everything and shows empty state
1581
  document.getElementById('refresh-btn').addEventListener('click', () => {
1582
  resetAnalysisInterface();
1583
  });
1584
+
1585
  // Try Next Button - same as refresh but keeps the interface ready
1586
  document.getElementById('try-next-btn').addEventListener('click', () => {
1587
  resetAnalysisInterface();
1588
  });
1589
+
1590
  async function performAnalysis(mode, text, file) {
1591
  const analyzeBtn = document.getElementById('analyze-btn');
1592
  analyzeBtn.disabled = true;
1593
  analyzeBtn.innerHTML = '⏳ Analyzing...';
1594
  showLoading();
1595
+
1596
  try {
1597
  let response;
1598
  if (mode === 'paste') {
 
1610
  analyzeBtn.innerHTML = '🔍 Analyze Text';
1611
  }
1612
  }
1613
+
1614
  async function analyzeText(text) {
1615
  const domain = document.getElementById('domain-select').value || null;
1616
  const enableAttribution = document.getElementById('enable-attribution').checked;
1617
  const enableHighlighting = document.getElementById('enable-highlighting').checked;
1618
  const useSentenceLevel = document.getElementById('use-sentence-level').checked;
1619
  const includeMetricsSummary = document.getElementById('include-metrics-summary').checked;
1620
+
1621
  const response = await fetch(`${API_BASE}/api/analyze`, {
1622
  method: 'POST',
1623
  headers: { 'Content-Type': 'application/json' },
 
1631
  skip_expensive_metrics: false
1632
  })
1633
  });
1634
+
1635
  if (!response.ok) {
1636
  const error = await response.json();
1637
  throw new Error(error.error || 'Analysis failed');
1638
  }
1639
  return await response.json();
1640
  }
1641
+
1642
  async function analyzeFile(file) {
1643
  const domain = document.getElementById('domain-select').value || null;
1644
  const enableAttribution = document.getElementById('enable-attribution').checked;
1645
  const useSentenceLevel = document.getElementById('use-sentence-level').checked;
1646
  const includeMetricsSummary = document.getElementById('include-metrics-summary').checked;
1647
+
1648
  const formData = new FormData();
1649
  formData.append('file', file);
1650
  if (domain) formData.append('domain', domain);
 
1652
  formData.append('use_sentence_level', useSentenceLevel.toString());
1653
  formData.append('include_metrics_summary', includeMetricsSummary.toString());
1654
  formData.append('skip_expensive_metrics', 'false');
1655
+
1656
  const response = await fetch(`${API_BASE}/api/analyze/file`, {
1657
  method: 'POST',
1658
  body: formData
1659
  });
1660
+
1661
  if (!response.ok) {
1662
  const error = await response.json();
1663
  throw new Error(error.error || 'File analysis failed');
1664
  }
1665
  return await response.json();
1666
  }
1667
+
1668
  function showLoading() {
1669
  document.getElementById('summary-report').innerHTML = `
1670
  <div class="loading">
 
1676
  </div>
1677
  `;
1678
  }
1679
+
1680
  function showError(message) {
1681
  document.getElementById('summary-report').innerHTML = `
1682
  <div class="empty-state">
 
1686
  </div>
1687
  `;
1688
  }
1689
+
1690
  function displayResults(data) {
1691
  console.log('Response data:', data);
1692
  // Handle different response structures
 
1696
  console.error('Full response:', data);
1697
  return;
1698
  }
1699
+
1700
  // Extract data based on your actual API structure
1701
  const ensemble = detection.ensemble_result || detection.ensemble;
1702
  const prediction = detection.prediction || {};
1703
  const metrics = detection.metric_results || detection.metrics;
1704
  const analysis = detection.analysis || {};
1705
+
1706
  // Display Summary with enhanced reasoning
1707
  displaySummary(ensemble, prediction, analysis, data.attribution, data.reasoning);
1708
+
1709
  // Display Highlighted Text with enhanced features
1710
  if (data.highlighted_html) {
1711
  displayHighlightedText(data.highlighted_html);
 
1716
  </div>
1717
  `;
1718
  }
1719
+
1720
  // Display Metrics with carousel
1721
  if (metrics && Object.keys(metrics).length > 0) {
1722
  displayMetricsCarousel(metrics, analysis, ensemble);
 
1728
  `;
1729
  }
1730
  }
1731
+
1732
  function displaySummary(ensemble, prediction, analysis, attribution, reasoning) {
1733
+ // Extract and validate data with fallbacks
1734
+ const {
1735
+ aiProbability,
1736
+ humanProbability,
1737
+ mixedProbability,
1738
+ verdict,
1739
+ confidence,
1740
+ domain,
1741
+ isAI,
1742
+ gaugeColor,
1743
+ gaugeDegree,
1744
+ confidenceLevel,
1745
+ confidenceClass
1746
+ } = extractSummaryData(ensemble, analysis);
1747
+
1748
+ // Generate attribution HTML with proper filtering
1749
+ const attributionHTML = generateAttributionHTML(attribution);
1750
+
1751
+ document.getElementById('summary-report').innerHTML = `
1752
+ <div class="result-summary">
1753
+ ${createGaugeSection(aiProbability, humanProbability, mixedProbability, gaugeColor, gaugeDegree)}
1754
+ ${createInfoGrid(verdict, confidence, confidenceClass, domain, mixedProbability)}
1755
+ ${createEnhancedReasoningHTML(ensemble, analysis, reasoning)}
1756
+ ${attributionHTML}
1757
+ ${createDownloadActions()}
1758
+ </div>
1759
+ `;
1760
+ }
1761
+
1762
+ // Helper function to extract and validate summary data
1763
+ function extractSummaryData(ensemble, analysis) {
1764
  const aiProbability = ensemble.ai_probability !== undefined ?
1765
  (ensemble.ai_probability * 100).toFixed(0) : '0';
1766
+
1767
+ const humanProbability = ensemble.human_probability !== undefined ?
1768
+ (ensemble.human_probability * 100).toFixed(0) : '0';
1769
+
1770
+ const mixedProbability = ensemble.mixed_probability !== undefined ?
1771
+ (ensemble.mixed_probability * 100).toFixed(0) : '0';
1772
+
1773
  const verdict = ensemble.final_verdict || 'Unknown';
1774
  const confidence = ensemble.overall_confidence !== undefined ?
1775
  (ensemble.overall_confidence * 100).toFixed(1) : '0';
 
1777
  const isAI = verdict.toLowerCase().includes('ai');
1778
  const gaugeColor = isAI ? 'var(--danger)' : 'var(--success)';
1779
  const gaugeDegree = aiProbability * 3.6;
1780
+
1781
+ const confidenceLevel = getConfidenceLevel(parseFloat(confidence));
1782
+ const confidenceClass = getConfidenceClass(confidenceLevel);
1783
+
1784
+ return {
1785
+ aiProbability,
1786
+ humanProbability,
1787
+ mixedProbability,
1788
+ verdict,
1789
+ confidence,
1790
+ domain,
1791
+ isAI,
1792
+ gaugeColor,
1793
+ gaugeDegree,
1794
+ confidenceLevel,
1795
+ confidenceClass
1796
+ };
1797
+ }
1798
+
1799
+ // Helper function to determine confidence level
1800
+ function getConfidenceLevel(confidence) {
1801
+ if (confidence >= 70) return 'HIGH';
1802
+ if (confidence >= 40) return 'MEDIUM';
1803
+ return 'LOW';
1804
+ }
1805
+
1806
+ // Helper function to get confidence CSS class
1807
+ function getConfidenceClass(confidenceLevel) {
1808
+ const classMap = {
1809
+ 'HIGH': 'confidence-high',
1810
+ 'MEDIUM': 'confidence-medium',
1811
+ 'LOW': 'confidence-low'
1812
+ };
1813
+ return classMap[confidenceLevel] || 'confidence-low';
1814
+ }
1815
+
1816
+ // Helper function to generate attribution HTML with filtering
1817
+ function generateAttributionHTML(attribution) {
1818
+ if (!attribution || !attribution.predicted_model) {
1819
+ return '';
1820
+ }
1821
+
1822
+ const modelName = formatModelName(attribution.predicted_model);
1823
+ const modelConf = attribution.confidence ?
1824
+ (attribution.confidence * 100).toFixed(1) : 'N/A';
1825
+
1826
+ const topModelsHTML = generateTopModelsHTML(attribution.model_probabilities);
1827
+ const reasoningHTML = generateAttributionReasoningHTML(attribution.reasoning);
1828
+
1829
+ // Only show attribution if confidence is meaningful (>30%)
1830
+ if (attribution.confidence > 0.3) {
1831
+ return `
1832
  <div class="attribution-section">
1833
  <div class="attribution-title">🤖 AI Model Attribution</div>
1834
+ ${topModelsHTML}
1835
+ <div class="attribution-confidence">
1836
+ Attribution Confidence: <strong>${modelConf}%</strong>
1837
+ </div>
1838
+ ${reasoningHTML}
1839
  </div>
1840
  `;
1841
  }
1842
+
1843
+ return '';
1844
+ }
1845
+
1846
+ // Helper function to generate top models HTML with filtering
1847
+ function generateTopModelsHTML(modelProbabilities) {
1848
+ if (!modelProbabilities) {
1849
+ return '<div class="attribution-uncertain">Model probabilities not available</div>';
1850
+ }
1851
+
1852
+ // Filter and sort models
1853
+ const meaningfulModels = Object.entries(modelProbabilities)
1854
+ .sort((a, b) => b[1] - a[1])
1855
+ .filter(([model, prob]) => prob > 0.15) // Only show models with >15% probability
1856
+ .slice(0, 3); // Show top 3
1857
+
1858
+ if (meaningfulModels.length === 0) {
1859
+ return `
1860
+ <div class="attribution-uncertain">
1861
+ Model attribution uncertain - text patterns don't strongly match any specific AI model
1862
  </div>
1863
+ `;
1864
+ }
1865
+
1866
+ return meaningfulModels.map(([model, prob]) =>
1867
+ `<div class="model-match">
1868
+ <span class="model-name">${formatModelName(model)}</span>
1869
+ <span class="model-confidence">${(prob * 100).toFixed(1)}%</span>
1870
+ </div>`
1871
+ ).join('');
1872
+ }
1873
+
1874
+ // Helper function to format model names
1875
+ function formatModelName(modelName) {
1876
+ return modelName.replace(/_/g, ' ').replace(/-/g, ' ').toUpperCase();
1877
+ }
1878
+
1879
+ // Helper function to generate attribution reasoning HTML
1880
+ function generateAttributionReasoningHTML(reasoning) {
1881
+ if (!reasoning || !Array.isArray(reasoning) || reasoning.length === 0) {
1882
+ return '';
1883
+ }
1884
+
1885
+ return `
1886
+ <div class="attribution-reasoning">
1887
+ ${reasoning[0]}
1888
+ </div>
1889
+ `;
1890
+ }
1891
+
1892
+ // Helper function to create single-progress gauge section
1893
+ function createGaugeSection(aiProbability, humanProbability, mixedProbability, gaugeColor, gaugeDegree) {
1894
+ // Determine which probability is highest
1895
+ let maxValue, maxColor, maxLabel;
1896
+
1897
+ if (aiProbability >= humanProbability && aiProbability >= mixedProbability) {
1898
+ maxValue = aiProbability;
1899
+ maxColor = 'var(--danger)';
1900
+ maxLabel = 'AI Probability';
1901
+ } else if (humanProbability >= aiProbability && humanProbability >= mixedProbability) {
1902
+ maxValue = humanProbability;
1903
+ maxColor = 'var(--success)';
1904
+ maxLabel = 'Human Probability';
1905
+ } else {
1906
+ maxValue = mixedProbability;
1907
+ maxColor = 'var(--primary)';
1908
+ maxLabel = 'Mixed Probability';
1909
+ }
1910
+
1911
+ // Calculate the degree for the progress (maxValue% of 360 degrees)
1912
+ const progressDegree = (maxValue / 100) * 360;
1913
+
1914
+ return `
1915
+ <div class="gauge-container">
1916
+ <div class="single-progress-gauge" style="
1917
+ background: conic-gradient(
1918
+ ${maxColor} 0deg,
1919
+ ${maxColor} ${progressDegree}deg,
1920
+ rgba(51, 65, 85, 0.3) ${progressDegree}deg,
1921
+ rgba(51, 65, 85, 0.3) 360deg
1922
+ );
1923
+ ">
1924
+ <div class="gauge-inner">
1925
+ <div class="gauge-value" style="color: ${maxColor}">${maxValue}%</div>
1926
+ <div class="gauge-label">${maxLabel}</div>
1927
  </div>
1928
+ </div>
1929
+ </div>
1930
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin: 1.5rem 0;">
1931
+ <div style="text-align: center; padding: 1rem; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.3);">
1932
+ <div style="font-size: 0.85rem; color: var(--danger); margin-bottom: 0.25rem; font-weight: 600;">AI</div>
1933
+ <div style="font-size: 1.4rem; font-weight: 700; color: var(--danger);">${aiProbability}%</div>
1934
+ </div>
1935
+ <div style="text-align: center; padding: 1rem; background: rgba(16, 185, 129, 0.1); border-radius: 8px; border: 1px solid rgba(16, 185, 129, 0.3);">
1936
+ <div style="font-size: 0.85rem; color: var(--success); margin-bottom: 0.25rem; font-weight: 600;">Human</div>
1937
+ <div style="font-size: 1.4rem; font-weight: 700; color: var(--success);">${humanProbability}%</div>
1938
+ </div>
1939
+ <div style="text-align: center; padding: 1rem; background: rgba(6, 182, 212, 0.1); border-radius: 8px; border: 1px solid rgba(6, 182, 212, 0.3);">
1940
+ <div style="font-size: 0.85rem; color: var(--primary); margin-bottom: 0.25rem; font-weight: 600;">Mixed</div>
1941
+ <div style="font-size: 1.4rem; font-weight: 700; color: var(--primary);">${mixedProbability}%</div>
1942
+ </div>
1943
+ </div>
1944
+ <style>
1945
+ .single-progress-gauge {
1946
+ width: 220px;
1947
+ height: 220px;
1948
+ margin: 0 auto 2rem;
1949
+ position: relative;
1950
+ border-radius: 50%;
1951
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
1952
+ }
1953
+
1954
+ .gauge-inner {
1955
+ position: absolute;
1956
+ width: 170px;
1957
+ height: 170px;
1958
+ background: var(--bg-panel);
1959
+ border-radius: 50%;
1960
+ top: 50%;
1961
+ left: 50%;
1962
+ transform: translate(-50%, -50%);
1963
+ display: flex;
1964
+ flex-direction: column;
1965
+ align-items: center;
1966
+ justify-content: center;
1967
+ }
1968
+
1969
+ .gauge-value {
1970
+ font-size: 3rem;
1971
+ font-weight: 800;
1972
+ }
1973
+
1974
+ .gauge-label {
1975
+ font-size: 0.9rem;
1976
+ color: var(--text-secondary);
1977
+ margin-top: 0.25rem;
1978
+ }
1979
+ </style>
1980
+ `;
1981
+ }
1982
+
1983
+
1984
+ // Helper function to create info grid
1985
+ function createInfoGrid(verdict, confidence, confidenceClass, domain, mixedProbability) {
1986
+ const mixedContentInfo = mixedProbability > 10 ?
1987
+ `<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--primary);">
1988
+ 🔀 ${mixedProbability}% Mixed Content Detected
1989
+ </div>` : '';
1990
+
1991
+ return `
1992
+ <div class="result-info-grid">
1993
+ <div class="info-card">
1994
+ <div class="info-label">Verdict</div>
1995
+ <div class="info-value verdict-text">${verdict}</div>
1996
+ ${mixedContentInfo}
1997
+ </div>
1998
+ <div class="info-card">
1999
+ <div class="info-label">Confidence Level</div>
2000
+ <div class="info-value">
2001
+ <span class="confidence-badge ${confidenceClass}">${confidence}%</span>
2002
  </div>
2003
  </div>
2004
+ <div class="info-card">
2005
+ <div class="info-label">Content Domain</div>
2006
+ <div class="info-value domain-text">${formatDomainName(domain)}</div>
 
 
 
 
 
 
2007
  </div>
2008
  </div>
2009
  `;
2010
  }
2011
+
2012
+ // Helper function to create download actions
2013
+ function createDownloadActions() {
2014
+ return `
2015
+ <div class="download-actions">
2016
+ <button class="download-btn" onclick="downloadReport('json')">
2017
+ 📄 Download JSON
2018
+ </button>
2019
+ <button class="download-btn" onclick="downloadReport('pdf')">
2020
+ 📑 Download PDF Report
2021
+ </button>
2022
+ </div>
2023
+ `;
2024
+ }
2025
+
2026
+ // Helper function to format domain names
2027
+ function formatDomainName(domain) {
2028
+ return domain.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
2029
+ }
2030
+
2031
  function createEnhancedReasoningHTML(ensemble, analysis, reasoning) {
2032
+ // Use reasoning data if available
2033
  if (reasoning && reasoning.summary) {
2034
+ // Process the summary into bullet points
2035
+ const bulletPoints = formatSummaryAsBulletPoints(reasoning.summary, ensemble, analysis);
2036
+
2037
+ // Process key indicators with markdown formatting
2038
+ let processedIndicators = [];
2039
+ if (reasoning.key_indicators && reasoning.key_indicators.length > 0) {
2040
+ processedIndicators = reasoning.key_indicators.map(indicator => {
2041
+ let processedIndicator = indicator;
2042
+
2043
+ // Remove HTML entities
2044
+ processedIndicator = processedIndicator.replace(/&ast;/g, '*')
2045
+ .replace(/&#42;/g, '*');
2046
+
2047
+ // Process bold formatting
2048
+ processedIndicator = processedIndicator.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
2049
+ .replace(/\*([^*]+)\*/g, '<strong>$1</strong>');
2050
+
2051
+ // Clean up remaining asterisks
2052
+ processedIndicator = processedIndicator.replace(/\*\*/g, '')
2053
+ .replace(/\*(?![^<]*>)/g, '');
2054
+
2055
+ // Replace underscores with spaces
2056
+ processedIndicator = processedIndicator.replace(/_/g, ' ');
2057
+
2058
+
2059
+ return processedIndicator;
2060
+ });
2061
+ }
2062
+
2063
  return `
2064
  <div class="reasoning-box enhanced">
2065
  <div class="reasoning-header">
 
2073
  <div class="verdict-text">${ensemble.final_verdict}</div>
2074
  <div class="probability">AI Probability: <span class="probability-value">${(ensemble.ai_probability * 100).toFixed(2)}%</span></div>
2075
  </div>
2076
+ <div class="reasoning-bullet-points">
2077
+ ${bulletPoints}
2078
  </div>
2079
+ ${processedIndicators.length > 0 ? `
2080
  <div class="metrics-breakdown">
2081
+ <div class="breakdown-header" style="text-align: center; font-weight: 700; color: var(--text-secondary); margin-bottom: 1rem;">
2082
+ KEY INDICATORS
2083
+ </div>
2084
+ ${processedIndicators.map(indicator => {
2085
+ // Split indicator into metric name and sub-metric details
2086
+ const colonIndex = indicator.indexOf(':');
2087
+ if (colonIndex !== -1) {
2088
+ const metricName = indicator.substring(0, colonIndex).trim();
2089
+ const metricDetails = indicator.substring(colonIndex + 1).trim();
2090
+
2091
+ return `
2092
+ <div style="margin-bottom: 1rem; text-align: left;">
2093
+ <div style="font-weight: 700; color: #fff; text-align: center; margin-bottom: 0.5rem; font-size: 1rem;">
2094
+ ${metricName}
2095
+ </div>
2096
+ <div style="color: var(--text-secondary); font-size: 0.9rem; line-height: 1.4; text-align: left;">
2097
+ ${metricDetails}
2098
+ </div>
2099
  </div>
2100
+ `;
2101
+ } else {
2102
+ // If no colon, treat as general indicator
2103
+ return `
2104
+ <div style="margin-bottom: 1rem; text-align: left;">
2105
+ <div style="color: var(--text-secondary); font-size: 0.9rem; line-height: 1.4;">
2106
+ ${indicator}
2107
+ </div>
2108
+ </div>
2109
+ `;
2110
+ }
2111
  }).join('')}
2112
  </div>
2113
  ` : ''}
 
2124
  return `
2125
  <div class="reasoning-box">
2126
  <div class="reasoning-title">💡 Detection Reasoning</div>
2127
+ <p class="reasoning-text" style="text-align: left;">
2128
  Analysis based on 6-metric ensemble with domain-aware calibration.
2129
  The system evaluated linguistic patterns, statistical features, and semantic structures
2130
  to determine content authenticity with ${(ensemble.overall_confidence * 100).toFixed(1)}% confidence.
 
2132
  </div>
2133
  `;
2134
  }
2135
+
2136
+ // Helper function to format summary as bullet points
2137
+ function formatSummaryAsBulletPoints(summary, ensemble, analysis) {
2138
+ let processedSummary = summary;
2139
+
2140
+ // Remove any existing HTML entities for asterisks first
2141
+ processedSummary = processedSummary.replace(/&ast;/g, '*')
2142
+ .replace(/&#42;/g, '*');
2143
+
2144
+ // Process markdown bold formatting
2145
+ processedSummary = processedSummary.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
2146
+ .replace(/\*([^*]+)\*/g, '<strong>$1</strong>');
2147
+
2148
+ // Final cleanup: remove any remaining standalone asterisks that weren't processed
2149
+ processedSummary = processedSummary.replace(/\*\*/g, '')
2150
+ .replace(/\*(?![^<]*>)/g, '');
2151
+
2152
+ // Split the summary into sentences/phrases for bullet points
2153
+ const sentences = processedSummary.split(/\.\s+/);
2154
+
2155
+ // Create bullet points from key information
2156
+ const bulletPoints = [];
2157
+
2158
+ // Add confidence level as first bullet
2159
+ const confidenceLevel = ensemble.overall_confidence >= 0.7 ? 'High Confidence' :
2160
+ ensemble.overall_confidence >= 0.4 ? 'Medium Confidence' : 'Low Confidence';
2161
+ bulletPoints.push(`<div class="bullet-point">• ${confidenceLevel}</div>`);
2162
+
2163
+ // Add verdict as second bullet
2164
+ bulletPoints.push(`<div class="bullet-point">• ${ensemble.final_verdict}</div>`);
2165
+
2166
+ // Add AI probability as third bullet
2167
+ bulletPoints.push(`<div class="bullet-point">• AI Probability: ${(ensemble.ai_probability * 100).toFixed(2)}%</div>`);
2168
+
2169
+ // Add the main analysis sentences as individual bullets
2170
+ sentences.forEach(sentence => {
2171
+ if (sentence.trim() &&
2172
+ !sentence.includes('confidence') &&
2173
+ !sentence.includes(ensemble.final_verdict) &&
2174
+ !sentence.includes('AI probability')) {
2175
+ // Clean up the sentence and add as bullet
2176
+ let cleanSentence = sentence.trim();
2177
+ if (!cleanSentence.endsWith('.')) {
2178
+ cleanSentence += '.';
2179
+ }
2180
+ bulletPoints.push(`<div class="bullet-point">• ${cleanSentence}</div>`);
2181
+ }
2182
+ });
2183
+
2184
+ return bulletPoints.join('');
2185
+ }
2186
+
2187
  function displayHighlightedText(html) {
2188
  document.getElementById('highlighted-report').innerHTML = `
2189
  ${createDefaultLegend()}
 
2193
  ${getHighlightStyles()}
2194
  `;
2195
  }
2196
+
2197
  function createDefaultLegend() {
2198
  return `
2199
  <div class="highlight-legend">
 
2232
  </div>
2233
  `;
2234
  }
2235
+
2236
  function getHighlightStyles() {
2237
  return `
2238
  <style>
 
2288
  </style>
2289
  `;
2290
  }
2291
+
2292
  function displayMetricsCarousel(metrics, analysis, ensemble) {
2293
  const metricOrder = ['structural', 'perplexity', 'entropy', 'semantic_analysis', 'linguistic', 'multi_perturbation_stability'];
2294
  const availableMetrics = metricOrder.filter(key => metrics[key]);
2295
  totalMetrics = availableMetrics.length;
2296
+
2297
  if (totalMetrics === 0) {
2298
  document.getElementById('metrics-report').innerHTML = `
2299
  <div class="empty-state">
 
2302
  `;
2303
  return;
2304
  }
2305
+
2306
  let carouselHTML = `
2307
  <div class="metrics-carousel-container">
2308
  <div class="metrics-carousel-content">
2309
  `;
2310
+
2311
  availableMetrics.forEach((metricKey, index) => {
2312
  const metric = metrics[metricKey];
2313
  if (!metric) return;
2314
+
2315
  const aiProb = (metric.ai_probability * 100).toFixed(1);
2316
  const humanProb = (metric.human_probability * 100).toFixed(1);
2317
+ const mixedProb = (metric.mixed_probability * 100).toFixed(1);
2318
  const confidence = (metric.confidence * 100).toFixed(1);
2319
  const weight = ensemble.metric_contributions && ensemble.metric_contributions[metricKey] ?
2320
+ (ensemble.metric_contributions[metricKey].weight * 100).toFixed(1) : '0.0';
2321
+
2322
+ // Determine verdict based on probabilities
2323
+ let verdictText, verdictClass;
2324
+ if (metric.mixed_probability > 0.3) {
2325
+ verdictText = 'MIXED';
2326
+ verdictClass = 'verdict-mixed';
2327
+ } else if (metric.ai_probability >= 0.6) {
2328
+ verdictText = 'AI';
2329
+ verdictClass = 'verdict-ai';
2330
+ } else if (metric.ai_probability >= 0.4) {
2331
+ verdictText = 'UNCERTAIN';
2332
+ verdictClass = 'verdict-uncertain';
2333
+ } else {
2334
+ verdictText = 'HUMAN';
2335
+ verdictClass = 'verdict-human';
2336
+ }
2337
+
2338
  carouselHTML += `
2339
  <div class="metric-slide ${index === 0 ? 'active' : ''}" data-metric-index="${index}">
2340
  <div class="metric-result-card">
 
2344
  <div class="metric-description">
2345
  ${getMetricDescription(metricKey)}
2346
  </div>
2347
+
2348
+ <!-- Enhanced Probability Display with Mixed -->
2349
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin: 1rem 0;">
2350
+ <div style="text-align: center;">
2351
  <div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.25rem;">AI</div>
2352
  <div style="background: rgba(51, 65, 85, 0.5); height: 8px; border-radius: 4px; overflow: hidden;">
2353
  <div style="background: var(--danger); height: 100%; width: ${aiProb}%; transition: width 0.5s;"></div>
2354
  </div>
2355
  <div style="font-size: 0.85rem; font-weight: 600; margin-top: 0.25rem;">${aiProb}%</div>
2356
  </div>
2357
+ <div style="text-align: center;">
2358
  <div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.25rem;">Human</div>
2359
  <div style="background: rgba(51, 65, 85, 0.5); height: 8px; border-radius: 4px; overflow: hidden;">
2360
  <div style="background: var(--success); height: 100%; width: ${humanProb}%; transition: width 0.5s;"></div>
2361
  </div>
2362
  <div style="font-size: 0.85rem; font-weight: 600; margin-top: 0.25rem;">${humanProb}%</div>
2363
  </div>
2364
+ <div style="text-align: center;">
2365
+ <div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.25rem;">Mixed</div>
2366
+ <div style="background: rgba(51, 65, 85, 0.5); height: 8px; border-radius: 4px; overflow: hidden;">
2367
+ <div style="background: var(--primary); height: 100%; width: ${mixedProb}%; transition: width 0.5s;"></div>
2368
+ </div>
2369
+ <div style="font-size: 0.85rem; font-weight: 600; margin-top: 0.25rem;">${mixedProb}%</div>
2370
+ </div>
2371
  </div>
2372
+
2373
  <div style="display: flex; justify-content: space-between; align-items: center; margin: 0.75rem 0;">
2374
  <span class="metric-verdict ${verdictClass}">${verdictText}</span>
2375
  <span style="font-size: 0.85rem; color: var(--text-secondary);">Confidence: ${confidence}% | Weight: ${weight}%</span>
 
2380
  </div>
2381
  `;
2382
  });
2383
+
2384
  carouselHTML += `
2385
  </div>
2386
  <div class="metrics-carousel-nav">
 
2390
  </div>
2391
  </div>
2392
  `;
2393
+
2394
  document.getElementById('metrics-report').innerHTML = carouselHTML;
2395
  updateCarouselButtons();
2396
  }
2397
+
2398
  function navigateMetrics(direction) {
2399
  const newMetricIndex = currentMetricIndex + direction;
2400
  if (newMetricIndex >= 0 && newMetricIndex < totalMetrics) {
 
2402
  updateMetricCarousel();
2403
  }
2404
  }
2405
+
2406
  function updateMetricCarousel() {
2407
  const slides = document.querySelectorAll('.metric-slide');
2408
  slides.forEach((slide, index) => {
 
2419
  positionElement.textContent = `${currentMetricIndex + 1} / ${totalMetrics}`;
2420
  }
2421
  }
2422
+
2423
  function updateCarouselButtons() {
2424
  const prevBtn = document.querySelector('.prev-btn');
2425
  const nextBtn = document.querySelector('.next-btn');
 
2430
  nextBtn.disabled = currentMetricIndex === totalMetrics - 1;
2431
  }
2432
  }
2433
+
2434
  function renderMetricDetails(metricName, details) {
2435
  if (!details || Object.keys(details).length === 0) return '';
2436
+
2437
  // Key metrics to show for each type
2438
  const importantKeys = {
2439
  'structural': ['burstiness_score', 'length_uniformity', 'avg_sentence_length', 'std_sentence_length'],
 
2441
  'entropy': ['token_diversity', 'sequence_unpredictability', 'char_entropy'],
2442
  'semantic_analysis': ['coherence_score', 'consistency_score', 'repetition_score'],
2443
  'linguistic': ['pos_diversity', 'syntactic_complexity', 'grammatical_consistency'],
2444
+ 'multi_perturbation_stability': ['stability_score', 'curvature_score', 'likelihood_ratio', 'perturbation_variance', 'mixed_probability']
2445
  };
2446
+
2447
  const keysToShow = importantKeys[metricName] || Object.keys(details).slice(0, 6);
2448
+
2449
  let detailsHTML = '<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">';
2450
  detailsHTML += '<div style="font-size: 0.9rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.75rem;">📈 Detailed Metrics:</div>';
2451
  detailsHTML += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; font-size: 0.85rem;">';
2452
+
2453
  keysToShow.forEach(key => {
2454
  if (details[key] !== undefined && details[key] !== null) {
2455
+ let value = details[key];
2456
+ let displayValue;
2457
+
2458
+ // Format values appropriately
2459
+ if (typeof value === 'number') {
2460
+ if (key.includes('score') || key.includes('ratio') || key.includes('probability')) {
2461
+ displayValue = (value * 100).toFixed(2) + '%';
2462
+ } else if (value < 1 && value > 0) {
2463
+ displayValue = value.toFixed(4);
2464
+ } else {
2465
+ displayValue = value.toFixed(2);
2466
+ }
2467
+ } else {
2468
+ displayValue = value;
2469
+ }
2470
+
2471
  const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2472
  detailsHTML += `
2473
  <div style="background: rgba(15, 23, 42, 0.6); padding: 0.5rem; border-radius: 6px;">
2474
  <div style="color: var(--text-muted); font-size: 0.75rem; margin-bottom: 0.25rem;">${label}</div>
2475
+ <div style="color: var(--primary); font-weight: 700;">${displayValue}</div>
2476
  </div>
2477
  `;
2478
  }
2479
  });
2480
+
2481
  detailsHTML += '</div></div>';
2482
  return detailsHTML;
2483
  }
2484
+
2485
  function getMetricDescription(metricName) {
2486
  const descriptions = {
2487
  structural: 'Analyzes sentence structure, length patterns, and statistical features.',
 
2493
  };
2494
  return descriptions[metricName] || 'Metric analysis complete.';
2495
  }
2496
+
2497
  function formatMetricName(name) {
2498
  const names = {
2499
  structural: 'Structural Analysis',
 
2505
  };
2506
  return names[name] || name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
2507
  }
2508
+
 
 
2509
  async function downloadReport(format) {
2510
  if (!currentAnalysisData) {
2511
  alert('No analysis data available');
2512
  return;
2513
  }
2514
+
2515
  try {
2516
  const analysisId = currentAnalysisData.analysis_id;
2517
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
2518
+
2519
  // For JSON, download directly from current data
2520
  if (format === 'json') {
2521
  const data = {
 
2530
  await downloadBlob(blob, filename);
2531
  return;
2532
  }
2533
+
2534
  // Get the original text for report generation
2535
  const activeTab = document.querySelector('.input-tab.active').dataset.tab;
2536
  let textToSend = '';
 
2540
  textToSend = currentAnalysisData.detection_result?.processed_text?.text ||
2541
  'Uploaded file content - see analysis for details';
2542
  }
2543
+
2544
  // For PDF, request from server
2545
  const formData = new FormData();
2546
  formData.append('analysis_id', analysisId);
2547
  formData.append('text', textToSend);
2548
  formData.append('formats', format);
2549
  formData.append('include_highlights', document.getElementById('enable-highlighting').checked.toString());
2550
+
2551
  const response = await fetch(`${API_BASE}/api/report/generate`, {
2552
  method: 'POST',
2553
  body: formData
2554
  });
2555
+
2556
  if (!response.ok) {
2557
  throw new Error('Report generation failed');
2558
  }
2559
+
2560
  const result = await response.json();
2561
  if (result.reports && result.reports[format]) {
2562
  const filename = result.reports[format];
 
2575
  alert('Failed to download report. Please try again.');
2576
  }
2577
  }
2578
+
2579
  async function downloadBlob(blob, filename) {
2580
  try {
2581
  const url = URL.createObjectURL(blob);
 
2595
  alert('Download failed. Please try again.');
2596
  }
2597
  }
2598
+
2599
  function showDownloadSuccess(filename) {
2600
  const notification = document.createElement('div');
2601
  notification.style.cssText = `
 
2618
  </div>
2619
  `;
2620
  document.body.appendChild(notification);
2621
+
2622
  if (!document.querySelector('#download-animation')) {
2623
  const style = document.createElement('style');
2624
  style.id = 'download-animation';
 
2630
  `;
2631
  document.head.appendChild(style);
2632
  }
2633
+
2634
  setTimeout(() => {
2635
  if (notification.parentNode) {
2636
  notification.parentNode.removeChild(notification);
2637
  }
2638
  }, 3000);
2639
  }
2640
+
2641
  // Smooth scrolling for anchor links
2642
  document.querySelectorAll('a[href^="#"]').forEach(anchor => {
2643
  anchor.addEventListener('click', function (e) {
 
2651
  }
2652
  });
2653
  });
2654
+
2655
  // Initialize - show landing page by default
2656
  showLanding();
2657
  </script>