File size: 11,030 Bytes
cf36534
 
 
 
17d5d2c
cf36534
def0a7d
cf36534
17d5d2c
2afbb75
266b0b6
17d5d2c
cf36534
17d5d2c
266b0b6
17d5d2c
266b0b6
17d5d2c
 
 
266b0b6
17d5d2c
cf36534
17d5d2c
cf36534
17d5d2c
cf36534
 
 
 
17d5d2c
cf36534
17d5d2c
cf36534
17d5d2c
cf36534
17d5d2c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7475f6d
17d5d2c
7475f6d
 
 
 
 
 
 
a88cb35
7475f6d
cf36534
17d5d2c
 
 
 
 
 
a88cb35
 
17d5d2c
 
 
 
 
 
 
 
 
 
 
a88cb35
 
17d5d2c
 
 
a88cb35
17d5d2c
 
 
cf36534
17d5d2c
cf36534
 
17d5d2c
266b0b6
afbaeea
17d5d2c
 
 
 
 
 
 
 
 
 
 
 
afbaeea
17d5d2c
266b0b6
17d5d2c
afbaeea
266b0b6
17d5d2c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afbaeea
266b0b6
cf36534
 
 
 
17d5d2c
cf36534
 
 
 
 
 
 
a88cb35
cf36534
 
 
17d5d2c
cf36534
 
 
17d5d2c
 
 
afbaeea
17d5d2c
 
cf36534
 
17d5d2c
 
 
cf36534
17d5d2c
cf36534
a88cb35
cf36534
17d5d2c
cf36534
17d5d2c
266b0b6
17d5d2c
 
 
266b0b6
17d5d2c
266b0b6
17d5d2c
 
 
 
 
 
 
 
 
266b0b6
17d5d2c
266b0b6
 
 
17d5d2c
 
 
 
 
 
 
 
 
7475f6d
17d5d2c
 
 
 
 
7475f6d
17d5d2c
 
 
7475f6d
17d5d2c
7475f6d
17d5d2c
 
 
 
 
 
 
 
 
 
 
 
7475f6d
17d5d2c
7475f6d
a88cb35
7475f6d
17d5d2c
 
 
 
 
 
 
 
 
 
 
 
 
def0a7d
17d5d2c
266b0b6
17d5d2c
266b0b6
17d5d2c
cf36534
 
17d5d2c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afbaeea
17d5d2c
 
 
 
 
 
 
 
 
 
7475f6d
cf36534
17d5d2c
cf36534
17d5d2c
cf36534
 
 
17d5d2c
266b0b6
17d5d2c
cf36534
17d5d2c
cf36534
 
 
 
 
 
 
 
17d5d2c
cf36534
17d5d2c
 
cf36534
 
a88cb35
17d5d2c
cf36534
 
 
 
17d5d2c
 
cf36534
 
17d5d2c
cf36534
17d5d2c
7475f6d
cf36534
17d5d2c
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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
from fastapi import FastAPI
from pydantic import BaseModel
import requests
import trafilatura
from ddgs import DDGS
import uvicorn
import re

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

# --------------------------------------------------
# APP
# --------------------------------------------------

app = FastAPI(title="Llama AI Deep Research Backend")

# --------------------------------------------------
# EMBEDDING MODEL
# --------------------------------------------------

embedding_model = SentenceTransformer("all-MiniLM-L6-v2")

# --------------------------------------------------
# REQUEST MODEL
# --------------------------------------------------

class ResearchRequest(BaseModel):
    query: str

# --------------------------------------------------
# SETTINGS
# --------------------------------------------------

MAX_RESULTS = 5
REQUEST_TIMEOUT = 10
CHUNK_SIZE = 700
CHUNK_OVERLAP = 120
TOP_RELEVANT_CHUNKS = 4

# FIX: Reduced from 2500 β†’ 600 chars per source.
# Mobile context is 512 tokens (~2000 chars total).
# With 3–5 sources at 600 chars each we would already overflow.
# 600 chars per source gives enough signal without flooding the client.
MAX_SOURCE_CONTENT_CHARS = 600

