maxime-antoine-dev commited on
Commit
df0ce09
·
1 Parent(s): 406b25f

added technical confidence

Browse files
Files changed (4) hide show
  1. README.md +47 -0
  2. main.py +246 -855
  3. requirements.txt +1 -0
  4. utils.py +171 -0
README.md CHANGED
@@ -10,3 +10,50 @@ short_description: API for fades LLM
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
13
+
14
+ ## Build
15
+ ```
16
+ docker build -t fades-api .
17
+ ```
18
+
19
+ ## Start
20
+ ```
21
+ docker run --rm -it -p 7860:7860 -v "${PWD}\data:/data" fades-api
22
+ ```
23
+
24
+ ## Switch to another version of the model
25
+
26
+ Quantized model : 4Go, ideal for CPU
27
+ ```python
28
+ from huggingface_hub import hf_hub_download
29
+
30
+ REPO_ID = "maxime-antoine-dev/maxime-antoine-dev/fades-mistral-v02-gguf
31
+ REPO_TYPE = "model"
32
+
33
+ # v1
34
+ p1 = hf_hub_download(repo_id=REPO_ID, filename=HF_FILENAME, revision="v1")
35
+ print("v1 path:", p1)
36
+
37
+ # v2
38
+ p2 = hf_hub_download(repo_id=REPO_ID, filename=HF_FILENAME, revision="v2")
39
+ print("v2 path:", p2)
40
+ ```
41
+
42
+
43
+
44
+ Full Model 13Go : ideal for GPU
45
+ ```python
46
+ from huggingface_hub import hf_hub_download
47
+
48
+ REPO_ID = "maxime-antoine-dev/maxime-antoine-dev/fades
49
+ REPO_TYPE = "model"
50
+
51
+ # v1
52
+ p1 = hf_hub_download(repo_id=REPO_ID, filename=HF_FILENAME, revision="v1")
53
+ print("v1 path:", p1)
54
+
55
+ # v2
56
+ p2 = hf_hub_download(repo_id=REPO_ID, filename=HF_FILENAME, revision="v2")
57
+ print("v2 path:", p2)
58
+ ```
59
+
main.py CHANGED
@@ -1,56 +1,34 @@
1
- # main.py
2
  import os
3
  import json
4
  import time
5
- import uuid
6
  import asyncio
7
  import re
8
- from typing import Any, Dict, Optional, List, Tuple
9
-
10
- from fastapi import FastAPI
11
  from fastapi.middleware.cors import CORSMiddleware
12
- from pydantic import BaseModel, Field
 
 
 
13
  from huggingface_hub import hf_hub_download
14
  from llama_cpp import Llama
15
 
 
 
 
 
 
16
 
17
- # ============================
18
- # Config (model)
19
- # ============================
20
- GGUF_REPO_ID = os.getenv("GGUF_REPO_ID", "maxime-antoine-dev/fades-mistral-v02-gguf")
21
- GGUF_FILENAME = os.getenv("GGUF_FILENAME", "mistral_v02_fades.Q4_K_M.gguf")
22
-
23
- # Model load params (fixed once at startup)
24
- N_CTX = int(os.getenv("N_CTX", "1536"))
25
- CPU_COUNT = os.cpu_count() or 4
26
- N_THREADS = int(os.getenv("N_THREADS", str(min(8, max(1, CPU_COUNT - 1)))))
27
- N_BATCH = int(os.getenv("N_BATCH", "256"))
28
-
29
- # Default generation params ("normal")
30
- MAX_NEW_TOKENS_DEFAULT = int(os.getenv("MAX_NEW_TOKENS", "180"))
31
- TEMPERATURE_DEFAULT = float(os.getenv("TEMPERATURE", "0.0"))
32
- TOP_P_DEFAULT = float(os.getenv("TOP_P", "0.95"))
33
-
34
- # "Light" generation params
35
- LIGHT_MAX_NEW_TOKENS = int(os.getenv("LIGHT_MAX_NEW_TOKENS", "60"))
36
- LIGHT_TEMPERATURE = float(os.getenv("LIGHT_TEMPERATURE", "0.0"))
37
- LIGHT_TOP_P = float(os.getenv("LIGHT_TOP_P", "0.9"))
38
-
39
- # "Light" runtime knobs
40
- LIGHT_N_BATCH = int(os.getenv("LIGHT_N_BATCH", "64"))
41
-
42
- # Anti-loop defaults
43
- REPEAT_PENALTY_DEFAULT = float(os.getenv("REPEAT_PENALTY", "1.15"))
44
 
45
- # Cache only SUCCESSFUL generations (TTL)
46
- CACHE_TTL_S = int(os.getenv("CACHE_TTL_S", "300")) # 5 minutes
47
- CACHE_MAX_ITEMS = int(os.getenv("CACHE_MAX_ITEMS", "512"))
48
-
49
- # One request at a time on CPU
50
  GEN_LOCK = asyncio.Lock()
51
-
52
- app = FastAPI(title="FADES Fallacy Detector (GGUF / llama.cpp)")
53
-
54
 
55
  # ============================
56
  # CORS (for browser front-ends)
@@ -69,61 +47,63 @@ app.add_middleware(
69
  allow_headers=["*"],
70
  )
71
 
72
-
73
- # ============================
74
- # Schemas
75
- # ============================
76
- class GenParams(BaseModel):
77
- light: bool = False
78
- max_new_tokens: Optional[int] = None
79
- temperature: Optional[float] = None
80
- top_p: Optional[float] = None
81
- repeat_penalty: Optional[float] = None
82
-
83
-
84
- class AnalyzeRequest(GenParams):
85
- text: str
86
-
87
-
88
- class RewriteRequest(GenParams):
89
- text: str
90
- quote: str = Field(..., description="Verbatim substring that must be replaced.")
91
- fallacy_type: str = Field(..., description="Fallacy type of the quote.")
92
- rationale: str = Field(..., description="Why the quote is fallacious.")
93
- occurrence: int = Field(0, description="Which occurrence of quote to replace (0-based).")
94
-
95
-
96
- # ============================
97
- # Labels & Prompts
98
- # ============================
99
  ALLOWED_LABELS = [
100
- "none",
101
- "faulty generalization",
102
- "false causality",
103
- "circular reasoning",
104
- "ad populum",
105
- "ad hominem",
106
- "fallacy of logic",
107
- "appeal to emotion",
108
- "false dilemma",
109
- "equivocation",
110
- "fallacy of extension",
111
- "fallacy of relevance",
112
- "fallacy of credibility",
113
- "miscellaneous",
114
- "intentional",
115
  ]
116
- LABELS_STR = ", ".join([f'"{x}"' for x in ALLOWED_LABELS])
117
 
