File size: 5,715 Bytes
b1aba72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51e9502
b1aba72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
retrieval.py
-------------------
Retrieval nodes and pipeline orchestration.
"""
import time
import numpy as np
from tavily import TavilyClient

import vectorstore as vs_module
from config import CONFIDENCE_THRESHOLD, TAVILY_API_KEY
from logging_config import get_logger

logger = get_logger(__name__)


# Node 1: FAISS retrieval

def retrieve_node(state: dict) -> dict:
  """
  Search the FAISS index and decide whether web search is needed.

  Confidence scoring: sim = 1 / (1 + L2_distance). If best similarity
  < CONFIDENCE_THRESHOLD, sets needs_web=True to route to web_node.
  """
  query = state["query"]

  # Access the current FAISS index via the module — never cache this
  # as a local variable because chunk_and_index() updates it in place.
  vs = vs_module.vectorstore

  if vs is None:
    logger.info("FAISS store is empty -> routing to web search")
    return {
        "retrieved_docs": "",
        "needs_web":      True,
        "confidence":     0.0,
        "source_type":    "none",
    }

  try:
      docs_with_scores = vs.similarity_search_with_score(query, k=6)
  except Exception as e:
    logger.warning(f"FAISS search failed: {e} -> web fallback")
    return {
        "retrieved_docs": "",
        "needs_web":      True,
        "confidence":     0.0,
        "source_type":    "none",
    }

  if not docs_with_scores:
    logger.info("No matches -> routing to web search")
    return {
        "retrieved_docs": "",
        "needs_web":      True,
        "confidence":     0.0,
        "source_type":    "none",
    }

  distances    = np.array([score for _, score in docs_with_scores])
  similarities = 1.0 / (1.0 + distances)
  best_score   = float(np.max(similarities))

  logger.info(f"best_sim={best_score:.3f} (threshold={CONFIDENCE_THRESHOLD})")

  docs_text = "\n\n".join(
      f"[Source: {doc.metadata.get('source', 'unknown')}]\n"
      f"{doc.page_content[:800]}"
      for doc, _ in docs_with_scores
  )

  needs_web = best_score < CONFIDENCE_THRESHOLD
  if needs_web:
      logger.info("Low confidence -> fetching from Tavily")
  else:
      logger.info("Sufficient confidence -> skipping web search")

  return {
      "retrieved_docs": docs_text,
      "needs_web":      needs_web,
      "confidence":     best_score,
      "source_type":    "faiss",
  }


# Node 2: Tavily web search

def web_node(state: dict) -> dict:
  """
  Fetch fresh results from Tavily and index them into FAISS.

  Full raw content is indexed for future queries; a capped display
  snippet (1500 chars/source) goes into the answer prompt.
  """
  query = state["query"]
  logger.info(f"Searching Tavily: {query!r}")

  if not TAVILY_API_KEY:
      logger.warning("TAVILY_API_KEY not set — skipping web search")
      return {"web_results": "", "sources_count": 0, "source_type": "none"}

  try:
      client   = TavilyClient(api_key=TAVILY_API_KEY)
      response = client.search(
          query=query,
          max_results=5,
          search_depth="basic",
          include_raw_content=True,
          include_answer=False,
      )
  except Exception as e:
      logger.error(f"Tavily failed: {e}")
      return {"web_results": "", "sources_count": 0, "source_type": "none"}

  results = response.get("results", [])
  if not results:
      logger.info("Tavily returned no results")
      return {"web_results": "", "sources_count": 0, "source_type": "none"}

  parts         = []
  indexed_count = 0

  for item in results:
    title = item.get("title", "Untitled")
    url   = item.get("url",   "")
    raw   = (item.get("raw_content") or item.get("content", "")).strip()
    display = raw[:1500]

    if not display:
      continue

    if url and len(raw) > 100:
      try:
          vs_module.chunk_and_index(raw, source_url=url)
          indexed_count += 1
      except Exception as e:
          logger.warning(f"Indexing failed for {url}: {e}")

    parts.append(f"TITLE: {title}\nURL:   {url}\n\n{display}")

  logger.info(f"{len(parts)} source(s) fetched | {indexed_count} indexed into FAISS")

  separator   = "\n\n" + ("─" * 60) + "\n\n"
  web_results = separator.join(parts) if parts else ""

  return {
      "web_results":   web_results,
      "source_type":   "web",
      "sources_count": len(parts),
  }


# Pipeline orchestrator

def run_pipeline_phase1(query: str, history: list = None) -> dict:
  """
  Call retrieve_node and (optionally) web_node in sequence.

  Direct node calls (not app.invoke()) let app.py intercept between
  retrieval and generation — necessary to show a 'Generating...'
  status update to the user before the (now much faster, but still
  non-zero) GPU generation call runs.
  """
  from ui_helpers import build_retrieval_query

  if history is None:
      history = []

  retrieval_query = build_retrieval_query(query, history)
  if retrieval_query != query:
      logger.debug(f"Follow-up enriched: {retrieval_query!r}")

  state = {
      "query":          retrieval_query,
      "retrieved_docs": "",
      "web_results":    "",
      "answer":         "",
      "needs_web":      False,
      "confidence":     0.0,
      "source_type":    "none",
      "sources_count":  0,
      "prompt_tokens":  0,
      "t_retrieve":     0.0,
      "t_web":          0.0,
      "history":        history,
  }

  t0 = time.perf_counter()
  state.update(retrieve_node(state))
  state["t_retrieve"] = time.perf_counter() - t0

  if state.get("needs_web", False):
      t1 = time.perf_counter()
      state.update(web_node(state))
      state["t_web"] = time.perf_counter() - t1

  # Restore original query — the model answers what the user typed,
  # not the internally enriched search string
  state["query"] = query

  return state