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 +1 -0
- indice/audio_transcriber.py +167 -0
- indice/auth.py +67 -0
- indice/cli.py +113 -0
- indice/config.json +30 -0
- indice/db.py +106 -0
- indice/doc_ingest.py +98 -0
- indice/dossier_analyze.py +89 -0
- indice/dossier_export.py +166 -0
- indice/dossiers.py +149 -0
- indice/indice_core.py +371 -0
- indice/packs.py +82 -0
- indice/profiles/cybersecurity/cti.json +144 -0
- indice/profiles/healthcare/consultation.json +219 -0
- indice/profiles/healthcare/pharmacovigilance.json +114 -0
- indice/profiles/investigation/ediscovery.json +92 -0
- indice/profiles/investigation/intercept.json +53 -0
- indice/profiles/investigation/social.json +172 -0
- indice/server.py +629 -0
- indice/static/index.html +1047 -0
- indice/static/indice_logo.png +0 -0
- indice/web_collect.py +148 -0
- indice-0.3.0.dist-info/METADATA +121 -0
- indice-0.3.0.dist-info/RECORD +29 -0
- indice-0.3.0.dist-info/WHEEL +5 -0
- indice-0.3.0.dist-info/entry_points.txt +2 -0
- indice-0.3.0.dist-info/licenses/LICENSE +201 -0
- indice-0.3.0.dist-info/licenses/NOTICE +4 -0
- indice-0.3.0.dist-info/top_level.txt +1 -0
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}
|