File size: 8,096 Bytes
135a99c
 
 
 
 
 
 
 
4e1de0b
135a99c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f16a073
 
 
 
4e1de0b
 
 
 
 
 
135a99c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e1de0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135a99c
 
 
 
4e1de0b
 
 
 
 
 
135a99c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e1de0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135a99c
4e1de0b
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
"""
The chatbot's brain: embed the question, retrieve a broad set of relevant
chunks (hybrid vector + keyword search) from Supabase, and ask the LLM to
write a genuinely generative answer grounded in that material β€” not a
templated or copy-pasted response β€” citing which document each part came
from.
"""
import logging
import re
from typing import List, Optional

import requests

import db
from config import GROQ_API_KEY, GROQ_CHAT_MODEL, TOP_K_CHUNKS, MAX_CONTEXT_CHARS
from embeddings import embed_query

logger = logging.getLogger("janmat.rag")

GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"

SYSTEM_PROMPT = """You are JanMat, an assistant that answers questions about Indian \
legislative bills, their PRS Legislative Research briefs, related news articles, and \
public opinion submissions.

Rules:
- Answer using the numbered SOURCE excerpts provided below. Do not invent facts that \
aren't supported by them.
- Write a genuinely thought-out, generative answer in your own words β€” synthesise \
across the sources, explain context and implications, and structure the answer to \
actually help the reader understand the topic. Do not just copy or lightly rephrase a \
single source's sentence.
- If the sources give a fuller picture together than any one of them alone, weave that \
together rather than picking one source and ignoring the rest.
- After each claim, cite the source number(s) it came from, like [1] or [2,3].
- Format for readability: a short opening paragraph that answers the question \
directly, then bullet points or short paragraphs for the details. Use **bold** \
sparingly for key terms. Avoid wide multi-column tables β€” they're hard to read on \
a phone; prefer bullets. Don't add a heading above a single short paragraph.
- Explain the substance in your own words. Short quoted phrases are fine where \
the exact legal wording matters, but don't string together long verbatim passages \
from the source text β€” the point is to make the material understandable, not to \
reproduce it.
- Only cite a source if you actually used it for that claim. Don't add citations \
to look thorough.
- If the sources don't contain enough information to answer, say so plainly instead of \
guessing.
- Keep the answer in plain, accessible language β€” assume the reader is a citizen, not \
a lawyer or policy expert.
- If sources disagree (e.g. a bill's official text vs. a news article's framing vs. a \
public comment), point that out rather than picking one silently.
"""


def _format_context(chunks: List[dict]) -> str:
    parts = []
    total = 0
    for i, c in enumerate(chunks, start=1):
        header = f"[{i}] {c['title']} ({c['kind']}, {c['url']}" + (
            f", page {c['page_number']})" if c.get("page_number") else ")"
        )
        block = f"{header}\n{c['content']}\n"
        if total + len(block) > MAX_CONTEXT_CHARS:
            break
        parts.append(block)
        total += len(block)
    return "\n---\n".join(parts)


def retrieve(question: str, top_k: int = TOP_K_CHUNKS, filter_kind: Optional[str] = None) -> List[dict]:
    """Hybrid retrieval: embeds the question for vector search, and passes the
    raw text too so Postgres can blend in trigram keyword matching β€” catches
    exact bill names / section numbers that a pure embedding search can miss,
    while still ranking mostly on semantic similarity."""
    query_vec = embed_query(question)
    return db.match_chunks(query_vec, query_text=question, match_count=top_k, filter_kind=filter_kind)


def _call_groq(messages: list, temperature: float = 0.3) -> str:
    if not GROQ_API_KEY:
        raise RuntimeError("GROQ_API_KEY is not set")
    headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
    payload = {"model": GROQ_CHAT_MODEL, "temperature": temperature, "messages": messages}
    resp = requests.post(GROQ_URL, headers=headers, json=payload, timeout=60)
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"]


def _extract_cited_numbers(answer: str, max_n: int) -> List[int]:
    """
    Find which source numbers the model actually cited, e.g. [1], [2,3],
    [4, 5]. Returns them sorted and de-duplicated, ignoring any number
    outside the range of sources we actually supplied.
    """
    cited = set()
    for group in re.findall(r"\[([\d\s,]+)\]", answer):
        for part in group.split(","):
            part = part.strip()
            if part.isdigit():
                n = int(part)
                if 1 <= n <= max_n:
                    cited.add(n)
    return sorted(cited)


def _renumber_answer(answer: str, old_to_new: dict) -> str:
    """
    After dropping uncited sources, the surviving ones are renumbered 1..N.
    Rewrite the markers in the answer text to match, so [12] becomes [3] if
    source 12 is now the third in the list.
    """
    def replace(match):
        seen = []
        for part in match.group(1).split(","):
            part = part.strip()
            if part.isdigit() and int(part) in old_to_new:
                new = str(old_to_new[int(part)])
                # Two chunks from the same document map to the same new
                # number β€” don't emit "[1,1]".
                if new not in seen:
                    seen.append(new)
        return f"[{','.join(seen)}]" if seen else ""

    return re.sub(r"\[([\d\s,]+)\]", replace, answer)


def answer_question(session_id: str, question: str, filter_kind: Optional[str] = None) -> dict:
    """
    Returns:
      {"answer": str, "citations": [{"n": 1, "title":.., "url":.., "kind":.., "document_id":..}], "chunks_used": int}

    Note: `citations` contains only the sources the model actually cited in
    its answer β€” not every chunk that was retrieved. Retrieval deliberately
    pulls a broad set of candidates (TOP_K_CHUNKS) so the model has enough
    context to work with, but listing all of them under the answer would be
    misleading: most retrieved chunks don't end up informing the response.
    """
    chunks = retrieve(question, filter_kind=filter_kind)
    if not chunks:
        return {
            "answer": (
                "I don't have any ingested bill text, PDFs, articles, or public "
                "opinions that match this question yet. Try asking about a bill "
                "that's already been ingested, or trigger an ingestion run."
            ),
            "citations": [],
            "chunks_used": 0,
        }

    context = _format_context(chunks)

    history = db.get_recent_messages(session_id, limit=6)
    messages = [{"role": "system", "content": SYSTEM_PROMPT}]
    for h in history:
        messages.append({"role": h["role"], "content": h["content"]})
    messages.append(
        {
            "role": "user",
            "content": f"SOURCES:\n{context}\n\nQUESTION: {question}",
        }
    )

    answer = _call_groq(messages)

    # Keep only the sources the model actually cited. Multiple chunks often
    # come from the same document, so also de-duplicate by URL β€” citing the
    # same PDF three times as [2], [7], [13] is noise, not precision.
    cited_numbers = _extract_cited_numbers(answer, len(chunks))

    citations = []
    old_to_new = {}
    seen_urls = {}
    for n in cited_numbers:
        c = chunks[n - 1]
        url = c["url"]
        if url in seen_urls:
            # Already citing this document β€” point this marker at the
            # existing entry instead of adding a duplicate.
            old_to_new[n] = seen_urls[url]
            continue
        new_n = len(citations) + 1
        seen_urls[url] = new_n
        old_to_new[n] = new_n
        citations.append(
            {
                "n": new_n,
                "document_id": c["document_id"],
                "title": c["title"],
                "url": url,
                "kind": c["kind"],
                "page_number": c.get("page_number"),
            }
        )

    answer = _renumber_answer(answer, old_to_new)

    return {"answer": answer, "citations": citations, "chunks_used": len(chunks)}