118
- END_SENTINEL = "<END_JSON>"
119
- STOP_SEQS = [END_SENTINEL]
120
-
121
- ANALYZE_PROMPT = f"""You are a fallacy detection assistant.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
- You MUST choose labels ONLY from this list (exact string):
124
- {LABELS_STR}
 
 
 
 
 
 
 
 
 
125
 
126
- You MUST return ONLY valid JSON with this schema:
127
  {{
128
  "has_fallacy": boolean,
129
  "fallacies": [
@@ -136,818 +116,229 @@ You MUST return ONLY valid JSON with this schema:
136
  ],
137
  "overall_explanation": string
138
  }}
139
-
140
- Hard rules:
141
- - Output ONLY JSON. No markdown. No extra text.
142
- - evidence_quotes MUST be verbatim substrings copied from the input text (no paraphrase).
143
- - Keep each evidence quote short (prefer 1–2 sentences; max 240 chars).
144
- - confidence MUST be a real probability between 0.0 and 1.0 (use 2 decimals). It MUST NOT be always the same.
145
- - The rationale MUST be specific (2–4 sentences). DO NOT use generic filler.
146
- - You MUST NOT output this sentence anywhere:
147
- "The input contains fallacious reasoning consistent with the predicted type(s)."
148
- - overall_explanation MUST be specific (2–5 sentences).
149
-
150
- IMPORTANT TERMINATION:
151
- - After the JSON object, output the token {END_SENTINEL} and stop.
152
-
153
- INPUT:
154
- {{text}}
155
-
156
- OUTPUT (JSON then {END_SENTINEL}):"""
157
-
158
- REWRITE_PROMPT = f"""You are rewriting a small quoted span inside a larger text.
159
-
160
- Goal:
161
- - You MUST propose a replacement for the QUOTE only.
162
- - The replacement should remove the fallacious reasoning described, while keeping the same tone/style/tense/entities.
163
- - The replacement MUST be plausible in the surrounding context and should be similar length (roughly +/- 40%).
164
- - Do NOT change anything outside the quote. Do NOT add new facts not implied by the original.
165
- - Do NOT introduce new fallacies.
166
-
167
- Return ONLY valid JSON with this schema:
168
  {{
169
- "replacement_quote": string,
170
  "why_this_fix": string
171
  }}
 
172
 
173
- Hard rules:
174
- - Output ONLY JSON. No markdown. No extra text.
175
- - replacement_quote should be standalone text (no surrounding quotes).
176
- - why_this_fix: 1–3 sentences, specific.
177
-
178
- IMPORTANT TERMINATION:
179
- - After the JSON object, output the token {END_SENTINEL} and stop.
180
-
181
- INPUT_TEXT:
182
- {{text}}
183
-
184
- QUOTE_TO_REWRITE:
185
- {{quote}}
186
-
187
- FALLACY_TYPE:
188
- {{fallacy_type}}
189
-
190
- WHY_FALLACIOUS:
191
- {{rationale}}
192
-
193
- OUTPUT (JSON then {END_SENTINEL}):"""
194
-
195
-
196
- def build_analyze_messages(text: str) -> List[Dict[str, str]]:
197
- return [
198
- {"role": "system", "content": "Return only JSON. Exactly one JSON object. No extra text."},
199
- {"role": "user", "content": ANALYZE_PROMPT.replace("{text}", text)},
200
- ]
201
-
202
-
203
- def build_rewrite_messages(text: str, quote: str, fallacy_type: str, rationale: str) -> List[Dict[str, str]]:
204
- prompt = (
205
- REWRITE_PROMPT
206
- .replace("{text}", text)
207
- .replace("{quote}", quote)
208
- .replace("{fallacy_type}", fallacy_type)
209
- .replace("{rationale}", rationale)
210
- )
211
- return [
212
- {"role": "system", "content": "Return only JSON. Exactly one JSON object. No extra text."},
213
- {"role": "user", "content": prompt},
214
- ]
215
-
216
-
217
- # ============================
218
- # Logging
219
- # ============================
220
- def _log(rid: str, msg: str):
221
- print(f"[{rid}] {msg}", flush=True)
222
-
223
-
224
- # ============================
225
- # Robust JSON extraction + repair
226
- # ============================
227
- def _strip_sentinel(s: str) -> str:
228
- if not isinstance(s, str):
229
- return ""
230
- idx = s.find(END_SENTINEL)
231
- if idx != -1:
232
- return s[:idx]
233
- return s
234
-
235
-
236
- def stop_at_complete_json(text: str) -> Optional[str]:
237
  start = text.find("{")
238
- if start == -1:
239
- return None
240
 
241
  depth = 0
242
- in_str = False
243
- esc = False
244
-
245
- for i in range(start, len(text)):
246
- ch = text[i]
247
- if in_str:
248
- if esc:
249
- esc = False
250
- elif ch == "\\":
251
- esc = True
252
- elif ch == '"':
253
- in_str = False
254
- continue
255
-
256
- if ch == '"':
257
- in_str = True
258
- continue
259
- if ch == "{":
260
  depth += 1
261
- elif ch == "}":
262
  depth -= 1
263
  if depth == 0:
264
- return text[start : i + 1]
265
- return None
266
-
267
-
268
- def extract_first_json_obj(s: str) -> Optional[Dict[str, Any]]:
269
- s = _strip_sentinel(s)
270
- cut = stop_at_complete_json(s) or s
271
- start = cut.find("{")
272
- end = cut.rfind("}")
273
- if start == -1 or end == -1 or end <= start:
274
- return None
275
- cand = cut[start : end + 1].strip()
276
- try:
277
- return json.loads(cand)
278
- except Exception:
279
- return None
280
-
281
-
282
- def _count_unescaped_quotes(s: str) -> int:
283
- in_str = False
284
- esc = False
285
- count = 0
286
- for ch in s:
287
- if esc:
288
- esc = False
289
- continue
290
- if ch == "\\":
291
- esc = True
292
- continue
293
- if ch == '"':
294
- count += 1
295
- in_str = not in_str
296
- return count
297
-
298
-
299
- def _balance_braces_outside_strings(s: str) -> Tuple[int, int]:
300
- opens = 0
301
- closes = 0
302
- in_str = False
303
- esc = False
304
- for ch in s:
305
- if in_str:
306
- if esc:
307
- esc = False
308
- elif ch == "\\":
309
- esc = True
310
- elif ch == '"':
311
- in_str = False
312
- continue
313
- else:
314
- if ch == '"':
315
- in_str = True
316
- continue
317
- if ch == "{":
318
- opens += 1
319
- elif ch == "}":
320
- closes += 1
321
- return opens, closes
322
-
323
-
324
- def try_repair_and_parse_json(raw: str) -> Optional[Dict[str, Any]]:
325
  """
326
- Best-effort repair when model got stuck/repetitive and didn't close JSON.
327
- Strategy:
328
- - take from first '{'
329
- - if quotes count odd => append '"'
330
- - balance braces outside strings by appending missing '}'
331
- - try json.loads
332
  """
333
- if not isinstance(raw, str):
334
- return None
335
- s = _strip_sentinel(raw)
336
- start = s.find("{")
337
- if start == -1:
338
- return None
339
- cand = s[start:].strip()
340
-
341
- # If it contains huge repetition, hard-trim after some chars to avoid pathological payloads.
342
- # (Keeps server responsive.)
343
- MAX_CAND = 50_000
344
- if len(cand) > MAX_CAND:
345
- cand = cand[:MAX_CAND]
346
-
347
- # Close open string if needed
348
- if _count_unescaped_quotes(cand) % 2 == 1:
349
- cand += '"'
350
-
351
- opens, closes = _balance_braces_outside_strings(cand)
352
- if closes > opens:
353
- # can't safely repair this
354
- return None
355
- if opens > closes:
356
- cand += "}" * (opens - closes)
357
-
358
- cand = cand.strip()
359
 
