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
@@ -0,0 +1,66 @@
1
+ """Add full-text search and durable conversations without replacing existing documents.
2
+
3
+ Revision ID: a42e9d8f0b31
4
+ Revises: 5c3b75f9c5c1
5
+ """
6
+
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+ from sqlalchemy.dialects import postgresql
10
+
11
+ revision = "a42e9d8f0b31"
12
+ down_revision = "5c3b75f9c5c1"
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ op.add_column(
19
+ "chunks",
20
+ sa.Column(
21
+ "search_vector",
22
+ postgresql.TSVECTOR(),
23
+ sa.Computed("to_tsvector('english'::regconfig, text)", persisted=True),
24
+ nullable=False,
25
+ ),
26
+ )
27
+ op.create_index("ix_chunks_search_vector", "chunks", ["search_vector"], postgresql_using="gin")
28
+ op.create_table(
29
+ "chat_sessions",
30
+ sa.Column("id", sa.UUID(), nullable=False),
31
+ sa.Column("document_id", sa.UUID(), nullable=True),
32
+ sa.Column("title", sa.String(200), nullable=False),
33
+ sa.Column(
34
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
35
+ ),
36
+ sa.Column(
37
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
38
+ ),
39
+ sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="RESTRICT"),
40
+ sa.PrimaryKeyConstraint("id"),
41
+ )
42
+ op.create_index("ix_chat_sessions_updated_at", "chat_sessions", ["updated_at"])
43
+ op.create_table(
44
+ "messages",
45
+ sa.Column("id", sa.UUID(), nullable=False),
46
+ sa.Column("session_id", sa.UUID(), nullable=False),
47
+ sa.Column("ordinal", sa.Integer(), nullable=False),
48
+ sa.Column("role", sa.String(16), nullable=False),
49
+ sa.Column("content", sa.Text(), nullable=False),
50
+ sa.Column("sources", postgresql.JSONB(), nullable=False),
51
+ sa.Column("structured", postgresql.JSONB(), nullable=True),
52
+ sa.Column(
53
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
54
+ ),
55
+ sa.ForeignKeyConstraint(["session_id"], ["chat_sessions.id"], ondelete="CASCADE"),
56
+ sa.PrimaryKeyConstraint("id"),
57
+ sa.UniqueConstraint("session_id", "ordinal"),
58
+ )
59
+
60
+
61
+ def downgrade() -> None:
62
+ op.drop_table("messages")
63
+ op.drop_index("ix_chat_sessions_updated_at", table_name="chat_sessions")
64
+ op.drop_table("chat_sessions")
65
+ op.drop_index("ix_chunks_search_vector", table_name="chunks")
66
+ op.drop_column("chunks", "search_vector")
File without changes
convo/pipeline/chat.py ADDED
@@ -0,0 +1,129 @@
1
+ """One chat flow: route, retrieve/rerank, generate a validated answer, persist the turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from convo.core.config import Settings
11
+ from convo.core.contracts import (
12
+ Answer,
13
+ ChatModel,
14
+ Document,
15
+ GenerationSelection,
16
+ Message,
17
+ ReasoningEffort,
18
+ SessionSnapshot,
19
+ SessionSummary,
20
+ )
21
+ from convo.llm.auth import ChatGPTAuth
22
+ from convo.llm.client import ChatGPTProvider
23
+ from convo.llm.credentials import KeyringCredentialStore
24
+ from convo.pipeline.routing import conversation_reply, search_query
25
+ from convo.pipeline.worker import IndexWorker
26
+
27
+ if TYPE_CHECKING:
28
+ from convo.db.conversations import Conversations
29
+
30
+
31
+ class RagBackend:
32
+ def __init__(self, settings: Settings) -> None:
33
+ self.settings = settings
34
+ self.documents = IndexWorker(settings)
35
+ self.auth = ChatGPTAuth(KeyringCredentialStore())
36
+ self.chat = ChatGPTProvider(self.auth)
37
+ self._conversations: Conversations | None = None
38
+ self._session_lock = asyncio.Lock()
39
+
40
+ def _store(self) -> Conversations:
41
+ if self._conversations is None:
42
+ from convo.db.conversations import Conversations
43
+
44
+ self._conversations = Conversations(self.settings)
45
+ return self._conversations
46
+
47
+ async def prepare(self, path: Path) -> Document:
48
+ return await self.documents.prepare(path)
49
+
50
+ async def prepare_with_progress(self, path: Path, progress: Callable[[str], None]) -> Document:
51
+ return await self.documents.prepare(path, progress)
52
+
53
+ async def _answer(
54
+ self,
55
+ document: Document | None,
56
+ question: str,
57
+ history: tuple[Message, ...],
58
+ progress: Callable[[str], None] | None = None,
59
+ ) -> Answer:
60
+ if not question.strip() or len(question) > 4_000:
61
+ from convo.core.exceptions import BackendNotConnected
62
+
63
+ raise BackendNotConnected("Use a question between 1 and 4,000 characters.")
64
+ conversational = conversation_reply(question)
65
+ if conversational:
66
+ return Answer(conversational)
67
+ if document is None:
68
+ return Answer("Choose a file with /file, then ask me a question about it.")
69
+ query = search_query(question, history)
70
+ hits = await self.documents.search(document, query, progress)
71
+ if progress:
72
+ progress("writing a grounded answer…")
73
+ return await self.chat.answer(document.path.name, question, history, hits)
74
+
75
+ async def answer(
76
+ self, document: Document, question: str, history: tuple[Message, ...]
77
+ ) -> Answer:
78
+ return await self._answer(document, question, history)
79
+
80
+ async def new_session(self, document: Document | None = None) -> SessionSnapshot:
81
+ async with self._session_lock:
82
+ return await asyncio.to_thread(lambda: self._store().create(document))
83
+
84
+ async def list_sessions(self) -> tuple[SessionSummary, ...]:
85
+ async with self._session_lock:
86
+ return await asyncio.to_thread(lambda: self._store().list())
87
+
88
+ async def load_session(self, session_id: str) -> SessionSnapshot:
89
+ async with self._session_lock:
90
+ return await asyncio.to_thread(lambda: self._store().load(session_id))
91
+
92
+ async def answer_session(
93
+ self, session_id: str, question: str, progress: Callable[[str], None] | None = None
94
+ ) -> Answer:
95
+ # No database transaction stays open during embedding or the LLM request.
96
+ snapshot = await self.load_session(session_id)
97
+ answer = await self._answer(snapshot.document, question, snapshot.history, progress)
98
+ async with self._session_lock:
99
+ await asyncio.to_thread(
100
+ lambda: self._store().append_turn(
101
+ session_id, question, answer, expected_messages=len(snapshot.history)
102
+ )
103
+ )
104
+ return answer
105
+
106
+ async def login_openai(self) -> str:
107
+ account = await self.auth.login()
108
+ self.chat.reset_catalog()
109
+ return account
110
+
111
+ def generation_selection(self) -> GenerationSelection:
112
+ return self.chat.generation_selection()
113
+
114
+ async def list_models(self) -> tuple[ChatModel, ...]:
115
+ return await self.chat.list_models()
116
+
117
+ async def select_model(self, model_id: str) -> GenerationSelection:
118
+ return await self.chat.select_model(model_id)
119
+
120
+ async def cycle_reasoning(self) -> GenerationSelection:
121
+ return await self.chat.cycle_reasoning()
122
+
123
+ async def select_reasoning(self, effort: ReasoningEffort | None) -> GenerationSelection:
124
+ return await self.chat.select_reasoning(effort)
125
+
126
+ def close(self) -> None:
127
+ self.documents.close()
128
+ if self._conversations is not None:
129
+ self._conversations.close()
@@ -0,0 +1,42 @@
1
+ """Convert reader documents into the application's small chunk contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from llama_index.core.schema import Document
10
+
11
+ from convo.core.contracts import ChunkData
12
+
13
+ MAX_CHUNKS = 2_000
14
+
15
+
16
+ def split_documents(
17
+ documents: list[Document], tokenizer: Callable[[str], list[int]]
18
+ ) -> list[ChunkData]:
19
+ """Split reader output using the embedding model's tokenizer and chunk size."""
20
+ from llama_index.core.node_parser import SentenceSplitter
21
+ from llama_index.core.schema import MetadataMode
22
+
23
+ splitter = SentenceSplitter(
24
+ chunk_size=512,
25
+ chunk_overlap=64,
26
+ tokenizer=tokenizer,
27
+ include_metadata=False,
28
+ include_prev_next_rel=False,
29
+ )
30
+ chunks: list[ChunkData] = []
31
+ for section, document in enumerate(documents, 1):
32
+ page = document.metadata.get("page_label")
33
+ locator = f"page {page}" if page is not None else f"reader section {section}"
34
+ for node in splitter.get_nodes_from_documents([document]):
35
+ text = node.get_content(metadata_mode=MetadataMode.NONE).strip()
36
+ if text:
37
+ chunks.append(ChunkData(text=text, locator=locator))
38
+ if len(chunks) > MAX_CHUNKS:
39
+ raise ValueError("File produced too many chunks")
40
+ if not chunks:
41
+ raise ValueError("File did not produce any chunks")
42
+ return chunks
@@ -0,0 +1,83 @@
1
+ """Generate Qwen3 vectors locally with sentence-transformers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+
9
+ if TYPE_CHECKING:
10
+ from sentence_transformers import SentenceTransformer
11
+
12
+ from convo.core.config import EMBEDDING_DIMENSIONS
13
+
14
+
15
+ class Embedder:
16
+ def __init__(self, model_name: str) -> None:
17
+ self.name = model_name
18
+ self._model: SentenceTransformer | None = None
19
+
20
+ @property
21
+ def model(self) -> SentenceTransformer:
22
+ if self._model is None:
23
+ from sentence_transformers import SentenceTransformer
24
+
25
+ # A cached model should not make HTTP checks on every new CLI session.
26
+ model = None
27
+ try:
28
+ model = SentenceTransformer(self.name, local_files_only=True)
29
+ except (OSError, TypeError):
30
+ # A partial ST cache can return an empty pooling config and raise
31
+ # TypeError instead of OSError. Let the Hub fill missing assets.
32
+ pass
33
+ # Leave the exception scope first so a partial model and its traceback
34
+ # can be released before allocating the replacement weights.
35
+ if model is None:
36
+ model = SentenceTransformer(self.name)
37
+ self._model = model
38
+ self._model.max_seq_length = 2048
39
+ return self._model
40
+
41
+ @property
42
+ def signature(self) -> str:
43
+ # Bump v1 after changing reader behavior, tokenizer, prompts or model revision.
44
+ return f"{self.name}:qwen-tokenizer:512tokens:64overlap:normalized:2048max:v1"
45
+
46
+ def tokenize(self, text: str) -> list[int]:
47
+ return list(self.model.tokenizer.encode(text, add_special_tokens=False))
48
+
49
+ def documents(self, texts: list[str]) -> list[list[float]]:
50
+ if not texts:
51
+ raise ValueError("Cannot embed an empty chunk list")
52
+ vectors = np.asarray(
53
+ self.model.encode(
54
+ texts,
55
+ batch_size=8,
56
+ normalize_embeddings=True,
57
+ convert_to_numpy=True,
58
+ show_progress_bar=False,
59
+ ),
60
+ dtype=np.float32,
61
+ )
62
+ if vectors.shape != (len(texts), EMBEDDING_DIMENSIONS):
63
+ raise ValueError("Unexpected embedding dimensions")
64
+ if not np.isfinite(vectors).all():
65
+ raise ValueError("Invalid embeddings")
66
+ return vectors.tolist()
67
+
68
+ def query(self, question: str) -> list[float]:
69
+ if not question.strip():
70
+ raise ValueError("Cannot embed an empty question")
71
+ vector = np.asarray(
72
+ self.model.encode(
73
+ [question],
74
+ prompt_name="query",
75
+ normalize_embeddings=True,
76
+ convert_to_numpy=True,
77
+ show_progress_bar=False,
78
+ )[0],
79
+ dtype=np.float32,
80
+ )
81
+ if vector.shape != (EMBEDDING_DIMENSIONS,) or not np.isfinite(vector).all():
82
+ raise ValueError("Unexpected query embedding")
83
+ return vector.tolist()
@@ -0,0 +1,160 @@
1
+ """Own file preparation and document-scoped pgvector search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import tempfile
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import TypeVar
11
+ from uuid import NAMESPACE_URL, UUID, uuid5
12
+
13
+ from sqlalchemy import text
14
+ from sqlalchemy.exc import OperationalError, SQLAlchemyError
15
+
16
+ from convo.core.config import Settings
17
+ from convo.core.contracts import Document, Hit
18
+ from convo.core.exceptions import BackendNotConnected
19
+ from convo.db.chunks import replace_chunks, retrieve
20
+ from convo.db.documents import upsert_document
21
+ from convo.db.session import make_session_factory
22
+ from convo.pipeline.chunking import split_documents
23
+ from convo.pipeline.embeddings import Embedder
24
+ from convo.pipeline.ingestion import MAX_BYTES, read_file
25
+ from convo.pipeline.reranking import Reranker
26
+ from convo.pipeline.routing import is_overview
27
+
28
+ _Result = TypeVar("_Result")
29
+
30
+
31
+ class DocumentIndex:
32
+ """Coordinate parsing, embedding, PostgreSQL writes, and scoped retrieval."""
33
+
34
+ def __init__(self, settings: Settings) -> None:
35
+ self.engine, self.sessions = make_session_factory(settings)
36
+ self.embedder = Embedder(settings.embedding_model)
37
+ self.reranker = Reranker(settings.reranker_model, settings.rerank_min_score)
38
+ self.candidate_limit = settings.retrieval_candidates
39
+ self.lock = asyncio.Lock()
40
+ self._closed = False
41
+ self.progress: Callable[[str], None] = lambda _stage: None
42
+
43
+ def _check_database(self) -> None:
44
+ """Fail before parsing or downloading weights when storage isn't ready."""
45
+ try:
46
+ with self.engine.connect() as connection:
47
+ connection.execute(text("SELECT id, embedding, search_vector FROM chunks LIMIT 0"))
48
+ except OperationalError as error:
49
+ raise BackendNotConnected(
50
+ "Cannot connect to PostgreSQL. Check DATABASE_URL and start your Podman "
51
+ "machine/container (`podman start convo-postgres`), then select the file again."
52
+ ) from error
53
+ except SQLAlchemyError as error:
54
+ raise BackendNotConnected(
55
+ "The RAG database is not ready. Run `convo --setup`, then select the file again."
56
+ ) from error
57
+
58
+ def _prepare(self, path: Path) -> Document:
59
+ self.progress("checking PostgreSQL…")
60
+ self._check_database()
61
+ path = path.resolve(strict=True)
62
+ before = path.stat()
63
+ if before.st_size > MAX_BYTES:
64
+ raise ValueError("File exceeds 10 MB")
65
+ with path.open("rb") as stream:
66
+ raw = stream.read(MAX_BYTES + 1)
67
+ if len(raw) > MAX_BYTES:
68
+ raise ValueError("File grew past the size limit")
69
+ content_hash = hashlib.sha256(raw).hexdigest()
70
+
71
+ # The reader parses the exact bytes that are hashed, even if the source changes later.
72
+ self.progress("reading file…")
73
+ with tempfile.TemporaryDirectory(prefix="convo-reader-") as temporary_directory:
74
+ snapshot = Path(temporary_directory) / path.name
75
+ snapshot.write_bytes(raw)
76
+ documents = read_file(snapshot)
77
+ self.progress("loading Qwen3 (first use downloads model weights)…")
78
+ # Loading here gives the UI a distinct stage before tokenization starts.
79
+ self.embedder.model
80
+ self.progress("splitting text…")
81
+ chunks = split_documents(documents, tokenizer=self.embedder.tokenize)
82
+ self.progress(f"embedding {len(chunks)} chunks…")
83
+ vectors = self.embedder.documents([chunk.text for chunk in chunks])
84
+
85
+ after = path.stat()
86
+ with path.open("rb") as stream:
87
+ current_raw = stream.read(MAX_BYTES + 1)
88
+ current_hash = hashlib.sha256(current_raw).hexdigest()
89
+ if (
90
+ len(current_raw) > MAX_BYTES
91
+ or current_hash != content_hash
92
+ or (before.st_mtime_ns, before.st_size) != (after.st_mtime_ns, after.st_size)
93
+ ):
94
+ raise ValueError("File changed during indexing; select it again")
95
+
96
+ signature = self.embedder.signature
97
+ # Snapshot identity includes path, exact content, and reader/embedding recipe.
98
+ document_id = uuid5(NAMESPACE_URL, f"{path}\n{content_hash}\n{signature}")
99
+ # Complete parsing/model work before opening the transaction; only row writes are atomic.
100
+ self.progress("saving index to PostgreSQL…")
101
+ with self.sessions.begin() as session:
102
+ upsert_document(session, document_id, path, content_hash, signature)
103
+ replace_chunks(session, document_id, chunks, vectors)
104
+ return Document(id=str(document_id), path=path)
105
+
106
+ async def prepare(self, path: Path) -> Document:
107
+ self._ensure_open()
108
+ async with self.lock:
109
+ return await self._run_worker(self._prepare, path)
110
+
111
+ def _retrieve(self, document: Document, question: str) -> list[Hit]:
112
+ self.progress("embedding the question…")
113
+ vector = self.embedder.query(question)
114
+ overview = is_overview(question)
115
+ self.progress(
116
+ "sampling sections for an overview…" if overview else "searching vectors and keywords…"
117
+ )
118
+ with self.sessions() as session:
119
+ candidates = retrieve(
120
+ session,
121
+ UUID(document.id),
122
+ self.embedder.signature,
123
+ vector,
124
+ question,
125
+ limit=5 if overview else self.candidate_limit,
126
+ overview=overview,
127
+ )
128
+ # The database session is closed before model loading/reranking.
129
+ self.progress("reranking candidates (first use downloads a small model)…")
130
+ return self.reranker.rerank(question, candidates, filter_weak=not overview)
131
+
132
+ async def search(self, document: Document, question: str) -> list[Hit]:
133
+ self._ensure_open()
134
+ if not question.strip() or len(question) > 4_000:
135
+ raise ValueError("Use a question between 1 and 4,000 characters")
136
+ async with self.lock:
137
+ return await self._run_worker(self._retrieve, document, question)
138
+
139
+ async def _run_worker(self, function: Callable[..., _Result], *args: object) -> _Result:
140
+ """Keep a worker under the lock until its thread has really stopped."""
141
+ task = asyncio.create_task(asyncio.to_thread(function, *args))
142
+ try:
143
+ return await asyncio.shield(task)
144
+ except asyncio.CancelledError:
145
+ # Cancelling asyncio.to_thread cannot stop its thread. Wait here so a later
146
+ # operation cannot use the embedder or engine concurrently with that thread.
147
+ try:
148
+ await asyncio.shield(task)
149
+ except Exception:
150
+ pass
151
+ raise
152
+
153
+ def _ensure_open(self) -> None:
154
+ if self._closed:
155
+ raise RuntimeError("Document index is closed")
156
+
157
+ def close(self) -> None:
158
+ if not self._closed:
159
+ self.engine.dispose()
160
+ self._closed = True
@@ -0,0 +1,46 @@
1
+ """Read one selected file with LlamaIndex's file readers only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+ from zipfile import ZipFile
8
+
9
+ if TYPE_CHECKING:
10
+ from llama_index.core.schema import Document
11
+
12
+ SUPPORTED = {".txt", ".md", ".pdf", ".docx", ".csv", ".json", ".py", ".rst", ".log"}
13
+ MAX_BYTES = 10 * 1024 * 1024
14
+ MAX_EXTRACTED_CHARACTERS = 500_000
15
+ MAX_DOCX_EXPANDED_BYTES = 50 * 1024 * 1024
16
+
17
+
18
+ def read_file(path: Path) -> list[Document]:
19
+ """Load text and reader metadata for one file; never scan its directory."""
20
+ if not path.is_file() or path.suffix.lower() not in SUPPORTED:
21
+ raise ValueError("Choose a supported file")
22
+ if path.stat().st_size > MAX_BYTES:
23
+ raise ValueError("File exceeds 10 MB")
24
+ if path.suffix.lower() == ".docx":
25
+ with ZipFile(path) as archive:
26
+ if sum(item.file_size for item in archive.infolist()) > MAX_DOCX_EXPANDED_BYTES:
27
+ raise ValueError("Expanded DOCX is too large")
28
+
29
+ from llama_index.core import SimpleDirectoryReader
30
+ from llama_index.readers.file import PDFReader
31
+
32
+ documents = SimpleDirectoryReader(
33
+ input_files=[str(path)],
34
+ encoding="utf-8",
35
+ errors="strict",
36
+ raise_on_error=True,
37
+ # Keep PDF pages separate, preserving page_label metadata for sources.
38
+ file_extractor={".pdf": PDFReader(return_full_document=False)},
39
+ ).load_data()
40
+ documents = [document for document in documents if document.text.strip()]
41
+ if (
42
+ not documents
43
+ or sum(len(document.text) for document in documents) > MAX_EXTRACTED_CHARACTERS
44
+ ):
45
+ raise ValueError("Empty file or too much extracted text")
46
+ return documents
@@ -0,0 +1,63 @@
1
+ """Rerank the hybrid candidate pool with a small, local cross-encoder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+
10
+ from convo.core.contracts import Hit
11
+
12
+ if TYPE_CHECKING:
13
+ from sentence_transformers import CrossEncoder
14
+
15
+
16
+ class Reranker:
17
+ def __init__(self, name: str, min_score: float = -3.0) -> None:
18
+ self.name, self.min_score = name, min_score
19
+ self._model: CrossEncoder | None = None
20
+
21
+ @property
22
+ def model(self) -> CrossEncoder:
23
+ if self._model is None:
24
+ from sentence_transformers import CrossEncoder
25
+ from torch.nn import Identity
26
+
27
+ model = None
28
+ try:
29
+ model = CrossEncoder(
30
+ self.name, local_files_only=True, max_length=512, activation_fn=Identity()
31
+ )
32
+ except OSError:
33
+ pass
34
+ if model is None:
35
+ model = CrossEncoder(self.name, max_length=512, activation_fn=Identity())
36
+ self._model = model
37
+ return self._model
38
+
39
+ def rerank(
40
+ self, question: str, hits: list[Hit], limit: int = 5, *, filter_weak: bool = True
41
+ ) -> list[Hit]:
42
+ if not hits:
43
+ return []
44
+ scores = np.asarray(
45
+ self.model.predict(
46
+ [(question, hit.text) for hit in hits],
47
+ batch_size=8,
48
+ show_progress_bar=False,
49
+ convert_to_numpy=True,
50
+ ),
51
+ dtype=np.float32,
52
+ ).reshape(-1)
53
+ if len(scores) != len(hits) or not np.isfinite(scores).all():
54
+ raise ValueError("Reranker returned invalid scores")
55
+ ranked = sorted(
56
+ zip(hits, scores, strict=True), key=lambda item: float(item[1]), reverse=True
57
+ )
58
+ # Raw model logits are ranking scores, not calibrated probabilities.
59
+ return [
60
+ replace(hit, score=float(score))
61
+ for hit, score in ranked
62
+ if not filter_weak or float(score) >= self.min_score
63
+ ][:limit]
@@ -0,0 +1,64 @@
1
+ """Conservative conversational routing and history-aware follow-up search queries."""
2
+
3
+ import re
4
+
5
+ from convo.core.contracts import Message
6
+
7
+ _GREETING = re.compile(
8
+ r"(?:hi|hello|hey|hey there|hello there|good morning|good evening|good afternoon)[!.\s]*", re.I
9
+ )
10
+ _THANKS = re.compile(r"(?:thanks|thank you|thanks a lot|thank you so much)[!.\s]*", re.I)
11
+
12
+
13
+ def conversation_reply(question: str) -> str | None:
14
+ if _GREETING.fullmatch(question.strip()):
15
+ return "Hi! Ask me about your selected file, or use /file to choose one."
16
+ if _THANKS.fullmatch(question.strip()):
17
+ return "You're welcome! Ask another question whenever you're ready."
18
+ return None
19
+
20
+
21
+ def is_overview(question: str) -> bool:
22
+ normalized = re.sub(r"[^\w\s]", "", question.casefold()).strip()
23
+ normalized = re.sub(r"^(?:please|can you|could you)\s+", "", normalized)
24
+ return normalized in {
25
+ "summarize",
26
+ "summarise",
27
+ "summary",
28
+ "summarize this",
29
+ "summarise this",
30
+ "summarize this file",
31
+ "summarise this file",
32
+ "summarize the file",
33
+ "summarize this document",
34
+ "summarise this document",
35
+ "summarize the document",
36
+ "what is this file about",
37
+ "what is this document about",
38
+ "explain this file",
39
+ "explain this document",
40
+ "give me an overview of this file",
41
+ "give me a summary of this file",
42
+ "give me a summary of this document",
43
+ }
44
+
45
+
46
+ def search_query(question: str, history: tuple[Message, ...]) -> str:
47
+ # Mixed greetings/questions remain document queries. Short references use the
48
+ # last user question to avoid embedding an ambiguous 'what about that?' alone.
49
+ if is_overview(question):
50
+ return question
51
+ if len(question.split()) <= 16 and re.search(
52
+ r"\b(it|that|those|they|them|more|further|why|explain|elaborate)\b", question, re.I
53
+ ):
54
+ previous = next(
55
+ (
56
+ message.content
57
+ for message in reversed(history)
58
+ if message.role == "user" and conversation_reply(message.content) is None
59
+ ),
60
+ None,
61
+ )
62
+ if previous:
63
+ return f"{previous[:600]}\n{question}"[:4_000]
64
+ return question