colloquial 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.
- colloquial/__init__.py +3 -0
- colloquial/__main__.py +4 -0
- colloquial/cli.py +500 -0
- colloquial/config.py +192 -0
- colloquial/core/__init__.py +1 -0
- colloquial/core/detect.py +121 -0
- colloquial/core/discourse.py +4538 -0
- colloquial/core/diversity.py +136 -0
- colloquial/core/langdetect.py +46 -0
- colloquial/core/report.py +173 -0
- colloquial/core/stats.py +119 -0
- colloquial/core/surface.py +621 -0
- colloquial/langs/__init__.py +7 -0
- colloquial/mcp.py +326 -0
- colloquial/providers.py +195 -0
- colloquial/rewrite/__init__.py +1 -0
- colloquial/rewrite/engine.py +200 -0
- colloquial/rewrite/llm.py +140 -0
- colloquial/rewrite/mechanical.py +840 -0
- colloquial/style/ar.md +74 -0
- colloquial/style/en.md +101 -0
- colloquial/style/hi.md +53 -0
- colloquial/style/ja.md +45 -0
- colloquial/style/ko.md +109 -0
- colloquial/style/pt-br.md +103 -0
- colloquial/style/uk.md +59 -0
- colloquial/style/zh.md +51 -0
- colloquial-0.1.0.dist-info/METADATA +244 -0
- colloquial-0.1.0.dist-info/RECORD +33 -0
- colloquial-0.1.0.dist-info/WHEEL +5 -0
- colloquial-0.1.0.dist-info/entry_points.txt +2 -0
- colloquial-0.1.0.dist-info/licenses/LICENSE +21 -0
- colloquial-0.1.0.dist-info/top_level.txt +1 -0
colloquial/__init__.py
ADDED
colloquial/__main__.py
ADDED
colloquial/cli.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Colloquial CLI.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
colloquial detect <path> [--lang LANG] [--report] [--format text|json]
|
|
5
|
+
colloquial humanize <path> [--lang] [--to LANG] [--self-check] [--report]
|
|
6
|
+
colloquial providers -- list OpenAI-compatible providers
|
|
7
|
+
colloquial models -- live model list from your configured provider
|
|
8
|
+
colloquial setup -- configure provider + key + model (interactive)
|
|
9
|
+
colloquial config -- show the resolved configuration
|
|
10
|
+
colloquial langs -- list available language profiles
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, TypedDict, cast
|
|
21
|
+
|
|
22
|
+
from colloquial import config as cfg
|
|
23
|
+
from colloquial.core import surface
|
|
24
|
+
from colloquial.core.detect import detect, prepare_text
|
|
25
|
+
from colloquial.core.langdetect import guess_language
|
|
26
|
+
from colloquial.core.report import format_report, score_document
|
|
27
|
+
from colloquial.providers import PROVIDERS, requires_key
|
|
28
|
+
from colloquial.rewrite.llm import LLMClient, LLMError
|
|
29
|
+
|
|
30
|
+
_TEXT_SUFFIXES = {".md", ".mdx", ".markdown", ".txt"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DetectResult(TypedDict):
|
|
34
|
+
path: str
|
|
35
|
+
language: str
|
|
36
|
+
words: int
|
|
37
|
+
sentences: int
|
|
38
|
+
ai_score: float
|
|
39
|
+
humanity_score: float
|
|
40
|
+
findings: int
|
|
41
|
+
burstiness: float
|
|
42
|
+
mattr: float
|
|
43
|
+
findings_detail: list[dict[str, Any]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read(path: str) -> str:
|
|
47
|
+
p = Path(path)
|
|
48
|
+
if p.is_dir():
|
|
49
|
+
files = (
|
|
50
|
+
f.read_text(encoding="utf-8", errors="replace")
|
|
51
|
+
for f in sorted(p.rglob("*"))
|
|
52
|
+
if f.suffix in _TEXT_SUFFIXES
|
|
53
|
+
)
|
|
54
|
+
return "\n\n".join(files)
|
|
55
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _detect_one(path: str, lang: str, fmt: str) -> DetectResult:
|
|
59
|
+
raw = _read(path)
|
|
60
|
+
det = detect(raw, language=lang)
|
|
61
|
+
ai, humanity = score_document(det)
|
|
62
|
+
result: DetectResult = {
|
|
63
|
+
"path": path,
|
|
64
|
+
"language": lang,
|
|
65
|
+
"words": det.text_stats.word_count,
|
|
66
|
+
"sentences": det.text_stats.sentence_count,
|
|
67
|
+
"ai_score": ai,
|
|
68
|
+
"humanity_score": humanity,
|
|
69
|
+
"findings": len(det.findings),
|
|
70
|
+
"burstiness": det.text_stats.burstiness,
|
|
71
|
+
"mattr": det.text_stats.mattr,
|
|
72
|
+
"findings_detail": [],
|
|
73
|
+
}
|
|
74
|
+
if fmt == "json":
|
|
75
|
+
result["findings_detail"] = [
|
|
76
|
+
{
|
|
77
|
+
"line": f.line,
|
|
78
|
+
"severity": f.severity,
|
|
79
|
+
"note": f.note,
|
|
80
|
+
"weight": f.weight,
|
|
81
|
+
"snippet": f.snippet[:80],
|
|
82
|
+
}
|
|
83
|
+
for f in det.findings[:20]
|
|
84
|
+
]
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_detect(args: argparse.Namespace) -> int:
|
|
89
|
+
if args.lang == "auto":
|
|
90
|
+
# M0: default to pt-br when the text is mostly accented; else en.
|
|
91
|
+
# A real language detector lands with the profile work.
|
|
92
|
+
args.lang = _guess_lang(args.paths[0])
|
|
93
|
+
results = [_detect_one(p, args.lang, args.format) for p in _expand(args.paths)]
|
|
94
|
+
if args.format == "json":
|
|
95
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
for r in results:
|
|
99
|
+
print(
|
|
100
|
+
f"{r['path']}: ai={r['ai_score']}/100 "
|
|
101
|
+
f"humanity={r['humanity_score']}/100 "
|
|
102
|
+
f"({r['words']} words, {r['findings']} surface hits)"
|
|
103
|
+
)
|
|
104
|
+
if args.report:
|
|
105
|
+
raw = _read(r["path"])
|
|
106
|
+
det = detect(raw, language=r["language"])
|
|
107
|
+
print()
|
|
108
|
+
print(format_report(det, prepare_text(raw)))
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _expand(paths: list[str]) -> list[str]:
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
for p in paths:
|
|
115
|
+
path = Path(p)
|
|
116
|
+
if path.is_dir():
|
|
117
|
+
out.extend(
|
|
118
|
+
str(f)
|
|
119
|
+
for f in sorted(path.rglob("*"))
|
|
120
|
+
if f.suffix in _TEXT_SUFFIXES and ".git" not in f.parts
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
out.append(str(path))
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _guess_lang(path: str) -> str:
|
|
128
|
+
raw = _read(path)[:2000]
|
|
129
|
+
return guess_language(raw)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
133
|
+
parser = argparse.ArgumentParser(
|
|
134
|
+
prog="colloquial",
|
|
135
|
+
description=(
|
|
136
|
+
"Rewrite machine text into colloquial, native-sounding language — in any tongue."
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
140
|
+
|
|
141
|
+
det = sub.add_parser("detect", help="score text for machine-likeness (deterministic, offline)")
|
|
142
|
+
det.add_argument("paths", nargs="+", help="files or directories (.md/.mdx/.txt)")
|
|
143
|
+
det.add_argument("--lang", default="auto", help="language profile (auto, en, pt-br, ...)")
|
|
144
|
+
det.add_argument("--report", action="store_true", help="print full humanization report")
|
|
145
|
+
det.add_argument("--format", choices=["text", "json"], default="text")
|
|
146
|
+
det.set_defaults(func=_cmd_detect)
|
|
147
|
+
|
|
148
|
+
langs = sub.add_parser("langs", help="list available language profiles")
|
|
149
|
+
langs.set_defaults(func=_cmd_langs)
|
|
150
|
+
|
|
151
|
+
prov = sub.add_parser("providers", help="list built-in OpenAI-compatible providers")
|
|
152
|
+
prov.set_defaults(func=_cmd_providers)
|
|
153
|
+
|
|
154
|
+
mod = sub.add_parser(
|
|
155
|
+
"models", help="live model list from your configured provider (GET /models)"
|
|
156
|
+
)
|
|
157
|
+
mod.add_argument("--provider", default=None, help="override the configured provider")
|
|
158
|
+
mod.set_defaults(func=_cmd_models)
|
|
159
|
+
|
|
160
|
+
setup = sub.add_parser(
|
|
161
|
+
"setup",
|
|
162
|
+
help="configure provider, API key and model (writes ~/.config/colloquial/config.toml)",
|
|
163
|
+
)
|
|
164
|
+
setup.add_argument("--provider", default=None, help="provider key from `colloquial providers`")
|
|
165
|
+
setup.add_argument("--base-url", default=None, help="override the provider base URL")
|
|
166
|
+
setup.add_argument("--model", default=None, help="model id (skips the live list)")
|
|
167
|
+
setup.add_argument(
|
|
168
|
+
"--api-key",
|
|
169
|
+
default=None,
|
|
170
|
+
help="API key (prefer the interactive prompt; shells keep history)",
|
|
171
|
+
)
|
|
172
|
+
setup.set_defaults(func=_cmd_setup)
|
|
173
|
+
|
|
174
|
+
conf = sub.add_parser("config", help="show the resolved configuration (key masked)")
|
|
175
|
+
conf.set_defaults(func=_cmd_config)
|
|
176
|
+
|
|
177
|
+
mcp = sub.add_parser(
|
|
178
|
+
"mcp",
|
|
179
|
+
help="run the MCP server over stdio (Model Context Protocol; tools for any agent)",
|
|
180
|
+
)
|
|
181
|
+
mcp.set_defaults(func=_cmd_mcp)
|
|
182
|
+
|
|
183
|
+
hum = sub.add_parser(
|
|
184
|
+
"humanize",
|
|
185
|
+
help="rewrite text so it reads human (same language), on your configured provider",
|
|
186
|
+
)
|
|
187
|
+
hum.add_argument("paths", nargs="+", help="files or directories (.md/.mdx/.txt)")
|
|
188
|
+
hum.add_argument("--lang", default="auto", help="language profile (auto, en, pt-br, ar, ...)")
|
|
189
|
+
hum.add_argument(
|
|
190
|
+
"--to",
|
|
191
|
+
default=None,
|
|
192
|
+
help="translate-and-humanize into this language in one pass",
|
|
193
|
+
)
|
|
194
|
+
hum.add_argument(
|
|
195
|
+
"--from",
|
|
196
|
+
dest="from_lang",
|
|
197
|
+
default=None,
|
|
198
|
+
help="source language for --to (default: auto-detected)",
|
|
199
|
+
)
|
|
200
|
+
hum.add_argument(
|
|
201
|
+
"--self-check",
|
|
202
|
+
action="store_true",
|
|
203
|
+
help="re-detect the output and iterate once more if it still reads machine-made",
|
|
204
|
+
)
|
|
205
|
+
hum.add_argument(
|
|
206
|
+
"--report", action="store_true", help="print before/after scores and applied rewrites"
|
|
207
|
+
)
|
|
208
|
+
hum.add_argument("--out", default=None, help="write output to file instead of stdout")
|
|
209
|
+
hum.set_defaults(func=_cmd_humanize)
|
|
210
|
+
|
|
211
|
+
return parser
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _cmd_langs(args: argparse.Namespace) -> int:
|
|
215
|
+
for code in surface.available():
|
|
216
|
+
prof = surface.get_profile(code)
|
|
217
|
+
print(f"{code:8} {prof.name if prof else ''}")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cmd_providers(args: argparse.Namespace) -> int:
|
|
222
|
+
for key, p in PROVIDERS.items():
|
|
223
|
+
key_note = "" if p.requires_key else " (no key needed)"
|
|
224
|
+
print(f"{key:12} {p.label:28} {p.base_url}{key_note}")
|
|
225
|
+
if p.note:
|
|
226
|
+
print(f"{'':12} {p.note}")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _cmd_models(args: argparse.Namespace) -> int:
|
|
231
|
+
try:
|
|
232
|
+
client = LLMClient(provider=args.provider)
|
|
233
|
+
models = client.list_models()
|
|
234
|
+
except LLMError as exc:
|
|
235
|
+
print(f"colloquial: error: {exc}", file=sys.stderr)
|
|
236
|
+
return 2
|
|
237
|
+
marker = client.model
|
|
238
|
+
for model in models:
|
|
239
|
+
current = " <- configured" if model == marker else ""
|
|
240
|
+
print(f"{model}{current}")
|
|
241
|
+
print(f"\n{len(models)} models available from {client.base_url}")
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _cmd_config(args: argparse.Namespace) -> int:
|
|
246
|
+
settings = cfg.resolve_llm_settings()
|
|
247
|
+
path = cfg.config_path()
|
|
248
|
+
print(f"config file : {path}{'' if path.exists() else ' (not created yet)'}")
|
|
249
|
+
print(f"provider : {settings.provider or '(none)'}")
|
|
250
|
+
print(f"base_url : {settings.base_url or '(none)'}")
|
|
251
|
+
print(f"model : {settings.model or '(none)'}")
|
|
252
|
+
print(f"api key : {cfg.mask_key(settings.api_key)}")
|
|
253
|
+
print(f"key needed : {'yes' if settings.needs_key else 'no'}")
|
|
254
|
+
print(f"source : {settings.source}")
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _ask(prompt: str) -> str:
|
|
259
|
+
try:
|
|
260
|
+
return input(prompt).strip()
|
|
261
|
+
except EOFError:
|
|
262
|
+
return ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _ask_key(prompt: str) -> str:
|
|
266
|
+
import getpass
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
return getpass.getpass(prompt).strip()
|
|
270
|
+
except (EOFError, getpass.GetPassWarning):
|
|
271
|
+
return ""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _pick_model(client: LLMClient, catalog_default: str) -> str:
|
|
275
|
+
"""List live models when possible and let the user choose one."""
|
|
276
|
+
try:
|
|
277
|
+
models = client.list_models()
|
|
278
|
+
except LLMError as exc:
|
|
279
|
+
print(f" could not list models ({exc}).")
|
|
280
|
+
typed = _ask(f" model id [{catalog_default or 'required'}]: ")
|
|
281
|
+
return typed or catalog_default
|
|
282
|
+
if not models:
|
|
283
|
+
return catalog_default
|
|
284
|
+
for i, model in enumerate(models[:40], 1):
|
|
285
|
+
print(f" {i:3}. {model}")
|
|
286
|
+
if len(models) > 40:
|
|
287
|
+
print(f" ... and {len(models) - 40} more")
|
|
288
|
+
choice = _ask(f" model number or id [{catalog_default or '1'}]: ")
|
|
289
|
+
if not choice:
|
|
290
|
+
return catalog_default or models[0]
|
|
291
|
+
if choice.isdigit() and 1 <= int(choice) <= len(models[:40]):
|
|
292
|
+
return models[int(choice) - 1]
|
|
293
|
+
return choice
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _cmd_setup(args: argparse.Namespace) -> int:
|
|
297
|
+
provider_key: str = args.provider or ""
|
|
298
|
+
base_url: str = args.base_url or ""
|
|
299
|
+
model: str = args.model or ""
|
|
300
|
+
api_key: str = args.api_key or ""
|
|
301
|
+
|
|
302
|
+
interactive = not provider_key
|
|
303
|
+
if interactive:
|
|
304
|
+
# Easiest first: providers that need no account at all, then the rest.
|
|
305
|
+
ordered = sorted(PROVIDERS.items(), key=lambda item: item[1].requires_key)
|
|
306
|
+
print("Providers (all OpenAI-compatible; no-key options first):\n")
|
|
307
|
+
for i, (key, provider) in enumerate(ordered, 1):
|
|
308
|
+
suffix = " (no key needed)" if not provider.requires_key else ""
|
|
309
|
+
print(f"{i:3}. {key:12} {provider.label:28}{suffix}")
|
|
310
|
+
print(f"{len(ordered) + 1:3}. {'custom':12} any OpenAI-compatible endpoint")
|
|
311
|
+
choice = _ask("\nprovider number or name: ")
|
|
312
|
+
if choice.isdigit() and 1 <= int(choice) <= len(ordered):
|
|
313
|
+
provider_key = ordered[int(choice) - 1][0]
|
|
314
|
+
else:
|
|
315
|
+
provider_key = choice
|
|
316
|
+
|
|
317
|
+
if provider_key == "custom":
|
|
318
|
+
provider_key = ""
|
|
319
|
+
base_url = base_url or _ask("base_url (e.g. https://example.com/v1): ")
|
|
320
|
+
elif provider_key in PROVIDERS:
|
|
321
|
+
base_url = base_url or PROVIDERS[provider_key].base_url
|
|
322
|
+
model = model or PROVIDERS[provider_key].default_model
|
|
323
|
+
elif provider_key:
|
|
324
|
+
print(f"colloquial: error: unknown provider '{provider_key}'.", file=sys.stderr)
|
|
325
|
+
print("Run `colloquial providers` to see the catalog.", file=sys.stderr)
|
|
326
|
+
return 2
|
|
327
|
+
|
|
328
|
+
if not base_url:
|
|
329
|
+
print("colloquial: error: no base_url given; nothing to configure.", file=sys.stderr)
|
|
330
|
+
return 2
|
|
331
|
+
|
|
332
|
+
provider_catalog = PROVIDERS.get(provider_key)
|
|
333
|
+
needs_key = requires_key(provider_key, base_url)
|
|
334
|
+
if needs_key and not api_key:
|
|
335
|
+
if interactive:
|
|
336
|
+
api_key = _ask_key("API key (input hidden, leave empty to use env vars only): ")
|
|
337
|
+
if not api_key and not api_key_present_in_env():
|
|
338
|
+
print(
|
|
339
|
+
"colloquial: error: this provider needs an API key. "
|
|
340
|
+
"Pass --api-key, or set COLLOQUIAL_API_KEY later.",
|
|
341
|
+
file=sys.stderr,
|
|
342
|
+
)
|
|
343
|
+
return 2
|
|
344
|
+
|
|
345
|
+
if not model:
|
|
346
|
+
print("\nChecking the connection and fetching models...")
|
|
347
|
+
try:
|
|
348
|
+
client = LLMClient(
|
|
349
|
+
provider=provider_key or None,
|
|
350
|
+
base_url=base_url,
|
|
351
|
+
api_key=api_key or None,
|
|
352
|
+
)
|
|
353
|
+
model = _pick_model(client, provider_catalog.default_model if provider_catalog else "")
|
|
354
|
+
print(" connection ok.")
|
|
355
|
+
except LLMError as exc:
|
|
356
|
+
print(f" could not connect ({exc}).")
|
|
357
|
+
print(" (the configuration is still saved; fix the connection later)")
|
|
358
|
+
model = _ask(f"model id [{model or 'required'}]: ") or model
|
|
359
|
+
if not model:
|
|
360
|
+
print("colloquial: error: no model selected.", file=sys.stderr)
|
|
361
|
+
return 2
|
|
362
|
+
|
|
363
|
+
path = cfg.save_llm(provider=provider_key, base_url=base_url, model=model, api_key=api_key)
|
|
364
|
+
print(f"\nSaved {path} (mode 0600).")
|
|
365
|
+
print(f" provider : {provider_key or 'custom'}")
|
|
366
|
+
print(f" base_url : {base_url}")
|
|
367
|
+
print(f" model : {model}")
|
|
368
|
+
print(f" api key : {cfg.mask_key(api_key)}")
|
|
369
|
+
print("\nTry it: colloquial humanize README.md --report")
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def api_key_present_in_env() -> bool:
|
|
374
|
+
import os
|
|
375
|
+
|
|
376
|
+
return bool(os.environ.get("COLLOQUIAL_API_KEY") or os.environ.get("OPENAI_API_KEY"))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _cmd_mcp(args: argparse.Namespace) -> int:
|
|
380
|
+
from colloquial.mcp import serve
|
|
381
|
+
|
|
382
|
+
return serve()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
_NO_PROVIDER_CLI_HELP = (
|
|
386
|
+
"colloquial: error: no provider configured — rewriting needs a model.\n"
|
|
387
|
+
" run `colloquial setup` (a local model such as Ollama needs no account), or\n"
|
|
388
|
+
" set COLLOQUIAL_API_KEY with COLLOQUIAL_BASE_URL and COLLOQUIAL_MODEL.\n"
|
|
389
|
+
" `colloquial detect` works without a provider."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _provider_ready() -> bool:
|
|
394
|
+
from colloquial.config import resolve_llm_settings
|
|
395
|
+
|
|
396
|
+
settings = resolve_llm_settings()
|
|
397
|
+
return bool(settings.base_url) and (bool(settings.api_key) or not settings.needs_key)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _ensure_provider() -> bool:
|
|
401
|
+
"""Guarantee a provider before rewriting; walk the user through setup once.
|
|
402
|
+
|
|
403
|
+
On a terminal the first `humanize` with nothing configured opens the setup
|
|
404
|
+
interactively, because there is no rewriting without a model. In a pipe or
|
|
405
|
+
cron there is nobody to answer prompts, so the caller gets a clear error and
|
|
406
|
+
a non-zero exit instead.
|
|
407
|
+
"""
|
|
408
|
+
if _provider_ready():
|
|
409
|
+
return True
|
|
410
|
+
if not sys.stdin.isatty():
|
|
411
|
+
print(_NO_PROVIDER_CLI_HELP, file=sys.stderr)
|
|
412
|
+
return False
|
|
413
|
+
print("colloquial: no provider configured yet — rewriting needs a model.")
|
|
414
|
+
print("Let's set one up now (Ctrl-C to cancel).\n")
|
|
415
|
+
code = _cmd_setup(argparse.Namespace(provider=None, base_url=None, model=None, api_key=None))
|
|
416
|
+
if code != 0 or not _provider_ready():
|
|
417
|
+
print(_NO_PROVIDER_CLI_HELP, file=sys.stderr)
|
|
418
|
+
return False
|
|
419
|
+
print()
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _cmd_humanize(args: argparse.Namespace) -> int:
|
|
424
|
+
from colloquial.rewrite.engine import humanize_same_lang, humanize_translate
|
|
425
|
+
|
|
426
|
+
if not _ensure_provider():
|
|
427
|
+
return 2
|
|
428
|
+
|
|
429
|
+
if args.to:
|
|
430
|
+
for path in _expand(args.paths):
|
|
431
|
+
raw = _read(path)
|
|
432
|
+
result = humanize_translate(
|
|
433
|
+
raw,
|
|
434
|
+
to_lang=args.to,
|
|
435
|
+
from_lang=args.from_lang,
|
|
436
|
+
self_check=args.self_check,
|
|
437
|
+
)
|
|
438
|
+
if args.report:
|
|
439
|
+
print(f"== {path} ({result.note})")
|
|
440
|
+
print(f" ai before: {result.before_ai}/100 → after: {result.after_ai}/100")
|
|
441
|
+
if result.applied:
|
|
442
|
+
print(f" rewrites: {', '.join(result.applied[:12])}")
|
|
443
|
+
print()
|
|
444
|
+
if args.out:
|
|
445
|
+
Path(args.out).write_text(result.output, encoding="utf-8")
|
|
446
|
+
print(f"wrote {args.out}")
|
|
447
|
+
else:
|
|
448
|
+
print(result.output)
|
|
449
|
+
return 0
|
|
450
|
+
|
|
451
|
+
if args.lang == "auto":
|
|
452
|
+
args.lang = _guess_lang(args.paths[0])
|
|
453
|
+
for path in _expand(args.paths):
|
|
454
|
+
raw = _read(path)
|
|
455
|
+
result = humanize_same_lang(
|
|
456
|
+
raw,
|
|
457
|
+
lang=args.lang,
|
|
458
|
+
self_check=args.self_check,
|
|
459
|
+
)
|
|
460
|
+
if args.report:
|
|
461
|
+
print(f"== {path} ({args.lang}, {result.note})")
|
|
462
|
+
print(f" ai before: {result.before_ai}/100 → after: {result.after_ai}/100")
|
|
463
|
+
if result.applied:
|
|
464
|
+
print(f" rewrites: {', '.join(result.applied[:12])}")
|
|
465
|
+
print()
|
|
466
|
+
if args.out:
|
|
467
|
+
Path(args.out).write_text(result.output, encoding="utf-8")
|
|
468
|
+
print(f"wrote {args.out}")
|
|
469
|
+
else:
|
|
470
|
+
print(result.output)
|
|
471
|
+
return 0
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def main(argv: list[str] | None = None) -> int:
|
|
475
|
+
parser = build_parser()
|
|
476
|
+
try:
|
|
477
|
+
args = parser.parse_args(argv)
|
|
478
|
+
except SystemExit as exc:
|
|
479
|
+
# argparse raises SystemExit(0) for --help/--version and a non-zero code
|
|
480
|
+
# for usage errors. Return the code as-is: turning the help exit (0)
|
|
481
|
+
# into an error would make `colloquial --help` fail in a script.
|
|
482
|
+
code = exc.code
|
|
483
|
+
if code is None:
|
|
484
|
+
return 2
|
|
485
|
+
return code if isinstance(code, int) else 2
|
|
486
|
+
func = cast(Callable[[argparse.Namespace], int] | None, getattr(args, "func", None))
|
|
487
|
+
if func is None:
|
|
488
|
+
parser.print_help()
|
|
489
|
+
return 2
|
|
490
|
+
try:
|
|
491
|
+
return func(args)
|
|
492
|
+
except KeyboardInterrupt:
|
|
493
|
+
return 130
|
|
494
|
+
except Exception as exc:
|
|
495
|
+
print(f"colloquial: error: {exc}", file=sys.stderr)
|
|
496
|
+
return 2
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
raise SystemExit(main())
|
colloquial/config.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""User configuration for Colloquial's LLM mode.
|
|
2
|
+
|
|
3
|
+
Settings live in a small TOML file written with mode 0600:
|
|
4
|
+
|
|
5
|
+
~/.config/colloquial/config.toml
|
|
6
|
+
|
|
7
|
+
overridable with ``$COLLOQUIAL_CONFIG`` (full path) or ``$XDG_CONFIG_HOME``.
|
|
8
|
+
The file is hand-parsed with a minimal parser so Python 3.10 stays supported
|
|
9
|
+
(stdlib ``tomllib`` only exists from 3.11). Only a flat ``[llm]`` section of
|
|
10
|
+
string values is read or written; unknown lines are ignored, so editing the
|
|
11
|
+
file by hand is safe.
|
|
12
|
+
|
|
13
|
+
Resolution precedence, highest first:
|
|
14
|
+
explicit arguments > environment variables > config file > catalog defaults
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from colloquial.providers import PROVIDERS, requires_key
|
|
25
|
+
|
|
26
|
+
_SECTION = re.compile(r"^\[(?P<name>[A-Za-z0-9_.-]+)\]$")
|
|
27
|
+
_KV = re.compile(r'^(?P<key>[A-Za-z0-9_-]+)\s*=\s*"(?P<value>.*)"\s*(?:#.*)?$')
|
|
28
|
+
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigError(RuntimeError):
|
|
32
|
+
"""Raised when the configuration file is unreadable or invalid."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_path() -> Path:
|
|
36
|
+
"""Where the config file lives (respects env overrides)."""
|
|
37
|
+
explicit = os.environ.get("COLLOQUIAL_CONFIG")
|
|
38
|
+
if explicit:
|
|
39
|
+
return Path(explicit)
|
|
40
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
41
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
42
|
+
return base / "colloquial" / "config.toml"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _unescape(value: str) -> str:
|
|
46
|
+
out: list[str] = []
|
|
47
|
+
i = 0
|
|
48
|
+
while i < len(value):
|
|
49
|
+
char = value[i]
|
|
50
|
+
if char == "\\" and i + 1 < len(value):
|
|
51
|
+
out.append(_ESCAPES.get(value[i + 1], value[i + 1]))
|
|
52
|
+
i += 2
|
|
53
|
+
else:
|
|
54
|
+
out.append(char)
|
|
55
|
+
i += 1
|
|
56
|
+
return "".join(out)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _escape(value: str) -> str:
|
|
60
|
+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_llm() -> dict[str, str]:
|
|
64
|
+
"""Read the ``[llm]`` section; missing file yields an empty dict."""
|
|
65
|
+
path = config_path()
|
|
66
|
+
if not path.exists():
|
|
67
|
+
return {}
|
|
68
|
+
try:
|
|
69
|
+
text = path.read_text(encoding="utf-8")
|
|
70
|
+
except OSError as exc: # pragma: no cover - unreadable file edge
|
|
71
|
+
raise ConfigError(f"cannot read {path}: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
section = ""
|
|
74
|
+
values: dict[str, str] = {}
|
|
75
|
+
for raw in text.splitlines():
|
|
76
|
+
line = raw.strip()
|
|
77
|
+
if not line or line.startswith("#"):
|
|
78
|
+
continue
|
|
79
|
+
section_match = _SECTION.match(line)
|
|
80
|
+
if section_match:
|
|
81
|
+
section = section_match.group("name")
|
|
82
|
+
continue
|
|
83
|
+
kv_match = _KV.match(line)
|
|
84
|
+
if kv_match and section == "llm":
|
|
85
|
+
values[kv_match.group("key")] = _unescape(kv_match.group("value"))
|
|
86
|
+
return values
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save_llm(
|
|
90
|
+
*,
|
|
91
|
+
provider: str,
|
|
92
|
+
base_url: str,
|
|
93
|
+
model: str,
|
|
94
|
+
api_key: str,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""Write the ``[llm]`` section with 0600 permissions; return the path."""
|
|
97
|
+
path = config_path()
|
|
98
|
+
try:
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
lines = [
|
|
101
|
+
"# Colloquial configuration. Written by `colloquial setup`.",
|
|
102
|
+
"# The API key below is stored in plain text with mode 0600.",
|
|
103
|
+
"# Prefer `COLLOQUIAL_API_KEY` in the environment to keep it out of files.",
|
|
104
|
+
"",
|
|
105
|
+
"[llm]",
|
|
106
|
+
f'provider = "{_escape(provider)}"',
|
|
107
|
+
]
|
|
108
|
+
if base_url:
|
|
109
|
+
lines.append(f'base_url = "{_escape(base_url)}"')
|
|
110
|
+
if model:
|
|
111
|
+
lines.append(f'model = "{_escape(model)}"')
|
|
112
|
+
if api_key:
|
|
113
|
+
lines.append(f'api_key = "{_escape(api_key)}"')
|
|
114
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
115
|
+
path.chmod(0o600)
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise ConfigError(f"cannot write {path}: {exc}") from exc
|
|
118
|
+
return path
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def mask_key(key: str) -> str:
|
|
122
|
+
"""Show only a short prefix/suffix so logs and screens stay safe."""
|
|
123
|
+
if not key:
|
|
124
|
+
return "(none)"
|
|
125
|
+
if len(key) <= 8:
|
|
126
|
+
return "***"
|
|
127
|
+
return f"{key[:4]}...{key[-4:]}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class LLMSettings:
|
|
132
|
+
"""Fully resolved LLM settings plus where they came from."""
|
|
133
|
+
|
|
134
|
+
provider: str
|
|
135
|
+
base_url: str
|
|
136
|
+
model: str
|
|
137
|
+
api_key: str
|
|
138
|
+
source: str
|
|
139
|
+
needs_key: bool
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_llm_settings(
|
|
143
|
+
*,
|
|
144
|
+
provider: str | None = None,
|
|
145
|
+
base_url: str | None = None,
|
|
146
|
+
model: str | None = None,
|
|
147
|
+
api_key: str | None = None,
|
|
148
|
+
) -> LLMSettings:
|
|
149
|
+
"""Merge arguments, environment variables and the config file."""
|
|
150
|
+
file_values = load_llm()
|
|
151
|
+
|
|
152
|
+
resolved_provider = (
|
|
153
|
+
provider or os.environ.get("COLLOQUIAL_PROVIDER") or file_values.get("provider") or ""
|
|
154
|
+
)
|
|
155
|
+
catalog = PROVIDERS.get(resolved_provider)
|
|
156
|
+
resolved_base_url = (
|
|
157
|
+
base_url
|
|
158
|
+
or os.environ.get("COLLOQUIAL_BASE_URL")
|
|
159
|
+
or file_values.get("base_url")
|
|
160
|
+
or (catalog.base_url if catalog else "")
|
|
161
|
+
).rstrip("/")
|
|
162
|
+
resolved_model = (
|
|
163
|
+
model
|
|
164
|
+
or os.environ.get("COLLOQUIAL_MODEL")
|
|
165
|
+
or file_values.get("model")
|
|
166
|
+
or (catalog.default_model if catalog else "")
|
|
167
|
+
)
|
|
168
|
+
resolved_key = (
|
|
169
|
+
api_key
|
|
170
|
+
or os.environ.get("COLLOQUIAL_API_KEY")
|
|
171
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
172
|
+
or file_values.get("api_key")
|
|
173
|
+
or ""
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if provider or os.environ.get("COLLOQUIAL_PROVIDER"):
|
|
177
|
+
source = "arguments/environment" if provider else "environment"
|
|
178
|
+
elif file_values:
|
|
179
|
+
source = "config file"
|
|
180
|
+
else:
|
|
181
|
+
# Nothing configured and no provider selected: the caller decides what
|
|
182
|
+
# to do about it (the CLI opens setup, the library raises).
|
|
183
|
+
source = "none"
|
|
184
|
+
|
|
185
|
+
return LLMSettings(
|
|
186
|
+
provider=resolved_provider,
|
|
187
|
+
base_url=resolved_base_url,
|
|
188
|
+
model=resolved_model,
|
|
189
|
+
api_key=resolved_key,
|
|
190
|
+
source=source,
|
|
191
|
+
needs_key=requires_key(resolved_provider, resolved_base_url),
|
|
192
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Colloquial core — deterministic detection and scoring."""
|