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.
Files changed (53) hide show
  1. convo/__init__.py +3 -0
  2. convo/__main__.py +110 -0
  3. convo/bootstrap.py +273 -0
  4. convo/core/__init__.py +1 -0
  5. convo/core/config.py +53 -0
  6. convo/core/contracts.py +160 -0
  7. convo/core/exceptions.py +5 -0
  8. convo/db/__init__.py +1 -0
  9. convo/db/base.py +13 -0
  10. convo/db/chunks.py +132 -0
  11. convo/db/conversations.py +167 -0
  12. convo/db/documents.py +34 -0
  13. convo/db/models.py +81 -0
  14. convo/db/session.py +27 -0
  15. convo/llm/__init__.py +0 -0
  16. convo/llm/auth.py +504 -0
  17. convo/llm/client.py +207 -0
  18. convo/llm/credentials.py +192 -0
  19. convo/llm/models.py +71 -0
  20. convo/llm/streaming.py +62 -0
  21. convo/migrations/env.py +43 -0
  22. convo/migrations/script.py.mako +22 -0
  23. convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
  24. convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
  25. convo/migrations/versions/README.md +3 -0
  26. convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
  27. convo/pipeline/__init__.py +0 -0
  28. convo/pipeline/chat.py +129 -0
  29. convo/pipeline/chunking.py +42 -0
  30. convo/pipeline/embeddings.py +83 -0
  31. convo/pipeline/index.py +160 -0
  32. convo/pipeline/ingestion.py +46 -0
  33. convo/pipeline/reranking.py +63 -0
  34. convo/pipeline/routing.py +64 -0
  35. convo/pipeline/sessions.py +40 -0
  36. convo/pipeline/worker.py +226 -0
  37. convo/prompts/__init__.py +0 -0
  38. convo/prompts/answers.py +97 -0
  39. convo/prompts/schema.py +97 -0
  40. convo/ui/__init__.py +1 -0
  41. convo/ui/app.py +752 -0
  42. convo/ui/commands.py +37 -0
  43. convo/ui/files.py +110 -0
  44. convo/ui/models.py +84 -0
  45. convo/ui/preview.py +20 -0
  46. convo/ui/sessions.py +97 -0
  47. convo/ui/theme.tcss +64 -0
  48. convo/ui/thinking.py +70 -0
  49. convo_cli-0.1.0.dist-info/METADATA +160 -0
  50. convo_cli-0.1.0.dist-info/RECORD +53 -0
  51. convo_cli-0.1.0.dist-info/WHEEL +4 -0
  52. convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
  53. convo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
