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
|
@@ -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()
|
convo/pipeline/index.py
ADDED
|
@@ -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
|