ragkit-dev 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.
rag_kit/env.py ADDED
@@ -0,0 +1,84 @@
1
+ """Explicitly copy selected .env values into os.environ.
2
+
3
+ `Settings.load()` never touches os.environ. Some libraries, however, only read
4
+ their configuration from the environment - Hugging Face reads HF_HOME when it
5
+ is first imported, and HTTP clients read proxy variables - so a value placed
6
+ in the project's .env would otherwise be ignored.
7
+
8
+ `export_env_file(settings)` closes that gap with an allow-list. Only the
9
+ application entry points call it: the CLI, the default API app (`main.app`)
10
+ at startup, and the Streamlit UI. Importing the package, building a Pipeline
11
+ or calling create_app() never exports anything.
12
+
13
+ Policy:
14
+ * Exported: variables other libraries read from the environment (see
15
+ EXPORT_PREFIXES / EXPORT_NAMES), plus any names listed in RAG_EXPORT_ENV
16
+ (comma-separated, in .env or the environment).
17
+ * Never exported: rag-kit's own settings, including GOOGLE_API_KEY and
18
+ QDRANT_API_KEY, and any RAG_* variable. Settings reads those directly, and
19
+ server/UI child processes re-read .env themselves.
20
+ * Variables already set in the process win; empty values are skipped.
21
+ * Only names are ever logged or returned, never values. HF_TOKEN is a secret
22
+ but is exported, because huggingface_hub reads it only from the environment.
23
+ """
24
+
25
+ import logging
26
+ import os
27
+ from typing import List, MutableMapping, Optional
28
+
29
+ from dotenv import dotenv_values
30
+
31
+ from rag_kit.settings import SETTING_NAMES, Settings
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ OPT_IN_VARIABLE = "RAG_EXPORT_ENV"
36
+
37
+ # Hugging Face, transformers, sentence-transformers and torch runtime settings.
38
+ EXPORT_PREFIXES = ("HF_", "HUGGINGFACE_", "TRANSFORMERS_", "SENTENCE_TRANSFORMERS_", "TORCH_")
39
+ EXPORT_NAMES = frozenset({
40
+ "TOKENIZERS_PARALLELISM", "CUDA_VISIBLE_DEVICES", "OMP_NUM_THREADS",
41
+ "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE",
42
+ })
43
+ # Proxy variables are honoured in either case by most HTTP clients.
44
+ PROXY_NAMES = frozenset({"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY"})
45
+
46
+
47
+ def is_rag_kit_setting(name: str) -> bool:
48
+ return name in SETTING_NAMES or name.upper().startswith("RAG_")
49
+
50
+
51
+ def _allow_listed(name: str) -> bool:
52
+ return (name in EXPORT_NAMES or name.upper() in PROXY_NAMES
53
+ or name.startswith(EXPORT_PREFIXES))
54
+
55
+
56
+ def _opt_in_names(file_values: dict, environ: MutableMapping[str, str]) -> set:
57
+ raw = environ.get(OPT_IN_VARIABLE) or file_values.get(OPT_IN_VARIABLE) or ""
58
+ return {name.strip() for name in raw.split(",") if name.strip()}
59
+
60
+
61
+ def export_env_file(settings: Settings, environ: Optional[MutableMapping[str, str]] = None,
62
+ override: bool = False) -> List[str]:
63
+ """Copy allow-listed and opted-in keys from settings.env_file into `environ`
64
+ (default: os.environ). Returns the sorted names that were set."""
65
+ environ = os.environ if environ is None else environ
66
+ file_values = dotenv_values(settings.env_file)
67
+ opt_in = _opt_in_names(file_values, environ)
68
+
69
+ refused = sorted(name for name in opt_in if is_rag_kit_setting(name))
70
+ if refused:
71
+ logger.warning(f" ! {OPT_IN_VARIABLE} lists rag-kit settings, which are never "
72
+ f"exported: {', '.join(refused)}")
73
+
74
+ exported = []
75
+ for name, value in file_values.items():
76
+ if not value or is_rag_kit_setting(name):
77
+ continue
78
+ if not (_allow_listed(name) or name in opt_in):
79
+ continue
80
+ if name in environ and not override:
81
+ continue # the process environment wins
82
+ environ[name] = value
83
+ exported.append(name)
84
+ return sorted(exported)
rag_kit/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Errors the pipeline raises on purpose.
2
+
3
+ Every message says what went wrong *and* what to do about it, so the CLI, the
4
+ FastAPI service and the Streamlit UI can show it to the user as-is.
5
+ """
6
+
7
+
8
+ class RagError(RuntimeError):
9
+ """Base class for expected, user-fixable failures."""
10
+
11
+
12
+ class ConfigError(RagError):
13
+ """A setting is missing or invalid. The message says how to fix it."""
14
+
15
+
16
+ class UnsupportedFileError(RagError):
17
+ """Step 1: the input is missing, empty, or not a supported type."""
18
+
19
+
20
+ class ExtractionError(RagError):
21
+ """Step 2: the file could not be opened or read."""
22
+
23
+
24
+ class EmptyDocumentError(RagError):
25
+ """Steps 2-4: the file was read but produced no usable text."""
26
+
27
+
28
+ class EmbeddingProviderError(RagError):
29
+ """Step 5: the embedding model could not be loaded or called."""
30
+
31
+
32
+ class VectorStoreError(RagError):
33
+ """Step 6/9: Qdrant is unreachable, locked, missing, or incompatible."""
34
+
35
+
36
+ class GenerationError(RagError):
37
+ """Step 10: the LLM could not be created or called."""
rag_kit/log.py ADDED
@@ -0,0 +1,42 @@
1
+ """Step progress goes through logging, not print().
2
+
3
+ Library code logs to the "rag_kit" logger, which has only a NullHandler, so a
4
+ program that imports the pipeline sees no output unless it asks for it.
5
+ Applications (the CLI, the API service, the Streamlit UI) call
6
+ `show_progress()` to print the same "[1] Document : ..." lines as before.
7
+ """
8
+
9
+ import logging
10
+ import sys
11
+
12
+ LOGGER_NAME = "rag_kit" # rag_kit/__init__.py gives it a NullHandler
13
+
14
+
15
+ class _CurrentStdout(logging.StreamHandler):
16
+ """Writes to whatever sys.stdout is when a record is emitted, so
17
+ contextlib.redirect_stdout (the CLI's --json mode) and test capture still
18
+ apply - exactly like the print() calls this replaces."""
19
+
20
+ def __init__(self):
21
+ super().__init__(sys.stdout)
22
+
23
+ @property
24
+ def stream(self):
25
+ return sys.stdout
26
+
27
+ @stream.setter
28
+ def stream(self, _value):
29
+ pass
30
+
31
+
32
+ def show_progress(level: int = logging.INFO) -> logging.Handler:
33
+ """Print step progress (message text only) to stdout. Safe to call twice."""
34
+ logger = logging.getLogger(LOGGER_NAME)
35
+ for handler in [h for h in logger.handlers if isinstance(h, _CurrentStdout)]:
36
+ logger.removeHandler(handler)
37
+ handler = _CurrentStdout()
38
+ handler.setFormatter(logging.Formatter("%(message)s"))
39
+ logger.addHandler(handler)
40
+ logger.setLevel(level)
41
+ logger.propagate = False # do not print twice if the root logger is configured
42
+ return handler
@@ -0,0 +1,132 @@
1
+ """Font-encoding repair for PDFs with broken glyph maps.
2
+
3
+ Some PDFs (this project's `-Free-English-Grammar.pdf` among them - watermarked by
4
+ pdforall.com) embed subset fonts with *no* ToUnicode CMap. Every PDF text
5
+ extractor - pypdf and PyMuPDF alike - then returns the raw glyph indices, which
6
+ come out as `& ? ! & & ? !` garbage.
7
+
8
+ The scramble is a plain, document-wide substitution cipher, so it is fully
9
+ recoverable without OCR. Two subset fonts carry the body:
10
+
11
+ TTE1AEB1B8t00 regular body text (~93 % of characters)
12
+ TTE1AEB0C8t00 bold headings / emphasised grammar terms
13
+
14
+ The maps below were reconstructed by decoding known words. Anything that is not
15
+ one of those two fonts (Times-Roman digits, the Helvetica watermark) already
16
+ extracts correctly and is passed through untouched.
17
+
18
+ `repair_page` rebuilds a page's text span by span, so page boundaries - and
19
+ therefore the page numbers cited in step 11 - stay exactly where PyMuPDF puts
20
+ them.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+
27
+ BODY_FONT = "TTE1AEB1B8t00"
28
+ BOLD_FONT = "TTE1AEB0C8t00"
29
+
30
+ # --- regular body font -------------------------------------------------------
31
+ BODY_MAP = {
32
+ 0x01: " ", 0x02: "b", 0x03: "y", 0x04: "S", 0x05: "e", 0x06: "c",
33
+ 0x07: "o", 0x08: "n", 0x09: "d", 0x0A: "i", 0x0B: "t", 0x0C: ".",
34
+ 0x0D: "C", 0x0E: "p", 0x0F: "r", 0x10: "g", 0x11: "h", 0x13: "2",
35
+ 0x14: "0", 0x15: "T", 0x16: "m", 0x17: "a", 0x18: "l", 0x19: "s",
36
+ 0x1A: "k", 0x1B: "u", 0x1C: "f", 0x1F: "w", 0x20: "v",
37
+ 0x21: ",", 0x22: "x", 0x23: "I", 0x24: ":", 0x26: "A", 0x27: "E",
38
+ 0x28: "F", 0x29: "G", 0x2A: "N", 0x2B: "z", 0x2C: "H", 0x2D: "U",
39
+ 0x2E: "D", 0x2F: "O", 0x30: "R", 0x31: "M", 0x32: "L", 0x33: "V",
40
+ 0x34: "B", 0x35: "W", 0x36: "P", 0x37: "1", 0x38: "3", 0x39: "4",
41
+ 0x3A: "Q", 0x3B: "q", 0x3C: "5", 0x3D: "6", 0x3E: "7", 0x3F: "j",
42
+ 0x40: "8", 0x42: "9", 0x43: "/", 0x44: "(", 0x45: ")", 0x46: "?",
43
+ 0x47: "’", 0x48: "Y", 0x49: " ", 0x4A: '"', 0x4B: '"', 0x4C: "J",
44
+ 0x4E: "!", 0x25: "•",
45
+ }
46
+
47
+ # --- bold headings / emphasis font ----------------------------------------
48
+ BOLD_MAP = {
49
+ 0x01: " ", 0x02: "s", 0x03: "e", 0x04: "y", 0x05: "f", 0x06: "i",
50
+ 0x08: "o", 0x09: "c", 0x0A: "a", 0x0B: "w", 0x0D: "s", 0x0E: "h",
51
+ 0x0F: "m", 0x11: "n", 0x13: "l", 0x15: "r", 0x1A: "a", 0x1F: "t",
52
+ 0x22: "C", 0x24: "f", 0x26: "t", 0x27: "b", 0x29: "v", 0x2C: "d",
53
+ 0x30: "u", 0x36: "p", 0x37: "’", 0x38: "W", 0x39: ",", 0x3A: "u",
54
+ 0x3B: "v", 0x3C: "j", 0x3E: "k", 0x3F: "Q", 0x41: "Q", 0x46: ")",
55
+ }
56
+
57
+ # Capital glyphs the font draws as "<capital><phantom 0x20>"; the trailing
58
+ # glyph 0x20 (otherwise 'v') must be swallowed.
59
+ _CAP_ABSORB_V = {0x29, 0x2C, 0x2D, 0x2F, 0x31}
60
+
61
+ # characters worth keeping verbatim when a glyph is not in the map
62
+ _KEEP = set(" .,;:!?()[]{}\"'‘’“”-–—/&%$#@*+=<>0123456789\n\t")
63
+
64
+ _PHANTOM_V = re.compile(r"\b([HOMGU])v(?=[a-z])")
65
+
66
+
67
+ def _decode_span(raw: str, fmap: dict, absorb_v: bool) -> str:
68
+ out = []
69
+ i, n = 0, len(raw)
70
+ while i < n:
71
+ cp = ord(raw[i])
72
+ if cp in fmap:
73
+ out.append(fmap[cp])
74
+ if absorb_v and cp in _CAP_ABSORB_V and i + 1 < n and ord(raw[i + 1]) == 0x20:
75
+ i += 1 # swallow the phantom 'v' that trails this capital
76
+ elif cp < 0x20:
77
+ pass # unmapped control glyph - drop it rather than emit noise
78
+ elif raw[i] in _KEEP or raw[i].isalnum():
79
+ out.append(raw[i])
80
+ i += 1
81
+ return "".join(out)
82
+
83
+
84
+ def repair_page(page) -> str:
85
+ """Return the readable text of one ``pymupdf`` page.
86
+
87
+ Broken subset fonts are decoded via the substitution maps above; every other
88
+ font is passed through as PyMuPDF extracted it.
89
+ """
90
+ data = page.get_text("rawdict")
91
+ lines = []
92
+ for block in data["blocks"]:
93
+ for line in block.get("lines", []):
94
+ buf = []
95
+ for span in line["spans"]:
96
+ raw = "".join(ch["c"] for ch in span["chars"])
97
+ font = span["font"]
98
+ if font == BODY_FONT:
99
+ buf.append(_decode_span(raw, BODY_MAP, absorb_v=True))
100
+ elif font == BOLD_FONT:
101
+ buf.append(_decode_span(raw, BOLD_MAP, absorb_v=False))
102
+ else:
103
+ buf.append(raw)
104
+ lines.append("".join(buf))
105
+ return _PHANTOM_V.sub(r"\1", "\n".join(lines))
106
+
107
+
108
+ # --- quality scoring -------------------------------------------------------
109
+ _WORD = re.compile(r"[A-Za-z][A-Za-z'’-]*")
110
+ _VOWEL = re.compile(r"[aeiouyAEIOUY]")
111
+
112
+
113
+ def text_quality(text: str) -> tuple[float, float]:
114
+ """(alphabetic-character ratio, plausible-English-word ratio) for `text`."""
115
+ stripped = [c for c in text if not c.isspace()]
116
+ if not stripped:
117
+ return 0.0, 0.0
118
+ alpha_ratio = sum(c.isalpha() for c in stripped) / len(stripped)
119
+
120
+ words = _WORD.findall(text)
121
+ if not words:
122
+ return alpha_ratio, 0.0
123
+ good = sum(
124
+ 1 for w in words
125
+ if 1 <= len(w) <= 20 and (len(w) <= 2 or _VOWEL.search(w))
126
+ )
127
+ return alpha_ratio, good / len(words)
128
+
129
+
130
+ def is_readable(text: str, min_alpha: float = 0.70, min_words: float = 0.80) -> bool:
131
+ alpha_ratio, word_ratio = text_quality(text)
132
+ return alpha_ratio >= min_alpha and word_ratio >= min_words
rag_kit/pipeline.py ADDED
@@ -0,0 +1,169 @@
1
+ """The 11 steps wired together, shared by the CLI, FastAPI and Streamlit.
2
+
3
+ pipeline = Pipeline() # nothing is loaded yet
4
+ pipeline.ingest("data/book.pdf") # steps 1-6
5
+ result = pipeline.ask("What is a noun?") # steps 7-11 (minus printing)
6
+ pipeline.close()
7
+
8
+ The embedding model, Qdrant client and LLM are created on first use and then
9
+ reused. A local Qdrant store allows one client per process, so keep one
10
+ Pipeline per process.
11
+ """
12
+
13
+ import threading
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import List, Optional, Tuple
17
+
18
+ from langchain_core.documents import Document
19
+
20
+ from rag_kit.errors import EmptyDocumentError
21
+ from rag_kit.settings import Settings, default_settings
22
+ from rag_kit.step01_load_pdf import load_document
23
+ from rag_kit.step02_extract_text import extract_text
24
+ from rag_kit.step03_clean_text import clean_documents
25
+ from rag_kit.step04_chunking import chunk_documents
26
+ from rag_kit.step05_embedding import get_embeddings
27
+ from rag_kit.step06_vector_store import (
28
+ delete_document,
29
+ get_client,
30
+ list_documents,
31
+ open_vector_store,
32
+ store_chunks,
33
+ )
34
+ from rag_kit.step08_query_embedding import embed_question
35
+ from rag_kit.step09_retrieve import retrieve
36
+ from rag_kit.step10_generate import NO_RESULTS_ANSWER, generate_answer, get_llm
37
+ from rag_kit.step11_answer import build_sources, strip_markers
38
+
39
+ ScoredDocs = List[Tuple[Document, float]]
40
+
41
+
42
+ @dataclass
43
+ class AnswerResult:
44
+ question: str
45
+ answer: str # raw model output, with [S1] markers
46
+ status: str # "answered" or "no_results"
47
+ sources: List[dict] = field(default_factory=list)
48
+ scored_docs: ScoredDocs = field(default_factory=list, repr=False)
49
+
50
+ @property
51
+ def answer_without_markers(self) -> str:
52
+ return strip_markers(self.answer)
53
+
54
+
55
+ class Pipeline:
56
+ def __init__(self, collection: Optional[str] = None, embeddings=None, client=None, llm=None,
57
+ settings: Optional[Settings] = None):
58
+ self.settings = settings or default_settings()
59
+ self.collection = collection or self.settings.collection_name
60
+ self._embeddings = embeddings
61
+ self._client = client
62
+ self._llm = llm
63
+ self._vector_store = None
64
+ self._lock = threading.RLock()
65
+
66
+ # --- lazily created resources -------------------------------------------
67
+ @property
68
+ def embeddings(self):
69
+ with self._lock:
70
+ if self._embeddings is None:
71
+ self._embeddings = get_embeddings(self.settings) # 5
72
+ return self._embeddings
73
+
74
+ def client(self, create: bool = False):
75
+ with self._lock:
76
+ if self._client is None:
77
+ self._client = get_client(create=create, settings=self.settings)
78
+ return self._client
79
+
80
+ @property
81
+ def vector_store(self):
82
+ with self._lock:
83
+ if self._vector_store is None: # 6 (read side)
84
+ self._vector_store = open_vector_store(self.embeddings, self.client(),
85
+ self.collection, self.settings)
86
+ return self._vector_store
87
+
88
+ @property
89
+ def llm(self):
90
+ with self._lock:
91
+ if self._llm is None:
92
+ self._llm = get_llm(self.settings) # 10
93
+ return self._llm
94
+
95
+ @property
96
+ def ready(self) -> bool:
97
+ """True once the collection has been opened and validated."""
98
+ return self._vector_store is not None
99
+
100
+ def warm_up(self, llm: bool = True) -> None:
101
+ """Load everything a query needs, so the first request is not slow."""
102
+ _ = self.vector_store
103
+ if llm:
104
+ _ = self.llm
105
+
106
+ def close(self) -> None:
107
+ with self._lock:
108
+ if self._client is not None:
109
+ self._client.close()
110
+ self._client = self._vector_store = None
111
+
112
+ # --- steps 1-6 -----------------------------------------------------------
113
+ def ingest(self, path, recreate: bool = False, font_repair: Optional[bool] = None) -> dict:
114
+ source = load_document(path) # 1
115
+ pages = extract_text(source, font_repair=font_repair, # 2
116
+ settings=self.settings)
117
+ pages = clean_documents(pages) # 3
118
+ if not pages:
119
+ raise EmptyDocumentError(
120
+ f"No usable text in {source.name} after cleaning. If it is a scanned "
121
+ "PDF, run OCR first (e.g. `ocrmypdf in.pdf out.pdf`)."
122
+ )
123
+ chunks = chunk_documents(pages, self.settings.chunk_size, # 4
124
+ self.settings.chunk_overlap)
125
+ with self._lock:
126
+ report = store_chunks(chunks, self.embeddings, recreate=recreate, # 5 + 6
127
+ client=self.client(create=True), collection=self.collection,
128
+ settings=self.settings)
129
+ self._vector_store = None # re-validate on next read (collection may be new)
130
+ report["file"] = source.name
131
+ report["pages"] = len(pages)
132
+ return report
133
+
134
+ # --- steps 8-11 ----------------------------------------------------------
135
+ def search(self, question: str, k: Optional[int] = None,
136
+ min_score: Optional[float] = None) -> ScoredDocs:
137
+ question = _require_question(question)
138
+ query_vector = embed_question(self.embeddings, question) # 8
139
+ return retrieve(self.vector_store, query_vector, k or self.settings.top_k, # 9
140
+ self.settings.min_score if min_score is None else min_score)
141
+
142
+ def ask(self, question: str, k: Optional[int] = None,
143
+ min_score: Optional[float] = None) -> AnswerResult:
144
+ question = _require_question(question)
145
+ scored_docs = self.search(question, k=k, min_score=min_score)
146
+ if not scored_docs:
147
+ return AnswerResult(question, NO_RESULTS_ANSWER, "no_results")
148
+ answer = generate_answer(question, scored_docs, llm=self.llm, # 10
149
+ settings=self.settings)
150
+ return AnswerResult(question, answer, "answered", # 11
151
+ build_sources(scored_docs), scored_docs)
152
+
153
+ # --- maintenance ---------------------------------------------------------
154
+ def documents(self) -> dict:
155
+ return list_documents(self.client(), self.collection, self.settings)
156
+
157
+ def delete(self, source_file: str) -> int:
158
+ with self._lock:
159
+ removed = delete_document(self.client(), Path(source_file).name, self.collection,
160
+ self.settings)
161
+ self._vector_store = None
162
+ return removed
163
+
164
+
165
+ def _require_question(question: str) -> str:
166
+ question = (question or "").strip()
167
+ if not question:
168
+ raise ValueError("Question is empty.")
169
+ return question
rag_kit/py.typed ADDED
File without changes