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,153 @@
1
+ """LangChain LLM abstraction for LongParser Chat.
2
+
3
+ Replaces custom llm_router.py with LangChain's provider-specific chat models.
4
+ Supports: OpenAI, Gemini, Groq, OpenRouter.
5
+ Includes: with_structured_output, with_retry.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from typing import Optional
13
+
14
+ from .schemas import ChatConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Default models per provider (updated Feb 2026)
20
+ DEFAULT_MODELS: dict[str, str] = {
21
+ "openai": "gpt-5.3-codex",
22
+ "gemini": "gemini-2.5-flash",
23
+ "groq": "openai/gpt-oss-120b",
24
+ "openrouter": "openai/gpt-5.3-codex",
25
+ }
26
+
27
+
28
+ def _create_openai(model: str, temperature: float, max_tokens: int,
29
+ max_retries: int, callbacks: Optional[list] = None):
30
+ """Create OpenAI chat model."""
31
+ from langchain_openai import ChatOpenAI
32
+ return ChatOpenAI(
33
+ model=model,
34
+ temperature=temperature,
35
+ max_tokens=max_tokens,
36
+ max_retries=max_retries,
37
+ callbacks=callbacks or [],
38
+ )
39
+
40
+
41
+ def _create_gemini(model: str, temperature: float, max_tokens: int,
42
+ max_retries: int, callbacks: Optional[list] = None):
43
+ """Create Google Gemini chat model."""
44
+ from langchain_google_genai import ChatGoogleGenerativeAI
45
+ return ChatGoogleGenerativeAI(
46
+ model=model,
47
+ temperature=temperature,
48
+ max_output_tokens=max_tokens,
49
+ max_retries=max_retries,
50
+ callbacks=callbacks or [],
51
+ )
52
+
53
+
54
+ def _create_groq(model: str, temperature: float, max_tokens: int,
55
+ max_retries: int, callbacks: Optional[list] = None):
56
+ """Create Groq chat model."""
57
+ from langchain_groq import ChatGroq
58
+ return ChatGroq(
59
+ model=model,
60
+ temperature=temperature,
61
+ max_tokens=max_tokens,
62
+ max_retries=max_retries,
63
+ callbacks=callbacks or [],
64
+ )
65
+
66
+
67
+ def _create_openrouter(model: str, temperature: float, max_tokens: int,
68
+ max_retries: int, callbacks: Optional[list] = None):
69
+ """Create OpenRouter chat model (OpenAI-compatible)."""
70
+ from langchain_openai import ChatOpenAI
71
+ return ChatOpenAI(
72
+ model=model,
73
+ temperature=temperature,
74
+ max_tokens=max_tokens,
75
+ max_retries=max_retries,
76
+ base_url="https://openrouter.ai/api/v1",
77
+ api_key=os.getenv("OPENROUTER_API_KEY", ""),
78
+ callbacks=callbacks or [],
79
+ )
80
+
81
+
82
+ _CREATORS = {
83
+ "openai": _create_openai,
84
+ "gemini": _create_gemini,
85
+ "groq": _create_groq,
86
+ "openrouter": _create_openrouter,
87
+ }
88
+
89
+
90
+ def get_chat_model(
91
+ provider: Optional[str] = None,
92
+ model: Optional[str] = None,
93
+ config: Optional[ChatConfig] = None,
94
+ *,
95
+ temperature: float = 0.1,
96
+ max_tokens: Optional[int] = None,
97
+ json_mode: bool = False,
98
+ callbacks: Optional[list] = None,
99
+ ):
100
+ """Create a LangChain chat model for any supported provider.
101
+
102
+ Args:
103
+ provider: LLM provider name (openai, gemini, groq, openrouter).
104
+ model: Model name. If None, uses config or provider default.
105
+ config: ChatConfig for defaults and reliability settings.
106
+ temperature: Sampling temperature.
107
+ max_tokens: Max output tokens.
108
+ json_mode: If True, wraps with .with_structured_output(LLMAnswer).
109
+ callbacks: Optional LangChain callback handlers.
110
+
111
+ Returns:
112
+ A LangChain BaseChatModel (or structured output wrapper).
113
+ """
114
+ config = config or ChatConfig()
115
+ provider = provider or config.llm_provider
116
+ model = model or config.llm_model or DEFAULT_MODELS.get(provider, "gpt-4o")
117
+ max_tokens = max_tokens or config.max_output_tokens
118
+
119
+ creator = _CREATORS.get(provider)
120
+ if not creator:
121
+ raise ValueError(
122
+ f"Unknown LLM provider: {provider}. "
123
+ f"Supported: {', '.join(_CREATORS)}"
124
+ )
125
+
126
+ llm = creator(
127
+ model=model,
128
+ temperature=temperature,
129
+ max_tokens=max_tokens,
130
+ max_retries=config.llm_max_retries,
131
+ callbacks=callbacks,
132
+ )
133
+
134
+ # Structured output: returns Pydantic LLMAnswer directly
135
+ if json_mode:
136
+ from .schemas import LLMAnswer
137
+ llm = llm.with_structured_output(LLMAnswer)
138
+
139
+ return llm
140
+
141
+
142
+ def get_plain_chat_model(
143
+ provider: Optional[str] = None,
144
+ model: Optional[str] = None,
145
+ config: Optional[ChatConfig] = None,
146
+ ):
147
+ """Get a plain (non-structured) chat model for summarization / plain text tasks."""
148
+ return get_chat_model(
149
+ provider=provider,
150
+ model=model,
151
+ config=config,
152
+ json_mode=False,
153
+ )
@@ -0,0 +1,111 @@
1
+ """LangChain retriever for LongParser Chat.
2
+
3
+ Wraps existing vector store + embeddings as a LangChain BaseRetriever,
4
+ enabling plugging into LCEL chains.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any, Optional
11
+
12
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
13
+ from langchain_core.documents import Document
14
+ from langchain_core.retrievers import BaseRetriever
15
+ from pydantic import Field
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class LongParserRetriever(BaseRetriever):
21
+ """LangChain retriever backed by LongParser's existing vector store infra.
22
+
23
+ Connects to the same Chroma/FAISS/Qdrant indexes built by the embed pipeline.
24
+ Uses LangChain-native embeddings for query encoding.
25
+ """
26
+
27
+ db: Any = Field(exclude=True)
28
+ tenant_id: str
29
+ job_id: str
30
+ top_k: int = 5
31
+
32
+ # Resolved at runtime from index_version
33
+ _vector_db: Optional[str] = None
34
+ _model_name: Optional[str] = None
35
+ _provider: Optional[str] = None
36
+ _configured_dimensions: Optional[int] = None
37
+ _collection: Optional[str] = None
38
+
39
+ class Config:
40
+ arbitrary_types_allowed = True
41
+
42
+ async def _resolve_index(self) -> None:
43
+ """Load index metadata from MongoDB (lazy, once)."""
44
+ if self._model_name is not None:
45
+ return
46
+ iv_doc = await self.db.get_latest_index_version(self.tenant_id, self.job_id)
47
+ if not iv_doc:
48
+ raise ValueError(f"No embedding index for job {self.job_id}")
49
+ self._vector_db = iv_doc.get("vector_db", "chroma")
50
+ self._model_name = iv_doc["model"]
51
+ self._provider = iv_doc.get("provider", "huggingface")
52
+ self._configured_dimensions = iv_doc.get("configured_dimensions")
53
+ self._collection = iv_doc.get("collection", "longparser")
54
+
55
+ def _get_relevant_documents(
56
+ self,
57
+ query: str,
58
+ *,
59
+ run_manager: Optional[CallbackManagerForRetrieverRun] = None,
60
+ ) -> list[Document]:
61
+ """Sync retrieval — delegates to existing vector store."""
62
+ import asyncio
63
+ return asyncio.get_event_loop().run_until_complete(
64
+ self._aget_relevant_documents(query, run_manager=run_manager)
65
+ )
66
+
67
+ async def _aget_relevant_documents(
68
+ self,
69
+ query: str,
70
+ *,
71
+ run_manager: Optional[CallbackManagerForRetrieverRun] = None,
72
+ ) -> list[Document]:
73
+ """Async retrieval using existing EmbeddingEngine + vector store."""
74
+ await self._resolve_index()
75
+
76
+ from ..embeddings import EmbeddingEngine
77
+ from ..vectorstores import get_vector_store
78
+
79
+ # Embed query using same model that built the index
80
+ engine = EmbeddingEngine(
81
+ provider=self._provider,
82
+ model_name=self._model_name,
83
+ dimensions=self._configured_dimensions
84
+ )
85
+ query_embedding = engine.embed_query(query)
86
+
87
+ # Search vector DB
88
+ store = get_vector_store(
89
+ self._vector_db,
90
+ collection_name=self._collection,
91
+ index_fingerprint=engine.get_fingerprint(),
92
+ )
93
+ filters = {"tenant_id": self.tenant_id, "job_id": self.job_id}
94
+ results = store.search(query_embedding, top_k=self.top_k, filters=filters)
95
+
96
+ # Convert to LangChain Documents
97
+ documents = []
98
+ for r in results:
99
+ meta = r.get("metadata", {})
100
+ documents.append(Document(
101
+ page_content=r.get("document", ""),
102
+ metadata={
103
+ "chunk_id": meta.get("chunk_id", r.get("id", "")),
104
+ "score": r.get("score", 0),
105
+ "chunk_type": meta.get("chunk_type", ""),
106
+ "page_numbers": meta.get("page_numbers", []),
107
+ "block_ids": meta.get("block_ids", []),
108
+ },
109
+ ))
110
+
111
+ return documents
@@ -0,0 +1,164 @@
1
+ """Pydantic models for LongParser Chat API."""
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
+
14
+ # ---------------------------------------------------------------------------
15
+ # Enums
16
+ # ---------------------------------------------------------------------------
17
+
18
+ class FactSourceType(str, Enum):
19
+ """Allowed fact source types."""
20
+ DOC = "doc"
21
+ USER = "user"
22
+ ASSISTANT_INFERENCE = "assistant_inference" # ephemeral — never persisted
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Config (read from env with defaults)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ class ChatConfig(BaseModel):
30
+ """Chat configuration — all values from env with sensible defaults."""
31
+
32
+ llm_provider: str = Field(
33
+ default_factory=lambda: os.getenv("LONGPARSER_LLM_PROVIDER", "openai")
34
+ )
35
+ llm_model: str = Field(
36
+ default_factory=lambda: os.getenv("LONGPARSER_LLM_MODEL", "gpt-4o")
37
+ )
38
+ max_input_tokens: int = Field(
39
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_MAX_INPUT_TOKENS", "1000"))
40
+ )
41
+ max_output_tokens: int = Field(
42
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_MAX_OUTPUT_TOKENS", "2000"))
43
+ )
44
+ max_prompt_tokens: int = Field(
45
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_MAX_PROMPT_TOKENS", "6000"))
46
+ )
47
+ max_top_k: int = Field(
48
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_MAX_TOP_K", "10"))
49
+ )
50
+ rate_limit: int = Field(
51
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_RATE_LIMIT", "20"))
52
+ )
53
+ short_term_turns: int = Field(
54
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_SHORT_TERM_TURNS", "8"))
55
+ )
56
+ summarize_every: int = Field(
57
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_SUMMARIZE_EVERY", "10"))
58
+ )
59
+ extract_facts_every: int = Field(
60
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_EXTRACT_FACTS_EVERY", "20"))
61
+ )
62
+ max_facts: int = Field(
63
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_MAX_FACTS", "20"))
64
+ )
65
+ llm_timeout: float = Field(
66
+ default_factory=lambda: float(os.getenv("LONGPARSER_LLM_TIMEOUT", "30"))
67
+ )
68
+ llm_max_retries: int = Field(
69
+ default_factory=lambda: int(os.getenv("LONGPARSER_LLM_MAX_RETRIES", "3"))
70
+ )
71
+ ttl_days: int = Field(
72
+ default_factory=lambda: int(os.getenv("LONGPARSER_CHAT_TTL_DAYS", "30"))
73
+ )
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Request / Response Models
78
+ # ---------------------------------------------------------------------------
79
+
80
+ class CreateSessionRequest(BaseModel):
81
+ """POST /chat/sessions — create a chat session."""
82
+ job_id: str
83
+
84
+
85
+ class ChatRequest(BaseModel):
86
+ """POST /chat — ask a question."""
87
+ session_id: str
88
+ job_id: str
89
+ question: str
90
+ llm_provider: Optional[str] = None # override env default
91
+ llm_model: Optional[str] = None # override env default
92
+ top_k: int = 5
93
+ idempotency_key: Optional[str] = None
94
+ require_approval: bool = False # opt-in HITL review
95
+
96
+
97
+ class HITLResumeRequest(BaseModel):
98
+ """POST /chat/resume — resume a paused HITL chat."""
99
+ session_id: str
100
+ thread_id: str # LangGraph thread ID
101
+ action: str # "approve" | "edit" | "reject"
102
+ edited_answer: Optional[str] = None # only for action="edit"
103
+
104
+
105
+ class SourceRef(BaseModel):
106
+ """A reference to a retrieved chunk used as evidence."""
107
+ chunk_id: str
108
+ score: float
109
+ text: str = ""
110
+ page_numbers: list[int] = Field(default_factory=list)
111
+
112
+
113
+ class ChatResponse(BaseModel):
114
+ """Response body for POST /chat."""
115
+ session_id: str
116
+ turn_id: str
117
+ answer: str
118
+ sources: list[SourceRef] = Field(default_factory=list)
119
+ status: str = "complete" # "complete" | "pending_review"
120
+ thread_id: Optional[str] = None # set when status="pending_review"
121
+
122
+
123
+ class LLMAnswer(BaseModel):
124
+ """Structured LLM output — enforced via with_structured_output."""
125
+ answer: str
126
+ cited_chunk_ids: list[str] = Field(default_factory=list)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Turn & Fact Models (stored in MongoDB)
131
+ # ---------------------------------------------------------------------------
132
+
133
+ class Turn(BaseModel):
134
+ """A single Q&A turn in a chat session."""
135
+ turn_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
136
+ question: str
137
+ answer: str
138
+ sources: list[SourceRef] = Field(default_factory=list)
139
+ archived: bool = False
140
+ idempotency_key: Optional[str] = None
141
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
142
+
143
+
144
+ class Fact(BaseModel):
145
+ """A long-term fact extracted from conversation."""
146
+ type: str # entities_from_doc | user_preferences | decisions
147
+ source: FactSourceType
148
+ fact: str
149
+ supporting_chunk_ids: list[str] = Field(default_factory=list)
150
+ confidence: float = 0.0
151
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
152
+
153
+
154
+ class SessionInfo(BaseModel):
155
+ """Response for GET /chat/sessions/{id}."""
156
+ session_id: str
157
+ tenant_id: str
158
+ job_id: str
159
+ turn_count: int = 0
160
+ rolling_summary: str = ""
161
+ long_term_facts: list[Fact] = Field(default_factory=list)
162
+ created_at: datetime
163
+ updated_at: Optional[datetime] = None
164
+ deleted_at: Optional[datetime] = None