File size: 5,677 Bytes
0a19322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Understand the question BEFORE searching: page numbers, which document, a clean search query."""
import json
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path

from . import llm
from .config import settings

log = logging.getLogger("rag.query")

# ------------------------------------------------------------------ page numbers
_NUM = r"\d{1,4}"
_SEP = r"(?:-|\u2013|\u2014|to|se|,|&|and|aur)"
_LIST = rf"{_NUM}(?:\s*{_SEP}\s*{_NUM})*"
# "page 20", "pages 20-25", "page no. 20", "pg 5, 6 and 7"
_PAGE_AFTER = re.compile(
    rf"(?:\bpages?|\bpgs?\b|\bpp\.)\s*(?:no\.?|number|num|nbr|#|:)?\s*({_LIST})", re.I
)
# "20th page", "20 no ke page", "20 se 25 pages"
_PAGE_BEFORE = re.compile(
    rf"\b({_NUM}(?:\s*(?:-|\u2013|\u2014|to|se)\s*{_NUM})?)(?:st|nd|rd|th)?\s*"
    r"(?:no\.?|number|nambar|num)?\s*(?:ke|ka|ki|wale|wala|vale|vala|wali|vali)?\s*"
    r"(?:pages?|pg|panne|panna|pej)\b",
    re.I,
)


def _parse_expr(expr: str) -> set[int]:
    expr = re.sub(r"\b(?:to|se)\b|\u2013|\u2014", "-", expr, flags=re.I)
    out: set[int] = set()
    for part in re.split(r"\s*(?:,|&|\band\b|\baur\b)\s*", expr, flags=re.I):
        part = part.strip()
        m = re.fullmatch(r"(\d+)\s*-\s*(\d+)", part)
        if m:
            a, b = int(m[1]), int(m[2])
            if a <= b and b - a <= 60:
                out.update(range(a, b + 1))
            else:
                out.update([a, b])
        elif part.isdigit():
            out.add(int(part))
    return out


def extract_pages(question: str) -> list[int]:
    pages: set[int] = set()
    for rx in (_PAGE_AFTER, _PAGE_BEFORE):
        for m in rx.finditer(question):
            pages |= _parse_expr(m.group(1))
    return sorted(p for p in pages if p >= 1)[:50]


# --------------------------------------------------------------- document names
def _norm(s: str) -> str:
    return re.sub(r"[\W_]+", " ", s.lower()).strip()


def match_docs(question: str, docs: list[dict]) -> list[str]:
    """Documents whose file name is mentioned in the question."""
    q = f" {_norm(question)} "
    hits = []
    for d in docs:
        stem = _norm(Path(d["filename"]).stem)
        if len(stem) >= 3 and f" {stem} " in q:
            hits.append(d["doc_id"])
    return hits


# ---------------------------------------------------------------- LLM rewriting
REWRITE_SYSTEM = """You prepare a user's question for a document search engine. You do NOT answer it.
The user may write English, Hindi or Hinglish (Hindi in Roman letters), and may have typos.

Return ONLY a JSON object, no other text:
{"search_query": "...", "pages": [], "document": null, "intent": "search"}

- search_query: a standalone, keyword-rich ENGLISH search query. Fix typos, translate Hinglish,
  and resolve words like "it" or "that" using recent_chat. Do not include page numbers in it.
- pages: list of page numbers the user refers to (e.g. "20 no ke page" -> [20]); [] if none.
- document: one name copied EXACTLY from available_documents if the user clearly refers to a
  specific document, otherwise null.
- intent: "page_lookup" if the user wants to know what is written on / contained in specific pages,
  otherwise "search"."""


def _extract_json(raw: str) -> dict:
    start, end = raw.find("{"), raw.rfind("}")
    if start == -1 or end <= start:
        raise ValueError("no JSON in model reply")
    return json.loads(raw[start : end + 1])


def _rewrite_with_llm(question: str, docs: list[dict], history: list[dict]) -> dict:
    recent = [{"role": h["role"], "text": h["content"][:300]} for h in history[-4:]]
    payload = json.dumps(
        {"available_documents": [d["filename"] for d in docs][:30], "recent_chat": recent, "question": question},
        ensure_ascii=False,
    )
    raw = llm.chat(
        [{"role": "system", "content": REWRITE_SYSTEM}, {"role": "user", "content": payload}],
        max_tokens=800,
        temperature=0,
    )
    return _extract_json(raw)


# ------------------------------------------------------------------------- plan
@dataclass
class QueryPlan:
    search_query: str
    intent: str = "search"  # "search" | "page_lookup"
    pages: list[int] = field(default_factory=list)
    doc_ids: list[str] = field(default_factory=list)  # documents named in the question
    used_llm: bool = False


def analyze(question: str, docs: list[dict], history: list[dict] | None = None) -> QueryPlan:
    history = history or []
    regex_pages = extract_pages(question)
    doc_ids = match_docs(question, docs)
    plan = QueryPlan(search_query=question, pages=regex_pages, doc_ids=doc_ids)
    plan.intent = "page_lookup" if regex_pages else "search"

    if not settings.use_query_rewrite:
        return plan
    try:
        data = _rewrite_with_llm(question, docs, history)
    except Exception as e:  # rate limit, bad JSON, no key... fall back to the plain question
        log.warning("Query rewrite skipped: %s", e)
        return plan

    plan.used_llm = True
    sq = data.get("search_query")
    if isinstance(sq, str) and sq.strip():
        plan.search_query = sq.strip()

    llm_pages = [int(p) for p in (data.get("pages") or []) if str(p).isdigit() and int(p) >= 1][:50]
    plan.pages = sorted(set(llm_pages)) or regex_pages
    if plan.pages:
        plan.intent = "page_lookup" if data.get("intent") != "search" else "search"
    else:
        plan.intent = "search"

    if not plan.doc_ids and isinstance(data.get("document"), str):
        wanted = data["document"].strip().lower()
        for d in docs:
            if d["filename"].lower() == wanted:
                plan.doc_ids = [d["doc_id"]]
                break
    return plan