# FIX: Reduced from 5000 β†’ 900 chars for the global summary.
# 900 chars β‰ˆ 225 tokens β€” fits comfortably in the mobile prompt budget
# while still conveying the key findings across all sources.
MAX_FINAL_SUMMARY_CHARS = 900

# --------------------------------------------------
# BLOCKED DOMAINS
# --------------------------------------------------

BLOCKED_DOMAINS = [
    "sourceforge.net",
    "topbusinesssoftware.com",
    "g2.com",
    "capterra.com",
    "softwareadvice.com",
    "slashdot.org",
]

# --------------------------------------------------
# JUNK SENTENCE FILTER
# FIX: The semantic ranker scores promotional sentences highly when they
# contain query words (e.g. "AI", "latest").  Filter them out before
# ranking so they never appear in the summary or compressed content.
# --------------------------------------------------

JUNK_PHRASES = [
    "newsletter", "subscribe", "sign up", "sign-up",
    "delivered to your inbox", "your inbox", "our inbox",
    "trending now", "stay ahead", "chatai", "what is chatai",
    "advertisement", "sponsored", "promoted",
    "click here", "read more", "learn more",
    "follow us", "more headlines", "by relevance", "by date",
    "top stories", "breaking news",
    "terms of service", "privacy policy", "cookie",
    "all rights reserved", "copyright",
    "please enable javascript", "javascript is required",
    "log in", "sign in", "register now",
]

def is_junk_sentence(sentence: str) -> bool:
    lower = sentence.lower()
    return any(phrase in lower for phrase in JUNK_PHRASES)

# --------------------------------------------------
# CLEAN TEXT
# --------------------------------------------------

def clean_text(text):
    if not text:
        return ""
    text = re.sub(r'\s+', ' ', text)
    return text.strip()

# --------------------------------------------------
# SENTENCE SPLITTER
# Now filters junk sentences at the source.
# --------------------------------------------------

def split_sentences(text):
    sentences = re.split(r'(?<=[.!?])\s+', text)
    return [
        s.strip()
        for s in sentences
        if len(s.strip()) > 40 and not is_junk_sentence(s)
    ]

# --------------------------------------------------
# DOMAIN FILTER
# --------------------------------------------------

def is_blocked_domain(url):
    for domain in BLOCKED_DOMAINS:
        if domain in url:
            return True
    return False

# --------------------------------------------------
# CHUNKING
# --------------------------------------------------

def chunk_text(text, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP):
    words = text.split()
    chunks = []
    start = 0
    while start < len(words):
        end = start + chunk_size
        chunk = words[start:end]
        chunks.append(" ".join(chunk))
        start += chunk_size - overlap
    return chunks

# --------------------------------------------------
# EXTRACT CONTENT
# --------------------------------------------------

def extract_content(url):
    try:
        response = requests.get(
            url,
            timeout=REQUEST_TIMEOUT,
            headers={"User-Agent": "Mozilla/5.0"},
        )
        if response.status_code != 200:
            return ""
        extracted = trafilatura.extract(
            response.text,
            include_links=False,
            include_images=False,
            favor_precision=True,
        )
        if not extracted:
            return ""
        return clean_text(extracted)
    except Exception:
        return ""

# --------------------------------------------------
# SEMANTIC RANKING
# --------------------------------------------------

def rank_chunks(query, chunks):
    if not chunks:
        return []
    try:
        query_embedding = embedding_model.encode([query])
        chunk_embeddings = embedding_model.encode(chunks)
        similarities = cosine_similarity(query_embedding, chunk_embeddings)[0]
        ranked = sorted(
            zip(chunks, similarities),
            key=lambda x: x[1],
            reverse=True,
        )
        return [chunk for chunk, score in ranked[:TOP_RELEVANT_CHUNKS]]
    except Exception:
        return chunks[:TOP_RELEVANT_CHUNKS]

# --------------------------------------------------
# SOURCE COMPRESSION
# --------------------------------------------------

