| import json |
| from groq import Groq |
| from backend.config import GROQ_API_KEY, GROQ_MODEL, GROQ_TIMEOUT |
| from backend.rag.generator import GeneratedAnswer, _build_citations, _get_groq_client |
| from backend.rag.multi_retriever import MultiSourceResult |
| from backend.rag.retriever import RetrievedChunk |
| from backend.core.schemas import UnifiedChunkMetadata |
| from backend.database.connection import get_connection |
|
|
| def _fetch_unified_metadata(chunk_id: str) -> dict: |
| """Fetch unified_metadata for a specific chunk from the database.""" |
| try: |
| with get_connection() as conn: |
| cursor = conn.cursor(dictionary=True) |
| cursor.execute("SELECT unified_metadata FROM chunks WHERE id = %s", (chunk_id,)) |
| row = cursor.fetchone() |
| if row and row['unified_metadata']: |
| |
| |
| meta = row['unified_metadata'] |
| if isinstance(meta, str): |
| return json.loads(meta) |
| return meta |
| except Exception as e: |
| print(f"[MultiGenerator] Error fetching metadata for {chunk_id}: {e}") |
| return {} |
|
|
| def _format_chunk_for_prompt(rank: int, chunk: RetrievedChunk) -> str: |
| |
| meta_dict = _fetch_unified_metadata(chunk.chunk_id) |
| |
| |
| section_id = meta_dict.get("section_id") |
| case_name = meta_dict.get("case_name") |
| para_range = meta_dict.get("para_range") |
| |
| ref_detail = "" |
| if section_id: |
| ref_detail = f"Section {section_id}" |
| elif case_name: |
| ref_detail = case_name |
| elif para_range: |
| ref_detail = f"para {para_range}" |
| else: |
| |
| if chunk.page_number: |
| ref_detail = f"page {chunk.page_number}" |
| elif chunk.timestamp_s is not None: |
| ref_detail = f"at {chunk.timestamp_s}s" |
|
|
| header = f"[{rank}] {chunk.source_title}" |
| if ref_detail: |
| header += f" — {ref_detail}" |
| |
| return f"{header}\n{chunk.chunk_text}" |
|
|
| def build_single_source_prompt(question: str, result: MultiSourceResult, history: list[dict] | None, image_context: str | None = None, is_legal: bool = False) -> list[dict]: |
| if is_legal: |
| system_prompt = """You are a legal information assistant for Indian law. Answer using ONLY the provided context. |
| Structure your answer as: |
| ANSWER: [clear explanation] |
| LEGAL BASIS: [exact quote from source] |
| CITATIONS: [numbered list: Document | Section/Para | Court | Date] |
| AMENDMENTS: [any amendments to cited sections] |
| Never give legal advice. State only what the law says.""" |
| else: |
| system_prompt = """You are an expert research assistant. Answer accurately using ONLY the provided context. |
| Structure your answer as: |
| ANSWER: [clear, detailed explanation with inline citations like [Source 1]] |
| KEY CONCEPTS: [list the main concepts from the source relevant to the question] |
| CITATIONS: [numbered list: Document | Page/Section] |
| Quote directly from the source when relevant. If the context doesn't contain the answer, say so clearly.""" |
|
|
| context_parts = [] |
| for i, chunk in enumerate(result.all_chunks, start=1): |
| context_parts.append(_format_chunk_for_prompt(i, chunk)) |
| |
| context_block = "\n\n".join(context_parts) |
| |
| if image_context: |
| system_prompt = f"{system_prompt}\n\n{image_context}" |
|
|
| messages = [ |
| {"role": "system", "content": f"{system_prompt}\n\nRETRIEVED CONTEXT:\n{context_block}"} |
| ] |
| |
| if history: |
| |
| valid_history = [m for m in history if m.get('role') in ('user', 'assistant') and m.get('content')] |
| messages.extend(valid_history[-12:]) |
| |
| messages.append({"role": "user", "content": question}) |
| return messages |
|
|
| def build_comparison_prompt(question: str, result: MultiSourceResult, history: list[dict] | None, image_context: str | None = None, is_legal: bool = False) -> list[dict]: |
| if is_legal: |
| system_prompt = """You are a legal analyst. Compare the provided sources objectively. |
| Structure your answer as: |
| QUERY: [restate what is being compared] |
| SOURCE A — {first_source}: |
| [what source A says, with exact quote] |
| SOURCE B — {second_source}: |
| [what source B says, with exact quote] |
| KEY DIFFERENCES: |
| [bullet points of substantive differences] |
| KEY SIMILARITIES: |
| [bullet points of shared principles] |
| CITATIONS: [numbered, one per claim] |
| Do not take sides. Report what each source states.""" |
| else: |
| system_prompt = """You are a research analyst. Compare the provided sources objectively and thoroughly. |
| Structure your answer EXACTLY as: |
| |
| ## Comparison Overview |
| [1-2 sentence summary of what is being compared] |
| |
| ## {first_source} |
| [Key points and explanation from this source, with page references] |
| |
| ## {second_source} |
| [Key points and explanation from this source, with page references] |
| |
| ## Key Differences |
| [Bullet points of the most important differences between the sources] |
| |
| ## Key Similarities |
| [Bullet points of shared concepts or principles] |
| |
| ## Citations |
| [Numbered list, one per factual claim] |
| |
| Be specific. Quote directly from sources. Do not introduce outside knowledge.""" |
|
|
| context_parts = [] |
| for title, chunks in result.source_groups.items(): |
| group_context = "\n".join([_format_chunk_for_prompt(i+1, c) for i, c in enumerate(chunks)]) |
| context_parts.append(f"=== SOURCE: {title} ===\n{group_context}") |
| |
| context_block = "\n\n".join(context_parts) |
| |
| |
| titles = list(result.source_groups.keys()) |
| s_prompt = system_prompt |
| if len(titles) >= 2: |
| s_prompt = s_prompt.replace("{first_source}", titles[0]).replace("{second_source}", titles[1]) |
| elif len(titles) == 1: |
| s_prompt = s_prompt.replace("{first_source}", titles[0]).replace("{second_source}", "") |
| |
| if len(titles) >= 2: |
| s_prompt = s_prompt.replace("{first source title}", titles[0]).replace("{second source title}", titles[1]) |
| |
| if image_context: |
| s_prompt = f"{s_prompt}\n\n{image_context}" |
|
|
| messages = [ |
| {"role": "system", "content": f"{s_prompt}\n\nRETRIEVED CONTEXT:\n{context_block}"} |
| ] |
| |
| if history: |
| valid_history = [m for m in history if m.get('role') in ('user', 'assistant') and m.get('content')] |
| messages.extend(valid_history[-12:]) |
| |
| messages.append({"role": "user", "content": question}) |
| return messages |
|
|
| def build_synthesis_prompt(question: str, result: MultiSourceResult, history: list[dict] | None, image_context: str | None = None, is_legal: bool = False) -> list[dict]: |
| if is_legal: |
| system_prompt = """You are a legal research synthesizer for Indian law. Consolidate and summarize information from MULTIPLE sources. |
| Structure your answer as: |
| CONSOLIDATED LEGAL VIEW: [comprehensive answer weaving sources] |
| BY STATUTE/CASE: [for each source, key points and citations] |
| RULES: Cite every claim. Quote exactly when mentioning sections. Do not give legal advice.""" |
| else: |
| system_prompt = """You are a research synthesizer. Your job is to consolidate and summarize information from MULTIPLE sources. |
| |
| Structure your answer EXACTLY as: |
| ## Consolidated Answer |
| [A comprehensive 2-3 paragraph answer that weaves information from ALL sources] |
| |
| ## By Source |
| [For each source: source name in bold, then 2-3 key points from that source with page/section references] |
| |
| ## Common Themes |
| [Bullet list of themes found across sources] |
| |
| ## Key Differences |
| [Where sources differ or contradict, if any] |
| |
| ## Citations |
| [Numbered list with source title and reference for each claim] |
| |
| RULES: Cite EVERY claim. Reference specific pages/sections. Do NOT guess.""" |
|
|
| context_parts = [] |
| for title, chunks in result.source_groups.items(): |
| |
| group_context = "\n".join([_format_chunk_for_prompt(i+1, c) for i, c in enumerate(chunks[:4])]) |
| context_parts.append(f"=== SOURCE: {title} ===\n{group_context}") |
| |
| context_block = "\n\n".join(context_parts) |
| |
| if image_context: |
| system_prompt = f"{system_prompt}\n\n{image_context}" |
|
|
| messages = [ |
| {"role": "system", "content": f"{system_prompt}\n\nRETRIEVED CONTEXT:\n{context_block}"} |
| ] |
| |
| if history: |
| valid_history = [m for m in history if m.get('role') in ('user', 'assistant') and m.get('content')] |
| messages.extend(valid_history[-12:]) |
| |
| messages.append({"role": "user", "content": question}) |
| return messages |
|
|
| def generate_multi_answer(question: str, result: MultiSourceResult, history: list[dict] | None = None, image_context: str | None = None, is_legal: bool = False) -> GeneratedAnswer: |
| if not result.all_chunks: |
| return GeneratedAnswer( |
| answer="I searched your knowledge base but found no relevant information. \n This usually means: (1) no documents have been ingested yet, \n (2) your question doesn't match any uploaded content, or \n (3) the FAISS index is empty. \n Please upload a PDF or website first, then try again.", |
| citations=[], |
| chunks=[] |
| ) |
|
|
| |
| if result.query_intent == "comparison": |
| messages = build_comparison_prompt(question, result, history, image_context, is_legal=is_legal) |
| elif result.query_intent == "synthesis": |
| messages = build_synthesis_prompt(question, result, history, image_context, is_legal=is_legal) |
| else: |
| messages = build_single_source_prompt(question, result, history, image_context, is_legal=is_legal) |
| |
| |
| try: |
| client = _get_groq_client() |
| response = client.chat.completions.create( |
| model=GROQ_MODEL, |
| messages=messages, |
| stream=False, |
| timeout=GROQ_TIMEOUT |
| ) |
| answer = response.choices[0].message.content |
| except Exception as e: |
| print(f"[MultiGenerator] Groq Error: {e}") |
| answer = f"Error generating answer: {e}" |
|
|
| |
| citations = _build_citations(result.all_chunks) |
| |
| return GeneratedAnswer( |
| answer=answer, |
| citations=citations, |
| chunks=result.all_chunks |
| ) |
|
|