indice 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
indice/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Indice — investigation assistance engine (package)."""
@@ -0,0 +1,167 @@
1
+ """
2
+ audio_transcriber.py — audio acquisition + transcription for Indice Voice Analyzer.
3
+
4
+ Three sources, one async interface (`stream_transcriptions`):
5
+
6
+ * MICROPHONE — sounddevice capture -> energy VAD gating -> faster-whisper.
7
+ * FILE — transcribe a recorded file with faster-whisper.
8
+
9
+ CRITICAL FIX vs. the original spec: VAD and Whisper are synchronous, CPU-heavy
10
+ calls. Running them directly inside the asyncio event loop blocks every WebSocket on the
11
+ server. Here, every model call is dispatched with `asyncio.to_thread(...)` so the event
12
+ loop stays responsive.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import queue
19
+ import time
20
+ from typing import Any, AsyncGenerator, Dict, Optional
21
+
22
+
23
+ class AudioStreamTranscriber:
24
+ def __init__(self, config: Dict[str, Any], use_microphone: bool = False):
25
+ p = config.get("pipeline", {})
26
+ self.config = config
27
+ self.use_microphone = use_microphone
28
+ self.sample_rate = int(p.get("sample_rate", 16000))
29
+ self.model_size = p.get("model_size", "base.en")
30
+ self.device = p.get("device", "cpu")
31
+ self.compute_type = p.get("compute_type", "int8")
32
+ self.vad_threshold = float(p.get("vad_threshold", 0.5))
33
+ self.energy_threshold = float(p.get("energy_threshold", 0.01))
34
+ self.silence_frames_limit = int(p.get("silence_frames", 18))
35
+ self.chunk_samples = int(p.get("chunk_samples", 512))
36
+ self.sim_interval = float(p.get("simulation_interval_seconds", 1.1))
37
+ self.beam_size = int(p.get("beam_size", 1))
38
+ lang = p.get("language", "auto")
39
+ # None lets faster-whisper auto-detect the language (FR or EN) per source.
40
+ self.language = None if lang in ("", "auto", None) else lang
41
+
42
+ self.audio_queue: "queue.Queue" = queue.Queue()
43
+ self.whisper_model = None
44
+ self.vad_model = None
45
+
46
+ # ------------------------------------------------------------------ #
47
+ def _load_models(self) -> None:
48
+ """Blocking Whisper load -- always called via asyncio.to_thread (no torch)."""
49
+ from faster_whisper import WhisperModel # type: ignore
50
+
51
+ self.whisper_model = WhisperModel(
52
+ self.model_size, device=self.device, compute_type=self.compute_type
53
+ )
54
+
55
+ @staticmethod
56
+ def _now_ts() -> str:
57
+ return time.strftime("%H:%M:%S")
58
+
59
+ # ------------------------------------------------------------------ #
60
+ async def stream_transcriptions(self) -> AsyncGenerator[Dict[str, str], None]:
61
+ """Yield {'ts', 'text'} dicts as speech is transcribed (live capture)."""
62
+ async for item in self._stream_microphone():
63
+ yield item
64
+
65
+ # ------------------------------------------------------------------ #
66
+ def _audio_callback(self, indata, frames, time_info, status): # pragma: no cover
67
+ self.audio_queue.put(indata[:, 0].copy())
68
+
69
+ def _reset_segmenter(self) -> None:
70
+ self._speech_buffer: list = []
71
+ self._silence_frames: int = 0
72
+
73
+ async def _ingest_chunk(self, chunk, vad_fn, transcribe_fn):
74
+ """Feed one audio chunk through the VAD-gated segmenter.
75
+
76
+ `vad_fn(chunk) -> float` and `transcribe_fn(audio) -> str` are injected so this
77
+ state machine can be exercised with real models (mic path) or stubs (tests),
78
+ and both are dispatched OFF the event loop via asyncio.to_thread. Returns a
79
+ transcribed string when a speech segment completes, else None.
80
+ """
81
+ import numpy as np # type: ignore
82
+
83
+ # VAD inference OFF the event loop.
84
+ speech_prob = await asyncio.to_thread(vad_fn, chunk)
85
+
86
+ if speech_prob > self.vad_threshold:
87
+ self._speech_buffer.append(chunk)
88
+ self._silence_frames = 0
89
+ elif self._speech_buffer:
90
+ self._speech_buffer.append(chunk)
91
+ self._silence_frames += 1
92
+ if self._silence_frames > self.silence_frames_limit:
93
+ full_audio = np.concatenate(self._speech_buffer)
94
+ self._reset_segmenter()
95
+ # Transcription OFF the event loop.
96
+ text = await asyncio.to_thread(transcribe_fn, full_audio)
97
+ return text or None
98
+ return None
99
+
100
+ async def _stream_microphone(self) -> AsyncGenerator[Dict[str, str], None]: # pragma: no cover
101
+ import sounddevice as sd # type: ignore
102
+ import numpy as np # type: ignore
103
+
104
+ if self.whisper_model is None:
105
+ await asyncio.to_thread(self._load_models)
106
+
107
+ self._reset_segmenter()
108
+
109
+ def vad_fn(chunk):
110
+ # Energy-based VAD (RMS) -- deterministic, offline, no torch/Silero.
111
+ rms = float(np.sqrt(np.mean(np.square(chunk)))) if len(chunk) else 0.0
112
+ return 1.0 if rms >= self.energy_threshold else 0.0
113
+
114
+ with sd.InputStream(
115
+ samplerate=self.sample_rate,
116
+ channels=1,
117
+ dtype="float32",
118
+ blocksize=self.chunk_samples,
119
+ callback=self._audio_callback,
120
+ ):
121
+ while True:
122
+ await asyncio.sleep(0.01)
123
+ while not self.audio_queue.empty():
124
+ chunk = self.audio_queue.get_nowait()
125
+ text = await self._ingest_chunk(chunk, vad_fn, self._transcribe_array)
126
+ if text:
127
+ yield {"ts": self._now_ts(), "text": text}
128
+
129
+ # ------------------------------------------------------------------ #
130
+ def _transcribe_array(self, audio) -> str: # pragma: no cover
131
+ """Blocking Whisper call — always dispatched via asyncio.to_thread."""
132
+ segments, _ = self.whisper_model.transcribe(
133
+ audio, beam_size=self.beam_size, language=self.language, vad_filter=False
134
+ )
135
+ return " ".join(seg.text.strip() for seg in segments).strip()
136
+
137
+ async def transcribe_file(self, path: str) -> AsyncGenerator[Dict[str, str], None]:
138
+ """Transcribe a recorded file, yielding each segment as it is decoded."""
139
+ if self.whisper_model is None:
140
+ await asyncio.to_thread(self._load_models_whisper_only)
141
+
142
+ segments = await asyncio.to_thread(self._file_segments, path)
143
+ for seg in segments:
144
+ yield {"ts": self._fmt_seconds(seg["start"]), "text": seg["text"]}
145
+
146
+ def _load_models_whisper_only(self) -> None: # pragma: no cover
147
+ from faster_whisper import WhisperModel # type: ignore
148
+
149
+ self.whisper_model = WhisperModel(
150
+ self.model_size, device=self.device, compute_type=self.compute_type
151
+ )
152
+
153
+ def _file_segments(self, path: str): # pragma: no cover
154
+ segments, _ = self.whisper_model.transcribe(
155
+ path, beam_size=self.beam_size, language=self.language, vad_filter=True
156
+ )
157
+ out = []
158
+ for seg in segments:
159
+ txt = seg.text.strip()
160
+ if txt:
161
+ out.append({"start": seg.start, "text": txt})
162
+ return out
163
+
164
+ @staticmethod
165
+ def _fmt_seconds(s: float) -> str:
166
+ s = int(s or 0)
167
+ return f"{s // 60:02d}:{s % 60:02d}"
indice/auth.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ auth.py — token authentication for the Indice access gate.
3
+
4
+ One operator credential. The passphrase comes from the INDICE_PASSPHRASE env var when
5
+ set, otherwise from config.json. It is verified with a salted PBKDF2-HMAC-SHA256 hash
6
+ (per-process random salt) and a constant-time compare, issuing an opaque in-memory
7
+ bearer token with a TTL. Running on the shipped default is reported so the server can
8
+ warn the operator before the app is exposed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import os
16
+ import secrets
17
+ import time
18
+ from typing import Any, Dict, Optional
19
+
20
+ _DEFAULT_PASSPHRASE = "indice"
21
+ _PBKDF2_ROUNDS = 200_000
22
+
23
+
24
+ def _derive(passphrase: str, salt: bytes) -> bytes:
25
+ return hashlib.pbkdf2_hmac("sha256", (passphrase or "").encode("utf-8"), salt, _PBKDF2_ROUNDS)
26
+
27
+
28
+ class AuthManager:
29
+ def __init__(self, config: Dict[str, Any]):
30
+ auth = config.get("auth", {})
31
+ self.operator_id = os.environ.get("INDICE_OPERATOR", auth.get("operator_id", "OPERATOR"))
32
+ env_pass = os.environ.get("INDICE_PASSPHRASE")
33
+ config_pass = auth.get("passphrase", _DEFAULT_PASSPHRASE)
34
+ passphrase = env_pass if env_pass is not None else config_pass
35
+ # True when no INDICE_PASSPHRASE is set and config still ships the default.
36
+ self.using_default_credentials = env_pass is None and config_pass == _DEFAULT_PASSPHRASE
37
+ self._salt = secrets.token_bytes(16)
38
+ self._hash = _derive(passphrase, self._salt)
39
+ self.ttl = int(auth.get("token_ttl_seconds", 3600))
40
+ self._tokens: Dict[str, float] = {}
41
+
42
+ def login(self, operator_id: str, passphrase: str) -> Optional[str]:
43
+ """Return a bearer token on success, else None."""
44
+ ok_pass = hmac.compare_digest(self._hash, _derive(passphrase or "", self._salt))
45
+ ok_op = bool(operator_id) and operator_id.strip().upper().startswith(
46
+ self.operator_id.split("//")[0].strip().upper()
47
+ )
48
+ if ok_pass and ok_op:
49
+ token = secrets.token_urlsafe(32)
50
+ self._tokens[token] = time.time() + self.ttl
51
+ return token
52
+ return None
53
+
54
+ def validate(self, token: Optional[str]) -> bool:
55
+ if not token:
56
+ return False
57
+ expiry = self._tokens.get(token)
58
+ if expiry is None:
59
+ return False
60
+ if time.time() > expiry:
61
+ self._tokens.pop(token, None)
62
+ return False
63
+ return True
64
+
65
+ def logout(self, token: Optional[str]) -> None:
66
+ if token:
67
+ self._tokens.pop(token, None)
indice/cli.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ cli.py -- command-line entry point for Indice.
3
+
4
+ indice serve [--host H] [--port P] [--reload] [--open]
5
+ indice analyze <path> [--subject "a, b"] [--format md|pdf] [--out FILE]
6
+
7
+ `serve` launches the local web app; `analyze` runs the deterministic engine over a
8
+ file or folder and prints (or writes) a report -- no browser, no LLM.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+
16
+
17
+ def _cmd_serve(args):
18
+ import uvicorn
19
+ if args.host not in ("127.0.0.1", "localhost", ""):
20
+ print("WARNING: host %s exposes Indice beyond this machine. Set "
21
+ "INDICE_PASSPHRASE and put it behind a reverse proxy / TLS." % args.host,
22
+ file=sys.stderr)
23
+ if args.open:
24
+ import threading
25
+ import webbrowser
26
+ host = "127.0.0.1" if args.host in ("0.0.0.0", "") else args.host
27
+ url = "http://%s:%d" % (host, args.port)
28
+ threading.Timer(1.5, lambda: webbrowser.open(url)).start()
29
+ uvicorn.run("indice.server:app", host=args.host, port=args.port, reload=args.reload)
30
+
31
+
32
+ def _collect_files(path):
33
+ if os.path.isfile(path):
34
+ return [path]
35
+ out = []
36
+ if os.path.isdir(path):
37
+ for root, _dirs, names in os.walk(path):
38
+ for n in sorted(names):
39
+ out.append(os.path.join(root, n))
40
+ return out
41
+
42
+
43
+ def _cmd_analyze(args):
44
+ from . import doc_ingest, dossier_analyze, dossier_export
45
+ files = _collect_files(args.path)
46
+ if not files:
47
+ print("Aucun fichier a analyser dans : %s" % args.path, file=sys.stderr)
48
+ return 2
49
+ sources = []
50
+ for i, f in enumerate(files):
51
+ if not doc_ingest.is_text_like(f):
52
+ continue
53
+ try:
54
+ text = doc_ingest.extract_text(f)
55
+ except Exception as exc:
56
+ print(" (ignore) %s : %s" % (os.path.basename(f), exc), file=sys.stderr)
57
+ continue
58
+ sources.append({
59
+ "id": "s%d" % (i + 1), "title": os.path.basename(f), "url": "",
60
+ "published_date": "", "platform": "fichier local",
61
+ "orientation": "source", "raw_text": text,
62
+ })
63
+ if not sources:
64
+ print("Aucun texte exploitable (formats : txt, md, csv, pdf, docx, images).",
65
+ file=sys.stderr)
66
+ return 2
67
+ dossier = {"id": "CLI", "name": os.path.basename(os.path.abspath(args.path)) or "dossier",
68
+ "operator": "cli", "sources": sources}
69
+ analysis = dossier_analyze.analyze(dossier, args.subject or "")
70
+ if args.format == "pdf":
71
+ data = dossier_export.to_pdf(dossier, analysis)
72
+ out = args.out or (dossier["name"] + ".pdf")
73
+ with open(out, "wb") as fh:
74
+ fh.write(data)
75
+ print("Rapport PDF ecrit : %s" % out)
76
+ else:
77
+ md = dossier_export.to_markdown(dossier, analysis)
78
+ if args.out:
79
+ with open(args.out, "w", encoding="utf-8") as fh:
80
+ fh.write(md)
81
+ print("Rapport Markdown ecrit : %s" % args.out)
82
+ else:
83
+ print(md)
84
+ return 0
85
+
86
+
87
+ def main(argv=None):
88
+ p = argparse.ArgumentParser(prog="indice", description="Indice - du cas au dossier.")
89
+ sub = p.add_subparsers(dest="cmd")
90
+
91
+ ps = sub.add_parser("serve", help="Lancer l'interface web locale.")
92
+ ps.add_argument("--host", default="127.0.0.1")
93
+ ps.add_argument("--port", type=int, default=8000)
94
+ ps.add_argument("--reload", action="store_true")
95
+ ps.add_argument("--open", action="store_true", help="Ouvrir le navigateur au demarrage.")
96
+ ps.set_defaults(func=_cmd_serve)
97
+
98
+ pa = sub.add_parser("analyze", help="Analyser un fichier/dossier -> rapport.")
99
+ pa.add_argument("path")
100
+ pa.add_argument("--subject", default="", help="Sujet / selecteurs (separes par des virgules).")
101
+ pa.add_argument("--format", choices=["md", "pdf"], default="md")
102
+ pa.add_argument("--out", help="Fichier de sortie (sinon stdout pour md).")
103
+ pa.set_defaults(func=_cmd_analyze)
104
+
105
+ args = p.parse_args(argv)
106
+ if not getattr(args, "cmd", None):
107
+ p.print_help()
108
+ return 1
109
+ return args.func(args) or 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ sys.exit(main())
indice/config.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "auth": {
3
+ "operator_id": "ANALYSTE",
4
+ "passphrase": "indice",
5
+ "token_ttl_seconds": 3600,
6
+ "_comment": "Demo credential only. Override passphrase with env INDICE_PASSPHRASE. Never ship a real deployment with a plaintext passphrase in config."
7
+ },
8
+ "pipeline": {
9
+ "sample_rate": 16000,
10
+ "model_size": "base",
11
+ "device": "cpu",
12
+ "compute_type": "int8",
13
+ "vad_threshold": 0.5,
14
+ "silence_frames": 18,
15
+ "chunk_samples": 512,
16
+ "simulation_interval_seconds": 1.1,
17
+ "beam_size": 1,
18
+ "language": "auto",
19
+ "energy_threshold": 0.006
20
+ },
21
+ "default_settings": {
22
+ "vad": true,
23
+ "diarize": true,
24
+ "redact": false,
25
+ "autolock": true,
26
+ "language": "en",
27
+ "model_label": "faster-whisper · base.en",
28
+ "vad_threshold": 0.62
29
+ }
30
+ }
indice/db.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ db.py — SQLite persistence for Indice Voice Analyzer.
3
+
4
+ Stores analysis sessions (archive + per-session report), target dossiers, and the live
5
+ settings. Only settings are seeded; sessions and targets start empty and fill as real
6
+ analyses run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sqlite3
14
+ import threading
15
+ import time
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ DB_PATH = os.environ.get("INDICE_DB", os.path.join(os.environ.get("INDICE_DATA", os.getcwd()), "indice.db"))
19
+ _LOCK = threading.Lock()
20
+
21
+
22
+ def _connect() -> sqlite3.Connection:
23
+ conn = sqlite3.connect(DB_PATH)
24
+ conn.row_factory = sqlite3.Row
25
+ return conn
26
+
27
+
28
+ def init_db(config: Dict[str, Any]) -> None:
29
+ with _LOCK, _connect() as conn:
30
+ conn.executescript(
31
+ """
32
+ CREATE TABLE IF NOT EXISTS sessions (
33
+ id TEXT PRIMARY KEY, subject TEXT, channel TEXT, date TEXT,
34
+ dur TEXT, status TEXT, status_color TEXT, created_at REAL, report_json TEXT
35
+ );
36
+ CREATE TABLE IF NOT EXISTS targets (code TEXT PRIMARY KEY, data_json TEXT);
37
+ CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data_json TEXT);
38
+ """
39
+ )
40
+ # Settings are the only seeded row. Sessions/targets start EMPTY and fill as
41
+ # real analyses run -- no demo content.
42
+ if conn.execute("SELECT COUNT(*) AS n FROM settings").fetchone()["n"] == 0:
43
+ conn.execute("INSERT OR REPLACE INTO settings VALUES (1, ?)",
44
+ (json.dumps(config.get("default_settings", {})),))
45
+
46
+
47
+ def list_sessions(query: Optional[str] = None) -> List[Dict[str, Any]]:
48
+ with _LOCK, _connect() as conn:
49
+ rows = conn.execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
50
+ out = []
51
+ for r in rows:
52
+ if query:
53
+ q = query.lower()
54
+ if q not in r["subject"].lower() and q not in r["id"].lower():
55
+ continue
56
+ out.append({
57
+ "id": r["id"], "subject": r["subject"], "channel": r["channel"],
58
+ "date": r["date"], "dur": r["dur"], "status": r["status"],
59
+ "statusColor": r["status_color"],
60
+ })
61
+ return out
62
+
63
+
64
+ def get_session(session_id: str) -> Optional[Dict[str, Any]]:
65
+ with _LOCK, _connect() as conn:
66
+ r = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
67
+ if not r:
68
+ return None
69
+ report = json.loads(r["report_json"] or "{}")
70
+ return {
71
+ "id": r["id"], "subject": r["subject"], "channel": r["channel"],
72
+ "date": r["date"], "dur": r["dur"], "status": r["status"],
73
+ "statusColor": r["status_color"], "report": report,
74
+ }
75
+
76
+
77
+ def create_session(session: Dict[str, Any]) -> None:
78
+ with _LOCK, _connect() as conn:
79
+ conn.execute(
80
+ "INSERT OR REPLACE INTO sessions VALUES (?,?,?,?,?,?,?,?,?)",
81
+ (
82
+ session["id"], session.get("subject", "UNASSIGNED"),
83
+ session.get("channel", "VOICE · LIVE"), session.get("date", ""),
84
+ session.get("dur", "—"), session.get("status", "REVIEW"),
85
+ session.get("statusColor", "#d8b24a"), time.time(),
86
+ json.dumps(session.get("report", {})),
87
+ ),
88
+ )
89
+
90
+
91
+ def get_target(code: str) -> Optional[Dict[str, Any]]:
92
+ with _LOCK, _connect() as conn:
93
+ r = conn.execute("SELECT data_json FROM targets WHERE code = ?", (code,)).fetchone()
94
+ return json.loads(r["data_json"]) if r else None
95
+
96
+
97
+ def get_settings() -> Dict[str, Any]:
98
+ with _LOCK, _connect() as conn:
99
+ r = conn.execute("SELECT data_json FROM settings WHERE id = 1").fetchone()
100
+ return json.loads(r["data_json"]) if r else {}
101
+
102
+
103
+ def save_settings(settings: Dict[str, Any]) -> Dict[str, Any]:
104
+ with _LOCK, _connect() as conn:
105
+ conn.execute("INSERT OR REPLACE INTO settings VALUES (1, ?)", (json.dumps(settings),))
106
+ return settings
indice/doc_ingest.py ADDED
@@ -0,0 +1,98 @@
1
+ """
2
+ doc_ingest.py — extract analyzable text from a source file.
3
+
4
+ Server-side, offline, deterministic tooling (no LLM):
5
+ * text (.txt/.md/.csv/.log/.json) -> read directly
6
+ * PDF (.pdf) -> PyMuPDF text layer; if scanned, OCR pages (RapidOCR)
7
+ * DOCX (.docx) -> python-docx
8
+ * images (.png/.jpg/...) -> OCR (RapidOCR)
9
+
10
+ Optional libs are lazy-loaded with a clear error naming what to install.
11
+ """
12
+ import os
13
+ import tempfile
14
+
15
+ TEXT_EXT = {".txt", ".md", ".csv", ".log", ".json"}
16
+ DOC_EXT = {".pdf", ".docx"}
17
+ IMAGE_EXT = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"}
18
+
19
+ _OCR = None
20
+ _OCR_TRIED = False
21
+
22
+
23
+ def is_text_like(path: str) -> bool:
24
+ return os.path.splitext(path)[1].lower() in (TEXT_EXT | DOC_EXT | IMAGE_EXT)
25
+
26
+
27
+ def _ocr_engine():
28
+ """Lazy RapidOCR (ONNX, CPU, offline). None if not installed."""
29
+ global _OCR, _OCR_TRIED
30
+ if _OCR_TRIED:
31
+ return _OCR
32
+ _OCR_TRIED = True
33
+ try:
34
+ from rapidocr_onnxruntime import RapidOCR # type: ignore
35
+ _OCR = RapidOCR()
36
+ except Exception:
37
+ _OCR = None
38
+ return _OCR
39
+
40
+
41
+ def _ocr_file(path: str) -> str:
42
+ ocr = _ocr_engine()
43
+ if ocr is None:
44
+ raise RuntimeError("OCR non disponible : installer rapidocr-onnxruntime "
45
+ "(pip install rapidocr-onnxruntime) cote serveur")
46
+ result, _ = ocr(path)
47
+ return "\n".join(line[1] for line in (result or []))
48
+
49
+
50
+ def extract_text(path: str) -> str:
51
+ ext = os.path.splitext(path)[1].lower()
52
+ if ext in TEXT_EXT:
53
+ with open(path, encoding="utf-8", errors="ignore") as f:
54
+ return f.read()
55
+ if ext in IMAGE_EXT:
56
+ return _ocr_file(path)
57
+ if ext == ".pdf":
58
+ try:
59
+ import fitz # PyMuPDF
60
+ except Exception:
61
+ raise RuntimeError("PDF non lu : installer PyMuPDF (pip install pymupdf) cote serveur")
62
+ doc = fitz.open(path)
63
+ text = "\n".join(page.get_text() for page in doc)
64
+ if len(text.strip()) >= 20:
65
+ return text # has a real text layer
66
+ # scanned PDF -> OCR each page via RapidOCR
67
+ ocr = _ocr_engine()
68
+ if ocr is None:
69
+ if text.strip():
70
+ return text
71
+ raise RuntimeError("PDF scanne : installer rapidocr-onnxruntime pour l'OCR")
72
+ out = []
73
+ for page in doc:
74
+ pix = page.get_pixmap(dpi=200)
75
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
76
+ tmp.close()
77
+ try:
78
+ pix.save(tmp.name)
79
+ res, _ = ocr(tmp.name)
80
+ out.append("\n".join(line[1] for line in (res or [])))
81
+ finally:
82
+ try:
83
+ os.unlink(tmp.name)
84
+ except OSError:
85
+ pass
86
+ return "\n".join(out)
87
+ if ext == ".docx":
88
+ try:
89
+ import docx # python-docx
90
+ except Exception:
91
+ raise RuntimeError("DOCX non lu : installer python-docx cote serveur")
92
+ d = docx.Document(path)
93
+ return "\n".join(p.text for p in d.paragraphs)
94
+ raise RuntimeError("Format non supporte : " + ext)
95
+
96
+
97
+ def to_lines(text: str):
98
+ return [ln.strip() for ln in text.splitlines() if ln.strip()]
@@ -0,0 +1,89 @@
1
+ """
2
+ dossier_analyze.py -- deterministic analysis of a constituted dossier (no LLM).
3
+
4
+ Given a dossier's saved sources, it:
5
+ * extracts the dated passages mentioning the subject / selectors -- the
6
+ "before/now" juxtaposition the analyst reads to judge coherence, and
7
+ * runs the open-extraction engine over all source texts for entities.
8
+
9
+ Indice surfaces and aligns; the human judges.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from typing import Any, Dict, List
15
+
16
+ _SENT = re.compile(r"[^.!?\n]+[.!?]?")
17
+
18
+
19
+ def _selectors(dossier: Dict[str, Any], extra: str) -> List[str]:
20
+ raw = (dossier.get("name", "") or "") + " " + (extra or "")
21
+ out, seen = [], set()
22
+ for t in re.split(r"[,\s]+", raw):
23
+ t = t.strip()
24
+ if len(t) >= 3 and t.lower() not in seen:
25
+ seen.add(t.lower())
26
+ out.append(t)
27
+ return out
28
+
29
+
30
+ def _passages_for(text: str, selectors: List[str]) -> List[Dict[str, Any]]:
31
+ out = []
32
+ for m in _SENT.finditer(text or ""):
33
+ s = m.group().strip()
34
+ if not s:
35
+ continue
36
+ low = s.lower()
37
+ hit = sorted({sel for sel in selectors if sel.lower() in low})
38
+ if hit:
39
+ out.append({"passage": s[:400], "matched": hit})
40
+ return out
41
+
42
+
43
+ def _date_key(d: str) -> str:
44
+ return (d or "").strip() or "9999" # empty dates sort last
45
+
46
+
47
+ def analyze(dossier: Dict[str, Any], extra_selectors: str = "") -> Dict[str, Any]:
48
+ selectors = _selectors(dossier, extra_selectors)
49
+ sources = dossier.get("sources", [])
50
+
51
+ # 1) dated juxtaposition passages (oldest first = "avant" -> "maintenant")
52
+ passages: List[Dict[str, Any]] = []
53
+ for src in sources:
54
+ for p in _passages_for(src.get("raw_text", ""), selectors):
55
+ passages.append({
56
+ "date": src.get("published_date", ""),
57
+ "title": src.get("title", ""),
58
+ "url": src.get("url", ""),
59
+ "platform": src.get("platform", ""),
60
+ "orientation": src.get("orientation", "unknown"),
61
+ "passage": p["passage"],
62
+ "matched": p["matched"],
63
+ })
64
+ passages.sort(key=lambda x: _date_key(x["date"]))
65
+
66
+ # 2) entities via the open-extraction engine (deterministic, no spaCy)
67
+ entities: List[Dict[str, Any]] = []
68
+ try:
69
+ from .indice_core import CorrelationEngine
70
+ from . import packs
71
+ try:
72
+ profile = packs.load_domains(["justice"])
73
+ except Exception:
74
+ profile = {"patterns": [], "lexicon": []}
75
+ engine = CorrelationEngine(profile, watchlist=selectors, use_ner=False)
76
+ for src in sources:
77
+ for line in (src.get("raw_text", "") or "").splitlines():
78
+ line = line.strip()
79
+ if line:
80
+ engine.analyze_utterance(line)
81
+ entities = [{"name": k, "category": v.get("category"), "count": v.get("count")}
82
+ for k, v in engine.entities.items()]
83
+ entities.sort(key=lambda e: -(e.get("count") or 0))
84
+ entities = entities[:40]
85
+ except Exception:
86
+ entities = []
87
+
88
+ return {"selectors": selectors, "sources": len(sources),
89
+ "passages": passages, "entities": entities}