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/db/chunks.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import delete, func, select
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from convo.core.config import EMBEDDING_DIMENSIONS
|
|
9
|
+
from convo.core.contracts import ChunkData, Hit
|
|
10
|
+
from convo.db.models import ChunkRow, DocumentRow
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def keyword_query(question: str) -> str:
|
|
14
|
+
"""Natural questions use optional content terms rather than requiring every word."""
|
|
15
|
+
ignored = set(
|
|
16
|
+
"a an the what when where why who which how is are was were do does did can could "
|
|
17
|
+
"would should will you me i we it this that these those file document please tell "
|
|
18
|
+
"explain describe summarize summarise about in on of for to and or".split()
|
|
19
|
+
)
|
|
20
|
+
terms = dict.fromkeys(
|
|
21
|
+
term.casefold()
|
|
22
|
+
for term in re.findall(r"\w+(?:[-.]\w+)*", question)
|
|
23
|
+
if term.casefold() not in ignored
|
|
24
|
+
)
|
|
25
|
+
return " OR ".join(list(terms)[:32]) or question
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def replace_chunks(
|
|
29
|
+
session: Session,
|
|
30
|
+
document_id: UUID,
|
|
31
|
+
chunks: list[ChunkData],
|
|
32
|
+
vectors: list[list[float]],
|
|
33
|
+
) -> None:
|
|
34
|
+
if not chunks or len(chunks) != len(vectors):
|
|
35
|
+
raise ValueError("Chunks and vectors do not match")
|
|
36
|
+
if any(len(vector) != EMBEDDING_DIMENSIONS for vector in vectors):
|
|
37
|
+
raise ValueError("Incorrect embedding dimensions")
|
|
38
|
+
if any(not chunk.text.strip() or not chunk.locator for chunk in chunks):
|
|
39
|
+
raise ValueError("Chunks need text and a source locator")
|
|
40
|
+
if any(not math.isfinite(value) for vector in vectors for value in vector):
|
|
41
|
+
raise ValueError("Embeddings must contain finite values")
|
|
42
|
+
session.execute(delete(ChunkRow).where(ChunkRow.document_id == document_id))
|
|
43
|
+
session.add_all(
|
|
44
|
+
[
|
|
45
|
+
ChunkRow(
|
|
46
|
+
document_id=document_id,
|
|
47
|
+
ordinal=i,
|
|
48
|
+
text=chunk.text,
|
|
49
|
+
locator=chunk.locator,
|
|
50
|
+
embedding=vector,
|
|
51
|
+
)
|
|
52
|
+
for i, (chunk, vector) in enumerate(zip(chunks, vectors, strict=True))
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reciprocal_rank_fusion(rankings: list[list[Hit]], limit: int = 20) -> list[Hit]:
|
|
58
|
+
"""Fuse lexical and semantic ranks without comparing incompatible raw scores."""
|
|
59
|
+
scores: dict[object, float] = {}
|
|
60
|
+
hits: dict[object, Hit] = {}
|
|
61
|
+
for ranking in rankings:
|
|
62
|
+
seen = set()
|
|
63
|
+
for rank, hit in enumerate(ranking, 1):
|
|
64
|
+
key = hit.chunk_id if hit.chunk_id is not None else (hit.text, hit.locator)
|
|
65
|
+
if key in seen:
|
|
66
|
+
continue
|
|
67
|
+
seen.add(key)
|
|
68
|
+
hits[key] = hit
|
|
69
|
+
scores[key] = scores.get(key, 0.0) + 1.0 / (60 + rank)
|
|
70
|
+
# Stable insertion order breaks equal scores deterministically.
|
|
71
|
+
return [hits[key] for key in sorted(scores, key=lambda key: -scores[key])[:limit]]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def retrieve(
|
|
75
|
+
session: Session,
|
|
76
|
+
document_id: UUID,
|
|
77
|
+
signature: str,
|
|
78
|
+
query: list[float],
|
|
79
|
+
question: str,
|
|
80
|
+
limit: int = 20,
|
|
81
|
+
*,
|
|
82
|
+
overview: bool = False,
|
|
83
|
+
) -> list[Hit]:
|
|
84
|
+
document = session.get(DocumentRow, document_id)
|
|
85
|
+
if document is None or document.index_signature != signature:
|
|
86
|
+
raise ValueError("Missing or outdated index; select the file again")
|
|
87
|
+
if len(query) != EMBEDDING_DIMENSIONS or any(not math.isfinite(value) for value in query):
|
|
88
|
+
raise ValueError("Invalid query embedding")
|
|
89
|
+
if not 1 <= limit <= 50 or not question.strip():
|
|
90
|
+
raise ValueError("Use a non-empty query and a candidate limit between 1 and 50")
|
|
91
|
+
distance = ChunkRow.embedding.cosine_distance(query).label("distance")
|
|
92
|
+
columns = (ChunkRow.id, ChunkRow.text, ChunkRow.locator, distance)
|
|
93
|
+
if overview:
|
|
94
|
+
# Broad summaries need coverage, not a passage ranker's QA relevance cutoff.
|
|
95
|
+
rows = list(
|
|
96
|
+
session.execute(
|
|
97
|
+
select(*columns)
|
|
98
|
+
.where(ChunkRow.document_id == document_id)
|
|
99
|
+
.order_by(ChunkRow.ordinal)
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
size = min(limit, len(rows))
|
|
103
|
+
indices = [round(index * (len(rows) - 1) / max(1, size - 1)) for index in range(size)]
|
|
104
|
+
return [
|
|
105
|
+
Hit(rows[index].text, rows[index].locator, float(rows[index].distance), rows[index].id)
|
|
106
|
+
for index in indices
|
|
107
|
+
]
|
|
108
|
+
semantic = (
|
|
109
|
+
select(*columns)
|
|
110
|
+
.where(ChunkRow.document_id == document_id)
|
|
111
|
+
.order_by(distance, ChunkRow.ordinal)
|
|
112
|
+
.limit(limit)
|
|
113
|
+
)
|
|
114
|
+
lexical_query = func.websearch_to_tsquery("english", keyword_query(question))
|
|
115
|
+
lexical_rank = func.ts_rank_cd(ChunkRow.search_vector, lexical_query)
|
|
116
|
+
lexical = (
|
|
117
|
+
select(*columns)
|
|
118
|
+
.where(
|
|
119
|
+
ChunkRow.document_id == document_id,
|
|
120
|
+
ChunkRow.search_vector.op("@@")(lexical_query),
|
|
121
|
+
)
|
|
122
|
+
.order_by(lexical_rank.desc(), ChunkRow.ordinal)
|
|
123
|
+
.limit(limit)
|
|
124
|
+
)
|
|
125
|
+
rankings = [
|
|
126
|
+
[
|
|
127
|
+
Hit(text, locator, float(distance), identity)
|
|
128
|
+
for identity, text, locator, distance in session.execute(statement)
|
|
129
|
+
]
|
|
130
|
+
for statement in (semantic, lexical)
|
|
131
|
+
]
|
|
132
|
+
return reciprocal_rank_fusion(rankings, limit)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""SQLAlchemy storage for sessions and atomic, completed conversation turns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
11
|
+
from uuid import UUID, uuid4
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import func, select
|
|
14
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
15
|
+
|
|
16
|
+
from convo.core.config import Settings
|
|
17
|
+
from convo.core.contracts import (
|
|
18
|
+
Answer,
|
|
19
|
+
Document,
|
|
20
|
+
Message,
|
|
21
|
+
SessionSnapshot,
|
|
22
|
+
SessionSummary,
|
|
23
|
+
Source,
|
|
24
|
+
)
|
|
25
|
+
from convo.core.exceptions import BackendNotConnected
|
|
26
|
+
from convo.db.models import ChatSessionRow, DocumentRow, MessageRow
|
|
27
|
+
from convo.db.session import make_session_factory
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from sqlalchemy.engine import Engine
|
|
31
|
+
from sqlalchemy.orm import Session
|
|
32
|
+
|
|
33
|
+
from convo.db.session import SessionFactory
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Conversations:
|
|
37
|
+
"""Created cheaply at startup; connect only when a session is requested."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, settings: Settings) -> None:
|
|
40
|
+
self.settings = settings
|
|
41
|
+
self.engine: Engine | None = None
|
|
42
|
+
self.sessions: SessionFactory | None = None
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def transaction(self) -> Generator[Session]:
|
|
46
|
+
try:
|
|
47
|
+
if self.sessions is None:
|
|
48
|
+
self.engine, self.sessions = make_session_factory(self.settings)
|
|
49
|
+
with self.sessions.begin() as session:
|
|
50
|
+
yield session
|
|
51
|
+
except SQLAlchemyError as error:
|
|
52
|
+
raise BackendNotConnected(
|
|
53
|
+
"Could not access saved conversations. Check PostgreSQL/Docker/Podman "
|
|
54
|
+
"and run `convo --setup`."
|
|
55
|
+
) from error
|
|
56
|
+
|
|
57
|
+
def create(self, document: Document | None = None) -> SessionSnapshot:
|
|
58
|
+
session_id = uuid4()
|
|
59
|
+
title = document.path.name[:200] if document else "New conversation"
|
|
60
|
+
with self.transaction() as session:
|
|
61
|
+
if document and session.get(DocumentRow, UUID(document.id)) is None:
|
|
62
|
+
raise BackendNotConnected("The document snapshot is missing. Select it with /file.")
|
|
63
|
+
session.add(
|
|
64
|
+
ChatSessionRow(
|
|
65
|
+
id=session_id,
|
|
66
|
+
document_id=UUID(document.id) if document else None,
|
|
67
|
+
title=title,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return SessionSnapshot(str(session_id), title, document)
|
|
71
|
+
|
|
72
|
+
def list(self) -> tuple[SessionSummary, ...]:
|
|
73
|
+
with self.transaction() as session:
|
|
74
|
+
statement = (
|
|
75
|
+
select(ChatSessionRow, DocumentRow.source_path, func.count(MessageRow.id))
|
|
76
|
+
.outerjoin(DocumentRow, DocumentRow.id == ChatSessionRow.document_id)
|
|
77
|
+
.outerjoin(MessageRow, MessageRow.session_id == ChatSessionRow.id)
|
|
78
|
+
.group_by(ChatSessionRow.id, DocumentRow.source_path)
|
|
79
|
+
.order_by(ChatSessionRow.updated_at.desc(), ChatSessionRow.id)
|
|
80
|
+
)
|
|
81
|
+
return tuple(
|
|
82
|
+
SessionSummary(
|
|
83
|
+
str(row.id), row.title, Path(path).name if path else None, row.updated_at, count
|
|
84
|
+
)
|
|
85
|
+
for row, path, count in session.execute(statement)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def load(self, session_id: str) -> SessionSnapshot:
|
|
89
|
+
try:
|
|
90
|
+
identity = UUID(session_id)
|
|
91
|
+
except ValueError as error:
|
|
92
|
+
raise BackendNotConnected("Choose a valid session with /sessions.") from error
|
|
93
|
+
with self.transaction() as session:
|
|
94
|
+
row = session.get(ChatSessionRow, identity)
|
|
95
|
+
if row is None:
|
|
96
|
+
raise BackendNotConnected("This conversation no longer exists. Use /sessions.")
|
|
97
|
+
document = None
|
|
98
|
+
if row.document_id:
|
|
99
|
+
stored = session.get(DocumentRow, row.document_id)
|
|
100
|
+
if stored is None:
|
|
101
|
+
raise BackendNotConnected("The saved document snapshot is missing.")
|
|
102
|
+
document = Document(str(stored.id), Path(stored.source_path))
|
|
103
|
+
messages = session.scalars(
|
|
104
|
+
select(MessageRow)
|
|
105
|
+
.where(MessageRow.session_id == identity)
|
|
106
|
+
.order_by(MessageRow.ordinal)
|
|
107
|
+
).all()
|
|
108
|
+
history = tuple(
|
|
109
|
+
Message(
|
|
110
|
+
cast(Literal["user", "assistant"], message.role),
|
|
111
|
+
message.content,
|
|
112
|
+
tuple(Source(**source) for source in message.sources),
|
|
113
|
+
message.structured,
|
|
114
|
+
)
|
|
115
|
+
for message in messages
|
|
116
|
+
)
|
|
117
|
+
return SessionSnapshot(str(row.id), row.title, document, history)
|
|
118
|
+
|
|
119
|
+
def append_turn(
|
|
120
|
+
self, session_id: str, question: str, answer: Answer, expected_messages: int
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Commit both messages together; never save an incomplete or stale answer."""
|
|
123
|
+
identity = UUID(session_id)
|
|
124
|
+
with self.transaction() as session:
|
|
125
|
+
row = session.get(ChatSessionRow, identity, with_for_update=True)
|
|
126
|
+
if row is None:
|
|
127
|
+
raise BackendNotConnected("The conversation no longer exists. Use /sessions.")
|
|
128
|
+
count = (
|
|
129
|
+
session.scalar(
|
|
130
|
+
select(func.count())
|
|
131
|
+
.select_from(MessageRow)
|
|
132
|
+
.where(MessageRow.session_id == identity)
|
|
133
|
+
)
|
|
134
|
+
or 0
|
|
135
|
+
)
|
|
136
|
+
if count != expected_messages:
|
|
137
|
+
raise BackendNotConnected(
|
|
138
|
+
"This conversation changed in another process. Reopen it with /sessions and retry."
|
|
139
|
+
)
|
|
140
|
+
session.add_all(
|
|
141
|
+
[
|
|
142
|
+
MessageRow(
|
|
143
|
+
session_id=identity,
|
|
144
|
+
ordinal=count,
|
|
145
|
+
role="user",
|
|
146
|
+
content=question,
|
|
147
|
+
sources=[],
|
|
148
|
+
structured=None,
|
|
149
|
+
),
|
|
150
|
+
MessageRow(
|
|
151
|
+
session_id=identity,
|
|
152
|
+
ordinal=count + 1,
|
|
153
|
+
role="assistant",
|
|
154
|
+
content=answer.text,
|
|
155
|
+
sources=[asdict(source) for source in answer.sources],
|
|
156
|
+
structured=answer.structured,
|
|
157
|
+
),
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
if count == 0:
|
|
161
|
+
row.title = question.strip()[:200]
|
|
162
|
+
row.updated_at = datetime.now(UTC)
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
if self.engine is not None:
|
|
166
|
+
self.engine.dispose()
|
|
167
|
+
self.engine, self.sessions = None, None
|
convo/db/documents.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.dialects.postgresql import insert
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from convo.db.models import DocumentRow
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def upsert_document(
|
|
11
|
+
session: Session,
|
|
12
|
+
document_id: UUID,
|
|
13
|
+
path: Path,
|
|
14
|
+
content_hash: str,
|
|
15
|
+
signature: str,
|
|
16
|
+
) -> None:
|
|
17
|
+
values = {
|
|
18
|
+
"id": document_id,
|
|
19
|
+
"source_path": str(path),
|
|
20
|
+
"content_hash": content_hash,
|
|
21
|
+
"index_signature": signature,
|
|
22
|
+
}
|
|
23
|
+
statement = insert(DocumentRow).values(**values)
|
|
24
|
+
session.execute(
|
|
25
|
+
statement.on_conflict_do_update(
|
|
26
|
+
index_elements=[DocumentRow.id],
|
|
27
|
+
set_={
|
|
28
|
+
"source_path": str(path),
|
|
29
|
+
"content_hash": content_hash,
|
|
30
|
+
"index_signature": signature,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
# The upsert locks the document row until commit, serializing replacement.
|
convo/db/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from uuid import UUID, uuid4
|
|
3
|
+
|
|
4
|
+
from pgvector.sqlalchemy import VECTOR
|
|
5
|
+
from sqlalchemy import (
|
|
6
|
+
Computed,
|
|
7
|
+
DateTime,
|
|
8
|
+
ForeignKey,
|
|
9
|
+
Index,
|
|
10
|
+
Integer,
|
|
11
|
+
String,
|
|
12
|
+
Text,
|
|
13
|
+
UniqueConstraint,
|
|
14
|
+
func,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
|
17
|
+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
18
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
19
|
+
|
|
20
|
+
from convo.core.config import EMBEDDING_DIMENSIONS
|
|
21
|
+
from convo.db.base import Base
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DocumentRow(Base):
|
|
25
|
+
__tablename__ = "documents"
|
|
26
|
+
|
|
27
|
+
id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True)
|
|
28
|
+
source_path: Mapped[str] = mapped_column(Text, nullable=False)
|
|
29
|
+
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
30
|
+
index_signature: Mapped[str] = mapped_column(Text, nullable=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChunkRow(Base):
|
|
34
|
+
__tablename__ = "chunks"
|
|
35
|
+
__table_args__ = (
|
|
36
|
+
UniqueConstraint("document_id", "ordinal"),
|
|
37
|
+
Index("ix_chunks_search_vector", "search_vector", postgresql_using="gin"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
41
|
+
document_id: Mapped[UUID] = mapped_column(
|
|
42
|
+
PG_UUID(as_uuid=True),
|
|
43
|
+
ForeignKey("documents.id", ondelete="CASCADE"),
|
|
44
|
+
nullable=False,
|
|
45
|
+
)
|
|
46
|
+
ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
47
|
+
text: Mapped[str] = mapped_column(Text, nullable=False)
|
|
48
|
+
locator: Mapped[str] = mapped_column(Text, nullable=False)
|
|
49
|
+
embedding: Mapped[list[float]] = mapped_column(VECTOR(EMBEDDING_DIMENSIONS), nullable=False)
|
|
50
|
+
search_vector: Mapped[str] = mapped_column(
|
|
51
|
+
TSVECTOR, Computed("to_tsvector('english'::regconfig, text)", persisted=True)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ChatSessionRow(Base):
|
|
56
|
+
__tablename__ = "chat_sessions"
|
|
57
|
+
__table_args__ = (Index("ix_chat_sessions_updated_at", "updated_at"),)
|
|
58
|
+
|
|
59
|
+
id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
|
60
|
+
document_id: Mapped[UUID | None] = mapped_column(
|
|
61
|
+
PG_UUID(as_uuid=True), ForeignKey("documents.id", ondelete="RESTRICT"), nullable=True
|
|
62
|
+
)
|
|
63
|
+
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
64
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
65
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MessageRow(Base):
|
|
69
|
+
__tablename__ = "messages"
|
|
70
|
+
__table_args__ = (UniqueConstraint("session_id", "ordinal"),)
|
|
71
|
+
|
|
72
|
+
id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
|
73
|
+
session_id: Mapped[UUID] = mapped_column(
|
|
74
|
+
PG_UUID(as_uuid=True), ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False
|
|
75
|
+
)
|
|
76
|
+
ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
77
|
+
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
78
|
+
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
79
|
+
sources: Mapped[list[dict[str, str]]] = mapped_column(JSONB, nullable=False, default=list)
|
|
80
|
+
structured: Mapped[dict[str, object] | None] = mapped_column(JSONB, nullable=True)
|
|
81
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
convo/db/session.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Create the PostgreSQL engine and SQLAlchemy session factory.
|
|
2
|
+
|
|
3
|
+
Each database operation gets its own short-lived session.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import create_engine
|
|
11
|
+
from sqlalchemy.engine import Engine
|
|
12
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
13
|
+
|
|
14
|
+
from convo.core.config import Settings
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
SessionFactory = sessionmaker[Session]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_session_factory(settings: Settings) -> tuple[Engine, SessionFactory]:
|
|
21
|
+
engine = create_engine(
|
|
22
|
+
settings.database_url,
|
|
23
|
+
pool_pre_ping=True,
|
|
24
|
+
echo=False,
|
|
25
|
+
connect_args={"connect_timeout": 10},
|
|
26
|
+
)
|
|
27
|
+
return engine, sessionmaker(engine, expire_on_commit=False)
|
convo/llm/__init__.py
ADDED
|
File without changes
|