convo/llm/client.py ADDED
@@ -0,0 +1,207 @@
1
+ """Generate evidence-grounded answers through a connected ChatGPT account."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import httpx
8
+ from openai import BadRequestError
9
+
10
+ from convo.core.contracts import (
11
+ Answer,
12
+ ChatModel,
13
+ GenerationSelection,
14
+ Hit,
15
+ Message,
16
+ ReasoningEffort,
17
+ )
18
+ from convo.core.exceptions import BackendNotConnected
19
+ from convo.llm.auth import RESOURCE, ChatGPTAuth
20
+ from convo.llm.models import parse_model_catalog
21
+ from convo.llm.streaming import complete_chatgpt
22
+ from convo.prompts.answers import INSTRUCTIONS, bounded_hits, make_input
23
+ from convo.prompts.schema import insufficient_answer, parse_answer
24
+
25
+ MODEL_LIST_URL = f"{RESOURCE}/models"
26
+
27
+
28
+ class ChatGPTProvider:
29
+ """OpenAI generation through the user's connected ChatGPT account."""
30
+
31
+ def __init__(self, auth: ChatGPTAuth) -> None:
32
+ self.auth = auth
33
+ self._models: tuple[ChatModel, ...] = ()
34
+ self._catalog_at = 0.0
35
+ self._catalog_account: tuple[str, str] | None = None
36
+
37
+ def reset_catalog(self) -> None:
38
+ self._models = ()
39
+ self._catalog_at = 0.0
40
+ self._catalog_account = None
41
+
42
+ def generation_selection(self) -> GenerationSelection:
43
+ profile = self.auth.account_profile()
44
+ if profile is None:
45
+ return GenerationSelection()
46
+ if profile.selected_model is None:
47
+ return GenerationSelection(connected=True)
48
+ model = next((item for item in self._models if item.id == profile.selected_model), None)
49
+ if model is None:
50
+ model = ChatModel(
51
+ profile.selected_model, profile.selected_model_name or profile.selected_model
52
+ )
53
+ return GenerationSelection(model, profile.selected_reasoning_effort, connected=True)
54
+
55
+ async def list_models(self) -> tuple[ChatModel, ...]:
56
+ await self._refresh_catalog(await self.auth.access_token())
57
+ return self._models
58
+
59
+ async def _refresh_catalog(self, access_token: str) -> None:
60
+ models = await list_account_models(access_token)
61
+ if not models:
62
+ raise BackendNotConnected("This ChatGPT account has no models available for Convo.")
63
+ profile = self.auth.account_profile()
64
+ if profile is None:
65
+ raise BackendNotConnected("Connect your ChatGPT account with /login")
66
+ model = next((item for item in models if item.id == profile.selected_model), models[0])
67
+ effort = (
68
+ profile.selected_reasoning_effort
69
+ if model.id == profile.selected_model
70
+ and profile.selected_reasoning_effort in model.reasoning_efforts
71
+ else None
72
+ )
73
+ if (
74
+ profile.selected_model,
75
+ profile.selected_model_name,
76
+ profile.selected_reasoning_effort,
77
+ ) != (model.id, model.name, effort):
78
+ self.auth.save_generation_preferences(model.id, model.name, effort)
79
+ self._models = models
80
+ self._catalog_account = (profile.client_id, profile.subject)
81
+ self._catalog_at = time.monotonic()
82
+
83
+ async def _ensure_catalog(self, access_token: str | None = None) -> None:
84
+ profile = self.auth.account_profile()
85
+ account = (profile.client_id, profile.subject) if profile else None
86
+ if (
87
+ not self._models
88
+ or account != self._catalog_account
89
+ or time.monotonic() - self._catalog_at > 300
90
+ ):
91
+ await self._refresh_catalog(access_token or await self.auth.access_token())
92
+
93
+ async def select_model(self, model_id: str) -> GenerationSelection:
94
+ await self._ensure_catalog()
95
+ model = next((item for item in self._models if item.id == model_id), None)
96
+ if model is None:
97
+ raise ValueError("That model is no longer available. Run /models to refresh the list.")
98
+ current = self.generation_selection()
99
+ effort = (
100
+ current.reasoning_effort
101
+ if current.reasoning_effort in model.reasoning_efforts
102
+ else None
103
+ )
104
+ self.auth.save_generation_preferences(model.id, model.name, effort)
105
+ return GenerationSelection(model, effort, connected=True)
106
+
107
+ async def cycle_reasoning(self) -> GenerationSelection:
108
+ await self._ensure_catalog()
109
+ current = self.generation_selection()
110
+ model = current.model
111
+ if model is None:
112
+ raise BackendNotConnected("Choose a ChatGPT model with /models first")
113
+ choices = (None, *model.reasoning_efforts)
114
+ index = (
115
+ choices.index(current.reasoning_effort) if current.reasoning_effort in choices else 0
116
+ )
117
+ effort = choices[(index + 1) % len(choices)]
118
+ self.auth.save_generation_preferences(model.id, model.name, effort)
119
+ return GenerationSelection(model, effort, connected=True)
120
+
121
+ async def select_reasoning(self, effort: ReasoningEffort | None) -> GenerationSelection:
122
+ await self._ensure_catalog()
123
+ model = self.generation_selection().model
124
+ if model is None:
125
+ raise BackendNotConnected("Choose a ChatGPT model with /models first")
126
+ if effort is not None and effort not in model.reasoning_efforts:
127
+ supported = ", ".join(("auto", *model.reasoning_efforts))
128
+ raise ValueError(f"{model.name} supports these thinking levels: {supported}.")
129
+ self.auth.save_generation_preferences(model.id, model.name, effort)
130
+ return GenerationSelection(model, effort, connected=True)
131
+
132
+ async def answer(
133
+ self,
134
+ filename: str,
135
+ question: str,
136
+ history: tuple[Message, ...],
137
+ hits: list[Hit],
138
+ ) -> Answer:
139
+ excerpts = bounded_hits(hits)
140
+ if not excerpts:
141
+ return insufficient_answer()
142
+ access_token = await self.auth.access_token()
143
+ await self._ensure_catalog(access_token)
144
+ selection = self.generation_selection()
145
+ if selection.model is None:
146
+ raise BackendNotConnected("Choose a ChatGPT model with /models first")
147
+ input_items = make_input(filename, question, history, excerpts)
148
+ for attempt in range(2):
149
+ try:
150
+ raw = await complete_chatgpt(
151
+ access_token=access_token,
152
+ model=selection.model.id,
153
+ instructions=INSTRUCTIONS,
154
+ input_items=input_items,
155
+ reasoning_effort=selection.reasoning_effort,
156
+ structured=True,
157
+ )
158
+ except BadRequestError as error:
159
+ raise BackendNotConnected(
160
+ "The selected model could not accept the structured-answer request. "
161
+ "Choose another model with /models and retry."
162
+ ) from error
163
+ try:
164
+ return parse_answer(raw, filename, excerpts)
165
+ except ValueError:
166
+ if attempt:
167
+ raise BackendNotConnected(
168
+ "ChatGPT could not produce a valid answer with supporting quotations. "
169
+ "Try a more specific question; no incomplete answer was saved."
170
+ ) from None
171
+ input_items.extend(
172
+ [
173
+ {"role": "assistant", "content": raw[:30_000]},
174
+ {
175
+ "role": "user",
176
+ "content": "Your response failed evidence validation. Return the "
177
+ "required schema with a supporting citation for each answer section. Use only "
178
+ "the current source IDs and exact quotes copied from their excerpts. No inline "
179
+ "citation markers in text. If unsupported, use insufficient_evidence.",
180
+ },
181
+ ]
182
+ )
183
+ raise RuntimeError("No validated answer")
184
+
185
+
186
+ async def list_account_models(access_token: str) -> tuple[ChatModel, ...]:
187
+ """Fetch the account's displayable model catalog in the server's order."""
188
+ try:
189
+ async with httpx.AsyncClient(timeout=20) as client:
190
+ response = await client.get(
191
+ MODEL_LIST_URL,
192
+ headers={"Authorization": f"Bearer {access_token}"},
193
+ )
194
+ response.raise_for_status()
195
+ return parse_model_catalog(response.json())
196
+ except httpx.HTTPStatusError as error:
197
+ if error.response.status_code in (401, 403):
198
+ raise BackendNotConnected(
199
+ "ChatGPT model access was denied. Reconnect with /login."
200
+ ) from error
201
+ raise BackendNotConnected(
202
+ "Could not load ChatGPT models. Retry /models shortly."
203
+ ) from error
204
+ except httpx.HTTPError as error:
205
+ raise BackendNotConnected(
206
+ "Could not reach ChatGPT. Check your connection and retry /models."
207
+ ) from error
@@ -0,0 +1,192 @@
1
+ """Secure token storage and owner-only account metadata for local sign-in."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+ from convo.core.contracts import REASONING_EFFORTS, ReasoningEffort
14
+
15
+
16
+ class CredentialStore(Protocol):
17
+ """Small seam around secure local token storage."""
18
+
19
+ def save(self, key: str, value: str) -> None: ...
20
+
21
+ def load(self, key: str) -> str | None: ...
22
+
23
+ def delete(self, key: str) -> None: ...
24
+
25
+
26
+ class KeyringCredentialStore:
27
+ """Store OAuth secrets in the operating system's credential manager."""
28
+
29
+ service_name = "convo"
30
+
31
+ def save(self, key: str, value: str) -> None:
32
+ import keyring
33
+
34
+ keyring.set_password(self.service_name, key, value)
35
+
36
+ def load(self, key: str) -> str | None:
37
+ import keyring
38
+
39
+ return keyring.get_password(self.service_name, key)
40
+
41
+ def delete(self, key: str) -> None:
42
+ import keyring
43
+ from keyring.errors import PasswordDeleteError
44
+
45
+ try:
46
+ keyring.delete_password(self.service_name, key)
47
+ except PasswordDeleteError:
48
+ pass
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class AccountProfile:
53
+ """Non-secret metadata for the one ChatGPT account connected to this CLI."""
54
+
55
+ client_id: str
56
+ subject: str
57
+ email: str | None
58
+ name: str | None
59
+ issuer: str
60
+ scopes: tuple[str, ...]
61
+ expires_at: float
62
+ selected_model: str | None = None
63
+ selected_model_name: str | None = None
64
+ selected_reasoning_effort: ReasoningEffort | None = None
65
+
66
+ @classmethod
67
+ def from_dict(cls, value: dict[str, object]) -> AccountProfile:
68
+ scopes = value.get("scopes")
69
+ if not isinstance(scopes, list) or not all(isinstance(scope, str) for scope in scopes):
70
+ raise ValueError("Invalid scopes in saved ChatGPT profile")
71
+ client_id = value.get("client_id")
72
+ subject = value.get("subject")
73
+ issuer = value.get("issuer")
74
+ expires_at = value.get("expires_at")
75
+ email = value.get("email")
76
+ name = value.get("name")
77
+ selected_model = value.get("selected_model")
78
+ selected_model_name = value.get("selected_model_name")
79
+ selected_effort = value.get("selected_reasoning_effort")
80
+ if not isinstance(client_id, str) or not client_id:
81
+ raise ValueError("Invalid identity in saved ChatGPT profile")
82
+ if not isinstance(subject, str) or not subject:
83
+ raise ValueError("Invalid identity in saved ChatGPT profile")
84
+ if not isinstance(issuer, str) or not issuer:
85
+ raise ValueError("Invalid identity in saved ChatGPT profile")
86
+ if not isinstance(expires_at, (float, int)):
87
+ raise ValueError("Invalid expiry in saved ChatGPT profile")
88
+ if email is not None and not isinstance(email, str):
89
+ raise ValueError("Invalid email in saved ChatGPT profile")
90
+ if name is not None and not isinstance(name, str):
91
+ raise ValueError("Invalid name in saved ChatGPT profile")
92
+ if selected_model is not None and not isinstance(selected_model, str):
93
+ raise ValueError("Invalid selected model in saved ChatGPT profile")
94
+ return cls(
95
+ client_id=client_id,
96
+ subject=subject,
97
+ email=email,
98
+ name=name,
99
+ issuer=issuer,
100
+ scopes=tuple(scopes),
101
+ expires_at=float(expires_at),
102
+ selected_model=selected_model,
103
+ selected_model_name=selected_model_name
104
+ if isinstance(selected_model_name, str)
105
+ else None,
106
+ selected_reasoning_effort=(
107
+ selected_effort
108
+ if isinstance(selected_effort, str) and selected_effort in REASONING_EFFORTS
109
+ else None
110
+ ),
111
+ )
112
+
113
+
114
+ def default_config_dir() -> Path:
115
+ """Return the per-user config directory; allow a test/developer override."""
116
+ override = os.environ.get("CONVO_CONFIG_HOME")
117
+ if override:
118
+ return Path(override).expanduser()
119
+ if sys.platform == "darwin":
120
+ return Path.home() / "Library" / "Application Support" / "convo"
121
+ if sys.platform == "win32":
122
+ return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "convo"
123
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "convo"
124
+
125
+
126
+ class AccountProfileStore:
127
+ """Persist a stable host ID and non-secret account details outside the repo."""
128
+
129
+ def __init__(self, directory: Path | None = None) -> None:
130
+ self.directory = (directory or default_config_dir()).expanduser()
131
+ self.profile_path = self.directory / "chatgpt-profile.json"
132
+ self.host_id_path = self.directory / "host-id"
133
+
134
+ def _ensure_private_directory(self) -> None:
135
+ self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
136
+ if os.name != "nt":
137
+ self.directory.chmod(0o700)
138
+
139
+ def host_id(self) -> str:
140
+ self._ensure_private_directory()
141
+ try:
142
+ value = self.host_id_path.read_text(encoding="utf-8").strip()
143
+ except FileNotFoundError:
144
+ value = ""
145
+ if value.startswith("urn:uuid:") and len(value) == len("urn:uuid:") + 36:
146
+ return value
147
+
148
+ from uuid import uuid4
149
+
150
+ value = f"urn:uuid:{uuid4()}"
151
+ self._write_private(self.host_id_path, value.encode("utf-8"))
152
+ return value
153
+
154
+ def load(self) -> AccountProfile | None:
155
+ try:
156
+ raw = self.profile_path.read_text(encoding="utf-8")
157
+ except FileNotFoundError:
158
+ return None
159
+ try:
160
+ value = json.loads(raw)
161
+ if not isinstance(value, dict):
162
+ raise ValueError("Saved ChatGPT profile must be a JSON object")
163
+ return AccountProfile.from_dict(value)
164
+ except (json.JSONDecodeError, ValueError) as error:
165
+ raise ValueError("Saved ChatGPT profile is invalid; sign in again") from error
166
+
167
+ def save(self, profile: AccountProfile) -> None:
168
+ self._ensure_private_directory()
169
+ content = json.dumps(asdict(profile), indent=2, sort_keys=True).encode("utf-8")
170
+ self._write_private(self.profile_path, content)
171
+
172
+ @staticmethod
173
+ def _write_private(path: Path, content: bytes) -> None:
174
+ """Atomically replace a file and keep it owner-readable on Unix."""
175
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
176
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
177
+ temporary_path = Path(temporary_name)
178
+ try:
179
+ if os.name != "nt":
180
+ os.fchmod(descriptor, 0o600)
181
+ with os.fdopen(descriptor, "wb") as stream:
182
+ stream.write(content)
183
+ stream.flush()
184
+ os.fsync(stream.fileno())
185
+ os.replace(temporary_path, path)
186
+ if os.name != "nt":
187
+ path.chmod(0o600)
188
+ finally:
189
+ try:
190
+ temporary_path.unlink()
191
+ except FileNotFoundError:
192
+ pass
convo/llm/models.py ADDED
@@ -0,0 +1,71 @@
1
+ """Normalize account catalogs and limit reasoning controls to known capabilities."""
2
+
3
+ import re
4
+
5
+ from convo.core.contracts import REASONING_EFFORTS, ChatModel, ReasoningEffort
6
+
7
+ # A conservative subset documented for these standard models. Catalog-provided
8
+ # capabilities take precedence; unfamiliar models keep the server's default.
9
+ _STANDARD_REASONING_MODELS = (
10
+ "gpt-5.5",
11
+ "gpt-5.6",
12
+ "gpt-6-astra",
13
+ "gpt-6-sol",
14
+ "gpt-6.1-sol",
15
+ "gpt-6-luna",
16
+ )
17
+
18
+
19
+ def reasoning_effort(value: object) -> ReasoningEffort | None:
20
+ if isinstance(value, str) and value in REASONING_EFFORTS:
21
+ return value
22
+ return None
23
+
24
+
25
+ def parse_model_catalog(payload: object) -> tuple[ChatModel, ...]:
26
+ if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
27
+ raise RuntimeError("ChatGPT returned an invalid model catalog")
28
+ models: list[ChatModel] = []
29
+ seen: set[str] = set()
30
+ for item in payload["models"]:
31
+ if not isinstance(item, dict) or item.get("visibility") != "list":
32
+ continue
33
+ slug = item.get("slug")
34
+ if not isinstance(slug, str) or not slug or slug in seen:
35
+ continue
36
+ seen.add(slug)
37
+ display_name = item.get("display_name")
38
+ capabilities = item.get(
39
+ "supported_reasoning_levels", item.get("supported_reasoning_efforts")
40
+ )
41
+ if isinstance(capabilities, list):
42
+ efforts = {
43
+ effort
44
+ for entry in capabilities
45
+ if (
46
+ effort := reasoning_effort(
47
+ entry.get("effort") if isinstance(entry, dict) else entry
48
+ )
49
+ )
50
+ is not None
51
+ }
52
+ supported = tuple(effort for effort in REASONING_EFFORTS if effort in efforts)
53
+ elif any(
54
+ re.fullmatch(re.escape(model) + r"(?:-\d{4}-\d{2}-\d{2})?", slug)
55
+ for model in _STANDARD_REASONING_MODELS
56
+ ):
57
+ supported = ("low", "medium", "high")
58
+ else:
59
+ supported = ()
60
+ default = reasoning_effort(
61
+ item.get("default_reasoning_level", item.get("default_reasoning_effort"))
62
+ )
63
+ models.append(
64
+ ChatModel(
65
+ id=slug,
66
+ name=display_name if isinstance(display_name, str) and display_name else slug,
67
+ reasoning_efforts=supported,
68
+ default_reasoning_effort=default if default in supported else None,
69
+ )
70
+ )
71
+ return tuple(models)
convo/llm/streaming.py ADDED
@@ -0,0 +1,62 @@
1
+ """Collect ChatGPT's required streamed Responses API events into one answer."""
2
+
3
+ from typing import cast
4
+
5
+ from openai import AsyncOpenAI, omit
6
+ from openai.types.responses import ResponseFormatTextJSONSchemaConfigParam, ResponseInputParam
7
+
8
+ from convo.core.contracts import ReasoningEffort
9
+ from convo.core.exceptions import BackendNotConnected
10
+ from convo.prompts.schema import response_format
11
+
12
+
13
+ async def complete_chatgpt(
14
+ access_token: str,
15
+ model: str,
16
+ instructions: str,
17
+ input_items: ResponseInputParam,
18
+ reasoning_effort: ReasoningEffort | None = None,
19
+ *,
20
+ structured: bool = False,
21
+ ) -> str:
22
+ """Return text only after a successful terminal response event."""
23
+ async with AsyncOpenAI(
24
+ api_key=access_token,
25
+ base_url="https://api.openai.com/v1",
26
+ max_retries=0,
27
+ timeout=90,
28
+ ) as client:
29
+ stream = await client.responses.create(
30
+ model=model,
31
+ instructions=instructions,
32
+ input=input_items,
33
+ store=False,
34
+ stream=True,
35
+ reasoning={"effort": reasoning_effort} if reasoning_effort is not None else omit,
36
+ text={"format": cast(ResponseFormatTextJSONSchemaConfigParam, response_format())}
37
+ if structured
38
+ else omit,
39
+ )
40
+ text: list[str] = []
41
+ completed = False
42
+ async with stream:
43
+ async for event in stream:
44
+ if event.type == "response.output_text.delta":
45
+ text.append(event.delta)
46
+ elif event.type == "response.refusal.delta":
47
+ raise BackendNotConnected(
48
+ "ChatGPT declined this request. Try a different question."
49
+ )
50
+ elif event.type == "response.failed":
51
+ error = event.response.error
52
+ code = error.code if error else "unknown_error"
53
+ raise RuntimeError(f"ChatGPT response failed ({code})")
54
+ elif event.type == "response.incomplete":
55
+ raise RuntimeError("ChatGPT response was incomplete")
56
+ elif event.type == "error":
57
+ raise RuntimeError("ChatGPT stream returned an error")
58
+ elif event.type == "response.completed":
59
+ completed = True
60
+ if not completed:
61
+ raise RuntimeError("ChatGPT stream ended before response.completed")
62
+ return "".join(text)
@@ -0,0 +1,43 @@
1
+ from alembic import context
2
+ from sqlalchemy import create_engine, pool
3
+
4
+ from convo.core.config import load_settings
5
+ from convo.db import models as models
6
+ from convo.db.base import Base
7
+
8
+ config = context.config
9
+ settings = load_settings()
10
+ target_metadata = Base.metadata
11
+
12
+
13
+ def run_migrations_offline() -> None:
14
+ context.configure(
15
+ url=settings.database_url,
16
+ target_metadata=target_metadata,
17
+ literal_binds=True,
18
+ dialect_opts={"paramstyle": "named"},
19
+ compare_type=True,
20
+ )
21
+ with context.begin_transaction():
22
+ context.run_migrations()
23
+
24
+
25
+ def run_migrations_online() -> None:
26
+ engine = create_engine(settings.database_url, poolclass=pool.NullPool)
27
+ try:
28
+ with engine.connect() as connection:
29
+ context.configure(
30
+ connection=connection,
31
+ target_metadata=target_metadata,
32
+ compare_type=True,
33
+ )
34
+ with context.begin_transaction():
35
+ context.run_migrations()
36
+ finally:
37
+ engine.dispose()
38
+
39
+
40
+ if context.is_offline_mode():
41
+ run_migrations_offline()
42
+ else:
43
+ run_migrations_online()
@@ -0,0 +1,22 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ """
6
+
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+ ${imports if imports else ""}
10
+
11
+ revision = ${repr(up_revision)}
12
+ down_revision = ${repr(down_revision)}
13
+ branch_labels = ${repr(branch_labels)}
14
+ depends_on = ${repr(depends_on)}
15
+
16
+
17
+ def upgrade() -> None:
18
+ ${upgrades if upgrades else "pass"}
19
+
20
+
21
+ def downgrade() -> None:
22
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,22 @@
1
+ """describe your schema change
2
+
3
+ Revision ID: 5c3b75f9c5c1
4
+ Revises: 732cf62e465c
5
+ """
6
+
7
+ revision = "5c3b75f9c5c1"
8
+ down_revision = "732cf62e465c"
9
+ branch_labels = None
10
+ depends_on = None
11
+
12
+
13
+ def upgrade() -> None:
14
+ # ### commands auto generated by Alembic - please adjust! ###
15
+ pass
16
+ # ### end Alembic commands ###
17
+
18
+
19
+ def downgrade() -> None:
20
+ # ### commands auto generated by Alembic - please adjust! ###
21
+ pass
22
+ # ### end Alembic commands ###
@@ -0,0 +1,49 @@
1
+ """create rag tables
2
+
3
+ Revision ID: 732cf62e465c
4
+ Revises: None
5
+ """
6
+
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+ from pgvector.sqlalchemy import VECTOR
10
+
11
+ revision = "732cf62e465c"
12
+ down_revision = None
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ op.execute("CREATE EXTENSION IF NOT EXISTS vector")
19
+ op.create_table(
20
+ "documents",
21
+ sa.Column("id", sa.UUID(), nullable=False),
22
+ sa.Column("source_path", sa.Text(), nullable=False),
23
+ sa.Column("content_hash", sa.String(64), nullable=False),
24
+ sa.Column("index_signature", sa.Text(), nullable=False),
25
+ sa.PrimaryKeyConstraint("id", name="pk_documents"),
26
+ )
27
+ op.create_table(
28
+ "chunks",
29
+ sa.Column("id", sa.Integer(), nullable=False),
30
+ sa.Column("document_id", sa.UUID(), nullable=False),
31
+ sa.Column("ordinal", sa.Integer(), nullable=False),
32
+ sa.Column("text", sa.Text(), nullable=False),
33
+ sa.Column("locator", sa.Text(), nullable=False),
34
+ sa.Column("embedding", VECTOR(1024), nullable=False),
35
+ sa.ForeignKeyConstraint(
36
+ ["document_id"],
37
+ ["documents.id"],
38
+ name="fk_chunks_document_id_documents",
39
+ ondelete="CASCADE",
40
+ ),
41
+ sa.PrimaryKeyConstraint("id", name="pk_chunks"),
42
+ sa.UniqueConstraint("document_id", "ordinal", name="uq_chunks_document_id"),
43
+ )
44
+
45
+
46
+ def downgrade() -> None:
47
+ op.drop_table("chunks")
48
+ op.drop_table("documents")
49
+ # Retain the extension: another application
@@ -0,0 +1,3 @@
1
+ # Migration revisions
2
+
3
+ Create the initial handwritten revision following backend.md step 4. It must enable the vector extension before creating the vector column. This folder contains no applied or generated revisions yet.