Spaces:
Runtime error
Runtime error
Delete pipeline
Browse files- pipeline/__pycache__/answer_generator.cpython-310.pyc +0 -0
- pipeline/__pycache__/crawler.cpython-310.pyc +0 -0
- pipeline/__pycache__/embed_and_store.cpython-310.pyc +0 -0
- pipeline/__pycache__/link_ranker.cpython-310.pyc +0 -0
- pipeline/__pycache__/query_handler.cpython-310.pyc +0 -0
- pipeline/__pycache__/search_and_scrape.cpython-310.pyc +0 -0
- pipeline/answer_generator.py +0 -141
- pipeline/crawler.py +0 -58
- pipeline/embed_and_store.py +0 -22
- pipeline/link_ranker.py +0 -32
- pipeline/query_handler.py +0 -8
- pipeline/search_and_scrape.py +0 -58
pipeline/__pycache__/answer_generator.cpython-310.pyc
DELETED
|
Binary file (5.33 kB)
|
|
|
pipeline/__pycache__/crawler.cpython-310.pyc
DELETED
|
Binary file (2.04 kB)
|
|
|
pipeline/__pycache__/embed_and_store.cpython-310.pyc
DELETED
|
Binary file (933 Bytes)
|
|
|
pipeline/__pycache__/link_ranker.cpython-310.pyc
DELETED
|
Binary file (1.44 kB)
|
|
|
pipeline/__pycache__/query_handler.cpython-310.pyc
DELETED
|
Binary file (458 Bytes)
|
|
|
pipeline/__pycache__/search_and_scrape.cpython-310.pyc
DELETED
|
Binary file (1.87 kB)
|
|
|
pipeline/answer_generator.py
DELETED
|
@@ -1,141 +0,0 @@
|
|
| 1 |
-
|
| 2 |
-
import streamlit as st
|
| 3 |
-
import google.generativeai as genai
|
| 4 |
-
from collections import defaultdict
|
| 5 |
-
import math
|
| 6 |
-
|
| 7 |
-
def score_documents_with_gemini(docs, user_query):
|
| 8 |
-
model = genai.GenerativeModel("gemini-2.0-pro-exp-02-05")
|
| 9 |
-
scored_docs = []
|
| 10 |
-
|
| 11 |
-
for i, doc in enumerate(docs):
|
| 12 |
-
url = doc.metadata.get("url", "No URL")
|
| 13 |
-
snippet = doc.page_content[:500]
|
| 14 |
-
|
| 15 |
-
prompt = f"""
|
| 16 |
-
You are a relevance evaluator assistant.
|
| 17 |
-
|
| 18 |
-
Given the user's query and a document snippet, score how relevant this document is (1 to 5 scale):
|
| 19 |
-
|
| 20 |
-
User Query:
|
| 21 |
-
\"\"\"{user_query}\"\"\"
|
| 22 |
-
|
| 23 |
-
Document Snippet:
|
| 24 |
-
\"\"\"{snippet}\"\"\"
|
| 25 |
-
|
| 26 |
-
Return a single number (1-5):
|
| 27 |
-
"""
|
| 28 |
-
try:
|
| 29 |
-
response = model.generate_content(prompt)
|
| 30 |
-
score = int("".join(filter(str.isdigit, response.text.strip())))
|
| 31 |
-
except:
|
| 32 |
-
score = 3 # fallback
|
| 33 |
-
|
| 34 |
-
scored_docs.append({
|
| 35 |
-
"doc": doc,
|
| 36 |
-
"url": url,
|
| 37 |
-
"score": score
|
| 38 |
-
})
|
| 39 |
-
|
| 40 |
-
scored_docs.sort(key=lambda x: x["score"], reverse=True)
|
| 41 |
-
return scored_docs
|
| 42 |
-
|
| 43 |
-
def get_diverse_documents(scored_docs, min_required):
|
| 44 |
-
by_url = defaultdict(list)
|
| 45 |
-
for entry in scored_docs:
|
| 46 |
-
by_url[entry["url"]].append(entry)
|
| 47 |
-
|
| 48 |
-
diverse_docs = []
|
| 49 |
-
for entries in by_url.values():
|
| 50 |
-
if len(diverse_docs) >= min_required:
|
| 51 |
-
break
|
| 52 |
-
diverse_docs.append(entries[0]) # take top scoring for each unique URL
|
| 53 |
-
|
| 54 |
-
return diverse_docs
|
| 55 |
-
|
| 56 |
-
def generate_answer(user_query, retriever, scraped_results):
|
| 57 |
-
raw_docs = retriever.get_relevant_documents(user_query)
|
| 58 |
-
scored_docs = score_documents_with_gemini(raw_docs, user_query)
|
| 59 |
-
|
| 60 |
-
min_required = max(1, int(0.7 * len(scraped_results)))
|
| 61 |
-
selected_docs = get_diverse_documents(scored_docs, min_required=math.ceil(0.7 * len(scraped_results)))
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
context = ""
|
| 65 |
-
unique_sources = {}
|
| 66 |
-
for i, entry in enumerate(selected_docs):
|
| 67 |
-
doc = entry["doc"]
|
| 68 |
-
url = doc.metadata.get("url", "No URL Provided")
|
| 69 |
-
if url not in unique_sources:
|
| 70 |
-
unique_sources[url] = f"Source {len(unique_sources) + 1}"
|
| 71 |
-
label = unique_sources[url]
|
| 72 |
-
context += f"[{label}] ({url}):\n{doc.page_content}\n\n"
|
| 73 |
-
|
| 74 |
-
prompt = f"""
|
| 75 |
-
You are a senior research analyst generating professional and comprehensive reports based on provided source documents.
|
| 76 |
-
|
| 77 |
-
Please create a well-structured response to the user query using the information below. Your goal is to present both qualitative and quantitative data (including tables and pricing breakdowns) in clean, readable Markdown.
|
| 78 |
-
|
| 79 |
-
### Instructions:
|
| 80 |
-
- Use markdown headings for sections.
|
| 81 |
-
- Format numbers or pricing data in tables if applicable.
|
| 82 |
-
- Cite facts using inline citations like [Source 1].
|
| 83 |
-
- Use bullet points where useful.
|
| 84 |
-
- If structured data (e.g., features, specs, pricing, tables) is mentioned in sources, render it **as a table** with proper headers.
|
| 85 |
-
- You MUST incorporate information from **at least 3 different sources** in your response.
|
| 86 |
-
- If some sources contain overlapping content, reference each one explicitly.
|
| 87 |
-
- Avoid relying only on a single source unless it's the only one that contains all details.
|
| 88 |
-
- Use inline citations like [Source 1], [Source 2], etc. after each fact or quote.
|
| 89 |
-
- If you cannot extract anything unique from a source, say so in the "Source Coverage" section at the bottom.
|
| 90 |
-
|
| 91 |
-
### Output Format:
|
| 92 |
-
- Clean, structured Markdown
|
| 93 |
-
- Use headers like `## Overview`, `## Key Pricing Details`, `## Comparison`, etc.
|
| 94 |
-
- Always include citations and distinguish when info comes from different sources.
|
| 95 |
-
|
| 96 |
-
## Source Coverage
|
| 97 |
-
- Source 1: Used in Model Comparison and Free Usage info
|
| 98 |
-
- Source 2: Confirmed pricing differences
|
| 99 |
-
- Source 3: Mentioned rate limits and billing tags
|
| 100 |
-
- Source 4: Not used due to repetition with Source 1
|
| 101 |
-
|
| 102 |
-
---
|
| 103 |
-
|
| 104 |
-
### SOURCE DOCUMENTS:
|
| 105 |
-
{context}
|
| 106 |
-
|
| 107 |
-
---
|
| 108 |
-
|
| 109 |
-
### USER QUERY:
|
| 110 |
-
\"\"\"{user_query}\"\"\"
|
| 111 |
-
|
| 112 |
-
Please begin.
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
"""
|
| 116 |
-
|
| 117 |
-
model = genai.GenerativeModel("gemini-2.0-pro-exp-02-05")
|
| 118 |
-
response = model.generate_content(prompt)
|
| 119 |
-
answer = response.text.strip()
|
| 120 |
-
|
| 121 |
-
st.markdown("### π§ Answer")
|
| 122 |
-
if any(r.get("page", 1) > 1 for r in scraped_results):
|
| 123 |
-
st.markdown("<span style='color:orange; font-weight:bold;'>β οΈ Includes content from deeper Google search pages</span>", unsafe_allow_html=True)
|
| 124 |
-
st.markdown(answer, unsafe_allow_html=True)
|
| 125 |
-
|
| 126 |
-
# Relevance Table UI
|
| 127 |
-
with st.expander("π Document Relevance Ranking"):
|
| 128 |
-
for entry in scored_docs:
|
| 129 |
-
url = entry["url"]
|
| 130 |
-
score = entry["score"]
|
| 131 |
-
label = unique_sources.get(url, "-")
|
| 132 |
-
rating = "High" if score >= 4 else "Medium" if score == 3 else "Low"
|
| 133 |
-
st.markdown(f"<small>πΉ **{label}** | {rating} Relevance | Score: {score}/5 β <a href='{url}' target='_blank'>{url}</a></small>", unsafe_allow_html=True)
|
| 134 |
-
|
| 135 |
-
# Final Source List
|
| 136 |
-
with st.expander("π Source Citations"):
|
| 137 |
-
if unique_sources:
|
| 138 |
-
for url, label in unique_sources.items():
|
| 139 |
-
st.markdown(f"<small>πΉ **{label}**: <a href='{url}' target='_blank'>{url}</a></small>", unsafe_allow_html=True)
|
| 140 |
-
else:
|
| 141 |
-
st.markdown("<small>No sources available.</small>", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipeline/crawler.py
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
# pipeline/crawler.py
|
| 2 |
-
|
| 3 |
-
import requests
|
| 4 |
-
from bs4 import BeautifulSoup
|
| 5 |
-
from urllib.parse import urljoin, urlparse
|
| 6 |
-
from typing import List, Dict, Tuple, Set
|
| 7 |
-
from pipeline.link_ranker import rank_links_by_query
|
| 8 |
-
|
| 9 |
-
def extract_internal_links(soup: BeautifulSoup, base_url: str) -> List[str]:
|
| 10 |
-
internal_links = []
|
| 11 |
-
base_domain = urlparse(base_url).netloc
|
| 12 |
-
|
| 13 |
-
for a in soup.find_all("a", href=True):
|
| 14 |
-
href = a["href"]
|
| 15 |
-
full_url = urljoin(base_url, href)
|
| 16 |
-
parsed = urlparse(full_url)
|
| 17 |
-
|
| 18 |
-
if parsed.netloc == base_domain:
|
| 19 |
-
internal_links.append(full_url)
|
| 20 |
-
|
| 21 |
-
return list(set(internal_links))
|
| 22 |
-
|
| 23 |
-
def crawl_site(start_url: str, user_query: str, max_depth: int = 2, max_links: int = 5) -> List[Dict]:
|
| 24 |
-
visited: Set[str] = set()
|
| 25 |
-
queue: List[Tuple[str, int]] = [(start_url, 0)]
|
| 26 |
-
crawled_pages = []
|
| 27 |
-
|
| 28 |
-
while queue and len(crawled_pages) < max_links:
|
| 29 |
-
url, depth = queue.pop(0)
|
| 30 |
-
if url in visited or depth > max_depth:
|
| 31 |
-
continue
|
| 32 |
-
visited.add(url)
|
| 33 |
-
|
| 34 |
-
try:
|
| 35 |
-
response = requests.get(url, timeout=10)
|
| 36 |
-
response.raise_for_status()
|
| 37 |
-
html = response.text
|
| 38 |
-
soup = BeautifulSoup(html, "html.parser")
|
| 39 |
-
content = "\n".join(p.get_text() for p in soup.find_all("p") if p.get_text())
|
| 40 |
-
|
| 41 |
-
crawled_pages.append({
|
| 42 |
-
"url": url,
|
| 43 |
-
"depth": depth,
|
| 44 |
-
"content": content.strip()
|
| 45 |
-
})
|
| 46 |
-
|
| 47 |
-
if len(crawled_pages) >= max_links:
|
| 48 |
-
break
|
| 49 |
-
|
| 50 |
-
internal_links = extract_internal_links(soup, url)
|
| 51 |
-
ranked_links = rank_links_by_query(internal_links, user_query, top_k=5)
|
| 52 |
-
queue.extend([(link, depth + 1) for link in ranked_links if link not in visited])
|
| 53 |
-
|
| 54 |
-
except Exception as e:
|
| 55 |
-
print(f"[Crawler] Failed to crawl {url}: {e}")
|
| 56 |
-
continue
|
| 57 |
-
|
| 58 |
-
return crawled_pages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipeline/embed_and_store.py
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
|
| 2 |
-
from langchain.schema import Document
|
| 3 |
-
from langchain.vectorstores import Chroma
|
| 4 |
-
|
| 5 |
-
def embed_and_store_chunks(all_chunks, embedding_model, persist_dir="chroma_store"):
|
| 6 |
-
embedded_chunks = []
|
| 7 |
-
for chunk in all_chunks:
|
| 8 |
-
vector = embedding_model.embed_query(chunk["content"])
|
| 9 |
-
embedded_chunks.append({
|
| 10 |
-
"embedding": vector,
|
| 11 |
-
"content": chunk["content"],
|
| 12 |
-
"metadata": chunk["metadata"]
|
| 13 |
-
})
|
| 14 |
-
|
| 15 |
-
documents = [Document(page_content=chunk["content"], metadata=chunk["metadata"]) for chunk in embedded_chunks]
|
| 16 |
-
chroma_store = Chroma.from_documents(
|
| 17 |
-
documents=documents,
|
| 18 |
-
embedding=embedding_model,
|
| 19 |
-
persist_directory=persist_dir
|
| 20 |
-
)
|
| 21 |
-
chroma_store.persist()
|
| 22 |
-
return chroma_store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipeline/link_ranker.py
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
# pipeline/link_ranker.py
|
| 2 |
-
|
| 3 |
-
from typing import List
|
| 4 |
-
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 5 |
-
from sklearn.metrics.pairwise import cosine_similarity
|
| 6 |
-
|
| 7 |
-
def rank_links_by_query(links: List[str], query: str, top_k: int = 5) -> List[str]:
|
| 8 |
-
"""
|
| 9 |
-
Rank internal links based on relevance to the query using TF-IDF cosine similarity.
|
| 10 |
-
|
| 11 |
-
Args:
|
| 12 |
-
links (List[str]): Anchor texts or hrefs to rank.
|
| 13 |
-
query (str): User's question or topic.
|
| 14 |
-
top_k (int): Number of top links to return.
|
| 15 |
-
|
| 16 |
-
Returns:
|
| 17 |
-
List[str]: Ranked list of links most relevant to the query.
|
| 18 |
-
"""
|
| 19 |
-
if not links:
|
| 20 |
-
return []
|
| 21 |
-
|
| 22 |
-
try:
|
| 23 |
-
vectorizer = TfidfVectorizer().fit([query] + links)
|
| 24 |
-
vectors = vectorizer.transform([query] + links)
|
| 25 |
-
query_vec = vectors[0]
|
| 26 |
-
link_vecs = vectors[1:]
|
| 27 |
-
scores = cosine_similarity(query_vec, link_vecs).flatten()
|
| 28 |
-
ranked_links = [link for _, link in sorted(zip(scores, links), reverse=True)]
|
| 29 |
-
return ranked_links[:top_k]
|
| 30 |
-
except Exception as e:
|
| 31 |
-
print(f"[LinkRanker] Failed to rank links: {e}")
|
| 32 |
-
return links[:top_k]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipeline/query_handler.py
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
|
| 2 |
-
from agent.query_analyzer import QueryAnalyzer
|
| 3 |
-
|
| 4 |
-
def analyze_query(user_query: str):
|
| 5 |
-
analyzer = QueryAnalyzer()
|
| 6 |
-
metadata = analyzer.analyze_query(user_query)
|
| 7 |
-
keyword_chunks = metadata.get("KeywordChunks", [[user_query]])
|
| 8 |
-
return keyword_chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipeline/search_and_scrape.py
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
|
| 2 |
-
# pipeline/search_and_scrape.py
|
| 3 |
-
|
| 4 |
-
import streamlit as st
|
| 5 |
-
from urllib.parse import urlparse
|
| 6 |
-
from pipeline.crawler import crawl_site
|
| 7 |
-
|
| 8 |
-
def search_and_scrape(keyword_chunks, search_tool, scraper, chunker, user_query, max_links=4, max_pages=3, max_crawl_depth=2, max_crawl_pages=3):
|
| 9 |
-
st.sidebar.markdown("### π Scraped Sources")
|
| 10 |
-
scraped_results = []
|
| 11 |
-
all_chunks = []
|
| 12 |
-
|
| 13 |
-
for chunk in keyword_chunks:
|
| 14 |
-
if len(scraped_results) >= max_links:
|
| 15 |
-
break
|
| 16 |
-
|
| 17 |
-
query = " ".join(chunk)
|
| 18 |
-
chunk_results = search_tool.search(query, num_results=10, max_pages=max_pages)
|
| 19 |
-
|
| 20 |
-
for result in chunk_results:
|
| 21 |
-
if len(scraped_results) >= max_links:
|
| 22 |
-
break
|
| 23 |
-
|
| 24 |
-
# Check if it's a homepage-style URL
|
| 25 |
-
parsed = urlparse(result["link"])
|
| 26 |
-
if parsed.path in ["", "/", "/index.html", "/home"]:
|
| 27 |
-
try:
|
| 28 |
-
crawled_pages = crawl_site(result["link"], user_query, max_depth=max_crawl_depth, max_links=max_crawl_pages)
|
| 29 |
-
for page in crawled_pages:
|
| 30 |
-
if len(scraped_results) >= max_links:
|
| 31 |
-
break
|
| 32 |
-
doc_chunks = chunker.chunk_text(page["content"], {"url": page["url"], "title": f"Crawled from {result['link']}"})
|
| 33 |
-
all_chunks.extend(doc_chunks)
|
| 34 |
-
scraped_results.append({"title": f"Crawled from {result['link']}", "link": page["url"], "page": result.get("page", 1)})
|
| 35 |
-
st.sidebar.markdown(f"<span style='color:green'>π Crawled</span> πΉ <a href='{page['url']}' target='_blank'>{page['url']}</a>", unsafe_allow_html=True)
|
| 36 |
-
except Exception as e:
|
| 37 |
-
st.sidebar.warning(f"Failed to crawl {result['link']} β {e}")
|
| 38 |
-
continue
|
| 39 |
-
|
| 40 |
-
# Standard page scraping
|
| 41 |
-
scraped = scraper.scrape(result["link"])
|
| 42 |
-
if scraped["content"]:
|
| 43 |
-
doc_chunks = chunker.chunk_text(scraped["content"], {
|
| 44 |
-
"url": result["link"],
|
| 45 |
-
"title": result["title"]
|
| 46 |
-
})
|
| 47 |
-
all_chunks.extend(doc_chunks)
|
| 48 |
-
scraped_results.append(result)
|
| 49 |
-
|
| 50 |
-
page_info = result.get("page", 1)
|
| 51 |
-
if page_info > 1:
|
| 52 |
-
st.sidebar.markdown(
|
| 53 |
-
f"<span style='color:orange'>π Page {page_info}</span> πΉ <a href='{result['link']}' target='_blank'>{result['title']}</a>",
|
| 54 |
-
unsafe_allow_html=True)
|
| 55 |
-
else:
|
| 56 |
-
st.sidebar.markdown(f"πΉ [Page {page_info}] [{result['title']}]({result['link']})")
|
| 57 |
-
|
| 58 |
-
return scraped_results, all_chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|