360
- try:
361
- return json.loads(cand)
362
- except Exception:
363
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
 
 
365
 
366
- # ============================
367
- # Model load
368
- # ============================
369
- llm: Optional[Llama] = None
370
- model_path: Optional[str] = None
371
- load_error: Optional[str] = None
372
- loaded_at_ts: Optional[float] = None
373
 
 
 
374
 
375
- def load_llama() -> None:
376
- global llm, model_path, load_error, loaded_at_ts
377
 
378
- print("=== FADES startup ===", flush=True)
379
- print(f"GGUF_REPO_ID={GGUF_REPO_ID}", flush=True)
380
- print(f"GGUF_FILENAME={GGUF_FILENAME}", flush=True)
381
- print(f"N_CTX={N_CTX} N_THREADS={N_THREADS} N_BATCH={N_BATCH}", flush=True)
382
 
 
 
 
383
  try:
384
- t0 = time.time()
385
- mp = hf_hub_download(
386
- repo_id=GGUF_REPO_ID,
387
- filename=GGUF_FILENAME,
388
- token=os.getenv("HF_TOKEN"),
389
- )
390
- t1 = time.time()
391
- print(f"✅ GGUF downloaded: {mp} ({t1 - t0:.1f}s)", flush=True)
392
-
393
- t2 = time.time()
394
- llm_local = Llama(
395
- model_path=mp,
396
- n_ctx=N_CTX,
397
- n_threads=N_THREADS,
398
- n_batch=N_BATCH,
399
- n_gpu_layers=0,
400
- verbose=False,
401
  )
402
- t3 = time.time()
403
- print(f"✅ Model loaded: ({t3 - t2:.1f}s) n_ctx={N_CTX} threads={N_THREADS} batch={N_BATCH}", flush=True)
404
-
405
- llm = llm_local
406
- model_path = mp
407
- load_error = None
408
- loaded_at_ts = time.time()
409
- print("=== Startup OK ===", flush=True)
410
-
411
  except Exception as e:
412
- load_error = repr(e)
413
- print(f"❌ Startup FAILED: {load_error}", flush=True)
414
-
415
 
416
- @app.on_event("startup")
417
- def _startup():
418
- load_llama()
419
-
420
-
421
- @app.get("/")
422
- def root():
423
- return {"ok": True, "hint": "Use GET /health, POST /analyze, POST /rewrite"}
424
 
 
 
 
 
 
425
 
426
  @app.get("/health")
427
  def health():
428
- return {
429
- "ok": llm is not None and load_error is None,
430
- "model_loaded": llm is not None,
431
- "load_error": load_error,
432
- "gguf_repo": GGUF_REPO_ID,
433
- "gguf_filename": GGUF_FILENAME,
434
- "model_path": model_path,
435
- "n_ctx": N_CTX,
436
- "n_threads": N_THREADS,
437
- "n_batch": N_BATCH,
438
- "loaded_at_ts": loaded_at_ts,
439
- }
440
-
441
-
442
- # ============================
443
- # Param selection
444
- # ============================
445
- def pick_params(req: GenParams) -> Dict[str, Any]:
446
- if req.light:
447
- params = {
448
- "max_new_tokens": LIGHT_MAX_NEW_TOKENS,
449
- "temperature": LIGHT_TEMPERATURE,
450
- "top_p": LIGHT_TOP_P,
451
- "n_batch": LIGHT_N_BATCH,
452
- "repeat_penalty": REPEAT_PENALTY_DEFAULT,
453
- }
454
- else:
455
- params = {
456
- "max_new_tokens": MAX_NEW_TOKENS_DEFAULT,
457
- "temperature": TEMPERATURE_DEFAULT,
458
- "top_p": TOP_P_DEFAULT,
459
- "n_batch": N_BATCH,
460
- "repeat_penalty": REPEAT_PENALTY_DEFAULT,
461
- }
462
-
463
- if req.max_new_tokens is not None:
464
- params["max_new_tokens"] = int(req.max_new_tokens)
465
- if req.temperature is not None:
466
- params["temperature"] = float(req.temperature)
467
- if req.top_p is not None:
468
- params["top_p"] = float(req.top_p)
469
- if req.repeat_penalty is not None:
470
- params["repeat_penalty"] = float(req.repeat_penalty)
471
-
472
- # Safety caps
473
- params["max_new_tokens"] = max(1, min(int(params["max_new_tokens"]), 400))
474
- params["temperature"] = max(0.0, min(float(params["temperature"]), 1.5))
475
- params["top_p"] = max(0.05, min(float(params["top_p"]), 1.0))
476
- params["n_batch"] = max(16, min(int(params["n_batch"]), 512))
477
- params["repeat_penalty"] = max(1.0, min(float(params["repeat_penalty"]), 1.5))
478
- return params
479
 
 
 
 
 
 
480
 
481
- # ============================
482
- # Post-processing helpers
483
- # ============================
484
- _TEMPLATE_SENTENCE = "The input contains fallacious reasoning consistent with the predicted type(s)."
485
- _TEMPLATE_RE = re.compile(
486
- r"(?is)\bThe input contains fallacious reasoning consistent with the predicted type\(s\)\.\s*"
487
- )
488
-
489
-
490
- def strip_template_sentence(text: Any) -> str:
491
- if not isinstance(text, str):
492
- return ""
493
- out = _TEMPLATE_RE.sub("", text)
494
- out = out.replace(_TEMPLATE_SENTENCE, "")
495
- out = re.sub(r"\s{2,}", " ", out).strip()
496
- out = re.sub(r"^[\s\-–—:;,\.\u2022]+", "", out).strip()
497
- out = out.replace("..", ".").replace(" ,", ",").strip()
498
- return out
499
-
500
-
501
- # ============================
502
- # Output sanitation / validation
503
- # ============================
504
- def _clamp01(x: Any, default: float = 0.5) -> float:
505
- try:
506
- v = float(x)
507
- except Exception:
508
- return default
509
- if v < 0.0:
510
- return 0.0
511
- if v > 1.0:
512
- return 1.0
513
- return v
514
-
515
-
516
- def _is_allowed_label(lbl: Any) -> bool:
517
- return isinstance(lbl, str) and lbl in ALLOWED_LABELS and lbl != "none"
518
-
519
-
520
- def sanitize_analyze_output(obj: Dict[str, Any], input_text: str) -> Dict[str, Any]:
521
- has_fallacy = bool(obj.get("has_fallacy", False))
522
- fallacies_in = obj.get("fallacies", [])
523
- if not isinstance(fallacies_in, list):
524
- fallacies_in = []
525
-
526
- fallacies_out = []
527
- for f in fallacies_in:
528
- if not isinstance(f, dict):
529
- continue
530
- f_type = f.get("type")
531
- if not _is_allowed_label(f_type):
532
- continue
533
-
534
- conf = _clamp01(f.get("confidence", 0.5))
535
- conf = float(f"{conf:.2f}")
536
-
537
- ev = f.get("evidence_quotes", [])
538
- if not isinstance(ev, list):
539
- ev = []
540
-
541
- ev_clean: List[str] = []
542
- for q in ev:
543
- if not isinstance(q, str):
544
- continue
545
- qq = q.strip()
546
- if not qq:
547
- continue
548
- if qq in input_text:
549
- ev_clean.append(qq if len(qq) <= 240 else qq[:240])
550
-
551
- rationale = strip_template_sentence(f.get("rationale", ""))
552
-
553
- fallacies_out.append(
554
- {
555
- "type": f_type,
556
- "confidence": conf,
557
- "evidence_quotes": ev_clean[:3],
558
- "rationale": rationale,
559
- }
560
- )
561
-
562
- overall = strip_template_sentence(obj.get("overall_explanation", ""))
563
-
564
- if len(fallacies_out) == 0:
565
- has_fallacy = False
566
-
567
- return {
568
- "has_fallacy": has_fallacy,
569
- "fallacies": fallacies_out,
570
- "overall_explanation": overall,
571
- }
572
-
573
 
