convo-cli 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.
- convo/__init__.py +3 -0
- convo/__main__.py +110 -0
- convo/bootstrap.py +273 -0
- convo/core/__init__.py +1 -0
- convo/core/config.py +53 -0
- convo/core/contracts.py +160 -0
- convo/core/exceptions.py +5 -0
- convo/db/__init__.py +1 -0
- convo/db/base.py +13 -0
- convo/db/chunks.py +132 -0
- convo/db/conversations.py +167 -0
- convo/db/documents.py +34 -0
- convo/db/models.py +81 -0
- convo/db/session.py +27 -0
- convo/llm/__init__.py +0 -0
- convo/llm/auth.py +504 -0
- convo/llm/client.py +207 -0
- convo/llm/credentials.py +192 -0
- convo/llm/models.py +71 -0
- convo/llm/streaming.py +62 -0
- convo/migrations/env.py +43 -0
- convo/migrations/script.py.mako +22 -0
- convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
- convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
- convo/migrations/versions/README.md +3 -0
- convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
- convo/pipeline/__init__.py +0 -0
- convo/pipeline/chat.py +129 -0
- convo/pipeline/chunking.py +42 -0
- convo/pipeline/embeddings.py +83 -0
- convo/pipeline/index.py +160 -0
- convo/pipeline/ingestion.py +46 -0
- convo/pipeline/reranking.py +63 -0
- convo/pipeline/routing.py +64 -0
- convo/pipeline/sessions.py +40 -0
- convo/pipeline/worker.py +226 -0
- convo/prompts/__init__.py +0 -0
- convo/prompts/answers.py +97 -0
- convo/prompts/schema.py +97 -0
- convo/ui/__init__.py +1 -0
- convo/ui/app.py +752 -0
- convo/ui/commands.py +37 -0
- convo/ui/files.py +110 -0
- convo/ui/models.py +84 -0
- convo/ui/preview.py +20 -0
- convo/ui/sessions.py +97 -0
- convo/ui/theme.tcss +64 -0
- convo/ui/thinking.py +70 -0
- convo_cli-0.1.0.dist-info/METADATA +160 -0
- convo_cli-0.1.0.dist-info/RECORD +53 -0
- convo_cli-0.1.0.dist-info/WHEEL +4 -0
- convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- convo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
convo/__init__.py
ADDED
convo/__main__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from convo.ui.app import ConvoApp
|
|
6
|
+
from convo.ui.preview import ShellBackend
|
|
7
|
+
from convo.ui.thinking import thinking_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(description="Chat with a file in your current directory.")
|
|
12
|
+
parser.add_argument("--directory", type=Path, default=None, help="Browse this folder instead")
|
|
13
|
+
parser.add_argument(
|
|
14
|
+
"--thinking-key",
|
|
15
|
+
type=thinking_key,
|
|
16
|
+
default=None,
|
|
17
|
+
help="Thinking cycle shortcut (default: CONVO_THINKING_KEY or ctrl+t)",
|
|
18
|
+
)
|
|
19
|
+
mode = parser.add_mutually_exclusive_group()
|
|
20
|
+
mode.add_argument(
|
|
21
|
+
"--shell",
|
|
22
|
+
action="store_true",
|
|
23
|
+
help="Run the UI preview without PostgreSQL, embeddings, or ChatGPT",
|
|
24
|
+
)
|
|
25
|
+
mode.add_argument(
|
|
26
|
+
"--download-model",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Download and warm Qwen3 and the reranker outside the TUI, then exit",
|
|
29
|
+
)
|
|
30
|
+
mode.add_argument(
|
|
31
|
+
"--setup",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="Create and migrate Convo's local PostgreSQL/pgvector storage, then exit",
|
|
34
|
+
)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
directory = (args.directory or Path.cwd()).expanduser().resolve()
|
|
37
|
+
if not directory.is_dir():
|
|
38
|
+
parser.error(f"Not a directory: {directory}")
|
|
39
|
+
if args.shell:
|
|
40
|
+
try:
|
|
41
|
+
app = ConvoApp(directory, ShellBackend(), thinking_shortcut=args.thinking_key)
|
|
42
|
+
except ValueError as error:
|
|
43
|
+
parser.error(str(error))
|
|
44
|
+
app.run()
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from convo.core.config import load_settings
|
|
49
|
+
from convo.pipeline.chat import RagBackend
|
|
50
|
+
except ImportError as error:
|
|
51
|
+
parser.error(
|
|
52
|
+
f"Backend dependency {error.name} is missing. Install the published command with "
|
|
53
|
+
"`uv tool install --force convo-cli`; from a checkout run `uv run convo`."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if args.setup:
|
|
57
|
+
from convo.bootstrap import SetupError, setup
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
settings_path = setup()
|
|
61
|
+
except (SetupError, ValueError) as error:
|
|
62
|
+
parser.error(str(error))
|
|
63
|
+
print(f"Convo storage is ready. Settings are in {settings_path}. Run `convo` to start.")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
if args.download_model:
|
|
67
|
+
# Warming models does not require a running database or a container runtime.
|
|
68
|
+
from convo.core.config import Settings, load_environment
|
|
69
|
+
from convo.core.contracts import Hit
|
|
70
|
+
from convo.pipeline.embeddings import Embedder
|
|
71
|
+
from convo.pipeline.reranking import Reranker
|
|
72
|
+
|
|
73
|
+
load_environment()
|
|
74
|
+
settings = Settings(
|
|
75
|
+
"",
|
|
76
|
+
os.getenv("EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-0.6B"),
|
|
77
|
+
reranker_model=os.getenv("RERANKER_MODEL", "cross-encoder/ms-marco-MiniLM-L6-v2"),
|
|
78
|
+
)
|
|
79
|
+
print(
|
|
80
|
+
"Downloading/loading Qwen3 embeddings. First use can take several minutes.", flush=True
|
|
81
|
+
)
|
|
82
|
+
Embedder(settings.embedding_model).query("warm up")
|
|
83
|
+
print("Downloading/loading the local reranker…", flush=True)
|
|
84
|
+
Reranker(settings.reranker_model).rerank(
|
|
85
|
+
"What is being cached?", [Hit("Model weights are cached locally.", "warmup", 0.0)]
|
|
86
|
+
)
|
|
87
|
+
print("Both models are ready. Run convo and choose a file with /file.")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
from convo.bootstrap import SetupError, ensure_storage
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
ensure_storage()
|
|
94
|
+
settings = load_settings()
|
|
95
|
+
except (SetupError, ValueError) as error:
|
|
96
|
+
parser.error(str(error))
|
|
97
|
+
|
|
98
|
+
backend = RagBackend(settings)
|
|
99
|
+
try:
|
|
100
|
+
try:
|
|
101
|
+
app = ConvoApp(directory, backend, thinking_shortcut=args.thinking_key)
|
|
102
|
+
except ValueError as error:
|
|
103
|
+
parser.error(str(error))
|
|
104
|
+
app.run()
|
|
105
|
+
finally:
|
|
106
|
+
backend.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
convo/bootstrap.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Prepare local pgvector storage on startup and keep migrations available in wheels.
|
|
2
|
+
|
|
3
|
+
Convo manages only its labelled container and named volume. Explicit DATABASE_URL
|
|
4
|
+
connections are left alone at startup; ``convo --setup`` migrates them on request.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from importlib.resources import as_file, files
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
CONTAINER_NAME = "convo-postgres"
|
|
20
|
+
VOLUME_NAME = "convo-postgres-data"
|
|
21
|
+
IMAGE = "pgvector/pgvector:pg16"
|
|
22
|
+
DATABASE_NAME = "convo"
|
|
23
|
+
DATABASE_USER = "convo"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SetupError(RuntimeError):
|
|
27
|
+
"""The machine cannot safely complete the requested local setup."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run(command: list[str], *, allow_failure: bool = False) -> subprocess.CompletedProcess[str]:
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(command, check=False, capture_output=True, text=True)
|
|
33
|
+
except OSError as error:
|
|
34
|
+
raise SetupError(f"Could not run {command[0]!r}: {error}") from error
|
|
35
|
+
if result.returncode and not allow_failure:
|
|
36
|
+
detail = result.stderr.strip() or result.stdout.strip() or "unknown error"
|
|
37
|
+
raise SetupError(f"{' '.join(command[:2])} failed: {detail}")
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _container_runtime() -> str:
|
|
42
|
+
installed = []
|
|
43
|
+
for candidate in ("docker", "podman"):
|
|
44
|
+
if shutil.which(candidate):
|
|
45
|
+
installed.append(candidate)
|
|
46
|
+
if _run([candidate, "info"], allow_failure=True).returncode == 0:
|
|
47
|
+
return candidate
|
|
48
|
+
if installed:
|
|
49
|
+
if "podman" in installed and sys.platform == "darwin":
|
|
50
|
+
return "podman"
|
|
51
|
+
return installed[0]
|
|
52
|
+
raise SetupError(
|
|
53
|
+
"Convo needs Docker Desktop or Podman to create its local PostgreSQL storage. "
|
|
54
|
+
"Install one, start it, then run `convo` again."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ensure_podman_machine(runtime: str) -> None:
|
|
59
|
+
"""Start Podman's macOS VM when it exists; leave other platforms untouched."""
|
|
60
|
+
if runtime != "podman" or sys.platform != "darwin":
|
|
61
|
+
return
|
|
62
|
+
if _run([runtime, "info"], allow_failure=True).returncode == 0:
|
|
63
|
+
return
|
|
64
|
+
machines = _run([runtime, "machine", "list", "--format", "{{.Name}}"], allow_failure=True)
|
|
65
|
+
if machines.returncode:
|
|
66
|
+
raise SetupError(
|
|
67
|
+
"Podman is installed but unavailable. Start Podman, then run `convo --setup`."
|
|
68
|
+
)
|
|
69
|
+
if not machines.stdout.strip():
|
|
70
|
+
_run([runtime, "machine", "init"])
|
|
71
|
+
_run([runtime, "machine", "start"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_local_settings(database_url: str) -> Path:
|
|
75
|
+
from convo.core.config import user_settings_path
|
|
76
|
+
|
|
77
|
+
target = user_settings_path()
|
|
78
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
79
|
+
contents = (
|
|
80
|
+
"# Created by `convo --setup`. Keep this file private.\n"
|
|
81
|
+
f"DATABASE_URL={database_url}\n"
|
|
82
|
+
"CONVO_LOCAL_CONTAINER=1\n"
|
|
83
|
+
"EMBEDDING_MODEL=Qwen/Qwen3-Embedding-0.6B\n"
|
|
84
|
+
"RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L6-v2\n"
|
|
85
|
+
"RERANK_MIN_SCORE=-3\n"
|
|
86
|
+
"RETRIEVAL_CANDIDATES=20\n"
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
90
|
+
except FileExistsError as error:
|
|
91
|
+
raise SetupError(
|
|
92
|
+
f"Settings already exist at {target}. Configure DATABASE_URL there."
|
|
93
|
+
) from error
|
|
94
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
95
|
+
output.write(contents)
|
|
96
|
+
return target
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _configured_database_url() -> str | None:
|
|
100
|
+
from convo.core.config import load_environment, load_settings
|
|
101
|
+
|
|
102
|
+
load_environment()
|
|
103
|
+
if not os.getenv("DATABASE_URL"):
|
|
104
|
+
return None
|
|
105
|
+
# Invalid supplied settings must be reported, never replaced by a new database.
|
|
106
|
+
return load_settings().database_url
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_managed_database(database_url: str | None) -> bool:
|
|
110
|
+
from dotenv import dotenv_values
|
|
111
|
+
|
|
112
|
+
from convo.core.config import user_settings_path
|
|
113
|
+
|
|
114
|
+
saved = dotenv_values(user_settings_path())
|
|
115
|
+
return bool(
|
|
116
|
+
database_url
|
|
117
|
+
and saved.get("CONVO_LOCAL_CONTAINER") == "1"
|
|
118
|
+
and saved.get("DATABASE_URL") == database_url
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _ensure_container(runtime: str, database_url: str | None) -> str:
|
|
123
|
+
from convo.core.config import user_settings_path
|
|
124
|
+
|
|
125
|
+
existing = _run([runtime, "container", "inspect", CONTAINER_NAME], allow_failure=True)
|
|
126
|
+
if existing.returncode == 0:
|
|
127
|
+
if database_url is None:
|
|
128
|
+
raise SetupError(
|
|
129
|
+
f"{CONTAINER_NAME!r} already exists, but Convo has no saved database URL. "
|
|
130
|
+
"Set DATABASE_URL yourself or remove that unused container manually before retrying."
|
|
131
|
+
)
|
|
132
|
+
label = _run(
|
|
133
|
+
[
|
|
134
|
+
runtime,
|
|
135
|
+
"inspect",
|
|
136
|
+
"--format",
|
|
137
|
+
'{{index .Config.Labels "io.convo.managed"}}',
|
|
138
|
+
CONTAINER_NAME,
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
if label.stdout.strip() != "true":
|
|
142
|
+
raise SetupError(
|
|
143
|
+
f"{CONTAINER_NAME!r} belongs to another setup. Set DATABASE_URL to use it explicitly."
|
|
144
|
+
)
|
|
145
|
+
_run([runtime, "start", CONTAINER_NAME])
|
|
146
|
+
return database_url
|
|
147
|
+
|
|
148
|
+
if database_url is not None:
|
|
149
|
+
raise SetupError(
|
|
150
|
+
f"Saved local storage exists but {CONTAINER_NAME!r} is missing. "
|
|
151
|
+
f"Restore the container using the saved credentials and volume {VOLUME_NAME!r}."
|
|
152
|
+
)
|
|
153
|
+
if _run([runtime, "volume", "inspect", VOLUME_NAME], allow_failure=True).returncode == 0:
|
|
154
|
+
raise SetupError(
|
|
155
|
+
f"Volume {VOLUME_NAME!r} already exists without saved credentials. "
|
|
156
|
+
"Restore its connection settings before starting Convo."
|
|
157
|
+
)
|
|
158
|
+
if user_settings_path().exists():
|
|
159
|
+
raise SetupError(
|
|
160
|
+
f"Settings already exist at {user_settings_path()}. Configure DATABASE_URL there."
|
|
161
|
+
)
|
|
162
|
+
password = secrets.token_urlsafe(24)
|
|
163
|
+
database_url = (
|
|
164
|
+
f"postgresql+psycopg://{DATABASE_USER}:{quote(password, safe='')}"
|
|
165
|
+
f"@localhost:5432/{DATABASE_NAME}"
|
|
166
|
+
)
|
|
167
|
+
_run(
|
|
168
|
+
[
|
|
169
|
+
runtime,
|
|
170
|
+
"run",
|
|
171
|
+
"--name",
|
|
172
|
+
CONTAINER_NAME,
|
|
173
|
+
"--label",
|
|
174
|
+
"io.convo.managed=true",
|
|
175
|
+
"-e",
|
|
176
|
+
f"POSTGRES_USER={DATABASE_USER}",
|
|
177
|
+
"-e",
|
|
178
|
+
f"POSTGRES_PASSWORD={password}",
|
|
179
|
+
"-e",
|
|
180
|
+
f"POSTGRES_DB={DATABASE_NAME}",
|
|
181
|
+
"-p",
|
|
182
|
+
"127.0.0.1:5432:5432",
|
|
183
|
+
"-v",
|
|
184
|
+
f"{VOLUME_NAME}:/var/lib/postgresql/data",
|
|
185
|
+
"-d",
|
|
186
|
+
IMAGE,
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
_write_local_settings(database_url)
|
|
190
|
+
return database_url
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _wait_for_postgres(runtime: str) -> None:
|
|
194
|
+
for _attempt in range(30):
|
|
195
|
+
ready = _run(
|
|
196
|
+
[
|
|
197
|
+
runtime,
|
|
198
|
+
"exec",
|
|
199
|
+
CONTAINER_NAME,
|
|
200
|
+
"pg_isready",
|
|
201
|
+
"-U",
|
|
202
|
+
DATABASE_USER,
|
|
203
|
+
"-d",
|
|
204
|
+
DATABASE_NAME,
|
|
205
|
+
],
|
|
206
|
+
allow_failure=True,
|
|
207
|
+
)
|
|
208
|
+
if ready.returncode == 0:
|
|
209
|
+
return
|
|
210
|
+
time.sleep(1)
|
|
211
|
+
raise SetupError("PostgreSQL did not become ready within 30 seconds. Check the container logs.")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _migration_directory() -> Path | None:
|
|
215
|
+
from convo.core.config import PROJECT_ROOT
|
|
216
|
+
|
|
217
|
+
source_directory = PROJECT_ROOT / "migrations"
|
|
218
|
+
if source_directory.is_dir():
|
|
219
|
+
return source_directory
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _upgrade_database() -> None:
|
|
224
|
+
from alembic import command
|
|
225
|
+
from alembic.config import Config
|
|
226
|
+
|
|
227
|
+
migration_directory = _migration_directory()
|
|
228
|
+
if migration_directory is not None:
|
|
229
|
+
config = Config()
|
|
230
|
+
config.set_main_option("script_location", str(migration_directory))
|
|
231
|
+
command.upgrade(config, "head")
|
|
232
|
+
return
|
|
233
|
+
# Hatch copies the source migration tree into the installed package. Keep
|
|
234
|
+
# `as_file` open while Alembic reads scripts for zip-safe resource access.
|
|
235
|
+
with as_file(files("convo").joinpath("migrations")) as packaged_directory:
|
|
236
|
+
config = Config()
|
|
237
|
+
config.set_main_option("script_location", str(packaged_directory))
|
|
238
|
+
command.upgrade(config, "head")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def setup() -> Path:
|
|
242
|
+
"""Provision local storage or migrate an explicitly configured PostgreSQL database."""
|
|
243
|
+
configured_url = _configured_database_url()
|
|
244
|
+
local_container = _is_managed_database(configured_url)
|
|
245
|
+
if configured_url is not None and not local_container:
|
|
246
|
+
# An explicitly supplied DATABASE_URL may point at an existing remote or
|
|
247
|
+
# shared database. Do not create a second local container around it.
|
|
248
|
+
database_url = configured_url
|
|
249
|
+
else:
|
|
250
|
+
print("Preparing Convo's local storage…", flush=True)
|
|
251
|
+
runtime = _container_runtime()
|
|
252
|
+
_ensure_podman_machine(runtime)
|
|
253
|
+
database_url = _ensure_container(runtime, configured_url)
|
|
254
|
+
_wait_for_postgres(runtime)
|
|
255
|
+
# `load_settings` reads the saved file; this assignment also supports an external DATABASE_URL.
|
|
256
|
+
os.environ["DATABASE_URL"] = database_url
|
|
257
|
+
try:
|
|
258
|
+
_upgrade_database()
|
|
259
|
+
except Exception as error: # Alembic has several driver-specific exception classes.
|
|
260
|
+
raise SetupError(
|
|
261
|
+
"Could not prepare the database schema. Check PostgreSQL access and pgvector, "
|
|
262
|
+
"then retry `convo --setup`."
|
|
263
|
+
) from error
|
|
264
|
+
from convo.core.config import user_settings_path
|
|
265
|
+
|
|
266
|
+
return user_settings_path()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def ensure_storage() -> None:
|
|
270
|
+
"""Bootstrap first use and restart managed storage before entering the TUI."""
|
|
271
|
+
database_url = _configured_database_url()
|
|
272
|
+
if database_url is None or _is_managed_database(database_url):
|
|
273
|
+
setup()
|
convo/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared backend configuration, values, and errors."""
|
convo/core/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
9
|
+
EMBEDDING_DIMENSIONS = 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def user_settings_path() -> Path:
|
|
13
|
+
"""Return the private per-user settings file used by an installed CLI."""
|
|
14
|
+
config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
15
|
+
return config_home / "convo" / ".env"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Settings:
|
|
20
|
+
database_url: str = field(repr=False)
|
|
21
|
+
embedding_model: str
|
|
22
|
+
reranker_model: str = "cross-encoder/ms-marco-MiniLM-L6-v2"
|
|
23
|
+
rerank_min_score: float = -3.0
|
|
24
|
+
retrieval_candidates: int = 20
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_environment() -> None:
|
|
28
|
+
# Environment variables win. The user file makes an installed tool independent
|
|
29
|
+
# of its read-only site-packages directory; the repository file remains useful
|
|
30
|
+
# for contributors running from a checkout.
|
|
31
|
+
load_dotenv(user_settings_path(), override=False)
|
|
32
|
+
load_dotenv(PROJECT_ROOT / ".env", override=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_settings() -> Settings:
|
|
36
|
+
load_environment()
|
|
37
|
+
database_url = os.environ.get("DATABASE_URL", "")
|
|
38
|
+
if not database_url.startswith("postgresql+psycopg://"):
|
|
39
|
+
raise ValueError("Set DATABASE_URL to a PostgreSQL/Psycopg URL")
|
|
40
|
+
embedding_model = os.getenv("EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-0.6B")
|
|
41
|
+
if embedding_model != "Qwen/Qwen3-Embedding-0.6B":
|
|
42
|
+
raise ValueError("Changing the embedding model requires a dimension/schema review")
|
|
43
|
+
rerank_min_score = float(os.getenv("RERANK_MIN_SCORE", "-3"))
|
|
44
|
+
retrieval_candidates = int(os.getenv("RETRIEVAL_CANDIDATES", "20"))
|
|
45
|
+
if not math.isfinite(rerank_min_score) or not 5 <= retrieval_candidates <= 50:
|
|
46
|
+
raise ValueError("Use a finite RERANK_MIN_SCORE and RETRIEVAL_CANDIDATES between 5 and 50")
|
|
47
|
+
return Settings(
|
|
48
|
+
database_url=database_url,
|
|
49
|
+
embedding_model=embedding_model,
|
|
50
|
+
reranker_model=os.getenv("RERANKER_MODEL", "cross-encoder/ms-marco-MiniLM-L6-v2"),
|
|
51
|
+
rerank_min_score=rerank_min_score,
|
|
52
|
+
retrieval_candidates=retrieval_candidates,
|
|
53
|
+
)
|
convo/core/contracts.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Shared values and the single boundary between the TUI and backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
12
|
+
REASONING_EFFORTS: tuple[ReasoningEffort, ...] = (
|
|
13
|
+
"none",
|
|
14
|
+
"minimal",
|
|
15
|
+
"low",
|
|
16
|
+
"medium",
|
|
17
|
+
"high",
|
|
18
|
+
"xhigh",
|
|
19
|
+
"max",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ChatModel:
|
|
25
|
+
"""A displayable model from the connected account's catalog."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
reasoning_efforts: tuple[ReasoningEffort, ...] = ()
|
|
30
|
+
default_reasoning_effort: ReasoningEffort | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class GenerationSelection:
|
|
35
|
+
model: ChatModel | None = None
|
|
36
|
+
reasoning_effort: ReasoningEffort | None = None # None lets the server choose its default.
|
|
37
|
+
connected: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Document:
|
|
42
|
+
id: str
|
|
43
|
+
path: Path
|
|
44
|
+
indexed: bool = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Message:
|
|
49
|
+
role: Literal["user", "assistant"]
|
|
50
|
+
content: str
|
|
51
|
+
sources: tuple[Source, ...] = ()
|
|
52
|
+
structured: dict[str, object] | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Source:
|
|
57
|
+
label: str # e.g. "notes.md · lines 12–24"; never invent a locator.
|
|
58
|
+
excerpt: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Answer:
|
|
63
|
+
text: str
|
|
64
|
+
sources: tuple[Source, ...] = ()
|
|
65
|
+
structured: dict[str, object] | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ChunkData:
|
|
70
|
+
text: str
|
|
71
|
+
locator: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Hit:
|
|
76
|
+
text: str
|
|
77
|
+
locator: str
|
|
78
|
+
distance: float
|
|
79
|
+
chunk_id: int | None = None
|
|
80
|
+
score: float | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class SessionSummary:
|
|
85
|
+
id: str
|
|
86
|
+
title: str
|
|
87
|
+
filename: str | None
|
|
88
|
+
updated_at: datetime
|
|
89
|
+
message_count: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class SessionSnapshot:
|
|
94
|
+
id: str
|
|
95
|
+
title: str
|
|
96
|
+
document: Document | None
|
|
97
|
+
history: tuple[Message, ...] = ()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Backend(Protocol):
|
|
101
|
+
async def prepare(self, path: Path) -> Document:
|
|
102
|
+
"""Parse, chunk, embed and index a file; return only when ready."""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
async def answer(
|
|
106
|
+
self, document: Document, question: str, history: tuple[Message, ...]
|
|
107
|
+
) -> Answer:
|
|
108
|
+
"""Retrieve only this document, call ChatGPT, return text and sources.
|
|
109
|
+
|
|
110
|
+
history contains completed turns, excluding the current question.
|
|
111
|
+
"""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@runtime_checkable
|
|
116
|
+
class PreparationControls(Protocol):
|
|
117
|
+
"""Optional progress reporting; callbacks run on the UI event loop."""
|
|
118
|
+
|
|
119
|
+
async def prepare_with_progress(
|
|
120
|
+
self, path: Path, progress: Callable[[str], None]
|
|
121
|
+
) -> Document: ...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@runtime_checkable
|
|
125
|
+
class SessionControls(Protocol):
|
|
126
|
+
"""Durable sessions; successful turns are saved before the response returns."""
|
|
127
|
+
|
|
128
|
+
async def new_session(self, document: Document | None = None) -> SessionSnapshot: ...
|
|
129
|
+
|
|
130
|
+
async def list_sessions(self) -> tuple[SessionSummary, ...]: ...
|
|
131
|
+
|
|
132
|
+
async def load_session(self, session_id: str) -> SessionSnapshot: ...
|
|
133
|
+
|
|
134
|
+
async def answer_session(
|
|
135
|
+
self, session_id: str, question: str, progress: Callable[[str], None] | None = None
|
|
136
|
+
) -> Answer: ...
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@runtime_checkable
|
|
140
|
+
class AccountControls(Protocol):
|
|
141
|
+
"""Optional ChatGPT sign-in exposed by a completed backend."""
|
|
142
|
+
|
|
143
|
+
async def login_openai(self) -> str:
|
|
144
|
+
"""Connect a ChatGPT account through Sign in with ChatGPT."""
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@runtime_checkable
|
|
149
|
+
class GenerationControls(Protocol):
|
|
150
|
+
"""Optional account model and thinking controls, independent of the TUI."""
|
|
151
|
+
|
|
152
|
+
def generation_selection(self) -> GenerationSelection: ...
|
|
153
|
+
|
|
154
|
+
async def list_models(self) -> tuple[ChatModel, ...]: ...
|
|
155
|
+
|
|
156
|
+
async def select_model(self, model_id: str) -> GenerationSelection: ...
|
|
157
|
+
|
|
158
|
+
async def cycle_reasoning(self) -> GenerationSelection: ...
|
|
159
|
+
|
|
160
|
+
async def select_reasoning(self, effort: ReasoningEffort | None) -> GenerationSelection: ...
|
convo/core/exceptions.py
ADDED
convo/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SQLAlchemy models, connections, and document queries."""
|
convo/db/base.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from sqlalchemy import MetaData
|
|
2
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Base(DeclarativeBase):
|
|
6
|
+
metadata = MetaData(
|
|
7
|
+
naming_convention={
|
|
8
|
+
"ix": "ix_%(column_0_label)s",
|
|
9
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
10
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
11
|
+
"pk": "pk_%(table_name)s",
|
|
12
|
+
}
|
|
13
|
+
)
|