baldanders 0.1.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.
- baldanders/__init__.py +3 -0
- baldanders/cli.py +365 -0
- baldanders/config.py +133 -0
- baldanders/history.py +127 -0
- baldanders/mapping.py +111 -0
- baldanders/model_manager.py +93 -0
- baldanders/providers/__init__.py +1 -0
- baldanders/providers/anthropic.py +57 -0
- baldanders/providers/base.py +96 -0
- baldanders/providers/claude_code.py +101 -0
- baldanders/providers/codex.py +133 -0
- baldanders/providers/grok.py +88 -0
- baldanders/providers/openai.py +56 -0
- baldanders/scrubber/__init__.py +1 -0
- baldanders/scrubber/engine.py +146 -0
- baldanders/scrubber/ner.py +291 -0
- baldanders/scrubber/regex.py +575 -0
- baldanders-0.1.0.dist-info/METADATA +205 -0
- baldanders-0.1.0.dist-info/RECORD +22 -0
- baldanders-0.1.0.dist-info/WHEEL +4 -0
- baldanders-0.1.0.dist-info/entry_points.txt +2 -0
- baldanders-0.1.0.dist-info/licenses/LICENSE +21 -0
baldanders/__init__.py
ADDED
baldanders/cli.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Baldanders CLI — personal-data firewall for cloud LLMs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TransferSpeedColumn
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from baldanders import __version__
|
|
17
|
+
from baldanders.config import Config, create_default_config, load_config
|
|
18
|
+
from baldanders.history import (
|
|
19
|
+
delete_session, list_sessions, load_session, new_session_id, save_session,
|
|
20
|
+
)
|
|
21
|
+
from baldanders.mapping import SessionMap
|
|
22
|
+
from baldanders.model_manager import download_model, is_downloaded, model_dir
|
|
23
|
+
from baldanders.providers.base import BaseProvider
|
|
24
|
+
from baldanders.scrubber.engine import ScrubEngine
|
|
25
|
+
|
|
26
|
+
console = Console(highlight=False)
|
|
27
|
+
|
|
28
|
+
PROVIDERS = ["claude", "chatgpt", "grok", "anthropic-api", "openai-api", "deepseek", "groq"]
|
|
29
|
+
|
|
30
|
+
_OPENAI_COMPATIBLE = {
|
|
31
|
+
# name: (base url, default model, api key env var)
|
|
32
|
+
"openai-api": ("https://api.openai.com/v1", "gpt-4o", "OPENAI_API_KEY"),
|
|
33
|
+
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat", "DEEPSEEK_API_KEY"),
|
|
34
|
+
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", "GROQ_API_KEY"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
HELP_TEXT = (
|
|
38
|
+
"/paste multi-line input (end with a single . on its own line)\n"
|
|
39
|
+
"/map show which token stands for which real value\n"
|
|
40
|
+
"/raw toggle scrubbing off/on for the next messages\n"
|
|
41
|
+
"/clear start a fresh conversation (keeps the token map)\n"
|
|
42
|
+
"/help show this help\n"
|
|
43
|
+
"/quit exit"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_provider(name: str, api_key: str | None, base_url: str | None) -> BaseProvider:
|
|
48
|
+
# Consumer-auth providers (no API key needed)
|
|
49
|
+
if name == "claude":
|
|
50
|
+
from baldanders.providers.claude_code import ClaudeCodeProvider
|
|
51
|
+
return ClaudeCodeProvider()
|
|
52
|
+
if name == "chatgpt":
|
|
53
|
+
from baldanders.providers.codex import CodexProvider
|
|
54
|
+
return CodexProvider()
|
|
55
|
+
if name == "grok":
|
|
56
|
+
from baldanders.providers.grok import GrokCLIProvider
|
|
57
|
+
return GrokCLIProvider()
|
|
58
|
+
|
|
59
|
+
# API-key providers
|
|
60
|
+
if name == "anthropic-api":
|
|
61
|
+
from baldanders.providers.anthropic import AnthropicProvider
|
|
62
|
+
return AnthropicProvider(api_key=api_key)
|
|
63
|
+
if name in _OPENAI_COMPATIBLE:
|
|
64
|
+
from baldanders.providers.openai import OpenAIProvider
|
|
65
|
+
url, model, key_env = _OPENAI_COMPATIBLE[name]
|
|
66
|
+
provider = OpenAIProvider(api_key=api_key, base_url=base_url or url, default_model=model, key_env=key_env)
|
|
67
|
+
provider.name = name
|
|
68
|
+
return provider
|
|
69
|
+
raise click.BadParameter(f"Unknown provider: {name}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _build_engine(session_map: SessionMap, config: Config) -> ScrubEngine:
|
|
73
|
+
md = model_dir() if is_downloaded() else None
|
|
74
|
+
engine = ScrubEngine(session_map, model_dir=md, config=config)
|
|
75
|
+
for problem in engine.config_errors:
|
|
76
|
+
console.print(f"[yellow]Config:[/yellow] {problem}")
|
|
77
|
+
return engine
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_input(raw_mode: bool) -> str | None:
|
|
81
|
+
"""Read one message from the terminal. Returns None on EOF/Ctrl-C."""
|
|
82
|
+
prompt = "[bold red]Raw:[/bold red] " if raw_mode else "[bold blue]You:[/bold blue] "
|
|
83
|
+
try:
|
|
84
|
+
first_line = console.input(prompt)
|
|
85
|
+
except (EOFError, KeyboardInterrupt):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
if first_line.strip().lower() == "/paste":
|
|
89
|
+
console.print("[dim] Paste your text. Type a single . on a line to send.[/dim]")
|
|
90
|
+
lines: list[str] = []
|
|
91
|
+
while True:
|
|
92
|
+
try:
|
|
93
|
+
line = console.input("[dim]...:[/dim] ")
|
|
94
|
+
except (EOFError, KeyboardInterrupt):
|
|
95
|
+
break
|
|
96
|
+
if line.strip() == ".":
|
|
97
|
+
break
|
|
98
|
+
lines.append(line)
|
|
99
|
+
return "\n".join(lines).strip()
|
|
100
|
+
|
|
101
|
+
lines = [first_line]
|
|
102
|
+
if first_line.rstrip().endswith(("(", "{", "[", ",", "\\", ";")):
|
|
103
|
+
console.print("[dim] (multi-line: empty line to send)[/dim]")
|
|
104
|
+
while True:
|
|
105
|
+
try:
|
|
106
|
+
line = console.input("[dim]...:[/dim] ")
|
|
107
|
+
except (EOFError, KeyboardInterrupt):
|
|
108
|
+
break
|
|
109
|
+
if line.strip() == "":
|
|
110
|
+
break
|
|
111
|
+
lines.append(line)
|
|
112
|
+
return "\n".join(lines).strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _repl(
|
|
116
|
+
provider: BaseProvider,
|
|
117
|
+
engine: ScrubEngine,
|
|
118
|
+
session_map: SessionMap,
|
|
119
|
+
messages: list[dict[str, str]],
|
|
120
|
+
session_id: str,
|
|
121
|
+
model: str | None,
|
|
122
|
+
verbose: bool = False,
|
|
123
|
+
save: bool = True,
|
|
124
|
+
) -> None:
|
|
125
|
+
ner = engine.ner_status
|
|
126
|
+
ner_line = "[green]loaded[/green]" if ner == "loaded" else f"[yellow]regex-only[/yellow] [dim]({ner})[/dim]"
|
|
127
|
+
console.print()
|
|
128
|
+
console.print(Panel(
|
|
129
|
+
f"[bold]Baldanders {__version__}[/bold] — your personal data never leaves this machine.\n"
|
|
130
|
+
f"Provider: [bold]{provider.name}[/bold] Session: [dim]{session_id}{'' if save else ' (not saved)'}[/dim]\n"
|
|
131
|
+
f"Name/address model: {ner_line}\n"
|
|
132
|
+
"Commands: [dim]/paste /map /raw /clear /help /quit[/dim]",
|
|
133
|
+
border_style="blue",
|
|
134
|
+
))
|
|
135
|
+
console.print()
|
|
136
|
+
|
|
137
|
+
raw_mode = False
|
|
138
|
+
|
|
139
|
+
def persist() -> None:
|
|
140
|
+
if save:
|
|
141
|
+
save_session(session_id, session_map, messages, provider_state=provider.get_state())
|
|
142
|
+
|
|
143
|
+
while True:
|
|
144
|
+
raw = _read_input(raw_mode)
|
|
145
|
+
if raw is None:
|
|
146
|
+
console.print("\n[dim]Bye.[/dim]")
|
|
147
|
+
break
|
|
148
|
+
if not raw:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
command = raw.lower()
|
|
152
|
+
if command in ("/quit", "/exit"):
|
|
153
|
+
console.print("[dim]Bye.[/dim]")
|
|
154
|
+
break
|
|
155
|
+
if command == "/help":
|
|
156
|
+
console.print(Text(HELP_TEXT, style="dim"))
|
|
157
|
+
console.print()
|
|
158
|
+
continue
|
|
159
|
+
if command == "/map":
|
|
160
|
+
mapping = session_map.get_map()
|
|
161
|
+
if not mapping:
|
|
162
|
+
console.print("[dim]No personal data mapped yet.[/dim]")
|
|
163
|
+
for token, real in mapping.items():
|
|
164
|
+
line = Text(" ")
|
|
165
|
+
line.append(token)
|
|
166
|
+
line.append(" → ")
|
|
167
|
+
line.append(real, style="bold")
|
|
168
|
+
console.print(line)
|
|
169
|
+
console.print()
|
|
170
|
+
continue
|
|
171
|
+
if command == "/raw":
|
|
172
|
+
raw_mode = not raw_mode
|
|
173
|
+
if raw_mode:
|
|
174
|
+
console.print("[bold red]Raw mode ON — messages are sent exactly as typed, with no scrubbing.[/bold red]\n")
|
|
175
|
+
else:
|
|
176
|
+
console.print("[dim]Raw mode OFF — scrubbing enabled.[/dim]\n")
|
|
177
|
+
continue
|
|
178
|
+
if command == "/clear":
|
|
179
|
+
messages.clear()
|
|
180
|
+
provider.reset()
|
|
181
|
+
persist()
|
|
182
|
+
console.print("[dim]Conversation cleared.[/dim]\n")
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Scrub personal data (unless raw mode)
|
|
186
|
+
if raw_mode:
|
|
187
|
+
scrubbed = raw
|
|
188
|
+
else:
|
|
189
|
+
scrubbed = engine.scrub(raw)
|
|
190
|
+
if scrubbed != raw:
|
|
191
|
+
diff = Text(" [scrubbed] ", style="dim yellow")
|
|
192
|
+
diff.append(scrubbed, style="yellow")
|
|
193
|
+
console.print(diff)
|
|
194
|
+
|
|
195
|
+
if verbose:
|
|
196
|
+
console.print()
|
|
197
|
+
console.print(Panel(
|
|
198
|
+
Text(scrubbed),
|
|
199
|
+
title="[bold red]SENT TO AI (exactly this, nothing else)[/bold red]",
|
|
200
|
+
border_style="red",
|
|
201
|
+
))
|
|
202
|
+
console.print()
|
|
203
|
+
|
|
204
|
+
messages.append({"role": "user", "content": scrubbed})
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
with console.status("[dim]Thinking...[/dim]"):
|
|
208
|
+
response = await provider.send(messages, model=model)
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
console.print("[dim]Cancelled.[/dim]\n")
|
|
211
|
+
messages.pop()
|
|
212
|
+
continue
|
|
213
|
+
except Exception as e:
|
|
214
|
+
console.print(Text.assemble(("Error: ", "red"), str(e)))
|
|
215
|
+
console.print()
|
|
216
|
+
messages.pop()
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
if verbose:
|
|
220
|
+
console.print(Panel(
|
|
221
|
+
Text(response),
|
|
222
|
+
title="[bold cyan]RAW RESPONSE FROM AI[/bold cyan]",
|
|
223
|
+
border_style="cyan",
|
|
224
|
+
))
|
|
225
|
+
|
|
226
|
+
messages.append({"role": "assistant", "content": response})
|
|
227
|
+
|
|
228
|
+
# Restore personal data in the response
|
|
229
|
+
rehydrated = session_map.rehydrate(response)
|
|
230
|
+
|
|
231
|
+
console.print()
|
|
232
|
+
console.print("[bold green]Assistant:[/bold green]")
|
|
233
|
+
console.print(Markdown(rehydrated))
|
|
234
|
+
console.print()
|
|
235
|
+
|
|
236
|
+
persist()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@click.group(invoke_without_command=True)
|
|
240
|
+
@click.option("--provider", "-p", default="claude", type=click.Choice(PROVIDERS),
|
|
241
|
+
help="LLM backend. claude/chatgpt/grok use your subscription (no API key).")
|
|
242
|
+
@click.option("--model", "-m", default=None, help="Model name override.")
|
|
243
|
+
@click.option("--api-key", default=None, envvar="BALDANDERS_API_KEY",
|
|
244
|
+
help="API key (only needed for direct API providers).")
|
|
245
|
+
@click.option("--base-url", default=None, help="Custom API base URL (OpenAI-compatible providers).")
|
|
246
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show exact text sent to and received from AI.")
|
|
247
|
+
@click.option("--resume", "-r", default=None, help="Resume a previous session by ID.")
|
|
248
|
+
@click.option("--no-save", is_flag=True, help="Don't write this session to disk.")
|
|
249
|
+
@click.version_option(__version__, prog_name="baldanders")
|
|
250
|
+
@click.pass_context
|
|
251
|
+
def main(ctx: click.Context, provider: str, model: str | None, api_key: str | None,
|
|
252
|
+
base_url: str | None, verbose: bool, resume: str | None, no_save: bool) -> None:
|
|
253
|
+
"""Baldanders — local personal-data firewall for cloud LLMs."""
|
|
254
|
+
if ctx.invoked_subcommand is not None:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
llm = _build_provider(provider, api_key, base_url)
|
|
259
|
+
except (ValueError, RuntimeError, click.BadParameter) as e:
|
|
260
|
+
console.print(Text(str(e), style="red"))
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
|
|
263
|
+
create_default_config()
|
|
264
|
+
config = load_config()
|
|
265
|
+
save = config.save_sessions and not no_save
|
|
266
|
+
|
|
267
|
+
if resume:
|
|
268
|
+
loaded = load_session(resume)
|
|
269
|
+
if not loaded:
|
|
270
|
+
console.print(Text(f"Session '{resume}' not found.", style="red"))
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
session_map, messages, provider_state = loaded
|
|
273
|
+
llm.set_state(provider_state)
|
|
274
|
+
session_id = resume
|
|
275
|
+
console.print(f"[dim]Resumed session {session_id} ({len(messages)} messages)[/dim]")
|
|
276
|
+
else:
|
|
277
|
+
session_map = SessionMap()
|
|
278
|
+
messages = []
|
|
279
|
+
session_id = new_session_id()
|
|
280
|
+
|
|
281
|
+
engine = _build_engine(session_map, config)
|
|
282
|
+
|
|
283
|
+
async def run() -> None:
|
|
284
|
+
try:
|
|
285
|
+
await _repl(llm, engine, session_map, messages, session_id, model, verbose=verbose, save=save)
|
|
286
|
+
finally:
|
|
287
|
+
if save and messages:
|
|
288
|
+
save_session(session_id, session_map, messages, provider_state=llm.get_state())
|
|
289
|
+
await llm.aclose()
|
|
290
|
+
|
|
291
|
+
asyncio.run(run())
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@main.command()
|
|
295
|
+
def download() -> None:
|
|
296
|
+
"""Download the multilingual name-and-address model (~320 MB)."""
|
|
297
|
+
try:
|
|
298
|
+
import gliner # noqa: F401
|
|
299
|
+
except ImportError:
|
|
300
|
+
console.print('[yellow]Note:[/yellow] the model needs the NER extra: pip install "baldanders[ner]"')
|
|
301
|
+
|
|
302
|
+
with Progress(
|
|
303
|
+
TextColumn("[dim]{task.description}"), BarColumn(), DownloadColumn(), TransferSpeedColumn(),
|
|
304
|
+
console=console,
|
|
305
|
+
) as progress:
|
|
306
|
+
tasks: dict[str, int] = {}
|
|
307
|
+
|
|
308
|
+
def on_progress(name: str, done: int, total: int | None) -> None:
|
|
309
|
+
if name not in tasks:
|
|
310
|
+
tasks[name] = progress.add_task(name, total=total)
|
|
311
|
+
progress.update(tasks[name], completed=done)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
path = asyncio.run(download_model(progress=on_progress))
|
|
315
|
+
except Exception as e:
|
|
316
|
+
console.print(Text(f"Download failed: {e}", style="red"))
|
|
317
|
+
sys.exit(1)
|
|
318
|
+
console.print(f"[green]Model ready in {path}[/green]")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@main.command()
|
|
322
|
+
@click.argument("text", required=False)
|
|
323
|
+
@click.option("--show-map", is_flag=True, help="Also print the token → value mapping (to stderr).")
|
|
324
|
+
@click.option("--json", "as_json", is_flag=True, help="Print detected spans as JSON instead.")
|
|
325
|
+
def scrub(text: str | None, show_map: bool, as_json: bool) -> None:
|
|
326
|
+
"""Scrub TEXT (or stdin) and print the result. Nothing is sent anywhere."""
|
|
327
|
+
if text is None:
|
|
328
|
+
text = sys.stdin.read()
|
|
329
|
+
session_map = SessionMap()
|
|
330
|
+
engine = _build_engine(session_map, load_config())
|
|
331
|
+
if as_json:
|
|
332
|
+
spans = engine.detect(text)
|
|
333
|
+
click.echo(json.dumps(
|
|
334
|
+
[{"start": s.start, "end": s.end, "type": s.entity_type, "text": s.text} for s in spans],
|
|
335
|
+
ensure_ascii=False, indent=2,
|
|
336
|
+
))
|
|
337
|
+
return
|
|
338
|
+
click.echo(engine.scrub(text), nl=not text.endswith("\n"))
|
|
339
|
+
if show_map:
|
|
340
|
+
for token, real in session_map.get_map().items():
|
|
341
|
+
click.echo(f"{token} → {real}", err=True)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@main.command()
|
|
345
|
+
@click.option("--delete", "delete_id", default=None, metavar="ID", help="Delete a saved session.")
|
|
346
|
+
def sessions(delete_id: str | None) -> None:
|
|
347
|
+
"""List saved sessions."""
|
|
348
|
+
if delete_id:
|
|
349
|
+
if delete_session(delete_id):
|
|
350
|
+
console.print(f"[dim]Deleted session {delete_id}.[/dim]")
|
|
351
|
+
else:
|
|
352
|
+
console.print(Text(f"Session '{delete_id}' not found.", style="red"))
|
|
353
|
+
return
|
|
354
|
+
saved = list_sessions()
|
|
355
|
+
if not saved:
|
|
356
|
+
console.print("[dim]No saved sessions.[/dim]")
|
|
357
|
+
return
|
|
358
|
+
for s in saved:
|
|
359
|
+
provider = f" {s['provider']}" if s.get("provider") else ""
|
|
360
|
+
console.print(f" [bold]{s['id']}[/bold] {s['message_count']} messages{provider} [dim]{s['updated_at']}[/dim]")
|
|
361
|
+
console.print("\n[dim]Resume with: baldanders --resume <id>[/dim]")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
main()
|
baldanders/config.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""User configuration — allowlist, denylist, custom patterns, regions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import locale
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
BASE_DIR = Path(os.environ.get("BALDANDERS_HOME", Path.home() / ".baldanders"))
|
|
15
|
+
DEFAULT_CONFIG_PATH = BASE_DIR / "config.yaml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Config:
|
|
20
|
+
# Terms that should NEVER be scrubbed (S3 buckets, table names, etc.)
|
|
21
|
+
allowlist: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
# Terms that should ALWAYS be scrubbed (custom sensitive values)
|
|
24
|
+
denylist: dict[str, str] = field(default_factory=dict) # value -> entity_type
|
|
25
|
+
|
|
26
|
+
# Extra regex patterns: entity name -> pattern string
|
|
27
|
+
custom_patterns: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
# Countries (ISO 3166 alpha-2) whose local phone formats to recognise.
|
|
30
|
+
# Numbers written with a +country prefix are always recognised.
|
|
31
|
+
regions: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
# Also scrub checksum-valid ID numbers that have no keyword ("DNI", "BSN", ...) nearby.
|
|
34
|
+
paranoid: bool = False
|
|
35
|
+
|
|
36
|
+
# NER confidence threshold (0-1) for all entity types. None = tuned per-type defaults.
|
|
37
|
+
ner_threshold: float | None = None
|
|
38
|
+
|
|
39
|
+
# Write conversations and the token mapping to ~/.baldanders/sessions.
|
|
40
|
+
save_sessions: bool = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def system_regions() -> list[str]:
|
|
44
|
+
"""Best guess at the user's country from the OS locale (e.g. es_AR -> AR)."""
|
|
45
|
+
candidates = [os.environ.get(v, "") for v in ("LC_ALL", "LC_TELEPHONE", "LANG")]
|
|
46
|
+
try:
|
|
47
|
+
candidates.append(locale.getlocale()[0] or "")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
for value in candidates:
|
|
51
|
+
m = re.match(r"^[a-z]{2,3}[_-]([A-Z]{2})\b", value)
|
|
52
|
+
if m:
|
|
53
|
+
return [m.group(1)]
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
|
58
|
+
"""Load config from YAML file. Returns defaults if file doesn't exist."""
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return Config(regions=system_regions())
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
import yaml
|
|
64
|
+
except ImportError:
|
|
65
|
+
log.warning("PyYAML not installed — ignoring config file. pip install pyyaml")
|
|
66
|
+
return Config(regions=system_regions())
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
70
|
+
except Exception:
|
|
71
|
+
log.warning("Failed to parse config at %s", path, exc_info=True)
|
|
72
|
+
return Config(regions=system_regions())
|
|
73
|
+
|
|
74
|
+
regions = [str(r).upper() for r in (data.get("regions") or [])] or system_regions()
|
|
75
|
+
return Config(
|
|
76
|
+
allowlist=[str(t) for t in (data.get("allowlist") or [])],
|
|
77
|
+
denylist={str(k): str(v).upper() for k, v in (data.get("denylist") or {}).items()},
|
|
78
|
+
custom_patterns={str(k): str(v) for k, v in (data.get("custom_patterns") or {}).items()},
|
|
79
|
+
regions=regions,
|
|
80
|
+
paranoid=bool(data.get("paranoid", False)),
|
|
81
|
+
ner_threshold=float(data["ner_threshold"]) if data.get("ner_threshold") is not None else None,
|
|
82
|
+
save_sessions=bool(data.get("save_sessions", True)),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_default_config(path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
87
|
+
"""Create a starter config file with examples."""
|
|
88
|
+
if path.exists():
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
path.write_text("""\
|
|
93
|
+
# Baldanders configuration
|
|
94
|
+
# ========================
|
|
95
|
+
|
|
96
|
+
# Countries whose local phone formats to recognise (ISO codes like AR, US, DE, IN).
|
|
97
|
+
# Numbers with a +country prefix are always caught. Empty = guess from your OS locale.
|
|
98
|
+
regions: []
|
|
99
|
+
# - AR
|
|
100
|
+
# - US
|
|
101
|
+
|
|
102
|
+
# Also scrub checksum-valid ID numbers (BSN, PESEL, Steuer-ID, ...) even when
|
|
103
|
+
# no keyword like "DNI" or "BSN" is nearby. Catches more, but plain numbers
|
|
104
|
+
# in code or maths may get tokenised too.
|
|
105
|
+
paranoid: false
|
|
106
|
+
|
|
107
|
+
# Name/address model confidence (0-1). Lower catches more, with more false
|
|
108
|
+
# positives. Leave unset for tuned defaults (names 0.7, addresses 0.4).
|
|
109
|
+
# ner_threshold: 0.6
|
|
110
|
+
|
|
111
|
+
# Save conversations (including the real values behind each token) to
|
|
112
|
+
# ~/.baldanders/sessions so you can --resume them. Files are readable only by you.
|
|
113
|
+
save_sessions: true
|
|
114
|
+
|
|
115
|
+
# Terms that should NEVER be scrubbed.
|
|
116
|
+
# Use this for internal infrastructure names, bucket names, table names, etc.
|
|
117
|
+
allowlist:
|
|
118
|
+
# - verify-internal-data-prod
|
|
119
|
+
# - my-s3-bucket-name
|
|
120
|
+
|
|
121
|
+
# Terms that should ALWAYS be scrubbed (case-insensitive), mapped to an entity type.
|
|
122
|
+
# Use this for values the detectors might miss.
|
|
123
|
+
denylist: {}
|
|
124
|
+
# "Acme Corp Internal": ORGANIZATION
|
|
125
|
+
# "Project Falcon": PROJECT
|
|
126
|
+
|
|
127
|
+
# Custom regex patterns. The whole match is scrubbed; if the pattern has a
|
|
128
|
+
# capturing group, only the first group is.
|
|
129
|
+
custom_patterns: {}
|
|
130
|
+
# customer_id: "CID-\\\\d{8}"
|
|
131
|
+
# portfolio_ref: "PF/\\\\d{4}/\\\\d{6}"
|
|
132
|
+
""", encoding="utf-8")
|
|
133
|
+
log.info("Created default config at %s", path)
|
baldanders/history.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Session history — persists token mapping and conversation across restarts.
|
|
2
|
+
|
|
3
|
+
Session files contain the real values behind every token, so they are written
|
|
4
|
+
atomically with owner-only permissions (0600, directory 0700).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from baldanders.config import BASE_DIR
|
|
19
|
+
from baldanders.mapping import SessionMap
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
DEFAULT_HISTORY_DIR = BASE_DIR / "sessions"
|
|
24
|
+
|
|
25
|
+
_SESSION_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _session_path(session_id: str, history_dir: Path = DEFAULT_HISTORY_DIR) -> Path:
|
|
29
|
+
if not _SESSION_ID.match(session_id):
|
|
30
|
+
raise ValueError(f"Invalid session id: {session_id!r}")
|
|
31
|
+
return history_dir / f"{session_id}.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ensure_private_dir(path: Path) -> None:
|
|
35
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
try:
|
|
37
|
+
os.chmod(path, 0o700)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_session(
|
|
43
|
+
session_id: str,
|
|
44
|
+
session_map: SessionMap,
|
|
45
|
+
messages: list[dict[str, str]],
|
|
46
|
+
history_dir: Path = DEFAULT_HISTORY_DIR,
|
|
47
|
+
provider_state: dict[str, Any] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Save session state to disk."""
|
|
50
|
+
_ensure_private_dir(history_dir)
|
|
51
|
+
path = _session_path(session_id, history_dir)
|
|
52
|
+
data = {
|
|
53
|
+
"session_id": session_id,
|
|
54
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
55
|
+
"mapping": session_map.to_dict(),
|
|
56
|
+
"messages": messages,
|
|
57
|
+
"provider_state": provider_state or {},
|
|
58
|
+
}
|
|
59
|
+
# mkstemp creates the file with 0600 permissions.
|
|
60
|
+
fd, tmp = tempfile.mkstemp(dir=history_dir, prefix=".tmp-", suffix=".json")
|
|
61
|
+
try:
|
|
62
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
63
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
64
|
+
os.replace(tmp, path)
|
|
65
|
+
except BaseException:
|
|
66
|
+
Path(tmp).unlink(missing_ok=True)
|
|
67
|
+
raise
|
|
68
|
+
log.debug("Session saved to %s", path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_session(
|
|
72
|
+
session_id: str,
|
|
73
|
+
history_dir: Path = DEFAULT_HISTORY_DIR,
|
|
74
|
+
) -> tuple[SessionMap, list[dict[str, str]], dict[str, Any]] | None:
|
|
75
|
+
"""Load a saved session. Returns (session_map, messages, provider_state) or None."""
|
|
76
|
+
try:
|
|
77
|
+
path = _session_path(session_id, history_dir)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
if not path.exists():
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
85
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
86
|
+
log.warning("Failed to load session %s", session_id)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
session_map = SessionMap.from_dict(data.get("mapping", {}))
|
|
90
|
+
messages = data.get("messages", [])
|
|
91
|
+
return session_map, messages, data.get("provider_state") or {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def list_sessions(history_dir: Path = DEFAULT_HISTORY_DIR) -> list[dict]:
|
|
95
|
+
"""List all saved sessions."""
|
|
96
|
+
if not history_dir.exists():
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
sessions = []
|
|
100
|
+
for path in sorted(history_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
103
|
+
sessions.append({
|
|
104
|
+
"id": data.get("session_id", path.stem),
|
|
105
|
+
"updated_at": data.get("updated_at", ""),
|
|
106
|
+
"message_count": len(data.get("messages", [])),
|
|
107
|
+
"provider": (data.get("provider_state") or {}).get("provider", ""),
|
|
108
|
+
})
|
|
109
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
110
|
+
continue
|
|
111
|
+
return sessions
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def delete_session(session_id: str, history_dir: Path = DEFAULT_HISTORY_DIR) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
path = _session_path(session_id, history_dir)
|
|
117
|
+
except ValueError:
|
|
118
|
+
return False
|
|
119
|
+
if path.exists():
|
|
120
|
+
path.unlink()
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def new_session_id() -> str:
|
|
126
|
+
"""Generate a session ID from the current timestamp."""
|
|
127
|
+
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|