574
- def generate_overall_explanation(clean: Dict[str, Any]) -> str:
575
- has_fallacy = bool(clean.get("has_fallacy"))
576
- fallacies = clean.get("fallacies") or []
577
- if not has_fallacy or not fallacies:
578
- return (
579
- "No clear fallacious reasoning was detected in the text. "
580
- "The argument appears broadly consistent as written, though it may still rely on unstated assumptions."
581
  )
 
582
 
583
- # unique types
584
- types: List[str] = []
585
- for f in fallacies:
586
- if isinstance(f, dict):
587
- t = f.get("type")
588
- if isinstance(t, str) and t not in types:
589
- types.append(t)
590
-
591
- # example
592
- example = ""
593
- for f in fallacies:
594
- if isinstance(f, dict):
595
- ev = f.get("evidence_quotes")
596
- if isinstance(ev, list) and ev and isinstance(ev[0], str) and ev[0].strip():
597
- example = ev[0].strip()
598
- break
599
- if example and len(example) > 160:
600
- example = example[:160].rstrip() + "…"
601
-
602
- risk_map = {
603
- "faulty generalization": "It can cause you to over-apply a conclusion from too little evidence.",
604
- "false causality": "It can lead to incorrect cause-and-effect beliefs and bad decisions based on them.",
605
- "circular reasoning": "It can make a claim look proven while it is actually assumed from the start.",
606
- "ad populum": "It can make popularity feel like proof, which can spread or entrench misinformation.",
607
- "ad hominem": "It can shift focus from evidence to personal attacks, increasing polarization.",
608
- "fallacy of logic": "It can make the argument sound coherent while a key logical step does not follow.",
609
- "appeal to emotion": "It can push decisions through fear/anger rather than evidence.",
610
- "false dilemma": "It can hide reasonable alternatives by framing the situation as only two options.",
611
- "equivocation": "It can create confusion by changing the meaning of key terms mid-argument.",
612
- "fallacy of extension": "It can exaggerate consequences by leaping from a modest premise to an extreme outcome.",
613
- "fallacy of relevance": "It can distract from the real issue with points that do not support the conclusion.",
614
- "fallacy of credibility": "It can replace evidence with perceived authority or social credibility.",
615
- "miscellaneous": "It can still mislead by making the conclusion feel stronger than the support provided.",
616
- "intentional": "It can be persuasive while bypassing careful reasoning, increasing the chance of manipulation.",
617
- }
618
-
619
- risks: List[str] = []
620
- for t in types:
621
- rs = risk_map.get(t)
622
- if rs and rs not in risks:
623
- risks.append(rs)
624
- if len(risks) >= 2:
625
- break
626
 
627
- types_str = ", ".join(types) if len(types) <= 3 else ", ".join(types[:3]) + "…"
628
- out = (
629
- f"The text contains fallacious reasoning ({types_str}) that can make the conclusion seem stronger than the evidence supports."
630
- )
631
- if example:
632
- out += f' For example: "{example}".'
633
- out += " Risk: " + (" ".join(risks) if risks else "it may mislead readers by presenting weak support as if it were decisive.")
634
- return out.strip()
635
-
636
-
637
- # ============================
638
- # Success-only cache
639
- # ============================
640
- _SUCCESS_CACHE: Dict[Tuple[Any, ...], Tuple[float, Dict[str, Any]]] = {}
641
 
 
 
 
 
 
642
 
643
- def _cache_get(key: Tuple[Any, ...]) -> Optional[Dict[str, Any]]:
644
- item = _SUCCESS_CACHE.get(key)
645
- if not item:
646
- return None
647
- ts, val = item
648
- if (time.time() - ts) > CACHE_TTL_S:
649
- _SUCCESS_CACHE.pop(key, None)
650
- return None
651
- return val
652
-
653
-
654
- def _cache_put(key: Tuple[Any, ...], val: Dict[str, Any]) -> None:
655
- # naive eviction if too big
656
- if len(_SUCCESS_CACHE) >= CACHE_MAX_ITEMS:
657
- # drop oldest
658
- oldest_key = min(_SUCCESS_CACHE.items(), key=lambda kv: kv[1][0])[0]
659
- _SUCCESS_CACHE.pop(oldest_key, None)
660
- _SUCCESS_CACHE[key] = (time.time(), val)
661
-
662
-
663
- # ============================
664
- # Completion (task-aware)
665
- # ============================
666
- def _chat_completion(
667
- task: str,
668
- payload: str,
669
- light: bool,
670
- max_new_tokens: int,
671
- temperature: float,
672
- top_p: float,
673
- n_batch: int,
674
- repeat_penalty: float,
675
- ) -> Dict[str, Any]:
676
- if llm is None:
677
- return {"ok": False, "error": "model_not_loaded", "detail": load_error}
678
-
679
- key = (task, payload, light, max_new_tokens, temperature, top_p, n_batch, repeat_penalty)
680
- cached = _cache_get(key)
681
- if cached is not None:
682
- return {"ok": True, "result": cached, "cached": True}
683
-
684
- try:
685
- llm.n_batch = int(n_batch) # type: ignore[attr-defined]
686
- except Exception:
687
- pass
688
 
689
  try:
690
- data = json.loads(payload)
691
- except Exception:
692
- return {"ok": False, "error": "bad_payload"}
693
-
694
- if task == "analyze":
695
- messages = build_analyze_messages(data["text"])
696
- elif task == "rewrite":
697
- messages = build_rewrite_messages(
698
- data["text"],
699
- data["quote"],
700
- data["fallacy_type"],
701
- data["rationale"],
702
- )
703
- else:
704
- return {"ok": False, "error": "unknown_task"}
705
-
706
- t0 = time.time()
707
- out = llm.create_chat_completion(
708
- messages=messages,
709
- max_tokens=int(max_new_tokens),
710
- temperature=float(temperature),
711
- top_p=float(top_p),
712
- repeat_penalty=float(repeat_penalty),
713
- stop=STOP_SEQS,
714
- stream=False,
715
- )
716
- t1 = time.time()
717
-
718
- raw = out["choices"][0]["message"]["content"]
719
- raw = _strip_sentinel(raw)
720
-
721
- obj = extract_first_json_obj(raw)
722
- if obj is None:
723
- # attempt repair (close quote/braces) to avoid unusable responses
724
- obj = try_repair_and_parse_json(raw)
725
-
726
- if obj is None:
727
- return {"ok": False, "error": "json_parse_error", "raw": raw, "gen_s": round(t1 - t0, 3)}
728
-
729
- # success only: store in cache
730
- _cache_put(key, obj)
731
-
732
- return {"ok": True, "result": obj, "gen_s": round(t1 - t0, 3)}
733
-
734
-
735
- def _occurrence_index(text: str, sub: str, occurrence: int) -> int:
736
- if occurrence < 0:
737
- return -1
738
- start = 0
739
- for _ in range(occurrence + 1):
740
- idx = text.find(sub, start)
741
- if idx == -1:
742
- return -1
743
- start = idx + max(1, len(sub))
744
- return idx
745
-
746
-
747
- def _replace_nth(text: str, old: str, new: str, occurrence: int) -> Dict[str, Any]:
748
- idx = _occurrence_index(text, old, occurrence)
749
- if idx == -1:
750
- return {"ok": False, "error": "quote_not_found"}
751
- return {
752
- "ok": True,
753
- "rewritten_text": text[:idx] + new + text[idx + len(old) :],
754
- "start_char": idx,
755
- "end_char": idx + len(new),
756
- "old_start_char": idx,
757
- "old_end_char": idx + len(old),
758
- }
759
 
 
 
 
760
 
761
- # ============================
762
- # Routes
763
- # ============================
764
- @app.post("/analyze")
765
- async def analyze(req: AnalyzeRequest) -> Dict[str, Any]:
766
- rid = uuid.uuid4().hex[:10]
767
- t0 = time.time()
768
 
769
- _log(rid, f"📩 /analyze received (light={req.light}) chars={len(req.text) if req.text else 0}")
 
770
 
771
- if not req.text or not req.text.strip():
772
- return {"ok": False, "error": "empty_text"}
773
 
774
- params = pick_params(req)
775
- _log(
776
- rid,
777
- f"⚙️ Params: max_new_tokens={params['max_new_tokens']} temp={params['temperature']} top_p={params['top_p']} n_batch={params['n_batch']} repeat_penalty={params['repeat_penalty']}",
778
- )
779
-
780
- payload = json.dumps({"text": req.text}, ensure_ascii=False)
781
-
782
- async with GEN_LOCK:
783
- _log(rid, "🧠 Generating analyze...")
784
- res = _chat_completion(
785
- "analyze",
786
- payload,
787
- bool(req.light),
788
- int(params["max_new_tokens"]),
789
- float(params["temperature"]),
790
- float(params["top_p"]),
791
- int(params["n_batch"]),
792
- float(params["repeat_penalty"]),
793
- )
794
 
795
- elapsed_total = time.time() - t0
796
-
797
- if not res.get("ok"):
798
- _log(rid, f"❌ /analyze failed: {res.get('error')}")
799
- return {
800
- **res,
801
- "meta": {
802
- "request_id": rid,
803
- "light": bool(req.light),
804
- "params": {
805
- "max_new_tokens": int(params["max_new_tokens"]),
806
- "temperature": float(params["temperature"]),
807
- "top_p": float(params["top_p"]),
808
- "n_batch": int(params["n_batch"]),
809
- "repeat_penalty": float(params["repeat_penalty"]),
810
- },
811
- "timings_s": {"total": round(elapsed_total, 3), "gen": res.get("gen_s", None)},
812
- },
813
- }
814
 
815
- clean = sanitize_analyze_output(res["result"], req.text)
816
- # ensure overall explanation is always a useful summary + risk
817
- # clean["overall_explanation"] = generate_overall_explanation(clean)
818
 
819
- _log(rid, f"✅ /analyze ok fallacies={len(clean.get('fallacies', []))} total={elapsed_total:.2f}s")
820
  return {
821
- "ok": True,
822
- "result": clean,
823
  "meta": {
824
- "request_id": rid,
825
- "light": bool(req.light),
826
- "params": {
827
- "max_new_tokens": int(params["max_new_tokens"]),
828
- "temperature": float(params["temperature"]),
829
- "top_p": float(params["top_p"]),
830
- "n_batch": int(params["n_batch"]),
831
- "repeat_penalty": float(params["repeat_penalty"]),
832
- },
833
- "timings_s": {"total": round(elapsed_total, 3), "gen": res.get("gen_s", None)},
834
- },
835
  }
836
 
837
-
838
  @app.post("/rewrite")
839
- async def rewrite(req: RewriteRequest) -> Dict[str, Any]:
840
- rid = uuid.uuid4().hex[:10]
841
- t0 = time.time()
842
-
843
- _log(
844
- rid,
845
- f"📩 /rewrite received (light={req.light}) text_chars={len(req.text) if req.text else 0} quote_chars={len(req.quote) if req.quote else 0}",
846
- )
847
-
848
- if not req.text or not req.text.strip():
849
- return {"ok": False, "error": "empty_text"}
850
- if not req.quote or not req.quote.strip():
851
- return {"ok": False, "error": "empty_quote"}
852
-
853
- quote = req.quote.strip()
854
- occurrence = int(req.occurrence or 0)
855
-
856
- if _occurrence_index(req.text, quote, occurrence) == -1:
857
- return {"ok": False, "error": "quote_not_found", "detail": {"occurrence": occurrence}}
858
-
859
- params = pick_params(req)
860
- if req.light and req.max_new_tokens is None:
861
- params["max_new_tokens"] = max(params["max_new_tokens"], 80)
862
-
863
- payload = json.dumps(
864
- {
865
- "text": req.text,
866
- "quote": quote,
867
- "fallacy_type": req.fallacy_type,
868
- "rationale": req.rationale,
869
- },
870
- ensure_ascii=False,
871
- )
872
-
873
  async with GEN_LOCK:
