Spaces:
Running
Running
File size: 18,624 Bytes
2faa523 c724b78 2faa523 ea266ea 7caa7d0 c32a53f c724b78 c32a53f c724b78 ea266ea c724b78 ea266ea c724b78 ea266ea c724b78 ea266ea 7caa7d0 c32a53f c724b78 c32a53f 2faa523 c724b78 2faa523 c724b78 c32a53f 2faa523 6bcbe41 c724b78 2faa523 7caa7d0 2faa523 c32a53f c724b78 2faa523 c724b78 2faa523 c724b78 2faa523 ea266ea c724b78 ea266ea c724b78 ea266ea 2faa523 7caa7d0 2faa523 bec23a3 2faa523 ea266ea 2faa523 ea266ea 2faa523 bec23a3 2faa523 c32a53f 2faa523 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 | # answer_generator.py
import logging
from langchain_huggingface import HuggingFaceEndpoint
from langchain_huggingface.chat_models import ChatHuggingFace
from langchain_core.messages import HumanMessage
import config
import json
import os
from citation_provenance import page_label
logger = logging.getLogger(__name__)
logging.basicConfig(level=config.LOG_LEVEL, format=config.LOG_FORMAT)
def describe_llm_error(e):
"""Maps a provider failure to a user-facing reason without leaking the raw
exception text (which can include request URLs or account details)."""
provider = getattr(config, "HF_INFERENCE_PROVIDER", "configured")
response = getattr(e, "response", None)
status = getattr(response, "status_code", None)
if status == 401:
return "The Hugging Face token was rejected (401). Check that HF_TOKEN is valid."
if status == 402:
return (
"The Hugging Face account behind HF_TOKEN has used up its Inference "
"Providers credits (402). Add credits/upgrade to PRO, or supply your own token."
)
if status == 403:
return (
"HF_TOKEN is not allowed to call Inference Providers (403). Enable "
"'Make calls to Inference Providers' on the token."
)
if status == 404:
return (
f"Model '{config.LLM_REPO_ID}' is not served by provider '{provider}' (404). "
"Change LLM_REPO_ID or HF_INFERENCE_PROVIDER."
)
if status == 413:
return ("The evidence request exceeds the language-model provider's size or token limit (413). "
"Try a narrower question or a shorter evidence history. Token permissions are not the cause of this error.")
if status == 429:
return "The language-model provider is rate-limiting requests (429). Please retry shortly."
suffix = f" (HTTP {status})" if status else ""
return (
f"The language-model service is temporarily unavailable{suffix}. "
f"Verify that the Hugging Face provider '{provider}' is enabled and "
"that HF_TOKEN has Inference Providers permission."
)
def _stream_llm_answer(llm_instance, messages, retry_messages=None):
"""Iterate the remote stream while keeping provider failures out of the UI."""
emitted = False
try:
for part in llm_instance.stream(messages):
emitted = True
yield part
except Exception as e:
if not emitted and retry_messages and getattr(getattr(e, "response", None), "status_code", None) == 413:
yield from _stream_llm_answer(llm_instance, retry_messages)
return
logger.error(f"Error while streaming the Chat LLM response: {e}", exc_info=True)
yield describe_llm_error(e)
ANSWER_LANGUAGES = ("English", "Hindi")
def language_instruction(answer_language="English"):
"""Returns a prompt line asking for the answer in the chosen language, or
"" for English (the prompts' default). Citation markers, circular numbers
and statutory references stay verbatim so they still match the sources."""
if answer_language == "Hindi":
return (
"Write the entire answer in Hindi (Devanagari script), even though the "
"sources are in English. Keep source numbers such as [1], circular "
"numbers, dates, section numbers, and official scheme names exactly as "
"they appear in the sources."
)
return ""
def _sanitize_conversation_context(conversation_context):
"""Neutralizes text in conversation_context that could be mistaken for
our own prompt delimiters.
conversation_context is built from earlier turns' assistant answers --
LLM output nominally grounded in retrieved documents, but those
documents are bulk-ingested, untrusted PDFs. If a prior answer happens
to echo (or hallucinate) text resembling the "PREVIOUS CONVERSATION"
markers below, splicing it in verbatim could let it prematurely close
that block and have the remainder read as new instructions.
"""
if not conversation_context:
return conversation_context
return (
conversation_context.replace(
"--- END PREVIOUS CONVERSATION ---", "[conversation marker omitted]"
).replace("--- PREVIOUS CONVERSATION", "[conversation marker omitted]")
)
def format_prompt(query, retrieved_chunks_data, conversation_context="", answer_language="English", policy_context=None, currency=None):
conversation_context = _sanitize_conversation_context(conversation_context)
if not retrieved_chunks_data:
context_str = "No relevant information found in the documents."
else:
context_parts = []
for i, chunk_data in enumerate(retrieved_chunks_data):
meta = chunk_data.get('metadata', {})
title = meta.get('title') or "EPFO Document"
circular_no = meta.get('circular_no') or "N/A"
source_pdf = meta.get('english_pdf_link') or meta.get('source_pdf', 'N/A')
page_no = page_label(meta)
evidence_status = meta.get("evidence_status", "retrieved source; applicability unverified")
source_info = f"[Title: {title} | Identifier: {circular_no} | Date: {meta.get('date', 'unknown')} | PDF: {source_pdf} | Page: {page_no} | Authority: {meta.get('authority', 'not classified')} | Evidence: {evidence_status}]"
context_parts.append(f"Source [{chunk_data.get('citation_number', i+1)}] {source_info}:\n{chunk_data['text']}")
context_str = "\n\n".join(context_parts)
policy_section = ""
if policy_context:
policy_section = (
"\nPOLICY EVIDENCE REVIEW (curated metadata, not an additional numbered source):\n"
+ json.dumps(policy_context, ensure_ascii=False)
+ "\nExplain changes by issue date, accounting for later superseding directions and amendments. "
"A numbered SOP or established commencement date does not establish the latest applicable policy. "
"Never invent version numbers. Only source_checked relationships are verified. Never promote an unverified target or unknown commencement to fact; missing originals remain unverified. "
"Describe explicit supersession even if timing is unresolved; do not call it merely implementation of an unchanged older SOP. "
"Partial changes affect only their stated scope; established supersession displaces directions only within that scope and commencement. "
"Distinguish Board approvals, press reports, software dates, processing limits and deadlines from operative rules and entitlement. "
"Supplementary hybrid sources are unverified for amendment and applicability. "
"State material gaps and unknown dates; never claim complete applicable rules, current validity or live implementation. "
"Cite numbered source pages, never metadata alone.\n"
)
currency_section = ""
if currency:
currency_section = (
"\nCURRENCY CHECK (search metadata, not a numbered source):\n"
+ json.dumps({k: currency.get(k) for k in ("newest_cited", "oldest_cited", "undated_sources", "newer_related", "corpus_as_of")}, ensure_ascii=False)
+ "\nBegin the answer with the issue date of the most recent circular you rely on. "
"Attribute every rule to its circular and date (for example 'As per circular dated ...'); "
"never say a provision is current, in force or still applicable. "
"State that a later circular not among the sources may have amended or replaced it. "
"If newer_related is not empty, tell the user these newer circulars exist and must be read before relying on the answer; "
"do not describe their content, because they are not numbered sources.\n"
)
history_section = (
f"\n--- PREVIOUS CONVERSATION (for follow-up context only; "
f"ground every factual claim in the numbered sources above, not in "
f"prior turns) ---\n{conversation_context}\n--- END PREVIOUS CONVERSATION ---\n"
if conversation_context
else ""
)
prompt = f"""You are a helpful and precise assistant specializing in Employees' Provident Fund Organisation (EPFO) rules, circulars, schemes, and manuals.
Answer the user's question based strictly on the context provided below.
Support factual claims with inline source numbers such as [1] or [2], matching the numbered sources below the answer.
Also mention relevant circular numbers, dates, or statutory sections when they are present in the context.
Never invent a source number or cite a source that does not support the claim.
Where page provenance is unverified, cite the document without inventing a page.
Relevance or recent issue date does not establish that a provision remains applicable.
If the provided context does not contain enough information to answer the question, state clearly that the information was not found in the documents.
{language_instruction(answer_language)}
Context from EPFO Documents:
-----------------------
{context_str}
-----------------------
{history_section}
{policy_section}{currency_section}
Question: {query}
Helpful & Grounded Answer:"""
return prompt
def bounded_prompt(query, chunks, conversation_context="", answer_language="English", policy_context=None, limit=18000, currency=None):
"""Bound the complete request, preserving original UI citation numbers.
Omit whole source pages instead of cutting instructions out of context. The
model is told about reduced coverage. Retry uses the same numbered sources.
"""
if len(query) > 6000:
raise ValueError("Please shorten the question to 6,000 characters.")
selected = [{**c, "citation_number": c.get("citation_number", i + 1)} for i, c in enumerate(chunks)]
if policy_context:
selected.sort(key=lambda c: (str(c.get("metadata", {}).get("date", "")),
bool(c.get("metadata", {}).get("policy_change_evidence") or
"relationship or commencement" in c.get("metadata", {}).get("evidence_status", ""))), reverse=True)
# One page per document before further pages of the same document.
first, rest, seen = [], [], set()
for item in selected:
meta = item.get("metadata", {})
key = (meta.get("date"), meta.get("circular_no"), meta.get("title"))
(rest if key in seen else first).append(item)
seen.add(key)
selected = first + rest
context = None
if policy_context:
context = {**policy_context}
# Page text already supplies the evidence. Do not repeat long OCR quotes.
context["relationships"] = [{k: v for k, v in r.items() if k not in ("evidence", "effective_date_evidence")} for r in context.get("relationships", [])]
if context and len(json.dumps(context, ensure_ascii=False).encode("utf-8")) > limit // 4:
# Reserve the request for actual numbered evidence, including on 413 retry.
context = {
"as_of": policy_context.get("as_of"), "scope": policy_context.get("scope"),
"conclusion": "complete_applicability_not_established",
"documents": [{k: d.get(k) for k in ("id", "title", "issued_on", "status")}
for d in sorted(policy_context.get("documents", []), key=lambda d: d.get("issued_on", ""), reverse=True)[:4]],
"relationships": [{k: r.get(k) for k in ("source", "target", "type", "scope", "verification", "status")}
for r in policy_context.get("relationships", [])[-3:]],
"limitations": ["Review metadata condensed for request limits. Full inventory and gaps are displayed separately. Unknown commencement and missing originals remain unverified; complete applicability is not established."],
}
if context and limit <= 9000:
context = {
"as_of": context.get("as_of"), "conclusion": "complete_applicability_not_established",
"recent_documents_id_date_status": [[d.get("id"), d.get("issued_on"), d.get("status")] for d in context.get("documents", [])],
"checked_changes_source_target_type": [[r.get("source"), r.get("target"), r.get("type")] for r in context.get("relationships", []) if r.get("verification") == "source_checked"],
"limitations": ["Inventory condensed; full review is in UI. Missing originals and commencement remain unverified. No complete applicability claim."],
}
history = conversation_context[-1500:]
while True:
prompt = format_prompt(query, selected, history, answer_language, context, currency)
if len(prompt.encode("utf-8")) <= limit:
return prompt
if history:
history = ""
elif selected:
# Keep relationship/commencement pages whenever other pages can go.
if policy_context:
# Keep the newest dated change pages before historical controls.
drop = len(selected) - 1
else:
drop = next((i for i in range(len(selected)-1, -1, -1)
if "relationship or commencement" not in selected[i].get("metadata", {}).get("evidence_status", "")), len(selected)-1)
selected.pop(drop)
if context:
context["prompt_coverage_reduced"] = True
if "Source pages omitted to meet provider request limits; answer coverage is incomplete." not in context["limitations"]:
context["limitations"] = list(context["limitations"]) + ["Source pages omitted to meet provider request limits; answer coverage is incomplete."]
elif context:
context = {"as_of": context.get("as_of"), "scope": context.get("scope"),
"conclusion": "complete_applicability_not_established",
"limitations": ["Policy inventory omitted to meet request limits; no complete applicability claim is supported."]}
if len(format_prompt(query, [], "", answer_language, context, currency).encode("utf-8")) > limit:
raise ValueError("The question is too long for the configured request limit.")
else:
raise ValueError("The question is too long for the configured request limit.")
def get_llm_answer(query, retrieved_chunks_data, llm_instance, stream=False, conversation_context="", answer_language="English", policy_context=None, currency=None):
if not query:
logger.warning("Query is empty. Cannot generate answer.")
return "No query provided."
if llm_instance is None:
logger.error("LLM instance is not provided. Cannot generate answer.")
return "LLM not available."
try:
prompt_string = bounded_prompt(query, retrieved_chunks_data, conversation_context,
answer_language, policy_context,
limit=int(os.environ.get("LLM_MAX_PROMPT_BYTES", "18000")), currency=currency)
retry_prompt = bounded_prompt(query, retrieved_chunks_data, "", answer_language,
policy_context, limit=9000, currency=currency)
except ValueError as error:
return str(error)
logger.debug(f"Formatted Prompt String for Chat LLM:\n{prompt_string}")
logger.info(f"Sending prompt to Chat LLM for query: '{query[:100]}...'")
messages = [HumanMessage(content=prompt_string)]
if stream:
return _stream_llm_answer(llm_instance, messages, [HumanMessage(content=retry_prompt)])
try:
try:
response_message = llm_instance.invoke(messages)
except Exception as error:
if getattr(getattr(error, "response", None), "status_code", None) != 413:
raise
response_message = llm_instance.invoke([HumanMessage(content=retry_prompt)])
logger.info("Received response from Chat LLM.")
if hasattr(response_message, 'content'):
return response_message.content
else:
logger.error(f"Unexpected response type from Chat LLM: {type(response_message)}. Full response: {response_message}")
return str(response_message)
except Exception as e:
logger.error(f"Error during Chat LLM invocation: {e}", exc_info=True)
return describe_llm_error(e)
def initialize_llm(hf_token=None, max_new_tokens=None):
token = hf_token or config.HF_TOKEN
if not token:
logger.error("Hugging Face API token (HF_TOKEN) is not set. LLM cannot be initialized.")
raise ValueError("HF_TOKEN not found. LLM initialization failed.")
try:
logger.info(
f"Initializing Chat LLM via HuggingFaceEndpoint: {config.LLM_REPO_ID}, "
f"Task: {config.LLM_TASK}"
)
kwargs = {
"repo_id": config.LLM_REPO_ID,
"task": config.LLM_TASK,
"temperature": config.LLM_TEMPERATURE,
"max_new_tokens": max_new_tokens or getattr(config, "LLM_MAX_NEW_TOKENS", 2048),
"huggingfacehub_api_token": token,
}
if getattr(config, "HF_INFERENCE_PROVIDER", None):
kwargs["provider"] = config.HF_INFERENCE_PROVIDER
endpoint = HuggingFaceEndpoint(**kwargs)
chat_kwargs = {}
reasoning_effort = getattr(config, "LLM_REASONING_EFFORT", "")
if reasoning_effort:
# chat_completion() forwards extra_body verbatim to the provider.
chat_kwargs["model_kwargs"] = {"extra_body": {"reasoning_effort": reasoning_effort}}
chat_model = ChatHuggingFace(llm=endpoint, **chat_kwargs)
logger.info("ChatHuggingFace LLM initialized successfully.")
return chat_model
except Exception as e:
logger.error(f"Failed to initialize Chat LLM: {e}", exc_info=True)
raise
if __name__ == '__main__':
logger.info("Starting Answer Generator test...")
try:
llm_service = initialize_llm()
sample_query = "What is the procedure for joint declaration?"
sample_context = [{
"text": "Joint declaration SOP outlines the procedure for member profile correction.",
"metadata": {"title": "SOP Joint Declaration", "source_pdf": "Circular_JD.pdf", "page_number": "1"}
}]
print(get_llm_answer(sample_query, sample_context, llm_service))
except Exception as e:
logger.info(f"Test run completed: {e}")
|