Knowledge_forge / app.py
nisaralll's picture
name
27cb11f
Raw History Blame Contribute Delete
15.1 kB
import os
import re
import json
import uuid
import io
import base64
import numpy as np
import google.generativeai as genai
import faiss
# IMPORTANT: Set the matplotlib backend BEFORE importing pyplot
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import seaborn as sns
import requests
from flask import Flask, render_template, request, jsonify
from flask_cors import CORS
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from youtube_transcript_api import YouTubeTranscriptApi
from bs4 import BeautifulSoup
# --- App Initialization & Configuration (Unchanged) ---
app = Flask(__name__, template_folder='templates', static_folder='static')
CORS(app)
# Load API Key from environment variable. For local dev, you can replace the value.
# IMPORTANT: Remove hardcoded key before pushing to a public repository.
GEMINI_API_KEY =os.environ.get("GEMINI_API_KEY")
genai.configure(api_key=GEMINI_API_KEY)
embedder = SentenceTransformer('all-MiniLM-L6-v2')
MODEL_DIMENSION = 384
KNOWLEDGE_BASE_FILE = 'knowledge_base.json'
INDEX_FILE = 'faiss_index.bin'
# --- KnowledgeBase Class (Unchanged) ---
class KnowledgeBase:
def __init__(self):
self.dimension = MODEL_DIMENSION
self.knowledge_base = []
self.index = faiss.IndexFlatL2(self.dimension)
self.load()
def load(self):
try:
if os.path.exists(KNOWLEDGE_BASE_FILE) and os.path.exists(INDEX_FILE):
with open(KNOWLEDGE_BASE_FILE, 'r', encoding='utf-8') as f:
self.knowledge_base = json.load(f)
self.index = faiss.read_index(INDEX_FILE)
except Exception as e:
print(f"Error loading knowledge base: {e}.")
self.clear()
def save(self):
try:
with open(KNOWLEDGE_BASE_FILE, 'w', encoding='utf-8') as f:
json.dump(self.knowledge_base, f, indent=2)
if self.index.ntotal > 0:
faiss.write_index(self.index, INDEX_FILE)
except Exception as e:
print(f"Error saving knowledge base: {e}")
def clear(self):
self.knowledge_base = []
self.index = faiss.IndexFlatL2(self.dimension)
if os.path.exists(KNOWLEDGE_BASE_FILE): os.remove(KNOWLEDGE_BASE_FILE)
if os.path.exists(INDEX_FILE): os.remove(INDEX_FILE)
print("Knowledge base cleared.")
kb = KnowledgeBase()
# --- Helper Functions (Unchanged) ---
def crawl_url(url: str) -> str:
try:
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
for element in soup(["script", "style", "nav", "footer", "header"]):
element.decompose()
return " ".join(soup.stripped_strings)
except Exception as e:
print(f"Error crawling {url}: {e}")
return None
def get_youtube_transcript(video_url: str) -> str:
try:
video_id_match = re.search(r'(?<=v=)[^&]+|(?<=be/)[^?]+', video_url)
video_id = video_id_match.group(0) if video_id_match else None
if not video_id: return None
transcript_list = YouTubeTranscriptApi.get_transcript(video_id)
return " ".join([d['text'] for d in transcript_list])
except Exception as e:
print(f"Error fetching YouTube transcript: {e}")
return None
def chunk_text(text: str, max_length: int = 400) -> list:
words = re.split(r'\s+', text)
return [" ".join(words[i:i+max_length]) for i in range(0, len(words), max_length)]
def update_knowledge_base(text: str, source: str, source_type: str):
if not text: return False
chunks = chunk_text(text)
if not chunks: return False
embeddings = embedder.encode(chunks, convert_to_numpy=True)
for chunk in chunks:
kb.knowledge_base.append({"id": str(uuid.uuid4()), "text": chunk, "source": source, "type": source_type})
kb.index.add(embeddings)
kb.save()
return True
def retrieve_chunks(query: str, source: str = None, k: int = 5) -> list:
if kb.index.ntotal == 0: return []
query_embedding = embedder.encode([query], convert_to_numpy=True)
indices_to_search = list(range(kb.index.ntotal))
if source:
indices_to_search = [i for i, item in enumerate(kb.knowledge_base) if item["source"] == source]
if not indices_to_search: return []
search_index = kb.index
if source and len(indices_to_search) > 0:
source_embeddings = np.array([kb.index.reconstruct(i) for i in indices_to_search]).astype('float32')
search_index = faiss.IndexFlatL2(kb.dimension)
search_index.add(source_embeddings)
distances, indices = search_index.search(query_embedding.astype('float32'), min(k, search_index.ntotal))
original_indices = [indices_to_search[i] for i in indices[0]] if source else indices[0]
return [kb.knowledge_base[i]["text"] for i in original_indices]
def clean_llm_output(raw_text: str, output_type: str) -> str:
if output_type == 'mermaid':
match = re.search(r'```mermaid(.*?)```', raw_text, re.DOTALL)
return match.group(1).strip() if match else raw_text.strip()
if output_type == 'json':
match = re.search(r'```json(.*?)```', raw_text, re.DOTALL)
if match: return match.group(1).strip()
match = re.search(r'(\[.*\]|\{.*\})', raw_text, re.DOTALL)
return match.group(0) if match else "{}"
return raw_text
# --- NEW: Mermaid Syntax Sanitizer ---
def validate_and_sanitize_mermaid(code: str) -> str:
"""
Automatically quotes node text containing special characters to prevent Mermaid syntax errors.
"""
sanitized_lines = []
# This regex finds a node ID and the text within its brackets, e.g., E[text here]
node_pattern = re.compile(r'(\w+)(\[.+?\]|\(.+?\)|{.+?})')
for line in code.split('\n'):
match = node_pattern.search(line)
if match:
node_id_part = line[:match.start(2)]
node_content = match.group(2)
node_text = node_content[1:-1] # Get text inside brackets
# If text has special characters and is not already quoted, quote it
if re.search(r'[()\[\]{}:+]', node_text) and not (node_text.startswith('"') and node_text.endswith('"')):
# Escape existing quotes inside the text before wrapping
escaped_text = node_text.replace('"', '&quot;')
sanitized_line = f'{node_id_part}["{escaped_text}"]'
sanitized_lines.append(sanitized_line)
else:
sanitized_lines.append(line)
else:
sanitized_lines.append(line)
return "\n".join(sanitized_lines)
# --- API Endpoints ---
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/add-content', methods=['POST'])
def add_content():
# ... (endpoint remains the same) ...
data = request.get_json()
source, source_type = data.get('source'), data.get('source_type')
text = ""
if source_type == "youtube": text = get_youtube_transcript(source)
elif source_type == "text": text = source
elif source_type == "web": text = crawl_url(source)
if not text: return jsonify({"success": False, "message": f"Failed to extract content from {source_type}."})
success = update_knowledge_base(text, source, source_type)
return jsonify({"success": success, "message": "Content added successfully." if success else "Failed to process content."})
@app.route('/api/ask-question', methods=['POST'])
def ask_question_route():
# ... (endpoint remains the same) ...
data = request.get_json()
question, source = data.get('question'), data.get('source')
chunks = retrieve_chunks(question, source)
if not chunks: return jsonify({"success": True, "question": question, "answer": "I don't have enough information to answer that. Please add relevant content first."})
context = "\n\n".join(chunks)
prompt = f"Context:\n{context}\n\nQuestion: {question}\n\nAnswer the question based *only* on the provided context. Format the answer clearly using markdown."
model = genai.GenerativeModel('gemini-1.5-flash')
response = model.generate_content(prompt)
return jsonify({"success": True, "question": question, "answer": response.text.strip()})
@app.route('/api/generate-tool-content', methods=['POST'])
def generate_tool_content_route():
data = request.get_json()
tool, topic, source = data.get('tool'), data.get('topic'), data.get('source')
if not topic: return jsonify({"success": False, "message": "A topic is required."})
chunks = retrieve_chunks(topic, source)
if not chunks: return jsonify({"success": False, "message": "Not enough content for this topic."})
context = "\n\n".join(chunks)
# --- FIX: Stronger Prompts for Mermaid ---
mermaid_prompt_injection = """
IMPORTANT RULES:
1. Use simple, single-word node IDs (e.g., A, B, C1, C2).
2. CRITICAL: If any node text contains special characters like (), [], {}, :, +, or others, you MUST enclose the entire text in double quotes. For example: `A["Node with (parentheses)"]`.
"""
prompts = {
"mock_test": f"Based on the context, generate a {5} question multiple-choice mock test on '{topic}'. Provide 4 options (A, B, C, D) per question and list the correct answers in a separate 'Answers' section at the end.\n\nContext:\n{context}",
"mindmap": f"Create a Mermaid.js 'mindmap' syntax about '{topic}' based on the context.\n\n{mermaid_prompt_injection}\n\nContext:\n{context}\n\nProvide only the Mermaid code inside a ```mermaid block.",
"flowchart": f"Create a Mermaid.js 'graph TD' syntax for a flowchart illustrating '{topic}' based on the context.\n\n{mermaid_prompt_injection}\n\nContext:\n{context}\n\nProvide only the Mermaid code inside a ```mermaid block.",
"storyboard": f"Create a 3-panel storyboard about '{topic}'. For each panel, provide a 'scene' description and a relevant 'icon' from Font Awesome (e.g., 'fas fa-lightbulb'). Return ONLY a valid JSON array of objects with 'scene' and 'icon' keys, inside a ```json block.\n\nContext:\n{context}"
}
if tool not in prompts:
return jsonify({"success": False, "message": "Invalid tool specified."})
model = genai.GenerativeModel('gemini-1.5-flash')
response = model.generate_content(prompts[tool])
if tool in ["mindmap", "flowchart"]:
raw_mermaid = clean_llm_output(response.text, 'mermaid')
# --- FIX: Sanitize Mermaid output before sending to frontend ---
sanitized_mermaid = validate_and_sanitize_mermaid(raw_mermaid)
return jsonify({"success": True, "type": tool, "title": f"{tool.capitalize()} for {topic}", "mermaid_code": sanitized_mermaid})
elif tool == "storyboard":
try:
storyboard_data = json.loads(clean_llm_output(response.text, 'json'))
return jsonify({"success": True, "type": "storyboard", "title": f"Storyboard for {topic}", "content": storyboard_data})
except json.JSONDecodeError:
return jsonify({"success": False, "message": "Failed to generate a valid storyboard from the AI."})
else: # mock_test
return jsonify({"success": True, "type": "mock_test", "title": f"Mock Test on {topic}", "content": response.text.strip()})
# ... (All other endpoints like /api/extract-entities, /api/cluster-topics, etc., remain unchanged) ...
@app.route('/api/extract-entities', methods=['POST'])
def extract_entities_route():
data = request.get_json()
source = data.get('source')
text = " ".join(item['text'] for item in kb.knowledge_base if not source or item['source'] == source)
if not text: return jsonify({"success": False, "message": "No content available to analyze."})
prompt = f"From the text, extract 'Person', 'Organization', and 'Location' named entities. Return a JSON object with these keys, each with a list of unique names. If empty, return an empty list.\n\nText:\n{text}\n\nProvide ONLY the valid JSON object inside a ```json block."
model = genai.GenerativeModel('gemini-1.5-flash')
response = model.generate_content(prompt)
try:
entities = json.loads(clean_llm_output(response.text, 'json'))
return jsonify({"success": True, "entities": entities})
except json.JSONDecodeError:
return jsonify({"success": False, "message": "Failed to parse entities from the AI response."})
@app.route('/api/cluster-topics', methods=['GET'])
def cluster_topics_route():
if kb.index.ntotal < 5:
return jsonify({"success": False, "message": "Need at least 5 content chunks to create topics."})
embeddings = np.array([kb.index.reconstruct(i) for i in range(kb.index.ntotal)]).astype('float32')
num_clusters = min(5, kb.index.ntotal // 2)
if num_clusters < 2:
return jsonify({"success": False, "message": "Not enough diverse content to form clusters."})
kmeans = KMeans(n_clusters=num_clusters, random_state=42, n_init=10).fit(embeddings)
labels = kmeans.labels_
cluster_labels = []
for i in range(num_clusters):
sample_texts = " ".join([kb.knowledge_base[int(j)]["text"] for j in np.where(labels == i)[0][:3]])
prompt = f"Based on this text, provide a short, 2-3 word topic label: {sample_texts}"
model = genai.GenerativeModel('gemini-1.5-flash')
response = model.generate_content(prompt)
cluster_labels.append(re.sub(r'["*]', '', response.text.strip()))
pca = PCA(n_components=2)
reduced = pca.fit_transform(embeddings)
plt.style.use('dark_background')
fig, ax = plt.subplots(figsize=(10, 8))
sns.scatterplot(x=reduced[:, 0], y=reduced[:, 1], hue=[cluster_labels[l] for l in labels], palette="viridis", s=120, alpha=0.8, ax=ax, edgecolor='w', linewidth=0.5)
ax.set_title("2D Visualization of Content Topics", fontsize=16, color='white')
ax.set_xlabel("Principal Component 1", color='white')
ax.set_ylabel("Principal Component 2", color='white')
ax.tick_params(colors='white', which='both')
legend = ax.legend(title="Topics", bbox_to_anchor=(1.05, 1), loc='upper left')
plt.setp(legend.get_texts(), color='white')
plt.setp(legend.get_title(), color='white')
fig.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format='png', transparent=True)
plt.close(fig)
img_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
return jsonify({"success": True, "image": f"data:image/png;base64,{img_base64}"})
@app.route('/api/get-sources', methods=['GET'])
def get_sources_route():
return jsonify({"success": True, "sources": sorted(list(set(item['source'] for item in kb.knowledge_base)))})
@app.route('/api/clear-data', methods=['POST'])
def clear_data_route():
kb.clear()
return jsonify({"success": True, "message": "Knowledge base cleared."})
if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000, debug=True)