874
- _log(rid, "🧠 Generating rewrite replacement_quote...")
875
- res = _chat_completion(
876
- "rewrite",
877
- payload,
878
- bool(req.light),
879
- int(params["max_new_tokens"]),
880
- float(params["temperature"]),
881
- float(params["top_p"]),
882
- int(params["n_batch"]),
883
- float(params["repeat_penalty"]),
884
- )
885
-
886
- elapsed_total = time.time() - t0
887
-
888
- if not res.get("ok"):
889
- _log(rid, f"❌ /rewrite failed: {res.get('error')}")
890
- return {
891
- **res,
892
- "meta": {
893
- "request_id": rid,
894
- "light": bool(req.light),
895
- "params": {
896
- "max_new_tokens": int(params["max_new_tokens"]),
897
- "temperature": float(params["temperature"]),
898
- "top_p": float(params["top_p"]),
899
- "n_batch": int(params["n_batch"]),
900
- "repeat_penalty": float(params["repeat_penalty"]),
901
- },
902
- "timings_s": {"total": round(elapsed_total, 3), "gen": res.get("gen_s", None)},
903
- },
904
- }
905
-
906
- obj = res["result"]
907
- if not isinstance(obj, dict):
908
- return {"ok": False, "error": "bad_rewrite_output"}
909
-
910
- replacement = obj.get("replacement_quote")
911
- if not isinstance(replacement, str):
912
- return {"ok": False, "error": "missing_replacement_quote", "raw": obj}
913
-
914
- replacement = replacement.strip()
915
- if not replacement:
916
- return {"ok": False, "error": "empty_replacement_quote", "raw": obj}
917
-
918
- why = obj.get("why_this_fix")
919
- why = strip_template_sentence(why)
920
-
921
- rep = _replace_nth(req.text, quote, replacement, occurrence)
922
- if not rep.get("ok"):
923
- return {"ok": False, "error": rep.get("error", "replace_failed")}
924
-
925
- _log(rid, f"✅ /rewrite ok total={elapsed_total:.2f}s")
926
- return {
927
- "ok": True,
928
- "result": {
929
- "rewritten_text": rep["rewritten_text"],
930
- "old_quote": quote,
931
- "replacement_quote": replacement,
932
- "why_this_fix": why,
933
- "occurrence": occurrence,
934
- "span": {
935
- "old_start_char": rep["old_start_char"],
936
- "old_end_char": rep["old_end_char"],
937
- "new_start_char": rep["start_char"],
938
- "new_end_char": rep["end_char"],
939
- },
940
- },
941
- "meta": {
942
- "request_id": rid,
943
- "light": bool(req.light),
944
- "params": {
945
- "max_new_tokens": int(params["max_new_tokens"]),
946
- "temperature": float(params["temperature"]),
947
- "top_p": float(params["top_p"]),
948
- "n_batch": int(params["n_batch"]),
949
- "repeat_penalty": float(params["repeat_penalty"]),
950
- },
951
- "timings_s": {"total": round(elapsed_total, 3), "gen": res.get("gen_s", None)},
952
- },
953
- }
 
 
1
  import os
2
  import json
3
  import time
4
+ import math
5
  import asyncio
6
  import re
7
+ from functools import lru_cache
8
+ from typing import Any, Dict, List, Optional
 
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ import nest_asyncio
11
+ import uvicorn
12
+ from fastapi import FastAPI
13
+ from pydantic import BaseModel
14
  from huggingface_hub import hf_hub_download
15
  from llama_cpp import Llama
16
 
17
+ ENABLE_FULL_CONFIDENCE = True
18
+ USE_FLASH_ATTN = True
19
+ N_BATCH = 1024
20
+ N_THREADS = 6
21
+ N_CTX = 1024
22
 
23
+ DRIVE_CACHE_DIR = "/content/drive/MyDrive/FADES_Models_Cache"
24
+ if os.path.exists("/content/drive") and not os.path.exists(DRIVE_CACHE_DIR):
25
+ try: os.makedirs(DRIVE_CACHE_DIR)
26
+ except: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ GGUF_REPO_ID = "maxime-antoine-dev/fades-mistral-v02-gguf"
29
+ GGUF_FILENAME = "mistral_v02_fades.Q4_K_M.gguf"
 
 
 
30
  GEN_LOCK = asyncio.Lock()
31
+ app = FastAPI(title="FADES Fallacy Detector API (Final)")
 
 
32
 
33
  # ============================
34
  # CORS (for browser front-ends)
 
47
  allow_headers=["*"],
48
  )
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  ALLOWED_LABELS = [
51
+ "none", "faulty generalization", "false causality", "circular reasoning",
52
+ "ad populum", "ad hominem", "fallacy of logic", "appeal to emotion",
53
+ "false dilemma", "equivocation", "fallacy of extension",
54
+ "fallacy of relevance", "fallacy of credibility", "miscellaneous", "intentional"
 
 
 
 
 
 
 
 
 
 
 
55
  ]
 
56
 
57
+ # mapping des premiers mots vers les labels (pour regrouper les probas)
58
+ LABEL_MAPPING = {
59
+ "none": ["none"],
60
+ "faulty": ["faulty generalization"],
61
+ "false": ["false causality", "false dilemma"],
62
+ "circular": ["circular reasoning"],
63
+ "ad": ["ad populum", "ad hominem"],
64
+ "fallacy": ["fallacy of logic", "extension", "relevance", "credibility"],
65
+ "appeal": ["appeal to emotion"],
66
+ "equivocation": ["equivocation"],
67
+ "miscellaneous": ["miscellaneous"],
68
+ "intentional": ["intentional"]
69
+ }
70
+
71
+ # On ajoute des exemples (Few-Shot) pour guider le modèle
72
+ ANALYZE_SYS_PROMPT = """You are a logic expert. Detect logical fallacies.
73
+ OUTPUT JSON ONLY.
74
+
75
+ RULES:
76
+ 1. Use ONLY these labels: {labels}
77
+ 2. "rationale": Explain WHY.
78
+ 3. "confidence": 0.0 to 1.0.
79
+
80
+ EXAMPLES (Follow this logic):
81
+
82
+ Input: "You are stupid, so your opinion is wrong."
83
+ Output: {{
84
+ "has_fallacy": true,
85
+ "fallacies": [{{
86
+ "type": "ad hominem",
87
+ "confidence": 0.95,
88
+ "evidence_quotes": ["You are stupid"],
89
+ "rationale": "Direct attack on the person rather than the argument."
90
+ }}],
91
+ "overall_explanation": "Ad Hominem attack."
92
+ }}
93
 
94
+ Input: "Think of the children! We must ban this immediately or they will suffer!"
95
+ Output: {{
96
+ "has_fallacy": true,
97
+ "fallacies": [{{
98
+ "type": "appeal to emotion",
99
+ "confidence": 0.90,
100
+ "evidence_quotes": ["Think of the children", "they will suffer"],
101
+ "rationale": "Uses fear and pity to manipulate opinion without logical proof."
102
+ }}],
103
+ "overall_explanation": "Manipulative emotional appeal."
104
+ }}
105
 
106
+ JSON SCHEMA:
107
  {{
108
  "has_fallacy": boolean,
109
  "fallacies": [
 
116
  ],
117
  "overall_explanation": string
118
  }}
119
+ """
120
+ REWRITE_SYS_PROMPT = """You are a text editor. Rewrite to remove the fallacy.
121
+ Output Format (JSON):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  {{
123
+ "rewritten_text": string,
124
  "why_this_fix": string
125
  }}
126
+ """
127
 
128
+ def clean_and_repair_json(text: str) -> str:
129
+ text = text.replace("```json", "").replace("```", "").strip()
130
+
131
+ # 2. On cherche le premier '{'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  start = text.find("{")
133
+ if start == -1: return text
 
134
 
135
  depth = 0
136
+ for i, char in enumerate(text[start:], start=start):
137
+ if char == "{":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  depth += 1
139
+ elif char == "}":
140
  depth -= 1
141
  if depth == 0:
142
+ potential_json = text[start:i+1]
143
+ try:
144
+ json.loads(potential_json)
145
+ return potential_json
146
+ except:
147
+ pass
148
+ end = text.rfind("}")
149
+ if start != -1 and end != -1:
150
+ return text[start:end+1]
151
+
152
+ return text
153
+
154
+ def analyze_alternatives(start_index: int, top_logprobs_list: List[Dict[str, float]]) -> Dict[str, float]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  """
156
+ Regarde les 'top_logprobs' au moment le label a commencé à être écrit.
157
+ Retourne un dictionnaire des probabilités pour chaque FAMILLE de label.
158
+ Ex: {"Ad ...": 0.8, "Faulty ...": 0.1, "None": 0.05}
 
 
 
159
  """