def compress_chunks(query, chunks):
    try:
        all_sentences = []
        for chunk in chunks:
            sentences = split_sentences(chunk)   # junk already filtered here
            all_sentences.extend(sentences)
        if not all_sentences:
            return ""
        query_embedding = embedding_model.encode([query])
        sentence_embeddings = embedding_model.encode(all_sentences)
        similarities = cosine_similarity(query_embedding, sentence_embeddings)[0]
        ranked = sorted(
            zip(all_sentences, similarities),
            key=lambda x: x[1],
            reverse=True,
        )
        final_sentences = []
        used = set()
        total_chars = 0
        for sentence, score in ranked:
            normalized = sentence.lower().strip()
            if normalized in used:
                continue
            used.add(normalized)
            if total_chars + len(sentence) > MAX_SOURCE_CONTENT_CHARS:
                break
            final_sentences.append(sentence)
            total_chars += len(sentence)
        return " ".join(final_sentences)   # FIX: single paragraph, not \n\n β€” saves chars
    except Exception:
        return ""

# --------------------------------------------------
# GLOBAL SYNTHESIS
# --------------------------------------------------

def synthesize_research(query, results):
    try:
        all_sentences = []
        for result in results:
            content = result.get("content", "")
            if not content:
                continue
            sentences = split_sentences(content)   # junk already filtered
            all_sentences.extend(sentences)
        if not all_sentences:
            return ""
        query_embedding = embedding_model.encode([query])
        sentence_embeddings = embedding_model.encode(all_sentences)
        similarities = cosine_similarity(query_embedding, sentence_embeddings)[0]
        ranked = sorted(
            zip(all_sentences, similarities),
            key=lambda x: x[1],
            reverse=True,
        )
        final_sentences = []
        used = set()
        total_chars = 0
        for sentence, score in ranked:
            normalized = sentence.lower().strip()
            if normalized in used:
                continue
            used.add(normalized)
            if total_chars + len(sentence) > MAX_FINAL_SUMMARY_CHARS:
                break
            final_sentences.append(sentence)
            total_chars += len(sentence)
        return " ".join(final_sentences)   # FIX: single paragraph, not \n\n
    except Exception:
        return ""

# --------------------------------------------------
# RESEARCH PIPELINE
# --------------------------------------------------

def perform_research(query):
    source_results = []
    try:
        with DDGS() as ddgs:
            search_results = list(ddgs.text(query, max_results=MAX_RESULTS))
        if not search_results:
            return None
        for item in search_results:
            try:
                title = item.get("title", "")
                url = item.get("href", "")
                snippet = item.get("body", "")
                if not url:
                    continue
                if is_blocked_domain(url):
                    continue
                content = extract_content(url)
                if not content:
                    content = snippet
                if not content:
                    continue
                chunks = chunk_text(content)
                ranked_chunks = rank_chunks(query, chunks)
                compressed_content = compress_chunks(query, ranked_chunks)
                if not compressed_content:
                    continue
                source_results.append({
                    "title": title,
                    "url": url,
                    "snippet": snippet,
                    "content": compressed_content,   # semantically ranked extraction
                })
            except Exception:
                continue
        if not source_results:
            return None
        research_summary = synthesize_research(query, source_results)
        return {
            "sources": source_results,
            "research_summary": research_summary,
        }
    except Exception:
        return None

# --------------------------------------------------
# ROOT
# --------------------------------------------------

@app.get("/")
async def root():
    return {"status": "running"}

# --------------------------------------------------
# DEEP RESEARCH ENDPOINT
# --------------------------------------------------

@app.post("/deep-research")
async def deep_research(req: ResearchRequest):
    query = req.query.strip()
    if not query:
        return {
            "success": False,
            "message": "Query is empty.",
            "results": [],
        }
    research_data = perform_research(query)
    if not research_data:
        return {
            "success": False,
            "message": "No useful research results found.",
            "results": [],
        }
    return {
        "success": True,
        "query": query,
        "research_summary": research_data["research_summary"],
        "results": research_data["sources"],
    }

# --------------------------------------------------
# START
# --------------------------------------------------

if __name__ == "__main__":
    uvicorn.run("app:app", host="0.0.0.0", port=7860)