longparser 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.
@@ -0,0 +1,181 @@
1
+ """Embedding engine for LongParser — wraps LangChain providers.
2
+
3
+ Supported: HuggingFace (local), OpenAI, Gemini.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ from typing import Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Global lock for single-flight dimension discovery within a process
18
+ _dim_lock = threading.Lock()
19
+
20
+
21
+ class EmbeddingEngine:
22
+ """Multi-provider embedding engine.
23
+
24
+ Parameters
25
+ ----------
26
+ provider:
27
+ "huggingface", "openai", or "gemini".
28
+ model_name:
29
+ Model ID (e.g., "BAAI/bge-base-en-v1.5", "text-embedding-3-small").
30
+ dimensions:
31
+ Optional override for embedding dimensions (useful for OpenAI/Gemini
32
+ to avoid API calls or specifically configure text-embedding-3).
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ provider: str = "huggingface",
38
+ model_name: str = "BAAI/bge-base-en-v1.5",
39
+ dimensions: Optional[int] = None,
40
+ ) -> None:
41
+ self.provider = provider.lower()
42
+ self.model_name = model_name
43
+ self.configured_dimensions = dimensions
44
+ self._dim: Optional[int] = dimensions
45
+
46
+ # Provider-specific configurations
47
+ self._gemini_doc_task = "RETRIEVAL_DOCUMENT"
48
+ self._gemini_query_task = "RETRIEVAL_QUERY"
49
+ self._hf_normalize = True
50
+
51
+ if self.provider == "openai":
52
+ from langchain_openai import OpenAIEmbeddings
53
+ # Pass dimensions kwargs if provided (supported for text-embedding-3*)
54
+ kwargs = {}
55
+ if self.configured_dimensions:
56
+ kwargs["dimensions"] = self.configured_dimensions
57
+ self.model = OpenAIEmbeddings(model=self.model_name, **kwargs)
58
+ logger.info(f"EmbeddingEngine: Initialized OpenAI {model_name}")
59
+
60
+ elif self.provider == "gemini":
61
+ from langchain_google_genai import GoogleGenerativeAIEmbeddings
62
+ kwargs = {}
63
+ if self.configured_dimensions:
64
+ kwargs["output_dimensionality"] = self.configured_dimensions
65
+ self.model = GoogleGenerativeAIEmbeddings(model=self.model_name, **kwargs)
66
+ logger.info(f"EmbeddingEngine: Initialized Gemini {model_name}")
67
+
68
+ elif self.provider == "huggingface":
69
+ from langchain_huggingface import HuggingFaceEmbeddings
70
+ self.model = HuggingFaceEmbeddings(
71
+ model_name=self.model_name,
72
+ encode_kwargs={"normalize_embeddings": self._hf_normalize}
73
+ )
74
+ logger.info(f"EmbeddingEngine: Initialized HuggingFace {model_name}")
75
+
76
+ else:
77
+ raise ValueError(f"Unknown embedding provider: {provider}")
78
+
79
+ def get_fingerprint(self) -> str:
80
+ """Return a stable 10-char SHA1 hash of the full embedding configuration.
81
+ This is used to isolate vector spaces when models/configs change.
82
+ """
83
+ config = {
84
+ "p": self.provider,
85
+ "m": self.model_name,
86
+ "d": self.configured_dimensions,
87
+ }
88
+ if self.provider == "gemini":
89
+ config["t_doc"] = self._gemini_doc_task
90
+ config["t_qry"] = self._gemini_query_task
91
+ elif self.provider == "huggingface":
92
+ config["n"] = self._hf_normalize
93
+
94
+ # Stable json dump
95
+ cfg_str = json.dumps(config, sort_keys=True)
96
+ return hashlib.sha1(cfg_str.encode("utf-8")).hexdigest()[:10]
97
+
98
+ @property
99
+ def dim(self) -> int:
100
+ """Lazy-evaluated, thread-safe, cross-process cached embedding dimension."""
101
+ if self._dim is not None:
102
+ return self._dim
103
+
104
+ # Global dimension lock
105
+ with _dim_lock:
106
+ # Check if another thread resolved it while waiting
107
+ if self._dim is not None:
108
+ return self._dim
109
+
110
+ fp = self.get_fingerprint()
111
+ cache_key = f"cleanrag:embed_dim:{fp}"
112
+
113
+ # 1) Try Redis cross-process cache if available
114
+ try:
115
+ import redis
116
+ redis_url = os.getenv("LONGPARSER_REDIS_URL", "redis://localhost:6379/0")
117
+ r = redis.from_url(redis_url, socket_connect_timeout=1)
118
+ r.ping() # Fail fast if unavailable
119
+ cached = r.get(cache_key)
120
+ if cached:
121
+ self._dim = int(cached)
122
+ logger.debug(f"Resolved dim from Redis cache: {self._dim}")
123
+ return self._dim
124
+ except Exception as e:
125
+ logger.debug(f"Redis cache check failed (likely no redis): {e}")
126
+
127
+ # 2) Fallback: perform an API call to discover it.
128
+ # We strictly use the Document retrieval task setting to ensure dimension matches index.
129
+ logger.info(f"Discovering embedding dimension for {self.provider}/{self.model_name}...")
130
+ test_doc = ["test"]
131
+ try:
132
+ if self.provider == "gemini":
133
+ # Explicitly pass the document task type
134
+ test_vec = self.model.embed_documents(test_doc, task_type=self._gemini_doc_task)[0]
135
+ else:
136
+ test_vec = self.model.embed_documents(test_doc)[0]
137
+ except Exception as e:
138
+ logger.error(f"Failed to discover embedding dimension: {e}")
139
+ raise # Fail loudly! Missing API keys should crash here.
140
+
141
+ self._dim = len(test_vec)
142
+ logger.info(f"Discovered dimension: {self._dim}")
143
+
144
+ # 3) Cache it in Redis
145
+ try:
146
+ if 'r' in locals():
147
+ r.set(cache_key, self._dim)
148
+ except Exception:
149
+ pass
150
+
151
+ return self._dim
152
+
153
+ def embed_chunks(self, texts: list[str], batch_size: int = 64) -> list[list[float]]:
154
+ """Embed a list of text chunks.
155
+
156
+ Returns list of float vectors (one per chunk).
157
+ """
158
+ if not texts:
159
+ return []
160
+
161
+ if self.provider == "gemini":
162
+ # Gemini strictly enforces maximum 100 texts per batch request
163
+ effective_batch = min(batch_size, 100)
164
+ all_embeddings = []
165
+ for i in range(0, len(texts), effective_batch):
166
+ batch = texts[i:i + effective_batch]
167
+ # Pass explicit task_type parameter per call
168
+ all_embeddings.extend(
169
+ self.model.embed_documents(batch, task_type=self._gemini_doc_task)
170
+ )
171
+ return all_embeddings
172
+ else:
173
+ return self.model.embed_documents(texts)
174
+
175
+ def embed_query(self, query: str) -> list[float]:
176
+ """Embed a single search query."""
177
+ if self.provider == "gemini":
178
+ # For queries, Gemini optimizes with a different task_type
179
+ return self.model.embed_query(query, task_type=self._gemini_query_task)
180
+ else:
181
+ return self.model.embed_query(query)
@@ -0,0 +1,97 @@
1
+ """Abstracted job queue backend for LongParser.
2
+
3
+ ARQ (async Redis queue) is the default implementation.
4
+ The QueueBackend ABC allows swapping to Celery/Dramatiq
5
+ without rewriting business logic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any, Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class QueueBackend(ABC):
18
+ """Abstract queue interface — swap implementations without changing routes."""
19
+
20
+ @abstractmethod
21
+ async def enqueue(self, task_name: str, payload: dict) -> str:
22
+ """Enqueue a task. Returns task/job reference ID."""
23
+ ...
24
+
25
+ @abstractmethod
26
+ async def cancel(self, task_id: str) -> bool:
27
+ """Cancel a queued/running task. Returns True if cancelled."""
28
+ ...
29
+
30
+ @abstractmethod
31
+ async def status(self, task_id: str) -> dict:
32
+ """Get task status and progress."""
33
+ ...
34
+
35
+
36
+ class ARQBackend(QueueBackend):
37
+ """ARQ (async Redis queue) implementation."""
38
+
39
+ def __init__(self, redis_url: str = "redis://localhost:6379"):
40
+ self.redis_url = redis_url
41
+ self._pool = None
42
+
43
+ async def _get_pool(self):
44
+ if self._pool is None:
45
+ from arq import create_pool
46
+ from arq.connections import RedisSettings
47
+
48
+ url = self.redis_url.replace("redis://", "")
49
+ # Strip database number (e.g., /0) if present
50
+ url = url.split("/")[0]
51
+ host, _, port_str = url.partition(":")
52
+ port = int(port_str) if port_str else 6379
53
+ self._pool = await create_pool(RedisSettings(host=host, port=port))
54
+ return self._pool
55
+
56
+ async def enqueue(self, task_name: str, payload: dict) -> str:
57
+ """Enqueue a task via ARQ."""
58
+ pool = await self._get_pool()
59
+ job = await pool.enqueue_job(task_name, **payload)
60
+ job_id = job.job_id if job else "unknown"
61
+ logger.info(f"Enqueued {task_name} → ARQ job {job_id}")
62
+ return job_id
63
+
64
+ async def cancel(self, task_id: str) -> bool:
65
+ """Cancel via ARQ job abort (best-effort)."""
66
+ try:
67
+ from arq.jobs import Job
68
+ pool = await self._get_pool()
69
+ job = Job(task_id, pool)
70
+ await job.abort()
71
+ return True
72
+ except Exception as e:
73
+ logger.warning(f"Failed to abort ARQ job {task_id}: {e}")
74
+ return False
75
+
76
+ async def status(self, task_id: str) -> dict:
77
+ """Get ARQ job status."""
78
+ try:
79
+ from arq.jobs import Job
80
+ pool = await self._get_pool()
81
+ job = Job(task_id, pool)
82
+ info = await job.info()
83
+ if info is None:
84
+ return {"status": "unknown"}
85
+ return {
86
+ "status": info.status,
87
+ "result": info.result,
88
+ "enqueue_time": str(info.enqueue_time) if info.enqueue_time else None,
89
+ }
90
+ except Exception:
91
+ return {"status": "unknown"}
92
+
93
+ async def close(self) -> None:
94
+ """Close the Redis pool."""
95
+ if self._pool:
96
+ await self._pool.close()
97
+ self._pool = None
File without changes
@@ -0,0 +1,204 @@
1
+ """API request/response models for LongParser HITL review system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from enum import Enum
9
+ from typing import Optional
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from ..schemas import BlockType
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Enums
18
+ # ---------------------------------------------------------------------------
19
+
20
+ class JobStatus(str, Enum):
21
+ """Job lifecycle states."""
22
+ QUEUED = "queued"
23
+ EXTRACTING = "extracting"
24
+ READY_FOR_REVIEW = "ready_for_review"
25
+ FINALIZING = "finalizing"
26
+ FINALIZED = "finalized"
27
+ EMBEDDING = "embedding"
28
+ INDEXED = "indexed"
29
+ FAILED = "failed"
30
+ CANCELLED = "cancelled"
31
+
32
+
33
+ class ReviewStatus(str, Enum):
34
+ """Block/chunk review states."""
35
+ PENDING = "pending"
36
+ APPROVED = "approved"
37
+ EDITED = "edited"
38
+ REJECTED = "rejected"
39
+
40
+
41
+ class FinalizePolicy(str, Enum):
42
+ """What to do with pending items on finalize."""
43
+ REJECT_PENDING = "reject_pending"
44
+ APPROVE_PENDING = "approve_pending"
45
+ REQUIRE_ALL_APPROVED = "require_all_approved"
46
+
47
+
48
+ class UserRole(str, Enum):
49
+ """RBAC roles."""
50
+ ADMIN = "admin"
51
+ REVIEWER = "reviewer"
52
+ VIEWER = "viewer"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Revision (append-only audit trail)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ class Revision(BaseModel):
60
+ """Immutable record of a block/chunk edit."""
61
+ revision_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
62
+ entity_type: str # "block" | "chunk"
63
+ entity_id: str
64
+ previous_revision_id: Optional[str] = None
65
+ action: ReviewStatus
66
+ original_text: str
67
+ edited_text: Optional[str] = None
68
+ edited_type: Optional[BlockType] = None
69
+ reviewer_id: str = ""
70
+ reviewer_note: str = ""
71
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # API Request Models
76
+ # ---------------------------------------------------------------------------
77
+
78
+ class BlockReviewUpdate(BaseModel):
79
+ """Request body for PATCH /jobs/{id}/blocks/{bid}."""
80
+ status: ReviewStatus
81
+ edited_text: Optional[str] = None
82
+ edited_type: Optional[BlockType] = None
83
+ reviewer_note: str = ""
84
+ version: int # optimistic locking
85
+
86
+
87
+ class ChunkReviewUpdate(BaseModel):
88
+ """Request body for PATCH /jobs/{id}/chunks/{cid}."""
89
+ status: ReviewStatus
90
+ edited_text: Optional[str] = None
91
+ reviewer_note: str = ""
92
+ version: int # optimistic locking
93
+
94
+
95
+ class FinalizeRequest(BaseModel):
96
+ """Request body for POST /jobs/{id}/finalize."""
97
+ finalize_policy: FinalizePolicy = FinalizePolicy.REJECT_PENDING
98
+
99
+
100
+ class EmbedRequest(BaseModel):
101
+ """Request body for POST /jobs/{id}/embed."""
102
+ provider: str = Field(
103
+ default_factory=lambda: os.getenv("LONGPARSER_EMBED_PROVIDER", "huggingface")
104
+ )
105
+ model: str = Field(
106
+ default_factory=lambda: os.getenv("LONGPARSER_EMBED_MODEL", "BAAI/bge-base-en-v1.5")
107
+ )
108
+ vector_db: str = Field(
109
+ default_factory=lambda: os.getenv("LONGPARSER_VECTOR_DB", "chroma")
110
+ ) # "chroma" | "faiss" | "qdrant"
111
+ collection_name: Optional[str] = None
112
+
113
+
114
+ class SearchRequest(BaseModel):
115
+ """Request body for POST /search."""
116
+ query: str
117
+ job_id: str
118
+ top_k: int = 5
119
+ index_version: Optional[str] = None # defaults to latest
120
+ filters: dict = Field(default_factory=dict)
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # API Response Models
125
+ # ---------------------------------------------------------------------------
126
+
127
+ class ReviewProgress(BaseModel):
128
+ """Review completion stats."""
129
+ approved: int = 0
130
+ edited: int = 0
131
+ rejected: int = 0
132
+ pending: int = 0
133
+
134
+
135
+ class JobResponse(BaseModel):
136
+ """Response body for GET /jobs/{id}."""
137
+ job_id: str
138
+ tenant_id: str
139
+ status: JobStatus
140
+ source_file: str
141
+ file_hash: str = ""
142
+ total_pages: int = 0
143
+ total_blocks: int = 0
144
+ total_chunks: int = 0
145
+ review_progress: ReviewProgress = Field(default_factory=ReviewProgress)
146
+ created_at: datetime
147
+ finalized_at: Optional[datetime] = None
148
+ error: Optional[str] = None
149
+
150
+
151
+ class JobListResponse(BaseModel):
152
+ """Response body for GET /jobs."""
153
+ jobs: list[JobResponse]
154
+ total: int
155
+
156
+
157
+ class BlockResponse(BaseModel):
158
+ """Block data for API responses (confidence excluded)."""
159
+ block_id: str
160
+ type: BlockType
161
+ text: str
162
+ order_index: int = 0
163
+ heading_level: Optional[int] = None
164
+ indent_level: int = 0
165
+ hierarchy_path: list[str] = Field(default_factory=list)
166
+ page_number: int = 0
167
+ review_status: ReviewStatus = ReviewStatus.PENDING
168
+ current_revision_id: Optional[str] = None
169
+ version: int = 1
170
+
171
+
172
+ class ChunkResponse(BaseModel):
173
+ """Chunk data for API responses."""
174
+ chunk_id: str
175
+ text: str
176
+ token_count: int = 0
177
+ chunk_type: str = ""
178
+ section_path: list[str] = Field(default_factory=list)
179
+ page_numbers: list[int] = Field(default_factory=list)
180
+ block_ids: list[str] = Field(default_factory=list)
181
+ review_status: ReviewStatus = ReviewStatus.PENDING
182
+ current_revision_id: Optional[str] = None
183
+ version: int = 1
184
+
185
+
186
+ class SearchResult(BaseModel):
187
+ """Single search result."""
188
+ chunk_id: str
189
+ text: str
190
+ score: float
191
+ chunk_type: str = ""
192
+ section_path: list[str] = Field(default_factory=list)
193
+ page_numbers: list[int] = Field(default_factory=list)
194
+ block_ids: list[str] = Field(default_factory=list)
195
+ metadata: dict = Field(default_factory=dict)
196
+
197
+
198
+ class SearchResponse(BaseModel):
199
+ """Response body for POST /search."""
200
+ results: list[SearchResult]
201
+ index_version: str
202
+ model: str
203
+ query: str
204
+ total: int