160
+ if start_index < 0 or start_index >= len(top_logprobs_list):
161
+ return {}
162
+ candidates = top_logprobs_list[start_index]
163
+
164
+ distribution = {}
165
+ total_prob = 0.0
166
+
167
+ for token, logprob in candidates.items():
168
+ clean_tok = str(token).replace(" ", "").lower().strip()
169
+ prob = math.exp(logprob)
170
+
171
+ matched = False
172
+ for key, group in LABEL_MAPPING.items():
173
+ if clean_tok.startswith(key):
174
+ group_name = f"{key.capitalize()} ({'/'.join([g.split()[-1] for g in group])})" if len(group) > 1 else group[0].title()
175
+ distribution[group_name] = distribution.get(group_name, 0.0) + prob
176
+ matched = True
177
+ break
 
 
 
 
 
 
 
 
178
 
179
+ if not matched:
180
+ distribution["_other_"] = distribution.get("_other_", 0.0) + prob
181
+
182
+ total_prob += prob
183
+
184
+ return {k: round(v, 4) for k, v in distribution.items() if v > 0.001}
185
+
186
+ def extract_label_info(target_label: str, tokens: List[str], logprobs: List[float], top_logprobs: List[Dict]) -> Dict:
187
+ """Récupère la confiance spécifique ET la distribution des alternatives"""
188
+ if not target_label: return {"conf": 0.0, "dist": {}}
189
+
190
+ target_clean = target_label.lower().strip()
191
+ current_text = ""
192
+ start_index = -1
193
+
194
+ # on chreche trouver où commence le label
195
+ for i, token in enumerate(tokens):
196
+ tok_str = str(token) if not isinstance(token, bytes) else token.decode('utf-8', errors='ignore')
197
+ current_text += tok_str
198
+ #oOn cherche le label s'il apparaît
199
+ if target_clean in current_text.lower() and start_index == -1:
200
+ start_index = max(0, i - 5)
201
+ # on affine pour trouver le vrai début (souvent précédé de guillemets)
202
+ # c'est approximatif mais suffisant pour choper le bon token
203
+ for j in range(start_index, i + 1):
204
+ t_s = str(tokens[j]).lower()
205
+ # si le token commence par la première lettre du label
206
+ if target_clean[0] in t_s:
207
+ start_index = j
208
+ break
209
+ break
210
 
211
+ conf = 0.0
212
+ dist = {}
213
 
214
+ if start_index != -1:
 
 
 
 
 
 
215
 
216
+ valid = [math.exp(logprobs[k]) for k in range(start_index, min(len(logprobs), start_index+3)) if logprobs[k] is not None]
217
+ conf = round(sum(valid)/len(valid), 4) if valid else 0.0
218
 
219
+ if top_logprobs:
220
+ dist = analyze_alternatives(start_index, top_logprobs)
221
 
222
+ return {"conf": conf, "dist": dist}
 
 
 
223
 
224
+ @lru_cache(maxsize=1)
225
+ def get_model():
226
+ print(f"📦 Loading Model...")
227
  try:
228
+ model_path = hf_hub_download(repo_id=GGUF_REPO_ID, filename=GGUF_FILENAME, cache_dir=DRIVE_CACHE_DIR)
229
+ llm = Llama(
230
+ model_path=model_path, n_ctx=N_CTX, n_threads=N_THREADS, n_batch=N_BATCH, verbose=False,
231
+ n_gpu_layers=-1, flash_attn=USE_FLASH_ATTN, logits_all=ENABLE_FULL_CONFIDENCE
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  )
233
+ return llm
 
 
 
 
 
 
 
 
234
  except Exception as e:
235
+ print(f"❌ Error: {e}")
236
+ raise e
 
237
 
238
+ class AnalyzeRequest(BaseModel):
239
+ text: str
240
+ max_new_tokens: int = 300
241
+ temperature: float = 0.1
 
 
 
 
242
 
243
+ class RewriteRequest(BaseModel):
244
+ text: str
245
+ fallacy_type: str
246
+ rationale: str
247
+ max_new_tokens: int = 300
248
 
249
  @app.get("/health")
250
  def health():
251
+ get_model()
252
+ return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
+ @app.post("/analyze")
255
+ async def analyze(req: AnalyzeRequest):
256
+ llm = get_model()
257
+ system_prompt = ANALYZE_SYS_PROMPT.format(labels=", ".join(ALLOWED_LABELS))
258
+ prompt = f"[INST] {system_prompt}\n\nINPUT TEXT:\n{req.text} [/INST]"
259
 
260
+ req_logprobs = 20 if ENABLE_FULL_CONFIDENCE else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
+ async with GEN_LOCK:
263
+ start_time = time.time()
264
+ output = llm(
265
+ prompt, max_tokens=req.max_new_tokens, temperature=req.temperature, top_p=0.95,
266
+ repeat_penalty=1.15, stop=["</s>", "```"], echo=False, logprobs=req_logprobs
 
 
267
  )
268
+ gen_time = time.time() - start_time
269
 
270
+ raw_text = output['choices'][0]['text']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
+ tokens = []
273
+ logprobs = []
274
+ top_logprobs = []
 
 
 
 
 
 
 
 
 
 
 
275
 
276
+ if ENABLE_FULL_CONFIDENCE and 'logprobs' in output['choices'][0]:
277
+ lp_data = output['choices'][0]['logprobs']
278
+ tokens = lp_data.get('tokens', [])
279
+ logprobs = lp_data.get('token_logprobs', [])
280
+ top_logprobs = lp_data.get('top_logprobs', [])
281
 
282
+ cleaned_text = clean_and_repair_json(raw_text)
283
+ result_json = {}
284
+ success = False
285
+ technical_confidence = 0.0
286
+ label_distribution = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
  try:
289
+ result_json = json.loads(cleaned_text)
290
+ success = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
+ if result_json.get("has_fallacy") and result_json.get("fallacies"):
293
+ for fallacy in result_json["fallacies"]:
294
+ d_type = fallacy.get("type", "")
295
 
296
+ if ENABLE_FULL_CONFIDENCE:
297
+ info = extract_label_info(d_type, tokens, logprobs, top_logprobs)
 
 
 
 
 
298
 
299
+ spec_conf = info["conf"]
300
+ label_distribution = info["dist"]
301
 
302
+ fallacy["technical_confidence"] = spec_conf
303
+ fallacy["alternatives"] = label_distribution
304
 
