Spaces:
Running
Running
Download answer_generator.py from gauravmeena0708/epfo-circulars: direct link, hf CLI and curl.
- Browser
- Download file 18.6 kB
-
https://huggingface.co/spaces/gauravmeena0708/epfo-circulars/resolve/main/answer_generator.py
- Command line
-
hf download hf://spaces/gauravmeena0708/epfo-circulars/answer_generator.py
-
curl -L -o answer_generator.py https://huggingface.co/spaces/gauravmeena0708/epfo-circulars/resolve/main/answer_generator.py
18.6 kB
| # 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}") | |