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/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