305
+ declared = fallacy.get("confidence", 0.8)
306
+ fallacy["confidence"] = round((declared + spec_conf) / 2, 2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
+ if technical_confidence == 0.0: technical_confidence = spec_conf
309
+ else:
310
+ if ENABLE_FULL_CONFIDENCE:
311
+ info = extract_label_info("has_fallacy", tokens, logprobs, top_logprobs)
312
+ label_distribution = info["dist"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
+ except json.JSONDecodeError:
315
+ result_json = {"error": "JSON Error", "raw": raw_text}
316
+ success = False
317
 
 
318
  return {
319
+ "ok": success,
320
+ "result": result_json,
321
  "meta": {
322
+ "tech_conf": technical_confidence,
323
+ "distribution": label_distribution,
324
+ "time": round(gen_time, 2)
325
+ }
 
 
 
 
 
 
 
326
  }
327
 
 
328
  @app.post("/rewrite")
329
+ async def rewrite(req: RewriteRequest):
330
+ llm = get_model()
331
+ system_prompt = REWRITE_SYS_PROMPT.format(fallacy_type=req.fallacy_type, rationale=req.rationale)
332
+ prompt = f"[INST] {system_prompt}\n\nTEXT TO FIX:\n{req.text} [/INST]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  async with GEN_LOCK:
334
+ output = llm(prompt, max_tokens=req.max_new_tokens, temperature=0.7, repeat_penalty=1.1, stop=["</s>", "}"])
335
+ try:
336
+ res = json.loads(clean_and_repair_json(output['choices'][0]['text']))
337
+ ok = True
338
+ except:
339
+ res = {"raw": output['choices'][0]['text']}
340
+ ok = False
341
+ return {"ok": ok, "result": res}
342
+
343
+ if __name__ == "__main__":
344
+ uvicorn.run(app, host="0.0.0.0", port=8000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -2,3 +2,4 @@ fastapi>=0.110
2
  uvicorn[standard]>=0.27
3
  huggingface_hub>=0.23
4
  llama-cpp-python==0.2.90
 
 
2
  uvicorn[standard]>=0.27
3
  huggingface_hub>=0.23
4
  llama-cpp-python==0.2.90
5
+ nest-asyncio
utils.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Any, Dict, Optional, List
4
+ from prompts import ALLOWED_LABELS
5
+
6
+ # ----------------------------
7
+ # Robust JSON extraction
8
+ # ----------------------------
9
+ def stop_at_complete_json(text: str) -> Optional[str]:
10
+ start = text.find("{")
11
+ if start == -1:
12
+ return None
13
+
14
+ depth = 0
15
+ in_str = False
16
+ esc = False
17
+
18
+ for i in range(start, len(text)):
19
+ ch = text[i]
20
+ if in_str:
21
+ if esc:
22
+ esc = False
23
+ elif ch == "\\":
24
+ esc = True
25
+ elif ch == '"':
26
+ in_str = False
27
+ continue
28
+
29
+ if ch == '"':
30
+ in_str = True
31
+ continue
32
+ if ch == "{":
33
+ depth += 1
34
+ elif ch == "}":
35
+ depth -= 1
36
+ if depth == 0:
37
+ return text[start : i + 1]
38
+ return None
39
+
40
+
41
+ def extract_first_json_obj(s: str) -> Optional[Dict[str, Any]]:
42
+ cut = stop_at_complete_json(s) or s
43
+ start = cut.find("{")
44
+ end = cut.rfind("}")
45
+ if start == -1 or end == -1 or end <= start:
46
+ return None
47
+ cand = cut[start : end + 1].strip()
48
+ try:
49
+ return json.loads(cand)
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ # ----------------------------
55
+ # Post-processing: remove template sentence
56
+ # ----------------------------
57
+ _TEMPLATE_RE = re.compile(
58
+ r"\bthe input contains fallacious reasoning consistent with the predicted type\(s\)\b\.?",
59
+ flags=re.IGNORECASE,
60
+ )
61
+
62
+ def strip_template_sentence(text: str) -> str:
63
+ if not isinstance(text, str):
64
+ return ""
65
+ out = _TEMPLATE_RE.sub("", text)
66
+ out = out.replace("..", ".").strip()
67
+ out = re.sub(r"\s{2,}", " ", out)
68
+ out = re.sub(r"^\s*[\-–—:;,\.\s]+", "", out).strip()
69
+ return out
70
+
71
+
72
+ # ----------------------------
73
+ # Output sanitation / validation
74
+ # ----------------------------
75
+ def _clamp01(x: Any, default: float = 0.5) -> float:
76
+ try:
77
+ v = float(x)
78
+ except Exception:
79
+ return default
80
+ return 0.0 if v < 0.0 else (1.0 if v > 1.0 else v)
81
+
82
+
83
+ def _is_allowed_label(lbl: Any) -> bool:
84
+ return isinstance(lbl, str) and lbl in ALLOWED_LABELS and lbl != "none"
85
+
86
+
87
+ def sanitize_analyze_output(obj: Dict[str, Any], input_text: str) -> Dict[str, Any]:
88
+ has_fallacy = bool(obj.get("has_fallacy", False))
89
+ fallacies_in = obj.get("fallacies", [])
90
+ if not isinstance(fallacies_in, list):
91
+ fallacies_in = []
92
+
93
+ fallacies_out = []
94
+ for f in fallacies_in:
95
+ if not isinstance(f, dict):
96
+ continue
97
+ f_type = f.get("type")
98
+ if not _is_allowed_label(f_type):
99
+ continue
100
+
101
+ conf = _clamp01(f.get("confidence", 0.5))
102
+ conf = float(f"{conf:.2f}")
103
+
104
+ ev = f.get("evidence_quotes", [])
105
+ if not isinstance(ev, list):
106
+ ev = []
107
+
108
+ ev_clean: List[str] = []
109
+ for q in ev:
110
+ if not isinstance(q, str):
111
+ continue
112
+ qq = q.strip()
113
+ if not qq:
114
+ continue
115
+ if qq in input_text:
116
+ if len(qq) <= 240:
117
+ ev_clean.append(qq)
118
+ else:
119
+ short = qq[:240]
120
+ ev_clean.append(short if short in input_text else qq)
121
+
122
+ rationale = strip_template_sentence(str(f.get("rationale", "")).strip())
123
+
124
+ fallacies_out.append(
125
+ {
126
+ "type": f_type,
127
+ "confidence": conf,
128
+ "evidence_quotes": ev_clean[:3],
129
+ "rationale": rationale,
130
+ }
131
+ )
132
+
133
+ overall = strip_template_sentence(str(obj.get("overall_explanation", "")).strip())
134
+
135
+ if len(fallacies_out) == 0:
136
+ has_fallacy = False
137
+
138
+ return {
139
+ "has_fallacy": has_fallacy,
140
+ "fallacies": fallacies_out,
141
+ "overall_explanation": overall,
142
+ }
143
+
144
+
145
+ # ----------------------------
146
+ # Replace helpers
147
+ # ----------------------------
148
+ def occurrence_index(text: str, sub: str, occurrence: int) -> int:
149
+ if occurrence < 0:
150
+ return -1
151
+ start = 0
152
+ for _ in range(occurrence + 1):
153
+ idx = text.find(sub, start)
154
+ if idx == -1:
155
+ return -1
156
+ start = idx + max(1, len(sub))
157
+ return idx
158
+
159
+
160
+ def replace_nth(text: str, old: str, new: str, occurrence: int) -> Dict[str, Any]:
161
+ idx = occurrence_index(text, old, occurrence)
162
+ if idx == -1:
163
+ return {"ok": False, "error": "quote_not_found"}
164
+ return {
165
+ "ok": True,
166
+ "rewritten_text": text[:idx] + new + text[idx + len(old) :],
167
+ "start_char": idx,
168
+ "end_char": idx + len(new),
169
+ "old_start_char": idx,
170
+ "old_end_char": idx + len(old),
171
+ }